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 an Entra public client and optionally sign in",
68 "idempotent",
69 vec![
70 field("profile", "string"),
71 field("config_path", "string"),
72 field("signed_in", "boolean"),
73 field("client_id", "string"),
74 field("tenant", "string"),
75 field("channel_history_requested", "boolean"),
76 field("read_only", "boolean"),
77 ],
78 );
79 value["args"] = json!([
80 arg(
81 "--client-id",
82 "string",
83 "Override the maintained teams-cli public-client application ID"
84 ),
85 arg("--tenant", "string", "Tenant ID, domain, or organizations"),
86 arg(
87 "--no-login",
88 "boolean",
89 "Save configuration without signing in"
90 ),
91 arg("--device-code", "boolean", "Use device-code sign-in"),
92 arg(
93 "--channel-history",
94 "boolean",
95 "Request admin-consented channel-history access"
96 ),
97 arg("--read-only", "boolean", "Block remote write operations")
98 ]);
99 value
100 },
101 {
102 let mut value = single(
103 "auth login",
104 "Sign in with delegated Microsoft OAuth and protect credentials in the OS store",
105 "idempotent",
106 vec![
107 field("profile", "string"),
108 field("expires_at", "integer"),
109 nullable_field("scope", "string"),
110 field("channel_history_requested", "boolean"),
111 ],
112 );
113 value["args"] = json!([
114 arg(
115 "--device-code",
116 "boolean",
117 "Use device-code sign-in for headless and remote environments"
118 ),
119 arg(
120 "--channel-history",
121 "boolean",
122 "Request admin-consented channel-history access"
123 )
124 ]);
125 value
126 },
127 single(
128 "auth logout",
129 "Remove locally stored delegated credentials",
130 "idempotent",
131 vec![field("profile", "string"), field("signed_in", "boolean")],
132 ),
133 {
134 let mut value = single(
135 "auth status",
136 "Verify authentication and inspect credential state",
137 "read_only",
138 vec![
139 field("profile", "string"),
140 field("configured", "boolean"),
141 field("signed_in", "boolean"),
142 field("verified", "boolean"),
143 nullable_field("identity", "object"),
144 field("config_path", "string"),
145 field("read_only", "boolean"),
146 nullable_array_field("granted_scopes", "string"),
147 nullable_field("channel_history", "boolean"),
148 ],
149 );
150 value["args"] = json!([arg(
151 "--offline",
152 "boolean",
153 "Inspect local credential state without contacting Microsoft Graph"
154 )]);
155 value
156 },
157 single(
158 "profile list",
159 "List configured profiles and identify the active one",
160 "read_only",
161 vec![array_field("items", "object"), field("total", "integer")],
162 ),
163 {
164 let mut value = single(
165 "profile use",
166 "Select the default profile for future commands",
167 "idempotent",
168 vec![field("profile", "string"), field("active", "boolean")],
169 );
170 value["args"] = json!([required_arg("name", "string", "Profile name")]);
171 value
172 },
173 {
174 let mut value = single(
175 "profile remove",
176 "Remove a profile and its stored credential",
177 "idempotent",
178 vec![field("profile", "string"), field("removed", "boolean")],
179 );
180 value["args"] = json!([required_arg("name", "string", "Profile name")]);
181 value["confirmation_bypass_arg"] = json!("--yes");
182 value
183 },
184 single(
185 "config show",
186 "Print the resolved profile without credentials",
187 "read_only",
188 vec![
189 field("profile", "string"),
190 field("client_id", "string"),
191 field("tenant", "string"),
192 field("read_only", "boolean"),
193 field("config_path", "string"),
194 ],
195 ),
196 single(
197 "config path",
198 "Print the absolute configuration file path",
199 "read_only",
200 vec![],
201 ),
202 single(
203 "whoami",
204 "Show the signed-in Microsoft 365 identity",
205 "read_only",
206 identity,
207 ),
208 offset_paged(
209 "list",
210 "List teams joined by the signed-in user",
211 vec![
212 field("id", "string"),
213 field("displayName", "string"),
214 nullable_field("description", "string"),
215 ],
216 ),
217 {
218 let mut value = offset_paged(
219 "channels list",
220 "List channels in a team",
221 vec![
222 field("id", "string"),
223 field("displayName", "string"),
224 nullable_field("description", "string"),
225 field("membershipType", "string"),
226 ],
227 );
228 value["args"]
229 .as_array_mut()
230 .unwrap()
231 .insert(0, required_arg("team", "string", "Team ID"));
232 value
233 },
234 paged(
235 "chats list",
236 "List one-to-one, group, and meeting chats",
237 vec![
238 field("id", "string"),
239 field("chatType", "string"),
240 nullable_field("topic", "string"),
241 field("lastUpdatedDateTime", "string"),
242 ],
243 ),
244 {
245 let mut value = paged(
246 "messages list",
247 "List messages from one chat, or from a channel with optional admin-consented access",
248 vec![
249 field("id", "string"),
250 field("createdDateTime", "string"),
251 field("from", "object"),
252 field("body", "object"),
253 ],
254 );
255 let args = value["args"].as_array_mut().unwrap();
256 args.splice(
257 0..0,
258 [
259 arg("--chat", "string", "Chat ID"),
260 arg("--team", "string", "Team ID"),
261 arg("--channel", "string", "Channel ID"),
262 ],
263 );
264 value
265 },
266 {
267 let mut value = single(
268 "messages send",
269 "Send one plain-text message as the signed-in user",
270 "non_idempotent",
271 vec![
272 field("id", "string"),
273 field("createdDateTime", "string"),
274 nullable_field("webUrl", "string"),
275 ],
276 );
277 value["args"] = json!([
278 arg("--chat", "string", "Chat ID"),
279 arg("--team", "string", "Team ID"),
280 arg("--channel", "string", "Channel ID"),
281 required_arg("--body", "string", "Message body, or - for stdin")
282 ]);
283 value
284 },
285 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")]}),
286 {
287 let mut value = single(
288 "doctor",
289 "Check configuration, credential storage, Microsoft identity, and Teams provisioning",
290 "read_only",
291 vec![array_field("checks", "object"), field("healthy", "boolean")],
292 );
293 value["args"] = json!([arg(
294 "--offline",
295 "boolean",
296 "Check local state without contacting Microsoft Graph"
297 )]);
298 value
299 },
300 single(
301 "capabilities",
302 "Describe supported and deliberately unsupported capabilities",
303 "read_only",
304 vec![
305 array_field("supported", "string"),
306 array_field("unsupported", "string"),
307 array_field("account_types", "string"),
308 field("api", "string"),
309 array_field("notes", "string"),
310 ],
311 ),
312 json!({
313 "name":"schema",
314 "description":"Emit the offline CLI Spec v0.3 contract",
315 "effects":"read_only",
316 "cardinality":"single",
317 "stdout_schema":{},
318 "args":[arg("--command", "string", "Return only one complete command path")]
319 }),
320 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")]}),
321 ];
322 if let Some(filter) = command_filter {
323 commands.retain(|value| value["name"] == filter);
324 }
325 json!({
326 "$schema": "https://clispec.dev/schema/v0.3.json",
327 "clispec": "0.3",
328 "name": "teams",
329 "version": env!("CARGO_PKG_VERSION"),
330 "description": "A Microsoft Teams CLI and TUI designed equally for humans and agents",
331 "output": {"tty": "text", "piped": "json"},
332 "global_args": [
333 {"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
334 {"name":"--profile", "type":"string", "description":"Configuration profile"},
335 {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"},
336 {"name":"--yes", "short":"-y", "type":"boolean", "description":"Skip confirmation prompts for destructive operations"}
337 ],
338 "commands": commands,
339 "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
340 "extensions": {
341 "authentication":"delegated_oauth",
342 "api":"Microsoft Graph v1.0",
343 "default_client_id": crate::config::DEFAULT_CLIENT_ID,
344 "login_modes":["browser_pkce","device_code"],
345 "read_only_env":"TEAMS_READ_ONLY",
346 "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
347 "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
348 }
349 })
350}