Skip to content

[CLAUDE ROUTINE]: CX/Reliability enhancement — extend scripts/preuninstall.mjs to clean up Codex / Copilot / Cursor hook entries so npm uninstall -g failproofai leaves zero orphans across every supported CLI #291

@NiveditJain

Description

@NiveditJain

Summary

failproofai already ships first-class install support for four agent CLIs on main — Claude Code, OpenAI Codex, GitHub Copilot CLI, and Cursor Agent — and failproofai policies --install --cli <name> happily wires up hook entries into each one's native settings file. The npm preuninstall lifecycle script that runs on npm uninstall -g failproofai, however, only knows how to clean up the Claude side (~/.claude/settings.json, <cwd>/.claude/settings.json, <cwd>/.claude/settings.local.json). The Codex / Copilot / Cursor entries it wrote at install time are left in place after uninstall, which means the next npm install -g failproofai (or a manual edit to those files) is the only way for users to get back to a clean slate.

This is an opportunity to bring uninstall parity in line with install parity — every CLI we register hooks for at install time should have its hooks gracefully unregistered at uninstall time, with the same "best-effort, never block" posture that the current Claude cleanup already follows.

Closely related but distinct from already-tracked items:

Where it shows up today

scripts/preuninstall.mjs:30-88 — the cleanup function removeHooksFromFile() is hard-coded to Claude's matcher-wrapper schema (settings.hooks[eventType] → matchers[].hooks[]). The schema-walk logic itself only fits Claude:

for (const eventType of Object.keys(settings.hooks)) {
  const matchers = settings.hooks[eventType];        // Claude shape
  if (!Array.isArray(matchers)) continue;
  for (let i = matchers.length - 1; i >= 0; i--) {
    const matcher = matchers[i];
    if (!matcher.hooks) continue;                     // Claude shape
    matcher.hooks = matcher.hooks.filter((h) => {
      if (h[FAILPROOFAI_HOOK_MARKER] === true) return false;
      const cmd = typeof h.command === "string" ? h.command : "";
      if (cmd.includes("failproofai") && cmd.includes("--hook")) return false;
      return true;
    });
    // …
  }
}

scripts/preuninstall.mjs:96-101 — the candidate-paths list only enumerates Claude config locations:

const candidates = [
  resolve(home, ".claude", "settings.json"),              // user scope
  resolve(projectCwd, ".claude", "settings.json"),        // project scope
  resolve(projectCwd, ".claude", "settings.local.json"),  // local scope
];

What's missing — every CLI that src/hooks/integrations.ts knows how to write to:

CLI User-scope path Project-scope path
Claude ~/.claude/settings.json <cwd>/.claude/settings.json + .local.json
Codex ~/.codex/hooks.json <cwd>/.codex/hooks.json
Copilot ~/.copilot/hooks/failproofai.json <cwd>/.github/hooks/failproofai.json
Cursor ~/.cursor/hooks.json <cwd>/.cursor/hooks.json

…and each of those uses a different schema, so a one-size-fits-all walker doesn't work either.

Coverage gap as it stands

flowchart LR
    User[npm uninstall -g failproofai]
    Pre[scripts/preuninstall.mjs]
    User --> Pre
    Pre -->|cleans| Claude[~/.claude/settings.json<br/>+ project + local]
    Pre -.x.-> Codex[~/.codex/hooks.json<br/>+ project]
    Pre -.x.-> Copilot[~/.copilot/hooks/failproofai.json<br/>+ .github/hooks/]
    Pre -.x.-> Cursor[~/.cursor/hooks.json<br/>+ project]

    style Claude fill:#d4edda,stroke:#28a745
    style Codex fill:#f8d7da,stroke:#dc3545
    style Copilot fill:#f8d7da,stroke:#dc3545
    style Cursor fill:#f8d7da,stroke:#dc3545
Loading

Green = cleaned today. Red = orphaned hook entries left behind after uninstall.

Why it matters in user terms

  • Reinstall reads stale config. A user who uninstalls and later reinstalls (or who installs a different version into a new global) will inherit those orphaned entries pointing at a binary path that may or may not still resolve. Because each CLI's hook command shells out to npx -y failproofai or a pinned binary, the user may see "command not found" or, worse, a different version's behavior than they expect.
  • Hidden state across machines. Sysadmins and CI users who do npm install -g on a build image and tear it down expect a clean tree. Today they have to know to also rm -rf ~/.codex/hooks.json etc. — invisible to anyone who hasn't read this code.
  • Asymmetry surprises. Today a user can run failproofai policies --install --cli codex --scope project and npm uninstall -g failproofai does the right thing for Claude but silently leaves Codex behind. The principle of least surprise says install/uninstall should mirror each other.
  • Beta CLIs lose trust. For users evaluating Codex / Copilot / Cursor support, "uninstall actually uninstalls" is a small but disproportionately confidence-building detail.

