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:
+24
-16
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -66,6 +67,7 @@ func main() {
|
||||
maxTurns = flag.Int("max-turns", 50, "max tool-calling rounds per turn")
|
||||
permMode = flag.String("permission", "auto", "permission mode (default, accept_edits, bypass, deny, plan, auto)")
|
||||
incognito = flag.Bool("incognito", false, "incognito mode — no persistence, no learning")
|
||||
profileFlag = flag.String("profile", "", "config profile to load (empty = default_profile from base config)")
|
||||
verbose = flag.Bool("verbose", false, "enable debug logging")
|
||||
version = flag.Bool("version", false, "print version and exit")
|
||||
)
|
||||
@@ -118,9 +120,17 @@ func main() {
|
||||
logger := slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: logLevel}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Load config (defaults → global → project → env vars)
|
||||
cfg, err := gnomacfg.Load()
|
||||
// Load config (defaults → global base → profile → project → env vars).
|
||||
// Profile mode engages only when ~/.config/gnoma/profiles/ exists.
|
||||
cfg, profile, err := gnomacfg.LoadWithProfile(*profileFlag)
|
||||
if err != nil {
|
||||
// Profile resolution failures are fatal: we can't guess which
|
||||
// profile the user meant, and silently falling back to defaults
|
||||
// is the worst possible UX.
|
||||
if errors.Is(err, gnomacfg.ErrProfileResolution) {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "warning: config load: %v\n", err)
|
||||
defaults := gnomacfg.Defaults()
|
||||
cfg = &defaults
|
||||
@@ -131,6 +141,8 @@ func main() {
|
||||
"keys", len(cfg.Provider.APIKeys),
|
||||
"perm_mode", cfg.Permission.Mode,
|
||||
"perm_rules", len(cfg.Permission.Rules),
|
||||
"profile_active", profile.Active,
|
||||
"profile_name", profile.Name,
|
||||
)
|
||||
|
||||
// CLI flags override config
|
||||
@@ -152,7 +164,7 @@ func main() {
|
||||
case "slm":
|
||||
os.Exit(runSLMCommand(cliArgs[1:], cfg, logger))
|
||||
case "router":
|
||||
os.Exit(runRouterCommand(cliArgs[1:]))
|
||||
os.Exit(runRouterCommand(cliArgs[1:], profile))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,17 +323,18 @@ func main() {
|
||||
// Elf manager (created now, agent tool registered after router exists)
|
||||
// We'll register the agent tool after the router is created below
|
||||
|
||||
// Create session store
|
||||
sessStore := session.NewSessionStore(gnomacfg.ProjectRoot(), cfg.Session.MaxKeep, logger)
|
||||
// Create session store. Per-profile session dir keeps work/private
|
||||
// sessions from cross-contaminating the resume list.
|
||||
sessStore := session.NewSessionStoreAt(profile.SessionDir(gnomacfg.ProjectRoot()), cfg.Session.MaxKeep, logger)
|
||||
|
||||
// Create router and register the provider as a single arm
|
||||
// (M4 foundation: one provider from CLI. Multi-provider routing comes with config.)
|
||||
rtr := router.New(router.Config{Logger: logger})
|
||||
|
||||
// Restore QualityTracker data from disk (best-effort)
|
||||
// Restore QualityTracker data from disk (best-effort). Per-profile
|
||||
// path avoids bandit cross-contamination between work/private/etc.
|
||||
{
|
||||
userCfgDir, _ := os.UserConfigDir()
|
||||
qualityPath := filepath.Join(userCfgDir, "gnoma", "quality.json")
|
||||
qualityPath := profile.QualityFile(gnomacfg.GlobalConfigDir())
|
||||
if data, err := os.ReadFile(qualityPath); err == nil {
|
||||
var snap router.QualitySnapshot
|
||||
if err := json.Unmarshal(data, &snap); err == nil {
|
||||
@@ -341,14 +354,9 @@ func main() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
userCfgDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
logger.Warn("quality save skipped: no user config dir", "error", err)
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(userCfgDir, "gnoma")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "quality.json"), data, 0o644)
|
||||
qualityPath := profile.QualityFile(gnomacfg.GlobalConfigDir())
|
||||
_ = os.MkdirAll(filepath.Dir(qualityPath), 0o755)
|
||||
_ = os.WriteFile(qualityPath, data, 0o644)
|
||||
}()
|
||||
var armID router.ArmID
|
||||
if primaryProviderOK {
|
||||
|
||||
+8
-10
@@ -4,15 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
gnomacfg "somegit.dev/Owlibou/gnoma/internal/config"
|
||||
"somegit.dev/Owlibou/gnoma/internal/router"
|
||||
)
|
||||
|
||||
// runRouterCommand handles `gnoma router <subcommand>`. Returns an exit code.
|
||||
func runRouterCommand(args []string) int {
|
||||
func runRouterCommand(args []string, profile gnomacfg.Profile) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gnoma router <command>")
|
||||
fmt.Fprintln(os.Stderr, "commands:")
|
||||
@@ -21,20 +21,15 @@ func runRouterCommand(args []string) int {
|
||||
}
|
||||
switch args[0] {
|
||||
case "stats":
|
||||
return runRouterStats()
|
||||
return runRouterStats(profile)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown router command: %s\n", args[0])
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func runRouterStats() int {
|
||||
userCfgDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
path := filepath.Join(userCfgDir, "gnoma", "quality.json")
|
||||
func runRouterStats(profile gnomacfg.Profile) int {
|
||||
path := profile.QualityFile(gnomacfg.GlobalConfigDir())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -52,6 +47,9 @@ func runRouterStats() int {
|
||||
return 1
|
||||
}
|
||||
|
||||
if profile.Active {
|
||||
fmt.Printf("Profile: %s\n\n", profile.Name)
|
||||
}
|
||||
printArmTable(snap)
|
||||
fmt.Println()
|
||||
printClassifierTable(snap)
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import "time"
|
||||
|
||||
// Config is the top-level configuration.
|
||||
type Config struct {
|
||||
// DefaultProfile names the profile loaded when no --profile flag is
|
||||
// passed. Only meaningful when ~/.config/gnoma/profiles/ exists; see
|
||||
// LoadWithProfile.
|
||||
DefaultProfile string `toml:"default_profile"`
|
||||
|
||||
Provider ProviderSection `toml:"provider"`
|
||||
Permission PermissionSection `toml:"permission"`
|
||||
Tools ToolsSection `toml:"tools"`
|
||||
|
||||
+13
-33
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -10,40 +9,21 @@ import (
|
||||
)
|
||||
|
||||
// Load reads and merges config from all layers.
|
||||
//
|
||||
// Backward-compatible entry point: callers that don't care about
|
||||
// profiles get the same behaviour they always had. Behind the scenes
|
||||
// this delegates to LoadWithProfile(""), which engages profile mode
|
||||
// only when ~/.config/gnoma/profiles/ exists.
|
||||
//
|
||||
// Order (lowest to highest priority):
|
||||
// 1. Defaults
|
||||
// 2. Global config: ~/.config/gnoma/config.toml
|
||||
// 3. Project config: .gnoma/config.toml
|
||||
// 4. Environment variables
|
||||
// 1. Defaults
|
||||
// 2. Global config: ~/.config/gnoma/config.toml
|
||||
// 3. Selected profile (only if profiles/ exists): profiles/<name>.toml
|
||||
// 4. Project config: .gnoma/config.toml
|
||||
// 5. Environment variables
|
||||
func Load() (*Config, error) {
|
||||
cfg := Defaults()
|
||||
|
||||
// Layer 1: Global config
|
||||
globalPath := globalConfigPath()
|
||||
if err := loadTOML(&cfg, globalPath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("loading global config %s: %w", globalPath, err)
|
||||
}
|
||||
// Deep copy global hooks before the project layer.
|
||||
// toml.Decode may reuse the backing array, so a plain slice-header copy
|
||||
// would alias into whatever the project decode writes.
|
||||
// Also reset cfg.Hooks to nil so the project layer starts clean —
|
||||
// if the project config is absent, cfg.Hooks stays nil and the append
|
||||
// below just returns the global hooks unchanged.
|
||||
globalHooks := append([]HookConfig(nil), cfg.Hooks...)
|
||||
cfg.Hooks = nil
|
||||
|
||||
// Layer 2: Project config
|
||||
projectPath := projectConfigPath()
|
||||
if err := loadTOML(&cfg, projectPath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("loading project config %s: %w", projectPath, err)
|
||||
}
|
||||
// User hooks run first, project hooks after.
|
||||
cfg.Hooks = append(globalHooks, cfg.Hooks...)
|
||||
|
||||
// Layer 3: Environment variables
|
||||
applyEnv(&cfg)
|
||||
|
||||
return &cfg, nil
|
||||
cfg, _, err := LoadWithProfile("")
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func loadTOML(cfg *Config, path string) error {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrProfileResolution is the sentinel returned (wrapped) when profile
|
||||
// loading fails because the user's selection cannot be resolved — e.g.
|
||||
// --profile names a missing file, profiles/ exists with no
|
||||
// default_profile, or default_profile names a missing file.
|
||||
//
|
||||
// Callers can distinguish these actionable misconfigurations from
|
||||
// generic config-file errors via errors.Is and exit non-zero rather
|
||||
// than silently falling back to defaults.
|
||||
var ErrProfileResolution = errors.New("profile resolution")
|
||||
|
||||
// Profile carries the resolved profile metadata for the current session.
|
||||
// Active=false means the profile system is not engaged — either because
|
||||
// no profiles/ directory exists alongside the base config.toml, or because
|
||||
// the caller went through the backward-compatible Load() entry point on a
|
||||
// non-profile installation. Treat Active==false as "use legacy paths" and
|
||||
// do not assume Name has any particular value.
|
||||
type Profile struct {
|
||||
Name string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// QualityFile returns the absolute path to the router quality file for
|
||||
// this profile. Legacy callers (Active==false) get the original
|
||||
// ~/.config/gnoma/quality.json. Active profiles get a per-profile file
|
||||
// at ~/.config/gnoma/quality-<name>.json so the bandit doesn't
|
||||
// cross-contaminate between profile workloads. globalDir is the same
|
||||
// directory GlobalConfigDir() returns.
|
||||
func (p Profile) QualityFile(globalDir string) string {
|
||||
if !p.Active {
|
||||
return filepath.Join(globalDir, "quality.json")
|
||||
}
|
||||
return filepath.Join(globalDir, "quality-"+p.Name+".json")
|
||||
}
|
||||
|
||||
// SessionDir returns the per-profile session directory. Legacy callers
|
||||
// (Active==false) get <projectRoot>/.gnoma/sessions/; active profiles
|
||||
// get <projectRoot>/.gnoma/sessions/<name>/ so resuming `work` doesn't
|
||||
// surface `private` sessions.
|
||||
func (p Profile) SessionDir(projectRoot string) string {
|
||||
if !p.Active {
|
||||
return filepath.Join(projectRoot, ".gnoma", "sessions")
|
||||
}
|
||||
return filepath.Join(projectRoot, ".gnoma", "sessions", p.Name)
|
||||
}
|
||||
|
||||
// LoadWithProfile is the profile-aware entry point.
|
||||
//
|
||||
// Layering, lowest to highest priority:
|
||||
// 1. Defaults
|
||||
// 2. Global base: ~/.config/gnoma/config.toml
|
||||
// 3. Selected profile: ~/.config/gnoma/profiles/<name>.toml
|
||||
// (only when profiles/ directory exists)
|
||||
// 4. Project config: .gnoma/config.toml
|
||||
// 5. Environment variables
|
||||
//
|
||||
// flagName carries the --profile argument (empty when not set). When
|
||||
// empty: use base default_profile if profiles/ is engaged, otherwise
|
||||
// fall back to legacy behaviour (no profiles).
|
||||
//
|
||||
// Profile activation policy: profile mode engages iff
|
||||
// ~/.config/gnoma/profiles/ exists as a directory. This keeps existing
|
||||
// single-config installations on their current paths untouched.
|
||||
func LoadWithProfile(flagName string) (*Config, Profile, error) {
|
||||
cfg := Defaults()
|
||||
|
||||
globalPath := globalConfigPath()
|
||||
if err := loadTOML(&cfg, globalPath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, Profile{}, fmt.Errorf("loading global config %s: %w", globalPath, err)
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(GlobalConfigDir(), "profiles")
|
||||
profilesActive := false
|
||||
if st, err := os.Stat(profilesDir); err == nil && st.IsDir() {
|
||||
profilesActive = true
|
||||
}
|
||||
|
||||
resolvedName, err := resolveProfileName(profilesActive, flagName, cfg.DefaultProfile, profilesDir, globalPath)
|
||||
if err != nil {
|
||||
return nil, Profile{}, err
|
||||
}
|
||||
|
||||
if profilesActive {
|
||||
profilePath := filepath.Join(profilesDir, resolvedName+".toml")
|
||||
|
||||
// Slices need explicit merge policy: snapshot base, let profile
|
||||
// decode overwrite, then re-merge per slice type.
|
||||
baseHooks := append([]HookConfig(nil), cfg.Hooks...)
|
||||
baseArms := append([]ArmConfig(nil), cfg.Arms...)
|
||||
baseMCP := append([]MCPServerConfig(nil), cfg.MCPServers...)
|
||||
cfg.Hooks = nil
|
||||
cfg.Arms = nil
|
||||
cfg.MCPServers = nil
|
||||
|
||||
if err := loadTOML(&cfg, profilePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
avail, _ := listProfiles(profilesDir)
|
||||
return nil, Profile{}, fmt.Errorf("%w: profile %q not found at %s; available: %s",
|
||||
ErrProfileResolution, resolvedName, profilePath, strings.Join(avail, ", "))
|
||||
}
|
||||
return nil, Profile{}, fmt.Errorf("loading profile %s: %w", profilePath, err)
|
||||
}
|
||||
|
||||
cfg.Hooks = append(baseHooks, cfg.Hooks...)
|
||||
cfg.Arms = mergeArmsByID(baseArms, cfg.Arms)
|
||||
cfg.MCPServers = mergeMCPServersByName(baseMCP, cfg.MCPServers)
|
||||
}
|
||||
|
||||
// Project layer: same Hooks-append semantics the legacy loader uses.
|
||||
priorHooks := append([]HookConfig(nil), cfg.Hooks...)
|
||||
cfg.Hooks = nil
|
||||
|
||||
projectPath := projectConfigPath()
|
||||
if err := loadTOML(&cfg, projectPath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, Profile{}, fmt.Errorf("loading project config %s: %w", projectPath, err)
|
||||
}
|
||||
cfg.Hooks = append(priorHooks, cfg.Hooks...)
|
||||
|
||||
applyEnv(&cfg)
|
||||
|
||||
prof := Profile{Active: profilesActive}
|
||||
if profilesActive {
|
||||
prof.Name = resolvedName
|
||||
}
|
||||
return &cfg, prof, nil
|
||||
}
|
||||
|
||||
func resolveProfileName(profilesActive bool, flagName, defaultProfile, profilesDir, globalPath string) (string, error) {
|
||||
switch {
|
||||
case !profilesActive && flagName != "":
|
||||
return "", fmt.Errorf("%w: --profile %q set but no profiles directory at %s; create %s/%s.toml first",
|
||||
ErrProfileResolution, flagName, profilesDir, profilesDir, flagName)
|
||||
case !profilesActive:
|
||||
return "", nil
|
||||
case flagName != "":
|
||||
if err := validateProfileName(flagName); err != nil {
|
||||
return "", fmt.Errorf("%w: --profile %q: %v", ErrProfileResolution, flagName, err)
|
||||
}
|
||||
return flagName, nil
|
||||
case defaultProfile != "":
|
||||
if err := validateProfileName(defaultProfile); err != nil {
|
||||
return "", fmt.Errorf("%w: default_profile %q: %v", ErrProfileResolution, defaultProfile, err)
|
||||
}
|
||||
return defaultProfile, nil
|
||||
default:
|
||||
avail, _ := listProfiles(profilesDir)
|
||||
hint := ""
|
||||
if len(avail) > 0 {
|
||||
hint = " available: " + strings.Join(avail, ", ")
|
||||
}
|
||||
return "", fmt.Errorf("%w: profiles directory present but no profile selected: pass --profile <name> or set default_profile in %s.%s",
|
||||
ErrProfileResolution, globalPath, hint)
|
||||
}
|
||||
}
|
||||
|
||||
// validateProfileName rejects names that would escape the profiles
|
||||
// directory or produce surprising paths in derived locations like
|
||||
// quality-<name>.json and sessions/<name>/. Allowed: letters, digits,
|
||||
// hyphens, underscores. Rejected: empty, anything containing path
|
||||
// separators, dots, or other characters that could enable traversal
|
||||
// or shell-meaningful filenames.
|
||||
func validateProfileName(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("must not be empty")
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-' || r == '_':
|
||||
default:
|
||||
return fmt.Errorf("invalid character %q (allowed: letters, digits, '-', '_')", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProfiles returns the sorted profile names in ~/.config/gnoma/profiles/.
|
||||
// Returns nil (no error) when the directory does not exist.
|
||||
func ListProfiles() ([]string, error) {
|
||||
return listProfiles(filepath.Join(GlobalConfigDir(), "profiles"))
|
||||
}
|
||||
|
||||
func listProfiles(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".toml") {
|
||||
continue
|
||||
}
|
||||
names = append(names, strings.TrimSuffix(name, ".toml"))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// mergeArmsByID returns base arms with each profile arm appended (new
|
||||
// ID) or used to replace the matching base entry (existing ID). Order
|
||||
// preserves base ordering, then appends profile-only IDs.
|
||||
func mergeArmsByID(base, profile []ArmConfig) []ArmConfig {
|
||||
if len(profile) == 0 {
|
||||
return base
|
||||
}
|
||||
overrides := make(map[string]ArmConfig, len(profile))
|
||||
for _, p := range profile {
|
||||
overrides[p.ID] = p
|
||||
}
|
||||
out := make([]ArmConfig, 0, len(base)+len(profile))
|
||||
seen := make(map[string]bool, len(base))
|
||||
for _, b := range base {
|
||||
if p, ok := overrides[b.ID]; ok {
|
||||
out = append(out, p)
|
||||
} else {
|
||||
out = append(out, b)
|
||||
}
|
||||
seen[b.ID] = true
|
||||
}
|
||||
for _, p := range profile {
|
||||
if !seen[p.ID] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeMCPServersByName mirrors mergeArmsByID for MCP server entries,
|
||||
// keyed by Name.
|
||||
func mergeMCPServersByName(base, profile []MCPServerConfig) []MCPServerConfig {
|
||||
if len(profile) == 0 {
|
||||
return base
|
||||
}
|
||||
overrides := make(map[string]MCPServerConfig, len(profile))
|
||||
for _, p := range profile {
|
||||
overrides[p.Name] = p
|
||||
}
|
||||
out := make([]MCPServerConfig, 0, len(base)+len(profile))
|
||||
seen := make(map[string]bool, len(base))
|
||||
for _, b := range base {
|
||||
if p, ok := overrides[b.Name]; ok {
|
||||
out = append(out, p)
|
||||
} else {
|
||||
out = append(out, b)
|
||||
}
|
||||
seen[b.Name] = true
|
||||
}
|
||||
for _, p := range profile {
|
||||
if !seen[p.Name] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// profileTestEnv stages a fake $XDG_CONFIG_HOME with a base config and an
|
||||
// optional profiles directory, then chdirs into a project dir. Callers
|
||||
// supply the base config TOML and a map of profile-name → profile TOML.
|
||||
// An empty profiles map means no profiles/ directory is created at all
|
||||
// (legacy fallback). To create profiles/ but leave it empty, pass a non-nil
|
||||
// empty map.
|
||||
func profileTestEnv(t *testing.T, baseTOML string, profiles map[string]string) (projectDir string) {
|
||||
t.Helper()
|
||||
globalDir := t.TempDir()
|
||||
gnomaDir := filepath.Join(globalDir, "gnoma")
|
||||
if err := os.MkdirAll(gnomaDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if baseTOML != "" {
|
||||
if err := os.WriteFile(filepath.Join(gnomaDir, "config.toml"), []byte(baseTOML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if profiles != nil {
|
||||
profilesDir := filepath.Join(gnomaDir, "profiles")
|
||||
if err := os.MkdirAll(profilesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, body := range profiles {
|
||||
if err := os.WriteFile(filepath.Join(profilesDir, name+".toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
projectDir = t.TempDir()
|
||||
pGnomaDir := filepath.Join(projectDir, ".gnoma")
|
||||
if err := os.MkdirAll(pGnomaDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", globalDir)
|
||||
origDir, _ := os.Getwd()
|
||||
_ = os.Chdir(projectDir)
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
return projectDir
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_NoProfilesDir_FallsBackToLegacy(t *testing.T) {
|
||||
profileTestEnv(t, `
|
||||
[provider]
|
||||
default = "anthropic"
|
||||
`, nil)
|
||||
|
||||
cfg, prof, err := LoadWithProfile("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
if prof.Active {
|
||||
t.Errorf("Profile.Active = true, want false when no profiles/ dir")
|
||||
}
|
||||
if cfg.Provider.Default != "anthropic" {
|
||||
t.Errorf("Provider.Default = %q, want anthropic", cfg.Provider.Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_NoProfilesDir_FlagSet_Errors(t *testing.T) {
|
||||
profileTestEnv(t, `[provider]
|
||||
default = "anthropic"
|
||||
`, nil)
|
||||
|
||||
_, _, err := LoadWithProfile("work")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --profile is set but no profiles/ dir exists")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "work") {
|
||||
t.Errorf("error should mention requested profile name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_ProfilesDir_NoFlag_NoDefault_Errors(t *testing.T) {
|
||||
profileTestEnv(t, `[provider]
|
||||
default = "anthropic"
|
||||
`, map[string]string{
|
||||
"work": `[provider]` + "\n" + `default = "openai"`,
|
||||
"private": `[provider]` + "\n" + `default = "mistral"`,
|
||||
})
|
||||
|
||||
_, _, err := LoadWithProfile("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when profiles/ exists but no default_profile or flag")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "work") || !strings.Contains(err.Error(), "private") {
|
||||
t.Errorf("error should list available profiles, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_DefaultProfileFromBase(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "work"
|
||||
|
||||
[provider]
|
||||
default = "anthropic"
|
||||
`, map[string]string{
|
||||
"work": `[provider]
|
||||
default = "openai"
|
||||
`,
|
||||
})
|
||||
|
||||
cfg, prof, err := LoadWithProfile("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
if !prof.Active || prof.Name != "work" {
|
||||
t.Errorf("Profile = %+v, want {work true}", prof)
|
||||
}
|
||||
if cfg.Provider.Default != "openai" {
|
||||
t.Errorf("Provider.Default = %q, want openai (profile override)", cfg.Provider.Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_FlagOverridesDefault(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "work"
|
||||
|
||||
[provider]
|
||||
default = "anthropic"
|
||||
`, map[string]string{
|
||||
"work": `[provider]` + "\n" + `default = "openai"`,
|
||||
"private": `[provider]` + "\n" + `default = "mistral"`,
|
||||
})
|
||||
|
||||
cfg, prof, err := LoadWithProfile("private")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
if prof.Name != "private" {
|
||||
t.Errorf("Profile.Name = %q, want private", prof.Name)
|
||||
}
|
||||
if cfg.Provider.Default != "mistral" {
|
||||
t.Errorf("Provider.Default = %q, want mistral", cfg.Provider.Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_FlagProfileMissing_Errors(t *testing.T) {
|
||||
profileTestEnv(t, `[provider]
|
||||
default = "anthropic"
|
||||
`, map[string]string{
|
||||
"work": `[provider]` + "\n" + `default = "openai"`,
|
||||
})
|
||||
|
||||
_, _, err := LoadWithProfile("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --profile points to missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nonexistent") || !strings.Contains(err.Error(), "work") {
|
||||
t.Errorf("error should mention bad name and list available, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_DefaultProfileMissing_Errors(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "ghost"
|
||||
|
||||
[provider]
|
||||
default = "anthropic"
|
||||
`, map[string]string{
|
||||
"work": `[provider]` + "\n" + `default = "openai"`,
|
||||
})
|
||||
|
||||
_, _, err := LoadWithProfile("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when default_profile points to missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ghost") {
|
||||
t.Errorf("error should mention bad default_profile name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_MergesScalarAndMaps(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "work"
|
||||
|
||||
[provider]
|
||||
default = "anthropic"
|
||||
model = "claude-base"
|
||||
max_tokens = 4096
|
||||
|
||||
[provider.api_keys]
|
||||
anthropic = "BASE_A"
|
||||
openai = "BASE_O"
|
||||
|
||||
[cli_agents]
|
||||
claude = "claude-base"
|
||||
gemini = "gemini-base"
|
||||
`, map[string]string{
|
||||
"work": `[provider]
|
||||
default = "openai"
|
||||
|
||||
[provider.api_keys]
|
||||
mistral = "PROFILE_M"
|
||||
|
||||
[cli_agents]
|
||||
claude = "claude-work"
|
||||
`,
|
||||
})
|
||||
|
||||
cfg, _, err := LoadWithProfile("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
// Profile overrode default.
|
||||
if cfg.Provider.Default != "openai" {
|
||||
t.Errorf("Default = %q, want openai", cfg.Provider.Default)
|
||||
}
|
||||
// Profile didn't touch model/max_tokens — base wins.
|
||||
if cfg.Provider.Model != "claude-base" {
|
||||
t.Errorf("Model = %q, want claude-base (base preserved)", cfg.Provider.Model)
|
||||
}
|
||||
if cfg.Provider.MaxTokens != 4096 {
|
||||
t.Errorf("MaxTokens = %d, want 4096 (base preserved)", cfg.Provider.MaxTokens)
|
||||
}
|
||||
// Map per-key merge.
|
||||
if cfg.Provider.APIKeys["anthropic"] != "BASE_A" {
|
||||
t.Errorf("APIKeys[anthropic] = %q, want BASE_A", cfg.Provider.APIKeys["anthropic"])
|
||||
}
|
||||
if cfg.Provider.APIKeys["openai"] != "BASE_O" {
|
||||
t.Errorf("APIKeys[openai] = %q, want BASE_O", cfg.Provider.APIKeys["openai"])
|
||||
}
|
||||
if cfg.Provider.APIKeys["mistral"] != "PROFILE_M" {
|
||||
t.Errorf("APIKeys[mistral] = %q, want PROFILE_M", cfg.Provider.APIKeys["mistral"])
|
||||
}
|
||||
// cli_agents map per-key.
|
||||
if cfg.CLIAgents["claude"] != "claude-work" {
|
||||
t.Errorf("CLIAgents[claude] = %q, want claude-work", cfg.CLIAgents["claude"])
|
||||
}
|
||||
if cfg.CLIAgents["gemini"] != "gemini-base" {
|
||||
t.Errorf("CLIAgents[gemini] = %q, want gemini-base", cfg.CLIAgents["gemini"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_ArmsMerge_AppendByID(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "work"
|
||||
|
||||
[[arms]]
|
||||
id = "anthropic/opus"
|
||||
cost_weight = 1.0
|
||||
strengths = ["planning"]
|
||||
|
||||
[[arms]]
|
||||
id = "openai/gpt"
|
||||
cost_weight = 0.8
|
||||
`, map[string]string{
|
||||
"work": `
|
||||
[[arms]]
|
||||
id = "anthropic/opus"
|
||||
cost_weight = 0.3
|
||||
|
||||
[[arms]]
|
||||
id = "mistral/large"
|
||||
cost_weight = 0.5
|
||||
`,
|
||||
})
|
||||
|
||||
cfg, _, err := LoadWithProfile("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
if len(cfg.Arms) != 3 {
|
||||
t.Fatalf("len(Arms) = %d, want 3, got %+v", len(cfg.Arms), cfg.Arms)
|
||||
}
|
||||
byID := make(map[string]ArmConfig, 3)
|
||||
for _, a := range cfg.Arms {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
// anthropic/opus: profile overrides cost_weight (and strengths get
|
||||
// dropped because profile replaced the entry).
|
||||
if byID["anthropic/opus"].CostWeight != 0.3 {
|
||||
t.Errorf("opus.CostWeight = %v, want 0.3 (profile override)", byID["anthropic/opus"].CostWeight)
|
||||
}
|
||||
// openai/gpt: untouched by profile.
|
||||
if byID["openai/gpt"].CostWeight != 0.8 {
|
||||
t.Errorf("gpt.CostWeight = %v, want 0.8 (base preserved)", byID["openai/gpt"].CostWeight)
|
||||
}
|
||||
// mistral/large: new from profile.
|
||||
if byID["mistral/large"].CostWeight != 0.5 {
|
||||
t.Errorf("mistral.CostWeight = %v, want 0.5 (profile new)", byID["mistral/large"].CostWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_HooksMerge_Append(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "work"
|
||||
|
||||
[[hooks]]
|
||||
name = "base-hook"
|
||||
event = "pre_tool_use"
|
||||
type = "command"
|
||||
exec = "echo base"
|
||||
`, map[string]string{
|
||||
"work": `
|
||||
[[hooks]]
|
||||
name = "profile-hook"
|
||||
event = "post_tool_use"
|
||||
type = "command"
|
||||
exec = "echo profile"
|
||||
`,
|
||||
})
|
||||
|
||||
cfg, _, err := LoadWithProfile("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
if len(cfg.Hooks) != 2 {
|
||||
t.Fatalf("len(Hooks) = %d, want 2", len(cfg.Hooks))
|
||||
}
|
||||
if cfg.Hooks[0].Name != "base-hook" {
|
||||
t.Errorf("Hooks[0] = %q, want base-hook first", cfg.Hooks[0].Name)
|
||||
}
|
||||
if cfg.Hooks[1].Name != "profile-hook" {
|
||||
t.Errorf("Hooks[1] = %q, want profile-hook second", cfg.Hooks[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_MCPServersMerge_AppendByName(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "work"
|
||||
|
||||
[[mcp_servers]]
|
||||
name = "git"
|
||||
command = "mcp-git-base"
|
||||
|
||||
[[mcp_servers]]
|
||||
name = "shared"
|
||||
command = "mcp-shared"
|
||||
`, map[string]string{
|
||||
"work": `
|
||||
[[mcp_servers]]
|
||||
name = "git"
|
||||
command = "mcp-git-work"
|
||||
|
||||
[[mcp_servers]]
|
||||
name = "extra"
|
||||
command = "mcp-extra"
|
||||
`,
|
||||
})
|
||||
|
||||
cfg, _, err := LoadWithProfile("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
if len(cfg.MCPServers) != 3 {
|
||||
t.Fatalf("len(MCPServers) = %d, want 3, got %+v", len(cfg.MCPServers), cfg.MCPServers)
|
||||
}
|
||||
byName := make(map[string]MCPServerConfig, 3)
|
||||
for _, s := range cfg.MCPServers {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
if byName["git"].Command != "mcp-git-work" {
|
||||
t.Errorf("git.Command = %q, want mcp-git-work (profile override)", byName["git"].Command)
|
||||
}
|
||||
if byName["shared"].Command != "mcp-shared" {
|
||||
t.Errorf("shared.Command = %q, want mcp-shared (base preserved)", byName["shared"].Command)
|
||||
}
|
||||
if byName["extra"].Command != "mcp-extra" {
|
||||
t.Errorf("extra.Command = %q, want mcp-extra (profile new)", byName["extra"].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_EnvOverridesProfile(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "work"
|
||||
`, map[string]string{
|
||||
"work": `
|
||||
[provider.api_keys]
|
||||
anthropic = "PROFILE_KEY"
|
||||
`,
|
||||
})
|
||||
t.Setenv("ANTHROPIC_API_KEY", "ENV_KEY")
|
||||
|
||||
cfg, _, err := LoadWithProfile("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithProfile: %v", err)
|
||||
}
|
||||
if cfg.Provider.APIKeys["anthropic"] != "ENV_KEY" {
|
||||
t.Errorf("APIKeys[anthropic] = %q, want ENV_KEY", cfg.Provider.APIKeys["anthropic"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile_QualityFile(t *testing.T) {
|
||||
globalDir := "/test/gnoma"
|
||||
cases := []struct {
|
||||
name string
|
||||
prof Profile
|
||||
want string
|
||||
}{
|
||||
{"legacy", Profile{Active: false}, "/test/gnoma/quality.json"},
|
||||
{"active_work", Profile{Active: true, Name: "work"}, "/test/gnoma/quality-work.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.prof.QualityFile(globalDir); got != tc.want {
|
||||
t.Errorf("QualityFile = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile_SessionDir(t *testing.T) {
|
||||
root := "/repo"
|
||||
cases := []struct {
|
||||
name string
|
||||
prof Profile
|
||||
want string
|
||||
}{
|
||||
{"legacy", Profile{Active: false}, "/repo/.gnoma/sessions"},
|
||||
{"active_private", Profile{Active: true, Name: "private"}, "/repo/.gnoma/sessions/private"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.prof.SessionDir(root); got != tc.want {
|
||||
t.Errorf("SessionDir = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProfiles(t *testing.T) {
|
||||
profileTestEnv(t, ``, map[string]string{
|
||||
"alpha": `[provider]
|
||||
default = "anthropic"`,
|
||||
"beta": `[provider]` + "\n" + `default = "openai"`,
|
||||
"gamma": `[provider]` + "\n" + `default = "mistral"`,
|
||||
})
|
||||
|
||||
names, err := ListProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListProfiles: %v", err)
|
||||
}
|
||||
if len(names) != 3 || names[0] != "alpha" || names[1] != "beta" || names[2] != "gamma" {
|
||||
t.Errorf("ListProfiles = %v, want [alpha beta gamma]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProfiles_NoDir(t *testing.T) {
|
||||
profileTestEnv(t, ``, nil)
|
||||
|
||||
names, err := ListProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListProfiles: %v", err)
|
||||
}
|
||||
if names != nil {
|
||||
t.Errorf("ListProfiles = %v, want nil when no profiles/ dir", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_RejectsTraversalNames(t *testing.T) {
|
||||
profileTestEnv(t, ``, map[string]string{
|
||||
"work": `[provider]` + "\n" + `default = "openai"`,
|
||||
})
|
||||
|
||||
cases := []string{
|
||||
"../foo",
|
||||
"foo/bar",
|
||||
"foo\\bar",
|
||||
"..",
|
||||
".hidden",
|
||||
"work .toml", // space is rejected too
|
||||
}
|
||||
for _, name := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := LoadWithProfile(name)
|
||||
if err == nil {
|
||||
t.Fatalf("LoadWithProfile(%q) should error, got nil", name)
|
||||
}
|
||||
if !errors.Is(err, ErrProfileResolution) {
|
||||
t.Errorf("error %v does not wrap ErrProfileResolution", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_RejectsTraversalInDefaultProfile(t *testing.T) {
|
||||
profileTestEnv(t, `default_profile = "../foo"
|
||||
`, map[string]string{
|
||||
"work": `[provider]` + "\n" + `default = "openai"`,
|
||||
})
|
||||
|
||||
_, _, err := LoadWithProfile("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when default_profile contains traversal")
|
||||
}
|
||||
if !errors.Is(err, ErrProfileResolution) {
|
||||
t.Errorf("error %v does not wrap ErrProfileResolution", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithProfile_ResolutionErrors_AreSentinel(t *testing.T) {
|
||||
// All three resolution-failure paths must wrap ErrProfileResolution
|
||||
// so the caller in cmd/gnoma can distinguish actionable user errors
|
||||
// from generic config failures.
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
profiles map[string]string
|
||||
flag string
|
||||
}{
|
||||
{
|
||||
name: "flag_set_no_profiles_dir",
|
||||
base: `[provider]` + "\n" + `default = "anthropic"`,
|
||||
profiles: nil,
|
||||
flag: "work",
|
||||
},
|
||||
{
|
||||
name: "dir_present_no_flag_no_default",
|
||||
base: `[provider]` + "\n" + `default = "anthropic"`,
|
||||
profiles: map[string]string{"work": `[provider]` + "\n" + `default = "openai"`},
|
||||
flag: "",
|
||||
},
|
||||
{
|
||||
name: "flag_set_profile_missing",
|
||||
base: `[provider]` + "\n" + `default = "anthropic"`,
|
||||
profiles: map[string]string{"work": `[provider]` + "\n" + `default = "openai"`},
|
||||
flag: "missing",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
profileTestEnv(t, tc.base, tc.profiles)
|
||||
_, _, err := LoadWithProfile(tc.flag)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrProfileResolution) {
|
||||
t.Errorf("error %v does not wrap ErrProfileResolution", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_BackwardCompat(t *testing.T) {
|
||||
// Existing Load() callers should keep working when no profiles/ dir
|
||||
// exists. This guards against accidentally breaking the migration
|
||||
// path described in the post-SLM plan.
|
||||
profileTestEnv(t, `[provider]
|
||||
default = "anthropic"
|
||||
`, nil)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Provider.Default != "anthropic" {
|
||||
t.Errorf("Provider.Default = %q, want anthropic", cfg.Provider.Default)
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,15 @@ type SessionStore struct {
|
||||
|
||||
// NewSessionStore creates a store rooted at <projectRoot>/.gnoma/sessions/.
|
||||
func NewSessionStore(projectRoot string, maxKeep int, logger *slog.Logger) *SessionStore {
|
||||
return NewSessionStoreAt(filepath.Join(projectRoot, ".gnoma", "sessions"), maxKeep, logger)
|
||||
}
|
||||
|
||||
// NewSessionStoreAt creates a store rooted at an explicit sessions directory.
|
||||
// Use this when the directory layout differs from <projectRoot>/.gnoma/sessions
|
||||
// (e.g. per-profile session segregation under .gnoma/sessions/<profile>/).
|
||||
func NewSessionStoreAt(sessionsDir string, maxKeep int, logger *slog.Logger) *SessionStore {
|
||||
return &SessionStore{
|
||||
dir: filepath.Join(projectRoot, ".gnoma", "sessions"),
|
||||
dir: sessionsDir,
|
||||
maxKeep: maxKeep,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user