#!/usr/bin/env bash set -euo pipefail DUOTERM_VERSION="1.0.0" DUOTERM_HOME="${DUOTERM_HOME:-$HOME/.claude-accounts}" DUOTERM_STATE="$DUOTERM_HOME/current" DUOTERM_SHARED_CONF="$DUOTERM_HOME/shared.conf" PRIMARY_DIR="${DUOTERM_PRIMARY_DIR:-$HOME/.claude}" PRIMARY_NAME="primary" DEFAULT_SHARED="CLAUDE.md settings.json settings.local.json commands hooks skills agents plugins projects history.jsonl" die() { printf 'duoterm: %s\n' "$*" >&2; exit 1; } warn() { printf 'duoterm: %s\n' "$*" >&2; } have() { command -v "$1" >/dev/null 2>&1; } shared_items() { if [ -f "$DUOTERM_SHARED_CONF" ]; then sed -e 's/#.*$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//' "$DUOTERM_SHARED_CONF" | grep -v '^$' else printf '%s\n' $DEFAULT_SHARED fi } valid_name() { case "$1" in "") return 1 ;; "$PRIMARY_NAME") return 1 ;; *[!a-zA-Z0-9_-]*) return 1 ;; *) return 0 ;; esac } current_name() { if [ -f "$DUOTERM_STATE" ]; then tr -d '[:space:]' < "$DUOTERM_STATE" else printf '%s' "$PRIMARY_NAME" fi } slot_dir() { case "$1" in "$PRIMARY_NAME") printf '%s' "$PRIMARY_DIR" ;; *) printf '%s' "$DUOTERM_HOME/$1" ;; esac } config_dir_for() { case "$1" in "$PRIMARY_NAME") printf '' ;; *) printf '%s' "$DUOTERM_HOME/$1" ;; esac } slot_exists() { case "$1" in "$PRIMARY_NAME") [ -d "$PRIMARY_DIR" ] ;; *) [ -d "$DUOTERM_HOME/$1" ] ;; esac } state_json_for() { case "$1" in "$PRIMARY_NAME") printf '%s' "$HOME/.claude.json" ;; *) printf '%s' "$DUOTERM_HOME/$1/.claude.json" ;; esac } slot_email() { local json json="$(state_json_for "$1")" [ -f "$json" ] || { printf 'not set up'; return; } if have jq; then jq -r '.oauthAccount.emailAddress // "not signed in"' "$json" 2>/dev/null || printf 'unreadable' else local found found="$(tr ',' '\n' < "$json" 2>/dev/null | grep -m1 '"emailAddress"' | sed -e 's/.*"emailAddress"[[:space:]]*:[[:space:]]*"//' -e 's/".*//')" || true if [ -n "${found:-}" ]; then printf '%s' "$found"; else printf 'not signed in'; fi fi } slot_names() { printf '%s\n' "$PRIMARY_NAME" [ -d "$DUOTERM_HOME" ] || return 0 local d for d in "$DUOTERM_HOME"/*/; do [ -d "$d" ] || continue basename "$d" done } link_shared() { local dir="$1" item [ -d "$dir" ] || die "no such directory: $dir" while IFS= read -r item; do [ -n "$item" ] || continue [ -e "$PRIMARY_DIR/$item" ] || continue if [ -L "$dir/$item" ]; then rm -f "$dir/$item"; fi if [ -e "$dir/$item" ]; then continue; fi ln -s "$PRIMARY_DIR/$item" "$dir/$item" done <" valid_name "$name" || die "name must be letters, digits, dash or underscore, and not '$PRIMARY_NAME'" local dir="$DUOTERM_HOME/$name" [ -d "$dir" ] && die "$name already exists at $dir" [ -d "$PRIMARY_DIR" ] || die "no primary config at $PRIMARY_DIR - run claude once first" mkdir -p "$dir" chmod 700 "$dir" link_shared "$dir" if [ -f "$HOME/.claude.json" ]; then if have jq; then jq 'del(.oauthAccount, .userID, .cachedStatsigGates, .customApiKeyResponses)' \ "$HOME/.claude.json" > "$dir/.claude.json" else printf '{"hasCompletedOnboarding":true}\n' > "$dir/.claude.json" warn "jq not found - new slot starts without project trust or MCP servers" fi chmod 600 "$dir/.claude.json" fi printf 'created %s\n' "$dir" printf 'next: duoterm login %s\n' "$name" } cmd_login() { local name="${1:-}" dir bin [ -n "$name" ] || die "usage: duoterm login " [ "$name" = "$PRIMARY_NAME" ] && die "primary signs in with a plain 'claude' session" dir="$(config_dir_for "$name")" [ -d "$dir" ] || die "no such account: $name (duoterm add $name)" bin="$(claude_bin)" || die "claude not found on PATH" printf 'starting claude with CLAUDE_CONFIG_DIR=%s\n' "$dir" printf 'type /login inside it, finish in the browser, then /exit\n\n' CLAUDE_CONFIG_DIR="$dir" exec "$bin" } cmd_use() { local name="${1:-}" [ -n "$name" ] || die "usage: duoterm use " slot_exists "$name" || die "no such account: $name" mkdir -p "$DUOTERM_HOME" printf '%s\n' "$name" > "$DUOTERM_STATE" printf 'now: %s (%s)\n' "$name" "$(slot_email "$name")" printf 'new claude sessions use it. Sessions already running keep theirs.\n' } cmd_run() { local name="${1:-}" dir bin [ -n "$name" ] || die "usage: duoterm run [claude args...]" shift [ "${1:-}" = "--" ] && shift slot_exists "$name" || die "no such account: $name" bin="$(claude_bin)" || die "claude not found on PATH" dir="$(config_dir_for "$name")" if [ -n "$dir" ]; then CLAUDE_CONFIG_DIR="$dir" exec "$bin" "$@" else exec env -u CLAUDE_CONFIG_DIR "$bin" "$@" fi } cmd_with() { local name="${1:-}" dir [ -n "$name" ] || die "usage: duoterm with [args...]" shift [ "$#" -gt 0 ] || die "usage: duoterm with [args...]" slot_exists "$name" || die "no such account: $name" dir="$(config_dir_for "$name")" if [ -n "$dir" ]; then CLAUDE_CONFIG_DIR="$dir" exec "$@" else exec env -u CLAUDE_CONFIG_DIR "$@" fi } cmd_status() { local n dir n="$(current_name)" dir="$(config_dir_for "$n")" printf 'current : %s (%s)\n' "$n" "$(slot_email "$n")" if [ -n "$dir" ]; then printf 'dir : %s\n' "$dir" else printf 'dir : %s (CLAUDE_CONFIG_DIR unset)\n' "$PRIMARY_DIR" fi printf 'accounts: %s\n' "$(slot_names | tr '\n' ' ')" } cmd_list() { local n cur cur="$(current_name)" while IFS= read -r n; do [ -n "$n" ] || continue if [ "$n" = "$cur" ]; then printf '* '; else printf ' '; fi printf '%-14s %s\n' "$n" "$(slot_email "$n")" done <" [ "$name" = "$PRIMARY_NAME" ] && die "primary is your real ~/.claude - duoterm will not remove it" dir="$DUOTERM_HOME/$name" [ -d "$dir" ] || die "no such account: $name" printf 'This deletes %s\n' "$dir" printf 'Shared config is symlinked, so only this account'"'"'s identity is lost.\n' printf 'Type the account name to confirm: ' local answer IFS= read -r answer || true [ "$answer" = "$name" ] || die "not confirmed" for entry in "$dir"/* "$dir"/.[!.]*; do if [ -L "$entry" ]; then rm -f "$entry"; fi done rm -rf "$dir" [ "$(current_name)" = "$name" ] && printf '%s\n' "$PRIMARY_NAME" > "$DUOTERM_STATE" printf 'removed %s\n' "$name" printf 'The keychain item for that directory is now orphaned. Remove it with:\n' printf ' in Keychain Access, search for "Claude Code-credentials"\n' } cmd_shell() { local target="${1:-}" if [ -z "$target" ]; then case "${SHELL:-}" in */fish) target=fish ;; */bash) target=bash ;; *) target=zsh ;; esac fi case "$target" in zsh) cat <<'EOF' # duoterm - route every interactive `claude` to the selected account. # Added by: duoterm shell zsh claude() { local __duoterm_root="${DUOTERM_HOME:-$HOME/.claude-accounts}" __duoterm_slot __duoterm_bin __duoterm_bin="$(whence -p claude)" || { print -u2 "claude binary not found on PATH"; return 127; } if [[ -r "$__duoterm_root/current" ]]; then __duoterm_slot="${$(<"$__duoterm_root/current")//[[:space:]]/}" else __duoterm_slot=primary fi if [[ -n "$__duoterm_slot" && "$__duoterm_slot" != primary && -d "$__duoterm_root/$__duoterm_slot" ]]; then CLAUDE_CONFIG_DIR="$__duoterm_root/$__duoterm_slot" "$__duoterm_bin" "$@" else env -u CLAUDE_CONFIG_DIR "$__duoterm_bin" "$@" fi } EOF ;; bash) cat <<'EOF' # duoterm - route every interactive `claude` to the selected account. # Added by: duoterm shell bash claude() { local __duoterm_root="${DUOTERM_HOME:-$HOME/.claude-accounts}" __duoterm_slot __duoterm_bin __duoterm_bin="$(type -P claude)" || { echo "claude binary not found on PATH" >&2; return 127; } if [ -r "$__duoterm_root/current" ]; then __duoterm_slot="$(tr -d '[:space:]' < "$__duoterm_root/current")" else __duoterm_slot=primary fi if [ -n "$__duoterm_slot" ] && [ "$__duoterm_slot" != primary ] && [ -d "$__duoterm_root/$__duoterm_slot" ]; then CLAUDE_CONFIG_DIR="$__duoterm_root/$__duoterm_slot" "$__duoterm_bin" "$@" else env -u CLAUDE_CONFIG_DIR "$__duoterm_bin" "$@" fi } EOF ;; fish) cat <<'EOF' # duoterm - route every interactive `claude` to the selected account. # Added by: duoterm shell fish function claude set -l __duoterm_root (test -n "$DUOTERM_HOME"; and echo $DUOTERM_HOME; or echo $HOME/.claude-accounts) set -l __duoterm_bin (command -s claude) if test -z "$__duoterm_bin" echo "claude binary not found on PATH" >&2 return 127 end set -l __duoterm_slot primary if test -r "$__duoterm_root/current" set __duoterm_slot (string trim (cat "$__duoterm_root/current")) end if test -n "$__duoterm_slot" -a "$__duoterm_slot" != primary -a -d "$__duoterm_root/$__duoterm_slot" CLAUDE_CONFIG_DIR="$__duoterm_root/$__duoterm_slot" $__duoterm_bin $argv else env -u CLAUDE_CONFIG_DIR $__duoterm_bin $argv end end EOF ;; *) die "unknown shell: $target (zsh, bash, fish)" ;; esac } cmd_doctor() { local bad=0 n dir item rcfile found printf 'duoterm : %s\n' "$DUOTERM_VERSION" printf 'root : %s\n' "$DUOTERM_HOME" printf 'state : %s -> %s\n' "$DUOTERM_STATE" "$(current_name)" printf 'primary : %s\n' "$PRIMARY_DIR" if have claude; then printf 'claude : %s\n' "$(command -v claude)" else printf 'claude : NOT ON PATH\n'; bad=1 fi have jq || printf 'jq : missing (optional; new slots lose project trust)\n' case "$(uname -s)" in Darwin) printf 'creds : macOS keychain, keyed to the config dir path\n' ;; *) printf 'creds : .credentials.json inside each config dir\n' ;; esac while IFS= read -r n; do [ -n "$n" ] || continue dir="$(config_dir_for "$n")" [ -n "$dir" ] || continue while IFS= read -r item; do [ -n "$item" ] || continue if [ -L "$dir/$item" ] && [ ! -e "$dir/$item" ]; then printf 'DANGLING : %s/%s\n' "$dir" "$item"; bad=1 fi done </dev/null; then printf 'wrapper : wired in %s\n' "$rcfile"; found=1 fi done if [ "$found" = 0 ]; then printf 'wrapper : MISSING - add: eval "$(duoterm shell)"\n'; bad=1 fi [ "$bad" = 0 ] && printf 'ok\n' || printf 'problems above\n' return 0 } cmd_help() { cat <<'EOF' duoterm - more than one Claude account signed in at once duoterm which account new sessions use duoterm list every account and its email, current one starred duoterm use switch. No sign-out, no browser duoterm add create an account slot duoterm login sign that slot in. Once, then it persists duoterm run [args] run one session as without switching duoterm with run any command with that account's environment duoterm sync [name] relink shared config after adding files to ~/.claude duoterm remove delete a slot (never the primary) duoterm path [name] config dir of an account (empty for primary) duoterm env [name] the export/unset line a script should eval duoterm shell [zsh|bash|fish] print the shell wrapper duoterm doctor check the wiring duoterm version The account named "primary" is your existing ~/.claude, used with CLAUDE_CONFIG_DIR unset. Setting that variable to ~/.claude explicitly is not the same thing - the credential lookup misses and the session reports Not logged in. duoterm never does that. Shared configuration is symlinked from each slot back to the primary, so hooks, skills, commands, agents and settings stay identical and only the identity differs. Override the list in $DUOTERM_HOME/shared.conf, one entry per line. Environment: DUOTERM_HOME account root (default ~/.claude-accounts) DUOTERM_PRIMARY_DIR primary config dir (default ~/.claude) https://auto.relay70.dev/duoterm/ EOF } case "${1:-status}" in add|new) shift; cmd_add "${1:-}" ;; login) shift; cmd_login "${1:-}" ;; use|switch) shift; cmd_use "${1:-}" ;; run) shift; cmd_run "$@" ;; with) shift; cmd_with "$@" ;; list|ls) cmd_list ;; status|st) cmd_status ;; path) shift; cmd_path "${1:-}" ;; env) shift; cmd_env "${1:-}" ;; sync) shift; cmd_sync "${1:-}" ;; remove|rm) shift; cmd_remove "${1:-}" ;; shell|hook) shift; cmd_shell "${1:-}" ;; doctor) cmd_doctor ;; version|--version|-v) printf 'duoterm %s\n' "$DUOTERM_VERSION" ;; help|--help|-h) cmd_help ;; *) die "unknown command: $1 (try duoterm help)" ;; esac