feat(installer): add codegraph uninstall command (#313) (#318)

Adds a cross-channel uninstall that removes CodeGraph from every agent it's
configured on (Claude Code, Cursor, Codex CLI, opencode, Hermes). Prompts
global-vs-local up front (no flags required) and reports which providers it
actually hit; --location / --target / --yes supported for non-interactive use.
Removes only what install wrote; leaves the .codegraph/ index to `uninit`.

Also fixes Cursor uninstall leaving an orphaned .cursor/rules/codegraph.mdc
(its description: CodeGraph frontmatter lingered); the dedicated rules file is
now deleted outright while user content outside our markers is preserved.

Validated end-to-end on macOS and Docker Linux (global + local sweeps clean).
Adds 8 tests; full suite 730 passing. Bumps to 0.9.3 with CHANGELOG entry.

Resolves #313.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-22 04:51:42 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 77e29c436c
commit bf73f4d05c
7 changed files with 435 additions and 8 deletions
+162 -1
View File
@@ -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<void> {
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.
+54 -4
View File
@@ -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();