graph-indexer 2.1.0 → 3.0.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 (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +31 -105
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. package/watch-daemon.mjs +0 -289
@@ -0,0 +1,356 @@
1
+ /**
2
+ * `graph-indexer hook <event>`: agent hooks that put the index into the agent's normal loop.
3
+ *
4
+ * Reads the hook payload on stdin (Claude Code's format, which Codex, Copilot CLI, Devin and VS Code
5
+ * share; Gemini CLI and Cursor tool names are recognised too) and, only when it has something
6
+ * useful to add, prints the host's "additional context" field:
7
+ *
8
+ * post-tool after an edit: the edit checker on the edited file (syntax errors introduced,
9
+ * calls that no longer fit a changed signature, removed names still in use);
10
+ * after a search for a definition that did not find it: where it is defined;
11
+ * after a read: the indirection the lines involve, resolved (which override runs,
12
+ * which table entry handles a key, what reaches a method by a built name, what a
13
+ * decorator is — silent when there is none, never repeated in a session);
14
+ * while the agent is crawling (three or more searches and reads since its last
15
+ * edit): after a read, where the names used in the lines it read are defined, and
16
+ * after a grep for a name several definitions share, which definition is which
17
+ * session-start one line saying the index exists (builds it in the background if missing), plus
18
+ * the lookup rules when the repository's instruction files do not carry them
19
+ * subagent-start the line and the lookup rules, for subagents that do not read CLAUDE.md/AGENTS.md
20
+ *
21
+ * Staying quiet until the agent is crawling matters: context added to every search made "where is
22
+ * X" questions dearer in other projects' measurements. Fail-open by design: any error, timeout or
23
+ * irrelevant input prints nothing and exits 0. Context is phrased as facts.
24
+ *
25
+ * A resident process (the MCP server, or `graph-indexer daemon`) answers when one runs for the
26
+ * repository, with the index already open (src/cli/resident.mjs); otherwise the hook opens the
27
+ * index itself and starts a daemon for the next calls.
28
+ */
29
+ import fs from 'node:fs';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+ import { spawn } from 'node:child_process';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { findRepoRoot, dataDir, DATA_DIR_NAME } from '../util/paths.mjs';
35
+ import { specForPath } from '../parse/languages.mjs';
36
+
37
+ const MAX_CONTEXT = 1500;
38
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'edit', 'write', 'apply_patch', 'replace', 'write_file']);
39
+ const GREP_TOOLS = new Set(['Grep', 'grep', 'search_file_content', 'grep_search']);
40
+ const READ_TOOLS = new Set(['Read', 'read_file', 'read', 'view']);
41
+ const SHELL_TOOLS = new Set(['Bash', 'shell', 'Shell', 'run_shell_command', 'exec_command']);
42
+ // navigation calls since the last edit from which the agent counts as crawling
43
+ const CRAWL = 3;
44
+ const DEF_SEARCH = /\b(def|class|function|func|fn|interface|struct|trait|enum|type)\s+(\\?\(?)?\w/;
45
+ const SEARCH_CMD = /(?:^|[|;&(]\s*|\s)(grep|egrep|fgrep|rg|ugrep|ag|ack|git\s+grep)\s+(.*)$/;
46
+ const STOP_IDENTS = new Set(['function', 'return', 'import', 'export', 'const', 'class', 'async', 'await', 'self', 'this', 'true', 'false', 'null', 'None', 'TODO', 'FIXME', 'test', 'error', 'string']);
47
+
48
+ async function readStdin() {
49
+ if (process.stdin.isTTY) return {};
50
+ const chunks = [];
51
+ for await (const c of process.stdin) chunks.push(c);
52
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return {}; }
53
+ }
54
+
55
+ /** Cursor's own hook events are camelCase (`postToolUse`) and take `additional_context`. */
56
+ const isCursor = (input) => /^[a-z]/.test(input.hook_event_name ?? '') || input.cursor_version != null;
57
+
58
+ /** What the hook prints for `context` in the calling host's format, or null for nothing. */
59
+ function format(event, context, input = {}, max = MAX_CONTEXT) {
60
+ if (!context) return null;
61
+ const text = context.length > max ? context.slice(0, max - 1) + '…' : context;
62
+ const out = isCursor(input) ? { additional_context: text } : { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
63
+ return JSON.stringify(out);
64
+ }
65
+
66
+ /** The text a tool returned, whatever the host's shape (string, Bash {stdout}, Grep {content}). */
67
+ function responseText(input) {
68
+ const r = input.tool_response ?? input.tool_result ?? input.toolResult ?? null;
69
+ if (r == null) return null;
70
+ if (typeof r === 'string') return r;
71
+ if (typeof r.stdout === 'string') return r.stdout;
72
+ if (typeof r.content === 'string') return r.content;
73
+ if (typeof r.file?.content === 'string') return r.file.content;
74
+ if (Array.isArray(r.content)) return r.content.map(c => c?.text ?? '').join('\n');
75
+ return JSON.stringify(r);
76
+ }
77
+
78
+ /** Navigation calls since the last edit and definitions already shown, per session (crawl detector). */
79
+ function sessionState(input) {
80
+ const id = String(input.session_id ?? input.sessionId ?? input.conversation_id ?? 'default').replace(/[^\w.-]/g, '_').slice(0, 80);
81
+ const file = path.join(os.tmpdir(), 'graph-indexer-hooks', `${id}.json`);
82
+ let st = { nav: 0, shown: [] };
83
+ try { st = { ...st, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { /* first call */ }
84
+ return {
85
+ st,
86
+ save() {
87
+ try {
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ st.shown = st.shown.slice(-400);
90
+ fs.writeFileSync(file, JSON.stringify(st));
91
+ } catch { /* optional */ }
92
+ },
93
+ };
94
+ }
95
+
96
+ /** A file read through the shell: [file, from, to] for `sed -n 'a,bp' f`, `head -n N f`, `cat f`, `nl -ba f | sed -n …`. */
97
+ export function shellRead(command) {
98
+ const c = String(command ?? '').replace(/^\s*cd\s+\S+\s*&&\s*/, '');
99
+ let m;
100
+ if ((m = /^(?:nl\s+-ba\s+|cat\s+-n\s+)(\S+)\s*\|\s*sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s*$/.exec(c))) return [m[1], +m[2], +m[3]];
101
+ if ((m = /^sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s+(\S+)\s*$/.exec(c))) return [m[3], +m[1], +m[2]];
102
+ if ((m = /^head\s+-(?:n\s*)?(\d+)\s+(\S+)\s*$/.exec(c))) return [m[2], 1, +m[1]];
103
+ if ((m = /^cat\s+(?:-n\s+)?(\S+\.\w+)\s*$/.exec(c))) return [m[1], 1, 2000];
104
+ return null;
105
+ }
106
+
107
+ /** Minimal shell-words split (quotes and escapes) for extracting a grep pattern. */
108
+ function shellWords(s) {
109
+ const out = [];
110
+ let cur = '', q = null, any = false;
111
+ for (let i = 0; i < s.length; i++) {
112
+ const ch = s[i];
113
+ if (q) { if (ch === q) q = null; else if (ch === '\\' && q === '"' && i + 1 < s.length) cur += s[++i]; else cur += ch; continue; }
114
+ if (ch === '"' || ch === "'") { q = ch; any = true; continue; }
115
+ if (ch === '\\' && i + 1 < s.length) { cur += s[++i]; any = true; continue; }
116
+ if (/\s/.test(ch)) { if (cur || any) out.push(cur); cur = ''; any = false; continue; }
117
+ if (ch === '|' || ch === ';' || ch === '&') { if (cur || any) out.push(cur); break; }
118
+ cur += ch;
119
+ }
120
+ if (cur || any) out.push(cur);
121
+ return out;
122
+ }
123
+
124
+ /** The pattern of a grep-like shell command (first non-option word, or the value of -e). */
125
+ export function grepPattern(command) {
126
+ const m = SEARCH_CMD.exec(command ?? '');
127
+ if (!m) return null;
128
+ const words = shellWords(m[2]);
129
+ for (let i = 0; i < words.length; i++) {
130
+ const w = words[i];
131
+ if (w === '-e' || w === '--regexp') return words[i + 1] ?? null;
132
+ if (w.startsWith('-')) { if (/^-(A|B|C|m|g|t|T|-type|-glob|-include|-exclude|-max-count)$/.test(w)) i++; continue; }
133
+ return w;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ const hasIndex = (root) => fs.existsSync(path.join(root, DATA_DIR_NAME, 'index.db'));
139
+
140
+ /** The index for one hook call when no resident process answers: opened, synced, closed after. */
141
+ function localIndex(root) {
142
+ return async () => {
143
+ if (!hasIndex(root)) return null; // never build a whole index inside a tool hook
144
+ const { CodeIntel } = await import('../query/intel.mjs');
145
+ const intel = new CodeIntel({ root });
146
+ await intel.open();
147
+ return { intel, release: () => intel.close() };
148
+ };
149
+ }
150
+
151
+ async function afterEdit(root, input, acquire) {
152
+ const file = input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? input.tool_input?.path ?? null;
153
+ if (!file) return null;
154
+ const rel = path.isAbsolute(file) ? path.relative(root, file).split(path.sep).join('/') : file;
155
+ if (rel.startsWith('..') || !specForPath(rel)) return null;
156
+ const ix = await acquire();
157
+ if (!ix) return null;
158
+ const { intel } = ix;
159
+ try {
160
+ const { checkChanges } = await import('../query/check.mjs');
161
+ const r = await checkChanges(intel, { files: [rel], tests: false });
162
+ if (r.error) return null;
163
+ const lines = [];
164
+ for (const e of r.syntax.slice(0, 3)) lines.push(`${e.path}:${e.line} has a syntax error introduced since the last commit (${e.missing ? `missing ${e.missing}` : 'unexpected code'}): ${e.text.slice(0, 100)}`);
165
+ for (const a of r.arity.slice(0, 4)) {
166
+ const sites = a.sites.slice(0, 4).map(x => `${x.path}:${x.line} (passes ${x.argc.n})`).join(', ');
167
+ lines.push(a.newCall ? `The call at ${sites} does not fit ${a.target}, which takes ${a.is} argument(s).`
168
+ : `${a.target} now takes ${a.is} argument(s) (was ${a.was}); ${a.sites.length} of its ${a.total} call sites no longer fit: ${sites}${a.sites.length > 4 ? ', …' : ''}.`);
169
+ }
170
+ for (const m of r.removed.slice(0, 3)) {
171
+ const uses = m.uses.slice(0, 4).map(u => `${u.path}:${u.line}`).join(', ');
172
+ lines.push(`${m.qname} no longer exists in ${m.path} but is still used ${m.total + m.imports.length} time(s): ${uses}${m.total > 4 ? ', …' : ''}.`);
173
+ }
174
+ if (!lines.length) return null;
175
+ return `graph-indexer check after this edit:\n- ${lines.join('\n- ')}\n(check_changes lists everything, including the tests to run.)`;
176
+ } finally { ix.release(); }
177
+ }
178
+
179
+ async function afterGrep(root, input, crawling, acquire) {
180
+ const shell = !GREP_TOOLS.has(input.tool_name);
181
+ const pattern = shell ? grepPattern(input.tool_input?.command) : input.tool_input?.pattern;
182
+ if (!pattern || pattern.length > 200) return null;
183
+ const { identifierOf } = await import('../query/textsearch.mjs');
184
+ const literal = shell && /(^|\s)(-F|--fixed-strings)\b/.test(input.tool_input?.command ?? '');
185
+ const ident = identifierOf(pattern, literal);
186
+ if (!ident || ident.length < 3 || STOP_IDENTS.has(ident)) return null;
187
+ const out = responseText(input);
188
+ // a search for a definition that did not find it: say where it is (always — it replaces the
189
+ // next search). A search for uses is left alone unless the agent is crawling.
190
+ const defSearch = DEF_SEARCH.test(pattern) || (out != null && out.trim() === '');
191
+ const foundDef = out != null && new RegExp(`\\b(def|class|function|func|fn|interface|struct|trait|enum|type)\\s+${ident}\\b`).test(out);
192
+ if (!(defSearch && !foundDef) && !crawling) return null;
193
+ const ix = await acquire();
194
+ if (!ix) return null;
195
+ const { intel } = ix;
196
+ try {
197
+ const defs = intel.store.all(`SELECT s.id, s.qname, s.kind, s.start_line, s.end_line, s.sig, f.path, f.is_test,
198
+ (SELECT COUNT(*) FROM refs r WHERE r.dst_id = s.id) AS uses
199
+ FROM symbols s JOIN files f ON f.id = s.file_id
200
+ WHERE s.name = ? AND s.kind IN ('function','method','class','interface','struct','trait','enum','type','field','property','constant','constructor')
201
+ ORDER BY uses DESC, f.is_test, f.path`, ident);
202
+ // one entry per qualified name (overloads, re-declarations)
203
+ const byQ = new Map();
204
+ for (const d of defs) { const p = byQ.get(d.qname); if (!p) byQ.set(d.qname, { ...d }); else p.uses += d.uses; }
205
+ const list = [...byQ.values()].filter(d => !d.is_test || d.uses > 0);
206
+ if (!list.length) return null;
207
+ if (defSearch && !foundDef) {
208
+ const shown = list.slice(0, 3).map(d => `${d.qname} → ${d.path}:${d.start_line}-${d.end_line} ${String(d.sig ?? d.kind).replace(/\s+/g, ' ').slice(0, 110)}`);
209
+ return `graph-indexer: "${ident}" is defined at:\n ${shown.join('\n ')}${list.length > 3 ? `\n (${list.length - 3} more definitions with this name)` : ''}`;
210
+ }
211
+ const unbound = intel.store.get(`SELECT COUNT(*) AS n FROM refs WHERE name = ? AND dst_id IS NULL AND kind IN ('call','new')`, ident)?.n ?? 0;
212
+ const totalUses = list.reduce((n, d) => n + d.uses, 0);
213
+ if (list.length === 1 && totalUses < 8) return null; // one definition, few uses: grep output is already clear
214
+ const shown = list.slice(0, 5).map(d => `${d.qname} (${d.path}:${d.start_line}, ${d.uses} bound use${d.uses === 1 ? '' : 's'})`);
215
+ const head = list.length === 1
216
+ ? `graph-indexer: "${ident}" is ${shown[0]}`
217
+ : `graph-indexer: "${ident}" names ${list.length} different definitions — ${shown.join('; ')}${list.length > 5 ? `; ${list.length - 5} more` : ''}`;
218
+ return `${head}.${unbound ? ` ${unbound} same-name call${unbound === 1 ? ' has' : 's have'} a receiver of unknown type.` : ''} find_references on one qualified name lists only its uses, with the enclosing function of each.`;
219
+ } finally { ix.release(); }
220
+ }
221
+
222
+ /**
223
+ * After a read: the indirection the lines involve, resolved (which override runs, which table entry
224
+ * handles a key, what reaches a method by a built name, what a decorator is) — always, since it is
225
+ * silent when there is none; and while the agent is crawling, where the names the lines use are defined.
226
+ */
227
+ async function afterRead(root, input, state, crawling, acquire) {
228
+ let file = null, from = 1, to = 2000;
229
+ if (READ_TOOLS.has(input.tool_name)) {
230
+ file = input.tool_input?.file_path ?? input.tool_input?.path ?? input.tool_input?.absolute_path ?? null;
231
+ const off = Number(input.tool_input?.offset ?? 0), lim = Number(input.tool_input?.limit ?? 0);
232
+ if (off > 0) from = off;
233
+ if (lim > 0) to = from + lim - 1;
234
+ } else {
235
+ const r = shellRead(input.tool_input?.command);
236
+ if (!r) return null;
237
+ [file, from, to] = r;
238
+ }
239
+ if (!file) return null;
240
+ const cwd = input.cwd || root;
241
+ const abs = path.isAbsolute(file) ? file : path.join(cwd, file);
242
+ const rel = path.relative(root, abs).split(path.sep).join('/');
243
+ if (rel.startsWith('..') || !specForPath(rel)) return null;
244
+ const ix = await acquire();
245
+ if (!ix) return null;
246
+ const { intel } = ix;
247
+ try {
248
+ await intel.revalidate([rel]); // a long-lived index may not have seen the file change yet
249
+ const out = [];
250
+ const { factsForRange, renderFacts } = await import('../query/facts.mjs');
251
+ state.st.facts ??= [];
252
+ const facts = factsForRange(intel, rel, from, to, { max: 4, shown: new Set(state.st.facts) });
253
+ if (facts.length) {
254
+ for (const f of facts) state.st.facts.push(f.key);
255
+ out.push(`graph-indexer, resolved for these lines:\n${renderFacts(facts).map(l => ` ${l}`).join('\n')}`);
256
+ }
257
+ if (crawling) {
258
+ const { definitionsUsed } = await import('../query/read.mjs');
259
+ const n = Math.max(1, to - from + 1);
260
+ const { rows } = definitionsUsed(intel, rel, from, to, { max: Math.min(12, Math.max(4, Math.round(n / 15))), skipIds: new Set(state.st.shown) });
261
+ for (const r of rows) state.st.shown.push(r.id);
262
+ if (rows.length) out.push(`graph-indexer: where names used in these lines are defined (read them directly instead of searching):\n${rows.map(r => ` ${r.qname} → ${r.path}:${r.start_line} ${String(r.sig || r.kind).replace(/\s+/g, ' ').replace(/\s+#.*$/, '').slice(0, 100)}`).join('\n')}`);
263
+ }
264
+ return out.length ? out.join('\n') : null;
265
+ } finally { ix.release(); }
266
+ }
267
+
268
+ // the lookup rules, for sessions and subagents whose instruction files do not carry them
269
+ const RULES = `How to look code up in this repository (graph-indexer):
270
+ - Several things at once: several tool calls in one message, or one search with alternatives — not one search per turn.
271
+ - The function or the 50–100 lines you need, not whole files; to find a definition, search for its definition line (\`def name\`, \`class Name\`) across the package in one search.
272
+ - Exact uses of a function or class, even when other code shares its name: find_references (\`npx graph-indexer refs NAME\` in a shell). Before changing what other code relies on: change_impact; when you are done: check_changes, then run the tests it names, once.`;
273
+
274
+ async function sessionLine(root, { rules = false, acquire }) {
275
+ if (!hasIndex(root)) return null; // being built in the background (spawnResident); say nothing yet
276
+ const ix = await acquire();
277
+ if (!ix) return null;
278
+ const { intel } = ix;
279
+ try {
280
+ const st = intel.stats();
281
+ const line = `graph-indexer has a live index of this repository (${st.files} files, ${st.symbols} symbols). Its MCP tools answer with exact locations: find_references (exact uses, even when other code shares the name), change_impact (what a change affects and which tests to run), check_changes (what an edit broke, and the closest tests), call_graph, read_code (symbols or ranges by name, with where each name they use is defined), search_text, search_code and outline.`;
282
+ // the managed block of `init` already carries the rules; otherwise add them
283
+ const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => { try { return fs.readFileSync(path.join(root, f), 'utf8').includes('<!-- graph-indexer:start -->'); } catch { return false; } });
284
+ return rules || !carried ? `${line}\n${RULES}` : line;
285
+ } finally { ix.release(); }
286
+ }
287
+
288
+ /**
289
+ * One hook call: what the hook prints (in the calling host's format), or null. `acquire()` gives
290
+ * the index: opened for this call, or the resident process's own.
291
+ */
292
+ export async function handleHook(event, input, { root, acquire }) {
293
+ let text = null, name = input.hook_event_name ?? null;
294
+ if (event === 'post-tool') {
295
+ name ??= 'PostToolUse';
296
+ const tool = input.tool_name ?? input.toolName ?? '';
297
+ const command = input.tool_input?.command;
298
+ const state = sessionState(input);
299
+ if (EDIT_TOOLS.has(tool)) {
300
+ state.st.nav = 0;
301
+ state.save();
302
+ text = await afterEdit(root, input, acquire);
303
+ } else if (GREP_TOOLS.has(tool) || (SHELL_TOOLS.has(tool) && grepPattern(command))) {
304
+ state.st.nav++;
305
+ state.save();
306
+ text = await afterGrep(root, input, state.st.nav >= CRAWL, acquire);
307
+ } else if (READ_TOOLS.has(tool) || (SHELL_TOOLS.has(tool) && shellRead(command))) {
308
+ state.st.nav++;
309
+ text = await afterRead(root, input, state, state.st.nav >= CRAWL, acquire);
310
+ state.save();
311
+ }
312
+ } else if (event === 'session-start' || event === 'subagent-start') {
313
+ name ??= event === 'session-start' ? 'SessionStart' : 'SubagentStart';
314
+ text = await sessionLine(root, { rules: event === 'subagent-start', acquire });
315
+ }
316
+ return format(name, text, input, event === 'post-tool' ? MAX_CONTEXT : 2500);
317
+ }
318
+
319
+ /**
320
+ * Start a resident process (`graph-indexer daemon`) so later hook calls skip opening the index; at
321
+ * session start it also builds a missing index. At most once a minute per repository, and never
322
+ * from a tool hook in a repository without an index.
323
+ */
324
+ function spawnResident(root, event) {
325
+ const starting = event === 'session-start' || event === 'subagent-start';
326
+ if (!starting && !hasIndex(root)) return;
327
+ const bin = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin/graph-indexer.mjs');
328
+ const off = process.env.GRAPH_INDEXER_RESIDENT === '0';
329
+ if (off && (!starting || hasIndex(root))) return;
330
+ try {
331
+ const mark = path.join(dataDir(root), 'resident.started');
332
+ try { if (Date.now() - fs.statSync(mark).mtimeMs < 60_000) return; } catch { /* first time */ }
333
+ fs.writeFileSync(mark, String(Date.now()));
334
+ // without a resident, still build a missing index in the background so the first query is fast
335
+ spawn(process.execPath, [bin, off ? 'index' : 'daemon', '--repo', root], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
336
+ } catch { /* optional */ }
337
+ }
338
+
339
+ export async function runHook(event, { repo = null, version = null } = {}) {
340
+ const deadlineMs = event === 'post-tool' ? 4000 : 3000;
341
+ const timer = setTimeout(() => process.exit(0), deadlineMs); // fail open: never hold the agent up
342
+ try {
343
+ const input = await readStdin();
344
+ const root = findRepoRoot(input.cwd || repo || process.cwd());
345
+ const { askResident, residentRequired } = await import('./resident.mjs');
346
+ const reply = await askResident(root, { op: 'hook', event, input }, { version, totalMs: 2000 });
347
+ let out = null;
348
+ if (reply) out = reply.out;
349
+ else if (!residentRequired()) {
350
+ out = await handleHook(event, input, { root, acquire: localIndex(root) });
351
+ spawnResident(root, event);
352
+ }
353
+ if (out) process.stdout.write(out + '\n');
354
+ } catch { /* fail open */ }
355
+ clearTimeout(timer);
356
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * `graph-indexer init`: wire the MCP server into the coding agents used in this repository.
3
+ *
4
+ * Writes project-level MCP configuration (merged, never clobbering other servers) and, unless
5
+ * --no-instructions, a short managed block in AGENTS.md / CLAUDE.md naming the tools and when to
6
+ * use them (a few lines — long generated context files measurably hurt agents). For Claude Code it
7
+ * also writes, unless --no-helper, the structural helper sub-agent (.claude/agents/code-structure.md,
8
+ * src/cli/helper.mjs). Idempotent.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { findRepoRoot } from '../util/paths.mjs';
14
+ import { HELPER_NAME, claudeAgentFile } from './helper.mjs';
15
+
16
+ const BLOCK_START = '<!-- graph-indexer:start -->';
17
+ const BLOCK_END = '<!-- graph-indexer:end -->';
18
+ // The lookup rules first, then graph-indexer for what a text search cannot answer exactly: the
19
+ // arrangement that cost least in the fourth benchmark round (docs/AGENTIC-BENCHMARK.md). Sending
20
+ // every read and search through graph-indexer cost more on issue fixes than the rules alone.
21
+ const SNIPPET = `${BLOCK_START}
22
+ ## Looking code up (graph-indexer)
23
+ - Explore in one pass: when you need several definitions or files, ask for them together — several tool calls in one message, or one search with alternatives — not one search per turn.
24
+ - Read keyholes, not files: the function or the 50–100 lines you need (find the line first, then read that range).
25
+ - To find where a name is defined, search for its definition line (\`def name\`, \`class Name\`, \`function name\`) across the package in one search, not directory by directory.
26
+ - Check once: run the tests that cover your change when you are done; do not re-run a check nothing has changed.
27
+
28
+ The \`graph-indexer\` MCP server answers what a text search cannot answer exactly:
29
+ - Uses of a function, method or class that other code shares a name with: \`find_references\` (exact call sites, says when the list is complete; kind \`inherit\` for subclasses and implementations). Callers of callers: \`call_graph\`.
30
+ - Before changing a signature other code relies on: \`change_impact\`; after: \`check_changes\`.
31
+ ${BLOCK_END}`;
32
+
33
+ /** The managed instructions block `init` writes to CLAUDE.md / AGENTS.md (markers included). */
34
+ export function managedBlock() { return SNIPPET; }
35
+
36
+ const AGENTS = {
37
+ claude: { label: 'Claude Code', file: '.mcp.json', key: 'mcpServers', detect: ['.claude', 'CLAUDE.md', '.mcp.json'], instructions: 'CLAUDE.md' },
38
+ cursor: { label: 'Cursor', file: '.cursor/mcp.json', key: 'mcpServers', detect: ['.cursor', '.cursorrules'], instructions: 'AGENTS.md', workspaceVar: '${workspaceFolder}' },
39
+ vscode: { label: 'VS Code (Copilot)', file: '.vscode/mcp.json', key: 'servers', detect: ['.vscode'], instructions: 'AGENTS.md', workspaceVar: '${workspaceFolder}', vscode: true },
40
+ gemini: { label: 'Gemini CLI', file: '.gemini/settings.json', key: 'mcpServers', detect: ['.gemini', 'GEMINI.md'], instructions: 'AGENTS.md' },
41
+ codex: { label: 'Codex CLI', file: null, detect: ['AGENTS.md', '.codex'], instructions: 'AGENTS.md' },
42
+ // Kilo Code (CLI and VS Code, rebuilt on OpenCode) also reads opencode.json; an existing kilo.json is used instead
43
+ opencode: { label: 'OpenCode / Kilo Code', file: 'opencode.json', alt: ['kilo.json', '.kilo/kilo.json'], key: 'mcp', detect: ['opencode.json', 'opencode.jsonc', '.opencode', 'kilo.json', 'kilo.jsonc', '.kilo', '.kilocode'], instructions: 'AGENTS.md', opencode: true },
44
+ junie: { label: 'Junie', file: '.junie/mcp/mcp.json', key: 'mcpServers', detect: ['.junie'], instructions: 'AGENTS.md' },
45
+ // Zed runs project context servers in the project root; it reads the first of .rules, …, AGENTS.md, CLAUDE.md
46
+ zed: { label: 'Zed', file: '.zed/settings.json', key: 'context_servers', detect: ['.zed'], instructions: 'AGENTS.md', zed: true },
47
+ // Devin Desktop (formerly Windsurf) and Devin CLI load the Claude Code hooks in .claude/ by default
48
+ devin: { label: 'Devin Desktop / Devin CLI', file: null, detect: ['.devin', '.windsurf', '.windsurfrules'], instructions: 'AGENTS.md' },
49
+ };
50
+
51
+ function readJson(p) {
52
+ try { return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return null; }
53
+ }
54
+
55
+ function serverEntry(agent, { local }) {
56
+ const here = path.dirname(fileURLToPath(import.meta.url));
57
+ const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
58
+ const repoArgs = agent.workspaceVar ? ['--repo', agent.workspaceVar] : [];
59
+ const base = local
60
+ ? { command: process.execPath, args: [bin, 'serve', ...repoArgs] }
61
+ : { command: 'npx', args: ['-y', 'graph-indexer@3', 'serve', ...repoArgs] };
62
+ if (agent.opencode) return { type: 'local', command: [base.command, ...base.args], enabled: true };
63
+ if (agent.zed) return { ...base, env: {} };
64
+ return agent.vscode ? { type: 'stdio', ...base } : base;
65
+ }
66
+
67
+ /** The config file to edit: the agent's default, or an existing alternative it also reads. */
68
+ function configFile(agent, repo) {
69
+ for (const f of agent.alt ?? []) if (fs.existsSync(path.join(repo, f))) return f;
70
+ return agent.file;
71
+ }
72
+
73
+ function upsertBlock(file, dryRun) {
74
+ const exists = fs.existsSync(file);
75
+ const text = exists ? fs.readFileSync(file, 'utf8') : '';
76
+ let next;
77
+ if (text.includes(BLOCK_START) && text.includes(BLOCK_END)) {
78
+ next = text.replace(new RegExp(`${BLOCK_START}[\\s\\S]*?${BLOCK_END}`), SNIPPET);
79
+ } else {
80
+ next = (text.trimEnd() ? text.trimEnd() + '\n\n' : '') + SNIPPET + '\n';
81
+ }
82
+ if (next === text) return 'unchanged';
83
+ if (!dryRun) fs.writeFileSync(file, next);
84
+ return exists ? 'updated' : 'created';
85
+ }
86
+
87
+ /** An installed `graph-indexer` executable on PATH, if any (hooks start in ~0.1 s instead of npx's ~1 s). */
88
+ function installedBin() {
89
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
90
+ if (!dir) continue;
91
+ const f = path.join(dir, process.platform === 'win32' ? 'graph-indexer.cmd' : 'graph-indexer');
92
+ try { if (fs.statSync(f).isFile()) return f; } catch { /* not here */ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** The command agents run for graph-indexer: this installation with --local, an installed binary, or npx. */
98
+ function cliCommand(local) {
99
+ const here = path.dirname(fileURLToPath(import.meta.url));
100
+ const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
101
+ if (local) return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
102
+ const installed = installedBin();
103
+ return installed ? JSON.stringify(installed) : 'npx -y graph-indexer@3';
104
+ }
105
+
106
+ /** The command that starts graph-indexer, as an argv array (for the OpenCode/Kilo Code plugin). */
107
+ function cliArgv(local) {
108
+ const here = path.dirname(fileURLToPath(import.meta.url));
109
+ if (local) return [process.execPath, path.resolve(here, '../../bin/graph-indexer.mjs')];
110
+ const installed = installedBin();
111
+ return installed ? [installed] : ['npx', '-y', 'graph-indexer@3'];
112
+ }
113
+
114
+ /**
115
+ * The OpenCode / Kilo Code plugin (integrations/opencode/graph-indexer.js): after reads, searches
116
+ * and edits it appends what the Claude Code hooks add as context. Kilo Code loads project plugins
117
+ * from .kilo/plugin/, OpenCode from .opencode/plugins/.
118
+ */
119
+ function writeOpenCodePlugin(repo, local, dryRun) {
120
+ const here = path.dirname(fileURLToPath(import.meta.url));
121
+ const src = fs.readFileSync(path.resolve(here, '../../integrations/opencode/graph-indexer.js'), 'utf8')
122
+ .replace(/^const DAEMON = .*$/m, `const DAEMON = ${JSON.stringify([...cliArgv(local), 'daemon'])};`);
123
+ const kilo = ['kilo.json', 'kilo.jsonc', '.kilo', '.kilocode'].some(f => fs.existsSync(path.join(repo, f)));
124
+ const open = ['opencode.json', 'opencode.jsonc', '.opencode'].some(f => fs.existsSync(path.join(repo, f)));
125
+ const targets = [...(kilo ? ['.kilo/plugin/graph-indexer.js'] : []), ...(open || !kilo ? ['.opencode/plugins/graph-indexer.js'] : [])];
126
+ const out = [];
127
+ for (const rel of targets) {
128
+ const file = path.join(repo, rel);
129
+ const same = fs.existsSync(file) && fs.readFileSync(file, 'utf8') === src;
130
+ if (!same && !dryRun) {
131
+ fs.mkdirSync(path.dirname(file), { recursive: true });
132
+ fs.writeFileSync(file, src);
133
+ }
134
+ out.push(`${same ? 'already in' : 'written to'} ${rel}`);
135
+ }
136
+ return `OpenCode / Kilo Code plugin (definitions after reads, missed definitions, edit check): ${out.join(', ')}`;
137
+ }
138
+
139
+ /**
140
+ * Claude Code hooks (project .claude/settings.json), merged with existing ones: after edits the
141
+ * edit checker runs on the edited file, after a grep for a shared identifier the definitions are
142
+ * told apart, and sessions/subagents learn in one line that the index exists. Silent otherwise.
143
+ */
144
+ function upsertClaudeHooks(repo, local, dryRun) {
145
+ const file = path.join(repo, '.claude', 'settings.json');
146
+ const cfg = readJson(file) ?? {};
147
+ const cmd = cliCommand(local);
148
+ const ours = (h) => /graph-indexer(\.mjs|\.cmd)?"?(@\d+)? hook /.test(h?.command ?? '');
149
+ const want = {
150
+ PostToolUse: { matcher: 'Edit|Write|MultiEdit|Grep|Bash|Read', hooks: [{ type: 'command', command: `${cmd} hook post-tool`, timeout: 10 }] },
151
+ SessionStart: { hooks: [{ type: 'command', command: `${cmd} hook session-start`, timeout: 10 }] },
152
+ SubagentStart: { hooks: [{ type: 'command', command: `${cmd} hook subagent-start`, timeout: 10 }] },
153
+ };
154
+ cfg.hooks ??= {};
155
+ const before = JSON.stringify(cfg.hooks);
156
+ for (const [event, entry] of Object.entries(want)) {
157
+ const list = (cfg.hooks[event] ?? []).map(e => ({ ...e, hooks: (e.hooks ?? []).filter(h => !ours(h)) })).filter(e => e.hooks.length);
158
+ list.push(entry);
159
+ cfg.hooks[event] = list;
160
+ }
161
+ const changed = before !== JSON.stringify(cfg.hooks);
162
+ if (changed && !dryRun) {
163
+ fs.mkdirSync(path.dirname(file), { recursive: true });
164
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
165
+ }
166
+ return `Claude Code hooks: ${changed ? 'written to' : 'already in'} .claude/settings.json (post-edit check, grep disambiguation, session line)`;
167
+ }
168
+
169
+ /**
170
+ * The structural helper for Claude Code (.claude/agents/code-structure.md). A file of that name the
171
+ * user wrote is left alone; an earlier version of ours is updated.
172
+ */
173
+ function upsertClaudeHelper(repo, dryRun) {
174
+ const rel = `.claude/agents/${HELPER_NAME}.md`;
175
+ const file = path.join(repo, rel);
176
+ const want = claudeAgentFile();
177
+ const have = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
178
+ if (have === want) return `Claude Code: structural helper already in ${rel}`;
179
+ if (have != null && !have.includes("graph-indexer's index")) return `Claude Code: ${rel} exists and is not graph-indexer's, so it was left as is`;
180
+ if (!dryRun) {
181
+ fs.mkdirSync(path.dirname(file), { recursive: true });
182
+ fs.writeFileSync(file, want);
183
+ }
184
+ return `Claude Code: structural helper ${have == null ? 'written to' : 'updated in'} ${rel} (a sub-agent on a small model that answers call-site, caller, subclass and impact questions from the index)`;
185
+ }
186
+
187
+ export async function runInit({ opt, flag, repo: repoArg = null }) {
188
+ const repo = path.resolve(repoArg ?? opt('--repo') ?? findRepoRoot(process.cwd()));
189
+ const dryRun = flag('--dry-run');
190
+ const local = flag('--local');
191
+ const hooks = flag('--hooks');
192
+ const noInstructions = flag('--no-instructions');
193
+ const noHelper = flag('--no-helper');
194
+ const all = flag('--all');
195
+ const agentList = opt('--agents');
196
+ let chosen;
197
+ if (agentList) chosen = agentList.split(',').map(s => s.trim()).filter(k => AGENTS[k]);
198
+ else if (all) chosen = Object.keys(AGENTS);
199
+ else {
200
+ chosen = Object.entries(AGENTS).filter(([, a]) => a.detect.some(d => fs.existsSync(path.join(repo, d)))).map(([k]) => k);
201
+ if (!chosen.length) chosen = ['claude'];
202
+ }
203
+ const report = [];
204
+ for (const key of chosen) {
205
+ const a = AGENTS[key];
206
+ if (a.file) {
207
+ const rel = configFile(a, repo);
208
+ const file = path.join(repo, rel);
209
+ const raw = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
210
+ const entry = serverEntry(a, { local });
211
+ // never rewrite a file whose comments or JSONC syntax a JSON round trip would lose
212
+ const cfg = raw == null ? {} : readJson(file);
213
+ if (!cfg || /^\s*\/\/|\/\*/m.test(raw ?? '')) {
214
+ const already = JSON.stringify(cfg?.[a.key]?.['graph-indexer'] ?? null) === JSON.stringify(entry);
215
+ report.push(already ? `${a.label}: already configured ${rel}`
216
+ : `${a.label}: ${rel} has comments or is not plain JSON, so it was left as is; add under "${a.key}": "graph-indexer": ${JSON.stringify(entry)}`);
217
+ continue;
218
+ }
219
+ cfg[a.key] ??= {};
220
+ const before = JSON.stringify(cfg[a.key]['graph-indexer'] ?? null);
221
+ cfg[a.key]['graph-indexer'] = entry;
222
+ const changed = before !== JSON.stringify(entry);
223
+ if (changed && !dryRun) {
224
+ fs.mkdirSync(path.dirname(file), { recursive: true });
225
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
226
+ }
227
+ report.push(`${a.label}: ${changed ? (before === 'null' ? 'added' : 'updated') : 'already configured'} ${rel}`);
228
+ } else if (key === 'codex') {
229
+ const entry = serverEntry(a, { local });
230
+ report.push(`Codex CLI: add to ~/.codex/config.toml →\n [mcp_servers.graph-indexer]\n command = "${entry.command}"\n args = ${JSON.stringify(entry.args)}\n cwd = "${repo}"`);
231
+ } else if (key === 'devin') {
232
+ const entry = serverEntry(a, { local });
233
+ report.push(`Devin: reads AGENTS.md${hooks ? ' and the Claude Code hooks in .claude/settings.json' : ' (and, with --hooks, the Claude Code hooks)'}; add the MCP server in Devin's MCP settings → command "${entry.command}", args ${JSON.stringify([...entry.args, '--repo', repo])}`);
234
+ }
235
+ }
236
+ if (!noHelper && chosen.includes('claude')) report.push(upsertClaudeHelper(repo, dryRun));
237
+ // Devin Desktop/CLI, Copilot CLI and Cursor also run the Claude Code hooks in .claude/settings.json
238
+ if (hooks && (chosen.includes('claude') || chosen.includes('devin'))) report.push(upsertClaudeHooks(repo, local, dryRun));
239
+ if (hooks && chosen.includes('opencode')) report.push(writeOpenCodePlugin(repo, local, dryRun));
240
+ if (!noInstructions) {
241
+ const targets = [...new Set(chosen.map(k => AGENTS[k].instructions))];
242
+ for (const t of targets) report.push(`${t}: ${upsertBlock(path.join(repo, t), dryRun)} graph-indexer instructions block`);
243
+ }
244
+ // keep the index out of version control
245
+ const gi = path.join(repo, '.gitignore');
246
+ const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
247
+ if (!/^\/?\.graph-indexer\/?$/m.test(giText)) {
248
+ if (!dryRun) fs.writeFileSync(gi, (giText.trimEnd() ? giText.trimEnd() + '\n' : '') + '.graph-indexer/\n');
249
+ report.push('.gitignore: added .graph-indexer/');
250
+ }
251
+ process.stdout.write(`graph-indexer init ${dryRun ? '(dry run) ' : ''}in ${repo}\n ${report.join('\n ')}\n\n` +
252
+ `Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args ["-y","graph-indexer@3","serve","--repo","${repo}"].\n` +
253
+ `Build the index now with: npx graph-indexer index (the server also builds it on first start)\n` +
254
+ (hooks ? '' : `Optional: graph-indexer init --hooks adds Claude Code hooks (edit check after each edit, grep disambiguation).\n`));
255
+ }