diff --git a/CHANGELOG.md b/CHANGELOG.md index b544414..df30968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.3] - 2026-05-22 + +### Added +- **`codegraph uninstall` command.** Cleanly removes CodeGraph from every agent + it's configured on — Claude Code, Cursor, Codex CLI, opencode, and Hermes + Agent — in one step. It asks up front whether to remove the global config + (`~/.claude`, `~/.codex`, …) or just this project's local config (no flags + required), then prints exactly which agents it touched so you can see what + changed. `--location`, `--target`, and `--yes` are accepted for scripted / + non-interactive use. It removes only what `install` wrote (MCP server entry, + instructions block, permissions) and leaves your `.codegraph/` index alone + (use `codegraph uninit` for that). Resolves + [#313](https://github.com/colbymchenry/codegraph/issues/313) — previously the + only cleanup path was an npm `preuninstall` hook that the published bundle + never shipped, so `npm uninstall -g` left every agent pointing at a CodeGraph + MCP server that no longer existed. + +### Fixed +- **Cursor uninstall left an orphaned `.cursor/rules/codegraph.mdc`.** It + stripped the rule body but left the file and its `description: CodeGraph …` + frontmatter behind. The dedicated rules file is now deleted outright on + uninstall, while any content you added outside CodeGraph's markers is kept. + ## [0.9.2] - 2026-05-21 ### Added @@ -93,6 +116,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). find its bundle. The release pipeline now verifies every package reached the registry (and is idempotent), so a release can't pass green-but-broken again. +[0.9.3]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.3 [0.9.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2 [0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1 diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 44e90d6..59e869e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; +import { uninstallTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; import { cleanupLegacyHooks } from '../src/installer/targets/claude'; @@ -723,6 +724,160 @@ describe('Installer targets — TOML serializer (Codex backbone)', () => { }); }); +describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('un-home'); + tmpCwd = mkTmpDir('un-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('sweeps every agent it was installed on and reports removed for each (global)', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + + const reports = uninstallTargets(ALL_TARGETS, 'global'); + + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + expect(r.status).toBe('removed'); + expect(r.removedPaths.length).toBeGreaterThan(0); + // The actual config is gone afterward. + expect(t.detect('global').alreadyConfigured).toBe(false); + } + }); + + it('is safe on a clean slate — every agent reports not-configured, nothing removed', () => { + const reports = uninstallTargets(ALL_TARGETS, 'global'); + for (const r of reports) { + expect(r.status).toBe('not-configured'); + expect(r.removedPaths).toEqual([]); + } + }); + + it('reports removed only for agents that were actually configured', () => { + // Install on Claude only; the rest stay untouched. + getTarget('claude')!.install('global', { autoAllow: true }); + + const reports = uninstallTargets(ALL_TARGETS, 'global'); + + const claude = reports.find((r) => r.id === 'claude')!; + expect(claude.status).toBe('removed'); + expect(claude.displayName).toBe(getTarget('claude')!.displayName); + + for (const r of reports.filter((x) => x.id !== 'claude')) { + expect(r.status).toBe('not-configured'); + } + }); + + it('marks global-only agents as unsupported for a local sweep (and never touches them)', () => { + const reports = uninstallTargets(ALL_TARGETS, 'local'); + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + if (t.supportsLocation('local')) { + expect(r.status).toBe('not-configured'); + } else { + expect(r.status).toBe('unsupported'); + expect(r.removedPaths).toEqual([]); + expect(r.notes[0]).toMatch(/global-only/); + } + } + }); + + it('is idempotent — a second sweep finds nothing left to remove', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + const first = uninstallTargets(ALL_TARGETS, 'global'); + expect(first.some((r) => r.status === 'removed')).toBe(true); + + const second = uninstallTargets(ALL_TARGETS, 'global'); + for (const r of second) { + expect(r.status).toBe('not-configured'); + expect(r.removedPaths).toEqual([]); + } + }); + + it('a --target subset removes only the chosen agents, leaving siblings configured', () => { + getTarget('claude')!.install('global', { autoAllow: true }); + getTarget('cursor')!.install('global', { autoAllow: true }); + + const reports = uninstallTargets(resolveTargetFlag('claude', 'global'), 'global'); + + expect(reports.map((r) => r.id)).toEqual(['claude']); + expect(reports[0].status).toBe('removed'); + // Cursor was not in the subset — still configured. + expect(getTarget('cursor')!.detect('global').alreadyConfigured).toBe(true); + expect(getTarget('claude')!.detect('global').alreadyConfigured).toBe(false); + }); +}); + +describe('Installer — Cursor rules file cleanup on uninstall', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + const cursor = getTarget('cursor')!; + + beforeEach(() => { + tmpHome = mkTmpDir('cur-home'); + tmpCwd = mkTmpDir('cur-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + const rulesFile = () => path.join(process.cwd(), '.cursor', 'rules', 'codegraph.mdc'); + + it('deletes the dedicated codegraph.mdc entirely (no orphaned frontmatter left behind)', () => { + cursor.install('local', { autoAllow: true }); + expect(fs.existsSync(rulesFile())).toBe(true); + + cursor.uninstall('local'); + + // The whole file — frontmatter included — is gone, not just the block. + expect(fs.existsSync(rulesFile())).toBe(false); + expect(cursor.detect('local').alreadyConfigured).toBe(false); + }); + + it('preserves user content added outside the codegraph markers (strips only our block)', () => { + cursor.install('local', { autoAllow: true }); + const withUserContent = + fs.readFileSync(rulesFile(), 'utf-8') + '\n## My own rule\nkeep me\n'; + fs.writeFileSync(rulesFile(), withUserContent); + + cursor.uninstall('local'); + + expect(fs.existsSync(rulesFile())).toBe(true); + const after = fs.readFileSync(rulesFile(), 'utf-8'); + expect(after).toContain('keep me'); + // Our tool-usage block is gone. + expect(after).not.toContain('codegraph_search'); + expect(after).not.toContain('CODEGRAPH_START'); + }); +}); + function listAllFiles(dir: string): string[] { if (!fs.existsSync(dir)) return []; const out: string[] = []; diff --git a/package-lock.json b/package-lock.json index 4934249..36c592b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@colbymchenry/codegraph", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.9.2", + "version": "0.9.3", "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index 4ea9321..f813c1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colbymchenry/codegraph", - "version": "0.9.2", + "version": "0.9.3", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index dac8ce1..6f90e6f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -7,6 +7,7 @@ * Usage: * codegraph Run interactive installer (when no args) * codegraph install Run interactive installer + * codegraph uninstall Remove CodeGraph from your agents * codegraph init [path] Initialize CodeGraph in a project * codegraph uninit [path] Remove CodeGraph from a project * codegraph index [path] Index all files in the project @@ -1398,6 +1399,42 @@ program } }); +/** + * codegraph uninstall + * + * Inverse of `install`. Removes the codegraph MCP server entry, + * instructions block, and permissions from every agent (or a + * `--target` subset). Prompts global-vs-local when not given. Does NOT + * delete the `.codegraph/` index — that's `codegraph uninit`. + */ +program + .command('uninstall') + .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all') + .option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt') + .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') + .action(async (opts: { + target?: string; + location?: string; + yes?: boolean; + }) => { + const { runUninstaller } = await import('../installer'); + if (opts.location && opts.location !== 'global' && opts.location !== 'local') { + error(`--location must be "global" or "local" (got "${opts.location}").`); + process.exit(1); + } + try { + await runUninstaller({ + target: opts.target, + location: opts.location as 'global' | 'local' | undefined, + yes: opts.yes, + }); + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + // Parse and run program.parse(); diff --git a/src/installer/index.ts b/src/installer/index.ts index e5b1841..0826d8d 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -21,7 +21,7 @@ import { getTarget, resolveTargetFlag, } from './targets/registry'; -import type { AgentTarget, Location, WriteResult } from './targets/types'; +import type { AgentTarget, Location, TargetId, 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 @@ -217,6 +217,167 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis clack.outro(finalNote); } +export interface RunUninstallerOptions { + /** + * Comma-separated target list, or `auto` / `all` / `none`. Defaults + * to `all` — uninstall sweeps every known agent and reports which + * ones it actually touched, so the user doesn't have to know where + * they configured it. + */ + target?: string; + /** Skip the location prompt; use this value directly. */ + location?: Location; + /** Non-interactive: location=global, target=all, no prompts. */ + yes?: boolean; +} + +export type UninstallStatus = 'removed' | 'not-configured' | 'unsupported'; + +/** + * Per-target outcome of an uninstall sweep. `removed` means we deleted + * at least one thing; `not-configured` means the agent had no codegraph + * config at this location (nothing to do); `unsupported` means the + * agent has no config concept for this location (e.g. Codex is + * global-only, so a `local` uninstall skips it). + */ +export interface UninstallReport { + id: TargetId; + displayName: string; + status: UninstallStatus; + /** Absolute paths we actually edited/removed (action === 'removed'). */ + removedPaths: string[]; + /** Verbatim notes from the target (rare for uninstall). */ + notes: string[]; +} + +/** + * Pure uninstall sweep — no prompts, no I/O beyond the targets' own + * file edits. Exposed (and unit-tested) separately from the clack UI in + * `runUninstaller` so the aggregation logic can be asserted directly. + * + * Each target's `uninstall()` is already safe to call when nothing was + * installed (it returns `not-found` actions), so this is safe to run + * across every target unconditionally. + */ +export function uninstallTargets( + targets: readonly AgentTarget[], + location: Location, +): UninstallReport[] { + return targets.map((target) => { + if (!target.supportsLocation(location)) { + const only: Location = location === 'local' ? 'global' : 'local'; + return { + id: target.id, + displayName: target.displayName, + status: 'unsupported' as const, + removedPaths: [], + notes: [`no ${location} config — this agent is ${only}-only`], + }; + } + const result = target.uninstall(location); + const removedPaths = result.files + .filter((f) => f.action === 'removed') + .map((f) => f.path); + return { + id: target.id, + displayName: target.displayName, + status: removedPaths.length > 0 ? ('removed' as const) : ('not-configured' as const), + removedPaths, + notes: result.notes ?? [], + }; + }); +} + +/** + * Interactive uninstaller — the inverse of `runInstallerWithOptions`. + * Asks global-vs-local first (unless `--location`/`--yes` is given), + * then sweeps every agent target (or the `--target` subset) and prints + * one block per agent so the user sees exactly which providers it hit. + * + * Removes only what install wrote (MCP server entry, instructions + * block, permissions) — never the `.codegraph/` index, which `codegraph + * uninit` owns. + */ +export async function runUninstaller(opts: RunUninstallerOptions): Promise { + const clack = await importESM('@clack/prompts'); + + clack.intro(`CodeGraph v${getVersion()} — uninstall`); + + const useDefaults = opts.yes === true; + + // Step 1: which location — asked FIRST, the one decision the user + // must make. Global sweeps ~/.claude, ~/.codex, etc.; local sweeps + // the configs in this project directory. + let location: Location; + if (opts.location) { + location = opts.location; + } else if (useDefaults) { + location = 'global'; + } else { + const sel = await clack.select({ + message: 'Remove CodeGraph from all your projects, or just this one?', + options: [ + { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes' }, + { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc' }, + ], + initialValue: 'global' as const, + }); + if (clack.isCancel(sel)) { + clack.cancel('Uninstall cancelled.'); + process.exit(0); + } + location = sel; + } + + // Step 2: which agents. Default is every agent, so the user doesn't + // have to remember where they installed it — unconfigured agents are + // reported as "nothing to remove" and left untouched. An explicit + // --target subsets this. + let targets: AgentTarget[]; + if (opts.target !== undefined) { + targets = resolveTargetFlag(opts.target, location); + } else { + targets = [...ALL_TARGETS]; + } + if (targets.length === 0) { + clack.outro('No agent targets selected — nothing to do.'); + return; + } + + // Step 3: sweep + per-agent feedback. + const reports = uninstallTargets(targets, location); + const removed = reports.filter((r) => r.status === 'removed'); + + for (const r of reports) { + if (r.status === 'removed') { + for (const p of r.removedPaths) { + clack.log.success(`${r.displayName}: removed ${tildify(p)}`); + } + } else if (r.status === 'not-configured') { + clack.log.info(`${r.displayName}: not configured — nothing to remove`); + } else { + clack.log.info(`${r.displayName}: skipped — ${r.notes[0] ?? 'unsupported location'}`); + } + } + + // Step 4: for local uninstall, the index dir is separate — point at + // `uninit` so the user knows it's still there (and how to remove it). + if (location === 'local' && fs.existsSync(path.join(process.cwd(), '.codegraph'))) { + clack.log.info('The .codegraph/ index for this project is still here. Run `codegraph uninit` to delete it.'); + } + + // Step 5: summary. + if (removed.length > 0) { + const names = removed.map((r) => r.displayName).join(', '); + clack.outro( + `Removed CodeGraph from ${removed.length} agent${removed.length > 1 ? 's' : ''}: ${names}. ` + + `Restart ${removed.length > 1 ? 'them' : 'it'} to apply.`, + ); + } else { + clack.outro(`CodeGraph was not configured in any ${location} agent — nothing to remove.`); + } +} + /** * For every target that has a global config and exposes * `wireProjectSurfaces`, write its project-local surfaces (e.g. diff --git a/src/installer/targets/cursor.ts b/src/installer/targets/cursor.ts index 850b6fc..fb60a00 100644 --- a/src/installer/targets/cursor.ts +++ b/src/installer/targets/cursor.ts @@ -46,7 +46,6 @@ import { getMcpServerConfig, jsonDeepEqual, readJsonFile, - removeMarkedSection, replaceOrAppendMarkedSection, writeJsonFile, } from './shared'; @@ -140,9 +139,7 @@ class CursorTarget implements AgentTarget { } if (loc === 'local') { - const rules = rulesPath(); - const action = removeMarkedSection(rules, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); - files.push({ path: rules, action }); + files.push(removeRulesEntry()); } return { files }; @@ -237,4 +234,57 @@ function writeRulesEntry(): WriteResult['files'][number] { return { path: file, action: mapped }; } +/** + * Remove the Cursor rules file on uninstall. + * + * Unlike the shared CLAUDE.md / AGENTS.md files (where codegraph owns + * only a marker-delimited section), `.cursor/rules/codegraph.mdc` is a + * file we create OUTRIGHT — the frontmatter is ours too. So a plain + * `removeMarkedSection` is wrong here: it would strip our instruction + * block but leave the orphaned `description: CodeGraph ...` frontmatter + * behind, so the file lingers and still "mentions" codegraph. + * + * Instead: strip our block, and if nothing but our own frontmatter + * remains, delete the whole file. Only when the user has added their + * own content outside our markers do we keep the file (minus our block). + */ +function removeRulesEntry(): WriteResult['files'][number] { + const file = rulesPath(); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + + let content: string; + try { + content = fs.readFileSync(file, 'utf-8'); + } catch { + return { path: file, action: 'not-found' }; + } + + const ourFrontmatter = MDC_FRONTMATTER.trim(); + const startIdx = content.indexOf(CODEGRAPH_SECTION_START); + const endIdx = content.indexOf(CODEGRAPH_SECTION_END); + + // Our marked block is present — strip it, then decide what's left. + if (startIdx !== -1 && endIdx > startIdx) { + const before = content.substring(0, startIdx).trimEnd(); + const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length).trimStart(); + const remainder = (before + (before && after ? '\n\n' : '') + after).trim(); + if (remainder === '' || remainder === ourFrontmatter) { + try { fs.unlinkSync(file); } catch { /* ignore */ } + } else { + atomicWriteFileSync(file, remainder + '\n'); + } + return { path: file, action: 'removed' }; + } + + // No block, but the file is still our pristine frontmatter-only file + // — it's ours, so remove it. + if (content.trim() === ourFrontmatter) { + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; + } + + // Foreign content we don't recognize — leave it alone. + return { path: file, action: 'not-found' }; +} + export const cursorTarget: AgentTarget = new CursorTarget();