Skip to main content

homeassistant_cli/commands/
schema.rs

1pub fn build_schema() -> serde_json::Value {
2    serde_json::json!({
3        "clispec": "0.2",
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 | null"},
119                    {"name": "old_state", "type": "object | null"}
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                "output_fields": [
264                    {"name": "entity_id", "type": "string"},
265                    {"name": "platform", "type": "string"},
266                    {"name": "name", "type": "string | null"},
267                    {"name": "original_name", "type": "string | null"},
268                    {"name": "disabled_by", "type": "string | null"},
269                    {"name": "area_id", "type": "string | null"},
270                    {"name": "device_id", "type": "string | null"}
271                ]
272            },
273            {
274                "name": "registry entity remove",
275                "description": "Permanently remove entities from the entity registry. Use --dry-run to preview. Requires --yes when stdin is not a TTY.",
276                "mutating": true,
277                "args": [
278                    {
279                        "name": "entity_ids",
280                        "type": "string[]",
281                        "required": true,
282                        "description": "One or more entity IDs to remove"
283                    },
284                    {
285                        "name": "--dry-run",
286                        "type": "boolean",
287                        "required": false,
288                        "default": false,
289                        "description": "Print what would be removed without connecting to Home Assistant"
290                    },
291                    {
292                        "name": "--yes",
293                        "type": "boolean",
294                        "required": false,
295                        "default": false,
296                        "description": "Skip the confirmation prompt (required when stdin is not a TTY)"
297                    }
298                ],
299                "output_fields": [
300                    {"name": "ok", "type": "boolean"},
301                    {"name": "data", "type": "array", "description": "Per-entity removal status"},
302                    {"name": "entity_id", "type": "string"},
303                    {"name": "status", "type": "string", "description": "removed | not_found | error | dry_run"},
304                    {"name": "error", "type": "string | null"}
305                ]
306            },
307            {
308                "name": "init",
309                "description": "Set up credentials interactively. When stdout is not a TTY, prints JSON setup instructions.",
310                "mutating": true,
311                "args": [
312                    {
313                        "name": "--profile",
314                        "type": "string",
315                        "required": false,
316                        "description": "Profile to create or update"
317                    }
318                ],
319                "output_fields": []
320            },
321            {
322                "name": "config show",
323                "description": "Show current configuration and active profile",
324                "mutating": false,
325                "args": [],
326                "output_fields": [
327                    {"name": "config_file", "type": "string"},
328                    {"name": "file_exists", "type": "boolean"},
329                    {"name": "profiles", "type": "array"},
330                    {"name": "env", "type": "object"}
331                ]
332            },
333            {
334                "name": "config set",
335                "description": "Set a config value in the active profile",
336                "mutating": true,
337                "args": [
338                    {
339                        "name": "key",
340                        "type": "string",
341                        "required": true,
342                        "enum": ["url", "token"],
343                        "description": "Config key to set"
344                    },
345                    {
346                        "name": "value",
347                        "type": "string",
348                        "required": true,
349                        "description": "Value to set"
350                    }
351                ],
352                "output_fields": [
353                    {"name": "ok", "type": "boolean"},
354                    {"name": "key", "type": "string"},
355                    {"name": "profile", "type": "string"}
356                ]
357            },
358            {
359                "name": "schema",
360                "description": "Print this machine-readable schema. Use for agent introspection.",
361                "mutating": false,
362                "args": [],
363                "output_fields": []
364            },
365            {
366                "name": "completions",
367                "description": "Generate shell completions",
368                "mutating": false,
369                "args": [
370                    {
371                        "name": "shell",
372                        "type": "string",
373                        "required": true,
374                        "enum": ["bash", "zsh", "fish", "elvish", "powershell"],
375                        "description": "Shell to generate completions for"
376                    }
377                ],
378                "output_fields": []
379            }
380        ],
381        "errors": [
382            {
383                "kind": "auth",
384                "exit_code": 2,
385                "retryable": false,
386                "description": "Authentication failed. Token is missing, expired, or invalid."
387            },
388            {
389                "kind": "not_found",
390                "exit_code": 3,
391                "retryable": false,
392                "description": "The requested entity, service, or resource does not exist."
393            },
394            {
395                "kind": "connection",
396                "exit_code": 4,
397                "retryable": true,
398                "description": "Could not reach Home Assistant. Check URL and network connectivity."
399            },
400            {
401                "kind": "partial_failure",
402                "exit_code": 5,
403                "retryable": false,
404                "description": "Batch operation: some items succeeded and some failed. See per-item status in data[]."
405            },
406            {
407                "kind": "confirmation_required",
408                "exit_code": 6,
409                "retryable": false,
410                "description": "Destructive command requires --yes when stdin is not a TTY."
411            },
412            {
413                "kind": "conflict",
414                "exit_code": 7,
415                "retryable": false,
416                "description": "Resource exists with a different configuration than requested."
417            },
418            {
419                "kind": "invalid_input",
420                "exit_code": 1,
421                "retryable": false,
422                "description": "Invalid argument, flag value, or JSON input."
423            },
424            {
425                "kind": "api_error",
426                "exit_code": 1,
427                "retryable": false,
428                "description": "Home Assistant returned a non-2xx response."
429            },
430            {
431                "kind": "error",
432                "exit_code": 1,
433                "retryable": false,
434                "description": "General error not covered by a more specific kind."
435            }
436        ]
437    })
438}
439
440pub fn print_schema() {
441    println!(
442        "{}",
443        serde_json::to_string_pretty(&build_schema()).expect("serialize")
444    );
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    /// The clispec v0.2 JSON Schema, vendored for offline validation.
452    const CLISPEC_SCHEMA_V0_2: &str = include_str!("../../tests/fixtures/clispec-v0.2.json");
453
454    fn validate_against_v0_2(instance: &serde_json::Value) -> Result<(), String> {
455        let schema: serde_json::Value = serde_json::from_str(CLISPEC_SCHEMA_V0_2)
456            .expect("vendored clispec schema must be valid JSON");
457        let validator = jsonschema::draft202012::new(&schema)
458            .map_err(|e| format!("vendored schema is not a valid Draft 2020-12 schema: {e}"))?;
459        match validator.iter_errors(instance).next() {
460            None => Ok(()),
461            Some(err) => Err(format!("{}: {}", err.instance_path, err)),
462        }
463    }
464
465    #[test]
466    fn schema_is_valid_json() {
467        let schema = build_schema();
468        assert!(schema.is_object());
469    }
470
471    #[test]
472    fn schema_validates_against_clispec_v0_2() {
473        let schema = build_schema();
474        validate_against_v0_2(&schema)
475            .expect("ha schema must validate against clispec v0.2 JSON Schema");
476    }
477
478    #[test]
479    fn schema_has_clispec_version() {
480        let schema = build_schema();
481        assert_eq!(schema["clispec"], "0.2");
482    }
483
484    #[test]
485    fn schema_has_global_args_array() {
486        let schema = build_schema();
487        let global_args = schema["global_args"].as_array().unwrap();
488        let names: Vec<&str> = global_args
489            .iter()
490            .map(|a| a["name"].as_str().unwrap())
491            .collect();
492        assert!(
493            names.contains(&"--output"),
494            "global_args must include --output"
495        );
496        assert!(
497            names.contains(&"--profile"),
498            "global_args must include --profile"
499        );
500        assert!(
501            names.contains(&"--quiet"),
502            "global_args must include --quiet"
503        );
504    }
505
506    #[test]
507    fn schema_global_args_have_required_type_field() {
508        let schema = build_schema();
509        let global_args = schema["global_args"].as_array().unwrap();
510        for arg in global_args {
511            assert!(
512                arg.get("type").is_some(),
513                "global arg '{}' is missing required 'type' field",
514                arg["name"]
515            );
516        }
517    }
518
519    #[test]
520    fn schema_output_global_arg_has_auto_default() {
521        let schema = build_schema();
522        let global_args = schema["global_args"].as_array().unwrap();
523        let output_arg = global_args
524            .iter()
525            .find(|a| a["name"] == "--output")
526            .expect("--output must be in global_args");
527        assert_eq!(
528            output_arg["default"], "auto",
529            "--output default must be 'auto' (three-valued flag)"
530        );
531        let values = output_arg["enum"].as_array().unwrap();
532        let value_strings: Vec<&str> = values.iter().map(|v| v.as_str().unwrap()).collect();
533        assert!(value_strings.contains(&"auto"));
534        assert!(value_strings.contains(&"text"));
535        assert!(value_strings.contains(&"json"));
536    }
537
538    #[test]
539    fn schema_commands_array_has_all_expected_commands() {
540        let schema = build_schema();
541        let commands = schema["commands"].as_array().unwrap();
542        let names: Vec<&str> = commands
543            .iter()
544            .map(|c| c["name"].as_str().unwrap())
545            .collect();
546        assert!(names.contains(&"entity get"));
547        assert!(names.contains(&"entity list"));
548        assert!(names.contains(&"entity watch"));
549        assert!(names.contains(&"service call"));
550        assert!(names.contains(&"service list"));
551        assert!(names.contains(&"event fire"));
552        assert!(names.contains(&"event watch"));
553        assert!(names.contains(&"registry entity list"));
554        assert!(names.contains(&"registry entity remove"));
555        assert!(names.contains(&"schema"));
556        assert!(names.contains(&"init"));
557        assert!(names.contains(&"config show"));
558        assert!(names.contains(&"config set"));
559    }
560
561    #[test]
562    fn schema_all_commands_have_mutating_field() {
563        let schema = build_schema();
564        let commands = schema["commands"].as_array().unwrap();
565        for cmd in commands {
566            assert!(
567                cmd.get("mutating").is_some_and(|m| m.is_boolean()),
568                "command '{}' is missing required 'mutating' boolean field",
569                cmd["name"]
570            );
571        }
572    }
573
574    #[test]
575    fn schema_all_command_args_have_type_field() {
576        let schema = build_schema();
577        let commands = schema["commands"].as_array().unwrap();
578        for cmd in commands {
579            if let Some(args) = cmd.get("args").and_then(|a| a.as_array()) {
580                for arg in args {
581                    assert!(
582                        arg.get("type").is_some(),
583                        "arg '{}' in command '{}' is missing required 'type' field",
584                        arg["name"],
585                        cmd["name"]
586                    );
587                }
588            }
589        }
590    }
591
592    #[test]
593    fn schema_errors_array_has_required_kinds() {
594        let schema = build_schema();
595        let errors = schema["errors"].as_array().unwrap();
596        let kinds: Vec<&str> = errors.iter().map(|e| e["kind"].as_str().unwrap()).collect();
597        assert!(kinds.contains(&"auth"), "errors must include 'auth' kind");
598        assert!(
599            kinds.contains(&"not_found"),
600            "errors must include 'not_found' kind"
601        );
602        assert!(
603            kinds.contains(&"connection"),
604            "errors must include 'connection' kind"
605        );
606        assert!(
607            kinds.contains(&"confirmation_required"),
608            "errors must include 'confirmation_required' kind"
609        );
610        assert!(
611            kinds.contains(&"conflict"),
612            "errors must include 'conflict' kind"
613        );
614    }
615
616    #[test]
617    fn schema_all_error_kinds_have_exit_code() {
618        let schema = build_schema();
619        let errors = schema["errors"].as_array().unwrap();
620        for error in errors {
621            assert!(
622                error.get("exit_code").is_some_and(|c| c.is_u64()),
623                "error kind '{}' is missing required 'exit_code' field",
624                error["kind"]
625            );
626        }
627    }
628
629    #[test]
630    fn schema_list_commands_have_pagination_args() {
631        let schema = build_schema();
632        let commands = schema["commands"].as_array().unwrap();
633        let list_commands = ["entity list", "service list"];
634        for list_name in list_commands {
635            let cmd = commands
636                .iter()
637                .find(|c| c["name"] == list_name)
638                .unwrap_or_else(|| panic!("command '{}' must exist in schema", list_name));
639            let args = cmd["args"].as_array().unwrap();
640            let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect();
641            assert!(
642                arg_names.contains(&"--limit"),
643                "command '{}' must declare --limit",
644                list_name
645            );
646            assert!(
647                arg_names.contains(&"--offset"),
648                "command '{}' must declare --offset",
649                list_name
650            );
651            assert!(
652                arg_names.contains(&"--fields"),
653                "command '{}' must declare --fields",
654                list_name
655            );
656        }
657    }
658
659    #[test]
660    fn schema_mutating_commands_declare_yes_flag() {
661        let schema = build_schema();
662        let commands = schema["commands"].as_array().unwrap();
663        // service call, event fire, and registry entity remove are mutating and confirm.
664        let confirming_commands = ["service call", "event fire", "registry entity remove"];
665        for cmd_name in confirming_commands {
666            let cmd = commands
667                .iter()
668                .find(|c| c["name"] == cmd_name)
669                .unwrap_or_else(|| panic!("command '{}' must exist in schema", cmd_name));
670            let args = cmd["args"].as_array().unwrap();
671            let arg_names: Vec<&str> = args.iter().map(|a| a["name"].as_str().unwrap()).collect();
672            assert!(
673                arg_names.contains(&"--yes"),
674                "mutating command '{}' must declare --yes flag",
675                cmd_name
676            );
677        }
678    }
679
680    #[test]
681    fn schema_has_output_fields_on_data_commands() {
682        let schema = build_schema();
683        let commands = schema["commands"].as_array().unwrap();
684        let data_commands = ["entity get", "entity list", "config show"];
685        for cmd_name in data_commands {
686            let cmd = commands
687                .iter()
688                .find(|c| c["name"] == cmd_name)
689                .unwrap_or_else(|| panic!("command '{}' must exist in schema", cmd_name));
690            assert!(
691                cmd.get("output_fields")
692                    .and_then(|f| f.as_array())
693                    .is_some_and(|a| !a.is_empty()),
694                "command '{}' must declare non-empty output_fields",
695                cmd_name
696            );
697        }
698    }
699}