forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
434 lines (391 loc) · 14.9 KB
/
Copy pathindex.ts
File metadata and controls
434 lines (391 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
/**
* CodeGraph Interactive Installer
*
* Multi-target: writes MCP server config + instructions for the
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
* Uses @clack/prompts for the interactive UI; `runInstallerWithOptions`
* is the non-interactive entry point used by the `--target` /
* `--print-config` CLI flags.
*/
import { execSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import {
ALL_TARGETS,
detectAll,
getTarget,
resolveTargetFlag,
} from './targets/registry';
import type { AgentTarget, Location, WriteResult } from './targets/types';
import { getGlyphs } from '../ui/glyphs';
// Import the lightweight submodules directly (not the ../sync barrel, which
// re-exports FileWatcher and would transitively pull in ../extraction — the
// installer must stay importable even when native modules can't load).
import { watchDisabledReason } from '../sync/watch-policy';
import { isGitRepo, isSyncHookInstalled, installGitSyncHook } from '../sync/git-hooks';
// Backwards-compat: keep these named exports — downstream code may
// import them. The shim in `config-writer.ts` continues to re-export
// them too.
export {
writeMcpConfig,
writePermissions,
writeClaudeMd,
hasMcpConfig,
hasPermissions,
hasClaudeMdSection,
} from './config-writer';
export type { InstallLocation } from './config-writer';
// Dynamic import helper — tsc compiles import() to require() in CJS mode,
// which fails for ESM-only packages. This bypasses the transformation.
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const importESM = new Function('specifier', 'return import(specifier)') as
(specifier: string) => Promise<typeof import('@clack/prompts')>;
function formatNumber(n: number): string {
return n.toLocaleString();
}
function getVersion(): string {
try {
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version;
} catch {
return '0.0.0';
}
}
export interface RunInstallerOptions {
/** Comma-separated target list, or `auto` / `all` / `none`. */
target?: string;
/** Skip the location prompt; use this value directly. */
location?: Location;
/** Skip the auto-allow prompt; use this value directly. */
autoAllow?: boolean;
/**
* Skip every confirm and use defaults: location=global,
* autoAllow=true, target=auto. For scripting / CI.
*/
yes?: boolean;
}
/**
* Interactive entry point — preserves the historical UX (`codegraph
* install` with no args goes through the prompts), but now starts
* the targets multi-select pre-populated with detected agents.
*/
export async function runInstaller(): Promise<void> {
return runInstallerWithOptions({});
}
export async function runInstallerWithOptions(opts: RunInstallerOptions): Promise<void> {
const clack = await importESM('@clack/prompts');
clack.intro(`CodeGraph v${getVersion()}`);
// --yes implies all defaults; explicit flags still win.
const useDefaults = opts.yes === true;
// Step 1: which agent targets? Asked FIRST so the user knows what
// they're committing to before we touch npm or disk. Detection
// probes the user-provided location if known, else 'global' as the
// most common default — labels are a hint, not load-bearing.
const detectionLocation: Location = opts.location ?? 'global';
const targets = await resolveTargets(clack, opts, detectionLocation, useDefaults);
if (targets.length === 0) {
clack.outro('No agent targets selected — nothing to do.');
return;
}
// Step 2: install the codegraph npm package on PATH (always offered;
// matches existing behavior). Skipped when --yes (assume present).
if (!useDefaults) {
const shouldInstallGlobally = await clack.confirm({
message: 'Install the codegraph CLI on your PATH? (Required so agents can launch the MCP server)',
initialValue: true,
});
if (clack.isCancel(shouldInstallGlobally)) {
clack.cancel('Installation cancelled.');
process.exit(0);
}
if (shouldInstallGlobally) {
const s = clack.spinner();
s.start('Installing codegraph CLI...');
try {
execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' });
s.stop('Installed codegraph CLI on PATH');
} catch {
s.stop('Could not install (permission denied)');
clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph');
}
} else {
clack.log.info('Skipped CLI install — agents will not be able to launch the MCP server without it');
}
}
// Step 3: where the per-agent config files should land.
let location: Location;
if (opts.location) {
location = opts.location;
} else if (useDefaults) {
location = 'global';
} else {
// If every selected target is global-only (e.g. Codex), skip the
// prompt and force user-wide — project-local would just produce
// skip warnings.
const allGlobalOnly = targets.every((t) => !t.supportsLocation('local'));
if (allGlobalOnly) {
location = 'global';
clack.log.info('Writing user-wide configs (selected agents have no project-local config).');
} else {
const sel = await clack.select({
message: 'Apply agent configs to all your projects, or just this one?',
options: [
{ value: 'global' as const, label: 'All projects', hint: '~/.claude, ~/.cursor, etc.' },
{ value: 'local' as const, label: 'Just this project', hint: './.claude, ./.cursor, etc.' },
],
initialValue: 'global' as const,
});
if (clack.isCancel(sel)) {
clack.cancel('Installation cancelled.');
process.exit(0);
}
location = sel;
}
}
// Step 4: auto-allow permissions (only meaningful for Claude;
// skipped silently by other targets).
let autoAllow: boolean;
if (opts.autoAllow !== undefined) {
autoAllow = opts.autoAllow;
} else if (useDefaults) {
autoAllow = true;
} else if (targets.some((t) => t.id === 'claude')) {
const ans = await clack.confirm({
message: 'Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code)',
initialValue: true,
});
if (clack.isCancel(ans)) {
clack.cancel('Installation cancelled.');
process.exit(0);
}
autoAllow = ans;
} else {
autoAllow = false;
}
// Step 5: per-target install loop.
for (const target of targets) {
if (!target.supportsLocation(location)) {
clack.log.warn(
`${target.displayName}: skipped — does not support --location=${location}.`,
);
continue;
}
const result = target.install(location, { autoAllow });
for (const file of result.files) {
const verb = file.action === 'unchanged'
? 'Unchanged'
: file.action === 'created' ? 'Created' : 'Updated';
clack.log.success(`${target.displayName}: ${verb} ${tildify(file.path)}`);
}
for (const note of result.notes ?? []) {
clack.log.info(`${target.displayName}: ${note}`);
}
}
// Step 6: for local install, initialize the project.
if (location === 'local') {
await initializeLocalProject(clack, useDefaults);
}
if (location === 'global') {
clack.note('cd your-project\ncodegraph init -i', 'Quick start');
}
const finalNote = targets.length > 0
? `Done! Restart your agent${targets.length > 1 ? 's' : ''} to use CodeGraph.`
: 'Done!';
clack.outro(finalNote);
}
/**
* For every target that has a global config and exposes
* `wireProjectSurfaces`, write its project-local surfaces (e.g.
* Cursor's `.cursor/rules/codegraph.mdc`). Idempotent — runs
* silently when there's nothing to write.
*
* Called by `codegraph init` so that a user who ran
* `codegraph install` once globally doesn't have to re-run it per
* project to get full agent support.
*
* Returns the list of `(target, file)` pairs that were created or
* updated — caller decides how to surface them.
*/
export function wireProjectSurfacesForGlobalAgents(): Array<{
target: AgentTarget;
file: WriteResult['files'][number];
}> {
const written: Array<{ target: AgentTarget; file: WriteResult['files'][number] }> = [];
for (const target of ALL_TARGETS) {
if (typeof target.wireProjectSurfaces !== 'function') continue;
const detection = target.detect('global');
if (!detection.alreadyConfigured) continue;
const result = target.wireProjectSurfaces();
for (const file of result.files) {
if (file.action === 'created' || file.action === 'updated') {
written.push({ target, file });
}
}
}
return written;
}
/**
* Replace home-directory prefix in a path with `~/` for cleaner log
* lines. Pure cosmetic.
*/
function tildify(p: string): string {
const home = require('os').homedir();
if (p.startsWith(home + path.sep)) return '~' + p.substring(home.length);
return p;
}
async function resolveTargets(
clack: typeof import('@clack/prompts'),
opts: RunInstallerOptions,
location: Location,
useDefaults: boolean,
): Promise<AgentTarget[]> {
// Explicit --target flag wins.
if (opts.target !== undefined) {
return resolveTargetFlag(opts.target, location);
}
// --yes implies auto-detect.
if (useDefaults) {
return resolveTargetFlag('auto', location);
}
// Interactive multi-select.
const detected = detectAll(location);
const initialValues = detected
.filter(({ detection }) => detection.installed)
.map(({ target }) => target.id);
// If nothing detected, default to Claude alone (matches the
// historical default and the smallest-surprise outcome).
const initial = initialValues.length > 0 ? initialValues : ['claude'];
const choice = await clack.multiselect<string>({
message: 'Which agents should CodeGraph configure?',
options: ALL_TARGETS.map((t) => {
const det = detected.find(({ target }) => target.id === t.id)!.detection;
const flag = det.installed ? '(detected)' : '(not found)';
const globalOnly = !t.supportsLocation('local') ? ' — global only' : '';
return {
value: t.id,
label: `${t.displayName} ${flag}${globalOnly}`,
};
}),
initialValues: initial,
required: false,
});
if (clack.isCancel(choice)) {
clack.cancel('Installation cancelled.');
process.exit(0);
}
return choice
.map((id) => getTarget(id))
.filter((t): t is AgentTarget => t !== undefined);
}
/**
* Initialize CodeGraph in the current project (for local installs), then
* offer the watch fallback when the live watcher won't run here (see
* offerWatchFallback). Agent-agnostic by nature.
*/
async function initializeLocalProject(
clack: typeof import('@clack/prompts'),
useDefaults = false,
): Promise<void> {
const projectPath = process.cwd();
let CodeGraph: typeof import('../index').default;
try {
CodeGraph = (await import('../index')).default;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
clack.log.error(`Could not load native modules: ${msg}`);
clack.log.info('Skipping project initialization. Run "codegraph init -i" later.');
return;
}
// Check if already initialized
if (CodeGraph.isInitialized(projectPath)) {
clack.log.info('CodeGraph already initialized in this project');
await offerWatchFallback(clack, projectPath, { yes: useDefaults });
return;
}
// Initialize
const cg = await CodeGraph.init(projectPath);
clack.log.success('Created .codegraph/ directory');
// Index the project with shimmer progress (worker thread for smooth animation)
const { createShimmerProgress } = await import('../ui/shimmer-progress');
process.stdout.write(`\x1b[2m${getGlyphs().rail}\x1b[0m\n`);
const progress = createShimmerProgress();
const result = await cg.indexAll({
onProgress: progress.onProgress,
});
await progress.stop();
if (result.filesErrored > 0) {
clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} failed, ${formatNumber(result.nodesCreated)} symbols)`);
} else {
clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.nodesCreated)} symbols)`);
}
cg.close();
await offerWatchFallback(clack, projectPath, { yes: useDefaults });
}
/**
* When the live file watcher will be disabled for this project (e.g. WSL2
* /mnt drives, or CODEGRAPH_NO_WATCH), the index would silently go stale.
* Explain that, and offer to keep it fresh automatically via git hooks
* (commit / pull / checkout) instead of manual `codegraph sync`.
*
* No-op on environments where the watcher runs normally, so it's safe to
* call unconditionally after init.
*/
export async function offerWatchFallback(
clack: typeof import('@clack/prompts'),
projectPath: string,
opts: { yes?: boolean } = {},
): Promise<void> {
const reason = watchDisabledReason(projectPath);
if (!reason) return; // Watcher runs normally — nothing to set up.
clack.log.warn(`Live file watching is disabled here — ${reason}.`);
clack.log.info('Until you re-sync, the CodeGraph index stays frozen — it will not pick up edits on its own.');
// No git repo → the commit-hook path doesn't apply; point at manual sync.
if (!isGitRepo(projectPath)) {
clack.log.info('Run `codegraph sync` after changing files to refresh the index.');
return;
}
// Already wired up on a previous run — confirm and move on without nagging.
if (isSyncHookInstalled(projectPath)) {
clack.log.info('Git sync hooks are already installed — the index refreshes after commit / pull / checkout.');
return;
}
let choice: 'hook' | 'manual';
if (opts.yes) {
choice = 'hook';
} else {
const sel = await clack.select({
message: 'How should CodeGraph keep its index fresh?',
options: [
{ value: 'hook' as const, label: 'Sync on git commit / pull / checkout', hint: 'installs git hooks (recommended)' },
{ value: 'manual' as const, label: 'I\'ll run `codegraph sync` myself', hint: 'fully manual' },
],
initialValue: 'hook' as const,
});
if (clack.isCancel(sel)) {
clack.log.info('Skipped — run `codegraph sync` after changes to refresh the index.');
return;
}
choice = sel;
}
if (choice === 'manual') {
clack.log.info('Run `codegraph sync` after changing files to refresh the index.');
return;
}
const result = installGitSyncHook(projectPath);
if (result.installed.length > 0) {
clack.log.success(
`Installed git ${result.installed.join(', ')} hook${result.installed.length > 1 ? 's' : ''} — ` +
'the index refreshes in the background after each.',
);
clack.log.info('Run `codegraph sync` anytime to refresh immediately.');
} else {
clack.log.warn(
`Could not install git hooks${result.skipped ? ` (${result.skipped})` : ''}. ` +
'Run `codegraph sync` after changes instead.',
);
}
}