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 object_field(name: &str, nullable: bool, description: &str, fields: Vec<Value>) -> Value {
18 json!({"name": name, "type": "object", "nullable": nullable, "description": description, "fields": fields})
19}
20fn described(mut field: Value, description: &str) -> Value {
21 field["description"] = json!(description);
22 field
23}
24fn debugging_setup() -> Value {
25 object_field(
26 "debugging_setup",
27 true,
28 "Managed connection commands, available when no endpoint listens. Restart commands require confirmation and pass debugging flags only to the Teams launch; recover clears an interrupted setup; clear-flags removes debugging flags from the user-wide WebView2 setting after confirmation",
29 vec![
30 described(
31 field("shell", "string"),
32 "zsh, bash, wsl or powershell: where the commands run",
33 ),
34 field("open", "string"),
35 field("close", "string"),
36 ],
37 )
38}
39fn sender() -> Value {
40 object_field(
41 "from",
42 true,
43 "Sender; null for system events",
44 vec![object_field(
45 "user",
46 true,
47 "The sending user",
48 vec![
49 field("id", "string"),
50 nullable_field("displayName", "string"),
51 ],
52 )],
53 )
54}
55fn body() -> Value {
56 object_field(
57 "body",
58 false,
59 "Message content",
60 vec![
61 json!({"name": "contentType", "type": "string", "enum": ["text", "html"]}),
62 field("content", "string"),
63 ],
64 )
65}
66fn arg(name: &str, kind: &str, description: &str) -> Value {
67 json!({"name": name, "type": kind, "description": description})
68}
69fn required_arg(name: &str, kind: &str, description: &str) -> Value {
70 json!({"name": name, "type": kind, "description": description, "required": true})
71}
72
73fn single(name: &str, description: &str, effects: &str, fields: Vec<Value>) -> Value {
74 json!({"name": name, "description": description, "effects": effects, "cardinality": "single", "output_fields": fields})
75}
76
77fn paged(name: &str, description: &str, fields: Vec<Value>) -> Value {
78 json!({
79 "name": name, "description": description, "effects": "read_only", "cardinality": "unbounded",
80 "pagination": {"style": "cursor", "cursor_field": "next_cursor", "cursor_arg": "--cursor", "limit_arg": "--limit"},
81 "fields_arg": "--fields",
82 "args": [
83 {"name": "--limit", "type": "integer", "default": 50, "x-minimum": 1, "x-maximum": 50, "description": "Maximum records in this page"},
84 arg("--cursor", "string", "Opaque next_cursor value from the previous page; omit for the newest page"),
85 arg("--fields", "string", "Comma-separated top-level output fields to keep; unknown names are omitted")
86 ],
87 "output_fields": fields
88 })
89}
90
91fn offset_paged(name: &str, description: &str, fields: Vec<Value>) -> Value {
92 json!({
93 "name": name, "description": description, "effects": "read_only", "cardinality": "unbounded",
94 "pagination": {"style": "offset", "offset_arg": "--offset", "limit_arg": "--limit"},
95 "fields_arg": "--fields",
96 "args": [
97 {"name": "--limit", "type": "integer", "default": 50, "x-minimum": 1, "x-maximum": 100, "description": "Maximum records in this page"},
98 {"name": "--offset", "type": "integer", "default": 0, "x-minimum": 0, "description": "Number of records to skip; use next_offset from the previous page"},
99 arg("--fields", "string", "Comma-separated top-level output fields to keep; unknown names are omitted")
100 ],
101 "output_fields": fields
102 })
103}
104
105fn conversation_args() -> Vec<Value> {
106 vec![
107 json!({"name": "--chat", "type": "string", "x-conflicts": ["--team", "--channel"], "description": "Chat ID from chats list; give this, or --team with --channel"}),
108 json!({"name": "--team", "type": "string", "x-requires": ["--channel"], "description": "Team ID (Graph only)"}),
109 json!({"name": "--channel", "type": "string", "x-requires": ["--team"], "description": "Channel ID within --team (Graph only)"}),
110 ]
111}
112
113fn command_errors(name: &str) -> &'static [&'static str] {
116 const GRAPH_READ: &[&str] = &[
117 "auth",
118 "permission_denied",
119 "not_found",
120 "rate_limit",
121 "api_error",
122 "unsupported",
123 ];
124 const CONVERSATION_READ: &[&str] = &[
125 "auth",
126 "permission_denied",
127 "not_found",
128 "rate_limit",
129 "api_error",
130 "unsupported",
131 "desktop_error",
132 ];
133 match name {
134 "init" => &[
135 "auth",
136 "tty_required",
137 "confirmation_required",
138 "unsupported",
139 "desktop_error",
140 ],
141 "auth login" => &["auth", "tty_required", "unsupported"],
142 "auth logout" => &["unsupported"],
143 "auth status" => &["auth", "rate_limit", "api_error", "desktop_error"],
144 "profile remove" => &["confirmation_required"],
145 "whoami" | "list" | "channels list" => GRAPH_READ,
146 "chats list" | "messages list" | "people search" => CONVERSATION_READ,
147 "messages send" | "chats create" => &[
148 "auth",
149 "read_only",
150 "permission_denied",
151 "not_found",
152 "rate_limit",
153 "api_error",
154 "confirmation_required",
155 "unconfirmed",
156 "unsupported",
157 "desktop_error",
158 ],
159 "messages status" => &["unsupported", "desktop_error"],
160 "desktop status" | "desktop supervise" => &["desktop_error", "unsupported"],
161 "desktop login" | "desktop connect" | "desktop disconnect" | "desktop recover" => {
162 &["desktop_error", "unsupported", "confirmation_required"]
163 }
164 "tui" => &["tty_required"],
165 "doctor" => &["unhealthy"],
166 "schema" => &["not_found"],
167 _ => &[],
168 }
169}
170
171fn example(name: &str) -> Value {
172 let (args, stdin): (&[&str], Option<&str>) = match name {
173 "init" => (&["init", "--no-login"], None),
174 "auth login" => (&["auth", "login", "--device-code"], None),
175 "auth logout" => (&["auth", "logout"], None),
176 "auth status" => (&["auth", "status", "--offline"], None),
177 "profile list" => (&["profile", "list"], None),
178 "profile use" => (&["profile", "use", "work"], None),
179 "profile remove" => (&["profile", "remove", "old", "--yes"], None),
180 "config show" => (&["config", "show"], None),
181 "config path" => (&["config", "path"], None),
182 "whoami" => (&["whoami"], None),
183 "list" => (&["list", "--limit", "20"], None),
184 "channels list" => (&["channels", "list", "TEAM_ID"], None),
185 "chats list" => (
186 &[
187 "chats",
188 "list",
189 "--limit",
190 "20",
191 "--fields",
192 "id,topic,lastUpdatedDateTime",
193 ],
194 None,
195 ),
196 "people search" => (&["people", "search", "Alex", "--limit", "20"], None),
197 "chats create" => (
198 &["chats", "create", "--recipient", "PERSON_ID", "--yes"],
199 None,
200 ),
201 "messages list" => (
202 &["messages", "list", "--chat", "CHAT_ID", "--limit", "20"],
203 None,
204 ),
205 "messages send" => (
206 &[
207 "messages", "send", "--chat", "CHAT_ID", "--body", "-", "--yes",
208 ],
209 Some("On it."),
210 ),
211 "messages status" => (
212 &[
213 "messages",
214 "status",
215 "--chat",
216 "CHAT_ID",
217 "--client-message-id",
218 "100000000000000001",
219 ],
220 None,
221 ),
222 "desktop supervise" => (&["desktop", "supervise"], Some("")),
223 "desktop status" => (&["desktop", "status", "--port", "9222"], None),
224 "desktop login" => (
225 &["desktop", "login", "--desktop-client", "browser", "--yes"],
226 None,
227 ),
228 "desktop connect" => (&["desktop", "connect", "--port", "9222", "--yes"], None),
229 "desktop disconnect" => (&["desktop", "disconnect", "--port", "9222", "--yes"], None),
230 "desktop recover" => (&["desktop", "recover", "--port", "9222"], None),
231 "tui" => (&["tui", "--demo", "--snapshot"], None),
232 "doctor" => (&["doctor", "--offline"], None),
233 "capabilities" => (&["capabilities"], None),
234 "schema" => (&["schema", "--command", "messages send"], None),
235 "completions" => (&["completions", "bash"], None),
236 _ => return Value::Null,
237 };
238 let mut value = json!({"args": args});
239 if let Some(stdin) = stdin {
240 value["stdin"] = json!(stdin);
241 }
242 value
243}
244
245pub fn generate(command_filter: Option<&str>) -> Value {
246 let identity = vec![
247 field("id", "string"),
248 field("displayName", "string"),
249 field("userPrincipalName", "string"),
250 nullable_field("mail", "string"),
251 ];
252 let mut commands = vec![
253 {
254 let mut value = single(
255 "init",
256 "Guided account setup in a terminal without connection flags; otherwise configure a Graph or experimental desktop profile. Browser setup verifies a chat read; native setup may restart Teams after confirmation",
257 "non_idempotent",
258 vec![
259 field("profile", "string"),
260 field("backend", "string"),
261 field("config_path", "string"),
262 nullable_field("signed_in", "boolean"),
263 field("client_id", "string"),
264 field("tenant", "string"),
265 field("channel_history_requested", "boolean"),
266 nullable_field("credential_store", "string"),
267 nullable_field("pass_store_dir", "string"),
268 field("read_only", "boolean"),
269 nullable_field("desktop_port", "integer"),
270 nullable_field("desktop_account", "string"),
271 nullable_field("desktop_client", "string"),
272 nullable_field("browser_auto_start", "boolean"),
273 nullable_field("endpoint_available", "boolean"),
274 nullable_field("connection", "object"),
275 field("session_verified", "boolean"),
276 nullable_field("send_capability_verified", "boolean"),
277 nullable_field("teams_restarted", "boolean"),
278 nullable_field("browser_started", "boolean"),
279 nullable_field("chat_list_empty", "boolean"),
280 ],
281 );
282 value["args"] = json!([
283 {"name":"--backend","type":"string","enum":["graph","desktop"],"default":"graph","description":"Connection method; default Graph in scripts, guided account selection in a terminal with no connection flags"},
284 {"name":"--desktop-port","type":"integer","description":"Loopback endpoint port (desktop only; native/browser default 9222, browser-core default 9223)","x-minimum":1024,"x-maximum":65535},
285 {"name":"--desktop-client","type":"string","enum":["native","browser","browser-core"],"default":"native","description":"Native Teams, installed Chrome/Edge, or a managed lightweight browser; core defaults to personal and downloads a matching browser pair on first sign-in"},
286 {"name":"--desktop-account","type":"string","enum":["auto","personal","work-school"],"default":"auto","description":"Select the Teams account surface; defaults to personal for browser-core, auto otherwise; multiple cached accounts remain unsupported"},
287 arg(
288 "--client-id",
289 "string",
290 "Override the maintained teams-cli public-client application ID"
291 ),
292 arg("--tenant", "string", "Tenant ID, domain, or organizations"),
293 arg(
294 "--no-login",
295 "boolean",
296 "Save configuration without sign-in or contacting Teams"
297 ),
298 {"name":"--credential-store","type":"string","enum":["native","pass"],"description":"Graph only: OS credential store (Windows DPAPI under WSL) or an initialized GPG-encrypted pass store. Saved per profile; existing profiles keep their choice"},
299 arg("--pass-store-dir", "string", "Existing pass directory (Graph/pass only); resolved to an absolute path and saved per profile"),
300 arg("--device-code", "boolean", "Use device-code sign-in"),
301 arg(
302 "--channel-history",
303 "boolean",
304 "Request admin-consented channel-history access"
305 ),
306 {"name":"--read-only","type":"boolean","x-conflicts":["--allow-send"],"description":"Block remote write operations"},
307 {"name":"--allow-send","type":"boolean","x-requires":{"--backend":"desktop"},"x-conflicts":["--read-only"],"description":"Allow a desktop profile to send chat messages (desktop profiles are read-only otherwise)"},
308 {"name":"--auto-start","type":"boolean","x-requires":{"--backend":"desktop"},"description":"Opt in to reopening a dedicated browser when the interactive inbox starts; never applies to native Teams or CLI read commands"}
309 ]);
310 value["confirmation_bypass_arg"] = json!("--yes");
311 value
312 },
313 {
314 let mut value = single(
315 "auth login",
316 "Sign in with delegated Microsoft OAuth and protect credentials in the profile's saved credential store",
317 "idempotent",
318 vec![
319 field("profile", "string"),
320 field("expires_at", "integer"),
321 nullable_field("scope", "string"),
322 field("channel_history_requested", "boolean"),
323 ],
324 );
325 value["args"] = json!([
326 arg(
327 "--device-code",
328 "boolean",
329 "Use device-code sign-in for headless and remote environments"
330 ),
331 arg(
332 "--channel-history",
333 "boolean",
334 "Request admin-consented channel-history access"
335 )
336 ]);
337 value
338 },
339 single(
340 "auth logout",
341 "Remove locally stored delegated credentials",
342 "idempotent",
343 vec![field("profile", "string"), field("signed_in", "boolean")],
344 ),
345 {
346 let mut value = single(
347 "auth status",
348 "Verify authentication and inspect credential state",
349 "read_only",
350 vec![
351 field("profile", "string"),
352 field("configured", "boolean"),
353 field("backend", "string"),
354 nullable_field("signed_in", "boolean"),
355 nullable_field("credential_store", "string"),
356 nullable_field("credential_error", "string"),
357 field("verified", "boolean"),
358 nullable_field("identity", "object"),
359 field("config_path", "string"),
360 field("read_only", "boolean"),
361 nullable_array_field("granted_scopes", "string"),
362 nullable_field("channel_history", "boolean"),
363 nullable_field("endpoint_available", "boolean"),
364 nullable_field("connection", "object"),
365 field("session_verified", "boolean"),
366 ],
367 );
368 value["args"] = json!([arg(
369 "--offline",
370 "boolean",
371 "Inspect local state without contacting Graph or the desktop endpoint"
372 )]);
373 value
374 },
375 single(
376 "profile list",
377 "List configured profiles and identify the active one",
378 "read_only",
379 vec![
380 json!({"name": "items", "type": "array", "items": {"type": "object", "fields": [
381 field("name", "string"),
382 field("active", "boolean"),
383 json!({"name": "backend", "type": "string", "enum": ["graph", "desktop"]}),
384 field("client_id", "string"),
385 field("tenant", "string"),
386 field("read_only", "boolean")
387 ]}}),
388 field("total", "integer"),
389 ],
390 ),
391 {
392 let mut value = single(
393 "profile use",
394 "Select the default profile for future commands",
395 "idempotent",
396 vec![field("profile", "string"), field("active", "boolean")],
397 );
398 value["args"] = json!([required_arg("name", "string", "Profile name")]);
399 value
400 },
401 {
402 let mut value = single(
403 "profile remove",
404 "Remove a profile and its stored credential",
405 "idempotent",
406 vec![field("profile", "string"), field("removed", "boolean")],
407 );
408 value["args"] = json!([required_arg("name", "string", "Profile name")]);
409 value["confirmation_bypass_arg"] = json!("--yes");
410 value
411 },
412 single(
413 "config show",
414 "Print the resolved profile without credentials",
415 "read_only",
416 vec![
417 field("profile", "string"),
418 field("backend", "string"),
419 nullable_field("desktop_port", "integer"),
420 nullable_field("desktop_account", "string"),
421 nullable_field("desktop_client", "string"),
422 field("browser_auto_start", "boolean"),
423 nullable_field("credential_store", "string"),
424 nullable_field("pass_store_dir", "string"),
425 field("client_id", "string"),
426 field("tenant", "string"),
427 field("read_only", "boolean"),
428 field("config_path", "string"),
429 ],
430 ),
431 single(
432 "config path",
433 "Print the absolute configuration file path",
434 "read_only",
435 vec![],
436 ),
437 single(
438 "whoami",
439 "Show the signed-in Microsoft 365 identity",
440 "read_only",
441 identity,
442 ),
443 offset_paged(
444 "list",
445 "List teams joined by the signed-in user",
446 vec![
447 field("id", "string"),
448 field("displayName", "string"),
449 nullable_field("description", "string"),
450 ],
451 ),
452 {
453 let mut value = offset_paged(
454 "channels list",
455 "List channels in a team",
456 vec![
457 field("id", "string"),
458 field("displayName", "string"),
459 nullable_field("description", "string"),
460 field("membershipType", "string"),
461 ],
462 );
463 value["args"]
464 .as_array_mut()
465 .unwrap()
466 .insert(0, required_arg("team", "string", "Team ID"));
467 value
468 },
469 {
470 let mut value = single(
471 "people search",
472 "Find up to 50 verified people by name or email; refine the query when truncated; never sends or creates a chat",
473 "read_only",
474 vec![
475 field("id", "string"),
476 field("displayName", "string"),
477 nullable_field("email", "string"),
478 field("source", "string"),
479 ],
480 );
481 value["cardinality"] = json!("bounded");
482 value["args"] = json!([
483 {"name":"query","type":"string","required":true,"x-min-length":2,"x-max-length":256,"description":"Name or email prefix; 2–256 characters, no control characters"},
484 {"name":"--limit","type":"integer","default":20,"x-minimum":1,"x-maximum":50,"description":"Maximum matching people; refine the query for more specific results"}
485 ]);
486 value
487 },
488 {
489 let mut value = single(
490 "chats create",
491 "Create or reuse a one-to-one conversation with a verified person; requires a writable profile and confirmation; sends no message",
492 "idempotent",
493 vec![
494 field("id", "string"),
495 field("chatType", "string"),
496 nullable_field("topic", "string"),
497 nullable_field("source", "string"),
498 ],
499 );
500 value["args"] = json!([required_arg(
501 "--recipient",
502 "string",
503 "Exact person ID from people search in the same profile"
504 )]);
505 value["confirmation_bypass_arg"] = json!("--yes");
506 value
507 },
508 paged(
509 "chats list",
510 "List one-to-one, group, and meeting chats",
511 vec![
512 described(
513 field("id", "string"),
514 "Chat ID for messages list/send; the chat with yourself is 48:notes on desktop",
515 ),
516 json!({"name": "chatType", "type": "string", "enum": ["oneOnOne", "group", "meeting"]}),
517 nullable_field("topic", "string"),
518 described(field("lastUpdatedDateTime", "string"), "RFC 3339"),
519 json!({"name": "members", "type": "array", "items": {"type": "object", "fields": [
520 field("userId", "string"),
521 nullable_field("displayName", "string"),
522 described(field("avatarData", "string"), "Optional desktop-only raster data URI, from a photo already loaded by the selected Teams page; absent when unavailable")
523 ]}}),
524 described(
525 field("avatarData", "string"),
526 "Optional desktop-only one-to-one chat avatar as a raster data URI; member photos may be available for groups",
527 ),
528 object_field(
529 "viewpoint",
530 true,
531 "The signed-in user's read state",
532 vec![nullable_field("lastMessageReadDateTime", "string")],
533 ),
534 object_field(
535 "lastMessagePreview",
536 true,
537 "Latest message; desktop only",
538 vec![
539 field("id", "string"),
540 field("createdDateTime", "string"),
541 sender(),
542 body(),
543 ],
544 ),
545 ],
546 ),
547 {
548 let mut value = paged(
549 "messages list",
550 "List messages from one chat, or from a channel with optional admin-consented access",
551 vec![
552 field("id", "string"),
553 described(
554 field("createdDateTime", "string"),
555 "RFC 3339; pages are newest first",
556 ),
557 sender(),
558 body(),
559 described(nullable_field("deleted", "boolean"), "Desktop only"),
560 described(
561 nullable_field("clientMessageId", "string"),
562 "Desktop cache only; exact send correlation ID",
563 ),
564 described(
565 nullable_field("status", "string"),
566 "Desktop cache only; raw Teams status",
567 ),
568 ],
569 );
570 let args = value["args"].as_array_mut().unwrap();
571 args.splice(0..0, conversation_args());
572 value
573 },
574 {
575 let mut value = single(
576 "messages send",
577 "Send one plain-text message as the signed-in user",
578 "non_idempotent",
579 vec![
580 described(field("id", "string"), "Message ID"),
581 nullable_field("createdDateTime", "string"),
582 nullable_field("webUrl", "string"),
583 nullable_field("chatId", "string"),
584 described(
585 nullable_field("source", "string"),
586 "\"desktop\" for desktop sends",
587 ),
588 described(
589 nullable_field("clientMessageId", "string"),
590 "Desktop only; correlation ID, not an idempotency key",
591 ),
592 described(
593 nullable_field("delivery_state", "string"),
594 "Desktop only: submitted, queued, delivered, failed or unknown",
595 ),
596 described(
597 nullable_field("status", "string"),
598 "Desktop only; Teams' own delivery label, e.g. Delivered",
599 ),
600 ],
601 );
602 let mut args = conversation_args();
603 args.push(json!({"name": "--body", "type": "string", "required": true, "x-max-length": crate::desktop::MAX_BODY,
604 "description": "Plain-text message body, or - to read it from stdin; must not be blank"}));
605 value["args"] = json!(args);
606 value["confirmation_bypass_arg"] = json!("--yes");
607 value
608 },
609 json!({"name":"tui", "description":"Open the keyboard-first terminal interface; reopens an opted-in browser session on an interactive terminal; offers a confirmed Teams debugging restart on WSL when no endpoint listens; Enter sends a chat message on a profile that allows writes", "effects":"non_idempotent", "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")]}),
610 {
611 let mut value = single(
612 "doctor",
613 "Check configuration and the selected backend's access (desktop checks endpoint metadata)",
614 "read_only",
615 vec![
616 field("backend", "string"),
617 array_field("checks", "object"),
618 field("healthy", "boolean"),
619 nullable_field("connection", "object"),
620 nullable_field("endpoint_available", "boolean"),
621 field("session_verified", "boolean"),
622 debugging_setup(),
623 ],
624 );
625 value["args"] = json!([arg(
626 "--offline",
627 "boolean",
628 "Check local state without contacting Graph or the desktop endpoint"
629 )]);
630 value
631 },
632 single(
633 "capabilities",
634 "Describe supported and deliberately unsupported capabilities",
635 "read_only",
636 vec![
637 array_field("supported", "string"),
638 array_field("unsupported", "string"),
639 array_field("account_types", "string"),
640 field("api", "string"),
641 array_field("notes", "string"),
642 field("backends", "object"),
643 ],
644 ),
645 json!({
646 "name":"schema",
647 "description":"Emit the offline CLI Spec v0.3 contract",
648 "effects":"read_only",
649 "cardinality":"single",
650 "stdout_schema":{},
651 "args":[arg("--command", "string", "Return only one complete command path")]
652 }),
653 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")]}),
654 ];
655 let mut status = single(
656 "messages status",
657 "Reconcile one client message ID against the newest 50 cached desktop messages; absence remains unknown",
658 "read_only",
659 vec![
660 field("chatId", "string"),
661 field("clientMessageId", "string"),
662 field("found", "boolean"),
663 nullable_field("id", "string"),
664 nullable_field("status", "string"),
665 field("delivery_state", "string"),
666 field("source", "string"),
667 field("scope", "string"),
668 field("retryable", "boolean"),
669 ],
670 );
671 status["args"] = json!([
672 required_arg("--chat", "string", "Chat ID"),
673 required_arg(
674 "--client-message-id",
675 "string",
676 "18-digit correlation ID returned by send or unconfirmed error"
677 )
678 ]);
679 commands.push(status);
680 for (name, description, effects) in [
681 (
682 "desktop login",
683 "Sign in visibly, verify chats, then restart headless with the same dedicated profile; browser only, requires confirmation",
684 "non_idempotent",
685 ),
686 (
687 "desktop status",
688 "Inspect endpoint metadata and managed-session state; does not verify sign-in",
689 "read_only",
690 ),
691 (
692 "desktop connect",
693 "Open a dedicated browser, or restart native Teams with debugging; requires confirmation",
694 "non_idempotent",
695 ),
696 (
697 "desktop disconnect",
698 "Close the verified dedicated browser, or restart managed native Teams normally; requires confirmation",
699 "non_idempotent",
700 ),
701 (
702 "desktop recover",
703 "Restore interrupted native settings; browser recovery may close a verified dedicated browser and requires confirmation",
704 "idempotent",
705 ),
706 ] {
707 let mut value = single(
708 name,
709 description,
710 effects,
711 vec![
712 field("port", "integer"),
713 nullable_field("endpoint_available", "boolean"),
714 field("session_verified", "boolean"),
715 field("management", "object"),
716 nullable_field("connection", "object"),
717 nullable_field("browser_profile", "string"),
718 ],
719 );
720 value["args"] = json!([{"name":"--desktop-client","type":"string","enum":["native","browser","browser-core"],"description":"Override the configured desktop client"},{"name":"--port","type":"integer","x-minimum":1024,"x-maximum":65535,"description":"Override selected profile port"}]);
721 if name == "desktop connect" {
722 value["args"].as_array_mut().expect("args").push(arg(
723 "--visible",
724 "boolean",
725 "Open the browser visibly for sign-in; browser client only",
726 ));
727 }
728 if matches!(
729 name,
730 "desktop login" | "desktop connect" | "desktop disconnect" | "desktop recover"
731 ) {
732 value["confirmation_bypass_arg"] = json!("--yes");
733 }
734 commands.push(value);
735 }
736 let mut supervise = single(
737 "desktop supervise",
738 "Application browser lifetime protocol: emits a client token, then closes only application-owned browsers when stdin closes and their last user exits; macOS/Linux only",
739 "idempotent",
740 vec![field("protocol", "integer"), field("client", "string")],
741 );
742 supervise["args"] = json!([arg(
743 "--keep-alive",
744 "boolean",
745 "Preserve newly started browsers after the application exits"
746 )]);
747 commands.push(supervise);
748 for command in &mut commands {
749 let name = command["name"].as_str().unwrap_or_default().to_string();
750 let mut errors = vec!["invalid_input", "unexpected_error"];
751 errors.extend(command_errors(&name));
752 command["errors"] = json!(errors);
753 let example = example(&name);
754 if !example.is_null() {
755 command["example"] = example;
756 }
757 command["extensions"] = json!({"backends": match command["name"].as_str().unwrap_or_default() {
758 "auth login" | "auth logout" | "whoami" | "list" | "channels list" => vec!["graph"],
759 "desktop status" | "desktop login" | "desktop connect" | "desktop disconnect" | "desktop recover" | "desktop supervise" | "messages status" => vec!["desktop"],
760 _ => vec!["graph", "desktop"],
761 }});
762 match command["name"].as_str().unwrap_or_default() {
763 "desktop supervise" => {
764 command["extensions"]["stdin_lifetime"] = json!(true);
765 }
766 "tui" => {
767 command["extensions"]["desktop"] = json!(
768 "macOS/Windows/WSL (browser client also on Linux); chats and chat messages from the Teams client's local cache; sending needs a profile created with --allow-send"
769 )
770 }
771 "chats list" | "messages list" => {
772 command["extensions"]["desktop"] = json!(
773 "macOS/Windows/WSL (browser client also on Linux); chats, not channels; read from the Teams client's local cache; pages carry source \"desktop_cache\""
774 )
775 }
776 "messages send" => {
777 command["extensions"]["desktop"] = json!(
778 "macOS/Windows/WSL (browser client also on Linux); plain-text chat messages through the running Teams window; needs a profile created with `init --allow-send`; prompts on a terminal and needs --yes otherwise; results carry source \"desktop\", clientMessageId and Teams' status"
779 )
780 }
781 "people search" => {
782 command["extensions"]["graph"] = json!(
783 "Work/school directory profiles; User.ReadBasic.All consent required; guest callers and personal accounts unsupported"
784 );
785 command["extensions"]["desktop"] = json!(
786 "Uses the selected Teams client's own people search; IDs belong to that account and must not be reused with another profile"
787 );
788 }
789 "chats create" => {
790 command["extensions"]["graph"] = json!(
791 "Chat.Create; includes the signed-in user and verified recipient; returns an existing one-on-one chat when present; no automatic retry after an unconfirmed response"
792 );
793 command["extensions"]["desktop"] = json!(
794 "Needs a profile created with --allow-send; opens or creates a one-to-one chat through the selected Teams client; requires --yes without a terminal; sends no message"
795 );
796 }
797 _ => {}
798 }
799 }
800 if let Some(filter) = command_filter {
801 commands.retain(|value| value["name"] == filter);
802 }
803 json!({
804 "$schema": "https://clispec.dev/schema/v0.3.json",
805 "clispec": "0.3",
806 "name": "teams",
807 "version": env!("CARGO_PKG_VERSION"),
808 "description": "A Microsoft Teams CLI and TUI designed equally for humans and agents",
809 "output": {"tty": "text", "piped": "json"},
810 "global_args": [
811 {"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
812 {"name":"--profile", "type":"string", "x-env":"TEAMS_PROFILE", "description":"Configuration profile; defaults to the active one"},
813 {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"},
814 {"name":"--yes", "short":"-y", "type":"boolean", "description":"Confirm without prompting (conversation creation, desktop sends, profile removal)"}
815 ],
816 "commands": commands,
817 "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
818 "extensions": {
819 "backends":backend_capabilities(),
820 "authentication":"delegated_oauth",
821 "api":"Microsoft Graph v1.0",
822 "default_client_id": crate::config::DEFAULT_CLIENT_ID,
823 "login_modes":["browser_pkce","device_code"],
824 "read_only_env":"TEAMS_READ_ONLY",
825 "environment":{
826 "TEAMS_PROFILE":"Profile to use when --profile is not given",
827 "TEAMS_READ_ONLY":"true blocks every remote write; false cannot unlock a desktop profile",
828 "TEAMS_ACCESS_TOKEN":"Graph access token that overrides the stored credential for this run",
829 "TEAMS_CLIENT_ID":"Graph application ID override",
830 "TEAMS_TENANT":"Graph tenant override",
831 "TEAMS_DESKTOP_HELPER":"Path to a teams.exe speaking the same helper protocol to use as the WSL desktop helper"
832 },
833 "collection_envelope":{
834 "items":"array of records described by the command's output_fields",
835 "next_cursor":"cursor-paged commands: pass to --cursor for the next page; absent on the last page",
836 "next_offset":"offset-paged commands: pass to --offset for the next page; absent on the last page",
837 "truncated":"true when more records exist",
838 "source":"\"desktop_cache\" when records come from the Teams client's local cache; absent for Graph"
839 },
840 "error_envelope":"stderr carries {\"error\":{\"kind\",\"message\",\"retryable\"}} when stdout is not a terminal or --output json is given; rate_limit adds retry_after_seconds when Microsoft sends it; uncertain desktop sends add chatId, clientMessageId and delivery_state unknown",
841 "idempotency":"Sends cannot be made idempotent: neither Graph nor Teams accepts a caller-supplied key. Never resend after unconfirmed without reading the conversation first.",
842 "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
843 "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
844 }
845 })
846}
847
848pub fn backend_capabilities() -> Value {
849 json!({
850 "graph":{"default":true,"authentication":"delegated_oauth","api":"Microsoft Graph v1.0","account_types":["Microsoft 365 work or school"]},
851 "desktop":{"experimental":true,"read_only_by_default":true,"platforms":["macos","windows","wsl","linux"],"clients":{"native":["macos","windows","wsl"],"browser":["macos","linux","wsl"],"browser-core":["macos","linux","wsl"]},"browser_validation":"experimental; core personal messaging validated on macOS only","browser_core":{"account_types":["personal"],"bootstrap":"build-pinned; unknown builds refused","browser_versions":"automatically installed matching Chrome for Testing and chrome-headless-shell; paired path overrides supported","live_validation":["macos-personal"]},
852 "account_types":["Microsoft 365 work or school","personal Microsoft accounts"],"account_selection":"account_type_then_single_account_only",
853 "authentication":"teams_desktop_session_unverified","supported":["configuration","connection metadata","doctor","auth status","managed connect/status/disconnect/recover","delivery reconciliation by client message ID","people search","create or reuse one-to-one chats (opt-in)","chats (macos, windows, wsl; browser also on linux)","chat messages (macos, windows, wsl; browser also on linux)","send plain-text chat messages (macos, windows, wsl; browser also on linux; opt-in)","TUI (macos, windows, wsl; browser also on linux)"],
854 "unsupported":["teams and channels","identity","channel messages, replies, mentions, attachments and edits","subscriptions","unconfirmed Teams launch or restart"],
855 "requires":"native: Teams-owned loopback debugging endpoint (WSL needs Windows interop); browser: installed Chrome/Chromium/Edge with a dedicated profile and visible initial sign-in",
856 "transport":{"macos":"native_loopback","windows":"windows_native","wsl":"windows_helper_stdio","browser":"dedicated_chromium_loopback"},
857 "chat_source":"desktop_cache","session_verified":false,"default_port":crate::config::DEFAULT_DESKTOP_PORT}
858 })
859}