1use serde::{Deserialize, Serialize};
17
18pub const CATALOG_SCHEMA_VERSION: u32 = 1;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct JsonEnvelope<T: Serialize> {
28 #[serde(rename = "schemaVersion")]
29 pub schema_version: u32,
30 pub ok: bool,
31 pub data: Option<T>,
32 pub error: Option<JsonError>,
33 #[serde(default)]
34 pub warnings: Vec<JsonWarning>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct JsonError {
39 pub code: String,
40 pub message: String,
41 #[serde(default)]
45 pub details: serde_json::Value,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct JsonWarning {
50 pub code: String,
51 pub message: String,
52}
53
54pub trait JsonOutput {
59 const SCHEMA_VERSION: u32;
60 type Data: Serialize;
61 fn into_envelope(self) -> JsonEnvelope<Self::Data>;
62}
63
64impl<T: Serialize> JsonEnvelope<T> {
65 pub fn ok(schema_version: u32, data: T) -> Self {
66 Self {
67 schema_version,
68 ok: true,
69 data: Some(data),
70 error: None,
71 warnings: Vec::new(),
72 }
73 }
74
75 pub fn err(
76 schema_version: u32,
77 code: impl Into<String>,
78 message: impl Into<String>,
79 ) -> JsonEnvelope<T> {
80 Self {
81 schema_version,
82 ok: false,
83 data: None,
84 error: Some(JsonError {
85 code: code.into(),
86 message: message.into(),
87 details: serde_json::Value::Null,
88 }),
89 warnings: Vec::new(),
90 }
91 }
92
93 pub fn with_details(mut self, details: serde_json::Value) -> Self {
94 if let Some(err) = self.error.as_mut() {
95 err.details = details;
96 }
97 self
98 }
99
100 pub fn with_warning(mut self, code: impl Into<String>, message: impl Into<String>) -> Self {
101 self.warnings.push(JsonWarning {
102 code: code.into(),
103 message: message.into(),
104 });
105 self
106 }
107}
108
109#[derive(Debug, Clone, Serialize)]
113pub struct SchemaEntry {
114 pub command: &'static str,
115 #[serde(rename = "schemaVersion")]
116 pub schema_version: u32,
117 pub description: &'static str,
118 #[serde(skip_serializing_if = "Option::is_none", rename = "schemaJson")]
119 pub schema_json: Option<serde_json::Value>,
120}
121
122pub fn catalog() -> Vec<SchemaEntry> {
129 vec![
130 SchemaEntry {
131 command: "doctor",
132 schema_version: crate::commands::doctor::DOCTOR_SCHEMA_VERSION,
133 description: "Capability matrix: host, per-target buildability, per-provider reachability, per-stdlib-effect availability.",
134 schema_json: None,
135 },
136 SchemaEntry {
137 command: "host lease",
138 schema_version: crate::commands::host::HOST_LEASE_CLI_SCHEMA_VERSION,
139 description: "Machine-global host lease acquire, renew, release, and status receipts.",
140 schema_json: None,
141 },
142 SchemaEntry {
143 command: "session export",
144 schema_version: 1,
145 description: "Portable Harn session bundle export.",
146 schema_json: None,
147 },
148 SchemaEntry {
149 command: "provider catalog show",
150 schema_version: 1,
151 description: "Resolved provider/model catalog snapshot.",
152 schema_json: None,
153 },
154 SchemaEntry {
155 command: "connect status",
156 schema_version: crate::commands::connect::status::CONNECT_STATUS_SCHEMA_VERSION,
157 description: "Outbound-connector readiness report.",
158 schema_json: None,
159 },
160 SchemaEntry {
161 command: "connect setup-plan",
162 schema_version: crate::commands::connect::status::CONNECT_SETUP_PLAN_SCHEMA_VERSION,
163 description: "Step-by-step plan to bring a connector online.",
164 schema_json: None,
165 },
166 SchemaEntry {
167 command: "connect",
168 schema_version: crate::commands::connect::setup_events::CONNECT_SETUP_EVENT_SCHEMA_VERSION,
169 description: "Secret-free connector setup progress and terminal events as NDJSON.",
170 schema_json: None,
171 },
172 SchemaEntry {
173 command: "mcp status",
174 schema_version: crate::commands::mcp::MCP_STATUS_SCHEMA_VERSION,
175 description: "Per-server MCP readiness: transport, connection state, tool/resource/prompt counts, last error.",
176 schema_json: None,
177 },
178 SchemaEntry {
179 command: "mcp discover",
180 schema_version: crate::commands::mcp::MCP_DISCOVERY_SCHEMA_VERSION,
181 description:
182 "Unofficial MCP endpoint discovery from /.well-known/mcp.json: source URL, found flag, and descriptor.",
183 schema_json: None,
184 },
185 SchemaEntry {
186 command: "run",
187 schema_version: crate::commands::run::json_events::RUN_JSON_SCHEMA_VERSION,
188 description: "Pipeline-run NDJSON event stream (stdout, stderr, transcript, tool, hook, persona, result, error).",
189 schema_json: None,
190 },
191 SchemaEntry {
192 command: "portable compile|start|resume",
193 schema_version: crate::commands::portable::PORTABLE_CLI_SCHEMA_VERSION,
194 description: "Portable artifact compilation and deterministic execute/resume terminal states.",
195 schema_json: None,
196 },
197 SchemaEntry {
198 command: "parse",
199 schema_version: crate::commands::parse_tokens::PARSE_JSON_SCHEMA_VERSION,
200 description: "Tagged Harn AST tree with byte spans for parser tooling.",
201 schema_json: None,
202 },
203 SchemaEntry {
204 command: "tokens",
205 schema_version: crate::commands::parse_tokens::TOKENS_JSON_SCHEMA_VERSION,
206 description: "Lexer token stream with source lexemes and byte spans.",
207 schema_json: None,
208 },
209 SchemaEntry {
210 command: "check",
211 schema_version: crate::commands::check::CHECK_SCHEMA_VERSION,
212 description: "Per-file static check results with diagnostics and summary counts.",
213 schema_json: None,
214 },
215 SchemaEntry {
216 command: "package verify",
217 schema_version:
218 crate::commands::package_verify::PACKAGE_VERIFY_SCHEMA_VERSION,
219 description: "Complete package verification receipt with inferred package kinds and per-gate applicability, reachability, and results.",
220 schema_json: None,
221 },
222 SchemaEntry {
223 command: "skill list",
224 schema_version: crate::commands::skills::SKILLS_LIST_SCHEMA_VERSION,
225 description: "Canonical embedded or disk-backed Harn skill catalog.",
226 schema_json: None,
227 },
228 SchemaEntry {
229 command: "skill get",
230 schema_version: crate::commands::skills::SKILLS_GET_SCHEMA_VERSION,
231 description: "One canonical skill card with an optional full SKILL.md body.",
232 schema_json: None,
233 },
234 SchemaEntry {
235 command: "skill validate",
236 schema_version: crate::commands::skills::SKILLS_VALIDATE_SCHEMA_VERSION,
237 description: "Skill bundle validation result from the runtime's canonical parser.",
238 schema_json: None,
239 },
240 SchemaEntry {
241 command: "fmt",
242 schema_version: crate::commands::check::FMT_SCHEMA_VERSION,
243 description: "Per-file formatting result report for write and check modes.",
244 schema_json: None,
245 },
246 SchemaEntry {
247 command: "check --provider-matrix",
248 schema_version: crate::commands::check::provider_matrix::PROVIDER_MATRIX_SCHEMA_VERSION,
249 description: "Provider/model capability matrix rows.",
250 schema_json: None,
251 },
252 SchemaEntry {
253 command: "provider catalog support",
254 schema_version: crate::commands::provider_support::PROVIDER_SUPPORT_SCHEMA_VERSION,
255 description: "Generated provider recommendation and support matrix.",
256 schema_json: None,
257 },
258 SchemaEntry {
259 command: "models batch plan",
260 schema_version: 1,
261 description:
262 "Provider Batch API candidates plus Harn live-adapter support for offline workloads.",
263 schema_json: None,
264 },
265 SchemaEntry {
266 command: "models batch manifest",
267 schema_version: 1,
268 description:
269 "Provider-neutral offline batch manifest summary and request groups.",
270 schema_json: None,
271 },
272 SchemaEntry {
273 command: "models batch prepare",
274 schema_version: 1,
275 description:
276 "Provider-native batch request files, deterministic prepare receipt, and lifecycle state.",
277 schema_json: None,
278 },
279 SchemaEntry {
280 command: "models batch submit",
281 schema_version: 1,
282 description:
283 "Batch submission receipt with provider job ids, dry-run operations, and lifecycle state.",
284 schema_json: None,
285 },
286 SchemaEntry {
287 command: "models batch status",
288 schema_version: 1,
289 description:
290 "Provider batch status receipt with cached/dry-run validation and lifecycle counts.",
291 schema_json: None,
292 },
293 SchemaEntry {
294 command: "models batch cancel",
295 schema_version: 1,
296 description:
297 "Batch cancellation receipt with redacted cancel operations, skipped-job reasons, and lifecycle counts.",
298 schema_json: None,
299 },
300 SchemaEntry {
301 command: "models batch download",
302 schema_version: 1,
303 description:
304 "Provider result-file download receipt with artifact paths, hashes, and lifecycle counts.",
305 schema_json: None,
306 },
307 SchemaEntry {
308 command: "models lora plan",
309 schema_version: 1,
310 description: "Portable LoRA/QLoRA route plan: base model, tool-call format, trainer, data, eval, and launch contract.",
311 schema_json: None,
312 },
313 SchemaEntry {
314 command: "models lora inspect",
315 schema_version: 1,
316 description:
317 "PEFT LoRA adapter compatibility report with base-model, provider, tool-call, and launch metadata.",
318 schema_json: None,
319 },
320 SchemaEntry {
321 command: "models lora export",
322 schema_version: 1,
323 description:
324 "Trainer-ready LoRA dataset export report, including contract id, manifest paths, stats, and validation results.",
325 schema_json: None,
326 },
327 SchemaEntry {
328 command: "models lora manifest",
329 schema_version: 1,
330 description:
331 "Canonical LoRA training-run manifest with route, data, artifact, serving, and promotion contracts.",
332 schema_json: None,
333 },
334 SchemaEntry {
335 command: "models lora preflight",
336 schema_version: 1,
337 description:
338 "LoRA corpus readiness report before GPU training, including sequence-fit, tool-call shape, and threshold failures.",
339 schema_json: None,
340 },
341 SchemaEntry {
342 command: "models lora promote",
343 schema_version: 1,
344 description:
345 "LoRA promotion probe matrix receipt collected from adapter-loaded behavioral probe outputs.",
346 schema_json: None,
347 },
348 SchemaEntry {
349 command: "models lora train",
350 schema_version: 1,
351 description:
352 "LoRA trainer backend receipt with route contract, dataset hashes, backend argv, and post-training manifest commands.",
353 schema_json: None,
354 },
355 SchemaEntry {
356 command: "check --connector-matrix",
357 schema_version: crate::commands::check::connector_matrix::CONNECTOR_MATRIX_SCHEMA_VERSION,
358 description: "Connector package capability matrix rows.",
359 schema_json: None,
360 },
361 SchemaEntry {
362 command: "test conformance",
363 schema_version: crate::commands::test::CONFORMANCE_TEST_SCHEMA_VERSION,
364 description:
365 "Conformance results with xfail accounting, fixture snapshot key, and duration distribution.",
366 schema_json: None,
367 },
368 SchemaEntry {
369 command: "test --json-out",
370 schema_version: crate::test_report::USER_TEST_REPORT_SCHEMA_VERSION,
371 description:
372 "User-test report with typed timeout, per-case and aggregate phases, module attribution, and duration distribution.",
373 schema_json: None,
374 },
375 SchemaEntry {
376 command: "time run",
377 schema_version: crate::commands::time::TIME_RUN_SCHEMA_VERSION,
378 description:
379 "Per-phase wall-clock + cache hit/miss + per-LLM/tool-call latency for `harn run`.",
380 schema_json: None,
381 },
382 SchemaEntry {
383 command: "fix --plan",
384 schema_version: crate::commands::fix::FIX_PLAN_SCHEMA_VERSION,
385 description: "Plan repair-bearing diagnostics without editing files.",
386 schema_json: None,
387 },
388 SchemaEntry {
389 command: "fix --apply",
390 schema_version: crate::commands::fix::FIX_APPLY_SCHEMA_VERSION,
391 description: "Apply clean repair edits at or below a declared safety ceiling.",
392 schema_json: None,
393 },
394 SchemaEntry {
395 command: "pack",
396 schema_version: crate::commands::pack::PACK_SCHEMA_VERSION,
397 description: "Signed-ready .harnpack run-bundle build summary.",
398 schema_json: Some(crate::commands::pack::json_schema()),
399 },
400 SchemaEntry {
401 command: "pack verify",
402 schema_version: crate::commands::pack::PACK_VERIFY_SCHEMA_VERSION,
403 description:
404 "Result of verifying a .harnpack: bundle hash, signature, per-module hashes.",
405 schema_json: Some(crate::commands::pack::verify_json_schema()),
406 },
407 SchemaEntry {
408 command: "dev",
409 schema_version: 1,
410 description: "`harn dev --watch` incremental NDJSON event stream (ready / fingerprint_changed / rerun / diagnostics / tests).",
411 schema_json: None,
412 },
413 SchemaEntry {
414 command: "routes",
415 schema_version: 1,
416 description: "Static trigger route, budget, capability, and vendor-lock inventory.",
417 schema_json: None,
418 },
419 SchemaEntry {
420 command: "usage",
421 schema_version: crate::commands::usage::USAGE_SCHEMA_VERSION,
422 description:
423 "LLM spend/usage rollup from the event log: per-group calls, cost_usd, tokens, cache telemetry, and time-series cumulatives.",
424 schema_json: None,
425 },
426 SchemaEntry {
427 command: "graph",
428 schema_version: crate::commands::graph::GRAPH_SCHEMA_VERSION,
429 description:
430 "Static module graph with public symbols, imports, capabilities, effects, and host-call surface.",
431 schema_json: None,
432 },
433 SchemaEntry {
434 command: "lint",
435 schema_version: crate::commands::check::LINT_SCHEMA_VERSION,
436 description:
437 "Per-file lint diagnostics with severity, fixable/fixed counts, and summary.",
438 schema_json: Some(crate::commands::check::lint_json_schema()),
439 },
440 SchemaEntry {
441 command: "replay",
442 schema_version: crate::commands::replay::REPLAY_SCHEMA_VERSION,
443 description:
444 "Replay summary: per-stage status/outcome/branch, embedded fixture verdicts, and multi-run determinism.",
445 schema_json: None,
446 },
447 SchemaEntry {
448 command: "version",
449 schema_version: crate::VERSION_SCHEMA_VERSION,
450 description: "CLI build metadata: name, version, description.",
451 schema_json: None,
452 },
453 SchemaEntry {
454 command: "upgrade",
455 schema_version: crate::commands::upgrade::UPGRADE_SCHEMA_VERSION,
456 description:
457 "Self-update probe (`--check`) or install summary: current, target, archive URL, install outcome.",
458 schema_json: None,
459 },
460 SchemaEntry {
461 command: "explain --catalog",
462 schema_version: crate::commands::diagnostics_catalog::SCHEMA_VERSION,
463 description:
464 "Diagnostic-code catalog: per-code summary, repair, safety, related codes.",
465 schema_json: None,
466 },
467 SchemaEntry {
468 command: "mcp presets",
469 schema_version: crate::commands::mcp::presets::MCP_PRESETS_SCHEMA_VERSION,
470 description:
471 "Canonical catalog of well-known MCP server presets (Notion, Linear, GitHub, filesystem): id, transport, command/url template, auth kind, and required placeholders.",
472 schema_json: None,
473 },
474 ]
475}
476
477pub fn to_string_pretty<T: Serialize>(envelope: &JsonEnvelope<T>) -> String {
480 serde_json::to_string_pretty(envelope).expect("JsonEnvelope serializes")
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use serde_json::json;
487
488 #[derive(Serialize)]
489 struct Payload {
490 value: u32,
491 }
492
493 fn catalog_command_paths(command: &str) -> (Vec<Vec<String>>, Vec<String>) {
499 let tokens: Vec<&str> = command.split_whitespace().collect();
500 let split = tokens
501 .iter()
502 .position(|token| token.starts_with('-'))
503 .unwrap_or(tokens.len());
504 let (subcommands, flags) = tokens.split_at(split);
505 let flags = flags.iter().map(|f| f.to_string()).collect();
506 let Some((leaves, prefix)) = subcommands.split_last() else {
507 return (Vec::new(), flags);
508 };
509 let paths = leaves
510 .split('|')
511 .map(|leaf| {
512 let mut path: Vec<String> = prefix.iter().map(|s| (*s).to_string()).collect();
513 path.push(leaf.to_string());
514 path
515 })
516 .collect();
517 (paths, flags)
518 }
519
520 fn resolve_command<'a>(root: &'a clap::Command, path: &[String]) -> Option<&'a clap::Command> {
521 let mut current = root;
522 for segment in path {
523 current = current.get_subcommands().find(|sub| {
524 sub.get_name() == segment || sub.get_all_aliases().any(|alias| alias == segment)
525 })?;
526 }
527 Some(current)
528 }
529
530 #[test]
538 fn catalog_commands_exist_in_the_cli() {
539 use clap::CommandFactory;
540 let root = crate::cli::Cli::command();
541 const POSITIONAL_VALUE_ROWS: &[&str] = &["test conformance"];
550
551 let mut drift = Vec::new();
552 for entry in catalog() {
553 if POSITIONAL_VALUE_ROWS.contains(&entry.command) {
554 continue;
555 }
556 let (paths, flags) = catalog_command_paths(entry.command);
557 for path in paths {
558 let Some(command) = resolve_command(&root, &path) else {
559 drift.push(format!(
560 "`{}` names no such command (row: `{}`)",
561 path.join(" "),
562 entry.command
563 ));
564 continue;
565 };
566 for flag in &flags {
567 let long = flag.trim_start_matches('-');
568 if !command
569 .get_arguments()
570 .any(|arg| arg.get_long() == Some(long))
571 {
572 drift.push(format!(
573 "`{}` takes no `{flag}` (row: `{}`)",
574 path.join(" "),
575 entry.command
576 ));
577 }
578 }
579 }
580 }
581 assert!(
582 drift.is_empty(),
583 "`--json-schemas` advertises {} command(s) the CLI does not expose:\n {}",
584 drift.len(),
585 drift.join("\n ")
586 );
587 }
588
589 #[test]
590 fn ok_envelope_round_trips() {
591 let env = JsonEnvelope::ok(7, Payload { value: 42 });
592 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
593 assert_eq!(v["schemaVersion"], 7);
594 assert_eq!(v["ok"], true);
595 assert_eq!(v["data"]["value"], 42);
596 assert!(v["error"].is_null());
599 assert_eq!(v["warnings"], json!([]));
600 }
601
602 #[test]
603 fn err_envelope_carries_details() {
604 let env: JsonEnvelope<()> = JsonEnvelope::err(2, "io", "disk full")
605 .with_details(json!({ "path": "/var/log/harn" }));
606 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
607 assert_eq!(v["schemaVersion"], 2);
608 assert_eq!(v["ok"], false);
609 assert_eq!(v["error"]["code"], "io");
610 assert_eq!(v["error"]["message"], "disk full");
611 assert_eq!(v["error"]["details"]["path"], "/var/log/harn");
612 assert!(v["data"].is_null());
613 }
614
615 #[test]
616 fn warnings_serialize_when_present() {
617 let env = JsonEnvelope::ok(1, Payload { value: 1 })
618 .with_warning("deprecated.flag", "--format=json is deprecated");
619 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
620 assert_eq!(v["warnings"][0]["code"], "deprecated.flag");
621 assert_eq!(v["warnings"][0]["message"], "--format=json is deprecated");
622 }
623
624 #[test]
625 fn catalog_is_nonempty_and_unique() {
626 let entries = catalog();
627 assert!(!entries.is_empty(), "catalog should ship with E2.1 seeds");
628 let mut commands: Vec<_> = entries.iter().map(|e| e.command).collect();
629 commands.sort();
630 let unique_count = {
631 let mut deduped = commands.clone();
632 deduped.dedup();
633 deduped.len()
634 };
635 assert_eq!(commands.len(), unique_count, "command names must be unique");
636 }
637
638 #[test]
639 fn catalog_includes_fix_plan() {
640 let entries = catalog();
641 let entry = entries
642 .iter()
643 .find(|entry| entry.command == "fix --plan")
644 .expect("fix --plan schema should be registered");
645 assert_eq!(
646 entry.schema_version,
647 crate::commands::fix::FIX_PLAN_SCHEMA_VERSION
648 );
649 let entry = entries
650 .iter()
651 .find(|entry| entry.command == "fix --apply")
652 .expect("fix apply schema should be registered");
653 assert_eq!(
654 entry.schema_version,
655 crate::commands::fix::FIX_APPLY_SCHEMA_VERSION
656 );
657 }
658
659 #[test]
660 fn catalog_includes_models_lora_commands() {
661 let entries = catalog();
662 for command in [
663 "models lora plan",
664 "models lora inspect",
665 "models lora export",
666 "models lora manifest",
667 "models lora preflight",
668 "models lora promote",
669 "models lora train",
670 ] {
671 let entry = entries
672 .iter()
673 .find(|entry| entry.command == command)
674 .unwrap_or_else(|| panic!("{command} schema should be registered"));
675 assert_eq!(entry.schema_version, 1);
676 }
677 }
678
679 #[test]
680 fn catalog_includes_models_batch_commands() {
681 let entries = catalog();
682 for command in [
683 "models batch plan",
684 "models batch manifest",
685 "models batch prepare",
686 "models batch submit",
687 "models batch status",
688 "models batch cancel",
689 "models batch download",
690 ] {
691 let entry = entries
692 .iter()
693 .find(|entry| entry.command == command)
694 .unwrap_or_else(|| panic!("{command} schema should be registered"));
695 assert_eq!(entry.schema_version, 1);
696 }
697 }
698
699 #[test]
700 fn schema_versions_are_positive() {
701 for entry in catalog() {
702 assert!(
703 entry.schema_version >= 1,
704 "{} should have schemaVersion >= 1",
705 entry.command
706 );
707 }
708 }
709
710 #[test]
711 fn catalog_lint_publishes_schema_json() {
712 let entry = catalog()
713 .into_iter()
714 .find(|entry| entry.command == "lint")
715 .expect("lint schema should be registered");
716 assert_eq!(
717 entry.schema_version,
718 crate::commands::check::LINT_SCHEMA_VERSION
719 );
720 let schema = entry.schema_json.expect("lint schemaJson must be present");
721 assert_eq!(schema["title"], "harn lint --json");
722 assert_eq!(schema["properties"]["schemaVersion"]["const"], 1);
723 jsonschema::draft202012::meta::validate(&schema).expect("lint schema meta-valid");
724 }
725}