graph-indexer 3.0.1 → 3.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 (52) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +61 -8
  3. package/bin/graph-indexer.mjs +46 -5
  4. package/integrations/claude-code/.claude-plugin/plugin.json +1 -1
  5. package/integrations/claude-code/agents/code-structure.md +0 -3
  6. package/integrations/claude-code/skills/setup/SKILL.md +26 -0
  7. package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
  8. package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
  9. package/integrations/claude-code-local/README.md +10 -0
  10. package/integrations/claude-code-local/agents/code-structure.md +22 -0
  11. package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +3 -1
  12. package/package.json +1 -1
  13. package/src/cli/helper.mjs +15 -1
  14. package/src/cli/hook.mjs +123 -41
  15. package/src/cli/init.mjs +16 -1
  16. package/src/cli/resident.mjs +1 -1
  17. package/src/index/indexer.mjs +77 -41
  18. package/src/index/modules.mjs +76 -2
  19. package/src/index/parse-worker.mjs +12 -0
  20. package/src/index/parsepool.mjs +73 -0
  21. package/src/index/resolver.mjs +362 -23
  22. package/src/mcp/server.mjs +16 -12
  23. package/src/mcp/tools.mjs +417 -25
  24. package/src/parse/arity.mjs +39 -2
  25. package/src/parse/extract.mjs +269 -45
  26. package/src/parse/lang/ecma.mjs +2 -0
  27. package/src/parse/lang/go.mjs +2 -0
  28. package/src/parse/lang/jvm.mjs +33 -0
  29. package/src/parse/lang/markdown.mjs +92 -0
  30. package/src/parse/lang/python.mjs +43 -0
  31. package/src/parse/lang/rust.mjs +215 -15
  32. package/src/parse/lang/sfc.mjs +125 -0
  33. package/src/parse/languages.mjs +9 -2
  34. package/src/query/check.mjs +49 -13
  35. package/src/query/facts.mjs +3 -2
  36. package/src/query/injection-spring.mjs +405 -0
  37. package/src/query/injection.mjs +173 -0
  38. package/src/query/intel.mjs +76 -14
  39. package/src/query/issue.mjs +156 -0
  40. package/src/query/orm.mjs +286 -0
  41. package/src/query/read.mjs +6 -1
  42. package/src/query/report.mjs +53 -0
  43. package/src/query/routes.mjs +142 -0
  44. package/src/query/showcase.mjs +59 -0
  45. package/src/search/search.mjs +85 -17
  46. package/src/search/semantic.mjs +201 -0
  47. package/src/search/vectors.mjs +122 -0
  48. package/src/store/db.mjs +22 -4
  49. package/vendor/grammars/README.md +47 -0
  50. package/vendor/grammars/kotlin.wasm.gz +0 -0
  51. package/vendor/grammars/manifest.json +4 -4
  52. /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
@@ -6,7 +6,8 @@
6
6
  * graph-indexer index [--repo DIR] build / update the index and print stats
7
7
  * graph-indexer init [--repo DIR] [--agents …] wire the MCP server into your coding agents
8
8
  * graph-indexer status [--repo DIR]
9
- * graph-indexer search <query> [--path P] [--kind K] [--limit N]
9
+ * graph-indexer explore <task in words> [--symbols a,b] [--path P] [--max-tokens N]
10
+ * graph-indexer search <query> [--or QUERY]… [--path P] [--kind K] [--limit N]
10
11
  * graph-indexer read <target>… [--full] symbols, file:START-END ranges or files, with the
11
12
  * definitions each one uses
12
13
  * graph-indexer symbol <name>… [--no-code]
@@ -15,6 +16,7 @@
15
16
  * graph-indexer impact [--symbols a,b] [--files x,y] [--diff] [--depth N]
16
17
  * graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
17
18
  * graph-indexer files <text|glob> [--path P]
19
+ * graph-indexer demo grep versus exact uses, on this repository
18
20
  *
19
21
  * The query commands print exactly what the MCP tools return, so humans (and agents limited to a
20
22
  * shell) get the same answers.
@@ -33,6 +35,8 @@ let cmd = aliasOf[invokedAs] ?? null;
33
35
  if (!cmd && argv.length && !argv[0].startsWith('-')) { const c = argv.shift(); cmd = aliasOf[c] ?? c; }
34
36
 
35
37
  function flag(name) { const i = argv.indexOf(name); if (i < 0) return false; argv.splice(i, 1); return true; }
