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