3432 lines
106 KiB
Go
3432 lines
106 KiB
Go
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 <user> --`. 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.<name>] 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].<field> (highest precedence)
|
|
// 2. global [display].<field>
|
|
// 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
|
|
}
|