183 lines
5.1 KiB
Bash
Executable File
183 lines
5.1 KiB
Bash
Executable File
#!/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 <<EOF
|
|
Usage: $0 <command> [profile]
|
|
|
|
launch <profile> open a profile, leaving others running
|
|
switch <profile> quit all other profiles, then open this one
|
|
quit <profile> 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 "pid<TAB>user-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 <<EOF || true
|
|
tell application "System Events"
|
|
set proc to first process whose unix id is $pid
|
|
set frontmost of proc to true
|
|
try
|
|
perform action "AXRaise" of window 1 of proc
|
|
end try
|
|
end tell
|
|
EOF
|
|
}
|
|
|
|
# start a claude desktop process with the right env vars and user-data-dir
|
|
app_start() {
|
|
get_dirs "$1"
|
|
# app dir empty means default profile, just open normally
|
|
[[ -z "$APP_DIR" ]] && { open -a "$APP"; return; }
|
|
mkdir -p "$APP_DIR" "$CODE_DIR"
|
|
CLAUDE_CONFIG_DIR="$CODE_DIR" "$BIN" --user-data-dir="$APP_DIR" > /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
|