diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f48ac52..b8062f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,8 +55,10 @@ jobs: fi echo "✓ Build output matches committed file" - - name: Run proxy regression checks - run: bash scripts/test-socks5h-probes.sh + - name: Run regression checks + run: | + bash scripts/test-socks5h-probes.sh + bash scripts/test-claude-bulk-version-commands.sh js-check: name: JS syntax diff --git a/README.md b/README.md index b7f3751..671afea 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ cac claude install latest # 安装最新版 cac claude install 2.1.81 # 安装指定版本 cac claude ls # 列出已安装版本 cac claude pin 2.1.81 # 当前环境切换版本 +cac claude update-all latest # 所有环境切换到最新版 +cac claude prune # 卸载未被环境使用的版本 cac claude uninstall 2.1.81 # 卸载 ``` @@ -121,6 +123,8 @@ cac ls # = cac env ls | `cac claude uninstall ` | 卸载版本 | | `cac claude ls` | 列出已安装版本 | | `cac claude pin ` | 当前环境绑定版本 | +| `cac claude update-all [latest\|]` | 所有环境绑定到同一版本 | +| `cac claude prune` | 卸载未被任何环境使用的版本 | | **环境管理** | | | `cac env create [-p proxy] [-c ver] [--clone] [--telemetry mode] [--persona preset]` | 创建环境(自动激活,`--telemetry transparent/stealth/paranoid` 控制遥测,`--persona macos-vscode/...` 用于容器) | | `cac env ls` | 列出环境 | @@ -275,6 +279,8 @@ cac claude install latest # install latest cac claude install 2.1.81 # install specific version cac claude ls # list installed versions cac claude pin 2.1.81 # pin current env to version +cac claude update-all latest # pin all envs to latest +cac claude prune # remove versions unused by any env cac claude uninstall 2.1.81 # remove ``` @@ -306,6 +312,8 @@ Each environment is fully isolated: | `cac claude uninstall ` | Remove version | | `cac claude ls` | List installed versions | | `cac claude pin ` | Pin current env to version | +| `cac claude update-all [latest\|]` | Pin all environments to one version | +| `cac claude prune` | Remove versions unused by any environment | | **Environment management** | | | `cac env create [-p proxy] [-c ver] [--clone] [--telemetry mode] [--persona preset]` | Create environment (auto-activates, `--telemetry transparent/stealth/paranoid` for telemetry control, `--persona macos-vscode/...` for containers) | | `cac env ls` | List environments | diff --git a/cac b/cac index 60c70cd..7eb9b76 100755 --- a/cac +++ b/cac @@ -2915,6 +2915,60 @@ _claude_cmd_uninstall() { echo "$(_green_bold "Uninstalled") Claude Code $(_cyan "$ver")" } +_claude_cmd_update_all() { + local target="${1:-latest}" + local ver + if [[ "$target" == "latest" ]]; then + printf "Fetching latest version ... " + ver=$(_fetch_latest_version) || _die "failed to fetch latest version" + echo "$(_cyan "$ver")" + else + ver="$target" + fi + + [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]] || \ + _die "invalid version $(_cyan "'$ver'")" + + mkdir -p "$VERSIONS_DIR" + _download_version "$ver" + _update_latest + + local count=0 env_dir + for env_dir in "$ENVS_DIR"/*/; do + [[ -d "$env_dir" ]] || continue + echo "$ver" > "$env_dir/version" + (( count += 1 )) + done + + echo "$(_green_bold "Pinned") $count environment(s) -> Claude Code $(_cyan "$ver")" +} + +_claude_cmd_prune() { + _update_latest 2>/dev/null || true + if [[ ! -d "$VERSIONS_DIR" ]]; then + echo "$(_dim " No versions installed.")" + return + fi + + local removed=0 ver_dir ver count + for ver_dir in "$VERSIONS_DIR"/*/; do + [[ -d "$ver_dir" ]] || continue + ver=$(basename "$ver_dir") + count=$(_envs_using_version "$ver") + if [[ "$count" -eq 0 ]]; then + rm -rf "${VERSIONS_DIR:?}/$ver" + (( removed += 1 )) + fi + done + + _update_latest 2>/dev/null || true + if [[ "$removed" -eq 0 ]]; then + echo "$(_dim " No unused versions to prune.")" + else + echo "$(_green_bold "Uninstalled") $removed unused version(s)" + fi +} + _claude_cmd_ls() { _update_latest 2>/dev/null || true if [[ ! -d "$VERSIONS_DIR" ]] || [[ -z "$(ls -A "$VERSIONS_DIR" 2>/dev/null)" ]]; then @@ -2957,6 +3011,8 @@ cmd_claude() { case "${1:-help}" in install) _claude_cmd_install "${@:2}" ;; uninstall) _claude_cmd_uninstall "${@:2}" ;; + update-all) _claude_cmd_update_all "${@:2}" ;; + prune) _claude_cmd_prune ;; ls|list) _claude_cmd_ls ;; pin) _claude_cmd_pin "${@:2}" ;; help|-h|--help) @@ -2964,6 +3020,8 @@ cmd_claude() { echo echo " $(_bold "install") [latest|] Install a Claude Code version" echo " $(_bold "uninstall") Remove an installed version" + echo " $(_bold "update-all") [latest|] Pin all environments to a version" + echo " $(_bold "prune") Remove installed versions not used by any environment" echo " $(_bold "ls") List installed versions" echo " $(_bold "pin") Pin current environment to a version" ;; @@ -3629,6 +3687,8 @@ cmd_help() { echo " $(_green "cac claude install") [latest|ver] Install Claude Code" echo " $(_green "cac claude ls") List installed versions" echo " $(_green "cac claude pin") Pin env to a version" + echo " $(_green "cac claude update-all") [latest|ver] Pin all envs to a version" + echo " $(_green "cac claude prune") Remove unused versions" echo " $(_green "cac claude uninstall") Remove a version" echo diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 3cbc292..93bfe7b 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,14 @@ description: Release history and notable changes All notable changes to this project are documented here. Each entry links to the corresponding PR or issue. +## Unreleased + +**Feature: bulk Claude Code version management** + +- Added `cac claude update-all [latest|]` to install a target version if needed and pin every environment to it. +- Added `cac claude prune` to remove installed Claude Code versions that are not used by any environment. +- Added a shell regression test covering bulk pinning and unused-version pruning. + ## v1.5.7 2026-04-27 diff --git a/docs/commands/claude.mdx b/docs/commands/claude.mdx index d81cba4..5bf4577 100644 --- a/docs/commands/claude.mdx +++ b/docs/commands/claude.mdx @@ -49,6 +49,27 @@ cac claude pin 2.1.81 This writes the version to `~/.cac/envs//version`. The wrapper resolves this file at launch time, so the change takes effect on the next `claude` invocation. +## update-all + +Install a target version if needed, then pin every environment to it. + +```bash +cac claude update-all latest +cac claude update-all 2.1.81 +``` + +Use this after installing a new Claude Code release when you want all isolated environments to launch the same binary. + +## prune + +Remove installed versions that are not pinned by any environment. + +```bash +cac claude prune +``` + +This is the bulk cleanup equivalent of running `cac claude uninstall ` for every unused version. + ## uninstall Remove an installed version. Fails if any environment is using it. diff --git a/docs/zh/changelog.mdx b/docs/zh/changelog.mdx index adacf0e..d4da608 100644 --- a/docs/zh/changelog.mdx +++ b/docs/zh/changelog.mdx @@ -5,6 +5,14 @@ description: 版本发布历史和重要变更 所有重要变更记录在此,每个条目关联对应的 PR 或 Issue。 +## Unreleased + +**功能:批量管理 Claude Code 版本** + +- 新增 `cac claude update-all [latest|]`,必要时先安装目标版本,然后将所有环境锁定到该版本。 +- 新增 `cac claude prune`,移除未被任何环境使用的已安装 Claude Code 版本。 +- 新增 shell 回归测试,覆盖批量锁定和未使用版本清理。 + ## v1.5.7 2026-04-27 diff --git a/docs/zh/commands/claude.mdx b/docs/zh/commands/claude.mdx index c2806c0..a2dfd7a 100644 --- a/docs/zh/commands/claude.mdx +++ b/docs/zh/commands/claude.mdx @@ -49,6 +49,27 @@ cac claude pin 2.1.81 这会将版本写入 `~/.cac/envs//version`。包装器在启动时解析此文件,因此更改会在下次 `claude` 调用时生效。 +## update-all + +必要时先安装目标版本,然后将所有环境锁定到该版本。 + +```bash +cac claude update-all latest +cac claude update-all 2.1.81 +``` + +适用于安装新版 Claude Code 后,希望所有隔离环境都启动同一个二进制版本的场景。 + +## prune + +移除未被任何环境锁定的已安装版本。 + +```bash +cac claude prune +``` + +这相当于对每个未使用版本批量运行 `cac claude uninstall `。 + ## uninstall 移除已安装的版本。如果有环境正在使用该版本,操作会失败。 diff --git a/scripts/test-claude-bulk-version-commands.sh b/scripts/test-claude-bulk-version-commands.sh new file mode 100755 index 0000000..ed20a73 --- /dev/null +++ b/scripts/test-claude-bulk-version-commands.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CAC="$ROOT/cac" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +export HOME="$TMP/home" +CAC_DIR="$HOME/.cac" +ENVS_DIR="$CAC_DIR/envs" +VERSIONS_DIR="$CAC_DIR/versions" + +make_version() { + local ver="$1" + mkdir -p "$VERSIONS_DIR/$ver" + printf '#!/usr/bin/env bash\nexit 0\n' > "$VERSIONS_DIR/$ver/claude" + chmod +x "$VERSIONS_DIR/$ver/claude" +} + +make_env() { + local name="$1" ver="$2" + mkdir -p "$ENVS_DIR/$name/.claude" + printf '%s\n' "$ver" > "$ENVS_DIR/$name/version" +} + +assert_eq() { + local expected="$1" actual="$2" label="$3" + if [[ "$expected" != "$actual" ]]; then + echo "FAIL: $label: expected '$expected', got '$actual'" >&2 + exit 1 + fi +} + +assert_exists() { + [[ -e "$1" ]] || { echo "FAIL: expected path to exist: $1" >&2; exit 1; } +} + +assert_missing() { + [[ ! -e "$1" ]] || { echo "FAIL: expected path to be removed: $1" >&2; exit 1; } +} + +strip_ansi() { + perl -pe 's/\e\[[0-9;]*[A-Za-z]//g' +} + +mkdir -p "$ENVS_DIR" "$VERSIONS_DIR" +make_version "2.1.100" +make_version "2.1.200" +make_version "2.1.300" +make_env "work" "2.1.100" +make_env "backup" "2.1.100" + +UPDATE_OUT="$TMP/cac-update-all.out" +PRUNE_OUT="$TMP/cac-prune.out" + +"$CAC" claude update-all 2.1.200 >"$UPDATE_OUT" +assert_eq "2.1.200" "$(tr -d '[:space:]' < "$ENVS_DIR/work/version")" "work version" +assert_eq "2.1.200" "$(tr -d '[:space:]' < "$ENVS_DIR/backup/version")" "backup version" +strip_ansi < "$UPDATE_OUT" | grep -q "Pinned 2 environment(s) -> Claude Code 2.1.200" || { + echo "FAIL: update-all output did not report pinned env count" >&2 + cat "$UPDATE_OUT" >&2 + exit 1 +} + +"$CAC" claude prune >"$PRUNE_OUT" +assert_missing "$VERSIONS_DIR/2.1.100" +assert_exists "$VERSIONS_DIR/2.1.200/claude" +assert_missing "$VERSIONS_DIR/2.1.300" +assert_eq "2.1.200" "$(tr -d '[:space:]' < "$VERSIONS_DIR/.latest")" "latest version" +strip_ansi < "$PRUNE_OUT" | grep -q "Uninstalled 2 unused version(s)" || { + echo "FAIL: prune output did not report removed version count" >&2 + cat "$PRUNE_OUT" >&2 + exit 1 +} + +echo "PASS: claude bulk version commands" diff --git a/src/cmd_claude.sh b/src/cmd_claude.sh index c02c5a3..2f48ad9 100644 --- a/src/cmd_claude.sh +++ b/src/cmd_claude.sh @@ -97,6 +97,60 @@ _claude_cmd_uninstall() { echo "$(_green_bold "Uninstalled") Claude Code $(_cyan "$ver")" } +_claude_cmd_update_all() { + local target="${1:-latest}" + local ver + if [[ "$target" == "latest" ]]; then + printf "Fetching latest version ... " + ver=$(_fetch_latest_version) || _die "failed to fetch latest version" + echo "$(_cyan "$ver")" + else + ver="$target" + fi + + [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]] || \ + _die "invalid version $(_cyan "'$ver'")" + + mkdir -p "$VERSIONS_DIR" + _download_version "$ver" + _update_latest + + local count=0 env_dir + for env_dir in "$ENVS_DIR"/*/; do + [[ -d "$env_dir" ]] || continue + echo "$ver" > "$env_dir/version" + (( count += 1 )) + done + + echo "$(_green_bold "Pinned") $count environment(s) -> Claude Code $(_cyan "$ver")" +} + +_claude_cmd_prune() { + _update_latest 2>/dev/null || true + if [[ ! -d "$VERSIONS_DIR" ]]; then + echo "$(_dim " No versions installed.")" + return + fi + + local removed=0 ver_dir ver count + for ver_dir in "$VERSIONS_DIR"/*/; do + [[ -d "$ver_dir" ]] || continue + ver=$(basename "$ver_dir") + count=$(_envs_using_version "$ver") + if [[ "$count" -eq 0 ]]; then + rm -rf "${VERSIONS_DIR:?}/$ver" + (( removed += 1 )) + fi + done + + _update_latest 2>/dev/null || true + if [[ "$removed" -eq 0 ]]; then + echo "$(_dim " No unused versions to prune.")" + else + echo "$(_green_bold "Uninstalled") $removed unused version(s)" + fi +} + _claude_cmd_ls() { _update_latest 2>/dev/null || true if [[ ! -d "$VERSIONS_DIR" ]] || [[ -z "$(ls -A "$VERSIONS_DIR" 2>/dev/null)" ]]; then @@ -139,6 +193,8 @@ cmd_claude() { case "${1:-help}" in install) _claude_cmd_install "${@:2}" ;; uninstall) _claude_cmd_uninstall "${@:2}" ;; + update-all) _claude_cmd_update_all "${@:2}" ;; + prune) _claude_cmd_prune ;; ls|list) _claude_cmd_ls ;; pin) _claude_cmd_pin "${@:2}" ;; help|-h|--help) @@ -146,6 +202,8 @@ cmd_claude() { echo echo " $(_bold "install") [latest|] Install a Claude Code version" echo " $(_bold "uninstall") Remove an installed version" + echo " $(_bold "update-all") [latest|] Pin all environments to a version" + echo " $(_bold "prune") Remove installed versions not used by any environment" echo " $(_bold "ls") List installed versions" echo " $(_bold "pin") Pin current environment to a version" ;; diff --git a/src/cmd_help.sh b/src/cmd_help.sh index 97bb6fc..2030aee 100644 --- a/src/cmd_help.sh +++ b/src/cmd_help.sh @@ -18,6 +18,8 @@ cmd_help() { echo " $(_green "cac claude install") [latest|ver] Install Claude Code" echo " $(_green "cac claude ls") List installed versions" echo " $(_green "cac claude pin") Pin env to a version" + echo " $(_green "cac claude update-all") [latest|ver] Pin all envs to a version" + echo " $(_green "cac claude prune") Remove unused versions" echo " $(_green "cac claude uninstall") Remove a version" echo