zoomcli 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. zoomcli-0.2.1/.gitignore +2 -0
  2. zoomcli-0.2.1/CHANGELOG.md +37 -0
  3. {zoomcli-0.2.0 → zoomcli-0.2.1}/Cargo.lock +2 -1
  4. {zoomcli-0.2.0 → zoomcli-0.2.1}/Cargo.toml +2 -1
  5. zoomcli-0.2.1/Formula/zoom-cli.rb +32 -0
  6. {zoomcli-0.2.0 → zoomcli-0.2.1}/PKG-INFO +1 -1
  7. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/api/client.rs +116 -20
  8. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/api/mod.rs +12 -0
  9. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/api/types.rs +75 -0
  10. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/config.rs +75 -0
  11. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/meetings.rs +31 -4
  12. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/recordings.rs +140 -32
  13. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/reports.rs +91 -6
  14. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/users.rs +122 -4
  15. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/webinars.rs +1 -4
  16. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/config.rs +107 -33
  17. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/main.rs +71 -1
  18. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/output.rs +40 -2
  19. zoomcli-0.2.0/.gitignore +0 -1
  20. zoomcli-0.2.0/CHANGELOG.md +0 -18
  21. {zoomcli-0.2.0 → zoomcli-0.2.1}/.github/workflows/ci.yml +0 -0
  22. {zoomcli-0.2.0 → zoomcli-0.2.1}/.github/workflows/release.yml +0 -0
  23. {zoomcli-0.2.0 → zoomcli-0.2.1}/Makefile +0 -0
  24. {zoomcli-0.2.0 → zoomcli-0.2.1}/README.md +0 -0
  25. {zoomcli-0.2.0 → zoomcli-0.2.1}/pyproject.toml +0 -0
  26. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/init.rs +0 -0
  27. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/mod.rs +0 -0
  28. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/lib.rs +0 -0
  29. {zoomcli-0.2.0 → zoomcli-0.2.1}/src/test_support.rs +0 -0
  30. {zoomcli-0.2.0 → zoomcli-0.2.1}/zoom_cli/__init__.py +0 -0
  31. {zoomcli-0.2.0 → zoomcli-0.2.1}/zoom_cli/__main__.py +0 -0
