graph-indexer 1.2.0 → 2.0.0

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