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