#!/usr/bin/env bash # claude-profile — Launch Claude Code with a profile and engagement mode # # Usage: # claude-profile # interactive: profile + mode # claude-profile # profile fixed, mode interactive # claude-profile --mode # mode fixed, profile interactive # claude-profile --preset # all from presets.yaml # claude-profile --mode # profile + mode fixed # claude-profile --dryrun [...] # show resolved values, do not launch # claude-profile [...] -- # 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 -> Anthropic model ID driver_model_id() { case "$1" in haiku) echo "claude-haiku-4-5-20251001" ;; sonnet) echo "claude-sonnet-4-6" ;; opus) echo "claude-opus-4-6" ;; opus-1m) echo "claude-opus-4-6[1m]" ;; *) err "Unknown driver '$1'. Valid: haiku sonnet opus opus-1m" ;; esac } # === 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=$((i + 1)) done } 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 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_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=$((i + 1)) 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" # Apply WezTerm theme immediately so the terminal changes during the mode picker. # Read theme name from profile's wezterm-theme file, falling back to the profile name. if [[ -f "$target/wezterm-theme" ]]; then WEZTERM_THEME=$(cat "$target/wezterm-theme") else WEZTERM_THEME="$PROFILE" fi trap reset_wezterm_profile EXIT set_wezterm_profile "$WEZTERM_THEME" # === 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 # Apply preset overrides for async/autoloop [[ -n "${ASYNC_OVERRIDE:-}" ]] && ASYNC_OK="$ASYNC_OVERRIDE" [[ -n "${AUTOLOOP_OVERRIDE:-}" ]] && { if [[ "$AUTOLOOP_OVERRIDE" == "yes" || "$AUTOLOOP_OVERRIDE" == "no" ]]; then [[ "$AUTOLOOP_OVERRIDE" == "no" ]] && AUTOLOOP="none" fi } # === Phase 2.5: Resolve model ID === # Default: Anthropic model from driver MODEL_ID=$(driver_model_id "$DRIVER") # Override from profile's provider.env if present PROVIDER_ENV="$target/provider.env" if [[ -f "$PROVIDER_ENV" ]]; then # Source provider.env into a subshell-safe set of variables. # We parse key=value lines rather than sourcing directly to avoid side effects. while IFS='=' read -r key value; do [[ -z "$key" || "$key" == \#* ]] && continue case "$key" in MODEL_ID) MODEL_ID="$value" ;; ANTHROPIC_BASE_URL) PROVIDER_BASE_URL="$value" ;; ANTHROPIC_API_KEY_FILE) PROVIDER_API_KEY_FILE="$value" ;; *) PROVIDER_EXTRA_ENV+=("$key=$value") ;; esac done < "$PROVIDER_ENV" fi # === Phase 3: Launch === # Validate context-load exists [[ -x "$CONTEXT_LOADER" ]] || err "context-load not found at $CONTEXT_LOADER" # Build active-mode.env content active_env=$(cat <}" 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 ${CLAUDE_ARGS[*]}" else dryrun_log "Would execute: claude --model $MODEL_ID --append-system-prompt " 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 === export CLAUDE_CONFIG_DIR="$target" # Disable adaptive thinking (existing behaviour) export CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1 # Ensure proper colour support export COLORTERM=truecolor # Export provider env vars from profile's provider.env if [[ -n "${PROVIDER_BASE_URL:-}" ]]; then export ANTHROPIC_BASE_URL="$PROVIDER_BASE_URL" fi if [[ -n "${PROVIDER_API_KEY_FILE:-}" ]]; then expanded_key_file="${PROVIDER_API_KEY_FILE/#\~/$HOME}" [[ -f "$expanded_key_file" ]] || err "API key file not found: $expanded_key_file" api_key=$(cat "$expanded_key_file") export ANTHROPIC_API_KEY="$api_key" fi for entry in "${PROVIDER_EXTRA_ENV[@]+"${PROVIDER_EXTRA_ENV[@]}"}"; do [[ -n "$entry" ]] && export "$entry" done # 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[@]}"