1use std::{
4 collections::{BTreeMap, BTreeSet},
5 env, fs,
6 path::{Path, PathBuf},
7};
8
9use serde::{Deserialize, Serialize};
10use starweaver_model::MaxTokensParameter;
11use toml::Value;
12
13use crate::{
14 args::{Cli, CliCommand, ConfigCommand, HitlPolicy, OutputMode, SetupCommand},
15 error::io_error,
16 oauth::CODEX_BASE_URL,
17 slash_commands::{normalize_command_name, valid_command_name, SlashCommandDefinition},
18 CliError, CliResult,
19};
20
21mod env_overrides;
22mod metadata;
23mod state;
24mod templates;
25mod values;
26
27use env_overrides::{apply_cli_overrides, apply_env, parse_hitl_policy, parse_output_mode};
28pub use metadata::{mcp_servers, tool_need_approval};
29use metadata::{merge_json_value, read_mcp_config, read_tools_config};
30pub use state::{
31 clear_current_session, ensure_config_dirs, read_current_session, write_current_session,
32};
33use templates::default_config_template;
34pub use templates::{
35 init_config_file, write_default_subagent_presets, DEFAULT_GLOBAL_GITIGNORE_TEMPLATE,
36 DEFAULT_MCP_TEMPLATE, DEFAULT_PROJECT_GITIGNORE_TEMPLATE, DEFAULT_TOOLS_TEMPLATE,
37};
38pub use values::{get_config_value, set_config_value, ConfigScope};
39
40#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
42pub struct CliConfig {
43 pub global_dir: PathBuf,
45 pub project_dir: PathBuf,
47 pub tui_state_dir: PathBuf,
49 pub desktop_state_dir: PathBuf,
51 pub database_path: PathBuf,
53 pub file_store_path: PathBuf,
55 pub default_profile: String,
57 pub skill_dirs: Vec<PathBuf>,
59 pub subagent_dirs: Vec<PathBuf>,
61 pub disabled_subagents: Vec<String>,
63 pub workspace_root: PathBuf,
65 pub environment_provider: String,
67 pub files_policy: String,
69 pub shell_enabled: bool,
71 pub shell_review: CliShellReviewConfig,
73 pub default_output: OutputMode,
75 pub default_hitl: HitlPolicy,
77 pub update_channel: String,
79 pub oauth_refresh: OAuthRefreshConfig,
81 pub default_model: Option<CliModelProfile>,
83 pub model_profiles: BTreeMap<String, CliModelProfile>,
85 pub env_vars: BTreeMap<String, String>,
87 pub providers: ProviderConfigs,
89 pub tools_config: serde_json::Value,
91 pub mcp_config: serde_json::Value,
93 pub unmapped_metadata: serde_json::Value,
95 pub slash_commands: BTreeMap<String, SlashCommandDefinition>,
97 pub auto_trim: bool,
99 pub current_session_keep_recent_runs: usize,
101 pub all_sessions_keep_days: u64,
103}
104
105#[allow(clippy::struct_field_names)]
107#[derive(Clone, Debug)]
108pub struct ConfigResolver {
109 global_dir: Option<PathBuf>,
110 project_dir: Option<PathBuf>,
111 shared_agents_dir: Option<PathBuf>,
112 current_dir: Option<PathBuf>,
113}
114
115#[derive(Clone, Debug, Default, Deserialize)]
116struct FileConfig {
117 general: Option<GeneralConfig>,
118 storage: Option<StorageConfig>,
119 environment: Option<EnvironmentConfig>,
120 security: Option<FileSecurityConfig>,
121 update: Option<UpdateConfig>,
122 providers: Option<FileProviderConfigs>,
123 oauth_refresh: Option<FileOAuthRefreshConfig>,
124 model_profiles: Option<BTreeMap<String, FileModelProfile>>,
125 env: Option<BTreeMap<String, String>>,
126 skills: Option<SkillsConfig>,
127 subagents: Option<SubagentsConfig>,
128 commands: Option<BTreeMap<String, FileCommandDefinition>>,
129 trim: Option<TrimConfig>,
130}
131
132#[derive(Clone, Debug, Default, Deserialize)]
133struct GeneralConfig {
134 default_profile: Option<String>,
135 default_output: Option<OutputMode>,
136 default_hitl: Option<HitlPolicy>,
137 model: Option<String>,
138 model_settings: Option<String>,
139 model_cfg: Option<String>,
140 max_requests: Option<u64>,
141}
142
143#[derive(Clone, Debug, Default, Deserialize)]
144struct FileOAuthRefreshConfig {
145 enabled: Option<bool>,
146 interval_seconds: Option<u64>,
147 failure_retry_seconds: Option<u64>,
148 refresh_on_startup: Option<bool>,
149}
150
151#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153pub struct OAuthRefreshConfig {
154 pub enabled: bool,
156 pub interval_seconds: u64,
158 pub failure_retry_seconds: u64,
160 pub refresh_on_startup: bool,
162}
163
164impl Default for OAuthRefreshConfig {
165 fn default() -> Self {
166 Self {
167 enabled: true,
168 interval_seconds: 30 * 60,
169 failure_retry_seconds: 60,
170 refresh_on_startup: true,
171 }
172 }
173}
174
175#[derive(Clone, Debug, Default, Deserialize)]
176struct FileModelProfile {
177 label: Option<String>,
178 model: Option<String>,
179 model_settings: Option<String>,
180 model_cfg: Option<String>,
181}
182
183#[derive(Clone, Debug, Default, Deserialize)]
184struct FileCommandDefinition {
185 prompt: Option<String>,
186 description: Option<String>,
187 aliases: Option<Vec<String>>,
188}
189
190#[derive(Clone, Debug, Default, Deserialize)]
191struct SkillsConfig {
192 dirs: Option<Vec<String>>,
193 additional_dirs: Option<Vec<String>>,
194}
195
196#[derive(Clone, Debug, Default, Deserialize)]
197struct SubagentsConfig {
198 dirs: Option<Vec<String>>,
199 additional_dirs: Option<Vec<String>>,
200 disabled: Option<Vec<String>>,
201 disabled_builtins: Option<Vec<String>>,
202}
203
204#[derive(Clone, Debug, Default, Deserialize)]
205struct StorageConfig {
206 database_path: Option<String>,
207 file_store_path: Option<String>,
208}
209
210#[derive(Clone, Debug, Default, Deserialize)]
211struct EnvironmentConfig {
212 workspace_root: Option<String>,
213 provider: Option<String>,
214 files_policy: Option<String>,
215 shell_enabled: Option<bool>,
216}
217
218#[derive(Clone, Debug, Default, Deserialize)]
219struct FileSecurityConfig {
220 shell_review: Option<FileShellReviewConfig>,
221}
222
223#[derive(Clone, Debug, Default, Deserialize)]
224struct FileShellReviewConfig {
225 enabled: Option<bool>,
226 model: Option<String>,
227 model_settings: Option<String>,
228 on_needs_approval: Option<String>,
229 risk_threshold: Option<String>,
230 system_prompt: Option<String>,
231}
232
233#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
235pub struct CliShellReviewConfig {
236 pub enabled: bool,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub model: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub model_settings: Option<String>,
244 pub on_needs_approval: String,
246 pub risk_threshold: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub system_prompt: Option<String>,
251}
252
253impl Default for CliShellReviewConfig {
254 fn default() -> Self {
255 Self {
256 enabled: false,
257 model: None,
258 model_settings: None,
259 on_needs_approval: "defer".to_string(),
260 risk_threshold: "high".to_string(),
261 system_prompt: None,
262 }
263 }
264}
265
266impl CliShellReviewConfig {
267 fn validate(&self) -> CliResult<()> {
268 if self.enabled
269 && self
270 .model
271 .as_deref()
272 .map_or(true, |model| model.trim().is_empty())
273 {
274 return Err(CliError::Config(
275 "security.shell_review.model is required when shell review is enabled".to_string(),
276 ));
277 }
278 validate_shell_review_action(&self.on_needs_approval)?;
279 validate_shell_review_risk(&self.risk_threshold)?;
280 Ok(())
281 }
282}
283
284#[derive(Clone, Debug, Default, Deserialize)]
285struct UpdateConfig {
286 channel: Option<String>,
287}
288
289#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
291pub struct CliModelProfile {
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub label: Option<String>,
295 pub model_id: String,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub model_cfg: Option<String>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub model_settings: Option<String>,
303}
304
305#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
307pub struct ProviderConfigs {
308 pub openai: ProviderConfig,
310 pub anthropic: ProviderConfig,
312 pub gemini: ProviderConfig,
314 pub codex: ProviderConfig,
316 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
318 pub gateways: BTreeMap<String, ProviderConfig>,
319}
320
321#[derive(Clone, Debug, Default, Deserialize)]
322struct FileProviderConfigs {
323 openai: Option<FileProviderConfig>,
324 anthropic: Option<FileProviderConfig>,
325 gemini: Option<FileProviderConfig>,
326 codex: Option<FileProviderConfig>,
327 #[serde(flatten)]
328 gateways: BTreeMap<String, FileProviderConfig>,
329}
330
331#[derive(Clone, Debug, Default, Deserialize)]
332struct FileProviderConfig {
333 enabled: Option<bool>,
334 api_key_env: Option<String>,
335 base_url: Option<String>,
336 endpoint_path: Option<String>,
337 max_tokens_parameter: Option<MaxTokensParameter>,
338}
339
340#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
342pub struct ProviderConfig {
343 pub enabled: bool,
345 pub api_key_env: Option<String>,
347 pub base_url: Option<String>,
349 pub endpoint_path: Option<String>,
351 pub max_tokens_parameter: MaxTokensParameter,
353}
354
355impl Default for ProviderConfig {
356 fn default() -> Self {
357 Self {
358 enabled: true,
359 api_key_env: None,
360 base_url: None,
361 endpoint_path: None,
362 max_tokens_parameter: MaxTokensParameter::Default,
363 }
364 }
365}
366
367#[derive(Clone, Debug, Default, Deserialize)]
368struct TrimConfig {
369 auto_after_run: Option<bool>,
370 current_session_keep_recent_runs: Option<usize>,
371 all_sessions_keep_days: Option<u64>,
372}
373
374impl Default for ConfigResolver {
375 fn default() -> Self {
376 Self {
377 global_dir: env::var_os("STARWEAVER_CONFIG_DIR").map(PathBuf::from),
378 project_dir: env::var_os("STARWEAVER_PROJECT_DIR").map(PathBuf::from),
379 shared_agents_dir: None,
380 current_dir: None,
381 }
382 }
383}
384
385impl ConfigResolver {
386 #[must_use]
388 pub fn for_tests(root: &std::path::Path) -> Self {
389 Self {
390 global_dir: Some(root.join("global")),
391 project_dir: Some(root.join("project/.starweaver")),
392 shared_agents_dir: Some(root.join("shared-agents")),
393 current_dir: Some(root.join("project")),
394 }
395 }
396
397 pub fn resolve(&self, cli: &Cli) -> CliResult<CliConfig> {
399 let current_dir = self.current_dir.clone().unwrap_or_else(default_current_dir);
400 let global_dir = self.global_dir.clone().unwrap_or_else(default_global_dir);
401 let project_dir = self
402 .project_dir
403 .clone()
404 .unwrap_or_else(|| default_project_dir(cli, &global_dir, ¤t_dir));
405 let shared_agents_dir = self
406 .shared_agents_dir
407 .clone()
408 .unwrap_or_else(default_shared_agents_dir);
409 let mut config = CliConfig {
410 global_dir: global_dir.clone(),
411 project_dir: project_dir.clone(),
412 tui_state_dir: global_dir.join("tui"),
413 desktop_state_dir: global_dir.join("desktop"),
414 database_path: project_dir.join("starweaver.sqlite"),
415 file_store_path: project_dir.join("store"),
416 default_profile: "general".to_string(),
417 skill_dirs: default_skill_dirs(&global_dir, &shared_agents_dir, &project_dir),
418 subagent_dirs: vec![global_dir.join("subagents"), project_dir.join("subagents")],
419 disabled_subagents: Vec::new(),
420 workspace_root: default_workspace_root(&project_dir, &global_dir, ¤t_dir),
421 environment_provider: "local".to_string(),
422 files_policy: "read_write".to_string(),
423 shell_enabled: true,
424 shell_review: CliShellReviewConfig::default(),
425 default_output: OutputMode::AguiJsonl,
426 default_hitl: HitlPolicy::Defer,
427 update_channel: "stable".to_string(),
428 oauth_refresh: OAuthRefreshConfig::default(),
429 default_model: None,
430 model_profiles: BTreeMap::new(),
431 env_vars: BTreeMap::new(),
432 providers: default_provider_configs(),
433 tools_config: serde_json::Value::Null,
434 mcp_config: serde_json::Value::Null,
435 unmapped_metadata: serde_json::json!({}),
436 slash_commands: BTreeMap::new(),
437 auto_trim: true,
438 current_session_keep_recent_runs: 20,
439 all_sessions_keep_days: 60,
440 };
441 bootstrap_global_config_dir(&global_dir)?;
442 apply_file_config(&mut config, &global_dir.join("config.toml"))?;
443 apply_file_config(&mut config, &project_dir.join("config.toml"))?;
444 config.tools_config = read_tools_config(&global_dir, &project_dir)?;
445 config.mcp_config = read_mcp_config(&global_dir, &project_dir)?;
446 apply_env(&mut config);
447 apply_cli_overrides(&mut config, cli, &project_dir);
448 config.shell_review.validate()?;
449 Ok(config)
450 }
451}
452
453fn default_global_dir() -> PathBuf {
454 env::var_os("HOME")
455 .map_or_else(|| PathBuf::from("."), PathBuf::from)
456 .join(".starweaver")
457}
458
459fn default_shared_agents_dir() -> PathBuf {
460 env::var_os("HOME")
461 .map_or_else(|| PathBuf::from("."), PathBuf::from)
462 .join(".agents")
463}
464
465fn default_current_dir() -> PathBuf {
466 env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
467}
468
469fn default_skill_dirs(
470 global_dir: &std::path::Path,
471 shared_agents_dir: &std::path::Path,
472 project_dir: &std::path::Path,
473) -> Vec<PathBuf> {
474 vec![
475 global_dir.join("skills"),
476 shared_agents_dir.join("skills"),
477 project_dir.join("skills"),
478 ]
479}
480
481fn default_project_dir(cli: &Cli, global_dir: &Path, current_dir: &Path) -> PathBuf {
482 if wants_project_config(cli) {
483 return current_dir.join(".starweaver");
484 }
485 find_project_dir(current_dir, global_dir).unwrap_or_else(|| global_dir.to_path_buf())
486}
487
488fn default_workspace_root(project_dir: &Path, global_dir: &Path, current_dir: &Path) -> PathBuf {
489 if paths_equivalent(project_dir, global_dir) {
490 return current_dir.to_path_buf();
491 }
492 project_dir
493 .parent()
494 .filter(|parent| !parent.as_os_str().is_empty())
495 .map_or_else(|| current_dir.to_path_buf(), std::path::Path::to_path_buf)
496}
497
498const fn wants_project_config(cli: &Cli) -> bool {
499 matches!(
500 &cli.command,
501 Some(
502 CliCommand::Setup(SetupCommand {
503 global: false,
504 project: true,
505 ..
506 }) | CliCommand::Config {
507 command: ConfigCommand::Init {
508 global: false,
509 project: true,
510 ..
511 } | ConfigCommand::Set {
512 global: false,
513 project: true,
514 ..
515 }
516 }
517 )
518 )
519}
520
521fn find_project_dir(start: &Path, global_dir: &Path) -> Option<PathBuf> {
522 let home_project_dir = env::var_os("HOME")
523 .map(PathBuf::from)
524 .map(|home| home.join(".starweaver"));
525 let mut current = start.to_path_buf();
526 loop {
527 let candidate = current.join(".starweaver");
528 let is_global_dir = paths_equivalent(&candidate, global_dir);
529 let is_home_global_dir = home_project_dir
530 .as_ref()
531 .is_some_and(|home| paths_equivalent(&candidate, home));
532 if candidate.join("config.toml").exists() && !is_global_dir && !is_home_global_dir {
533 return Some(candidate);
534 }
535 if !current.pop() {
536 return None;
537 }
538 }
539}
540
541fn paths_equivalent(left: &Path, right: &Path) -> bool {
542 if left == right {
543 return true;
544 }
545 let normalized_left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
546 let normalized_right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
547 normalized_left == normalized_right
548}
549
550fn bootstrap_global_config_dir(global_dir: &Path) -> CliResult<()> {
551 fs::create_dir_all(global_dir).map_err(|error| io_error(global_dir, error))?;
552 let config_path = global_dir.join("config.toml");
553 if !config_path.exists() {
554 fs::write(&config_path, default_config_template(ConfigScope::Global))
555 .map_err(|error| io_error(&config_path, error))?;
556 }
557 for (path, content) in [
558 (global_dir.join("tools.toml"), DEFAULT_TOOLS_TEMPLATE),
559 (global_dir.join("mcp.json"), DEFAULT_MCP_TEMPLATE),
560 (
561 global_dir.join(".gitignore"),
562 DEFAULT_GLOBAL_GITIGNORE_TEMPLATE,
563 ),
564 ] {
565 if !path.exists() {
566 fs::write(&path, content).map_err(|error| io_error(&path, error))?;
567 }
568 }
569 for name in ["skills", "subagents", "tui", "desktop"] {
570 let path = global_dir.join(name);
571 fs::create_dir_all(&path).map_err(|error| io_error(path, error))?;
572 }
573 write_default_subagent_presets(global_dir, false)?;
574 Ok(())
575}
576
577fn default_provider_configs() -> ProviderConfigs {
578 ProviderConfigs {
579 openai: ProviderConfig {
580 api_key_env: Some("OPENAI_API_KEY".to_string()),
581 base_url: Some("https://api.openai.com/v1".to_string()),
582 ..ProviderConfig::default()
583 },
584 anthropic: ProviderConfig {
585 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
586 base_url: Some("https://api.anthropic.com/v1".to_string()),
587 ..ProviderConfig::default()
588 },
589 gemini: ProviderConfig {
590 api_key_env: Some("GEMINI_API_KEY".to_string()),
591 base_url: Some("https://generativelanguage.googleapis.com/v1beta".to_string()),
592 ..ProviderConfig::default()
593 },
594 codex: ProviderConfig {
595 base_url: Some(CODEX_BASE_URL.to_string()),
596 max_tokens_parameter: MaxTokensParameter::Omit,
597 ..ProviderConfig::default()
598 },
599 gateways: BTreeMap::new(),
600 }
601}
602
603#[allow(clippy::too_many_lines)]
604fn apply_file_config(config: &mut CliConfig, path: &PathBuf) -> CliResult<()> {
605 if !path.exists() {
606 return Ok(());
607 }
608 let content = fs::read_to_string(path).map_err(|error| io_error(path, error))?;
609 let raw = content
610 .parse::<Value>()
611 .map_err(|error| CliError::Config(error.to_string()))?;
612 merge_unmapped_metadata(config, &raw);
613 let parsed = toml::from_str::<FileConfig>(&content)?;
614 let base = path.parent().unwrap_or_else(|| std::path::Path::new("."));
615 if let Some(general) = parsed.general {
616 let has_default_profile = general.default_profile.is_some();
617 let model = general.model.clone();
618 let model_settings = general.model_settings.clone();
619 let model_cfg = general.model_cfg.clone();
620 let max_requests = general.max_requests;
621 if let Some(max_requests) = max_requests {
622 merge_json_value(
623 &mut config.unmapped_metadata,
624 serde_json::json!({"general": {"max_requests": max_requests}}),
625 );
626 }
627 if let Some(profile) = general.default_profile {
628 config.default_profile = profile;
629 }
630 if let Some(output) = general.default_output {
631 config.default_output = output;
632 }
633 if let Some(hitl) = general.default_hitl {
634 config.default_hitl = hitl;
635 }
636 if let Some(model_id) = model {
637 config.default_model = Some(CliModelProfile {
638 label: Some("Default".to_string()),
639 model_id,
640 model_cfg,
641 model_settings,
642 });
643 if !has_default_profile {
644 config.default_profile = "default_model".to_string();
645 }
646 }
647 }
648 if let Some(storage) = parsed.storage {
649 if let Some(database_path) = storage.database_path {
650 config.database_path = expand_path(&database_path, base);
651 }
652 if let Some(file_store_path) = storage.file_store_path {
653 config.file_store_path = expand_path(&file_store_path, base);
654 }
655 }
656 if let Some(environment) = parsed.environment {
657 if let Some(workspace_root) = environment.workspace_root {
658 config.workspace_root = expand_path(&workspace_root, base);
659 }
660 if let Some(provider) = environment.provider {
661 config.environment_provider = provider;
662 }
663 if let Some(files_policy) = environment.files_policy {
664 config.files_policy = files_policy;
665 }
666 if let Some(shell_enabled) = environment.shell_enabled {
667 config.shell_enabled = shell_enabled;
668 }
669 }
670 if let Some(security) = parsed.security {
671 if let Some(shell_review) = security.shell_review {
672 merge_shell_review_config(&mut config.shell_review, shell_review)?;
673 }
674 }
675 if let Some(update) = parsed.update {
676 if let Some(channel) = update.channel {
677 config.update_channel = channel;
678 }
679 }
680 if let Some(providers) = parsed.providers {
681 merge_provider_configs(&mut config.providers, providers);
682 }
683 if let Some(oauth_refresh) = parsed.oauth_refresh {
684 merge_oauth_refresh_config(&mut config.oauth_refresh, &oauth_refresh)?;
685 }
686 if let Some(env_vars) = parsed.env {
687 config.env_vars.extend(env_vars);
688 }
689 if let Some(model_profiles) = parsed.model_profiles {
690 for (name, profile) in model_profiles {
691 if let Some(model_id) = profile.model {
692 config.model_profiles.insert(
693 name,
694 CliModelProfile {
695 label: profile.label,
696 model_id,
697 model_cfg: profile.model_cfg,
698 model_settings: profile.model_settings,
699 },
700 );
701 }
702 }
703 }
704 if let Some(skills) = parsed.skills {
705 merge_skill_dirs(config, skills, base);
706 }
707 if let Some(subagents) = parsed.subagents {
708 merge_subagent_config(config, subagents, base);
709 }
710 if let Some(commands) = parsed.commands {
711 merge_slash_commands(config, commands);
712 }
713 if let Some(trim) = parsed.trim {
714 if let Some(auto_after_run) = trim.auto_after_run {
715 config.auto_trim = auto_after_run;
716 }
717 if let Some(keep) = trim.current_session_keep_recent_runs {
718 config.current_session_keep_recent_runs = keep;
719 }
720 if let Some(days) = trim.all_sessions_keep_days {
721 config.all_sessions_keep_days = days;
722 }
723 }
724 Ok(())
725}
726
727fn merge_skill_dirs(config: &mut CliConfig, skills: SkillsConfig, base: &std::path::Path) {
728 if let Some(dirs) = skills.dirs {
729 config.skill_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
730 }
731 if let Some(additional_dirs) = skills.additional_dirs {
732 config
733 .skill_dirs
734 .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
735 }
736}
737
738fn merge_subagent_config(
739 config: &mut CliConfig,
740 subagents: SubagentsConfig,
741 base: &std::path::Path,
742) {
743 if let Some(dirs) = subagents.dirs {
744 config.subagent_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
745 }
746 if let Some(additional_dirs) = subagents.additional_dirs {
747 config
748 .subagent_dirs
749 .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
750 }
751 if let Some(disabled) = subagents.disabled {
752 config.disabled_subagents.extend(disabled);
753 }
754 if let Some(disabled_builtins) = subagents.disabled_builtins {
755 config.disabled_subagents.extend(disabled_builtins);
756 }
757 config.disabled_subagents.sort();
758 config.disabled_subagents.dedup();
759}
760
761fn merge_slash_commands(config: &mut CliConfig, commands: BTreeMap<String, FileCommandDefinition>) {
762 for (name, command) in commands {
763 let normalized = normalize_command_name(&name);
764 if !valid_command_name(&normalized) || reserved_slash_command(&normalized) {
765 continue;
766 }
767 let Some(prompt) = command.prompt.filter(|prompt| !prompt.trim().is_empty()) else {
768 continue;
769 };
770 let aliases = command
771 .aliases
772 .unwrap_or_default()
773 .into_iter()
774 .map(|alias| normalize_command_name(&alias))
775 .filter(|alias| valid_command_name(alias) && !reserved_slash_command(alias))
776 .collect::<BTreeSet<_>>()
777 .into_iter()
778 .filter(|alias| alias != &normalized)
779 .collect::<Vec<_>>();
780 let definition = SlashCommandDefinition {
781 name: normalized,
782 prompt,
783 description: command.description,
784 aliases,
785 };
786 upsert_slash_command(config, definition);
787 }
788}
789
790fn upsert_slash_command(config: &mut CliConfig, mut definition: SlashCommandDefinition) {
791 let canonical = definition.name.clone();
792 let stale_aliases = config
793 .slash_commands
794 .iter()
795 .filter(|(lookup, existing)| existing.name == canonical && *lookup != &canonical)
796 .map(|(lookup, _)| lookup.clone())
797 .collect::<Vec<_>>();
798 for alias in stale_aliases {
799 config.slash_commands.remove(&alias);
800 }
801 for existing in config.slash_commands.values_mut() {
802 if existing.name != canonical {
803 existing.aliases.retain(|alias| alias != &canonical);
804 }
805 }
806
807 let requested_aliases = std::mem::take(&mut definition.aliases);
808 let active_aliases = requested_aliases
809 .into_iter()
810 .filter(|alias| {
811 !config
812 .slash_commands
813 .get(alias)
814 .is_some_and(|existing| existing.name != canonical)
815 })
816 .collect::<Vec<_>>();
817 definition.aliases.clone_from(&active_aliases);
818 config.slash_commands.insert(canonical, definition.clone());
819 for alias in active_aliases {
820 config.slash_commands.insert(alias, definition.clone());
821 }
822}
823
824fn reserved_slash_command(name: &str) -> bool {
825 matches!(
826 name,
827 "help"
828 | "config"
829 | "mode"
830 | "act"
831 | "plan"
832 | "loop"
833 | "tasks"
834 | "session"
835 | "dump"
836 | "load"
837 | "clear"
838 | "cost"
839 | "exit"
840 | "model"
841 | "paste-image"
842 | "goal"
843 )
844}
845
846fn merge_unmapped_metadata(config: &mut CliConfig, raw: &Value) {
847 let Some(root) = raw.as_table() else {
848 return;
849 };
850 let mut metadata = serde_json::Map::new();
851 for key in ["display", "subagents", "security"] {
852 if let Some(value) = root.get(key).cloned() {
853 if let Ok(json) = serde_json::to_value(value) {
854 metadata.insert(key.to_string(), json);
855 }
856 }
857 }
858 if !metadata.is_empty() {
859 merge_json_value(
860 &mut config.unmapped_metadata,
861 serde_json::Value::Object(metadata),
862 );
863 }
864}
865
866fn merge_provider_configs(target: &mut ProviderConfigs, overlay: FileProviderConfigs) {
867 if let Some(openai) = overlay.openai {
868 merge_provider_config(&mut target.openai, openai);
869 }
870 if let Some(anthropic) = overlay.anthropic {
871 merge_provider_config(&mut target.anthropic, anthropic);
872 }
873 if let Some(gemini) = overlay.gemini {
874 merge_provider_config(&mut target.gemini, gemini);
875 }
876 if let Some(codex) = overlay.codex {
877 merge_provider_config(&mut target.codex, codex);
878 }
879 for (name, gateway) in overlay.gateways {
880 merge_provider_config(target.gateways.entry(name).or_default(), gateway);
881 }
882}
883
884fn merge_provider_config(target: &mut ProviderConfig, overlay: FileProviderConfig) {
885 if let Some(enabled) = overlay.enabled {
886 target.enabled = enabled;
887 }
888 if overlay.api_key_env.is_some() {
889 target.api_key_env = overlay.api_key_env;
890 }
891 if overlay.base_url.is_some() {
892 target.base_url = overlay.base_url;
893 }
894 if overlay.endpoint_path.is_some() {
895 target.endpoint_path = overlay.endpoint_path;
896 }
897 if let Some(max_tokens_parameter) = overlay.max_tokens_parameter {
898 target.max_tokens_parameter = max_tokens_parameter;
899 }
900}
901
902fn merge_shell_review_config(
903 target: &mut CliShellReviewConfig,
904 overlay: FileShellReviewConfig,
905) -> CliResult<()> {
906 if let Some(enabled) = overlay.enabled {
907 target.enabled = enabled;
908 }
909 if overlay.model.is_some() {
910 target.model = overlay.model;
911 }
912 if overlay.model_settings.is_some() {
913 target.model_settings = overlay.model_settings;
914 }
915 if let Some(action) = overlay.on_needs_approval {
916 target.on_needs_approval = validate_shell_review_action(&action)?.to_string();
917 }
918 if let Some(threshold) = overlay.risk_threshold {
919 target.risk_threshold = validate_shell_review_risk(&threshold)?.to_string();
920 }
921 if overlay.system_prompt.is_some() {
922 target.system_prompt = overlay.system_prompt;
923 }
924 Ok(())
925}
926
927fn validate_shell_review_action(value: &str) -> CliResult<&'static str> {
928 match value.trim() {
929 "defer" => Ok("defer"),
930 "deny" => Ok("deny"),
931 other => Err(CliError::Usage(format!(
932 "invalid security.shell_review.on_needs_approval: {other}; expected defer or deny"
933 ))),
934 }
935}
936
937fn validate_shell_review_risk(value: &str) -> CliResult<&'static str> {
938 match value.trim() {
939 "low" => Ok("low"),
940 "medium" => Ok("medium"),
941 "high" => Ok("high"),
942 "extra_high" | "extra-high" => Ok("extra_high"),
943 other => Err(CliError::Usage(format!(
944 "invalid security.shell_review.risk_threshold: {other}; expected low, medium, high, or extra_high"
945 ))),
946 }
947}
948
949fn merge_oauth_refresh_config(
950 target: &mut OAuthRefreshConfig,
951 overlay: &FileOAuthRefreshConfig,
952) -> CliResult<()> {
953 if let Some(enabled) = overlay.enabled {
954 target.enabled = enabled;
955 }
956 if let Some(interval_seconds) = overlay.interval_seconds {
957 if interval_seconds == 0 {
958 return Err(CliError::Usage(
959 "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
960 ));
961 }
962 target.interval_seconds = interval_seconds;
963 }
964 if let Some(failure_retry_seconds) = overlay.failure_retry_seconds {
965 if failure_retry_seconds == 0 {
966 return Err(CliError::Usage(
967 "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
968 ));
969 }
970 target.failure_retry_seconds = failure_retry_seconds;
971 }
972 if let Some(refresh_on_startup) = overlay.refresh_on_startup {
973 target.refresh_on_startup = refresh_on_startup;
974 }
975 Ok(())
976}
977
978fn expand_path(value: &str, base: &std::path::Path) -> PathBuf {
979 if let Some(rest) = value.strip_prefix("~/") {
980 return env::var_os("HOME")
981 .map_or_else(|| PathBuf::from("."), PathBuf::from)
982 .join(rest);
983 }
984 let path = PathBuf::from(value);
985 if path.is_absolute() {
986 path
987 } else {
988 base.join(path)
989 }
990}
991
992#[cfg(test)]
993mod tests;