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