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.
- package/CHANGELOG.md +412 -0
- package/README.md +216 -596
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +179 -28
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +107 -440
- package/{sqlite-store.mjs → engine/sqlite.mjs} +137 -42
- package/enrichment.mjs +111 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +151 -42
- package/init.mjs +1603 -171
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +55 -24
- package/package.json +44 -13
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +23 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/search-core.mjs +254 -43
- package/storage.mjs +28 -3
- package/watch-daemon.mjs +69 -23
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- 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 —
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
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
|
-
// ───
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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, -
|
|
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(
|
|
500
|
+
stdout.write(clipLine(` ${title} ${c.dim('(↑↓/Tab move · Space toggle · Enter confirm)')}`) + '\n\n');
|
|
100
501
|
|
|
101
|
-
|
|
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 =
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
stdout.write(
|
|
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
|
-
|
|
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 +
|
|
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) %
|
|
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, -
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
if (
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
935
|
+
return { action, rel: path.relative(PROJECT_ROOT, configPath) || configPath };
|
|
212
936
|
}
|
|
213
937
|
|
|
214
|
-
function
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1017
|
+
if (!pkg) return { added: 0, updated: 0, skipped: 'unreadable package.json' };
|
|
237
1018
|
|
|
238
1019
|
const scripts = pkg.scripts || {};
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 (
|
|
1027
|
+
if (added + updated > 0) {
|
|
251
1028
|
pkg.scripts = scripts;
|
|
252
1029
|
writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
253
1030
|
}
|
|
254
|
-
return
|
|
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
|
-
|
|
264
|
-
'
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
278
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
1520
|
+
const rerankEnabled = await confirm({ label: 'Enable LLM reranker? (one LLM call per query, sharper top hits)', def: Boolean(exRerank.enabled) });
|
|
328
1521
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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);
|