Files
small-scripts/scripts/claude-profile
Paul O'Reilly 143b32597e Add claude-profile engagement mode picker with statusline and session-start integration
- claude-profile: phase 1-3 picker (profile, mode, launch) with preset support,
  dryrun, WezTerm theming, and --append-system-prompt mode body injection
- 5 mode files (chat/quick/deep/hybrid/orch) with YAML frontmatter + prose body;
  new escalates_to field drives statusline →Opus arrow for deep and hybrid
- statusline.sh reads CLAUDE_CONFIG_DIR/active-mode.env to show
  [Sonnet→Opus] deep · topic format when launched via claude-profile
- Root CLAUDE.md session-start: auto-selects project from cwd or CLAUDE_PROJECT
  in active-mode.env, skipping the interactive picker when context is clear
- Spec, tests (37 assertions, 9 test files, all passing), context docs, and
  preset example included

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:36:38 +12:00

417 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# claude-profile — Launch Claude Code with a profile and engagement mode
#
# Usage:
# claude-profile # interactive: profile + mode
# claude-profile <profile> # profile fixed, mode interactive
# claude-profile --mode <name> # mode fixed, profile interactive
# claude-profile --preset <name> # both from presets.yaml
# claude-profile <profile> --mode <name> # both fixed
# claude-profile --dryrun [...] # show resolved values, do not launch
# claude-profile [...] -- <claude-args> # extra args passed through to claude
#
# Spec: specs/claude-profile.spec.md
set -euo pipefail
# === Constants ===
DEFAULT_PROFILE_DIR="$HOME/.claude"
PROFILE_PATTERN_PREFIX="$HOME/.claude-"
SCRIPT_PATH="$(readlink -f "$0")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MODES_DIR="$REPO_ROOT/data/claude-profile/modes"
# context-load is on PATH (symlinked into ~/sbin from claude-foundations).
# Fall back to a sibling location for testing isolation.
if command -v context-load >/dev/null 2>&1; then
CONTEXT_LOADER="$(command -v context-load)"
else
CONTEXT_LOADER="${SCRIPT_DIR}/context-load"
fi
# Driver logical name -> full Claude model ID
declare -A DRIVER_MAP=(
[haiku]="claude-haiku-4-5-20251001"
[sonnet]="claude-sonnet-4-6"
[opus]="claude-opus-4-6"
[opus-1m]="claude-opus-4-6[1m]"
)
# === CLI parsing ===
PROFILE=""
MODE=""
PROJECT=""
PRESET=""
DRYRUN=0
CLAUDE_ARGS=()
usage() {
sed -n '2,12p' "$0" | sed 's/^# \?//'
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) MODE="${2:-}"; shift 2 ;;
--preset) PRESET="${2:-}"; shift 2 ;;
--project) PROJECT="${2:-}"; shift 2 ;;
--dryrun|-n) DRYRUN=1; shift ;;
--help|-h) usage; exit 0 ;;
--) shift; CLAUDE_ARGS=("$@"); break ;;
-*) echo "Unknown flag: $1" >&2; usage >&2; exit 1 ;;
*)
if [[ -z "$PROFILE" ]]; then
PROFILE="$1"
else
echo "Unexpected positional argument: $1" >&2
exit 1
fi
shift
;;
esac
done
# Mutual exclusion
if [[ -n "$PRESET" && ( -n "$MODE" || -n "$PROJECT" ) ]]; then
echo "Error: --preset is mutually exclusive with --mode and --project" >&2
exit 1
fi
# === Helpers ===
err() { echo "Error: $*" >&2; exit 1; }
dryrun_log() { printf '[dryrun] %s\n' "$*"; }
# Extract a key from the YAML frontmatter of a markdown file.
# Frontmatter must be the first block, delimited by `---` lines.
parse_frontmatter() {
local file=$1 key=$2
awk -v key="$key" '
BEGIN { in_fm=0 }
NR==1 && /^---$/ { in_fm=1; next }
in_fm && /^---$/ { exit }
in_fm {
if (match($0, "^"key":[[:space:]]*")) {
value = substr($0, RLENGTH+1)
gsub(/^"|"$/, "", value)
print value
exit
}
}
' "$file"
}
# Extract the body of a mode file (everything after the second `---`).
mode_body() {
local file=$1
awk '
/^---$/ { fm++; next }
fm >= 2 { print }
' "$file"
}
# Read a named preset block from a presets.yaml file.
# Output: KEY=value lines (one per field), keys uppercased and prefixed PRESET_.
read_preset_block() {
local file=$1 name=$2
awk -v target="$name" '
/^[a-zA-Z][a-zA-Z0-9_-]*:[[:space:]]*$/ {
block_name = $0
sub(/:[[:space:]]*$/, "", block_name)
in_block = (block_name == target)
next
}
/^[a-zA-Z]/ { in_block = 0 }
in_block && /^[[:space:]]+[a-zA-Z]/ {
line = $0
sub(/^[[:space:]]+/, "", line)
key = line; value = line
sub(/:.*$/, "", key)
sub(/^[^:]+:[[:space:]]*/, "", value)
gsub(/"/, "", value)
up = toupper(key)
print "PRESET_" up "=" value
}
' "$file"
}
# List available modes (sorted), printing a numbered menu.
# Returns the array of mode names via the global MODE_LIST.
MODE_LIST=()
list_modes() {
MODE_LIST=()
local i=1
for f in "$MODES_DIR"/*.md; do
[[ -f "$f" ]] || continue
local name purpose
name=$(basename "$f" .md)
purpose=$(awk '/^## Purpose$/{getline; getline; print; exit}' "$f")
printf " %d) %-7s — %s\n" "$i" "$name" "$purpose"
MODE_LIST+=("$name")
((i++))
done
}
driver_to_model() {
local d=$1
local valid="${!DRIVER_MAP[*]}"
if [[ -z "${DRIVER_MAP[$d]:-}" ]]; then
err "Unknown driver '$d'. Valid: $valid"
fi
echo "${DRIVER_MAP[$d]}"
}
resolve_profile_dir() {
local name=$1
if [[ "$name" == "default" ]]; then
echo "$DEFAULT_PROFILE_DIR"
else
echo "${PROFILE_PATTERN_PREFIX}${name}"
fi
}
# WezTerm theming (existing behaviour, unchanged)
set_wezterm_profile() {
local profile_name="$1"
printf '\e]1337;SetUserVar=%s=%s\a' CLAUDE_PROFILE "$(printf '%s' "$profile_name" | base64)"
}
reset_wezterm_profile() {
printf '\e]1337;SetUserVar=%s=%s\a' CLAUDE_PROFILE "$(printf '%s' '_reset' | base64)"
}
# === Phase 1: Profile selection ===
# Sanity check on the modes directory before doing any picker work.
[[ -d "$MODES_DIR" ]] || err "Mode files directory not found at $MODES_DIR"
if [[ -n "$PRESET" ]]; then
# Find the preset across all profile presets.yaml files.
preset_profile_dir=""
preset_lines=""
for candidate in "$DEFAULT_PROFILE_DIR" ${PROFILE_PATTERN_PREFIX}*; do
[[ -d "$candidate" ]] || continue
local_presets="$candidate/presets.yaml"
[[ -f "$local_presets" ]] || continue
if grep -q "^${PRESET}:[[:space:]]*$" "$local_presets"; then
preset_profile_dir="$candidate"
preset_lines=$(read_preset_block "$local_presets" "$PRESET")
break
fi
done
[[ -n "$preset_profile_dir" ]] || err "Preset '$PRESET' not found in any presets.yaml"
# Apply preset values (PRESET_PROFILE, PRESET_MODE, etc.)
while IFS='=' read -r k v; do
[[ -z "$k" ]] && continue
case "$k" in
PRESET_PROFILE) preset_profile_name="$v" ;;
PRESET_MODE) MODE="$v" ;;
PRESET_PROJECT) PROJECT="$v" ;;
PRESET_TIME_HORIZON) TIME_HORIZON="$v" ;;
PRESET_ASYNC) ASYNC_OVERRIDE="$v" ;;
PRESET_AUTOLOOP) AUTOLOOP_OVERRIDE="$v" ;;
esac
done <<< "$preset_lines"
[[ -n "${preset_profile_name:-}" ]] || err "Preset '$PRESET' is missing required field: profile"
# If user also gave a positional profile, it must match
if [[ -n "$PROFILE" && "$PROFILE" != "$preset_profile_name" ]]; then
err "Profile mismatch: positional '$PROFILE', preset specifies '$preset_profile_name'"
fi
PROFILE="$preset_profile_name"
target=$(resolve_profile_dir "$PROFILE")
elif [[ -n "$PROFILE" ]]; then
target=$(resolve_profile_dir "$PROFILE")
else
# Interactive profile picker
echo "Claude Code profiles:"
echo ""
echo " 1) default ($DEFAULT_PROFILE_DIR)"
profiles=("default")
i=2
for dir in ${PROFILE_PATTERN_PREFIX}*; do
[[ -d "$dir" ]] || continue
name="${dir##*/.claude-}"
echo " $i) $name (~/.claude-$name)"
profiles+=("$name")
((i++))
done
echo ""
if [[ -t 0 ]]; then
read -rp "Choose profile [1]: " choice
else
choice=""
fi
choice="${choice:-1}"
if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#profiles[@]} )); then
err "Invalid profile choice: $choice"
fi
PROFILE="${profiles[$((choice - 1))]}"
target=$(resolve_profile_dir "$PROFILE")
fi
[[ -d "$target" ]] || err "Profile directory not found: $target"
# === Phase 2: Mode selection ===
if [[ -z "$MODE" ]]; then
# Determine default from last-mode file
last_mode_file="$target/last-mode"
default_mode="quick"
if [[ -f "$last_mode_file" ]]; then
candidate=$(cat "$last_mode_file")
if [[ -f "$MODES_DIR/$candidate.md" ]]; then
default_mode="$candidate"
fi
fi
echo ""
echo "Engagement modes:"
list_modes
echo ""
if [[ -t 0 ]]; then
read -rp "Choose mode [$default_mode]: " mode_choice
else
mode_choice=""
fi
mode_choice="${mode_choice:-$default_mode}"
if [[ "$mode_choice" =~ ^[0-9]+$ ]]; then
if (( mode_choice < 1 || mode_choice > ${#MODE_LIST[@]} )); then
err "Invalid mode choice: $mode_choice"
fi
MODE="${MODE_LIST[$((mode_choice - 1))]}"
else
MODE="$mode_choice"
fi
fi
mode_file="$MODES_DIR/$MODE.md"
[[ -f "$mode_file" ]] || err "Mode file not found: $mode_file"
# Parse frontmatter
DRIVER=$(parse_frontmatter "$mode_file" "driver")
TAG=$(parse_frontmatter "$mode_file" "tag")
ASYNC_OK=$(parse_frontmatter "$mode_file" "async_ok")
AUTOLOOP=$(parse_frontmatter "$mode_file" "autoloop")
PLAN_MODE_AUTO=$(parse_frontmatter "$mode_file" "plan_mode_auto")
SPEC_DRIVEN=$(parse_frontmatter "$mode_file" "spec_driven")
ESCALATES_TO=$(parse_frontmatter "$mode_file" "escalates_to")
# Validate required frontmatter fields
for field_name in DRIVER TAG ASYNC_OK; do
if [[ -z "${!field_name}" ]]; then
err "Mode file $mode_file is missing required frontmatter field: $(echo "$field_name" | tr '[:upper:]' '[:lower:]')"
fi
done
# Mode-specific interactive questions (skipped if preset)
if [[ -z "$PRESET" ]]; then
case "$MODE" in
deep|hybrid|orch)
if [[ -z "${TIME_HORIZON:-}" ]]; then
if [[ -t 0 ]]; then
read -rp "Time horizon (minutes/hours/overnight) [hours]: " th
else
th=""
fi
TIME_HORIZON="${th:-hours}"
fi
;;
esac
fi
# Apply preset overrides for async/autoloop
[[ -n "${ASYNC_OVERRIDE:-}" ]] && ASYNC_OK="$ASYNC_OVERRIDE"
[[ -n "${AUTOLOOP_OVERRIDE:-}" ]] && {
if [[ "$AUTOLOOP_OVERRIDE" == "yes" || "$AUTOLOOP_OVERRIDE" == "no" ]]; then
: # leave AUTOLOOP as the mode default unless explicitly disabled
[[ "$AUTOLOOP_OVERRIDE" == "no" ]] && AUTOLOOP="none"
fi
}
# === Phase 3: Launch ===
MODEL_ID=$(driver_to_model "$DRIVER")
# Validate context-load exists
[[ -x "$CONTEXT_LOADER" ]] || err "context-load not found at $CONTEXT_LOADER"
# Build active-mode.env content
active_env=$(cat <<EOF
CLAUDE_MODE=$MODE
CLAUDE_MODE_TAG=$TAG
CLAUDE_DRIVER=$DRIVER
CLAUDE_ESCALATES_TO=${ESCALATES_TO:-none}
CLAUDE_PROJECT=${PROJECT:-}
CLAUDE_TIME_HORIZON=${TIME_HORIZON:-}
CLAUDE_ASYNC_OK=$ASYNC_OK
CLAUDE_AUTOLOOP=$AUTOLOOP
CLAUDE_PLAN_MODE_AUTO=$PLAN_MODE_AUTO
CLAUDE_SPEC_DRIVEN=$SPEC_DRIVEN
CLAUDE_MODE_FILE=$mode_file
EOF
)
# Mode body (everything after the frontmatter)
body=$(mode_body "$mode_file")
if [[ -z "$body" ]]; then
err "Mode file has no body — nothing to load into the system prompt: $mode_file"
fi
# Pre-flight context-load (do this even in dryrun so length numbers are accurate,
# but suppress side effects — context-load is read-only).
context=$("$CONTEXT_LOADER")
combined_context="${context}
# === Engagement Mode: ${MODE} ===
${body}"
if (( DRYRUN )); then
dryrun_log "Profile: $PROFILE ($target)"
dryrun_log "Mode: $MODE"
dryrun_log "Mode file: $mode_file"
dryrun_log "Driver: $DRIVER"
dryrun_log "Driver model: $MODEL_ID"
dryrun_log "Project: ${PROJECT:-<not set, will be picked by CLAUDE.md>}"
dryrun_log "Time horizon: ${TIME_HORIZON:-<not asked in this mode>}"
dryrun_log "Async OK: $ASYNC_OK"
dryrun_log "Autoloop: $AUTOLOOP"
dryrun_log "Escalates to: ${ESCALATES_TO:-none}"
dryrun_log "Plan mode auto: $PLAN_MODE_AUTO"
dryrun_log "Spec driven: $SPEC_DRIVEN"
dryrun_log "Would write: $target/active-mode.env"
dryrun_log "Would write: $target/last-mode"
if (( ${#CLAUDE_ARGS[@]} > 0 )); then
dryrun_log "Would execute: claude --model $MODEL_ID --append-system-prompt <context+mode> ${CLAUDE_ARGS[*]}"
else
dryrun_log "Would execute: claude --model $MODEL_ID --append-system-prompt <context+mode>"
fi
dryrun_log "Context length: ${#combined_context} chars (${#context} from context-load + ${#body} from mode body)"
dryrun_log "No changes made."
exit 0
fi
# === Real launch from here on ===
# Set up WezTerm theming and reset trap
trap reset_wezterm_profile EXIT
export CLAUDE_CONFIG_DIR="$target"
set_wezterm_profile "$PROFILE"
# Disable adaptive thinking (existing behaviour)
export CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1
# Write state files
printf '%s\n' "$active_env" > "$target/active-mode.env"
printf '%s\n' "$MODE" > "$target/last-mode"
echo "Launching: $PROFILE / $MODE${PROJECT:+ / $PROJECT}"
exec claude --model "$MODEL_ID" --append-system-prompt "$combined_context" "${CLAUDE_ARGS[@]}"