Skip to content

feat: Claude Desktop multi-instance support#45

Merged
mswdev merged 22 commits into
developfrom
feature/desktop-multi-instance
May 27, 2026
Merged

feat: Claude Desktop multi-instance support#45
mswdev merged 22 commits into
developfrom
feature/desktop-multi-instance

Conversation

@mswdev
Copy link
Copy Markdown
Owner

@mswdev mswdev commented May 27, 2026

Summary

Adds a new ckipper desktop subcommand 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-dir against /Applications/Claude.app and gets a generated .app wrapper 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

  • Six subcommands: add, list, remove, rename, launch, login. Short alias dt.
  • login dance for the claude:// deep-link auth-callback gotcha: pgrep all running Claude processes, SIGTERM (with SIGKILL fallback after 5s), then open -n -a only the target.
  • Generated .app bundles at ~/Applications/Claude-<Name>.app with Info.plist, launcher script (zsh exec open -n -a with --user-data-dir baked in at generation time), best-effort icon copy from /Applications/Claude.app, and lsregister -f for Spotlight indexing.
  • Separate registry ~/.ckipper/desktop.json (schema v1) with its own lock — lib/core/registry.zsh primitives parametrized via new _at variants so the locking + atomic-write code is shared.
  • Doctor coverage: ckipper doctor includes Desktop checks (Claude.app presence, registry shape, per-instance data dir + bundle validity, deep-link WARN at 2+ instances).
  • Tab completion for subcommands and instance names (bumped CKIPPER_COMPLETION_VERSION to 9).
  • Launcher menu (bare ck) gains "Launch a Desktop instance" / "List Desktop instances" / "Add a Desktop instance".
  • Setup completion-summary card mentions ckipper desktop add (discovery only — the wizard does NOT walk through Desktop setup).
  • README + CHANGELOG with the deep-link gotcha clearly called out in a > Note: callout.
  • Lint guards pin the new _ckipper_desktop_* namespace to lib/desktop/ and add lib/desktop/ to every existing sibling-feature isolation guard.

Explicitly NOT in (by design)

  • No desktop default, desktop sync, or per-instance preferences.
  • No --adopt flag on desktop add.
  • No doctor --fix repairs for Desktop (read-only checks).
  • No Linux / Windows support — dispatcher refuses on non-macOS hosts.

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 only lib/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-guards clean
  • bats --recursive . → 579/579 passing (+62 from this branch)
  • New per-module suites: bundle_test.bats (7), instance-management_test.bats (22+), launcher_test.bats (9), doctor_test.bats (6), dispatcher_test.bats (8)
  • Manual: ckipper desktop add work produces a launchable ~/Applications/Claude-Work.app on a real macOS host with /Applications/Claude.app installed
  • Manual: ckipper desktop login work cleanly quits other Claude processes and launches only work (verified that the OAuth callback lands in the lone running window)
  • Manual: ckipper doctor prints the new "Desktop instances" section
  • Manual: Spotlight + Dock recognize the generated .app bundle (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.

Phase Commits
Foundation 5088507 registry refactor · 4691067 desktop constants
Scaffold f5f1d75 dispatcher + help · 1ae91fb test tightening
Bundle 9e6029b generator · 21e00c9 3-param cap fix
CRUD 5d4c4da add · dbf8dd8 list · 14e7107 remove · 641fdcf rename · a53c326 polish
Launcher 50c26e5 assert-not-running · cde3db4 login · c572783 launch
Doctor c843203
Polish be49835 lint guards · 77d44c5 completion · 32c71b3 menu · b567d81 setup card
Docs 1c2dfca README + CHANGELOG

mswdev added 22 commits May 27, 2026 15:38
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.
@mswdev mswdev marked this pull request as ready for review May 27, 2026 23:54
@mswdev mswdev merged commit 6f56cc8 into develop May 27, 2026
1 check passed
@mswdev mswdev deleted the feature/desktop-multi-instance branch May 28, 2026 00:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant