use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use whycodes_core::network::{self, NetworkPolicy};
use whycodes_core::sandbox::SandboxSettings;
use whycodes_core::types::{
AgentInfo, ApprovalMode, ModelConfig, PermissionAction, ProviderConfig,
};
pub const DEFAULT_PROVIDER: &str = "openrouter";
pub const DEFAULT_MODEL_ID: &str = "anthropic/claude-sonnet-4.5";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommandConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<ModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_turns: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
#[serde(default)]
pub models: HashMap<String, ModelConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub pricing: HashMap<String, PriceConfig>,
#[serde(default)]
pub agents: Vec<AgentInfo>,
#[serde(default = "default_agent")]
pub default_agent: String,
#[serde(default)]
pub default_model: Option<ModelConfig>,
#[serde(default)]
pub command_configs: HashMap<String, CommandConfig>,
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default)]
pub session: SessionConfig,
#[serde(default)]
pub tui: TuiConfig,
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub schema_version: u32,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>,
#[serde(default)]
pub lsp: LspConfig,
#[serde(default)]
pub permission: HashMap<String, PermissionAction>,
#[serde(default)]
pub commands: HashMap<String, CustomCommandConfig>,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub hooks: Vec<HookConfig>,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub swarm: SwarmConfig,
#[serde(default)]
pub automation: AutomationConfig,
#[serde(default)]
pub notify: NotifyConfig,
#[serde(default)]
pub slop: SlopConfig,
#[serde(default)]
pub websearch: WebSearchConfig,
#[serde(default, skip)]
pub system_prompt_overlays: SystemPromptOverlays,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SlopConfig {
#[serde(default = "default_slop_verbosity")]
pub verbosity: f64,
#[serde(default = "default_slop_erosion")]
pub erosion: f64,
#[serde(default = "default_slop_delta_loc")]
pub delta_loc: i64,
#[serde(default = "default_slop_block_verbosity")]
pub block_verbosity: f64,
#[serde(default = "default_slop_block_erosion")]
pub block_erosion: f64,
#[serde(default = "default_slop_block_delta_loc")]
pub block_delta_loc: i64,
#[serde(default = "default_slop_hotspots")]
pub hotspots: usize,
}
pub(crate) fn default_slop_verbosity() -> f64 {
0.25
}
pub(crate) fn default_slop_erosion() -> f64 {
0.50
}
pub(crate) fn default_slop_delta_loc() -> i64 {
800
}
pub(crate) fn default_slop_block_verbosity() -> f64 {
0.40
}
pub(crate) fn default_slop_block_erosion() -> f64 {
0.75
}
pub(crate) fn default_slop_block_delta_loc() -> i64 {
5_000
}
pub(crate) fn default_slop_hotspots() -> usize {
8
}
impl Default for SlopConfig {
fn default() -> Self {
Self {
verbosity: default_slop_verbosity(),
erosion: default_slop_erosion(),
delta_loc: default_slop_delta_loc(),
block_verbosity: default_slop_block_verbosity(),
block_erosion: default_slop_block_erosion(),
block_delta_loc: default_slop_block_delta_loc(),
hotspots: default_slop_hotspots(),
}
}
}
impl SlopConfig {
pub fn merge_with(&self, other: &Self) -> Self {
let d = Self::default();
Self {
verbosity: if (other.verbosity - d.verbosity).abs() > f64::EPSILON {
other.verbosity
} else {
self.verbosity
},
erosion: if (other.erosion - d.erosion).abs() > f64::EPSILON {
other.erosion
} else {
self.erosion
},
delta_loc: if other.delta_loc != d.delta_loc {
other.delta_loc
} else {
self.delta_loc
},
block_verbosity: if (other.block_verbosity - d.block_verbosity).abs() > f64::EPSILON {
other.block_verbosity
} else {
self.block_verbosity
},
block_erosion: if (other.block_erosion - d.block_erosion).abs() > f64::EPSILON {
other.block_erosion
} else {
self.block_erosion
},
block_delta_loc: if other.block_delta_loc != d.block_delta_loc {
other.block_delta_loc
} else {
self.block_delta_loc
},
hotspots: if other.hotspots != d.hotspots {
other.hotspots
} else {
self.hotspots
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationConfig {
#[serde(default = "default_max_background_jobs")]
pub max_background_jobs: usize,
}
pub(crate) fn default_max_background_jobs() -> usize {
8
}
impl Default for AutomationConfig {
fn default() -> Self {
Self {
max_background_jobs: default_max_background_jobs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_swarm_max_agents")]
pub max_agents: usize,
#[serde(default = "default_true")]
pub worktrees: bool,
#[serde(default)]
pub isolation: Option<String>,
}
impl SwarmConfig {
pub fn use_worktrees(&self) -> bool {
match self.isolation.as_deref() {
Some(s) if s.eq_ignore_ascii_case("checkout") => false,
Some(s) if s.eq_ignore_ascii_case("worktree") => true,
_ => self.worktrees,
}
}
}
pub(crate) fn default_swarm_max_agents() -> usize {
4
}
impl Default for SwarmConfig {
fn default() -> Self {
Self {
enabled: true,
max_agents: default_swarm_max_agents(),
worktrees: true,
isolation: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
#[default]
PreTool,
PreToolTransform,
PostTool,
SessionStart,
UserPromptSubmit,
Stop,
PreCompact,
SubagentStop,
}
impl HookEvent {
pub fn as_str(self) -> &'static str {
match self {
Self::PreTool => "pre_tool",
Self::PreToolTransform => "pre_tool_transform",
Self::PostTool => "post_tool",
Self::SessionStart => "session_start",
Self::UserPromptSubmit => "user_prompt_submit",
Self::Stop => "stop",
Self::PreCompact => "pre_compact",
Self::SubagentStop => "subagent_stop",
}
}
pub fn can_block(self) -> bool {
matches!(self, Self::PreTool | Self::UserPromptSubmit)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
#[serde(default)]
pub event: HookEvent,
#[serde(default = "default_hook_match", rename = "match")]
pub tool_match: String,
pub command: String,
#[serde(default)]
pub block_on_failure: bool,
#[serde(default)]
pub feedback: bool,
#[serde(default = "default_hook_timeout")]
pub timeout_secs: u64,
}
pub(crate) fn default_hook_match() -> String {
"*".to_string()
}
pub(crate) fn default_hook_timeout() -> u64 {
30
}
impl Default for HookConfig {
fn default() -> Self {
Self {
event: HookEvent::PreTool,
tool_match: default_hook_match(),
command: String::new(),
block_on_failure: false,
feedback: false,
timeout_secs: default_hook_timeout(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(default = "default_risk_threshold")]
pub bash_risk_threshold: String,
#[serde(default = "default_sandbox_mode")]
pub sandbox: String,
#[serde(default = "default_true_bool")]
pub sandbox_network: bool,
#[serde(default = "default_sandbox_fallback")]
pub sandbox_fallback: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub network_allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub network_denylist: Vec<String>,
#[serde(default = "default_true_bool")]
pub redact_secrets: bool,
}
pub(crate) fn default_risk_threshold() -> String {
"destructive".to_string()
}
pub(crate) fn default_sandbox_mode() -> String {
"workspace".to_string()
}
pub(crate) fn default_sandbox_fallback() -> String {
"allow".to_string()
}
pub(crate) fn default_true_bool() -> bool {
true
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
bash_risk_threshold: default_risk_threshold(),
sandbox: default_sandbox_mode(),
sandbox_network: true,
sandbox_fallback: default_sandbox_fallback(),
network_allowlist: Vec::new(),
network_denylist: Vec::new(),
redact_secrets: true,
}
}
}
impl SecurityConfig {
pub fn network_policy(&self) -> NetworkPolicy {
NetworkPolicy {
allowlist: self.network_allowlist.clone(),
denylist: self.network_denylist.clone(),
}
}
pub fn sandbox_settings(&self) -> SandboxSettings {
SandboxSettings::from_raw(&self.sandbox, self.sandbox_network, &self.sandbox_fallback)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomCommandConfig {
pub template: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtask: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum McpTransportKind {
#[default]
Stdio,
Http,
Sse,
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct LspConfig {
#[serde(
default,
alias = "idleTimeoutMs",
skip_serializing_if = "Option::is_none"
)]
pub idle_timeout_ms: Option<u64>,
#[serde(
default,
alias = "editDiagnosticsMs",
skip_serializing_if = "Option::is_none"
)]
pub edit_diagnostics_ms: Option<u64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub servers: HashMap<String, LspServerConfig>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
pub struct PriceConfig {
pub input: f64,
pub output: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct LspServerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, alias = "fileTypes", skip_serializing_if = "Vec::is_empty")]
pub file_types: Vec<String>,
#[serde(default, alias = "languageId", skip_serializing_if = "Option::is_none")]
pub language_id: Option<String>,
#[serde(default, alias = "rootMarkers", skip_serializing_if = "Vec::is_empty")]
pub root_markers: Vec<String>,
#[serde(
default,
alias = "initOptions",
skip_serializing_if = "Option::is_none"
)]
pub init_options: Option<toml::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings: Option<toml::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(default, alias = "isLinter", skip_serializing_if = "Option::is_none")]
pub is_linter: Option<bool>,
}
impl LspConfig {
pub fn to_runtime_settings(&self) -> (Option<u64>, Vec<(String, RuntimeLspServer)>) {
let servers = self
.servers
.iter()
.map(|(name, spec)| (name.clone(), spec.to_runtime()))
.collect();
(self.idle_timeout_ms, servers)
}
}
#[derive(Debug, Clone)]
pub struct RuntimeLspServer {
pub command: Option<String>,
pub args: Vec<String>,
pub file_types: Vec<String>,
pub language_id: Option<String>,
pub root_markers: Vec<String>,
pub init_options: Option<serde_json::Value>,
pub settings: Option<serde_json::Value>,
pub disabled: Option<bool>,
pub is_linter: Option<bool>,
}
impl LspServerConfig {
fn to_runtime(&self) -> RuntimeLspServer {
RuntimeLspServer {
command: self.command.clone(),
args: self.args.clone(),
file_types: self.file_types.clone(),
language_id: self.language_id.clone(),
root_markers: self.root_markers.clone(),
init_options: self.init_options.as_ref().and_then(toml_to_json),
settings: self.settings.as_ref().and_then(toml_to_json),
disabled: self.disabled,
is_linter: self.is_linter,
}
}
}
fn toml_to_json(value: &toml::Value) -> Option<serde_json::Value> {
serde_json::to_value(value).ok()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub transport: Option<McpTransportKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
}
impl McpServerConfig {
pub fn resolved_transport(&self) -> std::result::Result<McpTransportKind, String> {
if let Some(kind) = self.transport {
return match kind {
McpTransportKind::Stdio if self.command.is_none() => {
Err("MCP transport 'stdio' requires `command`".into())
}
McpTransportKind::Http | McpTransportKind::Sse | McpTransportKind::Auto
if self.url.is_none() =>
{
Err(format!("MCP transport '{kind:?}' requires `url`"))
}
other => Ok(other),
};
}
match (&self.url, &self.command) {
(Some(_), _) => Ok(McpTransportKind::Auto),
(None, Some(_)) => Ok(McpTransportKind::Stdio),
(None, None) => {
Err("MCP server config needs either `command` (stdio) or `url` (http/sse)".into())
}
}
}
pub fn stdio_problem(&self) -> Option<String> {
let command = self.command.as_deref()?.trim();
if command.is_empty() {
return Some("MCP server `command` is empty".into());
}
let file = command.rsplit(['/', '\\']).next().unwrap_or(command);
let stem = file.split('.').next().unwrap_or(file).to_ascii_lowercase();
let runner = matches!(stem.as_str(), "npx" | "pnpx" | "bunx" | "uvx");
(runner && self.args.iter().all(|a| a.trim().is_empty())).then(|| {
format!(
"`{command}` needs the package to run in `args` \
(e.g. args = [\"-y\", \"<package>\"])"
)
})
}
pub fn is_remote(&self) -> bool {
matches!(
self.resolved_transport().ok(),
Some(McpTransportKind::Http | McpTransportKind::Sse | McpTransportKind::Auto)
)
}
}
impl Default for Config {
fn default() -> Self {
use whycodes_core::types::{AgentInfo, AgentMode, PermissionSet};
let build_agent = AgentInfo {
name: "build".to_string(),
description:
"Primary coding agent with full tool access — write, edit, shell, and network."
.to_string(),
mode: AgentMode::Primary,
permission: PermissionSet {
allowed_tools: None,
denied_tools: None,
allow_file_writes: true,
allow_network: true,
allow_shell: true,
allowed_paths: None,
rules: Default::default(),
},
model: None,
system_prompt: None, temperature: None,
top_p: None,
};
let plan_agent = AgentInfo {
name: "plan".to_string(),
description: "Read-only planning agent — analyzes code and proposes changes but does not modify files.".to_string(),
mode: AgentMode::Primary,
permission: PermissionSet {
allowed_tools: Some(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"list".to_string(),
"repomap".to_string(),
"webfetch".to_string(),
"websearch".to_string(),
"lsp".to_string(),
]),
denied_tools: Some(vec![
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"shell".to_string(),
"apply_patch".to_string(),
"todowrite".to_string(),
"todo".to_string(),
"git_commit".to_string(),
"task".to_string(),
"swarm".to_string(),
]),
allow_file_writes: false,
allow_network: true,
allow_shell: false,
allowed_paths: None,
rules: Default::default(),
},
model: None,
system_prompt: None, temperature: None,
top_p: None,
};
let ask_agent = AgentInfo {
name: "ask".to_string(),
description:
"Read-only Q&A agent — explains code and answers questions without modifying files."
.to_string(),
mode: AgentMode::Primary,
permission: PermissionSet {
allowed_tools: Some(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"list".to_string(),
"repomap".to_string(),
"webfetch".to_string(),
"websearch".to_string(),
"lsp".to_string(),
"question".to_string(),
]),
denied_tools: Some(vec![
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"shell".to_string(),
"apply_patch".to_string(),
"todowrite".to_string(),
"todo".to_string(),
"git_commit".to_string(),
"task".to_string(),
"swarm".to_string(),
"plan".to_string(),
]),
allow_file_writes: false,
allow_network: true,
allow_shell: false,
allowed_paths: None,
rules: Default::default(),
},
model: None,
system_prompt: None, temperature: None,
top_p: None,
};
let whycodes_agent = AgentInfo {
name: "whycodes".to_string(),
description: "WhyCodes operator — explains WhyCodes and repairs its config; never writes project source.".to_string(),
mode: AgentMode::Primary,
permission: PermissionSet {
allowed_tools: Some(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"list".to_string(),
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"skill".to_string(),
"question".to_string(),
]),
denied_tools: Some(vec![
"apply_patch".to_string(),
"ast_edit".to_string(),
"todowrite".to_string(),
"todo".to_string(),
"git_commit".to_string(),
"task".to_string(),
"swarm".to_string(),
]),
allow_file_writes: true,
allow_network: false,
allow_shell: true,
allowed_paths: None,
rules: HashMap::from([
("write(**)".to_string(), PermissionAction::Deny),
("write(.whycodes/**)".to_string(), PermissionAction::Allow),
("bash(*)".to_string(), PermissionAction::Deny),
(
"bash(whycodes config *)".to_string(),
PermissionAction::Allow,
),
(
"bash(whycodes debug *)".to_string(),
PermissionAction::Allow,
),
]),
},
model: None,
system_prompt: None, temperature: None,
top_p: None,
};
let explore_agent = AgentInfo {
name: "explore".to_string(),
description: "Fast read-only subagent for exploring codebases (find files, search keywords, answer questions).".to_string(),
mode: AgentMode::Subagent,
permission: PermissionSet {
allowed_tools: Some(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"list".to_string(),
"repomap".to_string(),
"webfetch".to_string(),
"websearch".to_string(),
"lsp".to_string(),
]),
denied_tools: Some(vec![
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"shell".to_string(),
"apply_patch".to_string(),
"todowrite".to_string(),
"todo".to_string(),
"question".to_string(),
]),
allow_file_writes: false,
allow_network: true,
allow_shell: false,
allowed_paths: None,
rules: Default::default(),
},
model: None,
system_prompt: Some("You are a read-only exploration subagent. Find files, search code, answer questions. No file modifications. Do not call `question` — report findings to the parent.".to_string()),
temperature: None,
top_p: None,
};
let general_agent = AgentInfo {
name: "general".to_string(),
description: "General-purpose subagent for complex multi-step tasks. Full tools except todo. Use for parallel units of work.".to_string(),
mode: AgentMode::Subagent,
permission: PermissionSet {
allowed_tools: None,
denied_tools: Some(vec![
"todowrite".to_string(),
"todo".to_string(),
"todoread".to_string(),
]),
allow_file_writes: true,
allow_network: true,
allow_shell: true,
allowed_paths: None,
rules: Default::default(),
},
model: None,
system_prompt: Some("You are a general-purpose subagent for complex searches and multi-step tasks. Complete the goal thoroughly.".to_string()),
temperature: None,
top_p: None,
};
let scout_agent = AgentInfo {
name: "scout".to_string(),
description: "Read-only subagent for external docs and dependency research.".to_string(),
mode: AgentMode::Subagent,
permission: PermissionSet {
allowed_tools: Some(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"list".to_string(),
"repomap".to_string(),
"webfetch".to_string(),
"websearch".to_string(),
"bash".to_string(), ]),
denied_tools: Some(vec![
"write".to_string(),
"edit".to_string(),
"apply_patch".to_string(),
"todowrite".to_string(),
"todo".to_string(),
"question".to_string(),
]),
allow_file_writes: false,
allow_network: true,
allow_shell: true,
allowed_paths: None,
rules: Default::default(),
},
model: None,
system_prompt: Some("You are a scout subagent. Research external docs and dependencies. Prefer webfetch/websearch. Do not modify the workspace project files. Do not call `question` — report findings to the parent.".to_string()),
temperature: None,
top_p: None,
};
Config {
providers: HashMap::new(),
models: HashMap::new(),
pricing: HashMap::new(),
agents: vec![
build_agent,
plan_agent,
ask_agent,
whycodes_agent,
explore_agent,
general_agent,
scout_agent,
],
default_agent: "build".to_string(),
default_model: None,
command_configs: HashMap::new(),
tools: ToolsConfig::default(),
session: SessionConfig::default(),
tui: TuiConfig::default(),
general: GeneralConfig::default(),
schema_version: CONFIG_SCHEMA_VERSION,
mcp_servers: HashMap::new(),
lsp: LspConfig::default(),
permission: HashMap::new(),
commands: HashMap::new(),
security: SecurityConfig::default(),
hooks: Vec::new(),
memory: MemoryConfig::default(),
swarm: SwarmConfig::default(),
automation: AutomationConfig::default(),
notify: NotifyConfig::default(),
slop: SlopConfig::default(),
websearch: WebSearchConfig::default(),
system_prompt_overlays: SystemPromptOverlays::default(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemPromptOverlays {
pub providers: HashMap<String, String>,
pub models: HashMap<String, String>,
}
impl SystemPromptOverlays {
pub fn is_empty(&self) -> bool {
self.providers.is_empty() && self.models.is_empty()
}
pub fn sections(&self, provider: &str, model: &str) -> Vec<(String, String)> {
let p = overlay_key(provider);
let m = overlay_key(model);
let mut out = Vec::new();
if !p.is_empty()
&& let Some(body) = self.providers.get(&p)
{
out.push((format!("Provider instructions ({p})"), body.clone()));
}
if !m.is_empty() {
let body = if p.is_empty() {
self.models.get(&m)
} else {
self.models
.get(&format!("{p}/{m}"))
.or_else(|| self.models.get(&m))
};
if let Some(body) = body {
out.push((format!("Model instructions ({m})"), body.clone()));
}
}
out
}
}
pub(crate) fn overlay_key(raw: &str) -> String {
raw.trim().to_ascii_lowercase()
}
#[cfg(test)]
mod overlay_tests {
use super::*;
#[test]
fn overlay_key_trims_and_lowercases() {
assert_eq!(overlay_key(" XAI "), "xai");
assert_eq!(overlay_key(""), "");
}
#[test]
fn sections_provider_then_specific_model() {
let mut o = SystemPromptOverlays::default();
o.providers.insert("xai".into(), "prov".into());
o.models.insert("xai/grok-4.6".into(), "specific".into());
o.models.insert("grok-4.6".into(), "generic".into());
let s = o.sections("XAI", "grok-4.6");
assert_eq!(s.len(), 2);
assert_eq!(s[0].0, "Provider instructions (xai)");
assert_eq!(s[0].1, "prov");
assert_eq!(s[1].0, "Model instructions (grok-4.6)");
assert_eq!(s[1].1, "specific");
}
#[test]
fn sections_bare_model_when_no_provider_or_specific() {
let mut o = SystemPromptOverlays::default();
o.models.insert("grok-4.6".into(), "generic".into());
let s = o.sections("xai", "grok-4.6");
assert_eq!(
s,
vec![("Model instructions (grok-4.6)".into(), "generic".into())]
);
let only_model = o.sections("", "grok-4.6");
assert_eq!(only_model.len(), 1);
assert!(o.sections("xai", "missing").is_empty());
assert!(o.sections("", "").is_empty());
assert!(SystemPromptOverlays::default().is_empty());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub auto_inject: bool,
#[serde(default = "default_true")]
pub auto_retain: bool,
#[serde(default = "default_true")]
pub retain_llm: bool,
#[serde(default)]
pub retain_llm_always: bool,
#[serde(default = "default_retain_every_n")]
pub retain_every_n: usize,
#[serde(default = "default_retain_max_facts")]
pub retain_max_facts: usize,
#[serde(default = "default_memory_index_lines")]
pub max_index_lines: usize,
#[serde(default = "default_memory_index_bytes")]
pub max_index_bytes: usize,
#[serde(default = "default_memory_top_k")]
pub recall_top_k: usize,
#[serde(default = "default_memory_min_score")]
pub recall_min_score: f32,
#[serde(default = "default_memory_token_budget")]
pub recall_token_budget: usize,
#[serde(default = "default_memory_embed_dim")]
pub embed_dim: usize,
#[serde(default = "default_memory_scope")]
pub scope: String,
#[serde(default = "default_memory_backend")]
pub embed_backend: String,
#[serde(default = "default_true")]
pub code_inject: bool,
#[serde(default = "default_code_top_k")]
pub code_top_k: usize,
#[serde(default = "default_code_min_score")]
pub code_min_score: f32,
#[serde(default = "default_true")]
pub subagent_banks: bool,
#[serde(default = "default_true")]
pub auto_index: bool,
#[serde(default = "default_auto_index_files")]
pub auto_index_max_files: usize,
#[serde(default = "default_auto_index_chunks")]
pub auto_index_max_chunks: usize,
#[serde(default = "default_true")]
pub session_inject: bool,
#[serde(default = "default_session_top_k")]
pub session_top_k: usize,
#[serde(default = "default_session_min_score")]
pub session_min_score: f32,
#[serde(default = "default_true")]
pub consolidate: bool,
#[serde(default = "default_consolidate_max")]
pub consolidate_max: usize,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
enabled: true,
auto_inject: true,
auto_retain: true,
retain_llm: true,
retain_llm_always: false,
retain_every_n: default_retain_every_n(),
retain_max_facts: default_retain_max_facts(),
max_index_lines: default_memory_index_lines(),
max_index_bytes: default_memory_index_bytes(),
recall_top_k: default_memory_top_k(),
recall_min_score: default_memory_min_score(),
recall_token_budget: default_memory_token_budget(),
embed_dim: default_memory_embed_dim(),
scope: default_memory_scope(),
embed_backend: default_memory_backend(),
code_inject: true,
code_top_k: default_code_top_k(),
code_min_score: default_code_min_score(),
subagent_banks: true,
auto_index: true,
auto_index_max_files: default_auto_index_files(),
auto_index_max_chunks: default_auto_index_chunks(),
session_inject: true,
session_top_k: default_session_top_k(),
session_min_score: default_session_min_score(),
consolidate: true,
consolidate_max: default_consolidate_max(),
}
}
}
pub(crate) fn default_memory_index_lines() -> usize {
200
}
pub(crate) fn default_memory_index_bytes() -> usize {
25_600
}
pub(crate) fn default_memory_top_k() -> usize {
5
}
pub(crate) fn default_memory_min_score() -> f32 {
0.28
}
pub(crate) fn default_memory_token_budget() -> usize {
800
}
pub(crate) fn default_memory_embed_dim() -> usize {
256
}
pub(crate) fn default_retain_every_n() -> usize {
1
}
pub(crate) fn default_retain_max_facts() -> usize {
3
}
pub(crate) fn default_memory_scope() -> String {
"user".into()
}
pub(crate) fn default_memory_backend() -> String {
"hash".into()
}
pub(crate) fn default_code_top_k() -> usize {
4
}
pub(crate) fn default_code_min_score() -> f32 {
0.22
}
pub(crate) fn default_auto_index_files() -> usize {
1500
}
pub(crate) fn default_auto_index_chunks() -> usize {
4000
}
pub(crate) fn default_session_top_k() -> usize {
3
}
pub(crate) fn default_session_min_score() -> f32 {
0.22
}
pub(crate) fn default_consolidate_max() -> usize {
80
}
pub(crate) fn default_agent() -> String {
"build".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionToolConfig {
#[serde(default = "default_question_timeout_enabled")]
pub timeout_enabled: bool,
#[serde(default = "default_question_timeout_secs")]
pub timeout_secs: u64,
}
impl Default for QuestionToolConfig {
fn default() -> Self {
Self {
timeout_enabled: default_question_timeout_enabled(),
timeout_secs: default_question_timeout_secs(),
}
}
}
pub(crate) fn default_question_timeout_enabled() -> bool {
true
}
pub(crate) fn default_question_timeout_secs() -> u64 {
1800
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolsConfig {
#[serde(default = "default_true")]
pub enable_read: bool,
#[serde(default = "default_true")]
pub enable_write: bool,
#[serde(default = "default_true")]
pub enable_edit: bool,
#[serde(default = "default_true")]
pub enable_glob: bool,
#[serde(default = "default_true")]
pub enable_grep: bool,
#[serde(default = "default_true")]
pub enable_shell: bool,
#[serde(default = "default_true")]
pub enable_webfetch: bool,
#[serde(default = "default_true")]
pub enable_websearch: bool,
#[serde(default)]
pub question: QuestionToolConfig,
#[serde(default)]
pub disabled_tools: Vec<String>,
#[serde(default)]
pub custom_tools: HashMap<String, CustomToolConfig>,
#[serde(default)]
pub bash: BashConfig,
#[serde(default)]
pub format: FormatConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FormatConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for FormatConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BashConfig {
#[serde(default = "default_true")]
pub auto_background: bool,
#[serde(default = "default_auto_background_after_secs")]
pub auto_background_after_secs: u64,
}
pub(crate) fn default_auto_background_after_secs() -> u64 {
20
}
impl Default for BashConfig {
fn default() -> Self {
Self {
auto_background: true,
auto_background_after_secs: default_auto_background_after_secs(),
}
}
}
pub(crate) fn default_goal_max_turns() -> u32 {
30
}
pub(crate) fn default_true() -> bool {
true
}
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
#[serde(default = "default_max_tokens")]
pub max_context_tokens: usize,
#[serde(default = "default_compaction_threshold")]
pub compaction_threshold: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_buffer_percent: Option<u8>,
#[serde(default)]
pub store_path: Option<PathBuf>,
#[serde(default = "default_true")]
pub auto_title: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title_model: Option<String>,
#[serde(default = "default_tool_profile")]
pub tool_profile: String,
#[serde(default = "default_prompt_cache")]
pub prompt_cache: String,
#[serde(default)]
pub cache_warm_minutes: u64,
#[serde(default = "default_undo_depth")]
pub undo_depth: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_fast: Option<String>,
#[serde(default = "default_goal_max_turns")]
pub goal_max_turns: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_smol: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prewalk: Option<bool>,
#[serde(default = "default_model_race")]
pub model_race: String,
#[serde(default = "default_race_after_ms")]
pub race_after_ms: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallback_models: Vec<String>,
#[serde(default = "default_stream_retry_secs")]
pub stream_retry_secs: u64,
#[serde(default = "default_response_cache")]
pub response_cache: String,
#[serde(default = "default_true")]
pub preconnect: bool,
#[serde(default = "default_intent_guidance")]
pub intent_guidance: String,
#[serde(default = "default_compaction_llm")]
pub compaction_llm: String,
#[serde(default = "default_compaction_method")]
pub compaction_method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub magic_keywords: MagicKeywordsConfig,
#[serde(default)]
pub stream_rules: Vec<StreamRuleConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headless_ask: Option<HeadlessAskMode>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum HeadlessAskMode {
Deny,
Allow,
}
impl HeadlessAskMode {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"deny" | "ask-fail" | "fail" | "fail-closed" => Some(Self::Deny),
"allow" | "auto" | "approve" => Some(Self::Allow),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Deny => "deny",
Self::Allow => "allow",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct StreamRuleConfig {
#[serde(default)]
pub name: String,
#[serde(default)]
pub pattern: String,
#[serde(default)]
pub hint: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scope: Vec<StreamRuleScope>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "StreamRuleRepeat::is_always")]
pub repeat: StreamRuleRepeat,
}
impl StreamRuleConfig {
pub fn scopes(&self) -> Vec<StreamRuleScope> {
if self.scope.is_empty() {
vec![StreamRuleScope::Text]
} else {
self.scope.clone()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamRuleScope {
Text,
Thinking,
ToolArgs,
}
impl StreamRuleScope {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"text" => Some(Self::Text),
"thinking" => Some(Self::Thinking),
"tool_args" => Some(Self::ToolArgs),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Thinking => "thinking",
Self::ToolArgs => "tool_args",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum StreamRuleRepeat {
Once,
#[default]
Always,
AfterTurns(u32),
}
impl StreamRuleRepeat {
pub fn is_always(&self) -> bool {
*self == Self::Always
}
pub fn parse(s: &str) -> Option<Self> {
let s = s
.trim()
.trim_start_matches('{')
.trim_end_matches('}')
.trim();
match s.to_ascii_lowercase().as_str() {
"once" => return Some(Self::Once),
"always" => return Some(Self::Always),
_ => {}
}
let n = match s.split_once([':', '=']) {
Some((k, v)) if k.trim() == "after_turns" => v,
Some(_) => return None,
None => s,
};
n.trim().parse().ok().map(Self::AfterTurns)
}
pub fn allows(self, last: Option<u64>, turn: u64) -> bool {
match (self, last) {
(_, None) | (Self::Always, _) => true,
(Self::Once, Some(_)) => false,
(Self::AfterTurns(n), Some(t)) => turn.saturating_sub(t) >= u64::from(n),
}
}
pub fn label(self) -> String {
match self {
Self::Once => "once".into(),
Self::Always => "always".into(),
Self::AfterTurns(n) => format!("after_turns={n}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MagicKeywordsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub ultrathink: bool,
#[serde(default = "default_true")]
pub orchestrate: bool,
}
impl Default for MagicKeywordsConfig {
fn default() -> Self {
Self {
enabled: true,
ultrathink: true,
orchestrate: true,
}
}
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
max_context_tokens: default_max_tokens(),
compaction_threshold: default_compaction_threshold(),
compaction_buffer_percent: None,
store_path: None,
auto_title: true,
title_model: None,
tool_profile: default_tool_profile(),
prompt_cache: default_prompt_cache(),
cache_warm_minutes: 0,
undo_depth: default_undo_depth(),
model_fast: None,
goal_max_turns: default_goal_max_turns(),
goal_model: None,
model_smol: None,
model_plan: None,
prewalk: None,
model_race: default_model_race(),
race_after_ms: default_race_after_ms(),
fallback_models: Vec::new(),
stream_retry_secs: default_stream_retry_secs(),
response_cache: default_response_cache(),
preconnect: true,
intent_guidance: default_intent_guidance(),
compaction_llm: default_compaction_llm(),
compaction_method: default_compaction_method(),
reasoning_effort: None,
magic_keywords: MagicKeywordsConfig::default(),
stream_rules: Vec::new(),
headless_ask: None,
}
}
}
pub(crate) fn default_compaction_llm() -> String {
"auto".into()
}
pub(crate) fn default_compaction_method() -> String {
"local".into()
}
pub(crate) fn default_tool_profile() -> String {
"core".into()
}
pub(crate) fn default_prompt_cache() -> String {
"auto".into()
}
pub(crate) fn default_model_race() -> String {
"off".into()
}
pub(crate) fn default_race_after_ms() -> u64 {
800
}
pub(crate) fn default_stream_retry_secs() -> u64 {
60
}
pub(crate) fn default_undo_depth() -> usize {
50
}
pub(crate) fn default_response_cache() -> String {
"auto".into()
}
pub(crate) fn default_intent_guidance() -> String {
"auto".into()
}
pub(crate) fn default_max_tokens() -> usize {
200_000
}
pub(crate) fn default_compaction_threshold() -> usize {
150_000
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NotifyConfig {
#[serde(default)]
pub on: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord_webhook: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telegram_bot_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telegram_chat_id: Option<String>,
#[serde(default = "default_notify_timeout_secs")]
pub timeout_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub desktop: Option<bool>,
}
pub(crate) fn default_notify_timeout_secs() -> u64 {
8
}
impl Default for NotifyConfig {
fn default() -> Self {
Self {
on: Vec::new(),
discord_webhook: None,
telegram_bot_token: None,
telegram_chat_id: None,
timeout_secs: default_notify_timeout_secs(),
desktop: None,
}
}
}
impl NotifyConfig {
pub fn wants(&self, event: NotifyEvent) -> bool {
self.on.iter().any(|e| NotifyEvent::parse(e) == Some(event))
}
pub fn discord_webhook_url(&self) -> Option<&str> {
nonempty_str(self.discord_webhook.as_deref())
}
pub fn telegram_token(&self) -> Option<&str> {
nonempty_str(self.telegram_bot_token.as_deref())
}
pub fn telegram_chat(&self) -> Option<&str> {
nonempty_str(self.telegram_chat_id.as_deref())
}
pub fn has_channel(&self) -> bool {
self.discord_webhook_url().is_some()
|| (self.telegram_token().is_some() && self.telegram_chat().is_some())
}
pub fn enabled_for(&self, event: NotifyEvent) -> bool {
self.wants(event) && self.has_channel()
}
pub fn desktop_for(&self, event: NotifyEvent) -> bool {
self.desktop == Some(true) && self.wants(event)
}
pub(crate) fn merge_with(&self, other: &NotifyConfig) -> NotifyConfig {
let mut merged = self.clone();
if !other.on.is_empty() {
merged.on = other.on.clone();
}
if other.discord_webhook.is_some() {
merged.discord_webhook = other.discord_webhook.clone();
}
if other.telegram_bot_token.is_some() {
merged.telegram_bot_token = other.telegram_bot_token.clone();
}
if other.telegram_chat_id.is_some() {
merged.telegram_chat_id = other.telegram_chat_id.clone();
}
if other.timeout_secs != default_notify_timeout_secs() {
merged.timeout_secs = other.timeout_secs;
}
if other.desktop.is_some() {
merged.desktop = other.desktop;
}
merged
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebSearchConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallback: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<WebSearchCustomConfig>,
}
impl WebSearchConfig {
pub(crate) fn strip_project_only_keys(&mut self) -> Vec<&'static str> {
let mut dropped = Vec::new();
if self.custom.take().is_some() {
dropped.push("websearch.custom");
}
if self.engine.as_deref().is_some_and(is_custom_engine) {
self.engine = None;
dropped.push("websearch.engine = \"custom\"");
}
let before = self.fallback.len();
self.fallback.retain(|e| !is_custom_engine(e));
if self.fallback.len() != before {
dropped.push("\"custom\" in websearch.fallback");
}
dropped
}
pub(crate) fn merge_with(&self, other: &WebSearchConfig) -> WebSearchConfig {
let mut merged = self.clone();
if other.engine.is_some() {
merged.engine = other.engine.clone();
}
if !other.fallback.is_empty() {
merged.fallback = other.fallback.clone();
}
if other.custom.is_some() {
merged.custom = other.custom.clone();
}
merged
}
}
fn is_custom_engine(name: &str) -> bool {
name.trim().eq_ignore_ascii_case("custom")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebSearchCustomConfig {
pub endpoint: String,
#[serde(default = "default_websearch_method")]
pub method: String,
#[serde(default = "default_websearch_query_param")]
pub query_param: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub count_param: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub header_env: BTreeMap<String, String>,
#[serde(default = "default_websearch_results_pointer")]
pub results_pointer: String,
#[serde(default = "default_websearch_title_pointer")]
pub title_pointer: String,
#[serde(default = "default_websearch_url_pointer")]
pub url_pointer: String,
#[serde(default = "default_websearch_snippet_pointer")]
pub snippet_pointer: String,
}
pub(crate) fn default_websearch_method() -> String {
"GET".to_string()
}
pub(crate) fn default_websearch_query_param() -> String {
"q".to_string()
}
pub(crate) fn default_websearch_results_pointer() -> String {
"/results".to_string()
}
pub(crate) fn default_websearch_title_pointer() -> String {
"/title".to_string()
}
pub(crate) fn default_websearch_url_pointer() -> String {
"/url".to_string()
}
pub(crate) fn default_websearch_snippet_pointer() -> String {
"/snippet".to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotifyEvent {
TurnDone,
NeedInput,
}
impl NotifyEvent {
pub fn as_str(self) -> &'static str {
match self {
Self::TurnDone => "turn_done",
Self::NeedInput => "need_input",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"turn_done" | "turn-done" | "done" => Some(Self::TurnDone),
"need_input" | "need-input" | "input" => Some(Self::NeedInput),
_ => None,
}
}
}
pub(crate) fn parse_notify_on_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
.collect()
}
fn nonempty_str(s: Option<&str>) -> Option<&str> {
s.map(str::trim).filter(|s| !s.is_empty())
}
pub(crate) fn nonempty_opt(s: String) -> Option<String> {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
pub fn is_discord_webhook_url(url: &str) -> bool {
let url = url.trim();
if !url.starts_with("https://") {
return false;
}
let Ok(host) = network::host_from_url(url) else {
return false;
};
let host = host.to_ascii_lowercase();
if host != "discord.com" && host != "discordapp.com" {
return false;
}
let rest = url.strip_prefix("https://").unwrap_or(url);
let path_start = rest.find('/').unwrap_or(rest.len());
let path = rest[path_start..].split(['?', '#']).next().unwrap_or("");
path.starts_with("/api/webhooks/") && path.len() > "/api/webhooks/".len()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TuiConfig {
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub key_bindings: Option<HashMap<String, String>>,
#[serde(default = "default_prompt_suggestions")]
pub prompt_suggestions: String,
#[serde(default)]
pub show_sidebar: bool,
#[serde(default)]
pub agent_colors: HashMap<String, String>,
#[serde(default)]
pub skip_openrouter_key_prompt: bool,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
theme: None,
key_bindings: None,
prompt_suggestions: default_prompt_suggestions(),
show_sidebar: false,
agent_colors: HashMap::new(),
skip_openrouter_key_prompt: false,
}
}
}
pub(crate) fn default_prompt_suggestions() -> String {
"off".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
pub project_path: Option<PathBuf>,
pub log_level: Option<String>,
pub default_gcp_project: Option<String>,
#[serde(default = "default_true")]
pub auto_update: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_mode: Option<ApprovalMode>,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
project_path: None,
log_level: None,
default_gcp_project: None,
auto_update: true,
approval_mode: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CustomToolConfig {
pub command: String,
pub description: String,
pub parameters: Option<serde_json::Value>,
}
impl CustomCommandConfig {
pub fn render(&self, args: &str) -> String {
let parts: Vec<&str> = args.split_whitespace().collect();
let mut out = self.template.clone();
out = out.replace("$ARGUMENTS", args);
for (i, p) in parts.iter().enumerate() {
out = out.replace(&format!("${}", i + 1), p);
}
while let Some(start) = out.find("!`") {
if let Some(rel_end) = out[start + 2..].find('`') {
let cmd = &out[start + 2..start + 2 + rel_end];
let output = run_inline_shell(cmd);
out.replace_range(start..start + 2 + rel_end + 1, &output);
} else {
break;
}
}
out
}
}
fn run_inline_shell(cmd: &str) -> String {
#[cfg(windows)]
let output = std::process::Command::new("cmd").args(["/C", cmd]).output();
#[cfg(not(windows))]
let output = std::process::Command::new("sh").args(["-c", cmd]).output();
match output {
Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stdout).to_string();
let err = String::from_utf8_lossy(&o.stderr);
if !err.is_empty() {
s.push_str(&err);
}
s
}
Err(e) => format!("(command failed: {})", e),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mcp(
command: Option<&str>,
url: Option<&str>,
transport: Option<McpTransportKind>,
) -> McpServerConfig {
McpServerConfig {
transport,
command: command.map(str::to_string),
args: vec![],
env: None,
cwd: None,
url: url.map(str::to_string),
headers: None,
}
}
#[test]
fn mcp_notify_hooks_and_discord() {
assert_mcp_transport();
assert_notify_channels();
assert_notify_events_and_discord();
}
fn assert_mcp_transport() {
assert_eq!(
mcp(Some("npx"), None, None).resolved_transport().unwrap(),
McpTransportKind::Stdio
);
assert_eq!(
mcp(None, Some("https://mcp.example"), None)
.resolved_transport()
.unwrap(),
McpTransportKind::Auto
);
assert!(mcp(None, None, None).resolved_transport().is_err());
assert!(
mcp(None, None, Some(McpTransportKind::Stdio))
.resolved_transport()
.is_err()
);
assert!(
mcp(None, None, Some(McpTransportKind::Http))
.resolved_transport()
.is_err()
);
assert!(
mcp(None, None, Some(McpTransportKind::Sse))
.resolved_transport()
.is_err()
);
assert!(
mcp(None, None, Some(McpTransportKind::Auto))
.resolved_transport()
.is_err()
);
assert!(mcp(None, Some("https://x"), None).stdio_problem().is_none());
let empty = mcp(Some(" "), None, None).stdio_problem().unwrap();
assert!(empty.contains("empty"), "{empty}");
let bare = mcp(Some(r"C:\nodejs\npx.cmd"), None, None);
assert!(bare.stdio_problem().unwrap().contains("package"));
let with_pkg = McpServerConfig {
args: vec!["-y".into(), "@scope/server".into()],
..bare
};
assert!(with_pkg.stdio_problem().is_none());
assert!(mcp(Some("node"), None, None).stdio_problem().is_none());
let remote = mcp(None, Some("https://x"), Some(McpTransportKind::Http));
assert!(remote.is_remote());
assert!(!mcp(Some("npx"), None, Some(McpTransportKind::Stdio)).is_remote());
}
fn assert_notify_channels() {
let mut notify = NotifyConfig::default();
assert!(!notify.wants(NotifyEvent::TurnDone));
assert!(!notify.has_channel());
notify.on = vec!["turn_done".into(), "need-input".into()];
notify.discord_webhook = Some(" https://discord.com/api/webhooks/1/t ".into());
assert!(notify.wants(NotifyEvent::TurnDone));
assert!(notify.wants(NotifyEvent::NeedInput));
assert!(notify.has_channel());
assert!(notify.enabled_for(NotifyEvent::TurnDone));
assert_eq!(
notify.discord_webhook_url(),
Some("https://discord.com/api/webhooks/1/t")
);
notify.telegram_bot_token = Some(" tok ".into());
notify.telegram_chat_id = Some(" chat ".into());
assert_eq!(notify.telegram_token(), Some("tok"));
assert_eq!(notify.telegram_chat(), Some("chat"));
let overlay = NotifyConfig {
on: vec!["turn_done".into()],
discord_webhook: Some("https://other".into()),
telegram_bot_token: Some("b".into()),
telegram_chat_id: Some("c".into()),
timeout_secs: 12,
desktop: Some(true),
};
let merged = notify.merge_with(&overlay);
assert_eq!(merged.timeout_secs, 12);
assert!(merged.desktop_for(NotifyEvent::TurnDone));
assert!(!merged.desktop_for(NotifyEvent::NeedInput), "not in `on`");
assert_eq!(
merged.merge_with(&NotifyConfig::default()).desktop,
Some(true)
);
assert!(!NotifyConfig::default().desktop_for(NotifyEvent::TurnDone));
}
fn assert_notify_events_and_discord() {
assert_eq!(NotifyEvent::TurnDone.as_str(), "turn_done");
assert_eq!(NotifyEvent::NeedInput.as_str(), "need_input");
assert_eq!(NotifyEvent::parse("done"), Some(NotifyEvent::TurnDone));
assert_eq!(NotifyEvent::parse("input"), Some(NotifyEvent::NeedInput));
assert_eq!(NotifyEvent::parse("nope"), None);
assert_eq!(
parse_notify_on_csv("turn_done, need_input"),
vec!["turn_done", "need_input"]
);
assert_eq!(HookEvent::default(), HookEvent::PreTool);
let hook_json = serde_json::to_string(&HookEvent::PostTool).unwrap();
assert!(hook_json.contains("post_tool"));
assert!(is_discord_webhook_url(
"https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz"
));
assert!(is_discord_webhook_url(
"https://discordapp.com/api/webhooks/1/token"
));
assert!(!is_discord_webhook_url(
"https://example.com/api/webhooks/1/t"
));
assert!(!is_discord_webhook_url("not-a-url"));
assert!(!is_discord_webhook_url("https://discord.com/api/webhooks/"));
assert_eq!(CONFIG_SCHEMA_VERSION, 1);
}
}