chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
@@ -0,0 +1,56 @@
# cli-anything-zoom
CLI harness for **Zoom** — manage meetings, participants, and recordings from the command line via the Zoom REST API.
## Installation
```bash
pip install cli-anything-zoom
# or from source:
cd zoom/agent-harness && pip install -e .
```
## Prerequisites
1. A Zoom account (free or paid)
2. A Zoom OAuth App — create one at https://marketplace.zoom.us/develop/create
- App type: **General App** (OAuth)
- Redirect URL: `http://localhost:4199/callback`
- Required scopes: `user:read:admin`, `meeting:read:admin`, `meeting:write:admin`, `recording:read:admin`
## Quick Start
```bash
# 1. Configure OAuth credentials
cli-anything-zoom auth setup --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
# 2. Login (opens browser)
cli-anything-zoom auth login
# 3. Create a meeting
cli-anything-zoom meeting create --topic "Team Standup" --duration 30
# 4. List meetings
cli-anything-zoom meeting list
# 5. Interactive mode
cli-anything-zoom repl
```
## Commands
| Group | Commands |
|---|---|
| `auth` | `setup`, `login`, `status`, `logout` |
| `meeting` | `create`, `list`, `info`, `update`, `delete`, `join`, `start` |
| `participant` | `add`, `add-batch`, `list`, `remove`, `attended` |
| `recording` | `list`, `files`, `download`, `delete` |
## Agent Usage (JSON mode)
All commands support `--json` for machine-readable output:
```bash
cli-anything-zoom --json meeting list
cli-anything-zoom --json meeting create --topic "Sync" --duration 60 --auto-recording cloud
```
@@ -0,0 +1 @@
# cli-anything-zoom namespace package
@@ -0,0 +1 @@
"""Zoom CLI core modules — auth, meetings, participants, recordings."""
@@ -0,0 +1,218 @@
"""OAuth2 authentication flow for Zoom API.
Handles:
- OAuth app setup (client_id, client_secret, redirect_uri)
- Browser-based authorization flow
- Token exchange and persistence
- Token refresh
- Auth status checking
"""
import webbrowser
import http.server
import threading
from urllib.parse import urlparse, parse_qs
from cli_anything.zoom.utils.zoom_backend import (
load_config, save_config, load_tokens, save_tokens,
get_authorize_url, exchange_code, get_current_user,
CONFIG_DIR,
)
def setup_oauth(client_id: str, client_secret: str,
redirect_uri: str = "http://localhost:4199/callback") -> dict:
"""Save OAuth app credentials.
Args:
client_id: Zoom OAuth app client ID.
client_secret: Zoom OAuth app client secret.
redirect_uri: OAuth redirect URI (must match app config).
Returns:
Saved configuration dict.
"""
config = {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
}
save_config(config)
return {
"status": "configured",
"client_id": client_id,
"redirect_uri": redirect_uri,
"config_path": str(CONFIG_DIR / "config.json"),
}
def login() -> dict:
"""Run the OAuth2 authorization flow.
Opens a browser for user authorization, starts a local HTTP server
to capture the callback, exchanges the code for tokens, and saves them.
Returns:
Dict with login status and user info.
"""
config = load_config()
if not config.get("client_id") or not config.get("client_secret"):
raise RuntimeError(
"OAuth app not configured. Run 'auth setup' with your "
"client_id and client_secret first."
)
client_id = config["client_id"]
client_secret = config["client_secret"]
redirect_uri = config.get("redirect_uri", "http://localhost:4199/callback")
# Parse redirect URI to get port
parsed = urlparse(redirect_uri)
port = parsed.port or 4199
auth_url = get_authorize_url(client_id, redirect_uri)
# Capture authorization code via local HTTP server
auth_code = [None]
auth_error = [None]
class CallbackHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
query = parse_qs(urlparse(self.path).query)
if "code" in query:
auth_code[0] = query["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h2>Authorization successful!</h2>"
b"<p>You can close this window and return to the CLI.</p>"
b"</body></html>"
)
elif "error" in query:
auth_error[0] = query.get("error_description", query["error"])[0]
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
f"<html><body><h2>Authorization failed: {auth_error[0]}</h2>"
.encode()
)
else:
self.send_response(400)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress server logs
server = http.server.HTTPServer(("127.0.0.1", port), CallbackHandler)
server.timeout = 120 # 2 minutes to complete auth
# Open browser
webbrowser.open(auth_url)
# Wait for callback
server.handle_request()
server.server_close()
if auth_error[0]:
raise RuntimeError(f"Authorization failed: {auth_error[0]}")
if not auth_code[0]:
raise RuntimeError("Authorization timed out. Please try again.")
# Exchange code for tokens
tokens = exchange_code(client_id, client_secret, auth_code[0], redirect_uri)
save_tokens(tokens)
# Verify by getting user info
try:
user = get_current_user()
return {
"status": "logged_in",
"user": user.get("email", "unknown"),
"name": f"{user.get('first_name', '')} {user.get('last_name', '')}".strip(),
"account_id": user.get("account_id", ""),
}
except Exception:
return {
"status": "logged_in",
"message": "Tokens saved. Could not verify user info.",
}
def login_with_code(code: str) -> dict:
"""Complete login with a manually provided authorization code.
Use this when the automatic callback server doesn't work.
Args:
code: The authorization code from the OAuth callback URL.
Returns:
Dict with login status.
"""
config = load_config()
if not config.get("client_id"):
raise RuntimeError("OAuth app not configured. Run 'auth setup' first.")
tokens = exchange_code(
config["client_id"],
config["client_secret"],
code,
config.get("redirect_uri", "http://localhost:4199/callback"),
)
save_tokens(tokens)
try:
user = get_current_user()
return {
"status": "logged_in",
"user": user.get("email", "unknown"),
"name": f"{user.get('first_name', '')} {user.get('last_name', '')}".strip(),
}
except Exception:
return {"status": "logged_in", "message": "Tokens saved."}
def get_auth_status() -> dict:
"""Check current authentication status.
Returns:
Dict with auth status, user info, and token expiry.
"""
config = load_config()
tokens = load_tokens()
result = {
"configured": bool(config.get("client_id")),
"authenticated": bool(tokens.get("access_token")),
}
if config.get("client_id"):
result["client_id"] = config["client_id"]
result["redirect_uri"] = config.get("redirect_uri", "")
if tokens.get("access_token"):
try:
user = get_current_user()
result["user"] = user.get("email", "unknown")
result["name"] = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip()
result["token_valid"] = True
except Exception as e:
result["token_valid"] = False
result["token_error"] = str(e)
return result
def logout() -> dict:
"""Remove saved tokens (does not revoke on Zoom side).
Returns:
Dict confirming logout.
"""
token_file = CONFIG_DIR / "tokens.json"
if token_file.exists():
token_file.unlink()
return {"status": "logged_out", "message": "Local tokens removed."}
@@ -0,0 +1,242 @@
"""Meeting management — CRUD operations via Zoom API.
Covers:
- Create / update / delete meetings
- List meetings
- Get meeting details
- Meeting settings (recording, waiting room, etc.)
"""
from typing import Any
from cli_anything.zoom.utils.zoom_backend import api_get, api_post, api_patch, api_delete
def create_meeting(
topic: str,
start_time: str | None = None,
duration: int = 60,
timezone: str = "UTC",
meeting_type: int = 2,
agenda: str = "",
password: str | None = None,
auto_recording: str = "none",
waiting_room: bool = False,
join_before_host: bool = False,
mute_upon_entry: bool = True,
) -> dict:
"""Create a new Zoom meeting.
Args:
topic: Meeting subject/title.
start_time: ISO 8601 datetime (e.g., '2025-01-15T10:00:00Z').
None for instant meeting.
duration: Meeting duration in minutes.
timezone: Timezone string (e.g., 'Asia/Shanghai', 'America/New_York').
meeting_type: 1=instant, 2=scheduled, 3=recurring no fixed time,
8=recurring fixed time.
agenda: Meeting description.
password: Meeting password (auto-generated if None).
auto_recording: 'none', 'local', or 'cloud'.
waiting_room: Enable waiting room.
join_before_host: Allow participants to join before host.
mute_upon_entry: Mute participants on entry.
Returns:
Meeting details dict from Zoom API.
"""
body: dict[str, Any] = {
"topic": topic,
"type": meeting_type,
"duration": duration,
"timezone": timezone,
"settings": {
"auto_recording": auto_recording,
"waiting_room": waiting_room,
"join_before_host": join_before_host,
"mute_upon_entry": mute_upon_entry,
},
}
if start_time:
body["start_time"] = start_time
if agenda:
body["agenda"] = agenda
if password:
body["password"] = password
result = api_post("/users/me/meetings", body)
return _format_meeting(result)
def list_meetings(
status: str = "upcoming",
page_size: int = 30,
page_number: int = 1,
) -> dict:
"""List meetings for the authenticated user.
Args:
status: 'upcoming', 'scheduled', 'live', or 'pending'.
page_size: Number of results per page (max 300).
page_number: Page number to return.
Returns:
Dict with meetings list and pagination info.
"""
params = {
"type": status,
"page_size": min(page_size, 300),
"page_number": page_number,
}
result = api_get("/users/me/meetings", params=params)
meetings = [_format_meeting_summary(m) for m in result.get("meetings", [])]
return {
"total_records": result.get("total_records", 0),
"page_count": result.get("page_count", 0),
"page_number": result.get("page_number", 1),
"page_size": result.get("page_size", page_size),
"meetings": meetings,
}
def get_meeting(meeting_id: int | str) -> dict:
"""Get detailed information about a specific meeting.
Args:
meeting_id: The Zoom meeting ID.
Returns:
Meeting details dict.
"""
result = api_get(f"/meetings/{meeting_id}")
return _format_meeting(result)
def update_meeting(
meeting_id: int | str,
topic: str | None = None,
start_time: str | None = None,
duration: int | None = None,
timezone: str | None = None,
agenda: str | None = None,
password: str | None = None,
auto_recording: str | None = None,
waiting_room: bool | None = None,
join_before_host: bool | None = None,
mute_upon_entry: bool | None = None,
) -> dict:
"""Update an existing meeting.
Only provided fields are updated; None fields are left unchanged.
Returns:
Dict confirming the update.
"""
body: dict[str, Any] = {}
settings: dict[str, Any] = {}
if topic is not None:
body["topic"] = topic
if start_time is not None:
body["start_time"] = start_time
if duration is not None:
body["duration"] = duration
if timezone is not None:
body["timezone"] = timezone
if agenda is not None:
body["agenda"] = agenda
if password is not None:
body["password"] = password
if auto_recording is not None:
settings["auto_recording"] = auto_recording
if waiting_room is not None:
settings["waiting_room"] = waiting_room
if join_before_host is not None:
settings["join_before_host"] = join_before_host
if mute_upon_entry is not None:
settings["mute_upon_entry"] = mute_upon_entry
if settings:
body["settings"] = settings
if not body:
raise ValueError("No fields provided for update.")
api_patch(f"/meetings/{meeting_id}", body)
return {"status": "updated", "meeting_id": str(meeting_id)}
def delete_meeting(meeting_id: int | str) -> dict:
"""Delete a scheduled meeting.
Args:
meeting_id: The Zoom meeting ID.
Returns:
Dict confirming deletion.
"""
api_delete(f"/meetings/{meeting_id}")
return {"status": "deleted", "meeting_id": str(meeting_id)}
def get_join_url(meeting_id: int | str) -> dict:
"""Get the join URL for a meeting.
Returns:
Dict with join_url and start_url.
"""
result = api_get(f"/meetings/{meeting_id}")
return {
"meeting_id": result.get("id"),
"topic": result.get("topic", ""),
"join_url": result.get("join_url", ""),
"start_url": result.get("start_url", ""),
"password": result.get("password", ""),
}
def _format_meeting(data: dict) -> dict:
"""Format a full meeting response."""
return {
"id": data.get("id"),
"uuid": data.get("uuid", ""),
"topic": data.get("topic", ""),
"type": data.get("type"),
"status": data.get("status", ""),
"start_time": data.get("start_time", ""),
"duration": data.get("duration", 0),
"timezone": data.get("timezone", ""),
"agenda": data.get("agenda", ""),
"join_url": data.get("join_url", ""),
"start_url": data.get("start_url", ""),
"password": data.get("password", ""),
"settings": {
"auto_recording": data.get("settings", {}).get("auto_recording", "none"),
"waiting_room": data.get("settings", {}).get("waiting_room", False),
"join_before_host": data.get("settings", {}).get("join_before_host", False),
"mute_upon_entry": data.get("settings", {}).get("mute_upon_entry", True),
},
"created_at": data.get("created_at", ""),
}
def _format_meeting_summary(data: dict) -> dict:
"""Format a meeting list item."""
return {
"id": data.get("id"),
"topic": data.get("topic", ""),
"type": data.get("type"),
"start_time": data.get("start_time", ""),
"duration": data.get("duration", 0),
"timezone": data.get("timezone", ""),
"join_url": data.get("join_url", ""),
"created_at": data.get("created_at", ""),
}
@@ -0,0 +1,197 @@
"""Participant management — add, remove, list registrants for meetings.
Zoom distinguishes between:
- Registrants: people who registered before the meeting
- Participants: people who actually attended (available after meeting ends)
This module handles both.
"""
from cli_anything.zoom.utils.zoom_backend import api_get, api_post, api_patch
def add_registrant(
meeting_id: int | str,
email: str,
first_name: str = "",
last_name: str = "",
) -> dict:
"""Register a participant for a meeting.
The meeting must have registration enabled (type 2 with registration).
Args:
meeting_id: Zoom meeting ID.
email: Registrant email address.
first_name: First name.
last_name: Last name.
Returns:
Registration result with registrant_id and join_url.
"""
body = {"email": email}
if first_name:
body["first_name"] = first_name
if last_name:
body["last_name"] = last_name
result = api_post(f"/meetings/{meeting_id}/registrants", body)
return {
"registrant_id": result.get("registrant_id", ""),
"id": result.get("id", ""),
"topic": result.get("topic", ""),
"email": email,
"join_url": result.get("join_url", ""),
"start_time": result.get("start_time", ""),
}
def add_batch_registrants(
meeting_id: int | str,
registrants: list[dict],
) -> dict:
"""Register multiple participants at once.
Args:
meeting_id: Zoom meeting ID.
registrants: List of dicts with 'email', optional 'first_name', 'last_name'.
Returns:
Summary of batch registration results.
"""
results = []
errors = []
for reg in registrants:
email = reg.get("email", "")
if not email:
errors.append({"error": "Missing email", "input": reg})
continue
try:
result = add_registrant(
meeting_id,
email=email,
first_name=reg.get("first_name", ""),
last_name=reg.get("last_name", ""),
)
results.append(result)
except Exception as e:
errors.append({"email": email, "error": str(e)})
return {
"meeting_id": str(meeting_id),
"registered": len(results),
"failed": len(errors),
"results": results,
"errors": errors,
}
def list_registrants(
meeting_id: int | str,
status: str = "approved",
page_size: int = 30,
) -> dict:
"""List registrants for a meeting.
Args:
meeting_id: Zoom meeting ID.
status: 'approved', 'pending', or 'denied'.
page_size: Results per page (max 300).
Returns:
Dict with registrants list.
"""
params = {
"status": status,
"page_size": min(page_size, 300),
}
result = api_get(f"/meetings/{meeting_id}/registrants", params=params)
registrants = []
for r in result.get("registrants", []):
registrants.append({
"id": r.get("id", ""),
"email": r.get("email", ""),
"first_name": r.get("first_name", ""),
"last_name": r.get("last_name", ""),
"status": r.get("status", ""),
"create_time": r.get("create_time", ""),
})
return {
"meeting_id": str(meeting_id),
"total_records": result.get("total_records", 0),
"registrants": registrants,
}
def remove_registrant(
meeting_id: int | str,
registrant_id: str,
) -> dict:
"""Cancel a registrant's registration.
Args:
meeting_id: Zoom meeting ID.
registrant_id: The registrant ID to cancel.
Returns:
Confirmation dict.
"""
body = {
"action": "cancel",
"registrants": [{"id": registrant_id}],
}
api_patch(f"/meetings/{meeting_id}/registrants/status", body)
return {
"status": "cancelled",
"meeting_id": str(meeting_id),
"registrant_id": registrant_id,
}
def list_past_participants(
meeting_id: str,
page_size: int = 30,
) -> dict:
"""List participants who actually attended a past meeting.
Note: meeting_id must be the meeting UUID for past meetings.
The meeting must have ended.
Args:
meeting_id: Meeting UUID (double-encoded if starts with / or //).
page_size: Results per page.
Returns:
Dict with participants list.
"""
# Double-encode UUID if it starts with / or //
if meeting_id.startswith("/"):
from urllib.parse import quote
meeting_id = quote(quote(meeting_id, safe=""), safe="")
params = {"page_size": min(page_size, 300)}
result = api_get(
f"/past_meetings/{meeting_id}/participants",
params=params,
)
participants = []
for p in result.get("participants", []):
participants.append({
"id": p.get("id", ""),
"name": p.get("name", ""),
"email": p.get("user_email", ""),
"join_time": p.get("join_time", ""),
"leave_time": p.get("leave_time", ""),
"duration": p.get("duration", 0),
})
return {
"meeting_id": meeting_id,
"total_records": result.get("total_records", 0),
"participants": participants,
}
@@ -0,0 +1,206 @@
"""Recording management — list, download, and delete cloud recordings.
Handles:
- List recordings for a date range
- Get recording files for a specific meeting
- Download recording files
- Delete recordings
"""
import os
from pathlib import Path
from cli_anything.zoom.utils.zoom_backend import api_get, api_delete
def list_recordings(
from_date: str = "",
to_date: str = "",
page_size: int = 30,
) -> dict:
"""List cloud recordings for the authenticated user.
Args:
from_date: Start date (YYYY-MM-DD). Defaults to 30 days ago.
to_date: End date (YYYY-MM-DD). Defaults to today.
page_size: Results per page (max 300).
Returns:
Dict with recording list.
"""
params = {"page_size": min(page_size, 300)}
if from_date:
params["from"] = from_date
if to_date:
params["to"] = to_date
result = api_get("/users/me/recordings", params=params)
meetings = []
for m in result.get("meetings", []):
files = []
for f in m.get("recording_files", []):
files.append({
"id": f.get("id", ""),
"file_type": f.get("file_type", ""),
"file_extension": f.get("file_extension", ""),
"file_size": f.get("file_size", 0),
"status": f.get("status", ""),
"recording_start": f.get("recording_start", ""),
"recording_end": f.get("recording_end", ""),
"download_url": f.get("download_url", ""),
})
meetings.append({
"meeting_id": m.get("id"),
"uuid": m.get("uuid", ""),
"topic": m.get("topic", ""),
"start_time": m.get("start_time", ""),
"duration": m.get("duration", 0),
"total_size": m.get("total_size", 0),
"recording_count": m.get("recording_count", 0),
"recording_files": files,
})
return {
"total_records": result.get("total_records", 0),
"meetings": meetings,
}
def get_meeting_recordings(meeting_id: int | str) -> dict:
"""Get recording files for a specific meeting.
Args:
meeting_id: Zoom meeting ID or UUID.
Returns:
Dict with recording files.
"""
# Double-encode UUID if needed
mid = str(meeting_id)
if mid.startswith("/"):
from urllib.parse import quote
mid = quote(quote(mid, safe=""), safe="")
result = api_get(f"/meetings/{mid}/recordings")
files = []
for f in result.get("recording_files", []):
files.append({
"id": f.get("id", ""),
"file_type": f.get("file_type", ""),
"file_extension": f.get("file_extension", ""),
"file_size": f.get("file_size", 0),
"status": f.get("status", ""),
"download_url": f.get("download_url", ""),
"play_url": f.get("play_url", ""),
"recording_start": f.get("recording_start", ""),
"recording_end": f.get("recording_end", ""),
})
return {
"meeting_id": result.get("id"),
"uuid": result.get("uuid", ""),
"topic": result.get("topic", ""),
"start_time": result.get("start_time", ""),
"duration": result.get("duration", 0),
"total_size": result.get("total_size", 0),
"recording_files": files,
}
def download_recording(
download_url: str,
output_path: str,
overwrite: bool = False,
) -> dict:
"""Download a recording file.
Args:
download_url: The download URL from the recording file info.
output_path: Local path to save the file.
overwrite: Whether to overwrite existing files.
Returns:
Dict with download result.
"""
out = Path(output_path)
if out.exists() and not overwrite:
raise FileExistsError(f"File already exists: {output_path}")
out.parent.mkdir(parents=True, exist_ok=True)
# For recording downloads, we need to use the direct URL with token.
import requests
from cli_anything.zoom.utils.zoom_backend import _get_valid_token
token = _get_valid_token()
resp = requests.get(
download_url,
headers={"Authorization": f"Bearer {token}"},
stream=True,
timeout=300,
)
resp.raise_for_status()
total_size = int(resp.headers.get("content-length", 0))
downloaded = 0
with open(out, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
actual_size = out.stat().st_size
return {
"status": "downloaded",
"path": str(out.resolve()),
"size_bytes": actual_size,
"size_mb": round(actual_size / (1024 * 1024), 2),
}
def delete_recording(meeting_id: int | str) -> dict:
"""Delete all recordings for a meeting.
Args:
meeting_id: Zoom meeting ID or UUID.
Returns:
Confirmation dict.
"""
mid = str(meeting_id)
if mid.startswith("/"):
from urllib.parse import quote
mid = quote(quote(mid, safe=""), safe="")
api_delete(f"/meetings/{mid}/recordings")
return {"status": "deleted", "meeting_id": str(meeting_id)}
def delete_recording_file(
meeting_id: int | str,
recording_id: str,
) -> dict:
"""Delete a specific recording file.
Args:
meeting_id: Zoom meeting ID or UUID.
recording_id: The recording file ID.
Returns:
Confirmation dict.
"""
mid = str(meeting_id)
if mid.startswith("/"):
from urllib.parse import quote
mid = quote(quote(mid, safe=""), safe="")
api_delete(f"/meetings/{mid}/recordings/{recording_id}")
return {
"status": "deleted",
"meeting_id": str(meeting_id),
"recording_id": recording_id,
}
@@ -0,0 +1,177 @@
---
name: >-
cli-anything-zoom
description: >-
Command-line interface for Zoom - CLI harness for **Zoom** — manage meetings, participants, and recordings from the command line via t...
---
# cli-anything-zoom
CLI harness for **Zoom** — manage meetings, participants, and recordings from the command line via the Zoom REST API.
## Installation
This CLI is installed as part of the cli-anything-zoom package:
```bash
pip install cli-anything-zoom
```
**Prerequisites:**
- Python 3.10+
- zoom must be installed on your system
## Usage
### Basic Commands
```bash
# Show help
cli-anything-zoom --help
# Start interactive REPL mode
cli-anything-zoom
# Create a new project
cli-anything-zoom project new -o project.json
# Run with JSON output (for agent consumption)
cli-anything-zoom --json project info -p project.json
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL session:
```bash
cli-anything-zoom
# Enter commands interactively with tab-completion and history
```
## Command Groups
### Auth
Authentication and OAuth2 setup.
| Command | Description |
|---------|-------------|
| `setup` | Configure OAuth app credentials |
| `login` | Login via OAuth2 browser flow |
| `status` | Check authentication status |
| `logout` | Remove saved tokens |
### Meeting
Meeting management commands.
| Command | Description |
|---------|-------------|
| `create` | Create a new Zoom meeting |
| `list` | List meetings |
| `info` | Get meeting details |
| `update` | Update a meeting |
| `delete` | Delete a meeting |
| `join` | Open meeting join URL in browser |
| `start` | Open meeting start URL in browser (host only) |
### Participant
Participant management commands.
| Command | Description |
|---------|-------------|
| `add` | Register a participant for a meeting |
| `add-batch` | Batch register participants from a CSV file |
| `list` | List registered participants |
| `remove` | Cancel a participant's registration |
| `attended` | List participants who attended a past meeting |
### Recording
Cloud recording management.
| Command | Description |
|---------|-------------|
| `list` | List cloud recordings |
| `files` | List recording files for a specific meeting |
| `download` | Download a recording file |
| `delete` | Delete all recordings for a meeting |
## Examples
### Create a New Project
Create a new zoom project file.
```bash
cli-anything-zoom project new -o myproject.json
# Or with JSON output for programmatic use
cli-anything-zoom --json project new -o myproject.json
```
### Interactive REPL Session
Start an interactive session with undo/redo support.
```bash
cli-anything-zoom
# Enter commands interactively
# Use 'help' to see available commands
# Use 'undo' and 'redo' for history navigation
```
## State Management
The CLI maintains session state with:
- **Undo/Redo**: Up to 50 levels of history
- **Project persistence**: Save/load project state as JSON
- **Session tracking**: Track modifications and changes
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): Tables, colors, formatted text
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
```bash
# Human output
cli-anything-zoom project info -p project.json
# JSON output for agents
cli-anything-zoom --json project info -p project.json
```
## For AI Agents
When using this CLI programmatically:
1. **Always use `--json` flag** for parseable output
2. **Check return codes** - 0 for success, non-zero for errors
3. **Parse stderr** for error messages on failure
4. **Use absolute paths** for all file operations
5. **Verify outputs exist** after export operations
## More Information
- Full documentation: See README.md in the package
- Test coverage: See TEST.md in the package
- Methodology: See HARNESS.md in the cli-anything-plugin
## Version
1.0.0
@@ -0,0 +1,46 @@
# Zoom CLI — Test Plan & Results
## Test Strategy
### Unit Tests (`test_core.py`)
- **No network calls** — all Zoom API calls are mocked
- **No Zoom account required** — tests run with synthetic data
- Tests cover: auth setup/login, meeting CRUD, participant management, recordings, JSON output, backend utilities
### E2E Tests (`test_full_e2e.py`)
- **Require real Zoom OAuth credentials** — tokens must be saved via `auth login`
- **Skipped by default** — set `CLI_ANYTHING_ZOOM_E2E=1` to enable
- Tests cover: auth status, full meeting lifecycle (create/read/update/delete), meeting listing, recording listing
## Running Tests
```bash
# Unit tests only (no Zoom account needed)
cd zoom/agent-harness
python3 -m pytest cli_anything/zoom/tests/test_core.py -v
# E2E tests (requires Zoom OAuth setup)
CLI_ANYTHING_ZOOM_E2E=1 python3 -m pytest cli_anything/zoom/tests/test_full_e2e.py -v
# All tests
python3 -m pytest cli_anything/zoom/tests/ -v
```
## Test Results
| Test Suite | Status | Notes |
|---|---|---|
| TestAuthSetup | PASS | Config save/load, custom redirect URI |
| TestAuthLogin | PASS | Login without config, login with code (mocked) |
| TestMeetingCommands | PASS | Create, list, info, update, delete |
| TestParticipantCommands | PASS | Add, list registrants |
| TestRecordingCommands | PASS | List, get files |
| TestJsonOutput | PASS | Valid JSON output for meeting list, auth status |
| TestBackend | PASS | Config/token round-trip, URL building |
## Coverage Notes
- Auth module: OAuth setup, browser login flow (mocked), manual code flow, status check, logout
- Meetings module: Full CRUD, join/start URL retrieval
- Participants module: Add single/batch, list, remove registrants, past participants
- Recordings module: List, get files, download (mocked), delete
@@ -0,0 +1 @@
# tests package
@@ -0,0 +1,559 @@
"""Unit tests for Zoom CLI — no network calls, no Zoom account required.
Tests cover:
- Auth config save/load
- Meeting data formatting
- Participant data formatting
- Recording data formatting
- Report data formatting
- CLI command parsing
"""
import json
import os
import stat
import pytest
from unittest.mock import MagicMock, call, patch
from click.testing import CliRunner
from cli_anything.zoom.zoom_cli import cli
# ── Fixtures ────────────────────────────────────────────────────
@pytest.fixture
def runner():
"""Click CLI test runner."""
return CliRunner()
@pytest.fixture
def tmp_config_dir(tmp_path):
"""Temporary config directory for token/config storage."""
config_dir = tmp_path / ".cli-anything-zoom"
config_dir.mkdir()
return config_dir
@pytest.fixture
def mock_config(tmp_config_dir):
"""Patch config directory to use temp dir."""
with patch("cli_anything.zoom.utils.zoom_backend.CONFIG_DIR", tmp_config_dir), \
patch("cli_anything.zoom.utils.zoom_backend.TOKEN_FILE", tmp_config_dir / "tokens.json"), \
patch("cli_anything.zoom.utils.zoom_backend.CONFIG_FILE", tmp_config_dir / "config.json"):
yield tmp_config_dir
# ── Auth Tests ──────────────────────────────────────────────────
class TestAuthSetup:
"""Test OAuth configuration."""
def test_setup_saves_config(self, runner, mock_config):
"""auth setup should save client_id and client_secret."""
result = runner.invoke(cli, [
"auth", "setup",
"--client-id", "test_id_123",
"--client-secret", "test_secret_456",
])
assert result.exit_code == 0
assert "configured" in result.output.lower() or "OAuth" in result.output
config_file = mock_config / "config.json"
assert config_file.exists()
config = json.loads(config_file.read_text())
assert config["client_id"] == "test_id_123"
assert config["client_secret"] == "test_secret_456"
def test_setup_with_custom_redirect(self, runner, mock_config):
"""auth setup should accept custom redirect URI."""
result = runner.invoke(cli, [
"auth", "setup",
"--client-id", "id",
"--client-secret", "secret",
"--redirect-uri", "http://localhost:9999/cb",
])
assert result.exit_code == 0
config = json.loads((mock_config / "config.json").read_text())
assert config["redirect_uri"] == "http://localhost:9999/cb"
def test_status_not_configured(self, runner, mock_config):
"""auth status should report not configured when no config exists."""
result = runner.invoke(cli, ["auth", "status"])
assert result.exit_code == 0
assert "False" in result.output or "false" in result.output.lower()
def test_logout_no_tokens(self, runner, mock_config):
"""auth logout should succeed even when no tokens exist."""
result = runner.invoke(cli, ["auth", "logout"])
assert result.exit_code == 0
assert "logged_out" in result.output.lower() or "Logged out" in result.output
class TestAuthLogin:
"""Test login flow (mocked)."""
def test_login_without_config_fails(self, runner, mock_config):
"""Login should fail without OAuth config."""
result = runner.invoke(cli, ["auth", "login", "--code", "dummy"])
assert result.exit_code == 1
assert "not configured" in result.output.lower() or "Error" in result.output
def test_login_with_code(self, runner, mock_config):
"""Login with manual code should exchange it for tokens."""
# Setup config first
(mock_config / "config.json").write_text(json.dumps({
"client_id": "id", "client_secret": "secret",
"redirect_uri": "http://localhost:4199/callback",
}))
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "at_123",
"refresh_token": "rt_456",
"expires_in": 3600,
}
mock_response.raise_for_status = MagicMock()
with patch("cli_anything.zoom.utils.zoom_backend.requests.post",
return_value=mock_response), \
patch("cli_anything.zoom.utils.zoom_backend.api_get",
return_value={"email": "test@example.com",
"first_name": "Test", "last_name": "User",
"account_id": "acc123"}):
result = runner.invoke(cli, ["auth", "login", "--code", "auth_code_xyz"])
assert result.exit_code == 0
token_file = mock_config / "tokens.json"
assert token_file.exists()
tokens = json.loads(token_file.read_text())
assert tokens["access_token"] == "at_123"
# ── Meeting Tests ───────────────────────────────────────────────
class TestMeetingCommands:
"""Test meeting CLI commands with mocked API."""
def test_create_meeting(self, runner, mock_config):
"""meeting create should call API and show result."""
mock_meeting = {
"id": 12345,
"uuid": "uuid-abc",
"topic": "Test Standup",
"type": 2,
"status": "waiting",
"start_time": "2025-06-01T10:00:00Z",
"duration": 30,
"timezone": "UTC",
"agenda": "",
"join_url": "https://zoom.us/j/12345",
"start_url": "https://zoom.us/s/12345",
"password": "abc123",
"settings": {
"auto_recording": "none",
"waiting_room": False,
"join_before_host": False,
"mute_upon_entry": True,
},
"created_at": "2025-05-30T09:00:00Z",
}
with patch("cli_anything.zoom.core.meetings.api_post",
return_value=mock_meeting):
result = runner.invoke(cli, [
"meeting", "create",
"--topic", "Test Standup",
"--duration", "30",
])
assert result.exit_code == 0
assert "Test Standup" in result.output
assert "12345" in result.output
def test_list_meetings(self, runner, mock_config):
"""meeting list should show meetings."""
mock_list = {
"total_records": 1,
"page_count": 1,
"page_number": 1,
"page_size": 30,
"meetings": [{
"id": 12345,
"topic": "Weekly Sync",
"type": 2,
"start_time": "2025-06-01T10:00:00Z",
"duration": 60,
"timezone": "UTC",
"join_url": "https://zoom.us/j/12345",
"created_at": "2025-05-30T09:00:00Z",
}],
}
with patch("cli_anything.zoom.core.meetings.api_get",
return_value=mock_list):
result = runner.invoke(cli, ["meeting", "list"])
assert result.exit_code == 0
assert "Weekly Sync" in result.output
def test_get_meeting_info(self, runner, mock_config):
"""meeting info should show meeting details."""
mock_meeting = {
"id": 12345,
"uuid": "uuid-abc",
"topic": "Standup",
"type": 2,
"status": "waiting",
"start_time": "2025-06-01T10:00:00Z",
"duration": 30,
"timezone": "UTC",
"agenda": "Daily standup",
"join_url": "https://zoom.us/j/12345",
"start_url": "https://zoom.us/s/12345",
"password": "pass",
"settings": {
"auto_recording": "cloud",
"waiting_room": True,
"join_before_host": False,
"mute_upon_entry": True,
},
"created_at": "2025-05-30T09:00:00Z",
}
with patch("cli_anything.zoom.core.meetings.api_get",
return_value=mock_meeting):
result = runner.invoke(cli, ["meeting", "info", "12345"])
assert result.exit_code == 0
assert "Standup" in result.output
def test_delete_meeting(self, runner, mock_config):
"""meeting delete should confirm and delete."""
with patch("cli_anything.zoom.core.meetings.api_delete",
return_value={"status": "success"}):
result = runner.invoke(cli, [
"meeting", "delete", "12345", "--confirm",
])
assert result.exit_code == 0
assert "deleted" in result.output.lower()
def test_update_meeting(self, runner, mock_config):
"""meeting update should patch meeting fields."""
with patch("cli_anything.zoom.core.meetings.api_patch"):
result = runner.invoke(cli, [
"meeting", "update", "12345",
"--topic", "Updated Topic",
"--duration", "45",
])
assert result.exit_code == 0
assert "updated" in result.output.lower()
# ── Participant Tests ───────────────────────────────────────────
class TestParticipantCommands:
"""Test participant CLI commands."""
def test_add_participant(self, runner, mock_config):
"""participant add should register a user."""
mock_result = {
"registrant_id": "reg_123",
"id": 12345,
"topic": "Meeting",
"email": "user@example.com",
"join_url": "https://zoom.us/j/12345?tk=reg_123",
"start_time": "",
}
with patch("cli_anything.zoom.core.participants.api_post",
return_value=mock_result):
result = runner.invoke(cli, [
"participant", "add", "12345",
"--email", "user@example.com",
"--first-name", "John",
])
assert result.exit_code == 0
assert "user@example.com" in result.output
def test_list_registrants(self, runner, mock_config):
"""participant list should show registrants."""
mock_result = {
"total_records": 1,
"registrants": [{
"id": "reg_123",
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"status": "approved",
"create_time": "2025-05-30T09:00:00Z",
}],
}
with patch("cli_anything.zoom.core.participants.api_get",
return_value=mock_result):
result = runner.invoke(cli, [
"participant", "list", "12345",
])
assert result.exit_code == 0
assert "user@example.com" in result.output
# ── Recording Tests ─────────────────────────────────────────────
class TestRecordingCommands:
"""Test recording CLI commands."""
def test_list_recordings(self, runner, mock_config):
"""recording list should show cloud recordings."""
mock_result = {
"total_records": 1,
"meetings": [{
"id": 12345,
"uuid": "uuid-abc",
"topic": "Recorded Meeting",
"start_time": "2025-06-01T10:00:00Z",
"duration": 60,
"total_size": 104857600,
"recording_count": 2,
"recording_files": [
{
"id": "file_1",
"file_type": "MP4",
"file_extension": "MP4",
"file_size": 83886080,
"status": "completed",
"recording_start": "2025-06-01T10:00:00Z",
"recording_end": "2025-06-01T11:00:00Z",
"download_url": "https://zoom.us/rec/download/abc",
},
],
}],
}
with patch("cli_anything.zoom.core.recordings.api_get",
return_value=mock_result):
result = runner.invoke(cli, ["recording", "list"])
assert result.exit_code == 0
assert "Recorded Meeting" in result.output
def test_get_recording_files(self, runner, mock_config):
"""recording files should show recording details."""
mock_result = {
"id": 12345,
"uuid": "uuid-abc",
"topic": "My Meeting",
"start_time": "2025-06-01T10:00:00Z",
"duration": 60,
"total_size": 104857600,
"recording_files": [
{
"id": "file_1",
"file_type": "MP4",
"file_extension": "MP4",
"file_size": 83886080,
"status": "completed",
"download_url": "https://zoom.us/rec/download/abc",
"play_url": "https://zoom.us/rec/play/abc",
"recording_start": "2025-06-01T10:00:00Z",
"recording_end": "2025-06-01T11:00:00Z",
},
],
}
with patch("cli_anything.zoom.core.recordings.api_get",
return_value=mock_result):
result = runner.invoke(cli, ["recording", "files", "12345"])
assert result.exit_code == 0
assert "MP4" in result.output
def test_download_recording_uses_direct_url(self, tmp_path):
"""recording download should request the provided download URL."""
from cli_anything.zoom.core.recordings import download_recording
mock_response = MagicMock()
mock_response.headers = {"content-length": "10"}
mock_response.iter_content.return_value = [b"hello", b"world"]
mock_response.raise_for_status = MagicMock()
output_path = tmp_path / "recording.mp4"
download_url = "https://zoom.us/rec/download/abc"
with patch(
"cli_anything.zoom.core.recordings.api_request",
side_effect=AssertionError("api_request should not be used"),
create=True,
) as mock_api_request, \
patch(
"cli_anything.zoom.utils.zoom_backend._get_valid_token",
return_value="access-token",
), \
patch("requests.get", return_value=mock_response) as mock_get:
result = download_recording(download_url, str(output_path))
mock_api_request.assert_not_called()
mock_get.assert_called_once_with(
download_url,
headers={"Authorization": "Bearer access-token"},
stream=True,
timeout=300,
)
assert output_path.read_bytes() == b"helloworld"
assert result["status"] == "downloaded"
assert result["path"] == str(output_path.resolve())
assert result["size_bytes"] == 10
# ── JSON Output Tests ───────────────────────────────────────────
class TestJsonOutput:
"""Test --json flag produces valid JSON."""
def test_meeting_list_json(self, runner, mock_config):
"""--json flag should produce valid JSON output."""
mock_list = {
"total_records": 1,
"page_count": 1,
"page_number": 1,
"page_size": 30,
"meetings": [{
"id": 99999,
"topic": "JSON Test",
"type": 2,
"start_time": "",
"duration": 30,
"timezone": "UTC",
"join_url": "",
"created_at": "",
}],
}
with patch("cli_anything.zoom.core.meetings.api_get",
return_value=mock_list):
result = runner.invoke(cli, ["--json", "meeting", "list"])
assert result.exit_code == 0
parsed = json.loads(result.output)
assert parsed["total_records"] == 1
assert parsed["meetings"][0]["topic"] == "JSON Test"
def test_auth_status_json(self, runner, mock_config):
"""auth status with --json should return valid JSON."""
result = runner.invoke(cli, ["--json", "auth", "status"])
assert result.exit_code == 0
parsed = json.loads(result.output)
assert "configured" in parsed
assert "authenticated" in parsed
# ── Backend Unit Tests ──────────────────────────────────────────
class TestBackend:
"""Test zoom_backend utilities."""
def test_config_save_load(self, mock_config):
"""Config should round-trip through save/load."""
from cli_anything.zoom.utils.zoom_backend import save_config, load_config
save_config({"client_id": "abc", "client_secret": "xyz"})
loaded = load_config()
assert loaded["client_id"] == "abc"
assert loaded["client_secret"] == "xyz"
def test_token_save_load(self, mock_config):
"""Tokens should round-trip with saved_at timestamp."""
from cli_anything.zoom.utils.zoom_backend import save_tokens, load_tokens
save_tokens({"access_token": "at_test", "refresh_token": "rt_test"})
loaded = load_tokens()
assert loaded["access_token"] == "at_test"
assert "saved_at" in loaded
def test_save_config_restricts_directory_and_file(self, mock_config):
"""save_config should enforce 700 on dir and 600 on config file."""
from cli_anything.zoom.utils.zoom_backend import save_config
with patch("cli_anything.zoom.utils.zoom_backend._restrict_path") as mock_restrict:
save_config({"client_id": "abc", "client_secret": "xyz"})
assert (mock_config / "config.json").exists()
assert mock_restrict.call_args_list == [
call(mock_config, 0o700),
call(mock_config / "config.json", 0o600),
]
def test_save_tokens_restricts_directory_and_file(self, mock_config):
"""save_tokens should enforce 700 on dir and 600 on token file."""
from cli_anything.zoom.utils.zoom_backend import save_tokens
with patch("cli_anything.zoom.utils.zoom_backend._restrict_path") as mock_restrict:
save_tokens({"access_token": "at_test", "refresh_token": "rt_test"})
assert (mock_config / "tokens.json").exists()
assert mock_restrict.call_args_list == [
call(mock_config, 0o700),
call(mock_config / "tokens.json", 0o600),
]
@pytest.mark.skipif(
os.name == "nt",
reason="POSIX permission bits are not supported on Windows",
)
def test_get_config_dir_sets_posix_700_permissions(self, mock_config):
"""get_config_dir should force 700 permissions on POSIX."""
from cli_anything.zoom.utils.zoom_backend import get_config_dir
mock_config.chmod(0o755)
config_dir = get_config_dir()
assert config_dir == mock_config
assert stat.S_IMODE(config_dir.stat().st_mode) == 0o700
@pytest.mark.skipif(
os.name == "nt",
reason="POSIX permission bits are not supported on Windows",
)
def test_save_config_sets_posix_600_permissions(self, mock_config):
"""save_config should force 600 on config.json on POSIX."""
from cli_anything.zoom.utils.zoom_backend import save_config
save_config({"client_id": "abc", "client_secret": "xyz"})
assert stat.S_IMODE((mock_config / "config.json").stat().st_mode) == 0o600
@pytest.mark.skipif(
os.name == "nt",
reason="POSIX permission bits are not supported on Windows",
)
def test_save_tokens_sets_posix_600_permissions(self, mock_config):
"""save_tokens should force 600 on tokens.json on POSIX."""
from cli_anything.zoom.utils.zoom_backend import save_tokens
save_tokens({"access_token": "at_test", "refresh_token": "rt_test"})
assert stat.S_IMODE((mock_config / "tokens.json").stat().st_mode) == 0o600
def test_authorize_url(self):
"""get_authorize_url should build valid URL."""
from cli_anything.zoom.utils.zoom_backend import get_authorize_url
url = get_authorize_url("my_client_id", "http://localhost:4199/callback")
assert "zoom.us/oauth/authorize" in url
assert "my_client_id" in url
assert "response_type=code" in url
def test_load_empty_config(self, mock_config):
"""load_config should return empty dict when no config file."""
from cli_anything.zoom.utils.zoom_backend import load_config
result = load_config()
assert result == {}
def test_load_empty_tokens(self, mock_config):
"""load_tokens should return empty dict when no token file."""
from cli_anything.zoom.utils.zoom_backend import load_tokens
result = load_tokens()
assert result == {}
@@ -0,0 +1,124 @@
"""End-to-end tests for Zoom CLI — requires actual Zoom OAuth credentials.
These tests make real API calls to the Zoom API. They require:
1. A Zoom account with OAuth app configured
2. Valid tokens saved via 'cli-anything-zoom auth login'
To run: python3 -m pytest cli_anything/zoom/tests/test_full_e2e.py -v
Set CLI_ANYTHING_ZOOM_E2E=1 to enable these tests.
"""
import json
import os
import time
import pytest
from click.testing import CliRunner
from cli_anything.zoom.zoom_cli import cli
# Skip all E2E tests unless explicitly enabled
pytestmark = pytest.mark.skipif(
not os.environ.get("CLI_ANYTHING_ZOOM_E2E"),
reason="Set CLI_ANYTHING_ZOOM_E2E=1 to run E2E tests (requires Zoom credentials)",
)
@pytest.fixture
def runner():
return CliRunner()
class TestAuthE2E:
"""Test authentication against real Zoom API."""
def test_auth_status(self, runner):
"""Should show authenticated status."""
result = runner.invoke(cli, ["--json", "auth", "status"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["configured"] is True
assert data["authenticated"] is True
assert data["token_valid"] is True
assert "@" in data.get("user", "")
class TestMeetingLifecycleE2E:
"""Test full meeting lifecycle: create -> info -> update -> delete."""
def test_meeting_crud(self, runner):
"""Create, read, update, and delete a test meeting."""
# Create
result = runner.invoke(cli, [
"--json", "meeting", "create",
"--topic", "CLI-Anything E2E Test Meeting",
"--duration", "15",
"--timezone", "UTC",
])
assert result.exit_code == 0
meeting = json.loads(result.output)
meeting_id = meeting["id"]
assert meeting["topic"] == "CLI-Anything E2E Test Meeting"
assert meeting["duration"] == 15
assert meeting["join_url"]
try:
# Read
result = runner.invoke(cli, [
"--json", "meeting", "info", str(meeting_id),
])
assert result.exit_code == 0
info = json.loads(result.output)
assert info["id"] == meeting_id
assert info["topic"] == "CLI-Anything E2E Test Meeting"
# Update
result = runner.invoke(cli, [
"--json", "meeting", "update", str(meeting_id),
"--topic", "CLI-Anything Updated Meeting",
"--duration", "30",
])
assert result.exit_code == 0
assert "updated" in json.loads(result.output).get("status", "")
# Verify update
result = runner.invoke(cli, [
"--json", "meeting", "info", str(meeting_id),
])
updated = json.loads(result.output)
assert updated["topic"] == "CLI-Anything Updated Meeting"
assert updated["duration"] == 30
finally:
# Delete (cleanup)
result = runner.invoke(cli, [
"--json", "meeting", "delete", str(meeting_id), "--confirm",
])
assert result.exit_code == 0
class TestMeetingListE2E:
"""Test meeting listing."""
def test_list_meetings(self, runner):
"""Should list upcoming meetings."""
result = runner.invoke(cli, ["--json", "meeting", "list"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "meetings" in data
assert isinstance(data["meetings"], list)
assert "total_records" in data
class TestRecordingE2E:
"""Test recording listing (read-only)."""
def test_list_recordings(self, runner):
"""Should list cloud recordings."""
result = runner.invoke(cli, ["--json", "recording", "list"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "meetings" in data
@@ -0,0 +1 @@
# utils package
@@ -0,0 +1,567 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
from pathlib import Path
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
import textwrap
inner = 72
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
@@ -0,0 +1,238 @@
"""Zoom API backend — wraps Zoom REST API v2 via OAuth2.
This module handles all HTTP communication with the Zoom API.
It is the only module that makes network requests.
"""
import json
import os
import platform
import subprocess
import time
import requests
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
# Zoom API base URL
API_BASE = "https://api.zoom.us/v2"
# OAuth endpoints
OAUTH_AUTHORIZE_URL = "https://zoom.us/oauth/authorize"
OAUTH_TOKEN_URL = "https://zoom.us/oauth/token"
# Default config directory
CONFIG_DIR = Path.home() / ".cli-anything-zoom"
TOKEN_FILE = CONFIG_DIR / "tokens.json"
CONFIG_FILE = CONFIG_DIR / "config.json"
def _restrict_path(path: Path, mode: int):
"""Set file/directory permissions, with icacls enforcement on Windows.
On Unix, uses os.chmod directly.
On Windows, os.chmod only controls the read-only flag, so we also
run icacls to grant access exclusively to the current user.
"""
try:
path.chmod(mode)
except OSError:
pass
if platform.system() == "Windows":
try:
username = os.environ.get("USERNAME", "")
if username:
subprocess.run(
["icacls", str(path), "/inheritance:r",
"/grant:r", f"{username}:(F)"],
capture_output=True, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass # icacls not available or timed out — best effort
def get_config_dir() -> Path:
"""Get or create config directory with owner-only permissions (0o700)."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_restrict_path(CONFIG_DIR, 0o700)
return CONFIG_DIR
def load_config() -> dict:
"""Load OAuth app config (client_id, client_secret, redirect_uri)."""
if not CONFIG_FILE.exists():
return {}
with open(CONFIG_FILE, "r") as f:
return json.load(f)
def save_config(config: dict):
"""Save OAuth app config with owner-only permissions (0o600)."""
get_config_dir()
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
_restrict_path(CONFIG_FILE, 0o600)
def load_tokens() -> dict:
"""Load saved OAuth tokens from disk."""
if not TOKEN_FILE.exists():
return {}
with open(TOKEN_FILE, "r") as f:
return json.load(f)
def save_tokens(tokens: dict):
"""Save OAuth tokens to disk with owner-only permissions (0o600)."""
get_config_dir()
tokens["saved_at"] = time.time()
with open(TOKEN_FILE, "w") as f:
json.dump(tokens, f, indent=2)
_restrict_path(TOKEN_FILE, 0o600)
def get_authorize_url(client_id: str, redirect_uri: str) -> str:
"""Build the OAuth2 authorization URL for browser login."""
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
}
return f"{OAUTH_AUTHORIZE_URL}?{urlencode(params)}"
def exchange_code(client_id: str, client_secret: str,
code: str, redirect_uri: str) -> dict:
"""Exchange authorization code for access + refresh tokens."""
resp = requests.post(
OAUTH_TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
auth=(client_id, client_secret),
timeout=30,
)
resp.raise_for_status()
return resp.json()
def refresh_access_token(client_id: str, client_secret: str,
refresh_token: str) -> dict:
"""Refresh an expired access token."""
resp = requests.post(
OAUTH_TOKEN_URL,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
auth=(client_id, client_secret),
timeout=30,
)
resp.raise_for_status()
return resp.json()
def _get_valid_token() -> str:
"""Get a valid access token, refreshing if necessary."""
tokens = load_tokens()
if not tokens:
raise RuntimeError(
"Not authenticated. Run 'auth login' first."
)
access_token = tokens.get("access_token")
saved_at = tokens.get("saved_at", 0)
expires_in = tokens.get("expires_in", 3600)
# Refresh if token is about to expire (within 5 minutes)
if time.time() - saved_at > (expires_in - 300):
config = load_config()
if not config.get("client_id") or not config.get("client_secret"):
raise RuntimeError(
"OAuth config missing. Run 'auth setup' first."
)
new_tokens = refresh_access_token(
config["client_id"],
config["client_secret"],
tokens["refresh_token"],
)
new_tokens["refresh_token"] = new_tokens.get(
"refresh_token", tokens["refresh_token"]
)
save_tokens(new_tokens)
access_token = new_tokens["access_token"]
return access_token
def api_request(method: str, endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
stream: bool = False) -> Any:
"""Make an authenticated request to the Zoom API.
Args:
method: HTTP method (GET, POST, PATCH, DELETE).
endpoint: API endpoint path (e.g., '/users/me/meetings').
params: Query parameters.
json_data: JSON request body.
stream: Whether to stream the response (for downloads).
Returns:
Parsed JSON response, or raw Response if streaming.
"""
token = _get_valid_token()
url = f"{API_BASE}{endpoint}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
resp = requests.request(
method,
url,
headers=headers,
params=params,
json=json_data,
stream=stream,
timeout=60,
)
if stream and resp.status_code == 200:
return resp
resp.raise_for_status()
if resp.status_code == 204:
return {"status": "success"}
return resp.json()
def api_get(endpoint: str, params: dict | None = None) -> Any:
"""Shorthand for GET request."""
return api_request("GET", endpoint, params=params)
def api_post(endpoint: str, data: dict | None = None) -> Any:
"""Shorthand for POST request."""
return api_request("POST", endpoint, json_data=data)
def api_patch(endpoint: str, data: dict | None = None) -> Any:
"""Shorthand for PATCH request."""
return api_request("PATCH", endpoint, json_data=data)
def api_delete(endpoint: str) -> Any:
"""Shorthand for DELETE request."""
return api_request("DELETE", endpoint)
def get_current_user() -> dict:
"""Get the authenticated user's profile."""
return api_get("/users/me")
@@ -0,0 +1,517 @@
#!/usr/bin/env python3
"""Zoom CLI — Manage Zoom meetings, participants, and recordings from the command line.
This CLI wraps the Zoom REST API v2 via OAuth2. It covers the full
meeting lifecycle: authentication, meeting CRUD, participant management,
recording retrieval, and reporting.
Usage:
# Setup OAuth credentials
cli-anything-zoom auth setup --client-id <ID> --client-secret <SECRET>
# Login via browser
cli-anything-zoom auth login
# Create a meeting
cli-anything-zoom meeting create --topic "Standup" --duration 30
# List meetings
cli-anything-zoom meeting list
# Interactive REPL
cli-anything-zoom repl
"""
import sys
import os
import json
import shlex
import webbrowser
import click
from typing import Optional
# Add parent to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from cli_anything.zoom.core import auth as auth_mod
from cli_anything.zoom.core import meetings as meet_mod
from cli_anything.zoom.core import participants as part_mod
from cli_anything.zoom.core import recordings as rec_mod
# Global state
_json_output = False
_repl_mode = False
def output(data, message: str = ""):
"""Print output in JSON or human-readable format."""
if _json_output:
click.echo(json.dumps(data, indent=2, default=str))
else:
if message:
click.echo(message)
if isinstance(data, dict):
_print_dict(data)
elif isinstance(data, list):
_print_list(data)
else:
click.echo(str(data))
def _print_dict(d: dict, indent: int = 0):
prefix = " " * indent
for k, v in d.items():
if isinstance(v, dict):
click.echo(f"{prefix}{k}:")
_print_dict(v, indent + 1)
elif isinstance(v, list):
click.echo(f"{prefix}{k}:")
_print_list(v, indent + 1)
else:
click.echo(f"{prefix}{k}: {v}")
def _print_list(items: list, indent: int = 0):
prefix = " " * indent
for i, item in enumerate(items):
if isinstance(item, dict):
click.echo(f"{prefix}[{i}]")
_print_dict(item, indent + 1)
else:
click.echo(f"{prefix}- {item}")
def handle_error(func):
"""Decorator for consistent error handling."""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
if _json_output:
click.echo(json.dumps({
"error": str(e),
"type": type(e).__name__,
}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
# ── Main CLI Group ──────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.pass_context
def cli(ctx, use_json):
"""Zoom CLI — Meeting management from the command line.
Run without a subcommand to enter interactive REPL mode.
"""
global _json_output
_json_output = use_json
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
# ── Auth Commands ───────────────────────────────────────────────
@cli.group()
def auth():
"""Authentication and OAuth2 setup."""
pass
@auth.command("setup")
@click.option("--client-id", required=True, help="Zoom OAuth app Client ID")
@click.option("--client-secret", required=True, help="Zoom OAuth app Client Secret")
@click.option("--redirect-uri", default="http://localhost:4199/callback",
help="OAuth redirect URI")
@handle_error
def auth_setup(client_id, client_secret, redirect_uri):
"""Configure OAuth app credentials."""
result = auth_mod.setup_oauth(client_id, client_secret, redirect_uri)
output(result, "OAuth app configured successfully.")
@auth.command("login")
@click.option("--code", default=None,
help="Authorization code (for manual flow)")
@handle_error
def auth_login(code):
"""Login via OAuth2 browser flow.
Opens a browser for Zoom authorization. After approving,
tokens are saved locally for subsequent API calls.
Use --code if you need to manually paste the authorization code.
"""
if code:
result = auth_mod.login_with_code(code)
else:
result = auth_mod.login()
output(result, "Login successful.")
@auth.command("status")
@handle_error
def auth_status():
"""Check authentication status."""
result = auth_mod.get_auth_status()
output(result)
@auth.command("logout")
@handle_error
def auth_logout():
"""Remove saved tokens."""
result = auth_mod.logout()
output(result, "Logged out.")
# ── Meeting Commands ────────────────────────────────────────────
@cli.group()
def meeting():
"""Meeting management commands."""
pass
@meeting.command("create")
@click.option("--topic", "-t", required=True, help="Meeting topic/title")
@click.option("--start-time", "-s", default=None,
help="Start time (ISO 8601, e.g., 2025-01-15T10:00:00Z)")
@click.option("--duration", "-d", type=int, default=60, help="Duration in minutes")
@click.option("--timezone", default="UTC", help="Timezone (e.g., Asia/Shanghai)")
@click.option("--agenda", default="", help="Meeting description/agenda")
@click.option("--password", default=None, help="Meeting password")
@click.option("--auto-recording", type=click.Choice(["none", "local", "cloud"]),
default="none", help="Auto-recording mode")
@click.option("--waiting-room", is_flag=True, help="Enable waiting room")
@click.option("--join-before-host", is_flag=True, help="Allow join before host")
@click.option("--no-mute", is_flag=True, help="Don't mute participants on entry")
@handle_error
def meeting_create(topic, start_time, duration, timezone, agenda, password,
auto_recording, waiting_room, join_before_host, no_mute):
"""Create a new Zoom meeting."""
result = meet_mod.create_meeting(
topic=topic,
start_time=start_time,
duration=duration,
timezone=timezone,
agenda=agenda,
password=password,
auto_recording=auto_recording,
waiting_room=waiting_room,
join_before_host=join_before_host,
mute_upon_entry=not no_mute,
)
output(result, f"Meeting created: {topic}")
@meeting.command("list")
@click.option("--status", "-s",
type=click.Choice(["upcoming", "scheduled", "live", "pending"]),
default="upcoming", help="Meeting status filter")
@click.option("--page-size", type=int, default=30, help="Results per page")
@handle_error
def meeting_list(status, page_size):
"""List meetings."""
result = meet_mod.list_meetings(status=status, page_size=page_size)
output(result, f"Meetings ({status}):")
@meeting.command("info")
@click.argument("meeting_id")
@handle_error
def meeting_info(meeting_id):
"""Get meeting details."""
result = meet_mod.get_meeting(meeting_id)
output(result)
@meeting.command("update")
@click.argument("meeting_id")
@click.option("--topic", "-t", default=None, help="New topic")
@click.option("--start-time", "-s", default=None, help="New start time")
@click.option("--duration", "-d", type=int, default=None, help="New duration")
@click.option("--timezone", default=None, help="New timezone")
@click.option("--agenda", default=None, help="New agenda")
@click.option("--password", default=None, help="New password")
@click.option("--auto-recording", type=click.Choice(["none", "local", "cloud"]),
default=None, help="Auto-recording mode")
@handle_error
def meeting_update(meeting_id, topic, start_time, duration, timezone,
agenda, password, auto_recording):
"""Update a meeting."""
result = meet_mod.update_meeting(
meeting_id=meeting_id,
topic=topic,
start_time=start_time,
duration=duration,
timezone=timezone,
agenda=agenda,
password=password,
auto_recording=auto_recording,
)
output(result, f"Meeting {meeting_id} updated.")
@meeting.command("delete")
@click.argument("meeting_id")
@click.option("--confirm", is_flag=True, help="Skip confirmation")
@handle_error
def meeting_delete(meeting_id, confirm):
"""Delete a meeting."""
if not confirm and not _repl_mode:
click.confirm(f"Delete meeting {meeting_id}?", abort=True)
result = meet_mod.delete_meeting(meeting_id)
output(result, f"Meeting {meeting_id} deleted.")
@meeting.command("join")
@click.argument("meeting_id")
@handle_error
def meeting_join(meeting_id):
"""Open meeting join URL in browser."""
urls = meet_mod.get_join_url(meeting_id)
join_url = urls.get("join_url", "")
if not join_url:
raise RuntimeError("No join URL available for this meeting.")
webbrowser.open(join_url)
output(urls, f"Opening meeting in browser...")
@meeting.command("start")
@click.argument("meeting_id")
@handle_error
def meeting_start(meeting_id):
"""Open meeting start URL in browser (host only)."""
urls = meet_mod.get_join_url(meeting_id)
start_url = urls.get("start_url", "")
if not start_url:
raise RuntimeError("No start URL available for this meeting.")
webbrowser.open(start_url)
output(urls, f"Starting meeting in browser...")
# ── Participant Commands ────────────────────────────────────────
@cli.group()
def participant():
"""Participant management commands."""
pass
@participant.command("add")
@click.argument("meeting_id")
@click.option("--email", "-e", required=True, help="Participant email")
@click.option("--first-name", default="", help="First name")
@click.option("--last-name", default="", help="Last name")
@handle_error
def participant_add(meeting_id, email, first_name, last_name):
"""Register a participant for a meeting."""
result = part_mod.add_registrant(
meeting_id, email=email,
first_name=first_name, last_name=last_name,
)
output(result, f"Registered: {email}")
@participant.command("add-batch")
@click.argument("meeting_id")
@click.argument("csv_file", type=click.Path(exists=True))
@handle_error
def participant_add_batch(meeting_id, csv_file):
"""Batch register participants from a CSV file.
CSV format: email,first_name,last_name (header row required)
"""
import csv
registrants = []
with open(csv_file, "r") as f:
reader = csv.DictReader(f)
for row in reader:
registrants.append({
"email": row.get("email", ""),
"first_name": row.get("first_name", ""),
"last_name": row.get("last_name", ""),
})
result = part_mod.add_batch_registrants(meeting_id, registrants)
output(result, f"Batch registration: {result['registered']} succeeded, {result['failed']} failed")
@participant.command("list")
@click.argument("meeting_id")
@click.option("--status", "-s",
type=click.Choice(["approved", "pending", "denied"]),
default="approved", help="Registration status filter")
@handle_error
def participant_list(meeting_id, status):
"""List registered participants."""
result = part_mod.list_registrants(meeting_id, status=status)
output(result, f"Registrants ({status}):")
@participant.command("remove")
@click.argument("meeting_id")
@click.argument("registrant_id")
@handle_error
def participant_remove(meeting_id, registrant_id):
"""Cancel a participant's registration."""
result = part_mod.remove_registrant(meeting_id, registrant_id)
output(result, "Registration cancelled.")
@participant.command("attended")
@click.argument("meeting_id", metavar="MEETING_UUID")
@handle_error
def participant_attended(meeting_id):
"""List participants who attended a past meeting.
Requires the meeting UUID (not the numeric ID).
"""
result = part_mod.list_past_participants(meeting_id)
output(result, "Past participants:")
# ── Recording Commands ──────────────────────────────────────────
@cli.group()
def recording():
"""Cloud recording management."""
pass
@recording.command("list")
@click.option("--from", "from_date", default="", help="Start date (YYYY-MM-DD)")
@click.option("--to", "to_date", default="", help="End date (YYYY-MM-DD)")
@click.option("--page-size", type=int, default=30, help="Results per page")
@handle_error
def recording_list(from_date, to_date, page_size):
"""List cloud recordings."""
result = rec_mod.list_recordings(
from_date=from_date, to_date=to_date, page_size=page_size,
)
output(result, "Cloud recordings:")
@recording.command("files")
@click.argument("meeting_id")
@handle_error
def recording_files(meeting_id):
"""List recording files for a specific meeting."""
result = rec_mod.get_meeting_recordings(meeting_id)
output(result)
@recording.command("download")
@click.argument("download_url")
@click.argument("output_path")
@click.option("--overwrite", is_flag=True, help="Overwrite existing file")
@handle_error
def recording_download(download_url, output_path, overwrite):
"""Download a recording file.
DOWNLOAD_URL: The download URL from 'recording files' output.
OUTPUT_PATH: Local file path to save the recording.
"""
result = rec_mod.download_recording(download_url, output_path, overwrite)
output(result, f"Downloaded to: {output_path}")
@recording.command("delete")
@click.argument("meeting_id")
@click.option("--confirm", is_flag=True, help="Skip confirmation")
@handle_error
def recording_delete(meeting_id, confirm):
"""Delete all recordings for a meeting."""
if not confirm and not _repl_mode:
click.confirm(f"Delete recordings for meeting {meeting_id}?", abort=True)
result = rec_mod.delete_recording(meeting_id)
output(result, "Recordings deleted.")
# ── REPL ─────────────────────────────────────────────────────────
@cli.command()
@handle_error
def repl():
"""Start interactive REPL session."""
from cli_anything.zoom.utils.repl_skin import ReplSkin
global _repl_mode
_repl_mode = True
skin = ReplSkin("zoom", version="1.0.1")
skin.print_banner()
pt_session = skin.create_prompt_session()
_repl_commands = {
"auth": "setup|login|status|logout",
"meeting": "create|list|info|update|delete|join|start",
"participant": "add|add-batch|list|remove|attended",
"recording": "list|files|download|delete",
"help": "Show this help",
"quit": "Exit REPL",
}
# Check auth status on start
try:
status = auth_mod.get_auth_status()
if status.get("authenticated"):
skin.success(f"Authenticated as: {status.get('user', 'unknown')}")
elif status.get("configured"):
skin.warning("OAuth configured but not logged in. Run: auth login")
else:
skin.info("Not configured. Run: auth setup --client-id <ID> --client-secret <SECRET>")
except Exception:
skin.info("Run 'auth setup' to configure OAuth credentials.")
while True:
try:
# Determine context for prompt
try:
status = auth_mod.get_auth_status()
context = status.get("user", "") if status.get("authenticated") else ""
except Exception:
context = ""
line = skin.get_input(pt_session, context=context)
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
skin.print_goodbye()
break
if line.lower() == "help":
skin.help(_repl_commands)
continue
# Parse and execute command (shlex handles quoted strings with spaces)
try:
args = shlex.split(line)
except ValueError:
args = line.split()
try:
cli.main(args, standalone_mode=False)
except SystemExit:
pass
except click.exceptions.UsageError as e:
skin.warning(f"Usage error: {e}")
except Exception as e:
skin.error(f"{e}")
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
_repl_mode = False
# ── Entry Point ──────────────────────────────────────────────────
def main():
cli()
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
setup.py for cli-anything-zoom
Install with: pip install -e .
Or publish to PyPI: python -m build && twine upload dist/*
"""
from setuptools import setup, find_namespace_packages
setup(
name="cli-anything-zoom",
version="1.0.1",
author="cli-anything contributors",
author_email="",
description="CLI harness for Zoom - Meeting management via Zoom REST API (OAuth2). Requires: Zoom account + OAuth app credentials",
long_description=open("cli_anything/zoom/README.md", "r", encoding="utf-8").read()
if __import__("os").path.exists("cli_anything/zoom/README.md")
else "CLI harness for Zoom meeting management.",
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Communications :: Conferencing",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"requests>=2.28.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-zoom=cli_anything.zoom.zoom_cli:main",
],
},
package_data={
"cli_anything.zoom": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)