1pub fn build_schema() -> serde_json::Value {
2 let mut schema = serde_json::json!({
3 "clispec": "0.3",
4 "name": "ha",
5 "version": env!("CARGO_PKG_VERSION"),
6 "description": "Home Assistant CLI - agent-friendly with structured output and schema introspection",
7 "global_args": [
8 {
9 "name": "--profile",
10 "type": "string",
11 "required": false,
12 "description": "Config profile to use",
13 "env": "HA_PROFILE"
14 },
15 {
16 "name": "--output",
17 "type": "string",
18 "required": false,
19 "enum": ["auto", "text", "json"],
20 "default": "auto",
21 "description": "Output format. auto selects JSON when piped, text in a terminal. Explicit value always wins.",
22 "env": "HA_OUTPUT"
23 },
24 {
25 "name": "-o",
26 "type": "string",
27 "required": false,
28 "description": "Alias for --output"
29 },
30 {
31 "name": "--quiet",
32 "type": "boolean",
33 "required": false,
34 "default": false,
35 "description": "Suppress non-data output"
36 }
37 ],
38 "commands": [
39 {
40 "name": "entity get",
41 "description": "Get the current state of an entity",
42 "mutating": false,
43 "args": [
44 {
45 "name": "entity_id",
46 "type": "string",
47 "required": true,
48 "description": "Entity ID (e.g. light.living_room)"
49 }
50 ],
51 "output_fields": [
52 {"name": "entity_id", "type": "string"},
53 {"name": "state", "type": "string"},
54 {"name": "attributes", "type": "object"},
55 {"name": "last_changed", "type": "string", "description": "ISO 8601 timestamp"},
56 {"name": "last_updated", "type": "string", "description": "ISO 8601 timestamp"}
57 ]
58 },
59 {
60 "name": "entity list",
61 "description": "List all entities, optionally filtered by domain, state, or count",
62 "mutating": false,
63 "args": [
64 {
65 "name": "--domain",
66 "type": "string",
67 "required": false,
68 "description": "Filter by domain (e.g. light, switch, sensor)"
69 },
70 {
71 "name": "--state",
72 "type": "string",
73 "required": false,
74 "description": "Filter by state value (e.g. on, off, unavailable)"
75 },
76 {
77 "name": "--limit",
78 "type": "integer",
79 "required": false,
80 "default": 100,
81 "description": "Maximum number of entities to return"
82 },
83 {
84 "name": "--offset",
85 "type": "integer",
86 "required": false,
87 "default": 0,
88 "description": "Number of results to skip (pagination)"
89 },
90 {
91 "name": "--fields",
92 "type": "string",
93 "required": false,
94 "description": "Comma-separated list of fields to include (e.g. entity_id,state)"
95 }
96 ],
97 "output_fields": [
98 {"name": "items", "type": "array", "description": "Array of entity state objects"},
99 {"name": "total", "type": "integer", "description": "Total number of matching entities before pagination"},
100 {"name": "limit", "type": "integer"},
101 {"name": "offset", "type": "integer"}
102 ]
103 },
104 {
105 "name": "entity watch",
106 "description": "Stream state changes for an entity (SSE, runs until Ctrl+C)",
107 "mutating": false,
108 "args": [
109 {
110 "name": "entity_id",
111 "type": "string",
112 "required": true,
113 "description": "Entity ID to watch"
114 }
115 ],
116 "output_fields": [
117 {"name": "entity_id", "type": "string"},
118 {"name": "new_state", "type": "object", "nullable": true},
119 {"name": "old_state", "type": "object", "nullable": true}
120 ]
121 },
122 {
123 "name": "service call",
124 "description": "Call a Home Assistant service. Requires --yes or JSON mode when stdin is not a TTY.",
125 "mutating": true,
126 "args": [
127 {
128 "name": "service",
129 "type": "string",
130 "required": true,
131 "description": "Service in domain.service format (e.g. light.turn_on)"
132 },
133 {
134 "name": "--entity",
135 "type": "string",
136 "required": false,
137 "description": "Target entity ID"
138 },
139 {
140 "name": "--data",
141 "type": "string",
142 "required": false,
143 "description": "Additional service data as JSON string"
144 },
145 {
146 "name": "--yes",
147 "type": "boolean",
148 "required": false,
149 "default": false,
150 "description": "Skip the confirmation prompt (required when stdin is not a TTY)"
151 }
152 ],
153 "output_fields": [
154 {"name": "ok", "type": "boolean"},
155 {"name": "data", "type": "array", "description": "Array of affected entity states"}
156 ]
157 },
158 {
159 "name": "service list",
160 "description": "List available services",
161 "mutating": false,
162 "args": [
163 {
164 "name": "--domain",
165 "type": "string",
166 "required": false,
167 "description": "Filter by domain"
168 },
169 {
170 "name": "--limit",
171 "type": "integer",
172 "required": false,
173 "default": 100,
174 "description": "Maximum number of domains to return"
175 },
176 {
177 "name": "--offset",
178 "type": "integer",
179 "required": false,
180 "default": 0,
181 "description": "Number of domains to skip (pagination)"
182 },
183 {
184 "name": "--fields",
185 "type": "string",
186 "required": false,
187 "description": "Comma-separated list of fields to include"
188 }
189 ],
190 "output_fields": [
191 {"name": "items", "type": "array", "description": "Array of service domain objects"},
192 {"name": "total", "type": "integer"},
193 {"name": "limit", "type": "integer"},
194 {"name": "offset", "type": "integer"}
195 ]
196 },
197 {
198 "name": "event fire",
199 "description": "Fire a Home Assistant event. Requires --yes or JSON mode when stdin is not a TTY.",
200 "mutating": true,
201 "args": [
202 {
203 "name": "event_type",
204 "type": "string",
205 "required": true,
206 "description": "Event type to fire"
207 },
208 {
209 "name": "--data",
210 "type": "string",
211 "required": false,
212 "description": "Event data as JSON string"
213 },
214 {
215 "name": "--yes",
216 "type": "boolean",
217 "required": false,
218 "default": false,
219 "description": "Skip the confirmation prompt (required when stdin is not a TTY)"
220 }
221 ],
222 "output_fields": [
223 {"name": "ok", "type": "boolean"},
224 {"name": "data", "type": "object", "description": "Response from Home Assistant"}
225 ]
226 },
227 {
228 "name": "event watch",
229 "description": "Stream Home Assistant events (SSE, runs until Ctrl+C)",
230 "mutating": false,
231 "args": [
232 {
233 "name": "event_type",
234 "type": "string",
235 "required": false,
236 "description": "Filter by event type"
237 }
238 ],
239 "output_fields": [
240 {"name": "event_type", "type": "string"},
241 {"name": "data", "type": "object"},
242 {"name": "time_fired", "type": "string", "description": "ISO 8601 timestamp"}
243 ]
244 },
245 {
246 "name": "registry entity list",
247 "description": "List registered entities from the Home Assistant entity registry (WebSocket API)",
248 "mutating": false,
249 "args": [
250 {
251 "name": "--integration",
252 "type": "string",
253 "required": false,
254 "description": "Filter by integration/platform (e.g. hue, zha)"
255 },
256 {
257 "name": "--domain",
258 "type": "string",
259 "required": false,
260 "description": "Filter by domain (e.g. light, switch)"
261 },
262 {
263 "name": "--limit",
264 "type": "integer",
265 "required": false,
266 "default": 100,
267 "description": "Maximum number of registry entries to return"
268 },
269 {
270 "name": "--offset",
271 "type": "integer",
272 "required": false,
273 "default": 0,
274 "description": "Number of matching registry entries to skip"
275 },
276 {
277 "name": "--fields",
278 "type": "string",
279 "required": false,
280 "description": "Comma-separated fields to include in JSON records"
281 }
282 ],
283 "output_fields": [
284 {"name": "entity_id", "type": "string"},
285 {"name": "platform", "type": "string"},
286 {"name": "name", "type": "string | null"},
287 {"name": "original_name", "type": "string | null"},
288 {"name": "disabled_by", "type": "string | null"},
289 {"name": "area_id", "type": "string | null"},
290 {"name": "device_id", "type": "string | null"}
291 ]
292 },
293 {
294 "name": "registry entity remove",
295 "description": "Permanently remove entities from the entity registry. Use --dry-run to preview. Requires --yes when stdin is not a TTY.",
296 "mutating": true,
297 "args": [
298 {
299 "name": "entity_ids",
300 "type": "string[]",
301 "required": true,
302 "description": "One or more entity IDs to remove"
303 },
304 {
305 "name": "--dry-run",
306 "type": "boolean",
307 "required": false,
308 "default": false,
309 "description": "Print what would be removed without connecting to Home Assistant"
310 },
311 {
312 "name": "--yes",
313 "type": "boolean",
314 "required": false,
315 "default": false,
316 "description": "Skip the confirmation prompt (required when stdin is not a TTY)"
317 }
318 ],
319 "output_fields": [
320 {"name": "ok", "type": "boolean"},
321 {"name": "data", "type": "array", "description": "Per-entity removal status"},
322 {"name": "entity_id", "type": "string"},
323 {"name": "status", "type": "string", "description": "removed | not_found | error | dry_run"},
324 {"name": "error", "type": "string | null"}
325 ]
326 },
327 {
328 "name": "init",
329 "description": "Set up credentials interactively. When stdout is not a TTY, prints JSON setup instructions.",
330 "mutating": true,
331 "args": [
332 {
333 "name": "--profile",
334 "type": "string",
335 "required": false,
336 "description": "Profile to create or update"
337 }
338 ],
339 "output_fields": [
340 {"name": "configPath", "type": "string", "description": "Absolute path to the config file that will be written"},
341 {"name": "pathResolution", "type": "string", "description": "Description of how the config path is resolved"},
342 {"name": "recommendedPermissions", "type": "string", "description": "Recommended file permissions for the config file"},
343 {"name": "tokenInstructions", "type": "object", "description": "Step-by-step instructions for creating a long-lived access token"},
344 {"name": "requiredFields", "type": "array", "description": "Config keys required in the profile: url, token"},
345 {"name": "example", "type": "object", "description": "Example config file path and format"}
346 ]
347 },
348 {
349 "name": "config show",
350 "description": "Show current configuration and active profile",
351 "mutating": false,
352 "args": [],
353 "output_fields": [
354 {"name": "config_file", "type": "string"},
355 {"name": "file_exists", "type": "boolean"},
356 {"name": "profiles", "type": "array"},
357 {"name": "env", "type": "object"}
358 ]
359 },
360 {
361 "name": "config set",
362 "description": "Set a config value in the active profile",
363 "mutating": true,
364 "args": [
365 {
366 "name": "key",
367 "type": "string",
368 "required": true,
369 "enum": ["url", "token"],
370 "description": "Config key to set"
371 },
372 {
373 "name": "value",
374 "type": "string",
375 "required": true,
376 "description": "Value to set"
377 }
378 ],
379 "output_fields": [
380 {"name": "ok", "type": "boolean"},
381 {"name": "key", "type": "string"},
382 {"name": "profile", "type": "string"}
383 ]
384 },
385 {
386 "name": "schema",
387 "description": "Print this machine-readable schema. Use for agent introspection.",
388 "mutating": false,
389 "args": [],
390 "output_fields": []
391 },
392 {
393 "name": "completions",
394 "description": "Generate shell completions",
395 "mutating": false,
396 "args": [
397 {
398 "name": "shell",
399 "type": "string",
400 "required": true,
401 "enum": ["bash", "zsh", "fish", "elvish", "powershell"],
402 "description": "Shell to generate completions for"
403 }
404 ],
405 "output_fields": []
406 }
407 ],
408 "errors": [
409 {
410 "kind": "auth",
411 "exit_code": 2,
412 "retryable": false,
413 "description": "Authentication failed. Token is missing, expired, or invalid."
414 },
415 {
416 "kind": "not_found",
417 "exit_code": 3,
418 "retryable": false,
419 "description": "The requested entity, service, or resource does not exist."
420 },
421 {
422 "kind": "connection",
423 "exit_code": 4,
424 "retryable": true,
425 "description": "Could not reach Home Assistant. Check URL and network connectivity."
426 },
427 {
428 "kind": "partial_failure",
429 "exit_code": 5,
430 "retryable": false,
431 "description": "Batch operation: some items succeeded and some failed. See per-item status in data[]."
432 },
433 {
434 "kind": "confirmation_required",
435 "exit_code": 6,
436 "retryable": false,
437 "description": "Destructive command requires --yes when stdin is not a TTY."
438 },
439 {
440 "kind": "conflict",
441 "exit_code": 7,
442 "retryable": false,
443 "description": "Resource exists with a different configuration than requested."
444 },
445 {
446 "kind": "invalid_input",
447 "exit_code": 1,
448 "retryable": false,
449 "description": "Invalid argument, flag value, or JSON input."
450 },
451 {
452 "kind": "api_error",
453 "exit_code": 1,
454 "retryable": false,
455 "description": "Home Assistant returned a non-2xx response."
456 },
457 {
458 "kind": "error",
459 "exit_code": 1,
460 "retryable": false,
461 "description": "General error not covered by a more specific kind."
462 }
463 ]
464 });
465 enrich_v0_3(&mut schema);
466 schema
467}
468
469fn enrich_v0_3(schema: &mut serde_json::Value) {
470 schema["output"] = serde_json::json!({"tty": "text", "piped": "json"});
471 let Some(commands) = schema["commands"].as_array_mut() else {
472 return;
473 };
474
475 for command in commands {
476 let Some(object) = command.as_object_mut() else {
477 continue;
478 };
479 let name = object["name"].as_str().unwrap_or_default().to_string();
480 let mutating = object["mutating"].as_bool().unwrap_or(false);
481 let effects = if !mutating {
482 "read_only"
483 } else if matches!(name.as_str(), "config set" | "init") {
484 "idempotent"
485 } else {
486 "non_idempotent"
487 };
488 object.insert("effects".into(), serde_json::json!(effects));
489
490 if name == "completions" {
491 object.remove("output_fields");
492 object.insert("output_kind".into(), serde_json::json!("opaque"));
493 object.insert("media_type".into(), serde_json::json!("text/plain"));
494 continue;
495 }
496 if matches!(name.as_str(), "entity watch" | "event watch") {
497 object.insert("output_kind".into(), serde_json::json!("stream"));
498 object.insert("stream_format".into(), serde_json::json!("ndjson"));
499 continue;
500 }
501
502 let unbounded = matches!(
503 name.as_str(),
504 "entity list" | "service list" | "registry entity list"
505 );
506 object.insert(
507 "cardinality".into(),
508 serde_json::json!(if unbounded { "unbounded" } else { "bounded" }),
509 );
510 if unbounded {
511 object.insert(
512 "pagination".into(),
513 serde_json::json!({
514 "style": "offset",
515 "limit_arg": "--limit",
516 "offset_arg": "--offset"
517 }),
518 );
519 object.insert("fields_arg".into(), serde_json::json!("--fields"));
520 }
521 if matches!(
522 name.as_str(),
523 "service call" | "event fire" | "registry entity remove"
524 ) {
525 object.insert("confirmation_bypass_arg".into(), serde_json::json!("--yes"));
526 }
527 if name == "config show" {
528 object.insert(
529 "example".into(),
530 serde_json::json!({"args": ["config", "show"]}),
531 );
532 }
533 if name == "schema" {
534 object.remove("output_fields");
535 object.insert("cardinality".into(), serde_json::json!("single"));
536 object.insert(
537 "stdout_schema".into(),
538 serde_json::json!({"$ref": "https://clispec.dev/schema/v0.3.json"}),
539 );
540 }
541
542 if let Some(fields) = object
543 .get_mut("output_fields")
544 .and_then(|v| v.as_array_mut())
545 {
546 for field in fields {
547 let Some(field) = field.as_object_mut() else {
548 continue;
549 };
550 if let Some(base) = field
551 .get("type")
552 .and_then(|v| v.as_str())
553 .and_then(|kind| kind.strip_suffix(" | null"))
554 .map(str::to_owned)
555 {
556 field.insert("type".into(), serde_json::json!(base));
557 field.insert("nullable".into(), serde_json::json!(true));
558 }
559 if field.get("type").and_then(|v| v.as_str()) == Some("array")
560 && !field.contains_key("items")
561 {
562 let item_type =
563 if field.get("name").and_then(|v| v.as_str()) == Some("requiredFields") {
564 "string"
565 } else {
566 "object"
567 };
568 field.insert("items".into(), serde_json::json!({"type": item_type}));
569 }
570 }
571 }
572 if !object.contains_key("output_fields") && !object.contains_key("stdout_schema") {
573 object.insert("stdout_schema".into(), serde_json::json!({}));
574 }
575 }
576}
577
578pub fn print_schema() {
579 println!(
580 "{}",
581 serde_json::to_string_pretty(&build_schema()).expect("serialize")
582 );
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 const CLISPEC_SCHEMA_V0_3: &str = include_str!("../../tests/fixtures/clispec-v0.3.json");
591
592 fn validate_against_v0_3(instance: &serde_json::Value) -> Result<(), String> {
593 let schema: serde_json::Value = serde_json::from_str(CLISPEC_SCHEMA_V0_3)
594 .expect("vendored clispec schema must be valid JSON");
595 let validator = jsonschema::draft202012::new(&schema)
596 .map_err(|e| format!("vendored schema is not a valid Draft 2020-12 schema: {e}"))?;
597 match validator.iter_errors(instance).next() {
598 None => Ok(()),
599 Some(err) => Err(format!("{}: {}", err.instance_path, err)),
600 }
601 }
602
603 #[test]
604 fn schema_is_valid_json() {
605 let schema = build_schema();
606 assert!(schema.is_object());
607 }
608
609 #[test]
610 fn schema_validates_against_clispec_v0_3() {
611 let schema = build_schema();
612 validate_against_v0_3(&schema)
613 .expect("ha schema must validate against clispec v0.3 JSON Schema");
614 }
615
616 #[test]
617 fn schema_has_clispec_version() {
618 let schema = build_schema();
619 assert_eq!(schema["clispec"], "0.3");
620 }
621
622 #[test]
623 fn schema_has_global_args_array() {
624 let schema = build_schema();
625 let global_args = schema["global_args"].as_array().unwrap();
626 let names: Vec<&str> = global_args
627 .iter()
628 .map(|a| a["name"].as_str().unwrap())
629 .collect();
630 assert!(
631 names.contains(&"--output"),
632 "global_args must include --output"
633 );
634 assert!(
635 names.contains(&"--profile"),
636 "global_args must include --profile"
637 );
638 assert!(
639 names.contains(&"--quiet"),
640 "global_args must include --quiet"
641 );
642 }
643
644 #[test]
645 fn schema_global_args_have_required_type_field() {
646 let schema = build_schema();
647 let global_args = schema["global_args"].as_array().unwrap();
648 for arg in global_args {
649 assert!(
650 arg.get("type").is_some(),
651 "global arg '{}' is missing required 'type' field",
652 arg["name"]
653 );
654 }
655 }
656
657 #[test]
658 fn schema_output_global_arg_has_auto_default() {
659 let schema = build_schema();
660 let global_args = schema["global_args"].as_array().unwrap();
661 let output_arg = global_args
662 .iter()
663 .find(|a| a["name"] == "--output")
664 .expect("--output must be in global_args");
665 assert_eq!(
666 output_arg["default"], "auto",
667 "--output default must be 'auto' (three-valued flag)"
668 );
669 let values = output_arg["enum"].as_array().unwrap();
670 let value_strings: Vec<&str> = values.iter().map(|v| v.as_str().unwrap()).collect();
671 assert!(value_strings.contains(&"auto"));
672 assert!(value_strings.contains(&"text"));
673 assert!(value_strings.contains(&"json"));
674 }
675
676 #[test]
677 fn schema_commands_array_has_all_expected_commands() {
678 let schema = build_schema();
679 let commands = schema["commands"].as_array().unwrap();
680 let names: Vec<&str> = commands
681 .iter()
682 .map(|c| c["name"].as_str().unwrap())
683 .collect();
684 assert!(names.contains(&"entity get"));
685 assert!(names.contains(&"entity list"));
686 assert!(names.contains(&"entity watch"));
687 assert!(names.contains(&"service call"));
688 assert!(names.contains(&"service list"));
689 assert!(names.contains(&"event fire"));
690 assert!(names.contains(&"event watch"));
691 assert!(names.contains(&"registry entity list"));
692 assert!(names.contains(&"registry entity remove"));
693 assert!(names.contains(&"schema"));
694 assert!(names.contains(&"init"));
695 assert!(names.contains(&"config show"));
696 assert!(names.contains(&"config set"));
697 }
698
699 #[test]
700 fn schema_all_commands_have_mutating_field() {
701 let schema = build_schema();
702 let commands = schema["commands"].as_array().unwrap();
703 for cmd in commands {
704 assert!(
705 cmd.get("mutating").is_some_and(|m| m.is_boolean()),
706 "command '{}' is missing required 'mutating' boolean field",
707 cmd["name"]
708 );
709 }
710 }
711
712 #[test]
713 fn schema_all_command_args_have_type_field() {
714 let schema = build_schema();
715 let commands = schema["commands"].as_array().unwrap();
716 for cmd in commands {
717 if let Some(args) = cmd.get("args").and_then(|a| a.as_array()) {
718 for arg in args {
719 assert!(
720 arg.get("type").is_some(),
721 "arg '{}' in command '{}' is missing required 'type' field",
722 arg["name"],
723 cmd["name"]
724 );
725 }
726 }
727 }
728 }
729
730 #[test]
731 fn schema_errors_array_has_required_kinds() {
732 let schema = build_schema();
733 let errors = schema["errors"].as_array().unwrap();
734 let kinds: Vec<&str> = errors.iter().map(|e| e["kind"].as_str().unwrap()).collect();
735 assert!(kinds.contains(&"auth"), "errors must include 'auth' kind");
736 assert!(
737 kinds.contains(&"not_found"),
738 "errors must include 'not_found' kind"
739 );
740 assert!(
741 kinds.contains(&"connection"),
742 "errors must include 'connection' kind"
743 );
744 assert!(
745 kinds.contains(&"confirmation_required"),
746 "errors must include 'confirmation_required' kind"
747 );
748 assert!(
749 kinds.contains(&"conflict"),
750 "errors must include 'conflict' kind"
751 );
752 }
753
754 #[test]
755 fn schema_all_error_kinds_have_exit_code() {
756 let schema = build_schema();
757 let errors = schema["errors"].as_array().unwrap();
758 for error in errors {
759 assert!(
760 error.get("exit_code").is_some_and(|c| c.is_u64()),
761 "error kind '{}' is missing required 'exit_code' field",
762 error["kind"]
763 );
764 }
765 }
766
767 #[test]
768 fn schema_list_commands_have_pagination_args() {
769 let schema = build_schema();
770 let commands = schema["commands"].as_array().unwrap();
771 let list_commands = ["entity list", "service list"];
772 for list_name in list_commands {
773 let cmd = commands
774 .iter()
775 .find(|c| c["name"] == list_name)
776 .unwrap_or_else(|| panic!("command '{}' must exist in schema", list_name));
777 let args = cmd["args"].as_array().unwrap();
778 let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect();
779 assert!(
780 arg_names.contains(&"--limit"),
781 "command '{}' must declare --limit",
782 list_name
783 );
784 assert!(
785 arg_names.contains(&"--offset"),
786 "command '{}' must declare --offset",
787 list_name
788 );
789 assert!(
790 arg_names.contains(&"--fields"),
791 "command '{}' must declare --fields",
792 list_name
793 );
794 }
795 }
796
797 #[test]
798 fn schema_mutating_commands_declare_yes_flag() {
799 let schema = build_schema();
800 let commands = schema["commands"].as_array().unwrap();
801 let confirming_commands = ["service call", "event fire", "registry entity remove"];
803 for cmd_name in confirming_commands {
804 let cmd = commands
805 .iter()
806 .find(|c| c["name"] == cmd_name)
807 .unwrap_or_else(|| panic!("command '{}' must exist in schema", cmd_name));
808 let args = cmd["args"].as_array().unwrap();
809 let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect();
810 assert!(
811 arg_names.contains(&"--yes"),
812 "mutating command '{}' must declare --yes flag",
813 cmd_name
814 );
815 }
816 }
817
818 #[test]
819 fn schema_has_output_fields_on_data_commands() {
820 let schema = build_schema();
821 let commands = schema["commands"].as_array().unwrap();
822 let data_commands = ["entity get", "entity list", "config show"];
823 for cmd_name in data_commands {
824 let cmd = commands
825 .iter()
826 .find(|c| c["name"] == cmd_name)
827 .unwrap_or_else(|| panic!("command '{}' must exist in schema", cmd_name));
828 assert!(
829 cmd.get("output_fields")
830 .and_then(|f| f.as_array())
831 .is_some_and(|a| !a.is_empty()),
832 "command '{}' must declare non-empty output_fields",
833 cmd_name
834 );
835 }
836 }
837}