@danypops/lector 0.1.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/package.json +48 -0
  4. package/src/adapters/directory-size.ts +19 -0
  5. package/src/adapters/find-source-files.ts +35 -0
  6. package/src/adapters/git-repo-fetcher.ts +146 -0
  7. package/src/adapters/in-memory-content-cache.ts +19 -0
  8. package/src/adapters/in-memory-search-cache.ts +32 -0
  9. package/src/adapters/in-memory-symbol-graph.ts +80 -0
  10. package/src/adapters/in-memory-workspace.ts +28 -0
  11. package/src/adapters/local-filesystem-workspace.ts +96 -0
  12. package/src/adapters/local-git.ts +73 -0
  13. package/src/adapters/lsp/discover-seed-file.ts +130 -0
  14. package/src/adapters/lsp/json-rpc-stream.ts +67 -0
  15. package/src/adapters/lsp/language-server-process.ts +184 -0
  16. package/src/adapters/lsp/lsp-symbol-index.ts +470 -0
  17. package/src/adapters/lsp/process-resource-usage.ts +61 -0
  18. package/src/adapters/lsp/typescript-project-files.ts +51 -0
  19. package/src/adapters/read-only-workspace.ts +23 -0
  20. package/src/adapters/ripgrep-text-search.ts +97 -0
  21. package/src/adapters/source-manifest.ts +44 -0
  22. package/src/adapters/sqlite-content-cache.ts +67 -0
  23. package/src/adapters/sqlite-search-cache.ts +60 -0
  24. package/src/adapters/sqlite-symbol-graph.ts +154 -0
  25. package/src/adapters/tiered-search-cache.ts +29 -0
  26. package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +155 -0
  27. package/src/cli.ts +777 -0
  28. package/src/client.ts +63 -0
  29. package/src/constants.ts +16 -0
  30. package/src/daemon.ts +166 -0
  31. package/src/domain/assert-safe-git-argument.ts +19 -0
  32. package/src/domain/assert-safe-path-segment.ts +17 -0
  33. package/src/domain/assert-safe-repo-reference.ts +20 -0
  34. package/src/domain/assert-safe-search-query.ts +17 -0
  35. package/src/domain/bounded-job-executor.ts +219 -0
  36. package/src/domain/call-hierarchy.ts +23 -0
  37. package/src/domain/code-range.ts +11 -0
  38. package/src/domain/content-hash.ts +14 -0
  39. package/src/domain/diagnostic.ts +12 -0
  40. package/src/domain/diagnostics.ts +7 -0
  41. package/src/domain/document-symbol.ts +19 -0
  42. package/src/domain/document-symbols.ts +7 -0
  43. package/src/domain/exact-edit.ts +43 -0
  44. package/src/domain/find-references.ts +7 -0
  45. package/src/domain/find-workspace-symbols.ts +7 -0
  46. package/src/domain/git-diff-result.ts +5 -0
  47. package/src/domain/git-log-entry.ts +9 -0
  48. package/src/domain/git-status.ts +17 -0
  49. package/src/domain/go-to-definition.ts +7 -0
  50. package/src/domain/go-to-implementation.ts +7 -0
  51. package/src/domain/hover-at.ts +8 -0
  52. package/src/domain/hover.ts +7 -0
  53. package/src/domain/incoming-calls.ts +8 -0
  54. package/src/domain/language-server-descriptor.ts +122 -0
  55. package/src/domain/outgoing-calls.ts +8 -0
  56. package/src/domain/populate-symbol-graph.ts +91 -0
  57. package/src/domain/prepare-call-hierarchy.ts +8 -0
  58. package/src/domain/race-workspace-query.ts +39 -0
  59. package/src/domain/raw-read.ts +24 -0
  60. package/src/domain/reachable-symbols-from.ts +13 -0
  61. package/src/domain/repo-fetch-result.ts +18 -0
  62. package/src/domain/repo-reference.ts +7 -0
  63. package/src/domain/search-cache-key.ts +16 -0
  64. package/src/domain/search-text.ts +29 -0
  65. package/src/domain/symbol-edges-from.ts +9 -0
  66. package/src/domain/symbol-edges-to.ts +9 -0
  67. package/src/domain/symbol-graph-generation.ts +14 -0
  68. package/src/domain/symbol-node-id.ts +13 -0
  69. package/src/domain/text-search-result.ts +15 -0
  70. package/src/domain/workspace-query-outcome.ts +24 -0
  71. package/src/domain/workspace-symbol.ts +14 -0
  72. package/src/index.ts +121 -0
  73. package/src/ports/code-intelligence-port.ts +38 -0
  74. package/src/ports/content-cache-port.ts +52 -0
  75. package/src/ports/git-port.ts +23 -0
  76. package/src/ports/repo-fetcher-port.ts +11 -0
  77. package/src/ports/search-cache-port.ts +15 -0
  78. package/src/ports/symbol-graph-port.ts +35 -0
  79. package/src/ports/symbol-index-port.ts +11 -0
  80. package/src/ports/text-search-port.ts +12 -0
  81. package/src/ports/workspace-port.ts +35 -0
  82. package/src/service.ts +961 -0
  83. package/src/version.ts +6 -0
