返回 DeepSeek-Reasonix
render.go
根目录 / internal / config / render.go
1 package config
2
3 import (
4 "fmt"
5 "reflect"
6 "sort"
7 "strconv"
8 "strings"
9
10 "reasonix/internal/provider"
11 )
12
13 type RenderScope string
14
15 const (
16 RenderScopeFull RenderScope = "full"
17 RenderScopeUser RenderScope = "user"
18 RenderScopeProject RenderScope = "project"
19 )
20
21 // RenderTOML renders the config as annotated TOML in the `reasonix setup` house style:
22 // comments preserved, system_prompt as a multi-line string, helpful hints. The
23 // output round-trips back through Load (see render_test.go).
24 func RenderTOML(c *Config) string {
25 return RenderTOMLForScope(c, RenderScopeFull)
26 }
27
28 // RenderTOMLForScope renders an annotated TOML file for a specific persistence
29 // target. User configs can carry desktop and account-level preferences; project
30 // reasonix.toml stays focused on project behavior and intentionally excludes
31 // desktop-only preferences.
32 func RenderTOMLForScope(c *Config, scope RenderScope) string {
33 if c == nil {
34 c = Default()
35 }
36 switch scope {
37 case RenderScopeUser, RenderScopeProject:
38 default:
39 scope = RenderScopeFull
40 }
41 if scope == RenderScopeProject {
42 c = projectScopedConfigForRender(c)
43 }
44 defaults := Default()
45 var b strings.Builder
46
47 b.WriteString("# Reasonix configuration.\n")
48 fmt.Fprintf(&b, "# Resolution order: flag > ./reasonix.toml > %s > built-in defaults.\n", userConfigDisplayPath())
49 b.WriteString("# Fields marked user/global only are not overridden by ./reasonix.toml.\n")
50 b.WriteString("# Secrets are named via api_key_env and stored in Reasonix's global .env; never put keys here.\n\n")
51
52 fmt.Fprintf(&b, "config_version = %d # schema marker for diagnostics; old versions may ignore it\n", configVersion(c))
53 fmt.Fprintf(&b, "default_model = %q\n", c.DefaultModel)
54 if c.Language != "" {
55 fmt.Fprintf(&b, "language = %q # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG\n", c.Language)
56 } else {
57 b.WriteString("# language = \"zh\" # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG\n")
58 }
59 if scope != RenderScopeProject {
60 fmt.Fprintf(&b, "credentials_store = %q # legacy compatibility; provider keys are saved in Reasonix's global .env\n", normalizeCredentialsStore(c.CredentialsStore))
61 }
62 b.WriteString("\n")
63
64 if shouldRenderUI(c, defaults, scope) {
65 b.WriteString("[ui]\n")
66 fmt.Fprintf(&b, "theme = %q # auto|dark|light; CLI colors only; REASONIX_THEME can override per run\n", c.UITheme())
67 if style := c.UIThemeStyle(); style != "" {
68 fmt.Fprintf(&b, "theme_style = %q # CLI accent palette; REASONIX_THEME_STYLE can override per run\n", style)
69 } else {
70 b.WriteString("# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n")
71 }
72 if layout := c.UIShortcutLayout(); layout != "classic" {
73 fmt.Fprintf(&b, "shortcut_layout = %q # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n", layout)
74 } else {
75 b.WriteString("# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n")
76 }
77 if strings.TrimSpace(c.UI.CursorShape) != "" {
78 fmt.Fprintf(&b, "cursor_shape = %q # block|underline|bar; text input cursor shape\n", c.UICursorShape())
79 } else {
80 b.WriteString("# cursor_shape = \"bar\" # block|underline|bar; text input cursor shape\n")
81 }
82 if strings.TrimSpace(c.UI.CloseBehavior) != "" && scope == RenderScopeProject {
83 fmt.Fprintf(&b, "close_behavior = %q # legacy desktop close behavior; prefer [desktop].close_behavior in user config\n", c.DesktopCloseBehavior())
84 }
85 if c.UI.ShowReasoning {
86 b.WriteString("show_reasoning = true # CLI: show thinking text by default; false = collapsed (toggle with Ctrl+O)\n")
87 } else {
88 b.WriteString("# show_reasoning = true # CLI: show thinking text by default; false = collapsed (toggle with Ctrl+O)\n")
89 }
90 fmt.Fprintf(&b, "show_turn_usage = %v # CLI/TUI: show per-request token and cost receipts in the transcript\n", c.UI.ShowTurnUsage)
91 b.WriteString("\n")
92 }
93
94 if scope != RenderScopeProject {
95 b.WriteString("[desktop]\n")
96 if lang := c.DesktopLanguage(); lang != "" {
97 fmt.Fprintf(&b, "language = %q # desktop UI language; empty/auto = browser/OS auto-detect\n", lang)
98 } else {
99 b.WriteString("# language = \"zh\" # desktop UI language; empty/auto = browser/OS auto-detect\n")
100 }
101 if currency := c.DesktopCurrency(); currency != "" {
102 fmt.Fprintf(&b, "currency = %q # official pricing currency: CNY|USD; empty/auto follows language\n", currency)
103 } else {
104 b.WriteString("# currency = \"USD\" # official pricing currency: CNY|USD; empty/auto follows language\n")
105 }
106 fmt.Fprintf(&b, "layout_style = %q # desktop layout: classic|workbench|creation\n", c.DesktopLayoutStyle())
107 fmt.Fprintf(&b, "theme = %q # desktop only: auto|dark|light\n", c.DesktopTheme())
108 fmt.Fprintf(&b, "terminal_theme = %q # integrated terminal: auto|dark|light; auto follows the desktop app\n", c.DesktopTerminalTheme())
109 if style := c.DesktopThemeStyle(); style != "" {
110 fmt.Fprintf(&b, "theme_style = %q # desktop accent palette\n", style)
111 } else {
112 b.WriteString("# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n")
113 }
114 if opener := c.DesktopExternalOpener(); opener != "" {
115 fmt.Fprintf(&b, "external_opener = %q # desktop Open control: installed application id\n", opener)
116 } else {
117 b.WriteString("# external_opener = \"vscode\" # desktop Open control: installed application id\n")
118 }
119 fmt.Fprintf(&b, "close_behavior = %q # desktop: quit|background when the window close button is clicked\n", c.DesktopCloseBehavior())
120 fmt.Fprintf(&b, "status_bar_style = %q # desktop: icon|text metric labels in the bottom status bar\n", c.DesktopStatusBarStyle())
121 fmt.Fprintf(&b, "status_bar_items = %s # desktop: ordered visible bottom status bar items\n", renderStringArray(c.DesktopStatusBarItems()))
122 fmt.Fprintf(&b, "default_tool_approval_mode = %q # desktop: Ask/Auto/YOLO default for newly-created sessions\n", c.DesktopDefaultToolApprovalMode())
123 fmt.Fprintf(&b, "check_updates = %v # desktop: check for new versions on startup\n", c.DesktopCheckUpdates())
124 fmt.Fprintf(&b, "telemetry = %v # desktop: anonymous launch ping + scrubbed next-launch native crash diagnostics; never content\n", c.DesktopTelemetry())
125 fmt.Fprintf(&b, "metrics = %v # desktop: aggregate quality/lifecycle metrics (anonymous signal/bucket counts); never content\n", c.DesktopMetrics())
126 // A non-nil empty slice is intentional: provider_access = [] means the
127 // user removed every desktop access entry. Omitting it would make the next
128 // load treat the config as legacy and infer access again.
129 if c.Desktop.ProviderAccess != nil {
130 fmt.Fprintf(&b, "provider_access = %s # desktop settings: providers shown on Settings > Model > Access\n", renderStringArray(c.Desktop.ProviderAccess))
131 }
132 fmt.Fprintf(&b, "expand_thinking = %v # desktop: show reasoning text expanded by default; false = collapsed\n", c.Desktop.ExpandThinking)
133 fmt.Fprintf(&b, "display_mode = %q # desktop: standard|compact transcript display mode\n", c.DesktopDisplayMode())
134 if width := c.DesktopConversationWidth(); width == "full" {
135 fmt.Fprintf(&b, "conversation_width = %q # desktop: standard|full transcript width; empty = standard\n", width)
136 }
137 b.WriteString("\n")
138 } else if c.Desktop.ProviderAccess != nil {
139 // provider_access is intentionally mergeable across user and project
140 // configs. It is the only desktop field written to reasonix.toml: local
141 // providers then appear in that workspace's desktop model switcher without
142 // copying user-global appearance or security preferences into the project.
143 b.WriteString("[desktop]\n")
144 fmt.Fprintf(&b, "provider_access = %s # providers available to this workspace in the desktop model switcher\n\n", renderStringArray(c.Desktop.ProviderAccess))
145 }
146
147 if scope != RenderScopeProject {
148 if c.CLITelemetryConfigured() {
149 b.WriteString("[telemetry]\n")
150 fmt.Fprintf(&b, "cli_metrics = %q # CLI content-free usage metrics: auto|on|off; auto requires a local interactive terminal\n\n", c.CLITelemetryMode())
151 }
152
153 b.WriteString("[notifications]\n")
154 fmt.Fprintf(&b, "enabled = %v # system notifications for CLI and desktop turns; default off\n", c.Notifications.Enabled)
155 fmt.Fprintf(&b, "turn_done = %v # notify when a turn finishes\n", c.Notifications.TurnDone)
156 fmt.Fprintf(&b, "approval_request = %v # notify when a tool approval is waiting\n", c.Notifications.ApprovalRequest)
157 fmt.Fprintf(&b, "ask_request = %v # notify when a question is waiting\n", c.Notifications.AskRequest)
158 b.WriteString("\n")
159 }
160
161 if shouldRenderNetwork(c, defaults, scope) {
162 b.WriteString("[network]\n")
163 fmt.Fprintf(&b, "proxy_mode = %q # auto|env|custom|off; auto currently uses env proxy\n", c.NetworkProxyMode())
164 if c.Network.ProxyURL != "" {
165 fmt.Fprintf(&b, "proxy_url = %q # custom override, e.g. socks5://127.0.0.1:7890\n", c.Network.ProxyURL)
166 } else {
167 b.WriteString("# proxy_url = \"socks5://127.0.0.1:7890\" # optional custom override\n")
168 }
169 if c.Network.NoProxy != "" {
170 fmt.Fprintf(&b, "no_proxy = %q # honored for proxy_mode = \"custom\"\n", c.Network.NoProxy)
171 } else {
172 b.WriteString("# no_proxy = \"localhost,127.0.0.1,.local\" # honored for proxy_mode = \"custom\"\n")
173 }
174 b.WriteString("\n[network.proxy]\n")
175 proxyType := c.Network.Proxy.Type
176 if proxyType == "" {
177 proxyType = "socks5"
178 }
179 fmt.Fprintf(&b, "type = %q # http|https|socks5|socks5h\n", proxyType)
180 if c.Network.Proxy.Server != "" {
181 fmt.Fprintf(&b, "server = %q\n", c.Network.Proxy.Server)
182 } else {
183 b.WriteString("# server = \"127.0.0.1\"\n")
184 }
185 if c.Network.Proxy.Port > 0 {
186 fmt.Fprintf(&b, "port = %d\n", c.Network.Proxy.Port)
187 } else {
188 b.WriteString("# port = 7890\n")
189 }
190 if c.Network.Proxy.Username != "" {
191 fmt.Fprintf(&b, "username = %q\n", c.Network.Proxy.Username)
192 } else {
193 b.WriteString("# username = \"\"\n")
194 }
195 if c.Network.Proxy.Password != "" {
196 fmt.Fprintf(&b, "password = %q # supports ${VAR} expansion\n", c.Network.Proxy.Password)
197 } else {
198 b.WriteString("# password = \"${REASONIX_PROXY_PASSWORD}\" # optional; supports ${VAR} expansion\n")
199 }
200 b.WriteString("\n")
201 }
202 if shouldRenderEnvironment(c, defaults, scope) {
203 renderEnvironmentConfig(&b, c.Environment)
204 }
205
206 b.WriteString("[agent]\n")
207 if shouldRenderSystemPrompt(c, defaults, scope) {
208 b.WriteString("system_prompt = \"\"\"\n")
209 b.WriteString(c.Agent.SystemPrompt)
210 b.WriteString("\"\"\"\n")
211 } else {
212 b.WriteString("# system_prompt = \"\"\"...\"\"\" # omit to use the built-in prompt for this version\n")
213 }
214 if c.Agent.SystemPromptFile != "" {
215 fmt.Fprintf(&b, "system_prompt_file = %q\n", c.Agent.SystemPromptFile)
216 } else {
217 b.WriteString("# system_prompt_file = \"prompts/system.md\" # project paths stay in <workspace>; user paths may fall back to <reasonix home>\n")
218 }
219 fmt.Fprintf(&b, "temperature = %s\n", formatFloat(c.Agent.Temperature))
220 if strings.TrimSpace(c.Agent.RecoveryModel) != "" {
221 fmt.Fprintf(&b, "recovery_model = %q # optional independent reviewer for low-risk automatic recovery\n", c.Agent.RecoveryModel)
222 } else {
223 b.WriteString("# recovery_model = \"deepseek-pro\" # optional; falls back to guardian then main model\n")
224 }
225 if lang := c.ReasoningLanguage(); lang != "auto" {
226 fmt.Fprintf(&b, "reasoning_language = %q # visible reasoning language: auto|zh|en\n", lang)
227 } else {
228 b.WriteString("# reasoning_language = \"zh\" # visible reasoning language: auto|zh|en\n")
229 }
230 fmt.Fprintf(&b, "soft_compact_ratio = %s # notice only; keeps cache-first prefix intact\n", formatFloat(c.Agent.SoftCompactRatio))
231 fmt.Fprintf(&b, "tool_result_snip_ratio = %s # snip stale tool results at this fraction before summary compaction\n", formatFloat(c.Agent.ToolResultSnipRatio))
232 fmt.Fprintf(&b, "compact_ratio = %s # try compacting when prompt reaches this fraction\n", formatFloat(c.Agent.CompactRatio))
233 fmt.Fprintf(&b, "compact_force_ratio = %s # force compacting at this high-water mark\n", formatFloat(c.Agent.CompactForceRatio))
234 if c.Agent.Keep != nil {
235 fmt.Fprintf(&b, "keep = %s # compaction keep policy: errors, user_marked\n", renderStringArray(c.Agent.Keep))
236 } else {
237 b.WriteString("# keep = [\"errors\"] # compaction keep policy: errors, user_marked\n")
238 }
239 if c.Agent.RecentKeep > 0 {
240 fmt.Fprintf(&b, "recent_keep = %d # minimum recent messages kept verbatim\n", c.Agent.RecentKeep)
241 } else {
242 b.WriteString("# recent_keep = 2 # minimum recent messages kept verbatim\n")
243 }
244 fmt.Fprintf(&b, "cold_resume_prune = %v # elide stale tool results when reopening a session past the provider cache window\n", c.ColdResumePruneEnabled())
245 if len(c.Agent.PlanModeReadOnlyCommands) > 0 {
246 fmt.Fprintf(&b, "plan_mode_read_only_commands = %s # legacy compatibility only; Plan bash uses Permissions\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands))
247 } else {
248 b.WriteString("# plan_mode_read_only_commands = [\"gh issue view\"] # legacy compatibility only; Plan bash uses Permissions\n")
249 }
250 if c.Agent.PlannerModel != "" {
251 fmt.Fprintf(&b, "planner_model = %q # low-frequency planner (two-model collaboration)\n", c.Agent.PlannerModel)
252 } else {
253 b.WriteString("# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n")
254 }
255 if c.Agent.SubagentModel != "" {
256 fmt.Fprintf(&b, "subagent_model = %q # default model for runAs=subagent skills\n", c.Agent.SubagentModel)
257 } else {
258 b.WriteString("# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n")
259 }
260 if len(c.Agent.SubagentModels) > 0 {
261 fmt.Fprintf(&b, "subagent_models = %s # per-skill overrides\n", renderStringMap(c.Agent.SubagentModels))
262 } else {
263 b.WriteString("# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n")
264 }
265 if c.Agent.SubagentEffort != "" {
266 fmt.Fprintf(&b, "subagent_effort = %q # default effort for subagent entry points\n", c.Agent.SubagentEffort)
267 } else {
268 b.WriteString("# subagent_effort = \"high\" # optional default effort for subagents\n")
269 }
270 if len(c.Agent.SubagentEfforts) > 0 {
271 fmt.Fprintf(&b, "subagent_efforts = %s # per-tool/skill effort overrides\n", renderStringMap(c.Agent.SubagentEfforts))
272 } else {
273 b.WriteString("# subagent_efforts = { review = \"max\", task = \"high\" } # per-tool/skill effort overrides\n")
274 }
275 if c.Agent.MaxSubagentDepth != defaults.Agent.MaxSubagentDepth {
276 fmt.Fprintf(&b, "max_subagent_depth = %d # nested subagent delegation depth; 1 restores the old single-layer boundary\n", c.Agent.MaxSubagentDepth)
277 } else {
278 b.WriteString("# max_subagent_depth = 2 # nested subagent delegation depth; set 1 to disable nested delegation\n")
279 }
280 if c.Agent.MaxSubagentConcurrency != defaults.Agent.MaxSubagentConcurrency {
281 fmt.Fprintf(&b, "max_subagent_concurrency = %d # session-wide sub-agent concurrency (task/fleet/skills)\n", c.Agent.MaxSubagentConcurrency)
282 } else {
283 b.WriteString("# max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills)\n")
284 }
285 if c.Agent.MaxParallelWriters != defaults.Agent.MaxParallelWriters {
286 fmt.Fprintf(&b, "max_parallel_writers = %d # concurrent writers with non-overlapping write_paths\n", c.Agent.MaxParallelWriters)
287 } else {
288 b.WriteString("# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths\n")
289 }
290 if c.Agent.OutputStyle != "" {
291 fmt.Fprintf(&b, "output_style = %q # persona/tone folded into the prompt\n", c.Agent.OutputStyle)
292 } else {
293 b.WriteString("# output_style = \"explanatory\" # explanatory | learning | concise | custom; empty = default\n")
294 }
295 b.WriteString("\n")
296
297 if shouldRenderProviders(c, defaults, scope) {
298 for _, p := range c.Providers {
299 b.WriteString("[[providers]]\n")
300 fmt.Fprintf(&b, "name = %q\n", p.Name)
301 fmt.Fprintf(&b, "kind = %q\n", p.Kind)
302 fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL)
303 if p.ChatURL != "" {
304 fmt.Fprintf(&b, "chat_url = %q # optional full chat completions URL; disables automatic /chat/completions suffix\n", p.ChatURL)
305 }
306 if len(p.Models) > 0 {
307 fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models))
308 if p.Default != "" {
309 fmt.Fprintf(&b, "default = %q\n", p.Default)
310 }
311 } else if p.Model != "" {
312 fmt.Fprintf(&b, "model = %q\n", p.Model)
313 }
314 if p.ModelsURL != "" {
315 fmt.Fprintf(&b, "models_url = %q # auto-fetch models from this URL on startup\n", p.ModelsURL)
316 }
317 fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
318 if p.PresetID != "" {
319 fmt.Fprintf(&b, "preset_id = %q # curated preset identity; settings UI uses it to avoid duplicate installs\n", p.PresetID)
320 }
321 if p.PresetVersion > 0 {
322 fmt.Fprintf(&b, "preset_version = %d\n", p.PresetVersion)
323 }
324 if len(p.Headers) > 0 {
325 fmt.Fprintf(&b, "headers = %s # extra static request headers; keep secrets in api_key_env\n", renderStringMap(p.Headers))
326 }
327 if len(p.ExtraBody) > 0 {
328 fmt.Fprintf(&b, "extra_body = %s # extra top-level JSON request body fields for compatible gateways\n", renderAnyMap(p.ExtraBody))
329 }
330 if p.AuthHeader {
331 b.WriteString("auth_header = true # Anthropic-compatible: send Authorization: Bearer <api_key> instead of x-api-key\n")
332 }
333 if p.ResponsesMode != "" {
334 fmt.Fprintf(&b, "responses_mode = %q # responses provider: stateless|stateful\n", p.ResponsesMode)
335 }
336 if p.ResponsesStateful != nil {
337 fmt.Fprintf(&b, "responses_stateful = %t # legacy responses mode switch\n", *p.ResponsesStateful)
338 }
339 if p.BalanceURL != "" {
340 fmt.Fprintf(&b, "balance_url = %q # optional; wallet-balance endpoint shown in the status bar\n", p.BalanceURL)
341 }
342 if p.ContextWindow > 0 {
343 fmt.Fprintf(&b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow)
344 }
345 if p.MaxOutputTokens != 0 {
346 fmt.Fprintf(&b, "max_output_tokens = %d # total output cap; 0 = provider default, negative = omit when optional\n", p.MaxOutputTokens)
347 }
348 if p.Price != nil {
349 fmt.Fprintf(&b, "price = %s # provider-wide fallback, per 1M tokens\n", renderPricingInline(p.Price))
350 }
351 if len(p.Prices) > 0 {
352 fmt.Fprintf(&b, "prices = %s # per-model prices, per 1M tokens\n", renderPricingMap(p.Prices))
353 }
354 if p.Thinking != "" {
355 fmt.Fprintf(&b, "thinking = %q\n", p.Thinking)
356 }
357 if p.Effort != "" {
358 fmt.Fprintf(&b, "effort = %q\n", p.Effort)
359 }
360 if p.Vision {
361 b.WriteString("vision = true # provider accepts image input for all listed models\n")
362 }
363 if p.VisionModels != nil {
364 fmt.Fprintf(&b, "vision_models = %s # models in this provider that accept image input\n", renderStringArray(p.VisionModels))
365 }
366 if p.VisionDetail != "" {
367 fmt.Fprintf(&b, "vision_detail = %q # openai image detail hint: low|high; empty = auto\n", p.VisionDetail)
368 }
369 if p.WebSearch != nil {
370 fmt.Fprintf(&b, "web_search = %t # provider-executed web_search tool; omitted defaults on for supported official DeepSeek APIs\n", *p.WebSearch)
371 }
372 if p.ReasoningProtocol != "" {
373 fmt.Fprintf(&b, "reasoning_protocol = %q # auto|deepseek|glm|openai|none; overrides model/endpoint reasoning detection\n", p.ReasoningProtocol)
374 }
375 if len(p.SupportedEfforts) > 0 {
376 fmt.Fprintf(&b, "supported_efforts = %s # custom /effort levels exposed by this provider; overrides the built-in Kind/BaseURL default\n", renderStringArray(p.SupportedEfforts))
377 }
378 if p.DefaultEffort != "" {
379 fmt.Fprintf(&b, "default_effort = %q # used when /effort is auto or unset; must be one of supported_efforts\n", p.DefaultEffort)
380 }
381 if len(p.ModelOverrides) > 0 {
382 fmt.Fprintf(&b, "model_overrides = %s # per-model context/output/reasoning/vision overrides for mixed gateways\n", renderModelOverrides(p.ModelOverrides))
383 }
384 if p.NoProxy {
385 b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n")
386 }
387 b.WriteString("\n")
388 }
389 }
390
391 b.WriteString("[tools]\n")
392 if len(c.Tools.Enabled) == 0 {
393 b.WriteString("enabled = [] # empty = all built-in tools\n")
394 } else {
395 b.WriteString("enabled = [")
396 for i, t := range c.Tools.Enabled {
397 if i > 0 {
398 b.WriteString(", ")
399 }
400 fmt.Fprintf(&b, "%q", t)
401 }
402 b.WriteString("]\n")
403 }
404 fmt.Fprintf(&b, "bash_timeout_seconds = %d # foreground safety cap; set 0 for no tool-local cap\n", c.BashTimeoutSeconds())
405 fmt.Fprintf(&b, "mcp_startup_timeout_seconds = %d # background initialize + tools/list safety cap; per-plugin overrides may raise it\n", c.MCPStartupTimeoutSeconds())
406 fmt.Fprintf(&b, "mcp_call_timeout_seconds = %d # default MCP call safety cap; per-plugin/tool overrides may raise it\n\n", c.MCPCallTimeoutSeconds())
407
408 b.WriteString("[tools.background_jobs]\n")
409 fmt.Fprintf(&b, "stalled_warning_seconds = %d # warn once per background job after this many quiet seconds; 0 disables\n\n", c.BackgroundJobStalledWarningSeconds())
410
411 b.WriteString("[tools.shell]\n")
412 if c.Tools.Shell.Prefer != "" {
413 fmt.Fprintf(&b, "prefer = %q # auto|bash|powershell|pwsh; empty/default = auto-detect\n", c.Tools.Shell.Prefer)
414 } else {
415 b.WriteString("# prefer = \"auto\" # auto|bash|powershell|pwsh; empty/default = auto-detect\n")
416 }
417 if c.Tools.Shell.Path != "" {
418 fmt.Fprintf(&b, "path = %q # absolute path to the shell executable; empty = PATH lookup\n\n", c.Tools.Shell.Path)
419 } else {
420 b.WriteString("# path = \"/opt/homebrew/bin/bash\" # absolute path to the shell executable; empty = PATH lookup\n\n")
421 }
422
423 renderLSPConfig(&b, c.LSP)
424
425 b.WriteString("[skills]\n")
426 if len(c.Skills.Paths) > 0 {
427 fmt.Fprintf(&b, "paths = %s # extra custom skill roots\n", renderStringArray(c.Skills.Paths))
428 } else {
429 b.WriteString("# paths = [\"~/my-skills\", \"../shared/skills\"] # extra custom skill roots\n")
430 }
431 if len(c.Skills.ExcludedPaths) > 0 {
432 fmt.Fprintf(&b, "excluded_paths = %s # skill roots hidden from discovery\n", renderStringArray(c.Skills.ExcludedPaths))
433 } else {
434 b.WriteString("# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n")
435 }
436 if c.Skills.MaxDepth != 0 {
437 fmt.Fprintf(&b, "max_depth = %d # nested scan depth; default 3, set 1 for legacy root-only discovery\n", c.SkillMaxDepth())
438 } else {
439 b.WriteString("# max_depth = 3 # nested scan depth; set 1 for legacy root-only discovery\n")
440 }
441 if disabled := c.DisabledSkillNames(); len(disabled) > 0 {
442 fmt.Fprintf(&b, "disabled_skills = %s # hidden from the prompt, slash invocation, and skill tools\n\n", renderStringArray(disabled))
443 } else {
444 b.WriteString("# disabled_skills = [\"review\"] # hide noisy or unwanted skills\n\n")
445 }
446
447 b.WriteString("[permissions]\n")
448 b.WriteString("# Per-call gating. mode = writer fallback when no rule matches: ask|allow|deny.\n")
449 b.WriteString("# Readers always default to allow. Precedence: deny > ask > allow > fallback.\n")
450 b.WriteString("# Rules are \"Tool\" or \"Tool(specifier)\"; e.g. Bash(go test:*), Edit(src/**).\n")
451 mode := c.Permissions.Mode
452 if mode == "" {
453 mode = "ask"
454 }
455 fmt.Fprintf(&b, "mode = %q\n", mode)
456 if c.Permissions.AllowDynamicBash {
457 b.WriteString("allow_dynamic_bash = true # advanced: let mode=allow cover command substitution and interpreter -c/-e\n")
458 } else {
459 b.WriteString("# allow_dynamic_bash = false # advanced opt-in; deny/ask and exact rules still take precedence\n")
460 }
461 b.WriteString(renderRuleList("deny", c.Permissions.Deny, `["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode`))
462 b.WriteString(renderRuleList("allow", c.Permissions.Allow, `["Bash(go test:*)", "Bash(git status:*)"] # never prompted`))
463 b.WriteString(renderRuleList("ask", c.Permissions.Ask, `["Edit(src/**)"] # force a prompt even if otherwise allowed`))
464 b.WriteString("\n")
465
466 b.WriteString("[sandbox]\n")
467 b.WriteString("# Confine tool blast radius. File-writers (write_file/edit_file/multi_edit/move_file)\n")
468 b.WriteString("# may only write under workspace_root (empty = current dir) and allow_write extras.\n")
469 b.WriteString("# bash = \"enforce\" jails each command in an OS sandbox when available;\n")
470 b.WriteString("# without one, bash execution is refused. Empty defaults to enforce on macOS/Linux.\n")
471 b.WriteString("# Windows has no OS-level Bash sandbox and fixes bash = \"off\".\n")
472 b.WriteString("# network allows sandboxed bash egress.\n")
473 if c.Sandbox.WorkspaceRoot != "" {
474 fmt.Fprintf(&b, "workspace_root = %q\n", c.Sandbox.WorkspaceRoot)
475 } else {
476 b.WriteString("# workspace_root = \"\" # default: current working directory\n")
477 }
478 if len(c.Sandbox.AllowWrite) > 0 {
479 fmt.Fprintf(&b, "allow_write = %s\n", renderStringArray(c.Sandbox.AllowWrite))
480 } else {
481 b.WriteString("# allow_write = [\"/tmp\"] # extra dirs writers may also modify\n")
482 }
483 if len(c.Sandbox.ForbidRead) > 0 {
484 fmt.Fprintf(&b, "forbid_read = %s\n", renderStringArray(c.Sandbox.ForbidRead))
485 } else {
486 b.WriteString("# forbid_read = [] # dirs the agent cannot read or list\n")
487 }
488 fmt.Fprintf(&b, "bash = %q\n", c.BashMode())
489 fmt.Fprintf(&b, "network = %v\n", c.Sandbox.Network)
490 b.WriteString("\n")
491
492 b.WriteString("[statusline]\n")
493 b.WriteString("# A custom status line: a command whose first stdout line replaces the built-in\n")
494 b.WriteString("# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\",\"cwd\"} as JSON on stdin.\n")
495 if c.Statusline.Command != "" {
496 fmt.Fprintf(&b, "command = %q\n", c.Statusline.Command)
497 } else {
498 b.WriteString("# command = \"my-statusline.sh\"\n")
499 }
500 b.WriteString("\n")
501
502 if shouldRenderBot(c, defaults, scope) {
503 b.WriteString("# Bot gateway: multi-channel IM bot for QQ, Feishu/Lark, and WeChat.\n")
504 b.WriteString("[bot]\n")
505 fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Enabled)
506 if c.Bot.Model != "" {
507 fmt.Fprintf(&b, "model = %q\n", c.Bot.Model)
508 } else {
509 b.WriteString("# model = \"\" # empty = default_model\n")
510 }
511 if c.Bot.ToolApprovalMode != "" {
512 fmt.Fprintf(&b, "tool_approval_mode = %q # ask|auto|yolo; yolo skips tool approvals only\n", c.Bot.ToolApprovalMode)
513 } else {
514 b.WriteString("# tool_approval_mode = \"ask\" # ask|auto|yolo; ask and plan decisions still wait\n")
515 }
516 fmt.Fprintf(&b, "max_steps = %d\n", c.Bot.MaxSteps)
517 fmt.Fprintf(&b, "debounce_ms = %d\n", c.Bot.DebounceMs)
518 if c.Bot.QueueMode != "" {
519 fmt.Fprintf(&b, "queue_mode = %q # steer|followup|collect|interrupt\n", c.Bot.QueueMode)
520 } else {
521 b.WriteString("# queue_mode = \"steer\" # steer|followup|collect|interrupt\n")
522 }
523 if c.Bot.QueueCap > 0 {
524 fmt.Fprintf(&b, "queue_cap = %d\n", c.Bot.QueueCap)
525 } else {
526 b.WriteString("# queue_cap = 20\n")
527 }
528 if c.Bot.QueueDrop != "" {
529 fmt.Fprintf(&b, "queue_drop = %q # summarize|old|new\n", c.Bot.QueueDrop)
530 } else {
531 b.WriteString("# queue_drop = \"summarize\" # summarize|old|new\n")
532 }
533 fmt.Fprintf(&b, "ignore_self_messages = %v # ignore bot echo by returned message_id and configured self user ids\n", c.Bot.IgnoreSelfMessages)
534 b.WriteString("\n[bot.self_user_ids]\n")
535 fmt.Fprintf(&b, "qq = %s\n", renderStringArray(c.Bot.SelfUserIDs.QQ))
536 fmt.Fprintf(&b, "feishu = %s\n", renderStringArray(c.Bot.SelfUserIDs.Feishu))
537 fmt.Fprintf(&b, "weixin = %s\n", renderStringArray(c.Bot.SelfUserIDs.Weixin))
538 b.WriteString("\n[bot.control]\n")
539 fmt.Fprintf(&b, "enabled = %v # local loopback HTTP API for status/send; requires Bearer token\n", c.Bot.Control.Enabled)
540 if strings.TrimSpace(c.Bot.Control.Addr) != "" {
541 fmt.Fprintf(&b, "addr = %q\n", c.Bot.Control.Addr)
542 } else {
543 b.WriteString("# addr = \"127.0.0.1:37913\"\n")
544 }
545 if strings.TrimSpace(c.Bot.Control.TokenEnv) != "" {
546 fmt.Fprintf(&b, "token_env = %q\n", c.Bot.Control.TokenEnv)
547 } else {
548 b.WriteString("# token_env = \"REASONIX_BOT_CONTROL_TOKEN\"\n")
549 }
550 if len(c.Bot.Routes) > 0 {
551 for _, route := range c.Bot.Routes {
552 b.WriteString("\n[[bot.routes]]\n")
553 renderBotRoute(&b, route)
554 }
555 }
556 if len(c.Bot.DesktopWatchers) > 0 {
557 for _, watcher := range c.Bot.DesktopWatchers {
558 b.WriteString("\n[[bot.desktop_watchers]]\n")
559 renderBotDesktopWatcher(&b, watcher)
560 }
561 }
562 b.WriteString("\n[bot.pairing]\n")
563 fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Pairing.Enabled)
564 if c.Bot.Pairing.RequestTTLMinutes > 0 {
565 fmt.Fprintf(&b, "request_ttl_minutes = %d\n", c.Bot.Pairing.RequestTTLMinutes)
566 } else {
567 b.WriteString("# request_ttl_minutes = 60\n")
568 }
569 if c.Bot.Pairing.MaxPendingPerPlatform > 0 {
570 fmt.Fprintf(&b, "max_pending_per_platform = %d\n", c.Bot.Pairing.MaxPendingPerPlatform)
571 } else {
572 b.WriteString("# max_pending_per_platform = 3\n")
573 }
574 b.WriteString("\n[bot.allowlist]\n")
575 fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Allowlist.Enabled)
576 fmt.Fprintf(&b, "allow_all = %v\n", c.Bot.Allowlist.AllowAll)
577 fmt.Fprintf(&b, "qq_users = %s\n", renderStringArray(c.Bot.Allowlist.QQUsers))
578 fmt.Fprintf(&b, "feishu_users = %s\n", renderStringArray(c.Bot.Allowlist.FeishuUsers))
579 fmt.Fprintf(&b, "weixin_users = %s\n", renderStringArray(c.Bot.Allowlist.WeixinUsers))
580 fmt.Fprintf(&b, "qq_approvers = %s\n", renderStringArray(c.Bot.Allowlist.QQApprovers))
581 fmt.Fprintf(&b, "feishu_approvers = %s\n", renderStringArray(c.Bot.Allowlist.FeishuApprovers))
582 fmt.Fprintf(&b, "weixin_approvers = %s\n", renderStringArray(c.Bot.Allowlist.WeixinApprovers))
583 fmt.Fprintf(&b, "qq_admins = %s\n", renderStringArray(c.Bot.Allowlist.QQAdmins))
584 fmt.Fprintf(&b, "feishu_admins = %s\n", renderStringArray(c.Bot.Allowlist.FeishuAdmins))
585 fmt.Fprintf(&b, "weixin_admins = %s\n", renderStringArray(c.Bot.Allowlist.WeixinAdmins))
586 fmt.Fprintf(&b, "qq_groups = %s\n", renderStringArray(c.Bot.Allowlist.QQGroups))
587 fmt.Fprintf(&b, "feishu_groups = %s\n", renderStringArray(c.Bot.Allowlist.FeishuGroups))
588 fmt.Fprintf(&b, "weixin_groups = %s\n", renderStringArray(c.Bot.Allowlist.WeixinGroups))
589 b.WriteString("\n[bot.qq]\n")
590 fmt.Fprintf(&b, "enabled = %v\n", c.Bot.QQ.Enabled)
591 fmt.Fprintf(&b, "app_id = %q\n", c.Bot.QQ.AppID)
592 fmt.Fprintf(&b, "app_secret_env = %q\n", c.Bot.QQ.AppSecretEnv)
593 fmt.Fprintf(&b, "sandbox = %v\n", c.Bot.QQ.Sandbox)
594 if strings.TrimSpace(c.Bot.QQ.Model) != "" {
595 fmt.Fprintf(&b, "model = %q\n", strings.TrimSpace(c.Bot.QQ.Model))
596 }
597 if strings.TrimSpace(c.Bot.QQ.ToolApprovalMode) != "" {
598 fmt.Fprintf(&b, "tool_approval_mode = %q\n", strings.TrimSpace(c.Bot.QQ.ToolApprovalMode))
599 }
600 if strings.TrimSpace(c.Bot.QQ.WorkspaceRoot) != "" {
601 fmt.Fprintf(&b, "workspace_root = %q\n", strings.TrimSpace(c.Bot.QQ.WorkspaceRoot))
602 }
603 if parts := renderBotAccess(c.Bot.QQ.Access); parts != "" {
604 fmt.Fprintf(&b, "access = %s\n", parts)
605 }
606 b.WriteString("\n[bot.feishu]\n")
607 fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Feishu.Enabled)
608 fmt.Fprintf(&b, "app_id = %q\n", c.Bot.Feishu.AppID)
609 fmt.Fprintf(&b, "domain = %q\n", c.Bot.Feishu.Domain)
610 fmt.Fprintf(&b, "app_secret_env = %q\n", c.Bot.Feishu.AppSecretEnv)
611 fmt.Fprintf(&b, "verification_token = %q\n", c.Bot.Feishu.VerificationToken)
612 fmt.Fprintf(&b, "mode = %q\n", c.Bot.Feishu.Mode)
613 fmt.Fprintf(&b, "webhook_port = %d\n", c.Bot.Feishu.WebhookPort)
614 fmt.Fprintf(&b, "require_mention = %v\n", c.Bot.Feishu.RequireMention)
615 if len(c.Bot.Feishu.OutboundMediaRoots) > 0 {
616 fmt.Fprintf(&b, "outbound_media_roots = %s\n", renderStringArray(c.Bot.Feishu.OutboundMediaRoots))
617 }
618 b.WriteString("\n[bot.weixin]\n")
619 fmt.Fprintf(&b, "enabled = %v\n", c.Bot.Weixin.Enabled)
620 fmt.Fprintf(&b, "account_id = %q\n", c.Bot.Weixin.AccountID)
621 fmt.Fprintf(&b, "token_env = %q\n", c.Bot.Weixin.TokenEnv)
622 fmt.Fprintf(&b, "api_base = %q\n", c.Bot.Weixin.APIBase)
623 for _, conn := range c.Bot.Connections {
624 b.WriteString("\n[[bot.connections]]\n")
625 fmt.Fprintf(&b, "id = %q\n", conn.ID)
626 fmt.Fprintf(&b, "provider = %q\n", conn.Provider)
627 fmt.Fprintf(&b, "domain = %q\n", conn.Domain)
628 fmt.Fprintf(&b, "label = %q\n", conn.Label)
629 fmt.Fprintf(&b, "enabled = %v\n", conn.Enabled)
630 fmt.Fprintf(&b, "status = %q\n", conn.Status)
631 if conn.Model != "" {
632 fmt.Fprintf(&b, "model = %q\n", conn.Model)
633 }
634 if conn.ToolApprovalMode != "" {
635 fmt.Fprintf(&b, "tool_approval_mode = %q\n", conn.ToolApprovalMode)
636 }
637 if conn.WorkspaceRoot != "" {
638 fmt.Fprintf(&b, "workspace_root = %q\n", conn.WorkspaceRoot)
639 }
640 if parts := renderBotAccess(conn.Access); parts != "" {
641 fmt.Fprintf(&b, "access = %s\n", parts)
642 }
643 if conn.LastError != "" {
644 fmt.Fprintf(&b, "last_error = %q\n", conn.LastError)
645 }
646 if conn.CreatedAt != "" {
647 fmt.Fprintf(&b, "created_at = %q\n", conn.CreatedAt)
648 }
649 if conn.UpdatedAt != "" {
650 fmt.Fprintf(&b, "updated_at = %q\n", conn.UpdatedAt)
651 }
652 if parts := renderBotCredential(conn.Credential); parts != "" {
653 fmt.Fprintf(&b, "credential = %s\n", parts)
654 }
655 if len(conn.SessionMappings) > 0 {
656 fmt.Fprintf(&b, "session_mappings = %s\n", renderBotSessionMappings(conn.SessionMappings))
657 }
658 }
659 b.WriteString("\n")
660 }
661
662 // [secrets] is user/global only: LoadForRoot discards project values, so
663 // the project scope never renders it. Rendering it here is what lets a
664 // user's saved toggles survive config rewrites (WriteFile re-renders the
665 // whole file from the struct).
666 if scope != RenderScopeProject {
667 b.WriteString("[secrets] # credential protection; user/global only, ./reasonix.toml cannot override\n")
668 if c.Secrets.FilterSubprocessEnv {
669 b.WriteString("filter_subprocess_env = true # strip credential-named env vars from tool/hook/LSP/MCP subprocesses\n")
670 } else {
671 b.WriteString("# filter_subprocess_env = false # opt-in; stripping tokens breaks gh, HTTPS git push, npm publish\n")
672 }
673 if c.Secrets.ProtectSensitiveFiles {
674 b.WriteString("protect_sensitive_files = true # hide .env/.git-credentials/key files/~/.ssh from read tools\n")
675 } else {
676 b.WriteString("# protect_sensitive_files = false # opt-in; hiding credential files can break legitimate edit workflows\n")
677 }
678 b.WriteString("\n")
679 }
680
681 // [remote] is user/global only like [secrets]: LoadForRoot discards project
682 // values so a cloned repo can never inject SSH hosts. Rendered here so
683 // saved hosts survive full-file config rewrites.
684 if scope != RenderScopeProject && (c.Remote.ImportSSHConfig || len(c.Remote.Hosts) > 0) {
685 b.WriteString("[remote] # SSH remote hosts; user/global only, ./reasonix.toml cannot override\n")
686 if c.Remote.ImportSSHConfig {
687 b.WriteString("import_ssh_config = true # surface ~/.ssh/config aliases in `reasonix remote import`\n")
688 }
689 for _, h := range c.Remote.Hosts {
690 b.WriteString("\n[[remote.hosts]]\n")
691 fmt.Fprintf(&b, "name = %q\n", h.Name)
692 fmt.Fprintf(&b, "host = %q\n", h.Host)
693 if h.Port > 0 {
694 fmt.Fprintf(&b, "port = %d\n", h.Port)
695 }
696 if h.User != "" {
697 fmt.Fprintf(&b, "user = %q\n", h.User)
698 }
699 if h.IdentityFile != "" {
700 fmt.Fprintf(&b, "identity_file = %q # key file path; Reasonix never stores key material\n", h.IdentityFile)
701 }
702 if h.PassphraseEnv != "" {
703 fmt.Fprintf(&b, "passphrase_env = %q # env var name; value lives in Reasonix's global .env\n", h.PassphraseEnv)
704 }
705 if h.PasswordEnv != "" {
706 fmt.Fprintf(&b, "password_env = %q # env var name; value lives in Reasonix's global .env\n", h.PasswordEnv)
707 }
708 if h.ProxyJump != "" {
709 fmt.Fprintf(&b, "proxy_jump = %q # OpenSSH ProxyJump chain\n", h.ProxyJump)
710 }
711 if h.Workspace != "" {
712 fmt.Fprintf(&b, "workspace = %q # default remote workspace dir\n", h.Workspace)
713 }
714 if h.ServeInstall != "" {
715 fmt.Fprintf(&b, "serve_install = %q # auto|npm|upload|never\n", h.ServeInstall)
716 }
717 if h.UseSSHConfig {
718 b.WriteString("use_ssh_config = true # layer ~/.ssh/config values under unset fields\n")
719 }
720 for _, f := range h.Forwards {
721 b.WriteString("\n[[remote.hosts.forwards]]\n")
722 fmt.Fprintf(&b, "type = %q # local (-L) | remote (-R)\n", f.Type)
723 fmt.Fprintf(&b, "bind = %q\n", f.Bind)
724 fmt.Fprintf(&b, "target = %q\n", f.Target)
725 }
726 }
727 b.WriteString("\n")
728 }
729
730 b.WriteString("# External MCP servers. type: \"stdio\" (default, a subprocess) | \"http\" | \"sse\".\n")
731 b.WriteString("# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers.\n")
732 plugins := tomlPluginsForScope(c.Plugins, scope)
733 if len(plugins) == 0 {
734 b.WriteString("# [[plugins]]\n")
735 b.WriteString("# name = \"example\"\n")
736 b.WriteString("# command = \"reasonix-plugin-example\"\n")
737 b.WriteString("# startup_timeout_seconds = 60 # optional initialize + tools/list cap\n")
738 b.WriteString("# call_timeout_seconds = 600 # optional per-server MCP call timeout\n")
739 b.WriteString("# tool_timeout_seconds = { \"generate_video\" = 1800 } # raw MCP tool names\n")
740 b.WriteString("# [[plugins]] # a remote server over Streamable HTTP\n")
741 b.WriteString("# name = \"stripe\"\n")
742 b.WriteString("# type = \"http\"\n")
743 b.WriteString("# url = \"https://mcp.stripe.com\"\n")
744 b.WriteString("# headers = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n")
745 } else {
746 for _, pl := range plugins {
747 b.WriteString("\n[[plugins]]\n")
748 fmt.Fprintf(&b, "name = %q\n", pl.Name)
749 if pl.Type != "" {
750 fmt.Fprintf(&b, "type = %q\n", pl.Type)
751 }
752 if pl.Command != "" {
753 fmt.Fprintf(&b, "command = %q\n", pl.Command)
754 }
755 if len(pl.Args) > 0 {
756 fmt.Fprintf(&b, "args = %s\n", renderStringArray(pl.Args))
757 }
758 if pl.URL != "" {
759 fmt.Fprintf(&b, "url = %q\n", pl.URL)
760 }
761 if len(pl.Headers) > 0 {
762 fmt.Fprintf(&b, "headers = %s\n", renderStringMap(pl.Headers))
763 }
764 if len(pl.Env) > 0 {
765 fmt.Fprintf(&b, "env = %s\n", renderStringMap(pl.Env))
766 }
767 if pl.StartupTimeoutSeconds > 0 {
768 b.WriteString("# Per-server MCP initialize + tools/list timeout; 0 keeps the global/default cap.\n")
769 fmt.Fprintf(&b, "startup_timeout_seconds = %d\n", pl.StartupTimeoutSeconds)
770 }
771 if pl.CallTimeoutSeconds > 0 {
772 b.WriteString("# Per-server MCP call timeout; 0 keeps the global/default cap.\n")
773 fmt.Fprintf(&b, "call_timeout_seconds = %d\n", pl.CallTimeoutSeconds)
774 }
775 if hasPositiveIntMap(pl.ToolTimeoutSeconds) {
776 b.WriteString("# Raw MCP tool names with per-tool call timeouts.\n")
777 fmt.Fprintf(&b, "tool_timeout_seconds = %s\n", renderIntMap(pl.ToolTimeoutSeconds))
778 }
779 if pl.AutoStart != nil {
780 fmt.Fprintf(&b, "auto_start = %v\n", *pl.AutoStart)
781 }
782 }
783 }
784
785 return b.String()
786 }
787
788 // tomlPluginsForScope keeps merged runtime entries in their owning config
789 // source. Unknown provenance is retained for callers that construct a Config
790 // directly before saving it to a specific target.
791 func tomlPluginsForScope(plugins []PluginEntry, scope RenderScope) []PluginEntry {
792 if scope == RenderScopeFull {
793 return plugins
794 }
795 out := make([]PluginEntry, 0, len(plugins))
796 for _, pl := range plugins {
797 switch pl.Source {
798 case MCPSourceUnknown:
799 out = append(out, pl)
800 case MCPSourceUserConfig:
801 if scope == RenderScopeUser {
802 out = append(out, pl)
803 }
804 case MCPSourceProjectConfig:
805 if scope == RenderScopeProject {
806 out = append(out, pl)
807 }
808 }
809 }
810 return out
811 }
812
813 // RenderTOMLProjectDelta generates TOML containing only the sections and fields
814 // that differ from built-in defaults. Unlike RenderTOMLForScope (which renders
815 // the full config with comments), this emits clean TOML that can be surgically
816 // merged into an existing project config file via replaceTOMLSection.
817 func RenderTOMLProjectDelta(c *Config) string {
818 if c == nil {
819 return ""
820 }
821 d := Default()
822 var b strings.Builder
823
824 // Top-level scalar fields
825 if v := configVersion(c); v != d.ConfigVersion {
826 fmt.Fprintf(&b, "config_version = %d\n", v)
827 }
828 if c.DefaultModel != d.DefaultModel {
829 fmt.Fprintf(&b, "default_model = %q\n", c.DefaultModel)
830 }
831 if c.Language != "" && c.Language != d.Language {
832 fmt.Fprintf(&b, "language = %q\n", c.Language)
833 }
834
835 // [ui] section — whole-section comparison
836 if !reflect.DeepEqual(c.UI, d.UI) {
837 b.WriteString("[ui]\n")
838 if c.UI.Theme != d.UI.Theme {
839 fmt.Fprintf(&b, "theme = %q\n", c.UITheme())
840 }
841 if s := c.UIThemeStyle(); s != "" && s != d.UIThemeStyle() {
842 fmt.Fprintf(&b, "theme_style = %q\n", s)
843 }
844 if l := c.UIShortcutLayout(); l != "classic" {
845 fmt.Fprintf(&b, "shortcut_layout = %q\n", l)
846 }
847 if strings.TrimSpace(c.UI.CursorShape) != "" {
848 fmt.Fprintf(&b, "cursor_shape = %q\n", c.UICursorShape())
849 }
850 if c.UI.CloseBehavior != d.UI.CloseBehavior {
851 fmt.Fprintf(&b, "close_behavior = %q\n", c.DesktopCloseBehavior())
852 }
853 if c.UI.ShowReasoning != d.UI.ShowReasoning {
854 fmt.Fprintf(&b, "show_reasoning = %v\n", c.UI.ShowReasoning)
855 }
856 if c.UI.ShowTurnUsage != d.UI.ShowTurnUsage {
857 fmt.Fprintf(&b, "show_turn_usage = %v\n", c.UI.ShowTurnUsage)
858 }
859 b.WriteString("\n")
860 }
861
862 // [network] section
863 if !reflect.DeepEqual(c.Network, d.Network) {
864 b.WriteString("[network]\n")
865 if c.Network.ProxyMode != d.Network.ProxyMode {
866 fmt.Fprintf(&b, "proxy_mode = %q\n", c.NetworkProxyMode())
867 }
868 if c.Network.ProxyURL != "" {
869 fmt.Fprintf(&b, "proxy_url = %q\n", c.Network.ProxyURL)
870 }
871 if c.Network.NoProxy != "" {
872 fmt.Fprintf(&b, "no_proxy = %q\n", c.Network.NoProxy)
873 }
874 if c.Network.Proxy.Type != "" || c.Network.Proxy.Server != "" || c.Network.Proxy.Port > 0 || c.Network.Proxy.Username != "" || c.Network.Proxy.Password != "" {
875 b.WriteString("[network.proxy]\n")
876 pt := c.Network.Proxy.Type
877 if pt == "" {
878 pt = "socks5"
879 }
880 fmt.Fprintf(&b, "type = %q\n", pt)
881 if c.Network.Proxy.Server != "" {
882 fmt.Fprintf(&b, "server = %q\n", c.Network.Proxy.Server)
883 }
884 if c.Network.Proxy.Port > 0 {
885 fmt.Fprintf(&b, "port = %d\n", c.Network.Proxy.Port)
886 }
887 if c.Network.Proxy.Username != "" {
888 fmt.Fprintf(&b, "username = %q\n", c.Network.Proxy.Username)
889 }
890 if c.Network.Proxy.Password != "" {
891 fmt.Fprintf(&b, "password = %q\n", c.Network.Proxy.Password)
892 }
893 }
894 b.WriteString("\n")
895 }
896
897 // [agent] section — per-field comparison
898 var agentBuf strings.Builder
899 anyAgent := false
900
901 if sp := strings.TrimSpace(c.Agent.SystemPrompt); sp != "" && sp != d.Agent.SystemPrompt {
902 agentBuf.WriteString("system_prompt = \"\"\"\n")
903 agentBuf.WriteString(sp)
904 agentBuf.WriteString("\"\"\"\n")
905 anyAgent = true
906 }
907 if c.Agent.SystemPromptFile != "" && c.Agent.SystemPromptFile != d.Agent.SystemPromptFile {
908 fmt.Fprintf(&agentBuf, "system_prompt_file = %q\n", c.Agent.SystemPromptFile)
909 anyAgent = true
910 }
911 if c.Agent.Temperature != d.Agent.Temperature {
912 fmt.Fprintf(&agentBuf, "temperature = %s\n", formatFloat(c.Agent.Temperature))
913 anyAgent = true
914 }
915 if c.Agent.RecoveryModel != "" && c.Agent.RecoveryModel != d.Agent.RecoveryModel {
916 fmt.Fprintf(&agentBuf, "recovery_model = %q\n", c.Agent.RecoveryModel)
917 anyAgent = true
918 }
919 if c.Agent.ReasoningLanguage != d.Agent.ReasoningLanguage {
920 if l := c.ReasoningLanguage(); l != "auto" {
921 fmt.Fprintf(&agentBuf, "reasoning_language = %q\n", l)
922 anyAgent = true
923 }
924 }
925 if c.Agent.SoftCompactRatio != d.Agent.SoftCompactRatio {
926 fmt.Fprintf(&agentBuf, "soft_compact_ratio = %s\n", formatFloat(c.Agent.SoftCompactRatio))
927 anyAgent = true
928 }
929 if c.Agent.ToolResultSnipRatio != d.Agent.ToolResultSnipRatio {
930 fmt.Fprintf(&agentBuf, "tool_result_snip_ratio = %s\n", formatFloat(c.Agent.ToolResultSnipRatio))
931 anyAgent = true
932 }
933 if c.Agent.CompactRatio != d.Agent.CompactRatio {
934 fmt.Fprintf(&agentBuf, "compact_ratio = %s\n", formatFloat(c.Agent.CompactRatio))
935 anyAgent = true
936 }
937 if c.Agent.CompactForceRatio != d.Agent.CompactForceRatio {
938 fmt.Fprintf(&agentBuf, "compact_force_ratio = %s\n", formatFloat(c.Agent.CompactForceRatio))
939 anyAgent = true
940 }
941 if c.Agent.Keep != nil && !reflect.DeepEqual(c.Agent.Keep, d.Agent.Keep) {
942 fmt.Fprintf(&agentBuf, "keep = %s\n", renderStringArray(c.Agent.Keep))
943 anyAgent = true
944 }
945 if c.Agent.RecentKeep > 0 && c.Agent.RecentKeep != d.Agent.RecentKeep {
946 fmt.Fprintf(&agentBuf, "recent_keep = %d\n", c.Agent.RecentKeep)
947 anyAgent = true
948 }
949 if c.Agent.ColdResumePrune != d.Agent.ColdResumePrune {
950 fmt.Fprintf(&agentBuf, "cold_resume_prune = %v\n", c.ColdResumePruneEnabled())
951 anyAgent = true
952 }
953 if len(c.Agent.PlanModeReadOnlyCommands) > 0 && !reflect.DeepEqual(c.Agent.PlanModeReadOnlyCommands, d.Agent.PlanModeReadOnlyCommands) {
954 fmt.Fprintf(&agentBuf, "plan_mode_read_only_commands = %s\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands))
955 anyAgent = true
956 }
957 if c.Agent.PlannerModel != "" && c.Agent.PlannerModel != d.Agent.PlannerModel {
958 fmt.Fprintf(&agentBuf, "planner_model = %q\n", c.Agent.PlannerModel)
959 anyAgent = true
960 }
961 if c.Agent.SubagentModel != "" && c.Agent.SubagentModel != d.Agent.SubagentModel {
962 fmt.Fprintf(&agentBuf, "subagent_model = %q\n", c.Agent.SubagentModel)
963 anyAgent = true
964 }
965 if len(c.Agent.SubagentModels) > 0 && !reflect.DeepEqual(c.Agent.SubagentModels, d.Agent.SubagentModels) {
966 fmt.Fprintf(&agentBuf, "subagent_models = %s\n", renderStringMap(c.Agent.SubagentModels))
967 anyAgent = true
968 }
969 if c.Agent.SubagentEffort != "" && c.Agent.SubagentEffort != d.Agent.SubagentEffort {
970 fmt.Fprintf(&agentBuf, "subagent_effort = %q\n", c.Agent.SubagentEffort)
971 anyAgent = true
972 }
973 if len(c.Agent.SubagentEfforts) > 0 && !reflect.DeepEqual(c.Agent.SubagentEfforts, d.Agent.SubagentEfforts) {
974 fmt.Fprintf(&agentBuf, "subagent_efforts = %s\n", renderStringMap(c.Agent.SubagentEfforts))
975 anyAgent = true
976 }
977 if c.Agent.MaxSubagentDepth != d.Agent.MaxSubagentDepth {
978 fmt.Fprintf(&agentBuf, "max_subagent_depth = %d\n", c.Agent.MaxSubagentDepth)
979 anyAgent = true
980 }
981 if c.Agent.OutputStyle != "" && c.Agent.OutputStyle != d.Agent.OutputStyle {
982 fmt.Fprintf(&agentBuf, "output_style = %q\n", c.Agent.OutputStyle)
983 anyAgent = true
984 }
985
986 if anyAgent {
987 b.WriteString("[agent]\n")
988 b.WriteString(agentBuf.String())
989 b.WriteString("\n")
990 }
991
992 // [[providers]] — include user-defined providers that aren't built-in
993 proj := projectScopedConfigForRender(c)
994 if proj != nil && len(proj.Providers) > 0 && !reflect.DeepEqual(proj.Providers, d.Providers) {
995 for _, p := range proj.Providers {
996 b.WriteString("[[providers]]\n")
997 fmt.Fprintf(&b, "name = %q\n", p.Name)
998 fmt.Fprintf(&b, "kind = %q\n", p.Kind)
999 fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL)
1000 if p.ChatURL != "" {
1001 fmt.Fprintf(&b, "chat_url = %q\n", p.ChatURL)
1002 }
1003 if len(p.Models) > 0 {
1004 fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models))
1005 if p.Default != "" {
1006 fmt.Fprintf(&b, "default = %q\n", p.Default)
1007 }
1008 } else if p.Model != "" {
1009 fmt.Fprintf(&b, "model = %q\n", p.Model)
1010 }
1011 if p.ModelsURL != "" {
1012 fmt.Fprintf(&b, "models_url = %q\n", p.ModelsURL)
1013 }
1014 fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
1015 if p.PresetID != "" {
1016 fmt.Fprintf(&b, "preset_id = %q\n", p.PresetID)
1017 }
1018 if p.PresetVersion > 0 {
1019 fmt.Fprintf(&b, "preset_version = %d\n", p.PresetVersion)
1020 }
1021 if len(p.Headers) > 0 {
1022 fmt.Fprintf(&b, "headers = %s\n", renderStringMap(p.Headers))
1023 }
1024 if len(p.ExtraBody) > 0 {
1025 fmt.Fprintf(&b, "extra_body = %s\n", renderAnyMap(p.ExtraBody))
1026 }
1027 if p.AuthHeader {
1028 b.WriteString("auth_header = true\n")
1029 }
1030 if p.ResponsesMode != "" {
1031 fmt.Fprintf(&b, "responses_mode = %q\n", p.ResponsesMode)
1032 }
1033 if p.ResponsesStateful != nil {
1034 fmt.Fprintf(&b, "responses_stateful = %t\n", *p.ResponsesStateful)
1035 }
1036 if p.BalanceURL != "" {
1037 fmt.Fprintf(&b, "balance_url = %q\n", p.BalanceURL)
1038 }
1039 if p.ContextWindow > 0 {
1040 fmt.Fprintf(&b, "context_window = %d\n", p.ContextWindow)
1041 }
1042 if p.MaxOutputTokens != 0 {
1043 fmt.Fprintf(&b, "max_output_tokens = %d\n", p.MaxOutputTokens)
1044 }
1045 if p.Price != nil {
1046 fmt.Fprintf(&b, "price = %s\n", renderPricingInline(p.Price))
1047 }
1048 if len(p.Prices) > 0 {
1049 fmt.Fprintf(&b, "prices = %s\n", renderPricingMap(p.Prices))
1050 }
1051 if p.Thinking != "" {
1052 fmt.Fprintf(&b, "thinking = %q\n", p.Thinking)
1053 }
1054 if p.Effort != "" {
1055 fmt.Fprintf(&b, "effort = %q\n", p.Effort)
1056 }
1057 if p.Vision {
1058 b.WriteString("vision = true\n")
1059 }
1060 if p.VisionModels != nil {
1061 fmt.Fprintf(&b, "vision_models = %s\n", renderStringArray(p.VisionModels))
1062 }
1063 if p.VisionDetail != "" {
1064 fmt.Fprintf(&b, "vision_detail = %q\n", p.VisionDetail)
1065 }
1066 if p.WebSearch != nil {
1067 fmt.Fprintf(&b, "web_search = %t\n", *p.WebSearch)
1068 }
1069 if p.ReasoningProtocol != "" {
1070 fmt.Fprintf(&b, "reasoning_protocol = %q\n", p.ReasoningProtocol)
1071 }
1072 if len(p.SupportedEfforts) > 0 {
1073 fmt.Fprintf(&b, "supported_efforts = %s\n", renderStringArray(p.SupportedEfforts))
1074 }
1075 if p.DefaultEffort != "" {
1076 fmt.Fprintf(&b, "default_effort = %q\n", p.DefaultEffort)
1077 }
1078 if len(p.ModelOverrides) > 0 {
1079 fmt.Fprintf(&b, "model_overrides = %s\n", renderModelOverrides(p.ModelOverrides))
1080 }
1081 if p.NoProxy {
1082 b.WriteString("no_proxy = true\n")
1083 }
1084 b.WriteString("\n")
1085 }
1086 }
1087
1088 // [tools]
1089 if len(c.Tools.Enabled) > 0 ||
1090 (c.Tools.BashTimeoutSeconds != nil && *c.Tools.BashTimeoutSeconds != 0) ||
1091 (c.Tools.MCPStartupTimeoutSeconds != nil && *c.Tools.MCPStartupTimeoutSeconds > 0) ||
1092 (c.Tools.MCPCallTimeoutSeconds != nil && *c.Tools.MCPCallTimeoutSeconds > 0) {
1093 b.WriteString("[tools]\n")
1094 if len(c.Tools.Enabled) > 0 {
1095 fmt.Fprintf(&b, "enabled = %s\n", renderStringArray(c.Tools.Enabled))
1096 }
1097 if c.Tools.BashTimeoutSeconds != nil && *c.Tools.BashTimeoutSeconds != 0 {
1098 fmt.Fprintf(&b, "bash_timeout_seconds = %d\n", *c.Tools.BashTimeoutSeconds)
1099 }
1100 if c.Tools.MCPStartupTimeoutSeconds != nil && *c.Tools.MCPStartupTimeoutSeconds > 0 {
1101 fmt.Fprintf(&b, "mcp_startup_timeout_seconds = %d\n", *c.Tools.MCPStartupTimeoutSeconds)
1102 }
1103 if c.Tools.MCPCallTimeoutSeconds != nil && *c.Tools.MCPCallTimeoutSeconds > 0 {
1104 fmt.Fprintf(&b, "mcp_call_timeout_seconds = %d\n", *c.Tools.MCPCallTimeoutSeconds)
1105 }
1106 b.WriteString("\n")
1107 }
1108
1109 // [tools.background_jobs]
1110 if c.Tools.BackgroundJobs != d.Tools.BackgroundJobs {
1111 if c.Tools.BackgroundJobs.StalledWarningSeconds != nil && *c.Tools.BackgroundJobs.StalledWarningSeconds > 0 {
1112 b.WriteString("[tools.background_jobs]\n")
1113 fmt.Fprintf(&b, "stalled_warning_seconds = %d\n", *c.Tools.BackgroundJobs.StalledWarningSeconds)
1114 b.WriteString("\n")
1115 }
1116 }
1117
1118 // [tools.shell]
1119 if !reflect.DeepEqual(c.Tools.Shell, d.Tools.Shell) {
1120 b.WriteString("[tools.shell]\n")
1121 if c.Tools.Shell.Prefer != d.Tools.Shell.Prefer {
1122 fmt.Fprintf(&b, "prefer = %q\n", c.Tools.Shell.Prefer)
1123 }
1124 if c.Tools.Shell.Path != d.Tools.Shell.Path {
1125 fmt.Fprintf(&b, "path = %q\n", c.Tools.Shell.Path)
1126 }
1127 b.WriteString("\n")
1128 }
1129
1130 // [lsp]
1131 if !reflect.DeepEqual(c.LSP, d.LSP) {
1132 renderLSPConfig(&b, c.LSP)
1133 }
1134
1135 // [skills]
1136 if !reflect.DeepEqual(c.Skills, d.Skills) {
1137 b.WriteString("[skills]\n")
1138 if len(c.Skills.Paths) > 0 {
1139 fmt.Fprintf(&b, "paths = %s\n", renderStringArray(c.Skills.Paths))
1140 }
1141 if len(c.Skills.ExcludedPaths) > 0 {
1142 fmt.Fprintf(&b, "excluded_paths = %s\n", renderStringArray(c.Skills.ExcludedPaths))
1143 }
1144 if c.Skills.MaxDepth != 0 {
1145 fmt.Fprintf(&b, "max_depth = %d\n", c.SkillMaxDepth())
1146 }
1147 if disabled := c.DisabledSkillNames(); len(disabled) > 0 {
1148 fmt.Fprintf(&b, "disabled_skills = %s\n\n", renderStringArray(disabled))
1149 }
1150 }
1151
1152 // [permissions]
1153 if !reflect.DeepEqual(c.Permissions, d.Permissions) {
1154 b.WriteString("[permissions]\n")
1155 mode := c.Permissions.Mode
1156 if mode == "" {
1157 mode = "ask"
1158 }
1159 if mode != "ask" {
1160 fmt.Fprintf(&b, "mode = %q\n", mode)
1161 }
1162 if c.Permissions.AllowDynamicBash {
1163 b.WriteString("allow_dynamic_bash = true\n")
1164 }
1165 if len(c.Permissions.Deny) > 0 {
1166 fmt.Fprintf(&b, "deny = %s\n", renderStringArray(c.Permissions.Deny))
1167 }
1168 if len(c.Permissions.Allow) > 0 {
1169 fmt.Fprintf(&b, "allow = %s\n", renderStringArray(c.Permissions.Allow))
1170 }
1171 if len(c.Permissions.Ask) > 0 {
1172 fmt.Fprintf(&b, "ask = %s\n", renderStringArray(c.Permissions.Ask))
1173 }
1174 b.WriteString("\n")
1175 }
1176
1177 // [sandbox]
1178 if !reflect.DeepEqual(c.Sandbox, d.Sandbox) {
1179 var sandboxBuf strings.Builder
1180 if c.Sandbox.WorkspaceRoot != "" {
1181 fmt.Fprintf(&sandboxBuf, "workspace_root = %q\n", c.Sandbox.WorkspaceRoot)
1182 }
1183 if len(c.Sandbox.AllowWrite) > 0 {
1184 fmt.Fprintf(&sandboxBuf, "allow_write = %s\n", renderStringArray(c.Sandbox.AllowWrite))
1185 }
1186 // Only persist a bash mode when its effective value differs from the
1187 // platform default. On Windows, even explicit "enforce" currently
1188 // resolves to "off", so project configs should not imply otherwise.
1189 if strings.TrimSpace(c.Sandbox.Bash) != "" && c.BashMode() != d.BashModeForGOOS(runtimeGOOS) {
1190 fmt.Fprintf(&sandboxBuf, "bash = %q\n", c.BashMode())
1191 }
1192 if c.Sandbox.Network != d.Sandbox.Network {
1193 fmt.Fprintf(&sandboxBuf, "network = %v\n", c.Sandbox.Network)
1194 }
1195 if sandboxBuf.Len() > 0 {
1196 b.WriteString("[sandbox]\n")
1197 b.WriteString(sandboxBuf.String())
1198 b.WriteString("\n")
1199 }
1200 }
1201
1202 // [statusline]
1203 if !reflect.DeepEqual(c.Statusline, d.Statusline) {
1204 b.WriteString("[statusline]\n")
1205 if c.Statusline.Command != "" {
1206 fmt.Fprintf(&b, "command = %q\n", c.Statusline.Command)
1207 }
1208 b.WriteString("\n")
1209 }
1210
1211 // [[plugins]] — always include when set; replaces all existing entries
1212 for _, pl := range tomlPluginsForScope(c.Plugins, RenderScopeProject) {
1213 b.WriteString("[[plugins]]\n")
1214 fmt.Fprintf(&b, "name = %q\n", pl.Name)
1215 if pl.Type != "" {
1216 fmt.Fprintf(&b, "type = %q\n", pl.Type)
1217 }
1218 if pl.Command != "" {
1219 fmt.Fprintf(&b, "command = %q\n", pl.Command)
1220 }
1221 if len(pl.Args) > 0 {
1222 fmt.Fprintf(&b, "args = %s\n", renderStringArray(pl.Args))
1223 }
1224 if pl.URL != "" {
1225 fmt.Fprintf(&b, "url = %q\n", pl.URL)
1226 }
1227 if len(pl.Headers) > 0 {
1228 fmt.Fprintf(&b, "headers = %s\n", renderStringMap(pl.Headers))
1229 }
1230 if len(pl.Env) > 0 {
1231 fmt.Fprintf(&b, "env = %s\n", renderStringMap(pl.Env))
1232 }
1233 if pl.StartupTimeoutSeconds > 0 {
1234 fmt.Fprintf(&b, "startup_timeout_seconds = %d\n", pl.StartupTimeoutSeconds)
1235 }
1236 if pl.CallTimeoutSeconds > 0 {
1237 b.WriteString("# Per-server MCP call timeout; 0 keeps the global/default cap.\n")
1238 fmt.Fprintf(&b, "call_timeout_seconds = %d\n", pl.CallTimeoutSeconds)
1239 }
1240 if hasPositiveIntMap(pl.ToolTimeoutSeconds) {
1241 b.WriteString("# Raw MCP tool names with per-tool call timeouts.\n")
1242 fmt.Fprintf(&b, "tool_timeout_seconds = %s\n", renderIntMap(pl.ToolTimeoutSeconds))
1243 }
1244 if pl.AutoStart != nil {
1245 fmt.Fprintf(&b, "auto_start = %v\n", *pl.AutoStart)
1246 }
1247 b.WriteString("\n")
1248 }
1249
1250 return b.String()
1251 }
1252
1253 func renderPricingInline(p *provider.Pricing) string {
1254 if p == nil {
1255 return "{}"
1256 }
1257 return fmt.Sprintf("{ cache_hit = %v, input = %v, output = %v, currency = %q }",
1258 p.CacheHit, p.Input, p.Output, p.Symbol())
1259 }
1260
1261 func renderPricingMap(prices map[string]*provider.Pricing) string {
1262 if len(prices) == 0 {
1263 return "{}"
1264 }
1265 keys := make([]string, 0, len(prices))
1266 for model := range prices {
1267 if strings.TrimSpace(model) != "" && prices[model] != nil {
1268 keys = append(keys, model)
1269 }
1270 }
1271 if len(keys) == 0 {
1272 return "{}"
1273 }
1274 sort.Strings(keys)
1275 var b strings.Builder
1276 b.WriteString("{ ")
1277 for i, model := range keys {
1278 if i > 0 {
1279 b.WriteString(", ")
1280 }
1281 fmt.Fprintf(&b, "%s = %s", strconv.Quote(model), renderPricingInline(prices[model]))
1282 }
1283 b.WriteString(" }")
1284 return b.String()
1285 }
1286
1287 func configVersion(c *Config) int {
1288 if c != nil && c.ConfigVersion > 0 {
1289 return c.ConfigVersion
1290 }
1291 return Default().ConfigVersion
1292 }
1293
1294 func shouldRenderUI(c, defaults *Config, scope RenderScope) bool {
1295 if scope != RenderScopeProject {
1296 return true
1297 }
1298 return !reflect.DeepEqual(c.UI, defaults.UI)
1299 }
1300
1301 func shouldRenderNetwork(c, defaults *Config, scope RenderScope) bool {
1302 if scope != RenderScopeProject {
1303 return true
1304 }
1305 return !reflect.DeepEqual(c.Network, defaults.Network)
1306 }
1307
1308 func shouldRenderEnvironment(c, defaults *Config, scope RenderScope) bool {
1309 if scope != RenderScopeProject {
1310 return true
1311 }
1312 return !reflect.DeepEqual(c.Environment, defaults.Environment)
1313 }
1314
1315 func renderEnvironmentConfig(b *strings.Builder, cfg EnvironmentConfig) {
1316 b.WriteString("[environment]\n")
1317 enabled := true
1318 if cfg.Enabled != nil {
1319 enabled = *cfg.Enabled
1320 }
1321 fmt.Fprintf(b, "enabled = %v # inject a stable startup environment summary into the model prompt\n", enabled)
1322 if len(cfg.Tools) == 0 {
1323 b.WriteString("# [environment.tools]\n")
1324 b.WriteString("# go = \"/opt/homebrew/bin/go\" # trusted executable path; workspace-local paths are not auto-executed\n\n")
1325 return
1326 }
1327 b.WriteString("\n[environment.tools]\n")
1328 names := make([]string, 0, len(cfg.Tools))
1329 for name := range cfg.Tools {
1330 names = append(names, name)
1331 }
1332 sort.Strings(names)
1333 for _, name := range names {
1334 fmt.Fprintf(b, "%s = %q\n", renderTOMLKeyPart(name), cfg.Tools[name])
1335 }
1336 b.WriteString("\n")
1337 }
1338
1339 func shouldRenderProviders(c, defaults *Config, scope RenderScope) bool {
1340 if scope != RenderScopeProject {
1341 return true
1342 }
1343 return !reflect.DeepEqual(c.Providers, defaults.Providers)
1344 }
1345
1346 func projectScopedConfigForRender(c *Config) *Config {
1347 if c == nil || len(c.providerSources) == 0 {
1348 return c
1349 }
1350 cp := *c
1351 cp.Providers = make([]ProviderEntry, 0, len(c.Providers)+len(c.shadowedProjectProviders))
1352 for _, p := range c.Providers {
1353 if c.providerSources[providerMergeKey(p)] == providerSourceUser {
1354 continue
1355 }
1356 cp.Providers = append(cp.Providers, p)
1357 }
1358 cp.Providers = append(cp.Providers, c.shadowedProjectProviders...)
1359 return &cp
1360 }
1361
1362 func shouldRenderBot(c, defaults *Config, scope RenderScope) bool {
1363 if scope != RenderScopeProject {
1364 return true
1365 }
1366 return !reflect.DeepEqual(c.Bot, defaults.Bot)
1367 }
1368
1369 func shouldRenderSystemPrompt(c, defaults *Config, scope RenderScope) bool {
1370 if scope == RenderScopeFull {
1371 return true
1372 }
1373 return strings.TrimSpace(c.Agent.SystemPrompt) != "" && c.Agent.SystemPrompt != defaults.Agent.SystemPrompt
1374 }
1375
1376 func renderLSPConfig(b *strings.Builder, cfg LSPConfig) {
1377 b.WriteString("[lsp]\n")
1378 fmt.Fprintf(b, "enabled = %v # language server tools; servers launch lazily when used\n", cfg.Enabled)
1379 if len(cfg.Servers) == 0 {
1380 b.WriteString("# [lsp.servers.go]\n")
1381 b.WriteString("# command = \"gopls\"\n")
1382 b.WriteString("# args = []\n")
1383 b.WriteString("# extensions = [\".go\"]\n\n")
1384 return
1385 }
1386 b.WriteString("\n")
1387
1388 langs := make([]string, 0, len(cfg.Servers))
1389 for lang := range cfg.Servers {
1390 langs = append(langs, lang)
1391 }
1392 sort.Strings(langs)
1393 for _, lang := range langs {
1394 srv := cfg.Servers[lang]
1395 fmt.Fprintf(b, "[%s]\n", renderTOMLTablePath("lsp", "servers", lang))
1396 if srv.Command != "" {
1397 fmt.Fprintf(b, "command = %q\n", srv.Command)
1398 }
1399 if len(srv.Args) > 0 {
1400 fmt.Fprintf(b, "args = %s\n", renderStringArray(srv.Args))
1401 }
1402 if len(srv.Env) > 0 {
1403 fmt.Fprintf(b, "env = %s\n", renderStringMap(srv.Env))
1404 }
1405 if srv.LanguageID != "" {
1406 fmt.Fprintf(b, "language_id = %q\n", srv.LanguageID)
1407 }
1408 if len(srv.Extensions) > 0 {
1409 fmt.Fprintf(b, "extensions = %s\n", renderStringArray(srv.Extensions))
1410 }
1411 if srv.InstallHint != "" {
1412 fmt.Fprintf(b, "install_hint = %q\n", srv.InstallHint)
1413 }
1414 b.WriteString("\n")
1415 }
1416 }
1417
1418 func renderTOMLKeyPart(key string) string {
1419 if isBareTOMLKey(key) {
1420 return key
1421 }
1422 return strconv.Quote(key)
1423 }
1424
1425 func renderTOMLTablePath(parts ...string) string {
1426 rendered := make([]string, 0, len(parts))
1427 for _, part := range parts {
1428 rendered = append(rendered, renderTOMLKeyPart(part))
1429 }
1430 return strings.Join(rendered, ".")
1431 }
1432
1433 func isBareTOMLKey(key string) bool {
1434 if key == "" {
1435 return false
1436 }
1437 for _, r := range key {
1438 if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
1439 continue
1440 }
1441 return false
1442 }
1443 return true
1444 }
1445
1446 // renderStringArray renders a []string as a TOML inline array.
1447 func renderStringArray(ss []string) string {
1448 var b strings.Builder
1449 b.WriteByte('[')
1450 for i, s := range ss {
1451 if i > 0 {
1452 b.WriteString(", ")
1453 }
1454 fmt.Fprintf(&b, "%q", s)
1455 }
1456 b.WriteByte(']')
1457 return b.String()
1458 }
1459
1460 // renderStringMap renders a map[string]string as a TOML inline table with keys
1461 // in sorted order so output is deterministic (round-trips cleanly).
1462 func renderStringMap(m map[string]string) string {
1463 keys := make([]string, 0, len(m))
1464 for k := range m {
1465 keys = append(keys, k)
1466 }
1467 sort.Strings(keys)
1468 var b strings.Builder
1469 b.WriteString("{ ")
1470 for i, k := range keys {
1471 if i > 0 {
1472 b.WriteString(", ")
1473 }
1474 fmt.Fprintf(&b, "%s = %q", renderTOMLKeyPart(k), m[k])
1475 }
1476 b.WriteString(" }")
1477 return b.String()
1478 }
1479
1480 func renderAnyMap(m map[string]any) string {
1481 keys := make([]string, 0, len(m))
1482 for k, v := range m {
1483 if strings.TrimSpace(k) == "" {
1484 continue
1485 }
1486 if _, ok := renderAnyValue(v); ok {
1487 keys = append(keys, k)
1488 }
1489 }
1490 sort.Strings(keys)
1491 var b strings.Builder
1492 b.WriteString("{ ")
1493 for i, k := range keys {
1494 if i > 0 {
1495 b.WriteString(", ")
1496 }
1497 value, _ := renderAnyValue(m[k])
1498 fmt.Fprintf(&b, "%s = %s", strconv.Quote(k), value)
1499 }
1500 b.WriteString(" }")
1501 return b.String()
1502 }
1503
1504 func renderAnyValue(v any) (string, bool) {
1505 switch x := v.(type) {
1506 case nil:
1507 return "", false
1508 case string:
1509 return strconv.Quote(x), true
1510 case bool:
1511 if x {
1512 return "true", true
1513 }
1514 return "false", true
1515 case int:
1516 return strconv.Itoa(x), true
1517 case int8:
1518 return strconv.FormatInt(int64(x), 10), true
1519 case int16:
1520 return strconv.FormatInt(int64(x), 10), true
1521 case int32:
1522 return strconv.FormatInt(int64(x), 10), true
1523 case int64:
1524 return strconv.FormatInt(x, 10), true
1525 case uint:
1526 return strconv.FormatUint(uint64(x), 10), true
1527 case uint8:
1528 return strconv.FormatUint(uint64(x), 10), true
1529 case uint16:
1530 return strconv.FormatUint(uint64(x), 10), true
1531 case uint32:
1532 return strconv.FormatUint(uint64(x), 10), true
1533 case uint64:
1534 return strconv.FormatUint(x, 10), true
1535 case float32:
1536 return formatFloat(float64(x)), true
1537 case float64:
1538 return formatFloat(x), true
1539 case []any:
1540 parts := make([]string, 0, len(x))
1541 for _, item := range x {
1542 part, ok := renderAnyValue(item)
1543 if !ok {
1544 return "", false
1545 }
1546 parts = append(parts, part)
1547 }
1548 return "[" + strings.Join(parts, ", ") + "]", true
1549 case []string:
1550 return renderStringArray(x), true
1551 case map[string]any:
1552 return renderAnyMap(x), true
1553 case map[string]string:
1554 return renderStringMap(x), true
1555 default:
1556 return "", false
1557 }
1558 }
1559
1560 func renderModelOverrides(m map[string]ProviderModelOverride) string {
1561 keys := make([]string, 0, len(m))
1562 for k, ov := range m {
1563 if k == "" || modelOverrideEmpty(ov) {
1564 continue
1565 }
1566 keys = append(keys, k)
1567 }
1568 sort.Strings(keys)
1569 var b strings.Builder
1570 b.WriteString("{ ")
1571 for i, k := range keys {
1572 if i > 0 {
1573 b.WriteString(", ")
1574 }
1575 fmt.Fprintf(&b, "%q = %s", k, renderModelOverride(m[k]))
1576 }
1577 b.WriteString(" }")
1578 return b.String()
1579 }
1580
1581 func renderModelOverride(ov ProviderModelOverride) string {
1582 var parts []string
1583 if ov.ReasoningProtocol != "" {
1584 parts = append(parts, fmt.Sprintf("reasoning_protocol = %q", ov.ReasoningProtocol))
1585 }
1586 if len(ov.SupportedEfforts) > 0 {
1587 parts = append(parts, "supported_efforts = "+renderStringArray(ov.SupportedEfforts))
1588 }
1589 if ov.DefaultEffort != "" {
1590 parts = append(parts, fmt.Sprintf("default_effort = %q", ov.DefaultEffort))
1591 }
1592 if ov.Vision != nil {
1593 parts = append(parts, fmt.Sprintf("vision = %t", *ov.Vision))
1594 }
1595 if ov.ContextWindow > 0 {
1596 parts = append(parts, fmt.Sprintf("context_window = %d", ov.ContextWindow))
1597 }
1598 if ov.MaxOutputTokens != 0 {
1599 parts = append(parts, fmt.Sprintf("max_output_tokens = %d", ov.MaxOutputTokens))
1600 }
1601 return "{ " + strings.Join(parts, ", ") + " }"
1602 }
1603
1604 func modelOverrideEmpty(ov ProviderModelOverride) bool {
1605 return ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil && ov.ContextWindow <= 0 && ov.MaxOutputTokens == 0
1606 }
1607
1608 func hasPositiveIntMap(m map[string]int) bool {
1609 for k, v := range m {
1610 if strings.TrimSpace(k) != "" && v > 0 {
1611 return true
1612 }
1613 }
1614 return false
1615 }
1616
1617 // renderIntMap renders a map[string]int as a TOML inline table with positive
1618 // values only, preserving deterministic key order.
1619 func renderIntMap(m map[string]int) string {
1620 keys := make([]string, 0, len(m))
1621 for k, v := range m {
1622 if strings.TrimSpace(k) != "" && v > 0 {
1623 keys = append(keys, k)
1624 }
1625 }
1626 sort.Strings(keys)
1627 var b strings.Builder
1628 b.WriteString("{ ")
1629 for i, k := range keys {
1630 if i > 0 {
1631 b.WriteString(", ")
1632 }
1633 fmt.Fprintf(&b, "%q = %d", k, m[k])
1634 }
1635 b.WriteString(" }")
1636 return b.String()
1637 }
1638
1639 func renderBotCredential(cred BotConnectionCredential) string {
1640 parts := make(map[string]string)
1641 if cred.AppID != "" {
1642 parts["app_id"] = cred.AppID
1643 }
1644 if cred.AppSecretEnv != "" {
1645 parts["app_secret_env"] = cred.AppSecretEnv
1646 }
1647 if cred.AccountID != "" {
1648 parts["account_id"] = cred.AccountID
1649 }
1650 if cred.TokenEnv != "" {
1651 parts["token_env"] = cred.TokenEnv
1652 }
1653 if len(parts) == 0 {
1654 return ""
1655 }
1656 return renderStringMap(parts)
1657 }
1658
1659 func renderBotAccess(access BotAccessConfig) string {
1660 hasList := len(access.Users) > 0 || len(access.Groups) > 0 || len(access.Approvers) > 0 || len(access.Admins) > 0
1661 if !access.Enabled && !access.AllowAll && !access.PairingEnabled && !hasList {
1662 return ""
1663 }
1664 var parts []string
1665 parts = append(parts, fmt.Sprintf("enabled = %v", access.Enabled))
1666 parts = append(parts, fmt.Sprintf("allow_all = %v", access.AllowAll))
1667 parts = append(parts, fmt.Sprintf("pairing_enabled = %v", access.PairingEnabled))
1668 if len(access.Users) > 0 {
1669 parts = append(parts, "users = "+renderStringArray(access.Users))
1670 }
1671 if len(access.Groups) > 0 {
1672 parts = append(parts, "groups = "+renderStringArray(access.Groups))
1673 }
1674 if len(access.Approvers) > 0 {
1675 parts = append(parts, "approvers = "+renderStringArray(access.Approvers))
1676 }
1677 if len(access.Admins) > 0 {
1678 parts = append(parts, "admins = "+renderStringArray(access.Admins))
1679 }
1680 return "{ " + strings.Join(parts, ", ") + " }"
1681 }
1682
1683 func renderBotSessionMappings(mappings []BotConnectionSessionMapping) string {
1684 var b strings.Builder
1685 b.WriteByte('[')
1686 for i, mapping := range mappings {
1687 if i > 0 {
1688 b.WriteString(", ")
1689 }
1690 parts := map[string]string{
1691 "remote_id": mapping.RemoteID,
1692 "session_id": mapping.SessionID,
1693 }
1694 if mapping.SessionSource != "" {
1695 parts["session_source"] = mapping.SessionSource
1696 }
1697 if mapping.ChatType != "" {
1698 parts["chat_type"] = mapping.ChatType
1699 }
1700 if mapping.UserID != "" {
1701 parts["user_id"] = mapping.UserID
1702 }
1703 if mapping.ThreadID != "" {
1704 parts["thread_id"] = mapping.ThreadID
1705 }
1706 if mapping.Scope != "" {
1707 parts["scope"] = mapping.Scope
1708 }
1709 if mapping.WorkspaceRoot != "" {
1710 parts["workspace_root"] = mapping.WorkspaceRoot
1711 }
1712 if mapping.UpdatedAt != "" {
1713 parts["updated_at"] = mapping.UpdatedAt
1714 }
1715 b.WriteString(renderStringMap(parts))
1716 }
1717 b.WriteByte(']')
1718 return b.String()
1719 }
1720
1721 func renderBotRoute(b *strings.Builder, route BotRouteConfig) {
1722 if strings.TrimSpace(route.ConnectionID) != "" {
1723 fmt.Fprintf(b, "connection_id = %q\n", strings.TrimSpace(route.ConnectionID))
1724 }
1725 if strings.TrimSpace(route.Platform) != "" {
1726 fmt.Fprintf(b, "platform = %q\n", strings.TrimSpace(route.Platform))
1727 }
1728 if strings.TrimSpace(route.ChatType) != "" {
1729 fmt.Fprintf(b, "chat_type = %q\n", strings.TrimSpace(route.ChatType))
1730 }
1731 if strings.TrimSpace(route.ChatID) != "" {
1732 fmt.Fprintf(b, "chat_id = %q\n", strings.TrimSpace(route.ChatID))
1733 }
1734 if strings.TrimSpace(route.UserID) != "" {
1735 fmt.Fprintf(b, "user_id = %q\n", strings.TrimSpace(route.UserID))
1736 }
1737 if strings.TrimSpace(route.ThreadID) != "" {
1738 fmt.Fprintf(b, "thread_id = %q\n", strings.TrimSpace(route.ThreadID))
1739 }
1740 if strings.TrimSpace(route.Model) != "" {
1741 fmt.Fprintf(b, "model = %q\n", strings.TrimSpace(route.Model))
1742 }
1743 if strings.TrimSpace(route.ToolApprovalMode) != "" {
1744 fmt.Fprintf(b, "tool_approval_mode = %q\n", strings.TrimSpace(route.ToolApprovalMode))
1745 }
1746 if strings.TrimSpace(route.WorkspaceRoot) != "" {
1747 fmt.Fprintf(b, "workspace_root = %q\n", strings.TrimSpace(route.WorkspaceRoot))
1748 }
1749 }
1750
1751 func renderBotDesktopWatcher(b *strings.Builder, watcher BotDesktopWatcherConfig) {
1752 if strings.TrimSpace(watcher.Platform) != "" {
1753 fmt.Fprintf(b, "platform = %q\n", strings.TrimSpace(watcher.Platform))
1754 }
1755 if strings.TrimSpace(watcher.ConnectionID) != "" {
1756 fmt.Fprintf(b, "connection_id = %q\n", strings.TrimSpace(watcher.ConnectionID))
1757 }
1758 if strings.TrimSpace(watcher.Domain) != "" {
1759 fmt.Fprintf(b, "domain = %q\n", strings.TrimSpace(watcher.Domain))
1760 }
1761 if strings.TrimSpace(watcher.ChatType) != "" {
1762 fmt.Fprintf(b, "chat_type = %q\n", strings.TrimSpace(watcher.ChatType))
1763 }
1764 if strings.TrimSpace(watcher.ChatID) != "" {
1765 fmt.Fprintf(b, "chat_id = %q\n", strings.TrimSpace(watcher.ChatID))
1766 }
1767 }
1768
1769 // renderRuleList emits a permission rule list. A populated list renders as an
1770 // active TOML array; an empty one renders as a commented example so `reasonix setup`
1771 // scaffolds discoverable guidance without imposing surprising rules.
1772 func renderRuleList(key string, rules []string, example string) string {
1773 if len(rules) == 0 {
1774 return fmt.Sprintf("# %s = %s\n", key, example)
1775 }
1776 var b strings.Builder
1777 fmt.Fprintf(&b, "%s = [", key)
1778 for i, r := range rules {
1779 if i > 0 {
1780 b.WriteString(", ")
1781 }
1782 fmt.Fprintf(&b, "%q", r)
1783 }
1784 b.WriteString("]\n")
1785 return b.String()
1786 }
1787
1788 // formatFloat ensures a float renders with a decimal point so TOML types it as a
1789 // float, not an integer (e.g. 0 -> "0.0").
1790 func formatFloat(f float64) string {
1791 s := strconv.FormatFloat(f, 'f', -1, 64)
1792 if !strings.Contains(s, ".") {
1793 s += ".0"
1794 }
1795 return s
1796 }
1797
1797 lines GO