1733 lines
52 KiB
Go
1733 lines
52 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"reasonix/internal/fileutil"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/mcpdiag"
|
|
"reasonix/internal/netclient"
|
|
"reasonix/internal/permission"
|
|
)
|
|
|
|
// edit.go is the programmatic mutation surface a settings UI drives: change the
|
|
// default model, add/remove a provider, set the planner, edit permission rules,
|
|
// add/remove an MCP server — each validated, then persisted with SaveTo. It is
|
|
// separate from the `reasonix setup` wizard (cli) so a GUI can apply one setting at a
|
|
// time without replaying the whole interactive flow. Every mutator works on the
|
|
// in-memory *Config; nothing writes to disk until SaveTo/Save is called, so a UI
|
|
// can stage several changes and commit once. Mutations round-trip through
|
|
// RenderTOML → Load (the wizard relies on the same guarantee).
|
|
|
|
// permission rule list names accepted by the rule mutators.
|
|
const (
|
|
listAllow = "allow"
|
|
listAsk = "ask"
|
|
listDeny = "deny"
|
|
)
|
|
|
|
// SetDefaultModel points default_model at an existing model. It accepts both
|
|
// forms used by the runtime resolver:
|
|
// - "provider" — the provider's own default model;
|
|
// - "provider/model" — that specific model under that provider.
|
|
//
|
|
// Either is rejected when the target does not exist, so a UI can't strand
|
|
// the config on a model that doesn't exist.
|
|
func (c *Config) SetDefaultModel(name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("set default: empty name")
|
|
}
|
|
if _, ok := c.ResolveModel(name); !ok {
|
|
return fmt.Errorf("set default: no such model %q (configured: %s)", name, c.providerNames())
|
|
}
|
|
c.DefaultModel = name
|
|
return nil
|
|
}
|
|
|
|
// SetPlannerModel sets (or, with "", clears) agent.planner_model for two-model
|
|
// collaboration. A non-empty name must be a configured provider.
|
|
func (c *Config) SetPlannerModel(name string) error {
|
|
if name == "" {
|
|
c.Agent.PlannerModel = ""
|
|
return nil
|
|
}
|
|
if _, ok := c.Provider(name); !ok {
|
|
return fmt.Errorf("set planner: no provider %q (configured: %s)", name, c.providerNames())
|
|
}
|
|
c.Agent.PlannerModel = name
|
|
return nil
|
|
}
|
|
|
|
// SetAutoPlan sets the interactive auto-plan gate. "off" keeps plan mode manual;
|
|
// "on" opts into automatic read-only planning for complex-looking turns.
|
|
// "ask" is accepted as a legacy synonym for "on" but is never written back.
|
|
func (c *Config) SetAutoPlan(mode string) error {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "off":
|
|
c.Agent.AutoPlan = "off"
|
|
case "on", "ask":
|
|
c.Agent.AutoPlan = "on"
|
|
default:
|
|
return fmt.Errorf("auto_plan %q: must be off|on", mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopDefaultToolApprovalMode sets the Ask/Auto/YOLO posture used only
|
|
// for newly-created desktop sessions.
|
|
func (c *Config) SetDesktopDefaultToolApprovalMode(mode string) error {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "ask":
|
|
c.Desktop.DefaultToolApprovalMode = "ask"
|
|
case "auto":
|
|
c.Desktop.DefaultToolApprovalMode = "auto"
|
|
case "yolo", "full", "full-access", "bypass":
|
|
c.Desktop.DefaultToolApprovalMode = "yolo"
|
|
default:
|
|
return fmt.Errorf("default_tool_approval_mode %q: must be ask|auto|yolo", mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetMemoryCompilerEnabled toggles the v5 execution-memory compiler.
|
|
func (c *Config) SetMemoryCompilerEnabled(enabled bool) error {
|
|
c.Agent.MemoryCompiler.Enabled = &enabled
|
|
return nil
|
|
}
|
|
|
|
// SetMemoryCompilerVerbosity controls whether Memory v5 only observes turns or
|
|
// also injects compact execution contracts into provider-visible messages.
|
|
func (c *Config) SetMemoryCompilerVerbosity(verbosity string) error {
|
|
normalized := NormalizeMemoryCompilerVerbosity(verbosity)
|
|
if strings.TrimSpace(verbosity) != "" && normalized == MemoryCompilerVerbosityObserve {
|
|
switch strings.ToLower(strings.TrimSpace(verbosity)) {
|
|
case "observe", "observed", "silent", "minimal", "none":
|
|
default:
|
|
return fmt.Errorf("memory_compiler.verbosity %q: must be observe|compact", verbosity)
|
|
}
|
|
}
|
|
c.Agent.MemoryCompiler.Verbosity = normalized
|
|
return nil
|
|
}
|
|
|
|
// SetUIShortcutLayout selects the CLI keyboard shortcut layout. "classic" keeps
|
|
// historical behavior; "desktop" enables the two-axis desktop-style shortcuts.
|
|
func (c *Config) SetUIShortcutLayout(layout string) error {
|
|
switch strings.ToLower(strings.TrimSpace(layout)) {
|
|
case "", "classic", "default", "legacy", "off":
|
|
c.UI.ShortcutLayout = "classic"
|
|
case "desktop", "dual", "dual-axis", "dual_axis":
|
|
c.UI.ShortcutLayout = "desktop"
|
|
default:
|
|
return fmt.Errorf("shortcut_layout %q: must be classic|desktop", layout)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertProvider adds e, or replaces an existing provider with the same name
|
|
// (preserving its position). Required fields (name, kind, base_url, model/models)
|
|
// are validated; whether the kind is actually registered and the key resolves is
|
|
// checked later by provider.New / Validate, which give actionable errors.
|
|
func (c *Config) UpsertProvider(e ProviderEntry) error {
|
|
normalizeProviderEffortFields(&e)
|
|
if err := validateProvider(e); err != nil {
|
|
return err
|
|
}
|
|
for i := range c.Providers {
|
|
if c.Providers[i].Name == e.Name {
|
|
c.Providers[i] = e
|
|
return nil
|
|
}
|
|
}
|
|
c.Providers = append(c.Providers, e)
|
|
return nil
|
|
}
|
|
|
|
// UpsertProviderPreservingRuntime applies persisted provider fields while
|
|
// retaining credentials and capability state resolved by the latest config
|
|
// load. It is used when replaying an optimistic edit log onto fresh state.
|
|
func (c *Config) UpsertProviderPreservingRuntime(e ProviderEntry) error {
|
|
if current, ok := c.Provider(e.Name); ok && strings.TrimSpace(current.APIKeyEnv) == strings.TrimSpace(e.APIKeyEnv) {
|
|
e.resolvedAPIKey = current.resolvedAPIKey
|
|
e.resolvedSource = current.resolvedSource
|
|
e.visionOverride = current.visionOverride
|
|
}
|
|
return c.UpsertProvider(e)
|
|
}
|
|
|
|
// ProviderEntryConfigSnapshot strips process-only state from a provider copy so
|
|
// optimistic edit logs never retain resolved credential values.
|
|
func ProviderEntryConfigSnapshot(entry ProviderEntry) ProviderEntry {
|
|
entry.resolvedAPIKey = ""
|
|
entry.resolvedSource = CredentialSource{}
|
|
entry.visionOverride = nil
|
|
return entry
|
|
}
|
|
|
|
// ProviderEntriesConfigEqual compares persisted provider configuration while
|
|
// ignoring credentials and capability state resolved only for the current
|
|
// process. Setup uses it for optimistic conflict detection during replay.
|
|
func ProviderEntriesConfigEqual(a, b ProviderEntry) bool {
|
|
return reflect.DeepEqual(ProviderEntryConfigSnapshot(a), ProviderEntryConfigSnapshot(b))
|
|
}
|
|
|
|
// SetProviderEffort updates a provider's provider-specific thinking effort knob.
|
|
func (c *Config) SetProviderEffort(name, effort string) error {
|
|
for i := range c.Providers {
|
|
if c.Providers[i].Name == name {
|
|
c.Providers[i].Effort = normalizeStoredEffort(effort)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("set provider effort: no provider %q", name)
|
|
}
|
|
|
|
// SetLanguage pins the CLI UI/model language; empty/auto clears the override so runtime detection falls back to REASONIX_LANG / locale.
|
|
func (c *Config) SetLanguage(lang string) error {
|
|
switch strings.ToLower(strings.TrimSpace(lang)) {
|
|
case "", "auto":
|
|
c.Language = ""
|
|
case "en":
|
|
c.Language = "en"
|
|
case "zh":
|
|
c.Language = "zh"
|
|
default:
|
|
return fmt.Errorf("language %q: must be auto|en|zh", lang)
|
|
}
|
|
c.ApplyDeepSeekOfficialDefaultPricing()
|
|
return nil
|
|
}
|
|
|
|
// SetReasoningLanguage pins the preferred language for visible reasoning text.
|
|
// Empty/auto follows the conversation language.
|
|
func (c *Config) SetReasoningLanguage(lang string) error {
|
|
switch strings.ToLower(strings.TrimSpace(lang)) {
|
|
case "", "auto", "follow", "conversation", "detect", "default", "model", "model-default", "model_default", "provider":
|
|
c.Agent.ReasoningLanguage = ""
|
|
case "zh", "cn", "chinese", "中文":
|
|
c.Agent.ReasoningLanguage = "zh"
|
|
case "en", "english":
|
|
c.Agent.ReasoningLanguage = "en"
|
|
default:
|
|
return fmt.Errorf("reasoning language %q: must be auto|zh|en", lang)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopLanguage pins the desktop UI language. It intentionally does not
|
|
// modify Config.Language, which is used by the CLI/model-facing runtime.
|
|
func (c *Config) SetDesktopLanguage(lang string) error {
|
|
switch strings.ToLower(strings.TrimSpace(lang)) {
|
|
case "", "auto":
|
|
c.Desktop.Language = ""
|
|
case "en":
|
|
c.Desktop.Language = "en"
|
|
case "zh":
|
|
c.Desktop.Language = "zh"
|
|
default:
|
|
return fmt.Errorf("desktop language %q: must be auto|en|zh", lang)
|
|
}
|
|
c.ApplyDeepSeekOfficialDefaultPricing()
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopAppearance sets desktop-only theme preferences. It must not affect
|
|
// CLI theme settings or provider-visible request data.
|
|
func (c *Config) SetDesktopAppearance(theme, style string) error {
|
|
switch strings.ToLower(strings.TrimSpace(theme)) {
|
|
case "auto":
|
|
c.Desktop.Theme = "auto"
|
|
case "light":
|
|
c.Desktop.Theme = "light"
|
|
case "", "dark":
|
|
c.Desktop.Theme = "dark"
|
|
default:
|
|
return fmt.Errorf("desktop theme %q: must be auto|dark|light", theme)
|
|
}
|
|
if strings.TrimSpace(style) == "" {
|
|
c.Desktop.ThemeStyle = ""
|
|
return nil
|
|
}
|
|
normalized := normalizeThemeStyle(style)
|
|
if normalized == "" {
|
|
return fmt.Errorf("desktop theme style %q: must be graphite|aurora|slate|carbon|nocturne|amber", style)
|
|
}
|
|
c.Desktop.ThemeStyle = normalized
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopLayoutStyle sets the desktop layout style. UI-only; it must not
|
|
// affect CLI output or provider-visible request data.
|
|
func (c *Config) SetDesktopLayoutStyle(style string) error {
|
|
switch strings.ToLower(strings.TrimSpace(style)) {
|
|
case "", "classic":
|
|
c.Desktop.LayoutStyle = "classic"
|
|
case "workbench", "workspace":
|
|
c.Desktop.LayoutStyle = "workbench"
|
|
case "creation":
|
|
c.Desktop.LayoutStyle = "creation"
|
|
default:
|
|
return fmt.Errorf("desktop layout style %q: must be classic|workbench|creation", style)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopCloseBehavior sets the desktop close-window preference. It is
|
|
// intentionally UI-only and must not affect model prompts or provider-visible
|
|
// request data.
|
|
func (c *Config) SetDesktopCloseBehavior(mode string) error {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "quit", "exit":
|
|
c.Desktop.CloseBehavior = "quit"
|
|
case "", "background", "hide":
|
|
c.Desktop.CloseBehavior = "background"
|
|
default:
|
|
return fmt.Errorf("close behavior %q: must be quit|background", mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopDisplayMode sets the transcript display mode. UI-only.
|
|
func (c *Config) SetDesktopDisplayMode(mode string) error {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "compact", "minimal":
|
|
c.Desktop.DisplayMode = "compact"
|
|
case "", "standard":
|
|
c.Desktop.DisplayMode = "standard"
|
|
default:
|
|
return fmt.Errorf("display mode %q: must be standard|compact", mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopStatusBarStyle sets the desktop status bar metric label style.
|
|
// UI-only; it must not affect CLI output or provider-visible request data.
|
|
func (c *Config) SetDesktopStatusBarStyle(style string) error {
|
|
switch strings.ToLower(strings.TrimSpace(style)) {
|
|
case "icon", "icons":
|
|
c.Desktop.StatusBarStyle = "icon"
|
|
case "", "text", "label", "labels":
|
|
c.Desktop.StatusBarStyle = "text"
|
|
default:
|
|
return fmt.Errorf("status bar style %q: must be icon|text", style)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopStatusBarItems sets the ordered visible desktop status bar items.
|
|
// UI-only; it must not affect CLI output or provider-visible request data.
|
|
func (c *Config) SetDesktopStatusBarItems(items []string) error {
|
|
out := make([]string, 0, len(items))
|
|
seen := map[string]bool{}
|
|
for _, raw := range items {
|
|
id := strings.TrimSpace(raw)
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
if !knownDesktopStatusBarItems[id] {
|
|
return fmt.Errorf("status bar item %q: unknown item", id)
|
|
}
|
|
out = append(out, id)
|
|
seen[id] = true
|
|
}
|
|
if len(out) == 0 {
|
|
out = DefaultDesktopStatusBarItems()
|
|
}
|
|
c.Desktop.StatusBarItems = out
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopCheckUpdates sets whether the desktop app checks for updates on
|
|
// startup. Manual checks remain available in Settings regardless of this value.
|
|
func (c *Config) SetDesktopCheckUpdates(enabled bool) error {
|
|
c.Desktop.CheckUpdates = &enabled
|
|
return nil
|
|
}
|
|
|
|
// SetColdResumePrune toggles auto-elision of stale tool results on cold resume.
|
|
func (c *Config) SetColdResumePrune(enabled bool) error {
|
|
c.Agent.ColdResumePrune = &enabled
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopTelemetry sets whether the desktop sends the anonymous launch ping.
|
|
func (c *Config) SetDesktopTelemetry(enabled bool) error {
|
|
c.Desktop.Telemetry = &enabled
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopMetrics sets whether the desktop sends aggregate desktop metrics.
|
|
func (c *Config) SetDesktopMetrics(enabled bool) error {
|
|
c.Desktop.Metrics = &enabled
|
|
return nil
|
|
}
|
|
|
|
// SetUICloseBehavior is kept for callers compiled against the old edit API.
|
|
func (c *Config) SetUICloseBehavior(mode string) error {
|
|
return c.SetDesktopCloseBehavior(mode)
|
|
}
|
|
|
|
// SetExpandThinking sets whether the desktop reasoning/thinking section is
|
|
// expanded by default. It is desktop-only and must not affect CLI output or
|
|
// provider-visible request data.
|
|
func (c *Config) SetExpandThinking(on bool) error {
|
|
c.Desktop.ExpandThinking = on
|
|
return nil
|
|
}
|
|
|
|
// SetShowReasoning sets the CLI's default verbose-reasoning preference. When
|
|
// true, thinking text is shown in the chat TUI on startup; when false (the
|
|
// default), it stays collapsed until the user toggles it with Ctrl+O or
|
|
// /verbose.
|
|
func (c *Config) SetShowReasoning(on bool) error {
|
|
c.UI.ShowReasoning = on
|
|
return nil
|
|
}
|
|
|
|
// SetProviderThinking updates a provider's provider-specific thinking mode knob.
|
|
func (c *Config) SetProviderThinking(name, thinking string) error {
|
|
for i := range c.Providers {
|
|
if c.Providers[i].Name == name {
|
|
c.Providers[i].Thinking = strings.ToLower(strings.TrimSpace(thinking))
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("set provider thinking: no provider %q", name)
|
|
}
|
|
|
|
// SetNetwork updates ordinary outbound network proxy settings. Invalid custom
|
|
// proxy settings are rejected here so the desktop panel cannot save a config that
|
|
// would break provider startup.
|
|
func (c *Config) SetNetwork(n NetworkConfig) error {
|
|
n.ProxyMode = netclient.NormalizeMode(n.ProxyMode)
|
|
n.ProxyURL = strings.TrimSpace(n.ProxyURL)
|
|
n.NoProxy = strings.TrimSpace(n.NoProxy)
|
|
n.Proxy.Type = strings.ToLower(strings.TrimSpace(n.Proxy.Type))
|
|
n.Proxy.Server = strings.TrimSpace(n.Proxy.Server)
|
|
n.Proxy.Username = strings.TrimSpace(n.Proxy.Username)
|
|
c.Network = n
|
|
return netclient.Validate(c.NetworkProxySpec())
|
|
}
|
|
|
|
// ModelRefsProvider reports whether ref targets the named provider. It matches
|
|
// both bare provider names ("deepseek") and "provider/model" refs.
|
|
func ModelRefsProvider(ref, name string) bool {
|
|
ref = strings.TrimSpace(ref)
|
|
name = strings.TrimSpace(name)
|
|
if ref == "" || name == "" {
|
|
return false
|
|
}
|
|
if ref == name {
|
|
return true
|
|
}
|
|
prov, _, ok := strings.Cut(ref, "/")
|
|
return ok && prov == name
|
|
}
|
|
|
|
func (c *Config) modelRefTargetsProvider(ref, name string) bool {
|
|
if ModelRefsProvider(ref, name) {
|
|
return true
|
|
}
|
|
if e, ok := c.ResolveModel(ref); ok {
|
|
return e.Name == name
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RemoveProvider deletes the named provider. References to the removed provider
|
|
// are migrated to the first remaining configured provider when possible. The
|
|
// default model is required, so removal is refused when no fallback exists;
|
|
// optional planner/subagent refs are cleared instead of being left dangling.
|
|
func (c *Config) RemoveProvider(name string) error {
|
|
name = strings.TrimSpace(name)
|
|
idx := -1
|
|
for i := range c.Providers {
|
|
if c.Providers[i].Name == name {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return fmt.Errorf("remove provider: no provider %q", name)
|
|
}
|
|
|
|
defaultRefsProvider := c.modelRefTargetsProvider(c.DefaultModel, name)
|
|
plannerRefsProvider := c.modelRefTargetsProvider(c.Agent.PlannerModel, name)
|
|
subagentRefsProvider := c.modelRefTargetsProvider(c.Agent.SubagentModel, name)
|
|
subagentModelRefsProvider := map[string]bool{}
|
|
for skill, ref := range c.Agent.SubagentModels {
|
|
if c.modelRefTargetsProvider(ref, name) {
|
|
subagentModelRefsProvider[skill] = true
|
|
}
|
|
}
|
|
|
|
fallback := ""
|
|
if defaultRefsProvider || plannerRefsProvider || subagentRefsProvider || len(subagentModelRefsProvider) > 0 {
|
|
fallback = c.providerRemovalFallback(name)
|
|
}
|
|
if defaultRefsProvider && fallback == "" {
|
|
return fmt.Errorf("remove provider: %q is referenced by default_model and no other configured provider exists", name)
|
|
}
|
|
|
|
c.Providers = append(c.Providers[:idx], c.Providers[idx+1:]...)
|
|
|
|
if defaultRefsProvider {
|
|
c.DefaultModel = fallback
|
|
}
|
|
if plannerRefsProvider {
|
|
c.Agent.PlannerModel = fallback
|
|
}
|
|
if subagentRefsProvider {
|
|
c.Agent.SubagentModel = fallback
|
|
}
|
|
for skill := range subagentModelRefsProvider {
|
|
if fallback != "" {
|
|
c.Agent.SubagentModels[skill] = fallback
|
|
} else {
|
|
delete(c.Agent.SubagentModels, skill)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) providerRemovalFallback(name string) string {
|
|
for i := range c.Providers {
|
|
p := &c.Providers[i]
|
|
if p.Name == name || !p.Configured() || len(p.ModelList()) == 0 {
|
|
continue
|
|
}
|
|
return p.Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// validateProvider checks the fields a provider can't function without.
|
|
func validateProvider(e ProviderEntry) error {
|
|
switch {
|
|
case strings.TrimSpace(e.Name) == "":
|
|
return fmt.Errorf("provider: name is required")
|
|
case strings.TrimSpace(e.Kind) == "":
|
|
return fmt.Errorf("provider %q: kind is required", e.Name)
|
|
case strings.TrimSpace(e.BaseURL) == "":
|
|
return fmt.Errorf("provider %q: base_url is required", e.Name)
|
|
case !providerHasAnyModel(e):
|
|
return fmt.Errorf("provider %q: model is required", e.Name)
|
|
case strings.TrimSpace(e.APIKeyEnv) != "" && !IsValidCredentialKey(e.APIKeyEnv):
|
|
return fmt.Errorf("provider %q: api_key_env %q is not a valid environment variable name", e.Name, e.APIKeyEnv)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func providerHasAnyModel(e ProviderEntry) bool {
|
|
if strings.TrimSpace(e.Model) != "" {
|
|
return true
|
|
}
|
|
for _, m := range e.Models {
|
|
if strings.TrimSpace(m) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetPermissionMode sets the writer-fallback mode. Accepts "ask", "allow", or
|
|
// "deny" (case-insensitive); anything else errors rather than silently
|
|
// defaulting, so a UI surfaces a typo instead of installing a surprising mode.
|
|
func (c *Config) SetPermissionMode(mode string) error {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "ask", "allow", "deny":
|
|
c.Permissions.Mode = strings.ToLower(strings.TrimSpace(mode))
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("permission mode %q: must be ask|allow|deny", mode)
|
|
}
|
|
}
|
|
|
|
// AddPermissionRule appends a rule ("ToolName" or "ToolName(glob)") to the
|
|
// allow / ask / deny list. The rule is validated with the same parser the gate
|
|
// uses, and a duplicate is a no-op so a UI can call it idempotently.
|
|
func (c *Config) AddPermissionRule(list, rule string) error {
|
|
target, err := c.ruleList(list)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rule = strings.TrimSpace(rule)
|
|
if _, ok := permission.ParseRule(rule); !ok {
|
|
return fmt.Errorf("invalid permission rule %q (want \"ToolName\" or \"ToolName(glob)\")", rule)
|
|
}
|
|
for _, existing := range *target {
|
|
if existing == rule {
|
|
return nil // already present
|
|
}
|
|
}
|
|
*target = append(*target, rule)
|
|
return nil
|
|
}
|
|
|
|
// RemovePermissionRule drops the first exact match of rule from the named list,
|
|
// reporting whether anything was removed.
|
|
func (c *Config) RemovePermissionRule(list, rule string) (bool, error) {
|
|
target, err := c.ruleList(list)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
rule = strings.TrimSpace(rule)
|
|
for i, existing := range *target {
|
|
if existing == rule {
|
|
*target = append((*target)[:i], (*target)[i+1:]...)
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// ruleList returns a pointer to the named rule slice so mutators can append to
|
|
// it in place. An unknown list name errors.
|
|
func (c *Config) ruleList(list string) (*[]string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(list)) {
|
|
case listAllow:
|
|
return &c.Permissions.Allow, nil
|
|
case listAsk:
|
|
return &c.Permissions.Ask, nil
|
|
case listDeny:
|
|
return &c.Permissions.Deny, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown permission list %q (want allow|ask|deny)", list)
|
|
}
|
|
}
|
|
|
|
// AddSkillPath appends a custom skill root, deduping by its expanded absolute
|
|
// path while preserving the caller's original spelling in the config file.
|
|
func (c *Config) AddSkillPath(path string) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return fmt.Errorf("skill path: empty path")
|
|
}
|
|
want := CanonicalSkillPath(path)
|
|
c.removeExcludedSkillPath(want)
|
|
for _, existing := range c.Skills.Paths {
|
|
if CanonicalSkillPath(existing) == want {
|
|
return nil
|
|
}
|
|
}
|
|
c.Skills.Paths = append(c.Skills.Paths, path)
|
|
return nil
|
|
}
|
|
|
|
// RemoveSkillPath removes the first custom skill root matching path after
|
|
// expansion and path cleaning. It reports whether anything changed.
|
|
func (c *Config) RemoveSkillPath(path string) (bool, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return false, fmt.Errorf("skill path: empty path")
|
|
}
|
|
want := CanonicalSkillPath(path)
|
|
for i, existing := range c.Skills.Paths {
|
|
if CanonicalSkillPath(existing) == want {
|
|
c.Skills.Paths = append(c.Skills.Paths[:i], c.Skills.Paths[i+1:]...)
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// RestoreSkillPath removes a pseudo-deleted skill source from excluded_paths.
|
|
func (c *Config) RestoreSkillPath(path string) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return fmt.Errorf("skill path: empty path")
|
|
}
|
|
want := CanonicalSkillPath(path)
|
|
if want == "" {
|
|
return fmt.Errorf("skill path: empty path")
|
|
}
|
|
c.removeExcludedSkillPath(want)
|
|
return nil
|
|
}
|
|
|
|
// ExcludeSkillPath hides any skill discovery root matching path. This is used by
|
|
// UI "remove source" actions for convention roots that are not stored in paths.
|
|
func (c *Config) ExcludeSkillPath(path string) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return fmt.Errorf("skill path: empty path")
|
|
}
|
|
want := CanonicalSkillPath(path)
|
|
if want == "" {
|
|
return fmt.Errorf("skill path: empty path")
|
|
}
|
|
for _, existing := range c.Skills.ExcludedPaths {
|
|
if CanonicalSkillPath(existing) == want {
|
|
return nil
|
|
}
|
|
}
|
|
c.Skills.ExcludedPaths = append(c.Skills.ExcludedPaths, path)
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) removeExcludedSkillPath(want string) {
|
|
next := c.Skills.ExcludedPaths[:0]
|
|
for _, existing := range c.Skills.ExcludedPaths {
|
|
if CanonicalSkillPath(existing) != want {
|
|
next = append(next, existing)
|
|
}
|
|
}
|
|
c.Skills.ExcludedPaths = next
|
|
}
|
|
|
|
// SetSkillEnabled persists a per-skill enable/disable preference. Skills are
|
|
// enabled by default; disabling records the name, enabling removes it.
|
|
func (c *Config) SetSkillEnabled(name string, enabled bool) error {
|
|
name = strings.TrimSpace(name)
|
|
key := SkillNameKey(name)
|
|
if key == "" {
|
|
return fmt.Errorf("skill name %q: use letters, digits, '_', '-', '.', 1-64 chars, starting alphanumeric", name)
|
|
}
|
|
next := c.DisabledSkillNames()
|
|
idx := -1
|
|
for i, existing := range next {
|
|
if SkillNameKey(existing) == key {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if enabled {
|
|
if idx >= 0 {
|
|
next = append(next[:idx], next[idx+1:]...)
|
|
}
|
|
c.Skills.DisabledSkills = next
|
|
return nil
|
|
}
|
|
if idx < 0 {
|
|
next = append(next, name)
|
|
}
|
|
c.Skills.DisabledSkills = next
|
|
return nil
|
|
}
|
|
|
|
// CanonicalSkillPath expands env vars, ~ and relative segments to an absolute
|
|
// cleaned path for comparing skill roots. On Windows it folds case so paths that
|
|
// differ only in casing dedupe. Use only for comparison, never as stored config.
|
|
func CanonicalSkillPath(path string) string {
|
|
path = ExpandVars(strings.TrimSpace(path))
|
|
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
path = filepath.Join(home, path[2:])
|
|
}
|
|
} else if path == "~" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
path = home
|
|
}
|
|
}
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
path = abs
|
|
}
|
|
path = filepath.Clean(path)
|
|
if runtime.GOOS == "windows" {
|
|
return strings.ToLower(path)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// UpsertPlugin adds e, or replaces an MCP server with the same name (preserving
|
|
// position). The transport-specific required fields are validated: stdio needs
|
|
// a command, http/sse need a url.
|
|
func (c *Config) UpsertPlugin(e PluginEntry) error {
|
|
e, _ = NormalizePluginCommandLine(e)
|
|
if err := validatePlugin(e); err != nil {
|
|
return err
|
|
}
|
|
for i := range c.Plugins {
|
|
if c.Plugins[i].Name == e.Name {
|
|
c.Plugins[i] = e
|
|
return nil
|
|
}
|
|
}
|
|
c.Plugins = append(c.Plugins, e)
|
|
return nil
|
|
}
|
|
|
|
// RemovePlugin deletes the named MCP server, reporting whether it was present.
|
|
func (c *Config) RemovePlugin(name string) bool {
|
|
for i := range c.Plugins {
|
|
if c.Plugins[i].Name == name {
|
|
c.Plugins = append(c.Plugins[:i], c.Plugins[i+1:]...)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ClearPluginAuthentication removes locally stored auth-like material for one
|
|
// MCP server while keeping the server entry itself. It intentionally leaves
|
|
// non-auth config (command, URL host/path, ordinary env/header keys, tier) alone.
|
|
func (c *Config) ClearPluginAuthentication(name string) (PluginEntry, bool, error) {
|
|
for i := range c.Plugins {
|
|
if c.Plugins[i].Name != name {
|
|
continue
|
|
}
|
|
headers, env, url, changed := mcpdiag.ClearAuthConfig(c.Plugins[i].Headers, c.Plugins[i].Env, c.Plugins[i].URL)
|
|
c.Plugins[i].Headers = headers
|
|
c.Plugins[i].Env = env
|
|
c.Plugins[i].URL = url
|
|
return c.Plugins[i], changed, nil
|
|
}
|
|
return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name)
|
|
}
|
|
|
|
// TrustPluginReadOnlyTool adds one raw MCP tool name to a plugin's trusted
|
|
// read-only list. It reports changed=false when the entry already contains it.
|
|
func (c *Config) TrustPluginReadOnlyTool(name, toolName string) (PluginEntry, bool, error) {
|
|
name = strings.TrimSpace(name)
|
|
toolName = strings.TrimSpace(toolName)
|
|
if name == "" {
|
|
return PluginEntry{}, false, fmt.Errorf("trust plugin read-only tool: plugin name is required")
|
|
}
|
|
if toolName == "" {
|
|
return PluginEntry{}, false, fmt.Errorf("trust plugin read-only tool: tool name is required")
|
|
}
|
|
for i := range c.Plugins {
|
|
if c.Plugins[i].Name != name {
|
|
continue
|
|
}
|
|
trusted := uniqueStrings(c.Plugins[i].TrustedReadOnlyTools)
|
|
for _, existing := range trusted {
|
|
if existing == toolName {
|
|
c.Plugins[i].TrustedReadOnlyTools = trusted
|
|
return c.Plugins[i], false, nil
|
|
}
|
|
}
|
|
trusted = append(trusted, toolName)
|
|
c.Plugins[i].TrustedReadOnlyTools = trusted
|
|
return c.Plugins[i], true, nil
|
|
}
|
|
return PluginEntry{}, false, fmt.Errorf("trust plugin read-only tool: no plugin %q", name)
|
|
}
|
|
|
|
// ClearPluginAuthenticationInSource clears auth material in the file that actually
|
|
// owns the MCP server. Load() merges user/project TOML and project .mcp.json into
|
|
// one Config, so callers must not mutate that merged view and Save() it back: a
|
|
// .mcp.json-only server would otherwise be serialized into reasonix.toml or the
|
|
// user config. Source priority mirrors Load(): project TOML, user TOML, then the
|
|
// project .mcp.json entry if TOML did not define that server.
|
|
func ClearPluginAuthenticationInSource(name string) (PluginEntry, bool, string, error) {
|
|
if path := pluginTOMLSourcePath(name); path != "" {
|
|
cfg := LoadForEdit(path)
|
|
updated, changed, err := cfg.ClearPluginAuthentication(name)
|
|
if err != nil {
|
|
return PluginEntry{}, false, path, err
|
|
}
|
|
if changed {
|
|
if err := cfg.SaveTo(path); err != nil {
|
|
return PluginEntry{}, false, path, err
|
|
}
|
|
}
|
|
return updated, changed, path, nil
|
|
}
|
|
updated, changed, err := clearMCPJSONAuthentication(mcpJSONFile, name)
|
|
if err != nil {
|
|
return PluginEntry{}, false, "", err
|
|
}
|
|
return updated, changed, mcpJSONFile, nil
|
|
}
|
|
|
|
func pluginTOMLSourcePath(name string) string {
|
|
return pluginTOMLSourcePathForRoot(".", name)
|
|
}
|
|
|
|
type configSourceEdit struct {
|
|
path string
|
|
before []byte
|
|
perm os.FileMode
|
|
write func() error
|
|
}
|
|
|
|
func newConfigSourceEdit(path string, write func() error) (configSourceEdit, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return configSourceEdit{}, err
|
|
}
|
|
before, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return configSourceEdit{}, err
|
|
}
|
|
return configSourceEdit{path: path, before: before, perm: info.Mode().Perm(), write: write}, nil
|
|
}
|
|
|
|
func applyConfigSourceEdits(edits []configSourceEdit) error {
|
|
for i := range edits {
|
|
if err := edits[i].write(); err != nil {
|
|
var rollbackErrs []error
|
|
for j := i; j >= 0; j-- {
|
|
if rollbackErr := fileutil.AtomicWriteFile(edits[j].path, edits[j].before, edits[j].perm); rollbackErr != nil {
|
|
rollbackErrs = append(rollbackErrs, fmt.Errorf("restore %s: %w", edits[j].path, rollbackErr))
|
|
}
|
|
}
|
|
if rollbackErr := errors.Join(rollbackErrs...); rollbackErr != nil {
|
|
return errors.Join(err, fmt.Errorf("roll back MCP config removal: %w", rollbackErr))
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func planTOMLPluginRemoval(path, name string) (configSourceEdit, bool, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
cfg := Default()
|
|
if err := mergeFile(cfg, path); err != nil {
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
normalizeConfigForEdit(cfg)
|
|
if !cfg.RemovePlugin(name) {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
edit, err := newConfigSourceEdit(path, func() error { return cfg.SaveTo(path) })
|
|
return edit, err == nil, err
|
|
}
|
|
|
|
func planMCPJSONPluginRemoval(path, name string) (configSourceEdit, bool, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
root, servers, err := readMCPJSONRaw(path)
|
|
if err != nil {
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
if _, ok := servers[name]; !ok {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
delete(servers, name)
|
|
edit, err := newConfigSourceEdit(path, func() error { return writeMCPJSONServers(path, root, servers) })
|
|
return edit, err == nil, err
|
|
}
|
|
|
|
func planLegacyMCPDisable(path, name string) (configSourceEdit, bool, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
data, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
var root map[string]json.RawMessage
|
|
var view struct {
|
|
MCP []string `json:"mcp"`
|
|
MCPServers map[string]json.RawMessage `json:"mcpServers"`
|
|
MCPDisabled []string `json:"mcpDisabled"`
|
|
}
|
|
if err := json.Unmarshal(data, &root); err != nil {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
if err := json.Unmarshal(data, &view); err != nil {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
|
|
foundNamed := false
|
|
changed := false
|
|
filtered := make([]string, 0, len(view.MCP))
|
|
for i, raw := range view.MCP {
|
|
entry, ok := parseLegacyMCPSpec(raw)
|
|
if !ok {
|
|
filtered = append(filtered, raw)
|
|
continue
|
|
}
|
|
effectiveName := entry.Name
|
|
if effectiveName == "" {
|
|
effectiveName = anonymousMCPName(i)
|
|
}
|
|
if effectiveName != name {
|
|
filtered = append(filtered, raw)
|
|
continue
|
|
}
|
|
if entry.Name == "" {
|
|
changed = true
|
|
continue
|
|
}
|
|
foundNamed = true
|
|
filtered = append(filtered, raw)
|
|
}
|
|
if _, ok := view.MCPServers[name]; ok {
|
|
foundNamed = true
|
|
}
|
|
if foundNamed && !containsString(view.MCPDisabled, name) {
|
|
view.MCPDisabled = append(view.MCPDisabled, name)
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return configSourceEdit{}, false, nil
|
|
}
|
|
if len(filtered) != len(view.MCP) {
|
|
raw, marshalErr := json.Marshal(filtered)
|
|
if marshalErr != nil {
|
|
return configSourceEdit{}, false, marshalErr
|
|
}
|
|
root["mcp"] = raw
|
|
}
|
|
disabledRaw, err := json.Marshal(view.MCPDisabled)
|
|
if err != nil {
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
root["mcpDisabled"] = disabledRaw
|
|
out, err := json.MarshalIndent(root, "", " ")
|
|
if err != nil {
|
|
return configSourceEdit{}, false, err
|
|
}
|
|
out = append(out, '\n')
|
|
edit, err := newConfigSourceEdit(path, func() error {
|
|
return fileutil.AtomicWriteFile(path, out, info.Mode().Perm())
|
|
})
|
|
return edit, err == nil, err
|
|
}
|
|
|
|
// RemovePluginFromSourcesForRoot removes an MCP server from every writable
|
|
// config source that can contribute it for root. Removing all matching TOML
|
|
// declarations prevents a lower-priority duplicate from reappearing after the
|
|
// higher-priority entry is deleted. Every edit is planned before the first write,
|
|
// and legacy JSON receives a disable marker for older Reasonix versions.
|
|
func RemovePluginFromSourcesForRoot(root, name string) (bool, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return false, fmt.Errorf("remove MCP server: name is required")
|
|
}
|
|
|
|
unlock := LockUserConfigEdits()
|
|
defer unlock()
|
|
|
|
var edits []configSourceEdit
|
|
planTOML := func(path string) error {
|
|
edit, changed, err := planTOMLPluginRemoval(path, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if changed {
|
|
edits = append(edits, edit)
|
|
}
|
|
return nil
|
|
}
|
|
userPaths := userConfigCandidatePaths()
|
|
for _, path := range userPaths {
|
|
if err := planTOML(path); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
resolvedRoot := resolveRoot(root)
|
|
projectTOML := "reasonix.toml"
|
|
if resolvedRoot != "." {
|
|
projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
|
|
}
|
|
isUserPath := false
|
|
for _, path := range userPaths {
|
|
if samePath(path, projectTOML) {
|
|
isUserPath = true
|
|
break
|
|
}
|
|
}
|
|
if !isUserPath {
|
|
if err := planTOML(projectTOML); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
mcpPath := mcpJSONFile
|
|
if resolvedRoot != "." {
|
|
mcpPath = filepath.Join(resolvedRoot, mcpJSONFile)
|
|
}
|
|
mcpEdit, changed, err := planMCPJSONPluginRemoval(mcpPath, name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if changed {
|
|
edits = append(edits, mcpEdit)
|
|
}
|
|
legacyEdit, changed, err := planLegacyMCPDisable(legacyConfigPath(), name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if changed {
|
|
edits = append(edits, legacyEdit)
|
|
}
|
|
if len(edits) == 0 {
|
|
return false, nil
|
|
}
|
|
if err := applyConfigSourceEdits(edits); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// TrustPluginReadOnlyToolInSourceForRoot persists one trusted MCP read-only tool
|
|
// into the file that owns the server for root. TOML declarations win over
|
|
// .mcp.json, matching LoadForRoot merge precedence.
|
|
func TrustPluginReadOnlyToolInSourceForRoot(root, name, toolName string) (PluginEntry, bool, string, error) {
|
|
if path := pluginTOMLSourcePathForRoot(root, name); path != "" {
|
|
cfg := LoadForEdit(path)
|
|
updated, changed, err := cfg.TrustPluginReadOnlyTool(name, toolName)
|
|
if err != nil {
|
|
return PluginEntry{}, false, path, err
|
|
}
|
|
if changed {
|
|
if err := cfg.SaveTo(path); err != nil {
|
|
return PluginEntry{}, false, path, err
|
|
}
|
|
}
|
|
return updated, changed, path, nil
|
|
}
|
|
mcpPath := mcpJSONFile
|
|
if resolved := resolveRoot(root); resolved != "." {
|
|
mcpPath = filepath.Join(resolved, mcpJSONFile)
|
|
}
|
|
updated, changed, err := trustMCPJSONReadOnlyTool(mcpPath, name, toolName)
|
|
if err != nil {
|
|
return PluginEntry{}, false, mcpPath, err
|
|
}
|
|
return updated, changed, mcpPath, nil
|
|
}
|
|
|
|
func TrustPluginReadOnlyToolInSource(name, toolName string) (PluginEntry, bool, string, error) {
|
|
return TrustPluginReadOnlyToolInSourceForRoot(".", name, toolName)
|
|
}
|
|
|
|
func pluginTOMLSourcePathForRoot(root, name string) string {
|
|
projectTOML := "reasonix.toml"
|
|
if resolved := resolveRoot(root); resolved != "." {
|
|
projectTOML = filepath.Join(resolved, "reasonix.toml")
|
|
}
|
|
paths := append([]string{projectTOML}, userConfigCandidatePaths()...)
|
|
for _, path := range paths {
|
|
if strings.TrimSpace(path) == "" {
|
|
continue
|
|
}
|
|
cfg := LoadForEdit(path)
|
|
for _, p := range cfg.Plugins {
|
|
if p.Name == name {
|
|
return path
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// validatePlugin checks a plugin entry by transport. An empty Type means stdio.
|
|
func validatePlugin(e PluginEntry) error {
|
|
if strings.TrimSpace(e.Name) == "" {
|
|
return fmt.Errorf("plugin: name is required")
|
|
}
|
|
if e.CallTimeoutSeconds < 0 {
|
|
return fmt.Errorf("plugin %q: call_timeout_seconds must be >= 0", e.Name)
|
|
}
|
|
for name, sec := range e.ToolTimeoutSeconds {
|
|
if strings.TrimSpace(name) == "" {
|
|
return fmt.Errorf("plugin %q: tool_timeout_seconds contains an empty tool name", e.Name)
|
|
}
|
|
if sec < 0 {
|
|
return fmt.Errorf("plugin %q: tool_timeout_seconds[%q] must be >= 0", e.Name, name)
|
|
}
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(e.Type)) {
|
|
case "", "stdio":
|
|
if strings.TrimSpace(e.Command) == "" {
|
|
return fmt.Errorf("plugin %q: command is required for a stdio server", e.Name)
|
|
}
|
|
case "http", "sse", "streamable-http":
|
|
if strings.TrimSpace(e.URL) == "" {
|
|
return fmt.Errorf("plugin %q: url is required for a %s server", e.Name, e.Type)
|
|
}
|
|
default:
|
|
return fmt.Errorf("plugin %q: unknown type %q (want stdio|http|sse)", e.Name, e.Type)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SaveTo writes the configuration to path as annotated TOML, atomically: it
|
|
// writes a sibling temp file then renames, so a crash mid-write can't leave a
|
|
// half-written reasonix.toml that fails to parse on next load. Parent directories
|
|
// are created as needed.
|
|
//
|
|
// For project configs (./reasonix.toml) the write is incremental: only sections
|
|
// and fields that differ from built-in defaults are written, so the file never
|
|
// accumulates fields that override the user's global config. User configs still
|
|
// write the full annotated template since they are the user's own settings store.
|
|
func (c *Config) SaveTo(path string) error {
|
|
scope := renderScopeForPath(path)
|
|
if scope == RenderScopeProject {
|
|
return c.saveProjectIncremental(path)
|
|
}
|
|
return c.SaveToScope(path, scope)
|
|
}
|
|
|
|
func (c *Config) SaveToScope(path string, scope RenderScope) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("save: empty config path")
|
|
}
|
|
return writeConfigFile(path, RenderTOMLForScope(c, scope))
|
|
}
|
|
|
|
// saveProjectIncremental merges only the delta (non-default sections/fields)
|
|
// into the existing project config file, preserving all other content verbatim.
|
|
func (c *Config) saveProjectIncremental(path string) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("save: empty config path")
|
|
}
|
|
|
|
raw, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
raw = nil
|
|
}
|
|
|
|
body := string(raw)
|
|
isNew := body == ""
|
|
|
|
if isNew {
|
|
return writeConfigFile(path, RenderTOMLForScope(c, RenderScopeProject))
|
|
}
|
|
|
|
delta := RenderTOMLProjectDelta(c)
|
|
if tomlBodyHasTopLevelKey(body, "config_version") && !tomlBodyHasTopLevelKey(delta, "config_version") {
|
|
delta = fmt.Sprintf("config_version = %d\n", configVersion(c)) + delta
|
|
}
|
|
removePlugins := len(c.Plugins) == 0 && tomlBodyHasSection(body, "plugins")
|
|
removeSandboxBash := shouldRemoveIneffectiveProjectSandboxBash(body, c)
|
|
writeProviderAccess := c.Desktop.ProviderAccess != nil
|
|
if strings.TrimSpace(delta) == "" && !removePlugins && !removeSandboxBash && !writeProviderAccess {
|
|
return nil // no changes to write
|
|
}
|
|
|
|
// Parse delta into section blocks and merge each into body
|
|
if strings.TrimSpace(delta) != "" {
|
|
body = mergeTOMLDelta(body, delta)
|
|
}
|
|
if removePlugins {
|
|
body = removeTOMLSection(body, "plugins")
|
|
}
|
|
if removeSandboxBash {
|
|
body = removeTOMLSectionKey(body, "sandbox", "bash")
|
|
}
|
|
if writeProviderAccess {
|
|
body = upsertTOMLSectionKey(body, "desktop", "provider_access", "provider_access = "+renderStringArray(c.Desktop.ProviderAccess))
|
|
}
|
|
return writeConfigFile(path, body)
|
|
}
|
|
|
|
func shouldRemoveIneffectiveProjectSandboxBash(body string, c *Config) bool {
|
|
if c == nil || runtimeGOOS != "windows" {
|
|
return false
|
|
}
|
|
if c.BashMode() != "off" {
|
|
return false
|
|
}
|
|
value, ok := tomlSectionKeyValue(body, "sandbox", "bash")
|
|
return ok && tomlStringLiteralEquals(value, "enforce")
|
|
}
|
|
|
|
// mergeTOMLDelta parses delta into named TOML blocks and merges each into body
|
|
// via replaceTOMLSection. Consecutive array-of-tables entries ([[plugins]],
|
|
// [[providers]]) with the same name are merged into a single block so the
|
|
// replacement doesn't lose entries.
|
|
func mergeTOMLDelta(body, delta string) string {
|
|
lines := strings.Split(delta, "\n")
|
|
type section struct {
|
|
name string
|
|
content string
|
|
isArray bool
|
|
}
|
|
var topLevel strings.Builder
|
|
var sections []section
|
|
var curName string
|
|
var curBuf strings.Builder
|
|
curIsArray := false
|
|
|
|
flush := func() {
|
|
if curName == "" {
|
|
return
|
|
}
|
|
content := curBuf.String()
|
|
if curIsArray && len(sections) > 0 && sections[len(sections)-1].isArray && sections[len(sections)-1].name == curName {
|
|
sections[len(sections)-1].content += content
|
|
} else {
|
|
sections = append(sections, section{curName, content, curIsArray})
|
|
}
|
|
curBuf.Reset()
|
|
}
|
|
|
|
for _, line := range lines {
|
|
if name, isArray, ok := tomlEditSectionHeader(line); ok {
|
|
flush()
|
|
curName = name
|
|
curIsArray = isArray
|
|
curBuf.WriteString(line + "\n")
|
|
continue
|
|
}
|
|
if curName != "" {
|
|
curBuf.WriteString(line + "\n")
|
|
continue
|
|
}
|
|
if strings.TrimSpace(line) != "" {
|
|
topLevel.WriteString(line + "\n")
|
|
}
|
|
}
|
|
flush()
|
|
|
|
if top := strings.TrimSpace(topLevel.String()); top != "" {
|
|
body = mergeTOMLTopLevelFields(body, top+"\n")
|
|
}
|
|
for _, s := range sections {
|
|
body = replaceTOMLSection(body, s.name, s.content)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func mergeTOMLTopLevelFields(body, fields string) string {
|
|
for _, line := range strings.Split(fields, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
key, ok := tomlTopLevelKey(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
body = replaceTOMLTopLevelField(body, key, line+"\n")
|
|
}
|
|
return body
|
|
}
|
|
|
|
// SaveMinimalProjectReasoningLanguage writes a new project config that only
|
|
// overrides [agent].reasoning_language.
|
|
func SaveMinimalProjectReasoningLanguage(path, lang string) (string, error) {
|
|
cfg := Default()
|
|
if err := cfg.SetReasoningLanguage(lang); err != nil {
|
|
return "", err
|
|
}
|
|
body := fmt.Sprintf(`# Reasonix project configuration.
|
|
# Project-local overrides are merged over the user config.
|
|
|
|
[agent]
|
|
reasoning_language = %q
|
|
`, cfg.ReasoningLanguage())
|
|
return cfg.ReasoningLanguage(), writeConfigFile(path, body)
|
|
}
|
|
|
|
func writeConfigFile(path, body string) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("save: empty config path")
|
|
}
|
|
return fileutil.AtomicWriteFile(path, []byte(body), configFilePerm(path))
|
|
}
|
|
|
|
func configFilePerm(path string) os.FileMode {
|
|
if isUserConfigPath(path) {
|
|
return 0o600
|
|
}
|
|
return 0o644
|
|
}
|
|
|
|
// WritePermissionsSection replaces or creates the [permissions] section in a
|
|
// TOML file, preserving all other sections verbatim. When the file doesn't
|
|
// exist yet, it creates one containing only the permissions section.
|
|
func WritePermissionsSection(path string, allow []string) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("write permissions: empty config path")
|
|
}
|
|
|
|
raw, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
raw = nil
|
|
}
|
|
|
|
newBlock := fmt.Sprintf("[permissions]\nallow = %s\n", renderStringArray(allow))
|
|
|
|
body := string(raw)
|
|
if body == "" {
|
|
return writeConfigFile(path, newBlock)
|
|
}
|
|
|
|
body = replaceTOMLSection(body, "permissions", newBlock)
|
|
return writeConfigFile(path, body)
|
|
}
|
|
|
|
// replaceTOMLSection replaces the content of a named TOML section (including
|
|
// its header line) with newContent. It handles both [section] and [[section]]
|
|
// array-of-tables headers. If the section doesn't exist, newContent is appended
|
|
// at the end.
|
|
func replaceTOMLSection(body, sectionName, newContent string) string {
|
|
spans := tomlLineSpans(body)
|
|
arrayIdx := -1
|
|
for i, span := range spans {
|
|
name, isArray, ok := tomlEditSectionHeader(span.text)
|
|
if ok && isArray && name == sectionName {
|
|
arrayIdx = i
|
|
break
|
|
}
|
|
}
|
|
if arrayIdx >= 0 {
|
|
start := spans[arrayIdx].start
|
|
end := len(body)
|
|
for i := arrayIdx + 1; i < len(spans); i++ {
|
|
name, isArray, ok := tomlEditSectionHeader(spans[i].text)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if (isArray && name == sectionName) || strings.HasPrefix(name, sectionName+".") {
|
|
continue
|
|
}
|
|
end = spans[i].start
|
|
break
|
|
}
|
|
return body[:start] + strings.TrimRight(newContent, "\n") + "\n" + body[end:]
|
|
}
|
|
|
|
for _, span := range spans {
|
|
name, isArray, ok := tomlEditSectionHeader(span.text)
|
|
if !ok || isArray || name != sectionName {
|
|
continue
|
|
}
|
|
end := len(body)
|
|
for _, next := range spans {
|
|
if next.start <= span.start {
|
|
continue
|
|
}
|
|
if _, _, ok := tomlEditSectionHeader(next.text); ok {
|
|
end = next.start
|
|
break
|
|
}
|
|
}
|
|
return body[:span.start] + newContent + body[end:]
|
|
}
|
|
return strings.TrimRight(body, "\n") + "\n\n" + newContent
|
|
}
|
|
|
|
func upsertTOMLSectionKey(body, sectionName, key, line string) string {
|
|
line = strings.TrimRight(line, "\r\n") + "\n"
|
|
spans := tomlLineSpans(body)
|
|
sectionIdx := -1
|
|
sectionEnd := len(body)
|
|
for i, span := range spans {
|
|
name, isArray, ok := tomlEditSectionHeader(span.text)
|
|
if ok {
|
|
if sectionIdx >= 0 {
|
|
sectionEnd = span.start
|
|
break
|
|
}
|
|
if !isArray && name == sectionName {
|
|
sectionIdx = i
|
|
}
|
|
continue
|
|
}
|
|
if sectionIdx >= 0 {
|
|
if got, _, ok := tomlKeyValue(span.text); ok && got == key {
|
|
return body[:span.start] + line + body[span.end:]
|
|
}
|
|
}
|
|
}
|
|
if sectionIdx < 0 {
|
|
block := fmt.Sprintf("[%s]\n%s", sectionName, line)
|
|
return replaceTOMLSection(body, sectionName, block)
|
|
}
|
|
prefix := body[:sectionEnd]
|
|
if prefix != "" && !strings.HasSuffix(prefix, "\n") {
|
|
prefix += "\n"
|
|
}
|
|
return prefix + line + body[sectionEnd:]
|
|
}
|
|
|
|
func removeTOMLSection(body, sectionName string) string {
|
|
spans := tomlLineSpans(body)
|
|
for i, span := range spans {
|
|
name, isArray, ok := tomlEditSectionHeader(span.text)
|
|
if !ok || name != sectionName {
|
|
continue
|
|
}
|
|
end := len(body)
|
|
for j := i + 1; j < len(spans); j++ {
|
|
nextName, nextIsArray, ok := tomlEditSectionHeader(spans[j].text)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if (isArray && nextIsArray && nextName == sectionName) || strings.HasPrefix(nextName, sectionName+".") {
|
|
continue
|
|
}
|
|
end = spans[j].start
|
|
break
|
|
}
|
|
return strings.TrimRight(body[:span.start], "\n") + "\n" + body[end:]
|
|
}
|
|
return body
|
|
}
|
|
|
|
func removeTOMLSectionKey(body, sectionName, key string) string {
|
|
spans := tomlLineSpans(body)
|
|
sectionIdx := -1
|
|
keyIdx := -1
|
|
endIdx := len(spans)
|
|
for i, span := range spans {
|
|
name, isArray, ok := tomlEditSectionHeader(span.text)
|
|
if ok {
|
|
if sectionIdx >= 0 {
|
|
endIdx = i
|
|
break
|
|
}
|
|
if !isArray && name == sectionName {
|
|
sectionIdx = i
|
|
}
|
|
continue
|
|
}
|
|
if sectionIdx >= 0 && keyIdx < 0 {
|
|
if got, _, ok := tomlKeyValue(span.text); ok && got == key {
|
|
keyIdx = i
|
|
}
|
|
}
|
|
}
|
|
if sectionIdx < 0 || keyIdx < 0 {
|
|
return body
|
|
}
|
|
for i := sectionIdx + 1; i < endIdx; i++ {
|
|
if i == keyIdx {
|
|
continue
|
|
}
|
|
trimmed := strings.TrimSpace(spans[i].text)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
return body[:spans[keyIdx].start] + body[spans[keyIdx].end:]
|
|
}
|
|
sectionStart := spans[sectionIdx].start
|
|
sectionEnd := len(body)
|
|
if endIdx < len(spans) {
|
|
sectionEnd = spans[endIdx].start
|
|
}
|
|
return strings.TrimRight(body[:sectionStart], "\n") + "\n" + body[sectionEnd:]
|
|
}
|
|
|
|
type tomlLineSpan struct {
|
|
start int
|
|
end int
|
|
text string
|
|
}
|
|
|
|
func tomlLineSpans(body string) []tomlLineSpan {
|
|
if body == "" {
|
|
return nil
|
|
}
|
|
var spans []tomlLineSpan
|
|
for start := 0; start < len(body); {
|
|
end := len(body)
|
|
if idx := strings.IndexByte(body[start:], '\n'); idx >= 0 {
|
|
end = start + idx + 1
|
|
}
|
|
spans = append(spans, tomlLineSpan{start: start, end: end, text: body[start:end]})
|
|
start = end
|
|
}
|
|
return spans
|
|
}
|
|
|
|
func tomlEditSectionHeader(line string) (string, bool, bool) {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
return "", false, false
|
|
}
|
|
if before, _, ok := strings.Cut(trimmed, "#"); ok {
|
|
trimmed = strings.TrimSpace(before)
|
|
}
|
|
if strings.HasPrefix(trimmed, "[[") && strings.HasSuffix(trimmed, "]]") {
|
|
name := strings.TrimSpace(trimmed[2 : len(trimmed)-2])
|
|
return name, true, name != ""
|
|
}
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
name := strings.TrimSpace(trimmed[1 : len(trimmed)-1])
|
|
return name, false, name != ""
|
|
}
|
|
return "", false, false
|
|
}
|
|
|
|
func replaceTOMLTopLevelField(body, key, newLine string) string {
|
|
spans := tomlLineSpans(body)
|
|
insertAt := len(body)
|
|
for _, span := range spans {
|
|
if _, _, ok := tomlEditSectionHeader(span.text); ok {
|
|
insertAt = span.start
|
|
break
|
|
}
|
|
if got, ok := tomlTopLevelKey(span.text); ok && got == key {
|
|
return body[:span.start] + newLine + body[span.end:]
|
|
}
|
|
}
|
|
return body[:insertAt] + newLine + body[insertAt:]
|
|
}
|
|
|
|
func tomlTopLevelKey(line string) (string, bool) {
|
|
key, _, ok := tomlKeyValue(line)
|
|
return key, ok
|
|
}
|
|
|
|
func tomlKeyValue(line string) (string, string, bool) {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
return "", "", false
|
|
}
|
|
if before, _, ok := strings.Cut(trimmed, "#"); ok {
|
|
trimmed = strings.TrimSpace(before)
|
|
}
|
|
key, value, ok := strings.Cut(trimmed, "=")
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if key == "" || strings.Contains(key, ".") {
|
|
return "", "", false
|
|
}
|
|
return key, strings.TrimSpace(value), true
|
|
}
|
|
|
|
func tomlSectionKeyValue(body, sectionName, key string) (string, bool) {
|
|
inSection := false
|
|
for _, span := range tomlLineSpans(body) {
|
|
if name, isArray, ok := tomlEditSectionHeader(span.text); ok {
|
|
inSection = !isArray && name == sectionName
|
|
continue
|
|
}
|
|
if !inSection {
|
|
continue
|
|
}
|
|
got, value, ok := tomlKeyValue(span.text)
|
|
if ok && got == key {
|
|
return value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func tomlStringLiteralEquals(value, want string) bool {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) >= 2 {
|
|
quote := value[0]
|
|
if (quote == '"' || quote == '\'') && value[len(value)-1] == quote {
|
|
return value[1:len(value)-1] == want
|
|
}
|
|
}
|
|
return value == want
|
|
}
|
|
|
|
func tomlBodyHasTopLevelKey(body, key string) bool {
|
|
for _, span := range tomlLineSpans(body) {
|
|
if _, _, ok := tomlEditSectionHeader(span.text); ok {
|
|
return false
|
|
}
|
|
if got, ok := tomlTopLevelKey(span.text); ok && got == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tomlBodyHasSection(body, sectionName string) bool {
|
|
for _, span := range tomlLineSpans(body) {
|
|
name, _, ok := tomlEditSectionHeader(span.text)
|
|
if ok && name == sectionName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func renderScopeForPath(path string) RenderScope {
|
|
if isUserConfigPath(path) {
|
|
return RenderScopeUser
|
|
}
|
|
return RenderScopeProject
|
|
}
|
|
|
|
func isUserConfigPath(path string) bool {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return false
|
|
}
|
|
for _, uc := range userConfigCandidatePaths() {
|
|
uc = strings.TrimSpace(uc)
|
|
if uc == "" {
|
|
continue
|
|
}
|
|
pathAbs, pathErr := filepath.Abs(path)
|
|
ucAbs, ucErr := filepath.Abs(uc)
|
|
if pathErr == nil && ucErr == nil {
|
|
if filepath.Clean(pathAbs) == filepath.Clean(ucAbs) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if filepath.Clean(path) == filepath.Clean(uc) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsUserConfigPath reports whether path is one of Reasonix's current or legacy
|
|
// user-global config locations. Other paths use project-scoped rendering.
|
|
func IsUserConfigPath(path string) bool {
|
|
return isUserConfigPath(path)
|
|
}
|
|
|
|
// Save writes the configuration back to the file it was loaded from
|
|
// (SourcePath), or to ./reasonix.toml when none exists yet — the conventional
|
|
// project-local target a fresh GUI session would create.
|
|
func (c *Config) Save() error {
|
|
path := SourcePath()
|
|
if path == "" {
|
|
path = "reasonix.toml"
|
|
}
|
|
return c.SaveTo(path)
|
|
}
|
|
|
|
// SaveForRoot saves root's project config when it exists, falling back to the
|
|
// user's global config when root has no reasonix.toml. Existing project files
|
|
// are edited from their own TOML only, never from a runtime user+project merge.
|
|
func (c *Config) SaveForRoot(root string) error {
|
|
root = resolveRoot(root)
|
|
projectTOML := "reasonix.toml"
|
|
if root != "." {
|
|
projectTOML = filepath.Join(root, "reasonix.toml")
|
|
}
|
|
if _, err := os.Stat(projectTOML); err == nil {
|
|
projectCfg := LoadForEditWithoutCredentials(projectTOML)
|
|
return projectCfg.SaveTo(projectTOML)
|
|
}
|
|
if uc := userConfigPath(); uc != "" {
|
|
if err := os.MkdirAll(filepath.Dir(uc), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return c.SaveTo(uc)
|
|
}
|
|
return c.SaveTo(projectTOML)
|
|
}
|