635dad660c
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.
274 lines
8.7 KiB
Go
274 lines
8.7 KiB
Go
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
|
|
}
|