…+ --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>
Problem
There is no way to fully uninstall agents-cli. Setup adopts the user's real config:
switchConfigSymlink/importAgentmove~/.<agent>aside and replace it with a symlink into the version homes. But no code path ever restores the backup —getBackupsDir()is only ever written to. Sonpm uninstall -g @phnx-labs/agents-clileaves~/.agentsbehind,~/.claudea 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 ofagents setup. It removes agents-cli and restores the machine to its pre-install state.Flow (restore before disposal, since the backups live inside
~/.agents):~/.<agent>— newest timestamped backup if present, else the symlink target (theimportAgentcase, where the original was renamed into the version home). A real, un-adopted directory is left untouched.~/.claude.json, …).PATH(releaseAdoptedLauncher).stripShimPathLines).~/.agents— moved aside to~/.agents.removed-<timestamp>(recoverable) by default, or hard-deleted with--purge.npm uninstall -g …— a running CLI can't reliably delete its own binary.Safety
getConfigSymlinkVersion()(non-null only for a symlink into the versions dir) — the exact checkremoveVersionalready uses. A real~/.claudeagents-cli never adopted is classifiedleave-realand never modified.--dry-runprints the full plan (restored / left-untouched / removed) and changes nothing.--yes.~/.agentsmoved aside, not deleted).emit()(whose events path is memoized) can't re-create~/.agentsafter it is moved.Implementation
lib/uninstall.ts— read-onlyplanUninstall()+ mutatingexecuteUninstall()so--dry-runand the real run share one path.commands/uninstall.ts— thin command (plan → confirm → execute → print npm step).getAgentConfigPath+stripShimPathLinesfromshims.tsfor reuse (no duplication).command-registry.ts,index.ts) and adds it toSETUP_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, andplanUninstallbeing read-only.bun run buildclean; existingshimssuite passes.Verified end-to-end: a pre-existing real
~/.claudeadopted by a normal install is restored to its original content on uninstall; an un-adopted~/.codexis untouched; the shim PATH line is removed;~/.agentsis moved aside; and--purgehard-deletes while still restoring.Scope note
macOS Keychain items created by
agents secretsare 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