Skip to main content

homeassistant_cli/commands/
schema.rs

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    /// The clispec v0.3 JSON Schema, vendored for offline validation.
590    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        // service call, event fire, and registry entity remove are mutating and confirm.
802        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}