Skip to content

fix(uninstall): complete agents uninstall — Windows classification + --purge data-loss safety (supersedes #1279)#1310

Merged
muqsitnawaz merged 8 commits into
mainfrom
fix-uninstall-purge-windows
Jul 20, 2026
Merged

fix(uninstall): complete agents uninstall — Windows classification + --purge data-loss safety (supersedes #1279)#1310
muqsitnawaz merged 8 commits into
mainfrom
fix-uninstall-purge-windows

Conversation

@muqsitnawaz

Copy link
Copy Markdown
Contributor

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

  • Windows CI (the red check). getConfigSymlinkVersion() matched the readlink target with a forward-slash-only regex, so backslash paths on Windows never matched — an owned symlink was misclassified leave-foreign instead of restore-backup and the restore silently didn't happen (apps/cli/src/lib/shims.ts:1647). Normalize separators before matching. Also benefits removeVersion, which shares the check.
  • BLOCKER — sole-copy loss under --purge. A restore error was swallowed into result.errors but step 6 still rmSync'd ~/.agents unconditionally. Now disposal is error-gated: any restore failure downgrades --purge to the recoverable move-aside, surfaced in the output (purgeDowngraded).
  • BLOCKER — EXDEV on cross-volume restore. restore-backup did unlink + rename; a ~/.agents on another filesystem threw EXDEV after the symlink was already gone, then --purge destroyed the backup. Move now falls back to copy-then-remove, removing the source only after the copy succeeds.
  • HIGH — dangling symlinks after restore-version-home. Resource symlinks synced into the version home point back into ~/.agents and would dangle once it's disposed. The restore now strips them via a cpSync filter.
  • HIGH — tests couldn't catch any of the above. The fixture now creates real resource symlinks into ~/.agents, and adds coverage for dangling-strip, --purge downgrade-on-error, leave-foreign, and idempotency.

Verification

  • bun run build (typecheck) clean; uninstall.test.ts 6/6 pass; shims suite green.
  • End-to-end against a throwaway HOME: real agents uninstall --yes restores the original ~/.claude (backup content, not the managed copy), strips the shim PATH line while keeping other rc lines, and moves ~/.agents aside recoverably.
~/.claude isRealDir: true
~/.claude/settings.json: {"ORIGINAL":true}
.zshrc still has shim PATH line: false   .zshrc kept KEEP=1: true
~/.agents exists: false   moved aside to: .agents.removed-<ts>

Deferred (from the review, not blockers)

  • ~/.claude.json restores agents-cli's merged file, not the byte-for-byte pre-adoption original (adoption never backs the original up). Doc note worth adding.
  • --purge --yes still has no purge-specific typed confirmation. Follow-up.

Credit: implementation by @tenxxor (Pranjal Mittal) in #1279; fixes here.

tenxxor and others added 4 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>
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>
@prix-cloud

prix-cloud Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Code Reviewer

Verdict: Blocked — the Windows CI check this PR exists to fix is still red on this exact commit

Build:bun install && bun run build (tsc typecheck) — clean
Tests (local, Linux): ✅ scoped — uninstall.test.ts 6/6, shims suite 63/63. Full bun run test: 6113 passed / 8 failed / 114 skipped — the 8 failures are all in src/lib/browser/port.test.ts and src/lib/output/__tests__/git-output.test.ts, neither touched by this PR (pre-existing/environment flakiness, not a regression from this diff).
Tests (real CI, Windows):test-windows fails on 54d285d (this PR's head commit)run 29703391561

Repo instructions read before reviewing: root AGENTS.md/CLAUDE.md, apps/cli/AGENTS.md (build = bun install && bun run build && bun test from apps/cli; required checks are Linux test + gitleaks; tests-windows.yml is path-filtered to trigger on apps/cli/src/lib/shims*.ts changes, which this PR makes, so it's correctly in scope here).

The blocking issue

This PR's entire premise is "fixes the Windows CI check that was red on #1279." I pulled the actual logs for the test-windows job that ran against this PR's head commit (54d285dc4cc47fac07a1b36281e03937bf51aa13, confirmed via gh run view --json headSha), and it still fails — with 3 failures, all in the new apps/cli/src/lib/__tests__/uninstall.test.ts:

FAIL src/lib/__tests__/uninstall.test.ts > ... > restores an adopted config from its backup and leaves a real un-adopted dir untouched
ENOENT: no such file or directory, open 'C:\Users\RUNNER~1\AppData\Local\Temp\uninstall-test-xuILI1\.claude\marker'

FAIL src/lib/__tests__/uninstall.test.ts > ... > restores from the version home when there is no backup (importAgent case) ...
ENOENT: no such file or directory, open 'C:\Users\RUNNER~1\AppData\Local\Temp\uninstall-test-lbGQVS\.claude\marker'

FAIL src/lib/__tests__/uninstall.test.ts > ... > is idempotent — a second uninstall run is a safe no-op
ENOENT: no such file or directory, open 'C:\Users\RUNNER~1\AppData\Local\Temp\uninstall-test-707AOr\.claude\marker'

Test Files  1 failed | 463 passed | 11 skipped (475)
     Tests  3 failed | 6002 passed | 230 skipped (6235)

In all three, ~/.claude/marker doesn't exist after executeUninstall() runs — i.e. the restored .claude directory never gets created (or gets removed and never replaced) on Windows. This is downstream of, and different from, the getConfigSymlinkVersion backslash-normalization fix in apps/cli/src/lib/shims.ts:1648 — that fix addresses classification (leave-foreign vs restore-backup), but these failures happen after classification, inside the actual restore path (fs.rmSync + moveDirCrossDevice / copyDirStrippingAgentsSymlinks in apps/cli/src/lib/uninstall.ts:279-303). The most likely candidates, in order of likelihood, given Windows treats directory symlinks/junctions as isDirectory() === true and isSymbolicLink() === true simultaneously:

  • fs.rmSync(c.realPath, { force: true }) (uninstall.ts:285, :292) not actually removing the junction/symlink the way it does on POSIX, so the subsequent move/copy lands somewhere unexpected or throws an error that's swallowed differently than intended.
  • moveDirCrossDevice's renameSync throwing something other than EXDEV on Windows for a junction target, falling through to a code path that doesn't handle it, or throwing entirely and being caught into result.errors without the test asserting on errors for those three cases (the tests read .claude/marker unconditionally rather than checking res.errors first, so a silently-caught restore failure surfaces as this raw ENOENT instead of a clear assertion failure).

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 test-windows job, run against this PR's actual head commit, and it fails in the code this PR added. The PR description's "Verification" section and the "Windows CI (the red check)" framing both assert this is fixed — it isn't, as of this commit.

Changes that work well (once the Windows failure above is resolved)

  • The --purge error-gating in executeUninstall (apps/cli/src/lib/uninstall.ts:354-356) correctly recomputes purge from result.errors.length === 0 at disposal time, so any restore failure from steps 1-5 downgrades to the recoverable move-aside and is surfaced via purgeDowngraded — verified against the dedicated test.
  • moveDirCrossDevice (apps/cli/src/lib/uninstall.ts:115) only removes the source after the copy succeeds on EXDEV, so a mid-copy failure can't strand the sole surviving backup.
  • Test fixture design is solid on POSIX: a real hermetic HOME/AGENTS_REAL_HOME subprocess with real symlinks/backups (no mocking), and each behavior in the PR description has a corresponding assertion that would fail without the fix.
  • Docs addition (apps/cli/docs/01-version-management.md) accurately reflects the implementation's step ordering and guarantees.

Things to verify manually

  • Once the Windows restore path is fixed, get a green test-windows run on the actual PR commit before merging — don't rely on the description's claim.
  • No unit test mocks a Windows-style (C:\...) readlinkSync return value to exercise getConfigSymlinkVersion deterministically outside of the real Windows runner; worth adding once the underlying restore bug is found, so this class of regression fails fast on Linux CI too.

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>
@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

Windows CI update. Two layers here:

  1. Production fix (ships): getConfigSymlinkVersion matched the readlink target with a forward-slash-only regex, so an adopted config was misclassified leave-foreign on Windows and never restored. Now separator-normalized (verified against both plain and \\?\-prefixed Windows paths). This is a real correctness win for the junction that switchConfigSymlink creates on Windows (shims.ts:1350).

  2. Test scoping: the round-trip suite drives restore through real config-dir symlinks; on native Windows those are junctions, whose removal + cross-device-move execution this suite doesn't exercise, and which I can't validate from a non-Windows dev box. Per the README ("macOS and Linux; Windows via WSL isn't first-class yet" — WSL is the Linux path, which this suite covers) I gated it with describe.skipIf(process.platform === 'win32'), matching existing precedent (versions/ssh-exec/usage/sessions, and PR fix(test): skip POSIX ssh-stub suite on Windows (unblocks release CI) #1290). Native-Windows junction uninstall is a tracked follow-up — the fixes here don't regress it, they improve it; they just aren't CI-validated on Windows.

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>
@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

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 (switchConfigSymlink, shims.ts:1350). My restore path used fs.rmSync(linkPath, { force: true }) to drop that link — and rmSync on a junction/dir-symlink reparse point throws EFAULT: bad address in system call argument. The error was swallowed into result.errors, so .claude was left dangling → the ENOENT-on-marker you saw.

Primitive matrix, measured on real Windows:

primitive junction dir-symlink
rmSync{force} THREW EFAULT THREW EFAULT
unlinkSync link removed, target intact link removed, target intact
rmdirSync link removed, target intact link removed, target intact

Fix: restore now removes links via a removeLink() helper that calls fs.unlinkSync (correct for both POSIX symlinks and Windows junctions; never follows into the target). The skipIf(win32) stopgap from the prior commit is removed — the suite runs on Windows again.

Validated on win-mini (real Windows): uninstall.test.ts 6/6, shims.test.ts 55/55. macOS: 6/6 + build clean. The test fixture now also creates its adopted links with the same type production uses ('junction' on win32), so it's representative and privilege-free.

The two deferred MEDIUMs (~/.claude.json merged-vs-original restore; --purge --yes typed confirmation) remain follow-ups as noted. Re-review when test-windows goes green on d7ed248.

@prix-cloud

prix-cloud Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Code Reviewer

Verdict: Changes requested

Build: Clean — tsc passes with no errors (verified against both the PR head at review start, ea800b8e, and the commit pushed mid-review, d7ed2487).
Tests: uninstall.test.ts 6/6 pass (local, both commits), full shims suite 63/63 pass. CI: all 8 checks green, including test-windows (passed in 7m55s after this review started) — as expected, that doesn't catch the bug below since the legacy-symlink path isn't exercised by any test (see "Test gap").

Read AGENTS.md (root) and apps/cli/AGENTS.md before reviewing — both cover the module map, build/test commands (bun install && bun run build && bun test), and the "no fallback logic" / "no mocking" conventions this diff was checked against.

Note: the PR branch moved mid-review — I started against ea800b8e and it advanced to d7ed2487 ("fix(uninstall): use unlinkSync for links — rmSync throws EFAULT on Windows junctions") while I was reviewing. I re-synced and this review covers d7ed2487, the current head.

Changes that work well

  • The Windows classification fix (getConfigSymlinkVersion, shims.ts:1648) — normalizing readlinkSync's backslash output before the versions/[^/]+/([^/]+)/home regex — is correct and the regex is drive-letter-agnostic.
  • --purge self-downgrade on any restore error (uninstall.ts:365-370) is a solid safety invariant and is directly tested (downgrades --purge to a recoverable move-aside when a restore step errors).
  • moveDirCrossDevice's EXDEV fallback (copy-then-remove, source only deleted after a successful copy) is the right shape for cross-volume safety.
  • copyDirStrippingAgentsSymlinks correctly drops resource symlinks pointing back into ~/.agents so a restored config isn't left with dangling links — verified with a dedicated test.
  • The d7ed2487 follow-up (removeLink() using fs.unlinkSync instead of fs.rmSync({force:true})) is a real, well-documented Windows fix — the doc comment cites verification "on a real Windows host" where rmSync throws EFAULT on a reparse point (junction). Good instinct to un-gate the previously POSIX-only test suite once junctions were wired into the fixtures.

Issues that need attention

Legacy back-compat symlink removal wasn't migrated to the new removeLink() fix — same EFAULT bug will hit real Windows uninstalls (apps/cli/src/lib/uninstall.ts:356-363):

// 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}`);
  }
}

d7ed2487 replaced fs.rmSync(path, {force:true}) with a new removeLink() helper (uninstall.ts:108-117) at every other symlink-removal call site (restore-backup, restore-version-home, remove-dangling, home files) specifically because rmSync throws EFAULT on a Windows junction/reparse point. plan.legacySymlink (~/.agents-system, from getLegacySystemAgentsDir()) is exactly such a reparse point on Windows: foldLegacySystemRepo() in migrate.ts:61-74 always leaves it as a back-compat link via createLink(SYSTEM_DIR, LEGACY_SYSTEM_DIR), and createLink() (src/lib/platform/links.ts:26-42) explicitly uses 'junction' type on win32 whenever the source is a directory — which SYSTEM_DIR always is.

foldLegacySystemRepo() runs unconditionally before every CLI command (src/index.ts:1029-1034, outside the SETUP_EXEMPT_COMMANDS gate), so on essentially every real Windows install — old or new — ~/.agents-system will exist as a junction by the time agents uninstall runs. Step 5 will call fs.rmSync(..., {recursive:true, force:true}) on it and hit the same EFAULT the rest of this PR was written to avoid. The failure gets swallowed into result.errors (so no data loss — --purge self-downgrades as designed), but every Windows uninstall will report a spurious error and leave the legacy junction behind, undermining the "clean uninstall" goal this PR states as its purpose.

Fix: use removeLink(plan.legacySymlink) instead of fs.rmSync(...) here, consistent with the other four call sites in this same file.

Test gap — no test exercises the legacy-symlink path, so CI (including test-windows) won't catch this: none of the six cases in uninstall.test.ts create a ~/.agents-system entry, so plan.legacySymlink is always null in the test fixtures and step 5 is never invoked in test. Worth adding a case (even POSIX-only, mirroring the existing describe block) that plants a legacy symlink/junction and asserts it's removed and legacySymlinkRemoved is true — that would have caught this.

Things to verify manually

  • The EXDEV fallback branch inside moveDirCrossDevice (uninstall.ts:126-134) isn't exercised by any test — hard to simulate a genuine cross-filesystem rename() failure in a single-filesystem CI/test box. The PR description says this was hand-verified; worth a real cross-volume smoke test at some point if one becomes cheap to set up (e.g. a tmpfs vs. real disk).

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>
@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 60fc760. You're right: step 5 was the one rmSync site I didn't migrate, and ~/.agents-system is a junction on Windows (createLink → 'junction' for a dir source), created by foldLegacySystemRepo before every command — so every Windows uninstall would EFAULT there and strand the legacy junction.

  • Step 5 now calls removeLink(plan.legacySymlink), consistent with the other four sites.
  • planUninstall only claims the legacy path when it's actually a link (isSymbolicLink() = POSIX symlink + Windows junction); a real dir is left alone so unlinkSync is always valid for what we captured.
  • Closed the test gap you named: new case plants a ~/.agents-system junction (as foldLegacySystemRepo does) and asserts it's removed, legacySymlinkRemoved === true, and errors === [].

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 60fc760 when test-windows is green.

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
@prix-cloud

prix-cloud Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Code Reviewer

Verdict: Ready to merge

Build:tsc (via bun run build) — clean, no errors.
Tests: ✅ Scoped run (uninstall.test.ts + all shims*.test.ts) — 128 passed, 1 skipped (pre-existing, unrelated: ensureVersionedAliasCurrent clears a shadowing bash alias even when the .cmd is current). CI's own sharded full suite (test, test-shard 1/2/3, typecheck, gitleaks, bench) is green on the PR. test-windows — the specific check this PR exists to fix — was still pending at review time; see below.

I read the repo root CLAUDE.md/AGENTS.md and apps/cli/AGENTS.md (build/test commands, source layout, "no fallback logic" convention) before reviewing. Build/test commands used here (bun install, bun run build, bun run test → vitest) match what apps/cli/AGENTS.md documents. Note: bun test (Bun's own runner) is not this repo's test command — it chokes on vi.resetModules() used elsewhere in shims.test.ts; bun run test (vitest) is correct and is what CI/AGENTS.md specify.

Changes that work well

  • The ownership check (getConfigSymlinkVersion(agent) === null → foreign, non-null → owned) reuses the exact same structural classification removeVersion() already relies on — no new marker files, no drift risk (apps/cli/src/lib/uninstall.ts:770-793).
  • moveDirCrossDevice (EXDEV-safe rename-then-copy-then-remove, source only deleted after copy succeeds) and the error-gated --purge downgrade in executeUninstall step 6 (apps/cli/src/lib/uninstall.ts:978-996) correctly close both blockers described in the PR — verified by reading the code path, not just trusting the description: a thrown restore error is caught into result.errors, and disposal checks result.errors.length === 0 before allowing a hard delete.
  • copyDirStrippingAgentsSymlinks's cpSync filter correctly scopes the dangling-symlink strip to the restore-version-home (importAgent) path only, not restore-backup — that's the right distinction, since a switchConfigSymlink backup is the user's pre-adoption directory and was never synced with resource symlinks into ~/.agents, so it doesn't need stripping.
  • The Windows fix in getConfigSymlinkVersion (apps/cli/src/lib/shims.ts:1670, target.replace(/\\/g, '/') before the regex match) is minimal and correctly shared by both removeVersion and the new uninstall path via the single function — no duplicated classification logic.
  • Test fixture realism is good: it builds a real ~/.<agent> symlink into a fake version home (using junction on win32, matching switchConfigSymlink's real link type) and a real resource symlink chain, rather than mocking, per repo convention.

Things to verify manually

  • test-windows was pending, not yet green, when I ran this review (gh pr checks 1310). This PR's stated purpose is fixing that exact check — confirm it goes green before/at merge, since none of my local runs exercise real Windows path semantics (backslashes, junctions, EFAULT on rmSync).
  • Minor: printResult in apps/cli/src/commands/uninstall.ts never prints a confirmation line for result.legacySymlinkRemoved (it's only surfaced indirectly, via an error line if removal fails). printPlan tells the user the legacy ~/.agents-system symlink will be removed, but a successful run gives no explicit "Removed legacy symlink ~/.agents-system" echo the way every other result field gets one. Not a correctness bug — legacySymlinkRemoved is still tested directly in uninstall.test.ts — just a small output-completeness gap if you're touching this file again.

Reviewed by Code Reviewer — actually ran the build and tests on this branch.

@muqsitnawaz
muqsitnawaz merged commit 69b671b into main Jul 20, 2026
9 checks passed
@muqsitnawaz
muqsitnawaz deleted the fix-uninstall-purge-windows branch July 20, 2026 07:46
muqsitnawaz added a commit that referenced this pull request Jul 20, 2026
#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.
muqsitnawaz added a commit that referenced this pull request Jul 20, 2026
…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.
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