@@ -0,0 +1,2 @@
1
+ /target
2
+ /.worktrees
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/).
6
+
7
+
8
+ ## [0.2.1](https://github.com/rvben/zoom-cli/compare/v0.2.0...v0.2.1) - 2026-04-02
9
+
10
+ ### Added
11
+
12
+ - show pagination progress when fetching multiple pages ([e5e3364](https://github.com/rvben/zoom-cli/commit/e5e336472b9cb153afed1d5020d0a758b2342359))
13
+ - **config**: add zoom config delete command ([5841781](https://github.com/rvben/zoom-cli/commit/5841781a42e68c0c3568e9f16b06030fa19ec9ed))
14
+ - **reports**: add zoom reports participants command ([fcf08dc](https://github.com/rvben/zoom-cli/commit/fcf08dcf19186a3613f84af701d67114abd27ce6))
15
+ - **recordings**: add transcript download command ([c7b8fff](https://github.com/rvben/zoom-cli/commit/c7b8fff658e57b296d27e2632cf98e9187925ef8))
16
+ - **users**: add create, deactivate, and activate commands ([eb4fc29](https://github.com/rvben/zoom-cli/commit/eb4fc2900837add72d7ecc75227c6e8ae68a5517))
17
+ - **meetings**: add zoom meetings invite command ([051e7e6](https://github.com/rvben/zoom-cli/commit/051e7e6e4471ae01d30e3102c78ca18fe602fe17))
18
+
19
+ ### Fixed
20
+
21
+ - add clickable link to missing-scope error message ([c71f3ba](https://github.com/rvben/zoom-cli/commit/c71f3bae31479e7fd3103dff05800a6b2afd1e4f))
22
+ - parse Zoom error JSON and give actionable guidance for scope errors ([9ffac38](https://github.com/rvben/zoom-cli/commit/9ffac3889c124684a354be1f05b3538a1837b7fe))
23
+ - hoist safe_topic out of per-file loop in recordings::download ([fa409c8](https://github.com/rvben/zoom-cli/commit/fa409c84f5363c1261a7c2f97b60ccb0873d3861))
24
+ - **config,recordings**: exit non-zero when config delete aborts non-interactively, output structured JSON from transcript ([ed6f9f7](https://github.com/rvben/zoom-cli/commit/ed6f9f73d1379d64394e1747d2c77fb04c3d6c1c))
25
+
26
+ ## [0.2.0](https://github.com/rvben/zoom-cli/compare/v0.1.0...v0.2.0) - 2026-04-02
27
+
28
+ ### Added
29
+
30
+ - **init**: redesign interactive setup with excellent UX ([2bc79df](https://github.com/rvben/zoom-cli/commit/2bc79dfc82e18f476e69ea7aaaa4416d3fce2c23))
31
+ - **config**: add zoom config show command ([dbe8490](https://github.com/rvben/zoom-cli/commit/dbe84901f3618e6c17542ae6fd8e5c52a741eb29))
32
+
33
+ ### Fixed
34
+
35
+ - double-encode recording UUIDs and write downloads atomically ([f346939](https://github.com/rvben/zoom-cli/commit/f346939569336d808e5c02f7c2029c55e7273169))
36
+ - unreachable!() in send_with_retry was reachable, causing panics ([ceb905d](https://github.com/rvben/zoom-cli/commit/ceb905d7b12dfbc25fa8a7150daf2a5f09158375))
37
+ - transparent token refresh on 401, --permanent flag for recordings delete ([fdae155](https://github.com/rvben/zoom-cli/commit/fdae155e096e953c4a6634a177abb9c7ce2ab6cf))
@@ -2219,7 +2219,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2219
2219
 
2220
2220
  [[package]]
2221
2221
  name = "zoom-cli"
2222
- version = "0.2.0"
2222
+ version = "0.2.1"
2223
2223
  dependencies = [
2224
2224
  "assert_cmd",
2225
2225
  "base64",
@@ -2235,5 +2235,6 @@ dependencies = [
2235
2235
  "tempfile",
2236
2236
  "tokio",
2237
2237
  "toml",
2238
+ "toml_edit",
2238
2239
  "wiremock",
2239
2240
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "zoom-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "Agent-friendly Zoom CLI with JSON output, structured exit codes, and schema introspection"
@@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] }
23
23
  serde_json = "1"
24
24
  tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "io-util", "time"] }
25
25
  toml = "0.8"
26
+ toml_edit = "0.22"
26
27
  owo-colors = "4"
27
28
  dirs = "6"
28
29
  base64 = "0.22"
@@ -0,0 +1,32 @@
1
+ class ZoomCli < Formula
2
+ desc "Agent-friendly Zoom CLI with JSON output, structured exit codes, and schema introspection"
3
+ homepage "https://github.com/rvben/zoom-cli"
4
+ version "0.2.0"
5
+ license "MIT"
6
+
7
+ on_macos do
8
+ on_arm do
9
+ url "https://github.com/rvben/zoom-cli/releases/download/v0.2.0/zoom-cli-v0.2.0-aarch64-apple-darwin.tar.gz"
10
+ sha256 "1646d509545baaf0dad29c136f6d63a02d8537c0b2cf42b29c64b2777cca267b"
11
+ end
12
+ on_intel do
13
+ url "https://github.com/rvben/zoom-cli/releases/download/v0.2.0/zoom-cli-v0.2.0-x86_64-apple-darwin.tar.gz"
14
+ sha256 "94f11f7a6cbb59e1fe5e1b55e8518c52ed2774a1fbbfd7ede824f84258786c18"
15
+ end
16
+ end
17
+
18
+ def install
19
+ bin.install "zoom"
20
+ end
21
+
22
+ def caveats
23
+ <<~EOS
24
+ Run `zoom init` to set up your Zoom Server-to-Server OAuth credentials.
25
+ Run `zoom config show` to verify your configuration.
26
+ EOS
27
+ end
28
+
29
+ test do
30
+ assert_match "zoom #{version}", shell_output("#{bin}/zoom --version")
31
+ end
32
+ end
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zoomcli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -2,6 +2,7 @@ use base64::Engine;
2
2
  use base64::engine::general_purpose::STANDARD as BASE64;
3
3
  use serde::Serialize;
4
4
  use serde::de::DeserializeOwned;
5
+ use std::io::IsTerminal;
5
6
 
6
7
  use super::ApiError;
7
8
  use super::types::{self, *};
@@ -13,6 +14,41 @@ const ZOOM_OAUTH_BASE: &str = "https://zoom.us";
13
14
  /// 4 attempts = initial + 3 retries.
14
15
  const MAX_RETRY_ATTEMPTS: u32 = 4;
15
16
 
17
+ /// Extract a human-readable message from a Zoom API error body.
18
+ ///
19
+ /// Zoom wraps errors as `{"code": N, "message": "..."}`. We unwrap that and,
20
+ /// for known patterns, add actionable guidance beyond the raw API message.
21
+ fn parse_zoom_error(body: &str) -> String {
22
+ let Ok(val) = serde_json::from_str::<serde_json::Value>(body) else {
23
+ return body.trim().to_owned();
24
+ };
25
+
26
+ let message = match val["message"].as_str() {
27
+ Some(m) => m.to_owned(),
28
+ None => return body.to_owned(),
29
+ };
30
+
31
+ // Code 4711: token is valid but missing a required OAuth scope.
32
+ // Extract the scope name and give actionable instructions.
33
+ if val["code"].as_u64() == Some(4711) || message.contains("does not contain scopes") {
34
+ let scope = message
35
+ .find('[')
36
+ .and_then(|s| message.find(']').map(|e| &message[s + 1..e]))
37
+ .unwrap_or("");
38
+ let what = if scope.is_empty() {
39
+ message.clone()
40
+ } else {
41
+ format!("Missing required OAuth scope: {scope}")
42
+ };
43
+ let link = crate::output::hyperlink("https://marketplace.zoom.us/user/build");
44
+ return format!(
45
+ "{what}\nAdd this scope to your app at {link}, then run `zoom init` to update credentials."
46
+ );
47
+ }
48
+
49
+ message
50
+ }
51
+
16
52
  pub struct ZoomClient {
17
53
  http: reqwest::Client,
18
54
  base_url: String,
@@ -90,7 +126,7 @@ impl ZoomClient {
90
126
  let body = resp.text().await.unwrap_or_default();
91
127
  return Err(ApiError::Api {
92
128
  status: status.as_u16(),
93
- message: body,
129
+ message: parse_zoom_error(&body),
94
130
  });
95
131
  }
96
132
 
@@ -175,16 +211,25 @@ impl ZoomClient {
175
211
  T: DeserializeOwned + types::Paginated,
176
212
  {
177
213
  let mut result: T = self.get_with_query(path, base_params).await?;
214
+ let mut page_num: u32 = 1;
178
215
  loop {
179
216
  let token = match result.next_page_token() {
180
217
  Some(t) if !t.is_empty() => t.to_owned(),
181
218
  _ => break,
182
219
  };
220
+ page_num += 1;
221
+ if std::io::stderr().is_terminal() {
222
+ eprint!("\r Fetching page {page_num}...");
223
+ let _ = std::io::Write::flush(&mut std::io::stderr());
224
+ }
183
225
  let mut params = base_params.to_vec();
184
226
  params.push(("next_page_token", token.as_str()));
185
227
  let next: T = self.get_with_query(path, &params).await?;
186
228
  result.append_page(next);
187
229
  }
230
+ if page_num > 1 && std::io::stderr().is_terminal() {
231
+ eprint!("\r \r");
232
+ }
188
233
  Ok(result)
189
234
  }
190
235
 
@@ -245,18 +290,18 @@ impl ZoomClient {
245
290
  200..=299 => Ok(resp.json::<T>().await?),
246
291
  401 | 403 => {
247
292
  let body = resp.text().await.unwrap_or_default();
248
- Err(ApiError::Auth(body))
293
+ Err(ApiError::Auth(parse_zoom_error(&body)))
249
294
  }
250
295
  404 => {
251
296
  let body = resp.text().await.unwrap_or_default();
252
- Err(ApiError::NotFound(body))
297
+ Err(ApiError::NotFound(parse_zoom_error(&body)))
253
298
  }
254
299
  429 => Err(ApiError::RateLimit),
255
300
  _ => {
256
301
  let body = resp.text().await.unwrap_or_default();
257
302
  Err(ApiError::Api {
258
303
  status: status.as_u16(),
259
- message: body,
304
+ message: parse_zoom_error(&body),
260
305
  })
261
306
  }
262
307
  }
@@ -268,18 +313,18 @@ impl ZoomClient {
268
313
  200..=299 => Ok(()),
269
314
  401 | 403 => {
270
315
  let body = resp.text().await.unwrap_or_default();
271
- Err(ApiError::Auth(body))
316
+ Err(ApiError::Auth(parse_zoom_error(&body)))
272
317
  }
273
318
  404 => {
274
319
  let body = resp.text().await.unwrap_or_default();
275
- Err(ApiError::NotFound(body))
320
+ Err(ApiError::NotFound(parse_zoom_error(&body)))
276
321
  }
277
322
  429 => Err(ApiError::RateLimit),
278
323
  _ => {
279
324
  let body = resp.text().await.unwrap_or_default();
280
325
  Err(ApiError::Api {
281
326
  status: status.as_u16(),
282
- message: body,
327
+ message: parse_zoom_error(&body),
283
328
  })
284
329
  }
285
330
  }
@@ -294,10 +339,8 @@ impl ZoomClient {
294
339
  ) -> Result<MeetingList, ApiError> {
295
340
  let path = format!("/users/{user_id}/meetings");
296
341
  let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
297
- let mt_owned;
298
342
  if let Some(mt) = meeting_type {
299
- mt_owned = mt.to_owned();
300
- params.push(("type", mt_owned.as_str()));
343
+ params.push(("type", mt));
301
344
  }
302
345
  self.get_all_pages(&path, &params).await
303
346
  }
@@ -306,6 +349,14 @@ impl ZoomClient {
306
349
  self.get(&format!("/meetings/{meeting_id}")).await
307
350
  }
308
351
 
352
+ pub async fn get_meeting_invitation(
353
+ &mut self,
354
+ meeting_id: u64,
355
+ ) -> Result<MeetingInvitation, ApiError> {
356
+ self.get(&format!("/meetings/{meeting_id}/invitation"))
357
+ .await
358
+ }
359
+
309
360
  pub async fn create_meeting(
310
361
  &mut self,
311
362
  user_id: &str,
@@ -340,10 +391,8 @@ impl ZoomClient {
340
391
 
341
392
  pub async fn list_users(&mut self, status: Option<&str>) -> Result<UserList, ApiError> {
342
393
  let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
343
- let st_owned;
344
394
  if let Some(st) = status {
345
- st_owned = st.to_owned();
346
- params.push(("status", st_owned.as_str()));
395
+ params.push(("status", st));
347
396
  }
348
397
  self.get_all_pages("/users", &params).await
349
398
  }
@@ -352,6 +401,17 @@ impl ZoomClient {
352
401
  self.get(&format!("/users/{user_id}")).await
353
402
  }
354
403
 
404
+ pub async fn create_user(&mut self, req: CreateUserRequest) -> Result<User, ApiError> {
405
+ self.post("/users", &req).await
406
+ }
407
+
408
+ pub async fn set_user_status(&mut self, user_id: &str, action: &str) -> Result<(), ApiError> {
409
+ let req = UserStatusRequest {
410
+ action: action.into(),
411
+ };
412
+ self.put(&format!("/users/{user_id}/status"), &req).await
413
+ }
414
+
355
415
  // ── Participants ─────────────────────────────────────────────────────────
356
416
 
357
417
  pub async fn list_past_meeting_participants(
@@ -376,15 +436,11 @@ impl ZoomClient {
376
436
  ) -> Result<RecordingList, ApiError> {
377
437
  let path = format!("/users/{user_id}/recordings");
378
438
  let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
379
- let from_owned;
380
- let to_owned;
381
439
  if let Some(f) = from {
382
- from_owned = f.to_owned();
383
- params.push(("from", from_owned.as_str()));
440
+ params.push(("from", f));
384
441
  }
385
442
  if let Some(t) = to {
386
- to_owned = t.to_owned();
387
- params.push(("to", to_owned.as_str()));
443
+ params.push(("to", t));
388
444
  }
389
445
  self.get_all_pages(&path, &params).await
390
446
  }
@@ -455,7 +511,7 @@ impl ZoomClient {
455
511
  let body = resp.text().await.unwrap_or_default();
456
512
  return Err(ApiError::Api {
457
513
  status: status.as_u16(),
458
- message: body,
514
+ message: parse_zoom_error(&body),
459
515
  });
460
516
  }
461
517
 
@@ -513,6 +569,18 @@ impl ZoomClient {
513
569
  .await
514
570
  }
515
571
 
572
+ pub async fn list_meeting_participant_reports(
573
+ &mut self,
574
+ meeting_id: &str,
575
+ ) -> Result<MeetingParticipantReportList, ApiError> {
576
+ let encoded_id = encode_meeting_id(meeting_id);
577
+ self.get_all_pages(
578
+ &format!("/report/meetings/{encoded_id}/participants"),
579
+ &[("page_size", "300")],
580
+ )
581
+ .await
582
+ }
583
+
516
584
  // ── Webinars ──────────────────────────────────────────────────────────────
517
585
 
518
586
  pub async fn list_webinars(&mut self, user_id: &str) -> Result<WebinarList, ApiError> {
@@ -561,6 +629,34 @@ mod tests {
561
629
  use wiremock::matchers::{header, method, path, query_param};
562
630
  use wiremock::{Mock, MockServer, ResponseTemplate};
563
631
 
632
+ #[test]
633
+ fn parse_zoom_error_extracts_message_from_json() {
634
+ assert_eq!(
635
+ parse_zoom_error(r#"{"code":3001,"message":"Meeting does not exist"}"#),
636
+ "Meeting does not exist"
637
+ );
638
+ }
639
+
640
+ #[test]
641
+ fn parse_zoom_error_scope_4711_gives_actionable_message() {
642
+ let body = r#"{"code":4711,"message":"Invalid access token, does not contain scopes:[report:read:user:admin]."}"#;
643
+ let msg = parse_zoom_error(body);
644
+ assert!(
645
+ msg.contains("report:read:user:admin"),
646
+ "must name the missing scope"
647
+ );
648
+ assert!(msg.contains("zoom init"), "must tell user how to fix it");
649
+ assert!(
650
+ !msg.contains("Invalid access token"),
651
+ "must not echo the raw API message"
652
+ );
653
+ }
654
+
655
+ #[test]
656
+ fn parse_zoom_error_falls_back_to_raw_body_for_non_json() {
657
+ assert_eq!(parse_zoom_error("plain text error"), "plain text error");
658
+ }
659
+
564
660
  async fn mock_client(server: &MockServer) -> ZoomClient {
565
661
  ZoomClient::new_for_test(
566
662
  format!("{}/v2", server.uri()),
@@ -115,6 +115,18 @@ mod tests {
115
115
  assert!(msg.contains("Invalid parameter: duration"));
116
116
  }
117
117
 
118
+ #[test]
119
+ fn api_error_scope_message_is_actionable() {
120
+ // Simulates what parse_zoom_error produces for a code-4711 response.
121
+ let err = ApiError::Api {
122
+ status: 400,
123
+ message: "Missing required OAuth scope: report:read:user:admin\nAdd this scope to your Zoom Server-to-Server OAuth app, then run `zoom init` to update credentials.".into(),
124
+ };
125
+ let msg = err.to_string();
126
+ assert!(msg.contains("report:read:user:admin"));
127
+ assert!(msg.contains("zoom init"), "must tell user how to fix it");
128
+ }
129
+
118
130
  #[test]
119
131
  fn other_error_display_is_verbatim() {
120
132
  let err = ApiError::Other("unexpected failure".into());
@@ -112,6 +112,30 @@ pub struct UserList {
112
112
  pub page_size: Option<u32>,
113
113
  }
114
114
 
115
+ // ── User management ──────────────────────────────────────────────────────────
116
+
117
+ #[derive(Debug, Serialize)]
118
+ pub struct CreateUserInfo {
119
+ pub email: String,
120
+ #[serde(rename = "type")]
121
+ pub user_type: u8,
122
+ #[serde(skip_serializing_if = "Option::is_none")]
123
+ pub first_name: Option<String>,
124
+ #[serde(skip_serializing_if = "Option::is_none")]
125
+ pub last_name: Option<String>,
126
+ }
127
+
128
+ #[derive(Debug, Serialize)]
129
+ pub struct CreateUserRequest {
130
+ pub action: String,
131
+ pub user_info: CreateUserInfo,
132
+ }
133
+
134
+ #[derive(Debug, Serialize)]
135
+ pub struct UserStatusRequest {
136
+ pub action: String,
137
+ }
138
+
115
139
  // ── Recording ─────────────────────────────────────────────────────────────────
116
140
 
117
141
  #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -136,6 +160,8 @@ pub struct RecordingFile {
136
160
  #[serde(skip_serializing_if = "Option::is_none")]
137
161
  pub file_type: Option<String>,
138
162
  #[serde(skip_serializing_if = "Option::is_none")]
163
+ pub file_extension: Option<String>,
164
+ #[serde(skip_serializing_if = "Option::is_none")]
139
165
  pub file_size: Option<u64>,
140
166
  #[serde(skip_serializing_if = "Option::is_none")]
141
167
  pub play_url: Option<String>,
@@ -180,6 +206,13 @@ pub struct MeetingStatusRequest {
180
206
  pub action: String,
181
207
  }
182
208
 
209
+ // ── Meeting invitation ────────────────────────────────────────────────────────
210
+
211
+ #[derive(Debug, Clone, Deserialize, Serialize)]
212
+ pub struct MeetingInvitation {
213
+ pub invitation: String,
214
+ }
215
+
183
216
  // ── Participants ──────────────────────────────────────────────────────────────
184
217
 
185
218
  #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -251,6 +284,38 @@ pub struct UserMeetingReportList {
251
284
  pub page_size: Option<u32>,
252
285
  }
253
286
 
287
+ // ── Meeting participant report ────────────────────────────────────────────────
288
+
289
+ #[derive(Debug, Clone, Deserialize, Serialize)]
290
+ pub struct MeetingParticipantReport {
291
+ #[serde(skip_serializing_if = "Option::is_none")]
292
+ pub id: Option<String>,
293
+ #[serde(skip_serializing_if = "Option::is_none")]
294
+ pub user_id: Option<String>,
295
+ #[serde(skip_serializing_if = "Option::is_none")]
296
+ pub name: Option<String>,
297
+ #[serde(skip_serializing_if = "Option::is_none")]
298
+ pub user_email: Option<String>,
299
+ #[serde(skip_serializing_if = "Option::is_none")]
300
+ pub join_time: Option<String>,
301
+ #[serde(skip_serializing_if = "Option::is_none")]
302
+ pub leave_time: Option<String>,
303
+ /// Participation duration in seconds.
304
+ #[serde(skip_serializing_if = "Option::is_none")]
305
+ pub duration: Option<u64>,
306
+ }
307
+
308
+ #[derive(Debug, Deserialize, Serialize)]
309
+ pub struct MeetingParticipantReportList {
310
+ pub participants: Vec<MeetingParticipantReport>,
311
+ #[serde(skip_serializing_if = "is_none_or_empty")]
312
+ pub next_page_token: Option<String>,
313
+ #[serde(skip_serializing_if = "Option::is_none")]
314
+ pub total_records: Option<u64>,
315
+ #[serde(skip_serializing)]
316
+ pub page_size: Option<u32>,
317
+ }
318
+
254
319
  // ── Webinar ───────────────────────────────────────────────────────────────────
255
320
 
256
321
  #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -344,6 +409,16 @@ impl Paginated for UserMeetingReportList {
344
409
  }
345
410
  }
346
411
 
412
+ impl Paginated for MeetingParticipantReportList {
413
+ fn next_page_token(&self) -> Option<&str> {
414
+ self.next_page_token.as_deref().filter(|s| !s.is_empty())
415
+ }
416
+ fn append_page(&mut self, next: Self) {
417
+ self.participants.extend(next.participants);
418
+ self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
419
+ }
420
+ }
421
+
347
422
  impl Paginated for WebinarList {
348
423
  fn next_page_token(&self) -> Option<&str> {
349
424
  self.next_page_token.as_deref().filter(|s| !s.is_empty())
@@ -1,3 +1,4 @@
1
+ use crate::api::ApiError;
1
2
  use crate::config;
2
3
  use crate::output::{self, OutputConfig};
3
4
 
@@ -87,6 +88,32 @@ pub fn show(profile_arg: Option<&str>, out: &OutputConfig) {
87
88
  }
88
89
  }
89
90
 
91
+ pub fn delete(profile_name: &str, force: bool, out: &OutputConfig) -> Result<(), ApiError> {
92
+ let config_path = config::config_path();
93
+
94
+ if !force {
95
+ eprint!("Delete profile '{profile_name}'? [y/N] ");
96
+ use std::io::{BufRead, IsTerminal};
97
+ if !std::io::stdin().is_terminal() {
98
+ eprintln!();
99
+ eprintln!("Use --force to delete non-interactively.");
100
+ return Err(ApiError::Other(
101
+ "stdin is not a terminal; use --force to delete non-interactively".into(),
102
+ ));
103
+ }
104
+ let mut input = String::new();
105
+ std::io::stdin().lock().read_line(&mut input).unwrap_or(0);
106
+ if input.trim().to_lowercase() != "y" {
107
+ out.print_message("Aborted.");
108
+ return Ok(());
109
+ }
110
+ }
111
+
112
+ config::delete_profile(&config_path, profile_name)?;
113
+ out.print_message(&format!("Profile '{profile_name}' deleted."));
114
+ Ok(())
115
+ }
116
+
90
117
  fn masked_or_unset(value: &Option<String>) -> String {
91
118
  match value {
92
119
  Some(v) => output::mask_credential(v),
@@ -236,4 +263,52 @@ client_secret = "work-csec"
236
263
  );
237
264
  assert_eq!(masked_or_unset(&None), "(not set)");
238
265
  }
266
+
267
+ #[test]
268
+ fn delete_removes_profile_from_config() {
269
+ let _lock = ProcessEnvLock::acquire().unwrap();
270
+ let dir = TempDir::new().unwrap();
271
+ write_config(
272
+ dir.path(),
273
+ r#"
274
+ [default]
275
+ account_id = "def-acct"
276
+ client_id = "def-cid"
277
+ client_secret = "def-csec"
278
+
279
+ [work]
280
+ account_id = "work-acct"
281
+ client_id = "work-cid"
282
+ client_secret = "work-csec"
283
+ "#,
284
+ )
285
+ .unwrap();
286
+
287
+ let _cfg_dir = set_config_dir_env(dir.path());
288
+ let _env_acct = EnvVarGuard::unset("ZOOM_ACCOUNT_ID");
289
+ let _env_cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
290
+ let _env_csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
291
+ let _env_prof = EnvVarGuard::unset("ZOOM_PROFILE");
292
+ let path = dir.path().join("zoom-cli").join("config.toml");
293
+ config::delete_profile(&path, "work").unwrap();
294
+
295
+ let summary = config::load_for_show(None);
296
+ assert_eq!(summary.profiles.len(), 1);
297
+ assert_eq!(summary.profiles[0].name, "default");
298
+ }
299
+
300
+ #[test]
301
+ fn delete_returns_not_found_for_missing_profile() {
302
+ let _lock = ProcessEnvLock::acquire().unwrap();
303
+ let dir = TempDir::new().unwrap();
304
+ write_config(
305
+ dir.path(),
306
+ "[default]\naccount_id=\"x\"\nclient_id=\"y\"\nclient_secret=\"z\"\n",
307
+ )
308
+ .unwrap();
309
+
310
+ let path = dir.path().join("zoom-cli").join("config.toml");
311
+ let err = config::delete_profile(&path, "nonexistent").unwrap_err();
312
+ assert!(matches!(err, crate::api::ApiError::NotFound(_)));
313
+ }
239
314
  }
@@ -243,6 +243,20 @@ pub async fn participants(
243
243
  Ok(())
244
244
  }
245
245
 
246
+ pub async fn invite(
247
+ client: &mut ZoomClient,
248
+ out: &OutputConfig,
249
+ meeting_id: u64,
250
+ ) -> Result<(), ApiError> {
251
+ let inv = client.get_meeting_invitation(meeting_id).await?;
252
+ if out.json {
253
+ out.print_data(&serde_json::to_string_pretty(&inv).expect("serialize"));
254
+ } else {
255
+ out.print_data(&inv.invitation);
256
+ }
257
+ Ok(())
258
+ }
259
+
246
260
  #[cfg(test)]
247
261
  mod tests {
248
262
  use super::*;
@@ -251,10 +265,7 @@ mod tests {
251
265
  use wiremock::{Mock, MockServer, ResponseTemplate};
252
266
 
253
267
  fn test_out() -> OutputConfig {
254
- OutputConfig {
255
- json: true,
256
- quiet: true,
257
- }
268
+ OutputConfig::for_test()
258
269
  }
259
270
 
260
271
  #[test]
@@ -403,6 +414,22 @@ mod tests {
403
414
  end(&mut client, &test_out(), 555666777).await.unwrap();
404
415
  }
405
416
 
417
+ #[tokio::test]
418
+ async fn meetings_invite_returns_invitation_text() {
419
+ let server = MockServer::start().await;
420
+ Mock::given(method("GET"))
421
+ .and(path("/v2/meetings/123456789/invitation"))
422
+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
423
+ "invitation": "Join Zoom Meeting\nhttps://zoom.us/j/123456789"
424
+ })))
425
+ .mount(&server)
426
+ .await;
427
+
428
+ let mut client =
429
+ ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
430
+ invite(&mut client, &test_out(), 123456789).await.unwrap();
431
+ }
432
+
406
433
  #[tokio::test]
407
434
  async fn meetings_participants_returns_table_data() {
408
435
  let server = MockServer::start().await;