1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::types::{
7 CONFIG_SCHEMA_VERSION, CommandConfig, Config, CustomCommandConfig, SystemPromptOverlays,
8 nonempty_opt, overlay_key, parse_notify_on_csv,
9};
10use whycodes_core::network;
11use whycodes_core::types::{AgentInfo, ModelConfig, ProviderConfig};
12use whycodes_core::{Error, Result};
13
14pub(crate) fn toml_err(msg: String) -> Error {
15 Error::Config(msg)
16}
17
18pub(crate) fn ensure_parent_dir(path: &Path) -> Result<()> {
19 if let Some(parent) = path.parent()
20 && !parent.as_os_str().is_empty()
21 {
22 std::fs::create_dir_all(parent)?;
23 }
24 Ok(())
25}
26
27pub(crate) fn map_toml_ser(r: std::result::Result<String, toml::ser::Error>) -> Result<String> {
28 match r {
29 Ok(s) => Ok(s),
30 Err(e) => Err(toml_err(e.to_string())),
31 }
32}
33
34pub(crate) fn encode_toml(cfg: &Config) -> Result<String> {
35 map_toml_ser(toml::to_string_pretty(cfg))
36}
37
38pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
51 let parent = path
52 .parent()
53 .filter(|p| !p.as_os_str().is_empty())
54 .unwrap_or_else(|| Path::new("."));
55 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
56 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
57 let thread = std::thread::current().id();
58 let tmp = parent.join(format!(
59 ".{}.tmp-{}-{thread:?}-{n}",
60 path.file_name()
61 .and_then(|n| n.to_str())
62 .unwrap_or("config.toml"),
63 std::process::id()
64 ));
65 std::fs::write(&tmp, contents)?;
66 let persist = || std::fs::rename(&tmp, path);
67 let Err(first) = persist() else { return Ok(()) };
68 persist().map_err(|e| fold_tmp_cleanup(e, &first, std::fs::remove_file(&tmp)).into())
69}
70
71pub(crate) fn fold_tmp_cleanup(
75 err: std::io::Error,
76 first: &std::io::Error,
77 cleanup: std::io::Result<()>,
78) -> std::io::Error {
79 match cleanup {
80 Ok(()) => err,
81 Err(cleanup) => std::io::Error::other(format!(
82 "{err}; tmp cleanup: {cleanup} (first attempt: {first})"
83 )),
84 }
85}
86
87pub(crate) fn parse_global_text(content: &str) -> Result<Config> {
90 let mut cfg: Config = toml::from_str(content).map_err(|e| toml_err(e.to_string()))?;
91 for (key, provider) in &mut cfg.providers {
92 if provider.name.is_empty() {
93 provider.name = key.clone();
94 }
95 }
96 Ok(cfg)
97}
98
99pub(crate) fn parse_project_text(
102 content: &str,
103 path: &Path,
104) -> std::result::Result<Config, toml::de::Error> {
105 let mut project = toml::from_str::<Config>(content)?;
106 clear_unset_default_agent(&mut project, content);
107 if project.schema_version < CONFIG_SCHEMA_VERSION {
108 project.schema_version = CONFIG_SCHEMA_VERSION;
109 }
110 let dropped = project.websearch.strip_project_only_keys();
111 if let Some(msg) = project_websearch_warning(path, &dropped) {
112 tracing::warn!("{msg}");
113 }
114 Ok(project)
115}
116
117pub(crate) fn clear_unset_default_agent(project: &mut Config, content: &str) {
120 let table = toml::from_str::<toml::Table>(content).unwrap_or_default();
121 if !table.contains_key("default_agent") {
122 project.default_agent.clear();
123 }
124}
125
126fn warn_project_config(kind: &str, path: &Path, e: impl std::fmt::Display) {
127 let msg = format!("Failed to {kind} project config at {}: {e}", path.display());
128 tracing::warn!("{msg}");
129}
130
131pub(crate) fn project_websearch_warning(path: &Path, dropped: &[&str]) -> Option<String> {
134 if dropped.is_empty() {
135 return None;
136 }
137 Some(format!(
138 "Ignoring {} from project config at {}: the custom search endpoint, its headers and header_env are read only from the global config.",
139 dropped.join(", "),
140 path.display()
141 ))
142}
143
144pub(crate) fn migrate_legacy_instance() {
149 if whycodes_core::paths::whycodes_home().is_some() {
150 return;
151 }
152 let dest = whycodes_core::paths::instance_root();
153 let sources = whycodes_core::paths::legacy_instance_roots();
154 migrate_legacy_into(&dest, &sources);
155}
156
157pub(crate) fn migrate_legacy_into(dest: &Path, sources: &[PathBuf]) {
158 if dest_has_instance_files(dest) {
159 return;
160 }
161 let Some(src) = sources.iter().find(|root| dest_has_instance_files(root)) else {
162 return;
163 };
164 if let Err(e) = std::fs::create_dir_all(dest) {
165 tracing::warn!("legacy config migrate: mkdir {}: {e}", dest.display());
166 return;
167 }
168 for name in ["config.toml", "auth.json", "whycodes.db"] {
169 copy_if_missing(&src.join(name), &dest.join(name));
170 }
171}
172
173pub(crate) fn dest_has_instance_files(dest: &Path) -> bool {
174 dest.join("config.toml").is_file()
175 || dest.join("auth.json").is_file()
176 || dest.join("whycodes.db").is_file()
177}
178
179pub(crate) fn copy_if_missing(src: &Path, dest: &Path) {
180 if !src.is_file() || dest.exists() {
181 return;
182 }
183 match std::fs::copy(src, dest) {
184 Ok(_) => tracing::info!("migrated {} → {}", src.display(), dest.display()),
185 Err(e) => tracing::warn!("legacy config migrate: copy {} failed: {e}", dest.display()),
186 }
187}
188
189impl Config {
190 pub fn load() -> Result<Self> {
199 migrate_legacy_instance();
200 Self::read_or_seed(&Self::default_path()?)
201 }
202
203 pub fn load_or_create() -> Result<Self> {
205 migrate_legacy_instance();
206 let path = Self::default_path()?;
207 if !path.exists() {
208 let cfg = Self::seeded_openrouter();
209 match cfg.save() {
210 Ok(()) => return Ok(cfg),
211 Err(e) => {
212 tracing::warn!("first-run config.toml could not be written: {e}");
213 return Ok(cfg);
214 }
215 }
216 }
217 Self::read_or_seed(&path)
218 }
219
220 fn read_or_seed(path: &Path) -> Result<Self> {
221 let mut cfg = Self::read_raw(path)?;
222 cfg.expand_notify_secrets();
223 Ok(cfg)
224 }
225
226 fn read_raw(path: &Path) -> Result<Self> {
229 if !path.exists() {
230 return Ok(Self::seeded_openrouter());
231 }
232 let content = std::fs::read_to_string(path)?;
233 let mut cfg = parse_global_text(&content)?;
234 cfg.migrate_schema()?;
235 Ok(cfg)
236 }
237
238 pub fn load_global_raw() -> Result<Self> {
242 migrate_legacy_instance();
243 Self::read_raw(&Self::default_path()?)
244 }
245
246 pub fn update_global(edit: &mut dyn FnMut(&mut Config)) -> Result<()> {
254 let before = Self::load_global_raw()?;
255 let mut after = before.clone();
256 edit(&mut after);
257 Self::patch_global_file(&before, &after)
258 }
259
260 fn patch_global_file(before: &Config, after: &Config) -> Result<()> {
263 let path = Self::default_path()?;
264 if !path.exists() {
265 return after.save();
266 }
267 let text = std::fs::read_to_string(&path)?;
268 let content =
269 crate::patch::patched_text(&text, &before.to_toml_value()?, &after.to_toml_value()?)?;
270 write_atomic(&path, content.as_bytes())
271 }
272
273 pub fn migrate_schema(&mut self) -> Result<bool> {
276 if self.schema_version >= CONFIG_SCHEMA_VERSION {
277 return Ok(false);
278 }
279 let before = self.clone();
280 self.schema_version = CONFIG_SCHEMA_VERSION;
281 match Self::patch_global_file(&before, self) {
285 Ok(()) => Ok(true),
286 Err(e) => {
287 tracing::warn!("config schema migrate could not rewrite config.toml: {e}");
288 Ok(true)
289 }
290 }
291 }
292
293 pub fn save(&self) -> Result<()> {
295 let path = Self::default_path()?;
296 ensure_parent_dir(&path)?;
297 let content = encode_toml(self)?;
298 write_atomic(&path, content.as_bytes())?;
299 Ok(())
300 }
301
302 pub fn default_path() -> Result<PathBuf> {
304 Ok(whycodes_core::paths::config_file())
305 }
306
307 pub fn data_dir() -> Result<PathBuf> {
309 Ok(whycodes_core::paths::data_dir())
310 }
311
312 pub fn load_layered(project_dir: &Path) -> Result<Self> {
322 let mut config = Self::default();
323
324 match Self::load_or_create() {
326 Ok(global) => {
327 config = config.merge_with(&global);
328 }
329 Err(e) => {
330 tracing::error!("Failed to load global config: {e}");
333 return Err(e);
334 }
335 }
336
337 if let Some(project) = Self::load_project_layer(project_dir) {
339 config = config.merge_with(&project);
340 }
341 config.load_stream_rule_files(project_dir);
343
344 config.apply_env_overrides();
346 config.expand_notify_secrets();
347 config.apply_slop_file(project_dir);
348
349 Ok(config)
350 }
351
352 pub fn load_project_layer(project_dir: &Path) -> Option<Self> {
356 let project_config_path = Self::project_config_path(project_dir);
357 if !project_config_path.exists() {
358 return None;
359 }
360 match std::fs::read_to_string(&project_config_path) {
361 Ok(content) => match parse_project_text(&content, &project_config_path) {
362 Ok(project) => Some(project),
363 Err(e) => {
364 warn_project_config("parse", &project_config_path, e);
365 None
366 }
367 },
368 Err(e) => {
369 warn_project_config("read", &project_config_path, e);
370 None
371 }
372 }
373 }
374
375 pub fn set_openrouter_api_key(&mut self, key: &str) -> Result<()> {
377 let trimmed = key.trim();
378 if trimmed.is_empty() {
379 return Err(toml_err("OpenRouter API key is empty".into()));
380 }
381 self.put_openrouter_api_key(trimmed);
382 Self::update_global(&mut |global| global.put_openrouter_api_key(trimmed))
383 }
384
385 fn put_openrouter_api_key(&mut self, trimmed: &str) {
386 use crate::types::{DEFAULT_MODEL_ID, DEFAULT_PROVIDER};
387 let entry = self
388 .providers
389 .entry(DEFAULT_PROVIDER.to_string())
390 .or_insert_with(|| ProviderConfig {
391 name: DEFAULT_PROVIDER.to_string(),
392 api_key: None,
393 api_base: None,
394 base_url: None,
395 headers: None,
396 models: Vec::new(),
397 tool_arguments: None,
398 extra: HashMap::new(),
399 credentials: Default::default(),
400 });
401 if entry.name.is_empty() {
402 entry.name = DEFAULT_PROVIDER.to_string();
403 }
404 entry.api_key = Some(trimmed.to_string());
405 if self.default_model.is_none() {
406 self.default_model = Some(ModelConfig {
407 model_id: DEFAULT_MODEL_ID.to_string(),
408 provider_id: DEFAULT_PROVIDER.to_string(),
409 max_tokens: None,
410 context_window: None,
411 temperature: None,
412 top_p: None,
413 thinking: None,
414 supports_tools: None,
415 supports_images: None,
416 });
417 }
418 }
419
420 pub fn mark_openrouter_key_prompt_skipped(&mut self) -> Result<()> {
422 self.tui.skip_openrouter_key_prompt = true;
423 Self::update_global(&mut |global| global.tui.skip_openrouter_key_prompt = true)
424 }
425
426 pub fn seeded_openrouter() -> Self {
428 use crate::types::{DEFAULT_MODEL_ID, DEFAULT_PROVIDER};
429 let mut cfg = Self::default();
430 cfg.providers.insert(
431 DEFAULT_PROVIDER.to_string(),
432 ProviderConfig {
433 name: DEFAULT_PROVIDER.to_string(),
434 api_key: None,
435 api_base: None,
436 base_url: None,
437 headers: None,
438 models: Vec::new(),
439 tool_arguments: None,
440 extra: HashMap::new(),
441 credentials: Default::default(),
442 },
443 );
444 cfg.default_model = Some(ModelConfig {
445 model_id: DEFAULT_MODEL_ID.to_string(),
446 provider_id: DEFAULT_PROVIDER.to_string(),
447 max_tokens: None,
448 context_window: None,
449 temperature: None,
450 top_p: None,
451 thinking: None,
452 supports_tools: None,
453 supports_images: None,
454 });
455 cfg
456 }
457
458 pub fn apply_slop_file(&mut self, project_dir: &Path) {
460 let path = crate::project_slop_path(project_dir);
461 if !path.exists() {
462 return;
463 }
464 match std::fs::read_to_string(&path) {
465 Ok(text) => match toml::from_str::<crate::types::SlopConfig>(&text) {
466 Ok(overlay) => self.slop = self.slop.merge_with(&overlay),
467 Err(e) => {
468 tracing::warn!("Failed to parse slop config at {}: {e}", path.display());
469 }
470 },
471 Err(e) => {
472 tracing::warn!("Failed to read slop config at {}: {e}", path.display());
473 }
474 }
475 }
476
477 pub fn apply_env_overrides(&mut self) {
479 self.apply_provider_model_env();
480 self.apply_session_env();
481 self.apply_security_env();
482 self.apply_feature_env();
483 self.apply_swarm_env();
484 self.apply_notify_env();
485 }
486
487 fn apply_provider_model_env(&mut self) {
488 if let Ok(provider_name) = std::env::var("WHYCODES_PROVIDER") {
491 if !self.providers.contains_key(&provider_name) {
492 let api_key = std::env::var(format!("{}_API_KEY", provider_name.to_uppercase()))
494 .ok()
495 .or_else(|| std::env::var("OPENAI_API_KEY").ok())
496 .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok());
497
498 self.providers.insert(
499 provider_name.clone(),
500 ProviderConfig {
501 name: provider_name.clone(),
502 api_key,
503 api_base: None,
504 base_url: None,
505 headers: None,
506 models: Vec::new(),
507 tool_arguments: None,
508 extra: HashMap::new(),
509 credentials: Default::default(),
510 },
511 );
512 }
513
514 if self.default_model.is_none()
516 && let Ok(model_name) = std::env::var("WHYCODES_MODEL")
517 {
518 self.default_model = Some(ModelConfig {
519 model_id: model_name,
520 provider_id: provider_name,
521 max_tokens: None,
522 context_window: None,
523 temperature: None,
524 top_p: None,
525 thinking: None,
526 supports_tools: None,
527 supports_images: None,
528 });
529 }
530 } else if let Ok(model_name) = std::env::var("WHYCODES_MODEL") {
531 if let Some(ref mut default_model) = self.default_model {
534 default_model.model_id = model_name;
535 } else {
536 self.default_model = Some(ModelConfig {
537 model_id: model_name,
538 provider_id: String::new(),
539 max_tokens: None,
540 context_window: None,
541 temperature: None,
542 top_p: None,
543 thinking: None,
544 supports_tools: None,
545 supports_images: None,
546 });
547 }
548 }
549 }
550
551 fn apply_session_env(&mut self) {
552 if let Ok(val) = std::env::var("WHYCODES_MAX_TURNS")
554 && let Ok(n) = val.parse::<usize>()
555 {
556 self.session.max_context_tokens = n;
557 }
558 if let Ok(val) = std::env::var("WHYCODES_UNDO_DEPTH")
559 && let Ok(n) = val.parse::<usize>()
560 {
561 self.session.undo_depth = n;
562 }
563
564 if let Ok(val) = std::env::var("WHYCODES_LOG_LEVEL") {
566 self.general.log_level = Some(val);
567 }
568
569 if let Ok(val) = std::env::var("WHYCODES_PROJECT_DIR") {
571 self.general.project_path = Some(PathBuf::from(val));
572 }
573
574 if let Ok(val) = std::env::var("WHYCODES_COMPACTION_METHOD") {
576 let val = val.trim().to_ascii_lowercase();
577 if matches!(val.as_str(), "auto" | "local") {
578 self.session.compaction_method = val;
579 }
580 }
581 }
582
583 fn apply_security_env(&mut self) {
584 if let Ok(val) = std::env::var("WHYCODES_SANDBOX") {
585 self.security.sandbox = val;
586 }
587 if let Ok(val) = std::env::var("WHYCODES_SANDBOX_NETWORK") {
588 self.security.sandbox_network = matches!(
589 val.to_ascii_lowercase().as_str(),
590 "1" | "true" | "yes" | "on"
591 );
592 }
593 if let Ok(val) = std::env::var("WHYCODES_SANDBOX_FALLBACK") {
594 self.security.sandbox_fallback = val;
595 }
596 if let Ok(val) = std::env::var("WHYCODES_REDACT_SECRETS") {
597 self.security.redact_secrets = matches!(
598 val.to_ascii_lowercase().as_str(),
599 "1" | "true" | "yes" | "on"
600 );
601 }
602 if let Ok(val) = std::env::var("WHYCODES_NETWORK_ALLOWLIST") {
603 self.security.network_allowlist = network::parse_domain_list(&val);
604 }
605 if let Ok(val) = std::env::var("WHYCODES_NETWORK_DENYLIST") {
606 self.security.network_denylist = network::parse_domain_list(&val);
607 }
608 if let Ok(val) = std::env::var("WHYCODES_WEBSEARCH_ENGINE")
609 && !val.trim().is_empty()
610 {
611 self.websearch.engine = Some(val.trim().to_ascii_lowercase());
612 }
613 }
614
615 fn apply_feature_env(&mut self) {
616 if let Ok(val) = std::env::var("WHYCODES_NO_MEMORY")
618 && matches!(
619 val.to_ascii_lowercase().as_str(),
620 "1" | "true" | "yes" | "on"
621 )
622 {
623 self.memory.enabled = false;
624 }
625 if let Ok(val) = std::env::var("WHYCODES_NO_AUTO_UPDATE")
626 && matches!(
627 val.to_ascii_lowercase().as_str(),
628 "1" | "true" | "yes" | "on"
629 )
630 {
631 self.general.auto_update = false;
632 }
633 if let Ok(val) = std::env::var("WHYCODES_AUTO_UPDATE") {
634 match val.to_ascii_lowercase().as_str() {
635 "0" | "false" | "no" | "off" => self.general.auto_update = false,
636 "1" | "true" | "yes" | "on" => self.general.auto_update = true,
637 _ => {}
638 }
639 }
640 if let Ok(val) = std::env::var("WHYCODES_APPROVAL_MODE")
641 && let Some(mode) = whycodes_core::types::ApprovalMode::parse(&val)
642 {
643 self.general.approval_mode = Some(mode);
644 }
645 if let Ok(val) = std::env::var("WHYCODES_HEADLESS_ASK")
646 && let Some(mode) = crate::HeadlessAskMode::parse(&val)
647 {
648 self.session.headless_ask = Some(mode);
649 }
650 if let Ok(val) = std::env::var("WHYCODES_PREWALK") {
651 match val.to_ascii_lowercase().as_str() {
652 "0" | "false" | "no" | "off" => self.session.prewalk = Some(false),
653 "1" | "true" | "yes" | "on" => self.session.prewalk = Some(true),
654 _ => {}
655 }
656 }
657 if let Ok(val) = std::env::var("WHYCODES_MEMORY") {
658 match val.to_ascii_lowercase().as_str() {
659 "0" | "false" | "no" | "off" => self.memory.enabled = false,
660 "1" | "true" | "yes" | "on" => self.memory.enabled = true,
661 _ => {}
662 }
663 }
664 }
665
666 fn apply_swarm_env(&mut self) {
667 if let Ok(val) = std::env::var("WHYCODES_SWARM") {
669 match val.to_ascii_lowercase().as_str() {
670 "0" | "false" | "no" | "off" => self.swarm.enabled = false,
671 "1" | "true" | "yes" | "on" => self.swarm.enabled = true,
672 _ => {}
673 }
674 }
675 if let Ok(val) = std::env::var("WHYCODES_SWARM_MAX_AGENTS")
676 && let Ok(n) = val.parse::<usize>()
677 {
678 self.swarm.max_agents = n.clamp(1, 8);
679 }
680 if let Ok(val) = std::env::var("WHYCODES_SWARM_WORKTREES") {
681 match val.to_ascii_lowercase().as_str() {
682 "0" | "false" | "no" | "off" => self.swarm.worktrees = false,
683 "1" | "true" | "yes" | "on" => self.swarm.worktrees = true,
684 _ => {}
685 }
686 }
687 }
688
689 fn apply_notify_env(&mut self) {
690 if let Ok(val) = std::env::var("WHYCODES_NOTIFY_ON") {
691 self.notify.on = parse_notify_on_csv(&val);
692 }
693 if let Ok(val) = std::env::var("WHYCODES_NOTIFY_DISCORD_WEBHOOK") {
694 self.notify.discord_webhook = Some(val);
695 }
696 if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TELEGRAM_BOT_TOKEN") {
697 self.notify.telegram_bot_token = Some(val);
698 }
699 if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TELEGRAM_CHAT_ID") {
700 self.notify.telegram_chat_id = Some(val);
701 }
702 if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TIMEOUT_SECS")
703 && let Ok(n) = val.parse::<u64>()
704 {
705 self.notify.timeout_secs = n.clamp(1, 60);
706 }
707 if let Ok(val) = std::env::var("WHYCODES_NOTIFY_DESKTOP") {
708 match val.trim().to_ascii_lowercase().as_str() {
709 "1" | "true" | "yes" | "on" => self.notify.desktop = Some(true),
710 "0" | "false" | "no" | "off" => self.notify.desktop = Some(false),
711 _ => {}
712 }
713 }
714 }
715
716 pub fn load_command_files(&mut self, project_dir: &Path) {
720 if let Ok(global_dir) = Self::default_path()
721 && let Some(parent) = global_dir.parent()
722 {
723 load_commands_from_dir(&mut self.commands, &parent.join("commands"));
724 }
725 load_commands_from_dir(
726 &mut self.commands,
727 &whycodes_core::project_dir(project_dir).join("commands"),
728 );
729 load_commands_from_dir(
731 &mut self.commands,
732 &project_dir.join(".opencode").join("commands"),
733 );
734 self.ensure_builtin_prompt_commands();
736 self.load_prompt_files(project_dir);
737 }
738
739 pub fn load_prompt_files(&mut self, project_dir: &Path) {
743 if let Ok(global_dir) = Self::default_path()
744 && let Some(parent) = global_dir.parent()
745 {
746 load_prompt_overlays(&mut self.system_prompt_overlays, &parent.join("prompts"));
747 }
748 load_prompt_overlays(
749 &mut self.system_prompt_overlays,
750 &whycodes_core::project_dir(project_dir).join("prompts"),
751 );
752 }
753
754 pub fn ensure_builtin_prompt_commands(&mut self) {
757 for (name, cmd) in builtin_prompt_commands() {
758 self.commands.entry(name.to_string()).or_insert(cmd);
759 }
760 }
761}
762
763fn builtin_prompt_commands() -> Vec<(&'static str, CustomCommandConfig)> {
765 vec![
766 (
767 "review",
768 CustomCommandConfig {
769 description: Some("AI code review of git changes".into()),
770 agent: Some("plan".into()),
771 model: None,
772 subtask: None,
773 template: r#"Review the current working tree changes for this project.
774
7751. Run git_status and git_diff (unstaged and staged) to see what changed.
7762. Read only the files that matter for the diff.
7773. Produce a structured review:
778 - Summary (2–4 sentences)
779 - Strengths
780 - Issues (severity: high / medium / low) with file:line when possible
781 - Suggested follow-ups
7824. Do NOT write or edit files unless the user explicitly asked to apply fixes.
7835. Prefer read-only tools (git_*, read, grep, glob, list).
784
785Extra context from the user: $ARGUMENTS"#
786 .into(),
787 },
788 ),
789 (
790 "security-review",
791 CustomCommandConfig {
792 description: Some("Security-focused review of changes".into()),
793 agent: Some("plan".into()),
794 model: None,
795 subtask: None,
796 template: r#"Perform a security-focused review of the current git changes.
797
7981. Inspect git_status / git_diff and relevant files.
7992. Look for: secrets/credentials, injection (SQL/command/path), authz gaps,
800 unsafe shell, SSRF, insecure deserialization, dependency risks, XSS.
8013. Output:
802 - Executive summary
803 - Findings table: severity | location | issue | remediation
804 - What looks safe
8054. Do not modify files unless asked. Prefer read-only tools.
806
807Focus / notes: $ARGUMENTS"#
808 .into(),
809 },
810 ),
811 (
812 "commit",
813 CustomCommandConfig {
814 description: Some("Draft a git commit message from the diff".into()),
815 agent: None,
816 model: None,
817 subtask: None,
818 template: r#"Prepare a git commit for the current changes.
819
8201. Run git_status and git_diff (include staged if any).
8212. Draft a concise commit message (subject ≤72 chars; body if needed).
8223. If the user asked to commit (or says "commit now" / "yes"), stage relevant
823 files and use git_commit. Otherwise show the proposed message and ask.
8244. Never force-push, never amend published history, never commit secrets (.env, keys).
825
826User notes: $ARGUMENTS"#
827 .into(),
828 },
829 ),
830 ]
831}
832
833fn load_prompt_overlays(into: &mut SystemPromptOverlays, dir: &Path) {
834 load_prompt_overlays_at(into, dir, None);
835}
836
837fn load_prompt_overlays_at(into: &mut SystemPromptOverlays, dir: &Path, provider: Option<&str>) {
838 let entries = match std::fs::read_dir(dir) {
839 Ok(e) => e,
840 Err(_missing_dir) => return,
841 };
842 for entry in entries.flatten() {
843 let path = entry.path();
844 if path.is_dir() {
845 if provider.is_some() {
846 continue;
847 }
848 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
849 continue;
850 };
851 if name.eq_ignore_ascii_case("models") {
852 load_model_overlay_dir(into, &path);
853 } else {
854 load_prompt_overlays_at(into, &path, Some(name));
855 }
856 continue;
857 }
858 if path.extension().and_then(|e| e.to_str()) != Some("md") {
859 continue;
860 }
861 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
862 continue;
863 };
864 let Some(body) = read_overlay_markdown(&path) else {
865 continue;
866 };
867 if let Some(p) = provider {
868 into.models
869 .insert(format!("{}/{}", overlay_key(p), overlay_key(stem)), body);
870 } else {
871 into.providers.insert(overlay_key(stem), body);
872 }
873 }
874}
875
876fn load_model_overlay_dir(into: &mut SystemPromptOverlays, dir: &Path) {
877 let entries = match std::fs::read_dir(dir) {
878 Ok(e) => e,
879 Err(_missing_dir) => return,
880 };
881 for entry in entries.flatten() {
882 let path = entry.path();
883 if path.is_dir() {
884 continue;
885 }
886 if path.extension().and_then(|e| e.to_str()) != Some("md") {
887 continue;
888 }
889 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
890 continue;
891 };
892 let Some(body) = read_overlay_markdown(&path) else {
893 continue;
894 };
895 into.models.insert(overlay_key(stem), body);
896 }
897}
898
899fn read_overlay_markdown(path: &Path) -> Option<String> {
900 let content = std::fs::read_to_string(path).ok()?;
901 let body = strip_overlay_frontmatter(&content);
902 if body.is_empty() { None } else { Some(body) }
903}
904
905fn strip_overlay_frontmatter(content: &str) -> String {
906 let trimmed = content.trim();
907 if let Some(rest) = trimmed.strip_prefix("---")
908 && let Some((_, body)) = rest.split_once("---")
909 {
910 return body.trim().to_string();
911 }
912 trimmed.to_string()
913}
914
915fn load_commands_from_dir(into: &mut HashMap<String, CustomCommandConfig>, dir: &Path) {
916 let entries = match std::fs::read_dir(dir) {
917 Ok(e) => e,
918 Err(_) => return,
919 };
920 for entry in entries.flatten() {
921 let path = entry.path();
922 if path.extension().and_then(|e| e.to_str()) != Some("md") {
923 continue;
924 }
925 let name = path
926 .file_stem()
927 .and_then(|s| s.to_str())
928 .unwrap_or("cmd")
929 .to_string();
930 if let Ok(content) = std::fs::read_to_string(&path)
931 && let Some(cmd) = parse_command_markdown(&content)
932 {
933 into.insert(name, cmd);
934 }
935 }
936}
937
938pub(crate) fn parse_command_markdown(content: &str) -> Option<CustomCommandConfig> {
940 let content = content.trim();
941 if !content.starts_with("---") {
942 return Some(CustomCommandConfig {
943 template: content.to_string(),
944 description: None,
945 agent: None,
946 model: None,
947 subtask: None,
948 });
949 }
950 let (front, body) = content.strip_prefix("---")?.split_once("---")?;
951 let front = front.trim();
952 let body = body.trim().to_string();
953
954 let mut description = None;
955 let mut agent = None;
956 let mut model = None;
957 let mut subtask = None;
958 for line in front.lines() {
959 let line = line.trim();
960 if let Some((k, v)) = line.split_once(':') {
961 let k = k.trim();
962 let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
963 match k {
964 "description" => description = Some(v),
965 "agent" => agent = Some(v),
966 "model" => model = Some(v),
967 "subtask" => {
968 subtask = Some(matches!(
969 v.to_ascii_lowercase().as_str(),
970 "true" | "yes" | "1"
971 ))
972 }
973 _ => {}
974 }
975 }
976 }
977
978 Some(CustomCommandConfig {
979 template: body,
980 description,
981 agent,
982 model,
983 subtask,
984 })
985}
986
987impl Config {
988 pub fn get_command_config(&self, command: &str) -> Option<&CommandConfig> {
990 self.command_configs.get(command)
991 }
992
993 pub fn expand_notify_secrets(&mut self) {
998 if let Some(url) = self.notify.discord_webhook.take() {
999 let expanded = Self::substitute_vars(&url);
1000 self.notify.discord_webhook = nonempty_opt(expanded);
1001 }
1002 if let Some(token) = self.notify.telegram_bot_token.take() {
1003 let expanded = Self::substitute_vars(&token);
1004 self.notify.telegram_bot_token = nonempty_opt(expanded);
1005 }
1006 if let Some(chat) = self.notify.telegram_chat_id.take() {
1007 let expanded = Self::substitute_vars(&chat);
1008 self.notify.telegram_chat_id = nonempty_opt(expanded);
1009 }
1010 }
1011
1012 pub fn substitute_vars(value: &str) -> String {
1017 let mut result = String::with_capacity(value.len());
1018 let mut chars = value.chars().peekable();
1019
1020 while let Some(ch) = chars.next() {
1021 if ch == '$' {
1022 match chars.peek() {
1023 Some('{') => {
1024 chars.next(); let mut var_name = String::new();
1027 while let Some(&c) = chars.peek() {
1028 if c == '}' {
1029 chars.next(); break;
1031 }
1032 var_name.push(c);
1033 chars.next();
1034 }
1035 if let Ok(val) = std::env::var(&var_name) {
1037 result.push_str(&val);
1038 } else {
1039 result.push_str(&format!("${{{}}}", var_name));
1040 }
1041 }
1042 Some(c) if c.is_alphanumeric() || *c == '_' => {
1043 let mut var_name = String::new();
1045 while let Some(&c) = chars.peek() {
1046 if c.is_alphanumeric() || c == '_' {
1047 var_name.push(c);
1048 chars.next();
1049 } else {
1050 break;
1051 }
1052 }
1053 if let Ok(val) = std::env::var(&var_name) {
1054 result.push_str(&val);
1055 } else {
1056 result.push_str(&format!("${}", var_name));
1057 }
1058 }
1059 _ => {
1060 result.push('$');
1062 }
1063 }
1064 } else {
1065 result.push(ch);
1066 }
1067 }
1068
1069 result
1070 }
1071
1072 pub fn project_path(&self) -> PathBuf {
1074 self.general
1075 .project_path
1076 .clone()
1077 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
1078 }
1079
1080 pub fn get_provider(&self, name: &str) -> Option<&ProviderConfig> {
1082 self.providers.get(name)
1083 }
1084
1085 pub fn get_model(&self, provider: &str, model: &str) -> Option<&ModelConfig> {
1087 self.models
1088 .values()
1089 .find(|m| m.provider_id == provider && m.model_id == model)
1090 }
1091
1092 pub fn configured_context_window(&self, provider: &str, model: &str) -> Option<u32> {
1097 self.get_model(provider, model)
1098 .and_then(|m| m.context_window)
1099 .or_else(|| {
1100 self.default_model.as_ref().and_then(|m| {
1101 if m.model_id == model
1102 && (m.provider_id.is_empty() || m.provider_id == provider)
1103 {
1104 m.context_window
1105 } else {
1106 None
1107 }
1108 })
1109 })
1110 }
1111
1112 pub fn get_agent(&self, name: &str) -> Option<&AgentInfo> {
1114 self.agents.iter().find(|a| a.name == name)
1115 }
1116
1117 pub fn default_agent(&self) -> Option<&AgentInfo> {
1119 self.get_agent(&self.default_agent)
1120 }
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use crate::types::Config;
1127 use std::path::Path;
1128
1129 #[test]
1130 fn helpers_markdown_and_parent_dir() {
1131 assert!(toml_err("x".into()).to_string().contains("x"));
1132 let cfg = Config::default();
1133 let encoded = encode_toml(&cfg).unwrap();
1134 assert!(!encoded.is_empty());
1135 assert!(map_toml_ser(Ok("ok".into())).unwrap() == "ok");
1136
1137 let dir = tempfile::tempdir().unwrap();
1138 let nested = dir.path().join("a").join("b.toml");
1139 ensure_parent_dir(&nested).unwrap();
1140 assert!(nested.parent().unwrap().is_dir());
1141 ensure_parent_dir(Path::new("no-parent")).unwrap();
1142
1143 let plain = parse_command_markdown("echo hi").unwrap();
1144 assert_eq!(plain.template, "echo hi");
1145 assert!(plain.description.is_none());
1146
1147 let with_front = parse_command_markdown(
1148 "---\ndescription: d\nagent: build\nmodel: gpt\nsubtask: true\nunknown: x\n---\nbody",
1149 )
1150 .unwrap();
1151 assert_eq!(with_front.template, "body");
1152 assert_eq!(with_front.description.as_deref(), Some("d"));
1153 assert_eq!(with_front.agent.as_deref(), Some("build"));
1154 assert_eq!(with_front.model.as_deref(), Some("gpt"));
1155 assert_eq!(with_front.subtask, Some(true));
1156
1157 let yes = parse_command_markdown("---\nsubtask: yes\n---\nx").unwrap();
1158 assert_eq!(yes.subtask, Some(true));
1159 let one = parse_command_markdown("---\nsubtask: 1\n---\nx").unwrap();
1160 assert_eq!(one.subtask, Some(true));
1161 let no = parse_command_markdown("---\nsubtask: false\n---\nx").unwrap();
1162 assert_eq!(no.subtask, Some(false));
1163 assert!(parse_command_markdown("---\nno-end").is_none());
1164
1165 assert!(cfg.get_command_config("missing").is_none());
1166 }
1167
1168 #[test]
1169 fn prompt_overlay_loader_covers_missing_and_junk() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let missing = dir.path().join("no-such-prompts");
1172 let mut empty = SystemPromptOverlays::default();
1173 load_prompt_overlays(&mut empty, &missing);
1174 load_model_overlay_dir(&mut empty, &missing);
1175 assert!(empty.is_empty());
1176 assert!(read_overlay_markdown(&missing).is_none());
1177 assert!(read_overlay_markdown(dir.path()).is_none());
1178
1179 let models = dir.path().join("models");
1180 std::fs::create_dir_all(models.join("nested")).unwrap();
1181 std::fs::write(models.join("skip.txt"), "ignored").unwrap();
1182 std::fs::write(models.join("empty.md"), " \n").unwrap();
1183 std::fs::write(models.join("ok.md"), "bare model extra").unwrap();
1184 let mut loaded = SystemPromptOverlays::default();
1185 load_model_overlay_dir(&mut loaded, &models);
1186 assert_eq!(
1187 loaded.models.get("ok").map(String::as_str),
1188 Some("bare model extra")
1189 );
1190 assert!(!loaded.models.contains_key("skip"));
1191 assert!(!loaded.models.contains_key("empty"));
1192 assert!(!loaded.models.contains_key("nested"));
1193
1194 std::fs::write(dir.path().join("notes.txt"), "not markdown").unwrap();
1195 let mut mixed = SystemPromptOverlays::default();
1196 load_prompt_overlays(&mut mixed, dir.path());
1197 assert_eq!(
1198 mixed.models.get("ok").map(String::as_str),
1199 Some("bare model extra")
1200 );
1201
1202 let provider_dir = dir.path().join("xai");
1203 std::fs::create_dir_all(provider_dir.join("nested")).unwrap();
1204 std::fs::write(provider_dir.join("nested/skip.md"), "nope").unwrap();
1205 std::fs::write(provider_dir.join("notes.txt"), "ignored").unwrap();
1206 let mut under_provider = SystemPromptOverlays::default();
1207 load_prompt_overlays_at(&mut under_provider, &provider_dir, Some("xai"));
1208 assert!(under_provider.models.is_empty());
1209 assert!(under_provider.providers.is_empty());
1210 }
1211
1212 #[cfg(unix)]
1214 #[test]
1215 fn prompt_overlay_skips_non_utf8_names() {
1216 use std::os::unix::ffi::OsStrExt;
1217 let dir = tempfile::tempdir().unwrap();
1218 let bad_dir = dir.path().join(std::ffi::OsStr::from_bytes(b"bad\xffdir"));
1219 std::fs::create_dir(&bad_dir).unwrap();
1220 std::fs::write(
1221 dir.path().join(std::ffi::OsStr::from_bytes(b"bad\xff.md")),
1222 "x",
1223 )
1224 .unwrap();
1225 let models = dir.path().join("models");
1226 std::fs::create_dir(&models).unwrap();
1227 std::fs::write(models.join(std::ffi::OsStr::from_bytes(b"m\xff.md")), "y").unwrap();
1228
1229 let mut loaded = SystemPromptOverlays::default();
1230 load_prompt_overlays(&mut loaded, dir.path());
1231 assert!(loaded.providers.is_empty());
1232 assert!(loaded.models.is_empty());
1233 }
1234}