Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5088507
refactor(core): parametrize registry primitives on file path
mswdev May 27, 2026
4691067
feat(core): declare CKIPPER_DESKTOP_REGISTRY constants
mswdev May 27, 2026
f5f1d75
feat(desktop): scaffold lib/desktop/ dispatcher + help
mswdev May 27, 2026
1ae91fb
test(desktop): tighten dispatcher --help routing assertions
mswdev May 27, 2026
9e6029b
feat(desktop): add .app bundle generator
mswdev May 27, 2026
21e00c9
refactor(desktop): drop 4th param from bundle_write_plist (3-param cap)
mswdev May 27, 2026
5d4c4da
feat(desktop): implement desktop add
mswdev May 27, 2026
dbf8dd8
feat(desktop): implement desktop list
mswdev May 27, 2026
14e7107
feat(desktop): implement desktop remove
mswdev May 27, 2026
641fdcf
feat(desktop): implement desktop rename
mswdev May 27, 2026
a53c326
refactor(desktop): name deep-link threshold + tighten rename test regex
mswdev May 27, 2026
50c26e5
refactor(desktop): relocate _assert_not_running to launcher.zsh
mswdev May 27, 2026
cde3db4
feat(desktop): implement desktop login (deep-link auth dance)
mswdev May 27, 2026
c572783
feat(desktop): implement desktop launch
mswdev May 27, 2026
c843203
feat(desktop): add doctor checks + wire into top-level doctor
mswdev May 27, 2026
be49835
chore(lint): pin _ckipper_desktop_ namespace to lib/desktop/
mswdev May 27, 2026
77d44c5
feat(completion): add desktop subcommand + instance-name completion
mswdev May 27, 2026
32c71b3
feat(launcher): surface Desktop entries in the bare-ck menu
mswdev May 27, 2026
b567d81
feat(setup): mention 'ckipper desktop add' in completion summary
mswdev May 27, 2026
1c2dfca
docs(desktop): document multi-instance support and deep-link gotcha
mswdev May 27, 2026
d5a29fc
refactor(desktop): /simplify follow-up cleanups
mswdev May 27, 2026
85509f3
fix(desktop): quote generated launcher paths against runtime re-expan…
mswdev May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions .claude/rules/shell-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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.

Expand All @@ -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.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>/`) and a generated `.app` wrapper bundle (`~/Applications/Claude-<Name>.app`) that shows up in Spotlight and the Dock.
- **New:** Subcommands `add`, `list`, `remove`, `rename`, `launch`, `login`.
- **New:** `ckipper desktop login <name>` 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.
Expand Down
15 changes: 8 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,45 @@ Two terminals running the **same** account simultaneously will hit a known OAuth

If you want concurrent runs of the *same* account, register it twice under two names (`personal-a`, `personal-b`) — though this means re-`/login` for each.

## Claude Desktop instances

Ckipper also manages multiple isolated Claude **Desktop** (Electron app) instances on macOS via the `--user-data-dir` flag. Each instance is a fully isolated sandbox — separate auth, MCP servers, projects, conversation history, Cowork VM — and shows up in Spotlight and the Dock as `Claude-<Name>.app`.

CLI accounts (`ckipper account *`) and Desktop instances (`ckipper desktop *`) are independent and configured separately. An account named `work` and a Desktop instance named `work` share nothing but the name.

### Add an instance

```bash
ckipper desktop add work
```

Creates `~/.claude-desktop-work/` (user-data dir) and `~/Applications/Claude-Work.app` (wrapper bundle whose launcher exec's `open -n -a /Applications/Claude.app --args --user-data-dir=…`). Requires `/Applications/Claude.app` to be installed.

### Use an instance

```bash
ckipper desktop launch work # open the instance (also works from Spotlight / Dock)
ckipper desktop list # see registered instances + running status
ckipper desktop rename work prod # rename in place
ckipper desktop remove work # interactively prompt to delete user-data dir + bundle
```

> **Note: claude:// deep-link auth gotcha.** macOS routes `claude://` URLs (the OAuth callback used by `/login`) to whichever Claude app was most recently active. With two or more Desktop instances running, the callback can land in the wrong window. `ckipper desktop login <name>` works around this by quitting *every* running Claude process and launching only the target — complete `/login` there, then re-open the others as needed. This is a one-time-per-instance setup cost; once authenticated, instances run side by side indefinitely.

```bash
ckipper desktop login work # quit all, launch only 'work' — safe for /login flows
```

### How instances are stored

- Per-instance data lives in `~/.claude-desktop-<name>/` (Electron `userData` dir).
- Generated `.app` wrappers live in `~/Applications/Claude-<Name>.app` (per-user, no admin required). The launcher script bakes `--user-data-dir` in at generation time — no runtime path-walking.
- The registry mapping instance names to dirs and bundles lives at `~/.ckipper/desktop.json` (separate file from `accounts.json`, separate schema version).

### Diagnostics

`ckipper doctor` runs Desktop checks alongside the account checks: `/Applications/Claude.app` is installed (if any instances are registered), `desktop.json` is well-formed, each instance's data dir + `.app` bundle exist and parse, and a warning fires when two or more instances are registered (the deep-link reminder).

## Sync state between accounts

`ckipper account sync` copies state between registered accounts — MCP servers, settings, agents, commands, skills, user hooks, etc. — interactively by default, with one source and one or more destinations.
Expand Down
49 changes: 44 additions & 5 deletions ckipper.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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"
Expand All @@ -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`)
Expand Down Expand Up @@ -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 ;;
Expand Down Expand Up @@ -179,6 +194,7 @@ _ckipper_help() {
" ckipper worktree <subcommand> Manage git worktrees (alias: wt)" \
" ckipper run <project> <branch> Shortcut for \`ckipper worktree run\`" \
" ckipper config <subcommand> View and modify Ckipper settings" \
" ckipper desktop <subcommand> 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" \
Expand Down Expand Up @@ -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."
}
Expand All @@ -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
Expand All @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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' \
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading