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