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)
|
||||
|
||||
Reference in New Issue
Block a user