fix(uninstall): complete agents uninstall — Windows classification + --purge data-loss safety (supersedes #1279)#1310
Conversation
…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>
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>
Code ReviewerVerdict: Blocked — the Windows CI check this PR exists to fix is still red on this exact commit Build: ✅ Repo instructions read before reviewing: root The blocking issueThis PR's entire premise is "fixes the Windows CI check that was red on #1279." I pulled the actual logs for the In all three,
I can't repro Windows locally in this sandbox to pin the exact line, but the observation itself is solid: this is the real, required-by-branch-protection Changes that work well (once the Windows failure above is resolved)
Things to verify manually
Reviewed by Code Reviewer — actually ran the build and tests on this branch. |
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>
|
Windows CI update. Two layers here:
Not hiding a Windows bug: keeping the real fixes, scoping the test to the tested platforms, and calling the gap out explicitly. |
…ndows 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>
|
Windows is genuinely fixed now — reproduced and validated on a real Windows host, not gated. Thanks @prix-cloud, your diagnosis was exactly right: the failure was in the restore execution, downstream of classification. Root cause (confirmed on win-mini, bun 1.3.14 = CI): production adoption creates the config link as a junction on Windows ( Primitive matrix, measured on real Windows:
Fix: restore now removes links via a Validated on win-mini (real Windows): The two deferred MEDIUMs ( |
Code ReviewerVerdict: Changes requested Build: Clean — Read Note: the PR branch moved mid-review — I started against Changes that work well
Issues that need attentionLegacy back-compat symlink removal wasn't migrated to the new // 5. Remove the legacy back-compat symlink, if present.
if (plan.legacySymlink) {
try {
fs.rmSync(plan.legacySymlink, { recursive: true, force: true });
result.legacySymlinkRemoved = true;
} catch (err) {
result.errors.push(`legacy ${plan.legacySymlink}: ${(err as Error).message}`);
}
}
Fix: use Test gap — no test exercises the legacy-symlink path, so CI (including Things to verify manually
Reviewed by Code Reviewer — actually ran the build and tests on this branch. |
…ink 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>
|
Good catch — fixed in
Validated on real Windows (win-mini, bun 1.3.14 = CI): uninstall 7/7 (including the new legacy case, which EFAULT'd before this fix) and macOS 7/7 + build clean. Re-review on On the EXDEV branch you flagged as untested: agreed it's hard to exercise without a genuine cross-volume rename; left as a documented hand-verified path per your note. |
…indows # Conflicts: # apps/cli/docs/01-version-management.md
Code ReviewerVerdict: Ready to merge Build: ✅ I read the repo root Changes that work well
Things to verify manually
Reviewed by Code Reviewer — actually ran the build and tests on this branch. |
#1321 cut 1.20.71 but it was never published (npm still 1.20.70) and its release PR merged after main had diverged, so no CI-tested 1.20.71 tree exists and the catch-up publish is refused. This resets the release so a fresh release.sh 1.20.71 cut publishes current main — which now also carries agents uninstall (#1310), isolated installs (#1278), and logs --json (#1322). - Revert apps/cli/package.json 1.20.71 -> 1.20.70 (undo #1321's orphaned bump) so release.sh does a fresh cut instead of the failing catch-up. - Restore the 6 changelog fragments #1321 consumed, so the re-cut documents ALL unreleased features (release-changelog overwrites version.md, and would otherwise drop them). - Remove the stale .changelog/1.20.71.md and regenerate CHANGELOG.md. Next release note (uninstall) added earlier in this branch. After merge: release.sh 1.20.71 --apply folds all 9 fragments and publishes.
…1321 release) (#1324) * docs(changelog): add agents uninstall note for the next release #1310 shipped agents uninstall but didn't queue a changelog fragment; the release step requires every user-visible change to document itself. * chore(release): re-cut 1.20.71 from current main #1321 cut 1.20.71 but it was never published (npm still 1.20.70) and its release PR merged after main had diverged, so no CI-tested 1.20.71 tree exists and the catch-up publish is refused. This resets the release so a fresh release.sh 1.20.71 cut publishes current main — which now also carries agents uninstall (#1310), isolated installs (#1278), and logs --json (#1322). - Revert apps/cli/package.json 1.20.71 -> 1.20.70 (undo #1321's orphaned bump) so release.sh does a fresh cut instead of the failing catch-up. - Restore the 6 changelog fragments #1321 consumed, so the re-cut documents ALL unreleased features (release-changelog overwrites version.md, and would otherwise drop them). - Remove the stale .changelog/1.20.71.md and regenerate CHANGELOG.md. Next release note (uninstall) added earlier in this branch. After merge: release.sh 1.20.71 --apply folds all 9 fragments and publishes.
What this is
Completes @tenxxor's
agents uninstall(#1279) — it carries Pranjal's full implementation and adds the fixes for the review blockers plus the failing Windows check, so it can go in green. Supersedes #1279 (which can be closed in its favor).This directly serves Pranjal's original adoption ask: a clean uninstall that returns the machine to its pre-agents-cli state, so the cost of trying the CLI is near zero.
Fixes on top of #1279
getConfigSymlinkVersion()matched thereadlinktarget with a forward-slash-only regex, so backslash paths on Windows never matched — an owned symlink was misclassifiedleave-foreigninstead ofrestore-backupand the restore silently didn't happen (apps/cli/src/lib/shims.ts:1647). Normalize separators before matching. Also benefitsremoveVersion, which shares the check.--purge. A restore error was swallowed intoresult.errorsbut step 6 stillrmSync'd~/.agentsunconditionally. Now disposal is error-gated: any restore failure downgrades--purgeto the recoverable move-aside, surfaced in the output (purgeDowngraded).restore-backupdidunlink+rename; a~/.agentson another filesystem threwEXDEVafter the symlink was already gone, then--purgedestroyed the backup. Move now falls back to copy-then-remove, removing the source only after the copy succeeds.restore-version-home. Resource symlinks synced into the version home point back into~/.agentsand would dangle once it's disposed. The restore now strips them via acpSyncfilter.~/.agents, and adds coverage for dangling-strip,--purgedowngrade-on-error,leave-foreign, and idempotency.Verification
bun run build(typecheck) clean;uninstall.test.ts6/6 pass;shimssuite green.HOME: realagents uninstall --yesrestores the original~/.claude(backup content, not the managed copy), strips the shim PATH line while keeping other rc lines, and moves~/.agentsaside recoverably.Deferred (from the review, not blockers)
~/.claude.jsonrestores agents-cli's merged file, not the byte-for-byte pre-adoption original (adoption never backs the original up). Doc note worth adding.--purge --yesstill has no purge-specific typed confirmation. Follow-up.Credit: implementation by @tenxxor (Pranjal Mittal) in #1279; fixes here.