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.
- package/CHANGELOG.md +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +31 -105
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- package/watch-daemon.mjs +0 -289
package/src/cli/hook.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/cli/init.mjs
ADDED
|
@@ -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
|
+
}
|