@cruxy/cli 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent/loop.d.ts +12 -0
  2. package/dist/agent/loop.js +20 -0
  3. package/dist/agent/session.d.ts +18 -1
  4. package/dist/agent/session.js +38 -6
  5. package/dist/cli/commands/run.js +31 -1
  6. package/dist/cli/commands/usage.d.ts +9 -0
  7. package/dist/cli/commands/usage.js +81 -0
  8. package/dist/cli/program.js +2 -0
  9. package/dist/cli/session-factory.js +30 -1
  10. package/dist/config/schema.d.ts +407 -14
  11. package/dist/config/schema.js +77 -0
  12. package/dist/constants.d.ts +7 -0
  13. package/dist/constants.js +7 -0
  14. package/dist/errors/constructors.d.ts +30 -0
  15. package/dist/errors/constructors.js +86 -0
  16. package/dist/errors/types.d.ts +13 -0
  17. package/dist/errors/types.js +25 -0
  18. package/dist/lsp/client.d.ts +25 -0
  19. package/dist/lsp/client.js +43 -0
  20. package/dist/lsp/index.d.ts +8 -0
  21. package/dist/lsp/index.js +8 -0
  22. package/dist/lsp/pool.d.ts +48 -0
  23. package/dist/lsp/pool.js +132 -0
  24. package/dist/lsp/registry.d.ts +38 -0
  25. package/dist/lsp/registry.js +133 -0
  26. package/dist/lsp/server.d.ts +48 -0
  27. package/dist/lsp/server.js +264 -0
  28. package/dist/lsp/service.d.ts +44 -0
  29. package/dist/lsp/service.js +76 -0
  30. package/dist/lsp/tools/common.d.ts +23 -0
  31. package/dist/lsp/tools/common.js +75 -0
  32. package/dist/lsp/tools/find-definition.d.ts +23 -0
  33. package/dist/lsp/tools/find-definition.js +41 -0
  34. package/dist/lsp/tools/find-references.d.ts +23 -0
  35. package/dist/lsp/tools/find-references.js +41 -0
  36. package/dist/lsp/tools/get-diagnostics.d.ts +17 -0
  37. package/dist/lsp/tools/get-diagnostics.js +43 -0
  38. package/dist/lsp/tools/hover.d.ts +23 -0
  39. package/dist/lsp/tools/hover.js +38 -0
  40. package/dist/lsp/tools/index.d.ts +4 -0
  41. package/dist/lsp/tools/index.js +4 -0
  42. package/dist/lsp/transport.d.ts +48 -0
  43. package/dist/lsp/transport.js +264 -0
  44. package/dist/lsp/types.d.ts +107 -0
  45. package/dist/lsp/types.js +1 -0
  46. package/dist/plan/service.d.ts +10 -1
  47. package/dist/plan/service.js +2 -0
  48. package/dist/tools/file/grep-files.d.ts +2 -2
  49. package/dist/usage/collect.d.ts +40 -0
  50. package/dist/usage/collect.js +34 -0
  51. package/dist/usage/cost.d.ts +19 -0
  52. package/dist/usage/cost.js +29 -0
  53. package/dist/usage/index.d.ts +15 -0
  54. package/dist/usage/index.js +15 -0
  55. package/dist/usage/store.d.ts +37 -0
  56. package/dist/usage/store.js +83 -0
  57. package/dist/usage/summary.d.ts +32 -0
  58. package/dist/usage/summary.js +119 -0
  59. package/dist/usage/types.d.ts +220 -0
  60. package/dist/usage/types.js +47 -0
  61. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import type { LanguageServer, LspDiagnostic, LspHover, LspLocation, LspTransport, ServerSpec } from "./types.js";
