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
87#[derive(Debug, Clone, Copy)]
90pub struct Field {
91 pub path: &'static str,
93 pub kind: Kind,
95 pub description: &'static str,
97}
98
99const fn f(path: &'static str, kind: Kind, description: &'static str) -> Field {
100 Field {
101 path,
102 kind,
103 description,
104 }
105}
106
107pub const CONFIG_SCHEMA_FIELDS: &[Field] = &[
110 f(
111 "$schema",
112 Kind::Str,
113 "Pointer to this JSON Schema, so editors validate the file. Declarative only — Volter Harness never fetches it.",
114 ),
115 f(
116 "schema_version",
117 Kind::Int,
118 "Config schema version. `1` is the only version this build understands; anything else is rejected rather than reinterpreted.",
119 ),
120 f(
121 "extends",
122 Kind::Str,
123 "A built-in preset name (`cc-parity`, `cx-parity`, `supercode-default`, …) or, in the user/global layer only, a path to another config file.",
124 ),
125 f(
126 "core.model",
127 Kind::Str,
128 "Model id or alias for the main loop.",
129 ),
130 f(
131 "core.base_url",
132 Kind::Str,
133 "OpenAI-compatible endpoint. Supports `${VAR}` / `${VAR:-default}` / `{file:…}` substitution. [project-forbidden]",
134 ),
135 f(
136 "core.api_key_env",
137 Kind::Str,
138 "Environment variable name the API key is read from. [project-forbidden]",
139 ),
140 f(
141 "core.api_key_cmd",
142 Kind::Str,
143 "Credential helper: a shell command whose trimmed stdout is the API key. [project-forbidden]",
144 ),
145 f(
146 "core.api_key_command",
147 Kind::StrArray,
148 "Credential helper as argv (exec'd directly, no shell); its trimmed stdout is the API key. Consulted before `api_key_cmd`. [project-forbidden]",
149 ),
150 f(
151 "core.update_check",
152 Kind::Bool,
153 "Check for a newer release at startup. Opt-in: absent/false means no startup network access.",
154 ),
155 f(
156 "core.effort",
157 Kind::Str,
158 "Reasoning-effort level passed to the provider (`low` | `medium` | `high`).",
159 ),
160 f(
161 "core.temperature",
162 Kind::Num,
163 "Sampling temperature.",
164 ),
165 f(
166 "core.max_tokens",
167 Kind::Int,
168 "Max output tokens per model turn.",
169 ),
170 f(
171 "core.max_iterations",
172 Kind::Int,
173 "Per-run tool-use iteration budget (must be >= 1).",
174 ),
175 f(
176 "core.max_total_output_tokens",
177 Kind::Int,
178 "Cap on cumulative completion tokens across one run; 0/absent = off.",
179 ),
180 f(
181 "core.max_budget_usd",
182 Kind::Num,
183 "Cap on the cumulative dollar cost of one run; 0/absent = off. Refused at startup for a model this build cannot price.",
184 ),
185 f(
186 "core.max_steps",
187 Kind::Int,
188 "Cap on the number of tool calls executed across one run; 0/absent = off. Distinct from `max_iterations` (model round-trips).",
189 ),
190 f(
191 "core.price_input_per_mtok",
192 Kind::Num,
193 "Dollars per million input tokens for this model, overriding the built-in price table. Set together with `price_output_per_mtok`.",
194 ),
195 f(
196 "core.price_output_per_mtok",
197 Kind::Num,
198 "Dollars per million output tokens for this model.",
199 ),
200 f(
201 "core.max_tool_output_bytes",
202 Kind::Int,
203 "Truncation cap on a single tool result.",
204 ),
205 f(
206 "core.parallel_tool_calls",
207 Kind::Bool,
208 "Execute independent tool calls from one turn concurrently.",
209 ),
210 f(
211 "core.tool_output_spill",
212 Kind::Bool,
213 "Write a truncated tool result's full bytes to a per-session spill file the model can read back.",
214 ),
215 f(
216 "core.shell_env_snapshot",
217 Kind::Bool,
218 "Snapshot the login shell's environment for shell tool calls.",
219 ),
220 f(
221 "core.system_prompt",
222 Kind::Str,
223 "Replace the system prompt. Supports `${VAR}` / `{file:…}` substitution. [project-forbidden]",
224 ),
225 f(
226 "core.append_system_prompt",
227 Kind::Str,
228 "Append to the system prompt rather than replacing it. [project-forbidden]",
229 ),
230 f(
231 "core.project_context",
232 Kind::Bool,
233 "Auto-load CLAUDE.md / AGENTS.md instruction files.",
234 ),
235 f(
236 "core.env_context",
237 Kind::Bool,
238 "Append an `# Environment` block (cwd, platform, date, git branch at the project root).",
239 ),
240 f(
241 "core.context_injections",
242 Kind::Bool,
243 "Append the configured synthetic context blocks to the system prompt.",
244 ),
245 f(
246 "core.nested_instructions",
247 Kind::Bool,
248 "Load instruction files from subdirectories on demand.",
249 ),
250 f(
251 "core.instruction_imports",
252 Kind::Bool,
253 "Expand `@relative/path` imports inside instruction files.",
254 ),
255 f(
256 "core.project_root_markers",
257 Kind::StrArray,
258 "Filenames/directories that mark the project root; every root walk stops at the first one. Defaults to [\".git\"].",
259 ),
260 f(
261 "core.hot_reload",
262 Kind::Bool,
263 "Reserved: live-apply config edits without restart. Parsed and round-tripped, with no consumer in this build.",
264 ),
265 f(
266 "core.project_doc_max_bytes",
267 Kind::Int,
268 "Hygiene cap on the total bytes of assembled instruction-file content.",
269 ),
270 f(
271 "core.project_doc_excludes",
272 Kind::StrArray,
273 "Glob/path patterns naming instruction files to skip when assembling project context.",
274 ),
275 f(
276 "core.project_doc_strip_comments",
277 Kind::Bool,
278 "Drop `<!-- … -->` spans from instruction files before injecting them.",
279 ),
280 f(
281 "core.file_mentions",
282 Kind::Bool,
283 "Expand `@path` tokens in a prompt into that file's contents, subject to the \
284 permission engine's read rules.",
285 ),
286 f(
287 "core.output_style",
288 Kind::Str,
289 "Named response-style layer appended to the system prompt (a built-in style, or a \
290 markdown file under the harness's own output-style roots).",
291 ),
292 f(
293 "core.path_rules",
294 Kind::Bool,
295 "Load `.claude/rules/*.md` rule files; a rule with `paths:` frontmatter is injected \
296 only when a tool touches a matching file.",
297 ),
298 f(
299 "core.additional_dirs",
300 Kind::StrArray,
301 "Extra roots tools may access. A project layer may only add contained relative paths.",
302 ),
303 f(
304 "core.extra_headers",
305 Kind::StrMap,
306 "Extra HTTP headers on every provider request. Values support substitution. [project-forbidden]",
307 ),
308 f(
309 "core.extra_body",
310 Kind::AnyMap,
311 "Extra JSON merged into every provider request body. [project-forbidden]",
312 ),
313 f(
314 "core.doom_loop_threshold",
315 Kind::Int,
316 "Break the run after this many identical repeated tool calls; absent = off.",
317 ),
318 f(
319 "core.model_switch.allow_switch",
320 Kind::Bool,
321 "Allow switching models mid-session (recorded as a `model_change` event).",
322 ),
323 f(
324 "core.model_switch.notice",
325 Kind::Bool,
326 "On a mid-session model change, splice a notice into the conversation so the incoming model reads the handoff.",
327 ),
328 f(
329 "core.retry.enabled",
330 Kind::Bool,
331 "Retry failed provider requests.",
332 ),
333 f(
334 "core.retry.max_retries",
335 Kind::Int,
336 "Maximum retry attempts.",
337 ),
338 f(
339 "core.retry.base_delay_ms",
340 Kind::Int,
341 "Base backoff delay in milliseconds (doubles per attempt).",
342 ),
343 f(
344 "core.tools.enabled",
345 Kind::StrArray,
346 "The default-active built-in tool names.",
347 ),
348 f(
349 "core.tools.schema_tier",
350 Kind::Str,
351 "Global advertised-schema tier (`full` | `medium` | `minimal`).",
352 ),
353 f(
354 "core.tools.read_file.multimodal",
355 Kind::Bool,
356 "Allow `read_file` to return image, PDF and notebook content as model-visible content.",
357 ),
358 f(
359 "core.tools.read_file.line_numbers",
360 Kind::Bool,
361 "Number `read_file` output `cat -n` style, from the requested offset.",
362 ),
363 f(
364 "core.tools.edit_file.require_read_before_edit",
365 Kind::Bool,
366 "Reject an edit to a path this session has not read.",
367 ),
368 f(
369 "core.tools.edit_file.notebook_aware",
370 Kind::Bool,
371 "Edit notebook cells as cells rather than as raw JSON.",
372 ),
373 f(
374 "core.tools.edit_file.schema_tier",
375 Kind::Str,
376 "Per-tool schema-tier override for `edit_file`.",
377 ),
378 f(
379 "core.tools.bash.enabled",
380 Kind::Bool,
381 "Register the `bash` tool.",
382 ),
383 f(
384 "core.tools.bash.description",
385 Kind::Str,
386 "Override the `bash` tool's advertised description.",
387 ),
388 f(
389 "core.tools.bash.schema_tier",
390 Kind::Str,
391 "Per-tool schema-tier override for `bash`.",
392 ),
393 f(
394 "core.tools.bash.timeout_secs",
395 Kind::Int,
396 "Per-command timeout for the `bash` tool.",
397 ),
398 f(
399 "core.skills.enabled",
400 Kind::Bool,
401 "Enable the skills subsystem.",
402 ),
403 f(
404 "core.skills.dirs",
405 Kind::StrArray,
406 "Extra skill roots, merged over the user + project defaults.",
407 ),
408 f(
409 "core.skills.harness",
410 Kind::Str,
411 "Whose documented skill-root table the loop discovers SKILL.md packages from \
412 (`claude-code`, `codex`, `opencode`, `pi`, `hermes`, `openclaw`).",
413 ),
414 f(
415 "core.skills.implicit_match",
416 Kind::Bool,
417 "Also load a skill's body when a message merely describes it, not only on an \
418 explicit `$slug` mention or `/name` invocation.",
419 ),
420 f(
421 "core.skills.shell_injection",
422 Kind::Bool,
423 "Execute `` !`cmd` `` inside a skill/command body when the body is loaded, through \
424 the permissions engine. Off leaves the token as literal text.",
425 ),
426 f(
427 "core.prompts",
428 Kind::StrMap,
429 "Named prompt/skill templates, merged key-wise onto the built-ins. [project-forbidden]",
430 ),
431 f(
432 "core.compaction.enabled",
433 Kind::Bool,
434 "Master switch for automatic history compaction.",
435 ),
436 f(
437 "core.compaction.after_messages",
438 Kind::Int,
439 "Compact once the history exceeds this many messages.",
440 ),
441 f(
442 "core.compaction.reserve_tokens",
443 Kind::Int,
444 "Token headroom compaction aims to leave free.",
445 ),
446 f(
447 "core.compaction.keep_recent_tokens",
448 Kind::Int,
449 "Recent-history tokens compaction never touches.",
450 ),
451 f(
452 "core.compaction.summarize",
453 Kind::Bool,
454 "Summarize compacted spans with a side model call instead of dropping them.",
455 ),
456 f(
457 "core.compaction.focus_instructions",
458 Kind::Str,
459 "Instructions steering what a compaction summary keeps. [project-forbidden]",
460 ),
461 f(
462 "core.session.dir",
463 Kind::Str,
464 "Session-store location. [project-forbidden]",
465 ),
466 f(
467 "core.session.name",
468 Kind::Str,
469 "Default session name. [project-forbidden]",
470 ),
471 f(
472 "core.session.persist",
473 Kind::Bool,
474 "Persist sessions; false = ephemeral. [project-forbidden]",
475 ),
476 f(
477 "core.session.retention_days",
478 Kind::Int,
479 "Retention window `sessions prune` enforces. [project-forbidden]",
480 ),
481 f(
482 "core.session.export_format",
483 Kind::Str,
484 "Human transcript export format (`text` | `html`). [project-forbidden]",
485 ),
486 f(
487 "core.session.auto_title",
488 Kind::Bool,
489 "Title a session automatically after the first exchange.",
490 ),
491 f(
492 "core.session.git_metadata",
493 Kind::Bool,
494 "Record git branch/sha with each session write. [project-forbidden]",
495 ),
496 f(
497 "core.session.append_only",
498 Kind::Bool,
499 "Flush every message to the session journal as it is produced. [project-forbidden]",
500 ),
501 f(
502 "core.session.queue_persist",
503 Kind::Bool,
504 "Record pending steering/follow-up inputs in the journal so they survive a restart. [project-forbidden]",
505 ),
506 f(
507 "core.steering.steering_mode",
508 Kind::Str,
509 "How queued steering input is delivered (`all` | `one-at-a-time`).",
510 ),
511 f(
512 "core.steering.follow_up_mode",
513 Kind::Str,
514 "How queued follow-up turns are delivered (`all` | `one-at-a-time`).",
515 ),
516 f(
517 "core.output.format",
518 Kind::Str,
519 "Default output format (`text` | `json`).",
520 ),
521 f(
522 "capabilities",
523 Kind::CapabilityMap,
524 "The §2 capability modules, keyed by module name.",
525 ),
526 f(
527 "experimental",
528 Kind::AnyMap,
529 "Staged feature-flag gates. `supercode features list` shows every flag this build knows and its stage.",
530 ),
531];
532
533pub fn config_schema() -> Value {
535 let mut root = Map::new();
536 for field in CONFIG_SCHEMA_FIELDS {
537 let mut leaf = field.kind.schema();
538 if let Some(obj) = leaf.as_object_mut() {
539 obj.insert(
540 "description".into(),
541 Value::String(field.description.into()),
542 );
543 }
544 insert_at(&mut root, field.path, leaf);
545 }
546 json!({
547 "$schema": "https://json-schema.org/draft/2020-12/schema",
548 "$id": CONFIG_SCHEMA_URL,
549 "title": "supercode config",
550 "description":
551 "The single supercode config file (COMPOSABLE-HARNESS-DESIGN.md §3.1): \
552 `.supercode.toml`, `.supercode.local.toml`, \
553 `~/.config/supercode/config.toml`, or the JSON mirror. \
554 Keys marked [project-forbidden] are stripped from a project-layer file \
555 (§3.3 monotonic tightening): a repo may narrow the harness, never widen \
556 or redirect it.",
557 "type": "object",
558 "additionalProperties": false,
559 "properties": Value::Object(root),
560 })
561}
562
563pub fn config_schema_json() -> String {
565 format!(
566 "{}\n",
567 serde_json::to_string_pretty(&config_schema()).expect("schema serializes")
568 )
569}
570
571fn insert_at(root: &mut Map<String, Value>, path: &str, leaf: Value) {
574 let parts: Vec<&str> = path.split('.').collect();
575 let (last, parents) = parts.split_last().expect("non-empty path");
576 let mut cursor = root;
577 for part in parents {
578 let entry = cursor.entry((*part).to_string()).or_insert_with(
579 || json!({ "type": "object", "additionalProperties": false, "properties": {} }),
580 );
581 cursor = entry
582 .as_object_mut()
583 .expect("intermediate schema node is an object")
584 .entry("properties".to_string())
585 .or_insert_with(|| Value::Object(Map::new()))
586 .as_object_mut()
587 .expect("properties is an object");
588 }
589 cursor.insert((*last).to_string(), leaf);
590}
591
592pub fn parsed_key_paths() -> Vec<String> {
597 let value = serde_json::to_value(HarnessConfig::default()).expect("default config serializes");
598 let mut out = Vec::new();
599 collect_paths("", &value, &mut out);
600 out.sort();
601 out
602}
603
604fn collect_paths(prefix: &str, value: &Value, out: &mut Vec<String>) {
605 match value {
606 Value::Object(map) if !map.is_empty() => {
607 for (k, v) in map {
608 let path = if prefix.is_empty() {
609 k.clone()
610 } else {
611 format!("{prefix}.{k}")
612 };
613 collect_paths(&path, v, out);
614 }
615 }
616 _ if !prefix.is_empty() => out.push(prefix.to_string()),
617 _ => {}
618 }
619}