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: 1,
157 description: "Outbound-connector readiness report.",
158 schema_json: None,
159 },
160 SchemaEntry {
161 command: "connect setup-plan",
162 schema_version: 1,
163 description: "Step-by-step plan to bring a connector online.",
164 schema_json: None,
165 },
166 SchemaEntry {
167 command: "mcp status",
168 schema_version: crate::commands::mcp::MCP_STATUS_SCHEMA_VERSION,
169 description: "Per-server MCP readiness: transport, connection state, tool/resource/prompt counts, last error.",
170 schema_json: None,
171 },
172 SchemaEntry {
173 command: "mcp discover",
174 schema_version: crate::commands::mcp::MCP_DISCOVERY_SCHEMA_VERSION,
175 description:
176 "Unofficial MCP endpoint discovery from /.well-known/mcp.json: source URL, found flag, and descriptor.",
177 schema_json: None,
178 },
179 SchemaEntry {
180 command: "run",
181 schema_version: crate::commands::run::json_events::RUN_JSON_SCHEMA_VERSION,
182 description: "Pipeline-run NDJSON event stream (stdout, stderr, transcript, tool, hook, persona, result, error).",
183 schema_json: None,
184 },
185 SchemaEntry {
186 command: "parse",
187 schema_version: crate::commands::parse_tokens::PARSE_JSON_SCHEMA_VERSION,
188 description: "Tagged Harn AST tree with byte spans for parser tooling.",
189 schema_json: None,
190 },
191 SchemaEntry {
192 command: "tokens",
193 schema_version: crate::commands::parse_tokens::TOKENS_JSON_SCHEMA_VERSION,
194 description: "Lexer token stream with source lexemes and byte spans.",
195 schema_json: None,
196 },
197 SchemaEntry {
198 command: "check",
199 schema_version: crate::commands::check::CHECK_SCHEMA_VERSION,
200 description: "Per-file static check results with diagnostics and summary counts.",
201 schema_json: None,
202 },
203 SchemaEntry {
204 command: "package verify",
205 schema_version:
206 crate::commands::package_verify::PACKAGE_VERIFY_SCHEMA_VERSION,
207 description: "Complete package verification receipt with inferred package kinds and per-gate applicability, reachability, and results.",
208 schema_json: None,
209 },
210 SchemaEntry {
211 command: "fmt",
212 schema_version: crate::commands::check::FMT_SCHEMA_VERSION,
213 description: "Per-file formatting result report for write and check modes.",
214 schema_json: None,
215 },
216 SchemaEntry {
217 command: "check provider-matrix",
218 schema_version: crate::commands::check::provider_matrix::PROVIDER_MATRIX_SCHEMA_VERSION,
219 description: "Provider/model capability matrix rows.",
220 schema_json: None,
221 },
222 SchemaEntry {
223 command: "provider catalog support",
224 schema_version: crate::commands::provider_support::PROVIDER_SUPPORT_SCHEMA_VERSION,
225 description: "Generated provider recommendation and support matrix.",
226 schema_json: None,
227 },
228 SchemaEntry {
229 command: "models batch plan",
230 schema_version: 1,
231 description:
232 "Provider Batch API candidates plus Harn live-adapter support for offline workloads.",
233 schema_json: None,
234 },
235 SchemaEntry {
236 command: "models batch manifest",
237 schema_version: 1,
238 description:
239 "Provider-neutral offline batch manifest summary and request groups.",
240 schema_json: None,
241 },
242 SchemaEntry {
243 command: "models batch prepare",
244 schema_version: 1,
245 description:
246 "Provider-native batch request files, deterministic prepare receipt, and lifecycle state.",
247 schema_json: None,
248 },
249 SchemaEntry {
250 command: "models batch submit",
251 schema_version: 1,
252 description:
253 "Batch submission receipt with provider job ids, dry-run operations, and lifecycle state.",
254 schema_json: None,
255 },
256 SchemaEntry {
257 command: "models batch status",
258 schema_version: 1,
259 description:
260 "Provider batch status receipt with cached/dry-run validation and lifecycle counts.",
261 schema_json: None,
262 },
263 SchemaEntry {
264 command: "models batch cancel",
265 schema_version: 1,
266 description:
267 "Batch cancellation receipt with redacted cancel operations, skipped-job reasons, and lifecycle counts.",
268 schema_json: None,
269 },
270 SchemaEntry {
271 command: "models batch download",
272 schema_version: 1,
273 description:
274 "Provider result-file download receipt with artifact paths, hashes, and lifecycle counts.",
275 schema_json: None,
276 },
277 SchemaEntry {
278 command: "models lora plan",
279 schema_version: 1,
280 description: "Portable LoRA/QLoRA route plan: base model, tool-call format, trainer, data, eval, and launch contract.",
281 schema_json: None,
282 },
283 SchemaEntry {
284 command: "models lora inspect",
285 schema_version: 1,
286 description:
287 "PEFT LoRA adapter compatibility report with base-model, provider, tool-call, and launch metadata.",
288 schema_json: None,
289 },
290 SchemaEntry {
291 command: "models lora export",
292 schema_version: 1,
293 description:
294 "Trainer-ready LoRA dataset export report, including contract id, manifest paths, stats, and validation results.",
295 schema_json: None,
296 },
297 SchemaEntry {
298 command: "models lora manifest",
299 schema_version: 1,
300 description:
301 "Canonical LoRA training-run manifest with route, data, artifact, serving, and promotion contracts.",
302 schema_json: None,
303 },
304 SchemaEntry {
305 command: "models lora preflight",
306 schema_version: 1,
307 description:
308 "LoRA corpus readiness report before GPU training, including sequence-fit, tool-call shape, and threshold failures.",
309 schema_json: None,
310 },
311 SchemaEntry {
312 command: "models lora promote",
313 schema_version: 1,
314 description:
315 "LoRA promotion probe matrix receipt collected from adapter-loaded behavioral probe outputs.",
316 schema_json: None,
317 },
318 SchemaEntry {
319 command: "models lora train",
320 schema_version: 1,
321 description:
322 "LoRA trainer backend receipt with route contract, dataset hashes, backend argv, and post-training manifest commands.",
323 schema_json: None,
324 },
325 SchemaEntry {
326 command: "check connector-matrix",
327 schema_version: crate::commands::check::connector_matrix::CONNECTOR_MATRIX_SCHEMA_VERSION,
328 description: "Connector package capability matrix rows.",
329 schema_json: None,
330 },
331 SchemaEntry {
332 command: "test conformance",
333 schema_version: crate::commands::test::CONFORMANCE_TEST_SCHEMA_VERSION,
334 description:
335 "Conformance results with xfail accounting, fixture snapshot key, and duration distribution.",
336 schema_json: None,
337 },
338 SchemaEntry {
339 command: "test --json-out",
340 schema_version: crate::test_report::USER_TEST_REPORT_SCHEMA_VERSION,
341 description:
342 "User-test report with typed timeout, per-case and aggregate phases, module attribution, and duration distribution.",
343 schema_json: None,
344 },
345 SchemaEntry {
346 command: "time run",
347 schema_version: crate::commands::time::TIME_RUN_SCHEMA_VERSION,
348 description:
349 "Per-phase wall-clock + cache hit/miss + per-LLM/tool-call latency for `harn run`.",
350 schema_json: None,
351 },
352 SchemaEntry {
353 command: "fix plan",
354 schema_version: crate::commands::fix::FIX_PLAN_SCHEMA_VERSION,
355 description: "Plan repair-bearing diagnostics without editing files.",
356 schema_json: None,
357 },
358 SchemaEntry {
359 command: "fix apply",
360 schema_version: crate::commands::fix::FIX_APPLY_SCHEMA_VERSION,
361 description: "Apply clean repair edits at or below a declared safety ceiling.",
362 schema_json: None,
363 },
364 SchemaEntry {
365 command: "skills list",
366 schema_version: 1,
367 description: "Canonical Harn skill corpus, frontmatter only.",
368 schema_json: None,
369 },
370 SchemaEntry {
371 command: "skills get",
372 schema_version: 1,
373 description: "One canonical skill's frontmatter (and body with --full).",
374 schema_json: None,
375 },
376 SchemaEntry {
377 command: "pack",
378 schema_version: crate::commands::pack::PACK_SCHEMA_VERSION,
379 description: "Signed-ready .harnpack run-bundle build summary.",
380 schema_json: Some(crate::commands::pack::json_schema()),
381 },
382 SchemaEntry {
383 command: "pack verify",
384 schema_version: crate::commands::pack::PACK_VERIFY_SCHEMA_VERSION,
385 description:
386 "Result of verifying a .harnpack: bundle hash, signature, per-module hashes.",
387 schema_json: Some(crate::commands::pack::verify_json_schema()),
388 },
389 SchemaEntry {
390 command: "dev",
391 schema_version: 1,
392 description: "`harn dev --watch` incremental NDJSON event stream (ready / fingerprint_changed / rerun / diagnostics / tests).",
393 schema_json: None,
394 },
395 SchemaEntry {
396 command: "routes",
397 schema_version: 1,
398 description: "Static trigger route, budget, capability, and vendor-lock inventory.",
399 schema_json: None,
400 },
401 SchemaEntry {
402 command: "usage",
403 schema_version: crate::commands::usage::USAGE_SCHEMA_VERSION,
404 description:
405 "LLM spend/usage rollup from the event log: per-group calls, cost_usd, tokens, cache telemetry, and time-series cumulatives.",
406 schema_json: None,
407 },
408 SchemaEntry {
409 command: "graph",
410 schema_version: crate::commands::graph::GRAPH_SCHEMA_VERSION,
411 description:
412 "Static module graph with public symbols, imports, capabilities, effects, and host-call surface.",
413 schema_json: None,
414 },
415 SchemaEntry {
416 command: "lint",
417 schema_version: crate::commands::check::LINT_SCHEMA_VERSION,
418 description:
419 "Per-file lint diagnostics with severity, fixable/fixed counts, and summary.",
420 schema_json: None,
421 },
422 SchemaEntry {
423 command: "replay",
424 schema_version: crate::commands::replay::REPLAY_SCHEMA_VERSION,
425 description:
426 "Replay summary: per-stage status/outcome/branch, embedded fixture verdicts, and multi-run determinism.",
427 schema_json: None,
428 },
429 SchemaEntry {
430 command: "version",
431 schema_version: crate::VERSION_SCHEMA_VERSION,
432 description: "CLI build metadata: name, version, description.",
433 schema_json: None,
434 },
435 SchemaEntry {
436 command: "upgrade",
437 schema_version: crate::commands::upgrade::UPGRADE_SCHEMA_VERSION,
438 description:
439 "Self-update probe (`--check`) or install summary: current, target, archive URL, install outcome.",
440 schema_json: None,
441 },
442 SchemaEntry {
443 command: "explain --catalog",
444 schema_version: crate::commands::diagnostics_catalog::SCHEMA_VERSION,
445 description:
446 "Diagnostic-code catalog: per-code summary, repair, safety, related codes.",
447 schema_json: None,
448 },
449 SchemaEntry {
450 command: "mcp presets",
451 schema_version: crate::commands::mcp::presets::MCP_PRESETS_SCHEMA_VERSION,
452 description:
453 "Canonical catalog of well-known MCP server presets (Notion, Linear, GitHub, filesystem): id, transport, command/url template, auth kind, and required placeholders.",
454 schema_json: None,
455 },
456 ]
457}
458
459pub fn to_string_pretty<T: Serialize>(envelope: &JsonEnvelope<T>) -> String {
462 serde_json::to_string_pretty(envelope).expect("JsonEnvelope serializes")
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use serde_json::json;
469
470 #[derive(Serialize)]
471 struct Payload {
472 value: u32,
473 }
474
475 #[test]
476 fn ok_envelope_round_trips() {
477 let env = JsonEnvelope::ok(7, Payload { value: 42 });
478 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
479 assert_eq!(v["schemaVersion"], 7);
480 assert_eq!(v["ok"], true);
481 assert_eq!(v["data"]["value"], 42);
482 assert!(v["error"].is_null());
485 assert_eq!(v["warnings"], json!([]));
486 }
487
488 #[test]
489 fn err_envelope_carries_details() {
490 let env: JsonEnvelope<()> = JsonEnvelope::err(2, "io", "disk full")
491 .with_details(json!({ "path": "/var/log/harn" }));
492 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
493 assert_eq!(v["schemaVersion"], 2);
494 assert_eq!(v["ok"], false);
495 assert_eq!(v["error"]["code"], "io");
496 assert_eq!(v["error"]["message"], "disk full");
497 assert_eq!(v["error"]["details"]["path"], "/var/log/harn");
498 assert!(v["data"].is_null());
499 }
500
501 #[test]
502 fn warnings_serialize_when_present() {
503 let env = JsonEnvelope::ok(1, Payload { value: 1 })
504 .with_warning("deprecated.flag", "--format=json is deprecated");
505 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
506 assert_eq!(v["warnings"][0]["code"], "deprecated.flag");
507 assert_eq!(v["warnings"][0]["message"], "--format=json is deprecated");
508 }
509
510 #[test]
511 fn catalog_is_nonempty_and_unique() {
512 let entries = catalog();
513 assert!(!entries.is_empty(), "catalog should ship with E2.1 seeds");
514 let mut commands: Vec<_> = entries.iter().map(|e| e.command).collect();
515 commands.sort();
516 let unique_count = {
517 let mut deduped = commands.clone();
518 deduped.dedup();
519 deduped.len()
520 };
521 assert_eq!(commands.len(), unique_count, "command names must be unique");
522 }
523
524 #[test]
525 fn catalog_includes_fix_plan() {
526 let entries = catalog();
527 let entry = entries
528 .iter()
529 .find(|entry| entry.command == "fix plan")
530 .expect("fix plan schema should be registered");
531 assert_eq!(
532 entry.schema_version,
533 crate::commands::fix::FIX_PLAN_SCHEMA_VERSION
534 );
535 let entry = entries
536 .iter()
537 .find(|entry| entry.command == "fix apply")
538 .expect("fix apply schema should be registered");
539 assert_eq!(
540 entry.schema_version,
541 crate::commands::fix::FIX_APPLY_SCHEMA_VERSION
542 );
543 }
544
545 #[test]
546 fn catalog_includes_models_lora_commands() {
547 let entries = catalog();
548 for command in [
549 "models lora plan",
550 "models lora inspect",
551 "models lora export",
552 "models lora manifest",
553 "models lora preflight",
554 "models lora promote",
555 "models lora train",
556 ] {
557 let entry = entries
558 .iter()
559 .find(|entry| entry.command == command)
560 .unwrap_or_else(|| panic!("{command} schema should be registered"));
561 assert_eq!(entry.schema_version, 1);
562 }
563 }
564
565 #[test]
566 fn catalog_includes_models_batch_commands() {
567 let entries = catalog();
568 for command in [
569 "models batch plan",
570 "models batch manifest",
571 "models batch prepare",
572 "models batch submit",
573 "models batch status",
574 "models batch cancel",
575 "models batch download",
576 ] {
577 let entry = entries
578 .iter()
579 .find(|entry| entry.command == command)
580 .unwrap_or_else(|| panic!("{command} schema should be registered"));
581 assert_eq!(entry.schema_version, 1);
582 }
583 }
584
585 #[test]
586 fn schema_versions_are_positive() {
587 for entry in catalog() {
588 assert!(
589 entry.schema_version >= 1,
590 "{} should have schemaVersion >= 1",
591 entry.command
592 );
593 }
594 }
595}