index.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  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, WriteResult } 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. writeClaudeMd,
  37. hasMcpConfig,
  38. hasPermissions,
  39. hasClaudeMdSection,
  40. } from './config-writer';
  41. export type { InstallLocation } from './config-writer';
  42. // Dynamic import helper — tsc compiles import() to require() in CJS mode,
  43. // which fails for ESM-only packages. This bypasses the transformation.
  44. // eslint-disable-next-line @typescript-eslint/no-implied-eval
  45. const importESM = new Function('specifier', 'return import(specifier)') as
  46. (specifier: string) => Promise<typeof import('@clack/prompts')>;
  47. function formatNumber(n: number): string {
  48. return n.toLocaleString();
  49. }
  50. function getVersion(): string {
  51. try {
  52. const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
  53. const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
  54. return packageJson.version;
  55. } catch {
  56. return '0.0.0';
  57. }
  58. }
  59. export interface RunInstallerOptions {
  60. /** Comma-separated target list, or `auto` / `all` / `none`. */
  61. target?: string;
  62. /** Skip the location prompt; use this value directly. */
  63. location?: Location;
  64. /** Skip the auto-allow prompt; use this value directly. */
  65. autoAllow?: boolean;
  66. /**
  67. * Skip every confirm and use defaults: location=global,
  68. * autoAllow=true, target=auto. For scripting / CI.
  69. */
  70. yes?: boolean;
  71. }
  72. /**
  73. * Interactive entry point — preserves the historical UX (`codegraph
  74. * install` with no args goes through the prompts), but now starts
  75. * the targets multi-select pre-populated with detected agents.
  76. */
  77. export async function runInstaller(): Promise<void> {
  78. return runInstallerWithOptions({});
  79. }
  80. export async function runInstallerWithOptions(opts: RunInstallerOptions): Promise<void> {
  81. const clack = await importESM('@clack/prompts');
  82. clack.intro(`CodeGraph v${getVersion()}`);
  83. // --yes implies all defaults; explicit flags still win.
  84. const useDefaults = opts.yes === true;
  85. // Step 1: which agent targets? Asked FIRST so the user knows what
  86. // they're committing to before we touch npm or disk. Detection
  87. // probes the user-provided location if known, else 'global' as the
  88. // most common default — labels are a hint, not load-bearing.
  89. const detectionLocation: Location = opts.location ?? 'global';
  90. const targets = await resolveTargets(clack, opts, detectionLocation, useDefaults);
  91. if (targets.length === 0) {
  92. clack.outro('No agent targets selected — nothing to do.');
  93. return;
  94. }
  95. // Step 2: install the codegraph npm package on PATH (always offered;
  96. // matches existing behavior). Skipped when --yes (assume present).
  97. if (!useDefaults) {
  98. const shouldInstallGlobally = await clack.confirm({
  99. message: 'Install the codegraph CLI on your PATH? (Required so agents can launch the MCP server)',
  100. initialValue: true,
  101. });
  102. if (clack.isCancel(shouldInstallGlobally)) {
  103. clack.cancel('Installation cancelled.');
  104. process.exit(0);
  105. }
  106. if (shouldInstallGlobally) {
  107. const s = clack.spinner();
  108. s.start('Installing codegraph CLI...');
  109. try {
  110. execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' });
  111. s.stop('Installed codegraph CLI on PATH');
  112. } catch {
  113. s.stop('Could not install (permission denied)');
  114. clack.log.warn('Try: sudo npm install -g @colbymchenry/codegraph');
  115. }
  116. } else {
  117. clack.log.info('Skipped CLI install — agents will not be able to launch the MCP server without it');
  118. }
  119. }
  120. // Step 3: where the per-agent config files should land.
  121. let location: Location;
  122. if (opts.location) {
  123. location = opts.location;
  124. } else if (useDefaults) {
  125. location = 'global';
  126. } else {
  127. // If every selected target is global-only (e.g. Codex), skip the
  128. // prompt and force user-wide — project-local would just produce
  129. // skip warnings.
  130. const allGlobalOnly = targets.every((t) => !t.supportsLocation('local'));
  131. if (allGlobalOnly) {
  132. location = 'global';
  133. clack.log.info('Writing user-wide configs (selected agents have no project-local config).');
  134. } else {
  135. const sel = await clack.select({
  136. message: 'Apply agent configs to all your projects, or just this one?',
  137. options: [
  138. { value: 'global' as const, label: 'All projects', hint: '~/.claude, ~/.cursor, etc.' },
  139. { value: 'local' as const, label: 'Just this project', hint: './.claude, ./.cursor, etc.' },
  140. ],
  141. initialValue: 'global' as const,
  142. });
  143. if (clack.isCancel(sel)) {
  144. clack.cancel('Installation cancelled.');
  145. process.exit(0);
  146. }
  147. location = sel;
  148. }
  149. }
  150. // Step 4: auto-allow permissions (only meaningful for Claude;
  151. // skipped silently by other targets).
  152. let autoAllow: boolean;
  153. if (opts.autoAllow !== undefined) {
  154. autoAllow = opts.autoAllow;
  155. } else if (useDefaults) {
  156. autoAllow = true;
  157. } else if (targets.some((t) => t.id === 'claude')) {
  158. const ans = await clack.confirm({
  159. message: 'Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code)',
  160. initialValue: true,
  161. });
  162. if (clack.isCancel(ans)) {
  163. clack.cancel('Installation cancelled.');
  164. process.exit(0);
  165. }
  166. autoAllow = ans;
  167. } else {
  168. autoAllow = false;
  169. }
  170. // Step 5: per-target install loop.
  171. for (const target of targets) {
  172. if (!target.supportsLocation(location)) {
  173. clack.log.warn(
  174. `${target.displayName}: skipped — does not support --location=${location}.`,
  175. );
  176. continue;
  177. }
  178. const result = target.install(location, { autoAllow });
  179. for (const file of result.files) {
  180. const verb = file.action === 'unchanged'
  181. ? 'Unchanged'
  182. : file.action === 'created' ? 'Created' : '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' },
  295. { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini' },
  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. * For every target that has a global config and exposes
  351. * `wireProjectSurfaces`, write its project-local surfaces (e.g.
  352. * Cursor's `.cursor/rules/codegraph.mdc`). Idempotent — runs
  353. * silently when there's nothing to write.
  354. *
  355. * Called by `codegraph init` so that a user who ran
  356. * `codegraph install` once globally doesn't have to re-run it per
  357. * project to get full agent support.
  358. *
  359. * Returns the list of `(target, file)` pairs that were created or
  360. * updated — caller decides how to surface them.
  361. */
  362. export function wireProjectSurfacesForGlobalAgents(): Array<{
  363. target: AgentTarget;
  364. file: WriteResult['files'][number];
  365. }> {
  366. const written: Array<{ target: AgentTarget; file: WriteResult['files'][number] }> = [];
  367. for (const target of ALL_TARGETS) {
  368. if (typeof target.wireProjectSurfaces !== 'function') continue;
  369. const detection = target.detect('global');
  370. if (!detection.alreadyConfigured) continue;
  371. const result = target.wireProjectSurfaces();
  372. for (const file of result.files) {
  373. if (file.action === 'created' || file.action === 'updated') {
  374. written.push({ target, file });
  375. }
  376. }
  377. }
  378. return written;
  379. }
  380. /**
  381. * Replace home-directory prefix in a path with `~/` for cleaner log
  382. * lines. Pure cosmetic.
  383. */
  384. function tildify(p: string): string {
  385. const home = require('os').homedir();
  386. if (p.startsWith(home + path.sep)) return '~' + p.substring(home.length);
  387. return p;
  388. }
  389. async function resolveTargets(
  390. clack: typeof import('@clack/prompts'),
  391. opts: RunInstallerOptions,
  392. location: Location,
  393. useDefaults: boolean,
  394. ): Promise<AgentTarget[]> {
  395. // Explicit --target flag wins.
  396. if (opts.target !== undefined) {
  397. return resolveTargetFlag(opts.target, location);
  398. }
  399. // --yes implies auto-detect.
  400. if (useDefaults) {
  401. return resolveTargetFlag('auto', location);
  402. }
  403. // Interactive multi-select.
  404. const detected = detectAll(location);
  405. const initialValues = detected
  406. .filter(({ detection }) => detection.installed)
  407. .map(({ target }) => target.id);
  408. // If nothing detected, default to Claude alone (matches the
  409. // historical default and the smallest-surprise outcome).
  410. const initial = initialValues.length > 0 ? initialValues : ['claude'];
  411. const choice = await clack.multiselect<string>({
  412. message: 'Which agents should CodeGraph configure?',
  413. options: ALL_TARGETS.map((t) => {
  414. const det = detected.find(({ target }) => target.id === t.id)!.detection;
  415. const flag = det.installed ? '(detected)' : '(not found)';
  416. const globalOnly = !t.supportsLocation('local') ? ' — global only' : '';
  417. return {
  418. value: t.id,
  419. label: `${t.displayName} ${flag}${globalOnly}`,
  420. };
  421. }),
  422. initialValues: initial,
  423. required: false,
  424. });
  425. if (clack.isCancel(choice)) {
  426. clack.cancel('Installation cancelled.');
  427. process.exit(0);
  428. }
  429. return choice
  430. .map((id) => getTarget(id))
  431. .filter((t): t is AgentTarget => t !== undefined);
  432. }
  433. /**
  434. * Initialize CodeGraph in the current project (for local installs), then
  435. * offer the watch fallback when the live watcher won't run here (see
  436. * offerWatchFallback). Agent-agnostic by nature.
  437. */
  438. async function initializeLocalProject(
  439. clack: typeof import('@clack/prompts'),
  440. useDefaults = false,
  441. ): Promise<void> {
  442. const projectPath = process.cwd();
  443. let CodeGraph: typeof import('../index').default;
  444. try {
  445. CodeGraph = (await import('../index')).default;
  446. } catch (err) {
  447. const msg = err instanceof Error ? err.message : String(err);
  448. clack.log.error(`Could not load native modules: ${msg}`);
  449. clack.log.info('Skipping project initialization. Run "codegraph init -i" later.');
  450. return;
  451. }
  452. // Check if already initialized
  453. if (CodeGraph.isInitialized(projectPath)) {
  454. clack.log.info('CodeGraph already initialized in this project');
  455. await offerWatchFallback(clack, projectPath, { yes: useDefaults });
  456. return;
  457. }
  458. // Initialize
  459. const cg = await CodeGraph.init(projectPath);
  460. clack.log.success('Created .codegraph/ directory');
  461. // Index the project with shimmer progress (worker thread for smooth animation)
  462. const { createShimmerProgress } = await import('../ui/shimmer-progress');
  463. process.stdout.write(`\x1b[2m${getGlyphs().rail}\x1b[0m\n`);
  464. const progress = createShimmerProgress();
  465. const result = await cg.indexAll({
  466. onProgress: progress.onProgress,
  467. });
  468. await progress.stop();
  469. if (result.filesErrored > 0) {
  470. clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} failed, ${formatNumber(result.nodesCreated)} symbols)`);
  471. } else {
  472. clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.nodesCreated)} symbols)`);
  473. }
  474. cg.close();
  475. await offerWatchFallback(clack, projectPath, { yes: useDefaults });
  476. }
  477. /**
  478. * When the live file watcher will be disabled for this project (e.g. WSL2
  479. * /mnt drives, or CODEGRAPH_NO_WATCH), the index would silently go stale.
  480. * Explain that, and offer to keep it fresh automatically via git hooks
  481. * (commit / pull / checkout) instead of manual `codegraph sync`.
  482. *
  483. * No-op on environments where the watcher runs normally, so it's safe to
  484. * call unconditionally after init.
  485. */
  486. export async function offerWatchFallback(
  487. clack: typeof import('@clack/prompts'),
  488. projectPath: string,
  489. opts: { yes?: boolean } = {},
  490. ): Promise<void> {
  491. const reason = watchDisabledReason(projectPath);
  492. if (!reason) return; // Watcher runs normally — nothing to set up.
  493. clack.log.warn(`Live file watching is disabled here — ${reason}.`);
  494. clack.log.info('Until you re-sync, the CodeGraph index stays frozen — it will not pick up edits on its own.');
  495. // No git repo → the commit-hook path doesn't apply; point at manual sync.
  496. if (!isGitRepo(projectPath)) {
  497. clack.log.info('Run `codegraph sync` after changing files to refresh the index.');
  498. return;
  499. }
  500. // Already wired up on a previous run — confirm and move on without nagging.
  501. if (isSyncHookInstalled(projectPath)) {
  502. clack.log.info('Git sync hooks are already installed — the index refreshes after commit / pull / checkout.');
  503. return;
  504. }
  505. let choice: 'hook' | 'manual';
  506. if (opts.yes) {
  507. choice = 'hook';
  508. } else {
  509. const sel = await clack.select({
  510. message: 'How should CodeGraph keep its index fresh?',
  511. options: [
  512. { value: 'hook' as const, label: 'Sync on git commit / pull / checkout', hint: 'installs git hooks (recommended)' },
  513. { value: 'manual' as const, label: 'I\'ll run `codegraph sync` myself', hint: 'fully manual' },
  514. ],
  515. initialValue: 'hook' as const,
  516. });
  517. if (clack.isCancel(sel)) {
  518. clack.log.info('Skipped — run `codegraph sync` after changes to refresh the index.');
  519. return;
  520. }
  521. choice = sel;
  522. }
  523. if (choice === 'manual') {
  524. clack.log.info('Run `codegraph sync` after changing files to refresh the index.');
  525. return;
  526. }
  527. const result = installGitSyncHook(projectPath);
  528. if (result.installed.length > 0) {
  529. clack.log.success(
  530. `Installed git ${result.installed.join(', ')} hook${result.installed.length > 1 ? 's' : ''} — ` +
  531. 'the index refreshes in the background after each.',
  532. );
  533. clack.log.info('Run `codegraph sync` anytime to refresh immediately.');
  534. } else {
  535. clack.log.warn(
  536. `Could not install git hooks${result.skipped ? ` (${result.skipped})` : ''}. ` +
  537. 'Run `codegraph sync` after changes instead.',
  538. );
  539. }
  540. }