Files
vikingowl d84b295da2 feat(tui): /profile slash command + status-bar profile badge (Phase C-3)
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.
2026-05-19 21:59:11 +02:00

7.4 KiB

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:

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 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:

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.