feat(claude-profile): add provider selection (model + endpoint + credentials)

Introduces provider files (`data/claude-profile/providers/*.yaml`) that
bundle model ID, API base URL, API key source, and extra env vars. Phase 2.5
(provider selection) added between mode selection and launch, with a default
derived from the mode's driver field so existing Anthropic usage is unchanged.

Ships 5 providers: anthropic-haiku/sonnet/opus/opus-1m and minimax-sonnet.
MiniMax token decrypted from agent-runtimes SOPS env, stored at
~/.claude-secrets/minimax-auth-token. Adding new providers = one YAML file,
no script changes.

58 dryrun tests passing (up from 37).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul O'Reilly
2026-04-23 18:16:22 +12:00
parent 5a1b4b14bc
commit a11051e0a8
10 changed files with 362 additions and 86 deletions

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env bash
# claude-profile — Launch Claude Code with a profile and engagement mode
# claude-profile — Launch Claude Code with a profile, engagement mode, and provider
#
# 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 # 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 --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
#
@@ -22,6 +23,7 @@ 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.
@@ -31,18 +33,22 @@ 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]"
)
# Driver logical name -> default provider (fallback when --provider not given)
driver_default_provider() {
case "$1" in
haiku) echo "anthropic-haiku" ;;
sonnet) echo "anthropic-sonnet" ;;
opus) echo "anthropic-opus" ;;
opus-1m) echo "anthropic-opus-1m" ;;
*) err "Unknown driver '$1'. Valid: haiku sonnet opus opus-1m" ;;
esac
}
# === CLI parsing ===
PROFILE=""
MODE=""
PROVIDER=""
PROJECT=""
PRESET=""
DRYRUN=0
@@ -54,13 +60,14 @@ usage() {
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) MODE="${2:-}"; shift 2 ;;
--preset) PRESET="${2:-}"; shift 2 ;;
--project) PROJECT="${2:-}"; shift 2 ;;
--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 ;;
--help|-h) usage; exit 0 ;;
--) shift; CLAUDE_ARGS=("$@"); break ;;
-*) echo "Unknown flag: $1" >&2; usage >&2; exit 1 ;;
--help|-h) usage; exit 0 ;;
--) shift; CLAUDE_ARGS=("$@"); break ;;
-*) echo "Unknown flag: $1" >&2; usage >&2; exit 1 ;;
*)
if [[ -z "$PROFILE" ]]; then
PROFILE="$1"
@@ -74,8 +81,8 @@ while [[ $# -gt 0 ]]; do
done
# Mutual exclusion
if [[ -n "$PRESET" && ( -n "$MODE" || -n "$PROJECT" ) ]]; then
echo "Error: --preset is mutually exclusive with --mode and --project" >&2
if [[ -n "$PRESET" && ( -n "$MODE" || -n "$PROJECT" || -n "$PROVIDER" ) ]]; then
echo "Error: --preset is mutually exclusive with --mode, --provider, and --project" >&2
exit 1
fi
@@ -138,6 +145,40 @@ 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=()
@@ -151,17 +192,25 @@ list_modes() {
purpose=$(awk '/^## Purpose$/{getline; getline; print; exit}' "$f")
printf " %d) %-7s — %s\n" "$i" "$name" "$purpose"
MODE_LIST+=("$name")
((i++))
i=$((i + 1))
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]}"
# 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() {
@@ -186,6 +235,7 @@ 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.
@@ -203,12 +253,13 @@ if [[ -n "$PRESET" ]]; then
done
[[ -n "$preset_profile_dir" ]] || err "Preset '$PRESET' not found in any presets.yaml"
# Apply preset values (PRESET_PROFILE, PRESET_MODE, etc.)
# 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_PROVIDER) PROVIDER="$v" ;;
PRESET_PROJECT) PROJECT="$v" ;;
PRESET_TIME_HORIZON) TIME_HORIZON="$v" ;;
PRESET_ASYNC) ASYNC_OVERRIDE="$v" ;;
@@ -238,7 +289,7 @@ else
name="${dir##*/.claude-}"
echo " $i) $name (~/.claude-$name)"
profiles+=("$name")
((i++))
i=$((i + 1))
done
echo ""
if [[ -t 0 ]]; then
@@ -329,14 +380,52 @@ fi
[[ -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 ===
# === Phase 2.5: Provider selection ===
MODEL_ID=$(driver_to_model "$DRIVER")
default_provider=$(driver_default_provider "$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"
# === Phase 3: Launch ===
# Validate context-load exists
[[ -x "$CONTEXT_LOADER" ]] || err "context-load not found at $CONTEXT_LOADER"
@@ -346,6 +435,7 @@ 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:-}
@@ -374,11 +464,14 @@ combined_context="${context}
${body}"
if (( DRYRUN )); then
dryrun_log "Profile: $PROFILE ($target)"
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 "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)"
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"
@@ -408,9 +501,24 @@ 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
export ANTHROPIC_BASE_URL="$PROVIDER_BASE_URL"
fi
if [[ -n "$PROVIDER_API_KEY_ENV" && -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"
api_key=$(cat "$expanded_key_file")
export "${PROVIDER_API_KEY_ENV}=${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")
# 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}"
echo "Launching: $PROFILE / $MODE / $PROVIDER${PROJECT:+ / $PROJECT}"
exec claude --model "$MODEL_ID" --append-system-prompt "$combined_context" "${CLAUDE_ARGS[@]}"