Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/bugabinga/pivi/extension/index.ts

Raw
/**
 * pivi companion extension.
 *
 * Loaded additively when pivi starts Pi, alongside the user's normal Pi configuration.
 * It makes loaded Neovim buffers authoritative for built-in file tools by injecting
 * operations, so result and detail shapes stay exactly those of the built-in tools.
 * It also exposes the live editor: core state and unrestricted Lua execution.
 */

import {
	createEditTool,
	createReadTool,
	createWriteTool,
	type EditOperations,
	type ExtensionAPI,
	type ReadOperations,
	type WriteOperations,
} from "@earendil-works/pi-coding-agent";
import { spawn } from "child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "fs";
import { readFile, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { Type } from "typebox";

type BridgeResponse = {
	ok: boolean;
	error?: string;
	content?: string;
	source?: string;
	state?: unknown;
	view?: unknown;
	result?: string;
	count?: number;
	persisted?: boolean;
	joined?: boolean;
	found?: boolean;
	tag?: string;
	file?: string;
	line?: number;
	text?: string;
	candidates?: string[];
};

const here = dirname(fileURLToPath(import.meta.url));

const address = () => process.env.PIVI_NVIM_ADDRESS ?? "";
const nvimExecutable = () => process.env.PIVI_NVIM_EXE ?? "nvim";
const clientScript = () => process.env.PIVI_NVIM_CLIENT ?? join(here, "client.lua");

/**
 * Talks to the Neovim instance that started this Pi process.
 * A throwaway headless Neovim performs the msgpack RPC call, so no protocol
 * implementation is duplicated here.
 */
async function bridge(request: Record<string, unknown>): Promise<BridgeResponse> {
	const target = address();
	if (!target) return { ok: false, error: "no neovim address" };

	const directory = mkdtempSync(join(tmpdir(), "pivi-"));
	const requestPath = join(directory, "request.json");
	const responsePath = join(directory, "response.json");
	writeFileSync(requestPath, JSON.stringify(request), "utf8");

	const code = await new Promise<number>((done) => {
		const child = spawn(
			nvimExecutable(),
			["--headless", "--clean", "-l", clientScript(), target, requestPath, responsePath],
			{ stdio: "ignore" },
		);
		child.on("error", () => done(1));
		child.on("close", (status) => done(status ?? 1));
	});

	if (code !== 0) return { ok: false, error: "neovim client failed" };

	try {
		return JSON.parse(readFileSync(responsePath, "utf8")) as BridgeResponse;
	} catch (failure) {
		return { ok: false, error: String(failure) };
	}
}

function bufferOperations(cwd: string): ReadOperations & WriteOperations & EditOperations {
	const absolute = (path: string) => resolve(cwd, path);

	return {
		readFile: async (path: string) => {
			const answer = await bridge({ action: "read", path: absolute(path) });
			if (answer.ok && answer.source === "buffer" && typeof answer.content === "string") {
				return Buffer.from(answer.content, "utf8");
			}
			return readFile(absolute(path));
		},
		access: async (path: string) => {
			const answer = await bridge({ action: "read", path: absolute(path) });
			if (answer.ok) return;
			await readFile(absolute(path));
		},
		writeFile: async (path: string, content: string) => {
			const answer = await bridge({ action: "write", path: absolute(path), content });
			if (answer.ok && answer.source === "buffer") return;
			await writeFile(absolute(path), content, "utf8");
		},
	};
}

export default function (pi: ExtensionAPI) {
	const cwd = process.cwd();
	const operations = bufferOperations(cwd);

	const read = createReadTool(cwd, { operations });
	const write = createWriteTool(cwd, { operations });
	const edit = createEditTool(cwd, { operations });

	pi.registerTool({ ...read });
	pi.registerTool({ ...write });
	pi.registerTool({ ...edit });

	pi.registerTool({
		name: "nvim_state",
		label: "Neovim state",
		description:
			"Read live core Neovim state: working directory, mode, current buffer and window, loaded buffers with unsaved status, and window layout.",
		promptSnippet: "Inspect the live Neovim editor state",
		promptGuidelines: [
			"Use nvim_state when the answer depends on what the user currently has open, selected, or unsaved.",
		],
		parameters: Type.Object({}),
		async execute() {
			const answer = await bridge({ action: "state" });
			// The visible view is attached to help questions already; this is the
			// wider ambient state a model asks for explicitly.
			if (!answer.ok) {
				return {
					content: [{ type: "text" as const, text: `Neovim unavailable: ${answer.error}` }],
					details: { error: true },
				};
			}
			return {
				content: [{ type: "text" as const, text: JSON.stringify(answer.state, null, 2) }],
				details: { state: answer.state },
			};
		},
	});

	pi.registerTool({
		name: "nvim_lua",
		label: "Neovim Lua",
		description:
			"Execute arbitrary Lua inside the running Neovim instance and return the inspected result. Full editor access: buffers, selections, windows, tabs, marks, jumps, changes, lists, diagnostics, and commands.",
		promptSnippet: "Run Lua inside the running Neovim instance",
		promptGuidelines: [
			"Use nvim_lua for editor operations that no other tool covers, and prefer returning a value over printing.",
		],
		parameters: Type.Object({
			code: Type.String({ description: "Lua chunk; return a value to receive it" }),
		}),
		async execute(_id, params: { code: string }) {
			const answer = await bridge({ action: "lua", code: params.code });
			if (!answer.ok) {
				return {
					content: [{ type: "text" as const, text: `Lua failed: ${answer.error}` }],
					details: { error: true },
				};
			}
			return {
				content: [{ type: "text" as const, text: answer.result ?? "nil" }],
				details: { ok: true },
			};
		},
	});

	pi.registerTool({
		name: "nvim_help",
		label: "Neovim help",
		description:
			"Read Neovim documentation for a help tag, resolved against this machine's runtime path, so the answer matches this Neovim version and the installed plugins. Equivalent to typing :help {tag}.",
		promptSnippet: "Read Neovim documentation for a help tag",
		promptGuidelines: [
			"Use nvim_help instead of recalling Neovim or plugin documentation; it reflects the running version.",
			"When nvim_help reports no match, pick one of the returned candidate tags and call it again.",
		],
		parameters: Type.Object({
			tag: Type.String({ description: "Help tag, exactly as typed after :help" }),
		}),
		async execute(_id, params: { tag: string }) {
			const answer = await bridge({ action: "help", tag: params.tag });
			if (!answer.ok) {
				return {
					content: [{ type: "text" as const, text: `Help unavailable: ${answer.error}` }],
					details: { error: true },
				};
			}
			if (!answer.found) {
				const candidates = answer.candidates ?? [];
				const hint = candidates.length > 0 ? `\nCandidates: ${candidates.join(", ")}` : "";
				return {
					content: [{ type: "text" as const, text: `No help tag "${params.tag}".${hint}` }],
					details: { found: false, candidates },
				};
			}
			return {
				content: [
					{ type: "text" as const, text: `${answer.file}:${answer.line}\n\n${answer.text ?? ""}` },
				],
				details: { found: true, tag: answer.tag, file: answer.file, line: answer.line },
			};
		},
	});

	pi.registerTool({
		name: "nvim_findings",
		label: "Neovim findings",
		description:
			"Publish cross-file findings to the Neovim quickfix list so the user can navigate them with normal motions.",
		promptSnippet: "Publish findings to the Neovim quickfix list",
		promptGuidelines: ["Use nvim_findings when reporting several file locations the user should visit."],
		parameters: Type.Object({
			title: Type.Optional(Type.String()),
			items: Type.Array(
				Type.Object({
					path: Type.String(),
					line: Type.Optional(Type.Number()),
					column: Type.Optional(Type.Number()),
					text: Type.Optional(Type.String()),
					severity: Type.Optional(Type.String()),
				}),
			),
		}),
		async execute(_id, params: { title?: string; items: unknown[] }) {
			const answer = await bridge({ action: "findings", title: params.title, items: params.items });
			if (!answer.ok) {
				return {
					content: [{ type: "text" as const, text: `Findings failed: ${answer.error}` }],
					details: { error: true },
				};
			}
			return {
				content: [{ type: "text" as const, text: `Published ${answer.count} findings to the quickfix list.` }],
				details: { count: answer.count },
			};
		},
	});
}