38
+ /** Every value of a repeatable option (`--or a --or b`). */
39
+ function opts(name) { const out = []; let v; while ((v = opt(name)) != null) out.push(v); return out; }
36
40
  function opt(name, dflt = null) {
37
41
  const i = argv.indexOf(name);
38
42
  if (i < 0) return dflt;
@@ -120,13 +124,21 @@ async function serveResident(intel, { idleMs = 0, onIdle = null } = {}) {
120
124
  const HELP = `graph-indexer ${pkg.version} — live code graph & search for AI coding agents (MCP)
121
125
 
122
126
  Usage:
123
- graph-indexer init [--repo DIR] [--agents claude,cursor,vscode,gemini,codex,opencode,junie,zed,devin] [--all] [--hooks] [--local] [--dry-run] [--no-instructions] [--no-helper]
127
+ graph-indexer init [--repo DIR] [--agents claude,cursor,vscode,gemini,codex,opencode,junie,zed,devin] [--all] [--hooks] [--local] [--dry-run] [--no-instructions] [--no-helper] [--no-demo]
124
128
  graph-indexer serve [--repo DIR] start the MCP server on stdio
125
129
  graph-indexer index [--repo DIR] build or update the index
126
130
  graph-indexer status [--repo DIR]
127
- graph-indexer search <query> [--path P] [--kind K] [--limit N]
131
+ graph-indexer explore <task in words> [--or QUERY]… [--symbols a,b] [--path P] [--max-tokens N]
132
+ relevant code, callers, definitions used and tests
133
+ graph-indexer search <query> [--or QUERY]… [--path P] [--kind K] [--limit N]
134
+ --or: other phrasings of the same need, fused
128
135
  graph-indexer grep <regex> [--path P] [--literal|-F] [-i] [--limit N]
129
136
  graph-indexer files <text|glob> [--path P] [--limit N] find files by path or name
137
+ graph-indexer demo a method that shares its name: grep versus its exact uses
138
+ graph-indexer report summary to paste into an issue (no paths, names or code)
139
+ graph-indexer routes [path | METHOD /path] HTTP routes and their handlers
140
+ graph-indexer inject [token | class] dependency injection: tokens, providers, injection points
141
+ graph-indexer models [name | table] ORM models: table, migrations, Prisma client calls
130
142
  graph-indexer read <target>… [--full] [--max-lines N] symbols (Class.method), ranges (file:10-80) or files,
131
143
  with where each name they use is defined
132
144
  graph-indexer symbol <name>… [--no-code] one or several definitions
@@ -136,6 +148,8 @@ Usage:
136
148
  graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
137
149
  graph-indexer check [--files x,y] [--base REV] verify uncommitted edits
138
150
  graph-indexer hook post-tool|session-start|subagent-start agent hook (JSON on stdin)
151
+ graph-indexer semantic install|status optional static code-embedding model (32 MB, MIT) for
152
+ search in words; used with GRAPH_INDEXER_SEMANTIC=1
139
153
  graph-indexer daemon [--idle-minutes N] keep the index open for hooks and CLI queries
140
154
  (the hooks start it; the MCP server does the same)
141
155
 
@@ -170,6 +184,17 @@ async function main() {
170
184
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, stop);
171
185
  return;
172
186
  }
187
+ case 'semantic': {
188
+ const { installModel, modelDir, semanticEnabled, MODEL } = await import('../src/search/semantic.mjs');
189
+ const sub = argv.shift() ?? 'status';
190
+ if (sub === 'install') {
191
+ const dir = await installModel({ log });
192
+ process.stdout.write(`${MODEL.repo}@${MODEL.revision.slice(0, 12)} (${MODEL.license}) installed in ${dir}\nTurn the semantic channel on with GRAPH_INDEXER_SEMANTIC=1.\n`);
193
+ } else {
194
+ process.stdout.write(JSON.stringify({ model: MODEL.repo, revision: MODEL.revision, dir: modelDir(), installed: fs.existsSync(path.join(modelDir(), 'model.safetensors')), enabled: semanticEnabled() }, null, 2) + '\n');
195
+ }
196
+ return;
197
+ }
173
198
  case 'index': {
174
199
  const t0 = Date.now();
175
200
  const intel = await openIntel();
@@ -191,9 +216,13 @@ async function main() {
191
216
  await runInit({ argv, opt, flag, log, version: pkg.version, repo: repoArg });
192
217
  return;
193
218
  }
219
+ case 'explore': {
220
+ const symbols = (opt('--symbols') ?? '').split(',').filter(Boolean); const pathF = opt('--path'); const maxTokens = Number(opt('--max-tokens', 2000)); const queries = opts('--or');
221
+ return runTool('explore', { query: argv.join(' '), queries, symbols, path: pathF, max_tokens: maxTokens });
222
+ }
194
223
  case 'search': {
195
- const pathF = opt('--path'); const kind = opt('--kind'); const limit = Number(opt('--limit', 8));
196
- return runTool('search_code', { query: argv.join(' '), path: pathF, kind, limit });
224
+ const pathF = opt('--path'); const kind = opt('--kind'); const limit = Number(opt('--limit', 8)); const queries = opts('--or');
225
+ return runTool('search_code', { query: argv.join(' '), queries, path: pathF, kind, limit });
197
226
  }
198
227
  case 'grep': {
199
228
  const pathF = opt('--path'); const literal = flag('--literal') || flag('-F'); const ic = flag('-i') || flag('--ignore-case'); const limit = Number(opt('--limit', 60));
@@ -234,6 +263,18 @@ async function main() {
234
263
  const files = (opt('--files') ?? '').split(',').filter(Boolean); const base = opt('--base', 'HEAD');
235
264
  return runTool('check_changes', { files, base });
236
265
  }
266
+ case 'demo': return runTool('demo', {});
267
+ case 'routes': return runTool('routes', { filter: argv.join(' ') });
268
+ case 'inject': return runTool('inject', { filter: argv.join(' ') });
269
+ case 'models': return runTool('models', { filter: argv.join(' ') });
270
+ case 'report': {
271
+ // for bug reports: sizes, languages, binding rates and latencies — no paths, names or code
272
+ const intel = await openIntel({ quiet: true });
273
+ const { reportText } = await import('../src/query/report.mjs');
274
+ process.stdout.write(await reportText(intel, { version: pkg.version }) + '\n');
275
+ intel.close();
276
+ return;
277
+ }
237
278
  case 'files': {
238
279
  const pathF = opt('--path'); const limit = Number(opt('--limit', 100));
239
280
  return runTool('find_files', { pattern: argv.join(' '), path: pathF, limit });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Live code graph for Claude Code: exact references, structural grep, change impact and a post-edit check, from a local index with no model or network.",
5
5
  "author": { "name": "MaquinaTech", "url": "https://github.com/MaquinaTech" },
6
6
  "homepage": "https://github.com/MaquinaTech/graph-indexer",
@@ -1,9 +1,6 @@
1
1
  ---
2
2
  name: code-structure
3
3
  description: "Exact answers to structural questions about this repository's code, from graph-indexer's index, on a small fast model: every call site of a function or method (told apart from same-name methods of other classes), callers of callers, classes that extend or implement a type, and what a change would affect with the tests that cover it. Give it the symbol (Class.method, with its file when you know it) and the scope (directories, whether tests count, how many levels); it replies with path:line lists and says whether they are complete. Delegate these instead of grepping for names and reading files yourself."
4
- model: haiku
5
- disallowedTools: Edit, Write, MultiEdit, NotebookEdit
6
- omitClaudeMd: true
7
4
  ---
8
5
  You answer structural questions about the code in this repository — where something is used, who calls it, what extends or implements it, what a change would affect — exactly and briefly, from graph-indexer's index (the graph-indexer tools). The index binds every reference through scopes, imports and receiver types, so methods that only share a name are kept apart, and it re-syncs with the files before every answer.
9
6
 
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: setup
3
+ description: Connect graph-indexer's MCP server to this repository so Claude Code can use exact references, call graphs and change impact. Use when the user installs this plugin, or when the graph-indexer tools (find_references, call_graph, change_impact) are not available.
4
+ ---
5
+ # Connect graph-indexer
6
+
7
+ This plugin brings the `code-structure` sub-agent and these instructions. The graph-indexer tools
8
+ come from a local MCP server that the user adds to the repository once. The plugin directory does not
9
+ allow plugins to start local programs, so the user must approve this step.
10
+
11
+ 1. Check that Node.js 22.23.4+, 24.22.1+ or 26.12.1+ is installed (`node --version`).
12
+ 2. Ask the user to confirm, then run from the repository root:
13
+
14
+ ```bash
15
+ claude mcp add graph-indexer -- npx -y --prefix / graph-indexer@3.1.0 serve
16
+ ```
17
+
18
+ `--prefix /` makes npx run only the published package, never a copy shipped inside the repository.
19
+ The version is pinned on purpose: update it by hand when a newer graph-indexer is released.
20
+
21
+ 3. Optional, with the user's consent: `npx -y --prefix / graph-indexer@3.1.0 init --hooks` also writes the
22
+ Claude Code hooks (an edit check after each edit, grep disambiguation) to `.claude/settings.json`.
23
+ 4. Restart Claude Code, or run `/mcp`, and confirm `graph-indexer` is connected.
24
+
25
+ Then use `find_references`, `call_graph`, `change_impact`, `read_code` and `check_changes`, or delegate
26
+ structural questions to the `code-structure` sub-agent.
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "graph-indexer",
3
+ "version": "3.1.0",
4
+ "description": "Live code graph for Claude Code: exact references, structural grep, change impact and a post-edit check, from a local index with no model or network.",
5
+ "author": { "name": "MaquinaTech", "url": "https://github.com/MaquinaTech" },
6
+ "homepage": "https://github.com/MaquinaTech/graph-indexer",
7
+ "license": "MIT",
8
+ "icon": "https://raw.githubusercontent.com/MaquinaTech/graph-indexer/main/assets/logo.jpg"
9
+ }
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "graph-indexer": {
4
4
  "command": "npx",
5
- "args": ["-y", "--prefix", "/", "graph-indexer@3.0.1", "serve"]
5
+ "args": ["-y", "--prefix", "/", "graph-indexer@3.1.0", "serve"]
6
6
  }
7
7
  }
8
8
  }
@@ -0,0 +1,10 @@
1
+ # graph-indexer: full Claude Code plugin (manual install)
2
+
3
+ This folder is the plugin with everything wired in: the MCP server (`.mcp.json`), the hooks
4
+ (`hooks/`) and the `code-structure` sub-agent. The plugin directory lists only plugins made of
5
+ Markdown and JSON (see `../claude-code`), so this one is not listed there. To use it, copy the
6
+ folder or point a local marketplace at it. `npx -y --prefix / graph-indexer@3.1.0 init --hooks`
7
+ gives the same result without a plugin.
8
+
9
+ Each release must update the version in `.mcp.json`, `hooks/gi-hook.mjs` and
10
+ `.claude-plugin/plugin.json`; `test/plugin-pins.test.mjs` checks that they equal `package.json`.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: code-structure
3
+ description: "Exact answers to structural questions about this repository's code, from graph-indexer's index, on a small fast model: every call site of a function or method (told apart from same-name methods of other classes), callers of callers, classes that extend or implement a type, and what a change would affect with the tests that cover it. Give it the symbol (Class.method, with its file when you know it) and the scope (directories, whether tests count, how many levels); it replies with path:line lists and says whether they are complete. Delegate these instead of grepping for names and reading files yourself."
4
+ model: haiku
5
+ disallowedTools: Edit, Write, MultiEdit, NotebookEdit
6
+ omitClaudeMd: true
7
+ ---
8
+ You answer structural questions about the code in this repository — where something is used, who calls it, what extends or implements it, what a change would affect — exactly and briefly, from graph-indexer's index (the graph-indexer tools). The index binds every reference through scopes, imports and receiver types, so methods that only share a name are kept apart, and it re-syncs with the files before every answer.
9
+
10
+ How to answer
11
+ - Call sites or other uses of a function, method or class: `find_references` on it (`Class.method`, or `path:Name` when the name is ambiguous), with kind `call` for calls only, `include_tests: false` when tests are excluded and `path` for one directory. It says when the list is complete.
12
+ - Callers of callers: `call_graph` with direction `callers` and the depth asked for (two levels up is depth 2), with `include_tests: false` when tests are excluded.
13
+ - Classes that extend a class or implement an interface, directly or through other classes: `find_references` with kind `inherit`.
14
+ - What a change affects (call sites to update, overrides, subclasses that inherit it, tests to run): `change_impact` with the symbols.
15
+ - A definition or a signature: `read_code` with its name.
16
+ - Answer from these results. Read code or search text only to settle what they report as unresolved or incomplete (a call through a name built at run time, a receiver of unknown type), not to redo a list they give as complete.
17
+ - Keep to the scope asked: the directories, whether tests count, how many levels.
18
+
19
+ Reply
20
+ - When the request gives an answer format, follow it exactly.
21
+ - Otherwise one item per line as `path:LINE`, relative to the repository root, with a few words after it when they help (the enclosing function, the kind of use), then one line saying whether the list is complete or what could not be resolved.
22
+ - Nothing else: no account of how you found it. You do not change files.
@@ -20,6 +20,8 @@ import { spawn } from 'node:child_process';
20
20
  import { pathToFileURL } from 'node:url';
21
21
 
22
22
  const PROTOCOL = 1;
23
+ // the exact release this plugin version runs through npx (never a range): keep it equal to package.json's version
24
+ export const VERSION = '3.1.0';
23
25
  const WIN = process.platform === 'win32';
24
26
 
25
27
  export function findRepoRoot(start) {
@@ -90,7 +92,7 @@ async function main() {
90
92
  // npx from running a graph-indexer that the repository ships in its own node_modules
91
93
  // (on Windows npx.cmd needs a shell, so the home directory is quoted for cmd.exe)
92
94
  const home = WIN ? `"${os.homedir()}"` : os.homedir();
93
- const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', home, 'graph-indexer@3', 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
95
+ const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', home, `graph-indexer@${VERSION}`, 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
94
96
  child.on('error', () => { /* fail open */ });
95
97
  child.stdin.on('error', () => { /* fail open */ });
96
98
  child.stdin.end(raw);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Live code graph and search for AI coding agents over MCP: tree-sitter (WASM) symbols, scope/import/type-aware references, call graphs, change impact and hybrid search — local, zero-dependency, no native builds.",
5
5
  "type": "module",
6
6
  "main": "src/query/intel.mjs",
@@ -4,7 +4,7 @@
4
4
  * index, and replies with the answer alone. The main agent delegates the question and gets back a
5
5
  * path:line list instead of the searches and reads behind it, and the lookup runs on a model that
6
6
  * costs a fraction per token. `init` writes it for Claude Code (.claude/agents/) and the Claude Code
7
- * plugin ships the same file (integrations/claude-code/agents/).
7
+ * plugin ships it too (integrations/claude-code/agents/, with only the frontmatter keys the plugin directory accepts).
8
8
  */
9
9
 
10
10
  export const HELPER_NAME = 'code-structure';
@@ -65,3 +65,17 @@ omitClaudeMd: true
65
65
  ${helperInstructions()}
66
66
  `;
67
67
  }
68
+
69
+ /**
70
+ * The same sub-agent for the Claude Code plugin listed in the plugin directory, which accepts only
71
+ * a short list of frontmatter keys (name and description): the model and the tool limits are left
72
+ * to the session, and the instructions already say it does not change files.
73
+ */
74
+ export function pluginAgentFile() {
75
+ return `---
76
+ name: ${HELPER_NAME}
77
+ description: ${JSON.stringify(DESCRIPTION)}
78
+ ---
79
+ ${helperInstructions()}
80
+ `;
81
+ }
package/src/cli/hook.mjs CHANGED
@@ -29,10 +29,10 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
- import { spawn } from 'node:child_process';
32
+ import { spawn, spawnSync } from 'node:child_process';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { findRepoRoot, dataDir, DATA_DIR_NAME, readRepoFile } from '../util/paths.mjs';
35
- import { specForPath } from '../parse/languages.mjs';
35
+ import { codeSpecForPath as specForPath } from '../parse/languages.mjs'; // hooks report on code files only
36
36
 
37
37
  const MAX_CONTEXT = 1500;
38
38
  const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'edit', 'write', 'apply_patch', 'replace', 'write_file']);
