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/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/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/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. diff --git a/ckipper.zsh b/ckipper.zsh index 2000ee0..fbba9e8 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}" @@ -65,6 +67,14 @@ 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/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 source "$CKIPPER_REPO_DIR/lib/setup/prereqs.zsh" source "$CKIPPER_REPO_DIR/lib/setup/prompts.zsh" @@ -90,7 +100,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`) @@ -126,19 +136,24 @@ 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 _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 ;; @@ -179,6 +194,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" \ @@ -207,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." } @@ -222,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 @@ -232,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' @@ -246,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' @@ -275,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' \ @@ -299,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 @@ -335,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) diff --git a/lib/core/registry.zsh b/lib/core/registry.zsh index 8d020ed..7d0f836 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,37 +294,64 @@ _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, # 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() { + _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 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 + echo "Migrating ${registry_file:t} v1 → v2..." >&2 + _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" ] +} diff --git a/lib/desktop/bundle.zsh b/lib/desktop/bundle.zsh new file mode 100644 index 0000000..7b4d91e --- /dev/null +++ b/lib/desktop/bundle.zsh @@ -0,0 +1,199 @@ +#!/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`. 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). +_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 + +# 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" + 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" "$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" + # 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" <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 + ${_CKIPPER_DESKTOP_BUNDLE_VERSION} + CFBundleVersion + ${_CKIPPER_DESKTOP_BUNDLE_VERSION} + 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..2c05d49 --- /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" ] +} diff --git a/lib/desktop/dispatcher.zsh b/lib/desktop/dispatcher.zsh new file mode 100644 index 0000000..1c5fa68 --- /dev/null +++ b/lib/desktop/dispatcher.zsh @@ -0,0 +1,66 @@ +#!/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[@]}" +} diff --git a/lib/desktop/dispatcher_test.bats b/lib/desktop/dispatcher_test.bats new file mode 100644 index 0000000..90df63a --- /dev/null +++ b/lib/desktop/dispatcher_test.bats @@ -0,0 +1,97 @@ +#!/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 ] + # "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 ] + # "Why this exists:" appears only in login-specific help, not the overview. + [[ "$output" =~ "Why this exists:" ]] +} + +@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/doctor.zsh b/lib/desktop/doctor.zsh new file mode 100644 index 0000000..a112833 --- /dev/null +++ b/lib/desktop/doctor.zsh @@ -0,0 +1,192 @@ +#!/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. + +# 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" +} + +# 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_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_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_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)." +} + +# 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 ] +} 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)." +} diff --git a/lib/desktop/instance-management.zsh b/lib/desktop/instance-management.zsh new file mode 100644 index 0000000..117dea0 --- /dev/null +++ b/lib/desktop/instance-management.zsh @@ -0,0 +1,539 @@ +#!/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_-]+$' + +# 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. +# +# 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 >= _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." + 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" +} + +# 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 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. +_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 +} + +# 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'" +} + +# 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. +# +# 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. +# +# 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 new file mode 100644 index 0000000..1f30d0c --- /dev/null +++ b/lib/desktop/instance-management_test.bats @@ -0,0 +1,284 @@ +#!/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 +} + +# ── 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" ]] +} + +# ── 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" ]] +} + +# ── 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" ]] +} + +# ── 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" =~ "Nothing 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" ]] +} diff --git a/lib/desktop/launcher.zsh b/lib/desktop/launcher.zsh new file mode 100644 index 0000000..6b41989 --- /dev/null +++ b/lib/desktop/launcher.zsh @@ -0,0 +1,141 @@ +#!/usr/bin/env zsh +# Launch / login / process helpers for Claude Desktop instances. +# +# Three public entry points: +# _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 +} + +# Look up an instance's bundle path. Fails if the instance is not registered. +# +# 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" + [[ -f "$CKIPPER_DESKTOP_REGISTRY" ]] || { + echo "Desktop instance '$name' is not registered." >&2 + return 1 + } + 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 +# 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" +} + +# 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 new file mode 100644 index 0000000..7cd2d25 --- /dev/null +++ b/lib/desktop/launcher_test.bats @@ -0,0 +1,186 @@ +#!/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" ]] +} + +# ── 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" +} + +# ── 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" ]] +} 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 ] } 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" diff --git a/tests/lib/test-helper.bash b/tests/lib/test-helper.bash index 7d9d601..80f1720 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" } @@ -63,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; }