Files
pi-lsp/cli.ts
Evan Reichard 46e3cc4ccd feat(config): add per-repo .pi-lsp.json server overrides
Users can now drop a .pi-lsp.json at any ancestor of their working
files to add new LSP servers, override built-in ones, or disable
servers entirely. The nearest config (walking upward) wins.

- New src/config.ts: walks upward for .pi-lsp.json, parses, and
  merges with the built-in registry. Cached per config-file path
  with mtime invalidation. Falls back to built-ins on parse error.
- Merge rules: matching id shallow-merges (user wins); new id
  appends (must include match/command/args/rootMarkers); `disable`
  filters at the end.
- src/root.ts: pickServer() now resolves servers via the per-repo
  registry. Adds findServerById(filePath, id) and re-exports
  getServersForPath() for callers.
- src/daemon.ts: getOrCreateEntry() resolves serverId against the
  filePath's config so spawned servers reflect repo overrides.
- index.ts and cli.ts: replace direct `servers` imports with
  path-aware getServersForPath() lookups.
- Tests: 9 new unit tests covering merge semantics, walk-up
  discovery, mtime invalidation, and graceful fallback.
- Docs: README "Per-Repo Config" section + AGENTS.md updates.
2026-05-07 22:43:41 -04:00

174 lines
5.8 KiB
TypeScript
Executable File

#!/usr/bin/env -S npx tsx
import * as path from "node:path";
import { startClientForFile } from "./src/client.ts";
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
import {
daemonDiagnostics,
daemonRequest,
daemonShutdown,
daemonStatus,
} from "./src/daemonClient.ts";
import { socketPath } from "./src/daemonProtocol.ts";
// Usage
function usage(): never {
process.stderr.write(
`Usage:\n` +
` cli.ts <file> <lsp_command> <req_data_json> [--no-daemon]\n` +
` cli.ts daemon <status|stop>\n` +
`\n` +
`Commands: ${listCommands().join(", ")}\n` +
`\n` +
`By default requests are routed through the long-lived pi-lsp\n` +
`daemon (autospawned). Pass --no-daemon for a one-shot in-process\n` +
`client (useful for debugging).\n` +
`\n` +
`Example:\n` +
` cli.ts backend/api/server.go hover '{"position":{"line":223,"character":22}}'\n`,
);
process.exit(2);
}
// Daemon Subcommand - `daemon status` / `daemon stop`. Start is implicit:
// any LSP request autospawns the daemon if it isn't running.
async function daemonSubcommand(action: string | undefined): Promise<void> {
switch (action) {
case "status": {
const result = await daemonStatus();
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
return;
}
case "stop": {
try {
const result = await daemonShutdown();
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (err) {
// Already-stopped daemons surface as connect errors; treat as no-op.
process.stderr.write(
`daemon not running (${(err as Error).message})\n`,
);
}
return;
}
default:
process.stderr.write(
`Usage: cli.ts daemon <status|stop>\nSocket: ${socketPath()}\n`,
);
process.exit(2);
}
}
// Run In-Process - The legacy one-shot path: spawn the LSP server here,
// run the command, dispose, exit. Kept for `--no-daemon` debugging.
async function runInProcess(
fileArg: string,
cmdArg: string,
params: Record<string, unknown>,
): Promise<void> {
if (!isLspCommand(cmdArg)) {
process.stderr.write(
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
);
process.exit(2);
}
const filePath = path.resolve(fileArg);
const server = pickServer(filePath);
const { client, uri } = await startClientForFile(server, filePath);
try {
const result = await runCommand(cmdArg, client, uri, params);
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
} finally {
// Hard Teardown - The no-daemon path is short-lived/debug-only. Avoid
// graceful JSON-RPC shutdown because the server stdio stream may already
// be closed, which can surface ERR_STREAM_DESTROYED during process exit.
void client.dispose({ graceful: false });
}
}
// Run Via Daemon - The default path. Hover/definition/references/completion/
// documentSymbol map to specific LSP method strings; diagnostics uses a
// dedicated op since it's notification-driven.
async function runViaDaemon(
fileArg: string,
cmdArg: string,
params: Record<string, unknown>,
): Promise<void> {
const methodMap: Record<string, string> = {
hover: "textDocument/hover",
definition: "textDocument/definition",
references: "textDocument/references",
completion: "textDocument/completion",
documentSymbol: "textDocument/documentSymbol",
};
const filePath = path.resolve(fileArg);
let result: unknown;
if (cmdArg === "diagnostics") {
// Pick All Available Servers For Diagnostics - Resolves against any
// `.pi-lsp.json` reachable from the file so per-repo overrides apply.
const ext = path.extname(filePath).replace(/^\./, "");
const serverIds = getServersForPath(filePath)
.filter((s) => s.match.includes(ext) && isServerAvailable(s))
.map((s) => s.id);
result = await daemonDiagnostics(filePath, serverIds);
} else if (cmdArg in methodMap) {
const server = pickServer(filePath);
// References Default - Match commands.ts: include declaration unless
// caller explicitly overrode `context`.
if (cmdArg === "references" && !("context" in params)) {
params.context = { includeDeclaration: true };
}
result = await daemonRequest(filePath, server.id, methodMap[cmdArg], params);
} else {
process.stderr.write(
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
);
process.exit(2);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
}
async function main() {
const argv = process.argv.slice(2);
// Daemon Subcommand - First arg is the literal word "daemon".
if (argv[0] === "daemon") {
await daemonSubcommand(argv[1]);
return;
}
// Parse Flags - Pull out --no-daemon; positional args are unchanged.
const noDaemon = argv.includes("--no-daemon");
const positional = argv.filter((a) => a !== "--no-daemon");
const [fileArg, cmdArg, jsonArg] = positional;
if (!fileArg || !cmdArg || jsonArg === undefined) usage();
// Parse Request JSON
let params: Record<string, unknown>;
try {
params = jsonArg.trim() === "" ? {} : JSON.parse(jsonArg);
} catch (err) {
process.stderr.write(
`Invalid JSON for req_data_json: ${(err as Error).message}\n`,
);
process.exit(2);
}
if (noDaemon) {
await runInProcess(fileArg, cmdArg, params);
} else {
await runViaDaemon(fileArg, cmdArg, params);
}
}
main()
.then(() => {
// Hard Exit - Any lingering handles (sockets, LSP stdio, daemon stubs)
// would keep the event loop alive. For a short-lived CLI we just exit.
process.exit(0);
})
.catch((err) => {
process.stderr.write(`${(err as Error).stack ?? err}\n`);
process.exit(1);
});