Adds the in-TUI surface for the profile system: - Status bar carries " · profile: <name>" next to the SLM badge when profile mode is engaged (renders nothing in legacy single-config installations). - /profile (no args) shows the active profile and lists available ones. - /profile <name> switches by re-executing gnoma via syscall.Exec under --profile <name>. Critical cleanups (quality.json snapshot, SLM backend Close, session.Close) fire explicitly before exec since defers don't run after exec replaces the process image. Using syscall.Exec rather than a child process avoids stacking a process level on every switch and propagates the new gnoma's exit code directly to the shell. - Autocomplete after "/profile " offers configured profile names; the completion source is threaded from main.go via tui.Config. Conversation history is not preserved across a switch — profile change implies different context, different keys, different permission mode, so a clean reset is the correct semantic.
7.4 KiB
Profiles
Profiles let you keep multiple independent gnoma configurations and switch between them. Common cases:
workvs.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):
- Built-in defaults.
~/.config/gnoma/config.toml— the base config.~/.config/gnoma/profiles/<name>.toml— the active profile (only whenprofiles/exists).<projectRoot>/.gnoma/config.toml— project overrides.- 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_profilefrom the baseconfig.tomlis 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:
default_profile = "work"
# Settings here apply to every profile unless the profile overrides them.
[tools]
bash_timeout = "30s"
~/.config/gnoma/profiles/work.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:
[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:
[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
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.
Inspecting profiles
gnoma profile list lists configured profiles and marks the default
plus the currently active one:
$ gnoma profile list
Profiles in /home/x/.config/gnoma/profiles:
experiment
private (active)
work (default)
Base config: /home/x/.config/gnoma/config.toml
If default_profile points at a file that doesn't exist, the listing
flags it explicitly so the command doubles as a diagnostic:
ghost (default, missing)
gnoma profile show <name> prints the merged effective config a
profile produces — sections, configured providers (key names only;
values are never printed), CLI agent overrides, arms, hooks, MCP
servers, and the per-profile quality + session paths:
$ gnoma profile show work
Profile: work
Base config: /home/x/.config/gnoma/config.toml
Profile file: /home/x/.config/gnoma/profiles/work.toml
[provider]
default = anthropic
model = claude-sonnet-4
api_keys = anthropic, openai
[cli_agents]
claude = claude-work
gemini = (canonical)
[permission]
mode = default
…
Quality data: /home/x/.config/gnoma/quality-work.json
Session dir: /repo/.gnoma/sessions/work
Both profile list and profile show work even when profile
resolution is otherwise broken — they're the recovery affordance for
diagnosing misconfigurations.
Inside the TUI
The status bar carries a · profile: <name> indicator next to the SLM
badge so the active profile is always visible while you work.
/profile (no args) prints the active profile and the list of
available ones. /profile <name> switches to another profile by
re-executing gnoma with --profile <name> — the implementation uses
syscall.Exec so no extra process level is stacked and the new
gnoma's exit code propagates directly to your shell. Conversation
history is not preserved across a switch; the new gnoma starts with
a fresh session.
Autocomplete after /profile offers configured profile names.
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 byid. Profile entries override the matching base entry; new IDs append. So a profile can tweak one arm'scost_weightwithout redeclaring the rest.[[mcp_servers]]: merged byname(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:
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.tomlas 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 theprofiles/directory to create.
Existing single-config installations don't need to do anything.