package/src/cli.ts ADDED
@@ -0,0 +1,777 @@
1
+ #!/usr/bin/env bun
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { InMemoryWorkspace } from "./adapters/in-memory-workspace.ts";
8
+ import { LocalFilesystemWorkspace } from "./adapters/local-filesystem-workspace.ts";
9
+ import { connectLectorClient } from "./client.ts";
10
+ import { LECTOR_PATH_NAMES } from "./constants.ts";
11
+ import { serveMain } from "./daemon.ts";
12
+ import type { JobSnapshot } from "./domain/bounded-job-executor.ts";
13
+ import type { ContentHash } from "./domain/content-hash.ts";
14
+ import type { WorkspacePort } from "./ports/workspace-port.ts";
15
+ import type { WorkspaceId } from "./service.ts";
16
+
17
+ const USAGE = `Usage:
18
+ lector serve [--workspace <id>]... [--workspace-path <id>=<dir>]... [--dynamic-workspaces]
19
+ at least one --workspace, --workspace-path, or --dynamic-workspaces is required
20
+ --workspace <id> ephemeral in-memory workspace (data lost on restart)
21
+ --workspace-path <id>=<dir> real directory <dir>, registered under <id>
22
+ --dynamic-workspaces start with none pre-registered; every workspace is added at
23
+ runtime via "lector workspace register" (workspace.registerPath) --
24
+ the mode a long-lived background daemon (e.g. lector.service) wants,
25
+ since it does not know upfront which project(s) will attach to it
26
+ lector service <install|start|stop|restart|status>
27
+ install: writes a user systemd unit (lector serve --dynamic-workspaces), enables + starts it
28
+ lector workspace register <dir> [--json]
29
+ lector workspace read <workspace-id> <path> [--json]
30
+ lector workspace edit <workspace-id> <path> --content <text> (--expected-hash <hash> | --create) [--json]
31
+ lector workspace symbols <workspace-id> <query> [--seed-file <path>] [--json]
32
+ lector workspace definition <workspace-id> <path> <line> <character> [--json]
33
+ lector workspace implementation <workspace-id> <path> <line> <character> [--json]
34
+ lector workspace references <workspace-id> <path> <line> <character> [--include-declaration] [--json]
35
+ lector workspace hover <workspace-id> <path> <line> <character> [--json]
36
+ lector workspace document-symbols <workspace-id> <path> [--json]
37
+ lector workspace diagnostics <workspace-id> <path> [--json]
38
+ lector workspace call-hierarchy <prepare|incoming|outgoing> <workspace-id> <path> <line> <character> [--json]
39
+ lector workspace populate-symbol-graph <workspace-id> --max-files <n> --max-symbols-per-file <n>
40
+ [--background] [--wait-ms <n>] [--json]
41
+ --background submits a bounded process-lifetime job; --wait-ms waits briefly for a fast result
42
+ lector job status <job-id> [--json]
43
+ lector workspace symbol-graph <reachable-from|edges-from|edges-to> <workspace-id> <path> <line> <character>
44
+ [--max-depth <n>] [--kind <calls|references|contains>] [--json]
45
+ --max-depth is required for reachable-from, ignored for edges-from/edges-to
46
+ lector workspace has-warm-index <workspace-id> [--json]
47
+ never spawns a symbol index -- reports whether one is already warm
48
+ lector workspace cache-status <workspace-id> --max-files <n> --max-symbols-per-file <n> [--json]
49
+ lector workspace git-status <workspace-id> [--json]
50
+ lector workspace git-log <workspace-id> --max-count <n> [--json]
51
+ lector workspace git-diff <workspace-id> [--ref <ref>] --max-bytes <n> [--json]
52
+ lector workspace repo-fetch <owner>/<repo>[@ref] [--host <host>] [--json]
53
+ shallow-clones an external repo into a disk-bounded cache and registers it read-only
54
+ lector workspace search-text <workspace-id> <query> --max-matches <n> --max-bytes <n> [--json]
55
+ lector search symbols <query> [--workspace <id>]... [--timeout-ms <n>] [--json]
56
+ lector search text <query> --max-matches <n> --max-bytes <n> [--workspace <id>]... [--timeout-ms <n>] [--json]
57
+ fans out across the given --workspace id(s); with none given, every currently-registered
58
+ workspace, daemon-wide -- this daemon is a shared service, so that default can include a
59
+ project a different, concurrent Pi session registered. Prefer explicit --workspace when you
60
+ mean "my own current projects".
61
+ a workspace whose language server is still cold-starting is reported as "loading", not
62
+ silently omitted and not blocking every other workspace's real results
63
+ `;
64
+
65
+ function fail(message: string): never {
66
+ console.error(message);
67
+ process.exit(1);
68
+ }
69
+
70
+ function collectFlagValues(args: string[], flag: string): string[] {
71
+ const values: string[] = [];
72
+ for (let index = 0; index < args.length; index++) {
73
+ if (args[index] === flag) {
74
+ const value = args[index + 1];
75
+ if (value === undefined) fail(`${flag} requires a value`);
76
+ values.push(value);
77
+ index++;
78
+ }
79
+ }
80
+ return values;
81
+ }
82
+
83
+ function flagValue(args: string[], flag: string): string | undefined {
84
+ return collectFlagValues(args, flag).at(-1);
85
+ }
86
+
87
+ function hasFlag(args: string[], flag: string): boolean {
88
+ return args.includes(flag);
89
+ }
90
+
91
+ function parseWorkspacePathFlag(raw: string): { id: string; dir: string } {
92
+ const separatorIndex = raw.indexOf("=");
93
+ if (separatorIndex <= 0 || separatorIndex === raw.length - 1) {
94
+ fail(`--workspace-path expects <id>=<dir>, got "${raw}"`);
95
+ }
96
+ return { id: raw.slice(0, separatorIndex), dir: raw.slice(separatorIndex + 1) };
97
+ }
98
+
99
+ async function runServe(args: string[]): Promise<void> {
100
+ const memoryIds = collectFlagValues(args, "--workspace");
101
+ const pathEntries = collectFlagValues(args, "--workspace-path").map(parseWorkspacePathFlag);
102
+ const dynamicWorkspaces = hasFlag(args, "--dynamic-workspaces");
103
+ if (memoryIds.length === 0 && pathEntries.length === 0 && !dynamicWorkspaces) {
104
+ fail("lector serve requires at least one --workspace <id>, --workspace-path <id>=<dir>, or --dynamic-workspaces");
105
+ }
106
+
107
+ const workspaces = new Map<WorkspaceId, WorkspacePort>();
108
+ for (const id of memoryIds) workspaces.set(id, new InMemoryWorkspace());
109
+ for (const { id, dir } of pathEntries) workspaces.set(id, new LocalFilesystemWorkspace(dir));
110
+
111
+ const summary =
112
+ [...memoryIds.map((id) => `${id} (in-memory)`), ...pathEntries.map(({ id, dir }) => `${id} (${dir})`)].join(", ") || "none pre-registered, dynamic-only";
113
+
114
+ serveMain({
115
+ workspaces,
116
+ allowDynamicOnly: dynamicWorkspaces,
117
+ onListen: ({ host, port }) => {
118
+ console.error(`Lector listening on ${host}:${port} (workspaces: ${summary})`);
119
+ },
120
+ });
121
+ }
122
+
123
+ async function runWorkspaceRegister(dir: string | undefined, flags: string[]): Promise<void> {
124
+ if (!dir) fail(USAGE);
125
+ const client = await connectLectorClient();
126
+ const result = await client.call("workspace.registerPath", { path: dir });
127
+ console.log(hasFlag(flags, "--json") ? JSON.stringify(result) : `${result.workspaceId} (${result.created ? "created" : "already registered"})`);
128
+ }
129
+
130
+ async function runWorkspaceSymbols(workspaceId: string | undefined, query: string | undefined, flags: string[]): Promise<void> {
131
+ if (!workspaceId || !query) fail(USAGE);
132
+ const seedFile = flagValue(flags, "--seed-file"); // omit to auto-discover one
133
+ const client = await connectLectorClient();
134
+ const { symbols } = await client.call("workspace.findSymbols", { workspaceId, query, seedFile });
135
+ if (hasFlag(flags, "--json")) {
136
+ console.log(JSON.stringify(symbols));
137
+ return;
138
+ }
139
+ if (symbols.length === 0) {
140
+ console.log(`no symbols matched "${query}"`);
141
+ return;
142
+ }
143
+ for (const symbol of symbols) {
144
+ console.log(`${symbol.kind} ${symbol.name} -- ${symbol.location.path}:${symbol.location.line}:${symbol.location.character}`);
145
+ }
146
+ }
147
+
148
+ function parsePosition(line: string | undefined, character: string | undefined): { line: number; character: number } {
149
+ const parsedLine = Number(line);
150
+ const parsedCharacter = Number(character);
151
+ if (!line || !character || !Number.isInteger(parsedLine) || !Number.isInteger(parsedCharacter)) {
152
+ fail(USAGE);
153
+ }
154
+ return { line: parsedLine, character: parsedCharacter };
155
+ }
156
+
157
+ async function runWorkspaceDefinition(workspaceId: string | undefined, path: string | undefined, rest: string[]): Promise<void> {
158
+ if (!workspaceId || !path) fail(USAGE);
159
+ const [lineArg, characterArg, ...flags] = rest;
160
+ const { line, character } = parsePosition(lineArg, characterArg);
161
+ const client = await connectLectorClient();
162
+ const { locations } = await client.call("workspace.goToDefinition", { workspaceId, path, line, character });
163
+ if (hasFlag(flags, "--json")) {
164
+ console.log(JSON.stringify(locations));
165
+ return;
166
+ }
167
+ if (locations.length === 0) {
168
+ console.log("no definition found");
169
+ return;
170
+ }
171
+ for (const location of locations) console.log(`${location.path}:${location.line}:${location.character}`);
172
+ }
173
+
174
+ async function runWorkspaceImplementation(workspaceId: string | undefined, path: string | undefined, rest: string[]): Promise<void> {
175
+ if (!workspaceId || !path) fail(USAGE);
176
+ const [lineArg, characterArg, ...flags] = rest;
177
+ const { line, character } = parsePosition(lineArg, characterArg);
178
+ const client = await connectLectorClient();
179
+ const { locations } = await client.call("workspace.goToImplementation", { workspaceId, path, line, character });
180
+ if (hasFlag(flags, "--json")) {
181
+ console.log(JSON.stringify(locations));
182
+ return;
183
+ }
184
+ if (locations.length === 0) {
185
+ console.log("no implementation found");
186
+ return;
187
+ }
188
+ for (const location of locations) console.log(`${location.path}:${location.line}:${location.character}`);
189
+ }
190
+
191
+ async function runWorkspaceReferences(workspaceId: string | undefined, path: string | undefined, rest: string[]): Promise<void> {
192
+ if (!workspaceId || !path) fail(USAGE);
193
+ const [lineArg, characterArg, ...flags] = rest;
194
+ const { line, character } = parsePosition(lineArg, characterArg);
195
+ const includeDeclaration = hasFlag(flags, "--include-declaration");
196
+ const client = await connectLectorClient();
197
+ const { locations } = await client.call("workspace.findReferences", { workspaceId, path, line, character, includeDeclaration });
198
+ if (hasFlag(flags, "--json")) {
199
+ console.log(JSON.stringify(locations));
200
+ return;
201
+ }
202
+ if (locations.length === 0) {
203
+ console.log("no references found");
204
+ return;
205
+ }
206
+ for (const location of locations) console.log(`${location.path}:${location.line}:${location.character}`);
207
+ }
208
+
209
+ async function runWorkspaceHover(workspaceId: string | undefined, path: string | undefined, rest: string[]): Promise<void> {
210
+ if (!workspaceId || !path) fail(USAGE);
211
+ const [lineArg, characterArg, ...flags] = rest;
212
+ const { line, character } = parsePosition(lineArg, characterArg);
213
+ const client = await connectLectorClient();
214
+ const { hover } = await client.call("workspace.hover", { workspaceId, path, line, character });
215
+ if (hasFlag(flags, "--json")) {
216
+ console.log(JSON.stringify(hover ?? null));
217
+ return;
218
+ }
219
+ console.log(hover ? hover.contents : "no hover information available");
220
+ }
221
+
222
+ async function runWorkspaceDocumentSymbols(workspaceId: string | undefined, path: string | undefined, flags: string[]): Promise<void> {
223
+ if (!workspaceId || !path) fail(USAGE);
224
+ const client = await connectLectorClient();
225
+ const { symbols } = await client.call("workspace.documentSymbols", { workspaceId, path });
226
+ if (hasFlag(flags, "--json")) {
227
+ console.log(JSON.stringify(symbols));
228
+ return;
229
+ }
230
+ if (symbols.length === 0) {
231
+ console.log("no symbols found");
232
+ return;
233
+ }
234
+ const printEntry = (entry: (typeof symbols)[number], depth: number): void => {
235
+ console.log(`${" ".repeat(depth)}${entry.kind} ${entry.name} -- ${entry.range.path}:${entry.range.start.line}:${entry.range.start.character}`);
236
+ for (const child of entry.children ?? []) printEntry(child, depth + 1);
237
+ };
238
+ for (const entry of symbols) printEntry(entry, 0);
239
+ }
240
+
241
+ async function runWorkspaceDiagnostics(workspaceId: string | undefined, path: string | undefined, flags: string[]): Promise<void> {
242
+ if (!workspaceId || !path) fail(USAGE);
243
+ const client = await connectLectorClient();
244
+ const { diagnostics } = await client.call("workspace.diagnostics", { workspaceId, path });
245
+ if (hasFlag(flags, "--json")) {
246
+ console.log(JSON.stringify(diagnostics));
247
+ return;
248
+ }
249
+ if (diagnostics.length === 0) {
250
+ console.log("no diagnostics");
251
+ return;
252
+ }
253
+ for (const diagnostic of diagnostics) {
254
+ console.log(
255
+ `${diagnostic.severity} ${diagnostic.range.path}:${diagnostic.range.start.line}:${diagnostic.range.start.character} -- ${diagnostic.message}${diagnostic.source ? ` (${diagnostic.source}${diagnostic.code !== undefined ? ` ${diagnostic.code}` : ""})` : ""}`,
256
+ );
257
+ }
258
+ }
259
+
260
+ function formatCallHierarchyEntry(entry: { kind: string; name: string; location: { path: string; line: number; character: number } }): string {
261
+ return `${entry.kind} ${entry.name} -- ${entry.location.path}:${entry.location.line}:${entry.location.character}`;
262
+ }
263
+
264
+ async function runWorkspaceCallHierarchy(
265
+ subcommand: string | undefined,
266
+ workspaceId: string | undefined,
267
+ path: string | undefined,
268
+ rest: string[],
269
+ ): Promise<void> {
270
+ if (!workspaceId || !path) fail(USAGE);
271
+ const [lineArg, characterArg, ...flags] = rest;
272
+ const { line, character } = parsePosition(lineArg, characterArg);
273
+ const client = await connectLectorClient();
274
+
275
+ if (subcommand === "prepare") {
276
+ const { items } = await client.call("workspace.prepareCallHierarchy", { workspaceId, path, line, character });
277
+ if (hasFlag(flags, "--json")) {
278
+ console.log(JSON.stringify(items));
279
+ return;
280
+ }
281
+ if (items.length === 0) {
282
+ console.log("no call-hierarchy root at this position");
283
+ return;
284
+ }
285
+ for (const item of items) console.log(formatCallHierarchyEntry(item));
286
+ return;
287
+ }
288
+ if (subcommand === "incoming") {
289
+ const { calls } = await client.call("workspace.incomingCalls", { workspaceId, path, line, character });
290
+ if (hasFlag(flags, "--json")) {
291
+ console.log(JSON.stringify(calls));
292
+ return;
293
+ }
294
+ if (calls.length === 0) {
295
+ console.log("no incoming calls found");
296
+ return;
297
+ }
298
+ for (const call of calls) console.log(formatCallHierarchyEntry(call.from));
299
+ return;
300
+ }
301
+ if (subcommand === "outgoing") {
302
+ const { calls } = await client.call("workspace.outgoingCalls", { workspaceId, path, line, character });
303
+ if (hasFlag(flags, "--json")) {
304
+ console.log(JSON.stringify(calls));
305
+ return;
306
+ }
307
+ if (calls.length === 0) {
308
+ console.log("no outgoing calls found");
309
+ return;
310
+ }
311
+ for (const call of calls) console.log(formatCallHierarchyEntry(call.to));
312
+ return;
313
+ }
314
+ fail(USAGE);
315
+ }
316
+
317
+ function formatSymbolNode(node: { kind: string; name: string; location: { path: string; line: number; character: number } }): string {
318
+ return `${node.kind} ${node.name} -- ${node.location.path}:${node.location.line}:${node.location.character}`;
319
+ }
320
+
321
+ function requiredIntFlag(flags: string[], flag: string): number {
322
+ const raw = flagValue(flags, flag);
323
+ const parsed = Number(raw);
324
+ if (raw === undefined || !Number.isInteger(parsed)) fail(`${flag} <n> is required`);
325
+ return parsed;
326
+ }
327
+
328
+ function formatJobSnapshot(job: JobSnapshot<{ filesProcessed: number; symbolsProcessed: number; nodesAdded: number; edgesAdded: number }>): string {
329
+ if (job.status === "queued") return `${job.id}: queued (${job.operation}); poll with: lector job status ${job.id}`;
330
+ if (job.status === "running") return `${job.id}: still running (${job.operation}); poll with: lector job status ${job.id}`;
331
+ if (job.status === "failed") return `${job.id}: failed [${job.error.code}] -- ${job.error.message}`;
332
+ return `${job.id}: succeeded -- ${job.result.filesProcessed} files, ${job.result.symbolsProcessed} symbols, ${job.result.nodesAdded} nodes, ${job.result.edgesAdded} edges`;
333
+ }
334
+
335
+ async function runWorkspacePopulateSymbolGraph(workspaceId: string | undefined, flags: string[]): Promise<void> {
336
+ if (!workspaceId) fail(USAGE);
337
+ const maxFiles = requiredIntFlag(flags, "--max-files");
338
+ const maxSymbolsPerFile = requiredIntFlag(flags, "--max-symbols-per-file");
339
+ const client = await connectLectorClient();
340
+ if (hasFlag(flags, "--background")) {
341
+ const waitMsRaw = flagValue(flags, "--wait-ms");
342
+ const waitMs = waitMsRaw === undefined ? 0 : Number(waitMsRaw);
343
+ const { job } = await client.call("job.submit", {
344
+ operation: "workspace.populateSymbolGraph",
345
+ input: { workspaceId, maxFiles, maxSymbolsPerFile },
346
+ waitMs,
347
+ });
348
+ console.log(hasFlag(flags, "--json") ? JSON.stringify(job) : formatJobSnapshot(job));
349
+ return;
350
+ }
351
+ const result = await client.call("workspace.populateSymbolGraph", { workspaceId, maxFiles, maxSymbolsPerFile });
352
+ console.log(
353
+ hasFlag(flags, "--json")
354
+ ? JSON.stringify(result)
355
+ : `${result.filesProcessed} files, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`,
356
+ );
357
+ }
358
+
359
+ async function runJobStatus(jobId: string | undefined, flags: string[]): Promise<void> {
360
+ if (!jobId) fail(USAGE);
361
+ const client = await connectLectorClient();
362
+ const { job } = await client.call("job.status", { jobId });
363
+ console.log(hasFlag(flags, "--json") ? JSON.stringify(job) : formatJobSnapshot(job));
364
+ }
365
+
366
+ async function runWorkspaceCacheStatus(workspaceId: string | undefined, flags: string[]): Promise<void> {
367
+ if (!workspaceId) fail(USAGE);
368
+ const maxFiles = requiredIntFlag(flags, "--max-files");
369
+ const maxSymbolsPerFile = requiredIntFlag(flags, "--max-symbols-per-file");
370
+ const client = await connectLectorClient();
371
+ const status = await client.call("workspace.cacheStatus", { workspaceId, maxFiles, maxSymbolsPerFile });
372
+ if (hasFlag(flags, "--json")) {
373
+ console.log(JSON.stringify(status));
374
+ return;
375
+ }
376
+ if (status.status === "not-cached") console.log(`not cached -- ${status.reason}`);
377
+ else if (status.status === "caching") console.log(`caching -- job ${status.jobId}`);
378
+ else console.log(`cached -- completed ${new Date(status.generation.completedAt).toISOString()}`);
379
+ }
380
+
381
+ async function runWorkspaceHasWarmIndex(workspaceId: string | undefined, flags: string[]): Promise<void> {
382
+ if (!workspaceId) fail(USAGE);
383
+ const client = await connectLectorClient();
384
+ const { warm } = await client.call("workspace.hasWarmIndex", { workspaceId });
385
+ console.log(hasFlag(flags, "--json") ? JSON.stringify({ warm }) : warm ? "warm" : "not warm");
386
+ }
387
+
388
+ async function runWorkspaceGitStatus(workspaceId: string | undefined, flags: string[]): Promise<void> {
389
+ if (!workspaceId) fail(USAGE);
390
+ const client = await connectLectorClient();
391
+ const summary = await client.call("workspace.gitStatus", { workspaceId });
392
+ if (hasFlag(flags, "--json")) {
393
+ console.log(JSON.stringify(summary));
394
+ return;
395
+ }
396
+ const branch = summary.current ?? "(detached)";
397
+ const tracking = summary.tracking ? `, tracking ${summary.tracking} (+${summary.ahead}/-${summary.behind})` : "";
398
+ console.log(`On branch ${branch}${tracking}`);
399
+ if (summary.files.length === 0) {
400
+ console.log("working tree clean");
401
+ return;
402
+ }
403
+ for (const file of summary.files) {
404
+ const code = `${file.indexStatus}${file.workingDirStatus}`;
405
+ console.log(file.renamedFrom ? `${code} ${file.renamedFrom} -> ${file.path}` : `${code} ${file.path}`);
406
+ }
407
+ }
408
+
409
+ async function runWorkspaceGitLog(workspaceId: string | undefined, flags: string[]): Promise<void> {
410
+ if (!workspaceId) fail(USAGE);
411
+ const maxCount = requiredIntFlag(flags, "--max-count");
412
+ const client = await connectLectorClient();
413
+ const { entries } = await client.call("workspace.gitLog", { workspaceId, maxCount });
414
+ if (hasFlag(flags, "--json")) {
415
+ console.log(JSON.stringify(entries));
416
+ return;
417
+ }
418
+ for (const entry of entries) console.log(`${entry.sha.slice(0, 8)} ${entry.authoredAt} ${entry.authorName} -- ${entry.message}`);
419
+ }
420
+
421
+ async function runWorkspaceGitDiff(workspaceId: string | undefined, flags: string[]): Promise<void> {
422
+ if (!workspaceId) fail(USAGE);
423
+ const ref = flagValue(flags, "--ref");
424
+ const maxBytes = requiredIntFlag(flags, "--max-bytes");
425
+ const client = await connectLectorClient();
426
+ const result = await client.call("workspace.gitDiff", { workspaceId, ref, maxBytes });
427
+ if (hasFlag(flags, "--json")) {
428
+ console.log(JSON.stringify(result));
429
+ return;
430
+ }
431
+ console.log(result.diff);
432
+ if (result.truncated) console.log("... (truncated)");
433
+ }
434
+
435
+ /** Parses "owner/repo[@ref]" into the explicit fields repo.fetch expects; --host overrides the "github.com" default. */
436
+ function parseRepoSpec(spec: string, host: string): { host: string; owner: string; repo: string; ref: string | null } {
437
+ const [ownerRepo, ref] = spec.split("@");
438
+ const [owner, repo] = (ownerRepo ?? "").split("/");
439
+ if (!owner || !repo) fail(`repo spec must be "<owner>/<repo>[@ref]", got "${spec}"`);
440
+ return { host, owner, repo, ref: ref ?? null };
441
+ }
442
+
443
+ async function runWorkspaceRepoFetch(spec: string | undefined, flags: string[]): Promise<void> {
444
+ if (!spec) fail(USAGE);
445
+ const host = flagValue(flags, "--host") ?? "github.com";
446
+ const reference = parseRepoSpec(spec, host);
447
+ const client = await connectLectorClient();
448
+ const result = await client.call("repo.fetch", reference);
449
+ if (hasFlag(flags, "--json")) {
450
+ console.log(JSON.stringify(result));
451
+ return;
452
+ }
453
+ console.log(`${result.workspaceId} ${result.fromCache ? "(from cache)" : "(fetched)"} -- ${result.path}`);
454
+ if (result.refFallbackOccurred) console.log(`note: requested ref not found, fell back to the default branch (resolved: ${result.resolvedRef})`);
455
+ }
456
+
457
+ async function runWorkspaceSearchText(workspaceId: string | undefined, query: string | undefined, flags: string[]): Promise<void> {
458
+ if (!workspaceId || !query) fail(USAGE);
459
+ const maxMatches = requiredIntFlag(flags, "--max-matches");
460
+ const maxBytes = requiredIntFlag(flags, "--max-bytes");
461
+ const client = await connectLectorClient();
462
+ const result = await client.call("workspace.searchText", { workspaceId, query, maxMatches, maxBytes });
463
+ if (hasFlag(flags, "--json")) {
464
+ console.log(JSON.stringify(result));
465
+ return;
466
+ }
467
+ if (result.matches.length === 0) {
468
+ console.log(`no matches for "${query}"`);
469
+ return;
470
+ }
471
+ for (const match of result.matches) console.log(`${match.path}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`);
472
+ if (result.truncated) console.log("... (truncated)");
473
+ }
474
+
475
+ async function runSearchSymbols(query: string | undefined, flags: string[]): Promise<void> {
476
+ if (!query) fail(USAGE);
477
+ const timeoutMs = flagValue(flags, "--timeout-ms");
478
+ const workspaceIds = collectFlagValues(flags, "--workspace");
479
+ const client = await connectLectorClient();
480
+ const { results } = await client.call("search.symbols", {
481
+ query,
482
+ workspaceIds: workspaceIds.length === 0 ? undefined : workspaceIds,
483
+ timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
484
+ });
485
+ if (hasFlag(flags, "--json")) {
486
+ console.log(JSON.stringify(results));
487
+ return;
488
+ }
489
+ if (results.length === 0) {
490
+ console.log("no workspaces registered with a known root to search");
491
+ return;
492
+ }
493
+ for (const outcome of results) {
494
+ if (outcome.status === "loading") {
495
+ console.log(`${outcome.workspaceId}: still loading -- ${outcome.message}`);
496
+ continue;
497
+ }
498
+ if (outcome.status === "error") {
499
+ console.log(`${outcome.workspaceId}: error -- ${outcome.message}`);
500
+ continue;
501
+ }
502
+ if (outcome.result.symbols.length === 0) {
503
+ console.log(`${outcome.workspaceId}: no symbols matched "${query}"`);
504
+ continue;
505
+ }
506
+ for (const symbol of outcome.result.symbols) {
507
+ console.log(`${outcome.workspaceId}: ${symbol.kind} ${symbol.name} -- ${symbol.location.path}:${symbol.location.line}:${symbol.location.character}`);
508
+ }
509
+ }
510
+ }
511
+
512
+ async function runSearchText(query: string | undefined, flags: string[]): Promise<void> {
513
+ if (!query) fail(USAGE);
514
+ const maxMatches = requiredIntFlag(flags, "--max-matches");
515
+ const maxBytes = requiredIntFlag(flags, "--max-bytes");
516
+ const timeoutMs = flagValue(flags, "--timeout-ms");
517
+ const workspaceIds = collectFlagValues(flags, "--workspace");
518
+ const client = await connectLectorClient();
519
+ const { results } = await client.call("search.text", {
520
+ query,
521
+ maxMatches,
522
+ maxBytes,
523
+ workspaceIds: workspaceIds.length === 0 ? undefined : workspaceIds,
524
+ timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
525
+ });
526
+ if (hasFlag(flags, "--json")) {
527
+ console.log(JSON.stringify(results));
528
+ return;
529
+ }
530
+ if (results.length === 0) {
531
+ console.log("no workspaces registered with a known root to search");
532
+ return;
533
+ }
534
+ for (const outcome of results) {
535
+ if (outcome.status === "loading") {
536
+ console.log(`${outcome.workspaceId}: still loading -- ${outcome.message}`);
537
+ continue;
538
+ }
539
+ if (outcome.status === "error") {
540
+ console.log(`${outcome.workspaceId}: error -- ${outcome.message}`);
541
+ continue;
542
+ }
543
+ if (outcome.result.matches.length === 0) {
544
+ console.log(`${outcome.workspaceId}: no matches for "${query}"`);
545
+ continue;
546
+ }
547
+ for (const match of outcome.result.matches) {
548
+ console.log(`${outcome.workspaceId}: ${match.path}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`);
549
+ }
550
+ if (outcome.result.truncated) console.log(`${outcome.workspaceId}: ... (truncated)`);
551
+ }
552
+ }
553
+
554
+ function parseSymbolEdgeKind(flags: string[]): "calls" | "references" | "contains" | undefined {
555
+ const raw = flagValue(flags, "--kind");
556
+ if (raw === undefined) return undefined;
557
+ if (raw !== "calls" && raw !== "references" && raw !== "contains") fail(`--kind must be calls, references, or contains; got "${raw}"`);
558
+ return raw;
559
+ }
560
+
561
+ async function runWorkspaceSymbolGraphQuery(
562
+ subcommand: string | undefined,
563
+ workspaceId: string | undefined,
564
+ path: string | undefined,
565
+ rest: string[],
566
+ ): Promise<void> {
567
+ if (!workspaceId || !path) fail(USAGE);
568
+ const [lineArg, characterArg, ...flags] = rest;
569
+ const { line, character } = parsePosition(lineArg, characterArg);
570
+ const kind = parseSymbolEdgeKind(flags);
571
+ const client = await connectLectorClient();
572
+
573
+ if (subcommand === "reachable-from") {
574
+ const maxDepth = requiredIntFlag(flags, "--max-depth");
575
+ const { symbols } = await client.call("workspace.reachableFrom", { workspaceId, path, line, character, maxDepth, kind });
576
+ if (hasFlag(flags, "--json")) {
577
+ console.log(JSON.stringify(symbols));
578
+ return;
579
+ }
580
+ if (symbols.length === 0) {
581
+ console.log("nothing reachable at this position (has the graph been populated for this workspace?)");
582
+ return;
583
+ }
584
+ for (const symbol of symbols) console.log(formatSymbolNode(symbol));
585
+ return;
586
+ }
587
+ if (subcommand === "edges-from" || subcommand === "edges-to") {
588
+ const operation = subcommand === "edges-from" ? "workspace.symbolEdgesFrom" : "workspace.symbolEdgesTo";
589
+ const { symbols } = await client.call(operation, { workspaceId, path, line, character, kind });
590
+ if (hasFlag(flags, "--json")) {
591
+ console.log(JSON.stringify(symbols));
592
+ return;
593
+ }
594
+ if (symbols.length === 0) {
595
+ console.log("no edges found (has the graph been populated for this workspace?)");
596
+ return;
597
+ }
598
+ for (const symbol of symbols) console.log(formatSymbolNode(symbol));
599
+ return;
600
+ }
601
+ fail(USAGE);
602
+ }
603
+
604
+ async function runWorkspaceRead(workspaceId: string | undefined, path: string | undefined, flags: string[]): Promise<void> {
605
+ if (!workspaceId || !path) fail(USAGE);
606
+ const client = await connectLectorClient();
607
+ const result = await client.call("workspace.rawRead", { workspaceId, path });
608
+ console.log(hasFlag(flags, "--json") ? JSON.stringify(result) : `${result.path} [${result.hash}]\n${result.content}`);
609
+ }
610
+
611
+ async function runWorkspaceEdit(workspaceId: string | undefined, path: string | undefined, flags: string[]): Promise<void> {
612
+ if (!workspaceId || !path) fail(USAGE);
613
+ const content = flagValue(flags, "--content");
614
+ if (content === undefined) fail("lector workspace edit requires --content <text>");
615
+
616
+ const create = hasFlag(flags, "--create");
617
+ const expectedHashFlag = flagValue(flags, "--expected-hash");
618
+ if (create === (expectedHashFlag !== undefined)) {
619
+ fail("lector workspace edit requires exactly one of --create or --expected-hash <hash>");
620
+ }
621
+ const expectedHash = create ? null : (expectedHashFlag as ContentHash);
622
+
623
+ const client = await connectLectorClient();
624
+ const result = await client.call("workspace.exactEdit", { workspaceId, path, expectedHash, content });
625
+ console.log(hasFlag(flags, "--json") ? JSON.stringify(result) : `${result.path}: ${result.previousHash ?? "(new)"} -> ${result.newHash}`);
626
+ }
627
+
628
+ /**
629
+ * systemd user-unit lifecycle (`install|start|stop|restart|status`) for a
630
+ * persistent Lector daemon. `install` always runs `serve
631
+ * --dynamic-workspaces`: a long-lived background daemon cannot know
632
+ * upfront which project(s) will attach to it, so it starts with zero
633
+ * pre-registered workspaces and relies entirely on workspace.registerPath
634
+ * at runtime.
635
+ */
636
+ export interface SystemdUnitOptions {
637
+ bunBin: string;
638
+ cliPath: string;
639
+ }
640
+
641
+ export function renderSystemdUnit(options: SystemdUnitOptions): string {
642
+ return `[Unit]
643
+ Description=Lector filesystem & code-intelligence service
644
+ After=default.target
645
+
646
+ [Service]
647
+ Type=simple
648
+ ExecStart=${options.bunBin} ${options.cliPath} serve --dynamic-workspaces
649
+ Restart=always
650
+ RestartSec=2
651
+
652
+ [Install]
653
+ WantedBy=default.target
654
+ `;
655
+ }
656
+
657
+ function unitPath(): string {
658
+ const configHome = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
659
+ return join(configHome, "systemd", "user", LECTOR_PATH_NAMES.systemdUnitName);
660
+ }
661
+
662
+ function systemctl(...args: string[]): void {
663
+ execFileSync("systemctl", ["--user", ...args], { stdio: "inherit" });
664
+ }
665
+
666
+ function installService(): void {
667
+ const path = unitPath();
668
+ mkdirSync(dirname(path), { recursive: true });
669
+ writeFileSync(path, renderSystemdUnit({ bunBin: process.execPath, cliPath: fileURLToPath(import.meta.url) }));
670
+ systemctl("daemon-reload");
671
+ systemctl("enable", LECTOR_PATH_NAMES.systemdUnitName);
672
+ systemctl("restart", LECTOR_PATH_NAMES.systemdUnitName);
673
+ }
674
+
675
+ function runService(action: string | undefined): void {
676
+ switch (action) {
677
+ case "install":
678
+ installService();
679
+ return;
680
+ case "start":
681
+ systemctl("start", LECTOR_PATH_NAMES.systemdUnitName);
682
+ return;
683
+ case "stop":
684
+ systemctl("stop", LECTOR_PATH_NAMES.systemdUnitName);
685
+ return;
686
+ case "restart":
687
+ systemctl("restart", LECTOR_PATH_NAMES.systemdUnitName);
688
+ return;
689
+ case "status":
690
+ systemctl("status", LECTOR_PATH_NAMES.systemdUnitName);
691
+ return;
692
+ default:
693
+ fail(USAGE);
694
+ }
695
+ }
696
+
697
+ async function main(): Promise<void> {
698
+ const [command, ...rest] = process.argv.slice(2);
699
+
700
+ if (command === "serve") return runServe(rest);
701
+ if (command === "service") return runService(rest[0]);
702
+
703
+ if (command === "search") {
704
+ const [action, query, ...searchFlags] = rest;
705
+ if (action === "symbols") return runSearchSymbols(query, searchFlags);
706
+ if (action === "text") return runSearchText(query, searchFlags);
707
+ fail(USAGE);
708
+ }
709
+
710
+ if (command === "job") {
711
+ const [action, jobId, ...jobFlags] = rest;
712
+ if (action === "status") return runJobStatus(jobId, jobFlags);
713
+ fail(USAGE);
714
+ }
715
+
716
+ if (command === "workspace") {
717
+ const [action, ...actionArgs] = rest;
718
+ if (action === "register") {
719
+ const [dir, ...flags] = actionArgs;
720
+ return runWorkspaceRegister(dir, flags);
721
+ }
722
+ const [workspaceId, path, ...flags] = actionArgs;
723
+ if (action === "read") return runWorkspaceRead(workspaceId, path, flags);
724
+ if (action === "edit") return runWorkspaceEdit(workspaceId, path, flags);
725
+ if (action === "symbols") return runWorkspaceSymbols(workspaceId, path, flags);
726
+ if (action === "search-text") return runWorkspaceSearchText(workspaceId, path, flags);
727
+ if (action === "definition") return runWorkspaceDefinition(workspaceId, path, flags);
728
+ if (action === "implementation") return runWorkspaceImplementation(workspaceId, path, flags);
729
+ if (action === "references") return runWorkspaceReferences(workspaceId, path, flags);
730
+ if (action === "hover") return runWorkspaceHover(workspaceId, path, flags);
731
+ if (action === "document-symbols") return runWorkspaceDocumentSymbols(workspaceId, path, flags);
732
+ if (action === "diagnostics") return runWorkspaceDiagnostics(workspaceId, path, flags);
733
+ if (action === "call-hierarchy") {
734
+ const [subcommand, chWorkspaceId, chPath, ...chRest] = actionArgs;
735
+ return runWorkspaceCallHierarchy(subcommand, chWorkspaceId, chPath, chRest);
736
+ }
737
+ if (action === "populate-symbol-graph") {
738
+ const [psgWorkspaceId, ...psgFlags] = actionArgs;
739
+ return runWorkspacePopulateSymbolGraph(psgWorkspaceId, psgFlags);
740
+ }
741
+ if (action === "symbol-graph") {
742
+ const [subcommand, sgWorkspaceId, sgPath, ...sgRest] = actionArgs;
743
+ return runWorkspaceSymbolGraphQuery(subcommand, sgWorkspaceId, sgPath, sgRest);
744
+ }
745
+ if (action === "has-warm-index") {
746
+ const [hwiWorkspaceId, ...hwiFlags] = actionArgs;
747
+ return runWorkspaceHasWarmIndex(hwiWorkspaceId, hwiFlags);
748
+ }
749
+ if (action === "cache-status") {
750
+ const [cacheWorkspaceId, ...cacheFlags] = actionArgs;
751
+ return runWorkspaceCacheStatus(cacheWorkspaceId, cacheFlags);
752
+ }
753
+ if (action === "git-status" || action === "git-log" || action === "git-diff") {
754
+ // None of these take a <path> positional -- the generic [workspaceId, path, ...flags]
755
+ // destructure above would misparse the first flag as path (the exact bug
756
+ // populate-symbol-graph's own CLI wiring hit).
757
+ const [gitWorkspaceId, ...gitFlags] = actionArgs;
758
+ if (action === "git-status") return runWorkspaceGitStatus(gitWorkspaceId, gitFlags);
759
+ if (action === "git-log") return runWorkspaceGitLog(gitWorkspaceId, gitFlags);
760
+ return runWorkspaceGitDiff(gitWorkspaceId, gitFlags);
761
+ }
762
+ if (action === "repo-fetch") {
763
+ const [spec, ...repoFlags] = actionArgs;
764
+ return runWorkspaceRepoFetch(spec, repoFlags);
765
+ }
766
+ fail(USAGE);
767
+ }
768
+
769
+ fail(USAGE);
770
+ }
771
+
772
+ if (import.meta.main) {
773
+ main().catch((error: unknown) => {
774
+ console.error(error instanceof Error ? error.message : String(error));
775
+ process.exit(1);
776
+ });
777
+ }