1pub fn build_schema() -> serde_json::Value {
2 serde_json::json!({
3 "name": "ha",
4 "version": env!("CARGO_PKG_VERSION"),
5 "description": "Home Assistant CLI — agent-friendly with structured output and schema introspection",
6 "global_flags": [
7 {"name": "--profile", "env": "HA_PROFILE", "description": "Config profile to use"},
8 {"name": "--output", "values": ["json", "table", "plain"], "env": "HA_OUTPUT", "description": "Output format (auto: json when piped, table in TTY)"},
9 {"name": "--quiet", "description": "Suppress non-data output"}
10 ],
11 "error_envelope": {"ok": false, "error": {"code": "string", "message": "string"}},
12 "exit_codes": {
13 "0": "success",
14 "1": "general error",
15 "2": "auth/config error",
16 "3": "not found",
17 "4": "connection error",
18 "5": "partial failure (some items in a batch succeeded, some failed)"
19 },
20 "commands": [
21 {
22 "name": "entity get",
23 "description": "Get the current state of an entity",
24 "args": [{"name": "entity_id", "required": true, "description": "Entity ID (e.g. light.living_room)"}],
25 "json_shape": {
26 "ok": true,
27 "data": {
28 "entity_id": "string",
29 "state": "string",
30 "attributes": "object",
31 "last_changed": "ISO 8601 timestamp",
32 "last_updated": "ISO 8601 timestamp"
33 }
34 }
35 },
36 {
37 "name": "entity list",
38 "description": "List all entities, optionally filtered by domain, state, or count",
39 "flags": [
40 {"name": "--domain", "description": "Filter by domain (e.g. light, switch, sensor)"},
41 {"name": "--state", "description": "Filter by state value (e.g. on, off, unavailable)"},
42 {"name": "--limit", "description": "Maximum number of entities to return"}
43 ],
44 "json_shape": {
45 "ok": true,
46 "data": [{"entity_id": "string", "state": "string", "attributes": "object", "last_changed": "string", "last_updated": "string"}]
47 }
48 },
49 {
50 "name": "entity watch",
51 "description": "Stream state changes for an entity (SSE, runs until Ctrl+C)",
52 "args": [{"name": "entity_id", "required": true}],
53 "json_shape": {
54 "ok": true,
55 "data": {"entity_id": "string", "new_state": "EntityState | null", "old_state": "EntityState | null"}
56 }
57 },
58 {
59 "name": "service call",
60 "description": "Call a Home Assistant service",
61 "args": [{"name": "service", "required": true, "description": "Service in domain.service format (e.g. light.turn_on)"}],
62 "flags": [
63 {"name": "--entity", "description": "Target entity ID"},
64 {"name": "--data", "description": "Additional service data as JSON string"}
65 ],
66 "json_shape": {"ok": true, "data": "array of affected states"}
67 },
68 {
69 "name": "service list",
70 "description": "List available services",
71 "flags": [{"name": "--domain", "description": "Filter by domain"}],
72 "json_shape": {
73 "ok": true,
74 "data": [{"domain": "string", "services": {"service_name": {"name": "string", "description": "string"}}}]
75 }
76 },
77 {
78 "name": "event fire",
79 "description": "Fire a Home Assistant event",
80 "args": [{"name": "event_type", "required": true}],
81 "flags": [{"name": "--data", "description": "Event data as JSON string"}],
82 "json_shape": {"ok": true, "data": {"message": "string"}}
83 },
84 {
85 "name": "event watch",
86 "description": "Stream Home Assistant events (SSE, runs until Ctrl+C)",
87 "args": [{"name": "event_type", "required": false, "description": "Filter by event type"}],
88 "json_shape": {
89 "ok": true,
90 "data": {"event_type": "string", "data": "object", "time_fired": "ISO 8601 timestamp"}
91 }
92 },
93 {
94 "name": "registry entity list",
95 "description": "List registered entities from the Home Assistant entity registry (WebSocket API)",
96 "flags": [
97 {"name": "--integration", "description": "Filter by integration/platform (e.g. hue, zha)"},
98 {"name": "--domain", "description": "Filter by domain (e.g. light, switch)"}
99 ],
100 "json_shape": {
101 "ok": true,
102 "data": [{
103 "entity_id": "string",
104 "platform": "string",
105 "name": "string | null",
106 "original_name": "string | null",
107 "disabled_by": "string | null",
108 "area_id": "string | null",
109 "device_id": "string | null"
110 }]
111 }
112 },
113 {
114 "name": "registry entity remove",
115 "description": "Permanently remove entities from the entity registry. --dry-run never connects. In a TTY, requires --yes to bypass the confirmation prompt.",
116 "args": [{"name": "entity_ids", "required": true, "description": "One or more entity IDs to remove"}],
117 "flags": [
118 {"name": "--dry-run", "description": "Print what would be removed without connecting to Home Assistant"},
119 {"name": "--yes", "description": "Skip the interactive confirmation prompt"}
120 ],
121 "exit_codes": {"5": "one or more removals failed; see per-entity status in data[]"},
122 "json_shape": {
123 "ok": "bool (true only when every removal succeeded)",
124 "data": [{
125 "entity_id": "string",
126 "status": "removed | not_found | error | dry_run",
127 "error": "string (only present when status is not_found or error)"
128 }]
129 }
130 },
131 {
132 "name": "init",
133 "description": "Set up credentials interactively. When stdout is not a TTY, prints JSON setup instructions.",
134 "flags": [{"name": "--profile", "description": "Profile to create or update"}]
135 },
136 {
137 "name": "config show",
138 "description": "Show current configuration and active profile"
139 },
140 {
141 "name": "config set",
142 "description": "Set a config value in the active profile",
143 "args": [
144 {"name": "key", "required": true, "description": "Config key: url or token"},
145 {"name": "value", "required": true}
146 ]
147 },
148 {
149 "name": "schema",
150 "description": "Print this machine-readable schema. Use for agent introspection.",
151 "json_shape": "this document"
152 },
153 {
154 "name": "completions",
155 "description": "Generate shell completions",
156 "args": [{"name": "shell", "required": true, "values": ["bash", "zsh", "fish", "elvish", "powershell"]}]
157 }
158 ]
159 })
160}
161
162pub fn print_schema() {
163 println!(
164 "{}",
165 serde_json::to_string_pretty(&build_schema()).expect("serialize")
166 );
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn schema_is_valid_json() {
175 let schema = build_schema();
176 assert!(schema.is_object());
177 }
178
179 #[test]
180 fn schema_has_expected_commands() {
181 let schema = build_schema();
182 let commands = schema["commands"].as_array().unwrap();
183 let names: Vec<&str> = commands
184 .iter()
185 .map(|c| c["name"].as_str().unwrap())
186 .collect();
187 assert!(names.contains(&"entity get"));
188 assert!(names.contains(&"entity list"));
189 assert!(names.contains(&"entity watch"));
190 assert!(names.contains(&"service call"));
191 assert!(names.contains(&"service list"));
192 assert!(names.contains(&"event fire"));
193 assert!(names.contains(&"event watch"));
194 assert!(names.contains(&"registry entity list"));
195 assert!(names.contains(&"registry entity remove"));
196 assert!(names.contains(&"schema"));
197 assert!(names.contains(&"init"));
198 assert!(names.contains(&"config show"));
199 assert!(names.contains(&"config set"));
200 }
201
202 #[test]
203 fn schema_entity_get_has_json_shape() {
204 let schema = build_schema();
205 let commands = schema["commands"].as_array().unwrap();
206 let entity_get = commands.iter().find(|c| c["name"] == "entity get").unwrap();
207 assert!(entity_get["json_shape"]["data"]["entity_id"].is_string());
208 assert!(entity_get["json_shape"]["data"]["state"].is_string());
209 }
210
211 #[test]
212 fn schema_includes_global_flags() {
213 let schema = build_schema();
214 let globals = schema["global_flags"].as_array().unwrap();
215 let flag_names: Vec<&str> = globals
216 .iter()
217 .map(|f| f["name"].as_str().unwrap())
218 .collect();
219 assert!(flag_names.contains(&"--output"));
220 assert!(flag_names.contains(&"--profile"));
221 assert!(flag_names.contains(&"--quiet"));
222 }
223}