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