Skip to content

Instantly share code, notes, and snippets.

@aoxborrow
Last active August 18, 2026 07:48
Show Gist options
  • Select an option

  • Save aoxborrow/5a3a0ac0aa37819cc19439b70a552221 to your computer and use it in GitHub Desktop.

Select an option

Save aoxborrow/5a3a0ac0aa37819cc19439b70a552221 to your computer and use it in GitHub Desktop.
Multiple Claude Desktop instances on macOS — color-coded, no-duplicate launchers (personal + work accounts)

Multiple Claude Desktop instances on macOS — with color-coded, no-duplicate launchers

Run two (or more) fully isolated Claude Desktop instances side by side on macOS — e.g. one signed into your personal account and one into your work account — each with its own color-coded Dock launcher that focuses the existing window instead of spawning duplicates.

This builds on Philipp Stracker's excellent --user-data-dir approach (write-up) and adds two things:

  1. Recolored icons so you can tell the launchers apart at a glance (blue work / red personal / etc.).
  2. A smart, self-contained launcher that fixes the "every click opens another window" problem.

⭐ Recommended: give every account its own isolated instance

The most reliable setup is to create a separate, dedicated instance for each account — including your "personal" one — and stop using Claude's built-in default profile for day-to-day use. Don't try to make one launcher target the shared default profile.

Why: Claude has no per-profile single-instance lock, so two processes can run on one profile at once — and only the first instance on a profile can use its login database. Every additional instance on that same profile shows up logged out. If a "personal" launcher points at the default profile (which the stock Claude icon also uses), you end up with duplicate, logged-out windows. Giving each account its own dir guarantees one profile → one instance → always logged in, and makes the focus-don't-duplicate logic rock-solid.

Already logged into the default profile and don't want to re-auth? See Migrating an existing login — you can copy your current profile into the new dedicated dir.


How it works

Claude Desktop is an Electron app. Electron's --user-data-dir flag points the app at a completely separate profile folder (config, sessions, auth tokens, MCP servers, Cowork data):

open -n -a "/Applications/Claude.app" --args --user-data-dir="$HOME/.claude-instances/work"

A tiny macOS .app "wrapper" just runs that command, so it shows up in Launchpad/Dock like a normal app. Each wrapper stores its instance name in Contents/MacOS/config.sh and runs Contents/MacOS/claude-launcher.

The duplicate problem (and the fix)

The naive launcher uses open -n — the -n flag means "open a NEW instance, even if one is already running." Claude has no per-profile single-instance lock, so every click spawns another process against the same profile dir. That stacks up duplicate windows and risks corrupting the shared profile.

