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 preserve prior settings; recover restores interrupted setup settings",
29        vec![
30            described(
31                field("shell", "string"),
32                "zsh, 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" => &["auth", "tty_required", "desktop_error"],
135        "auth login" => &["auth", "tty_required", "unsupported"],
136        "auth logout" => &["unsupported"],
137        "auth status" => &["auth", "rate_limit", "api_error", "desktop_error"],
138        "profile remove" => &["confirmation_required"],
139        "whoami" | "list" | "channels list" => GRAPH_READ,
140        "chats list" | "messages list" => CONVERSATION_READ,
141        "messages send" => &[
142            "auth",
143            "read_only",
144            "permission_denied",
145            "not_found",
146            "rate_limit",
147            "api_error",
148            "confirmation_required",
149            "unconfirmed",
150            "unsupported",
151            "desktop_error",
152        ],
153        "messages status" => &["unsupported", "desktop_error"],
154        "desktop status" | "desktop recover" => &["desktop_error", "unsupported"],
155        "desktop connect" | "desktop disconnect" => {
156            &["desktop_error", "unsupported", "confirmation_required"]
157        }
158        "tui" => &["tty_required"],
159        "doctor" => &["unhealthy"],
160        "schema" => &["not_found"],
161        _ => &[],
162    }
163}
164
165fn example(name: &str) -> Value {
166    let (args, stdin): (&[&str], Option<&str>) = match name {
167        "init" => (&["init", "--no-login"], None),
168        "auth login" => (&["auth", "login", "--device-code"], None),
169        "auth logout" => (&["auth", "logout"], None),
170        "auth status" => (&["auth", "status", "--offline"], None),
171        "profile list" => (&["profile", "list"], None),
172        "profile use" => (&["profile", "use", "work"], None),
173        "profile remove" => (&["profile", "remove", "old", "--yes"], None),
174        "config show" => (&["config", "show"], None),
175        "config path" => (&["config", "path"], None),
176        "whoami" => (&["whoami"], None),
177        "list" => (&["list", "--limit", "20"], None),
178        "channels list" => (&["channels", "list", "TEAM_ID"], None),
179        "chats list" => (
180            &[
181                "chats",
182                "list",
183                "--limit",
184                "20",
185                "--fields",
186                "id,topic,lastUpdatedDateTime",
187            ],
188            None,
189        ),
190        "messages list" => (
191            &["messages", "list", "--chat", "CHAT_ID", "--limit", "20"],
192            None,
193        ),
194        "messages send" => (
195            &[
196                "messages", "send", "--chat", "CHAT_ID", "--body", "-", "--yes",
197            ],
198            Some("On it."),
199        ),
200        "messages status" => (
201            &[
202                "messages",
203                "status",
204                "--chat",
205                "CHAT_ID",
206                "--client-message-id",
207                "100000000000000001",
208            ],
209            None,
210        ),
211        "desktop status" => (&["desktop", "status", "--port", "9222"], None),
212        "desktop connect" => (&["desktop", "connect", "--port", "9222", "--yes"], None),
213        "desktop disconnect" => (&["desktop", "disconnect", "--port", "9222", "--yes"], None),
214        "desktop recover" => (&["desktop", "recover", "--port", "9222"], None),
215        "tui" => (&["tui", "--demo", "--snapshot"], None),
216        "doctor" => (&["doctor", "--offline"], None),
217        "capabilities" => (&["capabilities"], None),
218        "schema" => (&["schema", "--command", "messages send"], None),
219        "completions" => (&["completions", "bash"], None),
220        _ => return Value::Null,
221    };
222    let mut value = json!({"args": args});
223    if let Some(stdin) = stdin {
224        value["stdin"] = json!(stdin);
225    }
226    value
227}
228
229pub fn generate(command_filter: Option<&str>) -> Value {
230    let identity = vec![
231        field("id", "string"),
232        field("displayName", "string"),
233        field("userPrincipalName", "string"),
234        nullable_field("mail", "string"),
235    ];
236    let mut commands = vec![
237        {
238            let mut value = single(
239                "init",
240                "Configure a Graph or experimental desktop profile",
241                "idempotent",
242                vec![
243                    field("profile", "string"),
244                    field("backend", "string"),
245                    field("config_path", "string"),
246                    nullable_field("signed_in", "boolean"),
247                    field("client_id", "string"),
248                    field("tenant", "string"),
249                    field("channel_history_requested", "boolean"),
250                    field("read_only", "boolean"),
251                    nullable_field("desktop_port", "integer"),
252                    nullable_field("desktop_account", "string"),
253                    nullable_field("endpoint_available", "boolean"),
254                    nullable_field("connection", "object"),
255                    field("session_verified", "boolean"),
256                    debugging_setup(),
257                ],
258            );
259            value["args"] = json!([
260                {"name":"--backend","type":"string","enum":["graph","desktop"],"default":"graph","description":"Connection method; desktop supports diagnostics, chats and opt-in chat sending"},
261                {"name":"--desktop-port","type":"integer","description":"Existing loopback endpoint port (desktop only; default 9222)","x-minimum":1024,"x-maximum":65535},
262                {"name":"--desktop-account","type":"string","enum":["auto","personal","work-school"],"default":"auto","description":"Select the Teams account surface; multiple cached accounts within it remain unsupported"},
263                arg(
264                    "--client-id",
265                    "string",
266                    "Override the maintained teams-cli public-client application ID"
267                ),
268                arg("--tenant", "string", "Tenant ID, domain, or organizations"),
269                arg(
270                    "--no-login",
271                    "boolean",
272                    "Save configuration without sign-in or desktop endpoint access"
273                ),
274                arg("--device-code", "boolean", "Use device-code sign-in"),
275                arg(
276                    "--channel-history",
277                    "boolean",
278                    "Request admin-consented channel-history access"
279                ),
280                {"name":"--read-only","type":"boolean","x-conflicts":["--allow-send"],"description":"Block remote write operations"},
281                {"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)"}
282            ]);
283            value
284        },
285        {
286            let mut value = single(
287                "auth login",
288                "Sign in with delegated Microsoft OAuth and protect credentials in the OS store",
289                "idempotent",
290                vec![
291                    field("profile", "string"),
292                    field("expires_at", "integer"),
293                    nullable_field("scope", "string"),
294                    field("channel_history_requested", "boolean"),
295                ],
296            );
297            value["args"] = json!([
298                arg(
299                    "--device-code",
300                    "boolean",
301                    "Use device-code sign-in for headless and remote environments"
302                ),
303                arg(
304                    "--channel-history",
305                    "boolean",
306                    "Request admin-consented channel-history access"
307                )
308            ]);
309            value
310        },
311        single(
312            "auth logout",
313            "Remove locally stored delegated credentials",
314            "idempotent",
315            vec![field("profile", "string"), field("signed_in", "boolean")],
316        ),
317        {
318            let mut value = single(
319                "auth status",
320                "Verify authentication and inspect credential state",
321                "read_only",
322                vec![
323                    field("profile", "string"),
324                    field("configured", "boolean"),
325                    field("backend", "string"),
326                    nullable_field("signed_in", "boolean"),
327                    field("verified", "boolean"),
328                    nullable_field("identity", "object"),
329                    field("config_path", "string"),
330                    field("read_only", "boolean"),
331                    nullable_array_field("granted_scopes", "string"),
332                    nullable_field("channel_history", "boolean"),
333                    nullable_field("endpoint_available", "boolean"),
334                    nullable_field("connection", "object"),
335                    field("session_verified", "boolean"),
336                ],
337            );
338            value["args"] = json!([arg(
339                "--offline",
340                "boolean",
341                "Inspect local state without contacting Graph or the desktop endpoint"
342            )]);
343            value
344        },
345        single(
346            "profile list",
347            "List configured profiles and identify the active one",
348            "read_only",
349            vec![
350                json!({"name": "items", "type": "array", "items": {"type": "object", "fields": [
351                    field("name", "string"),
352                    field("active", "boolean"),
353                    json!({"name": "backend", "type": "string", "enum": ["graph", "desktop"]}),
354                    field("client_id", "string"),
355                    field("tenant", "string"),
356                    field("read_only", "boolean")
357                ]}}),
358                field("total", "integer"),
359            ],
360        ),
361        {
362            let mut value = single(
363                "profile use",
364                "Select the default profile for future commands",
365                "idempotent",
366                vec![field("profile", "string"), field("active", "boolean")],
367            );
368            value["args"] = json!([required_arg("name", "string", "Profile name")]);
369            value
370        },
371        {
372            let mut value = single(
373                "profile remove",
374                "Remove a profile and its stored credential",
375                "idempotent",
376                vec![field("profile", "string"), field("removed", "boolean")],
377            );
378            value["args"] = json!([required_arg("name", "string", "Profile name")]);
379            value["confirmation_bypass_arg"] = json!("--yes");
380            value
381        },
382        single(
383            "config show",
384            "Print the resolved profile without credentials",
385            "read_only",
386            vec![
387                field("profile", "string"),
388                field("backend", "string"),
389                nullable_field("desktop_port", "integer"),
390                nullable_field("desktop_account", "string"),
391                field("client_id", "string"),
392                field("tenant", "string"),
393                field("read_only", "boolean"),
394                field("config_path", "string"),
395            ],
396        ),
397        single(
398            "config path",
399            "Print the absolute configuration file path",
400            "read_only",
401            vec![],
402        ),
403        single(
404            "whoami",
405            "Show the signed-in Microsoft 365 identity",
406            "read_only",
407            identity,
408        ),
409        offset_paged(
410            "list",
411            "List teams joined by the signed-in user",
412            vec![
413                field("id", "string"),
414                field("displayName", "string"),
415                nullable_field("description", "string"),
416            ],
417        ),
418        {
419            let mut value = offset_paged(
420                "channels list",
421                "List channels in a team",
422                vec![
423                    field("id", "string"),
424                    field("displayName", "string"),
425                    nullable_field("description", "string"),
426                    field("membershipType", "string"),
427                ],
428            );
429            value["args"]
430                .as_array_mut()
431                .unwrap()
432                .insert(0, required_arg("team", "string", "Team ID"));
433            value
434        },
435        paged(
436            "chats list",
437            "List one-to-one, group, and meeting chats",
438            vec![
439                described(
440                    field("id", "string"),
441                    "Chat ID for messages list/send; the chat with yourself is 48:notes on desktop",
442                ),
443                json!({"name": "chatType", "type": "string", "enum": ["oneOnOne", "group", "meeting"]}),
444                nullable_field("topic", "string"),
445                described(field("lastUpdatedDateTime", "string"), "RFC 3339"),
446                json!({"name": "members", "type": "array", "items": {"type": "object", "fields": [
447                    field("userId", "string"),
448                    nullable_field("displayName", "string")
449                ]}}),
450                object_field(
451                    "viewpoint",
452                    true,
453                    "The signed-in user's read state",
454                    vec![nullable_field("lastMessageReadDateTime", "string")],
455                ),
456                object_field(
457                    "lastMessagePreview",
458                    true,
459                    "Latest message; desktop only",
460                    vec![
461                        field("id", "string"),
462                        field("createdDateTime", "string"),
463                        sender(),
464                        body(),
465                    ],
466                ),
467            ],
468        ),
469        {
470            let mut value = paged(
471                "messages list",
472                "List messages from one chat, or from a channel with optional admin-consented access",
473                vec![
474                    field("id", "string"),
475                    described(
476                        field("createdDateTime", "string"),
477                        "RFC 3339; pages are newest first",
478                    ),
479                    sender(),
480                    body(),
481                    described(nullable_field("deleted", "boolean"), "Desktop only"),
482                    described(
483                        nullable_field("clientMessageId", "string"),
484                        "Desktop cache only; exact send correlation ID",
485                    ),
486                    described(
487                        nullable_field("status", "string"),
488                        "Desktop cache only; raw Teams status",
489                    ),
490                ],
491            );
492            let args = value["args"].as_array_mut().unwrap();
493            args.splice(0..0, conversation_args());
494            value
495        },
496        {
497            let mut value = single(
498                "messages send",
499                "Send one plain-text message as the signed-in user",
500                "non_idempotent",
501                vec![
502                    described(field("id", "string"), "Message ID"),
503                    nullable_field("createdDateTime", "string"),
504                    nullable_field("webUrl", "string"),
505                    nullable_field("chatId", "string"),
506                    described(
507                        nullable_field("source", "string"),
508                        "\"desktop\" for desktop sends",
509                    ),
510                    described(
511                        nullable_field("clientMessageId", "string"),
512                        "Desktop only; correlation ID, not an idempotency key",
513                    ),
514                    described(
515                        nullable_field("delivery_state", "string"),
516                        "Desktop only: submitted, queued, delivered, failed or unknown",
517                    ),
518                    described(
519                        nullable_field("status", "string"),
520                        "Desktop only; Teams' own delivery label, e.g. Delivered",
521                    ),
522                ],
523            );
524            let mut args = conversation_args();
525            args.push(json!({"name": "--body", "type": "string", "required": true, "x-max-length": crate::desktop::MAX_BODY,
526                "description": "Plain-text message body, or - to read it from stdin; must not be blank"}));
527            value["args"] = json!(args);
528            value["confirmation_bypass_arg"] = json!("--yes");
529            value
530        },
531        json!({"name":"tui", "description":"Open the keyboard-first terminal interface; 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")]}),
532        {
533            let mut value = single(
534                "doctor",
535                "Check configuration and the selected backend's access (desktop checks endpoint metadata)",
536                "read_only",
537                vec![
538                    field("backend", "string"),
539                    array_field("checks", "object"),
540                    field("healthy", "boolean"),
541                    nullable_field("connection", "object"),
542                    nullable_field("endpoint_available", "boolean"),
543                    field("session_verified", "boolean"),
544                    debugging_setup(),
545                ],
546            );
547            value["args"] = json!([arg(
548                "--offline",
549                "boolean",
550                "Check local state without contacting Graph or the desktop endpoint"
551            )]);
552            value
553        },
554        single(
555            "capabilities",
556            "Describe supported and deliberately unsupported capabilities",
557            "read_only",
558            vec![
559                array_field("supported", "string"),
560                array_field("unsupported", "string"),
561                array_field("account_types", "string"),
562                field("api", "string"),
563                array_field("notes", "string"),
564                field("backends", "object"),
565            ],
566        ),
567        json!({
568            "name":"schema",
569            "description":"Emit the offline CLI Spec v0.3 contract",
570            "effects":"read_only",
571            "cardinality":"single",
572            "stdout_schema":{},
573            "args":[arg("--command", "string", "Return only one complete command path")]
574        }),
575        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")]}),
576    ];
577    let mut status = single(
578        "messages status",
579        "Reconcile one client message ID against the newest 50 cached desktop messages; absence remains unknown",
580        "read_only",
581        vec![
582            field("chatId", "string"),
583            field("clientMessageId", "string"),
584            field("found", "boolean"),
585            nullable_field("id", "string"),
586            nullable_field("status", "string"),
587            field("delivery_state", "string"),
588            field("source", "string"),
589            field("scope", "string"),
590            field("retryable", "boolean"),
591        ],
592    );
593    status["args"] = json!([
594        required_arg("--chat", "string", "Chat ID"),
595        required_arg(
596            "--client-message-id",
597            "string",
598            "18-digit correlation ID returned by send or unconfirmed error"
599        )
600    ]);
601    commands.push(status);
602    for (name, description, effects) in [
603        (
604            "desktop status",
605            "Inspect endpoint metadata and managed-session state; does not verify sign-in",
606            "read_only",
607        ),
608        (
609            "desktop connect",
610            "Explicitly restart Teams with debugging; preserve settings and never adopt an existing endpoint",
611            "non_idempotent",
612        ),
613        (
614            "desktop disconnect",
615            "Explicitly restart this managed Teams process normally; preserve unrelated processes and settings",
616            "non_idempotent",
617        ),
618        (
619            "desktop recover",
620            "Restore interrupted setup settings without restarting Teams or accessing the endpoint",
621            "idempotent",
622        ),
623    ] {
624        let mut value = single(
625            name,
626            description,
627            effects,
628            vec![
629                field("port", "integer"),
630                nullable_field("endpoint_available", "boolean"),
631                field("session_verified", "boolean"),
632                field("management", "object"),
633                nullable_field("connection", "object"),
634            ],
635        );
636        value["args"] = json!([{"name":"--port","type":"integer","x-minimum":1024,"x-maximum":65535,"description":"Override selected profile port"}]);
637        if matches!(name, "desktop connect" | "desktop disconnect") {
638            value["confirmation_bypass_arg"] = json!("--yes");
639        }
640        commands.push(value);
641    }
642    for command in &mut commands {
643        let name = command["name"].as_str().unwrap_or_default().to_string();
644        let mut errors = vec!["invalid_input", "unexpected_error"];
645        errors.extend(command_errors(&name));
646        command["errors"] = json!(errors);
647        let example = example(&name);
648        if !example.is_null() {
649            command["example"] = example;
650        }
651        command["extensions"] = json!({"backends": match command["name"].as_str().unwrap_or_default() {
652            "auth login" | "auth logout" | "whoami" | "list" | "channels list" => vec!["graph"],
653            "desktop status" | "desktop connect" | "desktop disconnect" | "desktop recover" | "messages status" => vec!["desktop"],
654            _ => vec!["graph", "desktop"],
655        }});
656        match command["name"].as_str().unwrap_or_default() {
657            "tui" => {
658                command["extensions"]["desktop"] = json!(
659                    "macOS/Windows/WSL; chats and chat messages from the Teams client's local cache; sending needs a profile created with --allow-send"
660                )
661            }
662            "chats list" | "messages list" => {
663                command["extensions"]["desktop"] = json!(
664                    "macOS/Windows/WSL; chats, not channels; read from the Teams client's local cache; pages carry source \"desktop_cache\""
665                )
666            }
667            "messages send" => {
668                command["extensions"]["desktop"] = json!(
669                    "macOS/Windows/WSL; 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"
670                )
671            }
672            _ => {}
673        }
674    }
675    if let Some(filter) = command_filter {
676        commands.retain(|value| value["name"] == filter);
677    }
678    json!({
679        "$schema": "https://clispec.dev/schema/v0.3.json",
680        "clispec": "0.3",
681        "name": "teams",
682        "version": env!("CARGO_PKG_VERSION"),
683        "description": "A Microsoft Teams CLI and TUI designed equally for humans and agents",
684        "output": {"tty": "text", "piped": "json"},
685        "global_args": [
686            {"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
687            {"name":"--profile", "type":"string", "x-env":"TEAMS_PROFILE", "description":"Configuration profile; defaults to the active one"},
688            {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"},
689            {"name":"--yes", "short":"-y", "type":"boolean", "description":"Confirm without prompting (desktop sends, profile removal)"}
690        ],
691        "commands": commands,
692        "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
693        "extensions": {
694            "backends":backend_capabilities(),
695            "authentication":"delegated_oauth",
696            "api":"Microsoft Graph v1.0",
697            "default_client_id": crate::config::DEFAULT_CLIENT_ID,
698            "login_modes":["browser_pkce","device_code"],
699            "read_only_env":"TEAMS_READ_ONLY",
700            "environment":{
701                "TEAMS_PROFILE":"Profile to use when --profile is not given",
702                "TEAMS_READ_ONLY":"true blocks every remote write; false cannot unlock a desktop profile",
703                "TEAMS_ACCESS_TOKEN":"Graph access token that overrides the stored credential for this run",
704                "TEAMS_CLIENT_ID":"Graph application ID override",
705                "TEAMS_TENANT":"Graph tenant override",
706                "TEAMS_DESKTOP_HELPER":"Path to a teams.exe speaking the same helper protocol to use as the WSL desktop helper"
707            },
708            "collection_envelope":{
709                "items":"array of records described by the command's output_fields",
710                "next_cursor":"cursor-paged commands: pass to --cursor for the next page; absent on the last page",
711                "next_offset":"offset-paged commands: pass to --offset for the next page; absent on the last page",
712                "truncated":"true when more records exist",
713                "source":"\"desktop_cache\" when records come from the Teams client's local cache; absent for Graph"
714            },
715            "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",
716            "idempotency":"Sends cannot be made idempotent: neither Graph nor Teams accepts a caller-supplied key. Never resend after unconfirmed without reading the conversation first.",
717            "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
718            "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
719        }
720    })
721}
722
723pub fn backend_capabilities() -> Value {
724    json!({
725        "graph":{"default":true,"authentication":"delegated_oauth","api":"Microsoft Graph v1.0","account_types":["Microsoft 365 work or school"]},
726        "desktop":{"experimental":true,"read_only_by_default":true,"platforms":["macos","windows","wsl"],
727            "account_types":["Microsoft 365 work or school","personal Microsoft accounts"],"account_selection":"account_type_then_single_account_only",
728            "authentication":"teams_desktop_session_unverified","supported":["configuration","connection metadata","doctor","auth status","managed connect/status/disconnect/recover","delivery reconciliation by client message ID","chats (macos, windows, wsl)","chat messages (macos, windows, wsl)","send plain-text chat messages (macos, windows, wsl; opt-in)","TUI (macos, windows, wsl)"],
729            "unsupported":["teams and channels","identity","channel messages, replies, mentions, attachments and edits","subscriptions","automatic Teams launch or restart"],
730            "requires":"existing Teams-owned loopback debugging endpoint; WSL requires Windows executable interop",
731            "transport":{"macos":"native_loopback","windows":"windows_native","wsl":"windows_helper_stdio"},
732            "chat_source":"desktop_cache","session_verified":false,"default_port":crate::config::DEFAULT_DESKTOP_PORT}
733    })
734}