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    /// Codex OAuth provider config.
318    pub codex: ProviderConfig,
319    /// Named gateway provider configs.
320    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
321    pub gateways: BTreeMap<String, ProviderConfig>,
322}
323
324#[derive(Clone, Debug, Default, Deserialize)]
325struct FileProviderConfigs {
326    openai: Option<FileProviderConfig>,
327    anthropic: Option<FileProviderConfig>,
328    gemini: Option<FileProviderConfig>,
329    codex: Option<FileProviderConfig>,
330    #[serde(flatten)]
331    gateways: BTreeMap<String, FileProviderConfig>,
332}
333
334#[derive(Clone, Debug, Default, Deserialize)]
335struct FileProviderConfig {
336    enabled: Option<bool>,
337    api_key_env: Option<String>,
338    base_url: Option<String>,
339    endpoint_path: Option<String>,
340    max_tokens_parameter: Option<MaxTokensParameter>,
341}
342
343/// Single provider API configuration.
344#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
345pub struct ProviderConfig {
346    /// Enable provider-backed profile resolution for this provider.
347    pub enabled: bool,
348    /// Environment variable containing the provider API key.
349    pub api_key_env: Option<String>,
350    /// Provider or gateway base URL.
351    pub base_url: Option<String>,
352    /// Override endpoint path.
353    pub endpoint_path: Option<String>,
354    /// Provider or gateway max-token parameter mapping.
355    pub max_tokens_parameter: MaxTokensParameter,
356}
357
358impl Default for ProviderConfig {
359    fn default() -> Self {
360        Self {
361            enabled: true,
362            api_key_env: None,
363            base_url: None,
364            endpoint_path: None,
365            max_tokens_parameter: MaxTokensParameter::Default,
366        }
367    }
368}
369
370#[derive(Clone, Debug, Default, Deserialize)]
371struct TrimConfig {
372    auto_after_run: Option<bool>,
373    current_session_keep_recent_runs: Option<usize>,
374    all_sessions_keep_days: Option<u64>,
375}
376
377impl Default for ConfigResolver {
378    fn default() -> Self {
379        Self {
380            global_dir: env::var_os("STARWEAVER_CONFIG_DIR").map(PathBuf::from),
381            project_dir: env::var_os("STARWEAVER_PROJECT_DIR").map(PathBuf::from),
382            shared_agents_dir: None,
383            current_dir: None,
384        }
385    }
386}
387
388impl ConfigResolver {
389    /// Build a resolver pinned to a root for deterministic tests.
390    #[must_use]
391    pub fn for_tests(root: &std::path::Path) -> Self {
392        Self {
393            global_dir: Some(root.join("global")),
394            project_dir: Some(root.join("project/.starweaver")),
395            shared_agents_dir: Some(root.join("shared-agents")),
396            current_dir: Some(root.join("project")),
397        }
398    }
399
400    /// Resolve final config.
401    pub fn resolve(&self, cli: &Cli) -> CliResult<CliConfig> {
402        let current_dir = self.current_dir.clone().unwrap_or_else(default_current_dir);
403        let global_dir = self.global_dir.clone().unwrap_or_else(default_global_dir);
404        let project_dir = self
405            .project_dir
406            .clone()
407            .unwrap_or_else(|| default_project_dir(cli, &global_dir, &current_dir));
408        let shared_agents_dir = self
409            .shared_agents_dir
410            .clone()
411            .unwrap_or_else(default_shared_agents_dir);
412        let mut config = CliConfig {
413            global_dir: global_dir.clone(),
414            project_dir: project_dir.clone(),
415            tui_state_dir: global_dir.join("tui"),
416            desktop_state_dir: global_dir.join("desktop"),
417            database_path: project_dir.join("starweaver.sqlite"),
418            file_store_path: project_dir.join("store"),
419            default_profile: "general".to_string(),
420            skill_dirs: default_skill_dirs(&global_dir, &shared_agents_dir, &project_dir),
421            subagent_dirs: vec![global_dir.join("subagents"), project_dir.join("subagents")],
422            disabled_subagents: Vec::new(),
423            workspace_root: default_workspace_root(&project_dir, &global_dir, &current_dir),
424            environment_provider: "local".to_string(),
425            files_policy: "read_write".to_string(),
426            shell_enabled: true,
427            shell_review: CliShellReviewConfig::default(),
428            default_output: OutputMode::AguiJsonl,
429            default_hitl: HitlPolicy::Defer,
430            max_goal_iterations: 10,
431            update_channel: "stable".to_string(),
432            oauth_refresh: OAuthRefreshConfig::default(),
433            default_model: None,
434            model_profiles: BTreeMap::new(),
435            env_vars: BTreeMap::new(),
436            providers: default_provider_configs(),
437            tools_config: serde_json::Value::Null,
438            mcp_config: serde_json::Value::Null,
439            unmapped_metadata: serde_json::json!({}),
440            slash_commands: BTreeMap::new(),
441            auto_trim: true,
442            current_session_keep_recent_runs: 20,
443            all_sessions_keep_days: 60,
444        };
445        bootstrap_global_config_dir(&global_dir)?;
446        apply_file_config(&mut config, &global_dir.join("config.toml"))?;
447        apply_file_config(&mut config, &project_dir.join("config.toml"))?;
448        config.tools_config = read_tools_config(&global_dir, &project_dir)?;
449        config.mcp_config = read_mcp_config(&global_dir, &project_dir)?;
450        apply_env(&mut config);
451        apply_cli_overrides(&mut config, cli, &project_dir);
452        config.shell_review.validate()?;
453        Ok(config)
454    }
455}
456
457fn default_global_dir() -> PathBuf {
458    env::var_os("HOME")
459        .map_or_else(|| PathBuf::from("."), PathBuf::from)
460        .join(".starweaver")
461}
462
463fn default_shared_agents_dir() -> PathBuf {
464    env::var_os("HOME")
465        .map_or_else(|| PathBuf::from("."), PathBuf::from)
466        .join(".agents")
467}
468
469fn default_current_dir() -> PathBuf {
470    env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
471}
472
473fn default_skill_dirs(
474    global_dir: &std::path::Path,
475    shared_agents_dir: &std::path::Path,
476    project_dir: &std::path::Path,
477) -> Vec<PathBuf> {
478    vec![
479        global_dir.join("skills"),
480        shared_agents_dir.join("skills"),
481        project_dir.join("skills"),
482    ]
483}
484
485fn default_project_dir(cli: &Cli, global_dir: &Path, current_dir: &Path) -> PathBuf {
486    if wants_project_config(cli) {
487        return current_dir.join(".starweaver");
488    }
489    find_project_dir(current_dir, global_dir).unwrap_or_else(|| global_dir.to_path_buf())
490}
491
492fn default_workspace_root(project_dir: &Path, global_dir: &Path, current_dir: &Path) -> PathBuf {
493    if paths_equivalent(project_dir, global_dir) {
494        return current_dir.to_path_buf();
495    }
496    project_dir
497        .parent()
498        .filter(|parent| !parent.as_os_str().is_empty())
499        .map_or_else(|| current_dir.to_path_buf(), std::path::Path::to_path_buf)
500}
501
502const fn wants_project_config(cli: &Cli) -> bool {
503    matches!(
504        &cli.command,
505        Some(
506            CliCommand::Setup(SetupCommand {
507                global: false,
508                project: true,
509                ..
510            }) | CliCommand::Config {
511                command: ConfigCommand::Init {
512                    global: false,
513                    project: true,
514                    ..
515                } | ConfigCommand::Set {
516                    global: false,
517                    project: true,
518                    ..
519                }
520            }
521        )
522    )
523}
524
525fn find_project_dir(start: &Path, global_dir: &Path) -> Option<PathBuf> {
526    let home_project_dir = env::var_os("HOME")
527        .map(PathBuf::from)
528        .map(|home| home.join(".starweaver"));
529    let mut current = start.to_path_buf();
530    loop {
531        let candidate = current.join(".starweaver");
532        let is_global_dir = paths_equivalent(&candidate, global_dir);
533        let is_home_global_dir = home_project_dir
534            .as_ref()
535            .is_some_and(|home| paths_equivalent(&candidate, home));
536        if candidate.join("config.toml").exists() && !is_global_dir && !is_home_global_dir {
537            return Some(candidate);
538        }
539        if !current.pop() {
540            return None;
541        }
542    }
543}
544
545fn paths_equivalent(left: &Path, right: &Path) -> bool {
546    if left == right {
547        return true;
548    }
549    let normalized_left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
550    let normalized_right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
551    normalized_left == normalized_right
552}
553
554fn bootstrap_global_config_dir(global_dir: &Path) -> CliResult<()> {
555    fs::create_dir_all(global_dir).map_err(|error| io_error(global_dir, error))?;
556    let config_path = global_dir.join("config.toml");
557    if !config_path.exists() {
558        fs::write(&config_path, default_config_template(ConfigScope::Global))
559            .map_err(|error| io_error(&config_path, error))?;
560    }
561    for (path, content) in [
562        (global_dir.join("tools.toml"), DEFAULT_TOOLS_TEMPLATE),
563        (global_dir.join("mcp.json"), DEFAULT_MCP_TEMPLATE),
564        (
565            global_dir.join(".gitignore"),
566            DEFAULT_GLOBAL_GITIGNORE_TEMPLATE,
567        ),
568    ] {
569        if !path.exists() {
570            fs::write(&path, content).map_err(|error| io_error(&path, error))?;
571        }
572    }
573    for name in ["skills", "subagents", "tui", "desktop"] {
574        let path = global_dir.join(name);
575        fs::create_dir_all(&path).map_err(|error| io_error(path, error))?;
576    }
577    write_default_subagent_presets(global_dir, false)?;
578    Ok(())
579}
580
581fn default_provider_configs() -> ProviderConfigs {
582    ProviderConfigs {
583        openai: ProviderConfig {
584            api_key_env: Some("OPENAI_API_KEY".to_string()),
585            base_url: Some("https://api.openai.com/v1".to_string()),
586            ..ProviderConfig::default()
587        },
588        anthropic: ProviderConfig {
589            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
590            base_url: Some("https://api.anthropic.com/v1".to_string()),
591            ..ProviderConfig::default()
592        },
593        gemini: ProviderConfig {
594            api_key_env: Some("GEMINI_API_KEY".to_string()),
595            base_url: Some("https://generativelanguage.googleapis.com/v1beta".to_string()),
596            ..ProviderConfig::default()
597        },
598        codex: ProviderConfig {
599            base_url: Some(CODEX_BASE_URL.to_string()),
600            max_tokens_parameter: MaxTokensParameter::Omit,
601            ..ProviderConfig::default()
602        },
603        gateways: BTreeMap::new(),
604    }
605}
606
607#[allow(clippy::too_many_lines)]
608fn apply_file_config(config: &mut CliConfig, path: &PathBuf) -> CliResult<()> {
609    if !path.exists() {
610        return Ok(());
611    }
612    let content = fs::read_to_string(path).map_err(|error| io_error(path, error))?;
613    let raw = content
614        .parse::<Value>()
615        .map_err(|error| CliError::Config(error.to_string()))?;
616    merge_unmapped_metadata(config, &raw);
617    let parsed = toml::from_str::<FileConfig>(&content)?;
618    let base = path.parent().unwrap_or_else(|| std::path::Path::new("."));
619    if let Some(general) = parsed.general {
620        let has_default_profile = general.default_profile.is_some();
621        let model = general.model.clone();
622        let model_settings = general.model_settings.clone();
623        let model_cfg = general.model_cfg.clone();
624        let max_requests = general.max_requests;
625        if let Some(max_requests) = max_requests {
626            merge_json_value(
627                &mut config.unmapped_metadata,
628                serde_json::json!({"general": {"max_requests": max_requests}}),
629            );
630        }
631        if let Some(profile) = general.default_profile {
632            config.default_profile = profile;
633        }
634        if let Some(output) = general.default_output {
635            config.default_output = output;
636        }
637        if let Some(hitl) = general.default_hitl {
638            config.default_hitl = hitl;
639        }
640        if let Some(max_goal_iterations) = general.max_goal_iterations {
641            config.max_goal_iterations = max_goal_iterations.max(1);
642        }
643        if let Some(model_id) = model {
644            config.default_model = Some(CliModelProfile {
645                label: Some("Default".to_string()),
646                model_id,
647                model_cfg,
648                model_settings,
649            });
650            if !has_default_profile {
651                config.default_profile = "default_model".to_string();
652            }
653        }
654    }
655    if let Some(storage) = parsed.storage {
656        if let Some(database_path) = storage.database_path {
657            config.database_path = expand_path(&database_path, base);
658        }
659        if let Some(file_store_path) = storage.file_store_path {
660            config.file_store_path = expand_path(&file_store_path, base);
661        }
662    }
663    if let Some(environment) = parsed.environment {
664        if let Some(workspace_root) = environment.workspace_root {
665            config.workspace_root = expand_path(&workspace_root, base);
666        }
667        if let Some(provider) = environment.provider {
668            config.environment_provider = provider;
669        }
670        if let Some(files_policy) = environment.files_policy {
671            config.files_policy = files_policy;
672        }
673        if let Some(shell_enabled) = environment.shell_enabled {
674            config.shell_enabled = shell_enabled;
675        }
676    }
677    if let Some(security) = parsed.security {
678        if let Some(shell_review) = security.shell_review {
679            merge_shell_review_config(&mut config.shell_review, shell_review)?;
680        }
681    }
682    if let Some(update) = parsed.update {
683        if let Some(channel) = update.channel {
684            config.update_channel = channel;
685        }
686    }
687    if let Some(providers) = parsed.providers {
688        merge_provider_configs(&mut config.providers, providers);
689    }
690    if let Some(oauth_refresh) = parsed.oauth_refresh {
691        merge_oauth_refresh_config(&mut config.oauth_refresh, &oauth_refresh)?;
692    }
693    if let Some(env_vars) = parsed.env {
694        config.env_vars.extend(env_vars);
695    }
696    if let Some(model_profiles) = parsed.model_profiles {
697        for (name, profile) in model_profiles {
698            if let Some(model_id) = profile.model {
699                config.model_profiles.insert(
700                    name,
701                    CliModelProfile {
702                        label: profile.label,
703                        model_id,
704                        model_cfg: profile.model_cfg,
705                        model_settings: profile.model_settings,
706                    },
707                );
708            }
709        }
710    }
711    if let Some(skills) = parsed.skills {
712        merge_skill_dirs(config, skills, base);
713    }
714    if let Some(subagents) = parsed.subagents {
715        merge_subagent_config(config, subagents, base);
716    }
717    if let Some(commands) = parsed.commands {
718        merge_slash_commands(config, commands);
719    }
720    if let Some(trim) = parsed.trim {
721        if let Some(auto_after_run) = trim.auto_after_run {
722            config.auto_trim = auto_after_run;
723        }
724        if let Some(keep) = trim.current_session_keep_recent_runs {
725            config.current_session_keep_recent_runs = keep;
726        }
727        if let Some(days) = trim.all_sessions_keep_days {
728            config.all_sessions_keep_days = days;
729        }
730    }
731    Ok(())
732}
733
734fn merge_skill_dirs(config: &mut CliConfig, skills: SkillsConfig, base: &std::path::Path) {
735    if let Some(dirs) = skills.dirs {
736        config.skill_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
737    }
738    if let Some(additional_dirs) = skills.additional_dirs {
739        config
740            .skill_dirs
741            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
742    }
743}
744
745fn merge_subagent_config(
746    config: &mut CliConfig,
747    subagents: SubagentsConfig,
748    base: &std::path::Path,
749) {
750    if let Some(dirs) = subagents.dirs {
751        config.subagent_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
752    }
753    if let Some(additional_dirs) = subagents.additional_dirs {
754        config
755            .subagent_dirs
756            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
757    }
758    if let Some(disabled) = subagents.disabled {
759        config.disabled_subagents.extend(disabled);
760    }
761    if let Some(disabled_builtins) = subagents.disabled_builtins {
762        config.disabled_subagents.extend(disabled_builtins);
763    }
764    config.disabled_subagents.sort();
765    config.disabled_subagents.dedup();
766}
767
768fn merge_slash_commands(config: &mut CliConfig, commands: BTreeMap<String, FileCommandDefinition>) {
769    for (name, command) in commands {
770        let normalized = normalize_command_name(&name);
771        if !valid_command_name(&normalized) || reserved_slash_command(&normalized) {
772            continue;
773        }
774        let Some(prompt) = command.prompt.filter(|prompt| !prompt.trim().is_empty()) else {
775            continue;
776        };
777        let aliases = command
778            .aliases
779            .unwrap_or_default()
780            .into_iter()
781            .map(|alias| normalize_command_name(&alias))
782            .filter(|alias| valid_command_name(alias) && !reserved_slash_command(alias))
783            .collect::<BTreeSet<_>>()
784            .into_iter()
785            .filter(|alias| alias != &normalized)
786            .collect::<Vec<_>>();
787        let definition = SlashCommandDefinition {
788            name: normalized,
789            prompt,
790            description: command.description,
791            aliases,
792        };
793        upsert_slash_command(config, definition);
794    }
795}
796
797fn upsert_slash_command(config: &mut CliConfig, mut definition: SlashCommandDefinition) {
798    let canonical = definition.name.clone();
799    let stale_aliases = config
800        .slash_commands
801        .iter()
802        .filter(|(lookup, existing)| existing.name == canonical && *lookup != &canonical)
803        .map(|(lookup, _)| lookup.clone())
804        .collect::<Vec<_>>();
805    for alias in stale_aliases {
806        config.slash_commands.remove(&alias);
807    }
808    for existing in config.slash_commands.values_mut() {
809        if existing.name != canonical {
810            existing.aliases.retain(|alias| alias != &canonical);
811        }
812    }
813
814    let requested_aliases = std::mem::take(&mut definition.aliases);
815    let active_aliases = requested_aliases
816        .into_iter()
817        .filter(|alias| {
818            !config
819                .slash_commands
820                .get(alias)
821                .is_some_and(|existing| existing.name != canonical)
822        })
823        .collect::<Vec<_>>();
824    definition.aliases.clone_from(&active_aliases);
825    config.slash_commands.insert(canonical, definition.clone());
826    for alias in active_aliases {
827        config.slash_commands.insert(alias, definition.clone());
828    }
829}
830
831fn reserved_slash_command(name: &str) -> bool {
832    matches!(
833        name,
834        "help"
835            | "config"
836            | "mode"
837            | "act"
838            | "plan"
839            | "loop"
840            | "tasks"
841            | "session"
842            | "dump"
843            | "load"
844            | "clear"
845            | "cost"
846            | "exit"
847            | "model"
848            | "paste-image"
849            | "goal"
850    )
851}
852
853fn merge_unmapped_metadata(config: &mut CliConfig, raw: &Value) {
854    let Some(root) = raw.as_table() else {
855        return;
856    };
857    let mut metadata = serde_json::Map::new();
858    for key in ["display", "subagents", "security"] {
859        if let Some(value) = root.get(key).cloned() {
860            if let Ok(json) = serde_json::to_value(value) {
861                metadata.insert(key.to_string(), json);
862            }
863        }
864    }
865    if !metadata.is_empty() {
866        merge_json_value(
867            &mut config.unmapped_metadata,
868            serde_json::Value::Object(metadata),
869        );
870    }
871}
872
873fn merge_provider_configs(target: &mut ProviderConfigs, overlay: FileProviderConfigs) {
874    if let Some(openai) = overlay.openai {
875        merge_provider_config(&mut target.openai, openai);
876    }
877    if let Some(anthropic) = overlay.anthropic {
878        merge_provider_config(&mut target.anthropic, anthropic);
879    }
880    if let Some(gemini) = overlay.gemini {
881        merge_provider_config(&mut target.gemini, gemini);
882    }
883    if let Some(codex) = overlay.codex {
884        merge_provider_config(&mut target.codex, codex);
885    }
886    for (name, gateway) in overlay.gateways {
887        merge_provider_config(target.gateways.entry(name).or_default(), gateway);
888    }
889}
890
891fn merge_provider_config(target: &mut ProviderConfig, overlay: FileProviderConfig) {
892    if let Some(enabled) = overlay.enabled {
893        target.enabled = enabled;
894    }
895    if overlay.api_key_env.is_some() {
896        target.api_key_env = overlay.api_key_env;
897    }
898    if overlay.base_url.is_some() {
899        target.base_url = overlay.base_url;
900    }
901    if overlay.endpoint_path.is_some() {
902        target.endpoint_path = overlay.endpoint_path;
903    }
904    if let Some(max_tokens_parameter) = overlay.max_tokens_parameter {
905        target.max_tokens_parameter = max_tokens_parameter;
906    }
907}
908
909fn merge_shell_review_config(
910    target: &mut CliShellReviewConfig,
911    overlay: FileShellReviewConfig,
912) -> CliResult<()> {
913    if let Some(enabled) = overlay.enabled {
914        target.enabled = enabled;
915    }
916    if overlay.model.is_some() {
917        target.model = overlay.model;
918    }
919    if overlay.model_settings.is_some() {
920        target.model_settings = overlay.model_settings;
921    }
922    if let Some(action) = overlay.on_needs_approval {
923        target.on_needs_approval = validate_shell_review_action(&action)?.to_string();
924    }
925    if let Some(threshold) = overlay.risk_threshold {
926        target.risk_threshold = validate_shell_review_risk(&threshold)?.to_string();
927    }
928    if overlay.system_prompt.is_some() {
929        target.system_prompt = overlay.system_prompt;
930    }
931    Ok(())
932}
933
934fn validate_shell_review_action(value: &str) -> CliResult<&'static str> {
935    match value.trim() {
936        "defer" => Ok("defer"),
937        "deny" => Ok("deny"),
938        other => Err(CliError::Usage(format!(
939            "invalid security.shell_review.on_needs_approval: {other}; expected defer or deny"
940        ))),
941    }
942}
943
944fn validate_shell_review_risk(value: &str) -> CliResult<&'static str> {
945    match value.trim() {
946        "low" => Ok("low"),
947        "medium" => Ok("medium"),
948        "high" => Ok("high"),
949        "extra_high" | "extra-high" => Ok("extra_high"),
950        other => Err(CliError::Usage(format!(
951            "invalid security.shell_review.risk_threshold: {other}; expected low, medium, high, or extra_high"
952        ))),
953    }
954}
955
956fn merge_oauth_refresh_config(
957    target: &mut OAuthRefreshConfig,
958    overlay: &FileOAuthRefreshConfig,
959) -> CliResult<()> {
960    if let Some(enabled) = overlay.enabled {
961        target.enabled = enabled;
962    }
963    if let Some(interval_seconds) = overlay.interval_seconds {
964        if interval_seconds == 0 {
965            return Err(CliError::Usage(
966                "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
967            ));
968        }
969        target.interval_seconds = interval_seconds;
970    }
971    if let Some(failure_retry_seconds) = overlay.failure_retry_seconds {
972        if failure_retry_seconds == 0 {
973            return Err(CliError::Usage(
974                "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
975            ));
976        }
977        target.failure_retry_seconds = failure_retry_seconds;
978    }
979    if let Some(refresh_on_startup) = overlay.refresh_on_startup {
980        target.refresh_on_startup = refresh_on_startup;
981    }
982    Ok(())
983}
984
985fn expand_path(value: &str, base: &std::path::Path) -> PathBuf {
986    if let Some(rest) = value.strip_prefix("~/") {
987        return env::var_os("HOME")
988            .map_or_else(|| PathBuf::from("."), PathBuf::from)
989            .join(rest);
990    }
991    let path = PathBuf::from(value);
992    if path.is_absolute() {
993        path
994    } else {
995        base.join(path)
996    }
997}
998
999#[cfg(test)]
1000mod tests;