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