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 ],
77 );
78 value["args"] = json!([
79 arg(
80 "--client-id",
81 "string",
82 "Override the maintained teams-cli public-client application ID"
83 ),
84 arg("--tenant", "string", "Tenant ID, domain, or organizations"),
85 arg(
86 "--no-login",
87 "boolean",
88 "Save configuration without signing in"
89 ),
90 arg("--device-code", "boolean", "Use device-code sign-in"),
91 arg(
92 "--channel-history",
93 "boolean",
94 "Request admin-consented channel-history access"
95 )
96 ]);
97 value
98 },
99 {
100 let mut value = single(
101 "auth login",
102 "Sign in with delegated Microsoft OAuth and protect credentials in the OS store",
103 "idempotent",
104 vec![
105 field("profile", "string"),
106 field("expires_at", "integer"),
107 nullable_field("scope", "string"),
108 field("channel_history_requested", "boolean"),
109 ],
110 );
111 value["args"] = json!([
112 arg(
113 "--device-code",
114 "boolean",
115 "Use device-code sign-in for headless and remote environments"
116 ),
117 arg(
118 "--channel-history",
119 "boolean",
120 "Request admin-consented channel-history access"
121 )
122 ]);
123 value
124 },
125 single(
126 "auth logout",
127 "Remove locally stored delegated credentials",
128 "idempotent",
129 vec![field("profile", "string"), field("signed_in", "boolean")],
130 ),
131 single(
132 "auth status",
133 "Inspect local configuration and credential presence without network access",
134 "read_only",
135 vec![
136 field("profile", "string"),
137 field("configured", "boolean"),
138 field("signed_in", "boolean"),
139 field("config_path", "string"),
140 nullable_array_field("granted_scopes", "string"),
141 nullable_field("channel_history", "boolean"),
142 ],
143 ),
144 single(
145 "whoami",
146 "Show the signed-in Microsoft 365 identity",
147 "read_only",
148 identity,
149 ),
150 offset_paged(
151 "list",
152 "List teams joined by the signed-in user",
153 vec![
154 field("id", "string"),
155 field("displayName", "string"),
156 nullable_field("description", "string"),
157 ],
158 ),
159 {
160 let mut value = offset_paged(
161 "channels list",
162 "List channels in a team",
163 vec![
164 field("id", "string"),
165 field("displayName", "string"),
166 nullable_field("description", "string"),
167 field("membershipType", "string"),
168 ],
169 );
170 value["args"]
171 .as_array_mut()
172 .unwrap()
173 .insert(0, required_arg("team", "string", "Team ID"));
174 value
175 },
176 paged(
177 "chats list",
178 "List one-to-one, group, and meeting chats",
179 vec![
180 field("id", "string"),
181 field("chatType", "string"),
182 nullable_field("topic", "string"),
183 field("lastUpdatedDateTime", "string"),
184 ],
185 ),
186 {
187 let mut value = paged(
188 "messages list",
189 "List messages from one chat, or from a channel with optional admin-consented access",
190 vec![
191 field("id", "string"),
192 field("createdDateTime", "string"),
193 field("from", "object"),
194 field("body", "object"),
195 ],
196 );
197 let args = value["args"].as_array_mut().unwrap();
198 args.splice(
199 0..0,
200 [
201 arg("--chat", "string", "Chat ID"),
202 arg("--team", "string", "Team ID"),
203 arg("--channel", "string", "Channel ID"),
204 ],
205 );
206 value
207 },
208 {
209 let mut value = single(
210 "messages send",
211 "Send one plain-text message as the signed-in user",
212 "non_idempotent",
213 vec![
214 field("id", "string"),
215 field("createdDateTime", "string"),
216 nullable_field("webUrl", "string"),
217 ],
218 );
219 value["args"] = json!([
220 arg("--chat", "string", "Chat ID"),
221 arg("--team", "string", "Team ID"),
222 arg("--channel", "string", "Channel ID"),
223 required_arg("--body", "string", "Message body, or - for stdin")
224 ]);
225 value
226 },
227 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")]}),
228 single(
229 "doctor",
230 "Check configuration, credential storage, Microsoft identity, and Teams provisioning",
231 "read_only",
232 vec![array_field("checks", "object"), field("healthy", "boolean")],
233 ),
234 single(
235 "capabilities",
236 "Describe supported and deliberately unsupported capabilities",
237 "read_only",
238 vec![
239 array_field("supported", "string"),
240 array_field("unsupported", "string"),
241 array_field("account_types", "string"),
242 field("api", "string"),
243 array_field("notes", "string"),
244 ],
245 ),
246 json!({
247 "name":"schema",
248 "description":"Emit the offline CLI Spec v0.3 contract",
249 "effects":"read_only",
250 "cardinality":"single",
251 "stdout_schema":{},
252 "args":[arg("--command", "string", "Return only one complete command path")]
253 }),
254 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")]}),
255 ];
256 if let Some(filter) = command_filter {
257 commands.retain(|value| value["name"] == filter);
258 }
259 json!({
260 "$schema": "https://clispec.dev/schema/v0.3.json",
261 "clispec": "0.3",
262 "name": "teams",
263 "version": env!("CARGO_PKG_VERSION"),
264 "description": "A Microsoft Teams CLI and TUI designed equally for humans and agents",
265 "output": {"tty": "text", "piped": "json"},
266 "global_args": [
267 {"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
268 {"name":"--profile", "type":"string", "description":"Configuration profile"},
269 {"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"}
270 ],
271 "commands": commands,
272 "errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
273 "extensions": {
274 "authentication":"delegated_oauth",
275 "api":"Microsoft Graph v1.0",
276 "default_client_id": crate::config::DEFAULT_CLIENT_ID,
277 "login_modes":["browser_pkce","device_code"],
278 "baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
279 "privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
280 }
281 })
282}