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