claude-profile: remove provider-yaml system, embed model config in profiles

Provider selection phase removed; Anthropic driver→model_id is now
hardcoded in the script and non-Anthropic profiles supply provider.env
(MODEL_ID, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY_FILE).

Test suite baseline changes 58 → 41 assertions: the 17 dropped
assertions covered the removed provider-yaml selection phase, not
retained behaviour.

Includes refactor-derived memory: decisions (provider architecture,
driver enforcement via --model), bash gotchas (read -rp under set -e,
never eval user config), process lessons.
This commit is contained in:
Paul O'Reilly
2026-06-12 21:26:07 +12:00
parent c0a2110ee6
commit 9e13b7f096
16 changed files with 271 additions and 398 deletions

View File

@@ -1,11 +1,10 @@
#!/usr/bin/env bash
# claude-profile — Launch Claude Code with a profile, engagement mode, and provider
# claude-profile — Launch Claude Code with a profile and engagement mode
#
# Usage:
# claude-profile # interactive: profile + mode + provider
# claude-profile <profile> # profile fixed, rest interactive
# claude-profile --mode <name> # mode fixed, profile + provider interactive
# claude-profile --provider <name> # provider fixed, profile + mode interactive
# 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
@@ -23,7 +22,6 @@ SCRIPT_PATH="$(readlink -f "$0")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MODES_DIR="$REPO_ROOT/data/claude-profile/modes"
PROVIDERS_DIR="$REPO_ROOT/data/claude-profile/providers"
# context-load is on PATH (symlinked into ~/sbin from claude-foundations).
# Fall back to a sibling location for testing isolation.
@@ -33,13 +31,13 @@ else
CONTEXT_LOADER="${SCRIPT_DIR}/context-load"
fi
# Driver logical name -> default provider (fallback when --provider not given)
driver_default_provider() {
# Driver logical name -> Anthropic model ID
driver_model_id() {
case "$1" in
haiku) echo "anthropic-haiku" ;;
sonnet) echo "anthropic-sonnet" ;;
opus) echo "anthropic-opus" ;;
opus-1m) echo "anthropic-opus-1m" ;;
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
}
@@ -48,7 +46,6 @@ driver_default_provider() {
PROFILE=""
MODE=""
PROVIDER=""
PROJECT=""
PRESET=""
DRYRUN=0
@@ -61,7 +58,6 @@ usage() {
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) MODE="${2:-}"; shift 2 ;;
--provider) PROVIDER="${2:-}"; shift 2 ;;
--preset) PRESET="${2:-}"; shift 2 ;;
--project) PROJECT="${2:-}"; shift 2 ;;
--dryrun|-n) DRYRUN=1; shift ;;
@@ -81,8 +77,8 @@ while [[ $# -gt 0 ]]; do
done
# Mutual exclusion
if [[ -n "$PRESET" && ( -n "$MODE" || -n "$PROJECT" || -n "$PROVIDER" ) ]]; then
echo "Error: --preset is mutually exclusive with --mode, --provider, and --project" >&2
if [[ -n "$PRESET" && ( -n "$MODE" || -n "$PROJECT" ) ]]; then
echo "Error: --preset is mutually exclusive with --mode and --project" >&2
exit 1
fi
@@ -145,40 +141,6 @@ read_preset_block() {
' "$file"
}
# Extract a scalar field from a provider YAML file (non-indented key: value).
parse_provider_field() {
local file=$1 key=$2
awk -v key="$key" '
/^extra_env:[[:space:]]*$/ { in_extra=1; next }
/^[a-zA-Z_]/ { in_extra=0 }
!in_extra {
if (match($0, "^"key":[[:space:]]*")) {
val = substr($0, RLENGTH+1)
gsub(/^[[:space:]]*"?/, "", val)
gsub(/"?[[:space:]]*$/, "", val)
print val; exit
}
}
' "$file"
}
# Output KEY=value lines for the extra_env block in a provider YAML file.
parse_extra_env() {
local file=$1
awk '
/^extra_env:[[:space:]]*$/ { in_extra=1; next }
/^[a-zA-Z_]/ { in_extra=0 }
in_extra && /^[[:space:]]+[A-Z_]/ {
line = $0; sub(/^[[:space:]]+/, "", line)
key = line; val = line
sub(/:.*$/, "", key)
sub(/^[^:]+:[[:space:]]*/, "", val)
gsub(/^[[:space:]]*"?/, "", val); gsub(/"?[[:space:]]*$/, "", val)
print key "=" val
}
' "$file"
}
# List available modes (sorted), printing a numbered menu.
# Returns the array of mode names via the global MODE_LIST.
MODE_LIST=()
@@ -196,23 +158,6 @@ list_modes() {
done
}
# List available providers, printing a numbered menu.
# Returns the array of provider names via the global PROVIDER_LIST.
PROVIDER_LIST=()
list_providers() {
PROVIDER_LIST=()
local i=1
for f in "$PROVIDERS_DIR"/*.yaml; do
[[ -f "$f" ]] || continue
local name display
name=$(parse_provider_field "$f" name)
display=$(parse_provider_field "$f" display)
printf " %d) %-22s — %s\n" "$i" "$name" "$display"
PROVIDER_LIST+=("$name")
i=$((i + 1))
done
}
resolve_profile_dir() {
local name=$1
if [[ "$name" == "default" ]]; then
@@ -235,7 +180,6 @@ reset_wezterm_profile() {
# Sanity check on the modes directory before doing any picker work.
[[ -d "$MODES_DIR" ]] || err "Mode files directory not found at $MODES_DIR"
[[ -d "$PROVIDERS_DIR" ]] || err "Providers directory not found at $PROVIDERS_DIR"
if [[ -n "$PRESET" ]]; then
# Find the preset across all profile presets.yaml files.
@@ -259,9 +203,7 @@ if [[ -n "$PRESET" ]]; then
case "$k" in
PRESET_PROFILE) preset_profile_name="$v" ;;
PRESET_MODE) MODE="$v" ;;
PRESET_PROVIDER) PROVIDER="$v" ;;
PRESET_PROJECT) PROJECT="$v" ;;
PRESET_TIME_HORIZON) TIME_HORIZON="$v" ;;
PRESET_ASYNC) ASYNC_OVERRIDE="$v" ;;
PRESET_AUTOLOOP) AUTOLOOP_OVERRIDE="$v" ;;
esac
@@ -307,6 +249,16 @@ 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
@@ -360,22 +312,6 @@ for field_name in DRIVER TAG ASYNC_OK; do
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:-}" ]] && {
@@ -384,55 +320,25 @@ fi
fi
}
# === Phase 2.5: Provider selection ===
# === Phase 2.5: Resolve model ID ===
default_provider=$(driver_default_provider "$DRIVER")
# Default: Anthropic model from driver
MODEL_ID=$(driver_model_id "$DRIVER")
if [[ -z "$PROVIDER" ]]; then
if [[ -z "$PRESET" ]]; then
echo ""
echo "Provider (model + API):"
list_providers
echo ""
if [[ -t 0 ]]; then
read -rp "Choose provider [$default_provider]: " prov_choice
else
prov_choice=""
fi
prov_choice="${prov_choice:-$default_provider}"
if [[ "$prov_choice" =~ ^[0-9]+$ ]]; then
if (( prov_choice < 1 || prov_choice > ${#PROVIDER_LIST[@]} )); then
err "Invalid provider choice: $prov_choice"
fi
PROVIDER="${PROVIDER_LIST[$((prov_choice - 1))]}"
else
PROVIDER="$prov_choice"
fi
else
PROVIDER="$default_provider"
fi
fi
provider_file="$PROVIDERS_DIR/$PROVIDER.yaml"
[[ -f "$provider_file" ]] || err "Provider not found: $PROVIDER (looked in $PROVIDERS_DIR)"
PROVIDER_DISPLAY=$(parse_provider_field "$provider_file" display)
MODEL_ID=$(parse_provider_field "$provider_file" model_id)
PROVIDER_BASE_URL=$(parse_provider_field "$provider_file" base_url)
PROVIDER_API_KEY_ENV=$(parse_provider_field "$provider_file" api_key_env)
PROVIDER_API_KEY_FILE=$(parse_provider_field "$provider_file" api_key_file)
[[ -n "$MODEL_ID" ]] || err "Provider file $provider_file is missing required field: model_id"
# Warn when a non-Anthropic provider is selected but the profile has stored OAuth credentials.
# Claude Code uses .credentials.json for auth before checking ANTHROPIC_API_KEY, which
# routes requests to Anthropic regardless of ANTHROPIC_BASE_URL.
if [[ -n "$PROVIDER_BASE_URL" && -f "$target/.credentials.json" ]]; then
echo "Warning: profile '$PROFILE' has stored OAuth credentials ($target/.credentials.json)." >&2
echo " These take precedence over ANTHROPIC_API_KEY and will route requests to Anthropic," >&2
echo " not $PROVIDER. Use a profile without .credentials.json for non-Anthropic providers." >&2
echo " See: claude-profile --help" >&2
# 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 ===
@@ -445,10 +351,8 @@ active_env=$(cat <<EOF
CLAUDE_MODE=$MODE
CLAUDE_MODE_TAG=$TAG
CLAUDE_DRIVER=$DRIVER
CLAUDE_PROVIDER=$PROVIDER
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
@@ -478,12 +382,11 @@ if (( DRYRUN )); then
dryrun_log "Mode: $MODE"
dryrun_log "Mode file: $mode_file"
dryrun_log "Driver: $DRIVER"
dryrun_log "Provider: $PROVIDER ($PROVIDER_DISPLAY)"
dryrun_log "Model ID: $MODEL_ID"
[[ -n "$PROVIDER_BASE_URL" ]] && dryrun_log "Base URL: $PROVIDER_BASE_URL"
[[ -n "$PROVIDER_API_KEY_ENV" ]] && dryrun_log "API key env: $PROVIDER_API_KEY_ENV (from $PROVIDER_API_KEY_FILE)"
[[ -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 "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}"
@@ -503,32 +406,31 @@ 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
# Export provider env vars for non-Anthropic providers
if [[ -n "$PROVIDER_BASE_URL" ]]; then
# 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_ENV" && -n "$PROVIDER_API_KEY_FILE" ]]; then
if [[ -n "${PROVIDER_API_KEY_FILE:-}" ]]; then
expanded_key_file="${PROVIDER_API_KEY_FILE/#\~/$HOME}"
[[ -f "$expanded_key_file" ]] || err "Provider API key file not found: $expanded_key_file"
[[ -f "$expanded_key_file" ]] || err "API key file not found: $expanded_key_file"
api_key=$(cat "$expanded_key_file")
export "${PROVIDER_API_KEY_ENV}=${api_key}"
export ANTHROPIC_API_KEY="$api_key"
fi
# Export extra_env entries from provider file
while IFS='=' read -r k v; do
[[ -n "$k" ]] && export "${k}=${v}"
done < <(parse_extra_env "$provider_file")
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 / $PROVIDER${PROJECT:+ / $PROJECT}"
echo "Launching: $PROFILE / $MODE${PROJECT:+ / $PROJECT}"
exec claude --model "$MODEL_ID" --append-system-prompt "$combined_context" "${CLAUDE_ARGS[@]}"