chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user