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.
451 lines
15 KiB
Bash
Executable File
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[@]}"
|