graph-indexer 2.1.1 → 3.0.1

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 (131) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +236 -312
  3. package/SECURITY.md +53 -58
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +9 -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 +103 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +129 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +366 -0
  14. package/src/cli/init.mjs +272 -0
  15. package/src/cli/resident.mjs +185 -0
  16. package/src/index/discover.mjs +128 -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 +332 -0
  21. package/src/index/resolver.mjs +961 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +974 -0
  25. package/src/parse/arity.mjs +153 -0
  26. package/src/parse/extract.mjs +1038 -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 +345 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +683 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +130 -0
  44. package/src/query/textsearch.mjs +178 -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/git.mjs +30 -0
  49. package/src/util/paths.mjs +63 -0
  50. package/src/util/regex.mjs +2 -0
  51. package/vendor/grammars/bash.wasm.gz +0 -0
  52. package/vendor/grammars/c.wasm.gz +0 -0
  53. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  54. package/vendor/grammars/cpp.wasm.gz +0 -0
  55. package/vendor/grammars/css.wasm.gz +0 -0
  56. package/vendor/grammars/go.wasm.gz +0 -0
  57. package/vendor/grammars/java.wasm.gz +0 -0
  58. package/vendor/grammars/javascript.wasm.gz +0 -0
  59. package/vendor/grammars/kotlin.wasm.gz +0 -0
  60. package/vendor/grammars/manifest.json +148 -0
  61. package/vendor/grammars/php.wasm.gz +0 -0
  62. package/vendor/grammars/python.wasm.gz +0 -0
  63. package/vendor/grammars/ruby.wasm.gz +0 -0
  64. package/vendor/grammars/rust.wasm.gz +0 -0
  65. package/vendor/grammars/scala.wasm.gz +0 -0
  66. package/vendor/grammars/tsx.wasm.gz +0 -0
  67. package/vendor/grammars/typescript.wasm.gz +0 -0
  68. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  69. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  70. package/cli-ui.mjs +0 -78
  71. package/colbert.mjs +0 -168
  72. package/config.mjs +0 -494
  73. package/daemon-ctl.mjs +0 -175
  74. package/daemon-lock.mjs +0 -69
  75. package/embedders/python/mlx_embed_server.py +0 -61
  76. package/embedders/python/requirements-mlx.txt +0 -5
  77. package/embedders/setup-mlx.mjs +0 -110
  78. package/embeddings.mjs +0 -571
  79. package/engine/binary.mjs +0 -437
  80. package/engine/memory.mjs +0 -1003
  81. package/engine/sqlite.mjs +0 -1136
  82. package/enrichment.mjs +0 -521
  83. package/git-signals.mjs +0 -248
  84. package/indexer.mjs +0 -546
  85. package/init.mjs +0 -1809
  86. package/layout.mjs +0 -123
  87. package/mcp/centrality.mjs +0 -117
  88. package/mcp/format.mjs +0 -61
  89. package/mcp/resolver.mjs +0 -116
  90. package/mcp/symbolgraph.mjs +0 -137
  91. package/mcp/taint.mjs +0 -225
  92. package/mcp/tools.mjs +0 -1582
  93. package/mcp/topology.mjs +0 -676
  94. package/mcp-server.mjs +0 -197
  95. package/parse/extractor.mjs +0 -628
  96. package/parse/imports.mjs +0 -365
  97. package/parse/interprocedural.mjs +0 -134
  98. package/parse/languages.mjs +0 -253
  99. package/parse/metadata.mjs +0 -701
  100. package/parse/routes.mjs +0 -327
  101. package/parse/scip.mjs +0 -391
  102. package/parse/taint-patterns.mjs +0 -119
  103. package/prompts/CORE.md +0 -91
  104. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  105. package/prompts/INTEGRATION.md +0 -123
  106. package/prompts/frameworks/ANDROID.md +0 -17
  107. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  108. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  109. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  110. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  111. package/prompts/frameworks/RAILS.md +0 -20
  112. package/prompts/frameworks/REACT.md +0 -20
  113. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  114. package/prompts/languages/BASH.md +0 -22
  115. package/prompts/languages/C.md +0 -21
  116. package/prompts/languages/CSHARP.md +0 -24
  117. package/prompts/languages/CSS_SCSS.md +0 -22
  118. package/prompts/languages/GO.md +0 -24
  119. package/prompts/languages/JAVA.md +0 -23
  120. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  121. package/prompts/languages/KOTLIN.md +0 -24
  122. package/prompts/languages/PHP.md +0 -23
  123. package/prompts/languages/PYTHON.md +0 -23
  124. package/prompts/languages/RUBY.md +0 -23
  125. package/prompts/languages/RUST.md +0 -21
  126. package/prompts/languages/SWIFT.md +0 -21
  127. package/seal.mjs +0 -400
  128. package/search-core.mjs +0 -777
  129. package/search-sparse.mjs +0 -183
  130. package/storage.mjs +0 -73
  131. package/watch-daemon.mjs +0 -289
