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