use crate::runtime::SUPPORTED_PROVIDERS;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ValueKind {
Text,
Bool,
Secret,
List,
}
impl ValueKind {
pub fn as_str(self) -> &'static str {
match self {
ValueKind::Text => "text",
ValueKind::Bool => "bool",
ValueKind::Secret => "secret",
ValueKind::List => "list",
}
}
}
pub struct ConfigField {
pub key: &'static str,
pub aliases: &'static [&'static str],
pub title: &'static str,
pub description: &'static str,
pub kind: ValueKind,
pub default: Option<&'static str>,
pub examples: &'static [&'static str],
pub provider_scoped: bool,
}
pub fn schema() -> &'static [ConfigField] {
const FIELDS: &[ConfigField] = &[
ConfigField {
key: "default_provider",
aliases: &["provider"],
title: "Default provider",
description: "The model provider used when no --provider flag is given. A value set \
here takes precedence over environment-credential auto-detection, which \
applies only when this is unset. Persisted as `default_provider` (the \
legacy `provider` key is still read, and accepted as an alias).",
kind: ValueKind::Text,
default: Some("openai (auto-detected from available credentials)"),
examples: &[
"anthropic",
"codex",
"openai",
"google",
"openrouter",
"ollama",
],
provider_scoped: false,
},
ConfigField {
key: "default_model",
aliases: &["model"],
title: "Default model",
description: "Global fallback model spec applied to the active provider when that \
provider has no per-provider entry under `models`. Provider-relative, \
same `model [reasoning-effort]` form `/setup model` accepts. A \
per-provider `models.<provider>` pick always wins over this. Applied \
only when the model id is recognized for the active provider.",
kind: ValueKind::Text,
default: Some("the active provider's built-in default model"),
examples: &["claude-sonnet-4-5", "gpt-5.5 high", "gemini-2.5-pro"],
provider_scoped: false,
},
ConfigField {
key: "models",
aliases: &["model_for"],
title: "Per-provider model",
description: "Model spec remembered for a specific provider, so a pick survives \
restarts and provider switches. Addressed as `models.<provider>`.",
kind: ValueKind::Text,
default: None,
examples: &[
"models.openai = gpt-5.5 high",
"models.codex = gpt-5.5 high",
"models.anthropic = claude-opus-4-5",
],
provider_scoped: true,
},
ConfigField {
key: "tokens",
aliases: &["token"],
title: "Provider API token",
description: "API token for a provider, stored owner-only (0o600). Environment \
variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, …) always override the \
stored value. Addressed as `tokens.<provider>`.",
kind: ValueKind::Secret,
default: None,
examples: &["tokens.openai = sk-…", "tokens.anthropic = …"],
provider_scoped: true,
},
ConfigField {
key: "base_urls",
aliases: &["base_url", "url"],
title: "Provider endpoint base URL",
description: "Endpoint base URL for a provider. Used by the `custom` \
OpenAI-compatible provider (vLLM, llama.cpp, LM Studio, gateways). \
Addressed as `base_urls.<provider>`; must start with http:// or https://.",
kind: ValueKind::Text,
default: None,
examples: &["base_urls.custom = http://localhost:8000/v1"],
provider_scoped: true,
},
ConfigField {
key: "approval_mode",
aliases: &["approval"],
title: "Soft-approval paranoia level",
description: "How cautious yolop is about confirming critical actions: `protective` \
asks before any state change, `normal` asks only before destructive or \
outward-facing actions, `off` never asks. Common synonyms like \
`paranoid` and `yolo` are also accepted.",
kind: ValueKind::Text,
default: Some("normal"),
examples: &["protective", "normal", "off"],
provider_scoped: false,
},
ConfigField {
key: "attribution",
aliases: &[],
title: "Commit & PR attribution",
description: "When on, yolop appends a Co-Authored-By git trailer to commits it \
makes and a footer to PR descriptions it writes.",
kind: ValueKind::Bool,
default: Some("on"),
examples: &["on", "off"],
provider_scoped: false,
},
ConfigField {
key: "proactive_wake",
aliases: &["background_wake", "wake"],
title: "Proactive background wake",
description: "When on, the TUI auto-starts a turn so the agent reacts as soon as a \
background task finishes (instead of waiting for your next prompt). Turn \
off for a quieter session where you review finished tasks yourself.",
kind: ValueKind::Bool,
default: Some("on"),
examples: &["on", "off"],
provider_scoped: false,
},
ConfigField {
key: "worktrees",
aliases: &[],
title: "Git worktree isolation",
description: "Controls session worktrees for code changes: `auto` creates one when a \
prompt looks like implementation work, `always` uses a worktree in git \
repos from the start, `off` disables worktrees.",
kind: ValueKind::Text,
default: Some("auto"),
examples: &["auto", "always", "off"],
provider_scoped: false,
},
ConfigField {
key: "capabilities",
aliases: &["capability"],
title: "Harness capabilities",
description: "Ordered `[[capabilities]]` overrides applied on top of the default \
harness. Each entry has a `ref` (capability id), optional \
`enabled=false` to remove every instance with that ref, optional \
`append=true` to add a duplicate instance, and capability-specific \
config keys validated via `config_schema` / `validate_config`. Use \
`get_config key=capabilities` for the full registered catalog, or \
`get_config key=capabilities.<ref>` for one capability's schema \
metadata. Append entries with `set_config key=capabilities json=…`; \
pass `value=clear` to drop all stored overrides.",
kind: ValueKind::List,
default: None,
examples: &[
"capabilities.message_metadata",
"set_config key=capabilities json={\"ref\":\"message_metadata\",\"config\":{\"fields\":[\"timestamp\"]}}",
],
provider_scoped: true,
},
];
FIELDS
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum KeyTarget {
DefaultProvider,
DefaultModel,
Attribution,
ApprovalMode,
ProactiveWake,
Worktrees,
Model(String),
Token(String),
BaseUrl(String),
Capabilities,
CapabilityRef(String),
}
impl KeyTarget {
pub fn field(&self) -> &'static ConfigField {
let key = match self {
KeyTarget::DefaultProvider => "default_provider",
KeyTarget::DefaultModel => "default_model",
KeyTarget::Attribution => "attribution",
KeyTarget::ApprovalMode => "approval_mode",
KeyTarget::ProactiveWake => "proactive_wake",
KeyTarget::Worktrees => "worktrees",
KeyTarget::Model(_) => "models",
KeyTarget::Token(_) => "tokens",
KeyTarget::BaseUrl(_) => "base_urls",
KeyTarget::Capabilities | KeyTarget::CapabilityRef(_) => "capabilities",
};
schema()
.iter()
.find(|f| f.key == key)
.expect("KeyTarget always maps to a schema field")
}
}
pub fn parse_key(input: &str) -> Result<KeyTarget, String> {
let norm = input.trim().to_ascii_lowercase();
if norm.is_empty() {
return Err(format!("empty config key; known keys: {}", known_keys()));
}
let (head, sub) = match norm.split_once('.') {
Some((h, t)) => (h, Some(t.trim())),
None => (norm.as_str(), None),
};
let scalar = |target: KeyTarget| -> Result<KeyTarget, String> {
if sub.is_some() {
return Err(format!(
"`{head}` is a scalar key and takes no `.<provider>` segment"
));
}
Ok(target)
};
let scoped = |make: fn(String) -> KeyTarget| -> Result<KeyTarget, String> {
let provider = sub.filter(|s| !s.is_empty()).ok_or_else(|| {
format!("`{head}` is per-provider; address it as `{head}.<provider>`")
})?;
if !SUPPORTED_PROVIDERS.contains(&provider) {
return Err(format!(
"unknown provider `{provider}`; expected one of {}",
SUPPORTED_PROVIDERS.join(", ")
));
}
Ok(make(provider.to_string()))
};
match head {
"default_provider" | "provider" => scalar(KeyTarget::DefaultProvider),
"default_model" | "model" => scalar(KeyTarget::DefaultModel),
"attribution" => scalar(KeyTarget::Attribution),
"approval_mode" | "approval" => scalar(KeyTarget::ApprovalMode),
"proactive_wake" | "background_wake" | "wake" => scalar(KeyTarget::ProactiveWake),
"worktrees" | "worktree" => scalar(KeyTarget::Worktrees),
"models" | "model_for" => scoped(KeyTarget::Model),
"tokens" | "token" => scoped(KeyTarget::Token),
"base_urls" | "base_url" | "url" => scoped(KeyTarget::BaseUrl),
"capabilities" | "capability" => {
if let Some(cap_ref) = sub.filter(|s| !s.is_empty()) {
Ok(KeyTarget::CapabilityRef(cap_ref.to_string()))
} else {
Ok(KeyTarget::Capabilities)
}
}
_ => Err(format!(
"unknown config key `{input}`; known keys: {}",
known_keys()
)),
}
}
pub fn known_keys() -> String {
schema()
.iter()
.map(|f| {
if f.provider_scoped {
format!("{}.<provider>", f.key)
} else {
f.key.to_string()
}
})
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aliases_resolve_to_canonical_targets() {
assert_eq!(parse_key("provider").unwrap(), KeyTarget::DefaultProvider);
assert_eq!(
parse_key("default_provider").unwrap(),
KeyTarget::DefaultProvider
);
assert_eq!(parse_key("model").unwrap(), KeyTarget::DefaultModel);
assert_eq!(parse_key("default_model").unwrap(), KeyTarget::DefaultModel);
assert_eq!(parse_key("approval").unwrap(), KeyTarget::ApprovalMode);
assert_eq!(parse_key("approval_mode").unwrap(), KeyTarget::ApprovalMode);
}
#[test]
fn provider_scoped_keys_parse_and_validate() {
assert_eq!(
parse_key("tokens.openai").unwrap(),
KeyTarget::Token("openai".to_string())
);
assert_eq!(
parse_key("url.custom").unwrap(),
KeyTarget::BaseUrl("custom".to_string())
);
assert_eq!(
parse_key("Models.Anthropic").unwrap(),
KeyTarget::Model("anthropic".to_string())
);
}
#[test]
fn scalar_key_rejects_provider_segment() {
assert!(parse_key("attribution.openai").is_err());
assert!(parse_key("default_model.openai").is_err());
}
#[test]
fn provider_scoped_requires_segment_and_known_provider() {
assert!(parse_key("tokens").unwrap_err().contains("per-provider"));
assert!(
parse_key("tokens.nope")
.unwrap_err()
.contains("unknown provider")
);
}
#[test]
fn unknown_key_lists_known_keys() {
let err = parse_key("frobnicate").unwrap_err();
assert!(err.contains("default_provider"));
assert!(err.contains("tokens.<provider>"));
}
#[test]
fn capabilities_keys_parse() {
assert_eq!(parse_key("capabilities").unwrap(), KeyTarget::Capabilities);
assert_eq!(
parse_key("capabilities.message_metadata").unwrap(),
KeyTarget::CapabilityRef("message_metadata".to_string())
);
}
#[test]
fn every_target_maps_to_a_field() {
for target in [
KeyTarget::DefaultProvider,
KeyTarget::DefaultModel,
KeyTarget::Attribution,
KeyTarget::ApprovalMode,
KeyTarget::Model("openai".into()),
KeyTarget::Token("openai".into()),
KeyTarget::BaseUrl("custom".into()),
KeyTarget::Capabilities,
KeyTarget::CapabilityRef("message_metadata".into()),
] {
let _ = target.field();
}
}
}