#!/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 } from "./src/root.ts"; import { servers } from "./server.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 [--no-daemon]\n` + ` cli.ts daemon \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 { 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 \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, ): Promise { 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, ): Promise { const methodMap: Record = { 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 const ext = path.extname(filePath).replace(/^\./, ""); const serverIds = servers .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; 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); });