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