Lifecycle picture

sequenceDiagram
  participant User
  participant npm
  participant Pre as preuninstall.mjs
  participant Reg as integrations.ts registry
  participant Claude as ~/.claude
  participant Other as ~/.codex / ~/.cursor / ~/.copilot

  Note over User,Other: TODAY
  User->>npm: npm uninstall -g failproofai
  npm->>Pre: spawn preuninstall
  Pre->>Claude: removeHooksFromFile (3 paths)
  Pre--xOther: (no awareness — orphans persist)
  Pre->>npm: exit 0
  npm->>User: "removed"

  Note over User,Other: AFTER
  User->>npm: npm uninstall -g failproofai
  npm->>Pre: spawn preuninstall
  Pre->>Reg: list integrations + per-CLI cleanup
  Pre->>Claude: clean Claude (matcher-wrapper schema)
  Pre->>Other: clean Codex / Copilot / Cursor (per-CLI schema)
  Pre->>npm: exit 0  (totalRemoved sums across all CLIs)
  npm->>User: "removed"
Loading

Proposed enhancement

Two viable shapes — pick whichever the team prefers:

Option A — inline per-CLI cleanup in preuninstall.mjs (zero new deps). Mirror the Node-builtins-only constraint of the existing script and add three small removeHooksFrom{Codex,Copilot,Cursor}File() walkers that match each CLI's actual schema. Reuse the same FAILPROOFAI_HOOK_MARKER + legacy-failproofai --hook fallback discipline already used for Claude. Extend the candidate-paths list to enumerate the user-scope and project-scope paths for each CLI from the table above.

Option B — call into the integrations registry. src/hooks/integrations.ts already exposes per-CLI removeHooksFromFile(settingsPath) methods on each Integration, and manager.ts removeHooks() already orchestrates the multi-CLI walk for failproofai policies --uninstall. The cleanest fix is to have preuninstall reuse that same orchestration. The catch: today preuninstall is intentionally Node-builtins-only so it works even if the package's own dist/ is broken. Option B would soften that guarantee — recommend Option A on safety grounds, with a small TODO to share parsing helpers if/when preuninstall is allowed to require the bundle.

Either way:

  • Never block uninstall. Keep the existing try { … } catch {} outer wrapper so a per-CLI failure (corrupt JSON, EACCES) doesn't strand the user mid-uninstall.
  • Per-CLI count in the summary log. The current console.log("[failproofai] Removed N hook(s) from <path>") is great — extend it to print one line per CLI cleaned.
  • Telemetry parity. The existing trackInstallEvent("package_uninstalled", { hooks_removed }) already sums into one hooks_removed counter. Either keep it as a sum (simpler) or split into hooks_removed_claude / _codex / _copilot / _cursor (more useful for diagnosing per-CLI install rates).
  • Extensible for [luv-271] feat: unified OpenCode + Pi CLI integrations (beta) #270 / [luv-277] feat: add Gemini CLI integration (beta) #277. When OpenCode / Pi / Gemini land on main they'll plug into the same loop with one more table row — no code restructuring needed.

Acceptance criteria

  • After npm uninstall -g failproofai, zero failproofai-marked hook entries remain in ~/.claude/settings.json, ~/.codex/hooks.json, ~/.copilot/hooks/failproofai.json, ~/.cursor/hooks.json, or their project-scope (<cwd>/...) counterparts.
  • Third-party hooks in any of those files are preserved untouched.
  • Corrupt / unreadable settings files for one CLI do not block cleanup of the others.
  • [failproofai] Removed N hook(s) from <path> lines print one per CLI per scope where work was done.
  • Unit tests in __tests__/scripts/preuninstall.test.ts extend the existing Claude-only coverage with parallel cases per CLI: marked entry removed, legacy entry removed, third-party entry preserved, corrupt file → no-op, missing file → no-op.
  • Docker clean-install→uninstall test (per CLAUDE.md § Testing protocol) verifies an end-to-end uninstall against a session that had hooks installed for all four CLIs.
  • CHANGELOG.md entry under Unreleased → Fixes.

Severity

Medium — this is a CX / hygiene improvement, not a security or correctness defect. The visible symptom is "stale entries in CLI settings files after npm uninstall," which is mildly confusing for power users and mildly surprising for sysadmins. Bringing uninstall coverage in line with install coverage is a small, contained, high-confidence win that will only get more valuable as more CLIs are added.

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions