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 single(
134 "auth status",
135 "Inspect local configuration and credential presence without network access",
136 "read_only",
137 vec![
138 field("profile", "string"),
139 field("configured", "boolean"),
140 field("signed_in", "boolean"),
141 field("config_path", "string"),
142 field("read_only", "boolean"),
143 nullable_array_field("granted_scopes", "string"),
144 nullable_field("channel_history", "boolean"),
145 ],
146 ),
147 single(
148 "config show",
149 "Print the resolved profile without credentials",
150 "read_only",
151 vec![
152 field("profile", "string"),
153 field("client_id", "string"),
154 field("tenant", "string"),
155 field("read_only", "boolean"),
156 field("config_path", "string"),
157 ],
158 ),
159 single(
160 "config path",
161 "Print the absolute configuration file path",
162 "read_only",
163 vec![],
164 ),
165 single(
166 "whoami",
167 "Show the signed-in Microsoft 365 identity",
168 "read_only",
169 identity,
170 ),
171 offset_paged(
172 "list",
173 "List teams joined by the signed-in user",
174 vec![
175 field("id", "string"),
176 field("displayName", "string"),
177 nullable_field("description", "string"),
178 ],
179 ),
180 {
181 let mut value = offset_paged(
182 "channels list",
183 "List channels in a team",
184 vec![
185 field("id", "string"),
186 field("displayName", "string"),
187 nullable_field("description", "string"),
188 field("membershipType", "string"),
189 ],
190 );
191 value["args"]
192 .as_array_mut()
193 .unwrap()
194 .insert(0, required_arg("team", "string", "Team ID"));
195 value
196 },
197 paged(
198 "chats list",
199 "List one-to-one, group, and meeting chats",
200 vec![
201 field("id", "string"),
202 field("chatType", "string"),
203 nullable_field("topic", "string"),
204 field("lastUpdatedDateTime", "string"),
205 ],
206 ),
207 {
208 let mut value = paged(
209 "messages list",
210 "List messages from one chat, or from a channel with optional admin-consented access",
211 vec![
212 field("id", "string"),
213 field("createdDateTime", "string"),
214 field("from", "object"),
215 field("body", "object"),
216 ],
217 );
218 let args = value["args"].as_array_mut().unwrap();
219 args.splice(
220 0..0,
221 [
222 arg("--chat", "string", "Chat ID"),
223 arg("--team", "string", "Team ID"),
224 arg("--channel", "string", "Channel ID"),
225 ],
226 );
227 value
228 },
229 {
230 let mut value = single(
231 "messages send",
232 "Send one plain-text message as the signed-in user",
233 "non_idempotent",
234 vec![
235 field("id", "string"),
236 field("createdDateTime", "string"),
237 nullable_field("webUrl", "string"),
238 ],
239 );
240 value["args"] = json!([
241 arg("--chat", "string", "Chat ID"),
242 arg("--team", "string", "Team ID"),
243 arg("--channel", "string", "Channel ID"),
244 required_arg("--body", "string", "Message body, or - for stdin")
245 ]);
246 value
247 },
248 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")]}),
249 single(
250 "doctor",
251 "Check configuration, credential storage, Microsoft identity, and Teams provisioning",
252 "read_only",
253 vec![array_field("checks", "object"), field("healthy", "boolean")],
254 ),
255 single(
256 "capabilities",
257 "Describe supported and deliberately unsupported capabilities",
258 "read_only",
259 vec![
260 array_field("supported", "string"),
261 array_field("unsupported", "string"),
262 array_field("account_types", "string"),
263 field("api", "string"),
264 array_field("notes", "string"),
265 ],
266 ),
267 json!({
268 "name":"schema",
269 "description":"Emit the offline CLI Spec v0.3 contract",
270 "effects":"read_only",
271 "cardinality":"single",
272 "stdout_schema":{},
273 "args":[arg("--command", "string", "Return only one complete command path")]
274 }),
275 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")]}),
276 ];
277 if let Some(filter) = command_filter {
278 commands.retain(|value| value["name"] == filter);
279 }
280 json!({
281 "$schema": "https://clispec.dev/schema/v0.3.json",
282 "clispec": "0.3",
283 "name": "teams",
284 "version": env!("CARGO_PKG_VERSION"),
285 "description": "A Microsoft Teams CLI and TUI designed equally for humans and agents",
286 "output": {"tty": "text", "piped": "json"},
287 "global_args": [
288 {"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
289 {"name":"--profile", "type":"string", "description":"Configuration profile"},
290 {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"}
291 ],
292 "commands": commands,
293 "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
294 "extensions": {
295 "authentication":"delegated_oauth",
296 "api":"Microsoft Graph v1.0",
297 "default_client_id": crate::config::DEFAULT_CLIENT_ID,
298 "login_modes":["browser_pkce","device_code"],
299 "read_only_env":"TEAMS_READ_ONLY",
300 "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
301 "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
302 }
303 })
304}