@@ -103,15 +103,63 @@ function sessionState(input) {
103
103
  };
104
104
  }
105
105
 
106
+ /**
107
+ * The commands of a shell line, split at `;`, `&&`, `||` and newlines outside quotes, with heredoc
108
+ * bodies dropped and `cd DIR` steps left out (agents write `cd repo; sed -n …; grep …`).
109
+ */
110
+ export function shellSegments(command) {
111
+ return splitShell(command).filter(x => !/^cd\s+\S+$/.test(x));
112
+ }
113
+
114
+ function splitShell(command) {
115
+ const src = String(command ?? '');
116
+ const out = [];
117
+ let cur = '', q = null;
118
+ const lines = src.split('\n');
119
+ for (let li = 0; li < lines.length; li++) {
120
+ const line = lines[li];
121
+ for (let i = 0; i < line.length; i++) {
122
+ const ch = line[i];
123
+ if (q) { cur += ch; if (ch === q) q = null; else if (ch === '\\' && q === '"' && i + 1 < line.length) cur += line[++i]; continue; }
124
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
125
+ if (ch === '\\' && i + 1 < line.length) { cur += ch + line[++i]; continue; }
126
+ if (ch === ';' || (ch === '&' && line[i + 1] === '&') || (ch === '|' && line[i + 1] === '|')) {
127
+ out.push(cur); cur = '';
128
+ if (ch !== ';') i++;
129
+ continue;
130
+ }
131
+ cur += ch;
132
+ }
133
+ if (q) { cur += '\n'; continue; } // a quoted string spanning lines
134
+ // a heredoc (`<<'EOF'`): skip its body up to the delimiter
135
+ const hd = /<<-?\s*['"]?(\w+)['"]?/.exec(cur);
136
+ out.push(cur); cur = '';
137
+ if (hd) while (li + 1 < lines.length && lines[li + 1].trim() !== hd[1]) li++;
138
+ if (hd) li++;
139
+ }
140
+ if (cur) out.push(cur);
141
+ return out.map(x => x.trim()).filter(Boolean);
142
+ }
143
+
106
144
  /** 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 …`. */
107
145
  export function shellRead(command) {
108
- const c = String(command ?? '').replace(/^\s*cd\s+\S+\s*&&\s*/, '');
109
- let m;
110
- 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]];
111
- if ((m = /^sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s+(\S+)\s*$/.exec(c))) return [m[3], +m[1], +m[2]];
112
- if ((m = /^head\s+-(?:n\s*)?(\d+)\s+(\S+)\s*$/.exec(c))) return [m[2], 1, +m[1]];
113
- if ((m = /^cat\s+(?:-n\s+)?(\S+\.\w+)\s*$/.exec(c))) return [m[1], 1, 2000];
114
- return null;
146
+ return shellReads(command)[0] ?? null;
147
+ }
148
+
149
+ /** Every file read in a shell line (`cd repo; sed -n 10,40p a.py; sed -n 5,9p b.py`), relative paths resolved against a `cd` before them. */
150
+ export function shellReads(command) {
151
+ const out = [];
152
+ let dir = null;
153
+ const at = (f) => (dir && !path.isAbsolute(f) ? path.join(dir, f) : f);
154
+ for (const c of splitShell(command)) {
155
+ let m;
156
+ if ((m = /^cd\s+(['"]?)(\S+)\1$/.exec(c))) { dir = path.isAbsolute(m[2]) ? m[2] : path.join(dir ?? '', m[2]); continue; }
157
+ if ((m = /^(?:nl\s+-ba\s+|cat\s+-n\s+)(\S+)\s*\|\s*sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s*$/.exec(c))) out.push([at(m[1]), +m[2], +m[3]]);
158
+ else if ((m = /^sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s+(\S+)\s*$/.exec(c))) out.push([at(m[3]), +m[1], +m[2]]);
159
+ else if ((m = /^head\s+-(?:n\s*)?(\d+)\s+(\S+)\s*$/.exec(c))) out.push([at(m[2]), 1, +m[1]]);
160
+ else if ((m = /^cat\s+(?:-n\s+)?(\S+\.\w+)\s*$/.exec(c))) out.push([at(m[1]), 1, 2000]);
161
+ }
162
+ return out;
115
163
  }
116
164
 
117
165
  /** Minimal shell-words split (quotes and escapes) for extracting a grep pattern. */
@@ -158,17 +206,20 @@ function localIndex(root) {
158
206
  };
159
207
  }
160
208
 
161
- async function afterEdit(root, input, acquire) {
162
- const file = input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? input.tool_input?.path ?? null;
163
- if (!file) return null;
164
- const rel = path.isAbsolute(file) ? path.relative(root, file).split(path.sep).join('/') : file;
165
- if (rel.startsWith('..') || !specForPath(rel)) return null;
209
+ async function afterEdit(root, input, acquire, files = null) {
210
+ if (!files) {
211
+ const file = input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? input.tool_input?.path ?? null;
212
+ if (!file) return null;
213
+ files = [path.isAbsolute(file) ? path.relative(root, file).split(path.sep).join('/') : file];
214
+ }
215
+ files = files.filter(rel => !rel.startsWith('..') && specForPath(rel)).slice(0, 6);
216
+ if (!files.length) return null;
166
217
  const ix = await acquire();
167
218
  if (!ix) return null;
168
219
  const { intel } = ix;
169
220
  try {
170
221
  const { checkChanges } = await import('../query/check.mjs');
171
- const r = await checkChanges(intel, { files: [rel], tests: false });
222
+ const r = await checkChanges(intel, { files, tests: false });
172
223
  if (r.error) return null;
173
224
  const lines = [];
174
225
  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)}`);
@@ -235,46 +286,68 @@ async function afterGrep(root, input, crawling, acquire) {
235
286
  * silent when there is none; and while the agent is crawling, where the names the lines use are defined.
236
287
  */
237
288
  async function afterRead(root, input, state, crawling, acquire) {
238
- let file = null, from = 1, to = 2000;
289
+ let reads = [];
239
290
  if (READ_TOOLS.has(input.tool_name)) {
240
- file = input.tool_input?.file_path ?? input.tool_input?.path ?? input.tool_input?.absolute_path ?? null;
291
+ const file = input.tool_input?.file_path ?? input.tool_input?.path ?? input.tool_input?.absolute_path ?? null;
292
+ let from = 1, to = 2000;
241
293
  const off = Number(input.tool_input?.offset ?? 0), lim = Number(input.tool_input?.limit ?? 0);
242
294
  if (off > 0) from = off;
243
295
  if (lim > 0) to = from + lim - 1;
244
- } else {
245
- const r = shellRead(input.tool_input?.command);
246
- if (!r) return null;
247
- [file, from, to] = r;
248
- }
249
- if (!file) return null;
296
+ if (file) reads = [[file, from, to]];
297
+ } else reads = shellReads(input.tool_input?.command);
250
298
  const cwd = input.cwd || root;
251
- const abs = path.isAbsolute(file) ? file : path.join(cwd, file);
252
- const rel = path.relative(root, abs).split(path.sep).join('/');
253
- if (rel.startsWith('..') || !specForPath(rel)) return null;
299
+ const ranges = reads.map(([file, from, to]) => {
300
+ const abs = path.isAbsolute(file) ? file : path.join(cwd, file);
301
+ return { rel: path.relative(root, abs).split(path.sep).join('/'), from, to };
302
+ }).filter(r => !r.rel.startsWith('..') && specForPath(r.rel)).slice(0, 4);
303
+ if (!ranges.length) return null;
254
304
  const ix = await acquire();
255
305
  if (!ix) return null;
256
306
  const { intel } = ix;
257
307
  try {
258
- await intel.revalidate([rel]); // a long-lived index may not have seen the file change yet
259
- const out = [];
308
+ await intel.revalidate([...new Set(ranges.map(r => r.rel))]); // a long-lived index may not have seen the file change yet
309
+ const facts = [], defs = [];
260
310
  const { factsForRange, renderFacts } = await import('../query/facts.mjs');
311
+ const { definitionsUsed } = await import('../query/read.mjs');
261
312
  state.st.facts ??= [];
262
- const facts = factsForRange(intel, rel, from, to, { max: 4, shown: new Set(state.st.facts) });
263
- if (facts.length) {
264
- for (const f of facts) state.st.facts.push(f.key);
265
- out.push(`graph-indexer, resolved for these lines:\n${renderFacts(facts).map(l => ` ${l}`).join('\n')}`);
266
- }
267
- if (crawling) {
268
- const { definitionsUsed } = await import('../query/read.mjs');
269
- const n = Math.max(1, to - from + 1);
270
- const { rows } = definitionsUsed(intel, rel, from, to, { max: Math.min(12, Math.max(4, Math.round(n / 15))), skipIds: new Set(state.st.shown) });
271
- for (const r of rows) state.st.shown.push(r.id);
272
- 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')}`);
313
+ for (const { rel, from, to } of ranges) {
314
+ const fs1 = factsForRange(intel, rel, from, to, { max: Math.max(1, 4 - facts.length), shown: new Set(state.st.facts) });
315
+ for (const f of fs1) { state.st.facts.push(f.key); facts.push(f); }
316
+ if (crawling) {
317
+ const n = Math.max(1, to - from + 1);
318
+ const { rows } = definitionsUsed(intel, rel, from, to, { max: Math.min(12, Math.max(4, Math.round(n / 15))), skipIds: new Set(state.st.shown) });
319
+ for (const r of rows) { state.st.shown.push(r.id); if (defs.length < 12) defs.push(r); }
320
+ }
273
321
  }
322
+ const out = [];
323
+ if (facts.length) out.push(`graph-indexer, resolved for these lines:\n${renderFacts(facts).map(l => ` ${l}`).join('\n')}`);
324
+ if (defs.length) out.push(`graph-indexer: where names used in these lines are defined (read them directly instead of searching):\n${defs.map(r => ` ${r.qname} → ${r.path}:${r.start_line} ${String(r.sig || r.kind).replace(/\s+/g, ' ').replace(/\s+#.*$/, '').slice(0, 100)}`).join('\n')}`);
274
325
  return out.length ? out.join('\n') : null;
275
326
  } finally { ix.release(); }
276
327
  }
277
328
 
329
+ /**
330
+ * Source files a shell command changed (`sed -i`, a Python heredoc that rewrites a file, a patch):
331
+ * the working tree's modified and new files whose modification time moved since the hook last
332
+ * looked, so the edit checker also follows edits made without the edit tool.
333
+ */
334
+ function shellEdits(root, state) {
335
+ let r;
336
+ try { r = spawnSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all', '-z'], { encoding: 'utf8', timeout: 1500 }); } catch { return []; }
337
+ if (r.status !== 0 || typeof r.stdout !== 'string') return [];
338
+ const seen = state.st.mtimes ??= {};
339
+ const changed = [];
340
+ for (const e of r.stdout.split('\0')) {
341
+ if (e.length < 4 || e.startsWith('D') || e[1] === 'D') continue;
342
+ const rel = e.slice(3);
343
+ if (!specForPath(rel) || rel.startsWith(DATA_DIR_NAME + '/')) continue;
344
+ let m;
345
+ try { m = fs.statSync(path.join(root, rel)).mtimeMs; } catch { continue; }
346
+ if (seen[rel] !== m) { seen[rel] = m; changed.push(rel); }
347
+ }
348
+ return changed;
349
+ }
350
+
278
351
  // the lookup rules, for sessions and subagents whose instruction files do not carry them
279
352
  const RULES = `How to look code up in this repository (graph-indexer):
280
353
  - Several things at once: several tool calls in one message, or one search with alternatives — not one search per turn.
@@ -288,7 +361,7 @@ async function sessionLine(root, { rules = false, acquire }) {
288
361
  const { intel } = ix;
289
362
  try {
290
363
  const st = intel.stats();
291
- 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.`;
364
+ const line = `graph-indexer has a live index of this repository (${st.files} files, ${st.symbols} symbols). Its MCP tools answer with exact locations: explore (the code relevant to a task, its callers, the definitions it uses and its tests, in one call), 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.`;
292
365
  // the managed block of `init` already carries the rules; otherwise add them
293
366
  const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => (readRepoFile(root, f) ?? '').includes('<!-- graph-indexer:start -->'));
294
367
  return rules || !carried ? `${line}\n${RULES}` : line;
@@ -308,6 +381,7 @@ export async function handleHook(event, input, { root, acquire }) {
308
381
  const state = sessionState(input);
309
382
  if (EDIT_TOOLS.has(tool)) {
310
383
  state.st.nav = 0;
384
+ shellEdits(root, state); // so a later shell command does not report this edit again
311
385
  state.save();
312
386
  text = await afterEdit(root, input, acquire);
313
387
  } else if (GREP_TOOLS.has(tool) || (SHELL_TOOLS.has(tool) && grepPattern(command))) {
@@ -318,6 +392,12 @@ export async function handleHook(event, input, { root, acquire }) {
318
392
  state.st.nav++;
319
393
  text = await afterRead(root, input, state, state.st.nav >= CRAWL, acquire);
320
394
  state.save();
395
+ } else if (SHELL_TOOLS.has(tool)) {
396
+ // any other command may have edited files (`sed -i`, a script): check what it changed
397
+ const changed = shellEdits(root, state);
398
+ if (changed.length) state.st.nav = 0;
399
+ state.save();
400
+ if (changed.length) text = await afterEdit(root, input, acquire, changed);
321
401
  }
322
402
  } else if (event === 'session-start' || event === 'subagent-start') {
323
403
  name ??= event === 'session-start' ? 'SessionStart' : 'SubagentStart';
@@ -351,7 +431,9 @@ export async function runHook(event, { repo = null, version = null } = {}) {
351
431
  const timer = setTimeout(() => process.exit(0), deadlineMs); // fail open: never hold the agent up
352
432
  try {
353
433
  const input = await readStdin();
354
- const root = findRepoRoot(input.cwd || repo || process.cwd());
434
+ // an explicit --repo wins over the session's directory (a session started elsewhere, with
435
+ // the repository added to it)
436
+ const root = findRepoRoot(repo || input.cwd || process.cwd());
355
437
  const { askResident, residentRequired } = await import('./resident.mjs');
356
438
  const reply = await askResident(root, { op: 'hook', event, input }, { version, totalMs: 2000 });
357
439
  let out = null;
package/src/cli/init.mjs CHANGED
@@ -26,6 +26,8 @@ const SNIPPET = `${BLOCK_START}
26
26
  - Check once: run the tests that cover your change when you are done; do not re-run a check nothing has changed.
27
27
 
28
28
  The \`graph-indexer\` MCP server answers what a text search cannot answer exactly:
29
+ - Fixing an issue or bug report: make your first call \`explore\` with the names and terms the issue uses (or its text as written). It ranks the code where the names the issue uses meet (usually what you will change) and shows it with its callers, the definitions it uses and the tests to run; read further only what it leaves out.
30
+ - Starting on any other task in code you have not read: \`explore\` with the task in words.
29
31
  - 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
32
  - Before changing a signature other code relies on: \`change_impact\`; after: \`check_changes\`.
31
33
  ${BLOCK_END}`;
@@ -209,6 +211,7 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
209
211
  const hooks = flag('--hooks');
210
212
  const noInstructions = flag('--no-instructions');
211
213
  const noHelper = flag('--no-helper');
214
+ const noDemo = flag('--no-demo');
212
215
  const all = flag('--all');
213
216
  const agentList = opt('--agents');
214
217
  let chosen;
@@ -267,6 +270,18 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
267
270
  }
268
271
  process.stdout.write(`graph-indexer init ${dryRun ? '(dry run) ' : ''}in ${repo}\n ${report.join('\n ')}\n\n` +
269
272
  `Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args ${JSON.stringify([...NPX_ARGS, 'serve', '--repo', repo])}.\n` +
270
- `Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` +
273
+ (dryRun || noDemo ? `Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` : '') +
271
274
  (hooks ? '' : `Optional: graph-indexer init --hooks adds Claude Code hooks (edit check after each edit, grep disambiguation).\n`));
275
+ if (dryRun || noDemo) return;
276
+ // build the index now (the server would on first start) and show what it answers here
277
+ const { CodeIntel } = await import('../query/intel.mjs');
278
+ const { showcaseText } = await import('../query/showcase.mjs');
279
+ const intel = new CodeIntel({ root: repo });
280
+ try {
281
+ let last = 0;
282
+ await intel.open({ onProgress: (d, n) => { const now = Date.now(); if (now - last > 1000 || d === n) { last = now; process.stderr.write(`\r[graph-indexer] indexing ${d}/${n} files`); if (d === n) process.stderr.write('\n'); } } });
283
+ process.stdout.write(`\n${await showcaseText(intel)}\n`);
284
+ } catch (e) {
285
+ process.stdout.write(`\nThe index could not be built now (${e.message}); the server builds it on first start.\n`);
286
+ } finally { intel.close(); }
272
287
  }
@@ -16,7 +16,7 @@
16
16
  * { v, version, op: 'hook', event, input } → { ok, out } out: what the hook prints, or null
17
17
  * { v, version, op: 'tool', calls } → { ok, out } out: the CLI's output text
18
18
  * A request from another graph-indexer version gets { ok: false } and the client falls back, except
19
- * from thin clients that only relay hooks (`any: true`, integrations/claude-code/hooks/gi-hook.mjs).
19
+ * from thin clients that only relay hooks (`any: true`, integrations/claude-code-local/hooks/gi-hook.mjs).
20
20
  *
21
21
  * The socket is only reachable by its owner: inside the repository's .graph-indexer/ directory,
22
22
  * or, when that path is too long for a Unix socket, in a per-user directory with mode 0700 that