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    CliError, CliResult,
15    args::{Cli, CliCommand, ConfigCommand, HitlPolicy, OutputMode, SetupCommand, TuiRenderMode},
16    environment::{
17        EnvironmentAttachmentAccessMode, LOCAL_ENVIRONMENT_ATTACHMENT_ID,
18        is_valid_environment_attachment_id,
19    },
20    error::io_error,
21    oauth::CODEX_BASE_URL,
22    slash_commands::{SlashCommandDefinition, normalize_command_name, valid_command_name},
23};
24
25mod env_overrides;
26mod metadata;
27mod state;
28mod templates;
29mod values;
30
31use env_overrides::{apply_cli_overrides, apply_env, parse_hitl_policy, parse_output_mode};
32pub use metadata::{mcp_servers, tool_need_approval};
33use metadata::{
34    merge_json_value, read_mcp_config, read_tools_config, resolve_mcp_mode,
35    resolve_user_input_timeout_seconds,
36};
37pub use state::{
38    clear_current_session, ensure_config_dirs, read_current_session,
39    read_last_retention_maintenance, remove_project_state, remove_project_state_if_current_session,
40    write_current_session, write_last_retention_maintenance,
41};
42use templates::default_config_template;
43pub use templates::{
44    DEFAULT_GLOBAL_GITIGNORE_TEMPLATE, DEFAULT_MCP_TEMPLATE, DEFAULT_PROJECT_GITIGNORE_TEMPLATE,
45    DEFAULT_TOOLS_TEMPLATE, init_config_file, write_default_subagent_presets,
46};
47pub use values::{ConfigScope, get_config_value, set_config_value};
48
49/// Model-facing MCP tool exposure mode.
50#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum McpExposureMode {
53    /// Expose every configured server's declared tools with a server namespace.
54    #[default]
55    Direct,
56    /// Expose the fixed `mcp_search_tool` and `mcp_call_tool` proxy surface.
57    Proxy,
58}
59
60/// Resolved CLI configuration.
61#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
62pub struct CliConfig {
63    /// Global config root.
64    pub global_dir: PathBuf,
65    /// Project config root.
66    pub project_dir: PathBuf,
67    /// TUI local state root.
68    pub tui_state_dir: PathBuf,
69    /// `SQLite` database path.
70    pub database_path: PathBuf,
71    /// Local file store path.
72    pub file_store_path: PathBuf,
73    /// Default profile.
74    pub default_profile: String,
75    /// Skill directory search paths.
76    pub skill_dirs: Vec<PathBuf>,
77    /// Subagent directory search paths.
78    pub subagent_dirs: Vec<PathBuf>,
79    /// Disabled subagent names from layered subagent config.
80    pub disabled_subagents: Vec<String>,
81    /// Workspace root for environment providers.
82    pub workspace_root: PathBuf,
83    /// Environment provider kind.
84    pub environment_provider: String,
85    /// Filesystem policy mode.
86    pub files_policy: String,
87    /// Whether shell execution is enabled for environment tools.
88    pub shell_enabled: bool,
89    /// Shell command review configuration.
90    pub shell_review: CliShellReviewConfig,
91    /// Default output mode.
92    pub default_output: OutputMode,
93    /// Default headless human-in-the-loop policy.
94    pub default_hitl: HitlPolicy,
95    /// Default maximum runtime goal retry iterations.
96    pub max_goal_iterations: usize,
97    /// Default TUI transcript rendering mode.
98    pub tui_render_mode: TuiRenderMode,
99    /// Update channel metadata.
100    pub update_channel: String,
101    /// OAuth token refresh supervisor configuration.
102    pub oauth_refresh: OAuthRefreshConfig,
103    /// Default model from `[general] model` fields.
104    pub default_model: Option<CliModelProfile>,
105    /// Named model profiles from `[model_profiles.*]` fields.
106    pub model_profiles: BTreeMap<String, CliModelProfile>,
107    /// Named envd profiles from `[envd_profiles.*]` fields.
108    pub envd_profiles: BTreeMap<String, CliEnvdProfile>,
109    /// Environment variables loaded from config `[env]` sections.
110    pub env_vars: BTreeMap<String, String>,
111    /// Provider API configuration.
112    pub providers: ProviderConfigs,
113    /// Tool config metadata loaded from tools.toml.
114    pub tools_config: serde_json::Value,
115    /// MCP config metadata loaded from mcp.json.
116    pub mcp_config: serde_json::Value,
117    /// Unmapped config metadata preserved for configuration audits.
118    pub unmapped_metadata: serde_json::Value,
119    /// Custom slash commands loaded from `[commands.*]` config sections.
120    pub slash_commands: BTreeMap<String, SlashCommandDefinition>,
121    /// Automatic retention maintenance after a run.
122    pub auto_trim: bool,
123    /// Recent runs to keep for the current session during automatic trim.
124    pub current_session_keep_recent_runs: usize,
125    /// Recent runs to keep per session during all-session retention maintenance.
126    pub all_sessions_keep_recent_runs: usize,
127    /// Age horizon for all-session retention maintenance.
128    pub all_sessions_keep_days: u64,
129    /// Minimum interval between all-session retention maintenance runs.
130    pub all_sessions_interval_hours: u64,
131}
132
133impl CliConfig {
134    /// Return the model-facing MCP tool exposure mode.
135    #[must_use]
136    pub fn mcp_mode(&self) -> McpExposureMode {
137        resolve_mcp_mode(&self.tools_config).unwrap_or_default()
138    }
139
140    /// Return the positive structured-question timeout in seconds.
141    #[must_use]
142    pub fn user_input_timeout_seconds(&self) -> u64 {
143        resolve_user_input_timeout_seconds(&self.tools_config).unwrap_or(120)
144    }
145
146    /// Return the default-enabled constrained `CodeAct` configuration.
147    #[must_use]
148    pub fn codeact(&self) -> CliCodeActConfig {
149        self.unmapped_metadata
150            .get("codeact")
151            .cloned()
152            .and_then(|value| serde_json::from_value(value).ok())
153            .unwrap_or_default()
154    }
155
156    pub(crate) fn set_codeact(&mut self, codeact: &CliCodeActConfig) {
157        merge_json_value(
158            &mut self.unmapped_metadata,
159            serde_json::json!({"codeact": codeact}),
160        );
161    }
162
163    /// Return the opt-in current-active-desktop Computer Use configuration.
164    #[must_use]
165    pub fn computer_use(&self) -> CliComputerUseConfig {
166        self.unmapped_metadata
167            .get("computer_use")
168            .cloned()
169            .and_then(|value| serde_json::from_value(value).ok())
170            .unwrap_or_default()
171    }
172
173    pub(crate) fn set_computer_use(&mut self, computer_use: &CliComputerUseConfig) {
174        merge_json_value(
175            &mut self.unmapped_metadata,
176            serde_json::json!({"computer_use": computer_use}),
177        );
178    }
179
180    /// Reads a process environment variable with `[env]` config as a fallback.
181    #[must_use]
182    pub fn env_value(&self, name: &str) -> Option<String> {
183        let name = name.trim();
184        if name.is_empty() {
185            return None;
186        }
187        env::var(name)
188            .ok()
189            .filter(|value| !value.trim().is_empty())
190            .or_else(|| {
191                self.env_vars
192                    .get(name)
193                    .cloned()
194                    .filter(|value| !value.trim().is_empty())
195            })
196    }
197
198    /// Returns whether a process or configured environment value is present.
199    #[must_use]
200    pub fn env_value_present(&self, name: Option<&str>) -> bool {
201        name.and_then(|name| self.env_value(name)).is_some()
202    }
203}
204
205/// Config resolver.
206#[allow(clippy::struct_field_names)]
207#[derive(Clone, Debug)]
208pub struct ConfigResolver {
209    global_dir: Option<PathBuf>,
210    project_dir: Option<PathBuf>,
211    shared_agents_dir: Option<PathBuf>,
212    current_dir: Option<PathBuf>,
213}
214
215#[derive(Clone, Debug, Default, Deserialize)]
216struct FileConfig {
217    general: Option<GeneralConfig>,
218    storage: Option<StorageConfig>,
219    environment: Option<EnvironmentConfig>,
220    codeact: Option<FileCodeActConfig>,
221    computer_use: Option<FileComputerUseConfig>,
222    security: Option<FileSecurityConfig>,
223    update: Option<UpdateConfig>,
224    providers: Option<FileProviderConfigs>,
225    oauth_refresh: Option<FileOAuthRefreshConfig>,
226    model_profiles: Option<BTreeMap<String, FileModelProfile>>,
227    envd_profiles: Option<BTreeMap<String, FileEnvdProfile>>,
228    env: Option<BTreeMap<String, String>>,
229    skills: Option<SkillsConfig>,
230    subagents: Option<SubagentsConfig>,
231    commands: Option<BTreeMap<String, FileCommandDefinition>>,
232    tui: Option<TuiConfig>,
233    trim: Option<TrimConfig>,
234}
235
236#[derive(Clone, Debug, Default, Deserialize)]
237struct TuiConfig {
238    render_mode: Option<TuiRenderMode>,
239}
240
241#[derive(Clone, Debug, Default, Deserialize)]
242struct GeneralConfig {
243    default_profile: Option<String>,
244    default_output: Option<OutputMode>,
245    default_hitl: Option<HitlPolicy>,
246    max_goal_iterations: Option<usize>,
247    model: Option<String>,
248    model_settings: Option<String>,
249    model_cfg: Option<String>,
250    max_requests: Option<u64>,
251}
252
253#[derive(Clone, Debug, Default, Deserialize)]
254struct FileOAuthRefreshConfig {
255    enabled: Option<bool>,
256    interval_seconds: Option<u64>,
257    failure_retry_seconds: Option<u64>,
258    refresh_on_startup: Option<bool>,
259}
260
261/// OAuth token refresh supervisor configuration.
262#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
263pub struct OAuthRefreshConfig {
264    /// Whether OAuth refresh supervisor should be started for OAuth-backed models.
265    pub enabled: bool,
266    /// Successful-refresh interval in seconds.
267    pub interval_seconds: u64,
268    /// Retry interval in seconds after the last refresh attempt failed.
269    pub failure_retry_seconds: u64,
270    /// Refresh immediately when the supervisor starts.
271    pub refresh_on_startup: bool,
272}
273
274impl Default for OAuthRefreshConfig {
275    fn default() -> Self {
276        Self {
277            enabled: true,
278            interval_seconds: 30 * 60,
279            failure_retry_seconds: 60,
280            refresh_on_startup: true,
281        }
282    }
283}
284
285#[derive(Clone, Debug, Default, Deserialize)]
286struct FileModelProfile {
287    label: Option<String>,
288    model: Option<String>,
289    model_settings: Option<String>,
290    model_cfg: Option<String>,
291}
292
293#[derive(Clone, Debug, Default, Deserialize)]
294struct FileEnvdProfile {
295    label: Option<String>,
296    enabled: Option<bool>,
297    #[serde(alias = "endpoint_ref", alias = "endpointRef")]
298    endpoint: Option<String>,
299    auth_token: Option<String>,
300    auth_token_env: Option<String>,
301    environment_id: Option<String>,
302    mount_id: Option<String>,
303    mode: Option<String>,
304    #[serde(rename = "default")]
305    is_default: Option<bool>,
306}
307
308#[derive(Clone, Debug, Default, Deserialize)]
309struct FileCommandDefinition {
310    prompt: Option<String>,
311    description: Option<String>,
312    aliases: Option<Vec<String>>,
313}
314
315#[derive(Clone, Debug, Default, Deserialize)]
316struct SkillsConfig {
317    dirs: Option<Vec<String>>,
318    additional_dirs: Option<Vec<String>>,
319}
320
321#[derive(Clone, Debug, Default, Deserialize)]
322struct SubagentsConfig {
323    dirs: Option<Vec<String>>,
324    additional_dirs: Option<Vec<String>>,
325    disabled: Option<Vec<String>>,
326    disabled_builtins: Option<Vec<String>>,
327}
328
329#[derive(Clone, Debug, Default, Deserialize)]
330struct StorageConfig {
331    database_path: Option<String>,
332    file_store_path: Option<String>,
333}
334
335/// CLI-owned default-enabled constrained `CodeAct` configuration.
336#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
337pub struct CliCodeActConfig {
338    /// Install the bounded synchronous JavaScript orchestration toolset.
339    #[serde(default = "default_true")]
340    pub enabled: bool,
341}
342
343impl Default for CliCodeActConfig {
344    fn default() -> Self {
345        Self { enabled: true }
346    }
347}
348
349#[derive(Clone, Debug, Default, Deserialize)]
350struct FileCodeActConfig {
351    enabled: Option<bool>,
352}
353
354impl FileCodeActConfig {
355    const fn merge_into(self, config: &mut CliCodeActConfig) {
356        if let Some(enabled) = self.enabled {
357            config.enabled = enabled;
358        }
359    }
360}
361
362const fn default_true() -> bool {
363    true
364}
365
366/// CLI-owned opt-in Computer Use configuration.
367#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
368pub struct CliComputerUseConfig {
369    /// Install the current-desktop observe, pointer, and keyboard toolset.
370    #[serde(default)]
371    pub enabled: bool,
372    /// Capture either the primary active display or complete visible desktop.
373    #[serde(default = "default_computer_use_desktop_scope")]
374    pub desktop_scope: String,
375}
376
377impl Default for CliComputerUseConfig {
378    fn default() -> Self {
379        Self {
380            enabled: false,
381            desktop_scope: default_computer_use_desktop_scope(),
382        }
383    }
384}
385
386fn default_computer_use_desktop_scope() -> String {
387    "primary_display".to_owned()
388}
389
390#[derive(Clone, Debug, Default, Deserialize)]
391struct FileComputerUseConfig {
392    enabled: Option<bool>,
393    desktop_scope: Option<String>,
394}
395
396impl FileComputerUseConfig {
397    fn merge_into(self, config: &mut CliComputerUseConfig) {
398        if let Some(enabled) = self.enabled {
399            config.enabled = enabled;
400        }
401        if let Some(desktop_scope) = self.desktop_scope {
402            config.desktop_scope = desktop_scope;
403        }
404    }
405}
406
407#[derive(Clone, Debug, Default, Deserialize)]
408struct EnvironmentConfig {
409    workspace_root: Option<String>,
410    provider: Option<String>,
411    files_policy: Option<String>,
412    shell_enabled: Option<bool>,
413}
414
415#[derive(Clone, Debug, Default, Deserialize)]
416struct FileSecurityConfig {
417    shell_review: Option<FileShellReviewConfig>,
418}
419
420#[derive(Clone, Debug, Default, Deserialize)]
421struct FileShellReviewConfig {
422    enabled: Option<bool>,
423    model: Option<String>,
424    model_settings: Option<String>,
425    on_needs_approval: Option<String>,
426    risk_threshold: Option<String>,
427    system_prompt: Option<String>,
428}
429
430/// CLI shell command review configuration.
431#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
432pub struct CliShellReviewConfig {
433    /// Whether shell review is enabled.
434    pub enabled: bool,
435    /// Review model id.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub model: Option<String>,
438    /// Review model settings preset.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub model_settings: Option<String>,
441    /// Action when review reaches threshold: defer or deny.
442    pub on_needs_approval: String,
443    /// Risk threshold: low, medium, high, or `extra_high`.
444    pub risk_threshold: String,
445    /// Optional prompt override.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub system_prompt: Option<String>,
448}
449
450impl Default for CliShellReviewConfig {
451    fn default() -> Self {
452        Self {
453            enabled: false,
454            model: None,
455            model_settings: None,
456            on_needs_approval: "defer".to_string(),
457            risk_threshold: "high".to_string(),
458            system_prompt: None,
459        }
460    }
461}
462
463impl CliShellReviewConfig {
464    fn validate(&self) -> CliResult<()> {
465        if self.enabled
466            && self
467                .model
468                .as_deref()
469                .is_none_or(|model| model.trim().is_empty())
470        {
471            return Err(CliError::Config(
472                "security.shell_review.model is required when shell review is enabled".to_string(),
473            ));
474        }
475        validate_shell_review_action(&self.on_needs_approval)?;
476        validate_shell_review_risk(&self.risk_threshold)?;
477        Ok(())
478    }
479}
480
481#[derive(Clone, Debug, Default, Deserialize)]
482struct UpdateConfig {
483    channel: Option<String>,
484}
485
486/// CLI model profile resolved from config.
487#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
488pub struct CliModelProfile {
489    /// Human label for display.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub label: Option<String>,
492    /// Provider model id, such as `openai-responses:gpt-5`, `openai-responses-ws:gpt-5`, or `homelab@openai-responses:gpt-5`.
493    pub model_id: String,
494    /// Model config preset name from `model_cfg`.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub model_cfg: Option<String>,
497    /// Model settings preset name.
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub model_settings: Option<String>,
500}
501
502/// Envd profile resolved from config.
503#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
504pub struct CliEnvdProfile {
505    /// Human label for display.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub label: Option<String>,
508    /// Whether this profile is active for TUI runs.
509    pub enabled: bool,
510    /// HTTP endpoint for the envd JSON-RPC transport.
511    pub endpoint: String,
512    /// Bearer token stored directly in config. This is intentionally
513    /// request-only and must not be returned by config display surfaces.
514    #[serde(default, skip_serializing)]
515    pub auth_token: Option<String>,
516    /// Environment variable that contains the bearer token.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub auth_token_env: Option<String>,
519    /// Concrete envd environment id.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub environment_id: Option<String>,
522    /// Agent-facing mount id.
523    pub mount_id: String,
524    /// Access mode for the mount.
525    pub mode: EnvironmentAttachmentAccessMode,
526    /// Whether this profile should be the default TUI environment.
527    #[serde(rename = "default")]
528    pub is_default: bool,
529}
530
531/// Provider API configuration.
532#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
533pub struct ProviderConfigs {
534    /// `OpenAI` provider config.
535    pub openai: ProviderConfig,
536    /// Anthropic provider config.
537    pub anthropic: ProviderConfig,
538    /// Gemini provider config.
539    pub gemini: ProviderConfig,
540    /// Google Cloud Gemini provider config.
541    pub google_cloud: ProviderConfig,
542    /// Codex OAuth provider config.
543    pub codex: ProviderConfig,
544    /// Named gateway provider configs.
545    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
546    pub gateways: BTreeMap<String, ProviderConfig>,
547}
548
549#[derive(Clone, Debug, Default, Deserialize)]
550struct FileProviderConfigs {
551    openai: Option<FileProviderConfig>,
552    anthropic: Option<FileProviderConfig>,
553    gemini: Option<FileProviderConfig>,
554    google: Option<FileProviderConfig>,
555    #[serde(rename = "google-gla")]
556    google_gla: Option<FileProviderConfig>,
557    #[serde(rename = "google-cloud", alias = "google_cloud")]
558    google_cloud: Option<FileProviderConfig>,
559    #[serde(rename = "google-vertex")]
560    google_vertex: Option<FileProviderConfig>,
561    codex: Option<FileProviderConfig>,
562    #[serde(flatten)]
563    gateways: BTreeMap<String, FileProviderConfig>,
564}
565
566#[derive(Clone, Debug, Default, Deserialize)]
567struct FileProviderConfig {
568    enabled: Option<bool>,
569    api_key_env: Option<String>,
570    auth_token_env: Option<String>,
571    project: Option<String>,
572    location: Option<String>,
573    base_url: Option<String>,
574    endpoint_path: Option<String>,
575    max_tokens_parameter: Option<MaxTokensParameter>,
576}
577
578/// Single provider API configuration.
579#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
580pub struct ProviderConfig {
581    /// Enable provider-backed profile resolution for this provider.
582    pub enabled: bool,
583    /// Environment variable containing the provider API key.
584    pub api_key_env: Option<String>,
585    /// Environment variable containing a bearer access token.
586    pub auth_token_env: Option<String>,
587    /// Provider project identifier when required by the backend.
588    pub project: Option<String>,
589    /// Provider location or region when required by the backend.
590    pub location: Option<String>,
591    /// Provider or gateway base URL.
592    pub base_url: Option<String>,
593    /// Override endpoint path.
594    pub endpoint_path: Option<String>,
595    /// Provider or gateway max-token parameter mapping.
596    pub max_tokens_parameter: MaxTokensParameter,
597}
598
599impl Default for ProviderConfig {
600    fn default() -> Self {
601        Self {
602            enabled: true,
603            api_key_env: None,
604            auth_token_env: None,
605            project: None,
606            location: None,
607            base_url: None,
608            endpoint_path: None,
609            max_tokens_parameter: MaxTokensParameter::Default,
610        }
611    }
612}
613
614#[derive(Clone, Debug, Default, Deserialize)]
615struct TrimConfig {
616    auto_after_run: Option<bool>,
617    current_session_keep_recent_runs: Option<usize>,
618    all_sessions_keep_recent_runs: Option<usize>,
619    all_sessions_keep_days: Option<u64>,
620    all_sessions_interval_hours: Option<u64>,
621}
622
623impl Default for ConfigResolver {
624    fn default() -> Self {
625        Self {
626            global_dir: env::var_os("STARWEAVER_CONFIG_DIR").map(PathBuf::from),
627            project_dir: env::var_os("STARWEAVER_PROJECT_DIR").map(PathBuf::from),
628            shared_agents_dir: None,
629            current_dir: None,
630        }
631    }
632}
633
634impl ConfigResolver {
635    /// Build a resolver pinned to a root for deterministic tests.
636    #[must_use]
637    pub fn for_tests(root: &std::path::Path) -> Self {
638        Self {
639            global_dir: Some(root.join("global")),
640            project_dir: Some(root.join("project/.starweaver")),
641            shared_agents_dir: Some(root.join("shared-agents")),
642            current_dir: Some(root.join("project")),
643        }
644    }
645
646    /// Resolve final config.
647    pub fn resolve(&self, cli: &Cli) -> CliResult<CliConfig> {
648        let current_dir = self.current_dir.clone().unwrap_or_else(default_current_dir);
649        let global_dir = match self.global_dir.clone() {
650            Some(global_dir) => global_dir,
651            None => default_global_dir()?,
652        };
653        let project_dir = self
654            .project_dir
655            .clone()
656            .unwrap_or_else(|| default_project_dir(cli, &global_dir, &current_dir));
657        let shared_agents_dir = self
658            .shared_agents_dir
659            .clone()
660            .unwrap_or_else(default_shared_agents_dir);
661        let mut config = CliConfig {
662            global_dir: global_dir.clone(),
663            project_dir: project_dir.clone(),
664            tui_state_dir: global_dir.join("tui"),
665            database_path: starweaver_storage::canonical_session_database_path(&global_dir),
666            file_store_path: project_dir.join("store"),
667            default_profile: "general".to_string(),
668            skill_dirs: default_skill_dirs(&global_dir, &shared_agents_dir, &project_dir),
669            subagent_dirs: vec![global_dir.join("subagents"), project_dir.join("subagents")],
670            disabled_subagents: Vec::new(),
671            workspace_root: default_workspace_root(&project_dir, &global_dir, &current_dir),
672            environment_provider: "local".to_string(),
673            files_policy: "read_write".to_string(),
674            shell_enabled: true,
675            shell_review: CliShellReviewConfig::default(),
676            default_output: OutputMode::AguiJsonl,
677            default_hitl: HitlPolicy::Defer,
678            max_goal_iterations: 10,
679            tui_render_mode: TuiRenderMode::Concise,
680            update_channel: "stable".to_string(),
681            oauth_refresh: OAuthRefreshConfig::default(),
682            default_model: None,
683            model_profiles: BTreeMap::new(),
684            envd_profiles: BTreeMap::new(),
685            env_vars: BTreeMap::new(),
686            providers: default_provider_configs(),
687            tools_config: serde_json::Value::Null,
688            mcp_config: serde_json::Value::Null,
689            unmapped_metadata: serde_json::json!({}),
690            slash_commands: BTreeMap::new(),
691            auto_trim: true,
692            current_session_keep_recent_runs: 20,
693            all_sessions_keep_recent_runs: 20,
694            all_sessions_keep_days: 60,
695            all_sessions_interval_hours: 24,
696        };
697        bootstrap_global_config_dir(&global_dir)?;
698        apply_file_config(&mut config, &global_dir.join("config.toml"))?;
699        apply_file_config(&mut config, &project_dir.join("config.toml"))?;
700        config.tools_config = read_tools_config(&global_dir, &project_dir)?;
701        resolve_mcp_mode(&config.tools_config)?;
702        resolve_user_input_timeout_seconds(&config.tools_config)?;
703        config.mcp_config = read_mcp_config(&global_dir, &project_dir)?;
704        apply_env(&mut config);
705        apply_cli_overrides(&mut config, cli, &project_dir);
706        config.shell_review.validate()?;
707        Ok(config)
708    }
709}
710
711fn default_global_dir() -> CliResult<PathBuf> {
712    starweaver_storage::default_starweaver_config_dir()
713        .map_err(|error| CliError::Config(error.to_string()))
714}
715
716fn default_shared_agents_dir() -> PathBuf {
717    env::var_os("HOME")
718        .map_or_else(|| PathBuf::from("."), PathBuf::from)
719        .join(".agents")
720}
721
722fn default_current_dir() -> PathBuf {
723    env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
724}
725
726fn default_skill_dirs(
727    global_dir: &std::path::Path,
728    shared_agents_dir: &std::path::Path,
729    project_dir: &std::path::Path,
730) -> Vec<PathBuf> {
731    vec![
732        global_dir.join("skills"),
733        shared_agents_dir.join("skills"),
734        project_dir.join("skills"),
735    ]
736}
737
738fn default_project_dir(cli: &Cli, global_dir: &Path, current_dir: &Path) -> PathBuf {
739    if wants_project_config(cli) {
740        return current_dir.join(".starweaver");
741    }
742    find_project_dir(current_dir, global_dir).unwrap_or_else(|| global_dir.to_path_buf())
743}
744
745fn default_workspace_root(project_dir: &Path, global_dir: &Path, current_dir: &Path) -> PathBuf {
746    if paths_equivalent(project_dir, global_dir) {
747        return current_dir.to_path_buf();
748    }
749    project_dir
750        .parent()
751        .filter(|parent| !parent.as_os_str().is_empty())
752        .map_or_else(|| current_dir.to_path_buf(), std::path::Path::to_path_buf)
753}
754
755const fn wants_project_config(cli: &Cli) -> bool {
756    matches!(
757        &cli.command,
758        Some(
759            CliCommand::Setup(SetupCommand {
760                global: false,
761                project: true,
762                ..
763            }) | CliCommand::Config {
764                command: ConfigCommand::Init {
765                    global: false,
766                    project: true,
767                    ..
768                } | ConfigCommand::Set {
769                    global: false,
770                    project: true,
771                    ..
772                }
773            }
774        )
775    )
776}
777
778fn find_project_dir(start: &Path, global_dir: &Path) -> Option<PathBuf> {
779    let home_project_dir = starweaver_storage::default_starweaver_config_dir().ok();
780    let mut current = start.to_path_buf();
781    loop {
782        let candidate = current.join(".starweaver");
783        let is_global_dir = paths_equivalent(&candidate, global_dir);
784        let is_home_global_dir = home_project_dir
785            .as_ref()
786            .is_some_and(|home| paths_equivalent(&candidate, home));
787        if candidate.join("config.toml").exists() && !is_global_dir && !is_home_global_dir {
788            return Some(candidate);
789        }
790        if !current.pop() {
791            return None;
792        }
793    }
794}
795
796fn paths_equivalent(left: &Path, right: &Path) -> bool {
797    if left == right {
798        return true;
799    }
800    let normalized_left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
801    let normalized_right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
802    normalized_left == normalized_right
803}
804
805fn bootstrap_global_config_dir(global_dir: &Path) -> CliResult<()> {
806    fs::create_dir_all(global_dir).map_err(|error| io_error(global_dir, error))?;
807    let config_path = global_dir.join("config.toml");
808    if !config_path.exists() {
809        fs::write(&config_path, default_config_template(ConfigScope::Global))
810            .map_err(|error| io_error(&config_path, error))?;
811    }
812    for (path, content) in [
813        (global_dir.join("tools.toml"), DEFAULT_TOOLS_TEMPLATE),
814        (global_dir.join("mcp.json"), DEFAULT_MCP_TEMPLATE),
815        (
816            global_dir.join(".gitignore"),
817            DEFAULT_GLOBAL_GITIGNORE_TEMPLATE,
818        ),
819    ] {
820        if !path.exists() {
821            fs::write(&path, content).map_err(|error| io_error(&path, error))?;
822        }
823    }
824    for name in ["skills", "subagents", "tui"] {
825        let path = global_dir.join(name);
826        fs::create_dir_all(&path).map_err(|error| io_error(path, error))?;
827    }
828    write_default_subagent_presets(global_dir, false)?;
829    Ok(())
830}
831
832fn default_provider_configs() -> ProviderConfigs {
833    ProviderConfigs {
834        openai: ProviderConfig {
835            api_key_env: Some("OPENAI_API_KEY".to_string()),
836            base_url: Some("https://api.openai.com/v1".to_string()),
837            ..ProviderConfig::default()
838        },
839        anthropic: ProviderConfig {
840            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
841            base_url: Some("https://api.anthropic.com/v1".to_string()),
842            ..ProviderConfig::default()
843        },
844        gemini: ProviderConfig {
845            api_key_env: Some("GEMINI_API_KEY".to_string()),
846            base_url: Some("https://generativelanguage.googleapis.com/v1beta".to_string()),
847            ..ProviderConfig::default()
848        },
849        google_cloud: ProviderConfig {
850            api_key_env: Some("GOOGLE_API_KEY".to_string()),
851            auth_token_env: Some("GOOGLE_CLOUD_ACCESS_TOKEN".to_string()),
852            base_url: Some("https://aiplatform.googleapis.com".to_string()),
853            ..ProviderConfig::default()
854        },
855        codex: ProviderConfig {
856            base_url: Some(CODEX_BASE_URL.to_string()),
857            max_tokens_parameter: MaxTokensParameter::Omit,
858            ..ProviderConfig::default()
859        },
860        gateways: BTreeMap::new(),
861    }
862}
863
864#[allow(clippy::too_many_lines)]
865fn apply_file_config(config: &mut CliConfig, path: &PathBuf) -> CliResult<()> {
866    if !path.exists() {
867        return Ok(());
868    }
869    let content = fs::read_to_string(path).map_err(|error| io_error(path, error))?;
870    let raw = content
871        .parse::<Value>()
872        .map_err(|error| CliError::Config(error.to_string()))?;
873    merge_unmapped_metadata(config, &raw);
874    let parsed = toml::from_str::<FileConfig>(&content)?;
875    let base = path.parent().unwrap_or_else(|| std::path::Path::new("."));
876    if let Some(general) = parsed.general {
877        let has_default_profile = general.default_profile.is_some();
878        let model = general.model.clone();
879        let model_settings = general.model_settings.clone();
880        let model_cfg = general.model_cfg.clone();
881        let max_requests = general.max_requests;
882        if let Some(max_requests) = max_requests {
883            merge_json_value(
884                &mut config.unmapped_metadata,
885                serde_json::json!({"general": {"max_requests": max_requests}}),
886            );
887        }
888        if let Some(profile) = general.default_profile {
889            config.default_profile = profile;
890        }
891        if let Some(output) = general.default_output {
892            config.default_output = output;
893        }
894        if let Some(hitl) = general.default_hitl {
895            config.default_hitl = hitl;
896        }
897        if let Some(max_goal_iterations) = general.max_goal_iterations {
898            config.max_goal_iterations = max_goal_iterations.max(1);
899        }
900        if let Some(model_id) = model {
901            config.default_model = Some(CliModelProfile {
902                label: Some("Default".to_string()),
903                model_id,
904                model_cfg,
905                model_settings,
906            });
907            if !has_default_profile {
908                config.default_profile = "default_model".to_string();
909            }
910        }
911    }
912    if let Some(storage) = parsed.storage {
913        if let Some(database_path) = storage.database_path {
914            config.database_path = expand_path(&database_path, base);
915        }
916        if let Some(file_store_path) = storage.file_store_path {
917            config.file_store_path = expand_path(&file_store_path, base);
918        }
919    }
920    if let Some(environment) = parsed.environment {
921        if let Some(workspace_root) = environment.workspace_root {
922            config.workspace_root = expand_path(&workspace_root, base);
923        }
924        if let Some(provider) = environment.provider {
925            config.environment_provider = provider;
926        }
927        if let Some(files_policy) = environment.files_policy {
928            config.files_policy = files_policy;
929        }
930        if let Some(shell_enabled) = environment.shell_enabled {
931            config.shell_enabled = shell_enabled;
932        }
933    }
934    if let Some(codeact) = parsed.codeact {
935        let mut effective = config.codeact();
936        codeact.merge_into(&mut effective);
937        config.set_codeact(&effective);
938    }
939    if let Some(computer_use) = parsed.computer_use {
940        let mut effective = config.computer_use();
941        computer_use.merge_into(&mut effective);
942        config.set_computer_use(&effective);
943    }
944    if let Some(security) = parsed.security
945        && let Some(shell_review) = security.shell_review
946    {
947        merge_shell_review_config(&mut config.shell_review, shell_review)?;
948    }
949    if let Some(update) = parsed.update
950        && let Some(channel) = update.channel
951    {
952        config.update_channel = channel;
953    }
954    if let Some(providers) = parsed.providers {
955        merge_provider_configs(&mut config.providers, providers);
956    }
957    if let Some(oauth_refresh) = parsed.oauth_refresh {
958        merge_oauth_refresh_config(&mut config.oauth_refresh, &oauth_refresh)?;
959    }
960    if let Some(env_vars) = parsed.env {
961        config.env_vars.extend(env_vars);
962    }
963    if let Some(model_profiles) = parsed.model_profiles {
964        for (name, profile) in model_profiles {
965            if let Some(model_id) = profile.model {
966                config.model_profiles.insert(
967                    name,
968                    CliModelProfile {
969                        label: profile.label,
970                        model_id,
971                        model_cfg: profile.model_cfg,
972                        model_settings: profile.model_settings,
973                    },
974                );
975            }
976        }
977    }
978    if let Some(envd_profiles) = parsed.envd_profiles {
979        for (name, profile) in envd_profiles {
980            let profile = resolve_envd_profile(&name, profile)?;
981            config.envd_profiles.insert(name, profile);
982        }
983    }
984    if let Some(skills) = parsed.skills {
985        merge_skill_dirs(config, skills, base);
986    }
987    if let Some(subagents) = parsed.subagents {
988        merge_subagent_config(config, subagents, base);
989    }
990    if let Some(commands) = parsed.commands {
991        merge_slash_commands(config, commands);
992    }
993    if let Some(tui) = parsed.tui
994        && let Some(render_mode) = tui.render_mode
995    {
996        config.tui_render_mode = render_mode;
997    }
998    if let Some(trim) = parsed.trim {
999        if let Some(auto_after_run) = trim.auto_after_run {
1000            config.auto_trim = auto_after_run;
1001        }
1002        if let Some(keep) = trim.current_session_keep_recent_runs {
1003            config.current_session_keep_recent_runs = keep;
1004        }
1005        if let Some(keep) = trim.all_sessions_keep_recent_runs {
1006            config.all_sessions_keep_recent_runs = keep;
1007        }
1008        if let Some(days) = trim.all_sessions_keep_days {
1009            config.all_sessions_keep_days = days;
1010        }
1011        if let Some(hours) = trim.all_sessions_interval_hours {
1012            config.all_sessions_interval_hours = hours;
1013        }
1014    }
1015    Ok(())
1016}
1017
1018fn merge_skill_dirs(config: &mut CliConfig, skills: SkillsConfig, base: &std::path::Path) {
1019    if let Some(dirs) = skills.dirs {
1020        config.skill_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
1021    }
1022    if let Some(additional_dirs) = skills.additional_dirs {
1023        config
1024            .skill_dirs
1025            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
1026    }
1027}
1028
1029fn merge_subagent_config(
1030    config: &mut CliConfig,
1031    subagents: SubagentsConfig,
1032    base: &std::path::Path,
1033) {
1034    if let Some(dirs) = subagents.dirs {
1035        config.subagent_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
1036    }
1037    if let Some(additional_dirs) = subagents.additional_dirs {
1038        config
1039            .subagent_dirs
1040            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
1041    }
1042    if let Some(disabled) = subagents.disabled {
1043        config.disabled_subagents.extend(disabled);
1044    }
1045    if let Some(disabled_builtins) = subagents.disabled_builtins {
1046        config.disabled_subagents.extend(disabled_builtins);
1047    }
1048    config.disabled_subagents.sort();
1049    config.disabled_subagents.dedup();
1050}
1051
1052fn merge_slash_commands(config: &mut CliConfig, commands: BTreeMap<String, FileCommandDefinition>) {
1053    for (name, command) in commands {
1054        let normalized = normalize_command_name(&name);
1055        if !valid_command_name(&normalized) || reserved_slash_command(&normalized) {
1056            continue;
1057        }
1058        let Some(prompt) = command.prompt.filter(|prompt| !prompt.trim().is_empty()) else {
1059            continue;
1060        };
1061        let aliases = command
1062            .aliases
1063            .unwrap_or_default()
1064            .into_iter()
1065            .map(|alias| normalize_command_name(&alias))
1066            .filter(|alias| valid_command_name(alias) && !reserved_slash_command(alias))
1067            .collect::<BTreeSet<_>>()
1068            .into_iter()
1069            .filter(|alias| alias != &normalized)
1070            .collect::<Vec<_>>();
1071        let definition = SlashCommandDefinition {
1072            name: normalized,
1073            prompt,
1074            description: command.description,
1075            aliases,
1076        };
1077        upsert_slash_command(config, definition);
1078    }
1079}
1080
1081fn resolve_envd_profile(name: &str, profile: FileEnvdProfile) -> CliResult<CliEnvdProfile> {
1082    let endpoint = required_trimmed_envd_field(name, "endpoint", profile.endpoint)?;
1083    let mount_id = profile.mount_id.unwrap_or_else(|| name.to_string());
1084    let mount_id = mount_id.trim().to_string();
1085    if !is_valid_environment_attachment_id(&mount_id) {
1086        return Err(CliError::Config(format!(
1087            "envd profile {name} has invalid mount_id: {mount_id}; expected an ASCII slug"
1088        )));
1089    }
1090    if mount_id == LOCAL_ENVIRONMENT_ATTACHMENT_ID {
1091        return Err(CliError::Config(format!(
1092            "envd profile {name} cannot use reserved mount_id: {mount_id}"
1093        )));
1094    }
1095    let auth_token = profile
1096        .auth_token
1097        .as_deref()
1098        .map(|token| validate_envd_token_field(name, "auth_token", token))
1099        .transpose()?;
1100    let auth_token_env = profile
1101        .auth_token_env
1102        .as_deref()
1103        .map(|env| validate_envd_token_env_field(name, env))
1104        .transpose()?;
1105    if auth_token.is_none() && auth_token_env.is_none() {
1106        return Err(CliError::Config(format!(
1107            "envd profile {name} requires auth_token or auth_token_env"
1108        )));
1109    }
1110    let mode = match profile.mode.as_deref().unwrap_or("read_write").trim() {
1111        "read_only" | "read-only" => EnvironmentAttachmentAccessMode::ReadOnly,
1112        "read_write" | "read-write" => EnvironmentAttachmentAccessMode::ReadWrite,
1113        other => {
1114            return Err(CliError::Config(format!(
1115                "envd profile {name} has invalid mode: {other}; expected read_only or read_write"
1116            )));
1117        }
1118    };
1119    Ok(CliEnvdProfile {
1120        label: profile.label,
1121        enabled: profile.enabled.unwrap_or(true),
1122        endpoint,
1123        auth_token,
1124        auth_token_env,
1125        environment_id: profile
1126            .environment_id
1127            .map(|value| value.trim().to_string())
1128            .filter(|value| !value.is_empty()),
1129        mount_id,
1130        mode,
1131        is_default: profile.is_default.unwrap_or(false),
1132    })
1133}
1134
1135fn required_trimmed_envd_field(
1136    profile_name: &str,
1137    field: &str,
1138    value: Option<String>,
1139) -> CliResult<String> {
1140    let Some(value) = value else {
1141        return Err(CliError::Config(format!(
1142            "envd profile {profile_name} requires {field}"
1143        )));
1144    };
1145    let value = value.trim().to_string();
1146    if value.is_empty() {
1147        return Err(CliError::Config(format!(
1148            "envd profile {profile_name} {field} cannot be empty"
1149        )));
1150    }
1151    Ok(value)
1152}
1153
1154fn validate_envd_token_field(profile_name: &str, field: &str, token: &str) -> CliResult<String> {
1155    let token = token.trim().to_string();
1156    if token.is_empty() {
1157        return Err(CliError::Config(format!(
1158            "envd profile {profile_name} {field} cannot be empty"
1159        )));
1160    }
1161    if token.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) {
1162        return Err(CliError::Config(format!(
1163            "envd profile {profile_name} {field} cannot contain newlines"
1164        )));
1165    }
1166    Ok(token)
1167}
1168
1169fn validate_envd_token_env_field(profile_name: &str, env: &str) -> CliResult<String> {
1170    let env = env.trim().to_string();
1171    if env.is_empty() {
1172        return Err(CliError::Config(format!(
1173            "envd profile {profile_name} auth_token_env cannot be empty"
1174        )));
1175    }
1176    Ok(env)
1177}
1178
1179fn upsert_slash_command(config: &mut CliConfig, mut definition: SlashCommandDefinition) {
1180    let canonical = definition.name.clone();
1181    let stale_aliases = config
1182        .slash_commands
1183        .iter()
1184        .filter(|(lookup, existing)| existing.name == canonical && *lookup != &canonical)
1185        .map(|(lookup, _)| lookup.clone())
1186        .collect::<Vec<_>>();
1187    for alias in stale_aliases {
1188        config.slash_commands.remove(&alias);
1189    }
1190    for existing in config.slash_commands.values_mut() {
1191        if existing.name != canonical {
1192            existing.aliases.retain(|alias| alias != &canonical);
1193        }
1194    }
1195
1196    let requested_aliases = std::mem::take(&mut definition.aliases);
1197    let active_aliases = requested_aliases
1198        .into_iter()
1199        .filter(|alias| {
1200            config
1201                .slash_commands
1202                .get(alias)
1203                .is_none_or(|existing| existing.name == canonical)
1204        })
1205        .collect::<Vec<_>>();
1206    definition.aliases.clone_from(&active_aliases);
1207    config.slash_commands.insert(canonical, definition.clone());
1208    for alias in active_aliases {
1209        config.slash_commands.insert(alias, definition.clone());
1210    }
1211}
1212
1213fn reserved_slash_command(name: &str) -> bool {
1214    crate::command_catalog::builtin_command_names().contains(name)
1215        || matches!(name, "config" | "loop" | "dump" | "load" | "exit")
1216}
1217
1218fn merge_unmapped_metadata(config: &mut CliConfig, raw: &Value) {
1219    let Some(root) = raw.as_table() else {
1220        return;
1221    };
1222    let mut metadata = serde_json::Map::new();
1223    for key in ["display", "subagents", "security"] {
1224        if let Some(value) = root.get(key).cloned()
1225            && let Ok(json) = serde_json::to_value(value)
1226        {
1227            metadata.insert(key.to_string(), json);
1228        }
1229    }
1230    if !metadata.is_empty() {
1231        merge_json_value(
1232            &mut config.unmapped_metadata,
1233            serde_json::Value::Object(metadata),
1234        );
1235    }
1236}
1237
1238fn merge_provider_configs(target: &mut ProviderConfigs, overlay: FileProviderConfigs) {
1239    if let Some(openai) = overlay.openai {
1240        merge_provider_config(&mut target.openai, openai);
1241    }
1242    if let Some(anthropic) = overlay.anthropic {
1243        merge_provider_config(&mut target.anthropic, anthropic);
1244    }
1245    if let Some(gemini) = overlay.gemini {
1246        merge_provider_config(&mut target.gemini, gemini);
1247    }
1248    if let Some(google) = overlay.google {
1249        merge_provider_config(&mut target.gemini, google);
1250    }
1251    if let Some(google_gla) = overlay.google_gla {
1252        merge_provider_config(&mut target.gemini, google_gla);
1253    }
1254    if let Some(google_cloud) = overlay.google_cloud {
1255        merge_provider_config(&mut target.google_cloud, google_cloud);
1256    }
1257    if let Some(google_vertex) = overlay.google_vertex {
1258        merge_provider_config(&mut target.google_cloud, google_vertex);
1259    }
1260    if let Some(codex) = overlay.codex {
1261        merge_provider_config(&mut target.codex, codex);
1262    }
1263    for (name, gateway) in overlay.gateways {
1264        merge_provider_config(target.gateways.entry(name).or_default(), gateway);
1265    }
1266}
1267
1268fn merge_provider_config(target: &mut ProviderConfig, overlay: FileProviderConfig) {
1269    if let Some(enabled) = overlay.enabled {
1270        target.enabled = enabled;
1271    }
1272    if overlay.api_key_env.is_some() {
1273        target.api_key_env = overlay.api_key_env;
1274    }
1275    if overlay.auth_token_env.is_some() {
1276        target.auth_token_env = overlay.auth_token_env;
1277    }
1278    if overlay.project.is_some() {
1279        target.project = overlay.project;
1280    }
1281    if overlay.location.is_some() {
1282        target.location = overlay.location;
1283    }
1284    if overlay.base_url.is_some() {
1285        target.base_url = overlay.base_url;
1286    }
1287    if overlay.endpoint_path.is_some() {
1288        target.endpoint_path = overlay.endpoint_path;
1289    }
1290    if let Some(max_tokens_parameter) = overlay.max_tokens_parameter {
1291        target.max_tokens_parameter = max_tokens_parameter;
1292    }
1293}
1294
1295fn merge_shell_review_config(
1296    target: &mut CliShellReviewConfig,
1297    overlay: FileShellReviewConfig,
1298) -> CliResult<()> {
1299    if let Some(enabled) = overlay.enabled {
1300        target.enabled = enabled;
1301    }
1302    if overlay.model.is_some() {
1303        target.model = overlay.model;
1304    }
1305    if overlay.model_settings.is_some() {
1306        target.model_settings = overlay.model_settings;
1307    }
1308    if let Some(action) = overlay.on_needs_approval {
1309        target.on_needs_approval = validate_shell_review_action(&action)?.to_string();
1310    }
1311    if let Some(threshold) = overlay.risk_threshold {
1312        target.risk_threshold = validate_shell_review_risk(&threshold)?.to_string();
1313    }
1314    if overlay.system_prompt.is_some() {
1315        target.system_prompt = overlay.system_prompt;
1316    }
1317    Ok(())
1318}
1319
1320fn validate_shell_review_action(value: &str) -> CliResult<&'static str> {
1321    match value.trim() {
1322        "defer" => Ok("defer"),
1323        "deny" => Ok("deny"),
1324        other => Err(CliError::Usage(format!(
1325            "invalid security.shell_review.on_needs_approval: {other}; expected defer or deny"
1326        ))),
1327    }
1328}
1329
1330fn validate_shell_review_risk(value: &str) -> CliResult<&'static str> {
1331    match value.trim() {
1332        "low" => Ok("low"),
1333        "medium" => Ok("medium"),
1334        "high" => Ok("high"),
1335        "extra_high" | "extra-high" => Ok("extra_high"),
1336        other => Err(CliError::Usage(format!(
1337            "invalid security.shell_review.risk_threshold: {other}; expected low, medium, high, or extra_high"
1338        ))),
1339    }
1340}
1341
1342fn merge_oauth_refresh_config(
1343    target: &mut OAuthRefreshConfig,
1344    overlay: &FileOAuthRefreshConfig,
1345) -> CliResult<()> {
1346    if let Some(enabled) = overlay.enabled {
1347        target.enabled = enabled;
1348    }
1349    if let Some(interval_seconds) = overlay.interval_seconds {
1350        if interval_seconds == 0 {
1351            return Err(CliError::Usage(
1352                "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
1353            ));
1354        }
1355        target.interval_seconds = interval_seconds;
1356    }
1357    if let Some(failure_retry_seconds) = overlay.failure_retry_seconds {
1358        if failure_retry_seconds == 0 {
1359            return Err(CliError::Usage(
1360                "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
1361            ));
1362        }
1363        target.failure_retry_seconds = failure_retry_seconds;
1364    }
1365    if let Some(refresh_on_startup) = overlay.refresh_on_startup {
1366        target.refresh_on_startup = refresh_on_startup;
1367    }
1368    Ok(())
1369}
1370
1371fn expand_path(value: &str, base: &std::path::Path) -> PathBuf {
1372    if let Some(rest) = value.strip_prefix("~/") {
1373        return env::var_os("HOME")
1374            .map_or_else(|| PathBuf::from("."), PathBuf::from)
1375            .join(rest);
1376    }
1377    let path = PathBuf::from(value);
1378    if path.is_absolute() {
1379        path
1380    } else {
1381        base.join(path)
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests;