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