Skip to main content

harn_cli/
json_envelope.rs

1//! Canonical JSON envelope for `harn` CLI commands.
2//!
3//! Every `--json` mode returns a [`JsonEnvelope<T>`] — a versioned
4//! wrapper that exposes `schemaVersion`, `ok`, and either `data` or
5//! `error`. Soft signals attach as `warnings` so `ok: true` stays
6//! stable as long as the command succeeds.
7//!
8//! Schema versions are per-command and monotonically increasing.
9//! [`catalog`] returns the registry consumed by `harn --json-schemas`.
10//! New commands extend the catalog (and bump their own
11//! [`JsonOutput::SCHEMA_VERSION`]) when their JSON shape changes in a
12//! way agents need to detect.
13//!
14//! See epic #1753 (`--json` everywhere) for the broader contract.
15
16use serde::{Deserialize, Serialize};
17
18/// Schema version of the `harn --json-schemas` catalog itself. Bump
19/// when the shape of [`SchemaEntry`] or the catalog envelope changes.
20pub const CATALOG_SCHEMA_VERSION: u32 = 1;
21
22/// Versioned wrapper for every `--json` CLI output. All five fields
23/// are always serialized so consumers can rely on a flat shape:
24/// missing payloads surface as `null` and the empty `warnings` array
25/// is `[]` rather than absent.
26#[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    /// Free-form structured context. `null` when the error has no
42    /// structured payload — the field is always present so consumers
43    /// can read `error.details` without an existence check.
44    #[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
54/// Implemented by every CLI command that exposes a `--json` mode. The
55/// associated `SCHEMA_VERSION` is also surfaced in [`catalog`] so
56/// agents can negotiate per-command compatibility without parsing
57/// every payload.
58pub 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/// One row of the `harn --json-schemas` catalog. `schema_json` is
110/// inline when small; richer schemas live behind a future
111/// `schema_url` field documented per-command.
112#[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
122/// Static catalog of commands that already emit a stable JSON shape.
123///
124/// E2.1 seeds the commands that ship a `schema_version` today (doctor,
125/// session export, the provider catalog). New commands register here as
126/// they migrate to [`JsonEnvelope`] — for example, the `skills` family
127/// added in E3.2.
128pub 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: Some(crate::commands::check::lint_json_schema()),
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
459/// Encode an envelope as JSON. Uses pretty form so humans tailing the
460/// terminal can still read it; agents `jq`-pipe either form.
461pub 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        // All envelope fields are always serialized; absent payloads
483        // surface as JSON `null` / `[]`.
484        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
596    #[test]
597    fn catalog_lint_publishes_schema_json() {
598        let entry = catalog()
599            .into_iter()
600            .find(|entry| entry.command == "lint")
601            .expect("lint schema should be registered");
602        assert_eq!(
603            entry.schema_version,
604            crate::commands::check::LINT_SCHEMA_VERSION
605        );
606        let schema = entry.schema_json.expect("lint schemaJson must be present");
607        assert_eq!(schema["title"], "harn lint --json");
608        assert_eq!(schema["properties"]["schemaVersion"]["const"], 1);
609        jsonschema::draft202012::meta::validate(&schema).expect("lint schema meta-valid");
610    }
611}