Skip to content

feat(uninstall): complete agents uninstall that restores adopted configs#1279

Closed
tenxxor wants to merge 2 commits into
phnx-labs:mainfrom
tenxxor:feat/self-uninstall
Closed

feat(uninstall): complete agents uninstall that restores adopted configs#1279
tenxxor wants to merge 2 commits into
phnx-labs:mainfrom
tenxxor:feat/self-uninstall

Conversation

@tenxxor

@tenxxor tenxxor commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Problem

There is no way to fully uninstall agents-cli. Setup adopts the user's real config: switchConfigSymlink / importAgent move ~/.<agent> aside and replace it with a symlink into the version homes. But no code path ever restores the backupgetBackupsDir() is only ever written to. So npm uninstall -g @phnx-labs/agents-cli leaves ~/.agents behind, ~/.claude a dangling symlink, and the user's original config stranded under ~/.agents/.history/backups/. Removing the CLI actively disturbs the user's local setup.

What this adds

A first-class agents uninstall — the reverse of agents setup. It removes agents-cli and restores the machine to its pre-install state.

Flow (restore before disposal, since the backups live inside ~/.agents):

  1. Restore each adopted ~/.<agent> — newest timestamped backup if present, else the symlink target (the importAgent case, where the original was renamed into the version home). A real, un-adopted directory is left untouched.
  2. Restore owned home files (~/.claude.json, …).
  3. Release adopted launchers — restore native binaries on PATH (releaseAdoptedLauncher).
  4. Strip the shim dir from every shell rc file (reuses stripShimPathLines).
  5. Dispose of ~/.agents — moved aside to ~/.agents.removed-<timestamp> (recoverable) by default, or hard-deleted with --purge.
  6. Print the final npm uninstall -g … — a running CLI can't reliably delete its own binary.

Safety

  • Never touches an un-adopted config. Ownership is decided structurally by getConfigSymlinkVersion() (non-null only for a symlink into the versions dir) — the exact check removeVersion already uses. A real ~/.claude agents-cli never adopted is classified leave-real and never modified.
  • --dry-run prints the full plan (restored / left-untouched / removed) and changes nothing.
  • Refuses to run non-interactively without --yes.
  • Recoverable by default (~/.agents moved aside, not deleted).
  • Exempt from the setup gate, so it runs even from a broken/half-initialized state.
  • Silences the event log for the run so a late emit() (whose events path is memoized) can't re-create ~/.agents after it is moved.

Implementation

  • lib/uninstall.ts — read-only planUninstall() + mutating executeUninstall() so --dry-run and the real run share one path.
  • commands/uninstall.ts — thin command (plan → confirm → execute → print npm step).
  • Exports getAgentConfigPath + stripShimPathLines from shims.ts for reuse (no duplication).
  • Registers the command (command-registry.ts, index.ts) and adds it to SETUP_EXEMPT_COMMANDS.

Tests

