From de78e4d8bc40035314bf50651f9ccd6e4c1eec0d Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 4 May 2026 14:46:48 -0600 Subject: [PATCH 01/31] fix(sync): hint SPACE/ENTER when interactive picker returns empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gum choose --no-limit` exits 0 with empty stdout when the user presses ENTER without first toggling items with SPACE. The single-select source picker right before it accepts ENTER on its own, so muscle memory makes this trivially easy to do — and the wizard previously returned 1 silently in the targets case and printed only a bare "No types selected." in the types case, leaving users to think the picker was broken. Both empty-selection paths now print the same SPACE/ENTER hint to stderr before returning 1. Adds two regression tests exercising the fallback path (CKIPPER_NO_GUM=1) which mirrors the gum empty-stdout branch. --- lib/account/sync/dispatcher.zsh | 28 +++++++++++++++++++++++++-- lib/account/sync/dispatcher_test.bats | 23 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index 1ee9ef7..c032d97 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -95,15 +95,39 @@ _ckipper_account_sync_run() { fi if (( ${#_CKIPPER_SYNC_TARGETS} == 0 )); then _CKIPPER_SYNC_TARGETS=( ${(f)"$(_ckipper_account_sync_pick_targets "$_CKIPPER_SYNC_FROM")"} ) - (( ${#_CKIPPER_SYNC_TARGETS} == 0 )) && return 1 + if (( ${#_CKIPPER_SYNC_TARGETS} == 0 )); then + _ckipper_account_sync_empty_select_hint "target accounts" + return 1 + fi fi _ckipper_account_sync_validate_accounts || return 1 local -a types types=( ${(f)"$(_ckipper_account_sync_resolve_types)"} ) - (( ${#types} == 0 )) && { echo "No types selected." >&2; return 1; } + if (( ${#types} == 0 )); then + _ckipper_account_sync_empty_select_hint "sync types" + return 1 + fi _ckipper_account_sync_run_targets types } +# Print a friendly hint when a multi-select picker returns nothing. +# +# `gum choose --no-limit` exits 0 with empty stdout when the user presses +# ENTER without first toggling items with SPACE — easy to do because the +# single-select source picker right before it accepts ENTER on its own. +# Without this hint, the wizard exits silently and users believe the picker +# is broken (see PR adding this for the reproduction). +# +# Args: $1 — what was being selected ("target accounts" | "sync types"). +# Returns: 0 always. +# Errors (stderr): "No selected." plus a SPACE/ENTER hint. +_ckipper_account_sync_empty_select_hint() { + { + echo "No $1 selected." + echo "Hint: in the picker, press SPACE to mark items, then ENTER to confirm." + } >&2 +} + # Walk every target and apply the resolved type list. # # Args: $1 — name of array variable holding type ids. diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats index 735df2e..31c8afd 100644 --- a/lib/account/sync/dispatcher_test.bats +++ b/lib/account/sync/dispatcher_test.bats @@ -180,6 +180,29 @@ run_full() { [ "$status" -eq 0 ] } +# Regression: with no positional targets and an empty multi-select reply, +# the wizard previously returned 1 silently — users perceived the picker +# as broken. Both pickers now print a SPACE/ENTER hint before exiting. +@test "interactive sync prints a SPACE/ENTER hint when no targets are picked" { + setup_two_accounts + # Pipe enough blank lines to satisfy every prompt the fallback path asks + # for (source = "1", targets = "", types = ""). The empty targets line + # is what we're exercising. + run_full 'printf "1\n\n\n" | ckipper account sync' + [ "$status" -ne 0 ] + [[ "$output" == *"No target accounts selected"* ]] + [[ "$output" == *"press SPACE to mark items"* ]] +} + +@test "interactive sync prints a SPACE/ENTER hint when no types are picked" { + setup_two_accounts + # Source = "1", targets = "dst", types = "" (empty -> hint). + run_full 'printf "1\ndst\n\n" | ckipper account sync' + [ "$status" -ne 0 ] + [[ "$output" == *"No sync types selected"* ]] + [[ "$output" == *"press SPACE to mark items"* ]] +} + # Regression: when the user picks "View changes" then "Apply", the diff # output written by drill_down_loop must NOT pollute the captured action, # else the [[ "$action" == "apply" ]] check downstream silently skips apply. From c85466cfc4f6e89732db1d228b46cbe75759af97 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 4 May 2026 15:35:08 -0600 Subject: [PATCH 02/31] =?UTF-8?q?fix(worktree):=20speed=20up=20`worktree?= =?UTF-8?q?=20list`=20and=20stop=20leaking=20branch=3D=E2=80=A6=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surface together in `ckipper worktree list`: 1. Slow scan. `find` recursed unbounded under $CKIPPER_WORKTREES_DIR with only `*/node_modules/*` excluded, so the heavy build/cache trees (`dist`, `.next`, `target`, `__pycache__`, etc.) were walked in full. On a 6 GB / 380k-file worktrees tree the scan took ~700 ms. Pruning the known-heavy directory names brings it to ~30 ms (≈25× faster); bounding by depth was not viable because branches contain slashes (e.g. `feature/OGD-320-…`). 2. Spurious `branch='…'` lines under each bullet. `local branch` (no `=value`) inside the per-iteration loop body behaves like `typeset -p branch` once the variable already carries a value from a prior iteration. Hoist `branch` (and the other loop locals) above the `while` and assign without `local` inside, matching the existing precedent in lib/account/sync/preview.zsh. Adds three regression tests: - pruning skips `.git` files inside node_modules / dist / __pycache__ - branches with slashes still resolve (no depth bound) - output never contains literal `branch=` lines --- lib/worktree/worktree.zsh | 47 +++++++++++++++++++++++---- lib/worktree/worktree_test.bats | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/lib/worktree/worktree.zsh b/lib/worktree/worktree.zsh index 09beac9..adc3ae3 100644 --- a/lib/worktree/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -3,6 +3,18 @@ readonly _CKIPPER_WT_FIND_MAX_DEPTH=3 +# Directory names pruned during the worktree-list scan. Branches can contain +# slashes (e.g. `feature/foo`), so we cannot bound the search by depth — but +# we CAN skip the heavy build/cache trees that inflate scan time without +# containing real worktrees. Empirically takes a 6 GB / 380k-file worktrees +# tree from ~700 ms to ~30 ms. Also prunes `.git` directories (the parent +# repo's, not the worktree's `.git` *file*) so we don't descend into git's +# internal storage. Add new entries as the worktrees tree grows. +readonly _CKIPPER_WT_PRUNE_DIRS=( + .git node_modules .next .nuxt dist build target + .venv venv __pycache__ vendor .turbo .cache +) + # Print all worktrees under $CKIPPER_WORKTREES_DIR, grouped by project. # # Reads CKIPPER_PROJECTS_DIR and CKIPPER_WORKTREES_DIR globals. @@ -11,16 +23,19 @@ _ckipper_worktree_list_worktrees() { [[ ! -d "$CKIPPER_WORKTREES_DIR" ]] && return 0 local previous_project_for_grouping="" - find "$CKIPPER_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null \ + # Hoist loop locals out of the body: re-declaring `local var` (no =value) + # on a subsequent iteration causes zsh to print `var='prior_value'`, + # leaking lines onto the worktree list. + local wt_dir="" rel="" project="" after_first="" branch="" + _ckipper_worktree_find_worktree_git_files \ | sort \ | while IFS= read -r git_metadata_file; do - local wt_dir="${git_metadata_file:h}" - local rel="${wt_dir#$CKIPPER_WORKTREES_DIR/}" - local project="${rel%%/*}" - local after_first="${rel#*/}" + wt_dir="${git_metadata_file:h}" + rel="${wt_dir#$CKIPPER_WORKTREES_DIR/}" + project="${rel%%/*}" + after_first="${rel#*/}" [[ "$after_first" == "$rel" ]] && continue - local branch IFS=$'\t' read -r project branch < <(_ckipper_worktree_get_project_and_branch "$project" "$after_first") if [[ "$project" != "$previous_project_for_grouping" ]]; then @@ -32,6 +47,26 @@ _ckipper_worktree_list_worktrees() { done } +# Emit the `.git` files of every worktree under $CKIPPER_WORKTREES_DIR, +# pruning known-heavy directories (see _CKIPPER_WT_PRUNE_DIRS) so the scan +# does not descend into node_modules / dist / build / etc. Worktrees mark +# their root with a `.git` *file* (a gitdir reference), not a directory — +# we test for `-type f` to filter accordingly. +# +# Args: none. +# Returns: 0 always; prints absolute paths to `.git` files, one per line. +_ckipper_worktree_find_worktree_git_files() { + local -a prune_args=() + local d + for d in "${_CKIPPER_WT_PRUNE_DIRS[@]}"; do + (( ${#prune_args} > 0 )) && prune_args+=(-o) + prune_args+=(-name "$d") + done + find "$CKIPPER_WORKTREES_DIR" \ + \( -type d \( "${prune_args[@]}" \) -prune \) \ + -o \( -type f -name .git -print \) 2>/dev/null +} + # Resolve project and branch from path components for nested-project worktrees. # # Args: diff --git a/lib/worktree/worktree_test.bats b/lib/worktree/worktree_test.bats index af01dd6..ba3cdc0 100644 --- a/lib/worktree/worktree_test.bats +++ b/lib/worktree/worktree_test.bats @@ -46,6 +46,63 @@ _run_worktree() { [[ "$output" =~ "Worktrees" ]] } +# Regression: scanning was unbounded and pruned only node_modules. With a +# 6 GB worktrees tree the scan took ~700 ms. Pruning the heavy build dirs +# (`dist`, `.next`, `target`, `__pycache__`, etc.) brings it to ~30 ms and +# avoids reporting any phantom `.git` files nested inside those trees. +@test "_ckipper_worktree_list_worktrees skips .git files inside pruned dirs" { + # Real worktree (should appear in output). + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/.git" + + # Phantom .git files buried inside dirs we should prune. Each filename + # is unique so we can assert it does NOT show up by name. + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/node_modules/pkg-a" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/node_modules/pkg-a/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/dist/pkg-b" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/dist/pkg-b/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/__pycache__/pkg-c" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/__pycache__/pkg-c/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" == *"feature-x"* ]] + [[ "$output" != *"pkg-a"* ]] + [[ "$output" != *"pkg-b"* ]] + [[ "$output" != *"pkg-c"* ]] +} + +# Regression: branches contain slashes (`feature/foo`, `fix/bar`) so the +# scan must not be depth-bounded — the pruned find still has to surface a +# worktree whose `.git` lives several levels deep. +@test "_ckipper_worktree_list_worktrees finds worktrees whose branch name contains slashes" { + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/OGD-320-deep-branch" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/OGD-320-deep-branch/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" == *"feature/OGD-320-deep-branch"* ]] +} + +# Regression: `local branch` (no assignment) inside the per-worktree loop +# behaves like `typeset -p branch` once `branch` carries a value from a +# prior iteration, leaking literal `branch='…'` lines onto stdout. The fix +# is `local branch=""`. Two worktrees are needed to trigger iteration N>1 +# on the same loop scope. +@test "_ckipper_worktree_list_worktrees does not leak local-redeclare echoes" { + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/one" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/one/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/two" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/two/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" != *"branch="* ]] +} + @test "_ckipper_worktree_remove_worktree fails when worktree path does not exist" { _run_worktree "_ckipper_worktree_remove_worktree myapp nonexistent-branch" From 903ce2a71d9da03fbd0586d3a5e579b284b60b9c Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 4 May 2026 15:35:08 -0600 Subject: [PATCH 03/31] =?UTF-8?q?fix(worktree):=20speed=20up=20`worktree?= =?UTF-8?q?=20list`=20and=20stop=20leaking=20branch=3D=E2=80=A6=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surface together in `ckipper worktree list`: 1. Slow scan. `find` recursed unbounded under $CKIPPER_WORKTREES_DIR with only `*/node_modules/*` excluded, so the heavy build/cache trees (`dist`, `.next`, `target`, `__pycache__`, etc.) were walked in full. On a 6 GB / 380k-file worktrees tree the scan took ~700 ms. Pruning the known-heavy directory names brings it to ~30 ms (≈25× faster); bounding by depth was not viable because branches contain slashes (e.g. `feature/OGD-320-…`). 2. Spurious `branch='…'` lines under each bullet. `local branch` (no `=value`) inside the per-iteration loop body behaves like `typeset -p branch` once the variable already carries a value from a prior iteration. Hoist `branch` (and the other loop locals) above the `while` and assign without `local` inside, matching the existing precedent in lib/account/sync/preview.zsh. Adds three regression tests: - pruning skips `.git` files inside node_modules / dist / __pycache__ - branches with slashes still resolve (no depth bound) - output never contains literal `branch=` lines --- lib/worktree/worktree.zsh | 47 +++++++++++++++++++++++---- lib/worktree/worktree_test.bats | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/lib/worktree/worktree.zsh b/lib/worktree/worktree.zsh index 09beac9..adc3ae3 100644 --- a/lib/worktree/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -3,6 +3,18 @@ readonly _CKIPPER_WT_FIND_MAX_DEPTH=3 +# Directory names pruned during the worktree-list scan. Branches can contain +# slashes (e.g. `feature/foo`), so we cannot bound the search by depth — but +# we CAN skip the heavy build/cache trees that inflate scan time without +# containing real worktrees. Empirically takes a 6 GB / 380k-file worktrees +# tree from ~700 ms to ~30 ms. Also prunes `.git` directories (the parent +# repo's, not the worktree's `.git` *file*) so we don't descend into git's +# internal storage. Add new entries as the worktrees tree grows. +readonly _CKIPPER_WT_PRUNE_DIRS=( + .git node_modules .next .nuxt dist build target + .venv venv __pycache__ vendor .turbo .cache +) + # Print all worktrees under $CKIPPER_WORKTREES_DIR, grouped by project. # # Reads CKIPPER_PROJECTS_DIR and CKIPPER_WORKTREES_DIR globals. @@ -11,16 +23,19 @@ _ckipper_worktree_list_worktrees() { [[ ! -d "$CKIPPER_WORKTREES_DIR" ]] && return 0 local previous_project_for_grouping="" - find "$CKIPPER_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null \ + # Hoist loop locals out of the body: re-declaring `local var` (no =value) + # on a subsequent iteration causes zsh to print `var='prior_value'`, + # leaking lines onto the worktree list. + local wt_dir="" rel="" project="" after_first="" branch="" + _ckipper_worktree_find_worktree_git_files \ | sort \ | while IFS= read -r git_metadata_file; do - local wt_dir="${git_metadata_file:h}" - local rel="${wt_dir#$CKIPPER_WORKTREES_DIR/}" - local project="${rel%%/*}" - local after_first="${rel#*/}" + wt_dir="${git_metadata_file:h}" + rel="${wt_dir#$CKIPPER_WORKTREES_DIR/}" + project="${rel%%/*}" + after_first="${rel#*/}" [[ "$after_first" == "$rel" ]] && continue - local branch IFS=$'\t' read -r project branch < <(_ckipper_worktree_get_project_and_branch "$project" "$after_first") if [[ "$project" != "$previous_project_for_grouping" ]]; then @@ -32,6 +47,26 @@ _ckipper_worktree_list_worktrees() { done } +# Emit the `.git` files of every worktree under $CKIPPER_WORKTREES_DIR, +# pruning known-heavy directories (see _CKIPPER_WT_PRUNE_DIRS) so the scan +# does not descend into node_modules / dist / build / etc. Worktrees mark +# their root with a `.git` *file* (a gitdir reference), not a directory — +# we test for `-type f` to filter accordingly. +# +# Args: none. +# Returns: 0 always; prints absolute paths to `.git` files, one per line. +_ckipper_worktree_find_worktree_git_files() { + local -a prune_args=() + local d + for d in "${_CKIPPER_WT_PRUNE_DIRS[@]}"; do + (( ${#prune_args} > 0 )) && prune_args+=(-o) + prune_args+=(-name "$d") + done + find "$CKIPPER_WORKTREES_DIR" \ + \( -type d \( "${prune_args[@]}" \) -prune \) \ + -o \( -type f -name .git -print \) 2>/dev/null +} + # Resolve project and branch from path components for nested-project worktrees. # # Args: diff --git a/lib/worktree/worktree_test.bats b/lib/worktree/worktree_test.bats index af01dd6..ba3cdc0 100644 --- a/lib/worktree/worktree_test.bats +++ b/lib/worktree/worktree_test.bats @@ -46,6 +46,63 @@ _run_worktree() { [[ "$output" =~ "Worktrees" ]] } +# Regression: scanning was unbounded and pruned only node_modules. With a +# 6 GB worktrees tree the scan took ~700 ms. Pruning the heavy build dirs +# (`dist`, `.next`, `target`, `__pycache__`, etc.) brings it to ~30 ms and +# avoids reporting any phantom `.git` files nested inside those trees. +@test "_ckipper_worktree_list_worktrees skips .git files inside pruned dirs" { + # Real worktree (should appear in output). + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/.git" + + # Phantom .git files buried inside dirs we should prune. Each filename + # is unique so we can assert it does NOT show up by name. + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/node_modules/pkg-a" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/node_modules/pkg-a/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/dist/pkg-b" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/dist/pkg-b/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/__pycache__/pkg-c" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/__pycache__/pkg-c/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" == *"feature-x"* ]] + [[ "$output" != *"pkg-a"* ]] + [[ "$output" != *"pkg-b"* ]] + [[ "$output" != *"pkg-c"* ]] +} + +# Regression: branches contain slashes (`feature/foo`, `fix/bar`) so the +# scan must not be depth-bounded — the pruned find still has to surface a +# worktree whose `.git` lives several levels deep. +@test "_ckipper_worktree_list_worktrees finds worktrees whose branch name contains slashes" { + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/OGD-320-deep-branch" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/OGD-320-deep-branch/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" == *"feature/OGD-320-deep-branch"* ]] +} + +# Regression: `local branch` (no assignment) inside the per-worktree loop +# behaves like `typeset -p branch` once `branch` carries a value from a +# prior iteration, leaking literal `branch='…'` lines onto stdout. The fix +# is `local branch=""`. Two worktrees are needed to trigger iteration N>1 +# on the same loop scope. +@test "_ckipper_worktree_list_worktrees does not leak local-redeclare echoes" { + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/one" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/one/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/two" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/two/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" != *"branch="* ]] +} + @test "_ckipper_worktree_remove_worktree fails when worktree path does not exist" { _run_worktree "_ckipper_worktree_remove_worktree myapp nonexistent-branch" From 48c27f697fac5659f7507994f7416e3b30ed75b6 Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 4 May 2026 18:10:17 -0600 Subject: [PATCH 04/31] =?UTF-8?q?fix:=201.0=20polish=20=E2=80=94=20setup?= =?UTF-8?q?=20wizard=20UX,=20prompt=20cancel,=20project=20depth,=20docker?= =?UTF-8?q?=20spin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled post-launch fixes uncovered by a real-user dry-run: setup customize picker (Issue 1, 2) - Header now reads "Pick keys to customize (SPACE to mark, ENTER to confirm)" so the multi-select contract is obvious — same hazard as PR #41's sync picker. Users were habitually pressing ENTER on the first row (single- select muscle memory) and silently advancing with no overrides. - Detected-configuration table now carries a DESCRIPTION column so users can read what each key does without entering the picker. Polished the schema descriptions so bool keys state what `true` means explicitly (the user's reported confusion: "what is aliases_auto_source"). setup sync between existing accounts (Issue 3) - Cross-account sync was offered only after the wizard added a NEW account. Re-running `ckipper setup` on an established multi-account install never surfaced the sync feature. Added a top-level offer that fires when accounts >= 2. docker image build via setup (Issue 4) - `_core_prompt_spin` wraps `gum spin -- "$@"`, which execs argv as a binary. Passing a shell function failed with "executable file not found in $PATH". Drop the spinner — the build streams its own progress over ~5 min, which the user wants to see anyway. `_core_prompt_input` cancel propagation (Issue 5a) - `gum input` exits non-zero with empty stdout on Esc / Ctrl-C, but the helper substituted the default in both the cancel and empty-submit cases. The launcher branch prompt then created a worktree on `feature/dev` even when the user pressed Ctrl-X to back out. - Now propagates rc: cancel → return non-zero, no stdout. Empty submit → rc=0, default echoed (existing contract preserved). - Updated setup/dispatcher.zsh callers (account-name, customize-loop) to skip on cancellation rather than commit empty values. launcher project autodetect depth (Issue 5b) - Maxdepth was 3, but the existing comment claimed support for the `///` layout — `.git` sits one level deeper than the repo root, so depth 4 is needed. The user had a real project (~/Developer/AFF/happyhippo/hippo-vmail) hidden by the off-by-one. ~5 ms cost on the author's tree. Tests - 510/510 shell tests pass, including the new regressions: * _core_prompt_input returns non-zero with no stdout on EOF * _ckipper_setup_offer_existing_sync skips < 2 accounts * _ckipper_setup_offer_existing_sync invokes / skips sync_dispatch --- lib/core/prompt.zsh | 15 ++++++++-- lib/core/prompt_test.bats | 14 ++++++++++ lib/core/schema.zsh | 24 ++++++++-------- lib/launcher/menu.zsh | 10 ++++--- lib/setup/dispatcher.zsh | 45 ++++++++++++++++++++++++------ lib/setup/dispatcher_test.bats | 51 ++++++++++++++++++++++++++++++++++ lib/setup/prompts.zsh | 24 +++++++++++----- 7 files changed, 149 insertions(+), 34 deletions(-) diff --git a/lib/core/prompt.zsh b/lib/core/prompt.zsh index f6ccf9f..b6f4169 100644 --- a/lib/core/prompt.zsh +++ b/lib/core/prompt.zsh @@ -25,18 +25,27 @@ _core_prompt_use_gum() { # Prompt for a free-form string. Returns the entered value, or the supplied # default when input is empty. # +# Distinguishes "user submitted empty" (rc=0, value=default) from "user +# cancelled" (rc != 0, no stdout). `gum input` exits non-zero on Esc/Ctrl-C, +# and we propagate that so callers can distinguish cancellation. Previously +# we collapsed both into "echo default", which meant a cancelled launcher +# branch prompt silently created a worktree on the default branch name. +# # Args: $1 — label shown to the user; $2 — default value used on empty input. -# Returns: 0 always; prints the resolved value to stdout. +# Returns: 0 on submit (including empty submit); non-zero on cancellation. +# Prints the resolved value to stdout on success; nothing on cancel. _core_prompt_input() { local label="$1" default="$2" if _core_prompt_use_gum; then - local out + local out rc out=$(gum input --placeholder "$default" --prompt "$label > ") + rc=$? + (( rc != 0 )) && return $rc echo "${out:-$default}" return 0 fi local val="" - read -r "val?$label [$default]: " + read -r "val?$label [$default]: " || return $? echo "${val:-$default}" } diff --git a/lib/core/prompt_test.bats b/lib/core/prompt_test.bats index 814f171..ecec9f1 100644 --- a/lib/core/prompt_test.bats +++ b/lib/core/prompt_test.bats @@ -46,6 +46,20 @@ _run_prompt() { [ "$output" = "thedefault" ] } +# Regression: previously cancellation (EOF / Ctrl-C / Esc on the gum form) +# was indistinguishable from "user submitted empty" because both echoed +# the default. The launcher's branch prompt then created a worktree on +# `feature/dev` even when the user pressed Ctrl-X to back out. The fix +# propagates rc from gum / read so callers can distinguish cancel via rc. +@test "_core_prompt_input returns non-zero with no stdout when read sees EOF" { + run env CKIPPER_NO_GUM=1 PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/prompt.zsh\"; \ + _core_prompt_input \"Q\" \"thedefault\" 2>/dev/null" `, `~/Developer//`, -# `~/Developer///`) without scanning entire user homedirs. -readonly _CKIPPER_LAUNCHER_PROJECTS_MAXDEPTH=3 +# `find -maxdepth` value for project discovery. The repository's `.git` +# directory sits one level below the repo root, so to surface +# `////.git` we need depth 4. Anything +# deeper than that is almost always a vendored sub-repo or a demo project, +# so depth 4 is the sweet spot between coverage and scan time. +readonly _CKIPPER_LAUNCHER_PROJECTS_MAXDEPTH=4 # Print the launcher banner: a styled "Ckipper" header, the product tagline, # and a trailing blank line that separates the banner from whatever prompt or diff --git a/lib/setup/dispatcher.zsh b/lib/setup/dispatcher.zsh index b408e29..229e9c0 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -4,8 +4,7 @@ # # Depends on: # - lib/core/style.zsh (`_core_style_header`) -# - lib/core/prompt.zsh (`_core_prompt_confirm`, `_core_prompt_input`, -# `_core_prompt_spin`) +# - lib/core/prompt.zsh (`_core_prompt_confirm`, `_core_prompt_input`) # - lib/setup/prereqs.zsh (`_ckipper_setup_prereqs`) # - lib/setup/prompts.zsh (`_ckipper_setup_prompts_*`) # - lib/setup/apply.zsh (`_ckipper_setup_apply_global`, @@ -37,10 +36,27 @@ _ckipper_setup() { echo "Using current values." fi _ckipper_setup_offer_account + _ckipper_setup_offer_existing_sync _ckipper_setup_offer_image_build _ckipper_setup_print_completion_summary } +# Offer a between-accounts sync when the user has 2+ accounts already and +# the wizard's add-account step did not just run one (the post-add path +# already offers the sync inline). Without this, a re-run of `ckipper setup` +# on an established multi-account install never surfaces the sync feature. +# +# Returns: 0 always. +_ckipper_setup_offer_existing_sync() { + local count + count=$(jq -r '.accounts | length' "$CKIPPER_REGISTRY" 2>/dev/null || echo 0) + (( count < 2 )) && return 0 + if ! _core_prompt_confirm "Sync settings between two existing accounts?"; then + return 0 + fi + _ckipper_account_sync_dispatch +} + # 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. @@ -89,10 +105,16 @@ _ckipper_setup_run_customize_loop() { local -a picked picked=( ${(f)"$(_ckipper_setup_prompts_pick_keys)"} ) typeset -A updates - local key + local key value for key in "${picked[@]}"; do [[ -z "$key" ]] && continue - updates[$key]=$(_ckipper_setup_prompts_one_key "$key") + # If the per-key prompt returns non-zero the user cancelled it + # (Esc/Ctrl-C on gum). Skip the key rather than writing an empty + # override, which would silently blank out the value. + if ! value=$(_ckipper_setup_prompts_one_key "$key"); then + continue + fi + updates[$key]="$value" done _ckipper_setup_apply_global updates } @@ -128,7 +150,9 @@ _ckipper_setup_offer_account() { # Returns: 0 always (per-step failures are surfaced via the underlying calls). _ckipper_setup_add_account() { local name - name=$(_core_prompt_input "Account name" "$_CKIPPER_SETUP_DEFAULT_ACCOUNT_NAME") + if ! name=$(_core_prompt_input "Account name" "$_CKIPPER_SETUP_DEFAULT_ACCOUNT_NAME"); then + return 0 + fi _ckipper_account_add "$name" || return 0 typeset -A prefs _ckipper_setup_collect_account_prefs "$name" @@ -189,13 +213,16 @@ _ckipper_setup_collect_account_prefs() { "Forward host ~/.ssh into '$account' containers?" } -# Offer to build/rebuild the ckipper-dev Docker image now. Wraps the build in -# a gum spinner when available; the underlying helper streams docker output -# directly when running without gum. +# Offer to build/rebuild the ckipper-dev Docker image now. +# +# We invoke the build helper directly rather than wrapping it in a spinner. +# `gum spin -- ` execs its argv as a binary, so passing a shell function +# fails with "executable file not found in $PATH". The build also streams +# its own progress over ~5 min, which the user wants to see. # # Returns: 0 always. _ckipper_setup_offer_image_build() { if _core_prompt_confirm "Build the Docker image now? (slow; ~5 min)"; then - _core_prompt_spin "Building ckipper-dev image" _ckipper_worktree_build_image + _ckipper_worktree_build_image fi } diff --git a/lib/setup/dispatcher_test.bats b/lib/setup/dispatcher_test.bats index 9f8ebd4..bce5545 100644 --- a/lib/setup/dispatcher_test.bats +++ b/lib/setup/dispatcher_test.bats @@ -116,3 +116,54 @@ JSON [ "$status" -eq 0 ] [[ "$output" == *"STUB-BUILD"* ]] } + +# Regression: setup previously offered cross-account sync only after the +# user added a NEW account in the wizard. A user with 2+ existing accounts +# who declined "Add another?" never saw the sync feature surfaced. The +# behavioral signal we can assert (prompts written by zsh's `read "ans?…"` +# are suppressed when stdin is non-TTY, so we can't grep the label) is that +# the dispatch helper IS invoked on accept and SKIPPED otherwise. +@test "_ckipper_setup_offer_existing_sync skips when fewer than 2 accounts" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{"a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}}} +JSON + + _run_setup $'y\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" != *"STUB-SYNC"* ]] +} + +@test "_ckipper_setup_offer_existing_sync invokes sync_dispatch on yes" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{ + "a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}, + "b":{"config_dir":"/y","keychain_service":null,"registered_at":"t","preferences":{}} +}} +JSON + + _run_setup $'y\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" == *"STUB-SYNC"* ]] +} + +@test "_ckipper_setup_offer_existing_sync skips sync_dispatch on no" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{ + "a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}, + "b":{"config_dir":"/y","keychain_service":null,"registered_at":"t","preferences":{}} +}} +JSON + + _run_setup $'n\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" != *"STUB-SYNC"* ]] +} diff --git a/lib/setup/prompts.zsh b/lib/setup/prompts.zsh index a6199c9..1f4d9cc 100644 --- a/lib/setup/prompts.zsh +++ b/lib/setup/prompts.zsh @@ -30,15 +30,24 @@ readonly _CKIPPER_SETUP_PROMPTS_NO_GUM_SENTINEL="1" # Header rendered above the summary table. readonly _CKIPPER_SETUP_PROMPTS_HEADER="Detected configuration" +# Header for the multi-select picker. Mentions SPACE explicitly because the +# preceding y/N "customize?" prompt and the source-account picker are both +# single-select Enter — without the hint, users press Enter on the first +# row and silently advance with no overrides. +readonly _CKIPPER_SETUP_PROMPTS_PICKER_HEADER="Pick keys to customize (SPACE to mark, ENTER to confirm)" + # Pipe-separated row builder for the summary table. Resolves the effective -# value via _core_config_get and the source marker via _core_config_read_global -# (empty return ⇒ default; otherwise ⇒ user override). +# value via _core_config_get, the source marker via _core_config_read_global +# (empty return ⇒ default; otherwise ⇒ user override), and the description +# from the schema. Including the description here so the user can scan the +# table and know what each key does without having to drill into a picker +# for it (e.g. `aliases_auto_source` is opaque without context). # # Args: $1 — schema key. -# Returns: 0 always; prints "||" to stdout. +# Returns: 0 always; prints "|||" to stdout. _ckipper_setup_prompts_summary_row() { local key="$1" - local value source raw + local value source raw description value=$(_core_config_get "$key") raw=$(_core_config_read_global "$key") if [[ -z "$raw" ]]; then @@ -46,7 +55,8 @@ _ckipper_setup_prompts_summary_row() { else source="$_CKIPPER_SETUP_PROMPTS_SOURCE_USER" fi - printf '%s|%s|%s\n' "$key" "$value" "$source" + description="${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" + printf '%s|%s|%s|%s\n' "$key" "$value" "$source" "$description" } # Print every global-scoped key one per line in lexical order. Used by the @@ -74,7 +84,7 @@ _ckipper_setup_prompts_summary() { while IFS= read -r key; do _ckipper_setup_prompts_summary_row "$key" done < <(_ckipper_setup_prompts_global_keys) - } | _core_style_table SETTING VALUE SOURCE + } | _core_style_table SETTING VALUE SOURCE DESCRIPTION _core_style_divider } @@ -107,7 +117,7 @@ _ckipper_setup_prompts_pick_keys_fallback() { _ckipper_setup_prompts_pick_keys() { if _ckipper_setup_prompts_use_gum; then _ckipper_setup_prompts_global_keys \ - | gum choose --no-limit --header "Pick keys to customize" + | gum choose --no-limit --header "$_CKIPPER_SETUP_PROMPTS_PICKER_HEADER" return 0 fi _ckipper_setup_prompts_pick_keys_fallback From 6dbc92bbadf1a81240961e0beaec5afcb5a325cd Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 4 May 2026 18:29:21 -0600 Subject: [PATCH 05/31] fix(setup,config,core): address PR #43 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups uncovered by self-review: 1. Detected-config DESCRIPTION column was unrenderable (Critical) The 4th column added in this PR overflowed the fixed-width table (`%-22s` does not truncate; descriptions run 90+ chars), making the table effectively unreadable. Render descriptions on the line below each row instead — keeps the three-column alignment intact and gives the description unlimited room. Picker labels also enriched with `key — description` so the user sees what each setting does while choosing what to customize, with awk extracting the bare key on the way out. 2. `lib/config/set.zsh` missed `_core_prompt_input` cancel audit (Important). With the new rc-propagating contract, an Esc/Ctrl-C during `ckipper config set ` (no value arg) would silently blank the key. Added `if ! value=$(...); then return 1` guard and a regression test that pipes EOF and asserts the writer is bypassed. 3. `_core_prompt_spin` was orphaned (Important). The only caller (setup) was removed in this PR. Per CLAUDE.md "no half-finished implementations" — the function and its two tests are gone. Its `gum spin -- $@` design was incompatible with shell functions, which is the predominant pattern in this codebase, so re-using it later would require redesigning it anyway. Tests: 510/510 pass (net unchanged: -2 spin tests, +1 description-row, +1 config-set cancel). Lint clean. --- lib/config/dispatcher_test.bats | 36 +++++++++++++++++++++++++++++ lib/config/set.zsh | 7 +++++- lib/core/prompt.zsh | 17 -------------- lib/core/prompt_test.bats | 12 ---------- lib/setup/prompts.zsh | 41 +++++++++++++++++++-------------- lib/setup/prompts_test.bats | 13 +++++++++++ 6 files changed, 79 insertions(+), 47 deletions(-) diff --git a/lib/config/dispatcher_test.bats b/lib/config/dispatcher_test.bats index 1045ea5..f5b9567 100644 --- a/lib/config/dispatcher_test.bats +++ b/lib/config/dispatcher_test.bats @@ -78,6 +78,42 @@ _run_config_dispatch() { [ "$status" -ne 0 ] } +# Regression: when `ckipper config set ` is invoked WITHOUT a value, +# the handler prompts via _core_prompt_input. Cancellation (Esc/Ctrl-C on +# gum, EOF on the read fallback) must abort the write rather than commit +# an empty value — otherwise the user's only out is to silently blank the +# key. _core_prompt_input now returns non-zero on cancel; this test pins +# the abort-on-cancel behavior in `_ckipper_config_set`. +@test "config set aborts the write when the value prompt is cancelled" { + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_REGISTRY_VERSION="${CKIPPER_REGISTRY_VERSION:-2}" \ + CKIPPER_NO_GUM=1 \ + PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/core/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/core/style.zsh\" + source \"$REPO_ROOT/lib/core/help.zsh\" + source \"$REPO_ROOT/lib/core/prompt.zsh\" + source \"$REPO_ROOT/lib/config/set.zsh\" + source \"$REPO_ROOT/lib/config/get.zsh\" + _ckipper_config_set default_branch 2>/dev/null + echo \"set_rc=\$?\" + _ckipper_config_get default_branch + " /dev/null || return 1; } if [[ "$_CKIPPER_CONFIG_SET_HAS_VALUE" != "true" ]]; then local prompt_label="Value for $key (${_CKIPPER_SCHEMA_TYPE[$key]})" - value=$(_core_prompt_input "$prompt_label" "") + # Cancellation must abort the write, not commit an empty value. + # _core_prompt_input returns non-zero on Esc/Ctrl-C; without this + # check the user pressing cancel would silently blank the key. + if ! value=$(_core_prompt_input "$prompt_label" ""); then + return 1 + fi fi _core_config_set "$key" "$value" "$account" } diff --git a/lib/core/prompt.zsh b/lib/core/prompt.zsh index b6f4169..df7f0ba 100644 --- a/lib/core/prompt.zsh +++ b/lib/core/prompt.zsh @@ -101,20 +101,3 @@ _core_prompt_choose() { (( choice >= 1 && choice <= $# )) || return 1 echo "${@[choice]}" } - -# Run a command with a spinner indicator. Forwards the command's exit status. -# -# Args: $1 — label shown alongside the spinner; $2..$N — command and args to -# execute. The label is consumed by this function and never reaches the -# wrapped command. -# Returns: the exit status of the wrapped command. -_core_prompt_spin() { - local label="$1" - shift - if _core_prompt_use_gum; then - gum spin --spinner dot --title "$label" -- "$@" - return $? - fi - echo "$label..." >&2 - "$@" -} diff --git a/lib/core/prompt_test.bats b/lib/core/prompt_test.bats index ecec9f1..0618e2f 100644 --- a/lib/core/prompt_test.bats +++ b/lib/core/prompt_test.bats @@ -121,15 +121,3 @@ _run_prompt() { [ "$status" -eq 1 ] } - -@test "_core_prompt_spin runs the command and forwards exit status 0" { - _run_prompt "" '_core_prompt_spin "Working" true' - - [ "$status" -eq 0 ] -} - -@test "_core_prompt_spin forwards non-zero exit status" { - _run_prompt "" '_core_prompt_spin "Working" false' - - [ "$status" -eq 1 ] -} diff --git a/lib/setup/prompts.zsh b/lib/setup/prompts.zsh index 1f4d9cc..f988f3a 100644 --- a/lib/setup/prompts.zsh +++ b/lib/setup/prompts.zsh @@ -37,17 +37,14 @@ readonly _CKIPPER_SETUP_PROMPTS_HEADER="Detected configuration" readonly _CKIPPER_SETUP_PROMPTS_PICKER_HEADER="Pick keys to customize (SPACE to mark, ENTER to confirm)" # Pipe-separated row builder for the summary table. Resolves the effective -# value via _core_config_get, the source marker via _core_config_read_global -# (empty return ⇒ default; otherwise ⇒ user override), and the description -# from the schema. Including the description here so the user can scan the -# table and know what each key does without having to drill into a picker -# for it (e.g. `aliases_auto_source` is opaque without context). +# value via _core_config_get and the source marker via _core_config_read_global +# (empty return ⇒ default; otherwise ⇒ user override). # # Args: $1 — schema key. -# Returns: 0 always; prints "|||" to stdout. +# Returns: 0 always; prints "||" to stdout. _ckipper_setup_prompts_summary_row() { local key="$1" - local value source raw description + local value source raw value=$(_core_config_get "$key") raw=$(_core_config_read_global "$key") if [[ -z "$raw" ]]; then @@ -55,8 +52,7 @@ _ckipper_setup_prompts_summary_row() { else source="$_CKIPPER_SETUP_PROMPTS_SOURCE_USER" fi - description="${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" - printf '%s|%s|%s|%s\n' "$key" "$value" "$source" "$description" + printf '%s|%s|%s\n' "$key" "$value" "$source" } # Print every global-scoped key one per line in lexical order. Used by the @@ -79,12 +75,19 @@ _ckipper_setup_prompts_global_keys() { # Returns: 0 always. _ckipper_setup_prompts_summary() { _core_style_header "$_CKIPPER_SETUP_PROMPTS_HEADER" - local key - { - while IFS= read -r key; do - _ckipper_setup_prompts_summary_row "$key" - done < <(_ckipper_setup_prompts_global_keys) - } | _core_style_table SETTING VALUE SOURCE DESCRIPTION + _core_style_table_print_row "SETTING|VALUE|SOURCE" + # Per-key block: aligned three-column row + indented description on the + # next line. We render this manually rather than feeding a 4-column row + # to `_core_style_table` because the schema descriptions can run 90+ + # characters and the fixed-width column padding (22 chars) would leave + # them overflowing across the screen and breaking column alignment for + # every other column. + local key description + while IFS= read -r key; do + _core_style_table_print_row "$(_ckipper_setup_prompts_summary_row "$key")" + description="${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" + [[ -n "$description" ]] && echo " $description" + done < <(_ckipper_setup_prompts_global_keys) _core_style_divider } @@ -116,8 +119,12 @@ _ckipper_setup_prompts_pick_keys_fallback() { # keys, one per line, in schema order. _ckipper_setup_prompts_pick_keys() { if _ckipper_setup_prompts_use_gum; then - _ckipper_setup_prompts_global_keys \ - | gum choose --no-limit --header "$_CKIPPER_SETUP_PROMPTS_PICKER_HEADER" + local key + while IFS= read -r key; do + printf '%s — %s\n' "$key" "${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" + done < <(_ckipper_setup_prompts_global_keys) \ + | gum choose --no-limit --header "$_CKIPPER_SETUP_PROMPTS_PICKER_HEADER" \ + | awk '{print $1}' return 0 fi _ckipper_setup_prompts_pick_keys_fallback diff --git a/lib/setup/prompts_test.bats b/lib/setup/prompts_test.bats index 81ff702..6ac4f8e 100644 --- a/lib/setup/prompts_test.bats +++ b/lib/setup/prompts_test.bats @@ -67,6 +67,19 @@ _run_prompts() { [[ "$output" != *"ssh_forward"* ]] } +# Regression: descriptions used to live in a 4th column, which overflowed +# the fixed-width table because zsh's `printf '%-22s'` does not truncate. +# They now render on the line below each row, indented two spaces. +@test "_ckipper_setup_prompts_summary renders each schema description below its row" { + _run_prompts "" "_ckipper_setup_prompts_summary" + + [ "$status" -eq 0 ] + # Each schema description should appear verbatim somewhere in the output. + [[ "$output" == *"Bool. true = installer auto-adds the per-account aliases source line"* ]] + [[ "$output" == *"Path. Base directory containing your git projects."* ]] + [[ "$output" == *"Comma-separated int list. Container ports to forward to the host."* ]] +} + @test "_ckipper_setup_prompts_summary marks set values as (your config) and unset as (default)" { echo 'CKIPPER_NOTIFY_BELL="false"' >"$CKIPPER_DIR/docker/ckipper-config.zsh" From 22fac904e97d68d97631e6bd3e46c5cbf3ce4f2b Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 4 May 2026 19:39:08 -0600 Subject: [PATCH 06/31] =?UTF-8?q?fix(setup,sync):=201.0=20polish=20round?= =?UTF-8?q?=202=20=E2=80=94=20layout,=20completion=20handoff,=20cancel=20a?= =?UTF-8?q?udit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled fixes for issues uncovered when the user actually walked through `ckipper setup` end-to-end. Three parallel agents investigated cancel- propagation across the codebase, layout redesign, and wizard completeness; this commit synthesizes their findings. Detected configuration layout (Issue 1) Replaced the alternating wide-row + indented-description rendering with a card-style stack: each setting renders as ` ` on one line, with the schema description in dim color on the next, separated by blank lines. Removes the column-overflow problem (long values used to push the source marker out of alignment) and gives the eye a clear stopping point per setting. Empty values now render as `(empty)` instead of blank space. Wizard completion handoff (Issue 2) After a 5-minute docker build the old "Setup complete" header was buried in scrollback and the wizard exited silently. Now: - `_ckipper_setup_offer_image_build` records ok/failed/skipped and `_ckipper_setup_print_completion_summary` renders a coloured banner so a failed build is impossible to miss. - The summary lists `ckipper worktree rebuild-image` and `ckipper account sync` alongside the existing get-started commands so users can find them later without re-running the full wizard. - A "Press ENTER to finish setup" prompt anchors the screen so users know setup is over (skipped on non-TTY for CI). Per-account aliases auto-source (Issue 1b) `install.sh` appends the per-account aliases source line to ~/.zshrc, but a setup-only re-run never offered to. Added `_ckipper_setup_offer_aliases_source` with an idempotency check so re-runs don't duplicate the line. Without this, `claude-` launchers silently didn't work for users who only ran `ckipper setup`. Sync-after-2nd-add (Issue 3) Verified by trace: `_ckipper_setup_offer_initial_sync` correctly counts accounts AFTER the add and fires when count >= 2. No code change needed; the existing test covers this path. Rebuild-image discoverability (Issue 4a) `ckipper worktree rebuild-image` is the dedicated CLI; now mentioned in the completion summary's Maintenance block. Help text updated to list every wizard step including this one. Cancel propagation audit (Issue 5) Agent 1 found two unguarded sites in the sync interactive fallback (CKIPPER_NO_GUM=1 path): - `_ckipper_account_sync_pick_targets_fallback` - `_ckipper_account_sync_pick_types` (read fallback branch) Both now `|| return $?` after `_core_prompt_input` to propagate cancel. Other sites flagged by the audit (gum --no-limit pickers) are already protected by the dispatcher's empty-array length check + SPACE/ENTER hint added in PR #41. Tests - 520/520 shell tests pass (+10 over previous baseline). - New regressions: image build status (ok/failed/skipped), summary mentions rebuild-image + sync + failed-build banner, aliases-source skip/append/decline, sync interactive fallback cancel propagation. - Lint clean. --- lib/account/sync/interactive.zsh | 8 +- lib/account/sync/interactive_test.bats | 29 +++++++ lib/setup/dispatcher.zsh | 109 ++++++++++++++++++++----- lib/setup/dispatcher_test.bats | 85 +++++++++++++++++++ lib/setup/prompts.zsh | 52 +++++++----- 5 files changed, 238 insertions(+), 45 deletions(-) diff --git a/lib/account/sync/interactive.zsh b/lib/account/sync/interactive.zsh index adb69b5..1ab72c3 100644 --- a/lib/account/sync/interactive.zsh +++ b/lib/account/sync/interactive.zsh @@ -63,7 +63,10 @@ _ckipper_account_sync_pick_targets() { _ckipper_account_sync_pick_targets_fallback() { echo "Available targets: $*" >&2 local input - input=$(_core_prompt_input "Enter comma-separated targets" "") + # Propagate cancel (Esc/Ctrl-C/EOF) so the caller's empty-array check + # sees no targets and prints the SPACE/ENTER hint, instead of treating + # whatever shell garbage `$input` happens to contain as the list. + input=$(_core_prompt_input "Enter comma-separated targets" "") || return $? local name for name in ${(s:,:)input}; do echo "$name" @@ -87,7 +90,8 @@ _ckipper_account_sync_pick_types() { fi echo "Type tokens: ${(@k)_CKIPPER_SYNC_TYPE_LABEL}" >&2 local input - input=$(_core_prompt_input "Enter comma-separated types" "") + # Propagate cancel; same reasoning as pick_targets_fallback above. + input=$(_core_prompt_input "Enter comma-separated types" "") || return $? local name for name in ${(s:,:)input}; do echo "$name" diff --git a/lib/account/sync/interactive_test.bats b/lib/account/sync/interactive_test.bats index 1146059..1b57086 100644 --- a/lib/account/sync/interactive_test.bats +++ b/lib/account/sync/interactive_test.bats @@ -34,3 +34,32 @@ run_in_zsh() { [[ "$output" == *"client1,work,"* ]] [[ "$output" != *"personal"* ]] } + +# Regression: cancel from the comma-separated input prompt used to pass +# through as a 0-rc empty-output result, which the dispatcher then split +# into an empty array — masking cancel as "user submitted no targets". +# Now propagates the prompt's non-zero rc so callers can distinguish. +@test "_pick_targets_fallback returns non-zero on EOF (cancel)" { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_NO_GUM=1 TMP_HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/registry.zsh\"; \ + source \"$REPO_ROOT/lib/core/prompt.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/interactive.zsh\"; \ + _ckipper_account_sync_pick_targets_fallback work 2>/dev/null" /dev/null" Bundle worktree + Claude in one step" + echo " ck Interactive menu" + echo " claude- Per-account launcher (e.g. claude-personal)" echo "" - echo "Launch Claude in a worktree (host or Docker):" - echo " ckipper run # bundles worktree + Claude in one step" + echo "Maintenance:" + echo " ckipper config list Review every setting" + echo " ckipper doctor Diagnose installation issues" + echo " ckipper worktree rebuild-image Rebuild ckipper-dev Docker image" + echo " ckipper account sync Copy settings between accounts" echo "" - echo "Launch Claude directly with an account context:" - echo " claude- # auto-generated launcher" - echo " # bare-name shortcut, when free" +} + +# Render a single banner line about the docker image build outcome. Helps +# the user notice a build failure that would otherwise scroll past with +# the rest of `docker build` output. +# +# Args: $1 — `ok` | `failed` | `skipped`. +# Returns: 0 always. +_ckipper_setup_render_image_status() { + case "$1" in + ok) _core_style_color green "Docker image: built successfully." ;; + failed) _core_style_color red "Docker image: build FAILED — re-run with: ckipper worktree rebuild-image" ;; + skipped) _core_style_color dim "Docker image: skipped — build later with: ckipper worktree rebuild-image" ;; + esac echo "" - echo "Or just run 'ck' for the interactive menu." +} + +# Pause until the user presses Enter, so the "Setup complete" banner does +# not disappear off-screen behind the next shell prompt — particularly +# important when the docker build output preceded it. Skipped on +# non-interactive stdin (CI, piped installers). +# +# Returns: 0 always. +_ckipper_setup_wait_for_acknowledgement() { + [[ -t 0 ]] || return 0 + local _ack="" + read -r "_ack?Press ENTER to finish setup. " } # Print top-level setup help. @@ -90,7 +124,10 @@ _ckipper_setup_help() { " 1. Verifies prereqs (gum, jq, docker) and offers to brew-install missing." \ " 2. Shows your current global config and lets you customize any subset." \ " 3. Offers to register a Claude account and configure its preferences." \ - " 4. Offers to build the ckipper-dev Docker image." \ + " 4. Offers to sync settings between two existing accounts (≥ 2 accounts)." \ + " 5. Offers to wire per-account launchers (claude-) into ~/.zshrc." \ + " 6. Offers to build the ckipper-dev Docker image." \ + " 7. Prints a Setup Complete summary; press ENTER to finish." \ "" \ "Usage:" \ " ckipper setup Run the wizard." \ @@ -213,16 +250,46 @@ _ckipper_setup_collect_account_prefs() { "Forward host ~/.ssh into '$account' containers?" } -# Offer to build/rebuild the ckipper-dev Docker image now. +# Offer to build/rebuild the ckipper-dev Docker image now. Records the +# outcome in _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS so the completion +# summary can render a banner — without that signal, a failed build is +# easy to miss in the 5+ minutes of streaming docker output and the user +# would only discover it later when `--docker` runs hit "image not found." # -# We invoke the build helper directly rather than wrapping it in a spinner. -# `gum spin -- ` execs its argv as a binary, so passing a shell function -# fails with "executable file not found in $PATH". The build also streams -# its own progress over ~5 min, which the user wants to see. +# Sets _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS to one of: ok, failed, skipped. +# Returns: 0 always (failures are surfaced via the status global, not rc, +# so the wizard always finishes the post-build flow). +_ckipper_setup_offer_image_build() { + typeset -g _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS="skipped" + if ! _core_prompt_confirm "Build the Docker image now? (slow; ~5 min)"; then + return 0 + fi + if _ckipper_worktree_build_image; then + _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS="ok" + else + _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS="failed" + fi +} + +# Offer to add the per-account aliases source line to ~/.zshrc. The +# launchers (`claude-`, bare ``) only exist when the +# user's shell sources `~/.ckipper/aliases.zsh`. install.sh prints the +# suggestion but never appends it; setup-only re-runs (post-install) +# never see the suggestion at all. This step closes that loop, with an +# idempotency check so re-runs don't duplicate the line. # # Returns: 0 always. -_ckipper_setup_offer_image_build() { - if _core_prompt_confirm "Build the Docker image now? (slow; ~5 min)"; then - _ckipper_worktree_build_image +_ckipper_setup_offer_aliases_source() { + local zshrc="$HOME/.zshrc" + [[ -f "$zshrc" ]] || return 0 + grep -q 'ckipper/aliases\.zsh' "$zshrc" 2>/dev/null && return 0 + if ! _core_prompt_confirm "Add per-account launchers (claude-) to ~/.zshrc?"; then + return 0 fi + { + echo "" + echo "# Ckipper — per-account launchers (claude-, bare )" + echo '[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh' + } >> "$zshrc" + echo "Added the source line. Open a new shell (or run 'source ~/.zshrc')." } diff --git a/lib/setup/dispatcher_test.bats b/lib/setup/dispatcher_test.bats index bce5545..e507bac 100644 --- a/lib/setup/dispatcher_test.bats +++ b/lib/setup/dispatcher_test.bats @@ -117,6 +117,91 @@ JSON [[ "$output" == *"STUB-BUILD"* ]] } +# Regression: a failed docker build was easy to miss because 5 minutes of +# streaming output buried the completion message. The wizard now records +# build outcome (ok / failed / skipped) and the completion summary +# renders a colored banner that's findable at a glance. +@test "_ckipper_setup_offer_image_build records ok when build succeeds" { + _run_setup $'y\n' ' + _ckipper_worktree_build_image() { return 0; } + _ckipper_setup_offer_image_build + echo "status=$_CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS"' + + [[ "$output" == *"status=ok"* ]] +} + +@test "_ckipper_setup_offer_image_build records failed when build returns non-zero" { + _run_setup $'y\n' ' + _ckipper_worktree_build_image() { return 1; } + _ckipper_setup_offer_image_build + echo "status=$_CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS"' + + [[ "$output" == *"status=failed"* ]] +} + +@test "_ckipper_setup_offer_image_build records skipped when user declines" { + _run_setup $'n\n' ' + _ckipper_worktree_build_image() { echo SHOULD-NOT-RUN; } + _ckipper_setup_offer_image_build + echo "status=$_CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS"' + + [[ "$output" == *"status=skipped"* ]] + [[ "$output" != *"SHOULD-NOT-RUN"* ]] +} + +# Regression: completion summary previously listed only the basics; now +# it also points at `ckipper worktree rebuild-image` and `ckipper account +# sync` so users can find them without re-running the full wizard. +@test "_ckipper_setup_print_completion_summary mentions rebuild-image and sync" { + _run_setup "" "_ckipper_setup_print_completion_summary ok" + + [ "$status" -eq 0 ] + [[ "$output" == *"ckipper worktree rebuild-image"* ]] + [[ "$output" == *"ckipper account sync"* ]] +} + +# Regression: a build failure used to be invisible in the completion +# screen. Now the banner explicitly calls it out and points at the +# rebuild command. +@test "_ckipper_setup_print_completion_summary surfaces a failed build banner" { + _run_setup "" "_ckipper_setup_print_completion_summary failed" + + [ "$status" -eq 0 ] + [[ "$output" == *"FAILED"* ]] + [[ "$output" == *"rebuild-image"* ]] +} + +# Regression: setup never offered to wire the per-account aliases source +# line into ~/.zshrc. Users who installed via install.sh got it appended +# (line 158-160 of install.sh); users who only ever ran `ckipper setup` +# missed it and `claude-` launchers silently didn't work. +@test "_ckipper_setup_offer_aliases_source skips when ~/.zshrc already sources it" { + echo 'source ~/.ckipper/aliases.zsh' > "$TMP_HOME/.zshrc" + + _run_setup "" "_ckipper_setup_offer_aliases_source 2>&1" + + [ "$status" -eq 0 ] + [[ "$output" != *"Add per-account launchers"* ]] +} + +@test "_ckipper_setup_offer_aliases_source appends source line on accept" { + : > "$TMP_HOME/.zshrc" + + _run_setup $'y\n' "_ckipper_setup_offer_aliases_source 2>&1" + + [ "$status" -eq 0 ] + grep -q 'ckipper/aliases\.zsh' "$TMP_HOME/.zshrc" +} + +@test "_ckipper_setup_offer_aliases_source declines do not write to ~/.zshrc" { + : > "$TMP_HOME/.zshrc" + + _run_setup $'n\n' "_ckipper_setup_offer_aliases_source 2>&1" + + [ "$status" -eq 0 ] + ! grep -q 'ckipper/aliases\.zsh' "$TMP_HOME/.zshrc" +} + # Regression: setup previously offered cross-account sync only after the # user added a NEW account in the wizard. A user with 2+ existing accounts # who declined "Add another?" never saw the sync feature surfaced. The diff --git a/lib/setup/prompts.zsh b/lib/setup/prompts.zsh index f988f3a..4d11366 100644 --- a/lib/setup/prompts.zsh +++ b/lib/setup/prompts.zsh @@ -36,15 +36,20 @@ readonly _CKIPPER_SETUP_PROMPTS_HEADER="Detected configuration" # row and silently advance with no overrides. readonly _CKIPPER_SETUP_PROMPTS_PICKER_HEADER="Pick keys to customize (SPACE to mark, ENTER to confirm)" -# Pipe-separated row builder for the summary table. Resolves the effective -# value via _core_config_get and the source marker via _core_config_read_global -# (empty return ⇒ default; otherwise ⇒ user override). +# Width of the SETTING column in the card-style summary. 22 chars covers +# every key in the current schema (longest is `aliases_auto_source` at 19) +# with a 3-char gutter before the value. +readonly _CKIPPER_SETUP_PROMPTS_KEY_WIDTH=22 + +# Render one schema key as a two-line "card": ` ` +# on line 1 and the dim-colored description indented on line 2. Account +# keys are filtered out by the caller, so we don't have to handle scope here. # # Args: $1 — schema key. -# Returns: 0 always; prints "||" to stdout. -_ckipper_setup_prompts_summary_row() { +# Returns: 0 always; writes two lines (no trailing blank) to stdout. +_ckipper_setup_prompts_summary_card() { local key="$1" - local value source raw + local value source raw description display_value value=$(_core_config_get "$key") raw=$(_core_config_read_global "$key") if [[ -z "$raw" ]]; then @@ -52,7 +57,14 @@ _ckipper_setup_prompts_summary_row() { else source="$_CKIPPER_SETUP_PROMPTS_SOURCE_USER" fi - printf '%s|%s|%s\n' "$key" "$value" "$source" + # Empty values render as a discoverable placeholder rather than blank + # space, which otherwise reads like "the field is broken." + display_value="$value" + [[ -z "$display_value" ]] && display_value="(empty)" + description="${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" + printf '%-*s %s %s\n' \ + "$_CKIPPER_SETUP_PROMPTS_KEY_WIDTH" "$key" "$display_value" "$source" + [[ -n "$description" ]] && _core_style_color dim " $description" } # Print every global-scoped key one per line in lexical order. Used by the @@ -67,26 +79,22 @@ _ckipper_setup_prompts_global_keys() { done } -# Render the "detected configuration" summary table. Emits a styled header, -# followed by a SETTING | VALUE | SOURCE row per global-scoped key. Account -# keys are skipped because their effective value depends on which account the -# wizard is about to configure. +# Render the "detected configuration" summary as a stack of cards: each +# global key gets a ` ` line followed by a dim +# description, separated by blank lines. Replaces an earlier table-based +# rendering whose alternating wide-row + indented-description rhythm read +# as visually noisy and where long values overflowed the column padding. +# Account-scoped keys are intentionally skipped — their effective value +# depends on which account the wizard is about to configure. # # Returns: 0 always. _ckipper_setup_prompts_summary() { _core_style_header "$_CKIPPER_SETUP_PROMPTS_HEADER" - _core_style_table_print_row "SETTING|VALUE|SOURCE" - # Per-key block: aligned three-column row + indented description on the - # next line. We render this manually rather than feeding a 4-column row - # to `_core_style_table` because the schema descriptions can run 90+ - # characters and the fixed-width column padding (22 chars) would leave - # them overflowing across the screen and breaking column alignment for - # every other column. - local key description + local key first=1 while IFS= read -r key; do - _core_style_table_print_row "$(_ckipper_setup_prompts_summary_row "$key")" - description="${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" - [[ -n "$description" ]] && echo " $description" + (( first )) || echo + first=0 + _ckipper_setup_prompts_summary_card "$key" done < <(_ckipper_setup_prompts_global_keys) _core_style_divider } From 6cda4b7dd65e0e7b07aff1b7beda34bf7fb5a66e Mon Sep 17 00:00:00 2001 From: Matt White Date: Mon, 4 May 2026 19:55:29 -0600 Subject: [PATCH 07/31] fix(setup): style detected-config + completion screen with gum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 polish in response to user feedback that the previous card layout "still looks bad" and that the completion screen "doesn't match the format as the rest of the setup with the color and stuff." Detected configuration Renders through `gum table -p` with a rounded border tinted to gum's prompt-accent pink (212), so the block visually belongs to the same wizard as the Yes/No prompt below it. Auto-sizes columns to longest value; over-long values (e.g. 50+ char filesystem paths) are truncated with `…` to keep the table inside narrow terminals. Descriptions intentionally drop out of the summary — they appear as labels on the pick-keys-to-customize picker (added in PR #43), where they matter most. A dim-text tip below the table points users there. Falls back to a plain key/value/source list under CKIPPER_NO_GUM (tests, CI, hosts without gum). Same data, no border. Loop locals hoisted out of the body to dodge zsh's `local var` echo-on-redeclare quirk. Completion screen Wrapped in `gum style --border rounded --padding "1 2"` with a colored build-status row (✓ green / ✗ red / ○ dim) and bold section headers inside. Plain fallback path preserved for non-gum environments. Two sections — Getting started, Maintenance — each list 3-4 commands so users can find `ckipper worktree rebuild-image` and `ckipper account sync` without re-running the wizard. Tests - 521/521 pass. - Replaced the "renders descriptions inline" regression with two new pinning the new contract: descriptions DON'T appear in the summary body, and the summary points users at the picker for them. --- lib/setup/dispatcher.zsh | 98 +++++++++++++++++++++++++++--- lib/setup/prompts.zsh | 115 ++++++++++++++++++++++++------------ lib/setup/prompts_test.bats | 24 +++++--- 3 files changed, 183 insertions(+), 54 deletions(-) diff --git a/lib/setup/dispatcher.zsh b/lib/setup/dispatcher.zsh index 21d8b36..ce91d87 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -59,15 +59,82 @@ _ckipper_setup_offer_existing_sync() { _ckipper_account_sync_dispatch } -# Print the post-setup hint block: build-status banner, review/diagnose -# commands, two ways to launch Claude (per-account aliases or `ckipper -# run`), and the bare-`ck` menu. The build-status arg lets the user spot -# a failed image build at a glance — it's easy to miss otherwise because -# 5 minutes of streaming docker output buries the completion message. +# Print the post-setup completion screen: bordered gum-styled card with a +# build-status line and two columns of commands (getting-started and +# maintenance). The whole thing is one `gum style` block so it visually +# belongs to the same wizard as the gum-rendered prompts above it. A +# build failure is easy to miss after 5 minutes of streaming docker +# output; the colored status line is the primary signal. +# +# Falls back to plain ANSI rendering when CKIPPER_NO_GUM is set. # # Args: $1 — image build status: `ok` | `failed` | `skipped`. # Returns: 0 always. _ckipper_setup_print_completion_summary() { + local image_status="$1" + if _ckipper_setup_completion_use_gum; then + _ckipper_setup_render_completion_gum "$image_status" + else + _ckipper_setup_render_completion_plain "$image_status" + fi +} + +# Mirror of `_core_prompt_use_gum` — kept private so the completion path +# does not pull `_core_prompt_*` into its dependency surface. +# +# Returns: 0 if gum should drive rendering; 1 for the plain fallback. +_ckipper_setup_completion_use_gum() { + [[ "$CKIPPER_NO_GUM" == "1" ]] && return 1 + command -v gum >/dev/null 2>&1 +} + +# Render the completion screen via `gum style`. Pre-builds the inner +# content as a multi-line string so the border wraps the whole block. +# +# Args: $1 — image status (`ok` | `failed` | `skipped`). +# Returns: 0 always. +_ckipper_setup_render_completion_gum() { + local image_status="$1" + local content + content=$(_ckipper_setup_completion_inner "$image_status") + gum style \ + --border rounded \ + --padding "1 2" \ + --border-foreground "$_CKIPPER_SETUP_PROMPTS_BORDER_FG" \ + "$content" +} + +# Build the multi-line text content that goes inside the bordered card. +# The image-status line uses gum's foreground colors directly so the +# border block stays a single styled call. Sections are separated by +# blank lines for visual rhythm inside the card. +# +# Args: $1 — image status. +# Returns: 0 always; prints the multi-line content to stdout. +_ckipper_setup_completion_inner() { + local image_status="$1" + gum style --bold --foreground "$_CKIPPER_SETUP_PROMPTS_BORDER_FG" "Setup complete" + echo + _ckipper_setup_render_image_status_gum "$image_status" + echo + gum style --bold "Getting started:" + echo " ckipper run Bundle worktree + Claude" + echo " ck Interactive menu" + echo " claude- Per-account launcher (e.g. claude-personal)" + echo + gum style --bold "Maintenance:" + echo " ckipper config list Review every setting" + echo " ckipper doctor Diagnose installation issues" + echo " ckipper worktree rebuild-image Rebuild ckipper-dev Docker image" + echo " ckipper account sync Copy settings between accounts" +} + +# Plain-text completion screen for non-gum environments (CI, tests). Same +# information, no border or color. +# +# Args: $1 — image status. +# Returns: 0 always. +_ckipper_setup_render_completion_plain() { local image_status="$1" _core_style_header "Setup complete" _ckipper_setup_render_image_status "$image_status" @@ -84,9 +151,24 @@ _ckipper_setup_print_completion_summary() { echo "" } -# Render a single banner line about the docker image build outcome. Helps -# the user notice a build failure that would otherwise scroll past with -# the rest of `docker build` output. +# Render the docker-build-status line for the gum path using gum's +# foreground color codes (gum-color 46 = bright green, 196 = red, 244 = +# dim gray) so it nests cleanly inside the surrounding `gum style` block. +# +# Args: $1 — `ok` | `failed` | `skipped`. +# Returns: 0 always. +_ckipper_setup_render_image_status_gum() { + case "$1" in + ok) gum style --foreground 46 "✓ Docker image: built successfully." ;; + failed) gum style --foreground 196 "✗ Docker image: build FAILED — re-run: ckipper worktree rebuild-image" ;; + skipped) gum style --foreground 244 "○ Docker image: skipped — build later: ckipper worktree rebuild-image" ;; + esac +} + +# Plain-text image-status line (no gum). Uses the existing _core_style +# color palette so terminals that support ANSI still get a coloured +# banner; the no-color path falls through to plain text via +# _core_style_color's enablement check. # # Args: $1 — `ok` | `failed` | `skipped`. # Returns: 0 always. diff --git a/lib/setup/prompts.zsh b/lib/setup/prompts.zsh index 4d11366..1c07dbe 100644 --- a/lib/setup/prompts.zsh +++ b/lib/setup/prompts.zsh @@ -36,35 +36,50 @@ readonly _CKIPPER_SETUP_PROMPTS_HEADER="Detected configuration" # row and silently advance with no overrides. readonly _CKIPPER_SETUP_PROMPTS_PICKER_HEADER="Pick keys to customize (SPACE to mark, ENTER to confirm)" -# Width of the SETTING column in the card-style summary. 22 chars covers +# Border-foreground color for gum-styled summary blocks. 212 is gum's +# default pink — matches the prompt accent (Yes/No buttons) so the +# detected-config block visually belongs to the same wizard. +readonly _CKIPPER_SETUP_PROMPTS_BORDER_FG=212 + +# SETTING column width for the no-gum fallback summary. 22 chars covers # every key in the current schema (longest is `aliases_auto_source` at 19) # with a 3-char gutter before the value. readonly _CKIPPER_SETUP_PROMPTS_KEY_WIDTH=22 -# Render one schema key as a two-line "card": ` ` -# on line 1 and the dim-colored description indented on line 2. Account -# keys are filtered out by the caller, so we don't have to handle scope here. +# Maximum width of the VALUE cell before truncation. Caps the table width +# so it stays readable in narrow terminals and during shared-screen demos +# without hiding the at-a-glance setting/source signal — the full value +# is one `ckipper config get ` away. +readonly _CKIPPER_SETUP_PROMPTS_VALUE_MAX_WIDTH=40 + +# Build the pipe-separated row data for the summary table — one header +# row plus one row per global-scoped key. Emits to stdout for callers to +# pipe into `gum table -p` or to consume directly in fallback rendering. # -# Args: $1 — schema key. -# Returns: 0 always; writes two lines (no trailing blank) to stdout. -_ckipper_setup_prompts_summary_card() { - local key="$1" - local value source raw description display_value - value=$(_core_config_get "$key") - raw=$(_core_config_read_global "$key") - if [[ -z "$raw" ]]; then - source="$_CKIPPER_SETUP_PROMPTS_SOURCE_DEFAULT" - else - source="$_CKIPPER_SETUP_PROMPTS_SOURCE_USER" - fi - # Empty values render as a discoverable placeholder rather than blank - # space, which otherwise reads like "the field is broken." - display_value="$value" - [[ -z "$display_value" ]] && display_value="(empty)" - description="${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" - printf '%-*s %s %s\n' \ - "$_CKIPPER_SETUP_PROMPTS_KEY_WIDTH" "$key" "$display_value" "$source" - [[ -n "$description" ]] && _core_style_color dim " $description" +# Returns: 0 always; prints `||` rows (header first). +_ckipper_setup_prompts_summary_rows() { + printf 'SETTING|VALUE|SOURCE\n' + # Hoist loop locals — see fallback rendering for the reason. + local key="" value="" source="" raw="" display_value="" + while IFS= read -r key; do + value=$(_core_config_get "$key") + raw=$(_core_config_read_global "$key") + if [[ -z "$raw" ]]; then + source="$_CKIPPER_SETUP_PROMPTS_SOURCE_DEFAULT" + else + source="$_CKIPPER_SETUP_PROMPTS_SOURCE_USER" + fi + # Empty values render as a discoverable placeholder rather than + # blank space, which otherwise reads like "the field is broken." + display_value="$value" + [[ -z "$display_value" ]] && display_value="(empty)" + # Truncate over-long values so the table doesn't blow past + # narrow terminals. Trailing `…` signals truncation. + if (( ${#display_value} > _CKIPPER_SETUP_PROMPTS_VALUE_MAX_WIDTH )); then + display_value="${display_value[1,_CKIPPER_SETUP_PROMPTS_VALUE_MAX_WIDTH-1]}…" + fi + printf '%s|%s|%s\n' "$key" "$display_value" "$source" + done < <(_ckipper_setup_prompts_global_keys) } # Print every global-scoped key one per line in lexical order. Used by the @@ -79,24 +94,48 @@ _ckipper_setup_prompts_global_keys() { done } -# Render the "detected configuration" summary as a stack of cards: each -# global key gets a ` ` line followed by a dim -# description, separated by blank lines. Replaces an earlier table-based -# rendering whose alternating wide-row + indented-description rhythm read -# as visually noisy and where long values overflowed the column padding. -# Account-scoped keys are intentionally skipped — their effective value -# depends on which account the wizard is about to configure. +# Render the detected-configuration summary as a `gum table -p` styled +# table — auto-sizes columns to the longest value in each column and +# adapts to the terminal width. Schema descriptions are intentionally +# omitted from the summary (they were a 4th-column overflow problem in +# both prior layouts); descriptions surface as labels in the +# pick-keys-to-customize picker (``), so the user +# sees them at the moment they're deciding what to change. +# +# Falls back to a plain-text two-column layout under CKIPPER_NO_GUM (tests, +# non-TTY callers, and runners without gum installed). # # Returns: 0 always. _ckipper_setup_prompts_summary() { _core_style_header "$_CKIPPER_SETUP_PROMPTS_HEADER" - local key first=1 - while IFS= read -r key; do - (( first )) || echo - first=0 - _ckipper_setup_prompts_summary_card "$key" - done < <(_ckipper_setup_prompts_global_keys) - _core_style_divider + if _ckipper_setup_prompts_use_gum; then + _ckipper_setup_prompts_summary_rows \ + | gum table -p -s '|' --border rounded \ + --border.foreground "$_CKIPPER_SETUP_PROMPTS_BORDER_FG" + else + _ckipper_setup_prompts_summary_fallback + fi + _core_style_color dim \ + "Tip: pick a setting below to see its description and edit it." +} + +# Plain-text fallback when gum is unavailable. Renders the same data as +# ` ` — no borders, no colors, but parsable for +# tests and non-TTY callers. +# +# Returns: 0 always. +_ckipper_setup_prompts_summary_fallback() { + # Hoist loop locals outside the body — re-declaring `local var` (no + # =value) on iteration N>1 makes zsh print `var='prior_value'` since + # the variable carries a value from the previous iteration. Same idiom + # used in lib/worktree/worktree.zsh's list helper for the same reason. + local row="" key="" value="" source="" first=1 + while IFS= read -r row; do + (( first )) && { first=0; continue; } # skip header row + IFS='|' read -r key value source <<<"$row" + printf '%-*s %-28s %s\n' \ + "$_CKIPPER_SETUP_PROMPTS_KEY_WIDTH" "$key" "$value" "$source" + done < <(_ckipper_setup_prompts_summary_rows) } # Decide whether to use gum for the picker. Mirrors `_core_prompt_use_gum` but diff --git a/lib/setup/prompts_test.bats b/lib/setup/prompts_test.bats index 6ac4f8e..f8aa0ca 100644 --- a/lib/setup/prompts_test.bats +++ b/lib/setup/prompts_test.bats @@ -67,17 +67,25 @@ _run_prompts() { [[ "$output" != *"ssh_forward"* ]] } -# Regression: descriptions used to live in a 4th column, which overflowed -# the fixed-width table because zsh's `printf '%-22s'` does not truncate. -# They now render on the line below each row, indented two spaces. -@test "_ckipper_setup_prompts_summary renders each schema description below its row" { +# Regression: descriptions intentionally do NOT appear in the summary +# anymore — they live in the pick-keys-to-customize picker labels (added +# in PR #43) so the user sees them at the moment they decide what to +# change. The summary stays compact and renders cleanly through gum's +# styled table. This test pins the new contract: descriptions in picker, +# not summary. +@test "_ckipper_setup_prompts_summary does not embed descriptions inline" { _run_prompts "" "_ckipper_setup_prompts_summary" [ "$status" -eq 0 ] - # Each schema description should appear verbatim somewhere in the output. - [[ "$output" == *"Bool. true = installer auto-adds the per-account aliases source line"* ]] - [[ "$output" == *"Path. Base directory containing your git projects."* ]] - [[ "$output" == *"Comma-separated int list. Container ports to forward to the host."* ]] + [[ "$output" != *"Bool. true = installer auto-adds the per-account aliases source line"* ]] + [[ "$output" != *"Comma-separated int list. Container ports to forward to the host."* ]] +} + +@test "_ckipper_setup_prompts_summary points at the picker for descriptions" { + _run_prompts "" "_ckipper_setup_prompts_summary" + + [ "$status" -eq 0 ] + [[ "$output" == *"description"* ]] || [[ "$output" == *"Tip:"* ]] } @test "_ckipper_setup_prompts_summary marks set values as (your config) and unset as (default)" { From 5088507648b16141c1be509b500b2858c57511b2 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 15:38:31 -0600 Subject: [PATCH 08/31] refactor(core): parametrize registry primitives on file path Add _core_registry_update_at / _init_at / _check_version_at / _migrate_v1_to_v2_at variants that take an explicit registry file path; existing zero-arg wrappers delegate with $CKIPPER_REGISTRY as default. Lock paths and tmpfiles derive from the file path so multiple registries (accounts.json, desktop.json) do not contend on a shared lock. Prep for the desktop multi-instance feature, which needs its own registry file. --- lib/core/registry.zsh | 160 +++++++++++++++++++++++++++--------- lib/core/registry_test.bats | 56 ++++++++++++- 2 files changed, 175 insertions(+), 41 deletions(-) diff --git a/lib/core/registry.zsh b/lib/core/registry.zsh index 8d020ed..b6454dc 100644 --- a/lib/core/registry.zsh +++ b/lib/core/registry.zsh @@ -10,22 +10,24 @@ readonly _CORE_REGISTRY_LOCK_RETRY_INTERVAL_SECONDS=0.05 # Perform an atomic registry update via flock (Linux/GNU systems). # # Args: -# $1 — jq filter string +# $1 — registry file path (lock + tmpfile derive from this). +# $2 — jq filter string # $@ — remaining args passed to jq # # Returns: # 0 on success; 1 on jq or write failure. _core_registry_update_with_flock() { + local registry_file="$1"; shift local jq_filter="$1"; shift - local lock="$CKIPPER_DIR/.registry.lock" + local lock="${registry_file}.lock" local rc=1 : > "$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 "$_CORE_REGISTRY_FILE_PERMS" "$CKIPPER_REGISTRY" + local registry_tmpfile; registry_tmpfile=$(mktemp "${registry_file:h}/.registry.tmp.XXXXXX") + if jq "$@" "$jq_filter" "$registry_file" > "$registry_tmpfile" 2>/dev/null; then + mv "$registry_tmpfile" "$registry_file" + chmod "$_CORE_REGISTRY_FILE_PERMS" "$registry_file" rc=0 else rm -f "$registry_tmpfile" @@ -116,15 +118,17 @@ _core_registry_acquire_mkdir_lock() { # Perform an atomic registry update via mkdir lock (macOS fallback — no flock). # # Args: -# $1 — jq filter string +# $1 — registry file path (lock + tmpfile derive from this). +# $2 — 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 registry_file="$1"; shift local jq_filter="$1"; shift setopt local_options local_traps - local lockdir="$CKIPPER_DIR/.registry.lock.d" + local lockdir="${registry_file}.lock.d" _core_registry_acquire_mkdir_lock "$lockdir" || return 1 # Trap lives in this function (not in acquire) so it fires when the # critical section is done — not when acquire returns mid-critical-section. @@ -133,17 +137,19 @@ _core_registry_update_mkdir_fallback() { # local $lockdir is out of scope, so a deferred-expansion form (single quotes) # would expand to the empty string and rmdir would silently no-op. trap "rmdir '$lockdir' 2>/dev/null" EXIT - 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 "$_CORE_REGISTRY_FILE_PERMS" "$CKIPPER_REGISTRY" + local registry_tmpfile; registry_tmpfile=$(mktemp "${registry_file:h}/.registry.tmp.XXXXXX") + if jq "$@" "$jq_filter" "$registry_file" > "$registry_tmpfile" 2>/dev/null; then + mv "$registry_tmpfile" "$registry_file" + chmod "$_CORE_REGISTRY_FILE_PERMS" "$registry_file" return 0 fi rm -f "$registry_tmpfile" return 1 } -# Atomic registry write under flock (or mkdir-fallback for macOS). +# Atomic registry write under flock (or mkdir-fallback for macOS) on the +# default registry ($CKIPPER_REGISTRY). See _core_registry_update_at for the +# parametrized form. # # Args: # $1 — jq filter string; jq error() calls propagate as non-zero exit. @@ -152,16 +158,32 @@ _core_registry_update_mkdir_fallback() { # Returns: # 0 on successful jq+write; 1 on jq error or write failure. _core_registry_update() { - mkdir -p "$CKIPPER_DIR" + _core_registry_update_at "$CKIPPER_REGISTRY" "$@" +} + +# Atomic registry write on an arbitrary registry file. Lock paths and +# tmpfiles derive from the file path so multiple registries (accounts.json, +# desktop.json) do not contend on a shared lock. +# +# Args: +# $1 — registry file path. +# $2 — 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_at() { + local registry_file="$1"; shift + mkdir -p "${registry_file:h}" if command -v flock >/dev/null 2>&1; then - _core_registry_update_with_flock "$@" + _core_registry_update_with_flock "$registry_file" "$@" else - _core_registry_update_mkdir_fallback "$@" + _core_registry_update_mkdir_fallback "$registry_file" "$@" 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. +# Initialize an empty default registry ($CKIPPER_REGISTRY) with version field. +# See _core_registry_init_at for the parametrized form. # # Returns: # 0 always. @@ -169,18 +191,35 @@ _core_registry_update() { # Errors (stderr): # "Error: CKIPPER_REGISTRY_VERSION is not a positive integer" — when version var is invalid. _core_registry_init() { - [[ -f "$CKIPPER_REGISTRY" ]] && return 0 + _core_registry_init_at "$CKIPPER_REGISTRY" +} + +# Initialize an empty registry file with version field. Idempotent under +# concurrency via atomic create (mv -n) — two concurrent ckipper init's won't +# clobber each other. +# +# Args: +# $1 — registry file path. +# +# Returns: +# 0 always (or 1 on invalid version env var). +# +# Errors (stderr): +# "Error: CKIPPER_REGISTRY_VERSION is not a positive integer" — when version var is invalid. +_core_registry_init_at() { + local registry_file="$1" + [[ -f "$registry_file" ]] && 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") + mkdir -p "${registry_file:h}" + local registry_tmpfile; registry_tmpfile=$(mktemp "${registry_file:h}/.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 "$_CORE_REGISTRY_FILE_PERMS" "$CKIPPER_REGISTRY" + mv -n "$registry_tmpfile" "$registry_file" 2>/dev/null || rm -f "$registry_tmpfile" + [[ -f "$registry_file" ]] && chmod "$_CORE_REGISTRY_FILE_PERMS" "$registry_file" } # Build a JSON object of every account-scope schema key with its default @@ -211,10 +250,8 @@ _core_registry_account_defaults_json() { echo "{${entries%,}}" } -# 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. +# Auto-migrate the default v1 registry ($CKIPPER_REGISTRY) to v2 in place. +# See _core_registry_migrate_v1_to_v2_at for the parametrized form. # # Returns: # 0 on successful migration; 1 if backup write or jq update failed. @@ -222,14 +259,32 @@ _core_registry_account_defaults_json() { # 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 + _core_registry_migrate_v1_to_v2_at "$CKIPPER_REGISTRY" +} + +# Auto-migrate a v1 registry file to v2 in place. Backs up the v1 file +# (refuses to migrate without a backup), then rewrites it with .version=2 and +# a per-account .preferences block. Existing preferences win over defaults so +# partial-v2 fixtures keep their values. +# +# Args: +# $1 — registry file path. +# +# 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_at() { + local registry_file="$1" + local backup="${registry_file}.v1.bak.$(date -u +%Y%m%dT%H%M%SZ)" + if ! cp "$registry_file" "$backup" 2>/dev/null; then echo "Error: failed to write migration backup $backup" >&2 return 1 fi local defaults defaults=$(_core_registry_account_defaults_json) - _core_registry_update ' + _core_registry_update_at "$registry_file" ' .version = 2 | .accounts = ( .accounts | with_entries( @@ -239,9 +294,12 @@ _core_registry_migrate_v1_to_v2() { ' --argjson defaults "$defaults" } -# 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. +# Refuse to operate on the default registry ($CKIPPER_REGISTRY) when its +# version is unsupported or its schema is corrupt. Wraps the parametrized +# version check with the accounts.json-specific schema assertion (.accounts +# must be a JSON object). See _core_registry_check_version_at for a +# version-only check that does not enforce the accounts schema (used for +# alternate registries with different shapes). # # Returns: # 0 if registry is absent or valid; 1 on version mismatch, migration failure, @@ -252,24 +310,48 @@ _core_registry_migrate_v1_to_v2() { # "Error: registry version..." — on version mismatch. # "Error: ... is corrupt..." — on bad schema. _core_registry_check_version() { + _core_registry_check_version_at "$CKIPPER_REGISTRY" || return 1 [[ ! -f "$CKIPPER_REGISTRY" ]] && return 0 + _core_registry_assert_accounts_object || return 1 +} + +# Refuse to operate on a registry file whose version we don't understand. +# Auto-migrates a v1 registry to v2 (with backup) before checking the version. +# Does NOT enforce the accounts.json-specific schema shape — alternate +# registries (e.g. desktop.json) have different top-level keys. The default +# registry wrapper _core_registry_check_version layers that assertion on top. +# +# Args: +# $1 — registry file path. +# +# Returns: +# 0 if registry is absent or valid; 1 on version mismatch or migration failure. +# +# Errors (stderr): +# "Migrating accounts.json v1 → v2..." — informational notice during auto-migration. +# "Error: registry version..." — on version mismatch. +_core_registry_check_version_at() { + local registry_file="$1" + [[ ! -f "$registry_file" ]] && return 0 local cur - cur=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + cur=$(jq -r '.version // 0' "$registry_file" 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 + _core_registry_migrate_v1_to_v2_at "$registry_file" || return 1 fi local v - v=$(jq -r '.version // 0' "$CKIPPER_REGISTRY" 2>/dev/null) + v=$(jq -r '.version // 0' "$registry_file" 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 - _core_registry_assert_accounts_object || return 1 + return 0 } -# 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. +# Verify that .accounts in the default registry ($CKIPPER_REGISTRY) is a +# JSON object (not an array or other type). Surface a clear error with +# manual-recovery instructions when it isn't. This is accounts.json-specific +# and intentionally not parametrized. # # Returns: # 0 if the schema looks valid; 1 if .accounts is corrupt. diff --git a/lib/core/registry_test.bats b/lib/core/registry_test.bats index 20ae488..7505faa 100644 --- a/lib/core/registry_test.bats +++ b/lib/core/registry_test.bats @@ -130,7 +130,7 @@ _run_registry() { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" chmod 600 "$CKIPPER_REGISTRY" - _run_registry '_core_registry_update_mkdir_fallback ".default = \"alice\""' + _run_registry '_core_registry_update_mkdir_fallback "$CKIPPER_REGISTRY" ".default = \"alice\""' [ "$status" -eq 0 ] [[ ! -d "$CKIPPER_DIR/.registry.lock.d" ]] @@ -143,8 +143,60 @@ _run_registry() { echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" chmod 600 "$CKIPPER_REGISTRY" - _run_registry '_core_registry_update_mkdir_fallback "this is not a valid jq filter @@@"' + _run_registry '_core_registry_update_mkdir_fallback "$CKIPPER_REGISTRY" "this is not a valid jq filter @@@"' [ "$status" -ne 0 ] [[ ! -d "$CKIPPER_DIR/.registry.lock.d" ]] } + +@test "_core_registry_update_at writes to an alternate file" { + local alt="$BATS_TEST_TMPDIR/alt.json" + echo '{"version":1,"items":{}}' > "$alt" + _run_registry "_core_registry_update_at \"$alt\" '.items.x = \"hi\"'" + + [ "$status" -eq 0 ] + [ "$(jq -r '.items.x' "$alt")" = "hi" ] +} + +@test "_core_registry_update_at uses lock paths derived from the file path" { + local alt="$BATS_TEST_TMPDIR/alt.json" + echo '{"version":1,"items":{}}' > "$alt" + _run_registry "_core_registry_update_at \"$alt\" '.items.x = \"y\"'" + + [ "$status" -eq 0 ] + # Default registry untouched. + [ ! -f "$CKIPPER_REGISTRY" ] || ! jq -e '.items' "$CKIPPER_REGISTRY" >/dev/null 2>&1 +} + +@test "_core_registry_init_at initializes an alternate file" { + local alt="$BATS_TEST_TMPDIR/alt.json" + _run_registry "_core_registry_init_at \"$alt\"" + + [ "$status" -eq 0 ] + [ -f "$alt" ] + [ "$(jq -r '.version' "$alt")" = "1" ] +} + +@test "_core_registry_check_version_at accepts an alternate file" { + local alt="$BATS_TEST_TMPDIR/alt.json" + echo '{"version":1,"items":{}}' > "$alt" + _run_registry "_core_registry_check_version_at \"$alt\"" + + [ "$status" -eq 0 ] +} + +@test "_core_registry_check_version_at fails on version mismatch in alternate file" { + local alt="$BATS_TEST_TMPDIR/alt.json" + echo '{"version":99,"items":{}}' > "$alt" + _run_registry "_core_registry_check_version_at \"$alt\"" + + [ "$status" -ne 0 ] +} + +@test "_core_registry_update zero-arg wrapper still works (regression)" { + echo '{"version":1,"default":null,"accounts":{}}' > "$CKIPPER_REGISTRY" + _run_registry '_core_registry_update ".default = \"bob\""' + + [ "$status" -eq 0 ] + [ "$(jq -r '.default' "$CKIPPER_REGISTRY")" = "bob" ] +} From 4691067e1bd0182cf2944427db3238e7d4c0336d Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 15:44:05 -0600 Subject: [PATCH 09/31] feat(core): declare CKIPPER_DESKTOP_REGISTRY constants $CKIPPER_DIR/desktop.json at version 1. Used by the new lib/desktop/ feature in subsequent commits. --- ckipper.zsh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ckipper.zsh b/ckipper.zsh index 2000ee0..05ed96b 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -11,6 +11,8 @@ CKIPPER_DIR="${CKIPPER_DIR:-$HOME/.ckipper}" CKIPPER_REGISTRY="$CKIPPER_DIR/accounts.json" CKIPPER_REGISTRY_VERSION=2 +CKIPPER_DESKTOP_REGISTRY="$CKIPPER_DIR/desktop.json" +CKIPPER_DESKTOP_REGISTRY_VERSION=1 CKIPPER_REPO_DIR="${0:A:h}" From f5f1d75dcfef651fc8293e06fbe2783cbf3e4739 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 15:51:50 -0600 Subject: [PATCH 10/31] feat(desktop): scaffold lib/desktop/ dispatcher + help Adds the routing skeleton for the new `ckipper desktop` subcommand namespace. Subcommand handlers are stubbed and will be implemented in subsequent commits. - New feature dir lib/desktop/ with dispatcher.zsh + help.zsh - New top-level command 'desktop' with short alias 'dt' - macOS-only guard at the dispatcher entry - Per-subcommand --help / -h is short-circuited to focused help text --- ckipper.zsh | 9 +- lib/desktop/dispatcher.zsh | 77 ++++++++++++++++ lib/desktop/dispatcher_test.bats | 95 ++++++++++++++++++++ lib/desktop/help.zsh | 147 +++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 lib/desktop/dispatcher.zsh create mode 100644 lib/desktop/dispatcher_test.bats create mode 100644 lib/desktop/help.zsh diff --git a/ckipper.zsh b/ckipper.zsh index 05ed96b..9e06bc6 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -67,6 +67,10 @@ source "$CKIPPER_REPO_DIR/lib/config/list.zsh" source "$CKIPPER_REPO_DIR/lib/config/edit.zsh" source "$CKIPPER_REPO_DIR/lib/config/dispatcher.zsh" +# Desktop-namespace modules +source "$CKIPPER_REPO_DIR/lib/desktop/help.zsh" +source "$CKIPPER_REPO_DIR/lib/desktop/dispatcher.zsh" + # Setup-namespace modules source "$CKIPPER_REPO_DIR/lib/setup/prereqs.zsh" source "$CKIPPER_REPO_DIR/lib/setup/prompts.zsh" @@ -92,7 +96,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 run config setup doctor help) +_CKIPPER_COMMANDS=(account worktree run config desktop setup 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`) @@ -128,12 +132,14 @@ ckipper() { case "$cmd" in acct) cmd="account" ;; wt) cmd="worktree" ;; + dt) cmd="desktop" ;; esac case "$cmd" in account) _ckipper_account_dispatch "$@" ;; worktree) _ckipper_worktree_dispatch "$@" ;; run) _ckipper_run "$@" ;; config) _ckipper_config_dispatch "$@" ;; + desktop) _ckipper_desktop_dispatch "$@" ;; setup) _ckipper_setup "$@" ;; doctor) if [[ "$1" == "--help" || "$1" == "-h" ]]; then @@ -181,6 +187,7 @@ _ckipper_help() { " ckipper worktree Manage git worktrees (alias: wt)" \ " ckipper run Shortcut for \`ckipper worktree run\`" \ " ckipper config View and modify Ckipper settings" \ + " ckipper desktop Manage Claude Desktop instances (alias: dt)" \ " ckipper setup Run / re-run the interactive setup wizard" \ " ckipper doctor Diagnostic check of accounts and tooling" \ " ckipper help Show this overview" \ diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh new file mode 100644 index 0000000..787b67f --- /dev/null +++ b/lib/desktop/dispatcher.zsh @@ -0,0 +1,77 @@ +#!/usr/bin/env zsh +# Desktop-namespace dispatcher. Routes `ckipper desktop ` +# to the matching _ckipper_desktop_* function, prints overview/per- +# subcommand help, and suggests the closest subcommand on a typo via +# _core_fuzzy_suggest. +# +# Refuses to operate on non-macOS hosts at the dispatcher entry — +# Desktop multi-instance relies on macOS-specific facilities (`open -n -a`, +# `lsregister`, `.app` bundle format). + +# Known desktop subcommands. Used both for routing and for fuzzy-suggest. +_CKIPPER_DESKTOP_SUBCOMMANDS=( + add list remove rename login launch help +) + +# Dispatch a `desktop` subcommand. +# +# Args: +# $1 — subcommand name (add, list, remove, rename, login, launch, +# help, -h, --help, or empty) +# $2..$N — arguments forwarded to the subcommand handler +# +# Returns: 0 on success; 1 on unknown subcommand or non-macOS host. +# +# Errors (stderr): +# "ckipper desktop is macOS-only ..." — when OSTYPE != darwin* +# "Unknown command: ''. Did you mean ..." — on a typo +_ckipper_desktop_dispatch() { + _ckipper_desktop_assert_macos || return 1 + local cmd="$1" + shift 2>/dev/null + case "$cmd" in + add|list|remove|rename|login|launch) + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + _ckipper_desktop_help_for "$cmd" + return 0 + fi + "_ckipper_desktop_${cmd}" "$@" + ;; + ""|help|-h|--help) _ckipper_desktop_help ;; + *) _ckipper_desktop_unknown "$cmd"; return 1 ;; + esac +} + +# Refuse to run on non-macOS hosts. Uses the _CKIPPER_TEST_OSTYPE override +# for tests (same pattern as lib/core/keychain.zsh and lib/account/doctor.zsh). +# +# Returns: 0 if running on macOS; 1 otherwise. +# Errors (stderr): "ckipper desktop is macOS-only ..." when refusing. +_ckipper_desktop_assert_macos() { + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]] && return 0 + echo "ckipper desktop is macOS-only (Claude Desktop runs on macOS / Windows; only macOS is supported here)." >&2 + return 1 +} + +# Print an unknown-subcommand line with fuzzy suggestion and help pointer. +# All output goes to stderr via _core_unknown_command. +# +# Args: $1 — the unknown subcommand the user typed. +# Returns: 0 always. +_ckipper_desktop_unknown() { + local cmd="$1" + _core_unknown_command "$cmd" \ + "Run 'ckipper desktop help' for available commands." \ + "${_CKIPPER_DESKTOP_SUBCOMMANDS[@]}" +} + +# --- TEMPORARY STUBS (deleted as Tasks 5..11 land the real implementations) --- +# Each stub returns 1 so users typing them get a "not yet implemented" signal. +# The task number is embedded in each message for grep-ability when wiring up +# the real handlers. +_ckipper_desktop_add() { echo "ckipper desktop add: not yet implemented (Task 5)" >&2; return 1; } +_ckipper_desktop_list() { echo "ckipper desktop list: not yet implemented (Task 6)" >&2; return 1; } +_ckipper_desktop_remove() { echo "ckipper desktop remove: not yet implemented (Task 7)" >&2; return 1; } +_ckipper_desktop_rename() { echo "ckipper desktop rename: not yet implemented (Task 8)" >&2; return 1; } +_ckipper_desktop_login() { echo "ckipper desktop login: not yet implemented (Task 10)" >&2; return 1; } +_ckipper_desktop_launch() { echo "ckipper desktop launch: not yet implemented (Task 11)" >&2; return 1; } diff --git a/lib/desktop/dispatcher_test.bats b/lib/desktop/dispatcher_test.bats new file mode 100644 index 0000000..741eb83 --- /dev/null +++ b/lib/desktop/dispatcher_test.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# Module-level tests for lib/desktop/dispatcher.zsh. +# Verifies routing, help, macOS-guard, and fuzzy-suggest behaviour. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export _CKIPPER_TEST_OSTYPE="darwin19.0" +} + +teardown() { + teardown_isolated_env +} + +# Helper: run desktop dispatcher in a zsh subshell with all its dependencies +# (fuzzy.zsh, style.zsh, help.zsh, desktop help.zsh, desktop dispatcher.zsh) +# sourced and the subcommand handlers stubbed so routing can be exercised +# independently of feature code. +_run_dispatch() { + run env HOME="$TMP_HOME" PATH="$PATH" _CKIPPER_TEST_OSTYPE="$_CKIPPER_TEST_OSTYPE" \ + zsh -c " + source \"$REPO_ROOT/lib/core/fuzzy.zsh\" + source \"$REPO_ROOT/lib/core/style.zsh\" + source \"$REPO_ROOT/lib/core/help.zsh\" + source \"$REPO_ROOT/lib/desktop/help.zsh\" + source \"$REPO_ROOT/lib/desktop/dispatcher.zsh\" + _ckipper_desktop_add() { echo 'STUB-ADD' \"\$@\"; } + _ckipper_desktop_list() { echo 'STUB-LIST'; } + _ckipper_desktop_remove() { echo 'STUB-REMOVE'; } + _ckipper_desktop_rename() { echo 'STUB-RENAME'; } + _ckipper_desktop_login() { echo 'STUB-LOGIN'; } + _ckipper_desktop_launch() { echo 'STUB-LAUNCH'; } + _ckipper_desktop_dispatch $* + " +} + +@test "dispatch routes 'list' to _ckipper_desktop_list" { + _run_dispatch list + + [ "$status" -eq 0 ] + [ "$output" = "STUB-LIST" ] +} + +@test "dispatch routes 'add' with arguments to _ckipper_desktop_add" { + _run_dispatch add work + + [ "$status" -eq 0 ] + [[ "$output" =~ "STUB-ADD" ]] +} + +@test "dispatch short-circuits 'add --help' to per-subcommand help" { + _run_dispatch add --help + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper desktop add" ]] +} + +@test "dispatch short-circuits 'login -h' to per-subcommand help" { + _run_dispatch login -h + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper desktop login" ]] + [[ "$output" =~ "claude://" ]] +} + +@test "dispatch with no args prints overview help" { + _run_dispatch + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper desktop" ]] + [[ "$output" =~ "login" ]] + [[ "$output" =~ "Short form" ]] +} + +@test "dispatch with 'help' prints overview help" { + _run_dispatch help + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper desktop" ]] +} + +@test "dispatch suggests on typo" { + _run_dispatch addd + + [ "$status" -ne 0 ] + [[ "$output" =~ "add" ]] || [[ "$output" =~ "Did you mean" ]] +} + +@test "dispatch refuses on non-macOS" { + _CKIPPER_TEST_OSTYPE="linux-gnu" _run_dispatch list + + [ "$status" -ne 0 ] + [[ "$output" =~ "macOS" ]] || [[ "$output" =~ "darwin" ]] +} diff --git a/lib/desktop/help.zsh b/lib/desktop/help.zsh new file mode 100644 index 0000000..47409f0 --- /dev/null +++ b/lib/desktop/help.zsh @@ -0,0 +1,147 @@ +#!/usr/bin/env zsh +# Desktop-namespace help text. +# +# Owns ALL `ckipper desktop` help output — both the top-level overview +# (`ckipper desktop` / `ckipper desktop help`) and the focused per- +# subcommand help (`ckipper desktop --help`). Kept in a dedicated +# file because the desktop namespace has substantially longer help blocks +# (deep-link gotcha, bundle/data-dir layout) than account/worktree. +# +# Rendering goes through `_core_help_render` (lib/core/help.zsh) so chrome +# stays uniform across every ckipper subcommand. + +# Print the desktop-namespace usage summary. +# +# Returns: 0 always. +_ckipper_desktop_help() { + _core_help_render "ckipper desktop — manage Claude Desktop instances (macOS)" \ + "" \ + "Usage:" \ + " ckipper desktop add Register a new desktop instance" \ + " ckipper desktop list Show registered instances" \ + " ckipper desktop remove Unregister; prompts to delete dir + bundle" \ + " ckipper desktop rename Rename an instance in place" \ + " ckipper desktop login Quit ALL Claude apps then launch only " \ + " ckipper desktop launch Launch alongside any others" \ + "" \ + "Short form: \`ckipper dt ...\` is equivalent." \ + "" \ + "Run \`ckipper desktop --help\` for per-subcommand details." \ + "" \ + "Note: macOS routes \`claude://\` deep-link auth callbacks to whichever Claude" \ + "instance registered the URL scheme most recently. Use \`ckipper desktop login\`" \ + "to avoid auth landing in the wrong window." +} + +# Per-subcommand help text router. Each arm prints a focused usage block. +# +# Args: $1 — subcommand name (add, list, remove, rename, login, launch). +# Returns: 0 always. +_ckipper_desktop_help_for() { + case "$1" in + add) _ckipper_desktop_help_text_add ;; + list) _ckipper_desktop_help_text_list ;; + remove) _ckipper_desktop_help_text_remove ;; + rename) _ckipper_desktop_help_text_rename ;; + login) _ckipper_desktop_help_text_login ;; + launch) _ckipper_desktop_help_text_launch ;; + esac +} + +# Print help for `ckipper desktop add`. +# +# Returns: 0 always. +_ckipper_desktop_help_text_add() { + _core_help_render "ckipper desktop add " \ + "" \ + "Register a new Claude Desktop instance. must match ^[a-z0-9_-]+$." \ + "" \ + "Creates:" \ + " ~/.claude-desktop-/ Isolated user-data dir for this instance" \ + " ~/Applications/Claude-.app/ Wrapper bundle that launches Claude with" \ + " --user-data-dir pointed at the dir above" \ + "" \ + "Prerequisite: /Applications/Claude.app must be installed (download from" \ + "https://claude.ai/download). The wrapper bundle exec's \`open -n -a\` on it." +} + +# Print help for `ckipper desktop list`. +# +# Returns: 0 always. +_ckipper_desktop_help_text_list() { + _core_help_render "ckipper desktop list" \ + "" \ + "Print registered desktop instances. Columns:" \ + " name The instance name" \ + " user-data-dir Path to ~/.claude-desktop-/" \ + " bundle Path to the generated .app wrapper bundle" \ + " registered_at ISO-8601 timestamp from the registry" \ + " status running / stopped (from pgrep against the data dir)" +} + +# Print help for `ckipper desktop remove`. +# +# Returns: 0 always. +_ckipper_desktop_help_text_remove() { + _core_help_render "ckipper desktop remove " \ + "" \ + "Unregister a desktop instance from the registry, then interactively prompt" \ + "to delete:" \ + " - the user-data dir (~/.claude-desktop-/)" \ + " - the wrapper bundle (~/Applications/Claude-.app)" \ + "" \ + "Decline either prompt to keep the file/dir; the manual cleanup command is" \ + "shown so you can finish later." \ + "" \ + "Refuses if the instance is currently running — quit it first." +} + +# Print help for `ckipper desktop rename`. +# +# Returns: 0 always. +_ckipper_desktop_help_text_rename() { + _core_help_render "ckipper desktop rename " \ + "" \ + "Rename a desktop instance in place:" \ + " - Moves ~/.claude-desktop-/ → ~/.claude-desktop-/" \ + " - Regenerates the wrapper bundle as ~/Applications/Claude-.app" \ + " - Updates the registry (key + paths)" \ + "" \ + "Refuses if:" \ + " - the instance is currently running (so files aren't held open), or" \ + " - already exists in the registry (name collision)." +} + +# Print help for `ckipper desktop login`. +# +# Returns: 0 always. +_ckipper_desktop_help_text_login() { + _core_help_render "ckipper desktop login " \ + "" \ + "Quit ALL running Claude Desktop processes, then launch only ." \ + "" \ + "Why this exists: macOS routes \`claude://\` deep-link auth callbacks to" \ + "whichever Claude instance registered the URL scheme most recently. If" \ + "you start \`/login\` while two instances are running, the OAuth callback" \ + "can land in the wrong window. This is unfixable in user space." \ + "" \ + "The workaround is to quit other instances before logging in. This command" \ + "automates that dance: pgrep-and-kill every running Claude process, wait" \ + "for them to exit, then \`open -n -a\` only the target wrapper bundle." \ + "Complete \`/login\` in the lone running instance; the deep-link callback" \ + "has only one place to land." \ + "" \ + "See also: \`ckipper desktop launch \` to start an instance without" \ + "quitting the others." +} + +# Print help for `ckipper desktop launch`. +# +# Returns: 0 always. +_ckipper_desktop_help_text_launch() { + _core_help_render "ckipper desktop launch " \ + "" \ + "Launch a desktop instance via \`open -n -a\` on its wrapper bundle." \ + "Does NOT quit other running Claude instances — use \`ckipper desktop" \ + "login \` for that (needed before /login flows; see its --help)." +} From 1ae91fb7a3dd166aa6598b8c21a2b78b9e47e882 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 15:57:19 -0600 Subject: [PATCH 11/31] test(desktop): tighten dispatcher --help routing assertions The previous 'ckipper desktop add' / 'claude://' assertions also appeared in the overview help, so a silent fall-through from per-subcommand help to the overview would not have failed the test. Match on phrases that only exist in the per-subcommand help block ('Prerequisite:' for add, 'Why this exists:' for login) so the routing is actually under test. --- lib/desktop/dispatcher_test.bats | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/desktop/dispatcher_test.bats b/lib/desktop/dispatcher_test.bats index 741eb83..90df63a 100644 --- a/lib/desktop/dispatcher_test.bats +++ b/lib/desktop/dispatcher_test.bats @@ -53,15 +53,17 @@ _run_dispatch() { _run_dispatch add --help [ "$status" -eq 0 ] - [[ "$output" =~ "ckipper desktop add" ]] + # "Prerequisite:" appears only in add-specific help, not the overview — + # tightens the test so a silent fall-through to the overview would fail. + [[ "$output" =~ "Prerequisite:" ]] } @test "dispatch short-circuits 'login -h' to per-subcommand help" { _run_dispatch login -h [ "$status" -eq 0 ] - [[ "$output" =~ "ckipper desktop login" ]] - [[ "$output" =~ "claude://" ]] + # "Why this exists:" appears only in login-specific help, not the overview. + [[ "$output" =~ "Why this exists:" ]] } @test "dispatch with no args prints overview help" { From 9e6029bf8436a48a6d0325da8dded014869c37f5 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:06:28 -0600 Subject: [PATCH 12/31] feat(desktop): add .app bundle generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates a minimal macOS application bundle at the given path with: - Info.plist (CFBundleExecutable, Identifier, Name, IconFile when icon copied) - Contents/MacOS/launcher (zsh script execing /Applications/Claude.app with --user-data-dir baked in at generation time, not path-walked) - Contents/Resources/AppIcon.icns (best-effort copy from system app) - Launch Services indexing via lsregister -f (best-effort) Display name is title-cased (Claude-Work.app) while the canonical name and bundle identifier suffix stay lowercase. lsregister path is the full system path (not in $PATH) — overridable via _CKIPPER_TEST_LSREGISTER for tests. Source Claude.app path overridable via _CKIPPER_TEST_CLAUDE_APP. --- ckipper.zsh | 1 + lib/desktop/bundle.zsh | 185 +++++++++++++++++++++++++++++++++++ lib/desktop/bundle_test.bats | 91 +++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 lib/desktop/bundle.zsh create mode 100644 lib/desktop/bundle_test.bats diff --git a/ckipper.zsh b/ckipper.zsh index 9e06bc6..691c436 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -69,6 +69,7 @@ source "$CKIPPER_REPO_DIR/lib/config/dispatcher.zsh" # Desktop-namespace modules source "$CKIPPER_REPO_DIR/lib/desktop/help.zsh" +source "$CKIPPER_REPO_DIR/lib/desktop/bundle.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/dispatcher.zsh" # Setup-namespace modules diff --git a/lib/desktop/bundle.zsh b/lib/desktop/bundle.zsh new file mode 100644 index 0000000..d15ca92 --- /dev/null +++ b/lib/desktop/bundle.zsh @@ -0,0 +1,185 @@ +#!/usr/bin/env zsh +# .app bundle generator for Claude Desktop multi-instance wrappers. +# +# Public entry point: _ckipper_desktop_bundle_write. Produces a minimal, +# fully-formed macOS .app bundle (Info.plist + launcher + optional icon) +# whose launcher exec's `open -n -a /Applications/Claude.app` with the +# instance's --user-data-dir baked in at generation time. +# +# Test seams: _CKIPPER_TEST_CLAUDE_APP overrides the system Claude.app path +# used for icon copying; _CKIPPER_TEST_LSREGISTER overrides the lsregister +# binary path. Both are read inside helper bodies (NOT at module load) so +# per-test exports take effect. + +# Full system path to lsregister — not on $PATH, so we invoke it absolutely. +_CKIPPER_DESKTOP_LSREGISTER_PATH=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister + +# Canonical install path for Claude Desktop on macOS. Source of the icon and +# the executable our launcher exec's via `open -n -a`. +_CKIPPER_DESKTOP_SYSTEM_APP=/Applications/Claude.app + +# Reverse-DNS prefix for wrapper bundle identifiers. The instance name is +# appended (e.g. work → dev.ckipper.claude.desktop.work). +_CKIPPER_DESKTOP_BUNDLE_ID_PREFIX=dev.ckipper.claude.desktop + +# Mode bits for the generated launcher script (rwxr-xr-x). +_CKIPPER_DESKTOP_LAUNCHER_MODE=755 + +# Generate a complete .app bundle for a Claude Desktop instance. +# +# Materializes with Contents/Info.plist, Contents/MacOS/launcher +# (chmod 755), and optionally Contents/Resources/AppIcon.icns (copied from the +# system Claude.app when present). Best-effort registers the new bundle with +# Launch Services via lsregister. +# +# Args: +# $1 — instance name (lowercase canonical, e.g. "work" or "foo-bar") +# $2 — absolute bundle path (e.g. ~/Applications/Claude-Work.app) +# $3 — absolute user-data-dir path baked into the launcher's --user-data-dir +# +# Returns: 0 on success; non-zero if the bundle could not be written. +_ckipper_desktop_bundle_write() { + local name="$1" bundle="$2" data_dir="$3" + local display + display="$(_ckipper_desktop_bundle_title_case "$name")" + mkdir -p "$bundle/Contents/MacOS" "$bundle/Contents/Resources" || return 1 + _ckipper_desktop_bundle_write_launcher "$bundle" "$data_dir" || return 1 + local icon_copied=false + if _ckipper_desktop_bundle_copy_icon "$bundle"; then + icon_copied=true + fi + _ckipper_desktop_bundle_write_plist "$bundle" "$name" "Claude-$display" "$icon_copied" || return 1 + _ckipper_desktop_bundle_lsregister "$bundle" + return 0 +} + +# Title-case a hyphen-segmented lowercase name. +# +# Splits on `-`, applies zsh's ${(C)…} case-transform to each segment +# (capitalizing the first letter), then rejoins with `-`. So "foo-bar" +# becomes "Foo-Bar" and "work" becomes "Work". +# +# Args: $1 — lowercase name (e.g. "foo-bar"). +# Returns: 0 always. Prints the title-cased form on stdout. +_ckipper_desktop_bundle_title_case() { + local name="$1" + local -a segments titled + segments=("${(@s:-:)name}") + local seg + for seg in "${segments[@]}"; do + titled+=("${(C)seg}") + done + print -r -- "${(j:-:)titled}" +} + +# Write the bundle's launcher script. +# +# The script exec's `open -n -a "" --args +# --user-data-dir=""`, with the data_dir literal baked in at +# generation time (NOT resolved at runtime via path-walking). +# +# Args: +# $1 — bundle path +# $2 — user-data-dir to bake into --user-data-dir +# +# Returns: 0 on success; non-zero if the file could not be written/chmodded. +_ckipper_desktop_bundle_write_launcher() { + local bundle="$1" data_dir="$2" + local launcher="$bundle/Contents/MacOS/launcher" + cat > "$launcher" <CFBundleIconFile\n AppIcon\n' + _ckipper_desktop_bundle_plist_body "$name" "$display" "$icon_block" \ + > "$bundle/Contents/Info.plist" +} + +# Emit the Info.plist body to stdout. Split out of _write_plist so the +# writer stays under the 25-line cap (plists are inherently verbose). +# +# Args: +# $1 — canonical lowercase name (for CFBundleIdentifier suffix) +# $2 — display name (for CFBundleName) +# $3 — pre-formatted icon block (empty when no icon was copied) +# +# Returns: 0 always. Prints the plist XML to stdout. +_ckipper_desktop_bundle_plist_body() { + local name="$1" display="$2" icon_block="$3" + cat < + + + + CFBundleExecutable + launcher + CFBundleIdentifier + ${_CKIPPER_DESKTOP_BUNDLE_ID_PREFIX}.${name} + CFBundleName + ${display} + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + CFBundlePackageType + APPL + NSHighResolutionCapable + +${icon_block} + +EOF +} + +# Best-effort copy the system Claude.app's icon into the new bundle. +# +# Reads _CKIPPER_TEST_CLAUDE_APP at call time so tests can stub the source. +# Returns 0 only when the icon was successfully copied (the caller uses this +# to decide whether to include CFBundleIconFile in the plist). +# +# Args: $1 — bundle path. +# Returns: 0 on copy success; non-zero if the source icon is missing or copy +# failed (the caller treats this as "no icon" and continues). +_ckipper_desktop_bundle_copy_icon() { + local bundle="$1" + local source_app="${_CKIPPER_TEST_CLAUDE_APP:-$_CKIPPER_DESKTOP_SYSTEM_APP}" + local source_icon="$source_app/Contents/Resources/AppIcon.icns" + [[ -f "$source_icon" ]] || return 1 + cp "$source_icon" "$bundle/Contents/Resources/AppIcon.icns" +} + +# Register the new bundle with Launch Services so macOS picks it up without +# a logout. Best-effort: if lsregister is missing (e.g. in CI containers or +# under _CKIPPER_TEST_LSREGISTER pointing at a nonexistent path), silently +# skip. Always returns 0 so the caller doesn't treat indexing as a hard +# failure. +# +# Args: $1 — bundle path. +# Returns: 0 always. +_ckipper_desktop_bundle_lsregister() { + local bundle="$1" + local lsr="${_CKIPPER_TEST_LSREGISTER:-$_CKIPPER_DESKTOP_LSREGISTER_PATH}" + [[ -x "$lsr" ]] || return 0 + "$lsr" -f "$bundle" >/dev/null 2>&1 + return 0 +} diff --git a/lib/desktop/bundle_test.bats b/lib/desktop/bundle_test.bats new file mode 100644 index 0000000..9c5f676 --- /dev/null +++ b/lib/desktop/bundle_test.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats +# Tests for lib/desktop/bundle.zsh — the .app bundle generator. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env + export DESKTOP_BUNDLE_DIR="$TMP_HOME/Applications" + mkdir -p "$DESKTOP_BUNDLE_DIR" +} + +teardown() { + teardown_isolated_env +} + +# Helper that sources bundle.zsh in a clean zsh subshell and runs the cmd. +_run_bundle() { + local zsh_cmd="$1" + run env HOME="$TMP_HOME" PATH="$PATH" \ + _CKIPPER_TEST_CLAUDE_APP="${_CKIPPER_TEST_CLAUDE_APP:-}" \ + _CKIPPER_TEST_LSREGISTER="${_CKIPPER_TEST_LSREGISTER:-}" \ + zsh -c "source \"$REPO_ROOT/lib/desktop/bundle.zsh\"; $zsh_cmd" +} + +@test "bundle_write creates Contents/MacOS/launcher with correct shebang and --user-data-dir" { + local bundle="$DESKTOP_BUNDLE_DIR/Claude-Work.app" + local data_dir="$TMP_HOME/.claude-desktop-work" + _run_bundle "_ckipper_desktop_bundle_write work \"$bundle\" \"$data_dir\"" + + [ "$status" -eq 0 ] + [ -x "$bundle/Contents/MacOS/launcher" ] + head -1 "$bundle/Contents/MacOS/launcher" | grep -q '^#!/bin/zsh' + grep -q -- "--user-data-dir=\"$data_dir\"" "$bundle/Contents/MacOS/launcher" +} + +@test "bundle_write creates Info.plist with required CFBundle keys" { + local bundle="$DESKTOP_BUNDLE_DIR/Claude-Work.app" + _run_bundle "_ckipper_desktop_bundle_write work \"$bundle\" \"$TMP_HOME/.claude-desktop-work\"" + + [ -f "$bundle/Contents/Info.plist" ] + grep -q "launcher" "$bundle/Contents/Info.plist" + grep -q "dev.ckipper.claude.desktop.work" "$bundle/Contents/Info.plist" + grep -q "Claude-Work" "$bundle/Contents/Info.plist" +} + +@test "bundle_write title-cases multi-segment names" { + local bundle="$DESKTOP_BUNDLE_DIR/Claude-Foo-Bar.app" + _run_bundle "_ckipper_desktop_bundle_write foo-bar \"$bundle\" \"$TMP_HOME/.claude-desktop-foo-bar\"" + + grep -q "Claude-Foo-Bar" "$bundle/Contents/Info.plist" +} + +@test "bundle_write skips icon when Claude.app source is absent" { + local bundle="$DESKTOP_BUNDLE_DIR/Claude-X.app" + export _CKIPPER_TEST_CLAUDE_APP="/nonexistent/Claude.app" + _run_bundle "_ckipper_desktop_bundle_write x \"$bundle\" \"$TMP_HOME/.claude-desktop-x\"" + + [ "$status" -eq 0 ] + [ ! -f "$bundle/Contents/Resources/AppIcon.icns" ] + ! grep -q "CFBundleIconFile" "$bundle/Contents/Info.plist" +} + +@test "bundle_write copies icon and writes CFBundleIconFile when source exists" { + # Fake a Claude.app icon source. + local fake_app="$TMP_HOME/FakeClaude.app" + mkdir -p "$fake_app/Contents/Resources" + : > "$fake_app/Contents/Resources/AppIcon.icns" + local bundle="$DESKTOP_BUNDLE_DIR/Claude-Y.app" + export _CKIPPER_TEST_CLAUDE_APP="$fake_app" + _run_bundle "_ckipper_desktop_bundle_write y \"$bundle\" \"$TMP_HOME/.claude-desktop-y\"" + + [ "$status" -eq 0 ] + [ -f "$bundle/Contents/Resources/AppIcon.icns" ] + grep -q "CFBundleIconFile" "$bundle/Contents/Info.plist" +} + +@test "bundle_write tolerates missing lsregister" { + local bundle="$DESKTOP_BUNDLE_DIR/Claude-Z.app" + export _CKIPPER_TEST_LSREGISTER="/nonexistent/lsregister" + _run_bundle "_ckipper_desktop_bundle_write z \"$bundle\" \"$TMP_HOME/.claude-desktop-z\"" + + [ "$status" -eq 0 ] +} + +@test "bundle_write creates the parent directory if missing" { + local bundle="$TMP_HOME/nested/deeper/Applications/Claude-A.app" + _run_bundle "_ckipper_desktop_bundle_write a \"$bundle\" \"$TMP_HOME/.claude-desktop-a\"" + + [ "$status" -eq 0 ] + [ -d "$bundle/Contents/MacOS" ] +} From 21e00c964451c9bb6cf21f55a69383a8ceb41bcb Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:07:49 -0600 Subject: [PATCH 13/31] refactor(desktop): drop 4th param from bundle_write_plist (3-param cap) The display name was derived solely from the canonical name; compute it inside _write_plist via _title_case rather than threading it through the orchestrator. Brings every function back to the project's 3-param limit (.claude/rules/code-style.md). No behavior change; 7/7 tests still pass. --- lib/desktop/bundle.zsh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/desktop/bundle.zsh b/lib/desktop/bundle.zsh index d15ca92..ffb6d01 100644 --- a/lib/desktop/bundle.zsh +++ b/lib/desktop/bundle.zsh @@ -40,15 +40,13 @@ _CKIPPER_DESKTOP_LAUNCHER_MODE=755 # Returns: 0 on success; non-zero if the bundle could not be written. _ckipper_desktop_bundle_write() { local name="$1" bundle="$2" data_dir="$3" - local display - display="$(_ckipper_desktop_bundle_title_case "$name")" mkdir -p "$bundle/Contents/MacOS" "$bundle/Contents/Resources" || return 1 _ckipper_desktop_bundle_write_launcher "$bundle" "$data_dir" || return 1 local icon_copied=false if _ckipper_desktop_bundle_copy_icon "$bundle"; then icon_copied=true fi - _ckipper_desktop_bundle_write_plist "$bundle" "$name" "Claude-$display" "$icon_copied" || return 1 + _ckipper_desktop_bundle_write_plist "$bundle" "$name" "$icon_copied" || return 1 _ckipper_desktop_bundle_lsregister "$bundle" return 0 } @@ -98,17 +96,19 @@ EOF # Write Contents/Info.plist with the standard CFBundle keys. # # CFBundleIconFile is included ONLY when the caller signals an icon was -# copied — otherwise macOS would render a broken-icon glyph. +# copied — otherwise macOS would render a broken-icon glyph. CFBundleName +# is derived inside this function via _title_case so the orchestrator +# stays at the 3-parameter cap. # # Args: # $1 — bundle path -# $2 — canonical lowercase name (for CFBundleIdentifier suffix) -# $3 — display name (e.g. "Claude-Work") for CFBundleName -# $4 — "true" if an icon was copied; "false" otherwise +# $2 — canonical lowercase name (for CFBundleIdentifier suffix + display) +# $3 — "true" if an icon was copied; "false" otherwise # # Returns: 0 on success; non-zero if the plist could not be written. _ckipper_desktop_bundle_write_plist() { - local bundle="$1" name="$2" display="$3" icon_copied="$4" + local bundle="$1" name="$2" icon_copied="$3" + local display="Claude-$(_ckipper_desktop_bundle_title_case "$name")" local icon_block="" [[ "$icon_copied" = "true" ]] && \ icon_block=$' CFBundleIconFile\n AppIcon\n' From 5d4c4da339e093c8a1bb1b0af63656db3e94f4e3 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:23:08 -0600 Subject: [PATCH 14/31] feat(desktop): implement desktop add Registers a new Claude Desktop instance: creates ~/.claude-desktop-/, writes a Claude-.app bundle to ~/Applications/, and records the entry in ~/.ckipper/desktop.json. Validates the name regex, refuses on duplicates, requires /Applications/Claude.app to be installed, and refuses if the bundle path already exists. Prints a deep-link routing tip when this brings the instance count to two or more. Also demotes _CKIPPER_DESKTOP_SYSTEM_APP in bundle.zsh to honor an env-supplied override (was a plain assignment that overwrote the inherited value on source) so tests and the new Claude.app-presence assertion can point it at a fake bundle. --- ckipper.zsh | 1 + lib/desktop/bundle.zsh | 6 +- lib/desktop/dispatcher.zsh | 1 - lib/desktop/instance-management.zsh | 196 ++++++++++++++++++++++ lib/desktop/instance-management_test.bats | 102 +++++++++++ tests/lib/test-helper.bash | 4 + 6 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 lib/desktop/instance-management.zsh create mode 100644 lib/desktop/instance-management_test.bats diff --git a/ckipper.zsh b/ckipper.zsh index 691c436..70968c4 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -70,6 +70,7 @@ source "$CKIPPER_REPO_DIR/lib/config/dispatcher.zsh" # Desktop-namespace modules source "$CKIPPER_REPO_DIR/lib/desktop/help.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/bundle.zsh" +source "$CKIPPER_REPO_DIR/lib/desktop/instance-management.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/dispatcher.zsh" # Setup-namespace modules diff --git a/lib/desktop/bundle.zsh b/lib/desktop/bundle.zsh index ffb6d01..c143efa 100644 --- a/lib/desktop/bundle.zsh +++ b/lib/desktop/bundle.zsh @@ -15,8 +15,10 @@ _CKIPPER_DESKTOP_LSREGISTER_PATH=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister # Canonical install path for Claude Desktop on macOS. Source of the icon and -# the executable our launcher exec's via `open -n -a`. -_CKIPPER_DESKTOP_SYSTEM_APP=/Applications/Claude.app +# the executable our launcher exec's via `open -n -a`. Honors an env-supplied +# override so tests (and the `desktop add` Claude.app-presence assertion) can +# point this at a fake bundle without monkey-patching the file. +_CKIPPER_DESKTOP_SYSTEM_APP=${_CKIPPER_DESKTOP_SYSTEM_APP:-/Applications/Claude.app} # Reverse-DNS prefix for wrapper bundle identifiers. The instance name is # appended (e.g. work → dev.ckipper.claude.desktop.work). diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh index 787b67f..1efcca5 100644 --- a/lib/desktop/dispatcher.zsh +++ b/lib/desktop/dispatcher.zsh @@ -69,7 +69,6 @@ _ckipper_desktop_unknown() { # Each stub returns 1 so users typing them get a "not yet implemented" signal. # The task number is embedded in each message for grep-ability when wiring up # the real handlers. -_ckipper_desktop_add() { echo "ckipper desktop add: not yet implemented (Task 5)" >&2; return 1; } _ckipper_desktop_list() { echo "ckipper desktop list: not yet implemented (Task 6)" >&2; return 1; } _ckipper_desktop_remove() { echo "ckipper desktop remove: not yet implemented (Task 7)" >&2; return 1; } _ckipper_desktop_rename() { echo "ckipper desktop rename: not yet implemented (Task 8)" >&2; return 1; } diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh new file mode 100644 index 0000000..ccd7e4a --- /dev/null +++ b/lib/desktop/instance-management.zsh @@ -0,0 +1,196 @@ +#!/usr/bin/env zsh +# Desktop instance lifecycle subcommands: add, list, remove, rename. +# +# Owns the CRUD surface for ~/.ckipper/desktop.json — registry entries that +# pair a lowercase instance name with its user-data dir (~/.claude-desktop- +# /) and its wrapper .app bundle path (~/Applications/Claude-.app). +# +# Boundary notes: +# - Calls _ckipper_desktop_bundle_write (bundle.zsh) for .app generation. +# - Calls _core_registry_{init,update,check_version}_at on +# $CKIPPER_DESKTOP_REGISTRY, with $CKIPPER_REGISTRY_VERSION temporarily +# scoped via the `VAR=val cmd` inline-env idiom (zsh assigns VAR for the +# duration of cmd's invocation only — no global mutation). +# - HOME-derived paths are computed at call time inside helpers, NOT stored +# in module-level "constants", so per-test $HOME overrides take effect. + +# Regex for valid instance names — lowercase alphanumeric, underscore, hyphen. +# Mirrors lib/account/account-management.zsh's name regex for consistency. +readonly _CKIPPER_DESKTOP_NAME_REGEX='^[a-z0-9_-]+$' + +# Compute the user-data dir for a given instance name. HOME is read at call +# time so per-test overrides work; do NOT cache this in a module-level const. +# +# Args: $1 — instance name. +# Returns: 0 always. Prints the absolute path to stdout. +_ckipper_desktop_data_dir_for() { + local name="$1" + print -r -- "$HOME/.claude-desktop-${name}" +} + +# Compute the .app bundle path for a given instance name. HOME is read at +# call time. Requires lib/desktop/bundle.zsh to be sourced (provides the +# title-case helper used here). +# +# Args: $1 — instance name (lowercase). +# Returns: 0 always. Prints the absolute path to stdout. +_ckipper_desktop_bundle_path_for() { + local name="$1" + local titled + titled=$(_ckipper_desktop_bundle_title_case "$name") + print -r -- "$HOME/Applications/Claude-${titled}.app" +} + +# Validate a desktop instance name against _CKIPPER_DESKTOP_NAME_REGEX. +# Prints a usage line on empty input and a regex hint on invalid input. +# +# Args: $1 — proposed instance name. +# Returns: 0 if valid; 1 on empty or non-matching input. +# +# Errors (stderr): +# "Usage: ckipper desktop add " — when name is empty. +# "Instance name must match ..." — when name fails the regex. +_ckipper_desktop_validate_name() { + local name="$1" + if [[ -z "$name" ]]; then + echo "Usage: ckipper desktop add " >&2 + return 1 + fi + if [[ ! "$name" =~ $_CKIPPER_DESKTOP_NAME_REGEX ]]; then + echo "Instance name must match ^[a-z0-9_-]+$ (lowercase alphanumeric, underscore, hyphen)." >&2 + return 1 + fi +} + +# Assert that /Applications/Claude.app (or the test override) is installed. +# Reads $_CKIPPER_DESKTOP_SYSTEM_APP at call time so tests can override. +# +# Returns: 0 if the system Claude.app is present; 1 otherwise. +# Errors (stderr): "Claude.app not found at . Install from ." +_ckipper_desktop_assert_claude_app() { + [[ -d "$_CKIPPER_DESKTOP_SYSTEM_APP" ]] && return 0 + echo "Claude.app not found at $_CKIPPER_DESKTOP_SYSTEM_APP." >&2 + echo "Install Claude Desktop from https://claude.ai/download, then re-run." >&2 + return 1 +} + +# Initialize the desktop registry file and verify its version. Scopes +# $CKIPPER_REGISTRY_VERSION to the per-call inline-env so the accounts.json +# version global is not mutated. +# +# Returns: 0 on success; 1 on init failure or unsupported version. +_ckipper_desktop_init_registry() { + CKIPPER_REGISTRY_VERSION="$CKIPPER_DESKTOP_REGISTRY_VERSION" \ + _core_registry_init_at "$CKIPPER_DESKTOP_REGISTRY" || return 1 + CKIPPER_REGISTRY_VERSION="$CKIPPER_DESKTOP_REGISTRY_VERSION" \ + _core_registry_check_version_at "$CKIPPER_DESKTOP_REGISTRY" +} + +# Refuse if an instance with this name is already registered. +# +# Args: $1 — instance name. +# Returns: 0 if name is free; 1 if already present. +# Errors (stderr): "Desktop instance '' is already registered." +_ckipper_desktop_assert_unique() { + local name="$1" + if jq -e --arg n "$name" '.instances[$n]' "$CKIPPER_DESKTOP_REGISTRY" >/dev/null 2>&1; then + echo "Desktop instance '$name' is already registered." >&2 + return 1 + fi +} + +# Refuse if the .app bundle path is already occupied by some other directory. +# Catches the case where a previous ckipper run left a partial bundle behind +# or where the user manually placed an app at that path. +# +# Args: $1 — bundle path. +# Returns: 0 if free; 1 if a file or directory already exists at that path. +# Errors (stderr): "Bundle path already exists." +_ckipper_desktop_assert_no_bundle_collision() { + local bundle="$1" + if [[ -e "$bundle" ]]; then + echo "Bundle path $bundle already exists. Remove it manually or pick a different name." >&2 + return 1 + fi +} + +# Write the registry entry for a newly-registered instance. The entry shape +# mirrors what `desktop list` reads back: user_data_dir, app_bundle_path, +# registered_at (ISO 8601 UTC). Scopes $CKIPPER_REGISTRY_VERSION to the +# per-call inline-env. +# +# Args: $1 — instance name; $2 — user-data dir; $3 — bundle path. +# Returns: 0 on success; 1 on registry write failure. +_ckipper_desktop_register() { + local name="$1" data_dir="$2" bundle="$3" + local now + now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + CKIPPER_REGISTRY_VERSION="$CKIPPER_DESKTOP_REGISTRY_VERSION" \ + _core_registry_update_at "$CKIPPER_DESKTOP_REGISTRY" ' + .instances[$n] = { + user_data_dir: $d, + app_bundle_path: $b, + registered_at: $t + } + ' --arg n "$name" --arg d "$data_dir" --arg b "$bundle" --arg t "$now" +} + +# Count registered desktop instances. Used by the announce helper to decide +# whether the deep-link tip should fire (>= 2 means the user now has multiple +# instances and is at risk of OAuth callbacks landing in the wrong window). +# +# Returns: 0 always. Prints the count to stdout (0 if registry is missing). +_ckipper_desktop_instance_count() { + [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || { echo 0; return 0; } + jq -r '.instances // {} | length' "$CKIPPER_DESKTOP_REGISTRY" 2>/dev/null || echo 0 +} + +# Print the post-add summary. When this brings the total instance count to +# two or more, also nudge the user toward `ckipper desktop login` to avoid +# the deep-link auth-routing pitfall (see lib/desktop/help.zsh::login text). +# +# Args: $1 — instance name; $2 — bundle path. +# Returns: 0 always. +_ckipper_desktop_add_announce() { + local name="$1" bundle="$2" + echo "Registered Desktop instance '$name'." + echo "Bundle: $bundle" + echo "Data dir: $(_ckipper_desktop_data_dir_for "$name")" + local count + count=$(_ckipper_desktop_instance_count) + if (( count >= 2 )); then + echo "" + echo "Tip: with two or more Desktop instances installed, use \`ckipper desktop login \`" + echo "before running /login so the OAuth deep-link lands in the right window." + fi +} + +# Register a new Claude Desktop instance: create the user-data dir, generate +# its wrapper .app bundle, and record the entry in the desktop registry. +# Rolls back the data dir + bundle if the registry write fails. +# +# Args: $1 — instance name (must match _CKIPPER_DESKTOP_NAME_REGEX). +# Returns: 0 on success; 1 on validation, generation, or registry failure. +_ckipper_desktop_add() { + local name="$1" + _ckipper_desktop_validate_name "$name" || return 1 + _ckipper_desktop_assert_claude_app || return 1 + _ckipper_desktop_init_registry || return 1 + _ckipper_desktop_assert_unique "$name" || return 1 + local data_dir bundle + data_dir=$(_ckipper_desktop_data_dir_for "$name") + bundle=$(_ckipper_desktop_bundle_path_for "$name") + _ckipper_desktop_assert_no_bundle_collision "$bundle" || return 1 + mkdir -p "$data_dir" "$HOME/Applications" + if ! _ckipper_desktop_bundle_write "$name" "$bundle" "$data_dir"; then + rm -rf "$data_dir" "$bundle" + echo "Failed to write .app bundle; rolled back $data_dir and $bundle." >&2 + return 1 + fi + if ! _ckipper_desktop_register "$name" "$data_dir" "$bundle"; then + rm -rf "$data_dir" "$bundle" + echo "Failed to write desktop registry; rolled back $data_dir and $bundle." >&2 + return 1 + fi + _ckipper_desktop_add_announce "$name" "$bundle" +} diff --git a/lib/desktop/instance-management_test.bats b/lib/desktop/instance-management_test.bats new file mode 100644 index 0000000..59555ee --- /dev/null +++ b/lib/desktop/instance-management_test.bats @@ -0,0 +1,102 @@ +#!/usr/bin/env bats +# Tests for lib/desktop/instance-management.zsh — add/list/remove/rename +# of Claude Desktop instances in ~/.ckipper/desktop.json. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# Install a fake /Applications/Claude.app under the per-test $TMP_HOME and +# point the desktop module at it via the documented env override. Required +# before any `desktop add` test because the real add flow refuses when the +# system Claude.app is missing. +_install_fake_claude_app() { + export _CKIPPER_TEST_OSTYPE="darwin19.0" + export _CKIPPER_DESKTOP_SYSTEM_APP="$TMP_HOME/FakeClaude.app" + export _CKIPPER_TEST_CLAUDE_APP="$TMP_HOME/FakeClaude.app" + mkdir -p "$_CKIPPER_DESKTOP_SYSTEM_APP/Contents/MacOS" + mkdir -p "$_CKIPPER_DESKTOP_SYSTEM_APP/Contents/Resources" +} + +# ── desktop add ────────────────────────────────────────────────────────── + +@test "desktop add registers a new instance and writes registry entry" { + _install_fake_claude_app + + run_ckipper desktop add work + + [ "$status" -eq 0 ] + [ -f "$CKIPPER_DIR/desktop.json" ] + local recorded_dir + recorded_dir=$(jq -r '.instances.work.user_data_dir' "$CKIPPER_DIR/desktop.json") + [ "$recorded_dir" = "$HOME/.claude-desktop-work" ] + local recorded_bundle + recorded_bundle=$(jq -r '.instances.work.app_bundle_path' "$CKIPPER_DIR/desktop.json") + [ "$recorded_bundle" = "$HOME/Applications/Claude-Work.app" ] + [ -d "$HOME/.claude-desktop-work" ] + [ -d "$HOME/Applications/Claude-Work.app/Contents/MacOS" ] +} + +@test "desktop add refuses an invalid name" { + _install_fake_claude_app + + run_ckipper desktop add "Bad Name" + + [ "$status" -ne 0 ] + [[ "$output" =~ "must match" ]] +} + +@test "desktop add refuses an empty name with a usage hint" { + _install_fake_claude_app + + run_ckipper desktop add + + [ "$status" -ne 0 ] + [[ "$output" =~ [Uu]sage ]] +} + +@test "desktop add refuses a duplicate name" { + _install_fake_claude_app + + run_ckipper desktop add work + run_ckipper desktop add work + + [ "$status" -ne 0 ] + [[ "$output" =~ "already registered" ]] +} + +@test "desktop add refuses when /Applications/Claude.app is absent" { + export _CKIPPER_TEST_OSTYPE="darwin19.0" + export _CKIPPER_DESKTOP_SYSTEM_APP="$TMP_HOME/NoSuchApp.app" + + run_ckipper desktop add work + + [ "$status" -ne 0 ] + [[ "$output" =~ "Claude.app" ]] +} + +@test "desktop add refuses when bundle path already exists" { + _install_fake_claude_app + mkdir -p "$HOME/Applications/Claude-Work.app" + + run_ckipper desktop add work + + [ "$status" -ne 0 ] + [[ "$output" =~ "already exists" ]] +} + +@test "desktop add prints deep-link tip on the second add" { + _install_fake_claude_app + + run_ckipper desktop add work + run_ckipper desktop add personal + + [ "$status" -eq 0 ] + [[ "$output" =~ "ckipper desktop login" ]] +} diff --git a/tests/lib/test-helper.bash b/tests/lib/test-helper.bash index 7d9d601..5d6fbc1 100644 --- a/tests/lib/test-helper.bash +++ b/tests/lib/test-helper.bash @@ -51,6 +51,10 @@ run_ckipper() { _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-linux}" \ CKIPPER_FORCE="${CKIPPER_FORCE:-1}" \ CKIPPER_NO_GUM="${CKIPPER_NO_GUM:-1}" \ + _CKIPPER_DESKTOP_SYSTEM_APP="${_CKIPPER_DESKTOP_SYSTEM_APP:-}" \ + _CKIPPER_TEST_CLAUDE_APP="${_CKIPPER_TEST_CLAUDE_APP:-}" \ + _CKIPPER_TEST_LSREGISTER="${_CKIPPER_TEST_LSREGISTER:-}" \ + PGREP_STUB_MATCH="${PGREP_STUB_MATCH:-0}" \ zsh -c "$zsh_cmd" } From dbf8dd8b74bc7f69f1060b4001d94c2998061747 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:25:30 -0600 Subject: [PATCH 15/31] feat(desktop): implement desktop list Prints registered Desktop instances in a column layout: name, data dir, bundle path, registered_at, running/stopped status. Status comes from pgrep against the --user-data-dir cmdline argument so list reflects the same probe that desktop remove/rename will use to refuse on live instances. --- lib/desktop/dispatcher.zsh | 1 - lib/desktop/instance-management.zsh | 122 ++++++++++++++++++++++ lib/desktop/instance-management_test.bats | 46 ++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh index 1efcca5..2f6f299 100644 --- a/lib/desktop/dispatcher.zsh +++ b/lib/desktop/dispatcher.zsh @@ -69,7 +69,6 @@ _ckipper_desktop_unknown() { # Each stub returns 1 so users typing them get a "not yet implemented" signal. # The task number is embedded in each message for grep-ability when wiring up # the real handlers. -_ckipper_desktop_list() { echo "ckipper desktop list: not yet implemented (Task 6)" >&2; return 1; } _ckipper_desktop_remove() { echo "ckipper desktop remove: not yet implemented (Task 7)" >&2; return 1; } _ckipper_desktop_rename() { echo "ckipper desktop rename: not yet implemented (Task 8)" >&2; return 1; } _ckipper_desktop_login() { echo "ckipper desktop login: not yet implemented (Task 10)" >&2; return 1; } diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh index ccd7e4a..1c9c9f7 100644 --- a/lib/desktop/instance-management.zsh +++ b/lib/desktop/instance-management.zsh @@ -194,3 +194,125 @@ _ckipper_desktop_add() { fi _ckipper_desktop_add_announce "$name" "$bundle" } + +# Column widths (chars) used when rendering `ckipper desktop list` rows. +# Matched against the header printed by _ckipper_desktop_list_header. +readonly _CKIPPER_DESKTOP_LIST_COL_NAME=14 +readonly _CKIPPER_DESKTOP_LIST_COL_DATA_DIR=30 +readonly _CKIPPER_DESKTOP_LIST_COL_BUNDLE=36 +readonly _CKIPPER_DESKTOP_LIST_COL_REGISTERED=22 + +# Print the column-header row for `ckipper desktop list`. +# +# Returns: 0 always. +_ckipper_desktop_list_header() { + printf '%-*s%-*s%-*s%-*s%s\n' \ + "$_CKIPPER_DESKTOP_LIST_COL_NAME" "NAME" \ + "$_CKIPPER_DESKTOP_LIST_COL_DATA_DIR" "DATA-DIR" \ + "$_CKIPPER_DESKTOP_LIST_COL_BUNDLE" "BUNDLE" \ + "$_CKIPPER_DESKTOP_LIST_COL_REGISTERED" "REGISTERED" \ + "STATUS" +} + +# Shorten an absolute path under $HOME to a `~/`-prefixed form for display. +# Mirrors lib/account/account-management.zsh::_ckipper_account_list_short_dir; +# extracted again here because the account namespace is off-limits to siblings. +# +# Args: $1 — absolute path. +# Returns: 0 always; prints the (possibly shortened) path. +_ckipper_desktop_list_short_path() { + local path="$1" + [[ "$path" == "$HOME"* ]] && printf '~%s' "${path#$HOME}" || printf '%s' "$path" +} + +# Decide running status for a desktop instance by checking whether any +# process has the instance's --user-data-dir on its command line. This is +# the same probe used by `desktop remove` / `desktop rename` to refuse +# destructive ops on a live instance. +# +# Args: $1 — user-data dir to probe. +# Returns: 0 always. Prints "running" or "stopped" to stdout. +_ckipper_desktop_list_status() { + local data_dir="$1" + if pgrep -f -- "--user-data-dir=$data_dir" >/dev/null 2>&1; then + echo "running" + else + echo "stopped" + fi +} + +# Print a single instance row for `ckipper desktop list`. +# +# Args: +# $1 — instance name +# $2 — user-data dir +# $3 — app bundle path +# +# Reads `_CKIPPER_DESKTOP_LIST_REGISTERED_AT` (set by `_ckipper_desktop_list` +# before invoking) so this helper stays at the 3-parameter cap. The list +# loop pipes name/dir/bundle/registered_at as 4 tab-separated columns; we +# stash the timestamp in a module global to avoid a 4th positional. +_ckipper_desktop_list_row() { + local name="$1" data_dir="$2" bundle="$3" + local registered="$_CKIPPER_DESKTOP_LIST_REGISTERED_AT" + # NB: zsh's $status is a read-only special, so this var is `run_status`. + local short_data short_bundle run_status + short_data=$(_ckipper_desktop_list_short_path "$data_dir") + short_bundle=$(_ckipper_desktop_list_short_path "$bundle") + run_status=$(_ckipper_desktop_list_status "$data_dir") + printf '%-*s%-*s%-*s%-*s%s\n' \ + "$_CKIPPER_DESKTOP_LIST_COL_NAME" "$name" \ + "$_CKIPPER_DESKTOP_LIST_COL_DATA_DIR" "$short_data" \ + "$_CKIPPER_DESKTOP_LIST_COL_BUNDLE" "$short_bundle" \ + "$_CKIPPER_DESKTOP_LIST_COL_REGISTERED" "$registered" \ + "$run_status" +} + +# Module-level scratchpad for the in-progress list row. See +# _ckipper_desktop_list_row's doc-header for why this is global. +typeset -g _CKIPPER_DESKTOP_LIST_REGISTERED_AT="" + +# Print the empty-registry hint message when no instances are registered. +# +# Returns: 0 always. +_ckipper_desktop_list_empty_hint() { + echo "No Desktop instances registered. Run: ckipper desktop add " +} + +# Iterate the registry's .instances object and print one row per instance. +# Extracted from `_ckipper_desktop_list` so the orchestrator stays under +# the 25-line cap. +# +# Returns: 0 always. +_ckipper_desktop_list_print_rows() { + jq -r '.instances // {} | to_entries[] | "\(.key)\t\(.value.user_data_dir)\t\(.value.app_bundle_path)\t\(.value.registered_at // "-")"' \ + "$CKIPPER_DESKTOP_REGISTRY" | \ + while IFS=$'\t' read -r name data_dir bundle registered; do + _CKIPPER_DESKTOP_LIST_REGISTERED_AT="$registered" + _ckipper_desktop_list_row "$name" "$data_dir" "$bundle" + done +} + +# Print registered Desktop instances in a column layout: name, data dir, +# bundle path, registered_at, running/stopped status. Running detection is +# best-effort and uses pgrep against the cmdline --user-data-dir argument. +# +# Returns: 0 always. +_ckipper_desktop_list() { + if [[ ! -f "$CKIPPER_DESKTOP_REGISTRY" ]]; then + _ckipper_desktop_list_empty_hint + return 0 + fi + CKIPPER_REGISTRY_VERSION="$CKIPPER_DESKTOP_REGISTRY_VERSION" \ + _core_registry_check_version_at "$CKIPPER_DESKTOP_REGISTRY" || return 1 + local count + count=$(_ckipper_desktop_instance_count) + if (( count == 0 )); then + _ckipper_desktop_list_empty_hint + return 0 + fi + _core_style_header "Registered Desktop instances" + _ckipper_desktop_list_header + _core_style_divider + _ckipper_desktop_list_print_rows +} diff --git a/lib/desktop/instance-management_test.bats b/lib/desktop/instance-management_test.bats index 59555ee..8c321b9 100644 --- a/lib/desktop/instance-management_test.bats +++ b/lib/desktop/instance-management_test.bats @@ -100,3 +100,49 @@ _install_fake_claude_app() { [ "$status" -eq 0 ] [[ "$output" =~ "ckipper desktop login" ]] } + +# ── desktop list ───────────────────────────────────────────────────────── + +@test "desktop list shows hint when no instances are registered" { + _install_fake_claude_app + + run_ckipper desktop list + + [ "$status" -eq 0 ] + [[ "$output" =~ "No Desktop instances" ]] + [[ "$output" =~ "ckipper desktop add" ]] +} + +@test "desktop list prints registered instances" { + _install_fake_claude_app + run_ckipper desktop add work + run_ckipper desktop add personal + + run_ckipper desktop list + + [ "$status" -eq 0 ] + [[ "$output" =~ "work" ]] + [[ "$output" =~ "personal" ]] + [[ "$output" =~ "Claude-Work" ]] + [[ "$output" =~ "Claude-Personal" ]] +} + +@test "desktop list shows running status via pgrep" { + _install_fake_claude_app + run_ckipper desktop add work + + PGREP_STUB_MATCH=1 run_ckipper desktop list + + [ "$status" -eq 0 ] + [[ "$output" =~ "running" ]] +} + +@test "desktop list shows stopped status when pgrep finds nothing" { + _install_fake_claude_app + run_ckipper desktop add work + + run_ckipper desktop list + + [ "$status" -eq 0 ] + [[ "$output" =~ "stopped" ]] +} From 14e7107f64364de184879da35edc9f8e88666364 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:28:07 -0600 Subject: [PATCH 16/31] feat(desktop): implement desktop remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unregisters a Desktop instance from the registry, then interactively prompts to delete the user-data dir (default N — preserves user data: chats, settings, OAuth tokens) and the .app bundle (regeneratable via desktop add). Refuses if a Claude Desktop process is currently running against that user-data-dir. The not-running probe is _ckipper_desktop_assert_not_running. Task 9 will likely consolidate or extend it (e.g., richer process info); for now it lives next to its only callers (remove and the upcoming rename). --- lib/desktop/dispatcher.zsh | 1 - lib/desktop/instance-management.zsh | 104 ++++++++++++++++++++++ lib/desktop/instance-management_test.bats | 71 +++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh index 2f6f299..68838ad 100644 --- a/lib/desktop/dispatcher.zsh +++ b/lib/desktop/dispatcher.zsh @@ -69,7 +69,6 @@ _ckipper_desktop_unknown() { # Each stub returns 1 so users typing them get a "not yet implemented" signal. # The task number is embedded in each message for grep-ability when wiring up # the real handlers. -_ckipper_desktop_remove() { echo "ckipper desktop remove: not yet implemented (Task 7)" >&2; return 1; } _ckipper_desktop_rename() { echo "ckipper desktop rename: not yet implemented (Task 8)" >&2; return 1; } _ckipper_desktop_login() { echo "ckipper desktop login: not yet implemented (Task 10)" >&2; return 1; } _ckipper_desktop_launch() { echo "ckipper desktop launch: not yet implemented (Task 11)" >&2; return 1; } diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh index 1c9c9f7..646aabc 100644 --- a/lib/desktop/instance-management.zsh +++ b/lib/desktop/instance-management.zsh @@ -293,6 +293,110 @@ _ckipper_desktop_list_print_rows() { done } +# Refuse if a Claude Desktop process is currently running against the given +# user-data dir. Used by `desktop remove` and `desktop rename` to block +# destructive ops on a live instance. +# +# TODO(Task 9): replace with _ckipper_desktop_assert_not_running once that +# helper lands. Inlined here because remove/rename need the check before +# the launcher module exists. +# +# Args: $1 — user-data dir to probe. +# Returns: 0 if no matching process; 1 otherwise. +# Errors (stderr): "Refusing: ..." when a matching process is found. +_ckipper_desktop_assert_not_running() { + local data_dir="$1" + pgrep -f -- "--user-data-dir=$data_dir" >/dev/null 2>&1 || return 0 + echo "Refusing: a Claude Desktop instance is running for $data_dir." >&2 + echo "Quit it first, then re-run." >&2 + return 1 +} + +# Look up the user-data dir for a registered instance. +# +# Args: $1 — instance name. +# Returns: 0 if registered; 1 if not. +# Errors (stderr): "Desktop instance '' is not registered." +_ckipper_desktop_data_dir_of() { + local name="$1" + if ! jq -e --arg n "$name" '.instances[$n]' "$CKIPPER_DESKTOP_REGISTRY" >/dev/null 2>&1; then + echo "Desktop instance '$name' is not registered." >&2 + return 1 + fi + jq -r --arg n "$name" '.instances[$n].user_data_dir' "$CKIPPER_DESKTOP_REGISTRY" +} + +# Look up the app bundle path for a registered instance. Assumes the caller +# has already verified registration via _ckipper_desktop_data_dir_of. +# +# Args: $1 — instance name. +# Returns: 0 always. Prints the bundle path to stdout. +_ckipper_desktop_bundle_of() { + local name="$1" + jq -r --arg n "$name" '.instances[$n].app_bundle_path' "$CKIPPER_DESKTOP_REGISTRY" +} + +# Prompt the user to delete the user-data dir for a removed instance. +# Default is N — preserves user data (chats, settings, OAuth tokens). +# +# Args: $1 — instance name (label only); $2 — user-data dir path. +# Returns: 0 always. +_ckipper_desktop_remove_prompt_data_dir() { + local name="$1" data_dir="$2" + [[ -d "$data_dir" ]] || return 0 + if _core_prompt_confirm "Delete data dir $data_dir? (chats, settings, OAuth tokens)"; then + rm -rf "$data_dir" + echo "Deleted $data_dir." + return 0 + fi + echo "Kept $data_dir. To delete later: rm -rf '$data_dir'" +} + +# Prompt the user to delete the .app bundle for a removed instance. +# Default is N (gum confirm defaults to no) — but the bundle is regeneratable +# via `ckipper desktop add `, so the prompt text steers toward yes. +# +# Args: $1 — instance name (label only); $2 — bundle path. +# Returns: 0 always. +_ckipper_desktop_remove_prompt_bundle() { + local name="$1" bundle="$2" + [[ -d "$bundle" ]] || return 0 + if _core_prompt_confirm "Delete app bundle $bundle? (regeneratable via desktop add)"; then + rm -rf "$bundle" + echo "Deleted $bundle." + return 0 + fi + echo "Kept $bundle. To delete later: rm -rf '$bundle'" +} + +# Unregister a Desktop instance from the registry, then interactively prompt +# to delete the user-data dir (default N — preserves user data) and the +# .app bundle (regeneratable). Refuses if the instance is currently running. +# +# Args: $1 — instance name. +# Returns: 0 on success; 1 if not registered, running, or registry write fails. +_ckipper_desktop_remove() { + local name="$1" + if [[ -z "$name" ]]; then + echo "Usage: ckipper desktop remove " >&2 + return 1 + fi + [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || { echo "Desktop instance '$name' is not registered." >&2; return 1; } + local data_dir bundle + data_dir=$(_ckipper_desktop_data_dir_of "$name") || return 1 + bundle=$(_ckipper_desktop_bundle_of "$name") + _ckipper_desktop_assert_not_running "$data_dir" || return 1 + if ! CKIPPER_REGISTRY_VERSION="$CKIPPER_DESKTOP_REGISTRY_VERSION" \ + _core_registry_update_at "$CKIPPER_DESKTOP_REGISTRY" \ + 'del(.instances[$n])' --arg n "$name"; then + echo "Error: failed to unregister '$name' from the desktop registry." >&2 + return 1 + fi + echo "Unregistered Desktop instance '$name'." + _ckipper_desktop_remove_prompt_data_dir "$name" "$data_dir" + _ckipper_desktop_remove_prompt_bundle "$name" "$bundle" +} + # Print registered Desktop instances in a column layout: name, data dir, # bundle path, registered_at, running/stopped status. Running detection is # best-effort and uses pgrep against the cmdline --user-data-dir argument. diff --git a/lib/desktop/instance-management_test.bats b/lib/desktop/instance-management_test.bats index 8c321b9..a762b68 100644 --- a/lib/desktop/instance-management_test.bats +++ b/lib/desktop/instance-management_test.bats @@ -146,3 +146,74 @@ _install_fake_claude_app() { [ "$status" -eq 0 ] [[ "$output" =~ "stopped" ]] } + +# ── desktop remove ─────────────────────────────────────────────────────── + +# Run `ckipper desktop remove ` with stdin prefilled for the two +# y/N prompts. Mirrors run_ckipper but pipes the answers INTO the ckipper +# command (not into the source) — that ordering matters because zsh's `|` +# binds tighter than `;`. Saves repeating the same env-list per test. +_run_remove_with_answers() { + local answers="$1" name="$2" + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + PATH="$PATH" CKIPPER_FORCE="${CKIPPER_FORCE:-1}" CKIPPER_NO_GUM=1 \ + _CKIPPER_TEST_OSTYPE="${_CKIPPER_TEST_OSTYPE:-darwin19.0}" \ + _CKIPPER_DESKTOP_SYSTEM_APP="${_CKIPPER_DESKTOP_SYSTEM_APP:-}" \ + _CKIPPER_TEST_CLAUDE_APP="${_CKIPPER_TEST_CLAUDE_APP:-}" \ + PGREP_STUB_MATCH="${PGREP_STUB_MATCH:-0}" \ + zsh -c "source \"$REPO_ROOT/ckipper.zsh\"; printf '$answers' | ckipper desktop remove $name" +} + +@test "desktop remove unregisters and keeps dirs when prompts are declined" { + _install_fake_claude_app + run_ckipper desktop add work + + _run_remove_with_answers 'n\nn\n' work + + [ "$status" -eq 0 ] + [ -d "$HOME/.claude-desktop-work" ] + [ -d "$HOME/Applications/Claude-Work.app" ] + ! jq -e '.instances.work' "$CKIPPER_DIR/desktop.json" >/dev/null 2>&1 +} + +@test "desktop remove deletes dirs when both prompts accepted" { + _install_fake_claude_app + run_ckipper desktop add work + + _run_remove_with_answers 'y\ny\n' work + + [ "$status" -eq 0 ] + [ ! -d "$HOME/.claude-desktop-work" ] + [ ! -d "$HOME/Applications/Claude-Work.app" ] +} + +@test "desktop remove refuses if instance is running" { + _install_fake_claude_app + run_ckipper desktop add work + + PGREP_STUB_MATCH=1 _run_remove_with_answers '' work + + [ "$status" -ne 0 ] + [[ "$output" =~ "running" ]] + # Registry entry MUST be preserved when the refusal fires. + jq -e '.instances.work' "$CKIPPER_DIR/desktop.json" >/dev/null +} + +@test "desktop remove fails clearly when instance not registered" { + _install_fake_claude_app + + run_ckipper desktop remove ghost + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} + +@test "desktop remove fails clearly when no registry exists yet" { + _install_fake_claude_app + + run_ckipper desktop remove ghost + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} From 641fdcf6c0f1bd1bf101e69da0f132ce51490bc0 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:31:37 -0600 Subject: [PATCH 17/31] feat(desktop): implement desktop rename Renames a Desktop instance in place: moves the data dir, regenerates the .app bundle under the new name, and atomically swaps the registry entry. Refuses if the instance is running or if the destination name is already taken, validates the new name against the lowercase regex, and rolls back the directory move + bundle regeneration if the registry update fails. Helper splits (rename_validate / rename_perform_fs / rename_swap_registry / rename_rollback_fs) keep every function inside the 25-line / 2-nesting / 3-param caps from .claude/rules/code-style.md. --- lib/desktop/dispatcher.zsh | 1 - lib/desktop/instance-management.zsh | 129 ++++++++++++++++++++++ lib/desktop/instance-management_test.bats | 77 +++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh index 68838ad..459eb9a 100644 --- a/lib/desktop/dispatcher.zsh +++ b/lib/desktop/dispatcher.zsh @@ -69,6 +69,5 @@ _ckipper_desktop_unknown() { # Each stub returns 1 so users typing them get a "not yet implemented" signal. # The task number is embedded in each message for grep-ability when wiring up # the real handlers. -_ckipper_desktop_rename() { echo "ckipper desktop rename: not yet implemented (Task 8)" >&2; return 1; } _ckipper_desktop_login() { echo "ckipper desktop login: not yet implemented (Task 10)" >&2; return 1; } _ckipper_desktop_launch() { echo "ckipper desktop launch: not yet implemented (Task 11)" >&2; return 1; } diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh index 646aabc..ec8f546 100644 --- a/lib/desktop/instance-management.zsh +++ b/lib/desktop/instance-management.zsh @@ -369,6 +369,135 @@ _ckipper_desktop_remove_prompt_bundle() { echo "Kept $bundle. To delete later: rm -rf '$bundle'" } +# Validate `ckipper desktop rename ` arguments before any I/O. +# +# Args: $1 — old name; $2 — new name. +# Returns: 0 on valid input; 1 on any check failure. +# Errors (stderr): usage hint, regex hint, collision message, etc. +_ckipper_desktop_rename_validate() { + local old="$1" new="$2" + if [[ -z "$old" || -z "$new" ]]; then + echo "Usage: ckipper desktop rename " >&2 + return 1 + fi + if [[ ! "$new" =~ $_CKIPPER_DESKTOP_NAME_REGEX ]]; then + 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." >&2 + return 1 + fi + if ! jq -e --arg n "$old" '.instances[$n]' "$CKIPPER_DESKTOP_REGISTRY" >/dev/null 2>&1; then + echo "Desktop instance '$old' is not registered." >&2 + return 1 + fi + if jq -e --arg n "$new" '.instances[$n]' "$CKIPPER_DESKTOP_REGISTRY" >/dev/null 2>&1; then + echo "Desktop instance '$new' is already registered." >&2 + return 1 + fi +} + +# Atomically update the registry: insert the new entry (copied from the old +# but with refreshed user_data_dir + app_bundle_path) and delete the old +# entry — all in a single jq filter so a concurrent reader can never observe +# both or neither. +# +# Args: $1 — old name; $2 — new name. +# Returns: 0 on success; 1 on registry write failure. +_ckipper_desktop_rename_swap_registry() { + local old="$1" new="$2" + local new_data_dir new_bundle + new_data_dir=$(_ckipper_desktop_data_dir_for "$new") + new_bundle=$(_ckipper_desktop_bundle_path_for "$new") + CKIPPER_REGISTRY_VERSION="$CKIPPER_DESKTOP_REGISTRY_VERSION" \ + _core_registry_update_at "$CKIPPER_DESKTOP_REGISTRY" ' + .instances[$new] = ( + .instances[$old] + | .user_data_dir = $newdir + | .app_bundle_path = $newbundle + ) + | del(.instances[$old]) + ' --arg old "$old" --arg new "$new" \ + --arg newdir "$new_data_dir" --arg newbundle "$new_bundle" +} + +# Perform the on-disk side of a rename: move the user-data dir to its new +# path, then regenerate the .app bundle under the new name. Rolls back the +# dir move + new bundle if any step fails. Old bundle is removed only after +# the new bundle is written so a mid-rename crash always leaves at least +# one bundle usable. +# +# Args: $1 — old name; $2 — new name. +# Returns: 0 on success; 1 on any filesystem step failure. +_ckipper_desktop_rename_perform_fs() { + local old="$1" new="$2" + local old_dir new_dir old_bundle new_bundle + old_dir=$(_ckipper_desktop_data_dir_for "$old") + new_dir=$(_ckipper_desktop_data_dir_for "$new") + old_bundle=$(_ckipper_desktop_bundle_of "$old") + new_bundle=$(_ckipper_desktop_bundle_path_for "$new") + if [[ -e "$new_dir" || -e "$new_bundle" ]]; then + echo "Error: destination path already exists ($new_dir or $new_bundle)." >&2 + return 1 + fi + [[ -d "$old_dir" ]] && { mv "$old_dir" "$new_dir" || return 1; } + if ! _ckipper_desktop_bundle_write "$new" "$new_bundle" "$new_dir"; then + [[ -d "$new_dir" ]] && mv "$new_dir" "$old_dir" 2>/dev/null + return 1 + fi + [[ -d "$old_bundle" ]] && rm -rf "$old_bundle" +} + +# Roll back a partial rename when the registry write fails after the +# filesystem moves succeeded. Restores both the data dir and the original +# bundle (regenerated from the old name) so the registry/disk pair stays +# in sync. +# +# Args: $1 — old name; $2 — new name. +# Returns: 0 always (best-effort rollback). +_ckipper_desktop_rename_rollback_fs() { + local old="$1" new="$2" + local old_dir new_dir old_bundle new_bundle + old_dir=$(_ckipper_desktop_data_dir_for "$old") + new_dir=$(_ckipper_desktop_data_dir_for "$new") + old_bundle=$(_ckipper_desktop_bundle_path_for "$old") + new_bundle=$(_ckipper_desktop_bundle_path_for "$new") + [[ -d "$new_dir" ]] && mv "$new_dir" "$old_dir" 2>/dev/null + [[ -d "$new_bundle" ]] && rm -rf "$new_bundle" + _ckipper_desktop_bundle_write "$old" "$old_bundle" "$old_dir" 2>/dev/null + return 0 +} + +# Rename a registered Desktop instance: move the user-data dir, regenerate +# the .app bundle under the new name, and update the registry. Refuses if +# the instance is running or if the destination name is taken. Rolls back +# the filesystem changes if the registry write fails. +# +# Args: $1 — old name; $2 — new name. +# Returns: 0 on success; 1 on any failure. +_ckipper_desktop_rename() { + local old="$1" new="$2" + [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || { + echo "Desktop instance '$old' is not registered." >&2; return 1 + } + _ckipper_desktop_rename_validate "$old" "$new" || return 1 + local old_dir + old_dir=$(_ckipper_desktop_data_dir_for "$old") + _ckipper_desktop_assert_not_running "$old_dir" || return 1 + _ckipper_desktop_rename_perform_fs "$old" "$new" || { + echo "Error: filesystem rename failed; left in place." >&2; return 1 + } + if ! _ckipper_desktop_rename_swap_registry "$old" "$new"; then + _ckipper_desktop_rename_rollback_fs "$old" "$new" + echo "Error: registry update failed; reverted filesystem rename." >&2 + return 1 + fi + echo "Renamed Desktop instance '$old' → '$new'." + echo "Data dir: $old_dir → $(_ckipper_desktop_data_dir_for "$new")" + echo "Bundle: $(_ckipper_desktop_bundle_path_for "$new")" +} + # Unregister a Desktop instance from the registry, then interactively prompt # to delete the user-data dir (default N — preserves user data) and the # .app bundle (regeneratable). Refuses if the instance is currently running. diff --git a/lib/desktop/instance-management_test.bats b/lib/desktop/instance-management_test.bats index a762b68..bb5529e 100644 --- a/lib/desktop/instance-management_test.bats +++ b/lib/desktop/instance-management_test.bats @@ -217,3 +217,80 @@ _run_remove_with_answers() { [ "$status" -ne 0 ] [[ "$output" =~ "not registered" ]] } + +# ── desktop rename ─────────────────────────────────────────────────────── + +@test "desktop rename moves data dir, regenerates bundle, updates registry" { + _install_fake_claude_app + run_ckipper desktop add work + + run_ckipper desktop rename work prod + + [ "$status" -eq 0 ] + [ -d "$HOME/.claude-desktop-prod" ] + [ ! -d "$HOME/.claude-desktop-work" ] + [ -d "$HOME/Applications/Claude-Prod.app" ] + [ ! -d "$HOME/Applications/Claude-Work.app" ] + jq -e '.instances.prod' "$CKIPPER_DIR/desktop.json" >/dev/null + ! jq -e '.instances.work' "$CKIPPER_DIR/desktop.json" >/dev/null 2>&1 + local recorded_dir + recorded_dir=$(jq -r '.instances.prod.user_data_dir' "$CKIPPER_DIR/desktop.json") + [ "$recorded_dir" = "$HOME/.claude-desktop-prod" ] +} + +@test "desktop rename refuses collision with another registered instance" { + _install_fake_claude_app + run_ckipper desktop add work + run_ckipper desktop add personal + + run_ckipper desktop rename work personal + + [ "$status" -ne 0 ] + [[ "$output" =~ "already registered" ]] + # Both originals must survive the refusal. + jq -e '.instances.work' "$CKIPPER_DIR/desktop.json" >/dev/null + jq -e '.instances.personal' "$CKIPPER_DIR/desktop.json" >/dev/null +} + +@test "desktop rename refuses if source is running" { + _install_fake_claude_app + run_ckipper desktop add work + + PGREP_STUB_MATCH=1 run_ckipper desktop rename work prod + + [ "$status" -ne 0 ] + [[ "$output" =~ "running" ]] + # Source must survive the refusal. + jq -e '.instances.work' "$CKIPPER_DIR/desktop.json" >/dev/null + [ -d "$HOME/.claude-desktop-work" ] +} + +@test "desktop rename refuses if source is not registered" { + _install_fake_claude_app + run_ckipper desktop add other + + run_ckipper desktop rename ghost prod + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} + +@test "desktop rename refuses identical old/new names" { + _install_fake_claude_app + run_ckipper desktop add work + + run_ckipper desktop rename work work + + [ "$status" -ne 0 ] + [[ "$output" =~ [Nn]othing\ to\ do ]] +} + +@test "desktop rename refuses invalid new name" { + _install_fake_claude_app + run_ckipper desktop add work + + run_ckipper desktop rename work "Bad Name" + + [ "$status" -ne 0 ] + [[ "$output" =~ "must match" ]] +} From a53c3268ab748fbb9ddc86a5d8fe320beaa829e1 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:36:05 -0600 Subject: [PATCH 18/31] refactor(desktop): name deep-link threshold + tighten rename test regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small cleanups surfaced by the Task 5-8 self-review: - Extract the literal "2" in _ckipper_desktop_add_announce into a named constant (_CKIPPER_DESKTOP_DEEP_LINK_TIP_THRESHOLD) — the project's NO_MAGIC_NUMBERS rule has no exceptions, and the threshold has a domain meaning worth a doc comment. - Replace the brittle '[Nn]othing\ to\ do' regex in the rename identical-names test with a quoted literal substring match. Bash regex's '\ ' is interpretation-dependent; double-quoted strings on the RHS of =~ are guaranteed literal in bash 3.2+ (bats's floor on macOS). --- lib/desktop/instance-management.zsh | 9 ++++++++- lib/desktop/instance-management_test.bats | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh index ec8f546..9f86a73 100644 --- a/lib/desktop/instance-management.zsh +++ b/lib/desktop/instance-management.zsh @@ -18,6 +18,13 @@ # Mirrors lib/account/account-management.zsh's name regex for consistency. readonly _CKIPPER_DESKTOP_NAME_REGEX='^[a-z0-9_-]+$' +# Minimum number of registered instances that triggers the post-`desktop add` +# deep-link routing tip. With only one instance the `claude://` OAuth callback +# always lands in the right place; two or more brings the routing pitfall +# that `ckipper desktop login` is designed to solve, so we nudge the user +# toward it the moment they cross the threshold. +readonly _CKIPPER_DESKTOP_DEEP_LINK_TIP_THRESHOLD=2 + # Compute the user-data dir for a given instance name. HOME is read at call # time so per-test overrides work; do NOT cache this in a module-level const. # @@ -158,7 +165,7 @@ _ckipper_desktop_add_announce() { echo "Data dir: $(_ckipper_desktop_data_dir_for "$name")" local count count=$(_ckipper_desktop_instance_count) - if (( count >= 2 )); then + if (( count >= _CKIPPER_DESKTOP_DEEP_LINK_TIP_THRESHOLD )); then echo "" echo "Tip: with two or more Desktop instances installed, use \`ckipper desktop login \`" echo "before running /login so the OAuth deep-link lands in the right window." diff --git a/lib/desktop/instance-management_test.bats b/lib/desktop/instance-management_test.bats index bb5529e..423e05f 100644 --- a/lib/desktop/instance-management_test.bats +++ b/lib/desktop/instance-management_test.bats @@ -282,7 +282,7 @@ _run_remove_with_answers() { run_ckipper desktop rename work work [ "$status" -ne 0 ] - [[ "$output" =~ [Nn]othing\ to\ do ]] + [[ "$output" =~ "Nothing to do" ]] } @test "desktop rename refuses invalid new name" { From 50c26e51ebfc872b2534b9a816f83583f2d31827 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:46:15 -0600 Subject: [PATCH 19/31] refactor(desktop): relocate _assert_not_running to launcher.zsh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper was placed in instance-management.zsh during Tasks 7-8 as a temporary measure. Its proper home is lib/desktop/launcher.zsh alongside the launch / login functions that share the same pgrep semantics. Callers (remove, rename) are unchanged — function name is the same. Adds dedicated bats tests for the helper. Establishes launcher.zsh as the new launcher module with module-level timing constants used by Task 10's login dance. Also lifts _install_fake_claude_app from instance-management_test.bats into tests/lib/test-helper.bash so the new launcher_test.bats (and future test files) can share it. --- ckipper.zsh | 1 + lib/desktop/instance-management.zsh | 19 --------- lib/desktop/instance-management_test.bats | 12 ------ lib/desktop/launcher.zsh | 50 +++++++++++++++++++++++ lib/desktop/launcher_test.bats | 30 ++++++++++++++ tests/lib/test-helper.bash | 17 ++++++++ 6 files changed, 98 insertions(+), 31 deletions(-) create mode 100644 lib/desktop/launcher.zsh create mode 100644 lib/desktop/launcher_test.bats diff --git a/ckipper.zsh b/ckipper.zsh index 70968c4..079a72d 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -71,6 +71,7 @@ source "$CKIPPER_REPO_DIR/lib/config/dispatcher.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/help.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/bundle.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/instance-management.zsh" +source "$CKIPPER_REPO_DIR/lib/desktop/launcher.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/dispatcher.zsh" # Setup-namespace modules diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh index 9f86a73..025e5ba 100644 --- a/lib/desktop/instance-management.zsh +++ b/lib/desktop/instance-management.zsh @@ -300,25 +300,6 @@ _ckipper_desktop_list_print_rows() { done } -# Refuse if a Claude Desktop process is currently running against the given -# user-data dir. Used by `desktop remove` and `desktop rename` to block -# destructive ops on a live instance. -# -# TODO(Task 9): replace with _ckipper_desktop_assert_not_running once that -# helper lands. Inlined here because remove/rename need the check before -# the launcher module exists. -# -# Args: $1 — user-data dir to probe. -# Returns: 0 if no matching process; 1 otherwise. -# Errors (stderr): "Refusing: ..." when a matching process is found. -_ckipper_desktop_assert_not_running() { - local data_dir="$1" - pgrep -f -- "--user-data-dir=$data_dir" >/dev/null 2>&1 || return 0 - echo "Refusing: a Claude Desktop instance is running for $data_dir." >&2 - echo "Quit it first, then re-run." >&2 - return 1 -} - # Look up the user-data dir for a registered instance. # # Args: $1 — instance name. diff --git a/lib/desktop/instance-management_test.bats b/lib/desktop/instance-management_test.bats index 423e05f..1f30d0c 100644 --- a/lib/desktop/instance-management_test.bats +++ b/lib/desktop/instance-management_test.bats @@ -12,18 +12,6 @@ teardown() { teardown_isolated_env } -# Install a fake /Applications/Claude.app under the per-test $TMP_HOME and -# point the desktop module at it via the documented env override. Required -# before any `desktop add` test because the real add flow refuses when the -# system Claude.app is missing. -_install_fake_claude_app() { - export _CKIPPER_TEST_OSTYPE="darwin19.0" - export _CKIPPER_DESKTOP_SYSTEM_APP="$TMP_HOME/FakeClaude.app" - export _CKIPPER_TEST_CLAUDE_APP="$TMP_HOME/FakeClaude.app" - mkdir -p "$_CKIPPER_DESKTOP_SYSTEM_APP/Contents/MacOS" - mkdir -p "$_CKIPPER_DESKTOP_SYSTEM_APP/Contents/Resources" -} - # ── desktop add ────────────────────────────────────────────────────────── @test "desktop add registers a new instance and writes registry entry" { diff --git a/lib/desktop/launcher.zsh b/lib/desktop/launcher.zsh new file mode 100644 index 0000000..83e1607 --- /dev/null +++ b/lib/desktop/launcher.zsh @@ -0,0 +1,50 @@ +#!/usr/bin/env zsh +# Launch / login / process helpers for Claude Desktop instances. +# +# Three public entry points (only assert_not_running is in place after Task 9; +# launch / login land in Tasks 10 + 11): +# _ckipper_desktop_launch — open -n -a (no quit dance) +# _ckipper_desktop_login — quit ALL Claude.app processes, then launch +# _ckipper_desktop_assert_not_running — refuse if a Claude process owns +# this user-data-dir +# +# Why pgrep against --user-data-dir, not the bundle path: +# every wrapper's Contents/MacOS/launcher exec's /Applications/Claude.app +# directly, so the bundle path never appears in process listings. Only the +# system Claude binary path and the --user-data-dir flag do. The per-instance +# probe matches on that flag; the all-Claude probe (login dance) matches on +# the binary path. + +# Cmdline substring identifying any running Claude Desktop (Electron) process. +# Every wrapper bundle's launcher exec's /Applications/Claude.app — so the +# bundle path NEVER appears in pgrep output; only this path does. +readonly _CKIPPER_DESKTOP_CLAUDE_PROCESS_PATTERN='/Applications/Claude.app/Contents/MacOS/Claude' + +# Login-dance timing. typeset -g (NOT readonly) so tests can shrink the +# numbers for fast feedback without waiting the full 5s timeout. Consumed by +# Task 10's quit-all-Claude polling loop. +typeset -g _CKIPPER_DESKTOP_POLL_INTERVAL_SECONDS="0.2" +typeset -g _CKIPPER_DESKTOP_TERM_TIMEOUT_MAX_POLLS=25 + +# Refuse the operation if a Claude Desktop instance is currently running +# against this data dir. +# +# Detection: pgrep for the cmdline argument `--user-data-dir=`. Bundle +# path is irrelevant because wrapper bundles never appear in process listings +# (their launcher exec's into /Applications/Claude.app). +# +# Args: $1 — the user-data-dir to check. +# Returns: 0 if no matching process is found; 1 if the instance is running. +# +# Errors (stderr): +# "Refusing: a Claude Desktop instance is running for (PID(s): ...)." +# "Quit it first (Cmd-Q on the instance), then re-run." +_ckipper_desktop_assert_not_running() { + local data_dir="$1" + local pids + pids=$(pgrep -f -- "--user-data-dir=$data_dir" 2>/dev/null) || return 0 + [[ -z "$pids" ]] && return 0 + echo "Refusing: a Claude Desktop instance is running for $data_dir (PID(s): $pids)." >&2 + echo "Quit it first (Cmd-Q on the instance), then re-run." >&2 + return 1 +} diff --git a/lib/desktop/launcher_test.bats b/lib/desktop/launcher_test.bats new file mode 100644 index 0000000..5f792b5 --- /dev/null +++ b/lib/desktop/launcher_test.bats @@ -0,0 +1,30 @@ +#!/usr/bin/env bats +# Tests for lib/desktop/launcher.zsh — process checks (Task 9), +# desktop login (Task 10), and desktop launch (Task 11). + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { + setup_isolated_env +} + +teardown() { + teardown_isolated_env +} + +# ── _ckipper_desktop_assert_not_running ──────────────────────────────────── + +@test "assert_not_running returns 0 when pgrep finds nothing" { + run env HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/desktop/launcher.zsh\"; _ckipper_desktop_assert_not_running \"$HOME/.claude-desktop-work\"" + + [ "$status" -eq 0 ] +} + +@test "assert_not_running returns 1 and prints PID when pgrep finds a match" { + run env HOME="$TMP_HOME" PATH="$PATH" PGREP_STUB_MATCH=1 \ + zsh -c "source \"$REPO_ROOT/lib/desktop/launcher.zsh\"; _ckipper_desktop_assert_not_running \"$HOME/.claude-desktop-work\"" + + [ "$status" -ne 0 ] + [[ "$output" =~ "99999" ]] || [[ "$output" =~ "running" ]] +} diff --git a/tests/lib/test-helper.bash b/tests/lib/test-helper.bash index 5d6fbc1..80f1720 100644 --- a/tests/lib/test-helper.bash +++ b/tests/lib/test-helper.bash @@ -67,6 +67,23 @@ source_ckipper_file() { source "$REPO_ROOT/$rel_path" } +# Install a fake /Applications/Claude.app under the per-test $TMP_HOME and +# point the desktop module at it via the documented env override. Required +# before any `desktop add`/`login`/`launch` test because the real flows +# refuse when the system Claude.app is missing. +# +# Sets _CKIPPER_TEST_OSTYPE so the desktop dispatcher macOS-guard passes +# and _CKIPPER_DESKTOP_SYSTEM_APP / _CKIPPER_TEST_CLAUDE_APP to point at +# the fake bundle. Both vars are exported so child zsh subprocesses (the +# ones run_ckipper spawns) inherit them. +_install_fake_claude_app() { + export _CKIPPER_TEST_OSTYPE="darwin19.0" + export _CKIPPER_DESKTOP_SYSTEM_APP="$TMP_HOME/FakeClaude.app" + export _CKIPPER_TEST_CLAUDE_APP="$TMP_HOME/FakeClaude.app" + mkdir -p "$_CKIPPER_DESKTOP_SYSTEM_APP/Contents/MacOS" + mkdir -p "$_CKIPPER_DESKTOP_SYSTEM_APP/Contents/Resources" +} + # Assert a file exists. assert_file_exists() { [[ -f "$1" ]] || { echo "Expected file: $1" >&2; return 1; } From cde3db454d750c098b93dd492923dbbbf4614810 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:49:52 -0600 Subject: [PATCH 20/31] feat(desktop): implement desktop login (deep-link auth dance) Quits ALL running Claude Desktop processes via SIGTERM (with SIGKILL fallback after a 5s timeout), then opens only the target wrapper bundle. Works around the macOS claude:// deep-link auth-callback routing gotcha: with multiple instances running, the OAuth callback lands in whichever Claude app was most recently active. Quitting everything first guarantees the callback has only one place to land. Timing constants are typeset -g (not readonly) so tests can shrink the timeout for fast feedback. Removes the Task 10 stub from dispatcher.zsh. --- lib/desktop/dispatcher.zsh | 1 - lib/desktop/launcher.zsh | 85 ++++++++++++++++++++++- lib/desktop/launcher_test.bats | 120 +++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh index 459eb9a..55b3fcb 100644 --- a/lib/desktop/dispatcher.zsh +++ b/lib/desktop/dispatcher.zsh @@ -69,5 +69,4 @@ _ckipper_desktop_unknown() { # Each stub returns 1 so users typing them get a "not yet implemented" signal. # The task number is embedded in each message for grep-ability when wiring up # the real handlers. -_ckipper_desktop_login() { echo "ckipper desktop login: not yet implemented (Task 10)" >&2; return 1; } _ckipper_desktop_launch() { echo "ckipper desktop launch: not yet implemented (Task 11)" >&2; return 1; } diff --git a/lib/desktop/launcher.zsh b/lib/desktop/launcher.zsh index 83e1607..2bc1f5e 100644 --- a/lib/desktop/launcher.zsh +++ b/lib/desktop/launcher.zsh @@ -1,9 +1,8 @@ #!/usr/bin/env zsh # Launch / login / process helpers for Claude Desktop instances. # -# Three public entry points (only assert_not_running is in place after Task 9; -# launch / login land in Tasks 10 + 11): -# _ckipper_desktop_launch — open -n -a (no quit dance) +# Three public entry points: +# _ckipper_desktop_launch — open -n -a (no quit dance) [Task 11] # _ckipper_desktop_login — quit ALL Claude.app processes, then launch # _ckipper_desktop_assert_not_running — refuse if a Claude process owns # this user-data-dir @@ -48,3 +47,83 @@ _ckipper_desktop_assert_not_running() { echo "Quit it first (Cmd-Q on the instance), then re-run." >&2 return 1 } + +# Look up an instance's bundle path. Fails if the instance is not registered. +# Mirrors the registry-existence check pattern at +# instance-management.zsh::_ckipper_desktop_data_dir_of — kept local to the +# launcher namespace because feature dirs MUST NOT call into each other +# beyond public, namespaced entry points; instance-management.zsh's +# _ckipper_desktop_data_dir_of returns a different field (data_dir, not +# bundle) so we don't reuse it. +# +# Args: $1 — instance name. +# Returns: 0 with bundle path on stdout; 1 with error on stderr. +# Errors (stderr): "Desktop instance '' is not registered." +_ckipper_desktop_lookup_bundle() { + local name="$1" + if [[ ! -f "$CKIPPER_DESKTOP_REGISTRY" ]] \ + || ! jq -e --arg n "$name" '.instances[$n]' \ + "$CKIPPER_DESKTOP_REGISTRY" >/dev/null 2>&1; then + echo "Desktop instance '$name' is not registered." >&2 + return 1 + fi + jq -r --arg n "$name" '.instances[$n].app_bundle_path' "$CKIPPER_DESKTOP_REGISTRY" +} + +# Poll until every PID in $1 (newline-separated) has exited, escalating to +# SIGKILL once _TERM_TIMEOUT_MAX_POLLS polls have elapsed. Uses an integer +# poll count + a literal-string sleep interval to avoid floating-point +# arithmetic in zsh. +# +# Args: $1 — newline-separated PIDs (output of pgrep). +# Returns: 0 always. +_ckipper_desktop_wait_for_exit() { + local pids="$1" + local polls=0 pid still + while (( polls < _CKIPPER_DESKTOP_TERM_TIMEOUT_MAX_POLLS )); do + still=0 + for pid in ${(f)pids}; do + kill -0 "$pid" 2>/dev/null && still=1 + done + (( still == 0 )) && return 0 + sleep "$_CKIPPER_DESKTOP_POLL_INTERVAL_SECONDS" + (( polls += 1 )) + done + for pid in ${(f)pids}; do + kill -KILL "$pid" 2>/dev/null + done +} + +# Quit ALL running Claude Desktop processes (the bare app + every wrapper). +# SIGTERM first, then poll up to the configured timeout, then SIGKILL any +# stragglers via _ckipper_desktop_wait_for_exit. +# +# Returns: 0 once all processes have exited (or none were running). +_ckipper_desktop_quit_all_claude_processes() { + local pids + pids=$(pgrep -f "$_CKIPPER_DESKTOP_CLAUDE_PROCESS_PATTERN" 2>/dev/null) || return 0 + [[ -z "$pids" ]] && return 0 + local pid + for pid in ${(f)pids}; do + kill -TERM "$pid" 2>/dev/null + done + _ckipper_desktop_wait_for_exit "$pids" +} + +# Quit all running Claude Desktop processes, then launch only . +# +# Use this command to safely complete a /login flow: macOS routes +# claude:// deep-link callbacks to the most-recently-active Claude app, +# so with multiple instances running the callback can land in the wrong +# window. By quitting everything first and launching just , the +# deep-link callback has only one place to land. +# +# Args: $1 — instance name. +# Returns: 0 on success; 1 if the instance is not registered. +_ckipper_desktop_login() { + local name="$1" + local bundle + bundle=$(_ckipper_desktop_lookup_bundle "$name") || return 1 + _ckipper_desktop_quit_all_claude_processes + open -n -a "$bundle" +} diff --git a/lib/desktop/launcher_test.bats b/lib/desktop/launcher_test.bats index 5f792b5..3835388 100644 --- a/lib/desktop/launcher_test.bats +++ b/lib/desktop/launcher_test.bats @@ -28,3 +28,123 @@ teardown() { [ "$status" -ne 0 ] [[ "$output" =~ "99999" ]] || [[ "$output" =~ "running" ]] } + +# ── desktop login (Task 10) ──────────────────────────────────────────────── +# +# Login tests use zsh function overrides INSIDE the spawned subshell (not via +# PATH stubs) because the dance has multiple phases — initial pgrep, then +# wait_for_exit's kill -0 polls, then optional SIGKILL — and each phase needs +# a different mock response. PATH stubs can't carry that state. + +@test "login looks up bundle from registry and opens it" { + _install_fake_claude_app + run_ckipper desktop add work + + local mock_log="$TMP_HOME/mock.log" + : >"$mock_log" + run env HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" \ + _CKIPPER_TEST_OSTYPE="darwin19.0" \ + _CKIPPER_DESKTOP_SYSTEM_APP="$_CKIPPER_DESKTOP_SYSTEM_APP" \ + PATH="$PATH" MOCK_LOG="$mock_log" \ + zsh -c ' + source "'"$REPO_ROOT"'/ckipper.zsh" + pgrep() { return 1; } + open() { echo "open $*" >> "$MOCK_LOG"; } + ckipper desktop login work + ' + + [ "$status" -eq 0 ] + grep -q "open -n -a $HOME/Applications/Claude-Work.app" "$mock_log" +} + +@test "login quits running Claude processes via TERM before launching target" { + _install_fake_claude_app + run_ckipper desktop add work + + local mock_log="$TMP_HOME/mock.log" + : >"$mock_log" + run env HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" \ + _CKIPPER_TEST_OSTYPE="darwin19.0" \ + _CKIPPER_DESKTOP_SYSTEM_APP="$_CKIPPER_DESKTOP_SYSTEM_APP" \ + PATH="$PATH" MOCK_LOG="$mock_log" \ + zsh -c ' + source "'"$REPO_ROOT"'/ckipper.zsh" + # Shrink timeout so the test does not idle for 5s if mocks misbehave. + _CKIPPER_DESKTOP_TERM_TIMEOUT_MAX_POLLS=2 + _CKIPPER_DESKTOP_POLL_INTERVAL_SECONDS="0.05" + pgrep() { echo 1001; echo 1002; } + # kill -0 (alive-check) returns non-zero so wait_for_exit exits + # the polling loop immediately ("all dead"). kill -TERM is logged. + kill() { + echo "kill $*" >> "$MOCK_LOG" + [[ "$1" == "-0" ]] && return 1 + return 0 + } + open() { echo "open $*" >> "$MOCK_LOG"; } + ckipper desktop login work + ' + + [ "$status" -eq 0 ] + grep -q "kill -TERM 1001" "$mock_log" + grep -q "kill -TERM 1002" "$mock_log" + grep -q "open -n -a" "$mock_log" +} + +@test "login escalates SIGTERM to SIGKILL after timeout" { + _install_fake_claude_app + run_ckipper desktop add work + + local mock_log="$TMP_HOME/mock.log" + : >"$mock_log" + run env HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" \ + _CKIPPER_TEST_OSTYPE="darwin19.0" \ + _CKIPPER_DESKTOP_SYSTEM_APP="$_CKIPPER_DESKTOP_SYSTEM_APP" \ + PATH="$PATH" MOCK_LOG="$mock_log" \ + zsh -c ' + source "'"$REPO_ROOT"'/ckipper.zsh" + # Two polls at 50ms each = 0.1s before SIGKILL fires. + _CKIPPER_DESKTOP_TERM_TIMEOUT_MAX_POLLS=2 + _CKIPPER_DESKTOP_POLL_INTERVAL_SECONDS="0.05" + pgrep() { echo 1001; } + # kill -0 ALWAYS reports alive — forces escalation to SIGKILL. + kill() { + echo "kill $*" >> "$MOCK_LOG" + [[ "$1" == "-0" ]] && return 0 + return 0 + } + open() { echo "open $*" >> "$MOCK_LOG"; } + ckipper desktop login work + ' + + grep -q "kill -KILL 1001" "$mock_log" +} + +@test "login fails when instance is not registered" { + _install_fake_claude_app + + run_ckipper desktop login ghost + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} + +@test "login succeeds when no Claude processes are running" { + _install_fake_claude_app + run_ckipper desktop add work + + local mock_log="$TMP_HOME/mock.log" + : >"$mock_log" + run env HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" \ + _CKIPPER_TEST_OSTYPE="darwin19.0" \ + _CKIPPER_DESKTOP_SYSTEM_APP="$_CKIPPER_DESKTOP_SYSTEM_APP" \ + PATH="$PATH" MOCK_LOG="$mock_log" \ + zsh -c ' + source "'"$REPO_ROOT"'/ckipper.zsh" + pgrep() { return 1; } + open() { echo "open $*" >> "$MOCK_LOG"; } + ckipper desktop login work + ' + + [ "$status" -eq 0 ] + grep -q "open -n -a" "$mock_log" +} From c57278355bf6469f257b45e4647e7d15f5d0c3a2 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 16:54:09 -0600 Subject: [PATCH 21/31] feat(desktop): implement desktop launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens the registered Desktop instance via open -n -a on its wrapper bundle. Unlike desktop login, this does NOT quit other running Claude instances — use it when you know auth flows aren't in play. Removes the Task 11 stub from dispatcher.zsh, which now contains zero "not yet implemented" stubs; all six desktop subcommands (add, list, remove, rename, login, launch) are live. --- lib/desktop/dispatcher.zsh | 6 ------ lib/desktop/launcher.zsh | 15 +++++++++++++- lib/desktop/launcher_test.bats | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh index 55b3fcb..1c5fa68 100644 --- a/lib/desktop/dispatcher.zsh +++ b/lib/desktop/dispatcher.zsh @@ -64,9 +64,3 @@ _ckipper_desktop_unknown() { "Run 'ckipper desktop help' for available commands." \ "${_CKIPPER_DESKTOP_SUBCOMMANDS[@]}" } - -# --- TEMPORARY STUBS (deleted as Tasks 5..11 land the real implementations) --- -# Each stub returns 1 so users typing them get a "not yet implemented" signal. -# The task number is embedded in each message for grep-ability when wiring up -# the real handlers. -_ckipper_desktop_launch() { echo "ckipper desktop launch: not yet implemented (Task 11)" >&2; return 1; } diff --git a/lib/desktop/launcher.zsh b/lib/desktop/launcher.zsh index 2bc1f5e..867250d 100644 --- a/lib/desktop/launcher.zsh +++ b/lib/desktop/launcher.zsh @@ -2,7 +2,7 @@ # Launch / login / process helpers for Claude Desktop instances. # # Three public entry points: -# _ckipper_desktop_launch — open -n -a (no quit dance) [Task 11] +# _ckipper_desktop_launch — open -n -a (no quit dance) # _ckipper_desktop_login — quit ALL Claude.app processes, then launch # _ckipper_desktop_assert_not_running — refuse if a Claude process owns # this user-data-dir @@ -127,3 +127,16 @@ _ckipper_desktop_login() { _ckipper_desktop_quit_all_claude_processes open -n -a "$bundle" } + +# Open a registered Desktop instance without disturbing others. +# This is the simple, non-auth path — use `ckipper desktop login ` +# instead when completing a /login flow that involves deep-link callbacks. +# +# Args: $1 — instance name. +# Returns: 0 on success; 1 if the instance is not registered. +_ckipper_desktop_launch() { + local name="$1" + local bundle + bundle=$(_ckipper_desktop_lookup_bundle "$name") || return 1 + open -n -a "$bundle" +} diff --git a/lib/desktop/launcher_test.bats b/lib/desktop/launcher_test.bats index 3835388..7cd2d25 100644 --- a/lib/desktop/launcher_test.bats +++ b/lib/desktop/launcher_test.bats @@ -148,3 +148,39 @@ teardown() { [ "$status" -eq 0 ] grep -q "open -n -a" "$mock_log" } + +# ── desktop launch (Task 11) ─────────────────────────────────────────────── + +@test "launch opens the registered bundle without quitting other instances" { + _install_fake_claude_app + run_ckipper desktop add work + + local mock_log="$TMP_HOME/mock.log" + : >"$mock_log" + run env HOME="$TMP_HOME" CKIPPER_DIR="$CKIPPER_DIR" \ + _CKIPPER_TEST_OSTYPE="darwin19.0" \ + _CKIPPER_DESKTOP_SYSTEM_APP="$_CKIPPER_DESKTOP_SYSTEM_APP" \ + PATH="$PATH" MOCK_LOG="$mock_log" \ + zsh -c ' + source "'"$REPO_ROOT"'/ckipper.zsh" + # pgrep would trigger the quit dance if launch (mistakenly) called + # _ckipper_desktop_quit_all_claude_processes. It must NOT. + pgrep() { echo 1001; } + kill() { echo "UNEXPECTED kill $*" >> "$MOCK_LOG"; } + open() { echo "open $*" >> "$MOCK_LOG"; } + ckipper desktop launch work + ' + + [ "$status" -eq 0 ] + grep -q "open -n -a $HOME/Applications/Claude-Work.app" "$mock_log" + ! grep -q "UNEXPECTED kill" "$mock_log" +} + +@test "launch fails when instance is not registered" { + _install_fake_claude_app + + run_ckipper desktop launch ghost + + [ "$status" -ne 0 ] + [[ "$output" =~ "not registered" ]] +} From c8432031b19284d34095559e0c2e34b5eaa65257 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:06:38 -0600 Subject: [PATCH 22/31] feat(desktop): add doctor checks + wire into top-level doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lib/desktop/doctor.zsh runs after the account/tooling doctor: checks /Applications/Claude.app presence (FAIL only if instances exist), desktop.json schema, per-instance data_dir + .app bundle existence, plus an Info.plist parse via plutil when available. Warns when 2+ instances are registered (the claude:// deep-link reminder). Module is feature-isolated: uses lib/core/* helpers only, with its own fail/warn counters — does not touch the account-namespace doctor helpers. Top-level dispatcher composes the exit codes via || rc=1. --- ckipper.zsh | 7 +- lib/desktop/doctor.zsh | 209 +++++++++++++++++++++++++++++++++++ lib/desktop/doctor_test.bats | 61 ++++++++++ 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 lib/desktop/doctor.zsh create mode 100644 lib/desktop/doctor_test.bats diff --git a/ckipper.zsh b/ckipper.zsh index 079a72d..8859906 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -72,6 +72,7 @@ source "$CKIPPER_REPO_DIR/lib/desktop/help.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/bundle.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/instance-management.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/launcher.zsh" +source "$CKIPPER_REPO_DIR/lib/desktop/doctor.zsh" source "$CKIPPER_REPO_DIR/lib/desktop/dispatcher.zsh" # Setup-namespace modules @@ -149,7 +150,10 @@ ckipper() { _ckipper_help_text_doctor return 0 fi - _ckipper_doctor "$@" + local _rc=0 + _ckipper_doctor "$@" || _rc=1 + _ckipper_desktop_doctor || _rc=1 + return $_rc ;; "") _ckipper_launcher_menu ;; help|-h|--help) _ckipper_help ;; @@ -219,6 +223,7 @@ _ckipper_help_text_doctor() { " - Keychain entries reachable on macOS" \ " - ~/.zshrc sources ckipper.zsh" \ " - Stub ~/.claude state is absent" \ + " - Per-desktop-instance: data dir present, .app bundle valid (macOS only)" \ "" \ "Exits 0 if every check passes (or only INFOs/WARNs); exits 1 if any FAIL." } diff --git a/lib/desktop/doctor.zsh b/lib/desktop/doctor.zsh new file mode 100644 index 0000000..6ce0a89 --- /dev/null +++ b/lib/desktop/doctor.zsh @@ -0,0 +1,209 @@ +#!/usr/bin/env zsh +# Diagnostic checks for Claude Desktop instances. +# +# Read-only (no --fix paths). Called by the top-level `doctor)` case in +# ckipper.zsh after _ckipper_doctor (the account/tooling doctor). Returns 0 +# on all-pass-or-warns; 1 on any FAIL — the top-level dispatcher composes +# the rc via `|| rc=1`. +# +# Feature-dir isolation: this module calls ONLY lib/core/* helpers (notably +# _core_style_badge and _core_style_header). It does NOT reach into the +# account-namespace doctor helpers; counters are tracked locally. + +# Minimum instance count that triggers the deep-link routing reminder. Two +# or more registered Desktop instances means `claude://` OAuth callbacks may +# land in the wrong window — `ckipper desktop login` mitigates it. +readonly _CKIPPER_DESKTOP_DOCTOR_DEEP_LINK_THRESHOLD=2 + +# Module-level counters consumed by the orchestrator's exit-code decision. +# Kept local to the desktop namespace — no shared state with lib/account. +typeset -g _CKIPPER_DESKTOP_DOCTOR_FAIL=0 +typeset -g _CKIPPER_DESKTOP_DOCTOR_WARN=0 + +# Print a doctor result line and update local counters. +# +# Mirrors the account-side doctor's check helper shape but tracks its own +# counters so the two doctor modules never collide. PASS and INFO are +# non-incrementing; WARN and FAIL bump the matching local counter. +# +# Args: $1 — PASS|WARN|FAIL|INFO; $2 — message. +# Returns: 0 always. +_ckipper_desktop_doctor_render() { + local sym="$1" msg="$2" badge + case "$sym" in + PASS) badge=$(_core_style_badge PASS green) ;; + WARN) badge=$(_core_style_badge WARN yellow); (( _CKIPPER_DESKTOP_DOCTOR_WARN += 1 )) ;; + FAIL) badge=$(_core_style_badge FAIL red); (( _CKIPPER_DESKTOP_DOCTOR_FAIL += 1 )) ;; + INFO) badge="[INFO]" ;; + esac + printf ' %s %s\n' "$badge" "$msg" +} + +# Count registered desktop instances without depending on instance-management. +# Reads the registry directly via jq so feature-dir isolation holds (we don't +# call _ckipper_desktop_instance_count, even though it would behave the same — +# isolating the dependency surface keeps the doctor self-contained). +# +# Returns: 0 always. Prints the instance count on stdout (0 if registry absent +# or unreadable). +_ckipper_desktop_doctor_instance_count() { + [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || { echo 0; return 0; } + jq -r '.instances // {} | length' "$CKIPPER_DESKTOP_REGISTRY" 2>/dev/null || echo 0 +} + +# Check that the system Claude.app exists at $_CKIPPER_DESKTOP_SYSTEM_APP. +# +# On a CLI-only host with no registered instances the missing .app is +# expected — emit INFO and move on. With one or more instances registered, +# the .app is required (it's the open-target of every wrapper launcher), so +# its absence is a FAIL. +# +# Returns: 0 always (results printed via _ckipper_desktop_doctor_render). +_ckipper_desktop_doctor_claude_app_check() { + if [[ -d "$_CKIPPER_DESKTOP_SYSTEM_APP" ]]; then + _ckipper_desktop_doctor_render PASS "Claude.app present: $_CKIPPER_DESKTOP_SYSTEM_APP" + return 0 + fi + local count + count=$(_ckipper_desktop_doctor_instance_count) + if (( count >= 1 )); then + _ckipper_desktop_doctor_render FAIL \ + "Claude.app missing at $_CKIPPER_DESKTOP_SYSTEM_APP — $count instance(s) registered but wrapper launchers cannot open it." + return 0 + fi + _ckipper_desktop_doctor_render INFO \ + "Claude.app not installed and no instances registered — skipping (CLI-only host)." +} + +# Check that desktop.json (if present) parses and matches the expected +# schema version. Reads CKIPPER_DESKTOP_REGISTRY_VERSION via the inline-env +# scoping idiom so the accounts.json version global stays untouched. +# +# Returns: 0 always (results printed via _ckipper_desktop_doctor_render). +_ckipper_desktop_doctor_registry_check() { + if [[ ! -f "$CKIPPER_DESKTOP_REGISTRY" ]]; then + _ckipper_desktop_doctor_render INFO \ + "desktop.json not present (0 instances registered)." + return 0 + fi + if CKIPPER_REGISTRY_VERSION="$CKIPPER_DESKTOP_REGISTRY_VERSION" \ + _core_registry_check_version_at "$CKIPPER_DESKTOP_REGISTRY" >/dev/null 2>&1; then + _ckipper_desktop_doctor_render PASS \ + "desktop.json version $CKIPPER_DESKTOP_REGISTRY_VERSION matches expected" + else + _ckipper_desktop_doctor_render FAIL \ + "desktop.json has unsupported version or is corrupt — restore from backup or remove." + fi +} + +# Check that one instance's user_data_dir exists on disk. +# +# Args: $1 — instance name; $2 — user_data_dir path. +# Returns: 0 always. +_ckipper_desktop_doctor_check_data_dir() { + local name="$1" data_dir="$2" + if [[ -d "$data_dir" ]]; then + _ckipper_desktop_doctor_render PASS " [$name] data dir present: $data_dir" + else + _ckipper_desktop_doctor_render FAIL " [$name] data dir missing: $data_dir" + fi +} + +# Check that one instance's .app bundle and Info.plist exist; if plutil is +# on PATH, also lint the plist. plutil-missing emits INFO so CI containers +# without macOS tooling don't FAIL on what's a host-tooling gap. +# +# Args: $1 — instance name; $2 — app_bundle_path. +# Returns: 0 always. +_ckipper_desktop_doctor_check_bundle() { + local name="$1" bundle="$2" + local plist="$bundle/Contents/Info.plist" + if [[ ! -d "$bundle" ]]; then + _ckipper_desktop_doctor_render FAIL " [$name] .app bundle missing: $bundle" + return 0 + fi + _ckipper_desktop_doctor_render PASS " [$name] .app bundle present: $bundle" + if [[ ! -f "$plist" ]]; then + _ckipper_desktop_doctor_render FAIL " [$name] Info.plist missing: $plist" + return 0 + fi + _ckipper_desktop_doctor_render PASS " [$name] Info.plist present" + _ckipper_desktop_doctor_check_plist_parse "$name" "$plist" +} + +# Lint Info.plist via plutil when available. Skip with an INFO line otherwise. +# +# Args: $1 — instance name; $2 — plist path. +# Returns: 0 always. +_ckipper_desktop_doctor_check_plist_parse() { + local name="$1" plist="$2" + if ! command -v plutil >/dev/null 2>&1; then + _ckipper_desktop_doctor_render INFO " [$name] plutil missing, skipping plist parse" + return 0 + fi + if plutil -lint "$plist" >/dev/null 2>&1; then + _ckipper_desktop_doctor_render PASS " [$name] Info.plist parses cleanly" + else + _ckipper_desktop_doctor_render FAIL " [$name] Info.plist failed plutil -lint" + fi +} + +# Iterate every registered instance and run the per-instance check trio. +# Silent (no header) when desktop.json is absent or empty — the registry +# check already surfaced the empty state. +# +# Returns: 0 always (results printed via _ckipper_desktop_doctor_render). +_ckipper_desktop_doctor_per_instance_check() { + [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || return 0 + local count + count=$(_ckipper_desktop_doctor_instance_count) + (( count == 0 )) && return 0 + local rows + rows=$(jq -r '.instances // {} | to_entries[] | "\(.key)\t\(.value.user_data_dir)\t\(.value.app_bundle_path)"' \ + "$CKIPPER_DESKTOP_REGISTRY" 2>/dev/null) + [[ -z "$rows" ]] && return 0 + local name data_dir bundle + while IFS=$'\t' read -r name data_dir bundle; do + _ckipper_desktop_doctor_check_data_dir "$name" "$data_dir" + _ckipper_desktop_doctor_check_bundle "$name" "$bundle" + done <<< "$rows" +} + +# Print the deep-link routing reminder when 2+ instances are registered. +# Below the threshold this is silent — no PASS line, because this is a +# contextual nudge, not a pass/fail check. +# +# Returns: 0 always. +_ckipper_desktop_doctor_deep_link_warn() { + local count + count=$(_ckipper_desktop_doctor_instance_count) + (( count < _CKIPPER_DESKTOP_DOCTOR_DEEP_LINK_THRESHOLD )) && return 0 + _ckipper_desktop_doctor_render WARN \ + "2+ desktop instances registered — run 'ckipper desktop login ' before completing /login flows (claude:// deep-links route to the most-recently-active app)." +} + +# Run the desktop-instance diagnostic section. +# +# Skipped entirely (one INFO line, rc 0) on non-macOS hosts. On macOS, +# resets local counters, prints a section header, and runs the four +# sub-checks (Claude.app, registry shape, per-instance, deep-link warn). +# +# Returns: 0 on all-pass-or-warns; 1 if any sub-check incremented the +# local FAIL counter. The top-level doctor dispatcher composes +# this rc with the account doctor's rc via `|| rc=1`. +_ckipper_desktop_doctor() { + [[ "${_CKIPPER_TEST_OSTYPE:-$OSTYPE}" == darwin* ]] || { + _ckipper_desktop_doctor_render INFO "desktop: skipped (non-macOS)" + return 0 + } + _CKIPPER_DESKTOP_DOCTOR_FAIL=0 + _CKIPPER_DESKTOP_DOCTOR_WARN=0 + echo "" + _core_style_header "Desktop instances" + _ckipper_desktop_doctor_claude_app_check + _ckipper_desktop_doctor_registry_check + _ckipper_desktop_doctor_per_instance_check + _ckipper_desktop_doctor_deep_link_warn + (( _CKIPPER_DESKTOP_DOCTOR_FAIL > 0 )) && return 1 + return 0 +} diff --git a/lib/desktop/doctor_test.bats b/lib/desktop/doctor_test.bats new file mode 100644 index 0000000..25bffc8 --- /dev/null +++ b/lib/desktop/doctor_test.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats +# Tests for lib/desktop/doctor.zsh — the desktop diagnostic section. +# +# Tests drive doctor end-to-end via `run_ckipper doctor`, which exercises the +# top-level dispatcher wiring + the account/desktop doctor composition. Each +# test sets up only the env vars the asserted behavior actually depends on. + +load "${BATS_TEST_DIRNAME}/../../tests/lib/test-helper.bash" + +setup() { setup_isolated_env; } +teardown() { teardown_isolated_env; } + +@test "doctor desktop section skipped on non-macOS" { + # setup_isolated_env exports _CKIPPER_TEST_OSTYPE="linux" already. + run_ckipper doctor + [ "$status" -eq 0 ] || true # account-side checks may still WARN/FAIL — exit code agnostic + [[ "$output" =~ "desktop: skipped" ]] +} + +@test "doctor desktop passes with no instances and no Claude.app" { + export _CKIPPER_TEST_OSTYPE="darwin19.0" + # Force the system app constant at a path that doesn't exist. + export _CKIPPER_DESKTOP_SYSTEM_APP="$TMP_HOME/NoClaude.app" + run_ckipper doctor + # No instances + no Claude.app → INFO, no FAIL. + [[ "$output" =~ "0 instances" ]] || [[ "$output" =~ "skipped" ]] || [[ "$output" =~ "no instances" ]] +} + +@test "doctor desktop warns when 2+ instances exist" { + _install_fake_claude_app + run_ckipper desktop add work + run_ckipper desktop add personal + run_ckipper doctor + [[ "$output" =~ "2+ desktop instances" ]] || [[ "$output" =~ "deep-link" ]] +} + +@test "doctor desktop FAILs when /Applications/Claude.app missing AND instances exist" { + _install_fake_claude_app + run_ckipper desktop add work + # Now nuke the fake Claude.app and re-run doctor. + rm -rf "$_CKIPPER_DESKTOP_SYSTEM_APP" + run_ckipper doctor + [ "$status" -ne 0 ] + [[ "$output" =~ "Claude.app" ]] +} + +@test "doctor desktop FAILs when an instance data_dir is missing" { + _install_fake_claude_app + run_ckipper desktop add work + rm -rf "$HOME/.claude-desktop-work" + run_ckipper doctor + [ "$status" -ne 0 ] +} + +@test "doctor desktop FAILs when an instance .app bundle is missing" { + _install_fake_claude_app + run_ckipper desktop add work + rm -rf "$HOME/Applications/Claude-Work.app" + run_ckipper doctor + [ "$status" -ne 0 ] +} From be4983553f05e3486666de5970ae6c26c118db38 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:09:45 -0600 Subject: [PATCH 23/31] chore(lint): pin _ckipper_desktop_ namespace to lib/desktop/ Adds the merge-guard for the new desktop namespace and extends every existing feature-isolation guard's target list to include lib/desktop/ so sibling features (account, worktree, config) can't reach into it and desktop can't reach back. Orchestration dirs (launcher, setup, run) remain exempt per the dispatcher-exception pattern in shell-conventions.md. Updates shell-conventions.md's prefix inventory + guard list. --- .claude/rules/shell-conventions.md | 16 +++++++++------- Makefile | 15 ++++++++------- lib/desktop/instance-management.zsh | 4 ++-- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.claude/rules/shell-conventions.md b/.claude/rules/shell-conventions.md index 7bfce18..e93a3d0 100644 --- a/.claude/rules/shell-conventions.md +++ b/.claude/rules/shell-conventions.md @@ -31,6 +31,7 @@ Used to encode the dependency direction at a glance and let CI verify it: - `_ckipper_setup_*` — `lib/setup/` (first-run wizard) - `_ckipper_run_*` — `lib/run/` (top-level `ckipper run` shortcut) - `_ckipper_launcher_*` — `lib/launcher/` (bare-`ck` interactive menu) +- `_ckipper_desktop_*` — `lib/desktop/` (Claude Desktop multi-instance management) - `_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` @@ -44,7 +45,7 @@ Modules under `lib/` are sourced once by `ckipper.zsh` (the single entry script The `lib/` tree has two layers: -1. **Feature dirs** — `lib/account/`, `lib/worktree/`, `lib/config/`. Each owns a coherent slice of subcommand functionality. Feature dirs MUST NOT call into each other (account cannot call worktree, worktree cannot call config, etc.). Shared code goes in `lib/core/` per `file-organization.md`. +1. **Feature dirs** — `lib/account/`, `lib/worktree/`, `lib/config/`, `lib/desktop/`. Each owns a coherent slice of subcommand functionality. Feature dirs MUST NOT call into each other (account cannot call worktree, worktree cannot call config, desktop cannot call any other feature, etc.). Shared code goes in `lib/core/` per `file-organization.md`. 2. **Orchestration dirs** — `lib/launcher/`, `lib/setup/`, `lib/run/`. Their entire purpose is to delegate to feature dirs (the bare-`ck` menu, the first-run wizard, the `ckipper run` top-level shortcut). Orchestration dirs MAY call public, namespaced entry points from feature dirs (e.g. `_ckipper_worktree_dispatch`, `_ckipper_account_add`, `_ckipper_worktree_run`). They MUST NOT reach into another orchestration dir's internals. @@ -54,11 +55,12 @@ CI enforces the namespace separation via `make lint-merge-guards`. The grep-base - `grep -rE '\b_w_[a-z]' lib/` — empty (no leftover renames from the merge) - `grep -rE '\bW_[A-Z]' lib/` — empty (no leftover globals from the merge) -- `grep -rE '\b_ckipper_account_' lib/worktree/ lib/config/` — empty (sibling features can't call account) -- `grep -rE '\b_ckipper_worktree_' lib/account/ lib/config/` — empty (sibling features can't call worktree) -- `grep -rE '\b_ckipper_config_' lib/account/ lib/worktree/ lib/setup/ lib/run/ lib/core/` — empty (config namespace is pinned to lib/config/) -- `grep -rE '\b_ckipper_setup_' lib/account/ lib/worktree/ lib/config/ lib/run/ lib/core/` — empty (setup namespace is pinned to lib/setup/) -- `grep -rE '\b_ckipper_run_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/core/` — empty (run namespace is pinned to lib/run/) -- `grep -rE '\b_ckipper_launcher_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/run/ lib/core/` — empty (launcher namespace is pinned to lib/launcher/) +- `grep -rE '\b_ckipper_account_' lib/worktree/ lib/config/ lib/desktop/` — empty (sibling features can't call account) +- `grep -rE '\b_ckipper_worktree_' lib/account/ lib/config/ lib/desktop/` — empty (sibling features can't call worktree) +- `grep -rE '\b_ckipper_config_' lib/account/ lib/worktree/ lib/setup/ lib/run/ lib/core/ lib/desktop/` — empty (config namespace is pinned to lib/config/) +- `grep -rE '\b_ckipper_setup_' lib/account/ lib/worktree/ lib/config/ lib/run/ lib/core/ lib/desktop/` — empty (setup namespace is pinned to lib/setup/) +- `grep -rE '\b_ckipper_run_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/core/ lib/desktop/` — empty (run namespace is pinned to lib/run/) +- `grep -rE '\b_ckipper_launcher_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/run/ lib/core/ lib/desktop/` — empty (launcher namespace is pinned to lib/launcher/) +- `grep -rE '\b_ckipper_desktop_' lib/account/ lib/worktree/ lib/config/ lib/core/` — empty (sibling features + core can't call desktop; orchestration dirs may delegate) Orchestration dirs (`lib/launcher/`, `lib/setup/`, `lib/run/`) are *omitted* from the account/worktree/config guards by design — that's the dispatcher exception. Adding them would block the only legal pattern of cross-imports. diff --git a/Makefile b/Makefile index 06273b5..a5e8f04 100644 --- a/Makefile +++ b/Makefile @@ -50,13 +50,14 @@ lint-py: 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 --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/ lib/config/ 2>/dev/null || (echo "lint-merge-guards: feature dir contains account-namespace references (sibling features must not import; see shell-conventions.md)" >&2 && exit 1) - @! grep -rE '\b_ckipper_worktree_' lib/account/ lib/config/ 2>/dev/null || (echo "lint-merge-guards: feature dir contains worktree-namespace references (sibling features must not import; see shell-conventions.md)" >&2 && exit 1) - @! grep -rE '\b_ckipper_config_' lib/account/ lib/worktree/ lib/setup/ lib/run/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: config-namespace reference outside lib/config/ (siblings + lower layers cannot reach in)" >&2 && exit 1) - @! grep -rE '\b_ckipper_setup_' lib/account/ lib/worktree/ lib/config/ lib/run/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: setup-namespace reference outside lib/setup/" >&2 && exit 1) - @! grep -rE '\b_ckipper_run_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: run-namespace reference outside lib/run/" >&2 && exit 1) - @! grep -rE '\b_ckipper_launcher_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/run/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: launcher-namespace reference outside lib/launcher/" >&2 && exit 1) - @! grep -rE '^_core_[a-z_]+\(\)' lib/account/ lib/worktree/ lib/config/ lib/setup/ lib/run/ lib/launcher/ --include='*.zsh' 2>/dev/null || (echo "lint-merge-guards: _core_* function defined outside lib/core/ (see .claude/rules/shell-conventions.md — _core_* is reserved for lib/core/)" >&2 && exit 1) + @! grep -rE '\b_ckipper_account_' lib/worktree/ lib/config/ lib/desktop/ 2>/dev/null || (echo "lint-merge-guards: feature dir contains account-namespace references (sibling features must not import; see shell-conventions.md)" >&2 && exit 1) + @! grep -rE '\b_ckipper_worktree_' lib/account/ lib/config/ lib/desktop/ 2>/dev/null || (echo "lint-merge-guards: feature dir contains worktree-namespace references (sibling features must not import; see shell-conventions.md)" >&2 && exit 1) + @! grep -rE '\b_ckipper_config_' lib/account/ lib/worktree/ lib/setup/ lib/run/ lib/core/ lib/desktop/ 2>/dev/null || (echo "lint-merge-guards: config-namespace reference outside lib/config/ (siblings + lower layers cannot reach in)" >&2 && exit 1) + @! grep -rE '\b_ckipper_setup_' lib/account/ lib/worktree/ lib/config/ lib/run/ lib/core/ lib/desktop/ 2>/dev/null || (echo "lint-merge-guards: setup-namespace reference outside lib/setup/" >&2 && exit 1) + @! grep -rE '\b_ckipper_run_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/core/ lib/desktop/ 2>/dev/null || (echo "lint-merge-guards: run-namespace reference outside lib/run/" >&2 && exit 1) + @! grep -rE '\b_ckipper_launcher_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/run/ lib/core/ lib/desktop/ 2>/dev/null || (echo "lint-merge-guards: launcher-namespace reference outside lib/launcher/" >&2 && exit 1) + @! grep -rE '\b_ckipper_desktop_' lib/account/ lib/worktree/ lib/config/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: desktop-namespace reference outside lib/desktop/ (sibling features must not import; orchestration dirs may)" >&2 && exit 1) + @! grep -rE '^_core_[a-z_]+\(\)' lib/account/ lib/worktree/ lib/config/ lib/setup/ lib/run/ lib/launcher/ lib/desktop/ --include='*.zsh' 2>/dev/null || (echo "lint-merge-guards: _core_* function defined outside lib/core/ (see .claude/rules/shell-conventions.md — _core_* is reserved for lib/core/)" >&2 && exit 1) install: ./install.sh diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh index 025e5ba..117dea0 100644 --- a/lib/desktop/instance-management.zsh +++ b/lib/desktop/instance-management.zsh @@ -222,8 +222,8 @@ _ckipper_desktop_list_header() { } # Shorten an absolute path under $HOME to a `~/`-prefixed form for display. -# Mirrors lib/account/account-management.zsh::_ckipper_account_list_short_dir; -# extracted again here because the account namespace is off-limits to siblings. +# Mirrors the equivalent helper in lib/account/account-management.zsh +# (extracted again here because the account namespace is off-limits to siblings). # # Args: $1 — absolute path. # Returns: 0 always; prints the (possibly shortened) path. From 77d44c56390561e60be9977b4f198bbf7c8d647a Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:10:30 -0600 Subject: [PATCH 24/31] feat(completion): add desktop subcommand + instance-name completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps CKIPPER_COMPLETION_VERSION 8 → 9 so installed shells regenerate. Adds 'desktop' / 'dt' to the top-level command list and a new desktop_subs array. Instance-name arg3 completion reads keys from ~/.ckipper/desktop.json. --- ckipper.zsh | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/ckipper.zsh b/ckipper.zsh index 8859906..fbba9e8 100644 --- a/ckipper.zsh +++ b/ckipper.zsh @@ -239,7 +239,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=8 +CKIPPER_COMPLETION_VERSION=9 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 @@ -249,12 +249,12 @@ if [[ ! -f ~/.zsh/completions/_ckipper ]] \ # a completion file, not maintained shell logic). cat > ~/.zsh/completions/_ckipper << 'COMPEOF' #compdef ckipper ck -# ckipper-completion-version=8 +# ckipper-completion-version=9 _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 config_subs + local -a top_commands account_subs worktree_subs config_subs desktop_subs top_commands=( 'account:Manage Claude accounts' @@ -263,6 +263,8 @@ _ckipper() { 'wt:Short alias for worktree' 'run:Shortcut for worktree run' 'config:View and modify Ckipper settings' + 'desktop:Manage Claude Desktop instances' + 'dt:Short alias for desktop' 'setup:Run / re-run the setup wizard' 'doctor:Diagnostic check of accounts and tooling' 'help:Show top-level help' @@ -292,6 +294,15 @@ _ckipper() { 'edit:Open the config file in $EDITOR' 'help:Show config-namespace help' ) + desktop_subs=( + 'add:Register a new Desktop instance' + 'list:Show registered instances' + 'remove:Unregister a Desktop instance' + 'rename:Rename a Desktop instance in place' + 'login:Quit all Claude.app, launch only this one' + 'launch:Open a registered instance' + 'help:Show desktop-namespace help' + ) _arguments -C \ '1: :->cmd' \ @@ -316,6 +327,9 @@ _ckipper() { config) _describe -t subcommands 'config subcommand' config_subs && return 0 ;; + desktop|dt) + _describe -t subcommands 'desktop subcommand' desktop_subs && return 0 + ;; run) local -a projects local dir repo_dir rel @@ -352,6 +366,14 @@ _ckipper() { config_keys=( "${(@k)_CKIPPER_SCHEMA_TYPE}" ) _describe -t keys 'config key' config_keys && return 0 ;; + desktop/remove|dt/remove|desktop/rename|dt/rename|desktop/login|dt/login|desktop/launch|dt/launch) + local -a desktop_instances + local desktop_registry="${CKIPPER_DESKTOP_REGISTRY:-$HOME/.ckipper/desktop.json}" + if [[ -f "$desktop_registry" ]]; then + desktop_instances=( $(jq -r '.instances | keys[]' "$desktop_registry" 2>/dev/null) ) + fi + _describe -t instances 'desktop instance name' desktop_instances && return 0 + ;; esac case "${words[2]}" in run) From 32c71b3e32db72b322e8dea57fa150d278f51945 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:11:37 -0600 Subject: [PATCH 25/31] feat(launcher): surface Desktop entries in the bare-ck menu Adds 'Launch a Desktop instance', 'List Desktop instances', and 'Add a Desktop instance' to the launcher menu. 'Launch' uses a new helper that prompts the user to pick from registered instances; the other two delegate directly to _ckipper_desktop_dispatch. Updates menu test fixture for the new option count. --- lib/launcher/menu.zsh | 49 +++++++++++++++++++++++++++++-------- lib/launcher/menu_test.bats | 4 +-- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/lib/launcher/menu.zsh b/lib/launcher/menu.zsh index 4b776f3..49f7faa 100644 --- a/lib/launcher/menu.zsh +++ b/lib/launcher/menu.zsh @@ -14,17 +14,21 @@ # - lib/worktree/dispatcher.zsh (`_ckipper_worktree_dispatch`) # - lib/account/dispatcher.zsh (`_ckipper_account_dispatch`) # - lib/config/dispatcher.zsh (`_ckipper_config_dispatch`) +# - lib/desktop/dispatcher.zsh (`_ckipper_desktop_dispatch`) # - lib/setup/dispatcher.zsh (`_ckipper_setup`) # - lib/account/doctor.zsh (`_ckipper_doctor`) # Menu options shown by `_ckipper_launcher_menu`. The order is load-bearing: # `_ckipper_launcher_route` matches on the human-readable label, and tests -# rely on "Quit" being the 8th (and last) entry. +# rely on "Quit" being the 11th (and last) entry. typeset -gra _CKIPPER_LAUNCHER_OPTIONS=( "Run Claude on a worktree" "List worktrees" "List accounts" "Add an account" + "Launch a Desktop instance" + "List Desktop instances" + "Add a Desktop instance" "Run setup wizard" "Edit config" "Run doctor" @@ -74,15 +78,18 @@ _ckipper_launcher_menu() { _ckipper_launcher_route() { local choice="$1" case "$choice" in - "Run Claude on a worktree") _ckipper_launcher_route_run ;; - "List worktrees") _ckipper_worktree_dispatch list ;; - "List accounts") _ckipper_account_dispatch list ;; - "Add an account") _ckipper_account_dispatch add ;; - "Run setup wizard") _ckipper_setup ;; - "Edit config") _ckipper_config_dispatch edit ;; - "Run doctor") _ckipper_doctor ;; - "Quit") return 0 ;; - *) return 1 ;; + "Run Claude on a worktree") _ckipper_launcher_route_run ;; + "List worktrees") _ckipper_worktree_dispatch list ;; + "List accounts") _ckipper_account_dispatch list ;; + "Add an account") _ckipper_account_dispatch add ;; + "Launch a Desktop instance") _ckipper_launcher_route_desktop_launch ;; + "List Desktop instances") _ckipper_desktop_dispatch list ;; + "Add a Desktop instance") _ckipper_desktop_dispatch add ;; + "Run setup wizard") _ckipper_setup ;; + "Edit config") _ckipper_config_dispatch edit ;; + "Run doctor") _ckipper_doctor ;; + "Quit") return 0 ;; + *) return 1 ;; esac } @@ -124,3 +131,25 @@ _ckipper_launcher_route_run() { [[ -z "$branch" ]] && return 1 _ckipper_run "$project" "$branch" } + +# Pick a registered Desktop instance and dispatch `desktop launch`. +# When no instances exist, abort with a hint rather than an empty prompt. +# +# Returns: 0 on success; 1 if no instances are registered or the user +# cancels the choose prompt. +_ckipper_launcher_route_desktop_launch() { + local registry="${CKIPPER_DESKTOP_REGISTRY:-$HOME/.ckipper/desktop.json}" + if [[ ! -f "$registry" ]]; then + echo "No Desktop instances registered. Run: ckipper desktop add " >&2 + return 1 + fi + local -a instances + instances=( ${(f)"$(jq -r '.instances | keys[]' "$registry" 2>/dev/null)"} ) + if (( ${#instances} == 0 )); then + echo "No Desktop instances registered. Run: ckipper desktop add " >&2 + return 1 + fi + local name; name=$(_core_prompt_choose "Pick a Desktop instance" "${instances[@]}") + [[ -z "$name" ]] && return 1 + _ckipper_desktop_dispatch launch "$name" +} diff --git a/lib/launcher/menu_test.bats b/lib/launcher/menu_test.bats index 4b55fd4..3537d20 100644 --- a/lib/launcher/menu_test.bats +++ b/lib/launcher/menu_test.bats @@ -54,8 +54,8 @@ _run_launcher() { } @test "_ckipper_launcher_menu Quit selection returns 0" { - # "Quit" is the 8th option in _CKIPPER_LAUNCHER_OPTIONS. - _run_launcher "8" "_ckipper_launcher_menu" + # "Quit" is the 11th option in _CKIPPER_LAUNCHER_OPTIONS. + _run_launcher "11" "_ckipper_launcher_menu" [ "$status" -eq 0 ] } From b567d81f509839ba0193f8d63226ea20af53d699 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:12:07 -0600 Subject: [PATCH 26/31] feat(setup): mention 'ckipper desktop add' in completion summary Single line added to the 'Getting started:' section of both the gum and plain post-setup summary cards. Discovery-only; the wizard does not walk through Desktop setup (CLI and Desktop are configured independently per the design). --- lib/setup/dispatcher.zsh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/setup/dispatcher.zsh b/lib/setup/dispatcher.zsh index ce91d87..5fec79e 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -121,6 +121,7 @@ _ckipper_setup_completion_inner() { echo " ckipper run Bundle worktree + Claude" echo " ck Interactive menu" echo " claude- Per-account launcher (e.g. claude-personal)" + echo " ckipper desktop add Register a Claude Desktop instance" echo gum style --bold "Maintenance:" echo " ckipper config list Review every setting" @@ -142,6 +143,7 @@ _ckipper_setup_render_completion_plain() { echo " ckipper run Bundle worktree + Claude in one step" echo " ck Interactive menu" echo " claude- Per-account launcher (e.g. claude-personal)" + echo " ckipper desktop add Register a Claude Desktop instance" echo "" echo "Maintenance:" echo " ckipper config list Review every setting" From 1c2dfcad764231c40adf1a929fed32263f1ac863 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:18:10 -0600 Subject: [PATCH 27/31] docs(desktop): document multi-instance support and deep-link gotcha New 'Claude Desktop instances' section in README between 'Multiple accounts' and 'Sync state between accounts'. Covers add/list/launch/ rename/remove, the claude:// deep-link gotcha + login workaround, on- disk layout, and doctor coverage. Reinforces that CLI accounts and Desktop instances are independently configured. CHANGELOG entry under the Unreleased section listing the new namespace, the login dance, doctor integration, the desktop.json registry, the registry.zsh refactor that supports it, and the completion version bump. --- CHANGELOG.md | 10 ++++++++++ README.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55debc..94fb6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] — CLI + onboarding overhaul +### Claude Desktop multi-instance support + +- **New:** `ckipper desktop` namespace (alias `dt`) for managing isolated Claude Desktop (Electron app) instances on macOS, alongside the existing CLI multi-account support. Each instance gets its own user-data dir (`~/.claude-desktop-/`) and a generated `.app` wrapper bundle (`~/Applications/Claude-.app`) that shows up in Spotlight and the Dock. +- **New:** Subcommands `add`, `list`, `remove`, `rename`, `launch`, `login`. +- **New:** `ckipper desktop login ` quits every running Claude.app process via `SIGTERM` (with `SIGKILL` fallback after 5 s), then launches only the target — working around the `claude://` deep-link auth-callback routing gotcha where macOS sends OAuth callbacks to whichever Claude app was most recently active. +- **New:** `ckipper doctor` now includes Desktop checks (registry shape, per-instance data dir + `.app` bundle existence, `/Applications/Claude.app` presence, deep-link warning when 2+ instances exist). +- **New:** Registry at `~/.ckipper/desktop.json` (schema v1) — separate file from `accounts.json` with its own lock and atomic-write machinery. +- **Changed:** `lib/core/registry.zsh` primitives parametrized on file path (`_core_registry_update_at`, `_init_at`, `_check_version_at`) so the new desktop registry reuses the same locking + atomic-write code as accounts.json. +- **Changed:** Tab completion bumped to version 9 — added `desktop` / `dt` completion and per-instance-name completion read from `desktop.json`. + ### Sync system overhaul - **New:** `ckipper account sync` is fully interactive by default. Run with no args to pick source, targets, and types via gum pickers; pass positional args to skip the relevant pickers. diff --git a/README.md b/README.md index b5e9cc6..cbc31bb 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,45 @@ Two terminals running the **same** account simultaneously will hit a known OAuth 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. +## Claude Desktop instances + +Ckipper also manages multiple isolated Claude **Desktop** (Electron app) instances on macOS via the `--user-data-dir` flag. Each instance is a fully isolated sandbox — separate auth, MCP servers, projects, conversation history, Cowork VM — and shows up in Spotlight and the Dock as `Claude-.app`. + +CLI accounts (`ckipper account *`) and Desktop instances (`ckipper desktop *`) are independent and configured separately. An account named `work` and a Desktop instance named `work` share nothing but the name. + +### Add an instance + +```bash +ckipper desktop add work +``` + +Creates `~/.claude-desktop-work/` (user-data dir) and `~/Applications/Claude-Work.app` (wrapper bundle whose launcher exec's `open -n -a /Applications/Claude.app --args --user-data-dir=…`). Requires `/Applications/Claude.app` to be installed. + +### Use an instance + +```bash +ckipper desktop launch work # open the instance (also works from Spotlight / Dock) +ckipper desktop list # see registered instances + running status +ckipper desktop rename work prod # rename in place +ckipper desktop remove work # interactively prompt to delete user-data dir + bundle +``` + +> **Note: claude:// deep-link auth gotcha.** macOS routes `claude://` URLs (the OAuth callback used by `/login`) to whichever Claude app was most recently active. With two or more Desktop instances running, the callback can land in the wrong window. `ckipper desktop login ` works around this by quitting *every* running Claude process and launching only the target — complete `/login` there, then re-open the others as needed. This is a one-time-per-instance setup cost; once authenticated, instances run side by side indefinitely. + +```bash +ckipper desktop login work # quit all, launch only 'work' — safe for /login flows +``` + +### How instances are stored + +- Per-instance data lives in `~/.claude-desktop-/` (Electron `userData` dir). +- Generated `.app` wrappers live in `~/Applications/Claude-.app` (per-user, no admin required). The launcher script bakes `--user-data-dir` in at generation time — no runtime path-walking. +- The registry mapping instance names to dirs and bundles lives at `~/.ckipper/desktop.json` (separate file from `accounts.json`, separate schema version). + +### Diagnostics + +`ckipper doctor` runs Desktop checks alongside the account checks: `/Applications/Claude.app` is installed (if any instances are registered), `desktop.json` is well-formed, each instance's data dir + `.app` bundle exist and parse, and a warning fires when two or more instances are registered (the deep-link reminder). + ## Sync state between accounts `ckipper account sync` copies state between registered accounts — MCP servers, settings, agents, commands, skills, user hooks, etc. — interactively by default, with one source and one or more destinations. From d5a29fce918c603ffff7cbf4d4f087668865337d Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:39:40 -0600 Subject: [PATCH 28/31] refactor(desktop): /simplify follow-up cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied review findings (in-feature dedup + small leaks). Deferred cross-feature extractions to lib/core/ (doctor-render, path-tildify, name-validator) out of scope for this PR. - lib/core/registry.zsh: parametrized _at form's auto-migration message uses ${registry_file:t} instead of the hard-coded 'accounts.json' string (would have lied for future non-accounts migrations). Doc headers updated to match. - lib/desktop/doctor.zsh: deleted _ckipper_desktop_doctor_instance_count (byte-identical duplicate of _ckipper_desktop_instance_count in instance-management.zsh — same feature dir, no isolation rule). Removed duplicate _CKIPPER_DESKTOP_DOCTOR_DEEP_LINK_THRESHOLD and consume the existing _CKIPPER_DESKTOP_DEEP_LINK_TIP_THRESHOLD from instance-management.zsh. - lib/desktop/launcher.zsh: _ckipper_desktop_lookup_bundle collapsed from 2 jq invocations to 1 via jq's 'error()' on missing-key — on the launch / login hot path. - lib/desktop/bundle.zsh: extracted _CKIPPER_DESKTOP_BUNDLE_VERSION constant; CFBundleShortVersionString + CFBundleVersion both reference it (last magic string in the file). --- lib/core/registry.zsh | 6 +++--- lib/desktop/bundle.zsh | 9 +++++++-- lib/desktop/doctor.zsh | 25 ++++--------------------- lib/desktop/launcher.zsh | 21 ++++++++++----------- 4 files changed, 24 insertions(+), 37 deletions(-) diff --git a/lib/core/registry.zsh b/lib/core/registry.zsh index b6454dc..7d0f836 100644 --- a/lib/core/registry.zsh +++ b/lib/core/registry.zsh @@ -306,7 +306,7 @@ _core_registry_migrate_v1_to_v2_at() { # or corrupt schema. # # Errors (stderr): -# "Migrating accounts.json v1 → v2..." — informational notice during auto-migration. +# "Migrating v1 → v2..." — informational notice during auto-migration. # "Error: registry version..." — on version mismatch. # "Error: ... is corrupt..." — on bad schema. _core_registry_check_version() { @@ -328,7 +328,7 @@ _core_registry_check_version() { # 0 if registry is absent or valid; 1 on version mismatch or migration failure. # # Errors (stderr): -# "Migrating accounts.json v1 → v2..." — informational notice during auto-migration. +# "Migrating v1 → v2..." — informational notice during auto-migration. # "Error: registry version..." — on version mismatch. _core_registry_check_version_at() { local registry_file="$1" @@ -336,7 +336,7 @@ _core_registry_check_version_at() { local cur cur=$(jq -r '.version // 0' "$registry_file" 2>/dev/null) if [[ "$cur" == "1" ]] && (( CKIPPER_REGISTRY_VERSION >= 2 )); then - echo "Migrating accounts.json v1 → v2..." >&2 + echo "Migrating ${registry_file:t} v1 → v2..." >&2 _core_registry_migrate_v1_to_v2_at "$registry_file" || return 1 fi local v diff --git a/lib/desktop/bundle.zsh b/lib/desktop/bundle.zsh index c143efa..2070ab1 100644 --- a/lib/desktop/bundle.zsh +++ b/lib/desktop/bundle.zsh @@ -24,6 +24,11 @@ _CKIPPER_DESKTOP_SYSTEM_APP=${_CKIPPER_DESKTOP_SYSTEM_APP:-/Applications/Claude. # appended (e.g. work → dev.ckipper.claude.desktop.work). _CKIPPER_DESKTOP_BUNDLE_ID_PREFIX=dev.ckipper.claude.desktop +# Version stamped into the generated Info.plist (CFBundleVersion + +# CFBundleShortVersionString). Bump only when the bundle layout changes in a +# way users would notice — e.g. a new key set or a launcher rewrite. +_CKIPPER_DESKTOP_BUNDLE_VERSION="1.0" + # Mode bits for the generated launcher script (rwxr-xr-x). _CKIPPER_DESKTOP_LAUNCHER_MODE=755 @@ -141,9 +146,9 @@ _ckipper_desktop_bundle_plist_body() { CFBundleName ${display} CFBundleShortVersionString - 1.0 + ${_CKIPPER_DESKTOP_BUNDLE_VERSION} CFBundleVersion - 1.0 + ${_CKIPPER_DESKTOP_BUNDLE_VERSION} CFBundlePackageType APPL NSHighResolutionCapable diff --git a/lib/desktop/doctor.zsh b/lib/desktop/doctor.zsh index 6ce0a89..a112833 100644 --- a/lib/desktop/doctor.zsh +++ b/lib/desktop/doctor.zsh @@ -10,11 +10,6 @@ # _core_style_badge and _core_style_header). It does NOT reach into the # account-namespace doctor helpers; counters are tracked locally. -# Minimum instance count that triggers the deep-link routing reminder. Two -# or more registered Desktop instances means `claude://` OAuth callbacks may -# land in the wrong window — `ckipper desktop login` mitigates it. -readonly _CKIPPER_DESKTOP_DOCTOR_DEEP_LINK_THRESHOLD=2 - # Module-level counters consumed by the orchestrator's exit-code decision. # Kept local to the desktop namespace — no shared state with lib/account. typeset -g _CKIPPER_DESKTOP_DOCTOR_FAIL=0 @@ -39,18 +34,6 @@ _ckipper_desktop_doctor_render() { printf ' %s %s\n' "$badge" "$msg" } -# Count registered desktop instances without depending on instance-management. -# Reads the registry directly via jq so feature-dir isolation holds (we don't -# call _ckipper_desktop_instance_count, even though it would behave the same — -# isolating the dependency surface keeps the doctor self-contained). -# -# Returns: 0 always. Prints the instance count on stdout (0 if registry absent -# or unreadable). -_ckipper_desktop_doctor_instance_count() { - [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || { echo 0; return 0; } - jq -r '.instances // {} | length' "$CKIPPER_DESKTOP_REGISTRY" 2>/dev/null || echo 0 -} - # Check that the system Claude.app exists at $_CKIPPER_DESKTOP_SYSTEM_APP. # # On a CLI-only host with no registered instances the missing .app is @@ -65,7 +48,7 @@ _ckipper_desktop_doctor_claude_app_check() { return 0 fi local count - count=$(_ckipper_desktop_doctor_instance_count) + count=$(_ckipper_desktop_instance_count) if (( count >= 1 )); then _ckipper_desktop_doctor_render FAIL \ "Claude.app missing at $_CKIPPER_DESKTOP_SYSTEM_APP — $count instance(s) registered but wrapper launchers cannot open it." @@ -156,7 +139,7 @@ _ckipper_desktop_doctor_check_plist_parse() { _ckipper_desktop_doctor_per_instance_check() { [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || return 0 local count - count=$(_ckipper_desktop_doctor_instance_count) + count=$(_ckipper_desktop_instance_count) (( count == 0 )) && return 0 local rows rows=$(jq -r '.instances // {} | to_entries[] | "\(.key)\t\(.value.user_data_dir)\t\(.value.app_bundle_path)"' \ @@ -176,8 +159,8 @@ _ckipper_desktop_doctor_per_instance_check() { # Returns: 0 always. _ckipper_desktop_doctor_deep_link_warn() { local count - count=$(_ckipper_desktop_doctor_instance_count) - (( count < _CKIPPER_DESKTOP_DOCTOR_DEEP_LINK_THRESHOLD )) && return 0 + count=$(_ckipper_desktop_instance_count) + (( count < _CKIPPER_DESKTOP_DEEP_LINK_TIP_THRESHOLD )) && return 0 _ckipper_desktop_doctor_render WARN \ "2+ desktop instances registered — run 'ckipper desktop login ' before completing /login flows (claude:// deep-links route to the most-recently-active app)." } diff --git a/lib/desktop/launcher.zsh b/lib/desktop/launcher.zsh index 867250d..6b41989 100644 --- a/lib/desktop/launcher.zsh +++ b/lib/desktop/launcher.zsh @@ -49,25 +49,24 @@ _ckipper_desktop_assert_not_running() { } # Look up an instance's bundle path. Fails if the instance is not registered. -# Mirrors the registry-existence check pattern at -# instance-management.zsh::_ckipper_desktop_data_dir_of — kept local to the -# launcher namespace because feature dirs MUST NOT call into each other -# beyond public, namespaced entry points; instance-management.zsh's -# _ckipper_desktop_data_dir_of returns a different field (data_dir, not -# bundle) so we don't reuse it. # # Args: $1 — instance name. # Returns: 0 with bundle path on stdout; 1 with error on stderr. # Errors (stderr): "Desktop instance '' is not registered." _ckipper_desktop_lookup_bundle() { local name="$1" - if [[ ! -f "$CKIPPER_DESKTOP_REGISTRY" ]] \ - || ! jq -e --arg n "$name" '.instances[$n]' \ - "$CKIPPER_DESKTOP_REGISTRY" >/dev/null 2>&1; then + [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || { echo "Desktop instance '$name' is not registered." >&2 return 1 - fi - jq -r --arg n "$name" '.instances[$n].app_bundle_path' "$CKIPPER_DESKTOP_REGISTRY" + } + local bundle + bundle=$(jq -er --arg n "$name" \ + '.instances[$n].app_bundle_path // error("Desktop instance \($n) is not registered.")' \ + "$CKIPPER_DESKTOP_REGISTRY" 2>&1) || { + echo "Desktop instance '$name' is not registered." >&2 + return 1 + } + printf '%s\n' "$bundle" } # Poll until every PID in $1 (newline-separated) has exited, escalating to From 85509f3f0991ab9a996545c599920fae9dbb9548 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 17:54:23 -0600 Subject: [PATCH 29/31] fix(desktop): quote generated launcher paths against runtime re-expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .app bundle's Contents/MacOS/launcher used double-quoted strings for --user-data-dir and the system app path, so any $VAR / backtick / "$(…)" in the baked-in paths would be re-expanded at runtime when the wrapper bundle launches. Currently unreachable — instance names are regex-validated to ^[a-z0-9_-]+$ and the path is $HOME/.claude-desktop-, so neither $ nor backticks can appear. Defense-in-depth fix per PR #45 code review: switch to single-quoted output and escape any embedded single quotes via the standard '\\''-replacement idiom. Test updated to assert the single-quote form. No behavior change in any supported scenario. --- lib/desktop/bundle.zsh | 9 ++++++++- lib/desktop/bundle_test.bats | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/desktop/bundle.zsh b/lib/desktop/bundle.zsh index 2070ab1..7b4d91e 100644 --- a/lib/desktop/bundle.zsh +++ b/lib/desktop/bundle.zsh @@ -91,11 +91,18 @@ _ckipper_desktop_bundle_title_case() { _ckipper_desktop_bundle_write_launcher() { local bundle="$1" data_dir="$2" local launcher="$bundle/Contents/MacOS/launcher" + # Emit the paths inside single quotes so the generated script does NOT + # re-expand $VAR / `cmd` / "$(…)" at runtime. Names are regex-validated + # to ^[a-z0-9_-]+$ so $data_dir cannot contain a single quote today, but + # this escape protects against any future widening of that regex (and + # against odd $HOME values picked up by the prefix). + local app_q="${_CKIPPER_DESKTOP_SYSTEM_APP//\'/\'\\\'\'}" + local dir_q="${data_dir//\'/\'\\\'\'}" cat > "$launcher" < Date: Thu, 28 May 2026 14:09:47 -0600 Subject: [PATCH 30/31] docs(readme): surface Desktop multi-instance in intro + Solution + commands The previous Desktop section only existed deep in the README, so the top-of-page narrative (tagline, Inspired-by attribution, Problem, Solution, core commands table) gave no signal that Desktop management was a thing. Five surgical edits: - Tagline: add 'Claude Desktop multi-instance setups' to the feature list - Inspired-by: credit Philipp Stracker's gist, which inspired the .app wrapper + --user-data-dir approach - The Problem: second paragraph naming the single-instance-by-default pain point that motivated ckipper desktop - The Solution: second example block (ck desktop add work) parallel to ck run, showing the wrapper-bundle outcome - Core commands table: new row for ck desktop with the dt alias and anchor to the deeper section --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cbc31bb..856a154 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,16 @@ > **Platform:** macOS only — uses macOS Keychain, Docker Desktop, and host SSH agent forwarding. -A lightweight CLI for managing Claude Code accounts, worktrees, and Docker sandboxes. +A lightweight CLI for managing Claude Code accounts, git worktrees, Docker sandboxes, and Claude Desktop multi-instance setups. -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. +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 Claude Desktop multi-instance approach (generated `.app` wrappers + Electron `--user-data-dir`) was inspired by [Philipp Stracker's gist](https://gist.github.com/stracker-phil/9f84927a556632c7f9cc06663b534f14). ## 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. Running it inside a container is the whole point. +Separately: Claude Desktop is single-instance by default — sign in with one account, lose the other. Running personal and work side-by-side needs an isolated user-data dir per instance and a Spotlight/Dock entry that actually opens the right one. + ## The Solution ```bash @@ -20,6 +22,12 @@ ck run myorg/myapp my-feature Creates a git worktree, optionally 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. +```bash +ck desktop add work +``` + +Generates `~/Applications/Claude-Work.app` — a wrapper bundle that launches the system Claude Desktop against an isolated user-data dir. Spotlight, Dock, and Cmd-Tab treat it like any other app. Run it alongside your existing Claude Desktop with separate auth, MCP servers, and conversation history. + ## Quick start ```bash @@ -46,6 +54,7 @@ cd 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/sync/redeploy-hooks` | Manage Claude accounts (see [Sync state between accounts](#sync-state-between-accounts)) | +| `ck desktop add/list/remove/rename/launch/login` | Manage Claude Desktop instances (alias `dt`; see [Claude Desktop instances](#claude-desktop-instances)) | | `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 | From b1672b092d8463d345f4e6803d762377abf1ac8d Mon Sep 17 00:00:00 2001 From: Matt White Date: Thu, 28 May 2026 14:17:30 -0600 Subject: [PATCH 31/31] docs(readme): restructure for Desktop as peer feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates findings from a 3-agent README review (structure + consistency + onboarding). Tier-1 changes that all three agreed on, applied as one cohesive edit. Top-of-page additions: - One-line 'who is this for' between tagline and credits - Quick Start gains a first-commands-by-feature mini-list (ck run, ckipper account add, ck desktop add) so new readers see Desktop is a peer feature before scrolling 100 lines - Prerequisites adds /Applications/Claude.app, qualified 'only if you'll use ck desktop instances' (currently a runtime trap for Desktop-curious readers) Desktop section restructure: - Moved to after 'Sync state between accounts' so the accounts+sync pair stays contiguous (structure-agent finding) - Opens with a value-first sentence ('Run a personal Claude Desktop in one window and a work Claude Desktop in another...') instead of leading with the --user-data-dir mechanism - New 'Accounts vs Desktop instances' comparison table — explicit about what does NOT cross over (no shared auth, no shared MCP, sync only on the CLI side, no Desktop default) - Deep-link gotcha promoted from buried blockquote to its own H3 '### Don't run /login with two instances open' (mirrors Multiple Accounts' '### Don't run the same account in two sessions' shape) - 'Use an instance' split into 'Use' + 'List, rename, remove' to mirror the Multiple Accounts shape - 'Diagnostics' H3 renamed to 'Diagnose' for parallelism Trivial polish: - Sync section code-fence language tags: sh -> bash (matches every other fence in the file) Deferred to a follow-up PR (touches more sections, higher risk): - Diagnostics consolidation (3 doctor mentions in different parents) - Multi-account caveats -> Upstream caveats + Desktop fold-in - OAuth-race deep-dive de-duplication --- README.md | 113 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 72 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 856a154..d434a78 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A lightweight CLI for managing Claude Code accounts, git worktrees, Docker sandboxes, and Claude Desktop multi-instance setups. +For developers who want clean separation between personal and work Claude — in the terminal, in the Desktop app, or both — with the same registry, the same `doctor`, and the same `ck` short alias driving everything. + 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 Claude Desktop multi-instance approach (generated `.app` wrappers + Electron `--user-data-dir`) was inspired by [Philipp Stracker's gist](https://gist.github.com/stracker-phil/9f84927a556632c7f9cc06663b534f14). ## The Problem @@ -38,11 +40,20 @@ cd Ckipper `install.sh` deploys files under `~/.ckipper/`, adds the source line to your `.zshrc`, and ends by running the interactive setup wizard. The wizard registers your first Claude account, sets your projects directory, and configures default behaviors. Re-runnable any time via `ckipper setup`. +Your first commands by feature: + +```bash +ck run myorg/app feature/foo # sandboxed worktree + Claude Code (CLI) +ckipper account add work # multi-account CLI: register a second Claude Code account +ck desktop add work # multi-instance Desktop: register a wrapper .app bundle +``` + ### Prerequisites - **macOS** with zsh - **Docker Desktop** installed and running - **Claude Code** installed and authenticated (`claude` command works) +- **Claude Desktop** (`/Applications/Claude.app`) — *only if you'll use `ck desktop` instances* - **GitHub auth**: SSH keys added to your SSH agent, or `gh auth login` on host - **jq** and **gum** installed (`brew install jq gum`) @@ -149,45 +160,6 @@ Two terminals running the **same** account simultaneously will hit a known OAuth 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. -## Claude Desktop instances - -Ckipper also manages multiple isolated Claude **Desktop** (Electron app) instances on macOS via the `--user-data-dir` flag. Each instance is a fully isolated sandbox — separate auth, MCP servers, projects, conversation history, Cowork VM — and shows up in Spotlight and the Dock as `Claude-.app`. - -CLI accounts (`ckipper account *`) and Desktop instances (`ckipper desktop *`) are independent and configured separately. An account named `work` and a Desktop instance named `work` share nothing but the name. - -### Add an instance - -```bash -ckipper desktop add work -``` - -Creates `~/.claude-desktop-work/` (user-data dir) and `~/Applications/Claude-Work.app` (wrapper bundle whose launcher exec's `open -n -a /Applications/Claude.app --args --user-data-dir=…`). Requires `/Applications/Claude.app` to be installed. - -### Use an instance - -```bash -ckipper desktop launch work # open the instance (also works from Spotlight / Dock) -ckipper desktop list # see registered instances + running status -ckipper desktop rename work prod # rename in place -ckipper desktop remove work # interactively prompt to delete user-data dir + bundle -``` - -> **Note: claude:// deep-link auth gotcha.** macOS routes `claude://` URLs (the OAuth callback used by `/login`) to whichever Claude app was most recently active. With two or more Desktop instances running, the callback can land in the wrong window. `ckipper desktop login ` works around this by quitting *every* running Claude process and launching only the target — complete `/login` there, then re-open the others as needed. This is a one-time-per-instance setup cost; once authenticated, instances run side by side indefinitely. - -```bash -ckipper desktop login work # quit all, launch only 'work' — safe for /login flows -``` - -### How instances are stored - -- Per-instance data lives in `~/.claude-desktop-/` (Electron `userData` dir). -- Generated `.app` wrappers live in `~/Applications/Claude-.app` (per-user, no admin required). The launcher script bakes `--user-data-dir` in at generation time — no runtime path-walking. -- The registry mapping instance names to dirs and bundles lives at `~/.ckipper/desktop.json` (separate file from `accounts.json`, separate schema version). - -### Diagnostics - -`ckipper doctor` runs Desktop checks alongside the account checks: `/Applications/Claude.app` is installed (if any instances are registered), `desktop.json` is well-formed, each instance's data dir + `.app` bundle exist and parse, and a warning fires when two or more instances are registered (the deep-link reminder). - ## Sync state between accounts `ckipper account sync` copies state between registered accounts — MCP servers, settings, agents, commands, skills, user hooks, etc. — interactively by default, with one source and one or more destinations. @@ -211,7 +183,7 @@ Plugins are not a separate type — sync `enabledPlugins` + `extraKnownMarketpla ### Common commands -```sh +```bash # Full interactive wizard — picks source, targets, and types ckipper account sync @@ -244,7 +216,7 @@ ckipper account sync personal work --include all --yes Every destructive write is preceded by a copy to `/.ckipper-sync-backups/-from-/`. The summary table prints the backup directory path before applying. To restore: -```sh +```bash ckipper account sync undo work # restore most recent backup ckipper account sync undo work --pick # gum-pick from backup ledger ckipper account sync undo work --list # print backup directory paths @@ -259,6 +231,65 @@ These two commands sound similar but do different things: - **`ckipper account sync ... --include hooks`** — peer-to-peer copy of *user-written* hooks (any hook file in `/hooks/` whose filename does NOT match a ckipper-managed install hook). Includes the paired `settings.json` `.hooks` entry. - **`ckipper account redeploy-hooks`** — pushes the ckipper safety hooks (`bash-guardrails`, `protect-claude-config`, `docker-context`, `notify-bell`) from `~/.ckipper/hooks/` to every registered account. Run after editing a script in the install dir. +## Claude Desktop instances + +Run a personal Claude Desktop in one window and a work Claude Desktop in another, fully isolated. Each shows up in Spotlight and the Dock as its own `Claude-.app`, signs in with its own account, and keeps its own MCP servers, projects, and conversation history. Under the hood: a generated `.app` wrapper that launches the system `Claude.app` with Electron's `--user-data-dir` pointed at an isolated sandbox. + +### Accounts vs. Desktop instances + +Two independent isolation models, configured separately: + +| | CLI accounts (`ckipper account`) | Desktop instances (`ckipper desktop`) | +| --- | --- | --- | +| Scope | The `claude` CLI in a terminal | The `Claude.app` Electron app | +| Data dir | `~/.claude-/` | `~/.claude-desktop-/` | +| Registry | `~/.ckipper/accounts.json` | `~/.ckipper/desktop.json` | +| Auth | macOS Keychain (per account) | Electron-managed (per user-data dir) | +| Sync command | `ckipper account sync` | _not available — each instance signs in independently_ | +| Default selector | `ckipper account default ` | _not available — pick from Spotlight/Dock_ | + +An account named `work` and a Desktop instance named `work` share nothing but the name. `ckipper account sync` does not touch Desktop instances; `ckipper desktop` does not touch CLI accounts. + +### Add an instance + +```bash +ckipper desktop add work +``` + +Creates `~/.claude-desktop-work/` (user-data dir) and `~/Applications/Claude-Work.app` (wrapper bundle whose launcher exec's `open -n -a /Applications/Claude.app --args --user-data-dir=…`). Requires `/Applications/Claude.app` to be installed. + +### Use an instance + +```bash +ckipper desktop launch work # open the instance (also works from Spotlight / Dock) +``` + +### List, rename, remove + +```bash +ckipper desktop list # see registered instances + running status +ckipper desktop rename work prod # rename in place +ckipper desktop remove work # interactively prompt to delete user-data dir + bundle +``` + +### Don't run `/login` with two instances open + +macOS routes `claude://` URLs (the OAuth callback used by `/login`) to whichever Claude app was most recently active. With two or more Desktop instances running, the callback can land in the wrong window. `ckipper desktop login ` works around this by quitting *every* running Claude process and launching only the target — complete `/login` there, then re-open the others as needed. One-time cost per instance; once authenticated, instances run side by side indefinitely. + +```bash +ckipper desktop login work # quit all, launch only 'work' — safe for /login flows +``` + +### How instances are stored + +- Per-instance data lives in `~/.claude-desktop-/` (Electron `userData` dir). +- Generated `.app` wrappers live in `~/Applications/Claude-.app` (per-user, no admin required). The launcher script bakes `--user-data-dir` in at generation time — no runtime path-walking. +- The registry mapping instance names to dirs and bundles lives at `~/.ckipper/desktop.json` (separate file from `accounts.json`, separate schema version). + +### Diagnose + +`ckipper doctor` runs Desktop checks alongside the account checks: `/Applications/Claude.app` is installed (if any instances are registered), `desktop.json` is well-formed, each instance's data dir + `.app` bundle exist and parse, and a warning fires when two or more instances are registered (the deep-link reminder). + ## Security ### Docker isolation