index.ts 20 KB

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