package/SECURITY.md CHANGED
@@ -10,7 +10,7 @@ Please include:
10
10
 
11
11
  - A description of the vulnerability and its impact.
12
12
  - Steps to reproduce (a minimal proof of concept if possible).
13
- - The affected version (`graph-indexer --version` or the `version` field in `package.json`).
13
+ - The affected version (`graph-indexer status` prints it, or see the `version` field in `package.json`).
14
14
 
15
15
  You can expect an initial acknowledgement within **5 business days**. Coordinated
16
16
  disclosure is appreciated — we will work with you on a fix and credit before any
@@ -18,68 +18,63 @@ public discussion.
18
18
 
19
19
  ## Supported versions
20
20
 
21
- Security fixes are applied to the latest published `2.x` release. Older versions
21
+ Security fixes are applied to the latest published `3.x` release. Older versions
22
22
  are not maintained; please upgrade to the latest version.
23
23
 
24
24
  ## Security posture
25
25
 
26
- graph-indexer is designed to run locally as a developer tool. Its threat model is
27
- shaped by a few deliberate choices:
26
+ graph-indexer is a local developer tool. Its threat model follows from a few deliberate choices:
28
27
 
29
- - **Air-gapped by default.** In normal operation the only outbound network call is to
30
- a *local* Ollama endpoint (`OLLAMA_HOST`, default `http://localhost:11434`) for
31
- embeddings. With `INDEXER_EMBEDDINGS=off`, even that call is skipped and the tool is
32
- fully offline. There are no analytics, telemetry, crash reporters, or third-party API
33
- calls. **One exception:** the optional in-process embedding provider
34
- (`embedProvider: "local"`, or `"auto"` when no Ollama is found) downloads its model
35
- weights from the Hugging Face CDN **once** on first index, then runs fully offline
36
- from the local cache. Your source code is never sent anywhere — only model weights
37
- are fetched. For strictly air-gapped installs, set `embedProvider` to `"ollama"` or
38
- `"off"`, or pre-populate the model cache on a connected machine.
39
- - **Sealed mode (opt-in, fail-closed enforcement).** For regulated or strictly
40
- air-gapped installs, `--sealed strict` (`INDEXER_SEALED`) enforces **zero** network
41
- egress (lexical-only), and `--sealed local` permits **loopback only** (a local
42
- Ollama/MLX on this box is allowed, nothing leaves it). It is *fail-closed*: the build
43
- refuses to start if any enabled feature (embeddings/enrichment/rerank/HyDE) would
44
- egress beyond the tier, and it installs a **deny-by-default runtime egress guard** on
45
- the in-process network paths (`net`/`tls` sockets, `http(s)`, `fetch`) so even an
46
- accidental call is blocked — turning "we don't call out" into "we *can't*."
47
- `idx-index --attest` prints a deterministic attestation manifest (optionally
48
- Ed25519/RSA-**signed** and CI-verifiable) of the egress posture. See `seal.mjs`.
49
- - **No code execution.** Source files are parsed into ASTs with Tree-sitter. The
50
- indexer never imports, evaluates, or runs the code it indexes. The one subprocess
51
- it spawns is a local, read-only `git log` (see "git signals" below) — never the
52
- indexed code.
53
- - **Local git signals.** At index time the indexer optionally reads the repository's
54
- *local* commit log (`git log` on `MCP_PROJECT_ROOT`) to derive churn / recency /
55
- co-change ranking hints. No remote is contacted and no source content leaves the
56
- machine — only commit metadata (paths, timestamps) is read locally. Disable it
57
- entirely with `INDEXER_GIT_SIGNALS=off`, `--no-git-signals`, or
58
- `"gitSignals": false`.
59
- - **Path-traversal guard.** The `get_file_skeleton` MCP tool resolves and normalizes
60
- the requested path and rejects anything outside `MCP_PROJECT_ROOT`. Beyond the
61
- textual check it also resolves symlinks with `realpath` on both the root and the
62
- target and re-verifies containment, so a symlink *inside* the project that points
63
- outside it cannot be used to read arbitrary files on the host.
64
- - **Local artifacts only.** The index (`code-index.json`) and embeddings
65
- (`code-index.embeddings.bin`) are written inside the project and are git-ignored by
66
- `init`. They contain snippets of your source code — treat them with the same
67
- sensitivity as the repository itself and do not commit or share them.
68
- - **MCP transport.** The server communicates over stdio with the local MCP client
69
- (your IDE/agent). It does not open a network socket.
70
- - **Taint analysis is advisory, not a guarantee.** The opt-in `trace_taint` /
71
- `find_tainted_sinks` MCP tools statically trace untrusted sources to dangerous sinks
72
- over the call graph using pure regex catalogs + graph traversal (no model, no network,
73
- read-only). They are a **finder, not a verifier**: they favour precision and miss flows
74
- through dynamic dispatch, reflection, ORM/query-builder indirection, and untyped
75
- collections — **"0 findings" is not proof of safety.** Use them for orientation, not as
76
- a security gate.
28
+ - **No network.** graph-indexer makes no outbound connections: no telemetry, no model
29
+ downloads, no grammar installs. The tree-sitter runtime and all grammars ship inside the
30
+ package as WebAssembly (versions and SHA-256 checksums in `vendor/grammars/manifest.json`),
31
+ so there is no install-time or first-run download and no native build step.
32
+ - **No dependencies.** The package has no npm dependencies; it uses only Node.js built-ins
33
+ (`node:sqlite`, `node:fs`, `node:child_process`, …) and the vendored WebAssembly runtime.
34
+ - **No code execution.** Source files are parsed into syntax trees; the indexed code is never
35
+ imported, evaluated or run. The only subprocesses are read-only git commands
36
+ (`git rev-parse`, `git ls-files`, `git diff`, `git log`, `git show`) run in the indexed repository
37
+ with fixed arguments. The only tool input they take is the `base` revision of `check_changes`,
38
+ which is refused when git could read it as an option. Every git call switches off what the
39
+ repository's own git config could run: `core.fsmonitor`, hooks, signature verification,
40
+ clean/smudge/process filters, `diff.external` and textconv drivers.
41
+ - **No code from the repository through npx.** Every `npx` command graph-indexer runs or writes into
42
+ an agent's configuration passes `--prefix`, so a `node_modules/graph-indexer` that the repository
43
+ ships is never run in place of the published package.
44
+ - **Stays inside the repository.** Only files listed by git (or found by a `.gitignore`-aware
45
+ walk) under the repository root are indexed. Symlinks are followed only when their target
46
+ resolves inside the repository, so a link to a file elsewhere on the machine is never read,
47
+ indexed or served. Paths given to the tools (`read_code "file.ts:42"`, `outline`,
48
+ `change_impact`) are resolved (`..` and symbolic links) and must stay inside the repository;
49
+ `.git/` and `.graph-indexer/` are never served. Manifests (`package.json`, `tsconfig.json`,
50
+ `go.mod`) are read only as small regular files inside the repository.
51
+ - **Bounded work.** A regular expression given to `search_text` runs under a 5 s budget, and a file
52
+ whose syntax makes the structural queries super-linear is given up after 15 s, so neither can
53
+ block the server.
54
+ - **Read-only tools.** All MCP tools are read-only (`readOnlyHint: true`) and never modify
55
+ the repository.
56
+ - **stdio transport.** The MCP server talks to the local client over stdin/stdout. It does not
57
+ open a network socket.
58
+ - **Local socket for hooks and the CLI.** The MCP server, or `graph-indexer daemon` started by the
59
+ hooks, also answers hook and CLI queries over a Unix domain socket (a named pipe on Windows),
60
+ never TCP. The socket lives in `<repo>/.graph-indexer/` with mode 0600; when that path is too
61
+ long for a socket, in a per-user directory in the temp dir with mode 0700 whose owner and mode
62
+ both sides check before using it. It answers the same read-only queries as the CLI.
63
+ `GRAPH_INDEXER_RESIDENT=0` disables it.
64
+ - **Local artifacts.** The index is a SQLite database in `<repo>/.graph-indexer/` (git-ignored by
65
+ `init`, mode 0700; a `.graph-indexer` that is a symbolic link is refused). It contains excerpts of your source code — treat it with the same sensitivity as the
66
+ repository itself and do not commit or share it. Deleting the directory is always safe; it is
67
+ rebuilt on the next run.
68
+
69
+ ## Runtime
70
+
71
+ graph-indexer runs in the Node.js the user installs. It requires a release that includes the
72
+ October 2026 Node.js security fixes (22.23.4+, 24.22.1+ or 26.12.1+; CVE-2026-111497 and others).
73
+ Keep Node.js updated to the latest patch release of a supported line.
77
74
 