Subprocess tests against a real temp HOME (repo's no-mocking convention): restore-from-backup, restore-from-version-home, un-adopted dir left untouched, PATH strip, and planUninstall being read-only. bun run build clean; existing shims suite passes.

Verified end-to-end: a pre-existing real ~/.claude adopted by a normal install is restored to its original content on uninstall; an un-adopted ~/.codex is untouched; the shim PATH line is removed; ~/.agents is moved aside; and --purge hard-deletes while still restoring.

Scope note

macOS Keychain items created by agents secrets are managed by the signed helper app and are not removed even under --purge (documented in the notes). Bulk-Keychain teardown could be a follow-up.

🤖 Generated with Claude Code

tenxxor and others added 2 commits July 18, 2026 10:21
…nfigs

Add a first-class `agents uninstall` command: the reverse of `agents setup`.
It completely removes agents-cli AND restores the config directories that
adoption took over, so the machine is left as it was before install.

Today there is no way to fully undo agents-cli. `switchConfigSymlink`/
`importAgent` move the real ~/.<agent> aside and replace it with a symlink into
the version homes, but no code ever restores the backup -- so removing the CLI
leaves ~/.claude a dangling symlink and the original stranded under
~/.agents/.history/backups. This closes that gap.

Flow (restore runs before disposal because backups live inside ~/.agents):
1. Restore each adopted ~/.<agent>: newest backup if present, else the symlink
   target (importAgent case). A real, un-adopted dir is LEFT UNTOUCHED --
   ownership is decided structurally by getConfigSymlinkVersion, the same check
   removeVersion uses.
2. Restore owned home files (~/.claude.json, ...).
3. Release adopted launchers (restore native binaries on PATH).
4. Strip the shim dir from every shell rc file (reuses stripShimPathLines).
5. Dispose of ~/.agents: moved aside to ~/.agents.removed-<ts> (recoverable) by
   default, or hard-deleted with --purge.
6. Print the final `npm uninstall -g` (a CLI can't delete its own binary).

- `--dry-run` prints the full plan and changes nothing.
- Refuses to run non-interactively without `--yes`.
- Exempt from the setup gate so it works from a broken/half-setup state.
- Silences the event log for the run so a late emit() can't re-create ~/.agents.

Logic lives in lib/uninstall.ts (planUninstall/executeUninstall) with subprocess
tests against a real temp HOME: restore-from-backup, restore-from-version-home,
un-adopted dir left untouched, PATH strip, and dry-run read-only. Exports
getAgentConfigPath + stripShimPathLines from shims.ts for reuse. Docs added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The subprocess tests set HOME to a temp dir but inherited AGENTS_REAL_HOME
from the outer environment. state.ts derives ~/.agents from HOME while
getAgentConfigPath honors AGENTS_REAL_HOME, so a stale AGENTS_REAL_HOME made
the two diverge and the tests fail. Pin both to the test dir so the run is
hermetic regardless of the outer environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@muqsitnawaz

Copy link
Copy Markdown
Contributor

Review — NEEDS WORK ⚠️

Architecture is sound (read-only planUninstall + mutating executeUninstall, structural ownership via getConfigSymlinkVersion, restore-before-dispose ordering, move-aside default, well commented). But there are real data-loss defects under --purge plus a dangling-symlink restore, none of which the current tests can catch.

BLOCKER 1 — --purge hard-deletes ~/.agents even when a restore errored (sole-copy loss). executeUninstall swallows a failed restore into result.errors (catch (err) { result.errors.push(...) }) and step 6 still runs fs.rmSync(plan.agentsDir, { recursive: true, force: true }) unconditionally. For the restore-version-home case the original config is the sole copyimportAgentConfig MOVED it in with renameSync (import.ts:81) and restore only cpSyncs it back. A partial cpSync failure (ENOSPC/EACCES) → error swallowed → ~/.agents purged → only good copy destroyed. Fix: if result.errors.length > 0, downgrade --purge to move-aside; and/or use renameSync for restore-version-home.

BLOCKER 2 — EXDEV on restore-backup rename + --purge destroys the backup. if (c.kind === 'restore-backup') { fs.unlinkSync(c.realPath); fs.renameSync(c.source, c.realPath); }. c.source is under ~/.agents/.history/backups/…, c.realPath is $HOME/.<agent>. If ~/.agents is a symlink to another volume, renameSync throws EXDEV (Node has no copy-fallback); the symlink is already unlinked, the backup still sits inside ~/.agents, and step-6 --purge deletes it → ~/.<agent> gone AND backup gone. Same fix (error-gate purge; or copy-then-unlink / EXDEV fallback to cpSync).

HIGH — restore-version-home leaves dangling symlinks. Adoption syncs managed resource symlinks into the version-home config dir (skills at <versionHome>/<configDir>/skills/<name>symlinkSync(centralPath, …, 'dir') into ~/.agents/skills/, skills.ts:305; commands likewise commands.ts:350). Restore does cpSync(c.source, c.realPath, { recursive: true }), which preserves symlinks. After ~/.agents is moved aside/purged, the restored ~/.claude/skills/* and ~/.claude/commands/* are dangling links into a dir that no longer exists — a degraded restore. restore-backup avoids this (backups predate resource sync). Fix: strip ~/.agents-pointing symlinks while copying (cpSync filter, or post-restore prune).

HIGH — tests can't catch any of the above. uninstall.test.ts is genuinely real-fs/no-mock (good), but the fixture's adopt() writes only a plain marker file — it never creates the resource symlinks-into-~/.agents that real adoption produces. So the restore-version-home test copies a symlink-free dir and the dangling-symlink bug is structurally unreachable. Also missing: --purge; dangling-after-move; ~/.claude.json home-file restore; launcher release; remove-dangling; a real leave-foreign symlink; any error path; double-run idempotency.

MEDIUM — ~/.claude.json restores merged content, not the user's original. Adoption via switchHomeFileSymlinks never backs up the original; it merges + writes the merged JSON then symlinks in place. Uninstall's cpSync restores agents-cli's merged file, not the pristine pre-adoption one. Usually harmless (auth preserved) but not a true byte-for-byte reversal — worth a doc note since the doc claims the machine is "left as it was before agents-cli was installed."

MEDIUM — --purge --yes nukes everything with no purge-specific guard. Confirm lives in the command layer and is skipped by --yes, so agents uninstall --purge --yes in a script irreversibly deletes ~/.agents (sessions, binaries, secrets metadata, backups, trash safety-net) with zero prompt. Consider a distinct --confirm-purge / typed confirmation that --yes does not satisfy.

Verified correct: ordering (restore-backup renames the backup out of ~/.agents before disposal); second-run idempotency is a safe no-op; numeric backup sort in newestBackupDir; the two shims.ts exports are minimal; uninstall correctly added to SETUP_EXEMPT_COMMANDS.

Bottom line: fix the two --purge data-loss paths (error-gate disposal + prefer rename/copy-then-unlink) and the dangling-symlink restore, and make the test fixture create real resource symlinks so these are actually covered. The rest is nits/docs.

@muqsitnawaz

Copy link
Copy Markdown
Contributor

Closing in favor of #1310, which supersedes this and carries the same agents uninstall feature with the data-loss defects from my earlier review fixed (error-gated --purge disposal, EXDEV-safe backup restore, dangling-symlink pruning, and a fixture that creates real resource symlinks so those paths are actually covered). Thanks for the original architecture here, @tenxxor — the read-only planUninstall + mutating executeUninstall split carried straight over into #1310. This also has an unaddressed test-windows failure. Continuing the work in #1310.

muqsitnawaz added a commit that referenced this pull request Jul 20, 2026
…+ --purge data-loss safety (supersedes #1279) (#1310)

* feat(uninstall): complete `agents uninstall` that restores adopted configs

Add a first-class `agents uninstall` command: the reverse of `agents setup`.
It completely removes agents-cli AND restores the config directories that
adoption took over, so the machine is left as it was before install.

Today there is no way to fully undo agents-cli. `switchConfigSymlink`/
`importAgent` move the real ~/.<agent> aside and replace it with a symlink into
the version homes, but no code ever restores the backup -- so removing the CLI
leaves ~/.claude a dangling symlink and the original stranded under
~/.agents/.history/backups. This closes that gap.

Flow (restore runs before disposal because backups live inside ~/.agents):
1. Restore each adopted ~/.<agent>: newest backup if present, else the symlink
   target (importAgent case). A real, un-adopted dir is LEFT UNTOUCHED --
   ownership is decided structurally by getConfigSymlinkVersion, the same check
   removeVersion uses.
2. Restore owned home files (~/.claude.json, ...).
3. Release adopted launchers (restore native binaries on PATH).
4. Strip the shim dir from every shell rc file (reuses stripShimPathLines).
5. Dispose of ~/.agents: moved aside to ~/.agents.removed-<ts> (recoverable) by
   default, or hard-deleted with --purge.
6. Print the final `npm uninstall -g` (a CLI can't delete its own binary).

- `--dry-run` prints the full plan and changes nothing.
- Refuses to run non-interactively without `--yes`.
- Exempt from the setup gate so it works from a broken/half-setup state.
- Silences the event log for the run so a late emit() can't re-create ~/.agents.

Logic lives in lib/uninstall.ts (planUninstall/executeUninstall) with subprocess
tests against a real temp HOME: restore-from-backup, restore-from-version-home,
un-adopted dir left untouched, PATH strip, and dry-run read-only. Exports
getAgentConfigPath + stripShimPathLines from shims.ts for reuse. Docs added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(uninstall): pin AGENTS_REAL_HOME in subprocess env for hermeticity

The subprocess tests set HOME to a temp dir but inherited AGENTS_REAL_HOME
from the outer environment. state.ts derives ~/.agents from HOME while
getAgentConfigPath honors AGENTS_REAL_HOME, so a stale AGENTS_REAL_HOME made
the two diverge and the tests fail. Pin both to the test dir so the run is
hermetic regardless of the outer environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(uninstall): Windows classification + --purge data-loss safety

Builds on Pranjal Mittal's `agents uninstall` (#1279) and closes the review
blockers plus the failing Windows check.

- Windows CI fix: getConfigSymlinkVersion() matched the readlink target with a
  forward-slash-only regex, so backslash paths on Windows never matched and an
  owned symlink was misclassified `leave-foreign` instead of `restore-backup`.
  Normalize separators before matching (also benefits removeVersion, which shares
  the check).
- Blocker (sole-copy loss): --purge hard-deleted ~/.agents even when a restore
  errored. Error-gate disposal — any restore failure downgrades --purge to the
  recoverable move-aside, surfaced in the command output (purgeDowngraded).
- Blocker (EXDEV): restore-backup did unlink + rename; a cross-volume ~/.agents
  threw EXDEV after the symlink was already gone, then --purge destroyed the
  backup. Move now falls back to copy-then-remove (source removed only after the
  copy succeeds).
- Restore-version-home stripped: resource symlinks synced into the version home
  point back into ~/.agents and would dangle once it is disposed; the restore now
  drops them via a cpSync filter.
- Tests: fixture now creates real resource symlinks into ~/.agents; adds coverage
  for dangling-strip, --purge downgrade-on-error, leave-foreign, and idempotency.
  Verified end-to-end against a throwaway HOME: real `agents uninstall` restores
  the original ~/.claude, strips the shim PATH line (keeping other rc lines), and
  moves ~/.agents aside.

Co-Authored-By: Pranjal Mittal <37429384+tenxxor@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(uninstall): gate the symlink round-trip suite to POSIX

The subprocess round-trip drives restore through real config-dir symlinks. On
native Windows those are junctions (switchConfigSymlink uses 'junction'), whose
removal + cross-device move semantics this suite doesn't exercise, so it fails on
the Windows runner. agents-cli is macOS/Linux-first (README: Windows via WSL —
the Linux path, which this covers — isn't first-class yet). The production fixes
in this change still ship for Windows; native-Windows junction uninstall is a
tracked follow-up. Matches existing it.skipIf(win32) precedent (versions,
ssh-exec, usage, sessions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(uninstall): use unlinkSync for links — rmSync throws EFAULT on Windows junctions

The real Windows failure, reproduced and fixed on a real Windows host (not gated).

Root cause: my earlier restore path used `fs.rmSync(linkPath, { force: true })` to
drop the adopted config link before restoring. On Windows, production adoption
(switchConfigSymlink) creates that link as a JUNCTION, and `fs.rmSync` on a
junction / directory-symlink reparse point throws `EFAULT: bad address in system
call argument`. The error was swallowed into result.errors, so the restore never
completed and ~/.<agent> was left dangling — the ENOENT-on-marker CI failure.

Verified on win-mini (bun 1.3.14, same as CI) which removal primitive drops the
link without following into / destroying the target:
  rmSync{force}              -> THREW EFAULT   (junction AND dir-symlink)
  unlinkSync                 -> link removed, target intact  ✓
  rmdirSync                  -> link removed, target intact  ✓
`unlinkSync` is the correct cross-platform primitive (POSIX symlink + Windows
junction/dir-symlink), so restore now goes through a `removeLink()` helper that
calls it. Also reverts the home-file loop to the same.

- Un-gates the Windows uninstall suite (previous commit's skipIf was a stopgap
  while I lacked a Windows box) — now validated green on real Windows: 6/6 tests.
- Test adopt()/syncResource/leave-foreign now create links with the SAME type
  production uses ('junction' on win32), which is also privilege-free on Windows
  (directory symlinks need elevation; junctions don't).

Verified: uninstall 6/6 + shims 55/55 on real Windows (win-mini); uninstall 6/6 +
build clean on macOS.

Co-Authored-By: Pranjal Mittal <37429384+tenxxor@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(uninstall): route legacy ~/.agents-system removal through removeLink too

prix-cloud review catch: step 5 still used fs.rmSync({recursive,force}) on
plan.legacySymlink, which on Windows is a junction (createLink uses 'junction'
for a dir source) — the same reparse point that throws EFAULT. foldLegacySystemRepo
runs before every command, so ~/.agents-system exists as a junction on essentially
every real Windows install; every Windows uninstall would spuriously error there
and leave the legacy junction behind.

- Step 5 now calls removeLink(plan.legacySymlink), consistent with the other four
  link-removal sites.
- planUninstall only claims the legacy path when it is actually a link
  (isSymbolicLink covers POSIX symlink + Windows junction); a real dir is left
  alone so unlinkSync is always the correct primitive for what was captured.
- New test plants a ~/.agents-system junction (as foldLegacySystemRepo does) and
  asserts it's removed, legacySymlinkRemoved is true, and errors is empty — closes
  the test gap the reviewer flagged (no prior case exercised the legacy path).

Verified: uninstall 7/7 on real Windows (win-mini) AND macOS; build clean.

Co-Authored-By: Pranjal Mittal <37429384+tenxxor@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: tenxxor <37429384+tenxxor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants