use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use supercode_harness::configfile::{
merge_permissions_capability, CapabilityConfig, CoreSection, HarnessConfig,
};
use supercode_harness::{ApprovalPolicy, SandboxPolicy};
pub const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-8";
pub const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct FileConfig {
pub model: Option<String>,
pub base_url: Option<String>,
pub effort: Option<String>,
pub sandbox: Option<String>,
pub approval: Option<String>,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub project_context: Option<bool>,
pub system_prompt: Option<String>,
pub append_system_prompt: Option<String>,
pub api_key_cmd: Option<String>,
pub reduce: Option<bool>,
pub schema_tier: Option<String>,
pub cache_warnings: Option<bool>,
#[serde(default)]
pub hooks: crate::hooks::HooksFileConfig,
pub notify: Option<bool>,
pub notify_threshold_secs: Option<u64>,
pub notify_email: Option<NotifyEmailConfig>,
#[serde(default)]
pub core: CoreSection,
#[serde(default)]
pub extends: Option<String>,
#[serde(default)]
pub capabilities: BTreeMap<String, CapabilityConfig>,
#[serde(default)]
pub experimental: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub profiles: BTreeMap<String, Box<FileConfig>>,
#[serde(default)]
pub credentials: CredentialsConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct CredentialsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialStore {
File,
Keyring,
}
pub const KEYRING_SERVICE: &str = "supercode";
pub const KEYRING_ACCOUNT: &str = "api_key";
impl CredentialStore {
pub fn parse(value: Option<&str>) -> CredentialStore {
match value.map(str::trim) {
Some("keyring") => CredentialStore::Keyring,
_ => CredentialStore::File,
}
}
pub fn label(self) -> &'static str {
match self {
CredentialStore::File => "credentials.toml",
CredentialStore::Keyring => "OS keyring",
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NotifyEmailConfig {
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
pub from: String,
pub to: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
}
fn default_smtp_port() -> u16 {
25
}
const PROJECT_ALLOWED_CAPABILITY_ENABLE: &[&str] = &["reduction"];
const PROJECT_FORBIDDEN_CAPABILITY_TABLES: &[&str] =
&["hooks", "plugins", "server", "integrations", "trust"];
fn is_loosening_sandbox(v: &str) -> bool {
crate::parse_sandbox(v) == Some(SandboxPolicy::DangerFullAccess)
}
fn is_loosening_approval(v: &str) -> bool {
crate::parse_approval(v) == Some(ApprovalPolicy::Never)
}
fn sandbox_rank(p: SandboxPolicy) -> u8 {
match p {
SandboxPolicy::ReadOnly => 0,
SandboxPolicy::WorkspaceWrite => 1,
SandboxPolicy::DangerFullAccess => 2,
}
}
fn approval_rank(p: ApprovalPolicy) -> u8 {
match p {
ApprovalPolicy::Untrusted => 0,
ApprovalPolicy::OnRequest => 1,
ApprovalPolicy::ModelRequested => 2,
ApprovalPolicy::Never => 3,
}
}
fn clamp_sandbox(
user: Option<&str>,
project: Option<&str>,
clamped: &mut Vec<String>,
) -> Option<String> {
let Some(proj_raw) = project else {
return user.map(str::to_string);
};
let user_effective = user
.and_then(crate::parse_sandbox)
.unwrap_or(SandboxPolicy::WorkspaceWrite);
match crate::parse_sandbox(proj_raw) {
Some(p) if sandbox_rank(p) <= sandbox_rank(user_effective) => Some(proj_raw.to_string()),
_ => {
clamped.push("sandbox".to_string());
user.map(str::to_string)
}
}
}
fn clamp_approval(
user: Option<&str>,
project: Option<&str>,
clamped: &mut Vec<String>,
) -> Option<String> {
let Some(proj_raw) = project else {
return user.map(str::to_string);
};
let user_effective = user
.and_then(crate::parse_approval)
.unwrap_or(ApprovalPolicy::OnRequest);
match crate::parse_approval(proj_raw) {
Some(p) if approval_rank(p) <= approval_rank(user_effective) => Some(proj_raw.to_string()),
_ => {
clamped.push("approval".to_string());
user.map(str::to_string)
}
}
}
fn is_preset_path(v: &str) -> bool {
v.contains('/') || v.contains('\\') || v.to_ascii_lowercase().ends_with(".toml")
}
fn sanitize_capabilities(
capabilities: BTreeMap<String, CapabilityConfig>,
dropped: &mut Vec<String>,
) -> BTreeMap<String, CapabilityConfig> {
let mut out = BTreeMap::new();
for (name, mut cap) in capabilities {
if PROJECT_FORBIDDEN_CAPABILITY_TABLES.contains(&name.as_str()) {
dropped.push(format!("capabilities.{name}"));
continue;
}
if name == "mcp" {
if cap.settings.remove("servers").is_some() {
dropped.push("capabilities.mcp.servers".to_string());
}
if cap
.settings
.get("serve")
.and_then(serde_json::Value::as_bool)
== Some(true)
{
cap.settings.remove("serve");
dropped.push("capabilities.mcp.serve".to_string());
}
}
if name == "notify" && cap.settings.remove("email").is_some() {
dropped.push("capabilities.notify.email".to_string());
}
if name == "lsp" {
if cap.settings.remove("servers").is_some() {
dropped.push("capabilities.lsp.servers".to_string());
}
}
if name == "formatters" {
let formatter_keys: Vec<String> = cap
.settings
.keys()
.filter(|k| !matches!(k.as_str(), "diff_back" | "timeout_secs"))
.cloned()
.collect();
for key in formatter_keys {
cap.settings.remove(&key);
dropped.push(format!("capabilities.formatters.{key}"));
}
if cap
.settings
.get("diff_back")
.and_then(serde_json::Value::as_bool)
== Some(false)
{
cap.settings.remove("diff_back");
dropped.push("capabilities.formatters.diff_back".to_string());
}
}
if name == "permissions" {
match cap.settings.get("sandbox") {
Some(serde_json::Value::String(sb)) if is_loosening_sandbox(sb) => {
cap.settings.remove("sandbox");
dropped.push("capabilities.permissions.sandbox".to_string());
}
Some(serde_json::Value::Object(_)) => {
if let Some(tbl) = cap
.settings
.get_mut("sandbox")
.and_then(|v| v.as_object_mut())
{
if let Some(tier) = tbl.get("tier").and_then(|v| v.as_str()) {
if is_loosening_sandbox(tier) {
tbl.remove("tier");
dropped.push("capabilities.permissions.sandbox.tier".to_string());
}
}
if matches!(tbl.get("enabled"), Some(serde_json::Value::Bool(false))) {
tbl.remove("enabled");
dropped.push("capabilities.permissions.sandbox.enabled".to_string());
}
if let Some(esc) = tbl.get("escalation").and_then(|v| v.as_str()) {
if supercode_harness::sandbox::SandboxEscalation::parse(esc)
== Some(supercode_harness::sandbox::SandboxEscalation::Allow)
{
tbl.remove("escalation");
dropped.push(
"capabilities.permissions.sandbox.escalation".to_string(),
);
}
}
if let Some(ep) = tbl.get("env_policy").and_then(|v| v.as_str()) {
if supercode_harness::sandbox::SandboxEnvPolicy::parse(ep)
== Some(supercode_harness::sandbox::SandboxEnvPolicy::Inherit)
{
tbl.remove("env_policy");
dropped.push(
"capabilities.permissions.sandbox.env_policy".to_string(),
);
}
}
if let Some(net) = tbl.get_mut("network").and_then(|v| v.as_object_mut()) {
for k in ["allow_domains", "deny_domains"] {
if net.remove(k).is_some() {
dropped.push(format!(
"capabilities.permissions.sandbox.network.{k}"
));
}
}
if matches!(net.get("enabled"), Some(serde_json::Value::Bool(false))) {
net.remove("enabled");
dropped.push(
"capabilities.permissions.sandbox.network.enabled".to_string(),
);
}
}
}
}
_ => {}
}
if let Some(ap) = cap.settings.get("approval").and_then(|v| v.as_str()) {
if is_loosening_approval(ap) {
cap.settings.remove("approval");
dropped.push("capabilities.permissions.approval".to_string());
}
}
if cap.settings.remove("auto_approved_tools").is_some() {
dropped.push("capabilities.permissions.auto_approved_tools".to_string());
}
if let Some(rules) = cap
.settings
.get_mut("rules")
.and_then(|v| v.as_object_mut())
{
if rules.remove("allow").is_some() {
dropped.push("capabilities.permissions.rules.allow".to_string());
}
}
}
if matches!(
name.as_str(),
"tools_web" | "tools_background" | "telemetry" | "session_share"
) && cap.enabled == Some(true)
{
cap.enabled = None;
dropped.push(format!("capabilities.{name}.enabled"));
}
if cap.enabled == Some(true) && !PROJECT_ALLOWED_CAPABILITY_ENABLE.contains(&name.as_str())
{
cap.enabled = None;
dropped.push(format!("capabilities.{name}.enabled"));
}
out.insert(name, cap);
}
out
}
impl FileConfig {
fn sanitized_for_layer(self, layer: &str) -> FileConfig {
let mut dropped = Vec::new();
if self.base_url.is_some() {
dropped.push("base_url".to_string());
}
if self.system_prompt.is_some() {
dropped.push("system_prompt".to_string());
}
if self.append_system_prompt.is_some() {
dropped.push("append_system_prompt".to_string());
}
if self.api_key_cmd.is_some() {
dropped.push("api_key_cmd".to_string());
}
let sandbox = match self.sandbox {
Some(sb) if is_loosening_sandbox(&sb) => {
dropped.push("sandbox".to_string());
None
}
other => other,
};
let approval = match self.approval {
Some(ap) if is_loosening_approval(&ap) => {
dropped.push("approval".to_string());
None
}
other => other,
};
if self.hooks != crate::hooks::HooksFileConfig::default() {
dropped.push("hooks".to_string());
}
if self.notify.is_some() {
dropped.push("notify".to_string());
}
if self.notify_email.is_some() {
dropped.push("notify_email".to_string());
}
let extends = match self.extends {
Some(e) if is_preset_path(&e) => {
dropped.push("extends (path)".to_string());
None
}
other => other,
};
let capabilities = sanitize_capabilities(self.capabilities, &mut dropped);
let core_probe = HarnessConfig {
schema: None,
schema_version: 1,
extends: None,
core: self.core,
capabilities: BTreeMap::new(),
experimental: serde_json::Map::new(),
};
let (core_sanitized, core_dropped) =
supercode_harness::configfile::sanitize_for_project(&core_probe);
dropped.extend(core_dropped);
let core = core_sanitized.core;
if !self.experimental.is_empty() {
dropped.push("experimental".to_string());
}
if !self.profiles.is_empty() {
dropped.push("profiles".to_string());
}
if self.credentials != CredentialsConfig::default() {
dropped.push("credentials".to_string());
}
if !dropped.is_empty() {
warn_config_once(
format!("project-drop:{layer}:{}", dropped.join(",")),
format!(
"\x1b[33mwarning: ignoring untrusted field(s) [{}] from {layer} \
— set these in your user config or via flags\x1b[0m",
dropped.join(", ")
),
);
}
FileConfig {
base_url: None,
system_prompt: None,
append_system_prompt: None,
api_key_cmd: None,
sandbox,
approval,
hooks: crate::hooks::HooksFileConfig::default(),
notify: None,
notify_email: None,
extends,
capabilities,
core,
experimental: BTreeMap::new(),
profiles: BTreeMap::new(),
credentials: CredentialsConfig::default(),
..self
}
}
fn overlay_trusted(self, over: FileConfig) -> FileConfig {
let base = self;
let mut capabilities = base.capabilities;
for (name, cap) in over.capabilities {
capabilities.insert(name, cap);
}
let core_merged = supercode_harness::configfile::HarnessConfig {
core: base.core,
..Default::default()
}
.overlay(&supercode_harness::configfile::HarnessConfig {
core: over.core,
..Default::default()
})
.core;
FileConfig {
model: over.model.or(base.model),
base_url: over.base_url.or(base.base_url),
effort: over.effort.or(base.effort),
sandbox: over.sandbox.or(base.sandbox),
approval: over.approval.or(base.approval),
temperature: over.temperature.or(base.temperature),
max_tokens: over.max_tokens.or(base.max_tokens),
project_context: over.project_context.or(base.project_context),
system_prompt: over.system_prompt.or(base.system_prompt),
append_system_prompt: over.append_system_prompt.or(base.append_system_prompt),
api_key_cmd: over.api_key_cmd.or(base.api_key_cmd),
reduce: over.reduce.or(base.reduce),
schema_tier: over.schema_tier.or(base.schema_tier),
cache_warnings: over.cache_warnings.or(base.cache_warnings),
hooks: crate::hooks::HooksFileConfig {
pre_tool: over.hooks.pre_tool.or(base.hooks.pre_tool),
post_tool: over.hooks.post_tool.or(base.hooks.post_tool),
session_start: over.hooks.session_start.or(base.hooks.session_start),
session_end: over.hooks.session_end.or(base.hooks.session_end),
stop: over.hooks.stop.or(base.hooks.stop),
user_prompt_submit: over
.hooks
.user_prompt_submit
.or(base.hooks.user_prompt_submit),
notification: over.hooks.notification.or(base.hooks.notification),
subagent_start: over.hooks.subagent_start.or(base.hooks.subagent_start),
subagent_stop: over.hooks.subagent_stop.or(base.hooks.subagent_stop),
pre_compact: over.hooks.pre_compact.or(base.hooks.pre_compact),
post_compact: over.hooks.post_compact.or(base.hooks.post_compact),
timeout_ms: over.hooks.timeout_ms.or(base.hooks.timeout_ms),
},
notify: over.notify.or(base.notify),
notify_threshold_secs: over.notify_threshold_secs.or(base.notify_threshold_secs),
notify_email: over.notify_email.or(base.notify_email),
extends: over.extends.or(base.extends),
capabilities,
core: core_merged,
experimental: {
let mut e = base.experimental;
e.extend(over.experimental);
e
},
profiles: base.profiles,
credentials: CredentialsConfig {
store: over.credentials.store.or(base.credentials.store),
},
}
}
fn overlay_project(self, project: FileConfig) -> FileConfig {
let trusted = self;
let mut clamped = Vec::new();
let sandbox = clamp_sandbox(
trusted.sandbox.as_deref(),
project.sandbox.as_deref(),
&mut clamped,
);
let approval = clamp_approval(
trusted.approval.as_deref(),
project.approval.as_deref(),
&mut clamped,
);
if !clamped.is_empty() {
use std::sync::Once;
static WARNED: Once = Once::new();
WARNED.call_once(|| {
eprintln!(
"\x1b[33mwarning: a project .supercode.toml attempted to WIDEN [{}] beyond \
your own config — clamped to the stricter value (§3.3 monotonic tightening)\x1b[0m",
clamped.join(", ")
);
});
}
let mut capabilities = trusted.capabilities;
for (name, cap) in project.capabilities {
if name == "permissions" {
let trusted_permissions = capabilities.get("permissions").cloned();
if let Some(merged) =
merge_permissions_capability(trusted_permissions.as_ref(), Some(&cap))
{
let base_hc = supercode_harness::configfile::HarnessConfig {
capabilities: trusted_permissions
.clone()
.map(|c| BTreeMap::from([("permissions".to_string(), c)]))
.unwrap_or_default(),
..Default::default()
};
let project_hc = supercode_harness::configfile::HarnessConfig {
capabilities: BTreeMap::from([("permissions".to_string(), cap.clone())]),
..Default::default()
};
let mut merged_hc = supercode_harness::configfile::HarnessConfig {
capabilities: BTreeMap::from([("permissions".to_string(), merged)]),
..Default::default()
};
let more_clamped = supercode_harness::configfile::clamp_project_permissions(
&base_hc,
&project_hc,
&mut merged_hc,
);
if !more_clamped.is_empty() {
use std::sync::Once;
static WARNED2: Once = Once::new();
WARNED2.call_once(|| {
eprintln!(
"\x1b[33mwarning: a project .supercode.toml attempted to WIDEN \
[{}] beyond your own config — clamped to the stricter value \
(§3.3 monotonic tightening)\x1b[0m",
more_clamped.join(", ")
);
});
}
if let Some(final_permissions) = merged_hc.capabilities.remove("permissions") {
capabilities.insert(name, final_permissions);
}
}
continue;
}
if name == "reduction" {
let trusted_reduction = capabilities.get("reduction").cloned();
if let Some(merged) = supercode_harness::configfile::merge_reduction_capability(
trusted_reduction.as_ref(),
Some(&cap),
) {
capabilities.insert(name, merged);
}
continue;
}
capabilities.insert(name, cap);
}
let core = HarnessConfig {
core: trusted.core,
..Default::default()
}
.overlay(&HarnessConfig {
core: project.core,
..Default::default()
})
.core;
FileConfig {
model: project.model.or(trusted.model),
base_url: project.base_url.or(trusted.base_url),
effort: project.effort.or(trusted.effort),
sandbox,
approval,
temperature: project.temperature.or(trusted.temperature),
max_tokens: project.max_tokens.or(trusted.max_tokens),
project_context: project.project_context.or(trusted.project_context),
system_prompt: project.system_prompt.or(trusted.system_prompt),
append_system_prompt: project
.append_system_prompt
.or(trusted.append_system_prompt),
api_key_cmd: project.api_key_cmd.or(trusted.api_key_cmd),
reduce: project.reduce.or(trusted.reduce),
schema_tier: project.schema_tier.or(trusted.schema_tier),
cache_warnings: project.cache_warnings.or(trusted.cache_warnings),
hooks: crate::hooks::HooksFileConfig {
pre_tool: project.hooks.pre_tool.or(trusted.hooks.pre_tool),
post_tool: project.hooks.post_tool.or(trusted.hooks.post_tool),
session_start: project.hooks.session_start.or(trusted.hooks.session_start),
session_end: project.hooks.session_end.or(trusted.hooks.session_end),
stop: project.hooks.stop.or(trusted.hooks.stop),
user_prompt_submit: project
.hooks
.user_prompt_submit
.or(trusted.hooks.user_prompt_submit),
notification: project.hooks.notification.or(trusted.hooks.notification),
subagent_start: project
.hooks
.subagent_start
.or(trusted.hooks.subagent_start),
subagent_stop: project.hooks.subagent_stop.or(trusted.hooks.subagent_stop),
pre_compact: project.hooks.pre_compact.or(trusted.hooks.pre_compact),
post_compact: project.hooks.post_compact.or(trusted.hooks.post_compact),
timeout_ms: project.hooks.timeout_ms.or(trusted.hooks.timeout_ms),
},
notify: project.notify.or(trusted.notify),
notify_threshold_secs: project
.notify_threshold_secs
.or(trusted.notify_threshold_secs),
notify_email: project.notify_email.or(trusted.notify_email),
extends: project.extends.or(trusted.extends),
capabilities,
core,
experimental: {
let mut e = trusted.experimental;
e.extend(project.experimental);
e
},
profiles: trusted.profiles,
credentials: CredentialsConfig {
store: project.credentials.store.or(trusted.credentials.store),
},
}
}
}
pub fn config_home() -> PathBuf {
if let Ok(h) = std::env::var("SUPERCODE_HOME") {
if !h.is_empty() {
return PathBuf::from(h);
}
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("supercode");
}
}
let home = supercode_interchange::user_home()
.map(|home| home.to_string_lossy().into_owned())
.ok_or(std::env::VarError::NotPresent)
.unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config").join("supercode")
}
pub fn config_file() -> PathBuf {
config_home().join("config.toml")
}
pub fn credentials_file() -> PathBuf {
config_home().join("credentials.toml")
}
#[derive(Default, Deserialize, Serialize)]
struct Credentials {
api_key: Option<String>,
}
pub fn load(cwd: &std::path::Path) -> FileConfig {
load_with_trusted_extends(cwd).0
}
pub fn load_with_trusted_extends(cwd: &std::path::Path) -> (FileConfig, Option<String>) {
load_layered(cwd, &LoadOptions::default()).unwrap_or_else(|e| {
warn_config_once(format!("layered-load:{e}"), format!("warning: {e}"));
(FileConfig::default(), None)
})
}
#[derive(Debug, Clone, Default)]
pub struct LoadOptions {
pub profile: Option<String>,
pub settings: Vec<String>,
pub overrides: Vec<String>,
}
pub fn load_layered(
cwd: &std::path::Path,
opts: &LoadOptions,
) -> Result<(FileConfig, Option<String>), String> {
let user = read_file_config(&config_file());
let trusted_extends = user.extends.clone();
let markers = user
.core
.project_root_markers
.clone()
.unwrap_or_else(|| vec![".git".to_string()]);
let mut merged = user;
if let Some(name) = opts.profile.as_deref() {
let bundle = merged
.profiles
.get(name)
.map(|b| (**b).clone())
.ok_or_else(|| {
let known: Vec<&str> = merged.profiles.keys().map(String::as_str).collect();
if known.is_empty() {
format!(
"no profile `{name}`: this config defines no [profiles.*] table \
(add one to {})",
config_file().display()
)
} else {
format!("no profile `{name}` (known profiles: {})", known.join(", "))
}
})?;
merged = merged.overlay_trusted(bundle);
}
for name in [PROJECT_CONFIG_FILE, PROJECT_LOCAL_CONFIG_FILE] {
let Some(path) = find_upward(cwd, name, &markers) else {
continue;
};
merged = merged.overlay_project(read_file_config(&path).sanitized_for_layer(name));
}
Ok((apply_run_layers(merged, opts)?, trusted_extends))
}
pub fn apply_run_layers(base: FileConfig, opts: &LoadOptions) -> Result<FileConfig, String> {
let mut merged = base;
for spec in &opts.settings {
merged = merged.overlay_trusted(read_settings_layer(spec)?);
}
if !opts.overrides.is_empty() {
let text = supercode_harness::configfile::overrides_to_toml(&opts.overrides)
.map_err(|e| e.to_string())?;
let layer: FileConfig = toml::from_str(&text).map_err(|e| {
format!("invalid inline config override: {e} (assembled from `{text}`)")
})?;
merged = merged.overlay_trusted(layer);
}
Ok(merged)
}
fn read_settings_layer(spec: &str) -> Result<FileConfig, String> {
let trimmed = spec.trim();
if trimmed.starts_with('{') {
return serde_json::from_str(trimmed).map_err(|e| format!("invalid --settings JSON: {e}"));
}
let path = std::path::Path::new(trimmed);
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read --settings {}: {e}", path.display()))?;
if path.extension().is_some_and(|e| e == "toml") {
toml::from_str(&text).map_err(|e| format!("invalid --settings {}: {e}", path.display()))
} else {
serde_json::from_str(&text)
.map_err(|e| format!("invalid --settings {}: {e}", path.display()))
}
}
fn read_file_config(path: &std::path::Path) -> FileConfig {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return FileConfig::default(),
Err(e) => {
warn_config_once(
format!(
"read:{}:{:?}:{}",
path.display(),
e.kind(),
supercode_harness::reduce::content_hash(e.to_string().as_bytes())
),
format!(
"warning: failed to read config `{}` ({e}) — ignoring this file",
path.display()
),
);
return FileConfig::default();
}
};
match toml::from_str::<FileConfig>(&text) {
Ok(config) => config,
Err(e) => {
let (key, location) = parse_config_diagnostic_identity(path, &e);
warn_config_once(
key,
format!(
"warning: failed to parse config `{}` (TOML syntax/schema error{location}) \
— ignoring this file",
path.display(),
),
);
FileConfig::default()
}
}
}
fn parse_config_diagnostic_identity(
path: &std::path::Path,
error: &toml::de::Error,
) -> (String, String) {
let fingerprint = supercode_harness::reduce::content_hash(error.to_string().as_bytes());
let location = error
.span()
.map(|span| format!(" near byte {}", span.start))
.unwrap_or_default();
(
format!("parse:{}:{location}:{fingerprint}", path.display()),
location,
)
}
fn warn_config_once(key: String, message: String) {
static SEEN: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
let mut seen = seen.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if seen.insert(key) {
eprintln!("{message}");
}
}
pub const PROJECT_CONFIG_FILE: &str = ".supercode.toml";
pub const PROJECT_LOCAL_CONFIG_FILE: &str = ".supercode.local.toml";
fn find_upward(cwd: &std::path::Path, name: &str, markers: &[String]) -> Option<PathBuf> {
let root = supercode_harness::project_root_for(cwd, markers);
let mut dir = Some(cwd);
while let Some(d) = dir {
let candidate = d.join(name);
if candidate.is_file() {
return Some(candidate);
}
if root.as_deref() == Some(d) {
return None;
}
dir = d.parent();
}
None
}
fn read_toml<T: for<'de> Deserialize<'de>>(path: &std::path::Path) -> Option<T> {
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
fn eligible_env_vars(base_url: &str) -> &'static [&'static str] {
if base_url.to_ascii_lowercase().contains("openrouter") {
&["OPENROUTER_API_KEY"]
} else {
&["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
}
}
pub fn credential_store() -> CredentialStore {
CredentialStore::parse(
read_file_config(&config_file())
.credentials
.store
.as_deref(),
)
}
pub fn keyring_get() -> Option<String> {
let out = keyring_read_command()?.output().ok()?;
if !out.status.success() {
return None;
}
let key = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!key.is_empty()).then_some(key)
}
pub fn keyring_set(key: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let out = std::process::Command::new("security")
.args([
"add-generic-password",
"-U",
"-s",
KEYRING_SERVICE,
"-a",
KEYRING_ACCOUNT,
"-w",
key,
])
.output()
.map_err(|e| format!("`security` is not available ({e})"))?;
if !out.status.success() {
return Err(format!(
"`security add-generic-password` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
return Ok(());
}
#[cfg(target_os = "linux")]
{
use std::io::Write;
let mut child = std::process::Command::new("secret-tool")
.args([
"store",
"--label=supercode",
"service",
KEYRING_SERVICE,
"account",
KEYRING_ACCOUNT,
])
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
format!("`secret-tool` is not available ({e}); install libsecret-tools")
})?;
child
.stdin
.as_mut()
.ok_or_else(|| "cannot write to secret-tool".to_string())?
.write_all(key.as_bytes())
.map_err(|e| format!("cannot write to secret-tool: {e}"))?;
let status = child
.wait()
.map_err(|e| format!("secret-tool did not finish: {e}"))?;
if !status.success() {
return Err("`secret-tool store` failed".to_string());
}
return Ok(());
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = key;
Err(
"no OS keyring integration on this platform; use the default \
`[credentials] store = \"file\"`"
.to_string(),
)
}
}
fn keyring_read_command() -> Option<std::process::Command> {
#[cfg(target_os = "macos")]
{
let mut cmd = std::process::Command::new("security");
cmd.args([
"find-generic-password",
"-w",
"-s",
KEYRING_SERVICE,
"-a",
KEYRING_ACCOUNT,
]);
Some(cmd)
}
#[cfg(target_os = "linux")]
{
let mut cmd = std::process::Command::new("secret-tool");
cmd.args([
"lookup",
"service",
KEYRING_SERVICE,
"account",
KEYRING_ACCOUNT,
]);
Some(cmd)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
None
}
}
fn stored_api_key() -> Option<String> {
match credential_store() {
CredentialStore::File => {
read_toml::<Credentials>(&credentials_file()).and_then(|c| c.api_key)
}
CredentialStore::Keyring => keyring_get(),
}
}
pub fn resolve_api_key(flag: Option<&str>, base_url: &str) -> Option<String> {
if let Some(k) = flag {
if !k.is_empty() {
return Some(k.to_string());
}
}
for var in eligible_env_vars(base_url) {
if let Ok(v) = std::env::var(var) {
if !v.is_empty() {
return Some(v);
}
}
}
stored_api_key()
}
pub fn api_key_source(flag: Option<&str>, base_url: &str) -> Option<&'static str> {
if flag.map(|k| !k.is_empty()).unwrap_or(false) {
return Some("--api-key flag");
}
for var in eligible_env_vars(base_url) {
if std::env::var(var).map(|v| !v.is_empty()).unwrap_or(false) {
return Some(match *var {
"OPENROUTER_API_KEY" => "OPENROUTER_API_KEY env",
"OPENAI_API_KEY" => "OPENAI_API_KEY env",
_ => "ANTHROPIC_API_KEY env",
});
}
}
if stored_api_key().is_some() {
return Some(credential_store().label());
}
None
}
pub fn clear_api_key() -> std::io::Result<()> {
if credential_store() == CredentialStore::File {
return match std::fs::remove_file(credentials_file()) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
result => result,
};
}
#[cfg(target_os = "macos")]
let status = std::process::Command::new("security")
.args([
"delete-generic-password",
"-s",
KEYRING_SERVICE,
"-a",
KEYRING_ACCOUNT,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
#[cfg(target_os = "linux")]
let status = std::process::Command::new("secret-tool")
.args([
"clear",
"service",
KEYRING_SERVICE,
"account",
KEYRING_ACCOUNT,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
if status.success() || (cfg!(target_os = "macos") && status.code() == Some(44)) {
return Ok(());
}
Err(std::io::Error::other(
"could not remove the configured keyring credential",
))
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"keyring storage is unavailable on this platform",
))
}
pub fn save_config(cfg: &FileConfig) -> std::io::Result<()> {
std::fs::create_dir_all(config_home())?;
let text = toml::to_string_pretty(cfg).expect("serialize config");
std::fs::write(config_file(), text)
}
pub fn save_api_key(key: &str) -> std::io::Result<()> {
if credential_store() == CredentialStore::Keyring {
return keyring_set(key).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
}
std::fs::create_dir_all(config_home())?;
let creds = Credentials {
api_key: Some(key.to_string()),
};
let path = credentials_file();
std::fs::write(
&path,
toml::to_string_pretty(&creds).expect("serialize creds"),
)?;
set_owner_only(&path)?;
Ok(())
}
#[cfg(unix)]
fn set_owner_only(path: &std::path::Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
fn set_owner_only(_path: &std::path::Path) -> std::io::Result<()> {
Ok(())
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct McpServerDef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[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<std::collections::BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<std::collections::BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpOAuthServerConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpOAuthServerConfig {
pub device_authorization_endpoint: String,
pub token_endpoint: String,
pub client_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct McpServers {
#[serde(rename = "mcpServers", default)]
pub servers: std::collections::BTreeMap<String, McpServerDef>,
}
pub fn mcp_file() -> PathBuf {
config_home().join("mcp.json")
}
pub fn read_mcp_file(path: &std::path::Path) -> std::io::Result<McpServers> {
let text = std::fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn load_mcp() -> McpServers {
read_mcp_file(&mcp_file()).unwrap_or_default()
}
pub fn save_mcp(reg: &McpServers) -> std::io::Result<()> {
std::fs::create_dir_all(config_home())?;
std::fs::write(
mcp_file(),
serde_json::to_string_pretty(reg).expect("serialize mcp"),
)
}
pub fn mcp_oauth_file() -> PathBuf {
config_home().join("mcp_oauth.json")
}
#[derive(Default, Deserialize, Serialize)]
struct McpOAuthStore {
#[serde(default)]
servers: std::collections::BTreeMap<String, supercode_harness::mcp_oauth::McpOAuthTokens>,
}
pub fn load_mcp_oauth_tokens(server: &str) -> Option<supercode_harness::mcp_oauth::McpOAuthTokens> {
let store: McpOAuthStore = read_toml_or_json(&mcp_oauth_file())?;
store.servers.get(server).cloned()
}
pub fn save_mcp_oauth_tokens(
server: &str,
tokens: &supercode_harness::mcp_oauth::McpOAuthTokens,
) -> std::io::Result<()> {
std::fs::create_dir_all(config_home())?;
let mut store: McpOAuthStore = read_toml_or_json(&mcp_oauth_file()).unwrap_or_default();
store.servers.insert(server.to_string(), tokens.clone());
let path = mcp_oauth_file();
std::fs::write(
&path,
serde_json::to_string_pretty(&store).expect("serialize mcp oauth store"),
)?;
set_owner_only(&path)?;
Ok(())
}
pub fn remove_mcp_oauth_tokens(server: &str) -> std::io::Result<()> {
let Some(mut store): Option<McpOAuthStore> = read_toml_or_json(&mcp_oauth_file()) else {
return Ok(());
};
if store.servers.remove(server).is_some() {
let path = mcp_oauth_file();
std::fs::write(
&path,
serde_json::to_string_pretty(&store).expect("serialize mcp oauth store"),
)?;
set_owner_only(&path)?;
}
Ok(())
}
fn read_toml_or_json<T: serde::de::DeserializeOwned>(path: &std::path::Path) -> Option<T> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn resolve_model_alias(model: &str) -> String {
supercode_harness::model_catalog::resolve_alias(model)
}
pub fn alias_table() -> &'static [(&'static str, &'static str)] {
supercode_harness::model_catalog::DEFAULT_ALIASES
}