use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::types::{
CONFIG_SCHEMA_VERSION, CommandConfig, Config, CustomCommandConfig, SystemPromptOverlays,
nonempty_opt, overlay_key, parse_notify_on_csv,
};
use whycodes_core::network;
use whycodes_core::types::{AgentInfo, ModelConfig, ProviderConfig};
use whycodes_core::{Error, Result};
pub(crate) fn toml_err(msg: String) -> Error {
Error::Config(msg)
}
pub(crate) fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
Ok(())
}
pub(crate) fn map_toml_ser(r: std::result::Result<String, toml::ser::Error>) -> Result<String> {
match r {
Ok(s) => Ok(s),
Err(e) => Err(toml_err(e.to_string())),
}
}
pub(crate) fn encode_toml(cfg: &Config) -> Result<String> {
map_toml_ser(toml::to_string_pretty(cfg))
}
pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let thread = std::thread::current().id();
let tmp = parent.join(format!(
".{}.tmp-{}-{thread:?}-{n}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config.toml"),
std::process::id()
));
std::fs::write(&tmp, contents)?;
let persist = || std::fs::rename(&tmp, path);
let Err(first) = persist() else { return Ok(()) };
persist().map_err(|e| fold_tmp_cleanup(e, &first, std::fs::remove_file(&tmp)).into())
}
pub(crate) fn fold_tmp_cleanup(
err: std::io::Error,
first: &std::io::Error,
cleanup: std::io::Result<()>,
) -> std::io::Error {
match cleanup {
Ok(()) => err,
Err(cleanup) => std::io::Error::other(format!(
"{err}; tmp cleanup: {cleanup} (first attempt: {first})"
)),
}
}
pub(crate) fn parse_global_text(content: &str) -> Result<Config> {
let mut cfg: Config = toml::from_str(content).map_err(|e| toml_err(e.to_string()))?;
for (key, provider) in &mut cfg.providers {
if provider.name.is_empty() {
provider.name = key.clone();
}
}
Ok(cfg)
}
pub(crate) fn parse_project_text(
content: &str,
path: &Path,
) -> std::result::Result<Config, toml::de::Error> {
let mut project = toml::from_str::<Config>(content)?;
clear_unset_default_agent(&mut project, content);
if project.schema_version < CONFIG_SCHEMA_VERSION {
project.schema_version = CONFIG_SCHEMA_VERSION;
}
let dropped = project.websearch.strip_project_only_keys();
if let Some(msg) = project_websearch_warning(path, &dropped) {
tracing::warn!("{msg}");
}
Ok(project)
}
pub(crate) fn clear_unset_default_agent(project: &mut Config, content: &str) {
let table = toml::from_str::<toml::Table>(content).unwrap_or_default();
if !table.contains_key("default_agent") {
project.default_agent.clear();
}
}
fn warn_project_config(kind: &str, path: &Path, e: impl std::fmt::Display) {
let msg = format!("Failed to {kind} project config at {}: {e}", path.display());
tracing::warn!("{msg}");
}
pub(crate) fn project_websearch_warning(path: &Path, dropped: &[&str]) -> Option<String> {
if dropped.is_empty() {
return None;
}
Some(format!(
"Ignoring {} from project config at {}: the custom search endpoint, its headers and header_env are read only from the global config.",
dropped.join(", "),
path.display()
))
}
pub(crate) fn migrate_legacy_instance() {
if whycodes_core::paths::whycodes_home().is_some() {
return;
}
let dest = whycodes_core::paths::instance_root();
let sources = whycodes_core::paths::legacy_instance_roots();
migrate_legacy_into(&dest, &sources);
}
pub(crate) fn migrate_legacy_into(dest: &Path, sources: &[PathBuf]) {
if dest_has_instance_files(dest) {
return;
}
let Some(src) = sources.iter().find(|root| dest_has_instance_files(root)) else {
return;
};
if let Err(e) = std::fs::create_dir_all(dest) {
tracing::warn!("legacy config migrate: mkdir {}: {e}", dest.display());
return;
}
for name in ["config.toml", "auth.json", "whycodes.db"] {
copy_if_missing(&src.join(name), &dest.join(name));
}
}
pub(crate) fn dest_has_instance_files(dest: &Path) -> bool {
dest.join("config.toml").is_file()
|| dest.join("auth.json").is_file()
|| dest.join("whycodes.db").is_file()
}
pub(crate) fn copy_if_missing(src: &Path, dest: &Path) {
if !src.is_file() || dest.exists() {
return;
}
match std::fs::copy(src, dest) {
Ok(_) => tracing::info!("migrated {} → {}", src.display(), dest.display()),
Err(e) => tracing::warn!("legacy config migrate: copy {} failed: {e}", dest.display()),
}
}
impl Config {
pub fn load() -> Result<Self> {
migrate_legacy_instance();
Self::read_or_seed(&Self::default_path()?)
}
pub fn load_or_create() -> Result<Self> {
migrate_legacy_instance();
let path = Self::default_path()?;
if !path.exists() {
let cfg = Self::seeded_openrouter();
match cfg.save() {
Ok(()) => return Ok(cfg),
Err(e) => {
tracing::warn!("first-run config.toml could not be written: {e}");
return Ok(cfg);
}
}
}
Self::read_or_seed(&path)
}
fn read_or_seed(path: &Path) -> Result<Self> {
let mut cfg = Self::read_raw(path)?;
cfg.expand_notify_secrets();
Ok(cfg)
}
fn read_raw(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::seeded_openrouter());
}
let content = std::fs::read_to_string(path)?;
let mut cfg = parse_global_text(&content)?;
cfg.migrate_schema()?;
Ok(cfg)
}
pub fn load_global_raw() -> Result<Self> {
migrate_legacy_instance();
Self::read_raw(&Self::default_path()?)
}
pub fn update_global(edit: &mut dyn FnMut(&mut Config)) -> Result<()> {
let before = Self::load_global_raw()?;
let mut after = before.clone();
edit(&mut after);
Self::patch_global_file(&before, &after)
}
fn patch_global_file(before: &Config, after: &Config) -> Result<()> {
let path = Self::default_path()?;
if !path.exists() {
return after.save();
}
let text = std::fs::read_to_string(&path)?;
let content =
crate::patch::patched_text(&text, &before.to_toml_value()?, &after.to_toml_value()?)?;
write_atomic(&path, content.as_bytes())
}
pub fn migrate_schema(&mut self) -> Result<bool> {
if self.schema_version >= CONFIG_SCHEMA_VERSION {
return Ok(false);
}
let before = self.clone();
self.schema_version = CONFIG_SCHEMA_VERSION;
match Self::patch_global_file(&before, self) {
Ok(()) => Ok(true),
Err(e) => {
tracing::warn!("config schema migrate could not rewrite config.toml: {e}");
Ok(true)
}
}
}
pub fn save(&self) -> Result<()> {
let path = Self::default_path()?;
ensure_parent_dir(&path)?;
let content = encode_toml(self)?;
write_atomic(&path, content.as_bytes())?;
Ok(())
}
pub fn default_path() -> Result<PathBuf> {
Ok(whycodes_core::paths::config_file())
}
pub fn data_dir() -> Result<PathBuf> {
Ok(whycodes_core::paths::data_dir())
}
pub fn load_layered(project_dir: &Path) -> Result<Self> {
let mut config = Self::default();
match Self::load_or_create() {
Ok(global) => {
config = config.merge_with(&global);
}
Err(e) => {
tracing::error!("Failed to load global config: {e}");
return Err(e);
}
}
if let Some(project) = Self::load_project_layer(project_dir) {
config = config.merge_with(&project);
}
config.load_stream_rule_files(project_dir);
config.apply_env_overrides();
config.expand_notify_secrets();
config.apply_slop_file(project_dir);
Ok(config)
}
pub fn load_project_layer(project_dir: &Path) -> Option<Self> {
let project_config_path = Self::project_config_path(project_dir);
if !project_config_path.exists() {
return None;
}
match std::fs::read_to_string(&project_config_path) {
Ok(content) => match parse_project_text(&content, &project_config_path) {
Ok(project) => Some(project),
Err(e) => {
warn_project_config("parse", &project_config_path, e);
None
}
},
Err(e) => {
warn_project_config("read", &project_config_path, e);
None
}
}
}
pub fn set_openrouter_api_key(&mut self, key: &str) -> Result<()> {
let trimmed = key.trim();
if trimmed.is_empty() {
return Err(toml_err("OpenRouter API key is empty".into()));
}
self.put_openrouter_api_key(trimmed);
Self::update_global(&mut |global| global.put_openrouter_api_key(trimmed))
}
fn put_openrouter_api_key(&mut self, trimmed: &str) {
use crate::types::{DEFAULT_MODEL_ID, DEFAULT_PROVIDER};
let entry = self
.providers
.entry(DEFAULT_PROVIDER.to_string())
.or_insert_with(|| ProviderConfig {
name: DEFAULT_PROVIDER.to_string(),
api_key: None,
api_base: None,
base_url: None,
headers: None,
models: Vec::new(),
tool_arguments: None,
extra: HashMap::new(),
credentials: Default::default(),
});
if entry.name.is_empty() {
entry.name = DEFAULT_PROVIDER.to_string();
}
entry.api_key = Some(trimmed.to_string());
if self.default_model.is_none() {
self.default_model = Some(ModelConfig {
model_id: DEFAULT_MODEL_ID.to_string(),
provider_id: DEFAULT_PROVIDER.to_string(),
max_tokens: None,
context_window: None,
temperature: None,
top_p: None,
thinking: None,
supports_tools: None,
supports_images: None,
});
}
}
pub fn mark_openrouter_key_prompt_skipped(&mut self) -> Result<()> {
self.tui.skip_openrouter_key_prompt = true;
Self::update_global(&mut |global| global.tui.skip_openrouter_key_prompt = true)
}
pub fn seeded_openrouter() -> Self {
use crate::types::{DEFAULT_MODEL_ID, DEFAULT_PROVIDER};
let mut cfg = Self::default();
cfg.providers.insert(
DEFAULT_PROVIDER.to_string(),
ProviderConfig {
name: DEFAULT_PROVIDER.to_string(),
api_key: None,
api_base: None,
base_url: None,
headers: None,
models: Vec::new(),
tool_arguments: None,
extra: HashMap::new(),
credentials: Default::default(),
},
);
cfg.default_model = Some(ModelConfig {
model_id: DEFAULT_MODEL_ID.to_string(),
provider_id: DEFAULT_PROVIDER.to_string(),
max_tokens: None,
context_window: None,
temperature: None,
top_p: None,
thinking: None,
supports_tools: None,
supports_images: None,
});
cfg
}
pub fn apply_slop_file(&mut self, project_dir: &Path) {
let path = crate::project_slop_path(project_dir);
if !path.exists() {
return;
}
match std::fs::read_to_string(&path) {
Ok(text) => match toml::from_str::<crate::types::SlopConfig>(&text) {
Ok(overlay) => self.slop = self.slop.merge_with(&overlay),
Err(e) => {
tracing::warn!("Failed to parse slop config at {}: {e}", path.display());
}
},
Err(e) => {
tracing::warn!("Failed to read slop config at {}: {e}", path.display());
}
}
}
pub fn apply_env_overrides(&mut self) {
self.apply_provider_model_env();
self.apply_session_env();
self.apply_security_env();
self.apply_feature_env();
self.apply_swarm_env();
self.apply_notify_env();
}
fn apply_provider_model_env(&mut self) {
if let Ok(provider_name) = std::env::var("WHYCODES_PROVIDER") {
if !self.providers.contains_key(&provider_name) {
let api_key = std::env::var(format!("{}_API_KEY", provider_name.to_uppercase()))
.ok()
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok());
self.providers.insert(
provider_name.clone(),
ProviderConfig {
name: provider_name.clone(),
api_key,
api_base: None,
base_url: None,
headers: None,
models: Vec::new(),
tool_arguments: None,
extra: HashMap::new(),
credentials: Default::default(),
},
);
}
if self.default_model.is_none()
&& let Ok(model_name) = std::env::var("WHYCODES_MODEL")
{
self.default_model = Some(ModelConfig {
model_id: model_name,
provider_id: provider_name,
max_tokens: None,
context_window: None,
temperature: None,
top_p: None,
thinking: None,
supports_tools: None,
supports_images: None,
});
}
} else if let Ok(model_name) = std::env::var("WHYCODES_MODEL") {
if let Some(ref mut default_model) = self.default_model {
default_model.model_id = model_name;
} else {
self.default_model = Some(ModelConfig {
model_id: model_name,
provider_id: String::new(),
max_tokens: None,
context_window: None,
temperature: None,
top_p: None,
thinking: None,
supports_tools: None,
supports_images: None,
});
}
}
}
fn apply_session_env(&mut self) {
if let Ok(val) = std::env::var("WHYCODES_MAX_TURNS")
&& let Ok(n) = val.parse::<usize>()
{
self.session.max_context_tokens = n;
}
if let Ok(val) = std::env::var("WHYCODES_UNDO_DEPTH")
&& let Ok(n) = val.parse::<usize>()
{
self.session.undo_depth = n;
}
if let Ok(val) = std::env::var("WHYCODES_LOG_LEVEL") {
self.general.log_level = Some(val);
}
if let Ok(val) = std::env::var("WHYCODES_PROJECT_DIR") {
self.general.project_path = Some(PathBuf::from(val));
}
if let Ok(val) = std::env::var("WHYCODES_COMPACTION_METHOD") {
let val = val.trim().to_ascii_lowercase();
if matches!(val.as_str(), "auto" | "local") {
self.session.compaction_method = val;
}
}
}
fn apply_security_env(&mut self) {
if let Ok(val) = std::env::var("WHYCODES_SANDBOX") {
self.security.sandbox = val;
}
if let Ok(val) = std::env::var("WHYCODES_SANDBOX_NETWORK") {
self.security.sandbox_network = matches!(
val.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
);
}
if let Ok(val) = std::env::var("WHYCODES_SANDBOX_FALLBACK") {
self.security.sandbox_fallback = val;
}
if let Ok(val) = std::env::var("WHYCODES_REDACT_SECRETS") {
self.security.redact_secrets = matches!(
val.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
);
}
if let Ok(val) = std::env::var("WHYCODES_NETWORK_ALLOWLIST") {
self.security.network_allowlist = network::parse_domain_list(&val);
}
if let Ok(val) = std::env::var("WHYCODES_NETWORK_DENYLIST") {
self.security.network_denylist = network::parse_domain_list(&val);
}
if let Ok(val) = std::env::var("WHYCODES_WEBSEARCH_ENGINE")
&& !val.trim().is_empty()
{
self.websearch.engine = Some(val.trim().to_ascii_lowercase());
}
}
fn apply_feature_env(&mut self) {
if let Ok(val) = std::env::var("WHYCODES_NO_MEMORY")
&& matches!(
val.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
{
self.memory.enabled = false;
}
if let Ok(val) = std::env::var("WHYCODES_NO_AUTO_UPDATE")
&& matches!(
val.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
{
self.general.auto_update = false;
}
if let Ok(val) = std::env::var("WHYCODES_AUTO_UPDATE") {
match val.to_ascii_lowercase().as_str() {
"0" | "false" | "no" | "off" => self.general.auto_update = false,
"1" | "true" | "yes" | "on" => self.general.auto_update = true,
_ => {}
}
}
if let Ok(val) = std::env::var("WHYCODES_APPROVAL_MODE")
&& let Some(mode) = whycodes_core::types::ApprovalMode::parse(&val)
{
self.general.approval_mode = Some(mode);
}
if let Ok(val) = std::env::var("WHYCODES_HEADLESS_ASK")
&& let Some(mode) = crate::HeadlessAskMode::parse(&val)
{
self.session.headless_ask = Some(mode);
}
if let Ok(val) = std::env::var("WHYCODES_PREWALK") {
match val.to_ascii_lowercase().as_str() {
"0" | "false" | "no" | "off" => self.session.prewalk = Some(false),
"1" | "true" | "yes" | "on" => self.session.prewalk = Some(true),
_ => {}
}
}
if let Ok(val) = std::env::var("WHYCODES_MEMORY") {
match val.to_ascii_lowercase().as_str() {
"0" | "false" | "no" | "off" => self.memory.enabled = false,
"1" | "true" | "yes" | "on" => self.memory.enabled = true,
_ => {}
}
}
}
fn apply_swarm_env(&mut self) {
if let Ok(val) = std::env::var("WHYCODES_SWARM") {
match val.to_ascii_lowercase().as_str() {
"0" | "false" | "no" | "off" => self.swarm.enabled = false,
"1" | "true" | "yes" | "on" => self.swarm.enabled = true,
_ => {}
}
}
if let Ok(val) = std::env::var("WHYCODES_SWARM_MAX_AGENTS")
&& let Ok(n) = val.parse::<usize>()
{
self.swarm.max_agents = n.clamp(1, 8);
}
if let Ok(val) = std::env::var("WHYCODES_SWARM_WORKTREES") {
match val.to_ascii_lowercase().as_str() {
"0" | "false" | "no" | "off" => self.swarm.worktrees = false,
"1" | "true" | "yes" | "on" => self.swarm.worktrees = true,
_ => {}
}
}
}
fn apply_notify_env(&mut self) {
if let Ok(val) = std::env::var("WHYCODES_NOTIFY_ON") {
self.notify.on = parse_notify_on_csv(&val);
}
if let Ok(val) = std::env::var("WHYCODES_NOTIFY_DISCORD_WEBHOOK") {
self.notify.discord_webhook = Some(val);
}
if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TELEGRAM_BOT_TOKEN") {
self.notify.telegram_bot_token = Some(val);
}
if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TELEGRAM_CHAT_ID") {
self.notify.telegram_chat_id = Some(val);
}
if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TIMEOUT_SECS")
&& let Ok(n) = val.parse::<u64>()
{
self.notify.timeout_secs = n.clamp(1, 60);
}
if let Ok(val) = std::env::var("WHYCODES_NOTIFY_DESKTOP") {
match val.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => self.notify.desktop = Some(true),
"0" | "false" | "no" | "off" => self.notify.desktop = Some(false),
_ => {}
}
}
}
pub fn load_command_files(&mut self, project_dir: &Path) {
if let Ok(global_dir) = Self::default_path()
&& let Some(parent) = global_dir.parent()
{
load_commands_from_dir(&mut self.commands, &parent.join("commands"));
}
load_commands_from_dir(
&mut self.commands,
&whycodes_core::project_dir(project_dir).join("commands"),
);
load_commands_from_dir(
&mut self.commands,
&project_dir.join(".opencode").join("commands"),
);
self.ensure_builtin_prompt_commands();
self.load_prompt_files(project_dir);
}
pub fn load_prompt_files(&mut self, project_dir: &Path) {
if let Ok(global_dir) = Self::default_path()
&& let Some(parent) = global_dir.parent()
{
load_prompt_overlays(&mut self.system_prompt_overlays, &parent.join("prompts"));
}
load_prompt_overlays(
&mut self.system_prompt_overlays,
&whycodes_core::project_dir(project_dir).join("prompts"),
);
}
pub fn ensure_builtin_prompt_commands(&mut self) {
for (name, cmd) in builtin_prompt_commands() {
self.commands.entry(name.to_string()).or_insert(cmd);
}
}
}
fn builtin_prompt_commands() -> Vec<(&'static str, CustomCommandConfig)> {
vec![
(
"review",
CustomCommandConfig {
description: Some("AI code review of git changes".into()),
agent: Some("plan".into()),
model: None,
subtask: None,
template: r#"Review the current working tree changes for this project.
1. Run git_status and git_diff (unstaged and staged) to see what changed.
2. Read only the files that matter for the diff.
3. Produce a structured review:
- Summary (2–4 sentences)
- Strengths
- Issues (severity: high / medium / low) with file:line when possible
- Suggested follow-ups
4. Do NOT write or edit files unless the user explicitly asked to apply fixes.
5. Prefer read-only tools (git_*, read, grep, glob, list).
Extra context from the user: $ARGUMENTS"#
.into(),
},
),
(
"security-review",
CustomCommandConfig {
description: Some("Security-focused review of changes".into()),
agent: Some("plan".into()),
model: None,
subtask: None,
template: r#"Perform a security-focused review of the current git changes.
1. Inspect git_status / git_diff and relevant files.
2. Look for: secrets/credentials, injection (SQL/command/path), authz gaps,
unsafe shell, SSRF, insecure deserialization, dependency risks, XSS.
3. Output:
- Executive summary
- Findings table: severity | location | issue | remediation
- What looks safe
4. Do not modify files unless asked. Prefer read-only tools.
Focus / notes: $ARGUMENTS"#
.into(),
},
),
(
"commit",
CustomCommandConfig {
description: Some("Draft a git commit message from the diff".into()),
agent: None,
model: None,
subtask: None,
template: r#"Prepare a git commit for the current changes.
1. Run git_status and git_diff (include staged if any).
2. Draft a concise commit message (subject ≤72 chars; body if needed).
3. If the user asked to commit (or says "commit now" / "yes"), stage relevant
files and use git_commit. Otherwise show the proposed message and ask.
4. Never force-push, never amend published history, never commit secrets (.env, keys).
User notes: $ARGUMENTS"#
.into(),
},
),
]
}
fn load_prompt_overlays(into: &mut SystemPromptOverlays, dir: &Path) {
load_prompt_overlays_at(into, dir, None);
}
fn load_prompt_overlays_at(into: &mut SystemPromptOverlays, dir: &Path, provider: Option<&str>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_missing_dir) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if provider.is_some() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name.eq_ignore_ascii_case("models") {
load_model_overlay_dir(into, &path);
} else {
load_prompt_overlays_at(into, &path, Some(name));
}
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some(body) = read_overlay_markdown(&path) else {
continue;
};
if let Some(p) = provider {
into.models
.insert(format!("{}/{}", overlay_key(p), overlay_key(stem)), body);
} else {
into.providers.insert(overlay_key(stem), body);
}
}
}
fn load_model_overlay_dir(into: &mut SystemPromptOverlays, dir: &Path) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_missing_dir) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some(body) = read_overlay_markdown(&path) else {
continue;
};
into.models.insert(overlay_key(stem), body);
}
}
fn read_overlay_markdown(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let body = strip_overlay_frontmatter(&content);
if body.is_empty() { None } else { Some(body) }
}
fn strip_overlay_frontmatter(content: &str) -> String {
let trimmed = content.trim();
if let Some(rest) = trimmed.strip_prefix("---")
&& let Some((_, body)) = rest.split_once("---")
{
return body.trim().to_string();
}
trimmed.to_string()
}
fn load_commands_from_dir(into: &mut HashMap<String, CustomCommandConfig>, dir: &Path) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("cmd")
.to_string();
if let Ok(content) = std::fs::read_to_string(&path)
&& let Some(cmd) = parse_command_markdown(&content)
{
into.insert(name, cmd);
}
}
}
pub(crate) fn parse_command_markdown(content: &str) -> Option<CustomCommandConfig> {
let content = content.trim();
if !content.starts_with("---") {
return Some(CustomCommandConfig {
template: content.to_string(),
description: None,
agent: None,
model: None,
subtask: None,
});
}
let (front, body) = content.strip_prefix("---")?.split_once("---")?;
let front = front.trim();
let body = body.trim().to_string();
let mut description = None;
let mut agent = None;
let mut model = None;
let mut subtask = None;
for line in front.lines() {
let line = line.trim();
if let Some((k, v)) = line.split_once(':') {
let k = k.trim();
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
match k {
"description" => description = Some(v),
"agent" => agent = Some(v),
"model" => model = Some(v),
"subtask" => {
subtask = Some(matches!(
v.to_ascii_lowercase().as_str(),
"true" | "yes" | "1"
))
}
_ => {}
}
}
}
Some(CustomCommandConfig {
template: body,
description,
agent,
model,
subtask,
})
}
impl Config {
pub fn get_command_config(&self, command: &str) -> Option<&CommandConfig> {
self.command_configs.get(command)
}
pub fn expand_notify_secrets(&mut self) {
if let Some(url) = self.notify.discord_webhook.take() {
let expanded = Self::substitute_vars(&url);
self.notify.discord_webhook = nonempty_opt(expanded);
}
if let Some(token) = self.notify.telegram_bot_token.take() {
let expanded = Self::substitute_vars(&token);
self.notify.telegram_bot_token = nonempty_opt(expanded);
}
if let Some(chat) = self.notify.telegram_chat_id.take() {
let expanded = Self::substitute_vars(&chat);
self.notify.telegram_chat_id = nonempty_opt(expanded);
}
}
pub fn substitute_vars(value: &str) -> String {
let mut result = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' {
match chars.peek() {
Some('{') => {
chars.next(); let mut var_name = String::new();
while let Some(&c) = chars.peek() {
if c == '}' {
chars.next(); break;
}
var_name.push(c);
chars.next();
}
if let Ok(val) = std::env::var(&var_name) {
result.push_str(&val);
} else {
result.push_str(&format!("${{{}}}", var_name));
}
}
Some(c) if c.is_alphanumeric() || *c == '_' => {
let mut var_name = String::new();
while let Some(&c) = chars.peek() {
if c.is_alphanumeric() || c == '_' {
var_name.push(c);
chars.next();
} else {
break;
}
}
if let Ok(val) = std::env::var(&var_name) {
result.push_str(&val);
} else {
result.push_str(&format!("${}", var_name));
}
}
_ => {
result.push('$');
}
}
} else {
result.push(ch);
}
}
result
}
pub fn project_path(&self) -> PathBuf {
self.general
.project_path
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
pub fn get_provider(&self, name: &str) -> Option<&ProviderConfig> {
self.providers.get(name)
}
pub fn get_model(&self, provider: &str, model: &str) -> Option<&ModelConfig> {
self.models
.values()
.find(|m| m.provider_id == provider && m.model_id == model)
}
pub fn configured_context_window(&self, provider: &str, model: &str) -> Option<u32> {
self.get_model(provider, model)
.and_then(|m| m.context_window)
.or_else(|| {
self.default_model.as_ref().and_then(|m| {
if m.model_id == model
&& (m.provider_id.is_empty() || m.provider_id == provider)
{
m.context_window
} else {
None
}
})
})
}
pub fn get_agent(&self, name: &str) -> Option<&AgentInfo> {
self.agents.iter().find(|a| a.name == name)
}
pub fn default_agent(&self) -> Option<&AgentInfo> {
self.get_agent(&self.default_agent)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Config;
use std::path::Path;
#[test]
fn helpers_markdown_and_parent_dir() {
assert!(toml_err("x".into()).to_string().contains("x"));
let cfg = Config::default();
let encoded = encode_toml(&cfg).unwrap();
assert!(!encoded.is_empty());
assert!(map_toml_ser(Ok("ok".into())).unwrap() == "ok");
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b.toml");
ensure_parent_dir(&nested).unwrap();
assert!(nested.parent().unwrap().is_dir());
ensure_parent_dir(Path::new("no-parent")).unwrap();
let plain = parse_command_markdown("echo hi").unwrap();
assert_eq!(plain.template, "echo hi");
assert!(plain.description.is_none());
let with_front = parse_command_markdown(
"---\ndescription: d\nagent: build\nmodel: gpt\nsubtask: true\nunknown: x\n---\nbody",
)
.unwrap();
assert_eq!(with_front.template, "body");
assert_eq!(with_front.description.as_deref(), Some("d"));
assert_eq!(with_front.agent.as_deref(), Some("build"));
assert_eq!(with_front.model.as_deref(), Some("gpt"));
assert_eq!(with_front.subtask, Some(true));
let yes = parse_command_markdown("---\nsubtask: yes\n---\nx").unwrap();
assert_eq!(yes.subtask, Some(true));
let one = parse_command_markdown("---\nsubtask: 1\n---\nx").unwrap();
assert_eq!(one.subtask, Some(true));
let no = parse_command_markdown("---\nsubtask: false\n---\nx").unwrap();
assert_eq!(no.subtask, Some(false));
assert!(parse_command_markdown("---\nno-end").is_none());
assert!(cfg.get_command_config("missing").is_none());
}
#[test]
fn prompt_overlay_loader_covers_missing_and_junk() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("no-such-prompts");
let mut empty = SystemPromptOverlays::default();
load_prompt_overlays(&mut empty, &missing);
load_model_overlay_dir(&mut empty, &missing);
assert!(empty.is_empty());
assert!(read_overlay_markdown(&missing).is_none());
assert!(read_overlay_markdown(dir.path()).is_none());
let models = dir.path().join("models");
std::fs::create_dir_all(models.join("nested")).unwrap();
std::fs::write(models.join("skip.txt"), "ignored").unwrap();
std::fs::write(models.join("empty.md"), " \n").unwrap();
std::fs::write(models.join("ok.md"), "bare model extra").unwrap();
let mut loaded = SystemPromptOverlays::default();
load_model_overlay_dir(&mut loaded, &models);
assert_eq!(
loaded.models.get("ok").map(String::as_str),
Some("bare model extra")
);
assert!(!loaded.models.contains_key("skip"));
assert!(!loaded.models.contains_key("empty"));
assert!(!loaded.models.contains_key("nested"));
std::fs::write(dir.path().join("notes.txt"), "not markdown").unwrap();
let mut mixed = SystemPromptOverlays::default();
load_prompt_overlays(&mut mixed, dir.path());
assert_eq!(
mixed.models.get("ok").map(String::as_str),
Some("bare model extra")
);
let provider_dir = dir.path().join("xai");
std::fs::create_dir_all(provider_dir.join("nested")).unwrap();
std::fs::write(provider_dir.join("nested/skip.md"), "nope").unwrap();
std::fs::write(provider_dir.join("notes.txt"), "ignored").unwrap();
let mut under_provider = SystemPromptOverlays::default();
load_prompt_overlays_at(&mut under_provider, &provider_dir, Some("xai"));
assert!(under_provider.models.is_empty());
assert!(under_provider.providers.is_empty());
}
#[cfg(unix)]
#[test]
fn prompt_overlay_skips_non_utf8_names() {
use std::os::unix::ffi::OsStrExt;
let dir = tempfile::tempdir().unwrap();
let bad_dir = dir.path().join(std::ffi::OsStr::from_bytes(b"bad\xffdir"));
std::fs::create_dir(&bad_dir).unwrap();
std::fs::write(
dir.path().join(std::ffi::OsStr::from_bytes(b"bad\xff.md")),
"x",
)
.unwrap();
let models = dir.path().join("models");
std::fs::create_dir(&models).unwrap();
std::fs::write(models.join(std::ffi::OsStr::from_bytes(b"m\xff.md")), "y").unwrap();
let mut loaded = SystemPromptOverlays::default();
load_prompt_overlays(&mut loaded, dir.path());
assert!(loaded.providers.is_empty());
assert!(loaded.models.is_empty());
}
}