The smart launcher in this gist fixes it. On click it inspects that specific instance (matched by --user-data-dir; for the default instance, the main process that has no --user-data-dir) and does one of three things:

  • Running with a window → focuses that process's window by PID via System Events. No duplicate.
  • Running but windowless → opens a fresh window. Claude keeps its main process alive after you close the window, and a windowless instance cannot be re-shown per-instance (all instances share one bundle id, so open -a / "activate" can't target it). We deliberately do not kill the windowless process: it is often windowless precisely because it's still doing background work (e.g. a chat that's "thinking"), and killing it would lose that work. The leftover windowless process is harmless and goes away when you fully Quit (⌘Q) Claude.
  • Not running → launches fresh with open -n.

How "has a window?" is detected: a live window shows up as one or more --type=renderer processes for that profile; closing the window drops that count to 0.

So "always click the launcher you want" reliably focuses-or-launches the right instance, even after you've closed its window.

Tip: to keep the focus-don't-relaunch behavior working, close Claude with ⌘W (close window, app keeps running) when you want it to stay live, and ⌘Q when you truly want it gone.

What the colors do (and don't do)

The recolored icon lives on the launcher you click. Because all instances share the one /Applications/Claude.app binary, while running they all show the same stock Dock icon and the name "Claude" — the color only marks the launcher. The reliable in-app tell is the signed-in account. (Truly distinct running Dock icons would require a full standalone copy of Claude.app.)


Quick start

# 0. Have Claude Desktop installed at /Applications/Claude.app  (https://claude.ai/download)

# 1. Download the scripts from this gist, then make the helper executable
chmod +x make-claude-instance.sh

# 2. Create a WORK instance (isolated profile) with a BLUE launcher
./make-claude-instance.sh work Work 135

# 3. Create a PERSONAL instance (its own isolated profile) with a RED-ORANGE icon
./make-claude-instance.sh personal Personal -7

# 4. Launch from Launchpad / drag to Dock:  "Claude Work"  and  "Claude Personal"
#    First launch of each is a fresh profile -> sign into the right account once.
  • The first time a launcher focuses a running window, macOS asks "Claude … wants to control System Events" → click OK (one time per launcher).
  • Give every instance its own isolated dir — use a real name like personal / work, not default. One profile → one instance (see the ⭐ callout above for why).
  • The instance name default is still supported (it uses Claude's built-in profile with no --user-data-dir) for folks who want one launcher onto the stock profile — but it's only safe if that profile is never run by more than one instance at a time (i.e. don't also use the stock Claude icon). A dedicated dir is the robust, recommended choice.

Color cheatsheet (hue shift, in 0–255 = 0–360° units)

Look hueShift satGain (optional)
Stock orange 0 1.0
Red-orange -7 1.08
Green +80 1.0
Blue +135 1.0
Purple +170 1.0

Icon recoloring is optional — it only runs if Python 3 + Pillow are available (pip install Pillow). Without them you still get a fully working launcher with the stock icon.


Migrating an existing login (optional)

If you're already signed into Claude's default profile and want your new dedicated instance to start out logged in (with your settings, MCP servers, and sessions intact), copy the profile across while Claude is fully quit so the copy is consistent:

# 1. Quit Claude completely first (⌘Q every window; make sure nothing is running):
ps -axo command | grep "[C]laude.app/Contents/MacOS/Claude"   # should print nothing

# 2. Copy the default profile into your new dedicated dir, skipping lock files + caches:
rsync -a \
  --exclude 'Singleton*' \
  --exclude 'Cache' --exclude 'Code Cache' --exclude 'GPUCache' \
  --exclude 'DawnGraphiteCache' --exclude 'DawnWebGPUCache' --exclude 'ShaderCache' \
  --exclude 'Crashpad' --exclude 'blob_storage' \
  "$HOME/Library/Application Support/Claude/"  "$HOME/.claude-instances/personal/"

Your original default profile is left untouched (the stock Claude icon still opens it). The auth token lives in the copied profile (and/or the shared macOS Keychain), so the new instance should launch already signed in. If it doesn't, just sign in once — it sticks from then on.

Heads-up on size: the local-agent / Cowork runtime lives in a vm_bundles/ folder that can be many GB. The copy above includes it. To save disk, you can rm -rf the vm_bundles folder in the new dir afterward — it re-downloads on demand the next time you use that feature.

Managing instances

# List running Claude instances and their profiles
ps -axo pid,command | grep "[C]laude.app/Contents/MacOS/Claude"

# Remove a wrapper + its profile
rm -rf "/Applications/Claude Work.app"
rm -rf "$HOME/.claude-instances/work"

Files in this gist

  • make-claude-instance.sh — builds the .app wrapper (Info.plist, config, smart launcher) and optionally recolors the icon.
  • claude-launcher — the self-contained smart launcher template (embedded into each wrapper).
  • recolor-icon.py — hue/saturation recolor of the .icns (used by the helper; needs Pillow).

Credits

Core --user-data-dir isolation technique by Philipp Stracker. Smart focus-or-launch behavior and icon recoloring added here.

#!/bin/bash
# Self-contained smart launcher for an isolated Claude Desktop instance.
# Reads INSTANCE_NAME from the adjacent config.sh.
#
# Behavior on click:
# - Instance running WITH a window -> focus that window (no duplicate).
# - Instance running but WINDOWLESS -> open a fresh window WITHOUT killing the
# existing process. Claude keeps its main process alive after you close the
# window, often because it's still doing background work (a chat "thinking");
# killing it would lose that work, and a windowless instance can't be
# re-shown per-instance (shared bundle id). Leftover windowless processes are
# harmless and clear on Quit (Cmd-Q).
# - Instance not running -> launch fresh.
#
# Process-matching notes (verified empirically):
# * Claude helper processes use the SAME Contents/MacOS/Claude binary AND carry
# --user-data-dir. The only reliable discriminator is that helpers have a
# --type= flag and the main process does not.
# * A live window appears as one or more --type=renderer processes for that
# profile; closing the window drops the renderer count to 0.
source "$(dirname "$0")/config.sh"
CLAUDE_APP="/Applications/Claude.app"
INSTANCES_BASE="$HOME/.claude-instances"
main_pid() {
if [ "$INSTANCE_NAME" = "default" ]; then
ps -axww -o pid,command \
| grep -E "[C]laude\.app/Contents/MacOS/Claude( |$)" \
| grep -v -- "--type=" | grep -v -- "--user-data-dir" \
| awk '{print $1}' | head -1
else
ps -axww -o pid,command \
| grep -F -- "--user-data-dir=$INSTANCES_BASE/$INSTANCE_NAME" \
| grep -v -- "--type=" \
| awk '{print $1}' | head -1
fi
}
renderer_count() {
if [ "$INSTANCE_NAME" = "default" ]; then
ps -axww -o command | grep -- "--type=renderer" \
| grep -F -- "Claude.app/Contents/MacOS/Claude" \
| grep -v -F -- "--user-data-dir=$INSTANCES_BASE/" | grep -c .
else
ps -axww -o command | grep -- "--type=renderer" \
| grep -c -F -- "--user-data-dir=$INSTANCES_BASE/$INSTANCE_NAME"
fi
}
launch_fresh() {
if [ "$INSTANCE_NAME" = "default" ]; then
open -n -a "$CLAUDE_APP"
else
mkdir -p "$INSTANCES_BASE/$INSTANCE_NAME"
open -n -a "$CLAUDE_APP" --args --user-data-dir="$INSTANCES_BASE/$INSTANCE_NAME"
fi
}
PID=$(main_pid)
RENDERERS=$(renderer_count)
if [ -n "$PID" ] && [ "${RENDERERS:-0}" -gt 0 ]; then
# Running with a window: focus it. (First use triggers a one-time macOS
# "wants to control System Events" Automation prompt -- click OK.)
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $PID) to true" 2>/dev/null
else
# Not running, OR running but windowless. We do NOT kill the existing process:
# a windowless instance is often windowless precisely because it is doing
# background work (e.g. a chat still "thinking"), and killing it would lose
# that work. Instead just open a fresh window. Any leftover windowless
# process is harmless and goes away when you fully Quit (Cmd-Q) Claude.
launch_fresh
fi
#!/bin/bash
# make-claude-instance.sh
# Create a color-coded, isolated Claude Desktop launcher on macOS.
#
# Usage:
# ./make-claude-instance.sh <instance> <DisplayName> [hueShift] [satGain]
#
# instance Profile name. Use "default" for Claude's built-in profile
# (personal). Any other name => isolated profile under
# ~/.claude-instances/<instance>.
# DisplayName Shows in the Dock/Launchpad as "Claude <DisplayName>".
# hueShift Optional. Icon hue rotation, 0-255 (==0-360deg). e.g. 135=blue,
# -7=red-orange, 80=green, 170=purple. Default 0 (stock orange).
# satGain Optional. Saturation multiplier (default 1.0).
#
# Icon recoloring requires Python 3 + Pillow (pip install Pillow). Without them
# the launcher still works, just with the stock orange icon.
set -e
INSTANCE="$1"
DISPLAY_NAME="$2"
HUE_SHIFT="${3:-0}"
SAT_GAIN="${4:-1.0}"
CLAUDE_APP="/Applications/Claude.app"
INSTANCES_BASE="$HOME/.claude-instances"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -z "$INSTANCE" ] || [ -z "$DISPLAY_NAME" ]; then
echo "Usage: $0 <instance> <DisplayName> [hueShift] [satGain]"; exit 1
fi
if [ ! -d "$CLAUDE_APP" ]; then
echo "❌ Claude Desktop not found at $CLAUDE_APP — install from https://claude.ai/download"; exit 1
fi
WRAPPER="/Applications/Claude $DISPLAY_NAME.app"
if [ -d "$WRAPPER" ]; then
read -p "⚠️ '$WRAPPER' exists. Overwrite? (y/N): " ow
[ "$ow" = "y" ] || [ "$ow" = "Y" ] || { echo "Cancelled."; exit 0; }
rm -rf "$WRAPPER"
fi
mkdir -p "$WRAPPER/Contents/MacOS" "$WRAPPER/Contents/Resources"
[ "$INSTANCE" != "default" ] && mkdir -p "$INSTANCES_BASE/$INSTANCE"
# ---- Info.plist -------------------------------------------------------------
cat > "$WRAPPER/Contents/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>claude-launcher</string>
<key>CFBundleIdentifier</key><string>com.anthropic.claude.$INSTANCE</string>
<key>CFBundleName</key><string>Claude $DISPLAY_NAME</string>
<key>CFBundleDisplayName</key><string>Claude $DISPLAY_NAME</string>
<key>CFBundleVersion</key><string>1.0</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>CFBundleIconFile</key><string>claude-icon</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
EOF
# ---- config.sh --------------------------------------------------------------
echo "INSTANCE_NAME=$INSTANCE" > "$WRAPPER/Contents/MacOS/config.sh"
# ---- smart launcher (self-contained) ----------------------------------------
cat > "$WRAPPER/Contents/MacOS/claude-launcher" <<'LAUNCHER'
#!/bin/bash
# Behavior on click:
# - Running WITH a window -> focus it (no duplicate).
# - Running but WINDOWLESS -> kill it (-9) and relaunch (Claude keeps its main
# process alive after the window closes; a windowless instance can't be
# re-shown per-instance, so we relaunch).
# - Not running -> launch fresh.
#
# Process-matching notes (verified empirically):
# * Helper processes use the SAME Contents/MacOS/Claude binary AND carry
# --user-data-dir; only helpers have a --type= flag, the main does not.
# * A live window shows up as --type=renderer process(es) for that profile.
# * kill -9 on the main cleanly takes its helpers with it; the main ignores SIGTERM.
source "$(dirname "$0")/config.sh"
CLAUDE_APP="/Applications/Claude.app"
INSTANCES_BASE="$HOME/.claude-instances"
main_pid() {
if [ "$INSTANCE_NAME" = "default" ]; then
ps -axww -o pid,command | grep -E "[C]laude\.app/Contents/MacOS/Claude( |$)" \
| grep -v -- "--type=" | grep -v -- "--user-data-dir" | awk '{print $1}' | head -1
else
ps -axww -o pid,command | grep -F -- "--user-data-dir=$INSTANCES_BASE/$INSTANCE_NAME" \
| grep -v -- "--type=" | awk '{print $1}' | head -1
fi
}
renderer_count() {
if [ "$INSTANCE_NAME" = "default" ]; then
ps -axww -o command | grep -- "--type=renderer" | grep -F -- "Claude.app/Contents/MacOS/Claude" \
| grep -v -F -- "--user-data-dir=$INSTANCES_BASE/" | grep -c .
else
ps -axww -o command | grep -- "--type=renderer" | grep -c -F -- "--user-data-dir=$INSTANCES_BASE/$INSTANCE_NAME"
fi
}
launch_fresh() {
if [ "$INSTANCE_NAME" = "default" ]; then
open -n -a "$CLAUDE_APP"
else
mkdir -p "$INSTANCES_BASE/$INSTANCE_NAME"
open -n -a "$CLAUDE_APP" --args --user-data-dir="$INSTANCES_BASE/$INSTANCE_NAME"
fi
}
PID=$(main_pid)
RENDERERS=$(renderer_count)
if [ -n "$PID" ] && [ "${RENDERERS:-0}" -gt 0 ]; then
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $PID) to true" 2>/dev/null
else
# Not running, OR running but windowless. Do NOT kill: a windowless instance
# is often busy doing background work (e.g. a chat still "thinking"). Just
# open a fresh window. Any leftover windowless process clears on Quit (Cmd-Q).
launch_fresh
fi
LAUNCHER
chmod +x "$WRAPPER/Contents/MacOS/claude-launcher"
# ---- icon (recolor if Pillow available, else copy stock) --------------------
SRC_ICNS=$(find "$CLAUDE_APP" -name "*.icns" -type f 2>/dev/null | head -n 1)
DEST_ICNS="$WRAPPER/Contents/Resources/claude-icon.icns"
RECOLORED=false
if [ -n "$SRC_ICNS" ]; then
if [ "$HUE_SHIFT" != "0" ] || [ "$SAT_GAIN" != "1.0" ]; then
if python3 -c "import PIL" 2>/dev/null && [ -f "$SCRIPT_DIR/recolor-icon.py" ]; then
TMP=$(mktemp -d)
iconutil -c iconset "$SRC_ICNS" -o "$TMP/icon.iconset"
python3 "$SCRIPT_DIR/recolor-icon.py" "$TMP/icon.iconset" "$HUE_SHIFT" "$SAT_GAIN" >/dev/null
iconutil -c icns "$TMP/icon.iconset" -o "$DEST_ICNS"
rm -rf "$TMP"
RECOLORED=true
else
echo "ℹ️ Skipping recolor (need: python3 + Pillow + recolor-icon.py alongside). Using stock icon."
fi
fi
$RECOLORED || cp "$SRC_ICNS" "$DEST_ICNS"
else
echo "⚠️ No source icon found; wrapper will use the default macOS icon."
fi
# ---- register & refresh -----------------------------------------------------
LSREG=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
"$LSREG" -f "$WRAPPER" 2>/dev/null || true
touch "$WRAPPER"
killall Dock 2>/dev/null || true
echo "✅ Created: $WRAPPER"
[ "$INSTANCE" != "default" ] && echo " Profile: $INSTANCES_BASE/$INSTANCE"
$RECOLORED && echo " Icon recolored (hue $HUE_SHIFT, sat x$SAT_GAIN)"
echo " Find 'Claude $DISPLAY_NAME' in Launchpad or drag it to your Dock."
#!/usr/bin/env python3
"""Recolor a macOS .iconset's PNGs by rotating hue (and optionally scaling saturation).
Usage:
python3 recolor-icon.py <iconset_dir> <hue_shift> [sat_gain]
hue_shift : integer in Pillow HSV units (0-255 == 0-360 degrees). e.g. +135 ~ orange->blue.
sat_gain : float multiplier for saturation (default 1.0).
Operates in place on every *.png in the iconset directory. Requires Pillow.
"""
import os
import sys
from PIL import Image
iconset = sys.argv[1]
hue_shift = int(sys.argv[2]) if len(sys.argv) > 2 else 0
sat_gain = float(sys.argv[3]) if len(sys.argv) > 3 else 1.0
for name in os.listdir(iconset):
if not name.endswith(".png"):
continue
path = os.path.join(iconset, name)
im = Image.open(path).convert("RGBA")
r, g, b, a = im.split()
h, s, v = Image.merge("RGB", (r, g, b)).convert("HSV").split()
if hue_shift:
h = h.point(lambda i: (i + hue_shift) % 256)
if sat_gain != 1.0:
s = s.point(lambda i: min(255, int(i * sat_gain)))
out = Image.merge("HSV", (h, s, v)).convert("RGB")
r2, g2, b2 = out.split()
Image.merge("RGBA", (r2, g2, b2, a)).save(path)
print("recolored", name)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment