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:
co-authored by
Claude Opus 4.7
parent
77e29c436c
commit
bf73f4d05c
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user