From 60a5b722bfdc379a5dfc67c58f05d684f96ec65d Mon Sep 17 00:00:00 2001 From: Matt White Date: Sun, 3 May 2026 21:30:39 -0600 Subject: [PATCH 1/4] fix(account): print errors and usage to stderr; modernize keychain picker Two fixes in lib/account/account-management.zsh: 1. Account validation/usage messages ("Account 'foo' is already registered.", "Usage: ckipper account remove ", etc.) printed to stdout. Other modules (lib/config, lib/worktree, doctor.zsh) already use stderr, so `ckipper account remove foo 2>/dev/null` silenced success but leaked the failure path. Routed every error path through `>&2`. 2. The Keychain entry picker used by `ckipper account add --adopt` hand-rolled `nl + read -r` instead of using _core_prompt_choose. Switched to _core_prompt_choose with an explicit "(skip)" sentinel so gum/no-gum routing matches every other interactive picker. --- lib/account/account-management.zsh | 64 ++++++++++++++++-------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/lib/account/account-management.zsh b/lib/account/account-management.zsh index 57ad8b5..4b0cf6f 100644 --- a/lib/account/account-management.zsh +++ b/lib/account/account-management.zsh @@ -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 [--adopt]" + echo "Usage: ckipper account add [--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 } @@ -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="" @@ -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) @@ -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. @@ -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" @@ -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" @@ -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 } @@ -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 "; return 1; } + [[ -z "$name" ]] && { echo "Usage: ckipper account default " >&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" @@ -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 "; return 1; } + [[ -z "$name" ]] && { echo "Usage: ckipper account remove " >&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") @@ -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 " + echo "Usage: ckipper account rename " >&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 } @@ -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 From 9894aaf25c6b042c03cf150e7b9dc2cfdcfb3bac Mon Sep 17 00:00:00 2001 From: Matt White Date: Sun, 3 May 2026 21:30:50 -0600 Subject: [PATCH 2/4] refactor: standardize prompt helpers and module-constant prefixes Two consistency cleanups left over from the sync overhaul: 1. Three sites bypassed _core_prompt_*: lib/account/sync/interactive.zsh used raw `read -r "var?label"` for two free-form prompts, and lib/setup/prompts.zsh:98 used raw `read -r` for a y/N. All three now route through _core_prompt_input / _core_prompt_confirm. Same behavior in no-gum mode; future helper-layer changes propagate automatically. 2. Module-level globals in lib/account/sync/{dispatcher,engine,preview} used the bare _SYNC_* prefix. Every other module uses _CKIPPER_* per .claude/rules/shell-conventions.md. Renamed the eight dispatcher state vars (_SYNC_FROM, _SYNC_TARGETS, _SYNC_INCLUDE, _SYNC_EXCLUDE, _SYNC_DRY_RUN, _SYNC_YES, _SYNC_FORCE, _SYNC_CTX) plus the unprefixed ALIASES_FILE_PERMS readonly in aliases.zsh. Tests updated. Verified zero leftover _SYNC_* references via grep. --- lib/account/aliases.zsh | 4 +- lib/account/sync/dispatcher.zsh | 110 +++++++++++++------------- lib/account/sync/dispatcher_test.bats | 36 ++++----- lib/account/sync/engine.zsh | 24 +++--- lib/account/sync/interactive.zsh | 4 +- lib/account/sync/preview.zsh | 14 ++-- lib/setup/prompts.zsh | 4 +- 7 files changed, 97 insertions(+), 99 deletions(-) diff --git a/lib/account/aliases.zsh b/lib/account/aliases.zsh index 9fdc0f9..a638e33 100644 --- a/lib/account/aliases.zsh +++ b/lib/account/aliases.zsh @@ -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-` function and, if safe, a bare `` shortcut. @@ -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" diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index 8198f2b..3be224a 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -8,13 +8,13 @@ # - lib/account/sync/registry.zsh (resolve_includes, type validation) # - lib/core/registry.zsh (_core_account_dir) -typeset -g _SYNC_FROM="" -typeset -ga _SYNC_TARGETS=() -typeset -g _SYNC_INCLUDE="" -typeset -g _SYNC_EXCLUDE="" -typeset -g _SYNC_DRY_RUN="false" -typeset -g _SYNC_YES="false" -typeset -g _SYNC_FORCE="false" +typeset -g _CKIPPER_SYNC_FROM="" +typeset -ga _CKIPPER_SYNC_TARGETS=() +typeset -g _CKIPPER_SYNC_INCLUDE="" +typeset -g _CKIPPER_SYNC_EXCLUDE="" +typeset -g _CKIPPER_SYNC_DRY_RUN="false" +typeset -g _CKIPPER_SYNC_YES="false" +typeset -g _CKIPPER_SYNC_FORCE="false" # Per-target sync context. Populated once per target by run_one_target (and # augmented by apply_target with backup_dir). Engine/preview/finalize helpers @@ -26,7 +26,7 @@ typeset -g _SYNC_FORCE="false" # declaration (sourced first in production) keeps any state set there; # reset between sync invocations is handled by reset_args, not by the # declaration line. -typeset -gA _SYNC_CTX +typeset -gA _CKIPPER_SYNC_CTX # Reset all module-level _SYNC_* holders. Called at the top of every # parse_args invocation so re-running the dispatcher in the same shell @@ -34,21 +34,21 @@ typeset -gA _SYNC_CTX # # Returns: 0 always. _ckipper_account_sync_reset_args() { - _SYNC_FROM=""; _SYNC_TARGETS=() - _SYNC_INCLUDE=""; _SYNC_EXCLUDE="" - _SYNC_DRY_RUN="false"; _SYNC_YES="false"; _SYNC_FORCE="false" - _SYNC_CTX=() + _CKIPPER_SYNC_FROM=""; _CKIPPER_SYNC_TARGETS=() + _CKIPPER_SYNC_INCLUDE=""; _CKIPPER_SYNC_EXCLUDE="" + _CKIPPER_SYNC_DRY_RUN="false"; _CKIPPER_SYNC_YES="false"; _CKIPPER_SYNC_FORCE="false" + _CKIPPER_SYNC_CTX=() } -# Append a positional arg to _SYNC_FROM (first time) or _SYNC_TARGETS (every +# Append a positional arg to _CKIPPER_SYNC_FROM (first time) or _CKIPPER_SYNC_TARGETS (every # subsequent positional). Extracted from parse_args so the case body stays # at 2 levels of nesting per .claude/rules/code-style.md. # # Args: $1 — positional value. # Returns: 0 always. _ckipper_account_sync_accumulate_positional() { - [[ -z "$_SYNC_FROM" ]] && { _SYNC_FROM="$1"; return 0; } - _SYNC_TARGETS+=("$1") + [[ -z "$_CKIPPER_SYNC_FROM" ]] && { _CKIPPER_SYNC_FROM="$1"; return 0; } + _CKIPPER_SYNC_TARGETS+=("$1") } # Parse `ckipper account sync` arguments into module-level _SYNC_* vars. @@ -61,11 +61,11 @@ _ckipper_account_sync_parse_args() { _ckipper_account_sync_reset_args while [[ $# -gt 0 ]]; do case "$1" in - --include) _SYNC_INCLUDE="$2"; shift 2 ;; - --exclude) _SYNC_EXCLUDE="$2"; shift 2 ;; - --dry-run) _SYNC_DRY_RUN="true"; shift ;; - --yes) _SYNC_YES="true"; shift ;; - --force) _SYNC_FORCE="true"; shift ;; + --include) _CKIPPER_SYNC_INCLUDE="$2"; shift 2 ;; + --exclude) _CKIPPER_SYNC_EXCLUDE="$2"; shift 2 ;; + --dry-run) _CKIPPER_SYNC_DRY_RUN="true"; shift ;; + --yes) _CKIPPER_SYNC_YES="true"; shift ;; + --force) _CKIPPER_SYNC_FORCE="true"; shift ;; -h|--help) _ckipper_account_sync_help_text; return 2 ;; --*) echo "Unknown flag: $1" >&2; return 1 ;; *) _ckipper_account_sync_accumulate_positional "$1"; shift ;; @@ -97,12 +97,12 @@ _ckipper_account_sync_dispatch() { # # Returns: 0 on success across all targets; 1 if any target failed. _ckipper_account_sync_run() { - if [[ -z "$_SYNC_FROM" ]]; then - _SYNC_FROM=$(_ckipper_account_sync_pick_source) || return 1 + if [[ -z "$_CKIPPER_SYNC_FROM" ]]; then + _CKIPPER_SYNC_FROM=$(_ckipper_account_sync_pick_source) || return 1 fi - if (( ${#_SYNC_TARGETS} == 0 )); then - _SYNC_TARGETS=( ${(f)"$(_ckipper_account_sync_pick_targets "$_SYNC_FROM")"} ) - (( ${#_SYNC_TARGETS} == 0 )) && return 1 + if (( ${#_CKIPPER_SYNC_TARGETS} == 0 )); then + _CKIPPER_SYNC_TARGETS=( ${(f)"$(_ckipper_account_sync_pick_targets "$_CKIPPER_SYNC_FROM")"} ) + (( ${#_CKIPPER_SYNC_TARGETS} == 0 )) && return 1 fi _ckipper_account_sync_validate_accounts || return 1 local -a types @@ -119,8 +119,8 @@ _ckipper_account_sync_run_targets() { local types_var="$1" local -a types_local; types_local=( "${(@P)types_var}" ) local rc=0 target - for target in "${_SYNC_TARGETS[@]}"; do - _ckipper_account_sync_validate_pair "$_SYNC_FROM" "$target" || { rc=1; continue; } + for target in "${_CKIPPER_SYNC_TARGETS[@]}"; do + _ckipper_account_sync_validate_pair "$_CKIPPER_SYNC_FROM" "$target" || { rc=1; continue; } _ckipper_account_sync_run_one_target "$target" "${types_local[@]}" || rc=1 done return $rc @@ -130,9 +130,9 @@ _ckipper_account_sync_run_targets() { # # Returns: 0 if all registered; 1 if any unknown (error printed by _core_account_dir). _ckipper_account_sync_validate_accounts() { - _core_account_dir "$_SYNC_FROM" >/dev/null || return 1 + _core_account_dir "$_CKIPPER_SYNC_FROM" >/dev/null || return 1 local t - for t in "${_SYNC_TARGETS[@]}"; do + for t in "${_CKIPPER_SYNC_TARGETS[@]}"; do _core_account_dir "$t" >/dev/null || return 1 done } @@ -142,12 +142,12 @@ _ckipper_account_sync_validate_accounts() { # # Returns: 0; prints type ids one per line. _ckipper_account_sync_resolve_types() { - if [[ -z "$_SYNC_INCLUDE" && "$_SYNC_YES" != "true" && "$_SYNC_DRY_RUN" != "true" ]]; then + if [[ -z "$_CKIPPER_SYNC_INCLUDE" && "$_CKIPPER_SYNC_YES" != "true" && "$_CKIPPER_SYNC_DRY_RUN" != "true" ]]; then _ckipper_account_sync_pick_types return 0 fi - [[ -z "$_SYNC_INCLUDE" ]] && _SYNC_INCLUDE="all" - _ckipper_account_sync_resolve_includes "$_SYNC_INCLUDE" "$_SYNC_EXCLUDE" + [[ -z "$_CKIPPER_SYNC_INCLUDE" ]] && _CKIPPER_SYNC_INCLUDE="all" + _ckipper_account_sync_resolve_includes "$_CKIPPER_SYNC_INCLUDE" "$_CKIPPER_SYNC_EXCLUDE" } # One-target slice: build change set, render preview, prompt, apply. @@ -157,24 +157,24 @@ _ckipper_account_sync_resolve_types() { _ckipper_account_sync_run_one_target() { local target="$1"; shift local src_dir dst_dir - src_dir=$(_core_account_dir "$_SYNC_FROM") + src_dir=$(_core_account_dir "$_CKIPPER_SYNC_FROM") dst_dir=$(_core_account_dir "$target") # Dry-run is read-only; the running-Claude refusal exists to prevent # races with writes, so let preview-only invocations through. - if [[ "$_SYNC_DRY_RUN" != "true" ]]; then - _ckipper_account_sync_assert_dst_idle "$dst_dir" "$_SYNC_FORCE" || return 1 + if [[ "$_CKIPPER_SYNC_DRY_RUN" != "true" ]]; then + _ckipper_account_sync_assert_dst_idle "$dst_dir" "$_CKIPPER_SYNC_FORCE" || return 1 fi _ckipper_account_sync_prepare_target_artifacts "$target" "$src_dir" "$dst_dir" "$@" local action="apply" - if [[ "$_SYNC_DRY_RUN" != "true" && "$_SYNC_YES" != "true" ]]; then + if [[ "$_CKIPPER_SYNC_DRY_RUN" != "true" && "$_CKIPPER_SYNC_YES" != "true" ]]; then action=$(_ckipper_account_sync_preview_prompt) fi - [[ "$_SYNC_DRY_RUN" == "true" ]] && action="dry-run" + [[ "$_CKIPPER_SYNC_DRY_RUN" == "true" ]] && action="dry-run" _ckipper_account_sync_finalize "$action" } # Initialize per-target context and build the diff/summary artifacts. -# Steps: mktemp the changeset/summaries/items tmpfiles, populate _SYNC_CTX, +# Steps: mktemp the changeset/summaries/items tmpfiles, populate _CKIPPER_SYNC_CTX, # build the changeset and summaries TSVs, the drill-down items file, then # render the preview block. # @@ -185,15 +185,15 @@ _ckipper_account_sync_prepare_target_artifacts() { shift 3 local changeset summaries items changeset=$(mktemp); summaries=$(mktemp); items=$(mktemp) - _SYNC_CTX=( + _CKIPPER_SYNC_CTX=( src_dir "$src_dir" dst_dir "$dst_dir" - src_name "$_SYNC_FROM" dst_name "$target" + src_name "$_CKIPPER_SYNC_FROM" dst_name "$target" changeset "$changeset" summaries "$summaries" items "$items" ) _ckipper_account_sync_build_change_set "$src_dir" "$dst_dir" \ - "$_SYNC_FROM" "$target" "$@" > "$changeset" + "$_CKIPPER_SYNC_FROM" "$target" "$@" > "$changeset" _ckipper_account_sync_build_summaries "$src_dir" "$dst_dir" \ - "$_SYNC_FROM" "$target" < "$changeset" > "$summaries" + "$_CKIPPER_SYNC_FROM" "$target" < "$changeset" > "$summaries" _ckipper_account_sync_drill_down_items < "$changeset" > "$items" _ckipper_account_sync_show_preview "$target" "$dst_dir" "$changeset" "$summaries" } @@ -205,8 +205,8 @@ _ckipper_account_sync_prepare_target_artifacts() { _ckipper_account_sync_show_preview() { local target="$1" dst_dir="$2" changeset="$3" summaries="$4" local backup_path - backup_path=$(_ckipper_account_sync_backup_dir_path "$dst_dir" "$_SYNC_FROM") - _ckipper_account_sync_render_summary "$_SYNC_FROM" "$target" \ + backup_path=$(_ckipper_account_sync_backup_dir_path "$dst_dir" "$_CKIPPER_SYNC_FROM") + _ckipper_account_sync_render_summary "$_CKIPPER_SYNC_FROM" "$target" \ "$backup_path" "$summaries" < "$changeset" local counts; counts=$(_ckipper_account_sync_count_changes < "$changeset") local total new ow; read -r total new ow <<< "$counts" @@ -216,7 +216,7 @@ _ckipper_account_sync_show_preview() { # Apply / View changes / Abort prompt, with View looping back through # the drill-down picker until Apply or Abort. # -# Reads _SYNC_CTX[dst_name] for the prompt label. Callers capture this +# Reads _CKIPPER_SYNC_CTX[dst_name] for the prompt label. Callers capture this # function's stdout via $() to read the action token, so two precautions # keep "$action" clean: (1) drill_down_loop is redirected to stderr — its # diff output and "press enter" prompts are user-facing terminal output, @@ -227,7 +227,7 @@ _ckipper_account_sync_show_preview() { # # Returns: 0; prints "apply" | "abort". _ckipper_account_sync_preview_prompt() { - local target="${_SYNC_CTX[dst_name]}" + local target="${_CKIPPER_SYNC_CTX[dst_name]}" local choice="" while true; do choice=$(_core_prompt_choose "Apply changes to $target?" "Apply" "View changes" "Abort") @@ -240,7 +240,7 @@ _ckipper_account_sync_preview_prompt() { } # Run the chosen action, clean up tmpfiles, return the apply rc. Reads the -# tmpfile paths and apply args from _SYNC_CTX. +# tmpfile paths and apply args from _CKIPPER_SYNC_CTX. # # Args: $1 — action. # Returns: 0 unless action=apply and the apply failed. @@ -249,12 +249,12 @@ _ckipper_account_sync_finalize() { local rc=0 if [[ "$action" == "apply" ]]; then _ckipper_account_sync_apply_target \ - "${_SYNC_CTX[src_dir]}" "${_SYNC_CTX[dst_dir]}" \ - "${_SYNC_CTX[src_name]}" "${_SYNC_CTX[dst_name]}" \ - < "${_SYNC_CTX[changeset]}" + "${_CKIPPER_SYNC_CTX[src_dir]}" "${_CKIPPER_SYNC_CTX[dst_dir]}" \ + "${_CKIPPER_SYNC_CTX[src_name]}" "${_CKIPPER_SYNC_CTX[dst_name]}" \ + < "${_CKIPPER_SYNC_CTX[changeset]}" rc=$? fi - rm -f "${_SYNC_CTX[changeset]}" "${_SYNC_CTX[summaries]}" "${_SYNC_CTX[items]}" + rm -f "${_CKIPPER_SYNC_CTX[changeset]}" "${_CKIPPER_SYNC_CTX[summaries]}" "${_CKIPPER_SYNC_CTX[items]}" return $rc } @@ -290,12 +290,12 @@ _ckipper_account_sync_help_text() { # Undo subcommand dispatcher. # -# Force is read from a LOCAL var, not the module-level _SYNC_FORCE. The -# previous design read _SYNC_FORCE directly, which leaked across -# invocations: a prior `sync ... --force` left _SYNC_FORCE="true" in the +# Force is read from a LOCAL var, not the module-level _CKIPPER_SYNC_FORCE. The +# previous design read _CKIPPER_SYNC_FORCE directly, which leaked across +# invocations: a prior `sync ... --force` left _CKIPPER_SYNC_FORCE="true" in the # shell, and a subsequent `sync undo` (without --force) inherited it, # silently bypassing the running-Claude refusal. parse_args resets -# _SYNC_FORCE on the sync path, but the undo path skips parse_args. +# _CKIPPER_SYNC_FORCE on the sync path, but the undo path skips parse_args. # # Args: $1 — account name; flags: --pick | --list | --force. # Returns: 0 on success; 1 on user-visible failure. diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats index 546948e..7184b09 100644 --- a/lib/account/sync/dispatcher_test.bats +++ b/lib/account/sync/dispatcher_test.bats @@ -15,9 +15,9 @@ run_in_zsh() { @test "parse_args identifies --dry-run flag" { run_in_zsh ' _ckipper_account_sync_parse_args personal work --dry-run - echo "from=$_SYNC_FROM" - echo "targets=${_SYNC_TARGETS[*]}" - echo "dry_run=$_SYNC_DRY_RUN"' + echo "from=$_CKIPPER_SYNC_FROM" + echo "targets=${_CKIPPER_SYNC_TARGETS[*]}" + echo "dry_run=$_CKIPPER_SYNC_DRY_RUN"' [[ "$output" == *"from=personal"* ]] [[ "$output" == *"targets=work"* ]] [[ "$output" == *"dry_run=true"* ]] @@ -26,29 +26,29 @@ run_in_zsh() { @test "parse_args identifies --yes flag" { run_in_zsh ' _ckipper_account_sync_parse_args personal work --yes - echo "yes=$_SYNC_YES"' + echo "yes=$_CKIPPER_SYNC_YES"' [[ "$output" == *"yes=true"* ]] } @test "parse_args identifies multiple targets" { run_in_zsh ' _ckipper_account_sync_parse_args personal work client1 client2 - echo "targets=${(j:,:)_SYNC_TARGETS}"' + echo "targets=${(j:,:)_CKIPPER_SYNC_TARGETS}"' [[ "$output" == *"targets=work,client1,client2"* ]] } @test "parse_args identifies --include with comma list" { run_in_zsh ' _ckipper_account_sync_parse_args personal work --include mcp,settings - echo "include=$_SYNC_INCLUDE"' + echo "include=$_CKIPPER_SYNC_INCLUDE"' [[ "$output" == *"include=mcp,settings"* ]] } @test "parse_args identifies --exclude with comma list" { run_in_zsh ' _ckipper_account_sync_parse_args personal work --include all --exclude prefs - echo "include=$_SYNC_INCLUDE" - echo "exclude=$_SYNC_EXCLUDE"' + echo "include=$_CKIPPER_SYNC_INCLUDE" + echo "exclude=$_CKIPPER_SYNC_EXCLUDE"' [[ "$output" == *"include=all"* ]] [[ "$output" == *"exclude=prefs"* ]] } @@ -56,7 +56,7 @@ run_in_zsh() { @test "parse_args identifies --force" { run_in_zsh ' _ckipper_account_sync_parse_args personal work --force - echo "force=$_SYNC_FORCE"' + echo "force=$_CKIPPER_SYNC_FORCE"' [[ "$output" == *"force=true"* ]] } @@ -68,8 +68,8 @@ run_in_zsh() { @test "parse_args allows empty positionals (drop-to-picker)" { run_in_zsh ' _ckipper_account_sync_parse_args - echo "from=${_SYNC_FROM:-EMPTY}" - echo "n_targets=${#_SYNC_TARGETS[@]}"' + echo "from=${_CKIPPER_SYNC_FROM:-EMPTY}" + echo "n_targets=${#_CKIPPER_SYNC_TARGETS[@]}"' [[ "$output" == *"from=EMPTY"* ]] [[ "$output" == *"n_targets=0"* ]] } @@ -148,16 +148,16 @@ run_full() { [ "$status" -ne 0 ] } -# Bug B: undo_dispatch used to read $_SYNC_FORCE directly, which leaked +# Bug B: undo_dispatch used to read $_CKIPPER_SYNC_FORCE directly, which leaked # state from a prior sync invocation in the same shell. A user who ran -# `sync ... --force` (setting _SYNC_FORCE=true) and then ran `sync undo` +# `sync ... --force` (setting _CKIPPER_SYNC_FORCE=true) and then ran `sync undo` # without --force would silently bypass the running-Claude refusal because -# parse_args resets _SYNC_FORCE only on the sync path. Fix: undo uses a +# parse_args resets _CKIPPER_SYNC_FORCE only on the sync path. Fix: undo uses a # local force var, defaulting to false. @test "sync undo does NOT inherit --force from a prior sync invocation (Bug B)" { setup_two_accounts run_full ' - # Apply a sync first WITH --force so _SYNC_FORCE leaks to module state. + # Apply a sync first WITH --force so _CKIPPER_SYNC_FORCE leaks to module state. _core_running_claude_processes() { return 0; } ckipper account sync src dst --include mcp --yes --force >/dev/null 2>&1 # Now undo without --force; running Claude should block it again. @@ -186,10 +186,10 @@ run_full() { # choice=$(...) call inside preview_prompt opens a fresh subshell. @test "preview_prompt View changes then Apply yields exactly 'apply'" { run_in_zsh ' - _SYNC_FROM=src + _CKIPPER_SYNC_FROM=src items=$(mktemp); echo "x" > "$items" - _SYNC_CTX[dst_name]=dst - _SYNC_CTX[items]=$items + _CKIPPER_SYNC_CTX[dst_name]=dst + _CKIPPER_SYNC_CTX[items]=$items export _PROMPT_FLAG=$(mktemp) _core_prompt_choose() { if [[ -e "$_PROMPT_FLAG" ]]; then diff --git a/lib/account/sync/engine.zsh b/lib/account/sync/engine.zsh index 8e75db8..1329e41 100644 --- a/lib/account/sync/engine.zsh +++ b/lib/account/sync/engine.zsh @@ -44,7 +44,7 @@ # matching declaration doesn't reset state. Keys: src_dir, dst_dir, # src_name, dst_name, backup_dir (and from dispatcher: changeset, summaries, # items). -typeset -gA _SYNC_CTX +typeset -gA _CKIPPER_SYNC_CTX # Compute the strategy function name for a (type, verb) pair. # @@ -138,13 +138,13 @@ _ckipper_account_sync_walk_type() { } # Apply a change set to a single target. Steps: -# 1. Create backup dir + manifest, populate _SYNC_CTX[backup_dir]. +# 1. Create backup dir + manifest, populate _CKIPPER_SYNC_CTX[backup_dir]. # 2. For each change, call the strategy's apply (which itself calls # _ckipper_account_sync_backup_file before writing). # 3. On any failure: roll back via _ckipper_account_sync_rollback_target, # print the partial manifest's path, and return non-zero. # -# Also (re)populates _SYNC_CTX with the four name/dir args so the function +# Also (re)populates _CKIPPER_SYNC_CTX with the four name/dir args so the function # is callable on its own (engine_test.bats invokes it directly without # going through run_one_target). # @@ -158,9 +158,9 @@ _ckipper_account_sync_apply_target() { local backup_dir backup_dir=$(_ckipper_account_sync_backup_create "$dst_dir" "$src_name") _ckipper_account_sync_manifest_init "$backup_dir" "$src_name" "$dst_name" - _SYNC_CTX[src_dir]="$src_dir"; _SYNC_CTX[dst_dir]="$dst_dir" - _SYNC_CTX[src_name]="$src_name"; _SYNC_CTX[dst_name]="$dst_name" - _SYNC_CTX[backup_dir]="$backup_dir" + _CKIPPER_SYNC_CTX[src_dir]="$src_dir"; _CKIPPER_SYNC_CTX[dst_dir]="$dst_dir" + _CKIPPER_SYNC_CTX[src_name]="$src_name"; _CKIPPER_SYNC_CTX[dst_name]="$dst_name" + _CKIPPER_SYNC_CTX[backup_dir]="$backup_dir" local rc=0 type id display change_status while IFS=$'\t' read -r type id display change_status; do [[ -z "$type" || "$change_status" == "unchanged" ]] && continue @@ -177,7 +177,7 @@ _ckipper_account_sync_apply_target() { # the manifest schema. Types in _CKIPPER_SYNC_TYPE_USES_NAMES use names; # everything else uses dirs. # -# Reads src_dir/dst_dir/src_name/dst_name/backup_dir from _SYNC_CTX (set +# Reads src_dir/dst_dir/src_name/dst_name/backup_dir from _CKIPPER_SYNC_CTX (set # by apply_target). Keeping these in context drops the parameter count # from 8 to 3, satisfying the .claude/rules/code-style.md cap. # @@ -198,13 +198,13 @@ _ckipper_account_sync_apply_target() { _ckipper_account_sync_apply_one() { local type="$1" id="$2" local apply_fn; apply_fn=$(_ckipper_account_sync_strategy_fn "$type" apply) - local arg_a="${_SYNC_CTX[src_dir]}" arg_b="${_SYNC_CTX[dst_dir]}" - (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } + local arg_a="${_CKIPPER_SYNC_CTX[src_dir]}" arg_b="${_CKIPPER_SYNC_CTX[dst_dir]}" + (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="${_CKIPPER_SYNC_CTX[src_name]}"; arg_b="${_CKIPPER_SYNC_CTX[dst_name]}"; } local rel; rel=$(_ckipper_account_sync_manifest_rel "$type" "$id") - local live; live=$(_ckipper_account_sync_live_path "$type" "${_SYNC_CTX[dst_dir]}" "$rel") + local live; live=$(_ckipper_account_sync_live_path "$type" "${_CKIPPER_SYNC_CTX[dst_dir]}" "$rel") local op="overwrite"; [[ ! -e "$live" ]] && op="create" - _ckipper_account_sync_manifest_append "${_SYNC_CTX[backup_dir]}" "$rel" "$op" "$type" "$id" - "$apply_fn" "$arg_a" "$arg_b" "$id" "${_SYNC_CTX[backup_dir]}" + _ckipper_account_sync_manifest_append "${_CKIPPER_SYNC_CTX[backup_dir]}" "$rel" "$op" "$type" "$id" + "$apply_fn" "$arg_a" "$arg_b" "$id" "${_CKIPPER_SYNC_CTX[backup_dir]}" } # Compute the manifest's path field for a given (type, id). The relpath diff --git a/lib/account/sync/interactive.zsh b/lib/account/sync/interactive.zsh index de851c0..adb69b5 100644 --- a/lib/account/sync/interactive.zsh +++ b/lib/account/sync/interactive.zsh @@ -63,7 +63,7 @@ _ckipper_account_sync_pick_targets() { _ckipper_account_sync_pick_targets_fallback() { echo "Available targets: $*" >&2 local input - read -r "input?Enter comma-separated targets: " + input=$(_core_prompt_input "Enter comma-separated targets" "") local name for name in ${(s:,:)input}; do echo "$name" @@ -87,7 +87,7 @@ _ckipper_account_sync_pick_types() { fi echo "Type tokens: ${(@k)_CKIPPER_SYNC_TYPE_LABEL}" >&2 local input - read -r "input?Enter comma-separated types: " + input=$(_core_prompt_input "Enter comma-separated types" "") local name for name in ${(s:,:)input}; do echo "$name" diff --git a/lib/account/sync/preview.zsh b/lib/account/sync/preview.zsh index 1b32599..43156e7 100644 --- a/lib/account/sync/preview.zsh +++ b/lib/account/sync/preview.zsh @@ -14,7 +14,7 @@ readonly _CKIPPER_SYNC_DISPLAY_COL_WIDTH=26 # Per-target context (declared here too because preview_test.bats sources # only this module). See engine.zsh for the full key list. Re-declaration # without `=()` is a no-op so we don't reset state set by earlier modules. -typeset -gA _SYNC_CTX +typeset -gA _CKIPPER_SYNC_CTX # Print the divider line for the summary table. # @@ -94,12 +94,12 @@ _ckipper_account_sync_drill_down_items() { # full diff via the strategy's _diff function. Loops until the user # picks "Back" or hits EOF. # -# Reads _SYNC_CTX[items] for the items-file path; drill_down_show reads +# Reads _CKIPPER_SYNC_CTX[items] for the items-file path; drill_down_show reads # the rest of the per-target dirs/names directly. # # Returns: 0 always. _ckipper_account_sync_drill_down_loop() { - local items_file="${_SYNC_CTX[items]}" + local items_file="${_CKIPPER_SYNC_CTX[items]}" [[ ! -s "$items_file" ]] && { echo "No overwrites to drill into."; return 0; } # Hoist `local choice` and `local _ack` out of the loop: re-declaring # `local var` (no =value) on a subsequent iteration causes zsh to @@ -135,19 +135,19 @@ _ckipper_account_sync_drill_down_pick() { # in the items file to recover the original id (which may differ from # display, e.g. files-flat: id=agents/foo.md, display=foo.md). # -# Reads items file path and src/dst dirs/names from _SYNC_CTX. +# Reads items file path and src/dst dirs/names from _CKIPPER_SYNC_CTX. # # Args: $1 — picker choice (e.g. "[mcp] github"). # Returns: 0; prints the strategy's diff output. _ckipper_account_sync_drill_down_show() { local choice="$1" - local items_file="${_SYNC_CTX[items]}" + local items_file="${_CKIPPER_SYNC_CTX[items]}" local type="${choice#\[}"; type="${type%%]*}" local display="${choice#*] }" local id; id=$(_ckipper_account_sync_drill_down_resolve_id "$items_file" "$type" "$display") local diff_fn; diff_fn=$(_ckipper_account_sync_strategy_fn "$type" diff) - local arg_a="${_SYNC_CTX[src_dir]}" arg_b="${_SYNC_CTX[dst_dir]}" - (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="${_SYNC_CTX[src_name]}"; arg_b="${_SYNC_CTX[dst_name]}"; } + local arg_a="${_CKIPPER_SYNC_CTX[src_dir]}" arg_b="${_CKIPPER_SYNC_CTX[dst_dir]}" + (( ${+_CKIPPER_SYNC_TYPE_USES_NAMES[$type]} )) && { arg_a="${_CKIPPER_SYNC_CTX[src_name]}"; arg_b="${_CKIPPER_SYNC_CTX[dst_name]}"; } "$diff_fn" "$arg_a" "$arg_b" "$id" } diff --git a/lib/setup/prompts.zsh b/lib/setup/prompts.zsh index 0a4121a..235647d 100644 --- a/lib/setup/prompts.zsh +++ b/lib/setup/prompts.zsh @@ -94,9 +94,7 @@ _ckipper_setup_prompts_use_gum() { # # Returns: 0 always. _ckipper_setup_prompts_pick_keys_fallback() { - local ans="" - read -r "ans?Customize all? (y/N): " - [[ "$ans" =~ ^[yY] ]] || return 0 + _core_prompt_confirm "Customize all?" || return 0 _ckipper_setup_prompts_global_keys } From 872ccb3a5cd474cf9ec06dc2fd80e66f83833597 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sun, 3 May 2026 21:31:02 -0600 Subject: [PATCH 3/4] feat: doctor checks, config-key completion, fuller setup summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small additions that close gaps the README implies are covered: - ckipper doctor now checks (a) gum on PATH (FAIL — required for the setup/sync wizards), (b) ~/.zsh/completions/_ckipper exists and matches CKIPPER_COMPLETION_VERSION (WARN — stale completions only produce outdated tab suggestions), and (c) aliases.zsh parses with `zsh -n` (FAIL — a broken aliases file disables every per-account launcher in the user's shell). Also renamed MIN_HOOK_FILES to _CKIPPER_DOCTOR_MIN_HOOK_FILES while in doctor.zsh. - Tab completion now offers schema keys after `ck config get/set/unset`, sourced live from _CKIPPER_SCHEMA_TYPE. Bumped CKIPPER_COMPLETION_VERSION 7 → 8 so existing installs regenerate the cached completion file on next shell start. - The setup-wizard final message now lists all three Claude-launching paths (`ckipper run`, `claude-`, bare ``) and the `ck` interactive menu, plus a hint to run `ckipper doctor`. Previously it only mentioned `ckipper run`. --- ckipper.zsh | 9 ++++-- lib/account/doctor.zsh | 60 +++++++++++++++++++++++++++++++++++++--- lib/setup/dispatcher.zsh | 22 +++++++++++++-- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index a395465..37a9d6f 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -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 @@ -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}" @@ -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) diff --git a/lib/account/doctor.zsh b/lib/account/doctor.zsh index 70aaebf..b09a6a3 100644 --- a/lib/account/doctor.zsh +++ b/lib/account/doctor.zsh @@ -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 @@ -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. @@ -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: @@ -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 diff --git a/lib/setup/dispatcher.zsh b/lib/setup/dispatcher.zsh index 43ee57a..b408e29 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -38,9 +38,27 @@ _ckipper_setup() { fi _ckipper_setup_offer_account _ckipper_setup_offer_image_build + _ckipper_setup_print_completion_summary +} + +# Print the post-setup hint block: review-settings command, two ways to launch +# Claude (per-account aliases or `ckipper run`), and the bare-`ck` menu. +# Extracted so `_ckipper_setup` stays under the 25-line cap. +# +# Returns: 0 always. +_ckipper_setup_print_completion_summary() { _core_style_header "Setup complete" - echo "Run 'ckipper config list' to review settings." - echo "Run 'ckipper run ' to start working." + echo "Review settings: ckipper config list" + echo "Diagnose installation: ckipper doctor" + echo "" + echo "Launch Claude in a worktree (host or Docker):" + echo " ckipper run # bundles worktree + Claude in one step" + echo "" + echo "Launch Claude directly with an account context:" + echo " claude- # auto-generated launcher" + echo " # bare-name shortcut, when free" + echo "" + echo "Or just run 'ck' for the interactive menu." } # Print top-level setup help. From 019949080d9d1b79dfdc3bd294749ee104c16fa0 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sun, 3 May 2026 21:31:14 -0600 Subject: [PATCH 4/4] docs(readme): clarify Docker-mode limitations + slogan/table updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed slogan from "Multi-account Claude Code manager with Docker isolation, per-account preferences, and worktree-aware launchers." to "A lightweight Claude Code account, worktree, and Docker manager." Shorter, scans cleaner. - Core commands table now lists `sync` and `redeploy-hooks` under account-namespace subcommands. Both are fully implemented and documented later in the README, but the at-a-glance command index was missing them. - Renamed "Known limitations" → "Known limitations (Docker mode only)" and "Multi-account caveats" → "Multi-account caveats (host and Docker)" with intro lines that explicitly tag scope. The previous headings left it ambiguous whether each limitation applied only to the dockerized launch path or to ckipper in general. - Added two Docker-mode limitations confirmed via research: - Claude in Chrome MCP — the browser extension can't bridge the host/container boundary (anthropics/claude-code#25506). - Custom system-sound hooks — afplay/say/osascript don't exist in the Linux container; built-in notify-bell.sh sidesteps via the terminal bell (\a) character. --- README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b8b1820..c80074a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -45,7 +45,7 @@ cd Ckipper | `ck setup` | Interactive wizard for configuring Ckipper | | `ck run ` | 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 | @@ -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 @@ -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)