Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
de78e4d
fix(sync): hint SPACE/ENTER when interactive picker returns empty
mswdev May 4, 2026
c85466c
fix(worktree): speed up `worktree list` and stop leaking branch=… lines
mswdev May 4, 2026
903ce2a
fix(worktree): speed up `worktree list` and stop leaking branch=… lines
mswdev May 4, 2026
f2f5594
Merge pull request #41 from mswdev/feature/sync-empty-pick-hint
mswdev May 5, 2026
48c27f6
fix: 1.0 polish — setup wizard UX, prompt cancel, project depth, dock…
mswdev May 5, 2026
bf60408
Merge pull request #42 from mswdev/fix/worktree-list-perf
mswdev May 5, 2026
6dbc92b
fix(setup,config,core): address PR #43 review
mswdev May 5, 2026
e00903a
Merge pull request #43 from mswdev/fix/setup-and-prompt-1.0-polish
mswdev May 5, 2026
22fac90
fix(setup,sync): 1.0 polish round 2 — layout, completion handoff, can…
mswdev May 5, 2026
6cda4b7
fix(setup): style detected-config + completion screen with gum
mswdev May 5, 2026
93afe7a
Merge pull request #44 from mswdev/fix/setup-1.0-polish-round-2
mswdev May 5, 2026
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
6f56cc8
Merge pull request #45 from mswdev/feature/desktop-multi-instance
mswdev May 27, 2026
3c61311
docs(readme): surface Desktop multi-instance in intro + Solution + co…
mswdev May 28, 2026
b1672b0
docs(readme): restructure for Desktop as peer feature
mswdev May 28, 2026
b21c8a4
Merge pull request #46 from mswdev/docs/readme-desktop-mention
mswdev May 28, 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
87 changes: 83 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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`)

Expand All @@ -46,6 +65,7 @@ cd Ckipper
| `ck run <project> <branch>` | 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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -196,7 +216,7 @@ ckipper account sync personal work --include all --yes

Every destructive write is preceded by a copy to `<dst>/.ckipper-sync-backups/<UTC-ISO-ts>-from-<source>/`. 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
Expand All @@ -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 `<account>/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-<Name>.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-<name>/` | `~/.claude-desktop-<name>/` |
| 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 <name>` | _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 <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. 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-<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).

### 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
Expand Down
Loading
Loading