use serde_json::{json, Map, Value};
use crate::configfile::{HarnessConfig, MODULE_NAMES};
pub const CONFIG_SCHEMA_PATH: &str = "docs/schema/supercode-config.schema.json";
pub const CONFIG_SCHEMA_URL: &str =
"https://raw.githubusercontent.com/volter-ai/supercode/main/docs/schema/supercode-config.schema.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Str,
Int,
Num,
Bool,
StrArray,
StrMap,
AnyMap,
CapabilityMap,
}
impl Kind {
fn schema(self) -> Value {
match self {
Kind::Str => json!({ "type": "string" }),
Kind::Int => json!({ "type": "integer" }),
Kind::Num => json!({ "type": "number" }),
Kind::Bool => json!({ "type": "boolean" }),
Kind::StrArray => json!({ "type": "array", "items": { "type": "string" } }),
Kind::StrMap => {
json!({ "type": "object", "additionalProperties": { "type": "string" } })
}
Kind::AnyMap => json!({ "type": "object" }),
Kind::CapabilityMap => json!({
"type": "object",
"propertyNames": { "enum": MODULE_NAMES },
"additionalProperties": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Module master switch (§3.0: every capability table has `enabled`)."
}
},
"description": "A §2 capability module's table: `enabled` plus that module's own settings."
}
}),
}
}
#[cfg(test)]
fn sample_toml(self) -> &'static str {
match self {
Kind::Str => "\"x\"",
Kind::Int => "1",
Kind::Num => "1.5",
Kind::Bool => "true",
Kind::StrArray => "[\"x\"]",
Kind::StrMap => "{ k = \"v\" }",
Kind::AnyMap => "{ k = 1 }",
Kind::CapabilityMap => "{ permissions = { enabled = true } }",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Field {
pub path: &'static str,
pub kind: Kind,
pub description: &'static str,
}
const fn f(path: &'static str, kind: Kind, description: &'static str) -> Field {
Field {
path,
kind,
description,
}
}
pub const CONFIG_SCHEMA_FIELDS: &[Field] = &[
f(
"$schema",
Kind::Str,
"Pointer to this JSON Schema, so editors validate the file. Declarative only — supercode never fetches it.",
),
f(
"schema_version",
Kind::Int,
"Config schema version. `1` is the only version this build understands; anything else is rejected rather than reinterpreted.",
),
f(
"extends",
Kind::Str,
"A built-in preset name (`cc-parity`, `cx-parity`, `supercode-default`, …) or, in the user/global layer only, a path to another config file.",
),
f(
"core.model",
Kind::Str,
"Model id or alias for the main loop.",
),
f(
"core.base_url",
Kind::Str,
"OpenAI-compatible endpoint. Supports `${VAR}` / `${VAR:-default}` / `{file:…}` substitution. [project-forbidden]",
),
f(
"core.api_key_env",
Kind::Str,
"Environment variable name the API key is read from. [project-forbidden]",
),
f(
"core.api_key_cmd",
Kind::Str,
"Credential helper: a shell command whose trimmed stdout is the API key. [project-forbidden]",
),
f(
"core.api_key_command",
Kind::StrArray,
"Credential helper as argv (exec'd directly, no shell); its trimmed stdout is the API key. Consulted before `api_key_cmd`. [project-forbidden]",
),
f(
"core.update_check",
Kind::Bool,
"Check for a newer release at startup. Opt-in: absent/false means no startup network access.",
),
f(
"core.effort",
Kind::Str,
"Reasoning-effort level passed to the provider (`low` | `medium` | `high`).",
),
f(
"core.temperature",
Kind::Num,
"Sampling temperature.",
),
f(
"core.max_tokens",
Kind::Int,
"Max output tokens per model turn.",
),
f(
"core.max_iterations",
Kind::Int,
"Per-run tool-use iteration budget (must be >= 1).",
),
f(
"core.max_total_output_tokens",
Kind::Int,
"Cap on cumulative completion tokens across one run; 0/absent = off.",
),
f(
"core.max_budget_usd",
Kind::Num,
"Cap on the cumulative dollar cost of one run; 0/absent = off. Refused at startup for a model this build cannot price.",
),
f(
"core.max_steps",
Kind::Int,
"Cap on the number of tool calls executed across one run; 0/absent = off. Distinct from `max_iterations` (model round-trips).",
),
f(
"core.price_input_per_mtok",
Kind::Num,
"Dollars per million input tokens for this model, overriding the built-in price table. Set together with `price_output_per_mtok`.",
),
f(
"core.price_output_per_mtok",
Kind::Num,
"Dollars per million output tokens for this model.",
),
f(
"core.max_tool_output_bytes",
Kind::Int,
"Truncation cap on a single tool result.",
),
f(
"core.parallel_tool_calls",
Kind::Bool,
"Execute independent tool calls from one turn concurrently.",
),
f(
"core.tool_output_spill",
Kind::Bool,
"Write a truncated tool result's full bytes to a per-session spill file the model can read back.",
),
f(
"core.shell_env_snapshot",
Kind::Bool,
"Snapshot the login shell's environment for shell tool calls.",
),
f(
"core.system_prompt",
Kind::Str,
"Replace the system prompt. Supports `${VAR}` / `{file:…}` substitution. [project-forbidden]",
),
f(
"core.append_system_prompt",
Kind::Str,
"Append to the system prompt rather than replacing it. [project-forbidden]",
),
f(
"core.project_context",
Kind::Bool,
"Auto-load CLAUDE.md / AGENTS.md instruction files.",
),
f(
"core.env_context",
Kind::Bool,
"Append an `# Environment` block (cwd, platform, date, git branch at the project root).",
),
f(
"core.context_injections",
Kind::Bool,
"Append the configured synthetic context blocks to the system prompt.",
),
f(
"core.nested_instructions",
Kind::Bool,
"Load instruction files from subdirectories on demand.",
),
f(
"core.instruction_imports",
Kind::Bool,
"Expand `@relative/path` imports inside instruction files.",
),
f(
"core.project_root_markers",
Kind::StrArray,
"Filenames/directories that mark the project root; every root walk stops at the first one. Defaults to [\".git\"].",
),
f(
"core.hot_reload",
Kind::Bool,
"Reserved: live-apply config edits without restart. Parsed and round-tripped, with no consumer in this build.",
),
f(
"core.project_doc_max_bytes",
Kind::Int,
"Hygiene cap on the total bytes of assembled instruction-file content.",
),
f(
"core.project_doc_excludes",
Kind::StrArray,
"Glob/path patterns naming instruction files to skip when assembling project context.",
),
f(
"core.project_doc_strip_comments",
Kind::Bool,
"Drop `<!-- … -->` spans from instruction files before injecting them.",
),
f(
"core.file_mentions",
Kind::Bool,
"Expand `@path` tokens in a prompt into that file's contents, subject to the \
permission engine's read rules.",
),
f(
"core.output_style",
Kind::Str,
"Named response-style layer appended to the system prompt (a built-in style, or a \
markdown file under the harness's own output-style roots).",
),
f(
"core.path_rules",
Kind::Bool,
"Load `.claude/rules/*.md` rule files; a rule with `paths:` frontmatter is injected \
only when a tool touches a matching file.",
),
f(
"core.additional_dirs",
Kind::StrArray,
"Extra roots tools may access. A project layer may only add contained relative paths.",
),
f(
"core.extra_headers",
Kind::StrMap,
"Extra HTTP headers on every provider request. Values support substitution. [project-forbidden]",
),
f(
"core.extra_body",
Kind::AnyMap,
"Extra JSON merged into every provider request body. [project-forbidden]",
),
f(
"core.doom_loop_threshold",
Kind::Int,
"Break the run after this many identical repeated tool calls; absent = off.",
),
f(
"core.model_switch.allow_switch",
Kind::Bool,
"Allow switching models mid-session (recorded as a `model_change` event).",
),
f(
"core.model_switch.notice",
Kind::Bool,
"On a mid-session model change, splice a notice into the conversation so the incoming model reads the handoff.",
),
f(
"core.retry.enabled",
Kind::Bool,
"Retry failed provider requests.",
),
f(
"core.retry.max_retries",
Kind::Int,
"Maximum retry attempts.",
),
f(
"core.retry.base_delay_ms",
Kind::Int,
"Base backoff delay in milliseconds (doubles per attempt).",
),
f(
"core.tools.enabled",
Kind::StrArray,
"The default-active built-in tool names.",
),
f(
"core.tools.schema_tier",
Kind::Str,
"Global advertised-schema tier (`full` | `medium` | `minimal`).",
),
f(
"core.tools.read_file.multimodal",
Kind::Bool,
"Allow `read_file` to return image, PDF and notebook content as model-visible content.",
),
f(
"core.tools.read_file.line_numbers",
Kind::Bool,
"Number `read_file` output `cat -n` style, from the requested offset.",
),
f(
"core.tools.edit_file.require_read_before_edit",
Kind::Bool,
"Reject an edit to a path this session has not read.",
),
f(
"core.tools.edit_file.notebook_aware",
Kind::Bool,
"Edit notebook cells as cells rather than as raw JSON.",
),
f(
"core.tools.edit_file.schema_tier",
Kind::Str,
"Per-tool schema-tier override for `edit_file`.",
),
f(
"core.tools.bash.enabled",
Kind::Bool,
"Register the `bash` tool.",
),
f(
"core.tools.bash.description",
Kind::Str,
"Override the `bash` tool's advertised description.",
),
f(
"core.tools.bash.schema_tier",
Kind::Str,
"Per-tool schema-tier override for `bash`.",
),
f(
"core.tools.bash.timeout_secs",
Kind::Int,
"Per-command timeout for the `bash` tool.",
),
f(
"core.skills.enabled",
Kind::Bool,
"Enable the skills subsystem.",
),
f(
"core.skills.dirs",
Kind::StrArray,
"Extra skill roots, merged over the user + project defaults.",
),
f(
"core.skills.harness",
Kind::Str,
"Whose documented skill-root table the loop discovers SKILL.md packages from \
(`claude-code`, `codex`, `opencode`, `pi`, `hermes`, `openclaw`).",
),
f(
"core.skills.implicit_match",
Kind::Bool,
"Also load a skill's body when a message merely describes it, not only on an \
explicit `$slug` mention or `/name` invocation.",
),
f(
"core.skills.shell_injection",
Kind::Bool,
"Execute `` !`cmd` `` inside a skill/command body when the body is loaded, through \
the permissions engine. Off leaves the token as literal text.",
),
f(
"core.prompts",
Kind::StrMap,
"Named prompt/skill templates, merged key-wise onto the built-ins. [project-forbidden]",
),
f(
"core.compaction.enabled",
Kind::Bool,
"Master switch for automatic history compaction.",
),
f(
"core.compaction.after_messages",
Kind::Int,
"Compact once the history exceeds this many messages.",
),
f(
"core.compaction.reserve_tokens",
Kind::Int,
"Token headroom compaction aims to leave free.",
),
f(
"core.compaction.keep_recent_tokens",
Kind::Int,
"Recent-history tokens compaction never touches.",
),
f(
"core.compaction.summarize",
Kind::Bool,
"Summarize compacted spans with a side model call instead of dropping them.",
),
f(
"core.compaction.focus_instructions",
Kind::Str,
"Instructions steering what a compaction summary keeps. [project-forbidden]",
),
f(
"core.session.dir",
Kind::Str,
"Session-store location. [project-forbidden]",
),
f(
"core.session.name",
Kind::Str,
"Default session name. [project-forbidden]",
),
f(
"core.session.persist",
Kind::Bool,
"Persist sessions; false = ephemeral. [project-forbidden]",
),
f(
"core.session.retention_days",
Kind::Int,
"Retention window `sessions prune` enforces. [project-forbidden]",
),
f(
"core.session.export_format",
Kind::Str,
"Human transcript export format (`text` | `html`). [project-forbidden]",
),
f(
"core.session.auto_title",
Kind::Bool,
"Title a session automatically after the first exchange.",
),
f(
"core.session.git_metadata",
Kind::Bool,
"Record git branch/sha with each session write. [project-forbidden]",
),
f(
"core.session.append_only",
Kind::Bool,
"Flush every message to the session journal as it is produced. [project-forbidden]",
),
f(
"core.session.queue_persist",
Kind::Bool,
"Record pending steering/follow-up inputs in the journal so they survive a restart. [project-forbidden]",
),
f(
"core.steering.steering_mode",
Kind::Str,
"How queued steering input is delivered (`all` | `one-at-a-time`).",
),
f(
"core.steering.follow_up_mode",
Kind::Str,
"How queued follow-up turns are delivered (`all` | `one-at-a-time`).",
),
f(
"core.output.format",
Kind::Str,
"Default output format (`text` | `json`).",
),
f(
"capabilities",
Kind::CapabilityMap,
"The §2 capability modules, keyed by module name.",
),
f(
"experimental",
Kind::AnyMap,
"Staged feature-flag gates. `supercode features list` shows every flag this build knows and its stage.",
),
];
pub fn config_schema() -> Value {
let mut root = Map::new();
for field in CONFIG_SCHEMA_FIELDS {
let mut leaf = field.kind.schema();
if let Some(obj) = leaf.as_object_mut() {
obj.insert(
"description".into(),
Value::String(field.description.into()),
);
}
insert_at(&mut root, field.path, leaf);
}
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": CONFIG_SCHEMA_URL,
"title": "supercode config",
"description":
"The single supercode config file (COMPOSABLE-HARNESS-DESIGN.md §3.1): \
`.supercode.toml`, `.supercode.local.toml`, \
`~/.config/supercode/config.toml`, or the JSON mirror. \
Keys marked [project-forbidden] are stripped from a project-layer file \
(§3.3 monotonic tightening): a repo may narrow the harness, never widen \
or redirect it.",
"type": "object",
"additionalProperties": false,
"properties": Value::Object(root),
})
}
pub fn config_schema_json() -> String {
format!(
"{}\n",
serde_json::to_string_pretty(&config_schema()).expect("schema serializes")
)
}
fn insert_at(root: &mut Map<String, Value>, path: &str, leaf: Value) {
let parts: Vec<&str> = path.split('.').collect();
let (last, parents) = parts.split_last().expect("non-empty path");
let mut cursor = root;
for part in parents {
let entry = cursor.entry((*part).to_string()).or_insert_with(
|| json!({ "type": "object", "additionalProperties": false, "properties": {} }),
);
cursor = entry
.as_object_mut()
.expect("intermediate schema node is an object")
.entry("properties".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("properties is an object");
}
cursor.insert((*last).to_string(), leaf);
}
pub fn parsed_key_paths() -> Vec<String> {
let value = serde_json::to_value(HarnessConfig::default()).expect("default config serializes");
let mut out = Vec::new();
collect_paths("", &value, &mut out);
out.sort();
out
}
fn collect_paths(prefix: &str, value: &Value, out: &mut Vec<String>) {
match value {
Value::Object(map) if !map.is_empty() => {
for (k, v) in map {
let path = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
collect_paths(&path, v, out);
}
}
_ if !prefix.is_empty() => out.push(prefix.to_string()),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
#[test]
fn schema_covers_exactly_the_parsed_keys() {
let declared: BTreeSet<String> = CONFIG_SCHEMA_FIELDS
.iter()
.map(|f| f.path.to_string())
.collect();
let parsed: BTreeSet<String> = parsed_key_paths().into_iter().collect();
let missing: Vec<&String> = parsed.difference(&declared).collect();
let extra: Vec<&String> = declared.difference(&parsed).collect();
assert!(
missing.is_empty(),
"keys the parser accepts but the schema omits: {missing:?}"
);
assert!(
extra.is_empty(),
"keys the schema declares but the parser never emits: {extra:?}"
);
}
#[test]
fn every_schema_key_parses_with_its_declared_type() {
for field in CONFIG_SCHEMA_FIELDS {
let literal = if field.path == "extends" {
"\"supercode-default\""
} else {
field.kind.sample_toml()
};
let doc = toml_document(field.path, literal);
HarnessConfig::from_toml_str(&doc).unwrap_or_else(|e| {
panic!("{}: schema type rejected by parser: {e}\n{doc}", field.path)
});
let resolved = crate::configfile::resolve(
&doc,
None,
&crate::configfile::ResolveOptions { strict: true },
);
assert!(
resolved.is_ok(),
"{}: strict resolve rejected its own schema key: {:?}",
field.path,
resolved.err().map(|e| e.to_string())
);
}
}
fn toml_document(path: &str, literal: &str) -> String {
let quoted: Vec<String> = path
.split('.')
.map(|p| {
if p.chars().all(|c| c.is_alphanumeric() || c == '_') {
p.to_string()
} else {
format!("\"{p}\"")
}
})
.collect();
format!("{} = {}\n", quoted.join("."), literal)
}
#[test]
fn committed_schema_is_current() {
let path = workspace_root().join(CONFIG_SCHEMA_PATH);
if std::env::var("SUPERCODE_UPDATE_CONFIG_SCHEMA").is_ok() {
std::fs::create_dir_all(path.parent().expect("schema dir")).expect("create schema dir");
std::fs::write(&path, config_schema_json()).expect("write schema");
}
let committed = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
assert_eq!(
committed,
config_schema_json(),
"{} is stale — regenerate with `supercode config schema --write`",
CONFIG_SCHEMA_PATH
);
}
#[test]
fn schema_pointer_is_accepted_in_toml_and_json() {
let toml_doc = format!("\"$schema\" = \"{CONFIG_SCHEMA_URL}\"\n[core]\nmodel = \"m\"\n");
let resolved = crate::configfile::resolve(
&toml_doc,
None,
&crate::configfile::ResolveOptions { strict: true },
)
.expect("strict resolve accepts $schema");
assert_eq!(resolved.harness.schema.as_deref(), Some(CONFIG_SCHEMA_URL));
assert!(
!resolved.warnings.iter().any(|w| w.contains("$schema")),
"the schema pointer must not itself be diagnosed: {:?}",
resolved.warnings
);
let json_doc = format!("{{\"$schema\": \"{CONFIG_SCHEMA_URL}\", \"core\": {{}}}}");
let hc = HarnessConfig::from_json_str(&json_doc).expect("json mirror accepts $schema");
assert_eq!(hc.schema.as_deref(), Some(CONFIG_SCHEMA_URL));
}
#[test]
fn generated_schema_is_closed_and_addressable() {
let schema = config_schema();
assert_eq!(schema["type"], "object");
assert_eq!(schema["additionalProperties"], Value::Bool(false));
for field in CONFIG_SCHEMA_FIELDS {
let mut node = &schema;
for part in field.path.split('.') {
node = &node["properties"][part];
assert!(
!node.is_null(),
"{} is unreachable in the generated schema",
field.path
);
}
assert_eq!(
node["description"], field.description,
"{}: description lost",
field.path
);
}
}
}