Skip to main content

starweaver_cli/
config.rs

1//! CLI configuration resolution.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    env, fs,
6    path::{Path, PathBuf},
7};
8
9use serde::{Deserialize, Serialize};
10use starweaver_model::MaxTokensParameter;
11use toml::Value;
12
13use crate::{
14    args::{Cli, CliCommand, ConfigCommand, HitlPolicy, OutputMode, SetupCommand},
15    error::io_error,
16    oauth::CODEX_BASE_URL,
17    slash_commands::{normalize_command_name, valid_command_name, SlashCommandDefinition},
18    CliError, CliResult,
19};
20
21mod env_overrides;
22mod metadata;
23mod state;
24mod templates;
25mod values;
26
27use env_overrides::{apply_cli_overrides, apply_env, parse_hitl_policy, parse_output_mode};
28pub use metadata::{mcp_servers, tool_need_approval};
29use metadata::{merge_json_value, read_mcp_config, read_tools_config};
30pub use state::{
31    clear_current_session, ensure_config_dirs, read_current_session, write_current_session,
32};
33use templates::default_config_template;
34pub use templates::{
35    init_config_file, write_default_subagent_presets, DEFAULT_GLOBAL_GITIGNORE_TEMPLATE,
36    DEFAULT_MCP_TEMPLATE, DEFAULT_PROJECT_GITIGNORE_TEMPLATE, DEFAULT_TOOLS_TEMPLATE,
37};
38pub use values::{get_config_value, set_config_value, ConfigScope};
39
40/// Resolved CLI configuration.
41#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
42pub struct CliConfig {
43    /// Global config root.
44    pub global_dir: PathBuf,
45    /// Project config root.
46    pub project_dir: PathBuf,
47    /// TUI client state root.
48    pub tui_state_dir: PathBuf,
49    /// Desktop client state root.
50    pub desktop_state_dir: PathBuf,
51    /// `SQLite` database path.
52    pub database_path: PathBuf,
53    /// Local file store path.
54    pub file_store_path: PathBuf,
55    /// Default profile.
56    pub default_profile: String,
57    /// Skill directory search paths.
58    pub skill_dirs: Vec<PathBuf>,
59    /// Subagent directory search paths.
60    pub subagent_dirs: Vec<PathBuf>,
61    /// Disabled subagent names from layered subagent config.
62    pub disabled_subagents: Vec<String>,
63    /// Workspace root for environment providers.
64    pub workspace_root: PathBuf,
65    /// Environment provider kind.
66    pub environment_provider: String,
67    /// Filesystem policy mode.
68    pub files_policy: String,
69    /// Whether shell execution is enabled for environment tools.
70    pub shell_enabled: bool,
71    /// Shell command review configuration.
72    pub shell_review: CliShellReviewConfig,
73    /// Default output mode.
74    pub default_output: OutputMode,
75    /// Default headless human-in-the-loop policy.
76    pub default_hitl: HitlPolicy,
77    /// Default maximum runtime goal retry iterations.
78    pub max_goal_iterations: usize,
79    /// Update channel metadata.
80    pub update_channel: String,
81    /// OAuth token refresh supervisor configuration.
82    pub oauth_refresh: OAuthRefreshConfig,
83    /// Default model from `[general] model` fields.
84    pub default_model: Option<CliModelProfile>,
85    /// Named model profiles from `[model_profiles.*]` fields.
86    pub model_profiles: BTreeMap<String, CliModelProfile>,
87    /// Environment variables loaded from config `[env]` sections.
88    pub env_vars: BTreeMap<String, String>,
89    /// Provider API configuration.
90    pub providers: ProviderConfigs,
91    /// Tool config metadata loaded from tools.toml.
92    pub tools_config: serde_json::Value,
93    /// MCP config metadata loaded from mcp.json.
94    pub mcp_config: serde_json::Value,
95    /// Unmapped config metadata preserved for configuration audits.
96    pub unmapped_metadata: serde_json::Value,
97    /// Custom slash commands loaded from `[commands.*]` config sections.
98    pub slash_commands: BTreeMap<String, SlashCommandDefinition>,
99    /// Automatic trim after a run.
100    pub auto_trim: bool,
101    /// Recent runs to keep for automatic trim.
102    pub current_session_keep_recent_runs: usize,
103    /// Retention horizon for future all-session maintenance.
104    pub all_sessions_keep_days: u64,
105}
106
107/// Config resolver.
108#[allow(clippy::struct_field_names)]
109#[derive(Clone, Debug)]
110pub struct ConfigResolver {
111    global_dir: Option<PathBuf>,
112    project_dir: Option<PathBuf>,
113    shared_agents_dir: Option<PathBuf>,
114    current_dir: Option<PathBuf>,
115}
116
117#[derive(Clone, Debug, Default, Deserialize)]
118struct FileConfig {
119    general: Option<GeneralConfig>,
120    storage: Option<StorageConfig>,
121    environment: Option<EnvironmentConfig>,
122    security: Option<FileSecurityConfig>,
123    update: Option<UpdateConfig>,
124    providers: Option<FileProviderConfigs>,
125    oauth_refresh: Option<FileOAuthRefreshConfig>,
126    model_profiles: Option<BTreeMap<String, FileModelProfile>>,
127    env: Option<BTreeMap<String, String>>,
128    skills: Option<SkillsConfig>,
129    subagents: Option<SubagentsConfig>,
130    commands: Option<BTreeMap<String, FileCommandDefinition>>,
131    trim: Option<TrimConfig>,
132}
133
134#[derive(Clone, Debug, Default, Deserialize)]
135struct GeneralConfig {
136    default_profile: Option<String>,
137    default_output: Option<OutputMode>,
138    default_hitl: Option<HitlPolicy>,
139    max_goal_iterations: Option<usize>,
140    model: Option<String>,
141    model_settings: Option<String>,
142    model_cfg: Option<String>,
143    max_requests: Option<u64>,
144}
145
146#[derive(Clone, Debug, Default, Deserialize)]
147struct FileOAuthRefreshConfig {
148    enabled: Option<bool>,
149    interval_seconds: Option<u64>,
150    failure_retry_seconds: Option<u64>,
151    refresh_on_startup: Option<bool>,
152}
153
154/// OAuth token refresh supervisor configuration.
155#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
156pub struct OAuthRefreshConfig {
157    /// Whether OAuth refresh supervisor should be started for OAuth-backed models.
158    pub enabled: bool,
159    /// Successful-refresh interval in seconds.
160    pub interval_seconds: u64,
161    /// Retry interval in seconds after the last refresh attempt failed.
162    pub failure_retry_seconds: u64,
163    /// Refresh immediately when the supervisor starts.
164    pub refresh_on_startup: bool,
165}
166
167impl Default for OAuthRefreshConfig {
168    fn default() -> Self {
169        Self {
170            enabled: true,
171            interval_seconds: 30 * 60,
172            failure_retry_seconds: 60,
173            refresh_on_startup: true,
174        }
175    }
176}
177
178#[derive(Clone, Debug, Default, Deserialize)]
179struct FileModelProfile {
180    label: Option<String>,
181    model: Option<String>,
182    model_settings: Option<String>,
183    model_cfg: Option<String>,
184}
185
186#[derive(Clone, Debug, Default, Deserialize)]
187struct FileCommandDefinition {
188    prompt: Option<String>,
189    description: Option<String>,
190    aliases: Option<Vec<String>>,
191}
192
193#[derive(Clone, Debug, Default, Deserialize)]
194struct SkillsConfig {
195    dirs: Option<Vec<String>>,
196    additional_dirs: Option<Vec<String>>,
197}
198
199#[derive(Clone, Debug, Default, Deserialize)]
200struct SubagentsConfig {
201    dirs: Option<Vec<String>>,
202    additional_dirs: Option<Vec<String>>,
203    disabled: Option<Vec<String>>,
204    disabled_builtins: Option<Vec<String>>,
205}
206
207#[derive(Clone, Debug, Default, Deserialize)]
208struct StorageConfig {
209    database_path: Option<String>,
210    file_store_path: Option<String>,
211}
212
213#[derive(Clone, Debug, Default, Deserialize)]
214struct EnvironmentConfig {
215    workspace_root: Option<String>,
216    provider: Option<String>,
217    files_policy: Option<String>,
218    shell_enabled: Option<bool>,
219}
220
221#[derive(Clone, Debug, Default, Deserialize)]
222struct FileSecurityConfig {
223    shell_review: Option<FileShellReviewConfig>,
224}
225
226#[derive(Clone, Debug, Default, Deserialize)]
227struct FileShellReviewConfig {
228    enabled: Option<bool>,
229    model: Option<String>,
230    model_settings: Option<String>,
231    on_needs_approval: Option<String>,
232    risk_threshold: Option<String>,
233    system_prompt: Option<String>,
234}
235
236/// CLI shell command review configuration.
237#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
238pub struct CliShellReviewConfig {
239    /// Whether shell review is enabled.
240    pub enabled: bool,
241    /// Review model id.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub model: Option<String>,
244    /// Review model settings preset.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub model_settings: Option<String>,
247    /// Action when review reaches threshold: defer or deny.
248    pub on_needs_approval: String,
249    /// Risk threshold: low, medium, high, or `extra_high`.
250    pub risk_threshold: String,
251    /// Optional prompt override.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub system_prompt: Option<String>,
254}
255
256impl Default for CliShellReviewConfig {
257    fn default() -> Self {
258        Self {
259            enabled: false,
260            model: None,
261            model_settings: None,
262            on_needs_approval: "defer".to_string(),
263            risk_threshold: "high".to_string(),
264            system_prompt: None,
265        }
266    }
267}
268
269impl CliShellReviewConfig {
270    fn validate(&self) -> CliResult<()> {
271        if self.enabled
272            && self
273                .model
274                .as_deref()
275                .map_or(true, |model| model.trim().is_empty())
276        {
277            return Err(CliError::Config(
278                "security.shell_review.model is required when shell review is enabled".to_string(),
279            ));
280        }
281        validate_shell_review_action(&self.on_needs_approval)?;
282        validate_shell_review_risk(&self.risk_threshold)?;
283        Ok(())
284    }
285}
286
287#[derive(Clone, Debug, Default, Deserialize)]
288struct UpdateConfig {
289    channel: Option<String>,
290}
291
292/// CLI model profile resolved from config.
293#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
294pub struct CliModelProfile {
295    /// Human label for display.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub label: Option<String>,
298    /// Provider model id, such as `openai-responses:gpt-5` or `homelab@openai-responses:gpt-5`.
299    pub model_id: String,
300    /// Model config preset name from `model_cfg`.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub model_cfg: Option<String>,
303    /// Model settings preset name.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub model_settings: Option<String>,
306}
307
308/// Provider API configuration.
309#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
310pub struct ProviderConfigs {
311    /// `OpenAI` provider config.
312    pub openai: ProviderConfig,
313    /// Anthropic provider config.
314    pub anthropic: ProviderConfig,
315    /// Gemini provider config.
316    pub gemini: ProviderConfig,
317    /// Google Cloud Gemini provider config.
318    pub google_cloud: ProviderConfig,
319    /// Codex OAuth provider config.
320    pub codex: ProviderConfig,
321    /// Named gateway provider configs.
322    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
323    pub gateways: BTreeMap<String, ProviderConfig>,
324}
325
326#[derive(Clone, Debug, Default, Deserialize)]
327struct FileProviderConfigs {
328    openai: Option<FileProviderConfig>,
329    anthropic: Option<FileProviderConfig>,
330    gemini: Option<FileProviderConfig>,
331    google: Option<FileProviderConfig>,
332    #[serde(rename = "google-gla")]
333    google_gla: Option<FileProviderConfig>,
334    #[serde(rename = "google-cloud", alias = "google_cloud")]
335    google_cloud: Option<FileProviderConfig>,
336    #[serde(rename = "google-vertex")]
337    google_vertex: Option<FileProviderConfig>,
338    codex: Option<FileProviderConfig>,
339    #[serde(flatten)]
340    gateways: BTreeMap<String, FileProviderConfig>,
341}
342
343#[derive(Clone, Debug, Default, Deserialize)]
344struct FileProviderConfig {
345    enabled: Option<bool>,
346    api_key_env: Option<String>,
347    auth_token_env: Option<String>,
348    project: Option<String>,
349    location: Option<String>,
350    base_url: Option<String>,
351    endpoint_path: Option<String>,
352    max_tokens_parameter: Option<MaxTokensParameter>,
353}
354
355/// Single provider API configuration.
356#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
357pub struct ProviderConfig {
358    /// Enable provider-backed profile resolution for this provider.
359    pub enabled: bool,
360    /// Environment variable containing the provider API key.
361    pub api_key_env: Option<String>,
362    /// Environment variable containing a bearer access token.
363    pub auth_token_env: Option<String>,
364    /// Provider project identifier when required by the backend.
365    pub project: Option<String>,
366    /// Provider location or region when required by the backend.
367    pub location: Option<String>,
368    /// Provider or gateway base URL.
369    pub base_url: Option<String>,
370    /// Override endpoint path.
371    pub endpoint_path: Option<String>,
372    /// Provider or gateway max-token parameter mapping.
373    pub max_tokens_parameter: MaxTokensParameter,
374}
375
376impl Default for ProviderConfig {
377    fn default() -> Self {
378        Self {
379            enabled: true,
380            api_key_env: None,
381            auth_token_env: None,
382            project: None,
383            location: None,
384            base_url: None,
385            endpoint_path: None,
386            max_tokens_parameter: MaxTokensParameter::Default,
387        }
388    }
389}
390
391#[derive(Clone, Debug, Default, Deserialize)]
392struct TrimConfig {
393    auto_after_run: Option<bool>,
394    current_session_keep_recent_runs: Option<usize>,
395    all_sessions_keep_days: Option<u64>,
396}
397
398impl Default for ConfigResolver {
399    fn default() -> Self {
400        Self {
401            global_dir: env::var_os("STARWEAVER_CONFIG_DIR").map(PathBuf::from),
402            project_dir: env::var_os("STARWEAVER_PROJECT_DIR").map(PathBuf::from),
403            shared_agents_dir: None,
404            current_dir: None,
405        }
406    }
407}
408
409impl ConfigResolver {
410    /// Build a resolver pinned to a root for deterministic tests.
411    #[must_use]
412    pub fn for_tests(root: &std::path::Path) -> Self {
413        Self {
414            global_dir: Some(root.join("global")),
415            project_dir: Some(root.join("project/.starweaver")),
416            shared_agents_dir: Some(root.join("shared-agents")),
417            current_dir: Some(root.join("project")),
418        }
419    }
420
421    /// Resolve final config.
422    pub fn resolve(&self, cli: &Cli) -> CliResult<CliConfig> {
423        let current_dir = self.current_dir.clone().unwrap_or_else(default_current_dir);
424        let global_dir = self.global_dir.clone().unwrap_or_else(default_global_dir);
425        let project_dir = self
426            .project_dir
427            .clone()
428            .unwrap_or_else(|| default_project_dir(cli, &global_dir, &current_dir));
429        let shared_agents_dir = self
430            .shared_agents_dir
431            .clone()
432            .unwrap_or_else(default_shared_agents_dir);
433        let mut config = CliConfig {
434            global_dir: global_dir.clone(),
435            project_dir: project_dir.clone(),
436            tui_state_dir: global_dir.join("tui"),
437            desktop_state_dir: global_dir.join("desktop"),
438            database_path: project_dir.join("starweaver.sqlite"),
439            file_store_path: project_dir.join("store"),
440            default_profile: "general".to_string(),
441            skill_dirs: default_skill_dirs(&global_dir, &shared_agents_dir, &project_dir),
442            subagent_dirs: vec![global_dir.join("subagents"), project_dir.join("subagents")],
443            disabled_subagents: Vec::new(),
444            workspace_root: default_workspace_root(&project_dir, &global_dir, &current_dir),
445            environment_provider: "local".to_string(),
446            files_policy: "read_write".to_string(),
447            shell_enabled: true,
448            shell_review: CliShellReviewConfig::default(),
449            default_output: OutputMode::AguiJsonl,
450            default_hitl: HitlPolicy::Defer,
451            max_goal_iterations: 10,
452            update_channel: "stable".to_string(),
453            oauth_refresh: OAuthRefreshConfig::default(),
454            default_model: None,
455            model_profiles: BTreeMap::new(),
456            env_vars: BTreeMap::new(),
457            providers: default_provider_configs(),
458            tools_config: serde_json::Value::Null,
459            mcp_config: serde_json::Value::Null,
460            unmapped_metadata: serde_json::json!({}),
461            slash_commands: BTreeMap::new(),
462            auto_trim: true,
463            current_session_keep_recent_runs: 20,
464            all_sessions_keep_days: 60,
465        };
466        bootstrap_global_config_dir(&global_dir)?;
467        apply_file_config(&mut config, &global_dir.join("config.toml"))?;
468        apply_file_config(&mut config, &project_dir.join("config.toml"))?;
469        config.tools_config = read_tools_config(&global_dir, &project_dir)?;
470        config.mcp_config = read_mcp_config(&global_dir, &project_dir)?;
471        apply_env(&mut config);
472        apply_cli_overrides(&mut config, cli, &project_dir);
473        config.shell_review.validate()?;
474        Ok(config)
475    }
476}
477
478fn default_global_dir() -> PathBuf {
479    env::var_os("HOME")
480        .map_or_else(|| PathBuf::from("."), PathBuf::from)
481        .join(".starweaver")
482}
483
484fn default_shared_agents_dir() -> PathBuf {
485    env::var_os("HOME")
486        .map_or_else(|| PathBuf::from("."), PathBuf::from)
487        .join(".agents")
488}
489
490fn default_current_dir() -> PathBuf {
491    env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
492}
493
494fn default_skill_dirs(
495    global_dir: &std::path::Path,
496    shared_agents_dir: &std::path::Path,
497    project_dir: &std::path::Path,
498) -> Vec<PathBuf> {
499    vec![
500        global_dir.join("skills"),
501        shared_agents_dir.join("skills"),
502        project_dir.join("skills"),
503    ]
504}
505
506fn default_project_dir(cli: &Cli, global_dir: &Path, current_dir: &Path) -> PathBuf {
507    if wants_project_config(cli) {
508        return current_dir.join(".starweaver");
509    }
510    find_project_dir(current_dir, global_dir).unwrap_or_else(|| global_dir.to_path_buf())
511}
512
513fn default_workspace_root(project_dir: &Path, global_dir: &Path, current_dir: &Path) -> PathBuf {
514    if paths_equivalent(project_dir, global_dir) {
515        return current_dir.to_path_buf();
516    }
517    project_dir
518        .parent()
519        .filter(|parent| !parent.as_os_str().is_empty())
520        .map_or_else(|| current_dir.to_path_buf(), std::path::Path::to_path_buf)
521}
522
523const fn wants_project_config(cli: &Cli) -> bool {
524    matches!(
525        &cli.command,
526        Some(
527            CliCommand::Setup(SetupCommand {
528                global: false,
529                project: true,
530                ..
531            }) | CliCommand::Config {
532                command: ConfigCommand::Init {
533                    global: false,
534                    project: true,
535                    ..
536                } | ConfigCommand::Set {
537                    global: false,
538                    project: true,
539                    ..
540                }
541            }
542        )
543    )
544}
545
546fn find_project_dir(start: &Path, global_dir: &Path) -> Option<PathBuf> {
547    let home_project_dir = env::var_os("HOME")
548        .map(PathBuf::from)
549        .map(|home| home.join(".starweaver"));
550    let mut current = start.to_path_buf();
551    loop {
552        let candidate = current.join(".starweaver");
553        let is_global_dir = paths_equivalent(&candidate, global_dir);
554        let is_home_global_dir = home_project_dir
555            .as_ref()
556            .is_some_and(|home| paths_equivalent(&candidate, home));
557        if candidate.join("config.toml").exists() && !is_global_dir && !is_home_global_dir {
558            return Some(candidate);
559        }
560        if !current.pop() {
561            return None;
562        }
563    }
564}
565
566fn paths_equivalent(left: &Path, right: &Path) -> bool {
567    if left == right {
568        return true;
569    }
570    let normalized_left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
571    let normalized_right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
572    normalized_left == normalized_right
573}
574
575fn bootstrap_global_config_dir(global_dir: &Path) -> CliResult<()> {
576    fs::create_dir_all(global_dir).map_err(|error| io_error(global_dir, error))?;
577    let config_path = global_dir.join("config.toml");
578    if !config_path.exists() {
579        fs::write(&config_path, default_config_template(ConfigScope::Global))
580            .map_err(|error| io_error(&config_path, error))?;
581    }
582    for (path, content) in [
583        (global_dir.join("tools.toml"), DEFAULT_TOOLS_TEMPLATE),
584        (global_dir.join("mcp.json"), DEFAULT_MCP_TEMPLATE),
585        (
586            global_dir.join(".gitignore"),
587            DEFAULT_GLOBAL_GITIGNORE_TEMPLATE,
588        ),
589    ] {
590        if !path.exists() {
591            fs::write(&path, content).map_err(|error| io_error(&path, error))?;
592        }
593    }
594    for name in ["skills", "subagents", "tui", "desktop"] {
595        let path = global_dir.join(name);
596        fs::create_dir_all(&path).map_err(|error| io_error(path, error))?;
597    }
598    write_default_subagent_presets(global_dir, false)?;
599    Ok(())
600}
601
602fn default_provider_configs() -> ProviderConfigs {
603    ProviderConfigs {
604        openai: ProviderConfig {
605            api_key_env: Some("OPENAI_API_KEY".to_string()),
606            base_url: Some("https://api.openai.com/v1".to_string()),
607            ..ProviderConfig::default()
608        },
609        anthropic: ProviderConfig {
610            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
611            base_url: Some("https://api.anthropic.com/v1".to_string()),
612            ..ProviderConfig::default()
613        },
614        gemini: ProviderConfig {
615            api_key_env: Some("GEMINI_API_KEY".to_string()),
616            base_url: Some("https://generativelanguage.googleapis.com/v1beta".to_string()),
617            ..ProviderConfig::default()
618        },
619        google_cloud: ProviderConfig {
620            api_key_env: Some("GOOGLE_API_KEY".to_string()),
621            auth_token_env: Some("GOOGLE_CLOUD_ACCESS_TOKEN".to_string()),
622            base_url: Some("https://aiplatform.googleapis.com".to_string()),
623            ..ProviderConfig::default()
624        },
625        codex: ProviderConfig {
626            base_url: Some(CODEX_BASE_URL.to_string()),
627            max_tokens_parameter: MaxTokensParameter::Omit,
628            ..ProviderConfig::default()
629        },
630        gateways: BTreeMap::new(),
631    }
632}
633
634#[allow(clippy::too_many_lines)]
635fn apply_file_config(config: &mut CliConfig, path: &PathBuf) -> CliResult<()> {
636    if !path.exists() {
637        return Ok(());
638    }
639    let content = fs::read_to_string(path).map_err(|error| io_error(path, error))?;
640    let raw = content
641        .parse::<Value>()
642        .map_err(|error| CliError::Config(error.to_string()))?;
643    merge_unmapped_metadata(config, &raw);
644    let parsed = toml::from_str::<FileConfig>(&content)?;
645    let base = path.parent().unwrap_or_else(|| std::path::Path::new("."));
646    if let Some(general) = parsed.general {
647        let has_default_profile = general.default_profile.is_some();
648        let model = general.model.clone();
649        let model_settings = general.model_settings.clone();
650        let model_cfg = general.model_cfg.clone();
651        let max_requests = general.max_requests;
652        if let Some(max_requests) = max_requests {
653            merge_json_value(
654                &mut config.unmapped_metadata,
655                serde_json::json!({"general": {"max_requests": max_requests}}),
656            );
657        }
658        if let Some(profile) = general.default_profile {
659            config.default_profile = profile;
660        }
661        if let Some(output) = general.default_output {
662            config.default_output = output;
663        }
664        if let Some(hitl) = general.default_hitl {
665            config.default_hitl = hitl;
666        }
667        if let Some(max_goal_iterations) = general.max_goal_iterations {
668            config.max_goal_iterations = max_goal_iterations.max(1);
669        }
670        if let Some(model_id) = model {
671            config.default_model = Some(CliModelProfile {
672                label: Some("Default".to_string()),
673                model_id,
674                model_cfg,
675                model_settings,
676            });
677            if !has_default_profile {
678                config.default_profile = "default_model".to_string();
679            }
680        }
681    }
682    if let Some(storage) = parsed.storage {
683        if let Some(database_path) = storage.database_path {
684            config.database_path = expand_path(&database_path, base);
685        }
686        if let Some(file_store_path) = storage.file_store_path {
687            config.file_store_path = expand_path(&file_store_path, base);
688        }
689    }
690    if let Some(environment) = parsed.environment {
691        if let Some(workspace_root) = environment.workspace_root {
692            config.workspace_root = expand_path(&workspace_root, base);
693        }
694        if let Some(provider) = environment.provider {
695            config.environment_provider = provider;
696        }
697        if let Some(files_policy) = environment.files_policy {
698            config.files_policy = files_policy;
699        }
700        if let Some(shell_enabled) = environment.shell_enabled {
701            config.shell_enabled = shell_enabled;
702        }
703    }
704    if let Some(security) = parsed.security {
705        if let Some(shell_review) = security.shell_review {
706            merge_shell_review_config(&mut config.shell_review, shell_review)?;
707        }
708    }
709    if let Some(update) = parsed.update {
710        if let Some(channel) = update.channel {
711            config.update_channel = channel;
712        }
713    }
714    if let Some(providers) = parsed.providers {
715        merge_provider_configs(&mut config.providers, providers);
716    }
717    if let Some(oauth_refresh) = parsed.oauth_refresh {
718        merge_oauth_refresh_config(&mut config.oauth_refresh, &oauth_refresh)?;
719    }
720    if let Some(env_vars) = parsed.env {
721        config.env_vars.extend(env_vars);
722    }
723    if let Some(model_profiles) = parsed.model_profiles {
724        for (name, profile) in model_profiles {
725            if let Some(model_id) = profile.model {
726                config.model_profiles.insert(
727                    name,
728                    CliModelProfile {
729                        label: profile.label,
730                        model_id,
731                        model_cfg: profile.model_cfg,
732                        model_settings: profile.model_settings,
733                    },
734                );
735            }
736        }
737    }
738    if let Some(skills) = parsed.skills {
739        merge_skill_dirs(config, skills, base);
740    }
741    if let Some(subagents) = parsed.subagents {
742        merge_subagent_config(config, subagents, base);
743    }
744    if let Some(commands) = parsed.commands {
745        merge_slash_commands(config, commands);
746    }
747    if let Some(trim) = parsed.trim {
748        if let Some(auto_after_run) = trim.auto_after_run {
749            config.auto_trim = auto_after_run;
750        }
751        if let Some(keep) = trim.current_session_keep_recent_runs {
752            config.current_session_keep_recent_runs = keep;
753        }
754        if let Some(days) = trim.all_sessions_keep_days {
755            config.all_sessions_keep_days = days;
756        }
757    }
758    Ok(())
759}
760
761fn merge_skill_dirs(config: &mut CliConfig, skills: SkillsConfig, base: &std::path::Path) {
762    if let Some(dirs) = skills.dirs {
763        config.skill_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
764    }
765    if let Some(additional_dirs) = skills.additional_dirs {
766        config
767            .skill_dirs
768            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
769    }
770}
771
772fn merge_subagent_config(
773    config: &mut CliConfig,
774    subagents: SubagentsConfig,
775    base: &std::path::Path,
776) {
777    if let Some(dirs) = subagents.dirs {
778        config.subagent_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
779    }
780    if let Some(additional_dirs) = subagents.additional_dirs {
781        config
782            .subagent_dirs
783            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
784    }
785    if let Some(disabled) = subagents.disabled {
786        config.disabled_subagents.extend(disabled);
787    }
788    if let Some(disabled_builtins) = subagents.disabled_builtins {
789        config.disabled_subagents.extend(disabled_builtins);
790    }
791    config.disabled_subagents.sort();
792    config.disabled_subagents.dedup();
793}
794
795fn merge_slash_commands(config: &mut CliConfig, commands: BTreeMap<String, FileCommandDefinition>) {
796    for (name, command) in commands {
797        let normalized = normalize_command_name(&name);
798        if !valid_command_name(&normalized) || reserved_slash_command(&normalized) {
799            continue;
800        }
801        let Some(prompt) = command.prompt.filter(|prompt| !prompt.trim().is_empty()) else {
802            continue;
803        };
804        let aliases = command
805            .aliases
806            .unwrap_or_default()
807            .into_iter()
808            .map(|alias| normalize_command_name(&alias))
809            .filter(|alias| valid_command_name(alias) && !reserved_slash_command(alias))
810            .collect::<BTreeSet<_>>()
811            .into_iter()
812            .filter(|alias| alias != &normalized)
813            .collect::<Vec<_>>();
814        let definition = SlashCommandDefinition {
815            name: normalized,
816            prompt,
817            description: command.description,
818            aliases,
819        };
820        upsert_slash_command(config, definition);
821    }
822}
823
824fn upsert_slash_command(config: &mut CliConfig, mut definition: SlashCommandDefinition) {
825    let canonical = definition.name.clone();
826    let stale_aliases = config
827        .slash_commands
828        .iter()
829        .filter(|(lookup, existing)| existing.name == canonical && *lookup != &canonical)
830        .map(|(lookup, _)| lookup.clone())
831        .collect::<Vec<_>>();
832    for alias in stale_aliases {
833        config.slash_commands.remove(&alias);
834    }
835    for existing in config.slash_commands.values_mut() {
836        if existing.name != canonical {
837            existing.aliases.retain(|alias| alias != &canonical);
838        }
839    }
840
841    let requested_aliases = std::mem::take(&mut definition.aliases);
842    let active_aliases = requested_aliases
843        .into_iter()
844        .filter(|alias| {
845            !config
846                .slash_commands
847                .get(alias)
848                .is_some_and(|existing| existing.name != canonical)
849        })
850        .collect::<Vec<_>>();
851    definition.aliases.clone_from(&active_aliases);
852    config.slash_commands.insert(canonical, definition.clone());
853    for alias in active_aliases {
854        config.slash_commands.insert(alias, definition.clone());
855    }
856}
857
858fn reserved_slash_command(name: &str) -> bool {
859    matches!(
860        name,
861        "help"
862            | "config"
863            | "mode"
864            | "act"
865            | "plan"
866            | "loop"
867            | "tasks"
868            | "session"
869            | "dump"
870            | "load"
871            | "clear"
872            | "cost"
873            | "exit"
874            | "model"
875            | "paste-image"
876            | "goal"
877    )
878}
879
880fn merge_unmapped_metadata(config: &mut CliConfig, raw: &Value) {
881    let Some(root) = raw.as_table() else {
882        return;
883    };
884    let mut metadata = serde_json::Map::new();
885    for key in ["display", "subagents", "security"] {
886        if let Some(value) = root.get(key).cloned() {
887            if let Ok(json) = serde_json::to_value(value) {
888                metadata.insert(key.to_string(), json);
889            }
890        }
891    }
892    if !metadata.is_empty() {
893        merge_json_value(
894            &mut config.unmapped_metadata,
895            serde_json::Value::Object(metadata),
896        );
897    }
898}
899
900fn merge_provider_configs(target: &mut ProviderConfigs, overlay: FileProviderConfigs) {
901    if let Some(openai) = overlay.openai {
902        merge_provider_config(&mut target.openai, openai);
903    }
904    if let Some(anthropic) = overlay.anthropic {
905        merge_provider_config(&mut target.anthropic, anthropic);
906    }
907    if let Some(gemini) = overlay.gemini {
908        merge_provider_config(&mut target.gemini, gemini);
909    }
910    if let Some(google) = overlay.google {
911        merge_provider_config(&mut target.gemini, google);
912    }
913    if let Some(google_gla) = overlay.google_gla {
914        merge_provider_config(&mut target.gemini, google_gla);
915    }
916    if let Some(google_cloud) = overlay.google_cloud {
917        merge_provider_config(&mut target.google_cloud, google_cloud);
918    }
919    if let Some(google_vertex) = overlay.google_vertex {
920        merge_provider_config(&mut target.google_cloud, google_vertex);
921    }
922    if let Some(codex) = overlay.codex {
923        merge_provider_config(&mut target.codex, codex);
924    }
925    for (name, gateway) in overlay.gateways {
926        merge_provider_config(target.gateways.entry(name).or_default(), gateway);
927    }
928}
929
930fn merge_provider_config(target: &mut ProviderConfig, overlay: FileProviderConfig) {
931    if let Some(enabled) = overlay.enabled {
932        target.enabled = enabled;
933    }
934    if overlay.api_key_env.is_some() {
935        target.api_key_env = overlay.api_key_env;
936    }
937    if overlay.auth_token_env.is_some() {
938        target.auth_token_env = overlay.auth_token_env;
939    }
940    if overlay.project.is_some() {
941        target.project = overlay.project;
942    }
943    if overlay.location.is_some() {
944        target.location = overlay.location;
945    }
946    if overlay.base_url.is_some() {
947        target.base_url = overlay.base_url;
948    }
949    if overlay.endpoint_path.is_some() {
950        target.endpoint_path = overlay.endpoint_path;
951    }
952    if let Some(max_tokens_parameter) = overlay.max_tokens_parameter {
953        target.max_tokens_parameter = max_tokens_parameter;
954    }
955}
956
957fn merge_shell_review_config(
958    target: &mut CliShellReviewConfig,
959    overlay: FileShellReviewConfig,
960) -> CliResult<()> {
961    if let Some(enabled) = overlay.enabled {
962        target.enabled = enabled;
963    }
964    if overlay.model.is_some() {
965        target.model = overlay.model;
966    }
967    if overlay.model_settings.is_some() {
968        target.model_settings = overlay.model_settings;
969    }
970    if let Some(action) = overlay.on_needs_approval {
971        target.on_needs_approval = validate_shell_review_action(&action)?.to_string();
972    }
973    if let Some(threshold) = overlay.risk_threshold {
974        target.risk_threshold = validate_shell_review_risk(&threshold)?.to_string();
975    }
976    if overlay.system_prompt.is_some() {
977        target.system_prompt = overlay.system_prompt;
978    }
979    Ok(())
980}
981
982fn validate_shell_review_action(value: &str) -> CliResult<&'static str> {
983    match value.trim() {
984        "defer" => Ok("defer"),
985        "deny" => Ok("deny"),
986        other => Err(CliError::Usage(format!(
987            "invalid security.shell_review.on_needs_approval: {other}; expected defer or deny"
988        ))),
989    }
990}
991
992fn validate_shell_review_risk(value: &str) -> CliResult<&'static str> {
993    match value.trim() {
994        "low" => Ok("low"),
995        "medium" => Ok("medium"),
996        "high" => Ok("high"),
997        "extra_high" | "extra-high" => Ok("extra_high"),
998        other => Err(CliError::Usage(format!(
999            "invalid security.shell_review.risk_threshold: {other}; expected low, medium, high, or extra_high"
1000        ))),
1001    }
1002}
1003
1004fn merge_oauth_refresh_config(
1005    target: &mut OAuthRefreshConfig,
1006    overlay: &FileOAuthRefreshConfig,
1007) -> CliResult<()> {
1008    if let Some(enabled) = overlay.enabled {
1009        target.enabled = enabled;
1010    }
1011    if let Some(interval_seconds) = overlay.interval_seconds {
1012        if interval_seconds == 0 {
1013            return Err(CliError::Usage(
1014                "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
1015            ));
1016        }
1017        target.interval_seconds = interval_seconds;
1018    }
1019    if let Some(failure_retry_seconds) = overlay.failure_retry_seconds {
1020        if failure_retry_seconds == 0 {
1021            return Err(CliError::Usage(
1022                "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
1023            ));
1024        }
1025        target.failure_retry_seconds = failure_retry_seconds;
1026    }
1027    if let Some(refresh_on_startup) = overlay.refresh_on_startup {
1028        target.refresh_on_startup = refresh_on_startup;
1029    }
1030    Ok(())
1031}
1032
1033fn expand_path(value: &str, base: &std::path::Path) -> PathBuf {
1034    if let Some(rest) = value.strip_prefix("~/") {
1035        return env::var_os("HOME")
1036            .map_or_else(|| PathBuf::from("."), PathBuf::from)
1037            .join(rest);
1038    }
1039    let path = PathBuf::from(value);
1040    if path.is_absolute() {
1041        path
1042    } else {
1043        base.join(path)
1044    }
1045}
1046
1047#[cfg(test)]
1048mod tests;