Skip to main content

starweaver_cli/
config.rs

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