From 635dad660cfb1a00aaea9706693e0b6cf7f814df Mon Sep 17 00:00:00 2001 From: vikingowl <26+vikingowl@noreply.somegit.dev> Date: Tue, 19 May 2026 21:35:33 +0200 Subject: [PATCH] feat(config): per-profile config layering with --profile flag (Phase C-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.toml → /.gnoma/config.toml → env. Map sections merge per-key; [[arms]] and [[mcp_servers]] merge by id/name; [[hooks]] appends. Per-profile data: quality-.json and sessions// 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. --- cmd/gnoma/main.go | 40 +- cmd/gnoma/router_cmd.go | 18 +- docs/profiles.md | 196 +++++++ .../plans/2026-05-19-post-slm-unlock.md | 64 +- internal/config/config.go | 5 + internal/config/load.go | 46 +- internal/config/profile.go | 273 +++++++++ internal/config/profile_test.go | 554 ++++++++++++++++++ internal/session/store.go | 9 +- 9 files changed, 1128 insertions(+), 77 deletions(-) create mode 100644 docs/profiles.md create mode 100644 internal/config/profile.go create mode 100644 internal/config/profile_test.go diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 38004f8..5354135 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -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 { diff --git a/cmd/gnoma/router_cmd.go b/cmd/gnoma/router_cmd.go index 14a5612..f54dbec 100644 --- a/cmd/gnoma/router_cmd.go +++ b/cmd/gnoma/router_cmd.go @@ -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 `. 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 ") 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) diff --git a/docs/profiles.md b/docs/profiles.md new file mode 100644 index 0000000..5e37ef0 --- /dev/null +++ b/docs/profiles.md @@ -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: + +``` +/.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/.toml` — the **active** profile + (only when `profiles/` exists). +4. `/.gnoma/config.toml` — project overrides. +5. Environment variables (`ANTHROPIC_API_KEY`, `GNOMA_PROVIDER`, etc.). + +The active profile is resolved as follows: + +- If `--profile ` 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-.json`, `sessions//`) predictable and to prevent +path traversal via `--profile`. + +## Where per-profile data lives + +| Data | Path | +|---|---| +| Router quality (bandit telemetry) | `~/.config/gnoma/quality-.json` | +| Session history | `/.gnoma/sessions//` | +| 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-.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 `/.gnoma/sessions/` (no profile + subdirectory). +- `--profile ` returns a clear error pointing you at the + `profiles/` directory to create. + +Existing single-config installations don't need to do anything. diff --git a/docs/superpowers/plans/2026-05-19-post-slm-unlock.md b/docs/superpowers/plans/2026-05-19-post-slm-unlock.md index b652b2b..d706ea1 100644 --- a/docs/superpowers/plans/2026-05-19-post-slm-unlock.md +++ b/docs/superpowers/plans/2026-05-19-post-slm-unlock.md @@ -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 ` CLI flag. +- [x] `--profile ` 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 ` 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 + `/.gnoma/sessions//`. When no `profiles/` + directory exists, legacy `/.gnoma/sessions/` path + is preserved (no migration). +- **Per-profile `quality.json`**: yes, at + `~/.config/gnoma/quality-.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-.json` and prefixes output + with `Profile: `. + +**Effort:** C-1 shipped at ~250 LOC + ~370 LOC tests + docs page. +C-2 and C-3 still scoped at ~80 / ~120 LOC respectively. --- diff --git a/internal/config/config.go b/internal/config/config.go index 0f0ad7e..43b5c86 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/load.go b/internal/config/load.go index 55f9e05..045d4d2 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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/.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 { diff --git a/internal/config/profile.go b/internal/config/profile.go new file mode 100644 index 0000000..83ff517 --- /dev/null +++ b/internal/config/profile.go @@ -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-.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 /.gnoma/sessions/; active profiles +// get /.gnoma/sessions// 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/.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 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-.json and sessions//. 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 +} diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go new file mode 100644 index 0000000..cb3e7a2 --- /dev/null +++ b/internal/config/profile_test.go @@ -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) + } +} diff --git a/internal/session/store.go b/internal/session/store.go index c57a34c..d42792a 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -21,8 +21,15 @@ type SessionStore struct { // NewSessionStore creates a store rooted at /.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 /.gnoma/sessions +// (e.g. per-profile session segregation under .gnoma/sessions//). +func NewSessionStoreAt(sessionsDir string, maxKeep int, logger *slog.Logger) *SessionStore { return &SessionStore{ - dir: filepath.Join(projectRoot, ".gnoma", "sessions"), + dir: sessionsDir, maxKeep: maxKeep, logger: logger, }