Skip to main content

teams_cli/
schema.rs

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
113/// Error kinds each command can return, beyond `invalid_input` and `unexpected_error`,
114/// which every command can.
115fn 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}