#!/usr/bin/env bash # shannoncoat - run more than one claude desktop profile on mac. # # claude desktop app seems to keep everything important in one config directory, # so a different --user-data-dir gives a practically separate install. # and CLAUDE_CONFIG_DIR env var can isolate claude code sessions natively. # this script combines these to isolate both desktop sessions and the claude # code sessions they run. # # profiles are yaml files in ~/.shannoncoat: # # ~/.shannoncoat/client1.yaml -> profile named "client1" # paths: # code: ~/work/client1/.claude/code # desktop: ~/work/client1/.claude/desktop # # claude code inside the desktop app can run in some folder which already # defines a CLAUDE_CONFIG_DIR with direnv etc, that variable can be defined # this way to allow both individual and desktop sessions work properly: # export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" # set -euo pipefail APP="${CLAUDE_APP_PATH:-/Applications/Claude.app}" BIN="$APP/Contents/MacOS/Claude" ROOT="$HOME/.shannoncoat" DEFAULT="default" APP_DIR="" CODE_DIR="" CODE_KEY="code" DESKTOP_KEY="desktop" show_usage() { cat < [profile] launch open a profile, leaving others running switch quit all other profiles, then open this one quit quit a profile list list all running profiles Profiles are yaml files in ~/.shannoncoat/. Syntax: # profile-name.yaml paths: $CODE_KEY: ~/path/to/claude/code $DESKTOP_KEY: ~/path/to/claude/desktop EOF } # expand leading ~ in path string to $HOME tilde_path() { local p="$1" echo "${p/#\~/$HOME}" } # pull one key out of the yaml, simple job avoid yq for now read_key() { local file="$1" key="$2" sed -EHn "s/^\s+$key:\s+//p" "$file" | tr -d '"' } # set APP_DIR and CODE_DIR for a profile, or keep empty for $DEFAULT get_dirs() { local name="$1" file="$ROOT/$1.yaml" [[ "$name" == "$DEFAULT" ]] && { return; } if [[ ! -f "$file" ]]; then echo "no profile named $name (expected $file)" >&2 exit 1 fi APP_DIR="$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")" CODE_DIR="$(tilde_path "$(read_key "$file" $CODE_KEY)")" } # match a claude data dir to its profile name dir_to_profile() { local dir="$1" file name [[ "$dir" == "$DEFAULT" ]] && { echo "$DEFAULT"; return; } for file in "$ROOT"/*.yaml; do [[ -f "$file" ]] || continue name="$(basename "$file" .yaml)" if [[ "$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")" == "$dir" ]]; then echo "$name" return fi done echo "$dir" } # get all running claude processes as "piduser-data-dir" running_info() { local pid args dir while read -r pid args; do # if args has --user-data-dir=, extract it, otherwise use $DEFAULT if [[ "$args" == *--user-data-dir=* ]]; then dir="${args#*--user-data-dir=}" dir="${dir%% *}" else dir="$DEFAULT" fi printf '%s\t%s\n' "$pid" "$dir" done < <(pgrep -fl "$BIN" || true) } # get pid of a profile's claude process profile_to_pid() { get_dirs "$1" running_info | awk -F'\t' -v d="${APP_DIR:-default}" '$2 == d { print $1; exit }' } # switch focus to a process window, shell/term needs accessibility permissions app_focus() { local pid="$1" osascript > /dev/null 2>&1 < /dev/null 2>&1 & disown } # list all running profiles cmd_list() { local pid dir while read -r pid dir; do printf 'profile: %s\tpid: %s\n' "$(dir_to_profile "$dir")" "$pid" done < <(running_info) } # launch a profile or focus window if already running cmd_launch() { local name="${1:?profile name required}" pid pid="$(profile_to_pid "$name")" [[ -n "$pid" ]] && { app_focus "$pid"; return; } app_start "$name" } # switch to a profile quitting all others cmd_switch() { local name="${1:?profile name required}" pid dir keep # marks which profile to keep running, "default" if empty get_dirs "$name" keep="${APP_DIR:-"$DEFAULT"}" # quit all other running claudes except the one to $keep while read -r pid dir; do [[ "$dir" == "$keep" ]] && continue kill "$pid" 2>/dev/null || true done < <(running_info) # then launch selected profile cmd_launch "$name" } # quit a profile if running cmd_quit() { local name="${1:?profile name required}" pid pid="$(profile_to_pid "$name")" [[ -z "$pid" ]] && { echo "$name isn't running"; return; } kill "$pid" } # main entry point case "${1:-}" in launch) cmd_launch "${2:-}" ;; switch) cmd_switch "${2:-}" ;; quit) cmd_quit "${2:-}" ;; list) cmd_list ;; -h|--help) show_usage; exit 0 ;; *) show_usage; exit 1 ;; esac