From 5088507648b16141c1be509b500b2858c57511b2 Mon Sep 17 00:00:00 2001 From: Matt White Date: Wed, 27 May 2026 15:38:31 -0600 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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 06/22] 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 07/22] 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 08/22] 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 09/22] 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 10/22] 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 11/22] 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 12/22] 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 13/22] 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 14/22] 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 15/22] 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 16/22] 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 17/22] 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 18/22] 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 19/22] 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 20/22] 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 21/22] 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 22/22] 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" <