Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> **Platform:** macOS only — uses macOS Keychain, Docker Desktop, and host SSH agent forwarding.

Multi-account Claude Code manager with Docker isolation, per-account preferences, and worktree-aware launchers.
A lightweight CLI for managing Claude Code accounts, worktrees, and Docker sandboxes.

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.

Expand Down Expand Up @@ -45,7 +45,7 @@ cd Ckipper
| `ck setup` | Interactive wizard for configuring Ckipper |
| `ck run <project> <branch>` | Create-or-cd to a worktree, optionally Docker |
| `ck config get/set/unset/list/edit` | View and modify settings |
| `ck account add/list/default/remove/rename` | Manage Claude accounts |
| `ck account add/list/default/remove/rename/sync/redeploy-hooks` | Manage Claude accounts (see [Sync state between accounts](#sync-state-between-accounts)) |
| `ck worktree run/list/rm/rebuild-image` | Manage git worktrees |
| `ck doctor [--fix]` | Diagnose registry, hooks, schema; optionally repair |
| `ck` (no args) | Interactive launcher menu |
Expand Down Expand Up @@ -327,9 +327,9 @@ docker volume rm claude-uv-cache claude-uv-tools

The volumes are recreated automatically on the next container start.

## Known limitations
## Known limitations (Docker mode only)

These are inherent to running Claude Code inside a Docker container on macOS and cannot be fully resolved without upstream changes.
These apply only when you launch Claude Code inside the Ckipper Docker container (`ck run ... --docker`). On the host, these features work normally. They cannot be fully resolved without upstream changes.

### OAuth token expiry across host and container

Expand All @@ -343,9 +343,17 @@ 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
### Claude in Chrome MCP

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.
The [Claude in Chrome](https://www.anthropic.com/news/claude-for-chrome) browser extension cannot connect to Claude Code inside the container. The extension's discovery mechanism doesn't bridge the host/container boundary, so the extension reports "Browser extension is not connected." Reference: [#25506](https://github.com/anthropics/claude-code/issues/25506). Workaround: run Claude Code on the host (without `--docker`) when you need the Chrome extension.

### Custom system-sound hooks

User-written hooks that shell out to macOS audio/AppleScript binaries (`afplay`, `say`, `osascript`) won't work inside the container — the binaries don't exist on Linux and there's no host audio device. Ckipper's built-in `notify-bell.sh` sidesteps this by emitting the terminal bell character (`\a`), which most modern terminals translate into a system notification. If you author a hook that needs richer host-only notifications, gate it on `[ ! -f /.dockerenv ]` (the same idiom every built-in safety hook uses) so it no-ops in the container.

## Multi-account caveats (host and Docker)

These apply to the multi-account model in general — they're upstream Claude Code behavior, not Ckipper bugs, and they apply equally on the host and inside Docker. Ckipper papers over some of them; others you should know about.

### OAuth refresh token races (upstream)

Expand Down
9 changes: 7 additions & 2 deletions ckipper.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,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=7
CKIPPER_COMPLETION_VERSION=8
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
Expand All @@ -232,7 +232,7 @@ if [[ ! -f ~/.zsh/completions/_ckipper ]] \
# a completion file, not maintained shell logic).
cat > ~/.zsh/completions/_ckipper << 'COMPEOF'
#compdef ckipper ck
# ckipper-completion-version=7
# ckipper-completion-version=8

_ckipper() {
local projects_dir="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}"
Expand Down Expand Up @@ -328,6 +328,11 @@ _ckipper() {
fi
_describe -t accounts 'account name' accounts && return 0
;;
config/get|config/set|config/unset)
local -a config_keys
config_keys=( "${(@k)_CKIPPER_SCHEMA_TYPE}" )
_describe -t keys 'config key' config_keys && return 0
;;
esac
case "${words[2]}" in
run)
Expand Down
64 changes: 35 additions & 29 deletions lib/account/account-management.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ typeset -gA _CKIPPER_RENAME_CTX
_ckipper_account_add_validate_name() {
local name="$1"
if [[ -z "$name" ]]; then
echo "Usage: ckipper account add <name> [--adopt]"
echo "Usage: ckipper account add <name> [--adopt]" >&2
return 1
fi
if [[ ! "$name" =~ ^[a-z0-9_-]+$ ]]; then
echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)."
echo "Account name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." >&2
return 1
fi
if jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>/dev/null; then
echo "Account '$name' is already registered."
echo "Account '$name' is already registered." >&2
return 1
fi
}
Expand All @@ -47,7 +47,7 @@ _ckipper_account_add_validate_name() {
_ckipper_account_add_adopt_flow() {
local name="$1" dir="$2"
if [[ ! -d "$dir" ]]; then
echo "Cannot adopt: $dir does not exist."
echo "Cannot adopt: $dir does not exist." >&2
return 1
fi
local picked=""
Expand All @@ -60,8 +60,14 @@ _ckipper_account_add_adopt_flow() {
_ckipper_account_finalize_registration "adopt"
}

# Sentinel item shown alongside Keychain candidates so the picker has an
# explicit "skip" choice (consistent with _core_prompt_choose's no-empty-on-cancel
# semantics).
readonly _CKIPPER_ACCOUNT_KEYCHAIN_SKIP_LABEL="(skip — register without Keychain entry)"

# 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).
# On return, the nameref variable (arg $2) holds the chosen service (may be empty
# if the user picked the skip sentinel).
#
# Args:
# $1 — account name (for error messages)
Expand All @@ -75,16 +81,16 @@ _ckipper_account_add_pick_keychain_entry() {
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"
local -a items
items=( ${(f)candidates} "$_CKIPPER_ACCOUNT_KEYCHAIN_SKIP_LABEL" )
local picked
picked=$(_core_prompt_choose "Pick a Keychain entry for '$name'" "${items[@]}")
[[ -z "$picked" || "$picked" == "$_CKIPPER_ACCOUNT_KEYCHAIN_SKIP_LABEL" ]] && return 0
if ! _core_keychain_validate "$picked"; then
echo "Invalid Keychain service shape: $picked" >&2
return 1
fi
_picked_ref="$picked"
}

# Run the fresh registration flow: create the dir, deploy hooks, launch Claude, detect new keychain.
Expand All @@ -98,7 +104,7 @@ _ckipper_account_add_pick_keychain_entry() {
_ckipper_account_add_fresh_flow() {
local name="$1" dir="$2"
if [[ -d "$dir" ]]; then
echo "Directory $dir already exists. Use --adopt to register it."
echo "Directory $dir already exists. Use --adopt to register it." >&2
return 1
fi
mkdir -p "$dir/hooks"
Expand Down Expand Up @@ -170,8 +176,8 @@ _ckipper_account_add_check_credentials() {
local name="$1" dir="$2" new_service="$3"
if [[ -n "$new_service" ]]; then
if ! _core_keychain_validate "$new_service"; then
echo "Detected entry has unexpected shape: $new_service"
echo "Refusing to register. Use --adopt to register manually."
echo "Detected entry has unexpected shape: $new_service" >&2
echo "Refusing to register. Use --adopt to register manually." >&2
return 1
fi
echo "Detected new Keychain entry: $new_service"
Expand All @@ -181,8 +187,8 @@ _ckipper_account_add_check_credentials() {
echo "No new Keychain entry, but $dir/.credentials.json exists — proceeding with on-disk 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 account add $name --adopt"
echo "Warning: no new Keychain entry detected and no .credentials.json on disk." >&2
echo "Login may not have completed. Re-run /login or use: ckipper account add $name --adopt" >&2
return 1
}

Expand Down Expand Up @@ -395,9 +401,9 @@ _ckipper_account_list_row() {
_ckipper_account_default() {
_core_registry_check_version || return 1
local name="$1"
[[ -z "$name" ]] && { echo "Usage: ckipper account default <name>"; return 1; }
[[ -z "$name" ]] && { echo "Usage: ckipper account default <name>" >&2; return 1; }
if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then
echo "Account '$name' is not registered."
echo "Account '$name' is not registered." >&2
return 1
fi
_core_registry_update '.default = $n' --arg n "$name"
Expand All @@ -416,9 +422,9 @@ _ckipper_account_default() {
_ckipper_account_remove() {
_core_registry_check_version || return 1
local name="$1"
[[ -z "$name" ]] && { echo "Usage: ckipper account remove <name>"; return 1; }
[[ -z "$name" ]] && { echo "Usage: ckipper account remove <name>" >&2; return 1; }
if ! jq -e --arg n "$name" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null; then
echo "Account '$name' is not registered."
echo "Account '$name' is not registered." >&2
return 1
fi
local dir; dir=$(jq -r --arg n "$name" '.accounts[$n].config_dir' "$CKIPPER_REGISTRY")
Expand All @@ -444,23 +450,23 @@ _ckipper_account_remove() {
_ckipper_account_rename_validate() {
local old="$1" new="$2"
if [[ -z "$old" || -z "$new" ]]; then
echo "Usage: ckipper account rename <old> <new>"
echo "Usage: ckipper account rename <old> <new>" >&2
return 1
fi
if [[ ! "$new" =~ ^[a-z0-9_-]+$ ]]; then
echo "New name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)."
echo "New name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." >&2
return 1
fi
if [[ "$old" == "$new" ]]; then
echo "Old and new name are the same. Nothing to do."
echo "Old and new name are the same. Nothing to do." >&2
return 1
fi
if ! jq -e --arg n "$old" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then
echo "Account '$old' is not registered."
echo "Account '$old' is not registered." >&2
return 1
fi
if jq -e --arg n "$new" '.accounts[$n]' "$CKIPPER_REGISTRY" >/dev/null 2>&1; then
echo "Account '$new' is already registered."
echo "Account '$new' is already registered." >&2
return 1
fi
}
Expand All @@ -475,11 +481,11 @@ _ckipper_account_rename_validate() {
_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."
echo "Error: $new_dir already exists. Pick a different name or remove it first." >&2
return 1
fi
if [[ ! -d "$old_dir" ]]; then
echo "Error: source directory $old_dir does not exist."
echo "Error: source directory $old_dir does not exist." >&2
return 1
fi
_core_assert_no_running_claude || return 1
Expand Down
4 changes: 2 additions & 2 deletions lib/account/aliases.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Alias generation and install-hook redeploy subcommands:
# regenerate_aliases, redeploy_hooks_for, redeploy_hooks.

readonly ALIASES_FILE_PERMS=644
readonly _CKIPPER_ACCOUNT_ALIASES_FILE_PERMS=644

# Write a single account's launcher function lines to the aliases file being built.
# Generates a `claude-<name>` function and, if safe, a bare `<name>` shortcut.
Expand Down Expand Up @@ -82,7 +82,7 @@ _ckipper_account_regenerate_aliases() {
} > "$out.tmp"
# Atomic install — readers in other shells never see a partial file.
mv "$out.tmp" "$out"
chmod "$ALIASES_FILE_PERMS" "$out"
chmod "$_CKIPPER_ACCOUNT_ALIASES_FILE_PERMS" "$out"
# Re-source in the calling shell so newly-registered accounts are usable
# immediately without the user having to `exec zsh`.
source "$out"
Expand Down
60 changes: 56 additions & 4 deletions lib/account/doctor.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# plugin metadata has stale ~/.claude/ paths. The repair functions kept their
# original names so their existing tests work unchanged.

readonly MIN_HOOK_FILES=4
readonly _CKIPPER_DOCTOR_MIN_HOOK_FILES=4

# Module-level counters shared across all doctor helpers.
typeset -g _CKIPPER_DOCTOR_FAIL=0
Expand Down Expand Up @@ -49,13 +49,28 @@ _ckipper_doctor_tooling() {
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 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"
if [[ -d "$CKIPPER_DIR/hooks" ]] && (( $(ls -1 "$CKIPPER_DIR/hooks" 2>/dev/null | wc -l) >= _CKIPPER_DOCTOR_MIN_HOOK_FILES )); then
_ckipper_doctor_check PASS "hooks/ has ${_CKIPPER_DOCTOR_MIN_HOOK_FILES}+ files"
else
_ckipper_doctor_check WARN "hooks/ is missing or has fewer than $MIN_HOOK_FILES hook files"
_ckipper_doctor_check WARN "hooks/ is missing or has fewer than $_CKIPPER_DOCTOR_MIN_HOOK_FILES hook files"
fi
_ckipper_doctor_check_stale_w_vars
_ckipper_doctor_check_config_keys
_ckipper_doctor_check_gum
}

# Check that gum (charmbracelet/gum) is on PATH. Gum is a hard prereq for the
# setup wizard, sync wizard, and every interactive picker; without it ckipper
# falls back to read-prompt mode but loses keybindings, multi-select, and the
# spinner. Surface a missing install loudly so the user runs `brew install gum`.
#
# Returns: 0 always (results printed via _ckipper_doctor_check).
_ckipper_doctor_check_gum() {
if command -v gum >/dev/null 2>&1; then
_ckipper_doctor_check PASS "gum on PATH"
else
_ckipper_doctor_check FAIL "gum not on PATH — install via 'brew install gum'"
fi
}

# Detect pre-merge W_* variable assignments in ckipper-config.zsh.
Expand Down Expand Up @@ -436,6 +451,41 @@ _ckipper_doctor_accounts() {
done <<< "$names"
}

# Verify the generated aliases.zsh parses cleanly with `zsh -n`. A broken
# aliases file can land if disk fills mid-write or jq emits unexpected
# characters — the calling shell's `source` then prints errors and may leave
# the launcher functions undefined.
#
# Returns: 0 always (results printed via _ckipper_doctor_check).
_ckipper_doctor_check_aliases_parse() {
local f="$CKIPPER_DIR/aliases.zsh"
[[ -f "$f" ]] || return 0
if zsh -n "$f" 2>/dev/null; then
_ckipper_doctor_check PASS "aliases.zsh parses cleanly"
else
_ckipper_doctor_check FAIL "aliases.zsh has parse errors — regenerate via 'ckipper account add/remove' or re-run install.sh"
fi
}

# Check that the cached completion file matches the current
# CKIPPER_COMPLETION_VERSION. Stale files don't break ckipper but produce
# outdated tab-completions until the user starts a new zsh shell that re-runs
# the heredoc-regen block at the bottom of ckipper.zsh.
#
# Returns: 0 always (results printed via _ckipper_doctor_check).
_ckipper_doctor_check_completion() {
local cf="$HOME/.zsh/completions/_ckipper"
if [[ ! -f "$cf" ]]; then
_ckipper_doctor_check WARN "completion file ~/.zsh/completions/_ckipper missing — start a new zsh shell to regenerate"
return 0
fi
if grep -q "# ckipper-completion-version=$CKIPPER_COMPLETION_VERSION" "$cf" 2>/dev/null; then
_ckipper_doctor_check PASS "completion file matches version $CKIPPER_COMPLETION_VERSION"
else
_ckipper_doctor_check WARN "completion file is stale (expected version $CKIPPER_COMPLETION_VERSION) — start a new zsh shell to regenerate"
fi
}

# Check aliases.zsh and .zshrc integration lines, plus stub dir/file presence.
#
# Returns:
Expand All @@ -445,10 +495,12 @@ _ckipper_doctor_shell() {
_core_style_header "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
_ckipper_doctor_check_aliases_parse
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/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
_ckipper_doctor_check_completion
echo ""
_core_style_header "Stub files (cosmetic)"
if [[ -d "$HOME/.claude" ]]; then
Expand Down
Loading
Loading