diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a4ec0890c..d5eee897c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -67,6 +67,8 @@ # $vars and silently breaks ad-hoc `for ... in $pairs` loops). # Launch templates live in launch_template() below; placeholders replaced before launch: # __BRIEF__ absolute path to data//brief.md +# __CLAUDECONFIGDIR__ optional `CLAUDE_CONFIG_DIR= ` prefix from config/crew-config-dir +# (claude only; absent knob = bare `claude`, default config dir; see #599) # __TURNEND__ absolute path to state/.turn-ended (for harnesses whose # turn-end signal rides the launch command, e.g. codex -c notify=[...]) # __PIEXT__ absolute path to state/.pi-ext.ts (pi turn-end extension, @@ -84,7 +86,7 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { - sed -n '2,78p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,80p' "$0" | sed 's/^# \{0,1\}//' } case "${1:-}" in @@ -321,7 +323,11 @@ launch_template() { # does NOT suppress the interactive ghost text (verified empirically), so the env # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is # the defense-in-depth backstop for any pane this flag cannot reach. - claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; + # __CLAUDECONFIGDIR__ expands to a `CLAUDE_CONFIG_DIR= ` env prefix when + # config/crew-config-dir names an authenticated config dir, or to nothing when + # the knob is absent (bare `claude`, default config dir). See claude_config_dir_prefix + # below and #599 (hands-free spawn on a multi-account machine). + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false __CLAUDECONFIGDIR__claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; codex) if [ "$kind" = secondmate ]; then printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(cat __BRIEF__)"' @@ -477,6 +483,26 @@ effort_flag_for_harness() { esac } +# The optional CLAUDE_CONFIG_DIR launch prefix for a claude crewmate (#599). +# config/crew-config-dir is a local, gitignored file naming the config dir the +# crewmate authenticates with. On a multi-account machine the default ~/.claude +# can be empty/unauthenticated, stranding a bare-`claude` crewmate on the login +# wall; pointing it at an authenticated dir makes the spawn hands-free. The +# trimmed value becomes a `CLAUDE_CONFIG_DIR= ` env prefix scoped to this +# firstmate-launched agent, so it never mutates the captain's global config. +# Absent, empty, or a non-claude harness -> nothing, i.e. today's bare-`claude`, +# default-config-dir behavior. Only the claude template carries the placeholder. +claude_config_dir_prefix() { + local harness=$1 dir= + [ "$harness" = claude ] || return 0 + [ -f "$CONFIG/crew-config-dir" ] || return 0 + dir=$(head -n 1 "$CONFIG/crew-config-dir") + dir="${dir#"${dir%%[![:space:]]*}"}" # trim leading whitespace + dir="${dir%"${dir##*[![:space:]]}"}" # trim trailing whitespace + [ -n "$dir" ] || return 0 + printf 'CLAUDE_CONFIG_DIR=%s ' "$(shell_quote "$dir")" +} + json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } @@ -1038,8 +1064,10 @@ sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") +CLAUDECONFIGDIR=$(claude_config_dir_prefix "$HARNESS") LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} LAUNCH=${LAUNCH//__EFFORTFLAG__/$EFFORTFLAG} +LAUNCH=${LAUNCH//__CLAUDECONFIGDIR__/$CLAUDECONFIGDIR} LAUNCH=${LAUNCH//__BRIEF__/$sq_brief} LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend} LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} diff --git a/docs/configuration.md b/docs/configuration.md index d289c4c15..a621458f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -177,6 +177,14 @@ The primary propagates `config/crew-dispatch.json`, `config/crew-harness`, and ` For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer. For Pi secondmate launches, `fm-spawn.sh` starts Pi with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. +### Claude config dir (config/crew-config-dir) + +`config/crew-config-dir` is a local, gitignored file naming the Claude config dir (`CLAUDE_CONFIG_DIR`) a spawned `claude` crewmate authenticates with. +It exists for multi-account machines, where the default `~/.claude` can be empty or unauthenticated so a bare-`claude` crewmate lands on the login wall and never starts. +The first line's trimmed value becomes a per-launch `CLAUDE_CONFIG_DIR=` env prefix on the claude launch command only, scoped to that firstmate-launched agent, so it never mutates the captain's global config. +It applies to the `claude` harness only; other harnesses ignore it. +When the file is absent or empty, launch behavior is byte-identical to before: bare `claude` on its default config dir. + ## Crew dispatch profiles (config/crew-dispatch.json) `config/crew-dispatch.json` is an optional local, gitignored file containing natural-language rules that firstmate reads before dispatching a crewmate or scout. diff --git a/tests/fm-spawn-claude-config-dir.test.sh b/tests/fm-spawn-claude-config-dir.test.sh new file mode 100755 index 000000000..cae5c6bf2 --- /dev/null +++ b/tests/fm-spawn-claude-config-dir.test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Behavior tests for the claude crewmate CLAUDE_CONFIG_DIR launch knob (#599). +# +# On a multi-account machine the default ~/.claude can be empty/unauthenticated, +# so a bare-`claude` crewmate lands on the login wall and never starts. The local, +# gitignored config/crew-config-dir file names the config dir a claude crewmate +# should authenticate with; when set, fm-spawn prefixes the claude launch command +# with `CLAUDE_CONFIG_DIR= `. These tests drive a real claude ship spawn +# through fm-spawn.sh with a fake tmux that records the literal launch string, and +# pin both directions: +# 1. config/crew-config-dir set -> the launch gains the CLAUDE_CONFIG_DIR prefix. +# 2. config/crew-config-dir absent -> NO prefix, i.e. byte-identical prior +# behavior (bare `claude`, default config dir). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-ccd) + +# A fake tmux that satisfies fm-spawn's tmux backend and records the literal +# launch string. The launch command is the only send-keys call that uses `-l` +# (fm_backend_tmux_send_literal); the `treehouse get` and GOTMPDIR sends use the +# text-line form (trailing Enter, no -l) and the final Enter uses send_key, so +# capturing the argument after `-l` isolates the launch string. The pane-path +# query returns FM_FAKE_PANE_PATH so worktree discovery resolves immediately. +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + new-window) printf '@1\n'; exit 0 ;; + list-windows|has-session|new-session|set-window-option|kill-window) exit 0 ;; + send-keys) + prev= + for a in "$@"; do + [ "$prev" = "-l" ] && printf '%s' "$a" > "${FM_SEND_LOG:?}" + prev=$a + done + exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse gh-axi gh + printf '%s\n' "$fakebin" +} + +# Build one isolated case (home + project + worktree + fakebin), returning its +# fields. Each case gets a fresh id so state/data never collide across cases. +make_spawn_case() { + local name=$1 case_dir home proj wt fakebin id + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + id="ccd-$name-x1" + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + printf 'brief\n' > "$home/data/$id/brief.md" + fm_git_worktree "$proj" "$wt" "fm/$id" + touch "$home/state/.last-watcher-beat" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin|$id" +} + +# Drive a real claude ship spawn and echo the recorded launch string. +run_claude_spawn() { + local home=$1 proj=$2 wt=$3 fakebin=$4 id=$5 send_log=$6 out + out=$(FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_SEND_LOG="$send_log" PATH="$fakebin:$PATH" \ + "$SPAWN" "$id" "$proj" claude 2>&1) || { echo "SPAWN-FAILED: $out" >&2; return 1; } + cat "$send_log" +} + +test_config_dir_adds_prefix() { + local rec case_dir home proj wt fakebin id send_log ccd launch + rec=$(make_spawn_case with-knob) + IFS='|' read -r case_dir home proj wt fakebin id < "$home/config/crew-config-dir" + send_log="$case_dir/launch.log" + + launch=$(run_claude_spawn "$home" "$proj" "$wt" "$fakebin" "$id" "$send_log") \ + || fail "claude spawn with config/crew-config-dir failed" + + # The prefix must sit between the ghost-text env var and the claude verb, with + # the config dir shell-quoted (single quotes, no special chars in the path). + case "$launch" in + "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CONFIG_DIR='$ccd' claude --dangerously-skip-permissions "*) : ;; + *) fail "launch missing CLAUDE_CONFIG_DIR prefix"$'\n'"--- launch ---"$'\n'"$launch" ;; + esac + pass "config/crew-config-dir prefixes the claude launch with CLAUDE_CONFIG_DIR" +} + +test_absent_knob_is_backward_compatible() { + local rec case_dir home proj wt fakebin id send_log launch + rec=$(make_spawn_case no-knob) + IFS='|' read -r case_dir home proj wt fakebin id <