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