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/init.mjs DELETED
@@ -1,1809 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * @file init.mjs
4
- * @description graph-indexer init CLI — a guided, idempotent project setup. It
5
- * detects the stack, wires every installed IDE/agent to the MCP
6
- * server (merging into existing configs, never clobbering them),
7
- * assembles the layered agent prompt suite, installs npm scripts
8
- * (index + daemon control), tidies generated artifacts into
9
- * `.graph-indexer/`, and migrates pre-v1.4 layouts in place.
10
- * @author MaquinaTech <https://github.com/MaquinaTech>
11
- * @copyright (c) 2026 MaquinaTech. All rights reserved.
12
- * @license MIT
13
- * Copyright (c) 2026 MaquinaTech. All rights reserved.
14
- * Permission is hereby granted, free of charge, to any person obtaining a copy
15
- * of this software and associated documentation files (the "Software"), to deal
16
- * in the Software without restriction, including without limitation the rights
17
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
- * copies of the Software, and to permit persons to whom the Software is
19
- * furnished to do so, subject to the following conditions: The above copyright
20
- * notice and this permission notice shall be included in all copies or
21
- * substantial portions of the Software.
22
- */
23
- import fs from 'fs';
24
- import path from 'path';
25
- import os from 'os';
26
- import readline from 'readline';
27
- import { spawnSync, spawn } from 'child_process';
28
- import { fileURLToPath } from 'url';
29
- import { c, glyph, log, rule, box } from './cli-ui.mjs';
30
- import {
31
- DATA_DIR_NAME, CONFIG_FILE_NAME, ensureDataDir, artifactPaths,
32
- migrateLegacyLayout, hasLegacyLayout,
33
- } from './layout.mjs';
34
- import { readPid, isAlive } from './daemon-lock.mjs';
35
- import { ensureMlxEnv, mlxEnvReady, mlxVenvPython, mlxVenvDir } from './embedders/setup-mlx.mjs';
36
- import { localEmbedAvailable } from './embeddings.mjs';
37
-
38
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
-
40
- // Default MLX embed model offered in onboarding (mirrors config.mjs DEFAULTS.mlxEmbedModel).
41
- const MLX_MODEL_DEFAULT = 'mlx-community/all-MiniLM-L6-v2-4bit';
42
-
43
- // Curated MLX LLM models for enrichment / reranking, so the user picks from a short
44
- // proven list instead of typing a long `mlx-community/…` id. "Other…" always escapes.
45
- const MLX_LLM_MODELS = {
46
- enrichment: [
47
- { key: 'mlx-community/Qwen2.5-Coder-1.5B-Instruct-4bit', label: 'Qwen2.5-Coder 1.5B (4-bit)', desc: 'recommended · fast summaries' },
48
- { key: 'mlx-community/Qwen2.5-Coder-3B-Instruct-4bit', label: 'Qwen2.5-Coder 3B (4-bit)', desc: 'richer · a little slower' },
49
- ],
50
- reranker: [
51
- { key: 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit', label: 'Qwen2.5-Coder 7B (4-bit)', desc: 'recommended · sharpest judgment' },
52
- { key: 'mlx-community/Qwen2.5-Coder-14B-Instruct-4bit', label: 'Qwen2.5-Coder 14B (4-bit)', desc: 'sharper · needs ~12 GB RAM' },
53
- ],
54
- };
55
- const MLX_LLM_DEFAULTS = {
56
- enrichment: MLX_LLM_MODELS.enrichment[0].key,
57
- reranker: MLX_LLM_MODELS.reranker[0].key,
58
- };
59
-
60
- // ─── Subcommand dispatch ─────────────────────────────────────────────────────
61
- // package.json maps the `graph-indexer` bin to THIS file, so `npx graph-indexer
62
- // idx-mcp …` runs init.mjs with `idx-mcp` as argv[2] — npx resolves the package's
63
- // same-named bin, never the `idx-mcp` bin. Delegate those tokens to the real bins
64
- // (re-exec with inherited stdio, so a delegated MCP server talks straight over
65
- // stdin/stdout) so `graph-indexer <subcommand> …` works everywhere. A bare `init`
66
- // token, or none, falls through to the setup wizard below.
67
- const SUBCOMMAND_BINS = {
68
- 'idx-mcp': 'mcp-server.mjs',
69
- 'idx-index': 'indexer.mjs',
70
- 'idx-watch': 'watch-daemon.mjs',
71
- 'idx-daemon': 'daemon-ctl.mjs',
72
- };
73
- if (SUBCOMMAND_BINS[process.argv[2]]) {
74
- const target = path.join(__dirname, SUBCOMMAND_BINS[process.argv[2]]);
75
- const res = spawnSync(process.execPath, [target, ...process.argv.slice(3)], { stdio: 'inherit' });
76
- if (res.error) { console.error(res.error.message); process.exit(1); }
77
- process.exit(res.status == null ? 1 : res.status);
78
- }
79
-
80
- // ─── CLI parsing (target repo + flags) ───────────────────────────────────────
81
- /** Split argv into a flag set, a `--repo` value, and the first positional path. */
82
- function parseCli(argv) {
83
- const flags = new Set();
84
- let repo = null, positional = null;
85
- for (let i = 0; i < argv.length; i++) {
86
- const a = argv[i];
87
- if (a === '--repo') { repo = argv[++i] ?? null; continue; }
88
- if (a === 'init') continue; // explicit "run the wizard" token
89
- if (a.startsWith('-')) { flags.add(a); continue; }
90
- if (positional == null) positional = a; // first bare token = target repo
91
- }
92
- return { flags, repo, positional };
93
- }
94
- const cli = parseCli(process.argv.slice(2));
95
-
96
- const HELP = `graph-indexer — AST-precise, air-gapped code search for AI agents
97
-
98
- Usage:
99
- graph-indexer [init] [path] [options] Guided project setup (default)
100
- graph-indexer idx-mcp [--repo <path>] Start the MCP server (stdio)
101
- graph-indexer idx-index [--repo <path>] Build / refresh the index
102
- graph-indexer idx-daemon <start|stop|restart|status|logs> [--repo <path>]
103
- graph-indexer idx-watch [--repo <path>] Run the watch daemon in the foreground
104
-
105
- Setup options:
106
- path, --repo <path> Target repository (default: current directory)
107
- --yes, -y Non-interactive: accept detected/default selections (CI)
108
- --non-interactive Alias for --yes
109
- --all-languages Index every supported language (implies --yes)
110
- --dry-run Show the file actions without writing anything
111
- --help, -h Show this help
112
-
113
- Docs: https://github.com/MaquinaTech/graph-indexer
114
- `;
115
- if (cli.flags.has('--help') || cli.flags.has('-h')) { process.stdout.write(HELP); process.exit(0); }
116
-
117
- const isDryRun = cli.flags.has('--dry-run');
118
- const isAllLanguages = cli.flags.has('--all-languages');
119
- // CI / scripted runs: an explicit switch — or a non-TTY stdin — both mean "no prompts".
120
- const forceNonInteractive = isAllLanguages
121
- || cli.flags.has('--yes') || cli.flags.has('-y')
122
- || cli.flags.has('--non-interactive') || cli.flags.has('--ci');
123
- const isInteractive = !forceNonInteractive && Boolean(process.stdin.isTTY);
124
-
125
- const PROJECT_ROOT = path.resolve(cli.repo || cli.positional || process.cwd());
126
- const PATHS = artifactPaths(PROJECT_ROOT);
127
- const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
128
-
129
- // Is graph-indexer resolvable as a local dependency of the TARGET repo? When it is
130
- // (and a package.json exists), the npm scripts / bare bins resolve, so we wire
131
- // `npm run mcp:start`. Otherwise — non-Node repos, or Node repos where the user only
132
- // ran `npx graph-indexer` — we wire a self-contained `npx -p graph-indexer …`
133
- // command (and npx-form scripts) so the server still launches.
134
- function graphIndexerIsLocalDep(root) {
135
- if (fs.existsSync(path.join(root, 'node_modules', 'graph-indexer', 'package.json'))) return true;
136
- const pkg = readJsonSafe(path.join(root, 'package.json'));
137
- if (pkg) {
138
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
139
- if (deps['graph-indexer']) return true;
140
- }
141
- return false;
142
- }
143
- const localDep = graphIndexerIsLocalDep(PROJECT_ROOT);
144
-
145
- // Special case: setting up the graph-indexer repo on ITSELF (the maintainer
146
- // dogfooding the tool). It is not its own dependency, so the npx path would
147
- // otherwise fetch the PUBLISHED package from npm instead of this working tree —
148
- // we wire the local mcp-server.mjs directly so it runs the code under edit.
149
- const selfHost = !localDep
150
- && readJsonSafe(path.join(PROJECT_ROOT, 'package.json'))?.name === 'graph-indexer'
151
- && fs.existsSync(path.join(PROJECT_ROOT, 'mcp-server.mjs'));
152
-
153
- const TOTAL_STEPS = 7;
154
-
155
- // ─── Action ledger (drives the grouped end-of-run summary) ───────────────────────
156
-
157
- const GLYPH_FOR = {
158
- created: glyph.ok, updated: glyph.upd, migrated: glyph.move,
159
- kept: glyph.keep, skipped: glyph.skip, warn: glyph.warn,
160
- };
161
- const ledger = { created: [], updated: [], migrated: [], kept: [], skipped: [], warn: [] };
162
-
163
- /** Log one action live (with optional detail) and record it for the summary. */
164
- function act(kind, label, detail) {
165
- log(` ${GLYPH_FOR[kind]} ${label}${detail ? ' ' + c.dim(detail) : ''}`);
166
- ledger[kind].push(label);
167
- }
168
- /** Live-only line (e.g. the stack selection echo) — not recorded in the summary. */
169
- function line(g, label, detail) {
170
- log(` ${g} ${label}${detail ? ' ' + c.dim(detail) : ''}`);
171
- }
172
- function stepHeader(n, title) {
173
- log('\n' + c.bold(` ${c.cyan(`[${n}/${TOTAL_STEPS}]`)} ${title}`));
174
- }
175
-
176
- // ─── Language Registry ────────────────────────────────────────────────────────
177
-
178
- const LANGUAGES = [
179
- { key: 'typescript', label: 'TypeScript / TSX', exts: '.ts, .tsx' },
180
- { key: 'javascript', label: 'JavaScript', exts: '.js, .jsx, .mjs, .cjs' },
181
- { key: 'python', label: 'Python', exts: '.py' },
182
- { key: 'go', label: 'Go', exts: '.go' },
183
- { key: 'rust', label: 'Rust', exts: '.rs' },
184
- { key: 'php', label: 'PHP', exts: '.php' },
185
- { key: 'java', label: 'Java', exts: '.java' },
186
- { key: 'kotlin', label: 'Kotlin', exts: '.kt, .kts' },
187
- { key: 'csharp', label: 'C#', exts: '.cs' },
188
- { key: 'ruby', label: 'Ruby', exts: '.rb' },
189
- { key: 'css', label: 'CSS / SCSS', exts: '.css, .scss' },
190
- { key: 'c', label: 'C', exts: '.c, .h' },
191
- { key: 'bash', label: 'Bash / Shell', exts: '.sh, .bash' },
192
- { key: 'swift', label: 'Swift', exts: '.swift' },
193
- ];
194
-
195
- // ─── Framework Registry (Layer 2 prompt add-ons) ─────────────────────────────
196
-
197
- const FRAMEWORKS = [
198
- { key: 'react', label: 'React', hint: 'JSX components, hooks', langs: ['typescript', 'javascript'], prompt: 'frameworks/REACT.md' },
199
- { key: 'node-backend', label: 'Node.js / Express / NestJS', hint: 'routing, middleware, DI', langs: ['typescript', 'javascript'], prompt: 'frameworks/NODE_EXPRESS_NESTJS.md' },
200
- { key: 'python-web', label: 'FastAPI / Django', hint: 'route decorators, ORM', langs: ['python'], prompt: 'frameworks/FASTAPI_DJANGO.md' },
201
- { key: 'spring-boot', label: 'Spring Boot', hint: 'annotations, DI, AOP', langs: ['java', 'kotlin'], prompt: 'frameworks/SPRING_BOOT.md' },
202
- { key: 'rails', label: 'Ruby on Rails', hint: 'conventions, ActiveRecord', langs: ['ruby'], prompt: 'frameworks/RAILS.md' },
203
- { key: 'laravel-symfony', label: 'Laravel / Symfony', hint: 'facades, container, Eloquent', langs: ['php'], prompt: 'frameworks/LARAVEL_SYMFONY.md' },
204
- { key: 'aspnet-core', label: 'ASP.NET Core', hint: 'attribute routing, DI, EF', langs: ['csharp'], prompt: 'frameworks/ASPNET_CORE.md' },
205
- { key: 'android', label: 'Android (Jetpack / Compose)', hint: 'lifecycle, coroutines, DI', langs: ['kotlin'], prompt: 'frameworks/ANDROID.md' },
206
- ];
207
-
208
- /** Language key → Layer 2 language prompt file. */
209
- const LANGUAGE_PROMPTS = {
210
- typescript: 'languages/JAVASCRIPT_TYPESCRIPT.md',
211
- javascript: 'languages/JAVASCRIPT_TYPESCRIPT.md',
212
- python: 'languages/PYTHON.md',
213
- go: 'languages/GO.md',
214
- rust: 'languages/RUST.md',
215
- java: 'languages/JAVA.md',
216
- kotlin: 'languages/KOTLIN.md',
217
- csharp: 'languages/CSHARP.md',
218
- ruby: 'languages/RUBY.md',
219
- php: 'languages/PHP.md',
220
- css: 'languages/CSS_SCSS.md',
221
- c: 'languages/C.md',
222
- bash: 'languages/BASH.md',
223
- swift: 'languages/SWIFT.md',
224
- };
225
-
226
- // ─── MCP Server config blocks (self-contained when not a local dependency) ────
227
- // Resolve mcp-server.mjs relative to this file — works whether graph-indexer is
228
- // npm-installed (node_modules/graph-indexer/mcp-server.mjs), globally symlinked,
229
- // or run from the dev tree. Using `node <absolute-path>` avoids the npx cache
230
- // entirely, so stale cached versions can never shadow the currently-installed one.
231
- const PACKAGE_MCP_SERVER = fileURLToPath(new URL('mcp-server.mjs', import.meta.url));
232
-
233
- // Editors launched from the GUI (Dock/Finder/Spotlight) do NOT inherit the shell
234
- // PATH, so a bare `npx`/`npm` command makes the MCP host fail with `spawn npx
235
- // ENOENT`. We resolve the absolute binary that sits next to the `node` running
236
- // init (true for nvm/Homebrew/fnm/volta/system installs) and write THAT as the
237
- // command. We also bake the current PATH (prefixed with that bin dir) into the
238
- // server env, so the server and its children — the daemon, its `git`/`node`
239
- // subprocesses — resolve their tools too. These configs are already machine-
240
- // specific (absolute --repo / MCP_PROJECT_ROOT), so absolute paths fit. Re-run
241
- // init after switching Node versions to refresh them.
242
- const NODE_BIN_DIR = path.dirname(process.execPath);
243
- const IS_WIN = process.platform === 'win32';
244
- function resolveNodeSibling(name) {
245
- const exe = IS_WIN ? `${name}.cmd` : name;
246
- const abs = path.join(NODE_BIN_DIR, exe);
247
- return fs.existsSync(abs) ? abs : name; // fall back to bare name if not a sibling
248
- }
249
- const NPX_BIN = resolveNodeSibling('npx');
250
- const NPM_BIN = resolveNodeSibling('npm');
251
- const SERVER_ENV = {
252
- MCP_PROJECT_ROOT: PROJECT_ROOT,
253
- PATH: NODE_BIN_DIR + path.delimiter + (process.env.PATH || ''),
254
- };
255
-
256
- // Self-host runs the working tree's own server with absolute node — already
257
- // global-safe (absolute script + --repo), so it doubles as both forms.
258
- const SELF_HOST_CONFIG = { command: process.execPath, args: [path.join(PROJECT_ROOT, 'mcp-server.mjs'), '--repo', PROJECT_ROOT], env: SERVER_ENV };
259
-
260
- const SERVER_CONFIG = selfHost
261
- ? SELF_HOST_CONFIG
262
- : localDep
263
- ? { command: NPM_BIN, args: ['run', 'mcp:start'], env: SERVER_ENV }
264
- : { command: process.execPath, args: [PACKAGE_MCP_SERVER, '--repo', PROJECT_ROOT], env: SERVER_ENV };
265
-
266
- // Global clients (Claude Desktop) launch from outside the project; same approach.
267
- const SERVER_CONFIG_GLOBAL = selfHost
268
- ? SELF_HOST_CONFIG
269
- : localDep
270
- ? { command: NPM_BIN, args: ['run', '--prefix', PROJECT_ROOT, 'mcp:start'], env: SERVER_ENV }
271
- : { command: process.execPath, args: [PACKAGE_MCP_SERVER, '--repo', PROJECT_ROOT], env: SERVER_ENV };
272
-
273
- // Display commands for "Next steps" — adapt to the self-host / local-dep / npx decision.
274
- const q = (p) => (/\s/.test(p) ? `"${p}"` : p);
275
- const CMD = selfHost
276
- ? {
277
- index: `node ${q(path.join(PROJECT_ROOT, 'indexer.mjs'))} --repo ${q(PROJECT_ROOT)}`,
278
- daemonStatus: `node ${q(path.join(PROJECT_ROOT, 'daemon-ctl.mjs'))} status`,
279
- }
280
- : {
281
- index: localDep ? 'npm run mcp:index' : `npx -y -p graph-indexer idx-index --repo ${q(PROJECT_ROOT)}`,
282
- daemonStatus: localDep ? 'npm run mcp:daemon:status' : `npx -y -p graph-indexer idx-daemon status --repo ${q(PROJECT_ROOT)}`,
283
- };
284
-
285
- // ─── Utilities ────────────────────────────────────────────────────────────────
286
-
287
- function writeFile(filePath, content) {
288
- if (isDryRun) { log(c.dim(` [dry-run] would write ${path.relative(PROJECT_ROOT, filePath) || filePath}`)); return; }
289
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
290
- fs.writeFileSync(filePath, content, 'utf-8');
291
- }
292
-
293
- function stripJsonComments(str) {
294
- let result = '';
295
- let inString = false;
296
- let i = 0;
297
- while (i < str.length) {
298
- if (inString) {
299
- if (str[i] === '\\') { result += str[i++]; result += str[i++]; continue; }
300
- if (str[i] === '"') inString = false;
301
- result += str[i++];
302
- } else {
303
- if (str[i] === '"') { inString = true; result += str[i++]; continue; }
304
- if (str[i] === '/' && str[i + 1] === '/') { while (i < str.length && str[i] !== '\n') i++; continue; }
305
- if (str[i] === '/' && str[i + 1] === '*') {
306
- i += 2;
307
- while (i < str.length && !(str[i] === '*' && str[i + 1] === '/')) i++;
308
- i += 2;
309
- continue;
310
- }
311
- result += str[i++];
312
- }
313
- }
314
- return result;
315
- }
316
-
317
- function readJsonSafe(filePath) {
318
- try {
319
- if (fs.existsSync(filePath)) return JSON.parse(stripJsonComments(fs.readFileSync(filePath, 'utf-8')));
320
- } catch { /* malformed JSON — start fresh */ }
321
- return null;
322
- }
323
-
324
- function readTextSafe(filePath, maxBytes = 262144) {
325
- try {
326
- if (!fs.existsSync(filePath)) return '';
327
- const fd = fs.openSync(filePath, 'r');
328
- try {
329
- const buf = Buffer.alloc(Math.min(maxBytes, fs.fstatSync(fd).size));
330
- fs.readSync(fd, buf, 0, buf.length, 0);
331
- return buf.toString('utf-8');
332
- } finally { fs.closeSync(fd); }
333
- } catch { return ''; }
334
- }
335
-
336
- /** Structural equality for an MCP server entry (ignores key order). */
337
- function sameServer(a, b) {
338
- if (!a || !b) return false;
339
- return a.command === b.command
340
- && JSON.stringify(a.args || []) === JSON.stringify(b.args || [])
341
- && JSON.stringify(a.env || {}) === JSON.stringify(b.env || {});
342
- }
343
-
344
- // ─── Stack Detection (used to pre-select menu entries) ───────────────────────
345
-
346
- const EXT_TO_LANG = {
347
- '.ts': 'typescript', '.tsx': 'typescript',
348
- '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
349
- '.py': 'python', '.go': 'go', '.rs': 'rust', '.php': 'php', '.java': 'java',
350
- '.kt': 'kotlin', '.kts': 'kotlin', '.cs': 'csharp', '.rb': 'ruby',
351
- '.css': 'css', '.scss': 'css',
352
- '.c': 'c', '.h': 'c', '.sh': 'bash', '.bash': 'bash', '.swift': 'swift',
353
- };
354
-
355
- const SKIP_DIRS = new Set([
356
- 'node_modules', '.git', 'dist', 'build', 'out', 'target', 'vendor',
357
- 'venv', '.venv', '__pycache__', '.next', '.nuxt', 'coverage', '.idea', '.vscode',
358
- ]);
359
-
360
- /** Bounded breadth-first scan: counts source files per language, never more than ~4000 entries deep. */
361
- function detectLanguages(root) {
362
- const counts = {};
363
- let visited = 0;
364
- let totalSourceFiles = 0;
365
- let truncated = false; // hit the scan cap → repo is large (used for the Node 22 hint)
366
- const queue = [{ dir: root, depth: 0 }];
367
-
368
- while (queue.length > 0 && visited < 4000) {
369
- const { dir, depth } = queue.shift();
370
- let entries;
371
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
372
- for (const entry of entries) {
373
- if (++visited >= 4000) { truncated = true; break; }
374
- if (entry.isDirectory()) {
375
- if (depth < 5 && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
376
- queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
377
- }
378
- } else {
379
- const lang = EXT_TO_LANG[path.extname(entry.name)];
380
- if (lang) { counts[lang] = (counts[lang] || 0) + 1; totalSourceFiles++; }
381
- }
382
- }
383
- }
384
-
385
- // ≥3 files of a language counts as "present"; tiny repos get a lower bar.
386
- const threshold = totalSourceFiles < 15 ? 1 : 3;
387
- const langs = new Set(Object.keys(counts).filter(k => counts[k] >= threshold));
388
- return { langs, fileCount: totalSourceFiles, truncated };
389
- }
390
-
391
- /** Best-effort framework detection from dependency manifests. */
392
- function detectFrameworks(root) {
393
- const found = new Set();
394
-
395
- const pkg = readJsonSafe(path.join(root, 'package.json'));
396
- if (pkg) {
397
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
398
- if (deps.react || deps['react-dom'] || deps.next || deps.preact) found.add('react');
399
- if (deps.express || deps.fastify || deps.koa || deps['@nestjs/core'] || deps['@nestjs/common']) found.add('node-backend');
400
- }
401
-
402
- const pyManifests = ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py', 'setup.cfg']
403
- .map(f => readTextSafe(path.join(root, f))).join('\n');
404
- if (/\b(fastapi|django)\b/i.test(pyManifests)) found.add('python-web');
405
-
406
- const jvmManifests = ['pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts',
407
- 'app/build.gradle', 'app/build.gradle.kts']
408
- .map(f => readTextSafe(path.join(root, f))).join('\n');
409
- if (/spring-boot|org\.springframework/i.test(jvmManifests)) found.add('spring-boot');
410
- if (/com\.android\.(application|library)|androidx\./i.test(jvmManifests)) found.add('android');
411
-
412
- if (/\brails\b/i.test(readTextSafe(path.join(root, 'Gemfile')))) found.add('rails');
413
-
414
- const composer = readJsonSafe(path.join(root, 'composer.json'));
415
- if (composer) {
416
- const req = { ...(composer.require || {}), ...(composer['require-dev'] || {}) };
417
- if (Object.keys(req).some(k => k.startsWith('laravel/') || k.startsWith('symfony/'))) found.add('laravel-symfony');
418
- }
419
-
420
- if (/Microsoft\.NET\.Sdk\.Web|Microsoft\.AspNetCore/i.test(findCsprojContent(root))) found.add('aspnet-core');
421
-
422
- return found;
423
- }
424
-
425
- /** Concatenates the first few .csproj files found in the root or one level down. */
426
- function findCsprojContent(root) {
427
- const candidates = [];
428
- try {
429
- const top = fs.readdirSync(root, { withFileTypes: true });
430
- for (const entry of top) {
431
- if (entry.isFile() && entry.name.endsWith('.csproj')) candidates.push(path.join(root, entry.name));
432
- else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
433
- try {
434
- for (const sub of fs.readdirSync(path.join(root, entry.name))) {
435
- if (sub.endsWith('.csproj')) candidates.push(path.join(root, entry.name, sub));
436
- }
437
- } catch { /* unreadable dir — skip */ }
438
- }
439
- if (candidates.length >= 8) break;
440
- }
441
- } catch { /* unreadable root — no detection */ }
442
- return candidates.slice(0, 8).map(f => readTextSafe(f, 65536)).join('\n');
443
- }
444
-
445
- // ─── Interactive Menu Rendering Helpers ──────────────────────────────────────
446
-
447
- const ANSI_RE = /\x1B\[[0-9;]*m/g;
448
- /** Visible width of a string, ignoring SGR color escapes (which take no space). */
449
- const visibleLen = (s) => s.replace(ANSI_RE, '').length;
450
-
451
- /**
452
- * Clip one rendered line to the terminal width so it NEVER wraps. A wrapped line
453
- * occupies >1 physical row, which desyncs the `moveCursor(-lineCount)` redraw and
454
- * makes the menu repeat/grow on every keystroke. Color escapes are copied
455
- * verbatim (zero width); if we cut inside a colored span we close it with a reset
456
- * and mark the truncation with a dim ellipsis. One logical line ⇒ one screen row.
457
- */
458
- function clipLine(str) {
459
- const max = Math.max(1, (process.stdout.columns || 80) - 1);
460
- if (visibleLen(str) <= max) return str;
461
- const budget = max - 1; // reserve a column for the ellipsis
462
- let out = '', width = 0, i = 0, sawAnsi = false;
463
- while (i < str.length && width < budget) {
464
- if (str[i] === '\x1B') {
465
- const m = str.slice(i).match(/^\x1B\[[0-9;]*m/);
466
- if (m) { out += m[0]; i += m[0].length; sawAnsi = true; continue; }
467
- }
468
- out += str[i++]; width++;
469
- }
470
- return out + (sawAnsi ? '\x1B[0m' : '') + c.dim('…');
471
- }
472
-
473
- // ─── Interactive Multi-Select Menu ────────────────────────────────────────────
474
-
475
- /**
476
- * Renders an arrow-key multi-select. `items` need { key, label, desc }; entries
477
- * whose key is in `preselected` start checked. Resolves to the selected keys
478
- * (possibly empty — callers decide what an empty selection means).
479
- */
480
- function interactiveMultiSelect({ title, items, preselected = new Set(), detected = preselected }) {
481
- return new Promise((resolve) => {
482
- const { stdin, stdout } = process;
483
- readline.emitKeypressEvents(stdin);
484
- stdin.setRawMode(true);
485
- stdin.resume(); // a preceding readline prompt may have left stdin paused
486
- stdout.write('\x1B[?25l');
487
-
488
- let cursorIndex = 0;
489
- const selected = new Set(items.map((it, i) => (preselected.has(it.key) ? i : -1)).filter(i => i >= 0));
490
- let hasRendered = false;
491
- const lineCount = items.length + 2;
492
-
493
- const render = () => {
494
- if (hasRendered) {
495
- readline.moveCursor(stdout, 0, -lineCount);
496
- readline.cursorTo(stdout, 0);
497
- readline.clearScreenDown(stdout);
498
- }
499
- hasRendered = true;
500
-
501
- stdout.write(clipLine(` ${title} ${c.dim('(↑↓/Tab move · Space toggle · Enter confirm)')}`) + '\n\n');
502
-
503
- items.forEach((item, i) => {
504
- const isHovered = i === cursorIndex;
505
- const prefix = isHovered ? '❯' : ' ';
506
- const checkbox = selected.has(i) ? c.green('◉') : '◯';
507
- const detectedTag = detected.has(item.key) ? c.dim(' · detected') : '';
508
- const label = isHovered ? c.cyan(item.label.padEnd(28)) : item.label.padEnd(28);
509
- stdout.write(clipLine(` ${c.cyan(prefix)} ${checkbox} ${label} ${item.desc}${detectedTag}`) + '\n');
510
- });
511
- };
512
-
513
- const cleanup = () => {
514
- stdin.setRawMode(false);
515
- stdout.write('\x1B[?25h');
516
- stdin.removeListener('keypress', onKeypress);
517
- };
518
-
519
- const onKeypress = (str, key) => {
520
- if (key.ctrl && key.name === 'c') {
521
- cleanup();
522
- stdout.write('\n');
523
- process.exit(130);
524
- }
525
-
526
- if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
527
- cursorIndex = (cursorIndex - 1 + items.length) % items.length;
528
- render();
529
- } else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
530
- cursorIndex = (cursorIndex + 1) % items.length;
531
- render();
532
- } else if (key.name === 'space') {
533
- if (selected.has(cursorIndex)) selected.delete(cursorIndex);
534
- else selected.add(cursorIndex);
535
- render();
536
- } else if (key.name === 'return') {
537
- cleanup();
538
- readline.moveCursor(stdout, 0, -lineCount);
539
- readline.cursorTo(stdout, 0);
540
- readline.clearScreenDown(stdout);
541
- resolve(Array.from(selected).sort((a, b) => a - b).map(i => items[i].key));
542
- }
543
- };
544
-
545
- stdin.on('keypress', onKeypress);
546
- render();
547
- });
548
- }
549
-
550
- // ─── Single-select menu, line prompts & Ollama model discovery ───────────────
551
-
552
- const CUSTOM_MODEL = Symbol('custom-model'); // unique sentinel for the "Other…" choice
553
-
554
- /**
555
- * Arrow-key single-select. Enter confirms the currently hovered row, and the
556
- * cursor starts on `selectedKey`, so pressing Enter with no movement always
557
- * yields the default. `items` need { key, label, desc }.
558
- */
559
- function interactiveSelect({ title, items, selectedKey }) {
560
- return new Promise((resolve) => {
561
- const { stdin, stdout } = process;
562
- readline.emitKeypressEvents(stdin);
563
- if (stdin.isTTY) stdin.setRawMode(true);
564
- stdin.resume(); // a preceding readline prompt may have left stdin paused
565
- stdout.write('\x1B[?25l');
566
-
567
- let cursor = Math.max(0, items.findIndex(it => it.key === selectedKey));
568
- let hasRendered = false;
569
- const lineCount = items.length + 2;
570
-
571
- const render = () => {
572
- if (hasRendered) {
573
- readline.moveCursor(stdout, 0, -lineCount);
574
- readline.cursorTo(stdout, 0);
575
- readline.clearScreenDown(stdout);
576
- }
577
- hasRendered = true;
578
- stdout.write(clipLine(` ${title} ${c.dim('(↑↓ move · Enter select)')}`) + '\n\n');
579
- items.forEach((item, i) => {
580
- const hovered = i === cursor;
581
- const radio = hovered ? c.green('◉') : '◯';
582
- const label = hovered ? c.cyan(item.label.padEnd(26)) : item.label.padEnd(26);
583
- stdout.write(clipLine(` ${c.cyan(hovered ? '❯' : ' ')} ${radio} ${label} ${item.desc || ''}`) + '\n');
584
- });
585
- };
586
-
587
- const cleanup = () => {
588
- if (stdin.isTTY) stdin.setRawMode(false);
589
- stdout.write('\x1B[?25h');
590
- stdin.removeListener('keypress', onKeypress);
591
- };
592
-
593
- const onKeypress = (str, key) => {
594
- if (key.ctrl && key.name === 'c') { cleanup(); stdout.write('\n'); process.exit(130); }
595
- if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
596
- cursor = (cursor - 1 + items.length) % items.length; render();
597
- } else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
598
- cursor = (cursor + 1) % items.length; render();
599
- } else if (key.name === 'return') {
600
- cleanup();
601
- readline.moveCursor(stdout, 0, -lineCount);
602
- readline.cursorTo(stdout, 0);
603
- readline.clearScreenDown(stdout);
604
- resolve(items[cursor].key);
605
- }
606
- };
607
-
608
- stdin.on('keypress', onKeypress);
609
- render();
610
- });
611
- }
612
-
613
- /** One line of input via readline. Resolves the raw (untrimmed) answer. */
614
- function promptLine(prompt) {
615
- return new Promise((resolve) => {
616
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
617
- rl.question(prompt, (ans) => { rl.close(); resolve(ans); });
618
- });
619
- }
620
-
621
- /** Free-text prompt; empty input falls back to `def`. */
622
- async function promptText({ label, def = '', hint = '' }) {
623
- const tail = def ? c.dim(` [${def}]`) : '';
624
- const ans = await promptLine(` ${c.cyan('?')} ${label}${tail}${hint ? ' ' + c.dim(hint) : ''}\n ${c.cyan('›')} `);
625
- return ans.trim() || def;
626
- }
627
-
628
- /** Yes/No prompt; empty input falls back to `def`. */
629
- async function confirm({ label, def = false }) {
630
- const ans = (await promptLine(` ${c.cyan('?')} ${label} ${c.dim(def ? '[Y/n]' : '[y/N]')} `)).trim().toLowerCase();
631
- if (!ans) return def;
632
- return ans[0] === 'y';
633
- }
634
-
635
- /** Positive-integer prompt; empty input falls back to `def`, re-asks on garbage. */
636
- async function promptInt({ label, def, min = 1 }) {
637
- for (;;) {
638
- const raw = await promptText({ label, def: String(def) });
639
- const n = parseInt(raw, 10);
640
- if (Number.isInteger(n) && n >= min) return n;
641
- log(c.yellow(` Please enter a whole number ≥ ${min} (Enter = ${def}).`));
642
- }
643
- }
644
-
645
- /** Normalise free-form Ollama input — bare port, host:port, or full URL → URL. */
646
- function normalizeHost(raw) {
647
- let v = (raw || '').trim();
648
- if (!v) return 'http://localhost:11434';
649
- if (/^\d+$/.test(v)) return `http://localhost:${v}`; // bare port
650
- if (!/^https?:\/\//.test(v)) v = 'http://' + v; // add scheme
651
- return v.replace('://0.0.0.0', '://localhost');
652
- }
653
-
654
- /** GET {host}/api/tags → array of installed model names, or null if unreachable.
655
- * Deduplicates `:latest` variants — `nomic-embed-text:latest` and `nomic-embed-text`
656
- * are the same model; keep only the short canonical form. */
657
- async function listOllamaModels(host) {
658
- try {
659
- const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(2500) });
660
- if (!res.ok) return null;
661
- const data = await res.json();
662
- const raw = (data.models || []).map(m => m.name).filter(Boolean);
663
- // Canonical name = strip trailing `:latest` (Ollama's implicit default tag).
664
- // If both `foo` and `foo:latest` appear, keep `foo` and drop the tagged form.
665
- const canonical = raw.map(n => n.endsWith(':latest') ? n.slice(0, -7) : n);
666
- return [...new Set(canonical)];
667
- } catch { return null; }
668
- }
669
-
670
- /** GET {host}/v1/models → { running, loadedModel } for mlx_lm.server. */
671
- async function probeMlxLmServer(host, timeoutMs = 3000) {
672
- try {
673
- const res = await fetch(`${host}/v1/models`, { signal: AbortSignal.timeout(timeoutMs) });
674
- if (!res.ok) return { running: false, loadedModel: null };
675
- const data = await res.json();
676
- const first = (data.data || [])[0];
677
- return { running: true, loadedModel: first?.id || first?.name || null };
678
- } catch { return { running: false, loadedModel: null }; }
679
- }
680
-
681
- /** True iff `import mlx_lm` succeeds under the given interpreter. */
682
- function pythonHasMlxLm(py) {
683
- const r = spawnSync(py, ['-c', 'import mlx_lm'], { stdio: 'ignore', timeout: 8000 });
684
- return r.status === 0;
685
- }
686
-
687
- /** First interpreter with mlx_lm importable (shared MLX venv first), or null. */
688
- function findMlxLmPython() {
689
- for (const py of [mlxVenvPython(), 'python3', 'python']) {
690
- if (pythonHasMlxLm(py)) return py;
691
- }
692
- return null;
693
- }
694
-
695
- /**
696
- * Install mlx-lm into the shared MLX venv (creating the venv if absent). We install
697
- * ONLY mlx-lm here — an LLM-only user shouldn't pull the full embeddings stack.
698
- * Returns { ok, py?, error? }.
699
- */
700
- function installMlxLm({ log: logFn }) {
701
- const py = path.join(mlxVenvDir, 'bin', 'python3');
702
- if (!fs.existsSync(py)) {
703
- const base = ['python3.12', 'python3.11', 'python3.10', 'python3']
704
- .find(b => spawnSync(b, ['--version'], { stdio: 'ignore' }).status === 0);
705
- if (!base) return { ok: false, error: 'no python3 found on PATH to create the venv' };
706
- logFn(`creating MLX venv (${base} -m venv embedders/venv-mlx)`);
707
- const mk = spawnSync(base, ['-m', 'venv', mlxVenvDir], { stdio: 'inherit' });
708
- if (mk.status !== 0) return { ok: false, error: 'virtualenv creation failed' };
709
- spawnSync(py, ['-m', 'pip', 'install', '-q', '--upgrade', 'pip'], { stdio: 'inherit' });
710
- }
711
- logFn('installing mlx-lm (pip install mlx-lm — this may take a few minutes)');
712
- const pip = spawnSync(py, ['-m', 'pip', 'install', 'mlx-lm'], { stdio: 'inherit' });
713
- if (pip.status !== 0) return { ok: false, error: 'pip install mlx-lm failed' };
714
- return pythonHasMlxLm(py) ? { ok: true, py } : { ok: false, error: 'mlx-lm installed but still not importable' };
715
- }
716
-
717
- /** Fire one tiny completion to force the model to load/download. Best-effort. */
718
- async function warmMlxLmModel(host, model) {
719
- try {
720
- const res = await fetch(`${host}/v1/completions`, {
721
- method: 'POST',
722
- headers: { 'Content-Type': 'application/json' },
723
- body: JSON.stringify({ model, prompt: 'ok', max_tokens: 1, temperature: 0 }),
724
- signal: AbortSignal.timeout(600000), // up to 10 min for a first-time model download
725
- });
726
- return res.ok;
727
- } catch { return false; }
728
- }
729
-
730
- /**
731
- * After MLX LLM provider + models are chosen: verify the path end-to-end so the
732
- * first index run can't fail with "generator unreachable". Warns about the
733
- * single-model constraint, then — if the server is down — offers to install
734
- * mlx-lm (into the shared venv), start the server, and warm up the model.
735
- */
736
- async function checkMlxLmSetup({ mlxLmHost, enrichModel, rerankModel }) {
737
- const targetModel = enrichModel || rerankModel;
738
- if (!targetModel) return null;
739
-
740
- // mlx_lm.server serves exactly one model at a time — warn when both features
741
- // request different models so the user knows only one will actually be used.
742
- if (enrichModel && rerankModel && enrichModel !== rerankModel) {
743
- log('');
744
- log(` ${glyph.warn} ${c.yellow('mlx_lm.server loads one model at a time.')}`);
745
- log(c.dim(` Enrichment : ${enrichModel}`));
746
- log(c.dim(` Reranker : ${rerankModel}`));
747
- log(c.dim(' Both features will use whichever model the server has loaded.'));
748
- log(c.dim(' Tip: use the same model for both (the larger one is usually better).'));
749
- log('');
750
- }
751
-
752
- const probe = await probeMlxLmServer(mlxLmHost);
753
- if (probe.running) {
754
- if (probe.loadedModel && probe.loadedModel !== targetModel) {
755
- log('');
756
- log(` ${glyph.warn} ${c.yellow('mlx_lm.server is running but has a different model loaded.')}`);
757
- log(c.dim(` Loaded : ${probe.loadedModel}`));
758
- log(c.dim(` Expected : ${targetModel}`));
759
- log(c.dim(` Restart it with: ${c.cyan(`mlx_lm.server --model ${targetModel}`)}`));
760
- log('');
761
- } else {
762
- line(glyph.ok, 'mlx_lm.server', `running${probe.loadedModel ? ` · ${probe.loadedModel}` : ''} at ${mlxLmHost}`);
763
- }
764
- return true;
765
- }
766
-
767
- // Server not running — offer to provision and start it end-to-end.
768
- log('');
769
- log(` ${glyph.warn} ${c.yellow(`mlx_lm.server is not reachable at ${mlxLmHost}.`)}`);
770
- log(c.dim(' It must be running for MLX enrichment / reranking to work.'));
771
-
772
- let mlxPy = findMlxLmPython();
773
- if (!mlxPy) {
774
- log(c.dim(' mlx-lm is not installed yet.'));
775
- if (await confirm({ label: 'Install mlx-lm into embedders/venv-mlx now?', def: true })) {
776
- const res = installMlxLm({ log: (s) => log(c.dim(' ' + s)) });
777
- if (res.ok) { mlxPy = res.py; line(glyph.ok, 'mlx-lm', 'installed (embedders/venv-mlx)'); }
778
- else line(glyph.warn, 'mlx-lm', `install failed: ${res.error}`);
779
- }
780
- }
781
-
782
- const port = mlxLmHost.match(/:(\d+)/)?.[1] || '8080';
783
- if (!mlxPy) {
784
- log(c.dim(' Install it, then start the server manually before indexing:'));
785
- log(c.dim(` ${c.cyan('pip install mlx-lm')}`));
786
- log(c.dim(` ${c.cyan(`mlx_lm.server --model ${targetModel} --port ${port}`)}`));
787
- log('');
788
- return false;
789
- }
790
-
791
- if (!await confirm({ label: 'Start mlx_lm.server now? (runs in the background)', def: true })) {
792
- line(glyph.skip, 'mlx_lm.server', 'start it before indexing');
793
- log(c.dim(` ${c.cyan(`${mlxPy} -m mlx_lm.server --model ${targetModel} --port ${port}`)}`));
794
- return false;
795
- }
796
-
797
- const child = spawn(mlxPy, ['-m', 'mlx_lm.server', '--model', targetModel, '--port', port], {
798
- detached: true, stdio: 'ignore',
799
- });
800
- child.unref();
801
- log(c.dim(` started (PID ${child.pid}) — stop later with ${c.cyan(`kill ${child.pid}`)}`));
802
- log(c.dim(' waiting for the server to accept connections…'));
803
-
804
- let ready = false;
805
- for (let i = 0; i < 12 && !ready; i++) {
806
- await new Promise(r => setTimeout(r, 5000));
807
- if ((await probeMlxLmServer(mlxLmHost)).running) { ready = true; break; }
808
- if (i < 11) log(c.dim(` still starting… (${(i + 1) * 5}s)`));
809
- }
810
-
811
- if (!ready) {
812
- log(` ${glyph.warn} ${c.yellow('Server did not respond within 60 s — it may still be starting.')}`);
813
- log(c.dim(` Check: ${c.cyan(`curl ${mlxLmHost}/v1/models`)}`));
814
- return false;
815
- }
816
- line(glyph.ok, 'mlx_lm.server', `running at ${mlxLmHost}`);
817
-
818
- // Warm up so the (potentially large) model is downloaded/loaded before indexing,
819
- // turning a slow, failure-prone first index into a fast one. Opt-in — the download
820
- // can take minutes, and the first index run would otherwise trigger it anyway.
821
- if (await confirm({ label: `Pre-load ${targetModel} now? (downloads weights — avoids a slow first index)`, def: true })) {
822
- log(c.dim(' loading the model (first run downloads weights, please wait)…'));
823
- if (await warmMlxLmModel(mlxLmHost, targetModel)) line(glyph.ok, 'Model', `${targetModel} loaded and ready`);
824
- else log(` ${glyph.warn} ${c.yellow('Warm-up did not finish — the first index run will complete the download.')}`);
825
- }
826
- return true;
827
- }
828
-
829
- /**
830
- * Pick a model. With Ollama reachable: an arrow-select over the installed models
831
- * (the default is always listed and pre-highlighted) plus an "Other…" escape
832
- * hatch for a name to pull later. Offline: a plain text prompt. Either way,
833
- * Enter/empty yields `def`.
834
- */
835
- async function selectModel({ purpose, def, models }) {
836
- if (models && models.length) {
837
- const names = models.includes(def) ? models.slice() : [def, ...models];
838
- const items = names.map(n => ({
839
- key: n, label: n,
840
- desc: models.includes(n) ? c.dim('installed') : c.yellow('not installed — pulled on first use'),
841
- }));
842
- items.push({ key: CUSTOM_MODEL, label: 'Other (type a name)…', desc: '' });
843
- const choice = await interactiveSelect({ title: `Model for ${purpose}`, items, selectedKey: def });
844
- if (choice !== CUSTOM_MODEL) return choice;
845
- return await promptText({ label: `${purpose} model`, def });
846
- }
847
- return await promptText({ label: `${purpose} model`, def, hint: '· Ollama offline — used once pulled' });
848
- }
849
-
850
- /**
851
- * Pick the MLX embedding model. Offers the proven MiniLM 4-bit default plus an
852
- * "Other…" escape hatch for any mlx_embeddings-compatible model id. The chosen id is
853
- * passed to the Python server at spawn time and stamped into the embed-meta sidecar.
854
- */
855
- async function selectMlxModel(def) {
856
- const items = [
857
- { key: MLX_MODEL_DEFAULT, label: 'all-MiniLM-L6-v2 (4-bit)', desc: c.dim('recommended · 384-dim · fastest, proven') },
858
- { key: CUSTOM_MODEL, label: 'Other (type a model id)…', desc: c.dim('any mlx_embeddings-compatible model') },
859
- ];
860
- const choice = await interactiveSelect({ title: 'MLX embedding model', items, selectedKey: def });
861
- if (choice !== CUSTOM_MODEL) return choice;
862
- return await promptText({ label: 'MLX model id', def, hint: '· e.g. an mlx-community/* sentence model' });
863
- }
864
-
865
- /**
866
- * Pick an MLX LLM model for enrichment / reranking from a curated list (so the user
867
- * isn't forced to type a long id), plus an "Other…" escape for any instruct model.
868
- */
869
- async function selectMlxLlmModel({ purpose, def }) {
870
- const presets = MLX_LLM_MODELS[purpose] || [];
871
- const items = presets.map(m => ({ key: m.key, label: m.label, desc: c.dim(m.desc) }));
872
- items.push({ key: CUSTOM_MODEL, label: 'Other (type a model id)…', desc: c.dim('any mlx-community/* instruct model') });
873
- const selectedKey = presets.some(m => m.key === def) ? def : presets[0]?.key;
874
- const choice = await interactiveSelect({ title: `MLX model for ${purpose}`, items, selectedKey });
875
- if (choice !== CUSTOM_MODEL) return choice;
876
- return await promptText({ label: `${purpose} model id`, def, hint: '· model name as loaded in mlx_lm.server' });
877
- }
878
-
879
- // ─── Stack config persistence (.graph-indexer/config.json) ───────────────────
880
-
881
- function saveStackConfig({ languages, frameworks, agents, engine, interactive }) {
882
- if (isDryRun) { log(c.dim(` [dry-run] would write ${DATA_DIR_NAME}/${CONFIG_FILE_NAME}`)); return 'skipped'; }
883
- ensureDataDir(PROJECT_ROOT);
884
- const existing = readJsonSafe(PATHS.configPath) || {};
885
- const before = JSON.stringify(existing);
886
-
887
- if (interactive) {
888
- if (languages) existing.languages = languages; else delete existing.languages;
889
- if (frameworks && frameworks.length) existing.frameworks = frameworks; else delete existing.frameworks;
890
- // Always persist the explicit agent choice (an empty array means "none").
891
- if (agents) existing.agents = agents; else delete existing.agents;
892
- } else {
893
- if (languages && !existing.languages) existing.languages = languages;
894
- if (frameworks && frameworks.length && !existing.frameworks) existing.frameworks = frameworks;
895
- if (agents && !existing.agents) existing.agents = agents;
896
- }
897
-
898
- // Merge nested objects so power-user knobs we didn't prompt for (coreRatio,
899
- // topM, poolSize, …) survive a re-run.
900
- if (engine) {
901
- existing.storage = engine.storage;
902
- existing.embeddings = engine.embeddings;
903
- if (engine.embedProvider) existing.embedProvider = engine.embedProvider;
904
- existing.ollamaHost = engine.ollamaHost;
905
- if (engine.llmProvider) existing.llmProvider = engine.llmProvider;
906
- if (engine.mlxLmHost) existing.mlxLmHost = engine.mlxLmHost;
907
- existing.embedModel = engine.embedModel;
908
- if (engine.localEmbedModel) existing.localEmbedModel = engine.localEmbedModel;
909
- if (engine.mlxEmbedModel) existing.mlxEmbedModel = engine.mlxEmbedModel;
910
- existing.enrichment = { ...(existing.enrichment || {}), ...engine.enrichment };
911
- existing.rerank = { ...(existing.rerank || {}), ...engine.rerank };
912
- }
913
-
914
- const after = JSON.stringify(existing);
915
- if (after === before && fs.existsSync(PATHS.configPath)) return 'kept';
916
- const action = fs.existsSync(PATHS.configPath) ? 'updated' : 'created';
917
- fs.writeFileSync(PATHS.configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
918
- return action;
919
- }
920
-
921
- // ─── IDE Detectors & Configurators (merge-safe: never clobber other servers) ──
922
-
923
- /**
924
- * Insert/refresh only the `graph-indexer` entry inside an MCP config file,
925
- * preserving every other server and key the user already has. Returns the action
926
- * taken: 'created' | 'updated' | 'kept'.
927
- */
928
- function upsertMcpServer(configPath, containerKey, serverConfig) {
929
- const existing = readJsonSafe(configPath) || {};
930
- if (!existing[containerKey] || typeof existing[containerKey] !== 'object') existing[containerKey] = {};
931
- const current = existing[containerKey]['graph-indexer'];
932
- if (sameServer(current, serverConfig)) return { action: 'kept', rel: path.relative(PROJECT_ROOT, configPath) };
933
- const action = current ? 'updated' : 'created';
934
- existing[containerKey]['graph-indexer'] = serverConfig;
935
- writeFile(configPath, JSON.stringify(existing, null, 2) + '\n');
936
- return { action, rel: path.relative(PROJECT_ROOT, configPath) || configPath };
937
- }
938
-
939
- function configureVSCode() {
940
- const detected = fs.existsSync(path.join(PROJECT_ROOT, '.vscode'));
941
- return { ...upsertMcpServer(path.join(PROJECT_ROOT, '.vscode', 'mcp.json'), 'servers', SERVER_CONFIG), detected };
942
- }
943
-
944
- function configureCursor() {
945
- const detected = fs.existsSync(path.join(PROJECT_ROOT, '.cursor'));
946
- return { ...upsertMcpServer(path.join(PROJECT_ROOT, '.cursor', 'mcp.json'), 'mcpServers', SERVER_CONFIG), detected };
947
- }
948
-
949
- /** Per-platform Claude Desktop config path (Windows / macOS / Linux+XDG). */
950
- function claudeDesktopConfigPath() {
951
- if (process.platform === 'win32') {
952
- return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
953
- 'Claude', 'claude_desktop_config.json');
954
- }
955
- if (process.platform === 'darwin') {
956
- return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
957
- }
958
- // Linux & other XDG platforms: ~/.config/Claude (honouring $XDG_CONFIG_HOME).
959
- const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
960
- return path.join(xdg, 'Claude', 'claude_desktop_config.json');
961
- }
962
-
963
- function configureClaudeDesktop() {
964
- const configPath = claudeDesktopConfigPath();
965
- if (!fs.existsSync(path.dirname(configPath))) return false; // Claude Desktop not installed
966
- const res = upsertMcpServer(configPath, 'mcpServers', SERVER_CONFIG_GLOBAL);
967
- return { ...res, rel: 'claude_desktop_config.json', detected: true };
968
- }
969
-
970
- function configureClaudeCode() {
971
- // Project-scoped MCP servers for Claude Code live in `.mcp.json` at the repo
972
- // root — NOT `.claude/settings.json` (that file holds settings/permissions and
973
- // is not read as an MCP server source). A .claude dir or CLAUDE.md is only a
974
- // "detected" hint; the write target is always .mcp.json.
975
- const detected = fs.existsSync(path.join(PROJECT_ROOT, '.claude'))
976
- || fs.existsSync(path.join(PROJECT_ROOT, 'CLAUDE.md'))
977
- || fs.existsSync(path.join(PROJECT_ROOT, '.mcp.json'));
978
- const mcpJsonPath = path.join(PROJECT_ROOT, '.mcp.json');
979
- // Honour whichever container key the user already has; fall back to 'mcpServers'.
980
- const existing = readJsonSafe(mcpJsonPath) || {};
981
- const containerKey = existing.servers && typeof existing.servers === 'object' ? 'servers' : 'mcpServers';
982
- return { ...upsertMcpServer(mcpJsonPath, containerKey, SERVER_CONFIG), detected };
983
- }
984
-
985
- // ─── package.json scripts (index + daemon control) ───────────────────────────
986
-
987
- // Script values graph-indexer manages. When it's a local dependency the bare bins
988
- // resolve from node_modules/.bin; otherwise wrap them in `npx -p graph-indexer …`
989
- // so the scripts still run in npx-only Node repos. (The MCP wiring stays
990
- // self-contained regardless — these scripts are a convenience, not the launch path.)
991
- const SCRIPTS_LOCAL = {
992
- 'mcp:index': 'idx-index --repo .',
993
- 'mcp:start': 'idx-mcp',
994
- 'mcp:daemon:start': 'idx-daemon start',
995
- 'mcp:daemon:stop': 'idx-daemon stop',
996
- 'mcp:daemon:restart': 'idx-daemon restart',
997
- 'mcp:daemon:status': 'idx-daemon status',
998
- 'mcp:daemon:logs': 'idx-daemon logs',
999
- };
1000
- const SCRIPTS_NPX = Object.fromEntries(
1001
- Object.entries(SCRIPTS_LOCAL).map(([k, v]) => [k, `npx -y -p graph-indexer ${v}`]),
1002
- );
1003
- const CANON_SCRIPTS = localDep ? SCRIPTS_LOCAL : SCRIPTS_NPX;
1004
-
1005
- // Values any graph-indexer init has written (either form) — safe to refresh on
1006
- // upgrade or when the local-dep decision flips. Anything else under these keys is
1007
- // a user customisation we must not touch.
1008
- const OLD_SCRIPT_VALUES = {};
1009
- for (const k of Object.keys(SCRIPTS_LOCAL)) OLD_SCRIPT_VALUES[k] = new Set([SCRIPTS_LOCAL[k], SCRIPTS_NPX[k]]);
1010
- OLD_SCRIPT_VALUES['mcp:index'].add('node indexer.mjs --repo .').add('idx-index');
1011
- OLD_SCRIPT_VALUES['mcp:start'].add('node mcp-server.mjs');
1012
-
1013
- function addPackageScripts() {
1014
- const pkgPath = path.join(PROJECT_ROOT, 'package.json');
1015
- if (!fs.existsSync(pkgPath)) return { added: 0, updated: 0, skipped: 'no package.json' };
1016
-
1017
- const pkg = readJsonSafe(pkgPath);
1018
- if (!pkg) return { added: 0, updated: 0, skipped: 'unreadable package.json' };
1019
-
1020
- const scripts = pkg.scripts || {};
1021
- let added = 0, updated = 0;
1022
- for (const [k, v] of Object.entries(CANON_SCRIPTS)) {
1023
- if (!scripts[k]) { scripts[k] = v; added++; }
1024
- else if (scripts[k] !== v && (OLD_SCRIPT_VALUES[k]?.has(scripts[k]))) { scripts[k] = v; updated++; }
1025
- // else: identical (no-op) or a user customisation we must not touch
1026
- }
1027
-
1028
- if (added + updated > 0) {
1029
- pkg.scripts = scripts;
1030
- writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1031
- }
1032
- return { added, updated };
1033
- }
1034
-
1035
- // ─── .gitignore (marker-delimited managed block) ─────────────────────────────
1036
-
1037
- const GITIGNORE_BEGIN = '# >>> graph-indexer >>>';
1038
- const GITIGNORE_END = '# <<< graph-indexer <<<';
1039
- const GITIGNORE_BLOCK = [
1040
- GITIGNORE_BEGIN,
1041
- '# Generated artifacts — https://github.com/MaquinaTech/graph-indexer',
1042
- // Ignore the dir CONTENTS (not the dir itself) so the shared stack config can
1043
- // be re-included — git can't un-ignore a file inside a fully-ignored directory.
1044
- `/${DATA_DIR_NAME}/*`,
1045
- `!/${DATA_DIR_NAME}/${CONFIG_FILE_NAME}`,
1046
- GITIGNORE_END,
1047
- ].join('\n');
1048
-
1049
- // Individual lines pre-v1.4 init used to append; stripped during migration so the
1050
- // single `.graph-indexer/` rule replaces them cleanly.
1051
- const OLD_GITIGNORE_LINES = new Set([
1052
- 'code-index.json', 'code-index.embeddings.bin', 'code-index.json.tmp',
1053
- 'code-index.embeddings.bin.tmp', 'code-index.db', 'code-index.db-wal',
1054
- 'code-index.db-shm', 'code-index.enrichment.json', '.idx-daemon.pid',
1055
- '.idx-daemon.log', '.graph-indexer.json', '# graph-indexer runtime artifacts',
1056
- ]);
1057
-
1058
- function updateGitignore() {
1059
- const gitignorePath = path.join(PROJECT_ROOT, '.gitignore');
1060
- const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
1061
-
1062
- let body = existing.replace(
1063
- new RegExp(`\\n?${GITIGNORE_BEGIN}[\\s\\S]*?${GITIGNORE_END}\\n?`, 'g'), '\n'
1064
- );
1065
- const cleaned = body.split('\n').filter(line => !OLD_GITIGNORE_LINES.has(line.trim()));
1066
- body = cleaned.join('\n');
1067
-
1068
- const next = body.trimEnd() + (body.trim() ? '\n\n' : '') + GITIGNORE_BLOCK + '\n';
1069
- if (next === existing) return 'kept';
1070
- writeFile(gitignorePath, next);
1071
- return existing.trim() ? 'updated' : 'created';
1072
- }
1073
-
1074
- // ─── Agent prompt assembly (Layer 1 + Layer 2, from prompts/) ────────────────
1075
-
1076
- const PROMPTS_DIR = path.join(__dirname, 'prompts');
1077
-
1078
- function readPromptLayer(relPath) {
1079
- const p = path.join(PROMPTS_DIR, relPath);
1080
- return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8').trim() : null;
1081
- }
1082
-
1083
- /**
1084
- * Resolves which Layer 2 files apply to the chosen stack and concatenates them
1085
- * under the Layer 1 core. Returns { content, layers } or null if even the core
1086
- * prompt is missing (broken install).
1087
- */
1088
- function assembleAgentPrompt(langKeys, frameworkKeys) {
1089
- const layerFiles = ['CORE.md'];
1090
- const seen = new Set(layerFiles);
1091
- for (const key of langKeys) {
1092
- const file = LANGUAGE_PROMPTS[key];
1093
- if (file && !seen.has(file)) { seen.add(file); layerFiles.push(file); }
1094
- }
1095
- for (const key of frameworkKeys) {
1096
- const fw = FRAMEWORKS.find(f => f.key === key);
1097
- if (fw && !seen.has(fw.prompt)) { seen.add(fw.prompt); layerFiles.push(fw.prompt); }
1098
- }
1099
-
1100
- const parts = [];
1101
- const layers = [];
1102
- for (const file of layerFiles) {
1103
- const body = readPromptLayer(file);
1104
- if (body) {
1105
- parts.push(body);
1106
- layers.push(file.replace(/\.md$/, '').toLowerCase());
1107
- }
1108
- }
1109
- if (parts.length === 0) return null;
1110
-
1111
- const header = [
1112
- '<!--',
1113
- ' GRAPH_INDEXER_PROMPT.md — generated by `npx graph-indexer init`. Do not edit:',
1114
- ' re-run init after changing languages or frameworks to regenerate.',
1115
- ` Layers: ${layers.join(' + ')}`,
1116
- ' Project-specific rules (Layer 3) belong in GRAPH_INDEXER_DOMAIN.md.',
1117
- '-->',
1118
- ].join('\n');
1119
-
1120
- return { content: header + '\n\n' + parts.join('\n\n') + '\n', layers };
1121
- }
1122
-
1123
- /** Writes the assembled Layer 1+2 prompt. Always regenerated. */
1124
- function writeAssembledPrompt(assembled) {
1125
- const p = path.join(PROJECT_ROOT, 'GRAPH_INDEXER_PROMPT.md');
1126
- const existed = fs.existsSync(p);
1127
- const same = existed && readTextSafe(p) === assembled.content;
1128
- if (same) return 'kept';
1129
- writeFile(p, assembled.content);
1130
- return existed ? 'updated' : 'created';
1131
- }
1132
-
1133
- /** Copies the Layer 3 template once; the user owns it afterwards. */
1134
- function ensureDomainFile() {
1135
- const p = path.join(PROJECT_ROOT, 'GRAPH_INDEXER_DOMAIN.md');
1136
- if (fs.existsSync(p)) return false;
1137
- const template = readPromptLayer('DOMAIN_TEMPLATE.md');
1138
- if (!template) return false;
1139
- writeFile(p, template + '\n');
1140
- return true;
1141
- }
1142
-
1143
- /** Adds @-imports to CLAUDE.md (created if absent); idempotent. */
1144
- function ensureClaudeMdImports() {
1145
- const p = path.join(PROJECT_ROOT, 'CLAUDE.md');
1146
- const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
1147
- if (existing.includes('@GRAPH_INDEXER_PROMPT.md')) return false;
1148
-
1149
- const block = '<!-- graph-indexer agent prompt suite (added by `npx graph-indexer init`) -->\n'
1150
- + '@GRAPH_INDEXER_PROMPT.md\n'
1151
- + '@GRAPH_INDEXER_DOMAIN.md\n';
1152
- const content = existing.trim().length > 0
1153
- ? existing.trimEnd() + '\n\n' + block
1154
- : block;
1155
- writeFile(p, content);
1156
- return existing.trim().length > 0 ? 'updated' : 'created';
1157
- }
1158
-
1159
- const RULE_TAIL = '\n<!-- Layer 3 (project-specific rules): see GRAPH_INDEXER_DOMAIN.md at the repo root. -->\n';
1160
-
1161
- // ─── Per-agent prompt writers (driven by the AGENTS registry below) ──────────
1162
- // A "rule" file is fully owned by graph-indexer: the assembled prompt is written
1163
- // verbatim, with optional YAML frontmatter to make the rule always-on where the
1164
- // agent supports it.
1165
- function writeRuleFile(spec, assembled) {
1166
- const p = path.join(PROJECT_ROOT, ...spec.rel);
1167
- const existed = fs.existsSync(p);
1168
- const fm = spec.frontmatter
1169
- ? ['---',
1170
- 'description: graph-indexer usage rules for AI agents (generated — re-run `npx graph-indexer init` to regenerate)',
1171
- ...spec.frontmatter,
1172
- '---', '', ''].join('\n')
1173
- : '';
1174
- const content = fm + assembled.content + RULE_TAIL;
1175
- if (existed && readTextSafe(p) === content) return 'kept';
1176
- writeFile(p, content);
1177
- return existed ? 'updated' : 'created';
1178
- }
1179
-
1180
- // ─── Shared instruction files (marker-delimited managed block) ───────────────
1181
- // Agents that read a single shared markdown file as custom instructions. The file
1182
- // is shared with the user's own instructions, so our prompt lives in a marker
1183
- // block — regenerated each run, everything around the markers left untouched.
1184
- //
1185
- // `mode` decides what goes INSIDE the block:
1186
- // 'embed' — the full assembled prompt is inlined (the only correct option for
1187
- // agents with no transclusion: Copilot, Junie, Windsurf — they read
1188
- // the file literally, so an `@import` would show as plain text and the
1189
- // rules would silently not apply. AGENTS.md is embedded too: its main
1190
- // reader (OpenAI Codex) does not yet honour `@` imports.)
1191
- // 'import' — only `@./…` references to the canonical GRAPH_INDEXER_*.md files, so
1192
- // there is no duplication. Used ONLY where the agent has a documented,
1193
- // reliable import processor (Gemini CLI's Memory Import Processor).
1194
- const BLOCK_BEGIN = '<!-- >>> graph-indexer >>> -->';
1195
- const BLOCK_END = '<!-- <<< graph-indexer <<< -->';
1196
- // Gemini's import processor resolves `@./` relative to the file; both targets sit
1197
- // at the repo root next to GEMINI.md. (Claude Code's CLAUDE.md uses the bare `@`.)
1198
- const IMPORT_BODY = '@./GRAPH_INDEXER_PROMPT.md\n@./GRAPH_INDEXER_DOMAIN.md';
1199
-
1200
- function writeManagedBlockFile(spec, assembled) {
1201
- const p = path.join(PROJECT_ROOT, ...spec.rel);
1202
- const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
1203
-
1204
- const block = [
1205
- BLOCK_BEGIN,
1206
- '<!-- graph-indexer agent prompt suite — generated by `npx graph-indexer init`.',
1207
- ' Do not edit inside these markers; re-run init to regenerate. Project-specific',
1208
- ' rules (Layer 3) belong in GRAPH_INDEXER_DOMAIN.md at the repo root. -->',
1209
- '',
1210
- spec.mode === 'import' ? IMPORT_BODY : assembled.content.trim(),
1211
- BLOCK_END,
1212
- ].join('\n');
1213
-
1214
- // Strip any prior managed block, then re-append the fresh one (markers contain
1215
- // no regex-special characters, so they need no escaping — same as .gitignore).
1216
- const body = existing.replace(
1217
- new RegExp(`\\n?${BLOCK_BEGIN}[\\s\\S]*?${BLOCK_END}\\n?`, 'g'), '\n'
1218
- );
1219
- const next = body.trimEnd() + (body.trim() ? '\n\n' : '') + block + '\n';
1220
- if (next === existing) return 'kept';
1221
- writeFile(p, next);
1222
- return existing.trim() ? 'updated' : 'created';
1223
- }
1224
-
1225
- // ─── Unified agent / IDE registry ────────────────────────────────────────────
1226
- // One row per supported coding agent, tying together its (optional) MCP-server
1227
- // wiring and its (optional) prompt-instruction file. The "Agents & IDEs" step
1228
- // lets the user multi-select from this list; the MCP-wiring and Agent-instruction
1229
- // steps then act ONLY on the chosen rows — so deselecting an agent generates
1230
- // nothing for it (no MCP entry, no prompt file). `detect()` (read-only) decides
1231
- // which rows are pre-checked on first run. `prompt.kind`: 'claude' = CLAUDE.md
1232
- // @-imports · 'rule' = owned rule file · 'shared' = managed block ('embed' inline
1233
- // or 'import' @-references where the agent has a reliable import processor).
1234
- const agentPath = (...p) => path.join(PROJECT_ROOT, ...p);
1235
- const AGENTS = [
1236
- {
1237
- key: 'claude-code', label: 'Claude Code', hint: 'CLAUDE.md · .mcp.json',
1238
- detect: () => fs.existsSync(agentPath('.claude')) || fs.existsSync(agentPath('CLAUDE.md')) || fs.existsSync(agentPath('.mcp.json')),
1239
- mcp: { name: 'Claude Code', fn: () => configureClaudeCode() },
1240
- prompt: { kind: 'claude' },
1241
- },
1242
- {
1243
- key: 'claude-desktop', label: 'Claude Desktop', hint: 'global MCP config',
1244
- detect: () => fs.existsSync(path.dirname(claudeDesktopConfigPath())),
1245
- mcp: { name: 'Claude Desktop', fn: () => configureClaudeDesktop() },
1246
- prompt: null,
1247
- },
1248
- {
1249
- key: 'cursor', label: 'Cursor', hint: '.cursor/rules · .cursor/mcp.json',
1250
- detect: () => fs.existsSync(agentPath('.cursor')),
1251
- mcp: { name: 'Cursor', fn: () => configureCursor() },
1252
- prompt: { kind: 'rule', rel: ['.cursor', 'rules', 'graph-indexer.mdc'], label: 'always-on Cursor rule', frontmatter: ['alwaysApply: true'] },
1253
- },
1254
- {
1255
- key: 'vscode-copilot', label: 'VS Code / Copilot', hint: '.vscode/mcp.json · copilot-instructions.md',
1256
- detect: () => fs.existsSync(agentPath('.vscode')) || fs.existsSync(agentPath('.github', 'copilot-instructions.md')),
1257
- mcp: { name: 'VS Code', fn: () => configureVSCode() },
1258
- prompt: { kind: 'shared', rel: ['.github', 'copilot-instructions.md'], label: 'GitHub Copilot', mode: 'embed' },
1259
- },
1260
- {
1261
- key: 'windsurf', label: 'Windsurf', hint: '.windsurf/rules',
1262
- detect: () => fs.existsSync(agentPath('.windsurf')),
1263
- mcp: null,
1264
- prompt: { kind: 'rule', rel: ['.windsurf', 'rules', 'graph-indexer.md'], label: 'always-on Windsurf rule', frontmatter: ['trigger: always_on'] },
1265
- },
1266
- {
1267
- key: 'cline', label: 'Cline / Roo Code', hint: '.clinerules',
1268
- detect: () => fs.existsSync(agentPath('.clinerules')) || fs.existsSync(agentPath('.roo')),
1269
- mcp: null,
1270
- prompt: { kind: 'rule', rel: ['.clinerules', 'graph-indexer.md'], label: 'Cline / Roo Code rule', frontmatter: null },
1271
- },
1272
- {
1273
- key: 'junie', label: 'JetBrains Junie', hint: '.junie/guidelines.md',
1274
- detect: () => fs.existsSync(agentPath('.junie')) || fs.existsSync(agentPath('.idea')),
1275
- mcp: null,
1276
- prompt: { kind: 'shared', rel: ['.junie', 'guidelines.md'], label: 'JetBrains Junie', mode: 'embed' },
1277
- },
1278
- {
1279
- key: 'codex', label: 'Codex / AGENTS.md', hint: 'AGENTS.md (Codex · Zed · Jules)',
1280
- detect: () => fs.existsSync(agentPath('AGENTS.md')) || fs.existsSync(agentPath('.codex')),
1281
- mcp: null,
1282
- prompt: { kind: 'shared', rel: ['AGENTS.md'], label: 'AGENTS.md standard (Codex · Zed · Jules · …)', mode: 'embed' },
1283
- },
1284
- {
1285
- key: 'gemini', label: 'Gemini CLI', hint: 'GEMINI.md',
1286
- detect: () => fs.existsSync(agentPath('GEMINI.md')) || fs.existsSync(agentPath('.gemini')),
1287
- mcp: null,
1288
- prompt: { kind: 'shared', rel: ['GEMINI.md'], label: 'Gemini CLI', mode: 'import' },
1289
- },
1290
- ];
1291
-
1292
- /**
1293
- * Writes the prompt file(s) for one selected agent, dispatching on prompt.kind.
1294
- * Returns { action, rel, detail } for the summary ledger, or null if the agent
1295
- * has no prompt integration (e.g. Claude Desktop — MCP only).
1296
- */
1297
- function writeAgentPrompt(agent, assembled) {
1298
- const p = agent.prompt;
1299
- if (!p) return null;
1300
- if (p.kind === 'claude') {
1301
- const res = ensureClaudeMdImports();
1302
- return { action: res || 'kept', rel: 'CLAUDE.md', detail: res ? '@-imports for Claude Code' : 'imports already present' };
1303
- }
1304
- if (p.kind === 'rule') {
1305
- return { action: writeRuleFile(p, assembled), rel: p.rel.join('/'), detail: p.label };
1306
- }
1307
- const detail = p.mode === 'import' ? `@-imports for ${p.label} (no duplication)` : `managed block for ${p.label}`;
1308
- return { action: writeManagedBlockFile(p, assembled), rel: p.rel.join('/'), detail };
1309
- }
1310
-
1311
- // ─── Pre-flight: migrate any pre-v1.4 root layout into .graph-indexer/ ────────
1312
-
1313
- function runMigration() {
1314
- if (!hasLegacyLayout(PROJECT_ROOT)) return;
1315
-
1316
- log('\n' + c.bold(' ' + glyph.move + ' Tidying project layout'));
1317
-
1318
- let stoppedDaemon = false;
1319
- for (const pf of [path.join(PROJECT_ROOT, '.idx-daemon.pid'), PATHS.pidFile]) {
1320
- const pid = readPid(pf);
1321
- if (isAlive(pid)) {
1322
- try { if (!isDryRun) process.kill(pid, 'SIGTERM'); stoppedDaemon = true; } catch { /* gone */ }
1323
- }
1324
- }
1325
- if (stoppedDaemon) act('migrated', 'Stopped a running daemon', 'restart with npm run mcp:daemon:start');
1326
-
1327
- if (isDryRun) {
1328
- act('migrated', `Would relocate root artifacts → ${DATA_DIR_NAME}/`, 'run without --dry-run to apply');
1329
- return;
1330
- }
1331
-
1332
- const { moved, removed } = migrateLegacyLayout(PROJECT_ROOT);
1333
- for (const m of moved) act('migrated', `${m.from} → ${m.to}`);
1334
- for (const r of removed) act('migrated', `Removed stale ${r}`, 'superseded by .graph-indexer/');
1335
- if (moved.length === 0 && removed.length === 0) act('kept', 'Layout already tidy');
1336
- }
1337
-
1338
- // ─── Main ─────────────────────────────────────────────────────────────────────
1339
-
1340
- const pkgSelf = readJsonSafe(path.join(__dirname, 'package.json')) || {};
1341
- const scan = detectLanguages(PROJECT_ROOT);
1342
- const detectedLanguages = scan.langs;
1343
- const detectedFrameworks = detectFrameworks(PROJECT_ROOT);
1344
-
1345
- log('');
1346
- log(box([
1347
- `${c.bold('⚡ graph-indexer')} ${c.dim('· init')}${pkgSelf.version ? ' ' + c.dim('v' + pkgSelf.version) : ''}${isDryRun ? ' ' + c.yellow('[dry-run]') : ''}`,
1348
- c.dim('AST-precise, air-gapped code search for your AI agents'),
1349
- ]));
1350
- log('');
1351
- log(` ${c.dim('Project')} ${PROJECT_ROOT}`);
1352
- log(` ${c.dim('Mode')} ${isInteractive ? 'interactive' : 'non-interactive (auto-detect)'}`);
1353
- const stackPreview = [
1354
- detectedLanguages.size ? `${detectedLanguages.size} language(s)` : null,
1355
- detectedFrameworks.size ? `${detectedFrameworks.size} framework(s)` : null,
1356
- ].filter(Boolean).join(', ');
1357
- log(` ${c.dim('Detected')} ${stackPreview ? stackPreview + c.dim(' in this repo') : c.dim('no known stack auto-detected')}`);
1358
-
1359
- runMigration();
1360
-
1361
-
1362
- stepHeader(1, 'Languages to index');
1363
- log(c.dim(' Each language\'s Tree-sitter grammar installs on first index build (not bundled — keeps a fresh install small); already-installed grammars are reused.'));
1364
-
1365
- let selectedLanguages = null;
1366
- if (isInteractive) {
1367
- const picked = await interactiveMultiSelect({
1368
- title: 'Select languages',
1369
- items: LANGUAGES.map(l => ({ key: l.key, label: l.label, desc: c.dim(l.exts) })),
1370
- preselected: detectedLanguages,
1371
- });
1372
- selectedLanguages = picked.length > 0 ? picked : null;
1373
- }
1374
-
1375
- if (selectedLanguages) {
1376
- line(glyph.ok, 'Languages', selectedLanguages.map(k => LANGUAGES.find(l => l.key === k)?.label || k).join(', '));
1377
- } else {
1378
- line(glyph.ok, 'Languages', 'all supported (default)');
1379
- if (detectedLanguages.size > 0) log(c.dim(` detected: ${Array.from(detectedLanguages).join(', ')}`));
1380
- log(c.dim(' selecting a subset above installs only those grammars — narrower is faster/smaller'));
1381
- }
1382
-
1383
-
1384
- stepHeader(2, 'Frameworks (sharpen the agent prompt)');
1385
-
1386
- const promptLanguages = selectedLanguages || Array.from(detectedLanguages);
1387
- const availableFrameworks = FRAMEWORKS.filter(f => f.langs.some(l => promptLanguages.includes(l)));
1388
-
1389
- let selectedFrameworks = [];
1390
- if (availableFrameworks.length === 0) {
1391
- line(glyph.skip, 'Frameworks', 'none apply to this language selection');
1392
- } else if (isInteractive) {
1393
- selectedFrameworks = await interactiveMultiSelect({
1394
- title: 'Select frameworks',
1395
- items: availableFrameworks.map(f => ({ key: f.key, label: f.label, desc: c.dim(f.hint) })),
1396
- preselected: detectedFrameworks,
1397
- });
1398
- if (selectedFrameworks.length > 0) {
1399
- line(glyph.ok, 'Frameworks', selectedFrameworks.map(k => FRAMEWORKS.find(f => f.key === k)?.label || k).join(', '));
1400
- } else {
1401
- line(glyph.skip, 'Frameworks', 'none selected — language rules only');
1402
- }
1403
- } else {
1404
- selectedFrameworks = availableFrameworks.filter(f => detectedFrameworks.has(f.key)).map(f => f.key);
1405
- if (selectedFrameworks.length > 0) {
1406
- line(glyph.ok, 'Frameworks', 'auto-detected: ' + selectedFrameworks.map(k => FRAMEWORKS.find(f => f.key === k)?.label || k).join(', '));
1407
- } else {
1408
- line(glyph.skip, 'Frameworks', 'none detected — language rules only');
1409
- }
1410
- }
1411
-
1412
-
1413
- stepHeader(3, 'Search engine & LLM features');
1414
-
1415
- let engineConfig = null;
1416
- // MLX LLM readiness, surfaced to the "Build the index now?" step so we don't
1417
- // kick off an index that will fail enrichment/reranking against a down server.
1418
- // null = not applicable, true = confirmed running, false = configured but not ready.
1419
- let mlxServerReady = null;
1420
- {
1421
- const existing = readJsonSafe(PATHS.configPath) || {};
1422
- const exEnrich = existing.enrichment || {};
1423
- const exRerank = existing.rerank || {};
1424
-
1425
- if (!isInteractive) {
1426
- line(glyph.skip, 'Engine', 'using defaults / existing config (non-interactive)');
1427
- } else if (await confirm({ label: 'Use recommended defaults? (lexical search · no LLM · no network)', def: true })) {
1428
- const keepStorage = existing.storage === 'memory' || existing.storage === 'sqlite' ? existing.storage : 'auto';
1429
- engineConfig = {
1430
- storage: keepStorage,
1431
- embeddings: false,
1432
- embedProvider: 'off',
1433
- ollamaHost: existing.ollamaHost || 'http://localhost:11434',
1434
- embedModel: existing.embedModel || 'nomic-embed-text',
1435
- localEmbedModel: existing.localEmbedModel || 'Xenova/all-MiniLM-L6-v2',
1436
- mlxEmbedModel: existing.mlxEmbedModel || MLX_MODEL_DEFAULT,
1437
- enrichment: { enabled: false },
1438
- rerank: { enabled: false },
1439
- };
1440
- line(glyph.ok, 'Engine', 'recommended defaults — lexical search, no LLM, no network');
1441
- } else {
1442
- const storage = await interactiveSelect({
1443
- title: 'Storage backend',
1444
- items: [
1445
- { key: 'auto', label: 'Auto', desc: c.dim('recommended · in-memory now, SQLite past 15k chunks') },
1446
- { key: 'memory', label: 'In-memory', desc: c.dim('force · fastest · ideal for most repos') },
1447
- { key: 'sqlite', label: 'SQLite', desc: c.dim('force · persistent · for very large repos (1M+ LOC)') },
1448
- ],
1449
- selectedKey: existing.storage || 'auto',
1450
- });
1451
-
1452
- const engineItems = [
1453
- { key: 'off', label: 'Lexical only', desc: c.dim('default · keyword/symbol + stemming · no vectors, no dependencies') },
1454
- { key: 'auto', label: 'Auto', desc: c.dim('Ollama if running, else offers to install a bundled local model') },
1455
- { key: 'ollama', label: 'Ollama', desc: c.dim('highest quality · needs the Ollama app + a pulled model') },
1456
- { key: 'local', label: 'Local (in-process)', desc: c.dim('no daemon · installs @huggingface/transformers (~50 MB) once + a ~25 MB model on first index') },
1457
- ];
1458
- // MLX runs on the Apple Metal GPU and is macOS-only; only offer it there.
1459
- if (process.platform === 'darwin') {
1460
- engineItems.push({ key: 'mlx', label: 'Apple Metal (MLX)', desc: c.dim('fastest local vectors · auto-installs a Python venv (~900 MB)') });
1461
- }
1462
- const embedProvider = await interactiveSelect({
1463
- title: 'Semantic search engine',
1464
- items: engineItems,
1465
- selectedKey: existing.embeddings === true ? (existing.embedProvider || 'auto') : 'off',
1466
- });
1467
- const embeddingsEnabled = embedProvider !== 'off';
1468
-
1469
- // Ollama is probed lazily — only when something actually needs it (embeddings
1470
- // via ollama/auto, or an Ollama LLM provider chosen later). "Lexical only",
1471
- // "Local", and "Apple Metal" users are never asked for an Ollama URL.
1472
- let ollamaHost = existing.ollamaHost || 'http://localhost:11434';
1473
- let models = null;
1474
- let ollamaProbed = false;
1475
- const ensureOllamaProbed = async () => {
1476
- if (ollamaProbed) return;
1477
- ollamaProbed = true;
1478
- ollamaHost = normalizeHost(await promptText({ label: 'Ollama host', hint: '(URL or port)', def: ollamaHost }));
1479
- log(c.dim(' probing Ollama…'));
1480
- models = await listOllamaModels(ollamaHost);
1481
- if (models) line(glyph.ok, 'Ollama', `${models.length} model(s) at ${ollamaHost}`);
1482
- else line(glyph.warn, 'Ollama', `not reachable at ${ollamaHost} — you can still name models to pull later`);
1483
- };
1484
-
1485
- let embedModel = existing.embedModel || 'nomic-embed-text';
1486
- if (embedProvider === 'ollama' || embedProvider === 'auto') {
1487
- await ensureOllamaProbed();
1488
- embedModel = await selectModel({ purpose: 'embeddings', def: embedModel, models });
1489
- }
1490
- const localEmbedModel = existing.localEmbedModel || 'Xenova/all-MiniLM-L6-v2';
1491
-
1492
- // MLX (Apple Metal): let the user pick the model, then offer to provision the
1493
- // dedicated Python venv right now so the first index doesn't fail on missing deps.
1494
- let mlxEmbedModel = existing.mlxEmbedModel || MLX_MODEL_DEFAULT;
1495
- if (embedProvider === 'mlx') {
1496
- mlxEmbedModel = await selectMlxModel(mlxEmbedModel);
1497
- if (mlxEnvReady()) {
1498
- line(glyph.ok, 'MLX', 'environment ready (embedders/venv-mlx)');
1499
- } else if (await confirm({ label: 'Set up the MLX environment now? (creates embedders/venv-mlx + pip install, ~900 MB)', def: true })) {
1500
- log(c.dim(' provisioning MLX venv — downloads MLX wheels, may take a few minutes…'));
1501
- const res = ensureMlxEnv({ autoInstall: true, log: (s) => log(c.dim(' ' + s)) });
1502
- if (res.ready) line(glyph.ok, 'MLX', 'environment ready (embedders/venv-mlx)');
1503
- else line(glyph.warn, 'MLX', `setup incomplete: ${res.error} — run ${c.cyan('npm run embed:setup:mlx')} later`);
1504
- } else {
1505
- line(glyph.skip, 'MLX', `setup deferred — run ${c.cyan('npm run embed:setup:mlx')} before indexing`);
1506
- }
1507
- }
1508
-
1509
- // Local (in-process) embeddings: the @huggingface/transformers + ONNX runtime
1510
- // stack (100s of MB) is a devDependency, NOT shipped to end users by default
1511
- // (npm's `optionalDependencies` installs eagerly regardless of "optional" in
1512
- // its name — the opposite of what we want here), so it has to be installed on
1513
- // demand. Same "provision now" pattern as MLX above. `auto` only needs this
1514
- // when Ollama actually isn't reachable, so its "no setup" promise stays true
1515
- // for anyone who already has Ollama running.
1516
- if (embedProvider === 'local' || (embedProvider === 'auto' && ollamaProbed && !models)) {
1517
- if (await localEmbedAvailable()) {
1518
- line(glyph.ok, 'Local embeddings', '@huggingface/transformers installed');
1519
- } else if (localDep && await confirm({ label: 'Install the local embedding package now? (npm i @huggingface/transformers, ~50 MB)', def: true })) {
1520
- log(c.dim(' installing @huggingface/transformers…'));
1521
- const res = spawnSync(NPM_BIN, ['install', '@huggingface/transformers'], { cwd: PROJECT_ROOT, stdio: 'inherit' });
1522
- if (res.status === 0) line(glyph.ok, 'Local embeddings', 'installed');
1523
- else line(glyph.warn, 'Local embeddings', `install failed — run ${c.cyan('npm i @huggingface/transformers')} manually`);
1524
- } else {
1525
- line(glyph.skip, 'Local embeddings', `install later with ${c.cyan('npm i @huggingface/transformers')}${localDep ? '' : ' in your project'}`);
1526
- }
1527
- }
1528
-
1529
- const enrichEnabled = await confirm({ label: 'Enable LLM enrichment? (richer semantics, slower indexing)', def: Boolean(exEnrich.enabled) });
1530
-
1531
- // Show a data-driven warning before the reranker question when the detected
1532
- // stack is outside the languages where reranking is known to help.
1533
- const hasGoOrPython = detectedLanguages.has('go') || detectedLanguages.has('python');
1534
- if (!hasGoOrPython && detectedLanguages.size > 0) {
1535
- log('');
1536
- log(` ${glyph.warn} ${c.yellow('Reranker note — measured impact varies by language:')}`);
1537
- log(c.dim(' · Helps Go and Python repos (gin rank-1: 0.20 → 0.40)'));
1538
- log(c.dim(' · Regressions on JavaScript / TypeScript (express rank-1: 0.43 → 0.29)'));
1539
- log(c.dim(` · Detected: ${Array.from(detectedLanguages).join(', ')} — measure before enabling`));
1540
- log('');
1541
- }
1542
-
1543
- const rerankEnabled = await confirm({ label: 'Enable LLM reranker? (one LLM call per query, sharper top hits)', def: Boolean(exRerank.enabled) });
1544
-
1545
- // LLM provider: asked before model selection so the picker is provider-aware.
1546
- // MLX is macOS-only — on other platforms Ollama is the only backend, so we
1547
- // skip the question rather than offer an option that can't run.
1548
- const isMac = process.platform === 'darwin';
1549
- let llmProvider = (existing.llmProvider === 'mlx' && isMac) ? 'mlx' : 'ollama';
1550
- let mlxLmHost = existing.mlxLmHost || 'http://localhost:8080';
1551
- if (enrichEnabled || rerankEnabled) {
1552
- if (isMac) {
1553
- llmProvider = await interactiveSelect({
1554
- title: 'LLM backend for enrichment / reranking',
1555
- items: [
1556
- { key: 'ollama', label: 'Ollama', desc: c.dim('local daemon · any pulled model') },
1557
- { key: 'mlx', label: 'Apple MLX', desc: c.dim('mlx_lm.server · fastest on Apple Silicon') },
1558
- ],
1559
- selectedKey: llmProvider,
1560
- });
1561
- }
1562
- if (llmProvider === 'mlx') {
1563
- mlxLmHost = await promptText({ label: 'mlx_lm.server URL', def: mlxLmHost, hint: '· e.g. http://localhost:8080' });
1564
- } else {
1565
- await ensureOllamaProbed(); // the model pickers below need the installed list
1566
- }
1567
- }
1568
-
1569
- // Model selection is provider-aware: Ollama → installed list / pull-by-name;
1570
- // MLX → a curated list of proven coder models (or any id you type).
1571
- const enrichment = { enabled: enrichEnabled };
1572
- if (enrichEnabled) {
1573
- if (llmProvider === 'mlx') {
1574
- enrichment.model = await selectMlxLlmModel({ purpose: 'enrichment', def: exEnrich.model || MLX_LLM_DEFAULTS.enrichment });
1575
- } else {
1576
- enrichment.model = await selectModel({ purpose: 'enrichment', def: exEnrich.model || 'qwen2.5-coder:1.5b', models });
1577
- }
1578
- if (await confirm({ label: 'Tune enrichment limits?', def: false })) {
1579
- enrichment.maxChunks = await promptInt({ label: 'Max LLM calls per index run', def: exEnrich.maxChunks || 500 });
1580
- enrichment.concurrency = await promptInt({ label: 'Parallel LLM requests', def: exEnrich.concurrency || 4 });
1581
- }
1582
- }
1583
-
1584
- const rerank = { enabled: rerankEnabled };
1585
- if (rerankEnabled) {
1586
- if (llmProvider === 'mlx') {
1587
- rerank.model = await selectMlxLlmModel({ purpose: 'reranker', def: exRerank.model || MLX_LLM_DEFAULTS.reranker });
1588
- } else {
1589
- rerank.model = await selectModel({ purpose: 'reranker', def: exRerank.model || 'qwen2.5-coder:7b', models });
1590
- }
1591
- if (await confirm({ label: 'Tune reranker limits?', def: false })) {
1592
- rerank.topM = await promptInt({ label: 'Candidates shown to the judge', def: exRerank.topM || 12 });
1593
- rerank.poolSize = await promptInt({ label: 'Over-fetch pool size', def: exRerank.poolSize || 15 });
1594
- }
1595
- }
1596
-
1597
- // For MLX: verify the server is reachable and models are consistent, and
1598
- // remember whether it's ready so the build step can warn before failing.
1599
- if (llmProvider === 'mlx' && (enrichEnabled || rerankEnabled)) {
1600
- mlxServerReady = await checkMlxLmSetup({
1601
- mlxLmHost,
1602
- enrichModel: enrichEnabled ? enrichment.model : null,
1603
- rerankModel: rerankEnabled ? rerank.model : null,
1604
- });
1605
- }
1606
-
1607
- engineConfig = { storage, embeddings: embeddingsEnabled, embedProvider, ollamaHost, embedModel, localEmbedModel, mlxEmbedModel, llmProvider, mlxLmHost, enrichment, rerank };
1608
-
1609
- const embedSummary = embedProvider === 'off' ? 'lexical only'
1610
- : embedProvider === 'local' ? `local · ${localEmbedModel}`
1611
- : embedProvider === 'mlx' ? `MLX · ${mlxEmbedModel}`
1612
- : embedProvider === 'ollama' ? `Ollama · ${embedModel}`
1613
- : `auto · Ollama ${embedModel} → local`;
1614
- line(glyph.ok, 'Backend', storage === 'sqlite' ? 'SQLite (large repos)'
1615
- : storage === 'memory' ? 'In-memory (forced)' : 'Auto (in-memory → SQLite past 15k chunks)');
1616
- line(embedProvider === 'off' ? glyph.skip : glyph.ok, 'Embeddings', embedSummary);
1617
- const llmProviderLabel = llmProvider === 'mlx' ? `MLX · ${mlxLmHost}` : `Ollama · ${ollamaHost}`;
1618
- line(enrichEnabled ? glyph.ok : glyph.skip, 'Enrichment', enrichEnabled ? `${enrichment.model} via ${llmProviderLabel}` : 'disabled (default)');
1619
- line(rerankEnabled ? glyph.ok : glyph.skip, 'Reranker', rerankEnabled ? `${rerank.model} via ${llmProviderLabel}` : 'disabled (default)');
1620
- }
1621
-
1622
- // Warn (never fail): 'auto' can promote to SQLite past ~15k chunks, so flag
1623
- // large repos too — not just explicitly forced SQLite.
1624
- const effStorage = engineConfig ? engineConfig.storage : (existing.storage || 'auto');
1625
- if (nodeMajor < 22) {
1626
- if (effStorage === 'sqlite') {
1627
- act('warn', `SQLite backend needs Node 22+ (Node ${process.versions.node} active)`,
1628
- 'upgrade Node, or force in-memory (INDEXER_STORAGE=memory)');
1629
- } else if (effStorage === 'auto' && (scan.truncated || scan.fileCount > 2000)) {
1630
- act('warn', `Large repo on Node ${process.versions.node}`,
1631
- 'auto storage switches to SQLite past ~15k chunks, which needs Node 22+');
1632
- }
1633
- }
1634
- }
1635
-
1636
-
1637
- stepHeader(4, 'Agents & IDEs');
1638
-
1639
- // Which agents to wire up. Pre-checked default = your saved choice (a previous
1640
- // init), else the agents detected in this repo, else all supported. The chosen
1641
- // set drives BOTH the MCP wiring (step 5) and the agent prompts (step 7), so you
1642
- // pick your tools once and graph-indexer generates nothing for the rest.
1643
- const detectedAgents = new Set(
1644
- AGENTS.filter(a => { try { return a.detect(); } catch { return false; } }).map(a => a.key)
1645
- );
1646
- const savedAgentsRaw = readJsonSafe(PATHS.configPath)?.agents;
1647
- const savedAgents = Array.isArray(savedAgentsRaw)
1648
- ? savedAgentsRaw.filter(k => AGENTS.some(a => a.key === k))
1649
- : null;
1650
- const defaultAgents = savedAgents
1651
- ? new Set(savedAgents)
1652
- : detectedAgents.size ? new Set(detectedAgents)
1653
- : new Set(AGENTS.map(a => a.key));
1654
- const defaultSource = savedAgents ? 'from your saved config'
1655
- : detectedAgents.size ? 'detected in this repo'
1656
- : 'all supported (default)';
1657
-
1658
- let selectedAgentKeys;
1659
- if (isInteractive) {
1660
- const picked = await interactiveMultiSelect({
1661
- title: 'Select the agents / IDEs to wire up',
1662
- items: AGENTS.map(a => ({ key: a.key, label: a.label, desc: c.dim(a.hint) })),
1663
- preselected: defaultAgents,
1664
- detected: detectedAgents,
1665
- });
1666
- selectedAgentKeys = new Set(picked);
1667
- } else {
1668
- selectedAgentKeys = defaultAgents;
1669
- }
1670
- const selectedAgents = AGENTS.filter(a => selectedAgentKeys.has(a.key));
1671
-
1672
- if (selectedAgents.length === 0) {
1673
- line(glyph.skip, 'Agents', 'none selected — no MCP wiring or agent prompts will be generated');
1674
- } else {
1675
- line(glyph.ok, 'Agents', selectedAgents.map(a => a.label).join(', '));
1676
- log(c.dim(` ${defaultSource}`));
1677
- }
1678
-
1679
-
1680
- stepHeader(5, 'MCP server wiring');
1681
-
1682
- const mcpAgents = selectedAgents.filter(a => a.mcp);
1683
- if (mcpAgents.length === 0) {
1684
- line(glyph.skip, 'MCP wiring', selectedAgents.length ? 'no selected agent uses an MCP config' : 'no agents selected');
1685
- } else {
1686
- for (const { mcp } of mcpAgents) {
1687
- try {
1688
- const result = mcp.fn();
1689
- if (result === false) { act('skipped', mcp.name, 'not installed'); continue; }
1690
- const { action, rel, detected } = result;
1691
- act(action, mcp.name, detected ? `${rel} · detected` : `${rel} · ready if you use ${mcp.name}`);
1692
- } catch (e) {
1693
- act('warn', mcp.name, 'error: ' + e.message);
1694
- }
1695
- }
1696
- }
1697
-
1698
-
1699
- stepHeader(6, 'Project files & daemon control');
1700
-
1701
- const scriptRes = addPackageScripts();
1702
- if (scriptRes.skipped) {
1703
- act('skipped', 'package.json scripts', scriptRes.skipped);
1704
- } else if (scriptRes.added + scriptRes.updated === 0) {
1705
- act('kept', 'package.json scripts', 'already present');
1706
- } else {
1707
- act(scriptRes.added > 0 ? 'created' : 'updated', 'package.json scripts',
1708
- `mcp:index, mcp:start, mcp:daemon:* (${scriptRes.added} added${scriptRes.updated ? `, ${scriptRes.updated} refreshed` : ''})`);
1709
- }
1710
-
1711
- const gi = updateGitignore();
1712
- act(gi, '.gitignore', `${DATA_DIR_NAME}/ (config.json shared)`);
1713
-
1714
- const cfg = saveStackConfig({ languages: selectedLanguages, frameworks: selectedFrameworks, agents: Array.from(selectedAgentKeys), engine: engineConfig, interactive: isInteractive });
1715
- if (cfg !== 'skipped') act(cfg, `${DATA_DIR_NAME}/${CONFIG_FILE_NAME}`, 'stack + engine + agent selection');
1716
-
1717
- if (!isDryRun) ensureDataDir(PROJECT_ROOT);
1718
-
1719
-
1720
- stepHeader(7, 'Agent instructions');
1721
-
1722
- const assembled = assembleAgentPrompt(promptLanguages, selectedFrameworks);
1723
- let layersUsed = [];
1724
-
1725
- if (!assembled) {
1726
- act('warn', 'Prompt suite not found in the installed package', 'see prompts/ in the repo');
1727
- } else {
1728
- layersUsed = assembled.layers;
1729
- log(c.dim(` layers: ${layersUsed.join(' + ')}`));
1730
-
1731
- // Canonical source files — ALWAYS written (they're the single source of truth
1732
- // every agent file points at), independent of the agent selection.
1733
- const promptAction = writeAssembledPrompt(assembled);
1734
- act(promptAction, 'GRAPH_INDEXER_PROMPT.md', 'Layers 1+2 (regenerated each init)');
1735
-
1736
- if (ensureDomainFile()) act('created', 'GRAPH_INDEXER_DOMAIN.md', 'Layer 3 — yours to edit, never overwritten');
1737
- else act('kept', 'GRAPH_INDEXER_DOMAIN.md', 'exists — left untouched');
1738
-
1739
- // Per-agent files — only for the agents chosen in step 4.
1740
- const promptAgents = selectedAgents.filter(a => a.prompt);
1741
- if (promptAgents.length === 0) {
1742
- line(glyph.skip, 'Agent files', selectedAgents.length ? 'no selected agent needs a prompt file' : 'no agents selected');
1743
- } else {
1744
- for (const agent of promptAgents) {
1745
- const r = writeAgentPrompt(agent, assembled);
1746
- if (r) act(r.action, r.rel, r.detail);
1747
- }
1748
- }
1749
- }
1750
-
1751
-
1752
- log('\n' + rule());
1753
- log(` ${c.bold('Summary')}${isDryRun ? c.yellow(' (dry-run — nothing written)') : ''}`);
1754
- log(rule());
1755
-
1756
- const summaryOrder = [
1757
- ['created', 'Created'], ['updated', 'Updated'], ['migrated', 'Migrated'],
1758
- ['kept', 'Kept'], ['skipped', 'Skipped'], ['warn', 'Warnings'],
1759
- ];
1760
- let anySummary = false;
1761
- for (const [kind, label] of summaryOrder) {
1762
- const items = ledger[kind];
1763
- if (!items.length) continue;
1764
- anySummary = true;
1765
- log(` ${GLYPH_FOR[kind]} ${c.bold(label)} ${c.dim(`(${items.length})`)} ${c.dim(items.join(', '))}`);
1766
- }
1767
- if (!anySummary) log(c.dim(' Nothing to do.'));
1768
- if (layersUsed.length > 0) log(`\n ${c.dim('Prompt layers')} ${layersUsed.join(' + ')}`);
1769
-
1770
- // ── Build the index now (interactive) ────────────────────────────────────────
1771
- let indexBuilt = false;
1772
- if (isInteractive && !isDryRun) {
1773
- log('');
1774
- // If MLX enrichment/reranking is configured but the server isn't confirmed up,
1775
- // building now would fail those LLM steps — warn and don't default to yes.
1776
- if (mlxServerReady === false) {
1777
- log(` ${glyph.warn} ${c.yellow('mlx_lm.server is not confirmed running — enrichment / reranking will fail until it is.')}`);
1778
- }
1779
- if (await confirm({ label: 'Build the index now?', def: mlxServerReady !== false })) {
1780
- log('\n ' + c.dim('Running the indexer…') + '\n');
1781
- const res = spawnSync(process.execPath, [path.join(__dirname, 'indexer.mjs'), '--repo', PROJECT_ROOT], { stdio: 'inherit' });
1782
- indexBuilt = res.status === 0;
1783
- log('');
1784
- if (indexBuilt) log(` ${glyph.ok} ${c.bold('Index built.')} ${c.dim('graph-indexer is ready to use.')}`);
1785
- else log(` ${glyph.warn} ${c.yellow('Index build failed.')} ${c.dim('Re-run ' + CMD.index + ' to see the error.')}`);
1786
- }
1787
- }
1788
-
1789
-
1790
- log('\n' + c.bold(' Next steps'));
1791
- let nextStep = 1;
1792
- if (!indexBuilt) log(` ${c.cyan(nextStep++ + '.')} Build the index ${c.dim('→')} ${c.cyan(CMD.index)}`);
1793
- log(` ${c.cyan(nextStep++ + '.')} Restart your editor ${c.dim('→')} loads the MCP server (auto-starts the daemon)`);
1794
- log(` ${c.cyan(nextStep++ + '.')} Control the daemon ${c.dim('→')} ${c.cyan(CMD.daemonStatus)} ${c.dim('| start | stop | restart | logs')}`);
1795
- log(` ${c.cyan(nextStep++ + '.')} Add project rules ${c.dim('→')} edit ${c.cyan('GRAPH_INDEXER_DOMAIN.md')} (Layer 3)`);
1796
- if (selectedAgents.length) log(c.dim(` Wired: ${selectedAgents.map(a => a.label).join(' · ')} — re-run init to change; more in prompts/INTEGRATION.md`));
1797
- else log(c.dim(` No agents wired — re-run init to choose; see prompts/INTEGRATION.md`));
1798
- if (engineConfig && (engineConfig.enrichment.enabled || engineConfig.rerank.enabled)) {
1799
- const llmLabel = engineConfig.llmProvider === 'mlx'
1800
- ? `mlx_lm.server at ${engineConfig.mlxLmHost}`
1801
- : `Ollama at ${engineConfig.ollamaHost}`;
1802
- log(c.dim(` LLM features on — first index run calls ${llmLabel}`));
1803
- }
1804
-
1805
- log('\n' + rule());
1806
- log(` ${glyph.ok} ${c.bold('graph-indexer is ready.')} ${c.dim('Generated files live in ' + DATA_DIR_NAME + '/ — your root stays clean.')}`);
1807
- log(rule() + '\n');
1808
-
1809
- process.exit(0);