返回 DeepSeek-Reasonix
edit.go
根目录 / internal / config / edit.go
1 package config
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "math"
8 "os"
9 "path/filepath"
10 "reflect"
11 "regexp"
12 "runtime"
13 "slices"
14 "strings"
15
16 "github.com/BurntSushi/toml"
17
18 "reasonix/internal/extension/protocol"
19 "reasonix/internal/fileutil"
20 fileencoding "reasonix/internal/fileutil/encoding"
21 "reasonix/internal/mcpdiag"
22 "reasonix/internal/netclient"
23 "reasonix/internal/permission"
24 )
25
26 var validDesktopExternalOpenerID = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
27
28 // edit.go is the programmatic mutation surface a settings UI drives: change the
29 // default model, add/remove a provider, set the planner, edit permission rules,
30 // add/remove an MCP server — each validated, then persisted with SaveTo. It is
31 // separate from the `reasonix setup` wizard (cli) so a GUI can apply one setting at a
32 // time without replaying the whole interactive flow. Every mutator works on the
33 // in-memory *Config; nothing writes to disk until SaveTo/Save is called, so a UI
34 // can stage several changes and commit once. Mutations round-trip through
35 // RenderTOML → Load (the wizard relies on the same guarantee).
36
37 // permission rule list names accepted by the rule mutators.
38 const (
39 listAllow = "allow"
40 listAsk = "ask"
41 listDeny = "deny"
42 )
43
44 // SetDefaultModel points default_model at an existing model. It accepts both
45 // forms used by the runtime resolver:
46 // - "provider" — the provider's own default model;
47 // - "provider/model" — that specific model under that provider.
48 //
49 // Either is rejected when the target does not exist, so a UI can't strand
50 // the config on a model that doesn't exist. Plugin-namespaced refs
51 // (plugin/<plugin>/<provider>/<model>) are the exception: they belong to
52 // extension sidecars, so the config catalog cannot vouch for them — boot's
53 // merged resolver gates them at the next launch instead.
54 func (c *Config) SetDefaultModel(name string) error {
55 name = strings.TrimSpace(name)
56 if name == "" {
57 return fmt.Errorf("set default: empty name")
58 }
59 if _, ok := c.ResolveModel(name); !ok && protocol.PluginRefOwner(name) == "" {
60 return fmt.Errorf("set default: no such model %q (configured: %s)", name, c.providerNames())
61 }
62 c.DefaultModel = name
63 return nil
64 }
65
66 // SetPlannerModel sets (or, with "", clears) agent.planner_model for two-model
67 // collaboration. A non-empty name must be a configured provider.
68 func (c *Config) SetPlannerModel(name string) error {
69 if name == "" {
70 c.Agent.PlannerModel = ""
71 return nil
72 }
73 if _, ok := c.Provider(name); !ok {
74 return fmt.Errorf("set planner: no provider %q (configured: %s)", name, c.providerNames())
75 }
76 c.Agent.PlannerModel = name
77 return nil
78 }
79
80 // SetAutoPlan is retained for source compatibility with older desktop clients.
81 // Automatic plan mode is retired: "off" is an idempotent compatibility write,
82 // while every attempt to enable it is rejected explicitly.
83 func (c *Config) SetAutoPlan(mode string) error {
84 if strings.EqualFold(strings.TrimSpace(mode), "off") {
85 c.Agent.AutoPlan = "off"
86 c.Agent.AutoPlanClassifier = ""
87 return nil
88 }
89 return fmt.Errorf("automatic plan mode has been retired; use Plan Mode explicitly")
90 }
91
92 // SetDesktopDefaultToolApprovalMode sets the Ask/Auto/YOLO posture used only
93 // for newly-created desktop sessions.
94 func (c *Config) SetDesktopDefaultToolApprovalMode(mode string) error {
95 switch strings.ToLower(strings.TrimSpace(mode)) {
96 case "ask":
97 c.Desktop.DefaultToolApprovalMode = "ask"
98 case "auto":
99 c.Desktop.DefaultToolApprovalMode = "auto"
100 case "yolo", "full", "full-access", "bypass":
101 c.Desktop.DefaultToolApprovalMode = "yolo"
102 default:
103 return fmt.Errorf("default_tool_approval_mode %q: must be ask|auto|yolo", mode)
104 }
105 return nil
106 }
107
108 // SetUIShortcutLayout selects the CLI keyboard shortcut layout. "classic" keeps
109 // historical behavior; "desktop" enables the two-axis desktop-style shortcuts.
110 func (c *Config) SetUIShortcutLayout(layout string) error {
111 switch strings.ToLower(strings.TrimSpace(layout)) {
112 case "", "classic", "default", "legacy", "off":
113 c.UI.ShortcutLayout = "classic"
114 case "desktop", "dual", "dual-axis", "dual_axis":
115 c.UI.ShortcutLayout = "desktop"
116 default:
117 return fmt.Errorf("shortcut_layout %q: must be classic|desktop", layout)
118 }
119 return nil
120 }
121
122 // UpsertProvider adds e, or replaces an existing provider with the same name
123 // (preserving its position). Required fields (name, kind, base_url, model/models)
124 // are validated; whether the kind is actually registered and the key resolves is
125 // checked later by provider.New / Validate, which give actionable errors.
126 func (c *Config) UpsertProvider(e ProviderEntry) error {
127 normalizeProviderEffortFields(&e)
128 if err := validateProvider(e); err != nil {
129 return err
130 }
131 for i := range c.Providers {
132 if c.Providers[i].Name == e.Name {
133 c.Providers[i] = e
134 return nil
135 }
136 }
137 c.Providers = append(c.Providers, e)
138 return nil
139 }
140
141 // UpsertProviderPreservingRuntime applies persisted provider fields while
142 // retaining process-only state derived by the latest config load. It is used
143 // when replaying an optimistic edit log onto fresh state.
144 func (c *Config) UpsertProviderPreservingRuntime(e ProviderEntry) error {
145 if current, ok := c.Provider(e.Name); ok {
146 e.persistedOfficialCurrency = current.persistedOfficialCurrency
147 if strings.TrimSpace(current.APIKeyEnv) == strings.TrimSpace(e.APIKeyEnv) {
148 e.resolvedAPIKey = current.resolvedAPIKey
149 e.resolvedSource = current.resolvedSource
150 e.visionOverride = current.visionOverride
151 }
152 }
153 return c.UpsertProvider(e)
154 }
155
156 // ProviderEntryConfigSnapshot strips process-only state from a provider copy so
157 // optimistic edit logs contain only persisted configuration.
158 func ProviderEntryConfigSnapshot(entry ProviderEntry) ProviderEntry {
159 entry.resolvedAPIKey = ""
160 entry.resolvedSource = CredentialSource{}
161 entry.visionOverride = nil
162 entry.persistedOfficialCurrency = ""
163 return entry
164 }
165
166 // ProviderEntriesConfigEqual compares persisted provider configuration while
167 // ignoring credentials and capability state resolved only for the current
168 // process. Setup uses it for optimistic conflict detection during replay.
169 func ProviderEntriesConfigEqual(a, b ProviderEntry) bool {
170 return reflect.DeepEqual(ProviderEntryConfigSnapshot(a), ProviderEntryConfigSnapshot(b))
171 }
172
173 // SetProviderEffort updates a provider's provider-specific thinking effort knob.
174 func (c *Config) SetProviderEffort(name, effort string) error {
175 for i := range c.Providers {
176 if c.Providers[i].Name == name {
177 c.Providers[i].Effort = normalizeStoredEffort(effort)
178 return nil
179 }
180 }
181 return fmt.Errorf("set provider effort: no provider %q", name)
182 }
183
184 // SetLanguage pins the CLI UI/model language; empty/auto clears the override so runtime detection falls back to REASONIX_LANG / locale.
185 func (c *Config) SetLanguage(lang string) error {
186 switch strings.ToLower(strings.TrimSpace(lang)) {
187 case "", "auto":
188 c.Language = ""
189 case "en":
190 c.Language = "en"
191 case "zh":
192 c.Language = "zh"
193 default:
194 return fmt.Errorf("language %q: must be auto|en|zh", lang)
195 }
196 c.ApplyDeepSeekOfficialDefaultPricing()
197 return nil
198 }
199
200 // SetReasoningLanguage pins the preferred language for visible reasoning text.
201 // Empty/auto follows the conversation language.
202 func (c *Config) SetReasoningLanguage(lang string) error {
203 switch strings.ToLower(strings.TrimSpace(lang)) {
204 case "", "auto", "follow", "conversation", "detect", "default", "model", "model-default", "model_default", "provider":
205 c.Agent.ReasoningLanguage = ""
206 case "zh", "cn", "chinese", "中文":
207 c.Agent.ReasoningLanguage = "zh"
208 case "en", "english":
209 c.Agent.ReasoningLanguage = "en"
210 default:
211 return fmt.Errorf("reasoning language %q: must be auto|zh|en", lang)
212 }
213 return nil
214 }
215
216 // SetDesktopLanguage pins the desktop UI language. It intentionally does not
217 // modify Config.Language, which is used by the CLI/model-facing runtime.
218 func (c *Config) SetDesktopLanguage(lang string) error {
219 switch strings.ToLower(strings.TrimSpace(lang)) {
220 case "", "auto":
221 c.Desktop.Language = ""
222 case "en":
223 c.Desktop.Language = "en"
224 case "zh":
225 c.Desktop.Language = "zh"
226 default:
227 return fmt.Errorf("desktop language %q: must be auto|en|zh", lang)
228 }
229 c.ApplyDeepSeekOfficialDefaultPricing()
230 return nil
231 }
232
233 // SetDesktopCurrency pins the user-global official pricing region independently
234 // from language. The name is retained for persisted-schema compatibility.
235 // Empty/auto follows the language preference.
236 func (c *Config) SetDesktopCurrency(currency string) error {
237 overridePersisted := false
238 switch strings.ToUpper(strings.TrimSpace(currency)) {
239 case "", "AUTO":
240 c.Desktop.Currency = ""
241 case "CNY", "RMB", "CNH":
242 c.Desktop.Currency = "CNY"
243 overridePersisted = true
244 case "USD":
245 c.Desktop.Currency = "USD"
246 overridePersisted = true
247 default:
248 return fmt.Errorf("desktop currency %q: must be auto|CNY|USD", currency)
249 }
250 applyDeepSeekOfficialDefaultPricingWithOverride(c, overridePersisted)
251 return nil
252 }
253
254 // SetDesktopAppearance sets desktop-only theme preferences. It must not affect
255 // CLI theme settings or provider-visible request data.
256 func (c *Config) SetDesktopAppearance(theme, style string) error {
257 switch strings.ToLower(strings.TrimSpace(theme)) {
258 case "auto":
259 c.Desktop.Theme = "auto"
260 case "light":
261 c.Desktop.Theme = "light"
262 case "", "dark":
263 c.Desktop.Theme = "dark"
264 default:
265 return fmt.Errorf("desktop theme %q: must be auto|dark|light", theme)
266 }
267 if strings.TrimSpace(style) == "" {
268 c.Desktop.ThemeStyle = ""
269 return nil
270 }
271 normalized := normalizeThemeStyle(style)
272 if normalized == "" {
273 return fmt.Errorf("desktop theme style %q: must be graphite|aurora|slate|carbon|nocturne|amber", style)
274 }
275 c.Desktop.ThemeStyle = normalized
276 return nil
277 }
278
279 // SetDesktopTerminalTheme sets the integrated terminal colour preference.
280 // This is desktop-only UI state and never rebuilds or changes model requests.
281 func (c *Config) SetDesktopTerminalTheme(theme string) error {
282 switch strings.ToLower(strings.TrimSpace(theme)) {
283 case "", "auto":
284 c.Desktop.TerminalTheme = "auto"
285 case "dark":
286 c.Desktop.TerminalTheme = "dark"
287 case "light":
288 c.Desktop.TerminalTheme = "light"
289 default:
290 return fmt.Errorf("desktop terminal theme %q: must be auto|dark|light", theme)
291 }
292 return nil
293 }
294
295 // SetDesktopLayoutStyle sets the desktop layout style. UI-only; it must not
296 // affect CLI output or provider-visible request data.
297 func (c *Config) SetDesktopLayoutStyle(style string) error {
298 switch strings.ToLower(strings.TrimSpace(style)) {
299 case "", "classic":
300 c.Desktop.LayoutStyle = "classic"
301 case "workbench", "workspace":
302 c.Desktop.LayoutStyle = "workbench"
303 case "creation":
304 c.Desktop.LayoutStyle = "creation"
305 default:
306 return fmt.Errorf("desktop layout style %q: must be classic|workbench|creation", style)
307 }
308 return nil
309 }
310
311 // SetDesktopExternalOpener stores the stable id selected by the desktop Open
312 // control. Availability is deliberately checked by the native desktop shell,
313 // because config is shared across operating systems and installations.
314 func (c *Config) SetDesktopExternalOpener(id string) error {
315 id = strings.ToLower(strings.TrimSpace(id))
316 if id == "" {
317 c.Desktop.ExternalOpener = ""
318 return nil
319 }
320 if !validDesktopExternalOpenerID.MatchString(id) {
321 return fmt.Errorf("external opener %q: invalid id", id)
322 }
323 c.Desktop.ExternalOpener = id
324 return nil
325 }
326
327 // SetDesktopCloseBehavior sets the desktop close-window preference. It is
328 // intentionally UI-only and must not affect model prompts or provider-visible
329 // request data.
330 func (c *Config) SetDesktopCloseBehavior(mode string) error {
331 switch strings.ToLower(strings.TrimSpace(mode)) {
332 case "quit", "exit":
333 c.Desktop.CloseBehavior = "quit"
334 case "", "background", "hide":
335 c.Desktop.CloseBehavior = "background"
336 default:
337 return fmt.Errorf("close behavior %q: must be quit|background", mode)
338 }
339 return nil
340 }
341
342 // SetDesktopDisplayMode sets the transcript display mode. UI-only.
343 func (c *Config) SetDesktopDisplayMode(mode string) error {
344 switch strings.ToLower(strings.TrimSpace(mode)) {
345 case "compact", "minimal":
346 c.Desktop.DisplayMode = "compact"
347 case "", "standard":
348 c.Desktop.DisplayMode = "standard"
349 default:
350 return fmt.Errorf("display mode %q: must be standard|compact", mode)
351 }
352 return nil
353 }
354
355 // SetDesktopStatusBarStyle sets the desktop status bar metric label style.
356 // UI-only; it must not affect CLI output or provider-visible request data.
357 func (c *Config) SetDesktopStatusBarStyle(style string) error {
358 switch strings.ToLower(strings.TrimSpace(style)) {
359 case "icon", "icons":
360 c.Desktop.StatusBarStyle = "icon"
361 case "", "text", "label", "labels":
362 c.Desktop.StatusBarStyle = "text"
363 default:
364 return fmt.Errorf("status bar style %q: must be icon|text", style)
365 }
366 return nil
367 }
368
369 // SetDesktopStatusBarItems sets the ordered visible desktop status bar items.
370 // UI-only; it must not affect CLI output or provider-visible request data.
371 func (c *Config) SetDesktopStatusBarItems(items []string) error {
372 out := make([]string, 0, len(items))
373 seen := map[string]bool{}
374 for _, raw := range items {
375 id := strings.TrimSpace(raw)
376 if id == "" || seen[id] {
377 continue
378 }
379 if !knownDesktopStatusBarItems[id] {
380 return fmt.Errorf("status bar item %q: unknown item", id)
381 }
382 out = append(out, id)
383 seen[id] = true
384 }
385 if len(out) == 0 {
386 out = DefaultDesktopStatusBarItems()
387 }
388 c.Desktop.StatusBarItems = out
389 return nil
390 }
391
392 // SetDesktopCheckUpdates sets whether the desktop app checks for updates on
393 // startup. Manual checks remain available in Settings regardless of this value.
394 func (c *Config) SetDesktopCheckUpdates(enabled bool) error {
395 c.Desktop.CheckUpdates = &enabled
396 return nil
397 }
398
399 // SetDesktopUpdateChannel is retained for pre-single-channel Wails clients.
400 // Clearing the legacy field keeps the next canonical write channel-free.
401 func (c *Config) SetDesktopUpdateChannel(_ string) error {
402 c.Desktop.UpdateChannel = ""
403 return nil
404 }
405
406 // SetCLIUpdateChannel is retained for older CLI scripts. Every recognized
407 // historical value migrates to the official channel and is omitted on save.
408 func (c *Config) SetCLIUpdateChannel(channel string) error {
409 switch strings.ToLower(strings.TrimSpace(channel)) {
410 case "", "stable", "preview", "canary", "beta", "next":
411 c.CLI.UpdateChannel = ""
412 default:
413 return fmt.Errorf("CLI update channel %q is unsupported; Reasonix now uses the official release channel", channel)
414 }
415 return nil
416 }
417
418 // SetColdResumePrune toggles auto-elision of stale tool results on cold resume.
419 func (c *Config) SetColdResumePrune(enabled bool) error {
420 c.Agent.ColdResumePrune = &enabled
421 return nil
422 }
423
424 // SetCompactRatio updates the user-controlled auto-compaction threshold.
425 // Keep the editable range inside the default snip/force guard rails so lowering
426 // the threshold cannot accidentally turn normal cache growth into constant
427 // compaction, while higher values still retain context-exhaustion headroom.
428 func (c *Config) SetCompactRatio(ratio float64) error {
429 if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio < 0.65 || ratio > 0.85 {
430 return fmt.Errorf("compact ratio %v: must be between 0.65 and 0.85", ratio)
431 }
432 snip := c.Agent.ToolResultSnipRatio
433 force := c.Agent.CompactForceRatio
434 if snip > 0 && ratio <= snip {
435 return fmt.Errorf("compact ratio %.2f: must be greater than tool result snip ratio %.2f", ratio, snip)
436 }
437 if force > 0 && ratio >= force {
438 return fmt.Errorf("compact ratio %.2f: must be less than force ratio %.2f", ratio, force)
439 }
440 c.Agent.CompactRatio = ratio
441 return nil
442 }
443
444 // SetDesktopTelemetry sets whether the desktop sends the anonymous launch ping.
445 func (c *Config) SetDesktopTelemetry(enabled bool) error {
446 c.Desktop.Telemetry = &enabled
447 return nil
448 }
449
450 // SetDesktopMetrics sets whether the desktop sends aggregate desktop metrics.
451 func (c *Config) SetDesktopMetrics(enabled bool) error {
452 c.Desktop.Metrics = &enabled
453 return nil
454 }
455
456 // SetCLITelemetryMode sets the user-global content-free CLI metrics policy.
457 func (c *Config) SetCLITelemetryMode(mode string) error {
458 switch strings.ToLower(strings.TrimSpace(mode)) {
459 case "", "auto":
460 c.Telemetry.CLIMetrics = "auto"
461 case "on":
462 c.Telemetry.CLIMetrics = "on"
463 case "off":
464 c.Telemetry.CLIMetrics = "off"
465 default:
466 return fmt.Errorf("cli_metrics %q: must be auto|on|off", mode)
467 }
468 return nil
469 }
470
471 // SetDesktopConversationWidth sets the max transcript width preference.
472 // standard = 960px fixed; full = 90% of the parent, with a 960px floor.
473 // An empty value resets to standard.
474 func (c *Config) SetDesktopConversationWidth(width string) error {
475 switch strings.ToLower(strings.TrimSpace(width)) {
476 case "", "standard":
477 c.Desktop.ConversationWidth = "standard"
478 case "full":
479 c.Desktop.ConversationWidth = "full"
480 default:
481 return fmt.Errorf("conversation width %q: must be standard|full", width)
482 }
483 return nil
484 }
485
486 // SetUICloseBehavior is kept for callers compiled against the old edit API.
487 func (c *Config) SetUICloseBehavior(mode string) error {
488 return c.SetDesktopCloseBehavior(mode)
489 }
490
491 // SetExpandThinking sets whether the desktop reasoning/thinking section is
492 // expanded by default. It is desktop-only and must not affect CLI output or
493 // provider-visible request data.
494 func (c *Config) SetExpandThinking(on bool) error {
495 c.Desktop.ExpandThinking = on
496 return nil
497 }
498
499 // SetShowReasoning sets the CLI's default verbose-reasoning preference. When
500 // true, thinking text is shown in the chat TUI on startup; when false (the
501 // default), it stays collapsed until the user toggles it with Ctrl+O or
502 // /verbose.
503 func (c *Config) SetShowReasoning(on bool) error {
504 c.UI.ShowReasoning = on
505 return nil
506 }
507
508 // SetProviderThinking updates a provider's provider-specific thinking mode knob.
509 func (c *Config) SetProviderThinking(name, thinking string) error {
510 for i := range c.Providers {
511 if c.Providers[i].Name == name {
512 c.Providers[i].Thinking = strings.ToLower(strings.TrimSpace(thinking))
513 return nil
514 }
515 }
516 return fmt.Errorf("set provider thinking: no provider %q", name)
517 }
518
519 // SetNetwork updates ordinary outbound network proxy settings. Invalid custom
520 // proxy settings are rejected here so the desktop panel cannot save a config that
521 // would break provider startup.
522 func (c *Config) SetNetwork(n NetworkConfig) error {
523 n.ProxyMode = netclient.NormalizeMode(n.ProxyMode)
524 n.ProxyURL = strings.TrimSpace(n.ProxyURL)
525 n.NoProxy = strings.TrimSpace(n.NoProxy)
526 n.Proxy.Type = strings.ToLower(strings.TrimSpace(n.Proxy.Type))
527 n.Proxy.Server = strings.TrimSpace(n.Proxy.Server)
528 n.Proxy.Username = strings.TrimSpace(n.Proxy.Username)
529 c.Network = n
530 return netclient.Validate(c.NetworkProxySpec())
531 }
532
533 // ModelRefsProvider reports whether ref targets the named provider. It matches
534 // both bare provider names ("deepseek") and "provider/model" refs.
535 func ModelRefsProvider(ref, name string) bool {
536 ref = strings.TrimSpace(ref)
537 name = strings.TrimSpace(name)
538 if ref == "" || name == "" {
539 return false
540 }
541 if ref == name {
542 return true
543 }
544 prov, _, ok := strings.Cut(ref, "/")
545 return ok && prov == name
546 }
547
548 func (c *Config) modelRefTargetsProvider(ref, name string) bool {
549 if ModelRefsProvider(ref, name) {
550 return true
551 }
552 if e, ok := c.ResolveModel(ref); ok {
553 return e.Name == name
554 }
555 return false
556 }
557
558 // RemoveProvider deletes the named provider. References to the removed provider
559 // are migrated to the first remaining configured provider when possible. The
560 // default model is required, so removal is refused when no fallback exists;
561 // optional planner/subagent refs are cleared instead of being left dangling.
562 func (c *Config) RemoveProvider(name string) error {
563 name = strings.TrimSpace(name)
564 idx := -1
565 for i := range c.Providers {
566 if c.Providers[i].Name == name {
567 idx = i
568 break
569 }
570 }
571 if idx < 0 {
572 return fmt.Errorf("remove provider: no provider %q", name)
573 }
574
575 defaultRefsProvider := c.modelRefTargetsProvider(c.DefaultModel, name)
576 plannerRefsProvider := c.modelRefTargetsProvider(c.Agent.PlannerModel, name)
577 subagentRefsProvider := c.modelRefTargetsProvider(c.Agent.SubagentModel, name)
578 subagentModelRefsProvider := map[string]bool{}
579 for skill, ref := range c.Agent.SubagentModels {
580 if c.modelRefTargetsProvider(ref, name) {
581 subagentModelRefsProvider[skill] = true
582 }
583 }
584
585 fallback := ""
586 if defaultRefsProvider || plannerRefsProvider || subagentRefsProvider || len(subagentModelRefsProvider) > 0 {
587 fallback = c.providerRemovalFallback(name)
588 }
589 if defaultRefsProvider && fallback == "" {
590 return fmt.Errorf("remove provider: %q is referenced by default_model and no other configured provider exists", name)
591 }
592
593 c.Providers = append(c.Providers[:idx], c.Providers[idx+1:]...)
594
595 if defaultRefsProvider {
596 c.DefaultModel = fallback
597 }
598 if plannerRefsProvider {
599 c.Agent.PlannerModel = fallback
600 }
601 if subagentRefsProvider {
602 c.Agent.SubagentModel = fallback
603 }
604 for skill := range subagentModelRefsProvider {
605 if fallback != "" {
606 c.Agent.SubagentModels[skill] = fallback
607 } else {
608 delete(c.Agent.SubagentModels, skill)
609 }
610 }
611 return nil
612 }
613
614 func (c *Config) providerRemovalFallback(name string) string {
615 for i := range c.Providers {
616 p := &c.Providers[i]
617 if p.Name == name || !p.Configured() || len(p.ModelList()) == 0 {
618 continue
619 }
620 return p.Name
621 }
622 return ""
623 }
624
625 // validateProvider checks the fields a provider can't function without.
626 func validateProvider(e ProviderEntry) error {
627 switch {
628 case strings.TrimSpace(e.Name) == "":
629 return fmt.Errorf("provider: name is required")
630 case strings.TrimSpace(e.Kind) == "":
631 return fmt.Errorf("provider %q: kind is required", e.Name)
632 case strings.TrimSpace(e.BaseURL) == "":
633 return fmt.Errorf("provider %q: base_url is required", e.Name)
634 case !providerHasAnyModel(e):
635 return fmt.Errorf("provider %q: model is required", e.Name)
636 case strings.TrimSpace(e.APIKeyEnv) != "" && !IsValidCredentialKey(e.APIKeyEnv):
637 return fmt.Errorf("provider %q: api_key_env %q is not a valid environment variable name", e.Name, e.APIKeyEnv)
638 }
639 return nil
640 }
641
642 func providerHasAnyModel(e ProviderEntry) bool {
643 if strings.TrimSpace(e.Model) != "" {
644 return true
645 }
646 for _, m := range e.Models {
647 if strings.TrimSpace(m) != "" {
648 return true
649 }
650 }
651 return false
652 }
653
654 // SetPermissionMode sets the writer-fallback mode. Accepts "ask", "allow", or
655 // "deny" (case-insensitive); anything else errors rather than silently
656 // defaulting, so a UI surfaces a typo instead of installing a surprising mode.
657 func (c *Config) SetPermissionMode(mode string) error {
658 switch strings.ToLower(strings.TrimSpace(mode)) {
659 case "ask", "allow", "deny":
660 c.Permissions.Mode = strings.ToLower(strings.TrimSpace(mode))
661 return nil
662 default:
663 return fmt.Errorf("permission mode %q: must be ask|allow|deny", mode)
664 }
665 }
666
667 // AddPermissionRule appends a rule ("ToolName" or "ToolName(glob)") to the
668 // allow / ask / deny list. The rule is validated with the same parser the gate
669 // uses, and a duplicate is a no-op so a UI can call it idempotently.
670 func (c *Config) AddPermissionRule(list, rule string) error {
671 target, err := c.ruleList(list)
672 if err != nil {
673 return err
674 }
675 rule = strings.TrimSpace(rule)
676 if _, ok := permission.ParseRule(rule); !ok {
677 return fmt.Errorf("invalid permission rule %q (want \"ToolName\" or \"ToolName(glob)\")", rule)
678 }
679 for _, existing := range *target {
680 if existing == rule {
681 return nil // already present
682 }
683 }
684 *target = append(*target, rule)
685 return nil
686 }
687
688 // RemovePermissionRule drops the first exact match of rule from the named list,
689 // reporting whether anything was removed.
690 func (c *Config) RemovePermissionRule(list, rule string) (bool, error) {
691 target, err := c.ruleList(list)
692 if err != nil {
693 return false, err
694 }
695 rule = strings.TrimSpace(rule)
696 for i, existing := range *target {
697 if existing == rule {
698 *target = append((*target)[:i], (*target)[i+1:]...)
699 return true, nil
700 }
701 }
702 return false, nil
703 }
704
705 // ruleList returns a pointer to the named rule slice so mutators can append to
706 // it in place. An unknown list name errors.
707 func (c *Config) ruleList(list string) (*[]string, error) {
708 switch strings.ToLower(strings.TrimSpace(list)) {
709 case listAllow:
710 return &c.Permissions.Allow, nil
711 case listAsk:
712 return &c.Permissions.Ask, nil
713 case listDeny:
714 return &c.Permissions.Deny, nil
715 default:
716 return nil, fmt.Errorf("unknown permission list %q (want allow|ask|deny)", list)
717 }
718 }
719
720 // AddSkillPath appends a custom skill root, deduping by its expanded absolute
721 // path while preserving the caller's original spelling in the config file.
722 func (c *Config) AddSkillPath(path string) error {
723 path = strings.TrimSpace(path)
724 if path == "" {
725 return fmt.Errorf("skill path: empty path")
726 }
727 want := CanonicalSkillPath(path)
728 c.removeExcludedSkillPath(want)
729 for _, existing := range c.Skills.Paths {
730 if CanonicalSkillPath(existing) == want {
731 return nil
732 }
733 }
734 c.Skills.Paths = append(c.Skills.Paths, path)
735 return nil
736 }
737
738 // RemoveSkillPath removes the first custom skill root matching path after
739 // expansion and path cleaning. It reports whether anything changed.
740 func (c *Config) RemoveSkillPath(path string) (bool, error) {
741 path = strings.TrimSpace(path)
742 if path == "" {
743 return false, fmt.Errorf("skill path: empty path")
744 }
745 want := CanonicalSkillPath(path)
746 for i, existing := range c.Skills.Paths {
747 if CanonicalSkillPath(existing) == want {
748 c.Skills.Paths = append(c.Skills.Paths[:i], c.Skills.Paths[i+1:]...)
749 return true, nil
750 }
751 }
752 return false, nil
753 }
754
755 // RestoreSkillPath removes a pseudo-deleted skill source from excluded_paths.
756 func (c *Config) RestoreSkillPath(path string) error {
757 path = strings.TrimSpace(path)
758 if path == "" {
759 return fmt.Errorf("skill path: empty path")
760 }
761 want := CanonicalSkillPath(path)
762 if want == "" {
763 return fmt.Errorf("skill path: empty path")
764 }
765 c.removeExcludedSkillPath(want)
766 return nil
767 }
768
769 // ExcludeSkillPath hides any skill discovery root matching path. This is used by
770 // UI "remove source" actions for convention roots that are not stored in paths.
771 func (c *Config) ExcludeSkillPath(path string) error {
772 path = strings.TrimSpace(path)
773 if path == "" {
774 return fmt.Errorf("skill path: empty path")
775 }
776 want := CanonicalSkillPath(path)
777 if want == "" {
778 return fmt.Errorf("skill path: empty path")
779 }
780 for _, existing := range c.Skills.ExcludedPaths {
781 if CanonicalSkillPath(existing) == want {
782 return nil
783 }
784 }
785 c.Skills.ExcludedPaths = append(c.Skills.ExcludedPaths, path)
786 return nil
787 }
788
789 func (c *Config) removeExcludedSkillPath(want string) {
790 next := c.Skills.ExcludedPaths[:0]
791 for _, existing := range c.Skills.ExcludedPaths {
792 if CanonicalSkillPath(existing) != want {
793 next = append(next, existing)
794 }
795 }
796 c.Skills.ExcludedPaths = next
797 }
798
799 // SetSkillEnabled persists a per-skill enable/disable preference. Skills are
800 // enabled by default; disabling records the name, enabling removes it.
801 func (c *Config) SetSkillEnabled(name string, enabled bool) error {
802 name = strings.TrimSpace(name)
803 key := SkillNameKey(name)
804 if key == "" {
805 return fmt.Errorf("skill name %q: use letters, digits, '_', '-', '.', 1-64 chars, starting alphanumeric", name)
806 }
807 next := c.DisabledSkillNames()
808 idx := -1
809 for i, existing := range next {
810 if SkillNameKey(existing) == key {
811 idx = i
812 break
813 }
814 }
815 if enabled {
816 if idx >= 0 {
817 next = append(next[:idx], next[idx+1:]...)
818 }
819 c.Skills.DisabledSkills = next
820 return nil
821 }
822 if idx < 0 {
823 next = append(next, name)
824 }
825 c.Skills.DisabledSkills = next
826 return nil
827 }
828
829 // CanonicalSkillPath expands env vars, ~ and relative segments to an absolute
830 // cleaned path for comparing skill roots. On Windows it folds case so paths that
831 // differ only in casing dedupe. Use only for comparison, never as stored config.
832 func CanonicalSkillPath(path string) string {
833 path = ExpandVars(strings.TrimSpace(path))
834 if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
835 if home, err := os.UserHomeDir(); err == nil {
836 path = filepath.Join(home, path[2:])
837 }
838 } else if path == "~" {
839 if home, err := os.UserHomeDir(); err == nil {
840 path = home
841 }
842 }
843 if abs, err := filepath.Abs(path); err == nil {
844 path = abs
845 }
846 path = filepath.Clean(path)
847 if runtime.GOOS == "windows" {
848 return strings.ToLower(path)
849 }
850 return path
851 }
852
853 // UpsertPlugin adds e, or replaces an MCP server with the same name (preserving
854 // position). The transport-specific required fields are validated: stdio needs
855 // a command, http/sse need a url.
856 func (c *Config) UpsertPlugin(e PluginEntry) error {
857 e, _ = NormalizePluginCommandLine(e)
858 if err := validatePlugin(e); err != nil {
859 return err
860 }
861 for i := range c.Plugins {
862 if c.Plugins[i].Name == e.Name {
863 c.Plugins[i] = e
864 return nil
865 }
866 }
867 c.Plugins = append(c.Plugins, e)
868 return nil
869 }
870
871 // RemovePlugin deletes the named MCP server, reporting whether it was present.
872 func (c *Config) RemovePlugin(name string) bool {
873 for i := range c.Plugins {
874 if c.Plugins[i].Name == name {
875 c.Plugins = append(c.Plugins[:i], c.Plugins[i+1:]...)
876 return true
877 }
878 }
879 return false
880 }
881
882 // ClearPluginAuthentication removes locally stored auth-like material for one
883 // MCP server while keeping the server entry itself. It intentionally leaves
884 // non-auth config (command, URL host/path, ordinary env/header keys, tier) alone.
885 func (c *Config) ClearPluginAuthentication(name string) (PluginEntry, bool, error) {
886 for i := range c.Plugins {
887 if c.Plugins[i].Name != name {
888 continue
889 }
890 headers, env, url, changed := mcpdiag.ClearAuthConfig(c.Plugins[i].Headers, c.Plugins[i].Env, c.Plugins[i].URL)
891 c.Plugins[i].Headers = headers
892 c.Plugins[i].Env = env
893 c.Plugins[i].URL = url
894 return c.Plugins[i], changed, nil
895 }
896 return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name)
897 }
898
899 // ClearPluginAuthenticationInSource clears auth material in the file that actually
900 // owns the MCP server. Load() merges user/project TOML and project .mcp.json into
901 // one Config, so callers must not mutate that merged view and Save() it back: a
902 // .mcp.json-only server would otherwise be serialized into reasonix.toml or the
903 // user config. Source priority mirrors Load(): project TOML, user TOML, then the
904 // project .mcp.json entry if TOML did not define that server.
905 func ClearPluginAuthenticationInSource(name string) (PluginEntry, bool, string, error) {
906 return ClearPluginAuthenticationInSourceForRoot(".", name)
907 }
908
909 // ClearPluginAuthenticationInSourceForRoot clears auth material in the source
910 // that owns name for the supplied workspace. The root is explicit so a desktop
911 // action cannot drift to another project's reasonix.toml or .mcp.json after the
912 // user switches tabs while the action is waiting on a lifecycle lock.
913 func ClearPluginAuthenticationInSourceForRoot(root, name string) (PluginEntry, bool, string, error) {
914 resolvedRoot := resolveRoot(root)
915 projectTOML := "reasonix.toml"
916 projectMCPJSON := mcpJSONFile
917 if resolvedRoot != "." {
918 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
919 projectMCPJSON = filepath.Join(resolvedRoot, mcpJSONFile)
920 }
921 lockPaths := append([]string{}, userConfigCandidatePaths()...)
922 lockPaths = append(lockPaths, projectTOML, projectMCPJSON)
923 if legacy := legacyConfigPath(); strings.TrimSpace(legacy) != "" {
924 lockPaths = append(lockPaths, legacy)
925 }
926 unlock, err := lockConfigFilesEdits(lockPaths...)
927 if err != nil {
928 return PluginEntry{}, false, "", fmt.Errorf("clear plugin authentication: %w", err)
929 }
930 defer unlock()
931
932 cfg, err := LoadForRootReadOnly(root)
933 if err != nil {
934 return PluginEntry{}, false, "", err
935 }
936 entry, found := pluginEntryByName(cfg.Plugins, strings.TrimSpace(name))
937 if !found {
938 return PluginEntry{}, false, "", fmt.Errorf("clear plugin authentication: no plugin %q", name)
939 }
940 path := MCPConfigPathForEntry(root, entry)
941 if entry.Source != MCPSourceProjectMCPJSON {
942 cfg, err := LoadForEditReadOnlyStrict(path)
943 if err != nil {
944 return PluginEntry{}, false, path, err
945 }
946 updated, changed, err := cfg.ClearPluginAuthentication(name)
947 if err != nil {
948 return PluginEntry{}, false, path, err
949 }
950 if changed {
951 if err := cfg.SaveTo(path); err != nil {
952 return PluginEntry{}, false, path, err
953 }
954 }
955 return updated, changed, path, nil
956 }
957 updated, changed, err := clearMCPJSONAuthentication(path, name)
958 if err != nil {
959 return PluginEntry{}, false, "", err
960 }
961 return updated, changed, path, nil
962 }
963
964 func pluginTOMLSourcePathForRoot(root, name string) string {
965 projectTOML := "reasonix.toml"
966 if resolved := resolveRoot(root); resolved != "." {
967 projectTOML = filepath.Join(resolved, "reasonix.toml")
968 }
969 paths := append([]string{projectTOML}, userConfigCandidatePaths()...)
970 for _, path := range paths {
971 if strings.TrimSpace(path) == "" {
972 continue
973 }
974 cfg := LoadForEdit(path)
975 for _, p := range cfg.Plugins {
976 if p.Name == name {
977 return path
978 }
979 }
980 }
981 return ""
982 }
983
984 // MCPConfigPathForEntry returns the writable config file that owns entry.
985 // Runtime configuration is merged by name, so callers must use provenance
986 // instead of saving the merged Config back to whichever file happens to have
987 // the highest priority.
988 func MCPConfigPathForEntry(root string, entry PluginEntry) string {
989 resolvedRoot := resolveRoot(root)
990 projectTOML := "reasonix.toml"
991 projectMCPJSON := mcpJSONFile
992 if resolvedRoot != "." {
993 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
994 projectMCPJSON = filepath.Join(resolvedRoot, mcpJSONFile)
995 }
996 switch entry.Source {
997 case MCPSourceProjectConfig:
998 return projectTOML
999 case MCPSourceProjectMCPJSON:
1000 return projectMCPJSON
1001 case MCPSourceUserConfig:
1002 for _, path := range userConfigCandidatePaths() {
1003 cfg := LoadForEditWithoutCredentials(path)
1004 if _, ok := pluginEntryByName(cfg.Plugins, entry.Name); ok {
1005 return path
1006 }
1007 }
1008 return UserConfigPath()
1009 case MCPSourceLegacyUser:
1010 return legacyConfigPath()
1011 case MCPSourcePluginPackage:
1012 return ""
1013 }
1014 if path := pluginTOMLSourcePathForRoot(root, entry.Name); path != "" {
1015 return path
1016 }
1017 if _, found, err := LoadMCPJSONPlugin(projectMCPJSON, entry.Name); err == nil && found {
1018 return projectMCPJSON
1019 }
1020 return UserConfigPath()
1021 }
1022
1023 // UpsertPluginInSourceForRoot writes entry back to its owning scope. New and
1024 // legacy user entries are normalized into the current user-global config;
1025 // project entries remain in their original project file.
1026 func UpsertPluginInSourceForRoot(root string, entry PluginEntry) (string, error) {
1027 path := MCPConfigPathForEntry(root, entry)
1028 switch entry.Source {
1029 case MCPSourceProjectMCPJSON:
1030 unlock, err := LockConfigFileEdits(path)
1031 if err != nil {
1032 return path, err
1033 }
1034 defer unlock()
1035 if _, err := UpsertMCPJSONPlugin(path, entry); err != nil {
1036 return path, err
1037 }
1038 return path, nil
1039 case MCPSourcePluginPackage:
1040 return "", fmt.Errorf("MCP server %q is managed by an installed plugin package", entry.Name)
1041 case MCPSourceProjectConfig:
1042 // Keep the project path selected above.
1043 default:
1044 path = UserConfigPath()
1045 if strings.TrimSpace(path) == "" {
1046 return "", fmt.Errorf("cannot resolve user config path")
1047 }
1048 entry.Source = MCPSourceUserConfig
1049 }
1050
1051 unlock, err := LockConfigFileEdits(path)
1052 if err != nil {
1053 return path, err
1054 }
1055 defer unlock()
1056 cfg, err := LoadForEditReadOnlyStrict(path)
1057 if err != nil {
1058 return path, err
1059 }
1060 if err := cfg.UpsertPlugin(entry); err != nil {
1061 return path, err
1062 }
1063 return path, cfg.SaveTo(path)
1064 }
1065
1066 // InstallUserPluginForRoot persists an explicit global MCP install together
1067 // with its durable activation state. All config sources that can shadow the
1068 // global declaration stay locked through conflict detection, save, activation,
1069 // and any rollback, so a failed activation write cannot remove or overwrite a
1070 // concurrent config update.
1071 func InstallUserPluginForRoot(root string, entry PluginEntry, forceEnable bool) (string, error) {
1072 entry.Source = MCPSourceUserConfig
1073 unlock, err := LockConfigFilesEdits(mcpConfigSourcePathsForRoot(root)...)
1074 if err != nil {
1075 return "", fmt.Errorf("install MCP server: %w", err)
1076 }
1077 defer unlock()
1078
1079 effective, err := LoadForRootReadOnly(root)
1080 if err != nil {
1081 return "", err
1082 }
1083 for _, configured := range effective.Plugins {
1084 if configured.Name != entry.Name {
1085 continue
1086 }
1087 if configured.Source != MCPSourceUserConfig && configured.Source != MCPSourceLegacyUser {
1088 return "", fmt.Errorf(
1089 "MCP server %q is already configured by %s; edit or remove that declaration before installing a global server with the same name",
1090 entry.Name,
1091 configured.Source,
1092 )
1093 }
1094 break
1095 }
1096
1097 path := UserConfigPath()
1098 if strings.TrimSpace(path) == "" {
1099 return "", fmt.Errorf("cannot resolve user config path")
1100 }
1101 cfg, err := LoadForEditReadOnlyStrict(path)
1102 if err != nil {
1103 return path, err
1104 }
1105 previous, hadPrevious := pluginEntryByName(cfg.Plugins, entry.Name)
1106 if err := cfg.UpsertPlugin(entry); err != nil {
1107 return path, err
1108 }
1109 if err := cfg.SaveTo(path); err != nil {
1110 return path, err
1111 }
1112
1113 store := DefaultMCPActivationStore()
1114 var activationErr error
1115 if forceEnable {
1116 activationErr = store.SetServerEnabled(entry, root, true)
1117 } else {
1118 activationErr = store.ClearServer(entry, root)
1119 }
1120 if activationErr == nil {
1121 return path, nil
1122 }
1123
1124 var restoreErr error
1125 if hadPrevious {
1126 restoreErr = cfg.UpsertPlugin(previous)
1127 } else {
1128 cfg.RemovePlugin(entry.Name)
1129 }
1130 if restoreErr == nil {
1131 restoreErr = cfg.SaveTo(path)
1132 }
1133 if restoreErr != nil {
1134 restoreErr = fmt.Errorf("restore MCP server config: %w", restoreErr)
1135 }
1136 return path, errors.Join(activationErr, restoreErr)
1137 }
1138
1139 // RemovePluginFromSourceForRoot removes exactly the declaration represented by
1140 // entry. Lower-priority same-name declarations are intentionally preserved so
1141 // they can become effective after a project override is removed.
1142 func RemovePluginFromSourceForRoot(root string, entry PluginEntry) (bool, string, error) {
1143 path := MCPConfigPathForEntry(root, entry)
1144 if entry.Source == MCPSourcePluginPackage {
1145 return false, "", fmt.Errorf("MCP server %q is managed by an installed plugin package", entry.Name)
1146 }
1147 if strings.TrimSpace(path) == "" {
1148 return false, "", nil
1149 }
1150 unlock, err := LockConfigFileEdits(path)
1151 if err != nil {
1152 return false, path, err
1153 }
1154 defer unlock()
1155 return removePluginFromSourceForRootLocked(entry, path)
1156 }
1157
1158 // removePluginFromSourceForRootLocked removes exactly one source declaration
1159 // while the caller holds that source's config edit lock.
1160 func removePluginFromSourceForRootLocked(entry PluginEntry, path string) (bool, string, error) {
1161 switch entry.Source {
1162 case MCPSourceProjectMCPJSON:
1163 removed, err := RemoveMCPJSONPlugin(path, entry.Name)
1164 return removed, path, err
1165 case MCPSourcePluginPackage:
1166 return false, "", fmt.Errorf("MCP server %q is managed by an installed plugin package", entry.Name)
1167 case MCPSourceLegacyUser:
1168 edit, changed, err := planLegacyMCPDisable(path, entry.Name)
1169 if err != nil || !changed {
1170 return false, path, err
1171 }
1172 if err := applyConfigSourceEdits([]configSourceEdit{edit}); err != nil {
1173 return false, path, err
1174 }
1175 return true, path, nil
1176 }
1177 cfg, err := LoadForEditReadOnlyStrict(path)
1178 if err != nil {
1179 return false, path, err
1180 }
1181 if !cfg.RemovePlugin(entry.Name) {
1182 return false, path, nil
1183 }
1184 if err := cfg.SaveTo(path); err != nil {
1185 return false, path, err
1186 }
1187 return true, path, nil
1188 }
1189
1190 // RemovePluginFromEffectiveSourceForRoot removes only the declaration currently
1191 // selected by the project-over-global precedence rules.
1192 func RemovePluginFromEffectiveSourceForRoot(root, name string) (PluginEntry, bool, string, error) {
1193 unlock, err := LockConfigFilesEdits(mcpConfigSourcePathsForRoot(root)...)
1194 if err != nil {
1195 return PluginEntry{}, false, "", fmt.Errorf("remove effective MCP server: %w", err)
1196 }
1197 defer unlock()
1198
1199 cfg, err := LoadForRootReadOnly(root)
1200 if err != nil {
1201 return PluginEntry{}, false, "", err
1202 }
1203 entry, found := pluginEntryByName(cfg.Plugins, strings.TrimSpace(name))
1204 if !found {
1205 return PluginEntry{}, false, "", nil
1206 }
1207 path := MCPConfigPathForEntry(root, entry)
1208 removed, path, err := removePluginFromSourceForRootLocked(entry, path)
1209 return entry, removed, path, err
1210 }
1211
1212 // mcpConfigSourcePathsForRoot returns every writable source whose precedence can
1213 // decide which declaration is effective. A source-selection operation must lock
1214 // all of them before loading, otherwise another process can add a higher-priority
1215 // declaration after the load and turn a "remove effective" action into a removal
1216 // of a now-shadowed source.
1217 func mcpConfigSourcePathsForRoot(root string) []string {
1218 resolvedRoot := resolveRoot(root)
1219 projectTOML := "reasonix.toml"
1220 projectMCPJSON := mcpJSONFile
1221 if resolvedRoot != "." {
1222 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
1223 projectMCPJSON = filepath.Join(resolvedRoot, mcpJSONFile)
1224 }
1225 paths := append([]string{}, userConfigCandidatePaths()...)
1226 paths = append(paths, projectTOML, projectMCPJSON)
1227 if legacy := legacyConfigPath(); strings.TrimSpace(legacy) != "" {
1228 paths = append(paths, legacy)
1229 }
1230 return paths
1231 }
1232
1233 type configSourceEdit struct {
1234 path string
1235 resolvedPath string
1236 before []byte
1237 perm os.FileMode
1238 write func() error
1239 }
1240
1241 func newConfigSourceEdit(path string, write func() error) (configSourceEdit, error) {
1242 userOwned := isUserConfigPath(path) || samePath(path, legacyConfigPath())
1243 resolved, err := resolveConfigAccessPath(path, userOwned)
1244 if err != nil {
1245 return configSourceEdit{}, err
1246 }
1247 info, err := os.Stat(resolved)
1248 if err != nil {
1249 return configSourceEdit{}, err
1250 }
1251 before, err := os.ReadFile(resolved)
1252 if err != nil {
1253 return configSourceEdit{}, err
1254 }
1255 return configSourceEdit{
1256 path: path,
1257 resolvedPath: resolved,
1258 before: before,
1259 perm: info.Mode().Perm(),
1260 write: write,
1261 }, nil
1262 }
1263
1264 func applyConfigSourceEdits(edits []configSourceEdit) error {
1265 for i := range edits {
1266 if err := edits[i].write(); err != nil {
1267 var rollbackErrs []error
1268 for j := i; j >= 0; j-- {
1269 if rollbackErr := fileutil.AtomicWriteFile(edits[j].resolvedPath, edits[j].before, edits[j].perm); rollbackErr != nil {
1270 rollbackErrs = append(rollbackErrs, fmt.Errorf("restore %s: %w", edits[j].path, rollbackErr))
1271 }
1272 }
1273 if rollbackErr := errors.Join(rollbackErrs...); rollbackErr != nil {
1274 return errors.Join(err, fmt.Errorf("roll back MCP config removal: %w", rollbackErr))
1275 }
1276 return err
1277 }
1278 }
1279 return nil
1280 }
1281
1282 func planTOMLPluginRemoval(path, name string) (configSourceEdit, bool, error) {
1283 _, exists, err := statConfigPath(path)
1284 if err != nil {
1285 return configSourceEdit{}, false, err
1286 }
1287 if !exists {
1288 return configSourceEdit{}, false, nil
1289 }
1290 cfg := Default()
1291 if err := mergeFile(cfg, path); err != nil {
1292 return configSourceEdit{}, false, err
1293 }
1294 normalizeConfigForEdit(cfg)
1295 if !cfg.RemovePlugin(name) {
1296 return configSourceEdit{}, false, nil
1297 }
1298 edit, err := newConfigSourceEdit(path, func() error { return cfg.SaveTo(path) })
1299 return edit, err == nil, err
1300 }
1301
1302 func planMCPJSONPluginRemoval(path, name string) (configSourceEdit, bool, error) {
1303 resolved, exists, err := statConfigPath(path)
1304 if err != nil {
1305 return configSourceEdit{}, false, err
1306 }
1307 if !exists {
1308 return configSourceEdit{}, false, nil
1309 }
1310 root, servers, err := readMCPJSONRaw(resolved)
1311 if err != nil {
1312 return configSourceEdit{}, false, err
1313 }
1314 if _, ok := servers[name]; !ok {
1315 return configSourceEdit{}, false, nil
1316 }
1317 delete(servers, name)
1318 edit, err := newConfigSourceEdit(path, func() error { return writeMCPJSONServers(resolved, root, servers) })
1319 return edit, err == nil, err
1320 }
1321
1322 func planLegacyMCPDisable(path, name string) (configSourceEdit, bool, error) {
1323 if strings.TrimSpace(path) == "" {
1324 return configSourceEdit{}, false, nil
1325 }
1326 resolved, err := resolveConfigAccessPath(path, true)
1327 if err != nil {
1328 return configSourceEdit{}, false, err
1329 }
1330 info, err := os.Stat(resolved)
1331 if err != nil {
1332 if os.IsNotExist(err) {
1333 return configSourceEdit{}, false, nil
1334 }
1335 return configSourceEdit{}, false, err
1336 }
1337 data, err := fileencoding.ReadFileUTF8(resolved)
1338 if err != nil {
1339 return configSourceEdit{}, false, err
1340 }
1341 var root map[string]json.RawMessage
1342 var view struct {
1343 MCP []string `json:"mcp"`
1344 MCPServers map[string]json.RawMessage `json:"mcpServers"`
1345 MCPDisabled []string `json:"mcpDisabled"`
1346 }
1347 if err := json.Unmarshal(data, &root); err != nil {
1348 return configSourceEdit{}, false, nil
1349 }
1350 if err := json.Unmarshal(data, &view); err != nil {
1351 return configSourceEdit{}, false, nil
1352 }
1353
1354 foundNamed := false
1355 changed := false
1356 filtered := make([]string, 0, len(view.MCP))
1357 for i, raw := range view.MCP {
1358 entry, ok := parseLegacyMCPSpec(raw)
1359 if !ok {
1360 filtered = append(filtered, raw)
1361 continue
1362 }
1363 effectiveName := entry.Name
1364 if effectiveName == "" {
1365 effectiveName = anonymousMCPName(i)
1366 }
1367 if effectiveName != name {
1368 filtered = append(filtered, raw)
1369 continue
1370 }
1371 if entry.Name == "" {
1372 changed = true
1373 continue
1374 }
1375 foundNamed = true
1376 filtered = append(filtered, raw)
1377 }
1378 if _, ok := view.MCPServers[name]; ok {
1379 foundNamed = true
1380 }
1381 if foundNamed && !containsString(view.MCPDisabled, name) {
1382 view.MCPDisabled = append(view.MCPDisabled, name)
1383 changed = true
1384 }
1385 if !changed {
1386 return configSourceEdit{}, false, nil
1387 }
1388 if len(filtered) != len(view.MCP) {
1389 raw, marshalErr := json.Marshal(filtered)
1390 if marshalErr != nil {
1391 return configSourceEdit{}, false, marshalErr
1392 }
1393 root["mcp"] = raw
1394 }
1395 disabledRaw, err := json.Marshal(view.MCPDisabled)
1396 if err != nil {
1397 return configSourceEdit{}, false, err
1398 }
1399 root["mcpDisabled"] = disabledRaw
1400 out, err := json.MarshalIndent(root, "", " ")
1401 if err != nil {
1402 return configSourceEdit{}, false, err
1403 }
1404 out = append(out, '\n')
1405 edit, err := newConfigSourceEdit(path, func() error {
1406 return fileutil.AtomicWriteFile(resolved, out, info.Mode().Perm())
1407 })
1408 return edit, err == nil, err
1409 }
1410
1411 // RemovePluginFromSourcesForRoot removes an MCP server from every writable
1412 // config source that can contribute it for root. Removing all matching TOML
1413 // declarations prevents a lower-priority duplicate from reappearing after the
1414 // higher-priority entry is deleted. Every edit is planned before the first write,
1415 // and legacy JSON receives a disable marker for older Reasonix versions.
1416 func RemovePluginFromSourcesForRoot(root, name string) (bool, error) {
1417 name = strings.TrimSpace(name)
1418 if name == "" {
1419 return false, fmt.Errorf("remove MCP server: name is required")
1420 }
1421
1422 userPaths := userConfigCandidatePaths()
1423 resolvedRoot := resolveRoot(root)
1424 projectTOML := "reasonix.toml"
1425 if resolvedRoot != "." {
1426 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
1427 }
1428 isUserPath := false
1429 for _, path := range userPaths {
1430 if samePath(path, projectTOML) {
1431 isUserPath = true
1432 break
1433 }
1434 }
1435 mcpPath := mcpJSONFile
1436 if resolvedRoot != "." {
1437 mcpPath = filepath.Join(resolvedRoot, mcpJSONFile)
1438 }
1439 legacyPath := legacyConfigPath()
1440 lockPaths := append([]string{}, userPaths...)
1441 if !isUserPath {
1442 lockPaths = append(lockPaths, projectTOML)
1443 }
1444 lockPaths = append(lockPaths, mcpPath)
1445 if legacyPath != "" {
1446 lockPaths = append(lockPaths, legacyPath)
1447 }
1448 unlock, err := lockConfigFilesEdits(lockPaths...)
1449 if err != nil {
1450 return false, fmt.Errorf("remove MCP server: %w", err)
1451 }
1452 defer unlock()
1453
1454 var edits []configSourceEdit
1455 planTOML := func(path string) error {
1456 edit, changed, err := planTOMLPluginRemoval(path, name)
1457 if err != nil {
1458 return err
1459 }
1460 if changed {
1461 edits = append(edits, edit)
1462 }
1463 return nil
1464 }
1465 for _, path := range userPaths {
1466 if err := planTOML(path); err != nil {
1467 return false, err
1468 }
1469 }
1470 if !isUserPath {
1471 if err := planTOML(projectTOML); err != nil {
1472 return false, err
1473 }
1474 }
1475
1476 mcpEdit, changed, err := planMCPJSONPluginRemoval(mcpPath, name)
1477 if err != nil {
1478 return false, err
1479 }
1480 if changed {
1481 edits = append(edits, mcpEdit)
1482 }
1483 legacyEdit, changed, err := planLegacyMCPDisable(legacyPath, name)
1484 if err != nil {
1485 return false, err
1486 }
1487 if changed {
1488 edits = append(edits, legacyEdit)
1489 }
1490 if len(edits) == 0 {
1491 return false, nil
1492 }
1493 if err := applyConfigSourceEdits(edits); err != nil {
1494 return false, err
1495 }
1496 return true, nil
1497 }
1498
1499 // validatePlugin checks a plugin entry by transport. An empty Type means stdio.
1500 func validatePlugin(e PluginEntry) error {
1501 if strings.TrimSpace(e.Name) == "" {
1502 return fmt.Errorf("plugin: name is required")
1503 }
1504 if e.StartupTimeoutSeconds < 0 {
1505 return fmt.Errorf("plugin %q: startup_timeout_seconds must be >= 0", e.Name)
1506 }
1507 if e.CallTimeoutSeconds < 0 {
1508 return fmt.Errorf("plugin %q: call_timeout_seconds must be >= 0", e.Name)
1509 }
1510 for name, sec := range e.ToolTimeoutSeconds {
1511 if strings.TrimSpace(name) == "" {
1512 return fmt.Errorf("plugin %q: tool_timeout_seconds contains an empty tool name", e.Name)
1513 }
1514 if sec < 0 {
1515 return fmt.Errorf("plugin %q: tool_timeout_seconds[%q] must be >= 0", e.Name, name)
1516 }
1517 }
1518 switch strings.ToLower(strings.TrimSpace(e.Type)) {
1519 case "", "stdio":
1520 if strings.TrimSpace(e.Command) == "" {
1521 return fmt.Errorf("plugin %q: command is required for a stdio server", e.Name)
1522 }
1523 case "http", "sse", "streamable-http":
1524 if strings.TrimSpace(e.URL) == "" {
1525 return fmt.Errorf("plugin %q: url is required for a %s server", e.Name, e.Type)
1526 }
1527 default:
1528 return fmt.Errorf("plugin %q: unknown type %q (want stdio|http|sse)", e.Name, e.Type)
1529 }
1530 return nil
1531 }
1532
1533 // SaveTo writes the configuration to path as annotated TOML, atomically: it
1534 // writes a sibling temp file then renames, so a crash mid-write can't leave a
1535 // half-written reasonix.toml that fails to parse on next load. Parent directories
1536 // are created as needed.
1537 //
1538 // For project configs (./reasonix.toml) the write is incremental: only sections
1539 // and fields that differ from built-in defaults are written, so the file never
1540 // accumulates fields that override the user's global config. User configs still
1541 // write the full annotated template since they are the user's own settings store.
1542 func (c *Config) SaveTo(path string) error {
1543 if c == nil {
1544 return fmt.Errorf("save config: nil config")
1545 }
1546 if c.editLoadErr != nil {
1547 return fmt.Errorf("save config loaded from %q: %w", path, c.editLoadErr)
1548 }
1549 scope := renderScopeForPath(path)
1550 if scope == RenderScopeUser {
1551 if err := currentUserConfigEditLockError(); err != nil {
1552 return fmt.Errorf("save user config: %w", err)
1553 }
1554 }
1555 resolved, err := resolveConfigAccessPath(path, scope == RenderScopeUser)
1556 if err != nil {
1557 return err
1558 }
1559 if scope == RenderScopeProject {
1560 return c.saveProjectIncrementalResolved(path, resolved)
1561 }
1562 return writeConfigFileResolved(resolved, RenderTOMLForScope(c, scope), configFilePerm(path))
1563 }
1564
1565 func (c *Config) SaveToScope(path string, scope RenderScope) error {
1566 if c == nil {
1567 return fmt.Errorf("save config: nil config")
1568 }
1569 if c.editLoadErr != nil {
1570 return fmt.Errorf("save config loaded from %q: %w", path, c.editLoadErr)
1571 }
1572 if strings.TrimSpace(path) == "" {
1573 return fmt.Errorf("save: empty config path")
1574 }
1575 userConfig := scope == RenderScopeUser || (scope == RenderScopeFull && isUserConfigPath(path))
1576 if userConfig {
1577 if err := currentUserConfigEditLockError(); err != nil {
1578 return fmt.Errorf("save user config: %w", err)
1579 }
1580 }
1581 resolved, err := resolveConfigAccessPath(path, userConfig)
1582 if err != nil {
1583 return err
1584 }
1585 return writeConfigFileResolved(resolved, RenderTOMLForScope(c, scope), configFilePerm(path))
1586 }
1587
1588 func (c *Config) saveProjectIncrementalResolved(logicalPath, resolvedPath string) error {
1589 raw, err := fileencoding.ReadFileUTF8(resolvedPath)
1590 if err != nil {
1591 if !os.IsNotExist(err) {
1592 return err
1593 }
1594 raw = nil
1595 }
1596
1597 body := string(raw)
1598 isNew := body == ""
1599
1600 if isNew {
1601 return writeConfigFileResolved(resolvedPath, RenderTOMLForScope(c, RenderScopeProject), configFilePerm(logicalPath))
1602 }
1603
1604 delta := RenderTOMLProjectDelta(c)
1605 if tomlBodyHasTopLevelKey(body, "config_version") && !tomlBodyHasTopLevelKey(delta, "config_version") {
1606 delta = fmt.Sprintf("config_version = %d\n", configVersion(c)) + delta
1607 }
1608 removePlugins := len(tomlPluginsForScope(c.Plugins, RenderScopeProject)) == 0 && tomlBodyHasSection(body, "plugins")
1609 removeSandboxBash := shouldRemoveIneffectiveProjectSandboxBash(body, c)
1610 _, hasLegacyDesktopAutoGuard := tomlSectionKeyValue(body, "desktop", "default_auto_recovery_checkpoint")
1611 _, hasRetiredAgentAutoGuard := tomlSectionKeyValue(body, "agent", "auto_recovery_checkpoint")
1612 removeRetiredAutoGuard := hasLegacyDesktopAutoGuard || hasRetiredAgentAutoGuard
1613 writeProviderAccess := c.Desktop.ProviderAccess != nil
1614 if strings.TrimSpace(delta) == "" && !removePlugins && !removeSandboxBash && !removeRetiredAutoGuard && !writeProviderAccess {
1615 return nil // no changes to write
1616 }
1617
1618 // Parse delta into section blocks and merge each into body
1619 if strings.TrimSpace(delta) != "" {
1620 body = mergeTOMLDelta(body, delta)
1621 }
1622 if removePlugins {
1623 body = removeTOMLSection(body, "plugins")
1624 }
1625 if removeSandboxBash {
1626 body = removeTOMLSectionKey(body, "sandbox", "bash")
1627 }
1628 if removeRetiredAutoGuard {
1629 body = removeTOMLSectionKey(body, "desktop", "default_auto_recovery_checkpoint")
1630 body = removeTOMLSectionKey(body, "agent", "auto_recovery_checkpoint")
1631 }
1632 if writeProviderAccess {
1633 body = upsertTOMLSectionKey(body, "desktop", "provider_access", "provider_access = "+renderStringArray(c.Desktop.ProviderAccess))
1634 }
1635 return writeConfigFileResolved(resolvedPath, body, configFilePerm(logicalPath))
1636 }
1637
1638 func shouldRemoveIneffectiveProjectSandboxBash(body string, c *Config) bool {
1639 if c == nil || runtimeGOOS != "windows" {
1640 return false
1641 }
1642 if c.BashMode() != "off" {
1643 return false
1644 }
1645 value, ok := tomlSectionKeyValue(body, "sandbox", "bash")
1646 return ok && tomlStringLiteralEquals(value, "enforce")
1647 }
1648
1649 // mergeTOMLDelta parses delta into named TOML blocks and merges each into body
1650 // via replaceTOMLSection. Consecutive array-of-tables entries ([[plugins]],
1651 // [[providers]]) with the same name are merged into a single block so the
1652 // replacement doesn't lose entries.
1653 func mergeTOMLDelta(body, delta string) string {
1654 lines := strings.Split(delta, "\n")
1655 type section struct {
1656 name string
1657 content string
1658 isArray bool
1659 }
1660 var topLevel strings.Builder
1661 var sections []section
1662 var curName string
1663 var curBuf strings.Builder
1664 curIsArray := false
1665
1666 flush := func() {
1667 if curName == "" {
1668 return
1669 }
1670 content := curBuf.String()
1671 if curIsArray && len(sections) > 0 && sections[len(sections)-1].isArray && sections[len(sections)-1].name == curName {
1672 sections[len(sections)-1].content += content
1673 } else {
1674 sections = append(sections, section{curName, content, curIsArray})
1675 }
1676 curBuf.Reset()
1677 }
1678
1679 for _, line := range lines {
1680 if name, isArray, ok := tomlEditSectionHeader(line); ok {
1681 flush()
1682 curName = name
1683 curIsArray = isArray
1684 curBuf.WriteString(line + "\n")
1685 continue
1686 }
1687 if curName != "" {
1688 curBuf.WriteString(line + "\n")
1689 continue
1690 }
1691 if strings.TrimSpace(line) != "" {
1692 topLevel.WriteString(line + "\n")
1693 }
1694 }
1695 flush()
1696
1697 if top := strings.TrimSpace(topLevel.String()); top != "" {
1698 body = mergeTOMLTopLevelFields(body, top+"\n")
1699 }
1700 for _, s := range sections {
1701 body = replaceTOMLSection(body, s.name, s.content)
1702 }
1703 return body
1704 }
1705
1706 func mergeTOMLTopLevelFields(body, fields string) string {
1707 for _, line := range strings.Split(fields, "\n") {
1708 line = strings.TrimSpace(line)
1709 if line == "" {
1710 continue
1711 }
1712 key, ok := tomlTopLevelKey(line)
1713 if !ok {
1714 continue
1715 }
1716 body = replaceTOMLTopLevelField(body, key, line+"\n")
1717 }
1718 return body
1719 }
1720
1721 // SaveMinimalProjectReasoningLanguage writes a new project config that only
1722 // overrides [agent].reasoning_language.
1723 func SaveMinimalProjectReasoningLanguage(path, lang string) (string, error) {
1724 cfg := Default()
1725 if err := cfg.SetReasoningLanguage(lang); err != nil {
1726 return "", err
1727 }
1728 body := fmt.Sprintf(`# Reasonix project configuration.
1729 # Project-local overrides are merged over the user config.
1730
1731 [agent]
1732 reasoning_language = %q
1733 `, cfg.ReasoningLanguage())
1734 return cfg.ReasoningLanguage(), writeConfigFile(path, body)
1735 }
1736
1737 // SaveMinimalProjectCompactRatio writes a new project config that only
1738 // overrides [agent].compact_ratio.
1739 func SaveMinimalProjectCompactRatio(path string, ratio float64) (float64, error) {
1740 cfg := Default()
1741 if err := cfg.SetCompactRatio(ratio); err != nil {
1742 return 0, err
1743 }
1744 body := fmt.Sprintf(`# Reasonix project configuration.
1745 # Project-local overrides are merged over the user config.
1746
1747 [agent]
1748 compact_ratio = %s
1749 `, formatFloat(cfg.Agent.CompactRatio))
1750 return cfg.Agent.CompactRatio, writeConfigFile(path, body)
1751 }
1752
1753 func writeConfigFile(path, body string) error {
1754 if strings.TrimSpace(path) == "" {
1755 return fmt.Errorf("save: empty config path")
1756 }
1757 return atomicWriteToConfigFile(path, body, configFilePerm(path))
1758 }
1759
1760 func writeConfigFileResolved(path, body string, perm os.FileMode) error {
1761 if strings.TrimSpace(path) == "" {
1762 return fmt.Errorf("save: empty config path")
1763 }
1764 return fileutil.AtomicWriteFile(path, []byte(body), perm)
1765 }
1766
1767 // atomicWriteToConfigFile resolves the path once and writes only the validated
1768 // final target. This preserves valid links and fails closed for broken user
1769 // links or project links that escape their project root.
1770 func atomicWriteToConfigFile(path, body string, perm os.FileMode) error {
1771 resolved, err := resolveConfigReadPath(path)
1772 if err != nil {
1773 return err
1774 }
1775 if err := fileutil.AtomicWriteFile(resolved, []byte(body), perm); err != nil {
1776 return fmt.Errorf("write symlink target %q: %w", resolved, err)
1777 }
1778 return nil
1779 }
1780
1781 func configFilePerm(path string) os.FileMode {
1782 if isUserConfigPath(path) {
1783 return 0o600
1784 }
1785 return 0o644
1786 }
1787
1788 // WritePermissionsAllow updates only permissions.allow in a TOML file. All
1789 // other permission policy fields and unrelated content remain byte-for-byte
1790 // unchanged. Callers must validate and lock the latest file across their full
1791 // read-modify-write transaction before calling this function.
1792 func WritePermissionsAllow(path string, allow []string) error {
1793 if strings.TrimSpace(path) == "" {
1794 return fmt.Errorf("write permissions: empty config path")
1795 }
1796
1797 resolved, exists, err := statConfigPath(path)
1798 if err != nil {
1799 return err
1800 }
1801 var raw []byte
1802 if exists {
1803 raw, err = fileencoding.ReadFileUTF8(resolved)
1804 if err != nil {
1805 return err
1806 }
1807 } else {
1808 raw = nil
1809 }
1810
1811 body := string(raw)
1812 if body == "" {
1813 body = fmt.Sprintf("[permissions]\nallow = %s\n", renderStringArray(allow))
1814 } else {
1815 body = upsertTOMLSectionKey(body, "permissions", "allow", "allow = "+renderStringArray(allow))
1816 }
1817
1818 var candidate Config
1819 if _, err := toml.Decode(body, &candidate); err != nil {
1820 return fmt.Errorf("write permissions: validate updated config: %w", err)
1821 }
1822 if !slices.Equal(candidate.Permissions.Allow, allow) {
1823 return fmt.Errorf("write permissions: validate updated allow: got %v, want %v", candidate.Permissions.Allow, allow)
1824 }
1825 return writeConfigFileResolved(resolved, body, configFilePerm(path))
1826 }
1827
1828 // replaceTOMLSection replaces the content of a named TOML section (including
1829 // its header line) with newContent. It handles both [section] and [[section]]
1830 // array-of-tables headers. If the section doesn't exist, newContent is appended
1831 // at the end.
1832 func replaceTOMLSection(body, sectionName, newContent string) string {
1833 spans := tomlLineSpans(body)
1834 structural := tomlStructuralLineMask(spans)
1835 arrayIdx := -1
1836 for i, span := range spans {
1837 if !structural[i] {
1838 continue
1839 }
1840 name, isArray, ok := tomlEditSectionHeader(span.text)
1841 if ok && isArray && name == sectionName {
1842 arrayIdx = i
1843 break
1844 }
1845 }
1846 if arrayIdx >= 0 {
1847 start := spans[arrayIdx].start
1848 end := len(body)
1849 for i := arrayIdx + 1; i < len(spans); i++ {
1850 if !structural[i] {
1851 continue
1852 }
1853 name, isArray, ok := tomlEditSectionHeader(spans[i].text)
1854 if !ok {
1855 continue
1856 }
1857 if (isArray && name == sectionName) || strings.HasPrefix(name, sectionName+".") {
1858 continue
1859 }
1860 end = spans[i].start
1861 break
1862 }
1863 return body[:start] + strings.TrimRight(newContent, "\n") + "\n" + body[end:]
1864 }
1865
1866 for i, span := range spans {
1867 if !structural[i] {
1868 continue
1869 }
1870 name, isArray, ok := tomlEditSectionHeader(span.text)
1871 if !ok || isArray || name != sectionName {
1872 continue
1873 }
1874 end := len(body)
1875 for nextIdx, next := range spans {
1876 if !structural[nextIdx] {
1877 continue
1878 }
1879 if next.start <= span.start {
1880 continue
1881 }
1882 if _, _, ok := tomlEditSectionHeader(next.text); ok {
1883 end = next.start
1884 break
1885 }
1886 }
1887 return body[:span.start] + newContent + body[end:]
1888 }
1889 return strings.TrimRight(body, "\n") + "\n\n" + newContent
1890 }
1891
1892 func upsertTOMLSectionKey(body, sectionName, key, line string) string {
1893 line = strings.TrimRight(line, "\r\n") + "\n"
1894 spans := tomlLineSpans(body)
1895 structural := tomlStructuralLineMask(spans)
1896 sectionIdx := -1
1897 sectionEnd := len(body)
1898 for i, span := range spans {
1899 if !structural[i] {
1900 continue
1901 }
1902 name, isArray, ok := tomlEditSectionHeader(span.text)
1903 if ok {
1904 if sectionIdx >= 0 {
1905 sectionEnd = span.start
1906 break
1907 }
1908 if !isArray && name == sectionName {
1909 sectionIdx = i
1910 }
1911 continue
1912 }
1913 if sectionIdx >= 0 {
1914 if got, _, ok := tomlKeyValue(span.text); ok && got == key {
1915 endIdx := tomlValueEndSpan(spans, i)
1916 end := spans[endIdx].end
1917 if endIdx > i {
1918 if comments := tomlCommentsInSpans(spans, i, endIdx); len(comments) > 0 {
1919 line = strings.Join(comments, "\n") + "\n" + line
1920 }
1921 } else if comment := tomlInlineComment(spans[endIdx].text); comment != "" {
1922 line = strings.TrimRight(line, "\r\n") + " " + comment + "\n"
1923 }
1924 return body[:span.start] + line + body[end:]
1925 }
1926 }
1927 }
1928 if sectionIdx < 0 {
1929 block := fmt.Sprintf("[%s]\n%s", sectionName, line)
1930 return replaceTOMLSection(body, sectionName, block)
1931 }
1932 prefix := body[:sectionEnd]
1933 if prefix != "" && !strings.HasSuffix(prefix, "\n") {
1934 prefix += "\n"
1935 }
1936 return prefix + line + body[sectionEnd:]
1937 }
1938
1939 type tomlLexState struct {
1940 stringKind tomlStringKind
1941 escaped bool
1942 }
1943
1944 type tomlStringKind uint8
1945
1946 const (
1947 tomlStringNone tomlStringKind = iota
1948 tomlStringBasic
1949 tomlStringLiteral
1950 tomlStringMultilineBasic
1951 tomlStringMultilineLiteral
1952 )
1953
1954 func (s tomlLexState) inMultilineString() bool {
1955 return s.stringKind == tomlStringMultilineBasic || s.stringKind == tomlStringMultilineLiteral
1956 }
1957
1958 func scanTOMLLine(line string, state *tomlLexState, outsideString func(byte)) int {
1959 for i := 0; i < len(line); {
1960 ch := line[i]
1961 switch state.stringKind {
1962 case tomlStringBasic:
1963 if state.escaped {
1964 state.escaped = false
1965 i++
1966 continue
1967 }
1968 switch ch {
1969 case '\\':
1970 state.escaped = true
1971 case '"':
1972 state.stringKind = tomlStringNone
1973 }
1974 i++
1975 continue
1976 case tomlStringLiteral:
1977 if ch == '\'' {
1978 state.stringKind = tomlStringNone
1979 }
1980 i++
1981 continue
1982 case tomlStringMultilineBasic:
1983 if state.escaped {
1984 state.escaped = false
1985 i++
1986 continue
1987 }
1988 if ch == '\\' {
1989 state.escaped = true
1990 i++
1991 continue
1992 }
1993 if ch == '"' {
1994 run := tomlQuoteRun(line, i, '"')
1995 if run >= 3 {
1996 state.stringKind = tomlStringNone
1997 }
1998 i += run
1999 continue
2000 }
2001 i++
2002 continue
2003 case tomlStringMultilineLiteral:
2004 if ch == '\'' {
2005 run := tomlQuoteRun(line, i, '\'')
2006 if run >= 3 {
2007 state.stringKind = tomlStringNone
2008 }
2009 i += run
2010 continue
2011 }
2012 i++
2013 continue
2014 }
2015
2016 switch ch {
2017 case '#':
2018 return i
2019 case '"':
2020 run := tomlQuoteRun(line, i, '"')
2021 switch {
2022 case run == 1:
2023 state.stringKind = tomlStringBasic
2024 case run >= 3 && run < 6:
2025 state.stringKind = tomlStringMultilineBasic
2026 }
2027 i += run
2028 continue
2029 case '\'':
2030 run := tomlQuoteRun(line, i, '\'')
2031 switch {
2032 case run == 1:
2033 state.stringKind = tomlStringLiteral
2034 case run >= 3 && run < 6:
2035 state.stringKind = tomlStringMultilineLiteral
2036 }
2037 i += run
2038 continue
2039 default:
2040 if outsideString != nil {
2041 outsideString(ch)
2042 }
2043 }
2044 i++
2045 }
2046 return -1
2047 }
2048
2049 func tomlQuoteRun(line string, start int, quote byte) int {
2050 end := start
2051 for end < len(line) && line[end] == quote {
2052 end++
2053 }
2054 return end - start
2055 }
2056
2057 func tomlStructuralLineMask(spans []tomlLineSpan) []bool {
2058 structural := make([]bool, len(spans))
2059 state := tomlLexState{}
2060 for i, span := range spans {
2061 structural[i] = !state.inMultilineString()
2062 scanTOMLLine(span.text, &state, nil)
2063 }
2064 return structural
2065 }
2066
2067 func tomlValueEndSpan(spans []tomlLineSpan, start int) int {
2068 if start < 0 || start >= len(spans) {
2069 return start
2070 }
2071 _, value, ok := tomlKeyValue(spans[start].text)
2072 if !ok || !strings.HasPrefix(strings.TrimSpace(value), "[") {
2073 return start
2074 }
2075 depth := 0
2076 seenArray := false
2077 state := tomlLexState{}
2078 for i := start; i < len(spans); i++ {
2079 closed := false
2080 scanTOMLLine(spans[i].text, &state, func(ch byte) {
2081 switch ch {
2082 case '[':
2083 seenArray = true
2084 depth++
2085 case ']':
2086 if seenArray {
2087 depth--
2088 closed = depth == 0
2089 }
2090 }
2091 })
2092 if closed {
2093 return i
2094 }
2095 }
2096 return start
2097 }
2098
2099 func tomlInlineComment(line string) string {
2100 state := tomlLexState{}
2101 if i := scanTOMLLine(line, &state, nil); i >= 0 {
2102 return strings.TrimRight(line[i:], "\r\n")
2103 }
2104 return ""
2105 }
2106
2107 func tomlCommentsInSpans(spans []tomlLineSpan, start, end int) []string {
2108 state := tomlLexState{}
2109 var comments []string
2110 for i := start; i <= end; i++ {
2111 line := spans[i].text
2112 commentAt := scanTOMLLine(line, &state, nil)
2113 if commentAt < 0 {
2114 continue
2115 }
2116 indentEnd := 0
2117 for indentEnd < len(line) && (line[indentEnd] == ' ' || line[indentEnd] == '\t') {
2118 indentEnd++
2119 }
2120 comment := strings.TrimRight(line[commentAt:], "\r\n")
2121 comments = append(comments, line[:indentEnd]+comment)
2122 }
2123 return comments
2124 }
2125
2126 func removeTOMLSection(body, sectionName string) string {
2127 spans := tomlLineSpans(body)
2128 for i, span := range spans {
2129 name, isArray, ok := tomlEditSectionHeader(span.text)
2130 if !ok || name != sectionName {
2131 continue
2132 }
2133 end := len(body)
2134 for j := i + 1; j < len(spans); j++ {
2135 nextName, nextIsArray, ok := tomlEditSectionHeader(spans[j].text)
2136 if !ok {
2137 continue
2138 }
2139 if (isArray && nextIsArray && nextName == sectionName) || strings.HasPrefix(nextName, sectionName+".") {
2140 continue
2141 }
2142 end = spans[j].start
2143 break
2144 }
2145 return strings.TrimRight(body[:span.start], "\n") + "\n" + body[end:]
2146 }
2147 return body
2148 }
2149
2150 func removeTOMLSectionKey(body, sectionName, key string) string {
2151 spans := tomlLineSpans(body)
2152 sectionIdx := -1
2153 keyIdx := -1
2154 endIdx := len(spans)
2155 for i, span := range spans {
2156 name, isArray, ok := tomlEditSectionHeader(span.text)
2157 if ok {
2158 if sectionIdx >= 0 {
2159 endIdx = i
2160 break
2161 }
2162 if !isArray && name == sectionName {
2163 sectionIdx = i
2164 }
2165 continue
2166 }
2167 if sectionIdx >= 0 && keyIdx < 0 {
2168 if got, _, ok := tomlKeyValue(span.text); ok && got == key {
2169 keyIdx = i
2170 }
2171 }
2172 }
2173 if sectionIdx < 0 || keyIdx < 0 {
2174 return body
2175 }
2176 for i := sectionIdx + 1; i < endIdx; i++ {
2177 if i == keyIdx {
2178 continue
2179 }
2180 trimmed := strings.TrimSpace(spans[i].text)
2181 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
2182 continue
2183 }
2184 return body[:spans[keyIdx].start] + body[spans[keyIdx].end:]
2185 }
2186 sectionStart := spans[sectionIdx].start
2187 sectionEnd := len(body)
2188 if endIdx < len(spans) {
2189 sectionEnd = spans[endIdx].start
2190 }
2191 return strings.TrimRight(body[:sectionStart], "\n") + "\n" + body[sectionEnd:]
2192 }
2193
2194 type tomlLineSpan struct {
2195 start int
2196 end int
2197 text string
2198 }
2199
2200 func tomlLineSpans(body string) []tomlLineSpan {
2201 if body == "" {
2202 return nil
2203 }
2204 var spans []tomlLineSpan
2205 for start := 0; start < len(body); {
2206 end := len(body)
2207 if idx := strings.IndexByte(body[start:], '\n'); idx >= 0 {
2208 end = start + idx + 1
2209 }
2210 spans = append(spans, tomlLineSpan{start: start, end: end, text: body[start:end]})
2211 start = end
2212 }
2213 return spans
2214 }
2215
2216 func tomlEditSectionHeader(line string) (string, bool, bool) {
2217 trimmed := strings.TrimSpace(line)
2218 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
2219 return "", false, false
2220 }
2221 if before, _, ok := strings.Cut(trimmed, "#"); ok {
2222 trimmed = strings.TrimSpace(before)
2223 }
2224 if strings.HasPrefix(trimmed, "[[") && strings.HasSuffix(trimmed, "]]") {
2225 name := strings.TrimSpace(trimmed[2 : len(trimmed)-2])
2226 return name, true, name != ""
2227 }
2228 if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
2229 name := strings.TrimSpace(trimmed[1 : len(trimmed)-1])
2230 return name, false, name != ""
2231 }
2232 return "", false, false
2233 }
2234
2235 func replaceTOMLTopLevelField(body, key, newLine string) string {
2236 spans := tomlLineSpans(body)
2237 insertAt := len(body)
2238 for _, span := range spans {
2239 if _, _, ok := tomlEditSectionHeader(span.text); ok {
2240 insertAt = span.start
2241 break
2242 }
2243 if got, ok := tomlTopLevelKey(span.text); ok && got == key {
2244 return body[:span.start] + newLine + body[span.end:]
2245 }
2246 }
2247 return body[:insertAt] + newLine + body[insertAt:]
2248 }
2249
2250 func tomlTopLevelKey(line string) (string, bool) {
2251 key, _, ok := tomlKeyValue(line)
2252 return key, ok
2253 }
2254
2255 func tomlKeyValue(line string) (string, string, bool) {
2256 trimmed := strings.TrimSpace(line)
2257 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
2258 return "", "", false
2259 }
2260 if before, _, ok := strings.Cut(trimmed, "#"); ok {
2261 trimmed = strings.TrimSpace(before)
2262 }
2263 key, value, ok := strings.Cut(trimmed, "=")
2264 if !ok {
2265 return "", "", false
2266 }
2267 key = strings.TrimSpace(key)
2268 if key == "" || strings.Contains(key, ".") {
2269 return "", "", false
2270 }
2271 return key, strings.TrimSpace(value), true
2272 }
2273
2274 func tomlSectionKeyValue(body, sectionName, key string) (string, bool) {
2275 inSection := false
2276 for _, span := range tomlLineSpans(body) {
2277 if name, isArray, ok := tomlEditSectionHeader(span.text); ok {
2278 inSection = !isArray && name == sectionName
2279 continue
2280 }
2281 if !inSection {
2282 continue
2283 }
2284 got, value, ok := tomlKeyValue(span.text)
2285 if ok && got == key {
2286 return value, true
2287 }
2288 }
2289 return "", false
2290 }
2291
2292 func tomlStringLiteralEquals(value, want string) bool {
2293 value = strings.TrimSpace(value)
2294 if len(value) >= 2 {
2295 quote := value[0]
2296 if (quote == '"' || quote == '\'') && value[len(value)-1] == quote {
2297 return value[1:len(value)-1] == want
2298 }
2299 }
2300 return value == want
2301 }
2302
2303 func tomlBodyHasTopLevelKey(body, key string) bool {
2304 for _, span := range tomlLineSpans(body) {
2305 if _, _, ok := tomlEditSectionHeader(span.text); ok {
2306 return false
2307 }
2308 if got, ok := tomlTopLevelKey(span.text); ok && got == key {
2309 return true
2310 }
2311 }
2312 return false
2313 }
2314
2315 func tomlBodyHasSection(body, sectionName string) bool {
2316 for _, span := range tomlLineSpans(body) {
2317 name, _, ok := tomlEditSectionHeader(span.text)
2318 if ok && name == sectionName {
2319 return true
2320 }
2321 }
2322 return false
2323 }
2324
2325 func renderScopeForPath(path string) RenderScope {
2326 if isUserConfigPath(path) {
2327 return RenderScopeUser
2328 }
2329 return RenderScopeProject
2330 }
2331
2332 func isUserConfigPath(path string) bool {
2333 path = strings.TrimSpace(path)
2334 if path == "" {
2335 return false
2336 }
2337 for _, uc := range userConfigCandidatePaths() {
2338 uc = strings.TrimSpace(uc)
2339 if uc == "" {
2340 continue
2341 }
2342 pathAbs, pathErr := filepath.Abs(path)
2343 ucAbs, ucErr := filepath.Abs(uc)
2344 if pathErr == nil && ucErr == nil {
2345 if filepath.Clean(pathAbs) == filepath.Clean(ucAbs) {
2346 return true
2347 }
2348 continue
2349 }
2350 if filepath.Clean(path) == filepath.Clean(uc) {
2351 return true
2352 }
2353 }
2354 return false
2355 }
2356
2357 // IsUserConfigPath reports whether path is one of Reasonix's current or legacy
2358 // user-global config locations. Other paths use project-scoped rendering.
2359 func IsUserConfigPath(path string) bool {
2360 return isUserConfigPath(path)
2361 }
2362
2363 // Save writes the configuration back to the file it was loaded from
2364 // (SourcePath), or to ./reasonix.toml when none exists yet — the conventional
2365 // project-local target a fresh GUI session would create.
2366 func (c *Config) Save() error {
2367 path := SourcePath()
2368 if path == "" {
2369 path = "reasonix.toml"
2370 }
2371 return c.SaveTo(path)
2372 }
2373
2374 // SaveForRoot saves root's project config when it exists, falling back to the
2375 // user's global config when root has no reasonix.toml. Existing project files
2376 // are edited from their own TOML only, never from a runtime user+project merge.
2377 func (c *Config) SaveForRoot(root string) error {
2378 root = resolveRoot(root)
2379 projectTOML := "reasonix.toml"
2380 if root != "." {
2381 projectTOML = filepath.Join(root, "reasonix.toml")
2382 }
2383 if _, err := os.Stat(projectTOML); err == nil {
2384 projectCfg := LoadForEditWithoutCredentials(projectTOML)
2385 return projectCfg.SaveTo(projectTOML)
2386 }
2387 if uc := userConfigPath(); uc != "" {
2388 if err := os.MkdirAll(filepath.Dir(uc), 0o755); err != nil {
2389 return err
2390 }
2391 return c.SaveTo(uc)
2392 }
2393 return c.SaveTo(projectTOML)
2394 }
2395
2395 lines GO