2
+ /** Bounds threaded from `lsp.*` config into a server. */
3
+ export interface ServerTimeouts {
4
+ startupTimeout: number;
5
+ requestTimeout: number;
6
+ }
7
+ /**
8
+ * An initialized {@link LanguageServer} over an {@link LspTransport} (C.12).
9
+ * Owns the LSP handshake, lazily opens documents (servers answer only for open
10
+ * files), converts LSP's 0-based positions / `file://` URIs to normalized
11
+ * 1-based / path results, and shuts down cleanly. A missing/timed-out/crashed
12
+ * server surfaces as a coded error; an empty answer stays an honest `[]`/`null`.
13
+ */
14
+ export declare class Server implements LanguageServer {
15
+ private readonly transport;
16
+ private readonly root;
17
+ private readonly timeouts;
18
+ readonly language: string;
19
+ private _alive;
20
+ private readonly openDocs;
21
+ private readonly diagnosticsByUri;
22
+ private readonly diagnosticsWaiters;
23
+ private constructor();
24
+ get alive(): boolean;
25
+ /**
26
+ * Spawn+handshake a server: `initialize` (bounded by `startupTimeout`) then
27
+ * the `initialized` notification. A startup timeout throws a coded error after
28
+ * the transport is force-disposed — never a hang.
29
+ */
30
+ static start(transport: LspTransport, root: string, spec: ServerSpec, timeouts: ServerTimeouts): Promise<Server>;
31
+ definition(file: string, line: number, col: number): Promise<LspLocation[]>;
32
+ references(file: string, line: number, col: number): Promise<LspLocation[]>;
33
+ hover(file: string, line: number, col: number): Promise<LspHover | null>;
34
+ diagnostics(file: string): Promise<LspDiagnostic[]>;
35
+ shutdown(force?: boolean): Promise<void>;
36
+ private uri;
37
+ private ensureOpen;
38
+ private query;
39
+ /** One request with per-request timeout + coded-error mapping (timeout/crash). */
40
+ private request;
41
+ private onPublishDiagnostics;
42
+ private waitForDiagnostics;
43
+ private toLocations;
44
+ private toLocation;
45
+ private toDiagnostic;
46
+ /** `file://` URI → project-relative path (falls back to the fs path on error). */
47
+ private relativize;
48
+ }
@@ -0,0 +1,264 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import { pathToFileURL, fileURLToPath } from "node:url";
3
+ import { lspCrashed, lspTimeout } from "../errors/index.js";
4
+ import { TransportTimeoutError } from "./transport.js";
5
+ /**
6
+ * An initialized {@link LanguageServer} over an {@link LspTransport} (C.12).
7
+ * Owns the LSP handshake, lazily opens documents (servers answer only for open
8
+ * files), converts LSP's 0-based positions / `file://` URIs to normalized
9
+ * 1-based / path results, and shuts down cleanly. A missing/timed-out/crashed
10
+ * server surfaces as a coded error; an empty answer stays an honest `[]`/`null`.
11
+ */
12
+ export class Server {
13
+ transport;
14
+ root;
15
+ timeouts;
16
+ language;
17
+ _alive = true;
18
+ openDocs = new Set();
19
+ diagnosticsByUri = new Map();
20
+ diagnosticsWaiters = new Map();
21
+ constructor(transport, root, spec, timeouts) {
22
+ this.transport = transport;
23
+ this.root = root;
24
+ this.timeouts = timeouts;
25
+ this.language = spec.language;
26
+ this.transport.onCrash(() => {
27
+ this._alive = false;
28
+ });
29
+ this.transport.onNotification("textDocument/publishDiagnostics", (params) => this.onPublishDiagnostics(params));
30
+ }
31
+ get alive() {
32
+ return this._alive;
33
+ }
34
+ /**
35
+ * Spawn+handshake a server: `initialize` (bounded by `startupTimeout`) then
36
+ * the `initialized` notification. A startup timeout throws a coded error after
37
+ * the transport is force-disposed — never a hang.
38
+ */
39
+ static async start(transport, root, spec, timeouts) {
40
+ const server = new Server(transport, root, spec, timeouts);
41
+ const rootUri = pathToFileURL(root).toString();
42
+ try {
43
+ await transport.request("initialize", {
44
+ processId: process.pid,
45
+ rootUri,
46
+ workspaceFolders: [{ uri: rootUri, name: "root" }],
47
+ capabilities: {
48
+ textDocument: {
49
+ synchronization: { dynamicRegistration: false },
50
+ definition: { dynamicRegistration: false },
51
+ references: { dynamicRegistration: false },
52
+ hover: { contentFormat: ["markdown", "plaintext"] },
53
+ publishDiagnostics: {},
54
+ },
55
+ },
56
+ }, timeouts.startupTimeout);
57
+ }
58
+ catch (err) {
59
+ await transport.dispose(true);
60
+ if (err instanceof TransportTimeoutError) {
61
+ throw lspTimeout(spec.language, "startup", timeouts.startupTimeout);
62
+ }
63
+ throw err;
64
+ }
65
+ transport.notify("initialized", {});
66
+ return server;
67
+ }
68
+ async definition(file, line, col) {
69
+ await this.ensureOpen(file);
70
+ const result = await this.query("textDocument/definition", file, line, col);
71
+ return this.toLocations(result);
72
+ }
73
+ async references(file, line, col) {
74
+ await this.ensureOpen(file);
75
+ const result = await this.request("textDocument/references", {
76
+ textDocument: { uri: this.uri(file) },
77
+ position: toPosition(line, col),
78
+ context: { includeDeclaration: true },
79
+ });
80
+ return this.toLocations(result);
81
+ }
82
+ async hover(file, line, col) {
83
+ await this.ensureOpen(file);
84
+ const result = (await this.query("textDocument/hover", file, line, col));
85
+ if (!result || result.contents == null)
86
+ return null;
87
+ const contents = flattenHover(result.contents).trim();
88
+ if (!contents)
89
+ return null;
90
+ return {
91
+ contents,
92
+ range: result.range
93
+ ? this.toLocation(this.uri(file), result.range)
94
+ : undefined,
95
+ };
96
+ }
97
+ async diagnostics(file) {
98
+ const uri = this.uri(file);
99
+ const fresh = !this.openDocs.has(uri);
100
+ await this.ensureOpen(file);
101
+ // Most servers PUSH diagnostics via publishDiagnostics after didOpen. If a
102
+ // push already arrived (reused doc), return it; otherwise wait one bounded
103
+ // window for the first push, then honestly return whatever we have ([] is a
104
+ // real "no diagnostics", never conflated with "server unavailable").
105
+ if (!fresh && this.diagnosticsByUri.has(uri)) {
106
+ return this.diagnosticsByUri.get(uri) ?? [];
107
+ }
108
+ await this.waitForDiagnostics(uri, this.timeouts.requestTimeout);
109
+ return this.diagnosticsByUri.get(uri) ?? [];
110
+ }
111
+ async shutdown(force = false) {
112
+ if (this._alive && !force) {
113
+ try {
114
+ await this.request("shutdown", null);
115
+ this.transport.notify("exit", null);
116
+ }
117
+ catch {
118
+ // A server that won't answer shutdown gets force-killed below anyway.
119
+ }
120
+ }
121
+ this._alive = false;
122
+ await this.transport.dispose(force);
123
+ }
124
+ // ── internals ─────────────────────────────────────────────────────────────
125
+ uri(file) {
126
+ return pathToFileURL(file).toString();
127
+ }
128
+ async ensureOpen(file) {
129
+ const uri = this.uri(file);
130
+ if (this.openDocs.has(uri))
131
+ return;
132
+ const text = await fsp.readFile(file, "utf8");
133
+ this.transport.notify("textDocument/didOpen", {
134
+ textDocument: { uri, languageId: this.language, version: 1, text },
135
+ });
136
+ this.openDocs.add(uri);
137
+ }
138
+ query(method, file, line, col) {
139
+ return this.request(method, {
140
+ textDocument: { uri: this.uri(file) },
141
+ position: toPosition(line, col),
142
+ });
143
+ }
144
+ /** One request with per-request timeout + coded-error mapping (timeout/crash). */
145
+ async request(method, params) {
146
+ try {
147
+ return await this.transport.request(method, params, this.timeouts.requestTimeout);
148
+ }
149
+ catch (err) {
150
+ if (err instanceof TransportTimeoutError) {
151
+ throw lspTimeout(this.language, "request", this.timeouts.requestTimeout);
152
+ }
153
+ if (!this._alive) {
154
+ throw lspCrashed(this.language, err.message);
155
+ }
156
+ throw err;
157
+ }
158
+ }
159
+ onPublishDiagnostics(params) {
160
+ const p = params;
161
+ if (!p?.uri)
162
+ return;
163
+ const list = (p.diagnostics ?? []).map((d) => this.toDiagnostic(p.uri, d));
164
+ this.diagnosticsByUri.set(p.uri, list);
165
+ const waiters = this.diagnosticsWaiters.get(p.uri);
166
+ if (waiters) {
167
+ this.diagnosticsWaiters.delete(p.uri);
168
+ for (const w of waiters)
169
+ w();
170
+ }
171
+ }
172
+ waitForDiagnostics(uri, timeoutMs) {
173
+ if (this.diagnosticsByUri.has(uri))
174
+ return Promise.resolve();
175
+ return new Promise((resolve) => {
176
+ const waiters = this.diagnosticsWaiters.get(uri) ?? [];
177
+ let done = false;
178
+ const finish = () => {
179
+ if (done)
180
+ return;
181
+ done = true;
182
+ resolve();
183
+ };
184
+ waiters.push(finish);
185
+ this.diagnosticsWaiters.set(uri, waiters);
186
+ const timer = setTimeout(finish, timeoutMs);
187
+ timer.unref?.();
188
+ });
189
+ }
190
+ // ── normalization (LSP → normalized) ────────────────────────────────────────
191
+ toLocations(result) {
192
+ if (result == null)
193
+ return [];
194
+ const raw = Array.isArray(result) ? result : [result];
195
+ const out = [];
196
+ for (const item of raw) {
197
+ // Plain Location: { uri, range }. LocationLink: { targetUri, targetRange }.
198
+ const loc = item;
199
+ const uri = loc.uri ?? loc.targetUri;
200
+ const range = loc.range ?? loc.targetRange;
201
+ if (uri && range)
202
+ out.push(this.toLocation(uri, range));
203
+ }
204
+ return out;
205
+ }
206
+ toLocation(uri, range) {
207
+ return {
208
+ path: this.relativize(uri),
209
+ startLine: range.start.line + 1,
210
+ startCol: range.start.character + 1,
211
+ endLine: range.end.line + 1,
212
+ endCol: range.end.character + 1,
213
+ };
214
+ }
215
+ toDiagnostic(uri, d) {
216
+ return {
217
+ path: this.relativize(uri),
218
+ range: this.toLocation(uri, d.range),
219
+ severity: SEVERITY[d.severity ?? 1] ?? "error",
220
+ message: d.message,
221
+ source: d.source,
222
+ };
223
+ }
224
+ /** `file://` URI → project-relative path (falls back to the fs path on error). */
225
+ relativize(uri) {
226
+ let abs;
227
+ try {
228
+ abs = fileURLToPath(uri);
229
+ }
230
+ catch {
231
+ return uri;
232
+ }
233
+ const rel = relativePath(this.root, abs);
234
+ return rel;
235
+ }
236
+ }
237
+ const SEVERITY = {
238
+ 1: "error",
239
+ 2: "warning",
240
+ 3: "info",
241
+ 4: "hint",
242
+ };
243
+ function toPosition(line, col) {
244
+ // Tool inputs are 1-based; LSP is 0-based. Clamp at 0 defensively.
245
+ return { line: Math.max(0, line - 1), character: Math.max(0, col - 1) };
246
+ }
247
+ /** Flatten every hover-content shape (string / MarkedString / MarkupContent) to text. */
248
+ function flattenHover(contents) {
249
+ if (typeof contents === "string")
250
+ return contents;
251
+ if (Array.isArray(contents)) {
252
+ return contents
253
+ .map((c) => (typeof c === "string" ? c : c.value))
254
+ .join("\n");
255
+ }
256
+ return contents.value;
257
+ }
258
+ /** POSIX-normalized project-relative path (kept in one place for display). */
259
+ function relativePath(root, abs) {
260
+ const path = abs.startsWith(root)
261
+ ? abs.slice(root.length).replace(/^[/\\]+/, "")
262
+ : abs;
263
+ return path.split("\\").join("/");
264
+ }
@@ -0,0 +1,44 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { BinaryPresent, LspDiagnostic, LspHover, LspLocation, TransportFactory } from "./types.js";
3
+ /** Logger surface the service reports through (matches the indexing service). */
4
+ interface ServiceLogger {
5
+ debug(message: string): void;
6
+ info(message: string): void;
7
+ warn(message: string): void;
8
+ }
9
+ /**
10
+ * A ready-to-use LSP facade for one project root (C.12): it owns the registry,
11
+ * pool, and client, exposes the four normalized queries, and shuts the whole
12
+ * server pool down on `close()`. Build one with {@link getLspService}, cached
13
+ * per cwd so servers are spawned at most once per project per process.
14
+ */
15
+ export interface LspService {
16
+ definition(file: string, line: number, col: number): Promise<LspLocation[]>;
17
+ references(file: string, line: number, col: number): Promise<LspLocation[]>;
18
+ hover(file: string, line: number, col: number): Promise<LspHover | null>;
19
+ diagnostics(file: string): Promise<LspDiagnostic[]>;
20
+ /** Shut down every managed server (session end / process teardown). */
21
+ close(): Promise<void>;
22
+ }
23
+ /**
24
+ * Explicit dependency overrides, for tests only. Production callers (the LSP
25
+ * tools) never pass these, so they always go through the real stdio transport
26
+ * and PATH-based binary detection.
27
+ */
28
+ export interface LspServiceDeps {
29
+ /** Substitute the JSON-RPC transport (a fake stdio peer — no real binary). */
30
+ transportFactory?: TransportFactory;
31
+ /** Substitute PATH-based binary detection (force present/absent). */
32
+ binaryPresent?: BinaryPresent;
33
+ /** Substitute the idle clock. */
34
+ now?: () => number;
35
+ }
36
+ /**
37
+ * Get (or build) the {@link LspService} for a project root, cached per resolved
38
+ * cwd so the pool is created at most once per process. No server spawns until
39
+ * the first query. `deps` is for explicit test injection only.
40
+ */
41
+ export declare function getLspService(cwd: string, config: CruxyConfig, logger: ServiceLogger, deps?: LspServiceDeps): LspService;
42
+ /** Drop all cached services (shutting them down). For tests and process teardown. */
43
+ export declare function resetLspServices(): Promise<void>;
44
+ export {};
@@ -0,0 +1,76 @@
1
+ import path from "node:path";
2
+ import { Server } from "./server.js";
3
+ import { LspRegistry, binaryOnPath } from "./registry.js";
4
+ import { LspPool } from "./pool.js";
5
+ import { LspClient } from "./client.js";
6
+ class LspServiceImpl {
7
+ pool;
8
+ logger;
9
+ client;
10
+ constructor(pool, logger) {
11
+ this.pool = pool;
12
+ this.logger = logger;
13
+ this.client = new LspClient(pool);
14
+ }
15
+ definition(file, line, col) {
16
+ return this.client.definition(file, line, col);
17
+ }
18
+ references(file, line, col) {
19
+ return this.client.references(file, line, col);
20
+ }
21
+ hover(file, line, col) {
22
+ return this.client.hover(file, line, col);
23
+ }
24
+ diagnostics(file) {
25
+ return this.client.diagnostics(file);
26
+ }
27
+ async close() {
28
+ this.logger.debug("shutting down LSP servers");
29
+ await this.pool.shutdownAll();
30
+ }
31
+ }
32
+ // ── per-cwd cache ──────────────────────────────────────────────────────────────
33
+ const cache = new Map();
34
+ /**
35
+ * Get (or build) the {@link LspService} for a project root, cached per resolved
36
+ * cwd so the pool is created at most once per process. No server spawns until
37
+ * the first query. `deps` is for explicit test injection only.
38
+ */
39
+ export function getLspService(cwd, config, logger, deps) {
40
+ const root = path.resolve(cwd);
41
+ let service = cache.get(root);
42
+ if (!service) {
43
+ service = buildService(root, config, logger, deps);
44
+ cache.set(root, service);
45
+ }
46
+ return service;
47
+ }
48
+ /** Drop all cached services (shutting them down). For tests and process teardown. */
49
+ export async function resetLspServices() {
50
+ const services = [...cache.values()];
51
+ cache.clear();
52
+ for (const service of services) {
53
+ try {
54
+ await service.close();
55
+ }
56
+ catch {
57
+ /* already closed or failed to build */
58
+ }
59
+ }
60
+ }
61
+ function buildService(root, config, logger, deps) {
62
+ const lsp = config.lsp;
63
+ const registry = new LspRegistry(lsp, deps?.binaryPresent ?? binaryOnPath);
64
+ const timeouts = {
65
+ startupTimeout: lsp.startupTimeout,
66
+ requestTimeout: lsp.requestTimeout,
67
+ };
68
+ const transportFactory = deps?.transportFactory;
69
+ const pool = new LspPool(registry, root, { ...timeouts, maxServers: lsp.maxServers, idleTimeout: lsp.idleTimeout }, {
70
+ now: deps?.now,
71
+ startServer: transportFactory
72
+ ? (spec) => Server.start(transportFactory(spec, root), root, spec, timeouts)
73
+ : undefined,
74
+ });
75
+ return new LspServiceImpl(pool, logger);
76
+ }
@@ -0,0 +1,23 @@
1
+ import type { ToolContext, ToolResult } from "../../tools/types.js";
2
+ import { type LspService } from "../service.js";
3
+ import type { LspLocation } from "../types.js";
4
+ /**
5
+ * The shared spine of every LSP tool (C.12): enforce the master switch, validate
6
+ * the target path stays in the project root, prove it exists, get the per-cwd
7
+ * service, and run `query`. Read-only throughout — no `ctx.requestApproval`, so
8
+ * these bypass the U.3 gate exactly like `search_codebase` and `grep_files`.
9
+ *
10
+ * Errors are surfaced as `{ ok:false }` text the agent can act on: a coded LSP
11
+ * failure (no server / timeout / crash) is rendered WITH its next step, so the
12
+ * agent can reroute (e.g. to grep) or the user can install the server. A genuine
13
+ * empty answer never reaches here as an error — `query` returns it as `ok:true`.
14
+ */
15
+ export declare function runLspTool(ctx: ToolContext, file: string, query: (service: LspService, absFile: string) => Promise<ToolResult>): Promise<ToolResult>;
16
+ /**
17
+ * Render up to `max` locations as `path:line:col-endLine:endCol`, one per line,
18
+ * with a trailing "N more" note when capped — the same bounded-honest pattern as
19
+ * grep_files and search_codebase (never silently drop the overflow).
20
+ */
21
+ export declare function formatLocations(locations: LspLocation[], max: number): string;
22
+ /** Relative label for a validated absolute path, for tool messages. */
23
+ export declare function relLabel(cwd: string, absFile: string): string;
@@ -0,0 +1,75 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import { CruxyError } from "../../errors/index.js";
4
+ import { resolveInRoot } from "../../tools/file/paths.js";
5
+ import { getLspService } from "../service.js";
6
+ /**
7
+ * The shared spine of every LSP tool (C.12): enforce the master switch, validate
8
+ * the target path stays in the project root, prove it exists, get the per-cwd
9
+ * service, and run `query`. Read-only throughout — no `ctx.requestApproval`, so
10
+ * these bypass the U.3 gate exactly like `search_codebase` and `grep_files`.
11
+ *
12
+ * Errors are surfaced as `{ ok:false }` text the agent can act on: a coded LSP
13
+ * failure (no server / timeout / crash) is rendered WITH its next step, so the
14
+ * agent can reroute (e.g. to grep) or the user can install the server. A genuine
15
+ * empty answer never reaches here as an error — `query` returns it as `ok:true`.
16
+ */
17
+ export async function runLspTool(ctx, file, query) {
18
+ if (!ctx.config.lsp.enabled) {
19
+ return {
20
+ ok: false,
21
+ error: "LSP tools are disabled (set lsp.enabled = true to use language-server features)",
22
+ };
23
+ }
24
+ let absFile;
25
+ try {
26
+ absFile = await resolveInRoot(ctx, file);
27
+ }
28
+ catch (err) {
29
+ return { ok: false, error: err.message };
30
+ }
31
+ try {
32
+ const stat = await fsp.stat(absFile);
33
+ if (!stat.isFile()) {
34
+ return { ok: false, error: `not a file: ${file}` };
35
+ }
36
+ }
37
+ catch {
38
+ return { ok: false, error: `file not found: ${file}` };
39
+ }
40
+ try {
41
+ const service = getLspService(ctx.cwd, ctx.config, ctx.logger);
42
+ return await query(service, absFile);
43
+ }
44
+ catch (err) {
45
+ return { ok: false, error: describeError(err) };
46
+ }
47
+ }
48
+ /** Render a coded LSP error with its actionable next step, for the model to read. */
49
+ function describeError(err) {
50
+ if (CruxyError.is(err)) {
51
+ const cause = err.cause ? ` — ${err.cause}` : "";
52
+ const step = err.nextSteps[0] ? `\n→ ${err.nextSteps[0]}` : "";
53
+ return `[${err.code}] ${err.title}${cause}${step}`;
54
+ }
55
+ return err.message;
56
+ }
57
+ /**
58
+ * Render up to `max` locations as `path:line:col-endLine:endCol`, one per line,
59
+ * with a trailing "N more" note when capped — the same bounded-honest pattern as
60
+ * grep_files and search_codebase (never silently drop the overflow).
61
+ */
62
+ export function formatLocations(locations, max) {
63
+ const shown = locations.slice(0, max);
64
+ const lines = shown.map((l) => `${l.path}:${l.startLine}:${l.startCol}-${l.endLine}:${l.endCol}`);
65
+ const omitted = locations.length - shown.length;
66
+ if (omitted > 0) {
67
+ lines.push(`… [${omitted} more location(s) omitted]`);
68
+ }
69
+ return lines.join("\n");
70
+ }
71
+ /** Relative label for a validated absolute path, for tool messages. */
72
+ export function relLabel(cwd, absFile) {
73
+ const rel = path.relative(path.resolve(cwd), absFile);
74
+ return rel === "" ? absFile : rel.split(path.sep).join("/");
75
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../tools/types.js";
3
+ declare const parameters: z.ZodObject<{
4
+ file: z.ZodString;
5
+ line: z.ZodNumber;
6
+ column: z.ZodNumber;
7
+ }, "strip", z.ZodTypeAny, {
8
+ file: string;
9
+ line: number;
10
+ column: number;
11
+ }, {
12
+ file: string;
13
+ line: number;
14
+ column: number;
15
+ }>;
16
+ /**
17
+ * LSP go-to-definition (C.12). Read-only — no approval, like search_codebase.
18
+ * Returns the definition site(s) as `path:line:col-endLine:endCol`. A missing
19
+ * language server is a coded, actionable error (distinct from "no definition
20
+ * found", which is an honest empty result).
21
+ */
22
+ export declare const findDefinitionTool: Tool<typeof parameters>;
23
+ export {};
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ import { formatLocations, runLspTool } from "./common.js";
3
+ const parameters = z.object({
4
+ file: z
5
+ .string()
6
+ .min(1)
7
+ .describe("Project-relative path to the file containing the symbol."),
8
+ line: z
9
+ .number()
10
+ .int()
11
+ .positive()
12
+ .describe("1-based line number of the symbol to resolve."),
13
+ column: z
14
+ .number()
15
+ .int()
16
+ .positive()
17
+ .describe("1-based column of the symbol (position of the identifier)."),
18
+ });
19
+ /**
20
+ * LSP go-to-definition (C.12). Read-only — no approval, like search_codebase.
21
+ * Returns the definition site(s) as `path:line:col-endLine:endCol`. A missing
22
+ * language server is a coded, actionable error (distinct from "no definition
23
+ * found", which is an honest empty result).
24
+ */
25
+ export const findDefinitionTool = {
26
+ name: "find_definition",
27
+ description: "Resolve where a symbol is defined using the project's language server (go-to-definition). Give the file and the 1-based line/column of the identifier. Returns definition locations as 'path:line:col-endLine:endCol'. Read-only, no approval. Precise where grep is textual — prefer this to jump to a symbol's definition.",
28
+ parameters,
29
+ execute(input, ctx) {
30
+ return runLspTool(ctx, input.file, async (service, absFile) => {
31
+ const locations = await service.definition(absFile, input.line, input.column);
32
+ if (locations.length === 0) {
33
+ return { ok: true, output: "(no definition found)" };
34
+ }
35
+ return {
36
+ ok: true,
37
+ output: formatLocations(locations, ctx.config.lsp.maxResults),
38
+ };
39
+ });
40
+ },
41
+ };
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../tools/types.js";
3
+ declare const parameters: z.ZodObject<{
4
+ file: z.ZodString;
5
+ line: z.ZodNumber;
6
+ column: z.ZodNumber;
7
+ }, "strip", z.ZodTypeAny, {
8
+ file: string;
9
+ line: number;
10
+ column: number;
11
+ }, {
12
+ file: string;
13
+ line: number;
14
+ column: number;
15
+ }>;
16
+ /**
17
+ * LSP find-references (C.12). Read-only — no approval. Returns every use of the
18
+ * symbol (declaration included) as `path:line:col-endLine:endCol`, capped to
19
+ * `lsp.maxResults` with an "N more" note so a hot symbol can't flood the output.
20
+ * A missing server is a coded error, distinct from an honest "no references".
21
+ */
22
+ export declare const findReferencesTool: Tool<typeof parameters>;
23
+ export {};
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ import { formatLocations, runLspTool } from "./common.js";
3
+ const parameters = z.object({
4
+ file: z
5
+ .string()
6
+ .min(1)
7
+ .describe("Project-relative path to the file containing the symbol."),
8
+ line: z
9
+ .number()
10
+ .int()
11
+ .positive()
12
+ .describe("1-based line number of the symbol."),
13
+ column: z
14
+ .number()
15
+ .int()
16
+ .positive()
17
+ .describe("1-based column of the symbol (position of the identifier)."),
18
+ });
19
+ /**
20
+ * LSP find-references (C.12). Read-only — no approval. Returns every use of the
21
+ * symbol (declaration included) as `path:line:col-endLine:endCol`, capped to
22
+ * `lsp.maxResults` with an "N more" note so a hot symbol can't flood the output.
23
+ * A missing server is a coded error, distinct from an honest "no references".
24
+ */
25
+ export const findReferencesTool = {
26
+ name: "find_references",
27
+ description: "Find all references to a symbol using the project's language server. Give the file and the 1-based line/column of the identifier. Returns use sites as 'path:line:col-endLine:endCol' (capped, with an 'N more' note). Read-only, no approval. Precise where grep is textual — prefer this to see every caller/user of a symbol.",
28
+ parameters,
29
+ execute(input, ctx) {
30
+ return runLspTool(ctx, input.file, async (service, absFile) => {
31
+ const locations = await service.references(absFile, input.line, input.column);
32
+ if (locations.length === 0) {
33
+ return { ok: true, output: "(no references found)" };
34
+ }
35
+ return {
36
+ ok: true,
37
+ output: formatLocations(locations, ctx.config.lsp.maxResults),
38
+ };
39
+ });
40
+ },
41
+ };
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../tools/types.js";
3
+ declare const parameters: z.ZodObject<{
4
+ file: z.ZodString;
5
+ }, "strip", z.ZodTypeAny, {
6
+ file: string;
7
+ }, {
8
+ file: string;
9
+ }>;
10
+ /**
11
+ * LSP diagnostics (C.12): the language server's errors/warnings for a file.
12
+ * Read-only — no approval. Returns each diagnostic as
13
+ * `severity path:line:col message [source]`, capped to `lsp.maxResults`. A
14
+ * missing server is a coded error; a clean file is an honest empty result.
15
+ */
16
+ export declare const getDiagnosticsTool: Tool<typeof parameters>;
17
+ export {};