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