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..d434a78 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,18 @@ > **Platform:** macOS only — uses macOS Keychain, Docker Desktop, and host SSH agent forwarding. -A lightweight CLI for managing Claude Code accounts, worktrees, and Docker sandboxes. +A lightweight CLI for managing Claude Code accounts, git worktrees, Docker sandboxes, and Claude Desktop multi-instance setups. -Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, an egress firewall, safety hooks, macOS Keychain auth, and per-account isolation across credentials, settings, MCP, plugins, and projects. +For developers who want clean separation between personal and work Claude — in the terminal, in the Desktop app, or both — with the same registry, the same `doctor`, and the same `ck` short alias driving everything. + +Inspired by [incident.io's worktree workflow](https://incident.io/blog/shipping-faster-with-claude-code-and-git-worktrees) and [Rory Bain's gist](https://gist.github.com/rorydbain/e20e6ab0c7cc027fc1599bd2e430117d), extended with Docker containerization, an egress firewall, safety hooks, macOS Keychain auth, and per-account isolation across credentials, settings, MCP, plugins, and projects. The Claude Desktop multi-instance approach (generated `.app` wrappers + Electron `--user-data-dir`) was inspired by [Philipp Stracker's gist](https://gist.github.com/stracker-phil/9f84927a556632c7f9cc06663b534f14). ## The Problem `--dangerously-skip-permissions` lets Claude work autonomously without clicking Allow for every action — but on your actual machine it has full access to your filesystem, credentials, and network. Running it inside a container is the whole point. +Separately: Claude Desktop is single-instance by default — sign in with one account, lose the other. Running personal and work side-by-side needs an isolated user-data dir per instance and a Spotlight/Dock entry that actually opens the right one. + ## The Solution ```bash @@ -20,6 +24,12 @@ ck run myorg/myapp my-feature Creates a git worktree, optionally spins up a Docker container, and runs Claude inside it. Claude thinks it has full permissions but can only see the worktree. Your other projects, system files, and credentials are inaccessible. +```bash +ck desktop add work +``` + +Generates `~/Applications/Claude-Work.app` — a wrapper bundle that launches the system Claude Desktop against an isolated user-data dir. Spotlight, Dock, and Cmd-Tab treat it like any other app. Run it alongside your existing Claude Desktop with separate auth, MCP servers, and conversation history. + ## Quick start ```bash @@ -30,11 +40,20 @@ cd Ckipper `install.sh` deploys files under `~/.ckipper/`, adds the source line to your `.zshrc`, and ends by running the interactive setup wizard. The wizard registers your first Claude account, sets your projects directory, and configures default behaviors. Re-runnable any time via `ckipper setup`. +Your first commands by feature: + +```bash +ck run myorg/app feature/foo # sandboxed worktree + Claude Code (CLI) +ckipper account add work # multi-account CLI: register a second Claude Code account +ck desktop add work # multi-instance Desktop: register a wrapper .app bundle +``` + ### Prerequisites - **macOS** with zsh - **Docker Desktop** installed and running - **Claude Code** installed and authenticated (`claude` command works) +- **Claude Desktop** (`/Applications/Claude.app`) — *only if you'll use `ck desktop` instances* - **GitHub auth**: SSH keys added to your SSH agent, or `gh auth login` on host - **jq** and **gum** installed (`brew install jq gum`) @@ -46,6 +65,7 @@ cd Ckipper | `ck run ` | Create-or-cd to a worktree, optionally Docker | | `ck config get/set/unset/list/edit` | View and modify settings | | `ck account add/list/default/remove/rename/sync/redeploy-hooks` | Manage Claude accounts (see [Sync state between accounts](#sync-state-between-accounts)) | +| `ck desktop add/list/remove/rename/launch/login` | Manage Claude Desktop instances (alias `dt`; see [Claude Desktop instances](#claude-desktop-instances)) | | `ck worktree run/list/rm/rebuild-image` | Manage git worktrees | | `ck doctor [--fix]` | Diagnose registry, hooks, schema; optionally repair | | `ck` (no args) | Interactive launcher menu | @@ -163,7 +183,7 @@ Plugins are not a separate type — sync `enabledPlugins` + `extraKnownMarketpla ### Common commands -```sh +```bash # Full interactive wizard — picks source, targets, and types ckipper account sync @@ -196,7 +216,7 @@ ckipper account sync personal work --include all --yes Every destructive write is preceded by a copy to `/.ckipper-sync-backups/-from-/`. The summary table prints the backup directory path before applying. To restore: -```sh +```bash ckipper account sync undo work # restore most recent backup ckipper account sync undo work --pick # gum-pick from backup ledger ckipper account sync undo work --list # print backup directory paths @@ -211,6 +231,65 @@ These two commands sound similar but do different things: - **`ckipper account sync ... --include hooks`** — peer-to-peer copy of *user-written* hooks (any hook file in `/hooks/` whose filename does NOT match a ckipper-managed install hook). Includes the paired `settings.json` `.hooks` entry. - **`ckipper account redeploy-hooks`** — pushes the ckipper safety hooks (`bash-guardrails`, `protect-claude-config`, `docker-context`, `notify-bell`) from `~/.ckipper/hooks/` to every registered account. Run after editing a script in the install dir. +## Claude Desktop instances + +Run a personal Claude Desktop in one window and a work Claude Desktop in another, fully isolated. Each shows up in Spotlight and the Dock as its own `Claude-.app`, signs in with its own account, and keeps its own MCP servers, projects, and conversation history. Under the hood: a generated `.app` wrapper that launches the system `Claude.app` with Electron's `--user-data-dir` pointed at an isolated sandbox. + +### Accounts vs. Desktop instances + +Two independent isolation models, configured separately: + +| | CLI accounts (`ckipper account`) | Desktop instances (`ckipper desktop`) | +| --- | --- | --- | +| Scope | The `claude` CLI in a terminal | The `Claude.app` Electron app | +| Data dir | `~/.claude-/` | `~/.claude-desktop-/` | +| Registry | `~/.ckipper/accounts.json` | `~/.ckipper/desktop.json` | +| Auth | macOS Keychain (per account) | Electron-managed (per user-data dir) | +| Sync command | `ckipper account sync` | _not available — each instance signs in independently_ | +| Default selector | `ckipper account default ` | _not available — pick from Spotlight/Dock_ | + +An account named `work` and a Desktop instance named `work` share nothing but the name. `ckipper account sync` does not touch Desktop instances; `ckipper desktop` does not touch CLI accounts. + +### Add an instance + +```bash +ckipper desktop add work +``` + +Creates `~/.claude-desktop-work/` (user-data dir) and `~/Applications/Claude-Work.app` (wrapper bundle whose launcher exec's `open -n -a /Applications/Claude.app --args --user-data-dir=…`). Requires `/Applications/Claude.app` to be installed. + +### Use an instance + +```bash +ckipper desktop launch work # open the instance (also works from Spotlight / Dock) +``` + +### List, rename, remove + +```bash +ckipper desktop list # see registered instances + running status +ckipper desktop rename work prod # rename in place +ckipper desktop remove work # interactively prompt to delete user-data dir + bundle +``` + +### Don't run `/login` with two instances open + +macOS routes `claude://` URLs (the OAuth callback used by `/login`) to whichever Claude app was most recently active. With two or more Desktop instances running, the callback can land in the wrong window. `ckipper desktop login ` works around this by quitting *every* running Claude process and launching only the target — complete `/login` there, then re-open the others as needed. One-time cost per instance; once authenticated, instances run side by side indefinitely. + +```bash +ckipper desktop login work # quit all, launch only 'work' — safe for /login flows +``` + +### How instances are stored + +- Per-instance data lives in `~/.claude-desktop-/` (Electron `userData` dir). +- Generated `.app` wrappers live in `~/Applications/Claude-.app` (per-user, no admin required). The launcher script bakes `--user-data-dir` in at generation time — no runtime path-walking. +- The registry mapping instance names to dirs and bundles lives at `~/.ckipper/desktop.json` (separate file from `accounts.json`, separate schema version). + +### Diagnose + +`ckipper doctor` runs Desktop checks alongside the account checks: `/Applications/Claude.app` is installed (if any instances are registered), `desktop.json` is well-formed, each instance's data dir + `.app` bundle exist and parse, and a warning fires when two or more instances are registered (the deep-link reminder). + ## Security ### Docker isolation 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/account/sync/dispatcher.zsh b/lib/account/sync/dispatcher.zsh index 1ee9ef7..c032d97 100644 --- a/lib/account/sync/dispatcher.zsh +++ b/lib/account/sync/dispatcher.zsh @@ -95,15 +95,39 @@ _ckipper_account_sync_run() { fi if (( ${#_CKIPPER_SYNC_TARGETS} == 0 )); then _CKIPPER_SYNC_TARGETS=( ${(f)"$(_ckipper_account_sync_pick_targets "$_CKIPPER_SYNC_FROM")"} ) - (( ${#_CKIPPER_SYNC_TARGETS} == 0 )) && return 1 + if (( ${#_CKIPPER_SYNC_TARGETS} == 0 )); then + _ckipper_account_sync_empty_select_hint "target accounts" + return 1 + fi fi _ckipper_account_sync_validate_accounts || return 1 local -a types types=( ${(f)"$(_ckipper_account_sync_resolve_types)"} ) - (( ${#types} == 0 )) && { echo "No types selected." >&2; return 1; } + if (( ${#types} == 0 )); then + _ckipper_account_sync_empty_select_hint "sync types" + return 1 + fi _ckipper_account_sync_run_targets types } +# Print a friendly hint when a multi-select picker returns nothing. +# +# `gum choose --no-limit` exits 0 with empty stdout when the user presses +# ENTER without first toggling items with SPACE — easy to do because the +# single-select source picker right before it accepts ENTER on its own. +# Without this hint, the wizard exits silently and users believe the picker +# is broken (see PR adding this for the reproduction). +# +# Args: $1 — what was being selected ("target accounts" | "sync types"). +# Returns: 0 always. +# Errors (stderr): "No selected." plus a SPACE/ENTER hint. +_ckipper_account_sync_empty_select_hint() { + { + echo "No $1 selected." + echo "Hint: in the picker, press SPACE to mark items, then ENTER to confirm." + } >&2 +} + # Walk every target and apply the resolved type list. # # Args: $1 — name of array variable holding type ids. diff --git a/lib/account/sync/dispatcher_test.bats b/lib/account/sync/dispatcher_test.bats index 735df2e..31c8afd 100644 --- a/lib/account/sync/dispatcher_test.bats +++ b/lib/account/sync/dispatcher_test.bats @@ -180,6 +180,29 @@ run_full() { [ "$status" -eq 0 ] } +# Regression: with no positional targets and an empty multi-select reply, +# the wizard previously returned 1 silently — users perceived the picker +# as broken. Both pickers now print a SPACE/ENTER hint before exiting. +@test "interactive sync prints a SPACE/ENTER hint when no targets are picked" { + setup_two_accounts + # Pipe enough blank lines to satisfy every prompt the fallback path asks + # for (source = "1", targets = "", types = ""). The empty targets line + # is what we're exercising. + run_full 'printf "1\n\n\n" | ckipper account sync' + [ "$status" -ne 0 ] + [[ "$output" == *"No target accounts selected"* ]] + [[ "$output" == *"press SPACE to mark items"* ]] +} + +@test "interactive sync prints a SPACE/ENTER hint when no types are picked" { + setup_two_accounts + # Source = "1", targets = "dst", types = "" (empty -> hint). + run_full 'printf "1\ndst\n\n" | ckipper account sync' + [ "$status" -ne 0 ] + [[ "$output" == *"No sync types selected"* ]] + [[ "$output" == *"press SPACE to mark items"* ]] +} + # Regression: when the user picks "View changes" then "Apply", the diff # output written by drill_down_loop must NOT pollute the captured action, # else the [[ "$action" == "apply" ]] check downstream silently skips apply. diff --git a/lib/account/sync/interactive.zsh b/lib/account/sync/interactive.zsh index adb69b5..1ab72c3 100644 --- a/lib/account/sync/interactive.zsh +++ b/lib/account/sync/interactive.zsh @@ -63,7 +63,10 @@ _ckipper_account_sync_pick_targets() { _ckipper_account_sync_pick_targets_fallback() { echo "Available targets: $*" >&2 local input - input=$(_core_prompt_input "Enter comma-separated targets" "") + # Propagate cancel (Esc/Ctrl-C/EOF) so the caller's empty-array check + # sees no targets and prints the SPACE/ENTER hint, instead of treating + # whatever shell garbage `$input` happens to contain as the list. + input=$(_core_prompt_input "Enter comma-separated targets" "") || return $? local name for name in ${(s:,:)input}; do echo "$name" @@ -87,7 +90,8 @@ _ckipper_account_sync_pick_types() { fi echo "Type tokens: ${(@k)_CKIPPER_SYNC_TYPE_LABEL}" >&2 local input - input=$(_core_prompt_input "Enter comma-separated types" "") + # Propagate cancel; same reasoning as pick_targets_fallback above. + input=$(_core_prompt_input "Enter comma-separated types" "") || return $? local name for name in ${(s:,:)input}; do echo "$name" diff --git a/lib/account/sync/interactive_test.bats b/lib/account/sync/interactive_test.bats index 1146059..1b57086 100644 --- a/lib/account/sync/interactive_test.bats +++ b/lib/account/sync/interactive_test.bats @@ -34,3 +34,32 @@ run_in_zsh() { [[ "$output" == *"client1,work,"* ]] [[ "$output" != *"personal"* ]] } + +# Regression: cancel from the comma-separated input prompt used to pass +# through as a 0-rc empty-output result, which the dispatcher then split +# into an empty array — masking cancel as "user submitted no targets". +# Now propagates the prompt's non-zero rc so callers can distinguish. +@test "_pick_targets_fallback returns non-zero on EOF (cancel)" { + run env HOME="$HOME" CKIPPER_DIR="$CKIPPER_DIR" CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_NO_GUM=1 TMP_HOME="$TMP_HOME" PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/registry.zsh\"; \ + source \"$REPO_ROOT/lib/core/prompt.zsh\"; \ + source \"$REPO_ROOT/lib/account/sync/interactive.zsh\"; \ + _ckipper_account_sync_pick_targets_fallback work 2>/dev/null" /dev/null" ` is invoked WITHOUT a value, +# the handler prompts via _core_prompt_input. Cancellation (Esc/Ctrl-C on +# gum, EOF on the read fallback) must abort the write rather than commit +# an empty value — otherwise the user's only out is to silently blank the +# key. _core_prompt_input now returns non-zero on cancel; this test pins +# the abort-on-cancel behavior in `_ckipper_config_set`. +@test "config set aborts the write when the value prompt is cancelled" { + run env HOME="$TMP_HOME" \ + CKIPPER_DIR="$CKIPPER_DIR" \ + CKIPPER_REGISTRY="$CKIPPER_REGISTRY" \ + CKIPPER_REGISTRY_VERSION="${CKIPPER_REGISTRY_VERSION:-2}" \ + CKIPPER_NO_GUM=1 \ + PATH="$PATH" \ + zsh -c " + source \"$REPO_ROOT/lib/core/schema.zsh\" + source \"$REPO_ROOT/lib/core/config.zsh\" + source \"$REPO_ROOT/lib/core/registry.zsh\" + source \"$REPO_ROOT/lib/core/fuzzy.zsh\" + source \"$REPO_ROOT/lib/core/style.zsh\" + source \"$REPO_ROOT/lib/core/help.zsh\" + source \"$REPO_ROOT/lib/core/prompt.zsh\" + source \"$REPO_ROOT/lib/config/set.zsh\" + source \"$REPO_ROOT/lib/config/get.zsh\" + _ckipper_config_set default_branch 2>/dev/null + echo \"set_rc=\$?\" + _ckipper_config_get default_branch + " /dev/null || return 1; } if [[ "$_CKIPPER_CONFIG_SET_HAS_VALUE" != "true" ]]; then local prompt_label="Value for $key (${_CKIPPER_SCHEMA_TYPE[$key]})" - value=$(_core_prompt_input "$prompt_label" "") + # Cancellation must abort the write, not commit an empty value. + # _core_prompt_input returns non-zero on Esc/Ctrl-C; without this + # check the user pressing cancel would silently blank the key. + if ! value=$(_core_prompt_input "$prompt_label" ""); then + return 1 + fi fi _core_config_set "$key" "$value" "$account" } diff --git a/lib/core/prompt.zsh b/lib/core/prompt.zsh index f6ccf9f..df7f0ba 100644 --- a/lib/core/prompt.zsh +++ b/lib/core/prompt.zsh @@ -25,18 +25,27 @@ _core_prompt_use_gum() { # Prompt for a free-form string. Returns the entered value, or the supplied # default when input is empty. # +# Distinguishes "user submitted empty" (rc=0, value=default) from "user +# cancelled" (rc != 0, no stdout). `gum input` exits non-zero on Esc/Ctrl-C, +# and we propagate that so callers can distinguish cancellation. Previously +# we collapsed both into "echo default", which meant a cancelled launcher +# branch prompt silently created a worktree on the default branch name. +# # Args: $1 — label shown to the user; $2 — default value used on empty input. -# Returns: 0 always; prints the resolved value to stdout. +# Returns: 0 on submit (including empty submit); non-zero on cancellation. +# Prints the resolved value to stdout on success; nothing on cancel. _core_prompt_input() { local label="$1" default="$2" if _core_prompt_use_gum; then - local out + local out rc out=$(gum input --placeholder "$default" --prompt "$label > ") + rc=$? + (( rc != 0 )) && return $rc echo "${out:-$default}" return 0 fi local val="" - read -r "val?$label [$default]: " + read -r "val?$label [$default]: " || return $? echo "${val:-$default}" } @@ -92,20 +101,3 @@ _core_prompt_choose() { (( choice >= 1 && choice <= $# )) || return 1 echo "${@[choice]}" } - -# Run a command with a spinner indicator. Forwards the command's exit status. -# -# Args: $1 — label shown alongside the spinner; $2..$N — command and args to -# execute. The label is consumed by this function and never reaches the -# wrapped command. -# Returns: the exit status of the wrapped command. -_core_prompt_spin() { - local label="$1" - shift - if _core_prompt_use_gum; then - gum spin --spinner dot --title "$label" -- "$@" - return $? - fi - echo "$label..." >&2 - "$@" -} diff --git a/lib/core/prompt_test.bats b/lib/core/prompt_test.bats index 814f171..0618e2f 100644 --- a/lib/core/prompt_test.bats +++ b/lib/core/prompt_test.bats @@ -46,6 +46,20 @@ _run_prompt() { [ "$output" = "thedefault" ] } +# Regression: previously cancellation (EOF / Ctrl-C / Esc on the gum form) +# was indistinguishable from "user submitted empty" because both echoed +# the default. The launcher's branch prompt then created a worktree on +# `feature/dev` even when the user pressed Ctrl-X to back out. The fix +# propagates rc from gum / read so callers can distinguish cancel via rc. +@test "_core_prompt_input returns non-zero with no stdout when read sees EOF" { + run env CKIPPER_NO_GUM=1 PATH="$PATH" \ + zsh -c "source \"$REPO_ROOT/lib/core/prompt.zsh\"; \ + _core_prompt_input \"Q\" \"thedefault\" 2>/dev/null" "$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/core/schema.zsh b/lib/core/schema.zsh index a7650f9..3cf09fa 100644 --- a/lib/core/schema.zsh +++ b/lib/core/schema.zsh @@ -52,16 +52,18 @@ typeset -gA _CKIPPER_SCHEMA_SCOPE=( [ssh_forward]="account" ) -# One-line description shown by `ckipper config list` and the wizard. +# One-line description shown by `ckipper config list` and the wizard. For bool +# keys the description states what `true` does (the active behavior), so the +# user can read it and decide; `false` is just the inverse. typeset -gA _CKIPPER_SCHEMA_DESCRIPTION=( - [projects_dir]="Base directory containing your git projects." - [worktrees_dir]="Where worktrees are created (default: \$projects_dir/.worktrees)." - [ports]="Comma-separated ports to forward from container to host." - [default_branch]="Fallback base branch when origin/HEAD is unset." - [dep_install_cmd]="Command run after worktree creation. Empty = skip." - [notify_bell]="Install notify-bell hook into account dirs." - [aliases_auto_source]="install.sh auto-adds aliases.zsh source line to .zshrc." - [always_docker]="Default --docker on for this account." - [always_firewall]="Default --firewall on for this account." - [ssh_forward]="Forward host ~/.ssh into containers run with this account." + [projects_dir]="Path. Base directory containing your git projects." + [worktrees_dir]="Path. Where worktrees live. Empty = \$projects_dir/.worktrees." + [ports]="Comma-separated int list. Container ports to forward to the host." + [default_branch]="String. Fallback base branch when origin/HEAD is unset (e.g. main, develop)." + [dep_install_cmd]="String. Command run after worktree creation. Empty = skip dep install." + [notify_bell]="Bool. true = play a terminal bell on Stop / Notification hooks." + [aliases_auto_source]="Bool. true = installer auto-adds the per-account aliases source line to ~/.zshrc." + [always_docker]="Bool. true = run Claude in Docker by default for this account (override with --no-docker)." + [always_firewall]="Bool. true = enable the egress firewall by default for this account (override with --no-firewall)." + [ssh_forward]="Bool. true = mount host ~/.ssh into containers launched for this account." ) 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 39768a4..49f7faa 100644 --- a/lib/launcher/menu.zsh +++ b/lib/launcher/menu.zsh @@ -14,27 +14,33 @@ # - 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" "Quit" ) -# `find -maxdepth` value for project discovery. Three levels covers the common -# layouts (`~/Developer/`, `~/Developer//`, -# `~/Developer///`) without scanning entire user homedirs. -readonly _CKIPPER_LAUNCHER_PROJECTS_MAXDEPTH=3 +# `find -maxdepth` value for project discovery. The repository's `.git` +# directory sits one level below the repo root, so to surface +# `////.git` we need depth 4. Anything +# deeper than that is almost always a vendored sub-repo or a demo project, +# so depth 4 is the sweet spot between coverage and scan time. +readonly _CKIPPER_LAUNCHER_PROJECTS_MAXDEPTH=4 # Print the launcher banner: a styled "Ckipper" header, the product tagline, # and a trailing blank line that separates the banner from whatever prompt or @@ -72,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 } @@ -122,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 b408e29..5fec79e 100644 --- a/lib/setup/dispatcher.zsh +++ b/lib/setup/dispatcher.zsh @@ -4,8 +4,7 @@ # # Depends on: # - lib/core/style.zsh (`_core_style_header`) -# - lib/core/prompt.zsh (`_core_prompt_confirm`, `_core_prompt_input`, -# `_core_prompt_spin`) +# - lib/core/prompt.zsh (`_core_prompt_confirm`, `_core_prompt_input`) # - lib/setup/prereqs.zsh (`_ckipper_setup_prereqs`) # - lib/setup/prompts.zsh (`_ckipper_setup_prompts_*`) # - lib/setup/apply.zsh (`_ckipper_setup_apply_global`, @@ -37,28 +36,163 @@ _ckipper_setup() { echo "Using current values." fi _ckipper_setup_offer_account + _ckipper_setup_offer_existing_sync + _ckipper_setup_offer_aliases_source _ckipper_setup_offer_image_build - _ckipper_setup_print_completion_summary + _ckipper_setup_print_completion_summary "$_CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS" + _ckipper_setup_wait_for_acknowledgement } -# Print the post-setup hint block: review-settings command, two ways to launch -# Claude (per-account aliases or `ckipper run`), and the bare-`ck` menu. -# Extracted so `_ckipper_setup` stays under the 25-line cap. +# Offer a between-accounts sync when the user has 2+ accounts already and +# the wizard's add-account step did not just run one (the post-add path +# already offers the sync inline). Without this, a re-run of `ckipper setup` +# on an established multi-account install never surfaces the sync feature. # # Returns: 0 always. +_ckipper_setup_offer_existing_sync() { + local count + count=$(jq -r '.accounts | length' "$CKIPPER_REGISTRY" 2>/dev/null || echo 0) + (( count < 2 )) && return 0 + if ! _core_prompt_confirm "Sync settings between two existing accounts?"; then + return 0 + fi + _ckipper_account_sync_dispatch +} + +# Print the post-setup completion screen: bordered gum-styled card with a +# build-status line and two columns of commands (getting-started and +# maintenance). The whole thing is one `gum style` block so it visually +# belongs to the same wizard as the gum-rendered prompts above it. A +# build failure is easy to miss after 5 minutes of streaming docker +# output; the colored status line is the primary signal. +# +# Falls back to plain ANSI rendering when CKIPPER_NO_GUM is set. +# +# Args: $1 — image build status: `ok` | `failed` | `skipped`. +# Returns: 0 always. _ckipper_setup_print_completion_summary() { + local image_status="$1" + if _ckipper_setup_completion_use_gum; then + _ckipper_setup_render_completion_gum "$image_status" + else + _ckipper_setup_render_completion_plain "$image_status" + fi +} + +# Mirror of `_core_prompt_use_gum` — kept private so the completion path +# does not pull `_core_prompt_*` into its dependency surface. +# +# Returns: 0 if gum should drive rendering; 1 for the plain fallback. +_ckipper_setup_completion_use_gum() { + [[ "$CKIPPER_NO_GUM" == "1" ]] && return 1 + command -v gum >/dev/null 2>&1 +} + +# Render the completion screen via `gum style`. Pre-builds the inner +# content as a multi-line string so the border wraps the whole block. +# +# Args: $1 — image status (`ok` | `failed` | `skipped`). +# Returns: 0 always. +_ckipper_setup_render_completion_gum() { + local image_status="$1" + local content + content=$(_ckipper_setup_completion_inner "$image_status") + gum style \ + --border rounded \ + --padding "1 2" \ + --border-foreground "$_CKIPPER_SETUP_PROMPTS_BORDER_FG" \ + "$content" +} + +# Build the multi-line text content that goes inside the bordered card. +# The image-status line uses gum's foreground colors directly so the +# border block stays a single styled call. Sections are separated by +# blank lines for visual rhythm inside the card. +# +# Args: $1 — image status. +# Returns: 0 always; prints the multi-line content to stdout. +_ckipper_setup_completion_inner() { + local image_status="$1" + gum style --bold --foreground "$_CKIPPER_SETUP_PROMPTS_BORDER_FG" "Setup complete" + echo + _ckipper_setup_render_image_status_gum "$image_status" + echo + gum style --bold "Getting started:" + echo " ckipper run Bundle worktree + Claude" + echo " ck Interactive menu" + echo " claude- Per-account launcher (e.g. claude-personal)" + echo " ckipper desktop add Register a Claude Desktop instance" + echo + gum style --bold "Maintenance:" + echo " ckipper config list Review every setting" + echo " ckipper doctor Diagnose installation issues" + echo " ckipper worktree rebuild-image Rebuild ckipper-dev Docker image" + echo " ckipper account sync Copy settings between accounts" +} + +# Plain-text completion screen for non-gum environments (CI, tests). Same +# information, no border or color. +# +# Args: $1 — image status. +# Returns: 0 always. +_ckipper_setup_render_completion_plain() { + local image_status="$1" _core_style_header "Setup complete" - echo "Review settings: ckipper config list" - echo "Diagnose installation: ckipper doctor" + _ckipper_setup_render_image_status "$image_status" + echo "Getting started:" + 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 "Launch Claude in a worktree (host or Docker):" - echo " ckipper run # bundles worktree + Claude in one step" + echo "Maintenance:" + echo " ckipper config list Review every setting" + echo " ckipper doctor Diagnose installation issues" + echo " ckipper worktree rebuild-image Rebuild ckipper-dev Docker image" + echo " ckipper account sync Copy settings between accounts" echo "" - echo "Launch Claude directly with an account context:" - echo " claude- # auto-generated launcher" - echo " # bare-name shortcut, when free" +} + +# Render the docker-build-status line for the gum path using gum's +# foreground color codes (gum-color 46 = bright green, 196 = red, 244 = +# dim gray) so it nests cleanly inside the surrounding `gum style` block. +# +# Args: $1 — `ok` | `failed` | `skipped`. +# Returns: 0 always. +_ckipper_setup_render_image_status_gum() { + case "$1" in + ok) gum style --foreground 46 "✓ Docker image: built successfully." ;; + failed) gum style --foreground 196 "✗ Docker image: build FAILED — re-run: ckipper worktree rebuild-image" ;; + skipped) gum style --foreground 244 "○ Docker image: skipped — build later: ckipper worktree rebuild-image" ;; + esac +} + +# Plain-text image-status line (no gum). Uses the existing _core_style +# color palette so terminals that support ANSI still get a coloured +# banner; the no-color path falls through to plain text via +# _core_style_color's enablement check. +# +# Args: $1 — `ok` | `failed` | `skipped`. +# Returns: 0 always. +_ckipper_setup_render_image_status() { + case "$1" in + ok) _core_style_color green "Docker image: built successfully." ;; + failed) _core_style_color red "Docker image: build FAILED — re-run with: ckipper worktree rebuild-image" ;; + skipped) _core_style_color dim "Docker image: skipped — build later with: ckipper worktree rebuild-image" ;; + esac echo "" - echo "Or just run 'ck' for the interactive menu." +} + +# Pause until the user presses Enter, so the "Setup complete" banner does +# not disappear off-screen behind the next shell prompt — particularly +# important when the docker build output preceded it. Skipped on +# non-interactive stdin (CI, piped installers). +# +# Returns: 0 always. +_ckipper_setup_wait_for_acknowledgement() { + [[ -t 0 ]] || return 0 + local _ack="" + read -r "_ack?Press ENTER to finish setup. " } # Print top-level setup help. @@ -74,7 +208,10 @@ _ckipper_setup_help() { " 1. Verifies prereqs (gum, jq, docker) and offers to brew-install missing." \ " 2. Shows your current global config and lets you customize any subset." \ " 3. Offers to register a Claude account and configure its preferences." \ - " 4. Offers to build the ckipper-dev Docker image." \ + " 4. Offers to sync settings between two existing accounts (≥ 2 accounts)." \ + " 5. Offers to wire per-account launchers (claude-) into ~/.zshrc." \ + " 6. Offers to build the ckipper-dev Docker image." \ + " 7. Prints a Setup Complete summary; press ENTER to finish." \ "" \ "Usage:" \ " ckipper setup Run the wizard." \ @@ -89,10 +226,16 @@ _ckipper_setup_run_customize_loop() { local -a picked picked=( ${(f)"$(_ckipper_setup_prompts_pick_keys)"} ) typeset -A updates - local key + local key value for key in "${picked[@]}"; do [[ -z "$key" ]] && continue - updates[$key]=$(_ckipper_setup_prompts_one_key "$key") + # If the per-key prompt returns non-zero the user cancelled it + # (Esc/Ctrl-C on gum). Skip the key rather than writing an empty + # override, which would silently blank out the value. + if ! value=$(_ckipper_setup_prompts_one_key "$key"); then + continue + fi + updates[$key]="$value" done _ckipper_setup_apply_global updates } @@ -128,7 +271,9 @@ _ckipper_setup_offer_account() { # Returns: 0 always (per-step failures are surfaced via the underlying calls). _ckipper_setup_add_account() { local name - name=$(_core_prompt_input "Account name" "$_CKIPPER_SETUP_DEFAULT_ACCOUNT_NAME") + if ! name=$(_core_prompt_input "Account name" "$_CKIPPER_SETUP_DEFAULT_ACCOUNT_NAME"); then + return 0 + fi _ckipper_account_add "$name" || return 0 typeset -A prefs _ckipper_setup_collect_account_prefs "$name" @@ -189,13 +334,46 @@ _ckipper_setup_collect_account_prefs() { "Forward host ~/.ssh into '$account' containers?" } -# Offer to build/rebuild the ckipper-dev Docker image now. Wraps the build in -# a gum spinner when available; the underlying helper streams docker output -# directly when running without gum. +# Offer to build/rebuild the ckipper-dev Docker image now. Records the +# outcome in _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS so the completion +# summary can render a banner — without that signal, a failed build is +# easy to miss in the 5+ minutes of streaming docker output and the user +# would only discover it later when `--docker` runs hit "image not found." # -# Returns: 0 always. +# Sets _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS to one of: ok, failed, skipped. +# Returns: 0 always (failures are surfaced via the status global, not rc, +# so the wizard always finishes the post-build flow). _ckipper_setup_offer_image_build() { - if _core_prompt_confirm "Build the Docker image now? (slow; ~5 min)"; then - _core_prompt_spin "Building ckipper-dev image" _ckipper_worktree_build_image + typeset -g _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS="skipped" + if ! _core_prompt_confirm "Build the Docker image now? (slow; ~5 min)"; then + return 0 + fi + if _ckipper_worktree_build_image; then + _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS="ok" + else + _CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS="failed" + fi +} + +# Offer to add the per-account aliases source line to ~/.zshrc. The +# launchers (`claude-`, bare ``) only exist when the +# user's shell sources `~/.ckipper/aliases.zsh`. install.sh prints the +# suggestion but never appends it; setup-only re-runs (post-install) +# never see the suggestion at all. This step closes that loop, with an +# idempotency check so re-runs don't duplicate the line. +# +# Returns: 0 always. +_ckipper_setup_offer_aliases_source() { + local zshrc="$HOME/.zshrc" + [[ -f "$zshrc" ]] || return 0 + grep -q 'ckipper/aliases\.zsh' "$zshrc" 2>/dev/null && return 0 + if ! _core_prompt_confirm "Add per-account launchers (claude-) to ~/.zshrc?"; then + return 0 fi + { + echo "" + echo "# Ckipper — per-account launchers (claude-, bare )" + echo '[[ -f ~/.ckipper/aliases.zsh ]] && source ~/.ckipper/aliases.zsh' + } >> "$zshrc" + echo "Added the source line. Open a new shell (or run 'source ~/.zshrc')." } diff --git a/lib/setup/dispatcher_test.bats b/lib/setup/dispatcher_test.bats index 9f8ebd4..e507bac 100644 --- a/lib/setup/dispatcher_test.bats +++ b/lib/setup/dispatcher_test.bats @@ -116,3 +116,139 @@ JSON [ "$status" -eq 0 ] [[ "$output" == *"STUB-BUILD"* ]] } + +# Regression: a failed docker build was easy to miss because 5 minutes of +# streaming output buried the completion message. The wizard now records +# build outcome (ok / failed / skipped) and the completion summary +# renders a colored banner that's findable at a glance. +@test "_ckipper_setup_offer_image_build records ok when build succeeds" { + _run_setup $'y\n' ' + _ckipper_worktree_build_image() { return 0; } + _ckipper_setup_offer_image_build + echo "status=$_CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS"' + + [[ "$output" == *"status=ok"* ]] +} + +@test "_ckipper_setup_offer_image_build records failed when build returns non-zero" { + _run_setup $'y\n' ' + _ckipper_worktree_build_image() { return 1; } + _ckipper_setup_offer_image_build + echo "status=$_CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS"' + + [[ "$output" == *"status=failed"* ]] +} + +@test "_ckipper_setup_offer_image_build records skipped when user declines" { + _run_setup $'n\n' ' + _ckipper_worktree_build_image() { echo SHOULD-NOT-RUN; } + _ckipper_setup_offer_image_build + echo "status=$_CKIPPER_SETUP_LAST_IMAGE_BUILD_STATUS"' + + [[ "$output" == *"status=skipped"* ]] + [[ "$output" != *"SHOULD-NOT-RUN"* ]] +} + +# Regression: completion summary previously listed only the basics; now +# it also points at `ckipper worktree rebuild-image` and `ckipper account +# sync` so users can find them without re-running the full wizard. +@test "_ckipper_setup_print_completion_summary mentions rebuild-image and sync" { + _run_setup "" "_ckipper_setup_print_completion_summary ok" + + [ "$status" -eq 0 ] + [[ "$output" == *"ckipper worktree rebuild-image"* ]] + [[ "$output" == *"ckipper account sync"* ]] +} + +# Regression: a build failure used to be invisible in the completion +# screen. Now the banner explicitly calls it out and points at the +# rebuild command. +@test "_ckipper_setup_print_completion_summary surfaces a failed build banner" { + _run_setup "" "_ckipper_setup_print_completion_summary failed" + + [ "$status" -eq 0 ] + [[ "$output" == *"FAILED"* ]] + [[ "$output" == *"rebuild-image"* ]] +} + +# Regression: setup never offered to wire the per-account aliases source +# line into ~/.zshrc. Users who installed via install.sh got it appended +# (line 158-160 of install.sh); users who only ever ran `ckipper setup` +# missed it and `claude-` launchers silently didn't work. +@test "_ckipper_setup_offer_aliases_source skips when ~/.zshrc already sources it" { + echo 'source ~/.ckipper/aliases.zsh' > "$TMP_HOME/.zshrc" + + _run_setup "" "_ckipper_setup_offer_aliases_source 2>&1" + + [ "$status" -eq 0 ] + [[ "$output" != *"Add per-account launchers"* ]] +} + +@test "_ckipper_setup_offer_aliases_source appends source line on accept" { + : > "$TMP_HOME/.zshrc" + + _run_setup $'y\n' "_ckipper_setup_offer_aliases_source 2>&1" + + [ "$status" -eq 0 ] + grep -q 'ckipper/aliases\.zsh' "$TMP_HOME/.zshrc" +} + +@test "_ckipper_setup_offer_aliases_source declines do not write to ~/.zshrc" { + : > "$TMP_HOME/.zshrc" + + _run_setup $'n\n' "_ckipper_setup_offer_aliases_source 2>&1" + + [ "$status" -eq 0 ] + ! grep -q 'ckipper/aliases\.zsh' "$TMP_HOME/.zshrc" +} + +# Regression: setup previously offered cross-account sync only after the +# user added a NEW account in the wizard. A user with 2+ existing accounts +# who declined "Add another?" never saw the sync feature surfaced. The +# behavioral signal we can assert (prompts written by zsh's `read "ans?…"` +# are suppressed when stdin is non-TTY, so we can't grep the label) is that +# the dispatch helper IS invoked on accept and SKIPPED otherwise. +@test "_ckipper_setup_offer_existing_sync skips when fewer than 2 accounts" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{"a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}}} +JSON + + _run_setup $'y\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" != *"STUB-SYNC"* ]] +} + +@test "_ckipper_setup_offer_existing_sync invokes sync_dispatch on yes" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{ + "a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}, + "b":{"config_dir":"/y","keychain_service":null,"registered_at":"t","preferences":{}} +}} +JSON + + _run_setup $'y\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" == *"STUB-SYNC"* ]] +} + +@test "_ckipper_setup_offer_existing_sync skips sync_dispatch on no" { + cat >"$CKIPPER_REGISTRY" <<'JSON' +{"version":2,"default":"a","accounts":{ + "a":{"config_dir":"/x","keychain_service":null,"registered_at":"t","preferences":{}}, + "b":{"config_dir":"/y","keychain_service":null,"registered_at":"t","preferences":{}} +}} +JSON + + _run_setup $'n\n' ' + _ckipper_account_sync_dispatch() { echo "STUB-SYNC"; } + _ckipper_setup_offer_existing_sync' + + [ "$status" -eq 0 ] + [[ "$output" != *"STUB-SYNC"* ]] +} diff --git a/lib/setup/prompts.zsh b/lib/setup/prompts.zsh index a6199c9..1c07dbe 100644 --- a/lib/setup/prompts.zsh +++ b/lib/setup/prompts.zsh @@ -30,23 +30,56 @@ readonly _CKIPPER_SETUP_PROMPTS_NO_GUM_SENTINEL="1" # Header rendered above the summary table. readonly _CKIPPER_SETUP_PROMPTS_HEADER="Detected configuration" -# Pipe-separated row builder for the summary table. Resolves the effective -# value via _core_config_get and the source marker via _core_config_read_global -# (empty return ⇒ default; otherwise ⇒ user override). +# Header for the multi-select picker. Mentions SPACE explicitly because the +# preceding y/N "customize?" prompt and the source-account picker are both +# single-select Enter — without the hint, users press Enter on the first +# row and silently advance with no overrides. +readonly _CKIPPER_SETUP_PROMPTS_PICKER_HEADER="Pick keys to customize (SPACE to mark, ENTER to confirm)" + +# Border-foreground color for gum-styled summary blocks. 212 is gum's +# default pink — matches the prompt accent (Yes/No buttons) so the +# detected-config block visually belongs to the same wizard. +readonly _CKIPPER_SETUP_PROMPTS_BORDER_FG=212 + +# SETTING column width for the no-gum fallback summary. 22 chars covers +# every key in the current schema (longest is `aliases_auto_source` at 19) +# with a 3-char gutter before the value. +readonly _CKIPPER_SETUP_PROMPTS_KEY_WIDTH=22 + +# Maximum width of the VALUE cell before truncation. Caps the table width +# so it stays readable in narrow terminals and during shared-screen demos +# without hiding the at-a-glance setting/source signal — the full value +# is one `ckipper config get ` away. +readonly _CKIPPER_SETUP_PROMPTS_VALUE_MAX_WIDTH=40 + +# Build the pipe-separated row data for the summary table — one header +# row plus one row per global-scoped key. Emits to stdout for callers to +# pipe into `gum table -p` or to consume directly in fallback rendering. # -# Args: $1 — schema key. -# Returns: 0 always; prints "||" to stdout. -_ckipper_setup_prompts_summary_row() { - local key="$1" - local value source raw - value=$(_core_config_get "$key") - raw=$(_core_config_read_global "$key") - if [[ -z "$raw" ]]; then - source="$_CKIPPER_SETUP_PROMPTS_SOURCE_DEFAULT" - else - source="$_CKIPPER_SETUP_PROMPTS_SOURCE_USER" - fi - printf '%s|%s|%s\n' "$key" "$value" "$source" +# Returns: 0 always; prints `||` rows (header first). +_ckipper_setup_prompts_summary_rows() { + printf 'SETTING|VALUE|SOURCE\n' + # Hoist loop locals — see fallback rendering for the reason. + local key="" value="" source="" raw="" display_value="" + while IFS= read -r key; do + value=$(_core_config_get "$key") + raw=$(_core_config_read_global "$key") + if [[ -z "$raw" ]]; then + source="$_CKIPPER_SETUP_PROMPTS_SOURCE_DEFAULT" + else + source="$_CKIPPER_SETUP_PROMPTS_SOURCE_USER" + fi + # Empty values render as a discoverable placeholder rather than + # blank space, which otherwise reads like "the field is broken." + display_value="$value" + [[ -z "$display_value" ]] && display_value="(empty)" + # Truncate over-long values so the table doesn't blow past + # narrow terminals. Trailing `…` signals truncation. + if (( ${#display_value} > _CKIPPER_SETUP_PROMPTS_VALUE_MAX_WIDTH )); then + display_value="${display_value[1,_CKIPPER_SETUP_PROMPTS_VALUE_MAX_WIDTH-1]}…" + fi + printf '%s|%s|%s\n' "$key" "$display_value" "$source" + done < <(_ckipper_setup_prompts_global_keys) } # Print every global-scoped key one per line in lexical order. Used by the @@ -61,21 +94,48 @@ _ckipper_setup_prompts_global_keys() { done } -# Render the "detected configuration" summary table. Emits a styled header, -# followed by a SETTING | VALUE | SOURCE row per global-scoped key. Account -# keys are skipped because their effective value depends on which account the -# wizard is about to configure. +# Render the detected-configuration summary as a `gum table -p` styled +# table — auto-sizes columns to the longest value in each column and +# adapts to the terminal width. Schema descriptions are intentionally +# omitted from the summary (they were a 4th-column overflow problem in +# both prior layouts); descriptions surface as labels in the +# pick-keys-to-customize picker (``), so the user +# sees them at the moment they're deciding what to change. +# +# Falls back to a plain-text two-column layout under CKIPPER_NO_GUM (tests, +# non-TTY callers, and runners without gum installed). # # Returns: 0 always. _ckipper_setup_prompts_summary() { _core_style_header "$_CKIPPER_SETUP_PROMPTS_HEADER" - local key - { - while IFS= read -r key; do - _ckipper_setup_prompts_summary_row "$key" - done < <(_ckipper_setup_prompts_global_keys) - } | _core_style_table SETTING VALUE SOURCE - _core_style_divider + if _ckipper_setup_prompts_use_gum; then + _ckipper_setup_prompts_summary_rows \ + | gum table -p -s '|' --border rounded \ + --border.foreground "$_CKIPPER_SETUP_PROMPTS_BORDER_FG" + else + _ckipper_setup_prompts_summary_fallback + fi + _core_style_color dim \ + "Tip: pick a setting below to see its description and edit it." +} + +# Plain-text fallback when gum is unavailable. Renders the same data as +# ` ` — no borders, no colors, but parsable for +# tests and non-TTY callers. +# +# Returns: 0 always. +_ckipper_setup_prompts_summary_fallback() { + # Hoist loop locals outside the body — re-declaring `local var` (no + # =value) on iteration N>1 makes zsh print `var='prior_value'` since + # the variable carries a value from the previous iteration. Same idiom + # used in lib/worktree/worktree.zsh's list helper for the same reason. + local row="" key="" value="" source="" first=1 + while IFS= read -r row; do + (( first )) && { first=0; continue; } # skip header row + IFS='|' read -r key value source <<<"$row" + printf '%-*s %-28s %s\n' \ + "$_CKIPPER_SETUP_PROMPTS_KEY_WIDTH" "$key" "$value" "$source" + done < <(_ckipper_setup_prompts_summary_rows) } # Decide whether to use gum for the picker. Mirrors `_core_prompt_use_gum` but @@ -106,8 +166,12 @@ _ckipper_setup_prompts_pick_keys_fallback() { # keys, one per line, in schema order. _ckipper_setup_prompts_pick_keys() { if _ckipper_setup_prompts_use_gum; then - _ckipper_setup_prompts_global_keys \ - | gum choose --no-limit --header "Pick keys to customize" + local key + while IFS= read -r key; do + printf '%s — %s\n' "$key" "${_CKIPPER_SCHEMA_DESCRIPTION[$key]}" + done < <(_ckipper_setup_prompts_global_keys) \ + | gum choose --no-limit --header "$_CKIPPER_SETUP_PROMPTS_PICKER_HEADER" \ + | awk '{print $1}' return 0 fi _ckipper_setup_prompts_pick_keys_fallback diff --git a/lib/setup/prompts_test.bats b/lib/setup/prompts_test.bats index 81ff702..f8aa0ca 100644 --- a/lib/setup/prompts_test.bats +++ b/lib/setup/prompts_test.bats @@ -67,6 +67,27 @@ _run_prompts() { [[ "$output" != *"ssh_forward"* ]] } +# Regression: descriptions intentionally do NOT appear in the summary +# anymore — they live in the pick-keys-to-customize picker labels (added +# in PR #43) so the user sees them at the moment they decide what to +# change. The summary stays compact and renders cleanly through gum's +# styled table. This test pins the new contract: descriptions in picker, +# not summary. +@test "_ckipper_setup_prompts_summary does not embed descriptions inline" { + _run_prompts "" "_ckipper_setup_prompts_summary" + + [ "$status" -eq 0 ] + [[ "$output" != *"Bool. true = installer auto-adds the per-account aliases source line"* ]] + [[ "$output" != *"Comma-separated int list. Container ports to forward to the host."* ]] +} + +@test "_ckipper_setup_prompts_summary points at the picker for descriptions" { + _run_prompts "" "_ckipper_setup_prompts_summary" + + [ "$status" -eq 0 ] + [[ "$output" == *"description"* ]] || [[ "$output" == *"Tip:"* ]] +} + @test "_ckipper_setup_prompts_summary marks set values as (your config) and unset as (default)" { echo 'CKIPPER_NOTIFY_BELL="false"' >"$CKIPPER_DIR/docker/ckipper-config.zsh" diff --git a/lib/worktree/worktree.zsh b/lib/worktree/worktree.zsh index 09beac9..adc3ae3 100644 --- a/lib/worktree/worktree.zsh +++ b/lib/worktree/worktree.zsh @@ -3,6 +3,18 @@ readonly _CKIPPER_WT_FIND_MAX_DEPTH=3 +# Directory names pruned during the worktree-list scan. Branches can contain +# slashes (e.g. `feature/foo`), so we cannot bound the search by depth — but +# we CAN skip the heavy build/cache trees that inflate scan time without +# containing real worktrees. Empirically takes a 6 GB / 380k-file worktrees +# tree from ~700 ms to ~30 ms. Also prunes `.git` directories (the parent +# repo's, not the worktree's `.git` *file*) so we don't descend into git's +# internal storage. Add new entries as the worktrees tree grows. +readonly _CKIPPER_WT_PRUNE_DIRS=( + .git node_modules .next .nuxt dist build target + .venv venv __pycache__ vendor .turbo .cache +) + # Print all worktrees under $CKIPPER_WORKTREES_DIR, grouped by project. # # Reads CKIPPER_PROJECTS_DIR and CKIPPER_WORKTREES_DIR globals. @@ -11,16 +23,19 @@ _ckipper_worktree_list_worktrees() { [[ ! -d "$CKIPPER_WORKTREES_DIR" ]] && return 0 local previous_project_for_grouping="" - find "$CKIPPER_WORKTREES_DIR" -name ".git" -type f -not -path "*/node_modules/*" 2>/dev/null \ + # Hoist loop locals out of the body: re-declaring `local var` (no =value) + # on a subsequent iteration causes zsh to print `var='prior_value'`, + # leaking lines onto the worktree list. + local wt_dir="" rel="" project="" after_first="" branch="" + _ckipper_worktree_find_worktree_git_files \ | sort \ | while IFS= read -r git_metadata_file; do - local wt_dir="${git_metadata_file:h}" - local rel="${wt_dir#$CKIPPER_WORKTREES_DIR/}" - local project="${rel%%/*}" - local after_first="${rel#*/}" + wt_dir="${git_metadata_file:h}" + rel="${wt_dir#$CKIPPER_WORKTREES_DIR/}" + project="${rel%%/*}" + after_first="${rel#*/}" [[ "$after_first" == "$rel" ]] && continue - local branch IFS=$'\t' read -r project branch < <(_ckipper_worktree_get_project_and_branch "$project" "$after_first") if [[ "$project" != "$previous_project_for_grouping" ]]; then @@ -32,6 +47,26 @@ _ckipper_worktree_list_worktrees() { done } +# Emit the `.git` files of every worktree under $CKIPPER_WORKTREES_DIR, +# pruning known-heavy directories (see _CKIPPER_WT_PRUNE_DIRS) so the scan +# does not descend into node_modules / dist / build / etc. Worktrees mark +# their root with a `.git` *file* (a gitdir reference), not a directory — +# we test for `-type f` to filter accordingly. +# +# Args: none. +# Returns: 0 always; prints absolute paths to `.git` files, one per line. +_ckipper_worktree_find_worktree_git_files() { + local -a prune_args=() + local d + for d in "${_CKIPPER_WT_PRUNE_DIRS[@]}"; do + (( ${#prune_args} > 0 )) && prune_args+=(-o) + prune_args+=(-name "$d") + done + find "$CKIPPER_WORKTREES_DIR" \ + \( -type d \( "${prune_args[@]}" \) -prune \) \ + -o \( -type f -name .git -print \) 2>/dev/null +} + # Resolve project and branch from path components for nested-project worktrees. # # Args: diff --git a/lib/worktree/worktree_test.bats b/lib/worktree/worktree_test.bats index af01dd6..ba3cdc0 100644 --- a/lib/worktree/worktree_test.bats +++ b/lib/worktree/worktree_test.bats @@ -46,6 +46,63 @@ _run_worktree() { [[ "$output" =~ "Worktrees" ]] } +# Regression: scanning was unbounded and pruned only node_modules. With a +# 6 GB worktrees tree the scan took ~700 ms. Pruning the heavy build dirs +# (`dist`, `.next`, `target`, `__pycache__`, etc.) brings it to ~30 ms and +# avoids reporting any phantom `.git` files nested inside those trees. +@test "_ckipper_worktree_list_worktrees skips .git files inside pruned dirs" { + # Real worktree (should appear in output). + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/.git" + + # Phantom .git files buried inside dirs we should prune. Each filename + # is unique so we can assert it does NOT show up by name. + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/node_modules/pkg-a" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/node_modules/pkg-a/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/dist/pkg-b" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/dist/pkg-b/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature-x/__pycache__/pkg-c" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature-x/__pycache__/pkg-c/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" == *"feature-x"* ]] + [[ "$output" != *"pkg-a"* ]] + [[ "$output" != *"pkg-b"* ]] + [[ "$output" != *"pkg-c"* ]] +} + +# Regression: branches contain slashes (`feature/foo`, `fix/bar`) so the +# scan must not be depth-bounded — the pruned find still has to surface a +# worktree whose `.git` lives several levels deep. +@test "_ckipper_worktree_list_worktrees finds worktrees whose branch name contains slashes" { + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/OGD-320-deep-branch" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/OGD-320-deep-branch/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" == *"feature/OGD-320-deep-branch"* ]] +} + +# Regression: `local branch` (no assignment) inside the per-worktree loop +# behaves like `typeset -p branch` once `branch` carries a value from a +# prior iteration, leaking literal `branch='…'` lines onto stdout. The fix +# is `local branch=""`. Two worktrees are needed to trigger iteration N>1 +# on the same loop scope. +@test "_ckipper_worktree_list_worktrees does not leak local-redeclare echoes" { + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/one" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/one/.git" + mkdir -p "$CKIPPER_WORKTREES_DIR/myapp/feature/two" + touch "$CKIPPER_WORKTREES_DIR/myapp/feature/two/.git" + + _run_worktree "_ckipper_worktree_list_worktrees" + + [ "$status" -eq 0 ] + [[ "$output" != *"branch="* ]] +} + @test "_ckipper_worktree_remove_worktree fails when worktree path does not exist" { _run_worktree "_ckipper_worktree_remove_worktree myapp nonexistent-branch" 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; }