package config import ( "fmt" "log/slog" "os" "path/filepath" "reflect" "regexp" "runtime" "strconv" "strings" "sync" "github.com/BurntSushi/toml" ) // validRunAsUserName is the portable-username character set plus digits. // POSIX does not require a specific pattern, but every mainstream Linux and // macOS system accepts these characters for login names. Rejecting anything // outside this set removes an injection vector into the sudo argv. func isValidRunAsUserName(name string) bool { if name == "" || len(name) > 32 { return false } for i, r := range name { switch { case r >= 'a' && r <= 'z': case r >= 'A' && r <= 'Z': case r == '_': case r >= '0' && r <= '9' && i > 0: case (r == '-' || r == '.') && i > 0: default: return false } } return true } var dangerousEnvVars = map[string]bool{ "LD_PRELOAD": true, "LD_LIBRARY_PATH": true, "DYLD_INSERT_LIBRARIES": true, "DYLD_LIBRARY_PATH": true, "PATH": true, "HOME": true, "USER": true, "SHELL": true, "SUDO_USER": true, "SUDO_COMMAND": true, } func validateRunAsEnv(prefix string, envVars []string) error { for _, v := range envVars { name := strings.TrimSpace(v) if dangerousEnvVars[strings.ToUpper(name)] { return fmt.Errorf("config: %s.run_as_env must not include dangerous variable %q", prefix, name) } } return nil } func validateRunAsUser(prefix, name string) error { if name == "" { return nil } if runtime.GOOS == "windows" { return fmt.Errorf("config: %s.run_as_user is only supported on Linux/macOS", prefix) } if name == "root" || name == "0" { return fmt.Errorf("config: %s.run_as_user must not be root", prefix) } if !isValidRunAsUserName(name) { return fmt.Errorf("config: %s.run_as_user %q contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, .; must start with a letter or underscore)", prefix, name) } return nil } // configMu serializes read-modify-write cycles to prevent lost updates. var configMu sync.Mutex // ConfigPath stores the path to the config file for saving var ConfigPath string type Config struct { DataDir string `toml:"data_dir"` // session store directory, default ~/.cc-connect AttachmentSend string `toml:"attachment_send"` // Quiet is legacy: when true and [display] does not set thinking_messages / tool_messages, // engines behave as if those flags were false. Per-project quiet overrides when set. Quiet *bool `toml:"quiet,omitempty"` Providers []ProviderConfig `toml:"providers"` // global shared providers ProviderPresetsURL string `toml:"provider_presets_url,omitempty"` // remote JSON URL for provider presets Projects []ProjectConfig `toml:"projects"` Commands []CommandConfig `toml:"commands"` // global custom slash commands Aliases []AliasConfig `toml:"aliases"` // global command aliases BannedWords []string `toml:"banned_words"` // messages containing any of these words are blocked Log LogConfig `toml:"log"` Language string `toml:"language"` // "en" or "zh", default is "en" Speech SpeechConfig `toml:"speech"` TTS TTSConfig `toml:"tts"` Display DisplayConfig `toml:"display"` StreamPreview StreamPreviewConfig `toml:"stream_preview"` // real-time streaming preview InstantReply InstantReplyConfig `toml:"instant_reply"` // immediate confirmation reply RateLimit RateLimitConfig `toml:"rate_limit"` // per-session rate limiting OutgoingRateLimit OutgoingRateLimitConfig `toml:"outgoing_rate_limit"` // outgoing message throttling Relay RelayConfig `toml:"relay"` // bot-to-bot relay behavior Cron CronConfig `toml:"cron"` Queue QueueConfig `toml:"queue"` Webhook WebhookConfig `toml:"webhook"` Bridge BridgeConfig `toml:"bridge"` Management ManagementConfig `toml:"management"` Hooks []HookConfig `toml:"hooks"` IdleTimeoutMins *int `toml:"idle_timeout_mins,omitempty"` // max minutes between consecutive agent events; 0 = no timeout; default 120 MaxTurnTimeMins *int `toml:"max_turn_time_mins,omitempty"` // absolute wall-clock cap per turn in minutes; 0 = disabled (default) // WorkspaceIdleTimeoutMins controls the workspace idle reaper timeout // (multi-workspace mode) for every engine in the process. 0 disables // reaping. Default: 15 minutes. Defined as a top-level (process-global) // setting so the reaper policy is consistent across projects; per-project // configuration is intentionally not supported. WorkspaceIdleTimeoutMins *int `toml:"workspace_idle_timeout_mins,omitempty"` } // CronConfig controls cron job behavior. type CronConfig struct { Silent *bool `toml:"silent"` // suppress cron start notification; default false SessionMode string `toml:"session_mode"` // default session mode: "" or "reuse" (default) or "new_per_run" } // QueueConfig controls the per-session message queue. type QueueConfig struct { MaxDepth *int `toml:"max_depth"` // max queued messages per session; default 5 } // WebhookConfig controls the external HTTP webhook endpoint. type WebhookConfig struct { Enabled *bool `toml:"enabled"` // default false Port int `toml:"port,omitempty"` // listen port; default 9111 Token string `toml:"token,omitempty"` // shared secret for authentication; empty = no auth Path string `toml:"path,omitempty"` // URL path prefix; default "/hook" } // BridgeConfig controls the WebSocket bridge for external platform adapters. type BridgeConfig struct { Enabled *bool `toml:"enabled"` // default false Port int `toml:"port,omitempty"` // listen port; default 9810 Token string `toml:"token,omitempty"` // shared secret for authentication; required unless insecure=true Path string `toml:"path,omitempty"` // URL path; default "/bridge/ws" CORSOrigins []string `toml:"cors_origins,omitempty"` // allowed CORS origins; empty = no CORS Insecure *bool `toml:"insecure,omitempty"` // allow running without token (local dev only); default false } // HookConfig is a single event hook rule. type HookConfig struct { Event string `toml:"event"` // event name or "*" Type string `toml:"type"` // "command" or "http" Command string `toml:"command,omitempty"` // shell command (type=command) URL string `toml:"url,omitempty"` // HTTP endpoint (type=http) Timeout int `toml:"timeout,omitempty"` // seconds; 0 = default Async *bool `toml:"async,omitempty"` // nil = true (async by default) } // ManagementConfig controls the HTTP Management API for external tools. type ManagementConfig struct { Enabled *bool `toml:"enabled"` // default false Port int `toml:"port,omitempty"` // listen port; default 9820 Token string `toml:"token,omitempty"` // shared secret for authentication; required CORSOrigins []string `toml:"cors_origins,omitempty"` // allowed CORS origins; empty = no CORS } // Display mode constants. const ( DisplayModeFull = "full" // show thinking + tool messages as separate messages (default) DisplayModeCompact = "compact" // hide thinking/tool, each text segment is a separate card DisplayModeQuiet = "quiet" // hide thinking/tool, all text appends to one card ) // DisplayConfig controls how intermediate messages (thinking, tool output) are shown. type DisplayConfig struct { Mode *string `toml:"mode"` // "full" (default), "compact", or "quiet" CardMode *string `toml:"card_mode"` // "legacy" (default) or "rich" (Card 2.0 Feishu) ThinkingMessages *bool `toml:"thinking_messages"` // whether thinking messages are shown; default true ThinkingMaxLen *int `toml:"thinking_max_len"` // max chars for thinking messages; 0 = no truncation; default 300 ToolMaxLen *int `toml:"tool_max_len"` // max chars for tool use messages; 0 = no truncation; default 500 ToolMessages *bool `toml:"tool_messages"` // whether tool progress messages are shown; default true ShowContextIndicator *bool `toml:"show_context_indicator"` // whether [ctx: ~N%] suffix is shown; default true ReplyFooter *bool `toml:"reply_footer"` // whether Codex-like footer is shown; default true } // StreamPreviewConfig controls real-time streaming preview in IM. type StreamPreviewConfig struct { Enabled *bool `toml:"enabled"` // default true DisabledPlatforms []string `toml:"disabled_platforms,omitempty"` // platforms where preview is disabled (e.g. ["feishu"]) IntervalMs *int `toml:"interval_ms"` // min ms between updates; default 1500 MinDeltaChars *int `toml:"min_delta_chars"` // min new chars before update; default 30 MaxChars *int `toml:"max_chars"` // max preview length; default 2000 } // InstantReplyConfig controls the immediate confirmation reply sent when a message // is received, before the agent starts processing. This gives users quick feedback // that their message was received (e.g. "🤔 Thinking..."). type InstantReplyConfig struct { Enabled *bool `toml:"enabled"` // default false Content string `toml:"content"` // custom reply text; empty = use i18n default ("⏳ Processing...") } // RateLimitConfig controls per-session message rate limiting. type RateLimitConfig struct { MaxMessages *int `toml:"max_messages"` // max messages per window; 0 = disabled; default 20 WindowSecs *int `toml:"window_secs"` // window size in seconds; default 60 } // OutgoingRateLimitConfig controls how fast messages are sent TO platforms. // Prevents account bans on platforms with strict API rate limits (e.g. WeChat Work). type OutgoingRateLimitConfig struct { MaxPerSecond *float64 `toml:"max_per_second"` // messages per second; 0 = unlimited (default) Burst *int `toml:"burst"` // max burst size; default = ceil(max_per_second) Platforms map[string]OutgoingRateLimitPlatConfig `toml:"platforms"` // per-platform overrides keyed by platform type name } // OutgoingRateLimitPlatConfig is a per-platform override for outgoing rate limiting. type OutgoingRateLimitPlatConfig struct { MaxPerSecond *float64 `toml:"max_per_second"` Burst *int `toml:"burst"` } // UsersConfig controls per-user role assignments and policies within a project. type UsersConfig struct { DefaultRole string `toml:"default_role,omitempty"` // role for unmatched users; default "member" Roles map[string]RoleConfig `toml:"roles,omitempty"` } // RoleConfig defines policies for a user role. type RoleConfig struct { UserIDs []string `toml:"user_ids"` DisabledCommands []string `toml:"disabled_commands,omitempty"` RateLimit *RateLimitConfig `toml:"rate_limit,omitempty"` // nil = inherit global } // RelayConfig controls bot-to-bot relay behavior. type RelayConfig struct { TimeoutSecs *int `toml:"timeout_secs"` // max seconds to wait for relay response; 0 = disabled; default 120 } // SpeechConfig configures speech-to-text for voice messages. type SpeechConfig struct { Enabled bool `toml:"enabled"` Provider string `toml:"provider"` // "openai" | "groq" | "qwen" | "gemini" Language string `toml:"language"` // e.g. "zh", "en"; empty = auto-detect OpenAI struct { APIKey string `toml:"api_key"` BaseURL string `toml:"base_url"` Model string `toml:"model"` } `toml:"openai"` Groq struct { APIKey string `toml:"api_key"` Model string `toml:"model"` } `toml:"groq"` Qwen struct { APIKey string `toml:"api_key"` BaseURL string `toml:"base_url"` Model string `toml:"model"` } `toml:"qwen"` Gemini struct { APIKey string `toml:"api_key"` Model string `toml:"model"` } `toml:"gemini"` } // TTSConfig configures text-to-speech output (mirrors SpeechConfig style). type TTSConfig struct { Enabled bool `toml:"enabled"` Provider string `toml:"provider"` // "qwen" | "openai" | "minimax" | "mimo" | "espeak" | "pico" | "edge" Voice string `toml:"voice"` // default voice name (for edge: "zh-CN-XiaoxiaoNeural"; for pico: "zh-CN"; for espeak: "zh"; for mimo: "mimo_default" / "冰糖" / "Mia" …) TTSMode string `toml:"tts_mode"` // "voice_only" (default) | "always" MaxTextLen int `toml:"max_text_len"` // max rune count before skipping TTS; 0 = no limit OpenAI struct { APIKey string `toml:"api_key"` BaseURL string `toml:"base_url"` Model string `toml:"model"` } `toml:"openai"` Qwen struct { APIKey string `toml:"api_key"` BaseURL string `toml:"base_url"` Model string `toml:"model"` } `toml:"qwen"` MiniMax struct { APIKey string `toml:"api_key"` BaseURL string `toml:"base_url"` Model string `toml:"model"` } `toml:"minimax"` Mimo struct { APIKey string `toml:"api_key"` BaseURL string `toml:"base_url"` Model string `toml:"model"` } `toml:"mimo"` } // HeartbeatConfig controls periodic heartbeat for a project. type HeartbeatConfig struct { Enabled *bool `toml:"enabled"` // default false IntervalMins *int `toml:"interval_mins,omitempty"` // minutes between heartbeats; default 30 OnlyWhenIdle *bool `toml:"only_when_idle,omitempty"` // only fire when the session is not busy; default true SessionKey string `toml:"session_key,omitempty"` // target session key (e.g. "telegram:123:123"); required Prompt string `toml:"prompt,omitempty"` // explicit prompt; if empty, reads HEARTBEAT.md from work_dir Silent *bool `toml:"silent,omitempty"` // suppress heartbeat notification; default true TimeoutMins *int `toml:"timeout_mins,omitempty"` // max execution time; default 30 } // AutoCompressConfig controls automatic context compression for a project. type AutoCompressConfig struct { Enabled *bool `toml:"enabled,omitempty"` // default false MaxTokens *int `toml:"max_tokens,omitempty"` // estimated token threshold to trigger /compress MinGapMins *int `toml:"min_gap_mins,omitempty"` // minimum minutes between auto-compress runs (default 30) } // ObserveConfig controls forwarding of native terminal Claude Code sessions to a messaging platform. type ObserveConfig struct { Enabled bool `toml:"enabled"` Channel string `toml:"channel"` } // ReferenceConfig controls local file reference normalization and rendering. type ReferenceConfig struct { NormalizeAgents []string `toml:"normalize_agents,omitempty"` RenderPlatforms []string `toml:"render_platforms,omitempty"` DisplayPath string `toml:"display_path,omitempty"` MarkerStyle string `toml:"marker_style,omitempty"` EnclosureStyle string `toml:"enclosure_style,omitempty"` } // ProjectConfig binds one agent (with a specific work_dir) to one or more platforms. type ProjectConfig struct { Name string `toml:"name"` Mode string `toml:"mode,omitempty"` // "" or "multi-workspace" BaseDir string `toml:"base_dir,omitempty"` // parent dir for workspaces SkipGit *bool `toml:"skip_git,omitempty"` // WorkspaceInitAllowLocalPaths allows /workspace init and the conversational // init flow to bind existing local directories. Default false keeps init // limited to git URLs; use /workspace bind or /workspace route for explicit // local bindings. WorkspaceInitAllowLocalPaths *bool `toml:"workspace_init_allow_local_paths,omitempty"` Agent AgentConfig `toml:"agent"` Platforms []PlatformConfig `toml:"platforms"` Heartbeat HeartbeatConfig `toml:"heartbeat"` AutoCompress AutoCompressConfig `toml:"auto_compress"` // ResetOnIdleMins automatically rotates to a new cc-connect session after // the current session has been inactive for the specified number of minutes. // 0 or nil disables the behavior. ResetOnIdleMins *int `toml:"reset_on_idle_mins,omitempty"` // RunAsUser, when set, causes the agent command for this project to be // spawned under a different Unix user via `sudo -n -iu --`. This // provides OS-level file-system isolation from the supervisor user who // runs cc-connect itself. Requires passwordless sudo to the target user // and is POSIX-only. See docs/usage.md "Running agents as a different // Unix user" for setup and migration. RunAsUser string `toml:"run_as_user,omitempty"` // RunAsEnv optionally extends the minimal environment variable allowlist // that crosses the sudo boundary when RunAsUser is set. The default // allowlist (LANG, LC_*, TERM) is always included; PATH is NOT preserved // by default — the target user's login PATH is used. Dangerous variables // (LD_PRELOAD, PATH, HOME, etc.) are rejected at config validation. // Use this only for variables the target user cannot set in their profile. RunAsEnv []string `toml:"run_as_env,omitempty"` // ShowContextIndicator: nil/true = append [ctx: ~N%] to assistant replies; false = hide. ShowContextIndicator *bool `toml:"show_context_indicator,omitempty"` // ReplyFooter: nil/true = append a Codex-style footer; false = disable. // (model/reasoning/usage/workdir, when available) to assistant replies. ReplyFooter *bool `toml:"reply_footer,omitempty"` InjectSender *bool `toml:"inject_sender,omitempty"` // prepend sender identity (platform + user ID) to each message sent to the agent DisabledCommands []string `toml:"disabled_commands,omitempty"` // commands to disable for this project (e.g. ["restart", "upgrade"]) AdminFrom string `toml:"admin_from,omitempty"` // comma-separated user IDs allowed to run privileged commands; "*" = all allowed users Users *UsersConfig `toml:"users,omitempty"` // per-user role config; nil = legacy behavior // WorkspaceIdleTimeoutMinsLegacy is the deprecated per-project form of // the workspace idle reaper timeout. New configs should set the top-level // Config.WorkspaceIdleTimeoutMins instead. When the top-level field is // unset, this legacy value is still honored (with a deprecation warning) // to keep existing configs working. Will be removed in a future release. WorkspaceIdleTimeoutMinsLegacy *int `toml:"workspace_idle_timeout_mins,omitempty"` // Quiet is legacy per-project override; see Config.Quiet. When true and global [display] // omits thinking_messages / tool_messages, those default to off for this project. Quiet *bool `toml:"quiet,omitempty"` // Display, when non-nil, overrides individual fields of the global [display] // block for this project. Each sub-field is independently optional; unset // fields fall back to the global [display] value, then to the built-in // defaults. Example: enable verbose display globally but force quiet on a // specific noisy project, or vice versa. // // [display] // thinking_messages = true // tool_messages = true // // [[projects]] // name = "noisy-project" // [projects.display] // thinking_messages = false // tool_messages = false Display *DisplayConfig `toml:"display,omitempty"` Observe *ObserveConfig `toml:"observe,omitempty"` References ReferenceConfig `toml:"references,omitempty"` // FilterExternalSessions: when true, /list only shows sessions created by // cc-connect, hiding sessions created by direct CLI usage in the same work_dir. // Default is false (show all sessions). FilterExternalSessions *bool `toml:"filter_external_sessions,omitempty"` } type AgentConfig struct { Type string `toml:"type"` Options map[string]any `toml:"options"` ProviderRefs []string `toml:"provider_refs,omitempty"` // references to global [[providers]] by name Providers []ProviderConfig `toml:"providers"` } // ProviderModelConfig defines a selectable model entry for a provider, // with an optional short alias used by the /model command. type ProviderModelConfig struct { Model string `toml:"model"` Alias string `toml:"alias,omitempty"` } type ProviderConfig struct { Name string `toml:"name"` APIKey string `toml:"api_key"` BaseURL string `toml:"base_url,omitempty"` Model string `toml:"model,omitempty"` Models []ProviderModelConfig `toml:"models,omitempty"` Thinking string `toml:"thinking,omitempty"` Env map[string]string `toml:"env,omitempty"` AgentTypes []string `toml:"agent_types,omitempty"` // optional: restrict to specific agent types (e.g. ["claudecode", "codex"]) Endpoints map[string]string `toml:"endpoints,omitempty"` // per-agent-type base URL overrides (e.g. codex = "https://x/v1") AgentModels map[string]string `toml:"agent_models,omitempty"` // per-agent-type default model (e.g. codex = "openai/gpt-5.3-codex") AgentModelLists map[string][]ProviderModelConfig `toml:"agent_model_lists,omitempty"` // per-agent-type model lists (overrides Models when matched) Codex *CodexProviderConfig `toml:"codex,omitempty"` // Codex-specific provider settings } // CodexProviderConfig holds Codex CLI-specific provider fields // that map to [model_providers.] in Codex's own config.toml. type CodexProviderConfig struct { EnvKey string `toml:"env_key,omitempty" json:"env_key,omitempty"` WireAPI string `toml:"wire_api,omitempty" json:"wire_api,omitempty"` HTTPHeaders map[string]string `toml:"http_headers,omitempty" json:"http_headers,omitempty"` } type PlatformConfig struct { Type string `toml:"type"` Options map[string]any `toml:"options"` } // AliasConfig maps a trigger string to a command (e.g. "帮助" → "/help"). type AliasConfig struct { Name string `toml:"name"` // trigger text (e.g. "帮助") Command string `toml:"command"` // target command (e.g. "/help") } // CommandConfig defines a user-customizable slash command that expands a prompt template or executes a shell command. type CommandConfig struct { Name string `toml:"name"` Description string `toml:"description"` Prompt string `toml:"prompt"` // prompt template (mutually exclusive with Exec) Exec string `toml:"exec"` // shell command to execute (mutually exclusive with Prompt) WorkDir string `toml:"work_dir"` // optional: working directory for exec command } type LogConfig struct { Level string `toml:"level"` } func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config file: %w", err) } cfg := &Config{ Log: LogConfig{Level: "info"}, } if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } resolveEnvInConfig(cfg) if cfg.DataDir == "" { if home, err := os.UserHomeDir(); err == nil { cfg.DataDir = filepath.Join(home, ".cc-connect") } else { cfg.DataDir = ".cc-connect" } } cfg.AttachmentSend = strings.ToLower(strings.TrimSpace(cfg.AttachmentSend)) if cfg.AttachmentSend == "" { cfg.AttachmentSend = "on" } cfg.ResolveProviderRefs() if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } var envPlaceholderPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) func resolveEnvInConfig(cfg *Config) { resolveEnvValue(reflect.ValueOf(cfg)) } func resolveEnvValue(v reflect.Value) { if !v.IsValid() { return } switch v.Kind() { case reflect.Pointer: if !v.IsNil() { resolveEnvValue(v.Elem()) } case reflect.Struct: for i := 0; i < v.NumField(); i++ { resolveEnvValue(v.Field(i)) } case reflect.String: if v.CanSet() { v.SetString(resolveEnvPlaceholders(v.String())) } case reflect.Slice, reflect.Array: for i := 0; i < v.Len(); i++ { elem := v.Index(i) if elem.CanSet() { elem.Set(resolveEnvClone(elem)) continue } resolveEnvValue(elem) } case reflect.Map: if v.IsNil() { return } iter := v.MapRange() for iter.Next() { v.SetMapIndex(iter.Key(), resolveEnvClone(iter.Value())) } case reflect.Interface: if v.IsNil() || !v.CanSet() { return } v.Set(resolveEnvClone(v.Elem())) } } func resolveEnvClone(v reflect.Value) reflect.Value { if !v.IsValid() { return v } switch v.Kind() { case reflect.String: out := reflect.New(v.Type()).Elem() out.SetString(resolveEnvPlaceholders(v.String())) return out case reflect.Pointer: if v.IsNil() { return reflect.Zero(v.Type()) } out := reflect.New(v.Type().Elem()) out.Elem().Set(v.Elem()) resolveEnvValue(out.Elem()) return out case reflect.Struct: out := reflect.New(v.Type()).Elem() out.Set(v) resolveEnvValue(out) return out case reflect.Slice: if v.IsNil() { return reflect.Zero(v.Type()) } out := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) for i := 0; i < v.Len(); i++ { out.Index(i).Set(resolveEnvClone(v.Index(i))) } return out case reflect.Array: out := reflect.New(v.Type()).Elem() for i := 0; i < v.Len(); i++ { out.Index(i).Set(resolveEnvClone(v.Index(i))) } return out case reflect.Map: if v.IsNil() { return reflect.Zero(v.Type()) } out := reflect.MakeMapWithSize(v.Type(), v.Len()) iter := v.MapRange() for iter.Next() { out.SetMapIndex(iter.Key(), resolveEnvClone(iter.Value())) } return out case reflect.Interface: if v.IsNil() { return reflect.Zero(v.Type()) } out := reflect.New(v.Type()).Elem() out.Set(resolveEnvClone(v.Elem())) return out default: return v } } func resolveEnvPlaceholders(s string) string { if !strings.Contains(s, "${") { return s } return envPlaceholderPattern.ReplaceAllStringFunc(s, func(match string) string { parts := envPlaceholderPattern.FindStringSubmatch(match) if len(parts) != 2 { return match } val, ok := os.LookupEnv(parts[1]) if !ok { slog.Warn("config: env var placeholder references unset variable", "var", parts[1], "placeholder", match) } return val }) } // projectQuietEffective returns whether legacy quiet applies to this project: an explicit // per-project quiet overrides; otherwise the global root quiet applies. func projectQuietEffective(cfg *Config, proj *ProjectConfig) bool { if proj.Quiet != nil { return *proj.Quiet } if cfg.Quiet != nil { return *cfg.Quiet } return false } // EffectiveDisplay resolves the per-project [projects.display] override on top // of the global [display] block, falling back to built-in defaults. // // Resolution order for mode (thinking/tool visibility): // 1. Explicit [projects.display].mode wins. // 2. Explicit [display].mode wins. // 3. Legacy quiet = true (without display.mode) → "quiet". // 4. Default → "full". // // Resolution order for thinking_messages / tool_messages: // 1. project-level [projects.display]. (highest precedence) // 2. global [display]. // 3. mode-derived default (compact/quiet → false, full → true) func EffectiveDisplay(cfg *Config, proj *ProjectConfig) (mode string, thinkingMessages, toolMessages bool, thinkingMaxLen, toolMaxLen int, showContextIndicator, replyFooter bool) { var projDisp *DisplayConfig if proj != nil { projDisp = proj.Display } // Resolve mode. mode = DisplayModeFull if projDisp != nil && projDisp.Mode != nil { mode = *projDisp.Mode } else if cfg.Display.Mode != nil { mode = *cfg.Display.Mode } else if projectQuietEffective(cfg, proj) { mode = DisplayModeQuiet } // Mode-derived defaults. thinkingDefault, toolDefault := true, true switch mode { case DisplayModeCompact, DisplayModeQuiet: thinkingDefault, toolDefault = false, false } pickBool := func(projVal, globalVal *bool, dflt bool) bool { if projVal != nil { return *projVal } if globalVal != nil { return *globalVal } return dflt } pickInt := func(projVal, globalVal *int, dflt int) int { if projVal != nil { return *projVal } if globalVal != nil { return *globalVal } return dflt } getProjBool := func(f func(*DisplayConfig) *bool) *bool { if projDisp == nil { return nil } return f(projDisp) } getProjInt := func(f func(*DisplayConfig) *int) *int { if projDisp == nil { return nil } return f(projDisp) } thinkingMessages = pickBool( getProjBool(func(d *DisplayConfig) *bool { return d.ThinkingMessages }), cfg.Display.ThinkingMessages, thinkingDefault, ) toolMessages = pickBool( getProjBool(func(d *DisplayConfig) *bool { return d.ToolMessages }), cfg.Display.ToolMessages, toolDefault, ) thinkingMaxLen = pickInt( getProjInt(func(d *DisplayConfig) *int { return d.ThinkingMaxLen }), cfg.Display.ThinkingMaxLen, 300, ) toolMaxLen = pickInt( getProjInt(func(d *DisplayConfig) *int { return d.ToolMaxLen }), cfg.Display.ToolMaxLen, 500, ) // ShowContextIndicator precedence: proj.ShowContextIndicator > proj.Display.ShowContextIndicator > cfg.Display.ShowContextIndicator > default true if proj != nil && proj.ShowContextIndicator != nil { showContextIndicator = *proj.ShowContextIndicator } else if projDisp != nil && projDisp.ShowContextIndicator != nil { showContextIndicator = *projDisp.ShowContextIndicator } else if cfg.Display.ShowContextIndicator != nil { showContextIndicator = *cfg.Display.ShowContextIndicator } else { showContextIndicator = true } // ReplyFooter precedence: proj.ReplyFooter > proj.Display.ReplyFooter > cfg.Display.ReplyFooter > default true if proj != nil && proj.ReplyFooter != nil { replyFooter = *proj.ReplyFooter } else if projDisp != nil && projDisp.ReplyFooter != nil { replyFooter = *projDisp.ReplyFooter } else if cfg.Display.ReplyFooter != nil { replyFooter = *cfg.Display.ReplyFooter } else { replyFooter = true } return } // EffectiveCardMode returns the card rendering mode for the project: "rich" (Feishu Card 2.0) // or "legacy" (default plain messages). Per-project overrides global. func EffectiveCardMode(cfg *Config, proj *ProjectConfig) string { var projDisp *DisplayConfig if proj != nil { projDisp = proj.Display } if projDisp != nil && projDisp.CardMode != nil { if m := strings.ToLower(strings.TrimSpace(*projDisp.CardMode)); m == "rich" || m == "legacy" { return m } } if cfg.Display.CardMode != nil { if m := strings.ToLower(strings.TrimSpace(*cfg.Display.CardMode)); m == "rich" || m == "legacy" { return m } } return "legacy" } func (c *Config) validate() error { if err := validateDisplayConfig("display", &c.Display); err != nil { return err } switch strings.ToLower(strings.TrimSpace(c.AttachmentSend)) { case "", "on", "off": default: return fmt.Errorf("config: attachment_send must be \"on\" or \"off\"") } if c.Relay.TimeoutSecs != nil && *c.Relay.TimeoutSecs < 0 { return fmt.Errorf("config: relay.timeout_secs must be >= 0") } if len(c.Projects) == 0 { return fmt.Errorf("config: at least one [[projects]] entry is required") } for i, proj := range c.Projects { prefix := fmt.Sprintf("projects[%d]", i) if proj.Name == "" { return fmt.Errorf("config: %s.name is required", prefix) } if proj.Agent.Type == "" { return fmt.Errorf("config: %s.agent.type is required", prefix) } if len(proj.Platforms) == 0 { return fmt.Errorf("config: %s needs at least one [[projects.platforms]]", prefix) } for j, p := range proj.Platforms { if p.Type == "" { return fmt.Errorf("config: %s.platforms[%d].type is required", prefix, j) } } if proj.Mode == "multi-workspace" { if proj.BaseDir == "" { return fmt.Errorf("project %q: multi-workspace mode requires base_dir", proj.Name) } if _, ok := proj.Agent.Options["work_dir"]; ok { return fmt.Errorf("project %q: multi-workspace mode conflicts with agent work_dir (use base_dir instead)", proj.Name) } } if proj.ResetOnIdleMins != nil && *proj.ResetOnIdleMins < 0 { return fmt.Errorf("config: %s.reset_on_idle_mins must be >= 0", prefix) } if err := validateRunAsUser(prefix, proj.RunAsUser); err != nil { return err } if err := validateRunAsEnv(prefix, proj.RunAsEnv); err != nil { return err } if err := validateReferenceConfig(prefix, proj.References); err != nil { return err } if err := validateUsersConfig(prefix, proj.Users); err != nil { return err } if err := validateDisplayConfig(prefix+".display", proj.Display); err != nil { return err } } return nil } func validateDisplayConfig(prefix string, display *DisplayConfig) error { if display == nil { return nil } if display.Mode != nil { switch *display.Mode { case DisplayModeFull, DisplayModeCompact, DisplayModeQuiet: default: return fmt.Errorf("config: %s.mode must be \"full\", \"compact\", or \"quiet\"", prefix) } } if display.CardMode != nil { switch strings.ToLower(strings.TrimSpace(*display.CardMode)) { case "legacy", "rich": default: return fmt.Errorf("config: %s.card_mode must be \"legacy\" or \"rich\"", prefix) } } return nil } var supportedReferenceAgents = map[string]struct{}{ "all": {}, "codex": {}, "claudecode": {}, } var supportedReferencePlatforms = map[string]struct{}{ "all": {}, "feishu": {}, "weixin": {}, } var supportedReferenceDisplayPaths = map[string]struct{}{ "": {}, "absolute": {}, "relative": {}, "basename": {}, "dirname_basename": {}, "smart": {}, } var supportedReferenceMarkerStyles = map[string]struct{}{ "": {}, "none": {}, "ascii": {}, "emoji": {}, } var supportedReferenceEnclosureStyles = map[string]struct{}{ "": {}, "none": {}, "bracket": {}, "angle": {}, "fullwidth": {}, "code": {}, } func validateReferenceConfig(prefix string, rc ReferenceConfig) error { for _, v := range rc.NormalizeAgents { key := strings.ToLower(strings.TrimSpace(v)) if _, ok := supportedReferenceAgents[key]; !ok { return fmt.Errorf("config: %s.references.normalize_agents has unsupported value %q", prefix, v) } } for _, v := range rc.RenderPlatforms { key := strings.ToLower(strings.TrimSpace(v)) if _, ok := supportedReferencePlatforms[key]; !ok { return fmt.Errorf("config: %s.references.render_platforms has unsupported value %q", prefix, v) } } if _, ok := supportedReferenceDisplayPaths[strings.ToLower(strings.TrimSpace(rc.DisplayPath))]; !ok { return fmt.Errorf("config: %s.references.display_path has unsupported value %q", prefix, rc.DisplayPath) } if _, ok := supportedReferenceMarkerStyles[strings.ToLower(strings.TrimSpace(rc.MarkerStyle))]; !ok { return fmt.Errorf("config: %s.references.marker_style has unsupported value %q", prefix, rc.MarkerStyle) } if _, ok := supportedReferenceEnclosureStyles[strings.ToLower(strings.TrimSpace(rc.EnclosureStyle))]; !ok { return fmt.Errorf("config: %s.references.enclosure_style has unsupported value %q", prefix, rc.EnclosureStyle) } return nil } // validateUsersConfig checks the [projects.users] section for consistency. func validateUsersConfig(prefix string, u *UsersConfig) error { if u == nil { return nil } if len(u.Roles) == 0 { return fmt.Errorf("config: %s.users has no roles defined", prefix) } wildcardCount := 0 seenUserIDs := make(map[string]string) // userID → role name for roleName, rc := range u.Roles { if len(rc.UserIDs) == 0 { return fmt.Errorf("config: %s.users.roles.%s has empty user_ids", prefix, roleName) } for _, uid := range rc.UserIDs { if uid == "*" { wildcardCount++ continue } lower := strings.ToLower(uid) if prev, dup := seenUserIDs[lower]; dup { return fmt.Errorf("config: %s.users: user %q appears in both role %q and %q", prefix, uid, prev, roleName) } seenUserIDs[lower] = roleName } } if wildcardCount > 1 { return fmt.Errorf("config: %s.users: wildcard user_ids=[\"*\"] appears in multiple roles", prefix) } if u.DefaultRole != "" { if _, ok := u.Roles[u.DefaultRole]; !ok { return fmt.Errorf("config: %s.users.default_role %q does not match any defined role", prefix, u.DefaultRole) } } return nil } // SaveActiveProvider persists the active provider name for a project. // It uses surgical text editing to preserve comments and unknown fields. func SaveActiveProvider(projectName, providerName string) error { configMu.Lock() defer configMu.Unlock() return patchProjectAgentOption(projectName, "provider", providerName) } // SaveProviderModel persists the selected model for a provider in a project. // It first looks in the project's inline providers, then falls back to // global [[providers]] if the provider is referenced via provider_refs. // Uses surgical text editing to preserve comments and unknown fields. func SaveProviderModel(projectName, providerName, model string) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } raw := string(data) cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } projectIdx := -1 for i := range cfg.Projects { if cfg.Projects[i].Name == projectName { projectIdx = i break } } if projectIdx < 0 { return fmt.Errorf("project %q not found in config", projectName) } lines, hadTrailing := splitConfigLines(raw) spans := buildRawProjectSpans(lines) if projectIdx >= len(spans) { return fmt.Errorf("project %q located in parsed config but not raw file", projectName) } projSpan := spans[projectIdx] for j, prov := range cfg.Projects[projectIdx].Agent.Providers { if prov.Name == providerName { if j < len(projSpan.agentProviders) { ps := projSpan.agentProviders[j] lines = upsertTomlStringKey(lines, ps.start+1, ps.end, "model", model) return writeRawConfig(joinConfigLines(lines, hadTrailing)) } break } } for _, ref := range cfg.Projects[projectIdx].Agent.ProviderRefs { if ref == providerName { return patchGlobalProviderField(lines, hadTrailing, cfg, providerName, "model", model) } } return fmt.Errorf("provider %q not found in project %q", providerName, projectName) } func patchGlobalProviderField(lines []string, hadTrailing bool, cfg *Config, providerName, key, value string) error { globalStarts := make([]int, 0, 4) for i := range lines { if matchTableHeader(lines[i], "[[providers]]") { globalStarts = append(globalStarts, i) } } for k, gp := range cfg.Providers { if gp.Name != providerName || k >= len(globalStarts) { continue } gstart := globalStarts[k] gend := len(lines) - 1 if k+1 < len(globalStarts) { gend = globalStarts[k+1] - 1 } for j := gstart + 1; j <= gend; j++ { if isAnyTableHeader(lines[j]) { gend = j - 1 break } } lines = upsertTomlStringKey(lines, gstart+1, gend, key, value) return writeRawConfig(joinConfigLines(lines, hadTrailing)) } return fmt.Errorf("global provider %q not found", providerName) } // SaveAgentModel persists the selected default model for a project's agent. // It uses surgical text editing to preserve comments and unknown fields. func SaveAgentModel(projectName, model string) error { configMu.Lock() defer configMu.Unlock() return patchProjectAgentOption(projectName, "model", model) } // AddProviderToConfig adds a provider to a project's agent config and saves. func AddProviderToConfig(projectName string, provider ProviderConfig) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } found := false for i := range cfg.Projects { if cfg.Projects[i].Name == projectName { for _, existing := range cfg.Projects[i].Agent.Providers { if existing.Name == provider.Name { return fmt.Errorf("provider %q already exists in project %q", provider.Name, projectName) } } cfg.Projects[i].Agent.Providers = append(cfg.Projects[i].Agent.Providers, provider) found = true break } } if !found { return fmt.Errorf("project %q not found in config", projectName) } return saveConfig(cfg) } // RemoveProviderFromConfig removes a provider from a project's agent config and saves. // For global providers referenced via provider_refs, it removes the reference // instead of deleting the global definition. func RemoveProviderFromConfig(projectName, providerName string) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } found := false for i := range cfg.Projects { if cfg.Projects[i].Name != projectName { continue } // Check inline providers providers := cfg.Projects[i].Agent.Providers for j := range providers { if providers[j].Name == providerName { cfg.Projects[i].Agent.Providers = append(providers[:j], providers[j+1:]...) found = true break } } // Also remove from provider_refs if present refs := cfg.Projects[i].Agent.ProviderRefs for j := range refs { if refs[j] == providerName { cfg.Projects[i].Agent.ProviderRefs = append(refs[:j], refs[j+1:]...) found = true break } } break } if !found { return fmt.Errorf("provider %q not found in project %q", providerName, projectName) } return saveConfig(cfg) } // ResolveProviderRefs merges global [[providers]] into each project that uses // provider_refs. Inline [[projects.agent.providers]] entries are appended after // resolved refs; if an inline entry has the same name as a global one, the // inline entry wins (override). func (cfg *Config) ResolveProviderRefs() { if len(cfg.Providers) == 0 { return } globalByName := make(map[string]ProviderConfig, len(cfg.Providers)) for _, p := range cfg.Providers { globalByName[p.Name] = p } for i := range cfg.Projects { refs := cfg.Projects[i].Agent.ProviderRefs if len(refs) == 0 { continue } agentType := cfg.Projects[i].Agent.Type inlineNames := make(map[string]bool, len(cfg.Projects[i].Agent.Providers)) for _, p := range cfg.Projects[i].Agent.Providers { inlineNames[p.Name] = true } var resolved []ProviderConfig for _, name := range refs { if inlineNames[name] { continue // inline override takes precedence } gp, ok := globalByName[name] if !ok { slog.Warn("provider ref not found in global [[providers]]", "project", cfg.Projects[i].Name, "ref", name) continue } if len(gp.AgentTypes) > 0 && !containsString(gp.AgentTypes, agentType) { slog.Debug("skipping provider: agent type mismatch", "provider", name, "project", cfg.Projects[i].Name, "provider_agents", gp.AgentTypes, "project_agent", agentType) continue } resolved = append(resolved, gp.ResolveForAgent(agentType)) } cfg.Projects[i].Agent.Providers = append(resolved, cfg.Projects[i].Agent.Providers...) } } // ResolveForAgent applies per-agent-type overrides (Endpoints, AgentModels, // AgentModelLists) to a copy of the provider and returns it. func (p ProviderConfig) ResolveForAgent(agentType string) ProviderConfig { if ep, ok := p.Endpoints[agentType]; ok && ep != "" { p.BaseURL = ep } if am, ok := p.AgentModels[agentType]; ok && am != "" { p.Model = am } if aml, ok := p.AgentModelLists[agentType]; ok && len(aml) > 0 { p.Models = aml } return p } func containsString(ss []string, s string) bool { for _, v := range ss { if v == s { return true } } return false } // ── Global provider CRUD ─────────────────────────────────────── // ListGlobalProviders returns the top-level [[providers]] list. func ListGlobalProviders() ([]ProviderConfig, error) { configMu.Lock() defer configMu.Unlock() cfg, err := loadLocked() if err != nil { return nil, err } return cfg.Providers, nil } // AddGlobalProvider appends a provider to the top-level [[providers]] and saves. func AddGlobalProvider(provider ProviderConfig) error { configMu.Lock() defer configMu.Unlock() cfg, err := loadLocked() if err != nil { return err } for _, existing := range cfg.Providers { if existing.Name == provider.Name { return fmt.Errorf("global provider %q already exists", provider.Name) } } cfg.Providers = append(cfg.Providers, provider) return saveConfig(cfg) } // UpdateGlobalProvider replaces an existing global provider by name. func UpdateGlobalProvider(name string, provider ProviderConfig) error { configMu.Lock() defer configMu.Unlock() cfg, err := loadLocked() if err != nil { return err } for i := range cfg.Providers { if cfg.Providers[i].Name == name { provider.Name = name // name is immutable in update cfg.Providers[i] = provider return saveConfig(cfg) } } return fmt.Errorf("global provider %q not found", name) } // RemoveGlobalProvider removes a provider from top-level [[providers]] and // also strips the name from every project's provider_refs, then saves. func RemoveGlobalProvider(name string) error { configMu.Lock() defer configMu.Unlock() cfg, err := loadLocked() if err != nil { return err } found := false for i := range cfg.Providers { if cfg.Providers[i].Name == name { cfg.Providers = append(cfg.Providers[:i], cfg.Providers[i+1:]...) found = true break } } if !found { return fmt.Errorf("global provider %q not found", name) } for i := range cfg.Projects { refs := cfg.Projects[i].Agent.ProviderRefs for j := 0; j < len(refs); j++ { if refs[j] == name { cfg.Projects[i].Agent.ProviderRefs = append(refs[:j], refs[j+1:]...) break } } } return saveConfig(cfg) } func loadLocked() (*Config, error) { if ConfigPath == "" { return nil, fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } return cfg, nil } func saveConfig(cfg *Config) error { dir := filepath.Dir(ConfigPath) tmp, err := os.CreateTemp(dir, ".config-*.tmp") if err != nil { return fmt.Errorf("create temp config: %w", err) } tmpPath := tmp.Name() var buf strings.Builder if err := toml.NewEncoder(&buf).Encode(cfg); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("encode config: %w", err) } formatted := formatTOML(buf.String()) if _, err := tmp.WriteString(formatted); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("write config: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() os.Remove(tmpPath) return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } return os.Rename(tmpPath, ConfigPath) } // formatTOML post-processes raw TOML encoder output to improve readability: // - inserts blank lines before section/array-table headers // - removes empty section headers (no key-value pairs between this header and the next) // // It deliberately keeps all key-value lines intact, including zero-value ones // (e.g. `thinking_messages = false`, `port = 0`), because those may be explicitly set by the user. func formatTOML(raw string) string { lines := strings.Split(raw, "\n") // Pass 1: identify empty sections (header followed only by blank lines // until the next header or EOF). skipSection := make(map[int]bool) for i, line := range lines { trimmed := strings.TrimSpace(line) if len(trimmed) == 0 || trimmed[0] != '[' { continue } hasContent := false for j := i + 1; j < len(lines); j++ { t := strings.TrimSpace(lines[j]) if len(t) > 0 && t[0] == '[' { break } if t != "" { hasContent = true break } } if !hasContent { skipSection[i] = true } } // Pass 2: strip trailing whitespace from each line, skip empty sections, // ensure a blank line before section headers, and collapse consecutive // blank lines into one. var out []string prevBlank := false for i, line := range lines { if skipSection[i] { continue } line = strings.TrimRight(line, " \t") trimmed := strings.TrimSpace(line) isBlank := trimmed == "" if isBlank { if prevBlank { continue } prevBlank = true out = append(out, "") continue } prevBlank = false if trimmed[0] == '[' { if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { out = append(out, "") } } out = append(out, line) } // Trim leading and trailing blank lines, then ensure single trailing newline. for len(out) > 0 && strings.TrimSpace(out[0]) == "" { out = out[1:] } for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { out = out[:len(out)-1] } return strings.Join(out, "\n") + "\n" } // SaveLanguage saves the language setting to the config file. // Uses surgical text editing to preserve comments and unknown fields. func SaveLanguage(lang string) error { configMu.Lock() defer configMu.Unlock() return patchTopLevelField("language", lang) } // ListProjects returns project names from the config file. func ListProjects() ([]string, error) { if ConfigPath == "" { return nil, fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } var names []string for _, p := range cfg.Projects { names = append(names, p.Name) } return names, nil } // AddCommand adds a global custom command and persists to config. func AddCommand(cmd CommandConfig) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } for _, c := range cfg.Commands { if c.Name == cmd.Name { return fmt.Errorf("command %q already exists", cmd.Name) } } cfg.Commands = append(cfg.Commands, cmd) return saveConfig(cfg) } // RemoveCommand removes a global custom command and persists to config. func RemoveCommand(name string) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } found := false var remaining []CommandConfig for _, c := range cfg.Commands { if c.Name == name { found = true } else { remaining = append(remaining, c) } } if !found { return fmt.Errorf("command %q not found", name) } cfg.Commands = remaining return saveConfig(cfg) } // AddAlias adds a global alias and persists to config. func AddAlias(alias AliasConfig) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } for i, a := range cfg.Aliases { if a.Name == alias.Name { cfg.Aliases[i] = alias return saveConfig(cfg) } } cfg.Aliases = append(cfg.Aliases, alias) return saveConfig(cfg) } // RemoveAlias removes a global alias and persists to config. func RemoveAlias(name string) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } found := false var remaining []AliasConfig for _, a := range cfg.Aliases { if a.Name == name { found = true } else { remaining = append(remaining, a) } } if !found { return fmt.Errorf("alias %q not found", name) } cfg.Aliases = remaining return saveConfig(cfg) } // SaveDisplayConfig persists the display settings to the config file. // Uses surgical text editing to preserve comments and unknown fields. func SaveDisplayConfig(mode *string, thinkingMessages *bool, thinkingMaxLen, toolMaxLen *int, toolMessages *bool) error { configMu.Lock() defer configMu.Unlock() if mode != nil { if err := patchSectionField("display", "mode", quoteTomlString(*mode)); err != nil { return err } } if thinkingMessages != nil { if err := patchSectionField("display", "thinking_messages", fmt.Sprintf("%t", *thinkingMessages)); err != nil { return err } } if thinkingMaxLen != nil { if err := patchSectionField("display", "thinking_max_len", fmt.Sprintf("%d", *thinkingMaxLen)); err != nil { return err } } if toolMaxLen != nil { if err := patchSectionField("display", "tool_max_len", fmt.Sprintf("%d", *toolMaxLen)); err != nil { return err } } if toolMessages != nil { if err := patchSectionField("display", "tool_messages", fmt.Sprintf("%t", *toolMessages)); err != nil { return err } } return nil } // SaveTTSMode persists the TTS mode setting to the config file. // Uses surgical text editing to preserve comments and unknown fields. func SaveTTSMode(mode string) error { configMu.Lock() defer configMu.Unlock() return patchSectionField("tts", "tts_mode", quoteTomlString(mode)) } // GetProjectProviders returns providers for a given project. func GetProjectProviders(projectName string) ([]ProviderConfig, string, error) { if ConfigPath == "" { return nil, "", fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, "", fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, "", fmt.Errorf("parse config: %w", err) } for _, p := range cfg.Projects { if p.Name == projectName { active, _ := p.Agent.Options["provider"].(string) return p.Agent.Providers, active, nil } } return nil, "", fmt.Errorf("project %q not found", projectName) } // FeishuCredentialUpdateOptions controls how Feishu/Lark platform credentials // are written back into config.toml for a specific project. type FeishuCredentialUpdateOptions struct { ProjectName string // required PlatformIndex int // 1-based index among feishu/lark platforms in the project; 0 = first PlatformType string // optional target type: "feishu" or "lark"; empty keeps existing type AppID string // required AppSecret string // required OwnerOpenID string // optional owner id from onboarding flow SetAllowFromEmpty bool // when true, seed/append allow_from with OwnerOpenID while preserving "*" } // EnsureProjectWithFeishuOptions controls project auto-provisioning for Feishu/Lark setup. type EnsureProjectWithFeishuOptions struct { ProjectName string // required PlatformType string // optional: "feishu" or "lark", default "feishu" CloneFromProject string // optional source project name to clone agent config from WorkDir string // optional default work_dir when creating project AgentType string // optional default agent type when no source project exists, default "codex" } // EnsureProjectWithFeishuResult describes whether project provisioning created a new project. type EnsureProjectWithFeishuResult struct { Created bool AddedPlatform bool ProjectIndex int PlatformAbsIndex int // first feishu/lark platform in project, -1 if absent PlatformType string } // FeishuCredentialUpdateResult describes where credentials were written. type FeishuCredentialUpdateResult struct { ProjectName string ProjectIndex int PlatformAbsIndex int // absolute index in projects[i].platforms PlatformType string AllowFrom string } // EnsureProjectWithFeishuPlatform ensures target project exists. If project does // not exist, it creates one with a Feishu/Lark platform so credentials can be // written immediately. func EnsureProjectWithFeishuPlatform(opts EnsureProjectWithFeishuOptions) (*EnsureProjectWithFeishuResult, error) { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return nil, fmt.Errorf("config path not set") } projectName := strings.TrimSpace(opts.ProjectName) if projectName == "" { return nil, fmt.Errorf("project name is required") } platformType := strings.ToLower(strings.TrimSpace(opts.PlatformType)) if platformType == "" { platformType = "feishu" } if platformType != "feishu" && platformType != "lark" { return nil, fmt.Errorf("invalid platform type %q (want feishu or lark)", opts.PlatformType) } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, fmt.Errorf("read config: %w", err) } raw := string(data) cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } for i := range cfg.Projects { if cfg.Projects[i].Name != projectName { continue } platformIdx := firstFeishuPlatformIndex(cfg.Projects[i].Platforms) added := false if platformIdx < 0 { lines, hadTrailing := splitConfigLines(raw) spans := buildRawProjectSpans(lines) if i >= len(spans) { return nil, fmt.Errorf("project %q located in parsed config but not raw file", projectName) } insertAt := spans[i].end + 1 block := make([]string, 0, 7) if insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) != "" { block = append(block, "") } block = append(block, "[[projects.platforms]]") block = append(block, fmt.Sprintf("type = %s", quoteTomlString(platformType))) block = append(block, "") block = append(block, "[projects.platforms.options]") if insertAt < len(lines) && strings.TrimSpace(lines[insertAt]) != "" { block = append(block, "") } lines = insertLines(lines, insertAt, block) if err := writeRawConfig(joinConfigLines(lines, hadTrailing)); err != nil { return nil, err } platformIdx = len(cfg.Projects[i].Platforms) added = true } return &EnsureProjectWithFeishuResult{ Created: false, AddedPlatform: added, ProjectIndex: i, PlatformAbsIndex: platformIdx, PlatformType: platformType, }, nil } proj := ProjectConfig{ Name: projectName, Agent: pickAgentTemplateForNewProject(cfg, opts), Platforms: []PlatformConfig{{Type: platformType, Options: map[string]any{}}}, } if proj.Agent.Type == "" { proj.Agent.Type = "codex" } if proj.Agent.Options == nil { proj.Agent.Options = map[string]any{} } workDir := strings.TrimSpace(opts.WorkDir) if workDir != "" { proj.Agent.Options["work_dir"] = workDir } lines, hadTrailing := splitConfigLines(raw) if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) != "" { lines = append(lines, "") } lines = append(lines, "[[projects]]") lines = append(lines, fmt.Sprintf("name = %s", quoteTomlString(proj.Name))) lines = append(lines, "") lines = append(lines, "[projects.agent]") lines = append(lines, fmt.Sprintf("type = %s", quoteTomlString(proj.Agent.Type))) lines = append(lines, "") lines = append(lines, "[projects.agent.options]") if wd, ok := proj.Agent.Options["work_dir"].(string); ok && strings.TrimSpace(wd) != "" { lines = append(lines, fmt.Sprintf("work_dir = %s", quoteTomlString(wd))) } if mode, ok := proj.Agent.Options["mode"].(string); ok && strings.TrimSpace(mode) != "" { lines = append(lines, fmt.Sprintf("mode = %s", quoteTomlString(mode))) } lines = append(lines, "") lines = append(lines, "[[projects.platforms]]") lines = append(lines, fmt.Sprintf("type = %s", quoteTomlString(platformType))) lines = append(lines, "") lines = append(lines, "[projects.platforms.options]") if err := writeRawConfig(joinConfigLines(lines, hadTrailing)); err != nil { return nil, err } return &EnsureProjectWithFeishuResult{ Created: true, AddedPlatform: false, ProjectIndex: len(cfg.Projects) - 1, PlatformAbsIndex: len(cfg.Projects[len(cfg.Projects)-1].Platforms) - 1, PlatformType: platformType, }, nil } // SaveFeishuPlatformCredentials updates app_id/app_secret for a project's // Feishu/Lark platform and persists the config atomically. func SaveFeishuPlatformCredentials(opts FeishuCredentialUpdateOptions) (*FeishuCredentialUpdateResult, error) { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return nil, fmt.Errorf("config path not set") } if strings.TrimSpace(opts.ProjectName) == "" { return nil, fmt.Errorf("project name is required") } if strings.TrimSpace(opts.AppID) == "" || strings.TrimSpace(opts.AppSecret) == "" { return nil, fmt.Errorf("app_id and app_secret are required") } if opts.PlatformIndex < 0 { return nil, fmt.Errorf("platform index must be >= 0") } if opts.PlatformType != "" && opts.PlatformType != "feishu" && opts.PlatformType != "lark" { return nil, fmt.Errorf("invalid platform type %q (want feishu or lark)", opts.PlatformType) } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, fmt.Errorf("read config: %w", err) } raw := string(data) cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } projectIdx := -1 for i := range cfg.Projects { if cfg.Projects[i].Name == opts.ProjectName { projectIdx = i break } } if projectIdx < 0 { return nil, fmt.Errorf("project %q not found", opts.ProjectName) } proj := &cfg.Projects[projectIdx] candidates := make([]int, 0, len(proj.Platforms)) for i := range proj.Platforms { t := strings.ToLower(strings.TrimSpace(proj.Platforms[i].Type)) if t == "feishu" || t == "lark" { candidates = append(candidates, i) } } if len(candidates) == 0 { return nil, fmt.Errorf("project %q has no feishu/lark platform", opts.ProjectName) } targetPos := 0 if opts.PlatformIndex > 0 { targetPos = opts.PlatformIndex - 1 } if targetPos < 0 || targetPos >= len(candidates) { return nil, fmt.Errorf( "platform index %d out of range: project %q has %d feishu/lark platform(s)", opts.PlatformIndex, opts.ProjectName, len(candidates), ) } absIdx := candidates[targetPos] platform := &proj.Platforms[absIdx] if opts.PlatformType != "" { platform.Type = opts.PlatformType } if platform.Options == nil { platform.Options = map[string]any{} } platform.Options["app_id"] = strings.TrimSpace(opts.AppID) platform.Options["app_secret"] = strings.TrimSpace(opts.AppSecret) allowFrom := strings.TrimSpace(stringOption(platform.Options["allow_from"])) if opts.SetAllowFromEmpty && strings.TrimSpace(opts.OwnerOpenID) != "" { allowFrom = mergeAllowFromValue(allowFrom, strings.TrimSpace(opts.OwnerOpenID)) if allowFrom != "" { platform.Options["allow_from"] = allowFrom } } lines, hadTrailing := splitConfigLines(raw) spans := buildRawProjectSpans(lines) if projectIdx >= len(spans) { return nil, fmt.Errorf("project %q located in parsed config but not raw file", opts.ProjectName) } if absIdx >= len(spans[projectIdx].platforms) { return nil, fmt.Errorf("feishu/lark platform located in parsed config but not raw file") } reloadSpan := func() rawPlatformSpan { spans = buildRawProjectSpans(lines) return spans[projectIdx].platforms[absIdx] } span := spans[projectIdx].platforms[absIdx] if opts.PlatformType != "" { if span.typeLine >= 0 { lines[span.typeLine] = replaceTomlStringKeyLine(lines[span.typeLine], "type", opts.PlatformType) } else { lines = insertLines(lines, span.start+1, []string{fmt.Sprintf("type = %s", quoteTomlString(opts.PlatformType))}) } span = reloadSpan() } if span.optionsStart < 0 { insertAt := span.end + 1 block := make([]string, 0, 4) if insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) != "" { block = append(block, "") } block = append(block, "[projects.platforms.options]") if insertAt < len(lines) && strings.TrimSpace(lines[insertAt]) != "" { block = append(block, "") } lines = insertLines(lines, insertAt, block) span = reloadSpan() } lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "app_id", strings.TrimSpace(opts.AppID)) span = reloadSpan() lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "app_secret", strings.TrimSpace(opts.AppSecret)) span = reloadSpan() if opts.SetAllowFromEmpty && strings.TrimSpace(opts.OwnerOpenID) != "" { lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "allow_from", allowFrom) span = reloadSpan() } if err := writeRawConfig(joinConfigLines(lines, hadTrailing)); err != nil { return nil, err } return &FeishuCredentialUpdateResult{ ProjectName: opts.ProjectName, ProjectIndex: projectIdx, PlatformAbsIndex: absIdx, PlatformType: platform.Type, AllowFrom: allowFrom, }, nil } func stringOption(v any) string { if s, ok := v.(string); ok { return s } return "" } func mergeAllowFromValue(current, userID string) string { current = strings.TrimSpace(current) userID = strings.TrimSpace(userID) if current == "*" || userID == "" { return current } if current == "" { return userID } parts := strings.Split(current, ",") merged := make([]string, 0, len(parts)+1) seen := make(map[string]struct{}, len(parts)+1) appendPart := func(v string) { v = strings.TrimSpace(v) if v == "" { return } if v == "*" { merged = []string{"*"} return } if _, ok := seen[v]; ok { return } seen[v] = struct{}{} merged = append(merged, v) } for _, part := range parts { if len(merged) == 1 && merged[0] == "*" { return "*" } appendPart(part) } if len(merged) == 1 && merged[0] == "*" { return "*" } appendPart(userID) if len(merged) == 1 && merged[0] == "*" { return "*" } return strings.Join(merged, ",") } func firstFeishuPlatformIndex(platforms []PlatformConfig) int { for i := range platforms { t := strings.ToLower(strings.TrimSpace(platforms[i].Type)) if t == "feishu" || t == "lark" { return i } } return -1 } func firstWeixinPlatformIndex(platforms []PlatformConfig) int { for i := range platforms { t := strings.ToLower(strings.TrimSpace(platforms[i].Type)) if t == "weixin" { return i } } return -1 } // EnsureProjectWithWeixinOptions controls project auto-provisioning for Weixin (ilink) setup. type EnsureProjectWithWeixinOptions struct { ProjectName string CloneFromProject string WorkDir string AgentType string } // EnsureProjectWithWeixinResult describes whether project provisioning created a new project or platform block. type EnsureProjectWithWeixinResult struct { Created bool AddedPlatform bool ProjectIndex int PlatformAbsIndex int } // WeixinCredentialUpdateOptions updates token (and optional URLs) for a project's Weixin platform. type WeixinCredentialUpdateOptions struct { ProjectName string PlatformIndex int // 1-based index among weixin platforms; 0 = first Token string BaseURL string // optional; empty = do not change in TOML CDNBaseURL string // optional; empty = do not change AccountID string // optional ilink_bot_id → options.account_id ScannedUserID string // optional ilink_user_id for allow_from merge when SetAllowFromEmpty SetAllowFromEmpty bool } // WeixinCredentialUpdateResult describes where credentials were written. type WeixinCredentialUpdateResult struct { ProjectName string ProjectIndex int PlatformAbsIndex int AllowFrom string } // EnsureProjectWithWeixinPlatform ensures the target project exists and has a weixin platform entry. func EnsureProjectWithWeixinPlatform(opts EnsureProjectWithWeixinOptions) (*EnsureProjectWithWeixinResult, error) { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return nil, fmt.Errorf("config path not set") } projectName := strings.TrimSpace(opts.ProjectName) if projectName == "" { return nil, fmt.Errorf("project name is required") } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, fmt.Errorf("read config: %w", err) } raw := string(data) cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } for i := range cfg.Projects { if cfg.Projects[i].Name != projectName { continue } platformIdx := firstWeixinPlatformIndex(cfg.Projects[i].Platforms) added := false if platformIdx < 0 { lines, hadTrailing := splitConfigLines(raw) spans := buildRawProjectSpans(lines) if i >= len(spans) { return nil, fmt.Errorf("project %q located in parsed config but not raw file", projectName) } insertAt := spans[i].end + 1 block := make([]string, 0, 7) if insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) != "" { block = append(block, "") } block = append(block, "[[projects.platforms]]") block = append(block, `type = "weixin"`) block = append(block, "") block = append(block, "[projects.platforms.options]") if insertAt < len(lines) && strings.TrimSpace(lines[insertAt]) != "" { block = append(block, "") } lines = insertLines(lines, insertAt, block) if err := writeRawConfig(joinConfigLines(lines, hadTrailing)); err != nil { return nil, err } platformIdx = len(cfg.Projects[i].Platforms) added = true } return &EnsureProjectWithWeixinResult{ Created: false, AddedPlatform: added, ProjectIndex: i, PlatformAbsIndex: platformIdx, }, nil } proj := ProjectConfig{ Name: projectName, Agent: pickAgentTemplateForNewProject(cfg, EnsureProjectWithFeishuOptions{CloneFromProject: opts.CloneFromProject, WorkDir: opts.WorkDir, AgentType: opts.AgentType}), Platforms: []PlatformConfig{{Type: "weixin", Options: map[string]any{}}}, } if proj.Agent.Type == "" { proj.Agent.Type = "codex" } if proj.Agent.Options == nil { proj.Agent.Options = map[string]any{} } workDir := strings.TrimSpace(opts.WorkDir) if workDir != "" { proj.Agent.Options["work_dir"] = workDir } lines, hadTrailing := splitConfigLines(raw) if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) != "" { lines = append(lines, "") } lines = append(lines, "[[projects]]") lines = append(lines, fmt.Sprintf("name = %s", quoteTomlString(proj.Name))) lines = append(lines, "") lines = append(lines, "[projects.agent]") lines = append(lines, fmt.Sprintf("type = %s", quoteTomlString(proj.Agent.Type))) lines = append(lines, "") lines = append(lines, "[projects.agent.options]") if wd, ok := proj.Agent.Options["work_dir"].(string); ok && strings.TrimSpace(wd) != "" { lines = append(lines, fmt.Sprintf("work_dir = %s", quoteTomlString(wd))) } if mode, ok := proj.Agent.Options["mode"].(string); ok && strings.TrimSpace(mode) != "" { lines = append(lines, fmt.Sprintf("mode = %s", quoteTomlString(mode))) } lines = append(lines, "") lines = append(lines, "[[projects.platforms]]") lines = append(lines, `type = "weixin"`) lines = append(lines, "") lines = append(lines, "[projects.platforms.options]") if err := writeRawConfig(joinConfigLines(lines, hadTrailing)); err != nil { return nil, err } return &EnsureProjectWithWeixinResult{ Created: true, AddedPlatform: false, ProjectIndex: len(cfg.Projects), PlatformAbsIndex: 0, }, nil } // SaveWeixinPlatformCredentials updates token (and optional fields) for a project's Weixin platform. func SaveWeixinPlatformCredentials(opts WeixinCredentialUpdateOptions) (*WeixinCredentialUpdateResult, error) { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return nil, fmt.Errorf("config path not set") } if strings.TrimSpace(opts.ProjectName) == "" { return nil, fmt.Errorf("project name is required") } if strings.TrimSpace(opts.Token) == "" { return nil, fmt.Errorf("token is required") } if opts.PlatformIndex < 0 { return nil, fmt.Errorf("platform index must be >= 0") } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, fmt.Errorf("read config: %w", err) } raw := string(data) cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } projectIdx := -1 for i := range cfg.Projects { if cfg.Projects[i].Name == opts.ProjectName { projectIdx = i break } } if projectIdx < 0 { return nil, fmt.Errorf("project %q not found", opts.ProjectName) } proj := &cfg.Projects[projectIdx] candidates := make([]int, 0, len(proj.Platforms)) for i := range proj.Platforms { t := strings.ToLower(strings.TrimSpace(proj.Platforms[i].Type)) if t == "weixin" { candidates = append(candidates, i) } } if len(candidates) == 0 { return nil, fmt.Errorf("project %q has no weixin platform", opts.ProjectName) } targetPos := 0 if opts.PlatformIndex > 0 { targetPos = opts.PlatformIndex - 1 } if targetPos < 0 || targetPos >= len(candidates) { return nil, fmt.Errorf( "platform index %d out of range: project %q has %d weixin platform(s)", opts.PlatformIndex, opts.ProjectName, len(candidates), ) } absIdx := candidates[targetPos] platform := &proj.Platforms[absIdx] if platform.Options == nil { platform.Options = map[string]any{} } token := strings.TrimSpace(opts.Token) platform.Options["token"] = token if u := strings.TrimSpace(opts.BaseURL); u != "" { platform.Options["base_url"] = u } if u := strings.TrimSpace(opts.CDNBaseURL); u != "" { platform.Options["cdn_base_url"] = u } if id := strings.TrimSpace(opts.AccountID); id != "" { platform.Options["account_id"] = id } allowFrom := strings.TrimSpace(stringOption(platform.Options["allow_from"])) if opts.SetAllowFromEmpty && strings.TrimSpace(opts.ScannedUserID) != "" { allowFrom = mergeAllowFromValue(allowFrom, strings.TrimSpace(opts.ScannedUserID)) if allowFrom != "" { platform.Options["allow_from"] = allowFrom } } lines, hadTrailing := splitConfigLines(raw) spans := buildRawProjectSpans(lines) if projectIdx >= len(spans) { return nil, fmt.Errorf("project %q located in parsed config but not raw file", opts.ProjectName) } if absIdx >= len(spans[projectIdx].platforms) { return nil, fmt.Errorf("weixin platform located in parsed config but not raw file") } reloadSpan := func() rawPlatformSpan { spans = buildRawProjectSpans(lines) return spans[projectIdx].platforms[absIdx] } span := spans[projectIdx].platforms[absIdx] if span.optionsStart < 0 { insertAt := span.end + 1 block := make([]string, 0, 4) if insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) != "" { block = append(block, "") } block = append(block, "[projects.platforms.options]") if insertAt < len(lines) && strings.TrimSpace(lines[insertAt]) != "" { block = append(block, "") } lines = insertLines(lines, insertAt, block) span = reloadSpan() } lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "token", token) span = reloadSpan() if u := strings.TrimSpace(opts.BaseURL); u != "" { lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "base_url", u) span = reloadSpan() } if u := strings.TrimSpace(opts.CDNBaseURL); u != "" { lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "cdn_base_url", u) span = reloadSpan() } if id := strings.TrimSpace(opts.AccountID); id != "" { lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "account_id", id) span = reloadSpan() } if opts.SetAllowFromEmpty && strings.TrimSpace(opts.ScannedUserID) != "" { lines = upsertTomlStringKey(lines, span.optionsStart+1, span.optionsEnd, "allow_from", allowFrom) span = reloadSpan() } if err := writeRawConfig(joinConfigLines(lines, hadTrailing)); err != nil { return nil, err } return &WeixinCredentialUpdateResult{ ProjectName: opts.ProjectName, ProjectIndex: projectIdx, PlatformAbsIndex: absIdx, AllowFrom: allowFrom, }, nil } func pickAgentTemplateForNewProject(cfg *Config, opts EnsureProjectWithFeishuOptions) AgentConfig { cloneName := strings.TrimSpace(opts.CloneFromProject) if cloneName != "" { for i := range cfg.Projects { if cfg.Projects[i].Name == cloneName { return cloneAgentConfig(cfg.Projects[i].Agent) } } } if agentType := strings.TrimSpace(opts.AgentType); agentType != "" { realType, preset, _ := strings.Cut(agentType, ":") agentOpts := map[string]any{} if realType == "acp" && preset != "" { agentOpts["command"] = preset agentOpts["display_name"] = preset } return AgentConfig{ Type: realType, Options: agentOpts, } } if len(cfg.Projects) > 0 { return cloneAgentConfig(cfg.Projects[0].Agent) } return AgentConfig{ Type: "codex", Options: map[string]any{}, } } func cloneAgentConfig(in AgentConfig) AgentConfig { out := AgentConfig{ Type: in.Type, Options: cloneAnyMap(in.Options), } if len(in.Providers) > 0 { out.Providers = make([]ProviderConfig, len(in.Providers)) for i := range in.Providers { p := ProviderConfig{ Name: in.Providers[i].Name, APIKey: in.Providers[i].APIKey, BaseURL: in.Providers[i].BaseURL, Model: in.Providers[i].Model, Models: append([]ProviderModelConfig(nil), in.Providers[i].Models...), Thinking: in.Providers[i].Thinking, Env: cloneStringMap(in.Providers[i].Env), Endpoints: cloneStringMap(in.Providers[i].Endpoints), AgentModels: cloneStringMap(in.Providers[i].AgentModels), } if len(in.Providers[i].AgentModelLists) > 0 { p.AgentModelLists = make(map[string][]ProviderModelConfig, len(in.Providers[i].AgentModelLists)) for k, v := range in.Providers[i].AgentModelLists { p.AgentModelLists[k] = append([]ProviderModelConfig(nil), v...) } } if in.Providers[i].Codex != nil { p.Codex = &CodexProviderConfig{ EnvKey: in.Providers[i].Codex.EnvKey, WireAPI: in.Providers[i].Codex.WireAPI, HTTPHeaders: cloneStringMap(in.Providers[i].Codex.HTTPHeaders), } } out.Providers[i] = p } } return out } func cloneAnyMap(in map[string]any) map[string]any { if in == nil { return map[string]any{} } out := make(map[string]any, len(in)) for k, v := range in { out[k] = v } return out } func cloneStringMap(in map[string]string) map[string]string { if in == nil { return nil } out := make(map[string]string, len(in)) for k, v := range in { out[k] = v } return out } // patchProjectAgentOption does a surgical text-level update of a single key // under [projects.agent.options] for the given project. It preserves all // comments, unknown fields, and formatting in the config file. // The caller must hold configMu. func patchProjectAgentOption(projectName, key, value string) error { if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } raw := string(data) cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } projectIdx := -1 for i := range cfg.Projects { if cfg.Projects[i].Name == projectName { projectIdx = i break } } if projectIdx < 0 { return fmt.Errorf("project %q not found in config", projectName) } lines, hadTrailing := splitConfigLines(raw) spans := buildRawProjectSpans(lines) if projectIdx >= len(spans) { return fmt.Errorf("project %q located in parsed config but not raw file", projectName) } projSpan := spans[projectIdx] if projSpan.agentOptionsStart < 0 { // [projects.agent.options] doesn't exist; create it. insertAt := projSpan.agentEnd + 1 if projSpan.agentStart < 0 { // [projects.agent] also doesn't exist; insert after [[projects]] header + name line insertAt = projSpan.start + 1 for ln := projSpan.start + 1; ln <= projSpan.end; ln++ { if isAnyTableHeader(lines[ln]) { insertAt = ln break } insertAt = ln + 1 } block := []string{"", "[projects.agent]", "type = \"claudecode\"", "", "[projects.agent.options]"} lines = insertLines(lines, insertAt, block) } else { block := []string{"", "[projects.agent.options]"} lines = insertLines(lines, insertAt, block) } spans = buildRawProjectSpans(lines) projSpan = spans[projectIdx] } lines = upsertTomlStringKey(lines, projSpan.agentOptionsStart+1, projSpan.agentOptionsEnd, key, value) return writeRawConfig(joinConfigLines(lines, hadTrailing)) } // patchTopLevelField does a surgical text-level update of a single top-level // key in the config file. The caller must hold configMu. func patchTopLevelField(key, value string) error { if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } raw := string(data) lines, hadTrailing := splitConfigLines(raw) // Top-level keys appear before the first section header. topEnd := len(lines) - 1 for i := range lines { if isAnyTableHeader(lines[i]) { topEnd = i - 1 break } } for i := 0; i <= topEnd && i < len(lines); i++ { if matchTomlStringKey(lines[i], key) { lines[i] = replaceTomlStringKeyLine(lines[i], key, value) return writeRawConfig(joinConfigLines(lines, hadTrailing)) } } // Key not found; insert before the first section header. insertAt := topEnd + 1 if insertAt < 0 { insertAt = 0 } lines = insertLines(lines, insertAt, []string{fmt.Sprintf("%s = %s", key, quoteTomlString(value))}) return writeRawConfig(joinConfigLines(lines, hadTrailing)) } // patchSectionField does a surgical text-level update of a single key // under a given [section] in the config file. The caller must hold configMu. func patchSectionField(section, key, tomlValue string) error { if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } raw := string(data) lines, hadTrailing := splitConfigLines(raw) sectionStart := -1 sectionEnd := len(lines) - 1 header := "[" + section + "]" for i := range lines { if sectionStart < 0 && matchTableHeader(lines[i], header) { sectionStart = i continue } if sectionStart >= 0 && isAnyTableHeader(lines[i]) { sectionEnd = i - 1 break } } if sectionStart < 0 { topEnd := len(lines) - 1 for i := range lines { if isAnyTableHeader(lines[i]) { topEnd = i - 1 break } } insertAt := topEnd + 1 if insertAt < 0 { insertAt = 0 } block := []string{"", header, fmt.Sprintf("%s = %s", key, tomlValue)} lines = insertLines(lines, insertAt, block) return writeRawConfig(joinConfigLines(lines, hadTrailing)) } lines = upsertTomlRawKey(lines, sectionStart+1, sectionEnd, key, tomlValue) return writeRawConfig(joinConfigLines(lines, hadTrailing)) } type rawProjectSpan struct { start int end int platforms []rawPlatformSpan agentStart int // [projects.agent] header; -1 if absent agentEnd int // last line before the next header or project end agentOptionsStart int // [projects.agent.options] header; -1 if absent agentOptionsEnd int // last line of agent options section agentProviders []rawProviderSpan } type rawProviderSpan struct { start int // [[projects.agent.providers]] header end int nameLine int // line with name = "..." } type rawPlatformSpan struct { start int end int typeLine int optionsStart int optionsEnd int } func splitConfigLines(raw string) ([]string, bool) { if raw == "" { return []string{}, false } hadTrailing := strings.HasSuffix(raw, "\n") raw = strings.TrimSuffix(raw, "\n") if raw == "" { return []string{}, hadTrailing } return strings.Split(raw, "\n"), hadTrailing } func joinConfigLines(lines []string, hadTrailing bool) string { out := strings.Join(lines, "\n") if hadTrailing || len(lines) > 0 { out += "\n" } return out } func buildRawProjectSpans(lines []string) []rawProjectSpan { projectStarts := make([]int, 0, 4) for i := range lines { if matchTableHeader(lines[i], "[[projects]]") { projectStarts = append(projectStarts, i) } } if len(projectStarts) == 0 { return nil } spans := make([]rawProjectSpan, 0, len(projectStarts)) for i, start := range projectStarts { end := len(lines) - 1 if i+1 < len(projectStarts) { end = projectStarts[i+1] - 1 } span := rawProjectSpan{ start: start, end: end, agentStart: -1, agentEnd: -1, agentOptionsStart: -1, agentOptionsEnd: -1, } for ln := start + 1; ln <= end; ln++ { if matchTableHeader(lines[ln], "[projects.agent]") && !matchTableHeader(lines[ln], "[projects.agent.options]") && !matchTableHeader(lines[ln], "[[projects.agent.providers]]") { span.agentStart = ln span.agentEnd = end for j := ln + 1; j <= end; j++ { if isAnyTableHeader(lines[j]) { span.agentEnd = j - 1 break } } } if matchTableHeader(lines[ln], "[projects.agent.options]") { span.agentOptionsStart = ln span.agentOptionsEnd = end for j := ln + 1; j <= end; j++ { if isAnyTableHeader(lines[j]) { span.agentOptionsEnd = j - 1 break } } } if matchTableHeader(lines[ln], "[[projects.agent.providers]]") { provSpan := rawProviderSpan{start: ln, end: end, nameLine: -1} for j := ln + 1; j <= end; j++ { if isAnyTableHeader(lines[j]) { provSpan.end = j - 1 break } } for j := ln + 1; j <= provSpan.end; j++ { if matchTomlStringKey(lines[j], "name") { provSpan.nameLine = j break } } span.agentProviders = append(span.agentProviders, provSpan) } } platformStarts := make([]int, 0, 2) for ln := start + 1; ln <= end; ln++ { if matchTableHeader(lines[ln], "[[projects.platforms]]") { platformStarts = append(platformStarts, ln) } } for p, pstart := range platformStarts { pend := end if p+1 < len(platformStarts) { pend = platformStarts[p+1] - 1 } ps := rawPlatformSpan{ start: pstart, end: pend, typeLine: -1, optionsStart: -1, optionsEnd: -1, } inMainPlatformTable := true for ln := pstart + 1; ln <= pend; ln++ { if isAnyTableHeader(lines[ln]) { inMainPlatformTable = false } if inMainPlatformTable && ps.typeLine < 0 && matchTomlStringKey(lines[ln], "type") { ps.typeLine = ln } if ps.optionsStart < 0 && matchTableHeader(lines[ln], "[projects.platforms.options]") { ps.optionsStart = ln ps.optionsEnd = pend for j := ln + 1; j <= pend; j++ { if isAnyTableHeader(lines[j]) { ps.optionsEnd = j - 1 break } } } } span.platforms = append(span.platforms, ps) } spans = append(spans, span) } return spans } func matchTableHeader(line, header string) bool { t := strings.TrimSpace(line) if !strings.HasPrefix(t, header) { return false } if len(t) == len(header) { return true } next := t[len(header)] return next == ' ' || next == '\t' || next == '#' } func isAnyTableHeader(line string) bool { t := strings.TrimSpace(line) return strings.HasPrefix(t, "[") } func matchTomlStringKey(line, key string) bool { t := strings.TrimSpace(line) if t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "[") { return false } if !strings.HasPrefix(t, key) { return false } rest := strings.TrimSpace(strings.TrimPrefix(t, key)) return strings.HasPrefix(rest, "=") } func insertLines(lines []string, at int, block []string) []string { if at < 0 { at = 0 } if at > len(lines) { at = len(lines) } out := make([]string, 0, len(lines)+len(block)) out = append(out, lines[:at]...) out = append(out, block...) out = append(out, lines[at:]...) return out } func upsertTomlStringKey(lines []string, start, end int, key, value string) []string { if start < 0 { start = 0 } if end >= len(lines) { end = len(lines) - 1 } for i := start; i <= end && i < len(lines); i++ { if matchTomlStringKey(lines[i], key) { lines[i] = replaceTomlStringKeyLine(lines[i], key, value) return lines } } insertAt := end + 1 if insertAt < start { insertAt = start } return insertLines(lines, insertAt, []string{fmt.Sprintf("%s = %s", key, quoteTomlString(value))}) } func replaceTomlStringKeyLine(line, key, value string) string { indent := leadingWhitespace(line) comment := extractLineComment(line) updated := fmt.Sprintf("%s%s = %s", indent, key, quoteTomlString(value)) if comment != "" { updated += " " + comment } return updated } // upsertTomlRawKey is like upsertTomlStringKey but writes the value literally // (no quoting). Use for booleans, integers, and pre-formatted values. func upsertTomlRawKey(lines []string, start, end int, key, rawValue string) []string { if start < 0 { start = 0 } if end >= len(lines) { end = len(lines) - 1 } for i := start; i <= end && i < len(lines); i++ { if matchTomlStringKey(lines[i], key) { indent := leadingWhitespace(lines[i]) comment := extractLineComment(lines[i]) lines[i] = fmt.Sprintf("%s%s = %s", indent, key, rawValue) if comment != "" { lines[i] += " " + comment } return lines } } insertAt := end + 1 if insertAt < start { insertAt = start } return insertLines(lines, insertAt, []string{fmt.Sprintf("%s = %s", key, rawValue)}) } func quoteTomlString(value string) string { return strconv.Quote(value) } func leadingWhitespace(s string) string { i := 0 for i < len(s) { if s[i] != ' ' && s[i] != '\t' { break } i++ } return s[:i] } func extractLineComment(line string) string { inQuote := false escaped := false for i := 0; i < len(line); i++ { ch := line[i] if escaped { escaped = false continue } if ch == '\\' && inQuote { escaped = true continue } if ch == '"' { inQuote = !inQuote continue } if ch == '#' && !inQuote { return strings.TrimSpace(line[i:]) } } return "" } // ProjectSettingsUpdate carries optional field updates for SaveProjectSettings. type ProjectSettingsUpdate struct { Language *string AdminFrom *string DisabledCommands []string WorkDir *string Mode *string AgentType *string ShowContextIndicator *bool ReplyFooter *bool InjectSender *bool PlatformAllowFrom map[string]string } // SaveProjectSettings persists project-level settings and the global language to config.toml. func SaveProjectSettings(projectName string, update ProjectSettingsUpdate) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } if update.Language != nil { cfg.Language = *update.Language } for i := range cfg.Projects { if cfg.Projects[i].Name != projectName { continue } proj := &cfg.Projects[i] if update.AgentType != nil && *update.AgentType != proj.Agent.Type { newType := *update.AgentType proj.Agent.Type = newType // Filter out provider_refs incompatible with the new agent type. globalByName := make(map[string]ProviderConfig, len(cfg.Providers)) for _, p := range cfg.Providers { globalByName[p.Name] = p } var compatible []string for _, ref := range proj.Agent.ProviderRefs { gp, ok := globalByName[ref] if !ok { continue } if len(gp.AgentTypes) > 0 && !containsString(gp.AgentTypes, newType) { slog.Info("removing incompatible provider ref on agent type change", "project", projectName, "provider", ref, "provider_agents", gp.AgentTypes, "new_agent", newType) continue } compatible = append(compatible, ref) } proj.Agent.ProviderRefs = compatible // Clear active provider if it was removed. if opts := proj.Agent.Options; opts != nil { if prov, ok := opts["provider"].(string); ok && prov != "" { found := false for _, ref := range compatible { if ref == prov { found = true break } } if !found { delete(opts, "provider") } } } } if update.AdminFrom != nil { proj.AdminFrom = *update.AdminFrom } if update.DisabledCommands != nil { proj.DisabledCommands = update.DisabledCommands } if update.ShowContextIndicator != nil { v := *update.ShowContextIndicator proj.ShowContextIndicator = &v } if update.ReplyFooter != nil { v := *update.ReplyFooter proj.ReplyFooter = &v } if update.InjectSender != nil { v := *update.InjectSender proj.InjectSender = &v } if update.WorkDir != nil || update.Mode != nil { if proj.Agent.Options == nil { proj.Agent.Options = map[string]any{} } } if update.WorkDir != nil { wd := strings.TrimSpace(*update.WorkDir) if wd == "" { delete(proj.Agent.Options, "work_dir") } else { proj.Agent.Options["work_dir"] = wd } } if update.Mode != nil { mode := strings.TrimSpace(*update.Mode) if mode == "" { delete(proj.Agent.Options, "mode") } else { proj.Agent.Options["mode"] = mode } } if update.PlatformAllowFrom != nil { for j := range proj.Platforms { typ := strings.TrimSpace(proj.Platforms[j].Type) if typ == "" { continue } var af string var found bool for k, v := range update.PlatformAllowFrom { if strings.EqualFold(strings.TrimSpace(k), typ) { af, found = v, true break } } if !found { continue } if proj.Platforms[j].Options == nil { proj.Platforms[j].Options = map[string]any{} } proj.Platforms[j].Options["allow_from"] = strings.TrimSpace(af) } } return saveConfig(cfg) } return fmt.Errorf("project %q not found", projectName) } // GetProjectConfigDetails returns persisted project fields from the config file for the management API. func GetProjectConfigDetails(projectName string) map[string]any { if ConfigPath == "" { return nil } data, err := os.ReadFile(ConfigPath) if err != nil { return nil } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil } for _, p := range cfg.Projects { if p.Name != projectName { continue } result := map[string]any{} if p.Agent.Options != nil { if wd, ok := p.Agent.Options["work_dir"].(string); ok && strings.TrimSpace(wd) != "" { result["work_dir"] = wd } if mode, ok := p.Agent.Options["mode"].(string); ok && strings.TrimSpace(mode) != "" { result["mode"] = mode } } if p.ShowContextIndicator != nil { result["show_context_indicator"] = *p.ShowContextIndicator } if p.ReplyFooter != nil { result["reply_footer"] = *p.ReplyFooter } if p.InjectSender != nil { result["inject_sender"] = *p.InjectSender } platConfigs := make([]map[string]any, len(p.Platforms)) for j, plat := range p.Platforms { pc := map[string]any{"type": plat.Type} if plat.Options != nil { if af, ok := plat.Options["allow_from"].(string); ok { pc["allow_from"] = af } } platConfigs[j] = pc } result["platform_configs"] = platConfigs if len(p.Agent.ProviderRefs) > 0 { result["provider_refs"] = p.Agent.ProviderRefs } return result } return nil } // SaveProviderRefs updates provider_refs for a project. func SaveProviderRefs(projectName string, refs []string) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } for i := range cfg.Projects { if cfg.Projects[i].Name == projectName { cfg.Projects[i].Agent.ProviderRefs = refs return saveConfig(cfg) } } return fmt.Errorf("project %q not found", projectName) } // RemoveProject removes a project from the config file. func RemoveProject(projectName string) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } found := false for i := range cfg.Projects { if cfg.Projects[i].Name == projectName { cfg.Projects = append(cfg.Projects[:i], cfg.Projects[i+1:]...) found = true break } } if !found { return fmt.Errorf("project %q not found", projectName) } return saveConfig(cfg) } // AddPlatformToProject appends a platform config to a project. // If the project doesn't exist, it is created using agentType and workDir when provided, // otherwise agent config is cloned from the first existing project when present. func AddPlatformToProject(projectName string, platform PlatformConfig, workDir, agentType string) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } if platform.Options == nil { platform.Options = map[string]any{} } for i := range cfg.Projects { if cfg.Projects[i].Name == projectName { cfg.Projects[i].Platforms = append(cfg.Projects[i].Platforms, platform) return saveConfig(cfg) } } agentCfg := AgentConfig{Type: "codex", Options: map[string]any{}} at := strings.TrimSpace(agentType) if at != "" { agentCfg.Type = at } if len(cfg.Projects) > 0 && at == "" { agentCfg = cloneAgentConfig(cfg.Projects[0].Agent) } wd := strings.TrimSpace(workDir) if wd != "" { if agentCfg.Options == nil { agentCfg.Options = map[string]any{} } agentCfg.Options["work_dir"] = wd } cfg.Projects = append(cfg.Projects, ProjectConfig{ Name: projectName, Agent: agentCfg, Platforms: []PlatformConfig{platform}, }) return saveConfig(cfg) } func writeRawConfig(content string) error { content = formatTOML(content) dir := filepath.Dir(ConfigPath) tmp, err := os.CreateTemp(dir, ".config-*.tmp") if err != nil { return fmt.Errorf("create temp config: %w", err) } tmpPath := tmp.Name() if _, err := tmp.WriteString(content); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("write config: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() os.Remove(tmpPath) return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } return os.Rename(tmpPath, ConfigPath) } // FormatConfigFile reads the config file at the given path, formats it, and // writes it back. It validates the TOML syntax before writing. func FormatConfigFile(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("invalid TOML: %w", err) } formatted := formatTOML(string(data)) if formatted == string(data) { return nil } dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".config-*.tmp") if err != nil { return fmt.Errorf("create temp file: %w", err) } tmpPath := tmp.Name() if _, err := tmp.WriteString(formatted); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("write formatted config: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() os.Remove(tmpPath) return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } return os.Rename(tmpPath, path) } // GetGlobalSettings reads global settings from config.toml. func GetGlobalSettings() map[string]any { if ConfigPath == "" { return nil } data, err := os.ReadFile(ConfigPath) if err != nil { return nil } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil } result := map[string]any{ "language": cfg.Language, "attachment_send": cfg.AttachmentSend, "log_level": cfg.Log.Level, } if cfg.IdleTimeoutMins != nil { result["idle_timeout_mins"] = *cfg.IdleTimeoutMins } else { result["idle_timeout_mins"] = 120 } if cfg.MaxTurnTimeMins != nil { result["max_turn_time_mins"] = *cfg.MaxTurnTimeMins } else { result["max_turn_time_mins"] = 0 } // Display if cfg.Display.ThinkingMessages != nil { result["thinking_messages"] = *cfg.Display.ThinkingMessages } else { result["thinking_messages"] = true } if cfg.Display.ThinkingMaxLen != nil { result["thinking_max_len"] = *cfg.Display.ThinkingMaxLen } else { result["thinking_max_len"] = 300 } if cfg.Display.ToolMessages != nil { result["tool_messages"] = *cfg.Display.ToolMessages } else { result["tool_messages"] = true } if cfg.Display.ToolMaxLen != nil { result["tool_max_len"] = *cfg.Display.ToolMaxLen } else { result["tool_max_len"] = 500 } // Stream preview spEnabled := true if cfg.StreamPreview.Enabled != nil { spEnabled = *cfg.StreamPreview.Enabled } result["stream_preview_enabled"] = spEnabled spInterval := 1500 if cfg.StreamPreview.IntervalMs != nil { spInterval = *cfg.StreamPreview.IntervalMs } result["stream_preview_interval_ms"] = spInterval // Rate limit rlMax := 20 if cfg.RateLimit.MaxMessages != nil { rlMax = *cfg.RateLimit.MaxMessages } result["rate_limit_max_messages"] = rlMax rlWindow := 60 if cfg.RateLimit.WindowSecs != nil { rlWindow = *cfg.RateLimit.WindowSecs } result["rate_limit_window_secs"] = rlWindow // Queue queueMax := 5 if cfg.Queue.MaxDepth != nil { queueMax = *cfg.Queue.MaxDepth } result["queue_max_depth"] = queueMax return result } // GlobalSettingsUpdate holds fields to update in global config. type GlobalSettingsUpdate struct { Language *string `json:"language"` AttachmentSend *string `json:"attachment_send"` LogLevel *string `json:"log_level"` IdleTimeoutMins *int `json:"idle_timeout_mins"` ThinkingMessages *bool `json:"thinking_messages"` ThinkingMaxLen *int `json:"thinking_max_len"` ToolMessages *bool `json:"tool_messages"` ToolMaxLen *int `json:"tool_max_len"` StreamPreviewOn *bool `json:"stream_preview_enabled"` StreamPreviewIntMs *int `json:"stream_preview_interval_ms"` RateLimitMax *int `json:"rate_limit_max_messages"` RateLimitWindow *int `json:"rate_limit_window_secs"` QueueMaxDepth *int `json:"queue_max_depth"` } // SaveGlobalSettings persists global settings to config.toml. func SaveGlobalSettings(u GlobalSettingsUpdate) error { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } if u.Language != nil { cfg.Language = *u.Language } if u.AttachmentSend != nil { cfg.AttachmentSend = *u.AttachmentSend } if u.LogLevel != nil { cfg.Log.Level = *u.LogLevel } if u.IdleTimeoutMins != nil { cfg.IdleTimeoutMins = u.IdleTimeoutMins } if u.ThinkingMessages != nil { cfg.Display.ThinkingMessages = u.ThinkingMessages } if u.ThinkingMaxLen != nil { cfg.Display.ThinkingMaxLen = u.ThinkingMaxLen } if u.ToolMessages != nil { cfg.Display.ToolMessages = u.ToolMessages } if u.ToolMaxLen != nil { cfg.Display.ToolMaxLen = u.ToolMaxLen } if u.StreamPreviewOn != nil { cfg.StreamPreview.Enabled = u.StreamPreviewOn } if u.StreamPreviewIntMs != nil { cfg.StreamPreview.IntervalMs = u.StreamPreviewIntMs } if u.RateLimitMax != nil { cfg.RateLimit.MaxMessages = u.RateLimitMax } if u.RateLimitWindow != nil { cfg.RateLimit.WindowSecs = u.RateLimitWindow } if u.QueueMaxDepth != nil { cfg.Queue.MaxDepth = u.QueueMaxDepth } return saveConfig(cfg) } // WebSetupResult holds the config values after enabling web admin. type WebSetupResult struct { ManagementPort int ManagementToken string BridgePort int BridgeToken string AlreadyEnabled bool } // EnableWebAdmin enables the bridge and management sections in config.toml. // If already enabled, returns the existing config values without changes. func EnableWebAdmin(mgmtToken, bridgeToken string) (*WebSetupResult, error) { configMu.Lock() defer configMu.Unlock() if ConfigPath == "" { return nil, fmt.Errorf("config path not set") } data, err := os.ReadFile(ConfigPath) if err != nil { return nil, fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } mgmtEnabled := cfg.Management.Enabled != nil && *cfg.Management.Enabled bridgeEnabled := cfg.Bridge.Enabled != nil && *cfg.Bridge.Enabled if mgmtEnabled && bridgeEnabled { return &WebSetupResult{ ManagementPort: orDefault(cfg.Management.Port, 9820), ManagementToken: cfg.Management.Token, BridgePort: orDefault(cfg.Bridge.Port, 9810), BridgeToken: cfg.Bridge.Token, AlreadyEnabled: true, }, nil } t := true changed := false if !mgmtEnabled { cfg.Management.Enabled = &t if cfg.Management.Port == 0 { cfg.Management.Port = 9820 } if cfg.Management.Token == "" { cfg.Management.Token = mgmtToken } if len(cfg.Management.CORSOrigins) == 0 { cfg.Management.CORSOrigins = []string{"*"} } changed = true } if !bridgeEnabled { cfg.Bridge.Enabled = &t if cfg.Bridge.Port == 0 { cfg.Bridge.Port = 9810 } if cfg.Bridge.Token == "" { cfg.Bridge.Token = bridgeToken } if len(cfg.Bridge.CORSOrigins) == 0 { cfg.Bridge.CORSOrigins = []string{"*"} } changed = true } if changed { if err := saveConfig(cfg); err != nil { return nil, fmt.Errorf("save config: %w", err) } } return &WebSetupResult{ ManagementPort: orDefault(cfg.Management.Port, 9820), ManagementToken: cfg.Management.Token, BridgePort: orDefault(cfg.Bridge.Port, 9810), BridgeToken: cfg.Bridge.Token, AlreadyEnabled: false, }, nil } func orDefault(v, d int) int { if v == 0 { return d } return v }