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