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

@@ -2,7 +2,7 @@
## Purpose
Launch Claude Code with a named configuration profile, an engagement mode, and a provider. The profile selects which `~/.claude-*` config directory to use; the engagement mode bundles a driver model, subagent policy, async tolerance, and workflow stance; the provider determines the model ID, API endpoint, and credentials.
Launch Claude Code with a named configuration profile and an engagement mode. The profile selects which `~/.claude-*` config directory to use (and optionally embeds non-Anthropic provider config); the engagement mode bundles a driver model, subagent policy, async tolerance, and workflow stance.
## Usage
@@ -22,20 +22,19 @@ claude-profile [OPTIONS] [PROFILE] [-- CLAUDE_ARGS...]
| Flag | Description |
|------|-------------|
| `--mode <NAME>` | Skip the mode picker and use the named mode (`chat`, `quick`, `deep`, `hybrid`, `orch`). |
| `--provider <NAME>` | Skip the provider picker and use the named provider (e.g. `anthropic-sonnet`, `minimax-sonnet`). |
| `--preset <NAME>` | Use a named preset from the active profile's `presets.yaml`. Sets profile, mode, provider, and any preset-defined defaults in one shot. |
| `--preset <NAME>` | Use a named preset from the active profile's `presets.yaml`. Sets profile, mode, and any preset-defined defaults in one shot. |
| `--project <NAME>` | Pre-select a project (skips the project question for modes that ask it). |
| `--dryrun`, `-n` | Print all resolved values and the command that would be executed; do not launch `claude`, do not write `active-mode.env` or `last-mode`. |
| `--help`, `-h` | Show usage. |
### Mutual exclusion
- `--preset` is mutually exclusive with `--mode`, `--provider`, and `--project` — the preset defines all of those. Passing both is an error.
- `--preset` is mutually exclusive with `--mode` and `--project` — the preset defines all of those. Passing both is an error.
- A positional `PROFILE` combined with `--preset` is allowed only if the preset's `profile` field matches; mismatch is an error.
## Behaviour
The script runs in four phases: **profile selection**, **mode selection**, **provider selection**, **launch**. Each phase has an interactive path (prompt the user) and a non-interactive path (CLI flag or config file).
The script runs in three phases: **profile selection**, **mode selection**, **launch**. Each phase has an interactive path (prompt the user) and a non-interactive path (CLI flag or config file).
### Phase 1 — Profile selection
@@ -49,20 +48,30 @@ The script runs in four phases: **profile selection**, **mode selection**, **pro
### Phase 2 — Mode selection
1. If `--preset` is given, use the preset's `mode` and any other fields (project, time horizon, async, autoloop). Skip the rest of phase 2.
2. Else if `--mode <name>` is given, use it and proceed to mode-specific questions (project, async, etc., depending on the mode).
1. If `--preset` is given, use the preset's `mode` and any other fields (project, async, autoloop). Skip the rest of phase 2.
2. Else if `--mode <name>` is given, use it.
3. Else (interactive): list available modes by scanning `<repo>/data/claude-profile/modes/*.md`, display a numbered menu with each mode's `tag` and the first sentence of its `## Purpose` section. Default selection is the contents of `$CLAUDE_CONFIG_DIR/last-mode` (if present), else `quick`.
4. After the mode is chosen, ask only the questions relevant to that mode (see "Mode follow-up questions" below). Skip questions already answered by CLI flags.
5. Validate the chosen mode file exists at `<repo>/data/claude-profile/modes/<name>.md`. If not, error and exit 1.
6. Read the mode file's frontmatter to extract `driver`, `tag`, `async_ok`, `autoloop`, `plan_mode_auto`, `spec_driven`.
4. Validate the chosen mode file exists at `<repo>/data/claude-profile/modes/<name>.md`. If not, error and exit 1.
5. Read the mode file's frontmatter to extract `driver`, `tag`, `async_ok`, `autoloop`, `plan_mode_auto`, `spec_driven`, `escalates_to`.
### Phase 2.5 — Provider selection
### Model ID resolution
1. If `--preset` is given, use the preset's `provider` field (if present) or derive the default from the mode's `driver` field. Skip the rest of phase 2.5.
2. Else if `--provider <name>` is given, use it.
3. Else (interactive): derive the default provider from the mode's `driver` field (see "Driver-to-provider defaults" below). List available providers from `<repo>/data/claude-profile/providers/*.yaml`, display a numbered menu, prompt for selection. Default is the driver-derived provider, so pressing Enter preserves existing Anthropic behaviour.
4. Validate the chosen provider file exists at `<repo>/data/claude-profile/providers/<name>.yaml`. If not, error and exit 1.
5. Read the provider file to extract `model_id`, `base_url`, `api_key_env`, `api_key_file`, and `extra_env`.
The model ID is determined by combining the mode's `driver` field with the profile's optional `provider.env`:
1. Map the driver to an Anthropic model ID using the hardcoded table:
| Driver | Model ID |
|---|---|
| `haiku` | `claude-haiku-4-5-20251001` |
| `sonnet` | `claude-sonnet-4-6` |
| `opus` | `claude-opus-4-6` |
| `opus-1m` | `claude-opus-4-6[1m]` |
2. If `$CLAUDE_CONFIG_DIR/provider.env` exists, parse it. If it contains a `MODEL_ID` entry, use that instead of the hardcoded default.
This means Anthropic profiles need no extra config. Non-Anthropic profiles embed their model and API config in `provider.env`.
**Project selection is intentionally NOT asked by claude-profile.** The existing CLAUDE.md session-start flow handles project selection once `claude` is running. claude-profile accepts `--project <name>` as a non-interactive override (for presets and scripted launches), which writes `CLAUDE_PROJECT` into `active-mode.env`.
### Phase 3 — Launch
@@ -70,38 +79,12 @@ The script runs in four phases: **profile selection**, **mode selection**, **pro
2. Write the chosen mode name to `$CLAUDE_CONFIG_DIR/last-mode`.
3. Read the mode file's prose body (everything after the frontmatter) and append it to the context that `context-load` produces. The mode body becomes part of `--append-system-prompt`.
4. Set `CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1` (existing behaviour).
5. If the provider has a `base_url`, export `ANTHROPIC_BASE_URL=<value>`.
6. If the provider has `api_key_env` and `api_key_file`, read the key from the file and export it as `<api_key_env>=<value>`. Error if the file is missing.
7. Export any `extra_env` entries from the provider file.
8. Execute `claude --model "<provider.model_id>" --append-system-prompt "$context_plus_mode" "${CLAUDE_ARGS[@]}"`.
9. On exit, the existing WezTerm reset trap runs.
### Driver-to-provider defaults
When no `--provider` is given, the default provider is derived from the mode's `driver` field:
| Mode `driver` | Default provider |
|---|---|
| `haiku` | `anthropic-haiku` |
| `sonnet` | `anthropic-sonnet` |
| `opus` | `anthropic-opus` |
| `opus-1m` | `anthropic-opus-1m` |
This preserves backwards-compatible behaviour: launching without `--provider` behaves exactly as before.
### Mode follow-up questions
Each mode declares which questions are relevant. Asked in order; only those not already supplied via CLI flags are prompted for.
| Question | Asked in modes | Default | Drives |
|---|---|---|---|
| **Time horizon?** (minutes / hours / overnight) | deep, hybrid, orch | hours | Recorded in active-mode.env; the model adapts ScheduleWakeup cadence |
| **Async OK?** (yes / no) | deep, hybrid | mode default (yes for both) | Overrides `async_ok` |
| **Auto-loops?** (yes / no, confirms `/loop 2m /orchestrate`) | orch | yes | Overrides `autoloop` |
`chat` and `quick` ask no questions interactively.
**Project selection is intentionally NOT asked by claude-profile.** The existing CLAUDE.md session-start flow handles project selection once `claude` is running. claude-profile accepts `--project <name>` as a non-interactive override (for presets and scripted launches), which writes `CLAUDE_PROJECT` into `active-mode.env`. A future CLAUDE.md update will read that field and skip its own picker when set; until then, supplying `--project` will result in the CLAUDE.md picker still appearing — the user can confirm the same project. This is a documented v1 limitation.
5. Set `COLORTERM=truecolor` for proper colour support.
6. If `provider.env` specifies `ANTHROPIC_BASE_URL`, export it.
7. If `provider.env` specifies `ANTHROPIC_API_KEY_FILE`, read the key from the file and export it as `ANTHROPIC_API_KEY`. Error if the file is missing.
8. Export any other entries from `provider.env` as environment variables.
9. Execute `claude --model "<model_id>" --append-system-prompt "$context_plus_mode" "${CLAUDE_ARGS[@]}"`.
10. On exit, the existing WezTerm reset trap runs.
## File contracts
@@ -122,33 +105,25 @@ escalates_to: <none|opus> # used by statusline to render →Opus arrow
The body must include sections (in order): `## Purpose`, `## Driver constraint`, `## Subagent policy`, `## Reasoning posture`, `## Async policy`, `## Workflow stance`, `## Auto-fire at session start`, `## Status line format`, `## Escalation triggers`, `## Out of scope`, `## On context wipe (/clear)`. `## Named workflows available` is optional.
### Provider files — `<repo>/data/claude-profile/providers/<name>.yaml`
### Profile provider config — `$CLAUDE_CONFIG_DIR/provider.env`
Plain YAML (no frontmatter). Required field: `model_id`. All other fields are optional.
Optional. Only needed for non-Anthropic profiles. Plain key=value format (no quoting, no shell expansion):
```yaml
name: minimax-sonnet # unique identifier, matches filename
display: MiniMax (M2.7) # shown in the interactive picker
model_id: MiniMax-M2.7 # passed to claude --model
base_url: https://api.minimax.io/anthropic # exported as ANTHROPIC_BASE_URL (omit for Anthropic)
api_key_env: ANTHROPIC_API_KEY # env var name for the API key (omit for Anthropic)
api_key_file: ~/.claude-secrets/minimax-auth-token # file containing the raw token value
extra_env:
# Claude Code uses separate tier vars; all must point to the same model to prevent Anthropic fallback
ANTHROPIC_MODEL: MiniMax-M2.7
ANTHROPIC_SMALL_FAST_MODEL: MiniMax-M2.7
ANTHROPIC_DEFAULT_SONNET_MODEL: MiniMax-M2.7
ANTHROPIC_DEFAULT_OPUS_MODEL: MiniMax-M2.7
ANTHROPIC_DEFAULT_HAIKU_MODEL: MiniMax-M2.7
API_TIMEOUT_MS: "3000000"
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1"
DISABLE_PROMPT_CACHING: "1"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
```
MODEL_ID=MiniMax-M2.7
ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
ANTHROPIC_API_KEY_FILE=~/.claude-secrets/minimax-auth-token
ANTHROPIC_MODEL=MiniMax-M2.7
ANTHROPIC_SMALL_FAST_MODEL=MiniMax-M2.7
API_TIMEOUT_MS=3000000
```
Anthropic providers need only `name`, `display`, and `model_id` — Claude Code handles Anthropic auth via its own config.
Reserved keys with special handling:
- `MODEL_ID` — overrides the driver→model_id mapping; passed to `claude --model`
- `ANTHROPIC_BASE_URL` — exported as-is
- `ANTHROPIC_API_KEY_FILE` — file is read and its contents exported as `ANTHROPIC_API_KEY`
Adding a new provider = one new YAML file in `providers/`. No script changes required.
All other keys are exported as environment variables unchanged.
### Active mode env — `$CLAUDE_CONFIG_DIR/active-mode.env`
@@ -160,10 +135,8 @@ Written on every launch, overwritten each time. Read by:
CLAUDE_MODE=deep
CLAUDE_MODE_TAG=deep
CLAUDE_DRIVER=sonnet
CLAUDE_PROVIDER=anthropic-sonnet
CLAUDE_ESCALATES_TO=opus
CLAUDE_PROJECT=cluster-bootstrap
CLAUDE_TIME_HORIZON=hours
CLAUDE_ASYNC_OK=yes
CLAUDE_AUTOLOOP=none
CLAUDE_PLAN_MODE_AUTO=yes
@@ -175,42 +148,29 @@ CLAUDE_MODE_FILE=/home/paul/dev/claude/small-scripts/data/claude-profile/modes/d
Single line: the mode name. Used as the default in the next interactive picker.
### Last project per mode — `$CLAUDE_CONFIG_DIR/last-mode-project-<mode>`
Single line: the project name last used with this mode. Per-mode so switching modes doesn't lose the previous mode's project context.
### Presets — `$CLAUDE_CONFIG_DIR/presets.yaml`
Per-profile, user-edited. Each preset is a named bundle of answers to the picker:
```yaml
deep-cluster:
profile: oreillyit
profile: oreillyit-anthropic
mode: deep
project: cluster-bootstrap
time_horizon: hours
async: yes
quick-skills:
profile: oreillyit
profile: oreillyit-anthropic
mode: quick
project: custom-claude-skills
orch-overnight:
profile: oreillyit
mode: orch
project: ai-image-gen
time_horizon: overnight
autoloop: yes
project: small-scripts
quick-minimax:
profile: oreillyit
profile: oreillyit-minimax
mode: quick
provider: minimax-sonnet
project: small-scripts
```
The `profile` field is required. `provider` defaults to the driver-mapped Anthropic provider if omitted. See `data/claude-profile/presets.yaml.example` for a full example.
The `profile` field is required. See `data/claude-profile/presets.yaml.example` for a full example.
## Status line integration
@@ -232,36 +192,31 @@ When `--dryrun` (or `-n`) is passed, claude-profile resolves all values exactly
```
$ claude-profile --dryrun --preset deep-cluster
[dryrun] Profile: oreillyit (~/.claude-oreillyit)
[dryrun] Profile: oreillyit-anthropic (~/.claude-oreillyit-anthropic)
[dryrun] Mode: deep
[dryrun] Mode file: ~/dev/claude/small-scripts/data/claude-profile/modes/deep.md
[dryrun] Driver: sonnet
[dryrun] Provider: anthropic-sonnet (Anthropic (Sonnet))
[dryrun] Model ID: claude-sonnet-4-6
[dryrun] Project: cluster-bootstrap
[dryrun] Time horizon: hours
[dryrun] Async OK: yes
[dryrun] Autoloop: none
[dryrun] Would write: ~/.claude-oreillyit/active-mode.env
[dryrun] Would write: ~/.claude-oreillyit/last-mode
[dryrun] Would write: ~/.claude-oreillyit-anthropic/active-mode.env
[dryrun] Would write: ~/.claude-oreillyit-anthropic/last-mode
[dryrun] Would execute: claude --model claude-sonnet-4-6 --append-system-prompt "<context+mode>"
[dryrun] Context length: 4823 chars (3104 from context-load + 1719 from mode body)
[dryrun] No changes made.
```
**Dryrun with MiniMax provider:**
**Dryrun with MiniMax profile:**
```
$ claude-profile --dryrun oreillyit --mode quick --provider minimax-sonnet
[dryrun] Profile: oreillyit (~/.claude-oreillyit)
$ claude-profile --dryrun oreillyit-minimax --mode quick
[dryrun] Profile: oreillyit-minimax (~/.claude-oreillyit-minimax)
[dryrun] Mode: quick
[dryrun] Mode file: ~/dev/claude/small-scripts/data/claude-profile/modes/quick.md
[dryrun] Driver: sonnet
[dryrun] Provider: minimax-sonnet (MiniMax (M2.7))
[dryrun] Model ID: MiniMax-M2.7
[dryrun] Base URL: https://api.minimax.io/anthropic
[dryrun] API key env: ANTHROPIC_AUTH_TOKEN (from ~/.claude-secrets/minimax-auth-token)
[dryrun] Provider env: ~/.claude-oreillyit-minimax/provider.env
[dryrun] Project: <not set, will be picked by CLAUDE.md>
[dryrun] Time horizon: <not asked in this mode>
[dryrun] Async OK: no
[dryrun] Autoloop: none
[dryrun] Would execute: claude --model MiniMax-M2.7 --append-system-prompt "<context+mode>"
@@ -277,16 +232,12 @@ In dryrun mode the interactive prompts are still shown, so the user can walk thr
| `~/dev/claude/small-scripts/data/claude-profile/modes/` does not exist | Error: "Mode files directory not found at <path>." Exit 1. |
| Mode file referenced by `--mode` does not exist | Error: "Mode file not found: <path>." Exit 1. |
| Mode file has malformed frontmatter (missing required field) | Error naming the missing field and the file path. Exit 1. |
| `data/claude-profile/providers/` does not exist | Error: "Providers directory not found at <path>." Exit 1. |
| Provider file referenced by `--provider` does not exist | Error: "Provider not found: <name> (looked in <providers_dir>)." Exit 1. |
| Provider file exists but is missing `model_id` | Error: "Provider file <path> is missing required field: model_id." Exit 1. |
| Provider has `api_key_file` but the file does not exist at launch | Error: "Provider API key file not found: <path>." Exit 1. |
| `--provider` not given | Default provider derived from mode's `driver` field; interactive picker shown with that as the default. |
| `provider.env` exists but has no `MODEL_ID` | Driver default is used; other entries are still exported. |
| `provider.env` specifies `ANTHROPIC_API_KEY_FILE` but the file does not exist | Error: "API key file not found: <path>." Exit 1. |
| `presets.yaml` does not exist and `--preset` was given | Error: "Preset '<name>' not found in any presets.yaml." Exit 1. |
| `presets.yaml` exists but the named preset is not in it | Error: "Preset '<name>' not found in any presets.yaml." Exit 1. |
| Preset's `profile` field references a non-existent profile | Error. Exit 1. |
| `--preset` and `--mode` both given | Error: "mutually exclusive". Exit 1. |
| `--preset` and `--provider` both given | Error: "mutually exclusive". Exit 1. |
| Positional `PROFILE` and `--preset` profile field disagree | Error: "Profile mismatch: positional <X>, preset specifies <Y>." Exit 1. |
| `last-mode` file is missing or contains an unknown mode | Fall back to `quick` as the default. |
| Two parallel sessions launched against the same profile | Both write to the same `active-mode.env` — last writer wins. Documented limitation; not addressed in v1. |
@@ -300,7 +251,8 @@ In dryrun mode the interactive prompts are still shown, so the user can walk thr
$ claude-profile
Claude Code profiles:
1) default (~/.claude)
2) oreillyit (~/.claude-oreillyit)
2) oreillyit-anthropic (~/.claude-oreillyit-anthropic)
3) oreillyit-minimax (~/.claude-oreillyit-minimax)
Choose profile [1]: 2
Engagement modes:
@@ -311,34 +263,24 @@ Engagement modes:
5) orch — Decompose work and dispatch container agents
Choose mode [quick]: 3
Time horizon? [hours]:
Async OK? [yes]:
Launching: oreillyit / deep
Launching: oreillyit-anthropic / deep
[Claude Code starts; the existing CLAUDE.md project picker runs next]
```
**Direct — preset**
```
$ claude-profile --preset deep-cluster
Launching: oreillyit / deep / cluster-bootstrap (from preset deep-cluster)
Launching: oreillyit-anthropic / deep / cluster-bootstrap (from preset deep-cluster)
[Claude Code starts]
```
**Direct — profile + mode flag**
```
$ claude-profile oreillyit --mode quick --project small-scripts
Launching: oreillyit / quick / anthropic-sonnet / small-scripts
$ claude-profile oreillyit-anthropic --mode quick --project small-scripts
Launching: oreillyit-anthropic / quick / small-scripts
[Claude Code starts]
```
**Direct — MiniMax provider**
```
$ claude-profile oreillyit --mode quick --provider minimax-sonnet
Launching: oreillyit / quick / minimax-sonnet
[Claude Code starts with ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN set]
```
**Dryrun — verify a preset**
```
$ claude-profile --dryrun --preset deep-cluster
@@ -354,8 +296,8 @@ $ claude-profile --mode quick --project small-scripts -- --resume
## Out of scope (for v1)
- Multi-session coordination (parallel claude-profile launches against the same profile)
- Mid-session mode or provider switching from inside Claude Code (handled by a separate `switch mode` skill; requires relaunching)
- Editing mode or provider files in place (they are read-only data; customisation happens via `presets.yaml`)
- Mid-session mode switching from inside Claude Code (handled by a separate `switch mode` skill; requires relaunching)
- Editing mode files in place (they are read-only data; customisation happens via `presets.yaml`)
- Network/remote profiles (everything is local)
- SOPS-encrypted provider credentials at launch time (credentials are stored as plaintext in `~/.claude-secrets/`; SOPS decryption is handled by the agent-runtimes container harness for headless agents)
- Pinning the model used by named custom subagents at the harness level (those are controlled by the subagent's own definition file; the Agent tool's per-call `model` parameter is unaffected)