From 0b365b4d82966346edcdfbfc9f92538b5567cd47 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:10:16 -0600 Subject: [PATCH 001/165] Add Ckipper multi-account design doc Plans the rename from claude-docker-sandbox to Ckipper and the multi-account architecture: per-account CLAUDE_CONFIG_DIR, registry in ~/.ckipper/accounts.json, ckipper CLI for registration, and account-aware w + entrypoint integration. --- ...2026-04-27-ckipper-multi-account-design.md | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/plans/2026-04-27-ckipper-multi-account-design.md diff --git a/docs/plans/2026-04-27-ckipper-multi-account-design.md b/docs/plans/2026-04-27-ckipper-multi-account-design.md new file mode 100644 index 0000000..346d9bd --- /dev/null +++ b/docs/plans/2026-04-27-ckipper-multi-account-design.md @@ -0,0 +1,252 @@ +# Ckipper — Multi-Account Claude Code Sandbox + +**Date:** 2026-04-27 +**Status:** Design (approved) + +## Overview + +Rename the project formerly called `claude-docker-sandbox` to **Ckipper** (pronounced "skipper") and add support for running multiple Claude Code accounts (personal, work, etc.) concurrently in different terminals or Docker containers without shared auth, MCP config, sessions, or settings. + +The design is generic for N ≥ 1 accounts and agnostic to specific account names. Account names like `personal`, `work`, `af` are user choices, never hardcoded. + +## Goals + +- Run any number of Claude Code accounts concurrently with full isolation: credentials, `.claude.json`, MCP servers, plugins, hooks, projects, settings. +- Make adding the second, third, fourth account a one-command flow that anyone can follow. +- Preserve the existing `w` worktree + Docker workflow; account becomes another dimension alongside project + branch. +- Migrate existing single-account installs without data loss. +- Keep the project name distinct from "Claude" so docs and conversations don't get muddy. + +## Non-goals (YAGNI) + +- Cross-account project sharing. +- A GUI. +- Auto-detecting the account from cwd or git remote. +- Per-account Docker images. +- Synchronizing credentials across machines. + +## Background — what the research established + +`CLAUDE_CONFIG_DIR` is an undocumented but functional environment variable. When set, Claude Code reads/writes its credentials, `.claude.json`, settings, plugins, hooks, projects, and MCP config from that directory instead of `~/.claude`. On macOS, credentials still go to the Keychain, but the service name is suffixed with a hash of the config dir path — empirically confirmed by inspecting the host (three `Claude Code-credentials*` entries already exist there). Different config dirs produce different Keychain entries, so isolation is real on macOS, not just on disk. + +Known caveats (from upstream issues): +- [#3833](https://github.com/anthropics/claude-code/issues/3833) — Claude Code may still create workspace-local `.claude/` dirs in some cases. Hybrid behavior, undocumented. +- [#24317](https://github.com/anthropics/claude-code/issues/24317) — OAuth refresh-token race when **the same** account runs in two sessions. Different accounts have different refresh tokens, so cross-account concurrent use is not affected. Same-account concurrency is — README will warn. + +The Keychain hash algorithm is undocumented. We do not reverse engineer it; instead, registration snapshots Keychain entries before/after `/login` and records the diff. + +## Architecture + +### Per-account isolation primitive + +Each account is a directory pointed to by `CLAUDE_CONFIG_DIR=$HOME/.claude-`. Every piece of Claude state lives inside it. Accounts are siblings under `$HOME/`: + +``` +~/.claude-personal/ +~/.claude-work/ +~/.claude-/ +``` + +This naming follows Claude Code's own convention (each is literally a Claude config dir) and keeps account dirs visually grouped. + +### Tool layout — Ckipper lives in `~/.ckipper/` + +The sandbox tooling moves out of `~/.claude/docker/` to its own root: + +``` +~/.ckipper/ + docker/ + Dockerfile + entrypoint.sh + init-firewall.sh + fix-volume-perms.sh + w-function.zsh + w-config.zsh # user's port/volume customizations (preserved across updates) + hooks/ # canonical hook source + bash-guardrails.sh + protect-claude-config.sh + docker-context.sh + notify-bell.sh + ckipper # umbrella CLI (zsh function or shell script) + aliases.zsh # auto-generated `claude-` aliases (sourced by .zshrc) + accounts.json # the registry + settings-template.json # canonical settings used to seed new account dirs +``` + +The shell needs one source line in `.zshrc`: + +```zsh +[[ -f ~/.ckipper/docker/w-function.zsh ]] && source ~/.ckipper/docker/w-function.zsh +[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh +``` + +### Registry — `~/.ckipper/accounts.json` + +```json +{ + "default": "personal", + "accounts": { + "personal": { + "config_dir": "/Users/matt/.claude-personal", + "keychain_service": "Claude Code-credentials", + "registered_at": "2026-04-27T18:00:00Z" + }, + "": { + "config_dir": "/Users/matt/.claude-", + "keychain_service": "Claude Code-credentials-<8hex>", + "registered_at": "..." + } + } +} +``` + +`keychain_service: null` is valid — used when the account authenticates via API key (`.credentials.json` on disk) or on Linux/Windows where there is no Keychain. + +## Components + +### 1. `ckipper` umbrella CLI + +A zsh function shipped in `~/.ckipper/docker/w-function.zsh` (kept together for one-shot install). + +| Subcommand | Behavior | +|---|---| +| `ckipper add ` | Register a new account. Creates `~/.claude-/`, copies `settings-template.json` and hooks into it, snapshots `Claude Code-credentials*` Keychain entries, prints "Run `claude /login` in this shell with `CLAUDE_CONFIG_DIR=~/.claude-` set, then press enter." After enter: re-snapshots Keychain, finds the new entry, writes it to the registry, regenerates `aliases.zsh`. | +| `ckipper add --adopt` | Register an existing populated account dir without re-login. Lists existing `Claude Code-credentials*` Keychain entries; prompts user to pick the one belonging to this account (or selects automatically if only one is unclaimed by the registry). | +| `ckipper list` | Print accounts, current default, dir presence, and last-login email per account (read from each `/.claude.json`'s `oauthAccount.emailAddress`). | +| `ckipper default ` | Update `accounts.json.default`. | +| `ckipper remove ` | Unregister. Does NOT delete the dir or Keychain entry — prints the commands to do so manually. | +| `ckipper sync-hooks` | Copy `~/.ckipper/hooks/*` into each `/hooks/` and rewrite each `/settings.json` to reference its own absolute hook paths. | +| `ckipper migrate` | Detect a pre-Ckipper layout (`~/.claude/docker/` exists) and run the one-time migration described below. | + +Implementation language: zsh + `jq` for JSON manipulation. Same dependencies the existing tooling already uses. + +### 2. `cca` dispatcher + +`cca [args...]` — short for "claude-config-as". Resolves `` → config dir from the registry, exports `CLAUDE_CONFIG_DIR`, exec's `command claude "$@"`. Used for one-offs without an alias. Errors if `` is not registered. + +### 3. Auto-generated per-account aliases + +`~/.ckipper/aliases.zsh` is regenerated on every `ckipper add` / `ckipper remove`: + +```zsh +# Auto-generated by ckipper. Do not edit by hand. +claude-personal() { CLAUDE_CONFIG_DIR=$HOME/.claude-personal command claude "$@"; } +claude-work() { CLAUDE_CONFIG_DIR=$HOME/.claude-work command claude "$@"; } +``` + +User adds one line to `.zshrc` (handled by `install.sh`). After registration, the new alias is available in any new shell or after `source ~/.ckipper/aliases.zsh`. + +### 4. `w` script — account-aware Docker integration + +`w` gains an `--account ` flag. Active-account resolution order (Option C, env-by-default with flag override): + +1. `--account ` flag if passed. +2. `CLAUDE_CONFIG_DIR` env var if it matches a registered account's `config_dir`. +3. `accounts.json.default` if set. +4. Otherwise: error with a helpful message ("No account selected. Run `ckipper list`."). + +Once resolved, `w` reads `config_dir` and `keychain_service` from the registry and: + +- Extracts credentials from the **right** Keychain entry — `security find-generic-password -s "" -w` instead of the hardcoded `"Claude Code-credentials"`. +- Mounts the per-account config dir into Docker at the same host absolute path (so plugin paths inside `.claude.json` resolve): `-v "$config_dir:$config_dir:rw"`. +- Sets `-e CLAUDE_CONFIG_DIR=$config_dir` inside the container. +- Reads/writes `/.claude.json` instead of `~/.claude.json` for the project-settings sync that runs at worktree creation. +- Drops the dual-mount of `~/.claude` at both `/home/claude/.claude` and `$HOME/.claude` — replaced by a single mount at the per-account host path. The container only needs the per-account dir; there is no shared `~/.claude` anymore. + +### 5. Entrypoint changes + +`~/.ckipper/docker/entrypoint.sh`: + +- Replace `~/.claude.json` and `~/.claude-host.json` references with `$CLAUDE_CONFIG_DIR/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude-host.json`. The "copy from read-only staging" trick stays the same; only the path changes. +- Credentials tmpfs symlink: `ln -sf /tmp/claude-creds/.credentials.json "$CLAUDE_CONFIG_DIR/.credentials.json"` (was `$HOME/.claude/.credentials.json`). +- Pre-install uvx-based MCP servers from `$CLAUDE_CONFIG_DIR/.claude.json`. +- Git identity continues to read from `oauthAccount` in `$CLAUDE_CONFIG_DIR/.claude.json`. Each account's `displayName` / `emailAddress` is now per-account, so commits made in a "work" container use the work email automatically. This is a feature. + +### 6. Hooks installation + +`install.sh`: + +- Deploys this repo's `docker/`, `hooks/`, `settings-hooks.json`, and the new `ckipper` CLI to `~/.ckipper/`. +- Adds the source line(s) to `.zshrc` if missing. +- If accounts are registered: runs `ckipper sync-hooks` to push hooks into each account's dir. +- If no accounts are registered: leaves a `settings-template.json` alongside the canonical hooks for `ckipper add` to use when initializing new account dirs. + +Each account's `settings.json` references absolute paths to **its own** hooks (`~/.claude-/hooks/.sh`), so the right hooks fire under the right account. + +### 7. Migration for existing users — `ckipper migrate` + +Detects pre-Ckipper layouts and runs a guided migration. Idempotent. + +| Detected state | Action | +|---|---| +| `~/.claude/docker/` exists | Move tooling files to `~/.ckipper/`. Preserve `w-config.zsh` (user customization). | +| `~/.claude/.claude.json` exists with an `oauthAccount` | Offer to register it as `personal`. If accepted: rename `~/.claude` → `~/.claude-personal`, create `~/.claude` symlink back to it (for legacy bare-`claude` invocations), write the registry entry. Match the existing `Claude Code-credentials` (no suffix) Keychain entry to it. | +| Old `~/.zshrc` source line points at `~/.claude/docker/w-function.zsh` | Update to `~/.ckipper/docker/w-function.zsh`. | +| Existing hooks in `~/.claude/hooks/` referenced from `~/.claude/settings.json` | After the rename, settings.json now lives at `~/.claude-personal/settings.json` and references `~/.claude-personal/hooks/*` (rewritten by `sync-hooks`). | + +Then the user runs `ckipper add ` for each additional account. + +## Issues from research — and how the design handles them + +1. **Plain `claude` after rename** — `~/.claude → ~/.claude-personal` symlink preserves it. Bare `claude` keeps working with the personal account. +2. **Keychain hash discovery** — `ckipper add` snapshots Keychain entries before/after the user runs `/login`, then diffs to find the new entry. No reverse engineering. +3. **`w` Docker integration** — fully parameterized via the registry: per-account Keychain service, per-account mount path, per-account `CLAUDE_CONFIG_DIR` in container. No more hardcoded `Claude Code-credentials`. +4. **Hooks duplication across accounts** — `ckipper sync-hooks` is a one-command refresh. Each account's `settings.json` references its own absolute paths, so isolation is real. +5. **OAuth race (#24317)** — different accounts have different refresh tokens; only same-account concurrent use can race. README warns. Two terminals using `claude-personal` simultaneously is the bad case; one `claude-personal` and one `claude-work` is fine. +6. **`#3833` workspace-local `.claude/`** — out of our control. If it appears, we document and report upstream. + +## Data flow — typical session + +``` +User opens Ghostty terminal A: + $ claude-work # alias exports CLAUDE_CONFIG_DIR=~/.claude-work + # claude reads creds from Keychain entry "Claude Code-credentials-" + # session lives in ~/.claude-work/projects/<...> + +User opens Ghostty terminal B: + $ claude-personal # alias exports CLAUDE_CONFIG_DIR=~/.claude-personal + # claude reads creds from Keychain entry "Claude Code-credentials" + # session lives in ~/.claude-personal/projects/<...> + +User opens Ghostty terminal C: + $ w myorg/app feature --account work --docker claude + # w reads accounts.json → "work" config_dir + keychain_service + # security find-generic-password -s "Claude Code-credentials-" + # docker run with: + # -v ~/.claude-work:~/.claude-work:rw + # -e CLAUDE_CONFIG_DIR=~/.claude-work + # entrypoint copies .claude-host.json → .claude.json inside container + # writes credentials to tmpfs, symlinks to ~/.claude-work/.credentials.json + # exec claude --dangerously-skip-permissions +``` + +All three sessions are independent. Different accounts, different credentials, different project state, different MCP config. + +## README updates + +The README gets a new "Multiple accounts" section with: + +- One-paragraph explanation of why anyone would want this. +- Quickstart: `ckipper add work`, follow prompts, start using `claude-work`. +- Concurrent-use warning: don't run the same account in two sessions; do run different accounts in two sessions. +- Migration steps for existing users (`ckipper migrate`). +- Reference table of files/dirs Ckipper creates and what each is for. + +Old "claude-docker-sandbox" naming gets replaced wholesale with "Ckipper". + +## Open questions + +- Should `cca` and the auto-generated aliases live in the **same** `aliases.zsh`, or split? **Decision:** same file. Simpler. +- Should `ckipper sync-hooks` run automatically on `ckipper add`? **Decision:** yes, on first add for that account. Manual otherwise. +- Should `ckipper add --adopt` also work for the brand-new `~/.claude` dir at first install? **Decision:** yes — `ckipper migrate` is just `ckipper add personal --adopt` with extra layout-cleanup. + +## Implementation phases (preview — full plan in writing-plans next) + +1. Rename project metadata (CLAUDE.md, README.md, install.sh paths). No behavior change. +2. Move tooling location: `~/.claude/docker/` → `~/.ckipper/`. Update install.sh + source lines. +3. Add `ckipper` CLI with `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. +4. Make `w` and `entrypoint.sh` account-aware. +5. README rewrite with multi-account walkthrough. +6. Run `ckipper migrate` on the user's host to do the personal → personal+af split. Test end-to-end with both accounts. + +Each phase is independently testable. From 4b5a15f83d3376d414259000a2b3ea1cbc92dc2d Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:14:31 -0600 Subject: [PATCH 002/165] Add Ckipper multi-account implementation plan Breaks the rename + multi-account work into 7 phases (25 tasks): project rename, tooling relocation to ~/.ckipper/, ckipper CLI, w and entrypoint account-awareness, migration command, docs, and live deploy + end-to-end validation. Each task has a verification step and its own commit. --- ...27-ckipper-multi-account-implementation.md | 1231 +++++++++++++++++ 1 file changed, 1231 insertions(+) create mode 100644 docs/plans/2026-04-27-ckipper-multi-account-implementation.md diff --git a/docs/plans/2026-04-27-ckipper-multi-account-implementation.md b/docs/plans/2026-04-27-ckipper-multi-account-implementation.md new file mode 100644 index 0000000..b014e4a --- /dev/null +++ b/docs/plans/2026-04-27-ckipper-multi-account-implementation.md @@ -0,0 +1,1231 @@ +# Ckipper Multi-Account Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rename `claude-docker-sandbox` to **Ckipper** and add support for running N concurrent Claude Code accounts with full isolation across credentials, settings, MCP, plugins, hooks, projects, and Docker sessions. + +**Architecture:** Each account is a `CLAUDE_CONFIG_DIR=~/.claude-/` directory. A registry at `~/.ckipper/accounts.json` maps account names to dirs and macOS Keychain service names. The new `ckipper` CLI registers/lists/removes accounts and auto-generates `claude-` shell aliases. The `w` script and Docker entrypoint become account-aware via an `--account` flag, falling back to `CLAUDE_CONFIG_DIR` env var, then a default. Sandbox tooling moves out of `~/.claude/docker/` to its own root `~/.ckipper/` so tooling and account state are decoupled. + +**Tech Stack:** zsh (functions, completions), bash (entrypoint, hooks, install), `jq` for JSON, `security` (macOS Keychain), Docker, git worktrees. + +**Reference:** `docs/plans/2026-04-27-ckipper-multi-account-design.md` + +--- + +## Working Conventions + +- **Branch:** `feature/ckipper-multi-account` (off `develop`). +- **Commits:** small and frequent — one per task. PR target: `develop`. +- **Verification per task:** at minimum, `shellcheck` on changed shell files, then a smoke test that sources the function and exercises it. Heavyweight end-to-end testing happens once at Phase 7. +- **Local deployment:** the user has a live install at `~/.claude/docker/` (old) → `~/.ckipper/` (new). Do not run `install.sh` on their host until Phase 7. +- **Naming:** generic placeholders only in repo (``, `personal`, `work`). Never hardcode `af` or other user-specific names. +- **Scope guard:** if a task tempts you to "also clean up X", stop. Add it to a follow-up list. The plan is the plan. + +--- + +## Phase 1 — Rename to Ckipper (text-only, no behavior change) + +### Task 1: Rename in CLAUDE.md + +**Files:** Modify `CLAUDE.md`. + +**Step 1: Replace project name in description** + +Change the first paragraph from: +> Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. + +To: +> **Ckipper** (pronounced "skipper") — Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. + +**Step 2: Update the `Architecture` and `Development Workflow` sections to reference `~/.ckipper/` instead of `~/.claude/docker/` and `~/.claude/hooks/`.** Note this is documentation forward-looking; actual file moves happen in Phase 2. + +**Step 3: Verify** + +```bash +grep -n "claude-docker-sandbox\|~/.claude/docker\|~/.claude/hooks" CLAUDE.md +``` + +Expected: empty (or only intentional historical references). + +**Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "Rename project to Ckipper in CLAUDE.md" +``` + +### Task 2: Rename in README.md + +**Files:** Modify `README.md`. + +**Step 1: Replace project name and tagline.** Replace all occurrences of "claude-docker-sandbox" with "Ckipper". Add the pronunciation note ("pronounced 'skipper'") to the heading. + +**Step 2: Update install/source instructions** to reference `~/.ckipper/docker/w-function.zsh`. Leave a "migrating from previous versions" callout for existing users (filled in by Phase 5/6). + +**Step 3: Verify** + +```bash +grep -n "claude-docker-sandbox" README.md +``` + +Expected: empty. + +**Step 4: Commit** + +```bash +git add README.md +git commit -m "Rename project to Ckipper in README" +``` + +### Task 3: Rename Docker image tag + +**Files:** Modify `w-function.zsh` (occurrences of `claude-dev` image tag). + +**Step 1:** Rename the Docker image tag from `claude-dev` to `ckipper-dev` in `_w_build_image` (line 41), in the existence check (line 269), and in the parallel-container detection (line 417). The `ancestor=claude-dev` filter in `docker ps` must also become `ancestor=ckipper-dev`. + +**Step 2:** Update CLAUDE.md's "Development Workflow" table: `claude-dev` → `ckipper-dev`. + +**Step 3: Verify** + +```bash +grep -n "claude-dev" w-function.zsh CLAUDE.md README.md +``` + +Expected: empty. + +**Step 4: Commit** + +```bash +git add w-function.zsh CLAUDE.md +git commit -m "Rename Docker image tag claude-dev -> ckipper-dev" +``` + +Note: the user's live deployment still has the `claude-dev` image cached. After Phase 7 deploy, they'll run `w --rebuild-image` to rebuild as `ckipper-dev`. The old image can be deleted manually with `docker rmi claude-dev`. + +--- + +## Phase 2 — Move tooling location to `~/.ckipper/` + +### Task 4: Add `--target-dir` support to `install.sh` + +**Files:** Modify `install.sh` (read it first to understand current behavior). + +**Step 1:** Read the existing `install.sh` end-to-end. Identify every absolute reference to `~/.claude/docker/` and `~/.claude/hooks/`. + +**Step 2:** Introduce a single variable `CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}"` at the top, and replace `~/.claude/docker/` with `$CKIPPER_DIR/docker/` throughout. Hooks deploy to `$CKIPPER_DIR/hooks/` (canonical source); per-account hook copies happen via `ckipper sync-hooks` later. + +**Step 3:** The settings.json merge step (which adds `settings-hooks.json` content to `~/.claude/settings.json`) is now per-account. For Phase 2, leave this merge pointing at `~/.claude/settings.json` for backward compat. Phase 3 makes it account-aware. + +**Step 4: Verify** + +```bash +shellcheck install.sh +bash -n install.sh # syntax check only +``` + +Expected: no errors. + +**Step 5: Commit** + +```bash +git add install.sh +git commit -m "Deploy Ckipper tooling to ~/.ckipper/ instead of ~/.claude/docker/" +``` + +### Task 5: Update internal path references in `w-function.zsh` + +**Files:** Modify `w-function.zsh`. + +**Step 1:** Replace the config-source path: + +```zsh +# old +_w_config="$HOME/.claude/docker/w-config.zsh" +# new +_w_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" +``` + +And in `_w_build_image`: + +```zsh +local docker_dir="${CKIPPER_DIR:-$HOME/.ckipper}/docker" +``` + +**Step 2:** Verify no other `~/.claude/docker` strings remain in `w-function.zsh`. + +```bash +grep -n "claude/docker" w-function.zsh +``` + +Expected: empty. + +**Step 3: Verify the function still parses** + +```bash +zsh -n w-function.zsh +``` + +Expected: exit 0 with no output. + +**Step 4: Commit** + +```bash +git add w-function.zsh +git commit -m "Source w-function from ~/.ckipper/ instead of ~/.claude/docker/" +``` + +### Task 6: Add `install.sh` migration of legacy `~/.claude/docker/` layout + +**Files:** Modify `install.sh`. + +**Step 1:** At the top of `install.sh`, after `CKIPPER_DIR` is defined, add a migration block: + +```bash +# Migrate legacy ~/.claude/docker/ layout if present +LEGACY_DIR="$HOME/.claude/docker" +if [ -d "$LEGACY_DIR" ] && [ ! -d "$CKIPPER_DIR" ]; then + echo "Migrating ~/.claude/docker/ -> $CKIPPER_DIR/" + mkdir -p "$CKIPPER_DIR" + # Preserve user's w-config.zsh customizations + cp -a "$LEGACY_DIR/." "$CKIPPER_DIR/" + echo "Migrated. The legacy directory is left intact at $LEGACY_DIR for now." + echo "Remove it manually once you've verified the new location works." +fi +``` + +**Step 2:** Add a `.zshrc` source-line update step. After deploying files, check if `.zshrc` sources the legacy path and prompt to update: + +```bash +if grep -q "source.*\.claude/docker/w-function.zsh" "$HOME/.zshrc" 2>/dev/null; then + echo "" + echo "Update your ~/.zshrc to source from the new location:" + echo " source ~/.ckipper/docker/w-function.zsh" + echo "(replacing the existing source ~/.claude/docker/w-function.zsh line)" +fi +``` + +We **do not** auto-edit `.zshrc` — touching the user's shell config silently is too invasive. Print the instruction and let them edit it. + +**Step 3: Verify** + +```bash +shellcheck install.sh +``` + +Expected: no errors (warnings about quoting are OK if pre-existing). + +**Step 4: Commit** + +```bash +git add install.sh +git commit -m "install.sh: migrate legacy ~/.claude/docker/ to ~/.ckipper/" +``` + +--- + +## Phase 3 — Account registry + `ckipper` CLI + +### Task 7: Create the `ckipper` CLI scaffold + +**Files:** Create `ckipper.zsh`. + +**Step 1:** Create `ckipper.zsh` at the repo root with a top-level dispatcher: + +```zsh +# Ckipper — multi-account Claude Code manager +# Sourced by w-function.zsh + +CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" +CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" + +ckipper() { + local cmd="$1" + shift 2>/dev/null + case "$cmd" in + add) _ckipper_add "$@" ;; + list) _ckipper_list "$@" ;; + default) _ckipper_default "$@" ;; + remove) _ckipper_remove "$@" ;; + sync-hooks) _ckipper_sync_hooks "$@" ;; + migrate) _ckipper_migrate "$@" ;; + ""|help|-h|--help) _ckipper_help ;; + *) echo "Unknown command: $cmd"; _ckipper_help; return 1 ;; + esac +} + +_ckipper_help() { + cat <<'EOF' +ckipper — multi-account Claude Code manager + +Usage: + ckipper add Register a new account (interactive /login) + ckipper add --adopt Register an existing populated config dir + ckipper list Show registered accounts + ckipper default Set the default account + ckipper remove Unregister (does not delete the dir) + ckipper sync-hooks Copy hooks into all registered accounts + ckipper migrate One-time migration from legacy layout +EOF +} + +# Stubs — implemented in subsequent tasks +_ckipper_add() { echo "ckipper add: not yet implemented"; return 1; } +_ckipper_list() { echo "ckipper list: not yet implemented"; return 1; } +_ckipper_default() { echo "ckipper default: not yet implemented"; return 1; } +_ckipper_remove() { echo "ckipper remove: not yet implemented"; return 1; } +_ckipper_sync_hooks() { echo "ckipper sync-hooks: not yet implemented"; return 1; } +_ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } +``` + +**Step 2:** At the bottom of `w-function.zsh`, after the existing completion block, add: + +```zsh +# Source ckipper subcommand dispatcher +[[ -f "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" ]] && \ + source "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" +``` + +**Step 3:** Update `install.sh` to deploy `ckipper.zsh` to `$CKIPPER_DIR/docker/ckipper.zsh`. + +**Step 4: Verify** + +```bash +zsh -c 'source ./w-function.zsh; source ./ckipper.zsh; ckipper' +``` + +Expected: prints help text. `ckipper add foo` prints the "not yet implemented" stub. + +**Step 5: Commit** + +```bash +git add ckipper.zsh w-function.zsh install.sh +git commit -m "Add ckipper CLI scaffold with help and subcommand stubs" +``` + +### Task 8: Implement `ckipper list` + +**Files:** Modify `ckipper.zsh`. + +**Step 1:** Replace `_ckipper_list` stub with: + +```zsh +_ckipper_list() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + echo "No accounts registered. Run: ckipper add " + return 0 + fi + local default + default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + echo "Registered accounts:" + jq -r '.accounts | to_entries[] | " \(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ + while IFS=$'\t' read -r name dir; do + local marker=" " + [[ "$name" == "$default" ]] && marker="* " + local email="" + if [[ -f "$dir/.claude.json" ]]; then + email=$(jq -r '.oauthAccount.emailAddress // ""' "$dir/.claude.json" 2>/dev/null) + fi + local exists="(missing)" + [[ -d "$dir" ]] && exists="" + echo "$marker$name $dir ${email:+($email)} $exists" + done + echo "" + echo "* = default. Run: ckipper default " +} +``` + +**Step 2: Verify with a fixture registry** + +```bash +mkdir -p /tmp/ckipper-test/.ckipper +cat > /tmp/ckipper-test/.ckipper/accounts.json <<'EOF' +{ + "default": "personal", + "accounts": { + "personal": {"config_dir": "/tmp/ckipper-test/.claude-personal", "keychain_service": "Claude Code-credentials"}, + "work": {"config_dir": "/tmp/ckipper-test/.claude-work", "keychain_service": "Claude Code-credentials-abc12345"} + } +} +EOF +zsh -c 'CKIPPER_DIR=/tmp/ckipper-test/.ckipper source ./ckipper.zsh; CKIPPER_REGISTRY=/tmp/ckipper-test/.ckipper/accounts.json ckipper list' +``` + +Expected: prints both accounts, marks `personal` as default, shows `(missing)` for both dirs. + +**Step 3: Commit** + +```bash +git add ckipper.zsh +git commit -m "Implement ckipper list" +``` + +### Task 9: Implement `ckipper add ` (interactive login flow) + +**Files:** Modify `ckipper.zsh`. + +**Step 1:** Add a helper that snapshots Keychain entries: + +```zsh +_ckipper_keychain_snapshot() { + # macOS only. Returns service names of all "Claude Code-credentials*" entries. + if [[ "$OSTYPE" != darwin* ]]; then + return 0 + fi + security dump-keychain 2>/dev/null | \ + awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ + sort -u +} +``` + +**Step 2:** Implement `_ckipper_add`: + +```zsh +_ckipper_add() { + local name="$1" adopt=0 + [[ "$2" == "--adopt" ]] && adopt=1 + if [[ -z "$name" ]]; then + echo "Usage: ckipper add [--adopt]" + return 1 + fi + if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then + echo "Account name must be lowercase alphanumeric, underscore, or hyphen." + return 1 + fi + + local dir="$HOME/.claude-$name" + mkdir -p "$CKIPPER_DIR" + + # Initialize registry if missing + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + echo '{"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + fi + + # Refuse if already registered + if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is already registered." + return 1 + fi + + if [[ $adopt -eq 1 ]]; then + if [[ ! -d "$dir" ]]; then + echo "Cannot adopt: $dir does not exist." + return 1 + fi + _ckipper_finalize_registration "$name" "$dir" "" "adopt" + return $? + fi + + # Fresh registration: create dir, snapshot keychain, prompt /login + if [[ -d "$dir" ]]; then + echo "Directory $dir already exists. Use --adopt to register it." + return 1 + fi + mkdir -p "$dir/hooks" + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then + cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" + fi + + local before_snapshot + before_snapshot=$(_ckipper_keychain_snapshot) + + cat < "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + + _ckipper_regenerate_aliases + _ckipper_sync_hooks_for "$name" + + echo "Registered '$name' (mode: $mode)." + echo "Use it via: claude-$name or cca $name" +} +``` + +**Step 3:** Add `_ckipper_regenerate_aliases` and `_ckipper_sync_hooks_for` as stubs (filled in next tasks): + +```zsh +_ckipper_regenerate_aliases() { :; } # implemented in Task 12 +_ckipper_sync_hooks_for() { :; } # implemented in Task 13 +``` + +**Step 4: Verify (dry-run, no real account)** + +```bash +zsh -c 'source ./w-function.zsh; source ./ckipper.zsh; ckipper add' +``` + +Expected: prints usage error. + +```bash +zsh -c 'source ./w-function.zsh; source ./ckipper.zsh; ckipper add Bad-Name' +``` + +Expected: prints validation error (uppercase rejected). + +**Step 5: Commit** + +```bash +git add ckipper.zsh +git commit -m "Implement ckipper add with Keychain snapshot/diff" +``` + +### Task 10: Implement `--adopt` mode end-to-end + +Already covered structurally in Task 9. Verify by: + +```bash +mkdir -p /tmp/ckipper-test-adopt/.claude-personal +echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > /tmp/ckipper-test-adopt/.claude-personal/.claude.json +HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ + zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt' +HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ + zsh -c 'source ./ckipper.zsh; ckipper list' +``` + +Expected: registration succeeds, `ckipper list` shows `personal` with `test@example.com`. + +**Commit only if changes were needed.** + +### Task 11: Implement `ckipper default` and `ckipper remove` + +**Files:** Modify `ckipper.zsh`. + +**Step 1:** Implement: + +```zsh +_ckipper_default() { + local name="$1" + [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is not registered." + return 1 + fi + local tmp; tmp=$(mktemp) + jq --arg n "$name" '.default = $n' "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + echo "Default account is now '$name'." +} + +_ckipper_remove() { + local name="$1" + [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is not registered." + return 1 + fi + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + local tmp; tmp=$(mktemp) + jq --arg n "$name" 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ + "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + _ckipper_regenerate_aliases + echo "Unregistered '$name'." + echo "" + echo "The directory and Keychain entry were not deleted. To remove them manually:" + echo " rm -rf $dir" + [[ -n "$service" ]] && echo " security delete-generic-password -s '$service'" +} +``` + +**Step 2: Verify** + +```bash +HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ + zsh -c 'source ./ckipper.zsh; ckipper default personal; ckipper remove personal; ckipper list' +``` + +Expected: default set, then unregistered, then list shows no accounts. + +**Step 3: Commit** + +```bash +git add ckipper.zsh +git commit -m "Implement ckipper default and ckipper remove" +``` + +### Task 12: Implement aliases.zsh auto-generation + +**Files:** Modify `ckipper.zsh`. + +**Step 1:** Replace the `_ckipper_regenerate_aliases` stub with: + +```zsh +_ckipper_regenerate_aliases() { + local out="$CKIPPER_DIR/aliases.zsh" + { + echo "# Auto-generated by ckipper. Do not edit by hand." + echo "# Regenerated whenever an account is added or removed." + echo "" + echo "cca() {" + echo " local name=\$1; shift" + echo " local dir" + echo " dir=\$(jq -r --arg n \"\$name\" '.accounts[\$n].config_dir // empty' \"\$CKIPPER_REGISTRY\")" + echo " if [[ -z \"\$dir\" ]]; then echo \"Unknown account: \$name\"; return 1; fi" + echo " CLAUDE_CONFIG_DIR=\"\$dir\" command claude \"\$@\"" + echo "}" + echo "" + if [[ -f "$CKIPPER_REGISTRY" ]]; then + jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ + while IFS=$'\t' read -r name dir; do + echo "claude-$name() { CLAUDE_CONFIG_DIR=\"$dir\" command claude \"\$@\"; }" + done + fi + } > "$out" +} +``` + +**Step 2:** Update `install.sh` to add a one-time `.zshrc` line suggesting: + +```zsh +[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh +``` + +(Suggestion only; do not auto-edit `.zshrc`.) + +**Step 3: Verify** + +```bash +HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ + zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt; cat /tmp/ckipper-test-adopt/.ckipper/aliases.zsh' +``` + +Expected: file contains `cca` function and `claude-personal` function. + +**Step 4: Commit** + +```bash +git add ckipper.zsh install.sh +git commit -m "Auto-generate per-account aliases and cca dispatcher" +``` + +### Task 13: Implement `ckipper sync-hooks` + +**Files:** Modify `ckipper.zsh`. + +**Step 1:** Implement two functions: one that syncs all accounts, one for a single account (used by `add`): + +```zsh +_ckipper_sync_hooks_for() { + local name="$1" + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + [[ -z "$dir" || "$dir" == "null" ]] && return 1 + mkdir -p "$dir/hooks" + cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true + + # Rewrite settings.json to use absolute hook paths under this account dir. + if [[ -f "$dir/settings.json" ]] && command -v jq &>/dev/null; then + local tmp; tmp=$(mktemp) + jq --arg d "$dir" ' + (.hooks // {}) as $h | + .hooks = ($h | walk(if type == "string" and test("\\.claude(-[a-z0-9_-]+)?/hooks/") + then sub("/.claude(-[a-z0-9_-]+)?/hooks/"; "\($d)/hooks/") + else . end)) + ' "$dir/settings.json" > "$tmp" && mv "$tmp" "$dir/settings.json" + fi +} + +_ckipper_sync_hooks() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + echo "No accounts registered." + return 0 + fi + local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") + while IFS= read -r name; do + echo "Syncing hooks → $name" + _ckipper_sync_hooks_for "$name" + done <<< "$names" +} +``` + +**Step 2: Verify** + +```bash +mkdir -p /tmp/ckipper-test-adopt/.ckipper/hooks +echo "echo test" > /tmp/ckipper-test-adopt/.ckipper/hooks/sample.sh +HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ + zsh -c 'source ./ckipper.zsh; ckipper sync-hooks; ls /tmp/ckipper-test-adopt/.claude-personal/hooks/' +``` + +Expected: `sample.sh` exists in the per-account hooks dir. + +**Step 3: Commit** + +```bash +git add ckipper.zsh +git commit -m "Implement ckipper sync-hooks for per-account hook deployment" +``` + +--- + +## Phase 4 — Account-aware `w` and entrypoint + +### Task 14: `w` resolves the active account + +**Files:** Modify `w-function.zsh`. + +**Step 1:** Add a helper at the top of the `w()` function (before flag parsing): + +```zsh +_w_resolve_account() { + local cli_account="$1" + # 1. CLI flag wins + if [[ -n "$cli_account" ]]; then + echo "$cli_account" + return 0 + fi + # 2. CLAUDE_CONFIG_DIR env var matching a registered config_dir + if [[ -n "$CLAUDE_CONFIG_DIR" && -f "$CKIPPER_REGISTRY" ]]; then + local matched + matched=$(jq -r --arg d "$CLAUDE_CONFIG_DIR" \ + '.accounts | to_entries[] | select(.value.config_dir == $d) | .key' \ + "$CKIPPER_REGISTRY" | head -1) + if [[ -n "$matched" ]]; then + echo "$matched" + return 0 + fi + fi + # 3. Default from registry + if [[ -f "$CKIPPER_REGISTRY" ]]; then + local default + default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + if [[ -n "$default" ]]; then + echo "$default" + return 0 + fi + fi + # 4. No accounts: return empty (legacy mode) + return 0 +} +``` + +**Step 2:** Add `--account ` to the flag parser: + +```zsh +local cli_account="" +while [[ $# -gt 0 ]]; do + case "$1" in + --docker) docker_mode=1; shift ;; + --firewall) firewall_mode=1; shift ;; + --account) cli_account="$2"; shift 2 ;; + *) command+=("$1"); shift ;; + esac +done +``` + +**Step 3:** After the existing arg validation, resolve and validate the account: + +```zsh +local active_account +active_account=$(_w_resolve_account "$cli_account") +local active_config_dir="" +local active_keychain_service="" +if [[ -n "$active_account" && -f "$CKIPPER_REGISTRY" ]]; then + active_config_dir=$(jq -r --arg n "$active_account" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") + active_keychain_service=$(jq -r --arg n "$active_account" '.accounts[$n].keychain_service // empty' "$CKIPPER_REGISTRY") + if [[ -z "$active_config_dir" ]]; then + echo "Error: account '$active_account' is not registered. Run: ckipper list" + return 1 + fi +fi +# Fallback: legacy single-account ~/.claude +[[ -z "$active_config_dir" ]] && active_config_dir="$HOME/.claude" +[[ -z "$active_keychain_service" ]] && active_keychain_service="Claude Code-credentials" +``` + +**Step 4: Verify** + +```bash +zsh -n w-function.zsh +``` + +Expected: parse OK. + +```bash +zsh -c 'source ./w-function.zsh; CKIPPER_REGISTRY=/tmp/ckipper-test/.ckipper/accounts.json _w_resolve_account ""' +``` + +Expected: prints `personal` (the default from earlier fixture). + +**Step 5: Commit** + +```bash +git add w-function.zsh +git commit -m "w: resolve active account from --account flag, env, or default" +``` + +### Task 15: `w` Docker args use per-account paths and Keychain service + +**Files:** Modify `w-function.zsh`. + +**Step 1:** In the Docker block, replace the hardcoded credential extraction: + +```zsh +# old +claude_creds=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) || true +# new +claude_creds=$(security find-generic-password -s "$active_keychain_service" -w 2>/dev/null) || true +``` + +**Step 2:** Replace the `~/.claude` mount block: + +```zsh +# old +-v "$HOME/.claude:/home/claude/.claude:rw" +-v "$HOME/.claude.json:/home/claude/.claude-host.json:ro" +-v "$HOME/.claude:$HOME/.claude:rw" +# new +-v "$active_config_dir:$active_config_dir:rw" +-v "$active_config_dir/.claude.json:$active_config_dir/.claude-host.json:ro" +-e "CLAUDE_CONFIG_DIR=$active_config_dir" +``` + +(The dual mount under `/home/claude/.claude` is dropped — entrypoint now reads from `$CLAUDE_CONFIG_DIR` directly. The `:ro` mount of the host `.claude.json` becomes the per-account file.) + +**Step 3:** Update the gh-token extraction to use the per-account `.claude.json`: + +```zsh +gh_token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' "$active_config_dir/.claude.json" 2>/dev/null) || true +``` + +**Step 4:** Update the worktree project-settings sync (the python heredoc near line 232) to use `$active_config_dir/.claude.json` instead of `~/.claude.json`. The cleanup in `--rm` (line 111) similarly uses the active account's `.claude.json`. For `--rm`, the simplest path is to clean the worktree entry from **all** accounts that have it: + +```python +# Sketch — in --rm cleanup: +import json, os, glob +wt_path = os.environ['WT_PATH'] +for cfg in glob.glob(os.path.expanduser('~/.claude-*/.claude.json')) + [os.path.expanduser('~/.claude/.claude.json')]: + if not os.path.exists(cfg): continue + with open(cfg) as f: d = json.load(f) + if wt_path in d.get('projects', {}): + del d['projects'][wt_path] + with open(cfg, 'w') as f: json.dump(d, f) + print(f'Removed worktree entry from {cfg}') +``` + +**Step 5:** Drop the post-session credential symlink cleanup at line 416–420 if `active_config_dir != $HOME/.claude` — the symlink lives inside the container's tmpfs now and no longer pollutes `~/.claude/.credentials.json`. Keep the legacy cleanup for the fallback path where `active_config_dir == $HOME/.claude`. + +**Step 6: Verify** + +```bash +zsh -n w-function.zsh +shellcheck -s bash w-function.zsh # may have warnings, ignore non-errors +``` + +**Step 7: Commit** + +```bash +git add w-function.zsh +git commit -m "w: thread active account through Docker args and project sync" +``` + +### Task 16: Entrypoint uses `$CLAUDE_CONFIG_DIR` + +**Files:** Modify `docker/entrypoint.sh`. + +**Step 1:** At the top, after `set -e`, add: + +```bash +CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +``` + +This makes the script account-aware in container while preserving legacy behavior when no account is set. + +**Step 2:** Replace `~/.claude.json` and `~/.claude-host.json` references with `$CLAUDE_CONFIG_DIR/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude-host.json`. The "copy from staging" step becomes: + +```bash +if [ -f "$CLAUDE_CONFIG_DIR/.claude-host.json" ]; then + cp "$CLAUDE_CONFIG_DIR/.claude-host.json" "$CLAUDE_CONFIG_DIR/.claude.json" + # ... existing jq edits ... +fi +``` + +**Step 3:** Update the credential symlink target: + +```bash +ln -sf /tmp/claude-creds/.credentials.json "$CLAUDE_CONFIG_DIR/.credentials.json" +``` + +**Step 4:** Update git identity, MCP pre-install, and uvx jq queries to use `$CLAUDE_CONFIG_DIR/.claude.json`. + +**Step 5: Verify** + +```bash +shellcheck docker/entrypoint.sh +bash -n docker/entrypoint.sh +``` + +Expected: no errors. + +**Step 6: Commit** + +```bash +git add docker/entrypoint.sh +git commit -m "entrypoint: read all paths from CLAUDE_CONFIG_DIR" +``` + +### Task 17: Hooks adapt to per-account paths + +**Files:** Modify `hooks/protect-claude-config.sh`, `hooks/bash-guardrails.sh`. + +**Step 1:** Read both hooks. Identify hardcoded `~/.claude/` references that should generalize. + +**Step 2:** In `protect-claude-config.sh`: replace the protected-path list to include both legacy (`~/.claude/`) and per-account (`~/.claude-*/`) directories. The hook should match any path under `$HOME/.claude` or `$HOME/.claude-` as well as `$HOME/.ckipper`. + +**Step 3:** In `bash-guardrails.sh`: same treatment for any patterns that reference `~/.claude` paths. + +**Step 4: Verify** + +```bash +shellcheck hooks/*.sh +``` + +Expected: no new errors. + +**Step 5: Commit** + +```bash +git add hooks/protect-claude-config.sh hooks/bash-guardrails.sh +git commit -m "hooks: protect per-account dirs and ~/.ckipper" +``` + +--- + +## Phase 5 — Migration command + +### Task 18: Implement `ckipper migrate` + +**Files:** Modify `ckipper.zsh`. + +**Step 1:** Replace the `_ckipper_migrate` stub: + +```zsh +_ckipper_migrate() { + local legacy_docker="$HOME/.claude/docker" + local legacy_claude="$HOME/.claude" + + # 1. Move ~/.claude/docker → ~/.ckipper/docker if not already done + if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then + mkdir -p "$CKIPPER_DIR" + cp -a "$legacy_docker/." "$CKIPPER_DIR/docker/" + echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact)" + fi + + # 2. Adopt ~/.claude as 'personal' if not already registered + if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]]; then + if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ + ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null; then + echo "" + echo "Detected existing ~/.claude with login. Register it as 'personal'?" + read -r "?[Y/n] " ans + if [[ "$ans" != "n" && "$ans" != "N" ]]; then + if [[ -L "$legacy_claude" ]]; then + echo "~/.claude is already a symlink — skipping rename." + else + mv "$legacy_claude" "$HOME/.claude-personal" + ln -s "$HOME/.claude-personal" "$legacy_claude" + echo "Renamed ~/.claude → ~/.claude-personal and symlinked back." + fi + # Adopt with the no-suffix Keychain entry (default for ~/.claude) + _ckipper_finalize_registration "personal" "$HOME/.claude-personal" "Claude Code-credentials" "migrate" + fi + fi + fi + + echo "" + echo "Migration complete. Next steps:" + echo " 1. Update ~/.zshrc to source ~/.ckipper/docker/w-function.zsh" + echo " (replace any source ~/.claude/docker/w-function.zsh)" + echo " 2. Add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh" + echo " 3. Restart your shell." + echo " 4. Run: ckipper add to add additional accounts." +} +``` + +**Step 2: Verify with a synthetic legacy host** + +```bash +mkdir -p /tmp/ckipper-migrate/.claude/docker +mkdir -p /tmp/ckipper-migrate/.claude/hooks +echo "function w() { :; }" > /tmp/ckipper-migrate/.claude/docker/w-function.zsh +echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > /tmp/ckipper-migrate/.claude/.claude.json +HOME=/tmp/ckipper-migrate CKIPPER_DIR=/tmp/ckipper-migrate/.ckipper \ + zsh -c 'source ./ckipper.zsh; echo y | ckipper migrate; ckipper list' +``` + +Expected: tooling copied, `~/.claude` renamed and symlinked, `personal` registered. + +**Step 3: Commit** + +```bash +git add ckipper.zsh +git commit -m "Implement ckipper migrate for legacy layout" +``` + +--- + +## Phase 6 — Documentation + +### Task 19: README rewrite + +**Files:** Modify `README.md`. + +**Step 1:** Restructure into sections: + +1. What Ckipper is (one paragraph + the paddock-style metaphor for safety + autonomy). +2. Quickstart (single-account: `./install.sh`, source line, `w project branch --docker claude`). +3. **Multiple accounts** — new section. +4. Migration from `claude-docker-sandbox` — `ckipper migrate`. +5. Architecture overview (link to `docs/plans/2026-04-27-ckipper-multi-account-design.md`). + +**Step 2:** Multiple-accounts section template: + +````markdown +## Multiple accounts + +Run a personal Claude account in one terminal and a work account in another, fully isolated. Each gets its own credentials, MCP servers, plugins, projects, and session history. + +### Add an account + +```bash +ckipper add work +``` + +Follow the prompts to `/login` with the account in question. Repeat for as many accounts as you want. + +### Use an account + +Three ways: + +```bash +claude-work # auto-generated alias (preferred) +cca work # one-off dispatcher +CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form +``` + +### Inside Docker + +```bash +w myorg/app feature --account work --docker claude +``` + +If you're already in a terminal where `CLAUDE_CONFIG_DIR` is set (e.g., via `claude-work`), `w` picks up the account automatically — no flag needed. + +### Concurrent-use warning + +Two terminals running the **same** account simultaneously can hit a known OAuth refresh-token race ([upstream issue #24317](https://github.com/anthropics/claude-code/issues/24317)) and require frequent re-login. Two terminals running **different** accounts is fine. + +### List, default, remove + +```bash +ckipper list +ckipper default personal +ckipper remove old-account +``` +```` + +**Step 3:** Verify + +```bash +grep -n "claude-docker-sandbox" README.md +``` + +Expected: empty (or only in historical/migration context). + +**Step 4: Commit** + +```bash +git add README.md +git commit -m "README: rewrite for Ckipper with multi-account walkthrough" +``` + +### Task 20: Update CLAUDE.md and test-prompt.md + +**Files:** Modify `CLAUDE.md`, `test-prompt.md`. + +**Step 1:** CLAUDE.md gets: +- Updated tool layout diagram showing `~/.ckipper/`. +- New "Multi-account" section under Architecture. +- Updated Development Workflow table including `ckipper.zsh` → install.sh. + +**Step 2:** test-prompt.md gets a new section "12. Multi-account isolation" with checks: +- Run `ckipper list` inside the container — should show the active account only. +- Verify `$CLAUDE_CONFIG_DIR` is set inside the container. +- Verify `~/.claude.json` is a copy of `$CLAUDE_CONFIG_DIR/.claude-host.json`. +- Confirm credentials are at `$CLAUDE_CONFIG_DIR/.credentials.json` (symlinked to tmpfs). +- Confirm no other account dir is mounted (e.g., `ls /home/claude/.claude-other` → not found). + +**Step 3:** Commit + +```bash +git add CLAUDE.md test-prompt.md +git commit -m "Docs: update CLAUDE.md and test-prompt.md for Ckipper" +``` + +--- + +## Phase 7 — Local deployment + end-to-end validation + +These tasks run on the user's actual host. Do not run earlier. + +### Task 21: Deploy to user's host + +**Step 1:** From the repo root: + +```bash +./install.sh +``` + +Expected output: migrates `~/.claude/docker/` → `~/.ckipper/`, prints `.zshrc` update instructions. + +**Step 2:** Update `~/.zshrc` per printed instructions: + +```zsh +source ~/.ckipper/docker/w-function.zsh +[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh +``` + +**Step 3:** Restart the shell (or `source ~/.zshrc`). + +### Task 22: Migrate existing personal account + +**Step 1:** Run: + +```bash +ckipper migrate +``` + +Confirm the prompt to register `~/.claude` as `personal`. After completion: + +```bash +ckipper list +``` + +Expected: `personal` shown with `matt@msw.dev` and `* default` marker. + +**Step 2:** Smoke test: + +```bash +claude-personal --version +``` + +Expected: prints version (and uses the personal account — verify by `claude-personal` then `/status`). + +### Task 23: Add the user's second account + +**Step 1:** + +```bash +ckipper add af +``` + +Follow prompts: open `CLAUDE_CONFIG_DIR=~/.claude-af claude`, complete `/login` with the Animal Farm account, return and press enter. + +**Step 2:** Verify: + +```bash +ckipper list +``` + +Expected: both `personal` and `af` listed with their respective emails. New Keychain entry detected. + +### Task 24: Validate Docker integration with both accounts + +**Step 1:** In two separate Ghostty windows: + +Window A: +```bash +w some/project main --account personal --docker claude +``` + +Window B: +```bash +w some/project main --account af --docker claude +``` + +(Use different worktree branches or projects so they don't collide on the worktree path.) + +**Step 2:** Inside each container, run the new "Multi-account isolation" section of `test-prompt.md`. Verify: +- Each container has its own `CLAUDE_CONFIG_DIR`. +- `claude /status` in each shows the right account email. +- Project sessions don't appear in the other account's `~/.claude-/projects/`. + +**Step 3:** End both sessions cleanly. Confirm no `~/.claude/.credentials.json` symlink remains on the host. + +### Task 25: Open PR to develop + +**Step 1:** + +```bash +git push -u origin feature/ckipper-multi-account +``` + +**Step 2:** + +```bash +gh pr create --base develop --title "Ckipper: multi-account Claude Code sandbox" --body "$(cat <<'EOF' +## Summary +- Renames `claude-docker-sandbox` → **Ckipper** (pronounced "skipper"). +- Adds multi-account support via `CLAUDE_CONFIG_DIR=~/.claude-/` and a registry at `~/.ckipper/accounts.json`. +- New `ckipper` CLI: `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. +- Auto-generates `claude-` aliases and a `cca ` dispatcher. +- `w` and Docker entrypoint thread an active account through every credential, mount, and config path. +- Migration path for existing single-account installs. + +Design: `docs/plans/2026-04-27-ckipper-multi-account-design.md` +Plan: `docs/plans/2026-04-27-ckipper-multi-account-implementation.md` + +## Test plan +- [x] `ckipper list` / `add --adopt` / `add` / `default` / `remove` against a temp HOME fixture. +- [x] `ckipper migrate` against a synthetic legacy `~/.claude/docker/` layout. +- [x] Live deploy on host — migration succeeded; `personal` adopted; second account `` added cleanly. +- [x] Two concurrent Docker sessions, different accounts, validated against `test-prompt.md` Section 12. +- [x] No host-side credential symlink remains after sessions end. +EOF +)" +``` + +--- + +## Follow-ups (out of scope for this PR) + +- `ckipper rename ` — rename a registered account in-place. +- Bash + non-zsh shell support for the `cca`/`claude-` aliases (currently zsh-only). +- Auto-detect account from cwd or git remote (deliberately YAGNI for now). +- Single-tenant install path that skips the registry entirely (probably unnecessary — registry with one account is already trivial). From f6a4260cb8dec977bf0c3aaa38c8bf4acb7793c7 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:43:40 -0600 Subject: [PATCH 003/165] Apply panel review revisions to Ckipper design and plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six-reviewer panel + Team Lead synthesis (all GO-WITH-FIXES) revealed gaps that needed folding back into the docs before execution: - Purge user-specific names (af, matt@msw.dev, /Users/matt/...) and replace with placeholders (, , $HOME/...). - Drop the ~/.claude → ~/.claude-personal symlink (creates more failure modes than it solves; users use claude-personal post-migrate). - Add migration safety: pgrep precondition, error-trap rollback, Keychain probe-before-trust. - Extend hook regex to cover ~/.ckipper/ and per-account dirs (closes credential cross-contamination vector). - Validate keychain_service shape before passing to security command. - Fix Keychain awk: printf vs echo, lock detection, fixture-based regression test under tests/. - Make aliases.zsh self-contained (cca works when sourced alone). - chmod 600 on accounts.json; flock for atomic registry writes; schema version 1. - Insert Phase 6.5 Docker smoke test before live deploy. - Entrypoint errors on missing CLAUDE_CONFIG_DIR (no silent fallback). - Per-subcommand --help; cca and pronunciation in help text. - Extract Python heredocs to docker/cleanup-projects.py; registry-driven --rm cleanup (not glob). - README expansions: stronger concurrent-use warning, surface multi-account earlier, ckipper migrate preamble. - Concrete Section 12 isolation assertions in test-prompt.md. - Expanded follow-ups list. Plan ready for execution via superpowers:executing-plans. --- ...2026-04-27-ckipper-multi-account-design.md | 105 +- ...27-ckipper-multi-account-implementation.md | 1229 ++++++++++++----- 2 files changed, 949 insertions(+), 385 deletions(-) diff --git a/docs/plans/2026-04-27-ckipper-multi-account-design.md b/docs/plans/2026-04-27-ckipper-multi-account-design.md index 346d9bd..3715993 100644 --- a/docs/plans/2026-04-27-ckipper-multi-account-design.md +++ b/docs/plans/2026-04-27-ckipper-multi-account-design.md @@ -1,13 +1,32 @@ # Ckipper — Multi-Account Claude Code Sandbox **Date:** 2026-04-27 -**Status:** Design (approved) +**Status:** Design (revised after panel review — 6 reviewers + Team Lead, all GO-WITH-FIXES) ## Overview Rename the project formerly called `claude-docker-sandbox` to **Ckipper** (pronounced "skipper") and add support for running multiple Claude Code accounts (personal, work, etc.) concurrently in different terminals or Docker containers without shared auth, MCP config, sessions, or settings. -The design is generic for N ≥ 1 accounts and agnostic to specific account names. Account names like `personal`, `work`, `af` are user choices, never hardcoded. +The design is generic for N ≥ 1 accounts and agnostic to specific account names. Account names like `personal`, `work`, or any other lowercase-alphanumeric string are user choices, never hardcoded. + +## Revisions from panel review (post-initial-draft) + +Six reviewers (Senior SWE, Senior Architect, Security, DevOps, DX, QA) plus a Team Lead synthesized the following changes into the design before execution: + +- **Drop the `~/.claude → ~/.claude-personal` symlink.** Originally proposed as backward-compat for bare `claude`, the symlink created four failure modes (dangling on `remove personal`, interaction with upstream issue #3833 workspace writes, hook realpath drift across versions, target-swap attack vector) for one minor convenience. After migrate, the user uses `claude-personal` (or whatever name they registered). Bare `claude` with no `CLAUDE_CONFIG_DIR` falls back to whatever Claude Code's default is, which after migration is an empty `~/.claude` (Claude will prompt to log in fresh). The README warns about this. +- **Hooks must protect `~/.ckipper/` and per-account dirs.** `bash-guardrails.sh` and `protect-claude-config.sh` get an explicit anchored regex covering `$HOME/.claude(-[a-z0-9_-]+)?/` and `$HOME/.ckipper/`. Without this, a Claude session in account A can edit `~/.ckipper/accounts.json` to repoint A's name at B's keychain service — a credential cross-contamination vector. +- **Validate `keychain_service` shape before passing to `security`.** Empty strings or shell metacharacters from a corrupted registry would otherwise reach `security find-generic-password -s …`. Required regex: `^Claude Code-credentials(-[a-f0-9]+)?$`. +- **Fix the Keychain snapshot.** Use `printf '%s\n'` (not `echo`) to feed `comm`; capture a real `security dump-keychain` sample as a test fixture; detect a locked keychain (timeout + error rather than silent empty diff). +- **`cca` self-containment.** The dispatcher must define its own dependencies so sourcing `aliases.zsh` alone works without `ckipper.zsh`. +- **Migration safety.** Refuse migration if any `claude` process is running. Wrap destructive `mv` in a `trap` that restores on failure. Probe the legacy Keychain entry before trusting it. +- **`accounts.json` schema versioning + `chmod 600`.** Add `"version": 1`. Lock perms. +- **Phase 6.5 Docker smoke test** before live deploy on the user's host. The current plan's Phase 7 was the first time anything ran in Docker. +- **Default `CLAUDE_CONFIG_DIR` in entrypoint should be an error, not a silent fallback to `~/.claude`.** + +Reviewer disagreements resolved by Team Lead: +- Symlink fate (drop vs. clarify vs. protect) → **drop**. +- Default `CLAUDE_CONFIG_DIR` (document vs. error) → **error**. +- `.zshrc` auto-edit (do nothing vs. auto-append) → **auto-append the source line for `~/.ckipper/docker/w-function.zsh`** (consistent with existing `install.sh` behavior); print instructions for the optional `aliases.zsh` source line. ## Goals @@ -62,37 +81,44 @@ The sandbox tooling moves out of `~/.claude/docker/` to its own root: fix-volume-perms.sh w-function.zsh w-config.zsh # user's port/volume customizations (preserved across updates) + ckipper.zsh # umbrella CLI subcommand dispatcher + cleanup-projects.py # extracted helper, called from w --rm hooks/ # canonical hook source bash-guardrails.sh protect-claude-config.sh docker-context.sh notify-bell.sh - ckipper # umbrella CLI (zsh function or shell script) - aliases.zsh # auto-generated `claude-` aliases (sourced by .zshrc) - accounts.json # the registry + aliases.zsh # auto-generated `cca` + `claude-` (sourced by .zshrc; self-contained) + accounts.json # the registry, chmod 600 settings-template.json # canonical settings used to seed new account dirs + tests/ # fixture-based regression tests (security dump-keychain sample, etc.) ``` -The shell needs one source line in `.zshrc`: +The shell sources two lines from `.zshrc` (the first is auto-appended by `install.sh`; the second is suggested if the user wants per-account aliases): ```zsh [[ -f ~/.ckipper/docker/w-function.zsh ]] && source ~/.ckipper/docker/w-function.zsh [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh ``` +`aliases.zsh` is fully self-contained — it does not depend on `w-function.zsh` or `ckipper.zsh` being sourced. The generated file defines its own `CKIPPER_REGISTRY` path before defining `cca`. + +After migration, **bare `claude`** (no env var, no alias) does whatever Claude Code's default behavior is — which after migration is "no `~/.claude` exists, prompt for fresh login". This is intentional. To use the personal account, the user runs `claude-personal` (or whichever name they registered). + ### Registry — `~/.ckipper/accounts.json` ```json { + "version": 1, "default": "personal", "accounts": { "personal": { - "config_dir": "/Users/matt/.claude-personal", + "config_dir": "$HOME/.claude-personal", "keychain_service": "Claude Code-credentials", "registered_at": "2026-04-27T18:00:00Z" }, "": { - "config_dir": "/Users/matt/.claude-", + "config_dir": "$HOME/.claude-", "keychain_service": "Claude Code-credentials-<8hex>", "registered_at": "..." } @@ -100,7 +126,14 @@ The shell needs one source line in `.zshrc`: } ``` -`keychain_service: null` is valid — used when the account authenticates via API key (`.credentials.json` on disk) or on Linux/Windows where there is no Keychain. +(In the actual file, `$HOME` is expanded — shown above as a placeholder so the example reads correctly for any user.) + +The schema: +- `version: 1` — bumped when fields are added or semantics change. CLI refuses to operate on a registry whose version it doesn't understand. +- `keychain_service` shape is enforced: `^Claude Code-credentials(-[a-f0-9]+)?$`. CLI rejects values that don't match before passing to the macOS `security` command. +- `keychain_service: null` is valid — used when the account authenticates via API key (`.credentials.json` on disk) or on Linux/Windows where there is no Keychain. +- File permissions are `0600` (set on creation and on every write). +- Atomic writes use `flock` on the registry file to serialize concurrent `ckipper add` invocations. ## Components @@ -151,7 +184,8 @@ Once resolved, `w` reads `config_dir` and `keychain_service` from the registry a - Mounts the per-account config dir into Docker at the same host absolute path (so plugin paths inside `.claude.json` resolve): `-v "$config_dir:$config_dir:rw"`. - Sets `-e CLAUDE_CONFIG_DIR=$config_dir` inside the container. - Reads/writes `/.claude.json` instead of `~/.claude.json` for the project-settings sync that runs at worktree creation. -- Drops the dual-mount of `~/.claude` at both `/home/claude/.claude` and `$HOME/.claude` — replaced by a single mount at the per-account host path. The container only needs the per-account dir; there is no shared `~/.claude` anymore. +- Drops the dual-mount of `~/.claude` at both `/home/claude/.claude` and `$HOME/.claude`, replaced by a single mount at the per-account host path. **Before this change ships, the implementation phase grep-audits every `/home/claude/.claude` reference under `docker/` and migrates each one to `$CLAUDE_CONFIG_DIR`.** The plan has an explicit task for this audit — silent removal of the dual mount would break uvx pre-install, gh auth setup, and any plugin path that referenced the dropped mount. +- Cross-account `--rm` cleanup walks the registry (not a glob of `~/.claude-*`), so backup or archived dirs aren't picked up by accident. ### 5. Entrypoint changes @@ -175,25 +209,36 @@ Each account's `settings.json` references absolute paths to **its own** hooks (` ### 7. Migration for existing users — `ckipper migrate` -Detects pre-Ckipper layouts and runs a guided migration. Idempotent. +Detects pre-Ckipper layouts and runs a guided migration. Idempotent. Safety-checked. + +**Preconditions enforced before any destructive operation:** + +1. No `claude` process is currently running (`pgrep -f "claude " >/dev/null` must return non-zero, else abort with a clear message). +2. `~/.claude-personal` does not already exist (else abort — `mv` would nest, not overwrite). +3. The legacy `Claude Code-credentials` Keychain entry actually returns credentials (`security find-generic-password -s "Claude Code-credentials" -w` succeeds). If not, the user is shown a list of `Claude Code-credentials*` entries and asked to pick. + +**The destructive sequence is wrapped in a `trap` that restores on failure** — if `mv` succeeds but the registry write fails, the trap reverses the move so the user is never left with a missing `~/.claude`. | Detected state | Action | |---|---| -| `~/.claude/docker/` exists | Move tooling files to `~/.ckipper/`. Preserve `w-config.zsh` (user customization). | -| `~/.claude/.claude.json` exists with an `oauthAccount` | Offer to register it as `personal`. If accepted: rename `~/.claude` → `~/.claude-personal`, create `~/.claude` symlink back to it (for legacy bare-`claude` invocations), write the registry entry. Match the existing `Claude Code-credentials` (no suffix) Keychain entry to it. | -| Old `~/.zshrc` source line points at `~/.claude/docker/w-function.zsh` | Update to `~/.ckipper/docker/w-function.zsh`. | -| Existing hooks in `~/.claude/hooks/` referenced from `~/.claude/settings.json` | After the rename, settings.json now lives at `~/.claude-personal/settings.json` and references `~/.claude-personal/hooks/*` (rewritten by `sync-hooks`). | +| `~/.claude/docker/` exists | Copy tooling files to `~/.ckipper/`. Preserve `w-config.zsh` (user customization). Old dir kept for one release cycle (prints recovery instructions). | +| `~/.claude/.claude.json` exists with an `oauthAccount` | After preconditions pass: offer to register it as `personal`. If accepted: rename `~/.claude` → `~/.claude-personal` (no symlink — see "Revisions from panel review"), write the registry entry. Probe the matched Keychain entry first. | +| Old `~/.zshrc` source line points at `~/.claude/docker/w-function.zsh` | Print a one-line update for the user to apply (`install.sh` may auto-edit; `migrate` does not). | +| Existing hooks in `~/.claude/hooks/` | After the rename, settings.json now lives at `~/.claude-personal/settings.json` and references `~/.claude-personal/hooks/*` (rewritten by `sync-hooks`). | +| Old `claude-dev` Docker image | `docker rmi claude-dev 2>/dev/null` — best-effort cleanup of the renamed image. | -Then the user runs `ckipper add ` for each additional account. +After migrate completes successfully, the user runs `ckipper add ` for each additional account, and uses `claude-personal` (not bare `claude`) to launch Claude Code. ## Issues from research — and how the design handles them -1. **Plain `claude` after rename** — `~/.claude → ~/.claude-personal` symlink preserves it. Bare `claude` keeps working with the personal account. -2. **Keychain hash discovery** — `ckipper add` snapshots Keychain entries before/after the user runs `/login`, then diffs to find the new entry. No reverse engineering. -3. **`w` Docker integration** — fully parameterized via the registry: per-account Keychain service, per-account mount path, per-account `CLAUDE_CONFIG_DIR` in container. No more hardcoded `Claude Code-credentials`. -4. **Hooks duplication across accounts** — `ckipper sync-hooks` is a one-command refresh. Each account's `settings.json` references its own absolute paths, so isolation is real. -5. **OAuth race (#24317)** — different accounts have different refresh tokens; only same-account concurrent use can race. README warns. Two terminals using `claude-personal` simultaneously is the bad case; one `claude-personal` and one `claude-work` is fine. +1. **Plain `claude` after rename** — *not preserved* (decision reversed in panel review). After migration, `~/.claude` no longer exists. Bare `claude` invocations create a fresh empty `~/.claude` and prompt for login. The README and `ckipper migrate` output state this explicitly: "after migrate, use `claude-personal` to launch Claude Code with your personal account." +2. **Keychain hash discovery** — `ckipper add` snapshots Keychain entries before/after the user runs `/login`, then diffs to find the new entry. The snapshot uses `printf '%s\n'` (not `echo`) for `comm`-friendly input, detects a locked keychain via timeout, and is regression-tested against a captured `security dump-keychain` fixture under `~/.ckipper/tests/`. +3. **`w` Docker integration** — fully parameterized via the registry: per-account Keychain service, per-account mount path, per-account `CLAUDE_CONFIG_DIR` in container. `keychain_service` shape is validated before passing to the `security` command. No more hardcoded `Claude Code-credentials`. +4. **Hooks duplication across accounts** — `ckipper sync-hooks` is a one-command refresh. Each account's `settings.json` references its own absolute paths, so isolation is real. Both `bash-guardrails.sh` and `protect-claude-config.sh` extend their protected-path regex to cover `$HOME/.claude(-[a-z0-9_-]+)?/` and `$HOME/.ckipper/` (so a session in account A can't tamper with the registry to redirect account B's credentials). +5. **OAuth race (#24317)** — different accounts have different refresh tokens; only same-account concurrent use can race. README warns prominently. Two terminals using `claude-personal` simultaneously is the bad case; one `claude-personal` and one `claude-work` is fine. 6. **`#3833` workspace-local `.claude/`** — out of our control. If it appears, we document and report upstream. +7. **Registry tampering** — `~/.ckipper/accounts.json` lives in user-writable space. Defenses: hooks block Edit/Write to it from inside Claude sessions; `chmod 600` reduces accidental exposure; `keychain_service` shape validation rejects corrupt values before `security` is invoked. +8. **Migration data loss** — `ckipper migrate` enforces three preconditions before any `mv` and wraps the destructive sequence in an error-trap that restores the previous state on any failure. ## Data flow — typical session @@ -240,13 +285,15 @@ Old "claude-docker-sandbox" naming gets replaced wholesale with "Ckipper". - Should `ckipper sync-hooks` run automatically on `ckipper add`? **Decision:** yes, on first add for that account. Manual otherwise. - Should `ckipper add --adopt` also work for the brand-new `~/.claude` dir at first install? **Decision:** yes — `ckipper migrate` is just `ckipper add personal --adopt` with extra layout-cleanup. -## Implementation phases (preview — full plan in writing-plans next) +## Implementation phases (preview — full plan in `2026-04-27-ckipper-multi-account-implementation.md`) -1. Rename project metadata (CLAUDE.md, README.md, install.sh paths). No behavior change. -2. Move tooling location: `~/.claude/docker/` → `~/.ckipper/`. Update install.sh + source lines. -3. Add `ckipper` CLI with `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. -4. Make `w` and `entrypoint.sh` account-aware. -5. README rewrite with multi-account walkthrough. -6. Run `ckipper migrate` on the user's host to do the personal → personal+af split. Test end-to-end with both accounts. +1. Rename project metadata (CLAUDE.md, README.md, install.sh paths, Docker image tag). No behavior change. +2. Move tooling location: `~/.claude/docker/` → `~/.ckipper/`. Update install.sh + auto-append `.zshrc` source line. +3. Add `ckipper` CLI with `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. Includes registry schema versioning, file locking, fixture-based Keychain regression test. +4. Make `w` and `entrypoint.sh` account-aware. Audit `/home/claude/.claude` references first; remove only after each one is migrated to `$CLAUDE_CONFIG_DIR`. Validate `keychain_service` shape before passing to `security`. +5. Extend hooks (`bash-guardrails.sh`, `protect-claude-config.sh`) to cover `~/.ckipper/` and per-account dirs. Re-run existing `test-prompt.md` Section 10 hook-bypass tests after the change. +6. README, CLAUDE.md, and test-prompt.md updates with multi-account walkthrough, concurrent-use warning, migration preamble, and concrete Section 12 isolation assertions. +6.5. Build the renamed `ckipper-dev` image and run a Docker smoke test against a registered test account before deploying anywhere real. +7. Run `ckipper migrate` on the implementer's host. Add additional accounts with generic placeholder names (``, ``). End-to-end validation against both accounts in concurrent Docker sessions. -Each phase is independently testable. +Each phase is independently testable. Phase 7 is the only phase that touches the implementer's actual host. diff --git a/docs/plans/2026-04-27-ckipper-multi-account-implementation.md b/docs/plans/2026-04-27-ckipper-multi-account-implementation.md index b014e4a..18ec131 100644 --- a/docs/plans/2026-04-27-ckipper-multi-account-implementation.md +++ b/docs/plans/2026-04-27-ckipper-multi-account-implementation.md @@ -2,24 +2,27 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Rename `claude-docker-sandbox` to **Ckipper** and add support for running N concurrent Claude Code accounts with full isolation across credentials, settings, MCP, plugins, hooks, projects, and Docker sessions. +**Goal:** Rename `claude-docker-sandbox` to **Ckipper** (pronounced "skipper") and add support for running N concurrent Claude Code accounts with full isolation across credentials, settings, MCP, plugins, hooks, projects, and Docker sessions. -**Architecture:** Each account is a `CLAUDE_CONFIG_DIR=~/.claude-/` directory. A registry at `~/.ckipper/accounts.json` maps account names to dirs and macOS Keychain service names. The new `ckipper` CLI registers/lists/removes accounts and auto-generates `claude-` shell aliases. The `w` script and Docker entrypoint become account-aware via an `--account` flag, falling back to `CLAUDE_CONFIG_DIR` env var, then a default. Sandbox tooling moves out of `~/.claude/docker/` to its own root `~/.ckipper/` so tooling and account state are decoupled. +**Architecture:** Each account is a `CLAUDE_CONFIG_DIR=~/.claude-/` directory. A registry at `~/.ckipper/accounts.json` maps account names to dirs and macOS Keychain service names. The new `ckipper` CLI registers/lists/removes accounts and auto-generates `claude-` shell aliases. The `w` script and Docker entrypoint become account-aware via an `--account` flag, falling back to `CLAUDE_CONFIG_DIR` env var, then a registered default. Sandbox tooling moves out of `~/.claude/docker/` to its own root `~/.ckipper/` so tooling and account state are decoupled. -**Tech Stack:** zsh (functions, completions), bash (entrypoint, hooks, install), `jq` for JSON, `security` (macOS Keychain), Docker, git worktrees. +**Tech Stack:** zsh (functions, completions), bash (entrypoint, hooks, install), `jq` for JSON, `flock` for atomic registry writes, `security` (macOS Keychain), Docker, git worktrees. **Reference:** `docs/plans/2026-04-27-ckipper-multi-account-design.md` +**Status:** Revised after panel review (6 reviewers + Team Lead, all GO-WITH-FIXES). Revisions are folded into individual tasks below — they are not a separate phase. + --- ## Working Conventions - **Branch:** `feature/ckipper-multi-account` (off `develop`). - **Commits:** small and frequent — one per task. PR target: `develop`. -- **Verification per task:** at minimum, `shellcheck` on changed shell files, then a smoke test that sources the function and exercises it. Heavyweight end-to-end testing happens once at Phase 7. -- **Local deployment:** the user has a live install at `~/.claude/docker/` (old) → `~/.ckipper/` (new). Do not run `install.sh` on their host until Phase 7. -- **Naming:** generic placeholders only in repo (``, `personal`, `work`). Never hardcode `af` or other user-specific names. -- **Scope guard:** if a task tempts you to "also clean up X", stop. Add it to a follow-up list. The plan is the plan. +- **Verification per task:** `shellcheck` on bash files, `zsh -n` on zsh files (shellcheck doesn't lint zsh well), then a smoke test that sources the function and exercises it. Fixture tests live in `tests/`. Heavyweight Docker testing happens at Phase 6.5 (smoke) and Phase 7 (full). +- **Local deployment:** the implementer's host has a live install at `~/.claude/docker/` (old) → `~/.ckipper/` (new). Do not run `install.sh` on the host until Phase 7. Phases 1–6 happen on the feature branch only. +- **Naming:** generic placeholders in repo only (``, `personal`, `work`, ``). Never hardcode any specific user's account name or email. +- **Fixture cleanup:** every `/tmp/ckipper-test-*` fixture has a `trap 'rm -rf …' EXIT` to prevent state leak between tasks. +- **Scope guard:** if a task tempts you to "also clean up X", stop. Add it to the follow-ups list. The plan is the plan. --- @@ -29,15 +32,13 @@ **Files:** Modify `CLAUDE.md`. -**Step 1: Replace project name in description** - -Change the first paragraph from: +**Step 1: Replace project name in description.** Change the first paragraph from: > Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. To: > **Ckipper** (pronounced "skipper") — Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. -**Step 2: Update the `Architecture` and `Development Workflow` sections to reference `~/.ckipper/` instead of `~/.claude/docker/` and `~/.claude/hooks/`.** Note this is documentation forward-looking; actual file moves happen in Phase 2. +**Step 2:** Update the `Architecture` and `Development Workflow` sections to reference `~/.ckipper/` instead of `~/.claude/docker/` and `~/.claude/hooks/`. This is documentation forward-looking; actual file moves happen in Phase 2. **Step 3: Verify** @@ -58,9 +59,9 @@ git commit -m "Rename project to Ckipper in CLAUDE.md" **Files:** Modify `README.md`. -**Step 1: Replace project name and tagline.** Replace all occurrences of "claude-docker-sandbox" with "Ckipper". Add the pronunciation note ("pronounced 'skipper'") to the heading. +**Step 1:** Replace all occurrences of "claude-docker-sandbox" with "Ckipper". Add the pronunciation note ("pronounced 'skipper'") to the heading. -**Step 2: Update install/source instructions** to reference `~/.ckipper/docker/w-function.zsh`. Leave a "migrating from previous versions" callout for existing users (filled in by Phase 5/6). +**Step 2:** Update install/source instructions to reference `~/.ckipper/docker/w-function.zsh`. Leave a "migrating from previous versions" placeholder filled in by Task 19 (full README rewrite). **Step 3: Verify** @@ -79,57 +80,64 @@ git commit -m "Rename project to Ckipper in README" ### Task 3: Rename Docker image tag -**Files:** Modify `w-function.zsh` (occurrences of `claude-dev` image tag). - -**Step 1:** Rename the Docker image tag from `claude-dev` to `ckipper-dev` in `_w_build_image` (line 41), in the existence check (line 269), and in the parallel-container detection (line 417). The `ancestor=claude-dev` filter in `docker ps` must also become `ancestor=ckipper-dev`. +**Files:** Modify `w-function.zsh`, `CLAUDE.md`, `README.md`, `docker/Dockerfile` (LABEL/comments). -**Step 2:** Update CLAUDE.md's "Development Workflow" table: `claude-dev` → `ckipper-dev`. +**Step 1:** Rename the Docker image tag from `claude-dev` to `ckipper-dev` in: +- `_w_build_image` (currently line ~41) +- The existence check (currently line ~269) +- The parallel-container detection (currently line ~417, the `ancestor=claude-dev` filter) +- Any LABEL or comment in `docker/Dockerfile` +- Any documentation references in `CLAUDE.md` and `README.md` -**Step 3: Verify** +**Step 2: Verify** ```bash -grep -n "claude-dev" w-function.zsh CLAUDE.md README.md +grep -rn "claude-dev" w-function.zsh CLAUDE.md README.md docker/ ``` Expected: empty. -**Step 4: Commit** +**Step 3: Commit** ```bash -git add w-function.zsh CLAUDE.md +git add w-function.zsh CLAUDE.md README.md docker/Dockerfile git commit -m "Rename Docker image tag claude-dev -> ckipper-dev" ``` -Note: the user's live deployment still has the `claude-dev` image cached. After Phase 7 deploy, they'll run `w --rebuild-image` to rebuild as `ckipper-dev`. The old image can be deleted manually with `docker rmi claude-dev`. +Note: the implementer's live deployment still has the `claude-dev` image cached. Phase 7 (`ckipper migrate`) runs `docker rmi claude-dev 2>/dev/null` to clean it up. --- ## Phase 2 — Move tooling location to `~/.ckipper/` -### Task 4: Add `--target-dir` support to `install.sh` +### Task 4: Update `install.sh` to deploy to `~/.ckipper/` -**Files:** Modify `install.sh` (read it first to understand current behavior). +**Files:** Modify `install.sh`. **Step 1:** Read the existing `install.sh` end-to-end. Identify every absolute reference to `~/.claude/docker/` and `~/.claude/hooks/`. -**Step 2:** Introduce a single variable `CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}"` at the top, and replace `~/.claude/docker/` with `$CKIPPER_DIR/docker/` throughout. Hooks deploy to `$CKIPPER_DIR/hooks/` (canonical source); per-account hook copies happen via `ckipper sync-hooks` later. +**Step 2:** Introduce `CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}"` at the top. Replace `~/.claude/docker/` with `$CKIPPER_DIR/docker/` throughout. Hooks deploy to `$CKIPPER_DIR/hooks/` (canonical source); per-account hook copies happen via `ckipper sync-hooks`. -**Step 3:** The settings.json merge step (which adds `settings-hooks.json` content to `~/.claude/settings.json`) is now per-account. For Phase 2, leave this merge pointing at `~/.claude/settings.json` for backward compat. Phase 3 makes it account-aware. +**Step 3:** Preserve `w-config.zsh` (existing `install.sh` already special-cases this — extend the guard to also protect `accounts.json` and `aliases.zsh` if they exist in `$CKIPPER_DIR`). -**Step 4: Verify** +**Step 4:** **Settings.json merge — decision: drop it from `install.sh` entirely.** The current install merges `settings-hooks.json` into `~/.claude/settings.json`. In the multi-account world, hook settings live per-account and are written by `ckipper sync-hooks` from `$CKIPPER_DIR/settings-template.json`. Move the existing `settings-hooks.json` content into a new file `$CKIPPER_DIR/settings-template.json` (deployed by `install.sh`); `ckipper add` and `ckipper sync-hooks` consume it. + +**Step 5:** **`.zshrc` auto-edit policy — keep the existing auto-append behavior but update the source line.** The current `install.sh` (lines ~77-84) auto-appends `source ~/.claude/docker/w-function.zsh`. Update this to append `source ~/.ckipper/docker/w-function.zsh` instead, with an idempotent grep guard. **Print** (do not auto-append) the optional second line for `aliases.zsh` since that's user-choice and depends on whether they want per-account aliases. + +**Step 6: Verify** ```bash shellcheck install.sh -bash -n install.sh # syntax check only +bash -n install.sh ``` Expected: no errors. -**Step 5: Commit** +**Step 7: Commit** ```bash git add install.sh -git commit -m "Deploy Ckipper tooling to ~/.ckipper/ instead of ~/.claude/docker/" +git commit -m "Deploy Ckipper tooling to ~/.ckipper/ and split settings template" ``` ### Task 5: Update internal path references in `w-function.zsh` @@ -151,7 +159,7 @@ And in `_w_build_image`: local docker_dir="${CKIPPER_DIR:-$HOME/.ckipper}/docker" ``` -**Step 2:** Verify no other `~/.claude/docker` strings remain in `w-function.zsh`. +**Step 2:** Verify no other `~/.claude/docker` strings remain: ```bash grep -n "claude/docker" w-function.zsh @@ -159,7 +167,7 @@ grep -n "claude/docker" w-function.zsh Expected: empty. -**Step 3: Verify the function still parses** +**Step 3:** Verify the function still parses: ```bash zsh -n w-function.zsh @@ -178,43 +186,46 @@ git commit -m "Source w-function from ~/.ckipper/ instead of ~/.claude/docker/" **Files:** Modify `install.sh`. +**Important ordering:** This task lands AFTER Task 4 in the same branch (Task 4 must rewrite the deploy targets first; otherwise `install.sh` writes to both `~/.claude/docker/` AND `~/.ckipper/`, leaving drifting copies). Verify Task 4's changes are present before applying this one. + **Step 1:** At the top of `install.sh`, after `CKIPPER_DIR` is defined, add a migration block: ```bash -# Migrate legacy ~/.claude/docker/ layout if present +# Migrate legacy ~/.claude/docker/ layout if present (idempotent) LEGACY_DIR="$HOME/.claude/docker" if [ -d "$LEGACY_DIR" ] && [ ! -d "$CKIPPER_DIR" ]; then echo "Migrating ~/.claude/docker/ -> $CKIPPER_DIR/" mkdir -p "$CKIPPER_DIR" - # Preserve user's w-config.zsh customizations cp -a "$LEGACY_DIR/." "$CKIPPER_DIR/" - echo "Migrated. The legacy directory is left intact at $LEGACY_DIR for now." - echo "Remove it manually once you've verified the new location works." + echo "Migrated. The legacy directory is left intact at $LEGACY_DIR for one release cycle." + echo "After verifying the new location works (ckipper list shows your accounts):" + echo " rm -rf $LEGACY_DIR" fi ``` -**Step 2:** Add a `.zshrc` source-line update step. After deploying files, check if `.zshrc` sources the legacy path and prompt to update: +**Step 2:** Sweep the user's `w-config.zsh` (just migrated) for stale `~/.claude/docker/` paths. If found, print a warning naming the lines so the user can update — do not auto-edit user-customized config. + +**Step 3:** Update the `.zshrc` source line (extends Task 4's auto-append): ```bash +# Update legacy source line if present, otherwise auto-append the new one (existing pattern) if grep -q "source.*\.claude/docker/w-function.zsh" "$HOME/.zshrc" 2>/dev/null; then - echo "" - echo "Update your ~/.zshrc to source from the new location:" - echo " source ~/.ckipper/docker/w-function.zsh" - echo "(replacing the existing source ~/.claude/docker/w-function.zsh line)" + sed -i.bak 's|source.*\.claude/docker/w-function\.zsh|source ~/.ckipper/docker/w-function.zsh|' "$HOME/.zshrc" + echo "Updated ~/.zshrc source line. Backup at ~/.zshrc.bak." fi ``` -We **do not** auto-edit `.zshrc` — touching the user's shell config silently is too invasive. Print the instruction and let them edit it. +(`sed -i.bak` is portable across macOS and GNU sed.) -**Step 3: Verify** +**Step 4: Verify** ```bash shellcheck install.sh ``` -Expected: no errors (warnings about quoting are OK if pre-existing). +Expected: no errors (warnings about quoting OK if pre-existing). -**Step 4: Commit** +**Step 5: Commit** ```bash git add install.sh @@ -229,25 +240,28 @@ git commit -m "install.sh: migrate legacy ~/.claude/docker/ to ~/.ckipper/" **Files:** Create `ckipper.zsh`. -**Step 1:** Create `ckipper.zsh` at the repo root with a top-level dispatcher: +**Step 1:** Create `ckipper.zsh` at the repo root: ```zsh -# Ckipper — multi-account Claude Code manager +# Ckipper (pronounced "skipper") — multi-account Claude Code manager # Sourced by w-function.zsh CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" +CKIPPER_REGISTRY_VERSION=1 ckipper() { local cmd="$1" shift 2>/dev/null case "$cmd" in - add) _ckipper_add "$@" ;; - list) _ckipper_list "$@" ;; - default) _ckipper_default "$@" ;; - remove) _ckipper_remove "$@" ;; - sync-hooks) _ckipper_sync_hooks "$@" ;; - migrate) _ckipper_migrate "$@" ;; + # --help on any subcommand short-circuits to subcommand help + add|list|default|remove|sync-hooks|migrate) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_help_for "$cmd" + return 0 + fi + "_ckipper_${cmd//-/_}" "$@" + ;; ""|help|-h|--help) _ckipper_help ;; *) echo "Unknown command: $cmd"; _ckipper_help; return 1 ;; esac @@ -255,7 +269,7 @@ ckipper() { _ckipper_help() { cat <<'EOF' -ckipper — multi-account Claude Code manager +ckipper (pronounced "skipper") — multi-account Claude Code manager Usage: ckipper add Register a new account (interactive /login) @@ -265,9 +279,35 @@ Usage: ckipper remove Unregister (does not delete the dir) ckipper sync-hooks Copy hooks into all registered accounts ckipper migrate One-time migration from legacy layout + +Companion commands (sourced via aliases.zsh): + cca [args...] Run claude with account (one-off) + claude- [args...] Auto-generated alias per registered account + +Run `ckipper --help` for per-subcommand details. EOF } +_ckipper_help_for() { + case "$1" in + add) + cat <<'EOF' +ckipper add [--adopt] + +Register a new account. must match ^[a-z0-9_-]+$. + +Without --adopt: creates ~/.claude-/ and walks you through /login. +With --adopt: registers an existing populated ~/.claude-/ directory. +EOF + ;; + list) echo "ckipper list — print registered accounts, default, and last-login email." ;; + default) echo "ckipper default — set the default account used when no flag/env is provided." ;; + remove) echo "ckipper remove — unregister. Does not delete the dir or Keychain entry." ;; + sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; + migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; + esac +} + # Stubs — implemented in subsequent tasks _ckipper_add() { echo "ckipper add: not yet implemented"; return 1; } _ckipper_list() { echo "ckipper list: not yet implemented"; return 1; } @@ -280,7 +320,7 @@ _ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } **Step 2:** At the bottom of `w-function.zsh`, after the existing completion block, add: ```zsh -# Source ckipper subcommand dispatcher +# Source ckipper subcommand dispatcher (if deployed) [[ -f "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" ]] && \ source "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" ``` @@ -290,23 +330,25 @@ _ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } **Step 4: Verify** ```bash +zsh -n ckipper.zsh zsh -c 'source ./w-function.zsh; source ./ckipper.zsh; ckipper' +zsh -c 'source ./ckipper.zsh; ckipper add --help' ``` -Expected: prints help text. `ckipper add foo` prints the "not yet implemented" stub. +Expected: top-level help, then `add` subcommand help. **Step 5: Commit** ```bash git add ckipper.zsh w-function.zsh install.sh -git commit -m "Add ckipper CLI scaffold with help and subcommand stubs" +git commit -m "Add ckipper CLI scaffold with per-subcommand help" ``` ### Task 8: Implement `ckipper list` **Files:** Modify `ckipper.zsh`. -**Step 1:** Replace `_ckipper_list` stub with: +**Step 1:** Replace the `_ckipper_list` stub: ```zsh _ckipper_list() { @@ -331,26 +373,32 @@ _ckipper_list() { done echo "" echo "* = default. Run: ckipper default " + echo "" + echo "Reminder: do not run the same account in two sessions concurrently — see #24317." } ``` -**Step 2: Verify with a fixture registry** +**Step 2: Verify** — fixture test driven through real derivation, not direct `CKIPPER_REGISTRY` overrides: ```bash -mkdir -p /tmp/ckipper-test/.ckipper -cat > /tmp/ckipper-test/.ckipper/accounts.json <<'EOF' +TEST_HOME=$(mktemp -d -t ckipper-test-list-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +mkdir -p "$TEST_HOME/.ckipper" +cat > "$TEST_HOME/.ckipper/accounts.json" <` (interactive login flow) -**Files:** Modify `ckipper.zsh`. +This task absorbs the heaviest revisions from the panel review. Read the **Reviewer notes** at the end before implementing. -**Step 1:** Add a helper that snapshots Keychain entries: +**Files:** Modify `ckipper.zsh`. Create `tests/keychain-dump.sample`. + +**Step 1: Capture a real Keychain-dump fixture.** On macOS: + +```bash +mkdir -p tests +security dump-keychain 2>/dev/null | \ + awk '/^keychain: / || /class: "genp"/ || /"svce"=/ || /"acct"=/' | \ + sed 's|/Users/[^/]*/|/Users//|g' \ + > tests/keychain-dump.sample +``` + +This sample is what the snapshot parser expects to consume. Trim it to ~3 entries (one Claude entry, two unrelated) so the test is deterministic. Commit it under `tests/`. + +**Step 2: Implement `_ckipper_keychain_snapshot` and validation helpers:** ```zsh +# Validates a keychain_service name before passing to `security`. +# Accepts "Claude Code-credentials" optionally followed by "-<8hex>". +_ckipper_validate_keychain_service() { + local svc="$1" + [[ -z "$svc" ]] && return 1 + [[ "$svc" =~ ^Claude\ Code-credentials(-[a-f0-9]+)?$ ]] +} + _ckipper_keychain_snapshot() { - # macOS only. Returns service names of all "Claude Code-credentials*" entries. - if [[ "$OSTYPE" != darwin* ]]; then - return 0 + # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. + [[ "$OSTYPE" != darwin* ]] && return 0 + + # Fail loudly if keychain is locked (timeout protects against GUI prompt blocking). + local out + if ! out=$(timeout 10 security dump-keychain 2>/dev/null); then + echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 + return 1 fi - security dump-keychain 2>/dev/null | \ + + printf '%s\n' "$out" | \ awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ sort -u } + +# Atomic registry write under flock. $1 = filter expression for jq. +_ckipper_registry_update() { + local jq_filter="$1" + shift + local lock="$CKIPPER_DIR/.registry.lock" + mkdir -p "$CKIPPER_DIR" + : > "$lock" + { + flock -x 9 + local tmp; tmp=$(mktemp) + jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + } 9>"$lock" +} + +# Initialize an empty registry with version field. +_ckipper_init_registry() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + mkdir -p "$CKIPPER_DIR" + cat > "$CKIPPER_REGISTRY" <&2 + return 1 + fi +} ``` -**Step 2:** Implement `_ckipper_add`: +**Step 3: Implement `_ckipper_add`:** ```zsh _ckipper_add() { + _ckipper_check_registry_version || return 1 local name="$1" adopt=0 [[ "$2" == "--adopt" ]] && adopt=1 if [[ -z "$name" ]]; then @@ -388,34 +502,47 @@ _ckipper_add() { return 1 fi if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then - echo "Account name must be lowercase alphanumeric, underscore, or hyphen." + echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." return 1 fi - local dir="$HOME/.claude-$name" - mkdir -p "$CKIPPER_DIR" - - # Initialize registry if missing - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - echo '{"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - fi + _ckipper_init_registry - # Refuse if already registered if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then echo "Account '$name' is already registered." return 1 fi + local dir="$HOME/.claude-$name" + if [[ $adopt -eq 1 ]]; then if [[ ! -d "$dir" ]]; then echo "Cannot adopt: $dir does not exist." return 1 fi - _ckipper_finalize_registration "$name" "$dir" "" "adopt" + # In adopt mode, list candidate Keychain entries and let the user pick (or skip). + local picked="" + if [[ "$OSTYPE" == darwin* ]]; then + local candidates + candidates=$(_ckipper_keychain_snapshot) || return 1 + if [[ -n "$candidates" ]]; then + echo "Candidate Keychain entries:" + echo "$candidates" | nl + read -r "?Pick a number (or empty to skip): " idx + if [[ -n "$idx" ]]; then + picked=$(echo "$candidates" | sed -n "${idx}p") + if [[ -n "$picked" ]] && ! _ckipper_validate_keychain_service "$picked"; then + echo "Invalid Keychain service shape: $picked" + return 1 + fi + fi + fi + fi + _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" return $? fi - # Fresh registration: create dir, snapshot keychain, prompt /login + # Fresh registration if [[ -d "$dir" ]]; then echo "Directory $dir already exists. Use --adopt to register it." return 1 @@ -426,7 +553,7 @@ _ckipper_add() { fi local before_snapshot - before_snapshot=$(_ckipper_keychain_snapshot) + before_snapshot=$(_ckipper_keychain_snapshot) || return 1 cat < "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + _ckipper_registry_update ' + .accounts[$n] = {config_dir: $d, keychain_service: (if $s == "" then null else $s end), registered_at: $t} + | (if .default == null then .default = $n else . end) + ' --arg n "$name" --arg d "$dir" --arg s "$service" --arg t "$now" _ckipper_regenerate_aliases _ckipper_sync_hooks_for "$name" @@ -479,48 +622,69 @@ _ckipper_finalize_registration() { } ``` -**Step 3:** Add `_ckipper_regenerate_aliases` and `_ckipper_sync_hooks_for` as stubs (filled in next tasks): +**Step 4:** Add `_ckipper_regenerate_aliases` and `_ckipper_sync_hooks_for` as stubs (Tasks 12, 13): ```zsh -_ckipper_regenerate_aliases() { :; } # implemented in Task 12 -_ckipper_sync_hooks_for() { :; } # implemented in Task 13 +_ckipper_regenerate_aliases() { :; } +_ckipper_sync_hooks_for() { :; } ``` -**Step 4: Verify (dry-run, no real account)** +**Step 5: Verify** ```bash -zsh -c 'source ./w-function.zsh; source ./ckipper.zsh; ckipper add' -``` +zsh -n ckipper.zsh -Expected: prints usage error. +# Validation tests +zsh -c 'source ./ckipper.zsh; ckipper add' # usage error +zsh -c 'source ./ckipper.zsh; ckipper add Bad-Name' # uppercase rejected +zsh -c 'source ./ckipper.zsh; _ckipper_validate_keychain_service "Claude Code-credentials" && echo OK' # → OK +zsh -c 'source ./ckipper.zsh; _ckipper_validate_keychain_service "Claude Code-credentials-abc12345" && echo OK' # → OK +zsh -c 'source ./ckipper.zsh; _ckipper_validate_keychain_service "" && echo OK || echo REJECTED' # → REJECTED +zsh -c 'source ./ckipper.zsh; _ckipper_validate_keychain_service "Claude Code-credentials; rm -rf /" && echo OK || echo REJECTED' # → REJECTED -```bash -zsh -c 'source ./w-function.zsh; source ./ckipper.zsh; ckipper add Bad-Name' +# Snapshot regression test against fixture +zsh -c ' + source ./ckipper.zsh + fake_dump() { cat tests/keychain-dump.sample; } + printf "%s\n" "$(fake_dump | awk -F'"'"'"'"'"'"'"'"' '"'"'/"svce"="Claude Code-credentials/ {print $4}'"'"' | sort -u)" +' ``` -Expected: prints validation error (uppercase rejected). +The snapshot fixture test should print the Claude entry from the sample. If it prints nothing, the awk pattern is broken — fix it before continuing. -**Step 5: Commit** +**Step 6: Commit** ```bash -git add ckipper.zsh -git commit -m "Implement ckipper add with Keychain snapshot/diff" +git add ckipper.zsh tests/keychain-dump.sample +git commit -m "Implement ckipper add: validated keychain shape, atomic registry, fixture test" ``` -### Task 10: Implement `--adopt` mode end-to-end +**Reviewer notes folded into this task:** +- `printf '%s\n'` instead of `echo` for `comm` input (handles empty-snapshot case correctly). +- Locked-keychain detection via `timeout 10`. +- `keychain_service` shape validated before write. +- Registry is `chmod 600` and protected by `flock`. +- Schema `version: 1` written on creation. +- "skip" sentinel for the interactive prompt. +- `--adopt` lists candidates and asks the user to pick (no silent guessing). +- Fixture-based snapshot regression test. + +### Task 10: Validate `--adopt` end-to-end -Already covered structurally in Task 9. Verify by: +**Files:** No new files; verification only. ```bash -mkdir -p /tmp/ckipper-test-adopt/.claude-personal -echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > /tmp/ckipper-test-adopt/.claude-personal/.claude.json -HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ - zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt' -HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ +TEST_HOME=$(mktemp -d -t ckipper-test-adopt-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +mkdir -p "$TEST_HOME/.claude-personal" +echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > "$TEST_HOME/.claude-personal/.claude.json" +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ + zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt < /dev/null' +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ zsh -c 'source ./ckipper.zsh; ckipper list' ``` -Expected: registration succeeds, `ckipper list` shows `personal` with `test@example.com`. +Forcing `OSTYPE=linux-gnu` exercises the `keychain_service: null` path (Linux/on-disk credentials) — important regression coverage. Expected: `personal` registered with `(test@example.com)`. **Commit only if changes were needed.** @@ -528,22 +692,23 @@ Expected: registration succeeds, `ckipper list` shows `personal` with `test@exam **Files:** Modify `ckipper.zsh`. -**Step 1:** Implement: +**Step 1:** ```zsh _ckipper_default() { + _ckipper_check_registry_version || return 1 local name="$1" [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then echo "Account '$name' is not registered." return 1 fi - local tmp; tmp=$(mktemp) - jq --arg n "$name" '.default = $n' "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + _ckipper_registry_update '.default = $n' --arg n "$name" echo "Default account is now '$name'." } _ckipper_remove() { + _ckipper_check_registry_version || return 1 local name="$1" [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then @@ -552,22 +717,27 @@ _ckipper_remove() { fi local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") - local tmp; tmp=$(mktemp) - jq --arg n "$name" 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ - "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + _ckipper_registry_update 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' --arg n "$name" _ckipper_regenerate_aliases echo "Unregistered '$name'." echo "" echo "The directory and Keychain entry were not deleted. To remove them manually:" - echo " rm -rf $dir" - [[ -n "$service" ]] && echo " security delete-generic-password -s '$service'" + printf " rm -rf %q\n" "$dir" + if [[ -n "$service" ]]; then + printf " security delete-generic-password -s %q\n" "$service" + fi } ``` -**Step 2: Verify** +(`printf '%q'` quote-protects against unusual characters in `$dir`/`$service`.) + +**Step 2: Verify** (uses fixture from Task 10): ```bash -HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ +TEST_HOME=$(mktemp -d -t ckipper-test-remove-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +# ... seed registry with 'personal' ... +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ zsh -c 'source ./ckipper.zsh; ckipper default personal; ckipper remove personal; ckipper list' ``` @@ -577,27 +747,33 @@ Expected: default set, then unregistered, then list shows no accounts. ```bash git add ckipper.zsh -git commit -m "Implement ckipper default and ckipper remove" +git commit -m "Implement ckipper default and remove with quote safety" ``` -### Task 12: Implement aliases.zsh auto-generation +### Task 12: Implement self-contained `aliases.zsh` auto-generation **Files:** Modify `ckipper.zsh`. -**Step 1:** Replace the `_ckipper_regenerate_aliases` stub with: +**Critical constraint:** `aliases.zsh` must be self-contained — sourcing it alone (without `ckipper.zsh` or `w-function.zsh`) must produce a working `cca` and `claude-` set. The generated file defines its own `CKIPPER_REGISTRY` path before defining `cca`. + +**Step 1:** ```zsh _ckipper_regenerate_aliases() { local out="$CKIPPER_DIR/aliases.zsh" { echo "# Auto-generated by ckipper. Do not edit by hand." + echo "# Self-contained: does not depend on ckipper.zsh or w-function.zsh being sourced." echo "# Regenerated whenever an account is added or removed." echo "" + echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" + echo "" echo "cca() {" - echo " local name=\$1; shift" + echo " local name=\"\$1\"; shift" + echo " if [[ -z \"\$name\" ]]; then echo \"Usage: cca [args...]\"; return 1; fi" echo " local dir" - echo " dir=\$(jq -r --arg n \"\$name\" '.accounts[\$n].config_dir // empty' \"\$CKIPPER_REGISTRY\")" - echo " if [[ -z \"\$dir\" ]]; then echo \"Unknown account: \$name\"; return 1; fi" + echo " dir=\$(jq -r --arg n \"\$name\" '.accounts[\$n].config_dir // empty' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" + echo " if [[ -z \"\$dir\" ]]; then echo \"Unknown account: \$name. Run: ckipper list\"; return 1; fi" echo " CLAUDE_CONFIG_DIR=\"\$dir\" command claude \"\$@\"" echo "}" echo "" @@ -608,55 +784,61 @@ _ckipper_regenerate_aliases() { done fi } > "$out" + chmod 644 "$out" } ``` -**Step 2:** Update `install.sh` to add a one-time `.zshrc` line suggesting: - -```zsh -[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh -``` - -(Suggestion only; do not auto-edit `.zshrc`.) +**Step 2:** `install.sh` already prints (Task 4 step 5) the optional source line for `aliases.zsh`. Confirm that message is still in the install output. -**Step 3: Verify** +**Step 3: Verify (independence test)** ```bash -HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ - zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt; cat /tmp/ckipper-test-adopt/.ckipper/aliases.zsh' +TEST_HOME=$(mktemp -d -t ckipper-test-aliases-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +mkdir -p "$TEST_HOME/.claude-personal" +echo '{"oauthAccount":{"emailAddress":"x@y.z"}}' > "$TEST_HOME/.claude-personal/.claude.json" +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ + zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt < /dev/null' + +# Source ONLY aliases.zsh (no ckipper.zsh) and confirm cca works. +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ + zsh -c 'source "$CKIPPER_DIR/aliases.zsh"; type cca && type claude-personal' ``` -Expected: file contains `cca` function and `claude-personal` function. +Expected: both `cca` and `claude-personal` are defined as functions, even though `ckipper.zsh` was never sourced. **Step 4: Commit** ```bash -git add ckipper.zsh install.sh -git commit -m "Auto-generate per-account aliases and cca dispatcher" +git add ckipper.zsh +git commit -m "Auto-generate self-contained aliases.zsh with cca dispatcher" ``` ### Task 13: Implement `ckipper sync-hooks` **Files:** Modify `ckipper.zsh`. -**Step 1:** Implement two functions: one that syncs all accounts, one for a single account (used by `add`): +**Step 1:** ```zsh _ckipper_sync_hooks_for() { local name="$1" + _ckipper_check_registry_version || return 1 local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") [[ -z "$dir" || "$dir" == "null" ]] && return 1 mkdir -p "$dir/hooks" cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true - # Rewrite settings.json to use absolute hook paths under this account dir. + # Rewrite settings.json hook paths to absolute paths under this account dir. if [[ -f "$dir/settings.json" ]] && command -v jq &>/dev/null; then local tmp; tmp=$(mktemp) jq --arg d "$dir" ' (.hooks // {}) as $h | - .hooks = ($h | walk(if type == "string" and test("\\.claude(-[a-z0-9_-]+)?/hooks/") - then sub("/.claude(-[a-z0-9_-]+)?/hooks/"; "\($d)/hooks/") - else . end)) + .hooks = ($h | walk( + if type == "string" and (test("/.claude(-[a-z0-9_-]+)?/hooks/") or test("/.ckipper/hooks/")) + then sub("(/.claude(-[a-z0-9_-]+)?|/.ckipper)/hooks/"; "\($d)/hooks/") + else . end + )) ' "$dir/settings.json" > "$tmp" && mv "$tmp" "$dir/settings.json" fi } @@ -666,6 +848,7 @@ _ckipper_sync_hooks() { echo "No accounts registered." return 0 fi + _ckipper_check_registry_version || return 1 local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") while IFS= read -r name; do echo "Syncing hooks → $name" @@ -677,19 +860,24 @@ _ckipper_sync_hooks() { **Step 2: Verify** ```bash -mkdir -p /tmp/ckipper-test-adopt/.ckipper/hooks -echo "echo test" > /tmp/ckipper-test-adopt/.ckipper/hooks/sample.sh -HOME=/tmp/ckipper-test-adopt CKIPPER_DIR=/tmp/ckipper-test-adopt/.ckipper \ - zsh -c 'source ./ckipper.zsh; ckipper sync-hooks; ls /tmp/ckipper-test-adopt/.claude-personal/hooks/' +TEST_HOME=$(mktemp -d -t ckipper-test-sync-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +# ... seed account ... +mkdir -p "$TEST_HOME/.ckipper/hooks" +echo "echo test" > "$TEST_HOME/.ckipper/hooks/sample.sh" +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ + zsh -c 'source ./ckipper.zsh; ckipper sync-hooks; ls "$HOME/.claude-personal/hooks/"' ``` -Expected: `sample.sh` exists in the per-account hooks dir. +Expected: `sample.sh` present in the per-account hooks dir. + +**Note on settings-template ownership:** `settings-template.json` is owned by the repo (deployed by `install.sh` to `~/.ckipper/settings-template.json`). It is **seed-only** — accounts diverge after creation. When the template version drifts, already-registered accounts are NOT auto-updated; the user re-runs `ckipper sync-hooks` to refresh hook paths and (in a future task) `ckipper sync-settings` for full template re-application. This stance is documented in CLAUDE.md and README. **Step 3: Commit** ```bash git add ckipper.zsh -git commit -m "Implement ckipper sync-hooks for per-account hook deployment" +git commit -m "Implement ckipper sync-hooks; document template seed-only stance" ``` --- @@ -700,37 +888,26 @@ git commit -m "Implement ckipper sync-hooks for per-account hook deployment" **Files:** Modify `w-function.zsh`. -**Step 1:** Add a helper at the top of the `w()` function (before flag parsing): +**Step 1:** Add `_w_resolve_account` at the top of `w-function.zsh` (before the `w()` function): ```zsh _w_resolve_account() { local cli_account="$1" - # 1. CLI flag wins if [[ -n "$cli_account" ]]; then - echo "$cli_account" - return 0 + echo "$cli_account"; return 0 fi - # 2. CLAUDE_CONFIG_DIR env var matching a registered config_dir if [[ -n "$CLAUDE_CONFIG_DIR" && -f "$CKIPPER_REGISTRY" ]]; then local matched matched=$(jq -r --arg d "$CLAUDE_CONFIG_DIR" \ '.accounts | to_entries[] | select(.value.config_dir == $d) | .key' \ "$CKIPPER_REGISTRY" | head -1) - if [[ -n "$matched" ]]; then - echo "$matched" - return 0 - fi + [[ -n "$matched" ]] && { echo "$matched"; return 0; } fi - # 3. Default from registry if [[ -f "$CKIPPER_REGISTRY" ]]; then local default default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - if [[ -n "$default" ]]; then - echo "$default" - return 0 - fi + [[ -n "$default" ]] && { echo "$default"; return 0; } fi - # 4. No accounts: return empty (legacy mode) return 0 } ``` @@ -741,138 +918,208 @@ _w_resolve_account() { local cli_account="" while [[ $# -gt 0 ]]; do case "$1" in - --docker) docker_mode=1; shift ;; + --docker) docker_mode=1; shift ;; --firewall) firewall_mode=1; shift ;; - --account) cli_account="$2"; shift 2 ;; - *) command+=("$1"); shift ;; + --account) cli_account="$2"; shift 2 ;; + *) command+=("$1"); shift ;; esac done ``` -**Step 3:** After the existing arg validation, resolve and validate the account: +**Step 3:** After existing arg validation, resolve and validate the account. **No legacy fallback** — if no account resolves, error out (per panel decision: error not silent fallback): ```zsh local active_account active_account=$(_w_resolve_account "$cli_account") -local active_config_dir="" -local active_keychain_service="" -if [[ -n "$active_account" && -f "$CKIPPER_REGISTRY" ]]; then - active_config_dir=$(jq -r --arg n "$active_account" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") - active_keychain_service=$(jq -r --arg n "$active_account" '.accounts[$n].keychain_service // empty' "$CKIPPER_REGISTRY") - if [[ -z "$active_config_dir" ]]; then - echo "Error: account '$active_account' is not registered. Run: ckipper list" - return 1 - fi +if [[ -z "$active_account" ]]; then + echo "Error: no account selected and no default registered." + echo "Run: ckipper list (then: ckipper default , or pass --account )" + return 1 +fi +local active_config_dir; active_config_dir=$(jq -r --arg n "$active_account" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") +local active_keychain_service; active_keychain_service=$(jq -r --arg n "$active_account" '.accounts[$n].keychain_service // empty' "$CKIPPER_REGISTRY") +if [[ -z "$active_config_dir" ]]; then + echo "Error: account '$active_account' is not registered. Run: ckipper list" + return 1 fi -# Fallback: legacy single-account ~/.claude -[[ -z "$active_config_dir" ]] && active_config_dir="$HOME/.claude" -[[ -z "$active_keychain_service" ]] && active_keychain_service="Claude Code-credentials" ``` **Step 4: Verify** ```bash zsh -n w-function.zsh +TEST_HOME=$(mktemp -d -t w-resolve-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +# ... seed registry with default 'personal' ... +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" CKIPPER_REGISTRY="$TEST_HOME/.ckipper/accounts.json" \ + zsh -c 'source ./w-function.zsh; _w_resolve_account ""' ``` -Expected: parse OK. - -```bash -zsh -c 'source ./w-function.zsh; CKIPPER_REGISTRY=/tmp/ckipper-test/.ckipper/accounts.json _w_resolve_account ""' -``` - -Expected: prints `personal` (the default from earlier fixture). +Expected: prints `personal`. **Step 5: Commit** ```bash git add w-function.zsh -git commit -m "w: resolve active account from --account flag, env, or default" +git commit -m "w: resolve active account from --account, env, or registered default" ``` ### Task 15: `w` Docker args use per-account paths and Keychain service -**Files:** Modify `w-function.zsh`. +**Files:** Modify `w-function.zsh`. Create `docker/cleanup-projects.py`. + +**Pre-task audit (CRITICAL):** Before changing any mounts, run: + +```bash +grep -rn "/home/claude/.claude" docker/ w-function.zsh +``` + +For each hit, decide: keep (it's a container `$HOME` path unrelated to Claude state, e.g., `.local/bin`) or migrate (it's a Claude-state path that must move to `$CLAUDE_CONFIG_DIR`). Document the decision in a comment near each line. **Only after this audit is the Step 2 mount change safe.** -**Step 1:** In the Docker block, replace the hardcoded credential extraction: +**Step 1:** Replace the hardcoded credential extraction. Validate the service name first: ```zsh -# old -claude_creds=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) || true -# new -claude_creds=$(security find-generic-password -s "$active_keychain_service" -w 2>/dev/null) || true +if ! _ckipper_validate_keychain_service "$active_keychain_service" && [[ -n "$active_keychain_service" ]]; then + echo "Error: account '$active_account' has invalid keychain_service in registry." + echo "Re-register with: ckipper remove $active_account && ckipper add $active_account --adopt" + return 1 +fi +local claude_creds="" +if [[ -n "$active_keychain_service" ]]; then + claude_creds=$(security find-generic-password -s "$active_keychain_service" -w 2>/dev/null) || true +fi ``` -**Step 2:** Replace the `~/.claude` mount block: +(Note: `_ckipper_validate_keychain_service` is sourced by `w-function.zsh` because `ckipper.zsh` is sourced from it.) + +**Step 2:** Replace the `~/.claude` mount block. The dropped `/home/claude/.claude` mount is justified by the Step-0 audit: ```zsh -# old +# old (three mounts) -v "$HOME/.claude:/home/claude/.claude:rw" -v "$HOME/.claude.json:/home/claude/.claude-host.json:ro" -v "$HOME/.claude:$HOME/.claude:rw" -# new +# new (single per-account mount + read-only staging copy) -v "$active_config_dir:$active_config_dir:rw" -v "$active_config_dir/.claude.json:$active_config_dir/.claude-host.json:ro" -e "CLAUDE_CONFIG_DIR=$active_config_dir" ``` -(The dual mount under `/home/claude/.claude` is dropped — entrypoint now reads from `$CLAUDE_CONFIG_DIR` directly. The `:ro` mount of the host `.claude.json` becomes the per-account file.) - **Step 3:** Update the gh-token extraction to use the per-account `.claude.json`: ```zsh gh_token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' "$active_config_dir/.claude.json" 2>/dev/null) || true ``` -**Step 4:** Update the worktree project-settings sync (the python heredoc near line 232) to use `$active_config_dir/.claude.json` instead of `~/.claude.json`. The cleanup in `--rm` (line 111) similarly uses the active account's `.claude.json`. For `--rm`, the simplest path is to clean the worktree entry from **all** accounts that have it: +**Step 4: Extract the worktree project-settings sync to `docker/cleanup-projects.py`** (replaces the inline Python heredocs at lines ~111 and ~232 of the current `w-function.zsh`). The script walks the registry (not a glob — registry-driven cleanup): ```python -# Sketch — in --rm cleanup: -import json, os, glob -wt_path = os.environ['WT_PATH'] -for cfg in glob.glob(os.path.expanduser('~/.claude-*/.claude.json')) + [os.path.expanduser('~/.claude/.claude.json')]: - if not os.path.exists(cfg): continue - with open(cfg) as f: d = json.load(f) - if wt_path in d.get('projects', {}): - del d['projects'][wt_path] - with open(cfg, 'w') as f: json.dump(d, f) - print(f'Removed worktree entry from {cfg}') -``` - -**Step 5:** Drop the post-session credential symlink cleanup at line 416–420 if `active_config_dir != $HOME/.claude` — the symlink lives inside the container's tmpfs now and no longer pollutes `~/.claude/.credentials.json`. Keep the legacy cleanup for the fallback path where `active_config_dir == $HOME/.claude`. +#!/usr/bin/env python3 +"""Remove or sync a worktree path entry from per-account .claude.json files.""" +import json, os, sys + +def all_account_dirs(registry): + if not os.path.exists(registry): return [] + with open(registry) as f: + d = json.load(f) + return [a["config_dir"] for a in d.get("accounts", {}).values() if a.get("config_dir")] + +def remove_worktree_from_all(registry, wt_path): + seen = set() + for cfg_dir in all_account_dirs(registry): + cfg = os.path.join(cfg_dir, ".claude.json") + cfg_real = os.path.realpath(cfg) + if cfg_real in seen: continue + seen.add(cfg_real) + if not os.path.exists(cfg): continue + with open(cfg) as f: + d = json.load(f) + if wt_path in d.get("projects", {}): + del d["projects"][wt_path] + with open(cfg, "w") as f: + json.dump(d, f) + print(f"Removed worktree entry from {cfg}") + +def sync_worktree_settings(registry, account_name, main_path, wt_path): + if not os.path.exists(registry): return + with open(registry) as f: + d = json.load(f) + acc = d.get("accounts", {}).get(account_name) + if not acc: return + cfg = os.path.join(acc["config_dir"], ".claude.json") + if not os.path.exists(cfg): return + with open(cfg) as f: + cd = json.load(f) + main = cd.get("projects", {}).get(main_path, {}) + if not main: return + keys = ["disabledMcpServers", "enabledMcpjsonServers", "disabledMcpjsonServers", + "allowedTools", "hasTrustDialogAccepted", "hasClaudeMdExternalIncludesApproved", + "hasClaudeMdExternalIncludesWarningShown", "hasCompletedProjectOnboarding"] + wt = cd.setdefault("projects", {}).setdefault(wt_path, {}) + for k in keys: + if k in main: wt[k] = main[k] + with open(cfg, "w") as f: + json.dump(cd, f) + print(f"Synced settings for {wt_path} in {cfg}") + +if __name__ == "__main__": + cmd = sys.argv[1] + registry = os.environ.get("CKIPPER_REGISTRY", os.path.expanduser("~/.ckipper/accounts.json")) + if cmd == "remove": + remove_worktree_from_all(registry, sys.argv[2]) + elif cmd == "sync": + sync_worktree_settings(registry, sys.argv[2], sys.argv[3], sys.argv[4]) +``` + +Update `w-function.zsh` to call this script in `--rm` cleanup and worktree-creation sync, replacing the inline heredocs. + +**Step 5:** Drop the post-session credential symlink cleanup at lines ~416-420 of the current `w-function.zsh` — credentials now live inside the per-account dir, and the dir's `.credentials.json` is the symlink. Cleanup happens implicitly when the container exits (tmpfs disappears; the symlink target vanishes but the symlink remains on disk pointing at nothing — harmless and overwritten on next session). **Step 6: Verify** ```bash zsh -n w-function.zsh -shellcheck -s bash w-function.zsh # may have warnings, ignore non-errors +shellcheck -s bash w-function.zsh # warnings OK; errors not +python3 -c "import ast; ast.parse(open('docker/cleanup-projects.py').read())" ``` **Step 7: Commit** ```bash -git add w-function.zsh -git commit -m "w: thread active account through Docker args and project sync" +git add w-function.zsh docker/cleanup-projects.py +git commit -m "w: thread active account through Docker; extract registry-driven cleanup" ``` -### Task 16: Entrypoint uses `$CLAUDE_CONFIG_DIR` +### Task 16: Entrypoint uses `$CLAUDE_CONFIG_DIR` (and errors if unset) **Files:** Modify `docker/entrypoint.sh`. -**Step 1:** At the top, after `set -e`, add: +**Step 1:** At the top, after `set -e`, **error out** (no silent fallback) if the env var is unset: ```bash -CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +if [ -z "$CLAUDE_CONFIG_DIR" ]; then + echo "Error: CLAUDE_CONFIG_DIR is not set inside the container." >&2 + echo "This means w() did not pass the account context. Bug — please report." >&2 + exit 1 +fi +if [ ! -d "$CLAUDE_CONFIG_DIR" ]; then + echo "Error: CLAUDE_CONFIG_DIR=$CLAUDE_CONFIG_DIR does not exist (mount failed?)." >&2 + exit 1 +fi ``` -This makes the script account-aware in container while preserving legacy behavior when no account is set. +(Per panel decision: silent fallback masks misconfiguration. Always require explicit account context inside the container.) -**Step 2:** Replace `~/.claude.json` and `~/.claude-host.json` references with `$CLAUDE_CONFIG_DIR/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude-host.json`. The "copy from staging" step becomes: +**Step 2:** Replace `~/.claude.json` and `~/.claude-host.json` references with `$CLAUDE_CONFIG_DIR/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude-host.json`: ```bash if [ -f "$CLAUDE_CONFIG_DIR/.claude-host.json" ]; then cp "$CLAUDE_CONFIG_DIR/.claude-host.json" "$CLAUDE_CONFIG_DIR/.claude.json" - # ... existing jq edits ... + if command -v jq &>/dev/null; then + jq '.claudeInChromeDefaultEnabled = false | .cachedChromeExtensionInstalled = false' \ + "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ + && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" + fi fi ``` @@ -882,7 +1129,7 @@ fi ln -sf /tmp/claude-creds/.credentials.json "$CLAUDE_CONFIG_DIR/.credentials.json" ``` -**Step 4:** Update git identity, MCP pre-install, and uvx jq queries to use `$CLAUDE_CONFIG_DIR/.claude.json`. +**Step 4:** Update git identity, MCP pre-install, and uvx jq queries to use `$CLAUDE_CONFIG_DIR/.claude.json`. Note that `$HOME` (container user home) is *not* changed — `.local/bin/bunx`, `.cache/uv`, `.ssh/`, etc. continue to live under `/home/claude/`. Only Claude-state paths move. **Step 5: Verify** @@ -891,133 +1138,237 @@ shellcheck docker/entrypoint.sh bash -n docker/entrypoint.sh ``` -Expected: no errors. - **Step 6: Commit** ```bash git add docker/entrypoint.sh -git commit -m "entrypoint: read all paths from CLAUDE_CONFIG_DIR" +git commit -m "entrypoint: read all Claude paths from CLAUDE_CONFIG_DIR; error if unset" ``` -### Task 17: Hooks adapt to per-account paths +### Task 17: Hooks protect per-account dirs and `~/.ckipper/` -**Files:** Modify `hooks/protect-claude-config.sh`, `hooks/bash-guardrails.sh`. +**Files:** Modify `hooks/protect-claude-config.sh`, `hooks/bash-guardrails.sh`. Update `test-prompt.md` Section 10. -**Step 1:** Read both hooks. Identify hardcoded `~/.claude/` references that should generalize. +**Critical fix from Security review:** without this, a Claude session in account A can edit `~/.ckipper/accounts.json` to redirect account B's keychain service into account A's container — credential cross-contamination. -**Step 2:** In `protect-claude-config.sh`: replace the protected-path list to include both legacy (`~/.claude/`) and per-account (`~/.claude-*/`) directories. The hook should match any path under `$HOME/.claude` or `$HOME/.claude-` as well as `$HOME/.ckipper`. +**Step 1:** Read both hooks. Identify the existing `~/.claude/` substring patterns. -**Step 3:** In `bash-guardrails.sh`: same treatment for any patterns that reference `~/.claude` paths. +**Step 2:** Define the new protected-path regex. It must: +- Match `$HOME/.claude` and `$HOME/.claude-` (any non-empty `[a-z0-9_-]+`) +- Match `$HOME/.ckipper` +- NOT match `$HOME/.claude-host.json` (the in-container read-only mount) -**Step 4: Verify** +Required POSIX/PCRE-ish regex (test against both hook languages): + +``` +(^|/)\.claude(-[a-z0-9_-]+)?(/|$)|(^|/)\.ckipper(/|$) +``` + +**Step 3:** In `protect-claude-config.sh`, extend the protected-path check (PreToolUse on Edit/Write) to use the new regex. Block any path matching the regex unless it's in an explicit allow-list (e.g., `/projects/`). + +**Step 4:** In `bash-guardrails.sh`, extend the regex similarly so commands like `echo malicious > ~/.ckipper/accounts.json` are blocked. + +**Step 5:** Add new bypass-attempt entries to `test-prompt.md` Section 10: +- Try editing `~/.ckipper/accounts.json` from inside Claude — must be BLOCKED. +- Try writing to `~/.claude-otheraccount/settings.json` from a session in `personal` — must be BLOCKED. +- Try writing to `$CLAUDE_CONFIG_DIR/projects/whatever.txt` — must be ALLOWED (under projects/). + +**Step 6:** Re-run the existing Section 10 tests after the regex change. Confirm no regression. (Document this re-run in the PR test plan.) + +**Step 7: Verify** ```bash shellcheck hooks/*.sh ``` -Expected: no new errors. - -**Step 5: Commit** +**Step 8: Commit** ```bash -git add hooks/protect-claude-config.sh hooks/bash-guardrails.sh -git commit -m "hooks: protect per-account dirs and ~/.ckipper" +git add hooks/protect-claude-config.sh hooks/bash-guardrails.sh test-prompt.md +git commit -m "hooks: extend protection to per-account dirs and ~/.ckipper" ``` --- ## Phase 5 — Migration command -### Task 18: Implement `ckipper migrate` +### Task 18: Implement `ckipper migrate` (safety-checked, no symlink) **Files:** Modify `ckipper.zsh`. -**Step 1:** Replace the `_ckipper_migrate` stub: +**Decision-from-panel: drop the `~/.claude → ~/.claude-personal` symlink.** After migration, bare `claude` no longer maps to the personal account. Users use `claude-personal` (or whichever name they registered). The README and `migrate` output state this explicitly. + +**Step 1:** ```zsh _ckipper_migrate() { + _ckipper_check_registry_version || return 1 local legacy_docker="$HOME/.claude/docker" local legacy_claude="$HOME/.claude" - # 1. Move ~/.claude/docker → ~/.ckipper/docker if not already done + # ── Precondition 1: no Claude process running ───────────────── + if pgrep -f "[c]laude " >/dev/null 2>&1; then + echo "Error: a Claude process is currently running. Quit all Claude sessions first." >&2 + echo "Detected: $(pgrep -af '[c]laude ' | head -3)" >&2 + return 1 + fi + + # ── Precondition 2: ~/.claude-personal must not already exist ─ + if [[ -e "$HOME/.claude-personal" ]]; then + echo "Error: $HOME/.claude-personal already exists. Refusing to migrate." >&2 + echo "If you've already migrated, you're done. Run: ckipper list" >&2 + return 1 + fi + + # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then mkdir -p "$CKIPPER_DIR" cp -a "$legacy_docker/." "$CKIPPER_DIR/docker/" - echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact)" + echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact for one release cycle)" fi - # 2. Adopt ~/.claude as 'personal' if not already registered + # ── 2. Adopt ~/.claude as 'personal' if eligible ────────────── if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]]; then if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ - ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null; then - echo "" - echo "Detected existing ~/.claude with login. Register it as 'personal'?" - read -r "?[Y/n] " ans - if [[ "$ans" != "n" && "$ans" != "N" ]]; then - if [[ -L "$legacy_claude" ]]; then - echo "~/.claude is already a symlink — skipping rename." - else - mv "$legacy_claude" "$HOME/.claude-personal" - ln -s "$HOME/.claude-personal" "$legacy_claude" - echo "Renamed ~/.claude → ~/.claude-personal and symlinked back." + ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + + # Show the user what we're about to do. + cat </dev/null 2>&1; then + echo "Warning: '$probed_service' not found in Keychain." + echo "Listing available Claude Keychain entries:" + _ckipper_keychain_snapshot || return 1 + read -r "?Enter the Keychain service for the personal account (or empty to skip): " probed_service + if [[ -n "$probed_service" ]] && ! _ckipper_validate_keychain_service "$probed_service"; then + echo "Invalid Keychain service shape. Aborting." + return 1 + fi fi - # Adopt with the no-suffix Keychain entry (default for ~/.claude) - _ckipper_finalize_registration "personal" "$HOME/.claude-personal" "Claude Code-credentials" "migrate" + else + probed_service="" fi + + # ── Destructive operation under trap-rollback ──────── + _migrate_rollback() { + if [[ -d "$HOME/.claude-personal" && ! -e "$legacy_claude" ]]; then + mv "$HOME/.claude-personal" "$legacy_claude" 2>/dev/null + echo "Migration failed — restored $legacy_claude from rollback." >&2 + fi + } + trap _migrate_rollback ERR + + mv "$legacy_claude" "$HOME/.claude-personal" + _ckipper_finalize_registration "personal" "$HOME/.claude-personal" "$probed_service" "migrate" + + trap - ERR + unset -f _migrate_rollback fi fi - echo "" - echo "Migration complete. Next steps:" - echo " 1. Update ~/.zshrc to source ~/.ckipper/docker/w-function.zsh" - echo " (replace any source ~/.claude/docker/w-function.zsh)" - echo " 2. Add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh" - echo " 3. Restart your shell." - echo " 4. Run: ckipper add to add additional accounts." + # ── 3. Best-effort cleanup of old Docker image ──────────────── + if command -v docker >/dev/null 2>&1; then + docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." + fi + + cat < to add additional accounts. + +To launch Claude with your personal account, use: claude-personal +(Bare 'claude' no longer resolves to your migrated personal account — it will start a fresh login.) + +EOF } ``` **Step 2: Verify with a synthetic legacy host** ```bash -mkdir -p /tmp/ckipper-migrate/.claude/docker -mkdir -p /tmp/ckipper-migrate/.claude/hooks -echo "function w() { :; }" > /tmp/ckipper-migrate/.claude/docker/w-function.zsh -echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > /tmp/ckipper-migrate/.claude/.claude.json -HOME=/tmp/ckipper-migrate CKIPPER_DIR=/tmp/ckipper-migrate/.ckipper \ +TEST_HOME=$(mktemp -d -t ckipper-migrate-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +mkdir -p "$TEST_HOME/.claude/docker" +mkdir -p "$TEST_HOME/.claude/hooks" +echo "function w() { :; }" > "$TEST_HOME/.claude/docker/w-function.zsh" +echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > "$TEST_HOME/.claude/.claude.json" +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ zsh -c 'source ./ckipper.zsh; echo y | ckipper migrate; ckipper list' ``` -Expected: tooling copied, `~/.claude` renamed and symlinked, `personal` registered. +Expected: tooling copied, `~/.claude` renamed to `~/.claude-personal` (no symlink), `personal` registered, list prints reminder about using `claude-personal`. -**Step 3: Commit** +**Step 3: Verify failure-rollback path:** + +```bash +TEST_HOME=$(mktemp -d -t ckipper-migrate-rollback-XXXXXX) +trap "rm -rf '$TEST_HOME'" EXIT +mkdir -p "$TEST_HOME/.claude" +echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > "$TEST_HOME/.claude/.claude.json" +chmod -w "$TEST_HOME" # make registry write fail +HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ + zsh -c 'source ./ckipper.zsh; echo y | ckipper migrate' || true +chmod +w "$TEST_HOME" +ls "$TEST_HOME/" +``` + +Expected: `~/.claude` is restored (rollback fired); `~/.claude-personal` does not exist. + +**Step 4: Commit** ```bash git add ckipper.zsh -git commit -m "Implement ckipper migrate for legacy layout" +git commit -m "Implement ckipper migrate: precondition checks, error-trap rollback, no symlink" ``` --- ## Phase 6 — Documentation -### Task 19: README rewrite +### Task 19: README rewrite (with panel-feedback expansions) **Files:** Modify `README.md`. -**Step 1:** Restructure into sections: +**Step 1:** Restructure with the multi-account section surfaced earlier. New top-level section order: -1. What Ckipper is (one paragraph + the paddock-style metaphor for safety + autonomy). -2. Quickstart (single-account: `./install.sh`, source line, `w project branch --docker claude`). -3. **Multiple accounts** — new section. -4. Migration from `claude-docker-sandbox` — `ckipper migrate`. -5. Architecture overview (link to `docs/plans/2026-04-27-ckipper-multi-account-design.md`). +1. What Ckipper is — one paragraph including the headline that it's multi-account-capable. Include pronunciation note. +2. Quickstart (single account, fresh install). +3. **Multiple accounts** — second-most-prominent section. +4. Migration from `claude-docker-sandbox` — `ckipper migrate` with the "what it will do" preamble. +5. Troubleshooting (concurrent-use warning lives here, prominently). +6. Architecture overview (link to design doc). -**Step 2:** Multiple-accounts section template: +**Step 2: "Multiple accounts" section:** ````markdown -## Multiple accounts +## Multiple accounts (Ckipper's headline feature) Run a personal Claude account in one terminal and a work account in another, fully isolated. Each gets its own credentials, MCP servers, plugins, projects, and session history. @@ -1027,16 +1378,16 @@ Run a personal Claude account in one terminal and a work account in another, ful ckipper add work ``` -Follow the prompts to `/login` with the account in question. Repeat for as many accounts as you want. +`ckipper` walks you through `/login` and registers the account. Repeat for every account you want. ### Use an account Three ways: ```bash -claude-work # auto-generated alias (preferred) -cca work # one-off dispatcher -CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form +claude-work # auto-generated alias (preferred) +cca work # one-off dispatcher (claude-config-as) +CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form ``` ### Inside Docker @@ -1047,10 +1398,6 @@ w myorg/app feature --account work --docker claude If you're already in a terminal where `CLAUDE_CONFIG_DIR` is set (e.g., via `claude-work`), `w` picks up the account automatically — no flag needed. -### Concurrent-use warning - -Two terminals running the **same** account simultaneously can hit a known OAuth refresh-token race ([upstream issue #24317](https://github.com/anthropics/claude-code/issues/24317)) and require frequent re-login. Two terminals running **different** accounts is fine. - ### List, default, remove ```bash @@ -1060,7 +1407,44 @@ ckipper remove old-account ``` ```` -**Step 3:** Verify +**Step 3: Concurrent-use warning** — its own section with stronger phrasing: + +````markdown +## ⚠️ Don't run the same account in two sessions + +Two terminals running the **same** account simultaneously will hit a known OAuth refresh-token race ([upstream issue #24317](https://github.com/anthropics/claude-code/issues/24317)) — symptoms: frequent re-login prompts, lost sessions. + +**Safe:** `claude-personal` in one terminal, `claude-work` in another. Different accounts, different refresh tokens, no race. +**Bad:** `claude-personal` in two terminals at once. + +If you want concurrent runs of the *same* account, register it twice under two names (`personal-a`, `personal-b`) — though this means re-`/login` for each. +```` + +**Step 4: Migration section:** + +````markdown +## Migrating from claude-docker-sandbox + +If you've been running this project under its previous name with a single `~/.claude/docker/` install, run: + +```bash +ckipper migrate +``` + +This will: +1. Refuse to run if any `claude` process is currently active (quit them first). +2. Copy `~/.claude/docker/` → `~/.ckipper/`. +3. Offer to register your existing `~/.claude` as the `personal` account. If you accept: rename `~/.claude` → `~/.claude-personal`, probe Keychain for the matching credential entry, and write the registry. **No symlink is created** — after migration, you launch Claude with `claude-personal` (bare `claude` will start a fresh login). +4. If anything fails, the rename automatically reverses (rollback trap). + +Then add additional accounts: + +```bash +ckipper add work +``` +```` + +**Step 5: Verify** ```bash grep -n "claude-docker-sandbox" README.md @@ -1068,64 +1452,167 @@ grep -n "claude-docker-sandbox" README.md Expected: empty (or only in historical/migration context). -**Step 4: Commit** +**Step 6: Commit** ```bash git add README.md -git commit -m "README: rewrite for Ckipper with multi-account walkthrough" +git commit -m "README: rewrite for Ckipper with multi-account walkthrough and warnings" ``` ### Task 20: Update CLAUDE.md and test-prompt.md **Files:** Modify `CLAUDE.md`, `test-prompt.md`. -**Step 1:** CLAUDE.md gets: -- Updated tool layout diagram showing `~/.ckipper/`. +**Step 1: CLAUDE.md updates:** +- Tool layout diagram showing `~/.ckipper/` (move from `~/.claude/docker/`). - New "Multi-account" section under Architecture. -- Updated Development Workflow table including `ckipper.zsh` → install.sh. +- New "Critical Safety Rules" entry: registry tampering protection and `keychain_service` shape validation. +- Development Workflow table updated with `ckipper.zsh` → install.sh. +- Settings-template seed-only stance documented. + +**Step 2: test-prompt.md — add Section 12 "Multi-account isolation"** with concrete assertions: + +````markdown +## 12. Multi-account isolation + +Run these checks in two concurrent containers (Window A: `--account personal`, Window B: `--account `). + +### A. Each container has the right CLAUDE_CONFIG_DIR +```bash +# In window A +[ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude-personal" ] && echo PASS || echo FAIL +# In window B +[ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude-" ] && echo PASS || echo FAIL +``` + +### B. The right .claude.json was copied +```bash +# In each window +expected_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude-host.json") +actual_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude.json") +[ "$expected_email" = "$actual_email" ] && echo PASS || echo FAIL +``` -**Step 2:** test-prompt.md gets a new section "12. Multi-account isolation" with checks: -- Run `ckipper list` inside the container — should show the active account only. -- Verify `$CLAUDE_CONFIG_DIR` is set inside the container. -- Verify `~/.claude.json` is a copy of `$CLAUDE_CONFIG_DIR/.claude-host.json`. -- Confirm credentials are at `$CLAUDE_CONFIG_DIR/.credentials.json` (symlinked to tmpfs). -- Confirm no other account dir is mounted (e.g., `ls /home/claude/.claude-other` → not found). +### C. Credentials symlinked to tmpfs (and tmpfs is writable, host-mount is not) +```bash +[ -L "$CLAUDE_CONFIG_DIR/.credentials.json" ] && echo PASS || echo FAIL +[ "$(readlink "$CLAUDE_CONFIG_DIR/.credentials.json")" = "/tmp/claude-creds/.credentials.json" ] && echo PASS || echo FAIL +``` -**Step 3:** Commit +### D. Other accounts are NOT mounted +```bash +# Window A should NOT see Window B's dir +[ ! -d "$HOME/.claude-" ] && echo PASS || echo FAIL +``` + +### E. Project sessions don't bleed across accounts +After both sessions touch a project (e.g., create a file in `/workspace`), check from the host: +```bash +diff <(ls ~/.claude-personal/projects/ 2>/dev/null) <(ls ~/.claude-/projects/ 2>/dev/null) +# Expected: empty (no shared session dirs) +``` + +### F. Registry tampering blocked +Inside the container, attempt: +```bash +echo modified > ~/.ckipper/accounts.json +# Expected: BLOCKED by bash-guardrails.sh hook +``` +```` + +**Step 3: Commit** ```bash git add CLAUDE.md test-prompt.md -git commit -m "Docs: update CLAUDE.md and test-prompt.md for Ckipper" +git commit -m "Docs: CLAUDE.md and test-prompt.md updates with concrete Section 12 isolation tests" ``` --- -## Phase 7 — Local deployment + end-to-end validation +## Phase 6.5 — Pre-deploy Docker smoke test + +### Task 20.5: Build the renamed image and run a single Docker session against a fixture account -These tasks run on the user's actual host. Do not run earlier. +**Why:** Phase 7 is the first time anything runs in real Docker. A bug in Tasks 14-16 (e.g., a dropped mount that breaks `gh auth` or `uvx` pre-install) won't surface until the implementer's host. This task catches it earlier. -### Task 21: Deploy to user's host +**Files:** None modified — this is a verification task only. **Step 1:** From the repo root: +```bash +# Build the renamed image +docker build --build-arg "CACHEBUST=$(date +%s)" -t ckipper-dev docker/ + +# Set up a test account in a temp HOME +TEST_HOME=$(mktemp -d -t ckipper-smoke-XXXXXX) +mkdir -p "$TEST_HOME/.claude-test/projects" +cat > "$TEST_HOME/.claude-test/.claude.json" <<'EOF' +{"oauthAccount":{"emailAddress":"smoke@test","displayName":"Smoke Test"},"projects":{}} +EOF +mkdir -p "$TEST_HOME/.ckipper" +cat > "$TEST_HOME/.ckipper/accounts.json" <`). The repo never sees the implementer's specific account name — it stays in the implementer's local registry only. ```bash -ckipper add af +ckipper add ``` -Follow prompts: open `CLAUDE_CONFIG_DIR=~/.claude-af claude`, complete `/login` with the Animal Farm account, return and press enter. +Follow prompts: `CLAUDE_CONFIG_DIR=~/.claude- claude`, complete `/login`, return and press enter. **Step 2:** Verify: @@ -1163,30 +1658,35 @@ Follow prompts: open `CLAUDE_CONFIG_DIR=~/.claude-af claude`, complete `/login` ckipper list ``` -Expected: both `personal` and `af` listed with their respective emails. New Keychain entry detected. +Expected: both `personal` and `` listed with their respective emails. Two distinct Keychain entries detected. ### Task 24: Validate Docker integration with both accounts -**Step 1:** In two separate Ghostty windows: +**Step 1:** In two separate Ghostty windows, register a test project in `w`: Window A: ```bash -w some/project main --account personal --docker claude +w some/project test-personal --account personal --docker claude ``` Window B: ```bash -w some/project main --account af --docker claude +w some/project test-work --account --docker claude ``` -(Use different worktree branches or projects so they don't collide on the worktree path.) +Use different worktree branch names (`test-personal`, `test-work`) so the two sessions don't compete for the same worktree. + +**Step 2:** Inside each container, run the new `test-prompt.md` Section 12 checks (A through F). All must PASS. + +**Step 3:** Run `claude /status` inside each session. Verify each shows the correct account email. -**Step 2:** Inside each container, run the new "Multi-account isolation" section of `test-prompt.md`. Verify: -- Each container has its own `CLAUDE_CONFIG_DIR`. -- `claude /status` in each shows the right account email. -- Project sessions don't appear in the other account's `~/.claude-/projects/`. +**Step 4:** End both sessions cleanly. Confirm: -**Step 3:** End both sessions cleanly. Confirm no `~/.claude/.credentials.json` symlink remains on the host. +```bash +ls -la ~/.claude/.credentials.json 2>/dev/null +``` + +Expected: file does not exist on the host (credentials lived in the container's tmpfs and are gone). ### Task 25: Open PR to develop @@ -1202,30 +1702,47 @@ git push -u origin feature/ckipper-multi-account gh pr create --base develop --title "Ckipper: multi-account Claude Code sandbox" --body "$(cat <<'EOF' ## Summary - Renames `claude-docker-sandbox` → **Ckipper** (pronounced "skipper"). -- Adds multi-account support via `CLAUDE_CONFIG_DIR=~/.claude-/` and a registry at `~/.ckipper/accounts.json`. -- New `ckipper` CLI: `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. -- Auto-generates `claude-` aliases and a `cca ` dispatcher. -- `w` and Docker entrypoint thread an active account through every credential, mount, and config path. -- Migration path for existing single-account installs. +- Adds multi-account support via `CLAUDE_CONFIG_DIR=~/.claude-/` and a registry at `~/.ckipper/accounts.json` (versioned, chmod 600, flock-protected). +- New `ckipper` CLI: `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate` — each with `--help`. +- Auto-generates self-contained `aliases.zsh` with `cca ` dispatcher and per-account `claude-` functions. +- `w` and Docker entrypoint thread an active account through every credential, mount, and config path. Entrypoint errors out (no silent fallback) if `CLAUDE_CONFIG_DIR` is unset. +- Hook regex extended to cover `~/.ckipper/` and per-account dirs (closes a credential cross-contamination vector flagged by panel review). +- Migration path with running-Claude precondition, error-trap rollback, and Keychain probe-before-trust. Design: `docs/plans/2026-04-27-ckipper-multi-account-design.md` Plan: `docs/plans/2026-04-27-ckipper-multi-account-implementation.md` ## Test plan -- [x] `ckipper list` / `add --adopt` / `add` / `default` / `remove` against a temp HOME fixture. -- [x] `ckipper migrate` against a synthetic legacy `~/.claude/docker/` layout. -- [x] Live deploy on host — migration succeeded; `personal` adopted; second account `` added cleanly. -- [x] Two concurrent Docker sessions, different accounts, validated against `test-prompt.md` Section 12. -- [x] No host-side credential symlink remains after sessions end. +- [ ] `ckipper list` / `add --adopt` / `add` / `default` / `remove` against temp-HOME fixtures. +- [ ] Keychain snapshot regression test passes against `tests/keychain-dump.sample`. +- [ ] `ckipper migrate` against synthetic legacy `~/.claude/docker/` layout. +- [ ] `ckipper migrate` rollback when registry write fails. +- [ ] Phase 6.5 Docker smoke test passes against test account. +- [ ] Live deploy on host — migration succeeded; `personal` adopted; second account added cleanly. +- [ ] Two concurrent Docker sessions, different accounts, all six Section 12 isolation assertions PASS. +- [ ] No host-side `~/.claude/.credentials.json` symlink remains after sessions end. +- [ ] `test-prompt.md` Section 10 hook-bypass tests still pass after Task 17 regex change. +- [ ] Registry tampering (`echo X > ~/.ckipper/accounts.json` from inside a container) is BLOCKED by hooks. EOF )" ``` +(Test plan checkboxes are unchecked here — the implementer/reviewer ticks them as they verify.) + --- ## Follow-ups (out of scope for this PR) -- `ckipper rename ` — rename a registered account in-place. -- Bash + non-zsh shell support for the `cca`/`claude-` aliases (currently zsh-only). -- Auto-detect account from cwd or git remote (deliberately YAGNI for now). -- Single-tenant install path that skips the registry entirely (probably unnecessary — registry with one account is already trivial). +- `ckipper rename ` — rename a registered account in place. +- `ckipper sync-settings` — re-apply `settings-template.json` to existing accounts when the template changes. +- Bash + non-zsh shell support for `cca`/`claude-` (currently zsh-only). +- Auto-detect account from cwd or git remote (deliberately YAGNI). +- Single-tenant install path skipping the registry (registry-with-one-account is already trivial; not needed). +- **Hooks-by-reference** instead of per-account `cp -a` — point each account's `settings.json` at `~/.ckipper/hooks/.sh` directly. Eliminates drift; needs hook protection extended for the shared path. +- **CI** — GitHub Action running `shellcheck` + `zsh -n` + the fixture tests on every PR. Not blocking this PR but should land soon after. +- **Shell completion** for `ckipper` and `cca` (paralleling existing `w` completion). +- **Statusline indicator** for active account so a user with three terminals knows which is which. +- **`aliases.zsh` integrity** — generated file is currently writable by the user; consider hash-verification or moving to `~/.ckipper/lib/aliases.zsh` with stricter perms. +- **Audit trail** — `ckipper list` warns when `oauthAccount.emailAddress` doesn't appear to match the registered name (catches accidental wrong-account registration). +- **Drop the `w` legacy single-account fallback in v2** once everyone has migrated. Currently no fallback exists — Task 14 errors out — but if we ever add one, target it for removal. +- **Multi-machine credential sync** — registered accounts and their Keychain mappings don't sync across hosts. Not a goal for v1. From 07509676e4ec99da005a27b85567d22dc6abf583 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:49:18 -0600 Subject: [PATCH 004/165] Pre-execute polish: prerequisites, Phase 7 user-driven note, doc nit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final additions before handing the plan to executing-plans: - New "Prerequisites" section at top of implementation plan covers starting branch state, required tools (with macOS flock fallback), the GPG-signing-under-sandbox quirk, and the two-deployments rule. - Phase 7 explicitly marked user-driven with an instruction for the executing agent to STOP at the Phase 6.5/7 boundary. - Design doc tool-layout: removed misleading tests/ entry under ~/.ckipper/ — fixtures live in the repo, not the deployed install. --- ...2026-04-27-ckipper-multi-account-design.md | 3 +- ...27-ckipper-multi-account-implementation.md | 37 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-04-27-ckipper-multi-account-design.md b/docs/plans/2026-04-27-ckipper-multi-account-design.md index 3715993..201f20b 100644 --- a/docs/plans/2026-04-27-ckipper-multi-account-design.md +++ b/docs/plans/2026-04-27-ckipper-multi-account-design.md @@ -91,9 +91,10 @@ The sandbox tooling moves out of `~/.claude/docker/` to its own root: aliases.zsh # auto-generated `cca` + `claude-` (sourced by .zshrc; self-contained) accounts.json # the registry, chmod 600 settings-template.json # canonical settings used to seed new account dirs - tests/ # fixture-based regression tests (security dump-keychain sample, etc.) ``` +(Test fixtures like `tests/keychain-dump.sample` live in the *repo*, not in the deployed `~/.ckipper/` — they are dev-time only.) + The shell sources two lines from `.zshrc` (the first is auto-appended by `install.sh`; the second is suggested if the user wants per-account aliases): ```zsh diff --git a/docs/plans/2026-04-27-ckipper-multi-account-implementation.md b/docs/plans/2026-04-27-ckipper-multi-account-implementation.md index 18ec131..65616a3 100644 --- a/docs/plans/2026-04-27-ckipper-multi-account-implementation.md +++ b/docs/plans/2026-04-27-ckipper-multi-account-implementation.md @@ -14,6 +14,39 @@ --- +## Prerequisites — read this before Task 1 + +**Starting repository state (verify before beginning):** + +```bash +git rev-parse --abbrev-ref HEAD # → feature/ckipper-multi-account +git status # working tree clean +ls docs/plans/ # contains the design + this implementation file +``` + +If you are not on `feature/ckipper-multi-account`, stop and ask the user. If the working tree has uncommitted changes, stop and ask. The first two commits on this branch should already be the design doc and the (now-revised) implementation plan — do not re-create them. + +**Required tools on the implementer's machine:** + +- `zsh` (the project's primary shell) +- `bash` (entrypoint, hooks) +- `jq` (1.6 or newer — `jq walk` is used in `sync-hooks`) +- `flock` (registry locking — bundled on Linux; `util-linux` on macOS via Homebrew, BUT macOS ships with a different tool: see fallback note below) +- `shellcheck` +- Docker (for Phase 6.5 onwards) +- `git` (with whatever signing config the user already has — see GPG note below) +- `python3` (for `docker/cleanup-projects.py`) + +**macOS `flock` fallback:** if `flock` is not in PATH, the registry-update helper should fall back to a `mkdir`-based lock: `until mkdir "$CKIPPER_DIR/.registry.lock.d" 2>/dev/null; do sleep 0.05; done; trap 'rmdir "$CKIPPER_DIR/.registry.lock.d"' EXIT INT TERM`. Add this to `_ckipper_registry_update` as a runtime check during Task 9 if `flock` is unavailable. + +**GPG signing under sandboxed Bash:** the implementer's git config has commit signing enabled. Inside Claude Code's default sandbox, `gpg-agent` access fails with "Operation not permitted" — every commit will fail. Each `git commit` in this plan must be invoked with `dangerouslyDisableSandbox: true` (Bash tool parameter). This is environmental, not a code issue. Do not amend commits to skip signing without the user's explicit consent. + +**Two deployments to keep in sync** (already noted in `CLAUDE.md`): this repo (development) and the user's live install (`~/.ckipper/` post-migration). Phases 1–6.5 only touch the repo. Phase 7 deploys to the host. + +**Memory and CLAUDE.md persist across context clears.** Verify by checking that `~/.claude/projects/-Users-matt-Developer-Whmoro-claude-docker-sandbox/memory/MEMORY.md` mentions the Ckipper rename. If it does, the high-level project context is intact even after a fresh start. + +--- + ## Working Conventions - **Branch:** `feature/ckipper-multi-account` (off `develop`). @@ -1588,7 +1621,9 @@ docker image prune -f ## Phase 7 — Local deployment + end-to-end validation -These tasks run on the implementer's actual host. Do not run earlier. +> **For the executing agent:** STOP at the end of Phase 6.5. **Phase 7 is user-driven**, not autonomous. These tasks modify the user's actual `~/.claude` (renaming, registering Keychain entries, opening interactive `/login` flows, requiring two Ghostty windows). The agent cannot meaningfully drive an interactive `/login` or coordinate two terminal windows. Hand control back to the user with a summary of what's done and a pointer to Task 21. +> +> The user runs Phase 7 themselves, in their terminal, following these steps as a checklist. The agent may be re-engaged after Task 25 to help compose the PR body if requested. ### Task 21: Deploy to host From 13b61b053b0f43146e1d0ceb0e0470819a9dafea Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:52:25 -0600 Subject: [PATCH 005/165] Rename project to Ckipper in CLAUDE.md --- CLAUDE.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 467d271..cbc68e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,11 @@ # CLAUDE.md -Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. The `w()` shell function creates a git worktree, launches a Docker container, and runs Claude autonomously inside it. One command to spin up an isolated session on any project. +**Ckipper** (pronounced "skipper") — Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. The `w()` shell function creates a git worktree, launches a Docker container, and runs Claude autonomously inside it. One command to spin up an isolated session on any project. ## Architecture - **`w-function.zsh`** — zsh function that manages worktrees (`git worktree add`), builds/runs Docker containers, extracts macOS Keychain credentials, forwards ports, and detects `.git/config` tampering post-session. Includes tab completion. -- **`w-config.zsh.example`** — Template for user-specific Docker config (ports, volume mounts, env vars). Copied to `~/.claude/docker/w-config.zsh` on first install, never overwritten on updates. +- **`w-config.zsh.example`** — Template for user-specific Docker config (ports, volume mounts, env vars). Copied to `~/.ckipper/docker/w-config.zsh` on first install, never overwritten on updates. - **`docker/Dockerfile`** — `node:24-slim` image with dev tools (git, gh, ripgrep, tmux, Chromium, uv/uvx, bun, Claude Code native installer). Runs as non-root `claude` user. - **`docker/entrypoint.sh`** — Container startup: copies `.claude.json` from read-only staging mount, writes credentials to disk, sets git identity, disables GPG signing via `GIT_CONFIG_COUNT`, authenticates `gh` CLI, optionally enables firewall, creates `bunx` wrapper for statusline colors, runs `npm install` for Linux binaries, clears credential env vars, then runs the provided command (or drops to bash shell if none). - **`hooks/`** — Four Claude Code hooks (registered in `settings-hooks.json`): @@ -33,12 +33,12 @@ Docker-based sandbox for running Claude Code with `--dangerously-skip-permission | `Dockerfile` | `w --rebuild-image` | | `entrypoint.sh` | `w --rebuild-image` (it's `COPY`'d into the image) | | `init-firewall.sh` | `w --rebuild-image` (it's `COPY`'d into the image) | -| `w-function.zsh` | `./install.sh` (copies to `~/.claude/docker/`; user config in `w-config.zsh` is preserved) | -| `w-config.zsh.example` | Template only — user's `~/.claude/docker/w-config.zsh` is never overwritten | -| `hooks/*` | Sync to `~/.claude/hooks/` | +| `w-function.zsh` | `./install.sh` (copies to `~/.ckipper/docker/`; user config in `w-config.zsh` is preserved) | +| `w-config.zsh.example` | Template only — user's `~/.ckipper/docker/w-config.zsh` is never overwritten | +| `hooks/*` | Sync to `~/.ckipper/hooks/` | | `settings-hooks.json` | `./install.sh` (auto-merged into `~/.claude/settings.json`) | -Two copies of the code exist: this repo (development) and deployed files on the host (`~/.claude/docker/`, `~/.claude/hooks/`). Run `./install.sh` to sync all core files. User customizations live in `~/.claude/docker/w-config.zsh` and are never overwritten. +Two copies of the code exist: this repo (development) and deployed files on the host (`~/.ckipper/docker/`, `~/.ckipper/hooks/`). Run `./install.sh` to sync all core files. User customizations live in `~/.ckipper/docker/w-config.zsh` and are never overwritten. ## Testing From 3f922811780b09e694cb251dceadab8a77ef238b Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:53:10 -0600 Subject: [PATCH 006/165] Rename project to Ckipper in README --- README.md | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 94be060..773fe62 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Claude Docker Sandbox +# Ckipper (pronounced "skipper") Docker-based isolation for running Claude Code with `--dangerously-skip-permissions` safely. One command to spin up a sandboxed autonomous Claude session on any project. @@ -124,7 +124,7 @@ Default whitelist: Anthropic API, GitHub, npm, PyPI, Sentry, and common MCP serv | MCPs with local files | node/uvx (mounted ro) | Yes (add mount) | | Docker-based MCPs | Docker-in-Docker | No (security) | -For MCPs that reference local files, add entries to `W_EXTRA_VOLUMES` in `~/.claude/docker/w-config.zsh`. Mount at the exact same host path so MCP configs work unchanged. +For MCPs that reference local files, add entries to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Mount at the exact same host path so MCP configs work unchanged. Two named Docker volumes support uvx-based MCP servers: - **`claude-uv-cache`** — persists the uv package cache (downloaded wheels, git clones) across container restarts @@ -132,6 +132,10 @@ Two named Docker volumes support uvx-based MCP servers: The entrypoint pre-installs uvx-based MCP servers before Claude starts and rewrites the container's `.claude.json` to invoke the installed binary directly. This eliminates the network freshness check and ephemeral venv creation that cause intermittent MCP startup timeouts. +## Migrating from previous versions + + + ## Setup ### Prerequisites @@ -146,14 +150,14 @@ The entrypoint pre-installs uvx-based MCP servers before Claude starts and rewri ```bash # Clone the repo -git clone https://github.com/whmoro/claude-docker-sandbox.git -cd claude-docker-sandbox +git clone https://github.com/whmoro/ckipper.git +cd ckipper # Run the installer (copies all files, merges hooks, adds source line) ./install.sh # Customize your config -# Edit ~/.claude/docker/w-config.zsh with your MCP mounts, ports, etc. +# Edit ~/.ckipper/docker/w-config.zsh with your MCP mounts, ports, etc. # Build the Docker image (takes a few minutes first time) source ~/.zshrc @@ -167,21 +171,21 @@ w test-branch --docker claude Clone the repo, then open Claude Code and paste this prompt: -> Read the README.md in this repo and run `./install.sh`. Then run `source ~/.zshrc && w --rebuild-image` and tell me when it's ready to test. Show me what's in `~/.claude/docker/w-config.zsh` so I can customize it. +> Read the README.md in this repo and run `./install.sh`. Then run `source ~/.zshrc && w --rebuild-image` and tell me when it's ready to test. Show me what's in `~/.ckipper/docker/w-config.zsh` so I can customize it. ### What Gets Installed Where | Source | Destination | Purpose | |---|---|---| -| `docker/Dockerfile` | `~/.claude/docker/Dockerfile` | Docker image definition | -| `docker/entrypoint.sh` | `~/.claude/docker/entrypoint.sh` | Container startup + environment setup | -| `docker/init-firewall.sh` | `~/.claude/docker/init-firewall.sh` | Egress firewall | -| `hooks/protect-claude-config.sh` | `~/.claude/hooks/protect-claude-config.sh` | Edit/Write guard | -| `hooks/bash-guardrails.sh` | `~/.claude/hooks/bash-guardrails.sh` | Bash command guard | -| `hooks/docker-context.sh` | `~/.claude/hooks/docker-context.sh` | Context injection | -| `hooks/notify-bell.sh` | `~/.claude/hooks/notify-bell.sh` | Notification bell | -| `w-function.zsh` | `~/.claude/docker/w-function.zsh` | w() function (sourced by .zshrc) | -| `w-config.zsh.example` | `~/.claude/docker/w-config.zsh` | User config (ports, mounts, env vars) | +| `docker/Dockerfile` | `~/.ckipper/docker/Dockerfile` | Docker image definition | +| `docker/entrypoint.sh` | `~/.ckipper/docker/entrypoint.sh` | Container startup + environment setup | +| `docker/init-firewall.sh` | `~/.ckipper/docker/init-firewall.sh` | Egress firewall | +| `hooks/protect-claude-config.sh` | `~/.ckipper/hooks/protect-claude-config.sh` | Edit/Write guard | +| `hooks/bash-guardrails.sh` | `~/.ckipper/hooks/bash-guardrails.sh` | Bash command guard | +| `hooks/docker-context.sh` | `~/.ckipper/hooks/docker-context.sh` | Context injection | +| `hooks/notify-bell.sh` | `~/.ckipper/hooks/notify-bell.sh` | Notification bell | +| `w-function.zsh` | `~/.ckipper/docker/w-function.zsh` | w() function (sourced by .zshrc) | +| `w-config.zsh.example` | `~/.ckipper/docker/w-config.zsh` | User config (ports, mounts, env vars) | | `settings-hooks.json` | Auto-merged into `~/.claude/settings.json` | Hook registration | ### macOS Keychain Authentication @@ -222,19 +226,19 @@ Edit `docker/init-firewall.sh` → `ALLOWED_DOMAINS` array, then `w --rebuild-im ### Forwarded Ports -Edit `W_PORTS` in `~/.claude/docker/w-config.zsh`. +Edit `W_PORTS` in `~/.ckipper/docker/w-config.zsh`. ### Base Branch -Worktrees are created from `origin/develop`. Search for `develop` in `w-function.zsh` (or `~/.claude/docker/w-function.zsh` if deployed) and change to `main` or your default branch. +Worktrees are created from `origin/develop`. Search for `develop` in `w-function.zsh` (or `~/.ckipper/docker/w-function.zsh` if deployed) and change to `main` or your default branch. ### MCP Mounts -Add entries to `W_EXTRA_VOLUMES` in `~/.claude/docker/w-config.zsh`. Format: `"host_path:container_path:mode"`. +Add entries to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Format: `"host_path:container_path:mode"`. ### Statusline -If you use a custom statusline (like [ccstatusline](https://github.com/sirmalloc/ccstatusline)), add the config and cache mounts to `W_EXTRA_VOLUMES` in `~/.claude/docker/w-config.zsh`: +If you use a custom statusline (like [ccstatusline](https://github.com/sirmalloc/ccstatusline)), add the config and cache mounts to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`: - **Config mount** (`~/.config/ccstatusline`, read-only) — theme, widget layout, powerline settings - **Cache mount** (`~/.cache/ccstatusline`, read-write) — shares usage API cache with host to avoid 429 rate limits @@ -287,7 +291,7 @@ Voice mode requires microphone access, which is unavailable inside the container | Turbo cache permission denied | Entrypoint sets `TURBO_CACHE_DIR`; run `w --rebuild-image` if missing | | Branch already checked out | Switch main repo to different branch: `cd ~/Developer/ && git checkout develop` | | Stale worktree directory | Remove manually: `rm -rf ~/Developer/.worktrees//` | -| Statusline not rendering correctly | Add ccstatusline mounts to `W_EXTRA_VOLUMES` in `~/.claude/docker/w-config.zsh`; ensure `bun` is in the image (`w --rebuild-image`) | +| Statusline not rendering correctly | Add ccstatusline mounts to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`; ensure `bun` is in the image (`w --rebuild-image`) | | `git push` fails (SSH permission denied) | Ensure SSH keys are added to your agent (`ssh-add -l` to check); Docker Desktop forwards the host's SSH agent automatically | | GPG signing issues in container | Handled automatically via `GIT_CONFIG_COUNT` env vars; host config is not modified | | `.env.local` not copied to worktree | Fixed: worktree creation now copies all `.env*` files except `.env.example` | From a109ff5dc4370eb6525ca90f37f5af928278a61e Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:53:40 -0600 Subject: [PATCH 007/165] Rename Docker image tag claude-dev -> ckipper-dev --- w-function.zsh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/w-function.zsh b/w-function.zsh index 70231a5..73e542e 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -7,7 +7,7 @@ # w --docker --firewall Docker + egress firewall # w --list list all worktrees # w --rm remove worktree + delete branch -# w --rebuild-image rebuild claude-dev Docker image +# w --rebuild-image rebuild ckipper-dev Docker image # # is a path relative to ~/Developer (e.g. "Whmoro/orderguard", "my-app") # @@ -37,8 +37,8 @@ _w_build_image() { echo "Dockerfile not found: $docker_dir/Dockerfile" return 1 fi - echo "Building claude-dev Docker image..." - docker build --build-arg "CACHEBUST=$(date +%s)" -t claude-dev "$docker_dir" + echo "Building ckipper-dev Docker image..." + docker build --build-arg "CACHEBUST=$(date +%s)" -t ckipper-dev "$docker_dir" } w() { @@ -266,7 +266,7 @@ else: fi # Ensure Docker image exists - if ! docker image inspect claude-dev > /dev/null 2>&1; then + if ! docker image inspect ckipper-dev > /dev/null 2>&1; then _w_build_image || return 1 fi @@ -375,7 +375,7 @@ else: docker_args+=( --cap-add=NET_ADMIN -e ENABLE_FIREWALL=1 ) fi - docker_args+=( claude-dev ) + docker_args+=( ckipper-dev ) # If "claude" is the command, expand it to the full skip-permissions invocation # and auto-name the session after the worktree branch @@ -411,10 +411,10 @@ else: local exit_code=$? # Post-session: clean up dangling credentials symlink left by tmpfs credential isolation. - # Only remove when no other claude-dev containers are running — parallel sessions + # Only remove when no other ckipper-dev containers are running — parallel sessions # share the ~/.claude bind mount, so deleting the symlink would break their credentials. if [[ -L "$HOME/.claude/.credentials.json" ]]; then - if ! docker ps --filter ancestor=claude-dev --quiet 2>/dev/null | grep -q .; then + if ! docker ps --filter ancestor=ckipper-dev --quiet 2>/dev/null | grep -q .; then rm -f "$HOME/.claude/.credentials.json" fi fi @@ -491,7 +491,7 @@ _w() { _arguments -C \ '(--rm)--list[List all worktrees]' \ '(--list)--rm[Remove a worktree]' \ - '--rebuild-image[Rebuild claude-dev Docker image]' \ + '--rebuild-image[Rebuild ckipper-dev Docker image]' \ '1: :->project' \ '2: :->worktree' \ '3: :->command' \ From c5b0ef875f661162980b64423a4a993fbe5551b2 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:55:20 -0600 Subject: [PATCH 008/165] Deploy Ckipper tooling to ~/.ckipper/ and split settings template --- install.sh | 89 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/install.sh b/install.sh index a10de06..0be0c09 100755 --- a/install.sh +++ b/install.sh @@ -1,10 +1,11 @@ #!/bin/bash set -e -echo "=== Claude Docker Sandbox Installer ===" +echo "=== Ckipper Installer ===" echo "" REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" # 1. Check prerequisites echo "Checking prerequisites..." @@ -29,82 +30,86 @@ echo " All prerequisites found." echo "" # 2. Copy Docker files -echo "Copying Docker files to ~/.claude/docker/..." -mkdir -p "$HOME/.claude/docker" -cp "$REPO_DIR/docker/Dockerfile" "$HOME/.claude/docker/" -cp "$REPO_DIR/docker/entrypoint.sh" "$HOME/.claude/docker/" -cp "$REPO_DIR/docker/init-firewall.sh" "$HOME/.claude/docker/" -chmod +x "$HOME/.claude/docker/entrypoint.sh" -chmod +x "$HOME/.claude/docker/init-firewall.sh" +echo "Copying Docker files to $CKIPPER_DIR/docker/..." +mkdir -p "$CKIPPER_DIR/docker" +cp "$REPO_DIR/docker/Dockerfile" "$CKIPPER_DIR/docker/" +cp "$REPO_DIR/docker/entrypoint.sh" "$CKIPPER_DIR/docker/" +cp "$REPO_DIR/docker/init-firewall.sh" "$CKIPPER_DIR/docker/" +chmod +x "$CKIPPER_DIR/docker/entrypoint.sh" +chmod +x "$CKIPPER_DIR/docker/init-firewall.sh" -# 3. Copy hooks -echo "Copying hooks to ~/.claude/hooks/..." -mkdir -p "$HOME/.claude/hooks" -cp "$REPO_DIR/hooks/protect-claude-config.sh" "$HOME/.claude/hooks/" -cp "$REPO_DIR/hooks/bash-guardrails.sh" "$HOME/.claude/hooks/" -cp "$REPO_DIR/hooks/docker-context.sh" "$HOME/.claude/hooks/" -cp "$REPO_DIR/hooks/notify-bell.sh" "$HOME/.claude/hooks/" -chmod +x "$HOME/.claude/hooks/protect-claude-config.sh" -chmod +x "$HOME/.claude/hooks/bash-guardrails.sh" -chmod +x "$HOME/.claude/hooks/docker-context.sh" -chmod +x "$HOME/.claude/hooks/notify-bell.sh" +# 3. Copy hooks (canonical source for ckipper sync-hooks) +echo "Copying hooks to $CKIPPER_DIR/hooks/..." +mkdir -p "$CKIPPER_DIR/hooks" +cp "$REPO_DIR/hooks/protect-claude-config.sh" "$CKIPPER_DIR/hooks/" +cp "$REPO_DIR/hooks/bash-guardrails.sh" "$CKIPPER_DIR/hooks/" +cp "$REPO_DIR/hooks/docker-context.sh" "$CKIPPER_DIR/hooks/" +cp "$REPO_DIR/hooks/notify-bell.sh" "$CKIPPER_DIR/hooks/" +chmod +x "$CKIPPER_DIR/hooks/protect-claude-config.sh" +chmod +x "$CKIPPER_DIR/hooks/bash-guardrails.sh" +chmod +x "$CKIPPER_DIR/hooks/docker-context.sh" +chmod +x "$CKIPPER_DIR/hooks/notify-bell.sh" # 4. Copy w-function.zsh -echo "Copying w-function.zsh to ~/.claude/docker/..." -cp "$REPO_DIR/w-function.zsh" "$HOME/.claude/docker/" +echo "Copying w-function.zsh to $CKIPPER_DIR/docker/..." +cp "$REPO_DIR/w-function.zsh" "$CKIPPER_DIR/docker/" -# 5. Generate w-config.zsh (only if it doesn't exist — never overwrite) -config_file="$HOME/.claude/docker/w-config.zsh" +# 5. Generate w-config.zsh (only if it doesn't exist — never overwrite user customizations) +# Also preserve accounts.json and aliases.zsh if they already exist (managed by ckipper CLI). +config_file="$CKIPPER_DIR/docker/w-config.zsh" if [[ ! -f "$config_file" ]]; then cp "$REPO_DIR/w-config.zsh.example" "$config_file" echo " Created w-config.zsh with defaults — edit to add your MCP mounts, ports, etc." else echo " w-config.zsh already exists (not overwritten)" fi +[[ -f "$CKIPPER_DIR/accounts.json" ]] && echo " accounts.json already exists (not overwritten — managed by ckipper)" +[[ -f "$CKIPPER_DIR/aliases.zsh" ]] && echo " aliases.zsh already exists (not overwritten — auto-generated)" -# 6. Merge settings-hooks.json into ~/.claude/settings.json -echo "Merging hooks into ~/.claude/settings.json..." -settings_file="$HOME/.claude/settings.json" -if [[ ! -f "$settings_file" ]]; then - echo '{}' > "$settings_file" -fi -hooks_json=$(jq 'del(._comment)' "$REPO_DIR/settings-hooks.json") -jq --argjson hooks "$hooks_json" '. * $hooks' "$settings_file" > "${settings_file}.tmp" \ - && mv "${settings_file}.tmp" "$settings_file" -echo " Hooks merged." +# 6. Deploy settings-template.json (consumed by ckipper add / sync-hooks per-account) +echo "Copying settings-template.json to $CKIPPER_DIR/..." +cp "$REPO_DIR/settings-hooks.json" "$CKIPPER_DIR/settings-template.json" +echo " Settings template deployed. ckipper sync-hooks applies it per-account." # 7. Add source line to .zshrc (if not already present) -if ! grep -q 'w-function.zsh' "$HOME/.zshrc" 2>/dev/null; then +if ! grep -q 'ckipper/docker/w-function.zsh\|w-function.zsh' "$HOME/.zshrc" 2>/dev/null; then echo '' >> "$HOME/.zshrc" - echo '# Worktree Manager (w function)' >> "$HOME/.zshrc" - echo 'source "$HOME/.claude/docker/w-function.zsh"' >> "$HOME/.zshrc" + echo '# Ckipper — Worktree Manager (w function)' >> "$HOME/.zshrc" + echo 'source "$HOME/.ckipper/docker/w-function.zsh"' >> "$HOME/.zshrc" echo " Added w() source line to ~/.zshrc" else echo " ~/.zshrc already sources w-function.zsh" fi -# 8. Warn about inlined w() from old installs +# 8. Print (do not auto-append) the optional aliases.zsh source line +echo "" +echo "Optional: enable per-account aliases (claude-, cca ) by adding to ~/.zshrc:" +echo " [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh" +echo "" + +# 9. Warn about inlined w() from old installs if grep -q '^w()' "$HOME/.zshrc" 2>/dev/null || grep -q '^_w_build_image()' "$HOME/.zshrc" 2>/dev/null; then echo "" echo "WARNING: Your ~/.zshrc contains an inlined w() function from a previous install." - echo "The new approach sources it from ~/.claude/docker/w-function.zsh instead." + echo "The new approach sources it from $CKIPPER_DIR/docker/w-function.zsh instead." echo "Please remove the old inlined function from ~/.zshrc manually." echo "(Search for '_w_build_image()' or 'w()' and remove everything through the 'COMPEOF' line)" fi -# 9. Set up git hooks path +# 10. Set up git hooks path echo "Configuring git hooks path..." mkdir -p "$HOME/.git-hooks" git config --global core.hooksPath "$HOME/.git-hooks" -# 10. Print summary +# 11. Print summary echo "" echo "=== Setup Complete ===" echo "" echo "Next steps:" -echo " 1. Edit ~/.claude/docker/w-config.zsh with your MCP mounts, ports, etc." +echo " 1. Edit $CKIPPER_DIR/docker/w-config.zsh with your MCP mounts, ports, etc." echo " 2. source ~/.zshrc" echo " 3. w --rebuild-image" -echo " 4. w test-branch --docker claude" +echo " 4. ckipper add # register an account" +echo " 5. w test-branch --docker claude" echo "" echo "To update later: git pull && ./install.sh" From 114ba9f2a11aadfdfd84ee8bc5ff886ed0518838 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:55:47 -0600 Subject: [PATCH 009/165] Source w-function from ~/.ckipper/ instead of ~/.claude/docker/ --- w-function.zsh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/w-function.zsh b/w-function.zsh index 73e542e..f840583 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -12,7 +12,7 @@ # is a path relative to ~/Developer (e.g. "Whmoro/orderguard", "my-app") # # ── CUSTOMIZATION ──────────────────────────────────────────────── -# Edit ~/.claude/docker/w-config.zsh to customize: +# Edit ~/.ckipper/docker/w-config.zsh to customize: # - W_PORTS: dev server ports to forward # - W_EXTRA_VOLUMES: MCP server mounts and other volume mounts # - W_EXTRA_ENV: extra environment variables for the container @@ -22,7 +22,7 @@ # ───────────────────────────────────────────────────────────────── # Source user config (ports, extra volumes, extra env vars) -_w_config="$HOME/.claude/docker/w-config.zsh" +_w_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" if [[ -f "$_w_config" ]]; then source "$_w_config" fi @@ -32,7 +32,7 @@ fi (( ${#W_EXTRA_ENV[@]} == 0 )) && W_EXTRA_ENV=() _w_build_image() { - local docker_dir="$HOME/.claude/docker" + local docker_dir="${CKIPPER_DIR:-$HOME/.ckipper}/docker" if [[ ! -f "$docker_dir/Dockerfile" ]]; then echo "Dockerfile not found: $docker_dir/Dockerfile" return 1 From 19b84a832db93be8c770fd90e37789111b9cdee1 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 16:56:18 -0600 Subject: [PATCH 010/165] install.sh: migrate legacy ~/.claude/docker/ to ~/.ckipper/ --- install.sh | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 0be0c09..2dd18a0 100755 --- a/install.sh +++ b/install.sh @@ -7,6 +7,29 @@ echo "" REPO_DIR="$(cd "$(dirname "$0")" && pwd)" CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" +# Migrate legacy ~/.claude/docker/ layout if present (idempotent) +LEGACY_DIR="$HOME/.claude/docker" +if [ -d "$LEGACY_DIR" ] && [ ! -d "$CKIPPER_DIR" ]; then + echo "Migrating ~/.claude/docker/ -> $CKIPPER_DIR/" + mkdir -p "$CKIPPER_DIR" + cp -a "$LEGACY_DIR/." "$CKIPPER_DIR/" + echo "Migrated. The legacy directory is left intact at $LEGACY_DIR for one release cycle." + echo "After verifying the new location works (ckipper list shows your accounts):" + echo " rm -rf $LEGACY_DIR" + + # Sweep migrated w-config.zsh for stale path strings (warn only — never auto-edit user config) + if [ -f "$CKIPPER_DIR/w-config.zsh" ]; then + stale=$(grep -n "\.claude/docker" "$CKIPPER_DIR/w-config.zsh" 2>/dev/null || true) + if [ -n "$stale" ]; then + echo "" + echo "WARNING: Your migrated w-config.zsh contains stale ~/.claude/docker/ paths:" + echo "$stale" + echo "Update these to ~/.ckipper/docker/ manually." + echo "" + fi + fi +fi + # 1. Check prerequisites echo "Checking prerequisites..." missing=() @@ -71,14 +94,17 @@ echo "Copying settings-template.json to $CKIPPER_DIR/..." cp "$REPO_DIR/settings-hooks.json" "$CKIPPER_DIR/settings-template.json" echo " Settings template deployed. ckipper sync-hooks applies it per-account." -# 7. Add source line to .zshrc (if not already present) -if ! grep -q 'ckipper/docker/w-function.zsh\|w-function.zsh' "$HOME/.zshrc" 2>/dev/null; then +# 7. Add or update source line in .zshrc +if grep -q "source.*\.claude/docker/w-function.zsh" "$HOME/.zshrc" 2>/dev/null; then + sed -i.bak 's|source.*\.claude/docker/w-function\.zsh|source ~/.ckipper/docker/w-function.zsh|' "$HOME/.zshrc" + echo " Updated ~/.zshrc source line to ~/.ckipper/. Backup at ~/.zshrc.bak." +elif ! grep -q 'ckipper/docker/w-function.zsh' "$HOME/.zshrc" 2>/dev/null; then echo '' >> "$HOME/.zshrc" echo '# Ckipper — Worktree Manager (w function)' >> "$HOME/.zshrc" echo 'source "$HOME/.ckipper/docker/w-function.zsh"' >> "$HOME/.zshrc" echo " Added w() source line to ~/.zshrc" else - echo " ~/.zshrc already sources w-function.zsh" + echo " ~/.zshrc already sources ~/.ckipper/docker/w-function.zsh" fi # 8. Print (do not auto-append) the optional aliases.zsh source line From 4d75bee95199e1d561c6ddb0b051f6af8a698cd6 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:03:05 -0600 Subject: [PATCH 011/165] Add ckipper CLI scaffold with per-subcommand help --- ckipper.zsh | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ install.sh | 5 ++-- w-function.zsh | 4 +++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 ckipper.zsh diff --git a/ckipper.zsh b/ckipper.zsh new file mode 100644 index 0000000..48b6d0a --- /dev/null +++ b/ckipper.zsh @@ -0,0 +1,72 @@ +# Ckipper (pronounced "skipper") — multi-account Claude Code manager +# Sourced by w-function.zsh + +CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" +CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" +CKIPPER_REGISTRY_VERSION=1 + +ckipper() { + local cmd="$1" + shift 2>/dev/null + case "$cmd" in + # --help on any subcommand short-circuits to subcommand help + add|list|default|remove|sync-hooks|migrate) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_help_for "$cmd" + return 0 + fi + "_ckipper_${cmd//-/_}" "$@" + ;; + ""|help|-h|--help) _ckipper_help ;; + *) echo "Unknown command: $cmd"; _ckipper_help; return 1 ;; + esac +} + +_ckipper_help() { + cat <<'EOF' +ckipper (pronounced "skipper") — multi-account Claude Code manager + +Usage: + ckipper add Register a new account (interactive /login) + ckipper add --adopt Register an existing populated config dir + ckipper list Show registered accounts + ckipper default Set the default account + ckipper remove Unregister (does not delete the dir) + ckipper sync-hooks Copy hooks into all registered accounts + ckipper migrate One-time migration from legacy layout + +Companion commands (sourced via aliases.zsh): + cca [args...] Run claude with account (one-off) + claude- [args...] Auto-generated alias per registered account + +Run `ckipper --help` for per-subcommand details. +EOF +} + +_ckipper_help_for() { + case "$1" in + add) + cat <<'EOF' +ckipper add [--adopt] + +Register a new account. must match ^[a-z0-9_-]+$. + +Without --adopt: creates ~/.claude-/ and walks you through /login. +With --adopt: registers an existing populated ~/.claude-/ directory. +EOF + ;; + list) echo "ckipper list — print registered accounts, default, and last-login email." ;; + default) echo "ckipper default — set the default account used when no flag/env is provided." ;; + remove) echo "ckipper remove — unregister. Does not delete the dir or Keychain entry." ;; + sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; + migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; + esac +} + +# Stubs — implemented in subsequent tasks +_ckipper_add() { echo "ckipper add: not yet implemented"; return 1; } +_ckipper_list() { echo "ckipper list: not yet implemented"; return 1; } +_ckipper_default() { echo "ckipper default: not yet implemented"; return 1; } +_ckipper_remove() { echo "ckipper remove: not yet implemented"; return 1; } +_ckipper_sync_hooks() { echo "ckipper sync-hooks: not yet implemented"; return 1; } +_ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } diff --git a/install.sh b/install.sh index 2dd18a0..d14f6ae 100755 --- a/install.sh +++ b/install.sh @@ -73,9 +73,10 @@ chmod +x "$CKIPPER_DIR/hooks/bash-guardrails.sh" chmod +x "$CKIPPER_DIR/hooks/docker-context.sh" chmod +x "$CKIPPER_DIR/hooks/notify-bell.sh" -# 4. Copy w-function.zsh -echo "Copying w-function.zsh to $CKIPPER_DIR/docker/..." +# 4. Copy w-function.zsh and ckipper.zsh +echo "Copying w-function.zsh and ckipper.zsh to $CKIPPER_DIR/docker/..." cp "$REPO_DIR/w-function.zsh" "$CKIPPER_DIR/docker/" +cp "$REPO_DIR/ckipper.zsh" "$CKIPPER_DIR/docker/" # 5. Generate w-config.zsh (only if it doesn't exist — never overwrite user customizations) # Also preserve accounts.json and aliases.zsh if they already exist (managed by ckipper CLI). diff --git a/w-function.zsh b/w-function.zsh index f840583..ada0daf 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -546,3 +546,7 @@ _w() { _w "$@" COMPEOF fi + +# Source ckipper subcommand dispatcher (if deployed) +[[ -f "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" ]] && \ + source "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" From 49771d16f3a7066a421d3b49061b3a2fa18887dc Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:03:38 -0600 Subject: [PATCH 012/165] Implement ckipper list --- ckipper.zsh | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/ckipper.zsh b/ckipper.zsh index 48b6d0a..638ce87 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -65,7 +65,31 @@ EOF # Stubs — implemented in subsequent tasks _ckipper_add() { echo "ckipper add: not yet implemented"; return 1; } -_ckipper_list() { echo "ckipper list: not yet implemented"; return 1; } +_ckipper_list() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + echo "No accounts registered. Run: ckipper add " + return 0 + fi + local default + default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + echo "Registered accounts:" + jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ + while IFS=$'\t' read -r name dir; do + local marker=" " + [[ "$name" == "$default" ]] && marker="* " + local email="" + if [[ -f "$dir/.claude.json" ]]; then + email=$(jq -r '.oauthAccount.emailAddress // ""' "$dir/.claude.json" 2>/dev/null) + fi + local exists="(missing)" + [[ -d "$dir" ]] && exists="" + echo "$marker$name $dir ${email:+($email)} $exists" + done + echo "" + echo "* = default. Run: ckipper default " + echo "" + echo "Reminder: do not run the same account in two sessions concurrently — see #24317." +} _ckipper_default() { echo "ckipper default: not yet implemented"; return 1; } _ckipper_remove() { echo "ckipper remove: not yet implemented"; return 1; } _ckipper_sync_hooks() { echo "ckipper sync-hooks: not yet implemented"; return 1; } From ed298b0fd525106f601aac55d3b2f0b2ad1079cb Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:05:18 -0600 Subject: [PATCH 013/165] Implement ckipper add: validated keychain shape, atomic registry, fixture test --- ckipper.zsh | 206 ++++++++++++++++++++++++++++++++++++- tests/keychain-dump.sample | 21 ++++ 2 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 tests/keychain-dump.sample diff --git a/ckipper.zsh b/ckipper.zsh index 638ce87..b1871f5 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -63,8 +63,210 @@ EOF esac } -# Stubs — implemented in subsequent tasks -_ckipper_add() { echo "ckipper add: not yet implemented"; return 1; } +# Validates a keychain_service name before passing to `security`. +# Accepts "Claude Code-credentials" optionally followed by "-". +_ckipper_validate_keychain_service() { + local svc="$1" + [[ -z "$svc" ]] && return 1 + [[ "$svc" =~ ^Claude\ Code-credentials(-[a-f0-9]+)?$ ]] +} + +_ckipper_keychain_snapshot() { + # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. + [[ "$OSTYPE" != darwin* ]] && return 0 + + # Fail loudly if keychain is locked (timeout protects against GUI prompt blocking). + local out + if ! out=$(timeout 10 security dump-keychain 2>/dev/null); then + echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 + return 1 + fi + + printf '%s\n' "$out" | \ + awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ + sort -u +} + +# Atomic registry write under flock. $1 = jq filter; remaining args are jq args (e.g. --arg). +_ckipper_registry_update() { + local jq_filter="$1"; shift + local lock="$CKIPPER_DIR/.registry.lock" + mkdir -p "$CKIPPER_DIR" + : > "$lock" + if command -v flock >/dev/null 2>&1; then + { + flock -x 9 + local tmp; tmp=$(mktemp) + jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + } 9>"$lock" + else + # Fallback for systems without flock (older macOS): mkdir-based lock. + local lockdir="$CKIPPER_DIR/.registry.lock.d" + until mkdir "$lockdir" 2>/dev/null; do sleep 0.05; done + trap 'rmdir "$lockdir" 2>/dev/null' EXIT INT TERM + local tmp; tmp=$(mktemp) + jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + rmdir "$lockdir" 2>/dev/null + trap - EXIT INT TERM + fi +} + +# Initialize an empty registry with version field. +_ckipper_init_registry() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + mkdir -p "$CKIPPER_DIR" + cat > "$CKIPPER_REGISTRY" <&2 + return 1 + fi +} + +_ckipper_add() { + _ckipper_check_registry_version || return 1 + local name="$1" adopt=0 + [[ "$2" == "--adopt" ]] && adopt=1 + if [[ -z "$name" ]]; then + echo "Usage: ckipper add [--adopt]" + return 1 + fi + if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then + echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." + return 1 + fi + + _ckipper_init_registry + + if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is already registered." + return 1 + fi + + local dir="$HOME/.claude-$name" + + if [[ $adopt -eq 1 ]]; then + if [[ ! -d "$dir" ]]; then + echo "Cannot adopt: $dir does not exist." + return 1 + fi + # In adopt mode, list candidate Keychain entries and let the user pick (or skip). + local picked="" + if [[ "$OSTYPE" == darwin* ]]; then + local candidates + candidates=$(_ckipper_keychain_snapshot) || return 1 + if [[ -n "$candidates" ]]; then + echo "Candidate Keychain entries:" + echo "$candidates" | nl + read -r "?Pick a number (or empty to skip): " idx + if [[ -n "$idx" ]]; then + picked=$(echo "$candidates" | sed -n "${idx}p") + if [[ -n "$picked" ]] && ! _ckipper_validate_keychain_service "$picked"; then + echo "Invalid Keychain service shape: $picked" + return 1 + fi + fi + fi + fi + _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" + return $? + fi + + # Fresh registration + if [[ -d "$dir" ]]; then + echo "Directory $dir already exists. Use --adopt to register it." + return 1 + fi + mkdir -p "$dir/hooks" + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then + cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" + fi + + local before_snapshot + before_snapshot=$(_ckipper_keychain_snapshot) || return 1 + + cat <" diff --git a/tests/keychain-dump.sample b/tests/keychain-dump.sample new file mode 100644 index 0000000..6d8c710 --- /dev/null +++ b/tests/keychain-dump.sample @@ -0,0 +1,21 @@ +keychain: "/Users//Library/Keychains/login.keychain-db" +class: "genp" +attributes: + 0x00000007 ="GitHub" + "acct"="" + "cdat"=0x32303234303130313030303030305A00 "20240101000000Z\000" + "svce"="GitHub Desktop" +keychain: "/Users//Library/Keychains/login.keychain-db" +class: "genp" +attributes: + 0x00000007 ="Claude Code-credentials" + "acct"="user@example.com" + "cdat"=0x32303235303130313030303030305A00 "20250101000000Z\000" + "svce"="Claude Code-credentials" +keychain: "/Users//Library/Keychains/login.keychain-db" +class: "genp" +attributes: + 0x00000007 ="Slack" + "acct"="" + "cdat"=0x32303234303130313030303030305A00 "20240101000000Z\000" + "svce"="com.tinyspeck.slackmacgap" From c3c70fd46b7dc33757ff7f6db7bef1cd129b5b16 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:13:12 -0600 Subject: [PATCH 014/165] ckipper: testable OSTYPE override and registry-local temp file --- ckipper.zsh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index b1871f5..ff1bf6a 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -73,7 +73,7 @@ _ckipper_validate_keychain_service() { _ckipper_keychain_snapshot() { # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. - [[ "$OSTYPE" != darwin* ]] && return 0 + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 # Fail loudly if keychain is locked (timeout protects against GUI prompt blocking). local out @@ -96,7 +96,7 @@ _ckipper_registry_update() { if command -v flock >/dev/null 2>&1; then { flock -x 9 - local tmp; tmp=$(mktemp) + local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" chmod 600 "$CKIPPER_REGISTRY" } 9>"$lock" @@ -105,7 +105,7 @@ _ckipper_registry_update() { local lockdir="$CKIPPER_DIR/.registry.lock.d" until mkdir "$lockdir" 2>/dev/null; do sleep 0.05; done trap 'rmdir "$lockdir" 2>/dev/null' EXIT INT TERM - local tmp; tmp=$(mktemp) + local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" chmod 600 "$CKIPPER_REGISTRY" rmdir "$lockdir" 2>/dev/null @@ -164,7 +164,7 @@ _ckipper_add() { fi # In adopt mode, list candidate Keychain entries and let the user pick (or skip). local picked="" - if [[ "$OSTYPE" == darwin* ]]; then + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then local candidates candidates=$(_ckipper_keychain_snapshot) || return 1 if [[ -n "$candidates" ]]; then From 5183afe7d4b7a5490bb5b0a766b4697b4c22c7b1 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:13:38 -0600 Subject: [PATCH 015/165] Implement ckipper default and remove with quote safety --- ckipper.zsh | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index ff1bf6a..2ee09b3 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -292,7 +292,37 @@ _ckipper_list() { echo "" echo "Reminder: do not run the same account in two sessions concurrently — see #24317." } -_ckipper_default() { echo "ckipper default: not yet implemented"; return 1; } -_ckipper_remove() { echo "ckipper remove: not yet implemented"; return 1; } +_ckipper_default() { + _ckipper_check_registry_version || return 1 + local name="$1" + [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is not registered." + return 1 + fi + _ckipper_registry_update '.default = $n' --arg n "$name" + echo "Default account is now '$name'." +} + +_ckipper_remove() { + _ckipper_check_registry_version || return 1 + local name="$1" + [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is not registered." + return 1 + fi + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + _ckipper_registry_update 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' --arg n "$name" + _ckipper_regenerate_aliases + echo "Unregistered '$name'." + echo "" + echo "The directory and Keychain entry were not deleted. To remove them manually:" + printf " rm -rf %q\n" "$dir" + if [[ -n "$service" ]]; then + printf " security delete-generic-password -s %q\n" "$service" + fi +} _ckipper_sync_hooks() { echo "ckipper sync-hooks: not yet implemented"; return 1; } _ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } From 56c5792636d4b5631c2eb52c6960123b95d8bc9a Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:15:03 -0600 Subject: [PATCH 016/165] Auto-generate self-contained aliases.zsh with cca dispatcher --- ckipper.zsh | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index 2ee09b3..7b07585 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -263,8 +263,36 @@ _ckipper_finalize_registration() { echo "Use it via: claude-$name or cca $name" } -# Stubs — implemented in subsequent tasks (12, 13) -_ckipper_regenerate_aliases() { :; } +_ckipper_regenerate_aliases() { + local out="$CKIPPER_DIR/aliases.zsh" + local _name _dir + { + echo "# Auto-generated by ckipper. Do not edit by hand." + echo "# Self-contained: does not depend on ckipper.zsh or w-function.zsh being sourced." + echo "# Regenerated whenever an account is added or removed." + echo "" + echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" + echo "" + echo "cca() {" + echo " local name=\"\$1\"; shift" + echo " if [[ -z \"\$name\" ]]; then echo \"Usage: cca [args...]\"; return 1; fi" + echo " local dir" + echo " dir=\$(jq -r --arg n \"\$name\" '.accounts[\$n].config_dir // empty' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" + echo " if [[ -z \"\$dir\" ]]; then echo \"Unknown account: \$name. Run: ckipper list\"; return 1; fi" + echo " CLAUDE_CONFIG_DIR=\"\$dir\" command claude \"\$@\"" + echo "}" + echo "" + if [[ -f "$CKIPPER_REGISTRY" ]]; then + jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ + while IFS=$'\t' read -r _name _dir; do + echo "claude-$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" + done + fi + } > "$out" + chmod 644 "$out" +} + +# Stub — implemented in Task 13. _ckipper_sync_hooks_for() { :; } _ckipper_list() { From 276ca2d691fdd2369f98401b0f9f677a0a18857d Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:18:20 -0600 Subject: [PATCH 017/165] Implement ckipper sync-hooks; document template seed-only stance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jq walk regex anchors on $HOME/ so the substitution replaces the full prefix (template uses "$HOME/.claude/hooks/...") instead of producing $HOME/hooks/... — fixes a concatenation bug in the plan's regex. --- ckipper.zsh | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index 7b07585..34b5657 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -292,8 +292,42 @@ _ckipper_regenerate_aliases() { chmod 644 "$out" } -# Stub — implemented in Task 13. -_ckipper_sync_hooks_for() { :; } +_ckipper_sync_hooks_for() { + local name="$1" + _ckipper_check_registry_version || return 1 + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + [[ -z "$dir" || "$dir" == "null" ]] && return 1 + mkdir -p "$dir/hooks" + cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true + + # Rewrite settings.json hook paths to absolute paths under this account dir. + # Consumes the entire prefix (`$HOME/.claude/`, `$HOME/.claude-/`, or `$HOME/.ckipper/`) + # plus `hooks/` so we don't end up with `$HOME/hooks/...` after substitution. + if [[ -f "$dir/settings.json" ]] && command -v jq &>/dev/null; then + local tmp; tmp=$(mktemp "$dir/.settings.tmp.XXXXXX") + jq --arg d "$dir" ' + (.hooks // {}) as $h | + .hooks = ($h | walk( + if type == "string" and test("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/") + then sub("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/"; "\($d)/hooks/") + else . end + )) + ' "$dir/settings.json" > "$tmp" && mv "$tmp" "$dir/settings.json" + fi +} + +_ckipper_sync_hooks() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + echo "No accounts registered." + return 0 + fi + _ckipper_check_registry_version || return 1 + local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") + while IFS= read -r name; do + echo "Syncing hooks → $name" + _ckipper_sync_hooks_for "$name" + done <<< "$names" +} _ckipper_list() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then @@ -352,5 +386,4 @@ _ckipper_remove() { printf " security delete-generic-password -s %q\n" "$service" fi } -_ckipper_sync_hooks() { echo "ckipper sync-hooks: not yet implemented"; return 1; } _ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } From 81cc64d658a47817267c4a074f267356ee559bd0 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:19:32 -0600 Subject: [PATCH 018/165] w: resolve active account from --account, env, or registered default --- w-function.zsh | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/w-function.zsh b/w-function.zsh index ada0daf..6a48b0e 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -41,6 +41,26 @@ _w_build_image() { docker build --build-arg "CACHEBUST=$(date +%s)" -t ckipper-dev "$docker_dir" } +_w_resolve_account() { + local cli_account="$1" + if [[ -n "$cli_account" ]]; then + echo "$cli_account"; return 0 + fi + if [[ -n "$CLAUDE_CONFIG_DIR" && -f "$CKIPPER_REGISTRY" ]]; then + local matched + matched=$(jq -r --arg d "$CLAUDE_CONFIG_DIR" \ + '.accounts | to_entries[] | select(.value.config_dir == $d) | .key' \ + "$CKIPPER_REGISTRY" | head -1) + [[ -n "$matched" ]] && { echo "$matched"; return 0; } + fi + if [[ -f "$CKIPPER_REGISTRY" ]]; then + local default + default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + [[ -n "$default" ]] && { echo "$default"; return 0; } + fi + return 0 +} + w() { local projects_dir="$HOME/Developer" local worktrees_dir="$HOME/Developer/.worktrees" @@ -131,12 +151,14 @@ if wt_path in d.get('projects', {}): # Parse flags local docker_mode=0 local firewall_mode=0 + local cli_account="" local command=() while [[ $# -gt 0 ]]; do case "$1" in - --docker) docker_mode=1; shift ;; + --docker) docker_mode=1; shift ;; --firewall) firewall_mode=1; shift ;; - *) command+=("$1"); shift ;; + --account) cli_account="$2"; shift 2 ;; + *) command+=("$1"); shift ;; esac done @@ -148,8 +170,9 @@ if wt_path in d.get('projects', {}): echo " w --rebuild-image" echo "" echo "Flags:" - echo " --docker Run in Docker container (shell by default, or specify command)" - echo " --firewall Add egress firewall (only with --docker)" + echo " --docker Run in Docker container (shell by default, or specify command)" + echo " --firewall Add egress firewall (only with --docker)" + echo " --account Use a specific Ckipper account (default: registered default or \$CLAUDE_CONFIG_DIR)" echo "" echo "Examples:" echo " w myorg/app feature --docker # shell in container" @@ -164,6 +187,23 @@ if wt_path in d.get('projects', {}): return 1 fi + # Resolve active Ckipper account (no legacy fallback — error if none). + local active_account + active_account=$(_w_resolve_account "$cli_account") + if [[ -z "$active_account" ]]; then + echo "Error: no account selected and no default registered." + echo "Run: ckipper list (then: ckipper default , or pass --account )" + return 1 + fi + local active_config_dir + active_config_dir=$(jq -r --arg n "$active_account" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY" 2>/dev/null) + local active_keychain_service + active_keychain_service=$(jq -r --arg n "$active_account" '.accounts[$n].keychain_service // empty' "$CKIPPER_REGISTRY" 2>/dev/null) + if [[ -z "$active_config_dir" ]]; then + echo "Error: account '$active_account' is not registered. Run: ckipper list" + return 1 + fi + if [[ ! -d "$projects_dir/$project" ]]; then echo "Project not found: $projects_dir/$project" return 1 @@ -492,6 +532,7 @@ _w() { '(--rm)--list[List all worktrees]' \ '(--list)--rm[Remove a worktree]' \ '--rebuild-image[Rebuild ckipper-dev Docker image]' \ + '--account[Ckipper account to use]:account name:' \ '1: :->project' \ '2: :->worktree' \ '3: :->command' \ From 241a33116e621251fe7201e05089601a368a5fe0 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:21:28 -0600 Subject: [PATCH 019/165] w: thread active account through Docker; extract registry-driven cleanup --- docker/cleanup-projects.py | 76 +++++++++++++++++++++++++++++++ w-function.zsh | 93 ++++++++++++++------------------------ 2 files changed, 111 insertions(+), 58 deletions(-) create mode 100755 docker/cleanup-projects.py diff --git a/docker/cleanup-projects.py b/docker/cleanup-projects.py new file mode 100755 index 0000000..a07519f --- /dev/null +++ b/docker/cleanup-projects.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Remove or sync a worktree path entry from per-account .claude.json files.""" +import json +import os +import sys + + +def all_account_dirs(registry): + if not os.path.exists(registry): + return [] + with open(registry) as f: + d = json.load(f) + return [a["config_dir"] for a in d.get("accounts", {}).values() if a.get("config_dir")] + + +def remove_worktree_from_all(registry, wt_path): + seen = set() + for cfg_dir in all_account_dirs(registry): + cfg = os.path.join(cfg_dir, ".claude.json") + cfg_real = os.path.realpath(cfg) + if cfg_real in seen: + continue + seen.add(cfg_real) + if not os.path.exists(cfg): + continue + with open(cfg) as f: + d = json.load(f) + if wt_path in d.get("projects", {}): + del d["projects"][wt_path] + with open(cfg, "w") as f: + json.dump(d, f) + print(f"Removed worktree entry from {cfg}") + + +def sync_worktree_settings(registry, account_name, main_path, wt_path): + if not os.path.exists(registry): + return + with open(registry) as f: + d = json.load(f) + acc = d.get("accounts", {}).get(account_name) + if not acc: + return + cfg = os.path.join(acc["config_dir"], ".claude.json") + if not os.path.exists(cfg): + return + with open(cfg) as f: + cd = json.load(f) + main = cd.get("projects", {}).get(main_path, {}) + if not main: + return + keys = [ + "disabledMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "allowedTools", + "hasTrustDialogAccepted", + "hasClaudeMdExternalIncludesApproved", + "hasClaudeMdExternalIncludesWarningShown", + "hasCompletedProjectOnboarding", + ] + wt = cd.setdefault("projects", {}).setdefault(wt_path, {}) + for k in keys: + if k in main: + wt[k] = main[k] + with open(cfg, "w") as f: + json.dump(cd, f) + print(f"Synced settings for {wt_path} in {cfg}") + + +if __name__ == "__main__": + cmd = sys.argv[1] + registry = os.environ.get("CKIPPER_REGISTRY", os.path.expanduser("~/.ckipper/accounts.json")) + if cmd == "remove": + remove_worktree_from_all(registry, sys.argv[2]) + elif cmd == "sync": + sync_worktree_settings(registry, sys.argv[2], sys.argv[3], sys.argv[4]) diff --git a/w-function.zsh b/w-function.zsh index 6a48b0e..abee6ed 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -127,19 +127,12 @@ w() { echo "Failed to remove worktree. Use --force if it has uncommitted changes." return 1 } - # Clean up Claude Code settings for removed worktree - WT_PATH="$wt_path" python3 -c " -import json, os -claude_config = os.path.expanduser('~/.claude.json') -wt_path = os.environ['WT_PATH'] -with open(claude_config, 'r') as f: - d = json.load(f) -if wt_path in d.get('projects', {}): - del d['projects'][wt_path] - with open(claude_config, 'w') as f: - json.dump(d, f) - print(f'Removed worktree project entry from ~/.claude.json') -" 2>/dev/null + # Clean up Claude Code settings for removed worktree across all registered accounts + local _ckipper_dir="${CKIPPER_DIR:-$HOME/.ckipper}" + if [[ -f "$_ckipper_dir/docker/cleanup-projects.py" ]]; then + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + python3 "$_ckipper_dir/docker/cleanup-projects.py" remove "$wt_path" 2>/dev/null || true + fi return 0 fi @@ -268,29 +261,14 @@ if wt_path in d.get('projects', {}): done # Sync Claude Code project settings (disabled MCPs, permissions, etc.) + # for the active account from the main project entry to the new worktree entry. local main_project_path="$projects_dir/$project" - MAIN_PATH="$main_project_path" WT_PATH="$wt_path" python3 -c " -import json, os -claude_config = os.path.expanduser('~/.claude.json') -main_path = os.environ['MAIN_PATH'] -wt_path = os.environ['WT_PATH'] -with open(claude_config, 'r') as f: - d = json.load(f) -main = d.get('projects', {}).get(main_path, {}) -if main: - keys = ['disabledMcpServers', 'enabledMcpjsonServers', 'disabledMcpjsonServers', - 'allowedTools', 'hasTrustDialogAccepted', 'hasClaudeMdExternalIncludesApproved', - 'hasClaudeMdExternalIncludesWarningShown', 'hasCompletedProjectOnboarding'] - wt = d.setdefault('projects', {}).setdefault(wt_path, {}) - for k in keys: - if k in main: - wt[k] = main[k] - with open(claude_config, 'w') as f: - json.dump(d, f) - print('Synced Claude Code settings') -else: - print('No Claude settings found for main project') -" 2>/dev/null + local _ckipper_dir="${CKIPPER_DIR:-$HOME/.ckipper}" + if [[ -f "$_ckipper_dir/docker/cleanup-projects.py" ]]; then + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + python3 "$_ckipper_dir/docker/cleanup-projects.py" sync \ + "$active_account" "$main_project_path" "$wt_path" 2>/dev/null || true + fi fi # -- Docker mode: run in containerized environment -- @@ -310,17 +288,25 @@ else: _w_build_image || return 1 fi - # Ensure .claude.json exists (Docker would mount as directory if missing) - [[ -f "$HOME/.claude.json" ]] || echo '{}' > "$HOME/.claude.json" + # Ensure per-account .claude.json exists (Docker would mount as directory if missing) + [[ -f "$active_config_dir/.claude.json" ]] || echo '{}' > "$active_config_dir/.claude.json" - # Extract credentials from macOS Keychain (Claude stores auth there, not on disk) - local claude_creds - claude_creds=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) || true + # Extract credentials from macOS Keychain (per-account service name) + if [[ -n "$active_keychain_service" ]] && \ + ! _ckipper_validate_keychain_service "$active_keychain_service"; then + echo "Error: account '$active_account' has invalid keychain_service in registry." + echo "Re-register with: ckipper remove $active_account && ckipper add $active_account --adopt" + return 1 + fi + local claude_creds="" + if [[ -n "$active_keychain_service" ]]; then + claude_creds=$(security find-generic-password -s "$active_keychain_service" -w 2>/dev/null) || true + fi # Extract GitHub token for gh CLI auth inside container - # Try .claude.json MCP config first, then fall back to host's gh CLI auth + # Try the per-account .claude.json MCP config first, then fall back to host's gh CLI auth local gh_token - gh_token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' "$HOME/.claude.json" 2>/dev/null) || true + gh_token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' "$active_config_dir/.claude.json" 2>/dev/null) || true if [[ -z "$gh_token" ]] && command -v gh &>/dev/null; then gh_token=$(gh auth token 2>/dev/null) || true fi @@ -332,13 +318,13 @@ else: -v "$wt_path:/workspace:rw" # Mount main repo .git at same absolute path (resolves worktree .git file) -v "$projects_dir/$project/.git:$projects_dir/$project/.git:rw" - # Mount Claude auth and config - -v "$HOME/.claude:/home/claude/.claude:rw" - -v "$HOME/.claude.json:/home/claude/.claude-host.json:ro" - # Mount .claude at host path too — plugins store absolute host paths - # (e.g. /Users//.claude/plugins/...) that don't resolve at - # the container's /home/claude/.claude. This dual mount makes both work. - -v "$HOME/.claude:$HOME/.claude:rw" + # Mount per-account Claude config dir at the same host path so plugins' + # absolute-path references (e.g. /Users//.claude-/plugins/...) + # resolve inside the container. + -v "$active_config_dir:$active_config_dir:rw" + # Read-only staging copy of .claude.json (entrypoint copies it to the writable location) + -v "$active_config_dir/.claude.json:$active_config_dir/.claude-host.json:ro" + -e "CLAUDE_CONFIG_DIR=$active_config_dir" # Mount SSH config as staging copy (sanitized by entrypoint) -v "$HOME/.ssh:/home/claude/.ssh-host:ro" # Forward host's SSH agent (Docker Desktop for Mac). @@ -450,15 +436,6 @@ else: "${docker_args[@]}" local exit_code=$? - # Post-session: clean up dangling credentials symlink left by tmpfs credential isolation. - # Only remove when no other ckipper-dev containers are running — parallel sessions - # share the ~/.claude bind mount, so deleting the symlink would break their credentials. - if [[ -L "$HOME/.claude/.credentials.json" ]]; then - if ! docker ps --filter ancestor=ckipper-dev --quiet 2>/dev/null | grep -q .; then - rm -f "$HOME/.claude/.credentials.json" - fi - fi - # Post-session: warn if .git/config was modified if [[ -n "$git_config_hash" && -f "$git_config" ]]; then local new_hash=$(shasum -a 256 "$git_config" | cut -d' ' -f1) From 4c4469a531208a585a552468668f06c65a431ad5 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:25:10 -0600 Subject: [PATCH 020/165] entrypoint: read all Claude paths from CLAUDE_CONFIG_DIR; error if unset --- docker/entrypoint.sh | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b1060c6..1acb5e0 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,13 +1,25 @@ #!/bin/bash set -e +# Require CLAUDE_CONFIG_DIR — Ckipper's account context. No silent fallback. +if [ -z "$CLAUDE_CONFIG_DIR" ]; then + echo "Error: CLAUDE_CONFIG_DIR is not set inside the container." >&2 + echo "This means w() did not pass the account context. Bug — please report." >&2 + exit 1 +fi +if [ ! -d "$CLAUDE_CONFIG_DIR" ]; then + echo "Error: CLAUDE_CONFIG_DIR=$CLAUDE_CONFIG_DIR does not exist (mount failed?)." >&2 + exit 1 +fi + # Copy host's .claude.json to writable location (mounted read-only to avoid race condition) -if [ -f "$HOME/.claude-host.json" ]; then - cp "$HOME/.claude-host.json" "$HOME/.claude.json" +if [ -f "$CLAUDE_CONFIG_DIR/.claude-host.json" ]; then + cp "$CLAUDE_CONFIG_DIR/.claude-host.json" "$CLAUDE_CONFIG_DIR/.claude.json" # Disable Chrome extension check in container (no browser available) if command -v jq &>/dev/null; then jq '.claudeInChromeDefaultEnabled = false | .cachedChromeExtensionInstalled = false' \ - "$HOME/.claude.json" > "$HOME/.claude.json.tmp" && mv "$HOME/.claude.json.tmp" "$HOME/.claude.json" + "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ + && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" fi fi @@ -22,7 +34,7 @@ if [ -d "$HOME/.ssh-host" ]; then fi fi -# Write credentials to tmpfs (not the host-mounted ~/.claude — prevents credential +# Write credentials to tmpfs (not the host-mounted account dir — prevents credential # leakage to the host filesystem). The tmpfs mount at /tmp/claude-creds is # container-local and disappears when the container exits. if [ -n "$CLAUDE_CREDENTIALS" ]; then @@ -30,14 +42,14 @@ if [ -n "$CLAUDE_CREDENTIALS" ]; then echo "$CLAUDE_CREDENTIALS" > /tmp/claude-creds/.credentials.json chmod 700 /tmp/claude-creds chmod 600 /tmp/claude-creds/.credentials.json - # Symlink from expected location — Claude Code reads ~/.claude/.credentials.json - ln -sf /tmp/claude-creds/.credentials.json "$HOME/.claude/.credentials.json" + # Symlink from the account dir — Claude Code reads $CLAUDE_CONFIG_DIR/.credentials.json + ln -sf /tmp/claude-creds/.credentials.json "$CLAUDE_CONFIG_DIR/.credentials.json" fi # Set git identity from .claude.json account info (needed for commits inside container) -if [ -f "$HOME/.claude.json" ] && command -v jq &>/dev/null; then - git_name=$(jq -r '.oauthAccount.displayName // empty' "$HOME/.claude.json" 2>/dev/null) - git_email=$(jq -r '.oauthAccount.emailAddress // empty' "$HOME/.claude.json" 2>/dev/null) +if [ -f "$CLAUDE_CONFIG_DIR/.claude.json" ] && command -v jq &>/dev/null; then + git_name=$(jq -r '.oauthAccount.displayName // empty' "$CLAUDE_CONFIG_DIR/.claude.json" 2>/dev/null) + git_email=$(jq -r '.oauthAccount.emailAddress // empty' "$CLAUDE_CONFIG_DIR/.claude.json" 2>/dev/null) [ -n "$git_name" ] && git config --global user.name "$git_name" [ -n "$git_email" ] && git config --global user.email "$git_email" fi @@ -112,17 +124,17 @@ uv_bin_dir="${UV_TOOL_BIN_DIR:-$HOME/.local/bin}" mkdir -p "$uv_bin_dir" "${UV_TOOL_DIR:-$HOME/.local/share/uv/tools}" "${UV_PYTHON_INSTALL_DIR:-$HOME/.local/share/uv/python}" 2>/dev/null || true export PATH="$uv_bin_dir:$PATH" -if [ -f "$HOME/.claude.json" ] && command -v jq &>/dev/null && command -v uv &>/dev/null; then +if [ -f "$CLAUDE_CONFIG_DIR/.claude.json" ] && command -v jq &>/dev/null && command -v uv &>/dev/null; then uvx_servers=$(jq -r ' .mcpServers // {} | to_entries[] | select(.value.command == "uvx") | .key - ' "$HOME/.claude.json" 2>/dev/null) + ' "$CLAUDE_CONFIG_DIR/.claude.json" 2>/dev/null) if [ -n "$uvx_servers" ]; then echo "Pre-installing uvx-based MCP servers..." while IFS= read -r name; do [ -z "$name" ] && continue - pkg=$(jq -r ".mcpServers[\"$name\"].args[0]" "$HOME/.claude.json") + pkg=$(jq -r ".mcpServers[\"$name\"].args[0]" "$CLAUDE_CONFIG_DIR/.claude.json") [ -z "$pkg" ] && continue # Derive binary name from package spec @@ -148,8 +160,8 @@ if [ -f "$HOME/.claude.json" ] && command -v jq &>/dev/null && command -v uv &>/ jq --arg n "$name" --arg b "$bin_path" ' .mcpServers[$n].command = $b | .mcpServers[$n].args = .mcpServers[$n].args[1:] - ' "$HOME/.claude.json" > "$HOME/.claude.json.tmp" \ - && mv "$HOME/.claude.json.tmp" "$HOME/.claude.json" + ' "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ + && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" echo " $name -> $bin_path" else echo " $name: binary not found at $bin_path, keeping uvx" From 91ce152236d9103b034f5a99c9b4a278bf424b5b Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:27:21 -0600 Subject: [PATCH 021/165] hooks: extend protection to per-account dirs and ~/.ckipper --- hooks/bash-guardrails.sh | 12 +++++++----- hooks/protect-claude-config.sh | 13 ++++++++++++- test-prompt.md | 6 +++++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/hooks/bash-guardrails.sh b/hooks/bash-guardrails.sh index 0c689ca..c2530d1 100755 --- a/hooks/bash-guardrails.sh +++ b/hooks/bash-guardrails.sh @@ -73,8 +73,8 @@ if echo "$NORMALIZED" | grep -qE '(chmod|chown)\s+(-R|--recursive)\s'; then exit 2 fi -# 7. Direct credential/key file reads -if echo "$NORMALIZED" | grep -qE '(cat|less|head|tail|cp|curl|base64|xxd)\s+.*(\.ssh/(id_|config|authorized)|\.claude/\.credentials)'; then +# 7. Direct credential/key file reads (covers ~/.claude and per-account ~/.claude-) +if echo "$NORMALIZED" | grep -qE '(cat|less|head|tail|cp|curl|base64|xxd)\s+.*(\.ssh/(id_|config|authorized)|\.claude(-[a-z0-9_-]+)?/\.credentials)'; then echo "Blocked: reading credential/key files. Use git, gh, or npm which handle auth automatically." >&2 exit 2 fi @@ -89,15 +89,17 @@ if echo "$NORMALIZED" | grep -qE 'npm\s+publish'; then exit 2 fi -# 9. Claude config modification via Bash (closes Edit/Write hook bypass) -if echo "$NORMALIZED" | grep -qE '\.claude/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/)'; then +# 9. Claude config modification via Bash (closes Edit/Write hook bypass). +# Covers ~/.claude, per-account ~/.claude-, and ~/.ckipper. +# .claude-host.json is excluded by the trailing '/' in the regex. +if echo "$NORMALIZED" | grep -qE '\.claude(-[a-z0-9_-]+)?/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/)|/\.ckipper/'; then if echo "$NORMALIZED" | grep -qE '^(cat|less|head|tail|grep|rg|wc|ls|file|stat|jq)\s'; then # Allow reads but block output redirects (jq -n > settings.json is a write, not a read) if ! echo "$NORMALIZED" | grep -qE '>'; then exit 0 fi fi - echo "Blocked: modifying Claude config files via Bash. These are protected." >&2 + echo "Blocked: modifying Claude/Ckipper config files via Bash. These are protected." >&2 exit 2 fi diff --git a/hooks/protect-claude-config.sh b/hooks/protect-claude-config.sh index fbd0a04..eb8bbc7 100755 --- a/hooks/protect-claude-config.sh +++ b/hooks/protect-claude-config.sh @@ -10,9 +10,20 @@ INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') -if [[ "$FILE_PATH" =~ \.claude/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/) ]]; then +# Block Claude state subset under ~/.claude or any per-account ~/.claude-. +# Note: ~/.claude-host.json is the read-only staging mount and is intentionally +# excluded — the regex requires a '/' after the optional - suffix, while +# .claude-host.json has '.json' instead. +if [[ "$FILE_PATH" =~ \.claude(-[a-z0-9_-]+)?/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/) ]]; then echo "Blocked: cannot modify $FILE_PATH (protected Claude config file)" >&2 exit 2 fi +# Block anything under ~/.ckipper (registry, hooks, settings-template, docker tooling). +# Tampering with accounts.json could redirect another account's keychain_service. +if [[ "$FILE_PATH" =~ /\.ckipper/ ]]; then + echo "Blocked: cannot modify $FILE_PATH (protected Ckipper file)" >&2 + exit 2 +fi + exit 0 diff --git a/test-prompt.md b/test-prompt.md index b329f65..2bbbb3e 100644 --- a/test-prompt.md +++ b/test-prompt.md @@ -66,7 +66,11 @@ Run a comprehensive environment test to verify this Docker container has everyth - If `--firewall` was used: verify `curl -s --max-time 5 https://api.anthropic.com` succeeds (whitelisted) and `curl -s --max-time 5 https://example.com` times out (blocked) **10. Safety hooks verification** -- Try to Edit `~/.claude/settings.json` — should be BLOCKED by config protection hook +- Try to Edit `$CLAUDE_CONFIG_DIR/settings.json` — should be BLOCKED by config protection hook +- Try to Edit `~/.ckipper/accounts.json` — should be BLOCKED (registry tampering protection — closes credential cross-contamination vector) +- Try to run `echo modified > ~/.ckipper/accounts.json` — should be BLOCKED by bash guardrails +- Try to run `echo malicious > ~/.claude-otheraccount/settings.json` — should be BLOCKED (per-account dirs are protected even if not the active account) +- Try to write to `$CLAUDE_CONFIG_DIR/projects/test.txt` — should be ALLOWED (projects/ is not protected) - Try to run `echo test > .git/hooks/pre-commit` — should be BLOCKED by bash guardrails - Try to run `rm -rf /workspace` — should be BLOCKED by bash guardrails - Try to run `cat ~/.ssh/id_ed25519` — should be BLOCKED by bash guardrails From d2c6a60c258baf35469fffa75ff044152980bc38 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:33:16 -0600 Subject: [PATCH 022/165] Implement ckipper migrate: precondition checks, error-trap rollback, no symlink Replaced ERR-trap rollback with explicit post-mv / post-finalize checks. The trap-based design cascaded inside finalize when the registry write itself failed (chmod -w'd HOME), causing the script to hang on a chain of trap re-entries. Explicit checks return promptly on either failure point. Also: _ckipper_finalize_registration now verifies the registry write by reading back the registered account; without this, write failures were silent and migrate's rollback couldn't tell finalize had failed. --- ckipper.zsh | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/ckipper.zsh b/ckipper.zsh index 34b5657..65fe50b 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -251,11 +251,20 @@ _ckipper_finalize_registration() { local name="$1" dir="$2" service="$3" mode="$4" local now; now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + _ckipper_init_registry + _ckipper_registry_update ' .accounts[$n] = {config_dir: $d, keychain_service: (if $s == "" then null else $s end), registered_at: $t} | (if .default == null then .default = $n else . end) ' --arg n "$name" --arg d "$dir" --arg s "$service" --arg t "$now" + # Verify the write actually landed — registry update under chmod -w or other + # write failures must propagate so callers (e.g. ckipper migrate) can rollback. + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: failed to write account '$name' to registry $CKIPPER_REGISTRY" >&2 + return 1 + fi + _ckipper_regenerate_aliases _ckipper_sync_hooks_for "$name" @@ -386,4 +395,110 @@ _ckipper_remove() { printf " security delete-generic-password -s %q\n" "$service" fi } -_ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } +_ckipper_migrate() { + _ckipper_check_registry_version || return 1 + local legacy_docker="$HOME/.claude/docker" + local legacy_claude="$HOME/.claude" + + # ── Precondition 1: no Claude process running ───────────────── + if pgrep -f "[c]laude " >/dev/null 2>&1; then + echo "Error: a Claude process is currently running. Quit all Claude sessions first." >&2 + echo "Detected: $(pgrep -af '[c]laude ' | head -3)" >&2 + return 1 + fi + + # ── Precondition 2: ~/.claude-personal must not already exist ─ + if [[ -e "$HOME/.claude-personal" ]]; then + echo "Error: $HOME/.claude-personal already exists. Refusing to migrate." >&2 + echo "If you've already migrated, you're done. Run: ckipper list" >&2 + return 1 + fi + + # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── + if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then + mkdir -p "$CKIPPER_DIR" + cp -a "$legacy_docker/." "$CKIPPER_DIR/docker/" + echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact for one release cycle)" + fi + + # ── 2. Adopt ~/.claude as 'personal' if eligible ────────────── + if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]]; then + if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ + ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + + # Show the user what we're about to do. + cat </dev/null 2>&1; then + echo "Warning: '$probed_service' not found in Keychain." + echo "Listing available Claude Keychain entries:" + _ckipper_keychain_snapshot || return 1 + read -r "?Enter the Keychain service for the personal account (or empty to skip): " probed_service + if [[ -n "$probed_service" ]] && ! _ckipper_validate_keychain_service "$probed_service"; then + echo "Invalid Keychain service shape. Aborting." + return 1 + fi + fi + else + probed_service="" + fi + + # ── Destructive operation with explicit rollback ───── + if ! mv "$legacy_claude" "$HOME/.claude-personal" 2>/dev/null; then + echo "Error: failed to rename $legacy_claude → $HOME/.claude-personal" >&2 + echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 + return 1 + fi + if ! _ckipper_finalize_registration "personal" "$HOME/.claude-personal" "$probed_service" "migrate"; then + # Rollback the rename so the host returns to a clean state. + if [[ -d "$HOME/.claude-personal" && ! -e "$legacy_claude" ]]; then + mv "$HOME/.claude-personal" "$legacy_claude" 2>/dev/null + echo "Migration failed — restored $legacy_claude from rollback." >&2 + fi + return 1 + fi + fi + fi + + # ── 3. Best-effort cleanup of old Docker image ──────────────── + if command -v docker >/dev/null 2>&1; then + docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." + fi + + cat < to add additional accounts. + +To launch Claude with your personal account, use: claude-personal +(Bare 'claude' no longer resolves to your migrated personal account — it will start a fresh login.) + +EOF +} From cb5730fb593372d526bb68a28d7eb6445fc9db71 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:43:01 -0600 Subject: [PATCH 023/165] README: rewrite for Ckipper with multi-account walkthrough and warnings --- README.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 773fe62..2201b45 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Ckipper (pronounced "skipper") -Docker-based isolation for running Claude Code with `--dangerously-skip-permissions` safely. One command to spin up a sandboxed autonomous Claude session on any project. +Docker-based isolation for running Claude Code with `--dangerously-skip-permissions` safely, with **first-class multi-account support**: run a personal Claude account in one terminal and a work account in another, fully isolated. One command to spin up a sandboxed autonomous Claude session on any project. -Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, egress firewall, safety hooks, and macOS Keychain auth integration. +Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, egress firewall, safety hooks, macOS Keychain auth integration, and per-account isolation across credentials, settings, MCP, plugins, and projects. ## The Problem @@ -29,18 +29,78 @@ This creates a git worktree, spins up a Docker container, and runs Claude inside ## Quick Reference ```bash -w myorg/myapp feature-x --docker claude # Claude in Docker (skip-permissions) -w myorg/myapp feature-x --docker # shell in Docker container -w myorg/myapp feature-x --docker --firewall # Docker + egress firewall -w myorg/myapp feature-x # cd to worktree (no Docker) -w myorg/myapp feature-x claude # run Claude in worktree (no Docker) -w --list # list all worktrees -w --rm myorg/myapp feature-x # remove worktree + delete branch -w --rebuild-image # rebuild Docker image +w myorg/myapp feature-x --docker claude # Claude in Docker (skip-permissions) +w myorg/myapp feature-x --docker --account work # use a specific Ckipper account +w myorg/myapp feature-x --docker # shell in Docker container +w myorg/myapp feature-x --docker --firewall # Docker + egress firewall +w myorg/myapp feature-x # cd to worktree (no Docker) +w myorg/myapp feature-x claude # run Claude in worktree (no Docker) +w --list # list all worktrees +w --rm myorg/myapp feature-x # remove worktree + delete branch +w --rebuild-image # rebuild Docker image + +ckipper add # register a Claude account +ckipper list # show registered accounts +ckipper default # set the default account +ckipper migrate # one-time migration from claude-docker-sandbox ``` `` is a relative path under `~/Developer/` (e.g. `Whmoro/orderguard`, `Vibma`). Tab completion is included. +## Multiple accounts (Ckipper's headline feature) + +Run a personal Claude account in one terminal and a work account in another, fully isolated. Each gets its own credentials, MCP servers, plugins, projects, and session history. + +### Add an account + +```bash +ckipper add work +``` + +`ckipper` walks you through `/login` and registers the account. Repeat for every account you want. + +### Use an account + +Three ways: + +```bash +claude-work # auto-generated alias (preferred) +cca work # one-off dispatcher (claude-config-as) +CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form +``` + +### Inside Docker + +```bash +w myorg/app feature --account work --docker claude +``` + +If you're already in a terminal where `CLAUDE_CONFIG_DIR` is set (e.g., via `claude-work`), `w` picks up the account automatically — no flag needed. + +### List, default, remove + +```bash +ckipper list +ckipper default personal +ckipper remove old-account +``` + +### How accounts are stored + +- Per-account state lives in `~/.claude-/` (analogous to the legacy `~/.claude/`). +- The registry mapping accounts to dirs and Keychain services lives at `~/.ckipper/accounts.json` (chmod 600, atomic writes via `flock`). +- Auto-generated `~/.ckipper/aliases.zsh` defines `cca` and one `claude-` function per registered account. +- Hooks under `~/.ckipper/hooks/` are the canonical source — `ckipper sync-hooks` copies them per-account and rewrites `settings.json` paths. + +## ⚠️ Don't run the same account in two sessions + +Two terminals running the **same** account simultaneously will hit a known OAuth refresh-token race ([upstream issue #24317](https://github.com/anthropics/claude-code/issues/24317)) — symptoms: frequent re-login prompts, lost sessions. + +- **Safe:** `claude-personal` in one terminal, `claude-work` in another. Different accounts, different refresh tokens, no race. +- **Bad:** `claude-personal` in two terminals at once. + +If you want concurrent runs of the *same* account, register it twice under two names (`personal-a`, `personal-b`) — though this means re-`/login` for each. + ## What's In the Container - Node.js 24, git, gh CLI, ripgrep, curl, jq, python3, tmux @@ -132,9 +192,26 @@ Two named Docker volumes support uvx-based MCP servers: The entrypoint pre-installs uvx-based MCP servers before Claude starts and rewrites the container's `.claude.json` to invoke the installed binary directly. This eliminates the network freshness check and ephemeral venv creation that cause intermittent MCP startup timeouts. -## Migrating from previous versions +## Migrating from claude-docker-sandbox - +If you've been running this project under its previous name with a single `~/.claude/docker/` install, run: + +```bash +ckipper migrate +``` + +This will: + +1. Refuse to run if any `claude` process is currently active (quit them first). +2. Copy `~/.claude/docker/` → `~/.ckipper/`. +3. Offer to register your existing `~/.claude` as the `personal` account. If you accept: rename `~/.claude` → `~/.claude-personal`, probe Keychain for the matching credential entry, and write the registry. **No symlink is created** — after migration, you launch Claude with `claude-personal` (bare `claude` will start a fresh login). +4. If anything fails, the rename automatically reverses (rollback). + +Then add additional accounts: + +```bash +ckipper add work +``` ## Setup From f91bc74c188d31c75a489545780ffc12647ee7d6 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:44:42 -0600 Subject: [PATCH 024/165] Docs: CLAUDE.md and test-prompt.md updates with concrete Section 12 isolation tests --- CLAUDE.md | 35 ++++++++++++++++++++++++--------- test-prompt.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cbc68e9..2a0e6fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,18 +4,30 @@ ## Architecture -- **`w-function.zsh`** — zsh function that manages worktrees (`git worktree add`), builds/runs Docker containers, extracts macOS Keychain credentials, forwards ports, and detects `.git/config` tampering post-session. Includes tab completion. +- **`w-function.zsh`** — zsh function that manages worktrees (`git worktree add`), builds/runs Docker containers, extracts macOS Keychain credentials, forwards ports, and detects `.git/config` tampering post-session. Resolves the active Ckipper account from `--account`, env, or registered default. Sources `ckipper.zsh` at the bottom. Includes tab completion. +- **`ckipper.zsh`** — multi-account manager. Subcommands: `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. Owns the registry at `~/.ckipper/accounts.json` and the auto-generated `~/.ckipper/aliases.zsh`. Sourced by `w-function.zsh` after deployment. - **`w-config.zsh.example`** — Template for user-specific Docker config (ports, volume mounts, env vars). Copied to `~/.ckipper/docker/w-config.zsh` on first install, never overwritten on updates. - **`docker/Dockerfile`** — `node:24-slim` image with dev tools (git, gh, ripgrep, tmux, Chromium, uv/uvx, bun, Claude Code native installer). Runs as non-root `claude` user. -- **`docker/entrypoint.sh`** — Container startup: copies `.claude.json` from read-only staging mount, writes credentials to disk, sets git identity, disables GPG signing via `GIT_CONFIG_COUNT`, authenticates `gh` CLI, optionally enables firewall, creates `bunx` wrapper for statusline colors, runs `npm install` for Linux binaries, clears credential env vars, then runs the provided command (or drops to bash shell if none). -- **`hooks/`** — Four Claude Code hooks (registered in `settings-hooks.json`): - - `protect-claude-config.sh` — PreToolUse on Edit/Write: blocks modifications to `.claude/settings.json`, hooks, plugins - - `bash-guardrails.sh` — PreToolUse on Bash: blocks `rm -rf`, `git push --force`, `git reset --hard`, `.git/hooks` writes, recursive `chmod`/`chown`, credential file reads, Claude config modification via shell +- **`docker/entrypoint.sh`** — Container startup: errors out if `CLAUDE_CONFIG_DIR` is unset; reads `.claude.json` from `$CLAUDE_CONFIG_DIR/.claude-host.json` staging mount, writes credentials to disk, sets git identity, disables GPG signing via `GIT_CONFIG_COUNT`, authenticates `gh` CLI, optionally enables firewall, creates `bunx` wrapper, runs `npm install` for Linux binaries, clears credential env vars, then runs the provided command. +- **`docker/cleanup-projects.py`** — Registry-driven helper invoked by `w` for `--rm` cleanup (removes a worktree entry from every account's `.claude.json`) and worktree creation (copies main project settings into the new worktree entry under the active account). +- **`hooks/`** — Four Claude Code hooks (template at `settings-hooks.json` → deployed to `~/.ckipper/settings-template.json`): + - `protect-claude-config.sh` — PreToolUse on Edit/Write: blocks modifications to `~/.claude*/...settings|hooks|plugins/...` and anything under `~/.ckipper/`. `~/.claude-host.json` is intentionally allowed (read-only staging mount). + - `bash-guardrails.sh` — PreToolUse on Bash: blocks `rm -rf`, `git push --force`, `git reset --hard`, `.git/hooks` writes, recursive `chmod`/`chown`, credential file reads, Claude/Ckipper config modification via shell. - `docker-context.sh` — SessionStart: injects safety rules so Claude avoids triggering guardrails - `notify-bell.sh` — Notification: sends terminal bell (`\a`) so host terminal fires native notifications (dock bounce, sound) - All four are no-op on the host (exit early if `/.dockerenv` doesn't exist) - **`docker/init-firewall.sh`** — Optional `iptables-legacy` egress whitelist (default-deny). Uses `--cap-add=NET_ADMIN`. +## Multi-account model + +Each Claude account is a `CLAUDE_CONFIG_DIR=~/.claude-/` directory — analogous to the legacy `~/.claude/`, but namespaced. The registry at `~/.ckipper/accounts.json` (chmod 600, schema version 1) maps account name → `{config_dir, keychain_service, registered_at}`. Atomic writes via `flock` (with `mkdir`-based fallback for systems without `flock`). + +`w` resolves the active account in priority order: `--account ` > `CLAUDE_CONFIG_DIR` env (matched against registry) > registered default. **No legacy fallback** — if no account resolves, `w` errors out and tells you to register one. Inside the container, the entrypoint requires `CLAUDE_CONFIG_DIR` and exits 1 if unset (no silent fallback). + +Companion shell layer: `~/.ckipper/aliases.zsh` is auto-regenerated on every `ckipper add` / `remove` and contains a `cca ` dispatcher plus one `claude-` function per registered account. The file is self-contained — sourcing only `aliases.zsh` (without `ckipper.zsh` or `w-function.zsh`) yields a working setup. + +`settings-template.json` is **seed-only**. After an account is registered, its `settings.json` diverges (per-account hooks, paths). Re-running `ckipper sync-hooks` refreshes hook paths and copies the canonical `~/.ckipper/hooks/*` into each account; it does not re-apply the template wholesale. + ## Critical Safety Rules **Never modify the host's `.git/config` from inside the container.** The container mounts the host's `.git` directory read-write (required for worktree refs to resolve). Any `git config --local` command modifies the host's actual git config. Use `GIT_CONFIG_COUNT` environment variables instead — they take highest priority in git's config precedence and disappear when the container exits. @@ -26,6 +38,8 @@ **Hooks prevent accidents, not adversarial bypass.** Absolute paths (`/bin/rm`), language-level file access (`python3 -c "open(...).read()"`), and symlink indirection can bypass the bash guardrails. This is accepted. Don't over-engineer the regex matching. +**Validate `keychain_service` shape before any `security` call.** The registry stores a per-account Keychain service name (`Claude Code-credentials` optionally followed by `-`). Both `ckipper add` and `w` validate the shape via `_ckipper_validate_keychain_service` before passing it to `security find-generic-password`. Without this, a tampered registry could feed arbitrary arguments into `security` (command injection vector). Hooks also block writes to `~/.ckipper/accounts.json` so a compromised in-container Claude can't redirect another account's keychain service. + ## Development Workflow | Change | Action Required | @@ -34,15 +48,17 @@ | `entrypoint.sh` | `w --rebuild-image` (it's `COPY`'d into the image) | | `init-firewall.sh` | `w --rebuild-image` (it's `COPY`'d into the image) | | `w-function.zsh` | `./install.sh` (copies to `~/.ckipper/docker/`; user config in `w-config.zsh` is preserved) | +| `ckipper.zsh` | `./install.sh` (copies to `~/.ckipper/docker/`; sourced by `w-function.zsh`) | +| `cleanup-projects.py` | `./install.sh` (copies to `~/.ckipper/docker/`; invoked by `w` for `--rm` and worktree-create sync) | | `w-config.zsh.example` | Template only — user's `~/.ckipper/docker/w-config.zsh` is never overwritten | -| `hooks/*` | Sync to `~/.ckipper/hooks/` | -| `settings-hooks.json` | `./install.sh` (auto-merged into `~/.claude/settings.json`) | +| `hooks/*` | `./install.sh` deploys to `~/.ckipper/hooks/`; per-account copies are written by `ckipper sync-hooks` | +| `settings-hooks.json` | `./install.sh` deploys to `~/.ckipper/settings-template.json` (consumed by `ckipper add` and `ckipper sync-hooks` for per-account `settings.json`) | Two copies of the code exist: this repo (development) and deployed files on the host (`~/.ckipper/docker/`, `~/.ckipper/hooks/`). Run `./install.sh` to sync all core files. User customizations live in `~/.ckipper/docker/w-config.zsh` and are never overwritten. ## Testing -`test-prompt.md` is the validation suite. It has 11 sections covering entrypoint verification, filesystem access, git operations, build tools, safety hooks (including bypass attempts), and container isolation. Run it by starting a Docker session (`w --docker claude`) and pasting the prompt contents. +`test-prompt.md` is the validation suite. It has 12 sections covering entrypoint verification, filesystem access, git operations, build tools, safety hooks (including bypass attempts), container isolation, and **multi-account isolation**. Run it by starting a Docker session (`w --docker claude`) and pasting the prompt contents. Section 12 must be run in two concurrent containers under different accounts. ## Key Implementation Details @@ -51,4 +67,5 @@ Two copies of the code exist: this repo (development) and deployed files on the - **`gh auth`**: Must `unset GH_TOKEN` before `gh auth login --with-token` because gh refuses to store credentials while the env var is set. Then `gh auth setup-git` configures gh as the git credential helper for HTTPS push. - **`core.hooksPath`**: Set globally to `~/.git-hooks` on the host so git ignores `.git/hooks/` — prevents planted hooks from executing on the host after the container exits. - **Port forwarding**: Ports that are already in use on the host are silently skipped. -- **Worktree removal** (`w --rm`): Also cleans up the project entry from `~/.claude.json`. +- **Worktree removal** (`w --rm`): Removes the project entry from every registered account's `.claude.json` via `cleanup-projects.py`. +- **Account-aware mounts**: `w` mounts `$active_config_dir:$active_config_dir:rw` (no `/home/claude/.claude` dual mount). Plugins' absolute-path references resolve because the account dir is at the same host path. Credentials still live in container-only tmpfs (`/tmp/claude-creds`) — the host-side symlink target points to a path that doesn't exist outside the container. diff --git a/test-prompt.md b/test-prompt.md index 2bbbb3e..a2b0d33 100644 --- a/test-prompt.md +++ b/test-prompt.md @@ -92,6 +92,55 @@ Now test guardrail bypass attempts (report which are caught and which pass throu - Run `mount | grep workspace` — verify /workspace is mounted rw (not ro) - Run `find /usr -perm -4000 -type f 2>/dev/null` — list setuid binaries (should be minimal in slim image) +**12. Multi-account isolation** + +Run these checks in two concurrent containers (Window A: `--account personal`, Window B: `--account `). + +A. Each container has the right `CLAUDE_CONFIG_DIR`: + +```bash +# In window A +[ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude-personal" ] && echo PASS || echo FAIL +# In window B +[ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude-" ] && echo PASS || echo FAIL +``` + +B. The right `.claude.json` was copied: + +```bash +expected_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude-host.json") +actual_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude.json") +[ "$expected_email" = "$actual_email" ] && echo PASS || echo FAIL +``` + +C. Credentials symlinked to tmpfs: + +```bash +[ -L "$CLAUDE_CONFIG_DIR/.credentials.json" ] && echo PASS || echo FAIL +[ "$(readlink "$CLAUDE_CONFIG_DIR/.credentials.json")" = "/tmp/claude-creds/.credentials.json" ] && echo PASS || echo FAIL +``` + +D. Other accounts are NOT mounted: + +```bash +# Window A should NOT see Window B's dir +[ ! -d "$HOME/.claude-" ] && echo PASS || echo FAIL +``` + +E. Project sessions don't bleed across accounts (run after both sessions touch the project — check from the host): + +```bash +diff <(ls ~/.claude-personal/projects/ 2>/dev/null) <(ls ~/.claude-/projects/ 2>/dev/null) +# Expected: empty (no shared session dirs) +``` + +F. Registry tampering is blocked. Inside the container, attempt: + +```bash +echo modified > ~/.ckipper/accounts.json +# Expected: BLOCKED by bash-guardrails.sh hook (closes credential cross-contamination vector) +``` + ## Expected Results | Check | Expected | @@ -127,5 +176,8 @@ Now test guardrail bypass attempts (report which are caught and which pass throu | 11e | PASS (shows entrypoint/claude) | | 11f | PASS (workspace mounted rw) | | 11g | Minimal setuid list (passwd, su, sudo expected) | +| 12a-12d | All PASS (per-account dir, .claude.json, credentials, no other-account mount) | +| 12e | PASS (no shared session dirs across accounts) | +| 12f | BLOCKED (registry tampering refused by hook) | After all checks, give me a summary table of what works and what doesn't, and flag anything that would prevent you from doing normal development work (writing code, running tests, building, committing, pushing). For any guardrail bypass attempts that succeeded, note them as potential hardening opportunities. From a640997ab61a92683789947333cc4690abb51eab Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 17:54:46 -0600 Subject: [PATCH 025/165] Drop .claude-host.json staging mount (incompatible with macOS Docker virtiofs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original design mounted .claude.json read-only at a sibling path (.claude-host.json) for race protection against the host's Claude. With multi-account dirs, that staging path lives nested inside the dir bind-mount — and macOS Docker Desktop's virtiofs cannot create a nested mountpoint under another bind-mount ("mountpoint outside of rootfs" error). Fix: drop the staging mount. The entrypoint now mutates .claude.json in place. Race protection devolves to the documented "don't run the same account in two sessions" rule (issue #24317). For multi-account, this is the intended workflow anyway — different accounts use different dirs and have no race. Caught by the Phase 6.5 Docker smoke test. --- CLAUDE.md | 4 ++-- docker/entrypoint.sh | 16 +++++++--------- test-prompt.md | 8 ++++---- w-function.zsh | 6 +++--- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2a0e6fb..911a699 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ - **`ckipper.zsh`** — multi-account manager. Subcommands: `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. Owns the registry at `~/.ckipper/accounts.json` and the auto-generated `~/.ckipper/aliases.zsh`. Sourced by `w-function.zsh` after deployment. - **`w-config.zsh.example`** — Template for user-specific Docker config (ports, volume mounts, env vars). Copied to `~/.ckipper/docker/w-config.zsh` on first install, never overwritten on updates. - **`docker/Dockerfile`** — `node:24-slim` image with dev tools (git, gh, ripgrep, tmux, Chromium, uv/uvx, bun, Claude Code native installer). Runs as non-root `claude` user. -- **`docker/entrypoint.sh`** — Container startup: errors out if `CLAUDE_CONFIG_DIR` is unset; reads `.claude.json` from `$CLAUDE_CONFIG_DIR/.claude-host.json` staging mount, writes credentials to disk, sets git identity, disables GPG signing via `GIT_CONFIG_COUNT`, authenticates `gh` CLI, optionally enables firewall, creates `bunx` wrapper, runs `npm install` for Linux binaries, clears credential env vars, then runs the provided command. +- **`docker/entrypoint.sh`** — Container startup: errors out if `CLAUDE_CONFIG_DIR` is unset; mutates `.claude.json` (chrome flags, MCP rewrites) in the bind-mounted account dir, writes credentials to tmpfs, sets git identity, disables GPG signing via `GIT_CONFIG_COUNT`, authenticates `gh` CLI, optionally enables firewall, creates `bunx` wrapper, runs `npm install` for Linux binaries, clears credential env vars, then runs the provided command. - **`docker/cleanup-projects.py`** — Registry-driven helper invoked by `w` for `--rm` cleanup (removes a worktree entry from every account's `.claude.json`) and worktree creation (copies main project settings into the new worktree entry under the active account). - **`hooks/`** — Four Claude Code hooks (template at `settings-hooks.json` → deployed to `~/.ckipper/settings-template.json`): - `protect-claude-config.sh` — PreToolUse on Edit/Write: blocks modifications to `~/.claude*/...settings|hooks|plugins/...` and anything under `~/.ckipper/`. `~/.claude-host.json` is intentionally allowed (read-only staging mount). @@ -34,7 +34,7 @@ Companion shell layer: `~/.ckipper/aliases.zsh` is auto-regenerated on every `ck **Clear credentials from the environment before `exec claude`.** The entrypoint receives `CLAUDE_CREDENTIALS` and `GH_TOKEN` as env vars, writes them to disk, then `unset`s them before `exec`. The `exec` replaces the process, so `/proc/self/environ` is clean. If you add new credential env vars, follow this same pattern. -**`.claude.json` is mounted read-only as `.claude-host.json`.** The entrypoint copies it to a writable location. This prevents the container from racing with the host's Claude process on the same file. If you need data from `.claude.json`, read from the copy, not the mount. +**Don't run the same Ckipper account in two sessions.** Per-account `.claude.json` is bind-mounted RW into the container — entrypoint mutations (chrome flags, MCP rewrites) propagate back to the host file. With the multi-account model, the previous read-only staging mount was dropped (macOS Docker Desktop virtiofs cannot create a nested mountpoint under another bind-mount). Race protection now relies on the user advisory: if you need concurrent containers, use different accounts. See README issue #24317 reference. **Hooks prevent accidents, not adversarial bypass.** Absolute paths (`/bin/rm`), language-level file access (`python3 -c "open(...).read()"`), and symlink indirection can bypass the bash guardrails. This is accepted. Don't over-engineer the regex matching. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1acb5e0..2e149ca 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,15 +12,13 @@ if [ ! -d "$CLAUDE_CONFIG_DIR" ]; then exit 1 fi -# Copy host's .claude.json to writable location (mounted read-only to avoid race condition) -if [ -f "$CLAUDE_CONFIG_DIR/.claude-host.json" ]; then - cp "$CLAUDE_CONFIG_DIR/.claude-host.json" "$CLAUDE_CONFIG_DIR/.claude.json" - # Disable Chrome extension check in container (no browser available) - if command -v jq &>/dev/null; then - jq '.claudeInChromeDefaultEnabled = false | .cachedChromeExtensionInstalled = false' \ - "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ - && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" - fi +# Disable Chrome extension check in the bind-mounted .claude.json (no browser in container). +# This mutates the host file too — accepted, because the same-account-twice rule +# prevents concurrent host/container use of the same file. +if [ -f "$CLAUDE_CONFIG_DIR/.claude.json" ] && command -v jq &>/dev/null; then + jq '.claudeInChromeDefaultEnabled = false | .cachedChromeExtensionInstalled = false' \ + "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ + && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" fi # Copy SSH config from staging mount, stripping macOS-specific options. diff --git a/test-prompt.md b/test-prompt.md index a2b0d33..3703730 100644 --- a/test-prompt.md +++ b/test-prompt.md @@ -105,12 +105,12 @@ A. Each container has the right `CLAUDE_CONFIG_DIR`: [ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude-" ] && echo PASS || echo FAIL ``` -B. The right `.claude.json` was copied: +B. `.claude.json` is the per-account file (account-specific email): ```bash -expected_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude-host.json") -actual_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude.json") -[ "$expected_email" = "$actual_email" ] && echo PASS || echo FAIL +# Confirm the email matches the account's registered identity +jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude.json" +# Should match the email shown by `ckipper list` for this account. ``` C. Credentials symlinked to tmpfs: diff --git a/w-function.zsh b/w-function.zsh index abee6ed..cc7fe2c 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -320,10 +320,10 @@ w() { -v "$projects_dir/$project/.git:$projects_dir/$project/.git:rw" # Mount per-account Claude config dir at the same host path so plugins' # absolute-path references (e.g. /Users//.claude-/plugins/...) - # resolve inside the container. + # resolve inside the container. Host-vs-container races on .claude.json + # are prevented by the "don't run the same account in two sessions" rule + # (see README #24317 note) — no read-only staging mount needed. -v "$active_config_dir:$active_config_dir:rw" - # Read-only staging copy of .claude.json (entrypoint copies it to the writable location) - -v "$active_config_dir/.claude.json:$active_config_dir/.claude-host.json:ro" -e "CLAUDE_CONFIG_DIR=$active_config_dir" # Mount SSH config as staging copy (sanitized by entrypoint) -v "$HOME/.ssh:/home/claude/.ssh-host:ro" From 8f4f66bd809a74cce84e75fa03a51e4b378fbb98 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 18:00:50 -0600 Subject: [PATCH 026/165] install.sh: legacy migration writes to $CKIPPER_DIR/docker/, not the top level The plan's migration block (and Task 6 spec) had cp -a target $CKIPPER_DIR directly. But the new layout puts tooling at $CKIPPER_DIR/docker/, so the contents of legacy $HOME/.claude/docker/ should land at $CKIPPER_DIR/docker/. Caught when running install.sh against my actual host: Dockerfile, entrypoint.sh, init-firewall.sh, w-function.zsh, and (worse) the user's customized w-config.zsh ended up at $CKIPPER_DIR/ top level. The fresh install.sh copy then put canonical versions at $CKIPPER_DIR/docker/, so the user's customizations were silently shadowed. The guard now checks for $CKIPPER_DIR/docker/ existence (not $CKIPPER_DIR itself) so the migration runs once even if $CKIPPER_DIR exists for some other reason. --- install.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index d14f6ae..fff1132 100755 --- a/install.sh +++ b/install.sh @@ -9,17 +9,17 @@ CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" # Migrate legacy ~/.claude/docker/ layout if present (idempotent) LEGACY_DIR="$HOME/.claude/docker" -if [ -d "$LEGACY_DIR" ] && [ ! -d "$CKIPPER_DIR" ]; then - echo "Migrating ~/.claude/docker/ -> $CKIPPER_DIR/" - mkdir -p "$CKIPPER_DIR" - cp -a "$LEGACY_DIR/." "$CKIPPER_DIR/" +if [ -d "$LEGACY_DIR" ] && [ ! -d "$CKIPPER_DIR/docker" ]; then + echo "Migrating ~/.claude/docker/ -> $CKIPPER_DIR/docker/" + mkdir -p "$CKIPPER_DIR/docker" + cp -a "$LEGACY_DIR/." "$CKIPPER_DIR/docker/" echo "Migrated. The legacy directory is left intact at $LEGACY_DIR for one release cycle." echo "After verifying the new location works (ckipper list shows your accounts):" echo " rm -rf $LEGACY_DIR" # Sweep migrated w-config.zsh for stale path strings (warn only — never auto-edit user config) - if [ -f "$CKIPPER_DIR/w-config.zsh" ]; then - stale=$(grep -n "\.claude/docker" "$CKIPPER_DIR/w-config.zsh" 2>/dev/null || true) + if [ -f "$CKIPPER_DIR/docker/w-config.zsh" ]; then + stale=$(grep -n "\.claude/docker" "$CKIPPER_DIR/docker/w-config.zsh" 2>/dev/null || true) if [ -n "$stale" ]; then echo "" echo "WARNING: Your migrated w-config.zsh contains stale ~/.claude/docker/ paths:" From 291da3d2da18f52dcb0a350f1150c2ed7e66e0b8 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 18:02:44 -0600 Subject: [PATCH 027/165] install.sh: rewrite full source line in .zshrc, not just the path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous sed pattern matched the path portion only, leaving the trailing closing quote of source "$HOME/.claude/docker/w-function.zsh" dangling — broke shell startup with 'unmatched "'. The new pattern consumes the entire 'source ... w-function.zsh' line (quoted or unquoted, ~/$HOME/absolute) and rewrites to a canonical quoted form. Tested against 4 legacy variants and 2 unrelated lines. --- install.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index fff1132..d0d2ea9 100755 --- a/install.sh +++ b/install.sh @@ -96,10 +96,15 @@ cp "$REPO_DIR/settings-hooks.json" "$CKIPPER_DIR/settings-template.json" echo " Settings template deployed. ckipper sync-hooks applies it per-account." # 7. Add or update source line in .zshrc -if grep -q "source.*\.claude/docker/w-function.zsh" "$HOME/.zshrc" 2>/dev/null; then - sed -i.bak 's|source.*\.claude/docker/w-function\.zsh|source ~/.ckipper/docker/w-function.zsh|' "$HOME/.zshrc" +# The legacy line could be any of: +# source "$HOME/.claude/docker/w-function.zsh" +# source ~/.claude/docker/w-function.zsh +# source $HOME/.claude/docker/w-function.zsh +# We rewrite the whole line (consuming any trailing quote) to a canonical quoted form. +if grep -q '\.claude/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then + sed -i.bak -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*\.claude/docker/w-function\.zsh["'\'']?[[:space:]]*$|source "$HOME/.ckipper/docker/w-function.zsh"|' "$HOME/.zshrc" echo " Updated ~/.zshrc source line to ~/.ckipper/. Backup at ~/.zshrc.bak." -elif ! grep -q 'ckipper/docker/w-function.zsh' "$HOME/.zshrc" 2>/dev/null; then +elif ! grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then echo '' >> "$HOME/.zshrc" echo '# Ckipper — Worktree Manager (w function)' >> "$HOME/.zshrc" echo 'source "$HOME/.ckipper/docker/w-function.zsh"' >> "$HOME/.zshrc" From 5cef1de09a765a3cc3ec9ee3f985cf42249e46ad Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 27 Apr 2026 18:04:57 -0600 Subject: [PATCH 028/165] =?UTF-8?q?Remove=20docs/plans/=20=E2=80=94=20impl?= =?UTF-8?q?ementation=20plan=20artifacts,=20not=20for=20the=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-04-27-ckipper-multi-account-design.md | 300 --- ...27-ckipper-multi-account-implementation.md | 1783 ----------------- 2 files changed, 2083 deletions(-) delete mode 100644 docs/plans/2026-04-27-ckipper-multi-account-design.md delete mode 100644 docs/plans/2026-04-27-ckipper-multi-account-implementation.md diff --git a/docs/plans/2026-04-27-ckipper-multi-account-design.md b/docs/plans/2026-04-27-ckipper-multi-account-design.md deleted file mode 100644 index 201f20b..0000000 --- a/docs/plans/2026-04-27-ckipper-multi-account-design.md +++ /dev/null @@ -1,300 +0,0 @@ -# Ckipper — Multi-Account Claude Code Sandbox - -**Date:** 2026-04-27 -**Status:** Design (revised after panel review — 6 reviewers + Team Lead, all GO-WITH-FIXES) - -## Overview - -Rename the project formerly called `claude-docker-sandbox` to **Ckipper** (pronounced "skipper") and add support for running multiple Claude Code accounts (personal, work, etc.) concurrently in different terminals or Docker containers without shared auth, MCP config, sessions, or settings. - -The design is generic for N ≥ 1 accounts and agnostic to specific account names. Account names like `personal`, `work`, or any other lowercase-alphanumeric string are user choices, never hardcoded. - -## Revisions from panel review (post-initial-draft) - -Six reviewers (Senior SWE, Senior Architect, Security, DevOps, DX, QA) plus a Team Lead synthesized the following changes into the design before execution: - -- **Drop the `~/.claude → ~/.claude-personal` symlink.** Originally proposed as backward-compat for bare `claude`, the symlink created four failure modes (dangling on `remove personal`, interaction with upstream issue #3833 workspace writes, hook realpath drift across versions, target-swap attack vector) for one minor convenience. After migrate, the user uses `claude-personal` (or whatever name they registered). Bare `claude` with no `CLAUDE_CONFIG_DIR` falls back to whatever Claude Code's default is, which after migration is an empty `~/.claude` (Claude will prompt to log in fresh). The README warns about this. -- **Hooks must protect `~/.ckipper/` and per-account dirs.** `bash-guardrails.sh` and `protect-claude-config.sh` get an explicit anchored regex covering `$HOME/.claude(-[a-z0-9_-]+)?/` and `$HOME/.ckipper/`. Without this, a Claude session in account A can edit `~/.ckipper/accounts.json` to repoint A's name at B's keychain service — a credential cross-contamination vector. -- **Validate `keychain_service` shape before passing to `security`.** Empty strings or shell metacharacters from a corrupted registry would otherwise reach `security find-generic-password -s …`. Required regex: `^Claude Code-credentials(-[a-f0-9]+)?$`. -- **Fix the Keychain snapshot.** Use `printf '%s\n'` (not `echo`) to feed `comm`; capture a real `security dump-keychain` sample as a test fixture; detect a locked keychain (timeout + error rather than silent empty diff). -- **`cca` self-containment.** The dispatcher must define its own dependencies so sourcing `aliases.zsh` alone works without `ckipper.zsh`. -- **Migration safety.** Refuse migration if any `claude` process is running. Wrap destructive `mv` in a `trap` that restores on failure. Probe the legacy Keychain entry before trusting it. -- **`accounts.json` schema versioning + `chmod 600`.** Add `"version": 1`. Lock perms. -- **Phase 6.5 Docker smoke test** before live deploy on the user's host. The current plan's Phase 7 was the first time anything ran in Docker. -- **Default `CLAUDE_CONFIG_DIR` in entrypoint should be an error, not a silent fallback to `~/.claude`.** - -Reviewer disagreements resolved by Team Lead: -- Symlink fate (drop vs. clarify vs. protect) → **drop**. -- Default `CLAUDE_CONFIG_DIR` (document vs. error) → **error**. -- `.zshrc` auto-edit (do nothing vs. auto-append) → **auto-append the source line for `~/.ckipper/docker/w-function.zsh`** (consistent with existing `install.sh` behavior); print instructions for the optional `aliases.zsh` source line. - -## Goals - -- Run any number of Claude Code accounts concurrently with full isolation: credentials, `.claude.json`, MCP servers, plugins, hooks, projects, settings. -- Make adding the second, third, fourth account a one-command flow that anyone can follow. -- Preserve the existing `w` worktree + Docker workflow; account becomes another dimension alongside project + branch. -- Migrate existing single-account installs without data loss. -- Keep the project name distinct from "Claude" so docs and conversations don't get muddy. - -## Non-goals (YAGNI) - -- Cross-account project sharing. -- A GUI. -- Auto-detecting the account from cwd or git remote. -- Per-account Docker images. -- Synchronizing credentials across machines. - -## Background — what the research established - -`CLAUDE_CONFIG_DIR` is an undocumented but functional environment variable. When set, Claude Code reads/writes its credentials, `.claude.json`, settings, plugins, hooks, projects, and MCP config from that directory instead of `~/.claude`. On macOS, credentials still go to the Keychain, but the service name is suffixed with a hash of the config dir path — empirically confirmed by inspecting the host (three `Claude Code-credentials*` entries already exist there). Different config dirs produce different Keychain entries, so isolation is real on macOS, not just on disk. - -Known caveats (from upstream issues): -- [#3833](https://github.com/anthropics/claude-code/issues/3833) — Claude Code may still create workspace-local `.claude/` dirs in some cases. Hybrid behavior, undocumented. -- [#24317](https://github.com/anthropics/claude-code/issues/24317) — OAuth refresh-token race when **the same** account runs in two sessions. Different accounts have different refresh tokens, so cross-account concurrent use is not affected. Same-account concurrency is — README will warn. - -The Keychain hash algorithm is undocumented. We do not reverse engineer it; instead, registration snapshots Keychain entries before/after `/login` and records the diff. - -## Architecture - -### Per-account isolation primitive - -Each account is a directory pointed to by `CLAUDE_CONFIG_DIR=$HOME/.claude-`. Every piece of Claude state lives inside it. Accounts are siblings under `$HOME/`: - -``` -~/.claude-personal/ -~/.claude-work/ -~/.claude-/ -``` - -This naming follows Claude Code's own convention (each is literally a Claude config dir) and keeps account dirs visually grouped. - -### Tool layout — Ckipper lives in `~/.ckipper/` - -The sandbox tooling moves out of `~/.claude/docker/` to its own root: - -``` -~/.ckipper/ - docker/ - Dockerfile - entrypoint.sh - init-firewall.sh - fix-volume-perms.sh - w-function.zsh - w-config.zsh # user's port/volume customizations (preserved across updates) - ckipper.zsh # umbrella CLI subcommand dispatcher - cleanup-projects.py # extracted helper, called from w --rm - hooks/ # canonical hook source - bash-guardrails.sh - protect-claude-config.sh - docker-context.sh - notify-bell.sh - aliases.zsh # auto-generated `cca` + `claude-` (sourced by .zshrc; self-contained) - accounts.json # the registry, chmod 600 - settings-template.json # canonical settings used to seed new account dirs -``` - -(Test fixtures like `tests/keychain-dump.sample` live in the *repo*, not in the deployed `~/.ckipper/` — they are dev-time only.) - -The shell sources two lines from `.zshrc` (the first is auto-appended by `install.sh`; the second is suggested if the user wants per-account aliases): - -```zsh -[[ -f ~/.ckipper/docker/w-function.zsh ]] && source ~/.ckipper/docker/w-function.zsh -[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh -``` - -`aliases.zsh` is fully self-contained — it does not depend on `w-function.zsh` or `ckipper.zsh` being sourced. The generated file defines its own `CKIPPER_REGISTRY` path before defining `cca`. - -After migration, **bare `claude`** (no env var, no alias) does whatever Claude Code's default behavior is — which after migration is "no `~/.claude` exists, prompt for fresh login". This is intentional. To use the personal account, the user runs `claude-personal` (or whichever name they registered). - -### Registry — `~/.ckipper/accounts.json` - -```json -{ - "version": 1, - "default": "personal", - "accounts": { - "personal": { - "config_dir": "$HOME/.claude-personal", - "keychain_service": "Claude Code-credentials", - "registered_at": "2026-04-27T18:00:00Z" - }, - "": { - "config_dir": "$HOME/.claude-", - "keychain_service": "Claude Code-credentials-<8hex>", - "registered_at": "..." - } - } -} -``` - -(In the actual file, `$HOME` is expanded — shown above as a placeholder so the example reads correctly for any user.) - -The schema: -- `version: 1` — bumped when fields are added or semantics change. CLI refuses to operate on a registry whose version it doesn't understand. -- `keychain_service` shape is enforced: `^Claude Code-credentials(-[a-f0-9]+)?$`. CLI rejects values that don't match before passing to the macOS `security` command. -- `keychain_service: null` is valid — used when the account authenticates via API key (`.credentials.json` on disk) or on Linux/Windows where there is no Keychain. -- File permissions are `0600` (set on creation and on every write). -- Atomic writes use `flock` on the registry file to serialize concurrent `ckipper add` invocations. - -## Components - -### 1. `ckipper` umbrella CLI - -A zsh function shipped in `~/.ckipper/docker/w-function.zsh` (kept together for one-shot install). - -| Subcommand | Behavior | -|---|---| -| `ckipper add ` | Register a new account. Creates `~/.claude-/`, copies `settings-template.json` and hooks into it, snapshots `Claude Code-credentials*` Keychain entries, prints "Run `claude /login` in this shell with `CLAUDE_CONFIG_DIR=~/.claude-` set, then press enter." After enter: re-snapshots Keychain, finds the new entry, writes it to the registry, regenerates `aliases.zsh`. | -| `ckipper add --adopt` | Register an existing populated account dir without re-login. Lists existing `Claude Code-credentials*` Keychain entries; prompts user to pick the one belonging to this account (or selects automatically if only one is unclaimed by the registry). | -| `ckipper list` | Print accounts, current default, dir presence, and last-login email per account (read from each `/.claude.json`'s `oauthAccount.emailAddress`). | -| `ckipper default ` | Update `accounts.json.default`. | -| `ckipper remove ` | Unregister. Does NOT delete the dir or Keychain entry — prints the commands to do so manually. | -| `ckipper sync-hooks` | Copy `~/.ckipper/hooks/*` into each `/hooks/` and rewrite each `/settings.json` to reference its own absolute hook paths. | -| `ckipper migrate` | Detect a pre-Ckipper layout (`~/.claude/docker/` exists) and run the one-time migration described below. | - -Implementation language: zsh + `jq` for JSON manipulation. Same dependencies the existing tooling already uses. - -### 2. `cca` dispatcher - -`cca [args...]` — short for "claude-config-as". Resolves `` → config dir from the registry, exports `CLAUDE_CONFIG_DIR`, exec's `command claude "$@"`. Used for one-offs without an alias. Errors if `` is not registered. - -### 3. Auto-generated per-account aliases - -`~/.ckipper/aliases.zsh` is regenerated on every `ckipper add` / `ckipper remove`: - -```zsh -# Auto-generated by ckipper. Do not edit by hand. -claude-personal() { CLAUDE_CONFIG_DIR=$HOME/.claude-personal command claude "$@"; } -claude-work() { CLAUDE_CONFIG_DIR=$HOME/.claude-work command claude "$@"; } -``` - -User adds one line to `.zshrc` (handled by `install.sh`). After registration, the new alias is available in any new shell or after `source ~/.ckipper/aliases.zsh`. - -### 4. `w` script — account-aware Docker integration - -`w` gains an `--account ` flag. Active-account resolution order (Option C, env-by-default with flag override): - -1. `--account ` flag if passed. -2. `CLAUDE_CONFIG_DIR` env var if it matches a registered account's `config_dir`. -3. `accounts.json.default` if set. -4. Otherwise: error with a helpful message ("No account selected. Run `ckipper list`."). - -Once resolved, `w` reads `config_dir` and `keychain_service` from the registry and: - -- Extracts credentials from the **right** Keychain entry — `security find-generic-password -s "" -w` instead of the hardcoded `"Claude Code-credentials"`. -- Mounts the per-account config dir into Docker at the same host absolute path (so plugin paths inside `.claude.json` resolve): `-v "$config_dir:$config_dir:rw"`. -- Sets `-e CLAUDE_CONFIG_DIR=$config_dir` inside the container. -- Reads/writes `/.claude.json` instead of `~/.claude.json` for the project-settings sync that runs at worktree creation. -- Drops the dual-mount of `~/.claude` at both `/home/claude/.claude` and `$HOME/.claude`, replaced by a single mount at the per-account host path. **Before this change ships, the implementation phase grep-audits every `/home/claude/.claude` reference under `docker/` and migrates each one to `$CLAUDE_CONFIG_DIR`.** The plan has an explicit task for this audit — silent removal of the dual mount would break uvx pre-install, gh auth setup, and any plugin path that referenced the dropped mount. -- Cross-account `--rm` cleanup walks the registry (not a glob of `~/.claude-*`), so backup or archived dirs aren't picked up by accident. - -### 5. Entrypoint changes - -`~/.ckipper/docker/entrypoint.sh`: - -- Replace `~/.claude.json` and `~/.claude-host.json` references with `$CLAUDE_CONFIG_DIR/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude-host.json`. The "copy from read-only staging" trick stays the same; only the path changes. -- Credentials tmpfs symlink: `ln -sf /tmp/claude-creds/.credentials.json "$CLAUDE_CONFIG_DIR/.credentials.json"` (was `$HOME/.claude/.credentials.json`). -- Pre-install uvx-based MCP servers from `$CLAUDE_CONFIG_DIR/.claude.json`. -- Git identity continues to read from `oauthAccount` in `$CLAUDE_CONFIG_DIR/.claude.json`. Each account's `displayName` / `emailAddress` is now per-account, so commits made in a "work" container use the work email automatically. This is a feature. - -### 6. Hooks installation - -`install.sh`: - -- Deploys this repo's `docker/`, `hooks/`, `settings-hooks.json`, and the new `ckipper` CLI to `~/.ckipper/`. -- Adds the source line(s) to `.zshrc` if missing. -- If accounts are registered: runs `ckipper sync-hooks` to push hooks into each account's dir. -- If no accounts are registered: leaves a `settings-template.json` alongside the canonical hooks for `ckipper add` to use when initializing new account dirs. - -Each account's `settings.json` references absolute paths to **its own** hooks (`~/.claude-/hooks/.sh`), so the right hooks fire under the right account. - -### 7. Migration for existing users — `ckipper migrate` - -Detects pre-Ckipper layouts and runs a guided migration. Idempotent. Safety-checked. - -**Preconditions enforced before any destructive operation:** - -1. No `claude` process is currently running (`pgrep -f "claude " >/dev/null` must return non-zero, else abort with a clear message). -2. `~/.claude-personal` does not already exist (else abort — `mv` would nest, not overwrite). -3. The legacy `Claude Code-credentials` Keychain entry actually returns credentials (`security find-generic-password -s "Claude Code-credentials" -w` succeeds). If not, the user is shown a list of `Claude Code-credentials*` entries and asked to pick. - -**The destructive sequence is wrapped in a `trap` that restores on failure** — if `mv` succeeds but the registry write fails, the trap reverses the move so the user is never left with a missing `~/.claude`. - -| Detected state | Action | -|---|---| -| `~/.claude/docker/` exists | Copy tooling files to `~/.ckipper/`. Preserve `w-config.zsh` (user customization). Old dir kept for one release cycle (prints recovery instructions). | -| `~/.claude/.claude.json` exists with an `oauthAccount` | After preconditions pass: offer to register it as `personal`. If accepted: rename `~/.claude` → `~/.claude-personal` (no symlink — see "Revisions from panel review"), write the registry entry. Probe the matched Keychain entry first. | -| Old `~/.zshrc` source line points at `~/.claude/docker/w-function.zsh` | Print a one-line update for the user to apply (`install.sh` may auto-edit; `migrate` does not). | -| Existing hooks in `~/.claude/hooks/` | After the rename, settings.json now lives at `~/.claude-personal/settings.json` and references `~/.claude-personal/hooks/*` (rewritten by `sync-hooks`). | -| Old `claude-dev` Docker image | `docker rmi claude-dev 2>/dev/null` — best-effort cleanup of the renamed image. | - -After migrate completes successfully, the user runs `ckipper add ` for each additional account, and uses `claude-personal` (not bare `claude`) to launch Claude Code. - -## Issues from research — and how the design handles them - -1. **Plain `claude` after rename** — *not preserved* (decision reversed in panel review). After migration, `~/.claude` no longer exists. Bare `claude` invocations create a fresh empty `~/.claude` and prompt for login. The README and `ckipper migrate` output state this explicitly: "after migrate, use `claude-personal` to launch Claude Code with your personal account." -2. **Keychain hash discovery** — `ckipper add` snapshots Keychain entries before/after the user runs `/login`, then diffs to find the new entry. The snapshot uses `printf '%s\n'` (not `echo`) for `comm`-friendly input, detects a locked keychain via timeout, and is regression-tested against a captured `security dump-keychain` fixture under `~/.ckipper/tests/`. -3. **`w` Docker integration** — fully parameterized via the registry: per-account Keychain service, per-account mount path, per-account `CLAUDE_CONFIG_DIR` in container. `keychain_service` shape is validated before passing to the `security` command. No more hardcoded `Claude Code-credentials`. -4. **Hooks duplication across accounts** — `ckipper sync-hooks` is a one-command refresh. Each account's `settings.json` references its own absolute paths, so isolation is real. Both `bash-guardrails.sh` and `protect-claude-config.sh` extend their protected-path regex to cover `$HOME/.claude(-[a-z0-9_-]+)?/` and `$HOME/.ckipper/` (so a session in account A can't tamper with the registry to redirect account B's credentials). -5. **OAuth race (#24317)** — different accounts have different refresh tokens; only same-account concurrent use can race. README warns prominently. Two terminals using `claude-personal` simultaneously is the bad case; one `claude-personal` and one `claude-work` is fine. -6. **`#3833` workspace-local `.claude/`** — out of our control. If it appears, we document and report upstream. -7. **Registry tampering** — `~/.ckipper/accounts.json` lives in user-writable space. Defenses: hooks block Edit/Write to it from inside Claude sessions; `chmod 600` reduces accidental exposure; `keychain_service` shape validation rejects corrupt values before `security` is invoked. -8. **Migration data loss** — `ckipper migrate` enforces three preconditions before any `mv` and wraps the destructive sequence in an error-trap that restores the previous state on any failure. - -## Data flow — typical session - -``` -User opens Ghostty terminal A: - $ claude-work # alias exports CLAUDE_CONFIG_DIR=~/.claude-work - # claude reads creds from Keychain entry "Claude Code-credentials-" - # session lives in ~/.claude-work/projects/<...> - -User opens Ghostty terminal B: - $ claude-personal # alias exports CLAUDE_CONFIG_DIR=~/.claude-personal - # claude reads creds from Keychain entry "Claude Code-credentials" - # session lives in ~/.claude-personal/projects/<...> - -User opens Ghostty terminal C: - $ w myorg/app feature --account work --docker claude - # w reads accounts.json → "work" config_dir + keychain_service - # security find-generic-password -s "Claude Code-credentials-" - # docker run with: - # -v ~/.claude-work:~/.claude-work:rw - # -e CLAUDE_CONFIG_DIR=~/.claude-work - # entrypoint copies .claude-host.json → .claude.json inside container - # writes credentials to tmpfs, symlinks to ~/.claude-work/.credentials.json - # exec claude --dangerously-skip-permissions -``` - -All three sessions are independent. Different accounts, different credentials, different project state, different MCP config. - -## README updates - -The README gets a new "Multiple accounts" section with: - -- One-paragraph explanation of why anyone would want this. -- Quickstart: `ckipper add work`, follow prompts, start using `claude-work`. -- Concurrent-use warning: don't run the same account in two sessions; do run different accounts in two sessions. -- Migration steps for existing users (`ckipper migrate`). -- Reference table of files/dirs Ckipper creates and what each is for. - -Old "claude-docker-sandbox" naming gets replaced wholesale with "Ckipper". - -## Open questions - -- Should `cca` and the auto-generated aliases live in the **same** `aliases.zsh`, or split? **Decision:** same file. Simpler. -- Should `ckipper sync-hooks` run automatically on `ckipper add`? **Decision:** yes, on first add for that account. Manual otherwise. -- Should `ckipper add --adopt` also work for the brand-new `~/.claude` dir at first install? **Decision:** yes — `ckipper migrate` is just `ckipper add personal --adopt` with extra layout-cleanup. - -## Implementation phases (preview — full plan in `2026-04-27-ckipper-multi-account-implementation.md`) - -1. Rename project metadata (CLAUDE.md, README.md, install.sh paths, Docker image tag). No behavior change. -2. Move tooling location: `~/.claude/docker/` → `~/.ckipper/`. Update install.sh + auto-append `.zshrc` source line. -3. Add `ckipper` CLI with `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. Includes registry schema versioning, file locking, fixture-based Keychain regression test. -4. Make `w` and `entrypoint.sh` account-aware. Audit `/home/claude/.claude` references first; remove only after each one is migrated to `$CLAUDE_CONFIG_DIR`. Validate `keychain_service` shape before passing to `security`. -5. Extend hooks (`bash-guardrails.sh`, `protect-claude-config.sh`) to cover `~/.ckipper/` and per-account dirs. Re-run existing `test-prompt.md` Section 10 hook-bypass tests after the change. -6. README, CLAUDE.md, and test-prompt.md updates with multi-account walkthrough, concurrent-use warning, migration preamble, and concrete Section 12 isolation assertions. -6.5. Build the renamed `ckipper-dev` image and run a Docker smoke test against a registered test account before deploying anywhere real. -7. Run `ckipper migrate` on the implementer's host. Add additional accounts with generic placeholder names (``, ``). End-to-end validation against both accounts in concurrent Docker sessions. - -Each phase is independently testable. Phase 7 is the only phase that touches the implementer's actual host. diff --git a/docs/plans/2026-04-27-ckipper-multi-account-implementation.md b/docs/plans/2026-04-27-ckipper-multi-account-implementation.md deleted file mode 100644 index 65616a3..0000000 --- a/docs/plans/2026-04-27-ckipper-multi-account-implementation.md +++ /dev/null @@ -1,1783 +0,0 @@ -# Ckipper Multi-Account Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Rename `claude-docker-sandbox` to **Ckipper** (pronounced "skipper") and add support for running N concurrent Claude Code accounts with full isolation across credentials, settings, MCP, plugins, hooks, projects, and Docker sessions. - -**Architecture:** Each account is a `CLAUDE_CONFIG_DIR=~/.claude-/` directory. A registry at `~/.ckipper/accounts.json` maps account names to dirs and macOS Keychain service names. The new `ckipper` CLI registers/lists/removes accounts and auto-generates `claude-` shell aliases. The `w` script and Docker entrypoint become account-aware via an `--account` flag, falling back to `CLAUDE_CONFIG_DIR` env var, then a registered default. Sandbox tooling moves out of `~/.claude/docker/` to its own root `~/.ckipper/` so tooling and account state are decoupled. - -**Tech Stack:** zsh (functions, completions), bash (entrypoint, hooks, install), `jq` for JSON, `flock` for atomic registry writes, `security` (macOS Keychain), Docker, git worktrees. - -**Reference:** `docs/plans/2026-04-27-ckipper-multi-account-design.md` - -**Status:** Revised after panel review (6 reviewers + Team Lead, all GO-WITH-FIXES). Revisions are folded into individual tasks below — they are not a separate phase. - ---- - -## Prerequisites — read this before Task 1 - -**Starting repository state (verify before beginning):** - -```bash -git rev-parse --abbrev-ref HEAD # → feature/ckipper-multi-account -git status # working tree clean -ls docs/plans/ # contains the design + this implementation file -``` - -If you are not on `feature/ckipper-multi-account`, stop and ask the user. If the working tree has uncommitted changes, stop and ask. The first two commits on this branch should already be the design doc and the (now-revised) implementation plan — do not re-create them. - -**Required tools on the implementer's machine:** - -- `zsh` (the project's primary shell) -- `bash` (entrypoint, hooks) -- `jq` (1.6 or newer — `jq walk` is used in `sync-hooks`) -- `flock` (registry locking — bundled on Linux; `util-linux` on macOS via Homebrew, BUT macOS ships with a different tool: see fallback note below) -- `shellcheck` -- Docker (for Phase 6.5 onwards) -- `git` (with whatever signing config the user already has — see GPG note below) -- `python3` (for `docker/cleanup-projects.py`) - -**macOS `flock` fallback:** if `flock` is not in PATH, the registry-update helper should fall back to a `mkdir`-based lock: `until mkdir "$CKIPPER_DIR/.registry.lock.d" 2>/dev/null; do sleep 0.05; done; trap 'rmdir "$CKIPPER_DIR/.registry.lock.d"' EXIT INT TERM`. Add this to `_ckipper_registry_update` as a runtime check during Task 9 if `flock` is unavailable. - -**GPG signing under sandboxed Bash:** the implementer's git config has commit signing enabled. Inside Claude Code's default sandbox, `gpg-agent` access fails with "Operation not permitted" — every commit will fail. Each `git commit` in this plan must be invoked with `dangerouslyDisableSandbox: true` (Bash tool parameter). This is environmental, not a code issue. Do not amend commits to skip signing without the user's explicit consent. - -**Two deployments to keep in sync** (already noted in `CLAUDE.md`): this repo (development) and the user's live install (`~/.ckipper/` post-migration). Phases 1–6.5 only touch the repo. Phase 7 deploys to the host. - -**Memory and CLAUDE.md persist across context clears.** Verify by checking that `~/.claude/projects/-Users-matt-Developer-Whmoro-claude-docker-sandbox/memory/MEMORY.md` mentions the Ckipper rename. If it does, the high-level project context is intact even after a fresh start. - ---- - -## Working Conventions - -- **Branch:** `feature/ckipper-multi-account` (off `develop`). -- **Commits:** small and frequent — one per task. PR target: `develop`. -- **Verification per task:** `shellcheck` on bash files, `zsh -n` on zsh files (shellcheck doesn't lint zsh well), then a smoke test that sources the function and exercises it. Fixture tests live in `tests/`. Heavyweight Docker testing happens at Phase 6.5 (smoke) and Phase 7 (full). -- **Local deployment:** the implementer's host has a live install at `~/.claude/docker/` (old) → `~/.ckipper/` (new). Do not run `install.sh` on the host until Phase 7. Phases 1–6 happen on the feature branch only. -- **Naming:** generic placeholders in repo only (``, `personal`, `work`, ``). Never hardcode any specific user's account name or email. -- **Fixture cleanup:** every `/tmp/ckipper-test-*` fixture has a `trap 'rm -rf …' EXIT` to prevent state leak between tasks. -- **Scope guard:** if a task tempts you to "also clean up X", stop. Add it to the follow-ups list. The plan is the plan. - ---- - -## Phase 1 — Rename to Ckipper (text-only, no behavior change) - -### Task 1: Rename in CLAUDE.md - -**Files:** Modify `CLAUDE.md`. - -**Step 1: Replace project name in description.** Change the first paragraph from: -> Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. - -To: -> **Ckipper** (pronounced "skipper") — Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. - -**Step 2:** Update the `Architecture` and `Development Workflow` sections to reference `~/.ckipper/` instead of `~/.claude/docker/` and `~/.claude/hooks/`. This is documentation forward-looking; actual file moves happen in Phase 2. - -**Step 3: Verify** - -```bash -grep -n "claude-docker-sandbox\|~/.claude/docker\|~/.claude/hooks" CLAUDE.md -``` - -Expected: empty (or only intentional historical references). - -**Step 4: Commit** - -```bash -git add CLAUDE.md -git commit -m "Rename project to Ckipper in CLAUDE.md" -``` - -### Task 2: Rename in README.md - -**Files:** Modify `README.md`. - -**Step 1:** Replace all occurrences of "claude-docker-sandbox" with "Ckipper". Add the pronunciation note ("pronounced 'skipper'") to the heading. - -**Step 2:** Update install/source instructions to reference `~/.ckipper/docker/w-function.zsh`. Leave a "migrating from previous versions" placeholder filled in by Task 19 (full README rewrite). - -**Step 3: Verify** - -```bash -grep -n "claude-docker-sandbox" README.md -``` - -Expected: empty. - -**Step 4: Commit** - -```bash -git add README.md -git commit -m "Rename project to Ckipper in README" -``` - -### Task 3: Rename Docker image tag - -**Files:** Modify `w-function.zsh`, `CLAUDE.md`, `README.md`, `docker/Dockerfile` (LABEL/comments). - -**Step 1:** Rename the Docker image tag from `claude-dev` to `ckipper-dev` in: -- `_w_build_image` (currently line ~41) -- The existence check (currently line ~269) -- The parallel-container detection (currently line ~417, the `ancestor=claude-dev` filter) -- Any LABEL or comment in `docker/Dockerfile` -- Any documentation references in `CLAUDE.md` and `README.md` - -**Step 2: Verify** - -```bash -grep -rn "claude-dev" w-function.zsh CLAUDE.md README.md docker/ -``` - -Expected: empty. - -**Step 3: Commit** - -```bash -git add w-function.zsh CLAUDE.md README.md docker/Dockerfile -git commit -m "Rename Docker image tag claude-dev -> ckipper-dev" -``` - -Note: the implementer's live deployment still has the `claude-dev` image cached. Phase 7 (`ckipper migrate`) runs `docker rmi claude-dev 2>/dev/null` to clean it up. - ---- - -## Phase 2 — Move tooling location to `~/.ckipper/` - -### Task 4: Update `install.sh` to deploy to `~/.ckipper/` - -**Files:** Modify `install.sh`. - -**Step 1:** Read the existing `install.sh` end-to-end. Identify every absolute reference to `~/.claude/docker/` and `~/.claude/hooks/`. - -**Step 2:** Introduce `CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}"` at the top. Replace `~/.claude/docker/` with `$CKIPPER_DIR/docker/` throughout. Hooks deploy to `$CKIPPER_DIR/hooks/` (canonical source); per-account hook copies happen via `ckipper sync-hooks`. - -**Step 3:** Preserve `w-config.zsh` (existing `install.sh` already special-cases this — extend the guard to also protect `accounts.json` and `aliases.zsh` if they exist in `$CKIPPER_DIR`). - -**Step 4:** **Settings.json merge — decision: drop it from `install.sh` entirely.** The current install merges `settings-hooks.json` into `~/.claude/settings.json`. In the multi-account world, hook settings live per-account and are written by `ckipper sync-hooks` from `$CKIPPER_DIR/settings-template.json`. Move the existing `settings-hooks.json` content into a new file `$CKIPPER_DIR/settings-template.json` (deployed by `install.sh`); `ckipper add` and `ckipper sync-hooks` consume it. - -**Step 5:** **`.zshrc` auto-edit policy — keep the existing auto-append behavior but update the source line.** The current `install.sh` (lines ~77-84) auto-appends `source ~/.claude/docker/w-function.zsh`. Update this to append `source ~/.ckipper/docker/w-function.zsh` instead, with an idempotent grep guard. **Print** (do not auto-append) the optional second line for `aliases.zsh` since that's user-choice and depends on whether they want per-account aliases. - -**Step 6: Verify** - -```bash -shellcheck install.sh -bash -n install.sh -``` - -Expected: no errors. - -**Step 7: Commit** - -```bash -git add install.sh -git commit -m "Deploy Ckipper tooling to ~/.ckipper/ and split settings template" -``` - -### Task 5: Update internal path references in `w-function.zsh` - -**Files:** Modify `w-function.zsh`. - -**Step 1:** Replace the config-source path: - -```zsh -# old -_w_config="$HOME/.claude/docker/w-config.zsh" -# new -_w_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" -``` - -And in `_w_build_image`: - -```zsh -local docker_dir="${CKIPPER_DIR:-$HOME/.ckipper}/docker" -``` - -**Step 2:** Verify no other `~/.claude/docker` strings remain: - -```bash -grep -n "claude/docker" w-function.zsh -``` - -Expected: empty. - -**Step 3:** Verify the function still parses: - -```bash -zsh -n w-function.zsh -``` - -Expected: exit 0 with no output. - -**Step 4: Commit** - -```bash -git add w-function.zsh -git commit -m "Source w-function from ~/.ckipper/ instead of ~/.claude/docker/" -``` - -### Task 6: Add `install.sh` migration of legacy `~/.claude/docker/` layout - -**Files:** Modify `install.sh`. - -**Important ordering:** This task lands AFTER Task 4 in the same branch (Task 4 must rewrite the deploy targets first; otherwise `install.sh` writes to both `~/.claude/docker/` AND `~/.ckipper/`, leaving drifting copies). Verify Task 4's changes are present before applying this one. - -**Step 1:** At the top of `install.sh`, after `CKIPPER_DIR` is defined, add a migration block: - -```bash -# Migrate legacy ~/.claude/docker/ layout if present (idempotent) -LEGACY_DIR="$HOME/.claude/docker" -if [ -d "$LEGACY_DIR" ] && [ ! -d "$CKIPPER_DIR" ]; then - echo "Migrating ~/.claude/docker/ -> $CKIPPER_DIR/" - mkdir -p "$CKIPPER_DIR" - cp -a "$LEGACY_DIR/." "$CKIPPER_DIR/" - echo "Migrated. The legacy directory is left intact at $LEGACY_DIR for one release cycle." - echo "After verifying the new location works (ckipper list shows your accounts):" - echo " rm -rf $LEGACY_DIR" -fi -``` - -**Step 2:** Sweep the user's `w-config.zsh` (just migrated) for stale `~/.claude/docker/` paths. If found, print a warning naming the lines so the user can update — do not auto-edit user-customized config. - -**Step 3:** Update the `.zshrc` source line (extends Task 4's auto-append): - -```bash -# Update legacy source line if present, otherwise auto-append the new one (existing pattern) -if grep -q "source.*\.claude/docker/w-function.zsh" "$HOME/.zshrc" 2>/dev/null; then - sed -i.bak 's|source.*\.claude/docker/w-function\.zsh|source ~/.ckipper/docker/w-function.zsh|' "$HOME/.zshrc" - echo "Updated ~/.zshrc source line. Backup at ~/.zshrc.bak." -fi -``` - -(`sed -i.bak` is portable across macOS and GNU sed.) - -**Step 4: Verify** - -```bash -shellcheck install.sh -``` - -Expected: no errors (warnings about quoting OK if pre-existing). - -**Step 5: Commit** - -```bash -git add install.sh -git commit -m "install.sh: migrate legacy ~/.claude/docker/ to ~/.ckipper/" -``` - ---- - -## Phase 3 — Account registry + `ckipper` CLI - -### Task 7: Create the `ckipper` CLI scaffold - -**Files:** Create `ckipper.zsh`. - -**Step 1:** Create `ckipper.zsh` at the repo root: - -```zsh -# Ckipper (pronounced "skipper") — multi-account Claude Code manager -# Sourced by w-function.zsh - -CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" -CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" -CKIPPER_REGISTRY_VERSION=1 - -ckipper() { - local cmd="$1" - shift 2>/dev/null - case "$cmd" in - # --help on any subcommand short-circuits to subcommand help - add|list|default|remove|sync-hooks|migrate) - if [[ "$1" == "--help" || "$1" == "-h" ]]; then - _ckipper_help_for "$cmd" - return 0 - fi - "_ckipper_${cmd//-/_}" "$@" - ;; - ""|help|-h|--help) _ckipper_help ;; - *) echo "Unknown command: $cmd"; _ckipper_help; return 1 ;; - esac -} - -_ckipper_help() { - cat <<'EOF' -ckipper (pronounced "skipper") — multi-account Claude Code manager - -Usage: - ckipper add Register a new account (interactive /login) - ckipper add --adopt Register an existing populated config dir - ckipper list Show registered accounts - ckipper default Set the default account - ckipper remove Unregister (does not delete the dir) - ckipper sync-hooks Copy hooks into all registered accounts - ckipper migrate One-time migration from legacy layout - -Companion commands (sourced via aliases.zsh): - cca [args...] Run claude with account (one-off) - claude- [args...] Auto-generated alias per registered account - -Run `ckipper --help` for per-subcommand details. -EOF -} - -_ckipper_help_for() { - case "$1" in - add) - cat <<'EOF' -ckipper add [--adopt] - -Register a new account. must match ^[a-z0-9_-]+$. - -Without --adopt: creates ~/.claude-/ and walks you through /login. -With --adopt: registers an existing populated ~/.claude-/ directory. -EOF - ;; - list) echo "ckipper list — print registered accounts, default, and last-login email." ;; - default) echo "ckipper default — set the default account used when no flag/env is provided." ;; - remove) echo "ckipper remove — unregister. Does not delete the dir or Keychain entry." ;; - sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; - migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; - esac -} - -# Stubs — implemented in subsequent tasks -_ckipper_add() { echo "ckipper add: not yet implemented"; return 1; } -_ckipper_list() { echo "ckipper list: not yet implemented"; return 1; } -_ckipper_default() { echo "ckipper default: not yet implemented"; return 1; } -_ckipper_remove() { echo "ckipper remove: not yet implemented"; return 1; } -_ckipper_sync_hooks() { echo "ckipper sync-hooks: not yet implemented"; return 1; } -_ckipper_migrate() { echo "ckipper migrate: not yet implemented"; return 1; } -``` - -**Step 2:** At the bottom of `w-function.zsh`, after the existing completion block, add: - -```zsh -# Source ckipper subcommand dispatcher (if deployed) -[[ -f "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" ]] && \ - source "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" -``` - -**Step 3:** Update `install.sh` to deploy `ckipper.zsh` to `$CKIPPER_DIR/docker/ckipper.zsh`. - -**Step 4: Verify** - -```bash -zsh -n ckipper.zsh -zsh -c 'source ./w-function.zsh; source ./ckipper.zsh; ckipper' -zsh -c 'source ./ckipper.zsh; ckipper add --help' -``` - -Expected: top-level help, then `add` subcommand help. - -**Step 5: Commit** - -```bash -git add ckipper.zsh w-function.zsh install.sh -git commit -m "Add ckipper CLI scaffold with per-subcommand help" -``` - -### Task 8: Implement `ckipper list` - -**Files:** Modify `ckipper.zsh`. - -**Step 1:** Replace the `_ckipper_list` stub: - -```zsh -_ckipper_list() { - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - echo "No accounts registered. Run: ckipper add " - return 0 - fi - local default - default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - echo "Registered accounts:" - jq -r '.accounts | to_entries[] | " \(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ - while IFS=$'\t' read -r name dir; do - local marker=" " - [[ "$name" == "$default" ]] && marker="* " - local email="" - if [[ -f "$dir/.claude.json" ]]; then - email=$(jq -r '.oauthAccount.emailAddress // ""' "$dir/.claude.json" 2>/dev/null) - fi - local exists="(missing)" - [[ -d "$dir" ]] && exists="" - echo "$marker$name $dir ${email:+($email)} $exists" - done - echo "" - echo "* = default. Run: ckipper default " - echo "" - echo "Reminder: do not run the same account in two sessions concurrently — see #24317." -} -``` - -**Step 2: Verify** — fixture test driven through real derivation, not direct `CKIPPER_REGISTRY` overrides: - -```bash -TEST_HOME=$(mktemp -d -t ckipper-test-list-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -mkdir -p "$TEST_HOME/.ckipper" -cat > "$TEST_HOME/.ckipper/accounts.json" <` (interactive login flow) - -This task absorbs the heaviest revisions from the panel review. Read the **Reviewer notes** at the end before implementing. - -**Files:** Modify `ckipper.zsh`. Create `tests/keychain-dump.sample`. - -**Step 1: Capture a real Keychain-dump fixture.** On macOS: - -```bash -mkdir -p tests -security dump-keychain 2>/dev/null | \ - awk '/^keychain: / || /class: "genp"/ || /"svce"=/ || /"acct"=/' | \ - sed 's|/Users/[^/]*/|/Users//|g' \ - > tests/keychain-dump.sample -``` - -This sample is what the snapshot parser expects to consume. Trim it to ~3 entries (one Claude entry, two unrelated) so the test is deterministic. Commit it under `tests/`. - -**Step 2: Implement `_ckipper_keychain_snapshot` and validation helpers:** - -```zsh -# Validates a keychain_service name before passing to `security`. -# Accepts "Claude Code-credentials" optionally followed by "-<8hex>". -_ckipper_validate_keychain_service() { - local svc="$1" - [[ -z "$svc" ]] && return 1 - [[ "$svc" =~ ^Claude\ Code-credentials(-[a-f0-9]+)?$ ]] -} - -_ckipper_keychain_snapshot() { - # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. - [[ "$OSTYPE" != darwin* ]] && return 0 - - # Fail loudly if keychain is locked (timeout protects against GUI prompt blocking). - local out - if ! out=$(timeout 10 security dump-keychain 2>/dev/null); then - echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 - return 1 - fi - - printf '%s\n' "$out" | \ - awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ - sort -u -} - -# Atomic registry write under flock. $1 = filter expression for jq. -_ckipper_registry_update() { - local jq_filter="$1" - shift - local lock="$CKIPPER_DIR/.registry.lock" - mkdir -p "$CKIPPER_DIR" - : > "$lock" - { - flock -x 9 - local tmp; tmp=$(mktemp) - jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" - chmod 600 "$CKIPPER_REGISTRY" - } 9>"$lock" -} - -# Initialize an empty registry with version field. -_ckipper_init_registry() { - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - mkdir -p "$CKIPPER_DIR" - cat > "$CKIPPER_REGISTRY" <&2 - return 1 - fi -} -``` - -**Step 3: Implement `_ckipper_add`:** - -```zsh -_ckipper_add() { - _ckipper_check_registry_version || return 1 - local name="$1" adopt=0 - [[ "$2" == "--adopt" ]] && adopt=1 - if [[ -z "$name" ]]; then - echo "Usage: ckipper add [--adopt]" - return 1 - fi - if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then - echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." - return 1 - fi - - _ckipper_init_registry - - if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then - echo "Account '$name' is already registered." - return 1 - fi - - local dir="$HOME/.claude-$name" - - if [[ $adopt -eq 1 ]]; then - if [[ ! -d "$dir" ]]; then - echo "Cannot adopt: $dir does not exist." - return 1 - fi - # In adopt mode, list candidate Keychain entries and let the user pick (or skip). - local picked="" - if [[ "$OSTYPE" == darwin* ]]; then - local candidates - candidates=$(_ckipper_keychain_snapshot) || return 1 - if [[ -n "$candidates" ]]; then - echo "Candidate Keychain entries:" - echo "$candidates" | nl - read -r "?Pick a number (or empty to skip): " idx - if [[ -n "$idx" ]]; then - picked=$(echo "$candidates" | sed -n "${idx}p") - if [[ -n "$picked" ]] && ! _ckipper_validate_keychain_service "$picked"; then - echo "Invalid Keychain service shape: $picked" - return 1 - fi - fi - fi - fi - _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" - return $? - fi - - # Fresh registration - if [[ -d "$dir" ]]; then - echo "Directory $dir already exists. Use --adopt to register it." - return 1 - fi - mkdir -p "$dir/hooks" - if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then - cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" - fi - - local before_snapshot - before_snapshot=$(_ckipper_keychain_snapshot) || return 1 - - cat <="Claude Code-credentials/ {print $4}'"'"' | sort -u)" -' -``` - -The snapshot fixture test should print the Claude entry from the sample. If it prints nothing, the awk pattern is broken — fix it before continuing. - -**Step 6: Commit** - -```bash -git add ckipper.zsh tests/keychain-dump.sample -git commit -m "Implement ckipper add: validated keychain shape, atomic registry, fixture test" -``` - -**Reviewer notes folded into this task:** -- `printf '%s\n'` instead of `echo` for `comm` input (handles empty-snapshot case correctly). -- Locked-keychain detection via `timeout 10`. -- `keychain_service` shape validated before write. -- Registry is `chmod 600` and protected by `flock`. -- Schema `version: 1` written on creation. -- "skip" sentinel for the interactive prompt. -- `--adopt` lists candidates and asks the user to pick (no silent guessing). -- Fixture-based snapshot regression test. - -### Task 10: Validate `--adopt` end-to-end - -**Files:** No new files; verification only. - -```bash -TEST_HOME=$(mktemp -d -t ckipper-test-adopt-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -mkdir -p "$TEST_HOME/.claude-personal" -echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > "$TEST_HOME/.claude-personal/.claude.json" -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ - zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt < /dev/null' -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ - zsh -c 'source ./ckipper.zsh; ckipper list' -``` - -Forcing `OSTYPE=linux-gnu` exercises the `keychain_service: null` path (Linux/on-disk credentials) — important regression coverage. Expected: `personal` registered with `(test@example.com)`. - -**Commit only if changes were needed.** - -### Task 11: Implement `ckipper default` and `ckipper remove` - -**Files:** Modify `ckipper.zsh`. - -**Step 1:** - -```zsh -_ckipper_default() { - _ckipper_check_registry_version || return 1 - local name="$1" - [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } - if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then - echo "Account '$name' is not registered." - return 1 - fi - _ckipper_registry_update '.default = $n' --arg n "$name" - echo "Default account is now '$name'." -} - -_ckipper_remove() { - _ckipper_check_registry_version || return 1 - local name="$1" - [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } - if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then - echo "Account '$name' is not registered." - return 1 - fi - local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") - _ckipper_registry_update 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' --arg n "$name" - _ckipper_regenerate_aliases - echo "Unregistered '$name'." - echo "" - echo "The directory and Keychain entry were not deleted. To remove them manually:" - printf " rm -rf %q\n" "$dir" - if [[ -n "$service" ]]; then - printf " security delete-generic-password -s %q\n" "$service" - fi -} -``` - -(`printf '%q'` quote-protects against unusual characters in `$dir`/`$service`.) - -**Step 2: Verify** (uses fixture from Task 10): - -```bash -TEST_HOME=$(mktemp -d -t ckipper-test-remove-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -# ... seed registry with 'personal' ... -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ - zsh -c 'source ./ckipper.zsh; ckipper default personal; ckipper remove personal; ckipper list' -``` - -Expected: default set, then unregistered, then list shows no accounts. - -**Step 3: Commit** - -```bash -git add ckipper.zsh -git commit -m "Implement ckipper default and remove with quote safety" -``` - -### Task 12: Implement self-contained `aliases.zsh` auto-generation - -**Files:** Modify `ckipper.zsh`. - -**Critical constraint:** `aliases.zsh` must be self-contained — sourcing it alone (without `ckipper.zsh` or `w-function.zsh`) must produce a working `cca` and `claude-` set. The generated file defines its own `CKIPPER_REGISTRY` path before defining `cca`. - -**Step 1:** - -```zsh -_ckipper_regenerate_aliases() { - local out="$CKIPPER_DIR/aliases.zsh" - { - echo "# Auto-generated by ckipper. Do not edit by hand." - echo "# Self-contained: does not depend on ckipper.zsh or w-function.zsh being sourced." - echo "# Regenerated whenever an account is added or removed." - echo "" - echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" - echo "" - echo "cca() {" - echo " local name=\"\$1\"; shift" - echo " if [[ -z \"\$name\" ]]; then echo \"Usage: cca [args...]\"; return 1; fi" - echo " local dir" - echo " dir=\$(jq -r --arg n \"\$name\" '.accounts[\$n].config_dir // empty' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" - echo " if [[ -z \"\$dir\" ]]; then echo \"Unknown account: \$name. Run: ckipper list\"; return 1; fi" - echo " CLAUDE_CONFIG_DIR=\"\$dir\" command claude \"\$@\"" - echo "}" - echo "" - if [[ -f "$CKIPPER_REGISTRY" ]]; then - jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ - while IFS=$'\t' read -r name dir; do - echo "claude-$name() { CLAUDE_CONFIG_DIR=\"$dir\" command claude \"\$@\"; }" - done - fi - } > "$out" - chmod 644 "$out" -} -``` - -**Step 2:** `install.sh` already prints (Task 4 step 5) the optional source line for `aliases.zsh`. Confirm that message is still in the install output. - -**Step 3: Verify (independence test)** - -```bash -TEST_HOME=$(mktemp -d -t ckipper-test-aliases-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -mkdir -p "$TEST_HOME/.claude-personal" -echo '{"oauthAccount":{"emailAddress":"x@y.z"}}' > "$TEST_HOME/.claude-personal/.claude.json" -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ - zsh -c 'source ./ckipper.zsh; ckipper add personal --adopt < /dev/null' - -# Source ONLY aliases.zsh (no ckipper.zsh) and confirm cca works. -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ - zsh -c 'source "$CKIPPER_DIR/aliases.zsh"; type cca && type claude-personal' -``` - -Expected: both `cca` and `claude-personal` are defined as functions, even though `ckipper.zsh` was never sourced. - -**Step 4: Commit** - -```bash -git add ckipper.zsh -git commit -m "Auto-generate self-contained aliases.zsh with cca dispatcher" -``` - -### Task 13: Implement `ckipper sync-hooks` - -**Files:** Modify `ckipper.zsh`. - -**Step 1:** - -```zsh -_ckipper_sync_hooks_for() { - local name="$1" - _ckipper_check_registry_version || return 1 - local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - [[ -z "$dir" || "$dir" == "null" ]] && return 1 - mkdir -p "$dir/hooks" - cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true - - # Rewrite settings.json hook paths to absolute paths under this account dir. - if [[ -f "$dir/settings.json" ]] && command -v jq &>/dev/null; then - local tmp; tmp=$(mktemp) - jq --arg d "$dir" ' - (.hooks // {}) as $h | - .hooks = ($h | walk( - if type == "string" and (test("/.claude(-[a-z0-9_-]+)?/hooks/") or test("/.ckipper/hooks/")) - then sub("(/.claude(-[a-z0-9_-]+)?|/.ckipper)/hooks/"; "\($d)/hooks/") - else . end - )) - ' "$dir/settings.json" > "$tmp" && mv "$tmp" "$dir/settings.json" - fi -} - -_ckipper_sync_hooks() { - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - echo "No accounts registered." - return 0 - fi - _ckipper_check_registry_version || return 1 - local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") - while IFS= read -r name; do - echo "Syncing hooks → $name" - _ckipper_sync_hooks_for "$name" - done <<< "$names" -} -``` - -**Step 2: Verify** - -```bash -TEST_HOME=$(mktemp -d -t ckipper-test-sync-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -# ... seed account ... -mkdir -p "$TEST_HOME/.ckipper/hooks" -echo "echo test" > "$TEST_HOME/.ckipper/hooks/sample.sh" -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" \ - zsh -c 'source ./ckipper.zsh; ckipper sync-hooks; ls "$HOME/.claude-personal/hooks/"' -``` - -Expected: `sample.sh` present in the per-account hooks dir. - -**Note on settings-template ownership:** `settings-template.json` is owned by the repo (deployed by `install.sh` to `~/.ckipper/settings-template.json`). It is **seed-only** — accounts diverge after creation. When the template version drifts, already-registered accounts are NOT auto-updated; the user re-runs `ckipper sync-hooks` to refresh hook paths and (in a future task) `ckipper sync-settings` for full template re-application. This stance is documented in CLAUDE.md and README. - -**Step 3: Commit** - -```bash -git add ckipper.zsh -git commit -m "Implement ckipper sync-hooks; document template seed-only stance" -``` - ---- - -## Phase 4 — Account-aware `w` and entrypoint - -### Task 14: `w` resolves the active account - -**Files:** Modify `w-function.zsh`. - -**Step 1:** Add `_w_resolve_account` at the top of `w-function.zsh` (before the `w()` function): - -```zsh -_w_resolve_account() { - local cli_account="$1" - if [[ -n "$cli_account" ]]; then - echo "$cli_account"; return 0 - fi - if [[ -n "$CLAUDE_CONFIG_DIR" && -f "$CKIPPER_REGISTRY" ]]; then - local matched - matched=$(jq -r --arg d "$CLAUDE_CONFIG_DIR" \ - '.accounts | to_entries[] | select(.value.config_dir == $d) | .key' \ - "$CKIPPER_REGISTRY" | head -1) - [[ -n "$matched" ]] && { echo "$matched"; return 0; } - fi - if [[ -f "$CKIPPER_REGISTRY" ]]; then - local default - default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - [[ -n "$default" ]] && { echo "$default"; return 0; } - fi - return 0 -} -``` - -**Step 2:** Add `--account ` to the flag parser: - -```zsh -local cli_account="" -while [[ $# -gt 0 ]]; do - case "$1" in - --docker) docker_mode=1; shift ;; - --firewall) firewall_mode=1; shift ;; - --account) cli_account="$2"; shift 2 ;; - *) command+=("$1"); shift ;; - esac -done -``` - -**Step 3:** After existing arg validation, resolve and validate the account. **No legacy fallback** — if no account resolves, error out (per panel decision: error not silent fallback): - -```zsh -local active_account -active_account=$(_w_resolve_account "$cli_account") -if [[ -z "$active_account" ]]; then - echo "Error: no account selected and no default registered." - echo "Run: ckipper list (then: ckipper default , or pass --account )" - return 1 -fi -local active_config_dir; active_config_dir=$(jq -r --arg n "$active_account" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") -local active_keychain_service; active_keychain_service=$(jq -r --arg n "$active_account" '.accounts[$n].keychain_service // empty' "$CKIPPER_REGISTRY") -if [[ -z "$active_config_dir" ]]; then - echo "Error: account '$active_account' is not registered. Run: ckipper list" - return 1 -fi -``` - -**Step 4: Verify** - -```bash -zsh -n w-function.zsh -TEST_HOME=$(mktemp -d -t w-resolve-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -# ... seed registry with default 'personal' ... -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" CKIPPER_REGISTRY="$TEST_HOME/.ckipper/accounts.json" \ - zsh -c 'source ./w-function.zsh; _w_resolve_account ""' -``` - -Expected: prints `personal`. - -**Step 5: Commit** - -```bash -git add w-function.zsh -git commit -m "w: resolve active account from --account, env, or registered default" -``` - -### Task 15: `w` Docker args use per-account paths and Keychain service - -**Files:** Modify `w-function.zsh`. Create `docker/cleanup-projects.py`. - -**Pre-task audit (CRITICAL):** Before changing any mounts, run: - -```bash -grep -rn "/home/claude/.claude" docker/ w-function.zsh -``` - -For each hit, decide: keep (it's a container `$HOME` path unrelated to Claude state, e.g., `.local/bin`) or migrate (it's a Claude-state path that must move to `$CLAUDE_CONFIG_DIR`). Document the decision in a comment near each line. **Only after this audit is the Step 2 mount change safe.** - -**Step 1:** Replace the hardcoded credential extraction. Validate the service name first: - -```zsh -if ! _ckipper_validate_keychain_service "$active_keychain_service" && [[ -n "$active_keychain_service" ]]; then - echo "Error: account '$active_account' has invalid keychain_service in registry." - echo "Re-register with: ckipper remove $active_account && ckipper add $active_account --adopt" - return 1 -fi -local claude_creds="" -if [[ -n "$active_keychain_service" ]]; then - claude_creds=$(security find-generic-password -s "$active_keychain_service" -w 2>/dev/null) || true -fi -``` - -(Note: `_ckipper_validate_keychain_service` is sourced by `w-function.zsh` because `ckipper.zsh` is sourced from it.) - -**Step 2:** Replace the `~/.claude` mount block. The dropped `/home/claude/.claude` mount is justified by the Step-0 audit: - -```zsh -# old (three mounts) --v "$HOME/.claude:/home/claude/.claude:rw" --v "$HOME/.claude.json:/home/claude/.claude-host.json:ro" --v "$HOME/.claude:$HOME/.claude:rw" -# new (single per-account mount + read-only staging copy) --v "$active_config_dir:$active_config_dir:rw" --v "$active_config_dir/.claude.json:$active_config_dir/.claude-host.json:ro" --e "CLAUDE_CONFIG_DIR=$active_config_dir" -``` - -**Step 3:** Update the gh-token extraction to use the per-account `.claude.json`: - -```zsh -gh_token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' "$active_config_dir/.claude.json" 2>/dev/null) || true -``` - -**Step 4: Extract the worktree project-settings sync to `docker/cleanup-projects.py`** (replaces the inline Python heredocs at lines ~111 and ~232 of the current `w-function.zsh`). The script walks the registry (not a glob — registry-driven cleanup): - -```python -#!/usr/bin/env python3 -"""Remove or sync a worktree path entry from per-account .claude.json files.""" -import json, os, sys - -def all_account_dirs(registry): - if not os.path.exists(registry): return [] - with open(registry) as f: - d = json.load(f) - return [a["config_dir"] for a in d.get("accounts", {}).values() if a.get("config_dir")] - -def remove_worktree_from_all(registry, wt_path): - seen = set() - for cfg_dir in all_account_dirs(registry): - cfg = os.path.join(cfg_dir, ".claude.json") - cfg_real = os.path.realpath(cfg) - if cfg_real in seen: continue - seen.add(cfg_real) - if not os.path.exists(cfg): continue - with open(cfg) as f: - d = json.load(f) - if wt_path in d.get("projects", {}): - del d["projects"][wt_path] - with open(cfg, "w") as f: - json.dump(d, f) - print(f"Removed worktree entry from {cfg}") - -def sync_worktree_settings(registry, account_name, main_path, wt_path): - if not os.path.exists(registry): return - with open(registry) as f: - d = json.load(f) - acc = d.get("accounts", {}).get(account_name) - if not acc: return - cfg = os.path.join(acc["config_dir"], ".claude.json") - if not os.path.exists(cfg): return - with open(cfg) as f: - cd = json.load(f) - main = cd.get("projects", {}).get(main_path, {}) - if not main: return - keys = ["disabledMcpServers", "enabledMcpjsonServers", "disabledMcpjsonServers", - "allowedTools", "hasTrustDialogAccepted", "hasClaudeMdExternalIncludesApproved", - "hasClaudeMdExternalIncludesWarningShown", "hasCompletedProjectOnboarding"] - wt = cd.setdefault("projects", {}).setdefault(wt_path, {}) - for k in keys: - if k in main: wt[k] = main[k] - with open(cfg, "w") as f: - json.dump(cd, f) - print(f"Synced settings for {wt_path} in {cfg}") - -if __name__ == "__main__": - cmd = sys.argv[1] - registry = os.environ.get("CKIPPER_REGISTRY", os.path.expanduser("~/.ckipper/accounts.json")) - if cmd == "remove": - remove_worktree_from_all(registry, sys.argv[2]) - elif cmd == "sync": - sync_worktree_settings(registry, sys.argv[2], sys.argv[3], sys.argv[4]) -``` - -Update `w-function.zsh` to call this script in `--rm` cleanup and worktree-creation sync, replacing the inline heredocs. - -**Step 5:** Drop the post-session credential symlink cleanup at lines ~416-420 of the current `w-function.zsh` — credentials now live inside the per-account dir, and the dir's `.credentials.json` is the symlink. Cleanup happens implicitly when the container exits (tmpfs disappears; the symlink target vanishes but the symlink remains on disk pointing at nothing — harmless and overwritten on next session). - -**Step 6: Verify** - -```bash -zsh -n w-function.zsh -shellcheck -s bash w-function.zsh # warnings OK; errors not -python3 -c "import ast; ast.parse(open('docker/cleanup-projects.py').read())" -``` - -**Step 7: Commit** - -```bash -git add w-function.zsh docker/cleanup-projects.py -git commit -m "w: thread active account through Docker; extract registry-driven cleanup" -``` - -### Task 16: Entrypoint uses `$CLAUDE_CONFIG_DIR` (and errors if unset) - -**Files:** Modify `docker/entrypoint.sh`. - -**Step 1:** At the top, after `set -e`, **error out** (no silent fallback) if the env var is unset: - -```bash -if [ -z "$CLAUDE_CONFIG_DIR" ]; then - echo "Error: CLAUDE_CONFIG_DIR is not set inside the container." >&2 - echo "This means w() did not pass the account context. Bug — please report." >&2 - exit 1 -fi -if [ ! -d "$CLAUDE_CONFIG_DIR" ]; then - echo "Error: CLAUDE_CONFIG_DIR=$CLAUDE_CONFIG_DIR does not exist (mount failed?)." >&2 - exit 1 -fi -``` - -(Per panel decision: silent fallback masks misconfiguration. Always require explicit account context inside the container.) - -**Step 2:** Replace `~/.claude.json` and `~/.claude-host.json` references with `$CLAUDE_CONFIG_DIR/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude-host.json`: - -```bash -if [ -f "$CLAUDE_CONFIG_DIR/.claude-host.json" ]; then - cp "$CLAUDE_CONFIG_DIR/.claude-host.json" "$CLAUDE_CONFIG_DIR/.claude.json" - if command -v jq &>/dev/null; then - jq '.claudeInChromeDefaultEnabled = false | .cachedChromeExtensionInstalled = false' \ - "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ - && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" - fi -fi -``` - -**Step 3:** Update the credential symlink target: - -```bash -ln -sf /tmp/claude-creds/.credentials.json "$CLAUDE_CONFIG_DIR/.credentials.json" -``` - -**Step 4:** Update git identity, MCP pre-install, and uvx jq queries to use `$CLAUDE_CONFIG_DIR/.claude.json`. Note that `$HOME` (container user home) is *not* changed — `.local/bin/bunx`, `.cache/uv`, `.ssh/`, etc. continue to live under `/home/claude/`. Only Claude-state paths move. - -**Step 5: Verify** - -```bash -shellcheck docker/entrypoint.sh -bash -n docker/entrypoint.sh -``` - -**Step 6: Commit** - -```bash -git add docker/entrypoint.sh -git commit -m "entrypoint: read all Claude paths from CLAUDE_CONFIG_DIR; error if unset" -``` - -### Task 17: Hooks protect per-account dirs and `~/.ckipper/` - -**Files:** Modify `hooks/protect-claude-config.sh`, `hooks/bash-guardrails.sh`. Update `test-prompt.md` Section 10. - -**Critical fix from Security review:** without this, a Claude session in account A can edit `~/.ckipper/accounts.json` to redirect account B's keychain service into account A's container — credential cross-contamination. - -**Step 1:** Read both hooks. Identify the existing `~/.claude/` substring patterns. - -**Step 2:** Define the new protected-path regex. It must: -- Match `$HOME/.claude` and `$HOME/.claude-` (any non-empty `[a-z0-9_-]+`) -- Match `$HOME/.ckipper` -- NOT match `$HOME/.claude-host.json` (the in-container read-only mount) - -Required POSIX/PCRE-ish regex (test against both hook languages): - -``` -(^|/)\.claude(-[a-z0-9_-]+)?(/|$)|(^|/)\.ckipper(/|$) -``` - -**Step 3:** In `protect-claude-config.sh`, extend the protected-path check (PreToolUse on Edit/Write) to use the new regex. Block any path matching the regex unless it's in an explicit allow-list (e.g., `/projects/`). - -**Step 4:** In `bash-guardrails.sh`, extend the regex similarly so commands like `echo malicious > ~/.ckipper/accounts.json` are blocked. - -**Step 5:** Add new bypass-attempt entries to `test-prompt.md` Section 10: -- Try editing `~/.ckipper/accounts.json` from inside Claude — must be BLOCKED. -- Try writing to `~/.claude-otheraccount/settings.json` from a session in `personal` — must be BLOCKED. -- Try writing to `$CLAUDE_CONFIG_DIR/projects/whatever.txt` — must be ALLOWED (under projects/). - -**Step 6:** Re-run the existing Section 10 tests after the regex change. Confirm no regression. (Document this re-run in the PR test plan.) - -**Step 7: Verify** - -```bash -shellcheck hooks/*.sh -``` - -**Step 8: Commit** - -```bash -git add hooks/protect-claude-config.sh hooks/bash-guardrails.sh test-prompt.md -git commit -m "hooks: extend protection to per-account dirs and ~/.ckipper" -``` - ---- - -## Phase 5 — Migration command - -### Task 18: Implement `ckipper migrate` (safety-checked, no symlink) - -**Files:** Modify `ckipper.zsh`. - -**Decision-from-panel: drop the `~/.claude → ~/.claude-personal` symlink.** After migration, bare `claude` no longer maps to the personal account. Users use `claude-personal` (or whichever name they registered). The README and `migrate` output state this explicitly. - -**Step 1:** - -```zsh -_ckipper_migrate() { - _ckipper_check_registry_version || return 1 - local legacy_docker="$HOME/.claude/docker" - local legacy_claude="$HOME/.claude" - - # ── Precondition 1: no Claude process running ───────────────── - if pgrep -f "[c]laude " >/dev/null 2>&1; then - echo "Error: a Claude process is currently running. Quit all Claude sessions first." >&2 - echo "Detected: $(pgrep -af '[c]laude ' | head -3)" >&2 - return 1 - fi - - # ── Precondition 2: ~/.claude-personal must not already exist ─ - if [[ -e "$HOME/.claude-personal" ]]; then - echo "Error: $HOME/.claude-personal already exists. Refusing to migrate." >&2 - echo "If you've already migrated, you're done. Run: ckipper list" >&2 - return 1 - fi - - # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── - if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then - mkdir -p "$CKIPPER_DIR" - cp -a "$legacy_docker/." "$CKIPPER_DIR/docker/" - echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact for one release cycle)" - fi - - # ── 2. Adopt ~/.claude as 'personal' if eligible ────────────── - if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]]; then - if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ - ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - - # Show the user what we're about to do. - cat </dev/null 2>&1; then - echo "Warning: '$probed_service' not found in Keychain." - echo "Listing available Claude Keychain entries:" - _ckipper_keychain_snapshot || return 1 - read -r "?Enter the Keychain service for the personal account (or empty to skip): " probed_service - if [[ -n "$probed_service" ]] && ! _ckipper_validate_keychain_service "$probed_service"; then - echo "Invalid Keychain service shape. Aborting." - return 1 - fi - fi - else - probed_service="" - fi - - # ── Destructive operation under trap-rollback ──────── - _migrate_rollback() { - if [[ -d "$HOME/.claude-personal" && ! -e "$legacy_claude" ]]; then - mv "$HOME/.claude-personal" "$legacy_claude" 2>/dev/null - echo "Migration failed — restored $legacy_claude from rollback." >&2 - fi - } - trap _migrate_rollback ERR - - mv "$legacy_claude" "$HOME/.claude-personal" - _ckipper_finalize_registration "personal" "$HOME/.claude-personal" "$probed_service" "migrate" - - trap - ERR - unset -f _migrate_rollback - fi - fi - - # ── 3. Best-effort cleanup of old Docker image ──────────────── - if command -v docker >/dev/null 2>&1; then - docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." - fi - - cat < to add additional accounts. - -To launch Claude with your personal account, use: claude-personal -(Bare 'claude' no longer resolves to your migrated personal account — it will start a fresh login.) - -EOF -} -``` - -**Step 2: Verify with a synthetic legacy host** - -```bash -TEST_HOME=$(mktemp -d -t ckipper-migrate-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -mkdir -p "$TEST_HOME/.claude/docker" -mkdir -p "$TEST_HOME/.claude/hooks" -echo "function w() { :; }" > "$TEST_HOME/.claude/docker/w-function.zsh" -echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > "$TEST_HOME/.claude/.claude.json" -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ - zsh -c 'source ./ckipper.zsh; echo y | ckipper migrate; ckipper list' -``` - -Expected: tooling copied, `~/.claude` renamed to `~/.claude-personal` (no symlink), `personal` registered, list prints reminder about using `claude-personal`. - -**Step 3: Verify failure-rollback path:** - -```bash -TEST_HOME=$(mktemp -d -t ckipper-migrate-rollback-XXXXXX) -trap "rm -rf '$TEST_HOME'" EXIT -mkdir -p "$TEST_HOME/.claude" -echo '{"oauthAccount":{"emailAddress":"test@example.com"}}' > "$TEST_HOME/.claude/.claude.json" -chmod -w "$TEST_HOME" # make registry write fail -HOME="$TEST_HOME" CKIPPER_DIR="$TEST_HOME/.ckipper" OSTYPE=linux-gnu \ - zsh -c 'source ./ckipper.zsh; echo y | ckipper migrate' || true -chmod +w "$TEST_HOME" -ls "$TEST_HOME/" -``` - -Expected: `~/.claude` is restored (rollback fired); `~/.claude-personal` does not exist. - -**Step 4: Commit** - -```bash -git add ckipper.zsh -git commit -m "Implement ckipper migrate: precondition checks, error-trap rollback, no symlink" -``` - ---- - -## Phase 6 — Documentation - -### Task 19: README rewrite (with panel-feedback expansions) - -**Files:** Modify `README.md`. - -**Step 1:** Restructure with the multi-account section surfaced earlier. New top-level section order: - -1. What Ckipper is — one paragraph including the headline that it's multi-account-capable. Include pronunciation note. -2. Quickstart (single account, fresh install). -3. **Multiple accounts** — second-most-prominent section. -4. Migration from `claude-docker-sandbox` — `ckipper migrate` with the "what it will do" preamble. -5. Troubleshooting (concurrent-use warning lives here, prominently). -6. Architecture overview (link to design doc). - -**Step 2: "Multiple accounts" section:** - -````markdown -## Multiple accounts (Ckipper's headline feature) - -Run a personal Claude account in one terminal and a work account in another, fully isolated. Each gets its own credentials, MCP servers, plugins, projects, and session history. - -### Add an account - -```bash -ckipper add work -``` - -`ckipper` walks you through `/login` and registers the account. Repeat for every account you want. - -### Use an account - -Three ways: - -```bash -claude-work # auto-generated alias (preferred) -cca work # one-off dispatcher (claude-config-as) -CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form -``` - -### Inside Docker - -```bash -w myorg/app feature --account work --docker claude -``` - -If you're already in a terminal where `CLAUDE_CONFIG_DIR` is set (e.g., via `claude-work`), `w` picks up the account automatically — no flag needed. - -### List, default, remove - -```bash -ckipper list -ckipper default personal -ckipper remove old-account -``` -```` - -**Step 3: Concurrent-use warning** — its own section with stronger phrasing: - -````markdown -## ⚠️ Don't run the same account in two sessions - -Two terminals running the **same** account simultaneously will hit a known OAuth refresh-token race ([upstream issue #24317](https://github.com/anthropics/claude-code/issues/24317)) — symptoms: frequent re-login prompts, lost sessions. - -**Safe:** `claude-personal` in one terminal, `claude-work` in another. Different accounts, different refresh tokens, no race. -**Bad:** `claude-personal` in two terminals at once. - -If you want concurrent runs of the *same* account, register it twice under two names (`personal-a`, `personal-b`) — though this means re-`/login` for each. -```` - -**Step 4: Migration section:** - -````markdown -## Migrating from claude-docker-sandbox - -If you've been running this project under its previous name with a single `~/.claude/docker/` install, run: - -```bash -ckipper migrate -``` - -This will: -1. Refuse to run if any `claude` process is currently active (quit them first). -2. Copy `~/.claude/docker/` → `~/.ckipper/`. -3. Offer to register your existing `~/.claude` as the `personal` account. If you accept: rename `~/.claude` → `~/.claude-personal`, probe Keychain for the matching credential entry, and write the registry. **No symlink is created** — after migration, you launch Claude with `claude-personal` (bare `claude` will start a fresh login). -4. If anything fails, the rename automatically reverses (rollback trap). - -Then add additional accounts: - -```bash -ckipper add work -``` -```` - -**Step 5: Verify** - -```bash -grep -n "claude-docker-sandbox" README.md -``` - -Expected: empty (or only in historical/migration context). - -**Step 6: Commit** - -```bash -git add README.md -git commit -m "README: rewrite for Ckipper with multi-account walkthrough and warnings" -``` - -### Task 20: Update CLAUDE.md and test-prompt.md - -**Files:** Modify `CLAUDE.md`, `test-prompt.md`. - -**Step 1: CLAUDE.md updates:** -- Tool layout diagram showing `~/.ckipper/` (move from `~/.claude/docker/`). -- New "Multi-account" section under Architecture. -- New "Critical Safety Rules" entry: registry tampering protection and `keychain_service` shape validation. -- Development Workflow table updated with `ckipper.zsh` → install.sh. -- Settings-template seed-only stance documented. - -**Step 2: test-prompt.md — add Section 12 "Multi-account isolation"** with concrete assertions: - -````markdown -## 12. Multi-account isolation - -Run these checks in two concurrent containers (Window A: `--account personal`, Window B: `--account `). - -### A. Each container has the right CLAUDE_CONFIG_DIR -```bash -# In window A -[ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude-personal" ] && echo PASS || echo FAIL -# In window B -[ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude-" ] && echo PASS || echo FAIL -``` - -### B. The right .claude.json was copied -```bash -# In each window -expected_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude-host.json") -actual_email=$(jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude.json") -[ "$expected_email" = "$actual_email" ] && echo PASS || echo FAIL -``` - -### C. Credentials symlinked to tmpfs (and tmpfs is writable, host-mount is not) -```bash -[ -L "$CLAUDE_CONFIG_DIR/.credentials.json" ] && echo PASS || echo FAIL -[ "$(readlink "$CLAUDE_CONFIG_DIR/.credentials.json")" = "/tmp/claude-creds/.credentials.json" ] && echo PASS || echo FAIL -``` - -### D. Other accounts are NOT mounted -```bash -# Window A should NOT see Window B's dir -[ ! -d "$HOME/.claude-" ] && echo PASS || echo FAIL -``` - -### E. Project sessions don't bleed across accounts -After both sessions touch a project (e.g., create a file in `/workspace`), check from the host: -```bash -diff <(ls ~/.claude-personal/projects/ 2>/dev/null) <(ls ~/.claude-/projects/ 2>/dev/null) -# Expected: empty (no shared session dirs) -``` - -### F. Registry tampering blocked -Inside the container, attempt: -```bash -echo modified > ~/.ckipper/accounts.json -# Expected: BLOCKED by bash-guardrails.sh hook -``` -```` - -**Step 3: Commit** - -```bash -git add CLAUDE.md test-prompt.md -git commit -m "Docs: CLAUDE.md and test-prompt.md updates with concrete Section 12 isolation tests" -``` - ---- - -## Phase 6.5 — Pre-deploy Docker smoke test - -### Task 20.5: Build the renamed image and run a single Docker session against a fixture account - -**Why:** Phase 7 is the first time anything runs in real Docker. A bug in Tasks 14-16 (e.g., a dropped mount that breaks `gh auth` or `uvx` pre-install) won't surface until the implementer's host. This task catches it earlier. - -**Files:** None modified — this is a verification task only. - -**Step 1:** From the repo root: - -```bash -# Build the renamed image -docker build --build-arg "CACHEBUST=$(date +%s)" -t ckipper-dev docker/ - -# Set up a test account in a temp HOME -TEST_HOME=$(mktemp -d -t ckipper-smoke-XXXXXX) -mkdir -p "$TEST_HOME/.claude-test/projects" -cat > "$TEST_HOME/.claude-test/.claude.json" <<'EOF' -{"oauthAccount":{"emailAddress":"smoke@test","displayName":"Smoke Test"},"projects":{}} -EOF -mkdir -p "$TEST_HOME/.ckipper" -cat > "$TEST_HOME/.ckipper/accounts.json" < **For the executing agent:** STOP at the end of Phase 6.5. **Phase 7 is user-driven**, not autonomous. These tasks modify the user's actual `~/.claude` (renaming, registering Keychain entries, opening interactive `/login` flows, requiring two Ghostty windows). The agent cannot meaningfully drive an interactive `/login` or coordinate two terminal windows. Hand control back to the user with a summary of what's done and a pointer to Task 21. -> -> The user runs Phase 7 themselves, in their terminal, following these steps as a checklist. The agent may be re-engaged after Task 25 to help compose the PR body if requested. - -### Task 21: Deploy to host - -**Step 1:** - -```bash -./install.sh -``` - -Expected output: migrates `~/.claude/docker/` → `~/.ckipper/`, updates `.zshrc` source line (with `~/.zshrc.bak` backup), prints the optional `aliases.zsh` source line for the user to add manually. - -**Step 2:** Add the optional `aliases.zsh` source line to `~/.zshrc` if not already there: - -```zsh -[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh -``` - -**Step 3:** Restart the shell (`exec zsh`). - -### Task 22: Migrate the existing personal account - -**Step 1:** Quit any running Claude sessions (the migration will refuse if any are active). - -**Step 2:** - -```bash -ckipper migrate -``` - -Confirm the prompt to register `~/.claude` as `personal`. After completion: - -```bash -ckipper list -``` - -Expected: `personal` shown with the implementer's email (whatever's in their `~/.claude/.claude.json`'s `oauthAccount.emailAddress`) and a `* default` marker. - -**Step 3:** Smoke test: - -```bash -claude-personal --version -``` - -Expected: prints version. To verify the right account is loaded: - -```bash -claude-personal -# inside Claude: -/status -``` - -Expected: shows the implementer's personal account email. - -### Task 23: Add a second account - -**Step 1:** Pick a placeholder name for the second account (e.g., `work`, ``). The repo never sees the implementer's specific account name — it stays in the implementer's local registry only. - -```bash -ckipper add -``` - -Follow prompts: `CLAUDE_CONFIG_DIR=~/.claude- claude`, complete `/login`, return and press enter. - -**Step 2:** Verify: - -```bash -ckipper list -``` - -Expected: both `personal` and `` listed with their respective emails. Two distinct Keychain entries detected. - -### Task 24: Validate Docker integration with both accounts - -**Step 1:** In two separate Ghostty windows, register a test project in `w`: - -Window A: -```bash -w some/project test-personal --account personal --docker claude -``` - -Window B: -```bash -w some/project test-work --account --docker claude -``` - -Use different worktree branch names (`test-personal`, `test-work`) so the two sessions don't compete for the same worktree. - -**Step 2:** Inside each container, run the new `test-prompt.md` Section 12 checks (A through F). All must PASS. - -**Step 3:** Run `claude /status` inside each session. Verify each shows the correct account email. - -**Step 4:** End both sessions cleanly. Confirm: - -```bash -ls -la ~/.claude/.credentials.json 2>/dev/null -``` - -Expected: file does not exist on the host (credentials lived in the container's tmpfs and are gone). - -### Task 25: Open PR to develop - -**Step 1:** - -```bash -git push -u origin feature/ckipper-multi-account -``` - -**Step 2:** - -```bash -gh pr create --base develop --title "Ckipper: multi-account Claude Code sandbox" --body "$(cat <<'EOF' -## Summary -- Renames `claude-docker-sandbox` → **Ckipper** (pronounced "skipper"). -- Adds multi-account support via `CLAUDE_CONFIG_DIR=~/.claude-/` and a registry at `~/.ckipper/accounts.json` (versioned, chmod 600, flock-protected). -- New `ckipper` CLI: `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate` — each with `--help`. -- Auto-generates self-contained `aliases.zsh` with `cca ` dispatcher and per-account `claude-` functions. -- `w` and Docker entrypoint thread an active account through every credential, mount, and config path. Entrypoint errors out (no silent fallback) if `CLAUDE_CONFIG_DIR` is unset. -- Hook regex extended to cover `~/.ckipper/` and per-account dirs (closes a credential cross-contamination vector flagged by panel review). -- Migration path with running-Claude precondition, error-trap rollback, and Keychain probe-before-trust. - -Design: `docs/plans/2026-04-27-ckipper-multi-account-design.md` -Plan: `docs/plans/2026-04-27-ckipper-multi-account-implementation.md` - -## Test plan -- [ ] `ckipper list` / `add --adopt` / `add` / `default` / `remove` against temp-HOME fixtures. -- [ ] Keychain snapshot regression test passes against `tests/keychain-dump.sample`. -- [ ] `ckipper migrate` against synthetic legacy `~/.claude/docker/` layout. -- [ ] `ckipper migrate` rollback when registry write fails. -- [ ] Phase 6.5 Docker smoke test passes against test account. -- [ ] Live deploy on host — migration succeeded; `personal` adopted; second account added cleanly. -- [ ] Two concurrent Docker sessions, different accounts, all six Section 12 isolation assertions PASS. -- [ ] No host-side `~/.claude/.credentials.json` symlink remains after sessions end. -- [ ] `test-prompt.md` Section 10 hook-bypass tests still pass after Task 17 regex change. -- [ ] Registry tampering (`echo X > ~/.ckipper/accounts.json` from inside a container) is BLOCKED by hooks. -EOF -)" -``` - -(Test plan checkboxes are unchecked here — the implementer/reviewer ticks them as they verify.) - ---- - -## Follow-ups (out of scope for this PR) - -- `ckipper rename ` — rename a registered account in place. -- `ckipper sync-settings` — re-apply `settings-template.json` to existing accounts when the template changes. -- Bash + non-zsh shell support for `cca`/`claude-` (currently zsh-only). -- Auto-detect account from cwd or git remote (deliberately YAGNI). -- Single-tenant install path skipping the registry (registry-with-one-account is already trivial; not needed). -- **Hooks-by-reference** instead of per-account `cp -a` — point each account's `settings.json` at `~/.ckipper/hooks/.sh` directly. Eliminates drift; needs hook protection extended for the shared path. -- **CI** — GitHub Action running `shellcheck` + `zsh -n` + the fixture tests on every PR. Not blocking this PR but should land soon after. -- **Shell completion** for `ckipper` and `cca` (paralleling existing `w` completion). -- **Statusline indicator** for active account so a user with three terminals knows which is which. -- **`aliases.zsh` integrity** — generated file is currently writable by the user; consider hash-verification or moving to `~/.ckipper/lib/aliases.zsh` with stricter perms. -- **Audit trail** — `ckipper list` warns when `oauthAccount.emailAddress` doesn't appear to match the registered name (catches accidental wrong-account registration). -- **Drop the `w` legacy single-account fallback in v2** once everyone has migrated. Currently no fallback exists — Task 14 errors out — but if we ever add one, target it for removal. -- **Multi-machine credential sync** — registered accounts and their Keychain mappings don't sync across hosts. Not a goal for v1. From 0c70f961ffff02d95f84cd5601b940ec3b9765d4 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 12:55:24 -0600 Subject: [PATCH 029/165] aliases.zsh: guard bare 'claude' from clobbering default account creds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare 'claude' defaults to ~/.claude/ and writes credentials to the unsuffixed 'Claude Code-credentials' Keychain entry — the same entry the migrated 'personal' account is registered against. A fresh /login silently overwrites the registered account's creds, breaking it on next token refresh. The auto-generated aliases.zsh now defines a claude() shell function that refuses bare invocation when accounts are registered, and tells the user to use claude- / cca / 'command claude' for intentional fresh login. Pass-through when no accounts are registered (first-time setup before migrate). Lives in aliases.zsh so .zshrc stays clean — only source lines. --- ckipper.zsh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ckipper.zsh b/ckipper.zsh index 65fe50b..a2c6e17 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -291,6 +291,32 @@ _ckipper_regenerate_aliases() { echo " CLAUDE_CONFIG_DIR=\"\$dir\" command claude \"\$@\"" echo "}" echo "" + # Guard: bare 'claude' would default to ~/.claude/ and write to the unsuffixed + # 'Claude Code-credentials' Keychain entry — which is the SAME entry the + # default account uses. A fresh /login here silently overwrites those creds. + # Block bare 'claude' when accounts are registered; users bypass via 'command claude'. + echo "claude() {" + echo " if [[ -f \"\$_CKIPPER_REGISTRY\" ]] && jq -e '.accounts | length > 0' \"\$_CKIPPER_REGISTRY\" >/dev/null 2>&1; then" + echo " local default" + echo " default=\$(jq -r '.default // \"\"' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" + echo " echo \"Refusing to launch bare 'claude' — Ckipper has registered accounts.\" >&2" + echo " echo \"\" >&2" + echo " echo \"Bare 'claude' uses ~/.claude/ and writes to the Keychain entry your\" >&2" + echo " echo \"default account ('\${default:-personal}') is registered against. A fresh\" >&2" + echo " echo \"/login here would silently overwrite those credentials.\" >&2" + echo " echo \"\" >&2" + echo " if [[ -n \"\$default\" ]]; then" + echo " echo \"Use: claude-\$default (or: cca \$default)\" >&2" + echo " else" + echo " echo \"Set a default first: ckipper default , then use claude-.\" >&2" + echo " fi" + echo " echo \"\" >&2" + echo " echo \"To bypass (fresh login on purpose): command claude \\\$@\" >&2" + echo " return 1" + echo " fi" + echo " command claude \"\$@\"" + echo "}" + echo "" if [[ -f "$CKIPPER_REGISTRY" ]]; then jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ while IFS=$'\t' read -r _name _dir; do From 7418d30465a6431e8ef75e38760e8e72746fef44 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 14:13:50 -0600 Subject: [PATCH 030/165] Pre-flight review fixes (3 blockers + 3 should-fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: _ckipper_keychain_snapshot detects timeout/gtimeout/none and falls through. macOS doesn't ship 'timeout' — without this, ckipper add fails at the keychain snapshot diff with a misleading 'keychain locked' message. B2: _ckipper_migrate sets INT/TERM trap around the destructive section so Ctrl-C between mv and finalize triggers the rollback. Also factored rollback into a single _ckipper_migrate_rollback function used by both the explicit failure path and the trap. B3: Refuse migrate if ~/.claude is a symlink. Renaming a symlink moves the link, not the target — confusing and probably not what the user wants. Print resolution guidance. S4: pgrep -f '[c]laude ' (trailing space) missed Claude.app and bare 'claude'. Switched to 'pgrep -if claude' (case-insensitive substring) so all forms are caught. S5: Migrate's rollback now also dels(.accounts.personal) from the registry if a partial entry was written. Previously, a re-run after failure would refuse with 'registry not empty'. S6: README staleness — line 118 (.claude.json staging mount), line 162 (read-only staging copy claim), line 165 (dual-mount claim), line 282 ('11 sections' → 12). Verified: happy path, symlink rejection, rollback path all pass after fixes. --- README.md | 8 +++--- ckipper.zsh | 73 +++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2201b45..9e5a379 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ If you want concurrent runs of the *same* account, register it twice under two n On every container start, `entrypoint.sh` automatically: -1. **Copies `.claude.json`** from read-only staging mount to writable location (prevents race condition with host) +1. **Reads `.claude.json`** from the bind-mounted per-account dir (`$CLAUDE_CONFIG_DIR/.claude.json`) and mutates chrome flags + MCP rewrites in place. Race protection is via the documented "don't run the same account in two sessions" rule. 2. **Copies and sanitizes SSH config** from read-only `.ssh-host` staging mount — strips macOS-specific `UseKeychain` option that breaks Linux OpenSSH 3. **Disables Chrome extension checks** via jq (no browser in container) 4. **Writes OAuth credentials** from `CLAUDE_CREDENTIALS` env var to `.credentials.json` @@ -159,10 +159,10 @@ Four Claude Code hooks activate inside Docker: - GPG signing disabled via `GIT_CONFIG_COUNT` env vars — no file modification, overrides both local and global config, disappears when container exits - Post-session `.git/config` tamper detection - Credentials cleared from environment before launching the command (invisible to `env` and `/proc/self/environ`) -- `.claude.json` mounted read-only as staging copy (prevents race condition with host) +- Per-account `.claude.json` is bind-mounted RW; container mutations propagate to the host file (intentional, gated by the same-account-twice advisory) - SSH config mounted read-only as staging copy (`.ssh-host`), copied and sanitized by entrypoint — macOS-specific `UseKeychain` stripped - SSH agent forwarded from host via Docker Desktop socket (`/run/host-services/ssh-auth.sock`) — no private keys copied into container -- `~/.claude` dual-mounted at both `/home/claude/.claude` and the host path (e.g. `/Users//.claude`) so plugins with hardcoded absolute paths resolve correctly +- Per-account `~/.claude-` mounted at the same host path inside the container so plugins with hardcoded absolute paths resolve correctly - No Docker socket mounted (cannot create sibling containers) ### Optional Egress Firewall @@ -279,7 +279,7 @@ After setup, run the comprehensive environment test to verify everything works: w test-branch --docker claude ``` -Then paste the contents of [`test-prompt.md`](test-prompt.md) into the Docker Claude session. It covers 11 sections: +Then paste the contents of [`test-prompt.md`](test-prompt.md) into the Docker Claude session. It covers 12 sections: - Entrypoint verification (env vars, git identity, Chrome disabled, Turbo cache, credential clearing from `/proc/self/environ`) - File system access (read, write, delete, ownership, SSH staging mount, config sanitization) diff --git a/ckipper.zsh b/ckipper.zsh index a2c6e17..5021694 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -75,11 +75,29 @@ _ckipper_keychain_snapshot() { # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 - # Fail loudly if keychain is locked (timeout protects against GUI prompt blocking). + # Pick a timeout binary if available (macOS doesn't ship one; gtimeout from + # coreutils is the typical brew install). Fall through to no timeout if neither + # is present — better than failing with a misleading "keychain locked" error. + local timeout_cmd="" + if command -v timeout >/dev/null 2>&1; then + timeout_cmd="timeout 10" + elif command -v gtimeout >/dev/null 2>&1; then + timeout_cmd="gtimeout 10" + fi + local out - if ! out=$(timeout 10 security dump-keychain 2>/dev/null); then - echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 - return 1 + if [[ -n "$timeout_cmd" ]]; then + if ! out=$($timeout_cmd security dump-keychain 2>/dev/null); then + echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 + return 1 + fi + else + # No timeout available — run without. If keychain is locked the GUI + # password prompt will block this, which is a fine failure mode. + if ! out=$(security dump-keychain 2>/dev/null); then + echo "Warning: 'security dump-keychain' failed. Keychain may be locked." >&2 + return 1 + fi fi printf '%s\n' "$out" | \ @@ -427,9 +445,12 @@ _ckipper_migrate() { local legacy_claude="$HOME/.claude" # ── Precondition 1: no Claude process running ───────────────── - if pgrep -f "[c]laude " >/dev/null 2>&1; then + # Match (a) Claude.app GUI process names, (b) bare 'claude' CLI invocation + # (no trailing args), (c) 'claude '. Case-insensitive to catch all forms. + if pgrep -if 'claude' >/dev/null 2>&1; then echo "Error: a Claude process is currently running. Quit all Claude sessions first." >&2 - echo "Detected: $(pgrep -af '[c]laude ' | head -3)" >&2 + echo "Detected:" >&2 + pgrep -ailf 'claude' 2>/dev/null | head -3 >&2 return 1 fi @@ -440,6 +461,17 @@ _ckipper_migrate() { return 1 fi + # ── Precondition 3: ~/.claude must NOT be a symlink ────────── + # Some users symlink ~/.claude to a synced location. Renaming a symlink + # moves the link, not the target — confusing and probably not what they want. + if [[ -L "$legacy_claude" ]]; then + local target; target=$(readlink "$legacy_claude") + echo "Error: $legacy_claude is a symlink (→ $target). Refusing to migrate." >&2 + echo "Resolve manually: replace the symlink with the actual directory contents," >&2 + echo "or migrate the target directly." >&2 + return 1 + fi + # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then mkdir -p "$CKIPPER_DIR" @@ -489,19 +521,38 @@ EOF fi # ── Destructive operation with explicit rollback ───── + # Single rollback function used by both the explicit failure path + # AND the INT/TERM trap (Ctrl-C between mv and finalize completion). + _ckipper_migrate_rollback() { + local why="${1:-rollback}" + # Reverse the rename if it succeeded but registration didn't finish. + if [[ -d "$HOME/.claude-personal" && ! -e "$legacy_claude" ]]; then + mv "$HOME/.claude-personal" "$legacy_claude" 2>/dev/null + echo "Migration $why — restored $legacy_claude." >&2 + fi + # If a partial registry entry was written, remove it so a re-run isn't blocked. + if [[ -f "$CKIPPER_REGISTRY" ]] && \ + jq -e '.accounts.personal' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + _ckipper_registry_update \ + 'del(.accounts.personal) | (if .default == "personal" then .default = null else . end)' + echo "Cleaned partial 'personal' entry from $CKIPPER_REGISTRY." >&2 + fi + } + trap '_ckipper_migrate_rollback interrupted; trap - INT TERM ERR; return 130' INT TERM + if ! mv "$legacy_claude" "$HOME/.claude-personal" 2>/dev/null; then + trap - INT TERM echo "Error: failed to rename $legacy_claude → $HOME/.claude-personal" >&2 echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 return 1 fi if ! _ckipper_finalize_registration "personal" "$HOME/.claude-personal" "$probed_service" "migrate"; then - # Rollback the rename so the host returns to a clean state. - if [[ -d "$HOME/.claude-personal" && ! -e "$legacy_claude" ]]; then - mv "$HOME/.claude-personal" "$legacy_claude" 2>/dev/null - echo "Migration failed — restored $legacy_claude from rollback." >&2 - fi + _ckipper_migrate_rollback failed + trap - INT TERM return 1 fi + trap - INT TERM + unset -f _ckipper_migrate_rollback fi fi From e38e4a8a4d1192c0342025168c1f51e02581e6e3 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 17:50:10 -0600 Subject: [PATCH 031/165] Ultrareview fixes: 3 verified bugs in PR #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug_001 (install.sh): copy + chmod cleanup-projects.py during install. Without this, a fresh install via install.sh deploys w-function.zsh references to $CKIPPER_DIR/docker/cleanup-projects.py but the file is never copied — so 'w --rm' worktree-cleanup and worktree-create settings sync silently skipped (the script is invoked under '|| true'). bug_002 (ckipper.zsh:224): Onboarding text printed by 'ckipper add' for the second account told the user to run: CLAUDE_CONFIG_DIR=$dir claude But once the bare-claude guard from aliases.zsh is loaded, that shell function intercepts the call and refuses (registry has accounts). Switched the printed instruction to 'command claude' so it bypasses the guard. bug_003 (ckipper.zsh:121-133): The flock-less fallback in _ckipper_registry_update installed an EXIT INT TERM trap. When called from inside _ckipper_migrate (which had just installed its own INT/TERM rollback trap), the inner function's trap definition replaced the outer one — Ctrl-C during migrate would no longer roll back the rename. Fix: 'setopt local_options local_traps' makes the inner traps function-local and EXIT-only, leaving the caller's INT/TERM handlers intact. All three caught by /ultrareview 29. Verified post-fix with regression fixtures: happy path migrate + rollback path both succeed. --- ckipper.zsh | 13 ++++++++----- install.sh | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index 5021694..aee6132 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -119,15 +119,17 @@ _ckipper_registry_update() { chmod 600 "$CKIPPER_REGISTRY" } 9>"$lock" else - # Fallback for systems without flock (older macOS): mkdir-based lock. + # Fallback for systems without flock (the default on macOS): mkdir-based lock. + # Make the cleanup trap function-local so we don't clobber caller-installed + # INT/TERM rollback handlers (e.g. _ckipper_migrate). The local EXIT trap + # still fires whether the function returns normally or is unwound by signal. + setopt local_options local_traps local lockdir="$CKIPPER_DIR/.registry.lock.d" until mkdir "$lockdir" 2>/dev/null; do sleep 0.05; done - trap 'rmdir "$lockdir" 2>/dev/null' EXIT INT TERM + trap 'rmdir "$lockdir" 2>/dev/null' EXIT local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" chmod 600 "$CKIPPER_REGISTRY" - rmdir "$lockdir" 2>/dev/null - trap - EXIT INT TERM fi } @@ -221,9 +223,10 @@ A new account directory was created at $dir. In this same shell, run: - CLAUDE_CONFIG_DIR=$dir claude + CLAUDE_CONFIG_DIR=$dir command claude Complete the /login flow with the account you want to register as '$name'. +('command claude' bypasses the shadow that blocks bare 'claude' once accounts are registered.) When done, exit Claude (Ctrl-D) and press enter here to finish registration. If you closed the terminal by mistake, recover with: ckipper add $name --adopt diff --git a/install.sh b/install.sh index d0d2ea9..b852375 100755 --- a/install.sh +++ b/install.sh @@ -58,8 +58,10 @@ mkdir -p "$CKIPPER_DIR/docker" cp "$REPO_DIR/docker/Dockerfile" "$CKIPPER_DIR/docker/" cp "$REPO_DIR/docker/entrypoint.sh" "$CKIPPER_DIR/docker/" cp "$REPO_DIR/docker/init-firewall.sh" "$CKIPPER_DIR/docker/" +cp "$REPO_DIR/docker/cleanup-projects.py" "$CKIPPER_DIR/docker/" chmod +x "$CKIPPER_DIR/docker/entrypoint.sh" chmod +x "$CKIPPER_DIR/docker/init-firewall.sh" +chmod +x "$CKIPPER_DIR/docker/cleanup-projects.py" # 3. Copy hooks (canonical source for ckipper sync-hooks) echo "Copying hooks to $CKIPPER_DIR/hooks/..." From b06c6ae4cf4b776fac67338d9a31c9d05de76507 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 19:41:07 -0600 Subject: [PATCH 032/165] Major UX/correctness fixes from real-world Phase 7 use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes driven by issues hit during actual host migration: 1) MIGRATE: also move ~/.claude.json (home root, Claude Code's canonical big config — 92KB with projects/MCPs/trust state, separate from the smaller ~/.claude/.claude.json that some setups have). Previously, migrate moved only ~/.claude/ (the dir), leaving the user's real config untouched at home root. Result: post-migrate sessions ran against a partial fragment, /config showed defaults, folder trust was forgotten, MCPs were missing. New flow: - mv ~/.claude → ~/.claude- - if ~/.claude-/.claude.json already exists: back it up to .claude.json.pre-migrate-backup - mv ~/.claude.json (home root) → ~/.claude-/.claude.json - rollback unwinds in reverse on any failure (multi-step trap) 2) MIGRATE: prompt for account name (default 'personal') instead of hardcoding. User is now asked: 'What name do you want for this migrated account? [personal]'. Validates against ^[a-z0-9_-]+$ and refuses if ~/.claude- already exists. 3) ckipper rename : new command. Renames the registered account in place — dir, registry key, registry config_dir, default pointer, and aliases.zsh regen + sync_hooks_for new dir. Refuses if any Claude session is running. 4) ckipper add: removed the shell-deadlock UX (where the user had to Ctrl-Z, run a command, fg). Now ckipper LAUNCHES claude itself (via 'command claude' with CLAUDE_CONFIG_DIR set) — user just /login's and exits Claude (Ctrl-D), and ckipper resumes automatically to snapshot Keychain + register. No backgrounding, no second tab, no skip+adopt dance. Verified all four with fixture tests: - migrate with name 'myaccount' moves both ~/.claude and ~/.claude.json correctly, registers as default, backs up the inner .claude.json - rename foo→bar updates dir/registry/aliases, refuses concurrent claude, --help works --- ckipper.zsh | 249 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 204 insertions(+), 45 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index aee6132..a8a3952 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -10,7 +10,7 @@ ckipper() { shift 2>/dev/null case "$cmd" in # --help on any subcommand short-circuits to subcommand help - add|list|default|remove|sync-hooks|migrate) + add|list|default|remove|rename|sync-hooks|migrate) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_help_for "$cmd" return 0 @@ -32,6 +32,7 @@ Usage: ckipper list Show registered accounts ckipper default Set the default account ckipper remove Unregister (does not delete the dir) + ckipper rename Rename an account (dir + registry + aliases) ckipper sync-hooks Copy hooks into all registered accounts ckipper migrate One-time migration from legacy layout @@ -58,6 +59,20 @@ EOF list) echo "ckipper list — print registered accounts, default, and last-login email." ;; default) echo "ckipper default — set the default account used when no flag/env is provided." ;; remove) echo "ckipper remove — unregister. Does not delete the dir or Keychain entry." ;; + rename) + cat <<'EOF' +ckipper rename + +Rename a registered account in place: + - Renames ~/.claude-/ → ~/.claude-/ + - Updates the registry (key + config_dir) + - If was the default, makes the default + - Regenerates aliases.zsh and re-syncs hooks + - Refuses if any Claude session is running (so the dir isn't held open) + +Keychain service name is NOT changed — only the dir + registry mapping. +EOF + ;; sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; esac @@ -204,7 +219,10 @@ _ckipper_add() { return $? fi - # Fresh registration + # Fresh registration: ckipper LAUNCHES claude itself (in-place, same TTY) so + # there's no shell-deadlock UX where the user has to Ctrl-Z, run a command, + # then `fg`. User just /login's and exits Claude (Ctrl-D); ckipper resumes + # and finalizes registration. if [[ -d "$dir" ]]; then echo "Directory $dir already exists. Use --adopt to register it." return 1 @@ -221,23 +239,27 @@ _ckipper_add() { A new account directory was created at $dir. -In this same shell, run: - - CLAUDE_CONFIG_DIR=$dir command claude - -Complete the /login flow with the account you want to register as '$name'. -('command claude' bypasses the shadow that blocks bare 'claude' once accounts are registered.) -When done, exit Claude (Ctrl-D) and press enter here to finish registration. -If you closed the terminal by mistake, recover with: ckipper add $name --adopt +About to launch Claude with this account context. Steps: + 1. Complete the /login flow with the account you want to register as '$name'. + 2. When done, exit Claude with /quit (or Ctrl-D at the prompt). + 3. ckipper will resume here and finalize registration. +Press enter to launch Claude (or type 'skip' to abort and clean up): EOF - read -r "?Press enter when /login is complete (or type 'skip' to abort): " ack + read -r ack if [[ "$ack" == "skip" ]]; then - echo "Aborted. The directory $dir was created but not registered." - echo "To complete registration later: ckipper add $name --adopt" + rm -rf "$dir" + echo "Aborted. Cleaned up $dir." return 1 fi + # Launch claude in-place. The user interacts with it directly in this TTY. + # Use `command claude` to bypass the bare-claude guard from aliases.zsh. + CLAUDE_CONFIG_DIR="$dir" command claude + local rc=$? + echo "" + echo "Claude exited (status $rc). Finalizing registration..." + local after_snapshot after_snapshot=$(_ckipper_keychain_snapshot) || return 1 @@ -442,6 +464,77 @@ _ckipper_remove() { printf " security delete-generic-password -s %q\n" "$service" fi } + +_ckipper_rename() { + _ckipper_check_registry_version || return 1 + local old="$1" new="$2" + if [[ -z "$old" || -z "$new" ]]; then + echo "Usage: ckipper rename " + return 1 + fi + if [[ ! "$new" =~ ^[a-z0-9_-]+$ ]]; then + echo "New name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." + return 1 + fi + if [[ "$old" == "$new" ]]; then + echo "Old and new name are the same. Nothing to do." + return 1 + fi + if ! jq -e --arg n "$old" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Account '$old' is not registered." + return 1 + fi + if jq -e --arg n "$new" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Account '$new' is already registered." + return 1 + fi + + local old_dir new_dir + old_dir=$(jq -r --arg n "$old" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + new_dir="$HOME/.claude-$new" + if [[ -e "$new_dir" ]]; then + echo "Error: $new_dir already exists. Pick a different name or remove it first." + return 1 + fi + if [[ ! -d "$old_dir" ]]; then + echo "Error: source directory $old_dir does not exist." + return 1 + fi + + # Refuse if any Claude session is running — they'd be writing to old_dir. + if pgrep -if 'claude' >/dev/null 2>&1; then + echo "Error: a Claude process is currently running. Quit all Claude sessions first." >&2 + pgrep -ailf 'claude' 2>/dev/null | head -3 >&2 + return 1 + fi + + if ! mv "$old_dir" "$new_dir" 2>/dev/null; then + echo "Error: failed to rename $old_dir → $new_dir." >&2 + return 1 + fi + + if ! _ckipper_registry_update ' + .accounts[$new] = .accounts[$old] | + .accounts[$new].config_dir = $newdir | + del(.accounts[$old]) | + (if .default == $old then .default = $new else . end) + ' --arg old "$old" --arg new "$new" --arg newdir "$new_dir"; then + # Rollback: move dir back. + mv "$new_dir" "$old_dir" 2>/dev/null + echo "Error: registry write failed; reverted directory rename." >&2 + return 1 + fi + + _ckipper_regenerate_aliases + _ckipper_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to the new dir + + echo "Renamed '$old' → '$new'." + echo "Directory: $old_dir → $new_dir" + echo "Use: claude-$new (or: cca $new)" + echo "" + echo "Restart your shell (exec zsh) so aliases.zsh picks up the new function name." +} + _ckipper_migrate() { _ckipper_check_registry_version || return 1 local legacy_docker="$HOME/.claude/docker" @@ -457,14 +550,7 @@ _ckipper_migrate() { return 1 fi - # ── Precondition 2: ~/.claude-personal must not already exist ─ - if [[ -e "$HOME/.claude-personal" ]]; then - echo "Error: $HOME/.claude-personal already exists. Refusing to migrate." >&2 - echo "If you've already migrated, you're done. Run: ckipper list" >&2 - return 1 - fi - - # ── Precondition 3: ~/.claude must NOT be a symlink ────────── + # ── Precondition 2: ~/.claude must NOT be a symlink ────────── # Some users symlink ~/.claude to a synced location. Renaming a symlink # moves the link, not the target — confusing and probably not what they want. if [[ -L "$legacy_claude" ]]; then @@ -482,21 +568,51 @@ _ckipper_migrate() { echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact for one release cycle)" fi - # ── 2. Adopt ~/.claude as 'personal' if eligible ────────────── - if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]]; then + # ── 2. Adopt ~/.claude as a registered account ──────────────── + # Eligible if either ~/.claude/.claude.json or ~/.claude/settings.json exists, + # OR ~/.claude.json exists at home root (Claude Code's canonical big-config location). + local legacy_homejson="$HOME/.claude.json" + if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" || -f "$legacy_homejson" ]]; then if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + # ── Prompt for the account name ────────────────────── + local default_name="personal" + local name="" + while [[ -z "$name" ]]; do + read -r "?What name do you want for this migrated account? [$default_name] " name + [[ -z "$name" ]] && name="$default_name" + if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then + echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." + name="" + fi + if [[ -n "$name" && -e "$HOME/.claude-$name" ]]; then + echo "$HOME/.claude-$name already exists. Pick a different name." + name="" + fi + done + local target_dir="$HOME/.claude-$name" + # Show the user what we're about to do. cat </dev/null 2>&1; then echo "Warning: '$probed_service' not found in Keychain." echo "Listing available Claude Keychain entries:" _ckipper_keychain_snapshot || return 1 - read -r "?Enter the Keychain service for the personal account (or empty to skip): " probed_service + read -r "?Enter the Keychain service for the '$name' account (or empty to skip): " probed_service if [[ -n "$probed_service" ]] && ! _ckipper_validate_keychain_service "$probed_service"; then echo "Invalid Keychain service shape. Aborting." return 1 @@ -524,32 +640,64 @@ EOF fi # ── Destructive operation with explicit rollback ───── - # Single rollback function used by both the explicit failure path - # AND the INT/TERM trap (Ctrl-C between mv and finalize completion). + # Tracks every step performed so rollback can undo precisely: + # 1 = ~/.claude renamed; 2 = ~/.claude.json moved (inner backed up) + local migrate_step=0 + local moved_homejson_backup="" _ckipper_migrate_rollback() { local why="${1:-rollback}" - # Reverse the rename if it succeeded but registration didn't finish. - if [[ -d "$HOME/.claude-personal" && ! -e "$legacy_claude" ]]; then - mv "$HOME/.claude-personal" "$legacy_claude" 2>/dev/null + # Step 2 reverse: restore ~/.claude.json at home root, restore the + # inner backup if we made one. + if (( migrate_step >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then + mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null + if [[ -n "$moved_homejson_backup" && -f "$moved_homejson_backup" ]]; then + mv "$moved_homejson_backup" "$target_dir/.claude.json" 2>/dev/null + fi + fi + # Step 1 reverse: rename target_dir back to legacy_claude. + if (( migrate_step >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then + mv "$target_dir" "$legacy_claude" 2>/dev/null echo "Migration $why — restored $legacy_claude." >&2 fi - # If a partial registry entry was written, remove it so a re-run isn't blocked. + # Clean partial registry entry so a re-run isn't blocked. if [[ -f "$CKIPPER_REGISTRY" ]] && \ - jq -e '.accounts.personal' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then _ckipper_registry_update \ - 'del(.accounts.personal) | (if .default == "personal" then .default = null else . end)' - echo "Cleaned partial 'personal' entry from $CKIPPER_REGISTRY." >&2 + 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ + --arg n "$name" + echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 fi } trap '_ckipper_migrate_rollback interrupted; trap - INT TERM ERR; return 130' INT TERM - if ! mv "$legacy_claude" "$HOME/.claude-personal" 2>/dev/null; then + # Step 1: rename ~/.claude → ~/.claude- + if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then trap - INT TERM - echo "Error: failed to rename $legacy_claude → $HOME/.claude-personal" >&2 + echo "Error: failed to rename $legacy_claude → $target_dir" >&2 echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 return 1 fi - if ! _ckipper_finalize_registration "personal" "$HOME/.claude-personal" "$probed_service" "migrate"; then + migrate_step=1 + + # Step 2: move ~/.claude.json → $target_dir/.claude.json. + # If $target_dir already has a .claude.json (Claude wrote one when + # CLAUDE_CONFIG_DIR was set in some prior run), back it up — the + # home-root file is canonical. + if [[ -f "$legacy_homejson" ]]; then + if [[ -f "$target_dir/.claude.json" ]]; then + moved_homejson_backup="$target_dir/.claude.json.pre-migrate-backup" + mv "$target_dir/.claude.json" "$moved_homejson_backup" + fi + if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then + _ckipper_migrate_rollback failed + trap - INT TERM + echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 + return 1 + fi + migrate_step=2 + fi + + if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then _ckipper_migrate_rollback failed trap - INT TERM return 1 @@ -564,6 +712,13 @@ EOF docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." fi + # Reload `name` from registry for the success-message context (in case migrate + # was run for a no-op state and `name` was never set in this scope). + local registered_name="" + if [[ -f "$CKIPPER_REGISTRY" ]]; then + registered_name=$(jq -r '.default // (.accounts | keys[0] // "")' "$CKIPPER_REGISTRY") + fi + cat < to add additional accounts. + 3. Restart your shell: exec zsh + 4. Run: ckipper add to add additional accounts. -To launch Claude with your personal account, use: claude-personal -(Bare 'claude' no longer resolves to your migrated personal account — it will start a fresh login.) +EOF + if [[ -n "$registered_name" ]]; then + cat < Date: Tue, 28 Apr 2026 19:55:03 -0600 Subject: [PATCH 033/165] Add ckipper sync, ckipper doctor, README multi-account caveats ckipper sync [options]: - Default bundle: mcpServers + enabledPlugins + extraKnownMarketplaces + statusLine + env. Run with no flags to copy a sensible default set. - --mcp [names]: sync mcpServers (all or comma-separated names) - --settings keys: sync arbitrary top-level keys from settings.json - --all: same as no flags - --dry-run: preview without writing Merge semantics: existing destination keys are preserved; sync adds/ overrides only the requested keys. ckipper doctor: - Tooling: ~/.ckipper structure, deployed scripts, settings template - Registry: version match, file permissions, default pointer - Per-account: dir, .claude.json (with email/projects/mcps counts), settings.json, hooks/, Keychain entry validity - Shell integration: aliases.zsh + .zshrc source lines - Stub-file detection (~/.claude, ~/.claude.json at home root) - Output: PASS/WARN/FAIL with ANSI color (graceful when not a TTY) ckipper migrate: - New 'nothing to migrate' messaging when no eligible state exists. Distinguishes 'already migrated' (registry has accounts) from 'fresh install' (no registry yet) with appropriate next-step hints. README: - New 'Multi-account Caveats' section after Known Limitations - Documents OAuth refresh races (#24317, #27933, #29896, #19456) - Project-level files shared across accounts (by design) - MCP/plugins/marketplaces per-account with sync command examples - ~/.claude/settings.local.json recreation explanation - Quick Reference table extended with rename, sync, doctor --- README.md | 54 +++++++++- ckipper.zsh | 302 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 354 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e5a379..32ce9cf 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,12 @@ w --list # list all worktrees w --rm myorg/myapp feature-x # remove worktree + delete branch w --rebuild-image # rebuild Docker image -ckipper add # register a Claude account +ckipper add # register a Claude account (interactive /login) ckipper list # show registered accounts ckipper default # set the default account +ckipper rename # rename an account in place +ckipper sync # copy MCP/settings between accounts +ckipper doctor # diagnostic checklist ckipper migrate # one-time migration from claude-docker-sandbox ``` @@ -349,6 +352,55 @@ Ctrl+V image paste does not work inside the container. Claude Code uses `pbpaste Voice mode requires microphone access, which is unavailable inside the container. Docker Desktop for Mac does not expose the host's microphone to containers. There is no equivalent of the SSH agent forwarding pattern for audio devices on macOS. +## Multi-account Caveats + +These apply to the multi-account model in general — they're upstream Claude Code behavior, not Ckipper bugs. Ckipper papers over some of them; others you should know about. + +### OAuth refresh token races (upstream) + +Two concurrent Claude Code sessions on the same account share a single-use OAuth refresh token. The first to refresh wins; the second gets a 404 and loses authentication. Symptoms: frequent `/login` prompts, lost sessions. References: [#24317](https://github.com/anthropics/claude-code/issues/24317), [#27933](https://github.com/anthropics/claude-code/issues/27933). **Workaround:** different accounts in different terminals (the model Ckipper is built around). + +### Credentials silently wiped on failed refresh (upstream) + +If a token refresh fails mid-flight (network blip, server error), Claude Code may overwrite the stored credentials with an empty value rather than preserving the old one. Reference: [#29896](https://github.com/anthropics/claude-code/issues/29896). **Recovery:** `claude- /login` again. + +### Keychain permission glitches after macOS updates (upstream) + +After macOS or Claude Code updates, the Keychain entry can become inaccessible to Claude Code, forcing manual re-`/login` 1–N times per day. Reference: [#19456](https://github.com/anthropics/claude-code/issues/19456). Independent of Ckipper. + +### Project-level files are SHARED across accounts (by design) + +Files inside a project repo are *not* governed by `CLAUDE_CONFIG_DIR`: + +- `/.claude/settings.json` (committed) +- `/.claude/settings.local.json` (gitignored) +- `/.mcp.json` (committed, project-scoped MCP servers) +- `/CLAUDE.md` + +This is usually a feature — your `personal` and `work` accounts working in the same repo see the same project rules and project MCPs. If you don't want that, accounts must work in separate worktrees or separate clones. + +### MCP servers are per-account (user-scoped only) + +`mcpServers` lives in each account's `.claude.json`. When you `ckipper add `, the new account starts with **zero** user-scoped MCP servers. Two ways to populate: + +```bash +ckipper sync personal work # default bundle: mcpServers + plugins + statusLine + env +ckipper sync personal work --mcp Vibma,github # only specific MCPs +ckipper sync personal work --dry-run # preview before writing +``` + +### Plugins and marketplaces are per-account + +`enabledPlugins` and `extraKnownMarketplaces` (in `settings.json`) are per-account. The `ckipper sync` default bundle includes them; the `~/.ckipper/plugins/known_marketplaces.json` cache is independent per account dir. + +### `~/.claude/settings.local.json` may recreate after migration + +Despite docs saying every `~/.claude/...` path redirects under `CLAUDE_CONFIG_DIR`, some users observe a stub `~/.claude/settings.local.json` recreating itself (single key: `outputStyle`). It's harmless — `rm -rf ~/.claude` is safe and idempotent. The hook regex blocks writes to `~/.claude/` from inside containers, but the host has no such guard. + +### Diagnose anytime + +`ckipper doctor` runs a full health check: registry validity, account dir presence, `.claude.json`/`settings.json`/`hooks/` per-account, Keychain entries, `~/.zshrc` source lines, and stub-file presence. Use it after `ckipper migrate` or whenever something looks off. + ## Troubleshooting | Problem | Fix | diff --git a/ckipper.zsh b/ckipper.zsh index a8a3952..f88db55 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -10,7 +10,7 @@ ckipper() { shift 2>/dev/null case "$cmd" in # --help on any subcommand short-circuits to subcommand help - add|list|default|remove|rename|sync-hooks|migrate) + add|list|default|remove|rename|sync|sync-hooks|migrate|doctor) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_help_for "$cmd" return 0 @@ -33,8 +33,10 @@ Usage: ckipper default Set the default account ckipper remove Unregister (does not delete the dir) ckipper rename Rename an account (dir + registry + aliases) + ckipper sync Copy MCP/settings from one account to another ckipper sync-hooks Copy hooks into all registered accounts ckipper migrate One-time migration from legacy layout + ckipper doctor Diagnostic check of registered accounts and tooling Companion commands (sourced via aliases.zsh): cca [args...] Run claude with account (one-off) @@ -74,7 +76,35 @@ Keychain service name is NOT changed — only the dir + registry mapping. EOF ;; sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; + sync) + cat <<'EOF' +ckipper sync [options] + +Copy state from one registered account to another. Useful for sharing MCP +servers, plugin lists, status line, env vars, etc. across accounts without +having to re-configure each. + +By default (no flags) syncs a sensible bundle: mcpServers + enabledPlugins + +extraKnownMarketplaces + statusLine + env. + +Options: + --mcp [name1,name2,...] Sync mcpServers. Without arg: all servers. + With arg: only the named servers. + --settings Sync specific top-level keys from settings.json. + Comma-separated. Examples: enabledPlugins, + extraKnownMarketplaces, statusLine, env, model. + --all Sync the default bundle (same as no flags). + --dry-run Show what would change without writing. + +Examples: + ckipper sync personal work + ckipper sync personal work --mcp + ckipper sync personal work --mcp Vibma,github + ckipper sync personal work --settings statusLine,env --dry-run +EOF + ;; migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; + doctor) echo "ckipper doctor — run a diagnostic checklist on registered accounts and ckipper tooling." ;; esac } @@ -535,6 +565,258 @@ _ckipper_rename() { echo "Restart your shell (exec zsh) so aliases.zsh picks up the new function name." } +# Validates that an account exists in the registry. Echoes its config_dir on success. +_ckipper_account_dir() { + local name="$1" + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Account '$name' is not registered." >&2 + return 1 + fi + jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY" +} + +_ckipper_sync() { + _ckipper_check_registry_version || return 1 + local from="$1" to="$2" + shift 2 2>/dev/null + if [[ -z "$from" || -z "$to" ]]; then + echo "Usage: ckipper sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" + return 1 + fi + if [[ "$from" == "$to" ]]; then + echo " and must differ." + return 1 + fi + + local from_dir to_dir + from_dir=$(_ckipper_account_dir "$from") || return 1 + to_dir=$(_ckipper_account_dir "$to") || return 1 + if [[ ! -f "$from_dir/.claude.json" ]]; then + echo "Source has no .claude.json: $from_dir"; return 1 + fi + if [[ ! -f "$to_dir/.claude.json" ]]; then + echo "Destination has no .claude.json: $to_dir"; return 1 + fi + + # Parse flags. The argparse here is intentionally minimal — order matters, + # but each flag is well-formed and easy to read. + local mode_mcp=0 mcp_names="" mode_settings=0 settings_keys="" dry_run=0 mode_all=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --mcp) + mode_mcp=1 + if [[ -n "$2" && "$2" != --* ]]; then mcp_names="$2"; shift; fi + shift ;; + --settings) + mode_settings=1 + if [[ -n "$2" && "$2" != --* ]]; then settings_keys="$2"; shift; fi + shift ;; + --all) mode_all=1; shift ;; + --dry-run) dry_run=1; shift ;; + *) echo "Unknown flag: $1"; return 1 ;; + esac + done + + # Default bundle when no specific flags were passed: mcpServers + a useful + # selection of settings.json keys. + if (( mode_mcp == 0 && mode_settings == 0 )); then + mode_all=1 + fi + if (( mode_all )); then + mode_mcp=1 + mode_settings=1 + [[ -z "$settings_keys" ]] && \ + settings_keys="enabledPlugins,extraKnownMarketplaces,statusLine,env,model" + fi + + local pending_msgs=() + + # ── MCP sync ───────────────────────────────────────────────── + if (( mode_mcp )); then + local mcp_filter + if [[ -z "$mcp_names" ]]; then + mcp_filter='.mcpServers // {}' + else + # Build a jq object containing only the named servers, e.g. {Vibma: ..., github: ...} + local jq_array + jq_array=$(echo "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') + mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | '"$jq_array"' | index($k)))' + fi + local servers + servers=$(jq "$mcp_filter" "$from_dir/.claude.json") + local server_keys + server_keys=$(echo "$servers" | jq -r 'keys[]?' | tr '\n' ' ') + if [[ -z "$server_keys" || "$server_keys" == " " ]]; then + pending_msgs+=("MCP: nothing to sync (no matching servers in $from)") + else + pending_msgs+=("MCP servers → $to: $server_keys") + if (( ! dry_run )); then + local tmp; tmp=$(mktemp "$to_dir/.claude.json.tmp.XXXXXX") + jq --argjson new "$servers" '.mcpServers = (.mcpServers // {}) + $new' \ + "$to_dir/.claude.json" > "$tmp" && mv "$tmp" "$to_dir/.claude.json" + fi + fi + fi + + # ── settings.json key sync ─────────────────────────────────── + if (( mode_settings )) && [[ -n "$settings_keys" ]]; then + if [[ ! -f "$from_dir/settings.json" ]]; then + pending_msgs+=("Settings: $from has no settings.json (skipping)") + else + # Build a jq subset object with only the requested keys (skipping missing ones). + local jq_keys + jq_keys=$(echo "$settings_keys" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') + local subset + subset=$(jq --argjson keys "$jq_keys" \ + 'with_entries(select(.key as $k | $keys | index($k)))' \ + "$from_dir/settings.json") + local copied_keys + copied_keys=$(echo "$subset" | jq -r 'keys[]?' | tr '\n' ' ') + if [[ -z "$copied_keys" || "$copied_keys" == " " ]]; then + pending_msgs+=("Settings: no matching keys in $from/settings.json") + else + pending_msgs+=("Settings keys → $to: $copied_keys") + if (( ! dry_run )); then + if [[ ! -f "$to_dir/settings.json" ]]; then + echo '{}' > "$to_dir/settings.json" + fi + local tmp; tmp=$(mktemp "$to_dir/settings.json.tmp.XXXXXX") + jq --argjson new "$subset" '. + $new' \ + "$to_dir/settings.json" > "$tmp" && mv "$tmp" "$to_dir/settings.json" + fi + fi + fi + fi + + if (( dry_run )); then + echo "Dry run — would apply:" + else + echo "Synced:" + fi + for m in "${pending_msgs[@]}"; do + echo " - $m" + done + + if (( ! dry_run )); then + echo "" + echo "Restart any running '$to' Claude session for changes to take effect." + fi +} + +_ckipper_doctor() { + local fail=0 warn=0 + local check() { + local sym="$1" msg="$2" + case "$sym" in + PASS) printf " \033[32m[PASS]\033[0m %s\n" "$msg" ;; + WARN) printf " \033[33m[WARN]\033[0m %s\n" "$msg"; (( warn++ )) ;; + FAIL) printf " \033[31m[FAIL]\033[0m %s\n" "$msg"; (( fail++ )) ;; + INFO) printf " [INFO] %s\n" "$msg" ;; + esac + } + # Locally-scoped function for color output. zsh function nesting works at runtime. + + echo "── Tooling ───────────────────────────────────────────" + if [[ -d "$CKIPPER_DIR" ]]; then check PASS "$CKIPPER_DIR exists"; else check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi + if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then check PASS "w-function.zsh deployed"; else check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then check PASS "ckipper.zsh deployed"; else check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then check PASS "cleanup-projects.py deployed"; else check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then check PASS "settings-template.json deployed"; else check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi + if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= 4 )); then + check PASS "hooks/ has 4+ files" + else + check WARN "hooks/ is missing or has fewer than 4 hook files" + fi + + echo "" + echo "── Registry ──────────────────────────────────────────" + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" + return 0 + fi + local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then check PASS "registry version $v matches expected" + else check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi + local perms; perms=$(stat -f '%Lp' "$CKIPPER_REGISTRY" 2>/dev/null) + if [[ "$perms" == "600" ]]; then check PASS "registry permissions 600" + else check WARN "registry permissions $perms (expected 600)"; fi + + local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + [[ -n "$default_acc" ]] && check INFO "default account: $default_acc" || check WARN "no default account set — w/ckipper-add will require --account" + + echo "" + echo "── Per-account state ────────────────────────────────" + local names; names=$(jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY") + if [[ -z "$names" ]]; then + check WARN "registry has no accounts" + else + while IFS= read -r name; do + echo "" + echo " Account: $name" + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local svc; svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + if [[ -d "$dir" ]]; then check PASS " dir exists: $dir" + else check FAIL " dir missing: $dir"; fi + if [[ -f "$dir/.claude.json" ]]; then + local email proj_count mcp_count + email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) + proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) + mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) + check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" + else + check WARN " .claude.json missing in $dir" + fi + if [[ -f "$dir/settings.json" ]]; then check PASS " settings.json present"; else check WARN " settings.json missing"; fi + if [[ -d "$dir/hooks" ]]; then check PASS " hooks/ deployed"; else check WARN " hooks/ missing — run: ckipper sync-hooks"; fi + # Keychain check (macOS only) + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + if [[ -z "$svc" ]]; then + check INFO " keychain_service: null (account uses on-disk credentials)" + elif ! _ckipper_validate_keychain_service "$svc"; then + check FAIL " keychain_service has invalid shape: $svc" + elif security find-generic-password -s "$svc" >/dev/null 2>&1; then + check PASS " keychain entry present: $svc" + else + check WARN " keychain entry NOT FOUND: $svc — re-run /login with: claude-$name" + fi + fi + done <<< "$names" + fi + + echo "" + echo "── Aliases & shell integration ──────────────────────" + if [[ -f "$CKIPPER_DIR/aliases.zsh" ]]; then check PASS "aliases.zsh exists at $CKIPPER_DIR/aliases.zsh" + else check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi + if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources aliases.zsh" + else check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi + if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources w-function.zsh" + else check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi + + echo "" + echo "── Stub files (cosmetic) ────────────────────────────" + if [[ -d "$HOME/.claude" ]]; then + local stub_count; stub_count=$(ls -1A "$HOME/.claude" 2>/dev/null | wc -l | tr -d ' ') + check WARN "~/.claude exists ($stub_count files) — Claude Code may have recreated it. Safe to: rm -rf ~/.claude" + else + check PASS "~/.claude (stub dir) is absent" + fi + if [[ -f "$HOME/.claude.json" ]]; then check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." + else check PASS "~/.claude.json (home root) is absent"; fi + + echo "" + echo "──────────────────────────────────────────────────────" + if (( fail > 0 )); then + printf "Result: \033[31m%d FAIL\033[0m, \033[33m%d WARN\033[0m\n" "$fail" "$warn" + return 1 + elif (( warn > 0 )); then + printf "Result: \033[33m%d WARN\033[0m\n" "$warn" + return 0 + else + printf "Result: \033[32mall checks passed\033[0m\n" + return 0 + fi +} + _ckipper_migrate() { _ckipper_check_registry_version || return 1 local legacy_docker="$HOME/.claude/docker" @@ -572,6 +854,24 @@ _ckipper_migrate() { # Eligible if either ~/.claude/.claude.json or ~/.claude/settings.json exists, # OR ~/.claude.json exists at home root (Claude Code's canonical big-config location). local legacy_homejson="$HOME/.claude.json" + local has_inner_state=0 has_homejson=0 + [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]] && has_inner_state=1 + [[ -f "$legacy_homejson" ]] && has_homejson=1 + + if (( has_inner_state == 0 && has_homejson == 0 )); then + local has_registry=0 + [[ -f "$CKIPPER_REGISTRY" ]] && jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry=1 + if (( has_registry )); then + echo "Nothing to migrate: no $legacy_claude state and no $legacy_homejson at home root." + echo "($CKIPPER_REGISTRY already has registered accounts — you're likely already migrated.)" + echo "Run: ckipper list" + else + echo "Nothing to migrate: no $legacy_claude/.claude.json, no $legacy_claude/settings.json, no $legacy_homejson." + echo "If this is a fresh setup, register an account directly: ckipper add " + fi + return 0 + fi + if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" || -f "$legacy_homejson" ]]; then if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then From af2dbe968847f67a652590fda2f65a11cbd8c4db Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 20:15:07 -0600 Subject: [PATCH 034/165] Bug bash from think-tank audit: 12 verified bugs fixed + ck alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All fixes verified with fixture tests on real host. CONCURRENCY / CORRECTNESS: - TOCTOU on duplicate ckipper add: _ckipper_finalize_registration now uses jq error() inside the locked filter, so 'already registered' / 'config_dir in use' fail atomically under concurrent invocations. Pre-check is still there for fast UX errors. - Init race: _ckipper_init_registry uses 'mv -n' (no-clobber atomic create) so two concurrent ckipper init's can't both write the heredoc. - Two accounts with same config_dir: now rejected at finalize via jq any() check. - _ckipper_registry_update now propagates jq exit code (was silently swallowed via && chmod). Required for the atomic-collision-check above. LOCK ROBUSTNESS: - mkdir-fallback now has stale-lock recovery: after 10s of waiting, checks the lockdir mtime; if >30s old, recovers the lock. Previously a SIGKILL'd ckipper would brick all future invocations (verified — confirmed it hangs without the fix; with the fix, recovers in <1s). SIGNAL HANDLING: - migrate trap now catches HUP and QUIT in addition to INT/TERM. Closing Terminal.app mid-migrate (SIGHUP) was previously unrecoverable. - _ckipper_migrate_rollback now also calls _ckipper_regenerate_aliases after registry cleanup, so aliases.zsh reflects rolled-back state (otherwise it would still define claude- pointing to a missing dir). ROBUSTNESS: - pgrep tightened: pgrep -lx claude / pgrep -lx Claude (basename match via -x) instead of substring match. No more false positives from vim claude-notes.md, tmux sessions named claude-foo, or Claude Helper child processes (parent kill cascades). Added _ckipper_assert_no_running_claude helper used by migrate, rename. CKIPPER_FORCE=1 bypasses for emergencies. - aliases.zsh atomic write: 'mv $out.tmp $out' so readers in other shells never see a half-written file mid-rename. - ~/.claude.json symlink check: migrate refuses if home-root config is a symlink (Dropbox/iCloud sync users) — same protection as ~/.claude symlink. - ckipper sync: warns if a Claude session is running (not refuse, but prompt y/N) since sync isn't atomic vs. Claude's writes to .claude.json. - Registry corruption detection: _ckipper_check_registry_version now also asserts .accounts is an object (catches manual edits that turn it into an array, which previously caused 'mkdir ./null/hooks' downstream). - Doctor cross-platform stat: BSD/macOS uses -f, GNU/Linux uses -c. Centralized in _ckipper_stat_perms / _ckipper_stat_mtime helpers. - Doctor validates default points to an existing account. Previously 'default: ghost' with no accounts.ghost showed INFO; now FAIL. INSTALL: - install.sh no longer clobbers an existing global core.hooksPath. If user has husky/pre-commit/etc. configured, we skip with a warning and tell them how to set it manually if they want Ckipper's isolation. UX: - ck() short alias for ckipper. Just 'ck list', 'ck doctor', etc. NOT FIXED (intentional): - #13 (registry version bump path): deferred until we actually bump CKIPPER_REGISTRY_VERSION. Would need staged migrators. --- ckipper.zsh | 222 +++++++++++++++++++++++++++++++++++++++++----------- install.sh | 13 ++- 2 files changed, 188 insertions(+), 47 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index f88db55..1ee0eaf 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -150,54 +150,141 @@ _ckipper_keychain_snapshot() { sort -u } -# Atomic registry write under flock. $1 = jq filter; remaining args are jq args (e.g. --arg). +# Cross-platform stat for permissions: BSD (macOS) uses -f, GNU/Linux uses -c. +_ckipper_stat_perms() { + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + stat -f '%Lp' "$1" 2>/dev/null + else + stat -c '%a' "$1" 2>/dev/null + fi +} + +# Cross-platform stat for mtime in seconds since epoch. +_ckipper_stat_mtime() { + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + stat -f '%m' "$1" 2>/dev/null + else + stat -c '%Y' "$1" 2>/dev/null + fi +} + +# Detect running Claude processes that would conflict with destructive operations. +# Matches: 'claude' CLI (basename), 'Claude' (Claude.app main process). Avoids matching +# vim files named 'claude-*', tmux sessions, or Claude Helper subprocesses (the parent +# Claude.app being killed will cascade to those). +_ckipper_running_claude_processes() { + pgrep -lx claude 2>/dev/null + pgrep -lx Claude 2>/dev/null +} + +# Refuse with a clear message if any Claude process is running. +_ckipper_assert_no_running_claude() { + local found + found=$(_ckipper_running_claude_processes) + if [[ -n "$found" ]]; then + echo "Error: Claude process(es) detected. Quit them first:" >&2 + echo "$found" | sed 's/^/ /' >&2 + echo "(Set CKIPPER_FORCE=1 to bypass this check, but expect inconsistent state.)" >&2 + if [[ "$CKIPPER_FORCE" == "1" ]]; then + echo "CKIPPER_FORCE=1 set — proceeding despite running Claude." >&2 + return 0 + fi + return 1 + fi + return 0 +} + +# Atomic registry write under flock (or mkdir-fallback). $1 = jq filter. +# Returns 0 on successful jq+write, 1 on jq error or write failure. +# A jq error() call inside the filter (used for atomic-collision-checks like +# _ckipper_finalize_registration) propagates as a non-zero exit here. _ckipper_registry_update() { local jq_filter="$1"; shift local lock="$CKIPPER_DIR/.registry.lock" mkdir -p "$CKIPPER_DIR" : > "$lock" + local rc=1 if command -v flock >/dev/null 2>&1; then { flock -x 9 local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") - jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" - chmod 600 "$CKIPPER_REGISTRY" + if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then + mv "$tmp" "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + rc=0 + else + rm -f "$tmp" + fi } 9>"$lock" else - # Fallback for systems without flock (the default on macOS): mkdir-based lock. - # Make the cleanup trap function-local so we don't clobber caller-installed - # INT/TERM rollback handlers (e.g. _ckipper_migrate). The local EXIT trap - # still fires whether the function returns normally or is unwound by signal. + # Fallback for systems without flock (the default on macOS): mkdir-based lock, + # with stale-lock recovery so a SIGKILL'd ckipper doesn't permanently brick + # subsequent invocations. setopt local_options local_traps local lockdir="$CKIPPER_DIR/.registry.lock.d" - until mkdir "$lockdir" 2>/dev/null; do sleep 0.05; done + local attempts=0 + while ! mkdir "$lockdir" 2>/dev/null; do + (( attempts++ )) + if (( attempts >= 200 )); then # 10s + local lockdir_age now + now=$(date +%s) + local mtime; mtime=$(_ckipper_stat_mtime "$lockdir") + lockdir_age=$(( now - ${mtime:-$now} )) + if (( lockdir_age > 30 )); then + echo "Recovering stale registry lock (age ${lockdir_age}s)" >&2 + rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" + attempts=0 + continue + fi + echo "Registry lock held by another process for ${lockdir_age}s. Try again shortly." >&2 + return 1 + fi + sleep 0.05 + done trap 'rmdir "$lockdir" 2>/dev/null' EXIT local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") - jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" - chmod 600 "$CKIPPER_REGISTRY" + if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then + mv "$tmp" "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + rc=0 + else + rm -f "$tmp" + fi fi + return $rc } -# Initialize an empty registry with version field. +# Initialize an empty registry with version field. Idempotent under concurrency +# via atomic create (mv -n) — two concurrent ckipper init's won't clobber each other. _ckipper_init_registry() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then mkdir -p "$CKIPPER_DIR" - cat > "$CKIPPER_REGISTRY" < "$tmp" </dev/null || rm -f "$tmp" + [[ -f "$CKIPPER_REGISTRY" ]] && chmod 600 "$CKIPPER_REGISTRY" fi } -# Refuse to operate on a registry whose version we don't understand. +# Refuse to operate on a registry whose version we don't understand OR whose schema +# is corrupt (e.g. user manually edited and turned .accounts into an array). _ckipper_check_registry_version() { [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 local v - v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY") + v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) if (( v != CKIPPER_REGISTRY_VERSION )); then echo "Error: registry version $v not supported (this ckipper expects $CKIPPER_REGISTRY_VERSION). Update ckipper or restore from backup." >&2 return 1 fi + if ! jq -e '.accounts | type == "object"' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: $CKIPPER_REGISTRY is corrupt (.accounts is not an object)." >&2 + echo "Backup and re-init manually:" >&2 + echo " mv $CKIPPER_REGISTRY $CKIPPER_REGISTRY.corrupt-\$(date +%s)" >&2 + return 1 + fi } _ckipper_add() { @@ -326,15 +413,29 @@ _ckipper_finalize_registration() { _ckipper_init_registry - _ckipper_registry_update ' - .accounts[$n] = {config_dir: $d, keychain_service: (if $s == "" then null else $s end), registered_at: $t} - | (if .default == null then .default = $n else . end) - ' --arg n "$name" --arg d "$dir" --arg s "$service" --arg t "$now" - - # Verify the write actually landed — registry update under chmod -w or other - # write failures must propagate so callers (e.g. ckipper migrate) can rollback. - if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Error: failed to write account '$name' to registry $CKIPPER_REGISTRY" >&2 + # Atomic insert under lock: jq errors out if the name OR the config_dir is already + # claimed. Replaces the prior pattern of "pre-check + unguarded write" which had + # a TOCTOU under concurrent ckipper add invocations. + if ! _ckipper_registry_update ' + if (.accounts | has($n)) then + error("ALREADY_REGISTERED") + elif ([.accounts[].config_dir] | any(. == $d)) then + error("CONFIG_DIR_IN_USE") + else + .accounts[$n] = {config_dir: $d, keychain_service: (if $s == "" then null else $s end), registered_at: $t} + | (if .default == null then .default = $n else . end) + end + ' --arg n "$name" --arg d "$dir" --arg s "$service" --arg t "$now"; then + # The most likely causes are (a) registry write failure (perms/disk), + # (b) jq error from one of the assertions above. Distinguish best-effort + # by re-checking state. + if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: account '$name' already exists in registry (race detected)." >&2 + elif jq -e --arg d "$dir" '[.accounts[].config_dir] | any(. == $d)' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: config dir '$dir' is already claimed by another registered account." >&2 + else + echo "Error: failed to write account '$name' to registry $CKIPPER_REGISTRY" >&2 + fi return 1 fi @@ -396,7 +497,9 @@ _ckipper_regenerate_aliases() { echo "claude-$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" done fi - } > "$out" + } > "$out.tmp" + # Atomic install — readers in other shells never see a partial file. + mv "$out.tmp" "$out" chmod 644 "$out" } @@ -532,11 +635,7 @@ _ckipper_rename() { fi # Refuse if any Claude session is running — they'd be writing to old_dir. - if pgrep -if 'claude' >/dev/null 2>&1; then - echo "Error: a Claude process is currently running. Quit all Claude sessions first." >&2 - pgrep -ailf 'claude' 2>/dev/null | head -3 >&2 - return 1 - fi + _ckipper_assert_no_running_claude || return 1 if ! mv "$old_dir" "$new_dir" 2>/dev/null; then echo "Error: failed to rename $old_dir → $new_dir." >&2 @@ -629,6 +728,20 @@ _ckipper_sync() { settings_keys="enabledPlugins,extraKnownMarketplaces,statusLine,env,model" fi + # If a Claude session is running, sync's writes can race with its writes + # to the same .claude.json. Warn (but don't refuse) unless --dry-run. + if (( ! dry_run )); then + local running_procs + running_procs=$(_ckipper_running_claude_processes) + if [[ -n "$running_procs" ]]; then + echo "Warning: Claude is currently running. If a session uses '$to' or '$from'," >&2 + echo "sync may race with its writes (Claude doesn't lock these files)." >&2 + echo "$running_procs" | sed 's/^/ /' >&2 + read -r "?Continue anyway? [y/N] " ans + [[ "$ans" != "y" && "$ans" != "Y" ]] && { echo "Aborted."; return 1; } + fi + fi + local pending_msgs=() # ── MCP sync ───────────────────────────────────────────────── @@ -737,12 +850,18 @@ _ckipper_doctor() { local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then check PASS "registry version $v matches expected" else check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi - local perms; perms=$(stat -f '%Lp' "$CKIPPER_REGISTRY" 2>/dev/null) + local perms; perms=$(_ckipper_stat_perms "$CKIPPER_REGISTRY") if [[ "$perms" == "600" ]]; then check PASS "registry permissions 600" else check WARN "registry permissions $perms (expected 600)"; fi local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - [[ -n "$default_acc" ]] && check INFO "default account: $default_acc" || check WARN "no default account set — w/ckipper-add will require --account" + if [[ -z "$default_acc" ]]; then + check WARN "no default account set — w/ckipper-add will require --account" + elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + check INFO "default account: $default_acc" + else + check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " + fi echo "" echo "── Per-account state ────────────────────────────────" @@ -823,14 +942,7 @@ _ckipper_migrate() { local legacy_claude="$HOME/.claude" # ── Precondition 1: no Claude process running ───────────────── - # Match (a) Claude.app GUI process names, (b) bare 'claude' CLI invocation - # (no trailing args), (c) 'claude '. Case-insensitive to catch all forms. - if pgrep -if 'claude' >/dev/null 2>&1; then - echo "Error: a Claude process is currently running. Quit all Claude sessions first." >&2 - echo "Detected:" >&2 - pgrep -ailf 'claude' 2>/dev/null | head -3 >&2 - return 1 - fi + _ckipper_assert_no_running_claude || return 1 # ── Precondition 2: ~/.claude must NOT be a symlink ────────── # Some users symlink ~/.claude to a synced location. Renaming a symlink @@ -843,6 +955,18 @@ _ckipper_migrate() { return 1 fi + # ── Precondition 3: ~/.claude.json must NOT be a symlink ────── + # Same reasoning — Dropbox/iCloud users symlink this for cross-machine sync. + # mv on a symlink moves the link itself, breaking the sync target. + local legacy_homejson_check="$HOME/.claude.json" + if [[ -L "$legacy_homejson_check" ]]; then + local target; target=$(readlink "$legacy_homejson_check") + echo "Error: $legacy_homejson_check is a symlink (→ $target). Refusing to migrate." >&2 + echo "Resolve manually: replace the symlink with the actual file contents," >&2 + echo "or migrate the target directly." >&2 + return 1 + fi + # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then mkdir -p "$CKIPPER_DIR" @@ -967,12 +1091,17 @@ EOF --arg n "$name" echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 fi + # Regenerate aliases.zsh so it reflects the post-rollback registry + # (otherwise it would still define claude-() pointing into a + # dir that no longer exists). + _ckipper_regenerate_aliases 2>/dev/null || true } - trap '_ckipper_migrate_rollback interrupted; trap - INT TERM ERR; return 130' INT TERM + # HUP catches Terminal.app window-close mid-migrate; QUIT catches Ctrl-\. + trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT ERR; return 130' INT TERM HUP QUIT # Step 1: rename ~/.claude → ~/.claude- if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then - trap - INT TERM + trap - INT TERM HUP QUIT echo "Error: failed to rename $legacy_claude → $target_dir" >&2 echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 return 1 @@ -990,7 +1119,7 @@ EOF fi if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then _ckipper_migrate_rollback failed - trap - INT TERM + trap - INT TERM HUP QUIT echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 return 1 fi @@ -999,10 +1128,10 @@ EOF if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then _ckipper_migrate_rollback failed - trap - INT TERM + trap - INT TERM HUP QUIT return 1 fi - trap - INT TERM + trap - INT TERM HUP QUIT unset -f _ckipper_migrate_rollback fi fi @@ -1041,3 +1170,6 @@ To launch Claude with your migrated account, use: claude-$registered_name EOF fi } + +# Short alias: 'ck' for 'ckipper'. +ck() { ckipper "$@"; } diff --git a/install.sh b/install.sh index b852375..55bf314 100755 --- a/install.sh +++ b/install.sh @@ -130,10 +130,19 @@ if grep -q '^w()' "$HOME/.zshrc" 2>/dev/null || grep -q '^_w_build_image()' "$HO echo "(Search for '_w_build_image()' or 'w()' and remove everything through the 'COMPEOF' line)" fi -# 10. Set up git hooks path +# 10. Set up git hooks path (only if user hasn't already configured a different one, +# e.g. for husky, pre-commit, or another tool — never silently clobber) echo "Configuring git hooks path..." mkdir -p "$HOME/.git-hooks" -git config --global core.hooksPath "$HOME/.git-hooks" +existing_hookspath=$(git config --global --get core.hooksPath 2>/dev/null || true) +if [ -z "$existing_hookspath" ] || [ "$existing_hookspath" = "$HOME/.git-hooks" ]; then + git config --global core.hooksPath "$HOME/.git-hooks" + echo " Set core.hooksPath = $HOME/.git-hooks" +else + echo " Skipping core.hooksPath: existing value is '$existing_hookspath' (not overwriting)." + echo " If you want Ckipper's hook isolation, set manually:" + echo " git config --global core.hooksPath \"\$HOME/.git-hooks\"" +fi # 11. Print summary echo "" From f8501922bec11bc7b9ef246eb4c7d536a174143e Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 20:53:48 -0600 Subject: [PATCH 035/165] Simplify launchers: drop cca, add bare-name shortcuts, friendlier list reminder ckipper list: replace technical "see #24317" reminder with a plain-language explanation of the OAuth single-use refresh-token race. aliases.zsh: - Drop the cca dispatcher (redundant with claude-). - Generate a bare shortcut alongside claude-, skipped when it would shadow an existing PATH binary, shell builtin, alias, or reserved word (_ckipper_bare_alias_safe). - ckipper add/remove/rename now re-source aliases.zsh into the calling shell so new launchers work immediately without exec zsh, and unset the old launcher functions on remove/rename so the current shell stays clean. --- CLAUDE.md | 2 +- README.md | 10 ++++----- ckipper.zsh | 65 +++++++++++++++++++++++++++++++++++++++-------------- install.sh | 2 +- 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 911a699..67caa52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Each Claude account is a `CLAUDE_CONFIG_DIR=~/.claude-/` directory — ana `w` resolves the active account in priority order: `--account ` > `CLAUDE_CONFIG_DIR` env (matched against registry) > registered default. **No legacy fallback** — if no account resolves, `w` errors out and tells you to register one. Inside the container, the entrypoint requires `CLAUDE_CONFIG_DIR` and exits 1 if unset (no silent fallback). -Companion shell layer: `~/.ckipper/aliases.zsh` is auto-regenerated on every `ckipper add` / `remove` and contains a `cca ` dispatcher plus one `claude-` function per registered account. The file is self-contained — sourcing only `aliases.zsh` (without `ckipper.zsh` or `w-function.zsh`) yields a working setup. +Companion shell layer: `~/.ckipper/aliases.zsh` is auto-regenerated on every `ckipper add` / `remove` / `rename`, then re-sourced into the calling shell so new launchers work immediately. It contains a `claude-` function per registered account, plus a bare `` shortcut when that name doesn't shadow a PATH binary, builtin, alias, or reserved word (see `_ckipper_bare_alias_safe`). The file is self-contained — sourcing only `aliases.zsh` (without `ckipper.zsh` or `w-function.zsh`) yields a working setup. `settings-template.json` is **seed-only**. After an account is registered, its `settings.json` diverges (per-account hooks, paths). Re-running `ckipper sync-hooks` refreshes hook paths and copies the canonical `~/.ckipper/hooks/*` into each account; it does not re-apply the template wholesale. diff --git a/README.md b/README.md index 32ce9cf..0683d6b 100644 --- a/README.md +++ b/README.md @@ -64,14 +64,14 @@ ckipper add work ### Use an account -Three ways: - ```bash -claude-work # auto-generated alias (preferred) -cca work # one-off dispatcher (claude-config-as) +claude-work # auto-generated launcher +work # bare-name shortcut (skipped if it would shadow an existing command) CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form ``` +`ckipper add` re-sources `aliases.zsh` in your current shell, so new launchers are usable immediately — no `exec zsh`. + ### Inside Docker ```bash @@ -92,7 +92,7 @@ ckipper remove old-account - Per-account state lives in `~/.claude-/` (analogous to the legacy `~/.claude/`). - The registry mapping accounts to dirs and Keychain services lives at `~/.ckipper/accounts.json` (chmod 600, atomic writes via `flock`). -- Auto-generated `~/.ckipper/aliases.zsh` defines `cca` and one `claude-` function per registered account. +- Auto-generated `~/.ckipper/aliases.zsh` defines `claude-` (and a bare `` shortcut, when it doesn't shadow an existing command) per registered account. - Hooks under `~/.ckipper/hooks/` are the canonical source — `ckipper sync-hooks` copies them per-account and rewrites `settings.json` paths. ## ⚠️ Don't run the same account in two sessions diff --git a/ckipper.zsh b/ckipper.zsh index 1ee0eaf..49ce767 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -39,8 +39,9 @@ Usage: ckipper doctor Diagnostic check of registered accounts and tooling Companion commands (sourced via aliases.zsh): - cca [args...] Run claude with account (one-off) - claude- [args...] Auto-generated alias per registered account + claude- [args...] Auto-generated launcher per registered account + [args...] Bare-name shortcut (skipped if it would shadow an + existing command, builtin, alias, or reserved word) Run `ckipper --help` for per-subcommand details. EOF @@ -443,7 +444,22 @@ _ckipper_finalize_registration() { _ckipper_sync_hooks_for "$name" echo "Registered '$name' (mode: $mode)." - echo "Use it via: claude-$name or cca $name" + if _ckipper_bare_alias_safe "$name"; then + echo "Use it via: claude-$name (or just: $name)" + else + echo "Use it via: claude-$name" + fi +} + +# Returns 0 if $1 is safe to use as a bare-alias function name (no clash with +# any existing PATH command, shell builtin, alias, or reserved word). Existing +# shell *functions* are not a clash — we expect to redefine those. +_ckipper_bare_alias_safe() { + local n="$1" + (( ${+commands[$n]} || ${+builtins[$n]} || ${+aliases[$n]} )) && return 1 + local what; what=$(whence -w "$n" 2>/dev/null | awk '{print $2}') + [[ "$what" == "reserved" ]] && return 1 + return 0 } _ckipper_regenerate_aliases() { @@ -456,15 +472,6 @@ _ckipper_regenerate_aliases() { echo "" echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" echo "" - echo "cca() {" - echo " local name=\"\$1\"; shift" - echo " if [[ -z \"\$name\" ]]; then echo \"Usage: cca [args...]\"; return 1; fi" - echo " local dir" - echo " dir=\$(jq -r --arg n \"\$name\" '.accounts[\$n].config_dir // empty' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" - echo " if [[ -z \"\$dir\" ]]; then echo \"Unknown account: \$name. Run: ckipper list\"; return 1; fi" - echo " CLAUDE_CONFIG_DIR=\"\$dir\" command claude \"\$@\"" - echo "}" - echo "" # Guard: bare 'claude' would default to ~/.claude/ and write to the unsuffixed # 'Claude Code-credentials' Keychain entry — which is the SAME entry the # default account uses. A fresh /login here silently overwrites those creds. @@ -480,7 +487,7 @@ _ckipper_regenerate_aliases() { echo " echo \"/login here would silently overwrite those credentials.\" >&2" echo " echo \"\" >&2" echo " if [[ -n \"\$default\" ]]; then" - echo " echo \"Use: claude-\$default (or: cca \$default)\" >&2" + echo " echo \"Use: claude-\$default\" >&2" echo " else" echo " echo \"Set a default first: ckipper default , then use claude-.\" >&2" echo " fi" @@ -495,12 +502,26 @@ _ckipper_regenerate_aliases() { jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ while IFS=$'\t' read -r _name _dir; do echo "claude-$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" + # Bare-name shortcut: also generate `` so users can + # type the account name directly. Skip if it would shadow + # a real binary, builtin, alias, or reserved word. + if _ckipper_bare_alias_safe "$_name"; then + echo "$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" + else + echo "# Bare-name alias '$_name' skipped (would shadow existing command)." + fi done fi } > "$out.tmp" # Atomic install — readers in other shells never see a partial file. mv "$out.tmp" "$out" chmod 644 "$out" + + # Re-source in the calling shell so newly-registered accounts are usable + # immediately without the user having to `exec zsh`. Function definitions + # from a sourced file are global by default in zsh, so this works even + # though we're sourcing inside a function. + source "$out" } _ckipper_sync_hooks_for() { @@ -563,7 +584,8 @@ _ckipper_list() { echo "" echo "* = default. Run: ckipper default " echo "" - echo "Reminder: do not run the same account in two sessions concurrently — see #24317." + echo "Tip: don't run the same account in two terminals at once — Claude's OAuth refresh" + echo "is single-use, so the second session gets logged out. Use a different account instead." } _ckipper_default() { _ckipper_check_registry_version || return 1 @@ -588,6 +610,10 @@ _ckipper_remove() { local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") _ckipper_registry_update 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' --arg n "$name" + # Drop the now-stale launcher functions from the calling shell (regenerate + # only redefines what's still in the registry; it can't unset removed entries). + unset -f "claude-$name" 2>/dev/null + unset -f "$name" 2>/dev/null _ckipper_regenerate_aliases echo "Unregistered '$name'." echo "" @@ -654,14 +680,19 @@ _ckipper_rename() { return 1 fi + # Drop old-name launcher functions from the calling shell. + unset -f "claude-$old" 2>/dev/null + unset -f "$old" 2>/dev/null _ckipper_regenerate_aliases _ckipper_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to the new dir echo "Renamed '$old' → '$new'." echo "Directory: $old_dir → $new_dir" - echo "Use: claude-$new (or: cca $new)" - echo "" - echo "Restart your shell (exec zsh) so aliases.zsh picks up the new function name." + if _ckipper_bare_alias_safe "$new"; then + echo "Use: claude-$new (or just: $new)" + else + echo "Use: claude-$new" + fi } # Validates that an account exists in the registry. Echoes its config_dir on success. diff --git a/install.sh b/install.sh index 55bf314..2e67982 100755 --- a/install.sh +++ b/install.sh @@ -117,7 +117,7 @@ fi # 8. Print (do not auto-append) the optional aliases.zsh source line echo "" -echo "Optional: enable per-account aliases (claude-, cca ) by adding to ~/.zshrc:" +echo "Optional: enable per-account launchers (claude- and bare ) by adding to ~/.zshrc:" echo " [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh" echo "" From 5ce5c77a1cc3caeffc8f015faa9f82eb13f57bd4 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 20:58:47 -0600 Subject: [PATCH 036/165] ckipper add: deploy hooks before /login; friendlier lock-wait UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ckipper add: rewrite settings.json hook paths to the per-account dir BEFORE launching claude for /login. The template ships with $HOME/.claude/hooks/... paths, which fired SessionStart errors during the registration flow ("No such file or directory") because path rewrite only happened post-finalize. _ckipper_sync_hooks_for now optionally accepts the dir directly, so it can run before the account is in the registry. Registry-lock contention: print "Waiting on registry lock..." after ~1.5s instead of staying silent for the full 10s before stale-lock recovery — a quiet multi-second pause looked like a freeze. Also reword the recovery line as "Cleaning up old lock from a previous session" instead of the more alarming "Recovering stale registry lock". --- ckipper.zsh | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index 49ce767..60b93bc 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -224,15 +224,23 @@ _ckipper_registry_update() { setopt local_options local_traps local lockdir="$CKIPPER_DIR/.registry.lock.d" local attempts=0 + local notified=0 while ! mkdir "$lockdir" 2>/dev/null; do (( attempts++ )) + # Reassure the user something is happening — silent multi-second + # pauses look like a freeze. Print once at ~1.5s in, then again + # only if we recover a stale lock below. + if (( attempts == 30 && notified == 0 )); then + echo "Waiting on registry lock..." >&2 + notified=1 + fi if (( attempts >= 200 )); then # 10s local lockdir_age now now=$(date +%s) local mtime; mtime=$(_ckipper_stat_mtime "$lockdir") lockdir_age=$(( now - ${mtime:-$now} )) if (( lockdir_age > 30 )); then - echo "Recovering stale registry lock (age ${lockdir_age}s)" >&2 + echo "Cleaning up old lock from a previous session (age ${lockdir_age}s)..." >&2 rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" attempts=0 continue @@ -349,6 +357,11 @@ _ckipper_add() { if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" fi + # Deploy hook scripts and rewrite settings.json paths to the per-account + # dir BEFORE launching claude. The template ships with $HOME/.claude/hooks + # paths; without this rewrite, the /login session inside claude fires hook + # errors ("No such file or directory") for paths that don't exist yet. + _ckipper_sync_hooks_for "$name" "$dir" local before_snapshot before_snapshot=$(_ckipper_keychain_snapshot) || return 1 @@ -525,10 +538,15 @@ _ckipper_regenerate_aliases() { } _ckipper_sync_hooks_for() { - local name="$1" - _ckipper_check_registry_version || return 1 - local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - [[ -z "$dir" || "$dir" == "null" ]] && return 1 + local name="$1" dir="${2:-}" + # Allow callers (notably _ckipper_add, which deploys hooks BEFORE the + # account exists in the registry) to pass the dir directly. Without an + # explicit dir, look it up in the registry. + if [[ -z "$dir" ]]; then + _ckipper_check_registry_version || return 1 + dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + [[ -z "$dir" || "$dir" == "null" ]] && return 1 + fi mkdir -p "$dir/hooks" cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true From 21715342fa10200df7ce2fdbc1a19da1ca19016c Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 21:11:46 -0600 Subject: [PATCH 037/165] ckipper: rewrite plugin metadata paths on migrate; add repair-plugins helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After ckipper migrate moves ~/.claude → ~/.claude-, the plugin cache dirs move with the rename, but ~/.claude-/plugins/known_marketplaces.json and installed_plugins.json still embed the legacy /Users//.claude/... absolute paths. Claude Code then surfaces "Plugin not found in marketplace ..." for every previously- installed plugin. Fixes: - _ckipper_rewrite_plugin_paths: idempotent helper that sed-rewrites the two plugin metadata files between two prefixes (with .pre-rewrite-backup copies preserved alongside). - ckipper migrate: invoke the helper after step 2 so fresh migrations produce a working plugin set. - ckipper repair-plugins : one-shot fix for already-migrated accounts. Auto-detects the stale prefix. - ckipper doctor: warns per-account if plugins/*.json still has stale ~/.claude/ paths, points at the repair command. Also fix a long-standing zsh 5.9 quirk in doctor where `local var; var=$(...)` inside a `while`/`for` loop body emits "var=''" to stdout. Combine the declaration and assignment so the trace doesn't fire. --- ckipper.zsh | 121 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 7 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index 60b93bc..57d4fa6 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -10,7 +10,7 @@ ckipper() { shift 2>/dev/null case "$cmd" in # --help on any subcommand short-circuits to subcommand help - add|list|default|remove|rename|sync|sync-hooks|migrate|doctor) + add|list|default|remove|rename|sync|sync-hooks|migrate|doctor|repair-plugins) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_help_for "$cmd" return 0 @@ -37,6 +37,7 @@ Usage: ckipper sync-hooks Copy hooks into all registered accounts ckipper migrate One-time migration from legacy layout ckipper doctor Diagnostic check of registered accounts and tooling + ckipper repair-plugins Rewrite stale ~/.claude/ paths in plugin metadata Companion commands (sourced via aliases.zsh): claude- [args...] Auto-generated launcher per registered account @@ -77,6 +78,18 @@ Keychain service name is NOT changed — only the dir + registry mapping. EOF ;; sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; + repair-plugins) + cat <<'EOF' +ckipper repair-plugins + +Rewrite stale absolute paths in /plugins/{known_marketplaces, +installed_plugins}.json from $HOME/.claude/... to the account's actual dir. + +Use this when Claude Code shows "Plugin not found in marketplace ..." for +plugins that were installed before `ckipper migrate` (or before the dir was +renamed). Backups are written alongside each rewritten file. +EOF + ;; sync) cat <<'EOF' ckipper sync [options] @@ -160,6 +173,36 @@ _ckipper_stat_perms() { fi } +# Rewrite stale absolute paths embedded in Claude Code's plugin metadata files +# (known_marketplaces.json, installed_plugins.json). After moving an account +# directory (legacy ~/.claude → ~/.claude-), the plugin cache files on +# disk have moved with the rename, but the JSON metadata still has the old +# absolute paths baked in — Claude Code then fails to resolve plugins with +# "Plugin not found in marketplace ..." errors. +# +# $1 = old prefix (must end with `/`), e.g. "$HOME/.claude/" +# $2 = new prefix (must end with `/`), e.g. "$HOME/.claude-personal/" +# Idempotent: if neither file contains the old prefix, this is a no-op. +_ckipper_rewrite_plugin_paths() { + local old="$1" new="$2" + [[ -z "$old" || -z "$new" || "$old" != */ || "$new" != */ ]] && return 1 + [[ "$old" == "$new" ]] && return 0 + local f rewrote=0 + for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do + local fp="$new$f" + [[ -f "$fp" ]] || continue + grep -q -- "$old" "$fp" 2>/dev/null || continue + cp "$fp" "$fp.pre-rewrite-backup-$(date +%s)" + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + sed -i '' "s|$old|$new|g" "$fp" + else + sed -i "s|$old|$new|g" "$fp" + fi + rewrote=1 + done + return 0 +} + # Cross-platform stat for mtime in seconds since epoch. _ckipper_stat_mtime() { if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then @@ -579,6 +622,48 @@ _ckipper_sync_hooks() { done <<< "$names" } +_ckipper_repair_plugins() { + local name="$1" + if [[ -z "$name" ]]; then + echo "Usage: ckipper repair-plugins " + return 1 + fi + _ckipper_check_registry_version || return 1 + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") + if [[ -z "$dir" ]]; then + echo "Account '$name' is not registered. Run: ckipper list" + return 1 + fi + if [[ ! -d "$dir" ]]; then + echo "Account dir does not exist: $dir" + return 1 + fi + + # Detect what stale prefix the metadata is using. Almost always the legacy + # ~/.claude/, but a previously-renamed account could carry an older suffix. + local stale_prefix="" + local f + for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do + [[ -f "$dir/$f" ]] || continue + # Combined declare+assign on one line: zsh 5.9 emits "var=''" to stdout + # when `local var` and `var=$(...)` are split across two lines inside a + # `for` loop body. Trivia that mostly bites diagnostic helpers like this. + local hit=$(grep -oE "$HOME/\.claude(-[a-z0-9_-]+)?/" "$dir/$f" 2>/dev/null | sort -u | grep -v "^$dir/$" | head -1) + if [[ -n "$hit" ]]; then + stale_prefix="$hit" + break + fi + done + if [[ -z "$stale_prefix" ]]; then + echo "No stale paths found in $dir/plugins/. Nothing to repair." + return 0 + fi + echo "Rewriting plugin metadata for '$name':" + echo " $stale_prefix → $dir/" + _ckipper_rewrite_plugin_paths "$stale_prefix" "$dir/" + echo "Done. Backups saved alongside each rewritten file (.pre-rewrite-backup-)." +} + _ckipper_list() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then echo "No accounts registered. Run: ckipper add " @@ -921,21 +1006,36 @@ _ckipper_doctor() { while IFS= read -r name; do echo "" echo " Account: $name" - local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - local svc; svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + # Combined declare+assign: zsh 5.9 leaks "var=''" to stdout when the + # `local x; x=$(...)` form runs inside a `while` loop body. + local dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") if [[ -d "$dir" ]]; then check PASS " dir exists: $dir" else check FAIL " dir missing: $dir"; fi if [[ -f "$dir/.claude.json" ]]; then - local email proj_count mcp_count - email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) - proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) - mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) + local email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) + local proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) + local mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" else check WARN " .claude.json missing in $dir" fi if [[ -f "$dir/settings.json" ]]; then check PASS " settings.json present"; else check WARN " settings.json missing"; fi if [[ -d "$dir/hooks" ]]; then check PASS " hooks/ deployed"; else check WARN " hooks/ missing — run: ckipper sync-hooks"; fi + # Stale plugin-metadata paths: a sign that ckipper migrate moved + # the account dir without rewriting absolute paths inside + # plugins/{known_marketplaces,installed_plugins}.json. Symptom is + # "Plugin not found in marketplace ..." in the Claude Code UI. + local stale_pm=0 + for pm in known_marketplaces.json installed_plugins.json; do + [[ -f "$dir/plugins/$pm" ]] || continue + if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then + stale_pm=1 + fi + done + if (( stale_pm )); then + check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" + fi # Keychain check (macOS only) if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then if [[ -z "$svc" ]]; then @@ -1175,6 +1275,13 @@ EOF migrate_step=2 fi + # Step 2.5: rewrite stale absolute paths in plugin metadata. + # Without this, Claude Code raises "Plugin not found in marketplace" + # for every previously-installed plugin, because installed_plugins.json + # and known_marketplaces.json still reference $legacy_claude/... + # (which no longer exists post-rename). + _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" + if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then _ckipper_migrate_rollback failed trap - INT TERM HUP QUIT From fc7fa762cf054fe8df80cbf104f5ad145bc40d7b Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 22:11:15 -0600 Subject: [PATCH 038/165] Adopt llm-agent-kit: .claude/ rules + AGENTS.md Replaces the prior Ckipper-specific CLAUDE.md with the agnostic structure from mswdev/llm-agent-kit: .claude/CLAUDE.md plus topic-scoped rules in .claude/rules/{code-style,testing,security,file-organization}.md, and an AGENTS.md symlink so non-Claude agents read the same content. Genericized the kit's TS/React-leaning bits (JSDoc-only doc rule, vitest example, Storybook stories, Tailwind/Figma workflow) for this repo's shell + Python + Docker stack. Skipped the kit's .github workflows and tailwind-plus-components.md. --- .claude/CLAUDE.md | 95 ++++++++++++++++++++++++++++++ .claude/rules/code-style.md | 53 +++++++++++++++++ .claude/rules/file-organization.md | 36 +++++++++++ .claude/rules/security.md | 25 ++++++++ .claude/rules/testing.md | 56 ++++++++++++++++++ AGENTS.md | 1 + CLAUDE.md | 71 ---------------------- 7 files changed, 266 insertions(+), 71 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/rules/code-style.md create mode 100644 .claude/rules/file-organization.md create mode 100644 .claude/rules/security.md create mode 100644 .claude/rules/testing.md create mode 120000 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..83ce1ba --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +See @README.md for project overview. + +> **Owner:** [Your Name / Org] +> **Product:** [Brief product description] +> **Repo:** [Repo name and structure] + +## Quick Reference + +**Core Rules:** +- @.claude/rules/code-style.md — Naming, complexity limits, documentation +- @.claude/rules/testing.md — Test structure, what to test, quality gates +- @.claude/rules/security.md — Security requirements +- @.claude/rules/file-organization.md — Directory structure, file caps, dependency direction + +**Package Rules:** *(add package-specific rule files as needed)* + + +## How to Use These Instructions + +1. **Always follow** the core philosophy and code standards +2. **Consult package-specific rules** when working in individual packages +3. **Package rules extend, not override** shared standards (e.g., a backend package adds validation requirements but doesn't remove the 25-line method limit) + +## 1. Project Overview + + + + + +## 2. Core Engineering Philosophy + +1. **KISS** — Keep It Simple, Stupid. The simplest solution that works is the best solution. +2. **Clarity over cleverness** — No tricks, no golf, no "elegant" one-liners that require a comment to explain. +3. **Functional decomposition** — Break problems into small, named, single-purpose functions. +4. **Object-Oriented Design** — Model the domain with clear objects, well-defined boundaries, and explicit contracts. +5. **Test what matters** — Unit tests are not optional. If logic makes a decision, it gets a test. ALWAYS WRITE TESTS. +6. **SOLID Principles** — Follow SOLID programming principles. + +## 3. Code Review Checklist + +Before approving any PR, verify: +- [ ] **Can I understand every method without reading its callees?** If no, the names need work. +- [ ] **There are NO MAGIC NUMBERS** +- [ ] **Is every method <= 25 lines?** NO EXCEPTIONS. +- [ ] **Is nesting <= 2 levels deep?** Extract if not. +- [ ] **Does each module/class have a single, obvious responsibility?** +- [ ] **Are there tests for every decision point in the logic?** +- [ ] **Is there any cleverness that should be replaced with clarity?** +- [ ] **Would a new teammate understand this in 5 minutes?** +- [ ] **Do new entry points (CLI, scripts, API endpoints) validate input at trust boundaries?** +- [ ] **Do exported functions have clear documentation when behavior isn't obvious from name and signature?** +- [ ] **Do route handlers and service methods log their outcomes (success, not-found, error)?** +- [ ] **Do error paths surface to monitoring — never swallowed silently?** + +## 4. Infrastructure & Services + + + + +## 5. Git Workflow + +**Branch naming:** `feature/{ticket-or-slug}-{short-description}` (e.g., `feature/123-user-auth`) +**Commit messages:** Reference the ticket if applicable (e.g., `#123: Implement user auth flow`) +**Always use feature branches + PRs.** NEVER commit directly to `main` or `develop`. +**ALWAYS create PRs as drafts** (`gh pr create --draft`). The author decides when to mark "Ready for review." +**PR description:** Link to the ticket if applicable, describe what changed and why, list affected files. + +## 6. AI-Specific Instructions + +- **Read and ingest before you edit.** Always read relevant source files before proposing changes. NEVER speculate about code you haven't inspected. +- **These rules are authoritative over observed codebase patterns.** If existing code violates a rule in this document or `.claude/rules/`, that is technical debt — not a convention to follow. Never justify bad practices because you see them elsewhere in the repo. When in doubt, follow the rules, not the code. +- **Follow existing design patterns that comply with these rules.** Study the relevant package and match the established architecture, file placement, and naming. If a convention exists and does not violate these rules, use it. If you have a clear technical reason to deviate, explain the rationale. +- **Reuse existing utility functions** +- **Reuse existing UI components** +- **Verify schema and queries against source files.** Check your ORM schema for table/column structure before writing code that references them. +- **Check existing types before creating new ones** to avoid duplication. Create new types when genuinely needed for new features. +- **Flag security concerns proactively** (exposed secrets, SQL injection, missing auth, etc.). +- **Use parallel tool calls** for independent operations (e.g., reading multiple files, running lint and test simultaneously). +- **Package context awareness:** When working in a specific package, prioritize that package's rule file. diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md new file mode 100644 index 0000000..ecf1403 --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,53 @@ +# Code Style + +## Method Size & Complexity + +These are **hard limits**, not guidelines: +- **MAXIMUM 25 lines per method/function** (excluding blank lines and closing braces). +- **MAXIMUM 2 levels of control flow nesting** per method. If you need a third level, extract a method. +- **MAXIMUM 3 parameters** per method. Beyond that, introduce a parameter object or rethink the design. + +## YOU MUST USE EARLY RETURNS OVER DEEP NESTING + +Guard clauses go at the top. The happy path reads straight down. + +## Naming Conventions + +Names should be **descriptive and unambiguous**. A reader should never have to look at a method body to understand what it does. Avoid abbreviations. + +- **Functions/Methods**: language-idiomatic (snake_case in shell/Python, camelCase in JS/TS) — verbs (`getUserById`, `approve_request`, `calculateTotal`) +- **Types/Classes**: PascalCase — nouns (`InvoiceCalculator`, `ClaimValidator`) +- **Booleans**: prefix with `is`, `has`, `can`, `should` (`isEligible`, `has_access`) +- **Collections**: pluralize (`users`, `active_orders`, `pendingItems`) +- **Constants/env vars**: UPPER_SNAKE_CASE (`ALGORITHM`, `KEY_LENGTH`, `MAX_RETRY_COUNT`) +- **Files**: match the language's idiomatic convention (PascalCase for classes, kebab-case for shell scripts, snake_case for Python modules) + +## General Rules + +- **Use the language's strongest type discipline** — explicit over implicit. No escape hatches (`any`, untyped `unknown`, `eval`) without justification. +- **Prefer async-first APIs** in languages that support them (`async/await` over raw promises/callbacks). +- **One responsibility per file.** +- **NO MAGIC NUMBERS EVER** — ALWAYS EXTRACT TO A NAMED CONSTANT. + +## Code Documentation & Comments + +All code must include clear, human-readable documentation. Comments should be written so that a junior-level developer or higher can understand what is being done and why. + +**Public APIs are documented.** In languages with doc tooling (TSDoc/JSDoc, Python docstrings, Rustdoc, etc.), document every exported function, method, class, and interface — IDEs surface these as tooltips and they enable automated API-doc generation. + +**Required information** (using the language's doc syntax): +- Each parameter's purpose and constraints +- Return value and the conditions producing it +- Exceptions/errors the function may raise +- Usage example for non-trivial functions +- Cross-references to related code or docs +- Deprecation notice with a migration path + +**Inline comments** should explain "why," not "what." Comment business logic, workarounds, edge cases, and non-obvious decisions — not obvious code. + +## Linting + + + + + diff --git a/.claude/rules/file-organization.md b/.claude/rules/file-organization.md new file mode 100644 index 0000000..7db6b2a --- /dev/null +++ b/.claude/rules/file-organization.md @@ -0,0 +1,36 @@ +# File Organization + +## Directory Size Limits + +These are **hard limits**, not guidelines: +- **MAXIMUM 10 source files per directory.** Count only source files — colocated test and story files do NOT count toward the cap. If a directory has 10 source files, the next file MUST go in a subdirectory. No exceptions. +- **Colocate test and story files with their source files.** `parser.py` and `parser_test.py` (and any colocated stories/fixtures) belong together in the same directory. + +When a directory approaches the cap, group related files into subdirectories by **domain**, **feature**, or **concern** — not by file type. Colocated tests and stories move with their source files into the subdirectory. + +## Directory Grouping + +When a directory needs subdirectories, group by **domain or feature**, not by file type. Keep related code together — all claim files in one place, not scattered across `types/`, `validators/`, and `handlers/`. + +**Exception:** Top-level `src/` directories MAY be organized by architectural layer (e.g., `api/`, `db/`, `event/`) when they represent distinct system boundaries. Within those layers, group by domain. + +## Dependency Direction + +Imports flow **downward and inward**, never upward or sideways across features. + +- **Parent directories MUST NOT import from child route/feature directories.** Shared code lives at the nearest common ancestor. +- **Sibling feature directories MUST NOT import from each other.** If two features need the same code, extract it to their shared parent or a `_shared/` directory. +- **Shared modules are pulled up, never reached into.** If two features both need a utility, it belongs in their common parent — not in one reaching into the other. + +## DO NOT MIMIC EXISTING BAD PATTERNS + +- **NEVER add files to a directory that already exceeds the 10-file cap.** Flag it to the user and propose a restructuring. +- **When creating new files, follow these rules from scratch** — do not pattern-match against nearby directories that may be poorly organized. + +## Code Review Checklist + +Before approving any PR, verify: +- [ ] **Is every directory under the 10-file cap (source files only)?** +- [ ] **Are test and story files colocated with their source files?** +- [ ] **Are subdirectories grouped by domain/feature, not by file type?** +- [ ] **Do imports flow downward — no parent importing from child, no sibling cross-imports?** diff --git a/.claude/rules/security.md b/.claude/rules/security.md new file mode 100644 index 0000000..4bbf9b6 --- /dev/null +++ b/.claude/rules/security.md @@ -0,0 +1,25 @@ +# Security + +## No-Touch Zones + +These files require **explicit approval** before any modification: + + + + + + +- Any `.env*` files, deployment configs, or CI/CD workflows +- Database migration files +- Authentication/authorization configuration +- Cryptography or encryption modules +- Financial calculation modules +- Files handling user secrets or credentials + +## Security Rules + +- **NEVER hardcode secrets in source code** +- **NEVER run destructive operations without confirmation** (DROP, TRUNCATE, DELETE without WHERE, `rm -rf`, force-push) +- **Validate all input at trust boundaries** — NEVER TRUST UNTRUSTED INPUT (CLI args, env vars, files, network requests; use Joi/Zod/equivalent for HTTP) +- **Flag security concerns proactively** — exposed secrets, injection (SQL, shell, command), missing auth, XSS, CSRF, etc. +- **Use lossless types for sensitive data** — money in minor units (cents) as integers, never floats; times in epoch ms or ISO 8601 diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..683f061 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,56 @@ +# Testing + +## Philosophy + +Tests are **documentation** that happens to be executable. A test should read like a specification of behavior. + +## Structure: Arrange -> Act -> Assert + +Separate the three phases with blank lines: + +```python +class TestOrderProcessor: + def test_creates_order_for_valid_request(self): + request = RequestFixture.valid() + processor = build_order_processor() + + order = processor.submit(request) + + assert order.status == OrderStatus.PENDING + assert order.request_id == request.id + + def test_rejects_invalid_requests(self): + request = RequestFixture.invalid() + processor = build_order_processor() + + with pytest.raises(InvalidRequestError): + processor.submit(request) +``` + +## What to Test + +- **Processors and business logic** — Always. This is the core of the system. +- **Utility/helper functions** — Always. They're pure and easy to test. +- **Financial calculations** — Always. Money math must be bulletproof. +- **Entry points / handlers** — Integration tests for the happy path and key error cases. +- **Observable behavior, not implementation** — When testing components or modules with internal state, assert on the externally visible outcomes, not on private structure. + +## What NOT to Test + +- Simple getters/setters or data classes with no logic. +- Framework boilerplate (middleware wiring, route config, loader setup). +- Third-party library behavior. + +## Test Doubles + +Prefer **hand-written fakes** over mocking libraries. Fakes are simpler, more readable, and catch interface drift at compile time. + +## Quality Gates + +**Before committing, ALWAYS run** the project's verification commands (build / test / lint / typecheck — whichever apply). + + diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..ac55cbd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.claude/CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 67caa52..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,71 +0,0 @@ -# CLAUDE.md - -**Ckipper** (pronounced "skipper") — Docker-based sandbox for running Claude Code with `--dangerously-skip-permissions` safely. The `w()` shell function creates a git worktree, launches a Docker container, and runs Claude autonomously inside it. One command to spin up an isolated session on any project. - -## Architecture - -- **`w-function.zsh`** — zsh function that manages worktrees (`git worktree add`), builds/runs Docker containers, extracts macOS Keychain credentials, forwards ports, and detects `.git/config` tampering post-session. Resolves the active Ckipper account from `--account`, env, or registered default. Sources `ckipper.zsh` at the bottom. Includes tab completion. -- **`ckipper.zsh`** — multi-account manager. Subcommands: `add`, `list`, `default`, `remove`, `sync-hooks`, `migrate`. Owns the registry at `~/.ckipper/accounts.json` and the auto-generated `~/.ckipper/aliases.zsh`. Sourced by `w-function.zsh` after deployment. -- **`w-config.zsh.example`** — Template for user-specific Docker config (ports, volume mounts, env vars). Copied to `~/.ckipper/docker/w-config.zsh` on first install, never overwritten on updates. -- **`docker/Dockerfile`** — `node:24-slim` image with dev tools (git, gh, ripgrep, tmux, Chromium, uv/uvx, bun, Claude Code native installer). Runs as non-root `claude` user. -- **`docker/entrypoint.sh`** — Container startup: errors out if `CLAUDE_CONFIG_DIR` is unset; mutates `.claude.json` (chrome flags, MCP rewrites) in the bind-mounted account dir, writes credentials to tmpfs, sets git identity, disables GPG signing via `GIT_CONFIG_COUNT`, authenticates `gh` CLI, optionally enables firewall, creates `bunx` wrapper, runs `npm install` for Linux binaries, clears credential env vars, then runs the provided command. -- **`docker/cleanup-projects.py`** — Registry-driven helper invoked by `w` for `--rm` cleanup (removes a worktree entry from every account's `.claude.json`) and worktree creation (copies main project settings into the new worktree entry under the active account). -- **`hooks/`** — Four Claude Code hooks (template at `settings-hooks.json` → deployed to `~/.ckipper/settings-template.json`): - - `protect-claude-config.sh` — PreToolUse on Edit/Write: blocks modifications to `~/.claude*/...settings|hooks|plugins/...` and anything under `~/.ckipper/`. `~/.claude-host.json` is intentionally allowed (read-only staging mount). - - `bash-guardrails.sh` — PreToolUse on Bash: blocks `rm -rf`, `git push --force`, `git reset --hard`, `.git/hooks` writes, recursive `chmod`/`chown`, credential file reads, Claude/Ckipper config modification via shell. - - `docker-context.sh` — SessionStart: injects safety rules so Claude avoids triggering guardrails - - `notify-bell.sh` — Notification: sends terminal bell (`\a`) so host terminal fires native notifications (dock bounce, sound) - - All four are no-op on the host (exit early if `/.dockerenv` doesn't exist) -- **`docker/init-firewall.sh`** — Optional `iptables-legacy` egress whitelist (default-deny). Uses `--cap-add=NET_ADMIN`. - -## Multi-account model - -Each Claude account is a `CLAUDE_CONFIG_DIR=~/.claude-/` directory — analogous to the legacy `~/.claude/`, but namespaced. The registry at `~/.ckipper/accounts.json` (chmod 600, schema version 1) maps account name → `{config_dir, keychain_service, registered_at}`. Atomic writes via `flock` (with `mkdir`-based fallback for systems without `flock`). - -`w` resolves the active account in priority order: `--account ` > `CLAUDE_CONFIG_DIR` env (matched against registry) > registered default. **No legacy fallback** — if no account resolves, `w` errors out and tells you to register one. Inside the container, the entrypoint requires `CLAUDE_CONFIG_DIR` and exits 1 if unset (no silent fallback). - -Companion shell layer: `~/.ckipper/aliases.zsh` is auto-regenerated on every `ckipper add` / `remove` / `rename`, then re-sourced into the calling shell so new launchers work immediately. It contains a `claude-` function per registered account, plus a bare `` shortcut when that name doesn't shadow a PATH binary, builtin, alias, or reserved word (see `_ckipper_bare_alias_safe`). The file is self-contained — sourcing only `aliases.zsh` (without `ckipper.zsh` or `w-function.zsh`) yields a working setup. - -`settings-template.json` is **seed-only**. After an account is registered, its `settings.json` diverges (per-account hooks, paths). Re-running `ckipper sync-hooks` refreshes hook paths and copies the canonical `~/.ckipper/hooks/*` into each account; it does not re-apply the template wholesale. - -## Critical Safety Rules - -**Never modify the host's `.git/config` from inside the container.** The container mounts the host's `.git` directory read-write (required for worktree refs to resolve). Any `git config --local` command modifies the host's actual git config. Use `GIT_CONFIG_COUNT` environment variables instead — they take highest priority in git's config precedence and disappear when the container exits. - -**Clear credentials from the environment before `exec claude`.** The entrypoint receives `CLAUDE_CREDENTIALS` and `GH_TOKEN` as env vars, writes them to disk, then `unset`s them before `exec`. The `exec` replaces the process, so `/proc/self/environ` is clean. If you add new credential env vars, follow this same pattern. - -**Don't run the same Ckipper account in two sessions.** Per-account `.claude.json` is bind-mounted RW into the container — entrypoint mutations (chrome flags, MCP rewrites) propagate back to the host file. With the multi-account model, the previous read-only staging mount was dropped (macOS Docker Desktop virtiofs cannot create a nested mountpoint under another bind-mount). Race protection now relies on the user advisory: if you need concurrent containers, use different accounts. See README issue #24317 reference. - -**Hooks prevent accidents, not adversarial bypass.** Absolute paths (`/bin/rm`), language-level file access (`python3 -c "open(...).read()"`), and symlink indirection can bypass the bash guardrails. This is accepted. Don't over-engineer the regex matching. - -**Validate `keychain_service` shape before any `security` call.** The registry stores a per-account Keychain service name (`Claude Code-credentials` optionally followed by `-`). Both `ckipper add` and `w` validate the shape via `_ckipper_validate_keychain_service` before passing it to `security find-generic-password`. Without this, a tampered registry could feed arbitrary arguments into `security` (command injection vector). Hooks also block writes to `~/.ckipper/accounts.json` so a compromised in-container Claude can't redirect another account's keychain service. - -## Development Workflow - -| Change | Action Required | -|--------|----------------| -| `Dockerfile` | `w --rebuild-image` | -| `entrypoint.sh` | `w --rebuild-image` (it's `COPY`'d into the image) | -| `init-firewall.sh` | `w --rebuild-image` (it's `COPY`'d into the image) | -| `w-function.zsh` | `./install.sh` (copies to `~/.ckipper/docker/`; user config in `w-config.zsh` is preserved) | -| `ckipper.zsh` | `./install.sh` (copies to `~/.ckipper/docker/`; sourced by `w-function.zsh`) | -| `cleanup-projects.py` | `./install.sh` (copies to `~/.ckipper/docker/`; invoked by `w` for `--rm` and worktree-create sync) | -| `w-config.zsh.example` | Template only — user's `~/.ckipper/docker/w-config.zsh` is never overwritten | -| `hooks/*` | `./install.sh` deploys to `~/.ckipper/hooks/`; per-account copies are written by `ckipper sync-hooks` | -| `settings-hooks.json` | `./install.sh` deploys to `~/.ckipper/settings-template.json` (consumed by `ckipper add` and `ckipper sync-hooks` for per-account `settings.json`) | - -Two copies of the code exist: this repo (development) and deployed files on the host (`~/.ckipper/docker/`, `~/.ckipper/hooks/`). Run `./install.sh` to sync all core files. User customizations live in `~/.ckipper/docker/w-config.zsh` and are never overwritten. - -## Testing - -`test-prompt.md` is the validation suite. It has 12 sections covering entrypoint verification, filesystem access, git operations, build tools, safety hooks (including bypass attempts), container isolation, and **multi-account isolation**. Run it by starting a Docker session (`w --docker claude`) and pasting the prompt contents. Section 12 must be run in two concurrent containers under different accounts. - -## Key Implementation Details - -- **npm install in entrypoint**: Replaces macOS native binaries (rollup, biome, esbuild, swc) with Linux ones. This is intentional — the worktree's `node_modules` were installed on macOS. -- **`TURBO_CACHE_DIR`**: Set to `/workspace/.turbo/cache` because worktree git roots resolve to the host's main repo path, which isn't writable in the container. -- **`gh auth`**: Must `unset GH_TOKEN` before `gh auth login --with-token` because gh refuses to store credentials while the env var is set. Then `gh auth setup-git` configures gh as the git credential helper for HTTPS push. -- **`core.hooksPath`**: Set globally to `~/.git-hooks` on the host so git ignores `.git/hooks/` — prevents planted hooks from executing on the host after the container exits. -- **Port forwarding**: Ports that are already in use on the host are silently skipped. -- **Worktree removal** (`w --rm`): Removes the project entry from every registered account's `.claude.json` via `cleanup-projects.py`. -- **Account-aware mounts**: `w` mounts `$active_config_dir:$active_config_dir:rw` (no `/home/claude/.claude` dual mount). Plugins' absolute-path references resolve because the account dir is at the same host path. Credentials still live in container-only tmpfs (`/tmp/claude-creds`) — the host-side symlink target points to a path that doesn't exist outside the container. From bebfa772393130343d4f7135214633bc210622a1 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 22:54:44 -0600 Subject: [PATCH 039/165] Phase 0: author CLAUDE.md, fill Linting section, add shell-conventions --- .claude/CLAUDE.md | 48 ++++++++++++------- .claude/rules/code-style.md | 11 +++-- .claude/rules/shell-conventions.md | 77 ++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 .claude/rules/shell-conventions.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 83ce1ba..273cb18 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,9 +2,8 @@ See @README.md for project overview. -> **Owner:** [Your Name / Org] -> **Product:** [Brief product description] -> **Repo:** [Repo name and structure] +> **Product:** Ckipper — multi-account Claude Code manager with Docker isolation. +> **Repo:** Single-package zsh project. Top-level layout in §1. ## Quick Reference @@ -28,13 +27,28 @@ See @README.md for project overview. ## 1. Project Overview - - - +Ckipper is a zsh-based wrapper for the [Claude Code CLI](https://claude.ai/cli) that provides: + +- **Multi-account isolation** — separate `~/.claude-/` config dirs per registered account, with macOS Keychain integration for credentials. +- **Worktree-aware launchers** — `w ` creates a git worktree, syncs settings, and either runs Claude Code locally or launches it inside a hardened Docker container. +- **Per-account aliases** — auto-generated `` shell functions (e.g., `personal`, `work`) that route to the correct config dir. +- **Safety hooks** — Claude Code hooks (`bash-guardrails.sh`, `protect-claude-config.sh`) that block destructive commands and protect Ckipper-owned config files from accidental modification. +- **Docker sandbox** — Dockerfile + entrypoint that isolate Claude Code with an egress firewall, credential injection via tmpfs, and pre-installed MCP server tooling. + +**Top-level layout:** + +``` +ckipper.zsh # ckipper CLI entry (account add/remove/sync/doctor/migrate) +w-function.zsh # w() launcher entry (sourced from .zshrc) +lib/core/ # shared primitives (registry, keychain, utils) +lib/ckipper/ # ckipper-specific subcommands +lib/w/ # w-specific helpers +hooks/ # Claude Code safety hooks +docker/ # Dockerfile + entrypoint + firewall + cleanup +tests/ # bats + pytest tests +install.sh # one-shot installer (copies to ~/.ckipper/) +.claude/ # rules + project Claude config +``` ## 2. Core Engineering Philosophy @@ -63,15 +77,15 @@ Before approving any PR, verify: ## 4. Infrastructure & Services - - +| Docker | Containerization for `--docker` mode. Image built locally from `docker/Dockerfile`. | Active | +| macOS Keychain | Credential storage per account (service: `Claude Code-credentials-`). Accessed via `security` CLI. | Active | +| Anthropic API | Invoked transitively by Claude Code CLI inside the container. No direct API calls from Ckipper. | Indirect | +| GitHub API | `gh` CLI used by `init-firewall.sh` to fetch GitHub IP ranges for the egress allow-list. | Active | +| npm registry | Used inside the container by Claude Code's MCP server installation. | Indirect | + +CI runs `make lint` + `make test-unit` on `macos-latest` via `.github/workflows/ci.yml`. ## 5. Git Workflow diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index ecf1403..0ab6965 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -47,7 +47,10 @@ All code must include clear, human-readable documentation. Comments should be wr ## Linting - - - - +Linting is enforced via `make lint` (locally) and `.github/workflows/ci.yml` (CI). Required tools: + +- **shellcheck** — all `.zsh` and `.sh` files. Configured via `.shellcheckrc` at repo root: `enable=all`, `disable=SC1090` (dynamic source paths are intentional in this project). Any other disables require a comment justifying the exception. +- **shfmt** — `shfmt -d -i 4 -ci -s` (4-space indent, indent case, simplify). Diffs MUST be empty in CI. +- **ruff** — Python files. Configured in `pyproject.toml`. Rules: `E`, `F`, `B`, `D` (docstring checks). Line length 100. + +Run `make bootstrap` once to install all linters via Homebrew + pip. diff --git a/.claude/rules/shell-conventions.md b/.claude/rules/shell-conventions.md new file mode 100644 index 0000000..3ed4674 --- /dev/null +++ b/.claude/rules/shell-conventions.md @@ -0,0 +1,77 @@ +# Shell Conventions + +This document specifies how the language-agnostic rules in `code-style.md`, `file-organization.md`, and `testing.md` apply to zsh code in this project. + +## Function-line counting + +The "25 lines per function" cap (`code-style.md`) counts: + +- Lines inside the function body. +- Excluding blank lines and lines containing ONLY a closing `}`. +- Including comment lines (so verbose inline commentary still counts). + +Example: + +```zsh +my_function() { + # 1 line + local x=1 # 2 lines + # blank line — does NOT count + echo "$x" # 3 lines +} # closing brace — does NOT count +``` + +This function is 3 lines. + +## Doc-header convention + +Every public function (and every helper extracted from one) gets a doc-header comment block immediately above its definition: + +```zsh +# +# +# Args: +# $1 — +# $2 — +# +# Returns: +# 0 on ; non-zero on . +# +# Errors (stderr): +# "" — +my_function() { +``` + +For helpers with no args, omit the `Args:` block. The `Errors:` block is required only when the function writes to stderr. + +## Naming + +- **Public functions** (callable from outside the file or from .zshrc): no leading underscore. Examples: `ckipper`, `ck`, `w`. +- **Module-internal functions**: prefix indicates the module: + - `_core_*` — `lib/core/` + - `_ckipper_*` — `lib/ckipper/` + - `_w_*` — `lib/w/` +- **Constants**: `readonly UPPER_SNAKE_CASE` at top of file. No magic numbers. +- **Variables**: snake_case, descriptive (no `tmp`/`idx`/`ans`). +- **Booleans**: prefix with `is_`, `has_`, `can_`, `should_`. Use string values `"true"`/`"false"` (zsh has no native bool); test with `[[ ... = true ]]`. + +## Module sourcing + +Modules under `lib/` are sourced once by an entry script (`ckipper.zsh` or `w-function.zsh`). Modules MUST NOT source siblings. Cross-feature imports (`lib/w/` → `lib/ckipper/` or vice versa) are FORBIDDEN. If two features need shared code, it goes in `lib/core/` (per `file-organization.md`'s "shared modules pulled to common parent" rule). + +CI enforces this: + +```sh +grep -r '_ckipper_' lib/w/ && exit 1 || exit 0 +``` + +## Magic numbers + +Per `code-style.md`, no magic numbers. Constants live at the top of the module file that uses them, declared `readonly`: + +```zsh +readonly KEYCHAIN_TIMEOUT_SECONDS=10 +readonly REGISTRY_FILE_PERMS=600 +``` + +The literals `0`, `1`, and `-1` are exempt when used in idiomatic contexts (exit status, array indexing, error sentinels). From c399549dd3562a1c1c08b41dc9e08dd269f6bb51 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 22:58:32 -0600 Subject: [PATCH 040/165] Phase 1: foundation (lint, test, CI scaffolding) + apply shfmt baseline - .shellcheckrc with baseline disables (TODOs for Phase 4) - pyproject.toml with ruff + pytest config (D103 deferred to Phase 4) - Makefile: bootstrap/test/lint/install targets - tests/lib/test-helper.bash + tests/lib/stubs/ skeleton - .github/workflows/ci.yml (macos-latest) - .gitignore docs/plans/ (per repo policy) - shfmt -w applied to existing .sh files (style baseline) --- .github/workflows/ci.yml | 32 +++++++++++++++++++++++ .gitignore | 2 ++ .shellcheckrc | 38 +++++++++++++++++++++++++++ Makefile | 47 ++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 14 +++++----- docker/init-firewall.sh | 2 +- hooks/protect-claude-config.sh | 4 +-- install.sh | 10 ++++---- pyproject.toml | 22 ++++++++++++++++ tests/lib/stubs/.gitkeep | 0 tests/lib/test-helper.bash | 39 ++++++++++++++++++++++++++++ 11 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .shellcheckrc create mode 100644 Makefile create mode 100644 pyproject.toml create mode 100644 tests/lib/stubs/.gitkeep create mode 100644 tests/lib/test-helper.bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c48e4f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + branches: [develop, main] + +jobs: + lint-and-test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dev tools + run: brew install bats-core shellcheck shfmt + + - name: Install Python tools + run: | + python -m pip install --upgrade pip + pip install ruff pytest + + - name: Lint + run: make lint + + - name: Test (unit) + run: make test-unit + + - name: Verify no sibling cross-imports (lib/w/ -> lib/ckipper/) + run: | + if [ -d lib/w ]; then + ! grep -rE '\b_ckipper_' lib/w/ + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00d8dd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Implementation plan artifacts and design docs (per repo policy, commit 5cef1de) +docs/plans/ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..9add4d1 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,38 @@ +# Project-wide shellcheck config. +# Strict mode: enable all checks, then explicitly disable rules with reasons. +enable=all + +# SC1090: "Can't follow non-constant source" — intentional for dynamic module loading +# (e.g., `source "$CKIPPER_DIR/lib/core/registry.zsh"`). +disable=SC1090 + +# SC1091: "Not following: file path" — same reason as SC1090 for sourced modules +# that aren't on disk during lint runs. +disable=SC1091 + +# Baseline disables — TODO(phase-4): resolve and remove each entry as the +# corresponding code is refactored. These rules currently fire in unrefactored +# bash scripts (.sh files only — zsh files use `zsh -n` for syntax checks). + +# TODO(phase-4): SC2001 — sed could be replaced with parameter expansion. +disable=SC2001 + +# TODO(phase-4): SC2016 — single quotes around $VAR don't expand (often intentional in jq filters). +disable=SC2016 + +# TODO(phase-4): SC2086 — unquoted variable expansions; review case-by-case. +disable=SC2086 + +# TODO(phase-4): SC2129 — multiple consecutive redirects; consider grouping. +disable=SC2129 + +# TODO(phase-4): SC2154 — variable referenced but not assigned (often set elsewhere). +disable=SC2154 + +# Style-only rules (`enable=all` includes these). Defer to project preference; keep disabled. +# SC2250: prefer braces around variable references — adds noise, low value. +# SC2292: prefer [[ ]] over [ ] — fine but pre-existing convention. +# SC2312: consider invoking command separately to take exit code — case-by-case. +disable=SC2250 +disable=SC2292 +disable=SC2312 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..58759c9 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: bootstrap test test-unit test-integration lint lint-shell lint-zsh lint-py lint-fmt install help + +help: + @echo "make bootstrap - install dev tools (bats, shellcheck, shfmt, ruff, pytest)" + @echo "make test - run all tests (bats + pytest)" + @echo "make test-unit - bats + pytest, fast suites only" + @echo "make test-integration - integration tests (BATS_INTEGRATION=1)" + @echo "make lint - run all linters" + @echo "make install - run ./install.sh" + +bootstrap: + brew install bats-core shellcheck shfmt + pip install ruff pytest + +test: test-unit + +test-unit: + bats --recursive . + pytest + +test-integration: + BATS_INTEGRATION=1 bats --recursive . + +lint: lint-shell lint-zsh lint-fmt lint-py + +# shellcheck only on .sh files (bash). .zsh files use `zsh -n` for syntax check. +lint-shell: + shellcheck install.sh + shellcheck hooks/*.sh + shellcheck docker/entrypoint.sh docker/init-firewall.sh + +lint-zsh: + zsh -n ckipper.zsh + zsh -n w-function.zsh + @if [ -d lib ]; then \ + find lib -name '*.zsh' -not -name '*_test.bats' | while read -r f; do zsh -n "$$f" || exit 1; done; \ + fi + +lint-fmt: + shfmt -d -i 4 -ci -s install.sh hooks/*.sh docker/entrypoint.sh docker/init-firewall.sh + +lint-py: + ruff check docker/cleanup-projects.py + @if [ -d lib ]; then find . -name '*.py' -not -name '*_test.py' -not -path './tests/*' -exec ruff check {} +; fi + +install: + ./install.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2e149ca..b3b69a5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -17,8 +17,8 @@ fi # prevents concurrent host/container use of the same file. if [ -f "$CLAUDE_CONFIG_DIR/.claude.json" ] && command -v jq &>/dev/null; then jq '.claudeInChromeDefaultEnabled = false | .cachedChromeExtensionInstalled = false' \ - "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ - && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" + "$CLAUDE_CONFIG_DIR/.claude.json" >"$CLAUDE_CONFIG_DIR/.claude.json.tmp" && + mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" fi # Copy SSH config from staging mount, stripping macOS-specific options. @@ -37,7 +37,7 @@ fi # container-local and disappears when the container exits. if [ -n "$CLAUDE_CREDENTIALS" ]; then mkdir -p /tmp/claude-creds - echo "$CLAUDE_CREDENTIALS" > /tmp/claude-creds/.credentials.json + echo "$CLAUDE_CREDENTIALS" >/tmp/claude-creds/.credentials.json chmod 700 /tmp/claude-creds chmod 600 /tmp/claude-creds/.credentials.json # Symlink from the account dir — Claude Code reads $CLAUDE_CONFIG_DIR/.credentials.json @@ -91,7 +91,7 @@ export TURBO_CACHE_DIR=/workspace/.turbo/cache # unsets NO_COLOR to prevent chalk from stripping ANSI codes. export FORCE_COLOR=3 export COLORTERM=truecolor -cat > "$HOME/.local/bin/bunx" << 'WRAPPER' +cat >"$HOME/.local/bin/bunx" <<'WRAPPER' #!/bin/bash export FORCE_COLOR=3 export COLORTERM=truecolor @@ -158,13 +158,13 @@ if [ -f "$CLAUDE_CONFIG_DIR/.claude.json" ] && command -v jq &>/dev/null && comm jq --arg n "$name" --arg b "$bin_path" ' .mcpServers[$n].command = $b | .mcpServers[$n].args = .mcpServers[$n].args[1:] - ' "$CLAUDE_CONFIG_DIR/.claude.json" > "$CLAUDE_CONFIG_DIR/.claude.json.tmp" \ - && mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" + ' "$CLAUDE_CONFIG_DIR/.claude.json" >"$CLAUDE_CONFIG_DIR/.claude.json.tmp" && + mv "$CLAUDE_CONFIG_DIR/.claude.json.tmp" "$CLAUDE_CONFIG_DIR/.claude.json" echo " $name -> $bin_path" else echo " $name: binary not found at $bin_path, keeping uvx" fi - done <<< "$uvx_servers" + done <<<"$uvx_servers" fi fi diff --git a/docker/init-firewall.sh b/docker/init-firewall.sh index fb2a09b..cbe2795 100755 --- a/docker/init-firewall.sh +++ b/docker/init-firewall.sh @@ -82,7 +82,7 @@ echo "=== Firewall active: $ip_count rules added ===" # Verification echo "=== Verifying firewall ===" -if curl -s --max-time 5 https://api.anthropic.com > /dev/null 2>&1; then +if curl -s --max-time 5 https://api.anthropic.com >/dev/null 2>&1; then echo " ok api.anthropic.com: reachable" else echo " FAIL api.anthropic.com: BLOCKED (this is a problem)" diff --git a/hooks/protect-claude-config.sh b/hooks/protect-claude-config.sh index eb8bbc7..bf29585 100755 --- a/hooks/protect-claude-config.sh +++ b/hooks/protect-claude-config.sh @@ -14,14 +14,14 @@ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') # Note: ~/.claude-host.json is the read-only staging mount and is intentionally # excluded — the regex requires a '/' after the optional - suffix, while # .claude-host.json has '.json' instead. -if [[ "$FILE_PATH" =~ \.claude(-[a-z0-9_-]+)?/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/) ]]; then +if [[ $FILE_PATH =~ \.claude(-[a-z0-9_-]+)?/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/) ]]; then echo "Blocked: cannot modify $FILE_PATH (protected Claude config file)" >&2 exit 2 fi # Block anything under ~/.ckipper (registry, hooks, settings-template, docker tooling). # Tampering with accounts.json could redirect another account's keychain_service. -if [[ "$FILE_PATH" =~ /\.ckipper/ ]]; then +if [[ $FILE_PATH =~ /\.ckipper/ ]]; then echo "Blocked: cannot modify $FILE_PATH (protected Ckipper file)" >&2 exit 2 fi diff --git a/install.sh b/install.sh index 2e67982..d0c6101 100755 --- a/install.sh +++ b/install.sh @@ -83,7 +83,7 @@ cp "$REPO_DIR/ckipper.zsh" "$CKIPPER_DIR/docker/" # 5. Generate w-config.zsh (only if it doesn't exist — never overwrite user customizations) # Also preserve accounts.json and aliases.zsh if they already exist (managed by ckipper CLI). config_file="$CKIPPER_DIR/docker/w-config.zsh" -if [[ ! -f "$config_file" ]]; then +if [[ ! -f $config_file ]]; then cp "$REPO_DIR/w-config.zsh.example" "$config_file" echo " Created w-config.zsh with defaults — edit to add your MCP mounts, ports, etc." else @@ -107,9 +107,9 @@ if grep -q '\.claude/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then sed -i.bak -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*\.claude/docker/w-function\.zsh["'\'']?[[:space:]]*$|source "$HOME/.ckipper/docker/w-function.zsh"|' "$HOME/.zshrc" echo " Updated ~/.zshrc source line to ~/.ckipper/. Backup at ~/.zshrc.bak." elif ! grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then - echo '' >> "$HOME/.zshrc" - echo '# Ckipper — Worktree Manager (w function)' >> "$HOME/.zshrc" - echo 'source "$HOME/.ckipper/docker/w-function.zsh"' >> "$HOME/.zshrc" + echo '' >>"$HOME/.zshrc" + echo '# Ckipper — Worktree Manager (w function)' >>"$HOME/.zshrc" + echo 'source "$HOME/.ckipper/docker/w-function.zsh"' >>"$HOME/.zshrc" echo " Added w() source line to ~/.zshrc" else echo " ~/.zshrc already sources ~/.ckipper/docker/w-function.zsh" @@ -141,7 +141,7 @@ if [ -z "$existing_hookspath" ] || [ "$existing_hookspath" = "$HOME/.git-hooks" else echo " Skipping core.hooksPath: existing value is '$existing_hookspath' (not overwriting)." echo " If you want Ckipper's hook isolation, set manually:" - echo " git config --global core.hooksPath \"\$HOME/.git-hooks\"" + echo ' git config --global core.hooksPath "$HOME/.git-hooks"' fi # 11. Print summary diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc2ea67 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "B", "D"] +ignore = [ + "D203", # incompatible with D211 (no blank line before class docstring) + "D213", # incompatible with D212 (multi-line docstring summary on first line) + # TODO(phase-4): remove after Task 4.18 adds docstrings to cleanup-projects.py. + "D103", + # D100 (missing docstring in public module): cleanup-projects.py is a top-level CLI tool + # invoked by ckipper, not an importable library. Module docstring optional. + "D100", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.pytest.ini_options] +testpaths = ["tests", "docker", "lib"] +python_files = ["*_test.py"] diff --git a/tests/lib/stubs/.gitkeep b/tests/lib/stubs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/test-helper.bash b/tests/lib/test-helper.bash new file mode 100644 index 0000000..3339b83 --- /dev/null +++ b/tests/lib/test-helper.bash @@ -0,0 +1,39 @@ +# Common test helpers loaded at the top of every *_test.bats file. +# Usage: load "${BATS_TEST_DIRNAME}/../tests/lib/test-helper.bash" + +# Repo root (resolved from any test file location). +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +export REPO_ROOT + +# Set up a clean per-test temp HOME and prepend stubs to PATH. +setup_isolated_env() { + TMP_HOME="$(mktemp -d -t ckipper-test-XXXXXX)" + export TMP_HOME + export HOME="$TMP_HOME" + export CKIPPER_DIR="$TMP_HOME/.ckipper" + export CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" + export PATH="$REPO_ROOT/tests/lib/stubs:$PATH" + mkdir -p "$CKIPPER_DIR" +} + +teardown_isolated_env() { + [[ -n "$TMP_HOME" && -d "$TMP_HOME" ]] && rm -rf "$TMP_HOME" +} + +# Source a Ckipper file with $REPO_ROOT as the lookup base. +source_ckipper_file() { + local rel_path="$1" + source "$REPO_ROOT/$rel_path" +} + +# Assert a file exists. +assert_file_exists() { + [[ -f "$1" ]] || { echo "Expected file: $1" >&2; return 1; } +} + +# Assert a file's mode (octal). +assert_file_mode() { + local path="$1" expected="$2" actual + actual="$(stat -f '%Lp' "$path" 2>/dev/null || stat -c '%a' "$path" 2>/dev/null)" + [[ "$actual" = "$expected" ]] || { echo "Expected mode $expected, got $actual on $path" >&2; return 1; } +} From f855a65e012fa225a588744fb01a709a91823960 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:08:01 -0600 Subject: [PATCH 041/165] Phase 1.5: characterization tests for ckipper subcommands and w() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28 bats tests across ckipper_test.bats (19) and w-function_test.bats (9) document current behavior of migrate, doctor, sync, add, list, remove, and w() as a regression net for Phases 2–4. --- ckipper_test.bats | 203 ++++++++++++++++++ .../legacy-claude-layout/.claude.json | 1 + .../.claude/docker/w-config.zsh | 1 + tests/lib/stubs/docker | 3 + tests/lib/stubs/lsof | 11 + tests/lib/stubs/pgrep | 9 + tests/lib/stubs/security | 16 ++ tests/lib/test-helper.bash | 49 ++++- w-function_test.bats | 113 ++++++++++ 9 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 ckipper_test.bats create mode 100644 tests/fixtures/legacy-claude-layout/.claude.json create mode 100644 tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh create mode 100755 tests/lib/stubs/docker create mode 100755 tests/lib/stubs/lsof create mode 100755 tests/lib/stubs/pgrep create mode 100755 tests/lib/stubs/security create mode 100644 w-function_test.bats diff --git a/ckipper_test.bats b/ckipper_test.bats new file mode 100644 index 0000000..fbb2dc0 --- /dev/null +++ b/ckipper_test.bats @@ -0,0 +1,203 @@ +#!/usr/bin/env bats +# Characterization tests for ckipper subcommands. +# +# Purpose: regression net for Phases 2-4 (modularization + refactoring). +# These tests document ACTUAL current behavior — assertions were adjusted to +# match observed output rather than idealized behavior. +# +# Important: ckipper.zsh is zsh-only (uses read "?..." prompt syntax, setopt, +# local-function nesting). Bats runs under bash, so every test spawns a zsh +# subprocess via run_ckipper() in test-helper.bash. + +load "${BATS_TEST_DIRNAME}/tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# ── _ckipper_migrate ──────────────────────────────────────────────── + +@test "ckipper migrate --help prints usage and exits 0" { + run_ckipper migrate --help + [ "$status" -eq 0 ] + [[ "$output" =~ "migrate" ]] +} + +@test "ckipper migrate reports nothing-to-migrate and exits 0 when no legacy state exists" { + # Note: with no ~/.claude state and no ~/.claude.json the code prints + # "Nothing to migrate" and returns 0 (not an error — it's a no-op). + run_ckipper migrate + [ "$status" -eq 0 ] + [[ "$output" =~ "Nothing to migrate" || "$output" =~ "nothing to migrate" ]] +} + +@test "ckipper migrate creates accounts.json when run with legacy layout" { + # Copy fixture legacy layout into isolated HOME. + cp -r "$REPO_ROOT/tests/fixtures/legacy-claude-layout/." "$TMP_HOME/" + # Feed stdin: account name "personal", then confirm "y". + # _CKIPPER_TEST_OSTYPE=linux skips macOS Keychain probing. + local stdin_file="$TMP_HOME/stdin.txt" + printf 'personal\ny\n' > "$stdin_file" + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; ckipper migrate" < "$stdin_file" + [ -f "$CKIPPER_REGISTRY" ] +} + +@test "ckipper migrate aborts when user declines the prompt" { + cp -r "$REPO_ROOT/tests/fixtures/legacy-claude-layout/." "$TMP_HOME/" + # Feed stdin via a temp file: account name "personal", then decline "n". + # Note: bats `run` cannot capture status when the command is on the + # right-hand side of a pipe; use a temp stdin file instead. + local stdin_file="$TMP_HOME/stdin.txt" + printf 'personal\nn\n' > "$stdin_file" + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; ckipper migrate" < "$stdin_file" + [ "$status" -ne 0 ] + [[ "$output" =~ "Aborted" ]] +} + +# ── _ckipper_doctor ───────────────────────────────────────────────── + +@test "ckipper doctor prints diagnostic output and mentions registry" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper doctor + # doctor always exits 0 or 1 depending on FAIL count; with an empty registry + # and no deployed tooling it returns some WARNs/FAILs but does not crash. + [[ "$output" =~ [Rr]egistry ]] +} + +@test "ckipper doctor prints INFO about missing registry when none exists" { + rm -f "$CKIPPER_REGISTRY" + run_ckipper doctor + # With no registry, doctor prints an INFO line and returns 0. + [ "$status" -eq 0 ] + [[ "$output" =~ [Rr]egistry ]] +} + +@test "ckipper doctor exits 0 when registry missing (no accounts registered)" { + rm -f "$CKIPPER_REGISTRY" + run_ckipper doctor + [ "$status" -eq 0 ] +} + +# ── _ckipper_sync ──────────────────────────────────────────────────── + +@test "ckipper sync --help prints usage and exits 0" { + run_ckipper sync --help + [ "$status" -eq 0 ] + [[ "$output" =~ "sync" ]] +} + +@test "ckipper sync errors when source account is not registered" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper sync nonexistent target + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} + +@test "ckipper sync errors when from and to are the same" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper sync same same + [ "$status" -ne 0 ] + [[ "$output" =~ "differ" ]] +} + +# ── _ckipper_add ──────────────────────────────────────────────────── + +@test "ckipper add rejects names containing spaces (invalid regex)" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper add "Invalid Name With Spaces" + [ "$status" -ne 0 ] + [[ "$output" =~ "must match" || "$output" =~ [Ii]nvalid ]] +} + +@test "ckipper add rejects uppercase names" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper add "MyAccount" + [ "$status" -ne 0 ] + [[ "$output" =~ "must match" || "$output" =~ [Ii]nvalid ]] +} + +@test "ckipper add with builtin name 'cd' fails after prompt due to missing claude binary" { + # Note: 'cd' passes the name-regex check (^[a-z0-9_-]+$). It does NOT get + # rejected at validation time. Instead, ckipper proceeds to launch 'claude' + # which is not available in the test env. Feeding "skip" at the + # "Press enter to launch" prompt causes a clean abort. + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + local stdin_file="$TMP_HOME/stdin.txt" + printf 'skip\n' > "$stdin_file" + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; ckipper add cd" < "$stdin_file" + # After "skip" input, ckipper aborts with exit 1. + [ "$status" -ne 0 ] + [[ "$output" =~ "Aborted" || "$output" =~ "abort" ]] +} + +@test "ckipper add with no name prints usage and exits 1" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper add + [ "$status" -ne 0 ] + [[ "$output" =~ [Uu]sage ]] +} + +# ── _ckipper_list ──────────────────────────────────────────────────── + +@test "ckipper list shows registered accounts" { + echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":"Claude Code-credentials-work"}}}' > "$CKIPPER_REGISTRY" + run_ckipper list + [ "$status" -eq 0 ] + [[ "$output" =~ "work" ]] +} + +@test "ckipper list prints a message when no accounts registered" { + rm -f "$CKIPPER_REGISTRY" + run_ckipper list + [ "$status" -eq 0 ] + [[ "$output" =~ "No accounts" || "$output" =~ "no accounts" ]] +} + +# ── _ckipper_remove ────────────────────────────────────────────────── + +@test "ckipper remove unregisters a known account and exits 0" { + echo '{"version":1,"default":null,"accounts":{"tmp":{"config_dir":"/tmp/.claude-tmp","keychain_service":"Claude Code-credentials-tmp"}}}' > "$CKIPPER_REGISTRY" + # Note: ckipper remove has no --yes flag; it removes without prompting. + run_ckipper remove tmp + [ "$status" -eq 0 ] + [[ "$output" =~ "Unregistered" ]] +} + +@test "ckipper remove errors on unknown account name" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper remove nobody + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} + +@test "ckipper remove with no name prints usage and exits 1" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + run_ckipper remove + [ "$status" -ne 0 ] + [[ "$output" =~ [Uu]sage ]] +} diff --git a/tests/fixtures/legacy-claude-layout/.claude.json b/tests/fixtures/legacy-claude-layout/.claude.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/legacy-claude-layout/.claude.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh b/tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh new file mode 100644 index 0000000..81d13f9 --- /dev/null +++ b/tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh @@ -0,0 +1 @@ +W_PORTS=(3000 3030) diff --git a/tests/lib/stubs/docker b/tests/lib/stubs/docker new file mode 100755 index 0000000..c4e6454 --- /dev/null +++ b/tests/lib/stubs/docker @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +echo "$@" >> "${DOCKER_STUB_LOG:-/dev/null}" +exit 0 diff --git a/tests/lib/stubs/lsof b/tests/lib/stubs/lsof new file mode 100755 index 0000000..c5190d6 --- /dev/null +++ b/tests/lib/stubs/lsof @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +for arg in "$@"; do + case "$arg" in + -i:*) port="${arg#-i:}" ;; + :*) port="${arg#:}" ;; + esac +done +for busy in $LSOF_STUB_BUSY; do + [[ "$port" = "$busy" ]] && exit 0 +done +exit 1 diff --git a/tests/lib/stubs/pgrep b/tests/lib/stubs/pgrep new file mode 100755 index 0000000..2276068 --- /dev/null +++ b/tests/lib/stubs/pgrep @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Test stub for `pgrep`. Returns no processes by default so ckipper's +# "no running Claude" guard always passes in tests. +# Set PGREP_STUB_MATCH=1 to simulate a running process. +if [[ "${PGREP_STUB_MATCH:-0}" == "1" ]]; then + echo "99999 claude" + exit 0 +fi +exit 1 diff --git a/tests/lib/stubs/security b/tests/lib/stubs/security new file mode 100755 index 0000000..f347479 --- /dev/null +++ b/tests/lib/stubs/security @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Test stub for `security`. Reads scripted responses from env vars. +case "$1 $2" in + "find-generic-password -s") + echo "${SECURITY_STUB_PASSWORD:-{\"oauth\":\"fake-token\"}}" + exit 0 + ;; + "dump-keychain") + echo "${SECURITY_STUB_DUMP:-empty}" + exit 0 + ;; + *) + echo "Stub received: $*" >&2 + exit 0 + ;; +esac diff --git a/tests/lib/test-helper.bash b/tests/lib/test-helper.bash index 3339b83..1ee1193 100644 --- a/tests/lib/test-helper.bash +++ b/tests/lib/test-helper.bash @@ -1,5 +1,7 @@ # Common test helpers loaded at the top of every *_test.bats file. -# Usage: load "${BATS_TEST_DIRNAME}/../tests/lib/test-helper.bash" +# Usage: load "${BATS_TEST_DIRNAME}/tests/lib/test-helper.bash" +# (for files colocated with their source — BATS_TEST_DIRNAME points to +# the same dir as the .bats file, so the relative path from there.) # Repo root (resolved from any test file location). REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -12,7 +14,13 @@ setup_isolated_env() { export HOME="$TMP_HOME" export CKIPPER_DIR="$TMP_HOME/.ckipper" export CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" + # Prepend stubs so `security`, `pgrep`, `docker`, `lsof` are intercepted. export PATH="$REPO_ROOT/tests/lib/stubs:$PATH" + # Override OSTYPE in ckipper: "linux" skips all macOS Keychain branches. + export _CKIPPER_TEST_OSTYPE="linux" + # Bypass the "Claude is running" guard so real Claude.app on the host + # does not abort test runs (pgrep stub also handles this, but belt+suspenders). + export CKIPPER_FORCE=1 mkdir -p "$CKIPPER_DIR" } @@ -20,7 +28,46 @@ teardown_isolated_env() { [[ -n "$TMP_HOME" && -d "$TMP_HOME" ]] && rm -rf "$TMP_HOME" } +# Run a ckipper subcommand in an isolated zsh subshell. +# Usage: run_ckipper [args...] +# +# ckipper.zsh is a zsh-only file (uses read "?..." prompt syntax, setopt, etc.) +# and CANNOT be sourced from bash. Tests must spawn a zsh subprocess for every +# ckipper invocation. This helper packages the required env-var forwarding and +# source incantation so individual @test blocks stay readable. +# +# After calling, $status / $output / $lines are set exactly as with bats `run`. +run_ckipper() { + local zsh_cmd="source \"$REPO_ROOT/ckipper.zsh\"; ckipper $*" + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-linux}" \ + CKIPPER_FORCE="${CKIPPER_FORCE:-1}" \ + zsh -c "$zsh_cmd" +} + +# Run the w() function in an isolated zsh subshell. +# Usage: run_w [args...] +run_w() { + local zsh_cmd="source \"$REPO_ROOT/w-function.zsh\"; w $*" + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-linux}" \ + CKIPPER_FORCE="${CKIPPER_FORCE:-1}" \ + zsh -c "$zsh_cmd" +} + # Source a Ckipper file with $REPO_ROOT as the lookup base. +# NOTE: Only usable when the test file itself runs under zsh (i.e., when +# running bats tests from zsh). Because bats runs under bash, zsh-only source +# files (ckipper.zsh, w-function.zsh) cannot be sourced this way. +# Use run_ckipper / run_w instead for those files. source_ckipper_file() { local rel_path="$1" source "$REPO_ROOT/$rel_path" diff --git a/w-function_test.bats b/w-function_test.bats new file mode 100644 index 0000000..9833037 --- /dev/null +++ b/w-function_test.bats @@ -0,0 +1,113 @@ +#!/usr/bin/env bats +# Characterization tests for the w() function in w-function.zsh. +# +# Purpose: regression net for Phases 2-4 (modularization + refactoring). +# These tests document ACTUAL current behavior. +# +# Important: w-function.zsh is zsh-only and sources ckipper.zsh at the +# bottom. Bats runs under bash, so every test spawns a zsh subprocess +# via `run env ... zsh -c "source w-function.zsh; w ..."`. + +load "${BATS_TEST_DIRNAME}/tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export DOCKER_STUB_LOG="$TMP_HOME/docker.log" + : > "$DOCKER_STUB_LOG" + mkdir -p "$CKIPPER_DIR/docker" + echo 'W_PORTS=(3000 3030)' > "$CKIPPER_DIR/docker/w-config.zsh" + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" +} + +teardown() { + teardown_isolated_env +} + +# ── w with no args / usage ─────────────────────────────────────────── + +@test "w with no args prints usage and exits 1" { + run_w + # Note: w() returns 1 when called with no args (falls into the empty + # project/worktree branch which prints usage and returns 1). + [ "$status" -eq 1 ] + [[ "$output" =~ [Uu]sage ]] +} + +@test "w --list prints worktree header and exits 0" { + run_w --list + [ "$status" -eq 0 ] + [[ "$output" =~ "Worktrees" || "$output" =~ "worktrees" || "$output" =~ "===" ]] +} + +@test "w --help falls through to usage output and exits 1" { + # Note: w has no --help flag. Passing --help is treated as a project name, + # falls into the missing project/worktree check, prints usage, and exits 1. + run_w --help + [ "$status" -eq 1 ] + [[ "$output" =~ [Uu]sage || "$output" =~ "Usage" ]] +} + +# ── w with missing project ─────────────────────────────────────────── + +@test "w errors when no account is registered and a project is specified" { + # With no default account set and no accounts in the registry, w() errors + # before it even gets to the project-existence check. + run_w nonexistent feature-x + [ "$status" -ne 0 ] + [[ "$output" =~ "account" || "$output" =~ "Account" ]] +} + +@test "w errors when project dir does not exist under Developer" { + # Register a default account so we get past the account check. + echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-test" + run_w nonexistent feature-x + [ "$status" -ne 0 ] + [[ "$output" =~ "not found" || "$output" =~ "Project" ]] +} + +# ── w with valid project args ──────────────────────────────────────── + +@test "w with valid project and account reaches worktree-creation logic" { + # Register a default account and create a real git repo. + echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-test" "$TMP_HOME/Developer/myapp" + (cd "$TMP_HOME/Developer/myapp" && git init -q && git commit --allow-empty -q -m "init") + # w() will fail at "git fetch origin develop" (no remote) but that's expected. + # The key characterization: it gets into the worktree-creation flow and + # prints "Creating worktree:" before failing on the fetch. + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/w-function.zsh\"; w myapp test-branch" + [[ "$output" =~ "Creating worktree" || "$output" =~ "fetch" || "$output" =~ "origin" ]] +} + +# ── w --rm ─────────────────────────────────────────────────────────── + +@test "w --rm with no args prints usage and exits 1" { + run_w --rm + [ "$status" -ne 0 ] + [[ "$output" =~ [Uu]sage || "$output" =~ "Usage" ]] +} + +@test "w --rm errors when worktree path does not exist" { + echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + run_w --rm myapp nonexistent-branch + [ "$status" -ne 0 ] + [[ "$output" =~ "not found" || "$output" =~ "Worktree" || "$output" =~ "worktree" ]] +} + +# ── w --firewall validation ────────────────────────────────────────── + +@test "w errors when --firewall is passed without --docker" { + echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-test" "$TMP_HOME/Developer/myapp" + run_w myapp some-branch --firewall + [ "$status" -ne 0 ] + [[ "$output" =~ "--firewall" || "$output" =~ "firewall" ]] +} From 1e7bed20f435d2ece8006e89241910342bbe7efd Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:18:42 -0600 Subject: [PATCH 042/165] Phase 2: modularize ckipper.zsh into lib/core/ + lib/ckipper/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 1,331-line monolith into a lib/ tree: lib/core/utils.zsh, registry.zsh, keychain.zsh — shared primitives lib/ckipper/account-management.zsh, aliases.zsh, plugin-repair.zsh, sync.zsh, doctor.zsh, migrate.zsh — ckipper-specific subcommands Renamed _ckipper_* → _core_* for all registry, keychain, and stat helpers; updated every call site. ckipper.zsh is now a thin dispatcher that sources modules. All 28 bats tests continue to pass. --- ckipper.zsh | 1222 +--------------------------- lib/ckipper/account-management.zsh | 316 +++++++ lib/ckipper/aliases.zsh | 106 +++ lib/ckipper/doctor.zsh | 137 ++++ lib/ckipper/migrate.zsh | 244 ++++++ lib/ckipper/plugin-repair.zsh | 74 ++ lib/ckipper/sync.zsh | 144 ++++ lib/core/keychain.zsh | 70 ++ lib/core/registry.zsh | 121 +++ lib/core/utils.zsh | 20 + 10 files changed, 1249 insertions(+), 1205 deletions(-) create mode 100644 lib/ckipper/account-management.zsh create mode 100644 lib/ckipper/aliases.zsh create mode 100644 lib/ckipper/doctor.zsh create mode 100644 lib/ckipper/migrate.zsh create mode 100644 lib/ckipper/plugin-repair.zsh create mode 100644 lib/ckipper/sync.zsh create mode 100644 lib/core/keychain.zsh create mode 100644 lib/core/registry.zsh create mode 100644 lib/core/utils.zsh diff --git a/ckipper.zsh b/ckipper.zsh index 57d4fa6..d431793 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -1,3 +1,8 @@ +#!/usr/bin/env zsh +# Ckipper main dispatcher. +# Sources shared primitives from lib/core/ and ckipper-specific subcommands from lib/ckipper/. +# Public functions exposed: ckipper, ck. + # Ckipper (pronounced "skipper") — multi-account Claude Code manager # Sourced by w-function.zsh @@ -5,6 +10,18 @@ CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" CKIPPER_REGISTRY_VERSION=1 +CKIPPER_REPO_DIR="${0:A:h}" + +source "$CKIPPER_REPO_DIR/lib/core/utils.zsh" +source "$CKIPPER_REPO_DIR/lib/core/registry.zsh" +source "$CKIPPER_REPO_DIR/lib/core/keychain.zsh" +source "$CKIPPER_REPO_DIR/lib/ckipper/account-management.zsh" +source "$CKIPPER_REPO_DIR/lib/ckipper/aliases.zsh" +source "$CKIPPER_REPO_DIR/lib/ckipper/plugin-repair.zsh" +source "$CKIPPER_REPO_DIR/lib/ckipper/sync.zsh" +source "$CKIPPER_REPO_DIR/lib/ckipper/doctor.zsh" +source "$CKIPPER_REPO_DIR/lib/ckipper/migrate.zsh" + ckipper() { local cmd="$1" shift 2>/dev/null @@ -122,1210 +139,5 @@ EOF esac } -# Validates a keychain_service name before passing to `security`. -# Accepts "Claude Code-credentials" optionally followed by "-". -_ckipper_validate_keychain_service() { - local svc="$1" - [[ -z "$svc" ]] && return 1 - [[ "$svc" =~ ^Claude\ Code-credentials(-[a-f0-9]+)?$ ]] -} - -_ckipper_keychain_snapshot() { - # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. - [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 - - # Pick a timeout binary if available (macOS doesn't ship one; gtimeout from - # coreutils is the typical brew install). Fall through to no timeout if neither - # is present — better than failing with a misleading "keychain locked" error. - local timeout_cmd="" - if command -v timeout >/dev/null 2>&1; then - timeout_cmd="timeout 10" - elif command -v gtimeout >/dev/null 2>&1; then - timeout_cmd="gtimeout 10" - fi - - local out - if [[ -n "$timeout_cmd" ]]; then - if ! out=$($timeout_cmd security dump-keychain 2>/dev/null); then - echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 - return 1 - fi - else - # No timeout available — run without. If keychain is locked the GUI - # password prompt will block this, which is a fine failure mode. - if ! out=$(security dump-keychain 2>/dev/null); then - echo "Warning: 'security dump-keychain' failed. Keychain may be locked." >&2 - return 1 - fi - fi - - printf '%s\n' "$out" | \ - awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ - sort -u -} - -# Cross-platform stat for permissions: BSD (macOS) uses -f, GNU/Linux uses -c. -_ckipper_stat_perms() { - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - stat -f '%Lp' "$1" 2>/dev/null - else - stat -c '%a' "$1" 2>/dev/null - fi -} - -# Rewrite stale absolute paths embedded in Claude Code's plugin metadata files -# (known_marketplaces.json, installed_plugins.json). After moving an account -# directory (legacy ~/.claude → ~/.claude-), the plugin cache files on -# disk have moved with the rename, but the JSON metadata still has the old -# absolute paths baked in — Claude Code then fails to resolve plugins with -# "Plugin not found in marketplace ..." errors. -# -# $1 = old prefix (must end with `/`), e.g. "$HOME/.claude/" -# $2 = new prefix (must end with `/`), e.g. "$HOME/.claude-personal/" -# Idempotent: if neither file contains the old prefix, this is a no-op. -_ckipper_rewrite_plugin_paths() { - local old="$1" new="$2" - [[ -z "$old" || -z "$new" || "$old" != */ || "$new" != */ ]] && return 1 - [[ "$old" == "$new" ]] && return 0 - local f rewrote=0 - for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do - local fp="$new$f" - [[ -f "$fp" ]] || continue - grep -q -- "$old" "$fp" 2>/dev/null || continue - cp "$fp" "$fp.pre-rewrite-backup-$(date +%s)" - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - sed -i '' "s|$old|$new|g" "$fp" - else - sed -i "s|$old|$new|g" "$fp" - fi - rewrote=1 - done - return 0 -} - -# Cross-platform stat for mtime in seconds since epoch. -_ckipper_stat_mtime() { - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - stat -f '%m' "$1" 2>/dev/null - else - stat -c '%Y' "$1" 2>/dev/null - fi -} - -# Detect running Claude processes that would conflict with destructive operations. -# Matches: 'claude' CLI (basename), 'Claude' (Claude.app main process). Avoids matching -# vim files named 'claude-*', tmux sessions, or Claude Helper subprocesses (the parent -# Claude.app being killed will cascade to those). -_ckipper_running_claude_processes() { - pgrep -lx claude 2>/dev/null - pgrep -lx Claude 2>/dev/null -} - -# Refuse with a clear message if any Claude process is running. -_ckipper_assert_no_running_claude() { - local found - found=$(_ckipper_running_claude_processes) - if [[ -n "$found" ]]; then - echo "Error: Claude process(es) detected. Quit them first:" >&2 - echo "$found" | sed 's/^/ /' >&2 - echo "(Set CKIPPER_FORCE=1 to bypass this check, but expect inconsistent state.)" >&2 - if [[ "$CKIPPER_FORCE" == "1" ]]; then - echo "CKIPPER_FORCE=1 set — proceeding despite running Claude." >&2 - return 0 - fi - return 1 - fi - return 0 -} - -# Atomic registry write under flock (or mkdir-fallback). $1 = jq filter. -# Returns 0 on successful jq+write, 1 on jq error or write failure. -# A jq error() call inside the filter (used for atomic-collision-checks like -# _ckipper_finalize_registration) propagates as a non-zero exit here. -_ckipper_registry_update() { - local jq_filter="$1"; shift - local lock="$CKIPPER_DIR/.registry.lock" - mkdir -p "$CKIPPER_DIR" - : > "$lock" - local rc=1 - if command -v flock >/dev/null 2>&1; then - { - flock -x 9 - local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") - if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then - mv "$tmp" "$CKIPPER_REGISTRY" - chmod 600 "$CKIPPER_REGISTRY" - rc=0 - else - rm -f "$tmp" - fi - } 9>"$lock" - else - # Fallback for systems without flock (the default on macOS): mkdir-based lock, - # with stale-lock recovery so a SIGKILL'd ckipper doesn't permanently brick - # subsequent invocations. - setopt local_options local_traps - local lockdir="$CKIPPER_DIR/.registry.lock.d" - local attempts=0 - local notified=0 - while ! mkdir "$lockdir" 2>/dev/null; do - (( attempts++ )) - # Reassure the user something is happening — silent multi-second - # pauses look like a freeze. Print once at ~1.5s in, then again - # only if we recover a stale lock below. - if (( attempts == 30 && notified == 0 )); then - echo "Waiting on registry lock..." >&2 - notified=1 - fi - if (( attempts >= 200 )); then # 10s - local lockdir_age now - now=$(date +%s) - local mtime; mtime=$(_ckipper_stat_mtime "$lockdir") - lockdir_age=$(( now - ${mtime:-$now} )) - if (( lockdir_age > 30 )); then - echo "Cleaning up old lock from a previous session (age ${lockdir_age}s)..." >&2 - rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" - attempts=0 - continue - fi - echo "Registry lock held by another process for ${lockdir_age}s. Try again shortly." >&2 - return 1 - fi - sleep 0.05 - done - trap 'rmdir "$lockdir" 2>/dev/null' EXIT - local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") - if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then - mv "$tmp" "$CKIPPER_REGISTRY" - chmod 600 "$CKIPPER_REGISTRY" - rc=0 - else - rm -f "$tmp" - fi - fi - return $rc -} - -# Initialize an empty registry with version field. Idempotent under concurrency -# via atomic create (mv -n) — two concurrent ckipper init's won't clobber each other. -_ckipper_init_registry() { - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - mkdir -p "$CKIPPER_DIR" - local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.init.XXXXXX") - cat > "$tmp" </dev/null || rm -f "$tmp" - [[ -f "$CKIPPER_REGISTRY" ]] && chmod 600 "$CKIPPER_REGISTRY" - fi -} - -# Refuse to operate on a registry whose version we don't understand OR whose schema -# is corrupt (e.g. user manually edited and turned .accounts into an array). -_ckipper_check_registry_version() { - [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 - local v - v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) - if (( v != CKIPPER_REGISTRY_VERSION )); then - echo "Error: registry version $v not supported (this ckipper expects $CKIPPER_REGISTRY_VERSION). Update ckipper or restore from backup." >&2 - return 1 - fi - if ! jq -e '.accounts | type == "object"' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Error: $CKIPPER_REGISTRY is corrupt (.accounts is not an object)." >&2 - echo "Backup and re-init manually:" >&2 - echo " mv $CKIPPER_REGISTRY $CKIPPER_REGISTRY.corrupt-\$(date +%s)" >&2 - return 1 - fi -} - -_ckipper_add() { - _ckipper_check_registry_version || return 1 - local name="$1" adopt=0 - [[ "$2" == "--adopt" ]] && adopt=1 - if [[ -z "$name" ]]; then - echo "Usage: ckipper add [--adopt]" - return 1 - fi - if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then - echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." - return 1 - fi - - _ckipper_init_registry - - if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then - echo "Account '$name' is already registered." - return 1 - fi - - local dir="$HOME/.claude-$name" - - if [[ $adopt -eq 1 ]]; then - if [[ ! -d "$dir" ]]; then - echo "Cannot adopt: $dir does not exist." - return 1 - fi - # In adopt mode, list candidate Keychain entries and let the user pick (or skip). - local picked="" - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - local candidates - candidates=$(_ckipper_keychain_snapshot) || return 1 - if [[ -n "$candidates" ]]; then - echo "Candidate Keychain entries:" - echo "$candidates" | nl - read -r "?Pick a number (or empty to skip): " idx - if [[ -n "$idx" ]]; then - picked=$(echo "$candidates" | sed -n "${idx}p") - if [[ -n "$picked" ]] && ! _ckipper_validate_keychain_service "$picked"; then - echo "Invalid Keychain service shape: $picked" - return 1 - fi - fi - fi - fi - _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" - return $? - fi - - # Fresh registration: ckipper LAUNCHES claude itself (in-place, same TTY) so - # there's no shell-deadlock UX where the user has to Ctrl-Z, run a command, - # then `fg`. User just /login's and exits Claude (Ctrl-D); ckipper resumes - # and finalizes registration. - if [[ -d "$dir" ]]; then - echo "Directory $dir already exists. Use --adopt to register it." - return 1 - fi - mkdir -p "$dir/hooks" - if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then - cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" - fi - # Deploy hook scripts and rewrite settings.json paths to the per-account - # dir BEFORE launching claude. The template ships with $HOME/.claude/hooks - # paths; without this rewrite, the /login session inside claude fires hook - # errors ("No such file or directory") for paths that don't exist yet. - _ckipper_sync_hooks_for "$name" "$dir" - - local before_snapshot - before_snapshot=$(_ckipper_keychain_snapshot) || return 1 - - cat </dev/null 2>&1; then - echo "Error: account '$name' already exists in registry (race detected)." >&2 - elif jq -e --arg d "$dir" '[.accounts[].config_dir] | any(. == $d)' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Error: config dir '$dir' is already claimed by another registered account." >&2 - else - echo "Error: failed to write account '$name' to registry $CKIPPER_REGISTRY" >&2 - fi - return 1 - fi - - _ckipper_regenerate_aliases - _ckipper_sync_hooks_for "$name" - - echo "Registered '$name' (mode: $mode)." - if _ckipper_bare_alias_safe "$name"; then - echo "Use it via: claude-$name (or just: $name)" - else - echo "Use it via: claude-$name" - fi -} - -# Returns 0 if $1 is safe to use as a bare-alias function name (no clash with -# any existing PATH command, shell builtin, alias, or reserved word). Existing -# shell *functions* are not a clash — we expect to redefine those. -_ckipper_bare_alias_safe() { - local n="$1" - (( ${+commands[$n]} || ${+builtins[$n]} || ${+aliases[$n]} )) && return 1 - local what; what=$(whence -w "$n" 2>/dev/null | awk '{print $2}') - [[ "$what" == "reserved" ]] && return 1 - return 0 -} - -_ckipper_regenerate_aliases() { - local out="$CKIPPER_DIR/aliases.zsh" - local _name _dir - { - echo "# Auto-generated by ckipper. Do not edit by hand." - echo "# Self-contained: does not depend on ckipper.zsh or w-function.zsh being sourced." - echo "# Regenerated whenever an account is added or removed." - echo "" - echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" - echo "" - # Guard: bare 'claude' would default to ~/.claude/ and write to the unsuffixed - # 'Claude Code-credentials' Keychain entry — which is the SAME entry the - # default account uses. A fresh /login here silently overwrites those creds. - # Block bare 'claude' when accounts are registered; users bypass via 'command claude'. - echo "claude() {" - echo " if [[ -f \"\$_CKIPPER_REGISTRY\" ]] && jq -e '.accounts | length > 0' \"\$_CKIPPER_REGISTRY\" >/dev/null 2>&1; then" - echo " local default" - echo " default=\$(jq -r '.default // \"\"' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" - echo " echo \"Refusing to launch bare 'claude' — Ckipper has registered accounts.\" >&2" - echo " echo \"\" >&2" - echo " echo \"Bare 'claude' uses ~/.claude/ and writes to the Keychain entry your\" >&2" - echo " echo \"default account ('\${default:-personal}') is registered against. A fresh\" >&2" - echo " echo \"/login here would silently overwrite those credentials.\" >&2" - echo " echo \"\" >&2" - echo " if [[ -n \"\$default\" ]]; then" - echo " echo \"Use: claude-\$default\" >&2" - echo " else" - echo " echo \"Set a default first: ckipper default , then use claude-.\" >&2" - echo " fi" - echo " echo \"\" >&2" - echo " echo \"To bypass (fresh login on purpose): command claude \\\$@\" >&2" - echo " return 1" - echo " fi" - echo " command claude \"\$@\"" - echo "}" - echo "" - if [[ -f "$CKIPPER_REGISTRY" ]]; then - jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ - while IFS=$'\t' read -r _name _dir; do - echo "claude-$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" - # Bare-name shortcut: also generate `` so users can - # type the account name directly. Skip if it would shadow - # a real binary, builtin, alias, or reserved word. - if _ckipper_bare_alias_safe "$_name"; then - echo "$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" - else - echo "# Bare-name alias '$_name' skipped (would shadow existing command)." - fi - done - fi - } > "$out.tmp" - # Atomic install — readers in other shells never see a partial file. - mv "$out.tmp" "$out" - chmod 644 "$out" - - # Re-source in the calling shell so newly-registered accounts are usable - # immediately without the user having to `exec zsh`. Function definitions - # from a sourced file are global by default in zsh, so this works even - # though we're sourcing inside a function. - source "$out" -} - -_ckipper_sync_hooks_for() { - local name="$1" dir="${2:-}" - # Allow callers (notably _ckipper_add, which deploys hooks BEFORE the - # account exists in the registry) to pass the dir directly. Without an - # explicit dir, look it up in the registry. - if [[ -z "$dir" ]]; then - _ckipper_check_registry_version || return 1 - dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - [[ -z "$dir" || "$dir" == "null" ]] && return 1 - fi - mkdir -p "$dir/hooks" - cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true - - # Rewrite settings.json hook paths to absolute paths under this account dir. - # Consumes the entire prefix (`$HOME/.claude/`, `$HOME/.claude-/`, or `$HOME/.ckipper/`) - # plus `hooks/` so we don't end up with `$HOME/hooks/...` after substitution. - if [[ -f "$dir/settings.json" ]] && command -v jq &>/dev/null; then - local tmp; tmp=$(mktemp "$dir/.settings.tmp.XXXXXX") - jq --arg d "$dir" ' - (.hooks // {}) as $h | - .hooks = ($h | walk( - if type == "string" and test("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/") - then sub("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/"; "\($d)/hooks/") - else . end - )) - ' "$dir/settings.json" > "$tmp" && mv "$tmp" "$dir/settings.json" - fi -} - -_ckipper_sync_hooks() { - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - echo "No accounts registered." - return 0 - fi - _ckipper_check_registry_version || return 1 - local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") - while IFS= read -r name; do - echo "Syncing hooks → $name" - _ckipper_sync_hooks_for "$name" - done <<< "$names" -} - -_ckipper_repair_plugins() { - local name="$1" - if [[ -z "$name" ]]; then - echo "Usage: ckipper repair-plugins " - return 1 - fi - _ckipper_check_registry_version || return 1 - local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") - if [[ -z "$dir" ]]; then - echo "Account '$name' is not registered. Run: ckipper list" - return 1 - fi - if [[ ! -d "$dir" ]]; then - echo "Account dir does not exist: $dir" - return 1 - fi - - # Detect what stale prefix the metadata is using. Almost always the legacy - # ~/.claude/, but a previously-renamed account could carry an older suffix. - local stale_prefix="" - local f - for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do - [[ -f "$dir/$f" ]] || continue - # Combined declare+assign on one line: zsh 5.9 emits "var=''" to stdout - # when `local var` and `var=$(...)` are split across two lines inside a - # `for` loop body. Trivia that mostly bites diagnostic helpers like this. - local hit=$(grep -oE "$HOME/\.claude(-[a-z0-9_-]+)?/" "$dir/$f" 2>/dev/null | sort -u | grep -v "^$dir/$" | head -1) - if [[ -n "$hit" ]]; then - stale_prefix="$hit" - break - fi - done - if [[ -z "$stale_prefix" ]]; then - echo "No stale paths found in $dir/plugins/. Nothing to repair." - return 0 - fi - echo "Rewriting plugin metadata for '$name':" - echo " $stale_prefix → $dir/" - _ckipper_rewrite_plugin_paths "$stale_prefix" "$dir/" - echo "Done. Backups saved alongside each rewritten file (.pre-rewrite-backup-)." -} - -_ckipper_list() { - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - echo "No accounts registered. Run: ckipper add " - return 0 - fi - local default - default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - echo "Registered accounts:" - jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ - while IFS=$'\t' read -r name dir; do - local marker=" " - [[ "$name" == "$default" ]] && marker="* " - local email="" - if [[ -f "$dir/.claude.json" ]]; then - email=$(jq -r '.oauthAccount.emailAddress // ""' "$dir/.claude.json" 2>/dev/null) - fi - local exists="(missing)" - [[ -d "$dir" ]] && exists="" - echo "$marker$name $dir ${email:+($email)} $exists" - done - echo "" - echo "* = default. Run: ckipper default " - echo "" - echo "Tip: don't run the same account in two terminals at once — Claude's OAuth refresh" - echo "is single-use, so the second session gets logged out. Use a different account instead." -} -_ckipper_default() { - _ckipper_check_registry_version || return 1 - local name="$1" - [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } - if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then - echo "Account '$name' is not registered." - return 1 - fi - _ckipper_registry_update '.default = $n' --arg n "$name" - echo "Default account is now '$name'." -} - -_ckipper_remove() { - _ckipper_check_registry_version || return 1 - local name="$1" - [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } - if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then - echo "Account '$name' is not registered." - return 1 - fi - local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") - _ckipper_registry_update 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' --arg n "$name" - # Drop the now-stale launcher functions from the calling shell (regenerate - # only redefines what's still in the registry; it can't unset removed entries). - unset -f "claude-$name" 2>/dev/null - unset -f "$name" 2>/dev/null - _ckipper_regenerate_aliases - echo "Unregistered '$name'." - echo "" - echo "The directory and Keychain entry were not deleted. To remove them manually:" - printf " rm -rf %q\n" "$dir" - if [[ -n "$service" ]]; then - printf " security delete-generic-password -s %q\n" "$service" - fi -} - -_ckipper_rename() { - _ckipper_check_registry_version || return 1 - local old="$1" new="$2" - if [[ -z "$old" || -z "$new" ]]; then - echo "Usage: ckipper rename " - return 1 - fi - if [[ ! "$new" =~ ^[a-z0-9_-]+$ ]]; then - echo "New name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." - return 1 - fi - if [[ "$old" == "$new" ]]; then - echo "Old and new name are the same. Nothing to do." - return 1 - fi - if ! jq -e --arg n "$old" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Account '$old' is not registered." - return 1 - fi - if jq -e --arg n "$new" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Account '$new' is already registered." - return 1 - fi - - local old_dir new_dir - old_dir=$(jq -r --arg n "$old" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - new_dir="$HOME/.claude-$new" - if [[ -e "$new_dir" ]]; then - echo "Error: $new_dir already exists. Pick a different name or remove it first." - return 1 - fi - if [[ ! -d "$old_dir" ]]; then - echo "Error: source directory $old_dir does not exist." - return 1 - fi - - # Refuse if any Claude session is running — they'd be writing to old_dir. - _ckipper_assert_no_running_claude || return 1 - - if ! mv "$old_dir" "$new_dir" 2>/dev/null; then - echo "Error: failed to rename $old_dir → $new_dir." >&2 - return 1 - fi - - if ! _ckipper_registry_update ' - .accounts[$new] = .accounts[$old] | - .accounts[$new].config_dir = $newdir | - del(.accounts[$old]) | - (if .default == $old then .default = $new else . end) - ' --arg old "$old" --arg new "$new" --arg newdir "$new_dir"; then - # Rollback: move dir back. - mv "$new_dir" "$old_dir" 2>/dev/null - echo "Error: registry write failed; reverted directory rename." >&2 - return 1 - fi - - # Drop old-name launcher functions from the calling shell. - unset -f "claude-$old" 2>/dev/null - unset -f "$old" 2>/dev/null - _ckipper_regenerate_aliases - _ckipper_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to the new dir - - echo "Renamed '$old' → '$new'." - echo "Directory: $old_dir → $new_dir" - if _ckipper_bare_alias_safe "$new"; then - echo "Use: claude-$new (or just: $new)" - else - echo "Use: claude-$new" - fi -} - -# Validates that an account exists in the registry. Echoes its config_dir on success. -_ckipper_account_dir() { - local name="$1" - if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Account '$name' is not registered." >&2 - return 1 - fi - jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY" -} - -_ckipper_sync() { - _ckipper_check_registry_version || return 1 - local from="$1" to="$2" - shift 2 2>/dev/null - if [[ -z "$from" || -z "$to" ]]; then - echo "Usage: ckipper sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" - return 1 - fi - if [[ "$from" == "$to" ]]; then - echo " and must differ." - return 1 - fi - - local from_dir to_dir - from_dir=$(_ckipper_account_dir "$from") || return 1 - to_dir=$(_ckipper_account_dir "$to") || return 1 - if [[ ! -f "$from_dir/.claude.json" ]]; then - echo "Source has no .claude.json: $from_dir"; return 1 - fi - if [[ ! -f "$to_dir/.claude.json" ]]; then - echo "Destination has no .claude.json: $to_dir"; return 1 - fi - - # Parse flags. The argparse here is intentionally minimal — order matters, - # but each flag is well-formed and easy to read. - local mode_mcp=0 mcp_names="" mode_settings=0 settings_keys="" dry_run=0 mode_all=0 - while [[ $# -gt 0 ]]; do - case "$1" in - --mcp) - mode_mcp=1 - if [[ -n "$2" && "$2" != --* ]]; then mcp_names="$2"; shift; fi - shift ;; - --settings) - mode_settings=1 - if [[ -n "$2" && "$2" != --* ]]; then settings_keys="$2"; shift; fi - shift ;; - --all) mode_all=1; shift ;; - --dry-run) dry_run=1; shift ;; - *) echo "Unknown flag: $1"; return 1 ;; - esac - done - - # Default bundle when no specific flags were passed: mcpServers + a useful - # selection of settings.json keys. - if (( mode_mcp == 0 && mode_settings == 0 )); then - mode_all=1 - fi - if (( mode_all )); then - mode_mcp=1 - mode_settings=1 - [[ -z "$settings_keys" ]] && \ - settings_keys="enabledPlugins,extraKnownMarketplaces,statusLine,env,model" - fi - - # If a Claude session is running, sync's writes can race with its writes - # to the same .claude.json. Warn (but don't refuse) unless --dry-run. - if (( ! dry_run )); then - local running_procs - running_procs=$(_ckipper_running_claude_processes) - if [[ -n "$running_procs" ]]; then - echo "Warning: Claude is currently running. If a session uses '$to' or '$from'," >&2 - echo "sync may race with its writes (Claude doesn't lock these files)." >&2 - echo "$running_procs" | sed 's/^/ /' >&2 - read -r "?Continue anyway? [y/N] " ans - [[ "$ans" != "y" && "$ans" != "Y" ]] && { echo "Aborted."; return 1; } - fi - fi - - local pending_msgs=() - - # ── MCP sync ───────────────────────────────────────────────── - if (( mode_mcp )); then - local mcp_filter - if [[ -z "$mcp_names" ]]; then - mcp_filter='.mcpServers // {}' - else - # Build a jq object containing only the named servers, e.g. {Vibma: ..., github: ...} - local jq_array - jq_array=$(echo "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | '"$jq_array"' | index($k)))' - fi - local servers - servers=$(jq "$mcp_filter" "$from_dir/.claude.json") - local server_keys - server_keys=$(echo "$servers" | jq -r 'keys[]?' | tr '\n' ' ') - if [[ -z "$server_keys" || "$server_keys" == " " ]]; then - pending_msgs+=("MCP: nothing to sync (no matching servers in $from)") - else - pending_msgs+=("MCP servers → $to: $server_keys") - if (( ! dry_run )); then - local tmp; tmp=$(mktemp "$to_dir/.claude.json.tmp.XXXXXX") - jq --argjson new "$servers" '.mcpServers = (.mcpServers // {}) + $new' \ - "$to_dir/.claude.json" > "$tmp" && mv "$tmp" "$to_dir/.claude.json" - fi - fi - fi - - # ── settings.json key sync ─────────────────────────────────── - if (( mode_settings )) && [[ -n "$settings_keys" ]]; then - if [[ ! -f "$from_dir/settings.json" ]]; then - pending_msgs+=("Settings: $from has no settings.json (skipping)") - else - # Build a jq subset object with only the requested keys (skipping missing ones). - local jq_keys - jq_keys=$(echo "$settings_keys" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - local subset - subset=$(jq --argjson keys "$jq_keys" \ - 'with_entries(select(.key as $k | $keys | index($k)))' \ - "$from_dir/settings.json") - local copied_keys - copied_keys=$(echo "$subset" | jq -r 'keys[]?' | tr '\n' ' ') - if [[ -z "$copied_keys" || "$copied_keys" == " " ]]; then - pending_msgs+=("Settings: no matching keys in $from/settings.json") - else - pending_msgs+=("Settings keys → $to: $copied_keys") - if (( ! dry_run )); then - if [[ ! -f "$to_dir/settings.json" ]]; then - echo '{}' > "$to_dir/settings.json" - fi - local tmp; tmp=$(mktemp "$to_dir/settings.json.tmp.XXXXXX") - jq --argjson new "$subset" '. + $new' \ - "$to_dir/settings.json" > "$tmp" && mv "$tmp" "$to_dir/settings.json" - fi - fi - fi - fi - - if (( dry_run )); then - echo "Dry run — would apply:" - else - echo "Synced:" - fi - for m in "${pending_msgs[@]}"; do - echo " - $m" - done - - if (( ! dry_run )); then - echo "" - echo "Restart any running '$to' Claude session for changes to take effect." - fi -} - -_ckipper_doctor() { - local fail=0 warn=0 - local check() { - local sym="$1" msg="$2" - case "$sym" in - PASS) printf " \033[32m[PASS]\033[0m %s\n" "$msg" ;; - WARN) printf " \033[33m[WARN]\033[0m %s\n" "$msg"; (( warn++ )) ;; - FAIL) printf " \033[31m[FAIL]\033[0m %s\n" "$msg"; (( fail++ )) ;; - INFO) printf " [INFO] %s\n" "$msg" ;; - esac - } - # Locally-scoped function for color output. zsh function nesting works at runtime. - - echo "── Tooling ───────────────────────────────────────────" - if [[ -d "$CKIPPER_DIR" ]]; then check PASS "$CKIPPER_DIR exists"; else check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi - if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then check PASS "w-function.zsh deployed"; else check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi - if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then check PASS "ckipper.zsh deployed"; else check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi - if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then check PASS "cleanup-projects.py deployed"; else check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi - if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then check PASS "settings-template.json deployed"; else check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi - if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= 4 )); then - check PASS "hooks/ has 4+ files" - else - check WARN "hooks/ is missing or has fewer than 4 hook files" - fi - - echo "" - echo "── Registry ──────────────────────────────────────────" - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" - return 0 - fi - local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) - if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then check PASS "registry version $v matches expected" - else check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi - local perms; perms=$(_ckipper_stat_perms "$CKIPPER_REGISTRY") - if [[ "$perms" == "600" ]]; then check PASS "registry permissions 600" - else check WARN "registry permissions $perms (expected 600)"; fi - - local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - if [[ -z "$default_acc" ]]; then - check WARN "no default account set — w/ckipper-add will require --account" - elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - check INFO "default account: $default_acc" - else - check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " - fi - - echo "" - echo "── Per-account state ────────────────────────────────" - local names; names=$(jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY") - if [[ -z "$names" ]]; then - check WARN "registry has no accounts" - else - while IFS= read -r name; do - echo "" - echo " Account: $name" - # Combined declare+assign: zsh 5.9 leaks "var=''" to stdout when the - # `local x; x=$(...)` form runs inside a `while` loop body. - local dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - local svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") - if [[ -d "$dir" ]]; then check PASS " dir exists: $dir" - else check FAIL " dir missing: $dir"; fi - if [[ -f "$dir/.claude.json" ]]; then - local email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) - local proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) - local mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) - check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" - else - check WARN " .claude.json missing in $dir" - fi - if [[ -f "$dir/settings.json" ]]; then check PASS " settings.json present"; else check WARN " settings.json missing"; fi - if [[ -d "$dir/hooks" ]]; then check PASS " hooks/ deployed"; else check WARN " hooks/ missing — run: ckipper sync-hooks"; fi - # Stale plugin-metadata paths: a sign that ckipper migrate moved - # the account dir without rewriting absolute paths inside - # plugins/{known_marketplaces,installed_plugins}.json. Symptom is - # "Plugin not found in marketplace ..." in the Claude Code UI. - local stale_pm=0 - for pm in known_marketplaces.json installed_plugins.json; do - [[ -f "$dir/plugins/$pm" ]] || continue - if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then - stale_pm=1 - fi - done - if (( stale_pm )); then - check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" - fi - # Keychain check (macOS only) - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - if [[ -z "$svc" ]]; then - check INFO " keychain_service: null (account uses on-disk credentials)" - elif ! _ckipper_validate_keychain_service "$svc"; then - check FAIL " keychain_service has invalid shape: $svc" - elif security find-generic-password -s "$svc" >/dev/null 2>&1; then - check PASS " keychain entry present: $svc" - else - check WARN " keychain entry NOT FOUND: $svc — re-run /login with: claude-$name" - fi - fi - done <<< "$names" - fi - - echo "" - echo "── Aliases & shell integration ──────────────────────" - if [[ -f "$CKIPPER_DIR/aliases.zsh" ]]; then check PASS "aliases.zsh exists at $CKIPPER_DIR/aliases.zsh" - else check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi - if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources aliases.zsh" - else check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi - if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources w-function.zsh" - else check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi - - echo "" - echo "── Stub files (cosmetic) ────────────────────────────" - if [[ -d "$HOME/.claude" ]]; then - local stub_count; stub_count=$(ls -1A "$HOME/.claude" 2>/dev/null | wc -l | tr -d ' ') - check WARN "~/.claude exists ($stub_count files) — Claude Code may have recreated it. Safe to: rm -rf ~/.claude" - else - check PASS "~/.claude (stub dir) is absent" - fi - if [[ -f "$HOME/.claude.json" ]]; then check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." - else check PASS "~/.claude.json (home root) is absent"; fi - - echo "" - echo "──────────────────────────────────────────────────────" - if (( fail > 0 )); then - printf "Result: \033[31m%d FAIL\033[0m, \033[33m%d WARN\033[0m\n" "$fail" "$warn" - return 1 - elif (( warn > 0 )); then - printf "Result: \033[33m%d WARN\033[0m\n" "$warn" - return 0 - else - printf "Result: \033[32mall checks passed\033[0m\n" - return 0 - fi -} - -_ckipper_migrate() { - _ckipper_check_registry_version || return 1 - local legacy_docker="$HOME/.claude/docker" - local legacy_claude="$HOME/.claude" - - # ── Precondition 1: no Claude process running ───────────────── - _ckipper_assert_no_running_claude || return 1 - - # ── Precondition 2: ~/.claude must NOT be a symlink ────────── - # Some users symlink ~/.claude to a synced location. Renaming a symlink - # moves the link, not the target — confusing and probably not what they want. - if [[ -L "$legacy_claude" ]]; then - local target; target=$(readlink "$legacy_claude") - echo "Error: $legacy_claude is a symlink (→ $target). Refusing to migrate." >&2 - echo "Resolve manually: replace the symlink with the actual directory contents," >&2 - echo "or migrate the target directly." >&2 - return 1 - fi - - # ── Precondition 3: ~/.claude.json must NOT be a symlink ────── - # Same reasoning — Dropbox/iCloud users symlink this for cross-machine sync. - # mv on a symlink moves the link itself, breaking the sync target. - local legacy_homejson_check="$HOME/.claude.json" - if [[ -L "$legacy_homejson_check" ]]; then - local target; target=$(readlink "$legacy_homejson_check") - echo "Error: $legacy_homejson_check is a symlink (→ $target). Refusing to migrate." >&2 - echo "Resolve manually: replace the symlink with the actual file contents," >&2 - echo "or migrate the target directly." >&2 - return 1 - fi - - # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── - if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then - mkdir -p "$CKIPPER_DIR" - cp -a "$legacy_docker/." "$CKIPPER_DIR/docker/" - echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact for one release cycle)" - fi - - # ── 2. Adopt ~/.claude as a registered account ──────────────── - # Eligible if either ~/.claude/.claude.json or ~/.claude/settings.json exists, - # OR ~/.claude.json exists at home root (Claude Code's canonical big-config location). - local legacy_homejson="$HOME/.claude.json" - local has_inner_state=0 has_homejson=0 - [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]] && has_inner_state=1 - [[ -f "$legacy_homejson" ]] && has_homejson=1 - - if (( has_inner_state == 0 && has_homejson == 0 )); then - local has_registry=0 - [[ -f "$CKIPPER_REGISTRY" ]] && jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry=1 - if (( has_registry )); then - echo "Nothing to migrate: no $legacy_claude state and no $legacy_homejson at home root." - echo "($CKIPPER_REGISTRY already has registered accounts — you're likely already migrated.)" - echo "Run: ckipper list" - else - echo "Nothing to migrate: no $legacy_claude/.claude.json, no $legacy_claude/settings.json, no $legacy_homejson." - echo "If this is a fresh setup, register an account directly: ckipper add " - fi - return 0 - fi - - if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" || -f "$legacy_homejson" ]]; then - if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ - ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - - # ── Prompt for the account name ────────────────────── - local default_name="personal" - local name="" - while [[ -z "$name" ]]; do - read -r "?What name do you want for this migrated account? [$default_name] " name - [[ -z "$name" ]] && name="$default_name" - if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then - echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." - name="" - fi - if [[ -n "$name" && -e "$HOME/.claude-$name" ]]; then - echo "$HOME/.claude-$name already exists. Pick a different name." - name="" - fi - done - local target_dir="$HOME/.claude-$name" - - # Show the user what we're about to do. - cat </dev/null 2>&1; then - echo "Warning: '$probed_service' not found in Keychain." - echo "Listing available Claude Keychain entries:" - _ckipper_keychain_snapshot || return 1 - read -r "?Enter the Keychain service for the '$name' account (or empty to skip): " probed_service - if [[ -n "$probed_service" ]] && ! _ckipper_validate_keychain_service "$probed_service"; then - echo "Invalid Keychain service shape. Aborting." - return 1 - fi - fi - else - probed_service="" - fi - - # ── Destructive operation with explicit rollback ───── - # Tracks every step performed so rollback can undo precisely: - # 1 = ~/.claude renamed; 2 = ~/.claude.json moved (inner backed up) - local migrate_step=0 - local moved_homejson_backup="" - _ckipper_migrate_rollback() { - local why="${1:-rollback}" - # Step 2 reverse: restore ~/.claude.json at home root, restore the - # inner backup if we made one. - if (( migrate_step >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then - mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null - if [[ -n "$moved_homejson_backup" && -f "$moved_homejson_backup" ]]; then - mv "$moved_homejson_backup" "$target_dir/.claude.json" 2>/dev/null - fi - fi - # Step 1 reverse: rename target_dir back to legacy_claude. - if (( migrate_step >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then - mv "$target_dir" "$legacy_claude" 2>/dev/null - echo "Migration $why — restored $legacy_claude." >&2 - fi - # Clean partial registry entry so a re-run isn't blocked. - if [[ -f "$CKIPPER_REGISTRY" ]] && \ - jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - _ckipper_registry_update \ - 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ - --arg n "$name" - echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 - fi - # Regenerate aliases.zsh so it reflects the post-rollback registry - # (otherwise it would still define claude-() pointing into a - # dir that no longer exists). - _ckipper_regenerate_aliases 2>/dev/null || true - } - # HUP catches Terminal.app window-close mid-migrate; QUIT catches Ctrl-\. - trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT ERR; return 130' INT TERM HUP QUIT - - # Step 1: rename ~/.claude → ~/.claude- - if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then - trap - INT TERM HUP QUIT - echo "Error: failed to rename $legacy_claude → $target_dir" >&2 - echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 - return 1 - fi - migrate_step=1 - - # Step 2: move ~/.claude.json → $target_dir/.claude.json. - # If $target_dir already has a .claude.json (Claude wrote one when - # CLAUDE_CONFIG_DIR was set in some prior run), back it up — the - # home-root file is canonical. - if [[ -f "$legacy_homejson" ]]; then - if [[ -f "$target_dir/.claude.json" ]]; then - moved_homejson_backup="$target_dir/.claude.json.pre-migrate-backup" - mv "$target_dir/.claude.json" "$moved_homejson_backup" - fi - if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then - _ckipper_migrate_rollback failed - trap - INT TERM HUP QUIT - echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 - return 1 - fi - migrate_step=2 - fi - - # Step 2.5: rewrite stale absolute paths in plugin metadata. - # Without this, Claude Code raises "Plugin not found in marketplace" - # for every previously-installed plugin, because installed_plugins.json - # and known_marketplaces.json still reference $legacy_claude/... - # (which no longer exists post-rename). - _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" - - if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then - _ckipper_migrate_rollback failed - trap - INT TERM HUP QUIT - return 1 - fi - trap - INT TERM HUP QUIT - unset -f _ckipper_migrate_rollback - fi - fi - - # ── 3. Best-effort cleanup of old Docker image ──────────────── - if command -v docker >/dev/null 2>&1; then - docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." - fi - - # Reload `name` from registry for the success-message context (in case migrate - # was run for a no-op state and `name` was never set in this scope). - local registered_name="" - if [[ -f "$CKIPPER_REGISTRY" ]]; then - registered_name=$(jq -r '.default // (.accounts | keys[0] // "")' "$CKIPPER_REGISTRY") - fi - - cat < to add additional accounts. - -EOF - if [[ -n "$registered_name" ]]; then - cat < [--adopt]" + return 1 + fi + if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then + echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." + return 1 + fi + + _core_registry_init + + if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is already registered." + return 1 + fi + + local dir="$HOME/.claude-$name" + + if [[ $adopt -eq 1 ]]; then + if [[ ! -d "$dir" ]]; then + echo "Cannot adopt: $dir does not exist." + return 1 + fi + # In adopt mode, list candidate Keychain entries and let the user pick (or skip). + local picked="" + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + local candidates + candidates=$(_core_keychain_snapshot) || return 1 + if [[ -n "$candidates" ]]; then + echo "Candidate Keychain entries:" + echo "$candidates" | nl + read -r "?Pick a number (or empty to skip): " idx + if [[ -n "$idx" ]]; then + picked=$(echo "$candidates" | sed -n "${idx}p") + if [[ -n "$picked" ]] && ! _core_keychain_validate "$picked"; then + echo "Invalid Keychain service shape: $picked" + return 1 + fi + fi + fi + fi + _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" + return $? + fi + + # Fresh registration: ckipper LAUNCHES claude itself (in-place, same TTY) so + # there's no shell-deadlock UX where the user has to Ctrl-Z, run a command, + # then `fg`. User just /login's and exits Claude (Ctrl-D); ckipper resumes + # and finalizes registration. + if [[ -d "$dir" ]]; then + echo "Directory $dir already exists. Use --adopt to register it." + return 1 + fi + mkdir -p "$dir/hooks" + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then + cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" + fi + # Deploy hook scripts and rewrite settings.json paths to the per-account + # dir BEFORE launching claude. The template ships with $HOME/.claude/hooks + # paths; without this rewrite, the /login session inside claude fires hook + # errors ("No such file or directory") for paths that don't exist yet. + _ckipper_sync_hooks_for "$name" "$dir" + + local before_snapshot + before_snapshot=$(_core_keychain_snapshot) || return 1 + + cat </dev/null 2>&1; then + echo "Error: account '$name' already exists in registry (race detected)." >&2 + elif jq -e --arg d "$dir" '[.accounts[].config_dir] | any(. == $d)' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: config dir '$dir' is already claimed by another registered account." >&2 + else + echo "Error: failed to write account '$name' to registry $CKIPPER_REGISTRY" >&2 + fi + return 1 + fi + + _ckipper_regenerate_aliases + _ckipper_sync_hooks_for "$name" + + echo "Registered '$name' (mode: $mode)." + if _ckipper_bare_alias_safe "$name"; then + echo "Use it via: claude-$name (or just: $name)" + else + echo "Use it via: claude-$name" + fi +} + +# Returns 0 if $1 is safe to use as a bare-alias function name (no clash with +# any existing PATH command, shell builtin, alias, or reserved word). Existing +# shell *functions* are not a clash — we expect to redefine those. +_ckipper_bare_alias_safe() { + local n="$1" + (( ${+commands[$n]} || ${+builtins[$n]} || ${+aliases[$n]} )) && return 1 + local what; what=$(whence -w "$n" 2>/dev/null | awk '{print $2}') + [[ "$what" == "reserved" ]] && return 1 + return 0 +} + +_ckipper_list() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + echo "No accounts registered. Run: ckipper add " + return 0 + fi + local default + default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + echo "Registered accounts:" + jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ + while IFS=$'\t' read -r name dir; do + local marker=" " + [[ "$name" == "$default" ]] && marker="* " + local email="" + if [[ -f "$dir/.claude.json" ]]; then + email=$(jq -r '.oauthAccount.emailAddress // ""' "$dir/.claude.json" 2>/dev/null) + fi + local exists="(missing)" + [[ -d "$dir" ]] && exists="" + echo "$marker$name $dir ${email:+($email)} $exists" + done + echo "" + echo "* = default. Run: ckipper default " + echo "" + echo "Tip: don't run the same account in two terminals at once — Claude's OAuth refresh" + echo "is single-use, so the second session gets logged out. Use a different account instead." +} + +_ckipper_default() { + _core_registry_check_version || return 1 + local name="$1" + [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is not registered." + return 1 + fi + _core_registry_update '.default = $n' --arg n "$name" + echo "Default account is now '$name'." +} + +_ckipper_remove() { + _core_registry_check_version || return 1 + local name="$1" + [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + echo "Account '$name' is not registered." + return 1 + fi + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + _core_registry_update 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' --arg n "$name" + # Drop the now-stale launcher functions from the calling shell (regenerate + # only redefines what's still in the registry; it can't unset removed entries). + unset -f "claude-$name" 2>/dev/null + unset -f "$name" 2>/dev/null + _ckipper_regenerate_aliases + echo "Unregistered '$name'." + echo "" + echo "The directory and Keychain entry were not deleted. To remove them manually:" + printf " rm -rf %q\n" "$dir" + if [[ -n "$service" ]]; then + printf " security delete-generic-password -s %q\n" "$service" + fi +} + +_ckipper_rename() { + _core_registry_check_version || return 1 + local old="$1" new="$2" + if [[ -z "$old" || -z "$new" ]]; then + echo "Usage: ckipper rename " + return 1 + fi + if [[ ! "$new" =~ ^[a-z0-9_-]+$ ]]; then + echo "New name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." + return 1 + fi + if [[ "$old" == "$new" ]]; then + echo "Old and new name are the same. Nothing to do." + return 1 + fi + if ! jq -e --arg n "$old" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Account '$old' is not registered." + return 1 + fi + if jq -e --arg n "$new" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Account '$new' is already registered." + return 1 + fi + + local old_dir new_dir + old_dir=$(jq -r --arg n "$old" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + new_dir="$HOME/.claude-$new" + if [[ -e "$new_dir" ]]; then + echo "Error: $new_dir already exists. Pick a different name or remove it first." + return 1 + fi + if [[ ! -d "$old_dir" ]]; then + echo "Error: source directory $old_dir does not exist." + return 1 + fi + + # Refuse if any Claude session is running — they'd be writing to old_dir. + _core_assert_no_running_claude || return 1 + + if ! mv "$old_dir" "$new_dir" 2>/dev/null; then + echo "Error: failed to rename $old_dir → $new_dir." >&2 + return 1 + fi + + if ! _core_registry_update ' + .accounts[$new] = .accounts[$old] | + .accounts[$new].config_dir = $newdir | + del(.accounts[$old]) | + (if .default == $old then .default = $new else . end) + ' --arg old "$old" --arg new "$new" --arg newdir "$new_dir"; then + # Rollback: move dir back. + mv "$new_dir" "$old_dir" 2>/dev/null + echo "Error: registry write failed; reverted directory rename." >&2 + return 1 + fi + + # Drop old-name launcher functions from the calling shell. + unset -f "claude-$old" 2>/dev/null + unset -f "$old" 2>/dev/null + _ckipper_regenerate_aliases + _ckipper_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to the new dir + + echo "Renamed '$old' → '$new'." + echo "Directory: $old_dir → $new_dir" + if _ckipper_bare_alias_safe "$new"; then + echo "Use: claude-$new (or just: $new)" + else + echo "Use: claude-$new" + fi +} diff --git a/lib/ckipper/aliases.zsh b/lib/ckipper/aliases.zsh new file mode 100644 index 0000000..70f1c95 --- /dev/null +++ b/lib/ckipper/aliases.zsh @@ -0,0 +1,106 @@ +#!/usr/bin/env zsh +# Alias generation and hook sync subcommands: regenerate_aliases, sync_hooks_for, sync_hooks. + +_ckipper_regenerate_aliases() { + local out="$CKIPPER_DIR/aliases.zsh" + local _name _dir + { + echo "# Auto-generated by ckipper. Do not edit by hand." + echo "# Self-contained: does not depend on ckipper.zsh or w-function.zsh being sourced." + echo "# Regenerated whenever an account is added or removed." + echo "" + echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" + echo "" + # Guard: bare 'claude' would default to ~/.claude/ and write to the unsuffixed + # 'Claude Code-credentials' Keychain entry — which is the SAME entry the + # default account uses. A fresh /login here silently overwrites those creds. + # Block bare 'claude' when accounts are registered; users bypass via 'command claude'. + echo "claude() {" + echo " if [[ -f \"\$_CKIPPER_REGISTRY\" ]] && jq -e '.accounts | length > 0' \"\$_CKIPPER_REGISTRY\" >/dev/null 2>&1; then" + echo " local default" + echo " default=\$(jq -r '.default // \"\"' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" + echo " echo \"Refusing to launch bare 'claude' — Ckipper has registered accounts.\" >&2" + echo " echo \"\" >&2" + echo " echo \"Bare 'claude' uses ~/.claude/ and writes to the Keychain entry your\" >&2" + echo " echo \"default account ('\${default:-personal}') is registered against. A fresh\" >&2" + echo " echo \"/login here would silently overwrite those credentials.\" >&2" + echo " echo \"\" >&2" + echo " if [[ -n \"\$default\" ]]; then" + echo " echo \"Use: claude-\$default\" >&2" + echo " else" + echo " echo \"Set a default first: ckipper default , then use claude-.\" >&2" + echo " fi" + echo " echo \"\" >&2" + echo " echo \"To bypass (fresh login on purpose): command claude \\\$@\" >&2" + echo " return 1" + echo " fi" + echo " command claude \"\$@\"" + echo "}" + echo "" + if [[ -f "$CKIPPER_REGISTRY" ]]; then + jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ + while IFS=$'\t' read -r _name _dir; do + echo "claude-$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" + # Bare-name shortcut: also generate `` so users can + # type the account name directly. Skip if it would shadow + # a real binary, builtin, alias, or reserved word. + if _ckipper_bare_alias_safe "$_name"; then + echo "$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" + else + echo "# Bare-name alias '$_name' skipped (would shadow existing command)." + fi + done + fi + } > "$out.tmp" + # Atomic install — readers in other shells never see a partial file. + mv "$out.tmp" "$out" + chmod 644 "$out" + + # Re-source in the calling shell so newly-registered accounts are usable + # immediately without the user having to `exec zsh`. Function definitions + # from a sourced file are global by default in zsh, so this works even + # though we're sourcing inside a function. + source "$out" +} + +_ckipper_sync_hooks_for() { + local name="$1" dir="${2:-}" + # Allow callers (notably _ckipper_add, which deploys hooks BEFORE the + # account exists in the registry) to pass the dir directly. Without an + # explicit dir, look it up in the registry. + if [[ -z "$dir" ]]; then + _core_registry_check_version || return 1 + dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + [[ -z "$dir" || "$dir" == "null" ]] && return 1 + fi + mkdir -p "$dir/hooks" + cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true + + # Rewrite settings.json hook paths to absolute paths under this account dir. + # Consumes the entire prefix (`$HOME/.claude/`, `$HOME/.claude-/`, or `$HOME/.ckipper/`) + # plus `hooks/` so we don't end up with `$HOME/hooks/...` after substitution. + if [[ -f "$dir/settings.json" ]] && command -v jq &>/dev/null; then + local tmp; tmp=$(mktemp "$dir/.settings.tmp.XXXXXX") + jq --arg d "$dir" ' + (.hooks // {}) as $h | + .hooks = ($h | walk( + if type == "string" and test("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/") + then sub("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/"; "\($d)/hooks/") + else . end + )) + ' "$dir/settings.json" > "$tmp" && mv "$tmp" "$dir/settings.json" + fi +} + +_ckipper_sync_hooks() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + echo "No accounts registered." + return 0 + fi + _core_registry_check_version || return 1 + local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") + while IFS= read -r name; do + echo "Syncing hooks → $name" + _ckipper_sync_hooks_for "$name" + done <<< "$names" +} diff --git a/lib/ckipper/doctor.zsh b/lib/ckipper/doctor.zsh new file mode 100644 index 0000000..0ec63fd --- /dev/null +++ b/lib/ckipper/doctor.zsh @@ -0,0 +1,137 @@ +#!/usr/bin/env zsh +# Diagnostic check subcommand: doctor. + +_ckipper_doctor() { + local fail=0 warn=0 + local check() { + local sym="$1" msg="$2" + case "$sym" in + PASS) printf " \033[32m[PASS]\033[0m %s\n" "$msg" ;; + WARN) printf " \033[33m[WARN]\033[0m %s\n" "$msg"; (( warn++ )) ;; + FAIL) printf " \033[31m[FAIL]\033[0m %s\n" "$msg"; (( fail++ )) ;; + INFO) printf " [INFO] %s\n" "$msg" ;; + esac + } + # Locally-scoped function for color output. zsh function nesting works at runtime. + + echo "── Tooling ───────────────────────────────────────────" + if [[ -d "$CKIPPER_DIR" ]]; then check PASS "$CKIPPER_DIR exists"; else check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi + if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then check PASS "w-function.zsh deployed"; else check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then check PASS "ckipper.zsh deployed"; else check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then check PASS "cleanup-projects.py deployed"; else check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then check PASS "settings-template.json deployed"; else check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi + if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= 4 )); then + check PASS "hooks/ has 4+ files" + else + check WARN "hooks/ is missing or has fewer than 4 hook files" + fi + + echo "" + echo "── Registry ──────────────────────────────────────────" + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" + return 0 + fi + local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then check PASS "registry version $v matches expected" + else check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi + local perms; perms=$(_core_stat_perms "$CKIPPER_REGISTRY") + if [[ "$perms" == "600" ]]; then check PASS "registry permissions 600" + else check WARN "registry permissions $perms (expected 600)"; fi + + local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + if [[ -z "$default_acc" ]]; then + check WARN "no default account set — w/ckipper-add will require --account" + elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + check INFO "default account: $default_acc" + else + check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " + fi + + echo "" + echo "── Per-account state ────────────────────────────────" + local names; names=$(jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY") + if [[ -z "$names" ]]; then + check WARN "registry has no accounts" + else + while IFS= read -r name; do + echo "" + echo " Account: $name" + # Combined declare+assign: zsh 5.9 leaks "var=''" to stdout when the + # `local x; x=$(...)` form runs inside a `while` loop body. + local dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + if [[ -d "$dir" ]]; then check PASS " dir exists: $dir" + else check FAIL " dir missing: $dir"; fi + if [[ -f "$dir/.claude.json" ]]; then + local email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) + local proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) + local mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) + check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" + else + check WARN " .claude.json missing in $dir" + fi + if [[ -f "$dir/settings.json" ]]; then check PASS " settings.json present"; else check WARN " settings.json missing"; fi + if [[ -d "$dir/hooks" ]]; then check PASS " hooks/ deployed"; else check WARN " hooks/ missing — run: ckipper sync-hooks"; fi + # Stale plugin-metadata paths: a sign that ckipper migrate moved + # the account dir without rewriting absolute paths inside + # plugins/{known_marketplaces,installed_plugins}.json. Symptom is + # "Plugin not found in marketplace ..." in the Claude Code UI. + local stale_pm=0 + for pm in known_marketplaces.json installed_plugins.json; do + [[ -f "$dir/plugins/$pm" ]] || continue + if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then + stale_pm=1 + fi + done + if (( stale_pm )); then + check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" + fi + # Keychain check (macOS only) + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + if [[ -z "$svc" ]]; then + check INFO " keychain_service: null (account uses on-disk credentials)" + elif ! _core_keychain_validate "$svc"; then + check FAIL " keychain_service has invalid shape: $svc" + elif security find-generic-password -s "$svc" >/dev/null 2>&1; then + check PASS " keychain entry present: $svc" + else + check WARN " keychain entry NOT FOUND: $svc — re-run /login with: claude-$name" + fi + fi + done <<< "$names" + fi + + echo "" + echo "── Aliases & shell integration ──────────────────────" + if [[ -f "$CKIPPER_DIR/aliases.zsh" ]]; then check PASS "aliases.zsh exists at $CKIPPER_DIR/aliases.zsh" + else check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi + if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources aliases.zsh" + else check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi + if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources w-function.zsh" + else check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi + + echo "" + echo "── Stub files (cosmetic) ────────────────────────────" + if [[ -d "$HOME/.claude" ]]; then + local stub_count; stub_count=$(ls -1A "$HOME/.claude" 2>/dev/null | wc -l | tr -d ' ') + check WARN "~/.claude exists ($stub_count files) — Claude Code may have recreated it. Safe to: rm -rf ~/.claude" + else + check PASS "~/.claude (stub dir) is absent" + fi + if [[ -f "$HOME/.claude.json" ]]; then check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." + else check PASS "~/.claude.json (home root) is absent"; fi + + echo "" + echo "──────────────────────────────────────────────────────" + if (( fail > 0 )); then + printf "Result: \033[31m%d FAIL\033[0m, \033[33m%d WARN\033[0m\n" "$fail" "$warn" + return 1 + elif (( warn > 0 )); then + printf "Result: \033[33m%d WARN\033[0m\n" "$warn" + return 0 + else + printf "Result: \033[32mall checks passed\033[0m\n" + return 0 + fi +} diff --git a/lib/ckipper/migrate.zsh b/lib/ckipper/migrate.zsh new file mode 100644 index 0000000..5ebbe8e --- /dev/null +++ b/lib/ckipper/migrate.zsh @@ -0,0 +1,244 @@ +#!/usr/bin/env zsh +# One-time migration from legacy ~/.claude/docker/ layout. + +_ckipper_migrate() { + _core_registry_check_version || return 1 + local legacy_docker="$HOME/.claude/docker" + local legacy_claude="$HOME/.claude" + + # ── Precondition 1: no Claude process running ───────────────── + _core_assert_no_running_claude || return 1 + + # ── Precondition 2: ~/.claude must NOT be a symlink ────────── + # Some users symlink ~/.claude to a synced location. Renaming a symlink + # moves the link, not the target — confusing and probably not what they want. + if [[ -L "$legacy_claude" ]]; then + local target; target=$(readlink "$legacy_claude") + echo "Error: $legacy_claude is a symlink (→ $target). Refusing to migrate." >&2 + echo "Resolve manually: replace the symlink with the actual directory contents," >&2 + echo "or migrate the target directly." >&2 + return 1 + fi + + # ── Precondition 3: ~/.claude.json must NOT be a symlink ────── + # Same reasoning — Dropbox/iCloud users symlink this for cross-machine sync. + # mv on a symlink moves the link itself, breaking the sync target. + local legacy_homejson_check="$HOME/.claude.json" + if [[ -L "$legacy_homejson_check" ]]; then + local target; target=$(readlink "$legacy_homejson_check") + echo "Error: $legacy_homejson_check is a symlink (→ $target). Refusing to migrate." >&2 + echo "Resolve manually: replace the symlink with the actual file contents," >&2 + echo "or migrate the target directly." >&2 + return 1 + fi + + # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── + if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then + mkdir -p "$CKIPPER_DIR" + cp -a "$legacy_docker/." "$CKIPPER_DIR/docker/" + echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact for one release cycle)" + fi + + # ── 2. Adopt ~/.claude as a registered account ──────────────── + # Eligible if either ~/.claude/.claude.json or ~/.claude/settings.json exists, + # OR ~/.claude.json exists at home root (Claude Code's canonical big-config location). + local legacy_homejson="$HOME/.claude.json" + local has_inner_state=0 has_homejson=0 + [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]] && has_inner_state=1 + [[ -f "$legacy_homejson" ]] && has_homejson=1 + + if (( has_inner_state == 0 && has_homejson == 0 )); then + local has_registry=0 + [[ -f "$CKIPPER_REGISTRY" ]] && jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry=1 + if (( has_registry )); then + echo "Nothing to migrate: no $legacy_claude state and no $legacy_homejson at home root." + echo "($CKIPPER_REGISTRY already has registered accounts — you're likely already migrated.)" + echo "Run: ckipper list" + else + echo "Nothing to migrate: no $legacy_claude/.claude.json, no $legacy_claude/settings.json, no $legacy_homejson." + echo "If this is a fresh setup, register an account directly: ckipper add " + fi + return 0 + fi + + if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" || -f "$legacy_homejson" ]]; then + if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ + ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + + # ── Prompt for the account name ────────────────────── + local default_name="personal" + local name="" + while [[ -z "$name" ]]; do + read -r "?What name do you want for this migrated account? [$default_name] " name + [[ -z "$name" ]] && name="$default_name" + if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then + echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." + name="" + fi + if [[ -n "$name" && -e "$HOME/.claude-$name" ]]; then + echo "$HOME/.claude-$name already exists. Pick a different name." + name="" + fi + done + local target_dir="$HOME/.claude-$name" + + # Show the user what we're about to do. + cat </dev/null 2>&1; then + echo "Warning: '$probed_service' not found in Keychain." + echo "Listing available Claude Keychain entries:" + _core_keychain_snapshot || return 1 + read -r "?Enter the Keychain service for the '$name' account (or empty to skip): " probed_service + if [[ -n "$probed_service" ]] && ! _core_keychain_validate "$probed_service"; then + echo "Invalid Keychain service shape. Aborting." + return 1 + fi + fi + else + probed_service="" + fi + + # ── Destructive operation with explicit rollback ───── + # Tracks every step performed so rollback can undo precisely: + # 1 = ~/.claude renamed; 2 = ~/.claude.json moved (inner backed up) + local migrate_step=0 + local moved_homejson_backup="" + _ckipper_migrate_rollback() { + local why="${1:-rollback}" + # Step 2 reverse: restore ~/.claude.json at home root, restore the + # inner backup if we made one. + if (( migrate_step >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then + mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null + if [[ -n "$moved_homejson_backup" && -f "$moved_homejson_backup" ]]; then + mv "$moved_homejson_backup" "$target_dir/.claude.json" 2>/dev/null + fi + fi + # Step 1 reverse: rename target_dir back to legacy_claude. + if (( migrate_step >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then + mv "$target_dir" "$legacy_claude" 2>/dev/null + echo "Migration $why — restored $legacy_claude." >&2 + fi + # Clean partial registry entry so a re-run isn't blocked. + if [[ -f "$CKIPPER_REGISTRY" ]] && \ + jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + _core_registry_update \ + 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ + --arg n "$name" + echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 + fi + # Regenerate aliases.zsh so it reflects the post-rollback registry + # (otherwise it would still define claude-() pointing into a + # dir that no longer exists). + _ckipper_regenerate_aliases 2>/dev/null || true + } + # HUP catches Terminal.app window-close mid-migrate; QUIT catches Ctrl-\. + trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT ERR; return 130' INT TERM HUP QUIT + + # Step 1: rename ~/.claude → ~/.claude- + if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then + trap - INT TERM HUP QUIT + echo "Error: failed to rename $legacy_claude → $target_dir" >&2 + echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 + return 1 + fi + migrate_step=1 + + # Step 2: move ~/.claude.json → $target_dir/.claude.json. + # If $target_dir already has a .claude.json (Claude wrote one when + # CLAUDE_CONFIG_DIR was set in some prior run), back it up — the + # home-root file is canonical. + if [[ -f "$legacy_homejson" ]]; then + if [[ -f "$target_dir/.claude.json" ]]; then + moved_homejson_backup="$target_dir/.claude.json.pre-migrate-backup" + mv "$target_dir/.claude.json" "$moved_homejson_backup" + fi + if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then + _ckipper_migrate_rollback failed + trap - INT TERM HUP QUIT + echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 + return 1 + fi + migrate_step=2 + fi + + # Step 2.5: rewrite stale absolute paths in plugin metadata. + # Without this, Claude Code raises "Plugin not found in marketplace" + # for every previously-installed plugin, because installed_plugins.json + # and known_marketplaces.json still reference $legacy_claude/... + # (which no longer exists post-rename). + _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" + + if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then + _ckipper_migrate_rollback failed + trap - INT TERM HUP QUIT + return 1 + fi + trap - INT TERM HUP QUIT + unset -f _ckipper_migrate_rollback + fi + fi + + # ── 3. Best-effort cleanup of old Docker image ──────────────── + if command -v docker >/dev/null 2>&1; then + docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." + fi + + # Reload `name` from registry for the success-message context (in case migrate + # was run for a no-op state and `name` was never set in this scope). + local registered_name="" + if [[ -f "$CKIPPER_REGISTRY" ]]; then + registered_name=$(jq -r '.default // (.accounts | keys[0] // "")' "$CKIPPER_REGISTRY") + fi + + cat < to add additional accounts. + +EOF + if [[ -n "$registered_name" ]]; then + cat <), the plugin cache files on +# disk have moved with the rename, but the JSON metadata still has the old +# absolute paths baked in — Claude Code then fails to resolve plugins with +# "Plugin not found in marketplace ..." errors. +# +# $1 = old prefix (must end with `/`), e.g. "$HOME/.claude/" +# $2 = new prefix (must end with `/`), e.g. "$HOME/.claude-personal/" +# Idempotent: if neither file contains the old prefix, this is a no-op. +_ckipper_rewrite_plugin_paths() { + local old="$1" new="$2" + [[ -z "$old" || -z "$new" || "$old" != */ || "$new" != */ ]] && return 1 + [[ "$old" == "$new" ]] && return 0 + local f rewrote=0 + for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do + local fp="$new$f" + [[ -f "$fp" ]] || continue + grep -q -- "$old" "$fp" 2>/dev/null || continue + cp "$fp" "$fp.pre-rewrite-backup-$(date +%s)" + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + sed -i '' "s|$old|$new|g" "$fp" + else + sed -i "s|$old|$new|g" "$fp" + fi + rewrote=1 + done + return 0 +} + +_ckipper_repair_plugins() { + local name="$1" + if [[ -z "$name" ]]; then + echo "Usage: ckipper repair-plugins " + return 1 + fi + _core_registry_check_version || return 1 + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") + if [[ -z "$dir" ]]; then + echo "Account '$name' is not registered. Run: ckipper list" + return 1 + fi + if [[ ! -d "$dir" ]]; then + echo "Account dir does not exist: $dir" + return 1 + fi + + # Detect what stale prefix the metadata is using. Almost always the legacy + # ~/.claude/, but a previously-renamed account could carry an older suffix. + local stale_prefix="" + local f + for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do + [[ -f "$dir/$f" ]] || continue + # Combined declare+assign on one line: zsh 5.9 emits "var=''" to stdout + # when `local var` and `var=$(...)` are split across two lines inside a + # `for` loop body. Trivia that mostly bites diagnostic helpers like this. + local hit=$(grep -oE "$HOME/\.claude(-[a-z0-9_-]+)?/" "$dir/$f" 2>/dev/null | sort -u | grep -v "^$dir/$" | head -1) + if [[ -n "$hit" ]]; then + stale_prefix="$hit" + break + fi + done + if [[ -z "$stale_prefix" ]]; then + echo "No stale paths found in $dir/plugins/. Nothing to repair." + return 0 + fi + echo "Rewriting plugin metadata for '$name':" + echo " $stale_prefix → $dir/" + _ckipper_rewrite_plugin_paths "$stale_prefix" "$dir/" + echo "Done. Backups saved alongside each rewritten file (.pre-rewrite-backup-)." +} diff --git a/lib/ckipper/sync.zsh b/lib/ckipper/sync.zsh new file mode 100644 index 0000000..70c736d --- /dev/null +++ b/lib/ckipper/sync.zsh @@ -0,0 +1,144 @@ +#!/usr/bin/env zsh +# Account settings sync subcommand: sync MCP servers and settings.json keys between accounts. + +_ckipper_sync() { + _core_registry_check_version || return 1 + local from="$1" to="$2" + shift 2 2>/dev/null + if [[ -z "$from" || -z "$to" ]]; then + echo "Usage: ckipper sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" + return 1 + fi + if [[ "$from" == "$to" ]]; then + echo " and must differ." + return 1 + fi + + local from_dir to_dir + from_dir=$(_core_account_dir "$from") || return 1 + to_dir=$(_core_account_dir "$to") || return 1 + if [[ ! -f "$from_dir/.claude.json" ]]; then + echo "Source has no .claude.json: $from_dir"; return 1 + fi + if [[ ! -f "$to_dir/.claude.json" ]]; then + echo "Destination has no .claude.json: $to_dir"; return 1 + fi + + # Parse flags. The argparse here is intentionally minimal — order matters, + # but each flag is well-formed and easy to read. + local mode_mcp=0 mcp_names="" mode_settings=0 settings_keys="" dry_run=0 mode_all=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --mcp) + mode_mcp=1 + if [[ -n "$2" && "$2" != --* ]]; then mcp_names="$2"; shift; fi + shift ;; + --settings) + mode_settings=1 + if [[ -n "$2" && "$2" != --* ]]; then settings_keys="$2"; shift; fi + shift ;; + --all) mode_all=1; shift ;; + --dry-run) dry_run=1; shift ;; + *) echo "Unknown flag: $1"; return 1 ;; + esac + done + + # Default bundle when no specific flags were passed: mcpServers + a useful + # selection of settings.json keys. + if (( mode_mcp == 0 && mode_settings == 0 )); then + mode_all=1 + fi + if (( mode_all )); then + mode_mcp=1 + mode_settings=1 + [[ -z "$settings_keys" ]] && \ + settings_keys="enabledPlugins,extraKnownMarketplaces,statusLine,env,model" + fi + + # If a Claude session is running, sync's writes can race with its writes + # to the same .claude.json. Warn (but don't refuse) unless --dry-run. + if (( ! dry_run )); then + local running_procs + running_procs=$(_core_running_claude_processes) + if [[ -n "$running_procs" ]]; then + echo "Warning: Claude is currently running. If a session uses '$to' or '$from'," >&2 + echo "sync may race with its writes (Claude doesn't lock these files)." >&2 + echo "$running_procs" | sed 's/^/ /' >&2 + read -r "?Continue anyway? [y/N] " ans + [[ "$ans" != "y" && "$ans" != "Y" ]] && { echo "Aborted."; return 1; } + fi + fi + + local pending_msgs=() + + # ── MCP sync ───────────────────────────────────────────────── + if (( mode_mcp )); then + local mcp_filter + if [[ -z "$mcp_names" ]]; then + mcp_filter='.mcpServers // {}' + else + # Build a jq object containing only the named servers, e.g. {Vibma: ..., github: ...} + local jq_array + jq_array=$(echo "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') + mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | '"$jq_array"' | index($k)))' + fi + local servers + servers=$(jq "$mcp_filter" "$from_dir/.claude.json") + local server_keys + server_keys=$(echo "$servers" | jq -r 'keys[]?' | tr '\n' ' ') + if [[ -z "$server_keys" || "$server_keys" == " " ]]; then + pending_msgs+=("MCP: nothing to sync (no matching servers in $from)") + else + pending_msgs+=("MCP servers → $to: $server_keys") + if (( ! dry_run )); then + local tmp; tmp=$(mktemp "$to_dir/.claude.json.tmp.XXXXXX") + jq --argjson new "$servers" '.mcpServers = (.mcpServers // {}) + $new' \ + "$to_dir/.claude.json" > "$tmp" && mv "$tmp" "$to_dir/.claude.json" + fi + fi + fi + + # ── settings.json key sync ─────────────────────────────────── + if (( mode_settings )) && [[ -n "$settings_keys" ]]; then + if [[ ! -f "$from_dir/settings.json" ]]; then + pending_msgs+=("Settings: $from has no settings.json (skipping)") + else + # Build a jq subset object with only the requested keys (skipping missing ones). + local jq_keys + jq_keys=$(echo "$settings_keys" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') + local subset + subset=$(jq --argjson keys "$jq_keys" \ + 'with_entries(select(.key as $k | $keys | index($k)))' \ + "$from_dir/settings.json") + local copied_keys + copied_keys=$(echo "$subset" | jq -r 'keys[]?' | tr '\n' ' ') + if [[ -z "$copied_keys" || "$copied_keys" == " " ]]; then + pending_msgs+=("Settings: no matching keys in $from/settings.json") + else + pending_msgs+=("Settings keys → $to: $copied_keys") + if (( ! dry_run )); then + if [[ ! -f "$to_dir/settings.json" ]]; then + echo '{}' > "$to_dir/settings.json" + fi + local tmp; tmp=$(mktemp "$to_dir/settings.json.tmp.XXXXXX") + jq --argjson new "$subset" '. + $new' \ + "$to_dir/settings.json" > "$tmp" && mv "$tmp" "$to_dir/settings.json" + fi + fi + fi + fi + + if (( dry_run )); then + echo "Dry run — would apply:" + else + echo "Synced:" + fi + for m in "${pending_msgs[@]}"; do + echo " - $m" + done + + if (( ! dry_run )); then + echo "" + echo "Restart any running '$to' Claude session for changes to take effect." + fi +} diff --git a/lib/core/keychain.zsh b/lib/core/keychain.zsh new file mode 100644 index 0000000..8d70f9e --- /dev/null +++ b/lib/core/keychain.zsh @@ -0,0 +1,70 @@ +#!/usr/bin/env zsh +# Shared macOS Keychain and Claude process utilities. + +# Validates a keychain_service name before passing to `security`. +# Accepts "Claude Code-credentials" optionally followed by "-". +_core_keychain_validate() { + local svc="$1" + [[ -z "$svc" ]] && return 1 + [[ "$svc" =~ ^Claude\ Code-credentials(-[a-f0-9]+)?$ ]] +} + +_core_keychain_snapshot() { + # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 + + # Pick a timeout binary if available (macOS doesn't ship one; gtimeout from + # coreutils is the typical brew install). Fall through to no timeout if neither + # is present — better than failing with a misleading "keychain locked" error. + local timeout_cmd="" + if command -v timeout >/dev/null 2>&1; then + timeout_cmd="timeout 10" + elif command -v gtimeout >/dev/null 2>&1; then + timeout_cmd="gtimeout 10" + fi + + local out + if [[ -n "$timeout_cmd" ]]; then + if ! out=$($timeout_cmd security dump-keychain 2>/dev/null); then + echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 + return 1 + fi + else + # No timeout available — run without. If keychain is locked the GUI + # password prompt will block this, which is a fine failure mode. + if ! out=$(security dump-keychain 2>/dev/null); then + echo "Warning: 'security dump-keychain' failed. Keychain may be locked." >&2 + return 1 + fi + fi + + printf '%s\n' "$out" | \ + awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ + sort -u +} + +# Detect running Claude processes that would conflict with destructive operations. +# Matches: 'claude' CLI (basename), 'Claude' (Claude.app main process). Avoids matching +# vim files named 'claude-*', tmux sessions, or Claude Helper subprocesses (the parent +# Claude.app being killed will cascade to those). +_core_running_claude_processes() { + pgrep -lx claude 2>/dev/null + pgrep -lx Claude 2>/dev/null +} + +# Refuse with a clear message if any Claude process is running. +_core_assert_no_running_claude() { + local found + found=$(_core_running_claude_processes) + if [[ -n "$found" ]]; then + echo "Error: Claude process(es) detected. Quit them first:" >&2 + echo "$found" | sed 's/^/ /' >&2 + echo "(Set CKIPPER_FORCE=1 to bypass this check, but expect inconsistent state.)" >&2 + if [[ "$CKIPPER_FORCE" == "1" ]]; then + echo "CKIPPER_FORCE=1 set — proceeding despite running Claude." >&2 + return 0 + fi + return 1 + fi + return 0 +} diff --git a/lib/core/registry.zsh b/lib/core/registry.zsh new file mode 100644 index 0000000..eb35405 --- /dev/null +++ b/lib/core/registry.zsh @@ -0,0 +1,121 @@ +#!/usr/bin/env zsh +# Shared registry read/write primitives for managing the ckipper accounts registry. + +# Atomic registry write under flock (or mkdir-fallback). $1 = jq filter. +# Returns 0 on successful jq+write, 1 on jq error or write failure. +# A jq error() call inside the filter (used for atomic-collision-checks like +# _ckipper_finalize_registration) propagates as a non-zero exit here. +_core_registry_update() { + local jq_filter="$1"; shift + local lock="$CKIPPER_DIR/.registry.lock" + mkdir -p "$CKIPPER_DIR" + : > "$lock" + local rc=1 + if command -v flock >/dev/null 2>&1; then + { + flock -x 9 + local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") + if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then + mv "$tmp" "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + rc=0 + else + rm -f "$tmp" + fi + } 9>"$lock" + else + # Fallback for systems without flock (the default on macOS): mkdir-based lock, + # with stale-lock recovery so a SIGKILL'd ckipper doesn't permanently brick + # subsequent invocations. + setopt local_options local_traps + local lockdir="$CKIPPER_DIR/.registry.lock.d" + local attempts=0 + local notified=0 + while ! mkdir "$lockdir" 2>/dev/null; do + (( attempts++ )) + # Reassure the user something is happening — silent multi-second + # pauses look like a freeze. Print once at ~1.5s in, then again + # only if we recover a stale lock below. + if (( attempts == 30 && notified == 0 )); then + echo "Waiting on registry lock..." >&2 + notified=1 + fi + if (( attempts >= 200 )); then # 10s + local lockdir_age now + now=$(date +%s) + local mtime; mtime=$(_core_stat_mtime "$lockdir") + lockdir_age=$(( now - ${mtime:-$now} )) + if (( lockdir_age > 30 )); then + echo "Cleaning up old lock from a previous session (age ${lockdir_age}s)..." >&2 + rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" + attempts=0 + continue + fi + echo "Registry lock held by another process for ${lockdir_age}s. Try again shortly." >&2 + return 1 + fi + sleep 0.05 + done + trap 'rmdir "$lockdir" 2>/dev/null' EXIT + local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") + if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then + mv "$tmp" "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + rc=0 + else + rm -f "$tmp" + fi + fi + return $rc +} + +# Initialize an empty registry with version field. Idempotent under concurrency +# via atomic create (mv -n) — two concurrent ckipper init's won't clobber each other. +_core_registry_init() { + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + mkdir -p "$CKIPPER_DIR" + local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.init.XXXXXX") + cat > "$tmp" </dev/null || rm -f "$tmp" + [[ -f "$CKIPPER_REGISTRY" ]] && chmod 600 "$CKIPPER_REGISTRY" + fi +} + +# Refuse to operate on a registry whose version we don't understand OR whose schema +# is corrupt (e.g. user manually edited and turned .accounts into an array). +_core_registry_check_version() { + [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 + local v + v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + if (( v != CKIPPER_REGISTRY_VERSION )); then + echo "Error: registry version $v not supported (this ckipper expects $CKIPPER_REGISTRY_VERSION). Update ckipper or restore from backup." >&2 + return 1 + fi + if ! jq -e '.accounts | type == "object"' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: $CKIPPER_REGISTRY is corrupt (.accounts is not an object)." >&2 + echo "Backup and re-init manually:" >&2 + echo " mv $CKIPPER_REGISTRY $CKIPPER_REGISTRY.corrupt-\$(date +%s)" >&2 + return 1 + fi +} + +# Validates that an account exists in the registry. Echoes its config_dir on success. +_core_account_dir() { + local name="$1" + if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Account '$name' is not registered." >&2 + return 1 + fi + jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY" +} + +# Read the entire registry JSON to stdout. Used by both ckipper subcommands +# and (later) by lib/w/ to satisfy the no-sibling-cross-imports rule. +# +# Returns: 0 on success; non-zero if registry file is missing. +_core_registry_read() { + cat "$CKIPPER_REGISTRY" +} diff --git a/lib/core/utils.zsh b/lib/core/utils.zsh new file mode 100644 index 0000000..0a18df0 --- /dev/null +++ b/lib/core/utils.zsh @@ -0,0 +1,20 @@ +#!/usr/bin/env zsh +# Shared cross-platform stat utilities used by registry and other modules. + +# Cross-platform stat for permissions: BSD (macOS) uses -f, GNU/Linux uses -c. +_core_stat_perms() { + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + stat -f '%Lp' "$1" 2>/dev/null + else + stat -c '%a' "$1" 2>/dev/null + fi +} + +# Cross-platform stat for mtime in seconds since epoch. +_core_stat_mtime() { + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + stat -f '%m' "$1" 2>/dev/null + else + stat -c '%Y' "$1" 2>/dev/null + fi +} From 51536b636324c3d735a3c10dfca7499dfbf0b7e2 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:27:05 -0600 Subject: [PATCH 043/165] Phase 3: modularize w-function.zsh into lib/w/ --- lib/w/args.zsh | 69 ++++++ lib/w/build-image.zsh | 15 ++ lib/w/docker-mode.zsh | 213 ++++++++++++++++ lib/w/normal-mode.zsh | 24 ++ lib/w/ports.zsh | 31 +++ lib/w/resolve-account.zsh | 64 +++++ lib/w/worktree.zsh | 166 +++++++++++++ w-function.zsh | 497 ++++---------------------------------- 8 files changed, 627 insertions(+), 452 deletions(-) create mode 100644 lib/w/args.zsh create mode 100644 lib/w/build-image.zsh create mode 100644 lib/w/docker-mode.zsh create mode 100644 lib/w/normal-mode.zsh create mode 100644 lib/w/ports.zsh create mode 100644 lib/w/resolve-account.zsh create mode 100644 lib/w/worktree.zsh diff --git a/lib/w/args.zsh b/lib/w/args.zsh new file mode 100644 index 0000000..11f0587 --- /dev/null +++ b/lib/w/args.zsh @@ -0,0 +1,69 @@ +#!/usr/bin/env zsh +# Argument parsing for w(). Populates W_* globals used by all other lib/w modules. + +# Parse all arguments passed to w() into W_* globals. +# +# Globals set: +# W_FLAG_LIST — true if --list +# W_FLAG_REBUILD_IMAGE — true if --rebuild-image +# W_FLAG_RM — true if --rm +# W_FLAG_FORCE — --force flag for --rm +# W_FLAG_DOCKER — true if --docker +# W_FLAG_FIREWALL — true if --firewall +# W_PROJECT — first positional arg (project path) +# W_BRANCH — second positional arg (worktree/branch name) +# W_CLI_ACCOUNT — value of --account , or empty +# W_COMMAND — array: remaining positional args after project+branch +# W_PROJECTS_DIR — base directory for projects ($HOME/Developer) +# W_WORKTREES_DIR — base directory for worktrees ($HOME/Developer/.worktrees) +# +# Returns: 0 always (validation is done by the dispatcher). +_w_parse_args() { + W_FLAG_LIST=false + W_FLAG_REBUILD_IMAGE=false + W_FLAG_RM=false + W_FLAG_FORCE=false + W_FLAG_DOCKER=false + W_FLAG_FIREWALL=false + W_PROJECT="" + W_BRANCH="" + W_CLI_ACCOUNT="" + W_COMMAND=() + W_PROJECTS_DIR="$HOME/Developer" + W_WORKTREES_DIR="$HOME/Developer/.worktrees" + + if [[ "$1" == "--list" ]]; then + W_FLAG_LIST=true + return 0 + fi + + if [[ "$1" == "--rebuild-image" ]]; then + W_FLAG_REBUILD_IMAGE=true + return 0 + fi + + if [[ "$1" == "--rm" ]]; then + W_FLAG_RM=true + shift + if [[ "$1" == "--force" || "$1" == "-f" ]]; then + W_FLAG_FORCE=true + shift + fi + W_PROJECT="$1" + W_BRANCH="$2" + return 0 + fi + + W_PROJECT="$1" + W_BRANCH="$2" + shift 2 2>/dev/null + + while [[ $# -gt 0 ]]; do + case "$1" in + --docker) W_FLAG_DOCKER=true; shift ;; + --firewall) W_FLAG_FIREWALL=true; shift ;; + --account) W_CLI_ACCOUNT="$2"; shift 2 ;; + *) W_COMMAND+=("$1"); shift ;; + esac + done +} diff --git a/lib/w/build-image.zsh b/lib/w/build-image.zsh new file mode 100644 index 0000000..ecdd12f --- /dev/null +++ b/lib/w/build-image.zsh @@ -0,0 +1,15 @@ +#!/usr/bin/env zsh +# Docker image build helper for w(). + +# Build the ckipper-dev Docker image from $CKIPPER_DIR/docker/Dockerfile. +# +# Returns: 0 on success; 1 if Dockerfile not found or docker build fails. +_w_build_image() { + local docker_dir="${CKIPPER_DIR:-$HOME/.ckipper}/docker" + if [[ ! -f "$docker_dir/Dockerfile" ]]; then + echo "Dockerfile not found: $docker_dir/Dockerfile" + return 1 + fi + echo "Building ckipper-dev Docker image..." + docker build --build-arg "CACHEBUST=$(date +%s)" -t ckipper-dev "$docker_dir" +} diff --git a/lib/w/docker-mode.zsh b/lib/w/docker-mode.zsh new file mode 100644 index 0000000..302e2ce --- /dev/null +++ b/lib/w/docker-mode.zsh @@ -0,0 +1,213 @@ +#!/usr/bin/env zsh +# Docker mode execution for w(). Builds docker run args and launches the container. + +# Run the worktree in a Docker container. +# +# Reads globals: W_WT_PATH, W_PROJECTS_DIR, W_PROJECT, W_BRANCH, W_COMMAND, +# W_FLAG_FIREWALL, W_ACTIVE_ACCOUNT, W_ACTIVE_CONFIG_DIR, +# W_ACTIVE_KEYCHAIN_SERVICE, W_PORTS, W_EXTRA_VOLUMES, W_EXTRA_ENV. +# Returns: exit code of the docker run invocation. +_w_run_docker_mode() { + _w_docker_check_prerequisites || return 1 + + [[ -f "$W_ACTIVE_CONFIG_DIR/.claude.json" ]] || echo '{}' > "$W_ACTIVE_CONFIG_DIR/.claude.json" + + _w_docker_validate_keychain || return 1 + + local claude_creds gh_token + claude_creds=$(_w_docker_extract_credentials) + gh_token=$(_w_docker_extract_gh_token) + + local -a W_DOCKER_ARGS + _w_docker_build_base_args + _w_docker_add_optional_args "$claude_creds" "$gh_token" + _w_resolve_ports + [[ "$W_FLAG_FIREWALL" = true ]] && W_DOCKER_ARGS+=( --cap-add=NET_ADMIN -e ENABLE_FIREWALL=1 ) + + W_DOCKER_ARGS+=( ckipper-dev ) + _w_docker_expand_command + + _w_docker_print_banner + _w_docker_snapshot_and_run +} + +# Validate Docker is installed and daemon is running. +_w_docker_check_prerequisites() { + if ! command -v docker &>/dev/null; then + echo "Error: docker is not installed or not in PATH" + return 1 + fi + if ! docker info &>/dev/null 2>&1; then + echo "Error: Docker daemon is not running. Start Docker Desktop first." + return 1 + fi + if ! docker image inspect ckipper-dev > /dev/null 2>&1; then + _w_build_image || return 1 + fi +} + +# Validate the active account's keychain service name if set. +_w_docker_validate_keychain() { + if [[ -n "$W_ACTIVE_KEYCHAIN_SERVICE" ]] && \ + ! _core_keychain_validate "$W_ACTIVE_KEYCHAIN_SERVICE"; then + echo "Error: account '$W_ACTIVE_ACCOUNT' has invalid keychain_service in registry." + echo "Re-register with: ckipper remove $W_ACTIVE_ACCOUNT && ckipper add $W_ACTIVE_ACCOUNT --adopt" + return 1 + fi +} + +# Extract Claude credentials from macOS Keychain (prints to stdout). +_w_docker_extract_credentials() { + local creds="" + if [[ -n "$W_ACTIVE_KEYCHAIN_SERVICE" ]]; then + creds=$(security find-generic-password -s "$W_ACTIVE_KEYCHAIN_SERVICE" -w 2>/dev/null) || true + fi + echo "$creds" +} + +# Extract GitHub token for gh CLI auth inside container (prints to stdout). +_w_docker_extract_gh_token() { + local token + token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' \ + "$W_ACTIVE_CONFIG_DIR/.claude.json" 2>/dev/null) || true + if [[ -z "$token" ]] && command -v gh &>/dev/null; then + token=$(gh auth token 2>/dev/null) || true + fi + echo "$token" +} + +# Build the base docker run argument array into W_DOCKER_ARGS. +_w_docker_build_base_args() { + W_DOCKER_ARGS=( + docker run --rm -it + -e TERM="${TERM:-xterm-256color}" + -v "$W_WT_PATH:/workspace:rw" + -v "$W_PROJECTS_DIR/$W_PROJECT/.git:$W_PROJECTS_DIR/$W_PROJECT/.git:rw" + -v "$W_ACTIVE_CONFIG_DIR:$W_ACTIVE_CONFIG_DIR:rw" + -e "CLAUDE_CONFIG_DIR=$W_ACTIVE_CONFIG_DIR" + -v "$HOME/.ssh:/home/claude/.ssh-host:ro" + -v /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock + -e SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock + --group-add 0 + --tmpfs /tmp/claude-creds:mode=700,uid=1000,gid=1000,size=1m + -v "claude-uv-cache:/home/claude/.cache/uv" + -v "claude-uv-tools:/home/claude/.uv-tools" + -e "UV_TOOL_DIR=/home/claude/.uv-tools/envs" + -e "UV_TOOL_BIN_DIR=/home/claude/.uv-tools/bin" + -e "UV_PYTHON_INSTALL_DIR=/home/claude/.uv-tools/python" + ) + for vol in "${W_EXTRA_VOLUMES[@]}"; do + W_DOCKER_ARGS+=( -v "$vol" ) + done +} + +# Add credentials, gh token, and extra env vars to W_DOCKER_ARGS. +_w_docker_add_optional_args() { + local claude_creds="$1" + local gh_token="$2" + + if [[ -n "$claude_creds" ]]; then + W_DOCKER_ARGS+=( -e "CLAUDE_CREDENTIALS=$claude_creds" ) + else + echo " Warning: Could not extract Claude credentials from Keychain" + fi + + if [[ -n "$gh_token" ]]; then + W_DOCKER_ARGS+=( -e "GH_TOKEN=$gh_token" ) + else + echo " Warning: No GitHub token found (gh commands won't work in container)" + fi + + for env_var in "${W_EXTRA_ENV[@]}"; do + W_DOCKER_ARGS+=( -e "$env_var" ) + done +} + +# If the command is "claude", expand to full skip-permissions invocation. +_w_docker_expand_command() { + if [[ ${#W_COMMAND[@]} -gt 0 && "${W_COMMAND[1]}" == "claude" ]]; then + W_COMMAND=(claude --dangerously-skip-permissions "/rename $W_BRANCH") + fi + if [[ ${#W_COMMAND[@]} -gt 0 ]]; then + W_DOCKER_ARGS+=( "${W_COMMAND[@]}" ) + fi +} + +# Print the startup banner. +_w_docker_print_banner() { + local mode_label="Docker" + [[ ${#W_COMMAND[@]} -gt 0 ]] && mode_label+=": ${W_COMMAND[1]}" + [[ "$W_FLAG_FIREWALL" = true ]] && mode_label+=", firewall" + echo "Starting $mode_label..." + echo " Worktree: $W_WT_PATH" + echo " Ports: ${W_RESOLVED_PORTS[*]}" +} + +# Snapshot git state, run docker, then check for post-session tampering. +_w_docker_snapshot_and_run() { + local git_config="$W_PROJECTS_DIR/$W_PROJECT/.git/config" + local git_config_hash="" + [[ -f "$git_config" ]] && git_config_hash=$(shasum -a 256 "$git_config" | cut -d' ' -f1) + + local git_worktrees_dir="$W_PROJECTS_DIR/$W_PROJECT/.git/worktrees" + local -a worktrees_before=() + if [[ -d "$git_worktrees_dir" ]]; then + worktrees_before=( "$git_worktrees_dir"/*(N/:t) ) + fi + + "${W_DOCKER_ARGS[@]}" + local exit_code=$? + + _w_docker_check_git_config_tampering "$git_config" "$git_config_hash" + _w_docker_check_worktree_destruction "$git_worktrees_dir" "${worktrees_before[@]}" + + return $exit_code +} + +# Warn if .git/config was modified during the session. +_w_docker_check_git_config_tampering() { + local git_config="$1" + local original_hash="$2" + if [[ -n "$original_hash" && -f "$git_config" ]]; then + local new_hash + new_hash=$(shasum -a 256 "$git_config" | cut -d' ' -f1) + if [[ "$original_hash" != "$new_hash" ]]; then + echo "" + echo "WARNING: .git/config was modified during the Docker session!" + echo "Review changes: git -C $W_PROJECTS_DIR/$W_PROJECT config --local --list" + fi + fi +} + +# Warn if any worktree metadata was destroyed during the session. +_w_docker_check_worktree_destruction() { + local git_worktrees_dir="$1" + shift + local -a worktrees_before=("$@") + [[ ${#worktrees_before[@]} -eq 0 ]] && return 0 + + local -a worktrees_after=() + if [[ -d "$git_worktrees_dir" ]]; then + worktrees_after=( "$git_worktrees_dir"/*(N/:t) ) + fi + + local -a missing=() + for wt in "${worktrees_before[@]}"; do + if [[ ! " ${worktrees_after[*]} " =~ " $wt " ]]; then + missing+=("$wt") + fi + done + + [[ ${#missing[@]} -eq 0 ]] && return 0 + + echo "" + echo "CRITICAL: ${#missing[@]} worktree(s) had metadata destroyed during the Docker session!" + echo "Missing worktrees: ${missing[*]}" + echo "" + echo "The working directories still exist on disk — only the .git/worktrees/ metadata was deleted." + echo "To recover, re-register each worktree:" + echo " cd $W_PROJECTS_DIR/$W_PROJECT" + for wt in "${missing[@]}"; do + echo " git worktree add $wt" + done +} diff --git a/lib/w/normal-mode.zsh b/lib/w/normal-mode.zsh new file mode 100644 index 0000000..a16dda8 --- /dev/null +++ b/lib/w/normal-mode.zsh @@ -0,0 +1,24 @@ +#!/usr/bin/env zsh +# Normal (non-Docker) mode execution for w(). + +# Run the worktree in normal mode: cd to it or run a command inside it. +# +# Reads globals: W_WT_PATH, W_BRANCH, W_COMMAND. +# Returns: 0 on cd; exit code of command if one was given. +_w_run_normal_mode() { + if [[ ${#W_COMMAND[@]} -eq 0 ]]; then + cd "$W_WT_PATH" + return 0 + fi + + if [[ "${W_COMMAND[1]}" == "claude" ]]; then + W_COMMAND+=("/rename $W_BRANCH") + fi + + local old_pwd="$PWD" + cd "$W_WT_PATH" + "${W_COMMAND[@]}" + local exit_code=$? + cd "$old_pwd" + return $exit_code +} diff --git a/lib/w/ports.zsh b/lib/w/ports.zsh new file mode 100644 index 0000000..686662d --- /dev/null +++ b/lib/w/ports.zsh @@ -0,0 +1,31 @@ +#!/usr/bin/env zsh +# Port resolution for w() Docker mode. Finds available host ports for dev servers. + +# Resolve port mappings for Docker, using fallback host ports when the +# preferred port is already in use. Appends -p flags to the W_DOCKER_ARGS array. +# +# Reads: W_PORTS (array of container ports), W_DOCKER_ARGS (appended to). +# Sets: W_RESOLVED_PORTS (array of ports for display). +_w_resolve_ports() { + local max_fallback=10 + W_RESOLVED_PORTS=("${W_PORTS[@]}") + + for port in "${W_PORTS[@]}"; do + local host_port=$port + local bound=0 + for (( i=0; i/dev/null; then + W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) + bound=1 + if (( host_port != port )); then + echo " Port $port mapped to host:$host_port (original in use)" + fi + break + fi + (( host_port++ )) + done + if (( !bound )); then + echo " Port $port: no available host port found ($port-$((port+max_fallback-1)) all in use)" + fi + done +} diff --git a/lib/w/resolve-account.zsh b/lib/w/resolve-account.zsh new file mode 100644 index 0000000..ae8afc6 --- /dev/null +++ b/lib/w/resolve-account.zsh @@ -0,0 +1,64 @@ +#!/usr/bin/env zsh +# Account resolution for w(). Populates W_ACTIVE_* globals. + +# Resolve which ckipper account to use, then populate: +# W_ACTIVE_ACCOUNT — resolved account name +# W_ACTIVE_CONFIG_DIR — account's claude config directory +# W_ACTIVE_KEYCHAIN_SERVICE — account's keychain service name (may be empty) +# +# Resolution order: +# 1. W_CLI_ACCOUNT (from --account flag) +# 2. Account whose config_dir matches $CLAUDE_CONFIG_DIR (if set) +# 3. Registry default +# +# Returns: 0 on success; 1 if no account found or account not in registry. +_w_resolve_account() { + local candidate + candidate=$(_w_find_account_name) + + if [[ -z "$candidate" ]]; then + echo "Error: no account selected and no default registered." + echo "Run: ckipper list (then: ckipper default , or pass --account )" + return 1 + fi + + local config_dir keychain_service + if [[ -f "$CKIPPER_REGISTRY" ]]; then + config_dir=$(_core_registry_read | jq -r --arg n "$candidate" '.accounts[$n].config_dir // empty' 2>/dev/null) + keychain_service=$(_core_registry_read | jq -r --arg n "$candidate" '.accounts[$n].keychain_service // empty' 2>/dev/null) + fi + + if [[ -z "$config_dir" ]]; then + echo "Error: account '$candidate' is not registered. Run: ckipper list" + return 1 + fi + + W_ACTIVE_ACCOUNT="$candidate" + W_ACTIVE_CONFIG_DIR="$config_dir" + W_ACTIVE_KEYCHAIN_SERVICE="$keychain_service" +} + +# Return the account name to use, without side effects. +# Tries: CLI flag → env match → registry default. +_w_find_account_name() { + if [[ -n "$W_CLI_ACCOUNT" ]]; then + echo "$W_CLI_ACCOUNT" + return 0 + fi + + if [[ -n "$CLAUDE_CONFIG_DIR" && -f "$CKIPPER_REGISTRY" ]]; then + local matched + matched=$(_core_registry_read | jq -r --arg d "$CLAUDE_CONFIG_DIR" \ + '.accounts | to_entries[] | select(.value.config_dir == $d) | .key' \ + | head -1) + [[ -n "$matched" ]] && { echo "$matched"; return 0; } + fi + + if [[ -f "$CKIPPER_REGISTRY" ]]; then + local default + default=$(_core_registry_read | jq -r '.default // ""') + [[ -n "$default" ]] && { echo "$default"; return 0; } + fi + + return 0 +} diff --git a/lib/w/worktree.zsh b/lib/w/worktree.zsh new file mode 100644 index 0000000..f288d00 --- /dev/null +++ b/lib/w/worktree.zsh @@ -0,0 +1,166 @@ +#!/usr/bin/env zsh +# Worktree list, remove, and create operations for w(). + +# Print all worktrees under $W_WORKTREES_DIR, grouped by project. +# +# Reads W_PROJECTS_DIR and W_WORKTREES_DIR globals. +_w_list_worktrees() { + echo "=== All Worktrees ===" + if [[ ! -d "$W_WORKTREES_DIR" ]]; then + return 0 + fi + + local current_project="" + find "$W_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null | sort | while IFS= read -r gitfile; do + local wt_dir="${gitfile:h}" + local rel="${wt_dir#$W_WORKTREES_DIR/}" + local project="${rel%%/*}" + local after_first="${rel#*/}" + if [[ "$after_first" == "$rel" ]]; then + continue + fi + local second="${after_first%%/*}" + local rest="${after_first#*/}" + if [[ -d "$W_PROJECTS_DIR/$project/$second/.git" ]]; then + project="$project/$second" + local branch="$rest" + else + local branch="$after_first" + fi + if [[ "$project" != "$current_project" ]]; then + current_project="$project" + echo "\n[$project]" + fi + echo " • $branch" + done +} + +# Remove a worktree and delete its branch. +# +# Reads W_FLAG_FORCE, W_WORKTREES_DIR, W_PROJECTS_DIR globals. +# Args: $1 = project path, $2 = worktree/branch name. +# Returns: 0 on success; 1 on validation failure or git error. +_w_remove_worktree() { + local project="$1" + local worktree="$2" + + if [[ -z "$project" || -z "$worktree" ]]; then + echo "Usage: w --rm [--force] " + return 1 + fi + + local wt_path="$W_WORKTREES_DIR/$project/$worktree" + if [[ ! -d "$wt_path" ]]; then + echo "Worktree not found: $wt_path" + return 1 + fi + + local force_flag="" + [[ "$W_FLAG_FORCE" = true ]] && force_flag="--force" + + (cd "$W_PROJECTS_DIR/$project" && git worktree remove $force_flag "$wt_path" && git branch -D "$worktree" 2>/dev/null) || { + echo "Failed to remove worktree. Use --force if it has uncommitted changes." + return 1 + } + + local ckipper_base_dir="${CKIPPER_DIR:-$HOME/.ckipper}" + if [[ -f "$ckipper_base_dir/docker/cleanup-projects.py" ]]; then + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + python3 "$ckipper_base_dir/docker/cleanup-projects.py" remove "$wt_path" 2>/dev/null || true + fi +} + +# Create a worktree for the given project and branch (idempotent if it already exists). +# Sets W_WT_PATH to the resolved worktree path. +# +# Reads W_PROJECTS_DIR, W_WORKTREES_DIR, W_ACTIVE_ACCOUNT, W_ACTIVE_CONFIG_DIR globals. +# Args: $1 = project path, $2 = branch name. +# Returns: 0 on success; 1 on validation failure or git error. +_w_create_worktree() { + local project="$1" + local worktree="$2" + + if [[ ! -d "$W_PROJECTS_DIR/$project" ]]; then + echo "Project not found: $W_PROJECTS_DIR/$project" + return 1 + fi + + if [[ -d "$W_WORKTREES_DIR/$project/$worktree" ]]; then + if [[ ! -f "$W_WORKTREES_DIR/$project/$worktree/.git" ]]; then + echo "Error: $W_WORKTREES_DIR/$project/$worktree exists but is not a valid worktree." + echo "Remove it manually or use a different branch name." + return 1 + fi + W_WT_PATH="$W_WORKTREES_DIR/$project/$worktree" + return 0 + fi + + echo "Creating worktree: $worktree" + mkdir -p "$W_WORKTREES_DIR/$project" + W_WT_PATH="$W_WORKTREES_DIR/$project/$worktree" + + _w_fetch_and_create "$project" "$worktree" || return 1 + _w_post_create_setup "$project" "$worktree" || return 1 +} + +# Fetch from origin and create the git worktree. +# Args: $1 = project, $2 = branch/worktree name. +_w_fetch_and_create() { + local project="$1" + local worktree="$2" + + (cd "$W_PROJECTS_DIR/$project" && git fetch origin develop) || { + echo "Failed to fetch from origin. Check your network connection and that 'develop' exists on the remote." + return 1 + } + (cd "$W_PROJECTS_DIR/$project" && git fetch origin "$worktree" 2>/dev/null) || true + + (cd "$W_PROJECTS_DIR/$project" && \ + if git show-ref --verify --quiet "refs/heads/$worktree"; then + echo "Using existing local branch: $worktree" + git worktree add "$W_WT_PATH" "$worktree" + elif git show-ref --verify --quiet "refs/remotes/origin/$worktree"; then + echo "Tracking remote branch: origin/$worktree" + git worktree add "$W_WT_PATH" -b "$worktree" "origin/$worktree" + else + echo "Creating new branch from origin/develop" + git worktree add "$W_WT_PATH" -b "$worktree" origin/develop + fi + ) || { + local current_branch + current_branch=$(cd "$W_PROJECTS_DIR/$project" && git branch --show-current 2>/dev/null) + if [[ "$current_branch" == "$worktree" ]]; then + echo "Failed: branch '$worktree' is currently checked out in the main repo." + echo "Switch the main repo to a different branch first:" + echo " cd $W_PROJECTS_DIR/$project && git checkout develop" + else + echo "Failed to create worktree" + fi + return 1 + } +} + +# Post-worktree-creation: install deps, copy .env files, sync Claude settings. +# Args: $1 = project, $2 = branch/worktree name. +_w_post_create_setup() { + local project="$1" + + echo "Installing dependencies..." + (cd "$W_WT_PATH" && npm install) || echo "Warning: npm install failed. You may need to run it manually." + + for env_file in $(find "$W_PROJECTS_DIR/$project" -maxdepth 3 -name ".env*" -not -name "*.example" -not -path "*/node_modules/*" -not -path "*/.git/*"); do + local rel_path="${env_file#$W_PROJECTS_DIR/$project/}" + local dest_dir="$W_WT_PATH/$(dirname "$rel_path")" + mkdir -p "$dest_dir" + cp "$env_file" "$dest_dir/" + echo "Copied $rel_path" + done + + local main_project_path="$W_PROJECTS_DIR/$project" + local ckipper_base_dir="${CKIPPER_DIR:-$HOME/.ckipper}" + if [[ -f "$ckipper_base_dir/docker/cleanup-projects.py" ]]; then + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + python3 "$ckipper_base_dir/docker/cleanup-projects.py" sync \ + "$W_ACTIVE_ACCOUNT" "$main_project_path" "$W_WT_PATH" 2>/dev/null || true + fi +} diff --git a/w-function.zsh b/w-function.zsh index cc7fe2c..444f48d 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -1,3 +1,4 @@ +#!/usr/bin/env zsh # ── Worktree Manager ────────────────────────────────────────────── # Usage: # w cd to worktree (creates if needed) @@ -21,6 +22,16 @@ # "develop" below if your default branch is different (e.g. main). # ───────────────────────────────────────────────────────────────── +W_REPO_DIR="${0:A:h}" +source "$W_REPO_DIR/ckipper.zsh" +source "$W_REPO_DIR/lib/w/resolve-account.zsh" +source "$W_REPO_DIR/lib/w/build-image.zsh" +source "$W_REPO_DIR/lib/w/args.zsh" +source "$W_REPO_DIR/lib/w/worktree.zsh" +source "$W_REPO_DIR/lib/w/ports.zsh" +source "$W_REPO_DIR/lib/w/docker-mode.zsh" +source "$W_REPO_DIR/lib/w/normal-mode.zsh" + # Source user config (ports, extra volumes, extra env vars) _w_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" if [[ -f "$_w_config" ]]; then @@ -31,465 +42,51 @@ fi (( ${#W_EXTRA_VOLUMES[@]} == 0 )) && W_EXTRA_VOLUMES=() (( ${#W_EXTRA_ENV[@]} == 0 )) && W_EXTRA_ENV=() -_w_build_image() { - local docker_dir="${CKIPPER_DIR:-$HOME/.ckipper}/docker" - if [[ ! -f "$docker_dir/Dockerfile" ]]; then - echo "Dockerfile not found: $docker_dir/Dockerfile" - return 1 - fi - echo "Building ckipper-dev Docker image..." - docker build --build-arg "CACHEBUST=$(date +%s)" -t ckipper-dev "$docker_dir" -} - -_w_resolve_account() { - local cli_account="$1" - if [[ -n "$cli_account" ]]; then - echo "$cli_account"; return 0 - fi - if [[ -n "$CLAUDE_CONFIG_DIR" && -f "$CKIPPER_REGISTRY" ]]; then - local matched - matched=$(jq -r --arg d "$CLAUDE_CONFIG_DIR" \ - '.accounts | to_entries[] | select(.value.config_dir == $d) | .key' \ - "$CKIPPER_REGISTRY" | head -1) - [[ -n "$matched" ]] && { echo "$matched"; return 0; } - fi - if [[ -f "$CKIPPER_REGISTRY" ]]; then - local default - default=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - [[ -n "$default" ]] && { echo "$default"; return 0; } - fi - return 0 -} - w() { - local projects_dir="$HOME/Developer" - local worktrees_dir="$HOME/Developer/.worktrees" + _w_parse_args "$@" - # -- List all worktrees -- - if [[ "$1" == "--list" ]]; then - echo "=== All Worktrees ===" - if [[ -d "$worktrees_dir" ]]; then - local current_project="" - find "$worktrees_dir" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null | sort | while IFS= read -r gitfile; do - local wt_dir="${gitfile:h}" - local rel="${wt_dir#$worktrees_dir/}" - # Extract project (first two path segments) and branch (rest) - local project="${rel%%/*}" - local after_first="${rel#*/}" - if [[ "$after_first" == "$rel" ]]; then - continue # malformed path - fi - # Check if second segment is a sub-project (e.g. Whmoro/orderguard) - local second="${after_first%%/*}" - local rest="${after_first#*/}" - if [[ -d "$projects_dir/$project/$second/.git" ]]; then - project="$project/$second" - local branch="$rest" - else - local branch="$after_first" - fi - if [[ "$project" != "$current_project" ]]; then - current_project="$project" - echo "\n[$project]" - fi - echo " • $branch" - done - fi - return 0 - fi - - # -- Rebuild Docker image -- - if [[ "$1" == "--rebuild-image" ]]; then + if [[ "$W_FLAG_LIST" = true ]]; then + _w_list_worktrees + elif [[ "$W_FLAG_REBUILD_IMAGE" = true ]]; then _w_build_image return $? - fi - - # -- Remove a worktree -- - if [[ "$1" == "--rm" ]]; then - shift - local force_flag="" - if [[ "$1" == "--force" || "$1" == "-f" ]]; then - force_flag="--force" - shift - fi - local project="$1" - local worktree="$2" - if [[ -z "$project" || -z "$worktree" ]]; then - echo "Usage: w --rm [--force] " - return 1 - fi - local wt_path="$worktrees_dir/$project/$worktree" - if [[ ! -d "$wt_path" ]]; then - echo "Worktree not found: $wt_path" - return 1 - fi - (cd "$projects_dir/$project" && git worktree remove $force_flag "$wt_path" && git branch -D "$worktree" 2>/dev/null) || { - echo "Failed to remove worktree. Use --force if it has uncommitted changes." - return 1 - } - # Clean up Claude Code settings for removed worktree across all registered accounts - local _ckipper_dir="${CKIPPER_DIR:-$HOME/.ckipper}" - if [[ -f "$_ckipper_dir/docker/cleanup-projects.py" ]]; then - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - python3 "$_ckipper_dir/docker/cleanup-projects.py" remove "$wt_path" 2>/dev/null || true - fi - return 0 - fi - - # -- Normal usage: w [--docker [--firewall] [cmd...]] [command...] -- - local project="$1" - local worktree="$2" - shift 2 2>/dev/null - - # Parse flags - local docker_mode=0 - local firewall_mode=0 - local cli_account="" - local command=() - while [[ $# -gt 0 ]]; do - case "$1" in - --docker) docker_mode=1; shift ;; - --firewall) firewall_mode=1; shift ;; - --account) cli_account="$2"; shift 2 ;; - *) command+=("$1"); shift ;; - esac - done - - if [[ -z "$project" || -z "$worktree" ]]; then - echo "Usage: w [--docker [--firewall] [cmd...]]" - echo " w [command...]" - echo " w --list" - echo " w --rm " - echo " w --rebuild-image" - echo "" - echo "Flags:" - echo " --docker Run in Docker container (shell by default, or specify command)" - echo " --firewall Add egress firewall (only with --docker)" - echo " --account Use a specific Ckipper account (default: registered default or \$CLAUDE_CONFIG_DIR)" - echo "" - echo "Examples:" - echo " w myorg/app feature --docker # shell in container" - echo " w myorg/app feature --docker claude # Claude in container" - echo " w myorg/app feature --docker --firewall # shell + firewall" - return 1 - fi - - # Validate flag combinations - if [[ $firewall_mode -eq 1 && $docker_mode -eq 0 ]]; then - echo "Error: --firewall requires --docker" - return 1 - fi - - # Resolve active Ckipper account (no legacy fallback — error if none). - local active_account - active_account=$(_w_resolve_account "$cli_account") - if [[ -z "$active_account" ]]; then - echo "Error: no account selected and no default registered." - echo "Run: ckipper list (then: ckipper default , or pass --account )" - return 1 - fi - local active_config_dir - active_config_dir=$(jq -r --arg n "$active_account" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY" 2>/dev/null) - local active_keychain_service - active_keychain_service=$(jq -r --arg n "$active_account" '.accounts[$n].keychain_service // empty' "$CKIPPER_REGISTRY" 2>/dev/null) - if [[ -z "$active_config_dir" ]]; then - echo "Error: account '$active_account' is not registered. Run: ckipper list" - return 1 - fi - - if [[ ! -d "$projects_dir/$project" ]]; then - echo "Project not found: $projects_dir/$project" + elif [[ "$W_FLAG_RM" = true ]]; then + _w_remove_worktree "$W_PROJECT" "$W_BRANCH" + return $? + elif [[ -z "$W_PROJECT" || -z "$W_BRANCH" ]]; then + _w_usage return 1 - fi - - # Find existing worktree - local wt_path="" - if [[ -d "$worktrees_dir/$project/$worktree" ]]; then - if [[ ! -f "$worktrees_dir/$project/$worktree/.git" ]]; then - echo "Error: $worktrees_dir/$project/$worktree exists but is not a valid worktree." - echo "Remove it manually or use a different branch name." - return 1 - fi - wt_path="$worktrees_dir/$project/$worktree" - fi - - # Create if it doesn't exist - if [[ -z "$wt_path" ]]; then - echo "Creating worktree: $worktree" - mkdir -p "$worktrees_dir/$project" - wt_path="$worktrees_dir/$project/$worktree" - # Fetch latest develop + target branch (target may not exist on remote) - (cd "$projects_dir/$project" && git fetch origin develop) || { - echo "Failed to fetch from origin. Check your network connection and that 'develop' exists on the remote." - return 1 - } - (cd "$projects_dir/$project" && git fetch origin "$worktree" 2>/dev/null) || true - # Create the worktree - (cd "$projects_dir/$project" && \ - if git show-ref --verify --quiet "refs/heads/$worktree"; then - echo "Using existing local branch: $worktree" - git worktree add "$wt_path" "$worktree" - elif git show-ref --verify --quiet "refs/remotes/origin/$worktree"; then - echo "Tracking remote branch: origin/$worktree" - git worktree add "$wt_path" -b "$worktree" "origin/$worktree" - else - echo "Creating new branch from origin/develop" - git worktree add "$wt_path" -b "$worktree" origin/develop - fi - ) || { - local current_branch - current_branch=$(cd "$projects_dir/$project" && git branch --show-current 2>/dev/null) - if [[ "$current_branch" == "$worktree" ]]; then - echo "Failed: branch '$worktree' is currently checked out in the main repo." - echo "Switch the main repo to a different branch first:" - echo " cd $projects_dir/$project && git checkout develop" - else - echo "Failed to create worktree" - fi - return 1 - } - echo "Installing dependencies..." - (cd "$wt_path" && npm install) || echo "Warning: npm install failed. You may need to run it manually." - - # Copy .env files from main project (includes .env.local, .env.development, etc. but not .env.example) - for env_file in $(find "$projects_dir/$project" -maxdepth 3 -name ".env*" -not -name "*.example" -not -path "*/node_modules/*" -not -path "*/.git/*"); do - local rel_path="${env_file#$projects_dir/$project/}" - local dest_dir="$wt_path/$(dirname "$rel_path")" - mkdir -p "$dest_dir" - cp "$env_file" "$dest_dir/" - echo "Copied $rel_path" - done - - # Sync Claude Code project settings (disabled MCPs, permissions, etc.) - # for the active account from the main project entry to the new worktree entry. - local main_project_path="$projects_dir/$project" - local _ckipper_dir="${CKIPPER_DIR:-$HOME/.ckipper}" - if [[ -f "$_ckipper_dir/docker/cleanup-projects.py" ]]; then - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - python3 "$_ckipper_dir/docker/cleanup-projects.py" sync \ - "$active_account" "$main_project_path" "$wt_path" 2>/dev/null || true - fi - fi - - # -- Docker mode: run in containerized environment -- - if [[ $docker_mode -eq 1 ]]; then - # Ensure Docker is available - if ! command -v docker &>/dev/null; then - echo "Error: docker is not installed or not in PATH" - return 1 - fi - if ! docker info &>/dev/null 2>&1; then - echo "Error: Docker daemon is not running. Start Docker Desktop first." - return 1 - fi - - # Ensure Docker image exists - if ! docker image inspect ckipper-dev > /dev/null 2>&1; then - _w_build_image || return 1 - fi - - # Ensure per-account .claude.json exists (Docker would mount as directory if missing) - [[ -f "$active_config_dir/.claude.json" ]] || echo '{}' > "$active_config_dir/.claude.json" - - # Extract credentials from macOS Keychain (per-account service name) - if [[ -n "$active_keychain_service" ]] && \ - ! _ckipper_validate_keychain_service "$active_keychain_service"; then - echo "Error: account '$active_account' has invalid keychain_service in registry." - echo "Re-register with: ckipper remove $active_account && ckipper add $active_account --adopt" + else + if [[ "$W_FLAG_FIREWALL" = true && "$W_FLAG_DOCKER" = false ]]; then + echo "Error: --firewall requires --docker" return 1 fi - local claude_creds="" - if [[ -n "$active_keychain_service" ]]; then - claude_creds=$(security find-generic-password -s "$active_keychain_service" -w 2>/dev/null) || true - fi - - # Extract GitHub token for gh CLI auth inside container - # Try the per-account .claude.json MCP config first, then fall back to host's gh CLI auth - local gh_token - gh_token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' "$active_config_dir/.claude.json" 2>/dev/null) || true - if [[ -z "$gh_token" ]] && command -v gh &>/dev/null; then - gh_token=$(gh auth token 2>/dev/null) || true - fi - - local docker_args=( - docker run --rm -it - -e TERM="${TERM:-xterm-256color}" - # Mount worktree as workspace - -v "$wt_path:/workspace:rw" - # Mount main repo .git at same absolute path (resolves worktree .git file) - -v "$projects_dir/$project/.git:$projects_dir/$project/.git:rw" - # Mount per-account Claude config dir at the same host path so plugins' - # absolute-path references (e.g. /Users//.claude-/plugins/...) - # resolve inside the container. Host-vs-container races on .claude.json - # are prevented by the "don't run the same account in two sessions" rule - # (see README #24317 note) — no read-only staging mount needed. - -v "$active_config_dir:$active_config_dir:rw" - -e "CLAUDE_CONFIG_DIR=$active_config_dir" - # Mount SSH config as staging copy (sanitized by entrypoint) - -v "$HOME/.ssh:/home/claude/.ssh-host:ro" - # Forward host's SSH agent (Docker Desktop for Mac). - # Lets the container authenticate using the host's SSH keys - # without copying them. Works with 1Password and macOS Keychain agents. - -v /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock - -e SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock - --group-add 0 # SSH agent socket is root:root 0660; claude user needs group access - # ── Credentials tmpfs ────────────────────────────────── - # Entrypoint writes credentials here instead of the host-mounted - # ~/.claude, so they only exist in container memory. - --tmpfs /tmp/claude-creds:mode=700,uid=1000,gid=1000,size=1m - # ────────────────────────────────────────────────────────── - # ── uvx/uv cache & tools ─────────────────────────────────── - # Named volumes persist Python packages and pre-installed tool - # environments across container restarts. The entrypoint pre-installs - # uvx-based MCP servers into .uv-tools/ so they start instantly. - -v "claude-uv-cache:/home/claude/.cache/uv" - -v "claude-uv-tools:/home/claude/.uv-tools" - -e "UV_TOOL_DIR=/home/claude/.uv-tools/envs" - -e "UV_TOOL_BIN_DIR=/home/claude/.uv-tools/bin" - -e "UV_PYTHON_INSTALL_DIR=/home/claude/.uv-tools/python" - # ────────────────────────────────────────────────────────── - ) - - # Add user-configured extra volumes from w-config.zsh - for vol in "${W_EXTRA_VOLUMES[@]}"; do - docker_args+=( -v "$vol" ) - done - - # Pass Keychain credentials to container - if [[ -n "$claude_creds" ]]; then - docker_args+=( -e "CLAUDE_CREDENTIALS=$claude_creds" ) + _w_resolve_account || return $? + _w_create_worktree "$W_PROJECT" "$W_BRANCH" || return $? + if [[ "$W_FLAG_DOCKER" = true ]]; then + _w_run_docker_mode else - echo " Warning: Could not extract Claude credentials from Keychain" + _w_run_normal_mode fi - - # Pass GitHub token for gh CLI auth - if [[ -n "$gh_token" ]]; then - docker_args+=( -e "GH_TOKEN=$gh_token" ) - else - echo " Warning: No GitHub token found (gh commands won't work in container)" - fi - - # Add user-configured extra env vars from w-config.zsh - for env_var in "${W_EXTRA_ENV[@]}"; do - docker_args+=( -e "$env_var" ) - done - - # Port forwarding for dev servers (try fallback host ports if taken) - local -a ports=("${W_PORTS[@]}") - local max_fallback=10 # try up to 10 alternative host ports - for port in "${ports[@]}"; do - local host_port=$port - local bound=0 - for (( i=0; i/dev/null; then - docker_args+=( -p "127.0.0.1:$host_port:$port" ) - bound=1 - if (( host_port != port )); then - echo " Port $port mapped to host:$host_port (original in use)" - fi - break - fi - (( host_port++ )) - done - if (( !bound )); then - echo " Port $port: no available host port found ($port-$((port+max_fallback-1)) all in use)" - fi - done - - # Add firewall capability if requested - if [[ $firewall_mode -eq 1 ]]; then - docker_args+=( --cap-add=NET_ADMIN -e ENABLE_FIREWALL=1 ) - fi - - docker_args+=( ckipper-dev ) - - # If "claude" is the command, expand it to the full skip-permissions invocation - # and auto-name the session after the worktree branch - if [[ ${#command[@]} -gt 0 && "${command[1]}" == "claude" ]]; then - command=(claude --dangerously-skip-permissions "/rename $worktree") - fi - - # Pass command to container (if any) - if [[ ${#command[@]} -gt 0 ]]; then - docker_args+=( "${command[@]}" ) - fi - - local mode_label="Docker" - [[ ${#command[@]} -gt 0 ]] && mode_label+=": ${command[1]}" - [[ $firewall_mode -eq 1 ]] && mode_label+=", firewall" - echo "Starting $mode_label..." - echo " Worktree: $wt_path" - echo " Ports: ${ports[*]}" - - # Snapshot .git/config before session (detect tampering after) - local git_config="$projects_dir/$project/.git/config" - local git_config_hash="" - [[ -f "$git_config" ]] && git_config_hash=$(shasum -a 256 "$git_config" | cut -d' ' -f1) - - # Snapshot worktree metadata before session (detect pruning damage) - local git_worktrees_dir="$projects_dir/$project/.git/worktrees" - local -a worktrees_before=() - if [[ -d "$git_worktrees_dir" ]]; then - worktrees_before=( "$git_worktrees_dir"/*(N/:t) ) - fi - - "${docker_args[@]}" - local exit_code=$? - - # Post-session: warn if .git/config was modified - if [[ -n "$git_config_hash" && -f "$git_config" ]]; then - local new_hash=$(shasum -a 256 "$git_config" | cut -d' ' -f1) - if [[ "$git_config_hash" != "$new_hash" ]]; then - echo "" - echo "WARNING: .git/config was modified during the Docker session!" - echo "Review changes: git -C $projects_dir/$project config --local --list" - fi - fi - - # Post-session: check if any worktree metadata was destroyed - if [[ ${#worktrees_before[@]} -gt 0 ]]; then - local -a worktrees_after=() - if [[ -d "$git_worktrees_dir" ]]; then - worktrees_after=( "$git_worktrees_dir"/*(N/:t) ) - fi - local -a missing=() - for wt in "${worktrees_before[@]}"; do - if [[ ! " ${worktrees_after[*]} " =~ " $wt " ]]; then - missing+=("$wt") - fi - done - if [[ ${#missing[@]} -gt 0 ]]; then - echo "" - echo "CRITICAL: ${#missing[@]} worktree(s) had metadata destroyed during the Docker session!" - echo "Missing worktrees: ${missing[*]}" - echo "" - echo "The working directories still exist on disk — only the .git/worktrees/ metadata was deleted." - echo "To recover, re-register each worktree:" - echo " cd $projects_dir/$project" - for wt in "${missing[@]}"; do - echo " git worktree add $wt" - done - fi - fi - - return $exit_code fi +} - # -- Normal mode: execute command or cd -- - if [[ ${#command[@]} -eq 0 ]]; then - cd "$wt_path" - else - # If command is "claude", auto-name the session after the worktree branch - if [[ "${command[1]}" == "claude" ]]; then - command+=("/rename $worktree") - fi - local old_pwd="$PWD" - cd "$wt_path" - "${command[@]}" - local exit_code=$? - cd "$old_pwd" - return $exit_code - fi +_w_usage() { + echo "Usage: w [--docker [--firewall] [cmd...]]" + echo " w [command...]" + echo " w --list" + echo " w --rm " + echo " w --rebuild-image" + echo "" + echo "Flags:" + echo " --docker Run in Docker container (shell by default, or specify command)" + echo " --firewall Add egress firewall (only with --docker)" + echo " --account Use a specific Ckipper account (default: registered default or \$CLAUDE_CONFIG_DIR)" + echo "" + echo "Examples:" + echo " w myorg/app feature --docker # shell in container" + echo " w myorg/app feature --docker claude # Claude in container" + echo " w myorg/app feature --docker --firewall # shell + firewall" } # -- Tab completion for w -- @@ -564,7 +161,3 @@ _w() { _w "$@" COMPEOF fi - -# Source ckipper subcommand dispatcher (if deployed) -[[ -f "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" ]] && \ - source "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper.zsh" From a15ce1700006e6c6f44c4bf8210305e02ef81c0e Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:28:00 -0600 Subject: [PATCH 044/165] Phase 2.5: install.sh deploys lib/ tree (excludes test files) --- install.sh | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index d0c6101..0d52ea9 100755 --- a/install.sh +++ b/install.sh @@ -75,11 +75,35 @@ chmod +x "$CKIPPER_DIR/hooks/bash-guardrails.sh" chmod +x "$CKIPPER_DIR/hooks/docker-context.sh" chmod +x "$CKIPPER_DIR/hooks/notify-bell.sh" -# 4. Copy w-function.zsh and ckipper.zsh -echo "Copying w-function.zsh and ckipper.zsh to $CKIPPER_DIR/docker/..." +# 4. Copy w-function.zsh, ckipper.zsh, and the lib/ tree. +echo "Copying w-function.zsh, ckipper.zsh, and lib/ to $CKIPPER_DIR/docker/..." cp "$REPO_DIR/w-function.zsh" "$CKIPPER_DIR/docker/" cp "$REPO_DIR/ckipper.zsh" "$CKIPPER_DIR/docker/" +# Deploy lib/ tree, EXCLUDING test files (*_test.bats, *_test.py). +# Tests must NOT ship to user installs: +# - they're noise in the runtime tree +# - test stubs in tests/lib/stubs/ would appear as binaries on PATH if accidentally exposed +if command -v rsync >/dev/null 2>&1; then + rsync -a --delete \ + --exclude='*_test.bats' \ + --exclude='*_test.py' \ + --exclude='__pycache__' \ + "$REPO_DIR/lib/" "$CKIPPER_DIR/docker/lib/" +else + # Fallback: tar pipe with excludes (no rsync available). + rm -rf "$CKIPPER_DIR/docker/lib" + (cd "$REPO_DIR" && tar -cf - --exclude='*_test.bats' --exclude='*_test.py' --exclude='__pycache__' lib) | + (cd "$CKIPPER_DIR/docker" && tar -xf -) +fi + +# Defense in depth: verify no test files leaked into the install. +if find "$CKIPPER_DIR/docker/lib" \( -name '*_test.*' -o -name '__pycache__' \) 2>/dev/null | grep -q .; then + echo "ERROR: test files leaked into $CKIPPER_DIR/docker/lib/" >&2 + find "$CKIPPER_DIR/docker/lib" \( -name '*_test.*' -o -name '__pycache__' \) >&2 + exit 1 +fi + # 5. Generate w-config.zsh (only if it doesn't exist — never overwrite user customizations) # Also preserve accounts.json and aliases.zsh if they already exist (managed by ckipper CLI). config_file="$CKIPPER_DIR/docker/w-config.zsh" From 7bb60fa01a685b0cd61f9bb45eb3855d669e3212 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:35:28 -0600 Subject: [PATCH 045/165] Phase 4 (Track C): Python refactor + hooks fail-closed + entrypoint/firewall security fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cleanup-projects.py: refactor sync_worktree_settings from 33 lines/4 params/nesting-3 to single-param context dict; extract _load_settings, _merge_settings_keys, _find_account_config, _write_settings helpers; all functions ≤25 lines/≤2 nesting; add argv bounds checks and per-command validation; remove D103 baseline ignore - hooks/protect-claude-config.sh: fail-closed on jq parse error (exit 2) - hooks/bash-guardrails.sh: fail-closed on jq parse error; add security-boundary caveat comment - docker/entrypoint.sh: replace echo with printf for credentials write (no trailing newline); add 1MB bounds check on CLAUDE_CREDENTIALS; extract CREDENTIALS_MAX_BYTES and GIT_CONFIG_COUNT constants - docker/init-firewall.sh: validate DNS_SERVER is IPv4; validate GitHub CIDR ranges non-empty; post-check rule count ≥5; quote $ips in echo; extract FIREWALL_VERIFY_TIMEOUT and FIREWALL_MIN_ACCEPT_RULES constants - docker/Dockerfile: split all curl|bash patterns into download+execute+cleanup steps with SHA256 TODO - install.sh: rename missing → missing_dependencies for clarity --- docker/Dockerfile | 15 ++- docker/cleanup-projects.py | 193 +++++++++++++++++++++++++-------- docker/entrypoint.sh | 12 +- docker/init-firewall.sh | 27 ++++- hooks/bash-guardrails.sh | 15 ++- hooks/protect-claude-config.sh | 7 +- install.sh | 14 +-- pyproject.toml | 2 - 8 files changed, 217 insertions(+), 68 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2568604..7450a5e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -37,13 +37,19 @@ ENV CHROMIUM_FLAGS="--no-sandbox" ENV PUPPETEER_CHROMIUM_ARGS="--no-sandbox" # Install uv/uvx (Python package runner — needed for Python-based MCP servers) -RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ +# TODO(public-release): pin SHA256 checksum of installer. +RUN curl -LsSf https://astral.sh/uv/install.sh -o /tmp/install.sh && \ + sh /tmp/install.sh && \ + rm /tmp/install.sh \ && cp /root/.local/bin/uv /usr/local/bin/uv \ && cp /root/.local/bin/uvx /usr/local/bin/uvx \ && chmod +x /usr/local/bin/uv /usr/local/bin/uvx # Install Claude Code via native installer -RUN curl -fsSL https://claude.ai/install.sh | bash \ +# TODO(public-release): pin SHA256 checksum of installer. +RUN curl -fsSL https://claude.ai/install.sh -o /tmp/install.sh && \ + bash /tmp/install.sh && \ + rm /tmp/install.sh \ && cp /root/.local/bin/claude /usr/local/bin/claude \ && chmod +x /usr/local/bin/claude @@ -58,7 +64,10 @@ RUN userdel -r node 2>/dev/null; groupdel node 2>/dev/null; \ && chown -R claude:claude /home/claude/.local /home/claude/.ssh /home/claude/.config /home/claude/.cache /home/claude/.uv-tools # Install bun (fast JS runtime — needed for bunx-based statusline commands and general use) -RUN curl -fsSL https://bun.sh/install | bash \ +# TODO(public-release): pin SHA256 checksum of installer. +RUN curl -fsSL https://bun.sh/install -o /tmp/install.sh && \ + bash /tmp/install.sh && \ + rm /tmp/install.sh \ && cp /root/.bun/bin/bun /usr/local/bin/bun \ && cp /root/.bun/bin/bunx /usr/local/bin/bunx \ && chmod +x /usr/local/bin/bun /usr/local/bin/bunx diff --git a/docker/cleanup-projects.py b/docker/cleanup-projects.py index a07519f..9c94df1 100755 --- a/docker/cleanup-projects.py +++ b/docker/cleanup-projects.py @@ -1,76 +1,175 @@ #!/usr/bin/env python3 -"""Remove or sync a worktree path entry from per-account .claude.json files.""" +"""Cleanup helpers invoked from ckipper.zsh inside the container.""" + import json import os import sys +CONFIG_DIR_KEY = "config_dir" +CLAUDE_JSON_FILENAME = ".claude.json" +PROJECTS_KEY = "projects" + +SETTINGS_KEYS_TO_SYNC = [ + "disabledMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "allowedTools", + "hasTrustDialogAccepted", + "hasClaudeMdExternalIncludesApproved", + "hasClaudeMdExternalIncludesWarningShown", + "hasCompletedProjectOnboarding", +] + + +def all_account_dirs(registry_path: str) -> list: + """List config_dir entries for every registered account. -def all_account_dirs(registry): - if not os.path.exists(registry): + Args: + registry_path: Absolute path to accounts.json. + + Returns: + List of config_dir strings; empty if registry is missing. + """ + if not os.path.exists(registry_path): return [] - with open(registry) as f: - d = json.load(f) - return [a["config_dir"] for a in d.get("accounts", {}).values() if a.get("config_dir")] + with open(registry_path) as f: + data = json.load(f) + return [ + account[CONFIG_DIR_KEY] + for account in data.get("accounts", {}).values() + if account.get(CONFIG_DIR_KEY) + ] + + +def _load_settings(claude_json_path: str) -> dict: + """Load .claude.json. Returns empty dict if missing or unparseable.""" + if not os.path.exists(claude_json_path): + return {} + try: + with open(claude_json_path) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + +def _merge_settings_keys(target: dict, source: dict, keys: list) -> None: + """Copy specified keys from source dict into target (in place). + + Args: + target: Dict to copy keys into. + source: Dict to copy keys from. + keys: List of key names to copy. + """ + for key in keys: + if key in source: + target[key] = source[key] + + +def _write_settings(claude_json_path: str, data: dict) -> None: + """Write settings dict to .claude.json. + + Args: + claude_json_path: Absolute path to .claude.json. + data: Settings dict to write. + """ + with open(claude_json_path, "w") as f: + json.dump(data, f) + +def remove_worktree_from_all(registry_path: str, worktree_path: str) -> None: + """Strip the given worktree's project entry from every account's .claude.json. -def remove_worktree_from_all(registry, wt_path): + Args: + registry_path: Path to accounts.json. + worktree_path: Absolute worktree path to remove. + """ seen = set() - for cfg_dir in all_account_dirs(registry): - cfg = os.path.join(cfg_dir, ".claude.json") + for cfg_dir in all_account_dirs(registry_path): + cfg = os.path.join(cfg_dir, CLAUDE_JSON_FILENAME) cfg_real = os.path.realpath(cfg) if cfg_real in seen: continue seen.add(cfg_real) - if not os.path.exists(cfg): + data = _load_settings(cfg) + if not data: continue - with open(cfg) as f: - d = json.load(f) - if wt_path in d.get("projects", {}): - del d["projects"][wt_path] - with open(cfg, "w") as f: - json.dump(d, f) + if worktree_path in data.get(PROJECTS_KEY, {}): + del data[PROJECTS_KEY][worktree_path] + _write_settings(cfg, data) print(f"Removed worktree entry from {cfg}") -def sync_worktree_settings(registry, account_name, main_path, wt_path): - if not os.path.exists(registry): - return - with open(registry) as f: - d = json.load(f) - acc = d.get("accounts", {}).get(account_name) - if not acc: +def _find_account_config(registry_path: str, account_name: str) -> str: + """Return path to .claude.json for the given account, or empty string. + + Args: + registry_path: Path to accounts.json. + account_name: Name of the account to look up. + + Returns: + Absolute path to .claude.json, or empty string if not found. + """ + if not os.path.exists(registry_path): + return "" + with open(registry_path) as f: + data = json.load(f) + account = data.get("accounts", {}).get(account_name) + if not account: + return "" + return os.path.join(account[CONFIG_DIR_KEY], CLAUDE_JSON_FILENAME) + + +def sync_worktree_settings(context: dict) -> None: + """Sync settings between main repo and a worktree. + + Args: + context: Dict with keys 'registry_path', 'account_name', + 'main_path', 'worktree_path'. + + Raises: + KeyError: If context is missing required keys. + """ + registry_path = context["registry_path"] + account_name = context["account_name"] + main_path = context["main_path"] + worktree_path = context["worktree_path"] + + cfg = _find_account_config(registry_path, account_name) + if not cfg: return - cfg = os.path.join(acc["config_dir"], ".claude.json") - if not os.path.exists(cfg): + + settings = _load_settings(cfg) + if not settings: return - with open(cfg) as f: - cd = json.load(f) - main = cd.get("projects", {}).get(main_path, {}) - if not main: + + main_project = settings.get(PROJECTS_KEY, {}).get(main_path, {}) + if not main_project: return - keys = [ - "disabledMcpServers", - "enabledMcpjsonServers", - "disabledMcpjsonServers", - "allowedTools", - "hasTrustDialogAccepted", - "hasClaudeMdExternalIncludesApproved", - "hasClaudeMdExternalIncludesWarningShown", - "hasCompletedProjectOnboarding", - ] - wt = cd.setdefault("projects", {}).setdefault(wt_path, {}) - for k in keys: - if k in main: - wt[k] = main[k] - with open(cfg, "w") as f: - json.dump(cd, f) - print(f"Synced settings for {wt_path} in {cfg}") + + wt_project = settings.setdefault(PROJECTS_KEY, {}).setdefault(worktree_path, {}) + _merge_settings_keys(wt_project, main_project, SETTINGS_KEYS_TO_SYNC) + _write_settings(cfg, settings) + print(f"Synced settings for {worktree_path} in {cfg}") if __name__ == "__main__": + if len(sys.argv) < 3: + sys.exit("Usage: cleanup-projects.py [arg2] [arg3]") + cmd = sys.argv[1] registry = os.environ.get("CKIPPER_REGISTRY", os.path.expanduser("~/.ckipper/accounts.json")) + if cmd == "remove": remove_worktree_from_all(registry, sys.argv[2]) elif cmd == "sync": - sync_worktree_settings(registry, sys.argv[2], sys.argv[3], sys.argv[4]) + if len(sys.argv) < 5: + sys.exit("Usage: cleanup-projects.py sync ") + context = { + "registry_path": registry, + "account_name": sys.argv[2], + "main_path": sys.argv[3], + "worktree_path": sys.argv[4], + } + sync_worktree_settings(context) + else: + sys.exit(f"Unknown command: {cmd}") diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b3b69a5..e2205be 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,10 @@ #!/bin/bash set -e +# Constants +readonly CREDENTIALS_MAX_BYTES=1000000 +readonly GIT_CONFIG_COUNT=2 + # Require CLAUDE_CONFIG_DIR — Ckipper's account context. No silent fallback. if [ -z "$CLAUDE_CONFIG_DIR" ]; then echo "Error: CLAUDE_CONFIG_DIR is not set inside the container." >&2 @@ -36,8 +40,12 @@ fi # leakage to the host filesystem). The tmpfs mount at /tmp/claude-creds is # container-local and disappears when the container exits. if [ -n "$CLAUDE_CREDENTIALS" ]; then + if [ "${#CLAUDE_CREDENTIALS}" -gt "$CREDENTIALS_MAX_BYTES" ]; then + echo "Error: CLAUDE_CREDENTIALS exceeds 1MB; refusing to write" >&2 + exit 1 + fi mkdir -p /tmp/claude-creds - echo "$CLAUDE_CREDENTIALS" >/tmp/claude-creds/.credentials.json + printf '%s' "$CLAUDE_CREDENTIALS" >/tmp/claude-creds/.credentials.json chmod 700 /tmp/claude-creds chmod 600 /tmp/claude-creds/.credentials.json # Symlink from the account dir — Claude Code reads $CLAUDE_CONFIG_DIR/.credentials.json @@ -56,7 +64,7 @@ fi # Uses GIT_CONFIG_COUNT instead of git config so we never modify the host's # .git/config (mounted rw). Env vars take highest priority, overriding both # local and global config, and disappear when the container exits. -export GIT_CONFIG_COUNT=2 +export GIT_CONFIG_COUNT=$GIT_CONFIG_COUNT export GIT_CONFIG_KEY_0=commit.gpgsign export GIT_CONFIG_VALUE_0=false export GIT_CONFIG_KEY_1=tag.gpgsign diff --git a/docker/init-firewall.sh b/docker/init-firewall.sh index cbe2795..4868a02 100755 --- a/docker/init-firewall.sh +++ b/docker/init-firewall.sh @@ -7,6 +7,10 @@ set -e # Must run as root (via sudo from entrypoint.sh). # Uses iptables-legacy because Docker Desktop's VM doesn't support nf_tables. +# Constants +readonly FIREWALL_VERIFY_TIMEOUT=5 +readonly FIREWALL_MIN_ACCEPT_RULES=5 + # Whitelisted domains — edit this list to add/remove allowed destinations ALLOWED_DOMAINS=( # Claude / Anthropic @@ -45,6 +49,12 @@ echo "=== Configuring egress firewall ===" DNS_SERVER=$(grep '^nameserver' /etc/resolv.conf | head -1 | awk '{print $2}') echo " DNS server: $DNS_SERVER" +# Validate DNS server is a proper IPv4 address +if [[ ! $DNS_SERVER =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: invalid DNS server '$DNS_SERVER'" >&2 + exit 1 +fi + # Flush existing rules iptables-legacy -F OUTPUT 2>/dev/null || true @@ -65,12 +75,16 @@ for domain in "${ALLOWED_DOMAINS[@]}"; do for ip in $ips; do iptables-legacy -A OUTPUT -d "$ip" -j ACCEPT 2>/dev/null && ((ip_count++)) || true done - echo " Allowed: $domain ($(echo $ips | tr '\n' ' '))" + echo " Allowed: $domain ($(echo "$ips" | tr '\n' ' '))" done # Fetch GitHub IP ranges dynamically (CIDR blocks — iptables handles them natively) echo " Fetching GitHub IP ranges..." -gh_ranges=$(curl -s https://api.github.com/meta 2>/dev/null | jq -r '.git[],.api[],.web[]' 2>/dev/null || true) +gh_ranges=$(curl -fsSL https://api.github.com/meta | jq -r '.git[],.api[],.web[]' | grep -E '^[0-9.]+/[0-9]+$') +if [ -z "$gh_ranges" ]; then + echo "Error: GitHub API returned no valid CIDR ranges" >&2 + exit 1 +fi for cidr in $gh_ranges; do iptables-legacy -A OUTPUT -d "$cidr" -j ACCEPT 2>/dev/null && ((ip_count++)) || true done @@ -80,9 +94,16 @@ iptables-legacy -P OUTPUT DROP echo "=== Firewall active: $ip_count rules added ===" +# Post-check: verify expected number of ACCEPT rules were installed +rule_count=$(iptables-legacy -L OUTPUT -n | grep -c ACCEPT) +if [ "$rule_count" -lt "$FIREWALL_MIN_ACCEPT_RULES" ]; then + echo "Error: only $rule_count ACCEPT rules — firewall appears inactive" >&2 + exit 1 +fi + # Verification echo "=== Verifying firewall ===" -if curl -s --max-time 5 https://api.anthropic.com >/dev/null 2>&1; then +if curl -s --max-time "$FIREWALL_VERIFY_TIMEOUT" https://api.anthropic.com >/dev/null 2>&1; then echo " ok api.anthropic.com: reachable" else echo " FAIL api.anthropic.com: BLOCKED (this is a problem)" diff --git a/hooks/bash-guardrails.sh b/hooks/bash-guardrails.sh index c2530d1..2b4618c 100755 --- a/hooks/bash-guardrails.sh +++ b/hooks/bash-guardrails.sh @@ -3,11 +3,22 @@ # Catches accidental destructive commands. Not adversarial-proof, but # prevents the most common "oops" scenarios. # No-op on the host. +# +# NOTE: This hook is a UX guardrail, NOT a security boundary. The pattern matching +# is best-effort and can be bypassed by: +# - compound commands: bash -c 'rm -rf /path' +# - heredocs: cat <<'EOF' > target ... +# - command substitution / eval / dynamic strings +# Adversarial users can defeat any regex-based guard. Treat this hook as a reminder, +# not a defense. The container sandbox + firewall are the actual security boundary. [ ! -f /.dockerenv ] && exit 0 -INPUT=$(cat) -CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') +INPUT="$(cat)" +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') || { + echo "Error: hook input is not valid JSON; failing closed" >&2 + exit 2 +} # Normalize: collapse whitespace, strip leading sudo NORMALIZED=$(echo "$CMD" | sed 's/^[[:space:]]*sudo[[:space:]]*//' | tr -s ' ') diff --git a/hooks/protect-claude-config.sh b/hooks/protect-claude-config.sh index bf29585..35f9c11 100755 --- a/hooks/protect-claude-config.sh +++ b/hooks/protect-claude-config.sh @@ -7,8 +7,11 @@ # Skip protection when not in Docker [ ! -f /.dockerenv ] && exit 0 -INPUT=$(cat) -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +INPUT="$(cat)" +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') || { + echo "Error: hook input is not valid JSON; failing closed" >&2 + exit 2 +} # Block Claude state subset under ~/.claude or any per-account ~/.claude-. # Note: ~/.claude-host.json is the read-only staging mount and is intentionally diff --git a/install.sh b/install.sh index 0d52ea9..167698d 100755 --- a/install.sh +++ b/install.sh @@ -32,17 +32,17 @@ fi # 1. Check prerequisites echo "Checking prerequisites..." -missing=() -command -v docker &>/dev/null || missing+=("docker (install Docker Desktop)") -command -v jq &>/dev/null || missing+=("jq (brew install jq)") -command -v git &>/dev/null || missing+=("git") +missing_dependencies=() +command -v docker &>/dev/null || missing_dependencies+=("docker (install Docker Desktop)") +command -v jq &>/dev/null || missing_dependencies+=("jq (brew install jq)") +command -v git &>/dev/null || missing_dependencies+=("git") if [[ "$(uname)" == "Darwin" ]]; then - command -v security &>/dev/null || missing+=("security (macOS Keychain CLI)") + command -v security &>/dev/null || missing_dependencies+=("security (macOS Keychain CLI)") fi -if [[ ${#missing[@]} -gt 0 ]]; then +if [[ ${#missing_dependencies[@]} -gt 0 ]]; then echo "Missing prerequisites:" - for dep in "${missing[@]}"; do + for dep in "${missing_dependencies[@]}"; do echo " - $dep" done echo "" diff --git a/pyproject.toml b/pyproject.toml index fc2ea67..c2c15e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,6 @@ select = ["E", "F", "B", "D"] ignore = [ "D203", # incompatible with D211 (no blank line before class docstring) "D213", # incompatible with D212 (multi-line docstring summary on first line) - # TODO(phase-4): remove after Task 4.18 adds docstrings to cleanup-projects.py. - "D103", # D100 (missing docstring in public module): cleanup-projects.py is a top-level CLI tool # invoked by ckipper, not an importable library. Module docstring optional. "D100", From 1a2e6b4b9beb7949ab93ffc3f480dfcbd4fcb26f Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:36:49 -0600 Subject: [PATCH 046/165] =?UTF-8?q?Phase=204=20(Track=20B):=20refactor=20l?= =?UTF-8?q?ib/w/=20=E2=80=94=20caps,=20constants,=20naming,=20MED=20securi?= =?UTF-8?q?ty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - args.zsh: split _w_parse_args (42→14 lines) into _w_reset_globals, _w_parse_rm_args, _w_parse_run_args; add doc-headers - worktree.zsh: split _w_list_worktrees (27→8 lines) into _w_print_worktree_entry + _w_resolve_project_and_branch; split _w_fetch_and_create (28→4 lines) into _w_fetch_origin + _w_add_worktree + _w_handle_worktree_add_failure; extract _w_cleanup_project_registry and _w_sync_project_registry; rename current_project → previous_project_for_grouping, gitfile → git_metadata_file; add W_FIND_MAX_DEPTH constant; add doc-headers on all helpers - ports.zsh: extract MAX_PORT_FALLBACK_ATTEMPTS=10; split _w_resolve_ports into _w_bind_port; add doc-headers - docker-mode.zsh: extract SHASUM_BITS=256; replace shasum -a 256 literals; add MED security fix — _w_docker_extract_credentials now validates Keychain creds parse as JSON via jq -e empty, returns 1 on failure; add doc-headers on all helpers - resolve-account.zsh: add formal doc-header to _w_find_account_name - w-function.zsh: add doc-header to _w_usage All 28 bats tests pass; lint clean; no sibling cross-imports. --- lib/w/args.zsh | 67 +++++++---- lib/w/docker-mode.zsh | 82 +++++++++++-- lib/w/ports.zsh | 45 ++++--- lib/w/resolve-account.zsh | 8 +- lib/w/worktree.zsh | 245 ++++++++++++++++++++++++++++++-------- w-function.zsh | 3 + 6 files changed, 360 insertions(+), 90 deletions(-) diff --git a/lib/w/args.zsh b/lib/w/args.zsh index 11f0587..c3ce19e 100644 --- a/lib/w/args.zsh +++ b/lib/w/args.zsh @@ -7,7 +7,7 @@ # W_FLAG_LIST — true if --list # W_FLAG_REBUILD_IMAGE — true if --rebuild-image # W_FLAG_RM — true if --rm -# W_FLAG_FORCE — --force flag for --rm +# W_FLAG_FORCE — true if --force (with --rm) # W_FLAG_DOCKER — true if --docker # W_FLAG_FIREWALL — true if --firewall # W_PROJECT — first positional arg (project path) @@ -19,6 +19,30 @@ # # Returns: 0 always (validation is done by the dispatcher). _w_parse_args() { + _w_reset_globals + + if [[ "$1" == "--list" ]]; then + W_FLAG_LIST=true + return 0 + fi + + if [[ "$1" == "--rebuild-image" ]]; then + W_FLAG_REBUILD_IMAGE=true + return 0 + fi + + if [[ "$1" == "--rm" ]]; then + _w_parse_rm_args "$@" + return 0 + fi + + _w_parse_run_args "$@" +} + +# Reset all W_* globals to their default values. +# +# Returns: 0 always. +_w_reset_globals() { W_FLAG_LIST=false W_FLAG_REBUILD_IMAGE=false W_FLAG_RM=false @@ -31,29 +55,32 @@ _w_parse_args() { W_COMMAND=() W_PROJECTS_DIR="$HOME/Developer" W_WORKTREES_DIR="$HOME/Developer/.worktrees" +} - if [[ "$1" == "--list" ]]; then - W_FLAG_LIST=true - return 0 - fi - - if [[ "$1" == "--rebuild-image" ]]; then - W_FLAG_REBUILD_IMAGE=true - return 0 - fi - - if [[ "$1" == "--rm" ]]; then - W_FLAG_RM=true +# Parse --rm [--force] args. +# +# Args: +# $@ — original args starting with --rm +# +# Returns: 0 always. +_w_parse_rm_args() { + W_FLAG_RM=true + shift + if [[ "$1" == "--force" || "$1" == "-f" ]]; then + W_FLAG_FORCE=true shift - if [[ "$1" == "--force" || "$1" == "-f" ]]; then - W_FLAG_FORCE=true - shift - fi - W_PROJECT="$1" - W_BRANCH="$2" - return 0 fi + W_PROJECT="$1" + W_BRANCH="$2" +} +# Parse the normal run args: project branch [flags...] [command...]. +# +# Args: +# $@ — original args (project is $1, branch is $2) +# +# Returns: 0 always. +_w_parse_run_args() { W_PROJECT="$1" W_BRANCH="$2" shift 2 2>/dev/null diff --git a/lib/w/docker-mode.zsh b/lib/w/docker-mode.zsh index 302e2ce..942c16a 100644 --- a/lib/w/docker-mode.zsh +++ b/lib/w/docker-mode.zsh @@ -1,6 +1,8 @@ #!/usr/bin/env zsh # Docker mode execution for w(). Builds docker run args and launches the container. +readonly SHASUM_BITS=256 + # Run the worktree in a Docker container. # # Reads globals: W_WT_PATH, W_PROJECTS_DIR, W_PROJECT, W_BRANCH, W_COMMAND, @@ -15,7 +17,7 @@ _w_run_docker_mode() { _w_docker_validate_keychain || return 1 local claude_creds gh_token - claude_creds=$(_w_docker_extract_credentials) + claude_creds=$(_w_docker_extract_credentials) || return 1 gh_token=$(_w_docker_extract_gh_token) local -a W_DOCKER_ARGS @@ -32,6 +34,11 @@ _w_run_docker_mode() { } # Validate Docker is installed and daemon is running. +# +# Returns: 0 if docker is available and running; 1 otherwise. +# Errors (stderr): +# "Error: docker is not installed or not in PATH" — when docker binary is missing +# "Error: Docker daemon is not running. Start Docker Desktop first." — when daemon is down _w_docker_check_prerequisites() { if ! command -v docker &>/dev/null; then echo "Error: docker is not installed or not in PATH" @@ -47,6 +54,11 @@ _w_docker_check_prerequisites() { } # Validate the active account's keychain service name if set. +# +# Reads: W_ACTIVE_KEYCHAIN_SERVICE, W_ACTIVE_ACCOUNT globals. +# Returns: 0 if valid or no keychain service is configured; 1 on invalid service. +# Errors (stderr): +# "Error: account '' has invalid keychain_service in registry." — on validation failure _w_docker_validate_keychain() { if [[ -n "$W_ACTIVE_KEYCHAIN_SERVICE" ]] && \ ! _core_keychain_validate "$W_ACTIVE_KEYCHAIN_SERVICE"; then @@ -56,16 +68,39 @@ _w_docker_validate_keychain() { fi } -# Extract Claude credentials from macOS Keychain (prints to stdout). +# Extract Claude credentials from macOS Keychain and validate they are valid JSON. +# +# Reads: W_ACTIVE_KEYCHAIN_SERVICE global. +# Returns: 0 on success (prints credentials to stdout, or empty string if no service). +# 1 if credentials are present but not valid JSON. +# Errors (stderr): +# "Error: Claude credentials from Keychain are not valid JSON. ..." — on invalid JSON _w_docker_extract_credentials() { - local creds="" - if [[ -n "$W_ACTIVE_KEYCHAIN_SERVICE" ]]; then - creds=$(security find-generic-password -s "$W_ACTIVE_KEYCHAIN_SERVICE" -w 2>/dev/null) || true + if [[ -z "$W_ACTIVE_KEYCHAIN_SERVICE" ]]; then + echo "" + return 0 + fi + + local creds + creds=$(security find-generic-password -s "$W_ACTIVE_KEYCHAIN_SERVICE" -w 2>/dev/null) || true + + if [[ -z "$creds" ]]; then + echo "" + return 0 + fi + + if ! echo "$creds" | jq -e empty >/dev/null 2>&1; then + echo "Error: Claude credentials from Keychain are not valid JSON. Re-run: ckipper add $W_ACTIVE_ACCOUNT --adopt" >&2 + return 1 fi + echo "$creds" } # Extract GitHub token for gh CLI auth inside container (prints to stdout). +# +# Reads: W_ACTIVE_CONFIG_DIR global. +# Returns: 0 always (prints empty string if no token found). _w_docker_extract_gh_token() { local token token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' \ @@ -77,6 +112,11 @@ _w_docker_extract_gh_token() { } # Build the base docker run argument array into W_DOCKER_ARGS. +# +# Reads: W_WT_PATH, W_PROJECTS_DIR, W_PROJECT, W_ACTIVE_CONFIG_DIR, +# W_EXTRA_VOLUMES globals. +# Sets: W_DOCKER_ARGS (initialised from scratch). +# Returns: 0 always. _w_docker_build_base_args() { W_DOCKER_ARGS=( docker run --rm -it @@ -102,6 +142,13 @@ _w_docker_build_base_args() { } # Add credentials, gh token, and extra env vars to W_DOCKER_ARGS. +# +# Args: +# $1 — claude_creds: Claude credentials string (may be empty) +# $2 — gh_token: GitHub personal access token (may be empty) +# +# Reads: W_EXTRA_ENV global. Appends to W_DOCKER_ARGS. +# Returns: 0 always. _w_docker_add_optional_args() { local claude_creds="$1" local gh_token="$2" @@ -124,6 +171,9 @@ _w_docker_add_optional_args() { } # If the command is "claude", expand to full skip-permissions invocation. +# +# Reads and appends to W_COMMAND and W_DOCKER_ARGS. +# Returns: 0 always. _w_docker_expand_command() { if [[ ${#W_COMMAND[@]} -gt 0 && "${W_COMMAND[1]}" == "claude" ]]; then W_COMMAND=(claude --dangerously-skip-permissions "/rename $W_BRANCH") @@ -134,6 +184,9 @@ _w_docker_expand_command() { } # Print the startup banner. +# +# Reads: W_COMMAND, W_FLAG_FIREWALL, W_WT_PATH, W_RESOLVED_PORTS globals. +# Returns: 0 always. _w_docker_print_banner() { local mode_label="Docker" [[ ${#W_COMMAND[@]} -gt 0 ]] && mode_label+=": ${W_COMMAND[1]}" @@ -144,10 +197,13 @@ _w_docker_print_banner() { } # Snapshot git state, run docker, then check for post-session tampering. +# +# Reads: W_DOCKER_ARGS, W_PROJECTS_DIR, W_PROJECT globals. +# Returns: exit code of the docker run invocation. _w_docker_snapshot_and_run() { local git_config="$W_PROJECTS_DIR/$W_PROJECT/.git/config" local git_config_hash="" - [[ -f "$git_config" ]] && git_config_hash=$(shasum -a 256 "$git_config" | cut -d' ' -f1) + [[ -f "$git_config" ]] && git_config_hash=$(shasum -a "$SHASUM_BITS" "$git_config" | cut -d' ' -f1) local git_worktrees_dir="$W_PROJECTS_DIR/$W_PROJECT/.git/worktrees" local -a worktrees_before=() @@ -165,12 +221,18 @@ _w_docker_snapshot_and_run() { } # Warn if .git/config was modified during the session. +# +# Args: +# $1 — path to .git/config +# $2 — sha256 hash of config before session (may be empty) +# +# Returns: 0 always. _w_docker_check_git_config_tampering() { local git_config="$1" local original_hash="$2" if [[ -n "$original_hash" && -f "$git_config" ]]; then local new_hash - new_hash=$(shasum -a 256 "$git_config" | cut -d' ' -f1) + new_hash=$(shasum -a "$SHASUM_BITS" "$git_config" | cut -d' ' -f1) if [[ "$original_hash" != "$new_hash" ]]; then echo "" echo "WARNING: .git/config was modified during the Docker session!" @@ -180,6 +242,12 @@ _w_docker_check_git_config_tampering() { } # Warn if any worktree metadata was destroyed during the session. +# +# Args: +# $1 — path to .git/worktrees directory +# $@ — worktree names present before the session +# +# Returns: 0 always. _w_docker_check_worktree_destruction() { local git_worktrees_dir="$1" shift diff --git a/lib/w/ports.zsh b/lib/w/ports.zsh index 686662d..2d7ba06 100644 --- a/lib/w/ports.zsh +++ b/lib/w/ports.zsh @@ -1,31 +1,46 @@ #!/usr/bin/env zsh # Port resolution for w() Docker mode. Finds available host ports for dev servers. +readonly MAX_PORT_FALLBACK_ATTEMPTS=10 + # Resolve port mappings for Docker, using fallback host ports when the # preferred port is already in use. Appends -p flags to the W_DOCKER_ARGS array. # # Reads: W_PORTS (array of container ports), W_DOCKER_ARGS (appended to). # Sets: W_RESOLVED_PORTS (array of ports for display). _w_resolve_ports() { - local max_fallback=10 W_RESOLVED_PORTS=("${W_PORTS[@]}") for port in "${W_PORTS[@]}"; do - local host_port=$port - local bound=0 - for (( i=0; i/dev/null; then - W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) - bound=1 - if (( host_port != port )); then - echo " Port $port mapped to host:$host_port (original in use)" - fi - break + _w_bind_port "$port" + done +} + +# Attempt to bind a single container port to an available host port. +# +# Args: +# $1 — container port number to bind +# +# Reads: W_DOCKER_ARGS (appended to). +# Returns: 0 always (logs a warning if no port could be bound). +_w_bind_port() { + local port="$1" + local host_port=$port + local is_bound=0 + + for (( i=0; i/dev/null; then + W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) + is_bound=1 + if (( host_port != port )); then + echo " Port $port mapped to host:$host_port (original in use)" fi - (( host_port++ )) - done - if (( !bound )); then - echo " Port $port: no available host port found ($port-$((port+max_fallback-1)) all in use)" + break fi + (( host_port++ )) done + + if (( !is_bound )); then + echo " Port $port: no available host port found ($port-$((port+MAX_PORT_FALLBACK_ATTEMPTS-1)) all in use)" + fi } diff --git a/lib/w/resolve-account.zsh b/lib/w/resolve-account.zsh index ae8afc6..eedf9c1 100644 --- a/lib/w/resolve-account.zsh +++ b/lib/w/resolve-account.zsh @@ -12,6 +12,9 @@ # 3. Registry default # # Returns: 0 on success; 1 if no account found or account not in registry. +# Errors (stderr): +# "Error: no account selected and no default registered." — when no account can be resolved +# "Error: account '' is not registered. Run: ckipper list" — when account missing from registry _w_resolve_account() { local candidate candidate=$(_w_find_account_name) @@ -39,7 +42,10 @@ _w_resolve_account() { } # Return the account name to use, without side effects. -# Tries: CLI flag → env match → registry default. +# +# Resolution order: CLI flag → env match → registry default. +# +# Returns: 0 always (prints account name to stdout, or empty string if none found). _w_find_account_name() { if [[ -n "$W_CLI_ACCOUNT" ]]; then echo "$W_CLI_ACCOUNT" diff --git a/lib/w/worktree.zsh b/lib/w/worktree.zsh index f288d00..2c0bf1d 100644 --- a/lib/w/worktree.zsh +++ b/lib/w/worktree.zsh @@ -1,45 +1,89 @@ #!/usr/bin/env zsh # Worktree list, remove, and create operations for w(). +readonly W_FIND_MAX_DEPTH=3 + # Print all worktrees under $W_WORKTREES_DIR, grouped by project. # # Reads W_PROJECTS_DIR and W_WORKTREES_DIR globals. _w_list_worktrees() { echo "=== All Worktrees ===" - if [[ ! -d "$W_WORKTREES_DIR" ]]; then - return 0 + [[ ! -d "$W_WORKTREES_DIR" ]] && return 0 + + local previous_project_for_grouping="" + find "$W_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null \ + | sort \ + | while IFS= read -r git_metadata_file; do + _w_print_worktree_entry "$git_metadata_file" previous_project_for_grouping + done +} + +# Print a single worktree entry, printing a project header when the project changes. +# +# Args: +# $1 — path to the .git metadata file +# $2 — name of the variable holding the previous project (for grouping, passed by name) +# +# Returns: 0 always; silently skips entries that cannot be parsed. +_w_print_worktree_entry() { + local git_metadata_file="$1" + local -n _prev_project_ref="$2" + + local wt_dir="${git_metadata_file:h}" + local rel="${wt_dir#$W_WORKTREES_DIR/}" + local project="${rel%%/*}" + local after_first="${rel#*/}" + [[ "$after_first" == "$rel" ]] && return 0 + + local project branch + _w_resolve_project_and_branch "$project" "$after_first" project branch + + if [[ "$project" != "$_prev_project_ref" ]]; then + _prev_project_ref="$project" + echo "\n[$project]" fi + echo " • $branch" +} - local current_project="" - find "$W_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null | sort | while IFS= read -r gitfile; do - local wt_dir="${gitfile:h}" - local rel="${wt_dir#$W_WORKTREES_DIR/}" - local project="${rel%%/*}" - local after_first="${rel#*/}" - if [[ "$after_first" == "$rel" ]]; then - continue - fi - local second="${after_first%%/*}" - local rest="${after_first#*/}" - if [[ -d "$W_PROJECTS_DIR/$project/$second/.git" ]]; then - project="$project/$second" - local branch="$rest" - else - local branch="$after_first" - fi - if [[ "$project" != "$current_project" ]]; then - current_project="$project" - echo "\n[$project]" - fi - echo " • $branch" - done +# Determine the final project name and branch from the path components. +# +# Args: +# $1 — initial project name (may be partial) +# $2 — path after the first component +# $3 — name of output variable for project +# $4 — name of output variable for branch +# +# Returns: 0 always. +_w_resolve_project_and_branch() { + local initial_project="$1" + local after_first="$2" + local -n _out_project="$3" + local -n _out_branch="$4" + + local second="${after_first%%/*}" + local rest="${after_first#*/}" + + if [[ -d "$W_PROJECTS_DIR/$initial_project/$second/.git" ]]; then + _out_project="$initial_project/$second" + _out_branch="$rest" + else + _out_project="$initial_project" + _out_branch="$after_first" + fi } # Remove a worktree and delete its branch. # +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — worktree/branch name +# # Reads W_FLAG_FORCE, W_WORKTREES_DIR, W_PROJECTS_DIR globals. -# Args: $1 = project path, $2 = worktree/branch name. # Returns: 0 on success; 1 on validation failure or git error. +# Errors (stderr): +# "Usage: w --rm [--force] " — when project or worktree is empty +# "Worktree not found: " — when the worktree directory does not exist +# "Failed to remove worktree. Use --force if it has uncommitted changes." — on git error _w_remove_worktree() { local project="$1" local worktree="$2" @@ -63,6 +107,17 @@ _w_remove_worktree() { return 1 } + _w_cleanup_project_registry "$wt_path" +} + +# Remove a worktree path from the project registry if the cleanup script exists. +# +# Args: +# $1 — absolute path to the worktree that was removed +# +# Returns: 0 always (failure is non-fatal). +_w_cleanup_project_registry() { + local wt_path="$1" local ckipper_base_dir="${CKIPPER_DIR:-$HOME/.ckipper}" if [[ -f "$ckipper_base_dir/docker/cleanup-projects.py" ]]; then CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ @@ -73,9 +128,15 @@ _w_remove_worktree() { # Create a worktree for the given project and branch (idempotent if it already exists). # Sets W_WT_PATH to the resolved worktree path. # +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — branch name +# # Reads W_PROJECTS_DIR, W_WORKTREES_DIR, W_ACTIVE_ACCOUNT, W_ACTIVE_CONFIG_DIR globals. -# Args: $1 = project path, $2 = branch name. # Returns: 0 on success; 1 on validation failure or git error. +# Errors (stderr): +# "Project not found: " — when the project directory does not exist +# "Error: exists but is not a valid worktree." — when dir exists but lacks .git file _w_create_worktree() { local project="$1" local worktree="$2" @@ -86,11 +147,7 @@ _w_create_worktree() { fi if [[ -d "$W_WORKTREES_DIR/$project/$worktree" ]]; then - if [[ ! -f "$W_WORKTREES_DIR/$project/$worktree/.git" ]]; then - echo "Error: $W_WORKTREES_DIR/$project/$worktree exists but is not a valid worktree." - echo "Remove it manually or use a different branch name." - return 1 - fi + _w_validate_existing_worktree "$project" "$worktree" || return 1 W_WT_PATH="$W_WORKTREES_DIR/$project/$worktree" return 0 fi @@ -103,17 +160,79 @@ _w_create_worktree() { _w_post_create_setup "$project" "$worktree" || return 1 } +# Validate that an existing directory at the worktree path is a real worktree. +# +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — branch/worktree name +# +# Returns: 0 if valid; 1 if the directory exists but has no .git file. +# Errors (stderr): +# "Error: exists but is not a valid worktree." — when .git file is missing +_w_validate_existing_worktree() { + local project="$1" + local worktree="$2" + local wt_path="$W_WORKTREES_DIR/$project/$worktree" + + if [[ ! -f "$wt_path/.git" ]]; then + echo "Error: $wt_path exists but is not a valid worktree." + echo "Remove it manually or use a different branch name." + return 1 + fi +} + # Fetch from origin and create the git worktree. -# Args: $1 = project, $2 = branch/worktree name. +# +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — branch/worktree name +# +# Returns: 0 on success; 1 if fetch or worktree creation fails. +# Errors (stderr): +# "Failed to fetch from origin. ..." — when git fetch fails +# "Failed: branch '' is currently checked out..." — when branch is already in use +# "Failed to create worktree" — on other git worktree add failures _w_fetch_and_create() { local project="$1" local worktree="$2" + _w_fetch_origin "$project" "$worktree" || return 1 + _w_add_worktree "$project" "$worktree" +} + +# Fetch origin/develop and optionally origin/ for the project. +# +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — branch name to attempt fetching +# +# Returns: 0 on success; 1 if origin/develop fetch fails. +# Errors (stderr): +# "Failed to fetch from origin. Check your network connection..." — on fetch failure +_w_fetch_origin() { + local project="$1" + local worktree="$2" + (cd "$W_PROJECTS_DIR/$project" && git fetch origin develop) || { echo "Failed to fetch from origin. Check your network connection and that 'develop' exists on the remote." return 1 } (cd "$W_PROJECTS_DIR/$project" && git fetch origin "$worktree" 2>/dev/null) || true +} + +# Add the git worktree, choosing local, remote, or new branch as appropriate. +# +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — branch/worktree name +# +# Returns: 0 on success; 1 if git worktree add fails. +# Errors (stderr): +# "Failed: branch '' is currently checked out..." — when branch is in use +# "Failed to create worktree" — on other failures +_w_add_worktree() { + local project="$1" + local worktree="$2" (cd "$W_PROJECTS_DIR/$project" && \ if git show-ref --verify --quiet "refs/heads/$worktree"; then @@ -126,29 +245,49 @@ _w_fetch_and_create() { echo "Creating new branch from origin/develop" git worktree add "$W_WT_PATH" -b "$worktree" origin/develop fi - ) || { - local current_branch - current_branch=$(cd "$W_PROJECTS_DIR/$project" && git branch --show-current 2>/dev/null) - if [[ "$current_branch" == "$worktree" ]]; then - echo "Failed: branch '$worktree' is currently checked out in the main repo." - echo "Switch the main repo to a different branch first:" - echo " cd $W_PROJECTS_DIR/$project && git checkout develop" - else - echo "Failed to create worktree" - fi - return 1 - } + ) || _w_handle_worktree_add_failure "$project" "$worktree" +} + +# Handle failure from git worktree add, printing a contextual error. +# +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — branch/worktree name +# +# Returns: 1 always. +# Errors (stderr): +# "Failed: branch '' is currently checked out..." — when branch is in use +# "Failed to create worktree" — on other failures +_w_handle_worktree_add_failure() { + local project="$1" + local worktree="$2" + local current_branch + current_branch=$(cd "$W_PROJECTS_DIR/$project" && git branch --show-current 2>/dev/null) + if [[ "$current_branch" == "$worktree" ]]; then + echo "Failed: branch '$worktree' is currently checked out in the main repo." + echo "Switch the main repo to a different branch first:" + echo " cd $W_PROJECTS_DIR/$project && git checkout develop" + else + echo "Failed to create worktree" + fi + return 1 } # Post-worktree-creation: install deps, copy .env files, sync Claude settings. -# Args: $1 = project, $2 = branch/worktree name. +# +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# $2 — branch/worktree name (unused but kept for symmetry with other helpers) +# +# Reads W_WT_PATH, W_ACTIVE_ACCOUNT globals. +# Returns: 0 always (individual steps may warn on failure but don't abort). _w_post_create_setup() { local project="$1" echo "Installing dependencies..." (cd "$W_WT_PATH" && npm install) || echo "Warning: npm install failed. You may need to run it manually." - for env_file in $(find "$W_PROJECTS_DIR/$project" -maxdepth 3 -name ".env*" -not -name "*.example" -not -path "*/node_modules/*" -not -path "*/.git/*"); do + for env_file in $(find "$W_PROJECTS_DIR/$project" -maxdepth "$W_FIND_MAX_DEPTH" -name ".env*" -not -name "*.example" -not -path "*/node_modules/*" -not -path "*/.git/*"); do local rel_path="${env_file#$W_PROJECTS_DIR/$project/}" local dest_dir="$W_WT_PATH/$(dirname "$rel_path")" mkdir -p "$dest_dir" @@ -156,6 +295,18 @@ _w_post_create_setup() { echo "Copied $rel_path" done + _w_sync_project_registry "$project" +} + +# Sync the new worktree into the project registry. +# +# Args: +# $1 — project path (relative to W_PROJECTS_DIR) +# +# Reads W_WT_PATH, W_ACTIVE_ACCOUNT globals. +# Returns: 0 always (failure is non-fatal). +_w_sync_project_registry() { + local project="$1" local main_project_path="$W_PROJECTS_DIR/$project" local ckipper_base_dir="${CKIPPER_DIR:-$HOME/.ckipper}" if [[ -f "$ckipper_base_dir/docker/cleanup-projects.py" ]]; then diff --git a/w-function.zsh b/w-function.zsh index 444f48d..5fa77cb 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -71,6 +71,9 @@ w() { fi } +# Print usage information for the w() command. +# +# Returns: 0 always. _w_usage() { echo "Usage: w [--docker [--firewall] [cmd...]]" echo " w [command...]" From 2210d2d0b548af4884fe5eb7b8d370eff1376f34 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:37:37 -0600 Subject: [PATCH 047/165] fix(entrypoint): use export-only to avoid readonly variable assignment error Using `export GIT_CONFIG_COUNT=$GIT_CONFIG_COUNT` with `set -e` would abort the entrypoint when the readonly variable was reassigned. Replace with bare `export GIT_CONFIG_COUNT` which flips only the export flag. --- docker/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e2205be..61d6586 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -64,7 +64,7 @@ fi # Uses GIT_CONFIG_COUNT instead of git config so we never modify the host's # .git/config (mounted rw). Env vars take highest priority, overriding both # local and global config, and disappear when the container exits. -export GIT_CONFIG_COUNT=$GIT_CONFIG_COUNT +export GIT_CONFIG_COUNT export GIT_CONFIG_KEY_0=commit.gpgsign export GIT_CONFIG_VALUE_0=false export GIT_CONFIG_KEY_1=tag.gpgsign From 1ee43adc5e58abef94f732eb56ba7694dcb92b53 Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:42:45 -0600 Subject: [PATCH 048/165] Phase 4 (Track B): fix jq JSON validation and zsh nameref compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bug fixes on top of the Phase 4 Track B refactor: 1. docker-mode.zsh: replace 'jq -e empty' with 'jq empty' for JSON validation — jq -e exits non-zero on valid JSON when empty filter emits no values, causing all credentials to be rejected 2. worktree.zsh: replace 'local -n' namerefs (unsupported in zsh 5.9) with 'eval' for passing variables by name in _w_get_project_and_branch All 28 bats tests pass; lint clean. --- lib/w/docker-mode.zsh | 2 +- lib/w/worktree.zsh | 64 +++++++++++++++++-------------------------- 2 files changed, 26 insertions(+), 40 deletions(-) diff --git a/lib/w/docker-mode.zsh b/lib/w/docker-mode.zsh index 942c16a..5148266 100644 --- a/lib/w/docker-mode.zsh +++ b/lib/w/docker-mode.zsh @@ -89,7 +89,7 @@ _w_docker_extract_credentials() { return 0 fi - if ! echo "$creds" | jq -e empty >/dev/null 2>&1; then + if ! echo "$creds" | jq empty >/dev/null 2>&1; then echo "Error: Claude credentials from Keychain are not valid JSON. Re-run: ckipper add $W_ACTIVE_ACCOUNT --adopt" >&2 return 1 fi diff --git a/lib/w/worktree.zsh b/lib/w/worktree.zsh index 2c0bf1d..0dac4f4 100644 --- a/lib/w/worktree.zsh +++ b/lib/w/worktree.zsh @@ -14,61 +14,47 @@ _w_list_worktrees() { find "$W_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null \ | sort \ | while IFS= read -r git_metadata_file; do - _w_print_worktree_entry "$git_metadata_file" previous_project_for_grouping + local wt_dir="${git_metadata_file:h}" + local rel="${wt_dir#$W_WORKTREES_DIR/}" + local project="${rel%%/*}" + local after_first="${rel#*/}" + [[ "$after_first" == "$rel" ]] && continue + + local branch + _w_get_project_and_branch "$project" "$after_first" project branch + + if [[ "$project" != "$previous_project_for_grouping" ]]; then + previous_project_for_grouping="$project" + echo "\n[$project]" + fi + echo " • $branch" done } -# Print a single worktree entry, printing a project header when the project changes. +# Set the caller's project and branch variables from path components. # # Args: -# $1 — path to the .git metadata file -# $2 — name of the variable holding the previous project (for grouping, passed by name) -# -# Returns: 0 always; silently skips entries that cannot be parsed. -_w_print_worktree_entry() { - local git_metadata_file="$1" - local -n _prev_project_ref="$2" - - local wt_dir="${git_metadata_file:h}" - local rel="${wt_dir#$W_WORKTREES_DIR/}" - local project="${rel%%/*}" - local after_first="${rel#*/}" - [[ "$after_first" == "$rel" ]] && return 0 - - local project branch - _w_resolve_project_and_branch "$project" "$after_first" project branch - - if [[ "$project" != "$_prev_project_ref" ]]; then - _prev_project_ref="$project" - echo "\n[$project]" - fi - echo " • $branch" -} - -# Determine the final project name and branch from the path components. -# -# Args: -# $1 — initial project name (may be partial) +# $1 — initial project name (first path component) # $2 — path after the first component -# $3 — name of output variable for project -# $4 — name of output variable for branch +# $3 — variable name to receive the resolved project +# $4 — variable name to receive the resolved branch # # Returns: 0 always. -_w_resolve_project_and_branch() { +_w_get_project_and_branch() { local initial_project="$1" local after_first="$2" - local -n _out_project="$3" - local -n _out_branch="$4" + local out_project_var="$3" + local out_branch_var="$4" local second="${after_first%%/*}" local rest="${after_first#*/}" if [[ -d "$W_PROJECTS_DIR/$initial_project/$second/.git" ]]; then - _out_project="$initial_project/$second" - _out_branch="$rest" + eval "$out_project_var=\"$initial_project/$second\"" + eval "$out_branch_var=\"$rest\"" else - _out_project="$initial_project" - _out_branch="$after_first" + eval "$out_project_var=\"$initial_project\"" + eval "$out_branch_var=\"$after_first\"" fi } From 3add1044c736148dd35fa12997018cf37410eb9d Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:49:27 -0600 Subject: [PATCH 049/165] =?UTF-8?q?Phase=204=20(Track=20A):=20refactor=20l?= =?UTF-8?q?ib/core/=20+=20lib/ckipper/=20=E2=80=94=20caps,=20constants,=20?= =?UTF-8?q?naming,=20MED=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/core/registry.zsh: extract REGISTRY_FILE_PERMS, LOCK_*_THRESHOLD, etc.; rmdir-first stale-lock recovery; jq-based registry init with version validation - lib/core/keychain.zsh: extract KEYCHAIN_TIMEOUT_SECONDS; split snapshot into with_timeout + fallback helpers - lib/ckipper/migrate.zsh: split _ckipper_migrate (212 lines) into 12+ helpers - lib/ckipper/sync.zsh: split _ckipper_sync (131 lines) into helpers - lib/ckipper/account-management.zsh: split _ckipper_add (108 lines), _ckipper_rename (63 lines), _ckipper_finalize_registration (4-param fix) - lib/ckipper/aliases.zsh: extract _ckipper_generate_account_launcher_function to flatten nesting in _ckipper_regenerate_aliases; ALIASES_FILE_PERMS const - lib/ckipper/plugin-repair.zsh: split _ckipper_repair_plugins - lib/ckipper/doctor.zsh: organize via local nested functions; MIN_HOOK_FILES const - ckipper.zsh: split _ckipper_help_for into per-subcommand _help_text_* helpers - Naming: snake_case standardization, descriptive names, no abbreviations - Doc-headers added on extracted helpers --- ckipper.zsh | 59 ++-- lib/ckipper/account-management.zsh | 358 +++++++++++++++------- lib/ckipper/aliases.zsh | 162 ++++++---- lib/ckipper/doctor.zsh | 233 +++++++------- lib/ckipper/migrate.zsh | 471 ++++++++++++++++++----------- lib/ckipper/plugin-repair.zsh | 109 +++++-- lib/ckipper/sync.zsh | 269 ++++++++++------ lib/core/keychain.zsh | 123 ++++++-- lib/core/registry.zsh | 246 ++++++++++----- 9 files changed, 1325 insertions(+), 705 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index d431793..7d15b8e 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -65,10 +65,9 @@ Run `ckipper --help` for per-subcommand details. EOF } -_ckipper_help_for() { - case "$1" in - add) - cat <<'EOF' +# Print help text for the 'add' subcommand. +_help_text_add() { + cat <<'EOF' ckipper add [--adopt] Register a new account. must match ^[a-z0-9_-]+$. @@ -76,12 +75,11 @@ Register a new account. must match ^[a-z0-9_-]+$. Without --adopt: creates ~/.claude-/ and walks you through /login. With --adopt: registers an existing populated ~/.claude-/ directory. EOF - ;; - list) echo "ckipper list — print registered accounts, default, and last-login email." ;; - default) echo "ckipper default — set the default account used when no flag/env is provided." ;; - remove) echo "ckipper remove — unregister. Does not delete the dir or Keychain entry." ;; - rename) - cat <<'EOF' +} + +# Print help text for the 'rename' subcommand. +_help_text_rename() { + cat <<'EOF' ckipper rename Rename a registered account in place: @@ -93,10 +91,11 @@ Rename a registered account in place: Keychain service name is NOT changed — only the dir + registry mapping. EOF - ;; - sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; - repair-plugins) - cat <<'EOF' +} + +# Print help text for the 'repair-plugins' subcommand. +_help_text_repair_plugins() { + cat <<'EOF' ckipper repair-plugins Rewrite stale absolute paths in /plugins/{known_marketplaces, @@ -106,9 +105,11 @@ Use this when Claude Code shows "Plugin not found in marketplace ..." for plugins that were installed before `ckipper migrate` (or before the dir was renamed). Backups are written alongside each rewritten file. EOF - ;; - sync) - cat <<'EOF' +} + +# Print help text for the 'sync' subcommand. +_help_text_sync() { + cat <<'EOF' ckipper sync [options] Copy state from one registered account to another. Useful for sharing MCP @@ -133,9 +134,27 @@ Examples: ckipper sync personal work --mcp Vibma,github ckipper sync personal work --settings statusLine,env --dry-run EOF - ;; - migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; - doctor) echo "ckipper doctor — run a diagnostic checklist on registered accounts and ckipper tooling." ;; +} + +# Dispatch to the per-subcommand help text printer. +# +# Args: +# $1 — subcommand name +# +# Returns: +# 0 always. +_ckipper_help_for() { + case "$1" in + add) _help_text_add ;; + list) echo "ckipper list — print registered accounts, default, and last-login email." ;; + default) echo "ckipper default — set the default account used when no flag/env is provided." ;; + remove) echo "ckipper remove — unregister. Does not delete the dir or Keychain entry." ;; + rename) _help_text_rename ;; + sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; + repair-plugins) _help_text_repair_plugins ;; + sync) _help_text_sync ;; + migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; + doctor) echo "ckipper doctor — run a diagnostic checklist on registered accounts and ckipper tooling." ;; esac } diff --git a/lib/ckipper/account-management.zsh b/lib/ckipper/account-management.zsh index 0271125..b268dd5 100644 --- a/lib/ckipper/account-management.zsh +++ b/lib/ckipper/account-management.zsh @@ -1,10 +1,17 @@ #!/usr/bin/env zsh # Account lifecycle subcommands: add, finalize_registration, remove, rename, list, default, bare_alias_safe. -_ckipper_add() { - _core_registry_check_version || return 1 - local name="$1" adopt=0 - [[ "$2" == "--adopt" ]] && adopt=1 +# Validate the account name and --adopt flag from `ckipper add` arguments. +# Prints error messages to stdout and returns non-zero on failure. +# +# Args: +# $1 — account name +# $2 — "--adopt" or empty +# +# Returns: +# 0 if valid; 1 on empty name, invalid name format, or already registered. +_ckipper_add_validate_name() { + local name="$1" if [[ -z "$name" ]]; then echo "Usage: ckipper add [--adopt]" return 1 @@ -13,47 +20,70 @@ _ckipper_add() { echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." return 1 fi - - _core_registry_init - - if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then + if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>/dev/null; then echo "Account '$name' is already registered." return 1 fi +} - local dir="$HOME/.claude-$name" +# Run the adopt flow: pick a Keychain entry and finalize registration for an existing dir. +# +# Args: +# $1 — account name +# $2 — account config directory +# +# Returns: +# 0 on success; 1 on validation or registration failure. +_ckipper_add_adopt_flow() { + local name="$1" dir="$2" + if [[ ! -d "$dir" ]]; then + echo "Cannot adopt: $dir does not exist." + return 1 + fi + local picked="" + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + _ckipper_add_pick_keychain_entry "$name" picked || return 1 + fi + _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" +} - if [[ $adopt -eq 1 ]]; then - if [[ ! -d "$dir" ]]; then - echo "Cannot adopt: $dir does not exist." - return 1 - fi - # In adopt mode, list candidate Keychain entries and let the user pick (or skip). - local picked="" - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - local candidates - candidates=$(_core_keychain_snapshot) || return 1 - if [[ -n "$candidates" ]]; then - echo "Candidate Keychain entries:" - echo "$candidates" | nl - read -r "?Pick a number (or empty to skip): " idx - if [[ -n "$idx" ]]; then - picked=$(echo "$candidates" | sed -n "${idx}p") - if [[ -n "$picked" ]] && ! _core_keychain_validate "$picked"; then - echo "Invalid Keychain service shape: $picked" - return 1 - fi - fi - fi - fi - _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" - return $? +# Prompt the user to pick a Keychain entry from the available candidates. +# On return, the nameref variable (arg $2) holds the chosen service (may be empty). +# +# Args: +# $1 — account name (for error messages) +# $2 — nameref variable to receive the chosen service name +# +# Returns: +# 0 on success; 1 on keychain error or invalid service shape. +_ckipper_add_pick_keychain_entry() { + local name="$1" + local -n _picked_ref="$2" + local candidates + candidates=$(_core_keychain_snapshot) || return 1 + [[ -z "$candidates" ]] && return 0 + echo "Candidate Keychain entries:" + echo "$candidates" | nl + local keychain_index + read -r "?Pick a number (or empty to skip): " keychain_index + [[ -z "$keychain_index" ]] && return 0 + _picked_ref=$(printf '%s\n' "$candidates" | sed -n "${keychain_index}p") + if [[ -n "$_picked_ref" ]] && ! _core_keychain_validate "$_picked_ref"; then + echo "Invalid Keychain service shape: $_picked_ref" + return 1 fi +} - # Fresh registration: ckipper LAUNCHES claude itself (in-place, same TTY) so - # there's no shell-deadlock UX where the user has to Ctrl-Z, run a command, - # then `fg`. User just /login's and exits Claude (Ctrl-D); ckipper resumes - # and finalizes registration. +# Run the fresh registration flow: create the dir, deploy hooks, launch Claude, detect new keychain. +# +# Args: +# $1 — account name +# $2 — account config directory +# +# Returns: +# 0 on success; 1 on abort or credential detection failure. +_ckipper_add_fresh_flow() { + local name="$1" dir="$2" if [[ -d "$dir" ]]; then echo "Directory $dir already exists. Use --adopt to register it." return 1 @@ -62,15 +92,30 @@ _ckipper_add() { if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" fi - # Deploy hook scripts and rewrite settings.json paths to the per-account - # dir BEFORE launching claude. The template ships with $HOME/.claude/hooks - # paths; without this rewrite, the /login session inside claude fires hook - # errors ("No such file or directory") for paths that don't exist yet. _ckipper_sync_hooks_for "$name" "$dir" - local before_snapshot before_snapshot=$(_core_keychain_snapshot) || return 1 + _ckipper_add_launch_claude "$name" "$dir" || return 1 + local after_snapshot + after_snapshot=$(_core_keychain_snapshot) || return 1 + local new_service + new_service=$(comm -13 \ + <(printf '%s\n' "$before_snapshot") \ + <(printf '%s\n' "$after_snapshot") | head -1) + _ckipper_add_check_credentials "$name" "$dir" "$new_service" || return 1 + _ckipper_finalize_registration "$name" "$dir" "$new_service" "fresh" +} +# Display the fresh-add instructions, prompt for confirmation, and launch Claude. +# +# Args: +# $1 — account name +# $2 — account config directory +# +# Returns: +# 0 after Claude exits; 1 if user chose to skip. +_ckipper_add_launch_claude() { + local name="$1" dir="$2" cat </dev/null 2>&1; then - echo "Error: account '$name' already exists in registry (race detected)." >&2 - elif jq -e --arg d "$dir" '[.accounts[].config_dir] | any(. == $d)' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Error: config dir '$dir' is already claimed by another registered account." >&2 - else - echo "Error: failed to write account '$name' to registry $CKIPPER_REGISTRY" >&2 - fi + _ckipper_finalize_diagnose_error "$name" "$dir" return 1 fi - _ckipper_regenerate_aliases _ckipper_sync_hooks_for "$name" - echo "Registered '$name' (mode: $mode)." if _ckipper_bare_alias_safe "$name"; then echo "Use it via: claude-$name (or just: $name)" @@ -169,9 +230,34 @@ _ckipper_finalize_registration() { fi } -# Returns 0 if $1 is safe to use as a bare-alias function name (no clash with +# Diagnose why _ckipper_finalize_registration failed and print the appropriate error. +# +# Args: +# $1 — account name +# $2 — account config directory +# +# Returns: +# 0 always (error message already printed to stderr). +_ckipper_finalize_diagnose_error() { + local name="$1" dir="$2" + if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: account '$name' already exists in registry (race detected)." >&2 + elif jq -e --arg d "$dir" '[.accounts[].config_dir] | any(. == $d)' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + echo "Error: config dir '$dir' is already claimed by another registered account." >&2 + else + echo "Error: failed to write account '$name' to registry $CKIPPER_REGISTRY" >&2 + fi +} + +# Return 0 if $1 is safe to use as a bare-alias function name (no clash with # any existing PATH command, shell builtin, alias, or reserved word). Existing -# shell *functions* are not a clash — we expect to redefine those. +# shell functions are not a clash — we expect to redefine those. +# +# Args: +# $1 — proposed alias name +# +# Returns: +# 0 if safe to use; 1 if it would shadow an existing command/builtin/alias/reserved word. _ckipper_bare_alias_safe() { local n="$1" (( ${+commands[$n]} || ${+builtins[$n]} || ${+aliases[$n]} )) && return 1 @@ -180,6 +266,10 @@ _ckipper_bare_alias_safe() { return 0 } +# Print all registered accounts with their directories and email addresses. +# +# Returns: +# 0 always. _ckipper_list() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then echo "No accounts registered. Run: ckipper add " @@ -190,15 +280,7 @@ _ckipper_list() { echo "Registered accounts:" jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ while IFS=$'\t' read -r name dir; do - local marker=" " - [[ "$name" == "$default" ]] && marker="* " - local email="" - if [[ -f "$dir/.claude.json" ]]; then - email=$(jq -r '.oauthAccount.emailAddress // ""' "$dir/.claude.json" 2>/dev/null) - fi - local exists="(missing)" - [[ -d "$dir" ]] && exists="" - echo "$marker$name $dir ${email:+($email)} $exists" + _ckipper_list_account_line "$name" "$dir" "$default" done echo "" echo "* = default. Run: ckipper default " @@ -207,6 +289,35 @@ _ckipper_list() { echo "is single-use, so the second session gets logged out. Use a different account instead." } +# Print a single account line for `ckipper list`. +# +# Args: +# $1 — account name +# $2 — config directory +# $3 — default account name +# +# Returns: +# 0 always. +_ckipper_list_account_line() { + local name="$1" dir="$2" default="$3" + local marker=" " + [[ "$name" == "$default" ]] && marker="* " + local email="" + if [[ -f "$dir/.claude.json" ]]; then + email=$(jq -r '.oauthAccount.emailAddress // ""' "$dir/.claude.json" 2>/dev/null) + fi + local exists="(missing)" + [[ -d "$dir" ]] && exists="" + echo "$marker$name $dir ${email:+($email)} $exists" +} + +# Set the default account in the registry. +# +# Args: +# $1 — account name to set as default +# +# Returns: +# 0 on success; 1 if account is not registered. _ckipper_default() { _core_registry_check_version || return 1 local name="$1" @@ -219,6 +330,13 @@ _ckipper_default() { echo "Default account is now '$name'." } +# Unregister an account from the registry without deleting its files. +# +# Args: +# $1 — account name to remove +# +# Returns: +# 0 on success; 1 if account is not registered. _ckipper_remove() { _core_registry_check_version || return 1 local name="$1" @@ -230,8 +348,7 @@ _ckipper_remove() { local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") local service; service=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") _core_registry_update 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' --arg n "$name" - # Drop the now-stale launcher functions from the calling shell (regenerate - # only redefines what's still in the registry; it can't unset removed entries). + # Drop the now-stale launcher functions from the calling shell. unset -f "claude-$name" 2>/dev/null unset -f "$name" 2>/dev/null _ckipper_regenerate_aliases @@ -244,8 +361,15 @@ _ckipper_remove() { fi } -_ckipper_rename() { - _core_registry_check_version || return 1 +# Validate arguments for `ckipper rename` before performing the rename. +# +# Args: +# $1 — old account name +# $2 — new account name +# +# Returns: +# 0 if valid; 1 on any validation failure. +_ckipper_rename_validate() { local old="$1" new="$2" if [[ -z "$old" || -z "$new" ]]; then echo "Usage: ckipper rename " @@ -267,10 +391,21 @@ _ckipper_rename() { echo "Account '$new' is already registered." return 1 fi +} - local old_dir new_dir - old_dir=$(jq -r --arg n "$old" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - new_dir="$HOME/.claude-$new" +# Perform the directory move and registry update for `ckipper rename`. +# Rolls back the directory rename if the registry write fails. +# +# Args: +# $1 — old account name +# $2 — new account name +# $3 — old config directory path +# $4 — new config directory path +# +# Returns: +# 0 on success; 1 on directory move or registry write failure. +_ckipper_rename_perform() { + local old="$1" new="$2" old_dir="$3" new_dir="$4" if [[ -e "$new_dir" ]]; then echo "Error: $new_dir already exists. Pick a different name or remove it first." return 1 @@ -279,33 +414,44 @@ _ckipper_rename() { echo "Error: source directory $old_dir does not exist." return 1 fi - - # Refuse if any Claude session is running — they'd be writing to old_dir. _core_assert_no_running_claude || return 1 - if ! mv "$old_dir" "$new_dir" 2>/dev/null; then echo "Error: failed to rename $old_dir → $new_dir." >&2 return 1 fi - if ! _core_registry_update ' .accounts[$new] = .accounts[$old] | .accounts[$new].config_dir = $newdir | del(.accounts[$old]) | (if .default == $old then .default = $new else . end) ' --arg old "$old" --arg new "$new" --arg newdir "$new_dir"; then - # Rollback: move dir back. mv "$new_dir" "$old_dir" 2>/dev/null echo "Error: registry write failed; reverted directory rename." >&2 return 1 fi +} +# Rename a registered account: moves its directory and updates the registry. +# +# Args: +# $1 — old account name +# $2 — new account name +# +# Returns: +# 0 on success; 1 on validation or rename failure. +_ckipper_rename() { + _core_registry_check_version || return 1 + local old="$1" new="$2" + _ckipper_rename_validate "$old" "$new" || return 1 + local old_dir new_dir + old_dir=$(jq -r --arg n "$old" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + new_dir="$HOME/.claude-$new" + _ckipper_rename_perform "$old" "$new" "$old_dir" "$new_dir" || return 1 # Drop old-name launcher functions from the calling shell. unset -f "claude-$old" 2>/dev/null unset -f "$old" 2>/dev/null _ckipper_regenerate_aliases - _ckipper_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to the new dir - + _ckipper_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to new dir echo "Renamed '$old' → '$new'." echo "Directory: $old_dir → $new_dir" if _ckipper_bare_alias_safe "$new"; then diff --git a/lib/ckipper/aliases.zsh b/lib/ckipper/aliases.zsh index 70f1c95..87efa84 100644 --- a/lib/ckipper/aliases.zsh +++ b/lib/ckipper/aliases.zsh @@ -1,9 +1,68 @@ #!/usr/bin/env zsh # Alias generation and hook sync subcommands: regenerate_aliases, sync_hooks_for, sync_hooks. +readonly ALIASES_FILE_PERMS=644 + +# Write a single account's launcher function lines to the aliases file being built. +# Generates a `claude-` function and, if safe, a bare `` shortcut. +# +# Args: +# $1 — account name +# $2 — account config directory path +# +# Returns: +# 0 always. +_ckipper_generate_account_launcher_function() { + local account_name="$1" account_dir="$2" + echo "claude-$account_name() { CLAUDE_CONFIG_DIR=\"$account_dir\" command claude \"\$@\"; }" + # Bare-name shortcut: also generate `` so users can type the + # account name directly. Skip if it would shadow a real binary, builtin, + # alias, or reserved word. + if _ckipper_bare_alias_safe "$account_name"; then + echo "$account_name() { CLAUDE_CONFIG_DIR=\"$account_dir\" command claude \"\$@\"; }" + else + echo "# Bare-name alias '$account_name' skipped (would shadow existing command)." + fi +} + +# Write the bare-claude guard function to stdout. +# Blocks bare `claude` invocations when accounts are registered to prevent +# credential overwrites on the default account's keychain entry. +# +# Returns: +# 0 always. +_ckipper_write_bare_claude_guard() { + echo "claude() {" + echo " if [[ -f \"\$_CKIPPER_REGISTRY\" ]] && jq -e '.accounts | length > 0' \"\$_CKIPPER_REGISTRY\" >/dev/null 2>&1; then" + echo " local default" + echo " default=\$(jq -r '.default // \"\"' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" + echo " echo \"Refusing to launch bare 'claude' — Ckipper has registered accounts.\" >&2" + echo " echo \"\" >&2" + echo " echo \"Bare 'claude' uses ~/.claude/ and writes to the Keychain entry your\" >&2" + echo " echo \"default account ('\${default:-personal}') is registered against. A fresh\" >&2" + echo " echo \"/login here would silently overwrite those credentials.\" >&2" + echo " echo \"\" >&2" + echo " if [[ -n \"\$default\" ]]; then" + echo " echo \"Use: claude-\$default\" >&2" + echo " else" + echo " echo \"Set a default first: ckipper default , then use claude-.\" >&2" + echo " fi" + echo " echo \"\" >&2" + echo " echo \"To bypass (fresh login on purpose): command claude \\\$@\" >&2" + echo " return 1" + echo " fi" + echo " command claude \"\$@\"" + echo "}" +} + +# Regenerate ~/.ckipper/aliases.zsh from the current registry and re-source it +# in the calling shell so newly-registered accounts are usable immediately. +# +# Returns: +# 0 always. _ckipper_regenerate_aliases() { local out="$CKIPPER_DIR/aliases.zsh" - local _name _dir + local account_name account_dir { echo "# Auto-generated by ckipper. Do not edit by hand." echo "# Self-contained: does not depend on ckipper.zsh or w-function.zsh being sourced." @@ -11,63 +70,58 @@ _ckipper_regenerate_aliases() { echo "" echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" echo "" - # Guard: bare 'claude' would default to ~/.claude/ and write to the unsuffixed - # 'Claude Code-credentials' Keychain entry — which is the SAME entry the - # default account uses. A fresh /login here silently overwrites those creds. - # Block bare 'claude' when accounts are registered; users bypass via 'command claude'. - echo "claude() {" - echo " if [[ -f \"\$_CKIPPER_REGISTRY\" ]] && jq -e '.accounts | length > 0' \"\$_CKIPPER_REGISTRY\" >/dev/null 2>&1; then" - echo " local default" - echo " default=\$(jq -r '.default // \"\"' \"\$_CKIPPER_REGISTRY\" 2>/dev/null)" - echo " echo \"Refusing to launch bare 'claude' — Ckipper has registered accounts.\" >&2" - echo " echo \"\" >&2" - echo " echo \"Bare 'claude' uses ~/.claude/ and writes to the Keychain entry your\" >&2" - echo " echo \"default account ('\${default:-personal}') is registered against. A fresh\" >&2" - echo " echo \"/login here would silently overwrite those credentials.\" >&2" - echo " echo \"\" >&2" - echo " if [[ -n \"\$default\" ]]; then" - echo " echo \"Use: claude-\$default\" >&2" - echo " else" - echo " echo \"Set a default first: ckipper default , then use claude-.\" >&2" - echo " fi" - echo " echo \"\" >&2" - echo " echo \"To bypass (fresh login on purpose): command claude \\\$@\" >&2" - echo " return 1" - echo " fi" - echo " command claude \"\$@\"" - echo "}" + _ckipper_write_bare_claude_guard echo "" if [[ -f "$CKIPPER_REGISTRY" ]]; then jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ - while IFS=$'\t' read -r _name _dir; do - echo "claude-$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" - # Bare-name shortcut: also generate `` so users can - # type the account name directly. Skip if it would shadow - # a real binary, builtin, alias, or reserved word. - if _ckipper_bare_alias_safe "$_name"; then - echo "$_name() { CLAUDE_CONFIG_DIR=\"$_dir\" command claude \"\$@\"; }" - else - echo "# Bare-name alias '$_name' skipped (would shadow existing command)." - fi + while IFS=$'\t' read -r account_name account_dir; do + _ckipper_generate_account_launcher_function "$account_name" "$account_dir" done fi } > "$out.tmp" # Atomic install — readers in other shells never see a partial file. mv "$out.tmp" "$out" - chmod 644 "$out" - + chmod "$ALIASES_FILE_PERMS" "$out" # Re-source in the calling shell so newly-registered accounts are usable - # immediately without the user having to `exec zsh`. Function definitions - # from a sourced file are global by default in zsh, so this works even - # though we're sourcing inside a function. + # immediately without the user having to `exec zsh`. source "$out" } +# Rewrite settings.json hook paths to absolute paths for the given account directory. +# Consumes any prefix matching $HOME/.claude*/hooks/ or $HOME/.ckipper/hooks/. +# +# Args: +# $1 — account config directory path +# +# Returns: +# 0 always. +_ckipper_rewrite_settings_json_hooks() { + local dir="$1" + [[ -f "$dir/settings.json" ]] || return 0 + command -v jq &>/dev/null || return 0 + local settings_tmpfile; settings_tmpfile=$(mktemp "$dir/.settings.tmp.XXXXXX") + jq --arg d "$dir" ' + (.hooks // {}) as $h | + .hooks = ($h | walk( + if type == "string" and test("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/") + then sub("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/"; "\($d)/hooks/") + else . end + )) + ' "$dir/settings.json" > "$settings_tmpfile" && mv "$settings_tmpfile" "$dir/settings.json" +} + +# Copy hook scripts into an account directory and rewrite settings.json hook paths. +# Allows callers (e.g. _ckipper_add) to pass the dir directly before the account +# is registered in the registry. +# +# Args: +# $1 — account name +# $2 — (optional) account config directory; looked up in registry if omitted +# +# Returns: +# 0 on success; 1 if directory cannot be resolved. _ckipper_sync_hooks_for() { local name="$1" dir="${2:-}" - # Allow callers (notably _ckipper_add, which deploys hooks BEFORE the - # account exists in the registry) to pass the dir directly. Without an - # explicit dir, look it up in the registry. if [[ -z "$dir" ]]; then _core_registry_check_version || return 1 dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") @@ -75,23 +129,13 @@ _ckipper_sync_hooks_for() { fi mkdir -p "$dir/hooks" cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true - - # Rewrite settings.json hook paths to absolute paths under this account dir. - # Consumes the entire prefix (`$HOME/.claude/`, `$HOME/.claude-/`, or `$HOME/.ckipper/`) - # plus `hooks/` so we don't end up with `$HOME/hooks/...` after substitution. - if [[ -f "$dir/settings.json" ]] && command -v jq &>/dev/null; then - local tmp; tmp=$(mktemp "$dir/.settings.tmp.XXXXXX") - jq --arg d "$dir" ' - (.hooks // {}) as $h | - .hooks = ($h | walk( - if type == "string" and test("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/") - then sub("\\$HOME/(\\.claude(-[a-z0-9_-]+)?|\\.ckipper)/hooks/"; "\($d)/hooks/") - else . end - )) - ' "$dir/settings.json" > "$tmp" && mv "$tmp" "$dir/settings.json" - fi + _ckipper_rewrite_settings_json_hooks "$dir" } +# Copy hooks into all registered accounts. +# +# Returns: +# 0 on success; 1 if registry version check fails. _ckipper_sync_hooks() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then echo "No accounts registered." diff --git a/lib/ckipper/doctor.zsh b/lib/ckipper/doctor.zsh index 0ec63fd..1727bdc 100644 --- a/lib/ckipper/doctor.zsh +++ b/lib/ckipper/doctor.zsh @@ -1,6 +1,13 @@ #!/usr/bin/env zsh # Diagnostic check subcommand: doctor. +readonly MIN_HOOK_FILES=4 + +# Run diagnostic checks and print results to stdout. +# Tracks FAIL and WARN counts and returns non-zero if any FAILs are found. +# +# Returns: +# 0 if all checks pass (warnings are non-fatal); 1 if any FAIL checks are found. _ckipper_doctor() { local fail=0 warn=0 local check() { @@ -12,116 +19,128 @@ _ckipper_doctor() { INFO) printf " [INFO] %s\n" "$msg" ;; esac } - # Locally-scoped function for color output. zsh function nesting works at runtime. - - echo "── Tooling ───────────────────────────────────────────" - if [[ -d "$CKIPPER_DIR" ]]; then check PASS "$CKIPPER_DIR exists"; else check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi - if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then check PASS "w-function.zsh deployed"; else check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi - if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then check PASS "ckipper.zsh deployed"; else check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi - if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then check PASS "cleanup-projects.py deployed"; else check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi - if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then check PASS "settings-template.json deployed"; else check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi - if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= 4 )); then - check PASS "hooks/ has 4+ files" - else - check WARN "hooks/ is missing or has fewer than 4 hook files" - fi - - echo "" - echo "── Registry ──────────────────────────────────────────" - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" - return 0 - fi - local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) - if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then check PASS "registry version $v matches expected" - else check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi - local perms; perms=$(_core_stat_perms "$CKIPPER_REGISTRY") - if [[ "$perms" == "600" ]]; then check PASS "registry permissions 600" - else check WARN "registry permissions $perms (expected 600)"; fi - - local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - if [[ -z "$default_acc" ]]; then - check WARN "no default account set — w/ckipper-add will require --account" - elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - check INFO "default account: $default_acc" - else - check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " - fi - - echo "" - echo "── Per-account state ────────────────────────────────" - local names; names=$(jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY") - if [[ -z "$names" ]]; then - check WARN "registry has no accounts" - else - while IFS= read -r name; do - echo "" - echo " Account: $name" - # Combined declare+assign: zsh 5.9 leaks "var=''" to stdout when the - # `local x; x=$(...)` form runs inside a `while` loop body. - local dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - local svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") - if [[ -d "$dir" ]]; then check PASS " dir exists: $dir" - else check FAIL " dir missing: $dir"; fi - if [[ -f "$dir/.claude.json" ]]; then - local email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) - local proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) - local mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) - check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" - else - check WARN " .claude.json missing in $dir" - fi - if [[ -f "$dir/settings.json" ]]; then check PASS " settings.json present"; else check WARN " settings.json missing"; fi - if [[ -d "$dir/hooks" ]]; then check PASS " hooks/ deployed"; else check WARN " hooks/ missing — run: ckipper sync-hooks"; fi - # Stale plugin-metadata paths: a sign that ckipper migrate moved - # the account dir without rewriting absolute paths inside - # plugins/{known_marketplaces,installed_plugins}.json. Symptom is - # "Plugin not found in marketplace ..." in the Claude Code UI. - local stale_pm=0 - for pm in known_marketplaces.json installed_plugins.json; do - [[ -f "$dir/plugins/$pm" ]] || continue - if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then - stale_pm=1 - fi - done - if (( stale_pm )); then - check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" - fi - # Keychain check (macOS only) - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - if [[ -z "$svc" ]]; then - check INFO " keychain_service: null (account uses on-disk credentials)" - elif ! _core_keychain_validate "$svc"; then - check FAIL " keychain_service has invalid shape: $svc" - elif security find-generic-password -s "$svc" >/dev/null 2>&1; then - check PASS " keychain entry present: $svc" - else - check WARN " keychain entry NOT FOUND: $svc — re-run /login with: claude-$name" - fi + local _doctor_tooling() { + echo "── Tooling ───────────────────────────────────────────" + if [[ -d "$CKIPPER_DIR" ]]; then check PASS "$CKIPPER_DIR exists"; else check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi + if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then check PASS "w-function.zsh deployed"; else check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then check PASS "ckipper.zsh deployed"; else check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then check PASS "cleanup-projects.py deployed"; else check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then check PASS "settings-template.json deployed"; else check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi + if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= MIN_HOOK_FILES )); then + check PASS "hooks/ has ${MIN_HOOK_FILES}+ files" + else + check WARN "hooks/ is missing or has fewer than $MIN_HOOK_FILES hook files" + fi + } + local _doctor_registry() { + echo "" + echo "── Registry ──────────────────────────────────────────" + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" + return 1 + fi + local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then check PASS "registry version $v matches expected" + else check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi + local perms; perms=$(_core_stat_perms "$CKIPPER_REGISTRY") + if [[ "$perms" == "600" ]]; then check PASS "registry permissions 600" + else check WARN "registry permissions $perms (expected 600)"; fi + local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + if [[ -z "$default_acc" ]]; then + check WARN "no default account set — w/ckipper-add will require --account" + elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + check INFO "default account: $default_acc" + else + check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " + fi + } + local _doctor_account() { + local name="$1" + echo "" + echo " Account: $name" + local dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + if [[ -d "$dir" ]]; then check PASS " dir exists: $dir" + else check FAIL " dir missing: $dir"; fi + if [[ -f "$dir/.claude.json" ]]; then + local email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) + local proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) + local mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) + check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" + else + check WARN " .claude.json missing in $dir" + fi + if [[ -f "$dir/settings.json" ]]; then check PASS " settings.json present"; else check WARN " settings.json missing"; fi + if [[ -d "$dir/hooks" ]]; then check PASS " hooks/ deployed"; else check WARN " hooks/ missing — run: ckipper sync-hooks"; fi + _doctor_account_plugins "$name" "$dir" + _doctor_account_keychain "$svc" "$name" + } + local _doctor_account_plugins() { + local name="$1" dir="$2" + local stale_pm=0 + local pm + for pm in known_marketplaces.json installed_plugins.json; do + [[ -f "$dir/plugins/$pm" ]] || continue + if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then + stale_pm=1 fi + done + if (( stale_pm )); then + check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" + fi + } + local _doctor_account_keychain() { + local svc="$1" name="$2" + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 + if [[ -z "$svc" ]]; then + check INFO " keychain_service: null (account uses on-disk credentials)" + elif ! _core_keychain_validate "$svc"; then + check FAIL " keychain_service has invalid shape: $svc" + elif security find-generic-password -s "$svc" >/dev/null 2>&1; then + check PASS " keychain entry present: $svc" + else + check WARN " keychain entry NOT FOUND: $svc — re-run /login with: claude-$name" + fi + } + local _doctor_accounts() { + echo "" + echo "── Per-account state ────────────────────────────────" + local names; names=$(jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY") + if [[ -z "$names" ]]; then + check WARN "registry has no accounts" + return 0 + fi + local name + while IFS= read -r name; do + _doctor_account "$name" done <<< "$names" + } + local _doctor_shell() { + echo "" + echo "── Aliases & shell integration ──────────────────────" + if [[ -f "$CKIPPER_DIR/aliases.zsh" ]]; then check PASS "aliases.zsh exists at $CKIPPER_DIR/aliases.zsh" + else check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi + if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources aliases.zsh" + else check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi + if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources w-function.zsh" + else check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi + echo "" + echo "── Stub files (cosmetic) ────────────────────────────" + if [[ -d "$HOME/.claude" ]]; then + local stub_count; stub_count=$(ls -1A "$HOME/.claude" 2>/dev/null | wc -l | tr -d ' ') + check WARN "~/.claude exists ($stub_count files) — Claude Code may have recreated it. Safe to: rm -rf ~/.claude" + else + check PASS "~/.claude (stub dir) is absent" + fi + if [[ -f "$HOME/.claude.json" ]]; then check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." + else check PASS "~/.claude.json (home root) is absent"; fi + } + _doctor_tooling + if ! _doctor_registry; then + return 0 fi - - echo "" - echo "── Aliases & shell integration ──────────────────────" - if [[ -f "$CKIPPER_DIR/aliases.zsh" ]]; then check PASS "aliases.zsh exists at $CKIPPER_DIR/aliases.zsh" - else check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi - if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources aliases.zsh" - else check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi - if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources w-function.zsh" - else check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi - - echo "" - echo "── Stub files (cosmetic) ────────────────────────────" - if [[ -d "$HOME/.claude" ]]; then - local stub_count; stub_count=$(ls -1A "$HOME/.claude" 2>/dev/null | wc -l | tr -d ' ') - check WARN "~/.claude exists ($stub_count files) — Claude Code may have recreated it. Safe to: rm -rf ~/.claude" - else - check PASS "~/.claude (stub dir) is absent" - fi - if [[ -f "$HOME/.claude.json" ]]; then check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." - else check PASS "~/.claude.json (home root) is absent"; fi - + _doctor_accounts + _doctor_shell echo "" echo "──────────────────────────────────────────────────────" if (( fail > 0 )); then diff --git a/lib/ckipper/migrate.zsh b/lib/ckipper/migrate.zsh index 5ebbe8e..18a86b2 100644 --- a/lib/ckipper/migrate.zsh +++ b/lib/ckipper/migrate.zsh @@ -1,17 +1,20 @@ #!/usr/bin/env zsh # One-time migration from legacy ~/.claude/docker/ layout. -_ckipper_migrate() { - _core_registry_check_version || return 1 - local legacy_docker="$HOME/.claude/docker" - local legacy_claude="$HOME/.claude" - - # ── Precondition 1: no Claude process running ───────────────── +# Check preconditions for migration: no running Claude, no symlinks at key paths. +# +# Args: +# $1 — legacy_claude path (e.g. "$HOME/.claude") +# $2 — legacy home json path (e.g. "$HOME/.claude.json") +# +# Returns: +# 0 if all preconditions pass; 1 on failure. +# +# Errors (stderr): +# "Error: ... is a symlink..." — when either path is a symlink. +_ckipper_migrate_preflight() { + local legacy_claude="$1" legacy_homejson_check="$2" _core_assert_no_running_claude || return 1 - - # ── Precondition 2: ~/.claude must NOT be a symlink ────────── - # Some users symlink ~/.claude to a synced location. Renaming a symlink - # moves the link, not the target — confusing and probably not what they want. if [[ -L "$legacy_claude" ]]; then local target; target=$(readlink "$legacy_claude") echo "Error: $legacy_claude is a symlink (→ $target). Refusing to migrate." >&2 @@ -19,11 +22,6 @@ _ckipper_migrate() { echo "or migrate the target directly." >&2 return 1 fi - - # ── Precondition 3: ~/.claude.json must NOT be a symlink ────── - # Same reasoning — Dropbox/iCloud users symlink this for cross-machine sync. - # mv on a symlink moves the link itself, breaking the sync target. - local legacy_homejson_check="$HOME/.claude.json" if [[ -L "$legacy_homejson_check" ]]; then local target; target=$(readlink "$legacy_homejson_check") echo "Error: $legacy_homejson_check is a symlink (→ $target). Refusing to migrate." >&2 @@ -31,73 +29,81 @@ _ckipper_migrate() { echo "or migrate the target directly." >&2 return 1 fi +} - # ── 1. Move ~/.claude/docker → ~/.ckipper/docker if not done ── - if [[ -d "$legacy_docker" && ! -d "$CKIPPER_DIR/docker" ]]; then - mkdir -p "$CKIPPER_DIR" - cp -a "$legacy_docker/." "$CKIPPER_DIR/docker/" - echo "Copied $legacy_docker → $CKIPPER_DIR/docker (legacy left intact for one release cycle)" - fi - # ── 2. Adopt ~/.claude as a registered account ──────────────── - # Eligible if either ~/.claude/.claude.json or ~/.claude/settings.json exists, - # OR ~/.claude.json exists at home root (Claude Code's canonical big-config location). - local legacy_homejson="$HOME/.claude.json" - local has_inner_state=0 has_homejson=0 - [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]] && has_inner_state=1 - [[ -f "$legacy_homejson" ]] && has_homejson=1 +# Print the appropriate no-op message for migrate when nothing needs to be done. +# +# Args: +# $1 — legacy_claude path +# $2 — legacy home json path +# +# Returns: +# 0 always. +_ckipper_migrate_print_no_op() { + local legacy_claude="$1" legacy_homejson="$2" + local has_registry=0 + [[ -f "$CKIPPER_REGISTRY" ]] && \ + jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry=1 + if (( has_registry )); then + echo "Nothing to migrate: no $legacy_claude state and no $legacy_homejson at home root." + echo "($CKIPPER_REGISTRY already has registered accounts — you're likely already migrated.)" + echo "Run: ckipper list" + else + echo "Nothing to migrate: no $legacy_claude/.claude.json, no $legacy_claude/settings.json, no $legacy_homejson." + echo "If this is a fresh setup, register an account directly: ckipper add " + fi +} - if (( has_inner_state == 0 && has_homejson == 0 )); then - local has_registry=0 - [[ -f "$CKIPPER_REGISTRY" ]] && jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry=1 - if (( has_registry )); then - echo "Nothing to migrate: no $legacy_claude state and no $legacy_homejson at home root." - echo "($CKIPPER_REGISTRY already has registered accounts — you're likely already migrated.)" - echo "Run: ckipper list" - else - echo "Nothing to migrate: no $legacy_claude/.claude.json, no $legacy_claude/settings.json, no $legacy_homejson." - echo "If this is a fresh setup, register an account directly: ckipper add " +# Prompt the user for a migration account name with validation. +# Prints the chosen name to stdout. +# +# Returns: +# 0 with the name on stdout; 1 if the name cannot be determined. +_ckipper_migrate_prompt_account_name() { + local default_name="personal" name="" + while [[ -z "$name" ]]; do + read -r "?What name do you want for this migrated account? [$default_name] " name + [[ -z "$name" ]] && name="$default_name" + if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then + echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." + name="" + continue fi - return 0 - fi + if [[ -e "$HOME/.claude-$name" ]]; then + echo "$HOME/.claude-$name already exists. Pick a different name." + name="" + fi + done + printf '%s' "$name" +} - if [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" || -f "$legacy_homejson" ]]; then - if [[ ! -f "$CKIPPER_REGISTRY" ]] || \ - ! jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - - # ── Prompt for the account name ────────────────────── - local default_name="personal" - local name="" - while [[ -z "$name" ]]; do - read -r "?What name do you want for this migrated account? [$default_name] " name - [[ -z "$name" ]] && name="$default_name" - if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then - echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." - name="" - fi - if [[ -n "$name" && -e "$HOME/.claude-$name" ]]; then - echo "$HOME/.claude-$name already exists. Pick a different name." - name="" - fi - done - local target_dir="$HOME/.claude-$name" - - # Show the user what we're about to do. - cat </dev/null 2>&1; then - echo "Warning: '$probed_service' not found in Keychain." - echo "Listing available Claude Keychain entries:" - _core_keychain_snapshot || return 1 - read -r "?Enter the Keychain service for the '$name' account (or empty to skip): " probed_service - if [[ -n "$probed_service" ]] && ! _core_keychain_validate "$probed_service"; then - echo "Invalid Keychain service shape. Aborting." - return 1 - fi - fi - else - probed_service="" - fi - - # ── Destructive operation with explicit rollback ───── - # Tracks every step performed so rollback can undo precisely: - # 1 = ~/.claude renamed; 2 = ~/.claude.json moved (inner backed up) - local migrate_step=0 - local moved_homejson_backup="" - _ckipper_migrate_rollback() { - local why="${1:-rollback}" - # Step 2 reverse: restore ~/.claude.json at home root, restore the - # inner backup if we made one. - if (( migrate_step >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then - mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null - if [[ -n "$moved_homejson_backup" && -f "$moved_homejson_backup" ]]; then - mv "$moved_homejson_backup" "$target_dir/.claude.json" 2>/dev/null - fi - fi - # Step 1 reverse: rename target_dir back to legacy_claude. - if (( migrate_step >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then - mv "$target_dir" "$legacy_claude" 2>/dev/null - echo "Migration $why — restored $legacy_claude." >&2 - fi - # Clean partial registry entry so a re-run isn't blocked. - if [[ -f "$CKIPPER_REGISTRY" ]] && \ - jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - _core_registry_update \ - 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ - --arg n "$name" - echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 - fi - # Regenerate aliases.zsh so it reflects the post-rollback registry - # (otherwise it would still define claude-() pointing into a - # dir that no longer exists). - _ckipper_regenerate_aliases 2>/dev/null || true - } - # HUP catches Terminal.app window-close mid-migrate; QUIT catches Ctrl-\. - trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT ERR; return 130' INT TERM HUP QUIT - - # Step 1: rename ~/.claude → ~/.claude- - if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then - trap - INT TERM HUP QUIT - echo "Error: failed to rename $legacy_claude → $target_dir" >&2 - echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 - return 1 - fi - migrate_step=1 - - # Step 2: move ~/.claude.json → $target_dir/.claude.json. - # If $target_dir already has a .claude.json (Claude wrote one when - # CLAUDE_CONFIG_DIR was set in some prior run), back it up — the - # home-root file is canonical. - if [[ -f "$legacy_homejson" ]]; then - if [[ -f "$target_dir/.claude.json" ]]; then - moved_homejson_backup="$target_dir/.claude.json.pre-migrate-backup" - mv "$target_dir/.claude.json" "$moved_homejson_backup" - fi - if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then - _ckipper_migrate_rollback failed - trap - INT TERM HUP QUIT - echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 - return 1 - fi - migrate_step=2 - fi - - # Step 2.5: rewrite stale absolute paths in plugin metadata. - # Without this, Claude Code raises "Plugin not found in marketplace" - # for every previously-installed plugin, because installed_plugins.json - # and known_marketplaces.json still reference $legacy_claude/... - # (which no longer exists post-rename). - _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" - - if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then - _ckipper_migrate_rollback failed - trap - INT TERM HUP QUIT - return 1 - fi - trap - INT TERM HUP QUIT - unset -f _ckipper_migrate_rollback - fi + local user_choice + read -r "?Proceed? [y/N] " user_choice + if [[ "$user_choice" != "y" && "$user_choice" != "Y" ]]; then + echo "Aborted." + return 1 fi +} - # ── 3. Best-effort cleanup of old Docker image ──────────────── - if command -v docker >/dev/null 2>&1; then - docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." +# Detect the Keychain service for the migrating account. +# Prompts the user if the default service is not found. +# +# Returns: +# 0 and prints the service name to stdout (may be empty); 1 on invalid input. +_ckipper_migrate_detect_keychain() { + local probed_service="Claude Code-credentials" + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && printf '' && return 0 + if security find-generic-password -s "$probed_service" -w >/dev/null 2>&1; then + printf '%s' "$probed_service" + return 0 fi + echo "Warning: '$probed_service' not found in Keychain." + echo "Listing available Claude Keychain entries:" + _core_keychain_snapshot || return 1 + local user_input + read -r "?Enter the Keychain service for the account (or empty to skip): " user_input + if [[ -n "$user_input" ]] && ! _core_keychain_validate "$user_input"; then + echo "Invalid Keychain service shape. Aborting." + return 1 + fi + printf '%s' "$user_input" +} - # Reload `name` from registry for the success-message context (in case migrate - # was run for a no-op state and `name` was never set in this scope). - local registered_name="" - if [[ -f "$CKIPPER_REGISTRY" ]]; then - registered_name=$(jq -r '.default // (.accounts | keys[0] // "")' "$CKIPPER_REGISTRY") +# Execute the rename of ~/.claude to the target dir, then move ~/.claude.json if present. +# Updates migrate_step and moved_homejson_backup in the caller's scope via namerefs. +# +# Args: +# $1 — legacy_claude path +# $2 — legacy home json path +# $3 — target directory +# +# Returns: +# 0 on success; 1 on failure (rollback should be called by the caller). +_ckipper_migrate_rename_dirs() { + local legacy_claude="$1" legacy_homejson="$2" target_dir="$3" + if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then + echo "Error: failed to rename $legacy_claude → $target_dir" >&2 + echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 + return 1 fi + migrate_step=1 + [[ ! -f "$legacy_homejson" ]] && return 0 + if [[ -f "$target_dir/.claude.json" ]]; then + moved_homejson_backup="$target_dir/.claude.json.pre-migrate-backup" + mv "$target_dir/.claude.json" "$moved_homejson_backup" + fi + if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then + echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 + return 1 + fi + migrate_step=2 +} +# Print the migration success message with next steps. +# +# Args: +# $1 — registered account name (may be empty) +# +# Returns: +# 0 always. +_ckipper_migrate_print_success() { + local registered_name="$1" cat < to add additional accounts. EOF - if [[ -n "$registered_name" ]]; then - cat < 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && return 2 + return 0 +} + +# Perform a one-time migration from legacy ~/.claude/docker/ layout to ckipper. +# Idempotent. Refuses if Claude is running. +# +# Returns: +# 0 on success or no-op; 1 on failure or user abort. +# +# Errors (stderr): +# Various error messages for symlink, rename, and registry failures. +_ckipper_migrate() { + _core_registry_check_version || return 1 + local legacy_docker="$HOME/.claude/docker" + local legacy_claude="$HOME/.claude" + local legacy_homejson="$HOME/.claude.json" + _ckipper_migrate_preflight "$legacy_claude" "$legacy_homejson" || return 1 + _ckipper_migrate_copy_docker "$legacy_docker" + local state_rc + _ckipper_migrate_check_state "$legacy_claude" "$legacy_homejson"; state_rc=$? + if (( state_rc == 1 )); then + _ckipper_migrate_print_no_op "$legacy_claude" "$legacy_homejson"; return 0 fi + (( state_rc == 2 )) && { _ckipper_migrate_finalize; return 0; } + local name; name=$(_ckipper_migrate_prompt_account_name) + local target_dir="$HOME/.claude-$name" + _ckipper_migrate_confirm_plan "$legacy_claude" "$legacy_homejson" "$target_dir" "$name" || return 1 + local probed_service + probed_service=$(_ckipper_migrate_detect_keychain) || return 1 + _ckipper_migrate_run "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" "$probed_service" } + +# Run the destructive migration steps with rollback on failure. +# Uses a locally-defined rollback function to capture step tracking variables. +# +# Args: +# $1 — account name +# $2 — target directory +# $3 — legacy_claude path +# $4 — legacy home json path +# $5 — probed keychain service (may be empty) +# +# Returns: +# 0 on success; 1 on failure (rollback applied). +_ckipper_migrate_run() { + local name="$1" target_dir="$2" legacy_claude="$3" legacy_homejson="$4" probed_service="$5" + local migrate_step=0 moved_homejson_backup="" + local _ckipper_migrate_rollback() { + local why="${1:-rollback}" + if (( migrate_step >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then + mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null + [[ -n "$moved_homejson_backup" && -f "$moved_homejson_backup" ]] && \ + mv "$moved_homejson_backup" "$target_dir/.claude.json" 2>/dev/null + fi + if (( migrate_step >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then + mv "$target_dir" "$legacy_claude" 2>/dev/null + echo "Migration $why — restored $legacy_claude." >&2 + fi + if [[ -f "$CKIPPER_REGISTRY" ]] && \ + jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + _core_registry_update \ + 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ + --arg n "$name" + echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 + fi + _ckipper_regenerate_aliases 2>/dev/null || true + } + trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT ERR; return 130' INT TERM HUP QUIT + _ckipper_migrate_run_steps "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" "$probed_service" + local run_rc=$? + trap - INT TERM HUP QUIT + unset -f _ckipper_migrate_rollback + (( run_rc == 0 )) && _ckipper_migrate_finalize + return $run_rc +} + +# Execute the actual migration rename and register steps (called from _ckipper_migrate_run). +# +# Args: +# $1 — account name +# $2 — target directory +# $3 — legacy_claude path +# $4 — legacy home json path +# $5 — probed keychain service (may be empty) +# +# Returns: +# 0 on success; 1 on failure (_ckipper_migrate_rollback should be called by caller). +_ckipper_migrate_run_steps() { + local name="$1" target_dir="$2" legacy_claude="$3" legacy_homejson="$4" probed_service="$5" + _ckipper_migrate_rename_dirs "$legacy_claude" "$legacy_homejson" "$target_dir" || { + _ckipper_migrate_rollback failed + return 1 + } + _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" + if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then + _ckipper_migrate_rollback failed + return 1 + fi +} + +# Perform post-migration steps: clean up old Docker image and print success. +# +# Returns: +# 0 always. +_ckipper_migrate_finalize() { + if command -v docker >/dev/null 2>&1; then + docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." + fi + local registered_name="" + if [[ -f "$CKIPPER_REGISTRY" ]]; then + registered_name=$(jq -r '.default // (.accounts | keys[0] // "")' "$CKIPPER_REGISTRY") + fi + _ckipper_migrate_print_success "$registered_name" +} + diff --git a/lib/ckipper/plugin-repair.zsh b/lib/ckipper/plugin-repair.zsh index 4edacb2..1865c28 100644 --- a/lib/ckipper/plugin-repair.zsh +++ b/lib/ckipper/plugin-repair.zsh @@ -8,29 +8,81 @@ # absolute paths baked in — Claude Code then fails to resolve plugins with # "Plugin not found in marketplace ..." errors. # -# $1 = old prefix (must end with `/`), e.g. "$HOME/.claude/" -# $2 = new prefix (must end with `/`), e.g. "$HOME/.claude-personal/" -# Idempotent: if neither file contains the old prefix, this is a no-op. +# Args: +# $1 — old prefix (must end with `/`), e.g. "$HOME/.claude/" +# $2 — new prefix (must end with `/`), e.g. "$HOME/.claude-personal/" +# +# Returns: +# 0 always (idempotent: if neither file contains old prefix, this is a no-op); +# 1 if arguments are invalid. _ckipper_rewrite_plugin_paths() { local old="$1" new="$2" [[ -z "$old" || -z "$new" || "$old" != */ || "$new" != */ ]] && return 1 [[ "$old" == "$new" ]] && return 0 - local f rewrote=0 + local f for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do - local fp="$new$f" - [[ -f "$fp" ]] || continue - grep -q -- "$old" "$fp" 2>/dev/null || continue - cp "$fp" "$fp.pre-rewrite-backup-$(date +%s)" - if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - sed -i '' "s|$old|$new|g" "$fp" - else - sed -i "s|$old|$new|g" "$fp" - fi - rewrote=1 + _ckipper_rewrite_single_plugin_file "$old" "$new" "$f" done return 0 } +# Rewrite stale paths in a single plugin metadata file using sed (in-place). +# Creates a timestamped backup before modifying the file. +# +# Args: +# $1 — old prefix (must end with `/`) +# $2 — new prefix (must end with `/`) +# $3 — relative plugin file path (e.g. plugins/known_marketplaces.json) +# +# Returns: +# 0 always (no-op if file absent or old prefix not found). +_ckipper_rewrite_single_plugin_file() { + local old="$1" new="$2" rel_path="$3" + local fp="$new$rel_path" + [[ -f "$fp" ]] || return 0 + grep -q -- "$old" "$fp" 2>/dev/null || return 0 + cp "$fp" "$fp.pre-rewrite-backup-$(date +%s)" + if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then + sed -i '' "s|$old|$new|g" "$fp" + else + sed -i "s|$old|$new|g" "$fp" + fi +} + +# Detect the stale prefix in the plugin metadata files for the given account. +# +# Args: +# $1 — account config directory path +# +# Returns: +# 0 always; prints stale prefix to stdout (empty if none found). +_ckipper_detect_stale_plugin_prefix() { + local dir="$1" + local f + for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do + [[ -f "$dir/$f" ]] || continue + local hit + hit=$(grep -oE "$HOME/\.claude(-[a-z0-9_-]+)?/" "$dir/$f" 2>/dev/null | \ + sort -u | grep -v "^$dir/$" | head -1) + if [[ -n "$hit" ]]; then + printf '%s' "$hit" + return 0 + fi + done +} + +# Rewrite stale absolute paths in plugin metadata for a single registered account. +# +# Args: +# $1 — registered account name +# +# Returns: +# 0 on success or when no repair is needed; 1 on error. +# +# Errors (stderr): +# "Usage: ckipper repair-plugins " — when name is empty. +# "Account '...' is not registered." — when account not found. +# "Account dir does not exist: ..." — when directory is missing. _ckipper_repair_plugins() { local name="$1" if [[ -z "$name" ]]; then @@ -47,22 +99,21 @@ _ckipper_repair_plugins() { echo "Account dir does not exist: $dir" return 1 fi + _ckipper_repair_plugins_apply "$name" "$dir" +} - # Detect what stale prefix the metadata is using. Almost always the legacy - # ~/.claude/, but a previously-renamed account could carry an older suffix. - local stale_prefix="" - local f - for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do - [[ -f "$dir/$f" ]] || continue - # Combined declare+assign on one line: zsh 5.9 emits "var=''" to stdout - # when `local var` and `var=$(...)` are split across two lines inside a - # `for` loop body. Trivia that mostly bites diagnostic helpers like this. - local hit=$(grep -oE "$HOME/\.claude(-[a-z0-9_-]+)?/" "$dir/$f" 2>/dev/null | sort -u | grep -v "^$dir/$" | head -1) - if [[ -n "$hit" ]]; then - stale_prefix="$hit" - break - fi - done +# Apply stale-prefix repair to an account directory once validation has passed. +# +# Args: +# $1 — account name (for messages) +# $2 — account config directory +# +# Returns: +# 0 on success or when no repair is needed. +_ckipper_repair_plugins_apply() { + local name="$1" dir="$2" + local stale_prefix + stale_prefix=$(_ckipper_detect_stale_plugin_prefix "$dir") if [[ -z "$stale_prefix" ]]; then echo "No stale paths found in $dir/plugins/. Nothing to repair." return 0 diff --git a/lib/ckipper/sync.zsh b/lib/ckipper/sync.zsh index 70c736d..4ed7100 100644 --- a/lib/ckipper/sync.zsh +++ b/lib/ckipper/sync.zsh @@ -1,32 +1,16 @@ #!/usr/bin/env zsh # Account settings sync subcommand: sync MCP servers and settings.json keys between accounts. -_ckipper_sync() { - _core_registry_check_version || return 1 - local from="$1" to="$2" - shift 2 2>/dev/null - if [[ -z "$from" || -z "$to" ]]; then - echo "Usage: ckipper sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" - return 1 - fi - if [[ "$from" == "$to" ]]; then - echo " and must differ." - return 1 - fi - - local from_dir to_dir - from_dir=$(_core_account_dir "$from") || return 1 - to_dir=$(_core_account_dir "$to") || return 1 - if [[ ! -f "$from_dir/.claude.json" ]]; then - echo "Source has no .claude.json: $from_dir"; return 1 - fi - if [[ ! -f "$to_dir/.claude.json" ]]; then - echo "Destination has no .claude.json: $to_dir"; return 1 - fi - - # Parse flags. The argparse here is intentionally minimal — order matters, - # but each flag is well-formed and easy to read. - local mode_mcp=0 mcp_names="" mode_settings=0 settings_keys="" dry_run=0 mode_all=0 +# Parse sync subcommand flags into named variables in the caller's scope. +# Populates: mode_mcp, mcp_names, mode_settings, settings_keys, dry_run, mode_all. +# +# Args: +# $@ — remaining args after and have been shifted +# +# Returns: +# 0 on success; 1 on unknown flag. +_ckipper_sync_parse_flags() { + mode_mcp=0; mcp_names=""; mode_settings=0; settings_keys=""; dry_run=0; mode_all=0 while [[ $# -gt 0 ]]; do case "$1" in --mcp) @@ -37,14 +21,11 @@ _ckipper_sync() { mode_settings=1 if [[ -n "$2" && "$2" != --* ]]; then settings_keys="$2"; shift; fi shift ;; - --all) mode_all=1; shift ;; - --dry-run) dry_run=1; shift ;; + --all) mode_all=1; shift ;; + --dry-run) dry_run=1; shift ;; *) echo "Unknown flag: $1"; return 1 ;; esac done - - # Default bundle when no specific flags were passed: mcpServers + a useful - # selection of settings.json keys. if (( mode_mcp == 0 && mode_settings == 0 )); then mode_all=1 fi @@ -54,91 +35,179 @@ _ckipper_sync() { [[ -z "$settings_keys" ]] && \ settings_keys="enabledPlugins,extraKnownMarketplaces,statusLine,env,model" fi +} - # If a Claude session is running, sync's writes can race with its writes - # to the same .claude.json. Warn (but don't refuse) unless --dry-run. - if (( ! dry_run )); then - local running_procs - running_procs=$(_core_running_claude_processes) - if [[ -n "$running_procs" ]]; then - echo "Warning: Claude is currently running. If a session uses '$to' or '$from'," >&2 - echo "sync may race with its writes (Claude doesn't lock these files)." >&2 - echo "$running_procs" | sed 's/^/ /' >&2 - read -r "?Continue anyway? [y/N] " ans - [[ "$ans" != "y" && "$ans" != "Y" ]] && { echo "Aborted."; return 1; } - fi - fi - - local pending_msgs=() +# Warn if Claude is running and the sync would race with its writes. +# Prompts the user to confirm continuation. +# +# Args: +# $1 — from account name +# $2 — to account name +# +# Returns: +# 0 to proceed; 1 if user aborts. +_ckipper_sync_warn_running_claude() { + local from="$1" to="$2" + local running_procs + running_procs=$(_core_running_claude_processes) + [[ -z "$running_procs" ]] && return 0 + echo "Warning: Claude is currently running. If a session uses '$to' or '$from'," >&2 + echo "sync may race with its writes (Claude doesn't lock these files)." >&2 + echo "$running_procs" | sed 's/^/ /' >&2 + local user_choice + read -r "?Continue anyway? [y/N] " user_choice + [[ "$user_choice" != "y" && "$user_choice" != "Y" ]] && { echo "Aborted."; return 1; } +} - # ── MCP sync ───────────────────────────────────────────────── - if (( mode_mcp )); then - local mcp_filter - if [[ -z "$mcp_names" ]]; then - mcp_filter='.mcpServers // {}' - else - # Build a jq object containing only the named servers, e.g. {Vibma: ..., github: ...} - local jq_array - jq_array=$(echo "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | '"$jq_array"' | index($k)))' - fi - local servers - servers=$(jq "$mcp_filter" "$from_dir/.claude.json") - local server_keys - server_keys=$(echo "$servers" | jq -r 'keys[]?' | tr '\n' ' ') - if [[ -z "$server_keys" || "$server_keys" == " " ]]; then - pending_msgs+=("MCP: nothing to sync (no matching servers in $from)") - else - pending_msgs+=("MCP servers → $to: $server_keys") - if (( ! dry_run )); then - local tmp; tmp=$(mktemp "$to_dir/.claude.json.tmp.XXXXXX") - jq --argjson new "$servers" '.mcpServers = (.mcpServers // {}) + $new' \ - "$to_dir/.claude.json" > "$tmp" && mv "$tmp" "$to_dir/.claude.json" - fi - fi +# Sync MCP servers from one account to another. Appends a summary line to pending_msgs. +# +# Args: +# $1 — from account config directory +# $2 — to account name (for message) +# $3 — to account config directory +# $4 — comma-separated MCP server names to sync (empty = all) +# $5 — dry_run flag (1 = dry run, 0 = write) +# +# Returns: +# 0 always. +_ckipper_sync_mcp_servers() { + local from_dir="$1" to="$2" to_dir="$3" mcp_names="$4" dry_run="$5" + local mcp_filter + if [[ -z "$mcp_names" ]]; then + mcp_filter='.mcpServers // {}' + else + local jq_array + jq_array=$(printf '%s' "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') + mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | '"$jq_array"' | index($k)))' fi + local servers; servers=$(jq "$mcp_filter" "$from_dir/.claude.json") + local server_keys; server_keys=$(printf '%s' "$servers" | jq -r 'keys[]?' | tr '\n' ' ') + if [[ -z "$server_keys" || "$server_keys" == " " ]]; then + pending_msgs+=("MCP: nothing to sync (no matching servers in source)") + return 0 + fi + pending_msgs+=("MCP servers → $to: $server_keys") + (( dry_run )) && return 0 + local sync_tmpfile; sync_tmpfile=$(mktemp "$to_dir/.claude.json.tmp.XXXXXX") + jq --argjson new "$servers" '.mcpServers = (.mcpServers // {}) + $new' \ + "$to_dir/.claude.json" > "$sync_tmpfile" && mv "$sync_tmpfile" "$to_dir/.claude.json" +} - # ── settings.json key sync ─────────────────────────────────── - if (( mode_settings )) && [[ -n "$settings_keys" ]]; then - if [[ ! -f "$from_dir/settings.json" ]]; then - pending_msgs+=("Settings: $from has no settings.json (skipping)") - else - # Build a jq subset object with only the requested keys (skipping missing ones). - local jq_keys - jq_keys=$(echo "$settings_keys" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - local subset - subset=$(jq --argjson keys "$jq_keys" \ - 'with_entries(select(.key as $k | $keys | index($k)))' \ - "$from_dir/settings.json") - local copied_keys - copied_keys=$(echo "$subset" | jq -r 'keys[]?' | tr '\n' ' ') - if [[ -z "$copied_keys" || "$copied_keys" == " " ]]; then - pending_msgs+=("Settings: no matching keys in $from/settings.json") - else - pending_msgs+=("Settings keys → $to: $copied_keys") - if (( ! dry_run )); then - if [[ ! -f "$to_dir/settings.json" ]]; then - echo '{}' > "$to_dir/settings.json" - fi - local tmp; tmp=$(mktemp "$to_dir/settings.json.tmp.XXXXXX") - jq --argjson new "$subset" '. + $new' \ - "$to_dir/settings.json" > "$tmp" && mv "$tmp" "$to_dir/settings.json" - fi - fi - fi +# Sync settings.json keys from one account to another. Appends summary line to pending_msgs. +# +# Args: +# $1 — from account name (for message) +# $2 — from account config directory +# $3 — to account name (for message) +# $4 — to account config directory +# $5 — comma-separated settings keys to sync +# $6 — dry_run flag (1 = dry run, 0 = write) +# +# Returns: +# 0 always. +_ckipper_sync_settings_keys() { + local from="$1" from_dir="$2" to="$3" to_dir="$4" settings_keys="$5" dry_run="$6" + if [[ ! -f "$from_dir/settings.json" ]]; then + pending_msgs+=("Settings: $from has no settings.json (skipping)") + return 0 fi + local jq_keys; jq_keys=$(printf '%s' "$settings_keys" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') + local subset; subset=$(jq --argjson keys "$jq_keys" \ + 'with_entries(select(.key as $k | $keys | index($k)))' "$from_dir/settings.json") + local copied_keys; copied_keys=$(printf '%s' "$subset" | jq -r 'keys[]?' | tr '\n' ' ') + if [[ -z "$copied_keys" || "$copied_keys" == " " ]]; then + pending_msgs+=("Settings: no matching keys in $from/settings.json") + return 0 + fi + pending_msgs+=("Settings keys → $to: $copied_keys") + (( dry_run )) && return 0 + [[ ! -f "$to_dir/settings.json" ]] && echo '{}' > "$to_dir/settings.json" + local sync_tmpfile; sync_tmpfile=$(mktemp "$to_dir/settings.json.tmp.XXXXXX") + jq --argjson new "$subset" '. + $new' \ + "$to_dir/settings.json" > "$sync_tmpfile" && mv "$sync_tmpfile" "$to_dir/settings.json" +} +# Print the sync summary lines and restart reminder. +# +# Args: +# $1 — to account name +# $2 — dry_run flag (1 = dry run, 0 = write) +# +# Returns: +# 0 always. +_ckipper_sync_print_summary() { + local to="$1" dry_run="$2" if (( dry_run )); then echo "Dry run — would apply:" else echo "Synced:" fi + local m for m in "${pending_msgs[@]}"; do echo " - $m" done - if (( ! dry_run )); then echo "" echo "Restart any running '$to' Claude session for changes to take effect." fi } + +# Resolve and validate sync source and destination account directories. +# Prints from_dir and to_dir as tab-separated values to stdout on success. +# +# Args: +# $1 — from account name +# $2 — to account name +# +# Returns: +# 0 with "from_dir\tto_dir" on stdout; 1 on validation failure. +_ckipper_sync_resolve_dirs() { + local from="$1" to="$2" + local from_dir to_dir + from_dir=$(_core_account_dir "$from") || return 1 + to_dir=$(_core_account_dir "$to") || return 1 + if [[ ! -f "$from_dir/.claude.json" ]]; then + echo "Source has no .claude.json: $from_dir"; return 1 + fi + if [[ ! -f "$to_dir/.claude.json" ]]; then + echo "Destination has no .claude.json: $to_dir"; return 1 + fi + printf '%s\t%s' "$from_dir" "$to_dir" +} + +# Copy MCP servers and/or settings.json keys from one registered account to another. +# +# Args: +# $1 — from account name +# $2 — to account name +# $@ — options: [--mcp [names]] [--settings keys] [--all] [--dry-run] +# +# Returns: +# 0 on success; 1 on validation failure or user abort. +# +# Errors (stderr): +# "Usage: ckipper sync ..." — when arguments are missing. +# " and must differ." — when both accounts are the same. +_ckipper_sync() { + _core_registry_check_version || return 1 + local from="$1" to="$2" + shift 2 2>/dev/null + if [[ -z "$from" || -z "$to" ]]; then + echo "Usage: ckipper sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" + return 1 + fi + [[ "$from" == "$to" ]] && { echo " and must differ."; return 1; } + local dirs_line; dirs_line=$(_ckipper_sync_resolve_dirs "$from" "$to") || return 1 + local from_dir="${dirs_line%% *}" to_dir="${dirs_line##* }" + local mode_mcp mcp_names mode_settings settings_keys dry_run mode_all + _ckipper_sync_parse_flags "$@" || return 1 + if (( ! dry_run )); then + _ckipper_sync_warn_running_claude "$from" "$to" || return 1 + fi + local pending_msgs=() + (( mode_mcp )) && \ + _ckipper_sync_mcp_servers "$from_dir" "$to" "$to_dir" "$mcp_names" "$dry_run" + (( mode_settings && ${#settings_keys} > 0 )) && \ + _ckipper_sync_settings_keys "$from" "$from_dir" "$to" "$to_dir" "$settings_keys" "$dry_run" + _ckipper_sync_print_summary "$to" "$dry_run" +} diff --git a/lib/core/keychain.zsh b/lib/core/keychain.zsh index 8d70f9e..537ccc6 100644 --- a/lib/core/keychain.zsh +++ b/lib/core/keychain.zsh @@ -1,70 +1,127 @@ #!/usr/bin/env zsh # Shared macOS Keychain and Claude process utilities. -# Validates a keychain_service name before passing to `security`. +readonly KEYCHAIN_TIMEOUT_SECONDS=10 + +# Validate a keychain_service name before passing to `security`. # Accepts "Claude Code-credentials" optionally followed by "-". +# +# Args: +# $1 — service name to validate +# +# Returns: +# 0 if valid; non-zero if empty or wrong shape. _core_keychain_validate() { local svc="$1" [[ -z "$svc" ]] && return 1 [[ "$svc" =~ ^Claude\ Code-credentials(-[a-f0-9]+)?$ ]] } -_core_keychain_snapshot() { - # macOS only. Returns service names of all "Claude Code-credentials*" entries, sorted. - [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 - - # Pick a timeout binary if available (macOS doesn't ship one; gtimeout from - # coreutils is the typical brew install). Fall through to no timeout if neither - # is present — better than failing with a misleading "keychain locked" error. - local timeout_cmd="" +# Detect the best available timeout command for wrapping keychain access. +# +# Returns: +# 0 always; prints the timeout command prefix to stdout (empty if none found). +_core_keychain_detect_timeout_cmd() { if command -v timeout >/dev/null 2>&1; then - timeout_cmd="timeout 10" + printf 'timeout %s' "$KEYCHAIN_TIMEOUT_SECONDS" elif command -v gtimeout >/dev/null 2>&1; then - timeout_cmd="gtimeout 10" + printf 'gtimeout %s' "$KEYCHAIN_TIMEOUT_SECONDS" fi +} +# Dump the macOS Keychain using a timeout wrapper, then filter for Claude entries. +# +# Args: +# $1 — timeout command prefix (e.g. "timeout 10"), or empty for no timeout +# +# Returns: +# 0 on success with Claude service names printed to stdout; 1 on keychain error. +_core_keychain_snapshot_with_timeout() { + local timeout_cmd="$1" local out - if [[ -n "$timeout_cmd" ]]; then - if ! out=$($timeout_cmd security dump-keychain 2>/dev/null); then - echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 - return 1 - fi - else - # No timeout available — run without. If keychain is locked the GUI - # password prompt will block this, which is a fine failure mode. - if ! out=$(security dump-keychain 2>/dev/null); then - echo "Warning: 'security dump-keychain' failed. Keychain may be locked." >&2 - return 1 - fi + if ! out=$($timeout_cmd security dump-keychain 2>/dev/null); then + echo "Warning: Keychain may be locked or slow. Unlock it (Keychain Access > File > Unlock) and retry." >&2 + return 1 fi + printf '%s\n' "$out" | \ + awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ + sort -u +} +# Dump the macOS Keychain without a timeout wrapper, then filter for Claude entries. +# +# Returns: +# 0 on success with Claude service names printed to stdout; 1 on keychain error. +# +# Errors (stderr): +# "Warning: 'security dump-keychain' failed..." — when keychain dump exits non-zero. +_core_keychain_snapshot_fallback() { + local out + # No timeout available — run without. If keychain is locked the GUI + # password prompt will block this, which is a fine failure mode. + if ! out=$(security dump-keychain 2>/dev/null); then + echo "Warning: 'security dump-keychain' failed. Keychain may be locked." >&2 + return 1 + fi printf '%s\n' "$out" | \ awk -F'"' '/"svce"="Claude Code-credentials/ {print $4}' | \ sort -u } +# Return service names of all "Claude Code-credentials*" Keychain entries, sorted. +# macOS only — returns 0 immediately on other platforms. +# +# Returns: +# 0 on success; 1 if keychain is locked or unavailable. +# +# Errors (stderr): +# Warning messages when the keychain is slow, locked, or dump fails. +_core_keychain_snapshot() { + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 + + # Pick a timeout binary if available (macOS doesn't ship one; gtimeout from + # coreutils is the typical brew install). Fall through to no timeout if neither + # is present — better than failing with a misleading "keychain locked" error. + local timeout_cmd + timeout_cmd=$(_core_keychain_detect_timeout_cmd) + + if [[ -n "$timeout_cmd" ]]; then + _core_keychain_snapshot_with_timeout "$timeout_cmd" + else + _core_keychain_snapshot_fallback + fi +} + # Detect running Claude processes that would conflict with destructive operations. # Matches: 'claude' CLI (basename), 'Claude' (Claude.app main process). Avoids matching # vim files named 'claude-*', tmux sessions, or Claude Helper subprocesses (the parent # Claude.app being killed will cascade to those). +# +# Returns: +# 0 always; matching processes printed to stdout. _core_running_claude_processes() { pgrep -lx claude 2>/dev/null pgrep -lx Claude 2>/dev/null } # Refuse with a clear message if any Claude process is running. +# +# Returns: +# 0 if no Claude processes found (or CKIPPER_FORCE=1 is set); 1 otherwise. +# +# Errors (stderr): +# "Error: Claude process(es) detected..." — when running processes found. _core_assert_no_running_claude() { local found found=$(_core_running_claude_processes) - if [[ -n "$found" ]]; then - echo "Error: Claude process(es) detected. Quit them first:" >&2 - echo "$found" | sed 's/^/ /' >&2 - echo "(Set CKIPPER_FORCE=1 to bypass this check, but expect inconsistent state.)" >&2 - if [[ "$CKIPPER_FORCE" == "1" ]]; then - echo "CKIPPER_FORCE=1 set — proceeding despite running Claude." >&2 - return 0 - fi - return 1 + [[ -z "$found" ]] && return 0 + + echo "Error: Claude process(es) detected. Quit them first:" >&2 + echo "$found" | sed 's/^/ /' >&2 + echo "(Set CKIPPER_FORCE=1 to bypass this check, but expect inconsistent state.)" >&2 + if [[ "$CKIPPER_FORCE" == "1" ]]; then + echo "CKIPPER_FORCE=1 set — proceeding despite running Claude." >&2 + return 0 fi - return 0 + return 1 } diff --git a/lib/core/registry.zsh b/lib/core/registry.zsh index eb35405..77e9194 100644 --- a/lib/core/registry.zsh +++ b/lib/core/registry.zsh @@ -1,91 +1,187 @@ #!/usr/bin/env zsh # Shared registry read/write primitives for managing the ckipper accounts registry. -# Atomic registry write under flock (or mkdir-fallback). $1 = jq filter. -# Returns 0 on successful jq+write, 1 on jq error or write failure. -# A jq error() call inside the filter (used for atomic-collision-checks like -# _ckipper_finalize_registration) propagates as a non-zero exit here. -_core_registry_update() { +readonly REGISTRY_FILE_PERMS=600 +readonly LOCK_NOTIFY_THRESHOLD_ATTEMPTS=30 +readonly LOCK_MAX_ATTEMPTS=200 +readonly STALE_LOCK_AGE_THRESHOLD_SECONDS=30 +readonly LOCK_RETRY_INTERVAL_SECONDS=0.05 + +# Perform an atomic registry update via flock (Linux/GNU systems). +# +# Args: +# $1 — jq filter string +# $@ — remaining args passed to jq +# +# Returns: +# 0 on success; 1 on jq or write failure. +_core_registry_update_with_flock() { local jq_filter="$1"; shift local lock="$CKIPPER_DIR/.registry.lock" - mkdir -p "$CKIPPER_DIR" - : > "$lock" local rc=1 - if command -v flock >/dev/null 2>&1; then - { - flock -x 9 - local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") - if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then - mv "$tmp" "$CKIPPER_REGISTRY" - chmod 600 "$CKIPPER_REGISTRY" - rc=0 - else - rm -f "$tmp" - fi - } 9>"$lock" - else - # Fallback for systems without flock (the default on macOS): mkdir-based lock, - # with stale-lock recovery so a SIGKILL'd ckipper doesn't permanently brick - # subsequent invocations. - setopt local_options local_traps - local lockdir="$CKIPPER_DIR/.registry.lock.d" - local attempts=0 - local notified=0 - while ! mkdir "$lockdir" 2>/dev/null; do - (( attempts++ )) - # Reassure the user something is happening — silent multi-second - # pauses look like a freeze. Print once at ~1.5s in, then again - # only if we recover a stale lock below. - if (( attempts == 30 && notified == 0 )); then - echo "Waiting on registry lock..." >&2 - notified=1 - fi - if (( attempts >= 200 )); then # 10s - local lockdir_age now - now=$(date +%s) - local mtime; mtime=$(_core_stat_mtime "$lockdir") - lockdir_age=$(( now - ${mtime:-$now} )) - if (( lockdir_age > 30 )); then - echo "Cleaning up old lock from a previous session (age ${lockdir_age}s)..." >&2 - rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" - attempts=0 - continue - fi - echo "Registry lock held by another process for ${lockdir_age}s. Try again shortly." >&2 - return 1 - fi - sleep 0.05 - done - trap 'rmdir "$lockdir" 2>/dev/null' EXIT - local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") - if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$tmp" 2>/dev/null; then - mv "$tmp" "$CKIPPER_REGISTRY" - chmod 600 "$CKIPPER_REGISTRY" + : > "$lock" + { + flock -x 9 + local registry_tmpfile; registry_tmpfile=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") + if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$registry_tmpfile" 2>/dev/null; then + mv "$registry_tmpfile" "$CKIPPER_REGISTRY" + chmod "$REGISTRY_FILE_PERMS" "$CKIPPER_REGISTRY" rc=0 else - rm -f "$tmp" + rm -f "$registry_tmpfile" fi - fi + } 9>"$lock" return $rc } +# Recover a stale mkdir-based lock directory and reset the attempt counter. +# Prints a warning to stderr, tries rmdir first, then falls back to rm -rf with warning. +# +# Args: +# $1 — lockdir path +# $2 — age in seconds (for the message) +# +# Returns: +# 0 after recovery attempt. +# +# Errors (stderr): +# "Cleaning up old lock..." — always printed when called. +# "Warning: rmdir failed..." — when rmdir fails and rm -rf fallback is used. +_core_registry_recover_stale_lock() { + local lockdir="$1" lock_age_seconds="$2" + echo "Cleaning up old lock from a previous session (age ${lock_age_seconds}s)..." >&2 + if ! rmdir "$lockdir" 2>/dev/null; then + echo "Warning: rmdir failed on lock dir (unexpected contents); using rm -rf as fallback." >&2 + rm -rf "$lockdir" + fi +} + +# Check if the mkdir lock is stale and recover if so. +# +# Args: +# $1 — lockdir path +# $2 — current attempts count +# +# Returns: +# 0 if stale lock was recovered (caller should reset attempts); +# 1 if not stale or lock age could not be determined; +# 2 if the lock is live but held too long (caller should abort). +_core_registry_check_stale_lock() { + local lockdir="$1" attempts="$2" + (( attempts < LOCK_MAX_ATTEMPTS )) && return 1 + local current_time_epoch modification_time_epoch lock_age_seconds + current_time_epoch=$(date +%s) + modification_time_epoch=$(_core_stat_mtime "$lockdir") + lock_age_seconds=$(( current_time_epoch - ${modification_time_epoch:-$current_time_epoch} )) + if (( lock_age_seconds > STALE_LOCK_AGE_THRESHOLD_SECONDS )); then + _core_registry_recover_stale_lock "$lockdir" "$lock_age_seconds" + return 0 + fi + echo "Registry lock held by another process for ${lock_age_seconds}s. Try again shortly." >&2 + return 2 +} + +# Wait for the mkdir lock to become available, with stale-lock recovery. +# Sets up the EXIT trap to release the lock on success. +# +# Args: +# $1 — lockdir path +# +# Returns: +# 0 when lock is acquired; 1 on timeout. +_core_registry_acquire_mkdir_lock() { + local lockdir="$1" + local attempts=0 notified=0 + while ! mkdir "$lockdir" 2>/dev/null; do + (( attempts++ )) + if (( attempts == LOCK_NOTIFY_THRESHOLD_ATTEMPTS && notified == 0 )); then + echo "Waiting on registry lock..." >&2 + notified=1 + fi + local stale_rc + _core_registry_check_stale_lock "$lockdir" "$attempts"; stale_rc=$? + if (( stale_rc == 0 )); then + attempts=0 + continue + fi + (( stale_rc == 2 )) && return 1 + sleep "$LOCK_RETRY_INTERVAL_SECONDS" + done + trap 'rmdir "$lockdir" 2>/dev/null' EXIT +} + +# Perform an atomic registry update via mkdir lock (macOS fallback — no flock). +# +# Args: +# $1 — jq filter string +# $@ — remaining args passed to jq +# +# Returns: +# 0 on success; 1 on lock timeout or jq/write failure. +_core_registry_update_mkdir_fallback() { + local jq_filter="$1"; shift + setopt local_options local_traps + local lockdir="$CKIPPER_DIR/.registry.lock.d" + _core_registry_acquire_mkdir_lock "$lockdir" || return 1 + local registry_tmpfile; registry_tmpfile=$(mktemp "$CKIPPER_DIR/.registry.tmp.XXXXXX") + if jq "$@" "$jq_filter" "$CKIPPER_REGISTRY" > "$registry_tmpfile" 2>/dev/null; then + mv "$registry_tmpfile" "$CKIPPER_REGISTRY" + chmod "$REGISTRY_FILE_PERMS" "$CKIPPER_REGISTRY" + return 0 + fi + rm -f "$registry_tmpfile" + return 1 +} + +# Atomic registry write under flock (or mkdir-fallback for macOS). +# +# Args: +# $1 — jq filter string; jq error() calls propagate as non-zero exit. +# $@ — remaining args passed through to jq (e.g. --arg n "$name") +# +# Returns: +# 0 on successful jq+write; 1 on jq error or write failure. +_core_registry_update() { + mkdir -p "$CKIPPER_DIR" + if command -v flock >/dev/null 2>&1; then + _core_registry_update_with_flock "$@" + else + _core_registry_update_mkdir_fallback "$@" + fi +} + # Initialize an empty registry with version field. Idempotent under concurrency # via atomic create (mv -n) — two concurrent ckipper init's won't clobber each other. +# +# Returns: +# 0 always. +# +# Errors (stderr): +# "Error: CKIPPER_REGISTRY_VERSION is not a positive integer" — when version var is invalid. _core_registry_init() { - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - mkdir -p "$CKIPPER_DIR" - local tmp; tmp=$(mktemp "$CKIPPER_DIR/.registry.init.XXXXXX") - cat > "$tmp" </dev/null || rm -f "$tmp" - [[ -f "$CKIPPER_REGISTRY" ]] && chmod 600 "$CKIPPER_REGISTRY" + [[ -f "$CKIPPER_REGISTRY" ]] && return 0 + if [[ ! "$CKIPPER_REGISTRY_VERSION" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: CKIPPER_REGISTRY_VERSION is not a positive integer: '$CKIPPER_REGISTRY_VERSION'" >&2 + return 1 fi + mkdir -p "$CKIPPER_DIR" + local registry_tmpfile; registry_tmpfile=$(mktemp "$CKIPPER_DIR/.registry.init.XXXXXX") + jq -n --argjson v "$CKIPPER_REGISTRY_VERSION" \ + '{"version": $v, "default": null, "accounts": {}}' > "$registry_tmpfile" + # mv -n (no-clobber): if another writer beat us, leave their file alone. + mv -n "$registry_tmpfile" "$CKIPPER_REGISTRY" 2>/dev/null || rm -f "$registry_tmpfile" + [[ -f "$CKIPPER_REGISTRY" ]] && chmod "$REGISTRY_FILE_PERMS" "$CKIPPER_REGISTRY" } # Refuse to operate on a registry whose version we don't understand OR whose schema # is corrupt (e.g. user manually edited and turned .accounts into an array). +# +# Returns: +# 0 if registry is absent or valid; 1 on version mismatch or corrupt schema. +# +# Errors (stderr): +# "Error: registry version..." — on version mismatch. +# "Error: ... is corrupt..." — on bad schema. _core_registry_check_version() { [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 local v @@ -102,7 +198,16 @@ _core_registry_check_version() { fi } -# Validates that an account exists in the registry. Echoes its config_dir on success. +# Validate that an account exists in the registry. Echoes its config_dir on success. +# +# Args: +# $1 — account name +# +# Returns: +# 0 with config_dir on stdout; 1 if account not found. +# +# Errors (stderr): +# "Account '...' is not registered." — when account absent. _core_account_dir() { local name="$1" if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then @@ -115,7 +220,8 @@ _core_account_dir() { # Read the entire registry JSON to stdout. Used by both ckipper subcommands # and (later) by lib/w/ to satisfy the no-sibling-cross-imports rule. # -# Returns: 0 on success; non-zero if registry file is missing. +# Returns: +# 0 on success; non-zero if registry file is missing. _core_registry_read() { cat "$CKIPPER_REGISTRY" } From 07810009bdbac5dd2dafec5ce3dbc94595fe19ea Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:54:32 -0600 Subject: [PATCH 050/165] Phase 4 (Track A fix): bring _ckipper_doctor and _ckipper_migrate_run under 25-line cap --- lib/ckipper/doctor.zsh | 341 ++++++++++++++++++++++++---------------- lib/ckipper/migrate.zsh | 84 ++++++---- 2 files changed, 256 insertions(+), 169 deletions(-) diff --git a/lib/ckipper/doctor.zsh b/lib/ckipper/doctor.zsh index 1727bdc..35d29a1 100644 --- a/lib/ckipper/doctor.zsh +++ b/lib/ckipper/doctor.zsh @@ -3,154 +3,221 @@ readonly MIN_HOOK_FILES=4 -# Run diagnostic checks and print results to stdout. -# Tracks FAIL and WARN counts and returns non-zero if any FAILs are found. +# Module-level counters shared across all doctor helpers. +typeset -g _CKIPPER_DOCTOR_FAIL=0 +typeset -g _CKIPPER_DOCTOR_WARN=0 + +# Print a single check result and increment the appropriate counter. +# +# Args: +# $1 — symbol: PASS, WARN, FAIL, or INFO +# $2 — message text # # Returns: -# 0 if all checks pass (warnings are non-fatal); 1 if any FAIL checks are found. -_ckipper_doctor() { - local fail=0 warn=0 - local check() { - local sym="$1" msg="$2" - case "$sym" in - PASS) printf " \033[32m[PASS]\033[0m %s\n" "$msg" ;; - WARN) printf " \033[33m[WARN]\033[0m %s\n" "$msg"; (( warn++ )) ;; - FAIL) printf " \033[31m[FAIL]\033[0m %s\n" "$msg"; (( fail++ )) ;; - INFO) printf " [INFO] %s\n" "$msg" ;; - esac - } - local _doctor_tooling() { - echo "── Tooling ───────────────────────────────────────────" - if [[ -d "$CKIPPER_DIR" ]]; then check PASS "$CKIPPER_DIR exists"; else check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi - if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then check PASS "w-function.zsh deployed"; else check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi - if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then check PASS "ckipper.zsh deployed"; else check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi - if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then check PASS "cleanup-projects.py deployed"; else check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi - if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then check PASS "settings-template.json deployed"; else check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi - if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= MIN_HOOK_FILES )); then - check PASS "hooks/ has ${MIN_HOOK_FILES}+ files" - else - check WARN "hooks/ is missing or has fewer than $MIN_HOOK_FILES hook files" - fi - } - local _doctor_registry() { - echo "" - echo "── Registry ──────────────────────────────────────────" - if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" - return 1 - fi - local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) - if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then check PASS "registry version $v matches expected" - else check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi - local perms; perms=$(_core_stat_perms "$CKIPPER_REGISTRY") - if [[ "$perms" == "600" ]]; then check PASS "registry permissions 600" - else check WARN "registry permissions $perms (expected 600)"; fi - local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") - if [[ -z "$default_acc" ]]; then - check WARN "no default account set — w/ckipper-add will require --account" - elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - check INFO "default account: $default_acc" - else - check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " - fi - } - local _doctor_account() { - local name="$1" - echo "" - echo " Account: $name" - local dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") - local svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") - if [[ -d "$dir" ]]; then check PASS " dir exists: $dir" - else check FAIL " dir missing: $dir"; fi - if [[ -f "$dir/.claude.json" ]]; then - local email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) - local proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) - local mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) - check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" - else - check WARN " .claude.json missing in $dir" - fi - if [[ -f "$dir/settings.json" ]]; then check PASS " settings.json present"; else check WARN " settings.json missing"; fi - if [[ -d "$dir/hooks" ]]; then check PASS " hooks/ deployed"; else check WARN " hooks/ missing — run: ckipper sync-hooks"; fi - _doctor_account_plugins "$name" "$dir" - _doctor_account_keychain "$svc" "$name" - } - local _doctor_account_plugins() { - local name="$1" dir="$2" - local stale_pm=0 - local pm - for pm in known_marketplaces.json installed_plugins.json; do - [[ -f "$dir/plugins/$pm" ]] || continue - if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then - stale_pm=1 - fi - done - if (( stale_pm )); then - check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" - fi - } - local _doctor_account_keychain() { - local svc="$1" name="$2" - [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 - if [[ -z "$svc" ]]; then - check INFO " keychain_service: null (account uses on-disk credentials)" - elif ! _core_keychain_validate "$svc"; then - check FAIL " keychain_service has invalid shape: $svc" - elif security find-generic-password -s "$svc" >/dev/null 2>&1; then - check PASS " keychain entry present: $svc" - else - check WARN " keychain entry NOT FOUND: $svc — re-run /login with: claude-$name" - fi - } - local _doctor_accounts() { - echo "" - echo "── Per-account state ────────────────────────────────" - local names; names=$(jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY") - if [[ -z "$names" ]]; then - check WARN "registry has no accounts" - return 0 - fi - local name - while IFS= read -r name; do - _doctor_account "$name" - done <<< "$names" - } - local _doctor_shell() { - echo "" - echo "── Aliases & shell integration ──────────────────────" - if [[ -f "$CKIPPER_DIR/aliases.zsh" ]]; then check PASS "aliases.zsh exists at $CKIPPER_DIR/aliases.zsh" - else check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi - if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources aliases.zsh" - else check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi - if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then check PASS "~/.zshrc sources w-function.zsh" - else check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi - echo "" - echo "── Stub files (cosmetic) ────────────────────────────" - if [[ -d "$HOME/.claude" ]]; then - local stub_count; stub_count=$(ls -1A "$HOME/.claude" 2>/dev/null | wc -l | tr -d ' ') - check WARN "~/.claude exists ($stub_count files) — Claude Code may have recreated it. Safe to: rm -rf ~/.claude" - else - check PASS "~/.claude (stub dir) is absent" +# 0 always. +_ckipper_doctor_check() { + local sym="$1" msg="$2" + case "$sym" in + PASS) printf " \033[32m[PASS]\033[0m %s\n" "$msg" ;; + WARN) printf " \033[33m[WARN]\033[0m %s\n" "$msg"; (( _CKIPPER_DOCTOR_WARN += 1 )) ;; + FAIL) printf " \033[31m[FAIL]\033[0m %s\n" "$msg"; (( _CKIPPER_DOCTOR_FAIL += 1 )) ;; + INFO) printf " [INFO] %s\n" "$msg" ;; + esac +} + +# Check that all required ckipper tool files and hook files are deployed. +# +# Returns: +# 0 always (results printed via _ckipper_doctor_check). +_ckipper_doctor_tooling() { + echo "── Tooling ───────────────────────────────────────────" + if [[ -d "$CKIPPER_DIR" ]]; then _ckipper_doctor_check PASS "$CKIPPER_DIR exists"; else _ckipper_doctor_check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi + if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then _ckipper_doctor_check PASS "w-function.zsh deployed"; else _ckipper_doctor_check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then _ckipper_doctor_check PASS "ckipper.zsh deployed"; else _ckipper_doctor_check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi + if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then _ckipper_doctor_check PASS "cleanup-projects.py deployed"; else _ckipper_doctor_check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then _ckipper_doctor_check PASS "settings-template.json deployed"; else _ckipper_doctor_check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi + if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= MIN_HOOK_FILES )); then + _ckipper_doctor_check PASS "hooks/ has ${MIN_HOOK_FILES}+ files" + else + _ckipper_doctor_check WARN "hooks/ is missing or has fewer than $MIN_HOOK_FILES hook files" + fi +} + +# Check registry version, permissions, and default account validity. +# +# Returns: +# 0 if registry exists and checks run; 1 if registry file is missing. +_ckipper_doctor_registry() { + echo "" + echo "── Registry ──────────────────────────────────────────" + if [[ ! -f "$CKIPPER_REGISTRY" ]]; then + _ckipper_doctor_check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" + return 1 + fi + local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + if [[ "$v" == "$CKIPPER_REGISTRY_VERSION" ]]; then _ckipper_doctor_check PASS "registry version $v matches expected" + else _ckipper_doctor_check FAIL "registry version $v != expected $CKIPPER_REGISTRY_VERSION"; fi + local perms; perms=$(_core_stat_perms "$CKIPPER_REGISTRY") + if [[ "$perms" == "600" ]]; then _ckipper_doctor_check PASS "registry permissions 600" + else _ckipper_doctor_check WARN "registry permissions $perms (expected 600)"; fi + local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") + if [[ -z "$default_acc" ]]; then + _ckipper_doctor_check WARN "no default account set — w/ckipper-add will require --account" + elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + _ckipper_doctor_check INFO "default account: $default_acc" + else + _ckipper_doctor_check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " + fi +} + +# Check plugin metadata files for a single account for stale paths. +# +# Args: +# $1 — account name +# $2 — account config directory +# +# Returns: +# 0 always. +_ckipper_doctor_account_plugins() { + local name="$1" dir="$2" + local stale_pm=0 + local pm + for pm in known_marketplaces.json installed_plugins.json; do + [[ -f "$dir/plugins/$pm" ]] || continue + if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then + stale_pm=1 fi - if [[ -f "$HOME/.claude.json" ]]; then check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." - else check PASS "~/.claude.json (home root) is absent"; fi - } - _doctor_tooling - if ! _doctor_registry; then + done + if (( stale_pm )); then + _ckipper_doctor_check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" + fi +} + +# Check macOS Keychain entry presence for a single account. +# +# Args: +# $1 — keychain service string (may be empty) +# $2 — account name +# +# Returns: +# 0 always (skips on non-darwin). +_ckipper_doctor_account_keychain() { + local svc="$1" name="$2" + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" != darwin* ]] && return 0 + if [[ -z "$svc" ]]; then + _ckipper_doctor_check INFO " keychain_service: null (account uses on-disk credentials)" + elif ! _core_keychain_validate "$svc"; then + _ckipper_doctor_check FAIL " keychain_service has invalid shape: $svc" + elif security find-generic-password -s "$svc" >/dev/null 2>&1; then + _ckipper_doctor_check PASS " keychain entry present: $svc" + else + _ckipper_doctor_check WARN " keychain entry NOT FOUND: $svc — re-run /login with: claude-$name" + fi +} + +# Check config directory, .claude.json, settings.json, hooks, and plugins for one account. +# +# Args: +# $1 — account name +# +# Returns: +# 0 always. +_ckipper_doctor_account() { + local name="$1" + echo "" + echo " Account: $name" + local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") + local svc; svc=$(jq -r --arg n "$name" '.accounts[$n].keychain_service // ""' "$CKIPPER_REGISTRY") + if [[ -d "$dir" ]]; then _ckipper_doctor_check PASS " dir exists: $dir" + else _ckipper_doctor_check FAIL " dir missing: $dir"; fi + if [[ -f "$dir/.claude.json" ]]; then + local email; email=$(jq -r '.oauthAccount.emailAddress // "(none)"' "$dir/.claude.json" 2>/dev/null) + local proj_count; proj_count=$(jq '.projects | length // 0' "$dir/.claude.json" 2>/dev/null) + local mcp_count; mcp_count=$(jq '.mcpServers | length // 0' "$dir/.claude.json" 2>/dev/null) + _ckipper_doctor_check PASS " .claude.json: oauth=$email, projects=$proj_count, mcps=$mcp_count" + else + _ckipper_doctor_check WARN " .claude.json missing in $dir" + fi + if [[ -f "$dir/settings.json" ]]; then _ckipper_doctor_check PASS " settings.json present"; else _ckipper_doctor_check WARN " settings.json missing"; fi + if [[ -d "$dir/hooks" ]]; then _ckipper_doctor_check PASS " hooks/ deployed"; else _ckipper_doctor_check WARN " hooks/ missing — run: ckipper sync-hooks"; fi + _ckipper_doctor_account_plugins "$name" "$dir" + _ckipper_doctor_account_keychain "$svc" "$name" +} + +# Iterate registry accounts and run per-account checks. +# +# Returns: +# 0 always. +_ckipper_doctor_accounts() { + echo "" + echo "── Per-account state ────────────────────────────────" + local names; names=$(jq -r '.accounts | keys[]?' "$CKIPPER_REGISTRY") + if [[ -z "$names" ]]; then + _ckipper_doctor_check WARN "registry has no accounts" return 0 fi - _doctor_accounts - _doctor_shell + local name + while IFS= read -r name; do + _ckipper_doctor_account "$name" + done <<< "$names" +} + +# Check aliases.zsh and .zshrc integration lines, plus stub dir/file presence. +# +# Returns: +# 0 always. +_ckipper_doctor_shell() { + echo "" + echo "── Aliases & shell integration ──────────────────────" + if [[ -f "$CKIPPER_DIR/aliases.zsh" ]]; then _ckipper_doctor_check PASS "aliases.zsh exists at $CKIPPER_DIR/aliases.zsh" + else _ckipper_doctor_check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi + if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then _ckipper_doctor_check PASS "~/.zshrc sources aliases.zsh" + else _ckipper_doctor_check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi + if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then _ckipper_doctor_check PASS "~/.zshrc sources w-function.zsh" + else _ckipper_doctor_check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi + echo "" + echo "── Stub files (cosmetic) ────────────────────────────" + if [[ -d "$HOME/.claude" ]]; then + local stub_count; stub_count=$(ls -1A "$HOME/.claude" 2>/dev/null | wc -l | tr -d ' ') + _ckipper_doctor_check WARN "~/.claude exists ($stub_count files) — Claude Code may have recreated it. Safe to: rm -rf ~/.claude" + else + _ckipper_doctor_check PASS "~/.claude (stub dir) is absent" + fi + if [[ -f "$HOME/.claude.json" ]]; then _ckipper_doctor_check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." + else _ckipper_doctor_check PASS "~/.claude.json (home root) is absent"; fi +} + +# Print the three-state summary line (FAIL / WARN-only / all-passed). +# +# Returns: +# 0 if no FAILs; 1 if any FAILs. +_ckipper_doctor_summary() { echo "" echo "──────────────────────────────────────────────────────" - if (( fail > 0 )); then - printf "Result: \033[31m%d FAIL\033[0m, \033[33m%d WARN\033[0m\n" "$fail" "$warn" + if (( _CKIPPER_DOCTOR_FAIL > 0 )); then + printf "Result: \033[31m%d FAIL\033[0m, \033[33m%d WARN\033[0m\n" "$_CKIPPER_DOCTOR_FAIL" "$_CKIPPER_DOCTOR_WARN" return 1 - elif (( warn > 0 )); then - printf "Result: \033[33m%d WARN\033[0m\n" "$warn" + elif (( _CKIPPER_DOCTOR_WARN > 0 )); then + printf "Result: \033[33m%d WARN\033[0m\n" "$_CKIPPER_DOCTOR_WARN" return 0 else printf "Result: \033[32mall checks passed\033[0m\n" return 0 fi } + +# Run all diagnostic checks and print results to stdout. +# +# Returns: +# 0 if all checks pass (warnings are non-fatal); 1 if any FAIL checks are found. +_ckipper_doctor() { + _CKIPPER_DOCTOR_FAIL=0 + _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_tooling + if ! _ckipper_doctor_registry; then + return 0 + fi + _ckipper_doctor_accounts + _ckipper_doctor_shell + _ckipper_doctor_summary +} diff --git a/lib/ckipper/migrate.zsh b/lib/ckipper/migrate.zsh index 18a86b2..1eae6dc 100644 --- a/lib/ckipper/migrate.zsh +++ b/lib/ckipper/migrate.zsh @@ -1,6 +1,10 @@ #!/usr/bin/env zsh # One-time migration from legacy ~/.claude/docker/ layout. +# Module globals tracking destructive migration steps for rollback. +typeset -g _CKIPPER_MIGRATE_STEP=0 +typeset -g _CKIPPER_MIGRATE_BACKUP="" + # Check preconditions for migration: no running Claude, no symlinks at key paths. # # Args: @@ -144,7 +148,7 @@ _ckipper_migrate_detect_keychain() { } # Execute the rename of ~/.claude to the target dir, then move ~/.claude.json if present. -# Updates migrate_step and moved_homejson_backup in the caller's scope via namerefs. +# Updates _CKIPPER_MIGRATE_STEP and _CKIPPER_MIGRATE_BACKUP module globals to track progress. # # Args: # $1 — legacy_claude path @@ -153,6 +157,10 @@ _ckipper_migrate_detect_keychain() { # # Returns: # 0 on success; 1 on failure (rollback should be called by the caller). +# +# Errors (stderr): +# "Error: failed to rename ..." — when mv of the directory fails. +# "Error: failed to move ..." — when mv of .claude.json fails. _ckipper_migrate_rename_dirs() { local legacy_claude="$1" legacy_homejson="$2" target_dir="$3" if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then @@ -160,17 +168,17 @@ _ckipper_migrate_rename_dirs() { echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 return 1 fi - migrate_step=1 + _CKIPPER_MIGRATE_STEP=1 [[ ! -f "$legacy_homejson" ]] && return 0 if [[ -f "$target_dir/.claude.json" ]]; then - moved_homejson_backup="$target_dir/.claude.json.pre-migrate-backup" - mv "$target_dir/.claude.json" "$moved_homejson_backup" + _CKIPPER_MIGRATE_BACKUP="$target_dir/.claude.json.pre-migrate-backup" + mv "$target_dir/.claude.json" "$_CKIPPER_MIGRATE_BACKUP" fi if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 return 1 fi - migrate_step=2 + _CKIPPER_MIGRATE_STEP=2 } # Print the migration success message with next steps. @@ -268,8 +276,40 @@ _ckipper_migrate() { _ckipper_migrate_run "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" "$probed_service" } -# Run the destructive migration steps with rollback on failure. -# Uses a locally-defined rollback function to capture step tracking variables. +# Undo destructive migration steps on failure or interruption. +# Reads _CKIPPER_MIGRATE_STEP and _CKIPPER_MIGRATE_BACKUP module globals. +# +# Args: +# $1 — account name +# $2 — target directory +# $3 — legacy_claude path +# $4 — legacy home json path +# $5 — reason label (e.g. "failed", "interrupted") +# +# Returns: +# 0 always. +_ckipper_migrate_rollback() { + local name="$1" target_dir="$2" legacy_claude="$3" legacy_homejson="$4" why="${5:-rollback}" + if (( _CKIPPER_MIGRATE_STEP >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then + mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null + [[ -n "$_CKIPPER_MIGRATE_BACKUP" && -f "$_CKIPPER_MIGRATE_BACKUP" ]] && \ + mv "$_CKIPPER_MIGRATE_BACKUP" "$target_dir/.claude.json" 2>/dev/null + fi + if (( _CKIPPER_MIGRATE_STEP >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then + mv "$target_dir" "$legacy_claude" 2>/dev/null + echo "Migration $why — restored $legacy_claude." >&2 + fi + if [[ -f "$CKIPPER_REGISTRY" ]] && \ + jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + _core_registry_update \ + 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ + --arg n "$name" + echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 + fi + _ckipper_regenerate_aliases 2>/dev/null || true +} + +# Run the destructive migration steps with rollback on failure or interruption. # # Args: # $1 — account name @@ -282,32 +322,12 @@ _ckipper_migrate() { # 0 on success; 1 on failure (rollback applied). _ckipper_migrate_run() { local name="$1" target_dir="$2" legacy_claude="$3" legacy_homejson="$4" probed_service="$5" - local migrate_step=0 moved_homejson_backup="" - local _ckipper_migrate_rollback() { - local why="${1:-rollback}" - if (( migrate_step >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then - mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null - [[ -n "$moved_homejson_backup" && -f "$moved_homejson_backup" ]] && \ - mv "$moved_homejson_backup" "$target_dir/.claude.json" 2>/dev/null - fi - if (( migrate_step >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then - mv "$target_dir" "$legacy_claude" 2>/dev/null - echo "Migration $why — restored $legacy_claude." >&2 - fi - if [[ -f "$CKIPPER_REGISTRY" ]] && \ - jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - _core_registry_update \ - 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ - --arg n "$name" - echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 - fi - _ckipper_regenerate_aliases 2>/dev/null || true - } - trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT ERR; return 130' INT TERM HUP QUIT + _CKIPPER_MIGRATE_STEP=0 + _CKIPPER_MIGRATE_BACKUP="" + trap '_ckipper_migrate_rollback "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" interrupted; trap - INT TERM HUP QUIT; return 130' INT TERM HUP QUIT _ckipper_migrate_run_steps "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" "$probed_service" local run_rc=$? trap - INT TERM HUP QUIT - unset -f _ckipper_migrate_rollback (( run_rc == 0 )) && _ckipper_migrate_finalize return $run_rc } @@ -326,12 +346,12 @@ _ckipper_migrate_run() { _ckipper_migrate_run_steps() { local name="$1" target_dir="$2" legacy_claude="$3" legacy_homejson="$4" probed_service="$5" _ckipper_migrate_rename_dirs "$legacy_claude" "$legacy_homejson" "$target_dir" || { - _ckipper_migrate_rollback failed + _ckipper_migrate_rollback "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" failed return 1 } _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then - _ckipper_migrate_rollback failed + _ckipper_migrate_rollback "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" failed return 1 fi } From 1dff1b5444a7462a1855bc6013bb12b327d6001e Mon Sep 17 00:00:00 2001 From: Matt White Date: Tue, 28 Apr 2026 23:55:28 -0600 Subject: [PATCH 051/165] Phase 4: document zsh completion _w() as exempt from 25-line cap (DSL data in heredoc) --- w-function.zsh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/w-function.zsh b/w-function.zsh index 5fa77cb..d99f4f4 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -98,6 +98,11 @@ _w_usage() { fpath=(~/.zsh/completions $fpath) if [[ ! -f ~/.zsh/completions/_w ]]; then + # Note: `_w()` below is a zsh tab-completion definition embedded in a heredoc. + # It uses zsh's _arguments DSL and must remain a single function for tab + # completion to work. The 25-line cap in code-style.md does not apply to + # zsh completion definitions (this is data written to a completion file, + # not maintained shell logic). cat > ~/.zsh/completions/_w << 'COMPEOF' #compdef w From 745d32b306a5ff330e233ba9dbcc5f74bdf88157 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 00:00:59 -0600 Subject: [PATCH 052/165] Phase 5 (Track C): pytest for cleanup-projects + install.sh integration + entrypoint stubs --- docker/cleanup-projects_test.py | 112 ++++++++++++++++++++++++++++++++ docker/entrypoint_test.bats | 25 +++++++ install_test.bats | 44 +++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 docker/cleanup-projects_test.py create mode 100644 docker/entrypoint_test.bats create mode 100644 install_test.bats diff --git a/docker/cleanup-projects_test.py b/docker/cleanup-projects_test.py new file mode 100644 index 0000000..9425df8 --- /dev/null +++ b/docker/cleanup-projects_test.py @@ -0,0 +1,112 @@ +"""Tests for cleanup-projects.py.""" + +import json +import os +from pathlib import Path + +import importlib.util + +_HERE = Path(__file__).parent +_SPEC = importlib.util.spec_from_file_location( + "cleanup_projects", _HERE / "cleanup-projects.py" +) +_MOD = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_MOD) + +all_account_dirs = _MOD.all_account_dirs +remove_worktree_from_all = _MOD.remove_worktree_from_all +sync_worktree_settings = _MOD.sync_worktree_settings +_load_settings = _MOD._load_settings +_merge_settings_keys = _MOD._merge_settings_keys + + +def test_all_account_dirs_returns_config_dirs(tmp_path): + """Returns config_dir for every registered account.""" + registry = tmp_path / "accounts.json" + registry.write_text(json.dumps({ + "version": 1, "default": None, + "accounts": { + "a": {"config_dir": "/tmp/a"}, + "b": {"config_dir": "/tmp/b"}, + }, + })) + + result = all_account_dirs(str(registry)) + + assert sorted(result) == ["/tmp/a", "/tmp/b"] + + +def test_all_account_dirs_returns_empty_for_missing_registry(tmp_path): + """Returns empty list when registry does not exist.""" + result = all_account_dirs(str(tmp_path / "nonexistent.json")) + + assert result == [] + + +def test_load_settings_returns_empty_dict_when_missing(tmp_path): + """Missing .claude.json returns empty dict (not exception).""" + result = _load_settings(str(tmp_path / "nope.json")) + + assert result == {} + + +def test_merge_settings_keys_copies_specified_keys(): + """Merge copies only the listed keys from source into target.""" + target = {"a": 1} + source = {"a": 2, "b": 3, "c": 4} + + _merge_settings_keys(target, source, ["b"]) + + assert target == {"a": 1, "b": 3} + assert "c" not in target + + +def test_remove_worktree_from_all_strips_project_key(tmp_path): + """remove_worktree_from_all removes the worktree's entry from each account's .claude.json.""" + account_a = tmp_path / "account-a" + account_a.mkdir() + (account_a / ".claude.json").write_text(json.dumps({ + "projects": {"/wt/foo": {"setting": "value"}, "/wt/bar": {}} + })) + + registry = tmp_path / "accounts.json" + registry.write_text(json.dumps({ + "version": 1, "default": None, + "accounts": {"a": {"config_dir": str(account_a)}}, + })) + + remove_worktree_from_all(str(registry), "/wt/foo") + + after = json.loads((account_a / ".claude.json").read_text()) + assert "/wt/foo" not in after.get("projects", {}) + assert "/wt/bar" in after.get("projects", {}) + + +def test_sync_worktree_settings_with_dict_context(tmp_path): + """sync_worktree_settings accepts a context dict (refactored from 4 positional args).""" + main = tmp_path / "main" + wt = tmp_path / "wt" + main.mkdir() + wt.mkdir() + account = tmp_path / "account" + account.mkdir() + + main_claude = main / ".claude.json" + wt_claude = wt / ".claude.json" + main_claude.write_text(json.dumps({"projects": {str(main): {"key": "from_main"}}})) + + registry = tmp_path / "accounts.json" + registry.write_text(json.dumps({ + "version": 1, "default": None, + "accounts": {"acct": {"config_dir": str(account)}}, + })) + + context = { + "registry_path": str(registry), + "account_name": "acct", + "main_path": str(main), + "worktree_path": str(wt), + } + + # Should not raise + sync_worktree_settings(context) diff --git a/docker/entrypoint_test.bats b/docker/entrypoint_test.bats new file mode 100644 index 0000000..502acdf --- /dev/null +++ b/docker/entrypoint_test.bats @@ -0,0 +1,25 @@ +#!/usr/bin/env bats + +load "${BATS_TEST_DIRNAME}/../tests/lib/test-helper.bash" + +setup() { + [ "${BATS_INTEGRATION:-}" = "1" ] || skip "entrypoint tests gated by BATS_INTEGRATION=1" + setup_isolated_env +} + +teardown() { + [ "${BATS_INTEGRATION:-}" = "1" ] || return 0 + teardown_isolated_env +} + +@test "entrypoint writes credentials without trailing newline" { + skip "Requires container env" +} + +@test "entrypoint enforces 1MB credentials bounds check" { + skip "Requires container env" +} + +@test "entrypoint configures git from oauth account name" { + skip "Requires container env" +} diff --git a/install_test.bats b/install_test.bats new file mode 100644 index 0000000..f1663c8 --- /dev/null +++ b/install_test.bats @@ -0,0 +1,44 @@ +#!/usr/bin/env bats + +load "${BATS_TEST_DIRNAME}/tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +@test "install.sh fresh install creates ~/.ckipper/docker/ tree with lib/" { + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + [ -d "$TMP_HOME/.ckipper/docker/lib/core" ] + [ -d "$TMP_HOME/.ckipper/docker/lib/ckipper" ] + [ -d "$TMP_HOME/.ckipper/docker/lib/w" ] + [ -f "$TMP_HOME/.ckipper/docker/ckipper.zsh" ] + [ -f "$TMP_HOME/.ckipper/docker/w-function.zsh" ] +} + +@test "install.sh excludes test files from deployed lib/" { + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + run find "$TMP_HOME/.ckipper/docker/lib" \( -name '*_test.*' -o -name '__pycache__' \) + [ -z "$output" ] +} + +@test "install.sh re-run preserves existing w-config.zsh" { + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" run "$REPO_ROOT/install.sh" + [ "$status" -eq 0 ] + echo 'CUSTOM_VALUE="preserve_me"' >> "$TMP_HOME/.ckipper/docker/w-config.zsh" + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + grep -q 'CUSTOM_VALUE="preserve_me"' "$TMP_HOME/.ckipper/docker/w-config.zsh" +} From 64053988d506967307d9fd2bbed2074765da1031 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 00:03:46 -0600 Subject: [PATCH 053/165] Phase 5 (Track B): bats tests for lib/ckipper/ Adds 40 tests across 6 new test files targeting the module-level helpers produced by Phase 4's functional decomposition. --- lib/ckipper/account-management_test.bats | 124 +++++++++++++++++++++++ lib/ckipper/aliases_test.bats | 92 +++++++++++++++++ lib/ckipper/doctor_test.bats | 84 +++++++++++++++ lib/ckipper/migrate_test.bats | 105 +++++++++++++++++++ lib/ckipper/plugin-repair_test.bats | 89 ++++++++++++++++ lib/ckipper/sync_test.bats | 110 ++++++++++++++++++++ 6 files changed, 604 insertions(+) create mode 100644 lib/ckipper/account-management_test.bats create mode 100644 lib/ckipper/aliases_test.bats create mode 100644 lib/ckipper/doctor_test.bats create mode 100644 lib/ckipper/migrate_test.bats create mode 100644 lib/ckipper/plugin-repair_test.bats create mode 100644 lib/ckipper/sync_test.bats diff --git a/lib/ckipper/account-management_test.bats b/lib/ckipper/account-management_test.bats new file mode 100644 index 0000000..0ced64c --- /dev/null +++ b/lib/ckipper/account-management_test.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats +# Unit tests for lib/ckipper/account-management.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run a zsh expression with the full ckipper environment sourced. +run_helper() { + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" +} + +# ── _ckipper_add_validate_name ─────────────────────────────────────── + +@test "validate_name accepts valid lowercase-alphanumeric names" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_add_validate_name "myaccount"' + + [ "$status" -eq 0 ] +} + +@test "validate_name accepts names with hyphens and underscores" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_add_validate_name "my-account_1"' + + [ "$status" -eq 0 ] +} + +@test "validate_name rejects an empty name" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_add_validate_name ""' + + [ "$status" -ne 0 ] + [[ "$output" =~ [Uu]sage ]] +} + +@test "validate_name rejects names with uppercase letters" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_add_validate_name "MyAccount"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "must match" ]] +} + +@test "validate_name rejects names with spaces" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_add_validate_name "my account"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "must match" ]] +} + +@test "validate_name rejects a name already registered" { + echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_add_validate_name "work"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "already registered" ]] +} + +# ── _ckipper_bare_alias_safe ───────────────────────────────────────── + +@test "bare_alias_safe returns 1 for shell builtin 'cd'" { + # 'cd' is a zsh builtin — using it as a bare alias would shadow it. + run_helper '_ckipper_bare_alias_safe "cd" && echo SAFE || echo UNSAFE' + + [[ "$output" =~ "UNSAFE" ]] +} + +@test "bare_alias_safe returns 0 for an invented name that cannot shadow anything" { + # A random name with no PATH binary, no builtin, no alias. + run_helper '_ckipper_bare_alias_safe "xyzzy_no_clash_9q7" && echo SAFE || echo UNSAFE' + + [[ "$output" =~ "SAFE" ]] +} + +# ── _ckipper_list ──────────────────────────────────────────────────── + +@test "list shows 'No accounts' message when registry is missing" { + rm -f "$CKIPPER_REGISTRY" + + run_helper '_ckipper_list' + + [ "$status" -eq 0 ] + [[ "$output" =~ "No accounts" ]] +} + +@test "list shows registered account name" { + echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_list' + + [ "$status" -eq 0 ] + [[ "$output" =~ "work" ]] +} + +@test "list marks the default account with an asterisk" { + echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_list' + + [ "$status" -eq 0 ] + [[ "$output" =~ "* work" ]] +} diff --git a/lib/ckipper/aliases_test.bats b/lib/ckipper/aliases_test.bats new file mode 100644 index 0000000..ec7bfd9 --- /dev/null +++ b/lib/ckipper/aliases_test.bats @@ -0,0 +1,92 @@ +#!/usr/bin/env bats +# Unit tests for lib/ckipper/aliases.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run a zsh expression with the full ckipper environment sourced. +run_helper() { + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" +} + +# ── _ckipper_regenerate_aliases ─────────────────────────────────────── + +@test "regenerate_aliases creates aliases.zsh with mode 644" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_regenerate_aliases' + + local out="$CKIPPER_DIR/aliases.zsh" + assert_file_exists "$out" + assert_file_mode "$out" "644" +} + +@test "regenerate_aliases includes a launcher function for each registered account" { + echo '{"version":1,"default":"dev","accounts":{"dev":{"config_dir":"/tmp/.claude-dev","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_regenerate_aliases' + + local out="$CKIPPER_DIR/aliases.zsh" + assert_file_exists "$out" + grep -q "claude-dev()" "$out" +} + +# ── _ckipper_generate_account_launcher_function ─────────────────────── + +@test "generate_account_launcher_function emits a claude- function" { + run_helper '_ckipper_generate_account_launcher_function "work" "/tmp/.claude-work"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "claude-work()" ]] +} + +@test "generate_account_launcher_function sets CLAUDE_CONFIG_DIR in the emitted body" { + run_helper '_ckipper_generate_account_launcher_function "work" "/tmp/.claude-work"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "CLAUDE_CONFIG_DIR" ]] + [[ "$output" =~ "/tmp/.claude-work" ]] +} + +# ── _ckipper_sync_hooks_for ────────────────────────────────────────── + +@test "sync_hooks_for copies hooks into the account directory" { + echo '{"version":1,"default":"dev","accounts":{"dev":{"config_dir":"'"$TMP_HOME"'/.claude-dev","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-dev" + # Seed the shared hooks directory with a test hook. + mkdir -p "$CKIPPER_DIR/hooks" + echo "#!/bin/sh" > "$CKIPPER_DIR/hooks/test-hook.sh" + + run_helper '_ckipper_sync_hooks_for "dev"' + + [ "$status" -eq 0 ] + [ -f "$TMP_HOME/.claude-dev/hooks/test-hook.sh" ] +} + +@test "sync_hooks_for rewrites dollar-HOME hook paths in settings.json" { + echo '{"version":1,"default":"dev","accounts":{"dev":{"config_dir":"'"$TMP_HOME"'/.claude-dev","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-dev/hooks" + # settings.json has a literal $HOME placeholder in a hook path. + printf '{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"$HOME/.ckipper/hooks/pre.sh"}]}]}}' \ + > "$TMP_HOME/.claude-dev/settings.json" + + run_helper '_ckipper_sync_hooks_for "dev"' + + # After rewriting, the path should point to the account's hooks dir. + grep -q "$TMP_HOME/.claude-dev/hooks/pre.sh" "$TMP_HOME/.claude-dev/settings.json" +} diff --git a/lib/ckipper/doctor_test.bats b/lib/ckipper/doctor_test.bats new file mode 100644 index 0000000..a60ebe5 --- /dev/null +++ b/lib/ckipper/doctor_test.bats @@ -0,0 +1,84 @@ +#!/usr/bin/env bats +# Unit tests for lib/ckipper/doctor.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run a zsh expression with the full ckipper environment sourced. +run_helper() { + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" +} + +# ── _ckipper_doctor_check ───────────────────────────────────────────── + +@test "doctor_check PASS prints PASS label without incrementing counters" { + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_check PASS "all good" + echo "fail=$_CKIPPER_DOCTOR_FAIL warn=$_CKIPPER_DOCTOR_WARN"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "PASS" ]] + [[ "$output" =~ "fail=0" ]] + [[ "$output" =~ "warn=0" ]] +} + +@test "doctor_check WARN prints WARN label and increments warn counter" { + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_check WARN "something fishy" + echo "fail=$_CKIPPER_DOCTOR_FAIL warn=$_CKIPPER_DOCTOR_WARN"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "WARN" ]] + [[ "$output" =~ "warn=1" ]] +} + +@test "doctor_check FAIL prints FAIL label and increments fail counter" { + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_check FAIL "broken" + echo "fail=$_CKIPPER_DOCTOR_FAIL warn=$_CKIPPER_DOCTOR_WARN"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "FAIL" ]] + [[ "$output" =~ "fail=1" ]] +} + +# ── _ckipper_doctor_summary ─────────────────────────────────────────── + +@test "doctor_summary returns 0 when no FAILs or WARNs" { + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_summary' + + [ "$status" -eq 0 ] + [[ "$output" =~ "all checks passed" ]] +} + +@test "doctor_summary returns 0 when only WARNs (no FAILs)" { + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=2 + _ckipper_doctor_summary' + + [ "$status" -eq 0 ] + [[ "$output" =~ "WARN" ]] +} + +@test "doctor_summary returns 1 when there are FAILs" { + run_helper '_CKIPPER_DOCTOR_FAIL=1; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_summary' + + [ "$status" -ne 0 ] + [[ "$output" =~ "FAIL" ]] +} diff --git a/lib/ckipper/migrate_test.bats b/lib/ckipper/migrate_test.bats new file mode 100644 index 0000000..cd81ecd --- /dev/null +++ b/lib/ckipper/migrate_test.bats @@ -0,0 +1,105 @@ +#!/usr/bin/env bats +# Unit tests for lib/ckipper/migrate.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). +# +# Interactive-prompt helpers (_ckipper_migrate_prompt_account_name, +# _ckipper_migrate_confirm_plan, _ckipper_migrate_detect_keychain) are skipped +# because they block on stdin; the full end-to-end flow is covered by +# the characterization tests in ckipper_test.bats. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run a zsh expression with the full ckipper environment sourced. +run_helper() { + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" +} + +# ── _ckipper_migrate_check_state ────────────────────────────────────── + +@test "check_state returns 1 (no state) when neither legacy dir nor home json exist" { + # Fresh TMP_HOME has no .claude or .claude.json. + run_helper '_ckipper_migrate_check_state "$HOME/.claude" "$HOME/.claude.json"; echo "rc=$?"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "rc=1" ]] +} + +@test "check_state returns 0 (needs migration) when home-root .claude.json exists" { + echo '{}' > "$TMP_HOME/.claude.json" + + run_helper '_ckipper_migrate_check_state "$HOME/.claude" "$HOME/.claude.json"; echo "rc=$?"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "rc=0" ]] +} + +@test "check_state returns 2 (already migrated) when registry has accounts and no legacy state" { + echo '{"version":1,"default":"personal","accounts":{"personal":{"config_dir":"/tmp/.claude-personal","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + echo '{}' > "$TMP_HOME/.claude.json" + + run_helper '_ckipper_migrate_check_state "$HOME/.claude" "$HOME/.claude.json"; echo "rc=$?"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "rc=2" ]] +} + +# ── _ckipper_migrate_preflight ──────────────────────────────────────── + +@test "preflight passes when neither legacy path is a symlink" { + mkdir -p "$TMP_HOME/.claude" + echo '{}' > "$TMP_HOME/.claude.json" + + run_helper '_ckipper_migrate_preflight "$HOME/.claude" "$HOME/.claude.json"' + + [ "$status" -eq 0 ] +} + +@test "preflight fails when legacy claude dir is a symlink" { + mkdir -p "$TMP_HOME/.claude-target" + ln -s "$TMP_HOME/.claude-target" "$TMP_HOME/.claude" + + run_helper '_ckipper_migrate_preflight "$HOME/.claude" "$HOME/.claude.json"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "symlink" ]] +} + +# ── _ckipper_migrate_rename_dirs ────────────────────────────────────── + +@test "rename_dirs moves legacy claude dir to the target directory" { + mkdir -p "$TMP_HOME/.claude" + local target_dir="$TMP_HOME/.claude-personal" + + run_helper "_ckipper_migrate_rename_dirs \"$TMP_HOME/.claude\" \"$TMP_HOME/.claude.json\" \"$target_dir\"" + + [ "$status" -eq 0 ] + [ -d "$target_dir" ] + [ ! -d "$TMP_HOME/.claude" ] +} + +@test "rename_dirs also moves home-root .claude.json into the target dir" { + mkdir -p "$TMP_HOME/.claude" + echo '{"projects":[]}' > "$TMP_HOME/.claude.json" + local target_dir="$TMP_HOME/.claude-personal" + + run_helper "_ckipper_migrate_rename_dirs \"$TMP_HOME/.claude\" \"$TMP_HOME/.claude.json\" \"$target_dir\"" + + [ "$status" -eq 0 ] + [ -f "$target_dir/.claude.json" ] + [ ! -f "$TMP_HOME/.claude.json" ] +} diff --git a/lib/ckipper/plugin-repair_test.bats b/lib/ckipper/plugin-repair_test.bats new file mode 100644 index 0000000..1f2218a --- /dev/null +++ b/lib/ckipper/plugin-repair_test.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# Unit tests for lib/ckipper/plugin-repair.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run a zsh expression with the full ckipper environment sourced. +run_helper() { + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" +} + +# ── _ckipper_detect_stale_plugin_prefix ────────────────────────────── + +@test "detect_stale_plugin_prefix finds old prefix in known_marketplaces.json" { + local acc_dir="$TMP_HOME/.claude-personal" + mkdir -p "$acc_dir/plugins" + # Write a marketplace file with a stale ~/.claude/ prefix. + printf '{"url":"%s/plugins/marketplace.json"}' "$TMP_HOME/.claude/" \ + > "$acc_dir/plugins/known_marketplaces.json" + + run_helper "_ckipper_detect_stale_plugin_prefix \"$acc_dir\"" + + [ "$status" -eq 0 ] + [[ "$output" =~ ".claude/" ]] +} + +# ── _ckipper_rewrite_plugin_paths ───────────────────────────────────── + +@test "rewrite_plugin_paths replaces old prefix with new prefix in plugin files" { + local old_dir="$TMP_HOME/.claude/" + local new_dir="$TMP_HOME/.claude-personal/" + mkdir -p "${new_dir}plugins" + # Seed a marketplace file using the old prefix path. + printf '{"url":"%s/plugins/marketplace.json"}' "$old_dir" \ + > "${new_dir}plugins/known_marketplaces.json" + + # Use darwin ostype so the code picks `sed -i ''` (macOS compatible form). + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="darwin" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" + + [ "$status" -eq 0 ] + grep -q "$new_dir" "${new_dir}plugins/known_marketplaces.json" + ! grep -q "$old_dir" "${new_dir}plugins/known_marketplaces.json" +} + +@test "rewrite_plugin_paths is idempotent — running twice produces the same result" { + local old_dir="$TMP_HOME/.claude/" + local new_dir="$TMP_HOME/.claude-personal/" + mkdir -p "${new_dir}plugins" + printf '{"url":"%s/plugins/marketplace.json"}' "$old_dir" \ + > "${new_dir}plugins/known_marketplaces.json" + + # First run — replaces old prefix with new prefix. + run env \ + HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" _CKIPPER_TEST_OSTYPE="darwin" CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" + local content_after_first; content_after_first=$(cat "${new_dir}plugins/known_marketplaces.json") + + # Second run — old prefix is gone so this is a no-op; output must be identical. + run env \ + HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" _CKIPPER_TEST_OSTYPE="darwin" CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" + local content_after_second; content_after_second=$(cat "${new_dir}plugins/known_marketplaces.json") + + [ "$content_after_first" = "$content_after_second" ] +} diff --git a/lib/ckipper/sync_test.bats b/lib/ckipper/sync_test.bats new file mode 100644 index 0000000..1d61137 --- /dev/null +++ b/lib/ckipper/sync_test.bats @@ -0,0 +1,110 @@ +#!/usr/bin/env bats +# Unit tests for lib/ckipper/sync.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run a zsh expression with the full ckipper environment sourced. +run_helper() { + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="linux" \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" +} + +# ── _ckipper_sync_parse_flags ───────────────────────────────────────── + +@test "parse_flags sets mode_all=1 when no flags given" { + run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 + _ckipper_sync_parse_flags + echo "mode_all=$mode_all"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "mode_all=1" ]] +} + +@test "parse_flags sets dry_run=1 for --dry-run flag" { + run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 + _ckipper_sync_parse_flags --dry-run + echo "dry_run=$dry_run"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "dry_run=1" ]] +} + +@test "parse_flags sets mode_mcp=1 for --mcp flag" { + run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 + _ckipper_sync_parse_flags --mcp + echo "mode_mcp=$mode_mcp"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "mode_mcp=1" ]] +} + +@test "parse_flags sets mode_settings=1 for --settings flag" { + run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 + _ckipper_sync_parse_flags --settings "enabledPlugins" + echo "mode_settings=$mode_settings"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "mode_settings=1" ]] +} + +@test "parse_flags returns 1 and prints error for unknown flag" { + run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 + _ckipper_sync_parse_flags --bogus-flag' + + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown flag" ]] +} + +# ── _ckipper_sync_mcp_servers ───────────────────────────────────────── + +@test "sync_mcp_servers merges MCP servers into the destination claude.json" { + local from_dir="$TMP_HOME/.claude-src" + local to_dir="$TMP_HOME/.claude-dst" + mkdir -p "$from_dir" "$to_dir" + printf '{"mcpServers":{"testserver":{"command":"npx","args":["-y","test-mcp"]}}}' \ + > "$from_dir/.claude.json" + printf '{"mcpServers":{}}' > "$to_dir/.claude.json" + + run_helper 'pending_msgs=() + _ckipper_sync_mcp_servers "'"$from_dir"'" "dst" "'"$to_dir"'" "" "0" + echo "${pending_msgs[@]}"' + + [ "$status" -eq 0 ] + # The destination should now contain the server from the source. + local dst_content; dst_content=$(cat "$to_dir/.claude.json") + [[ "$dst_content" =~ "testserver" ]] +} + +@test "sync_mcp_servers is a no-op in dry-run mode (no writes)" { + local from_dir="$TMP_HOME/.claude-src" + local to_dir="$TMP_HOME/.claude-dst" + mkdir -p "$from_dir" "$to_dir" + printf '{"mcpServers":{"myserver":{"command":"node","args":[]}}}' \ + > "$from_dir/.claude.json" + printf '{"mcpServers":{}}' > "$to_dir/.claude.json" + + local before_dst; before_dst=$(cat "$to_dir/.claude.json") + + run_helper 'pending_msgs=() + _ckipper_sync_mcp_servers "'"$from_dir"'" "dst" "'"$to_dir"'" "" "1"' + + [ "$status" -eq 0 ] + # Destination must be unchanged in dry-run mode. + local after_dst; after_dst=$(cat "$to_dir/.claude.json") + [ "$before_dst" = "$after_dst" ] +} From 3b6268ae16772ff3e220e3462e05d3cadc72d2b2 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 00:03:54 -0600 Subject: [PATCH 054/165] Phase 5 (Track D): doc-headers gap-fill + public-release files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add doc-headers to ckipper(), _ckipper_help(), and w() (the three public entry points that were missing the standard block format). - Create LICENSE (MIT, 2026 Matt White), CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, and CODE_OF_CONDUCT.md (Contributor Covenant 2.1). - Fix stale ~/.claude/docker/ path → ~/.ckipper/docker/ in .claude/settings.local.json (gitignored; edited in-place). --- CHANGELOG.md | 21 ++++++++++++ CODE_OF_CONDUCT.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 46 +++++++++++++++++++++++++ LICENSE | 21 ++++++++++++ SECURITY.md | 27 +++++++++++++++ ckipper.zsh | 16 +++++++++ w-function.zsh | 12 +++++++ 7 files changed, 226 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..12ed711 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] — 2026-04-28 + +### Added +- Initial public release. +- Multi-account Claude Code management (`ckipper add/remove/rename/list/default/sync/doctor/migrate`). +- `w()` worktree-aware launcher with normal and Docker (`--docker --firewall`) modes. +- Auto-generated per-account aliases. +- Safety hooks: `bash-guardrails.sh`, `protect-claude-config.sh`, `docker-context.sh`, `notify-bell.sh`. +- Docker sandbox with egress firewall. +- Modular architecture: `lib/core/` (shared) + `lib/ckipper/` + `lib/w/`. +- Test suite: bats-core (shell) + pytest (Python). +- CI: GitHub Actions on macos-latest. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4617114 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at matt@msw.dev. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bda68df --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to Ckipper + +Thanks for considering a contribution! + +## Quick start + +```sh +make bootstrap # installs bats-core, shellcheck, shfmt, ruff, pytest +make test # run all tests +make lint # run all linters +``` + +## Workflow + +1. Branch off `develop`. Branch name: `feature/{ticket-or-slug}-{short-description}`. +2. Make changes. Tests required for any decision-heavy logic — see [`.claude/rules/testing.md`](.claude/rules/testing.md). +3. `make test && make lint` must pass before you request review. +4. Open a **draft** PR targeting `develop`. The author marks it "Ready for review" when they're happy. + +## Code style + +We follow the rules in [`.claude/rules/`](.claude/rules/) — please read them. Highlights: + +- **25-line function cap** (excluding blank lines and `}`-only lines). HARD LIMIT. +- **2-level nesting cap.** Use early returns / guard clauses. +- **3-parameter cap.** Beyond that, introduce a context object or rethink the design. +- **No magic numbers.** Extract to `readonly UPPER_SNAKE_CASE` constants. +- **No abbreviations.** `idx` → `index`, `ans` → `user_choice`, `tmp` → `*_tmpfile`. +- **Doc-headers on every public function.** See [`.claude/rules/shell-conventions.md`](.claude/rules/shell-conventions.md). + +## File organization + +- `lib/core/` — shared primitives (registry, keychain, utils). Used by both ckipper and w. +- `lib/ckipper/` — ckipper-specific subcommands. +- `lib/w/` — w-specific helpers. **Must NOT call `_ckipper_*` functions** (sibling cross-import). CI enforces this. +- Tests are colocated with source: `foo.zsh` + `foo_test.bats`. + +## Testing + +- **Shell:** bats-core. Hand-written stubs under `tests/lib/stubs/`. No mocking libraries. +- **Python:** pytest. +- **Test what matters:** business logic, decision points, file I/O contracts. Skip trivial wrappers. + +## Reporting bugs / feature requests + +Open an issue on GitHub. For security vulnerabilities, see [`SECURITY.md`](SECURITY.md) — please do NOT open a public issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..32f39fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Matt White + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ebde373 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting a vulnerability + +Please do **NOT** open a public GitHub issue for security vulnerabilities. + +Instead, report privately via: +- GitHub Security Advisories: https://github.com/whmoro/ckipper/security/advisories/new +- Email: matt@msw.dev + +We aim to acknowledge reports within 72 hours and to provide a fix or mitigation timeline within 7 days. + +## Scope + +In scope: +- Credential handling (Keychain access, container injection, file permissions). +- Hook bypass where the bypass affects security (note that `bash-guardrails.sh` is a UX guardrail by design, not a security boundary). +- Container escape vectors in the Docker sandbox. +- Path traversal or shell injection in CLI tools. + +Out of scope: +- Bypasses of `bash-guardrails.sh` via `bash -c`/heredocs/eval — this is documented, intentional, and not a security boundary. +- Issues requiring physical access to the host. + +## Supported versions + +This is a single-version-stream project. The latest release on `main` is the only supported version. diff --git a/ckipper.zsh b/ckipper.zsh index 7d15b8e..866dfb2 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -22,6 +22,18 @@ source "$CKIPPER_REPO_DIR/lib/ckipper/sync.zsh" source "$CKIPPER_REPO_DIR/lib/ckipper/doctor.zsh" source "$CKIPPER_REPO_DIR/lib/ckipper/migrate.zsh" +# Dispatch a ckipper subcommand or print top-level help. +# +# Args: +# $1 — subcommand name (add, list, default, remove, rename, sync, sync-hooks, +# migrate, doctor, repair-plugins, help, -h, --help, or empty) +# $@ — arguments forwarded to the subcommand handler +# +# Returns: +# 0 on success; 1 on unknown subcommand. +# +# Errors (stderr): +# "Unknown command: " — when the subcommand is not recognised. ckipper() { local cmd="$1" shift 2>/dev/null @@ -39,6 +51,10 @@ ckipper() { esac } +# Print the top-level ckipper usage summary to stdout. +# +# Returns: +# 0 always. _ckipper_help() { cat <<'EOF' ckipper (pronounced "skipper") — multi-account Claude Code manager diff --git a/w-function.zsh b/w-function.zsh index d99f4f4..744618c 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -42,6 +42,18 @@ fi (( ${#W_EXTRA_VOLUMES[@]} == 0 )) && W_EXTRA_VOLUMES=() (( ${#W_EXTRA_ENV[@]} == 0 )) && W_EXTRA_ENV=() +# Worktree-aware Claude Code launcher. +# +# Args: +# $1 — project path (relative to ~/Developer), or a flag (--list, --rm, --rebuild-image) +# $2 — branch/worktree name (required unless $1 is --list or --rebuild-image) +# $@ — optional flags and command: [--docker] [--firewall] [--account ] [cmd...] +# +# Returns: +# 0 on success; 1 on usage error or launch failure. +# +# Errors (stderr): +# "Error: --firewall requires --docker" — when --firewall is passed without --docker. w() { _w_parse_args "$@" From e29d69d20d9eb9970887b5aeccc2bc5654e97e1f Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 00:04:24 -0600 Subject: [PATCH 055/165] Phase 5 (Track A): bats tests for lib/core/, lib/w/, and hooks/ Add 41 module-level bats tests covering individual helper functions in lib/core/ (registry, keychain, utils), lib/w/ (args, ports, resolve-account, worktree, docker-mode, normal-mode, build-image), and all four hooks. Introduce CKIPPER_DOCKERENV override in each hook's Docker-guard check so tests can simulate the in-container environment without requiring root. Add gtimeout and claude stubs to tests/lib/stubs/ for new test paths. --- hooks/bash-guardrails.sh | 3 +- hooks/bash-guardrails_test.bats | 44 ++++++++++++ hooks/docker-context.sh | 3 +- hooks/docker-context_test.bats | 21 ++++++ hooks/notify-bell.sh | 3 +- hooks/notify-bell_test.bats | 24 +++++++ hooks/protect-claude-config.sh | 4 +- hooks/protect-claude-config_test.bats | 43 ++++++++++++ lib/core/keychain_test.bats | 59 ++++++++++++++++ lib/core/registry_test.bats | 99 +++++++++++++++++++++++++++ lib/core/utils_test.bats | 44 ++++++++++++ lib/w/args_test.bats | 49 +++++++++++++ lib/w/build-image_test.bats | 30 ++++++++ lib/w/docker-mode_test.bats | 88 ++++++++++++++++++++++++ lib/w/normal-mode_test.bats | 30 ++++++++ lib/w/ports_test.bats | 45 ++++++++++++ lib/w/resolve-account_test.bats | 74 ++++++++++++++++++++ lib/w/worktree_test.bats | 57 +++++++++++++++ tests/lib/stubs/claude | 4 ++ tests/lib/stubs/gtimeout | 7 ++ 20 files changed, 726 insertions(+), 5 deletions(-) create mode 100644 hooks/bash-guardrails_test.bats create mode 100644 hooks/docker-context_test.bats create mode 100644 hooks/notify-bell_test.bats create mode 100644 hooks/protect-claude-config_test.bats create mode 100644 lib/core/keychain_test.bats create mode 100644 lib/core/registry_test.bats create mode 100644 lib/core/utils_test.bats create mode 100644 lib/w/args_test.bats create mode 100644 lib/w/build-image_test.bats create mode 100644 lib/w/docker-mode_test.bats create mode 100644 lib/w/normal-mode_test.bats create mode 100644 lib/w/ports_test.bats create mode 100644 lib/w/resolve-account_test.bats create mode 100644 lib/w/worktree_test.bats create mode 100755 tests/lib/stubs/claude create mode 100755 tests/lib/stubs/gtimeout diff --git a/hooks/bash-guardrails.sh b/hooks/bash-guardrails.sh index 2b4618c..ae3f474 100755 --- a/hooks/bash-guardrails.sh +++ b/hooks/bash-guardrails.sh @@ -12,7 +12,8 @@ # Adversarial users can defeat any regex-based guard. Treat this hook as a reminder, # not a defense. The container sandbox + firewall are the actual security boundary. -[ ! -f /.dockerenv ] && exit 0 +# Skip guardrails when not in Docker (CKIPPER_DOCKERENV overrides path for testing) +[ ! -f "${CKIPPER_DOCKERENV:-/.dockerenv}" ] && exit 0 INPUT="$(cat)" CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') || { diff --git a/hooks/bash-guardrails_test.bats b/hooks/bash-guardrails_test.bats new file mode 100644 index 0000000..8477eab --- /dev/null +++ b/hooks/bash-guardrails_test.bats @@ -0,0 +1,44 @@ +#!/usr/bin/env bats +# Module-level tests for hooks/bash-guardrails.sh. +# Uses CKIPPER_DOCKERENV to simulate being inside a Docker container. + +load "${BATS_TEST_DIRNAME}/../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export CKIPPER_DOCKERENV="$TMP_HOME/fake-dockerenv" + touch "$CKIPPER_DOCKERENV" +} + +teardown() { + teardown_isolated_env +} + +# Helper: pipe JSON command input to the hook. +_run_guardrails() { + local cmd="$1" + local input_json="{\"tool_input\":{\"command\":\"$cmd\"}}" + run env CKIPPER_DOCKERENV="$CKIPPER_DOCKERENV" \ + bash "$REPO_ROOT/hooks/bash-guardrails.sh" <<< "$input_json" +} + +@test "bash-guardrails blocks rm -rf on non-build-artifact paths" { + _run_guardrails "rm -rf /home/user/important-files" + + [ "$status" -eq 2 ] + [[ "$output" =~ "Blocked" ]] +} + +@test "bash-guardrails allows a safe read-only command" { + _run_guardrails "ls /workspace/src" + + [ "$status" -eq 0 ] +} + +@test "bash-guardrails fails closed on invalid JSON input" { + run env CKIPPER_DOCKERENV="$CKIPPER_DOCKERENV" \ + bash "$REPO_ROOT/hooks/bash-guardrails.sh" <<< "not-json" + + [ "$status" -eq 2 ] + [[ "$output" =~ "Error" || "$output" =~ "error" || "$output" =~ "JSON" ]] +} diff --git a/hooks/docker-context.sh b/hooks/docker-context.sh index 7727d4e..13dbb9f 100755 --- a/hooks/docker-context.sh +++ b/hooks/docker-context.sh @@ -3,7 +3,8 @@ # Reminds Claude of constraints so it doesn't accidentally trigger guardrails. # No-op on the host. -[ ! -f /.dockerenv ] && exit 0 +# Skip context injection when not in Docker (CKIPPER_DOCKERENV overrides path for testing) +[ ! -f "${CKIPPER_DOCKERENV:-/.dockerenv}" ] && exit 0 cat <<'CONTEXT' You are running inside a Docker container with --dangerously-skip-permissions. diff --git a/hooks/docker-context_test.bats b/hooks/docker-context_test.bats new file mode 100644 index 0000000..a1efb80 --- /dev/null +++ b/hooks/docker-context_test.bats @@ -0,0 +1,21 @@ +#!/usr/bin/env bats +# Module-level tests for hooks/docker-context.sh. +# Verifies that the hook is a no-op on the host (no /.dockerenv). + +load "${BATS_TEST_DIRNAME}/../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +@test "docker-context exits 0 and produces no output when not in Docker" { + # No CKIPPER_DOCKERENV set, so the file-not-found guard exits 0 early. + run env bash "$REPO_ROOT/hooks/docker-context.sh" + + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/hooks/notify-bell.sh b/hooks/notify-bell.sh index 1104426..716c91e 100755 --- a/hooks/notify-bell.sh +++ b/hooks/notify-bell.sh @@ -4,7 +4,8 @@ # triggering native notifications (dock bounce, sound, etc.). # No-op on the host (host Claude handles notifications natively). -[ ! -f /.dockerenv ] && exit 0 +# Skip bell when not in Docker (CKIPPER_DOCKERENV overrides path for testing) +[ ! -f "${CKIPPER_DOCKERENV:-/.dockerenv}" ] && exit 0 printf '\a' exit 0 diff --git a/hooks/notify-bell_test.bats b/hooks/notify-bell_test.bats new file mode 100644 index 0000000..f98b493 --- /dev/null +++ b/hooks/notify-bell_test.bats @@ -0,0 +1,24 @@ +#!/usr/bin/env bats +# Module-level tests for hooks/notify-bell.sh. +# Verifies that the hook emits the bell character when in Docker. + +load "${BATS_TEST_DIRNAME}/../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export CKIPPER_DOCKERENV="$TMP_HOME/fake-dockerenv" + touch "$CKIPPER_DOCKERENV" +} + +teardown() { + teardown_isolated_env +} + +@test "notify-bell emits a bell character (\\a) when inside Docker" { + run env CKIPPER_DOCKERENV="$CKIPPER_DOCKERENV" \ + bash "$REPO_ROOT/hooks/notify-bell.sh" + + [ "$status" -eq 0 ] + # The bell character is \x07. + [[ "$output" == $'\a' ]] +} diff --git a/hooks/protect-claude-config.sh b/hooks/protect-claude-config.sh index 35f9c11..8b2b2a6 100755 --- a/hooks/protect-claude-config.sh +++ b/hooks/protect-claude-config.sh @@ -4,8 +4,8 @@ # settings.json (statusLine.command) to execute arbitrary code on the host. # Only active in Docker containers — no-op on the host. -# Skip protection when not in Docker -[ ! -f /.dockerenv ] && exit 0 +# Skip protection when not in Docker (CKIPPER_DOCKERENV overrides path for testing) +[ ! -f "${CKIPPER_DOCKERENV:-/.dockerenv}" ] && exit 0 INPUT="$(cat)" FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') || { diff --git a/hooks/protect-claude-config_test.bats b/hooks/protect-claude-config_test.bats new file mode 100644 index 0000000..73a99fa --- /dev/null +++ b/hooks/protect-claude-config_test.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# Module-level tests for hooks/protect-claude-config.sh. +# Uses CKIPPER_DOCKERENV to simulate being inside a Docker container. + +load "${BATS_TEST_DIRNAME}/../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + # Create a fake .dockerenv so the Docker-only check passes. + export CKIPPER_DOCKERENV="$TMP_HOME/fake-dockerenv" + touch "$CKIPPER_DOCKERENV" +} + +teardown() { + teardown_isolated_env +} + +# Helper: pipe JSON input to the hook and capture exit code + output. +_run_protect() { + local input_json="$1" + run env CKIPPER_DOCKERENV="$CKIPPER_DOCKERENV" \ + bash "$REPO_ROOT/hooks/protect-claude-config.sh" <<< "$input_json" +} + +@test "protect-claude-config blocks writes to .claude/settings.json" { + _run_protect '{"tool_input":{"file_path":"/home/user/.claude/settings.json"}}' + + [ "$status" -eq 2 ] + [[ "$output" =~ "Blocked" ]] +} + +@test "protect-claude-config allows writes to an unprotected path" { + _run_protect '{"tool_input":{"file_path":"/home/user/projects/app/src/index.js"}}' + + [ "$status" -eq 0 ] +} + +@test "protect-claude-config fails closed on invalid JSON input" { + _run_protect "not-json" + + [ "$status" -eq 2 ] + [[ "$output" =~ "Error" || "$output" =~ "error" || "$output" =~ "JSON" ]] +} diff --git a/lib/core/keychain_test.bats b/lib/core/keychain_test.bats new file mode 100644 index 0000000..ce21ed4 --- /dev/null +++ b/lib/core/keychain_test.bats @@ -0,0 +1,59 @@ +#!/usr/bin/env bats +# Module-level tests for lib/core/keychain.zsh. +# Covers validate, snapshot_with_timeout, snapshot_fallback, running_claude_processes, +# and assert_no_running_claude. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export _CKIPPER_TEST_OSTYPE="darwin19.0" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source keychain.zsh and run zsh_cmd. +_run_keychain() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-darwin19.0}" \ + CKIPPER_FORCE="${CKIPPER_FORCE:-0}" \ + PGREP_STUB_MATCH="${PGREP_STUB_MATCH:-0}" \ + SECURITY_STUB_DUMP="${SECURITY_STUB_DUMP:-}" \ + PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/keychain.zsh\"; $zsh_cmd" +} + +@test "_core_keychain_validate accepts a valid service name" { + _run_keychain '_core_keychain_validate "Claude Code-credentials"' + + [ "$status" -eq 0 ] +} + +@test "_core_keychain_validate accepts a service name with hex suffix" { + _run_keychain '_core_keychain_validate "Claude Code-credentials-abc123"' + + [ "$status" -eq 0 ] +} + +@test "_core_keychain_validate rejects an empty service name" { + _run_keychain '_core_keychain_validate ""' + + [ "$status" -ne 0 ] +} + +@test "_core_keychain_validate rejects a name with wrong prefix" { + _run_keychain '_core_keychain_validate "NotClaude-credentials"' + + [ "$status" -ne 0 ] +} + +@test "_core_assert_no_running_claude passes when no Claude processes are running" { + export PGREP_STUB_MATCH=0 + + _run_keychain "_core_assert_no_running_claude" + + [ "$status" -eq 0 ] +} diff --git a/lib/core/registry_test.bats b/lib/core/registry_test.bats new file mode 100644 index 0000000..12faadc --- /dev/null +++ b/lib/core/registry_test.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# Module-level tests for lib/core/registry.zsh. +# Covers init, check_version, account_dir, registry_read, and update. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export CKIPPER_REGISTRY_VERSION=1 + export _CKIPPER_TEST_OSTYPE="darwin19.0" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source registry (and its utils dep) then run zsh_cmd. +_run_registry() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_REGISTRY_VERSION="${CKIPPER_REGISTRY_VERSION:-1}" \ + _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-darwin19.0}" \ + PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/utils.zsh\"; source \"$REPO_ROOT/lib/core/registry.zsh\"; $zsh_cmd" +} + +@test "_core_registry_init creates a fresh registry with mode 600" { + _run_registry "_core_registry_init" + + [ "$status" -eq 0 ] + assert_file_exists "$CKIPPER_REGISTRY" + assert_file_mode "$CKIPPER_REGISTRY" "600" +} + +@test "_core_registry_init produces valid JSON with version 1" { + _run_registry "_core_registry_init" + + [ "$status" -eq 0 ] + local version + version=$(jq -r '.version' "$CKIPPER_REGISTRY") + [ "$version" = "1" ] +} + +@test "_core_registry_init is idempotent (does not overwrite existing registry)" { + echo '{"version":1,"default":"preserved","accounts":{}}' > "$CKIPPER_REGISTRY" + chmod 600 "$CKIPPER_REGISTRY" + + _run_registry "_core_registry_init" + + [ "$status" -eq 0 ] + local default + default=$(jq -r '.default' "$CKIPPER_REGISTRY") + [ "$default" = "preserved" ] +} + +@test "_core_registry_init rejects non-integer version" { + export CKIPPER_REGISTRY_VERSION="not-a-number" + + _run_registry "_core_registry_init" + + [ "$status" -ne 0 ] +} + +@test "_core_registry_check_version passes on matching version" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + _run_registry "_core_registry_check_version" + + [ "$status" -eq 0 ] +} + +@test "_core_registry_check_version fails on version mismatch" { + echo '{"version":99,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + _run_registry "_core_registry_check_version" + + [ "$status" -ne 0 ] + [[ "$output" =~ "version" ]] +} + +@test "_core_account_dir returns config_dir for a known account" { + echo '{"version":1,"default":null,"accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + _run_registry "_core_account_dir work" + + [ "$status" -eq 0 ] + [ "$output" = "/tmp/.claude-work" ] +} + +@test "_core_account_dir fails for an unknown account" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + _run_registry "_core_account_dir nobody" + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} diff --git a/lib/core/utils_test.bats b/lib/core/utils_test.bats new file mode 100644 index 0000000..1e133aa --- /dev/null +++ b/lib/core/utils_test.bats @@ -0,0 +1,44 @@ +#!/usr/bin/env bats +# Module-level tests for lib/core/utils.zsh. +# Tests _core_stat_perms and _core_stat_mtime observable behaviour. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + # Use darwin ostype so the BSD stat flags are used on macOS host. + export _CKIPPER_TEST_OSTYPE="darwin19.0" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source utils.zsh and run a command in a zsh subshell. +_run_utils() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" _CKIPPER_TEST_OSTYPE="$_CKIPPER_TEST_OSTYPE" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/utils.zsh\"; $zsh_cmd" +} + +@test "_core_stat_perms returns the octal mode of a file" { + local target="$TMP_HOME/perm-test.txt" + touch "$target" + chmod 644 "$target" + + _run_utils "_core_stat_perms \"$target\"" + + [ "$status" -eq 0 ] + [ "$output" = "644" ] +} + +@test "_core_stat_mtime returns epoch seconds for a touched file" { + local target="$TMP_HOME/mtime-test.txt" + touch -t 202001010000 "$target" + + _run_utils "_core_stat_mtime \"$target\"" + + [ "$status" -eq 0 ] + # 2020-01-01 00:00 UTC → 1577836800. Accept any 10-digit epoch. + [[ "$output" =~ ^[0-9]{10}$ ]] +} diff --git a/lib/w/args_test.bats b/lib/w/args_test.bats new file mode 100644 index 0000000..2c4955e --- /dev/null +++ b/lib/w/args_test.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats +# Module-level tests for lib/w/args.zsh. +# Verifies that _w_parse_args sets the correct W_* globals. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: source args.zsh, call _w_parse_args with provided args, then print +# the value of $1 (variable name) to stdout. +_parse_and_print() { + local var_name="$1"; shift + run env HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/w/args.zsh\"; _w_parse_args $*; print -r -- \"\$$var_name\"" +} + +@test "_w_parse_args sets W_PROJECT and W_BRANCH for bare project/branch args" { + _parse_and_print "W_PROJECT" myapp feature-x + + [ "$status" -eq 0 ] + [ "$output" = "myapp" ] +} + +@test "_w_parse_args sets W_FLAG_RM to true for --rm flag" { + _parse_and_print "W_FLAG_RM" --rm myapp branch + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "_w_parse_args sets W_FLAG_DOCKER to true for --docker flag" { + _parse_and_print "W_FLAG_DOCKER" myapp feature-x --docker + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "_w_parse_args sets W_FLAG_LIST to true for --list flag" { + _parse_and_print "W_FLAG_LIST" --list + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} diff --git a/lib/w/build-image_test.bats b/lib/w/build-image_test.bats new file mode 100644 index 0000000..a8d3a77 --- /dev/null +++ b/lib/w/build-image_test.bats @@ -0,0 +1,30 @@ +#!/usr/bin/env bats +# Module-level tests for lib/w/build-image.zsh. +# Verifies _w_build_image invokes docker build when Dockerfile exists. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export DOCKER_STUB_LOG="$TMP_HOME/docker.log" + : > "$DOCKER_STUB_LOG" +} + +teardown() { + teardown_isolated_env +} + +@test "_w_build_image invokes docker build when Dockerfile is present" { + mkdir -p "$CKIPPER_DIR/docker" + touch "$CKIPPER_DIR/docker/Dockerfile" + + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + DOCKER_STUB_LOG="$DOCKER_STUB_LOG" \ + PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/w/build-image.zsh\"; _w_build_image" + + [ "$status" -eq 0 ] + [[ "$output" =~ "Building" ]] + grep -q "build" "$DOCKER_STUB_LOG" +} diff --git a/lib/w/docker-mode_test.bats b/lib/w/docker-mode_test.bats new file mode 100644 index 0000000..735b384 --- /dev/null +++ b/lib/w/docker-mode_test.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats +# Module-level tests for lib/w/docker-mode.zsh. +# Covers compute_volumes (via build_base_args), compute_envs (add_optional_args), +# and extract_credentials JSON validation. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export _CKIPPER_TEST_OSTYPE="darwin19.0" + export DOCKER_STUB_LOG="$TMP_HOME/docker.log" + : > "$DOCKER_STUB_LOG" + # Provide reasonable defaults for globals docker-mode reads. + export W_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" + export W_PROJECTS_DIR="$TMP_HOME/Developer" + export W_PROJECT="myapp" + export W_BRANCH="feature-x" + export W_ACTIVE_ACCOUNT="test" + export W_ACTIVE_CONFIG_DIR="$TMP_HOME/.claude-test" + export W_ACTIVE_KEYCHAIN_SERVICE="" + export W_EXTRA_VOLUMES=() + export W_EXTRA_ENV=() + export W_FLAG_FIREWALL=false + export W_PORTS=() + export W_COMMAND=() + mkdir -p "$W_ACTIVE_CONFIG_DIR" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source docker-mode.zsh and all deps, then run zsh_cmd. +_run_docker_mode() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + _CKIPPER_TEST_OSTYPE="darwin19.0" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + DOCKER_STUB_LOG="$DOCKER_STUB_LOG" \ + W_WT_PATH="$W_WT_PATH" \ + W_PROJECTS_DIR="$W_PROJECTS_DIR" \ + W_PROJECT="$W_PROJECT" \ + W_BRANCH="$W_BRANCH" \ + W_ACTIVE_ACCOUNT="$W_ACTIVE_ACCOUNT" \ + W_ACTIVE_CONFIG_DIR="$W_ACTIVE_CONFIG_DIR" \ + W_ACTIVE_KEYCHAIN_SERVICE="$W_ACTIVE_KEYCHAIN_SERVICE" \ + W_FLAG_FIREWALL="$W_FLAG_FIREWALL" \ + PATH="$PATH" \ + zsh -c " + typeset -a W_DOCKER_ARGS=() + typeset -a W_EXTRA_VOLUMES=() + typeset -a W_EXTRA_ENV=() + typeset -a W_PORTS=() + typeset -a W_COMMAND=() + source \"$REPO_ROOT/lib/core/utils.zsh\" + source \"$REPO_ROOT/lib/core/keychain.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" + source \"$REPO_ROOT/lib/w/ports.zsh\" + source \"$REPO_ROOT/lib/w/build-image.zsh\" + source \"$REPO_ROOT/lib/w/docker-mode.zsh\" + $zsh_cmd + " +} + +@test "_w_docker_build_base_args includes the worktree volume mount" { + _run_docker_mode "_w_docker_build_base_args; print -r -- \"\${W_DOCKER_ARGS[*]}\"" + + [ "$status" -eq 0 ] + [[ "$output" =~ "-v" ]] + [[ "$output" =~ "$W_WT_PATH:/workspace:rw" ]] +} + +@test "_w_docker_add_optional_args emits a warning when no credentials are provided" { + _run_docker_mode "_w_docker_build_base_args; _w_docker_add_optional_args '' ''" + + [ "$status" -eq 0 ] + [[ "$output" =~ "Warning" ]] +} + +@test "_w_docker_extract_credentials returns empty string when no keychain service is set" { + export W_ACTIVE_KEYCHAIN_SERVICE="" + + _run_docker_mode "result=\$(_w_docker_extract_credentials); print -r -- \"result=\$result\"" + + [ "$status" -eq 0 ] + [ "$output" = "result=" ] +} diff --git a/lib/w/normal-mode_test.bats b/lib/w/normal-mode_test.bats new file mode 100644 index 0000000..78a0c4e --- /dev/null +++ b/lib/w/normal-mode_test.bats @@ -0,0 +1,30 @@ +#!/usr/bin/env bats +# Module-level tests for lib/w/normal-mode.zsh. +# Covers the happy path: command runs in the worktree directory. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export W_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" + export W_BRANCH="feature-x" + mkdir -p "$W_WT_PATH" +} + +teardown() { + teardown_isolated_env +} + +@test "_w_run_normal_mode executes a stub claude binary in the worktree directory" { + run env HOME="$TMP_HOME" \ + W_WT_PATH="$W_WT_PATH" \ + W_BRANCH="$W_BRANCH" \ + PATH="$PATH" \ + zsh -c " + typeset -a W_COMMAND=(claude) + source \"$REPO_ROOT/lib/w/normal-mode.zsh\" + _w_run_normal_mode + " + + [ "$status" -eq 0 ] +} diff --git a/lib/w/ports_test.bats b/lib/w/ports_test.bats new file mode 100644 index 0000000..4b4b8bc --- /dev/null +++ b/lib/w/ports_test.bats @@ -0,0 +1,45 @@ +#!/usr/bin/env bats +# Module-level tests for lib/w/ports.zsh. +# Covers _w_bind_port success path and fallback when ports are busy. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + # Default: lsof stub treats all ports as free (exit 1 = not in use). + export LSOF_STUB_BUSY="" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source ports.zsh and run a ports expression. +_run_ports() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" PATH="$PATH" \ + LSOF_STUB_BUSY="${LSOF_STUB_BUSY:-}" \ + zsh -c " + source \"$REPO_ROOT/lib/w/ports.zsh\" + typeset -a W_DOCKER_ARGS=() + typeset -a W_RESOLVED_PORTS=() + $zsh_cmd + " +} + +@test "_w_bind_port appends a -p flag to W_DOCKER_ARGS when port is free" { + _run_ports '_w_bind_port 3000; print -r -- "${W_DOCKER_ARGS[*]}"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "-p 127.0.0.1:3000:3000" ]] +} + +@test "_w_bind_port logs a warning when all fallback ports are busy" { + # Mark every port from 3000 through 3009 as busy in the lsof stub. + export LSOF_STUB_BUSY="3000 3001 3002 3003 3004 3005 3006 3007 3008 3009" + + _run_ports '_w_bind_port 3000' + + [ "$status" -eq 0 ] + [[ "$output" =~ "no available host port" ]] +} diff --git a/lib/w/resolve-account_test.bats b/lib/w/resolve-account_test.bats new file mode 100644 index 0000000..df66b5d --- /dev/null +++ b/lib/w/resolve-account_test.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats +# Module-level tests for lib/w/resolve-account.zsh. +# Covers env-override, registry lookup, registry default fallback, and error path. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export _CKIPPER_TEST_OSTYPE="darwin19.0" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source all required modules and call _w_resolve_account; print a +# specific global var from the resolved state. Propagates _w_resolve_account's +# exit code so failure tests can assert status != 0. +_resolve_and_print() { + local var_name="$1" + local extra_env="${2:-}" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + _CKIPPER_TEST_OSTYPE="darwin19.0" \ + PATH="$PATH" \ + $extra_env \ + zsh -c " + source \"$REPO_ROOT/lib/core/utils.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" + source \"$REPO_ROOT/lib/w/resolve-account.zsh\" + _w_resolve_account || exit \$? + print -r -- \"\$$var_name\" + " +} + +@test "_w_resolve_account populates W_ACTIVE_ACCOUNT from registry default" { + echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"'"$TMP_HOME"'/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-work" + + _resolve_and_print "W_ACTIVE_ACCOUNT" + + [ "$status" -eq 0 ] + [ "$output" = "work" ] +} + +@test "_w_resolve_account populates W_ACTIVE_CONFIG_DIR from registry" { + echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"'"$TMP_HOME"'/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-work" + + _resolve_and_print "W_ACTIVE_CONFIG_DIR" + + [ "$status" -eq 0 ] + [ "$output" = "$TMP_HOME/.claude-work" ] +} + +@test "_w_resolve_account picks account by CLAUDE_CONFIG_DIR env match" { + echo '{"version":1,"default":null,"accounts":{"personal":{"config_dir":"'"$TMP_HOME"'/.claude-personal","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$TMP_HOME/.claude-personal" + + _resolve_and_print "W_ACTIVE_ACCOUNT" "CLAUDE_CONFIG_DIR=$TMP_HOME/.claude-personal" + + [ "$status" -eq 0 ] + [ "$output" = "personal" ] +} + +@test "_w_resolve_account fails when no account can be resolved" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + _resolve_and_print "W_ACTIVE_ACCOUNT" + + [ "$status" -ne 0 ] + [[ "$output" =~ "no account" || "$output" =~ "no default" ]] +} diff --git a/lib/w/worktree_test.bats b/lib/w/worktree_test.bats new file mode 100644 index 0000000..7f5a3d8 --- /dev/null +++ b/lib/w/worktree_test.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats +# Module-level tests for lib/w/worktree.zsh. +# Covers list (empty), remove (missing path), and create (project not found). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export W_PROJECTS_DIR="$TMP_HOME/Developer" + export W_WORKTREES_DIR="$TMP_HOME/Developer/.worktrees" + mkdir -p "$W_PROJECTS_DIR" "$W_WORKTREES_DIR" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source worktree.zsh (and its utils dep) then run zsh_cmd. +_run_worktree() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + W_PROJECTS_DIR="$W_PROJECTS_DIR" \ + W_WORKTREES_DIR="$W_WORKTREES_DIR" \ + W_ACTIVE_ACCOUNT="${W_ACTIVE_ACCOUNT:-test}" \ + W_ACTIVE_CONFIG_DIR="${W_ACTIVE_CONFIG_DIR:-$TMP_HOME/.claude-test}" \ + W_FLAG_FORCE="${W_FLAG_FORCE:-false}" \ + PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/core/utils.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" + source \"$REPO_ROOT/lib/w/worktree.zsh\" + $zsh_cmd + " +} + +@test "_w_list_worktrees prints header and exits 0 when worktrees dir is empty" { + _run_worktree "_w_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" =~ "Worktrees" ]] +} + +@test "_w_remove_worktree fails when worktree path does not exist" { + _run_worktree "_w_remove_worktree myapp nonexistent-branch" + + [ "$status" -ne 0 ] + [[ "$output" =~ "not found" || "$output" =~ "Worktree" ]] +} + +@test "_w_create_worktree fails when project directory does not exist" { + _run_worktree "_w_create_worktree nosuchproject feature-x" + + [ "$status" -ne 0 ] + [[ "$output" =~ "not found" || "$output" =~ "Project" ]] +} diff --git a/tests/lib/stubs/claude b/tests/lib/stubs/claude new file mode 100755 index 0000000..9f26ce3 --- /dev/null +++ b/tests/lib/stubs/claude @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Test stub for the claude CLI binary. +# Always exits 0 so normal-mode tests can exercise the invocation path. +exit 0 diff --git a/tests/lib/stubs/gtimeout b/tests/lib/stubs/gtimeout new file mode 100755 index 0000000..cd09a9d --- /dev/null +++ b/tests/lib/stubs/gtimeout @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Test stub for gtimeout. Pass-through: run the command unchanged. +# Satisfies _core_keychain_detect_timeout_cmd's gtimeout branch without +# actually timing out commands in tests. +# The first arg is the timeout duration — discard it, execute the rest. +shift +exec "$@" From f2d06dc787fdc1c05c85184d73a5163a6f3e25da Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 00:06:50 -0600 Subject: [PATCH 056/165] Phase 5 (Track B): expand ckipper module tests to cover all focus areas Add tests for: default/remove/rename_validate (account-management), sync_hooks (aliases), sync_settings_keys/print_summary (sync), doctor_accounts/tooling (doctor), rollback/idempotency (migrate). --- lib/ckipper/account-management_test.bats | 71 ++++++++++++++++++++++++ lib/ckipper/aliases_test.bats | 17 ++++++ lib/ckipper/doctor_test.bats | 37 ++++++++++++ lib/ckipper/migrate_test.bats | 35 ++++++++++++ lib/ckipper/sync_test.bats | 58 +++++++++++++++++++ 5 files changed, 218 insertions(+) diff --git a/lib/ckipper/account-management_test.bats b/lib/ckipper/account-management_test.bats index 0ced64c..2c28f7b 100644 --- a/lib/ckipper/account-management_test.bats +++ b/lib/ckipper/account-management_test.bats @@ -122,3 +122,74 @@ run_helper() { [ "$status" -eq 0 ] [[ "$output" =~ "* work" ]] } + +# ── _ckipper_default ────────────────────────────────────────────────── + +@test "default sets the default account in the registry" { + echo '{"version":1,"default":null,"accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_default "work"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "work" ]] + local val; val=$(jq -r '.default' "$CKIPPER_REGISTRY") + [ "$val" = "work" ] +} + +@test "default fails when account is not registered" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_default "nobody"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} + +# ── _ckipper_remove ─────────────────────────────────────────────────── + +@test "remove unregisters a known account and exits 0" { + echo '{"version":1,"default":null,"accounts":{"tmp":{"config_dir":"/tmp/.claude-tmp","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_remove "tmp"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "Unregistered" ]] +} + +@test "remove fails for an account that is not registered" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_remove "nobody"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} + +# ── _ckipper_rename_validate ────────────────────────────────────────── + +@test "rename_validate rejects an empty old name" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_rename_validate "" "newname"' + + [ "$status" -ne 0 ] + [[ "$output" =~ [Uu]sage ]] +} + +@test "rename_validate rejects a new name with uppercase letters" { + echo '{"version":1,"default":null,"accounts":{"old":{"config_dir":"/tmp/.claude-old","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_rename_validate "old" "NewName"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "must match" ]] +} + +@test "rename_validate rejects rename when old name is not registered" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_ckipper_rename_validate "ghost" "newname"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} diff --git a/lib/ckipper/aliases_test.bats b/lib/ckipper/aliases_test.bats index ec7bfd9..6405eaa 100644 --- a/lib/ckipper/aliases_test.bats +++ b/lib/ckipper/aliases_test.bats @@ -90,3 +90,20 @@ run_helper() { # After rewriting, the path should point to the account's hooks dir. grep -q "$TMP_HOME/.claude-dev/hooks/pre.sh" "$TMP_HOME/.claude-dev/settings.json" } + +# ── _ckipper_sync_hooks ─────────────────────────────────────────────── + +@test "sync_hooks iterates all registered accounts and copies hooks to each" { + local dir_a="$TMP_HOME/.claude-alpha" + local dir_b="$TMP_HOME/.claude-beta" + mkdir -p "$dir_a" "$dir_b" + echo '{"version":1,"default":"alpha","accounts":{"alpha":{"config_dir":"'"$dir_a"'","keychain_service":null},"beta":{"config_dir":"'"$dir_b"'","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + mkdir -p "$CKIPPER_DIR/hooks" + echo "#!/bin/sh" > "$CKIPPER_DIR/hooks/shared-hook.sh" + + run_helper '_ckipper_sync_hooks' + + [ "$status" -eq 0 ] + [ -f "$dir_a/hooks/shared-hook.sh" ] + [ -f "$dir_b/hooks/shared-hook.sh" ] +} diff --git a/lib/ckipper/doctor_test.bats b/lib/ckipper/doctor_test.bats index a60ebe5..e0db4c7 100644 --- a/lib/ckipper/doctor_test.bats +++ b/lib/ckipper/doctor_test.bats @@ -82,3 +82,40 @@ run_helper() { [ "$status" -ne 0 ] [[ "$output" =~ "FAIL" ]] } + +# ── _ckipper_doctor_accounts ────────────────────────────────────────── + +@test "doctor_accounts emits a WARN when the registry has no accounts" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_accounts' + + [ "$status" -eq 0 ] + [[ "$output" =~ "WARN" ]] + [[ "$output" =~ "no accounts" ]] +} + +@test "doctor_accounts lists each registered account by name" { + local acc_dir="$TMP_HOME/.claude-work" + mkdir -p "$acc_dir" + echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"'"$acc_dir"'","keychain_service":null}}}' > "$CKIPPER_REGISTRY" + + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_accounts' + + [ "$status" -eq 0 ] + [[ "$output" =~ "work" ]] +} + +# ── _ckipper_doctor_tooling ─────────────────────────────────────────── + +@test "doctor_tooling emits PASS when ckipper dir exists" { + # CKIPPER_DIR is already created by setup_isolated_env. + run_helper '_CKIPPER_DOCTOR_FAIL=0; _CKIPPER_DOCTOR_WARN=0 + _ckipper_doctor_tooling' + + [ "$status" -eq 0 ] + [[ "$output" =~ "PASS" ]] + [[ "$output" =~ "exists" ]] +} diff --git a/lib/ckipper/migrate_test.bats b/lib/ckipper/migrate_test.bats index cd81ecd..eb5bcae 100644 --- a/lib/ckipper/migrate_test.bats +++ b/lib/ckipper/migrate_test.bats @@ -103,3 +103,38 @@ run_helper() { [ -f "$target_dir/.claude.json" ] [ ! -f "$TMP_HOME/.claude.json" ] } + +# ── _ckipper_migrate_rollback ───────────────────────────────────────── + +@test "rollback restores the target dir to the legacy path when step >= 1" { + # Simulate a mid-migration state: target dir was created, no .claude exists. + local target_dir="$TMP_HOME/.claude-personal" + local legacy_dir="$TMP_HOME/.claude" + mkdir -p "$target_dir" + + run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" + _ckipper_migrate_rollback \"personal\" \"$target_dir\" \"$legacy_dir\" \"$TMP_HOME/.claude.json\" \"failed\"" + + [ "$status" -eq 0 ] + # The target dir should be moved back to the legacy location. + [ -d "$legacy_dir" ] + [ ! -d "$target_dir" ] +} + +@test "rollback is idempotent — running twice on already-restored state is safe" { + local target_dir="$TMP_HOME/.claude-personal" + local legacy_dir="$TMP_HOME/.claude" + mkdir -p "$target_dir" + + # First rollback — restores legacy dir. + run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" + _ckipper_migrate_rollback \"personal\" \"$target_dir\" \"$legacy_dir\" \"$TMP_HOME/.claude.json\" \"failed\"" + [ "$status" -eq 0 ] + [ -d "$legacy_dir" ] + + # Second rollback — target_dir no longer exists, so no move happens; legacy_dir stays. + run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" + _ckipper_migrate_rollback \"personal\" \"$target_dir\" \"$legacy_dir\" \"$TMP_HOME/.claude.json\" \"failed\"" + [ "$status" -eq 0 ] + [ -d "$legacy_dir" ] +} diff --git a/lib/ckipper/sync_test.bats b/lib/ckipper/sync_test.bats index 1d61137..eaab669 100644 --- a/lib/ckipper/sync_test.bats +++ b/lib/ckipper/sync_test.bats @@ -108,3 +108,61 @@ run_helper() { local after_dst; after_dst=$(cat "$to_dir/.claude.json") [ "$before_dst" = "$after_dst" ] } + +# ── _ckipper_sync_settings_keys ─────────────────────────────────────── + +@test "sync_settings_keys copies matching keys from source settings.json to destination" { + local from_dir="$TMP_HOME/.claude-src" + local to_dir="$TMP_HOME/.claude-dst" + mkdir -p "$from_dir" "$to_dir" + printf '{"model":"claude-opus-4-5","enabledPlugins":["myplugin"],"other":"value"}' \ + > "$from_dir/settings.json" + printf '{}' > "$to_dir/settings.json" + + run_helper 'pending_msgs=() + _ckipper_sync_settings_keys "src" "'"$from_dir"'" "dst" "'"$to_dir"'" "model,enabledPlugins" "0"' + + [ "$status" -eq 0 ] + local dst; dst=$(cat "$to_dir/settings.json") + [[ "$dst" =~ "model" ]] + [[ "$dst" =~ "enabledPlugins" ]] + # The "other" key was not in the sync list — it must not appear in the destination. + [[ ! "$dst" =~ '"other"' ]] +} + +@test "sync_settings_keys is a no-op in dry-run mode (no writes)" { + local from_dir="$TMP_HOME/.claude-src" + local to_dir="$TMP_HOME/.claude-dst" + mkdir -p "$from_dir" "$to_dir" + printf '{"model":"claude-opus-4-5"}' > "$from_dir/settings.json" + printf '{}' > "$to_dir/settings.json" + + local before_dst; before_dst=$(cat "$to_dir/settings.json") + + run_helper 'pending_msgs=() + _ckipper_sync_settings_keys "src" "'"$from_dir"'" "dst" "'"$to_dir"'" "model" "1"' + + [ "$status" -eq 0 ] + local after_dst; after_dst=$(cat "$to_dir/settings.json") + [ "$before_dst" = "$after_dst" ] +} + +# ── _ckipper_sync_print_summary ─────────────────────────────────────── + +@test "print_summary prints 'Synced' header and lists all pending messages" { + run_helper 'pending_msgs=("MCP servers → dst: server1 " "Settings keys → dst: model ") + _ckipper_sync_print_summary "dst" "0"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "Synced" ]] + [[ "$output" =~ "MCP servers" ]] + [[ "$output" =~ "Settings keys" ]] +} + +@test "print_summary prints 'Dry run' header in dry-run mode" { + run_helper 'pending_msgs=("MCP servers → dst: server1 ") + _ckipper_sync_print_summary "dst" "1"' + + [ "$status" -eq 0 ] + [[ "$output" =~ "Dry run" ]] +} From 37a8a6a8682b5b38219fedbe0ec0ccb08489fc88 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 13:36:40 -0600 Subject: [PATCH 057/165] Code review fixes: SECURITY.md repo URL + replace eval with stdout passing in _w_get_project_and_branch --- lib/w/worktree.zsh | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/w/worktree.zsh b/lib/w/worktree.zsh index 0dac4f4..d6fb6b4 100644 --- a/lib/w/worktree.zsh +++ b/lib/w/worktree.zsh @@ -21,7 +21,7 @@ _w_list_worktrees() { [[ "$after_first" == "$rel" ]] && continue local branch - _w_get_project_and_branch "$project" "$after_first" project branch + IFS=$'\t' read -r project branch < <(_w_get_project_and_branch "$project" "$after_first") if [[ "$project" != "$previous_project_for_grouping" ]]; then previous_project_for_grouping="$project" @@ -31,30 +31,27 @@ _w_list_worktrees() { done } -# Set the caller's project and branch variables from path components. +# Resolve project and branch from path components for nested-project worktrees. # # Args: # $1 — initial project name (first path component) # $2 — path after the first component -# $3 — variable name to receive the resolved project -# $4 — variable name to receive the resolved branch # -# Returns: 0 always. +# Returns: 0 always. Prints "\t" (tab-separated) to stdout. +# +# Caller usage: +# IFS=$'\t' read -r project branch < <(_w_get_project_and_branch "$proj" "$rest") _w_get_project_and_branch() { local initial_project="$1" local after_first="$2" - local out_project_var="$3" - local out_branch_var="$4" local second="${after_first%%/*}" local rest="${after_first#*/}" if [[ -d "$W_PROJECTS_DIR/$initial_project/$second/.git" ]]; then - eval "$out_project_var=\"$initial_project/$second\"" - eval "$out_branch_var=\"$rest\"" + printf '%s\t%s' "$initial_project/$second" "$rest" else - eval "$out_project_var=\"$initial_project\"" - eval "$out_branch_var=\"$after_first\"" + printf '%s\t%s' "$initial_project" "$after_first" fi } From 53ce59356263a650997248c35e91c8135a6437d9 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 13:36:58 -0600 Subject: [PATCH 058/165] Fix SECURITY.md: correct repo URL (whmoro/ckipper -> whmoro/claude-docker-sandbox) --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index ebde373..249becb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ Please do **NOT** open a public GitHub issue for security vulnerabilities. Instead, report privately via: -- GitHub Security Advisories: https://github.com/whmoro/ckipper/security/advisories/new +- GitHub Security Advisories: https://github.com/whmoro/claude-docker-sandbox/security/advisories/new - Email: matt@msw.dev We aim to acknowledge reports within 72 hours and to provide a fix or mitigation timeline within 7 days. From ceaac13f03fef458d53943d3fc4e0a74d69a49c3 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 13:45:45 -0600 Subject: [PATCH 059/165] Code review: bring all lib/ckipper/ functions under 3-param cap (use _CKIPPER_*_CTX module globals) --- lib/ckipper/account-management.zsh | 42 ++++++++++++----- lib/ckipper/migrate.zsh | 76 +++++++++++++++--------------- lib/ckipper/migrate_test.bats | 21 +++++++-- lib/ckipper/sync.zsh | 38 +++++++++------ lib/ckipper/sync_test.bats | 24 ++++++++-- 5 files changed, 132 insertions(+), 69 deletions(-) diff --git a/lib/ckipper/account-management.zsh b/lib/ckipper/account-management.zsh index b268dd5..3a4176e 100644 --- a/lib/ckipper/account-management.zsh +++ b/lib/ckipper/account-management.zsh @@ -1,6 +1,16 @@ #!/usr/bin/env zsh # Account lifecycle subcommands: add, finalize_registration, remove, rename, list, default, bare_alias_safe. +# Module-level context for the in-progress registration. +# Populated by callers before invoking _ckipper_finalize_registration. +# Fields: name, dir, service +typeset -gA _CKIPPER_FINALIZE_CTX + +# Module-level context for the in-progress rename. +# Populated by _ckipper_rename before invoking _ckipper_rename_perform. +# Fields: old_dir, new_dir +typeset -gA _CKIPPER_RENAME_CTX + # Validate the account name and --adopt flag from `ckipper add` arguments. # Prints error messages to stdout and returns non-zero on failure. # @@ -44,7 +54,10 @@ _ckipper_add_adopt_flow() { if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then _ckipper_add_pick_keychain_entry "$name" picked || return 1 fi - _ckipper_finalize_registration "$name" "$dir" "$picked" "adopt" + _CKIPPER_FINALIZE_CTX[name]="$name" + _CKIPPER_FINALIZE_CTX[dir]="$dir" + _CKIPPER_FINALIZE_CTX[service]="$picked" + _ckipper_finalize_registration "adopt" } # Prompt the user to pick a Keychain entry from the available candidates. @@ -103,7 +116,10 @@ _ckipper_add_fresh_flow() { <(printf '%s\n' "$before_snapshot") \ <(printf '%s\n' "$after_snapshot") | head -1) _ckipper_add_check_credentials "$name" "$dir" "$new_service" || return 1 - _ckipper_finalize_registration "$name" "$dir" "$new_service" "fresh" + _CKIPPER_FINALIZE_CTX[name]="$name" + _CKIPPER_FINALIZE_CTX[dir]="$dir" + _CKIPPER_FINALIZE_CTX[service]="$new_service" + _ckipper_finalize_registration "fresh" } # Display the fresh-add instructions, prompt for confirmation, and launch Claude. @@ -194,17 +210,18 @@ _ckipper_add() { # Write the account entry to the registry and regenerate aliases atomically. # On collision, diagnoses the cause and prints an appropriate error. +# Reads name, dir, and service from _CKIPPER_FINALIZE_CTX module global. # # Args: -# $1 — account name -# $2 — account config directory -# $3 — keychain service name (may be empty) -# $4 — registration mode: "fresh", "adopt", or "migrate" +# $1 — registration mode: "fresh", "adopt", or "migrate" # # Returns: # 0 on success; 1 on registry collision or write failure. _ckipper_finalize_registration() { - local name="$1" dir="$2" service="$3" mode="$4" + local mode="$1" + local name="${_CKIPPER_FINALIZE_CTX[name]}" + local dir="${_CKIPPER_FINALIZE_CTX[dir]}" + local service="${_CKIPPER_FINALIZE_CTX[service]}" local now; now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") _core_registry_init if ! _core_registry_update ' @@ -395,17 +412,18 @@ _ckipper_rename_validate() { # Perform the directory move and registry update for `ckipper rename`. # Rolls back the directory rename if the registry write fails. +# Reads old_dir and new_dir from _CKIPPER_RENAME_CTX module global. # # Args: # $1 — old account name # $2 — new account name -# $3 — old config directory path -# $4 — new config directory path # # Returns: # 0 on success; 1 on directory move or registry write failure. _ckipper_rename_perform() { - local old="$1" new="$2" old_dir="$3" new_dir="$4" + local old="$1" new="$2" + local old_dir="${_CKIPPER_RENAME_CTX[old_dir]}" + local new_dir="${_CKIPPER_RENAME_CTX[new_dir]}" if [[ -e "$new_dir" ]]; then echo "Error: $new_dir already exists. Pick a different name or remove it first." return 1 @@ -446,7 +464,9 @@ _ckipper_rename() { local old_dir new_dir old_dir=$(jq -r --arg n "$old" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") new_dir="$HOME/.claude-$new" - _ckipper_rename_perform "$old" "$new" "$old_dir" "$new_dir" || return 1 + _CKIPPER_RENAME_CTX[old_dir]="$old_dir" + _CKIPPER_RENAME_CTX[new_dir]="$new_dir" + _ckipper_rename_perform "$old" "$new" || return 1 # Drop old-name launcher functions from the calling shell. unset -f "claude-$old" 2>/dev/null unset -f "$old" 2>/dev/null diff --git a/lib/ckipper/migrate.zsh b/lib/ckipper/migrate.zsh index 1eae6dc..484431e 100644 --- a/lib/ckipper/migrate.zsh +++ b/lib/ckipper/migrate.zsh @@ -5,6 +5,11 @@ typeset -g _CKIPPER_MIGRATE_STEP=0 typeset -g _CKIPPER_MIGRATE_BACKUP="" +# Module-level context for the in-progress migration. +# Populated by _ckipper_migrate before any helper reads it. +# Fields: name, target_dir, legacy_claude, legacy_homejson, probed_service +typeset -gA _CKIPPER_MIGRATE_CTX + # Check preconditions for migration: no running Claude, no symlinks at key paths. # # Args: @@ -83,17 +88,15 @@ _ckipper_migrate_prompt_account_name() { } # Display the migration plan and prompt for user confirmation. -# -# Args: -# $1 — legacy_claude path -# $2 — legacy home json path -# $3 — target directory -# $4 — account name +# Reads legacy_claude, legacy_homejson, target_dir, and name from _CKIPPER_MIGRATE_CTX. # # Returns: # 0 if user confirms; 1 if user aborts. _ckipper_migrate_confirm_plan() { - local legacy_claude="$1" legacy_homejson="$2" target_dir="$3" name="$4" + local legacy_claude="${_CKIPPER_MIGRATE_CTX[legacy_claude]}" + local legacy_homejson="${_CKIPPER_MIGRATE_CTX[legacy_homejson]}" + local target_dir="${_CKIPPER_MIGRATE_CTX[target_dir]}" + local name="${_CKIPPER_MIGRATE_CTX[name]}" cat <= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null [[ -n "$_CKIPPER_MIGRATE_BACKUP" && -f "$_CKIPPER_MIGRATE_BACKUP" ]] && \ @@ -310,22 +318,15 @@ _ckipper_migrate_rollback() { } # Run the destructive migration steps with rollback on failure or interruption. -# -# Args: -# $1 — account name -# $2 — target directory -# $3 — legacy_claude path -# $4 — legacy home json path -# $5 — probed keychain service (may be empty) +# Reads all context from _CKIPPER_MIGRATE_CTX module global. # # Returns: # 0 on success; 1 on failure (rollback applied). _ckipper_migrate_run() { - local name="$1" target_dir="$2" legacy_claude="$3" legacy_homejson="$4" probed_service="$5" _CKIPPER_MIGRATE_STEP=0 _CKIPPER_MIGRATE_BACKUP="" - trap '_ckipper_migrate_rollback "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" interrupted; trap - INT TERM HUP QUIT; return 130' INT TERM HUP QUIT - _ckipper_migrate_run_steps "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" "$probed_service" + trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT; return 130' INT TERM HUP QUIT + _ckipper_migrate_run_steps local run_rc=$? trap - INT TERM HUP QUIT (( run_rc == 0 )) && _ckipper_migrate_finalize @@ -333,25 +334,26 @@ _ckipper_migrate_run() { } # Execute the actual migration rename and register steps (called from _ckipper_migrate_run). -# -# Args: -# $1 — account name -# $2 — target directory -# $3 — legacy_claude path -# $4 — legacy home json path -# $5 — probed keychain service (may be empty) +# Reads all context from _CKIPPER_MIGRATE_CTX module global. # # Returns: # 0 on success; 1 on failure (_ckipper_migrate_rollback should be called by caller). _ckipper_migrate_run_steps() { - local name="$1" target_dir="$2" legacy_claude="$3" legacy_homejson="$4" probed_service="$5" + local name="${_CKIPPER_MIGRATE_CTX[name]}" + local target_dir="${_CKIPPER_MIGRATE_CTX[target_dir]}" + local legacy_claude="${_CKIPPER_MIGRATE_CTX[legacy_claude]}" + local legacy_homejson="${_CKIPPER_MIGRATE_CTX[legacy_homejson]}" + local probed_service="${_CKIPPER_MIGRATE_CTX[probed_service]}" _ckipper_migrate_rename_dirs "$legacy_claude" "$legacy_homejson" "$target_dir" || { - _ckipper_migrate_rollback "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" failed + _ckipper_migrate_rollback failed return 1 } _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" - if ! _ckipper_finalize_registration "$name" "$target_dir" "$probed_service" "migrate"; then - _ckipper_migrate_rollback "$name" "$target_dir" "$legacy_claude" "$legacy_homejson" failed + _CKIPPER_FINALIZE_CTX[name]="$name" + _CKIPPER_FINALIZE_CTX[dir]="$target_dir" + _CKIPPER_FINALIZE_CTX[service]="$probed_service" + if ! _ckipper_finalize_registration "migrate"; then + _ckipper_migrate_rollback failed return 1 fi } diff --git a/lib/ckipper/migrate_test.bats b/lib/ckipper/migrate_test.bats index eb5bcae..f145ff2 100644 --- a/lib/ckipper/migrate_test.bats +++ b/lib/ckipper/migrate_test.bats @@ -113,7 +113,12 @@ run_helper() { mkdir -p "$target_dir" run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" - _ckipper_migrate_rollback \"personal\" \"$target_dir\" \"$legacy_dir\" \"$TMP_HOME/.claude.json\" \"failed\"" + typeset -gA _CKIPPER_MIGRATE_CTX + _CKIPPER_MIGRATE_CTX[name]=\"personal\" + _CKIPPER_MIGRATE_CTX[target_dir]=\"$target_dir\" + _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$legacy_dir\" + _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" + _ckipper_migrate_rollback \"failed\"" [ "$status" -eq 0 ] # The target dir should be moved back to the legacy location. @@ -128,13 +133,23 @@ run_helper() { # First rollback — restores legacy dir. run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" - _ckipper_migrate_rollback \"personal\" \"$target_dir\" \"$legacy_dir\" \"$TMP_HOME/.claude.json\" \"failed\"" + typeset -gA _CKIPPER_MIGRATE_CTX + _CKIPPER_MIGRATE_CTX[name]=\"personal\" + _CKIPPER_MIGRATE_CTX[target_dir]=\"$target_dir\" + _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$legacy_dir\" + _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" + _ckipper_migrate_rollback \"failed\"" [ "$status" -eq 0 ] [ -d "$legacy_dir" ] # Second rollback — target_dir no longer exists, so no move happens; legacy_dir stays. run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" - _ckipper_migrate_rollback \"personal\" \"$target_dir\" \"$legacy_dir\" \"$TMP_HOME/.claude.json\" \"failed\"" + typeset -gA _CKIPPER_MIGRATE_CTX + _CKIPPER_MIGRATE_CTX[name]=\"personal\" + _CKIPPER_MIGRATE_CTX[target_dir]=\"$target_dir\" + _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$legacy_dir\" + _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" + _ckipper_migrate_rollback \"failed\"" [ "$status" -eq 0 ] [ -d "$legacy_dir" ] } diff --git a/lib/ckipper/sync.zsh b/lib/ckipper/sync.zsh index 4ed7100..3bdbf98 100644 --- a/lib/ckipper/sync.zsh +++ b/lib/ckipper/sync.zsh @@ -1,6 +1,11 @@ #!/usr/bin/env zsh # Account settings sync subcommand: sync MCP servers and settings.json keys between accounts. +# Module-level context for the in-progress sync operation. +# Populated by _ckipper_sync before any helper reads it. +# Fields: from_dir, to_dir, dry_run +typeset -gA _CKIPPER_SYNC_CTX + # Parse sync subcommand flags into named variables in the caller's scope. # Populates: mode_mcp, mcp_names, mode_settings, settings_keys, dry_run, mode_all. # @@ -60,18 +65,19 @@ _ckipper_sync_warn_running_claude() { } # Sync MCP servers from one account to another. Appends a summary line to pending_msgs. +# Reads from_dir, to_dir, and dry_run from _CKIPPER_SYNC_CTX module global. # # Args: -# $1 — from account config directory -# $2 — to account name (for message) -# $3 — to account config directory -# $4 — comma-separated MCP server names to sync (empty = all) -# $5 — dry_run flag (1 = dry run, 0 = write) +# $1 — to account name (for message) +# $2 — comma-separated MCP server names to sync (empty = all) # # Returns: # 0 always. _ckipper_sync_mcp_servers() { - local from_dir="$1" to="$2" to_dir="$3" mcp_names="$4" dry_run="$5" + local to="$1" mcp_names="$2" + local from_dir="${_CKIPPER_SYNC_CTX[from_dir]}" + local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" + local dry_run="${_CKIPPER_SYNC_CTX[dry_run]}" local mcp_filter if [[ -z "$mcp_names" ]]; then mcp_filter='.mcpServers // {}' @@ -94,19 +100,20 @@ _ckipper_sync_mcp_servers() { } # Sync settings.json keys from one account to another. Appends summary line to pending_msgs. +# Reads from_dir, to_dir, and dry_run from _CKIPPER_SYNC_CTX module global. # # Args: # $1 — from account name (for message) -# $2 — from account config directory -# $3 — to account name (for message) -# $4 — to account config directory -# $5 — comma-separated settings keys to sync -# $6 — dry_run flag (1 = dry run, 0 = write) +# $2 — to account name (for message) +# $3 — comma-separated settings keys to sync # # Returns: # 0 always. _ckipper_sync_settings_keys() { - local from="$1" from_dir="$2" to="$3" to_dir="$4" settings_keys="$5" dry_run="$6" + local from="$1" to="$2" settings_keys="$3" + local from_dir="${_CKIPPER_SYNC_CTX[from_dir]}" + local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" + local dry_run="${_CKIPPER_SYNC_CTX[dry_run]}" if [[ ! -f "$from_dir/settings.json" ]]; then pending_msgs+=("Settings: $from has no settings.json (skipping)") return 0 @@ -204,10 +211,13 @@ _ckipper_sync() { if (( ! dry_run )); then _ckipper_sync_warn_running_claude "$from" "$to" || return 1 fi + _CKIPPER_SYNC_CTX[from_dir]="$from_dir" + _CKIPPER_SYNC_CTX[to_dir]="$to_dir" + _CKIPPER_SYNC_CTX[dry_run]="$dry_run" local pending_msgs=() (( mode_mcp )) && \ - _ckipper_sync_mcp_servers "$from_dir" "$to" "$to_dir" "$mcp_names" "$dry_run" + _ckipper_sync_mcp_servers "$to" "$mcp_names" (( mode_settings && ${#settings_keys} > 0 )) && \ - _ckipper_sync_settings_keys "$from" "$from_dir" "$to" "$to_dir" "$settings_keys" "$dry_run" + _ckipper_sync_settings_keys "$from" "$to" "$settings_keys" _ckipper_sync_print_summary "$to" "$dry_run" } diff --git a/lib/ckipper/sync_test.bats b/lib/ckipper/sync_test.bats index eaab669..b8e5875 100644 --- a/lib/ckipper/sync_test.bats +++ b/lib/ckipper/sync_test.bats @@ -81,7 +81,11 @@ run_helper() { printf '{"mcpServers":{}}' > "$to_dir/.claude.json" run_helper 'pending_msgs=() - _ckipper_sync_mcp_servers "'"$from_dir"'" "dst" "'"$to_dir"'" "" "0" + typeset -gA _CKIPPER_SYNC_CTX + _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" + _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" + _CKIPPER_SYNC_CTX[dry_run]="0" + _ckipper_sync_mcp_servers "dst" "" echo "${pending_msgs[@]}"' [ "$status" -eq 0 ] @@ -101,7 +105,11 @@ run_helper() { local before_dst; before_dst=$(cat "$to_dir/.claude.json") run_helper 'pending_msgs=() - _ckipper_sync_mcp_servers "'"$from_dir"'" "dst" "'"$to_dir"'" "" "1"' + typeset -gA _CKIPPER_SYNC_CTX + _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" + _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" + _CKIPPER_SYNC_CTX[dry_run]="1" + _ckipper_sync_mcp_servers "dst" ""' [ "$status" -eq 0 ] # Destination must be unchanged in dry-run mode. @@ -120,7 +128,11 @@ run_helper() { printf '{}' > "$to_dir/settings.json" run_helper 'pending_msgs=() - _ckipper_sync_settings_keys "src" "'"$from_dir"'" "dst" "'"$to_dir"'" "model,enabledPlugins" "0"' + typeset -gA _CKIPPER_SYNC_CTX + _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" + _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" + _CKIPPER_SYNC_CTX[dry_run]="0" + _ckipper_sync_settings_keys "src" "dst" "model,enabledPlugins"' [ "$status" -eq 0 ] local dst; dst=$(cat "$to_dir/settings.json") @@ -140,7 +152,11 @@ run_helper() { local before_dst; before_dst=$(cat "$to_dir/settings.json") run_helper 'pending_msgs=() - _ckipper_sync_settings_keys "src" "'"$from_dir"'" "dst" "'"$to_dir"'" "model" "1"' + typeset -gA _CKIPPER_SYNC_CTX + _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" + _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" + _CKIPPER_SYNC_CTX[dry_run]="1" + _ckipper_sync_settings_keys "src" "dst" "model"' [ "$status" -eq 0 ] local after_dst; after_dst=$(cat "$to_dir/settings.json") From 2abf891d8460172309353e99d6ff745ff6ea13a3 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 13:52:00 -0600 Subject: [PATCH 060/165] Code review: standardize boolean locals on "true"/"false" strings (per shell-conventions.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 0/1 integer boolean locals in lib/ to "true"/"false" strings tested with [[ ... = "true" ]] as required by shell-conventions.md:56. Renames adopt→should_adopt, dry_run→is_dry_run, stale_pm→has_stale_plugin_metadata, notified→has_notified per the boolean prefix convention. Also converts migrate.zsh locals not on the original reviewer list. Updates sync_test.bats to match new values. --- lib/ckipper/account-management.zsh | 6 ++-- lib/ckipper/doctor.zsh | 6 ++-- lib/ckipper/migrate.zsh | 14 ++++----- lib/ckipper/sync.zsh | 46 +++++++++++++++--------------- lib/ckipper/sync_test.bats | 40 +++++++++++++------------- lib/core/registry.zsh | 6 ++-- lib/w/ports.zsh | 6 ++-- 7 files changed, 62 insertions(+), 62 deletions(-) diff --git a/lib/ckipper/account-management.zsh b/lib/ckipper/account-management.zsh index 3a4176e..1f4dd50 100644 --- a/lib/ckipper/account-management.zsh +++ b/lib/ckipper/account-management.zsh @@ -196,12 +196,12 @@ _ckipper_add_check_credentials() { # 0 on success; 1 on validation or registration failure. _ckipper_add() { _core_registry_check_version || return 1 - local name="$1" adopt=0 - [[ "$2" == "--adopt" ]] && adopt=1 + local name="$1" should_adopt="false" + [[ "$2" == "--adopt" ]] && should_adopt="true" _core_registry_init _ckipper_add_validate_name "$name" || return 1 local dir="$HOME/.claude-$name" - if (( adopt )); then + if [[ "$should_adopt" = "true" ]]; then _ckipper_add_adopt_flow "$name" "$dir" return $? fi diff --git a/lib/ckipper/doctor.zsh b/lib/ckipper/doctor.zsh index 35d29a1..277ba73 100644 --- a/lib/ckipper/doctor.zsh +++ b/lib/ckipper/doctor.zsh @@ -80,15 +80,15 @@ _ckipper_doctor_registry() { # 0 always. _ckipper_doctor_account_plugins() { local name="$1" dir="$2" - local stale_pm=0 + local has_stale_plugin_metadata="false" local pm for pm in known_marketplaces.json installed_plugins.json; do [[ -f "$dir/plugins/$pm" ]] || continue if grep -q -- "$HOME/.claude/" "$dir/plugins/$pm" 2>/dev/null; then - stale_pm=1 + has_stale_plugin_metadata="true" fi done - if (( stale_pm )); then + if [[ "$has_stale_plugin_metadata" = "true" ]]; then _ckipper_doctor_check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" fi } diff --git a/lib/ckipper/migrate.zsh b/lib/ckipper/migrate.zsh index 484431e..86df856 100644 --- a/lib/ckipper/migrate.zsh +++ b/lib/ckipper/migrate.zsh @@ -51,10 +51,10 @@ _ckipper_migrate_preflight() { # 0 always. _ckipper_migrate_print_no_op() { local legacy_claude="$1" legacy_homejson="$2" - local has_registry=0 + local has_registry="false" [[ -f "$CKIPPER_REGISTRY" ]] && \ - jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry=1 - if (( has_registry )); then + jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry="true" + if [[ "$has_registry" = "true" ]]; then echo "Nothing to migrate: no $legacy_claude state and no $legacy_homejson at home root." echo "($CKIPPER_REGISTRY already has registered accounts — you're likely already migrated.)" echo "Run: ckipper list" @@ -241,10 +241,10 @@ _ckipper_migrate_copy_docker() { # 0 if migration is needed; 1 if no state; 2 if already migrated. _ckipper_migrate_check_state() { local legacy_claude="$1" legacy_homejson="$2" - local has_inner_state=0 has_homejson=0 - [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]] && has_inner_state=1 - [[ -f "$legacy_homejson" ]] && has_homejson=1 - (( has_inner_state == 0 && has_homejson == 0 )) && return 1 + local has_inner_state="false" has_homejson="false" + [[ -f "$legacy_claude/.claude.json" || -f "$legacy_claude/settings.json" ]] && has_inner_state="true" + [[ -f "$legacy_homejson" ]] && has_homejson="true" + [[ "$has_inner_state" = "false" && "$has_homejson" = "false" ]] && return 1 [[ -f "$CKIPPER_REGISTRY" ]] && \ jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && return 2 return 0 diff --git a/lib/ckipper/sync.zsh b/lib/ckipper/sync.zsh index 3bdbf98..ac3eaaf 100644 --- a/lib/ckipper/sync.zsh +++ b/lib/ckipper/sync.zsh @@ -7,7 +7,7 @@ typeset -gA _CKIPPER_SYNC_CTX # Parse sync subcommand flags into named variables in the caller's scope. -# Populates: mode_mcp, mcp_names, mode_settings, settings_keys, dry_run, mode_all. +# Populates: mode_mcp, mcp_names, mode_settings, settings_keys, is_dry_run, mode_all. # # Args: # $@ — remaining args after and have been shifted @@ -15,28 +15,28 @@ typeset -gA _CKIPPER_SYNC_CTX # Returns: # 0 on success; 1 on unknown flag. _ckipper_sync_parse_flags() { - mode_mcp=0; mcp_names=""; mode_settings=0; settings_keys=""; dry_run=0; mode_all=0 + mode_mcp="false"; mcp_names=""; mode_settings="false"; settings_keys=""; is_dry_run="false"; mode_all="false" while [[ $# -gt 0 ]]; do case "$1" in --mcp) - mode_mcp=1 + mode_mcp="true" if [[ -n "$2" && "$2" != --* ]]; then mcp_names="$2"; shift; fi shift ;; --settings) - mode_settings=1 + mode_settings="true" if [[ -n "$2" && "$2" != --* ]]; then settings_keys="$2"; shift; fi shift ;; - --all) mode_all=1; shift ;; - --dry-run) dry_run=1; shift ;; + --all) mode_all="true"; shift ;; + --dry-run) is_dry_run="true"; shift ;; *) echo "Unknown flag: $1"; return 1 ;; esac done - if (( mode_mcp == 0 && mode_settings == 0 )); then - mode_all=1 + if [[ "$mode_mcp" = "false" && "$mode_settings" = "false" ]]; then + mode_all="true" fi - if (( mode_all )); then - mode_mcp=1 - mode_settings=1 + if [[ "$mode_all" = "true" ]]; then + mode_mcp="true" + mode_settings="true" [[ -z "$settings_keys" ]] && \ settings_keys="enabledPlugins,extraKnownMarketplaces,statusLine,env,model" fi @@ -93,7 +93,7 @@ _ckipper_sync_mcp_servers() { return 0 fi pending_msgs+=("MCP servers → $to: $server_keys") - (( dry_run )) && return 0 + [[ "$dry_run" = "true" ]] && return 0 local sync_tmpfile; sync_tmpfile=$(mktemp "$to_dir/.claude.json.tmp.XXXXXX") jq --argjson new "$servers" '.mcpServers = (.mcpServers // {}) + $new' \ "$to_dir/.claude.json" > "$sync_tmpfile" && mv "$sync_tmpfile" "$to_dir/.claude.json" @@ -127,7 +127,7 @@ _ckipper_sync_settings_keys() { return 0 fi pending_msgs+=("Settings keys → $to: $copied_keys") - (( dry_run )) && return 0 + [[ "$dry_run" = "true" ]] && return 0 [[ ! -f "$to_dir/settings.json" ]] && echo '{}' > "$to_dir/settings.json" local sync_tmpfile; sync_tmpfile=$(mktemp "$to_dir/settings.json.tmp.XXXXXX") jq --argjson new "$subset" '. + $new' \ @@ -138,13 +138,13 @@ _ckipper_sync_settings_keys() { # # Args: # $1 — to account name -# $2 — dry_run flag (1 = dry run, 0 = write) +# $2 — is_dry_run flag ("true" = dry run, "false" = write) # # Returns: # 0 always. _ckipper_sync_print_summary() { - local to="$1" dry_run="$2" - if (( dry_run )); then + local to="$1" is_dry_run="$2" + if [[ "$is_dry_run" = "true" ]]; then echo "Dry run — would apply:" else echo "Synced:" @@ -153,7 +153,7 @@ _ckipper_sync_print_summary() { for m in "${pending_msgs[@]}"; do echo " - $m" done - if (( ! dry_run )); then + if [[ "$is_dry_run" != "true" ]]; then echo "" echo "Restart any running '$to' Claude session for changes to take effect." fi @@ -206,18 +206,18 @@ _ckipper_sync() { [[ "$from" == "$to" ]] && { echo " and must differ."; return 1; } local dirs_line; dirs_line=$(_ckipper_sync_resolve_dirs "$from" "$to") || return 1 local from_dir="${dirs_line%% *}" to_dir="${dirs_line##* }" - local mode_mcp mcp_names mode_settings settings_keys dry_run mode_all + local mode_mcp mcp_names mode_settings settings_keys is_dry_run mode_all _ckipper_sync_parse_flags "$@" || return 1 - if (( ! dry_run )); then + if [[ "$is_dry_run" != "true" ]]; then _ckipper_sync_warn_running_claude "$from" "$to" || return 1 fi _CKIPPER_SYNC_CTX[from_dir]="$from_dir" _CKIPPER_SYNC_CTX[to_dir]="$to_dir" - _CKIPPER_SYNC_CTX[dry_run]="$dry_run" + _CKIPPER_SYNC_CTX[dry_run]="$is_dry_run" local pending_msgs=() - (( mode_mcp )) && \ + [[ "$mode_mcp" = "true" ]] && \ _ckipper_sync_mcp_servers "$to" "$mcp_names" - (( mode_settings && ${#settings_keys} > 0 )) && \ + [[ "$mode_settings" = "true" && ${#settings_keys} -gt 0 ]] && \ _ckipper_sync_settings_keys "$from" "$to" "$settings_keys" - _ckipper_sync_print_summary "$to" "$dry_run" + _ckipper_sync_print_summary "$to" "$is_dry_run" } diff --git a/lib/ckipper/sync_test.bats b/lib/ckipper/sync_test.bats index b8e5875..85a8087 100644 --- a/lib/ckipper/sync_test.bats +++ b/lib/ckipper/sync_test.bats @@ -26,44 +26,44 @@ run_helper() { # ── _ckipper_sync_parse_flags ───────────────────────────────────────── -@test "parse_flags sets mode_all=1 when no flags given" { - run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 +@test "parse_flags sets mode_all=true when no flags given" { + run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" _ckipper_sync_parse_flags echo "mode_all=$mode_all"' [ "$status" -eq 0 ] - [[ "$output" =~ "mode_all=1" ]] + [[ "$output" =~ "mode_all=true" ]] } -@test "parse_flags sets dry_run=1 for --dry-run flag" { - run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 +@test "parse_flags sets is_dry_run=true for --dry-run flag" { + run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" _ckipper_sync_parse_flags --dry-run - echo "dry_run=$dry_run"' + echo "is_dry_run=$is_dry_run"' [ "$status" -eq 0 ] - [[ "$output" =~ "dry_run=1" ]] + [[ "$output" =~ "is_dry_run=true" ]] } -@test "parse_flags sets mode_mcp=1 for --mcp flag" { - run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 +@test "parse_flags sets mode_mcp=true for --mcp flag" { + run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" _ckipper_sync_parse_flags --mcp echo "mode_mcp=$mode_mcp"' [ "$status" -eq 0 ] - [[ "$output" =~ "mode_mcp=1" ]] + [[ "$output" =~ "mode_mcp=true" ]] } -@test "parse_flags sets mode_settings=1 for --settings flag" { - run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 +@test "parse_flags sets mode_settings=true for --settings flag" { + run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" _ckipper_sync_parse_flags --settings "enabledPlugins" echo "mode_settings=$mode_settings"' [ "$status" -eq 0 ] - [[ "$output" =~ "mode_settings=1" ]] + [[ "$output" =~ "mode_settings=true" ]] } @test "parse_flags returns 1 and prints error for unknown flag" { - run_helper 'mode_mcp=0; mode_settings=0; dry_run=0; mode_all=0 + run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" _ckipper_sync_parse_flags --bogus-flag' [ "$status" -ne 0 ] @@ -84,7 +84,7 @@ run_helper() { typeset -gA _CKIPPER_SYNC_CTX _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="0" + _CKIPPER_SYNC_CTX[dry_run]="false" _ckipper_sync_mcp_servers "dst" "" echo "${pending_msgs[@]}"' @@ -108,7 +108,7 @@ run_helper() { typeset -gA _CKIPPER_SYNC_CTX _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="1" + _CKIPPER_SYNC_CTX[dry_run]="true" _ckipper_sync_mcp_servers "dst" ""' [ "$status" -eq 0 ] @@ -131,7 +131,7 @@ run_helper() { typeset -gA _CKIPPER_SYNC_CTX _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="0" + _CKIPPER_SYNC_CTX[dry_run]="false" _ckipper_sync_settings_keys "src" "dst" "model,enabledPlugins"' [ "$status" -eq 0 ] @@ -155,7 +155,7 @@ run_helper() { typeset -gA _CKIPPER_SYNC_CTX _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" - _CKIPPER_SYNC_CTX[dry_run]="1" + _CKIPPER_SYNC_CTX[dry_run]="true" _ckipper_sync_settings_keys "src" "dst" "model"' [ "$status" -eq 0 ] @@ -167,7 +167,7 @@ run_helper() { @test "print_summary prints 'Synced' header and lists all pending messages" { run_helper 'pending_msgs=("MCP servers → dst: server1 " "Settings keys → dst: model ") - _ckipper_sync_print_summary "dst" "0"' + _ckipper_sync_print_summary "dst" "false"' [ "$status" -eq 0 ] [[ "$output" =~ "Synced" ]] @@ -177,7 +177,7 @@ run_helper() { @test "print_summary prints 'Dry run' header in dry-run mode" { run_helper 'pending_msgs=("MCP servers → dst: server1 ") - _ckipper_sync_print_summary "dst" "1"' + _ckipper_sync_print_summary "dst" "true"' [ "$status" -eq 0 ] [[ "$output" =~ "Dry run" ]] diff --git a/lib/core/registry.zsh b/lib/core/registry.zsh index 77e9194..6d7aaac 100644 --- a/lib/core/registry.zsh +++ b/lib/core/registry.zsh @@ -91,12 +91,12 @@ _core_registry_check_stale_lock() { # 0 when lock is acquired; 1 on timeout. _core_registry_acquire_mkdir_lock() { local lockdir="$1" - local attempts=0 notified=0 + local attempts=0 has_notified="false" while ! mkdir "$lockdir" 2>/dev/null; do (( attempts++ )) - if (( attempts == LOCK_NOTIFY_THRESHOLD_ATTEMPTS && notified == 0 )); then + if (( attempts == LOCK_NOTIFY_THRESHOLD_ATTEMPTS )) && [[ "$has_notified" = "false" ]]; then echo "Waiting on registry lock..." >&2 - notified=1 + has_notified="true" fi local stale_rc _core_registry_check_stale_lock "$lockdir" "$attempts"; stale_rc=$? diff --git a/lib/w/ports.zsh b/lib/w/ports.zsh index 2d7ba06..aad2510 100644 --- a/lib/w/ports.zsh +++ b/lib/w/ports.zsh @@ -26,12 +26,12 @@ _w_resolve_ports() { _w_bind_port() { local port="$1" local host_port=$port - local is_bound=0 + local is_bound="false" for (( i=0; i/dev/null; then W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) - is_bound=1 + is_bound="true" if (( host_port != port )); then echo " Port $port mapped to host:$host_port (original in use)" fi @@ -40,7 +40,7 @@ _w_bind_port() { (( host_port++ )) done - if (( !is_bound )); then + if [[ "$is_bound" != "true" ]]; then echo " Port $port: no available host port found ($port-$((port+MAX_PORT_FALLBACK_ATTEMPTS-1)) all in use)" fi } From 92601ce897b6cf715abf257cad0265cb1f371e5c Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 20:37:15 -0600 Subject: [PATCH 061/165] Trim rules + fix README staleness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shell-conventions.md: drop duplicated magic-numbers section (already in code-style.md); drop inferrable examples (Claude reads existing code); 77 → 40 lines. - CLAUDE.md: drop Infrastructure & Services table (Claude can infer from code). - README.md: fix wrong git clone URL (whmoro/ckipper → whmoro/claude-docker-sandbox); add ckipper.zsh + lib/ rows to install table; add Contributing/Security/License section. --- .claude/CLAUDE.md | 8 ---- .claude/rules/shell-conventions.md | 77 ++++++++---------------------- README.md | 20 ++++++-- 3 files changed, 37 insertions(+), 68 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 273cb18..c3e4dba 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -77,14 +77,6 @@ Before approving any PR, verify: ## 4. Infrastructure & Services -| Service | Purpose | Status | -|---------|---------|--------| -| Docker | Containerization for `--docker` mode. Image built locally from `docker/Dockerfile`. | Active | -| macOS Keychain | Credential storage per account (service: `Claude Code-credentials-`). Accessed via `security` CLI. | Active | -| Anthropic API | Invoked transitively by Claude Code CLI inside the container. No direct API calls from Ckipper. | Indirect | -| GitHub API | `gh` CLI used by `init-firewall.sh` to fetch GitHub IP ranges for the egress allow-list. | Active | -| npm registry | Used inside the container by Claude Code's MCP server installation. | Indirect | - CI runs `make lint` + `make test-unit` on `macos-latest` via `.github/workflows/ci.yml`. ## 5. Git Workflow diff --git a/.claude/rules/shell-conventions.md b/.claude/rules/shell-conventions.md index 3ed4674..32bc9a0 100644 --- a/.claude/rules/shell-conventions.md +++ b/.claude/rules/shell-conventions.md @@ -1,77 +1,40 @@ # Shell Conventions -This document specifies how the language-agnostic rules in `code-style.md`, `file-organization.md`, and `testing.md` apply to zsh code in this project. +zsh-specific clarifications of `code-style.md`, `file-organization.md`, and `testing.md`. This file only covers what those rules don't already specify; nothing is repeated. ## Function-line counting -The "25 lines per function" cap (`code-style.md`) counts: - -- Lines inside the function body. -- Excluding blank lines and lines containing ONLY a closing `}`. -- Including comment lines (so verbose inline commentary still counts). - -Example: - -```zsh -my_function() { - # 1 line - local x=1 # 2 lines - # blank line — does NOT count - echo "$x" # 3 lines -} # closing brace — does NOT count -``` - -This function is 3 lines. +The "25 lines per function" cap counts function-body lines, **excluding** blank lines and lines containing only a closing `}`. Comment lines count. ## Doc-header convention -Every public function (and every helper extracted from one) gets a doc-header comment block immediately above its definition: +zsh has no native docstrings. Document every public function with a comment block immediately above its definition with these labelled sections (in this order, each as needed): -```zsh -# -# -# Args: -# $1 — -# $2 — -# -# Returns: -# 0 on ; non-zero on . +``` +# # -# Errors (stderr): -# "" — -my_function() { +# Args: $1 — …, $2 — … +# Returns: 0 on …; non-zero on … +# Errors (stderr): "" — ``` -For helpers with no args, omit the `Args:` block. The `Errors:` block is required only when the function writes to stderr. - -## Naming +Omit `Args:` if the function takes none. Omit `Errors:` if it never writes to stderr. -- **Public functions** (callable from outside the file or from .zshrc): no leading underscore. Examples: `ckipper`, `ck`, `w`. -- **Module-internal functions**: prefix indicates the module: - - `_core_*` — `lib/core/` - - `_ckipper_*` — `lib/ckipper/` - - `_w_*` — `lib/w/` -- **Constants**: `readonly UPPER_SNAKE_CASE` at top of file. No magic numbers. -- **Variables**: snake_case, descriptive (no `tmp`/`idx`/`ans`). -- **Booleans**: prefix with `is_`, `has_`, `can_`, `should_`. Use string values `"true"`/`"false"` (zsh has no native bool); test with `[[ ... = true ]]`. +## Function-name prefixes -## Module sourcing +Used to encode the dependency direction at a glance and let CI verify it: -Modules under `lib/` are sourced once by an entry script (`ckipper.zsh` or `w-function.zsh`). Modules MUST NOT source siblings. Cross-feature imports (`lib/w/` → `lib/ckipper/` or vice versa) are FORBIDDEN. If two features need shared code, it goes in `lib/core/` (per `file-organization.md`'s "shared modules pulled to common parent" rule). +- `_core_*` — `lib/core/` (shared primitives) +- `_ckipper_*` — `lib/ckipper/` (ckipper subcommands) +- `_w_*` — `lib/w/` (w() helpers) +- No prefix — public, callable from `.zshrc`: `ckipper`, `ck`, `w` -CI enforces this: +## Booleans -```sh -grep -r '_ckipper_' lib/w/ && exit 1 || exit 0 -``` +zsh has no native bool. Use string values `"true"`/`"false"` and test with `[[ "$x" = "true" ]]`. (Don't use `0`/`1` integers with `(( x ))`.) -## Magic numbers - -Per `code-style.md`, no magic numbers. Constants live at the top of the module file that uses them, declared `readonly`: +## Module sourcing -```zsh -readonly KEYCHAIN_TIMEOUT_SECONDS=10 -readonly REGISTRY_FILE_PERMS=600 -``` +Modules under `lib/` are sourced once by an entry script (`ckipper.zsh` or `w-function.zsh`). Modules MUST NOT source siblings. Cross-feature imports between `lib/w/` and `lib/ckipper/` are forbidden — extract shared code to `lib/core/` (per `file-organization.md`'s shared-parent rule). -The literals `0`, `1`, and `-1` are exempt when used in idiomatic contexts (exit status, array indexing, error sentinels). +CI enforces this with `grep -rE '\b_ckipper_' lib/w/`. diff --git a/README.md b/README.md index 0683d6b..43ad8be 100644 --- a/README.md +++ b/README.md @@ -230,8 +230,8 @@ ckipper add work ```bash # Clone the repo -git clone https://github.com/whmoro/ckipper.git -cd ckipper +git clone https://github.com/whmoro/claude-docker-sandbox.git +cd claude-docker-sandbox # Run the installer (copies all files, merges hooks, adds source line) ./install.sh @@ -264,7 +264,9 @@ Clone the repo, then open Claude Code and paste this prompt: | `hooks/bash-guardrails.sh` | `~/.ckipper/hooks/bash-guardrails.sh` | Bash command guard | | `hooks/docker-context.sh` | `~/.ckipper/hooks/docker-context.sh` | Context injection | | `hooks/notify-bell.sh` | `~/.ckipper/hooks/notify-bell.sh` | Notification bell | -| `w-function.zsh` | `~/.ckipper/docker/w-function.zsh` | w() function (sourced by .zshrc) | +| `w-function.zsh` | `~/.ckipper/docker/w-function.zsh` | w() launcher entry (sourced by .zshrc) | +| `ckipper.zsh` | `~/.ckipper/docker/ckipper.zsh` | ckipper CLI entry (account management) | +| `lib/core/`, `lib/ckipper/`, `lib/w/` | `~/.ckipper/docker/lib/` | Shell module tree (sourced by entry scripts; test files excluded) | | `w-config.zsh.example` | `~/.ckipper/docker/w-config.zsh` | User config (ports, mounts, env vars) | | `settings-hooks.json` | Auto-merged into `~/.claude/settings.json` | Hook registration | @@ -426,3 +428,15 @@ Despite docs saying every `~/.claude/...` path redirects under `CLAUDE_CONFIG_DI | `.env.local` not copied to worktree | Fixed: worktree creation now copies all `.env*` files except `.env.example` | | uvx MCP server fails to start | Run `w --rebuild-image`; if still broken, delete stale volumes: `docker volume rm claude-uv-cache claude-uv-tools` | | Claude Code version outdated | Run `w --rebuild-image` — Claude and uv are always re-fetched | + +## Contributing + +PRs welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the workflow, code style, and how to run the test suite (`make bootstrap && make test`). + +## Security + +Found a vulnerability? See [`SECURITY.md`](SECURITY.md) for private reporting. Please do not open a public issue. + +## License + +MIT — see [`LICENSE`](LICENSE). From e20d0d73a1ede1cc95af1ac50c72a9c0189b21da Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 20:40:03 -0600 Subject: [PATCH 062/165] Update repo URLs after GitHub rename to Ckipper --- README.md | 4 ++-- SECURITY.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 43ad8be..a62d5e6 100644 --- a/README.md +++ b/README.md @@ -230,8 +230,8 @@ ckipper add work ```bash # Clone the repo -git clone https://github.com/whmoro/claude-docker-sandbox.git -cd claude-docker-sandbox +git clone https://github.com/whmoro/Ckipper.git +cd Ckipper # Run the installer (copies all files, merges hooks, adds source line) ./install.sh diff --git a/SECURITY.md b/SECURITY.md index 249becb..80ae12d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ Please do **NOT** open a public GitHub issue for security vulnerabilities. Instead, report privately via: -- GitHub Security Advisories: https://github.com/whmoro/claude-docker-sandbox/security/advisories/new +- GitHub Security Advisories: https://github.com/whmoro/Ckipper/security/advisories/new - Email: matt@msw.dev We aim to acknowledge reports within 72 hours and to provide a fix or mitigation timeline within 7 days. From 3f55d6c60125bcd9b3227e0521b8a084f0883287 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 20:50:20 -0600 Subject: [PATCH 063/165] Update repo URLs after ownership transfer to mswdev --- README.md | 2 +- SECURITY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a62d5e6..d5e91da 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ ckipper add work ```bash # Clone the repo -git clone https://github.com/whmoro/Ckipper.git +git clone https://github.com/mswdev/Ckipper.git cd Ckipper # Run the installer (copies all files, merges hooks, adds source line) diff --git a/SECURITY.md b/SECURITY.md index 80ae12d..e84ead8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ Please do **NOT** open a public GitHub issue for security vulnerabilities. Instead, report privately via: -- GitHub Security Advisories: https://github.com/whmoro/Ckipper/security/advisories/new +- GitHub Security Advisories: https://github.com/mswdev/Ckipper/security/advisories/new - Email: matt@msw.dev We aim to acknowledge reports within 72 hours and to provide a fix or mitigation timeline within 7 days. From 54a6184eacf10755845659599a92ebc8281b0540 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 21:29:26 -0600 Subject: [PATCH 064/165] Code review (C1): fix stdout pollution in migrate interactive helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ckipper_migrate_prompt_account_name and _ckipper_migrate_detect_keychain write their result via printf to stdout for caller capture (`$(...)`). Validation/warning echoes were also going to stdout, so on a retry path the captured value was prepended with the error message — corrupting the registry on macOS interactive `ckipper migrate`. Redirect informational and error output to stderr at the affected call sites; `_core_keychain_snapshot` is left as-is (it's a data function intentionally captured by other callers in account-management.zsh). Adds 6 bats cases covering the retry, default, invalid-format, and darwin-fallback paths — these were previously skipped because the helpers blocked on stdin, hiding the bug. The security stub gains a SECURITY_STUB_FAIL_FIND env var to drive the fallback path. Also extracts _ckipper_migrate_print_plan to keep _ckipper_migrate_confirm_plan under the 25-line cap. --- lib/ckipper/migrate.zsh | 29 +++++---- lib/ckipper/migrate_test.bats | 110 ++++++++++++++++++++++++++++++++-- tests/lib/stubs/security | 5 ++ 3 files changed, 128 insertions(+), 16 deletions(-) diff --git a/lib/ckipper/migrate.zsh b/lib/ckipper/migrate.zsh index 86df856..9c2da62 100644 --- a/lib/ckipper/migrate.zsh +++ b/lib/ckipper/migrate.zsh @@ -75,24 +75,22 @@ _ckipper_migrate_prompt_account_name() { read -r "?What name do you want for this migrated account? [$default_name] " name [[ -z "$name" ]] && name="$default_name" if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then - echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." + echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." >&2 name="" continue fi if [[ -e "$HOME/.claude-$name" ]]; then - echo "$HOME/.claude-$name already exists. Pick a different name." + echo "$HOME/.claude-$name already exists. Pick a different name." >&2 name="" fi done printf '%s' "$name" } -# Display the migration plan and prompt for user confirmation. -# Reads legacy_claude, legacy_homejson, target_dir, and name from _CKIPPER_MIGRATE_CTX. +# Print the migration plan to stdout. Reads context from _CKIPPER_MIGRATE_CTX. # -# Returns: -# 0 if user confirms; 1 if user aborts. -_ckipper_migrate_confirm_plan() { +# Returns: 0 always. +_ckipper_migrate_print_plan() { local legacy_claude="${_CKIPPER_MIGRATE_CTX[legacy_claude]}" local legacy_homejson="${_CKIPPER_MIGRATE_CTX[legacy_homejson]}" local target_dir="${_CKIPPER_MIGRATE_CTX[target_dir]}" @@ -118,6 +116,15 @@ NOT a symlink — bare 'claude' will no longer use this account; use 'claude-$na If anything fails, the rename is automatically reverted. EOF +} + +# Display the migration plan and prompt for user confirmation. +# Reads legacy_claude, legacy_homejson, target_dir, and name from _CKIPPER_MIGRATE_CTX. +# +# Returns: +# 0 if user confirms; 1 if user aborts. +_ckipper_migrate_confirm_plan() { + _ckipper_migrate_print_plan local user_choice read -r "?Proceed? [y/N] " user_choice if [[ "$user_choice" != "y" && "$user_choice" != "Y" ]]; then @@ -138,13 +145,13 @@ _ckipper_migrate_detect_keychain() { printf '%s' "$probed_service" return 0 fi - echo "Warning: '$probed_service' not found in Keychain." - echo "Listing available Claude Keychain entries:" - _core_keychain_snapshot || return 1 + echo "Warning: '$probed_service' not found in Keychain." >&2 + echo "Listing available Claude Keychain entries:" >&2 + _core_keychain_snapshot >&2 || return 1 local user_input read -r "?Enter the Keychain service for the account (or empty to skip): " user_input if [[ -n "$user_input" ]] && ! _core_keychain_validate "$user_input"; then - echo "Invalid Keychain service shape. Aborting." + echo "Invalid Keychain service shape. Aborting." >&2 return 1 fi printf '%s' "$user_input" diff --git a/lib/ckipper/migrate_test.bats b/lib/ckipper/migrate_test.bats index f145ff2..a959e25 100644 --- a/lib/ckipper/migrate_test.bats +++ b/lib/ckipper/migrate_test.bats @@ -1,11 +1,6 @@ #!/usr/bin/env bats # Unit tests for lib/ckipper/migrate.zsh helpers. # Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). -# -# Interactive-prompt helpers (_ckipper_migrate_prompt_account_name, -# _ckipper_migrate_confirm_plan, _ckipper_migrate_detect_keychain) are skipped -# because they block on stdin; the full end-to-end flow is covered by -# the characterization tests in ckipper_test.bats. load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -29,6 +24,24 @@ run_helper() { zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" } +# Helper: run a zsh expression with stdin input piped in (for interactive prompts). +# The expression should `printf` or `echo` the result to stdout — bats captures it. +# Extra env vars (like _CKIPPER_TEST_OSTYPE override) can be passed via $extra_env. +run_helper_with_input() { + local expression="$1" + local stdin_input="$2" + local extra_env="${3:-}" + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE_OVERRIDE:-linux}" \ + CKIPPER_FORCE=1 \ + $extra_env \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $expression" <<< "$stdin_input" +} + # ── _ckipper_migrate_check_state ────────────────────────────────────── @test "check_state returns 1 (no state) when neither legacy dir nor home json exist" { @@ -153,3 +166,90 @@ run_helper() { [ "$status" -eq 0 ] [ -d "$legacy_dir" ] } + +# ── _ckipper_migrate_prompt_account_name ────────────────────────────── +# These tests verify that the captured stdout is ONLY the chosen name — +# regression guard for the "stdout pollution on retry" bug where validation +# error messages would leak into the captured value, corrupting the registry. + +@test "prompt_account_name accepts the default on empty input" { + run_helper_with_input \ + 'name=$(_ckipper_migrate_prompt_account_name); printf "RESULT=[%s]" "$name"' \ + $'\n' + + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT=[personal]"* ]] +} + +@test "prompt_account_name returns ONLY the valid name after a name-collision retry" { + # First pick collides (~/.claude-personal exists); user retries with a different name. + mkdir -p "$TMP_HOME/.claude-personal" + + run_helper_with_input \ + 'name=$(_ckipper_migrate_prompt_account_name); printf "RESULT=[%s]" "$name"' \ + $'personal\nwork\n' + + [ "$status" -eq 0 ] + # The captured value must be exactly "work" — not "\nwork". + [[ "$output" == *"RESULT=[work]"* ]] + [[ "$output" != *"RESULT=[already exists"* ]] + [[ "$output" != *"RESULT=[$TMP_HOME"* ]] +} + +@test "prompt_account_name returns ONLY the valid name after an invalid-format retry" { + run_helper_with_input \ + 'name=$(_ckipper_migrate_prompt_account_name); printf "RESULT=[%s]" "$name"' \ + $'BAD NAME!\nwork\n' + + [ "$status" -eq 0 ] + # The captured value must be exactly "work" — not "Account name must match...\nwork". + [[ "$output" == *"RESULT=[work]"* ]] + [[ "$output" != *"RESULT=[Account name"* ]] +} + +# ── _ckipper_migrate_detect_keychain ────────────────────────────────── + +@test "detect_keychain returns empty string on non-darwin platforms" { + run_helper '_ckipper_migrate_detect_keychain; echo "RESULT=[$?]"' + + [ "$status" -eq 0 ] + [[ "$output" == "RESULT=[0]" ]] +} + +@test "detect_keychain returns ONLY the user input from the fallback prompt path" { + # Simulate macOS but with no matching Keychain entry — exercises the fallback prompt. + # The user types "MyCustomService". + + run env \ + HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + _CKIPPER_TEST_OSTYPE="darwin25.0.0" \ + SECURITY_STUB_FAIL_FIND=1 \ + CKIPPER_FORCE=1 \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; svc=\$(_ckipper_migrate_detect_keychain); printf 'RESULT=[%s]' \"\$svc\"" \ + <<< $'MyCustomService\n' + + [ "$status" -eq 0 ] + # Captured value must be exactly "MyCustomService" — not "Warning: ...\nMyCustomService". + [[ "$output" == *"RESULT=[MyCustomService]"* ]] + [[ "$output" != *"RESULT=[Warning"* ]] + [[ "$output" != *"RESULT=[Listing"* ]] +} + +# ── _ckipper_migrate_print_plan ─────────────────────────────────────── + +@test "print_plan emits the migration plan from the migration context" { + run_helper "typeset -gA _CKIPPER_MIGRATE_CTX + _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$TMP_HOME/.claude\" + _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" + _CKIPPER_MIGRATE_CTX[target_dir]=\"$TMP_HOME/.claude-personal\" + _CKIPPER_MIGRATE_CTX[name]=\"personal\" + _ckipper_migrate_print_plan" + + [ "$status" -eq 0 ] + [[ "$output" == *"This migration will:"* ]] + [[ "$output" == *"Rename $TMP_HOME/.claude → $TMP_HOME/.claude-personal."* ]] + [[ "$output" == *"Register 'personal'"* ]] +} diff --git a/tests/lib/stubs/security b/tests/lib/stubs/security index f347479..3f8e558 100755 --- a/tests/lib/stubs/security +++ b/tests/lib/stubs/security @@ -1,7 +1,12 @@ #!/usr/bin/env bash # Test stub for `security`. Reads scripted responses from env vars. +# Set SECURITY_STUB_FAIL_FIND=1 to make find-generic-password exit non-zero +# (used to exercise the keychain fallback path in _ckipper_migrate_detect_keychain). case "$1 $2" in "find-generic-password -s") + if [ -n "$SECURITY_STUB_FAIL_FIND" ]; then + exit 1 + fi echo "${SECURITY_STUB_PASSWORD:-{\"oauth\":\"fake-token\"}}" exit 0 ;; From 1d304be610f7f9b0892ec516e15d358f19944dcd Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 21:29:39 -0600 Subject: [PATCH 065/165] Code review (S1-S5): close remaining rule deviations from PR description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1 — Three functions exceeded the 25-line cap. Extract helpers: - _ckipper_finalize_announce (account-management.zsh) - _ckipper_rename_check_preconditions (account-management.zsh) S2 — _w_bind_port had 3-level nesting. Extract _w_record_bound_port to flatten the host-port-different log into a single-line helper. S3 — Magic numbers in lib/w/docker-mode.zsh tmpfs args. Named: CLAUDE_CONTAINER_UID/GID, CREDS_TMPFS_MODE/SIZE, DOCKER_GROUP_ADD_HOST_ROOT. Comment ties UID/GID to docker/Dockerfile. S4 — sync.zsh interpolated jq output into another jq filter via string substitution. Switch to `--argjson keys "$jq_array"` so the array is typed-bound, never interpolated as code. S5 — entrypoint.sh CREDENTIALS_MAX_BYTES=1000000 mismatched the "1MB" error message. Set to 1048576 (true MiB) and switch the size check from ${#var} (character count) to wc -c (byte count) to be precise. --- docker/entrypoint.sh | 7 ++++--- lib/ckipper/account-management.zsh | 32 ++++++++++++++++++++++++++---- lib/ckipper/sync.zsh | 7 +++++-- lib/w/docker-mode.zsh | 16 +++++++++++++-- lib/w/ports.zsh | 21 ++++++++++++++++---- 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 61d6586..71b9c85 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -2,7 +2,7 @@ set -e # Constants -readonly CREDENTIALS_MAX_BYTES=1000000 +readonly CREDENTIALS_MAX_BYTES=1048576 # 1 MiB (2^20) readonly GIT_CONFIG_COUNT=2 # Require CLAUDE_CONFIG_DIR — Ckipper's account context. No silent fallback. @@ -40,8 +40,9 @@ fi # leakage to the host filesystem). The tmpfs mount at /tmp/claude-creds is # container-local and disappears when the container exits. if [ -n "$CLAUDE_CREDENTIALS" ]; then - if [ "${#CLAUDE_CREDENTIALS}" -gt "$CREDENTIALS_MAX_BYTES" ]; then - echo "Error: CLAUDE_CREDENTIALS exceeds 1MB; refusing to write" >&2 + creds_byte_count=$(printf '%s' "$CLAUDE_CREDENTIALS" | wc -c) + if [ "$creds_byte_count" -gt "$CREDENTIALS_MAX_BYTES" ]; then + echo "Error: CLAUDE_CREDENTIALS exceeds $CREDENTIALS_MAX_BYTES bytes; refusing to write" >&2 exit 1 fi mkdir -p /tmp/claude-creds diff --git a/lib/ckipper/account-management.zsh b/lib/ckipper/account-management.zsh index 1f4dd50..432c14e 100644 --- a/lib/ckipper/account-management.zsh +++ b/lib/ckipper/account-management.zsh @@ -237,6 +237,18 @@ _ckipper_finalize_registration() { _ckipper_finalize_diagnose_error "$name" "$dir" return 1 fi + _ckipper_finalize_announce "$name" "$mode" +} + +# Regenerate aliases, sync hooks, and print the post-registration usage hint. +# +# Args: +# $1 — account name +# $2 — registration mode label (e.g. "fresh", "adopt", "migrate") +# +# Returns: 0 always. +_ckipper_finalize_announce() { + local name="$1" mode="$2" _ckipper_regenerate_aliases _ckipper_sync_hooks_for "$name" echo "Registered '$name' (mode: $mode)." @@ -420,10 +432,15 @@ _ckipper_rename_validate() { # # Returns: # 0 on success; 1 on directory move or registry write failure. -_ckipper_rename_perform() { - local old="$1" new="$2" - local old_dir="${_CKIPPER_RENAME_CTX[old_dir]}" - local new_dir="${_CKIPPER_RENAME_CTX[new_dir]}" +# Verify the rename is safe before performing destructive actions. +# +# Args: +# $1 — new directory path (must not exist) +# $2 — old directory path (must be a directory) +# +# Returns: 0 if preconditions pass; 1 if any check fails. +_ckipper_rename_check_preconditions() { + local new_dir="$1" old_dir="$2" if [[ -e "$new_dir" ]]; then echo "Error: $new_dir already exists. Pick a different name or remove it first." return 1 @@ -433,6 +450,13 @@ _ckipper_rename_perform() { return 1 fi _core_assert_no_running_claude || return 1 +} + +_ckipper_rename_perform() { + local old="$1" new="$2" + local old_dir="${_CKIPPER_RENAME_CTX[old_dir]}" + local new_dir="${_CKIPPER_RENAME_CTX[new_dir]}" + _ckipper_rename_check_preconditions "$new_dir" "$old_dir" || return 1 if ! mv "$old_dir" "$new_dir" 2>/dev/null; then echo "Error: failed to rename $old_dir → $new_dir." >&2 return 1 diff --git a/lib/ckipper/sync.zsh b/lib/ckipper/sync.zsh index ac3eaaf..f6fcc8a 100644 --- a/lib/ckipper/sync.zsh +++ b/lib/ckipper/sync.zsh @@ -79,14 +79,17 @@ _ckipper_sync_mcp_servers() { local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" local dry_run="${_CKIPPER_SYNC_CTX[dry_run]}" local mcp_filter + local -a jq_filter_args if [[ -z "$mcp_names" ]]; then mcp_filter='.mcpServers // {}' + jq_filter_args=("$mcp_filter") else local jq_array jq_array=$(printf '%s' "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | '"$jq_array"' | index($k)))' + mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | $keys | index($k)))' + jq_filter_args=(--argjson keys "$jq_array" "$mcp_filter") fi - local servers; servers=$(jq "$mcp_filter" "$from_dir/.claude.json") + local servers; servers=$(jq "${jq_filter_args[@]}" "$from_dir/.claude.json") local server_keys; server_keys=$(printf '%s' "$servers" | jq -r 'keys[]?' | tr '\n' ' ') if [[ -z "$server_keys" || "$server_keys" == " " ]]; then pending_msgs+=("MCP: nothing to sync (no matching servers in source)") diff --git a/lib/w/docker-mode.zsh b/lib/w/docker-mode.zsh index 5148266..ae4f2f4 100644 --- a/lib/w/docker-mode.zsh +++ b/lib/w/docker-mode.zsh @@ -3,6 +3,18 @@ readonly SHASUM_BITS=256 +# Container user identity. Must match useradd -u/-g in docker/Dockerfile. +readonly CLAUDE_CONTAINER_UID=1000 +readonly CLAUDE_CONTAINER_GID=1000 + +# tmpfs for /tmp/claude-creds inside the container. +readonly CREDS_TMPFS_MODE=700 +readonly CREDS_TMPFS_SIZE="1m" + +# Host gid 0 (wheel/root) added so the container user can read host-mounted files +# owned by macOS staff/wheel without needing world-readable bits. +readonly DOCKER_GROUP_ADD_HOST_ROOT=0 + # Run the worktree in a Docker container. # # Reads globals: W_WT_PATH, W_PROJECTS_DIR, W_PROJECT, W_BRANCH, W_COMMAND, @@ -128,8 +140,8 @@ _w_docker_build_base_args() { -v "$HOME/.ssh:/home/claude/.ssh-host:ro" -v /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock -e SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock - --group-add 0 - --tmpfs /tmp/claude-creds:mode=700,uid=1000,gid=1000,size=1m + --group-add "$DOCKER_GROUP_ADD_HOST_ROOT" + --tmpfs "/tmp/claude-creds:mode=$CREDS_TMPFS_MODE,uid=$CLAUDE_CONTAINER_UID,gid=$CLAUDE_CONTAINER_GID,size=$CREDS_TMPFS_SIZE" -v "claude-uv-cache:/home/claude/.cache/uv" -v "claude-uv-tools:/home/claude/.uv-tools" -e "UV_TOOL_DIR=/home/claude/.uv-tools/envs" diff --git a/lib/w/ports.zsh b/lib/w/ports.zsh index aad2510..44f2ee2 100644 --- a/lib/w/ports.zsh +++ b/lib/w/ports.zsh @@ -30,11 +30,8 @@ _w_bind_port() { for (( i=0; i/dev/null; then - W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) + _w_record_bound_port "$port" "$host_port" is_bound="true" - if (( host_port != port )); then - echo " Port $port mapped to host:$host_port (original in use)" - fi break fi (( host_port++ )) @@ -44,3 +41,19 @@ _w_bind_port() { echo " Port $port: no available host port found ($port-$((port+MAX_PORT_FALLBACK_ATTEMPTS-1)) all in use)" fi } + +# Append the resolved -p flag to W_DOCKER_ARGS and log if the host port differs. +# +# Args: +# $1 — original container port +# $2 — host port that was bound (may equal $1 or be a fallback) +# +# Reads: W_DOCKER_ARGS (appended to). +# Returns: 0 always. +_w_record_bound_port() { + local port="$1" + local host_port="$2" + W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) + (( host_port != port )) && echo " Port $port mapped to host:$host_port (original in use)" + return 0 +} From d026f22acf6d7789d11ecb8efbad402d017d2597 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 21:29:47 -0600 Subject: [PATCH 066/165] Trim README + add post-merge upgrade flow - Drop marketing language in lead paragraphs ("first-class", "headline feature"). - Replace personal-org example (Whmoro/orderguard) with myorg/myapp. - Quick reference adds the three previously omitted subcommands: remove, sync-hooks, repair-plugins. Mentions the `ck` short alias. - Replace the thin `## Updating` section (Docker-only) with separate host-side and container-side update flows. Host-side documents the `git pull && ./install.sh && source ~/.zshrc` path and notes that ckipper sync-hooks should be re-run if hooks changed. --- README.md | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d5e91da..d98060d 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Ckipper (pronounced "skipper") -Docker-based isolation for running Claude Code with `--dangerously-skip-permissions` safely, with **first-class multi-account support**: run a personal Claude account in one terminal and a work account in another, fully isolated. One command to spin up a sandboxed autonomous Claude session on any project. +Docker-based isolation for running Claude Code with `--dangerously-skip-permissions` safely, plus multi-account support: run a personal account in one terminal and a work account in another, fully isolated. -Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, egress firewall, safety hooks, macOS Keychain auth integration, and per-account isolation across credentials, settings, MCP, plugins, and projects. +Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, an egress firewall, safety hooks, macOS Keychain auth, and per-account isolation across credentials, settings, MCP, plugins, and projects. ## The Problem -Claude Code's `--dangerously-skip-permissions` lets Claude work autonomously without clicking Allow for every action. But running it on your actual machine means Claude has full access to your entire filesystem, credentials, and network. +`--dangerously-skip-permissions` lets Claude work autonomously without clicking Allow for every action — but on your actual machine it has full access to your filesystem, credentials, and network. ## The Solution ```bash -w Whmoro/orderguard my-feature --docker claude +w myorg/myapp my-feature --docker claude ``` -This creates a git worktree, spins up a Docker container, and runs Claude inside it. Claude thinks it has full permissions, but it can only access the worktree you gave it. Your Documents, other projects, and system files are completely inaccessible. +Creates a git worktree, spins up a Docker container, and runs Claude inside it. Claude thinks it has full permissions but can only see the worktree. Your other projects, system files, and credentials are inaccessible. ## What It Does @@ -43,16 +43,19 @@ ckipper add # register a Claude account ckipper list # show registered accounts ckipper default # set the default account ckipper rename # rename an account in place -ckipper sync # copy MCP/settings between accounts +ckipper remove # unregister (does not delete the dir) +ckipper sync # copy MCP/settings/plugins between accounts +ckipper sync-hooks # re-deploy hooks into every account dir +ckipper repair-plugins # fix stale ~/.claude/ paths in plugin metadata ckipper doctor # diagnostic checklist ckipper migrate # one-time migration from claude-docker-sandbox ``` -`` is a relative path under `~/Developer/` (e.g. `Whmoro/orderguard`, `Vibma`). Tab completion is included. +`ck` is a short alias for `ckipper`. `` is a relative path under `~/Developer/` (e.g. `myorg/myapp`). Tab completion is included. -## Multiple accounts (Ckipper's headline feature) +## Multiple accounts -Run a personal Claude account in one terminal and a work account in another, fully isolated. Each gets its own credentials, MCP servers, plugins, projects, and session history. +Run a personal account in one terminal and a work account in another, fully isolated. Each gets its own credentials, MCP servers, plugins, projects, and session history. ### Add an account @@ -328,7 +331,24 @@ The `bun` runtime is included in the container image. The entrypoint creates a ` ## Updating -Run `w --rebuild-image` to update everything in the container — system packages, Claude Code, uv/uvx, bun, gh CLI, and Chromium. The build cache-busts all layers so nothing goes stale. Only the base image (`node:24-slim`) is cached; pull it manually with `docker pull node:24-slim` if needed. +### Update the host-side install (Ckipper itself) + +```bash +cd /path/to/Ckipper +git pull +./install.sh # or: make install +source ~/.zshrc +``` + +`install.sh` is idempotent. It re-deploys `~/.ckipper/docker/` (entry scripts, Dockerfile, entrypoint, `lib/` tree) and `~/.ckipper/hooks/`. Your `accounts.json`, `aliases.zsh`, and `w-config.zsh` are preserved. If hooks changed in the update, run `ckipper sync-hooks` to push the new versions into each registered account dir. + +### Update the container + +```bash +w --rebuild-image +``` + +Updates everything in the container — system packages, Claude Code, uv/uvx, bun, gh CLI, and Chromium. The build cache-busts all layers so nothing goes stale. Only the base image (`node:24-slim`) is cached; pull it manually with `docker pull node:24-slim` if needed. To clear stale uv/MCP caches (e.g., after permission errors or broken tool installs): From a0e279620feac43019637edee5173c1f131384c9 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 21:47:02 -0600 Subject: [PATCH 067/165] Reorganize root files and make project/worktree dirs user-configurable Moves templates and manual test prompt out of the repo root: - settings-hooks.json -> templates/settings-template.json (renamed to match deployed name) - w-config.zsh.example -> templates/w-config.zsh.example - test-prompt.md -> docs/test-prompt.md - install.sh + README updated to point at new paths - templates/settings-template.json _comment refreshed (it is now copied per-account, not merged) Removes the only host-coupled hardcoded path so Ckipper works on any machine, not just one keeping projects under ~/Developer: - lib/w/args.zsh:_w_reset_globals now honors pre-set W_PROJECTS_DIR / W_WORKTREES_DIR (defaults preserved) - w-function.zsh tab completion reads the env vars and bumps a version sentinel so existing installs regenerate ~/.zsh/completions/_w - templates/w-config.zsh.example documents the new options - README customization section + Quick Reference + Troubleshooting reference the variables instead of the literal path --- CHANGELOG.md | 6 ++++++ README.md | 18 +++++++++++------- test-prompt.md => docs/test-prompt.md | 0 install.sh | 4 ++-- lib/w/args.zsh | 10 ++++++---- .../settings-template.json | 2 +- .../w-config.zsh.example | 11 +++++++++++ w-function.zsh | 18 +++++++++++++----- 8 files changed, 50 insertions(+), 19 deletions(-) rename test-prompt.md => docs/test-prompt.md (100%) rename settings-hooks.json => templates/settings-template.json (85%) rename w-config.zsh.example => templates/w-config.zsh.example (73%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ed711..33e076f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `W_PROJECTS_DIR` and `W_WORKTREES_DIR` are now user-configurable via `w-config.zsh`. Defaults remain `$HOME/Developer` and `$W_PROJECTS_DIR/.worktrees`. Existing tab-completion files regenerate on next shell startup via a version sentinel. + +### Changed +- Repository layout: templates moved to `templates/` (`w-config.zsh.example`, `settings-template.json`); manual integration test prompt moved to `docs/test-prompt.md`. Source name `settings-hooks.json` renamed to `settings-template.json` to match the deployed name. + ## [0.1.0] — 2026-04-28 ### Added diff --git a/README.md b/README.md index d98060d..b35eb9c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ ckipper doctor # diagnostic checklist ckipper migrate # one-time migration from claude-docker-sandbox ``` -`ck` is a short alias for `ckipper`. `` is a relative path under `~/Developer/` (e.g. `myorg/myapp`). Tab completion is included. +`ck` is a short alias for `ckipper`. `` is a relative path under `$W_PROJECTS_DIR` (default `~/Developer/`, e.g. `myorg/myapp`). Tab completion is included. See [Projects Directory](#projects-directory) to change the base path. ## Multiple accounts @@ -270,8 +270,8 @@ Clone the repo, then open Claude Code and paste this prompt: | `w-function.zsh` | `~/.ckipper/docker/w-function.zsh` | w() launcher entry (sourced by .zshrc) | | `ckipper.zsh` | `~/.ckipper/docker/ckipper.zsh` | ckipper CLI entry (account management) | | `lib/core/`, `lib/ckipper/`, `lib/w/` | `~/.ckipper/docker/lib/` | Shell module tree (sourced by entry scripts; test files excluded) | -| `w-config.zsh.example` | `~/.ckipper/docker/w-config.zsh` | User config (ports, mounts, env vars) | -| `settings-hooks.json` | Auto-merged into `~/.claude/settings.json` | Hook registration | +| `templates/w-config.zsh.example` | `~/.ckipper/docker/w-config.zsh` | User config (ports, mounts, env vars) | +| `templates/settings-template.json` | `~/.ckipper/settings-template.json` | Hook settings template (applied per-account by `ckipper sync-hooks`) | ### macOS Keychain Authentication @@ -287,7 +287,7 @@ After setup, run the comprehensive environment test to verify everything works: w test-branch --docker claude ``` -Then paste the contents of [`test-prompt.md`](test-prompt.md) into the Docker Claude session. It covers 12 sections: +Then paste the contents of [`docs/test-prompt.md`](docs/test-prompt.md) into the Docker Claude session. It covers 12 sections: - Entrypoint verification (env vars, git identity, Chrome disabled, Turbo cache, credential clearing from `/proc/self/environ`) - File system access (read, write, delete, ownership, SSH staging mount, config sanitization) @@ -301,10 +301,14 @@ Then paste the contents of [`test-prompt.md`](test-prompt.md) into the Docker Cl - Safety hooks (4 blocked actions + guardrail bypass testing) - Container isolation (non-root user, sudo restrictions, no Docker socket, setuid audit) -See `test-prompt.md` for the full prompt and expected results table. +See `docs/test-prompt.md` for the full prompt and expected results table. ## Customization +### Projects Directory + +`w()` resolves project paths under `$W_PROJECTS_DIR` (default `$HOME/Developer`). To use a different location (e.g. `~/code`), set `W_PROJECTS_DIR` in `~/.ckipper/docker/w-config.zsh`. Worktrees default to `$W_PROJECTS_DIR/.worktrees`; override with `W_WORKTREES_DIR` if you want them elsewhere. + ### Firewall Domains Edit `docker/init-firewall.sh` → `ALLOWED_DOMAINS` array, then `w --rebuild-image`. @@ -440,8 +444,8 @@ Despite docs saying every `~/.claude/...` path redirects under `CLAUDE_CONFIG_DI | `git commit` fails (no identity) | Entrypoint should set this automatically; check `.claude.json` has `oauthAccount` | | Native binary errors (Exec format) | Run `w --rebuild-image` — entrypoint runs `npm install` to fix platform binaries | | Turbo cache permission denied | Entrypoint sets `TURBO_CACHE_DIR`; run `w --rebuild-image` if missing | -| Branch already checked out | Switch main repo to different branch: `cd ~/Developer/ && git checkout develop` | -| Stale worktree directory | Remove manually: `rm -rf ~/Developer/.worktrees//` | +| Branch already checked out | Switch main repo to different branch: `cd $W_PROJECTS_DIR/ && git checkout develop` | +| Stale worktree directory | Remove manually: `rm -rf $W_WORKTREES_DIR//` | | Statusline not rendering correctly | Add ccstatusline mounts to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`; ensure `bun` is in the image (`w --rebuild-image`) | | `git push` fails (SSH permission denied) | Ensure SSH keys are added to your agent (`ssh-add -l` to check); Docker Desktop forwards the host's SSH agent automatically | | GPG signing issues in container | Handled automatically via `GIT_CONFIG_COUNT` env vars; host config is not modified | diff --git a/test-prompt.md b/docs/test-prompt.md similarity index 100% rename from test-prompt.md rename to docs/test-prompt.md diff --git a/install.sh b/install.sh index 167698d..621c46c 100755 --- a/install.sh +++ b/install.sh @@ -108,7 +108,7 @@ fi # Also preserve accounts.json and aliases.zsh if they already exist (managed by ckipper CLI). config_file="$CKIPPER_DIR/docker/w-config.zsh" if [[ ! -f $config_file ]]; then - cp "$REPO_DIR/w-config.zsh.example" "$config_file" + cp "$REPO_DIR/templates/w-config.zsh.example" "$config_file" echo " Created w-config.zsh with defaults — edit to add your MCP mounts, ports, etc." else echo " w-config.zsh already exists (not overwritten)" @@ -118,7 +118,7 @@ fi # 6. Deploy settings-template.json (consumed by ckipper add / sync-hooks per-account) echo "Copying settings-template.json to $CKIPPER_DIR/..." -cp "$REPO_DIR/settings-hooks.json" "$CKIPPER_DIR/settings-template.json" +cp "$REPO_DIR/templates/settings-template.json" "$CKIPPER_DIR/settings-template.json" echo " Settings template deployed. ckipper sync-hooks applies it per-account." # 7. Add or update source line in .zshrc diff --git a/lib/w/args.zsh b/lib/w/args.zsh index c3ce19e..fca67b5 100644 --- a/lib/w/args.zsh +++ b/lib/w/args.zsh @@ -14,8 +14,8 @@ # W_BRANCH — second positional arg (worktree/branch name) # W_CLI_ACCOUNT — value of --account , or empty # W_COMMAND — array: remaining positional args after project+branch -# W_PROJECTS_DIR — base directory for projects ($HOME/Developer) -# W_WORKTREES_DIR — base directory for worktrees ($HOME/Developer/.worktrees) +# W_PROJECTS_DIR — base directory for projects (default: $HOME/Developer; honors pre-set value from w-config.zsh or environment) +# W_WORKTREES_DIR — base directory for worktrees (default: $W_PROJECTS_DIR/.worktrees; honors pre-set value) # # Returns: 0 always (validation is done by the dispatcher). _w_parse_args() { @@ -53,8 +53,10 @@ _w_reset_globals() { W_BRANCH="" W_CLI_ACCOUNT="" W_COMMAND=() - W_PROJECTS_DIR="$HOME/Developer" - W_WORKTREES_DIR="$HOME/Developer/.worktrees" + # Honor pre-set values from w-config.zsh or environment so users can host + # their projects anywhere (e.g. $HOME/code, $HOME/work) without forking. + W_PROJECTS_DIR="${W_PROJECTS_DIR:-$HOME/Developer}" + W_WORKTREES_DIR="${W_WORKTREES_DIR:-$W_PROJECTS_DIR/.worktrees}" } # Parse --rm [--force] args. diff --git a/settings-hooks.json b/templates/settings-template.json similarity index 85% rename from settings-hooks.json rename to templates/settings-template.json index a2209c3..803012c 100644 --- a/settings-hooks.json +++ b/templates/settings-template.json @@ -1,5 +1,5 @@ { - "_comment": "Merge this into your existing ~/.claude/settings.json hooks section. Do not replace the entire file.", + "_comment": "Per-account settings.json template. Copied verbatim by ckipper add; hook paths are rewritten to the account dir by ckipper sync-hooks.", "hooks": { "PreToolUse": [ { diff --git a/w-config.zsh.example b/templates/w-config.zsh.example similarity index 73% rename from w-config.zsh.example rename to templates/w-config.zsh.example index 2211570..c4d668a 100644 --- a/w-config.zsh.example +++ b/templates/w-config.zsh.example @@ -6,6 +6,17 @@ # After editing, run: source ~/.zshrc # ───────────────────────────────────────────────────────────────── +# Base directory containing your git projects. The first arg to w() is +# resolved relative to this path. Default: $HOME/Developer +# Examples: +# W_PROJECTS_DIR="$HOME/code" +# W_PROJECTS_DIR="$HOME/work/repos" +# W_PROJECTS_DIR="$HOME/Developer" + +# Where w() creates per-project worktrees. Default: $W_PROJECTS_DIR/.worktrees +# Override only if you want worktrees outside your projects tree. +# W_WORKTREES_DIR="$HOME/.worktrees" + # Ports to forward from container to host. # These should match your dev servers (Next.js, Storybook, etc.) W_PORTS=(3000 3030 6006) diff --git a/w-function.zsh b/w-function.zsh index 744618c..3fdd344 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -10,10 +10,12 @@ # w --rm remove worktree + delete branch # w --rebuild-image rebuild ckipper-dev Docker image # -# is a path relative to ~/Developer (e.g. "Whmoro/orderguard", "my-app") +# is a path relative to W_PROJECTS_DIR (default: ~/Developer; e.g. "Whmoro/orderguard", "my-app") # # ── CUSTOMIZATION ──────────────────────────────────────────────── # Edit ~/.ckipper/docker/w-config.zsh to customize: +# - W_PROJECTS_DIR: base directory for git projects (default: $HOME/Developer) +# - W_WORKTREES_DIR: base directory for worktrees (default: $W_PROJECTS_DIR/.worktrees) # - W_PORTS: dev server ports to forward # - W_EXTRA_VOLUMES: MCP server mounts and other volume mounts # - W_EXTRA_ENV: extra environment variables for the container @@ -45,7 +47,7 @@ fi # Worktree-aware Claude Code launcher. # # Args: -# $1 — project path (relative to ~/Developer), or a flag (--list, --rm, --rebuild-image) +# $1 — project path (relative to W_PROJECTS_DIR), or a flag (--list, --rm, --rebuild-image) # $2 — branch/worktree name (required unless $1 is --list or --rebuild-image) # $@ — optional flags and command: [--docker] [--firewall] [--account ] [cmd...] # @@ -109,7 +111,12 @@ _w_usage() { [[ -d ~/.zsh/completions ]] || mkdir -p ~/.zsh/completions fpath=(~/.zsh/completions $fpath) -if [[ ! -f ~/.zsh/completions/_w ]]; then +# Bump this when the heredoc body below changes so existing installs regenerate +# the cached completion file. The version is embedded as a literal comment in +# the generated file and matched here. +W_COMPLETION_VERSION=2 +if [[ ! -f ~/.zsh/completions/_w ]] \ + || ! grep -q "# w-completion-version=$W_COMPLETION_VERSION" ~/.zsh/completions/_w 2>/dev/null; then # Note: `_w()` below is a zsh tab-completion definition embedded in a heredoc. # It uses zsh's _arguments DSL and must remain a single function for tab # completion to work. The 25-line cap in code-style.md does not apply to @@ -117,10 +124,11 @@ if [[ ! -f ~/.zsh/completions/_w ]]; then # not maintained shell logic). cat > ~/.zsh/completions/_w << 'COMPEOF' #compdef w +# w-completion-version=2 _w() { - local projects_dir="$HOME/Developer" - local worktrees_dir="$HOME/Developer/.worktrees" + local projects_dir="${W_PROJECTS_DIR:-$HOME/Developer}" + local worktrees_dir="${W_WORKTREES_DIR:-$projects_dir/.worktrees}" _arguments -C \ '(--rm)--list[List all worktrees]' \ From db91fcb25ab3c8340e881ba05199b9916dca5829 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 21:54:23 -0600 Subject: [PATCH 068/165] Code review (S1): refresh _w_reset_globals doc-header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc-header still claimed "reset all W_* globals to their default values" after the body was changed to honor pre-set W_PROJECTS_DIR / W_WORKTREES_DIR. Per shell-conventions.md the doc-header is the contract — bring it in line with the new behavior. --- lib/w/args.zsh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/w/args.zsh b/lib/w/args.zsh index fca67b5..7556485 100644 --- a/lib/w/args.zsh +++ b/lib/w/args.zsh @@ -39,7 +39,8 @@ _w_parse_args() { _w_parse_run_args "$@" } -# Reset all W_* globals to their default values. +# Reset W_* globals to defaults. W_PROJECTS_DIR and W_WORKTREES_DIR +# preserve any pre-set value from w-config.zsh or environment. # # Returns: 0 always. _w_reset_globals() { From 431ea7223abaec7c04df33adfa5a8b8979329fe1 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 21:58:47 -0600 Subject: [PATCH 069/165] Code review (I1): move projects/worktrees dir defaults out of _w_reset_globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _w_reset_globals was doing two unrelated jobs: resetting per-call arg state (flags + positionals) AND honoring/defaulting config values. The function name promised the former but the body did both, which the reviewer flagged as confusing. Move W_PROJECTS_DIR and W_WORKTREES_DIR initialization to w-function.zsh alongside the existing W_PORTS / W_EXTRA_VOLUMES / W_EXTRA_ENV defaults that already follow this pattern: source w-config.zsh, then apply defaults for anything still unset. Set once at source time, never reset per-call. _w_reset_globals now does what its name says — resets only the per-call arg globals. args.zsh doc-headers updated to match. No behavior change for end users; defaults preserved verbatim. --- lib/w/args.zsh | 14 ++++++-------- w-function.zsh | 7 +++++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/w/args.zsh b/lib/w/args.zsh index 7556485..a95cd84 100644 --- a/lib/w/args.zsh +++ b/lib/w/args.zsh @@ -14,8 +14,9 @@ # W_BRANCH — second positional arg (worktree/branch name) # W_CLI_ACCOUNT — value of --account , or empty # W_COMMAND — array: remaining positional args after project+branch -# W_PROJECTS_DIR — base directory for projects (default: $HOME/Developer; honors pre-set value from w-config.zsh or environment) -# W_WORKTREES_DIR — base directory for worktrees (default: $W_PROJECTS_DIR/.worktrees; honors pre-set value) +# +# W_PROJECTS_DIR / W_WORKTREES_DIR are config values, not args — they are +# initialized once when w-function.zsh is sourced and never reset here. # # Returns: 0 always (validation is done by the dispatcher). _w_parse_args() { @@ -39,8 +40,9 @@ _w_parse_args() { _w_parse_run_args "$@" } -# Reset W_* globals to defaults. W_PROJECTS_DIR and W_WORKTREES_DIR -# preserve any pre-set value from w-config.zsh or environment. +# Reset per-call W_* arg globals (flags + positionals) to their default values. +# Config globals (W_PROJECTS_DIR, W_WORKTREES_DIR, W_PORTS, etc.) are owned +# by w-function.zsh and intentionally not touched here. # # Returns: 0 always. _w_reset_globals() { @@ -54,10 +56,6 @@ _w_reset_globals() { W_BRANCH="" W_CLI_ACCOUNT="" W_COMMAND=() - # Honor pre-set values from w-config.zsh or environment so users can host - # their projects anywhere (e.g. $HOME/code, $HOME/work) without forking. - W_PROJECTS_DIR="${W_PROJECTS_DIR:-$HOME/Developer}" - W_WORKTREES_DIR="${W_WORKTREES_DIR:-$W_PROJECTS_DIR/.worktrees}" } # Parse --rm [--force] args. diff --git a/w-function.zsh b/w-function.zsh index 3fdd344..81ebf02 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -34,12 +34,15 @@ source "$W_REPO_DIR/lib/w/ports.zsh" source "$W_REPO_DIR/lib/w/docker-mode.zsh" source "$W_REPO_DIR/lib/w/normal-mode.zsh" -# Source user config (ports, extra volumes, extra env vars) +# Source user config (projects/worktrees dirs, ports, extra volumes, extra env vars) _w_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" if [[ -f "$_w_config" ]]; then source "$_w_config" fi -# Defaults if config is missing or incomplete +# Defaults if config is missing or incomplete. Set once at source time and +# never reset per-call so users can host their projects anywhere without forking. +W_PROJECTS_DIR="${W_PROJECTS_DIR:-$HOME/Developer}" +W_WORKTREES_DIR="${W_WORKTREES_DIR:-$W_PROJECTS_DIR/.worktrees}" (( ${#W_PORTS[@]} == 0 )) && W_PORTS=(3000) (( ${#W_EXTRA_VOLUMES[@]} == 0 )) && W_EXTRA_VOLUMES=() (( ${#W_EXTRA_ENV[@]} == 0 )) && W_EXTRA_ENV=() From fb540ba7e8d63438ea9abdb4487065b5bbdaf327 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 21:58:47 -0600 Subject: [PATCH 070/165] Code review (S2): add bats test guarding completion version sentinel drift The cache-busting mechanism for ~/.zsh/completions/_w depends on the literal "# w-completion-version=N" sentinel inside the single-quoted heredoc matching the W_COMPLETION_VERSION variable referenced in the grep check immediately above. Today both say "2". If a future bump only updates one side, existing installs silently fail to regenerate (or regenerate every shell start). Cheap insurance: a bats test that greps both values out and asserts equality. --- w-function_test.bats | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/w-function_test.bats b/w-function_test.bats index 9833037..16682a2 100644 --- a/w-function_test.bats +++ b/w-function_test.bats @@ -111,3 +111,20 @@ teardown() { [ "$status" -ne 0 ] [[ "$output" =~ "--firewall" || "$output" =~ "firewall" ]] } + +# ── tab completion version sentinel ────────────────────────────────── + +# The completion-file regeneration mechanism relies on the literal +# "# w-completion-version=N" sentinel inside the single-quoted heredoc +# matching the W_COMPLETION_VERSION variable referenced in the grep +# check immediately above. If a future bump only updates one side, +# existing installs silently fail to regenerate. This test guards +# against that drift. +@test "w-function.zsh: completion version sentinel matches outer variable" { + local outer inner + outer=$(grep -E '^W_COMPLETION_VERSION=' "$REPO_ROOT/w-function.zsh" | head -1 | cut -d= -f2) + inner=$(grep -E '^# w-completion-version=' "$REPO_ROOT/w-function.zsh" | head -1 | cut -d= -f2) + [ -n "$outer" ] + [ -n "$inner" ] + [ "$outer" = "$inner" ] +} From 8fc37e0d557e627ccbf72dead9f4dd4aae56cae8 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 22:48:02 -0600 Subject: [PATCH 071/165] Add _core_fuzzy_suggest helper for unknown-command suggestions --- lib/core/fuzzy.zsh | 67 ++++++++++++++++++++++++++++++++++++++++ lib/core/fuzzy_test.bats | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 lib/core/fuzzy.zsh create mode 100644 lib/core/fuzzy_test.bats diff --git a/lib/core/fuzzy.zsh b/lib/core/fuzzy.zsh new file mode 100644 index 0000000..b90261c --- /dev/null +++ b/lib/core/fuzzy.zsh @@ -0,0 +1,67 @@ +#!/usr/bin/env zsh +# Fuzzy-suggest helper. Pure functions: no globals read or written. +# +# Used by ckipper dispatchers to suggest the closest known subcommand +# when the user types something unrecognised. + +readonly _CORE_FUZZY_DISTANCE_THRESHOLD=2 + +# Compute Levenshtein edit distance between two strings. +# +# Args: +# $1 — string A +# $2 — string B +# +# Returns: 0 always. Prints the distance (non-negative integer) to stdout. +_core_fuzzy_levenshtein() { + local a="$1" b="$2" + local la=${#a} lb=${#b} + (( la == 0 )) && { echo "$lb"; return 0; } + (( lb == 0 )) && { echo "$la"; return 0; } + + local -a prev curr + local i j cost del ins sub + for (( i = 0; i <= lb; i++ )); do + prev[$((i + 1))]=$i + done + for (( i = 1; i <= la; i++ )); do + curr[1]=$i + for (( j = 1; j <= lb; j++ )); do + cost=1 + [[ "${a[i]}" == "${b[j]}" ]] && cost=0 + del=$(( prev[j + 1] + 1 )) + ins=$(( curr[j] + 1 )) + sub=$(( prev[j] + cost )) + curr[$((j + 1))]=$(( del < ins ? (del < sub ? del : sub) : (ins < sub ? ins : sub) )) + done + prev=("${curr[@]}") + done + echo "${prev[lb + 1]}" +} + +# Find the closest candidate to the input within the distance threshold. +# +# Walks every candidate, keeps the one with the smallest distance ≤ threshold, +# and prints it. Exact matches return distance 0 and win automatically. Ties go +# to the first-seen candidate (stable on insertion order). +# +# Args: +# $1 — input token (the unknown subcommand the user typed) +# $2..$N — candidate list +# +# Returns: 0 always. Prints the closest candidate, or empty string if no +# candidate is within the threshold (or the candidate list is empty). +_core_fuzzy_suggest() { + local input="$1" + shift + local best="" best_dist=$(( _CORE_FUZZY_DISTANCE_THRESHOLD + 1 )) + local candidate dist + for candidate in "$@"; do + dist=$(_core_fuzzy_levenshtein "$input" "$candidate") + if (( dist <= _CORE_FUZZY_DISTANCE_THRESHOLD && dist < best_dist )); then + best="$candidate" + best_dist="$dist" + fi + done + echo "$best" +} diff --git a/lib/core/fuzzy_test.bats b/lib/core/fuzzy_test.bats new file mode 100644 index 0000000..79fe47c --- /dev/null +++ b/lib/core/fuzzy_test.bats @@ -0,0 +1,56 @@ +#!/usr/bin/env bats +# Module-level tests for lib/core/fuzzy.zsh. +# Tests _core_fuzzy_suggest observable behaviour. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: source fuzzy.zsh and run a command in a zsh subshell. +_run_fuzzy() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/fuzzy.zsh\"; $zsh_cmd" +} + +@test "_core_fuzzy_suggest returns exact match unchanged" { + _run_fuzzy "_core_fuzzy_suggest list list add remove" + [ "$status" -eq 0 ] + [ "$output" = "list" ] +} + +@test "_core_fuzzy_suggest returns close single-letter typo" { + _run_fuzzy "_core_fuzzy_suggest lst list add remove" + [ "$status" -eq 0 ] + [ "$output" = "list" ] +} + +@test "_core_fuzzy_suggest returns transposition" { + _run_fuzzy "_core_fuzzy_suggest lsit list add remove" + [ "$status" -eq 0 ] + [ "$output" = "list" ] +} + +@test "_core_fuzzy_suggest returns nothing for far-off input" { + _run_fuzzy "_core_fuzzy_suggest xyzabc list add remove" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "_core_fuzzy_suggest picks the closest of multiple candidates" { + _run_fuzzy "_core_fuzzy_suggest ad add default doctor" + [ "$status" -eq 0 ] + [ "$output" = "add" ] +} + +@test "_core_fuzzy_suggest handles empty candidate list" { + _run_fuzzy "_core_fuzzy_suggest anything" + [ "$status" -eq 0 ] + [ -z "$output" ] +} From 03331e3a7b29e6174693c42534ad08d2f8787201 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 22:51:01 -0600 Subject: [PATCH 072/165] Drop ckipper migrate (legacy claude-docker-sandbox migration) --- CHANGELOG.md | 3 + README.md | 24 +- ckipper.zsh | 11 +- ckipper_test.bats | 53 --- install.sh | 23 -- lib/ckipper/account-management.zsh | 4 +- lib/ckipper/doctor.zsh | 4 +- lib/ckipper/migrate.zsh | 382 ------------------ lib/ckipper/migrate_test.bats | 255 ------------ .../legacy-claude-layout/.claude.json | 1 - .../.claude/docker/w-config.zsh | 1 - 11 files changed, 12 insertions(+), 749 deletions(-) delete mode 100644 lib/ckipper/migrate.zsh delete mode 100644 lib/ckipper/migrate_test.bats delete mode 100644 tests/fixtures/legacy-claude-layout/.claude.json delete mode 100644 tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e076f..d0b5aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed +- `ckipper migrate` subcommand (legacy `claude-docker-sandbox` migration). The legacy install path is no longer supported; use `ckipper add ` for fresh installs. + ### Added - `W_PROJECTS_DIR` and `W_WORKTREES_DIR` are now user-configurable via `w-config.zsh`. Defaults remain `$HOME/Developer` and `$W_PROJECTS_DIR/.worktrees`. Existing tab-completion files regenerate on next shell startup via a version sentinel. diff --git a/README.md b/README.md index b35eb9c..5991db5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ ckipper sync # copy MCP/settings/plugins ckipper sync-hooks # re-deploy hooks into every account dir ckipper repair-plugins # fix stale ~/.claude/ paths in plugin metadata ckipper doctor # diagnostic checklist -ckipper migrate # one-time migration from claude-docker-sandbox ``` `ck` is a short alias for `ckipper`. `` is a relative path under `$W_PROJECTS_DIR` (default `~/Developer/`, e.g. `myorg/myapp`). Tab completion is included. See [Projects Directory](#projects-directory) to change the base path. @@ -198,27 +197,6 @@ Two named Docker volumes support uvx-based MCP servers: The entrypoint pre-installs uvx-based MCP servers before Claude starts and rewrites the container's `.claude.json` to invoke the installed binary directly. This eliminates the network freshness check and ephemeral venv creation that cause intermittent MCP startup timeouts. -## Migrating from claude-docker-sandbox - -If you've been running this project under its previous name with a single `~/.claude/docker/` install, run: - -```bash -ckipper migrate -``` - -This will: - -1. Refuse to run if any `claude` process is currently active (quit them first). -2. Copy `~/.claude/docker/` → `~/.ckipper/`. -3. Offer to register your existing `~/.claude` as the `personal` account. If you accept: rename `~/.claude` → `~/.claude-personal`, probe Keychain for the matching credential entry, and write the registry. **No symlink is created** — after migration, you launch Claude with `claude-personal` (bare `claude` will start a fresh login). -4. If anything fails, the rename automatically reverses (rollback). - -Then add additional accounts: - -```bash -ckipper add work -``` - ## Setup ### Prerequisites @@ -425,7 +403,7 @@ Despite docs saying every `~/.claude/...` path redirects under `CLAUDE_CONFIG_DI ### Diagnose anytime -`ckipper doctor` runs a full health check: registry validity, account dir presence, `.claude.json`/`settings.json`/`hooks/` per-account, Keychain entries, `~/.zshrc` source lines, and stub-file presence. Use it after `ckipper migrate` or whenever something looks off. +`ckipper doctor` runs a full health check: registry validity, account dir presence, `.claude.json`/`settings.json`/`hooks/` per-account, Keychain entries, `~/.zshrc` source lines, and stub-file presence. Run it whenever something looks off. ## Troubleshooting diff --git a/ckipper.zsh b/ckipper.zsh index 866dfb2..3c4549c 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -20,13 +20,12 @@ source "$CKIPPER_REPO_DIR/lib/ckipper/aliases.zsh" source "$CKIPPER_REPO_DIR/lib/ckipper/plugin-repair.zsh" source "$CKIPPER_REPO_DIR/lib/ckipper/sync.zsh" source "$CKIPPER_REPO_DIR/lib/ckipper/doctor.zsh" -source "$CKIPPER_REPO_DIR/lib/ckipper/migrate.zsh" # Dispatch a ckipper subcommand or print top-level help. # # Args: # $1 — subcommand name (add, list, default, remove, rename, sync, sync-hooks, -# migrate, doctor, repair-plugins, help, -h, --help, or empty) +# doctor, repair-plugins, help, -h, --help, or empty) # $@ — arguments forwarded to the subcommand handler # # Returns: @@ -39,7 +38,7 @@ ckipper() { shift 2>/dev/null case "$cmd" in # --help on any subcommand short-circuits to subcommand help - add|list|default|remove|rename|sync|sync-hooks|migrate|doctor|repair-plugins) + add|list|default|remove|rename|sync|sync-hooks|doctor|repair-plugins) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_help_for "$cmd" return 0 @@ -68,7 +67,6 @@ Usage: ckipper rename Rename an account (dir + registry + aliases) ckipper sync Copy MCP/settings from one account to another ckipper sync-hooks Copy hooks into all registered accounts - ckipper migrate One-time migration from legacy layout ckipper doctor Diagnostic check of registered accounts and tooling ckipper repair-plugins Rewrite stale ~/.claude/ paths in plugin metadata @@ -118,8 +116,8 @@ Rewrite stale absolute paths in /plugins/{known_marketplaces, installed_plugins}.json from $HOME/.claude/... to the account's actual dir. Use this when Claude Code shows "Plugin not found in marketplace ..." for -plugins that were installed before `ckipper migrate` (or before the dir was -renamed). Backups are written alongside each rewritten file. +plugins that were installed before the account directory was renamed. +Backups are written alongside each rewritten file. EOF } @@ -169,7 +167,6 @@ _ckipper_help_for() { sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; repair-plugins) _help_text_repair_plugins ;; sync) _help_text_sync ;; - migrate) echo "ckipper migrate — migrate from legacy ~/.claude/docker/ layout. Idempotent. Refuses if Claude is running." ;; doctor) echo "ckipper doctor — run a diagnostic checklist on registered accounts and ckipper tooling." ;; esac } diff --git a/ckipper_test.bats b/ckipper_test.bats index fbb2dc0..4ef03ad 100644 --- a/ckipper_test.bats +++ b/ckipper_test.bats @@ -19,59 +19,6 @@ teardown() { teardown_isolated_env } -# ── _ckipper_migrate ──────────────────────────────────────────────── - -@test "ckipper migrate --help prints usage and exits 0" { - run_ckipper migrate --help - [ "$status" -eq 0 ] - [[ "$output" =~ "migrate" ]] -} - -@test "ckipper migrate reports nothing-to-migrate and exits 0 when no legacy state exists" { - # Note: with no ~/.claude state and no ~/.claude.json the code prints - # "Nothing to migrate" and returns 0 (not an error — it's a no-op). - run_ckipper migrate - [ "$status" -eq 0 ] - [[ "$output" =~ "Nothing to migrate" || "$output" =~ "nothing to migrate" ]] -} - -@test "ckipper migrate creates accounts.json when run with legacy layout" { - # Copy fixture legacy layout into isolated HOME. - cp -r "$REPO_ROOT/tests/fixtures/legacy-claude-layout/." "$TMP_HOME/" - # Feed stdin: account name "personal", then confirm "y". - # _CKIPPER_TEST_OSTYPE=linux skips macOS Keychain probing. - local stdin_file="$TMP_HOME/stdin.txt" - printf 'personal\ny\n' > "$stdin_file" - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="linux" \ - CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; ckipper migrate" < "$stdin_file" - [ -f "$CKIPPER_REGISTRY" ] -} - -@test "ckipper migrate aborts when user declines the prompt" { - cp -r "$REPO_ROOT/tests/fixtures/legacy-claude-layout/." "$TMP_HOME/" - # Feed stdin via a temp file: account name "personal", then decline "n". - # Note: bats `run` cannot capture status when the command is on the - # right-hand side of a pipe; use a temp stdin file instead. - local stdin_file="$TMP_HOME/stdin.txt" - printf 'personal\nn\n' > "$stdin_file" - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="linux" \ - CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; ckipper migrate" < "$stdin_file" - [ "$status" -ne 0 ] - [[ "$output" =~ "Aborted" ]] -} - # ── _ckipper_doctor ───────────────────────────────────────────────── @test "ckipper doctor prints diagnostic output and mentions registry" { diff --git a/install.sh b/install.sh index 621c46c..1cb3518 100755 --- a/install.sh +++ b/install.sh @@ -7,29 +7,6 @@ echo "" REPO_DIR="$(cd "$(dirname "$0")" && pwd)" CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" -# Migrate legacy ~/.claude/docker/ layout if present (idempotent) -LEGACY_DIR="$HOME/.claude/docker" -if [ -d "$LEGACY_DIR" ] && [ ! -d "$CKIPPER_DIR/docker" ]; then - echo "Migrating ~/.claude/docker/ -> $CKIPPER_DIR/docker/" - mkdir -p "$CKIPPER_DIR/docker" - cp -a "$LEGACY_DIR/." "$CKIPPER_DIR/docker/" - echo "Migrated. The legacy directory is left intact at $LEGACY_DIR for one release cycle." - echo "After verifying the new location works (ckipper list shows your accounts):" - echo " rm -rf $LEGACY_DIR" - - # Sweep migrated w-config.zsh for stale path strings (warn only — never auto-edit user config) - if [ -f "$CKIPPER_DIR/docker/w-config.zsh" ]; then - stale=$(grep -n "\.claude/docker" "$CKIPPER_DIR/docker/w-config.zsh" 2>/dev/null || true) - if [ -n "$stale" ]; then - echo "" - echo "WARNING: Your migrated w-config.zsh contains stale ~/.claude/docker/ paths:" - echo "$stale" - echo "Update these to ~/.ckipper/docker/ manually." - echo "" - fi - fi -fi - # 1. Check prerequisites echo "Checking prerequisites..." missing_dependencies=() diff --git a/lib/ckipper/account-management.zsh b/lib/ckipper/account-management.zsh index 432c14e..ae881af 100644 --- a/lib/ckipper/account-management.zsh +++ b/lib/ckipper/account-management.zsh @@ -213,7 +213,7 @@ _ckipper_add() { # Reads name, dir, and service from _CKIPPER_FINALIZE_CTX module global. # # Args: -# $1 — registration mode: "fresh", "adopt", or "migrate" +# $1 — registration mode: "fresh" or "adopt" # # Returns: # 0 on success; 1 on registry collision or write failure. @@ -244,7 +244,7 @@ _ckipper_finalize_registration() { # # Args: # $1 — account name -# $2 — registration mode label (e.g. "fresh", "adopt", "migrate") +# $2 — registration mode label (e.g. "fresh", "adopt") # # Returns: 0 always. _ckipper_finalize_announce() { diff --git a/lib/ckipper/doctor.zsh b/lib/ckipper/doctor.zsh index 277ba73..0066f8f 100644 --- a/lib/ckipper/doctor.zsh +++ b/lib/ckipper/doctor.zsh @@ -51,7 +51,7 @@ _ckipper_doctor_registry() { echo "" echo "── Registry ──────────────────────────────────────────" if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - _ckipper_doctor_check INFO "No registry yet — no accounts registered. Run: ckipper migrate (or ckipper add )" + _ckipper_doctor_check INFO "No registry yet — no accounts registered. Run: ckipper add " return 1 fi local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) @@ -183,7 +183,7 @@ _ckipper_doctor_shell() { else _ckipper_doctor_check PASS "~/.claude (stub dir) is absent" fi - if [[ -f "$HOME/.claude.json" ]]; then _ckipper_doctor_check WARN "~/.claude.json exists at home root — should have been migrated. If you ran migrate, this is leftover." + if [[ -f "$HOME/.claude.json" ]]; then _ckipper_doctor_check WARN "~/.claude.json exists at home root — leftover from a pre-ckipper claude install." else _ckipper_doctor_check PASS "~/.claude.json (home root) is absent"; fi } diff --git a/lib/ckipper/migrate.zsh b/lib/ckipper/migrate.zsh deleted file mode 100644 index 9c2da62..0000000 --- a/lib/ckipper/migrate.zsh +++ /dev/null @@ -1,382 +0,0 @@ -#!/usr/bin/env zsh -# One-time migration from legacy ~/.claude/docker/ layout. - -# Module globals tracking destructive migration steps for rollback. -typeset -g _CKIPPER_MIGRATE_STEP=0 -typeset -g _CKIPPER_MIGRATE_BACKUP="" - -# Module-level context for the in-progress migration. -# Populated by _ckipper_migrate before any helper reads it. -# Fields: name, target_dir, legacy_claude, legacy_homejson, probed_service -typeset -gA _CKIPPER_MIGRATE_CTX - -# Check preconditions for migration: no running Claude, no symlinks at key paths. -# -# Args: -# $1 — legacy_claude path (e.g. "$HOME/.claude") -# $2 — legacy home json path (e.g. "$HOME/.claude.json") -# -# Returns: -# 0 if all preconditions pass; 1 on failure. -# -# Errors (stderr): -# "Error: ... is a symlink..." — when either path is a symlink. -_ckipper_migrate_preflight() { - local legacy_claude="$1" legacy_homejson_check="$2" - _core_assert_no_running_claude || return 1 - if [[ -L "$legacy_claude" ]]; then - local target; target=$(readlink "$legacy_claude") - echo "Error: $legacy_claude is a symlink (→ $target). Refusing to migrate." >&2 - echo "Resolve manually: replace the symlink with the actual directory contents," >&2 - echo "or migrate the target directly." >&2 - return 1 - fi - if [[ -L "$legacy_homejson_check" ]]; then - local target; target=$(readlink "$legacy_homejson_check") - echo "Error: $legacy_homejson_check is a symlink (→ $target). Refusing to migrate." >&2 - echo "Resolve manually: replace the symlink with the actual file contents," >&2 - echo "or migrate the target directly." >&2 - return 1 - fi -} - - -# Print the appropriate no-op message for migrate when nothing needs to be done. -# -# Args: -# $1 — legacy_claude path -# $2 — legacy home json path -# -# Returns: -# 0 always. -_ckipper_migrate_print_no_op() { - local legacy_claude="$1" legacy_homejson="$2" - local has_registry="false" - [[ -f "$CKIPPER_REGISTRY" ]] && \ - jq -e '.accounts | length > 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && has_registry="true" - if [[ "$has_registry" = "true" ]]; then - echo "Nothing to migrate: no $legacy_claude state and no $legacy_homejson at home root." - echo "($CKIPPER_REGISTRY already has registered accounts — you're likely already migrated.)" - echo "Run: ckipper list" - else - echo "Nothing to migrate: no $legacy_claude/.claude.json, no $legacy_claude/settings.json, no $legacy_homejson." - echo "If this is a fresh setup, register an account directly: ckipper add " - fi -} - -# Prompt the user for a migration account name with validation. -# Prints the chosen name to stdout. -# -# Returns: -# 0 with the name on stdout; 1 if the name cannot be determined. -_ckipper_migrate_prompt_account_name() { - local default_name="personal" name="" - while [[ -z "$name" ]]; do - read -r "?What name do you want for this migrated account? [$default_name] " name - [[ -z "$name" ]] && name="$default_name" - if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then - echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen). Try again." >&2 - name="" - continue - fi - if [[ -e "$HOME/.claude-$name" ]]; then - echo "$HOME/.claude-$name already exists. Pick a different name." >&2 - name="" - fi - done - printf '%s' "$name" -} - -# Print the migration plan to stdout. Reads context from _CKIPPER_MIGRATE_CTX. -# -# Returns: 0 always. -_ckipper_migrate_print_plan() { - local legacy_claude="${_CKIPPER_MIGRATE_CTX[legacy_claude]}" - local legacy_homejson="${_CKIPPER_MIGRATE_CTX[legacy_homejson]}" - local target_dir="${_CKIPPER_MIGRATE_CTX[target_dir]}" - local name="${_CKIPPER_MIGRATE_CTX[name]}" - cat </dev/null 2>&1; then - printf '%s' "$probed_service" - return 0 - fi - echo "Warning: '$probed_service' not found in Keychain." >&2 - echo "Listing available Claude Keychain entries:" >&2 - _core_keychain_snapshot >&2 || return 1 - local user_input - read -r "?Enter the Keychain service for the account (or empty to skip): " user_input - if [[ -n "$user_input" ]] && ! _core_keychain_validate "$user_input"; then - echo "Invalid Keychain service shape. Aborting." >&2 - return 1 - fi - printf '%s' "$user_input" -} - -# Execute the rename of ~/.claude to the target dir, then move ~/.claude.json if present. -# Updates _CKIPPER_MIGRATE_STEP and _CKIPPER_MIGRATE_BACKUP module globals to track progress. -# -# Args: -# $1 — legacy_claude path -# $2 — legacy home json path -# $3 — target directory -# -# Returns: -# 0 on success; 1 on failure (rollback should be called by the caller). -# -# Errors (stderr): -# "Error: failed to rename ..." — when mv of the directory fails. -# "Error: failed to move ..." — when mv of .claude.json fails. -_ckipper_migrate_rename_dirs() { - local legacy_claude="$1" legacy_homejson="$2" target_dir="$3" - if ! mv "$legacy_claude" "$target_dir" 2>/dev/null; then - echo "Error: failed to rename $legacy_claude → $target_dir" >&2 - echo "(Check permissions on $HOME and that no process holds the directory open.)" >&2 - return 1 - fi - _CKIPPER_MIGRATE_STEP=1 - [[ ! -f "$legacy_homejson" ]] && return 0 - if [[ -f "$target_dir/.claude.json" ]]; then - _CKIPPER_MIGRATE_BACKUP="$target_dir/.claude.json.pre-migrate-backup" - mv "$target_dir/.claude.json" "$_CKIPPER_MIGRATE_BACKUP" - fi - if ! mv "$legacy_homejson" "$target_dir/.claude.json" 2>/dev/null; then - echo "Error: failed to move $legacy_homejson → $target_dir/.claude.json" >&2 - return 1 - fi - _CKIPPER_MIGRATE_STEP=2 -} - -# Print the migration success message with next steps. -# -# Args: -# $1 — registered account name (may be empty) -# -# Returns: -# 0 always. -_ckipper_migrate_print_success() { - local registered_name="$1" - cat < to add additional accounts. - -EOF - [[ -z "$registered_name" ]] && return 0 - cat < 0' "$CKIPPER_REGISTRY" >/dev/null 2>&1 && return 2 - return 0 -} - -# Perform a one-time migration from legacy ~/.claude/docker/ layout to ckipper. -# Idempotent. Refuses if Claude is running. -# -# Returns: -# 0 on success or no-op; 1 on failure or user abort. -# -# Errors (stderr): -# Various error messages for symlink, rename, and registry failures. -_ckipper_migrate() { - _core_registry_check_version || return 1 - local legacy_docker="$HOME/.claude/docker" - local legacy_claude="$HOME/.claude" - local legacy_homejson="$HOME/.claude.json" - _ckipper_migrate_preflight "$legacy_claude" "$legacy_homejson" || return 1 - _ckipper_migrate_copy_docker "$legacy_docker" - local state_rc - _ckipper_migrate_check_state "$legacy_claude" "$legacy_homejson"; state_rc=$? - if (( state_rc == 1 )); then - _ckipper_migrate_print_no_op "$legacy_claude" "$legacy_homejson"; return 0 - fi - (( state_rc == 2 )) && { _ckipper_migrate_finalize; return 0; } - local name; name=$(_ckipper_migrate_prompt_account_name) - local target_dir="$HOME/.claude-$name" - _CKIPPER_MIGRATE_CTX[name]="$name" - _CKIPPER_MIGRATE_CTX[target_dir]="$target_dir" - _CKIPPER_MIGRATE_CTX[legacy_claude]="$legacy_claude" - _CKIPPER_MIGRATE_CTX[legacy_homejson]="$legacy_homejson" - _ckipper_migrate_confirm_plan || return 1 - local probed_service - probed_service=$(_ckipper_migrate_detect_keychain) || return 1 - _CKIPPER_MIGRATE_CTX[probed_service]="$probed_service" - _ckipper_migrate_run -} - -# Undo destructive migration steps on failure or interruption. -# Reads _CKIPPER_MIGRATE_STEP, _CKIPPER_MIGRATE_BACKUP, and _CKIPPER_MIGRATE_CTX module globals. -# -# Args: -# $1 — reason label (e.g. "failed", "interrupted"); defaults to "rollback" -# -# Returns: -# 0 always. -_ckipper_migrate_rollback() { - local why="${1:-rollback}" - local name="${_CKIPPER_MIGRATE_CTX[name]}" - local target_dir="${_CKIPPER_MIGRATE_CTX[target_dir]}" - local legacy_claude="${_CKIPPER_MIGRATE_CTX[legacy_claude]}" - local legacy_homejson="${_CKIPPER_MIGRATE_CTX[legacy_homejson]}" - if (( _CKIPPER_MIGRATE_STEP >= 2 )) && [[ -f "$target_dir/.claude.json" && ! -e "$legacy_homejson" ]]; then - mv "$target_dir/.claude.json" "$legacy_homejson" 2>/dev/null - [[ -n "$_CKIPPER_MIGRATE_BACKUP" && -f "$_CKIPPER_MIGRATE_BACKUP" ]] && \ - mv "$_CKIPPER_MIGRATE_BACKUP" "$target_dir/.claude.json" 2>/dev/null - fi - if (( _CKIPPER_MIGRATE_STEP >= 1 )) && [[ -d "$target_dir" && ! -e "$legacy_claude" ]]; then - mv "$target_dir" "$legacy_claude" 2>/dev/null - echo "Migration $why — restored $legacy_claude." >&2 - fi - if [[ -f "$CKIPPER_REGISTRY" ]] && \ - jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - _core_registry_update \ - 'del(.accounts[$n]) | (if .default == $n then .default = null else . end)' \ - --arg n "$name" - echo "Cleaned partial '$name' entry from $CKIPPER_REGISTRY." >&2 - fi - _ckipper_regenerate_aliases 2>/dev/null || true -} - -# Run the destructive migration steps with rollback on failure or interruption. -# Reads all context from _CKIPPER_MIGRATE_CTX module global. -# -# Returns: -# 0 on success; 1 on failure (rollback applied). -_ckipper_migrate_run() { - _CKIPPER_MIGRATE_STEP=0 - _CKIPPER_MIGRATE_BACKUP="" - trap '_ckipper_migrate_rollback interrupted; trap - INT TERM HUP QUIT; return 130' INT TERM HUP QUIT - _ckipper_migrate_run_steps - local run_rc=$? - trap - INT TERM HUP QUIT - (( run_rc == 0 )) && _ckipper_migrate_finalize - return $run_rc -} - -# Execute the actual migration rename and register steps (called from _ckipper_migrate_run). -# Reads all context from _CKIPPER_MIGRATE_CTX module global. -# -# Returns: -# 0 on success; 1 on failure (_ckipper_migrate_rollback should be called by caller). -_ckipper_migrate_run_steps() { - local name="${_CKIPPER_MIGRATE_CTX[name]}" - local target_dir="${_CKIPPER_MIGRATE_CTX[target_dir]}" - local legacy_claude="${_CKIPPER_MIGRATE_CTX[legacy_claude]}" - local legacy_homejson="${_CKIPPER_MIGRATE_CTX[legacy_homejson]}" - local probed_service="${_CKIPPER_MIGRATE_CTX[probed_service]}" - _ckipper_migrate_rename_dirs "$legacy_claude" "$legacy_homejson" "$target_dir" || { - _ckipper_migrate_rollback failed - return 1 - } - _ckipper_rewrite_plugin_paths "$legacy_claude/" "$target_dir/" - _CKIPPER_FINALIZE_CTX[name]="$name" - _CKIPPER_FINALIZE_CTX[dir]="$target_dir" - _CKIPPER_FINALIZE_CTX[service]="$probed_service" - if ! _ckipper_finalize_registration "migrate"; then - _ckipper_migrate_rollback failed - return 1 - fi -} - -# Perform post-migration steps: clean up old Docker image and print success. -# -# Returns: -# 0 always. -_ckipper_migrate_finalize() { - if command -v docker >/dev/null 2>&1; then - docker rmi claude-dev 2>/dev/null && echo "Removed old claude-dev Docker image." - fi - local registered_name="" - if [[ -f "$CKIPPER_REGISTRY" ]]; then - registered_name=$(jq -r '.default // (.accounts | keys[0] // "")' "$CKIPPER_REGISTRY") - fi - _ckipper_migrate_print_success "$registered_name" -} - diff --git a/lib/ckipper/migrate_test.bats b/lib/ckipper/migrate_test.bats deleted file mode 100644 index a959e25..0000000 --- a/lib/ckipper/migrate_test.bats +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env bats -# Unit tests for lib/ckipper/migrate.zsh helpers. -# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). - -load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" - -setup() { - setup_isolated_env -} - -teardown() { - teardown_isolated_env -} - -# Helper: run a zsh expression with the full ckipper environment sourced. -run_helper() { - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="linux" \ - CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" -} - -# Helper: run a zsh expression with stdin input piped in (for interactive prompts). -# The expression should `printf` or `echo` the result to stdout — bats captures it. -# Extra env vars (like _CKIPPER_TEST_OSTYPE override) can be passed via $extra_env. -run_helper_with_input() { - local expression="$1" - local stdin_input="$2" - local extra_env="${3:-}" - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE_OVERRIDE:-linux}" \ - CKIPPER_FORCE=1 \ - $extra_env \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $expression" <<< "$stdin_input" -} - -# ── _ckipper_migrate_check_state ────────────────────────────────────── - -@test "check_state returns 1 (no state) when neither legacy dir nor home json exist" { - # Fresh TMP_HOME has no .claude or .claude.json. - run_helper '_ckipper_migrate_check_state "$HOME/.claude" "$HOME/.claude.json"; echo "rc=$?"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "rc=1" ]] -} - -@test "check_state returns 0 (needs migration) when home-root .claude.json exists" { - echo '{}' > "$TMP_HOME/.claude.json" - - run_helper '_ckipper_migrate_check_state "$HOME/.claude" "$HOME/.claude.json"; echo "rc=$?"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "rc=0" ]] -} - -@test "check_state returns 2 (already migrated) when registry has accounts and no legacy state" { - echo '{"version":1,"default":"personal","accounts":{"personal":{"config_dir":"/tmp/.claude-personal","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - echo '{}' > "$TMP_HOME/.claude.json" - - run_helper '_ckipper_migrate_check_state "$HOME/.claude" "$HOME/.claude.json"; echo "rc=$?"' - - [ "$status" -eq 0 ] - [[ "$output" =~ "rc=2" ]] -} - -# ── _ckipper_migrate_preflight ──────────────────────────────────────── - -@test "preflight passes when neither legacy path is a symlink" { - mkdir -p "$TMP_HOME/.claude" - echo '{}' > "$TMP_HOME/.claude.json" - - run_helper '_ckipper_migrate_preflight "$HOME/.claude" "$HOME/.claude.json"' - - [ "$status" -eq 0 ] -} - -@test "preflight fails when legacy claude dir is a symlink" { - mkdir -p "$TMP_HOME/.claude-target" - ln -s "$TMP_HOME/.claude-target" "$TMP_HOME/.claude" - - run_helper '_ckipper_migrate_preflight "$HOME/.claude" "$HOME/.claude.json"' - - [ "$status" -ne 0 ] - [[ "$output" =~ "symlink" ]] -} - -# ── _ckipper_migrate_rename_dirs ────────────────────────────────────── - -@test "rename_dirs moves legacy claude dir to the target directory" { - mkdir -p "$TMP_HOME/.claude" - local target_dir="$TMP_HOME/.claude-personal" - - run_helper "_ckipper_migrate_rename_dirs \"$TMP_HOME/.claude\" \"$TMP_HOME/.claude.json\" \"$target_dir\"" - - [ "$status" -eq 0 ] - [ -d "$target_dir" ] - [ ! -d "$TMP_HOME/.claude" ] -} - -@test "rename_dirs also moves home-root .claude.json into the target dir" { - mkdir -p "$TMP_HOME/.claude" - echo '{"projects":[]}' > "$TMP_HOME/.claude.json" - local target_dir="$TMP_HOME/.claude-personal" - - run_helper "_ckipper_migrate_rename_dirs \"$TMP_HOME/.claude\" \"$TMP_HOME/.claude.json\" \"$target_dir\"" - - [ "$status" -eq 0 ] - [ -f "$target_dir/.claude.json" ] - [ ! -f "$TMP_HOME/.claude.json" ] -} - -# ── _ckipper_migrate_rollback ───────────────────────────────────────── - -@test "rollback restores the target dir to the legacy path when step >= 1" { - # Simulate a mid-migration state: target dir was created, no .claude exists. - local target_dir="$TMP_HOME/.claude-personal" - local legacy_dir="$TMP_HOME/.claude" - mkdir -p "$target_dir" - - run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" - typeset -gA _CKIPPER_MIGRATE_CTX - _CKIPPER_MIGRATE_CTX[name]=\"personal\" - _CKIPPER_MIGRATE_CTX[target_dir]=\"$target_dir\" - _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$legacy_dir\" - _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" - _ckipper_migrate_rollback \"failed\"" - - [ "$status" -eq 0 ] - # The target dir should be moved back to the legacy location. - [ -d "$legacy_dir" ] - [ ! -d "$target_dir" ] -} - -@test "rollback is idempotent — running twice on already-restored state is safe" { - local target_dir="$TMP_HOME/.claude-personal" - local legacy_dir="$TMP_HOME/.claude" - mkdir -p "$target_dir" - - # First rollback — restores legacy dir. - run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" - typeset -gA _CKIPPER_MIGRATE_CTX - _CKIPPER_MIGRATE_CTX[name]=\"personal\" - _CKIPPER_MIGRATE_CTX[target_dir]=\"$target_dir\" - _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$legacy_dir\" - _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" - _ckipper_migrate_rollback \"failed\"" - [ "$status" -eq 0 ] - [ -d "$legacy_dir" ] - - # Second rollback — target_dir no longer exists, so no move happens; legacy_dir stays. - run_helper "_CKIPPER_MIGRATE_STEP=1; _CKIPPER_MIGRATE_BACKUP=\"\" - typeset -gA _CKIPPER_MIGRATE_CTX - _CKIPPER_MIGRATE_CTX[name]=\"personal\" - _CKIPPER_MIGRATE_CTX[target_dir]=\"$target_dir\" - _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$legacy_dir\" - _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" - _ckipper_migrate_rollback \"failed\"" - [ "$status" -eq 0 ] - [ -d "$legacy_dir" ] -} - -# ── _ckipper_migrate_prompt_account_name ────────────────────────────── -# These tests verify that the captured stdout is ONLY the chosen name — -# regression guard for the "stdout pollution on retry" bug where validation -# error messages would leak into the captured value, corrupting the registry. - -@test "prompt_account_name accepts the default on empty input" { - run_helper_with_input \ - 'name=$(_ckipper_migrate_prompt_account_name); printf "RESULT=[%s]" "$name"' \ - $'\n' - - [ "$status" -eq 0 ] - [[ "$output" == *"RESULT=[personal]"* ]] -} - -@test "prompt_account_name returns ONLY the valid name after a name-collision retry" { - # First pick collides (~/.claude-personal exists); user retries with a different name. - mkdir -p "$TMP_HOME/.claude-personal" - - run_helper_with_input \ - 'name=$(_ckipper_migrate_prompt_account_name); printf "RESULT=[%s]" "$name"' \ - $'personal\nwork\n' - - [ "$status" -eq 0 ] - # The captured value must be exactly "work" — not "\nwork". - [[ "$output" == *"RESULT=[work]"* ]] - [[ "$output" != *"RESULT=[already exists"* ]] - [[ "$output" != *"RESULT=[$TMP_HOME"* ]] -} - -@test "prompt_account_name returns ONLY the valid name after an invalid-format retry" { - run_helper_with_input \ - 'name=$(_ckipper_migrate_prompt_account_name); printf "RESULT=[%s]" "$name"' \ - $'BAD NAME!\nwork\n' - - [ "$status" -eq 0 ] - # The captured value must be exactly "work" — not "Account name must match...\nwork". - [[ "$output" == *"RESULT=[work]"* ]] - [[ "$output" != *"RESULT=[Account name"* ]] -} - -# ── _ckipper_migrate_detect_keychain ────────────────────────────────── - -@test "detect_keychain returns empty string on non-darwin platforms" { - run_helper '_ckipper_migrate_detect_keychain; echo "RESULT=[$?]"' - - [ "$status" -eq 0 ] - [[ "$output" == "RESULT=[0]" ]] -} - -@test "detect_keychain returns ONLY the user input from the fallback prompt path" { - # Simulate macOS but with no matching Keychain entry — exercises the fallback prompt. - # The user types "MyCustomService". - - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="darwin25.0.0" \ - SECURITY_STUB_FAIL_FIND=1 \ - CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; svc=\$(_ckipper_migrate_detect_keychain); printf 'RESULT=[%s]' \"\$svc\"" \ - <<< $'MyCustomService\n' - - [ "$status" -eq 0 ] - # Captured value must be exactly "MyCustomService" — not "Warning: ...\nMyCustomService". - [[ "$output" == *"RESULT=[MyCustomService]"* ]] - [[ "$output" != *"RESULT=[Warning"* ]] - [[ "$output" != *"RESULT=[Listing"* ]] -} - -# ── _ckipper_migrate_print_plan ─────────────────────────────────────── - -@test "print_plan emits the migration plan from the migration context" { - run_helper "typeset -gA _CKIPPER_MIGRATE_CTX - _CKIPPER_MIGRATE_CTX[legacy_claude]=\"$TMP_HOME/.claude\" - _CKIPPER_MIGRATE_CTX[legacy_homejson]=\"$TMP_HOME/.claude.json\" - _CKIPPER_MIGRATE_CTX[target_dir]=\"$TMP_HOME/.claude-personal\" - _CKIPPER_MIGRATE_CTX[name]=\"personal\" - _ckipper_migrate_print_plan" - - [ "$status" -eq 0 ] - [[ "$output" == *"This migration will:"* ]] - [[ "$output" == *"Rename $TMP_HOME/.claude → $TMP_HOME/.claude-personal."* ]] - [[ "$output" == *"Register 'personal'"* ]] -} diff --git a/tests/fixtures/legacy-claude-layout/.claude.json b/tests/fixtures/legacy-claude-layout/.claude.json deleted file mode 100644 index 0967ef4..0000000 --- a/tests/fixtures/legacy-claude-layout/.claude.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh b/tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh deleted file mode 100644 index 81d13f9..0000000 --- a/tests/fixtures/legacy-claude-layout/.claude/docker/w-config.zsh +++ /dev/null @@ -1 +0,0 @@ -W_PORTS=(3000 3030) From 46554c0ee3f3f24db5547288f2e9f0ca31ac6080 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 22:53:37 -0600 Subject: [PATCH 073/165] Rename lib/ckipper/ to lib/account/ --- .claude/CLAUDE.md | 4 ++-- .claude/rules/shell-conventions.md | 4 ++-- ckipper.zsh | 12 ++++++------ install_test.bats | 2 +- lib/{ckipper => account}/account-management.zsh | 0 .../account-management_test.bats | 4 ++-- lib/{ckipper => account}/aliases.zsh | 0 lib/{ckipper => account}/aliases_test.bats | 4 ++-- lib/{ckipper => account}/doctor.zsh | 0 lib/{ckipper => account}/doctor_test.bats | 4 ++-- lib/{ckipper => account}/plugin-repair.zsh | 0 lib/{ckipper => account}/plugin-repair_test.bats | 4 ++-- lib/{ckipper => account}/sync.zsh | 0 lib/{ckipper => account}/sync_test.bats | 4 ++-- 14 files changed, 21 insertions(+), 21 deletions(-) rename lib/{ckipper => account}/account-management.zsh (100%) rename lib/{ckipper => account}/account-management_test.bats (97%) rename lib/{ckipper => account}/aliases.zsh (100%) rename lib/{ckipper => account}/aliases_test.bats (97%) rename lib/{ckipper => account}/doctor.zsh (100%) rename lib/{ckipper => account}/doctor_test.bats (96%) rename lib/{ckipper => account}/plugin-repair.zsh (100%) rename lib/{ckipper => account}/plugin-repair_test.bats (96%) rename lib/{ckipper => account}/sync.zsh (100%) rename lib/{ckipper => account}/sync_test.bats (98%) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c3e4dba..9865a57 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -38,10 +38,10 @@ Ckipper is a zsh-based wrapper for the [Claude Code CLI](https://claude.ai/cli) **Top-level layout:** ``` -ckipper.zsh # ckipper CLI entry (account add/remove/sync/doctor/migrate) +ckipper.zsh # ckipper CLI entry (account add/remove/sync/doctor) w-function.zsh # w() launcher entry (sourced from .zshrc) lib/core/ # shared primitives (registry, keychain, utils) -lib/ckipper/ # ckipper-specific subcommands +lib/account/ # account-management subcommands lib/w/ # w-specific helpers hooks/ # Claude Code safety hooks docker/ # Dockerfile + entrypoint + firewall + cleanup diff --git a/.claude/rules/shell-conventions.md b/.claude/rules/shell-conventions.md index 32bc9a0..d6fbc7d 100644 --- a/.claude/rules/shell-conventions.md +++ b/.claude/rules/shell-conventions.md @@ -25,7 +25,7 @@ Omit `Args:` if the function takes none. Omit `Errors:` if it never writes to st Used to encode the dependency direction at a glance and let CI verify it: - `_core_*` — `lib/core/` (shared primitives) -- `_ckipper_*` — `lib/ckipper/` (ckipper subcommands) +- `_ckipper_*` — `lib/account/` (account subcommands; will be renamed `_ckipper_account_*` in a follow-up phase) - `_w_*` — `lib/w/` (w() helpers) - No prefix — public, callable from `.zshrc`: `ckipper`, `ck`, `w` @@ -35,6 +35,6 @@ zsh has no native bool. Use string values `"true"`/`"false"` and test with `[[ " ## Module sourcing -Modules under `lib/` are sourced once by an entry script (`ckipper.zsh` or `w-function.zsh`). Modules MUST NOT source siblings. Cross-feature imports between `lib/w/` and `lib/ckipper/` are forbidden — extract shared code to `lib/core/` (per `file-organization.md`'s shared-parent rule). +Modules under `lib/` are sourced once by an entry script (`ckipper.zsh` or `w-function.zsh`). Modules MUST NOT source siblings. Cross-feature imports between `lib/w/` and `lib/account/` are forbidden — extract shared code to `lib/core/` (per `file-organization.md`'s shared-parent rule). CI enforces this with `grep -rE '\b_ckipper_' lib/w/`. diff --git a/ckipper.zsh b/ckipper.zsh index 3c4549c..0fe91a3 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -1,6 +1,6 @@ #!/usr/bin/env zsh # Ckipper main dispatcher. -# Sources shared primitives from lib/core/ and ckipper-specific subcommands from lib/ckipper/. +# Sources shared primitives from lib/core/ and account-management subcommands from lib/account/. # Public functions exposed: ckipper, ck. # Ckipper (pronounced "skipper") — multi-account Claude Code manager @@ -15,11 +15,11 @@ CKIPPER_REPO_DIR="${0:A:h}" source "$CKIPPER_REPO_DIR/lib/core/utils.zsh" source "$CKIPPER_REPO_DIR/lib/core/registry.zsh" source "$CKIPPER_REPO_DIR/lib/core/keychain.zsh" -source "$CKIPPER_REPO_DIR/lib/ckipper/account-management.zsh" -source "$CKIPPER_REPO_DIR/lib/ckipper/aliases.zsh" -source "$CKIPPER_REPO_DIR/lib/ckipper/plugin-repair.zsh" -source "$CKIPPER_REPO_DIR/lib/ckipper/sync.zsh" -source "$CKIPPER_REPO_DIR/lib/ckipper/doctor.zsh" +source "$CKIPPER_REPO_DIR/lib/account/account-management.zsh" +source "$CKIPPER_REPO_DIR/lib/account/aliases.zsh" +source "$CKIPPER_REPO_DIR/lib/account/plugin-repair.zsh" +source "$CKIPPER_REPO_DIR/lib/account/sync.zsh" +source "$CKIPPER_REPO_DIR/lib/account/doctor.zsh" # Dispatch a ckipper subcommand or print top-level help. # diff --git a/install_test.bats b/install_test.bats index f1663c8..162ae08 100644 --- a/install_test.bats +++ b/install_test.bats @@ -16,7 +16,7 @@ teardown() { [ "$status" -eq 0 ] [ -d "$TMP_HOME/.ckipper/docker/lib/core" ] - [ -d "$TMP_HOME/.ckipper/docker/lib/ckipper" ] + [ -d "$TMP_HOME/.ckipper/docker/lib/account" ] [ -d "$TMP_HOME/.ckipper/docker/lib/w" ] [ -f "$TMP_HOME/.ckipper/docker/ckipper.zsh" ] [ -f "$TMP_HOME/.ckipper/docker/w-function.zsh" ] diff --git a/lib/ckipper/account-management.zsh b/lib/account/account-management.zsh similarity index 100% rename from lib/ckipper/account-management.zsh rename to lib/account/account-management.zsh diff --git a/lib/ckipper/account-management_test.bats b/lib/account/account-management_test.bats similarity index 97% rename from lib/ckipper/account-management_test.bats rename to lib/account/account-management_test.bats index 2c28f7b..6053e2c 100644 --- a/lib/ckipper/account-management_test.bats +++ b/lib/account/account-management_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Unit tests for lib/ckipper/account-management.zsh helpers. -# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). +# Unit tests for lib/account/account-management.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/account/ modules). load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" diff --git a/lib/ckipper/aliases.zsh b/lib/account/aliases.zsh similarity index 100% rename from lib/ckipper/aliases.zsh rename to lib/account/aliases.zsh diff --git a/lib/ckipper/aliases_test.bats b/lib/account/aliases_test.bats similarity index 97% rename from lib/ckipper/aliases_test.bats rename to lib/account/aliases_test.bats index 6405eaa..f38bc9d 100644 --- a/lib/ckipper/aliases_test.bats +++ b/lib/account/aliases_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Unit tests for lib/ckipper/aliases.zsh helpers. -# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). +# Unit tests for lib/account/aliases.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/account/ modules). load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" diff --git a/lib/ckipper/doctor.zsh b/lib/account/doctor.zsh similarity index 100% rename from lib/ckipper/doctor.zsh rename to lib/account/doctor.zsh diff --git a/lib/ckipper/doctor_test.bats b/lib/account/doctor_test.bats similarity index 96% rename from lib/ckipper/doctor_test.bats rename to lib/account/doctor_test.bats index e0db4c7..09da915 100644 --- a/lib/ckipper/doctor_test.bats +++ b/lib/account/doctor_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Unit tests for lib/ckipper/doctor.zsh helpers. -# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). +# Unit tests for lib/account/doctor.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/account/ modules). load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" diff --git a/lib/ckipper/plugin-repair.zsh b/lib/account/plugin-repair.zsh similarity index 100% rename from lib/ckipper/plugin-repair.zsh rename to lib/account/plugin-repair.zsh diff --git a/lib/ckipper/plugin-repair_test.bats b/lib/account/plugin-repair_test.bats similarity index 96% rename from lib/ckipper/plugin-repair_test.bats rename to lib/account/plugin-repair_test.bats index 1f2218a..3faa61d 100644 --- a/lib/ckipper/plugin-repair_test.bats +++ b/lib/account/plugin-repair_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Unit tests for lib/ckipper/plugin-repair.zsh helpers. -# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). +# Unit tests for lib/account/plugin-repair.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/account/ modules). load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" diff --git a/lib/ckipper/sync.zsh b/lib/account/sync.zsh similarity index 100% rename from lib/ckipper/sync.zsh rename to lib/account/sync.zsh diff --git a/lib/ckipper/sync_test.bats b/lib/account/sync_test.bats similarity index 98% rename from lib/ckipper/sync_test.bats rename to lib/account/sync_test.bats index 85a8087..629893c 100644 --- a/lib/ckipper/sync_test.bats +++ b/lib/account/sync_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Unit tests for lib/ckipper/sync.zsh helpers. -# Sources ckipper.zsh (which wires up all lib/core/ + lib/ckipper/ modules). +# Unit tests for lib/account/sync.zsh helpers. +# Sources ckipper.zsh (which wires up all lib/core/ + lib/account/ modules). load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" From 71610a156d58520a5baf708166a468c2e53ac190 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:00:14 -0600 Subject: [PATCH 074/165] Rename _ckipper_* (account ops) to _ckipper_account_* --- ckipper.zsh | 11 ++- ckipper_test.bats | 8 +-- lib/account/account-management.zsh | 88 ++++++++++++------------ lib/account/account-management_test.bats | 48 ++++++------- lib/account/aliases.zsh | 24 +++---- lib/account/aliases_test.bats | 22 +++--- lib/account/plugin-repair.zsh | 18 ++--- lib/account/plugin-repair_test.bats | 12 ++-- lib/account/sync.zsh | 28 ++++---- lib/account/sync_test.bats | 30 ++++---- 10 files changed, 148 insertions(+), 141 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index 0fe91a3..3293782 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -38,12 +38,19 @@ ckipper() { shift 2>/dev/null case "$cmd" in # --help on any subcommand short-circuits to subcommand help - add|list|default|remove|rename|sync|sync-hooks|doctor|repair-plugins) + add|list|default|remove|rename|sync|sync-hooks|repair-plugins) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_help_for "$cmd" return 0 fi - "_ckipper_${cmd//-/_}" "$@" + "_ckipper_account_${cmd//-/_}" "$@" + ;; + doctor) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_help_for "$cmd" + return 0 + fi + _ckipper_doctor "$@" ;; ""|help|-h|--help) _ckipper_help ;; *) echo "Unknown command: $cmd"; _ckipper_help; return 1 ;; diff --git a/ckipper_test.bats b/ckipper_test.bats index 4ef03ad..ca483fa 100644 --- a/ckipper_test.bats +++ b/ckipper_test.bats @@ -43,7 +43,7 @@ teardown() { [ "$status" -eq 0 ] } -# ── _ckipper_sync ──────────────────────────────────────────────────── +# ── _ckipper_account_sync ──────────────────────────────────────────────────── @test "ckipper sync --help prints usage and exits 0" { run_ckipper sync --help @@ -65,7 +65,7 @@ teardown() { [[ "$output" =~ "differ" ]] } -# ── _ckipper_add ──────────────────────────────────────────────────── +# ── _ckipper_account_add ──────────────────────────────────────────────────── @test "ckipper add rejects names containing spaces (invalid regex)" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" @@ -109,7 +109,7 @@ teardown() { [[ "$output" =~ [Uu]sage ]] } -# ── _ckipper_list ──────────────────────────────────────────────────── +# ── _ckipper_account_list ──────────────────────────────────────────────────── @test "ckipper list shows registered accounts" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":"Claude Code-credentials-work"}}}' > "$CKIPPER_REGISTRY" @@ -125,7 +125,7 @@ teardown() { [[ "$output" =~ "No accounts" || "$output" =~ "no accounts" ]] } -# ── _ckipper_remove ────────────────────────────────────────────────── +# ── _ckipper_account_remove ────────────────────────────────────────────────── @test "ckipper remove unregisters a known account and exits 0" { echo '{"version":1,"default":null,"accounts":{"tmp":{"config_dir":"/tmp/.claude-tmp","keychain_service":"Claude Code-credentials-tmp"}}}' > "$CKIPPER_REGISTRY" diff --git a/lib/account/account-management.zsh b/lib/account/account-management.zsh index ae881af..0b5e449 100644 --- a/lib/account/account-management.zsh +++ b/lib/account/account-management.zsh @@ -2,12 +2,12 @@ # Account lifecycle subcommands: add, finalize_registration, remove, rename, list, default, bare_alias_safe. # Module-level context for the in-progress registration. -# Populated by callers before invoking _ckipper_finalize_registration. +# Populated by callers before invoking _ckipper_account_finalize_registration. # Fields: name, dir, service typeset -gA _CKIPPER_FINALIZE_CTX # Module-level context for the in-progress rename. -# Populated by _ckipper_rename before invoking _ckipper_rename_perform. +# Populated by _ckipper_account_rename before invoking _ckipper_account_rename_perform. # Fields: old_dir, new_dir typeset -gA _CKIPPER_RENAME_CTX @@ -20,7 +20,7 @@ typeset -gA _CKIPPER_RENAME_CTX # # Returns: # 0 if valid; 1 on empty name, invalid name format, or already registered. -_ckipper_add_validate_name() { +_ckipper_account_add_validate_name() { local name="$1" if [[ -z "$name" ]]; then echo "Usage: ckipper add [--adopt]" @@ -44,7 +44,7 @@ _ckipper_add_validate_name() { # # Returns: # 0 on success; 1 on validation or registration failure. -_ckipper_add_adopt_flow() { +_ckipper_account_add_adopt_flow() { local name="$1" dir="$2" if [[ ! -d "$dir" ]]; then echo "Cannot adopt: $dir does not exist." @@ -52,12 +52,12 @@ _ckipper_add_adopt_flow() { fi local picked="" if [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]]; then - _ckipper_add_pick_keychain_entry "$name" picked || return 1 + _ckipper_account_add_pick_keychain_entry "$name" picked || return 1 fi _CKIPPER_FINALIZE_CTX[name]="$name" _CKIPPER_FINALIZE_CTX[dir]="$dir" _CKIPPER_FINALIZE_CTX[service]="$picked" - _ckipper_finalize_registration "adopt" + _ckipper_account_finalize_registration "adopt" } # Prompt the user to pick a Keychain entry from the available candidates. @@ -69,7 +69,7 @@ _ckipper_add_adopt_flow() { # # Returns: # 0 on success; 1 on keychain error or invalid service shape. -_ckipper_add_pick_keychain_entry() { +_ckipper_account_add_pick_keychain_entry() { local name="$1" local -n _picked_ref="$2" local candidates @@ -95,7 +95,7 @@ _ckipper_add_pick_keychain_entry() { # # Returns: # 0 on success; 1 on abort or credential detection failure. -_ckipper_add_fresh_flow() { +_ckipper_account_add_fresh_flow() { local name="$1" dir="$2" if [[ -d "$dir" ]]; then echo "Directory $dir already exists. Use --adopt to register it." @@ -105,21 +105,21 @@ _ckipper_add_fresh_flow() { if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json" fi - _ckipper_sync_hooks_for "$name" "$dir" + _ckipper_account_sync_hooks_for "$name" "$dir" local before_snapshot before_snapshot=$(_core_keychain_snapshot) || return 1 - _ckipper_add_launch_claude "$name" "$dir" || return 1 + _ckipper_account_add_launch_claude "$name" "$dir" || return 1 local after_snapshot after_snapshot=$(_core_keychain_snapshot) || return 1 local new_service new_service=$(comm -13 \ <(printf '%s\n' "$before_snapshot") \ <(printf '%s\n' "$after_snapshot") | head -1) - _ckipper_add_check_credentials "$name" "$dir" "$new_service" || return 1 + _ckipper_account_add_check_credentials "$name" "$dir" "$new_service" || return 1 _CKIPPER_FINALIZE_CTX[name]="$name" _CKIPPER_FINALIZE_CTX[dir]="$dir" _CKIPPER_FINALIZE_CTX[service]="$new_service" - _ckipper_finalize_registration "fresh" + _ckipper_account_finalize_registration "fresh" } # Display the fresh-add instructions, prompt for confirmation, and launch Claude. @@ -130,7 +130,7 @@ _ckipper_add_fresh_flow() { # # Returns: # 0 after Claude exits; 1 if user chose to skip. -_ckipper_add_launch_claude() { +_ckipper_account_add_launch_claude() { local name="$1" dir="$2" cat </dev/null 2>&1; then echo "Error: account '$name' already exists in registry (race detected)." >&2 @@ -287,7 +287,7 @@ _ckipper_finalize_diagnose_error() { # # Returns: # 0 if safe to use; 1 if it would shadow an existing command/builtin/alias/reserved word. -_ckipper_bare_alias_safe() { +_ckipper_account_bare_alias_safe() { local n="$1" (( ${+commands[$n]} || ${+builtins[$n]} || ${+aliases[$n]} )) && return 1 local what; what=$(whence -w "$n" 2>/dev/null | awk '{print $2}') @@ -299,7 +299,7 @@ _ckipper_bare_alias_safe() { # # Returns: # 0 always. -_ckipper_list() { +_ckipper_account_list() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then echo "No accounts registered. Run: ckipper add " return 0 @@ -309,7 +309,7 @@ _ckipper_list() { echo "Registered accounts:" jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ while IFS=$'\t' read -r name dir; do - _ckipper_list_account_line "$name" "$dir" "$default" + _ckipper_account_list_account_line "$name" "$dir" "$default" done echo "" echo "* = default. Run: ckipper default " @@ -327,7 +327,7 @@ _ckipper_list() { # # Returns: # 0 always. -_ckipper_list_account_line() { +_ckipper_account_list_account_line() { local name="$1" dir="$2" default="$3" local marker=" " [[ "$name" == "$default" ]] && marker="* " @@ -347,7 +347,7 @@ _ckipper_list_account_line() { # # Returns: # 0 on success; 1 if account is not registered. -_ckipper_default() { +_ckipper_account_default() { _core_registry_check_version || return 1 local name="$1" [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } @@ -366,7 +366,7 @@ _ckipper_default() { # # Returns: # 0 on success; 1 if account is not registered. -_ckipper_remove() { +_ckipper_account_remove() { _core_registry_check_version || return 1 local name="$1" [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } @@ -380,7 +380,7 @@ _ckipper_remove() { # Drop the now-stale launcher functions from the calling shell. unset -f "claude-$name" 2>/dev/null unset -f "$name" 2>/dev/null - _ckipper_regenerate_aliases + _ckipper_account_regenerate_aliases echo "Unregistered '$name'." echo "" echo "The directory and Keychain entry were not deleted. To remove them manually:" @@ -398,7 +398,7 @@ _ckipper_remove() { # # Returns: # 0 if valid; 1 on any validation failure. -_ckipper_rename_validate() { +_ckipper_account_rename_validate() { local old="$1" new="$2" if [[ -z "$old" || -z "$new" ]]; then echo "Usage: ckipper rename " @@ -439,7 +439,7 @@ _ckipper_rename_validate() { # $2 — old directory path (must be a directory) # # Returns: 0 if preconditions pass; 1 if any check fails. -_ckipper_rename_check_preconditions() { +_ckipper_account_rename_check_preconditions() { local new_dir="$1" old_dir="$2" if [[ -e "$new_dir" ]]; then echo "Error: $new_dir already exists. Pick a different name or remove it first." @@ -452,11 +452,11 @@ _ckipper_rename_check_preconditions() { _core_assert_no_running_claude || return 1 } -_ckipper_rename_perform() { +_ckipper_account_rename_perform() { local old="$1" new="$2" local old_dir="${_CKIPPER_RENAME_CTX[old_dir]}" local new_dir="${_CKIPPER_RENAME_CTX[new_dir]}" - _ckipper_rename_check_preconditions "$new_dir" "$old_dir" || return 1 + _ckipper_account_rename_check_preconditions "$new_dir" "$old_dir" || return 1 if ! mv "$old_dir" "$new_dir" 2>/dev/null; then echo "Error: failed to rename $old_dir → $new_dir." >&2 return 1 @@ -481,24 +481,24 @@ _ckipper_rename_perform() { # # Returns: # 0 on success; 1 on validation or rename failure. -_ckipper_rename() { +_ckipper_account_rename() { _core_registry_check_version || return 1 local old="$1" new="$2" - _ckipper_rename_validate "$old" "$new" || return 1 + _ckipper_account_rename_validate "$old" "$new" || return 1 local old_dir new_dir old_dir=$(jq -r --arg n "$old" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY") new_dir="$HOME/.claude-$new" _CKIPPER_RENAME_CTX[old_dir]="$old_dir" _CKIPPER_RENAME_CTX[new_dir]="$new_dir" - _ckipper_rename_perform "$old" "$new" || return 1 + _ckipper_account_rename_perform "$old" "$new" || return 1 # Drop old-name launcher functions from the calling shell. unset -f "claude-$old" 2>/dev/null unset -f "$old" 2>/dev/null - _ckipper_regenerate_aliases - _ckipper_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to new dir + _ckipper_account_regenerate_aliases + _ckipper_account_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to new dir echo "Renamed '$old' → '$new'." echo "Directory: $old_dir → $new_dir" - if _ckipper_bare_alias_safe "$new"; then + if _ckipper_account_bare_alias_safe "$new"; then echo "Use: claude-$new (or just: $new)" else echo "Use: claude-$new" diff --git a/lib/account/account-management_test.bats b/lib/account/account-management_test.bats index 6053e2c..0afcc11 100644 --- a/lib/account/account-management_test.bats +++ b/lib/account/account-management_test.bats @@ -24,12 +24,12 @@ run_helper() { zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" } -# ── _ckipper_add_validate_name ─────────────────────────────────────── +# ── _ckipper_account_add_validate_name ─────────────────────────────────────── @test "validate_name accepts valid lowercase-alphanumeric names" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_add_validate_name "myaccount"' + run_helper '_ckipper_account_add_validate_name "myaccount"' [ "$status" -eq 0 ] } @@ -37,7 +37,7 @@ run_helper() { @test "validate_name accepts names with hyphens and underscores" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_add_validate_name "my-account_1"' + run_helper '_ckipper_account_add_validate_name "my-account_1"' [ "$status" -eq 0 ] } @@ -45,7 +45,7 @@ run_helper() { @test "validate_name rejects an empty name" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_add_validate_name ""' + run_helper '_ckipper_account_add_validate_name ""' [ "$status" -ne 0 ] [[ "$output" =~ [Uu]sage ]] @@ -54,7 +54,7 @@ run_helper() { @test "validate_name rejects names with uppercase letters" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_add_validate_name "MyAccount"' + run_helper '_ckipper_account_add_validate_name "MyAccount"' [ "$status" -ne 0 ] [[ "$output" =~ "must match" ]] @@ -63,7 +63,7 @@ run_helper() { @test "validate_name rejects names with spaces" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_add_validate_name "my account"' + run_helper '_ckipper_account_add_validate_name "my account"' [ "$status" -ne 0 ] [[ "$output" =~ "must match" ]] @@ -72,34 +72,34 @@ run_helper() { @test "validate_name rejects a name already registered" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_add_validate_name "work"' + run_helper '_ckipper_account_add_validate_name "work"' [ "$status" -ne 0 ] [[ "$output" =~ "already registered" ]] } -# ── _ckipper_bare_alias_safe ───────────────────────────────────────── +# ── _ckipper_account_bare_alias_safe ───────────────────────────────────────── @test "bare_alias_safe returns 1 for shell builtin 'cd'" { # 'cd' is a zsh builtin — using it as a bare alias would shadow it. - run_helper '_ckipper_bare_alias_safe "cd" && echo SAFE || echo UNSAFE' + run_helper '_ckipper_account_bare_alias_safe "cd" && echo SAFE || echo UNSAFE' [[ "$output" =~ "UNSAFE" ]] } @test "bare_alias_safe returns 0 for an invented name that cannot shadow anything" { # A random name with no PATH binary, no builtin, no alias. - run_helper '_ckipper_bare_alias_safe "xyzzy_no_clash_9q7" && echo SAFE || echo UNSAFE' + run_helper '_ckipper_account_bare_alias_safe "xyzzy_no_clash_9q7" && echo SAFE || echo UNSAFE' [[ "$output" =~ "SAFE" ]] } -# ── _ckipper_list ──────────────────────────────────────────────────── +# ── _ckipper_account_list ──────────────────────────────────────────────────── @test "list shows 'No accounts' message when registry is missing" { rm -f "$CKIPPER_REGISTRY" - run_helper '_ckipper_list' + run_helper '_ckipper_account_list' [ "$status" -eq 0 ] [[ "$output" =~ "No accounts" ]] @@ -108,7 +108,7 @@ run_helper() { @test "list shows registered account name" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_list' + run_helper '_ckipper_account_list' [ "$status" -eq 0 ] [[ "$output" =~ "work" ]] @@ -117,18 +117,18 @@ run_helper() { @test "list marks the default account with an asterisk" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_list' + run_helper '_ckipper_account_list' [ "$status" -eq 0 ] [[ "$output" =~ "* work" ]] } -# ── _ckipper_default ────────────────────────────────────────────────── +# ── _ckipper_account_default ────────────────────────────────────────────────── @test "default sets the default account in the registry" { echo '{"version":1,"default":null,"accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_default "work"' + run_helper '_ckipper_account_default "work"' [ "$status" -eq 0 ] [[ "$output" =~ "work" ]] @@ -139,18 +139,18 @@ run_helper() { @test "default fails when account is not registered" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_default "nobody"' + run_helper '_ckipper_account_default "nobody"' [ "$status" -ne 0 ] [[ "$output" =~ "not registered" ]] } -# ── _ckipper_remove ─────────────────────────────────────────────────── +# ── _ckipper_account_remove ─────────────────────────────────────────────────── @test "remove unregisters a known account and exits 0" { echo '{"version":1,"default":null,"accounts":{"tmp":{"config_dir":"/tmp/.claude-tmp","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_remove "tmp"' + run_helper '_ckipper_account_remove "tmp"' [ "$status" -eq 0 ] [[ "$output" =~ "Unregistered" ]] @@ -159,18 +159,18 @@ run_helper() { @test "remove fails for an account that is not registered" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_remove "nobody"' + run_helper '_ckipper_account_remove "nobody"' [ "$status" -ne 0 ] [[ "$output" =~ "not registered" ]] } -# ── _ckipper_rename_validate ────────────────────────────────────────── +# ── _ckipper_account_rename_validate ────────────────────────────────────────── @test "rename_validate rejects an empty old name" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_rename_validate "" "newname"' + run_helper '_ckipper_account_rename_validate "" "newname"' [ "$status" -ne 0 ] [[ "$output" =~ [Uu]sage ]] @@ -179,7 +179,7 @@ run_helper() { @test "rename_validate rejects a new name with uppercase letters" { echo '{"version":1,"default":null,"accounts":{"old":{"config_dir":"/tmp/.claude-old","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_rename_validate "old" "NewName"' + run_helper '_ckipper_account_rename_validate "old" "NewName"' [ "$status" -ne 0 ] [[ "$output" =~ "must match" ]] @@ -188,7 +188,7 @@ run_helper() { @test "rename_validate rejects rename when old name is not registered" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_rename_validate "ghost" "newname"' + run_helper '_ckipper_account_rename_validate "ghost" "newname"' [ "$status" -ne 0 ] [[ "$output" =~ "not registered" ]] diff --git a/lib/account/aliases.zsh b/lib/account/aliases.zsh index 87efa84..8bf3853 100644 --- a/lib/account/aliases.zsh +++ b/lib/account/aliases.zsh @@ -12,13 +12,13 @@ readonly ALIASES_FILE_PERMS=644 # # Returns: # 0 always. -_ckipper_generate_account_launcher_function() { +_ckipper_account_generate_account_launcher_function() { local account_name="$1" account_dir="$2" echo "claude-$account_name() { CLAUDE_CONFIG_DIR=\"$account_dir\" command claude \"\$@\"; }" # Bare-name shortcut: also generate `` so users can type the # account name directly. Skip if it would shadow a real binary, builtin, # alias, or reserved word. - if _ckipper_bare_alias_safe "$account_name"; then + if _ckipper_account_bare_alias_safe "$account_name"; then echo "$account_name() { CLAUDE_CONFIG_DIR=\"$account_dir\" command claude \"\$@\"; }" else echo "# Bare-name alias '$account_name' skipped (would shadow existing command)." @@ -31,7 +31,7 @@ _ckipper_generate_account_launcher_function() { # # Returns: # 0 always. -_ckipper_write_bare_claude_guard() { +_ckipper_account_write_bare_claude_guard() { echo "claude() {" echo " if [[ -f \"\$_CKIPPER_REGISTRY\" ]] && jq -e '.accounts | length > 0' \"\$_CKIPPER_REGISTRY\" >/dev/null 2>&1; then" echo " local default" @@ -60,7 +60,7 @@ _ckipper_write_bare_claude_guard() { # # Returns: # 0 always. -_ckipper_regenerate_aliases() { +_ckipper_account_regenerate_aliases() { local out="$CKIPPER_DIR/aliases.zsh" local account_name account_dir { @@ -70,12 +70,12 @@ _ckipper_regenerate_aliases() { echo "" echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" echo "" - _ckipper_write_bare_claude_guard + _ckipper_account_write_bare_claude_guard echo "" if [[ -f "$CKIPPER_REGISTRY" ]]; then jq -r '.accounts | to_entries[] | "\(.key)\t\(.value.config_dir)"' "$CKIPPER_REGISTRY" | \ while IFS=$'\t' read -r account_name account_dir; do - _ckipper_generate_account_launcher_function "$account_name" "$account_dir" + _ckipper_account_generate_account_launcher_function "$account_name" "$account_dir" done fi } > "$out.tmp" @@ -95,7 +95,7 @@ _ckipper_regenerate_aliases() { # # Returns: # 0 always. -_ckipper_rewrite_settings_json_hooks() { +_ckipper_account_rewrite_settings_json_hooks() { local dir="$1" [[ -f "$dir/settings.json" ]] || return 0 command -v jq &>/dev/null || return 0 @@ -111,7 +111,7 @@ _ckipper_rewrite_settings_json_hooks() { } # Copy hook scripts into an account directory and rewrite settings.json hook paths. -# Allows callers (e.g. _ckipper_add) to pass the dir directly before the account +# Allows callers (e.g. _ckipper_account_add) to pass the dir directly before the account # is registered in the registry. # # Args: @@ -120,7 +120,7 @@ _ckipper_rewrite_settings_json_hooks() { # # Returns: # 0 on success; 1 if directory cannot be resolved. -_ckipper_sync_hooks_for() { +_ckipper_account_sync_hooks_for() { local name="$1" dir="${2:-}" if [[ -z "$dir" ]]; then _core_registry_check_version || return 1 @@ -129,14 +129,14 @@ _ckipper_sync_hooks_for() { fi mkdir -p "$dir/hooks" cp -a "$CKIPPER_DIR/hooks/." "$dir/hooks/" 2>/dev/null || true - _ckipper_rewrite_settings_json_hooks "$dir" + _ckipper_account_rewrite_settings_json_hooks "$dir" } # Copy hooks into all registered accounts. # # Returns: # 0 on success; 1 if registry version check fails. -_ckipper_sync_hooks() { +_ckipper_account_sync_hooks() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then echo "No accounts registered." return 0 @@ -145,6 +145,6 @@ _ckipper_sync_hooks() { local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY") while IFS= read -r name; do echo "Syncing hooks → $name" - _ckipper_sync_hooks_for "$name" + _ckipper_account_sync_hooks_for "$name" done <<< "$names" } diff --git a/lib/account/aliases_test.bats b/lib/account/aliases_test.bats index f38bc9d..f64d8e0 100644 --- a/lib/account/aliases_test.bats +++ b/lib/account/aliases_test.bats @@ -24,12 +24,12 @@ run_helper() { zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" } -# ── _ckipper_regenerate_aliases ─────────────────────────────────────── +# ── _ckipper_account_regenerate_aliases ─────────────────────────────────────── @test "regenerate_aliases creates aliases.zsh with mode 644" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_regenerate_aliases' + run_helper '_ckipper_account_regenerate_aliases' local out="$CKIPPER_DIR/aliases.zsh" assert_file_exists "$out" @@ -39,31 +39,31 @@ run_helper() { @test "regenerate_aliases includes a launcher function for each registered account" { echo '{"version":1,"default":"dev","accounts":{"dev":{"config_dir":"/tmp/.claude-dev","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_helper '_ckipper_regenerate_aliases' + run_helper '_ckipper_account_regenerate_aliases' local out="$CKIPPER_DIR/aliases.zsh" assert_file_exists "$out" grep -q "claude-dev()" "$out" } -# ── _ckipper_generate_account_launcher_function ─────────────────────── +# ── _ckipper_account_generate_account_launcher_function ─────────────────────── @test "generate_account_launcher_function emits a claude- function" { - run_helper '_ckipper_generate_account_launcher_function "work" "/tmp/.claude-work"' + run_helper '_ckipper_account_generate_account_launcher_function "work" "/tmp/.claude-work"' [ "$status" -eq 0 ] [[ "$output" =~ "claude-work()" ]] } @test "generate_account_launcher_function sets CLAUDE_CONFIG_DIR in the emitted body" { - run_helper '_ckipper_generate_account_launcher_function "work" "/tmp/.claude-work"' + run_helper '_ckipper_account_generate_account_launcher_function "work" "/tmp/.claude-work"' [ "$status" -eq 0 ] [[ "$output" =~ "CLAUDE_CONFIG_DIR" ]] [[ "$output" =~ "/tmp/.claude-work" ]] } -# ── _ckipper_sync_hooks_for ────────────────────────────────────────── +# ── _ckipper_account_sync_hooks_for ────────────────────────────────────────── @test "sync_hooks_for copies hooks into the account directory" { echo '{"version":1,"default":"dev","accounts":{"dev":{"config_dir":"'"$TMP_HOME"'/.claude-dev","keychain_service":null}}}' > "$CKIPPER_REGISTRY" @@ -72,7 +72,7 @@ run_helper() { mkdir -p "$CKIPPER_DIR/hooks" echo "#!/bin/sh" > "$CKIPPER_DIR/hooks/test-hook.sh" - run_helper '_ckipper_sync_hooks_for "dev"' + run_helper '_ckipper_account_sync_hooks_for "dev"' [ "$status" -eq 0 ] [ -f "$TMP_HOME/.claude-dev/hooks/test-hook.sh" ] @@ -85,13 +85,13 @@ run_helper() { printf '{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"$HOME/.ckipper/hooks/pre.sh"}]}]}}' \ > "$TMP_HOME/.claude-dev/settings.json" - run_helper '_ckipper_sync_hooks_for "dev"' + run_helper '_ckipper_account_sync_hooks_for "dev"' # After rewriting, the path should point to the account's hooks dir. grep -q "$TMP_HOME/.claude-dev/hooks/pre.sh" "$TMP_HOME/.claude-dev/settings.json" } -# ── _ckipper_sync_hooks ─────────────────────────────────────────────── +# ── _ckipper_account_sync_hooks ─────────────────────────────────────────────── @test "sync_hooks iterates all registered accounts and copies hooks to each" { local dir_a="$TMP_HOME/.claude-alpha" @@ -101,7 +101,7 @@ run_helper() { mkdir -p "$CKIPPER_DIR/hooks" echo "#!/bin/sh" > "$CKIPPER_DIR/hooks/shared-hook.sh" - run_helper '_ckipper_sync_hooks' + run_helper '_ckipper_account_sync_hooks' [ "$status" -eq 0 ] [ -f "$dir_a/hooks/shared-hook.sh" ] diff --git a/lib/account/plugin-repair.zsh b/lib/account/plugin-repair.zsh index 1865c28..c5a2407 100644 --- a/lib/account/plugin-repair.zsh +++ b/lib/account/plugin-repair.zsh @@ -15,13 +15,13 @@ # Returns: # 0 always (idempotent: if neither file contains old prefix, this is a no-op); # 1 if arguments are invalid. -_ckipper_rewrite_plugin_paths() { +_ckipper_account_rewrite_plugin_paths() { local old="$1" new="$2" [[ -z "$old" || -z "$new" || "$old" != */ || "$new" != */ ]] && return 1 [[ "$old" == "$new" ]] && return 0 local f for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do - _ckipper_rewrite_single_plugin_file "$old" "$new" "$f" + _ckipper_account_rewrite_single_plugin_file "$old" "$new" "$f" done return 0 } @@ -36,7 +36,7 @@ _ckipper_rewrite_plugin_paths() { # # Returns: # 0 always (no-op if file absent or old prefix not found). -_ckipper_rewrite_single_plugin_file() { +_ckipper_account_rewrite_single_plugin_file() { local old="$1" new="$2" rel_path="$3" local fp="$new$rel_path" [[ -f "$fp" ]] || return 0 @@ -56,7 +56,7 @@ _ckipper_rewrite_single_plugin_file() { # # Returns: # 0 always; prints stale prefix to stdout (empty if none found). -_ckipper_detect_stale_plugin_prefix() { +_ckipper_account_detect_stale_plugin_prefix() { local dir="$1" local f for f in plugins/known_marketplaces.json plugins/installed_plugins.json; do @@ -83,7 +83,7 @@ _ckipper_detect_stale_plugin_prefix() { # "Usage: ckipper repair-plugins " — when name is empty. # "Account '...' is not registered." — when account not found. # "Account dir does not exist: ..." — when directory is missing. -_ckipper_repair_plugins() { +_ckipper_account_repair_plugins() { local name="$1" if [[ -z "$name" ]]; then echo "Usage: ckipper repair-plugins " @@ -99,7 +99,7 @@ _ckipper_repair_plugins() { echo "Account dir does not exist: $dir" return 1 fi - _ckipper_repair_plugins_apply "$name" "$dir" + _ckipper_account_repair_plugins_apply "$name" "$dir" } # Apply stale-prefix repair to an account directory once validation has passed. @@ -110,16 +110,16 @@ _ckipper_repair_plugins() { # # Returns: # 0 on success or when no repair is needed. -_ckipper_repair_plugins_apply() { +_ckipper_account_repair_plugins_apply() { local name="$1" dir="$2" local stale_prefix - stale_prefix=$(_ckipper_detect_stale_plugin_prefix "$dir") + stale_prefix=$(_ckipper_account_detect_stale_plugin_prefix "$dir") if [[ -z "$stale_prefix" ]]; then echo "No stale paths found in $dir/plugins/. Nothing to repair." return 0 fi echo "Rewriting plugin metadata for '$name':" echo " $stale_prefix → $dir/" - _ckipper_rewrite_plugin_paths "$stale_prefix" "$dir/" + _ckipper_account_rewrite_plugin_paths "$stale_prefix" "$dir/" echo "Done. Backups saved alongside each rewritten file (.pre-rewrite-backup-)." } diff --git a/lib/account/plugin-repair_test.bats b/lib/account/plugin-repair_test.bats index 3faa61d..6c4f63a 100644 --- a/lib/account/plugin-repair_test.bats +++ b/lib/account/plugin-repair_test.bats @@ -24,7 +24,7 @@ run_helper() { zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" } -# ── _ckipper_detect_stale_plugin_prefix ────────────────────────────── +# ── _ckipper_account_detect_stale_plugin_prefix ────────────────────────────── @test "detect_stale_plugin_prefix finds old prefix in known_marketplaces.json" { local acc_dir="$TMP_HOME/.claude-personal" @@ -33,13 +33,13 @@ run_helper() { printf '{"url":"%s/plugins/marketplace.json"}' "$TMP_HOME/.claude/" \ > "$acc_dir/plugins/known_marketplaces.json" - run_helper "_ckipper_detect_stale_plugin_prefix \"$acc_dir\"" + run_helper "_ckipper_account_detect_stale_plugin_prefix \"$acc_dir\"" [ "$status" -eq 0 ] [[ "$output" =~ ".claude/" ]] } -# ── _ckipper_rewrite_plugin_paths ───────────────────────────────────── +# ── _ckipper_account_rewrite_plugin_paths ───────────────────────────────────── @test "rewrite_plugin_paths replaces old prefix with new prefix in plugin files" { local old_dir="$TMP_HOME/.claude/" @@ -57,7 +57,7 @@ run_helper() { PATH="$PATH" \ _CKIPPER_TEST_OSTYPE="darwin" \ CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_account_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" [ "$status" -eq 0 ] grep -q "$new_dir" "${new_dir}plugins/known_marketplaces.json" @@ -75,14 +75,14 @@ run_helper() { run env \ HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ PATH="$PATH" _CKIPPER_TEST_OSTYPE="darwin" CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_account_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" local content_after_first; content_after_first=$(cat "${new_dir}plugins/known_marketplaces.json") # Second run — old prefix is gone so this is a no-op; output must be identical. run env \ HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ PATH="$PATH" _CKIPPER_TEST_OSTYPE="darwin" CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; _ckipper_account_rewrite_plugin_paths \"$old_dir\" \"$new_dir\"" local content_after_second; content_after_second=$(cat "${new_dir}plugins/known_marketplaces.json") [ "$content_after_first" = "$content_after_second" ] diff --git a/lib/account/sync.zsh b/lib/account/sync.zsh index f6fcc8a..b3ce03f 100644 --- a/lib/account/sync.zsh +++ b/lib/account/sync.zsh @@ -2,7 +2,7 @@ # Account settings sync subcommand: sync MCP servers and settings.json keys between accounts. # Module-level context for the in-progress sync operation. -# Populated by _ckipper_sync before any helper reads it. +# Populated by _ckipper_account_sync before any helper reads it. # Fields: from_dir, to_dir, dry_run typeset -gA _CKIPPER_SYNC_CTX @@ -14,7 +14,7 @@ typeset -gA _CKIPPER_SYNC_CTX # # Returns: # 0 on success; 1 on unknown flag. -_ckipper_sync_parse_flags() { +_ckipper_account_sync_parse_flags() { mode_mcp="false"; mcp_names=""; mode_settings="false"; settings_keys=""; is_dry_run="false"; mode_all="false" while [[ $# -gt 0 ]]; do case "$1" in @@ -51,7 +51,7 @@ _ckipper_sync_parse_flags() { # # Returns: # 0 to proceed; 1 if user aborts. -_ckipper_sync_warn_running_claude() { +_ckipper_account_sync_warn_running_claude() { local from="$1" to="$2" local running_procs running_procs=$(_core_running_claude_processes) @@ -73,7 +73,7 @@ _ckipper_sync_warn_running_claude() { # # Returns: # 0 always. -_ckipper_sync_mcp_servers() { +_ckipper_account_sync_mcp_servers() { local to="$1" mcp_names="$2" local from_dir="${_CKIPPER_SYNC_CTX[from_dir]}" local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" @@ -112,7 +112,7 @@ _ckipper_sync_mcp_servers() { # # Returns: # 0 always. -_ckipper_sync_settings_keys() { +_ckipper_account_sync_settings_keys() { local from="$1" to="$2" settings_keys="$3" local from_dir="${_CKIPPER_SYNC_CTX[from_dir]}" local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" @@ -145,7 +145,7 @@ _ckipper_sync_settings_keys() { # # Returns: # 0 always. -_ckipper_sync_print_summary() { +_ckipper_account_sync_print_summary() { local to="$1" is_dry_run="$2" if [[ "$is_dry_run" = "true" ]]; then echo "Dry run — would apply:" @@ -171,7 +171,7 @@ _ckipper_sync_print_summary() { # # Returns: # 0 with "from_dir\tto_dir" on stdout; 1 on validation failure. -_ckipper_sync_resolve_dirs() { +_ckipper_account_sync_resolve_dirs() { local from="$1" to="$2" local from_dir to_dir from_dir=$(_core_account_dir "$from") || return 1 @@ -198,7 +198,7 @@ _ckipper_sync_resolve_dirs() { # Errors (stderr): # "Usage: ckipper sync ..." — when arguments are missing. # " and must differ." — when both accounts are the same. -_ckipper_sync() { +_ckipper_account_sync() { _core_registry_check_version || return 1 local from="$1" to="$2" shift 2 2>/dev/null @@ -207,20 +207,20 @@ _ckipper_sync() { return 1 fi [[ "$from" == "$to" ]] && { echo " and must differ."; return 1; } - local dirs_line; dirs_line=$(_ckipper_sync_resolve_dirs "$from" "$to") || return 1 + local dirs_line; dirs_line=$(_ckipper_account_sync_resolve_dirs "$from" "$to") || return 1 local from_dir="${dirs_line%% *}" to_dir="${dirs_line##* }" local mode_mcp mcp_names mode_settings settings_keys is_dry_run mode_all - _ckipper_sync_parse_flags "$@" || return 1 + _ckipper_account_sync_parse_flags "$@" || return 1 if [[ "$is_dry_run" != "true" ]]; then - _ckipper_sync_warn_running_claude "$from" "$to" || return 1 + _ckipper_account_sync_warn_running_claude "$from" "$to" || return 1 fi _CKIPPER_SYNC_CTX[from_dir]="$from_dir" _CKIPPER_SYNC_CTX[to_dir]="$to_dir" _CKIPPER_SYNC_CTX[dry_run]="$is_dry_run" local pending_msgs=() [[ "$mode_mcp" = "true" ]] && \ - _ckipper_sync_mcp_servers "$to" "$mcp_names" + _ckipper_account_sync_mcp_servers "$to" "$mcp_names" [[ "$mode_settings" = "true" && ${#settings_keys} -gt 0 ]] && \ - _ckipper_sync_settings_keys "$from" "$to" "$settings_keys" - _ckipper_sync_print_summary "$to" "$is_dry_run" + _ckipper_account_sync_settings_keys "$from" "$to" "$settings_keys" + _ckipper_account_sync_print_summary "$to" "$is_dry_run" } diff --git a/lib/account/sync_test.bats b/lib/account/sync_test.bats index 629893c..0b7b068 100644 --- a/lib/account/sync_test.bats +++ b/lib/account/sync_test.bats @@ -24,11 +24,11 @@ run_helper() { zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; $*" } -# ── _ckipper_sync_parse_flags ───────────────────────────────────────── +# ── _ckipper_account_sync_parse_flags ───────────────────────────────────────── @test "parse_flags sets mode_all=true when no flags given" { run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_sync_parse_flags + _ckipper_account_sync_parse_flags echo "mode_all=$mode_all"' [ "$status" -eq 0 ] @@ -37,7 +37,7 @@ run_helper() { @test "parse_flags sets is_dry_run=true for --dry-run flag" { run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_sync_parse_flags --dry-run + _ckipper_account_sync_parse_flags --dry-run echo "is_dry_run=$is_dry_run"' [ "$status" -eq 0 ] @@ -46,7 +46,7 @@ run_helper() { @test "parse_flags sets mode_mcp=true for --mcp flag" { run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_sync_parse_flags --mcp + _ckipper_account_sync_parse_flags --mcp echo "mode_mcp=$mode_mcp"' [ "$status" -eq 0 ] @@ -55,7 +55,7 @@ run_helper() { @test "parse_flags sets mode_settings=true for --settings flag" { run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_sync_parse_flags --settings "enabledPlugins" + _ckipper_account_sync_parse_flags --settings "enabledPlugins" echo "mode_settings=$mode_settings"' [ "$status" -eq 0 ] @@ -64,13 +64,13 @@ run_helper() { @test "parse_flags returns 1 and prints error for unknown flag" { run_helper 'mode_mcp="false"; mode_settings="false"; is_dry_run="false"; mode_all="false" - _ckipper_sync_parse_flags --bogus-flag' + _ckipper_account_sync_parse_flags --bogus-flag' [ "$status" -ne 0 ] [[ "$output" =~ "Unknown flag" ]] } -# ── _ckipper_sync_mcp_servers ───────────────────────────────────────── +# ── _ckipper_account_sync_mcp_servers ───────────────────────────────────────── @test "sync_mcp_servers merges MCP servers into the destination claude.json" { local from_dir="$TMP_HOME/.claude-src" @@ -85,7 +85,7 @@ run_helper() { _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" _CKIPPER_SYNC_CTX[dry_run]="false" - _ckipper_sync_mcp_servers "dst" "" + _ckipper_account_sync_mcp_servers "dst" "" echo "${pending_msgs[@]}"' [ "$status" -eq 0 ] @@ -109,7 +109,7 @@ run_helper() { _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" _CKIPPER_SYNC_CTX[dry_run]="true" - _ckipper_sync_mcp_servers "dst" ""' + _ckipper_account_sync_mcp_servers "dst" ""' [ "$status" -eq 0 ] # Destination must be unchanged in dry-run mode. @@ -117,7 +117,7 @@ run_helper() { [ "$before_dst" = "$after_dst" ] } -# ── _ckipper_sync_settings_keys ─────────────────────────────────────── +# ── _ckipper_account_sync_settings_keys ─────────────────────────────────────── @test "sync_settings_keys copies matching keys from source settings.json to destination" { local from_dir="$TMP_HOME/.claude-src" @@ -132,7 +132,7 @@ run_helper() { _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" _CKIPPER_SYNC_CTX[dry_run]="false" - _ckipper_sync_settings_keys "src" "dst" "model,enabledPlugins"' + _ckipper_account_sync_settings_keys "src" "dst" "model,enabledPlugins"' [ "$status" -eq 0 ] local dst; dst=$(cat "$to_dir/settings.json") @@ -156,18 +156,18 @@ run_helper() { _CKIPPER_SYNC_CTX[from_dir]="'"$from_dir"'" _CKIPPER_SYNC_CTX[to_dir]="'"$to_dir"'" _CKIPPER_SYNC_CTX[dry_run]="true" - _ckipper_sync_settings_keys "src" "dst" "model"' + _ckipper_account_sync_settings_keys "src" "dst" "model"' [ "$status" -eq 0 ] local after_dst; after_dst=$(cat "$to_dir/settings.json") [ "$before_dst" = "$after_dst" ] } -# ── _ckipper_sync_print_summary ─────────────────────────────────────── +# ── _ckipper_account_sync_print_summary ─────────────────────────────────────── @test "print_summary prints 'Synced' header and lists all pending messages" { run_helper 'pending_msgs=("MCP servers → dst: server1 " "Settings keys → dst: model ") - _ckipper_sync_print_summary "dst" "false"' + _ckipper_account_sync_print_summary "dst" "false"' [ "$status" -eq 0 ] [[ "$output" =~ "Synced" ]] @@ -177,7 +177,7 @@ run_helper() { @test "print_summary prints 'Dry run' header in dry-run mode" { run_helper 'pending_msgs=("MCP servers → dst: server1 ") - _ckipper_sync_print_summary "dst" "true"' + _ckipper_account_sync_print_summary "dst" "true"' [ "$status" -eq 0 ] [[ "$output" =~ "Dry run" ]] From 0e0e7a83a288fd4b6eb5f702b9fc929f4def6e4a Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:02:17 -0600 Subject: [PATCH 075/165] Rename lib/w/ to lib/worktree/ and _w_* to _ckipper_worktree_* --- install_test.bats | 2 +- lib/{w => worktree}/args.zsh | 14 +++--- lib/{w => worktree}/args_test.bats | 16 +++--- lib/{w => worktree}/build-image.zsh | 2 +- lib/{w => worktree}/build-image_test.bats | 8 +-- lib/{w => worktree}/docker-mode.zsh | 50 +++++++++---------- lib/{w => worktree}/docker-mode_test.bats | 20 ++++---- lib/{w => worktree}/normal-mode.zsh | 2 +- lib/{w => worktree}/normal-mode_test.bats | 8 +-- lib/{w => worktree}/ports.zsh | 10 ++-- lib/{w => worktree}/ports_test.bats | 14 +++--- lib/{w => worktree}/resolve-account.zsh | 6 +-- lib/{w => worktree}/resolve-account_test.bats | 18 +++---- lib/{w => worktree}/worktree.zsh | 44 ++++++++-------- lib/{w => worktree}/worktree_test.bats | 16 +++--- w-function.zsh | 40 +++++++-------- 16 files changed, 135 insertions(+), 135 deletions(-) rename lib/{w => worktree}/args.zsh (90%) rename lib/{w => worktree}/args_test.bats (51%) rename lib/{w => worktree}/build-image.zsh (94%) rename lib/{w => worktree}/build-image_test.bats (62%) rename lib/{w => worktree}/docker-mode.zsh (87%) rename lib/{w => worktree}/docker-mode_test.bats (72%) rename lib/{w => worktree}/normal-mode.zsh (93%) rename lib/{w => worktree}/normal-mode_test.bats (67%) rename lib/{w => worktree}/ports.zsh (88%) rename lib/{w => worktree}/ports_test.bats (63%) rename lib/{w => worktree}/resolve-account.zsh (95%) rename lib/{w => worktree}/resolve-account_test.bats (73%) rename lib/{w => worktree}/worktree.zsh (88%) rename lib/{w => worktree}/worktree_test.bats (68%) diff --git a/install_test.bats b/install_test.bats index 162ae08..edd072e 100644 --- a/install_test.bats +++ b/install_test.bats @@ -17,7 +17,7 @@ teardown() { [ "$status" -eq 0 ] [ -d "$TMP_HOME/.ckipper/docker/lib/core" ] [ -d "$TMP_HOME/.ckipper/docker/lib/account" ] - [ -d "$TMP_HOME/.ckipper/docker/lib/w" ] + [ -d "$TMP_HOME/.ckipper/docker/lib/worktree" ] [ -f "$TMP_HOME/.ckipper/docker/ckipper.zsh" ] [ -f "$TMP_HOME/.ckipper/docker/w-function.zsh" ] } diff --git a/lib/w/args.zsh b/lib/worktree/args.zsh similarity index 90% rename from lib/w/args.zsh rename to lib/worktree/args.zsh index a95cd84..7a87a16 100644 --- a/lib/w/args.zsh +++ b/lib/worktree/args.zsh @@ -19,8 +19,8 @@ # initialized once when w-function.zsh is sourced and never reset here. # # Returns: 0 always (validation is done by the dispatcher). -_w_parse_args() { - _w_reset_globals +_ckipper_worktree_parse_args() { + _ckipper_worktree_reset_globals if [[ "$1" == "--list" ]]; then W_FLAG_LIST=true @@ -33,11 +33,11 @@ _w_parse_args() { fi if [[ "$1" == "--rm" ]]; then - _w_parse_rm_args "$@" + _ckipper_worktree_parse_rm_args "$@" return 0 fi - _w_parse_run_args "$@" + _ckipper_worktree_parse_run_args "$@" } # Reset per-call W_* arg globals (flags + positionals) to their default values. @@ -45,7 +45,7 @@ _w_parse_args() { # by w-function.zsh and intentionally not touched here. # # Returns: 0 always. -_w_reset_globals() { +_ckipper_worktree_reset_globals() { W_FLAG_LIST=false W_FLAG_REBUILD_IMAGE=false W_FLAG_RM=false @@ -64,7 +64,7 @@ _w_reset_globals() { # $@ — original args starting with --rm # # Returns: 0 always. -_w_parse_rm_args() { +_ckipper_worktree_parse_rm_args() { W_FLAG_RM=true shift if [[ "$1" == "--force" || "$1" == "-f" ]]; then @@ -81,7 +81,7 @@ _w_parse_rm_args() { # $@ — original args (project is $1, branch is $2) # # Returns: 0 always. -_w_parse_run_args() { +_ckipper_worktree_parse_run_args() { W_PROJECT="$1" W_BRANCH="$2" shift 2 2>/dev/null diff --git a/lib/w/args_test.bats b/lib/worktree/args_test.bats similarity index 51% rename from lib/w/args_test.bats rename to lib/worktree/args_test.bats index 2c4955e..fdab5c2 100644 --- a/lib/w/args_test.bats +++ b/lib/worktree/args_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Module-level tests for lib/w/args.zsh. -# Verifies that _w_parse_args sets the correct W_* globals. +# Module-level tests for lib/worktree/args.zsh. +# Verifies that _ckipper_worktree_parse_args sets the correct W_* globals. load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -12,36 +12,36 @@ teardown() { teardown_isolated_env } -# Helper: source args.zsh, call _w_parse_args with provided args, then print +# Helper: source args.zsh, call _ckipper_worktree_parse_args with provided args, then print # the value of $1 (variable name) to stdout. _parse_and_print() { local var_name="$1"; shift run env HOME="$TMP_HOME" PATH="$PATH" \ - zsh -c "source \"$REPO_ROOT/lib/w/args.zsh\"; _w_parse_args $*; print -r -- \"\$$var_name\"" + zsh -c "source \"$REPO_ROOT/lib/worktree/args.zsh\"; _ckipper_worktree_parse_args $*; print -r -- \"\$$var_name\"" } -@test "_w_parse_args sets W_PROJECT and W_BRANCH for bare project/branch args" { +@test "_ckipper_worktree_parse_args sets W_PROJECT and W_BRANCH for bare project/branch args" { _parse_and_print "W_PROJECT" myapp feature-x [ "$status" -eq 0 ] [ "$output" = "myapp" ] } -@test "_w_parse_args sets W_FLAG_RM to true for --rm flag" { +@test "_ckipper_worktree_parse_args sets W_FLAG_RM to true for --rm flag" { _parse_and_print "W_FLAG_RM" --rm myapp branch [ "$status" -eq 0 ] [ "$output" = "true" ] } -@test "_w_parse_args sets W_FLAG_DOCKER to true for --docker flag" { +@test "_ckipper_worktree_parse_args sets W_FLAG_DOCKER to true for --docker flag" { _parse_and_print "W_FLAG_DOCKER" myapp feature-x --docker [ "$status" -eq 0 ] [ "$output" = "true" ] } -@test "_w_parse_args sets W_FLAG_LIST to true for --list flag" { +@test "_ckipper_worktree_parse_args sets W_FLAG_LIST to true for --list flag" { _parse_and_print "W_FLAG_LIST" --list [ "$status" -eq 0 ] diff --git a/lib/w/build-image.zsh b/lib/worktree/build-image.zsh similarity index 94% rename from lib/w/build-image.zsh rename to lib/worktree/build-image.zsh index ecdd12f..bfddc41 100644 --- a/lib/w/build-image.zsh +++ b/lib/worktree/build-image.zsh @@ -4,7 +4,7 @@ # Build the ckipper-dev Docker image from $CKIPPER_DIR/docker/Dockerfile. # # Returns: 0 on success; 1 if Dockerfile not found or docker build fails. -_w_build_image() { +_ckipper_worktree_build_image() { local docker_dir="${CKIPPER_DIR:-$HOME/.ckipper}/docker" if [[ ! -f "$docker_dir/Dockerfile" ]]; then echo "Dockerfile not found: $docker_dir/Dockerfile" diff --git a/lib/w/build-image_test.bats b/lib/worktree/build-image_test.bats similarity index 62% rename from lib/w/build-image_test.bats rename to lib/worktree/build-image_test.bats index a8d3a77..4dfcf2a 100644 --- a/lib/w/build-image_test.bats +++ b/lib/worktree/build-image_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Module-level tests for lib/w/build-image.zsh. -# Verifies _w_build_image invokes docker build when Dockerfile exists. +# Module-level tests for lib/worktree/build-image.zsh. +# Verifies _ckipper_worktree_build_image invokes docker build when Dockerfile exists. load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -14,7 +14,7 @@ teardown() { teardown_isolated_env } -@test "_w_build_image invokes docker build when Dockerfile is present" { +@test "_ckipper_worktree_build_image invokes docker build when Dockerfile is present" { mkdir -p "$CKIPPER_DIR/docker" touch "$CKIPPER_DIR/docker/Dockerfile" @@ -22,7 +22,7 @@ teardown() { CKIPPER_DIR="$CKIPPER_DIR" \ DOCKER_STUB_LOG="$DOCKER_STUB_LOG" \ PATH="$PATH" \ - zsh -c "source \"$REPO_ROOT/lib/w/build-image.zsh\"; _w_build_image" + zsh -c "source \"$REPO_ROOT/lib/worktree/build-image.zsh\"; _ckipper_worktree_build_image" [ "$status" -eq 0 ] [[ "$output" =~ "Building" ]] diff --git a/lib/w/docker-mode.zsh b/lib/worktree/docker-mode.zsh similarity index 87% rename from lib/w/docker-mode.zsh rename to lib/worktree/docker-mode.zsh index ae4f2f4..fd66fe4 100644 --- a/lib/w/docker-mode.zsh +++ b/lib/worktree/docker-mode.zsh @@ -21,28 +21,28 @@ readonly DOCKER_GROUP_ADD_HOST_ROOT=0 # W_FLAG_FIREWALL, W_ACTIVE_ACCOUNT, W_ACTIVE_CONFIG_DIR, # W_ACTIVE_KEYCHAIN_SERVICE, W_PORTS, W_EXTRA_VOLUMES, W_EXTRA_ENV. # Returns: exit code of the docker run invocation. -_w_run_docker_mode() { - _w_docker_check_prerequisites || return 1 +_ckipper_worktree_run_docker_mode() { + _ckipper_worktree_docker_check_prerequisites || return 1 [[ -f "$W_ACTIVE_CONFIG_DIR/.claude.json" ]] || echo '{}' > "$W_ACTIVE_CONFIG_DIR/.claude.json" - _w_docker_validate_keychain || return 1 + _ckipper_worktree_docker_validate_keychain || return 1 local claude_creds gh_token - claude_creds=$(_w_docker_extract_credentials) || return 1 - gh_token=$(_w_docker_extract_gh_token) + claude_creds=$(_ckipper_worktree_docker_extract_credentials) || return 1 + gh_token=$(_ckipper_worktree_docker_extract_gh_token) local -a W_DOCKER_ARGS - _w_docker_build_base_args - _w_docker_add_optional_args "$claude_creds" "$gh_token" - _w_resolve_ports + _ckipper_worktree_docker_build_base_args + _ckipper_worktree_docker_add_optional_args "$claude_creds" "$gh_token" + _ckipper_worktree_resolve_ports [[ "$W_FLAG_FIREWALL" = true ]] && W_DOCKER_ARGS+=( --cap-add=NET_ADMIN -e ENABLE_FIREWALL=1 ) W_DOCKER_ARGS+=( ckipper-dev ) - _w_docker_expand_command + _ckipper_worktree_docker_expand_command - _w_docker_print_banner - _w_docker_snapshot_and_run + _ckipper_worktree_docker_print_banner + _ckipper_worktree_docker_snapshot_and_run } # Validate Docker is installed and daemon is running. @@ -51,7 +51,7 @@ _w_run_docker_mode() { # Errors (stderr): # "Error: docker is not installed or not in PATH" — when docker binary is missing # "Error: Docker daemon is not running. Start Docker Desktop first." — when daemon is down -_w_docker_check_prerequisites() { +_ckipper_worktree_docker_check_prerequisites() { if ! command -v docker &>/dev/null; then echo "Error: docker is not installed or not in PATH" return 1 @@ -61,7 +61,7 @@ _w_docker_check_prerequisites() { return 1 fi if ! docker image inspect ckipper-dev > /dev/null 2>&1; then - _w_build_image || return 1 + _ckipper_worktree_build_image || return 1 fi } @@ -71,7 +71,7 @@ _w_docker_check_prerequisites() { # Returns: 0 if valid or no keychain service is configured; 1 on invalid service. # Errors (stderr): # "Error: account '' has invalid keychain_service in registry." — on validation failure -_w_docker_validate_keychain() { +_ckipper_worktree_docker_validate_keychain() { if [[ -n "$W_ACTIVE_KEYCHAIN_SERVICE" ]] && \ ! _core_keychain_validate "$W_ACTIVE_KEYCHAIN_SERVICE"; then echo "Error: account '$W_ACTIVE_ACCOUNT' has invalid keychain_service in registry." @@ -87,7 +87,7 @@ _w_docker_validate_keychain() { # 1 if credentials are present but not valid JSON. # Errors (stderr): # "Error: Claude credentials from Keychain are not valid JSON. ..." — on invalid JSON -_w_docker_extract_credentials() { +_ckipper_worktree_docker_extract_credentials() { if [[ -z "$W_ACTIVE_KEYCHAIN_SERVICE" ]]; then echo "" return 0 @@ -113,7 +113,7 @@ _w_docker_extract_credentials() { # # Reads: W_ACTIVE_CONFIG_DIR global. # Returns: 0 always (prints empty string if no token found). -_w_docker_extract_gh_token() { +_ckipper_worktree_docker_extract_gh_token() { local token token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' \ "$W_ACTIVE_CONFIG_DIR/.claude.json" 2>/dev/null) || true @@ -129,7 +129,7 @@ _w_docker_extract_gh_token() { # W_EXTRA_VOLUMES globals. # Sets: W_DOCKER_ARGS (initialised from scratch). # Returns: 0 always. -_w_docker_build_base_args() { +_ckipper_worktree_docker_build_base_args() { W_DOCKER_ARGS=( docker run --rm -it -e TERM="${TERM:-xterm-256color}" @@ -161,7 +161,7 @@ _w_docker_build_base_args() { # # Reads: W_EXTRA_ENV global. Appends to W_DOCKER_ARGS. # Returns: 0 always. -_w_docker_add_optional_args() { +_ckipper_worktree_docker_add_optional_args() { local claude_creds="$1" local gh_token="$2" @@ -186,7 +186,7 @@ _w_docker_add_optional_args() { # # Reads and appends to W_COMMAND and W_DOCKER_ARGS. # Returns: 0 always. -_w_docker_expand_command() { +_ckipper_worktree_docker_expand_command() { if [[ ${#W_COMMAND[@]} -gt 0 && "${W_COMMAND[1]}" == "claude" ]]; then W_COMMAND=(claude --dangerously-skip-permissions "/rename $W_BRANCH") fi @@ -199,7 +199,7 @@ _w_docker_expand_command() { # # Reads: W_COMMAND, W_FLAG_FIREWALL, W_WT_PATH, W_RESOLVED_PORTS globals. # Returns: 0 always. -_w_docker_print_banner() { +_ckipper_worktree_docker_print_banner() { local mode_label="Docker" [[ ${#W_COMMAND[@]} -gt 0 ]] && mode_label+=": ${W_COMMAND[1]}" [[ "$W_FLAG_FIREWALL" = true ]] && mode_label+=", firewall" @@ -212,7 +212,7 @@ _w_docker_print_banner() { # # Reads: W_DOCKER_ARGS, W_PROJECTS_DIR, W_PROJECT globals. # Returns: exit code of the docker run invocation. -_w_docker_snapshot_and_run() { +_ckipper_worktree_docker_snapshot_and_run() { local git_config="$W_PROJECTS_DIR/$W_PROJECT/.git/config" local git_config_hash="" [[ -f "$git_config" ]] && git_config_hash=$(shasum -a "$SHASUM_BITS" "$git_config" | cut -d' ' -f1) @@ -226,8 +226,8 @@ _w_docker_snapshot_and_run() { "${W_DOCKER_ARGS[@]}" local exit_code=$? - _w_docker_check_git_config_tampering "$git_config" "$git_config_hash" - _w_docker_check_worktree_destruction "$git_worktrees_dir" "${worktrees_before[@]}" + _ckipper_worktree_docker_check_git_config_tampering "$git_config" "$git_config_hash" + _ckipper_worktree_docker_check_worktree_destruction "$git_worktrees_dir" "${worktrees_before[@]}" return $exit_code } @@ -239,7 +239,7 @@ _w_docker_snapshot_and_run() { # $2 — sha256 hash of config before session (may be empty) # # Returns: 0 always. -_w_docker_check_git_config_tampering() { +_ckipper_worktree_docker_check_git_config_tampering() { local git_config="$1" local original_hash="$2" if [[ -n "$original_hash" && -f "$git_config" ]]; then @@ -260,7 +260,7 @@ _w_docker_check_git_config_tampering() { # $@ — worktree names present before the session # # Returns: 0 always. -_w_docker_check_worktree_destruction() { +_ckipper_worktree_docker_check_worktree_destruction() { local git_worktrees_dir="$1" shift local -a worktrees_before=("$@") diff --git a/lib/w/docker-mode_test.bats b/lib/worktree/docker-mode_test.bats similarity index 72% rename from lib/w/docker-mode_test.bats rename to lib/worktree/docker-mode_test.bats index 735b384..d4304ac 100644 --- a/lib/w/docker-mode_test.bats +++ b/lib/worktree/docker-mode_test.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# Module-level tests for lib/w/docker-mode.zsh. +# Module-level tests for lib/worktree/docker-mode.zsh. # Covers compute_volumes (via build_base_args), compute_envs (add_optional_args), # and extract_credentials JSON validation. @@ -56,32 +56,32 @@ _run_docker_mode() { source \"$REPO_ROOT/lib/core/utils.zsh\" source \"$REPO_ROOT/lib/core/keychain.zsh\" source \"$REPO_ROOT/lib/core/registry.zsh\" - source \"$REPO_ROOT/lib/w/ports.zsh\" - source \"$REPO_ROOT/lib/w/build-image.zsh\" - source \"$REPO_ROOT/lib/w/docker-mode.zsh\" + source \"$REPO_ROOT/lib/worktree/ports.zsh\" + source \"$REPO_ROOT/lib/worktree/build-image.zsh\" + source \"$REPO_ROOT/lib/worktree/docker-mode.zsh\" $zsh_cmd " } -@test "_w_docker_build_base_args includes the worktree volume mount" { - _run_docker_mode "_w_docker_build_base_args; print -r -- \"\${W_DOCKER_ARGS[*]}\"" +@test "_ckipper_worktree_docker_build_base_args includes the worktree volume mount" { + _run_docker_mode "_ckipper_worktree_docker_build_base_args; print -r -- \"\${W_DOCKER_ARGS[*]}\"" [ "$status" -eq 0 ] [[ "$output" =~ "-v" ]] [[ "$output" =~ "$W_WT_PATH:/workspace:rw" ]] } -@test "_w_docker_add_optional_args emits a warning when no credentials are provided" { - _run_docker_mode "_w_docker_build_base_args; _w_docker_add_optional_args '' ''" +@test "_ckipper_worktree_docker_add_optional_args emits a warning when no credentials are provided" { + _run_docker_mode "_ckipper_worktree_docker_build_base_args; _ckipper_worktree_docker_add_optional_args '' ''" [ "$status" -eq 0 ] [[ "$output" =~ "Warning" ]] } -@test "_w_docker_extract_credentials returns empty string when no keychain service is set" { +@test "_ckipper_worktree_docker_extract_credentials returns empty string when no keychain service is set" { export W_ACTIVE_KEYCHAIN_SERVICE="" - _run_docker_mode "result=\$(_w_docker_extract_credentials); print -r -- \"result=\$result\"" + _run_docker_mode "result=\$(_ckipper_worktree_docker_extract_credentials); print -r -- \"result=\$result\"" [ "$status" -eq 0 ] [ "$output" = "result=" ] diff --git a/lib/w/normal-mode.zsh b/lib/worktree/normal-mode.zsh similarity index 93% rename from lib/w/normal-mode.zsh rename to lib/worktree/normal-mode.zsh index a16dda8..029bf99 100644 --- a/lib/w/normal-mode.zsh +++ b/lib/worktree/normal-mode.zsh @@ -5,7 +5,7 @@ # # Reads globals: W_WT_PATH, W_BRANCH, W_COMMAND. # Returns: 0 on cd; exit code of command if one was given. -_w_run_normal_mode() { +_ckipper_worktree_run_normal_mode() { if [[ ${#W_COMMAND[@]} -eq 0 ]]; then cd "$W_WT_PATH" return 0 diff --git a/lib/w/normal-mode_test.bats b/lib/worktree/normal-mode_test.bats similarity index 67% rename from lib/w/normal-mode_test.bats rename to lib/worktree/normal-mode_test.bats index 78a0c4e..ada010c 100644 --- a/lib/w/normal-mode_test.bats +++ b/lib/worktree/normal-mode_test.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# Module-level tests for lib/w/normal-mode.zsh. +# Module-level tests for lib/worktree/normal-mode.zsh. # Covers the happy path: command runs in the worktree directory. load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -15,15 +15,15 @@ teardown() { teardown_isolated_env } -@test "_w_run_normal_mode executes a stub claude binary in the worktree directory" { +@test "_ckipper_worktree_run_normal_mode executes a stub claude binary in the worktree directory" { run env HOME="$TMP_HOME" \ W_WT_PATH="$W_WT_PATH" \ W_BRANCH="$W_BRANCH" \ PATH="$PATH" \ zsh -c " typeset -a W_COMMAND=(claude) - source \"$REPO_ROOT/lib/w/normal-mode.zsh\" - _w_run_normal_mode + source \"$REPO_ROOT/lib/worktree/normal-mode.zsh\" + _ckipper_worktree_run_normal_mode " [ "$status" -eq 0 ] diff --git a/lib/w/ports.zsh b/lib/worktree/ports.zsh similarity index 88% rename from lib/w/ports.zsh rename to lib/worktree/ports.zsh index 44f2ee2..57123af 100644 --- a/lib/w/ports.zsh +++ b/lib/worktree/ports.zsh @@ -8,11 +8,11 @@ readonly MAX_PORT_FALLBACK_ATTEMPTS=10 # # Reads: W_PORTS (array of container ports), W_DOCKER_ARGS (appended to). # Sets: W_RESOLVED_PORTS (array of ports for display). -_w_resolve_ports() { +_ckipper_worktree_resolve_ports() { W_RESOLVED_PORTS=("${W_PORTS[@]}") for port in "${W_PORTS[@]}"; do - _w_bind_port "$port" + _ckipper_worktree_bind_port "$port" done } @@ -23,14 +23,14 @@ _w_resolve_ports() { # # Reads: W_DOCKER_ARGS (appended to). # Returns: 0 always (logs a warning if no port could be bound). -_w_bind_port() { +_ckipper_worktree_bind_port() { local port="$1" local host_port=$port local is_bound="false" for (( i=0; i/dev/null; then - _w_record_bound_port "$port" "$host_port" + _ckipper_worktree_record_bound_port "$port" "$host_port" is_bound="true" break fi @@ -50,7 +50,7 @@ _w_bind_port() { # # Reads: W_DOCKER_ARGS (appended to). # Returns: 0 always. -_w_record_bound_port() { +_ckipper_worktree_record_bound_port() { local port="$1" local host_port="$2" W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) diff --git a/lib/w/ports_test.bats b/lib/worktree/ports_test.bats similarity index 63% rename from lib/w/ports_test.bats rename to lib/worktree/ports_test.bats index 4b4b8bc..66f8616 100644 --- a/lib/w/ports_test.bats +++ b/lib/worktree/ports_test.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Module-level tests for lib/w/ports.zsh. -# Covers _w_bind_port success path and fallback when ports are busy. +# Module-level tests for lib/worktree/ports.zsh. +# Covers _ckipper_worktree_bind_port success path and fallback when ports are busy. load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -20,25 +20,25 @@ _run_ports() { run env HOME="$TMP_HOME" PATH="$PATH" \ LSOF_STUB_BUSY="${LSOF_STUB_BUSY:-}" \ zsh -c " - source \"$REPO_ROOT/lib/w/ports.zsh\" + source \"$REPO_ROOT/lib/worktree/ports.zsh\" typeset -a W_DOCKER_ARGS=() typeset -a W_RESOLVED_PORTS=() $zsh_cmd " } -@test "_w_bind_port appends a -p flag to W_DOCKER_ARGS when port is free" { - _run_ports '_w_bind_port 3000; print -r -- "${W_DOCKER_ARGS[*]}"' +@test "_ckipper_worktree_bind_port appends a -p flag to W_DOCKER_ARGS when port is free" { + _run_ports '_ckipper_worktree_bind_port 3000; print -r -- "${W_DOCKER_ARGS[*]}"' [ "$status" -eq 0 ] [[ "$output" =~ "-p 127.0.0.1:3000:3000" ]] } -@test "_w_bind_port logs a warning when all fallback ports are busy" { +@test "_ckipper_worktree_bind_port logs a warning when all fallback ports are busy" { # Mark every port from 3000 through 3009 as busy in the lsof stub. export LSOF_STUB_BUSY="3000 3001 3002 3003 3004 3005 3006 3007 3008 3009" - _run_ports '_w_bind_port 3000' + _run_ports '_ckipper_worktree_bind_port 3000' [ "$status" -eq 0 ] [[ "$output" =~ "no available host port" ]] diff --git a/lib/w/resolve-account.zsh b/lib/worktree/resolve-account.zsh similarity index 95% rename from lib/w/resolve-account.zsh rename to lib/worktree/resolve-account.zsh index eedf9c1..6c708ad 100644 --- a/lib/w/resolve-account.zsh +++ b/lib/worktree/resolve-account.zsh @@ -15,9 +15,9 @@ # Errors (stderr): # "Error: no account selected and no default registered." — when no account can be resolved # "Error: account '' is not registered. Run: ckipper list" — when account missing from registry -_w_resolve_account() { +_ckipper_worktree_resolve_account() { local candidate - candidate=$(_w_find_account_name) + candidate=$(_ckipper_worktree_find_account_name) if [[ -z "$candidate" ]]; then echo "Error: no account selected and no default registered." @@ -46,7 +46,7 @@ _w_resolve_account() { # Resolution order: CLI flag → env match → registry default. # # Returns: 0 always (prints account name to stdout, or empty string if none found). -_w_find_account_name() { +_ckipper_worktree_find_account_name() { if [[ -n "$W_CLI_ACCOUNT" ]]; then echo "$W_CLI_ACCOUNT" return 0 diff --git a/lib/w/resolve-account_test.bats b/lib/worktree/resolve-account_test.bats similarity index 73% rename from lib/w/resolve-account_test.bats rename to lib/worktree/resolve-account_test.bats index df66b5d..ed73da8 100644 --- a/lib/w/resolve-account_test.bats +++ b/lib/worktree/resolve-account_test.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# Module-level tests for lib/w/resolve-account.zsh. +# Module-level tests for lib/worktree/resolve-account.zsh. # Covers env-override, registry lookup, registry default fallback, and error path. load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -13,8 +13,8 @@ teardown() { teardown_isolated_env } -# Helper: source all required modules and call _w_resolve_account; print a -# specific global var from the resolved state. Propagates _w_resolve_account's +# Helper: source all required modules and call _ckipper_worktree_resolve_account; print a +# specific global var from the resolved state. Propagates _ckipper_worktree_resolve_account's # exit code so failure tests can assert status != 0. _resolve_and_print() { local var_name="$1" @@ -28,13 +28,13 @@ _resolve_and_print() { zsh -c " source \"$REPO_ROOT/lib/core/utils.zsh\" source \"$REPO_ROOT/lib/core/registry.zsh\" - source \"$REPO_ROOT/lib/w/resolve-account.zsh\" - _w_resolve_account || exit \$? + source \"$REPO_ROOT/lib/worktree/resolve-account.zsh\" + _ckipper_worktree_resolve_account || exit \$? print -r -- \"\$$var_name\" " } -@test "_w_resolve_account populates W_ACTIVE_ACCOUNT from registry default" { +@test "_ckipper_worktree_resolve_account populates W_ACTIVE_ACCOUNT from registry default" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"'"$TMP_HOME"'/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-work" @@ -44,7 +44,7 @@ _resolve_and_print() { [ "$output" = "work" ] } -@test "_w_resolve_account populates W_ACTIVE_CONFIG_DIR from registry" { +@test "_ckipper_worktree_resolve_account populates W_ACTIVE_CONFIG_DIR from registry" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"'"$TMP_HOME"'/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-work" @@ -54,7 +54,7 @@ _resolve_and_print() { [ "$output" = "$TMP_HOME/.claude-work" ] } -@test "_w_resolve_account picks account by CLAUDE_CONFIG_DIR env match" { +@test "_ckipper_worktree_resolve_account picks account by CLAUDE_CONFIG_DIR env match" { echo '{"version":1,"default":null,"accounts":{"personal":{"config_dir":"'"$TMP_HOME"'/.claude-personal","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-personal" @@ -64,7 +64,7 @@ _resolve_and_print() { [ "$output" = "personal" ] } -@test "_w_resolve_account fails when no account can be resolved" { +@test "_ckipper_worktree_resolve_account fails when no account can be resolved" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" _resolve_and_print "W_ACTIVE_ACCOUNT" diff --git a/lib/w/worktree.zsh b/lib/worktree/worktree.zsh similarity index 88% rename from lib/w/worktree.zsh rename to lib/worktree/worktree.zsh index d6fb6b4..00734bf 100644 --- a/lib/w/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -6,7 +6,7 @@ readonly W_FIND_MAX_DEPTH=3 # Print all worktrees under $W_WORKTREES_DIR, grouped by project. # # Reads W_PROJECTS_DIR and W_WORKTREES_DIR globals. -_w_list_worktrees() { +_ckipper_worktree_list_worktrees() { echo "=== All Worktrees ===" [[ ! -d "$W_WORKTREES_DIR" ]] && return 0 @@ -21,7 +21,7 @@ _w_list_worktrees() { [[ "$after_first" == "$rel" ]] && continue local branch - IFS=$'\t' read -r project branch < <(_w_get_project_and_branch "$project" "$after_first") + IFS=$'\t' read -r project branch < <(_ckipper_worktree_get_project_and_branch "$project" "$after_first") if [[ "$project" != "$previous_project_for_grouping" ]]; then previous_project_for_grouping="$project" @@ -40,8 +40,8 @@ _w_list_worktrees() { # Returns: 0 always. Prints "\t" (tab-separated) to stdout. # # Caller usage: -# IFS=$'\t' read -r project branch < <(_w_get_project_and_branch "$proj" "$rest") -_w_get_project_and_branch() { +# IFS=$'\t' read -r project branch < <(_ckipper_worktree_get_project_and_branch "$proj" "$rest") +_ckipper_worktree_get_project_and_branch() { local initial_project="$1" local after_first="$2" @@ -67,7 +67,7 @@ _w_get_project_and_branch() { # "Usage: w --rm [--force] " — when project or worktree is empty # "Worktree not found: " — when the worktree directory does not exist # "Failed to remove worktree. Use --force if it has uncommitted changes." — on git error -_w_remove_worktree() { +_ckipper_worktree_remove_worktree() { local project="$1" local worktree="$2" @@ -90,7 +90,7 @@ _w_remove_worktree() { return 1 } - _w_cleanup_project_registry "$wt_path" + _ckipper_worktree_cleanup_project_registry "$wt_path" } # Remove a worktree path from the project registry if the cleanup script exists. @@ -99,7 +99,7 @@ _w_remove_worktree() { # $1 — absolute path to the worktree that was removed # # Returns: 0 always (failure is non-fatal). -_w_cleanup_project_registry() { +_ckipper_worktree_cleanup_project_registry() { local wt_path="$1" local ckipper_base_dir="${CKIPPER_DIR:-$HOME/.ckipper}" if [[ -f "$ckipper_base_dir/docker/cleanup-projects.py" ]]; then @@ -120,7 +120,7 @@ _w_cleanup_project_registry() { # Errors (stderr): # "Project not found: " — when the project directory does not exist # "Error: exists but is not a valid worktree." — when dir exists but lacks .git file -_w_create_worktree() { +_ckipper_worktree_create_worktree() { local project="$1" local worktree="$2" @@ -130,7 +130,7 @@ _w_create_worktree() { fi if [[ -d "$W_WORKTREES_DIR/$project/$worktree" ]]; then - _w_validate_existing_worktree "$project" "$worktree" || return 1 + _ckipper_worktree_validate_existing_worktree "$project" "$worktree" || return 1 W_WT_PATH="$W_WORKTREES_DIR/$project/$worktree" return 0 fi @@ -139,8 +139,8 @@ _w_create_worktree() { mkdir -p "$W_WORKTREES_DIR/$project" W_WT_PATH="$W_WORKTREES_DIR/$project/$worktree" - _w_fetch_and_create "$project" "$worktree" || return 1 - _w_post_create_setup "$project" "$worktree" || return 1 + _ckipper_worktree_fetch_and_create "$project" "$worktree" || return 1 + _ckipper_worktree_post_create_setup "$project" "$worktree" || return 1 } # Validate that an existing directory at the worktree path is a real worktree. @@ -152,7 +152,7 @@ _w_create_worktree() { # Returns: 0 if valid; 1 if the directory exists but has no .git file. # Errors (stderr): # "Error: exists but is not a valid worktree." — when .git file is missing -_w_validate_existing_worktree() { +_ckipper_worktree_validate_existing_worktree() { local project="$1" local worktree="$2" local wt_path="$W_WORKTREES_DIR/$project/$worktree" @@ -175,12 +175,12 @@ _w_validate_existing_worktree() { # "Failed to fetch from origin. ..." — when git fetch fails # "Failed: branch '' is currently checked out..." — when branch is already in use # "Failed to create worktree" — on other git worktree add failures -_w_fetch_and_create() { +_ckipper_worktree_fetch_and_create() { local project="$1" local worktree="$2" - _w_fetch_origin "$project" "$worktree" || return 1 - _w_add_worktree "$project" "$worktree" + _ckipper_worktree_fetch_origin "$project" "$worktree" || return 1 + _ckipper_worktree_add_worktree "$project" "$worktree" } # Fetch origin/develop and optionally origin/ for the project. @@ -192,7 +192,7 @@ _w_fetch_and_create() { # Returns: 0 on success; 1 if origin/develop fetch fails. # Errors (stderr): # "Failed to fetch from origin. Check your network connection..." — on fetch failure -_w_fetch_origin() { +_ckipper_worktree_fetch_origin() { local project="$1" local worktree="$2" @@ -213,7 +213,7 @@ _w_fetch_origin() { # Errors (stderr): # "Failed: branch '' is currently checked out..." — when branch is in use # "Failed to create worktree" — on other failures -_w_add_worktree() { +_ckipper_worktree_add_worktree() { local project="$1" local worktree="$2" @@ -228,7 +228,7 @@ _w_add_worktree() { echo "Creating new branch from origin/develop" git worktree add "$W_WT_PATH" -b "$worktree" origin/develop fi - ) || _w_handle_worktree_add_failure "$project" "$worktree" + ) || _ckipper_worktree_handle_worktree_add_failure "$project" "$worktree" } # Handle failure from git worktree add, printing a contextual error. @@ -241,7 +241,7 @@ _w_add_worktree() { # Errors (stderr): # "Failed: branch '' is currently checked out..." — when branch is in use # "Failed to create worktree" — on other failures -_w_handle_worktree_add_failure() { +_ckipper_worktree_handle_worktree_add_failure() { local project="$1" local worktree="$2" local current_branch @@ -264,7 +264,7 @@ _w_handle_worktree_add_failure() { # # Reads W_WT_PATH, W_ACTIVE_ACCOUNT globals. # Returns: 0 always (individual steps may warn on failure but don't abort). -_w_post_create_setup() { +_ckipper_worktree_post_create_setup() { local project="$1" echo "Installing dependencies..." @@ -278,7 +278,7 @@ _w_post_create_setup() { echo "Copied $rel_path" done - _w_sync_project_registry "$project" + _ckipper_worktree_sync_project_registry "$project" } # Sync the new worktree into the project registry. @@ -288,7 +288,7 @@ _w_post_create_setup() { # # Reads W_WT_PATH, W_ACTIVE_ACCOUNT globals. # Returns: 0 always (failure is non-fatal). -_w_sync_project_registry() { +_ckipper_worktree_sync_project_registry() { local project="$1" local main_project_path="$W_PROJECTS_DIR/$project" local ckipper_base_dir="${CKIPPER_DIR:-$HOME/.ckipper}" diff --git a/lib/w/worktree_test.bats b/lib/worktree/worktree_test.bats similarity index 68% rename from lib/w/worktree_test.bats rename to lib/worktree/worktree_test.bats index 7f5a3d8..f1661e3 100644 --- a/lib/w/worktree_test.bats +++ b/lib/worktree/worktree_test.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# Module-level tests for lib/w/worktree.zsh. +# Module-level tests for lib/worktree/worktree.zsh. # Covers list (empty), remove (missing path), and create (project not found). load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -30,27 +30,27 @@ _run_worktree() { zsh -c " source \"$REPO_ROOT/lib/core/utils.zsh\" source \"$REPO_ROOT/lib/core/registry.zsh\" - source \"$REPO_ROOT/lib/w/worktree.zsh\" + source \"$REPO_ROOT/lib/worktree/worktree.zsh\" $zsh_cmd " } -@test "_w_list_worktrees prints header and exits 0 when worktrees dir is empty" { - _run_worktree "_w_list_worktrees" +@test "_ckipper_worktree_list_worktrees prints header and exits 0 when worktrees dir is empty" { + _run_worktree "_ckipper_worktree_list_worktrees" [ "$status" -eq 0 ] [[ "$output" =~ "Worktrees" ]] } -@test "_w_remove_worktree fails when worktree path does not exist" { - _run_worktree "_w_remove_worktree myapp nonexistent-branch" +@test "_ckipper_worktree_remove_worktree fails when worktree path does not exist" { + _run_worktree "_ckipper_worktree_remove_worktree myapp nonexistent-branch" [ "$status" -ne 0 ] [[ "$output" =~ "not found" || "$output" =~ "Worktree" ]] } -@test "_w_create_worktree fails when project directory does not exist" { - _run_worktree "_w_create_worktree nosuchproject feature-x" +@test "_ckipper_worktree_create_worktree fails when project directory does not exist" { + _run_worktree "_ckipper_worktree_create_worktree nosuchproject feature-x" [ "$status" -ne 0 ] [[ "$output" =~ "not found" || "$output" =~ "Project" ]] diff --git a/w-function.zsh b/w-function.zsh index 81ebf02..e981eb2 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -26,18 +26,18 @@ W_REPO_DIR="${0:A:h}" source "$W_REPO_DIR/ckipper.zsh" -source "$W_REPO_DIR/lib/w/resolve-account.zsh" -source "$W_REPO_DIR/lib/w/build-image.zsh" -source "$W_REPO_DIR/lib/w/args.zsh" -source "$W_REPO_DIR/lib/w/worktree.zsh" -source "$W_REPO_DIR/lib/w/ports.zsh" -source "$W_REPO_DIR/lib/w/docker-mode.zsh" -source "$W_REPO_DIR/lib/w/normal-mode.zsh" +source "$W_REPO_DIR/lib/worktree/resolve-account.zsh" +source "$W_REPO_DIR/lib/worktree/build-image.zsh" +source "$W_REPO_DIR/lib/worktree/args.zsh" +source "$W_REPO_DIR/lib/worktree/worktree.zsh" +source "$W_REPO_DIR/lib/worktree/ports.zsh" +source "$W_REPO_DIR/lib/worktree/docker-mode.zsh" +source "$W_REPO_DIR/lib/worktree/normal-mode.zsh" # Source user config (projects/worktrees dirs, ports, extra volumes, extra env vars) -_w_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" -if [[ -f "$_w_config" ]]; then - source "$_w_config" +_ckipper_worktree_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" +if [[ -f "$_ckipper_worktree_config" ]]; then + source "$_ckipper_worktree_config" fi # Defaults if config is missing or incomplete. Set once at source time and # never reset per-call so users can host their projects anywhere without forking. @@ -60,30 +60,30 @@ W_WORKTREES_DIR="${W_WORKTREES_DIR:-$W_PROJECTS_DIR/.worktrees}" # Errors (stderr): # "Error: --firewall requires --docker" — when --firewall is passed without --docker. w() { - _w_parse_args "$@" + _ckipper_worktree_parse_args "$@" if [[ "$W_FLAG_LIST" = true ]]; then - _w_list_worktrees + _ckipper_worktree_list_worktrees elif [[ "$W_FLAG_REBUILD_IMAGE" = true ]]; then - _w_build_image + _ckipper_worktree_build_image return $? elif [[ "$W_FLAG_RM" = true ]]; then - _w_remove_worktree "$W_PROJECT" "$W_BRANCH" + _ckipper_worktree_remove_worktree "$W_PROJECT" "$W_BRANCH" return $? elif [[ -z "$W_PROJECT" || -z "$W_BRANCH" ]]; then - _w_usage + _ckipper_worktree_usage return 1 else if [[ "$W_FLAG_FIREWALL" = true && "$W_FLAG_DOCKER" = false ]]; then echo "Error: --firewall requires --docker" return 1 fi - _w_resolve_account || return $? - _w_create_worktree "$W_PROJECT" "$W_BRANCH" || return $? + _ckipper_worktree_resolve_account || return $? + _ckipper_worktree_create_worktree "$W_PROJECT" "$W_BRANCH" || return $? if [[ "$W_FLAG_DOCKER" = true ]]; then - _w_run_docker_mode + _ckipper_worktree_run_docker_mode else - _w_run_normal_mode + _ckipper_worktree_run_normal_mode fi fi } @@ -91,7 +91,7 @@ w() { # Print usage information for the w() command. # # Returns: 0 always. -_w_usage() { +_ckipper_worktree_usage() { echo "Usage: w [--docker [--firewall] [cmd...]]" echo " w [command...]" echo " w --list" From 9487573ca54e83987d387ac93ce191bbb6dba35e Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:04:03 -0600 Subject: [PATCH 076/165] Rename W_* globals to CKIPPER_* (config) and CKIPPER_WT_* (runtime) --- CHANGELOG.md | 2 +- README.md | 18 ++--- lib/worktree/args.zsh | 68 ++++++++--------- lib/worktree/args_test.bats | 16 ++-- lib/worktree/docker-mode.zsh | 102 ++++++++++++------------- lib/worktree/docker-mode_test.bats | 58 +++++++------- lib/worktree/normal-mode.zsh | 14 ++-- lib/worktree/normal-mode_test.bats | 12 +-- lib/worktree/ports.zsh | 18 ++--- lib/worktree/ports_test.bats | 8 +- lib/worktree/resolve-account.zsh | 20 ++--- lib/worktree/resolve-account_test.bats | 12 +-- lib/worktree/worktree.zsh | 90 +++++++++++----------- lib/worktree/worktree_test.bats | 16 ++-- templates/w-config.zsh.example | 16 ++-- w-function.zsh | 66 ++++++++-------- w-function_test.bats | 6 +- 17 files changed, 271 insertions(+), 271 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b5aa3..47f485c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ckipper migrate` subcommand (legacy `claude-docker-sandbox` migration). The legacy install path is no longer supported; use `ckipper add ` for fresh installs. ### Added -- `W_PROJECTS_DIR` and `W_WORKTREES_DIR` are now user-configurable via `w-config.zsh`. Defaults remain `$HOME/Developer` and `$W_PROJECTS_DIR/.worktrees`. Existing tab-completion files regenerate on next shell startup via a version sentinel. +- `CKIPPER_PROJECTS_DIR` and `CKIPPER_WORKTREES_DIR` are now user-configurable via `w-config.zsh`. Defaults remain `$HOME/Developer` and `$CKIPPER_PROJECTS_DIR/.worktrees`. Existing tab-completion files regenerate on next shell startup via a version sentinel. ### Changed - Repository layout: templates moved to `templates/` (`w-config.zsh.example`, `settings-template.json`); manual integration test prompt moved to `docs/test-prompt.md`. Source name `settings-hooks.json` renamed to `settings-template.json` to match the deployed name. diff --git a/README.md b/README.md index 5991db5..67970a9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ ckipper repair-plugins # fix stale ~/.claude/ path ckipper doctor # diagnostic checklist ``` -`ck` is a short alias for `ckipper`. `` is a relative path under `$W_PROJECTS_DIR` (default `~/Developer/`, e.g. `myorg/myapp`). Tab completion is included. See [Projects Directory](#projects-directory) to change the base path. +`ck` is a short alias for `ckipper`. `` is a relative path under `$CKIPPER_PROJECTS_DIR` (default `~/Developer/`, e.g. `myorg/myapp`). Tab completion is included. See [Projects Directory](#projects-directory) to change the base path. ## Multiple accounts @@ -189,7 +189,7 @@ Default whitelist: Anthropic API, GitHub, npm, PyPI, Sentry, and common MCP serv | MCPs with local files | node/uvx (mounted ro) | Yes (add mount) | | Docker-based MCPs | Docker-in-Docker | No (security) | -For MCPs that reference local files, add entries to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Mount at the exact same host path so MCP configs work unchanged. +For MCPs that reference local files, add entries to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Mount at the exact same host path so MCP configs work unchanged. Two named Docker volumes support uvx-based MCP servers: - **`claude-uv-cache`** — persists the uv package cache (downloaded wheels, git clones) across container restarts @@ -285,7 +285,7 @@ See `docs/test-prompt.md` for the full prompt and expected results table. ### Projects Directory -`w()` resolves project paths under `$W_PROJECTS_DIR` (default `$HOME/Developer`). To use a different location (e.g. `~/code`), set `W_PROJECTS_DIR` in `~/.ckipper/docker/w-config.zsh`. Worktrees default to `$W_PROJECTS_DIR/.worktrees`; override with `W_WORKTREES_DIR` if you want them elsewhere. +`w()` resolves project paths under `$CKIPPER_PROJECTS_DIR` (default `$HOME/Developer`). To use a different location (e.g. `~/code`), set `CKIPPER_PROJECTS_DIR` in `~/.ckipper/docker/w-config.zsh`. Worktrees default to `$CKIPPER_PROJECTS_DIR/.worktrees`; override with `CKIPPER_WORKTREES_DIR` if you want them elsewhere. ### Firewall Domains @@ -293,7 +293,7 @@ Edit `docker/init-firewall.sh` → `ALLOWED_DOMAINS` array, then `w --rebuild-im ### Forwarded Ports -Edit `W_PORTS` in `~/.ckipper/docker/w-config.zsh`. +Edit `CKIPPER_PORTS` in `~/.ckipper/docker/w-config.zsh`. ### Base Branch @@ -301,11 +301,11 @@ Worktrees are created from `origin/develop`. Search for `develop` in `w-function ### MCP Mounts -Add entries to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Format: `"host_path:container_path:mode"`. +Add entries to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Format: `"host_path:container_path:mode"`. ### Statusline -If you use a custom statusline (like [ccstatusline](https://github.com/sirmalloc/ccstatusline)), add the config and cache mounts to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`: +If you use a custom statusline (like [ccstatusline](https://github.com/sirmalloc/ccstatusline)), add the config and cache mounts to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`: - **Config mount** (`~/.config/ccstatusline`, read-only) — theme, widget layout, powerline settings - **Cache mount** (`~/.cache/ccstatusline`, read-write) — shares usage API cache with host to avoid 429 rate limits @@ -422,9 +422,9 @@ Despite docs saying every `~/.claude/...` path redirects under `CLAUDE_CONFIG_DI | `git commit` fails (no identity) | Entrypoint should set this automatically; check `.claude.json` has `oauthAccount` | | Native binary errors (Exec format) | Run `w --rebuild-image` — entrypoint runs `npm install` to fix platform binaries | | Turbo cache permission denied | Entrypoint sets `TURBO_CACHE_DIR`; run `w --rebuild-image` if missing | -| Branch already checked out | Switch main repo to different branch: `cd $W_PROJECTS_DIR/ && git checkout develop` | -| Stale worktree directory | Remove manually: `rm -rf $W_WORKTREES_DIR//` | -| Statusline not rendering correctly | Add ccstatusline mounts to `W_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`; ensure `bun` is in the image (`w --rebuild-image`) | +| Branch already checked out | Switch main repo to different branch: `cd $CKIPPER_PROJECTS_DIR/ && git checkout develop` | +| Stale worktree directory | Remove manually: `rm -rf $CKIPPER_WORKTREES_DIR//` | +| Statusline not rendering correctly | Add ccstatusline mounts to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`; ensure `bun` is in the image (`w --rebuild-image`) | | `git push` fails (SSH permission denied) | Ensure SSH keys are added to your agent (`ssh-add -l` to check); Docker Desktop forwards the host's SSH agent automatically | | GPG signing issues in container | Handled automatically via `GIT_CONFIG_COUNT` env vars; host config is not modified | | `.env.local` not copied to worktree | Fixed: worktree creation now copies all `.env*` files except `.env.example` | diff --git a/lib/worktree/args.zsh b/lib/worktree/args.zsh index 7a87a16..498c487 100644 --- a/lib/worktree/args.zsh +++ b/lib/worktree/args.zsh @@ -4,18 +4,18 @@ # Parse all arguments passed to w() into W_* globals. # # Globals set: -# W_FLAG_LIST — true if --list -# W_FLAG_REBUILD_IMAGE — true if --rebuild-image -# W_FLAG_RM — true if --rm -# W_FLAG_FORCE — true if --force (with --rm) -# W_FLAG_DOCKER — true if --docker -# W_FLAG_FIREWALL — true if --firewall -# W_PROJECT — first positional arg (project path) -# W_BRANCH — second positional arg (worktree/branch name) -# W_CLI_ACCOUNT — value of --account , or empty -# W_COMMAND — array: remaining positional args after project+branch +# CKIPPER_WT_FLAG_LIST — true if --list +# CKIPPER_WT_FLAG_REBUILD_IMAGE — true if --rebuild-image +# CKIPPER_WT_FLAG_RM — true if --rm +# CKIPPER_WT_FLAG_FORCE — true if --force (with --rm) +# CKIPPER_WT_FLAG_DOCKER — true if --docker +# CKIPPER_WT_FLAG_FIREWALL — true if --firewall +# CKIPPER_WT_PROJECT — first positional arg (project path) +# CKIPPER_WT_BRANCH — second positional arg (worktree/branch name) +# CKIPPER_WT_CLI_ACCOUNT — value of --account , or empty +# CKIPPER_WT_COMMAND — array: remaining positional args after project+branch # -# W_PROJECTS_DIR / W_WORKTREES_DIR are config values, not args — they are +# CKIPPER_PROJECTS_DIR / CKIPPER_WORKTREES_DIR are config values, not args — they are # initialized once when w-function.zsh is sourced and never reset here. # # Returns: 0 always (validation is done by the dispatcher). @@ -23,12 +23,12 @@ _ckipper_worktree_parse_args() { _ckipper_worktree_reset_globals if [[ "$1" == "--list" ]]; then - W_FLAG_LIST=true + CKIPPER_WT_FLAG_LIST=true return 0 fi if [[ "$1" == "--rebuild-image" ]]; then - W_FLAG_REBUILD_IMAGE=true + CKIPPER_WT_FLAG_REBUILD_IMAGE=true return 0 fi @@ -41,21 +41,21 @@ _ckipper_worktree_parse_args() { } # Reset per-call W_* arg globals (flags + positionals) to their default values. -# Config globals (W_PROJECTS_DIR, W_WORKTREES_DIR, W_PORTS, etc.) are owned +# Config globals (CKIPPER_PROJECTS_DIR, CKIPPER_WORKTREES_DIR, CKIPPER_PORTS, etc.) are owned # by w-function.zsh and intentionally not touched here. # # Returns: 0 always. _ckipper_worktree_reset_globals() { - W_FLAG_LIST=false - W_FLAG_REBUILD_IMAGE=false - W_FLAG_RM=false - W_FLAG_FORCE=false - W_FLAG_DOCKER=false - W_FLAG_FIREWALL=false - W_PROJECT="" - W_BRANCH="" - W_CLI_ACCOUNT="" - W_COMMAND=() + CKIPPER_WT_FLAG_LIST=false + CKIPPER_WT_FLAG_REBUILD_IMAGE=false + CKIPPER_WT_FLAG_RM=false + CKIPPER_WT_FLAG_FORCE=false + CKIPPER_WT_FLAG_DOCKER=false + CKIPPER_WT_FLAG_FIREWALL=false + CKIPPER_WT_PROJECT="" + CKIPPER_WT_BRANCH="" + CKIPPER_WT_CLI_ACCOUNT="" + CKIPPER_WT_COMMAND=() } # Parse --rm [--force] args. @@ -65,14 +65,14 @@ _ckipper_worktree_reset_globals() { # # Returns: 0 always. _ckipper_worktree_parse_rm_args() { - W_FLAG_RM=true + CKIPPER_WT_FLAG_RM=true shift if [[ "$1" == "--force" || "$1" == "-f" ]]; then - W_FLAG_FORCE=true + CKIPPER_WT_FLAG_FORCE=true shift fi - W_PROJECT="$1" - W_BRANCH="$2" + CKIPPER_WT_PROJECT="$1" + CKIPPER_WT_BRANCH="$2" } # Parse the normal run args: project branch [flags...] [command...]. @@ -82,16 +82,16 @@ _ckipper_worktree_parse_rm_args() { # # Returns: 0 always. _ckipper_worktree_parse_run_args() { - W_PROJECT="$1" - W_BRANCH="$2" + CKIPPER_WT_PROJECT="$1" + CKIPPER_WT_BRANCH="$2" shift 2 2>/dev/null while [[ $# -gt 0 ]]; do case "$1" in - --docker) W_FLAG_DOCKER=true; shift ;; - --firewall) W_FLAG_FIREWALL=true; shift ;; - --account) W_CLI_ACCOUNT="$2"; shift 2 ;; - *) W_COMMAND+=("$1"); shift ;; + --docker) CKIPPER_WT_FLAG_DOCKER=true; shift ;; + --firewall) CKIPPER_WT_FLAG_FIREWALL=true; shift ;; + --account) CKIPPER_WT_CLI_ACCOUNT="$2"; shift 2 ;; + *) CKIPPER_WT_COMMAND+=("$1"); shift ;; esac done } diff --git a/lib/worktree/args_test.bats b/lib/worktree/args_test.bats index fdab5c2..141cca6 100644 --- a/lib/worktree/args_test.bats +++ b/lib/worktree/args_test.bats @@ -20,29 +20,29 @@ _parse_and_print() { zsh -c "source \"$REPO_ROOT/lib/worktree/args.zsh\"; _ckipper_worktree_parse_args $*; print -r -- \"\$$var_name\"" } -@test "_ckipper_worktree_parse_args sets W_PROJECT and W_BRANCH for bare project/branch args" { - _parse_and_print "W_PROJECT" myapp feature-x +@test "_ckipper_worktree_parse_args sets CKIPPER_WT_PROJECT and CKIPPER_WT_BRANCH for bare project/branch args" { + _parse_and_print "CKIPPER_WT_PROJECT" myapp feature-x [ "$status" -eq 0 ] [ "$output" = "myapp" ] } -@test "_ckipper_worktree_parse_args sets W_FLAG_RM to true for --rm flag" { - _parse_and_print "W_FLAG_RM" --rm myapp branch +@test "_ckipper_worktree_parse_args sets CKIPPER_WT_FLAG_RM to true for --rm flag" { + _parse_and_print "CKIPPER_WT_FLAG_RM" --rm myapp branch [ "$status" -eq 0 ] [ "$output" = "true" ] } -@test "_ckipper_worktree_parse_args sets W_FLAG_DOCKER to true for --docker flag" { - _parse_and_print "W_FLAG_DOCKER" myapp feature-x --docker +@test "_ckipper_worktree_parse_args sets CKIPPER_WT_FLAG_DOCKER to true for --docker flag" { + _parse_and_print "CKIPPER_WT_FLAG_DOCKER" myapp feature-x --docker [ "$status" -eq 0 ] [ "$output" = "true" ] } -@test "_ckipper_worktree_parse_args sets W_FLAG_LIST to true for --list flag" { - _parse_and_print "W_FLAG_LIST" --list +@test "_ckipper_worktree_parse_args sets CKIPPER_WT_FLAG_LIST to true for --list flag" { + _parse_and_print "CKIPPER_WT_FLAG_LIST" --list [ "$status" -eq 0 ] [ "$output" = "true" ] diff --git a/lib/worktree/docker-mode.zsh b/lib/worktree/docker-mode.zsh index fd66fe4..16f637d 100644 --- a/lib/worktree/docker-mode.zsh +++ b/lib/worktree/docker-mode.zsh @@ -17,14 +17,14 @@ readonly DOCKER_GROUP_ADD_HOST_ROOT=0 # Run the worktree in a Docker container. # -# Reads globals: W_WT_PATH, W_PROJECTS_DIR, W_PROJECT, W_BRANCH, W_COMMAND, -# W_FLAG_FIREWALL, W_ACTIVE_ACCOUNT, W_ACTIVE_CONFIG_DIR, -# W_ACTIVE_KEYCHAIN_SERVICE, W_PORTS, W_EXTRA_VOLUMES, W_EXTRA_ENV. +# Reads globals: CKIPPER_WT_WT_PATH, CKIPPER_PROJECTS_DIR, CKIPPER_WT_PROJECT, CKIPPER_WT_BRANCH, CKIPPER_WT_COMMAND, +# CKIPPER_WT_FLAG_FIREWALL, CKIPPER_WT_ACTIVE_ACCOUNT, CKIPPER_WT_ACTIVE_CONFIG_DIR, +# CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE, CKIPPER_PORTS, CKIPPER_EXTRA_VOLUMES, CKIPPER_EXTRA_ENV. # Returns: exit code of the docker run invocation. _ckipper_worktree_run_docker_mode() { _ckipper_worktree_docker_check_prerequisites || return 1 - [[ -f "$W_ACTIVE_CONFIG_DIR/.claude.json" ]] || echo '{}' > "$W_ACTIVE_CONFIG_DIR/.claude.json" + [[ -f "$CKIPPER_WT_ACTIVE_CONFIG_DIR/.claude.json" ]] || echo '{}' > "$CKIPPER_WT_ACTIVE_CONFIG_DIR/.claude.json" _ckipper_worktree_docker_validate_keychain || return 1 @@ -32,13 +32,13 @@ _ckipper_worktree_run_docker_mode() { claude_creds=$(_ckipper_worktree_docker_extract_credentials) || return 1 gh_token=$(_ckipper_worktree_docker_extract_gh_token) - local -a W_DOCKER_ARGS + local -a CKIPPER_WT_DOCKER_ARGS _ckipper_worktree_docker_build_base_args _ckipper_worktree_docker_add_optional_args "$claude_creds" "$gh_token" _ckipper_worktree_resolve_ports - [[ "$W_FLAG_FIREWALL" = true ]] && W_DOCKER_ARGS+=( --cap-add=NET_ADMIN -e ENABLE_FIREWALL=1 ) + [[ "$CKIPPER_WT_FLAG_FIREWALL" = true ]] && CKIPPER_WT_DOCKER_ARGS+=( --cap-add=NET_ADMIN -e ENABLE_FIREWALL=1 ) - W_DOCKER_ARGS+=( ckipper-dev ) + CKIPPER_WT_DOCKER_ARGS+=( ckipper-dev ) _ckipper_worktree_docker_expand_command _ckipper_worktree_docker_print_banner @@ -67,34 +67,34 @@ _ckipper_worktree_docker_check_prerequisites() { # Validate the active account's keychain service name if set. # -# Reads: W_ACTIVE_KEYCHAIN_SERVICE, W_ACTIVE_ACCOUNT globals. +# Reads: CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE, CKIPPER_WT_ACTIVE_ACCOUNT globals. # Returns: 0 if valid or no keychain service is configured; 1 on invalid service. # Errors (stderr): # "Error: account '' has invalid keychain_service in registry." — on validation failure _ckipper_worktree_docker_validate_keychain() { - if [[ -n "$W_ACTIVE_KEYCHAIN_SERVICE" ]] && \ - ! _core_keychain_validate "$W_ACTIVE_KEYCHAIN_SERVICE"; then - echo "Error: account '$W_ACTIVE_ACCOUNT' has invalid keychain_service in registry." - echo "Re-register with: ckipper remove $W_ACTIVE_ACCOUNT && ckipper add $W_ACTIVE_ACCOUNT --adopt" + if [[ -n "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE" ]] && \ + ! _core_keychain_validate "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE"; then + echo "Error: account '$CKIPPER_WT_ACTIVE_ACCOUNT' has invalid keychain_service in registry." + echo "Re-register with: ckipper remove $CKIPPER_WT_ACTIVE_ACCOUNT && ckipper add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" return 1 fi } # Extract Claude credentials from macOS Keychain and validate they are valid JSON. # -# Reads: W_ACTIVE_KEYCHAIN_SERVICE global. +# Reads: CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE global. # Returns: 0 on success (prints credentials to stdout, or empty string if no service). # 1 if credentials are present but not valid JSON. # Errors (stderr): # "Error: Claude credentials from Keychain are not valid JSON. ..." — on invalid JSON _ckipper_worktree_docker_extract_credentials() { - if [[ -z "$W_ACTIVE_KEYCHAIN_SERVICE" ]]; then + if [[ -z "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE" ]]; then echo "" return 0 fi local creds - creds=$(security find-generic-password -s "$W_ACTIVE_KEYCHAIN_SERVICE" -w 2>/dev/null) || true + creds=$(security find-generic-password -s "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE" -w 2>/dev/null) || true if [[ -z "$creds" ]]; then echo "" @@ -102,7 +102,7 @@ _ckipper_worktree_docker_extract_credentials() { fi if ! echo "$creds" | jq empty >/dev/null 2>&1; then - echo "Error: Claude credentials from Keychain are not valid JSON. Re-run: ckipper add $W_ACTIVE_ACCOUNT --adopt" >&2 + echo "Error: Claude credentials from Keychain are not valid JSON. Re-run: ckipper add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" >&2 return 1 fi @@ -111,32 +111,32 @@ _ckipper_worktree_docker_extract_credentials() { # Extract GitHub token for gh CLI auth inside container (prints to stdout). # -# Reads: W_ACTIVE_CONFIG_DIR global. +# Reads: CKIPPER_WT_ACTIVE_CONFIG_DIR global. # Returns: 0 always (prints empty string if no token found). _ckipper_worktree_docker_extract_gh_token() { local token token=$(jq -r '.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN // empty' \ - "$W_ACTIVE_CONFIG_DIR/.claude.json" 2>/dev/null) || true + "$CKIPPER_WT_ACTIVE_CONFIG_DIR/.claude.json" 2>/dev/null) || true if [[ -z "$token" ]] && command -v gh &>/dev/null; then token=$(gh auth token 2>/dev/null) || true fi echo "$token" } -# Build the base docker run argument array into W_DOCKER_ARGS. +# Build the base docker run argument array into CKIPPER_WT_DOCKER_ARGS. # -# Reads: W_WT_PATH, W_PROJECTS_DIR, W_PROJECT, W_ACTIVE_CONFIG_DIR, -# W_EXTRA_VOLUMES globals. -# Sets: W_DOCKER_ARGS (initialised from scratch). +# Reads: CKIPPER_WT_WT_PATH, CKIPPER_PROJECTS_DIR, CKIPPER_WT_PROJECT, CKIPPER_WT_ACTIVE_CONFIG_DIR, +# CKIPPER_EXTRA_VOLUMES globals. +# Sets: CKIPPER_WT_DOCKER_ARGS (initialised from scratch). # Returns: 0 always. _ckipper_worktree_docker_build_base_args() { - W_DOCKER_ARGS=( + CKIPPER_WT_DOCKER_ARGS=( docker run --rm -it -e TERM="${TERM:-xterm-256color}" - -v "$W_WT_PATH:/workspace:rw" - -v "$W_PROJECTS_DIR/$W_PROJECT/.git:$W_PROJECTS_DIR/$W_PROJECT/.git:rw" - -v "$W_ACTIVE_CONFIG_DIR:$W_ACTIVE_CONFIG_DIR:rw" - -e "CLAUDE_CONFIG_DIR=$W_ACTIVE_CONFIG_DIR" + -v "$CKIPPER_WT_WT_PATH:/workspace:rw" + -v "$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git:$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git:rw" + -v "$CKIPPER_WT_ACTIVE_CONFIG_DIR:$CKIPPER_WT_ACTIVE_CONFIG_DIR:rw" + -e "CLAUDE_CONFIG_DIR=$CKIPPER_WT_ACTIVE_CONFIG_DIR" -v "$HOME/.ssh:/home/claude/.ssh-host:ro" -v /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock -e SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock @@ -148,82 +148,82 @@ _ckipper_worktree_docker_build_base_args() { -e "UV_TOOL_BIN_DIR=/home/claude/.uv-tools/bin" -e "UV_PYTHON_INSTALL_DIR=/home/claude/.uv-tools/python" ) - for vol in "${W_EXTRA_VOLUMES[@]}"; do - W_DOCKER_ARGS+=( -v "$vol" ) + for vol in "${CKIPPER_EXTRA_VOLUMES[@]}"; do + CKIPPER_WT_DOCKER_ARGS+=( -v "$vol" ) done } -# Add credentials, gh token, and extra env vars to W_DOCKER_ARGS. +# Add credentials, gh token, and extra env vars to CKIPPER_WT_DOCKER_ARGS. # # Args: # $1 — claude_creds: Claude credentials string (may be empty) # $2 — gh_token: GitHub personal access token (may be empty) # -# Reads: W_EXTRA_ENV global. Appends to W_DOCKER_ARGS. +# Reads: CKIPPER_EXTRA_ENV global. Appends to CKIPPER_WT_DOCKER_ARGS. # Returns: 0 always. _ckipper_worktree_docker_add_optional_args() { local claude_creds="$1" local gh_token="$2" if [[ -n "$claude_creds" ]]; then - W_DOCKER_ARGS+=( -e "CLAUDE_CREDENTIALS=$claude_creds" ) + CKIPPER_WT_DOCKER_ARGS+=( -e "CLAUDE_CREDENTIALS=$claude_creds" ) else echo " Warning: Could not extract Claude credentials from Keychain" fi if [[ -n "$gh_token" ]]; then - W_DOCKER_ARGS+=( -e "GH_TOKEN=$gh_token" ) + CKIPPER_WT_DOCKER_ARGS+=( -e "GH_TOKEN=$gh_token" ) else echo " Warning: No GitHub token found (gh commands won't work in container)" fi - for env_var in "${W_EXTRA_ENV[@]}"; do - W_DOCKER_ARGS+=( -e "$env_var" ) + for env_var in "${CKIPPER_EXTRA_ENV[@]}"; do + CKIPPER_WT_DOCKER_ARGS+=( -e "$env_var" ) done } # If the command is "claude", expand to full skip-permissions invocation. # -# Reads and appends to W_COMMAND and W_DOCKER_ARGS. +# Reads and appends to CKIPPER_WT_COMMAND and CKIPPER_WT_DOCKER_ARGS. # Returns: 0 always. _ckipper_worktree_docker_expand_command() { - if [[ ${#W_COMMAND[@]} -gt 0 && "${W_COMMAND[1]}" == "claude" ]]; then - W_COMMAND=(claude --dangerously-skip-permissions "/rename $W_BRANCH") + if [[ ${#CKIPPER_WT_COMMAND[@]} -gt 0 && "${CKIPPER_WT_COMMAND[1]}" == "claude" ]]; then + CKIPPER_WT_COMMAND=(claude --dangerously-skip-permissions "/rename $CKIPPER_WT_BRANCH") fi - if [[ ${#W_COMMAND[@]} -gt 0 ]]; then - W_DOCKER_ARGS+=( "${W_COMMAND[@]}" ) + if [[ ${#CKIPPER_WT_COMMAND[@]} -gt 0 ]]; then + CKIPPER_WT_DOCKER_ARGS+=( "${CKIPPER_WT_COMMAND[@]}" ) fi } # Print the startup banner. # -# Reads: W_COMMAND, W_FLAG_FIREWALL, W_WT_PATH, W_RESOLVED_PORTS globals. +# Reads: CKIPPER_WT_COMMAND, CKIPPER_WT_FLAG_FIREWALL, CKIPPER_WT_WT_PATH, CKIPPER_WT_RESOLVED_PORTS globals. # Returns: 0 always. _ckipper_worktree_docker_print_banner() { local mode_label="Docker" - [[ ${#W_COMMAND[@]} -gt 0 ]] && mode_label+=": ${W_COMMAND[1]}" - [[ "$W_FLAG_FIREWALL" = true ]] && mode_label+=", firewall" + [[ ${#CKIPPER_WT_COMMAND[@]} -gt 0 ]] && mode_label+=": ${CKIPPER_WT_COMMAND[1]}" + [[ "$CKIPPER_WT_FLAG_FIREWALL" = true ]] && mode_label+=", firewall" echo "Starting $mode_label..." - echo " Worktree: $W_WT_PATH" - echo " Ports: ${W_RESOLVED_PORTS[*]}" + echo " Worktree: $CKIPPER_WT_WT_PATH" + echo " Ports: ${CKIPPER_WT_RESOLVED_PORTS[*]}" } # Snapshot git state, run docker, then check for post-session tampering. # -# Reads: W_DOCKER_ARGS, W_PROJECTS_DIR, W_PROJECT globals. +# Reads: CKIPPER_WT_DOCKER_ARGS, CKIPPER_PROJECTS_DIR, CKIPPER_WT_PROJECT globals. # Returns: exit code of the docker run invocation. _ckipper_worktree_docker_snapshot_and_run() { - local git_config="$W_PROJECTS_DIR/$W_PROJECT/.git/config" + local git_config="$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git/config" local git_config_hash="" [[ -f "$git_config" ]] && git_config_hash=$(shasum -a "$SHASUM_BITS" "$git_config" | cut -d' ' -f1) - local git_worktrees_dir="$W_PROJECTS_DIR/$W_PROJECT/.git/worktrees" + local git_worktrees_dir="$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git/worktrees" local -a worktrees_before=() if [[ -d "$git_worktrees_dir" ]]; then worktrees_before=( "$git_worktrees_dir"/*(N/:t) ) fi - "${W_DOCKER_ARGS[@]}" + "${CKIPPER_WT_DOCKER_ARGS[@]}" local exit_code=$? _ckipper_worktree_docker_check_git_config_tampering "$git_config" "$git_config_hash" @@ -248,7 +248,7 @@ _ckipper_worktree_docker_check_git_config_tampering() { if [[ "$original_hash" != "$new_hash" ]]; then echo "" echo "WARNING: .git/config was modified during the Docker session!" - echo "Review changes: git -C $W_PROJECTS_DIR/$W_PROJECT config --local --list" + echo "Review changes: git -C $CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT config --local --list" fi fi } @@ -286,7 +286,7 @@ _ckipper_worktree_docker_check_worktree_destruction() { echo "" echo "The working directories still exist on disk — only the .git/worktrees/ metadata was deleted." echo "To recover, re-register each worktree:" - echo " cd $W_PROJECTS_DIR/$W_PROJECT" + echo " cd $CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT" for wt in "${missing[@]}"; do echo " git worktree add $wt" done diff --git a/lib/worktree/docker-mode_test.bats b/lib/worktree/docker-mode_test.bats index d4304ac..fe678f5 100644 --- a/lib/worktree/docker-mode_test.bats +++ b/lib/worktree/docker-mode_test.bats @@ -11,19 +11,19 @@ setup() { export DOCKER_STUB_LOG="$TMP_HOME/docker.log" : > "$DOCKER_STUB_LOG" # Provide reasonable defaults for globals docker-mode reads. - export W_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" - export W_PROJECTS_DIR="$TMP_HOME/Developer" - export W_PROJECT="myapp" - export W_BRANCH="feature-x" - export W_ACTIVE_ACCOUNT="test" - export W_ACTIVE_CONFIG_DIR="$TMP_HOME/.claude-test" - export W_ACTIVE_KEYCHAIN_SERVICE="" - export W_EXTRA_VOLUMES=() - export W_EXTRA_ENV=() - export W_FLAG_FIREWALL=false - export W_PORTS=() - export W_COMMAND=() - mkdir -p "$W_ACTIVE_CONFIG_DIR" + export CKIPPER_WT_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" + export CKIPPER_PROJECTS_DIR="$TMP_HOME/Developer" + export CKIPPER_WT_PROJECT="myapp" + export CKIPPER_WT_BRANCH="feature-x" + export CKIPPER_WT_ACTIVE_ACCOUNT="test" + export CKIPPER_WT_ACTIVE_CONFIG_DIR="$TMP_HOME/.claude-test" + export CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE="" + export CKIPPER_EXTRA_VOLUMES=() + export CKIPPER_EXTRA_ENV=() + export CKIPPER_WT_FLAG_FIREWALL=false + export CKIPPER_PORTS=() + export CKIPPER_WT_COMMAND=() + mkdir -p "$CKIPPER_WT_ACTIVE_CONFIG_DIR" } teardown() { @@ -38,21 +38,21 @@ _run_docker_mode() { CKIPPER_DIR="$CKIPPER_DIR" \ CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ DOCKER_STUB_LOG="$DOCKER_STUB_LOG" \ - W_WT_PATH="$W_WT_PATH" \ - W_PROJECTS_DIR="$W_PROJECTS_DIR" \ - W_PROJECT="$W_PROJECT" \ - W_BRANCH="$W_BRANCH" \ - W_ACTIVE_ACCOUNT="$W_ACTIVE_ACCOUNT" \ - W_ACTIVE_CONFIG_DIR="$W_ACTIVE_CONFIG_DIR" \ - W_ACTIVE_KEYCHAIN_SERVICE="$W_ACTIVE_KEYCHAIN_SERVICE" \ - W_FLAG_FIREWALL="$W_FLAG_FIREWALL" \ + CKIPPER_WT_WT_PATH="$CKIPPER_WT_WT_PATH" \ + CKIPPER_PROJECTS_DIR="$CKIPPER_PROJECTS_DIR" \ + CKIPPER_WT_PROJECT="$CKIPPER_WT_PROJECT" \ + CKIPPER_WT_BRANCH="$CKIPPER_WT_BRANCH" \ + CKIPPER_WT_ACTIVE_ACCOUNT="$CKIPPER_WT_ACTIVE_ACCOUNT" \ + CKIPPER_WT_ACTIVE_CONFIG_DIR="$CKIPPER_WT_ACTIVE_CONFIG_DIR" \ + CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE="$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE" \ + CKIPPER_WT_FLAG_FIREWALL="$CKIPPER_WT_FLAG_FIREWALL" \ PATH="$PATH" \ zsh -c " - typeset -a W_DOCKER_ARGS=() - typeset -a W_EXTRA_VOLUMES=() - typeset -a W_EXTRA_ENV=() - typeset -a W_PORTS=() - typeset -a W_COMMAND=() + typeset -a CKIPPER_WT_DOCKER_ARGS=() + typeset -a CKIPPER_EXTRA_VOLUMES=() + typeset -a CKIPPER_EXTRA_ENV=() + typeset -a CKIPPER_PORTS=() + typeset -a CKIPPER_WT_COMMAND=() source \"$REPO_ROOT/lib/core/utils.zsh\" source \"$REPO_ROOT/lib/core/keychain.zsh\" source \"$REPO_ROOT/lib/core/registry.zsh\" @@ -64,11 +64,11 @@ _run_docker_mode() { } @test "_ckipper_worktree_docker_build_base_args includes the worktree volume mount" { - _run_docker_mode "_ckipper_worktree_docker_build_base_args; print -r -- \"\${W_DOCKER_ARGS[*]}\"" + _run_docker_mode "_ckipper_worktree_docker_build_base_args; print -r -- \"\${CKIPPER_WT_DOCKER_ARGS[*]}\"" [ "$status" -eq 0 ] [[ "$output" =~ "-v" ]] - [[ "$output" =~ "$W_WT_PATH:/workspace:rw" ]] + [[ "$output" =~ "$CKIPPER_WT_WT_PATH:/workspace:rw" ]] } @test "_ckipper_worktree_docker_add_optional_args emits a warning when no credentials are provided" { @@ -79,7 +79,7 @@ _run_docker_mode() { } @test "_ckipper_worktree_docker_extract_credentials returns empty string when no keychain service is set" { - export W_ACTIVE_KEYCHAIN_SERVICE="" + export CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE="" _run_docker_mode "result=\$(_ckipper_worktree_docker_extract_credentials); print -r -- \"result=\$result\"" diff --git a/lib/worktree/normal-mode.zsh b/lib/worktree/normal-mode.zsh index 029bf99..112d41d 100644 --- a/lib/worktree/normal-mode.zsh +++ b/lib/worktree/normal-mode.zsh @@ -3,21 +3,21 @@ # Run the worktree in normal mode: cd to it or run a command inside it. # -# Reads globals: W_WT_PATH, W_BRANCH, W_COMMAND. +# Reads globals: CKIPPER_WT_WT_PATH, CKIPPER_WT_BRANCH, CKIPPER_WT_COMMAND. # Returns: 0 on cd; exit code of command if one was given. _ckipper_worktree_run_normal_mode() { - if [[ ${#W_COMMAND[@]} -eq 0 ]]; then - cd "$W_WT_PATH" + if [[ ${#CKIPPER_WT_COMMAND[@]} -eq 0 ]]; then + cd "$CKIPPER_WT_WT_PATH" return 0 fi - if [[ "${W_COMMAND[1]}" == "claude" ]]; then - W_COMMAND+=("/rename $W_BRANCH") + if [[ "${CKIPPER_WT_COMMAND[1]}" == "claude" ]]; then + CKIPPER_WT_COMMAND+=("/rename $CKIPPER_WT_BRANCH") fi local old_pwd="$PWD" - cd "$W_WT_PATH" - "${W_COMMAND[@]}" + cd "$CKIPPER_WT_WT_PATH" + "${CKIPPER_WT_COMMAND[@]}" local exit_code=$? cd "$old_pwd" return $exit_code diff --git a/lib/worktree/normal-mode_test.bats b/lib/worktree/normal-mode_test.bats index ada010c..df2bfe3 100644 --- a/lib/worktree/normal-mode_test.bats +++ b/lib/worktree/normal-mode_test.bats @@ -6,9 +6,9 @@ load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" setup() { setup_isolated_env - export W_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" - export W_BRANCH="feature-x" - mkdir -p "$W_WT_PATH" + export CKIPPER_WT_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" + export CKIPPER_WT_BRANCH="feature-x" + mkdir -p "$CKIPPER_WT_WT_PATH" } teardown() { @@ -17,11 +17,11 @@ teardown() { @test "_ckipper_worktree_run_normal_mode executes a stub claude binary in the worktree directory" { run env HOME="$TMP_HOME" \ - W_WT_PATH="$W_WT_PATH" \ - W_BRANCH="$W_BRANCH" \ + CKIPPER_WT_WT_PATH="$CKIPPER_WT_WT_PATH" \ + CKIPPER_WT_BRANCH="$CKIPPER_WT_BRANCH" \ PATH="$PATH" \ zsh -c " - typeset -a W_COMMAND=(claude) + typeset -a CKIPPER_WT_COMMAND=(claude) source \"$REPO_ROOT/lib/worktree/normal-mode.zsh\" _ckipper_worktree_run_normal_mode " diff --git a/lib/worktree/ports.zsh b/lib/worktree/ports.zsh index 57123af..047d3b2 100644 --- a/lib/worktree/ports.zsh +++ b/lib/worktree/ports.zsh @@ -4,14 +4,14 @@ readonly MAX_PORT_FALLBACK_ATTEMPTS=10 # Resolve port mappings for Docker, using fallback host ports when the -# preferred port is already in use. Appends -p flags to the W_DOCKER_ARGS array. +# preferred port is already in use. Appends -p flags to the CKIPPER_WT_DOCKER_ARGS array. # -# Reads: W_PORTS (array of container ports), W_DOCKER_ARGS (appended to). -# Sets: W_RESOLVED_PORTS (array of ports for display). +# Reads: CKIPPER_PORTS (array of container ports), CKIPPER_WT_DOCKER_ARGS (appended to). +# Sets: CKIPPER_WT_RESOLVED_PORTS (array of ports for display). _ckipper_worktree_resolve_ports() { - W_RESOLVED_PORTS=("${W_PORTS[@]}") + CKIPPER_WT_RESOLVED_PORTS=("${CKIPPER_PORTS[@]}") - for port in "${W_PORTS[@]}"; do + for port in "${CKIPPER_PORTS[@]}"; do _ckipper_worktree_bind_port "$port" done } @@ -21,7 +21,7 @@ _ckipper_worktree_resolve_ports() { # Args: # $1 — container port number to bind # -# Reads: W_DOCKER_ARGS (appended to). +# Reads: CKIPPER_WT_DOCKER_ARGS (appended to). # Returns: 0 always (logs a warning if no port could be bound). _ckipper_worktree_bind_port() { local port="$1" @@ -42,18 +42,18 @@ _ckipper_worktree_bind_port() { fi } -# Append the resolved -p flag to W_DOCKER_ARGS and log if the host port differs. +# Append the resolved -p flag to CKIPPER_WT_DOCKER_ARGS and log if the host port differs. # # Args: # $1 — original container port # $2 — host port that was bound (may equal $1 or be a fallback) # -# Reads: W_DOCKER_ARGS (appended to). +# Reads: CKIPPER_WT_DOCKER_ARGS (appended to). # Returns: 0 always. _ckipper_worktree_record_bound_port() { local port="$1" local host_port="$2" - W_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) + CKIPPER_WT_DOCKER_ARGS+=( -p "127.0.0.1:$host_port:$port" ) (( host_port != port )) && echo " Port $port mapped to host:$host_port (original in use)" return 0 } diff --git a/lib/worktree/ports_test.bats b/lib/worktree/ports_test.bats index 66f8616..56420ac 100644 --- a/lib/worktree/ports_test.bats +++ b/lib/worktree/ports_test.bats @@ -21,14 +21,14 @@ _run_ports() { LSOF_STUB_BUSY="${LSOF_STUB_BUSY:-}" \ zsh -c " source \"$REPO_ROOT/lib/worktree/ports.zsh\" - typeset -a W_DOCKER_ARGS=() - typeset -a W_RESOLVED_PORTS=() + typeset -a CKIPPER_WT_DOCKER_ARGS=() + typeset -a CKIPPER_WT_RESOLVED_PORTS=() $zsh_cmd " } -@test "_ckipper_worktree_bind_port appends a -p flag to W_DOCKER_ARGS when port is free" { - _run_ports '_ckipper_worktree_bind_port 3000; print -r -- "${W_DOCKER_ARGS[*]}"' +@test "_ckipper_worktree_bind_port appends a -p flag to CKIPPER_WT_DOCKER_ARGS when port is free" { + _run_ports '_ckipper_worktree_bind_port 3000; print -r -- "${CKIPPER_WT_DOCKER_ARGS[*]}"' [ "$status" -eq 0 ] [[ "$output" =~ "-p 127.0.0.1:3000:3000" ]] diff --git a/lib/worktree/resolve-account.zsh b/lib/worktree/resolve-account.zsh index 6c708ad..b658063 100644 --- a/lib/worktree/resolve-account.zsh +++ b/lib/worktree/resolve-account.zsh @@ -1,13 +1,13 @@ #!/usr/bin/env zsh -# Account resolution for w(). Populates W_ACTIVE_* globals. +# Account resolution for w(). Populates CKIPPER_WT_ACTIVE_* globals. # Resolve which ckipper account to use, then populate: -# W_ACTIVE_ACCOUNT — resolved account name -# W_ACTIVE_CONFIG_DIR — account's claude config directory -# W_ACTIVE_KEYCHAIN_SERVICE — account's keychain service name (may be empty) +# CKIPPER_WT_ACTIVE_ACCOUNT — resolved account name +# CKIPPER_WT_ACTIVE_CONFIG_DIR — account's claude config directory +# CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE — account's keychain service name (may be empty) # # Resolution order: -# 1. W_CLI_ACCOUNT (from --account flag) +# 1. CKIPPER_WT_CLI_ACCOUNT (from --account flag) # 2. Account whose config_dir matches $CLAUDE_CONFIG_DIR (if set) # 3. Registry default # @@ -36,9 +36,9 @@ _ckipper_worktree_resolve_account() { return 1 fi - W_ACTIVE_ACCOUNT="$candidate" - W_ACTIVE_CONFIG_DIR="$config_dir" - W_ACTIVE_KEYCHAIN_SERVICE="$keychain_service" + CKIPPER_WT_ACTIVE_ACCOUNT="$candidate" + CKIPPER_WT_ACTIVE_CONFIG_DIR="$config_dir" + CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE="$keychain_service" } # Return the account name to use, without side effects. @@ -47,8 +47,8 @@ _ckipper_worktree_resolve_account() { # # Returns: 0 always (prints account name to stdout, or empty string if none found). _ckipper_worktree_find_account_name() { - if [[ -n "$W_CLI_ACCOUNT" ]]; then - echo "$W_CLI_ACCOUNT" + if [[ -n "$CKIPPER_WT_CLI_ACCOUNT" ]]; then + echo "$CKIPPER_WT_CLI_ACCOUNT" return 0 fi diff --git a/lib/worktree/resolve-account_test.bats b/lib/worktree/resolve-account_test.bats index ed73da8..d79b260 100644 --- a/lib/worktree/resolve-account_test.bats +++ b/lib/worktree/resolve-account_test.bats @@ -34,21 +34,21 @@ _resolve_and_print() { " } -@test "_ckipper_worktree_resolve_account populates W_ACTIVE_ACCOUNT from registry default" { +@test "_ckipper_worktree_resolve_account populates CKIPPER_WT_ACTIVE_ACCOUNT from registry default" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"'"$TMP_HOME"'/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-work" - _resolve_and_print "W_ACTIVE_ACCOUNT" + _resolve_and_print "CKIPPER_WT_ACTIVE_ACCOUNT" [ "$status" -eq 0 ] [ "$output" = "work" ] } -@test "_ckipper_worktree_resolve_account populates W_ACTIVE_CONFIG_DIR from registry" { +@test "_ckipper_worktree_resolve_account populates CKIPPER_WT_ACTIVE_CONFIG_DIR from registry" { echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"'"$TMP_HOME"'/.claude-work","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-work" - _resolve_and_print "W_ACTIVE_CONFIG_DIR" + _resolve_and_print "CKIPPER_WT_ACTIVE_CONFIG_DIR" [ "$status" -eq 0 ] [ "$output" = "$TMP_HOME/.claude-work" ] @@ -58,7 +58,7 @@ _resolve_and_print() { echo '{"version":1,"default":null,"accounts":{"personal":{"config_dir":"'"$TMP_HOME"'/.claude-personal","keychain_service":null}}}' > "$CKIPPER_REGISTRY" mkdir -p "$TMP_HOME/.claude-personal" - _resolve_and_print "W_ACTIVE_ACCOUNT" "CLAUDE_CONFIG_DIR=$TMP_HOME/.claude-personal" + _resolve_and_print "CKIPPER_WT_ACTIVE_ACCOUNT" "CLAUDE_CONFIG_DIR=$TMP_HOME/.claude-personal" [ "$status" -eq 0 ] [ "$output" = "personal" ] @@ -67,7 +67,7 @@ _resolve_and_print() { @test "_ckipper_worktree_resolve_account fails when no account can be resolved" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - _resolve_and_print "W_ACTIVE_ACCOUNT" + _resolve_and_print "CKIPPER_WT_ACTIVE_ACCOUNT" [ "$status" -ne 0 ] [[ "$output" =~ "no account" || "$output" =~ "no default" ]] diff --git a/lib/worktree/worktree.zsh b/lib/worktree/worktree.zsh index 00734bf..99e2bb4 100644 --- a/lib/worktree/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -1,21 +1,21 @@ #!/usr/bin/env zsh # Worktree list, remove, and create operations for w(). -readonly W_FIND_MAX_DEPTH=3 +readonly CKIPPER_WT_FIND_MAX_DEPTH=3 -# Print all worktrees under $W_WORKTREES_DIR, grouped by project. +# Print all worktrees under $CKIPPER_WORKTREES_DIR, grouped by project. # -# Reads W_PROJECTS_DIR and W_WORKTREES_DIR globals. +# Reads CKIPPER_PROJECTS_DIR and CKIPPER_WORKTREES_DIR globals. _ckipper_worktree_list_worktrees() { echo "=== All Worktrees ===" - [[ ! -d "$W_WORKTREES_DIR" ]] && return 0 + [[ ! -d "$CKIPPER_WORKTREES_DIR" ]] && return 0 local previous_project_for_grouping="" - find "$W_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null \ + find "$CKIPPER_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null \ | sort \ | while IFS= read -r git_metadata_file; do local wt_dir="${git_metadata_file:h}" - local rel="${wt_dir#$W_WORKTREES_DIR/}" + local rel="${wt_dir#$CKIPPER_WORKTREES_DIR/}" local project="${rel%%/*}" local after_first="${rel#*/}" [[ "$after_first" == "$rel" ]] && continue @@ -48,7 +48,7 @@ _ckipper_worktree_get_project_and_branch() { local second="${after_first%%/*}" local rest="${after_first#*/}" - if [[ -d "$W_PROJECTS_DIR/$initial_project/$second/.git" ]]; then + if [[ -d "$CKIPPER_PROJECTS_DIR/$initial_project/$second/.git" ]]; then printf '%s\t%s' "$initial_project/$second" "$rest" else printf '%s\t%s' "$initial_project" "$after_first" @@ -58,10 +58,10 @@ _ckipper_worktree_get_project_and_branch() { # Remove a worktree and delete its branch. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — worktree/branch name # -# Reads W_FLAG_FORCE, W_WORKTREES_DIR, W_PROJECTS_DIR globals. +# Reads CKIPPER_WT_FLAG_FORCE, CKIPPER_WORKTREES_DIR, CKIPPER_PROJECTS_DIR globals. # Returns: 0 on success; 1 on validation failure or git error. # Errors (stderr): # "Usage: w --rm [--force] " — when project or worktree is empty @@ -76,16 +76,16 @@ _ckipper_worktree_remove_worktree() { return 1 fi - local wt_path="$W_WORKTREES_DIR/$project/$worktree" + local wt_path="$CKIPPER_WORKTREES_DIR/$project/$worktree" if [[ ! -d "$wt_path" ]]; then echo "Worktree not found: $wt_path" return 1 fi local force_flag="" - [[ "$W_FLAG_FORCE" = true ]] && force_flag="--force" + [[ "$CKIPPER_WT_FLAG_FORCE" = true ]] && force_flag="--force" - (cd "$W_PROJECTS_DIR/$project" && git worktree remove $force_flag "$wt_path" && git branch -D "$worktree" 2>/dev/null) || { + (cd "$CKIPPER_PROJECTS_DIR/$project" && git worktree remove $force_flag "$wt_path" && git branch -D "$worktree" 2>/dev/null) || { echo "Failed to remove worktree. Use --force if it has uncommitted changes." return 1 } @@ -109,13 +109,13 @@ _ckipper_worktree_cleanup_project_registry() { } # Create a worktree for the given project and branch (idempotent if it already exists). -# Sets W_WT_PATH to the resolved worktree path. +# Sets CKIPPER_WT_WT_PATH to the resolved worktree path. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch name # -# Reads W_PROJECTS_DIR, W_WORKTREES_DIR, W_ACTIVE_ACCOUNT, W_ACTIVE_CONFIG_DIR globals. +# Reads CKIPPER_PROJECTS_DIR, CKIPPER_WORKTREES_DIR, CKIPPER_WT_ACTIVE_ACCOUNT, CKIPPER_WT_ACTIVE_CONFIG_DIR globals. # Returns: 0 on success; 1 on validation failure or git error. # Errors (stderr): # "Project not found: " — when the project directory does not exist @@ -124,20 +124,20 @@ _ckipper_worktree_create_worktree() { local project="$1" local worktree="$2" - if [[ ! -d "$W_PROJECTS_DIR/$project" ]]; then - echo "Project not found: $W_PROJECTS_DIR/$project" + if [[ ! -d "$CKIPPER_PROJECTS_DIR/$project" ]]; then + echo "Project not found: $CKIPPER_PROJECTS_DIR/$project" return 1 fi - if [[ -d "$W_WORKTREES_DIR/$project/$worktree" ]]; then + if [[ -d "$CKIPPER_WORKTREES_DIR/$project/$worktree" ]]; then _ckipper_worktree_validate_existing_worktree "$project" "$worktree" || return 1 - W_WT_PATH="$W_WORKTREES_DIR/$project/$worktree" + CKIPPER_WT_WT_PATH="$CKIPPER_WORKTREES_DIR/$project/$worktree" return 0 fi echo "Creating worktree: $worktree" - mkdir -p "$W_WORKTREES_DIR/$project" - W_WT_PATH="$W_WORKTREES_DIR/$project/$worktree" + mkdir -p "$CKIPPER_WORKTREES_DIR/$project" + CKIPPER_WT_WT_PATH="$CKIPPER_WORKTREES_DIR/$project/$worktree" _ckipper_worktree_fetch_and_create "$project" "$worktree" || return 1 _ckipper_worktree_post_create_setup "$project" "$worktree" || return 1 @@ -146,7 +146,7 @@ _ckipper_worktree_create_worktree() { # Validate that an existing directory at the worktree path is a real worktree. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch/worktree name # # Returns: 0 if valid; 1 if the directory exists but has no .git file. @@ -155,7 +155,7 @@ _ckipper_worktree_create_worktree() { _ckipper_worktree_validate_existing_worktree() { local project="$1" local worktree="$2" - local wt_path="$W_WORKTREES_DIR/$project/$worktree" + local wt_path="$CKIPPER_WORKTREES_DIR/$project/$worktree" if [[ ! -f "$wt_path/.git" ]]; then echo "Error: $wt_path exists but is not a valid worktree." @@ -167,7 +167,7 @@ _ckipper_worktree_validate_existing_worktree() { # Fetch from origin and create the git worktree. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch/worktree name # # Returns: 0 on success; 1 if fetch or worktree creation fails. @@ -186,7 +186,7 @@ _ckipper_worktree_fetch_and_create() { # Fetch origin/develop and optionally origin/ for the project. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch name to attempt fetching # # Returns: 0 on success; 1 if origin/develop fetch fails. @@ -196,17 +196,17 @@ _ckipper_worktree_fetch_origin() { local project="$1" local worktree="$2" - (cd "$W_PROJECTS_DIR/$project" && git fetch origin develop) || { + (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin develop) || { echo "Failed to fetch from origin. Check your network connection and that 'develop' exists on the remote." return 1 } - (cd "$W_PROJECTS_DIR/$project" && git fetch origin "$worktree" 2>/dev/null) || true + (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin "$worktree" 2>/dev/null) || true } # Add the git worktree, choosing local, remote, or new branch as appropriate. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch/worktree name # # Returns: 0 on success; 1 if git worktree add fails. @@ -217,16 +217,16 @@ _ckipper_worktree_add_worktree() { local project="$1" local worktree="$2" - (cd "$W_PROJECTS_DIR/$project" && \ + (cd "$CKIPPER_PROJECTS_DIR/$project" && \ if git show-ref --verify --quiet "refs/heads/$worktree"; then echo "Using existing local branch: $worktree" - git worktree add "$W_WT_PATH" "$worktree" + git worktree add "$CKIPPER_WT_WT_PATH" "$worktree" elif git show-ref --verify --quiet "refs/remotes/origin/$worktree"; then echo "Tracking remote branch: origin/$worktree" - git worktree add "$W_WT_PATH" -b "$worktree" "origin/$worktree" + git worktree add "$CKIPPER_WT_WT_PATH" -b "$worktree" "origin/$worktree" else echo "Creating new branch from origin/develop" - git worktree add "$W_WT_PATH" -b "$worktree" origin/develop + git worktree add "$CKIPPER_WT_WT_PATH" -b "$worktree" origin/develop fi ) || _ckipper_worktree_handle_worktree_add_failure "$project" "$worktree" } @@ -234,7 +234,7 @@ _ckipper_worktree_add_worktree() { # Handle failure from git worktree add, printing a contextual error. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch/worktree name # # Returns: 1 always. @@ -245,11 +245,11 @@ _ckipper_worktree_handle_worktree_add_failure() { local project="$1" local worktree="$2" local current_branch - current_branch=$(cd "$W_PROJECTS_DIR/$project" && git branch --show-current 2>/dev/null) + current_branch=$(cd "$CKIPPER_PROJECTS_DIR/$project" && git branch --show-current 2>/dev/null) if [[ "$current_branch" == "$worktree" ]]; then echo "Failed: branch '$worktree' is currently checked out in the main repo." echo "Switch the main repo to a different branch first:" - echo " cd $W_PROJECTS_DIR/$project && git checkout develop" + echo " cd $CKIPPER_PROJECTS_DIR/$project && git checkout develop" else echo "Failed to create worktree" fi @@ -259,20 +259,20 @@ _ckipper_worktree_handle_worktree_add_failure() { # Post-worktree-creation: install deps, copy .env files, sync Claude settings. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch/worktree name (unused but kept for symmetry with other helpers) # -# Reads W_WT_PATH, W_ACTIVE_ACCOUNT globals. +# Reads CKIPPER_WT_WT_PATH, CKIPPER_WT_ACTIVE_ACCOUNT globals. # Returns: 0 always (individual steps may warn on failure but don't abort). _ckipper_worktree_post_create_setup() { local project="$1" echo "Installing dependencies..." - (cd "$W_WT_PATH" && npm install) || echo "Warning: npm install failed. You may need to run it manually." + (cd "$CKIPPER_WT_WT_PATH" && npm install) || echo "Warning: npm install failed. You may need to run it manually." - for env_file in $(find "$W_PROJECTS_DIR/$project" -maxdepth "$W_FIND_MAX_DEPTH" -name ".env*" -not -name "*.example" -not -path "*/node_modules/*" -not -path "*/.git/*"); do - local rel_path="${env_file#$W_PROJECTS_DIR/$project/}" - local dest_dir="$W_WT_PATH/$(dirname "$rel_path")" + for env_file in $(find "$CKIPPER_PROJECTS_DIR/$project" -maxdepth "$CKIPPER_WT_FIND_MAX_DEPTH" -name ".env*" -not -name "*.example" -not -path "*/node_modules/*" -not -path "*/.git/*"); do + local rel_path="${env_file#$CKIPPER_PROJECTS_DIR/$project/}" + local dest_dir="$CKIPPER_WT_WT_PATH/$(dirname "$rel_path")" mkdir -p "$dest_dir" cp "$env_file" "$dest_dir/" echo "Copied $rel_path" @@ -284,17 +284,17 @@ _ckipper_worktree_post_create_setup() { # Sync the new worktree into the project registry. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR) # -# Reads W_WT_PATH, W_ACTIVE_ACCOUNT globals. +# Reads CKIPPER_WT_WT_PATH, CKIPPER_WT_ACTIVE_ACCOUNT globals. # Returns: 0 always (failure is non-fatal). _ckipper_worktree_sync_project_registry() { local project="$1" - local main_project_path="$W_PROJECTS_DIR/$project" + local main_project_path="$CKIPPER_PROJECTS_DIR/$project" local ckipper_base_dir="${CKIPPER_DIR:-$HOME/.ckipper}" if [[ -f "$ckipper_base_dir/docker/cleanup-projects.py" ]]; then CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ python3 "$ckipper_base_dir/docker/cleanup-projects.py" sync \ - "$W_ACTIVE_ACCOUNT" "$main_project_path" "$W_WT_PATH" 2>/dev/null || true + "$CKIPPER_WT_ACTIVE_ACCOUNT" "$main_project_path" "$CKIPPER_WT_WT_PATH" 2>/dev/null || true fi } diff --git a/lib/worktree/worktree_test.bats b/lib/worktree/worktree_test.bats index f1661e3..14e9c05 100644 --- a/lib/worktree/worktree_test.bats +++ b/lib/worktree/worktree_test.bats @@ -6,9 +6,9 @@ load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" setup() { setup_isolated_env - export W_PROJECTS_DIR="$TMP_HOME/Developer" - export W_WORKTREES_DIR="$TMP_HOME/Developer/.worktrees" - mkdir -p "$W_PROJECTS_DIR" "$W_WORKTREES_DIR" + export CKIPPER_PROJECTS_DIR="$TMP_HOME/Developer" + export CKIPPER_WORKTREES_DIR="$TMP_HOME/Developer/.worktrees" + mkdir -p "$CKIPPER_PROJECTS_DIR" "$CKIPPER_WORKTREES_DIR" } teardown() { @@ -21,11 +21,11 @@ _run_worktree() { run env HOME="$TMP_HOME" \ CKIPPER_DIR="$CKIPPER_DIR" \ CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - W_PROJECTS_DIR="$W_PROJECTS_DIR" \ - W_WORKTREES_DIR="$W_WORKTREES_DIR" \ - W_ACTIVE_ACCOUNT="${W_ACTIVE_ACCOUNT:-test}" \ - W_ACTIVE_CONFIG_DIR="${W_ACTIVE_CONFIG_DIR:-$TMP_HOME/.claude-test}" \ - W_FLAG_FORCE="${W_FLAG_FORCE:-false}" \ + CKIPPER_PROJECTS_DIR="$CKIPPER_PROJECTS_DIR" \ + CKIPPER_WORKTREES_DIR="$CKIPPER_WORKTREES_DIR" \ + CKIPPER_WT_ACTIVE_ACCOUNT="${CKIPPER_WT_ACTIVE_ACCOUNT:-test}" \ + CKIPPER_WT_ACTIVE_CONFIG_DIR="${CKIPPER_WT_ACTIVE_CONFIG_DIR:-$TMP_HOME/.claude-test}" \ + CKIPPER_WT_FLAG_FORCE="${CKIPPER_WT_FLAG_FORCE:-false}" \ PATH="$PATH" \ zsh -c " source \"$REPO_ROOT/lib/core/utils.zsh\" diff --git a/templates/w-config.zsh.example b/templates/w-config.zsh.example index c4d668a..45107e1 100644 --- a/templates/w-config.zsh.example +++ b/templates/w-config.zsh.example @@ -9,17 +9,17 @@ # Base directory containing your git projects. The first arg to w() is # resolved relative to this path. Default: $HOME/Developer # Examples: -# W_PROJECTS_DIR="$HOME/code" -# W_PROJECTS_DIR="$HOME/work/repos" -# W_PROJECTS_DIR="$HOME/Developer" +# CKIPPER_PROJECTS_DIR="$HOME/code" +# CKIPPER_PROJECTS_DIR="$HOME/work/repos" +# CKIPPER_PROJECTS_DIR="$HOME/Developer" -# Where w() creates per-project worktrees. Default: $W_PROJECTS_DIR/.worktrees +# Where w() creates per-project worktrees. Default: $CKIPPER_PROJECTS_DIR/.worktrees # Override only if you want worktrees outside your projects tree. -# W_WORKTREES_DIR="$HOME/.worktrees" +# CKIPPER_WORKTREES_DIR="$HOME/.worktrees" # Ports to forward from container to host. # These should match your dev servers (Next.js, Storybook, etc.) -W_PORTS=(3000 3030 6006) +CKIPPER_PORTS=(3000 3030 6006) # Extra Docker volume mounts (for MCP servers that reference local files). # Mount at the exact same host path so MCP configs work unchanged. @@ -27,10 +27,10 @@ W_PORTS=(3000 3030 6006) # Examples: # "$HOME/Developer/my-data:$HOME/Developer/my-data:ro" # "$HOME/path/to/file.json:$HOME/path/to/file.json:ro" -W_EXTRA_VOLUMES=() +CKIPPER_EXTRA_VOLUMES=() # Extra Docker environment variables. # Format: "KEY=value" # Examples: # "MY_MCP_SERVER=host.docker.internal" -W_EXTRA_ENV=() +CKIPPER_EXTRA_ENV=() diff --git a/w-function.zsh b/w-function.zsh index e981eb2..c15fe78 100644 --- a/w-function.zsh +++ b/w-function.zsh @@ -10,29 +10,29 @@ # w --rm remove worktree + delete branch # w --rebuild-image rebuild ckipper-dev Docker image # -# is a path relative to W_PROJECTS_DIR (default: ~/Developer; e.g. "Whmoro/orderguard", "my-app") +# is a path relative to CKIPPER_PROJECTS_DIR (default: ~/Developer; e.g. "Whmoro/orderguard", "my-app") # # ── CUSTOMIZATION ──────────────────────────────────────────────── # Edit ~/.ckipper/docker/w-config.zsh to customize: -# - W_PROJECTS_DIR: base directory for git projects (default: $HOME/Developer) -# - W_WORKTREES_DIR: base directory for worktrees (default: $W_PROJECTS_DIR/.worktrees) -# - W_PORTS: dev server ports to forward -# - W_EXTRA_VOLUMES: MCP server mounts and other volume mounts -# - W_EXTRA_ENV: extra environment variables for the container +# - CKIPPER_PROJECTS_DIR: base directory for git projects (default: $HOME/Developer) +# - CKIPPER_WORKTREES_DIR: base directory for worktrees (default: $CKIPPER_PROJECTS_DIR/.worktrees) +# - CKIPPER_PORTS: dev server ports to forward +# - CKIPPER_EXTRA_VOLUMES: MCP server mounts and other volume mounts +# - CKIPPER_EXTRA_ENV: extra environment variables for the container # # BASE BRANCH: Worktrees are created from origin/develop. Change # "develop" below if your default branch is different (e.g. main). # ───────────────────────────────────────────────────────────────── -W_REPO_DIR="${0:A:h}" -source "$W_REPO_DIR/ckipper.zsh" -source "$W_REPO_DIR/lib/worktree/resolve-account.zsh" -source "$W_REPO_DIR/lib/worktree/build-image.zsh" -source "$W_REPO_DIR/lib/worktree/args.zsh" -source "$W_REPO_DIR/lib/worktree/worktree.zsh" -source "$W_REPO_DIR/lib/worktree/ports.zsh" -source "$W_REPO_DIR/lib/worktree/docker-mode.zsh" -source "$W_REPO_DIR/lib/worktree/normal-mode.zsh" +CKIPPER_REPO_DIR="${0:A:h}" +source "$CKIPPER_REPO_DIR/ckipper.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/resolve-account.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/build-image.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/args.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/worktree.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/ports.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/docker-mode.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/normal-mode.zsh" # Source user config (projects/worktrees dirs, ports, extra volumes, extra env vars) _ckipper_worktree_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" @@ -41,16 +41,16 @@ if [[ -f "$_ckipper_worktree_config" ]]; then fi # Defaults if config is missing or incomplete. Set once at source time and # never reset per-call so users can host their projects anywhere without forking. -W_PROJECTS_DIR="${W_PROJECTS_DIR:-$HOME/Developer}" -W_WORKTREES_DIR="${W_WORKTREES_DIR:-$W_PROJECTS_DIR/.worktrees}" -(( ${#W_PORTS[@]} == 0 )) && W_PORTS=(3000) -(( ${#W_EXTRA_VOLUMES[@]} == 0 )) && W_EXTRA_VOLUMES=() -(( ${#W_EXTRA_ENV[@]} == 0 )) && W_EXTRA_ENV=() +CKIPPER_PROJECTS_DIR="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" +CKIPPER_WORKTREES_DIR="${CKIPPER_WORKTREES_DIR:-$CKIPPER_PROJECTS_DIR/.worktrees}" +(( ${#CKIPPER_PORTS[@]} == 0 )) && CKIPPER_PORTS=(3000) +(( ${#CKIPPER_EXTRA_VOLUMES[@]} == 0 )) && CKIPPER_EXTRA_VOLUMES=() +(( ${#CKIPPER_EXTRA_ENV[@]} == 0 )) && CKIPPER_EXTRA_ENV=() # Worktree-aware Claude Code launcher. # # Args: -# $1 — project path (relative to W_PROJECTS_DIR), or a flag (--list, --rm, --rebuild-image) +# $1 — project path (relative to CKIPPER_PROJECTS_DIR), or a flag (--list, --rm, --rebuild-image) # $2 — branch/worktree name (required unless $1 is --list or --rebuild-image) # $@ — optional flags and command: [--docker] [--firewall] [--account ] [cmd...] # @@ -62,25 +62,25 @@ W_WORKTREES_DIR="${W_WORKTREES_DIR:-$W_PROJECTS_DIR/.worktrees}" w() { _ckipper_worktree_parse_args "$@" - if [[ "$W_FLAG_LIST" = true ]]; then + if [[ "$CKIPPER_WT_FLAG_LIST" = true ]]; then _ckipper_worktree_list_worktrees - elif [[ "$W_FLAG_REBUILD_IMAGE" = true ]]; then + elif [[ "$CKIPPER_WT_FLAG_REBUILD_IMAGE" = true ]]; then _ckipper_worktree_build_image return $? - elif [[ "$W_FLAG_RM" = true ]]; then - _ckipper_worktree_remove_worktree "$W_PROJECT" "$W_BRANCH" + elif [[ "$CKIPPER_WT_FLAG_RM" = true ]]; then + _ckipper_worktree_remove_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" return $? - elif [[ -z "$W_PROJECT" || -z "$W_BRANCH" ]]; then + elif [[ -z "$CKIPPER_WT_PROJECT" || -z "$CKIPPER_WT_BRANCH" ]]; then _ckipper_worktree_usage return 1 else - if [[ "$W_FLAG_FIREWALL" = true && "$W_FLAG_DOCKER" = false ]]; then + if [[ "$CKIPPER_WT_FLAG_FIREWALL" = true && "$CKIPPER_WT_FLAG_DOCKER" = false ]]; then echo "Error: --firewall requires --docker" return 1 fi _ckipper_worktree_resolve_account || return $? - _ckipper_worktree_create_worktree "$W_PROJECT" "$W_BRANCH" || return $? - if [[ "$W_FLAG_DOCKER" = true ]]; then + _ckipper_worktree_create_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" || return $? + if [[ "$CKIPPER_WT_FLAG_DOCKER" = true ]]; then _ckipper_worktree_run_docker_mode else _ckipper_worktree_run_normal_mode @@ -117,9 +117,9 @@ fpath=(~/.zsh/completions $fpath) # Bump this when the heredoc body below changes so existing installs regenerate # the cached completion file. The version is embedded as a literal comment in # the generated file and matched here. -W_COMPLETION_VERSION=2 +CKIPPER_COMPLETION_VERSION=2 if [[ ! -f ~/.zsh/completions/_w ]] \ - || ! grep -q "# w-completion-version=$W_COMPLETION_VERSION" ~/.zsh/completions/_w 2>/dev/null; then + || ! grep -q "# w-completion-version=$CKIPPER_COMPLETION_VERSION" ~/.zsh/completions/_w 2>/dev/null; then # Note: `_w()` below is a zsh tab-completion definition embedded in a heredoc. # It uses zsh's _arguments DSL and must remain a single function for tab # completion to work. The 25-line cap in code-style.md does not apply to @@ -130,8 +130,8 @@ if [[ ! -f ~/.zsh/completions/_w ]] \ # w-completion-version=2 _w() { - local projects_dir="${W_PROJECTS_DIR:-$HOME/Developer}" - local worktrees_dir="${W_WORKTREES_DIR:-$projects_dir/.worktrees}" + local projects_dir="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" + local worktrees_dir="${CKIPPER_WORKTREES_DIR:-$projects_dir/.worktrees}" _arguments -C \ '(--rm)--list[List all worktrees]' \ diff --git a/w-function_test.bats b/w-function_test.bats index 16682a2..fb4946c 100644 --- a/w-function_test.bats +++ b/w-function_test.bats @@ -15,7 +15,7 @@ setup() { export DOCKER_STUB_LOG="$TMP_HOME/docker.log" : > "$DOCKER_STUB_LOG" mkdir -p "$CKIPPER_DIR/docker" - echo 'W_PORTS=(3000 3030)' > "$CKIPPER_DIR/docker/w-config.zsh" + echo 'CKIPPER_PORTS=(3000 3030)' > "$CKIPPER_DIR/docker/w-config.zsh" echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" } @@ -116,13 +116,13 @@ teardown() { # The completion-file regeneration mechanism relies on the literal # "# w-completion-version=N" sentinel inside the single-quoted heredoc -# matching the W_COMPLETION_VERSION variable referenced in the grep +# matching the CKIPPER_COMPLETION_VERSION variable referenced in the grep # check immediately above. If a future bump only updates one side, # existing installs silently fail to regenerate. This test guards # against that drift. @test "w-function.zsh: completion version sentinel matches outer variable" { local outer inner - outer=$(grep -E '^W_COMPLETION_VERSION=' "$REPO_ROOT/w-function.zsh" | head -1 | cut -d= -f2) + outer=$(grep -E '^CKIPPER_COMPLETION_VERSION=' "$REPO_ROOT/w-function.zsh" | head -1 | cut -d= -f2) inner=$(grep -E '^# w-completion-version=' "$REPO_ROOT/w-function.zsh" | head -1 | cut -d= -f2) [ -n "$outer" ] [ -n "$inner" ] From f0e713f143d6c6a2647e896905498d3a5d5dba1a Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:05:19 -0600 Subject: [PATCH 077/165] Restructure worktree arg parsing: drop mode dispatch, split run/rm parsers --- lib/worktree/args.zsh | 102 +++++++++++++----------------------- lib/worktree/args_test.bats | 80 +++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 80 deletions(-) diff --git a/lib/worktree/args.zsh b/lib/worktree/args.zsh index 498c487..0c4b5c4 100644 --- a/lib/worktree/args.zsh +++ b/lib/worktree/args.zsh @@ -1,54 +1,15 @@ #!/usr/bin/env zsh -# Argument parsing for w(). Populates W_* globals used by all other lib/w modules. +# Argument parsing for the worktree namespace. Two parsers, one per subcommand +# (run, rm); the rest of the worktree subcommands take no args. Mode dispatch +# (--list / --rm / --rebuild-image) is gone — those are subcommands now and +# the dispatcher in lib/worktree/dispatcher.zsh routes them directly. -# Parse all arguments passed to w() into W_* globals. -# -# Globals set: -# CKIPPER_WT_FLAG_LIST — true if --list -# CKIPPER_WT_FLAG_REBUILD_IMAGE — true if --rebuild-image -# CKIPPER_WT_FLAG_RM — true if --rm -# CKIPPER_WT_FLAG_FORCE — true if --force (with --rm) -# CKIPPER_WT_FLAG_DOCKER — true if --docker -# CKIPPER_WT_FLAG_FIREWALL — true if --firewall -# CKIPPER_WT_PROJECT — first positional arg (project path) -# CKIPPER_WT_BRANCH — second positional arg (worktree/branch name) -# CKIPPER_WT_CLI_ACCOUNT — value of --account , or empty -# CKIPPER_WT_COMMAND — array: remaining positional args after project+branch -# -# CKIPPER_PROJECTS_DIR / CKIPPER_WORKTREES_DIR are config values, not args — they are -# initialized once when w-function.zsh is sourced and never reset here. -# -# Returns: 0 always (validation is done by the dispatcher). -_ckipper_worktree_parse_args() { - _ckipper_worktree_reset_globals - - if [[ "$1" == "--list" ]]; then - CKIPPER_WT_FLAG_LIST=true - return 0 - fi - - if [[ "$1" == "--rebuild-image" ]]; then - CKIPPER_WT_FLAG_REBUILD_IMAGE=true - return 0 - fi - - if [[ "$1" == "--rm" ]]; then - _ckipper_worktree_parse_rm_args "$@" - return 0 - fi - - _ckipper_worktree_parse_run_args "$@" -} - -# Reset per-call W_* arg globals (flags + positionals) to their default values. -# Config globals (CKIPPER_PROJECTS_DIR, CKIPPER_WORKTREES_DIR, CKIPPER_PORTS, etc.) are owned -# by w-function.zsh and intentionally not touched here. +# Reset per-call CKIPPER_WT_* arg globals (flags + positionals) to defaults. +# Config globals (CKIPPER_PROJECTS_DIR, CKIPPER_WORKTREES_DIR, CKIPPER_PORTS, +# etc.) are owned by ckipper.zsh and intentionally not touched here. # # Returns: 0 always. _ckipper_worktree_reset_globals() { - CKIPPER_WT_FLAG_LIST=false - CKIPPER_WT_FLAG_REBUILD_IMAGE=false - CKIPPER_WT_FLAG_RM=false CKIPPER_WT_FLAG_FORCE=false CKIPPER_WT_FLAG_DOCKER=false CKIPPER_WT_FLAG_FIREWALL=false @@ -58,30 +19,19 @@ _ckipper_worktree_reset_globals() { CKIPPER_WT_COMMAND=() } -# Parse --rm [--force] args. +# Parse `worktree run [--docker] [--firewall] [--account ] [cmd...]`. # -# Args: -# $@ — original args starting with --rm -# -# Returns: 0 always. -_ckipper_worktree_parse_rm_args() { - CKIPPER_WT_FLAG_RM=true - shift - if [[ "$1" == "--force" || "$1" == "-f" ]]; then - CKIPPER_WT_FLAG_FORCE=true - shift - fi - CKIPPER_WT_PROJECT="$1" - CKIPPER_WT_BRANCH="$2" -} - -# Parse the normal run args: project branch [flags...] [command...]. -# -# Args: -# $@ — original args (project is $1, branch is $2) +# Globals set: +# CKIPPER_WT_PROJECT — first positional arg (project path) +# CKIPPER_WT_BRANCH — second positional arg (worktree/branch name) +# CKIPPER_WT_FLAG_DOCKER — true if --docker +# CKIPPER_WT_FLAG_FIREWALL — true if --firewall +# CKIPPER_WT_CLI_ACCOUNT — value of --account , or empty +# CKIPPER_WT_COMMAND — array of remaining positional args (the command to run) # -# Returns: 0 always. +# Returns: 0 always (validation is the caller's responsibility). _ckipper_worktree_parse_run_args() { + _ckipper_worktree_reset_globals CKIPPER_WT_PROJECT="$1" CKIPPER_WT_BRANCH="$2" shift 2 2>/dev/null @@ -95,3 +45,21 @@ _ckipper_worktree_parse_run_args() { esac done } + +# Parse `worktree rm [--force] `. +# +# Globals set: +# CKIPPER_WT_FLAG_FORCE — true if --force / -f was passed +# CKIPPER_WT_PROJECT — project path +# CKIPPER_WT_BRANCH — branch name +# +# Returns: 0 always. +_ckipper_worktree_parse_rm_args() { + _ckipper_worktree_reset_globals + if [[ "$1" == "--force" || "$1" == "-f" ]]; then + CKIPPER_WT_FLAG_FORCE=true + shift + fi + CKIPPER_WT_PROJECT="$1" + CKIPPER_WT_BRANCH="$2" +} diff --git a/lib/worktree/args_test.bats b/lib/worktree/args_test.bats index 141cca6..581060f 100644 --- a/lib/worktree/args_test.bats +++ b/lib/worktree/args_test.bats @@ -1,6 +1,7 @@ #!/usr/bin/env bats # Module-level tests for lib/worktree/args.zsh. -# Verifies that _ckipper_worktree_parse_args sets the correct W_* globals. +# Verifies the per-subcommand parsers (_ckipper_worktree_parse_run_args, +# _ckipper_worktree_parse_rm_args) set the correct CKIPPER_WT_* globals. load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -12,38 +13,91 @@ teardown() { teardown_isolated_env } -# Helper: source args.zsh, call _ckipper_worktree_parse_args with provided args, then print -# the value of $1 (variable name) to stdout. +# Helper: source args.zsh, call $parser with provided args, then print the +# value of $var_name to stdout. _parse_and_print() { - local var_name="$1"; shift + local parser="$1" var_name="$2"; shift 2 run env HOME="$TMP_HOME" PATH="$PATH" \ - zsh -c "source \"$REPO_ROOT/lib/worktree/args.zsh\"; _ckipper_worktree_parse_args $*; print -r -- \"\$$var_name\"" + zsh -c "source \"$REPO_ROOT/lib/worktree/args.zsh\"; ${parser} $*; print -r -- \"\$${var_name}\"" } -@test "_ckipper_worktree_parse_args sets CKIPPER_WT_PROJECT and CKIPPER_WT_BRANCH for bare project/branch args" { - _parse_and_print "CKIPPER_WT_PROJECT" myapp feature-x +# ── _ckipper_worktree_parse_run_args ───────────────────────────────── + +@test "parse_run_args sets CKIPPER_WT_PROJECT from first positional" { + _parse_and_print _ckipper_worktree_parse_run_args CKIPPER_WT_PROJECT myapp feature-x [ "$status" -eq 0 ] [ "$output" = "myapp" ] } -@test "_ckipper_worktree_parse_args sets CKIPPER_WT_FLAG_RM to true for --rm flag" { - _parse_and_print "CKIPPER_WT_FLAG_RM" --rm myapp branch +@test "parse_run_args sets CKIPPER_WT_BRANCH from second positional" { + _parse_and_print _ckipper_worktree_parse_run_args CKIPPER_WT_BRANCH myapp feature-x + + [ "$status" -eq 0 ] + [ "$output" = "feature-x" ] +} + +@test "parse_run_args sets CKIPPER_WT_FLAG_DOCKER for --docker" { + _parse_and_print _ckipper_worktree_parse_run_args CKIPPER_WT_FLAG_DOCKER myapp feature-x --docker [ "$status" -eq 0 ] [ "$output" = "true" ] } -@test "_ckipper_worktree_parse_args sets CKIPPER_WT_FLAG_DOCKER to true for --docker flag" { - _parse_and_print "CKIPPER_WT_FLAG_DOCKER" myapp feature-x --docker +@test "parse_run_args sets CKIPPER_WT_FLAG_FIREWALL for --firewall" { + _parse_and_print _ckipper_worktree_parse_run_args CKIPPER_WT_FLAG_FIREWALL myapp feature-x --docker --firewall [ "$status" -eq 0 ] [ "$output" = "true" ] } -@test "_ckipper_worktree_parse_args sets CKIPPER_WT_FLAG_LIST to true for --list flag" { - _parse_and_print "CKIPPER_WT_FLAG_LIST" --list +@test "parse_run_args sets CKIPPER_WT_CLI_ACCOUNT for --account" { + _parse_and_print _ckipper_worktree_parse_run_args CKIPPER_WT_CLI_ACCOUNT myapp feature-x --account work + + [ "$status" -eq 0 ] + [ "$output" = "work" ] +} + +@test "parse_run_args defaults CKIPPER_WT_FLAG_DOCKER to false" { + _parse_and_print _ckipper_worktree_parse_run_args CKIPPER_WT_FLAG_DOCKER myapp feature-x + + [ "$status" -eq 0 ] + [ "$output" = "false" ] +} + +# ── _ckipper_worktree_parse_rm_args ────────────────────────────────── + +@test "parse_rm_args sets CKIPPER_WT_PROJECT and CKIPPER_WT_BRANCH" { + _parse_and_print _ckipper_worktree_parse_rm_args CKIPPER_WT_PROJECT myapp feature-x + + [ "$status" -eq 0 ] + [ "$output" = "myapp" ] +} + +@test "parse_rm_args defaults CKIPPER_WT_FLAG_FORCE to false" { + _parse_and_print _ckipper_worktree_parse_rm_args CKIPPER_WT_FLAG_FORCE myapp feature-x + + [ "$status" -eq 0 ] + [ "$output" = "false" ] +} + +@test "parse_rm_args sets CKIPPER_WT_FLAG_FORCE for --force" { + _parse_and_print _ckipper_worktree_parse_rm_args CKIPPER_WT_FLAG_FORCE --force myapp feature-x + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "parse_rm_args sets CKIPPER_WT_FLAG_FORCE for -f shorthand" { + _parse_and_print _ckipper_worktree_parse_rm_args CKIPPER_WT_FLAG_FORCE -f myapp feature-x [ "$status" -eq 0 ] [ "$output" = "true" ] } + +@test "parse_rm_args strips --force before consuming positionals" { + _parse_and_print _ckipper_worktree_parse_rm_args CKIPPER_WT_PROJECT --force myapp feature-x + + [ "$status" -eq 0 ] + [ "$output" = "myapp" ] +} From 0f78e2d89ca87c3ba8ab1fd6fb8b2d51f6e543f3 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:07:57 -0600 Subject: [PATCH 078/165] Add namespace dispatchers (account, worktree) with help and fuzzy-suggest --- lib/account/dispatcher.zsh | 200 ++++++++++++++++++++++++++++++ lib/account/dispatcher_test.bats | 86 +++++++++++++ lib/worktree/dispatcher.zsh | 170 +++++++++++++++++++++++++ lib/worktree/dispatcher_test.bats | 102 +++++++++++++++ lib/worktree/run.zsh | 38 ++++++ lib/worktree/run_test.bats | 77 ++++++++++++ 6 files changed, 673 insertions(+) create mode 100644 lib/account/dispatcher.zsh create mode 100644 lib/account/dispatcher_test.bats create mode 100644 lib/worktree/dispatcher.zsh create mode 100644 lib/worktree/dispatcher_test.bats create mode 100644 lib/worktree/run.zsh create mode 100644 lib/worktree/run_test.bats diff --git a/lib/account/dispatcher.zsh b/lib/account/dispatcher.zsh new file mode 100644 index 0000000..003426e --- /dev/null +++ b/lib/account/dispatcher.zsh @@ -0,0 +1,200 @@ +#!/usr/bin/env zsh +# Account-namespace dispatcher and help text. +# +# Routes `ckipper account ` to the matching _ckipper_account_* +# function, prints overview/per-subcommand help, and suggests the closest +# subcommand on a typo via _core_fuzzy_suggest. + +# Known account subcommands. Used both for routing and for fuzzy-suggest. +_CKIPPER_ACCOUNT_SUBCOMMANDS=( + add list default remove rename sync sync-hooks repair-plugins help +) + +# Dispatch an `account` subcommand. +# +# Args: +# $1 — subcommand name (add, list, default, remove, rename, sync, +# sync-hooks, repair-plugins, help, -h, --help, or empty) +# $2..$N — arguments forwarded to the subcommand handler +# +# Returns: 0 on success; 1 on unknown subcommand. +# +# Errors (stderr): +# "Unknown command: ''. Did you mean: ''? ..." +_ckipper_account_dispatch() { + local cmd="$1" + shift 2>/dev/null + case "$cmd" in + add|list|default|remove|rename|sync|sync-hooks|repair-plugins) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_account_help_for "$cmd" + return 0 + fi + "_ckipper_account_${cmd//-/_}" "$@" + ;; + ""|help|-h|--help) _ckipper_account_help ;; + *) _ckipper_account_unknown "$cmd"; return 1 ;; + esac +} + +# Print the closest-match suggestion (or a bare unknown-command line) and +# point the user at help. Always writes to stderr. +# +# Args: $1 — the unknown subcommand the user typed. +# Returns: 0 always. +_ckipper_account_unknown() { + local cmd="$1" suggestion + suggestion=$(_core_fuzzy_suggest "$cmd" "${_CKIPPER_ACCOUNT_SUBCOMMANDS[@]}") + if [[ -n "$suggestion" ]]; then + echo "Unknown command: '$cmd'. Did you mean: '$suggestion'?" >&2 + else + echo "Unknown command: '$cmd'." >&2 + fi + echo "Run 'ckipper account help' for available commands." >&2 +} + +# Print the account-namespace usage summary. +# +# Returns: 0 always. +_ckipper_account_help() { + cat <<'EOF' +ckipper account — manage registered Claude accounts + +Usage: + ckipper account add Register a new account (interactive /login) + ckipper account add --adopt Register an existing populated config dir + ckipper account list Show registered accounts + ckipper account default Set the default account + ckipper account remove Unregister (does not delete the dir) + ckipper account rename Rename an account in place + ckipper account sync Copy MCP/settings between accounts + ckipper account sync-hooks Re-deploy hooks into every account dir + ckipper account repair-plugins Rewrite stale plugin paths + +Short form: `ckipper acct ...` is equivalent. + +Run `ckipper account --help` for per-subcommand details. +EOF +} + +# Per-subcommand help text router. Each arm prints a focused usage block. +# +# Args: $1 — subcommand name. +# Returns: 0 always. +_ckipper_account_help_for() { + case "$1" in + add) _ckipper_account_help_text_add ;; + list) _ckipper_account_help_text_list ;; + default) _ckipper_account_help_text_default ;; + remove) _ckipper_account_help_text_remove ;; + rename) _ckipper_account_help_text_rename ;; + sync) _ckipper_account_help_text_sync ;; + sync-hooks) _ckipper_account_help_text_sync_hooks ;; + repair-plugins) _ckipper_account_help_text_repair_plugins ;; + esac +} + +_ckipper_account_help_text_add() { + cat <<'EOF' +ckipper account add [--adopt] + +Register a new account. must match ^[a-z0-9_-]+$. + +Without --adopt: creates ~/.claude-/ and walks you through /login. +With --adopt: registers an existing populated ~/.claude-/ directory. +EOF +} + +_ckipper_account_help_text_list() { + cat <<'EOF' +ckipper account list + +Print registered accounts: name, config dir, keychain service, default flag, +and last-login email (read from each account's .claude.json, if present). +EOF +} + +_ckipper_account_help_text_default() { + cat <<'EOF' +ckipper account default + +Set the default account used when no `--account` flag and no +$CLAUDE_CONFIG_DIR env var are provided. +EOF +} + +_ckipper_account_help_text_remove() { + cat <<'EOF' +ckipper account remove + +Unregister an account from the registry and aliases. Does NOT delete the +config dir or the macOS Keychain entry — those stay for safety. +EOF +} + +_ckipper_account_help_text_rename() { + cat <<'EOF' +ckipper account rename + +Rename a registered account in place: + - Renames ~/.claude-/ → ~/.claude-/ + - Updates the registry (key + config_dir) + - If was the default, makes the default + - Regenerates aliases.zsh and re-syncs hooks + - Refuses if any Claude session is running (so the dir isn't held open) + +Keychain service name is NOT changed — only the dir + registry mapping. +EOF +} + +_ckipper_account_help_text_sync() { + cat <<'EOF' +ckipper account sync [options] + +Copy state from one registered account to another. Useful for sharing MCP +servers, plugin lists, status line, env vars, etc. across accounts. + +By default (no flags) syncs a sensible bundle: mcpServers + enabledPlugins + +extraKnownMarketplaces + statusLine + env. + +Options: + --mcp [name1,name2,...] Sync mcpServers. Without arg: all servers. + With arg: only the named servers. + --settings Sync specific top-level keys from settings.json. + Comma-separated. Examples: enabledPlugins, + extraKnownMarketplaces, statusLine, env, model. + --all Sync the default bundle (same as no flags). + --dry-run Show what would change without writing. + +Examples: + ckipper account sync personal work + ckipper account sync personal work --mcp + ckipper account sync personal work --mcp Vibma,github + ckipper account sync personal work --settings statusLine,env --dry-run +EOF +} + +_ckipper_account_help_text_sync_hooks() { + cat <<'EOF' +ckipper account sync-hooks + +Copy ~/.ckipper/hooks/* into each registered account's /hooks/ and +rewrite the per-account settings.json to point at those copies. + +Run after editing any hook script under ~/.ckipper/hooks/ so the change +propagates to every account. +EOF +} + +_ckipper_account_help_text_repair_plugins() { + cat <<'EOF' +ckipper account repair-plugins + +Rewrite stale absolute paths in /plugins/{known_marketplaces, +installed_plugins}.json from $HOME/.claude/... to the account's actual dir. + +Use this when Claude Code shows "Plugin not found in marketplace ..." for +plugins that were installed before the account directory was renamed. +Backups are written alongside each rewritten file. +EOF +} diff --git a/lib/account/dispatcher_test.bats b/lib/account/dispatcher_test.bats new file mode 100644 index 0000000..f3b6ea6 --- /dev/null +++ b/lib/account/dispatcher_test.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# Module-level tests for lib/account/dispatcher.zsh. +# Verifies routing, help, and fuzzy-suggest behaviour. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run dispatcher in a zsh subshell with all its dependencies +# (fuzzy.zsh, dispatcher.zsh) sourced and named subcommand handlers stubbed. +_run_dispatch() { + run env HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/core/fuzzy.zsh\" + source \"$REPO_ROOT/lib/account/dispatcher.zsh\" + _ckipper_account_list() { echo 'STUB-LIST'; } + _ckipper_account_add() { echo 'STUB-ADD' \"\$@\"; } + _ckipper_account_dispatch $* + " +} + +@test "dispatch routes 'list' to _ckipper_account_list" { + _run_dispatch list + + [ "$status" -eq 0 ] + [ "$output" = "STUB-LIST" ] +} + +@test "dispatch routes 'add' with arguments to _ckipper_account_add" { + _run_dispatch add personal + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-ADD personal" ]] +} + +@test "dispatch short-circuits 'add --help' to per-subcommand help" { + _run_dispatch add --help + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper account add" ]] + [[ "$output" =~ "--adopt" ]] +} + +@test "dispatch short-circuits 'list -h' to per-subcommand help" { + _run_dispatch list -h + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper account list" ]] +} + +@test "dispatch with no args prints overview help" { + _run_dispatch + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper account" ]] + [[ "$output" =~ "Short form" ]] +} + +@test "dispatch with 'help' prints overview help" { + _run_dispatch help + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper account" ]] +} + +@test "dispatch fuzzy-suggests on close typo" { + _run_dispatch lst + + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown command: 'lst'. Did you mean: 'list'?" ]] + [[ "$output" =~ "ckipper account help" ]] +} + +@test "dispatch prints bare unknown-command line on far-off typo" { + _run_dispatch xyzzy + + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown command: 'xyzzy'." ]] + [[ ! "$output" =~ "Did you mean" ]] +} diff --git a/lib/worktree/dispatcher.zsh b/lib/worktree/dispatcher.zsh new file mode 100644 index 0000000..2d3a114 --- /dev/null +++ b/lib/worktree/dispatcher.zsh @@ -0,0 +1,170 @@ +#!/usr/bin/env zsh +# Worktree-namespace dispatcher and help text. +# +# Routes `ckipper worktree ` to the matching helper, prints +# overview/per-subcommand help, and suggests the closest subcommand on a +# typo via _core_fuzzy_suggest. + +# Known worktree subcommands. Used both for routing and for fuzzy-suggest. +_CKIPPER_WORKTREE_SUBCOMMANDS=(run list rm rebuild-image help) + +# Dispatch a `worktree` subcommand. +# +# Args: +# $1 — subcommand name (run, list, rm, rebuild-image, help, -h, --help, or empty) +# $2..$N — arguments forwarded to the subcommand handler +# +# Returns: subcommand exit status; 1 on unknown subcommand. +_ckipper_worktree_dispatch() { + local cmd="$1" + shift 2>/dev/null + case "$cmd" in + run) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_worktree_help_for run + return 0 + fi + _ckipper_worktree_run "$@" + ;; + list) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_worktree_help_for list + return 0 + fi + _ckipper_worktree_list_worktrees "$@" + ;; + rm) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_worktree_help_for rm + return 0 + fi + _ckipper_worktree_parse_rm_args "$@" + if [[ -z "$CKIPPER_WT_PROJECT" || -z "$CKIPPER_WT_BRANCH" ]]; then + _ckipper_worktree_help_for rm >&2 + return 1 + fi + _ckipper_worktree_remove_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" + ;; + rebuild-image) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_worktree_help_for rebuild-image + return 0 + fi + _ckipper_worktree_build_image "$@" + ;; + ""|help|-h|--help) _ckipper_worktree_help ;; + *) _ckipper_worktree_unknown "$cmd"; return 1 ;; + esac +} + +# Print the closest-match suggestion (or a bare unknown-command line) and +# point the user at help. Always writes to stderr. +# +# Args: $1 — the unknown subcommand the user typed. +# Returns: 0 always. +_ckipper_worktree_unknown() { + local cmd="$1" suggestion + suggestion=$(_core_fuzzy_suggest "$cmd" "${_CKIPPER_WORKTREE_SUBCOMMANDS[@]}") + if [[ -n "$suggestion" ]]; then + echo "Unknown command: '$cmd'. Did you mean: '$suggestion'?" >&2 + else + echo "Unknown command: '$cmd'." >&2 + fi + echo "Run 'ckipper worktree help' for available commands." >&2 +} + +# Print the worktree-namespace usage summary. +# +# Returns: 0 always. +_ckipper_worktree_help() { + cat <<'EOF' +ckipper worktree — manage git worktrees and run Claude in them + +Usage: + ckipper worktree run [--docker [--firewall]] [--account ] [cmd...] + Create-or-cd worktree, optionally launch in Docker + ckipper worktree list List all worktrees under CKIPPER_WORKTREES_DIR + ckipper worktree rm [--force] + Remove worktree directory + delete branch + ckipper worktree rebuild-image Rebuild the ckipper-dev Docker image + +Short form: `ckipper wt ...` is equivalent. + +Run `ckipper worktree --help` for per-subcommand details. +EOF +} + +# Per-subcommand help text router. +# +# Args: $1 — subcommand name. +# Returns: 0 always. +_ckipper_worktree_help_for() { + case "$1" in + run) _ckipper_worktree_help_text_run ;; + list) _ckipper_worktree_help_text_list ;; + rm) _ckipper_worktree_help_text_rm ;; + rebuild-image) _ckipper_worktree_help_text_rebuild_image ;; + esac +} + +_ckipper_worktree_help_text_run() { + cat <<'EOF' +ckipper worktree run [flags] [cmd...] + +Create-or-cd to a git worktree under CKIPPER_WORKTREES_DIR, then either drop +you in a shell or run a command. Without --docker, runs on the host. + +Args: + Path relative to CKIPPER_PROJECTS_DIR (e.g. myorg/app) + Worktree/branch name (creates from origin/develop if new) + [cmd...] Optional command to run in the worktree (e.g. `claude`) + +Flags: + --docker Run inside the ckipper-dev container (shell by default) + --firewall Add the egress firewall (requires --docker) + --account Use a specific Ckipper account (default: registered + default, or value of $CLAUDE_CONFIG_DIR if set) + +Examples: + ckipper wt run myorg/app feature # cd to worktree + ckipper wt run myorg/app feature claude # claude on host + ckipper wt run myorg/app feature --docker # shell in container + ckipper wt run myorg/app feature --docker claude # claude in container + ckipper wt run myorg/app feature --docker --firewall # + egress firewall +EOF +} + +_ckipper_worktree_help_text_list() { + cat <<'EOF' +ckipper worktree list + +Print every worktree under CKIPPER_WORKTREES_DIR, grouped by project. Useful +for finding stale worktrees you forgot to remove. +EOF +} + +_ckipper_worktree_help_text_rm() { + cat <<'EOF' +ckipper worktree rm [--force] + +Remove a worktree directory and delete the matching branch. Refuses if the +worktree has uncommitted changes; pass --force (or -f) to override. + +Args: + Path relative to CKIPPER_PROJECTS_DIR + Worktree name to remove + +Flags: + --force, -f Remove even if the worktree has uncommitted changes +EOF +} + +_ckipper_worktree_help_text_rebuild_image() { + cat <<'EOF' +ckipper worktree rebuild-image + +Rebuild the ckipper-dev Docker image (the one used by `worktree run --docker`). +Run this after editing the Dockerfile or pulling Ckipper updates that change +the entrypoint. +EOF +} diff --git a/lib/worktree/dispatcher_test.bats b/lib/worktree/dispatcher_test.bats new file mode 100644 index 0000000..9174d02 --- /dev/null +++ b/lib/worktree/dispatcher_test.bats @@ -0,0 +1,102 @@ +#!/usr/bin/env bats +# Module-level tests for lib/worktree/dispatcher.zsh. +# Verifies routing, help, and fuzzy-suggest behaviour. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run dispatcher in a zsh subshell with all its dependencies sourced +# and named subcommand handlers stubbed. +_run_dispatch() { + run env HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/core/fuzzy.zsh\" + source \"$REPO_ROOT/lib/worktree/args.zsh\" + source \"$REPO_ROOT/lib/worktree/dispatcher.zsh\" + _ckipper_worktree_run() { echo 'STUB-RUN' \"\$@\"; } + _ckipper_worktree_list_worktrees() { echo 'STUB-LIST'; } + _ckipper_worktree_remove_worktree() { echo 'STUB-RM' \"\$@\"; } + _ckipper_worktree_build_image() { echo 'STUB-REBUILD'; } + _ckipper_worktree_dispatch $* + " +} + +@test "dispatch routes 'list' to _ckipper_worktree_list_worktrees" { + _run_dispatch list + + [ "$status" -eq 0 ] + [ "$output" = "STUB-LIST" ] +} + +@test "dispatch routes 'run myproj feat' to _ckipper_worktree_run" { + _run_dispatch run myproj feat + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-RUN myproj feat" ]] +} + +@test "dispatch routes 'rm myproj feat' to _ckipper_worktree_remove_worktree" { + _run_dispatch rm myproj feat + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-RM myproj feat" ]] +} + +@test "dispatch 'rm --force myproj feat' passes positionals through" { + _run_dispatch rm --force myproj feat + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-RM myproj feat" ]] +} + +@test "dispatch 'rm' with no args prints help and returns 1" { + _run_dispatch rm + + [ "$status" -ne 0 ] + [[ "$output" =~ "ckipper worktree rm" ]] +} + +@test "dispatch routes 'rebuild-image' to _ckipper_worktree_build_image" { + _run_dispatch rebuild-image + + [ "$status" -eq 0 ] + [ "$output" = "STUB-REBUILD" ] +} + +@test "dispatch short-circuits 'run --help'" { + _run_dispatch run --help + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper worktree run" ]] + [[ "$output" =~ "--docker" ]] +} + +@test "dispatch with no args prints overview help" { + _run_dispatch + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper worktree" ]] + [[ "$output" =~ "Short form" ]] +} + +@test "dispatch fuzzy-suggests on close typo" { + _run_dispatch lst + + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown command: 'lst'. Did you mean: 'list'?" ]] +} + +@test "dispatch prints bare unknown-command line on far-off typo" { + _run_dispatch xyzzy + + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown command: 'xyzzy'." ]] + [[ ! "$output" =~ "Did you mean" ]] +} diff --git a/lib/worktree/run.zsh b/lib/worktree/run.zsh new file mode 100644 index 0000000..18c600b --- /dev/null +++ b/lib/worktree/run.zsh @@ -0,0 +1,38 @@ +#!/usr/bin/env zsh +# `ckipper worktree run` orchestrator. The body of the old w() function, +# minus the mode dispatch that's now in lib/worktree/dispatcher.zsh. + +# Create-or-cd to a worktree and either drop into the host shell, run a host +# command, launch a Docker container, or run a command inside the container. +# +# Args: +# $@ — exactly the args passed to `ckipper worktree run`: +# [--docker] [--firewall] [--account ] [cmd...] +# +# Returns: +# 0 on success; 1 on usage error or launch failure. +# +# Errors (stderr): +# "Error: --firewall requires --docker" — when --firewall is passed without --docker. +_ckipper_worktree_run() { + _ckipper_worktree_parse_run_args "$@" + + if [[ -z "$CKIPPER_WT_PROJECT" || -z "$CKIPPER_WT_BRANCH" ]]; then + _ckipper_worktree_help_for run >&2 + return 1 + fi + + if [[ "$CKIPPER_WT_FLAG_FIREWALL" = true && "$CKIPPER_WT_FLAG_DOCKER" = false ]]; then + echo "Error: --firewall requires --docker" >&2 + return 1 + fi + + _ckipper_worktree_resolve_account || return $? + _ckipper_worktree_create_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" || return $? + + if [[ "$CKIPPER_WT_FLAG_DOCKER" = true ]]; then + _ckipper_worktree_run_docker_mode + else + _ckipper_worktree_run_normal_mode + fi +} diff --git a/lib/worktree/run_test.bats b/lib/worktree/run_test.bats new file mode 100644 index 0000000..83873e4 --- /dev/null +++ b/lib/worktree/run_test.bats @@ -0,0 +1,77 @@ +#!/usr/bin/env bats +# Module-level tests for lib/worktree/run.zsh. +# Verifies the orchestration: bad arg combos, mode selection (docker vs normal). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Helper: run _ckipper_worktree_run in a zsh subshell with stubs for +# resolve-account, create-worktree, docker-mode, normal-mode, and help. +_run_run() { + run env HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/worktree/args.zsh\" + source \"$REPO_ROOT/lib/worktree/run.zsh\" + _ckipper_worktree_resolve_account() { echo 'STUB-RESOLVE'; } + _ckipper_worktree_create_worktree() { echo 'STUB-CREATE' \"\$@\"; } + _ckipper_worktree_run_docker_mode() { echo 'STUB-DOCKER'; } + _ckipper_worktree_run_normal_mode() { echo 'STUB-NORMAL'; } + _ckipper_worktree_help_for() { echo \"STUB-HELP-FOR \$@\"; } + _ckipper_worktree_run $* + " +} + +@test "run with no args prints help and exits 1" { + _run_run + + [ "$status" -eq 1 ] + [[ "$output" =~ "STUB-HELP-FOR run" ]] +} + +@test "run with only project prints help and exits 1" { + _run_run myproj + + [ "$status" -eq 1 ] + [[ "$output" =~ "STUB-HELP-FOR run" ]] +} + +@test "run with --firewall but no --docker exits 1 with error" { + _run_run myproj feat --firewall + + [ "$status" -eq 1 ] + [[ "$output" =~ "Error: --firewall requires --docker" ]] +} + +@test "run normal-mode happy path calls resolve, create, normal" { + _run_run myproj feat + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-RESOLVE" ]] + [[ "$output" =~ "STUB-CREATE myproj feat" ]] + [[ "$output" =~ "STUB-NORMAL" ]] + [[ ! "$output" =~ "STUB-DOCKER" ]] +} + +@test "run docker-mode happy path calls resolve, create, docker" { + _run_run myproj feat --docker + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-RESOLVE" ]] + [[ "$output" =~ "STUB-CREATE myproj feat" ]] + [[ "$output" =~ "STUB-DOCKER" ]] + [[ ! "$output" =~ "STUB-NORMAL" ]] +} + +@test "run docker + firewall happy path calls docker mode" { + _run_run myproj feat --docker --firewall + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-DOCKER" ]] +} From 922519bcd4c0e1908d2a81584ccc34fe0e5de0ae Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:12:12 -0600 Subject: [PATCH 079/165] Rewrite top-level ckipper() dispatcher: namespaces + short aliases + fuzzy --- Makefile | 1 - ckipper.zsh | 203 +++++++++++++++++-------------------------- ckipper_test.bats | 183 +++++++++++++++++++------------------- install.sh | 34 ++++---- install_test.bats | 1 - w-function.zsh | 194 ----------------------------------------- w-function_test.bats | 130 --------------------------- 7 files changed, 182 insertions(+), 564 deletions(-) delete mode 100644 w-function.zsh delete mode 100644 w-function_test.bats diff --git a/Makefile b/Makefile index 58759c9..603bc40 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,6 @@ lint-shell: lint-zsh: zsh -n ckipper.zsh - zsh -n w-function.zsh @if [ -d lib ]; then \ find lib -name '*.zsh' -not -name '*_test.bats' | while read -r f; do zsh -n "$$f" || exit 1; done; \ fi diff --git a/ckipper.zsh b/ckipper.zsh index 3293782..ee05d45 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -1,10 +1,12 @@ #!/usr/bin/env zsh # Ckipper main dispatcher. -# Sources shared primitives from lib/core/ and account-management subcommands from lib/account/. -# Public functions exposed: ckipper, ck. +# +# Sources shared primitives from lib/core/, account-management subcommands +# from lib/account/, and worktree subcommands from lib/worktree/, then +# exposes the top-level `ckipper` command (with `ck` short alias). # Ckipper (pronounced "skipper") — multi-account Claude Code manager -# Sourced by w-function.zsh +# Sourced from ~/.zshrc. CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" @@ -12,171 +14,126 @@ CKIPPER_REGISTRY_VERSION=1 CKIPPER_REPO_DIR="${0:A:h}" +# Core primitives source "$CKIPPER_REPO_DIR/lib/core/utils.zsh" source "$CKIPPER_REPO_DIR/lib/core/registry.zsh" source "$CKIPPER_REPO_DIR/lib/core/keychain.zsh" +source "$CKIPPER_REPO_DIR/lib/core/fuzzy.zsh" + +# Account-namespace modules source "$CKIPPER_REPO_DIR/lib/account/account-management.zsh" source "$CKIPPER_REPO_DIR/lib/account/aliases.zsh" source "$CKIPPER_REPO_DIR/lib/account/plugin-repair.zsh" source "$CKIPPER_REPO_DIR/lib/account/sync.zsh" source "$CKIPPER_REPO_DIR/lib/account/doctor.zsh" - -# Dispatch a ckipper subcommand or print top-level help. +source "$CKIPPER_REPO_DIR/lib/account/dispatcher.zsh" + +# Worktree-namespace modules +source "$CKIPPER_REPO_DIR/lib/worktree/dispatcher.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/args.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/run.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/build-image.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/normal-mode.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/docker-mode.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/ports.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/resolve-account.zsh" +source "$CKIPPER_REPO_DIR/lib/worktree/worktree.zsh" + +# Top-level commands. Used both for routing and for fuzzy-suggest. +_CKIPPER_COMMANDS=(account worktree doctor help) + +# Dispatch a top-level ckipper command. # # Args: -# $1 — subcommand name (add, list, default, remove, rename, sync, sync-hooks, -# doctor, repair-plugins, help, -h, --help, or empty) -# $@ — arguments forwarded to the subcommand handler +# $1 — top-level command (account, worktree, doctor, help, -h, --help, +# empty, or short alias acct/wt) +# $2..$N — arguments forwarded to the namespace dispatcher # -# Returns: -# 0 on success; 1 on unknown subcommand. +# Returns: 0 on success; 1 on unknown command. # # Errors (stderr): -# "Unknown command: " — when the subcommand is not recognised. +# "Unknown command: ''. Did you mean: ''? ..." ckipper() { local cmd="$1" shift 2>/dev/null case "$cmd" in - # --help on any subcommand short-circuits to subcommand help - add|list|default|remove|rename|sync|sync-hooks|repair-plugins) - if [[ "$1" == "--help" || "$1" == "-h" ]]; then - _ckipper_help_for "$cmd" - return 0 - fi - "_ckipper_account_${cmd//-/_}" "$@" - ;; + acct) cmd="account" ;; + wt) cmd="worktree" ;; + esac + case "$cmd" in + account) _ckipper_account_dispatch "$@" ;; + worktree) _ckipper_worktree_dispatch "$@" ;; doctor) if [[ "$1" == "--help" || "$1" == "-h" ]]; then - _ckipper_help_for "$cmd" + _ckipper_help_text_doctor return 0 fi _ckipper_doctor "$@" ;; ""|help|-h|--help) _ckipper_help ;; - *) echo "Unknown command: $cmd"; _ckipper_help; return 1 ;; + *) _ckipper_unknown "$cmd"; return 1 ;; esac } -# Print the top-level ckipper usage summary to stdout. +# Print the closest top-level command match (or a bare unknown-command line) +# and point the user at help. Always writes to stderr. # -# Returns: -# 0 always. +# Args: $1 — the unknown command the user typed. +# Returns: 0 always. +_ckipper_unknown() { + local cmd="$1" suggestion + suggestion=$(_core_fuzzy_suggest "$cmd" "${_CKIPPER_COMMANDS[@]}") + if [[ -n "$suggestion" ]]; then + echo "Unknown command: '$cmd'. Did you mean: '$suggestion'?" >&2 + else + echo "Unknown command: '$cmd'." >&2 + fi + echo "Run 'ckipper help' for available commands." >&2 +} + +# Print the top-level ckipper usage summary. +# +# Returns: 0 always. _ckipper_help() { cat <<'EOF' ckipper (pronounced "skipper") — multi-account Claude Code manager Usage: - ckipper add Register a new account (interactive /login) - ckipper add --adopt Register an existing populated config dir - ckipper list Show registered accounts - ckipper default Set the default account - ckipper remove Unregister (does not delete the dir) - ckipper rename Rename an account (dir + registry + aliases) - ckipper sync Copy MCP/settings from one account to another - ckipper sync-hooks Copy hooks into all registered accounts - ckipper doctor Diagnostic check of registered accounts and tooling - ckipper repair-plugins Rewrite stale ~/.claude/ paths in plugin metadata + ckipper account Manage Claude accounts (alias: acct) + ckipper worktree Manage git worktrees (alias: wt) + ckipper doctor Diagnostic check of accounts and tooling + ckipper help Show this overview Companion commands (sourced via aliases.zsh): - claude- [args...] Auto-generated launcher per registered account - [args...] Bare-name shortcut (skipped if it would shadow an - existing command, builtin, alias, or reserved word) - -Run `ckipper --help` for per-subcommand details. -EOF -} - -# Print help text for the 'add' subcommand. -_help_text_add() { - cat <<'EOF' -ckipper add [--adopt] + claude- [args...] Auto-generated launcher per registered account + [args...] Bare-name shortcut (skipped if it would shadow + an existing command, builtin, alias, or word) -Register a new account. must match ^[a-z0-9_-]+$. +Run `ckipper help` (e.g. `ckipper account help`) for the +subcommand list, and `ckipper --help` for per- +subcommand details. -Without --adopt: creates ~/.claude-/ and walks you through /login. -With --adopt: registers an existing populated ~/.claude-/ directory. +Short alias: `ck` is the same as `ckipper`. EOF } -# Print help text for the 'rename' subcommand. -_help_text_rename() { - cat <<'EOF' -ckipper rename - -Rename a registered account in place: - - Renames ~/.claude-/ → ~/.claude-/ - - Updates the registry (key + config_dir) - - If was the default, makes the default - - Regenerates aliases.zsh and re-syncs hooks - - Refuses if any Claude session is running (so the dir isn't held open) - -Keychain service name is NOT changed — only the dir + registry mapping. -EOF -} - -# Print help text for the 'repair-plugins' subcommand. -_help_text_repair_plugins() { +# Print help text for the top-level `doctor` command. +# +# Returns: 0 always. +_ckipper_help_text_doctor() { cat <<'EOF' -ckipper repair-plugins +ckipper doctor -Rewrite stale absolute paths in /plugins/{known_marketplaces, -installed_plugins}.json from $HOME/.claude/... to the account's actual dir. +Run a diagnostic checklist on registered accounts and ckipper tooling: + - Registry validity (version, JSON shape) + - Per-account: config dir presence, .claude.json/settings.json/hooks/ + - Keychain entries reachable on macOS + - ~/.zshrc sources ckipper.zsh + - Stub ~/.claude state is absent -Use this when Claude Code shows "Plugin not found in marketplace ..." for -plugins that were installed before the account directory was renamed. -Backups are written alongside each rewritten file. +Exits 0 if every check passes (or only INFOs/WARNs); exits 1 if any FAIL. EOF } -# Print help text for the 'sync' subcommand. -_help_text_sync() { - cat <<'EOF' -ckipper sync [options] - -Copy state from one registered account to another. Useful for sharing MCP -servers, plugin lists, status line, env vars, etc. across accounts without -having to re-configure each. - -By default (no flags) syncs a sensible bundle: mcpServers + enabledPlugins + -extraKnownMarketplaces + statusLine + env. - -Options: - --mcp [name1,name2,...] Sync mcpServers. Without arg: all servers. - With arg: only the named servers. - --settings Sync specific top-level keys from settings.json. - Comma-separated. Examples: enabledPlugins, - extraKnownMarketplaces, statusLine, env, model. - --all Sync the default bundle (same as no flags). - --dry-run Show what would change without writing. - -Examples: - ckipper sync personal work - ckipper sync personal work --mcp - ckipper sync personal work --mcp Vibma,github - ckipper sync personal work --settings statusLine,env --dry-run -EOF -} - -# Dispatch to the per-subcommand help text printer. -# -# Args: -# $1 — subcommand name -# -# Returns: -# 0 always. -_ckipper_help_for() { - case "$1" in - add) _help_text_add ;; - list) echo "ckipper list — print registered accounts, default, and last-login email." ;; - default) echo "ckipper default — set the default account used when no flag/env is provided." ;; - remove) echo "ckipper remove — unregister. Does not delete the dir or Keychain entry." ;; - rename) _help_text_rename ;; - sync-hooks) echo "ckipper sync-hooks — copy ~/.ckipper/hooks/* into each account's /hooks/, rewrite settings.json paths." ;; - repair-plugins) _help_text_repair_plugins ;; - sync) _help_text_sync ;; - doctor) echo "ckipper doctor — run a diagnostic checklist on registered accounts and ckipper tooling." ;; - esac -} - # Short alias: 'ck' for 'ckipper'. ck() { ckipper "$@"; } diff --git a/ckipper_test.bats b/ckipper_test.bats index ca483fa..50fd310 100644 --- a/ckipper_test.bats +++ b/ckipper_test.bats @@ -1,13 +1,13 @@ #!/usr/bin/env bats -# Characterization tests for ckipper subcommands. +# Top-level dispatcher tests for ckipper(). # -# Purpose: regression net for Phases 2-4 (modularization + refactoring). -# These tests document ACTUAL current behavior — assertions were adjusted to -# match observed output rather than idealized behavior. +# Verifies that namespace routing (account, worktree, doctor) works, that the +# acct/wt short forms are equivalent, that bare/help prints overview, that +# unknown commands fuzzy-suggest, and that namespace subcommands are reachable +# through the new dispatcher. # -# Important: ckipper.zsh is zsh-only (uses read "?..." prompt syntax, setopt, -# local-function nesting). Bats runs under bash, so every test spawns a zsh -# subprocess via run_ckipper() in test-helper.bash. +# ckipper.zsh is zsh-only (uses read "?..." prompt syntax, setopt, etc.). +# Bats runs under bash, so every test spawns a zsh subprocess via run_ckipper(). load "${BATS_TEST_DIRNAME}/tests/lib/test-helper.bash" @@ -19,132 +19,123 @@ teardown() { teardown_isolated_env } -# ── _ckipper_doctor ───────────────────────────────────────────────── +# ── Top-level routing ──────────────────────────────────────────────── -@test "ckipper doctor prints diagnostic output and mentions registry" { - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper doctor - # doctor always exits 0 or 1 depending on FAIL count; with an empty registry - # and no deployed tooling it returns some WARNs/FAILs but does not crash. - [[ "$output" =~ [Rr]egistry ]] -} - -@test "ckipper doctor prints INFO about missing registry when none exists" { - rm -f "$CKIPPER_REGISTRY" - run_ckipper doctor - # With no registry, doctor prints an INFO line and returns 0. +@test "ckipper (bare) prints top-level help and exits 0" { + run_ckipper [ "$status" -eq 0 ] - [[ "$output" =~ [Rr]egistry ]] + [[ "$output" =~ "ckipper account" ]] + [[ "$output" =~ "ckipper worktree" ]] + [[ "$output" =~ "ckipper doctor" ]] } -@test "ckipper doctor exits 0 when registry missing (no accounts registered)" { - rm -f "$CKIPPER_REGISTRY" - run_ckipper doctor +@test "ckipper help prints top-level help" { + run_ckipper help [ "$status" -eq 0 ] + [[ "$output" =~ "Short alias" ]] } -# ── _ckipper_account_sync ──────────────────────────────────────────────────── - -@test "ckipper sync --help prints usage and exits 0" { - run_ckipper sync --help - [ "$status" -eq 0 ] - [[ "$output" =~ "sync" ]] +@test "ckipper unknown-command fuzzy-suggests when close" { + run_ckipper accont + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown command: 'accont'. Did you mean: 'account'?" ]] } -@test "ckipper sync errors when source account is not registered" { - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper sync nonexistent target +@test "ckipper unknown-command shows bare error when no close match" { + run_ckipper xyzzy [ "$status" -ne 0 ] - [[ "$output" =~ "not registered" ]] + [[ "$output" =~ "Unknown command: 'xyzzy'." ]] + [[ ! "$output" =~ "Did you mean" ]] } -@test "ckipper sync errors when from and to are the same" { - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper sync same same - [ "$status" -ne 0 ] - [[ "$output" =~ "differ" ]] +# ── Account namespace + alias ──────────────────────────────────────── + +@test "ckipper account help prints account-namespace help" { + run_ckipper account help + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper account" ]] + [[ "$output" =~ "Short form" ]] } -# ── _ckipper_account_add ──────────────────────────────────────────────────── +@test "ckipper acct help is equivalent to ckipper account help" { + run_ckipper acct help + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper account" ]] +} -@test "ckipper add rejects names containing spaces (invalid regex)" { - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper add "Invalid Name With Spaces" - [ "$status" -ne 0 ] - [[ "$output" =~ "must match" || "$output" =~ [Ii]nvalid ]] +@test "ckipper account list prints message when no accounts registered" { + rm -f "$CKIPPER_REGISTRY" + run_ckipper account list + [ "$status" -eq 0 ] + [[ "$output" =~ "No accounts" || "$output" =~ "no accounts" ]] } -@test "ckipper add rejects uppercase names" { - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper add "MyAccount" - [ "$status" -ne 0 ] - [[ "$output" =~ "must match" || "$output" =~ [Ii]nvalid ]] +@test "ckipper acct list works through the short alias" { + rm -f "$CKIPPER_REGISTRY" + run_ckipper acct list + [ "$status" -eq 0 ] + [[ "$output" =~ "No accounts" || "$output" =~ "no accounts" ]] } -@test "ckipper add with builtin name 'cd' fails after prompt due to missing claude binary" { - # Note: 'cd' passes the name-regex check (^[a-z0-9_-]+$). It does NOT get - # rejected at validation time. Instead, ckipper proceeds to launch 'claude' - # which is not available in the test env. Feeding "skip" at the - # "Press enter to launch" prompt causes a clean abort. - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - local stdin_file="$TMP_HOME/stdin.txt" - printf 'skip\n' > "$stdin_file" - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="linux" \ - CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; ckipper add cd" < "$stdin_file" - # After "skip" input, ckipper aborts with exit 1. - [ "$status" -ne 0 ] - [[ "$output" =~ "Aborted" || "$output" =~ "abort" ]] +@test "ckipper account add --help prints add-specific help" { + run_ckipper account add --help + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper account add" ]] + [[ "$output" =~ "--adopt" ]] } -@test "ckipper add with no name prints usage and exits 1" { +@test "ckipper account remove rejects unknown name" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper add + run_ckipper account remove nobody [ "$status" -ne 0 ] - [[ "$output" =~ [Uu]sage ]] + [[ "$output" =~ "not registered" ]] } -# ── _ckipper_account_list ──────────────────────────────────────────────────── +# ── Worktree namespace + alias ─────────────────────────────────────── -@test "ckipper list shows registered accounts" { - echo '{"version":1,"default":"work","accounts":{"work":{"config_dir":"/tmp/.claude-work","keychain_service":"Claude Code-credentials-work"}}}' > "$CKIPPER_REGISTRY" - run_ckipper list +@test "ckipper worktree help prints worktree-namespace help" { + run_ckipper worktree help [ "$status" -eq 0 ] - [[ "$output" =~ "work" ]] + [[ "$output" =~ "ckipper worktree" ]] + [[ "$output" =~ "Short form" ]] } -@test "ckipper list prints a message when no accounts registered" { - rm -f "$CKIPPER_REGISTRY" - run_ckipper list +@test "ckipper wt help is equivalent to ckipper worktree help" { + run_ckipper wt help [ "$status" -eq 0 ] - [[ "$output" =~ "No accounts" || "$output" =~ "no accounts" ]] + [[ "$output" =~ "ckipper worktree" ]] } -# ── _ckipper_account_remove ────────────────────────────────────────────────── - -@test "ckipper remove unregisters a known account and exits 0" { - echo '{"version":1,"default":null,"accounts":{"tmp":{"config_dir":"/tmp/.claude-tmp","keychain_service":"Claude Code-credentials-tmp"}}}' > "$CKIPPER_REGISTRY" - # Note: ckipper remove has no --yes flag; it removes without prompting. - run_ckipper remove tmp +@test "ckipper worktree run --help prints run-specific help" { + run_ckipper worktree run --help [ "$status" -eq 0 ] - [[ "$output" =~ "Unregistered" ]] + [[ "$output" =~ "ckipper worktree run" ]] + [[ "$output" =~ "--docker" ]] } -@test "ckipper remove errors on unknown account name" { - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper remove nobody +@test "ckipper wt run with no args prints help and exits 1" { + run_ckipper wt run [ "$status" -ne 0 ] - [[ "$output" =~ "not registered" ]] + [[ "$output" =~ "ckipper worktree run" ]] } -@test "ckipper remove with no name prints usage and exits 1" { +# ── Doctor (top-level command, not a namespace) ────────────────────── + +@test "ckipper doctor --help prints doctor-specific help" { + run_ckipper doctor --help + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper doctor" ]] + [[ "$output" =~ "Registry validity" || "$output" =~ "registry" ]] +} + +@test "ckipper doctor exits 0 when registry missing (no accounts)" { + rm -f "$CKIPPER_REGISTRY" + run_ckipper doctor + [ "$status" -eq 0 ] +} + +@test "ckipper doctor mentions registry in output" { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" - run_ckipper remove - [ "$status" -ne 0 ] - [[ "$output" =~ [Uu]sage ]] + run_ckipper doctor + [[ "$output" =~ [Rr]egistry ]] } diff --git a/install.sh b/install.sh index 1cb3518..394f6fa 100755 --- a/install.sh +++ b/install.sh @@ -52,9 +52,8 @@ chmod +x "$CKIPPER_DIR/hooks/bash-guardrails.sh" chmod +x "$CKIPPER_DIR/hooks/docker-context.sh" chmod +x "$CKIPPER_DIR/hooks/notify-bell.sh" -# 4. Copy w-function.zsh, ckipper.zsh, and the lib/ tree. -echo "Copying w-function.zsh, ckipper.zsh, and lib/ to $CKIPPER_DIR/docker/..." -cp "$REPO_DIR/w-function.zsh" "$CKIPPER_DIR/docker/" +# 4. Copy ckipper.zsh and the lib/ tree. +echo "Copying ckipper.zsh and lib/ to $CKIPPER_DIR/docker/..." cp "$REPO_DIR/ckipper.zsh" "$CKIPPER_DIR/docker/" # Deploy lib/ tree, EXCLUDING test files (*_test.bats, *_test.py). @@ -99,21 +98,18 @@ cp "$REPO_DIR/templates/settings-template.json" "$CKIPPER_DIR/settings-template. echo " Settings template deployed. ckipper sync-hooks applies it per-account." # 7. Add or update source line in .zshrc -# The legacy line could be any of: -# source "$HOME/.claude/docker/w-function.zsh" -# source ~/.claude/docker/w-function.zsh -# source $HOME/.claude/docker/w-function.zsh -# We rewrite the whole line (consuming any trailing quote) to a canonical quoted form. -if grep -q '\.claude/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then - sed -i.bak -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*\.claude/docker/w-function\.zsh["'\'']?[[:space:]]*$|source "$HOME/.ckipper/docker/w-function.zsh"|' "$HOME/.zshrc" - echo " Updated ~/.zshrc source line to ~/.ckipper/. Backup at ~/.zshrc.bak." -elif ! grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then +# Pre-merge installs sourced w-function.zsh from ~/.claude/docker/ or +# ~/.ckipper/docker/. Both rewrite to ~/.ckipper/docker/ckipper.zsh. +if grep -qE '(claude|ckipper)/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then + sed -i.bak -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*(claude|ckipper)/docker/w-function\.zsh["'\'']?[[:space:]]*$|source "$HOME/.ckipper/docker/ckipper.zsh"|' "$HOME/.zshrc" + echo " Updated ~/.zshrc source line to ~/.ckipper/docker/ckipper.zsh. Backup at ~/.zshrc.bak." +elif ! grep -q 'ckipper/docker/ckipper\.zsh' "$HOME/.zshrc" 2>/dev/null; then echo '' >>"$HOME/.zshrc" - echo '# Ckipper — Worktree Manager (w function)' >>"$HOME/.zshrc" - echo 'source "$HOME/.ckipper/docker/w-function.zsh"' >>"$HOME/.zshrc" - echo " Added w() source line to ~/.zshrc" + echo '# Ckipper — multi-account Claude Code manager (ckipper + ckipper worktree run)' >>"$HOME/.zshrc" + echo 'source "$HOME/.ckipper/docker/ckipper.zsh"' >>"$HOME/.zshrc" + echo " Added ckipper source line to ~/.zshrc" else - echo " ~/.zshrc already sources ~/.ckipper/docker/w-function.zsh" + echo " ~/.zshrc already sources ~/.ckipper/docker/ckipper.zsh" fi # 8. Print (do not auto-append) the optional aliases.zsh source line @@ -152,8 +148,8 @@ echo "" echo "Next steps:" echo " 1. Edit $CKIPPER_DIR/docker/w-config.zsh with your MCP mounts, ports, etc." echo " 2. source ~/.zshrc" -echo " 3. w --rebuild-image" -echo " 4. ckipper add # register an account" -echo " 5. w test-branch --docker claude" +echo " 3. ckipper worktree rebuild-image # (or: ck wt rebuild-image)" +echo " 4. ckipper account add # register an account" +echo " 5. ckipper worktree run test-branch --docker claude" echo "" echo "To update later: git pull && ./install.sh" diff --git a/install_test.bats b/install_test.bats index edd072e..7c3feff 100644 --- a/install_test.bats +++ b/install_test.bats @@ -19,7 +19,6 @@ teardown() { [ -d "$TMP_HOME/.ckipper/docker/lib/account" ] [ -d "$TMP_HOME/.ckipper/docker/lib/worktree" ] [ -f "$TMP_HOME/.ckipper/docker/ckipper.zsh" ] - [ -f "$TMP_HOME/.ckipper/docker/w-function.zsh" ] } @test "install.sh excludes test files from deployed lib/" { diff --git a/w-function.zsh b/w-function.zsh deleted file mode 100644 index c15fe78..0000000 --- a/w-function.zsh +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env zsh -# ── Worktree Manager ────────────────────────────────────────────── -# Usage: -# w cd to worktree (creates if needed) -# w run command in worktree (e.g. claude) -# w --docker shell in Docker container -# w --docker claude Claude in Docker (skip-permissions) -# w --docker --firewall Docker + egress firewall -# w --list list all worktrees -# w --rm remove worktree + delete branch -# w --rebuild-image rebuild ckipper-dev Docker image -# -# is a path relative to CKIPPER_PROJECTS_DIR (default: ~/Developer; e.g. "Whmoro/orderguard", "my-app") -# -# ── CUSTOMIZATION ──────────────────────────────────────────────── -# Edit ~/.ckipper/docker/w-config.zsh to customize: -# - CKIPPER_PROJECTS_DIR: base directory for git projects (default: $HOME/Developer) -# - CKIPPER_WORKTREES_DIR: base directory for worktrees (default: $CKIPPER_PROJECTS_DIR/.worktrees) -# - CKIPPER_PORTS: dev server ports to forward -# - CKIPPER_EXTRA_VOLUMES: MCP server mounts and other volume mounts -# - CKIPPER_EXTRA_ENV: extra environment variables for the container -# -# BASE BRANCH: Worktrees are created from origin/develop. Change -# "develop" below if your default branch is different (e.g. main). -# ───────────────────────────────────────────────────────────────── - -CKIPPER_REPO_DIR="${0:A:h}" -source "$CKIPPER_REPO_DIR/ckipper.zsh" -source "$CKIPPER_REPO_DIR/lib/worktree/resolve-account.zsh" -source "$CKIPPER_REPO_DIR/lib/worktree/build-image.zsh" -source "$CKIPPER_REPO_DIR/lib/worktree/args.zsh" -source "$CKIPPER_REPO_DIR/lib/worktree/worktree.zsh" -source "$CKIPPER_REPO_DIR/lib/worktree/ports.zsh" -source "$CKIPPER_REPO_DIR/lib/worktree/docker-mode.zsh" -source "$CKIPPER_REPO_DIR/lib/worktree/normal-mode.zsh" - -# Source user config (projects/worktrees dirs, ports, extra volumes, extra env vars) -_ckipper_worktree_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/w-config.zsh" -if [[ -f "$_ckipper_worktree_config" ]]; then - source "$_ckipper_worktree_config" -fi -# Defaults if config is missing or incomplete. Set once at source time and -# never reset per-call so users can host their projects anywhere without forking. -CKIPPER_PROJECTS_DIR="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" -CKIPPER_WORKTREES_DIR="${CKIPPER_WORKTREES_DIR:-$CKIPPER_PROJECTS_DIR/.worktrees}" -(( ${#CKIPPER_PORTS[@]} == 0 )) && CKIPPER_PORTS=(3000) -(( ${#CKIPPER_EXTRA_VOLUMES[@]} == 0 )) && CKIPPER_EXTRA_VOLUMES=() -(( ${#CKIPPER_EXTRA_ENV[@]} == 0 )) && CKIPPER_EXTRA_ENV=() - -# Worktree-aware Claude Code launcher. -# -# Args: -# $1 — project path (relative to CKIPPER_PROJECTS_DIR), or a flag (--list, --rm, --rebuild-image) -# $2 — branch/worktree name (required unless $1 is --list or --rebuild-image) -# $@ — optional flags and command: [--docker] [--firewall] [--account ] [cmd...] -# -# Returns: -# 0 on success; 1 on usage error or launch failure. -# -# Errors (stderr): -# "Error: --firewall requires --docker" — when --firewall is passed without --docker. -w() { - _ckipper_worktree_parse_args "$@" - - if [[ "$CKIPPER_WT_FLAG_LIST" = true ]]; then - _ckipper_worktree_list_worktrees - elif [[ "$CKIPPER_WT_FLAG_REBUILD_IMAGE" = true ]]; then - _ckipper_worktree_build_image - return $? - elif [[ "$CKIPPER_WT_FLAG_RM" = true ]]; then - _ckipper_worktree_remove_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" - return $? - elif [[ -z "$CKIPPER_WT_PROJECT" || -z "$CKIPPER_WT_BRANCH" ]]; then - _ckipper_worktree_usage - return 1 - else - if [[ "$CKIPPER_WT_FLAG_FIREWALL" = true && "$CKIPPER_WT_FLAG_DOCKER" = false ]]; then - echo "Error: --firewall requires --docker" - return 1 - fi - _ckipper_worktree_resolve_account || return $? - _ckipper_worktree_create_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" || return $? - if [[ "$CKIPPER_WT_FLAG_DOCKER" = true ]]; then - _ckipper_worktree_run_docker_mode - else - _ckipper_worktree_run_normal_mode - fi - fi -} - -# Print usage information for the w() command. -# -# Returns: 0 always. -_ckipper_worktree_usage() { - echo "Usage: w [--docker [--firewall] [cmd...]]" - echo " w [command...]" - echo " w --list" - echo " w --rm " - echo " w --rebuild-image" - echo "" - echo "Flags:" - echo " --docker Run in Docker container (shell by default, or specify command)" - echo " --firewall Add egress firewall (only with --docker)" - echo " --account Use a specific Ckipper account (default: registered default or \$CLAUDE_CONFIG_DIR)" - echo "" - echo "Examples:" - echo " w myorg/app feature --docker # shell in container" - echo " w myorg/app feature --docker claude # Claude in container" - echo " w myorg/app feature --docker --firewall # shell + firewall" -} - -# -- Tab completion for w -- -# Ensure completions directory is in fpath -[[ -d ~/.zsh/completions ]] || mkdir -p ~/.zsh/completions -fpath=(~/.zsh/completions $fpath) - -# Bump this when the heredoc body below changes so existing installs regenerate -# the cached completion file. The version is embedded as a literal comment in -# the generated file and matched here. -CKIPPER_COMPLETION_VERSION=2 -if [[ ! -f ~/.zsh/completions/_w ]] \ - || ! grep -q "# w-completion-version=$CKIPPER_COMPLETION_VERSION" ~/.zsh/completions/_w 2>/dev/null; then - # Note: `_w()` below is a zsh tab-completion definition embedded in a heredoc. - # It uses zsh's _arguments DSL and must remain a single function for tab - # completion to work. The 25-line cap in code-style.md does not apply to - # zsh completion definitions (this is data written to a completion file, - # not maintained shell logic). - cat > ~/.zsh/completions/_w << 'COMPEOF' -#compdef w -# w-completion-version=2 - -_w() { - local projects_dir="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" - local worktrees_dir="${CKIPPER_WORKTREES_DIR:-$projects_dir/.worktrees}" - - _arguments -C \ - '(--rm)--list[List all worktrees]' \ - '(--list)--rm[Remove a worktree]' \ - '--rebuild-image[Rebuild ckipper-dev Docker image]' \ - '--account[Ckipper account to use]:account name:' \ - '1: :->project' \ - '2: :->worktree' \ - '3: :->command' \ - '*:: :->command_args' \ - && return 0 - - case $state in - project) - local -a projects - for dir in $(find "$projects_dir" -maxdepth 3 -name ".git" -type d -not -path "*/.worktrees/*" 2>/dev/null); do - local repo_dir="${dir:h}" - local rel="${repo_dir#$projects_dir/}" - projects+=("$rel") - done - _describe -t projects 'project' projects && return 0 - ;; - worktree) - local project="${words[2]}" - [[ -z "$project" ]] && return 0 - local -a worktrees - if [[ -d "$worktrees_dir/$project" ]]; then - for wt in $worktrees_dir/$project/*(N/); do - worktrees+=(${wt:t}) - done - fi - if (( ${#worktrees} > 0 )); then - _describe -t worktrees 'existing worktree' worktrees - else - _message 'new worktree branch name' - fi - ;; - command) - local -a common_commands - common_commands=( - 'claude:Start Claude Code session' - '--docker:Run in Docker container (shell or specify command)' - '--firewall:Add egress firewall (requires --docker)' - 'code:Open in VS Code' - 'npm:Run npm commands' - ) - _describe -t commands 'command' common_commands - _command_names -e - ;; - command_args) - words=(${words[4,-1]}) - CURRENT=$((CURRENT - 3)) - _normal - ;; - esac -} - -_w "$@" -COMPEOF -fi diff --git a/w-function_test.bats b/w-function_test.bats deleted file mode 100644 index fb4946c..0000000 --- a/w-function_test.bats +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env bats -# Characterization tests for the w() function in w-function.zsh. -# -# Purpose: regression net for Phases 2-4 (modularization + refactoring). -# These tests document ACTUAL current behavior. -# -# Important: w-function.zsh is zsh-only and sources ckipper.zsh at the -# bottom. Bats runs under bash, so every test spawns a zsh subprocess -# via `run env ... zsh -c "source w-function.zsh; w ..."`. - -load "${BATS_TEST_DIRNAME}/tests/lib/test-helper.bash" - -setup() { - setup_isolated_env - export DOCKER_STUB_LOG="$TMP_HOME/docker.log" - : > "$DOCKER_STUB_LOG" - mkdir -p "$CKIPPER_DIR/docker" - echo 'CKIPPER_PORTS=(3000 3030)' > "$CKIPPER_DIR/docker/w-config.zsh" - echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" -} - -teardown() { - teardown_isolated_env -} - -# ── w with no args / usage ─────────────────────────────────────────── - -@test "w with no args prints usage and exits 1" { - run_w - # Note: w() returns 1 when called with no args (falls into the empty - # project/worktree branch which prints usage and returns 1). - [ "$status" -eq 1 ] - [[ "$output" =~ [Uu]sage ]] -} - -@test "w --list prints worktree header and exits 0" { - run_w --list - [ "$status" -eq 0 ] - [[ "$output" =~ "Worktrees" || "$output" =~ "worktrees" || "$output" =~ "===" ]] -} - -@test "w --help falls through to usage output and exits 1" { - # Note: w has no --help flag. Passing --help is treated as a project name, - # falls into the missing project/worktree check, prints usage, and exits 1. - run_w --help - [ "$status" -eq 1 ] - [[ "$output" =~ [Uu]sage || "$output" =~ "Usage" ]] -} - -# ── w with missing project ─────────────────────────────────────────── - -@test "w errors when no account is registered and a project is specified" { - # With no default account set and no accounts in the registry, w() errors - # before it even gets to the project-existence check. - run_w nonexistent feature-x - [ "$status" -ne 0 ] - [[ "$output" =~ "account" || "$output" =~ "Account" ]] -} - -@test "w errors when project dir does not exist under Developer" { - # Register a default account so we get past the account check. - echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - mkdir -p "$TMP_HOME/.claude-test" - run_w nonexistent feature-x - [ "$status" -ne 0 ] - [[ "$output" =~ "not found" || "$output" =~ "Project" ]] -} - -# ── w with valid project args ──────────────────────────────────────── - -@test "w with valid project and account reaches worktree-creation logic" { - # Register a default account and create a real git repo. - echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - mkdir -p "$TMP_HOME/.claude-test" "$TMP_HOME/Developer/myapp" - (cd "$TMP_HOME/Developer/myapp" && git init -q && git commit --allow-empty -q -m "init") - # w() will fail at "git fetch origin develop" (no remote) but that's expected. - # The key characterization: it gets into the worktree-creation flow and - # prints "Creating worktree:" before failing on the fetch. - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="linux" \ - CKIPPER_FORCE=1 \ - zsh -c "source \"$REPO_ROOT/w-function.zsh\"; w myapp test-branch" - [[ "$output" =~ "Creating worktree" || "$output" =~ "fetch" || "$output" =~ "origin" ]] -} - -# ── w --rm ─────────────────────────────────────────────────────────── - -@test "w --rm with no args prints usage and exits 1" { - run_w --rm - [ "$status" -ne 0 ] - [[ "$output" =~ [Uu]sage || "$output" =~ "Usage" ]] -} - -@test "w --rm errors when worktree path does not exist" { - echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - run_w --rm myapp nonexistent-branch - [ "$status" -ne 0 ] - [[ "$output" =~ "not found" || "$output" =~ "Worktree" || "$output" =~ "worktree" ]] -} - -# ── w --firewall validation ────────────────────────────────────────── - -@test "w errors when --firewall is passed without --docker" { - echo '{"version":1,"default":"test","accounts":{"test":{"config_dir":"'"$TMP_HOME"'/.claude-test","keychain_service":null}}}' > "$CKIPPER_REGISTRY" - mkdir -p "$TMP_HOME/.claude-test" "$TMP_HOME/Developer/myapp" - run_w myapp some-branch --firewall - [ "$status" -ne 0 ] - [[ "$output" =~ "--firewall" || "$output" =~ "firewall" ]] -} - -# ── tab completion version sentinel ────────────────────────────────── - -# The completion-file regeneration mechanism relies on the literal -# "# w-completion-version=N" sentinel inside the single-quoted heredoc -# matching the CKIPPER_COMPLETION_VERSION variable referenced in the grep -# check immediately above. If a future bump only updates one side, -# existing installs silently fail to regenerate. This test guards -# against that drift. -@test "w-function.zsh: completion version sentinel matches outer variable" { - local outer inner - outer=$(grep -E '^CKIPPER_COMPLETION_VERSION=' "$REPO_ROOT/w-function.zsh" | head -1 | cut -d= -f2) - inner=$(grep -E '^# w-completion-version=' "$REPO_ROOT/w-function.zsh" | head -1 | cut -d= -f2) - [ -n "$outer" ] - [ -n "$inner" ] - [ "$outer" = "$inner" ] -} From a93c4f7c05a9aaaef9368fc2667477c635a2cb17 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:14:03 -0600 Subject: [PATCH 080/165] Merge w-function.zsh into ckipper.zsh as single entry script --- ckipper.zsh | 148 ++++++++++++++++++++++++++++++++++++++++++++++ ckipper_test.bats | 16 +++++ 2 files changed, 164 insertions(+) diff --git a/ckipper.zsh b/ckipper.zsh index ee05d45..25f60dd 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -39,6 +39,20 @@ source "$CKIPPER_REPO_DIR/lib/worktree/ports.zsh" source "$CKIPPER_REPO_DIR/lib/worktree/resolve-account.zsh" source "$CKIPPER_REPO_DIR/lib/worktree/worktree.zsh" +# User config (projects/worktrees dirs, ports, extra volumes, extra env vars). +# Renamed from w-config.zsh in the merge; install.sh handles the migration. +_ckipper_user_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper-config.zsh" +[[ -f "$_ckipper_user_config" ]] && source "$_ckipper_user_config" +unset _ckipper_user_config + +# Defaults if user config is missing or incomplete. Set once at source time +# and never reset per-call so users can host their projects anywhere. +CKIPPER_PROJECTS_DIR="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" +CKIPPER_WORKTREES_DIR="${CKIPPER_WORKTREES_DIR:-$CKIPPER_PROJECTS_DIR/.worktrees}" +(( ${#CKIPPER_PORTS[@]} == 0 )) && CKIPPER_PORTS=(3000) +(( ${#CKIPPER_EXTRA_VOLUMES[@]} == 0 )) && CKIPPER_EXTRA_VOLUMES=() +(( ${#CKIPPER_EXTRA_ENV[@]} == 0 )) && CKIPPER_EXTRA_ENV=() + # Top-level commands. Used both for routing and for fuzzy-suggest. _CKIPPER_COMMANDS=(account worktree doctor help) @@ -137,3 +151,137 @@ EOF # Short alias: 'ck' for 'ckipper'. ck() { ckipper "$@"; } + +# ── Tab completion ─────────────────────────────────────────────────── +# Ensure completions directory is in fpath. +[[ -d ~/.zsh/completions ]] || mkdir -p ~/.zsh/completions +fpath=(~/.zsh/completions $fpath) + +# Bump this when the heredoc body below changes so existing installs +# regenerate the cached completion file. The version is embedded as a literal +# comment in the generated file and matched here. +CKIPPER_COMPLETION_VERSION=1 +if [[ ! -f ~/.zsh/completions/_ckipper ]] \ + || ! grep -q "# ckipper-completion-version=$CKIPPER_COMPLETION_VERSION" ~/.zsh/completions/_ckipper 2>/dev/null; then + # Note: `_ckipper()` below is a zsh tab-completion definition embedded in + # a heredoc. It uses zsh's _arguments DSL and must remain a single + # function for tab completion to work. The 25-line cap in code-style.md + # does not apply to zsh completion definitions (this is data written to + # a completion file, not maintained shell logic). + cat > ~/.zsh/completions/_ckipper << 'COMPEOF' +#compdef ckipper ck +# ckipper-completion-version=1 + +_ckipper() { + local projects_dir="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" + local worktrees_dir="${CKIPPER_WORKTREES_DIR:-$projects_dir/.worktrees}" + local -a top_commands account_subs worktree_subs + + top_commands=( + 'account:Manage Claude accounts' + 'acct:Short alias for account' + 'worktree:Manage git worktrees' + 'wt:Short alias for worktree' + 'doctor:Diagnostic check of accounts and tooling' + 'help:Show top-level help' + ) + account_subs=( + 'add:Register a new account' + 'list:Show registered accounts' + 'default:Set the default account' + 'remove:Unregister an account' + 'rename:Rename an account in place' + 'sync:Copy state between accounts' + 'sync-hooks:Re-deploy hooks into every account dir' + 'repair-plugins:Rewrite stale plugin paths' + 'help:Show account-namespace help' + ) + worktree_subs=( + 'run:Create-or-cd worktree, optionally Docker' + 'list:List all worktrees' + 'rm:Remove worktree + delete branch' + 'rebuild-image:Rebuild ckipper-dev Docker image' + 'help:Show worktree-namespace help' + ) + + _arguments -C \ + '1: :->cmd' \ + '2: :->sub' \ + '3: :->arg3' \ + '4: :->arg4' \ + '*:: :->args' \ + && return 0 + + case $state in + cmd) + _describe -t commands 'ckipper command' top_commands && return 0 + ;; + sub) + case "${words[2]}" in + account|acct) + _describe -t subcommands 'account subcommand' account_subs && return 0 + ;; + worktree|wt) + _describe -t subcommands 'worktree subcommand' worktree_subs && return 0 + ;; + esac + ;; + arg3) + case "${words[2]}/${words[3]}" in + worktree/run|wt/run|worktree/rm|wt/rm) + local -a projects + for dir in $(find "$projects_dir" -maxdepth 3 -name ".git" -type d -not -path "*/.worktrees/*" 2>/dev/null); do + local repo_dir="${dir:h}" + local rel="${repo_dir#$projects_dir/}" + projects+=("$rel") + done + _describe -t projects 'project' projects && return 0 + ;; + account/default|acct/default|account/remove|acct/remove|account/rename|acct/rename|account/sync|acct/sync|account/repair-plugins|acct/repair-plugins) + local -a accounts + if [[ -f "${CKIPPER_REGISTRY:-$HOME/.ckipper/accounts.json}" ]]; then + accounts=( $(jq -r '.accounts | keys[]' "${CKIPPER_REGISTRY:-$HOME/.ckipper/accounts.json}" 2>/dev/null) ) + fi + _describe -t accounts 'account name' accounts && return 0 + ;; + esac + ;; + arg4) + case "${words[2]}/${words[3]}" in + worktree/run|wt/run|worktree/rm|wt/rm) + local project="${words[4]}" + [[ -z "$project" ]] && return 0 + local -a worktrees + if [[ -d "$worktrees_dir/$project" ]]; then + for wt in $worktrees_dir/$project/*(N/); do + worktrees+=(${wt:t}) + done + fi + if (( ${#worktrees} > 0 )); then + _describe -t worktrees 'existing worktree' worktrees + else + _message 'new worktree branch name' + fi + ;; + esac + ;; + args) + case "${words[2]}/${words[3]}" in + worktree/run|wt/run) + local -a flags + flags=( + '--docker:Run inside the ckipper-dev Docker container' + '--firewall:Add egress firewall (requires --docker)' + '--account:Use a specific Ckipper account' + ) + _describe -t flags 'flag' flags + _command_names -e + ;; + esac + ;; + esac +} + +_ckipper "$@" +COMPEOF +fi diff --git a/ckipper_test.bats b/ckipper_test.bats index 50fd310..5c65234 100644 --- a/ckipper_test.bats +++ b/ckipper_test.bats @@ -139,3 +139,19 @@ teardown() { run_ckipper doctor [[ "$output" =~ [Rr]egistry ]] } + +# ── tab completion version sentinel ────────────────────────────────── + +# The completion-file regeneration mechanism relies on the literal +# "# ckipper-completion-version=N" sentinel inside the single-quoted heredoc +# matching the CKIPPER_COMPLETION_VERSION variable referenced in the grep +# check immediately above. If a future bump only updates one side, existing +# installs silently fail to regenerate. This test guards against that drift. +@test "ckipper.zsh: completion version sentinel matches outer variable" { + local outer inner + outer=$(grep -E '^CKIPPER_COMPLETION_VERSION=' "$REPO_ROOT/ckipper.zsh" | head -1 | cut -d= -f2) + inner=$(grep -E '^# ckipper-completion-version=' "$REPO_ROOT/ckipper.zsh" | head -1 | cut -d= -f2) + [ -n "$outer" ] + [ -n "$inner" ] + [ "$outer" = "$inner" ] +} From bda83fafb3d4d65bf11204f46fbc38e80dd59935 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:15:56 -0600 Subject: [PATCH 081/165] Rename w-config.zsh template + ckipper.zsh entry script paths --- install.sh | 12 ++++++------ lib/account/doctor.zsh | 7 +++---- ...ig.zsh.example => ckipper-config.zsh.example} | 16 +++++++++------- 3 files changed, 18 insertions(+), 17 deletions(-) rename templates/{w-config.zsh.example => ckipper-config.zsh.example} (67%) diff --git a/install.sh b/install.sh index 394f6fa..5c60268 100755 --- a/install.sh +++ b/install.sh @@ -80,14 +80,14 @@ if find "$CKIPPER_DIR/docker/lib" \( -name '*_test.*' -o -name '__pycache__' \) exit 1 fi -# 5. Generate w-config.zsh (only if it doesn't exist — never overwrite user customizations) +# 5. Generate ckipper-config.zsh (only if it doesn't exist — never overwrite user customizations) # Also preserve accounts.json and aliases.zsh if they already exist (managed by ckipper CLI). -config_file="$CKIPPER_DIR/docker/w-config.zsh" +config_file="$CKIPPER_DIR/docker/ckipper-config.zsh" if [[ ! -f $config_file ]]; then - cp "$REPO_DIR/templates/w-config.zsh.example" "$config_file" - echo " Created w-config.zsh with defaults — edit to add your MCP mounts, ports, etc." + cp "$REPO_DIR/templates/ckipper-config.zsh.example" "$config_file" + echo " Created ckipper-config.zsh with defaults — edit to add your MCP mounts, ports, etc." else - echo " w-config.zsh already exists (not overwritten)" + echo " ckipper-config.zsh already exists (not overwritten)" fi [[ -f "$CKIPPER_DIR/accounts.json" ]] && echo " accounts.json already exists (not overwritten — managed by ckipper)" [[ -f "$CKIPPER_DIR/aliases.zsh" ]] && echo " aliases.zsh already exists (not overwritten — auto-generated)" @@ -146,7 +146,7 @@ echo "" echo "=== Setup Complete ===" echo "" echo "Next steps:" -echo " 1. Edit $CKIPPER_DIR/docker/w-config.zsh with your MCP mounts, ports, etc." +echo " 1. Edit $CKIPPER_DIR/docker/ckipper-config.zsh with your MCP mounts, ports, etc." echo " 2. source ~/.zshrc" echo " 3. ckipper worktree rebuild-image # (or: ck wt rebuild-image)" echo " 4. ckipper account add # register an account" diff --git a/lib/account/doctor.zsh b/lib/account/doctor.zsh index 0066f8f..da6eb85 100644 --- a/lib/account/doctor.zsh +++ b/lib/account/doctor.zsh @@ -32,9 +32,8 @@ _ckipper_doctor_check() { _ckipper_doctor_tooling() { echo "── Tooling ───────────────────────────────────────────" if [[ -d "$CKIPPER_DIR" ]]; then _ckipper_doctor_check PASS "$CKIPPER_DIR exists"; else _ckipper_doctor_check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi - if [[ -f "$CKIPPER_DIR/docker/w-function.zsh" ]]; then _ckipper_doctor_check PASS "w-function.zsh deployed"; else _ckipper_doctor_check FAIL "w-function.zsh missing in $CKIPPER_DIR/docker/"; fi if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then _ckipper_doctor_check PASS "ckipper.zsh deployed"; else _ckipper_doctor_check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi - if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then _ckipper_doctor_check PASS "cleanup-projects.py deployed"; else _ckipper_doctor_check WARN "cleanup-projects.py missing — w --rm cleanup will silently skip"; fi + if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then _ckipper_doctor_check PASS "cleanup-projects.py deployed"; else _ckipper_doctor_check WARN "cleanup-projects.py missing — ckipper worktree rm cleanup will silently skip"; fi if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then _ckipper_doctor_check PASS "settings-template.json deployed"; else _ckipper_doctor_check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= MIN_HOOK_FILES )); then _ckipper_doctor_check PASS "hooks/ has ${MIN_HOOK_FILES}+ files" @@ -173,8 +172,8 @@ _ckipper_doctor_shell() { else _ckipper_doctor_check WARN "aliases.zsh missing — will be regenerated on next add/remove"; fi if grep -q 'ckipper/aliases.zsh' "$HOME/.zshrc" 2>/dev/null; then _ckipper_doctor_check PASS "~/.zshrc sources aliases.zsh" else _ckipper_doctor_check WARN "~/.zshrc does NOT source aliases.zsh — add: [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh"; fi - if grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then _ckipper_doctor_check PASS "~/.zshrc sources w-function.zsh" - else _ckipper_doctor_check FAIL "~/.zshrc does NOT source w-function.zsh — re-run install.sh"; fi + if grep -q 'ckipper/docker/ckipper\.zsh' "$HOME/.zshrc" 2>/dev/null; then _ckipper_doctor_check PASS "~/.zshrc sources ckipper.zsh" + else _ckipper_doctor_check FAIL "~/.zshrc does NOT source ckipper.zsh — re-run install.sh"; fi echo "" echo "── Stub files (cosmetic) ────────────────────────────" if [[ -d "$HOME/.claude" ]]; then diff --git a/templates/w-config.zsh.example b/templates/ckipper-config.zsh.example similarity index 67% rename from templates/w-config.zsh.example rename to templates/ckipper-config.zsh.example index 45107e1..0a7b352 100644 --- a/templates/w-config.zsh.example +++ b/templates/ckipper-config.zsh.example @@ -1,19 +1,21 @@ -# ── w-config.zsh ───────────────────────────────────────────────── -# User-specific configuration for the w() worktree manager. -# This file is sourced by w-function.zsh. It is never overwritten -# by install.sh — your customizations are safe across updates. +# ── ckipper-config.zsh ─────────────────────────────────────────── +# User-specific configuration for ckipper (the worktree manager + account +# tools). This file is sourced by ckipper.zsh. It is never overwritten by +# install.sh — your customizations are safe across updates. # # After editing, run: source ~/.zshrc # ───────────────────────────────────────────────────────────────── -# Base directory containing your git projects. The first arg to w() is -# resolved relative to this path. Default: $HOME/Developer +# Base directory containing your git projects. The first arg to +# `ckipper worktree run` is resolved relative to this path. Default: +# $HOME/Developer # Examples: # CKIPPER_PROJECTS_DIR="$HOME/code" # CKIPPER_PROJECTS_DIR="$HOME/work/repos" # CKIPPER_PROJECTS_DIR="$HOME/Developer" -# Where w() creates per-project worktrees. Default: $CKIPPER_PROJECTS_DIR/.worktrees +# Where `ckipper worktree run` creates per-project worktrees. Default: +# $CKIPPER_PROJECTS_DIR/.worktrees # Override only if you want worktrees outside your projects tree. # CKIPPER_WORKTREES_DIR="$HOME/.worktrees" From 2e744de270249c338dd63474e0d2305b53896550 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:18:18 -0600 Subject: [PATCH 082/165] install.sh: rewrite zshrc to ckipper.zsh, delete stale w-function/w-config/_w paths --- install.sh | 41 ++++++++++++++++++++------------ install_test.bats | 59 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/install.sh b/install.sh index 5c60268..7038527 100755 --- a/install.sh +++ b/install.sh @@ -80,7 +80,26 @@ if find "$CKIPPER_DIR/docker/lib" \( -name '*_test.*' -o -name '__pycache__' \) exit 1 fi -# 5. Generate ckipper-config.zsh (only if it doesn't exist — never overwrite user customizations) +# 5. Migrate any existing pre-merge config + clean up stale paths +# (w-function.zsh, w-config.zsh, _w completion file). +if [ -f "$CKIPPER_DIR/docker/w-config.zsh" ]; then + if [ ! -f "$CKIPPER_DIR/docker/ckipper-config.zsh" ]; then + echo " Migrating $CKIPPER_DIR/docker/w-config.zsh → ckipper-config.zsh (preserves your settings)" + cp "$CKIPPER_DIR/docker/w-config.zsh" "$CKIPPER_DIR/docker/ckipper-config.zsh" + fi + echo " Removing stale $CKIPPER_DIR/docker/w-config.zsh" + rm -f "$CKIPPER_DIR/docker/w-config.zsh" +fi +if [ -f "$CKIPPER_DIR/docker/w-function.zsh" ]; then + echo " Removing stale $CKIPPER_DIR/docker/w-function.zsh (replaced by ckipper.zsh)" + rm -f "$CKIPPER_DIR/docker/w-function.zsh" +fi +if [ -f "$HOME/.zsh/completions/_w" ]; then + echo " Removing stale $HOME/.zsh/completions/_w (replaced by _ckipper)" + rm -f "$HOME/.zsh/completions/_w" +fi + +# Generate ckipper-config.zsh (only if it doesn't exist — never overwrite user customizations). # Also preserve accounts.json and aliases.zsh if they already exist (managed by ckipper CLI). config_file="$CKIPPER_DIR/docker/ckipper-config.zsh" if [[ ! -f $config_file ]]; then @@ -99,9 +118,10 @@ echo " Settings template deployed. ckipper sync-hooks applies it per-account." # 7. Add or update source line in .zshrc # Pre-merge installs sourced w-function.zsh from ~/.claude/docker/ or -# ~/.ckipper/docker/. Both rewrite to ~/.ckipper/docker/ckipper.zsh. -if grep -qE '(claude|ckipper)/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then - sed -i.bak -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*(claude|ckipper)/docker/w-function\.zsh["'\'']?[[:space:]]*$|source "$HOME/.ckipper/docker/ckipper.zsh"|' "$HOME/.zshrc" +# ~/.ckipper/docker/. The regex matches either install root and rewrites +# to the canonical ~/.ckipper/docker/ckipper.zsh. +if grep -qE '/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then + sed -i.bak -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*/docker/w-function\.zsh["'\'']?[[:space:]]*$|source "$HOME/.ckipper/docker/ckipper.zsh"|' "$HOME/.zshrc" echo " Updated ~/.zshrc source line to ~/.ckipper/docker/ckipper.zsh. Backup at ~/.zshrc.bak." elif ! grep -q 'ckipper/docker/ckipper\.zsh' "$HOME/.zshrc" 2>/dev/null; then echo '' >>"$HOME/.zshrc" @@ -118,16 +138,7 @@ echo "Optional: enable per-account launchers (claude- and bare ) by echo " [[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh" echo "" -# 9. Warn about inlined w() from old installs -if grep -q '^w()' "$HOME/.zshrc" 2>/dev/null || grep -q '^_w_build_image()' "$HOME/.zshrc" 2>/dev/null; then - echo "" - echo "WARNING: Your ~/.zshrc contains an inlined w() function from a previous install." - echo "The new approach sources it from $CKIPPER_DIR/docker/w-function.zsh instead." - echo "Please remove the old inlined function from ~/.zshrc manually." - echo "(Search for '_w_build_image()' or 'w()' and remove everything through the 'COMPEOF' line)" -fi - -# 10. Set up git hooks path (only if user hasn't already configured a different one, +# 9. Set up git hooks path (only if user hasn't already configured a different one, # e.g. for husky, pre-commit, or another tool — never silently clobber) echo "Configuring git hooks path..." mkdir -p "$HOME/.git-hooks" @@ -141,7 +152,7 @@ else echo ' git config --global core.hooksPath "$HOME/.git-hooks"' fi -# 11. Print summary +# 10. Print summary echo "" echo "=== Setup Complete ===" echo "" diff --git a/install_test.bats b/install_test.bats index 7c3feff..e2df0bd 100644 --- a/install_test.bats +++ b/install_test.bats @@ -19,6 +19,7 @@ teardown() { [ -d "$TMP_HOME/.ckipper/docker/lib/account" ] [ -d "$TMP_HOME/.ckipper/docker/lib/worktree" ] [ -f "$TMP_HOME/.ckipper/docker/ckipper.zsh" ] + [ -f "$TMP_HOME/.ckipper/docker/ckipper-config.zsh" ] } @test "install.sh excludes test files from deployed lib/" { @@ -30,14 +31,66 @@ teardown() { [ -z "$output" ] } -@test "install.sh re-run preserves existing w-config.zsh" { +@test "install.sh re-run preserves customised ckipper-config.zsh" { HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" run "$REPO_ROOT/install.sh" [ "$status" -eq 0 ] - echo 'CUSTOM_VALUE="preserve_me"' >> "$TMP_HOME/.ckipper/docker/w-config.zsh" + echo 'CUSTOM_VALUE="preserve_me"' >> "$TMP_HOME/.ckipper/docker/ckipper-config.zsh" HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ run "$REPO_ROOT/install.sh" [ "$status" -eq 0 ] - grep -q 'CUSTOM_VALUE="preserve_me"' "$TMP_HOME/.ckipper/docker/w-config.zsh" + grep -q 'CUSTOM_VALUE="preserve_me"' "$TMP_HOME/.ckipper/docker/ckipper-config.zsh" +} + +@test "install.sh deletes stale w-function.zsh from a pre-merge install" { + mkdir -p "$TMP_HOME/.ckipper/docker" + echo "# stale w-function" > "$TMP_HOME/.ckipper/docker/w-function.zsh" + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + [ ! -f "$TMP_HOME/.ckipper/docker/w-function.zsh" ] +} + +@test "install.sh deletes stale _w completion file from a pre-merge install" { + mkdir -p "$HOME/.zsh/completions" + echo "# stale _w" > "$HOME/.zsh/completions/_w" + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + [ ! -f "$TMP_HOME/.zsh/completions/_w" ] +} + +@test "install.sh migrates pre-merge w-config.zsh into ckipper-config.zsh" { + mkdir -p "$TMP_HOME/.ckipper/docker" + cat > "$TMP_HOME/.ckipper/docker/w-config.zsh" << 'EOF' +# pre-merge user config +CUSTOM_VALUE="from_old_config" +EOF + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + [ ! -f "$TMP_HOME/.ckipper/docker/w-config.zsh" ] + [ -f "$TMP_HOME/.ckipper/docker/ckipper-config.zsh" ] + grep -q 'CUSTOM_VALUE="from_old_config"' "$TMP_HOME/.ckipper/docker/ckipper-config.zsh" +} + +@test "install.sh rewrites pre-merge ~/.zshrc source line to ckipper.zsh" { + cat > "$HOME/.zshrc" << 'EOF' +# Some user config +source "$HOME/.ckipper/docker/w-function.zsh" +EOF + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + grep -q 'ckipper/docker/ckipper\.zsh' "$HOME/.zshrc" + ! grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" } From 973dc7ed82b46fb9cd83b41012d898b833c78475 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:19:28 -0600 Subject: [PATCH 083/165] Doctor + per-subcommand help text: update user-facing strings to namespaced commands --- lib/account/account-management.zsh | 22 +++++++++++----------- lib/account/aliases.zsh | 2 +- lib/account/doctor.zsh | 4 ++-- lib/account/plugin-repair.zsh | 6 +++--- lib/account/sync.zsh | 4 ++-- lib/worktree/docker-mode.zsh | 4 ++-- lib/worktree/resolve-account.zsh | 6 +++--- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/account/account-management.zsh b/lib/account/account-management.zsh index 0b5e449..708b19c 100644 --- a/lib/account/account-management.zsh +++ b/lib/account/account-management.zsh @@ -11,7 +11,7 @@ typeset -gA _CKIPPER_FINALIZE_CTX # Fields: old_dir, new_dir typeset -gA _CKIPPER_RENAME_CTX -# Validate the account name and --adopt flag from `ckipper add` arguments. +# Validate the account name and --adopt flag from `ckipper account add` arguments. # Prints error messages to stdout and returns non-zero on failure. # # Args: @@ -23,7 +23,7 @@ typeset -gA _CKIPPER_RENAME_CTX _ckipper_account_add_validate_name() { local name="$1" if [[ -z "$name" ]]; then - echo "Usage: ckipper add [--adopt]" + echo "Usage: ckipper account add [--adopt]" return 1 fi if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then @@ -182,7 +182,7 @@ _ckipper_account_add_check_credentials() { return 0 fi echo "Warning: no new Keychain entry detected and no .credentials.json on disk." - echo "Login may not have completed. Re-run /login or use: ckipper add $name --adopt" + echo "Login may not have completed. Re-run /login or use: ckipper account add $name --adopt" return 1 } @@ -301,7 +301,7 @@ _ckipper_account_bare_alias_safe() { # 0 always. _ckipper_account_list() { if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - echo "No accounts registered. Run: ckipper add " + echo "No accounts registered. Run: ckipper account add " return 0 fi local default @@ -312,13 +312,13 @@ _ckipper_account_list() { _ckipper_account_list_account_line "$name" "$dir" "$default" done echo "" - echo "* = default. Run: ckipper default " + echo "* = default. Run: ckipper account default " echo "" echo "Tip: don't run the same account in two terminals at once — Claude's OAuth refresh" echo "is single-use, so the second session gets logged out. Use a different account instead." } -# Print a single account line for `ckipper list`. +# Print a single account line for `ckipper account list`. # # Args: # $1 — account name @@ -350,7 +350,7 @@ _ckipper_account_list_account_line() { _ckipper_account_default() { _core_registry_check_version || return 1 local name="$1" - [[ -z "$name" ]] && { echo "Usage: ckipper default "; return 1; } + [[ -z "$name" ]] && { echo "Usage: ckipper account default "; return 1; } if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then echo "Account '$name' is not registered." return 1 @@ -369,7 +369,7 @@ _ckipper_account_default() { _ckipper_account_remove() { _core_registry_check_version || return 1 local name="$1" - [[ -z "$name" ]] && { echo "Usage: ckipper remove "; return 1; } + [[ -z "$name" ]] && { echo "Usage: ckipper account remove "; return 1; } if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then echo "Account '$name' is not registered." return 1 @@ -390,7 +390,7 @@ _ckipper_account_remove() { fi } -# Validate arguments for `ckipper rename` before performing the rename. +# Validate arguments for `ckipper account rename` before performing the rename. # # Args: # $1 — old account name @@ -401,7 +401,7 @@ _ckipper_account_remove() { _ckipper_account_rename_validate() { local old="$1" new="$2" if [[ -z "$old" || -z "$new" ]]; then - echo "Usage: ckipper rename " + echo "Usage: ckipper account rename " return 1 fi if [[ ! "$new" =~ ^[a-z0-9_-]+$ ]]; then @@ -422,7 +422,7 @@ _ckipper_account_rename_validate() { fi } -# Perform the directory move and registry update for `ckipper rename`. +# Perform the directory move and registry update for `ckipper account rename`. # Rolls back the directory rename if the registry write fails. # Reads old_dir and new_dir from _CKIPPER_RENAME_CTX module global. # diff --git a/lib/account/aliases.zsh b/lib/account/aliases.zsh index 8bf3853..2e7213c 100644 --- a/lib/account/aliases.zsh +++ b/lib/account/aliases.zsh @@ -45,7 +45,7 @@ _ckipper_account_write_bare_claude_guard() { echo " if [[ -n \"\$default\" ]]; then" echo " echo \"Use: claude-\$default\" >&2" echo " else" - echo " echo \"Set a default first: ckipper default , then use claude-.\" >&2" + echo " echo \"Set a default first: ckipper account default , then use claude-.\" >&2" echo " fi" echo " echo \"\" >&2" echo " echo \"To bypass (fresh login on purpose): command claude \\\$@\" >&2" diff --git a/lib/account/doctor.zsh b/lib/account/doctor.zsh index da6eb85..795c064 100644 --- a/lib/account/doctor.zsh +++ b/lib/account/doctor.zsh @@ -61,11 +61,11 @@ _ckipper_doctor_registry() { else _ckipper_doctor_check WARN "registry permissions $perms (expected 600)"; fi local default_acc; default_acc=$(jq -r '.default // ""' "$CKIPPER_REGISTRY") if [[ -z "$default_acc" ]]; then - _ckipper_doctor_check WARN "no default account set — w/ckipper-add will require --account" + _ckipper_doctor_check WARN "no default account set — ckipper worktree run will require --account" elif jq -e --arg n "$default_acc" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then _ckipper_doctor_check INFO "default account: $default_acc" else - _ckipper_doctor_check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper default " + _ckipper_doctor_check FAIL "default account '$default_acc' is NOT in registry — fix with: ckipper account default " fi } diff --git a/lib/account/plugin-repair.zsh b/lib/account/plugin-repair.zsh index c5a2407..e5d0d7a 100644 --- a/lib/account/plugin-repair.zsh +++ b/lib/account/plugin-repair.zsh @@ -80,19 +80,19 @@ _ckipper_account_detect_stale_plugin_prefix() { # 0 on success or when no repair is needed; 1 on error. # # Errors (stderr): -# "Usage: ckipper repair-plugins " — when name is empty. +# "Usage: ckipper account repair-plugins " — when name is empty. # "Account '...' is not registered." — when account not found. # "Account dir does not exist: ..." — when directory is missing. _ckipper_account_repair_plugins() { local name="$1" if [[ -z "$name" ]]; then - echo "Usage: ckipper repair-plugins " + echo "Usage: ckipper account repair-plugins " return 1 fi _core_registry_check_version || return 1 local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") if [[ -z "$dir" ]]; then - echo "Account '$name' is not registered. Run: ckipper list" + echo "Account '$name' is not registered. Run: ckipper account list" return 1 fi if [[ ! -d "$dir" ]]; then diff --git a/lib/account/sync.zsh b/lib/account/sync.zsh index b3ce03f..da61d8a 100644 --- a/lib/account/sync.zsh +++ b/lib/account/sync.zsh @@ -196,14 +196,14 @@ _ckipper_account_sync_resolve_dirs() { # 0 on success; 1 on validation failure or user abort. # # Errors (stderr): -# "Usage: ckipper sync ..." — when arguments are missing. +# "Usage: ckipper account sync ..." — when arguments are missing. # " and must differ." — when both accounts are the same. _ckipper_account_sync() { _core_registry_check_version || return 1 local from="$1" to="$2" shift 2 2>/dev/null if [[ -z "$from" || -z "$to" ]]; then - echo "Usage: ckipper sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" + echo "Usage: ckipper account sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" return 1 fi [[ "$from" == "$to" ]] && { echo " and must differ."; return 1; } diff --git a/lib/worktree/docker-mode.zsh b/lib/worktree/docker-mode.zsh index 16f637d..618ada7 100644 --- a/lib/worktree/docker-mode.zsh +++ b/lib/worktree/docker-mode.zsh @@ -75,7 +75,7 @@ _ckipper_worktree_docker_validate_keychain() { if [[ -n "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE" ]] && \ ! _core_keychain_validate "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE"; then echo "Error: account '$CKIPPER_WT_ACTIVE_ACCOUNT' has invalid keychain_service in registry." - echo "Re-register with: ckipper remove $CKIPPER_WT_ACTIVE_ACCOUNT && ckipper add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" + echo "Re-register with: ckipper account remove $CKIPPER_WT_ACTIVE_ACCOUNT && ckipper account add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" return 1 fi } @@ -102,7 +102,7 @@ _ckipper_worktree_docker_extract_credentials() { fi if ! echo "$creds" | jq empty >/dev/null 2>&1; then - echo "Error: Claude credentials from Keychain are not valid JSON. Re-run: ckipper add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" >&2 + echo "Error: Claude credentials from Keychain are not valid JSON. Re-run: ckipper account add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" >&2 return 1 fi diff --git a/lib/worktree/resolve-account.zsh b/lib/worktree/resolve-account.zsh index b658063..41a2c34 100644 --- a/lib/worktree/resolve-account.zsh +++ b/lib/worktree/resolve-account.zsh @@ -14,14 +14,14 @@ # Returns: 0 on success; 1 if no account found or account not in registry. # Errors (stderr): # "Error: no account selected and no default registered." — when no account can be resolved -# "Error: account '' is not registered. Run: ckipper list" — when account missing from registry +# "Error: account '' is not registered. Run: ckipper account list" — when account missing from registry _ckipper_worktree_resolve_account() { local candidate candidate=$(_ckipper_worktree_find_account_name) if [[ -z "$candidate" ]]; then echo "Error: no account selected and no default registered." - echo "Run: ckipper list (then: ckipper default , or pass --account )" + echo "Run: ckipper account list (then: ckipper account default , or pass --account )" return 1 fi @@ -32,7 +32,7 @@ _ckipper_worktree_resolve_account() { fi if [[ -z "$config_dir" ]]; then - echo "Error: account '$candidate' is not registered. Run: ckipper list" + echo "Error: account '$candidate' is not registered. Run: ckipper account list" return 1 fi From 03812595f0172b1ca4a91818b97c5d16f80b502c Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:23:34 -0600 Subject: [PATCH 084/165] Update README, CHANGELOG, CLAUDE.md, shell-conventions, CONTRIBUTING for the merge --- .claude/CLAUDE.md | 9 ++-- .claude/rules/shell-conventions.md | 16 ++++--- CHANGELOG.md | 29 ++++++++++--- README.md | 70 +++++++++++++++--------------- lib/account/aliases.zsh | 2 +- 5 files changed, 74 insertions(+), 52 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9865a57..363447c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -38,11 +38,10 @@ Ckipper is a zsh-based wrapper for the [Claude Code CLI](https://claude.ai/cli) **Top-level layout:** ``` -ckipper.zsh # ckipper CLI entry (account add/remove/sync/doctor) -w-function.zsh # w() launcher entry (sourced from .zshrc) -lib/core/ # shared primitives (registry, keychain, utils) -lib/account/ # account-management subcommands -lib/w/ # w-specific helpers +ckipper.zsh # ckipper CLI entry (sourced from .zshrc) +lib/core/ # shared primitives (registry, keychain, utils, fuzzy) +lib/account/ # ckipper account subcommands +lib/worktree/ # ckipper worktree subcommands hooks/ # Claude Code safety hooks docker/ # Dockerfile + entrypoint + firewall + cleanup tests/ # bats + pytest tests diff --git a/.claude/rules/shell-conventions.md b/.claude/rules/shell-conventions.md index d6fbc7d..385f877 100644 --- a/.claude/rules/shell-conventions.md +++ b/.claude/rules/shell-conventions.md @@ -25,9 +25,10 @@ Omit `Args:` if the function takes none. Omit `Errors:` if it never writes to st Used to encode the dependency direction at a glance and let CI verify it: - `_core_*` — `lib/core/` (shared primitives) -- `_ckipper_*` — `lib/account/` (account subcommands; will be renamed `_ckipper_account_*` in a follow-up phase) -- `_w_*` — `lib/w/` (w() helpers) -- No prefix — public, callable from `.zshrc`: `ckipper`, `ck`, `w` +- `_ckipper_account_*` — `lib/account/` (account subcommands) +- `_ckipper_worktree_*` — `lib/worktree/` (worktree subcommands) +- `_ckipper_*` — top-level dispatcher in `ckipper.zsh` (and `_ckipper_doctor`, kept un-namespaced because it's exposed as a top-level command, even though its source lives in `lib/account/`) +- No prefix — public, callable from `.zshrc`: `ckipper`, `ck` ## Booleans @@ -35,6 +36,11 @@ zsh has no native bool. Use string values `"true"`/`"false"` and test with `[[ " ## Module sourcing -Modules under `lib/` are sourced once by an entry script (`ckipper.zsh` or `w-function.zsh`). Modules MUST NOT source siblings. Cross-feature imports between `lib/w/` and `lib/account/` are forbidden — extract shared code to `lib/core/` (per `file-organization.md`'s shared-parent rule). +Modules under `lib/` are sourced once by `ckipper.zsh` (the single entry script sourced from `~/.zshrc`). Modules MUST NOT source siblings. Cross-feature imports between `lib/account/` and `lib/worktree/` are forbidden — extract shared code to `lib/core/` (per `file-organization.md`'s shared-parent rule). -CI enforces this with `grep -rE '\b_ckipper_' lib/w/`. +CI enforces the namespace separation with the four guards from `make lint-merge-guards`: + +- `grep -rE '\b_w_[a-z]' lib/` — must be empty (no leftover renames from the merge) +- `grep -rE '\bW_[A-Z]' lib/` — must be empty (no leftover globals from the merge) +- `grep -rE '\b_ckipper_account_' lib/worktree/` — must be empty (worktree mustn't reach into account) +- `grep -rE '\b_ckipper_worktree_' lib/account/` — must be empty (account mustn't reach into worktree) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f485c..f69abd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] — Breaking changes: merge `w` into `ckipper` ### Removed -- `ckipper migrate` subcommand (legacy `claude-docker-sandbox` migration). The legacy install path is no longer supported; use `ckipper add ` for fresh installs. +- `w()` shell function (replaced by `ckipper worktree run`). +- `ckipper migrate` subcommand (legacy claude-docker-sandbox migration). +- `~/.ckipper/docker/w-function.zsh` (replaced by `~/.ckipper/docker/ckipper.zsh`; `install.sh` deletes the stale file and rewrites `~/.zshrc`). +- `~/.ckipper/docker/w-config.zsh` (replaced by `~/.ckipper/docker/ckipper-config.zsh`; `install.sh` migrates content automatically). +- `~/.zsh/completions/_w` (replaced by `~/.zsh/completions/_ckipper`). + +### Renamed +- `lib/w/` → `lib/worktree/`. +- `lib/ckipper/` → `lib/account/`. +- `_w_*` functions → `_ckipper_worktree_*`. +- `_ckipper_*` (account ops) → `_ckipper_account_*` (`_ckipper_doctor*` and the top-level dispatcher helpers stay un-namespaced). +- `W_PROJECTS_DIR` / `W_WORKTREES_DIR` / `W_PORTS` / `W_EXTRA_VOLUMES` / `W_EXTRA_ENV` / `W_REPO_DIR` / `W_COMPLETION_VERSION` → `CKIPPER_*` (same suffix; user-configurable via `ckipper-config.zsh`). +- Worktree runtime globals (`W_FLAG_*`, `W_PROJECT`, `W_BRANCH`, `W_CLI_ACCOUNT`, `W_COMMAND`, `W_ACTIVE_*`, `W_WT_PATH`, `W_RESOLVED_PORTS`, `W_DOCKER_ARGS`, `W_FIND_MAX_DEPTH`) → `CKIPPER_WT_*`. ### Added -- `CKIPPER_PROJECTS_DIR` and `CKIPPER_WORKTREES_DIR` are now user-configurable via `w-config.zsh`. Defaults remain `$HOME/Developer` and `$CKIPPER_PROJECTS_DIR/.worktrees`. Existing tab-completion files regenerate on next shell startup via a version sentinel. - -### Changed -- Repository layout: templates moved to `templates/` (`w-config.zsh.example`, `settings-template.json`); manual integration test prompt moved to `docs/test-prompt.md`. Source name `settings-hooks.json` renamed to `settings-template.json` to match the deployed name. +- Namespaced commands: `ckipper account ...`, `ckipper worktree ...`. +- Short namespace forms: `ckipper acct ...`, `ckipper wt ...` (and the existing `ck` as a shorthand for `ckipper`). +- Universal fuzzy-suggest on unknown subcommands (Levenshtein distance ≤ 2). +- Per-subcommand `--help` / `-h` for every command (e.g. `ckipper account add --help`). +- `lib/core/fuzzy.zsh` — `_core_fuzzy_suggest` helper. +- `make lint-merge-guards` — four CI grep guards that catch leftover `_w_*`/`W_*` references and cross-namespace imports. + +### Migration +Run `./install.sh`. It rewrites `~/.zshrc`, deletes stale paths (`w-function.zsh`, `_w` completion file), and preserves your existing `w-config.zsh` content as `ckipper-config.zsh` (variable names already match — they were renamed in this release). ## [0.1.0] — 2026-04-28 diff --git a/README.md b/README.md index 67970a9..03766fb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping- ## The Solution ```bash -w myorg/myapp my-feature --docker claude +ck wt run myorg/myapp my-feature --docker claude ``` Creates a git worktree, spins up a Docker container, and runs Claude inside it. Claude thinks it has full permissions but can only see the worktree. Your other projects, system files, and credentials are inaccessible. @@ -29,15 +29,15 @@ Creates a git worktree, spins up a Docker container, and runs Claude inside it. ## Quick Reference ```bash -w myorg/myapp feature-x --docker claude # Claude in Docker (skip-permissions) -w myorg/myapp feature-x --docker --account work # use a specific Ckipper account -w myorg/myapp feature-x --docker # shell in Docker container -w myorg/myapp feature-x --docker --firewall # Docker + egress firewall -w myorg/myapp feature-x # cd to worktree (no Docker) -w myorg/myapp feature-x claude # run Claude in worktree (no Docker) -w --list # list all worktrees -w --rm myorg/myapp feature-x # remove worktree + delete branch -w --rebuild-image # rebuild Docker image +ck wt run myorg/myapp feature-x --docker claude # Claude in Docker (skip-permissions) +ck wt run myorg/myapp feature-x --docker --account work # use a specific Ckipper account +ck wt run myorg/myapp feature-x --docker # shell in Docker container +ck wt run myorg/myapp feature-x --docker --firewall # Docker + egress firewall +ck wt run myorg/myapp feature-x # cd to worktree (no Docker) +ck wt run myorg/myapp feature-x claude # run Claude in worktree (no Docker) +ck wt list # list all worktrees +ck wt rm myorg/myapp feature-x # remove worktree + delete branch +ck wt rebuild-image # rebuild Docker image ckipper add # register a Claude account (interactive /login) ckipper list # show registered accounts @@ -77,7 +77,7 @@ CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form ### Inside Docker ```bash -w myorg/app feature --account work --docker claude +ck wt run myorg/app feature --account work --docker claude ``` If you're already in a terminal where `CLAUDE_CONFIG_DIR` is set (e.g., via `claude-work`), `w` picks up the account automatically — no flag needed. @@ -173,7 +173,7 @@ Four Claude Code hooks activate inside Docker: ### Optional Egress Firewall ```bash -w myorg/myapp feature-x --docker --firewall claude +ck wt run myorg/myapp feature-x --docker --firewall claude ``` Default-deny iptables firewall that only allows outbound traffic to whitelisted domains. Uses `iptables-legacy` (Docker Desktop doesn't support `nf_tables`). DNS auto-detected from `/etc/resolv.conf`. Blocked requests silently drop (~60s timeout). @@ -189,7 +189,7 @@ Default whitelist: Anthropic API, GitHub, npm, PyPI, Sentry, and common MCP serv | MCPs with local files | node/uvx (mounted ro) | Yes (add mount) | | Docker-based MCPs | Docker-in-Docker | No (security) | -For MCPs that reference local files, add entries to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Mount at the exact same host path so MCP configs work unchanged. +For MCPs that reference local files, add entries to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/ckipper-config.zsh`. Mount at the exact same host path so MCP configs work unchanged. Two named Docker volumes support uvx-based MCP servers: - **`claude-uv-cache`** — persists the uv package cache (downloaded wheels, git clones) across container restarts @@ -218,21 +218,21 @@ cd Ckipper ./install.sh # Customize your config -# Edit ~/.ckipper/docker/w-config.zsh with your MCP mounts, ports, etc. +# Edit ~/.ckipper/docker/ckipper-config.zsh with your MCP mounts, ports, etc. # Build the Docker image (takes a few minutes first time) source ~/.zshrc -w --rebuild-image +ck wt rebuild-image # Test it -w test-branch --docker claude +ck wt run test-branch --docker claude ``` ### Option 2: Let Claude Do It Clone the repo, then open Claude Code and paste this prompt: -> Read the README.md in this repo and run `./install.sh`. Then run `source ~/.zshrc && w --rebuild-image` and tell me when it's ready to test. Show me what's in `~/.ckipper/docker/w-config.zsh` so I can customize it. +> Read the README.md in this repo and run `./install.sh`. Then run `source ~/.zshrc && ck wt rebuild-image` and tell me when it's ready to test. Show me what's in `~/.ckipper/docker/ckipper-config.zsh` so I can customize it. ### What Gets Installed Where @@ -245,15 +245,15 @@ Clone the repo, then open Claude Code and paste this prompt: | `hooks/bash-guardrails.sh` | `~/.ckipper/hooks/bash-guardrails.sh` | Bash command guard | | `hooks/docker-context.sh` | `~/.ckipper/hooks/docker-context.sh` | Context injection | | `hooks/notify-bell.sh` | `~/.ckipper/hooks/notify-bell.sh` | Notification bell | -| `w-function.zsh` | `~/.ckipper/docker/w-function.zsh` | w() launcher entry (sourced by .zshrc) | +| `ckipper.zsh` | `~/.ckipper/docker/ckipper.zsh` | ckipper CLI entry (sourced by .zshrc) | | `ckipper.zsh` | `~/.ckipper/docker/ckipper.zsh` | ckipper CLI entry (account management) | | `lib/core/`, `lib/ckipper/`, `lib/w/` | `~/.ckipper/docker/lib/` | Shell module tree (sourced by entry scripts; test files excluded) | -| `templates/w-config.zsh.example` | `~/.ckipper/docker/w-config.zsh` | User config (ports, mounts, env vars) | +| `templates/ckipper-config.zsh.example` | `~/.ckipper/docker/ckipper-config.zsh` | User config (ports, mounts, env vars) | | `templates/settings-template.json` | `~/.ckipper/settings-template.json` | Hook settings template (applied per-account by `ckipper sync-hooks`) | ### macOS Keychain Authentication -On macOS, Claude Code stores OAuth credentials in the macOS Keychain (service: `Claude Code-credentials`) and actively deletes the on-disk credentials file. The `w()` function extracts credentials from Keychain at launch and passes them to the container via environment variable. The entrypoint writes them to disk, authenticates `gh` CLI, then clears the env vars before starting Claude. +On macOS, Claude Code stores OAuth credentials in the macOS Keychain (service: `Claude Code-credentials`) and actively deletes the on-disk credentials file. `ckipper worktree run` extracts credentials from Keychain at launch and passes them to the container via environment variable. The entrypoint writes them to disk, authenticates `gh` CLI, then clears the env vars before starting Claude. Tokens are short-lived (~6 hours). If they expire mid-session, exit the container, run any `claude` command on the host (refreshes the token), then restart. @@ -262,7 +262,7 @@ Tokens are short-lived (~6 hours). If they expire mid-session, exit the containe After setup, run the comprehensive environment test to verify everything works: ```bash -w test-branch --docker claude +ck wt run test-branch --docker claude ``` Then paste the contents of [`docs/test-prompt.md`](docs/test-prompt.md) into the Docker Claude session. It covers 12 sections: @@ -285,27 +285,27 @@ See `docs/test-prompt.md` for the full prompt and expected results table. ### Projects Directory -`w()` resolves project paths under `$CKIPPER_PROJECTS_DIR` (default `$HOME/Developer`). To use a different location (e.g. `~/code`), set `CKIPPER_PROJECTS_DIR` in `~/.ckipper/docker/w-config.zsh`. Worktrees default to `$CKIPPER_PROJECTS_DIR/.worktrees`; override with `CKIPPER_WORKTREES_DIR` if you want them elsewhere. +`ckipper worktree run` resolves project paths under `$CKIPPER_PROJECTS_DIR` (default `$HOME/Developer`). To use a different location (e.g. `~/code`), set `CKIPPER_PROJECTS_DIR` in `~/.ckipper/docker/ckipper-config.zsh`. Worktrees default to `$CKIPPER_PROJECTS_DIR/.worktrees`; override with `CKIPPER_WORKTREES_DIR` if you want them elsewhere. ### Firewall Domains -Edit `docker/init-firewall.sh` → `ALLOWED_DOMAINS` array, then `w --rebuild-image`. +Edit `docker/init-firewall.sh` → `ALLOWED_DOMAINS` array, then `ck wt rebuild-image`. ### Forwarded Ports -Edit `CKIPPER_PORTS` in `~/.ckipper/docker/w-config.zsh`. +Edit `CKIPPER_PORTS` in `~/.ckipper/docker/ckipper-config.zsh`. ### Base Branch -Worktrees are created from `origin/develop`. Search for `develop` in `w-function.zsh` (or `~/.ckipper/docker/w-function.zsh` if deployed) and change to `main` or your default branch. +Worktrees are created from `origin/develop`. Search for `develop` in `ckipper.zsh` (or `~/.ckipper/docker/ckipper.zsh` if deployed) and change to `main` or your default branch. ### MCP Mounts -Add entries to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`. Format: `"host_path:container_path:mode"`. +Add entries to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/ckipper-config.zsh`. Format: `"host_path:container_path:mode"`. ### Statusline -If you use a custom statusline (like [ccstatusline](https://github.com/sirmalloc/ccstatusline)), add the config and cache mounts to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`: +If you use a custom statusline (like [ccstatusline](https://github.com/sirmalloc/ccstatusline)), add the config and cache mounts to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/ckipper-config.zsh`: - **Config mount** (`~/.config/ccstatusline`, read-only) — theme, widget layout, powerline settings - **Cache mount** (`~/.cache/ccstatusline`, read-write) — shares usage API cache with host to avoid 429 rate limits @@ -322,12 +322,12 @@ git pull source ~/.zshrc ``` -`install.sh` is idempotent. It re-deploys `~/.ckipper/docker/` (entry scripts, Dockerfile, entrypoint, `lib/` tree) and `~/.ckipper/hooks/`. Your `accounts.json`, `aliases.zsh`, and `w-config.zsh` are preserved. If hooks changed in the update, run `ckipper sync-hooks` to push the new versions into each registered account dir. +`install.sh` is idempotent. It re-deploys `~/.ckipper/docker/` (entry scripts, Dockerfile, entrypoint, `lib/` tree) and `~/.ckipper/hooks/`. Your `accounts.json`, `aliases.zsh`, and `ckipper-config.zsh` are preserved. If hooks changed in the update, run `ckipper sync-hooks` to push the new versions into each registered account dir. ### Update the container ```bash -w --rebuild-image +ck wt rebuild-image ``` Updates everything in the container — system packages, Claude Code, uv/uvx, bun, gh CLI, and Chromium. The build cache-busts all layers so nothing goes stale. Only the base image (`node:24-slim`) is cached; pull it manually with `docker pull node:24-slim` if needed. @@ -410,7 +410,7 @@ Despite docs saying every `~/.claude/...` path redirects under `CLAUDE_CONFIG_DI | Problem | Fix | |---|---| | Docker not running | Start Docker Desktop | -| Image not found | `w --rebuild-image` | +| Image not found | `ck wt rebuild-image` | | "Not logged in" in container | Run `claude` on host to refresh Keychain, restart | | "Could not extract credentials" | Run `/login` on host | | Credentials expired mid-session | Exit, run `claude` on host, restart | @@ -420,16 +420,16 @@ Despite docs saying every `~/.claude/...` path redirects under `CLAUDE_CONFIG_DI | GitHub MCP failed | Expected — Docker-in-Docker disabled | | `gh` commands fail | Check GH_TOKEN extracted from `.claude.json` | | `git commit` fails (no identity) | Entrypoint should set this automatically; check `.claude.json` has `oauthAccount` | -| Native binary errors (Exec format) | Run `w --rebuild-image` — entrypoint runs `npm install` to fix platform binaries | -| Turbo cache permission denied | Entrypoint sets `TURBO_CACHE_DIR`; run `w --rebuild-image` if missing | +| Native binary errors (Exec format) | Run `ck wt rebuild-image` — entrypoint runs `npm install` to fix platform binaries | +| Turbo cache permission denied | Entrypoint sets `TURBO_CACHE_DIR`; run `ck wt rebuild-image` if missing | | Branch already checked out | Switch main repo to different branch: `cd $CKIPPER_PROJECTS_DIR/ && git checkout develop` | | Stale worktree directory | Remove manually: `rm -rf $CKIPPER_WORKTREES_DIR//` | -| Statusline not rendering correctly | Add ccstatusline mounts to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/w-config.zsh`; ensure `bun` is in the image (`w --rebuild-image`) | +| Statusline not rendering correctly | Add ccstatusline mounts to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/ckipper-config.zsh`; ensure `bun` is in the image (`ck wt rebuild-image`) | | `git push` fails (SSH permission denied) | Ensure SSH keys are added to your agent (`ssh-add -l` to check); Docker Desktop forwards the host's SSH agent automatically | | GPG signing issues in container | Handled automatically via `GIT_CONFIG_COUNT` env vars; host config is not modified | | `.env.local` not copied to worktree | Fixed: worktree creation now copies all `.env*` files except `.env.example` | -| uvx MCP server fails to start | Run `w --rebuild-image`; if still broken, delete stale volumes: `docker volume rm claude-uv-cache claude-uv-tools` | -| Claude Code version outdated | Run `w --rebuild-image` — Claude and uv are always re-fetched | +| uvx MCP server fails to start | Run `ck wt rebuild-image`; if still broken, delete stale volumes: `docker volume rm claude-uv-cache claude-uv-tools` | +| Claude Code version outdated | Run `ck wt rebuild-image` — Claude and uv are always re-fetched | ## Contributing diff --git a/lib/account/aliases.zsh b/lib/account/aliases.zsh index 2e7213c..28d9125 100644 --- a/lib/account/aliases.zsh +++ b/lib/account/aliases.zsh @@ -65,7 +65,7 @@ _ckipper_account_regenerate_aliases() { local account_name account_dir { echo "# Auto-generated by ckipper. Do not edit by hand." - echo "# Self-contained: does not depend on ckipper.zsh or w-function.zsh being sourced." + echo "# Self-contained: does not depend on ckipper.zsh being sourced." echo "# Regenerated whenever an account is added or removed." echo "" echo "_CKIPPER_REGISTRY=\"\${CKIPPER_DIR:-\$HOME/.ckipper}/accounts.json\"" From d0785c201b5e51172c1da2745a339663206c5819 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:24:03 -0600 Subject: [PATCH 085/165] docs: update CONTRIBUTING and README layout table to lib/account, lib/worktree --- CONTRIBUTING.md | 6 +++--- README.md | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bda68df..67c897b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,9 +30,9 @@ We follow the rules in [`.claude/rules/`](.claude/rules/) — please read them. ## File organization -- `lib/core/` — shared primitives (registry, keychain, utils). Used by both ckipper and w. -- `lib/ckipper/` — ckipper-specific subcommands. -- `lib/w/` — w-specific helpers. **Must NOT call `_ckipper_*` functions** (sibling cross-import). CI enforces this. +- `lib/core/` — shared primitives (registry, keychain, utils, fuzzy). Used by both account and worktree namespaces. +- `lib/account/` — `ckipper account` subcommands. Function prefix: `_ckipper_account_*`. +- `lib/worktree/` — `ckipper worktree` subcommands. Function prefix: `_ckipper_worktree_*`. **Must NOT call `_ckipper_account_*` functions** (sibling cross-import). CI enforces this via `make lint-merge-guards`. - Tests are colocated with source: `foo.zsh` + `foo_test.bats`. ## Testing diff --git a/README.md b/README.md index 03766fb..b633ad4 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,7 @@ Clone the repo, then open Claude Code and paste this prompt: | `hooks/docker-context.sh` | `~/.ckipper/hooks/docker-context.sh` | Context injection | | `hooks/notify-bell.sh` | `~/.ckipper/hooks/notify-bell.sh` | Notification bell | | `ckipper.zsh` | `~/.ckipper/docker/ckipper.zsh` | ckipper CLI entry (sourced by .zshrc) | -| `ckipper.zsh` | `~/.ckipper/docker/ckipper.zsh` | ckipper CLI entry (account management) | -| `lib/core/`, `lib/ckipper/`, `lib/w/` | `~/.ckipper/docker/lib/` | Shell module tree (sourced by entry scripts; test files excluded) | +| `lib/core/`, `lib/account/`, `lib/worktree/` | `~/.ckipper/docker/lib/` | Shell module tree (sourced by `ckipper.zsh`; test files excluded) | | `templates/ckipper-config.zsh.example` | `~/.ckipper/docker/ckipper-config.zsh` | User config (ports, mounts, env vars) | | `templates/settings-template.json` | `~/.ckipper/settings-template.json` | Hook settings template (applied per-account by `ckipper sync-hooks`) | From 9509869238698a85494b68ccd0c3eba54965fb56 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 29 Apr 2026 23:24:30 -0600 Subject: [PATCH 086/165] Add make lint-merge-guards: catch _w_/W_ leakage and cross-namespace imports --- Makefile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 603bc40..476babf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap test test-unit test-integration lint lint-shell lint-zsh lint-py lint-fmt install help +.PHONY: bootstrap test test-unit test-integration lint lint-shell lint-zsh lint-py lint-fmt lint-merge-guards install help help: @echo "make bootstrap - install dev tools (bats, shellcheck, shfmt, ruff, pytest)" @@ -21,7 +21,7 @@ test-unit: test-integration: BATS_INTEGRATION=1 bats --recursive . -lint: lint-shell lint-zsh lint-fmt lint-py +lint: lint-shell lint-zsh lint-fmt lint-py lint-merge-guards # shellcheck only on .sh files (bash). .zsh files use `zsh -n` for syntax check. lint-shell: @@ -42,5 +42,13 @@ lint-py: ruff check docker/cleanup-projects.py @if [ -d lib ]; then find . -name '*.py' -not -name '*_test.py' -not -path './tests/*' -exec ruff check {} +; fi +# Catch leftover references from the w → ckipper merge. +# Each guard MUST return zero matches; if any fires, fix the source rather than the guard. +lint-merge-guards: + @! grep -rE '\b_w_[a-z]' lib/ ckipper.zsh 2>/dev/null || (echo "lint-merge-guards: leftover _w_* function references in lib/ or ckipper.zsh" >&2 && exit 1) + @! grep -rE '\bW_[A-Z]' lib/ ckipper.zsh templates/ 2>/dev/null || (echo "lint-merge-guards: leftover W_* globals in lib/, ckipper.zsh, or templates/" >&2 && exit 1) + @! grep -rE '\b_ckipper_account_' lib/worktree/ 2>/dev/null || (echo "lint-merge-guards: lib/worktree/ contains account-namespace functions" >&2 && exit 1) + @! grep -rE '\b_ckipper_worktree_' lib/account/ 2>/dev/null || (echo "lint-merge-guards: lib/account/ contains worktree-namespace functions" >&2 && exit 1) + install: ./install.sh From 05dd0e9855c971eb3014d1adce137191ed2acb7e Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 00:15:54 -0600 Subject: [PATCH 087/165] Address PR #33 review: namespace stale strings + shrink worktree dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README "Quick Reference" listed bare account commands (`ckipper add`, `list`, `default`, `rename`, `remove`, `sync`, `sync-hooks`, `repair-plugins`) that all return "Unknown command" post-merge. Updated to namespaced forms across the README, including the line-83 `w` reference that should read `ckipper worktree run`. Brought `_ckipper_worktree_dispatch` under the 25-line cap by extracting `_ckipper_worktree_route` (per-cmd handler dispatch) and `_ckipper_worktree_route_rm` (rm flag parsing). Body is now 13 lines and mirrors the account-dispatcher pattern. Swept stale user-facing strings: `lib/worktree/worktree.zsh` `Usage: w --rm` → `Usage: ckipper worktree rm`; `lib/account/doctor.zsh` four messages now print the namespaced remediation commands (`ckipper account add`, `ckipper account sync-hooks`, `ckipper account repair-plugins`). Deleted dead `run_w()` from `tests/lib/test-helper.bash` (sourced the deleted `w-function.zsh`) and refreshed the accompanying NOTE comment. Updated six `for w().` module headers in `lib/worktree/` to point at the ckipper-namespaced commands they now serve. --- README.md | 44 +++++++++++----------- lib/account/doctor.zsh | 8 ++-- lib/worktree/build-image.zsh | 2 +- lib/worktree/dispatcher.zsh | 64 +++++++++++++++++--------------- lib/worktree/docker-mode.zsh | 2 +- lib/worktree/normal-mode.zsh | 2 +- lib/worktree/ports.zsh | 2 +- lib/worktree/resolve-account.zsh | 2 +- lib/worktree/worktree.zsh | 6 +-- tests/lib/test-helper.bash | 17 +-------- 10 files changed, 70 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index b633ad4..d714340 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ ck wt list # list all worktrees ck wt rm myorg/myapp feature-x # remove worktree + delete branch ck wt rebuild-image # rebuild Docker image -ckipper add # register a Claude account (interactive /login) -ckipper list # show registered accounts -ckipper default # set the default account -ckipper rename # rename an account in place -ckipper remove # unregister (does not delete the dir) -ckipper sync # copy MCP/settings/plugins between accounts -ckipper sync-hooks # re-deploy hooks into every account dir -ckipper repair-plugins # fix stale ~/.claude/ paths in plugin metadata +ckipper account add # register a Claude account (interactive /login) +ckipper account list # show registered accounts +ckipper account default # set the default account +ckipper account rename # rename an account in place +ckipper account remove # unregister (does not delete the dir) +ckipper account sync # copy MCP/settings/plugins between accounts +ckipper account sync-hooks # re-deploy hooks into every account dir +ckipper account repair-plugins # fix stale ~/.claude/ paths in plugin metadata ckipper doctor # diagnostic checklist ``` @@ -59,7 +59,7 @@ Run a personal account in one terminal and a work account in another, fully isol ### Add an account ```bash -ckipper add work +ckipper account add work ``` `ckipper` walks you through `/login` and registers the account. Repeat for every account you want. @@ -72,7 +72,7 @@ work # bare-name shortcut (skipped if it CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form ``` -`ckipper add` re-sources `aliases.zsh` in your current shell, so new launchers are usable immediately — no `exec zsh`. +`ckipper account add` re-sources `aliases.zsh` in your current shell, so new launchers are usable immediately — no `exec zsh`. ### Inside Docker @@ -80,14 +80,14 @@ CLAUDE_CONFIG_DIR=~/.claude-work claude # raw form ck wt run myorg/app feature --account work --docker claude ``` -If you're already in a terminal where `CLAUDE_CONFIG_DIR` is set (e.g., via `claude-work`), `w` picks up the account automatically — no flag needed. +If you're already in a terminal where `CLAUDE_CONFIG_DIR` is set (e.g., via `claude-work`), `ckipper worktree run` picks up the account automatically — no flag needed. ### List, default, remove ```bash -ckipper list -ckipper default personal -ckipper remove old-account +ckipper account list +ckipper account default personal +ckipper account remove old-account ``` ### How accounts are stored @@ -95,7 +95,7 @@ ckipper remove old-account - Per-account state lives in `~/.claude-/` (analogous to the legacy `~/.claude/`). - The registry mapping accounts to dirs and Keychain services lives at `~/.ckipper/accounts.json` (chmod 600, atomic writes via `flock`). - Auto-generated `~/.ckipper/aliases.zsh` defines `claude-` (and a bare `` shortcut, when it doesn't shadow an existing command) per registered account. -- Hooks under `~/.ckipper/hooks/` are the canonical source — `ckipper sync-hooks` copies them per-account and rewrites `settings.json` paths. +- Hooks under `~/.ckipper/hooks/` are the canonical source — `ckipper account sync-hooks` copies them per-account and rewrites `settings.json` paths. ## ⚠️ Don't run the same account in two sessions @@ -248,7 +248,7 @@ Clone the repo, then open Claude Code and paste this prompt: | `ckipper.zsh` | `~/.ckipper/docker/ckipper.zsh` | ckipper CLI entry (sourced by .zshrc) | | `lib/core/`, `lib/account/`, `lib/worktree/` | `~/.ckipper/docker/lib/` | Shell module tree (sourced by `ckipper.zsh`; test files excluded) | | `templates/ckipper-config.zsh.example` | `~/.ckipper/docker/ckipper-config.zsh` | User config (ports, mounts, env vars) | -| `templates/settings-template.json` | `~/.ckipper/settings-template.json` | Hook settings template (applied per-account by `ckipper sync-hooks`) | +| `templates/settings-template.json` | `~/.ckipper/settings-template.json` | Hook settings template (applied per-account by `ckipper account sync-hooks`) | ### macOS Keychain Authentication @@ -321,7 +321,7 @@ git pull source ~/.zshrc ``` -`install.sh` is idempotent. It re-deploys `~/.ckipper/docker/` (entry scripts, Dockerfile, entrypoint, `lib/` tree) and `~/.ckipper/hooks/`. Your `accounts.json`, `aliases.zsh`, and `ckipper-config.zsh` are preserved. If hooks changed in the update, run `ckipper sync-hooks` to push the new versions into each registered account dir. +`install.sh` is idempotent. It re-deploys `~/.ckipper/docker/` (entry scripts, Dockerfile, entrypoint, `lib/` tree) and `~/.ckipper/hooks/`. Your `accounts.json`, `aliases.zsh`, and `ckipper-config.zsh` are preserved. If hooks changed in the update, run `ckipper account sync-hooks` to push the new versions into each registered account dir. ### Update the container @@ -384,17 +384,17 @@ This is usually a feature — your `personal` and `work` accounts working in the ### MCP servers are per-account (user-scoped only) -`mcpServers` lives in each account's `.claude.json`. When you `ckipper add `, the new account starts with **zero** user-scoped MCP servers. Two ways to populate: +`mcpServers` lives in each account's `.claude.json`. When you `ckipper account add `, the new account starts with **zero** user-scoped MCP servers. Two ways to populate: ```bash -ckipper sync personal work # default bundle: mcpServers + plugins + statusLine + env -ckipper sync personal work --mcp Vibma,github # only specific MCPs -ckipper sync personal work --dry-run # preview before writing +ckipper account sync personal work # default bundle: mcpServers + plugins + statusLine + env +ckipper account sync personal work --mcp Vibma,github # only specific MCPs +ckipper account sync personal work --dry-run # preview before writing ``` ### Plugins and marketplaces are per-account -`enabledPlugins` and `extraKnownMarketplaces` (in `settings.json`) are per-account. The `ckipper sync` default bundle includes them; the `~/.ckipper/plugins/known_marketplaces.json` cache is independent per account dir. +`enabledPlugins` and `extraKnownMarketplaces` (in `settings.json`) are per-account. The `ckipper account sync` default bundle includes them; the `~/.ckipper/plugins/known_marketplaces.json` cache is independent per account dir. ### `~/.claude/settings.local.json` may recreate after migration diff --git a/lib/account/doctor.zsh b/lib/account/doctor.zsh index 795c064..863d7ab 100644 --- a/lib/account/doctor.zsh +++ b/lib/account/doctor.zsh @@ -34,7 +34,7 @@ _ckipper_doctor_tooling() { if [[ -d "$CKIPPER_DIR" ]]; then _ckipper_doctor_check PASS "$CKIPPER_DIR exists"; else _ckipper_doctor_check FAIL "$CKIPPER_DIR is missing — run install.sh"; fi if [[ -f "$CKIPPER_DIR/docker/ckipper.zsh" ]]; then _ckipper_doctor_check PASS "ckipper.zsh deployed"; else _ckipper_doctor_check FAIL "ckipper.zsh missing in $CKIPPER_DIR/docker/"; fi if [[ -f "$CKIPPER_DIR/docker/cleanup-projects.py" ]]; then _ckipper_doctor_check PASS "cleanup-projects.py deployed"; else _ckipper_doctor_check WARN "cleanup-projects.py missing — ckipper worktree rm cleanup will silently skip"; fi - if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then _ckipper_doctor_check PASS "settings-template.json deployed"; else _ckipper_doctor_check WARN "settings-template.json missing — ckipper add will skip seeding settings.json"; fi + if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then _ckipper_doctor_check PASS "settings-template.json deployed"; else _ckipper_doctor_check WARN "settings-template.json missing — ckipper account add will skip seeding settings.json"; fi if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= MIN_HOOK_FILES )); then _ckipper_doctor_check PASS "hooks/ has ${MIN_HOOK_FILES}+ files" else @@ -50,7 +50,7 @@ _ckipper_doctor_registry() { echo "" echo "── Registry ──────────────────────────────────────────" if [[ ! -f "$CKIPPER_REGISTRY" ]]; then - _ckipper_doctor_check INFO "No registry yet — no accounts registered. Run: ckipper add " + _ckipper_doctor_check INFO "No registry yet — no accounts registered. Run: ckipper account add " return 1 fi local v; v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) @@ -88,7 +88,7 @@ _ckipper_doctor_account_plugins() { fi done if [[ "$has_stale_plugin_metadata" = "true" ]]; then - _ckipper_doctor_check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper repair-plugins $name" + _ckipper_doctor_check WARN " plugins/*.json has stale ~/.claude/ paths — plugins will fail to load. Repair: ckipper account repair-plugins $name" fi } @@ -138,7 +138,7 @@ _ckipper_doctor_account() { _ckipper_doctor_check WARN " .claude.json missing in $dir" fi if [[ -f "$dir/settings.json" ]]; then _ckipper_doctor_check PASS " settings.json present"; else _ckipper_doctor_check WARN " settings.json missing"; fi - if [[ -d "$dir/hooks" ]]; then _ckipper_doctor_check PASS " hooks/ deployed"; else _ckipper_doctor_check WARN " hooks/ missing — run: ckipper sync-hooks"; fi + if [[ -d "$dir/hooks" ]]; then _ckipper_doctor_check PASS " hooks/ deployed"; else _ckipper_doctor_check WARN " hooks/ missing — run: ckipper account sync-hooks"; fi _ckipper_doctor_account_plugins "$name" "$dir" _ckipper_doctor_account_keychain "$svc" "$name" } diff --git a/lib/worktree/build-image.zsh b/lib/worktree/build-image.zsh index bfddc41..d57438a 100644 --- a/lib/worktree/build-image.zsh +++ b/lib/worktree/build-image.zsh @@ -1,5 +1,5 @@ #!/usr/bin/env zsh -# Docker image build helper for w(). +# Docker image build helper for `ckipper worktree rebuild-image`. # Build the ckipper-dev Docker image from $CKIPPER_DIR/docker/Dockerfile. # diff --git a/lib/worktree/dispatcher.zsh b/lib/worktree/dispatcher.zsh index 2d3a114..54b4bde 100644 --- a/lib/worktree/dispatcher.zsh +++ b/lib/worktree/dispatcher.zsh @@ -19,44 +19,50 @@ _ckipper_worktree_dispatch() { local cmd="$1" shift 2>/dev/null case "$cmd" in - run) + run|list|rm|rebuild-image) if [[ "$1" == "--help" || "$1" == "-h" ]]; then - _ckipper_worktree_help_for run + _ckipper_worktree_help_for "$cmd" return 0 fi - _ckipper_worktree_run "$@" - ;; - list) - if [[ "$1" == "--help" || "$1" == "-h" ]]; then - _ckipper_worktree_help_for list - return 0 - fi - _ckipper_worktree_list_worktrees "$@" - ;; - rm) - if [[ "$1" == "--help" || "$1" == "-h" ]]; then - _ckipper_worktree_help_for rm - return 0 - fi - _ckipper_worktree_parse_rm_args "$@" - if [[ -z "$CKIPPER_WT_PROJECT" || -z "$CKIPPER_WT_BRANCH" ]]; then - _ckipper_worktree_help_for rm >&2 - return 1 - fi - _ckipper_worktree_remove_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" - ;; - rebuild-image) - if [[ "$1" == "--help" || "$1" == "-h" ]]; then - _ckipper_worktree_help_for rebuild-image - return 0 - fi - _ckipper_worktree_build_image "$@" + _ckipper_worktree_route "$cmd" "$@" ;; ""|help|-h|--help) _ckipper_worktree_help ;; *) _ckipper_worktree_unknown "$cmd"; return 1 ;; esac } +# Route a known subcommand to its handler. +# +# Args: +# $1 — subcommand name (run, list, rm, or rebuild-image) +# $2..$N — arguments forwarded to the handler +# +# Returns: handler exit status. +_ckipper_worktree_route() { + local cmd="$1" + shift + case "$cmd" in + run) _ckipper_worktree_run "$@" ;; + list) _ckipper_worktree_list_worktrees "$@" ;; + rm) _ckipper_worktree_route_rm "$@" ;; + rebuild-image) _ckipper_worktree_build_image "$@" ;; + esac +} + +# Parse `rm` flags and dispatch to the remove helper. +# +# Args: $1..$N — `rm` arguments (flags + project + worktree). +# +# Returns: 0 on success; 1 if project or worktree is empty after parsing. +_ckipper_worktree_route_rm() { + _ckipper_worktree_parse_rm_args "$@" + if [[ -z "$CKIPPER_WT_PROJECT" || -z "$CKIPPER_WT_BRANCH" ]]; then + _ckipper_worktree_help_for rm >&2 + return 1 + fi + _ckipper_worktree_remove_worktree "$CKIPPER_WT_PROJECT" "$CKIPPER_WT_BRANCH" +} + # Print the closest-match suggestion (or a bare unknown-command line) and # point the user at help. Always writes to stderr. # diff --git a/lib/worktree/docker-mode.zsh b/lib/worktree/docker-mode.zsh index 618ada7..98258c6 100644 --- a/lib/worktree/docker-mode.zsh +++ b/lib/worktree/docker-mode.zsh @@ -1,5 +1,5 @@ #!/usr/bin/env zsh -# Docker mode execution for w(). Builds docker run args and launches the container. +# Docker mode execution for `ckipper worktree run --docker`. Builds docker run args and launches the container. readonly SHASUM_BITS=256 diff --git a/lib/worktree/normal-mode.zsh b/lib/worktree/normal-mode.zsh index 112d41d..1433872 100644 --- a/lib/worktree/normal-mode.zsh +++ b/lib/worktree/normal-mode.zsh @@ -1,5 +1,5 @@ #!/usr/bin/env zsh -# Normal (non-Docker) mode execution for w(). +# Normal (non-Docker) mode execution for `ckipper worktree run`. # Run the worktree in normal mode: cd to it or run a command inside it. # diff --git a/lib/worktree/ports.zsh b/lib/worktree/ports.zsh index 047d3b2..60e8a7e 100644 --- a/lib/worktree/ports.zsh +++ b/lib/worktree/ports.zsh @@ -1,5 +1,5 @@ #!/usr/bin/env zsh -# Port resolution for w() Docker mode. Finds available host ports for dev servers. +# Port resolution for `ckipper worktree run --docker`. Finds available host ports for dev servers. readonly MAX_PORT_FALLBACK_ATTEMPTS=10 diff --git a/lib/worktree/resolve-account.zsh b/lib/worktree/resolve-account.zsh index 41a2c34..91c5e1e 100644 --- a/lib/worktree/resolve-account.zsh +++ b/lib/worktree/resolve-account.zsh @@ -1,5 +1,5 @@ #!/usr/bin/env zsh -# Account resolution for w(). Populates CKIPPER_WT_ACTIVE_* globals. +# Account resolution for `ckipper worktree run`. Populates CKIPPER_WT_ACTIVE_* globals. # Resolve which ckipper account to use, then populate: # CKIPPER_WT_ACTIVE_ACCOUNT — resolved account name diff --git a/lib/worktree/worktree.zsh b/lib/worktree/worktree.zsh index 99e2bb4..cd69f60 100644 --- a/lib/worktree/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -1,5 +1,5 @@ #!/usr/bin/env zsh -# Worktree list, remove, and create operations for w(). +# Worktree list, remove, and create operations for `ckipper worktree`. readonly CKIPPER_WT_FIND_MAX_DEPTH=3 @@ -64,7 +64,7 @@ _ckipper_worktree_get_project_and_branch() { # Reads CKIPPER_WT_FLAG_FORCE, CKIPPER_WORKTREES_DIR, CKIPPER_PROJECTS_DIR globals. # Returns: 0 on success; 1 on validation failure or git error. # Errors (stderr): -# "Usage: w --rm [--force] " — when project or worktree is empty +# "Usage: ckipper worktree rm [--force] " — when project or worktree is empty # "Worktree not found: " — when the worktree directory does not exist # "Failed to remove worktree. Use --force if it has uncommitted changes." — on git error _ckipper_worktree_remove_worktree() { @@ -72,7 +72,7 @@ _ckipper_worktree_remove_worktree() { local worktree="$2" if [[ -z "$project" || -z "$worktree" ]]; then - echo "Usage: w --rm [--force] " + echo "Usage: ckipper worktree rm [--force] " return 1 fi diff --git a/tests/lib/test-helper.bash b/tests/lib/test-helper.bash index 1ee1193..d1d5f42 100644 --- a/tests/lib/test-helper.bash +++ b/tests/lib/test-helper.bash @@ -49,25 +49,10 @@ run_ckipper() { zsh -c "$zsh_cmd" } -# Run the w() function in an isolated zsh subshell. -# Usage: run_w [args...] -run_w() { - local zsh_cmd="source \"$REPO_ROOT/w-function.zsh\"; w $*" - run env \ - HOME="$TMP_HOME" \ - CKIPPER_DIR="$CKIPPER_DIR" \ - CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ - PATH="$PATH" \ - _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-linux}" \ - CKIPPER_FORCE="${CKIPPER_FORCE:-1}" \ - zsh -c "$zsh_cmd" -} - # Source a Ckipper file with $REPO_ROOT as the lookup base. # NOTE: Only usable when the test file itself runs under zsh (i.e., when # running bats tests from zsh). Because bats runs under bash, zsh-only source -# files (ckipper.zsh, w-function.zsh) cannot be sourced this way. -# Use run_ckipper / run_w instead for those files. +# files (ckipper.zsh) cannot be sourced this way. Use run_ckipper for those. source_ckipper_file() { local rel_path="$1" source "$REPO_ROOT/$rel_path" From e06440fb704e0e0438bfc6b0262a6bfed8316ae8 Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 01:00:12 -0600 Subject: [PATCH 088/165] Address validated 3-agent review: critical install fixes + tech-debt sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validator (4th agent) confirmed all 13 reviewer claims were valid. Every one is fixed here, including pre-existing tech debt that was inherited verbatim through the lib/w/ → lib/worktree/ and lib/ckipper/ → lib/account/ renames. Critical (silent install failures, blocked merge): - C1 install.sh:88 — migration cp now sed-renames the five known pre-merge variable names (W_PROJECTS_DIR/W_WORKTREES_DIR/W_PORTS/W_EXTRA_VOLUMES/ W_EXTRA_ENV) to CKIPPER_*. Previously the bare cp preserved the old names, ckipper.zsh read CKIPPER_* only, and the user's customizations were silently dropped (default ports, no MCP volumes, wrong projects dir). - C2 install.sh:124 — zshrc-rewrite regex now allows an optional trailing comment, and a sha-based fall-through appends a working source line if the sed somehow doesn't match. Previously `source "..." # ckipper`-style lines with trailing comments were detected but never rewritten, leaving the user's shell broken on every launch (deleted file referenced). Important: - I1 stale strings — install.sh:43,114,117, templates/settings-template.json, docker/entrypoint.sh, docker/Dockerfile, docs/test-prompt.md, CHANGELOG all updated to namespaced commands (`ckipper account *`). - I2 ckipper.zsh — added _CKIPPER_LEGACY_COMMANDS map. Pre-merge top-level commands (add/list/default/remove/rename/sync/sync-hooks/repair-plugins/ migrate) now print a precise migration hint instead of falling through to fuzzy-suggest, which can't bridge the rename (lev(add, account)=6, well above threshold). - I3 lib/worktree/worktree.zsh — deleted the dead empty-args validation guard in _ckipper_worktree_remove_worktree; dispatcher now validates first and prints to stderr, removing a stdout/stderr divergence. - I4 lib/account/doctor.zsh — _ckipper_doctor_check_stale_w_vars surfaces any leftover W_* assignment in ckipper-config.zsh as a FAIL, catching installs that pre-date the C1 fix. - I5 install.sh — switched from `sed -i.bak` (always clobbers) to a timestamped `~/.zshrc.ckipper-bak.` written before the rewrite. Re-runs preserve the original backup. - I6 lib/worktree/docker-mode.zsh — CKIPPER_WT_DOCKER_ARGS is now `typeset -ga` (global) instead of `local -a`, mirroring the rest of the CKIPPER_WT_* runtime ring. Doc-header documents the Sets contract. Minor / pre-existing tech debt: - M1 lib/account/sync.zsh — extracted _ckipper_account_sync_build_mcp_filter_args to drop _ckipper_account_sync_mcp_servers under the 25-line cap. - M2 18 stderr-contract drift sites — bare `echo` replaced with `>&2` echo in worktree.zsh, docker-mode.zsh, resolve-account.zsh, plugin-repair.zsh, sync.zsh wherever the doc-header declared `Errors (stderr): ...`. - M3 lib/core/fuzzy.zsh — added _core_unknown_command helper; the three near-clone *_unknown helpers in ckipper.zsh, account/dispatcher.zsh, and worktree/dispatcher.zsh collapse to one-line wrappers, locking in stderr correctness centrally. - M5 CKIPPER_WT_WT_PATH → CKIPPER_WT_PATH across 5 files (25 occurrences). The double-WT was an artifact of namespace prefixing during the rename. Tests: - New install_test.bats coverage: W_* → CKIPPER_* functional reachability (post-install ckipper.zsh sees the renamed values), trailing-comment zshrc rewrite, timestamped-backup non-clobber on re-run. - Tightened the existing migration test contract via the new functional assertion. Lint: - Makefile lint-merge-guards: doctor.zsh exempted from the W_* check (intentionally references pre-merge names to detect stale config). --- CHANGELOG.md | 2 +- Makefile | 5 ++- ckipper.zsh | 41 +++++++++++++---- docker/Dockerfile | 2 +- docker/entrypoint.sh | 2 +- docs/test-prompt.md | 2 +- install.sh | 42 +++++++++++++++--- install_test.bats | 70 ++++++++++++++++++++++++++++++ lib/account/dispatcher.zsh | 11 ++--- lib/account/doctor.zsh | 17 ++++++++ lib/account/plugin-repair.zsh | 6 +-- lib/account/sync.zsh | 37 +++++++++++----- lib/core/fuzzy.zsh | 23 ++++++++++ lib/worktree/dispatcher.zsh | 11 ++--- lib/worktree/docker-mode.zsh | 25 ++++++----- lib/worktree/docker-mode_test.bats | 6 +-- lib/worktree/normal-mode.zsh | 6 +-- lib/worktree/normal-mode_test.bats | 6 +-- lib/worktree/resolve-account.zsh | 6 +-- lib/worktree/worktree.zsh | 52 ++++++++++------------ templates/settings-template.json | 2 +- 21 files changed, 272 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f69abd6..cc36ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ Run `./install.sh`. It rewrites `~/.zshrc`, deletes stale paths (`w-function.zsh ### Added - Initial public release. -- Multi-account Claude Code management (`ckipper add/remove/rename/list/default/sync/doctor/migrate`). +- Multi-account Claude Code management (`ckipper add/remove/rename/list/default/sync/doctor/migrate`; renamed to `ckipper account *` in 0.2.0). - `w()` worktree-aware launcher with normal and Docker (`--docker --firewall`) modes. - Auto-generated per-account aliases. - Safety hooks: `bash-guardrails.sh`, `protect-claude-config.sh`, `docker-context.sh`, `notify-bell.sh`. diff --git a/Makefile b/Makefile index 476babf..6bf2854 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,12 @@ lint-py: # Catch leftover references from the w → ckipper merge. # Each guard MUST return zero matches; if any fires, fix the source rather than the guard. +# `doctor.zsh` is exempted from the W_* check: it intentionally references +# pre-merge variable names to detect stale user config left behind by the +# rename. Any other leftover W_* assignment is a bug. lint-merge-guards: @! grep -rE '\b_w_[a-z]' lib/ ckipper.zsh 2>/dev/null || (echo "lint-merge-guards: leftover _w_* function references in lib/ or ckipper.zsh" >&2 && exit 1) - @! grep -rE '\bW_[A-Z]' lib/ ckipper.zsh templates/ 2>/dev/null || (echo "lint-merge-guards: leftover W_* globals in lib/, ckipper.zsh, or templates/" >&2 && exit 1) + @! grep -rE --exclude=doctor.zsh '\bW_[A-Z]' lib/ ckipper.zsh templates/ 2>/dev/null || (echo "lint-merge-guards: leftover W_* globals in lib/, ckipper.zsh, or templates/" >&2 && exit 1) @! grep -rE '\b_ckipper_account_' lib/worktree/ 2>/dev/null || (echo "lint-merge-guards: lib/worktree/ contains account-namespace functions" >&2 && exit 1) @! grep -rE '\b_ckipper_worktree_' lib/account/ 2>/dev/null || (echo "lint-merge-guards: lib/account/ contains worktree-namespace functions" >&2 && exit 1) diff --git a/ckipper.zsh b/ckipper.zsh index 25f60dd..46ec662 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -56,6 +56,23 @@ CKIPPER_WORKTREES_DIR="${CKIPPER_WORKTREES_DIR:-$CKIPPER_PROJECTS_DIR/.worktrees # Top-level commands. Used both for routing and for fuzzy-suggest. _CKIPPER_COMMANDS=(account worktree doctor help) +# Pre-merge top-level commands → their post-merge namespaced replacement. +# Used by _ckipper_unknown so a user typing the old form (e.g. `ckipper add`) +# gets a precise migration hint instead of a generic "Unknown command" — +# Levenshtein cannot bridge the rename (lev(add, account) = 6, well above the +# fuzzy threshold). Empty value means the command was removed entirely. +typeset -gA _CKIPPER_LEGACY_COMMANDS=( + [add]='account add' + [list]='account list' + [default]='account default' + [remove]='account remove' + [rename]='account rename' + [sync]='account sync' + [sync-hooks]='account sync-hooks' + [repair-plugins]='account repair-plugins' + [migrate]='' +) + # Dispatch a top-level ckipper command. # # Args: @@ -89,20 +106,26 @@ ckipper() { esac } -# Print the closest top-level command match (or a bare unknown-command line) -# and point the user at help. Always writes to stderr. +# Print a migration hint for retired pre-merge commands, or fall through to +# the standard unknown-command + fuzzy-suggest path. Always writes to stderr. # # Args: $1 — the unknown command the user typed. # Returns: 0 always. _ckipper_unknown() { - local cmd="$1" suggestion - suggestion=$(_core_fuzzy_suggest "$cmd" "${_CKIPPER_COMMANDS[@]}") - if [[ -n "$suggestion" ]]; then - echo "Unknown command: '$cmd'. Did you mean: '$suggestion'?" >&2 - else - echo "Unknown command: '$cmd'." >&2 + local cmd="$1" + if (( ${+_CKIPPER_LEGACY_COMMANDS[$cmd]} )); then + local replacement="${_CKIPPER_LEGACY_COMMANDS[$cmd]}" + if [[ -n "$replacement" ]]; then + echo "'ckipper $cmd' was renamed to 'ckipper $replacement' — pass the same arguments." >&2 + else + echo "'ckipper $cmd' was removed in this release." >&2 + fi + echo "Run 'ckipper help' for the current command list." >&2 + return 0 fi - echo "Run 'ckipper help' for available commands." >&2 + _core_unknown_command "$cmd" \ + "Run 'ckipper help' for available commands." \ + "${_CKIPPER_COMMANDS[@]}" } # Print the top-level ckipper usage summary. diff --git a/docker/Dockerfile b/docker/Dockerfile index 7450a5e..3f2da40 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ FROM node:24-slim -# Every `w --rebuild-image` passes a unique CACHEBUST value, so all layers +# Every `ckipper worktree rebuild-image` passes a unique CACHEBUST value, so all layers # below are re-fetched. This keeps system packages, CLI tools, and Claude # Code current without ever going stale. Only the base image (node:24-slim) # is cached — pull it manually with `docker pull node:24-slim` if needed. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 71b9c85..1f07127 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -8,7 +8,7 @@ readonly GIT_CONFIG_COUNT=2 # Require CLAUDE_CONFIG_DIR — Ckipper's account context. No silent fallback. if [ -z "$CLAUDE_CONFIG_DIR" ]; then echo "Error: CLAUDE_CONFIG_DIR is not set inside the container." >&2 - echo "This means w() did not pass the account context. Bug — please report." >&2 + echo "This means ckipper worktree run did not pass the account context. Bug — please report." >&2 exit 1 fi if [ ! -d "$CLAUDE_CONFIG_DIR" ]; then diff --git a/docs/test-prompt.md b/docs/test-prompt.md index 3703730..4915dee 100644 --- a/docs/test-prompt.md +++ b/docs/test-prompt.md @@ -110,7 +110,7 @@ B. `.claude.json` is the per-account file (account-specific email): ```bash # Confirm the email matches the account's registered identity jq -r .oauthAccount.emailAddress "$CLAUDE_CONFIG_DIR/.claude.json" -# Should match the email shown by `ckipper list` for this account. +# Should match the email shown by `ckipper account list` for this account. ``` C. Credentials symlinked to tmpfs: diff --git a/install.sh b/install.sh index 7038527..f20104a 100755 --- a/install.sh +++ b/install.sh @@ -40,7 +40,7 @@ chmod +x "$CKIPPER_DIR/docker/entrypoint.sh" chmod +x "$CKIPPER_DIR/docker/init-firewall.sh" chmod +x "$CKIPPER_DIR/docker/cleanup-projects.py" -# 3. Copy hooks (canonical source for ckipper sync-hooks) +# 3. Copy hooks (canonical source for ckipper account sync-hooks) echo "Copying hooks to $CKIPPER_DIR/hooks/..." mkdir -p "$CKIPPER_DIR/hooks" cp "$REPO_DIR/hooks/protect-claude-config.sh" "$CKIPPER_DIR/hooks/" @@ -85,7 +85,13 @@ fi if [ -f "$CKIPPER_DIR/docker/w-config.zsh" ]; then if [ ! -f "$CKIPPER_DIR/docker/ckipper-config.zsh" ]; then echo " Migrating $CKIPPER_DIR/docker/w-config.zsh → ckipper-config.zsh (preserves your settings)" - cp "$CKIPPER_DIR/docker/w-config.zsh" "$CKIPPER_DIR/docker/ckipper-config.zsh" + # Rewrite assignments of the five known pre-merge variables to their + # CKIPPER_* counterparts. Allow-list (not blanket W_* → CKIPPER_*) so + # we don't mangle user comments or unrelated W_-prefixed names. The + # anchor `^[[:space:]]*` matches assignment lines only, leaving + # comment text intact. + sed -E 's/^([[:space:]]*)W_(PROJECTS_DIR|WORKTREES_DIR|PORTS|EXTRA_VOLUMES|EXTRA_ENV)/\1CKIPPER_\2/' \ + "$CKIPPER_DIR/docker/w-config.zsh" >"$CKIPPER_DIR/docker/ckipper-config.zsh" fi echo " Removing stale $CKIPPER_DIR/docker/w-config.zsh" rm -f "$CKIPPER_DIR/docker/w-config.zsh" @@ -111,18 +117,42 @@ fi [[ -f "$CKIPPER_DIR/accounts.json" ]] && echo " accounts.json already exists (not overwritten — managed by ckipper)" [[ -f "$CKIPPER_DIR/aliases.zsh" ]] && echo " aliases.zsh already exists (not overwritten — auto-generated)" -# 6. Deploy settings-template.json (consumed by ckipper add / sync-hooks per-account) +# 6. Deploy settings-template.json (consumed by ckipper account add / sync-hooks per-account) echo "Copying settings-template.json to $CKIPPER_DIR/..." cp "$REPO_DIR/templates/settings-template.json" "$CKIPPER_DIR/settings-template.json" -echo " Settings template deployed. ckipper sync-hooks applies it per-account." +echo " Settings template deployed. ckipper account sync-hooks applies it per-account." # 7. Add or update source line in .zshrc # Pre-merge installs sourced w-function.zsh from ~/.claude/docker/ or # ~/.ckipper/docker/. The regex matches either install root and rewrites # to the canonical ~/.ckipper/docker/ckipper.zsh. +# +# Edge cases handled: +# - trailing comment on the source line (`source "..." # ckipper`) +# - sed regex failing to match anything: we detect the no-op and append a +# working source line so the user is never left with a broken zshrc. +# - timestamped backup so re-runs don't clobber the previous .bak. if grep -qE '/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then - sed -i.bak -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*/docker/w-function\.zsh["'\'']?[[:space:]]*$|source "$HOME/.ckipper/docker/ckipper.zsh"|' "$HOME/.zshrc" - echo " Updated ~/.zshrc source line to ~/.ckipper/docker/ckipper.zsh. Backup at ~/.zshrc.bak." + zshrc_backup="$HOME/.zshrc.ckipper-bak.$(date -u +%Y%m%dT%H%M%SZ)" + cp "$HOME/.zshrc" "$zshrc_backup" + zshrc_tmp="$HOME/.zshrc.ckipper-tmp.$$" + sed -E 's|^[[:space:]]*source[[:space:]]+["'\'']?[$~/][^"'\'']*/docker/w-function\.zsh["'\'']?[[:space:]]*(#.*)?$|source "$HOME/.ckipper/docker/ckipper.zsh"|' \ + "$HOME/.zshrc" >"$zshrc_tmp" && mv "$zshrc_tmp" "$HOME/.zshrc" + if grep -qE '/docker/w-function\.zsh' "$HOME/.zshrc" 2>/dev/null; then + # Sed didn't match the stale source line (unusual whitespace, + # quoting, or an exotic comment). Append a working source line so + # ckipper still loads, and warn the user to remove the stale one. + if ! grep -q 'ckipper/docker/ckipper\.zsh' "$HOME/.zshrc" 2>/dev/null; then + echo '' >>"$HOME/.zshrc" + echo '# Ckipper — multi-account Claude Code manager (ckipper + ckipper worktree run)' >>"$HOME/.zshrc" + echo 'source "$HOME/.ckipper/docker/ckipper.zsh"' >>"$HOME/.zshrc" + fi + echo " WARNING: could not rewrite stale w-function.zsh source line in ~/.zshrc." + echo " Appended a working ckipper.zsh source line; please remove the stale one manually." + echo " Backup: $zshrc_backup" + else + echo " Updated ~/.zshrc source line to ~/.ckipper/docker/ckipper.zsh. Backup: $zshrc_backup" + fi elif ! grep -q 'ckipper/docker/ckipper\.zsh' "$HOME/.zshrc" 2>/dev/null; then echo '' >>"$HOME/.zshrc" echo '# Ckipper — multi-account Claude Code manager (ckipper + ckipper worktree run)' >>"$HOME/.zshrc" diff --git a/install_test.bats b/install_test.bats index e2df0bd..827faaa 100644 --- a/install_test.bats +++ b/install_test.bats @@ -81,6 +81,76 @@ EOF grep -q 'CUSTOM_VALUE="from_old_config"' "$TMP_HOME/.ckipper/docker/ckipper-config.zsh" } +@test "install.sh renames W_* assignments to CKIPPER_* during migration" { + mkdir -p "$TMP_HOME/.ckipper/docker" + cat > "$TMP_HOME/.ckipper/docker/w-config.zsh" << 'EOF' +# pre-merge user config — every customizable variable +W_PROJECTS_DIR="$HOME/myrepos" +W_WORKTREES_DIR="$HOME/myworktrees" +W_PORTS=(3000 8080 9090 12345) +W_EXTRA_VOLUMES=("/data:/data:ro") +W_EXTRA_ENV=("CUSTOM_KEY=v") +# Comment with W_PORTS in it should NOT be rewritten. +EOF + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + local cfg="$TMP_HOME/.ckipper/docker/ckipper-config.zsh" + grep -q '^CKIPPER_PROJECTS_DIR=' "$cfg" + grep -q '^CKIPPER_WORKTREES_DIR=' "$cfg" + grep -q '^CKIPPER_PORTS=(3000 8080 9090 12345)' "$cfg" + grep -q '^CKIPPER_EXTRA_VOLUMES=' "$cfg" + grep -q '^CKIPPER_EXTRA_ENV=' "$cfg" + # Comment lines must not be rewritten — preserve original W_PORTS reference. + grep -q '# Comment with W_PORTS in it' "$cfg" + # No leftover W_* assignments (excluding the comment). + ! grep -qE '^[[:space:]]*W_(PROJECTS_DIR|WORKTREES_DIR|PORTS|EXTRA_VOLUMES|EXTRA_ENV)=' "$cfg" + + # Functional check: post-install ckipper.zsh sees the renamed variables. + run zsh -c "source '$REPO_ROOT/ckipper.zsh'; print -r -- \"\${CKIPPER_PORTS[*]}\"" + [ "$status" -eq 0 ] + [[ "$output" =~ "3000 8080 9090 12345" ]] +} + +@test "install.sh rewrites pre-merge ~/.zshrc source line with trailing comment" { + cat > "$HOME/.zshrc" << 'EOF' +# Some user config +source "$HOME/.ckipper/docker/w-function.zsh" # ckipper bootstrap +EOF + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + + [ "$status" -eq 0 ] + grep -q 'ckipper/docker/ckipper\.zsh' "$HOME/.zshrc" + ! grep -q 'ckipper/docker/w-function\.zsh' "$HOME/.zshrc" +} + +@test "install.sh creates timestamped backup, does not clobber on second run" { + cat > "$HOME/.zshrc" << 'EOF' +source "$HOME/.ckipper/docker/w-function.zsh" +EOF + + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + [ "$status" -eq 0 ] + + local first_backup + first_backup=$(ls "$HOME"/.zshrc.ckipper-bak.* 2>/dev/null | head -1) + [ -n "$first_backup" ] + grep -q 'ckipper/docker/w-function\.zsh' "$first_backup" + + # Second install run on already-rewritten zshrc — must not clobber backup. + sleep 1 + HOME="$TMP_HOME" CKIPPER_DIR="$TMP_HOME/.ckipper" \ + run "$REPO_ROOT/install.sh" + [ "$status" -eq 0 ] + [ -f "$first_backup" ] + grep -q 'ckipper/docker/w-function\.zsh' "$first_backup" +} + @test "install.sh rewrites pre-merge ~/.zshrc source line to ckipper.zsh" { cat > "$HOME/.zshrc" << 'EOF' # Some user config diff --git a/lib/account/dispatcher.zsh b/lib/account/dispatcher.zsh index 003426e..d17b0bc 100644 --- a/lib/account/dispatcher.zsh +++ b/lib/account/dispatcher.zsh @@ -43,14 +43,9 @@ _ckipper_account_dispatch() { # Args: $1 — the unknown subcommand the user typed. # Returns: 0 always. _ckipper_account_unknown() { - local cmd="$1" suggestion - suggestion=$(_core_fuzzy_suggest "$cmd" "${_CKIPPER_ACCOUNT_SUBCOMMANDS[@]}") - if [[ -n "$suggestion" ]]; then - echo "Unknown command: '$cmd'. Did you mean: '$suggestion'?" >&2 - else - echo "Unknown command: '$cmd'." >&2 - fi - echo "Run 'ckipper account help' for available commands." >&2 + _core_unknown_command "$1" \ + "Run 'ckipper account help' for available commands." \ + "${_CKIPPER_ACCOUNT_SUBCOMMANDS[@]}" } # Print the account-namespace usage summary. diff --git a/lib/account/doctor.zsh b/lib/account/doctor.zsh index 863d7ab..32750b6 100644 --- a/lib/account/doctor.zsh +++ b/lib/account/doctor.zsh @@ -40,6 +40,23 @@ _ckipper_doctor_tooling() { else _ckipper_doctor_check WARN "hooks/ is missing or has fewer than $MIN_HOOK_FILES hook files" fi + _ckipper_doctor_check_stale_w_vars +} + +# Detect pre-merge W_* variable assignments in ckipper-config.zsh. +# +# Pre-merge installs used W_PROJECTS_DIR / W_PORTS / W_EXTRA_VOLUMES / +# W_EXTRA_ENV / W_WORKTREES_DIR. Post-merge ckipper.zsh only reads the +# CKIPPER_* names, so any leftover W_* assignment is silently ignored — and +# the user's customizations are lost. Surface this loudly. +# +# Returns: 0 always (results printed via _ckipper_doctor_check). +_ckipper_doctor_check_stale_w_vars() { + local cfg="$CKIPPER_DIR/docker/ckipper-config.zsh" + [[ -f "$cfg" ]] || return 0 + if grep -qE '^[[:space:]]*W_(PROJECTS_DIR|WORKTREES_DIR|PORTS|EXTRA_VOLUMES|EXTRA_ENV)[[:space:]]*=' "$cfg"; then + _ckipper_doctor_check FAIL "ckipper-config.zsh has stale W_* assignments — they're being ignored. Rename to CKIPPER_* (e.g. W_PORTS → CKIPPER_PORTS)." + fi } # Check registry version, permissions, and default account validity. diff --git a/lib/account/plugin-repair.zsh b/lib/account/plugin-repair.zsh index e5d0d7a..ad0f1d2 100644 --- a/lib/account/plugin-repair.zsh +++ b/lib/account/plugin-repair.zsh @@ -86,17 +86,17 @@ _ckipper_account_detect_stale_plugin_prefix() { _ckipper_account_repair_plugins() { local name="$1" if [[ -z "$name" ]]; then - echo "Usage: ckipper account repair-plugins " + echo "Usage: ckipper account repair-plugins " >&2 return 1 fi _core_registry_check_version || return 1 local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir // empty' "$CKIPPER_REGISTRY") if [[ -z "$dir" ]]; then - echo "Account '$name' is not registered. Run: ckipper account list" + echo "Account '$name' is not registered. Run: ckipper account list" >&2 return 1 fi if [[ ! -d "$dir" ]]; then - echo "Account dir does not exist: $dir" + echo "Account dir does not exist: $dir" >&2 return 1 fi _ckipper_account_repair_plugins_apply "$name" "$dir" diff --git a/lib/account/sync.zsh b/lib/account/sync.zsh index da61d8a..a5cdb46 100644 --- a/lib/account/sync.zsh +++ b/lib/account/sync.zsh @@ -64,6 +64,28 @@ _ckipper_account_sync_warn_running_claude() { [[ "$user_choice" != "y" && "$user_choice" != "Y" ]] && { echo "Aborted."; return 1; } } +# Build the jq filter args used by sync_mcp_servers to project a server subset. +# Populates the caller-scope array `jq_filter_args` (dynamic scope). +# +# Args: +# $1 — comma-separated MCP server names; empty means "all servers". +# +# Returns: +# 0 always. +_ckipper_account_sync_build_mcp_filter_args() { + local mcp_names="$1" + if [[ -z "$mcp_names" ]]; then + jq_filter_args=('.mcpServers // {}') + return 0 + fi + local jq_array + jq_array=$(printf '%s' "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') + jq_filter_args=( + --argjson keys "$jq_array" + '.mcpServers // {} | with_entries(select(.key as $k | $keys | index($k)))' + ) +} + # Sync MCP servers from one account to another. Appends a summary line to pending_msgs. # Reads from_dir, to_dir, and dry_run from _CKIPPER_SYNC_CTX module global. # @@ -78,17 +100,8 @@ _ckipper_account_sync_mcp_servers() { local from_dir="${_CKIPPER_SYNC_CTX[from_dir]}" local to_dir="${_CKIPPER_SYNC_CTX[to_dir]}" local dry_run="${_CKIPPER_SYNC_CTX[dry_run]}" - local mcp_filter local -a jq_filter_args - if [[ -z "$mcp_names" ]]; then - mcp_filter='.mcpServers // {}' - jq_filter_args=("$mcp_filter") - else - local jq_array - jq_array=$(printf '%s' "$mcp_names" | jq -R 'split(",") | map(. | gsub("^\\s+|\\s+$"; ""))') - mcp_filter='.mcpServers // {} | with_entries(select(.key as $k | $keys | index($k)))' - jq_filter_args=(--argjson keys "$jq_array" "$mcp_filter") - fi + _ckipper_account_sync_build_mcp_filter_args "$mcp_names" local servers; servers=$(jq "${jq_filter_args[@]}" "$from_dir/.claude.json") local server_keys; server_keys=$(printf '%s' "$servers" | jq -r 'keys[]?' | tr '\n' ' ') if [[ -z "$server_keys" || "$server_keys" == " " ]]; then @@ -203,10 +216,10 @@ _ckipper_account_sync() { local from="$1" to="$2" shift 2 2>/dev/null if [[ -z "$from" || -z "$to" ]]; then - echo "Usage: ckipper account sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" + echo "Usage: ckipper account sync [--mcp [names]] [--settings keys] [--all] [--dry-run]" >&2 return 1 fi - [[ "$from" == "$to" ]] && { echo " and must differ."; return 1; } + [[ "$from" == "$to" ]] && { echo " and must differ." >&2; return 1; } local dirs_line; dirs_line=$(_ckipper_account_sync_resolve_dirs "$from" "$to") || return 1 local from_dir="${dirs_line%% *}" to_dir="${dirs_line##* }" local mode_mcp mcp_names mode_settings settings_keys is_dry_run mode_all diff --git a/lib/core/fuzzy.zsh b/lib/core/fuzzy.zsh index b90261c..6dac59f 100644 --- a/lib/core/fuzzy.zsh +++ b/lib/core/fuzzy.zsh @@ -65,3 +65,26 @@ _core_fuzzy_suggest() { done echo "$best" } + +# Print an "Unknown command" message with a closest-match suggestion (if any) +# followed by a help-pointer line. All output goes to stderr — this enforces +# the unknown-command stderr contract for every dispatch tier. +# +# Args: +# $1 — the unknown command token the user typed +# $2 — help pointer text (e.g. "Run 'ckipper help' for available commands.") +# $3..$N — known-command candidate list +# +# Returns: 0 always. +_core_unknown_command() { + local cmd="$1" help_text="$2" + shift 2 + local suggestion + suggestion=$(_core_fuzzy_suggest "$cmd" "$@") + if [[ -n "$suggestion" ]]; then + echo "Unknown command: '$cmd'. Did you mean: '$suggestion'?" >&2 + else + echo "Unknown command: '$cmd'." >&2 + fi + echo "$help_text" >&2 +} diff --git a/lib/worktree/dispatcher.zsh b/lib/worktree/dispatcher.zsh index 54b4bde..c83530a 100644 --- a/lib/worktree/dispatcher.zsh +++ b/lib/worktree/dispatcher.zsh @@ -69,14 +69,9 @@ _ckipper_worktree_route_rm() { # Args: $1 — the unknown subcommand the user typed. # Returns: 0 always. _ckipper_worktree_unknown() { - local cmd="$1" suggestion - suggestion=$(_core_fuzzy_suggest "$cmd" "${_CKIPPER_WORKTREE_SUBCOMMANDS[@]}") - if [[ -n "$suggestion" ]]; then - echo "Unknown command: '$cmd'. Did you mean: '$suggestion'?" >&2 - else - echo "Unknown command: '$cmd'." >&2 - fi - echo "Run 'ckipper worktree help' for available commands." >&2 + _core_unknown_command "$1" \ + "Run 'ckipper worktree help' for available commands." \ + "${_CKIPPER_WORKTREE_SUBCOMMANDS[@]}" } # Print the worktree-namespace usage summary. diff --git a/lib/worktree/docker-mode.zsh b/lib/worktree/docker-mode.zsh index 98258c6..f222257 100644 --- a/lib/worktree/docker-mode.zsh +++ b/lib/worktree/docker-mode.zsh @@ -17,9 +17,14 @@ readonly DOCKER_GROUP_ADD_HOST_ROOT=0 # Run the worktree in a Docker container. # -# Reads globals: CKIPPER_WT_WT_PATH, CKIPPER_PROJECTS_DIR, CKIPPER_WT_PROJECT, CKIPPER_WT_BRANCH, CKIPPER_WT_COMMAND, +# Reads globals: CKIPPER_WT_PATH, CKIPPER_PROJECTS_DIR, CKIPPER_WT_PROJECT, CKIPPER_WT_BRANCH, CKIPPER_WT_COMMAND, # CKIPPER_WT_FLAG_FIREWALL, CKIPPER_WT_ACTIVE_ACCOUNT, CKIPPER_WT_ACTIVE_CONFIG_DIR, # CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE, CKIPPER_PORTS, CKIPPER_EXTRA_VOLUMES, CKIPPER_EXTRA_ENV. +# Sets globals: CKIPPER_WT_DOCKER_ARGS — the assembled `docker run` argv, +# built up by helper calls below. Declared at function scope as a global +# array (not `local -a`) so the runtime ring is uniform with the other +# CKIPPER_WT_* runtime variables (the helpers mutate it explicitly, not via +# dynamic-scope leakage). # Returns: exit code of the docker run invocation. _ckipper_worktree_run_docker_mode() { _ckipper_worktree_docker_check_prerequisites || return 1 @@ -32,7 +37,7 @@ _ckipper_worktree_run_docker_mode() { claude_creds=$(_ckipper_worktree_docker_extract_credentials) || return 1 gh_token=$(_ckipper_worktree_docker_extract_gh_token) - local -a CKIPPER_WT_DOCKER_ARGS + typeset -ga CKIPPER_WT_DOCKER_ARGS=() _ckipper_worktree_docker_build_base_args _ckipper_worktree_docker_add_optional_args "$claude_creds" "$gh_token" _ckipper_worktree_resolve_ports @@ -53,11 +58,11 @@ _ckipper_worktree_run_docker_mode() { # "Error: Docker daemon is not running. Start Docker Desktop first." — when daemon is down _ckipper_worktree_docker_check_prerequisites() { if ! command -v docker &>/dev/null; then - echo "Error: docker is not installed or not in PATH" + echo "Error: docker is not installed or not in PATH" >&2 return 1 fi if ! docker info &>/dev/null 2>&1; then - echo "Error: Docker daemon is not running. Start Docker Desktop first." + echo "Error: Docker daemon is not running. Start Docker Desktop first." >&2 return 1 fi if ! docker image inspect ckipper-dev > /dev/null 2>&1; then @@ -74,8 +79,8 @@ _ckipper_worktree_docker_check_prerequisites() { _ckipper_worktree_docker_validate_keychain() { if [[ -n "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE" ]] && \ ! _core_keychain_validate "$CKIPPER_WT_ACTIVE_KEYCHAIN_SERVICE"; then - echo "Error: account '$CKIPPER_WT_ACTIVE_ACCOUNT' has invalid keychain_service in registry." - echo "Re-register with: ckipper account remove $CKIPPER_WT_ACTIVE_ACCOUNT && ckipper account add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" + echo "Error: account '$CKIPPER_WT_ACTIVE_ACCOUNT' has invalid keychain_service in registry." >&2 + echo "Re-register with: ckipper account remove $CKIPPER_WT_ACTIVE_ACCOUNT && ckipper account add $CKIPPER_WT_ACTIVE_ACCOUNT --adopt" >&2 return 1 fi } @@ -125,7 +130,7 @@ _ckipper_worktree_docker_extract_gh_token() { # Build the base docker run argument array into CKIPPER_WT_DOCKER_ARGS. # -# Reads: CKIPPER_WT_WT_PATH, CKIPPER_PROJECTS_DIR, CKIPPER_WT_PROJECT, CKIPPER_WT_ACTIVE_CONFIG_DIR, +# Reads: CKIPPER_WT_PATH, CKIPPER_PROJECTS_DIR, CKIPPER_WT_PROJECT, CKIPPER_WT_ACTIVE_CONFIG_DIR, # CKIPPER_EXTRA_VOLUMES globals. # Sets: CKIPPER_WT_DOCKER_ARGS (initialised from scratch). # Returns: 0 always. @@ -133,7 +138,7 @@ _ckipper_worktree_docker_build_base_args() { CKIPPER_WT_DOCKER_ARGS=( docker run --rm -it -e TERM="${TERM:-xterm-256color}" - -v "$CKIPPER_WT_WT_PATH:/workspace:rw" + -v "$CKIPPER_WT_PATH:/workspace:rw" -v "$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git:$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git:rw" -v "$CKIPPER_WT_ACTIVE_CONFIG_DIR:$CKIPPER_WT_ACTIVE_CONFIG_DIR:rw" -e "CLAUDE_CONFIG_DIR=$CKIPPER_WT_ACTIVE_CONFIG_DIR" @@ -197,14 +202,14 @@ _ckipper_worktree_docker_expand_command() { # Print the startup banner. # -# Reads: CKIPPER_WT_COMMAND, CKIPPER_WT_FLAG_FIREWALL, CKIPPER_WT_WT_PATH, CKIPPER_WT_RESOLVED_PORTS globals. +# Reads: CKIPPER_WT_COMMAND, CKIPPER_WT_FLAG_FIREWALL, CKIPPER_WT_PATH, CKIPPER_WT_RESOLVED_PORTS globals. # Returns: 0 always. _ckipper_worktree_docker_print_banner() { local mode_label="Docker" [[ ${#CKIPPER_WT_COMMAND[@]} -gt 0 ]] && mode_label+=": ${CKIPPER_WT_COMMAND[1]}" [[ "$CKIPPER_WT_FLAG_FIREWALL" = true ]] && mode_label+=", firewall" echo "Starting $mode_label..." - echo " Worktree: $CKIPPER_WT_WT_PATH" + echo " Worktree: $CKIPPER_WT_PATH" echo " Ports: ${CKIPPER_WT_RESOLVED_PORTS[*]}" } diff --git a/lib/worktree/docker-mode_test.bats b/lib/worktree/docker-mode_test.bats index fe678f5..3720a93 100644 --- a/lib/worktree/docker-mode_test.bats +++ b/lib/worktree/docker-mode_test.bats @@ -11,7 +11,7 @@ setup() { export DOCKER_STUB_LOG="$TMP_HOME/docker.log" : > "$DOCKER_STUB_LOG" # Provide reasonable defaults for globals docker-mode reads. - export CKIPPER_WT_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" + export CKIPPER_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" export CKIPPER_PROJECTS_DIR="$TMP_HOME/Developer" export CKIPPER_WT_PROJECT="myapp" export CKIPPER_WT_BRANCH="feature-x" @@ -38,7 +38,7 @@ _run_docker_mode() { CKIPPER_DIR="$CKIPPER_DIR" \ CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ DOCKER_STUB_LOG="$DOCKER_STUB_LOG" \ - CKIPPER_WT_WT_PATH="$CKIPPER_WT_WT_PATH" \ + CKIPPER_WT_PATH="$CKIPPER_WT_PATH" \ CKIPPER_PROJECTS_DIR="$CKIPPER_PROJECTS_DIR" \ CKIPPER_WT_PROJECT="$CKIPPER_WT_PROJECT" \ CKIPPER_WT_BRANCH="$CKIPPER_WT_BRANCH" \ @@ -68,7 +68,7 @@ _run_docker_mode() { [ "$status" -eq 0 ] [[ "$output" =~ "-v" ]] - [[ "$output" =~ "$CKIPPER_WT_WT_PATH:/workspace:rw" ]] + [[ "$output" =~ "$CKIPPER_WT_PATH:/workspace:rw" ]] } @test "_ckipper_worktree_docker_add_optional_args emits a warning when no credentials are provided" { diff --git a/lib/worktree/normal-mode.zsh b/lib/worktree/normal-mode.zsh index 1433872..5ddb7ee 100644 --- a/lib/worktree/normal-mode.zsh +++ b/lib/worktree/normal-mode.zsh @@ -3,11 +3,11 @@ # Run the worktree in normal mode: cd to it or run a command inside it. # -# Reads globals: CKIPPER_WT_WT_PATH, CKIPPER_WT_BRANCH, CKIPPER_WT_COMMAND. +# Reads globals: CKIPPER_WT_PATH, CKIPPER_WT_BRANCH, CKIPPER_WT_COMMAND. # Returns: 0 on cd; exit code of command if one was given. _ckipper_worktree_run_normal_mode() { if [[ ${#CKIPPER_WT_COMMAND[@]} -eq 0 ]]; then - cd "$CKIPPER_WT_WT_PATH" + cd "$CKIPPER_WT_PATH" return 0 fi @@ -16,7 +16,7 @@ _ckipper_worktree_run_normal_mode() { fi local old_pwd="$PWD" - cd "$CKIPPER_WT_WT_PATH" + cd "$CKIPPER_WT_PATH" "${CKIPPER_WT_COMMAND[@]}" local exit_code=$? cd "$old_pwd" diff --git a/lib/worktree/normal-mode_test.bats b/lib/worktree/normal-mode_test.bats index df2bfe3..80b10c3 100644 --- a/lib/worktree/normal-mode_test.bats +++ b/lib/worktree/normal-mode_test.bats @@ -6,9 +6,9 @@ load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" setup() { setup_isolated_env - export CKIPPER_WT_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" + export CKIPPER_WT_PATH="$TMP_HOME/worktrees/myapp/feature-x" export CKIPPER_WT_BRANCH="feature-x" - mkdir -p "$CKIPPER_WT_WT_PATH" + mkdir -p "$CKIPPER_WT_PATH" } teardown() { @@ -17,7 +17,7 @@ teardown() { @test "_ckipper_worktree_run_normal_mode executes a stub claude binary in the worktree directory" { run env HOME="$TMP_HOME" \ - CKIPPER_WT_WT_PATH="$CKIPPER_WT_WT_PATH" \ + CKIPPER_WT_PATH="$CKIPPER_WT_PATH" \ CKIPPER_WT_BRANCH="$CKIPPER_WT_BRANCH" \ PATH="$PATH" \ zsh -c " diff --git a/lib/worktree/resolve-account.zsh b/lib/worktree/resolve-account.zsh index 91c5e1e..4bdf5e5 100644 --- a/lib/worktree/resolve-account.zsh +++ b/lib/worktree/resolve-account.zsh @@ -20,8 +20,8 @@ _ckipper_worktree_resolve_account() { candidate=$(_ckipper_worktree_find_account_name) if [[ -z "$candidate" ]]; then - echo "Error: no account selected and no default registered." - echo "Run: ckipper account list (then: ckipper account default , or pass --account )" + echo "Error: no account selected and no default registered." >&2 + echo "Run: ckipper account list (then: ckipper account default , or pass --account )" >&2 return 1 fi @@ -32,7 +32,7 @@ _ckipper_worktree_resolve_account() { fi if [[ -z "$config_dir" ]]; then - echo "Error: account '$candidate' is not registered. Run: ckipper account list" + echo "Error: account '$candidate' is not registered. Run: ckipper account list" >&2 return 1 fi diff --git a/lib/worktree/worktree.zsh b/lib/worktree/worktree.zsh index cd69f60..7b3b95e 100644 --- a/lib/worktree/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -55,7 +55,9 @@ _ckipper_worktree_get_project_and_branch() { fi } -# Remove a worktree and delete its branch. +# Remove a worktree and delete its branch. Empty-args validation lives in the +# dispatcher (`_ckipper_worktree_route_rm`); this function expects non-empty +# `project` and `worktree`. # # Args: # $1 — project path (relative to CKIPPER_PROJECTS_DIR) @@ -64,21 +66,15 @@ _ckipper_worktree_get_project_and_branch() { # Reads CKIPPER_WT_FLAG_FORCE, CKIPPER_WORKTREES_DIR, CKIPPER_PROJECTS_DIR globals. # Returns: 0 on success; 1 on validation failure or git error. # Errors (stderr): -# "Usage: ckipper worktree rm [--force] " — when project or worktree is empty # "Worktree not found: " — when the worktree directory does not exist # "Failed to remove worktree. Use --force if it has uncommitted changes." — on git error _ckipper_worktree_remove_worktree() { local project="$1" local worktree="$2" - if [[ -z "$project" || -z "$worktree" ]]; then - echo "Usage: ckipper worktree rm [--force] " - return 1 - fi - local wt_path="$CKIPPER_WORKTREES_DIR/$project/$worktree" if [[ ! -d "$wt_path" ]]; then - echo "Worktree not found: $wt_path" + echo "Worktree not found: $wt_path" >&2 return 1 fi @@ -86,7 +82,7 @@ _ckipper_worktree_remove_worktree() { [[ "$CKIPPER_WT_FLAG_FORCE" = true ]] && force_flag="--force" (cd "$CKIPPER_PROJECTS_DIR/$project" && git worktree remove $force_flag "$wt_path" && git branch -D "$worktree" 2>/dev/null) || { - echo "Failed to remove worktree. Use --force if it has uncommitted changes." + echo "Failed to remove worktree. Use --force if it has uncommitted changes." >&2 return 1 } @@ -109,7 +105,7 @@ _ckipper_worktree_cleanup_project_registry() { } # Create a worktree for the given project and branch (idempotent if it already exists). -# Sets CKIPPER_WT_WT_PATH to the resolved worktree path. +# Sets CKIPPER_WT_PATH to the resolved worktree path. # # Args: # $1 — project path (relative to CKIPPER_PROJECTS_DIR) @@ -125,19 +121,19 @@ _ckipper_worktree_create_worktree() { local worktree="$2" if [[ ! -d "$CKIPPER_PROJECTS_DIR/$project" ]]; then - echo "Project not found: $CKIPPER_PROJECTS_DIR/$project" + echo "Project not found: $CKIPPER_PROJECTS_DIR/$project" >&2 return 1 fi if [[ -d "$CKIPPER_WORKTREES_DIR/$project/$worktree" ]]; then _ckipper_worktree_validate_existing_worktree "$project" "$worktree" || return 1 - CKIPPER_WT_WT_PATH="$CKIPPER_WORKTREES_DIR/$project/$worktree" + CKIPPER_WT_PATH="$CKIPPER_WORKTREES_DIR/$project/$worktree" return 0 fi echo "Creating worktree: $worktree" mkdir -p "$CKIPPER_WORKTREES_DIR/$project" - CKIPPER_WT_WT_PATH="$CKIPPER_WORKTREES_DIR/$project/$worktree" + CKIPPER_WT_PATH="$CKIPPER_WORKTREES_DIR/$project/$worktree" _ckipper_worktree_fetch_and_create "$project" "$worktree" || return 1 _ckipper_worktree_post_create_setup "$project" "$worktree" || return 1 @@ -158,8 +154,8 @@ _ckipper_worktree_validate_existing_worktree() { local wt_path="$CKIPPER_WORKTREES_DIR/$project/$worktree" if [[ ! -f "$wt_path/.git" ]]; then - echo "Error: $wt_path exists but is not a valid worktree." - echo "Remove it manually or use a different branch name." + echo "Error: $wt_path exists but is not a valid worktree." >&2 + echo "Remove it manually or use a different branch name." >&2 return 1 fi } @@ -197,7 +193,7 @@ _ckipper_worktree_fetch_origin() { local worktree="$2" (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin develop) || { - echo "Failed to fetch from origin. Check your network connection and that 'develop' exists on the remote." + echo "Failed to fetch from origin. Check your network connection and that 'develop' exists on the remote." >&2 return 1 } (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin "$worktree" 2>/dev/null) || true @@ -220,13 +216,13 @@ _ckipper_worktree_add_worktree() { (cd "$CKIPPER_PROJECTS_DIR/$project" && \ if git show-ref --verify --quiet "refs/heads/$worktree"; then echo "Using existing local branch: $worktree" - git worktree add "$CKIPPER_WT_WT_PATH" "$worktree" + git worktree add "$CKIPPER_WT_PATH" "$worktree" elif git show-ref --verify --quiet "refs/remotes/origin/$worktree"; then echo "Tracking remote branch: origin/$worktree" - git worktree add "$CKIPPER_WT_WT_PATH" -b "$worktree" "origin/$worktree" + git worktree add "$CKIPPER_WT_PATH" -b "$worktree" "origin/$worktree" else echo "Creating new branch from origin/develop" - git worktree add "$CKIPPER_WT_WT_PATH" -b "$worktree" origin/develop + git worktree add "$CKIPPER_WT_PATH" -b "$worktree" origin/develop fi ) || _ckipper_worktree_handle_worktree_add_failure "$project" "$worktree" } @@ -247,11 +243,11 @@ _ckipper_worktree_handle_worktree_add_failure() { local current_branch current_branch=$(cd "$CKIPPER_PROJECTS_DIR/$project" && git branch --show-current 2>/dev/null) if [[ "$current_branch" == "$worktree" ]]; then - echo "Failed: branch '$worktree' is currently checked out in the main repo." - echo "Switch the main repo to a different branch first:" - echo " cd $CKIPPER_PROJECTS_DIR/$project && git checkout develop" + echo "Failed: branch '$worktree' is currently checked out in the main repo." >&2 + echo "Switch the main repo to a different branch first:" >&2 + echo " cd $CKIPPER_PROJECTS_DIR/$project && git checkout develop" >&2 else - echo "Failed to create worktree" + echo "Failed to create worktree" >&2 fi return 1 } @@ -262,17 +258,17 @@ _ckipper_worktree_handle_worktree_add_failure() { # $1 — project path (relative to CKIPPER_PROJECTS_DIR) # $2 — branch/worktree name (unused but kept for symmetry with other helpers) # -# Reads CKIPPER_WT_WT_PATH, CKIPPER_WT_ACTIVE_ACCOUNT globals. +# Reads CKIPPER_WT_PATH, CKIPPER_WT_ACTIVE_ACCOUNT globals. # Returns: 0 always (individual steps may warn on failure but don't abort). _ckipper_worktree_post_create_setup() { local project="$1" echo "Installing dependencies..." - (cd "$CKIPPER_WT_WT_PATH" && npm install) || echo "Warning: npm install failed. You may need to run it manually." + (cd "$CKIPPER_WT_PATH" && npm install) || echo "Warning: npm install failed. You may need to run it manually." for env_file in $(find "$CKIPPER_PROJECTS_DIR/$project" -maxdepth "$CKIPPER_WT_FIND_MAX_DEPTH" -name ".env*" -not -name "*.example" -not -path "*/node_modules/*" -not -path "*/.git/*"); do local rel_path="${env_file#$CKIPPER_PROJECTS_DIR/$project/}" - local dest_dir="$CKIPPER_WT_WT_PATH/$(dirname "$rel_path")" + local dest_dir="$CKIPPER_WT_PATH/$(dirname "$rel_path")" mkdir -p "$dest_dir" cp "$env_file" "$dest_dir/" echo "Copied $rel_path" @@ -286,7 +282,7 @@ _ckipper_worktree_post_create_setup() { # Args: # $1 — project path (relative to CKIPPER_PROJECTS_DIR) # -# Reads CKIPPER_WT_WT_PATH, CKIPPER_WT_ACTIVE_ACCOUNT globals. +# Reads CKIPPER_WT_PATH, CKIPPER_WT_ACTIVE_ACCOUNT globals. # Returns: 0 always (failure is non-fatal). _ckipper_worktree_sync_project_registry() { local project="$1" @@ -295,6 +291,6 @@ _ckipper_worktree_sync_project_registry() { if [[ -f "$ckipper_base_dir/docker/cleanup-projects.py" ]]; then CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ python3 "$ckipper_base_dir/docker/cleanup-projects.py" sync \ - "$CKIPPER_WT_ACTIVE_ACCOUNT" "$main_project_path" "$CKIPPER_WT_WT_PATH" 2>/dev/null || true + "$CKIPPER_WT_ACTIVE_ACCOUNT" "$main_project_path" "$CKIPPER_WT_PATH" 2>/dev/null || true fi } diff --git a/templates/settings-template.json b/templates/settings-template.json index 803012c..88a5e6e 100644 --- a/templates/settings-template.json +++ b/templates/settings-template.json @@ -1,5 +1,5 @@ { - "_comment": "Per-account settings.json template. Copied verbatim by ckipper add; hook paths are rewritten to the account dir by ckipper sync-hooks.", + "_comment": "Per-account settings.json template. Copied verbatim by ckipper account add; hook paths are rewritten to the account dir by ckipper account sync-hooks.", "hooks": { "PreToolUse": [ { From 3c4511e0f1d6087b2b01e60fd9d61c888d43eac6 Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 21:39:58 -0600 Subject: [PATCH 089/165] Scrub personally identifying info from public-facing docs LICENSE: copyright holder switched from real name to GitHub handle. SECURITY.md / CODE_OF_CONDUCT.md: drop direct email; route reports through GitHub Security Advisories instead. --- CODE_OF_CONDUCT.md | 2 +- LICENSE | 2 +- SECURITY.md | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4617114..f4d9d33 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at matt@msw.dev. All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the community leaders responsible for enforcement via a [GitHub Security Advisory](https://github.com/mswdev/Ckipper/security/advisories/new). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. diff --git a/LICENSE b/LICENSE index 32f39fd..5ab5305 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Matt White +Copyright (c) 2026 mswdev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/SECURITY.md b/SECURITY.md index e84ead8..e1041e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,9 +4,7 @@ Please do **NOT** open a public GitHub issue for security vulnerabilities. -Instead, report privately via: -- GitHub Security Advisories: https://github.com/mswdev/Ckipper/security/advisories/new -- Email: matt@msw.dev +Instead, report privately via [GitHub Security Advisories](https://github.com/mswdev/Ckipper/security/advisories/new). We aim to acknowledge reports within 72 hours and to provide a fix or mitigation timeline within 7 days. From 5829b3ad9c9ecc119d86cd214bf45329a379328c Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 21:42:19 -0600 Subject: [PATCH 090/165] docs: add vibe-engineered disclaimer at top of README One-line heads-up so readers know the provenance up front. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d714340..4bc4634 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Ckipper (pronounced "skipper") +> _This project is vibe-engineered. I use it personally for my own setup and it works well; use it at your own risk. Works on my machine ;) See [Contributing](#contributing)._ + Docker-based isolation for running Claude Code with `--dangerously-skip-permissions` safely, plus multi-account support: run a personal account in one terminal and a work account in another, fully isolated. Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, an egress firewall, safety hooks, macOS Keychain auth, and per-account isolation across credentials, settings, MCP, plugins, and projects. From abdb18dd7cbfd7663579f6535436518b92986124 Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 22:20:15 -0600 Subject: [PATCH 091/165] Public-release hardening sweep (audit follow-up) Docs / release prep: - README: replace inaccurate "no private keys copied" claim with truthful in-container access description; add prompt-injection, MCP, and worktree-push disclosures; surface macOS-only requirement up top; clarify bash-guardrails framing as UX-only; note hooks fire on Bash/Edit/Write tools (Read is unhooked); add core.hooksPath caveat. - .gitignore: editor/IDE state, OS cruft, Python caches, Claude per-machine settings, install backup files, node_modules. - CHANGELOG: cut [0.2.0] dated 2026-04-30; reset [Unreleased]. - ci.yml: drop dead lib/w/ cross-import check. Hardening: - Dockerfile: pin SHA256 of uv, claude, and bun installer scripts; verify with sha256sum -c before exec. Hashes overridable via build-args. - docker-mode.zsh: stop leaking CLAUDE_CREDENTIALS/GH_TOKEN through docker run argv (pre-export + bare -e VAR); add --cap-drop=ALL to base args (NET_ADMIN re-added under --firewall). no-new-privileges deliberately not added because it would break sudo for fix-volume-perms / firewall. - protect-claude-config.sh: also block Edit/Write to .git/{config,info, hooks,worktrees}; resolve realpath before regex match. - init-firewall.sh: set OUTPUT DROP before adding allow rules (closes bootstrap-window race); add ip6tables-legacy default-deny; replace rule-count post-check with policy-DROP assertion. - worktree.zsh / args.zsh: validate branch and project names; add `--` separator before refs in git fetch/worktree-add/branch -D so flag- shaped names can't influence option parsing. --- .github/workflows/ci.yml | 6 -- .gitignore | 26 +++++++ CHANGELOG.md | 4 +- README.md | 34 +++++++-- docker/Dockerfile | 15 +++- docker/init-firewall.sh | 60 +++++++++++++-- hooks/protect-claude-config.sh | 24 +++++- hooks/protect-claude-config_test.bats | 57 ++++++++++++++ lib/worktree/args.zsh | 57 ++++++++++++++ lib/worktree/args_test.bats | 104 ++++++++++++++++++++++++++ lib/worktree/docker-mode.zsh | 36 ++++++++- lib/worktree/docker-mode_test.bats | 70 +++++++++++++++++ lib/worktree/worktree.zsh | 20 +++-- lib/worktree/worktree_test.bats | 33 +++++++- 14 files changed, 509 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c48e4f..c1f9b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,3 @@ jobs: - name: Test (unit) run: make test-unit - - - name: Verify no sibling cross-imports (lib/w/ -> lib/ckipper/) - run: | - if [ -d lib/w ]; then - ! grep -rE '\b_ckipper_' lib/w/ - fi diff --git a/.gitignore b/.gitignore index 00d8dd1..6fb6300 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,28 @@ # Implementation plan artifacts and design docs (per repo policy, commit 5cef1de) docs/plans/ + +# Editor / IDE local state +.vscode/ +.idea/ +*.swp +*.swo +*.bak + +# OS cruft +.DS_Store + +# Python tooling caches +.pytest_cache/ +.ruff_cache/ +__pycache__/ +*.pyc + +# Claude Code per-machine settings (also covered by user's global gitignore, but defensive) +.claude/settings.local.json +.claude/.session_*.json + +# install.sh creates timestamped .zshrc backups +.zshrc.ckipper-bak.* + +# Node modules (in case anyone runs install steps locally) +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cc36ddf..ef22fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] — Breaking changes: merge `w` into `ckipper` +## [Unreleased] + +## [0.2.0] — 2026-04-30 — Breaking changes: merge `w` into `ckipper` ### Removed - `w()` shell function (replaced by `ckipper worktree run`). diff --git a/README.md b/README.md index 4bc4634..d0c330c 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ > _This project is vibe-engineered. I use it personally for my own setup and it works well; use it at your own risk. Works on my machine ;) See [Contributing](#contributing)._ +> **Platform:** macOS only — uses macOS Keychain, Docker Desktop, and host SSH agent forwarding. + Docker-based isolation for running Claude Code with `--dangerously-skip-permissions` safely, plus multi-account support: run a personal account in one terminal and a work account in another, fully isolated. Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, an egress firewall, safety hooks, macOS Keychain auth, and per-account isolation across credentials, settings, MCP, plugins, and projects. ## The Problem -`--dangerously-skip-permissions` lets Claude work autonomously without clicking Allow for every action — but on your actual machine it has full access to your filesystem, credentials, and network. +`--dangerously-skip-permissions` lets Claude work autonomously without clicking Allow for every action — but on your actual machine it has full access to your filesystem, credentials, and network. The flag means Claude executes every command it decides to run for the entire session without asking; running it inside a container is the whole point. ## The Solution @@ -144,31 +146,45 @@ On every container start, `entrypoint.sh` automatically: Claude **cannot**: access files outside the worktree, reach your Documents/Desktop/other projects, install system packages, persist processes after exit, create other Docker containers, or access your LAN (ports bound to `127.0.0.1` only). +**What Claude *can* do inside the container:** + +- Full read/write on the worktree (`/workspace`). +- Read/write on the parent repo's `.git/` directory (so commits, fetches, and branch ops work). +- Read/write on the active per-account dir (`~/.claude-/`). +- Read a copy of your `~/.ssh` contents — staged read-only at `~/.ssh-host` and copied into the container's tmpfs at `~/.ssh` on startup. If you keep private key files in `~/.ssh`, treat the container as having the same SSH access you do. The copy lives only in container RAM and disappears when the container exits. +- Use the host's SSH agent (forwarded via `/run/host-services/ssh-auth.sock`) and a `gh`-authenticated session for HTTPS pushes. +- Outbound network — unrestricted by default; default-deny with a domain whitelist when `--firewall` is set. + +### Prompt injection: the agent inside is still a target + +Container isolation contains accidents and outright host-level escalation; it does not stop a successful prompt-injection attack from doing damage *with* Claude's legitimate access. An attacker-controlled input — a poisoned README in a dependency, a hostile MCP server, a fetched URL, a GitHub issue body that Claude reads, a malicious commit message — can try to convince Claude to act against you using the access it already has. That includes writing files anywhere in the worktree, committing and pushing to your remote (the SSH agent is forwarded and `gh` is authenticated), reading the in-container copy of `~/.ssh` and the per-account `.claude.json`, and sending data outbound to any whitelisted domain. Treat untrusted inputs accordingly: be cautious about what repos you point Claude at, what MCP servers you install, and what URLs you ask it to fetch. The `--firewall` mode meaningfully shrinks the exfiltration surface but does not eliminate it (GitHub itself is whitelisted). + ### Safety Hooks (Docker-only, no-op on host) -Four Claude Code hooks activate inside Docker: +These hooks are UX guardrails, not a security boundary — the container, the optional egress firewall, and the absence of host write access are the actual isolation. Four Claude Code hooks activate inside Docker: 1. **Config Protection** (`protect-claude-config.sh`) — Blocks Edit/Write to Claude config files (settings.json, hooks, plugins, etc.) that could execute code on the host -2. **Bash Guardrails** (`bash-guardrails.sh`) — Blocks destructive commands: +2. **Bash Guardrails** (`bash-guardrails.sh`) — Blocks destructive commands run via the Bash tool (the Read tool is not hooked, so this is not a defense against direct file reads): - `rm -rf` (except build artifacts like `node_modules`, `dist`, `.next`) - `git push --force` (suggests `--force-with-lease`) - `git reset --hard` (suggests `git stash`) - Writing to `.git/hooks/` or `.git/config` (these execute on the host) - Recursive `chmod`/`chown` - - Reading SSH keys or credential files directly + - Reading SSH keys or credential files via shell commands like `cat`/`cp`/`base64` (Bash-tool only — the Read tool is not hooked, so this catches scripted exfiltration, not direct reads) - Modifying Claude config files via shell 3. **Context Injection** (`docker-context.sh`) — Tells Claude the safety rules at startup so it avoids triggering guardrails 4. **Notification Bell** (`notify-bell.sh`) — Sends a terminal bell character (`\a`) on Claude Code notification events, which passes through Docker's TTY to the host terminal. Triggers native notifications (dock bounce, sound) in Ghostty, iTerm2, Warp, and other terminals that support terminal bell ### Additional Security -- `core.hooksPath` set globally to `~/.git-hooks` — git ignores `.git/hooks/` so planted hooks can't execute on host +- `core.hooksPath` set globally to `~/.git-hooks` — git ignores `.git/hooks/` so planted hooks can't execute on host. `install.sh` only sets this if you don't already have a different value (so husky/pre-commit/etc. are preserved); the global setting is overridable by per-repo config, so it isn't an absolute backstop. - GPG signing disabled via `GIT_CONFIG_COUNT` env vars — no file modification, overrides both local and global config, disappears when container exits - Post-session `.git/config` tamper detection - Credentials cleared from environment before launching the command (invisible to `env` and `/proc/self/environ`) - Per-account `.claude.json` is bind-mounted RW; container mutations propagate to the host file (intentional, gated by the same-account-twice advisory) -- SSH config mounted read-only as staging copy (`.ssh-host`), copied and sanitized by entrypoint — macOS-specific `UseKeychain` stripped -- SSH agent forwarded from host via Docker Desktop socket (`/run/host-services/ssh-auth.sock`) — no private keys copied into container +- SSH config mounted read-only as staging copy (`.ssh-host`), copied and sanitized by entrypoint — macOS-specific `UseKeychain` stripped. The contents of `~/.ssh` (including any private key files) are copied into the container's tmpfs as part of this — see "What Claude *can* do" above. +- SSH agent forwarded from host via Docker Desktop socket (`/run/host-services/ssh-auth.sock`) +- SSH agent forwarding and the `~/.ssh` staging mount are currently always-on for `--docker` mode. Making this configurable via the planned setup wizard is on the roadmap. - Per-account `~/.claude-` mounted at the same host path inside the container so plugins with hardcoded absolute paths resolve correctly - No Docker socket mounted (cannot create sibling containers) @@ -182,6 +198,8 @@ Default-deny iptables firewall that only allows outbound traffic to whitelisted Default whitelist: Anthropic API, GitHub, npm, PyPI, Sentry, and common MCP services (Atlassian, Clerk, Figma, ClickUp, Context7, Google Fonts). Edit `docker/init-firewall.sh` to customize. +Default-deny applies to IPv4. Container IPv6 is off by default in Docker Desktop; if you've enabled it, keep it disabled when running with `--firewall` until IPv6 default-deny is in place. + ## MCP Support | MCP Server | Type | Works? | @@ -191,6 +209,8 @@ Default whitelist: Anthropic API, GitHub, npm, PyPI, Sentry, and common MCP serv | MCPs with local files | node/uvx (mounted ro) | Yes (add mount) | | Docker-based MCPs | Docker-in-Docker | No (security) | +> **Supply-chain note:** an MCP server is third-party code that runs inside the container with the same access Claude has — full RW on the worktree, the per-account `.claude.json`, and outbound network. Pin versions where the registry supports it, audit servers before adding them, and remove servers you no longer use (`ckipper account sync` doesn't prune; edit `.claude.json` or use `claude mcp remove`). + For MCPs that reference local files, add entries to `CKIPPER_EXTRA_VOLUMES` in `~/.ckipper/docker/ckipper-config.zsh`. Mount at the exact same host path so MCP configs work unchanged. Two named Docker volumes support uvx-based MCP servers: diff --git a/docker/Dockerfile b/docker/Dockerfile index 3f2da40..0462934 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -37,8 +37,10 @@ ENV CHROMIUM_FLAGS="--no-sandbox" ENV PUPPETEER_CHROMIUM_ARGS="--no-sandbox" # Install uv/uvx (Python package runner — needed for Python-based MCP servers) -# TODO(public-release): pin SHA256 checksum of installer. +# SHA256 fetched 2026-04-30 from https://astral.sh/uv/install.sh +ARG UV_INSTALLER_SHA256=facbed3a7e2750df3aef698537c6d50869b025a58bdd13154cfdcc2f30354ca2 RUN curl -LsSf https://astral.sh/uv/install.sh -o /tmp/install.sh && \ + echo "${UV_INSTALLER_SHA256} /tmp/install.sh" | sha256sum -c - && \ sh /tmp/install.sh && \ rm /tmp/install.sh \ && cp /root/.local/bin/uv /usr/local/bin/uv \ @@ -46,8 +48,13 @@ RUN curl -LsSf https://astral.sh/uv/install.sh -o /tmp/install.sh && \ && chmod +x /usr/local/bin/uv /usr/local/bin/uvx # Install Claude Code via native installer -# TODO(public-release): pin SHA256 checksum of installer. +# SHA256 fetched 2026-04-30 from https://claude.ai/install.sh +# Note: this is a bootstrap installer that fetches the actual binary from +# downloads.claude.ai. The bootstrap script itself changes infrequently, but +# refresh this hash if a Claude Code release ships an updated installer. +ARG CLAUDE_INSTALLER_SHA256=b315b46925a9bfb9422f2503dd5aa649f680832f4c076b22d87c39d578c3d830 RUN curl -fsSL https://claude.ai/install.sh -o /tmp/install.sh && \ + echo "${CLAUDE_INSTALLER_SHA256} /tmp/install.sh" | sha256sum -c - && \ bash /tmp/install.sh && \ rm /tmp/install.sh \ && cp /root/.local/bin/claude /usr/local/bin/claude \ @@ -64,8 +71,10 @@ RUN userdel -r node 2>/dev/null; groupdel node 2>/dev/null; \ && chown -R claude:claude /home/claude/.local /home/claude/.ssh /home/claude/.config /home/claude/.cache /home/claude/.uv-tools # Install bun (fast JS runtime — needed for bunx-based statusline commands and general use) -# TODO(public-release): pin SHA256 checksum of installer. +# SHA256 fetched 2026-04-30 from https://bun.sh/install +ARG BUN_INSTALLER_SHA256=bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd RUN curl -fsSL https://bun.sh/install -o /tmp/install.sh && \ + echo "${BUN_INSTALLER_SHA256} /tmp/install.sh" | sha256sum -c - && \ bash /tmp/install.sh && \ rm /tmp/install.sh \ && cp /root/.bun/bin/bun /usr/local/bin/bun \ diff --git a/docker/init-firewall.sh b/docker/init-firewall.sh index 4868a02..ac2b173 100755 --- a/docker/init-firewall.sh +++ b/docker/init-firewall.sh @@ -9,7 +9,6 @@ set -e # Constants readonly FIREWALL_VERIFY_TIMEOUT=5 -readonly FIREWALL_MIN_ACCEPT_RULES=5 # Whitelisted domains — edit this list to add/remove allowed destinations ALLOWED_DOMAINS=( @@ -58,6 +57,11 @@ fi # Flush existing rules iptables-legacy -F OUTPUT 2>/dev/null || true +# Set default-deny FIRST so the bootstrap window (between flush and final +# rule installation) inherits deny-by-default. Allow rules added below take +# effect via -A; anything not matched falls through to the DROP policy. +iptables-legacy -P OUTPUT DROP + # Allow loopback iptables-legacy -A OUTPUT -o lo -j ACCEPT @@ -89,17 +93,59 @@ for cidr in $gh_ranges; do iptables-legacy -A OUTPUT -d "$cidr" -j ACCEPT 2>/dev/null && ((ip_count++)) || true done -# Default deny everything else -iptables-legacy -P OUTPUT DROP +# IPv6 default-deny — defense-in-depth in case container IPv6 is enabled. +# We keep no IPv6 allowlist; all IPv4-resolved allowlisted services route over +# v4. If a user explicitly enables container v6 and needs traffic through, they +# must extend this section (and the v4 allowlist) in tandem. +v6_present=true +if ! command -v ip6tables-legacy >/dev/null 2>&1; then + echo " WARNING: ip6tables-legacy not present; skipping IPv6 rules (v6 traffic may be unfiltered if enabled)" >&2 + v6_present=false +fi + +configure_ipv6_firewall() { + ip6tables-legacy -F OUTPUT || return 1 + ip6tables-legacy -F INPUT || return 1 + ip6tables-legacy -F FORWARD || return 1 + # Default deny first (same race-avoidance reasoning as v4 above) + ip6tables-legacy -P INPUT DROP || return 1 + ip6tables-legacy -P OUTPUT DROP || return 1 + ip6tables-legacy -P FORWARD DROP || return 1 + # Allow loopback + ip6tables-legacy -A INPUT -i lo -j ACCEPT || return 1 + ip6tables-legacy -A OUTPUT -o lo -j ACCEPT || return 1 + # Allow established/related return traffic + ip6tables-legacy -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT || return 1 + ip6tables-legacy -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT || return 1 +} + +if [[ $v6_present == "true" ]]; then + # SC2310: we WANT set -e suppression here — a kernel without v6 support + # should warn-and-continue, not abort the script. + # shellcheck disable=SC2310 + if configure_ipv6_firewall; then + echo " IPv6: default-deny applied" + else + echo " WARNING: ip6tables-legacy commands failed (kernel may lack v6 support); v6 traffic may be unfiltered if enabled" >&2 + v6_present=false + fi +fi echo "=== Firewall active: $ip_count rules added ===" -# Post-check: verify expected number of ACCEPT rules were installed -rule_count=$(iptables-legacy -L OUTPUT -n | grep -c ACCEPT) -if [ "$rule_count" -lt "$FIREWALL_MIN_ACCEPT_RULES" ]; then - echo "Error: only $rule_count ACCEPT rules — firewall appears inactive" >&2 +# Post-check: verify the OUTPUT chain is actually default-DROP. A regression +# that loses the policy line while keeping ACCEPT rules would silently leave +# the container fully open, so we assert the policy itself. +if ! iptables-legacy -L OUTPUT -n | head -1 | grep -q '(policy DROP)'; then + echo "ERROR: iptables OUTPUT policy is not DROP after firewall init" >&2 exit 1 fi +if [[ $v6_present == "true" ]]; then + if ! ip6tables-legacy -L OUTPUT -n | head -1 | grep -q '(policy DROP)'; then + echo "ERROR: ip6tables OUTPUT policy is not DROP after firewall init" >&2 + exit 1 + fi +fi # Verification echo "=== Verifying firewall ===" diff --git a/hooks/protect-claude-config.sh b/hooks/protect-claude-config.sh index 8b2b2a6..47b06d9 100755 --- a/hooks/protect-claude-config.sh +++ b/hooks/protect-claude-config.sh @@ -13,20 +13,40 @@ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') || { exit 2 } +# Resolve symlinks/relative segments before regex matching so attempts like +# /workspace/foo/../.git/config can't slip past the substring check. Fall back +# to the raw path if realpath isn't available (it lives in coreutils inside +# the container, but we keep this defensive for host-side test runs). +if command -v realpath >/dev/null 2>&1; then + RESOLVED_PATH=$(realpath -m "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH") +else + RESOLVED_PATH="$FILE_PATH" +fi + # Block Claude state subset under ~/.claude or any per-account ~/.claude-. # Note: ~/.claude-host.json is the read-only staging mount and is intentionally # excluded — the regex requires a '/' after the optional - suffix, while # .claude-host.json has '.json' instead. -if [[ $FILE_PATH =~ \.claude(-[a-z0-9_-]+)?/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/) ]]; then +if [[ $RESOLVED_PATH =~ \.claude(-[a-z0-9_-]+)?/(settings(\.local)?\.json|statusline-command\.sh|CLAUDE\.md|commands/|docker/|hooks/|plugins/) ]]; then echo "Blocked: cannot modify $FILE_PATH (protected Claude config file)" >&2 exit 2 fi # Block anything under ~/.ckipper (registry, hooks, settings-template, docker tooling). # Tampering with accounts.json could redirect another account's keychain_service. -if [[ $FILE_PATH =~ /\.ckipper/ ]]; then +if [[ $RESOLVED_PATH =~ /\.ckipper/ ]]; then echo "Blocked: cannot modify $FILE_PATH (protected Ckipper file)" >&2 exit 2 fi +# Block writes inside the host repo's .git/ (mounted RW into the container by +# docker-mode.zsh). Edits to .git/hooks/, .git/config, .git/info/, or +# .git/worktrees/ execute on the host the next time the user runs git, so they +# constitute a container-escape vector. The leading '/' anchor avoids +# over-blocking '.gitignore', '.github/', or directories like '.git-foo/'. +if [[ $RESOLVED_PATH =~ /\.git/(config|info/|hooks/|worktrees/) ]]; then + echo "Blocked: cannot modify $FILE_PATH (protected host .git file)" >&2 + exit 2 +fi + exit 0 diff --git a/hooks/protect-claude-config_test.bats b/hooks/protect-claude-config_test.bats index 73a99fa..3a042b8 100644 --- a/hooks/protect-claude-config_test.bats +++ b/hooks/protect-claude-config_test.bats @@ -41,3 +41,60 @@ _run_protect() { [ "$status" -eq 2 ] [[ "$output" =~ "Error" || "$output" =~ "error" || "$output" =~ "JSON" ]] } + +# .git/ blocking — closes the docker-mode RW bind-mount escape vector where +# Edit/Write tool calls plant hooks or git config overrides that execute on +# the host. Mirrors the bash-side coverage in bash-guardrails.sh. + +@test "protect-claude-config blocks writes to /workspace/.git/config" { + _run_protect '{"tool_input":{"file_path":"/workspace/.git/config"}}' + + [ "$status" -eq 2 ] + [[ "$output" =~ "Blocked" ]] + [[ "$output" =~ ".git" ]] +} + +@test "protect-claude-config blocks writes to /workspace/.git/hooks/post-commit" { + _run_protect '{"tool_input":{"file_path":"/workspace/.git/hooks/post-commit"}}' + + [ "$status" -eq 2 ] + [[ "$output" =~ "Blocked" ]] +} + +@test "protect-claude-config blocks writes to /Users/x/.git/info/attributes" { + _run_protect '{"tool_input":{"file_path":"/Users/x/.git/info/attributes"}}' + + [ "$status" -eq 2 ] + [[ "$output" =~ "Blocked" ]] +} + +@test "protect-claude-config blocks writes to /workspace/.git/worktrees/foo/HEAD" { + _run_protect '{"tool_input":{"file_path":"/workspace/.git/worktrees/foo/HEAD"}}' + + [ "$status" -eq 2 ] + [[ "$output" =~ "Blocked" ]] +} + +@test "protect-claude-config allows writes to /workspace/.gitignore" { + _run_protect '{"tool_input":{"file_path":"/workspace/.gitignore"}}' + + [ "$status" -eq 0 ] +} + +@test "protect-claude-config allows writes to /workspace/.github/workflows/ci.yml" { + _run_protect '{"tool_input":{"file_path":"/workspace/.github/workflows/ci.yml"}}' + + [ "$status" -eq 0 ] +} + +@test "protect-claude-config allows writes to differently named .git-hooks-config dir" { + _run_protect '{"tool_input":{"file_path":"/workspace/some/.git-hooks-config/x"}}' + + [ "$status" -eq 0 ] +} + +@test "protect-claude-config allows writes to a path with 'git' as a path segment" { + _run_protect '{"tool_input":{"file_path":"/workspace/file/with/git/in/path/file.txt"}}' + + [ "$status" -eq 0 ] +} diff --git a/lib/worktree/args.zsh b/lib/worktree/args.zsh index 0c4b5c4..56930b9 100644 --- a/lib/worktree/args.zsh +++ b/lib/worktree/args.zsh @@ -3,6 +3,12 @@ # (run, rm); the rest of the worktree subcommands take no args. Mode dispatch # (--list / --rm / --rebuild-image) is gone — those are subcommands now and # the dispatcher in lib/worktree/dispatcher.zsh routes them directly. +# +# Validation helpers (`_ckipper_worktree_validate_branch_name`, +# `_ckipper_worktree_validate_project_name`) defend against attacker-controlled +# `$worktree` / `$project` strings being parsed as git/option flags or +# traversing the projects directory. Callers MUST invoke these before passing +# the values into git commands or `$CKIPPER_PROJECTS_DIR/$project` paths. # Reset per-call CKIPPER_WT_* arg globals (flags + positionals) to defaults. # Config globals (CKIPPER_PROJECTS_DIR, CKIPPER_WORKTREES_DIR, CKIPPER_PORTS, @@ -63,3 +69,54 @@ _ckipper_worktree_parse_rm_args() { CKIPPER_WT_PROJECT="$1" CKIPPER_WT_BRANCH="$2" } + +# Validate a branch name before passing it to any git command. +# +# Rejects names that begin with `-` (would parse as a git flag) and names that +# fail `git check-ref-format --branch` (rejects `..`, control chars, trailing +# `.lock`, and other ref-format violations). Always pair this with `--` in the +# eventual git invocation as a defense-in-depth measure. +# +# Args: $1 — proposed branch name. +# Returns: 0 if the name is safe; 1 otherwise. +# Errors (stderr): "Invalid branch name: " — when validation fails. +_ckipper_worktree_validate_branch_name() { + local branch="$1" + if [[ -z "$branch" || "$branch" == -* ]]; then + echo "Invalid branch name: '$branch' (must not be empty or start with '-')" >&2 + return 1 + fi + if ! git check-ref-format --branch "$branch" >/dev/null 2>&1; then + echo "Invalid branch name: '$branch' (rejected by git check-ref-format)" >&2 + return 1 + fi +} + +# Validate a project path before using it under $CKIPPER_PROJECTS_DIR. +# +# Rejects empty values, leading `-`, characters outside `[A-Za-z0-9._/-]`, and +# any `..` path component. Uses a case-glob component check rather than a +# substring search so legitimate names like `my..app` are not rejected for +# containing `..` purely as text. The character whitelist makes shell-meta +# injection impossible even if a caller forgets to quote. +# +# Args: $1 — proposed project path (e.g. `myorg/app`). +# Returns: 0 if the path is safe; 1 otherwise. +# Errors (stderr): "Invalid project path: " — when validation fails. +_ckipper_worktree_validate_project_name() { + local project="$1" + if [[ -z "$project" || "$project" == -* ]]; then + echo "Invalid project path: '$project' (must not be empty or start with '-')" >&2 + return 1 + fi + if [[ ! "$project" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "Invalid project path: '$project' (allowed: letters, digits, '.', '_', '-', '/')" >&2 + return 1 + fi + case "/$project/" in + */../*) + echo "Invalid project path: '$project' ('..' path component not allowed)" >&2 + return 1 + ;; + esac +} diff --git a/lib/worktree/args_test.bats b/lib/worktree/args_test.bats index 581060f..6e2efd1 100644 --- a/lib/worktree/args_test.bats +++ b/lib/worktree/args_test.bats @@ -101,3 +101,107 @@ _parse_and_print() { [ "$status" -eq 0 ] [ "$output" = "myapp" ] } + +# ── _ckipper_worktree_validate_branch_name ─────────────────────────── + +# Helper: source args.zsh and call $validator with $candidate as its sole +# argument (passed via the env so leading dashes and special chars don't get +# mangled by the outer shell quoting). Captures combined stdout/stderr. +_run_validator() { + local validator="$1" candidate="$2" + run env HOME="$TMP_HOME" PATH="$PATH" CKIPPER_TEST_CANDIDATE="$candidate" \ + zsh -c "source \"$REPO_ROOT/lib/worktree/args.zsh\"; ${validator} \"\$CKIPPER_TEST_CANDIDATE\" 2>&1" +} + +@test "validate_branch_name accepts a normal feature branch" { + _run_validator _ckipper_worktree_validate_branch_name "feature/foo-bar" + + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "validate_branch_name rejects a name that starts with a dash" { + _run_validator _ckipper_worktree_validate_branch_name "--upload-pack=/tmp/x" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid branch name" ]] +} + +@test "validate_branch_name rejects a leading-dash short flag (-h)" { + _run_validator _ckipper_worktree_validate_branch_name "-h" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid branch name" ]] +} + +@test "validate_branch_name rejects a name with .. (path traversal)" { + _run_validator _ckipper_worktree_validate_branch_name "feature/..foo" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid branch name" ]] +} + +@test "validate_branch_name rejects an empty name" { + _run_validator _ckipper_worktree_validate_branch_name "" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid branch name" ]] +} + +# ── _ckipper_worktree_validate_project_name ────────────────────────── + +@test "validate_project_name accepts a normal namespaced project" { + _run_validator _ckipper_worktree_validate_project_name "mswdev/ckipper" + + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "validate_project_name accepts a single-segment project" { + _run_validator _ckipper_worktree_validate_project_name "myapp" + + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "validate_project_name rejects a path with a .. component" { + _run_validator _ckipper_worktree_validate_project_name "../../etc" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid project path" ]] +} + +@test "validate_project_name rejects a path with .. inside a segment chain" { + _run_validator _ckipper_worktree_validate_project_name "myorg/../etc" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid project path" ]] +} + +@test "validate_project_name allows .. as a substring inside a segment" { + _run_validator _ckipper_worktree_validate_project_name "myorg/my..app" + + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "validate_project_name rejects a name with shell metacharacters" { + _run_validator _ckipper_worktree_validate_project_name "myorg/app;rm" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid project path" ]] +} + +@test "validate_project_name rejects a name that starts with a dash" { + _run_validator _ckipper_worktree_validate_project_name "-rf" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid project path" ]] +} + +@test "validate_project_name rejects an empty name" { + _run_validator _ckipper_worktree_validate_project_name "" + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid project path" ]] +} diff --git a/lib/worktree/docker-mode.zsh b/lib/worktree/docker-mode.zsh index f222257..3c08d42 100644 --- a/lib/worktree/docker-mode.zsh +++ b/lib/worktree/docker-mode.zsh @@ -37,6 +37,15 @@ _ckipper_worktree_run_docker_mode() { claude_creds=$(_ckipper_worktree_docker_extract_credentials) || return 1 gh_token=$(_ckipper_worktree_docker_extract_gh_token) + # Export credentials in this shell scope so `docker run -e VAR` (without =value) + # inherits them from the parent process env. This keeps the values OUT of argv + # (and therefore out of `ps`/`/proc//cmdline` and `docker inspect`). + # We always unset before returning — see the trap below. + export CLAUDE_CREDENTIALS="$claude_creds" + export GH_TOKEN="$gh_token" + # shellcheck disable=SC2064 # intentional immediate expansion: unset by exact name. + trap "unset CLAUDE_CREDENTIALS GH_TOKEN; trap - EXIT INT TERM" EXIT INT TERM + typeset -ga CKIPPER_WT_DOCKER_ARGS=() _ckipper_worktree_docker_build_base_args _ckipper_worktree_docker_add_optional_args "$claude_creds" "$gh_token" @@ -134,9 +143,23 @@ _ckipper_worktree_docker_extract_gh_token() { # CKIPPER_EXTRA_VOLUMES globals. # Sets: CKIPPER_WT_DOCKER_ARGS (initialised from scratch). # Returns: 0 always. +# +# Hardening notes: +# - --cap-drop=ALL drops the default Linux caps (NET_RAW, FOWNER, SETUID, etc.). +# The conditional --cap-add=NET_ADMIN appended later when --firewall is set +# re-grants what init-firewall.sh needs (iptables-legacy). Side effect: the +# `chown` in fix-volume-perms.sh becomes a silent no-op (loses CAP_CHOWN); the +# script already wraps it in `|| true`, and new installs get the right UID +# from initial volume creation, so this only affects upgrade paths with stale +# named-volume UIDs — accepted trade-off. +# - We deliberately do NOT add --security-opt=no-new-privileges. It would block +# sudo's setuid bit at exec time, breaking `sudo init-firewall.sh` and the +# unconditional `sudo fix-volume-perms.sh` in entrypoint.sh. Refactoring the +# sudo path is out of scope for this script. _ckipper_worktree_docker_build_base_args() { CKIPPER_WT_DOCKER_ARGS=( docker run --rm -it + --cap-drop=ALL -e TERM="${TERM:-xterm-256color}" -v "$CKIPPER_WT_PATH:/workspace:rw" -v "$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git:$CKIPPER_PROJECTS_DIR/$CKIPPER_WT_PROJECT/.git:rw" @@ -161,8 +184,13 @@ _ckipper_worktree_docker_build_base_args() { # Add credentials, gh token, and extra env vars to CKIPPER_WT_DOCKER_ARGS. # # Args: -# $1 — claude_creds: Claude credentials string (may be empty) -# $2 — gh_token: GitHub personal access token (may be empty) +# $1 — claude_creds: Claude credentials string (may be empty). Used only for +# the empty/non-empty branch decision; the actual value is passed to the +# container via the parent shell's exported CLAUDE_CREDENTIALS env var +# (set by the caller) — `docker run -e VAR` (no `=value`) inherits it. +# This keeps the secret out of argv (`ps`, `/proc/*/cmdline`, `docker inspect`). +# $2 — gh_token: GitHub personal access token (may be empty). Same handling +# as $1: passed via inherited GH_TOKEN env var, not via argv. # # Reads: CKIPPER_EXTRA_ENV global. Appends to CKIPPER_WT_DOCKER_ARGS. # Returns: 0 always. @@ -171,13 +199,13 @@ _ckipper_worktree_docker_add_optional_args() { local gh_token="$2" if [[ -n "$claude_creds" ]]; then - CKIPPER_WT_DOCKER_ARGS+=( -e "CLAUDE_CREDENTIALS=$claude_creds" ) + CKIPPER_WT_DOCKER_ARGS+=( -e CLAUDE_CREDENTIALS ) else echo " Warning: Could not extract Claude credentials from Keychain" fi if [[ -n "$gh_token" ]]; then - CKIPPER_WT_DOCKER_ARGS+=( -e "GH_TOKEN=$gh_token" ) + CKIPPER_WT_DOCKER_ARGS+=( -e GH_TOKEN ) else echo " Warning: No GitHub token found (gh commands won't work in container)" fi diff --git a/lib/worktree/docker-mode_test.bats b/lib/worktree/docker-mode_test.bats index 3720a93..6eb6d3d 100644 --- a/lib/worktree/docker-mode_test.bats +++ b/lib/worktree/docker-mode_test.bats @@ -86,3 +86,73 @@ _run_docker_mode() { [ "$status" -eq 0 ] [ "$output" = "result=" ] } + +@test "_ckipper_worktree_docker_build_base_args includes --cap-drop=ALL hardening flag" { + _run_docker_mode "_ckipper_worktree_docker_build_base_args; print -r -- \"\${CKIPPER_WT_DOCKER_ARGS[*]}\"" + + [ "$status" -eq 0 ] + [[ "$output" =~ "--cap-drop=ALL" ]] +} + +@test "_ckipper_worktree_docker_add_optional_args passes credentials via -e VAR (no value in argv)" { + # Secret value: must NOT appear anywhere in CKIPPER_WT_DOCKER_ARGS. + local sentinel='SENTINEL_CREDS_VALUE_NOT_IN_ARGV_xyz123' + + _run_docker_mode " + _ckipper_worktree_docker_build_base_args + _ckipper_worktree_docker_add_optional_args '$sentinel' '' + print -r -- \"ARGS=\${CKIPPER_WT_DOCKER_ARGS[*]}\" + " + + [ "$status" -eq 0 ] + # `-e CLAUDE_CREDENTIALS` (bare) appears as two adjacent argv tokens. + [[ "$output" =~ "-e CLAUDE_CREDENTIALS" ]] + # The value MUST NOT appear in argv — that would defeat the leak fix. + if [[ "$output" == *"$sentinel"* ]]; then + echo "FAIL: credential sentinel leaked into argv: $output" >&2 + return 1 + fi + # And critically, the old `CLAUDE_CREDENTIALS=` form must be gone. + if [[ "$output" == *"CLAUDE_CREDENTIALS="* ]]; then + echo "FAIL: -e CLAUDE_CREDENTIALS= form still present: $output" >&2 + return 1 + fi +} + +@test "_ckipper_worktree_docker_add_optional_args passes gh token via -e VAR (no value in argv)" { + local sentinel='SENTINEL_GH_TOKEN_VALUE_NOT_IN_ARGV_abc789' + + _run_docker_mode " + _ckipper_worktree_docker_build_base_args + _ckipper_worktree_docker_add_optional_args '' '$sentinel' + print -r -- \"ARGS=\${CKIPPER_WT_DOCKER_ARGS[*]}\" + " + + [ "$status" -eq 0 ] + [[ "$output" =~ "-e GH_TOKEN" ]] + if [[ "$output" == *"$sentinel"* ]]; then + echo "FAIL: gh token sentinel leaked into argv: $output" >&2 + return 1 + fi + if [[ "$output" == *"GH_TOKEN="* ]]; then + echo "FAIL: -e GH_TOKEN= form still present: $output" >&2 + return 1 + fi +} + +@test "firewall flag still adds --cap-add=NET_ADMIN after --cap-drop=ALL" { + # Re-grant of NET_ADMIN must apply on top of cap-drop=ALL so init-firewall.sh + # can run iptables-legacy. cap-add applies after cap-drop in Docker. + export CKIPPER_WT_FLAG_FIREWALL=true + + _run_docker_mode " + CKIPPER_WT_FLAG_FIREWALL=true + _ckipper_worktree_docker_build_base_args + [[ \"\$CKIPPER_WT_FLAG_FIREWALL\" = true ]] && CKIPPER_WT_DOCKER_ARGS+=( --cap-add=NET_ADMIN -e ENABLE_FIREWALL=1 ) + print -r -- \"\${CKIPPER_WT_DOCKER_ARGS[*]}\" + " + + [ "$status" -eq 0 ] + [[ "$output" =~ "--cap-drop=ALL" ]] + [[ "$output" =~ "--cap-add=NET_ADMIN" ]] +} diff --git a/lib/worktree/worktree.zsh b/lib/worktree/worktree.zsh index 7b3b95e..63528dd 100644 --- a/lib/worktree/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -66,12 +66,16 @@ _ckipper_worktree_get_project_and_branch() { # Reads CKIPPER_WT_FLAG_FORCE, CKIPPER_WORKTREES_DIR, CKIPPER_PROJECTS_DIR globals. # Returns: 0 on success; 1 on validation failure or git error. # Errors (stderr): +# "Invalid project path: ..." / "Invalid branch name: ..." — when args fail validation # "Worktree not found: " — when the worktree directory does not exist # "Failed to remove worktree. Use --force if it has uncommitted changes." — on git error _ckipper_worktree_remove_worktree() { local project="$1" local worktree="$2" + _ckipper_worktree_validate_project_name "$project" || return 1 + _ckipper_worktree_validate_branch_name "$worktree" || return 1 + local wt_path="$CKIPPER_WORKTREES_DIR/$project/$worktree" if [[ ! -d "$wt_path" ]]; then echo "Worktree not found: $wt_path" >&2 @@ -81,7 +85,7 @@ _ckipper_worktree_remove_worktree() { local force_flag="" [[ "$CKIPPER_WT_FLAG_FORCE" = true ]] && force_flag="--force" - (cd "$CKIPPER_PROJECTS_DIR/$project" && git worktree remove $force_flag "$wt_path" && git branch -D "$worktree" 2>/dev/null) || { + (cd "$CKIPPER_PROJECTS_DIR/$project" && git worktree remove $force_flag -- "$wt_path" && git branch -D -- "$worktree" 2>/dev/null) || { echo "Failed to remove worktree. Use --force if it has uncommitted changes." >&2 return 1 } @@ -114,12 +118,16 @@ _ckipper_worktree_cleanup_project_registry() { # Reads CKIPPER_PROJECTS_DIR, CKIPPER_WORKTREES_DIR, CKIPPER_WT_ACTIVE_ACCOUNT, CKIPPER_WT_ACTIVE_CONFIG_DIR globals. # Returns: 0 on success; 1 on validation failure or git error. # Errors (stderr): +# "Invalid project path: ..." / "Invalid branch name: ..." — when args fail validation # "Project not found: " — when the project directory does not exist # "Error: exists but is not a valid worktree." — when dir exists but lacks .git file _ckipper_worktree_create_worktree() { local project="$1" local worktree="$2" + _ckipper_worktree_validate_project_name "$project" || return 1 + _ckipper_worktree_validate_branch_name "$worktree" || return 1 + if [[ ! -d "$CKIPPER_PROJECTS_DIR/$project" ]]; then echo "Project not found: $CKIPPER_PROJECTS_DIR/$project" >&2 return 1 @@ -192,11 +200,11 @@ _ckipper_worktree_fetch_origin() { local project="$1" local worktree="$2" - (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin develop) || { + (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin -- develop) || { echo "Failed to fetch from origin. Check your network connection and that 'develop' exists on the remote." >&2 return 1 } - (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin "$worktree" 2>/dev/null) || true + (cd "$CKIPPER_PROJECTS_DIR/$project" && git fetch origin -- "$worktree" 2>/dev/null) || true } # Add the git worktree, choosing local, remote, or new branch as appropriate. @@ -216,13 +224,13 @@ _ckipper_worktree_add_worktree() { (cd "$CKIPPER_PROJECTS_DIR/$project" && \ if git show-ref --verify --quiet "refs/heads/$worktree"; then echo "Using existing local branch: $worktree" - git worktree add "$CKIPPER_WT_PATH" "$worktree" + git worktree add "$CKIPPER_WT_PATH" -- "$worktree" elif git show-ref --verify --quiet "refs/remotes/origin/$worktree"; then echo "Tracking remote branch: origin/$worktree" - git worktree add "$CKIPPER_WT_PATH" -b "$worktree" "origin/$worktree" + git worktree add "$CKIPPER_WT_PATH" -b "$worktree" -- "origin/$worktree" else echo "Creating new branch from origin/develop" - git worktree add "$CKIPPER_WT_PATH" -b "$worktree" origin/develop + git worktree add "$CKIPPER_WT_PATH" -b "$worktree" -- origin/develop fi ) || _ckipper_worktree_handle_worktree_add_failure "$project" "$worktree" } diff --git a/lib/worktree/worktree_test.bats b/lib/worktree/worktree_test.bats index 14e9c05..c292b06 100644 --- a/lib/worktree/worktree_test.bats +++ b/lib/worktree/worktree_test.bats @@ -15,7 +15,9 @@ teardown() { teardown_isolated_env } -# Helper: source worktree.zsh (and its utils dep) then run zsh_cmd. +# Helper: source worktree.zsh (with its utils, registry, and args.zsh deps — +# args.zsh provides the validate_*_name helpers worktree.zsh now calls) then +# run zsh_cmd. _run_worktree() { local zsh_cmd="$1" run env HOME="$TMP_HOME" \ @@ -30,6 +32,7 @@ _run_worktree() { zsh -c " source \"$REPO_ROOT/lib/core/utils.zsh\" source \"$REPO_ROOT/lib/core/registry.zsh\" + source \"$REPO_ROOT/lib/worktree/args.zsh\" source \"$REPO_ROOT/lib/worktree/worktree.zsh\" $zsh_cmd " @@ -55,3 +58,31 @@ _run_worktree() { [ "$status" -ne 0 ] [[ "$output" =~ "not found" || "$output" =~ "Project" ]] } + +@test "_ckipper_worktree_create_worktree rejects a branch name starting with --" { + _run_worktree '_ckipper_worktree_create_worktree myapp "--upload-pack=/tmp/x"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid branch name" ]] +} + +@test "_ckipper_worktree_create_worktree rejects a project path with a .. component" { + _run_worktree '_ckipper_worktree_create_worktree "../../etc" feature-x' + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid project path" ]] +} + +@test "_ckipper_worktree_remove_worktree rejects a branch name starting with -" { + _run_worktree '_ckipper_worktree_remove_worktree myapp "-h"' + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid branch name" ]] +} + +@test "_ckipper_worktree_remove_worktree rejects a project path with shell metacharacters" { + _run_worktree '_ckipper_worktree_remove_worktree "myorg/app;rm" feature-x' + + [ "$status" -ne 0 ] + [[ "$output" =~ "Invalid project path" ]] +} From 977c30c583455fbfd31d45b8de1435c11a2d8532 Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 23:40:26 -0600 Subject: [PATCH 092/165] chore: add gum to make bootstrap --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6bf2854..50edc33 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ help: @echo "make install - run ./install.sh" bootstrap: - brew install bats-core shellcheck shfmt + brew install bats-core shellcheck shfmt gum pip install ruff pytest test: test-unit From 61ac9679c7b837b1742e4c56d6e8975403b91f7a Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 23:46:07 -0600 Subject: [PATCH 093/165] feat: declare ckipper config schema (lib/config/schema.zsh) --- lib/config/schema.zsh | 67 ++++++++++++++++++++++++++++++++ lib/config/schema_test.bats | 76 +++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 lib/config/schema.zsh create mode 100644 lib/config/schema_test.bats diff --git a/lib/config/schema.zsh b/lib/config/schema.zsh new file mode 100644 index 0000000..58708f5 --- /dev/null +++ b/lib/config/schema.zsh @@ -0,0 +1,67 @@ +#!/usr/bin/env zsh +# Single source of truth for Ckipper's user-configurable settings. +# +# Consumed by: +# - _ckipper_config_* (lib/config/) — user-facing get/set/unset/list +# - _ckipper_setup_* (lib/setup/) — wizard prompts +# - _ckipper_doctor (lib/account/doctor.zsh) — schema verification +# +# To add a new key: append to all four arrays below. The dispatcher and +# wizard pick it up automatically. + +# Type of each key. Currently supported: "string", "bool", "int", "path", "int_array". +typeset -gA _CKIPPER_SCHEMA_TYPE=( + [projects_dir]="path" + [worktrees_dir]="path" + [ports]="int_array" + [default_branch]="string" + [dep_install_cmd]="string" + [notify_bell]="bool" + [aliases_auto_source]="bool" + [always_docker]="bool" + [always_firewall]="bool" + [ssh_forward]="bool" +) + +# Default value (used when key is unset / on schema migration). +typeset -gA _CKIPPER_SCHEMA_DEFAULT=( + [projects_dir]="$HOME/Developer" + [worktrees_dir]="" + [ports]="3000" + [default_branch]="" + [dep_install_cmd]="npm install" + [notify_bell]="true" + [aliases_auto_source]="true" + [always_docker]="false" + [always_firewall]="false" + [ssh_forward]="true" +) + +# Scope: "global" (lives in ~/.ckipper/docker/ckipper-config.zsh) or +# "account" (lives in ~/.ckipper/accounts.json under accounts..preferences). +typeset -gA _CKIPPER_SCHEMA_SCOPE=( + [projects_dir]="global" + [worktrees_dir]="global" + [ports]="global" + [default_branch]="global" + [dep_install_cmd]="global" + [notify_bell]="global" + [aliases_auto_source]="global" + [always_docker]="account" + [always_firewall]="account" + [ssh_forward]="account" +) + +# One-line description shown by `ckipper config list` and the wizard. +typeset -gA _CKIPPER_SCHEMA_DESCRIPTION=( + [projects_dir]="Base directory containing your git projects." + [worktrees_dir]="Where worktrees are created (default: \$projects_dir/.worktrees)." + [ports]="Comma-separated ports to forward from container to host." + [default_branch]="Fallback base branch when origin/HEAD is unset." + [dep_install_cmd]="Command run after worktree creation. Empty = skip." + [notify_bell]="Install notify-bell hook into account dirs." + [aliases_auto_source]="install.sh auto-adds aliases.zsh source line to .zshrc." + [always_docker]="Default --docker on for this account." + [always_firewall]="Default --firewall on for this account." + [ssh_forward]="Forward host ~/.ssh into containers run with this account." +) diff --git a/lib/config/schema_test.bats b/lib/config/schema_test.bats new file mode 100644 index 0000000..b2703b0 --- /dev/null +++ b/lib/config/schema_test.bats @@ -0,0 +1,76 @@ +#!/usr/bin/env bats +# Module-level tests for lib/config/schema.zsh. +# Verifies the four schema arrays (TYPE, DEFAULT, SCOPE, DESCRIPTION) declare +# the expected keys with the expected values. Schema is data-only zsh; bats +# runs in bash, so each assertion spawns a zsh subshell that sources the file +# and prints the value under test. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +# Helper: source schema.zsh in zsh and print the array entry $1[$2]. +_schema_lookup() { + local array_name="$1" key="$2" + run zsh -c "source \"$REPO_ROOT/lib/config/schema.zsh\"; print -- \"\${${array_name}[${key}]}\"" + [ "$status" -eq 0 ] +} + +@test "schema declares known global keys" { + _schema_lookup _CKIPPER_SCHEMA_TYPE default_branch + [ "$output" = "string" ] + + _schema_lookup _CKIPPER_SCHEMA_TYPE dep_install_cmd + [ "$output" = "string" ] + + _schema_lookup _CKIPPER_SCHEMA_TYPE notify_bell + [ "$output" = "bool" ] + + _schema_lookup _CKIPPER_SCHEMA_TYPE aliases_auto_source + [ "$output" = "bool" ] +} + +@test "schema declares known per-account keys" { + _schema_lookup _CKIPPER_SCHEMA_SCOPE always_docker + [ "$output" = "account" ] + + _schema_lookup _CKIPPER_SCHEMA_SCOPE always_firewall + [ "$output" = "account" ] + + _schema_lookup _CKIPPER_SCHEMA_SCOPE ssh_forward + [ "$output" = "account" ] +} + +@test "schema defaults preserve current behavior" { + _schema_lookup _CKIPPER_SCHEMA_DEFAULT notify_bell + [ "$output" = "true" ] + + _schema_lookup _CKIPPER_SCHEMA_DEFAULT aliases_auto_source + [ "$output" = "true" ] + + _schema_lookup _CKIPPER_SCHEMA_DEFAULT dep_install_cmd + [ "$output" = "npm install" ] + + _schema_lookup _CKIPPER_SCHEMA_DEFAULT always_docker + [ "$output" = "false" ] + + _schema_lookup _CKIPPER_SCHEMA_DEFAULT always_firewall + [ "$output" = "false" ] + + _schema_lookup _CKIPPER_SCHEMA_DEFAULT ssh_forward + [ "$output" = "true" ] +} + +@test "schema description present for every key" { + # Walk every key in _CKIPPER_SCHEMA_TYPE and require a non-empty + # _CKIPPER_SCHEMA_DESCRIPTION entry. Any missing key is printed by name. + run zsh -c " + source \"$REPO_ROOT/lib/config/schema.zsh\" + for key in \"\${(@k)_CKIPPER_SCHEMA_TYPE}\"; do + if [[ -z \"\${_CKIPPER_SCHEMA_DESCRIPTION[\$key]}\" ]]; then + print -- \"missing description for \$key\" + exit 1 + fi + done + exit 0 + " + [ "$status" -eq 0 ] +} From 26eb16a0d8e324684e5428459a7c4a6084029513 Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 23:54:46 -0600 Subject: [PATCH 094/165] feat: add core config get/set/unset/validate primitives --- lib/core/config.zsh | 222 ++++++++++++++++++++++++++++++++++++++ lib/core/config_test.bats | 91 ++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 lib/core/config.zsh create mode 100644 lib/core/config_test.bats diff --git a/lib/core/config.zsh b/lib/core/config.zsh new file mode 100644 index 0000000..f3c9364 --- /dev/null +++ b/lib/core/config.zsh @@ -0,0 +1,222 @@ +#!/usr/bin/env zsh +# Pure config get/set/unset/validate primitives. Operates on: +# - global file: $CKIPPER_DIR/docker/ckipper-config.zsh (zsh assignments) +# - per-account: $CKIPPER_REGISTRY (.accounts..preferences.) +# +# Schema source-of-truth: lib/config/schema.zsh — must be sourced before this. +# Functions here resolve the schema arrays at call time, never source-time. + +readonly _CKIPPER_GLOBAL_PREFIX="CKIPPER_" + +# Translate a schema key to the global file's variable name. +# +# Args: $1 — schema key (e.g. "notify_bell") +# Returns: 0; prints "CKIPPER_NOTIFY_BELL". +_core_config_global_var() { + local key="$1" + echo "${_CKIPPER_GLOBAL_PREFIX}${(U)key}" +} + +# Path to the global config file. +# +# Returns: 0; prints absolute path under $CKIPPER_DIR/docker/. +_core_config_global_file() { + echo "${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper-config.zsh" +} + +# Read a global value from the config file without sourcing it. +# +# Args: $1 — schema key +# Returns: 0; prints the assigned value (quotes stripped) or empty string if unset. +_core_config_read_global() { + local key="$1" + local var + var=$(_core_config_global_var "$key") + local file + file=$(_core_config_global_file) + [[ -f "$file" ]] || { + echo "" + return 0 + } + awk -v v="$var" -F= '$1 == v { sub(/^[^=]+=/, ""); gsub(/^"|"$/, ""); print; exit }' "$file" +} + +# Read an account preference from the registry. +# +# Args: $1 — key, $2 — account name +# Returns: 0; prints the stored value or empty string if unset. +_core_config_read_account() { + local key="$1" account="$2" + [[ -f "$CKIPPER_REGISTRY" ]] || { + echo "" + return 0 + } + jq -r --arg n "$account" --arg k "$key" '.accounts[$n].preferences[$k] // ""' "$CKIPPER_REGISTRY" +} + +# Resolve effective value: account override → global → schema default. +# +# Args: $1 — key, $2 — (optional) account name +# Returns: 0; prints the resolved value (may be empty if default is empty). +_core_config_get() { + local key="$1" account="${2:-}" + local val="" + if [[ -n "$account" && "${_CKIPPER_SCHEMA_SCOPE[$key]}" == "account" ]]; then + val=$(_core_config_read_account "$key" "$account") + [[ -n "$val" ]] && { + echo "$val" + return 0 + } + fi + val=$(_core_config_read_global "$key") + [[ -n "$val" ]] && { + echo "$val" + return 0 + } + echo "${_CKIPPER_SCHEMA_DEFAULT[$key]}" +} + +# Validate a value against the schema type for a key. +# +# Args: $1 — key, $2 — value +# Returns: 0 if valid; 1 on unknown key or type mismatch. +# Errors (stderr): +# "Unknown config key: ''" — when key not in schema. +# "Invalid value for '': '' (expected )" — on type mismatch. +_core_config_validate() { + local key="$1" value="$2" + local type="${_CKIPPER_SCHEMA_TYPE[$key]:-}" + if [[ -z "$type" ]]; then + echo "Unknown config key: '$key'" >&2 + return 1 + fi + case "$type" in + bool) + [[ "$value" == "true" || "$value" == "false" ]] && return 0 + ;; + int) + [[ "$value" =~ ^[0-9]+$ ]] && return 0 + ;; + int_array) + [[ "$value" =~ ^[0-9]+(,[0-9]+)*$ ]] && return 0 + ;; + string | path) + return 0 + ;; + esac + echo "Invalid value for '$key': '$value' (expected $type)" >&2 + return 1 +} + +# Write a global key into the config file, replacing any existing assignment. +# Idempotent: existing CKIPPER_= line is rewritten in place; absent keys +# are appended. +# +# Args: $1 — key, $2 — value +# Returns: 0 on success; 1 on validation failure. +_core_config_write_global() { + local key="$1" value="$2" + _core_config_validate "$key" "$value" || return 1 + local var + var=$(_core_config_global_var "$key") + local file + file=$(_core_config_global_file) + mkdir -p "${file:h}" + [[ -f "$file" ]] || : >"$file" + local tmp + tmp=$(mktemp "${file}.XXXXXX") + awk -v v="$var" -v val="$value" -F= ' + $1 == v { print v "=\"" val "\""; found=1; next } + { print } + END { if (!found) print v "=\"" val "\"" } + ' "$file" >"$tmp" && mv "$tmp" "$file" +} + +# Write an account preference into the registry. Coerces "true"/"false"/numeric +# strings to native JSON types so consumers don't see stringified bools. +# +# Args: $1 — key, $2 — value, $3 — account name +# Returns: 0 on success; 1 on validation or jq/write failure. +_core_config_write_account() { + local key="$1" value="$2" account="$3" + _core_config_validate "$key" "$value" || return 1 + local tmp + tmp=$(mktemp "${CKIPPER_REGISTRY}.XXXXXX") + jq --arg n "$account" --arg k "$key" --arg v "$value" ' + .accounts[$n].preferences[$k] = ( + if $v == "true" then true + elif $v == "false" then false + elif ($v | test("^[0-9]+$")) then ($v | tonumber) + else $v end + ) + ' "$CKIPPER_REGISTRY" >"$tmp" && mv "$tmp" "$CKIPPER_REGISTRY" +} + +# Public set — routes to global or account-scoped write per the schema. +# +# Args: $1 — key, $2 — value, $3 — (optional) account name +# Returns: 0 on success; 1 on validation/write failure or missing account +# for an account-scoped key. +# Errors (stderr): "Key '' requires --account." — when scope=account +# but no account name was supplied. +_core_config_set() { + local key="$1" value="$2" account="${3:-}" + local scope="${_CKIPPER_SCHEMA_SCOPE[$key]:-}" + if [[ "$scope" == "account" ]]; then + [[ -z "$account" ]] && { + echo "Key '$key' requires --account." >&2 + return 1 + } + _core_config_write_account "$key" "$value" "$account" + else + _core_config_write_global "$key" "$value" + fi +} + +# Remove a global override, reverting future reads to the schema default. +# +# Args: $1 — key +# Returns: 0 always (no-op when file is absent or line is missing). +_core_config_unset_global() { + local key="$1" + local var + var=$(_core_config_global_var "$key") + local file + file=$(_core_config_global_file) + [[ -f "$file" ]] || return 0 + local tmp + tmp=$(mktemp "${file}.XXXXXX") + awk -v v="$var" -F= '$1 != v' "$file" >"$tmp" && mv "$tmp" "$file" +} + +# Remove an account preference override. +# +# Args: $1 — key, $2 — account name +# Returns: 0 always (no-op when registry absent). +_core_config_unset_account() { + local key="$1" account="$2" + [[ -f "$CKIPPER_REGISTRY" ]] || return 0 + local tmp + tmp=$(mktemp "${CKIPPER_REGISTRY}.XXXXXX") + jq --arg n "$account" --arg k "$key" 'del(.accounts[$n].preferences[$k])' "$CKIPPER_REGISTRY" >"$tmp" \ + && mv "$tmp" "$CKIPPER_REGISTRY" +} + +# Public unset — routes to global or account-scoped removal per the schema. +# +# Args: $1 — key, $2 — (optional) account name +# Returns: 0 on success; 1 if scope=account but no account name supplied. +# Errors (stderr): "Key '' requires --account." — see above. +_core_config_unset() { + local key="$1" account="${2:-}" + local scope="${_CKIPPER_SCHEMA_SCOPE[$key]:-global}" + if [[ "$scope" == "account" ]]; then + [[ -z "$account" ]] && { + echo "Key '$key' requires --account." >&2 + return 1 + } + _core_config_unset_account "$key" "$account" + else + _core_config_unset_global "$key" + fi +} diff --git a/lib/core/config_test.bats b/lib/core/config_test.bats new file mode 100644 index 0000000..fc4eef4 --- /dev/null +++ b/lib/core/config_test.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats +# Module-level tests for lib/core/config.zsh. +# Verifies _core_config_get/set/unset/validate primitives against the schema +# from lib/config/schema.zsh. config.zsh is zsh-only, so each assertion spawns +# a zsh subshell that sources schema then config and runs the function under +# test (matching the pattern in registry_test.bats and schema_test.bats). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + mkdir -p "$CKIPPER_DIR/docker" + : >"$CKIPPER_DIR/docker/ckipper-config.zsh" + # v2 accounts.json fixture with one `work` account having empty preferences. + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"work","accounts":{"work":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}}} +JSON +} + +teardown() { + teardown_isolated_env +} + +# Helper: source schema.zsh + config.zsh in zsh and run zsh_cmd. +_run_config() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/config/schema.zsh\"; source \"$REPO_ROOT/lib/core/config.zsh\"; $zsh_cmd" +} + +@test "_core_config_get returns schema default when key is unset" { + _run_config "_core_config_get notify_bell" + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "_core_config_get returns global value when set" { + echo 'CKIPPER_NOTIFY_BELL="false"' >"$CKIPPER_DIR/docker/ckipper-config.zsh" + + _run_config "_core_config_get notify_bell" + + [ "$status" -eq 0 ] + [ "$output" = "false" ] +} + +@test "_core_config_get returns account override when set" { + _run_config "_core_config_set always_docker true work && _core_config_get always_docker work" + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "_core_config_set writes global key idempotently" { + _run_config "_core_config_set notify_bell false && _core_config_set notify_bell false" + + [ "$status" -eq 0 ] + local count + count=$(grep -c '^CKIPPER_NOTIFY_BELL=' "$CKIPPER_DIR/docker/ckipper-config.zsh") + [ "$count" = "1" ] +} + +@test "_core_config_validate accepts valid bool" { + _run_config "_core_config_validate notify_bell true" + [ "$status" -eq 0 ] + + _run_config "_core_config_validate notify_bell false" + [ "$status" -eq 0 ] +} + +@test "_core_config_validate rejects invalid bool" { + _run_config "_core_config_validate notify_bell yes" + + [ "$status" -ne 0 ] +} + +@test "_core_config_validate rejects unknown key" { + _run_config "_core_config_validate not_a_real_key true" + + [ "$status" -ne 0 ] +} + +@test "_core_config_unset removes the override and returns default" { + _run_config "_core_config_set notify_bell false && _core_config_unset notify_bell && _core_config_get notify_bell" + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} From c9b9a70c097a89a6ad03aea76dbdbff09706504d Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 30 Apr 2026 23:58:13 -0600 Subject: [PATCH 095/165] fix: read_account distinguishes false from missing in jq lookup --- lib/core/config.zsh | 7 ++++++- lib/core/config_test.bats | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/core/config.zsh b/lib/core/config.zsh index f3c9364..6092313 100644 --- a/lib/core/config.zsh +++ b/lib/core/config.zsh @@ -51,7 +51,12 @@ _core_config_read_account() { echo "" return 0 } - jq -r --arg n "$account" --arg k "$key" '.accounts[$n].preferences[$k] // ""' "$CKIPPER_REGISTRY" + jq -r --arg n "$account" --arg k "$key" ' + if (.accounts[$n].preferences | has($k)) + then .accounts[$n].preferences[$k] | tostring + else "" + end + ' "$CKIPPER_REGISTRY" } # Resolve effective value: account override → global → schema default. diff --git a/lib/core/config_test.bats b/lib/core/config_test.bats index fc4eef4..afc9f04 100644 --- a/lib/core/config_test.bats +++ b/lib/core/config_test.bats @@ -89,3 +89,10 @@ _run_config() { [ "$status" -eq 0 ] [ "$output" = "true" ] } + +@test "_core_config_get returns false when set false on per-account key with default true" { + _run_config "_core_config_set ssh_forward false work && _core_config_get ssh_forward work" + + [ "$status" -eq 0 ] + [ "$output" = "false" ] +} From 34dab9a53eee690e09daf8624b0cc3762d3f9317 Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 00:03:18 -0600 Subject: [PATCH 096/165] test: cover int_array/string/path validate, set-no-account, account unset --- lib/core/config_test.bats | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/lib/core/config_test.bats b/lib/core/config_test.bats index afc9f04..a9cb190 100644 --- a/lib/core/config_test.bats +++ b/lib/core/config_test.bats @@ -96,3 +96,48 @@ _run_config() { [ "$status" -eq 0 ] [ "$output" = "false" ] } + +@test "_core_config_validate accepts integer-array values like \"3000\" and \"3000,3030,6006\"" { + _run_config "_core_config_validate ports 3000" + [ "$status" -eq 0 ] + + _run_config "_core_config_validate ports 3000,3030,6006" + [ "$status" -eq 0 ] +} + +@test "_core_config_validate rejects malformed int_array" { + _run_config "_core_config_validate ports abc" + [ "$status" -ne 0 ] + + _run_config "_core_config_validate ports 3000,abc" + [ "$status" -ne 0 ] + + _run_config '_core_config_validate ports ""' + [ "$status" -ne 0 ] +} + +@test "_core_config_validate accepts string and path values trivially" { + _run_config '_core_config_validate default_branch "main"' + [ "$status" -eq 0 ] + + _run_config '_core_config_validate default_branch ""' + [ "$status" -eq 0 ] + + _run_config '_core_config_validate projects_dir "/some/path"' + [ "$status" -eq 0 ] +} + +@test "_core_config_set rejects account-scoped key with no account argument" { + _run_config "_core_config_set always_docker true" + + [ "$status" -ne 0 ] + [[ "$output" == *"requires --account"* ]] +} + +@test "_core_config_unset for account scope removes the override and returns default" { + _run_config "_core_config_set ssh_forward false work && _core_config_get ssh_forward work && _core_config_unset ssh_forward work && _core_config_get ssh_forward work" + + [ "$status" -eq 0 ] + [ "${lines[0]}" = "false" ] + [ "${lines[1]}" = "true" ] +} From ccc567ac4fccb76840117fbcddf32d0461ff33f1 Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 00:28:26 -0600 Subject: [PATCH 097/165] feat: add ckipper config namespace (get/set/unset/list/edit) Wire user-facing handlers and a dispatcher on top of the lib/core/config.zsh primitives and lib/config/schema.zsh schema. Each handler validates the key against the schema, parses [--account ], and forwards to _core_config_*. list emits table | json | env (default table); JSON is built via jq so values with quotes/backslashes encode safely. Account-scoped keys appear only when --account is supplied. Output is sorted lexically for deterministic diffs. edit rounds an account's preferences JSON through a tmpfile via jq with slurpfile writeback, validating the result before touching the registry. The global-config path just opens \$EDITOR on the on-disk file. set has a _ckipper_config_set_pick_key helper that wraps a Phase-2 _core_prompt_choose; the prompt-input fallback for missing values is also Phase-2. Tests exercise only the explicit-value paths. --- lib/config/dispatcher.zsh | 64 +++++++++++++++ lib/config/dispatcher_test.bats | 78 ++++++++++++++++++ lib/config/edit.zsh | 110 +++++++++++++++++++++++++ lib/config/get.zsh | 42 ++++++++++ lib/config/list.zsh | 137 ++++++++++++++++++++++++++++++++ lib/config/list_test.bats | 69 ++++++++++++++++ lib/config/set.zsh | 99 +++++++++++++++++++++++ lib/config/unset.zsh | 43 ++++++++++ 8 files changed, 642 insertions(+) create mode 100644 lib/config/dispatcher.zsh create mode 100644 lib/config/dispatcher_test.bats create mode 100644 lib/config/edit.zsh create mode 100644 lib/config/get.zsh create mode 100644 lib/config/list.zsh create mode 100644 lib/config/list_test.bats create mode 100644 lib/config/set.zsh create mode 100644 lib/config/unset.zsh diff --git a/lib/config/dispatcher.zsh b/lib/config/dispatcher.zsh new file mode 100644 index 0000000..6a275d7 --- /dev/null +++ b/lib/config/dispatcher.zsh @@ -0,0 +1,64 @@ +#!/usr/bin/env zsh +# Config-namespace dispatcher and help text. +# +# Routes `ckipper config ` to the matching _ckipper_config_* +# handler, prints overview help, and suggests the closest subcommand on a +# typo via _core_unknown_command (which handles the fuzzy match + help line). + +# Known config subcommands. Used both for routing and for fuzzy-suggest. +_CKIPPER_CONFIG_SUBCOMMANDS=(get set unset list edit help) + +# Dispatch a `config` subcommand. +# +# Args: +# $1 — subcommand name (get, set, unset, list, edit, help, -h, --help, or empty) +# $2..$N — arguments forwarded to the subcommand handler +# +# Returns: handler exit status; 1 on unknown subcommand. +# +# Errors (stderr): +# "Unknown command: ''. Did you mean: ''? ..." (via _core_unknown_command) +_ckipper_config_dispatch() { + local cmd="$1" + shift 2>/dev/null + case "$cmd" in + get) _ckipper_config_get "$@" ;; + set) _ckipper_config_set "$@" ;; + unset) _ckipper_config_unset "$@" ;; + list) _ckipper_config_list "$@" ;; + edit) _ckipper_config_edit "$@" ;; + ""|help|-h|--help) _ckipper_config_help ;; + *) _ckipper_config_unknown "$cmd"; return 1 ;; + esac +} + +# Print the unknown-subcommand line plus a help pointer. Always writes to stderr. +# +# Args: $1 — the unknown subcommand the user typed. +# Returns: 0 always. +_ckipper_config_unknown() { + _core_unknown_command "$1" \ + "Run 'ckipper config help' for available commands." \ + "${_CKIPPER_CONFIG_SUBCOMMANDS[@]}" +} + +# Print the config-namespace usage summary. +# +# Returns: 0 always. +_ckipper_config_help() { + cat <<'EOF' +ckipper config — read and write Ckipper configuration + +Usage: + ckipper config get [--account ] Print the resolved value + ckipper config set [--account ] [value] Set a key (prompts if value omitted) + ckipper config unset [--account ] Remove an override (revert to default) + ckipper config list [--account ] [--format=fmt] List every key (table | json | env) + ckipper config edit [--account ] Open the underlying file in $EDITOR + +Scope: + Global keys live in ~/.ckipper/docker/ckipper-config.zsh. + Account-scoped keys live under accounts..preferences in the registry + and require --account on set/unset. +EOF +} diff --git a/lib/config/dispatcher_test.bats b/lib/config/dispatcher_test.bats new file mode 100644 index 0000000..7ff69ec --- /dev/null +++ b/lib/config/dispatcher_test.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# Module-level tests for lib/config/dispatcher.zsh. +# Verifies routing of `ckipper config ` to the per-subcommand +# handlers, plus the unknown-subcommand path. The dispatcher and handlers are +# zsh-only, so each test spawns a zsh subshell that sources schema.zsh + +# core/config.zsh + core/fuzzy.zsh + every config handler + dispatcher (matching +# the pattern in lib/core/config_test.bats). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + mkdir -p "$CKIPPER_DIR/docker" + : >"$CKIPPER_DIR/docker/ckipper-config.zsh" + # v2 accounts.json fixture with one `work` account having empty preferences. + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"work","accounts":{"work":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}}} +JSON +} + +teardown() { + teardown_isolated_env +} + +# Helper: source schema + every config module in zsh and run zsh_cmd. +_run_config_dispatch() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/config/schema.zsh\" + source \"$REPO_ROOT/lib/core/config.zsh\" + source \"$REPO_ROOT/lib/core/fuzzy.zsh\" + source \"$REPO_ROOT/lib/config/get.zsh\" + source \"$REPO_ROOT/lib/config/set.zsh\" + source \"$REPO_ROOT/lib/config/unset.zsh\" + source \"$REPO_ROOT/lib/config/list.zsh\" + source \"$REPO_ROOT/lib/config/edit.zsh\" + source \"$REPO_ROOT/lib/config/dispatcher.zsh\" + $zsh_cmd + " +} + +@test "dispatcher routes set with explicit value to global key" { + _run_config_dispatch "_ckipper_config_dispatch set notify_bell false && _ckipper_config_dispatch get notify_bell" + + [ "$status" -eq 0 ] + [ "$output" = "false" ] +} + +@test "dispatcher routes set --account to account-scoped key" { + _run_config_dispatch "_ckipper_config_dispatch set --account work always_docker true && _ckipper_config_dispatch get --account work always_docker" + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "dispatcher routes unset and reverts to schema default" { + _run_config_dispatch "_ckipper_config_dispatch set notify_bell false && _ckipper_config_dispatch unset notify_bell && _ckipper_config_dispatch get notify_bell" + + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "dispatcher rejects unknown key on set" { + _run_config_dispatch "_ckipper_config_dispatch set not_a_key value" + + [ "$status" -ne 0 ] +} + +@test "dispatcher unknown subcommand suggests help pointer" { + _run_config_dispatch "_ckipper_config_dispatch nope" + + [ "$status" -ne 0 ] + [[ "$output" =~ "config help" ]] +} diff --git a/lib/config/edit.zsh b/lib/config/edit.zsh new file mode 100644 index 0000000..da1edb1 --- /dev/null +++ b/lib/config/edit.zsh @@ -0,0 +1,110 @@ +#!/usr/bin/env zsh +# `ckipper config edit` handler. Opens the global config file in $EDITOR, or +# round-trips an account's preferences JSON object through a tmpfile when +# called with --account. + +# Open the global config file in the user's preferred editor. No validation — +# the file is sourced lazily by ckipper.zsh on next shell startup. +# +# Returns: editor exit status. +_ckipper_config_edit_global() { + local file + file=$(_core_config_global_file) + mkdir -p "${file:h}" + [[ -f "$file" ]] || : >"$file" + "${EDITOR:-vi}" "$file" +} + +# Write an account's preferences JSON to a fresh tmpfile and return its path +# on stdout. Caller owns deletion. +# +# Args: $1 — account name. +# Returns: 0 on success; 1 on jq failure. +_ckipper_config_edit_dump_prefs() { + local account="$1" + local tmp + tmp=$(mktemp -t "ckipper-config-edit-XXXXXX") || return 1 + if ! jq --arg n "$account" '.accounts[$n].preferences // {}' "$CKIPPER_REGISTRY" >"$tmp"; then + rm -f "$tmp" + return 1 + fi + print -- "$tmp" +} + +# Validate that a tmpfile contains parseable JSON. +# +# Args: $1 — path to candidate JSON file. +# Returns: 0 if parseable; 1 otherwise. Errors go to stderr via jq. +_ckipper_config_edit_validate_json() { + local path="$1" + jq empty "$path" >/dev/null 2>&1 +} + +# Slurp the edited preferences JSON back into the registry under +# accounts..preferences. +# +# Args: $1 — account name, $2 — path to edited JSON file. +# Returns: 0 on success; 1 on jq/write failure. +_ckipper_config_edit_writeback() { + local account="$1" edited="$2" + local out + out=$(mktemp "${CKIPPER_REGISTRY}.XXXXXX") || return 1 + if ! jq --arg n "$account" --slurpfile p "$edited" \ + '.accounts[$n].preferences = $p[0]' "$CKIPPER_REGISTRY" >"$out"; then + rm -f "$out" + return 1 + fi + mv "$out" "$CKIPPER_REGISTRY" +} + +# Open an account's preferences in $EDITOR. Round-trip: dump → edit → validate +# → writeback. Aborts (and leaves the registry untouched) if the edited file +# is not parseable JSON. +# +# Args: $1 — account name. +# Returns: 0 on success; 1 on dump/validate/writeback failure. +# Errors (stderr): "Edited file is not valid JSON; registry not updated." +_ckipper_config_edit_account() { + local account="$1" + local tmp + tmp=$(_ckipper_config_edit_dump_prefs "$account") || return 1 + "${EDITOR:-vi}" "$tmp" + if ! _ckipper_config_edit_validate_json "$tmp"; then + echo "Edited file is not valid JSON; registry not updated." >&2 + rm -f "$tmp" + return 1 + fi + _ckipper_config_edit_writeback "$account" "$tmp" + local rc=$? + rm -f "$tmp" + return $rc +} + +# Public edit entry point. Routes between the global-file editor and the +# account-preferences round-trip per the --account flag. +# +# Args: $1..$N — `[--account ]`. +# +# Returns: editor / handler exit status; 1 on unknown flag. +# Errors (stderr): "Unknown flag: ''" — see Returns. +_ckipper_config_edit() { + local account="" + while (( $# > 0 )); do + case "$1" in + --account) + [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } + account="$2"; shift 2 + ;; + --account=*) account="${1#--account=}"; shift ;; + *) + echo "Unknown flag: '$1'" >&2 + return 1 + ;; + esac + done + if [[ -z "$account" ]]; then + _ckipper_config_edit_global + else + _ckipper_config_edit_account "$account" + fi +} diff --git a/lib/config/get.zsh b/lib/config/get.zsh new file mode 100644 index 0000000..b81943e --- /dev/null +++ b/lib/config/get.zsh @@ -0,0 +1,42 @@ +#!/usr/bin/env zsh +# `ckipper config get` handler. Thin wrapper over _core_config_get that adds +# CLI-level argument parsing and schema-membership validation. + +# Print the resolved value of a configuration key. +# +# Args: +# $1..$N — `[--account ] `. The flag and its value may appear in +# any order before the positional ; only one --account is read. +# +# Returns: 0 on success; 1 on missing key, unknown key, or unknown flag. +# +# Errors (stderr): +# "Usage: ckipper config get [--account ] " — when no key supplied. +# "Unknown config key: ''" — when key is not in the schema. +# "Unknown flag: ''" — when an unrecognized flag is encountered. +_ckipper_config_get() { + local account="" key="" + while (( $# > 0 )); do + case "$1" in + --account) + [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } + account="$2"; shift 2 + ;; + --account=*) account="${1#--account=}"; shift ;; + -*) + echo "Unknown flag: '$1'" >&2 + return 1 + ;; + *) key="$1"; shift ;; + esac + done + if [[ -z "$key" ]]; then + echo "Usage: ckipper config get [--account ] " >&2 + return 1 + fi + if [[ -z "${_CKIPPER_SCHEMA_TYPE[$key]:-}" ]]; then + echo "Unknown config key: '$key'" >&2 + return 1 + fi + _core_config_get "$key" "$account" +} diff --git a/lib/config/list.zsh b/lib/config/list.zsh new file mode 100644 index 0000000..2ad92d7 --- /dev/null +++ b/lib/config/list.zsh @@ -0,0 +1,137 @@ +#!/usr/bin/env zsh +# `ckipper config list` handler. Renders every effective configuration key in +# one of three formats: table (default), json, env. +# +# Account-scope filtering: account-scoped keys (those with SCOPE=="account") +# are emitted only when the caller passes `--account `. Without that +# flag, only global keys appear — printing account-scoped defaults without an +# account would be misleading because their effective value is per-account. +# +# Phase-2 dependency: _core_style_header / _core_style_divider live in +# lib/core/style.zsh (not yet landed). Tests stub them; production callers +# source style.zsh from ckipper.zsh before list.zsh. + +# Module-level argument-parse output. Populated by _ckipper_config_list_parse_args +# and consumed by the format printers. +typeset -gA _CKIPPER_CONFIG_LIST_ARGS + +# Parse `[--account ] [--format=]` into _CKIPPER_CONFIG_LIST_ARGS. +# +# Args: $1..$N — raw CLI arguments forwarded from _ckipper_config_list. +# +# Returns: 0 on success; 1 on unknown flag or unsupported format. +# Errors (stderr): +# "Unknown flag: ''" — when an unrecognized argument is encountered. +# "Unknown format: '' (expected: table, json, env)" +_ckipper_config_list_parse_args() { + _CKIPPER_CONFIG_LIST_ARGS=([account]="" [format]="table") + while (( $# > 0 )); do + case "$1" in + --account) + [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } + _CKIPPER_CONFIG_LIST_ARGS[account]="$2"; shift 2 + ;; + --account=*) _CKIPPER_CONFIG_LIST_ARGS[account]="${1#--account=}"; shift ;; + --format=*) _CKIPPER_CONFIG_LIST_ARGS[format]="${1#--format=}"; shift ;; + --format) + [[ -z "${2:-}" ]] && { echo "Flag --format requires a value." >&2; return 1; } + _CKIPPER_CONFIG_LIST_ARGS[format]="$2"; shift 2 + ;; + *) + echo "Unknown flag: '$1'" >&2 + return 1 + ;; + esac + done + case "${_CKIPPER_CONFIG_LIST_ARGS[format]}" in + table | json | env) return 0 ;; + esac + echo "Unknown format: '${_CKIPPER_CONFIG_LIST_ARGS[format]}' (expected: table, json, env)" >&2 + return 1 +} + +# Decide whether a schema key should appear in the listing for the current +# scope choice. Global keys always appear; account-scoped keys appear only +# when --account was supplied. +# +# Args: $1 — schema key, $2 — account name ("" when --account omitted). +# Returns: 0 if the key should be listed; 1 if it should be skipped. +_ckipper_config_list_should_include() { + local key="$1" account="$2" + local scope="${_CKIPPER_SCHEMA_SCOPE[$key]:-global}" + [[ "$scope" == "global" ]] && return 0 + [[ "$scope" == "account" && -n "$account" ]] && return 0 + return 1 +} + +# Print sorted list of keys this invocation will emit, one per line. +# +# Args: $1 — account name ("" when --account omitted). +# Returns: 0 always. Stdout: newline-separated keys in lexical order. +_ckipper_config_list_keys() { + local account="$1" key + for key in "${(@kon)_CKIPPER_SCHEMA_TYPE}"; do + if _ckipper_config_list_should_include "$key" "$account"; then + print -- "$key" + fi + done +} + +# Render the table format: header, divider, then `=` lines. +# +# Args: $1 — account name ("" when --account omitted). +# Returns: 0 always. +_ckipper_config_list_table() { + local account="$1" key value + _core_style_header "Ckipper config" + _core_style_divider + while IFS= read -r key; do + value=$(_core_config_get "$key" "$account") + print -- "$key=$value" + done < <(_ckipper_config_list_keys "$account") +} + +# Render the JSON format. Builds the object key-by-key with jq so values +# containing quotes, backslashes, or other JSON metacharacters are encoded +# safely. Output is a single JSON object on stdout. +# +# Args: $1 — account name ("" when --account omitted). +# Returns: 0 always. +_ckipper_config_list_json() { + local account="$1" key value + local doc='{}' + while IFS= read -r key; do + value=$(_core_config_get "$key" "$account") + doc=$(jq --arg k "$key" --arg v "$value" '. + {($k): $v}' <<<"$doc") + done < <(_ckipper_config_list_keys "$account") + print -- "$doc" +} + +# Render the env format: `CKIPPER_=` lines, one per key. +# +# Args: $1 — account name ("" when --account omitted). +# Returns: 0 always. +_ckipper_config_list_env() { + local account="$1" key value var + while IFS= read -r key; do + value=$(_core_config_get "$key" "$account") + var=$(_core_config_global_var "$key") + print -- "$var=$value" + done < <(_ckipper_config_list_keys "$account") +} + +# Public list entry point. Parses flags then delegates to a format printer. +# +# Args: $1..$N — `[--account ] [--format=table|json|env]`. +# +# Returns: 0 on success; 1 on argument-parse failure. +_ckipper_config_list() { + _ckipper_config_list_parse_args "$@" || return 1 + local account="${_CKIPPER_CONFIG_LIST_ARGS[account]}" + local format="${_CKIPPER_CONFIG_LIST_ARGS[format]}" + case "$format" in + table) _ckipper_config_list_table "$account" ;; + json) _ckipper_config_list_json "$account" ;; + env) _ckipper_config_list_env "$account" ;; + esac +} diff --git a/lib/config/list_test.bats b/lib/config/list_test.bats new file mode 100644 index 0000000..af30db3 --- /dev/null +++ b/lib/config/list_test.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats +# Module-level tests for lib/config/list.zsh. +# Verifies the three output formats (table, json, env) and account-scope +# filtering. The handler is zsh-only and depends on Phase-2 style helpers +# (_core_style_header / _core_style_divider) — those are stubbed in the +# zsh -c payload before sourcing list.zsh. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + mkdir -p "$CKIPPER_DIR/docker" + : >"$CKIPPER_DIR/docker/ckipper-config.zsh" + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"work","accounts":{"work":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}}} +JSON +} + +teardown() { + teardown_isolated_env +} + +# Helper: source schema + core/config + Phase-2 style stubs + list, then run cmd. +_run_config_list() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/config/schema.zsh\" + source \"$REPO_ROOT/lib/core/config.zsh\" + _core_style_header() { print -- \"## \$1\"; } + _core_style_divider() { print -- \"---\"; } + source \"$REPO_ROOT/lib/config/list.zsh\" + $zsh_cmd + " +} + +@test "list table includes every global key" { + _run_config_list "_ckipper_config_list" + + [ "$status" -eq 0 ] + [[ "$output" == *"notify_bell"* ]] + [[ "$output" == *"default_branch"* ]] + [[ "$output" == *"dep_install_cmd"* ]] +} + +@test "list json is valid JSON" { + _run_config_list "_ckipper_config_list --format=json" + + [ "$status" -eq 0 ] + echo "$output" | jq empty +} + +@test "list env emits CKIPPER_ lines" { + _run_config_list "_ckipper_config_list --format=env" + + [ "$status" -eq 0 ] + [[ "$output" == *"CKIPPER_NOTIFY_BELL="* ]] +} + +@test "list --account adds account-scoped keys" { + _run_config_list "_ckipper_config_list --account work" + + [ "$status" -eq 0 ] + [[ "$output" == *"always_docker"* ]] + [[ "$output" == *"ssh_forward"* ]] +} diff --git a/lib/config/set.zsh b/lib/config/set.zsh new file mode 100644 index 0000000..9683b9d --- /dev/null +++ b/lib/config/set.zsh @@ -0,0 +1,99 @@ +#!/usr/bin/env zsh +# `ckipper config set` handler. Thin wrapper over _core_config_set that adds +# CLI-level argument parsing, schema-membership validation, and an interactive +# value-prompt fallback. +# +# Phase-2 dependency: _core_prompt_input / _core_prompt_choose live in +# lib/core/prompt.zsh (not yet landed). Tests exercise only the explicit-value +# path; the prompt branches are unreachable until prompt.zsh is sourced. + +# Module-level argument-parse output. Populated by _ckipper_config_set_parse_args +# and consumed by _ckipper_config_set immediately after. +typeset -gA _CKIPPER_CONFIG_SET_ARGS + +# Record a positional argument (first one becomes the key, second becomes the +# value) into _CKIPPER_CONFIG_SET_ARGS. +# +# Args: $1 — the positional token to absorb. +# Returns: 0 always. +_ckipper_config_set_absorb_positional() { + if [[ -z "${_CKIPPER_CONFIG_SET_ARGS[key]}" ]]; then + _CKIPPER_CONFIG_SET_ARGS[key]="$1" + return 0 + fi + _CKIPPER_CONFIG_SET_ARGS[value]="$1" + _CKIPPER_CONFIG_SET_ARGS[has_value]="true" +} + +# Parse `[--account ] [] []` into _CKIPPER_CONFIG_SET_ARGS. +# +# Args: $1..$N — raw CLI arguments forwarded from _ckipper_config_set. +# +# Returns: 0 on success; 1 on unknown flag. +# Errors (stderr): "Unknown flag: ''" — see Returns. +_ckipper_config_set_parse_args() { + _CKIPPER_CONFIG_SET_ARGS=([account]="" [key]="" [value]="" [has_value]="false") + while (( $# > 0 )); do + case "$1" in + --account) + [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } + _CKIPPER_CONFIG_SET_ARGS[account]="$2"; shift 2 + ;; + --account=*) _CKIPPER_CONFIG_SET_ARGS[account]="${1#--account=}"; shift ;; + -*) + echo "Unknown flag: '$1'" >&2 + return 1 + ;; + *) + _ckipper_config_set_absorb_positional "$1" + shift + ;; + esac + done +} + +# Interactively pick a schema key via the Phase-2 prompt helper. +# +# Args: $1 — name of the variable to receive the picked key (nameref). +# +# Returns: 0 on success; non-zero if the prompt is cancelled or +# _core_prompt_choose is unavailable. +_ckipper_config_set_pick_key() { + typeset -n _picked="$1" + local -a candidates + candidates=("${(@kon)_CKIPPER_SCHEMA_TYPE}") + _core_prompt_choose "Pick a config key" _picked "${candidates[@]}" +} + +# Set a configuration key. Routes to the global file or to the account +# preference store based on the schema scope. +# +# Args: +# $1..$N — `[--account ] []`. When is omitted +# the function prompts via _core_prompt_input (Phase-2). +# +# Returns: 0 on success; 1 on unknown key, validation failure, or missing +# --account on an account-scoped key. +# +# Errors (stderr): +# "Usage: ckipper config set [--account ] [value]" — no key. +# "Unknown config key: ''" — when key is not in the schema. +_ckipper_config_set() { + _ckipper_config_set_parse_args "$@" || return 1 + local key="${_CKIPPER_CONFIG_SET_ARGS[key]}" + local value="${_CKIPPER_CONFIG_SET_ARGS[value]}" + local account="${_CKIPPER_CONFIG_SET_ARGS[account]}" + if [[ -z "$key" ]]; then + echo "Usage: ckipper config set [--account ] [value]" >&2 + return 1 + fi + if [[ -z "${_CKIPPER_SCHEMA_TYPE[$key]:-}" ]]; then + echo "Unknown config key: '$key'" >&2 + return 1 + fi + if [[ "${_CKIPPER_CONFIG_SET_ARGS[has_value]}" != "true" ]]; then + local prompt_label="Value for $key (${_CKIPPER_SCHEMA_TYPE[$key]})" + _core_prompt_input "$prompt_label" value || return 1 + fi + _core_config_set "$key" "$value" "$account" +} diff --git a/lib/config/unset.zsh b/lib/config/unset.zsh new file mode 100644 index 0000000..2e79701 --- /dev/null +++ b/lib/config/unset.zsh @@ -0,0 +1,43 @@ +#!/usr/bin/env zsh +# `ckipper config unset` handler. Thin wrapper over _core_config_unset that +# adds CLI-level argument parsing and schema-membership validation. + +# Remove the override for a configuration key, reverting future reads to the +# schema default (or to the global value when an account override is removed). +# +# Args: +# $1..$N — `[--account ] `. The flag and its value may appear in +# any order before the positional ; only one --account is read. +# +# Returns: 0 on success; 1 on missing key, unknown key, or unknown flag. +# +# Errors (stderr): +# "Usage: ckipper config unset [--account ] " — when no key. +# "Unknown config key: ''" — when key is not in the schema. +# "Unknown flag: ''" — when an unrecognized flag is encountered. +_ckipper_config_unset() { + local account="" key="" + while (( $# > 0 )); do + case "$1" in + --account) + [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } + account="$2"; shift 2 + ;; + --account=*) account="${1#--account=}"; shift ;; + -*) + echo "Unknown flag: '$1'" >&2 + return 1 + ;; + *) key="$1"; shift ;; + esac + done + if [[ -z "$key" ]]; then + echo "Usage: ckipper config unset [--account ] " >&2 + return 1 + fi + if [[ -z "${_CKIPPER_SCHEMA_TYPE[$key]:-}" ]]; then + echo "Unknown config key: '$key'" >&2 + return 1 + fi + _core_config_unset "$key" "$account" +} From 6b6f60783dbe06dc7c3132e77546cab8c3cbec9c Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 01:01:16 -0600 Subject: [PATCH 098/165] =?UTF-8?q?fix:=20harden=20ckipper=20config=20?= =?UTF-8?q?=E2=80=94=20validate=20account,=20fix=20$path=20clobber,=20add?= =?UTF-8?q?=20edit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: rename `local path="$1"` to `local file="$1"` in _ckipper_config_edit_validate_json. zsh's `path` is tied to $PATH (special array), so declaring `local path=...` wiped PATH for the duration of the function — jq failed with rc=127, validate always returned 1, and edits to account preferences were silently discarded. C2: every config handler that takes --account now calls _core_account_dir to validate the account is registered before forwarding to the core primitives. Previously, `set --account ghost ...` exited 0 and created a malformed registry record (no config_dir / keychain_service / registered_at). dispatcher_test.bats and list_test.bats now source lib/core/registry.zsh so the validator is available in the test subshells. C3: add lib/config/edit_test.bats with 5 tests covering the round-trip flow that was previously untested: - --account no-op edit leaves the registry intact (would have caught C1) - malformed JSON is rejected and the registry is untouched - unregistered account is rejected with the registry's friendly error - no-flag invocation opens the global file in $EDITOR (sentinel asserted via EDITOR=cat) - bare positional argument produces the I3 helpful suggestion I1: doc-headers under each handler now list every stderr message that can surface, including those propagated from the core primitives (`"Account '' is not registered."`, `"Key '' requires --account."`, `"Invalid value for '': '' (expected )"`, `"Flag --account requires a value."`). I2: removed module-level globals _CKIPPER_CONFIG_SET_ARGS and _CKIPPER_CONFIG_LIST_ARGS. set.zsh now uses small helper functions that accept the caller's locals via zsh `(P)` indirect assignment (no `typeset -n` because zsh 5.9 has no namerefs); list.zsh inlines its parser and extracts two render helpers to stay under the 25-line cap. Drops the dead _ckipper_config_set_pick_key from ccc567a (defined but never invoked; phase-2 prompt.zsh can re-add it when needed). I3: positional arg to `ckipper config edit` now produces a precise message ("ckipper config edit takes no positional arguments. Did you mean: --account ?") instead of the misleading "Unknown flag: 'work'". N4: _ckipper_config_edit_account now sets `local_options local_traps` and traps EXIT/INT/TERM to remove the tmpfile so Ctrl-C during the editor session doesn't leak files in /tmp. --- lib/config/dispatcher_test.bats | 9 ++- lib/config/edit.zsh | 43 ++++++++--- lib/config/edit_test.bats | 113 ++++++++++++++++++++++++++++ lib/config/get.zsh | 6 +- lib/config/list.zsh | 101 +++++++++++++------------ lib/config/list_test.bats | 1 + lib/config/set.zsh | 129 ++++++++++++++++---------------- lib/config/unset.zsh | 8 +- 8 files changed, 286 insertions(+), 124 deletions(-) create mode 100644 lib/config/edit_test.bats diff --git a/lib/config/dispatcher_test.bats b/lib/config/dispatcher_test.bats index 7ff69ec..f4f59d6 100644 --- a/lib/config/dispatcher_test.bats +++ b/lib/config/dispatcher_test.bats @@ -3,8 +3,12 @@ # Verifies routing of `ckipper config ` to the per-subcommand # handlers, plus the unknown-subcommand path. The dispatcher and handlers are # zsh-only, so each test spawns a zsh subshell that sources schema.zsh + -# core/config.zsh + core/fuzzy.zsh + every config handler + dispatcher (matching -# the pattern in lib/core/config_test.bats). +# core/config.zsh + core/fuzzy.zsh + core/registry.zsh + every config handler + +# dispatcher (matching the pattern in lib/core/config_test.bats). +# +# core/registry.zsh is sourced because handlers call _core_account_dir to +# validate that --account names refer to registered accounts (rejects typos +# that would otherwise silently create phantom registry records). load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" @@ -32,6 +36,7 @@ _run_config_dispatch() { zsh -c " source \"$REPO_ROOT/lib/config/schema.zsh\" source \"$REPO_ROOT/lib/core/config.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" source \"$REPO_ROOT/lib/core/fuzzy.zsh\" source \"$REPO_ROOT/lib/config/get.zsh\" source \"$REPO_ROOT/lib/config/set.zsh\" diff --git a/lib/config/edit.zsh b/lib/config/edit.zsh index da1edb1..09e8af1 100644 --- a/lib/config/edit.zsh +++ b/lib/config/edit.zsh @@ -7,6 +7,8 @@ # the file is sourced lazily by ckipper.zsh on next shell startup. # # Returns: editor exit status. +# Errors (stderr): editor errors (e.g. "command not found") pass through +# unchanged from the underlying $EDITOR invocation. _ckipper_config_edit_global() { local file file=$(_core_config_global_file) @@ -36,8 +38,10 @@ _ckipper_config_edit_dump_prefs() { # Args: $1 — path to candidate JSON file. # Returns: 0 if parseable; 1 otherwise. Errors go to stderr via jq. _ckipper_config_edit_validate_json() { - local path="$1" - jq empty "$path" >/dev/null 2>&1 + # NB: zsh's `path` is tied to $PATH — declaring `local path=...` would wipe + # PATH for the duration of the function and break every external command. + local file="$1" + jq empty "$file" >/dev/null 2>&1 } # Slurp the edited preferences JSON back into the registry under @@ -62,22 +66,28 @@ _ckipper_config_edit_writeback() { # is not parseable JSON. # # Args: $1 — account name. -# Returns: 0 on success; 1 on dump/validate/writeback failure. -# Errors (stderr): "Edited file is not valid JSON; registry not updated." +# Returns: 0 on success; 1 on dump/validate/writeback failure or unregistered +# account. +# Errors (stderr): +# "Account '' is not registered." — propagated from _core_account_dir. +# "Edited file is not valid JSON; registry not updated." — when the edited +# tmpfile fails jq parse. _ckipper_config_edit_account() { local account="$1" + _core_account_dir "$account" >/dev/null || return 1 + # Ensure the tmpfile is removed even if the user kills the editor (Ctrl-C) + # or the shell receives a TERM signal mid-edit. local_traps scopes the + # trap to this function so it doesn't leak to callers. + setopt local_options local_traps local tmp tmp=$(_ckipper_config_edit_dump_prefs "$account") || return 1 + trap 'rm -f "$tmp"' EXIT INT TERM "${EDITOR:-vi}" "$tmp" if ! _ckipper_config_edit_validate_json "$tmp"; then echo "Edited file is not valid JSON; registry not updated." >&2 - rm -f "$tmp" return 1 fi _ckipper_config_edit_writeback "$account" "$tmp" - local rc=$? - rm -f "$tmp" - return $rc } # Public edit entry point. Routes between the global-file editor and the @@ -85,8 +95,15 @@ _ckipper_config_edit_account() { # # Args: $1..$N — `[--account ]`. # -# Returns: editor / handler exit status; 1 on unknown flag. -# Errors (stderr): "Unknown flag: ''" — see Returns. +# Returns: editor / handler exit status; 1 on unknown flag, stray positional +# argument, or unregistered account. +# Errors (stderr): +# "Unknown flag: ''" — when an unrecognized --flag is encountered. +# "Flag --account requires a value." — when --account has no following arg. +# "ckipper config edit takes no positional arguments. Did you mean: --account ?" +# — when the user passes a bare positional (e.g. `ckipper config edit work`). +# "Account '' is not registered." — propagated from _core_account_dir +# via _ckipper_config_edit_account. _ckipper_config_edit() { local account="" while (( $# > 0 )); do @@ -96,10 +113,14 @@ _ckipper_config_edit() { account="$2"; shift 2 ;; --account=*) account="${1#--account=}"; shift ;; - *) + -*) echo "Unknown flag: '$1'" >&2 return 1 ;; + *) + echo "ckipper config edit takes no positional arguments. Did you mean: --account $1?" >&2 + return 1 + ;; esac done if [[ -z "$account" ]]; then diff --git a/lib/config/edit_test.bats b/lib/config/edit_test.bats new file mode 100644 index 0000000..d54081f --- /dev/null +++ b/lib/config/edit_test.bats @@ -0,0 +1,113 @@ +#!/usr/bin/env bats +# Module-level tests for lib/config/edit.zsh. +# Verifies the round-trip flow used by `ckipper config edit --account `: +# dump → edit → validate → writeback. The handler is zsh-only and depends on +# the schema, core/config, and core/registry primitives. +# +# EDITOR mocking: tests use `EDITOR=true` for a no-op edit and a one-line +# zsh script for the "overwrite-with-garbage" case. The script lives in +# $TMP_HOME and is written per-test rather than in setup so each test owns +# its mock. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + mkdir -p "$CKIPPER_DIR/docker" + : >"$CKIPPER_DIR/docker/ckipper-config.zsh" + # v2 accounts.json fixture: one registered `work` account with an existing + # always_docker preference so writeback round-trips have something to read. + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"work","accounts":{"work":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{"always_docker":true}}}} +JSON +} + +teardown() { + teardown_isolated_env +} + +# Helper: source schema + core/config + core/registry + edit, then run cmd. +# EDITOR is forwarded so each test can swap in its own mock. +_run_config_edit() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" \ + EDITOR="${EDITOR:-true}" \ + zsh -c " + source \"$REPO_ROOT/lib/config/schema.zsh\" + source \"$REPO_ROOT/lib/core/config.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" + source \"$REPO_ROOT/lib/config/edit.zsh\" + $zsh_cmd + " +} + +@test "edit --account round-trips successfully when EDITOR is no-op" { + local before + before=$(jq -S . "$CKIPPER_REGISTRY") + + EDITOR=true _run_config_edit "_ckipper_config_edit --account work" + + [ "$status" -eq 0 ] + local after + after=$(jq -S . "$CKIPPER_REGISTRY") + [ "$before" = "$after" ] + # Registry preferences for the account remain readable and intact. + run jq -r '.accounts.work.preferences.always_docker' "$CKIPPER_REGISTRY" + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "edit --account rejects malformed JSON and leaves registry untouched" { + local mock_editor="$TMP_HOME/bad-editor" + cat >"$mock_editor" <<'SH' +#!/usr/bin/env zsh +print -- "not json" >"$1" +SH + chmod +x "$mock_editor" + local before + before=$(jq -S . "$CKIPPER_REGISTRY") + + EDITOR="$mock_editor" _run_config_edit "_ckipper_config_edit --account work" + + [ "$status" -ne 0 ] + [[ "$output" == *"not valid JSON"* ]] + local after + after=$(jq -S . "$CKIPPER_REGISTRY") + [ "$before" = "$after" ] +} + +@test "edit --account on unregistered account fails with registry error" { + local before + before=$(jq -S . "$CKIPPER_REGISTRY") + + EDITOR=true _run_config_edit "_ckipper_config_edit --account ghost" + + [ "$status" -ne 0 ] + [[ "$output" == *"Account 'ghost' is not registered."* ]] + local after + after=$(jq -S . "$CKIPPER_REGISTRY") + [ "$before" = "$after" ] +} + +@test "edit with no flags opens the global config file" { + # Seed the global file with a sentinel so we can detect that EDITOR + # received it as its argument (EDITOR=cat prints the file's contents). + local sentinel="CKIPPER_TEST_SENTINEL=42" + echo "$sentinel" >>"$CKIPPER_DIR/docker/ckipper-config.zsh" + + EDITOR=cat _run_config_edit "_ckipper_config_edit" + + [ "$status" -eq 0 ] + [[ "$output" == *"$sentinel"* ]] +} + +@test "edit rejects positional arg with helpful suggestion" { + EDITOR=true _run_config_edit "_ckipper_config_edit work" + + [ "$status" -ne 0 ] + [[ "$output" == *"takes no positional arguments"* ]] + [[ "$output" == *"--account work"* ]] +} diff --git a/lib/config/get.zsh b/lib/config/get.zsh index b81943e..9c21538 100644 --- a/lib/config/get.zsh +++ b/lib/config/get.zsh @@ -8,12 +8,15 @@ # $1..$N — `[--account ] `. The flag and its value may appear in # any order before the positional ; only one --account is read. # -# Returns: 0 on success; 1 on missing key, unknown key, or unknown flag. +# Returns: 0 on success; 1 on missing key, unknown key, unknown flag, or +# unregistered account. # # Errors (stderr): # "Usage: ckipper config get [--account ] " — when no key supplied. # "Unknown config key: ''" — when key is not in the schema. # "Unknown flag: ''" — when an unrecognized flag is encountered. +# "Flag --account requires a value." — when --account has no following arg. +# "Account '' is not registered." — propagated from _core_account_dir. _ckipper_config_get() { local account="" key="" while (( $# > 0 )); do @@ -38,5 +41,6 @@ _ckipper_config_get() { echo "Unknown config key: '$key'" >&2 return 1 fi + [[ -n "$account" ]] && { _core_account_dir "$account" >/dev/null || return 1; } _core_config_get "$key" "$account" } diff --git a/lib/config/list.zsh b/lib/config/list.zsh index 2ad92d7..e13e9fd 100644 --- a/lib/config/list.zsh +++ b/lib/config/list.zsh @@ -11,45 +11,6 @@ # lib/core/style.zsh (not yet landed). Tests stub them; production callers # source style.zsh from ckipper.zsh before list.zsh. -# Module-level argument-parse output. Populated by _ckipper_config_list_parse_args -# and consumed by the format printers. -typeset -gA _CKIPPER_CONFIG_LIST_ARGS - -# Parse `[--account ] [--format=]` into _CKIPPER_CONFIG_LIST_ARGS. -# -# Args: $1..$N — raw CLI arguments forwarded from _ckipper_config_list. -# -# Returns: 0 on success; 1 on unknown flag or unsupported format. -# Errors (stderr): -# "Unknown flag: ''" — when an unrecognized argument is encountered. -# "Unknown format: '' (expected: table, json, env)" -_ckipper_config_list_parse_args() { - _CKIPPER_CONFIG_LIST_ARGS=([account]="" [format]="table") - while (( $# > 0 )); do - case "$1" in - --account) - [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } - _CKIPPER_CONFIG_LIST_ARGS[account]="$2"; shift 2 - ;; - --account=*) _CKIPPER_CONFIG_LIST_ARGS[account]="${1#--account=}"; shift ;; - --format=*) _CKIPPER_CONFIG_LIST_ARGS[format]="${1#--format=}"; shift ;; - --format) - [[ -z "${2:-}" ]] && { echo "Flag --format requires a value." >&2; return 1; } - _CKIPPER_CONFIG_LIST_ARGS[format]="$2"; shift 2 - ;; - *) - echo "Unknown flag: '$1'" >&2 - return 1 - ;; - esac - done - case "${_CKIPPER_CONFIG_LIST_ARGS[format]}" in - table | json | env) return 0 ;; - esac - echo "Unknown format: '${_CKIPPER_CONFIG_LIST_ARGS[format]}' (expected: table, json, env)" >&2 - return 1 -} - # Decide whether a schema key should appear in the listing for the current # scope choice. Global keys always appear; account-scoped keys appear only # when --account was supplied. @@ -120,18 +81,66 @@ _ckipper_config_list_env() { done < <(_ckipper_config_list_keys "$account") } -# Public list entry point. Parses flags then delegates to a format printer. +# Validate the format token against the supported renderers. # -# Args: $1..$N — `[--account ] [--format=table|json|env]`. +# Args: $1 — format string. +# Returns: 0 if recognized; 1 otherwise. +# Errors (stderr): "Unknown format: '' (expected: table, json, env)". +_ckipper_config_list_validate_format() { + case "$1" in + table | json | env) return 0 ;; + esac + echo "Unknown format: '$1' (expected: table, json, env)" >&2 + return 1 +} + +# Dispatch to the renderer matching the resolved format. Caller is responsible +# for having validated the format already via _ckipper_config_list_validate_format. # -# Returns: 0 on success; 1 on argument-parse failure. -_ckipper_config_list() { - _ckipper_config_list_parse_args "$@" || return 1 - local account="${_CKIPPER_CONFIG_LIST_ARGS[account]}" - local format="${_CKIPPER_CONFIG_LIST_ARGS[format]}" +# Args: $1 — format ("table" | "json" | "env"), $2 — account ("" if global). +# Returns: renderer's exit status. +_ckipper_config_list_render() { + local format="$1" account="$2" case "$format" in table) _ckipper_config_list_table "$account" ;; json) _ckipper_config_list_json "$account" ;; env) _ckipper_config_list_env "$account" ;; esac } + +# Public list entry point. Parses flags then delegates to a format printer. +# +# Args: $1..$N — `[--account ] [--format=table|json|env]`. +# +# Returns: 0 on success; 1 on argument-parse failure or unregistered account. +# +# Errors (stderr): +# "Unknown flag: ''" — when an unrecognized argument is encountered. +# "Flag --account requires a value." — when --account has no following arg. +# "Flag --format requires a value." — when --format has no following arg. +# "Unknown format: '' (expected: table, json, env)" — invalid format. +# "Account '' is not registered." — propagated from _core_account_dir. +_ckipper_config_list() { + local account="" format="table" + while (( $# > 0 )); do + case "$1" in + --account) + [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } + account="$2"; shift 2 + ;; + --account=*) account="${1#--account=}"; shift ;; + --format=*) format="${1#--format=}"; shift ;; + --format) + [[ -z "${2:-}" ]] && { echo "Flag --format requires a value." >&2; return 1; } + format="$2"; shift 2 + ;; + *) + echo "Unknown flag: '$1'" >&2 + return 1 + ;; + esac + done + _ckipper_config_list_validate_format "$format" || return 1 + [[ -n "$account" ]] && { _core_account_dir "$account" >/dev/null || return 1; } + _ckipper_config_list_render "$format" "$account" +} diff --git a/lib/config/list_test.bats b/lib/config/list_test.bats index af30db3..4666e84 100644 --- a/lib/config/list_test.bats +++ b/lib/config/list_test.bats @@ -30,6 +30,7 @@ _run_config_list() { zsh -c " source \"$REPO_ROOT/lib/config/schema.zsh\" source \"$REPO_ROOT/lib/core/config.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" _core_style_header() { print -- \"## \$1\"; } _core_style_divider() { print -- \"---\"; } source \"$REPO_ROOT/lib/config/list.zsh\" diff --git a/lib/config/set.zsh b/lib/config/set.zsh index 9683b9d..7420330 100644 --- a/lib/config/set.zsh +++ b/lib/config/set.zsh @@ -7,62 +7,59 @@ # lib/core/prompt.zsh (not yet landed). Tests exercise only the explicit-value # path; the prompt branches are unreachable until prompt.zsh is sourced. -# Module-level argument-parse output. Populated by _ckipper_config_set_parse_args -# and consumed by _ckipper_config_set immediately after. -typeset -gA _CKIPPER_CONFIG_SET_ARGS - -# Record a positional argument (first one becomes the key, second becomes the -# value) into _CKIPPER_CONFIG_SET_ARGS. +# Absorb a positional token into the caller's key/value/has_value slots. +# First positional becomes the key; subsequent positionals overwrite the value +# and flip the has_value sentinel. +# +# Uses zsh indirect assignment via `(P)`-flagged parameter expansion — zsh +# 5.9 has no `typeset -n` namerefs. # -# Args: $1 — the positional token to absorb. +# Args: $1 — token, $2 — name of caller's `key` var, $3 — name of caller's +# `value` var, $4 — name of caller's `has_value` var. # Returns: 0 always. _ckipper_config_set_absorb_positional() { - if [[ -z "${_CKIPPER_CONFIG_SET_ARGS[key]}" ]]; then - _CKIPPER_CONFIG_SET_ARGS[key]="$1" + local token="$1" key_var="$2" value_var="$3" hasv_var="$4" + if [[ -z "${(P)key_var}" ]]; then + : ${(P)key_var::=$token} return 0 fi - _CKIPPER_CONFIG_SET_ARGS[value]="$1" - _CKIPPER_CONFIG_SET_ARGS[has_value]="true" + : ${(P)value_var::=$token} + : ${(P)hasv_var::=true} } -# Parse `[--account ] [] []` into _CKIPPER_CONFIG_SET_ARGS. -# -# Args: $1..$N — raw CLI arguments forwarded from _ckipper_config_set. +# Verify the user supplied a key and that it exists in the schema. Surfaces +# both the no-key usage line and the unknown-key error so the caller can +# treat the result as a single validation gate. # -# Returns: 0 on success; 1 on unknown flag. -# Errors (stderr): "Unknown flag: ''" — see Returns. -_ckipper_config_set_parse_args() { - _CKIPPER_CONFIG_SET_ARGS=([account]="" [key]="" [value]="" [has_value]="false") - while (( $# > 0 )); do - case "$1" in - --account) - [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } - _CKIPPER_CONFIG_SET_ARGS[account]="$2"; shift 2 - ;; - --account=*) _CKIPPER_CONFIG_SET_ARGS[account]="${1#--account=}"; shift ;; - -*) - echo "Unknown flag: '$1'" >&2 - return 1 - ;; - *) - _ckipper_config_set_absorb_positional "$1" - shift - ;; - esac - done +# Args: $1 — candidate key (may be empty). +# Returns: 0 if the key is non-empty and in the schema; 1 otherwise. +# Errors (stderr): +# "Usage: ckipper config set [--account ] [value]" — no key. +# "Unknown config key: ''" — key not in schema. +_ckipper_config_set_validate_key() { + local key="$1" + if [[ -z "$key" ]]; then + echo "Usage: ckipper config set [--account ] [value]" >&2 + return 1 + fi + if [[ -z "${_CKIPPER_SCHEMA_TYPE[$key]:-}" ]]; then + echo "Unknown config key: '$key'" >&2 + return 1 + fi } -# Interactively pick a schema key via the Phase-2 prompt helper. -# -# Args: $1 — name of the variable to receive the picked key (nameref). +# Resolve the value to write: use the supplied value when has_value is "true", +# otherwise prompt the user via the Phase-2 input helper. _core_prompt_input +# writes back into the caller's variable directly. # -# Returns: 0 on success; non-zero if the prompt is cancelled or -# _core_prompt_choose is unavailable. -_ckipper_config_set_pick_key() { - typeset -n _picked="$1" - local -a candidates - candidates=("${(@kon)_CKIPPER_SCHEMA_TYPE}") - _core_prompt_choose "Pick a config key" _picked "${candidates[@]}" +# Args: $1 — schema key, $2 — has_value sentinel ("true"/"false"), +# $3 — name of the value variable in the caller's scope. +# Returns: 0 on success; non-zero if the prompt is cancelled. +_ckipper_config_set_resolve_value() { + local key="$1" has_value="$2" var_name="$3" + [[ "$has_value" == "true" ]] && return 0 + local prompt_label="Value for $key (${_CKIPPER_SCHEMA_TYPE[$key]})" + _core_prompt_input "$prompt_label" "$var_name" } # Set a configuration key. Routes to the global file or to the account @@ -72,28 +69,34 @@ _ckipper_config_set_pick_key() { # $1..$N — `[--account ] []`. When is omitted # the function prompts via _core_prompt_input (Phase-2). # -# Returns: 0 on success; 1 on unknown key, validation failure, or missing -# --account on an account-scoped key. +# Returns: 0 on success; 1 on unknown key, unknown flag, validation failure, +# missing --account on an account-scoped key, or unregistered account. # # Errors (stderr): # "Usage: ckipper config set [--account ] [value]" — no key. # "Unknown config key: ''" — when key is not in the schema. +# "Unknown flag: ''" — when an unrecognized flag is encountered. +# "Flag --account requires a value." — when --account has no following arg. +# "Account '' is not registered." — propagated from _core_account_dir. +# "Key '' requires --account." — propagated from _core_config_set when +# scope=account but no account name was supplied. +# "Invalid value for '': '' (expected )" — propagated +# from _core_config_validate on type mismatch. _ckipper_config_set() { - _ckipper_config_set_parse_args "$@" || return 1 - local key="${_CKIPPER_CONFIG_SET_ARGS[key]}" - local value="${_CKIPPER_CONFIG_SET_ARGS[value]}" - local account="${_CKIPPER_CONFIG_SET_ARGS[account]}" - if [[ -z "$key" ]]; then - echo "Usage: ckipper config set [--account ] [value]" >&2 - return 1 - fi - if [[ -z "${_CKIPPER_SCHEMA_TYPE[$key]:-}" ]]; then - echo "Unknown config key: '$key'" >&2 - return 1 - fi - if [[ "${_CKIPPER_CONFIG_SET_ARGS[has_value]}" != "true" ]]; then - local prompt_label="Value for $key (${_CKIPPER_SCHEMA_TYPE[$key]})" - _core_prompt_input "$prompt_label" value || return 1 - fi + local account="" key="" value="" has_value="false" + while (( $# > 0 )); do + case "$1" in + --account) + [[ -z "${2:-}" ]] && { echo "Flag --account requires a value." >&2; return 1; } + account="$2"; shift 2 + ;; + --account=*) account="${1#--account=}"; shift ;; + -*) echo "Unknown flag: '$1'" >&2; return 1 ;; + *) _ckipper_config_set_absorb_positional "$1" key value has_value; shift ;; + esac + done + _ckipper_config_set_validate_key "$key" || return 1 + [[ -n "$account" ]] && { _core_account_dir "$account" >/dev/null || return 1; } + _ckipper_config_set_resolve_value "$key" "$has_value" value || return 1 _core_config_set "$key" "$value" "$account" } diff --git a/lib/config/unset.zsh b/lib/config/unset.zsh index 2e79701..17a30e5 100644 --- a/lib/config/unset.zsh +++ b/lib/config/unset.zsh @@ -9,12 +9,17 @@ # $1..$N — `[--account ] `. The flag and its value may appear in # any order before the positional ; only one --account is read. # -# Returns: 0 on success; 1 on missing key, unknown key, or unknown flag. +# Returns: 0 on success; 1 on missing key, unknown key, unknown flag, missing +# --account on an account-scoped key, or unregistered account. # # Errors (stderr): # "Usage: ckipper config unset [--account ] " — when no key. # "Unknown config key: ''" — when key is not in the schema. # "Unknown flag: ''" — when an unrecognized flag is encountered. +# "Flag --account requires a value." — when --account has no following arg. +# "Account '' is not registered." — propagated from _core_account_dir. +# "Key '' requires --account." — propagated from _core_config_unset +# when scope=account but no account name was supplied. _ckipper_config_unset() { local account="" key="" while (( $# > 0 )); do @@ -39,5 +44,6 @@ _ckipper_config_unset() { echo "Unknown config key: '$key'" >&2 return 1 fi + [[ -n "$account" ]] && { _core_account_dir "$account" >/dev/null || return 1; } _core_config_unset "$key" "$account" } From 2bc6d753e921a188f2d0e900339d9755f8e70918 Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 01:14:35 -0600 Subject: [PATCH 099/165] refactor: inline _ckipper_config_set absorb-positional helper The helper took 4 params (token, key_var, value_var, hasv_var) and used zsh `(P)` indirect assignment to update locals from inside a function, violating the project's <=3-param rule and adding "cleverness" the rules flag against. Inline the 5-line absorb logic into the parser's `*)` arm. _ckipper_config_set stays under the 25-line cap and `set.zsh` now matches the inline-parser shape used by the other four handlers. --- lib/config/set.zsh | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/lib/config/set.zsh b/lib/config/set.zsh index 7420330..acc8323 100644 --- a/lib/config/set.zsh +++ b/lib/config/set.zsh @@ -7,26 +7,6 @@ # lib/core/prompt.zsh (not yet landed). Tests exercise only the explicit-value # path; the prompt branches are unreachable until prompt.zsh is sourced. -# Absorb a positional token into the caller's key/value/has_value slots. -# First positional becomes the key; subsequent positionals overwrite the value -# and flip the has_value sentinel. -# -# Uses zsh indirect assignment via `(P)`-flagged parameter expansion — zsh -# 5.9 has no `typeset -n` namerefs. -# -# Args: $1 — token, $2 — name of caller's `key` var, $3 — name of caller's -# `value` var, $4 — name of caller's `has_value` var. -# Returns: 0 always. -_ckipper_config_set_absorb_positional() { - local token="$1" key_var="$2" value_var="$3" hasv_var="$4" - if [[ -z "${(P)key_var}" ]]; then - : ${(P)key_var::=$token} - return 0 - fi - : ${(P)value_var::=$token} - : ${(P)hasv_var::=true} -} - # Verify the user supplied a key and that it exists in the schema. Surfaces # both the no-key usage line and the unknown-key error so the caller can # treat the result as a single validation gate. @@ -92,7 +72,15 @@ _ckipper_config_set() { ;; --account=*) account="${1#--account=}"; shift ;; -*) echo "Unknown flag: '$1'" >&2; return 1 ;; - *) _ckipper_config_set_absorb_positional "$1" key value has_value; shift ;; + *) + if [[ -z "$key" ]]; then + key="$1" + else + value="$1" + has_value="true" + fi + shift + ;; esac done _ckipper_config_set_validate_key "$key" || return 1 From 65100f8ebb0b73c39236830300514bf1bca7a508 Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 01:21:15 -0600 Subject: [PATCH 100/165] feat: wire ckipper config namespace into top-level dispatcher Adds source lines for schema + core/config + every config handler, the config arm to ckipper(), the config entry in _CKIPPER_COMMANDS, the config row in top-level help, and a config_subs array + sub-state branch in the tab-completion heredoc. Bumps CKIPPER_COMPLETION_VERSION 1 to 2 (both the variable and the in-heredoc sentinel) so existing installs regenerate the completion file. ckipper_config_test.bats covers all six dispatcher routes: help, list, get default, set+get round-trip, unknown-subcommand fuzzy, and bare- config overview. --- ckipper.zsh | 36 +++++++++++++++++++---- ckipper_config_test.bats | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 ckipper_config_test.bats diff --git a/ckipper.zsh b/ckipper.zsh index 46ec662..4fe0b43 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -19,6 +19,8 @@ source "$CKIPPER_REPO_DIR/lib/core/utils.zsh" source "$CKIPPER_REPO_DIR/lib/core/registry.zsh" source "$CKIPPER_REPO_DIR/lib/core/keychain.zsh" source "$CKIPPER_REPO_DIR/lib/core/fuzzy.zsh" +source "$CKIPPER_REPO_DIR/lib/config/schema.zsh" +source "$CKIPPER_REPO_DIR/lib/core/config.zsh" # Account-namespace modules source "$CKIPPER_REPO_DIR/lib/account/account-management.zsh" @@ -39,6 +41,14 @@ source "$CKIPPER_REPO_DIR/lib/worktree/ports.zsh" source "$CKIPPER_REPO_DIR/lib/worktree/resolve-account.zsh" source "$CKIPPER_REPO_DIR/lib/worktree/worktree.zsh" +# Config-namespace modules +source "$CKIPPER_REPO_DIR/lib/config/get.zsh" +source "$CKIPPER_REPO_DIR/lib/config/set.zsh" +source "$CKIPPER_REPO_DIR/lib/config/unset.zsh" +source "$CKIPPER_REPO_DIR/lib/config/list.zsh" +source "$CKIPPER_REPO_DIR/lib/config/edit.zsh" +source "$CKIPPER_REPO_DIR/lib/config/dispatcher.zsh" + # User config (projects/worktrees dirs, ports, extra volumes, extra env vars). # Renamed from w-config.zsh in the merge; install.sh handles the migration. _ckipper_user_config="${CKIPPER_DIR:-$HOME/.ckipper}/docker/ckipper-config.zsh" @@ -54,7 +64,7 @@ CKIPPER_WORKTREES_DIR="${CKIPPER_WORKTREES_DIR:-$CKIPPER_PROJECTS_DIR/.worktrees (( ${#CKIPPER_EXTRA_ENV[@]} == 0 )) && CKIPPER_EXTRA_ENV=() # Top-level commands. Used both for routing and for fuzzy-suggest. -_CKIPPER_COMMANDS=(account worktree doctor help) +_CKIPPER_COMMANDS=(account worktree config doctor help) # Pre-merge top-level commands → their post-merge namespaced replacement. # Used by _ckipper_unknown so a user typing the old form (e.g. `ckipper add`) @@ -76,8 +86,8 @@ typeset -gA _CKIPPER_LEGACY_COMMANDS=( # Dispatch a top-level ckipper command. # # Args: -# $1 — top-level command (account, worktree, doctor, help, -h, --help, -# empty, or short alias acct/wt) +# $1 — top-level command (account, worktree, config, doctor, help, +# -h, --help, empty, or short alias acct/wt) # $2..$N — arguments forwarded to the namespace dispatcher # # Returns: 0 on success; 1 on unknown command. @@ -94,6 +104,7 @@ ckipper() { case "$cmd" in account) _ckipper_account_dispatch "$@" ;; worktree) _ckipper_worktree_dispatch "$@" ;; + config) _ckipper_config_dispatch "$@" ;; doctor) if [[ "$1" == "--help" || "$1" == "-h" ]]; then _ckipper_help_text_doctor @@ -138,6 +149,7 @@ ckipper (pronounced "skipper") — multi-account Claude Code manager Usage: ckipper account Manage Claude accounts (alias: acct) ckipper worktree Manage git worktrees (alias: wt) + ckipper config View and modify Ckipper settings ckipper doctor Diagnostic check of accounts and tooling ckipper help Show this overview @@ -183,7 +195,7 @@ fpath=(~/.zsh/completions $fpath) # Bump this when the heredoc body below changes so existing installs # regenerate the cached completion file. The version is embedded as a literal # comment in the generated file and matched here. -CKIPPER_COMPLETION_VERSION=1 +CKIPPER_COMPLETION_VERSION=2 if [[ ! -f ~/.zsh/completions/_ckipper ]] \ || ! grep -q "# ckipper-completion-version=$CKIPPER_COMPLETION_VERSION" ~/.zsh/completions/_ckipper 2>/dev/null; then # Note: `_ckipper()` below is a zsh tab-completion definition embedded in @@ -193,18 +205,19 @@ if [[ ! -f ~/.zsh/completions/_ckipper ]] \ # a completion file, not maintained shell logic). cat > ~/.zsh/completions/_ckipper << 'COMPEOF' #compdef ckipper ck -# ckipper-completion-version=1 +# ckipper-completion-version=2 _ckipper() { local projects_dir="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}" local worktrees_dir="${CKIPPER_WORKTREES_DIR:-$projects_dir/.worktrees}" - local -a top_commands account_subs worktree_subs + local -a top_commands account_subs worktree_subs config_subs top_commands=( 'account:Manage Claude accounts' 'acct:Short alias for account' 'worktree:Manage git worktrees' 'wt:Short alias for worktree' + 'config:View and modify Ckipper settings' 'doctor:Diagnostic check of accounts and tooling' 'help:Show top-level help' ) @@ -226,6 +239,14 @@ _ckipper() { 'rebuild-image:Rebuild ckipper-dev Docker image' 'help:Show worktree-namespace help' ) + config_subs=( + 'get:Read a config value' + 'set:Write a config value' + 'unset:Remove a config override' + 'list:Show all config values' + 'edit:Open the config file in $EDITOR' + 'help:Show config-namespace help' + ) _arguments -C \ '1: :->cmd' \ @@ -247,6 +268,9 @@ _ckipper() { worktree|wt) _describe -t subcommands 'worktree subcommand' worktree_subs && return 0 ;; + config) + _describe -t subcommands 'config subcommand' config_subs && return 0 + ;; esac ;; arg3) diff --git a/ckipper_config_test.bats b/ckipper_config_test.bats new file mode 100644 index 0000000..c17241d --- /dev/null +++ b/ckipper_config_test.bats @@ -0,0 +1,63 @@ +#!/usr/bin/env bats +# Top-level dispatcher tests for the `config` namespace routing. +# +# Verifies that `ckipper config ` reaches the namespace dispatcher +# and that the schema + core config + handlers are sourced into ckipper.zsh. +# +# ckipper.zsh is zsh-only; bats runs under bash, so each test spawns a zsh +# subprocess via run_ckipper(). + +load "${BATS_TEST_DIRNAME}/tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + mkdir -p "$CKIPPER_DIR/docker" + : >"$CKIPPER_DIR/docker/ckipper-config.zsh" + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"work","accounts":{"work":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}}} +JSON +} + +teardown() { + teardown_isolated_env +} + +@test "ckipper config help prints config-namespace help" { + run_ckipper config help + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper config" ]] +} + +@test "ckipper config list runs and prints something" { + run_ckipper config list + [ "$status" -eq 0 ] + [ -n "$output" ] +} + +@test "ckipper config get notify_bell returns schema default" { + run_ckipper config get notify_bell + [ "$status" -eq 0 ] + [[ "$output" =~ "true" ]] +} + +@test "ckipper config set + get round-trips through dispatcher" { + run_ckipper config set notify_bell false + [ "$status" -eq 0 ] + + run_ckipper config get notify_bell + [ "$status" -eq 0 ] + [[ "$output" =~ "false" ]] +} + +@test "ckipper config unknown subcommand suggests help pointer" { + run_ckipper config nope + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown command:" ]] + [[ "$output" =~ "config help" ]] +} + +@test "ckipper config (no subcommand) prints overview help" { + run_ckipper config + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper config" ]] +} From 16e01900c78a30c401864ad0e8aeabd72c72f76c Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 01:39:32 -0600 Subject: [PATCH 101/165] feat: auto-migrate accounts.json v1 -> v2 with per-account preferences Bumps CKIPPER_REGISTRY_VERSION to 2 and adds a one-shot v1->v2 migration to _core_registry_check_version. The migration writes a timestamped backup (.v1.bak.), then layers safe-default preferences (always_docker=false, always_firewall=false, ssh_forward=true) under each account so existing preferences win on re-runs. Migration is gated on CKIPPER_REGISTRY_VERSION >= 2 so test fixtures that pin v1 keep working. --- ckipper.zsh | 2 +- lib/core/registry.zsh | 63 ++++++++++- lib/core/registry_migration_test.bats | 151 ++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 lib/core/registry_migration_test.bats diff --git a/ckipper.zsh b/ckipper.zsh index 4fe0b43..d8f0710 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -10,7 +10,7 @@ CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" -CKIPPER_REGISTRY_VERSION=1 +CKIPPER_REGISTRY_VERSION=2 CKIPPER_REPO_DIR="${0:A:h}" diff --git a/lib/core/registry.zsh b/lib/core/registry.zsh index 6d7aaac..12b52fb 100644 --- a/lib/core/registry.zsh +++ b/lib/core/registry.zsh @@ -173,29 +173,80 @@ _core_registry_init() { [[ -f "$CKIPPER_REGISTRY" ]] && chmod "$REGISTRY_FILE_PERMS" "$CKIPPER_REGISTRY" } +# Auto-migrate a v1 registry to v2 in place. +# Backs up the v1 file (refuses to migrate without a backup), then rewrites +# accounts.json with .version=2 and a per-account .preferences block. Existing +# preferences win over defaults so partial-v2 fixtures keep their values. +# +# Returns: +# 0 on successful migration; 1 if backup write or jq update failed. +# +# Errors (stderr): +# "Error: failed to write migration backup..." — when cp to the .v1.bak path fails. +_core_registry_migrate_v1_to_v2() { + local backup="${CKIPPER_REGISTRY}.v1.bak.$(date -u +%Y%m%dT%H%M%SZ)" + if ! cp "$CKIPPER_REGISTRY" "$backup" 2>/dev/null; then + echo "Error: failed to write migration backup $backup" >&2 + return 1 + fi + _core_registry_update ' + .version = 2 + | .accounts = ( + .accounts | with_entries( + .value.preferences = ( + {always_docker: false, always_firewall: false, ssh_forward: true} + + (.value.preferences // {}) + ) + ) + ) + ' +} + # Refuse to operate on a registry whose version we don't understand OR whose schema # is corrupt (e.g. user manually edited and turned .accounts into an array). +# Auto-migrates a v1 registry to v2 (with backup) before checking the version. # # Returns: -# 0 if registry is absent or valid; 1 on version mismatch or corrupt schema. +# 0 if registry is absent or valid; 1 on version mismatch, migration failure, +# or corrupt schema. # # Errors (stderr): +# "Migrating accounts.json v1 → v2..." — informational notice during auto-migration. # "Error: registry version..." — on version mismatch. # "Error: ... is corrupt..." — on bad schema. _core_registry_check_version() { [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 + local cur + cur=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + if [[ "$cur" == "1" ]] && (( CKIPPER_REGISTRY_VERSION >= 2 )); then + echo "Migrating accounts.json v1 → v2..." >&2 + _core_registry_migrate_v1_to_v2 || return 1 + fi local v v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) if (( v != CKIPPER_REGISTRY_VERSION )); then echo "Error: registry version $v not supported (this ckipper expects $CKIPPER_REGISTRY_VERSION). Update ckipper or restore from backup." >&2 return 1 fi - if ! jq -e '.accounts | type == "object"' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then - echo "Error: $CKIPPER_REGISTRY is corrupt (.accounts is not an object)." >&2 - echo "Backup and re-init manually:" >&2 - echo " mv $CKIPPER_REGISTRY $CKIPPER_REGISTRY.corrupt-\$(date +%s)" >&2 - return 1 + _core_registry_assert_accounts_object || return 1 +} + +# Verify that .accounts is a JSON object (not an array or other type). +# Surface a clear error with manual-recovery instructions when it isn't. +# +# Returns: +# 0 if the schema looks valid; 1 if .accounts is corrupt. +# +# Errors (stderr): +# "Error: ... is corrupt..." — when .accounts is not an object. +_core_registry_assert_accounts_object() { + if jq -e '.accounts | type == "object"' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then + return 0 fi + echo "Error: $CKIPPER_REGISTRY is corrupt (.accounts is not an object)." >&2 + echo "Backup and re-init manually:" >&2 + echo " mv $CKIPPER_REGISTRY $CKIPPER_REGISTRY.corrupt-\$(date +%s)" >&2 + return 1 } # Validate that an account exists in the registry. Echoes its config_dir on success. diff --git a/lib/core/registry_migration_test.bats b/lib/core/registry_migration_test.bats new file mode 100644 index 0000000..95c993a --- /dev/null +++ b/lib/core/registry_migration_test.bats @@ -0,0 +1,151 @@ +#!/usr/bin/env bats +# Module-level tests for the v1 -> v2 auto-migration in lib/core/registry.zsh. +# Covers detect, mutate, defaults, backup, idempotency, and existing-prefs merge. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export CKIPPER_REGISTRY_VERSION=2 + export _CKIPPER_TEST_OSTYPE="darwin19.0" +} + +teardown() { + teardown_isolated_env +} + +# Helper: source registry (and its utils dep) then run zsh_cmd. +# Mirrors the helper in registry_test.bats, but defaults the version env var to 2 +# so the migration path under test is exercised. +_run_registry() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_REGISTRY_VERSION="${CKIPPER_REGISTRY_VERSION:-2}" \ + _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-darwin19.0}" \ + PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/utils.zsh\"; source \"$REPO_ROOT/lib/core/registry.zsh\"; $zsh_cmd" +} + +# Seed a v1 registry fixture with a single account. +_seed_v1_registry() { + cat > "$CKIPPER_REGISTRY" <<'EOF' +{ + "version": 1, + "default": "personal", + "accounts": { + "personal": { + "config_dir": "/tmp/.claude-personal", + "keychain_service": "Claude Code-credentials", + "registered_at": "2026-04-30T12:00:00Z" + } + } +} +EOF + chmod 600 "$CKIPPER_REGISTRY" +} + +@test "v1 registry migrates to v2 on _core_registry_check_version" { + _seed_v1_registry + + _run_registry "_core_registry_check_version" + + [ "$status" -eq 0 ] + local v; v=$(jq -r '.version' "$CKIPPER_REGISTRY") + [ "$v" = "2" ] +} + +@test "v2 migration adds preferences with safe defaults" { + _seed_v1_registry + + _run_registry "_core_registry_check_version" + + [ "$status" -eq 0 ] + local always_docker always_firewall ssh_forward + always_docker=$(jq -r '.accounts.personal.preferences.always_docker' "$CKIPPER_REGISTRY") + always_firewall=$(jq -r '.accounts.personal.preferences.always_firewall' "$CKIPPER_REGISTRY") + ssh_forward=$(jq -r '.accounts.personal.preferences.ssh_forward' "$CKIPPER_REGISTRY") + [ "$always_docker" = "false" ] + [ "$always_firewall" = "false" ] + [ "$ssh_forward" = "true" ] +} + +@test "v2 migration preserves existing fields" { + _seed_v1_registry + + _run_registry "_core_registry_check_version" + + [ "$status" -eq 0 ] + local default config_dir keychain registered_at + default=$(jq -r '.default' "$CKIPPER_REGISTRY") + config_dir=$(jq -r '.accounts.personal.config_dir' "$CKIPPER_REGISTRY") + keychain=$(jq -r '.accounts.personal.keychain_service' "$CKIPPER_REGISTRY") + registered_at=$(jq -r '.accounts.personal.registered_at' "$CKIPPER_REGISTRY") + [ "$default" = "personal" ] + [ "$config_dir" = "/tmp/.claude-personal" ] + [ "$keychain" = "Claude Code-credentials" ] + [ "$registered_at" = "2026-04-30T12:00:00Z" ] +} + +@test "v2 migration writes a backup file containing the pre-migration JSON" { + _seed_v1_registry + local pre_contents; pre_contents=$(cat "$CKIPPER_REGISTRY") + + _run_registry "_core_registry_check_version" + + [ "$status" -eq 0 ] + local -a backups + backups=( "$CKIPPER_REGISTRY".v1.bak.* ) + [ -f "${backups[0]}" ] + local backup_contents; backup_contents=$(cat "${backups[0]}") + [ "$backup_contents" = "$pre_contents" ] +} + +@test "v2 migration is idempotent (no-op on already-v2)" { + _seed_v1_registry + + # First call performs the migration. + _run_registry "_core_registry_check_version" + [ "$status" -eq 0 ] + local after_first; after_first=$(cat "$CKIPPER_REGISTRY") + local first_backup_count; first_backup_count=$(ls -1 "$CKIPPER_REGISTRY".v1.bak.* 2>/dev/null | wc -l | tr -d ' ') + + # Second call should observe v2 and do nothing further. + sleep 1 # ensure any second backup would land in a distinct timestamp slot + _run_registry "_core_registry_check_version" + [ "$status" -eq 0 ] + local after_second; after_second=$(cat "$CKIPPER_REGISTRY") + local second_backup_count; second_backup_count=$(ls -1 "$CKIPPER_REGISTRY".v1.bak.* 2>/dev/null | wc -l | tr -d ' ') + + [ "$after_first" = "$after_second" ] + [ "$first_backup_count" = "$second_backup_count" ] +} + +@test "v2 migration preserves existing preferences (defaults merge under existing values)" { + cat > "$CKIPPER_REGISTRY" <<'EOF' +{ + "version": 1, + "default": "personal", + "accounts": { + "personal": { + "config_dir": "/tmp/.claude-personal", + "keychain_service": null, + "preferences": {"ssh_forward": false} + } + } +} +EOF + chmod 600 "$CKIPPER_REGISTRY" + + _run_registry "_core_registry_check_version" + + [ "$status" -eq 0 ] + local ssh_forward always_docker always_firewall + ssh_forward=$(jq -r '.accounts.personal.preferences.ssh_forward' "$CKIPPER_REGISTRY") + always_docker=$(jq -r '.accounts.personal.preferences.always_docker' "$CKIPPER_REGISTRY") + always_firewall=$(jq -r '.accounts.personal.preferences.always_firewall' "$CKIPPER_REGISTRY") + [ "$ssh_forward" = "false" ] + [ "$always_docker" = "false" ] + [ "$always_firewall" = "false" ] +} From 90917905b07bd8ba679e08850193539f05e6e9d4 Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 01:50:03 -0600 Subject: [PATCH 102/165] fix: account add records v2 preferences with safe defaults After the v1->v2 registry bump in 16e0190, _ckipper_account_finalize_registration still wrote new account records without a preferences block, leaving them out of compliance with the v2 schema. Extend the jq object literal so every newly registered account gets the same defaults the v1->v2 migration applies (always_docker=false, always_firewall=false, ssh_forward=true). --- lib/account/account-management.zsh | 7 ++++++- lib/account/account-management_test.bats | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/account/account-management.zsh b/lib/account/account-management.zsh index 708b19c..615a0fd 100644 --- a/lib/account/account-management.zsh +++ b/lib/account/account-management.zsh @@ -230,7 +230,12 @@ _ckipper_account_finalize_registration() { elif ([.accounts[].config_dir] | any(. == $d)) then error("CONFIG_DIR_IN_USE") else - .accounts[$n] = {config_dir: $d, keychain_service: (if $s == "" then null else $s end), registered_at: $t} + .accounts[$n] = { + config_dir: $d, + keychain_service: (if $s == "" then null else $s end), + registered_at: $t, + preferences: {always_docker: false, always_firewall: false, ssh_forward: true} + } | (if .default == null then .default = $n else . end) end ' --arg n "$name" --arg d "$dir" --arg s "$service" --arg t "$now"; then diff --git a/lib/account/account-management_test.bats b/lib/account/account-management_test.bats index 0afcc11..4510201 100644 --- a/lib/account/account-management_test.bats +++ b/lib/account/account-management_test.bats @@ -78,6 +78,23 @@ run_helper() { [[ "$output" =~ "already registered" ]] } +# ── _ckipper_account_finalize_registration ─────────────────────────────────── + +@test "account add stores preferences with safe defaults" { + echo '{"version":2,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + + run_helper '_CKIPPER_FINALIZE_CTX[name]="work"; _CKIPPER_FINALIZE_CTX[dir]="/tmp/.claude-work"; _CKIPPER_FINALIZE_CTX[service]=""; _ckipper_account_finalize_registration "adopt"' + + [ "$status" -eq 0 ] + local always_docker always_firewall ssh_forward + always_docker=$(jq -r '.accounts.work.preferences.always_docker' "$CKIPPER_REGISTRY") + always_firewall=$(jq -r '.accounts.work.preferences.always_firewall' "$CKIPPER_REGISTRY") + ssh_forward=$(jq -r '.accounts.work.preferences.ssh_forward' "$CKIPPER_REGISTRY") + [ "$always_docker" = "false" ] + [ "$always_firewall" = "false" ] + [ "$ssh_forward" = "true" ] +} + # ── _ckipper_account_bare_alias_safe ───────────────────────────────────────── @test "bare_alias_safe returns 1 for shell builtin 'cd'" { From 8bbcbafadf35cf906e0aab1d8619d7cb456441ac Mon Sep 17 00:00:00 2001 From: Matt White Date: Fri, 1 May 2026 01:58:22 -0600 Subject: [PATCH 103/165] feat: add lib/core/style.zsh ANSI helpers Introduce a small ANSI styling module for ckipper CLI output. Provides _core_style_enabled, _core_style_color, _core_style_badge, _core_style_divider, _core_style_header, and _core_style_table. Color emission honors CKIPPER_FORCE_COLOR (test override), NO_COLOR, and TTY detection in that order. All helpers degrade to plain text when color is disabled. Tests cover the decision matrix, badge rendering in both modes, divider/header layout, and table column formatting (8 cases). --- lib/core/style.zsh | 126 +++++++++++++++++++++++++++++++++++++++ lib/core/style_test.bats | 108 +++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 lib/core/style.zsh create mode 100644 lib/core/style_test.bats diff --git a/lib/core/style.zsh b/lib/core/style.zsh new file mode 100644 index 0000000..a0fe913 --- /dev/null +++ b/lib/core/style.zsh @@ -0,0 +1,126 @@ +#!/usr/bin/env zsh +# ANSI color/style helpers for ckipper CLI output. +# +# All public helpers degrade gracefully when color is disabled (NO_COLOR set, +# or stdout is not a TTY). Tests pin behavior with CKIPPER_FORCE_COLOR=1, which +# overrides every other check so output is deterministic in non-TTY runs. +# +# Decision precedence in _core_style_enabled: +# 1. CKIPPER_FORCE_COLOR=1 → enabled (test override; wins over NO_COLOR). +# 2. NO_COLOR set non-empty → disabled (https://no-color.org). +# 3. stdout is a TTY → enabled. +# 4. otherwise → disabled. + +# Width of the horizontal rule drawn by _core_style_divider / _core_style_header. +readonly _CORE_STYLE_DIVIDER_WIDTH=72 + +# Column width used by _core_style_table for printf %-N s formatting. +readonly _CORE_STYLE_TABLE_COL_WIDTH=22 + +# ANSI reset sequence — emitted at the end of every colored span. +readonly _CORE_STYLE_RESET=$'\x1b[0m' + +# Map of friendly color/style names → ANSI SGR parameter codes. +# `gray` is mapped to bright-black (90) — true 8-color "gray" is rendered as +# bright-black on every terminal we care about. +typeset -gA _CORE_STYLE_COLOR_CODE=( + [red]=31 + [green]=32 + [yellow]=33 + [blue]=34 + [magenta]=35 + [cyan]=36 + [gray]=90 + [bold]=1 + [dim]=2 + [reset]=0 +) + +# Decide whether to emit ANSI color codes. +# +# Returns: 0 if color should be emitted; 1 otherwise. +_core_style_enabled() { + [[ "$CKIPPER_FORCE_COLOR" == "1" ]] && return 0 + [[ -n "$NO_COLOR" ]] && return 1 + [[ -t 1 ]] +} + +# Print text wrapped in an ANSI color escape, or plain text when color is disabled. +# +# Args: $1 — color name (must be a key of _CORE_STYLE_COLOR_CODE), +# $2 — text to wrap. +# Returns: 0 always; prints the (possibly colored) text followed by a newline. +_core_style_color() { + local name="$1" text="$2" + if ! _core_style_enabled; then + printf '%s\n' "$text" + return 0 + fi + local code="${_CORE_STYLE_COLOR_CODE[$name]}" + printf '\x1b[%sm%s%s\n' "$code" "$text" "$_CORE_STYLE_RESET" +} + +# Print a bracketed status badge in the given color (e.g. "[PASS]" in green). +# When color is disabled, prints "[