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