Skip to main content

starweaver_cli/
config.rs

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