feat: add self-upgrade subcommand (agent-init upgrade)#73
Conversation
Add `agent-init upgrade` to update the binary in place from the latest GitHub release. The check is manual-only — no per-invocation network call. `--check` reports whether a newer version exists; `--dry-run` downloads and verifies without replacing; `--force` reinstalls or upgrades a dev build. The new internal/selfupdate package fetches the latest release, selects the asset matching the running OS/arch, verifies its SHA-256 against the release checksums.txt before installing, and swaps the binary atomically (write-temp-then-rename with a move-aside fallback for platforms that can't rename over a running executable). The install path is resolved via os.Executable (following symlinks); a non-writable install dir fails with a hint rather than attempting privilege escalation. Covers the package with unit tests (version compare, asset/checksum, archive extraction, full upgrade/dry-run/mismatch/force flows via an in-memory Source, and an httptest-backed GitHub client) plus CLI tests for help, the dev-build refusal, and arg validation. Documents the subcommand in docs/cli.md and the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Posting observations from a simplicity review pass for the record. Acknowledging up front that the missing linked issue is on me — I asked for this PR without filing one first, which is the soft gate Scope vs. stated motivation The body's stated goal is "there's no way to update the CLI." A minimum viable shape that delivers that is
In-package semver reimplementation
The Download-size cap
Untested portability branch
Docs drift into rationale The Stale CLI surface in
Latent unrelated finding
Test surface (positive) The end-to-end upgrade tests (replaces / already-up-to-date / force / dry-run / mismatch-leaves-binary / no-asset / missing-checksums) are exactly right coverage for a binary-swap path. Not bloat. |
Pre-merge checklist for this PRA review (see retroactive spec #76) found this feature complete, well-tested, and appropriately simple. Two items must be resolved before merge, listed here so a follow-up agent can pick them up: 1. Reconcile 2. Confirm the CI gate runs green on the PR head. Optional loose end (non-blocking): the move-aside fallback in Follow-ups split out of this work (do not block merge)
The two design decisions made here (manual-only / no auto-update; in-place binary replacement) are recorded in the project decision log. |
|
LOW — Fix: replace |
The archive download was capped at 200 MiB, but the per-entry read of the tar.gz/zip payload was unbounded. A release-pipeline-compromise (gzip/zip bomb) could decompress to many GB and OOM the upgrading user. Trust root (verified SHA-256 checksum from the same release) bounds exploitability; this is defense-in-depth. Wraps the per-entry io.ReadAll with io.LimitReader against a 64 MiB ceiling (~10x the shipped binary). Truncation is treated as an explicit error rather than a silent partial extract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ge impl Replaces the hand-rolled parseSemver/compareVersions/cmpInt (and the 11-row table that tested them) with golang.org/x/mod/semver.Compare, which already implements the full semver ordering and is the standard Go module for this. A small normalizeVersion wrapper preserves the existing caller contract (leading v optional, missing minor/patch fill to .0, "dev" treated as older than any release per semver's invalid-less-than-valid rule). Net subtraction of ~55 LoC. Trims the table test to the cases that exercise our normalization seam — semver-internal ordering is upstream's job. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Release archives are single-digit MiB; the previous 200 MiB ceiling let a misbehaving or hostile endpoint stream up to 199 MiB before the cap fired. 32 MiB is ~10x the largest plausible archive and a more honest ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous six bullets carried implementation rationale (atomic rename details, os.Executable symlink resolution) that already lives as code comments and is noise from a user's perspective. Cut to the facts a user needs: network call to GitHub, SHA-256 verification with fail-closed semantics, write permission required on the install path, dev-build needs --force. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.agent/AGENTS.md still listed five subcommands; this PR adds upgrade as the sixth. The canonical agent-facing CLI reference has to track the binary's actual surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fallback that handles the Windows "can't rename over a running executable" case had no test driving it. Force the primary os.Rename(file, dir) to fail with EISDIR on POSIX by pre-creating target as a directory; the fallback then renames it aside and installs the new binary. Asserts the final binary contents match the new release, which the previous tests only confirmed on the happy path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up go.sum (added by the semver dep) and the file-count tick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Review-comment follow-ups landed on this branch:
Plus 4e1def4 picks up the regenerated codemap (adds Out of scope per the original PR / your guidance: missing linked issue, stdlib vulncheck advisory (#77),
|
Why
A stale local binary looked like missing devcontainer features (it wasn't —
justis installed in every Dockerfile andanthropic.claude-codeis already in all four flavor devcontainers). The real gap is that there's no way to update the CLI. This adds an in-place self-upgrade.What
agent-init upgrade— update the binary in place from the latest GitHub release.--check— report whether a newer release exists; no download.--dry-run— download and verify, but don't replace the binary.--force— reinstall when already current, or upgrade adevbuild (which has no release version to compare against).Design decisions (confirmed up front):
upgrade(optionally--check).checksums.txt; a mismatch aborts and leaves the existing binary untouched.How
New
internal/selfupdatepackage:github.go) — readsGITHUB_TOKEN/GH_TOKENto lift the anonymous rate limit; capped download size; overridable base URL for tests.selfupdate.go) — semver compare, OS/arch asset selection matching the release-workflow names, checksum verification, tar.gz/zip extraction, and an atomic write-temp-then-rename swap with a move-aside fallback for platforms that can't rename over a running executable.os.Executable()(following symlinks); a non-writable install dir fails with a hint rather than attempting privilege escalation.Wired into
internal/cli/cli.go(dispatch +commandshelp table). Documented indocs/cli.mdand the README.Tests
internal/selfupdate: version compare, asset/binary naming, checksum verify (match /*prefix / mismatch / missing entry), archive extraction (tar.gz + zip), and full flows via an in-memorySource— upgrade, already-up-to-date,--force,--dry-run, checksum-mismatch-leaves-binary, no-asset-for-platform, missing-checksums — plus anhttptest-backed GitHub client.internal/cli:upgrade --help, dev-build refusal without--force, positional-arg rejection (all hermetic — no network).Gate
./.agent/scripts/check.shpasses. The only advisory finding is a pre-existing Go 1.26.3 stdlib vuln (GO-2026-5037/5039) fixed by a 1.26.4 toolchain bump — surfaced more by the newnet/httpcall path but not introduced by this change.Follow-ups (not in this PR)
CLAUDE.md's "CLI surface" section still lists five subcommands; left untouched since it's under.agent/, but it could mentionupgrade.🤖 Generated with Claude Code