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