1use serde_json::{Value, json};
2
3use crate::error;
4
5fn field(name: &str, kind: &str) -> Value {
6 json!({"name": name, "type": kind})
7}
8fn array_field(name: &str, item_kind: &str) -> Value {
9 json!({"name": name, "type": "array", "items": {"type": item_kind}})
10}
11fn nullable_field(name: &str, kind: &str) -> Value {
12 json!({"name": name, "type": kind, "nullable": true})
13}
14fn nullable_array_field(name: &str, item_kind: &str) -> Value {
15 json!({"name": name, "type": "array", "items": {"type": item_kind}, "nullable": true})
16}
17fn arg(name: &str, kind: &str, description: &str) -> Value {
18 json!({"name": name, "type": kind, "description": description})
19}
20fn required_arg(name: &str, kind: &str, description: &str) -> Value {
21 json!({"name": name, "type": kind, "description": description, "required": true})
22}
23
24fn single(name: &str, description: &str, effects: &str, fields: Vec<Value>) -> Value {
25 json!({"name": name, "description": description, "effects": effects, "cardinality": "single", "output_fields": fields})
26}
27
28fn paged(name: &str, description: &str, fields: Vec<Value>) -> Value {
29 json!({
30 "name": name, "description": description, "effects": "read_only", "cardinality": "unbounded",
31 "pagination": {"style": "cursor", "cursor_field": "next_cursor", "cursor_arg": "--cursor", "limit_arg": "--limit"},
32 "fields_arg": "--fields",
33 "args": [
34 arg("--limit", "integer", "Maximum records in this page"),
35 arg("--cursor", "string", "Opaque continuation URL from the previous page"),
36 arg("--fields", "string", "Comma-separated output fields")
37 ],
38 "output_fields": fields
39 })
40}
41
42fn offset_paged(name: &str, description: &str, fields: Vec<Value>) -> Value {
43 json!({
44 "name": name, "description": description, "effects": "read_only", "cardinality": "unbounded",
45 "pagination": {"style": "offset", "offset_arg": "--offset", "limit_arg": "--limit"},
46 "fields_arg": "--fields",
47 "args": [
48 arg("--limit", "integer", "Maximum records in this page"),
49 arg("--offset", "integer", "Number of records to skip"),
50 arg("--fields", "string", "Comma-separated output fields")
51 ],
52 "output_fields": fields
53 })
54}
55
56pub fn generate(command_filter: Option<&str>) -> Value {
57 let identity = vec![
58 field("id", "string"),
59 field("displayName", "string"),
60 field("userPrincipalName", "string"),
61 nullable_field("mail", "string"),
62 ];
63 let mut commands = vec![
64 {
65 let mut value = single(
66 "init",
67 "Configure a Graph or experimental desktop profile",
68 "idempotent",
69 vec![
70 field("profile", "string"),
71 field("backend", "string"),
72 field("config_path", "string"),
73 nullable_field("signed_in", "boolean"),
74 field("client_id", "string"),
75 field("tenant", "string"),
76 field("channel_history_requested", "boolean"),
77 field("read_only", "boolean"),
78 nullable_field("desktop_port", "integer"),
79 nullable_field("endpoint_available", "boolean"),
80 nullable_field("connection", "object"),
81 field("session_verified", "boolean"),
82 ],
83 );
84 value["args"] = json!([
85 {"name":"--backend","type":"string","enum":["graph","desktop"],"default":"graph","description":"Connection method; desktop supports diagnostics only"},
86 {"name":"--desktop-port","type":"integer","description":"Existing loopback endpoint port (desktop only; default 9222)","minimum":1024,"maximum":65535},
87 arg(
88 "--client-id",
89 "string",
90 "Override the maintained teams-cli public-client application ID"
91 ),
92 arg("--tenant", "string", "Tenant ID, domain, or organizations"),
93 arg(
94 "--no-login",
95 "boolean",
96 "Save configuration without sign-in or desktop endpoint access"
97 ),
98 arg("--device-code", "boolean", "Use device-code sign-in"),
99 arg(
100 "--channel-history",
101 "boolean",
102 "Request admin-consented channel-history access"
103 ),
104 arg("--read-only", "boolean", "Block remote write operations")
105 ]);
106 value
107 },
108 {
109 let mut value = single(
110 "auth login",
111 "Sign in with delegated Microsoft OAuth and protect credentials in the OS store",
112 "idempotent",
113 vec![
114 field("profile", "string"),
115 field("expires_at", "integer"),
116 nullable_field("scope", "string"),
117 field("channel_history_requested", "boolean"),
118 ],
119 );
120 value["args"] = json!([
121 arg(
122 "--device-code",
123 "boolean",
124 "Use device-code sign-in for headless and remote environments"
125 ),
126 arg(
127 "--channel-history",
128 "boolean",
129 "Request admin-consented channel-history access"
130 )
131 ]);
132 value
133 },
134 single(
135 "auth logout",
136 "Remove locally stored delegated credentials",
137 "idempotent",
138 vec![field("profile", "string"), field("signed_in", "boolean")],
139 ),
140 {
141 let mut value = single(
142 "auth status",
143 "Verify authentication and inspect credential state",
144 "read_only",
145 vec![
146 field("profile", "string"),
147 field("configured", "boolean"),
148 field("backend", "string"),
149 nullable_field("signed_in", "boolean"),
150 field("verified", "boolean"),
151 nullable_field("identity", "object"),
152 field("config_path", "string"),
153 field("read_only", "boolean"),
154 nullable_array_field("granted_scopes", "string"),
155 nullable_field("channel_history", "boolean"),
156 nullable_field("endpoint_available", "boolean"),
157 nullable_field("connection", "object"),
158 field("session_verified", "boolean"),
159 ],
160 );
161 value["args"] = json!([arg(
162 "--offline",
163 "boolean",
164 "Inspect local state without contacting Graph or the desktop endpoint"
165 )]);
166 value
167 },
168 single(
169 "profile list",
170 "List configured profiles and identify the active one",
171 "read_only",
172 vec![array_field("items", "object"), field("total", "integer")],
173 ),
174 {
175 let mut value = single(
176 "profile use",
177 "Select the default profile for future commands",
178 "idempotent",
179 vec![field("profile", "string"), field("active", "boolean")],
180 );
181 value["args"] = json!([required_arg("name", "string", "Profile name")]);
182 value
183 },
184 {
185 let mut value = single(
186 "profile remove",
187 "Remove a profile and its stored credential",
188 "idempotent",
189 vec![field("profile", "string"), field("removed", "boolean")],
190 );
191 value["args"] = json!([required_arg("name", "string", "Profile name")]);
192 value["confirmation_bypass_arg"] = json!("--yes");
193 value
194 },
195 single(
196 "config show",
197 "Print the resolved profile without credentials",
198 "read_only",
199 vec![
200 field("profile", "string"),
201 field("backend", "string"),
202 nullable_field("desktop_port", "integer"),
203 field("client_id", "string"),
204 field("tenant", "string"),
205 field("read_only", "boolean"),
206 field("config_path", "string"),
207 ],
208 ),
209 single(
210 "config path",
211 "Print the absolute configuration file path",
212 "read_only",
213 vec![],
214 ),
215 single(
216 "whoami",
217 "Show the signed-in Microsoft 365 identity",
218 "read_only",
219 identity,
220 ),
221 offset_paged(
222 "list",
223 "List teams joined by the signed-in user",
224 vec![
225 field("id", "string"),
226 field("displayName", "string"),
227 nullable_field("description", "string"),
228 ],
229 ),
230 {
231 let mut value = offset_paged(
232 "channels list",
233 "List channels in a team",
234 vec![
235 field("id", "string"),
236 field("displayName", "string"),
237 nullable_field("description", "string"),
238 field("membershipType", "string"),
239 ],
240 );
241 value["args"]
242 .as_array_mut()
243 .unwrap()
244 .insert(0, required_arg("team", "string", "Team ID"));
245 value
246 },
247 paged(
248 "chats list",
249 "List one-to-one, group, and meeting chats",
250 vec![
251 field("id", "string"),
252 field("chatType", "string"),
253 nullable_field("topic", "string"),
254 field("lastUpdatedDateTime", "string"),
255 ],
256 ),
257 {
258 let mut value = paged(
259 "messages list",
260 "List messages from one chat, or from a channel with optional admin-consented access",
261 vec![
262 field("id", "string"),
263 field("createdDateTime", "string"),
264 field("from", "object"),
265 field("body", "object"),
266 ],
267 );
268 let args = value["args"].as_array_mut().unwrap();
269 args.splice(
270 0..0,
271 [
272 arg("--chat", "string", "Chat ID"),
273 arg("--team", "string", "Team ID"),
274 arg("--channel", "string", "Channel ID"),
275 ],
276 );
277 value
278 },
279 {
280 let mut value = single(
281 "messages send",
282 "Send one plain-text message as the signed-in user",
283 "non_idempotent",
284 vec![
285 field("id", "string"),
286 field("createdDateTime", "string"),
287 nullable_field("webUrl", "string"),
288 ],
289 );
290 value["args"] = json!([
291 arg("--chat", "string", "Chat ID"),
292 arg("--team", "string", "Team ID"),
293 arg("--channel", "string", "Channel ID"),
294 required_arg("--body", "string", "Message body, or - for stdin")
295 ]);
296 value
297 },
298 json!({"name":"tui", "description":"Open the keyboard-first terminal interface", "effects":"read_only", "output_kind":"opaque", "media_type":"text/plain", "requires_tty":true, "args":[arg("--demo", "boolean", "Use deterministic sample data"), arg("--snapshot", "boolean", "Print one ANSI-free demo frame")]}),
299 {
300 let mut value = single(
301 "doctor",
302 "Check configuration and the selected backend's access (desktop is metadata only)",
303 "read_only",
304 vec![
305 field("backend", "string"),
306 array_field("checks", "object"),
307 field("healthy", "boolean"),
308 nullable_field("connection", "object"),
309 nullable_field("endpoint_available", "boolean"),
310 field("session_verified", "boolean"),
311 ],
312 );
313 value["args"] = json!([arg(
314 "--offline",
315 "boolean",
316 "Check local state without contacting Graph or the desktop endpoint"
317 )]);
318 value
319 },
320 single(
321 "capabilities",
322 "Describe supported and deliberately unsupported capabilities",
323 "read_only",
324 vec![
325 array_field("supported", "string"),
326 array_field("unsupported", "string"),
327 array_field("account_types", "string"),
328 field("api", "string"),
329 array_field("notes", "string"),
330 field("backends", "object"),
331 ],
332 ),
333 json!({
334 "name":"schema",
335 "description":"Emit the offline CLI Spec v0.3 contract",
336 "effects":"read_only",
337 "cardinality":"single",
338 "stdout_schema":{},
339 "args":[arg("--command", "string", "Return only one complete command path")]
340 }),
341 json!({"name":"completions", "description":"Generate a shell completion script", "effects":"read_only", "output_kind":"opaque", "media_type":"text/plain", "args":[arg("shell", "string", "Shell name")]}),
342 ];
343 for command in &mut commands {
344 command["extensions"] = json!({"backends": match command["name"].as_str().unwrap_or_default() {
345 "auth login" | "auth logout" | "whoami" | "list" | "channels list" | "chats list" | "messages list" | "messages send" => vec!["graph"],
346 _ => vec!["graph", "desktop"],
347 }});
348 if command["name"] == "tui" {
349 command["extensions"]["desktop"] =
350 json!("demo/snapshot only; live desktop data is unsupported");
351 }
352 }
353 if let Some(filter) = command_filter {
354 commands.retain(|value| value["name"] == filter);
355 }
356 json!({
357 "$schema": "https://clispec.dev/schema/v0.3.json",
358 "clispec": "0.3",
359 "name": "teams",
360 "version": env!("CARGO_PKG_VERSION"),
361 "description": "A Microsoft Teams CLI and TUI designed equally for humans and agents",
362 "output": {"tty": "text", "piped": "json"},
363 "global_args": [
364 {"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
365 {"name":"--profile", "type":"string", "description":"Configuration profile"},
366 {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"},
367 {"name":"--yes", "short":"-y", "type":"boolean", "description":"Skip confirmation prompts for destructive operations"}
368 ],
369 "commands": commands,
370 "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
371 "extensions": {
372 "backends":backend_capabilities(),
373 "authentication":"delegated_oauth",
374 "api":"Microsoft Graph v1.0",
375 "default_client_id": crate::config::DEFAULT_CLIENT_ID,
376 "login_modes":["browser_pkce","device_code"],
377 "read_only_env":"TEAMS_READ_ONLY",
378 "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
379 "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
380 }
381 })
382}
383
384pub fn backend_capabilities() -> Value {
385 json!({
386 "graph":{"default":true,"authentication":"delegated_oauth","api":"Microsoft Graph v1.0","account_types":["Microsoft 365 work or school"]},
387 "desktop":{"experimental":true,"read_only":true,"platforms":["macos","windows","wsl"],
388 "authentication":"teams_desktop_session_unverified","supported":["configuration","connection metadata","doctor","auth status"],
389 "unsupported":["chat and message access","identity","live TUI","send messages","subscriptions","automatic Teams launch or restart"],
390 "requires":"existing Teams-owned loopback debugging endpoint; Windows/WSL require powershell.exe",
391 "transport":{"macos":"native_loopback","windows":"windows_helper","wsl":"windows_helper_stdio"},
392 "session_verified":false,"default_port":crate::config::DEFAULT_DESKTOP_PORT}
393 })
394}