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 },
};
},
});
}