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