Files
small-scripts/scripts/claude-profile
Paul O'Reilly b76e56f750 fix(claude-profile): use --append-system-prompt-file to avoid ARG_MAX on large projects
Passing combined context inline as a CLI arg hits Linux ARG_MAX (~2 MB)
when project CONTEXT.md or MEMORY.md are large. Three changes:

1. Write combined_context to a mktemp file and pass via
   --append-system-prompt-file instead of the inline --append-system-prompt arg.

2. Drop exec in favour of a subprocess call so the EXIT trap fires
   after claude exits, properly resetting the WezTerm theme and cleaning
   up the temp file. (With exec, bash is replaced immediately and traps
   never fire on success — WezTerm reset was silently broken.)

3. Warn when context exceeds 150 KB and show context size (in KB) on
   the launch line and in dryrun output, so oversized CONTEXT.md files
   are visible before they become a crash.
2026-06-22 10:17:31 +12:00

451 lines
15 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> # all from presets.yaml
# claude-profile <profile> --mode <name> # profile + mode 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 -> 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-8" ;;
opus-1m) echo "claude-opus-4-8[1m]" ;;
fable) echo "claude-fable-5" ;;
*) err "Unknown driver '$1'. Valid: haiku sonnet opus opus-1m fable" ;;
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 <<EOF
CLAUDE_MODE=$MODE
CLAUDE_MODE_TAG=$TAG
CLAUDE_DRIVER=$DRIVER
CLAUDE_ESCALATES_TO=${ESCALATES_TO:-none}
CLAUDE_PROJECT=${PROJECT:-}
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}"
ctx_kb=$(( ${#combined_context} / 1024 ))
if (( ${#combined_context} > 150000 )); then
echo "Warning: context is ${ctx_kb}KB — large projects may benefit from trimming CONTEXT.md or MEMORY.md" >&2
fi
if (( DRYRUN )); then
dryrun_log "Profile: $PROFILE ($target)"
dryrun_log "Mode: $MODE"
dryrun_log "Mode file: $mode_file"
dryrun_log "Driver: $DRIVER"
dryrun_log "Model ID: $MODEL_ID"
[[ -n "${PROVIDER_BASE_URL:-}" ]] && dryrun_log "Base URL: $PROVIDER_BASE_URL"
[[ -n "${PROVIDER_API_KEY_FILE:-}" ]] && dryrun_log "API key file: $PROVIDER_API_KEY_FILE"
[[ -f "$PROVIDER_ENV" ]] && dryrun_log "Provider env: $PROVIDER_ENV"
dryrun_log "Project: ${PROJECT:-<not set, will be picked by CLAUDE.md>}"
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-file <tmpfile> ${CLAUDE_ARGS[*]}"
else
dryrun_log "Would execute: claude --model $MODEL_ID --append-system-prompt-file <tmpfile>"
fi
dryrun_log "Context length: ${#combined_context} chars (~${ctx_kb}KB; ${#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"
# Write context to a temp file — passing it inline as an argument hits ARG_MAX
# on large projects (context-load output can exceed 2 MB).
# Dropping exec (→ subprocess) means the EXIT trap fires after claude exits,
# which properly resets the WezTerm theme and cleans up the temp file.
context_file=$(mktemp /tmp/claude-profile-ctx-XXXXXX.md)
printf '%s\n' "$combined_context" > "$context_file"
trap 'rm -f "${context_file:-}"; reset_wezterm_profile' EXIT
echo "Launching: $PROFILE / $MODE${PROJECT:+ / $PROJECT} (context: ${ctx_kb}KB)"
claude --model "$MODEL_ID" --append-system-prompt-file "$context_file" "${CLAUDE_ARGS[@]}"