feat(config): per-profile config layering with --profile flag (Phase C-1)
Adds opt-in user profiles for swapping API keys, CLI binaries, and permission modes between contexts (work/private/experiment/...). Profile mode engages only when ~/.config/gnoma/profiles/ exists, so existing single-config installations are untouched. Selection order: --profile flag → default_profile in base config → fatal error. Layering: defaults → ~/.config/gnoma/config.toml → profiles/<name>.toml → <projectRoot>/.gnoma/config.toml → env. Map sections merge per-key; [[arms]] and [[mcp_servers]] merge by id/name; [[hooks]] appends. Per-profile data: quality-<name>.json and sessions/<name>/ keep the bandit and session list from cross-contaminating between profiles. Profile names restricted to [A-Za-z0-9_-] to block --profile=../foo path traversal into derived paths.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# Profiles
|
||||
|
||||
Profiles let you keep multiple independent gnoma configurations and switch
|
||||
between them. Common cases:
|
||||
|
||||
- `work` vs. `private` — different API keys, different CLI binaries,
|
||||
stricter or looser permission mode per context.
|
||||
- `experiment` — a non-default SLM model, plan mode, no persistence.
|
||||
|
||||
Profile mode is opt-in: gnoma stays on its single-config behavior until
|
||||
you create `~/.config/gnoma/profiles/`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
~/.config/gnoma/
|
||||
├── config.toml # base settings + default_profile
|
||||
├── profiles/ # opt-in directory; presence enables profile mode
|
||||
│ ├── work.toml
|
||||
│ ├── private.toml
|
||||
│ └── experiment.toml
|
||||
├── quality-work.json # per-profile router quality data
|
||||
├── quality-private.json
|
||||
└── quality-experiment.json
|
||||
```
|
||||
|
||||
Per-project, session storage segregates the same way:
|
||||
|
||||
```
|
||||
<projectRoot>/.gnoma/sessions/
|
||||
├── work/
|
||||
├── private/
|
||||
└── experiment/
|
||||
```
|
||||
|
||||
## Loading order
|
||||
|
||||
Each `gnoma` invocation merges configuration in this order (lowest to
|
||||
highest priority):
|
||||
|
||||
1. Built-in defaults.
|
||||
2. `~/.config/gnoma/config.toml` — the **base** config.
|
||||
3. `~/.config/gnoma/profiles/<name>.toml` — the **active** profile
|
||||
(only when `profiles/` exists).
|
||||
4. `<projectRoot>/.gnoma/config.toml` — project overrides.
|
||||
5. Environment variables (`ANTHROPIC_API_KEY`, `GNOMA_PROVIDER`, etc.).
|
||||
|
||||
The active profile is resolved as follows:
|
||||
|
||||
- If `--profile <name>` is passed on the CLI, that wins.
|
||||
- Otherwise, `default_profile` from the base `config.toml` is used.
|
||||
- If neither is set and `profiles/` exists, gnoma fails fast with a
|
||||
list of available profiles. (Silent fallback to defaults would hide
|
||||
configuration mistakes.)
|
||||
|
||||
## Example: base + two profiles
|
||||
|
||||
`~/.config/gnoma/config.toml`:
|
||||
|
||||
```toml
|
||||
default_profile = "work"
|
||||
|
||||
# Settings here apply to every profile unless the profile overrides them.
|
||||
[tools]
|
||||
bash_timeout = "30s"
|
||||
```
|
||||
|
||||
`~/.config/gnoma/profiles/work.toml`:
|
||||
|
||||
```toml
|
||||
[provider]
|
||||
default = "anthropic"
|
||||
[provider.api_keys]
|
||||
anthropic = "${ANTHROPIC_WORK_KEY}"
|
||||
|
||||
[cli_agents]
|
||||
claude = "claude-work"
|
||||
|
||||
[permission]
|
||||
mode = "default"
|
||||
|
||||
[slm]
|
||||
backend = "ollama"
|
||||
model = "reecdev/tiny3.5:1.5b"
|
||||
```
|
||||
|
||||
`~/.config/gnoma/profiles/private.toml`:
|
||||
|
||||
```toml
|
||||
[provider]
|
||||
default = "openai"
|
||||
[provider.api_keys]
|
||||
openai = "${OPENAI_PRIVATE_KEY}"
|
||||
|
||||
[cli_agents]
|
||||
claude = "claude-priv"
|
||||
|
||||
[permission]
|
||||
mode = "auto"
|
||||
|
||||
[slm]
|
||||
backend = "ollama"
|
||||
model = "reecdev/tiny3.5:500m"
|
||||
```
|
||||
|
||||
`~/.config/gnoma/profiles/experiment.toml`:
|
||||
|
||||
```toml
|
||||
[provider]
|
||||
default = "mistral"
|
||||
model = "mistral-large-latest"
|
||||
|
||||
[permission]
|
||||
mode = "plan"
|
||||
|
||||
[slm]
|
||||
enabled = false # turn the classifier off entirely
|
||||
|
||||
[session]
|
||||
max_keep = 0 # don't keep session history for experiments
|
||||
```
|
||||
|
||||
## Switching
|
||||
|
||||
```bash
|
||||
gnoma --profile work providers # use work profile
|
||||
gnoma --profile private # private profile, default subcommand (TUI)
|
||||
gnoma # base default_profile (here: work)
|
||||
```
|
||||
|
||||
Profile selection is per-invocation. Restart re-reads `default_profile`;
|
||||
no "last used" persistence — explicit switches stay explicit.
|
||||
|
||||
## Merge semantics
|
||||
|
||||
- **Scalars** (`provider.default`, `provider.model`, `tools.bash_timeout`,
|
||||
…): the profile value wins if set; otherwise base is preserved.
|
||||
- **Maps** (`provider.api_keys`, `provider.endpoints`, `cli_agents`,
|
||||
`rate_limits`): per-key merge. Profile overrides individual keys
|
||||
without erasing the rest.
|
||||
- **`[[hooks]]`**: profile hooks are appended after base hooks.
|
||||
- **`[[arms]]`**: merged by `id`. Profile entries override the matching
|
||||
base entry; new IDs append. So a profile can tweak one arm's
|
||||
`cost_weight` without redeclaring the rest.
|
||||
- **`[[mcp_servers]]`**: merged by `name` (same policy as arms).
|
||||
- **`[security]`**, **`[plugins]`**, etc.: profile replaces if the
|
||||
profile defines anything in that section.
|
||||
|
||||
The project-level `.gnoma/config.toml` layer applies on top of the
|
||||
merged base+profile result. Environment variables apply last and
|
||||
override everything.
|
||||
|
||||
## Profile name rules
|
||||
|
||||
Names must match `[A-Za-z0-9_-]+`. Dots, slashes, spaces, and other
|
||||
characters are rejected to keep derived paths
|
||||
(`quality-<name>.json`, `sessions/<name>/`) predictable and to prevent
|
||||
path traversal via `--profile`.
|
||||
|
||||
## Where per-profile data lives
|
||||
|
||||
| Data | Path |
|
||||
|---|---|
|
||||
| Router quality (bandit telemetry) | `~/.config/gnoma/quality-<profile>.json` |
|
||||
| Session history | `<projectRoot>/.gnoma/sessions/<profile>/` |
|
||||
| Plugins | `~/.config/gnoma/plugins/` (shared across profiles) |
|
||||
| Skills | `~/.config/gnoma/skills/` (shared across profiles) |
|
||||
|
||||
Plugins and skills stay global on purpose — they're code, not
|
||||
preferences. Use profile-specific `[plugins].enabled` / `disabled`
|
||||
lists if you need a different mix per profile.
|
||||
|
||||
## `gnoma router stats` and profiles
|
||||
|
||||
When a profile is active, `gnoma router stats` reads
|
||||
`quality-<profile>.json` and prefixes its output with the profile name
|
||||
so it's clear which dataset you're looking at. To compare profiles:
|
||||
|
||||
```bash
|
||||
gnoma --profile work router stats
|
||||
gnoma --profile private router stats
|
||||
```
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
If `~/.config/gnoma/profiles/` does not exist, gnoma behaves exactly
|
||||
as before:
|
||||
|
||||
- Reads `~/.config/gnoma/config.toml` as the only base config.
|
||||
- Stores quality data at `~/.config/gnoma/quality.json`.
|
||||
- Stores sessions at `<projectRoot>/.gnoma/sessions/` (no profile
|
||||
subdirectory).
|
||||
- `--profile <name>` returns a clear error pointing you at the
|
||||
`profiles/` directory to create.
|
||||
|
||||
Existing single-config installations don't need to do anything.
|
||||
@@ -222,32 +222,62 @@ model = "reecdev/tiny3.5:500m"
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] Config loader merges `config.toml` base + selected profile
|
||||
C-1 (foundational config + CLI) shipped 2026-05-19:
|
||||
|
||||
- [x] Config loader merges `config.toml` base + selected profile
|
||||
(profile overrides base, env vars override profile).
|
||||
- [ ] `--profile <name>` CLI flag.
|
||||
- [x] `--profile <name>` CLI flag.
|
||||
- [x] Migration path for existing single-config users: if no
|
||||
`profiles/` directory exists, fall back to the current behaviour
|
||||
(load `config.toml` as the sole config).
|
||||
- [x] Docs page with three full example profiles
|
||||
(`docs/profiles.md`).
|
||||
|
||||
C-2 (CLI surface, separate landing):
|
||||
|
||||
- [ ] `gnoma profile list` / `gnoma profile show <name>` subcommands.
|
||||
|
||||
C-3 (TUI integration, separate landing):
|
||||
|
||||
- [ ] TUI `/profile` slash command (with autocomplete on profile
|
||||
names, requires engine restart on switch).
|
||||
- [ ] Status-bar indicator shows the active profile (dim, next to the
|
||||
SLM badge: `· profile: work`).
|
||||
- [ ] Migration path for existing single-config users: if no
|
||||
`profiles/` directory exists, fall back to the current behaviour
|
||||
(load `config.toml` as the sole config).
|
||||
- [ ] Docs page with two or three full example profiles.
|
||||
|
||||
### Open design questions
|
||||
### Open design questions — resolved
|
||||
|
||||
- Should profile selection persist (last-used) or always come from
|
||||
`default_profile` on restart? Lean: always default unless `--profile`
|
||||
is set, and `/profile` in TUI is per-session.
|
||||
- Where do session files (`~/.local/share/gnoma/sessions/`) live —
|
||||
global or per-profile? Lean: per-profile, so resuming `work` doesn't
|
||||
surface `private` sessions.
|
||||
- Per-profile `quality.json` (router telemetry) — yes, otherwise the
|
||||
bandit cross-contaminates between profile workloads.
|
||||
- **Profile selection persistence**: per-session only. Restart
|
||||
re-reads `default_profile`; `--profile` overrides for one
|
||||
invocation; TUI `/profile` (C-3) will be session-scoped.
|
||||
- **Session file location**: per-profile, at
|
||||
`<projectRoot>/.gnoma/sessions/<profile>/`. When no `profiles/`
|
||||
directory exists, legacy `<projectRoot>/.gnoma/sessions/` path
|
||||
is preserved (no migration).
|
||||
- **Per-profile `quality.json`**: yes, at
|
||||
`~/.config/gnoma/quality-<profile>.json`. Legacy path preserved
|
||||
for single-config installations.
|
||||
|
||||
**Effort:** ~400 LOC across config loader, CLI, TUI; non-trivial because
|
||||
the config layering is foundational.
|
||||
### C-1 module map (shipped)
|
||||
|
||||
- `internal/config/profile.go` — `Profile` struct,
|
||||
`LoadWithProfile()`, `ListProfiles()`, slice-merge helpers
|
||||
(`mergeArmsByID`, `mergeMCPServersByName`),
|
||||
`validateProfileName()` (rejects path traversal), and the
|
||||
`ErrProfileResolution` sentinel for actionable misconfigurations.
|
||||
- `internal/config/load.go` — `Load()` now delegates to
|
||||
`LoadWithProfile("")` for backward compatibility.
|
||||
- `internal/config/config.go` — `DefaultProfile string` TOML key.
|
||||
- `internal/session/store.go` — `NewSessionStoreAt(dir, ...)`
|
||||
constructor accepting an explicit sessions directory.
|
||||
- `cmd/gnoma/main.go` — `--profile` flag, fatal exit on
|
||||
`ErrProfileResolution`, profile-aware quality.json and session
|
||||
paths.
|
||||
- `cmd/gnoma/router_cmd.go` — `gnoma router stats` reads the
|
||||
active profile's `quality-<name>.json` and prefixes output
|
||||
with `Profile: <name>`.
|
||||
|
||||
**Effort:** C-1 shipped at ~250 LOC + ~370 LOC tests + docs page.
|
||||
C-2 and C-3 still scoped at ~80 / ~120 LOC respectively.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user