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 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 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}