1use serde_json::{json, Map, Value};
25
26use crate::configfile::{HarnessConfig, MODULE_NAMES};
27
28pub const CONFIG_SCHEMA_PATH: &str = "docs/schema/supercode-config.schema.json";
30
31pub const CONFIG_SCHEMA_URL: &str =
34 "https://raw.githubusercontent.com/volter-ai/supercode/main/docs/schema/supercode-config.schema.json";
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Kind {
39 Str,
41 Int,
43 Num,
45 Bool,
47 StrArray,
49 StrMap,
51 AnyMap,
53 CapabilityMap,
55}
56
57impl Kind {
58 fn schema(self) -> Value {
59 match self {
60 Kind::Str => json!({ "type": "string" }),
61 Kind::Int => json!({ "type": "integer" }),
62 Kind::Num => json!({ "type": "number" }),
63 Kind::Bool => json!({ "type": "boolean" }),
64 Kind::StrArray => json!({ "type": "array", "items": { "type": "string" } }),
65 Kind::StrMap => {
66 json!({ "type": "object", "additionalProperties": { "type": "string" } })
67 }
68 Kind::AnyMap => json!({ "type": "object" }),
69 Kind::CapabilityMap => json!({
70 "type": "object",
71 "propertyNames": { "enum": MODULE_NAMES },
72 "additionalProperties": {
73 "type": "object",
74 "properties": {
75 "enabled": {
76 "type": "boolean",
77 "description": "Module master switch (§3.0: every capability table has `enabled`)."
78 }
79 },
80 "description": "A §2 capability module's table: `enabled` plus that module's own settings."
81 }
82 }),
83 }
84 }
85
86 #[cfg(test)]
88 fn sample_toml(self) -> &'static str {
89 match self {
90 Kind::Str => "\"x\"",
91 Kind::Int => "1",
92 Kind::Num => "1.5",
93 Kind::Bool => "true",
94 Kind::StrArray => "[\"x\"]",
95 Kind::StrMap => "{ k = \"v\" }",
96 Kind::AnyMap => "{ k = 1 }",
97 Kind::CapabilityMap => "{ permissions = { enabled = true } }",
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy)]
105pub struct Field {
106 pub path: &'static str,
108 pub kind: Kind,
110 pub description: &'static str,
112}
113
114const fn f(path: &'static str, kind: Kind, description: &'static str) -> Field {
115 Field {
116 path,
117 kind,
118 description,
119 }
120}
121
122pub const CONFIG_SCHEMA_FIELDS: &[Field] = &[
125 f(
126 "$schema",
127 Kind::Str,
128 "Pointer to this JSON Schema, so editors validate the file. Declarative only — supercode never fetches it.",
129 ),
130 f(
131 "schema_version",
132 Kind::Int,
133 "Config schema version. `1` is the only version this build understands; anything else is rejected rather than reinterpreted.",
134 ),
135 f(
136 "extends",
137 Kind::Str,
138 "A built-in preset name (`cc-parity`, `cx-parity`, `supercode-default`, …) or, in the user/global layer only, a path to another config file.",
139 ),
140 f(
141 "core.model",
142 Kind::Str,
143 "Model id or alias for the main loop.",
144 ),
145 f(
146 "core.base_url",
147 Kind::Str,
148 "OpenAI-compatible endpoint. Supports `${VAR}` / `${VAR:-default}` / `{file:…}` substitution. [project-forbidden]",
149 ),
150 f(
151 "core.api_key_env",
152 Kind::Str,
153 "Environment variable name the API key is read from. [project-forbidden]",
154 ),
155 f(
156 "core.api_key_cmd",
157 Kind::Str,
158 "Credential helper: a shell command whose trimmed stdout is the API key. [project-forbidden]",
159 ),
160 f(
161 "core.api_key_command",
162 Kind::StrArray,
163 "Credential helper as argv (exec'd directly, no shell); its trimmed stdout is the API key. Consulted before `api_key_cmd`. [project-forbidden]",
164 ),
165 f(
166 "core.update_check",
167 Kind::Bool,
168 "Check for a newer release at startup. Opt-in: absent/false means no startup network access.",
169 ),
170 f(
171 "core.effort",
172 Kind::Str,
173 "Reasoning-effort level passed to the provider (`low` | `medium` | `high`).",
174 ),
175 f(
176 "core.temperature",
177 Kind::Num,
178 "Sampling temperature.",
179 ),
180 f(
181 "core.max_tokens",
182 Kind::Int,
183 "Max output tokens per model turn.",
184 ),
185 f(
186 "core.max_iterations",
187 Kind::Int,
188 "Per-run tool-use iteration budget (must be >= 1).",
189 ),
190 f(
191 "core.max_total_output_tokens",
192 Kind::Int,
193 "Cap on cumulative completion tokens across one run; 0/absent = off.",
194 ),
195 f(
196 "core.max_budget_usd",
197 Kind::Num,
198 "Cap on the cumulative dollar cost of one run; 0/absent = off. Refused at startup for a model this build cannot price.",
199 ),
200 f(
201 "core.max_steps",
202 Kind::Int,
203 "Cap on the number of tool calls executed across one run; 0/absent = off. Distinct from `max_iterations` (model round-trips).",
204 ),
205 f(
206 "core.price_input_per_mtok",
207 Kind::Num,
208 "Dollars per million input tokens for this model, overriding the built-in price table. Set together with `price_output_per_mtok`.",
209 ),
210 f(
211 "core.price_output_per_mtok",
212 Kind::Num,
213 "Dollars per million output tokens for this model.",
214 ),
215 f(
216 "core.max_tool_output_bytes",
217 Kind::Int,
218 "Truncation cap on a single tool result.",
219 ),
220 f(
221 "core.parallel_tool_calls",
222 Kind::Bool,
223 "Execute independent tool calls from one turn concurrently.",
224 ),
225 f(
226 "core.tool_output_spill",
227 Kind::Bool,
228 "Write a truncated tool result's full bytes to a per-session spill file the model can read back.",
229 ),
230 f(
231 "core.shell_env_snapshot",
232 Kind::Bool,
233 "Snapshot the login shell's environment for shell tool calls.",
234 ),
235 f(
236 "core.system_prompt",
237 Kind::Str,
238 "Replace the system prompt. Supports `${VAR}` / `{file:…}` substitution. [project-forbidden]",
239 ),
240 f(
241 "core.append_system_prompt",
242 Kind::Str,
243 "Append to the system prompt rather than replacing it. [project-forbidden]",
244 ),
245 f(
246 "core.project_context",
247 Kind::Bool,
248 "Auto-load CLAUDE.md / AGENTS.md instruction files.",
249 ),
250 f(
251 "core.env_context",
252 Kind::Bool,
253 "Append an `# Environment` block (cwd, platform, date, git branch at the project root).",
254 ),
255 f(
256 "core.context_injections",
257 Kind::Bool,
258 "Append the configured synthetic context blocks to the system prompt.",
259 ),
260 f(
261 "core.nested_instructions",
262 Kind::Bool,
263 "Load instruction files from subdirectories on demand.",
264 ),
265 f(
266 "core.instruction_imports",
267 Kind::Bool,
268 "Expand `@relative/path` imports inside instruction files.",
269 ),
270 f(
271 "core.project_root_markers",
272 Kind::StrArray,
273 "Filenames/directories that mark the project root; every root walk stops at the first one. Defaults to [\".git\"].",
274 ),
275 f(
276 "core.hot_reload",
277 Kind::Bool,
278 "Reserved: live-apply config edits without restart. Parsed and round-tripped, with no consumer in this build.",
279 ),
280 f(
281 "core.project_doc_max_bytes",
282 Kind::Int,
283 "Hygiene cap on the total bytes of assembled instruction-file content.",
284 ),
285 f(
286 "core.project_doc_excludes",
287 Kind::StrArray,
288 "Glob/path patterns naming instruction files to skip when assembling project context.",
289 ),
290 f(
291 "core.project_doc_strip_comments",
292 Kind::Bool,
293 "Drop `<!-- … -->` spans from instruction files before injecting them.",
294 ),
295 f(
296 "core.file_mentions",
297 Kind::Bool,
298 "Expand `@path` tokens in a prompt into that file's contents, subject to the \
299 permission engine's read rules.",
300 ),
301 f(
302 "core.output_style",
303 Kind::Str,
304 "Named response-style layer appended to the system prompt (a built-in style, or a \
305 markdown file under the harness's own output-style roots).",
306 ),
307 f(
308 "core.path_rules",
309 Kind::Bool,
310 "Load `.claude/rules/*.md` rule files; a rule with `paths:` frontmatter is injected \
311 only when a tool touches a matching file.",
312 ),
313 f(
314 "core.additional_dirs",
315 Kind::StrArray,
316 "Extra roots tools may access. A project layer may only add contained relative paths.",
317 ),
318 f(
319 "core.extra_headers",
320 Kind::StrMap,
321 "Extra HTTP headers on every provider request. Values support substitution. [project-forbidden]",
322 ),
323 f(
324 "core.extra_body",
325 Kind::AnyMap,
326 "Extra JSON merged into every provider request body. [project-forbidden]",
327 ),
328 f(
329 "core.doom_loop_threshold",
330 Kind::Int,
331 "Break the run after this many identical repeated tool calls; absent = off.",
332 ),
333 f(
334 "core.model_switch.allow_switch",
335 Kind::Bool,
336 "Allow switching models mid-session (recorded as a `model_change` event).",
337 ),
338 f(
339 "core.model_switch.notice",
340 Kind::Bool,
341 "On a mid-session model change, splice a notice into the conversation so the incoming model reads the handoff.",
342 ),
343 f(
344 "core.retry.enabled",
345 Kind::Bool,
346 "Retry failed provider requests.",
347 ),
348 f(
349 "core.retry.max_retries",
350 Kind::Int,
351 "Maximum retry attempts.",
352 ),
353 f(
354 "core.retry.base_delay_ms",
355 Kind::Int,
356 "Base backoff delay in milliseconds (doubles per attempt).",
357 ),
358 f(
359 "core.tools.enabled",
360 Kind::StrArray,
361 "The default-active built-in tool names.",
362 ),
363 f(
364 "core.tools.schema_tier",
365 Kind::Str,
366 "Global advertised-schema tier (`full` | `medium` | `minimal`).",
367 ),
368 f(
369 "core.tools.read_file.multimodal",
370 Kind::Bool,
371 "Allow `read_file` to return image, PDF and notebook content as model-visible content.",
372 ),
373 f(
374 "core.tools.read_file.line_numbers",
375 Kind::Bool,
376 "Number `read_file` output `cat -n` style, from the requested offset.",
377 ),
378 f(
379 "core.tools.edit_file.require_read_before_edit",
380 Kind::Bool,
381 "Reject an edit to a path this session has not read.",
382 ),
383 f(
384 "core.tools.edit_file.notebook_aware",
385 Kind::Bool,
386 "Edit notebook cells as cells rather than as raw JSON.",
387 ),
388 f(
389 "core.tools.edit_file.schema_tier",
390 Kind::Str,
391 "Per-tool schema-tier override for `edit_file`.",
392 ),
393 f(
394 "core.tools.bash.enabled",
395 Kind::Bool,
396 "Register the `bash` tool.",
397 ),
398 f(
399 "core.tools.bash.description",
400 Kind::Str,
401 "Override the `bash` tool's advertised description.",
402 ),
403 f(
404 "core.tools.bash.schema_tier",
405 Kind::Str,
406 "Per-tool schema-tier override for `bash`.",
407 ),
408 f(
409 "core.tools.bash.timeout_secs",
410 Kind::Int,
411 "Per-command timeout for the `bash` tool.",
412 ),
413 f(
414 "core.skills.enabled",
415 Kind::Bool,
416 "Enable the skills subsystem.",
417 ),
418 f(
419 "core.skills.dirs",
420 Kind::StrArray,
421 "Extra skill roots, merged over the user + project defaults.",
422 ),
423 f(
424 "core.skills.harness",
425 Kind::Str,
426 "Whose documented skill-root table the loop discovers SKILL.md packages from \
427 (`claude-code`, `codex`, `opencode`, `pi`, `hermes`, `openclaw`).",
428 ),
429 f(
430 "core.skills.implicit_match",
431 Kind::Bool,
432 "Also load a skill's body when a message merely describes it, not only on an \
433 explicit `$slug` mention or `/name` invocation.",
434 ),
435 f(
436 "core.skills.shell_injection",
437 Kind::Bool,
438 "Execute `` !`cmd` `` inside a skill/command body when the body is loaded, through \
439 the permissions engine. Off leaves the token as literal text.",
440 ),
441 f(
442 "core.prompts",
443 Kind::StrMap,
444 "Named prompt/skill templates, merged key-wise onto the built-ins. [project-forbidden]",
445 ),
446 f(
447 "core.compaction.enabled",
448 Kind::Bool,
449 "Master switch for automatic history compaction.",
450 ),
451 f(
452 "core.compaction.after_messages",
453 Kind::Int,
454 "Compact once the history exceeds this many messages.",
455 ),
456 f(
457 "core.compaction.reserve_tokens",
458 Kind::Int,
459 "Token headroom compaction aims to leave free.",
460 ),
461 f(
462 "core.compaction.keep_recent_tokens",
463 Kind::Int,
464 "Recent-history tokens compaction never touches.",
465 ),
466 f(
467 "core.compaction.summarize",
468 Kind::Bool,
469 "Summarize compacted spans with a side model call instead of dropping them.",
470 ),
471 f(
472 "core.compaction.focus_instructions",
473 Kind::Str,
474 "Instructions steering what a compaction summary keeps. [project-forbidden]",
475 ),
476 f(
477 "core.session.dir",
478 Kind::Str,
479 "Session-store location. [project-forbidden]",
480 ),
481 f(
482 "core.session.name",
483 Kind::Str,
484 "Default session name. [project-forbidden]",
485 ),
486 f(
487 "core.session.persist",
488 Kind::Bool,
489 "Persist sessions; false = ephemeral. [project-forbidden]",
490 ),
491 f(
492 "core.session.retention_days",
493 Kind::Int,
494 "Retention window `sessions prune` enforces. [project-forbidden]",
495 ),
496 f(
497 "core.session.export_format",
498 Kind::Str,
499 "Human transcript export format (`text` | `html`). [project-forbidden]",
500 ),
501 f(
502 "core.session.auto_title",
503 Kind::Bool,
504 "Title a session automatically after the first exchange.",
505 ),
506 f(
507 "core.session.git_metadata",
508 Kind::Bool,
509 "Record git branch/sha with each session write. [project-forbidden]",
510 ),
511 f(
512 "core.session.append_only",
513 Kind::Bool,
514 "Flush every message to the session journal as it is produced. [project-forbidden]",
515 ),
516 f(
517 "core.session.queue_persist",
518 Kind::Bool,
519 "Record pending steering/follow-up inputs in the journal so they survive a restart. [project-forbidden]",
520 ),
521 f(
522 "core.steering.steering_mode",
523 Kind::Str,
524 "How queued steering input is delivered (`all` | `one-at-a-time`).",
525 ),
526 f(
527 "core.steering.follow_up_mode",
528 Kind::Str,
529 "How queued follow-up turns are delivered (`all` | `one-at-a-time`).",
530 ),
531 f(
532 "core.output.format",
533 Kind::Str,
534 "Default output format (`text` | `json`).",
535 ),
536 f(
537 "capabilities",
538 Kind::CapabilityMap,
539 "The §2 capability modules, keyed by module name.",
540 ),
541 f(
542 "experimental",
543 Kind::AnyMap,
544 "Staged feature-flag gates. `supercode features list` shows every flag this build knows and its stage.",
545 ),
546];
547
548pub fn config_schema() -> Value {
550 let mut root = Map::new();
551 for field in CONFIG_SCHEMA_FIELDS {
552 let mut leaf = field.kind.schema();
553 if let Some(obj) = leaf.as_object_mut() {
554 obj.insert(
555 "description".into(),
556 Value::String(field.description.into()),
557 );
558 }
559 insert_at(&mut root, field.path, leaf);
560 }
561 json!({
562 "$schema": "https://json-schema.org/draft/2020-12/schema",
563 "$id": CONFIG_SCHEMA_URL,
564 "title": "supercode config",
565 "description":
566 "The single supercode config file (COMPOSABLE-HARNESS-DESIGN.md §3.1): \
567 `.supercode.toml`, `.supercode.local.toml`, \
568 `~/.config/supercode/config.toml`, or the JSON mirror. \
569 Keys marked [project-forbidden] are stripped from a project-layer file \
570 (§3.3 monotonic tightening): a repo may narrow the harness, never widen \
571 or redirect it.",
572 "type": "object",
573 "additionalProperties": false,
574 "properties": Value::Object(root),
575 })
576}
577
578pub fn config_schema_json() -> String {
580 format!(
581 "{}\n",
582 serde_json::to_string_pretty(&config_schema()).expect("schema serializes")
583 )
584}
585
586fn insert_at(root: &mut Map<String, Value>, path: &str, leaf: Value) {
589 let parts: Vec<&str> = path.split('.').collect();
590 let (last, parents) = parts.split_last().expect("non-empty path");
591 let mut cursor = root;
592 for part in parents {
593 let entry = cursor.entry((*part).to_string()).or_insert_with(
594 || json!({ "type": "object", "additionalProperties": false, "properties": {} }),
595 );
596 cursor = entry
597 .as_object_mut()
598 .expect("intermediate schema node is an object")
599 .entry("properties".to_string())
600 .or_insert_with(|| Value::Object(Map::new()))
601 .as_object_mut()
602 .expect("properties is an object");
603 }
604 cursor.insert((*last).to_string(), leaf);
605}
606
607pub fn parsed_key_paths() -> Vec<String> {
612 let value = serde_json::to_value(HarnessConfig::default()).expect("default config serializes");
613 let mut out = Vec::new();
614 collect_paths("", &value, &mut out);
615 out.sort();
616 out
617}
618
619fn collect_paths(prefix: &str, value: &Value, out: &mut Vec<String>) {
620 match value {
621 Value::Object(map) if !map.is_empty() => {
622 for (k, v) in map {
623 let path = if prefix.is_empty() {
624 k.clone()
625 } else {
626 format!("{prefix}.{k}")
627 };
628 collect_paths(&path, v, out);
629 }
630 }
631 _ if !prefix.is_empty() => out.push(prefix.to_string()),
632 _ => {}
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use std::collections::BTreeSet;
640 use std::path::PathBuf;
641
642 fn workspace_root() -> PathBuf {
643 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
644 .join("../..")
645 .canonicalize()
646 .unwrap()
647 }
648
649 #[test]
654 fn schema_covers_exactly_the_parsed_keys() {
655 let declared: BTreeSet<String> = CONFIG_SCHEMA_FIELDS
656 .iter()
657 .map(|f| f.path.to_string())
658 .collect();
659 let parsed: BTreeSet<String> = parsed_key_paths().into_iter().collect();
660 let missing: Vec<&String> = parsed.difference(&declared).collect();
661 let extra: Vec<&String> = declared.difference(&parsed).collect();
662 assert!(
663 missing.is_empty(),
664 "keys the parser accepts but the schema omits: {missing:?}"
665 );
666 assert!(
667 extra.is_empty(),
668 "keys the schema declares but the parser never emits: {extra:?}"
669 );
670 }
671
672 #[test]
676 fn every_schema_key_parses_with_its_declared_type() {
677 for field in CONFIG_SCHEMA_FIELDS {
678 let literal = if field.path == "extends" {
682 "\"supercode-default\""
683 } else {
684 field.kind.sample_toml()
685 };
686 let doc = toml_document(field.path, literal);
687 HarnessConfig::from_toml_str(&doc).unwrap_or_else(|e| {
688 panic!("{}: schema type rejected by parser: {e}\n{doc}", field.path)
689 });
690 let resolved = crate::configfile::resolve(
691 &doc,
692 None,
693 &crate::configfile::ResolveOptions { strict: true },
694 );
695 assert!(
696 resolved.is_ok(),
697 "{}: strict resolve rejected its own schema key: {:?}",
698 field.path,
699 resolved.err().map(|e| e.to_string())
700 );
701 }
702 }
703
704 fn toml_document(path: &str, literal: &str) -> String {
707 let quoted: Vec<String> = path
708 .split('.')
709 .map(|p| {
710 if p.chars().all(|c| c.is_alphanumeric() || c == '_') {
711 p.to_string()
712 } else {
713 format!("\"{p}\"")
714 }
715 })
716 .collect();
717 format!("{} = {}\n", quoted.join("."), literal)
718 }
719
720 #[test]
724 fn committed_schema_is_current() {
725 let path = workspace_root().join(CONFIG_SCHEMA_PATH);
726 if std::env::var("SUPERCODE_UPDATE_CONFIG_SCHEMA").is_ok() {
732 std::fs::create_dir_all(path.parent().expect("schema dir")).expect("create schema dir");
733 std::fs::write(&path, config_schema_json()).expect("write schema");
734 }
735 let committed = std::fs::read_to_string(&path)
736 .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
737 assert_eq!(
738 committed,
739 config_schema_json(),
740 "{} is stale — regenerate with `supercode config schema --write`",
741 CONFIG_SCHEMA_PATH
742 );
743 }
744
745 #[test]
749 fn schema_pointer_is_accepted_in_toml_and_json() {
750 let toml_doc = format!("\"$schema\" = \"{CONFIG_SCHEMA_URL}\"\n[core]\nmodel = \"m\"\n");
751 let resolved = crate::configfile::resolve(
752 &toml_doc,
753 None,
754 &crate::configfile::ResolveOptions { strict: true },
755 )
756 .expect("strict resolve accepts $schema");
757 assert_eq!(resolved.harness.schema.as_deref(), Some(CONFIG_SCHEMA_URL));
758 assert!(
759 !resolved.warnings.iter().any(|w| w.contains("$schema")),
760 "the schema pointer must not itself be diagnosed: {:?}",
761 resolved.warnings
762 );
763
764 let json_doc = format!("{{\"$schema\": \"{CONFIG_SCHEMA_URL}\", \"core\": {{}}}}");
765 let hc = HarnessConfig::from_json_str(&json_doc).expect("json mirror accepts $schema");
766 assert_eq!(hc.schema.as_deref(), Some(CONFIG_SCHEMA_URL));
767 }
768
769 #[test]
772 fn generated_schema_is_closed_and_addressable() {
773 let schema = config_schema();
774 assert_eq!(schema["type"], "object");
775 assert_eq!(schema["additionalProperties"], Value::Bool(false));
776 for field in CONFIG_SCHEMA_FIELDS {
777 let mut node = &schema;
778 for part in field.path.split('.') {
779 node = &node["properties"][part];
780 assert!(
781 !node.is_null(),
782 "{} is unreachable in the generated schema",
783 field.path
784 );
785 }
786 assert_eq!(
787 node["description"], field.description,
788 "{}: description lost",
789 field.path
790 );
791 }
792 }
793}