Files
2026-07-13 13:00:08 +08:00

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)
}