feat: Claude Desktop multi-instance support#45
Merged
Conversation
Add _core_registry_update_at / _init_at / _check_version_at / _migrate_v1_to_v2_at variants that take an explicit registry file path; existing zero-arg wrappers delegate with $CKIPPER_REGISTRY as default. Lock paths and tmpfiles derive from the file path so multiple registries (accounts.json, desktop.json) do not contend on a shared lock. Prep for the desktop multi-instance feature, which needs its own registry file.
$CKIPPER_DIR/desktop.json at version 1. Used by the new lib/desktop/ feature in subsequent commits.
Adds the routing skeleton for the new `ckipper desktop` subcommand namespace. Subcommand handlers are stubbed and will be implemented in subsequent commits. - New feature dir lib/desktop/ with dispatcher.zsh + help.zsh - New top-level command 'desktop' with short alias 'dt' - macOS-only guard at the dispatcher entry - Per-subcommand --help / -h is short-circuited to focused help text
The previous 'ckipper desktop add' / 'claude://' assertions also appeared
in the overview help, so a silent fall-through from per-subcommand help
to the overview would not have failed the test. Match on phrases that
only exist in the per-subcommand help block ('Prerequisite:' for add,
'Why this exists:' for login) so the routing is actually under test.
Generates a minimal macOS application bundle at the given path with: - Info.plist (CFBundleExecutable, Identifier, Name, IconFile when icon copied) - Contents/MacOS/launcher (zsh script execing /Applications/Claude.app with --user-data-dir baked in at generation time, not path-walked) - Contents/Resources/AppIcon.icns (best-effort copy from system app) - Launch Services indexing via lsregister -f (best-effort) Display name is title-cased (Claude-Work.app) while the canonical name and bundle identifier suffix stay lowercase. lsregister path is the full system path (not in $PATH) — overridable via _CKIPPER_TEST_LSREGISTER for tests. Source Claude.app path overridable via _CKIPPER_TEST_CLAUDE_APP.
The display name was derived solely from the canonical name; compute it inside _write_plist via _title_case rather than threading it through the orchestrator. Brings every function back to the project's 3-param limit (.claude/rules/code-style.md). No behavior change; 7/7 tests still pass.
Registers a new Claude Desktop instance: creates ~/.claude-desktop-<name>/, writes a Claude-<Name>.app bundle to ~/Applications/, and records the entry in ~/.ckipper/desktop.json. Validates the name regex, refuses on duplicates, requires /Applications/Claude.app to be installed, and refuses if the bundle path already exists. Prints a deep-link routing tip when this brings the instance count to two or more. Also demotes _CKIPPER_DESKTOP_SYSTEM_APP in bundle.zsh to honor an env-supplied override (was a plain assignment that overwrote the inherited value on source) so tests and the new Claude.app-presence assertion can point it at a fake bundle.
Prints registered Desktop instances in a column layout: name, data dir, bundle path, registered_at, running/stopped status. Status comes from pgrep against the --user-data-dir cmdline argument so list reflects the same probe that desktop remove/rename will use to refuse on live instances.
Unregisters a Desktop instance from the registry, then interactively prompts to delete the user-data dir (default N — preserves user data: chats, settings, OAuth tokens) and the .app bundle (regeneratable via desktop add). Refuses if a Claude Desktop process is currently running against that user-data-dir. The not-running probe is _ckipper_desktop_assert_not_running. Task 9 will likely consolidate or extend it (e.g., richer process info); for now it lives next to its only callers (remove and the upcoming rename).
Renames a Desktop instance in place: moves the data dir, regenerates the .app bundle under the new name, and atomically swaps the registry entry. Refuses if the instance is running or if the destination name is already taken, validates the new name against the lowercase regex, and rolls back the directory move + bundle regeneration if the registry update fails. Helper splits (rename_validate / rename_perform_fs / rename_swap_registry / rename_rollback_fs) keep every function inside the 25-line / 2-nesting / 3-param caps from .claude/rules/code-style.md.
Two small cleanups surfaced by the Task 5-8 self-review: - Extract the literal "2" in _ckipper_desktop_add_announce into a named constant (_CKIPPER_DESKTOP_DEEP_LINK_TIP_THRESHOLD) — the project's NO_MAGIC_NUMBERS rule has no exceptions, and the threshold has a domain meaning worth a doc comment. - Replace the brittle '[Nn]othing\ to\ do' regex in the rename identical-names test with a quoted literal substring match. Bash regex's '\ ' is interpretation-dependent; double-quoted strings on the RHS of =~ are guaranteed literal in bash 3.2+ (bats's floor on macOS).
The helper was placed in instance-management.zsh during Tasks 7-8 as a temporary measure. Its proper home is lib/desktop/launcher.zsh alongside the launch / login functions that share the same pgrep semantics. Callers (remove, rename) are unchanged — function name is the same. Adds dedicated bats tests for the helper. Establishes launcher.zsh as the new launcher module with module-level timing constants used by Task 10's login dance. Also lifts _install_fake_claude_app from instance-management_test.bats into tests/lib/test-helper.bash so the new launcher_test.bats (and future test files) can share it.
Quits ALL running Claude Desktop processes via SIGTERM (with SIGKILL fallback after a 5s timeout), then opens only the target wrapper bundle. Works around the macOS claude:// deep-link auth-callback routing gotcha: with multiple instances running, the OAuth callback lands in whichever Claude app was most recently active. Quitting everything first guarantees the callback has only one place to land. Timing constants are typeset -g (not readonly) so tests can shrink the timeout for fast feedback. Removes the Task 10 stub from dispatcher.zsh.
Opens the registered Desktop instance via open -n -a on its wrapper bundle. Unlike desktop login, this does NOT quit other running Claude instances — use it when you know auth flows aren't in play. Removes the Task 11 stub from dispatcher.zsh, which now contains zero "not yet implemented" stubs; all six desktop subcommands (add, list, remove, rename, login, launch) are live.
New lib/desktop/doctor.zsh runs after the account/tooling doctor: checks /Applications/Claude.app presence (FAIL only if instances exist), desktop.json schema, per-instance data_dir + .app bundle existence, plus an Info.plist parse via plutil when available. Warns when 2+ instances are registered (the claude:// deep-link reminder). Module is feature-isolated: uses lib/core/* helpers only, with its own fail/warn counters — does not touch the account-namespace doctor helpers. Top-level dispatcher composes the exit codes via || rc=1.
Adds the merge-guard for the new desktop namespace and extends every existing feature-isolation guard's target list to include lib/desktop/ so sibling features (account, worktree, config) can't reach into it and desktop can't reach back. Orchestration dirs (launcher, setup, run) remain exempt per the dispatcher-exception pattern in shell-conventions.md. Updates shell-conventions.md's prefix inventory + guard list.
Bumps CKIPPER_COMPLETION_VERSION 8 → 9 so installed shells regenerate. Adds 'desktop' / 'dt' to the top-level command list and a new desktop_subs array. Instance-name arg3 completion reads keys from ~/.ckipper/desktop.json.
Adds 'Launch a Desktop instance', 'List Desktop instances', and 'Add a Desktop instance' to the launcher menu. 'Launch' uses a new helper that prompts the user to pick from registered instances; the other two delegate directly to _ckipper_desktop_dispatch. Updates menu test fixture for the new option count.
Single line added to the 'Getting started:' section of both the gum and plain post-setup summary cards. Discovery-only; the wizard does not walk through Desktop setup (CLI and Desktop are configured independently per the design).
New 'Claude Desktop instances' section in README between 'Multiple accounts' and 'Sync state between accounts'. Covers add/list/launch/ rename/remove, the claude:// deep-link gotcha + login workaround, on- disk layout, and doctor coverage. Reinforces that CLI accounts and Desktop instances are independently configured. CHANGELOG entry under the Unreleased section listing the new namespace, the login dance, doctor integration, the desktop.json registry, the registry.zsh refactor that supports it, and the completion version bump.
Applied review findings (in-feature dedup + small leaks). Deferred
cross-feature extractions to lib/core/ (doctor-render, path-tildify,
name-validator) out of scope for this PR.
- lib/core/registry.zsh: parametrized _at form's auto-migration message
uses ${registry_file:t} instead of the hard-coded 'accounts.json'
string (would have lied for future non-accounts migrations). Doc
headers updated to match.
- lib/desktop/doctor.zsh: deleted _ckipper_desktop_doctor_instance_count
(byte-identical duplicate of _ckipper_desktop_instance_count in
instance-management.zsh — same feature dir, no isolation rule).
Removed duplicate _CKIPPER_DESKTOP_DOCTOR_DEEP_LINK_THRESHOLD and
consume the existing _CKIPPER_DESKTOP_DEEP_LINK_TIP_THRESHOLD from
instance-management.zsh.
- lib/desktop/launcher.zsh: _ckipper_desktop_lookup_bundle collapsed
from 2 jq invocations to 1 via jq's 'error()' on missing-key — on
the launch / login hot path.
- lib/desktop/bundle.zsh: extracted _CKIPPER_DESKTOP_BUNDLE_VERSION
constant; CFBundleShortVersionString + CFBundleVersion both reference
it (last magic string in the file).
…sion The .app bundle's Contents/MacOS/launcher used double-quoted strings for --user-data-dir and the system app path, so any $VAR / backtick / "$(…)" in the baked-in paths would be re-expanded at runtime when the wrapper bundle launches. Currently unreachable — instance names are regex-validated to ^[a-z0-9_-]+$ and the path is $HOME/.claude-desktop-<name>, so neither $ nor backticks can appear. Defense-in-depth fix per PR #45 code review: switch to single-quoted output and escape any embedded single quotes via the standard '\\''-replacement idiom. Test updated to assert the single-quote form. No behavior change in any supported scenario.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new
ckipper desktopsubcommand namespace for managing isolated Claude Desktop (Electron app) instances on macOS, alongside the existing CLI multi-account support. Each instance is launched via--user-data-diragainst/Applications/Claude.appand gets a generated.appwrapper bundle that shows up in Spotlight and the Dock.CLI accounts (
ckipper account *) and Desktop instances (ckipper desktop *) are intentionally configured separately — no shared subcommands, no shared registry, no shared preferences.What's in
add,list,remove,rename,launch,login. Short aliasdt.logindance for theclaude://deep-link auth-callback gotcha: pgrep all running Claude processes, SIGTERM (with SIGKILL fallback after 5s), thenopen -n -aonly the target..appbundles at~/Applications/Claude-<Name>.appwith Info.plist, launcher script (zshexec open -n -awith--user-data-dirbaked in at generation time), best-effort icon copy from/Applications/Claude.app, andlsregister -ffor Spotlight indexing.~/.ckipper/desktop.json(schema v1) with its own lock —lib/core/registry.zshprimitives parametrized via new_atvariants so the locking + atomic-write code is shared.ckipper doctorincludes Desktop checks (Claude.app presence, registry shape, per-instance data dir + bundle validity, deep-link WARN at 2+ instances).CKIPPER_COMPLETION_VERSIONto 9).ck) gains "Launch a Desktop instance" / "List Desktop instances" / "Add a Desktop instance".ckipper desktop add(discovery only — the wizard does NOT walk through Desktop setup).> Note:callout._ckipper_desktop_*namespace tolib/desktop/and addlib/desktop/to every existing sibling-feature isolation guard.Explicitly NOT in (by design)
desktop default,desktop sync, or per-instance preferences.--adoptflag ondesktop add.doctor --fixrepairs for Desktop (read-only checks).Architecture
New feature dir
lib/desktop/with 6 source files:dispatcher.zsh,bundle.zsh,instance-management.zsh,launcher.zsh,doctor.zsh,help.zsh. Function namespace_ckipper_desktop_*. Calls onlylib/core/*helpers — never reaches into sibling feature dirs (lib/account/,lib/worktree/,lib/config/).Design + per-task implementation plan are in
docs/plans/2026-05-27-desktop-multi-instance-{design,implementation-plan}.md(gitignored).Test plan
make lint-shell lint-zsh lint-fmt lint-merge-guardscleanbats --recursive .→ 579/579 passing (+62 from this branch)bundle_test.bats(7),instance-management_test.bats(22+),launcher_test.bats(9),doctor_test.bats(6),dispatcher_test.bats(8)ckipper desktop add workproduces a launchable~/Applications/Claude-Work.appon a real macOS host with/Applications/Claude.appinstalledckipper desktop login workcleanly quits other Claude processes and launches onlywork(verified that the OAuth callback lands in the lone running window)ckipper doctorprints the new "Desktop instances" section.appbundle (lsregister -f)Commit map
20 commits, one logical change per commit; designed for clean cherry-pick or revert if any sub-step needs to back out.
5088507registry refactor ·4691067desktop constantsf5f1d75dispatcher + help ·1ae91fbtest tightening9e6029bgenerator ·21e00c93-param cap fix5d4c4daadd ·dbf8dd8list ·14e7107remove ·641fdcfrename ·a53c326polish50c26e5assert-not-running ·cde3db4login ·c572783launchc843203be49835lint guards ·77d44c5completion ·32c71b3menu ·b567d81setup card1c2dfcaREADME + CHANGELOG