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