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.
This commit is contained in:
61
index.ts
61
index.ts
@@ -12,8 +12,7 @@ import {
|
||||
daemonRequest,
|
||||
daemonStatus,
|
||||
} from "./src/daemonClient.ts";
|
||||
import { pickServer, isServerAvailable } from "./src/root.ts";
|
||||
import { servers } from "./server.ts";
|
||||
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
|
||||
import {
|
||||
ServerNotFoundError,
|
||||
UnsupportedExtensionError,
|
||||
@@ -326,10 +325,11 @@ async function runLsp(
|
||||
}
|
||||
|
||||
// Pick Diagnostic Servers - Returns all available, non-disabled servers
|
||||
// matching the file's extension. Used for fan-out diagnostics.
|
||||
// matching the file's extension. Resolves the per-repo config from the
|
||||
// file's directory so user-defined servers participate in fan-out.
|
||||
function pickDiagnosticServers(filePath: string): string[] {
|
||||
const ext = path.extname(filePath).replace(/^\./, "");
|
||||
return servers
|
||||
return getServersForPath(filePath)
|
||||
.filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id))
|
||||
.map((s) => s.id);
|
||||
}
|
||||
@@ -541,7 +541,9 @@ export default function (pi: ExtensionAPI) {
|
||||
if (!pi.getFlag("lsp-auto-check")) return;
|
||||
|
||||
// Skip If All Disabled - No LSP server is available for this instance.
|
||||
if (servers.every((s) => disabledServers.has(s.id))) return;
|
||||
// Resolve against ctx.cwd so user-defined servers count toward this check.
|
||||
const cwdServers = getServersForPath(ctx.cwd);
|
||||
if (cwdServers.every((s) => disabledServers.has(s.id))) return;
|
||||
|
||||
// Edit & Write Only
|
||||
if (!["edit", "write"].includes(event.toolName)) return;
|
||||
@@ -619,9 +621,16 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
// --- Server Control Commands ---
|
||||
|
||||
// Resolve Active Servers - Per-cwd helper so commands see the same
|
||||
// per-repo overrides the LLM does. We accept an optional cwd because some
|
||||
// closures (e.g. completions) don't get a context.
|
||||
const resolveServers = (cwd?: string) => getServersForPath(cwd ?? process.cwd());
|
||||
|
||||
// Shared Argument Completions - Suggests registered server IDs plus "all".
|
||||
// Uses process.cwd() since completion handlers don't receive a context;
|
||||
// close enough for argument hints.
|
||||
const serverCompletions = (prefix: string) => {
|
||||
const ids = [...servers.map((s) => s.id), "all"].filter((id) =>
|
||||
const ids = [...resolveServers().map((s) => s.id), "all"].filter((id) =>
|
||||
id.startsWith(prefix),
|
||||
);
|
||||
return ids.length > 0 ? ids.map((id) => ({ value: id, label: id })) : null;
|
||||
@@ -629,16 +638,17 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
// Parse Server IDs - Validates args against registered servers. Bare/empty
|
||||
// or "all" returns every server ID. Throws on unknown names.
|
||||
function parseServerIds(args: string | undefined): string[] {
|
||||
if (!args || !args.trim()) return servers.map((s) => s.id);
|
||||
function parseServerIds(args: string | undefined, cwd: string): string[] {
|
||||
const list = resolveServers(cwd);
|
||||
if (!args || !args.trim()) return list.map((s) => s.id);
|
||||
const ids = args.trim().split(/\s+/);
|
||||
if (ids.includes("all")) return servers.map((s) => s.id);
|
||||
const invalid = ids.filter((id) => !servers.some((s) => s.id === id));
|
||||
if (ids.includes("all")) return list.map((s) => s.id);
|
||||
const invalid = ids.filter((id) => !list.some((s) => s.id === id));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown server(s): ${invalid.join(
|
||||
", ",
|
||||
)}. Available: ${servers.map((s) => s.id).join(", ")}`,
|
||||
)}. Available: ${list.map((s) => s.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
@@ -647,9 +657,10 @@ export default function (pi: ExtensionAPI) {
|
||||
// Update Tool Visibility - When all servers are disabled, remove LSP tools
|
||||
// from the active set so the LLM won't attempt them. When any is enabled,
|
||||
// restore them. Captures current active tools at toggle time.
|
||||
function updateToolVisibility(): void {
|
||||
function updateToolVisibility(cwd: string): void {
|
||||
const list = resolveServers(cwd);
|
||||
const current = pi.getActiveTools();
|
||||
if (servers.every((s) => disabledServers.has(s.id))) {
|
||||
if (list.every((s) => disabledServers.has(s.id))) {
|
||||
// All disabled — strip LSP tools
|
||||
pi.setActiveTools(current.filter((name) => !lspToolNames.includes(name)));
|
||||
} else {
|
||||
@@ -699,13 +710,13 @@ export default function (pi: ExtensionAPI) {
|
||||
getArgumentCompletions: serverCompletions,
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const ids = parseServerIds(args);
|
||||
const ids = parseServerIds(args, ctx.cwd);
|
||||
for (const id of ids) {
|
||||
disabledServers.add(id);
|
||||
}
|
||||
updateToolVisibility();
|
||||
const label =
|
||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
||||
updateToolVisibility(ctx.cwd);
|
||||
const total = resolveServers(ctx.cwd).length;
|
||||
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||
ctx.ui.notify(`Disabled: ${label}`, "info");
|
||||
} catch (error) {
|
||||
const msg =
|
||||
@@ -725,13 +736,13 @@ export default function (pi: ExtensionAPI) {
|
||||
getArgumentCompletions: serverCompletions,
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const ids = parseServerIds(args);
|
||||
const ids = parseServerIds(args, ctx.cwd);
|
||||
for (const id of ids) {
|
||||
disabledServers.delete(id);
|
||||
}
|
||||
updateToolVisibility();
|
||||
const label =
|
||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
||||
updateToolVisibility(ctx.cwd);
|
||||
const total = resolveServers(ctx.cwd).length;
|
||||
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||
ctx.ui.notify(`Enabled: ${label}`, "info");
|
||||
} catch (error) {
|
||||
const msg =
|
||||
@@ -751,16 +762,16 @@ export default function (pi: ExtensionAPI) {
|
||||
getArgumentCompletions: serverCompletions,
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const ids = parseServerIds(args);
|
||||
if (ids.length === servers.length) {
|
||||
const ids = parseServerIds(args, ctx.cwd);
|
||||
const total = resolveServers(ctx.cwd).length;
|
||||
if (ids.length === total) {
|
||||
await daemonDestroyServer();
|
||||
} else {
|
||||
for (const id of ids) {
|
||||
await daemonDestroyServer(id);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
||||
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||
ctx.ui.notify(`Destroyed: ${label}`, "info");
|
||||
} catch (error) {
|
||||
const msg =
|
||||
|
||||
Reference in New Issue
Block a user