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:
2026-05-19 21:35:33 +02:00
parent 0aabd19906
commit 635dad660c
9 changed files with 1128 additions and 77 deletions
+24 -16
View File
@@ -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
View File
@@ -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)