Jelajahi Sumber

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>
Colby Mchenry 1 bulan lalu
induk
melakukan
bf73f4d05c

+ 24 - 0
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/)
 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).
 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
 ## [0.9.2] - 2026-05-21
 
 
 ### Added
 ### 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
   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.
   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.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2
 [0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1
 [0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1
 
 

+ 155 - 0
__tests__/installer-targets.test.ts

@@ -19,6 +19,7 @@ import * as fs from 'fs';
 import * as path from 'path';
 import * as path from 'path';
 import * as os from 'os';
 import * as os from 'os';
 import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
 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 { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
 import { cleanupLegacyHooks } from '../src/installer/targets/claude';
 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[] {
 function listAllFiles(dir: string): string[] {
   if (!fs.existsSync(dir)) return [];
   if (!fs.existsSync(dir)) return [];
   const out: string[] = [];
   const out: string[] = [];

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "@colbymchenry/codegraph",
   "name": "@colbymchenry/codegraph",
-  "version": "0.9.2",
+  "version": "0.9.3",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "@colbymchenry/codegraph",
       "name": "@colbymchenry/codegraph",
-      "version": "0.9.2",
+      "version": "0.9.3",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@clack/prompts": "^1.3.0",
         "@clack/prompts": "^1.3.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@colbymchenry/codegraph",
   "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.",
   "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
   "types": "dist/index.d.ts",

+ 37 - 0
src/bin/codegraph.ts

@@ -7,6 +7,7 @@
  * Usage:
  * Usage:
  *   codegraph                    Run interactive installer (when no args)
  *   codegraph                    Run interactive installer (when no args)
  *   codegraph install            Run interactive installer
  *   codegraph install            Run interactive installer
+ *   codegraph uninstall          Remove CodeGraph from your agents
  *   codegraph init [path]        Initialize CodeGraph in a project
  *   codegraph init [path]        Initialize CodeGraph in a project
  *   codegraph uninit [path]      Remove CodeGraph from a project
  *   codegraph uninit [path]      Remove CodeGraph from a project
  *   codegraph index [path]       Index all files in the 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 <ids>', 'Target agent(s): comma-separated ids, or "all". Default: all')
+  .option('-l, --location <where>', '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
 // Parse and run
 program.parse();
 program.parse();
 
 

+ 162 - 1
src/installer/index.ts

@@ -21,7 +21,7 @@ import {
   getTarget,
   getTarget,
   resolveTargetFlag,
   resolveTargetFlag,
 } from './targets/registry';
 } 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 { getGlyphs } from '../ui/glyphs';
 // Import the lightweight submodules directly (not the ../sync barrel, which
 // Import the lightweight submodules directly (not the ../sync barrel, which
 // re-exports FileWatcher and would transitively pull in ../extraction — the
 // re-exports FileWatcher and would transitively pull in ../extraction — the
@@ -217,6 +217,167 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
   clack.outro(finalNote);
   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
  * For every target that has a global config and exposes
  * `wireProjectSurfaces`, write its project-local surfaces (e.g.
  * `wireProjectSurfaces`, write its project-local surfaces (e.g.

+ 54 - 4
src/installer/targets/cursor.ts

@@ -46,7 +46,6 @@ import {
   getMcpServerConfig,
   getMcpServerConfig,
   jsonDeepEqual,
   jsonDeepEqual,
   readJsonFile,
   readJsonFile,
-  removeMarkedSection,
   replaceOrAppendMarkedSection,
   replaceOrAppendMarkedSection,
   writeJsonFile,
   writeJsonFile,
 } from './shared';
 } from './shared';
@@ -140,9 +139,7 @@ class CursorTarget implements AgentTarget {
     }
     }
 
 
     if (loc === 'local') {
     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 };
     return { files };
@@ -237,4 +234,57 @@ function writeRulesEntry(): WriteResult['files'][number] {
   return { path: file, action: mapped };
   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();
 export const cursorTarget: AgentTarget = new CursorTarget();