78
75
  ## Operational guidance
79
76
 
80
- - Point `MCP_PROJECT_ROOT` only at repositories you trust and intend to index.
81
- - If you set `OLLAMA_HOST` to a remote host, that host will receive code snippets as
82
- embedding input — only do so on a network and host you control.
83
- - The background watch daemon respects `.gitignore` and skips `node_modules`, build
84
- output, and dot-directories, so secrets in ignored paths are not indexed. Keep
85
- sensitive files git-ignored.
77
+ - Run the server only on repositories you trust and intend to index.
78
+ - Keep secrets out of indexed source files (graph-indexer indexes what git tracks or what a
79
+ `.gitignore`-aware walk finds; ignored paths, `node_modules`, build output and dot-directories
80
+ are skipped).
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * graph-indexer CLI.
4
+ *
5
+ * graph-indexer serve [--repo DIR] MCP server on stdio (what agents launch)
6
+ * graph-indexer index [--repo DIR] build / update the index and print stats
7
+ * graph-indexer init [--repo DIR] [--agents …] wire the MCP server into your coding agents
8
+ * graph-indexer status [--repo DIR]
9
+ * graph-indexer search <query> [--path P] [--kind K] [--limit N]
10
+ * graph-indexer read <target>… [--full] symbols, file:START-END ranges or files, with the
11
+ * definitions each one uses
12
+ * graph-indexer symbol <name>… [--no-code]
13
+ * graph-indexer refs <name> [--kind K] [--path P] [--no-tests] [--limit N]
14
+ * graph-indexer callgraph <name> [--direction callers|callees|both] [--depth N] [--no-tests]
15
+ * graph-indexer impact [--symbols a,b] [--files x,y] [--diff] [--depth N]
16
+ * graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
17
+ * graph-indexer files <text|glob> [--path P]
18
+ *
19
+ * The query commands print exactly what the MCP tools return, so humans (and agents limited to a
20
+ * shell) get the same answers.
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
28
+
29
+ const argv = process.argv.slice(2);
30
+ const aliasOf = { 'idx-mcp': 'serve', 'idx-index': 'index', mcp: 'serve', references: 'refs', 'call-graph': 'callgraph', map: 'outline' };
31
+ const invokedAs = path.basename(process.argv[1] || '').replace(/\.m?js$/, '');
32
+ let cmd = aliasOf[invokedAs] ?? null;
33
+ if (!cmd && argv.length && !argv[0].startsWith('-')) { const c = argv.shift(); cmd = aliasOf[c] ?? c; }
34
+
35
+ function flag(name) { const i = argv.indexOf(name); if (i < 0) return false; argv.splice(i, 1); return true; }
36
+ function opt(name, dflt = null) {
37
+ const i = argv.indexOf(name);
38
+ if (i < 0) return dflt;
39
+ const v = argv[i + 1];
40
+ argv.splice(i, 2);
41
+ return v;
42
+ }
43
+
44
+ const log = (m) => process.stderr.write(`[graph-indexer] ${m}\n`);
45
+ // global options are consumed before any command joins the remaining words into a query
46
+ const repoArg = opt('--repo') ?? process.env.GRAPH_INDEXER_REPO ?? process.env.MCP_PROJECT_ROOT ?? null;
47
+
48
+ async function openIntel({ watch = false, background = false, quiet = false } = {}) {
49
+ const { CodeIntel } = await import('../src/query/intel.mjs');
50
+ const { findRepoRoot } = await import('../src/util/paths.mjs');
51
+ const root = repoArg ? path.resolve(repoArg) : findRepoRoot(process.cwd());
52
+ const intel = new CodeIntel({ root, log, watch });
53
+ let last = 0;
54
+ const onProgress = quiet ? null : (d, n) => {
55
+ const now = Date.now();
56
+ 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'); }
57
+ };
58
+ await intel.open({ onProgress, background });
59
+ return intel;
60
+ }
61
+
62
+ /** Options left over after a command took its own are typos, not part of the query. */
63
+ function rejectUnknownOptions() {
64
+ const bad = argv.filter(a => /^--?[A-Za-z][\w-]*$/.test(a));
65
+ if (!bad.length) return false;
66
+ process.stderr.write(`Unknown option${bad.length > 1 ? 's' : ''} for "${cmd}": ${bad.join(' ')}\n\n${HELP}\n`);
67
+ process.exitCode = 2;
68
+ return true;
69
+ }
70
+
71
+ async function runTool(name, args) {
72
+ return runTools([[name, args]]);
73
+ }
74
+
75
+ /** Several tool calls against one open index (e.g. `symbol A B C`), outputs separated by a blank line. */
76
+ async function runTools(calls) {
77
+ if (rejectUnknownOptions()) return;
78
+ // a resident process (MCP server or daemon) answers without opening and syncing the index again
79
+ const { findRepoRoot } = await import('../src/util/paths.mjs');
80
+ const { askResident, residentRequired } = await import('../src/cli/resident.mjs');
81
+ const root = repoArg ? path.resolve(repoArg) : findRepoRoot(process.cwd());
82
+ const reply = await askResident(root, { op: 'tool', calls }, { version: pkg.version, totalMs: 60_000 });
83
+ if (reply && typeof reply.out === 'string') { process.stdout.write(reply.out + '\n'); return; }
84
+ if (residentRequired()) { process.stderr.write('graph-indexer: no resident process answers for this repository\n'); process.exitCode = 3; return; }
85
+ const intel = await openIntel({ quiet: true });
86
+ const { callTool } = await import('../src/mcp/tools.mjs');
87
+ try {
88
+ const outs = [];
89
+ for (const [name, args] of calls) outs.push(await callTool(intel, name, args, { cli: true }));
90
+ process.stdout.write(outs.join('\n\n') + '\n');
91
+ } finally { intel.close(); }
92
+ }
93
+
94
+ /**
95
+ * Answer hooks and CLI queries for this index over the repository's local socket (see
96
+ * src/cli/resident.mjs). Resolves to null when another process already does.
97
+ */
98
+ async function serveResident(intel, { idleMs = 0, onIdle = null } = {}) {
99
+ const { startResident } = await import('../src/cli/resident.mjs');
100
+ const hold = async () => ({ intel, release() {} });
101
+ return startResident({
102
+ root: intel.root, version: pkg.version, log, idleMs, onIdle,
103
+ wanted: () => fs.existsSync(intel.dbPath),
104
+ handle: async (req) => {
105
+ if (req.op === 'hook') {
106
+ const { handleHook } = await import('../src/cli/hook.mjs');
107
+ return handleHook(String(req.event ?? ''), req.input ?? {}, { root: intel.root, acquire: hold });
108
+ }
109
+ if (req.op === 'tool' && Array.isArray(req.calls)) {
110
+ const { callTool } = await import('../src/mcp/tools.mjs');
111
+ const outs = [];
112
+ for (const [name, args] of req.calls) outs.push(await callTool(intel, name, args ?? {}, { cli: true }));
113
+ return outs.join('\n\n');
114
+ }
115
+ return null;
116
+ },
117
+ });
118
+ }
119
+
120
+ const HELP = `graph-indexer ${pkg.version} — live code graph & search for AI coding agents (MCP)
121
+
122
+ 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]
124
+ graph-indexer serve [--repo DIR] start the MCP server on stdio
125
+ graph-indexer index [--repo DIR] build or update the index
126
+ graph-indexer status [--repo DIR]
127
+ graph-indexer search <query> [--path P] [--kind K] [--limit N]
128
+ graph-indexer grep <regex> [--path P] [--literal|-F] [-i] [--limit N]
129
+ graph-indexer files <text|glob> [--path P] [--limit N] find files by path or name
130
+ graph-indexer read <target>… [--full] [--max-lines N] symbols (Class.method), ranges (file:10-80) or files,
131
+ with where each name they use is defined
132
+ graph-indexer symbol <name>… [--no-code] one or several definitions
133
+ graph-indexer refs <name> [--kind call|type|inherit|new|value|decorator] [--path P] [--no-tests]
134
+ graph-indexer callgraph <name> [--direction callers|callees|both] [--depth N] [--no-tests]
135
+ graph-indexer impact [--symbols a,b] [--files x,y] [--diff] [--depth N]
136
+ graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
137
+ graph-indexer check [--files x,y] [--base REV] verify uncommitted edits
138
+ graph-indexer hook post-tool|session-start|subagent-start agent hook (JSON on stdin)
139
+ graph-indexer daemon [--idle-minutes N] keep the index open for hooks and CLI queries
140
+ (the hooks start it; the MCP server does the same)
141
+
142
+ The index lives in <repo>/.graph-indexer/ and is kept in sync automatically.`;
143
+
144
+ async function main() {
145
+ if (!cmd || cmd === 'help' || flag('--help') || flag('-h')) { process.stdout.write(HELP + '\n'); return; }
146
+ if (cmd === 'version' || flag('--version')) { process.stdout.write(pkg.version + '\n'); return; }
147
+ switch (cmd) {
148
+ case 'serve': {
149
+ const intel = await openIntel({ watch: true, background: true, quiet: true });
150
+ const { McpServer } = await import('../src/mcp/server.mjs');
151
+ const server = new McpServer({ intel, version: pkg.version, log, ready: intel.ready });
152
+ intel.ready.then((r) => log(`index ready: ${r.total} files (${r.added} added, ${r.changed} changed, ${r.removed} removed) in ${r.ms} ms`)).catch((e) => log(`indexing failed: ${e.stack || e.message}`));
153
+ server.onClose = () => { intel.close(); process.exit(0); };
154
+ server.listen();
155
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { intel.close(); process.exit(0); });
156
+ serveResident(intel).catch((e) => log(`resident: ${e.message}`));
157
+ return;
158
+ }
159
+ case 'daemon': {
160
+ // the resident for hooks and CLI queries when no MCP server runs; the hooks start it
161
+ const idle = Math.max(1, Number(opt('--idle-minutes', 30)));
162
+ const { findRepoRoot } = await import('../src/util/paths.mjs');
163
+ const { askResident } = await import('../src/cli/resident.mjs');
164
+ const root = repoArg ? path.resolve(repoArg) : findRepoRoot(process.cwd());
165
+ if (await askResident(root, { op: 'ping' }, { version: pkg.version })) return; // already served
166
+ const intel = await openIntel({ watch: true, quiet: true });
167
+ const stop = () => { intel.close(); process.exit(0); };
168
+ const res = await serveResident(intel, { idleMs: idle * 60_000, onIdle: stop });
169
+ if (!res) { intel.close(); return; }
170
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, stop);
171
+ return;
172
+ }
173
+ case 'index': {
174
+ const t0 = Date.now();
175
+ const intel = await openIntel();
176
+ const st = intel.stats();
177
+ const r = intel.lastSyncStats;
178
+ process.stdout.write(`indexed ${st.root}\n ${st.files} files · ${st.symbols} symbols · ${st.refs} references (${st.resolved} resolved, ${st.exact} exact)\n this run: ${r.added} added, ${r.changed} changed, ${r.removed} removed in ${Date.now() - t0} ms\n languages: ${st.langs.map(l => `${l.lang} ${l.n}`).join(', ')}\n`);
179
+ intel.close();
180
+ return;
181
+ }
182
+ case 'status': {
183
+ const intel = await openIntel({ quiet: true });
184
+ const st = intel.stats();
185
+ process.stdout.write(JSON.stringify({ version: pkg.version, ...st, db: intel.dbPath }, null, 2) + '\n');
186
+ intel.close();
187
+ return;
188
+ }
189
+ case 'init': {
190
+ const { runInit } = await import('../src/cli/init.mjs');
191
+ await runInit({ argv, opt, flag, log, version: pkg.version, repo: repoArg });
192
+ return;
193
+ }
194
+ 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 });
197
+ }
198
+ case 'grep': {
199
+ const pathF = opt('--path'); const literal = flag('--literal') || flag('-F'); const ic = flag('-i') || flag('--ignore-case'); const limit = Number(opt('--limit', 60));
200
+ const pattern = argv.shift();
201
+ return runTool('search_text', { pattern, path: pathF, literal, ignore_case: ic, limit });
202
+ }
203
+ case 'read': {
204
+ const full = flag('--full'); const maxLines = Number(opt('--max-lines', 200));
205
+ const targets = argv.filter(a => !a.startsWith('--'));
206
+ argv.length = 0;
207
+ return runTool('read_code', { targets, full, max_lines: maxLines });
208
+ }
209
+ case 'symbol': {
210
+ const noCode = flag('--no-code'); const maxLines = Number(opt('--max-lines', 200));
211
+ // `symbol A B C` reads several definitions in one call
212
+ const targets = argv.filter(a => !a.startsWith('--'));
213
+ return runTools((targets.length ? targets : ['']).map(t => ['get_symbol', { symbol: t, include_code: !noCode, max_lines: maxLines }]));
214
+ }
215
+ case 'refs': {
216
+ const kind = opt('--kind', 'all'); const noTests = flag('--no-tests'); const limit = Number(opt('--limit', 80)); const pathF = opt('--path');
217
+ return runTool('find_references', { symbol: argv.join(' '), kind, include_tests: !noTests, limit, ...(pathF ? { path: pathF } : {}) });
218
+ }
219
+ case 'callgraph': {
220
+ const direction = opt('--direction', 'both'); const depth = Number(opt('--depth', 2)); const limit = Number(opt('--limit', 40)); const noTests = flag('--no-tests');
221
+ return runTool('call_graph', { symbol: argv.join(' '), direction, depth, limit, include_tests: !noTests });
222
+ }
223
+ case 'impact': {
224
+ const symbols = (opt('--symbols') ?? '').split(',').filter(Boolean);
225
+ const files = (opt('--files') ?? '').split(',').filter(Boolean);
226
+ const diff = flag('--diff'); const depth = Number(opt('--depth', 3));
227
+ return runTool('change_impact', { symbols, files, diff, depth });
228
+ }
229
+ case 'hook': {
230
+ const { runHook } = await import('../src/cli/hook.mjs');
231
+ return runHook(argv.shift() ?? '', { repo: repoArg, version: pkg.version });
232
+ }
233
+ case 'check': {
234
+ const files = (opt('--files') ?? '').split(',').filter(Boolean); const base = opt('--base', 'HEAD');
235
+ return runTool('check_changes', { files, base });
236
+ }
237
+ case 'files': {
238
+ const pathF = opt('--path'); const limit = Number(opt('--limit', 100));
239
+ return runTool('find_files', { pattern: argv.join(' '), path: pathF, limit });
240
+ }
241
+ case 'outline': {
242
+ const focus = opt('--focus'); const maxTokens = Number(opt('--max-tokens', 1500));
243
+ return runTool('outline', { path: argv[0] ?? '', focus, max_tokens: maxTokens });
244
+ }
245
+ default:
246
+ process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}\n`);
247
+ process.exitCode = 2;
248
+ }
249
+ }
250
+
251
+ main().catch((e) => { process.stderr.write(`graph-indexer: ${e.stack || e.message}\n`); process.exit(1); });
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "graph-indexer",
3
+ "version": "3.0.1",
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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "graph-indexer": {
4
+ "command": "npx",
5
+ "args": ["-y", "--prefix", "/", "graph-indexer@3.0.1", "serve"]
6
+ }
7
+ }
8
+ }
@@ -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.
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The plugin's hook command: hands the hook payload to the graph-indexer resident process of the
4
+ * repository (the plugin's MCP server keeps one running) and prints its answer.
5
+ *
6
+ * Starting graph-indexer through `npx` costs about a second on every call; this client only
7
+ * connects to a local socket (tens of milliseconds). When no resident answers, it runs the full
8
+ * hook through npx once, which also starts a resident for the next calls.
9
+ *
10
+ * node gi-hook.mjs post-tool|session-start|subagent-start (hook payload on stdin)
11
+ *
12
+ * The socket location must match socketPath() in graph-indexer's src/cli/resident.mjs.
13
+ */
14
+ import fs from 'node:fs';
15
+ import net from 'node:net';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { spawn } from 'node:child_process';
20
+ import { pathToFileURL } from 'node:url';
21
+
22
+ const PROTOCOL = 1;
23
+ const WIN = process.platform === 'win32';
24
+
25
+ export function findRepoRoot(start) {
26
+ let dir = path.resolve(start);
27
+ for (;;) {
28
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
29
+ const parent = path.dirname(dir);
30
+ if (parent === dir) return path.resolve(start);
31
+ dir = parent;
32
+ }
33
+ }
34
+
35
+ export function socketPath(root) {
36
+ const abs = path.resolve(root);
37
+ const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
38
+ if (WIN) return `\\\\.\\pipe\\graph-indexer-${hash}`;
39
+ const inRepo = path.join(abs, '.graph-indexer', 'resident.sock');
40
+ if (Buffer.byteLength(inRepo) <= 100) return inRepo;
41
+ return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
42
+ }
43
+
44
+ function trusted(file, root) {
45
+ if (WIN) return true;
46
+ const mine = (st) => !process.getuid || st.uid === process.getuid();
47
+ try {
48
+ const sock = fs.lstatSync(file), dir = fs.lstatSync(path.dirname(file));
49
+ const inRepo = path.dirname(file) === path.join(path.resolve(root), '.graph-indexer');
50
+ // the socket and its directory are ours, not links or listeners planted by the repository or another user
51
+ return sock.isSocket() && mine(sock) && dir.isDirectory() && mine(dir) && (inRepo || (dir.mode & 0o077) === 0);
52
+ } catch { return false; }
53
+ }
54
+
55
+ /** The resident's answer ({ out }) or null. `any: true`: this client has no logic of its own to match. */
56
+ function ask(root, request, totalMs) {
57
+ const file = socketPath(root);
58
+ if (!trusted(file, root)) return Promise.resolve(null);
59
+ return new Promise((resolve) => {
60
+ let done = false, buf = '';
61
+ const sock = net.createConnection(file);
62
+ const finish = (v) => { if (!done) { done = true; clearTimeout(timer); sock.destroy(); resolve(v); } };
63
+ const timer = setTimeout(() => finish(null), totalMs);
64
+ sock.setEncoding('utf8');
65
+ sock.on('connect', () => sock.write(JSON.stringify({ v: PROTOCOL, any: true, ...request }) + '\n'));
66
+ sock.on('data', (d) => {
67
+ buf += d;
68
+ const i = buf.indexOf('\n');
69
+ if (i < 0) return;
70
+ let r = null;
71
+ try { r = JSON.parse(buf.slice(0, i)); } catch { /* garbled */ }
72
+ finish(r?.ok ? r : null);
73
+ });
74
+ sock.on('error', () => finish(null));
75
+ sock.on('close', () => finish(null));
76
+ });
77
+ }
78
+
79
+ async function main() {
80
+ const event = process.argv[2] ?? '';
81
+ const chunks = [];
82
+ if (!process.stdin.isTTY) for await (const c of process.stdin) chunks.push(c);
83
+ const raw = Buffer.concat(chunks).toString('utf8');
84
+ let input = {};
85
+ try { input = JSON.parse(raw || '{}'); } catch { return; }
86
+ const root = findRepoRoot(input.cwd || process.cwd());
87
+ const reply = await ask(root, { op: 'hook', event, input }, 2500);
88
+ if (reply) { if (reply.out) process.stdout.write(reply.out + '\n'); return; }
89
+ // no resident yet: the full hook through npx (it starts one for the next calls); --prefix keeps
90
+ // npx from running a graph-indexer that the repository ships in its own node_modules
91
+ // (on Windows npx.cmd needs a shell, so the home directory is quoted for cmd.exe)
92
+ 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 });
94
+ child.on('error', () => { /* fail open */ });
95
+ child.stdin.on('error', () => { /* fail open */ });
96
+ child.stdin.end(raw);
97
+ await new Promise((resolve) => child.on('close', resolve).on('error', resolve));
98
+ }
99
+
100
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
101
+ const timer = setTimeout(() => process.exit(0), 9000); // fail open before the host's 10 s timeout
102
+ main().catch(() => { /* fail open */ }).finally(() => clearTimeout(timer));
103
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|MultiEdit|Grep|Bash|Read",
6
+ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gi-hook.mjs\" post-tool", "timeout": 10 }]
7
+ }
8
+ ],
9
+ "SessionStart": [
10
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gi-hook.mjs\" session-start", "timeout": 10 }] }
11
+ ],
12
+ "SubagentStart": [
13
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gi-hook.mjs\" subagent-start", "timeout": 10 }] }
14
+ ]
15
+ }
16
+ }