diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 53e13b0..aa9c399 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -474,13 +474,13 @@ "description": "Guide a person in cultivating creativity in their own work and life: open conversational sessions on creative blocks, habits, environment, motivation, and resilience, or structured development of a concrete project or fledgling idea through a five-phase practice. Do not use for therapy or clinical support, general life coaching, product or stakeholder discovery, or as a study guide for a book." }, { - "name": "ghost-cli", + "name": "ghost", "source": "./", "skills": [ - "./ghost-cli" + "./ghost" ], "strict": false, - "description": "Manage Ghost CMS content from the terminal — create and list posts, pages, and tags, and fetch site info via the Ghost Admin API (v5/v6). Use when the user asks about ghost, cms, blog, blogging, posts, pages, tags, publishing, or site configuration." + "description": "Manage Ghost CMS content over the Admin API — browse posts, pages, and tags, draft and publish content, schedule posts, and inspect site info from the terminal. Do not use this skill for Ghost server installation or site administration (installing, nginx, SSL, systemd, updates); those belong to the official npm ghost-cli tooling." }, { "name": "github-runner", @@ -555,31 +555,22 @@ "description": "Convert operational incident and near-miss evidence into durable product, engineering, test, evaluation, and governance improvements with verified closure. Separate observed facts from causal hypotheses and unresolved uncertainty; map follow-up work across code, tests, skills, operations, product, and governance; track ownership, verification, and closure for every finding. Do not use to assign blame or produce a generic postmortem template; do not close learning because tickets were created — require evidence the intended change occurred." }, { - "name": "jellyfin-cli", + "name": "jellyfin", "source": "./", "skills": [ - "./jellyfin-cli" + "./jellyfin" ], "strict": false, - "description": "Query your Jellyfin media server from the terminal — recently added media, search, item details, next-up episodes, library browsing, server info, and stats. Use when the user asks about Jellyfin, media server, movies, TV shows, next episodes, or their media library." + "description": "Query your Jellyfin media server from the terminal — recently added media, search, item details, series navigation, next-up episodes, library browsing, server info, and user login. Use when the user asks about Jellyfin, media servers, movies, TV shows, next episodes, or their media library. Do not use this skill for server installation, library management, playback control, or Emby/Plex servers." }, { - "name": "jira-cli", + "name": "jira", "source": "./", "skills": [ - "./jira-cli" + "./jira" ], "strict": false, - "description": "Interact with Atlassian Jira from the terminal: search issues, view details, create issues, add comments, list projects, and transition status. Use when the user mentions Jira, a ticket key (e.g. PROJ-123), or asks about issues, bugs, tasks, projects, or sprint work." - }, - { - "name": "jira-jql", - "source": "./", - "skills": [ - "./jira-jql" - ], - "strict": false, - "description": "Expert-level skill for Jira Query Language (JQL). Use when the user asks about writing, debugging, optimizing, or understanding JQL queries; needs to filter Jira issues by complex criteria, date ranges, history, or cross-project conditions; wants to build saved filters, dashboard gadgets, or automation rules; or needs guidance on JQL performance, functions, operators, history operators (WAS/CHANGED), relative dates, role-based query patterns, or the JQL REST API." + "description": "Interact with Atlassian Jira from the terminal: search issues with JQL, view details, create issues, add comments, count matches with fast approximate-count, list projects, discover valid transitions, and change status. Includes a full JQL language reference (functions, operators, history predicates, date expressions, saved filters, performance tuning) plus REST auth/pagination guidance. Use when the user mentions Jira, a ticket key (e.g. PROJ-123), asks about issues, bugs, tasks, projects, or sprint work, or needs to write, debug, or optimize JQL queries. Do not use for GitHub or GitLab issue tracking, Jira site administration, or generic ticketing systems." }, { "name": "kanban-guru", @@ -771,13 +762,13 @@ "description": "Google's Open Knowledge Format (OKF) v0.1 — an open, vendor-neutral spec for representing knowledge as markdown files with YAML frontmatter, designed for AI agent consumption. Use when the user mentions OKF, Open Knowledge Format, Google's knowledge format, LLM wiki bundles, agent knowledge packs, creating OKF bundles, validating OKF documents, or converting knowledge into the OKF standard." }, { - "name": "openlibrary-cli", + "name": "openlibrary", "source": "./", "skills": [ - "./openlibrary-cli" + "./openlibrary" ], "strict": false, - "description": "Search books, authors, and works on Open Library from the terminal. Look up books by ISBN, search titles and authors, and fetch detailed work/author records via the public Open Library API. No API key required." + "description": "Query the Open Library catalog from the terminal: search books and authors, look up works, editions, and ISBNs, enumerate every edition of a work, read community ratings, and resolve cover-image URLs. Fully keyless public API. Includes the OL…M/W/A key-graph reference, ISBN 302-redirect resolution, search query syntax, covers-host rules, and worked pipelines. Do not use for library-IT administration (Koha/MARC/ILS migration), commercial book-data feeds, or managing your reading account on Open Library itself." }, { "name": "opensource-contributions", @@ -822,7 +813,7 @@ "./peertube" ], "strict": false, - "description": "Browse PeerTube federated video from the terminal: view videos and channels, search across instances, check server stats, and manage your account. Uses OAuth2 authentication with token persistence. Use when the user mentions PeerTube, federated video, decentralized video platforms, or browsing/uploading to a PeerTube instance." + "description": "Browse PeerTube federated video from the terminal — instance stats, latest videos, video detail, comment threads, channels, accounts, instance-local search, and OAuth2 login with per-instance token persistence. Set PEERTUBE_SERVER to any instance; point it at sepiasearch.org for fediverse-wide search. Use when the user mentions PeerTube, federated video, SepiaSearch, or browsing a specific PeerTube instance. Do not use this skill for YouTube/Vimeo uploads, video editing, or installing and administering a PeerTube server." }, { "name": "platform-engineering", @@ -1239,13 +1230,13 @@ "description": "Operate the observability stack that deploys as one unit: Prometheus scrape configuration, recording and alerting rules, relabeling, retention, and high availability; OpenTelemetry Collector pipelines (receivers, processors, exporters, sampling, trace/span correlation); and Loki ingest, LogQL, retention, and label design — with a bundled read-only telemetry-check script for Prometheus rule sanity and scrape-target reachability. Use when running, tuning, or troubleshooting a Prometheus, OpenTelemetry Collector, or Loki deployment, or reviewing the collection/ingest/retention layer. Do not use for observability strategy, SLI/SLO design, or paging policy (that is platform-engineering) or Grafana dashboards, panels, and Grafana-side alerting (that is grafana)." }, { - "name": "tempest-cli", + "name": "tempest", "source": "./", "skills": [ - "./tempest-cli" + "./tempest" ], "strict": false, - "description": "Query hyper-local weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, or wants conditions from their own station rather than a generic weather service." + "description": "Query hyper-local weather from a WeatherFlow Tempest station over its REST API and the hub's local UDP broadcast: current conditions, forecast, historical observations, and real-time decoded datagrams (obs_st, rapid_wind, evt_precip, evt_strike, hub_status). Use when the user asks about weather, temperature, rain, wind, humidity, or forecast data from their own Tempest/WeatherFlow station, or wants to parse the hub's UDP port 50222 broadcast. Do not use this skill for generic or city forecasts without a Tempest station (public weather services serve those), for Shakespeare's play The Tempest or other literature questions, or for weather hardware from other vendors - the REST endpoints require a personal-use token and the UDP broadcast only exists on a Tempest hub's LAN." }, { "name": "terraform", @@ -1266,13 +1257,13 @@ "description": "Build browser-based Three.js and WebGL scenes, animations, and interactive 3D visualizations with a small vanilla JavaScript starting point." }, { - "name": "tmdb-cli", + "name": "tmdb", "source": "./", "skills": [ - "./tmdb-cli" + "./tmdb" ], "strict": false, - "description": "Search and discover movies, TV shows, and trending content via The Movie Database (TMDb) API v3. Use when the user asks about movies, TV, film, cinema, genres, certifications, ratings, cast, upcoming releases, or trending media." + "description": "Query TMDb metadata for films and television, then enrich results with details, credits, providers, and external IDs. Do not use this skill for torrent search, streaming playback, or personal watch-history tracking." }, { "name": "traefik", @@ -1290,7 +1281,7 @@ "./trakt" ], "strict": false, - "description": "Discover trending, anticipated, and popular movies and TV shows via the Trakt.tv API from the terminal. No authentication required for read-only discovery. Use when the user asks about what to watch, trending movies, popular shows, or media discovery." + "description": "Discover and compare Trakt.tv trending, popular, and anticipated movies and shows from the terminal. Do not use this skill for TMDb catalog metadata, credits, images, or provider lookups; use the tmdb skill for those tasks." }, { "name": "transistor", @@ -1299,7 +1290,7 @@ "./transistor" ], "strict": false, - "description": "Manage Transistor.fm podcast hosting from the terminal: view shows, list episodes, check analytics, and get subscriber counts. Use when the user mentions Transistor, podcast hosting, podcast analytics, show management, or episode tracking." + "description": "Operate Transistor.fm podcast hosting from the terminal: verify API access, browse shows and episodes with JSON:API-aware output, run the episode publish lifecycle (create draft, attach audio, publish or schedule via the dedicated publish endpoint), pull download analytics, and manage private podcast subscribers and webhooks. Use when the user mentions Transistor, Transistor.fm, podcast hosting, episode publishing, private podcast subscribers, or podcast download analytics. Do not use this skill for other podcast hosts (Buzzsprout, Libsyn, Megaphone, Spotify for Creators), for editing or producing audio, or for feed/RSS parsing — the bundled CLI manages a Transistor account through its v1 API and cannot create new shows (dashboard-only)." }, { "name": "travel-guide", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a36f5e5..7d29bc2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -70,7 +70,7 @@ "./forward-deployed-engineering", "./frontend-engineering", "./genius-life", - "./ghost-cli", + "./ghost", "./github-runner", "./go-to-market", "./grafana", @@ -79,9 +79,8 @@ "./hugo-theme", "./implementation-planning", "./incident-learning", - "./jellyfin-cli", - "./jira-cli", - "./jira-jql", + "./jellyfin", + "./jira", "./kanban-guru", "./kubernetes", "./langchain", @@ -103,7 +102,7 @@ "./notion", "./nous-branding", "./open-knowledge-format", - "./openlibrary-cli", + "./openlibrary", "./opensource-contributions", "./operational-design", "./org-design", @@ -155,10 +154,10 @@ "./technical-documentation", "./technology-radar", "./telemetry", - "./tempest-cli", + "./tempest", "./terraform", "./three", - "./tmdb-cli", + "./tmdb", "./traefik", "./trakt", "./transistor", diff --git a/.gitignore b/.gitignore index 48253f4..e0f00bd 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ cashew-brain/ # ─── Python ─────────────────────────────────────────────────── __pycache__/ +test-results/ *.py[cod] *.egg-info/ *.egg diff --git a/README.md b/README.md index 41edc94..2919092 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ Build and maintain web frontends — component architecture, state management, A Guide a person in cultivating creativity in their own work and life: open conversational sessions on creative blocks, habits, environment, motivation, and resilience, or structured development of a concrete project or fledgling idea through a five-phase practice. Do not use for therapy or clinical support, general life coaching, product or stakeholder discovery, or as a study guide for a book. -### [ghost-cli](ghost-cli/SKILL.md) +### [ghost](ghost/SKILL.md) Ghost CMS from the terminal. Manage posts and pages, list tags, and check site info. Admin API key from Ghost Integrations. JWT authentication handled automatically. @@ -253,17 +253,13 @@ Turn an approved requirement or specification into an executable, dependency-awa Convert operational incident and near-miss evidence into verified, owned improvements across product, code, tests, evals, operations, and governance. Separates observed facts from causal hypotheses and unresolved uncertainty; maps escaped-from gaps (requirements, monitoring, authority, migration, adoption); assigns domain-specific follow-up work with owners and verification methods; and requires evidence of the implemented change — not just tickets — for closure. Routes implementation to SRE, QA, verification, agent evals, product lifecycle learning, implementation planning, resilience-and-recovery, and production-readiness. Ships 4 references (discovery brief, evidence/inference taxonomy, escaped-from analysis, follow-up domains, verification and closure), 4 templates (incident-learning record, causal/evidence ledger, follow-up work map, verification and closure record), and 5 evals. -### [jellyfin-cli](jellyfin-cli/SKILL.md) +### [jellyfin](jellyfin/SKILL.md) -Jellyfin media server from the terminal. Check server info, browse recently added and library contents, search and inspect media, see next-up episodes, and view statistics. +Jellyfin media server from the terminal. Log in as a user or use an API key, check server info, browse recently added and library contents, search and inspect media, walk series/seasons/episodes, see next-up episodes, and view statistics — with MediaBrowser auth, user-id scoping, and response-shape quirks documented. -### [jira-cli](jira-cli/SKILL.md) +### [jira](jira/SKILL.md) -Atlassian Jira from the terminal. Search issues with JQL, view details, create issues, add comments, list projects, and transition status. API token from id.atlassian.com. - -### [jira-jql](jira-jql/SKILL.md) - -Expert-level Jira Query Language reference covering all operators, functions (date/time, user, sprint/version, issue, custom field, JSM), history operators (WAS/CHANGED), relative dates, performance best practices, role-based ready queries, REST API usage, and troubleshooting. Three companion references: complete function catalog, role-specific query bank (dev, scrum master, PO, power user, admin), and gotchas/troubleshooting guide. +Atlassian Jira from the terminal. Search issues with JQL, view details, create issues, add comments, count matches, list projects, and transition status. Includes a full JQL reference (functions, history predicates, date expressions) plus REST auth/pagination guidance. API token from id.atlassian.com. ### [kanban-guru](kanban-guru/SKILL.md) @@ -356,9 +352,9 @@ prompt templates for text-only and reference-image-driven workflows. Google's Open Knowledge Format (OKF) v0.1 — create, validate, and consume vendor-neutral AI agent knowledge bundles. Markdown files with YAML frontmatter, organized in directory hierarchies with cross-links and progressive disclosure. Ships a validation script, concept template, example bundle, and detailed references covering the spec, bundle architecture, and real-world use cases. -### [openlibrary-cli](openlibrary-cli/SKILL.md) +### [openlibrary](openlibrary/SKILL.md) -Open Library book metadata from the terminal. Search books and authors, get work and edition details, lookup by ISBN. No API key required — the public Open Library API is free for everyone. +Query the Open Library catalog from the terminal: search books and authors, look up works, editions, and ISBNs, enumerate every edition of a work, read community ratings, and resolve cover-image URLs. Fully keyless public API — no API key required. ### [opensource-contributions](opensource-contributions/SKILL.md) @@ -378,7 +374,7 @@ Build and maintain owner-approved Primary, Alternate, Contingency, and Emergency ### [peertube](peertube/SKILL.md) -PeerTube federated video platform from the terminal. Browse videos and channels, search across instances, view server info. OAuth2 login with token persistence. Set PEERTUBE_SERVER to point at any instance. +PeerTube federated video from the terminal. Browse videos, channels, and comment threads on any instance, search instance-local or the whole fediverse via SepiaSearch, check server stats, and log in with OAuth2 — with per-instance token persistence and pagination/search-scope quirks documented. ### [platform-engineering](platform-engineering/SKILL.md) @@ -564,9 +560,9 @@ Turn technology preferences and architecture-governance choices into explicit, r Operate the Prometheus + OpenTelemetry + Loki observability stack as one unit: scrape config, recording and alerting rules, relabeling, retention, and HA; OpenTelemetry Collector pipelines (receivers, processors, exporters, sampling, trace/span correlation); and Loki ingest, LogQL, retention, and label design. Ships the read-only `telemetry-check` script (rule sanity + scrape-target reachability, `--json`), fixtures, tests, dated references, and 6 evals. Routes strategy to platform-engineering and dashboards to grafana. -### [tempest-cli](tempest-cli/SKILL.md) +### [tempest](tempest/SKILL.md) -Hyper-local weather from a WeatherFlow Tempest station. Query current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. A complete reference implementation of the cli-builder patterns in a working, testable project — including the CLI binary and full API field layout reference. +Hyper-local weather from a WeatherFlow Tempest station over the REST API and the hub's local UDP broadcast: current conditions, forecast, historical observations, and real-time decoded datagrams (obs_st, rapid_wind, evt_precip, evt_strike, hub_status) with metric-native values and conversion guidance. Not for generic city forecasts without a station, or weather hardware from other vendors. ### [terraform](terraform/SKILL.md) @@ -576,7 +572,7 @@ Operate Terraform and OpenTofu safely across the whole infrastructure lifecycle: Build browser-based Three.js and WebGL scenes, animations, and interactive 3D visualizations. -### [tmdb-cli](tmdb-cli/SKILL.md) +### [tmdb](tmdb/SKILL.md) The Movie Database API from the terminal. Search and discover movies and TV by genre, certification, rating, and date range. Check trending, upcoming, and now playing. Free API key from themoviedb.org. @@ -590,7 +586,7 @@ Trakt.tv media discovery from the terminal. Browse trending, anticipated, and po ### [transistor](transistor/SKILL.md) -Transistor.fm podcast hosting from the terminal. Manage shows and episodes, view subscriber analytics. API key from transistor.fm settings. +Operate Transistor.fm podcast hosting from the terminal: verify API access, browse shows and episodes with JSON:API-aware output, run the episode publish lifecycle (create draft, attach audio, publish or schedule via the dedicated publish endpoint), pull download analytics, and manage private podcast subscribers and webhooks. ### [travel-guide](travel-guide/SKILL.md) diff --git a/cli-builder/SKILL.md b/cli-builder/SKILL.md index fb08119..c112bab 100644 --- a/cli-builder/SKILL.md +++ b/cli-builder/SKILL.md @@ -54,7 +54,7 @@ Capture data shapes → Run tests as-you-go → Fix failures Each API or data source gets its own CLI. Do not combine disparate services into one tool. -**Correct:** `tmdb-cli` (TMDb only), `ghost-cli` (Ghost CMS only) +**Correct:** `tmdb` (TMDb only), `ghost` (Ghost CMS only) **Wrong:** `media-cli` (combines TMDb + Trakt + Radarr) Exception: services from the same vendor sharing auth (e.g. Radarr + Sonarr). @@ -484,6 +484,11 @@ The default for this repo is **`scripts/` inside the skill** — it follows the ## Agent-Readiness Checklist Use [the agent-readiness checklist](references/agent-readiness-checklist.md) before shipping a CLI. + +## When not to use + +Do not use this skill to design conversational agent tools or MCP servers — [references/mcp-vs-cli.md](references/mcp-vs-cli.md) carries that decision framework — and route general API design questions to [api-design-and-evolution](../api-design-and-evolution/SKILL.md). This skill also does not cover GUI, TUI, or web-app interface design. + ## References - [templates/bash-cli-scaffold.sh](templates/bash-cli-scaffold.sh) — Full bash project template with pre-wired global flags, logging helpers, and subcommand dispatch. Use as a starting point for any bash CLI. diff --git a/ghost-cli/README.md b/ghost-cli/README.md deleted file mode 100644 index 256d7b4..0000000 --- a/ghost-cli/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Ghost CMS from the Terminal - -Manage content on a Ghost CMS site: view site info, list and create posts and pages, manage tags — all via the Ghost Admin API (v5/v6). - -## Why Install This Skill - -When your agent loads this skill, it can **manage your Ghost CMS content** without the web editor. That means: - -- **List posts and pages** — by status (published, draft, scheduled) -- **Create content** — write and publish blog posts from the terminal -- **Manage tags** — list and browse tags -- **Check site info** — title, URL, description, version - -## What You Get - -| Directory | Purpose | -|-----------|---------| -| `SKILL.md` | Complete command reference with setup and examples | -| `scripts/ghost-cli` | CLI tool for Ghost Admin API operations | - -## Quick Start - -```bash -export GHOST_URL="https://your-ghost-site.com" -export GHOST_ADMIN_KEY="your-id:your-secret" -``` - -API key from Ghost Admin → Integrations → Create custom integration. - -## Triggers - -Load this when working with Ghost CMS — managing posts, pages, tags, or checking site configuration. - -## Requirements - -Python 3.8+ with `requests` library. diff --git a/ghost-cli/SKILL.md b/ghost-cli/SKILL.md deleted file mode 100644 index c36a7e9..0000000 --- a/ghost-cli/SKILL.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -name: ghost-cli -description: Manage Ghost CMS content from the terminal — create and list posts, pages, - and tags, and fetch site info via the Ghost Admin API (v5/v6). Use when the user - asks about ghost, cms, blog, blogging, posts, pages, tags, publishing, or site configuration. -license: MIT -compatibility: Requires GHOST_URL and GHOST_ADMIN_KEY env vars. Admin key in "id:secret" - format from Ghost Admin → Integrations. Python 3.8+ and the `requests` library. -metadata: - tags: ghost, cms, blog, blogging, post, page, tag, ghost-cms, content-management, - api-client - sources: https://ghost.org/docs/admin-api/, https://ghost.org/docs/ ---- - -# ghost-cli — Ghost CMS from the Terminal - -Manage content on a Ghost CMS site: view site info, list and create posts and pages, manage tags — all via the Ghost Admin API (v5/v6). - -## Setup - -1. Get your Admin API key from **Ghost Admin → Settings → Advanced → Integrations** (or **Ghost Admin → Integrations**). Create a custom integration to get a key in `id:secret` format. -2. Set these environment variables: - -```bash -export GHOST_URL="https://your-ghost-site.com" # your Ghost site URL -export GHOST_ADMIN_KEY="your-id:your-secret" # from Ghost Admin → Integrations -``` - -`--help` and `--dry-run` work without credentials (lazy auth). - -## Essential Commands - -### site — Get site information - -```bash -ghost-cli site # show site title, URL, description -ghost-cli --json site # machine-readable JSON -ghost-cli --dry-run site # preview without API call -``` - -Shows: site title, URL, description. - -### posts — List blog posts - -```bash -ghost-cli posts # 20 most recent posts -ghost-cli posts --limit 50 # more results -ghost-cli posts --status published # only published posts -ghost-cli posts --status draft # only draft posts -ghost-cli posts --status scheduled # only scheduled posts -ghost-cli posts --limit 10 --json # 10 most recent as JSON -``` - -Shows: title, status, slug, and last-updated date for each post. - -### create-post — Create a new blog post - -```bash -ghost-cli create-post --title "My First Post" # draft, no HTML -ghost-cli create-post --title "Hello World" --html "
Hello!
" # with HTML content -ghost-cli create-post --title "Ready" --html "Published
" --status published # publish immediately -ghost-cli create-post --title "Scheduled" --html "Later
" --status scheduled # schedule -ghost-cli create-post --title "Custom Slug" --slug "my-custom-url" # custom URL slug -ghost-cli create-post --title "Draft" --dry-run # preview without creating -``` - -Creates the post and returns its title, slug, and status. - -### pages — List pages - -```bash -ghost-cli pages # 20 most recent pages -ghost-cli pages --limit 50 # more results -ghost-cli pages --json # machine-readable JSON -``` - -Shows: title, status, slug, and last-updated date for each page. - -### tags — List tags - -```bash -ghost-cli tags # 50 tags with post counts -ghost-cli tags --limit 100 # more results -ghost-cli tags --json # machine-readable JSON -``` - -Shows: tag name, slug, and number of posts using each tag. - -## Global Flags - -These flags work anywhere in the command — before or after the subcommand: - -```bash -ghost-cli --json posts # JSON output -ghost-cli posts --json # same result, after subcommand -ghost-cli --dry-run create-post --title "Test" # preview without API call -ghost-cli --quiet posts # suppress diagnostic output -ghost-cli --verbose site # verbose logging -``` - -| Flag | Effect | -|------|--------| -| `--json` | Output machine-readable JSON instead of human-readable text | -| `--dry-run` | Show what API call would be made without executing it | -| `--quiet` | Suppress non-essential diagnostic output | -| `--verbose` | Enable verbose/debug logging | - -## Known Gotchas - -- **Admin API key format** — The `GHOST_ADMIN_KEY` must be in `id:secret` format (e.g. `644a4c1a2b3c4d5e6f7g8h9i:abcd1234efgh5678ijkl9012`). This is the format Ghost generates when you create a Custom Integration. A plain token or JWT will not work. -- **JWT token auto-generated** — The CLI generates a short-lived JWT (HS256, 5-minute expiry) internally from the Admin API key on each request. You don't need to create or manage JWT tokens yourself. -- **5-minute JWT window** — Each JWT is valid for 300 seconds (5 minutes). If your system clock is significantly skewed, requests may fail. Ensure NTP is synced. -- **API version v6** — The CLI sends `Accept-Version: v6.0` on all requests, targeting the Ghost Admin API v6. Response shapes follow the v6 spec. May also work against v5 sites. -- **HTML content format** — Post and page content must be provided as raw HTML strings via `--html`. Markdown is not auto-converted. If you write in Markdown, convert it to HTML first (e.g. with a markdown-to-html tool). -- **No update or delete commands** — The current CLI supports listing and creating posts/pages/tags, but does not include update or delete operations. Use the Ghost Admin UI or direct API calls for those. -- **No tag creation via CLI** — Tag listing works, but `create-tag` is not exposed as a subcommand. The GhostClient class has a `create_tag` method internally but it is not wired to a CLI command. -- **Rate limiting** — Ghost Admin API enforces rate limits. For heavy operations, stagger your requests. -- **Error output** — API errors (4xx/5xx) include the response body in the error message for debugging. Auth errors (401/403) explicitly tell you to check `GHOST_ADMIN_KEY`. - -## References - -- [scripts/ghost-cli](scripts/ghost-cli) — The CLI binary. Built following the cli-builder patterns: non-interactive, `--json`, `--dry-run`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth, structured logging. -- [Ghost Admin API Docs](https://ghost.org/docs/admin-api/) — Official Ghost Admin API documentation. -- [Ghost Integrations](https://ghost.org/docs/integrations/) — How to create Custom Integrations and get your Admin API key. diff --git a/ghost-cli/scripts/ghost-cli b/ghost-cli/scripts/ghost-cli deleted file mode 100755 index 04abb51..0000000 --- a/ghost-cli/scripts/ghost-cli +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env python3 -"""ghost-cli — Ghost CMS from the terminal. - -Manage content on a Ghost CMS site: create and edit posts and pages, -manage tags, and configure metadata. Requires GHOST_URL and GHOST_ADMIN_KEY. -""" - -import argparse -import base64 -import hashlib -import hmac -import json -import os -import sys -import time -import warnings -from typing import Any, Dict, List, Optional, Tuple - -warnings.simplefilter("ignore") - -import requests - -ENV_URL = os.getenv("GHOST_URL", "") -ENV_ADMIN_KEY = os.getenv("GHOST_ADMIN_KEY", "") - -QUIET = False -GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False} - - -def log(msg): - if not QUIET and not GLOBAL_FLAGS.get("json", False): - print(msg) - - -def warn(msg): - print(f"Warning: {msg}", file=sys.stderr) - - -def die(msg, exit_code=1): - print(f"Error: {msg}", file=sys.stderr) - sys.exit(exit_code) - - -def emit(human, data): - if GLOBAL_FLAGS.get("json", False): - print(json.dumps(data, default=str)) - else: - print(human) - - -def _preparse_global_flags(argv): - GLOBAL_BOOLS = {"--json", "--dry-run", "--quiet", "--verbose"} - flags, filtered = {}, [argv[0]] - i = 1 - while i < len(argv): - arg = argv[i] - if arg in GLOBAL_BOOLS: - flags[arg.lstrip("-").replace("-", "_")] = True - i += 1 - elif arg in ("--help", "-h"): - return flags, argv - elif arg == "--": - filtered.extend(argv[i:]) - break - else: - filtered.append(arg) - i += 1 - return flags, filtered - - -class GhostClient: - """Ghost Admin API client (v5/v6).""" - - def __init__(self, url="", key="", dry_run=False): - self.url = (url or ENV_URL).rstrip("/") - self.key = key or ENV_ADMIN_KEY - self.dry_run = dry_run - - def _jwt_token(self): - """Generate a short-lived JWT from the Admin API key (id:secret format).""" - if not self.key or ":" not in self.key: - die("GHOST_ADMIN_KEY must be in 'id:secret' format. Get it from Ghost Admin → Integrations.") - key_id, secret = self.key.split(":", 1) - now = int(time.time()) - header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "kid": key_id, "typ": "JWT"}).encode()).rstrip(b"=").decode() - payload = base64.urlsafe_b64encode(json.dumps({"iat": now, "exp": now + 300, "aud": "/admin/"}).encode()).rstrip(b"=").decode() - sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest() - sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode() - return f"{header}.{payload}.{sig_b64}" - - def _get(self, path, params=None): - url = f"{self.url}/ghost/api/admin{path}" - if self.dry_run: - return {"dry_run": True, "url": url, "params": params} - try: - resp = requests.get(url, params=params, - headers={"Authorization": f"Ghost {self._jwt_token()}", - "Accept-Version": "v6.0", "Accept": "application/json"}, - timeout=30) - except requests.ConnectionError as e: - die(f"Cannot connect to {self.url}: {e}") - if resp.status_code in (401, 403): - die(f"Auth failed ({resp.status_code}). Check GHOST_ADMIN_KEY.") - if resp.status_code >= 400: - try: - detail = resp.json() - except Exception: - detail = resp.text[:200] - die(f"API error ({resp.status_code}): {detail}") - return resp.json() - - def _post(self, path, json_data): - url = f"{self.url}/ghost/api/admin{path}" - if self.dry_run: - return {"dry_run": True, "url": url, "json": json_data} - try: - resp = requests.post(url, json=json_data, - headers={"Authorization": f"Ghost {self._jwt_token()}", - "Accept-Version": "v6.0", - "Content-Type": "application/json"}, - timeout=30) - except requests.ConnectionError as e: - die(f"Cannot connect: {e}") - if resp.status_code >= 400: - try: - detail = resp.json() - except Exception: - detail = resp.text[:200] - die(f"API error ({resp.status_code}): {detail}") - return resp.json() - - def _put(self, path, json_data): - url = f"{self.url}/ghost/api/admin{path}" - if self.dry_run: - return {"dry_run": True, "url": url, "json": json_data} - try: - resp = requests.put(url, json=json_data, - headers={"Authorization": f"Ghost {self._jwt_token()}", - "Accept-Version": "v6.0", - "Content-Type": "application/json"}, - timeout=30) - except requests.ConnectionError as e: - die(f"Cannot connect: {e}") - if resp.status_code >= 400: - try: - detail = resp.json() - except Exception: - detail = resp.text[:200] - die(f"API error ({resp.status_code}): {detail}") - return resp.json() - - def get_posts(self, limit=20, status=None): - params: Dict[str, Any] = {"limit": limit} - if status: - params["filter"] = f"status:{status}" - return self._get("/posts", params) - - def get_post(self, post_id): - return self._get(f"/posts/{post_id}") - - def create_post(self, title, html="", status="draft", slug=""): - post = {"title": title, "status": status} - if html: - post["html"] = html - if slug: - post["slug"] = slug - return self._post("/posts", {"posts": [post]}) - - def update_post(self, post_id, **kwargs): - return self._put(f"/posts/{post_id}", {"posts": [kwargs]}) - - def get_pages(self, limit=20): - return self._get("/pages", {"limit": limit}) - - def create_page(self, title, html="", status="draft", slug=""): - page = {"title": title, "status": status} - if html: - page["html"] = html - if slug: - page["slug"] = slug - return self._post("/pages", {"pages": [page]}) - - def get_tags(self, limit=50): - return self._get("/tags", {"limit": limit, "include": "count.posts"}) - - def create_tag(self, name, slug="", description=""): - tag = {"name": name} - if slug: - tag["slug"] = slug - if description: - tag["description"] = description - return self._post("/tags", {"tags": [tag]}) - - def get_site(self): - return self._get("/site") - - -def fmt_post(p): - title = p.get("title", "?") - status = p.get("status", "?") - slug = p.get("slug", "") - updated = (p.get("updated_at") or "")[:10] - return f" {title:45} [{status:7}] /{slug} updated {updated}" - - -def cmd_posts(client, args): - p = argparse.ArgumentParser(prog="ghost-cli posts") - p.add_argument("--limit", type=int, default=20) - p.add_argument("--status", choices=["published", "draft", "scheduled"]) - parsed, _ = p.parse_known_args(args) - - if client.dry_run: - return emit("[dry-run] List posts", {"dry_run": True}) - - data = client.get_posts(limit=parsed.limit, status=parsed.status) or {} - posts = data.get("posts", []) - if not posts: - return emit("No posts found.", {"posts": []}) - - lines = [fmt_post(p) for p in posts] - total = data.get("meta", {}).get("pagination", {}).get("total", len(posts)) - emit(f"{total} post(s):\n" + "\n".join(lines), - {"total": total, "posts": posts}) - - -def cmd_create_post(client, args): - p = argparse.ArgumentParser(prog="ghost-cli posts create") - p.add_argument("--title", required=True) - p.add_argument("--html", default="") - p.add_argument("--status", default="draft", choices=["draft", "published", "scheduled"]) - p.add_argument("--slug", default="") - parsed, _ = p.parse_known_args(args) - - if client.dry_run: - return emit(f"[dry-run] Create post: {parsed.title}", {"dry_run": True, **vars(parsed)}) - - data = client.create_post(parsed.title, html=parsed.html, status=parsed.status, slug=parsed.slug) or {} - posts = data.get("posts", []) - if posts: - post = posts[0] - emit(f"✅ Created: {post.get('title')} (/{post.get('slug')}) [{post.get('status')}]", - {"status": "created", "post": post}) - else: - emit("Post created but no data returned.", {"status": "created"}) - - -def cmd_pages(client, args): - p = argparse.ArgumentParser(prog="ghost-cli pages") - p.add_argument("--limit", type=int, default=20) - parsed, _ = p.parse_known_args(args) - - if client.dry_run: - return emit("[dry-run] List pages", {"dry_run": True}) - - data = client.get_pages(limit=parsed.limit) or {} - pages = data.get("pages", []) - if not pages: - return emit("No pages found.", {"pages": []}) - - lines = [fmt_post(p) for p in pages] - emit(f"{len(pages)} page(s):\n" + "\n".join(lines), {"pages": pages}) - - -def cmd_tags(client, args): - p = argparse.ArgumentParser(prog="ghost-cli tags") - p.add_argument("--limit", type=int, default=50) - parsed, _ = p.parse_known_args(args) - - if client.dry_run: - return emit("[dry-run] List tags", {"dry_run": True}) - - data = client.get_tags(limit=parsed.limit) or {} - tags = data.get("tags", []) - if not tags: - return emit("No tags found.", {"tags": []}) - - lines = [] - for t in tags: - name = t.get("name", "?") - slug = t.get("slug", "") - count = (t.get("count", {}) or {}).get("posts", 0) - lines.append(f" {name:25} /{slug} ({count} posts)") - emit(f"{len(tags)} tag(s):\n" + "\n".join(lines), {"tags": tags}) - - -def cmd_site(client, args): - if client.dry_run: - return emit("[dry-run] Get site info", {"dry_run": True}) - data = client.get_site() or {} - site = data.get("site", {}) - title = site.get("title", "?") - desc = site.get("description", "") - url = site.get("url", "?") - emit(f"🌐 {title}\n {url}\n {desc}", {"site": site}) - - -def main(): - global GLOBAL_FLAGS, QUIET - GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv) - if GLOBAL_FLAGS.get("quiet", False): - QUIET = True - if GLOBAL_FLAGS.get("json", False): - warnings.simplefilter("ignore") - - parser = argparse.ArgumentParser(prog="ghost-cli", description="Ghost CMS CLI.", - epilog="Set GHOST_URL and GHOST_ADMIN_KEY. Key format: id:secret from Integrations.") - sub = parser.add_subparsers(dest="command") - sub.add_parser("site", help="Site info") - - pp = sub.add_parser("posts", help="List posts") - pp.add_argument("--limit", type=int, default=20) - pp.add_argument("--status", choices=["published", "draft", "scheduled"]) - - cp = sub.add_parser("create-post", help="Create a post") - cp.add_argument("--title", required=True) - cp.add_argument("--html", default="") - cp.add_argument("--status", default="draft", choices=["draft", "published", "scheduled"]) - cp.add_argument("--slug", default="") - - sub.add_parser("pages", help="List pages").add_argument("--limit", type=int, default=20) - sub.add_parser("tags", help="List tags").add_argument("--limit", type=int, default=50) - - args = parser.parse_args(filtered_argv[1:]) - if not args.command: - parser.print_help() - sys.exit(1) - - client = GhostClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)) - - cmd_map = { - "site": cmd_site, "posts": cmd_posts, "create-post": cmd_create_post, - "pages": cmd_pages, "tags": cmd_tags, - } - handler = cmd_map.get(args.command) - if not handler: - parser.print_help() - sys.exit(1) - - remaining = filtered_argv[filtered_argv.index(args.command) + 1:] - handler(client, remaining) - - -if __name__ == "__main__": - main() diff --git a/ghost/README.md b/ghost/README.md new file mode 100644 index 0000000..7b48cdd --- /dev/null +++ b/ghost/README.md @@ -0,0 +1,52 @@ +# Ghost CMS content management from the terminal + +Let your agent browse, draft, publish, and schedule content on a Ghost blog or newsletter site through the Admin API — no web editor required. + +## Why Install This Skill + +Editing a Ghost site usually means clicking through the admin UI. This skill hands your agent direct, scripted control instead: + +- **See the whole editorial state** — published posts, plus the drafts and scheduled queue that public site feeds never show. +- **Publish programmatically** — create posts and pages as drafts, then flip them live after review, with safe collision-checked updates. +- **Schedule content** — stage posts to appear at future times. +- **Keep tags tidy** — list tags with usage counts, add new ones, drive exports. + +It speaks Ghost's exact authentication dialect automatically: your Admin API key (`id:secret`) becomes a fresh short-lived signed JWT for every command, so there are no tokens to mint, rotate, or paste anywhere. + +Not to be confused with Ghost's official npm `ghost-cli` tool, which installs and operates Ghost servers (`ghost install`, nginx/SSL setup, upgrades). This skill manages *content* on an already-running site; that one manages *servers*. + +## What You Get + +| Path | Purpose | +|------|---------| +| `SKILL.md` | Command reference: setup, browse/create/publish recipes, gotchas | +| `scripts/ghost` | CLI tool covering site info, post/page/tag operations, JSON + dry-run modes | +| `scripts/test_ghost.py` | Offline test suite for the CLI (all network mocked) | +| `references/admin-auth-and-basics.md` | Full JWT signing walkthrough with auth error signatures | +| `references/content-vs-admin-api.md` | Content vs Admin API choice guide and draft-visibility trap | +| `references/posts-pages-tags-endpoints.md` | Endpoint map, pagination loop patterns, error envelope | +| `references/worked-recipes.md` | Copy-paste workflows: draft→publish, exports, scheduling | +| `references/gotchas-field-guide.md` | Symptom-first troubleshooting for common failures | + +## Quick Start + +```bash +export GHOST_URL="https://your-ghost-site.com" +export GHOST_ADMIN_KEY="First!
" +``` + +Preview any write safely by adding `--dry-run` before the subcommand. + +## Triggers + +Load this skill when working with Ghost CMS content: listing posts/pages/tags, drafting or publishing blog content, scheduling posts, exporting site content, fixing Ghost API authentication errors, or investigating why drafts don't show up in a Ghost site feed. + +## Requirements + +- Python 3.8+ with the `requests` library. +- A running Ghost 5.x/6.x site where you can create a Custom Integration (Ghost Admin → Settings → Integrations). +- The integration's **Admin API Key** exported as `GHOST_ADMIN_KEY`, plus the site URL as `GHOST_URL`. Keep the key server-side; it signs mutations and must never ship in client code or CI logs. diff --git a/ghost/SKILL.md b/ghost/SKILL.md new file mode 100644 index 0000000..9767a77 --- /dev/null +++ b/ghost/SKILL.md @@ -0,0 +1,170 @@ +--- +name: ghost +description: Manage Ghost CMS content over the Admin API — browse posts, pages, + and tags, draft and publish content, schedule posts, and inspect site info from + the terminal. Do not use this skill for Ghost server installation or site + administration (installing, nginx, SSL, systemd, updates); those belong to the + official npm ghost-cli tooling. +license: MIT +compatibility: Requires GHOST_URL and GHOST_ADMIN_KEY env vars. Admin key in "id:secret" + format from Ghost Admin → Integrations. Python 3.8+ and the `requests` library. +metadata: + tags: ghost, cms, blog, blogging, post, page, tag, ghost-cms, content-management, + api-client + sources: https://docs.ghost.org/admin-api/, https://docs.ghost.org/content-api/ +--- + +# ghost — Ghost CMS content from the terminal + +Drive a Ghost CMS site's Admin API (v5/v6, `Accept-Version: v6.0`): list posts by status including drafts, create and publish pages and posts, manage tags, and check site info. Drafts, scheduled posts, and published content are all visible here because every call authenticates with a per-request Admin JWT built from your `id:secret` integration key. + +## Setup + +```bash +export GHOST_URL="https://your-ghost-site.com" +export GHOST_ADMIN_KEY="Hi
" +ghost create-post --title "Launch" --status published --html "We're live
" +ghost create-post --title "Later" --status scheduled \ + --published-at "2026-09-01T09:00:00.000Z" # future ISO-8601 required together +ghost create-page --title "About" --html "…
" --slug about +ghost create-tag --name "Engineering" --description "Technical posts" +``` + +### Edit and remove + +```bash +ghost update-post POST_ID --title "New title" \ + --updated-at "What shipped this week…
" > /tmp/draft.json + +post_id=$(jq -r '.post.id' /tmp/draft.json) + +# 2. Later: re-read to fetch the CURRENT updated_at (collision guard) +ghost get-post "$post_id" | grep updated_at + +# 3. Publish, passing the fresh timestamp +ghost update-post "$post_id" \ + --status published \ + --updated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" # NO — use the value from step 2 +``` + +The last line above is deliberately wrong: never synthesize `updated_at`. Copy the exact string from step 2's output; the server compares timestamps literally, and a mismatch means 409. + +Raw-API equivalent of step 3: + +```bash +curl -sS -X PUT "$BASE/posts/$POST_ID/" \ + -H "Authorization: Ghost $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept-Version: v6.0" \ + --data "{\"posts\":[{\"updated_at\":\"$UPDATED_AT\",\"status\":\"published\"}]}" +``` + +## Recipe 2: Review queue — everything unpublished + +Drafts, scheduled, and published live on different filters; one call per status: + +```bash +ghost posts --status draft --limit 50 --json | jq -r '.posts[] | "\(.title)\t\(.slug)"' +ghost posts --status scheduled --json | jq -r '.posts[] | "\(.title)\t\(.published_at // "-")"' +``` + +Pair it with an authoring-wide sanity check via jq types before feeding slugs onward: + +```bash +ghost posts --status draft --json | jq '{count: (.posts|length), all_slugs_strings: ([.posts[].slug | type] | all(. == "string"))}' +``` + +## Recipe 3: Full export, page by page + +Ghost 6 caps pages at 100 rows; `limit=all` is gone. + +```bash +page=1 +while :; do + ghost posts --limit 100 --page "$page" --json > "/tmp/posts-$page.json" + next=$(jq -r '.page.next // empty' "/tmp/posts-$page.json") + jq -r '.posts[].id' "/tmp/posts-$page.json" + [ -z "$next" ] && break + page=$next + sleep 0.2 +done +``` + +Note the loop reads `meta.pagination.next` from the CLI's `.page` field rather than computing `(page * limit) < total`; totals shift under concurrent edits. + +## Recipe 4: Publish at a future time (scheduling) + +```bash +ghost create-post --title "Launch day" \ + --html "We're live.
" \ + --status scheduled \ + --published-at "2026-09-01T09:00:00.000Z" +``` + +Requirements: the timestamp must be in the future and ISO 8601; Ghost processes the queue on its own schedule (typically within five minutes of the mark). The post remains visible as `scheduled` in the Admin plane, invisible publicly until flip time. CLI enforces the pairing (`--status scheduled` without `--published-at` errors before any request is made). + +## Recipe 5: Slug-based handoff between systems + +External systems key content by slug. Resolve slug → id → full record: + +```bash +curl -sS "$BASE/posts/slug/$SLUG/" \ + -H "Authorization: Ghost $TOKEN" \ + -H "Accept-Version: v6.0" | jq -r '.posts[0].id' +``` + +Then read or edit by that id. The CLI reads by id (`ghost get-postHi
") + self.assertEqual(result.returncode, 0) + payload = json.loads(result.stdout) + self.assertEqual(payload["params"], {"source": "html"}) + self.assertIn("html", payload["json"]["posts"][0]) + + no_html = self.run_cli("--dry-run", "--json", "create-post", + "--title", "Doc") + self.assertEqual(no_html.returncode, 0) + no_html_payload = json.loads(no_html.stdout) + self.assertNotIn("html", no_html_payload["json"]["posts"][0]) + self.assertNotIn("source=html", no_html_payload["url"]) + self.assertIsNone(no_html_payload["params"]) + + def test_update_post_dry_run_sends_source_html_only_with_html_payload(self): + result = self.run_cli("--dry-run", "--json", "update-post", "abc123", + "--html", "Edited
", + "--updated-at", "2026-08-26T12:00:00.000Z") + self.assertEqual(result.returncode, 0) + payload = json.loads(result.stdout) + self.assertEqual(payload["params"], {"source": "html"}) + self.assertIn("html", payload["fields"]) + + no_html = self.run_cli("--dry-run", "--json", "update-post", "abc123", + "--status", "draft", + "--updated-at", "2026-08-26T12:00:00.000Z") + self.assertEqual(no_html.returncode, 0) + no_html_payload = json.loads(no_html.stdout) + self.assertNotIn("html", no_html_payload["fields"]) + self.assertNotIn("source=html", no_html_payload["url"]) + self.assertIsNone(no_html_payload["params"]) + + def test_create_page_dry_run_sends_source_html_only_with_html_payload(self): + result = self.run_cli("--dry-run", "--json", "create-page", + "--title", "About", "--html", "About us
") + self.assertEqual(result.returncode, 0) + payload = json.loads(result.stdout) + self.assertEqual(payload["params"], {"source": "html"}) + self.assertIn("html", payload["json"]["pages"][0]) + + no_html = self.run_cli("--dry-run", "--json", "create-page", + "--title", "About") + self.assertEqual(no_html.returncode, 0) + no_html_payload = json.loads(no_html.stdout) + self.assertNotIn("html", no_html_payload["json"]["pages"][0]) + self.assertNotIn("source=html", no_html_payload["url"]) + self.assertIsNone(no_html_payload["params"]) + + def test_scheduled_post_requires_published_at_even_in_dry_run(self): + result = self.run_cli("--dry-run", "--json", "create-post", + "--title", "Later", "--status", "scheduled") + self.assertNotEqual(result.returncode, 0) + self.assertIn("--published-at", result.stderr) + + +class JwtSigningTests(unittest.TestCase): + """Known-answer JWT checks against fixed inputs (no network, no real keys).""" + + def setUp(self): + self.cli = load_cli() + + def sign_with_fixed_clock(self, key=FIXED_KEY): + original_time = self.cli.time.time + self.cli.time.time = lambda: 1700000000 + try: + client = self.cli.GhostClient(url="https://example.com", key=key) + token = client._jwt_token() + finally: + self.cli.time.time = original_time + return token + + @staticmethod + def decode_segment(segment): + import base64 + padded = segment + "=" * (-len(segment) % 4) + return json.loads(base64.urlsafe_b64decode(padded)) + + def test_jwt_matches_known_answer_token_exactly(self): + token = self.sign_with_fixed_clock() + self.assertEqual(token, f"{HEADER_B64}.{PAYLOAD_B64}.{SIG_B64}") + + def test_header_uses_hs256_kid_and_typ(self): + header = self.decode_segment(self.sign_with_fixed_clock().split(".")[0]) + self.assertEqual(header["alg"], "HS256") + self.assertEqual(header["typ"], "JWT") + self.assertEqual(header["kid"], KID) + + def test_payload_audience_and_five_minute_expiry(self): + payload = self.decode_segment(self.sign_with_fixed_clock().split(".")[1]) + self.assertEqual(payload["aud"], "/admin/") + self.assertEqual(payload["exp"] - payload["iat"], TOKEN_TTL) + self.assertEqual(payload["iat"], 1700000000) + + def test_signature_keys_hex_decoded_secret_not_literal_chars(self): + import base64 as b64 + import hashlib as hl + import hmac as hm + header_b64, payload_b64, sig_b64 = self.sign_with_fixed_clock().split(".") + expected = b64.urlsafe_b64encode( + hm.new(bytes.fromhex(SECRET_HEX), f"{header_b64}.{payload_b64}".encode(), hl.sha256).digest() + ).rstrip(b"=").decode() + self.assertEqual(sig_b64, expected) + literal_hex_signature = b64.urlsafe_b64encode( + hm.new(SECRET_HEX.encode(), f"{header_b64}.{payload_b64}".encode(), hl.sha256).digest() + ).rstrip(b"=").decode() + self.assertNotEqual(sig_b64, literal_hex_signature) + + def test_malformed_secret_half_is_graceful_error_not_traceback(self): + client = self.cli.GhostClient(url="https://example.com", key="5f9d4b1c8e2a43d7b6c0a1e9:not-hex!") + stderr = io.StringIO() + with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): + client._jwt_token() + self.assertIn("hexadecimal", stderr.getvalue()) + + def test_key_without_colon_names_required_format(self): + client = self.cli.GhostClient(url="https://example.com", key="justonepart") + stderr = io.StringIO() + with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): + client._jwt_token() + self.assertIn("id:secret", stderr.getvalue()) + + +class AdminApiAudienceTests(unittest.TestCase): + cli = load_cli() + + def test_unversioned_admin_path_uses_root_admin_audience(self): + self.assertEqual(self.cli.admin_api_audience("/ghost/api/admin/posts/"), "/admin/") + self.assertEqual(self.cli.admin_api_audience("/ghost/api/admin/"), "/admin/") + self.assertEqual(self.cli.admin_api_audience("nonsense"), "/admin/") + + def test_legacy_versioned_paths_scope_the_audience(self): + self.assertEqual(self.cli.admin_api_audience("/ghost/api/v3/admin/posts/"), "/v3/admin/") + self.assertEqual(self.cli.admin_api_audience("/ghost/api/v4/admin/"), "/v4/admin/") + + +class PipelineChainTests(unittest.TestCase): + """Per-skill contract: documented multi-step pipelines must execute stage by + stage, with each stage consuming the previous stage's emitted output.""" + + @classmethod + def setUpClass(cls): + cls.tmpdir = tempfile.TemporaryDirectory(prefix="ghost-pipeline-") + + @classmethod + def tearDownClass(cls): + cls.tmpdir.cleanup() + + def run_cli(self, *args, creds=False): + env = clean_env() + if creds: + env["GHOST_URL"] = "https://example.com" + env["GHOST_ADMIN_KEY"] = FIXED_KEY + return subprocess.run([str(SCRIPT), *args], text=True, + capture_output=True, env=env, + cwd=self.tmpdir.name) + + def run_jq(self, *jq_args): + return subprocess.run(["jq", *jq_args], + text=True, capture_output=True, env=clean_env(), + cwd=self.tmpdir.name) + + def write_stage_file(self, name, document): + path = Path(self.tmpdir.name) / name + path.write_text(json.dumps(document)) + return path.name + + def test_draft_then_publish_then_delete_chain_consumability(self): + post_id = "624c2b3fc1a5b7e9d4a0f2aa" + + # Stage 1: mint the draft plan; jq extracts method + URL + title field. + r1 = self.run_cli("--dry-run", "--json", "create-post", "--title", "Chain Post") + self.assertEqual(r1.returncode, 0) + stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout)) + check = self.run_jq("-r", ".method | select(. == \"post\") // empty", stage1) + self.assertEqual(check.stdout.strip(), "post") + url = self.run_jq("-r", ".url", stage1).stdout.strip() + self.assertIn("/ghost/api/admin/posts", url) + + # Stage 2: publish plan consumes a hand-built id + updated_at guard; + # jq asserts the collision-guard field travels into the request body. + r2 = self.run_cli("--dry-run", "--json", "update-post", post_id, + "--status", "published", + "--updated-at", "2026-08-26T12:00:00.000Z") + self.assertEqual(r2.returncode, 0) + stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout)) + guarded_at = self.run_jq("-r", ".fields.updated_at", stage2).stdout.strip() + self.assertRegex(guarded_at, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") + body_type = self.run_jq("-r", '.fields.status | select(. == "published") // empty', stage2) + self.assertEqual(body_type.stdout.strip(), "published") + + # Stage 3: teardown plan for the same post id consumed from stage 2's + # positional argument (verbatim string, not re-typed). + r3 = self.run_cli("--dry-run", "--json", "delete-post", post_id) + self.assertEqual(r3.returncode, 0) + stage3 = self.write_stage_file("stage3.json", json.loads(r3.stdout)) + self.assertEqual(self.run_jq("-r", ".method", stage3).stdout.strip(), "delete") + + def test_list_to_get_post_chain_type_contract(self): + row = {"title": "Hello World", "slug": "hello-world", "id": "abc123"} + + # Stage 1: a listing document (as ghost posts --json would produce); + # jq extracts .posts[0].id and asserts its JSON type is string. + listing_name = self.write_stage_file( + "listing.json", + {"total": 1, "page": {}, "posts": [row]}) + extracted = self.run_jq("-r", ".posts[0].id", listing_name) + self.assertEqual(extracted.returncode, 0, extracted.stderr) + self.assertEqual(extracted.stdout.strip(), row["id"]) + type_check = self.run_jq("-r", ".posts[0].id | type", listing_name) + self.assertEqual(type_check.stdout.strip(), "string") + slug_check = self.run_jq("-r", ".posts[0].slug | type", listing_name) + self.assertEqual(slug_check.stdout.strip(), "string") + + # Stage 2: get-post plan consumes exactly that id string positionally. + r2 = self.run_cli("--dry-run", "--json", "get-post", extracted.stdout.strip(), creds=True) + self.assertEqual(r2.returncode, 0) + plan = json.loads(r2.stdout) + self.assertTrue(plan["dry_run"]) + self.assertTrue(str(row["id"]) in plan["url"], plan["url"]) + self.assertTrue(plan["url"].startswith("https://")) + + +class MockedClientTests(unittest.TestCase): + """In-process handler logic with requests mocked at the call site.""" + + def load_with_flags(self, json_mode=True): + cli = load_cli() + cli.GLOBAL_FLAGS = {"json": json_mode, "dry_run": False, + "quiet": False, "verbose": False} + return cli + + def test_cmd_posts_parses_envelope_and_pagination_totals(self): + cli = self.load_with_flags() + client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) + client.get_posts = Mock(return_value={ + "posts": [ + {"title": "First", "slug": "first", "status": "draft"}, + {"title": "Second", "slug": "second", "status": "published"}, + ], + "meta": {"pagination": {"page": 2, "limit": 20, "pages": 7, + "total": 124, "next": 3, "prev": 1}}, + }) + captured = [] + + def capture_print(*args): + captured.append(args) + + with patch("builtins.print", capture_print): + cli.cmd_posts(client, ["--limit", "20"]) + client.get_posts.assert_called_once_with(limit=20, status=None, page=None, order=None) + self.assertEqual(len(captured), 1, captured) + emitted = captured[0][0] # emit() prints one argument in json mode + payload = json.loads(emitted) + self.assertEqual(payload["total"], 124) + self.assertEqual(len(payload["posts"]), 2) + self.assertEqual(payload["page"]["pages"], 7) + + def test_client_get_posts_builds_status_filter_param(self): + cli = self.load_with_flags() + client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) + seen = {} + + def fake_get(path, params=None): + seen["path"] = path + seen["params"] = params + return {"posts": [], "meta": {"pagination": {}}} + + client._get = fake_get + data = client.get_posts(limit=50, status="draft") + self.assertEqual(data["posts"], []) + self.assertEqual(seen["path"], "/posts") + self.assertEqual(seen["params"]["filter"], "status:draft") + self.assertEqual(seen["params"]["limit"], 50) + + def test_delete_post_tolerates_204_empty_body(self): + cli = self.load_with_flags(json_mode=False) + ok_empty = Mock(status_code=204) + del ok_empty.json # a real 204 carries no JSON body at all + cli.requests.delete = Mock(return_value=ok_empty) + client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) + captured = [] + + with patch("builtins.print", lambda *a, **k: captured.append(a)): + cli.cmd_delete_post(client, ["abc123"]) + + cli.requests.delete.assert_called_once() + called_url = cli.requests.delete.call_args.args[0] + self.assertEqual(called_url, "https://example.com/ghost/api/admin/posts/abc123") + auth_header = cli.requests.delete.call_args.kwargs["headers"]["Authorization"] + self.assertTrue(auth_header.startswith("Ghost eyJ")) + + def test_update_collision_error_message_advises_reget(self): + cli = self.load_with_flags() + collision = Mock(status_code=409, text="conflict") + collision.json = Mock(return_value={ + "errors": [{"message": "Saving failed! Someone else is editing this post.", + "type": "UpdateCollisionError", "code": "UPDATE_COLLISION"}], + }) + cli.requests.put = Mock(return_value=collision) + client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) + stderr = io.StringIO() + with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): + client.update_post("abc123", status="published", + updated_at="2026-01-01T00:00:00.000Z") + message = stderr.getvalue() + self.assertIn("409", message) + self.assertIn("Someone else is editing this post", message) + self.assertIn("Re-GET", message) + + def test_draft_read_on_content_api_style_404_routes_to_admin_guidance(self): + cli = self.load_with_flags() + missing = Mock(status_code=404, text="not found") + missing.json = Mock(return_value={ + "errors": [{"message": "Resource not found error.", + "type": "NotFoundError", "code": None}]}) + cli.requests.get = Mock(return_value=missing) + client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) + stderr = io.StringIO() + with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): + client._get("/posts/does-not-exist") + message = stderr.getvalue() + self.assertIn("404", message) + self.assertIn("Admin API", message) + + def test_auth_header_scheme_mistake_surfaces_ghost_scheme_hint(self): + cli = self.load_with_flags() + bad_scheme = Mock(status_code=401) + bad_scheme.text = ('{"errors":[{"message":"Authorization header format is ' + '"Authorization: Ghost [token]","context":null,' + '"type":"UnauthorizedError","code":"INVALID_AUTH_HEADER"}]}') + bad_scheme.json = Mock(return_value={"errors": [{ + "message": "Authorization header format is \"Authorization: Ghost [token]\"", + "type": "UnauthorizedError", "code": "INVALID_AUTH_HEADER"}]}) + cli.requests.get = Mock(return_value=bad_scheme) + client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) + stderr = io.StringIO() + with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): + client._get("/posts") + self.assertIn("Ghost [token]", stderr.getvalue()) + + def test_authorization_header_uses_ghost_scheme_and_version_headers(self): + cli = self.load_with_flags() + ok = Mock(status_code=200) + ok.json = Mock(return_value={"posts": []}) + cli.requests.get = Mock(return_value=ok) + client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) + client._get("/posts") + headers = cli.requests.get.call_args.kwargs["headers"] + self.assertTrue(headers["Authorization"].startswith("Ghost ")) + self.assertEqual(headers["Accept-Version"], "v6.0") + + def test_request_paths_target_unversioned_admin_api(self): + cli = self.load_with_flags() + ok = Mock(status_code=200) + ok.json = Mock(return_value={}) + cli.requests.get = Mock(return_value=ok) + client = cli.GhostClient(url="https://example.com/", key=FIXED_KEY) + client._get("/site") + called_url = cli.requests.get.call_args.args[0] + self.assertEqual(called_url, "https://example.com/ghost/api/admin/site") + + +class HtmlSourceFlagTests(unittest.TestCase): + """Regression: html write payloads must carry the docs-required + ?source=html query flag; mobiledoc/lexical writes must not send it.""" + + def setUp(self): + self.cli = load_cli() + self.cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, + "quiet": False, "verbose": False} + ok = Mock(status_code=201) + ok.json = Mock(return_value={"posts": []}) + self.cli.requests.post = Mock(return_value=ok) + self.client = self.cli.GhostClient(url="https://example.com", key=FIXED_KEY) + + def _put_ok(self): + ok = Mock(status_code=200) + ok.json = Mock(return_value={"posts": []}) + self.cli.requests.put = Mock(return_value=ok) + return self.cli.requests.put + + def test_create_post_with_html_sends_source_html_query_param(self): + self.client.create_post("Doc", html="Hi
") + call = self.cli.requests.post.call_args + self.assertEqual(call.kwargs["params"], {"source": "html"}) + self.assertIn("html", call.kwargs["json"]["posts"][0]) + + def test_create_post_without_html_omits_source_flag(self): + self.client.create_post("Doc") + call = self.cli.requests.post.call_args + self.assertNotIn("source", (call.kwargs.get("params") or {})) + self.assertNotIn("html", call.kwargs["json"]["posts"][0]) + + def test_update_post_with_html_sends_source_html_query_param(self): + put = self._put_ok() + self.client.update_post("abc123", html="Edited
", + updated_at="2026-08-26T12:00:00.000Z") + call = put.call_args + self.assertEqual(call.kwargs["params"], {"source": "html"}) + self.assertIn("html", call.kwargs["json"]["posts"][0]) + + def test_update_post_without_html_omits_source_flag(self): + put = self._put_ok() + self.client.update_post("abc123", status="published", + updated_at="2026-08-26T12:00:00.000Z") + call = put.call_args + self.assertNotIn("source", (call.kwargs.get("params") or {})) + self.assertNotIn("html", call.kwargs["json"]["posts"][0]) + + def test_create_page_with_html_sends_source_html_query_param(self): + self.client.create_page("About", html="About us
") + call = self.cli.requests.post.call_args + self.assertEqual(call.kwargs["params"], {"source": "html"}) + self.assertEqual(call.args[0], "https://example.com/ghost/api/admin/pages") + self.assertIn("html", call.kwargs["json"]["pages"][0]) + + def test_create_page_without_html_omits_source_flag(self): + self.client.create_page("About") + call = self.cli.requests.post.call_args + self.assertNotIn("source", (call.kwargs.get("params") or {})) + self.assertNotIn("html", call.kwargs["json"]["pages"][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/jellyfin-cli/README.md b/jellyfin-cli/README.md deleted file mode 100644 index 59df69b..0000000 --- a/jellyfin-cli/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Jellyfin Media Server from the Terminal - -Query your Jellyfin media library — recently added movies and episodes, search and inspect items, browse library contents, see next-up episodes, and check server stats. - -## Why Install This Skill - -When your agent loads this skill, it can **navigate your home media server** without opening a browser. That means: - -- **See what's new** — recently added movies and TV episodes -- **Search your library** — find any movie, show, or episode by keyword -- **Navigate your library** — inspect search results, browse collections, and page through items -- **See what is next** — find the next unwatched episodes for a user -- **Check server details** — server name, version, operating system, user count - -## What You Get - -| Directory | Purpose | -|-----------|---------| -| `SKILL.md` | Complete command reference with setup and examples | -| `scripts/jellyfin-cli` | CLI tool for Jellyfin API operations | - -## Quick Start - -```bash -scripts/jellyfin-cli --help -export JELLYFIN_URL="http://your-server:8096" -export JELLYFIN_API_KEY="your-api-key" -export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by recent, next-up, and item -``` - -API key from Dashboard → API Keys in the Jellyfin admin panel. - -```bash -scripts/jellyfin-cli search --query "dune" --type Movie -scripts/jellyfin-cli next-up --limit 5 -``` - -## Triggers - -Load this when asking about Jellyfin, media server content, recently added movies or TV, or browsing your home media library. - -## Requirements - -Python 3.8+ with `requests` library. Jellyfin server with API key; `recent`, `next-up`, and `item` also require a Jellyfin user ID. diff --git a/jellyfin-cli/SKILL.md b/jellyfin-cli/SKILL.md deleted file mode 100644 index 0c2da70..0000000 --- a/jellyfin-cli/SKILL.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -name: jellyfin-cli -description: Query your Jellyfin media server from the terminal — recently added media, - search, item details, next-up episodes, library browsing, server info, and stats. Use - when the user asks about Jellyfin, media server, movies, TV shows, next episodes, or - their media library. -license: MIT -compatibility: Requires JELLYFIN_URL (default http://localhost:8096) and JELLYFIN_API_KEY - env vars; `recent`, `next-up`, and `item` also require JELLYFIN_USER_ID or --user-id. - Python 3.8+ and the `requests` library. Generate an API key at Dashboard → API Keys in the Jellyfin admin panel. -metadata: - tags: jellyfin, media-server, movies, tv, episodes, recently-added, library, home-media, - api-client - sources: https://jellyfin.org/docs/general/clients/api, https://jellyfin.org/downloads ---- - -# jellyfin-cli — Jellyfin Media Server from the Terminal - -Query recently added movies and TV episodes, search and inspect media, browse libraries, see next-up episodes, check server info, and view library statistics — all from your Jellyfin server's REST API. - -## Setup - -1. Make sure your Jellyfin server is running and accessible. -2. Generate an API key in the Jellyfin Dashboard → **API Keys** → `+` to create a new key. -3. Set these environment variables: - -```bash -export JELLYFIN_URL="http://your-server:8096" # include protocol and port -export JELLYFIN_API_KEY="your-api-key-here" -export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by recent, next-up, and item -``` - -Run the bundled CLI as `scripts/jellyfin-cli`. `--help` and `--dry-run` work without credentials. - -## Essential Commands - -### info — Server information - -```bash -scripts/jellyfin-cli info # server name, version, OS, user count -scripts/jellyfin-cli info --json # machine-readable -scripts/jellyfin-cli --dry-run info # preview API requests -``` - -Shows: server name, version, operating system, number of users. - -### recent — Recently added media - -```bash -scripts/jellyfin-cli recent # last 10 items added -scripts/jellyfin-cli recent --limit 20 # more results -scripts/jellyfin-cli recent --movies # only recently added movies -scripts/jellyfin-cli recent --episodes # only recently added episodes -scripts/jellyfin-cli recent --user-id USER_ID # override JELLYFIN_USER_ID -scripts/jellyfin-cli recent --movies --limit 5 # top 5 recently added movies -scripts/jellyfin-cli recent --json # machine-readable -``` - -Uses Jellyfin's current `/Items/Latest` endpoint. `--movies` and `--episodes` send `includeItemTypes` to the server, so the requested limit applies to the selected media type. Shows: name, type (Movie/Episode), production year, series name (for episodes), date added. - -### search — Search your media library - -```bash -scripts/jellyfin-cli search --query "dune" # search everything -scripts/jellyfin-cli search --query "dune" --type Movie # movies only -scripts/jellyfin-cli search --query "star trek" --type Series,Episode -scripts/jellyfin-cli search --query "inception" --limit 5 # top 5 results -scripts/jellyfin-cli search --query "dune" --json # machine-readable -``` - -The `--type` flag accepts a comma-separated list of item types (e.g. `Movie,Series,Episode`). - -### Navigation — Inspect media and browse libraries - -```bash -scripts/jellyfin-cli search --query "dune" --type Movie # find an item ID -scripts/jellyfin-cli item --id ITEM_ID # inspect that item -scripts/jellyfin-cli libraries # find a library ID -scripts/jellyfin-cli browse --library-id LIBRARY_ID --type Movie --limit 20 -scripts/jellyfin-cli browse --library-id LIBRARY_ID --start-index 20 -scripts/jellyfin-cli next-up --limit 10 # next episodes for JELLYFIN_USER_ID -scripts/jellyfin-cli next-up --user-id USER_ID --json -``` - -Use `search -> item` to look up a result's metadata, and `libraries -> browse` to page through a collection. `next-up` returns the next unwatched episodes for the selected user. `item` and `next-up` require `JELLYFIN_USER_ID` or `--user-id`; all three commands are read-only. - -### libraries — List media libraries - -```bash -scripts/jellyfin-cli libraries # all configured libraries -scripts/jellyfin-cli libraries --json # machine-readable -``` - -Shows: library name, collection type (movies, tvshows, music, etc.), library ID. - -### stats — Library statistics - -```bash -scripts/jellyfin-cli stats # movie, series, episode, song counts -scripts/jellyfin-cli stats --json # machine-readable -``` - -Shows: total count of movies, series, episodes, and songs in the library. - -## Global Flags - -These flags work anywhere in the command — before or after the subcommand: - -```bash -scripts/jellyfin-cli --json recent --limit 5 # JSON output -scripts/jellyfin-cli recent --limit 5 --json # same result, after subcommand -scripts/jellyfin-cli --dry-run search --query "dune" # preview request without API call -``` - -| Flag | Effect | -|------|--------| -| `--json` | Output machine-readable JSON instead of human-readable text | -| `--dry-run` | Show each request path and parameters without executing it | - -## Known Gotchas - -- **JELLYFIN_URL must include protocol and port** — Both are required, e.g. `http://192.168.1.100:8096`. A bare hostname or IP without `http://` and `:8096` will fail. The default is `http://localhost:8096`. -- **User-scoped commands require an explicit user** — Set `JELLYFIN_USER_ID` or pass `--user-id USER_ID` to `recent`, `next-up`, or `item`. The CLI never selects an administrator automatically. A real request without either value fails before network access; dry-run previews the request with a null user ID. -- **Recent type filtering is server-side** — `--movies` and `--episodes` become the `/Items/Latest` `includeItemTypes` parameter before `limit`; no local filtering is applied. -- **Search type values** — The `--type` flag for `search` uses Jellyfin item type names (e.g. `Movie`, `Series`, `Episode`, `MusicArtist`, `MusicAlbum`). Multiple types are comma-separated without spaces. -- **API key location** — Generate the key in the Jellyfin Dashboard under **Dashboard → API Keys**. The key is sent as the `X-Emby-Token` header. -- **Lazy auth** — `--help` and `--dry-run` work even when `JELLYFIN_URL` and `JELLYFIN_API_KEY` are not set. Dry-run reports request paths and parameters but never sends credentials or makes a network call. -- **No pagination** — Every command returns a single page of results. The CLI does not auto-paginate beyond the first response. Use `--limit` to control result size. - -## References - -- [scripts/jellyfin-cli](scripts/jellyfin-cli) — The bundled read-only CLI binary with `--json`, `--dry-run`, and lazy authentication. -- [Jellyfin API Docs](https://jellyfin.org/docs/general/clients/api) — Official API documentation. -- [Jellyfin Downloads](https://jellyfin.org/downloads) — Server download and setup guide. diff --git a/jellyfin-cli/scripts/jellyfin-cli b/jellyfin-cli/scripts/jellyfin-cli deleted file mode 100755 index 4778db8..0000000 --- a/jellyfin-cli/scripts/jellyfin-cli +++ /dev/null @@ -1,420 +0,0 @@ -#!/usr/bin/env python3 -"""jellyfin-cli — Jellyfin media server from the terminal. - -Query recently added media, search your library, browse by collection, -and check server status. Requires JELLYFIN_URL and JELLYFIN_API_KEY. -""" - -import argparse -import json -import os -import sys -import warnings -from typing import Any, Dict - -warnings.simplefilter("ignore") - -import requests - -DEFAULT_SERVER = "http://localhost:8096" -ENV_URL = os.getenv("JELLYFIN_URL", DEFAULT_SERVER) -ENV_KEY = os.getenv("JELLYFIN_API_KEY", "") -ENV_USER_ID = os.getenv("JELLYFIN_USER_ID", "") - -GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False} - - -def die(msg, exit_code=1): - print(f"Error: {msg}", file=sys.stderr) - sys.exit(exit_code) - - -def emit(human, data): - if GLOBAL_FLAGS.get("json", False): - print(json.dumps(data, default=str)) - else: - print(human) - - -def _preparse_global_flags(argv): - GLOBAL_BOOLS = {"--json", "--dry-run"} - flags, filtered = {}, [argv[0]] - i = 1 - while i < len(argv): - arg = argv[i] - if arg in GLOBAL_BOOLS: - flags[arg.lstrip("-").replace("-", "_")] = True - i += 1 - elif arg in ("--help", "-h"): - return flags, argv - elif arg == "--": - filtered.extend(argv[i:]) - break - else: - filtered.append(arg) - i += 1 - return flags, filtered - - -class JellyfinClient: - """Jellyfin API client (v10.8+ compatible).""" - - def __init__(self, url="", key="", dry_run=False): - self.url = (url or ENV_URL).rstrip("/") - self.key = key or ENV_KEY - self.dry_run = dry_run - - def _get(self, path, params=None): - url = f"{self.url}{path}" - if self.dry_run: - return {"dry_run": True, "url": url, "params": params} - if not self.key: - die("JELLYFIN_API_KEY not set. Generate one in Dashboard → API Keys.") - try: - resp = requests.get(url, params=params, - headers={"X-Emby-Token": self.key, "Accept": "application/json"}, - timeout=30) - except requests.ConnectionError as e: - die(f"Cannot connect to {self.url}: {e}") - if resp.status_code == 401: - die("Auth failed (401). Check JELLYFIN_API_KEY.") - if resp.status_code >= 400: - try: - detail = resp.json() - except Exception: - detail = resp.text[:200] - die(f"API error ({resp.status_code}): {detail}") - return resp.json() - - def get_info(self): - return self._get("/System/Info") - - def get_users(self): - return self._get("/Users") - - def get_recent(self, user_id, limit=10, include_types=None): - params = {"userId": user_id, "fields": "DateCreated"} - if include_types: - params["includeItemTypes"] = ",".join(include_types) - params["limit"] = limit - return self._get("/Items/Latest", params=params) - - def get_next_up(self, user_id, limit=10): - return self._get(f"/Shows/NextUp", - params={"userId": user_id, "limit": limit}) - - def search(self, query, limit=20, include_types=None): - params = {"searchTerm": query, "limit": limit, "recursive": True} - if include_types: - params["includeItemTypes"] = ",".join(include_types) - return self._get("/Search/Hints", params=params) - - def get_libraries(self): - return self._get("/Library/MediaFolders") - - def get_items(self, parent_id, types=None, limit=50, sort_by="SortName", - sort_order="Ascending", start_index=0): - params = {"parentId": parent_id, "limit": limit, - "sortBy": sort_by, "sortOrder": sort_order, - "startIndex": start_index, "recursive": True} - if types: - params["includeItemTypes"] = ",".join(types) - return self._get("/Items", params=params) - - def get_item(self, item_id, user_id): - return self._get(f"/Items/{item_id}", params={"userId": user_id}) - - def get_seasons(self, series_id, user_id): - return self._get(f"/Shows/{series_id}/Seasons", params={"userId": user_id}) - - def get_episodes(self, series_id, season_id, user_id): - return self._get(f"/Shows/{series_id}/Episodes", - params={"seasonId": season_id, "userId": user_id}) - - def get_stats(self): - return self._get("/Items/Counts") - - -def fmt_date(ts): - if not ts: - return "?" - return ts[:10] if len(ts) > 10 else ts - - -def normalize_item(item): - """Return available Jellyfin item metadata with stable CLI field names.""" - fields = { - "id": item.get("Id"), - "name": item.get("Name"), - "type": item.get("Type"), - "year": item.get("ProductionYear"), - "series": item.get("SeriesName"), - "season_number": item.get("ParentIndexNumber"), - "episode_number": item.get("IndexNumber"), - "overview": item.get("Overview"), - "date_added": fmt_date(item["DateCreated"]) if item.get("DateCreated") else None, - "community_rating": item.get("CommunityRating"), - "official_rating": item.get("OfficialRating"), - "runtime_ticks": item.get("RunTimeTicks"), - } - return {key: value for key, value in fields.items() if value is not None and value != ""} - - -def cmd_info(client, args): - if client.dry_run: - return emit("[dry-run] GET /System/Info; GET /Users", { - "dry_run": True, - "requests": [ - {"path": "/System/Info", "params": {}}, - {"path": "/Users", "params": {}}, - ], - }) - data = client.get_info() or {} - name = data.get("ServerName", "?") - version = data.get("Version", "?") - os_info = f"{data.get('OperatingSystem', '?')}" - users = len(client.get_users() or []) - emit(f"🖥️ {name} v{version}\n OS: {os_info}\n Users: {users}", - {"name": name, "version": version, "operating_system": os_info, "users": users}) - - -def cmd_recent(client, args): - p = argparse.ArgumentParser(prog="jellyfin-cli recent") - p.add_argument("--limit", type=int, default=10) - media_type = p.add_mutually_exclusive_group() - media_type.add_argument("--movies", action="store_true") - media_type.add_argument("--episodes", action="store_true") - p.add_argument("--user-id", default=ENV_USER_ID, - help="Jellyfin user ID (default: JELLYFIN_USER_ID)") - parsed, _ = p.parse_known_args(args) - - include_types = ["Episode"] if parsed.episodes else ["Movie"] if parsed.movies else None - if client.dry_run: - params = {"userId": parsed.user_id or None, "fields": "DateCreated"} - if include_types: - params["includeItemTypes"] = ",".join(include_types) - params["limit"] = parsed.limit - return emit("[dry-run] GET /Items/Latest " + json.dumps(params), { - "dry_run": True, "path": "/Items/Latest", "params": params, - }) - - if not parsed.user_id: - die("recent requires --user-id or JELLYFIN_USER_ID.") - - items = client.get_recent(parsed.user_id, limit=parsed.limit, - include_types=include_types) or [] - if not items: - return emit("No recent items.", {"items": []}) - - lines, out = [], [] - for i in items: - name = i.get("Name", "?") - itype = i.get("Type", "?") - date = fmt_date(i.get("DateCreated", "")) - year = i.get("ProductionYear", "") - series = i.get("SeriesName", "") - series_str = f" [{series}]" if series else "" - lines.append(f" {name:45}{series_str} ({year}) {itype} added {date}") - out.append({"name": name, "type": itype, "year": year, - "series": series, "date_added": date, "id": i.get("Id")}) - emit(f"Recently added:\n" + "\n".join(lines), {"items": out}) - - -def cmd_search(client, args): - p = argparse.ArgumentParser(prog="jellyfin-cli search") - p.add_argument("--query", "-q", required=True) - p.add_argument("--type", help="Comma-separated types (Movie,Series,Episode)") - p.add_argument("--limit", type=int, default=20) - parsed, _ = p.parse_known_args(args) - - types = parsed.type.split(",") if parsed.type else None - if client.dry_run: - params = {"searchTerm": parsed.query, "limit": parsed.limit, "recursive": True} - if types: - params["includeItemTypes"] = ",".join(types) - return emit("[dry-run] GET /Search/Hints " + json.dumps(params), { - "dry_run": True, "path": "/Search/Hints", "params": params, - }) - - data = client.search(parsed.query, limit=parsed.limit, include_types=types) or {} - hints = data.get("SearchHints", []) - if not hints: - return emit("No results.", {"results": []}) - - lines, out = [], [] - for h in hints: - name = h.get("Name", "?") - itype = h.get("Type", "?") - year = h.get("ProductionYear", "") - series = h.get("Series", "") - series_str = f" [{series}]" if series else "" - lines.append(f" {name:45}{series_str} ({year}) [{itype}]") - out.append({"name": name, "type": itype, "year": year, "series": series, "id": h.get("ItemId")}) - emit(f"{len(hints)} result(s):\n" + "\n".join(lines), {"results": out}) - - -def cmd_next_up(client, args): - p = argparse.ArgumentParser(prog="jellyfin-cli next-up") - p.add_argument("--user-id", default=ENV_USER_ID) - p.add_argument("--limit", type=int, default=10) - parsed, _ = p.parse_known_args(args) - - params = {"userId": parsed.user_id or None, "limit": parsed.limit} - if client.dry_run: - return emit("[dry-run] GET /Shows/NextUp " + json.dumps(params), { - "dry_run": True, "path": "/Shows/NextUp", "params": params, - }) - if not parsed.user_id: - die("next-up requires --user-id or JELLYFIN_USER_ID.") - - data = client.get_next_up(parsed.user_id, limit=parsed.limit) or {} - items = [normalize_item(item) for item in data.get("Items", [])] - lines = [f" {item.get('name', '?')} [{item.get('type', '?')}]" for item in items] - emit("Next up:\n" + "\n".join(lines) if lines else "No next-up episodes.", { - "items": items, "total_record_count": data.get("TotalRecordCount", 0), - }) - - -def cmd_item(client, args): - p = argparse.ArgumentParser(prog="jellyfin-cli item") - p.add_argument("--id", required=True) - p.add_argument("--user-id", default=ENV_USER_ID) - parsed, _ = p.parse_known_args(args) - - params = {"userId": parsed.user_id or None} - path = f"/Items/{parsed.id}" - if client.dry_run: - return emit("[dry-run] GET " + path + " " + json.dumps(params), { - "dry_run": True, "path": path, "params": params, - }) - if not parsed.user_id: - die("item requires --user-id or JELLYFIN_USER_ID.") - - item = normalize_item(client.get_item(parsed.id, parsed.user_id) or {}) - emit(f"{item.get('name', '?')} [{item.get('type', '?')}]", item) - - -def cmd_browse(client, args): - p = argparse.ArgumentParser(prog="jellyfin-cli browse") - p.add_argument("--library-id", required=True) - p.add_argument("--type") - p.add_argument("--limit", type=int, default=50) - p.add_argument("--start-index", type=int, default=0) - parsed, _ = p.parse_known_args(args) - - types = parsed.type.split(",") if parsed.type else None - params = { - "parentId": parsed.library_id, "limit": parsed.limit, "sortBy": "SortName", - "sortOrder": "Ascending", "startIndex": parsed.start_index, "recursive": True, - } - if types: - params["includeItemTypes"] = ",".join(types) - if client.dry_run: - return emit("[dry-run] GET /Items " + json.dumps(params), { - "dry_run": True, "path": "/Items", "params": params, - }) - - data = client.get_items(parsed.library_id, types=types, limit=parsed.limit, - start_index=parsed.start_index) or {} - items = [normalize_item(item) for item in data.get("Items", [])] - lines = [f" {item.get('name', '?')} [{item.get('type', '?')}]" for item in items] - emit("Browse results:\n" + "\n".join(lines) if lines else "No items found.", { - "items": items, - "start_index": data.get("StartIndex", parsed.start_index), - "total_record_count": data.get("TotalRecordCount", 0), - }) - - -def cmd_libraries(client, args): - if client.dry_run: - return emit("[dry-run] GET /Library/MediaFolders {}", { - "dry_run": True, "path": "/Library/MediaFolders", "params": {}, - }) - data = client.get_libraries() or {} - libraries = data.get("Items", []) if isinstance(data, dict) else data - if not libraries: - return emit("No libraries found.", {"libraries": []}) - lines, out = [], [] - for lib in libraries: - name = lib.get("Name", "?") - lid = lib.get("Id", "?") - ctype = lib.get("CollectionType", "?") - lines.append(f" {name:30} [{ctype}] id={lid}") - out.append({"name": name, "id": lid, "type": ctype}) - emit(f"{len(libraries)} libraries:\n" + "\n".join(lines), {"libraries": out}) - - -def cmd_stats(client, args): - if client.dry_run: - return emit("[dry-run] GET /Items/Counts {}", { - "dry_run": True, "path": "/Items/Counts", "params": {}, - }) - data = client.get_stats() or {} - emit(f"📊 Library stats:\n" - f" Movies: {data.get('MovieCount', '?')}\n" - f" Series: {data.get('SeriesCount', '?')}\n" - f" Episodes: {data.get('EpisodeCount', '?')}\n" - f" Songs: {data.get('SongCount', '?')}", - {"movies": data.get("MovieCount"), "series": data.get("SeriesCount"), - "episodes": data.get("EpisodeCount"), "songs": data.get("SongCount")}) - - -def main(): - global GLOBAL_FLAGS - GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv) - if GLOBAL_FLAGS.get("json", False): - warnings.simplefilter("ignore") - - parser = argparse.ArgumentParser(prog="jellyfin-cli", description="Jellyfin media server CLI.", - epilog="Example: jellyfin-cli search --query dune") - parser.add_argument("--json", action="store_true", help="Output machine-readable JSON") - parser.add_argument("--dry-run", action="store_true", help="Preview API requests without network access") - sub = parser.add_subparsers(dest="command") - sub.add_parser("info", help="Server info", description="Show Jellyfin server details.", epilog="Example: jellyfin-cli info") - re = sub.add_parser("recent", help="Recently added", description="Show recently added movies or episodes.", epilog="Example: jellyfin-cli recent --movies --limit 5") - re.add_argument("--limit", type=int, default=10, help="Maximum items to return (default: 10)") - media_type = re.add_mutually_exclusive_group() - media_type.add_argument("--movies", action="store_true", help="Show only movies") - media_type.add_argument("--episodes", action="store_true", help="Show only episodes") - re.add_argument("--user-id", help="Jellyfin user ID (default: JELLYFIN_USER_ID)") - se = sub.add_parser("search", help="Search media", description="Search the Jellyfin media library.", epilog="Example: jellyfin-cli search --query dune --type Movie") - se.add_argument("--query", "-q", required=True, help="Text to search for") - se.add_argument("--type", help="Comma-separated item types, such as Movie,Series") - se.add_argument("--limit", type=int, default=20, help="Maximum results to return (default: 20)") - nu = sub.add_parser("next-up", help="Next unwatched episodes", description="Show the next unwatched episodes for a Jellyfin user.", epilog="Example: jellyfin-cli next-up --user-id USER_ID --limit 5") - nu.add_argument("--user-id", help="Jellyfin user ID (default: JELLYFIN_USER_ID)") - nu.add_argument("--limit", type=int, default=10, help="Maximum episodes to return (default: 10)") - it = sub.add_parser("item", help="Show item details", description="Show metadata for one Jellyfin library item.", epilog="Example: jellyfin-cli item --id ITEM_ID --user-id USER_ID") - it.add_argument("--id", required=True, help="Jellyfin item ID") - it.add_argument("--user-id", help="Jellyfin user ID (default: JELLYFIN_USER_ID)") - br = sub.add_parser("browse", help="Browse a library", description="List items in a Jellyfin media library.", epilog="Example: jellyfin-cli browse --library-id LIBRARY_ID --type Movie --limit 20") - br.add_argument("--library-id", required=True, help="Jellyfin library ID") - br.add_argument("--type", help="Comma-separated item types, such as Movie,Series") - br.add_argument("--limit", type=int, default=50, help="Maximum items to return (default: 50)") - br.add_argument("--start-index", type=int, default=0, help="Zero-based result offset (default: 0)") - sub.add_parser("libraries", help="List libraries", description="List configured media libraries.", epilog="Example: jellyfin-cli libraries") - sub.add_parser("stats", help="Library statistics", description="Show media library item counts.", epilog="Example: jellyfin-cli stats") - - args = parser.parse_args(filtered_argv[1:]) - if not args.command: - parser.print_help() - sys.exit(1) - - client = JellyfinClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)) - - cmd_map = { - "info": cmd_info, "recent": cmd_recent, "search": cmd_search, - "next-up": cmd_next_up, "item": cmd_item, "browse": cmd_browse, - "libraries": cmd_libraries, "stats": cmd_stats, - } - handler = cmd_map.get(args.command) - if not handler: - parser.print_help() - sys.exit(1) - - remaining = filtered_argv[filtered_argv.index(args.command) + 1:] - handler(client, remaining) - - -if __name__ == "__main__": - main() diff --git a/jellyfin-cli/tests/test_jellyfin_cli.py b/jellyfin-cli/tests/test_jellyfin_cli.py deleted file mode 100644 index 428f56a..0000000 --- a/jellyfin-cli/tests/test_jellyfin_cli.py +++ /dev/null @@ -1,221 +0,0 @@ -import contextlib -import importlib.machinery -import importlib.util -import io -import json -import pathlib -import subprocess -import unittest - - -SCRIPT = pathlib.Path(__file__).parents[1] / "scripts" / "jellyfin-cli" -LOADER = importlib.machinery.SourceFileLoader("jellyfin_cli", str(SCRIPT)) -SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) -jellyfin_cli = importlib.util.module_from_spec(SPEC) -LOADER.exec_module(jellyfin_cli) - - -class FakeClient: - def __init__(self, libraries=None, dry_run=False): - self.dry_run = dry_run - self.libraries = libraries - self.recent_calls = [] - - def get_libraries(self): - return self.libraries - - def get_recent(self, user_id, limit=10, include_types=None): - self.recent_calls.append((user_id, limit, include_types)) - return [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1"}] - - -class NavigationFakeClient: - def __init__(self, dry_run=False): - self.dry_run = dry_run - self.next_up_calls = [] - self.item_calls = [] - self.items_calls = [] - - def get_next_up(self, user_id, limit=10): - self.next_up_calls.append((user_id, limit)) - return { - "Items": [{"Name": "The Signal", "Type": "Episode", "Id": "episode-1", - "SeriesName": "Voyagers", "IndexNumber": 4}], - "StartIndex": 0, - "TotalRecordCount": 9, - } - - def get_item(self, item_id, user_id): - self.item_calls.append((item_id, user_id)) - return {"Name": "The Signal", "Type": "Episode", "Id": item_id, - "SeriesName": "Voyagers", "IndexNumber": 4, - "Overview": "A message arrives."} - - def get_items(self, parent_id, types=None, limit=50, sort_by="SortName", - sort_order="Ascending", start_index=0): - self.items_calls.append((parent_id, types, limit, sort_by, sort_order, start_index)) - return { - "Items": [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1", - "ProductionYear": 2016}], - "StartIndex": start_index, - "TotalRecordCount": 1, - } - - -class JellyfinCliTests(unittest.TestCase): - def setUp(self): - self.flags = jellyfin_cli.GLOBAL_FLAGS - self.env_user_id = jellyfin_cli.ENV_USER_ID - jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False} - - def tearDown(self): - jellyfin_cli.GLOBAL_FLAGS = self.flags - jellyfin_cli.ENV_USER_ID = self.env_user_id - - def test_hardened_recent_and_libraries_contracts(self): - output = io.StringIO() - libraries = FakeClient(libraries={"Items": [{"Name": "Films", "Id": "lib-1", "CollectionType": "movies"}]}) - with contextlib.redirect_stdout(output): - jellyfin_cli.cmd_libraries(libraries, []) - self.assertEqual(json.loads(output.getvalue())["libraries"][0]["name"], "Films") - - calls = [] - client = jellyfin_cli.JellyfinClient() - client._get = lambda path, params=None: calls.append((path, params)) or [] - client.get_recent("user-1", limit=3, include_types=["Movie", "Episode"]) - self.assertEqual(calls, [("/Items/Latest", {"userId": "user-1", "includeItemTypes": "Movie,Episode", "limit": 3, "fields": "DateCreated"})]) - - recent = FakeClient() - with contextlib.redirect_stdout(io.StringIO()): - jellyfin_cli.cmd_recent(recent, ["--user-id", "user-1", "--movies", "--limit", "2"]) - self.assertEqual(recent.recent_calls, [("user-1", 2, ["Movie"])]) - - with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): - jellyfin_cli.cmd_recent(recent, ["--movies", "--episodes"]) - - result = subprocess.run( - [str(SCRIPT), "recent", "--movies", "--episodes"], - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 2) - self.assertIn("not allowed with argument", result.stderr) - - def test_recent_requires_user_before_network_and_dry_run_previews_request(self): - jellyfin_cli.ENV_USER_ID = "" - client = FakeClient() - error = io.StringIO() - with contextlib.redirect_stderr(error), self.assertRaises(SystemExit): - jellyfin_cli.cmd_recent(client, []) - self.assertIn("JELLYFIN_USER_ID", error.getvalue()) - self.assertEqual(client.recent_calls, []) - - dry_run = FakeClient(dry_run=True) - output = io.StringIO() - with contextlib.redirect_stdout(output): - jellyfin_cli.cmd_recent(dry_run, ["--movies", "--limit", "2"]) - self.assertEqual(json.loads(output.getvalue()), { - "dry_run": True, - "path": "/Items/Latest", - "params": {"userId": None, "includeItemTypes": "Movie", "limit": 2, "fields": "DateCreated"}, - }) - self.assertEqual(dry_run.recent_calls, []) - - def test_navigation_client_contracts(self): - calls = [] - client = jellyfin_cli.JellyfinClient() - client._get = lambda path, params=None: calls.append((path, params)) or {} - - client.get_next_up("user-1", limit=3) - client.get_item("item-1", "user-1") - client.get_items("library-1", types=["Movie", "Series"], limit=4, start_index=2) - - self.assertEqual(calls, [ - ("/Shows/NextUp", {"userId": "user-1", "limit": 3}), - ("/Items/item-1", {"userId": "user-1"}), - ("/Items", {"parentId": "library-1", "limit": 4, "sortBy": "SortName", - "sortOrder": "Ascending", "startIndex": 2, "recursive": True, - "includeItemTypes": "Movie,Series"}), - ]) - - def test_next_up_item_and_browse_parse_results_as_json(self): - client = NavigationFakeClient() - - output = io.StringIO() - with contextlib.redirect_stdout(output): - jellyfin_cli.cmd_next_up(client, ["--user-id", "user-1", "--limit", "3"]) - self.assertEqual(json.loads(output.getvalue()), { - "items": [{"id": "episode-1", "name": "The Signal", "type": "Episode", - "series": "Voyagers", "episode_number": 4}], - "total_record_count": 9, - }) - self.assertEqual(client.next_up_calls, [("user-1", 3)]) - - output = io.StringIO() - with contextlib.redirect_stdout(output): - jellyfin_cli.cmd_item(client, ["--id", "episode-1", "--user-id", "user-1"]) - self.assertEqual(json.loads(output.getvalue()), { - "id": "episode-1", "name": "The Signal", "type": "Episode", - "series": "Voyagers", "episode_number": 4, "overview": "A message arrives.", - }) - self.assertEqual(client.item_calls, [("episode-1", "user-1")]) - - output = io.StringIO() - with contextlib.redirect_stdout(output): - jellyfin_cli.cmd_browse(client, ["--library-id", "library-1", "--type", "Movie", - "--limit", "4", "--start-index", "2"]) - self.assertEqual(json.loads(output.getvalue()), { - "items": [{"id": "movie-1", "name": "Arrival", "type": "Movie", "year": 2016}], - "start_index": 2, - "total_record_count": 1, - }) - self.assertEqual(client.items_calls, [("library-1", ["Movie"], 4, "SortName", "Ascending", 2)]) - - def test_user_scoped_navigation_requires_user_before_network(self): - jellyfin_cli.ENV_USER_ID = "" - for handler, arguments in ( - (jellyfin_cli.cmd_next_up, []), - (jellyfin_cli.cmd_item, ["--id", "item-1"]), - ): - client = NavigationFakeClient() - with self.subTest(handler=handler.__name__), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): - handler(client, arguments) - self.assertEqual(client.next_up_calls, []) - self.assertEqual(client.item_calls, []) - - def test_navigation_dry_runs_do_not_call_network_and_emit_requests(self): - cases = ( - (jellyfin_cli.cmd_next_up, ["--limit", "3"], {"path": "/Shows/NextUp", "params": {"userId": None, "limit": 3}}), - (jellyfin_cli.cmd_item, ["--id", "item-1"], {"path": "/Items/item-1", "params": {"userId": None}}), - (jellyfin_cli.cmd_browse, ["--library-id", "library-1", "--type", "Movie,Series", "--limit", "4", "--start-index", "2"], {"path": "/Items", "params": {"parentId": "library-1", "limit": 4, "sortBy": "SortName", "sortOrder": "Ascending", "startIndex": 2, "recursive": True, "includeItemTypes": "Movie,Series"}}), - ) - for handler, arguments, request in cases: - client = NavigationFakeClient(dry_run=True) - output = io.StringIO() - with self.subTest(handler=handler.__name__), contextlib.redirect_stdout(output): - handler(client, arguments) - self.assertEqual(json.loads(output.getvalue()), {"dry_run": True, **request}) - self.assertEqual(client.next_up_calls, []) - self.assertEqual(client.item_calls, []) - self.assertEqual(client.items_calls, []) - - def test_navigation_commands_dispatch_and_leaf_help_has_examples(self): - for command, arguments in ( - ("next-up", ["--user-id", "user-1", "--limit", "2"]), - ("item", ["--id", "item-1", "--user-id", "user-1"]), - ("browse", ["--library-id", "library-1", "--limit", "2"]), - ): - result = subprocess.run([str(SCRIPT), "--json", "--dry-run", command, *arguments], - capture_output=True, text=True) - with self.subTest(command=command): - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(json.loads(result.stdout)["dry_run"]) - - help_result = subprocess.run([str(SCRIPT), command, "--help"], capture_output=True, text=True) - with self.subTest(help_command=command): - self.assertEqual(help_result.returncode, 0) - self.assertIn("Example:", help_result.stdout) - - -if __name__ == "__main__": - unittest.main() diff --git a/jellyfin/README.md b/jellyfin/README.md new file mode 100644 index 0000000..b66f963 --- /dev/null +++ b/jellyfin/README.md @@ -0,0 +1,69 @@ +# Jellyfin Media Server from the Terminal + +Query your Jellyfin media library — recently added movies and episodes, search and inspect +items, walk series, seasons, and episodes, browse library contents, see next-up episodes, +log in as a user, and check server stats. + +## Why Install This Skill + +When your agent loads this skill, it can **navigate your home media server** without +opening a browser. That means: + +- **See what's new** — recently added movies and TV episodes, filtered server-side +- **Search your library** — find any movie, show, or episode by keyword +- **Navigate series** — walk a show's seasons and episodes, and see what's next unwatched +- **Browse collections** — list your libraries and page through everything in them +- **Authenticate properly** — log in as a user (or use Quick Connect) without fumbling + Jellyfin's unusual `MediaBrowser` authorization header, which trips up most scripts +- **Check server details** — server name, version, operating system, user count, counts + +Every command is read-only (plus a `login` helper), and `--dry-run` previews any request +without touching the network. + +## What You Get + +| Path | Purpose | +|------|---------| +| `SKILL.md` | Complete command reference with setup, gotchas, and recipes | +| `scripts/jellyfin` | CLI for Jellyfin API operations (`--json`, `--dry-run`) | +| `scripts/test_jellyfin_cli.py` | Offline test suite (all HTTP mocked) | +| `references/auth-and-sessions.md` | The MediaBrowser header scheme, login flow, token channels, deprecation timeline | +| `references/endpoint-catalog.md` | Endpoint-by-endpoint parameter and response-shape catalog | +| `references/user-scoping-and-errors.md` | Which calls need a user id, and why queries 400/404 without one | +| `references/gotchas-field-guide.md` | Wire-level failure signatures and version differences | +| `references/worked-recipes.md` | Multi-step curl/jq and CLI workflows | +| `references/quick-connect.md` | Passwordless Quick Connect login | +| `evals/evals.json` | Behavioral eval cases including negative triggers | + +## Quick Start + +```bash +scripts/jellyfin --help +export JELLYFIN_URL="http://your-server:8096" +export JELLYFIN_API_KEY="your-api-key" # Dashboard → API Keys +export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by user-scoped commands +``` + +```bash +scripts/jellyfin search --query "dune" --type Movie --json +scripts/jellyfin recent --movies --limit 5 +``` + +No API key yet? Log in as a user instead — the script sends the pre-token +`Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=...` header +that `POST /Users/AuthenticateByName` requires and prints the values to export: + +```bash +scripts/jellyfin login --username alice --prompt +``` + +## Triggers + +Load this when asking about Jellyfin, media server content, recently added movies or TV, +next-up episodes, browsing your home media library, or Jellyfin API authentication. + +## Requirements + +Python 3.8+ with `requests`. A running Jellyfin server (10.8+ behaviors assumed). +Authentication: an API key (Dashboard → API Keys), a user access token via `login`, or +Quick Connect. User-scoped commands also need a Jellyfin user id. diff --git a/jellyfin/SKILL.md b/jellyfin/SKILL.md new file mode 100644 index 0000000..7f7569c --- /dev/null +++ b/jellyfin/SKILL.md @@ -0,0 +1,226 @@ +--- +name: jellyfin +description: Query your Jellyfin media server from the terminal — recently added media, + search, item details, series navigation, next-up episodes, library browsing, server + info, and user login. Use when the user asks about Jellyfin, media servers, movies, TV + shows, next episodes, or their media library. Do not use this skill for server + installation, library management, playback control, or Emby/Plex servers. +license: MIT +compatibility: Requires Python 3.8+ and `requests`. Authenticate with JELLYFIN_API_KEY + (Dashboard → API Keys), a user access token from `login`, or Quick Connect; user-scoped + commands (`recent`, `next-up`, `item`, `seasons`, `episodes`) also need JELLYFIN_USER_ID + or --user-id. +metadata: + tags: jellyfin, media-server, movies, tv, episodes, recently-added, library, home-media, + api-client + sources: https://api.jellyfin.org/, https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f +--- + +# jellyfin — Jellyfin Media Server from the Terminal + +Query recently added movies and TV episodes, search and inspect media, walk series → +seasons → episodes, browse libraries, see next-up episodes, log in as a user, and check +server stats — all from your Jellyfin server's REST API. Every command is read-only +except `login`. + +## Setup + +1. Make sure your Jellyfin server is running and accessible (default `http://localhost:8096`). +2. Pick an authentication route: + - **API key** — Dashboard → **API Keys** → `+`. Administrator-level, no user identity: + every user-scoped command then needs an explicit user id. + - **User token** — run `scripts/jellyfin login --username NAME --prompt` once; it + prints the values to export. +3. Set these environment variables: + +```bash +export JELLYFIN_URL="http://your-server:8096" # include protocol and port +export JELLYFIN_API_KEY="your-api-key-here" # or JELLYFIN_TOKEN after `login` +export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by recent, next-up, item, seasons, episodes +``` + +Run the bundled CLI as `scripts/jellyfin`. `--help` and `--dry-run` work without +credentials. + +### How authentication works + +Jellyfin wants a `MediaBrowser`-scheme `Authorization` header on every call. The login +endpoint requires its `Client=..., Device=..., DeviceId=..., Version=...` quartet **before +any token exists** — the server rejects `POST /Users/AuthenticateByName` with +`400 Error processing request.` otherwise. Afterwards the access token (or API key) rides +the same header as `Token="..."`; the legacy `X-Emby-Token` header means the same thing +and is scheduled for removal from Jellyfin 12.0. The bundled CLI sends the modern form and +puts the token in exactly that one channel per request (never co-sends `X-Emby-Token`). +See [references/auth-and-sessions.md](references/auth-and-sessions.md). + +## Essential Commands + +### Authentication — get a session + +```bash +scripts/jellyfin login --username alice --prompt # prints JELLYFIN_* exports +echo "pw" | scripts/jellyfin login --username alice --password-stdin +scripts/jellyfin login --username alice --dry-run --json # preview the pre-token header +``` + +`login` demonstrates the full researched sequence: complete pre-token MediaBrowser header +→ `POST /Users/AuthenticateByName` → capture `User.Id` + `AccessToken` → print the +post-token header for reuse. It never echoes the password. + +### info — Server information + +```bash +scripts/jellyfin info # server name, version, OS, user count +scripts/jellyfin info --json +``` + +### recent — Recently added media + +```bash +scripts/jellyfin recent # last 10 items added for JELLYFIN_USER_ID +scripts/jellyfin recent --movies --limit 5 # server-side includeItemTypes filter +scripts/jellyfin recent --episodes --limit 20 --json +scripts/jellyfin recent --user-id USER_ID # override the env var +``` + +Hits `/Items/Latest` with `userId`; the response is a **bare JSON array** (no `Items` +wrapper), and `groupItems` merges episodes by series, so treat it as "what's new". + +### search — Search your media library + +```bash +scripts/jellyfin search --query "dune" # everything +scripts/jellyfin search --query "dune" --type Movie # comma-separated types +scripts/jellyfin search --query "star trek" --type Series,Episode --limit 5 --json +``` + +Search hits `/Search/Hints`; results carry `id` (with a deprecated `ItemId` twin on old +servers — the CLI already prefers the modern field). + +### Navigation — inspect items and walk series + +```bash +scripts/jellyfin search --query "dune" --type Movie --json # find an item ID +scripts/jellyfin item --id ITEM_ID # full metadata (needs user) +scripts/jellyfin seasons --series-id SERIES_ID # list seasons +scripts/jellyfin episodes --series-id SERIES_ID --season-id SEASON_ID +scripts/jellyfin next-up --limit 10 # next unwatched episodes +scripts/jellyfin next-up --series-id SERIES_ID --user-id USER_ID +``` + +`item`, `seasons`, `episodes`, and `next-up` are user-scoped: they require +`JELLYFIN_USER_ID` or `--user-id` and fail before any network call without one. + +### libraries — browse a collection + +```bash +scripts/jellyfin libraries # library IDs and types +scripts/jellyfin browse --library-id LIBRARY_ID --type Movie --limit 50 +scripts/jellyfin browse --library-id LIBRARY_ID --start-index 50 # paginate +scripts/jellyfin browse --library-id LIBRARY_ID --user-id USER_ID # userId sent explicitly +``` + +`libraries` reads `/Library/MediaFolders`, which is **admin-only** — non-admin tokens get +403 and should use `/UserViews` (see references). `browse` pages `/Items` with +`startIndex`/`limit` and passes `userId` when provided, since servers using non-API-key +auth reject unscoped queries with `400 userId is required`. + +### stats — Library statistics + +```bash +scripts/jellyfin stats # movie, series, episode, song counts (/Items/Counts) +``` + +## Pipeline recipes + +### Find a series, then its next unwatched episode + +```bash +scripts/jellyfin search --query "breaking bad" --type Series --json | jq -r '.results[0].id' +scripts/jellyfin next-up --series-id "$SERIES_ID" --user-id "$JELLYFIN_USER_ID" --json | jq -r '.items[0].name' +``` + +### Page through a whole library + +```bash +scripts/jellyfin browse --library-id "$LIB_ID" --limit 100 --start-index 0 --json | jq -c '.items' +# loop: advance --start-index by the returned count until .total_record_count is reached +``` + +### Log in and persist a session + +```bash +scripts/jellyfin login --username alice --prompt --json | jq -r '"\(.user_id) \(.access_token)"' +``` + +## JSON and jq + +Put `--json` before or after the subcommand. Output keys are stable snake_case: `items` +(with `id`, `name`, `type`, `year`, `series`, `season_number`, `episode_number`), +`results`, `libraries`, `total_record_count`, `start_index`. `--dry-run` emits a plan +carrying `dry_run`, `path`, and `params` (`login` adds `authorization_header`; `info` +composes a `requests` list), matching what would be sent, so jq can verify a chain before +running it live. Exit codes: `0` success (including dry-run), `1` CLI/API errors, `2` +argument errors. Use `jq -r '.items[] | [.name, .year] | @tsv'` for tabular handoff. + +## Known Gotchas + +- **JELLYFIN_URL must include protocol and port** — e.g. `http://192.168.1.100:8096`. +- **User-scoped commands require an explicit user** — `recent`, `next-up`, `item`, + `seasons`, `episodes` refuse to run without `JELLYFIN_USER_ID`/`--user-id`. The CLI + never picks an administrator for you. A missing userId on user-token requests makes the + server answer `400 userId is required`. +- **API keys have no user** — `/Users/Me` answers `400 Token is not owned by a user.` to + API keys by design; per-user queries need an explicit user id (see + [references/user-scoping-and-errors.md](references/user-scoping-and-errors.md)). +- **The login 400 vs 401 trap** — missing/partial MediaBrowser header → `400` with plain + text `Error processing request.`; wrong credentials → `401`. Same endpoint, different + failures. +- **Response shapes differ per endpoint** — `/Items` and `/Shows/*` wrap results in + `{Items, TotalRecordCount, StartIndex}`; `/Items/Latest` returns a bare array; search + uses a `SearchHints` key. Generic clients must branch (the CLI already does). +- **Recent type filtering is server-side** — `--movies`/`--episodes` become + `includeItemTypes` before `limit`; no local filtering. +- **NextUp needs userId on every server version** — omitting it crashed servers ≤10.8 and + silently scopes to the session user on ≥10.9. The CLI always sends it. +- **`libraries` is admin-only** — `/Library/MediaFolders` requires an administrator token; + non-admin tokens get 403. +- **Legacy auth is going away** — `X-Emby-Token`, `X-MediaBrowser-Token`, and the + `api_key` query parameter are deprecated; admins can already disable them (10.11+), and + removal targets 12.0. Prefer the modern Authorization header the CLI sends. +- **Lazy auth** — `--help` and `--dry-run` work without credentials; dry-run never touches + the network. + +## When to use + +Use this skill for read-only interaction with a running Jellyfin server: discovery of +what's new, searching and inspecting items, walking series and seasons, next-up planning, +library inventories, and obtaining a user session via `login` or Quick Connect. + +## When not to use + +Do not use this skill for server installation or administration (installing Jellyfin or +Emby, editing libraries, managing users) — every bundled command is read-only except +`login`. It does not target Plex or Kodi (different APIs — use their own tools), and it is +not a playback remote: route streaming or remote-control automation to Jellyfin's official +clients. + +## Reference Files + +| File | Use it for | +| ---- | ---------- | +| [references/auth-and-sessions.md](references/auth-and-sessions.md) | MediaBrowser header scheme, login flow, token channels, legacy deprecation, error signatures | +| [references/endpoint-catalog.md](references/endpoint-catalog.md) | Every read endpoint's parameters, response shapes, image URLs, pagination loop | +| [references/user-scoping-and-errors.md](references/user-scoping-and-errors.md) | The userId requirement matrix, API-key identity quirks, 400-vs-404 diagnosis | +| [references/gotchas-field-guide.md](references/gotchas-field-guide.md) | Wire-level failure signatures, version-drift ledger, mock shapes | +| [references/worked-recipes.md](references/worked-recipes.md) | Multi-step curl/jq and CLI recipes: login → latest, libraries → browse, search → seasons → episodes | +| [references/quick-connect.md](references/quick-connect.md) | Passwordless Quick Connect login flow | + +## Available Scripts and Prerequisites + +- `scripts/jellyfin` — the bundled Python CLI (`--json`, `--dry-run`, lazy auth). + Imports only the standard library and `requests`. +- `scripts/test_jellyfin_cli.py` — offline test suite (pytest + unittest compatible); + all HTTP behavior is mocked, zero network egress. +- Requires Python 3.8+ and `requests`. A running Jellyfin server (10.8+ assumed; tested + behaviors anchored to the 12.0-era OpenAPI spec). No service is started by this skill. diff --git a/jellyfin/evals/evals.json b/jellyfin/evals/evals.json new file mode 100644 index 0000000..093a85a --- /dev/null +++ b/jellyfin/evals/evals.json @@ -0,0 +1,68 @@ +{ + "schema_version": 1, + "skill_name": "jellyfin", + "evals": [ + { + "id": "recent-movies-json", + "prompt": "Show me the five movies most recently added to my Jellyfin server, as JSON.", + "expected_output": "Run scripts/jellyfin recent --movies --limit 5 --json with JELLYFIN_URL, JELLYFIN_API_KEY, and JELLYFIN_USER_ID configured; the /Items/Latest response is a bare JSON array, not an {Items: [...]} wrapper.", + "assertions": [ + "invokes scripts/jellyfin recent with --movies and --limit 5", + "uses --json for machine-readable output", + "does not wrap the result in an Items key because /Items/Latest returns a bare array" + ] + }, + { + "id": "search-to-episodes-pipeline", + "prompt": "Find the series Breaking Bad on my Jellyfin server and then list its episodes.", + "expected_output": "Chain scripts/jellyfin search --query 'breaking bad' --type Series --json to get a result id, then scripts/jellyfin seasons --series-id` inside `q`. |
+| `title`, `author` | Top-level scoped params equivalent to prefixing inside q (e.g. `/search.json?title=the+lord+of+the+rings`). |
+
+### Sort keys
+
+All of these returned HTTP 200 in live probes (`q=harry potter&limit=1`);
+the authoritative enumeration lives in Open Library source
+(`openlibrary/plugins/worksearch/schemes/works.py`):
+
+`new`, `old`, `rating asc`, `rating desc` (bare `rating` = desc), `editions`, `title`,
+`scans`, `key` (sorts as a *string*, not numerically), `random`, `readinglog`,
+`already_read`, `want_to_read`, `currently_reading`, `ebook_access`.
+
+The existing CLI exposes `--sort {editions,new,old,rating,title}` plus empty for
+relevance — a safe subset.
+
+## Pagination model: offsets only, no tokens
+
+There is no cursor or token concept anywhere on this API — clients compute the next
+offset from `numFound` and `start`:
+
+```json
+{"numFound": 4045, "start": 0, "numFoundExact": true,
+ "num_found": 4045, "documentation_url": "...", "q": "harry potter",
+ "offset": null, "docs": [...]}
+```
+
+- `start` mirrors the effective zero-based offset of the first doc
+ (`page=3&limit=10` → `start: 20`).
+- `numFoundExact: false` signals the count is approximate.
+- No documented cap on `limit` or `offset`: live probes honored `limit=2000` and
+ `offset=11000`. The response simply clamps to the matched set. Stay modest anyway —
+ the etiquette policy forbids using OL as a bulk backend.
+
+## Result document schema
+
+Common `docs[]` fields: `key` (path form `/works/OL…W`), `title`, `author_name[]`,
+`author_key[]`, `first_publish_year`, `edition_count`, `cover_edition_key`,
+`cover_i`, `ia[]` (Internet Archive scan ids), `has_fulltext`, `public_scan_b`,
+`language[]`, `subject[]`, `publisher[]`, `publish_year[]`, `isbn[]`,
+`number_of_pages_median`, `ebook_access`, `ratings_average`, `ratings_count`,
+`readinglog_count`, `seed[]`.
+
+The docs state the schema "is not guaranteed to be stable, but most common fields …
+should be safe to depend on". Treat exotic fields as best-effort.
+
+## Query syntax: field scopes and filters
+
+Verified live prefixes:
+
+| Prefix | Example | Notes |
+|--------|---------|-------|
+| `title:` | `q=title:flammable` | 551 hits |
+| `author:` | `q=author:solnit` | 129 hits |
+| `subject:` | `q=subject:"tennis rules"` | fuzzy containment (AND), not exact phrase |
+| `publisher:` | `q=publisher:harper` | 77,889 hits |
+| `isbn:` | `q=isbn:9780451524935` | ISBN-10 and ISBN-13 both resolve to the same single work |
+| `language:` | `q=language:fre` | excludes works without matching-language editions |
+
+Lucene extras from the official how-to ([search/howto](https://openlibrary.org/search/howto)):
+ranges (`first_publish_year:[1200 TO 1400]`, `publish_year:[* TO 1800]`),
+booleans `AND`/`OR`/`NOT`, negation `-subject_key:"apache_solr"`,
+prefix wildcards `ddc:200*`, normalized exact keys (`subject_key:`, `person_key:`,
+`place_key:`, `time_key:` — lowercase, spaces/slashes → underscores),
+availability filter `ebook_access:` with values `no_ebook`, `printdisabled`,
+`borrowable`, `public`, plus `has_fulltext:true`, `edition_count:N`,
+`readinglog_count:[25 TO *]`.
+
+## The `fields=` projection and its availability gotcha
+
+Requesting fewer fields shrinks payloads dramatically. Live behavior:
+
+- `fields=key,title` returns exactly those keys per doc.
+- **`availability` is silently omitted unless `ia` is also requested** — verified live:
+ - `fields=key,title,availability` → doc keys exactly `['key','title']`
+ - `fields=key,title,ia,availability` → full availability subdocument present
+
+With `ia` included, each doc gains:
+
+```json
+"availability": {
+ "status": "borrow_available",
+ "is_readable": false,
+ "is_lendable": true,
+ "is_printdisabled": true,
+ "openlibrary_work": "OL82563W",
+ "openlibrary_edition": "OL61057835M", ...
+}
+```
+
+`status` values include `borrow_available`, `borrow_unavailable`, `printdisabled`,
+`open` (readable), and absent/`error` when no scan exists.
+
+Bonus expansion: `fields=key,title,editions` nests a mini-result-set under each work
+(`numFound`/`start`/`docs[]` with edition fields); individual edition fields are
+requested as `editions.key`, `editions.ebook_access`, `editions.language`;
+`&editions.sort` overrides default boosting.
+
+## Author search: `/search/authors.json`
+
+Same envelope (`numFound`/`start`/`docs[]`); author docs carry bare-form keys
+(`OL9937375A`) unlike book-search path keys:
+
+```json
+{"name": "Mark Twain", "key": "OL9937375A",
+ "birth_date": "30 November 1835", "death_date": "21 April 1910",
+ "top_work": "Roughing It", "work_count": 2157,
+ "top_subjects": ["Twain, mark, 1835-1910", ...]}
+```
+
+Supports Solr syntax in `q` too (e.g. `birth_date:1973`) plus `limit`/`offset`.
+Note `birth_date`/`death_date` may be null or free-text strings ("7 February 1812") —
+they are display strings, not typed dates.
+
+Batch-fetch trick documented on the Authors API page: partial author records via
+book search with `q=key:(/authors/OL11111A OR /authors/OL22222A)`; there is no
+batch endpoint for full author records.
+
+## Subject browsing: `/subjects/.json` (plural!)
+
+The Subjects API ([dev/docs/api/subjects](https://openlibrary.org/dev/docs/api/subjects),
+marked experimental) browses works grouped by normalized subject:
+
+```
+GET https://openlibrary.org/subjects/pizza.json?limit=1
+→ {"key": "", "name": "pizza", "work_count": 519,
+ "works": [{"key": "", "title": "Pete's a Pizza",
+ "edition_count": 19, "authors": [{"name": "William Steig"}],
+ "first_publish_year": 1998, "availability": {...}}, ...]}
+```
+
+- Path is **plural** `/subjects/.json`; singular `/subject/pizza.json` 404s
+ (live-verified).
+- Names use underscores: `science_fiction`.
+- Params: `details=true` (adds related `subjects[]`/`authors[]`/`publishers[]`
+ with counts plus `publishing_history`), `ebooks=true`, `published_in=1500-1600`,
+ `limit`, `offset`.
+- Works here include `availability` by default, unlike `/search.json`.
+- Sibling collections exist for persons/places/times (`/persons/.json` etc.).
+
+## Full-text inside-book search: `/search/inside.json`
+
+Searches OCR text across millions of scanned books; Elasticsearch-shaped response:
+
+```
+GET https://openlibrary.org/search/inside.json?q=%22library science%22
+→ hits.total, hits.hits[] with _id (ia identifier), _score,
+ highlight.text[] ("{{{Library Science}}}" marks matches),
+ fields.identifier (ia id), edition.key/title, availability
+```
+
+Default page size 20; `limit`/`offset` supported (live-verified). A separate,
+documented-but-experimental *per-book* inside search actually runs on archive.org
+data nodes (`https://ia800204.us.archive.org/fulltext/inside.php?item_id=...`)
+— see [dev/docs/api/search_inside](https://openlibrary.org/dev/docs/api/search_inside)
+if you need per-page match geometry; that host is outside this skill's CLI.
+
+## Error model: silent empties vs hard failures
+
+Live-probed status codes — counterintuitive but consistent:
+
+| Request | Status | Body |
+|---------|--------|------|
+| missing or empty `q` | **200** | normal envelope, `numFound: 0`, `docs: []` |
+| malformed query `q=title:"unclosed` | **200** | `numFound: 0` — no error surfaced |
+| loosely-parseable garbage `q=(OR` | **200** | 568k loose matches |
+| invalid enum `sort=bogus` | **500** | plain text `Internal Server Error` (not JSON!) |
+| non-integer `limit=abc` | **422** | FastAPI validation JSON `{"detail":[{"type":"int_parsing",...}]}` |
+| negative `offset=-5` | **422** | FastAPI validation JSON `greater_than_equal` |
+| singular `/subject/pizza.json` | **404** | HTML error page |
+
+Design consequence: user-facing "no results" is usually **not** an error — treat empty
+`docs` as success. Conversely a bad `--sort` choice fails loudly as non-JSON 500, so
+clients should validate sort choices before sending (as the bundled CLI does).
+
+One environment caveat observed during research: responses can arrive with key fields
+masked to asterisks by anti-bot middleware depending on client reputation. Production
+responses carry real keys (the docs' own examples show them), but parsers should
+tolerate both `OL…W` and `/works/OL…W` shapes and not assume key presence.
+
+## Sources
+
+- https://openlibrary.org/dev/docs/api/search — Search API parameters, fields= semantics, editions sub-query
+- https://openlibrary.org/search/howto — query syntax, field scopes, filter examples
+- https://openlibrary.org/developers/api — rate-limit etiquette applying to search traffic
+- https://openlibrary.org/dev/docs/api/authors — author batch-fetch via key:(…) search
+- https://openlibrary.org/dev/docs/api/subjects — Subjects API params (details/ebooks/published_in)
+- https://openlibrary.org/dev/docs/api/search_inside — experimental per-book inside search (archive.org hosted)
+- Live read-only probes against openlibrary.org (2026-08-26): sort keys, limit/offset ranges, fields=availability interaction, error status codes
diff --git a/openlibrary/scripts/openlibrary b/openlibrary/scripts/openlibrary
new file mode 100755
index 0000000..abb82ae
--- /dev/null
+++ b/openlibrary/scripts/openlibrary
@@ -0,0 +1,587 @@
+#!/usr/bin/env python3
+"""openlibrary — Open Library book metadata from the terminal.
+
+Search books, authors, works, lookup by ISBN, enumerate editions of a work,
+and read community ratings/bookshelf counts using the public Open Library
+API. No API key required. Covers resolve on the separate covers host and
+identifier endpoints answer with 302 redirects — both are handled here.
+"""
+
+import argparse
+import json
+import os
+import sys
+import warnings
+from typing import Any, Dict, List, Optional, Tuple
+
+warnings.simplefilter("ignore")
+
+import requests
+
+# === Config ===
+DEFAULT_SERVER = "https://openlibrary.org"
+COVERS_SERVER = "https://covers.openlibrary.org"
+ENV_SERVER = os.getenv("OL_SERVER", DEFAULT_SERVER)
+ENV_EMAIL = os.getenv("OL_EMAIL", "")
+ENV_USER_AGENT = os.getenv("OL_USER_AGENT", "openlibrary/1.0 (+https://github.com)")
+
+# Merged/deleted wiki records can chain through redirect stubs; bound the walk.
+MAX_REDIRECT_HOPS = 5
+
+QUIET = False
+GLOBAL_FLAGS: Dict[str, Any] = {
+ "json": False, "dry_run": False, "quiet": False, "verbose": False
+}
+
+
+def log(msg: str) -> None:
+ if not QUIET and not GLOBAL_FLAGS.get("json", False):
+ print(msg)
+
+
+def warn(msg: str) -> None:
+ print(f"Warning: {msg}", file=sys.stderr)
+
+
+def die(msg: str, exit_code: int = 1) -> None:
+ print(f"Error: {msg}", file=sys.stderr)
+ sys.exit(exit_code)
+
+
+def emit(human: str, data: Any) -> None:
+ if GLOBAL_FLAGS.get("json", False):
+ print(json.dumps(data, default=str))
+ else:
+ print(human)
+
+
+def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]:
+ GLOBAL_BOOLS = {"--json", "--dry-run", "--quiet", "--verbose"}
+ flags: Dict[str, Any] = {}
+ filtered: List[str] = [argv[0]]
+ i = 1
+ while i < len(argv):
+ arg = argv[i]
+ if arg in GLOBAL_BOOLS:
+ flags[arg.lstrip("-").replace("-", "_")] = True
+ i += 1
+ elif arg in ("--help", "-h"):
+ return flags, argv
+ elif arg == "--":
+ filtered.extend(argv[i:])
+ break
+ else:
+ filtered.append(arg)
+ i += 1
+ return flags, filtered
+
+
+def normalize_olid(key: str) -> str:
+ """Strip a path-form key (/works/OL123W) down to its bare OLID (OL123W)."""
+ return key.rstrip("/").split("/")[-1] if key else ""
+
+
+def unwrap_text(value: Any) -> str:
+ """Open Library wraps free-text fields as {'type': '/type/text', 'value': ...}
+ on some records and plain strings on others."""
+ if isinstance(value, dict):
+ return str(value.get("value", ""))
+ return str(value) if value is not None else ""
+
+
+def is_redirect_stub(payload: Any) -> bool:
+ """Merged-away keys answer HTTP 200 with a /type/redirect stub instead of 3xx."""
+ return (
+ isinstance(payload, dict)
+ and isinstance(payload.get("type"), dict)
+ and payload["type"].get("key") == "/type/redirect"
+ and bool(payload.get("location"))
+ )
+
+
+class OpenLibraryClient:
+ """Client for the public Open Library API."""
+
+ def __init__(self, server: str = "", dry_run: bool = False):
+ self.server = (server or ENV_SERVER).rstrip("/")
+ self.dry_run = dry_run
+
+ def _headers(self) -> Dict[str, str]:
+ h = {"User-Agent": ENV_USER_AGENT}
+ if ENV_EMAIL:
+ h["User-Agent"] += f" (mailto:{ENV_EMAIL})"
+ return h
+
+ def _get(self, path: str, params: Optional[Dict] = None) -> Any:
+ """GET JSON from the metadata host, following both HTTP redirects
+ (identifier endpoints answer 302) and in-body /type/redirect stubs
+ (merged keys answer 200 with a location field)."""
+ url = f"{self.server}{path}"
+ if self.dry_run:
+ return {"dry_run": True, "url": url, "params": params}
+ hops = 0
+ while True:
+ try:
+ resp = requests.get(url, params=params, headers=self._headers(), timeout=30)
+ except requests.ConnectionError as e:
+ die(f"Cannot connect to {self.server}: {e}")
+ if resp.status_code == 404:
+ return None
+ if resp.status_code >= 400:
+ try:
+ detail = resp.json()
+ except Exception:
+ detail = resp.text[:200]
+ die(f"API error ({resp.status_code}): {detail}")
+ try:
+ data = resp.json()
+ except ValueError:
+ return {"raw": resp.text[:500]}
+ if is_redirect_stub(data) and hops < MAX_REDIRECT_HOPS:
+ # Stub locations are bare keys (/works/OL…W, no .json), and
+ # extension-less URLs redirect to HTML pages — always refetch
+ # with the .json suffix.
+ target = data["location"]
+ if not target.endswith(".json"):
+ target += ".json"
+ url = f"{self.server}{target}"
+ params = None
+ hops += 1
+ continue
+ if is_redirect_stub(data):
+ # Bounded stub walk exhausted (>N chained merges); make that
+ # visible instead of handing back an opaque stub silently.
+ warn(f"Redirect chain did not resolve within {MAX_REDIRECT_HOPS}"
+ f" hops (still stuck at {data.get('location', '?')})")
+ self.last_url = resp.url
+ return data
+
+ def last_final_url(self) -> str:
+ return getattr(self, "last_url", "")
+
+
+def cover_url(kind: str, value: Any, size: str = "M") -> Optional[str]:
+ """Build a covers-host URL. Returns None for absent/negative IDs (-1 means
+ 'no image'). Callers should append ?default=false when existence matters."""
+ if value is None:
+ return None
+ if isinstance(value, int) and value < 0:
+ return None
+ return f"{COVERS_SERVER}/{kind}/{value}-{size}.jpg"
+
+
+def fmt_author(a: Dict) -> str:
+ name = a.get("name", a.get("title", "?"))
+ key = normalize_olid(a.get("key", ""))
+ birth = a.get("birth_date", "")
+ death = a.get("death_date", "")
+ years = f" ({birth}–{death})" if birth or death else ""
+ return f" {name}{years} [{key}]"
+
+
+def cmd_search(client, args):
+ parser = argparse.ArgumentParser(prog="openlibrary search")
+ parser.add_argument("--query", "-q", required=True)
+ parser.add_argument("--limit", type=int, default=20)
+ parser.add_argument("--offset", type=int, default=0)
+ parser.add_argument("--sort", default="", choices=["", "editions", "new", "old", "rating", "title"])
+ parser.add_argument("--lang", default="")
+ parser.add_argument("--availability", default="")
+ parsed, _ = parser.parse_known_args(args)
+
+ if client.dry_run:
+ emit(f"[dry-run] Would search: {parsed.query}", {"dry_run": True, "command": "search",
+ "query": parsed.query, "url": f"{client.server}/search.json"})
+ return
+
+ params: Dict[str, Any] = {"q": parsed.query, "limit": parsed.limit, "offset": parsed.offset}
+ if parsed.sort:
+ params["sort"] = parsed.sort
+ if parsed.lang:
+ params["lang"] = parsed.lang
+ data = client._get("/search.json", params) or {}
+
+ docs = data.get("docs", [])
+ if not docs:
+ # Malformed queries parse loosely and come back 200-empty; that is a
+ # result, not an API failure.
+ emit("No results.", {"total": 0, "results": []})
+ return
+
+ lines, out = [], []
+ for d in docs:
+ title = d.get("title", "?")
+ authors = ", ".join(d.get("author_name", [])) or "?"
+ year = d.get("first_publish_year", "")
+ year_str = f" ({year})" if year else ""
+ edition_count = d.get("edition_count", 0)
+ lines.append(f" {title:50}{year_str} — {authors} ({edition_count} editions)")
+ out.append({
+ "title": title, "authors": d.get("author_name", []),
+ "first_publish_year": year, "edition_count": edition_count,
+ "key": d.get("key", ""), "isbn": d.get("isbn", [])[:3],
+ "cover_edition": d.get("cover_edition_key", ""),
+ "has_fulltext": d.get("has_fulltext", False),
+ })
+
+ total = data.get("numFound", len(docs))
+ emit(f"{total} result(s):\n" + "\n".join(lines), {"total": total, "results": out})
+
+
+def cmd_isbn(client, args):
+ parser = argparse.ArgumentParser(prog="openlibrary isbn")
+ parser.add_argument("isbn", help="ISBN number")
+ parsed, _ = parser.parse_known_args(args)
+
+ if client.dry_run:
+ emit(f"[dry-run] Would lookup ISBN: {parsed.isbn}",
+ {"dry_run": True, "command": "isbn", "isbn": parsed.isbn,
+ "url": f"{client.server}/isbn/{parsed.isbn}.json",
+ "note": "endpoint answers 302; request follows redirects"})
+ return
+
+ data = client._get(f"/isbn/{parsed.isbn}.json")
+ if not data:
+ emit(f"ISBN {parsed.isbn} not found.", {"error": "not found", "isbn": parsed.isbn})
+ return
+
+ title = data.get("title", "?")
+ work_keys = [normalize_olid(w.get("key", "")) for w in data.get("works", [])]
+ edition_authors = [a for a in (data.get("authors") or []) if isinstance(a, dict)]
+
+ def _edition_author_key(a: Dict) -> str:
+ # Canonical editions nest flat ({"key": "/authors/OL…A"}); tolerate
+ # stray double-nested refs ({"author": {"key": ...}}) so one accessor
+ # survives both wiki shapes.
+ ref = a.get("key") or (a.get("author") or {}).get("key") or ""
+ return normalize_olid(ref)
+
+ def _author_label(a: Dict) -> str:
+ # Edition records often carry key-only author refs (no embedded name);
+ # fall back to the bare OL…A so output is never just '?'.
+ label = a.get("name") or _edition_author_key(a)
+ return label or "?"
+
+ author_names = ", ".join(_author_label(a) for a in edition_authors)
+ # JSON hands off bare OL…A keys (same shape as `work --json`); display
+ # labels remain a human-surface concern only.
+ author_keys = sorted({
+ k for k in (_edition_author_key(a) for a in edition_authors) if k
+ })
+
+ if not author_keys and work_keys:
+ # Some editions ship authors:null entirely. The authoritative author
+ # links live on the work (double-nested authors[].author.key) — one
+ # extra read beats reporting an unknown author.
+ work_data = client._get(f"/works/{work_keys[0]}.json") or {}
+ author_keys = [
+ normalize_olid((a.get("author") or {}).get("key", ""))
+ for a in (work_data.get("authors") or [])
+ if isinstance(a, dict)
+ ]
+ author_keys = [k for k in author_keys if k]
+ if not author_names:
+ author_names = ", ".join(author_keys)
+ author_names = author_names or "?"
+ pages = data.get("number_of_pages", data.get("pagination", "?"))
+ publishers = ", ".join(data.get("publishers", [])) or "?"
+ publish_date = data.get("publish_date", "?")
+ subjects = ", ".join(data.get("subjects", [])[:5]) or "(none)"
+ description = unwrap_text(data.get("description", ""))
+ desc_short = f"\n Description: {description[:300]}" if description else ""
+
+ edition_key = normalize_olid(data.get("key", ""))
+ covers = [c for c in data.get("covers", []) if isinstance(c, int) and c >= 0]
+
+ emit(
+ f"📖 {title}\n"
+ f" Author(s): {author_names}\n"
+ f" Pages: {pages} Published: {publish_date}\n"
+ f" Publisher: {publishers}\n"
+ f" Subjects: {subjects}"
+ f"{desc_short}\n"
+ f" Edition: {edition_key} Works: {', '.join(work_keys) or '?'}",
+ {"isbn": parsed.isbn, "title": title, "authors": author_keys,
+ "pages": pages, "publish_date": publish_date,
+ "publishers": [p for p in (data.get("publishers") or []) if p],
+ "subjects": data.get("subjects", []),
+ "description": description,
+ "edition_key": edition_key,
+ "work_keys": work_keys,
+ "cover_id": covers[0] if covers else None,
+ "cover_url": cover_url("b/id", covers[0]) if covers else None}
+ )
+
+
+def cmd_author(client, args):
+ parser = argparse.ArgumentParser(prog="openlibrary author")
+ parser.add_argument("key", help="Author key (e.g. OL23919A)")
+ parsed, _ = parser.parse_known_args(args)
+
+ key = normalize_olid(parsed.key)
+ if client.dry_run:
+ emit(f"[dry-run] Would fetch author {key}",
+ {"dry_run": True, "command": "author", "key": key,
+ "url": f"{client.server}/authors/{key}.json"})
+ return
+
+ data = client._get(f"/authors/{key}.json")
+ if not data:
+ emit(f"Author {key} not found.", {"error": "not found"})
+ return
+
+ name = data.get("name", "?")
+ birth = data.get("birth_date", "")
+ death = data.get("death_date", "")
+ years = f" ({birth}–{death})" if birth or death else ""
+ bio = unwrap_text(data.get("bio", ""))
+ bio_short = f"\n{bio[:500]}" if bio else ""
+ photos = [p for p in data.get("photos", []) if isinstance(p, int) and p >= 0]
+ photo = cover_url("a/id", photos[0]) if photos else None
+ photo_note = f"\n Photo: {photo}" if photo else ""
+ emit(
+ f"👤 {name}{years}{bio_short}{photo_note}",
+ {"key": key, "name": name, "birth_date": birth,
+ "death_date": death, "bio": bio,
+ "wikipedia": data.get("wikipedia", ""),
+ "personal_name": data.get("personal_name", ""),
+ "photo_url": photo}
+ )
+
+
+def cmd_work(client, args):
+ parser = argparse.ArgumentParser(prog="openlibrary work")
+ parser.add_argument("key", help="Work key (e.g. OL123W)")
+ parsed, _ = parser.parse_known_args(args)
+
+ key = normalize_olid(parsed.key)
+ if client.dry_run:
+ emit(f"[dry-run] Would fetch work {key}",
+ {"dry_run": True, "command": "work", "key": key,
+ "url": f"{client.server}/works/{key}.json"})
+ return
+
+ data = client._get(f"/works/{key}.json")
+ if not data:
+ emit(f"Work {key} not found.", {"error": "not found"})
+ return
+
+ title = data.get("title", "?")
+ # Rare records carry an explicit "authors": null; a default-value .get
+ # alone does not protect against iterating None.
+ authors = [a for a in (data.get("authors") or []) if isinstance(a, dict)]
+ author_keys = [
+ normalize_olid((a.get("author") or {}).get("key", ""))
+ for a in authors
+ if (a.get("author") or {}).get("key")
+ ]
+ author_str = ", ".join(author_keys) or "?"
+ desc = unwrap_text(data.get("description", ""))
+ desc_short = f"\n{desc[:500]}" if desc else ""
+ subjects = ", ".join(data.get("subjects", [])[:5]) or "(none)"
+ covers = [c for c in data.get("covers", []) if isinstance(c, int) and c >= 0]
+
+ emit(
+ f"📖 {title}\n"
+ f" Author(s): {author_str}\n"
+ f" Subjects: {subjects}{desc_short}",
+ {"key": key, "title": title, "authors": author_keys,
+ "description": desc, "subjects": data.get("subjects", []),
+ "cover_url": cover_url("b/id", covers[0]) if covers else None}
+ )
+
+
+def cmd_search_authors(client, args):
+ parser = argparse.ArgumentParser(prog="openlibrary search-authors")
+ parser.add_argument("--query", "-q", required=True)
+ parser.add_argument("--limit", type=int, default=20)
+ parser.add_argument("--offset", type=int, default=0)
+ parsed, _ = parser.parse_known_args(args)
+
+ if client.dry_run:
+ emit(f"[dry-run] Would search authors: {parsed.query}",
+ {"dry_run": True, "command": "search-authors", "query": parsed.query,
+ "url": f"{client.server}/search/authors.json"})
+ return
+
+ data = client._get("/search/authors.json", {"q": parsed.query, "limit": parsed.limit, "offset": parsed.offset}) or {}
+ docs = data.get("docs", [])
+ if not docs:
+ emit("No authors found.", {"results": []})
+ return
+
+ lines, out = [], []
+ for d in docs:
+ name = d.get("name", "?")
+ key = normalize_olid(d.get("key", "")) or "?"
+ birth = d.get("birth_date", "") or ""
+ death = d.get("death_date", "") or ""
+ years = f" ({birth}–{death})" if birth or death else ""
+ top_work = d.get("top_work", "")
+ work_str = f" — {top_work}" if top_work else ""
+ lines.append(f" {name:30}{years} [{key}]{work_str}")
+ out.append({"name": name, "key": key, "birth_date": birth, "death_date": death,
+ "top_work": top_work, "work_count": d.get("work_count", 0)})
+
+ total = data.get("numFound", len(docs))
+ emit(f"{total} author(s):\n" + "\n".join(lines), {"total": total, "results": out})
+
+
+def cmd_editions(client, args):
+ """List every edition of a work via /works//editions.json."""
+ parser = argparse.ArgumentParser(prog="openlibrary editions")
+ parser.add_argument("key", help="Work key (e.g. OL81699W)")
+ parser.add_argument("--limit", type=int, default=50)
+ parser.add_argument("--offset", type=int, default=0)
+ parsed, _ = parser.parse_known_args(args)
+
+ key = normalize_olid(parsed.key)
+ if client.dry_run:
+ emit(f"[dry-run] Would list editions of work {key}",
+ {"dry_run": True, "command": "editions", "key": key,
+ "url": f"{client.server}/works/{key}/editions.json",
+ "params": {"limit": parsed.limit, "offset": parsed.offset}})
+ return
+
+ data = client._get(f"/works/{key}/editions.json",
+ {"limit": parsed.limit, "offset": parsed.offset})
+ if not data:
+ emit(f"Work {key} not found.", {"error": "not found"})
+ return
+
+ entries = data.get("entries", [])
+ size = data.get("size", len(entries))
+ if not entries:
+ emit(f"No editions listed for {key}.", {"size": 0, "editions": []})
+ return
+
+ lines, out = [], []
+ for e in entries:
+ ekey = normalize_olid(e.get("key", ""))
+ title = e.get("title", "?")
+ pub = e.get("publish_date", "?")
+ publisher = ", ".join(e.get("publishers", [])[:2]) or "?"
+ isbn13 = (e.get("isbn_13") or ["?"])[0]
+ lines.append(f" [{ekey}] {title} — {publisher}, {pub} (ISBN-13: {isbn13})")
+ out.append({"key": ekey, "title": title, "publish_date": pub,
+ "publishers": e.get("publishers", []),
+ "isbn_10": e.get("isbn_10", []), "isbn_13": e.get("isbn_13", []),
+ "pages": e.get("number_of_pages")})
+
+ links = data.get("links", {})
+ next_link = links.get("next", "")
+ more = ""
+ if next_link:
+ more = f"\n(more editions available; retry with --offset {parsed.offset + parsed.limit})"
+ emit(f"{size} edition(s) of {key}:\n" + "\n".join(lines) + more,
+ {"size": size, "editions": out, "next_offset":
+ parsed.offset + parsed.limit if next_link else None})
+
+
+def cmd_ratings(client, args):
+ """Community aggregates for a work: ratings + bookshelf counts."""
+ parser = argparse.ArgumentParser(prog="openlibrary ratings")
+ parser.add_argument("key", help="Work key (e.g. OL45804W)")
+ parsed, _ = parser.parse_known_args(args)
+
+ key = normalize_olid(parsed.key)
+ if client.dry_run:
+ emit(f"[dry-run] Would fetch ratings and shelf counts for work {key}",
+ {"dry_run": True, "command": "ratings", "key": key,
+ "urls": [f"{client.server}/works/{key}/ratings.json",
+ f"{client.server}/works/{key}/bookshelves.json"]})
+ return
+
+ ratings = client._get(f"/works/{key}/ratings.json") or {}
+ shelves = client._get(f"/works/{key}/bookshelves.json") or {}
+
+ summary = ratings.get("summary", {})
+ counts = ratings.get("counts", {})
+ shelf_counts = shelves.get("counts", {})
+ if not summary and not shelf_counts:
+ emit(f"No community data for work {key}.",
+ {"error": "no data", "key": key})
+ return
+
+ avg = summary.get("average")
+ avg_str = f"{avg:.2f}" if isinstance(avg, (int, float)) else "n/a"
+ rated = summary.get("count", 0)
+ wtr = shelf_counts.get("want_to_read", 0)
+ emit(
+ f"⭐ Work {key}: {avg_str} avg from {rated} rating(s)\n"
+ f" Distribution: " + ", ".join(f"{s}★={counts.get(s, 0)}" for s in ("5", "4", "3", "2", "1")) + "\n"
+ f" Shelves: want_to_read={wtr} currently_reading={shelf_counts.get('currently_reading', 0)} "
+ f"already_read={shelf_counts.get('already_read', 0)}",
+ {"key": key,
+ "average": avg, "ratings_count": rated,
+ "rating_distribution": counts,
+ "bookshelves": shelf_counts}
+ )
+
+
+def main():
+ global GLOBAL_FLAGS, QUIET
+ GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
+ if GLOBAL_FLAGS.get("quiet", False):
+ QUIET = True
+ if GLOBAL_FLAGS.get("json", False):
+ warnings.simplefilter("ignore")
+
+ parser = argparse.ArgumentParser(
+ prog="openlibrary",
+ description="Open Library book metadata from the terminal. No API key required.",
+ epilog="Global flags work anywhere: openlibrary --json search --query 'dune'"
+ )
+ sub = parser.add_subparsers(dest="command")
+
+ p_search = sub.add_parser("search", help="Search books")
+ p_search.add_argument("--query", "-q", required=True)
+ p_search.add_argument("--limit", type=int, default=20)
+ p_search.add_argument("--offset", type=int, default=0)
+ p_search.add_argument("--sort", default="", choices=["", "editions", "new", "old", "rating", "title"])
+ p_search.add_argument("--lang")
+ p_search.add_argument("--availability")
+
+ p_sa = sub.add_parser("search-authors", help="Search authors")
+ p_sa.add_argument("--query", "-q", required=True)
+ p_sa.add_argument("--limit", type=int, default=20)
+ p_sa.add_argument("--offset", type=int, default=0)
+
+ sub.add_parser("author", help="Get author details").add_argument("key")
+ sub.add_parser("work", help="Get work details").add_argument("key")
+ sub.add_parser("isbn", help="Lookup by ISBN").add_argument("isbn")
+
+ p_ed = sub.add_parser("editions", help="List all editions of a work")
+ p_ed.add_argument("key")
+ p_ed.add_argument("--limit", type=int, default=50)
+ p_ed.add_argument("--offset", type=int, default=0)
+
+ sub.add_parser("ratings", help="Community ratings and shelf counts for a work").add_argument("key")
+
+ args = parser.parse_args(filtered_argv[1:])
+ if not args.command:
+ parser.print_help()
+ sys.exit(1)
+
+ client = OpenLibraryClient(dry_run=GLOBAL_FLAGS.get("dry_run", False))
+
+ cmd_map = {
+ "search": cmd_search,
+ "search-authors": cmd_search_authors,
+ "author": cmd_author,
+ "work": cmd_work,
+ "isbn": cmd_isbn,
+ "editions": cmd_editions,
+ "ratings": cmd_ratings,
+ }
+ handler = cmd_map.get(args.command)
+ if not handler:
+ parser.print_help()
+ sys.exit(1)
+
+ remaining = filtered_argv[filtered_argv.index(args.command) + 1:]
+ handler(client, remaining)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/openlibrary/scripts/test_openlibrary.py b/openlibrary/scripts/test_openlibrary.py
new file mode 100644
index 0000000..f892d0e
--- /dev/null
+++ b/openlibrary/scripts/test_openlibrary.py
@@ -0,0 +1,536 @@
+"""Offline tests for the bundled openlibrary CLI (scripts/openlibrary).
+
+Four test classes per skill-builder contract:
+ 1. --help output
+ 2. argument-error paths
+ 3. --dry-run behavior
+ 4. mocked-client logic (requests mocked at the client-call site)
+
+Plus one env-guarded live class: Open Library is a keyless public API, so a
+small bounded set of live GETs runs ONLY when OPENLIBRARY_LIVE_TESTS=1; they
+skip cleanly otherwise (proxy-trap reruns pass with them skipped).
+"""
+
+import contextlib
+import importlib.machinery
+import importlib.util
+import io
+import json
+import os
+import pathlib
+import unittest
+from unittest import mock
+
+import requests
+
+SCRIPT = pathlib.Path(__file__).resolve().parent / "openlibrary"
+LOADER = importlib.machinery.SourceFileLoader("openlibrary_cli", str(SCRIPT))
+SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER)
+ol_cli = importlib.util.module_from_spec(SPEC)
+LOADER.exec_module(ol_cli)
+
+EDITION_KEY = "/books/" + "OL34854896M"
+WORK_KEY = "/works/" + "OL1168083W"
+AUTHOR_KEY = "/authors/" + "OL118077A"
+
+
+def run_cli(*argv):
+ """Invoke main() with argv[0] prepended; returns (exit_code, stdout, stderr)."""
+ out, err = io.StringIO(), io.StringIO()
+ code = 0
+ with mock.patch.object(ol_cli.sys, "argv", ["openlibrary", *argv]):
+ with mock.patch.object(ol_cli.sys, "stdout", out), \
+ mock.patch.object(ol_cli.sys, "stderr", err), \
+ contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ try:
+ ol_cli.main()
+ except SystemExit as exc:
+ code = exc.code if isinstance(exc.code, int) else 0
+ return code, out.getvalue(), err.getvalue()
+
+
+class FakeResponse:
+ def __init__(self, status_code=200, payload=None, text="", headers=None,
+ url="https://openlibrary.org/x"):
+ self.status_code = status_code
+ self._payload = payload
+ self.text = text or (json.dumps(payload) if payload is not None else "")
+ self.headers = headers or {}
+ self.url = url
+
+ def json(self):
+ if self._payload is None:
+ raise ValueError("no json")
+ return self._payload
+
+
+# === Class 1: help output ===
+
+
+class HelpOutputTests(unittest.TestCase):
+ def test_help_lists_all_subcommands(self):
+ code, out, _ = run_cli("--help")
+ self.assertEqual(code, 0)
+ for noun in ("search", "search-authors", "author", "work",
+ "isbn", "editions", "ratings"):
+ self.assertIn(noun, out)
+
+ def test_help_mentions_keyless_setup(self):
+ _, out, _ = run_cli("--help")
+ self.assertIn("No API key", out)
+
+ def test_subcommand_help_mentions_flags(self):
+ _, out, _ = run_cli("search", "--help")
+ for flag in ("--query", "--limit", "--offset", "--sort", "--lang"):
+ self.assertIn(flag, out)
+
+ def test_editions_help_documents_pagination(self):
+ _, out, _ = run_cli("editions", "--help")
+ self.assertIn("--limit", out)
+ self.assertIn("--offset", out)
+
+
+# === Class 2: argument errors ===
+
+
+class ArgumentErrorTests(unittest.TestCase):
+ def test_search_requires_query(self):
+ code, _, err = run_cli("search")
+ self.assertEqual(code, 2)
+ self.assertIn("--query", err)
+
+ def test_no_command_prints_help_and_exits(self):
+ code, out, _ = run_cli()
+ self.assertEqual(code, 1)
+ self.assertIn("usage:", out)
+
+ def test_unknown_subcommand_fails(self):
+ code, _, err = run_cli("frobnicate")
+ self.assertEqual(code, 2)
+ self.assertIn("invalid choice", err)
+
+ def test_sort_rejects_unknown_values_client_side(self):
+ # A bogus sort value makes Open Library return a plain-text HTTP 500,
+ # so the CLI validates sort choices before ever sending.
+ code, _, err = run_cli("search", "--query", "dune", "--sort", "bogus")
+ self.assertEqual(code, 2)
+ self.assertIn("--sort", err)
+
+
+# === Class 3: dry-run behavior ===
+
+
+class DryRunTests(unittest.TestCase):
+ def test_dry_run_isbn_reports_302_resolution_plan(self):
+ code, out, _ = run_cli("--dry-run", "--json", "isbn", "9780451524935")
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ self.assertTrue(plan["dry_run"])
+ self.assertEqual(plan["command"], "isbn")
+ self.assertIn("/isbn/9780451524935.json", plan["url"])
+ self.assertIn("302", plan["note"])
+
+ def test_dry_run_editions_emits_query_params(self):
+ code, out, _ = run_cli("--json", "--dry-run", "editions",
+ WORK_KEY, "--limit", "5")
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ self.assertEqual(plan["key"], "OL1168083W")
+ self.assertEqual(plan["params"]["limit"], 5)
+
+ def test_dry_run_ratings_plans_both_endpoints(self):
+ code, out, _ = run_cli("--dry-run", "--json", "ratings", "OL45804W")
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ joined = " ".join(plan["urls"])
+ self.assertIn("/ratings.json", joined)
+ self.assertIn("/bookshelves.json", joined)
+
+ def test_dry_run_never_touches_network(self):
+ with mock.patch.object(requests, "get") as req:
+ code, _, _ = run_cli("--dry-run", "work", WORK_KEY)
+ self.assertEqual(code, 0)
+ req.assert_not_called()
+
+
+# === Class 4: mocked client logic ===
+
+
+EDITION_RECORD = {
+ "type": {"key": "/type/edition"},
+ "key": EDITION_KEY,
+ "title": "Nineteen Eighty-Four",
+ "authors": None, # real records ship authors:null sometimes
+ "works": [{"key": WORK_KEY}],
+ "covers": [12054527, -1],
+ "number_of_pages": 328,
+ "publish_date": "1993?",
+ "publishers": ["Signet Classics"],
+ "description": {"type": "/type/text", "value": "A dystopian classic."},
+}
+
+WORK_RECORD = {
+ "type": {"key": "/type/work"},
+ "key": WORK_KEY,
+ "title": "Nineteen Eighty-Four",
+ "authors": [{"author": {"key": AUTHOR_KEY},
+ "type": {"key": "/type/author_role"}}],
+ "subjects": ["Totalitarianism"],
+ "description": "A dystopian classic.",
+ "covers": [-1],
+}
+
+
+class MockedClientTests(unittest.TestCase):
+ """Mock requests.get at the client-call site; zero network in this class."""
+
+ def setUp(self):
+ ol_cli.QUIET = False
+ ol_cli.GLOBAL_FLAGS.update(json=True, dry_run=False, quiet=False)
+
+ def test_isbn_surfaces_resolved_edition_and_work_keys(self):
+ # The edition record carries works[] but authors:null, so the CLI
+ # follows the work link once to recover author keys.
+ edition = FakeResponse(
+ 200, EDITION_RECORD,
+ url="https://openlibrary.org/books/" + "OL34854896M.json")
+ work = FakeResponse(200, WORK_RECORD)
+ with mock.patch.object(requests, "get", side_effect=[edition, work]) as req:
+ code, out, _ = run_cli("--json", "isbn", "9780451524935")
+ self.assertEqual(code, 0)
+ self.assertEqual(req.call_count, 2)
+ data = json.loads(out)
+ self.assertEqual(data["edition_key"], "OL34854896M")
+ self.assertEqual(data["work_keys"], ["OL1168083W"])
+ # Cover URLs point at the SEPARATE covers host, skipping -1 placeholders.
+ self.assertEqual(data["cover_url"], (
+ "https://covers.openlibrary.org/b/id/"
+ + "12054527-M.jpg"))
+
+ def test_isbn_falls_back_to_work_authors_when_edition_has_none(self):
+ edition = FakeResponse(200, EDITION_RECORD)
+ work = FakeResponse(200, WORK_RECORD)
+ with mock.patch.object(requests, "get", side_effect=[edition, work]) as req:
+ code, out, _ = run_cli("--json", "isbn", "9780451524935")
+ self.assertEqual(code, 0)
+ self.assertEqual(req.call_count, 2)
+ self.assertTrue(req.call_args_list[1].args[0].endswith(WORK_KEY + ".json"))
+ self.assertEqual(json.loads(out)["authors"], ["OL118077A"])
+
+ def test_work_with_explicit_null_authors_returns_empty_array(self):
+ # Real-world work records sometimes carry an explicit "authors": null;
+ # the CLI must render '?' and hand off [] instead of raising TypeError.
+ record = dict(WORK_RECORD, authors=None)
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, record)):
+ code, out, _ = run_cli("--json", "work", WORK_KEY)
+ self.assertEqual(code, 0)
+ data = json.loads(out)
+ self.assertIsInstance(data["authors"], list)
+ self.assertEqual(data["authors"], [])
+ ol_cli.GLOBAL_FLAGS.update(json=False)
+ try:
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, record)):
+ human_code, human_out, _ = run_cli("work", WORK_KEY)
+ finally:
+ ol_cli.GLOBAL_FLAGS.update(json=True)
+ self.assertEqual(human_code, 0)
+ self.assertIn("?", human_out)
+
+ def test_isbn_and_work_emit_same_json_type_under_authors_key(self):
+ # Symmetry contract: any "authors"-keyed field across CLI commands is a
+ # JSON array of bare OL…A key strings. Downstream jq pipelines can
+ # treat .authors identically regardless of the entry command.
+ edition_authored = {
+ "type": {"key": "/type/edition"},
+ "key": EDITION_KEY,
+ "title": "Nineteen Eighty-Four",
+ "authors": [{"key": AUTHOR_KEY}],
+ "works": [{"key": WORK_KEY}],
+ }
+ edition = FakeResponse(200, edition_authored)
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, WORK_RECORD)):
+ _, work_out, _ = run_cli("--json", "work", WORK_KEY)
+ _, isbn_out, _ = run_cli("--json", "isbn", "9780451524935")
+ work_data = json.loads(work_out)
+ isbn_data = json.loads(isbn_out)
+ for data in (work_data, isbn_data):
+ self.assertIsInstance(data["authors"], list)
+ self.assertNotIsInstance(data["authors"], str)
+ self.assertTrue(all(
+ isinstance(k, str) and k.endswith("A")
+ for k in data["authors"]))
+ self.assertEqual(isbn_data["authors"], ["OL118077A"])
+ self.assertEqual(work_data["authors"], ["OL118077A"])
+
+ def test_edition_key_only_author_refs_become_bare_keys_in_json(self):
+ edition_authored = {
+ "type": {"key": "/type/edition"},
+ "key": EDITION_KEY,
+ "title": "Nineteen Eighty-Four",
+ "authors": [{"key": "/authors/" + "OL118077A"},
+ {"key": "/authors/" + "OL7862984A"}],
+ "works": [{"key": WORK_KEY}],
+ }
+ edition = FakeResponse(200, edition_authored)
+ with mock.patch.object(requests, "get", return_value=edition) as req:
+ code, out, _ = run_cli("--json", "isbn", "9780451524935")
+ self.assertEqual(code, 0)
+ self.assertEqual(req.call_count, 1) # no fallback read needed
+ data = json.loads(out)
+ self.assertEqual(data["authors"],
+ sorted(["OL118077A", "OL7862984A"]))
+
+ def test_isbn_human_output_still_shows_comma_joined_labels(self):
+ # The display surface is unchanged: comma-joined names on stdout while
+ # --json carries the array shape.
+ edition = FakeResponse(200, {
+ "type": {"key": "/type/edition"}, "key": EDITION_KEY,
+ "title": "Nineteen Eighty-Four",
+ "authors": [{"name": "George Orwell"}, {"name": "Thomas Pynchon"}],
+ "works": [{"key": WORK_KEY}]})
+ ol_cli.GLOBAL_FLAGS.update(json=False)
+ try:
+ with mock.patch.object(requests, "get", return_value=edition):
+ code, out, _ = run_cli("isbn", "9780451524935")
+ finally:
+ ol_cli.GLOBAL_FLAGS.update(json=True)
+ self.assertEqual(code, 0)
+ self.assertIn("George Orwell, Thomas Pynchon", out)
+
+ def test_work_json_authors_are_bare_keys_array(self):
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, WORK_RECORD)):
+ code, out, _ = run_cli("--json", "work", WORK_KEY)
+ self.assertEqual(code, 0)
+ data = json.loads(out)
+ self.assertIsInstance(data["authors"], list)
+ self.assertEqual(data["authors"], ["OL118077A"])
+ self.assertRegex(data["authors"][0], r"^OL\d+A$")
+
+ def test_isbn_work_author_pipeline_handoff_is_executable(self):
+ """Mock the documented ISBN -> work -> author jq handoff end to end."""
+ edition = FakeResponse(200, EDITION_RECORD)
+ work = FakeResponse(200, WORK_RECORD)
+ author = FakeResponse(200, {"name": "George Orwell", "bio": "Writer"})
+ with mock.patch.object(requests, "get",
+ side_effect=[edition, work, work, author]) as req:
+ isbn_code, isbn_out, _ = run_cli("--json", "isbn", "9780451524935")
+ isbn_data = json.loads(isbn_out)
+ work_code, work_out, _ = run_cli(
+ "--json", "work", isbn_data["work_keys"][0])
+ work_data = json.loads(work_out)
+ author_code, author_out, _ = run_cli(
+ "--json", "author", work_data["authors"][0])
+ self.assertEqual((isbn_code, work_code, author_code), (0, 0, 0))
+ self.assertEqual(req.call_count, 4)
+ self.assertEqual(work_data["authors"][0], "OL118077A")
+ self.assertEqual(json.loads(author_out)["key"], "OL118077A")
+
+ def test_work_pipeline_handoff_fields_have_stable_json_types(self):
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, WORK_RECORD)):
+ _, out, _ = run_cli("--json", "work", WORK_KEY)
+ data = json.loads(out)
+ self.assertIsInstance(data["key"], str)
+ self.assertIsInstance(data["title"], str)
+ self.assertIsInstance(data["authors"], list)
+ self.assertTrue(all(isinstance(key, str) for key in data["authors"]))
+ self.assertIsInstance(data["subjects"], list)
+
+ def test_work_record_with_non_dict_author_entries_is_tolerated(self):
+ # Malformed wiki payloads can smuggle bare strings into authors[];
+ # tolerate-and-filter beats crash.
+ record = dict(WORK_RECORD,
+ authors=[{"author": {"key": AUTHOR_KEY}}, None])
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, record)):
+ code, out, _ = run_cli("--json", "work", WORK_KEY)
+ self.assertEqual(code, 0)
+ self.assertEqual(json.loads(out)["authors"], ["OL118077A"])
+
+ def test_isbn_handoff_fields_have_stable_json_types(self):
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(
+ 200, dict(EDITION_RECORD, authors=None),
+ url="https://openlibrary.org/books/x.json")):
+ _, out, _ = run_cli("--json", "isbn", "9780451524935")
+ data = json.loads(out)
+ for key in ("edition_key", "title", "description"):
+ self.assertIsInstance(data[key], str)
+ for key in ("authors", "work_keys", "publishers", "subjects"):
+ self.assertIsInstance(data[key], list)
+
+ def test_merge_redirect_stub_in_http_200_is_followed_with_json_suffix(self):
+ # Merged-away keys answer 200 with {type:/type/redirect, location},
+ # NOT a 3xx — the client must detect the stub and refetch. Stub
+ # locations are bare keys without .json; extension-less URLs redirect
+ # to HTML pages, so the client must append the suffix itself.
+ stub = FakeResponse(200, {"type": {"key": "/type/redirect"},
+ "location": WORK_KEY})
+ work = FakeResponse(200, WORK_RECORD)
+ with mock.patch.object(requests, "get", side_effect=[stub, work]) as req:
+ code, out, _ = run_cli("--json", "work", "OL24776360W")
+ self.assertEqual(code, 0)
+ self.assertEqual(
+ req.call_args_list[1].args[0],
+ "https://openlibrary.org" + WORK_KEY + ".json")
+ self.assertEqual(json.loads(out)["title"], "Nineteen Eighty-Four")
+
+ def test_redirect_stub_chain_beyond_hop_budget_warns_instead_of_silence(self):
+ # A work that keeps resolving into further merge stubs exhausts the
+ # bounded walk; the CLI must say so (stderr warning) rather than emit
+ # an unexplained /type/redirect payload.
+ stub = FakeResponse(200, {"type": {"key": "/type/redirect"},
+ "location": WORK_KEY})
+ responses = [stub] * (ol_cli.MAX_REDIRECT_HOPS + 1)
+ with mock.patch.object(requests, "get",
+ side_effect=responses) as req:
+ code, out, err = run_cli("--json", "work", "OL24776360W")
+ self.assertEqual(code, 0)
+ self.assertEqual(req.call_count, ol_cli.MAX_REDIRECT_HOPS + 1)
+ self.assertIn("did not resolve", err)
+ # Without the resolution the command degrades to an empty-shaped
+ # record; the stderr warning is what keeps that from being silent.
+ self.assertEqual(json.loads(out), {
+ "key": "OL24776360W", "title": "?", "authors": [],
+ "description": "", "subjects": [], "cover_url": None})
+
+ def test_text_wrapper_dict_is_unwrapped(self):
+ self.assertEqual(ol_cli.unwrap_text({"type": "/type/text", "value": "hi"}), "hi")
+ self.assertEqual(ol_cli.unwrap_text("plain"), "plain")
+ self.assertEqual(ol_cli.unwrap_text(None), "")
+
+ def test_normalize_olid_accepts_bare_and_path_forms(self):
+ self.assertEqual(ol_cli.normalize_olid("/works/" + "OL123W"), "OL123W")
+ self.assertEqual(ol_cli.normalize_olid("OL23919A"), "OL23919A")
+ self.assertEqual(ol_cli.normalize_olid(""), "")
+
+ def test_cover_url_rejects_negative_placeholder_ids(self):
+ self.assertIsNone(ol_cli.cover_url("b/id", -1))
+ self.assertIsNone(ol_cli.cover_url("a/id", None))
+ self.assertTrue(ol_cli.cover_url("b/id", 12054527).startswith(
+ "https://covers.openlibrary.org/b/id/"))
+
+ def test_search_sends_query_and_sort_params(self):
+ payload = {"numFound": 1, "docs": [
+ {"key": "/works/" + "OL1W", "title": "Dune",
+ "author_name": ["Frank Herbert"], "first_publish_year": 1965,
+ "edition_count": 90, "cover_edition_key": "OL1M",
+ "has_fulltext": True}]}
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, payload)) as req:
+ code, out, _ = run_cli("--json", "search", "--query", "dune",
+ "--limit", "2", "--sort", "editions")
+ self.assertEqual(code, 0)
+ req.assert_called_once()
+ sent = req.call_args.kwargs["params"]
+ self.assertEqual(sent["q"], "dune")
+ self.assertEqual(sent["sort"], "editions")
+ data = json.loads(out)
+ self.assertEqual(data["total"], 1)
+ self.assertEqual(data["results"][0]["key"], "/works/" + "OL1W")
+
+ def test_empty_search_results_are_success_not_error(self):
+ # Malformed queries parse loosely and return 200-empty; the CLI must
+ # report zero results without failing.
+ payload = {"numFound": 0, "docs": []}
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, payload)):
+ code, out, _ = run_cli("--json", "search", "--query", 'title:"unclosed')
+ self.assertEqual(code, 0)
+ self.assertEqual(json.loads(out)["total"], 0)
+
+ def test_editions_parses_entries_and_computes_next_offset(self):
+ payload = {"size": 6,
+ "links": {"self": "/works/x/editions.json?limit=3",
+ "next": "/works/x/editions.json?limit=3&offset=3"},
+ "entries": [
+ {"key": "/books/" + "OL1M", "title": "Ed. One",
+ "publishers": ["Ace"], "publish_date": "1965",
+ "isbn_13": ["9780000000002"]},
+ {"key": "/books/" + "OL2M", "title": "Ed. Two",
+ "publishers": [], "publish_date": "1980", "isbn_13": []},
+ {"key": "/books/" + "OL3M", "title": "Ed. Three",
+ "publishers": ["NEL"], "publish_date": "1974",
+ "isbn_13": ["9781111111113"]},
+ ]}
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(200, payload)) as req:
+ code, out, _ = run_cli("--json", "editions", "OL81699W", "--limit", "3")
+ self.assertEqual(code, 0)
+ self.assertEqual(req.call_args.kwargs["params"]["offset"], 0)
+ data = json.loads(out)
+ self.assertEqual(data["size"], 6)
+ self.assertEqual(len(data["editions"]), 3)
+ self.assertEqual(data["next_offset"], 3)
+
+ def test_ratings_joins_ratings_and_bookshelves(self):
+ ratings = FakeResponse(200, {"summary": {"average": 3.97, "count": 119},
+ "counts": {"5": 56, "4": 29}})
+ shelves = FakeResponse(200, {"counts": {"want_to_read": 1191,
+ "currently_reading": 97}})
+ with mock.patch.object(requests, "get", side_effect=[ratings, shelves]) as req:
+ code, out, _ = run_cli("--json", "ratings", "OL45804W")
+ self.assertEqual(code, 0)
+ self.assertTrue(req.call_args_list[0].args[0].endswith("/ratings.json"))
+ self.assertTrue(req.call_args_list[1].args[0].endswith("/bookshelves.json"))
+ data = json.loads(out)
+ self.assertAlmostEqual(data["average"], 3.97)
+ self.assertEqual(data["bookshelves"]["want_to_read"], 1191)
+
+ def test_404_reports_not_found_without_traceback(self):
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(404, None)):
+ code, out, _ = run_cli("work", "OL999999999W")
+ self.assertEqual(code, 0)
+ self.assertIn("not found", out.lower())
+
+ def test_server_error_names_status_and_dies(self):
+ # run_cli captures SystemExit; a 500 must exit 1 with the status named.
+ with mock.patch.object(requests, "get",
+ return_value=FakeResponse(500, text="Internal Server Error")):
+ code, _, err = run_cli("work", "OL1W")
+ self.assertEqual(code, 1)
+ self.assertIn("500", err)
+
+ def test_user_agent_carries_mailto_when_email_configured(self):
+ original = ol_cli.ENV_EMAIL
+ try:
+ ol_cli.ENV_EMAIL = "reader@example.org"
+ resp = FakeResponse(200, WORK_RECORD)
+ with mock.patch.object(requests, "get", return_value=resp) as req:
+ run_cli("work", WORK_KEY)
+ ua = req.call_args.kwargs["headers"]["User-Agent"]
+ self.assertIn("(mailto:reader@example.org)", ua)
+ finally:
+ ol_cli.ENV_EMAIL = original
+
+
+# === Class 5: env-guarded live probes (keyless public API) ===
+# Run only with OPENLIBRARY_LIVE_TESTS=1; skipped otherwise so the proxy-trap
+# rerun proves zero egress for everything above.
+
+
+@unittest.skipUnless(os.getenv("OPENLIBRARY_LIVE_TESTS") == "1",
+ "live probes disabled (set OPENLIBRARY_LIVE_TESTS=1)")
+class LiveGuardedTests(unittest.TestCase):
+ def test_live_isbn_resolves_through_302(self):
+ code, out, _ = run_cli("--json", "isbn", "9780451524935")
+ self.assertEqual(code, 0)
+ data = json.loads(out)
+ self.assertRegex(data["edition_key"], r"^OL\d+M$")
+ self.assertRegex(data["work_keys"][0], r"^OL\d+W$")
+ self.assertTrue(data["title"])
+
+ def test_live_editions_listing_returns_entries(self):
+ code, out, _ = run_cli("--json", "editions", "OL81699W", "--limit", "3")
+ self.assertEqual(code, 0)
+ data = json.loads(out)
+ self.assertGreaterEqual(data["size"], 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/peertube/README.md b/peertube/README.md
index 7ff9434..176181b 100644
--- a/peertube/README.md
+++ b/peertube/README.md
@@ -1,35 +1,78 @@
# PeerTube — Federated Video from the Terminal
-Browse videos, channels, and server info on any PeerTube instance. Search across the fediverse, list channels, check your account stats, and manage authentication.
+Browse any PeerTube instance from the command line: latest videos, video detail, comment
+threads, channels and accounts, instance stats, and OAuth2 login for your own account —
+plus fediverse-wide search through SepiaSearch.
## Why Install This Skill
-When your agent loads this skill, it can **navigate the federated video universe** without a browser. That means:
+When your agent loads this skill, it can **navigate the federated video universe** without
+a browser. That means:
-- **Browse videos** — recent uploads from any instance
-- **Search across instances** — find content in the fediverse
-- **Explore channels** — list channels and their videos
-- **Check server info** — instance name, description, user/video/view stats
-- **Authenticate** — OAuth2 login with token persistence
+- **Browse any instance** — latest videos with real offset pagination (the API has no
+ `page` parameter, and most naive wrappers get this wrong)
+- **Search the right scope** — instance-local search or the whole fediverse via
+ SepiaSearch, with the `searchTarget` semantics documented instead of guessed
+- **Inspect videos deeply** — full metadata, comment threads (the hyphenated
+ `/comment-threads` route), channels, and accounts by handle (`name@host`)
+- **Check instance health** — name, description, and user/video/view counters composed
+ from `/config/about` + `/server/stats` anonymously
+- **Authenticate safely** — OAuth2 password grant with per-instance, owner-only token
+ persistence, automatic refresh, and proper server-side revocation on logout
+- **Avoid the traps** — masked `client_secret` responses, token lifetimes that vary per
+ instance, 2FA `x-peertube-otp`, rate-limit headers, RFC7807 error bodies
+
+Every command is read-only except `login`/`logout`, and `--dry-run` previews any request
+without touching the network.
## What You Get
-| Directory | Purpose |
-|-----------|---------|
-| `SKILL.md` | Complete command reference with auth setup |
-| `scripts/peertube-cli` | CLI tool for PeerTube API |
+| Path | Purpose |
+|------|---------|
+| `SKILL.md` | Complete command reference with setup, gotchas, and recipes |
+| `scripts/peertube` | CLI for PeerTube API operations (`--json`, `--dry-run`, `--verbose`) |
+| `scripts/test_peertube.py` | Offline test suite (all HTTP mocked, zero egress) |
+| `references/auth-and-tokens.md` | The full OAuth2 flow, secret masking, token hygiene |
+| `references/search-and-discovery.md` | Instance-local vs SepiaSearch search scopes |
+| `references/endpoint-catalog.md` | Endpoint-by-endpoint parameters and response shapes |
+| `references/gotchas-field-guide.md` | Failure signatures and version drift |
+| `references/worked-recipes.md` | Multi-step CLI/jq and curl workflows |
+| `evals/evals.json` | Behavioral eval cases including negative triggers |
## Quick Start
```bash
-export PEERTUBE_SERVER="https://your-instance.example.com"
-peertube-cli auth login --username "myuser" --password "mypassword"
+export PEERTUBE_SERVER="https://" # any PeerTube instance
+scripts/peertube server # instance stats, anonymous
+scripts/peertube videos --limit 5 --json
+scripts/peertube search --query "linux" # searches THIS instance
+```
+
+Fediverse-wide search through SepiaSearch (same API shape, wider index):
+
+```bash
+PEERTUBE_SERVER="https://sepiasearch.org" scripts/peertube search --query "linux"
+```
+
+Optional login for your own account commands:
+
+```bash
+scripts/peertube login --username "" --prompt
+scripts/peertube me --json | jq '.role.label'
+scripts/peertube logout # revokes server-side + deletes token file
```
## Triggers
-Load this for PeerTube, federated video, decentralized video platforms, or browsing PeerTube content.
+Load this when asking about PeerTube, federated video, decentralized video platforms,
+SepiaSearch, browsing a specific PeerTube instance's videos or channels, or PeerTube API
+authentication.
## Requirements
-Python 3.8+ with `requests` library.
+Python 3.8+ with `requests`. One thing this skill always needs from you: **an instance
+host** — export `PEERTUBE_SERVER` (e.g. `https://`) or pass
+`--server https://...` per command, since PeerTube is federated and every command targets
+one instance. Reads are anonymous; `me`/`my-videos` need a token from `scripts/peertube
+login`. Tokens persist to `~/.config/peertube/token.json` (override the directory with
+`PEERTUBE_CONFIG_DIR`). Find public instances at [joinpeertube.org](https://joinpeertube.org).
diff --git a/peertube/SKILL.md b/peertube/SKILL.md
index 133686f..fe0c01e 100644
--- a/peertube/SKILL.md
+++ b/peertube/SKILL.md
@@ -1,116 +1,262 @@
---
name: peertube
-description: 'Browse PeerTube federated video from the terminal: view videos and channels,
- search across instances, check server stats, and manage your account. Uses OAuth2
- authentication with token persistence. Use when the user mentions PeerTube, federated
- video, decentralized video platforms, or browsing/uploading to a PeerTube instance.'
+description: Browse PeerTube federated video from the terminal — instance stats, latest
+ videos, video detail, comment threads, channels, accounts, instance-local search, and
+ OAuth2 login with per-instance token persistence. Set PEERTUBE_SERVER to any instance;
+ point it at sepiasearch.org for fediverse-wide search. Use when the user mentions
+ PeerTube, federated video, SepiaSearch, or browsing a specific PeerTube instance.
+ Do not use this skill for YouTube/Vimeo uploads, video editing, or installing and
+ administering a PeerTube server.
license: MIT
-compatibility: Requires PEERTUBE_SERVER env var (set to your instance URL, e.g. https://watch.nousresearch.com),
- Python 3.8+, and the `requests` library. OAuth2 tokens persisted to ~/.config/peertube-cli/token.json.
+compatibility: Requires Python 3.8+ and `requests`. Reads are anonymous; authenticated
+ commands (`me`, `my-videos`) need a token from `scripts/peertube login`. Tokens persist
+ per-instance to ~/.config/peertube/token.json (owner-only).
metadata:
- tags: peertube, federated-video, video-platform, activitypub, api-client
- sources: https://joinpeertube.org/, https://docs.joinpeertube.org/api/reference
+ tags: peertube, federated-video, activitypub, video-platform, sepiasearch, api-client
+ sources: https://docs.joinpeertube.org/api-rest-reference.html, https://sepiasearch.org/
---
-# peertube-cli — PeerTube Federated Video
+# peertube — PeerTube federated video from the terminal
-Browse videos, channels, and server info on any PeerTube instance. Search across the fediverse, list channels, check your account stats, and manage authentication — all from the terminal.
+Browse any PeerTube instance — a federated deployment, not a single API — from the
+terminal: instance stats, latest videos, full video detail, comment threads, channels,
+accounts, and instance-local search. Authenticate with OAuth2 only for your own account
+commands. Every command is read-only except `login`/`logout`.
## Setup
-1. Set the PeerTube instance URL:
+1. Choose the instance to talk to. Every command is per-instance; the API shape is
+ identical everywhere, but accounts, tokens, rules, and catalogs are not:
```bash
-export PEERTUBE_SERVER="https://your-instance.example.com"
+export PEERTUBE_SERVER="https://" # e.g. https://tilvids.com
```
-2. (Optional) Log in for authenticated operations:
+ To search the whole fediverse instead of one instance, point the same variable at the
+ public search index: `PEERTUBE_SERVER=https://sepiasearch.org` (same API shape — see
+ [references/search-and-discovery.md](references/search-and-discovery.md)).
+
+2. Nothing else is required to browse: videos, search, channels, comments, and instance
+ info are anonymous reads.
+
+3. (Optional) Log in only for your own account commands (`me`, `my-videos`):
```bash
-peertube-cli auth login --username "your-username" --password "your-password"
+scripts/peertube login --username --prompt
```
-The OAuth2 token is persisted to `~/.config/peertube-cli/token.json` and automatically reused. `--dry-run` works without authentication.
+### How authentication works
+
+PeerTube uses plain OAuth2 with per-instance client credentials: the CLI anonymously
+fetches the client pair from `GET /api/v1/oauth-clients/local` (singular `local`), then
+exchanges your username/password for a bearer token at `POST /api/v1/users/token`
+(`grant_type=password`, form-encoded). The token rides `Authorization: Bearer `,
+lives for the instance's configured lifetime (read `expires_in` from the response — do not
+assume a fixed number), and is refreshed automatically when it expires. The token file is
+written owner-only to `~/.config/peertube/token.json` keyed by server URL.
+**Do not commit tokens** — they are account credentials; revoke with `scripts/peertube
+logout` (`POST /users/revoke-token`) when done. Current production instances mask
+`client_secret` in the API response; the CLI detects this and explains the workaround.
+Details and wire-level error signatures:
+[references/auth-and-tokens.md](references/auth-and-tokens.md).
## Essential Commands
-### auth login — Authenticate to a PeerTube instance
+### server — instance stats and identity (anonymous)
```bash
-peertube-cli auth login --username "myuser" --password "mypassword" # login
+scripts/peertube server # name, description, user/video/view counters
+scripts/peertube server --json
```
-Token is saved to `~/.config/peertube-cli/token.json` and reused on subsequent calls. Tokens expire after the server-configured lifetime (typically 24h).
+Composes `GET /config/about` + `GET /server/stats` (canonical paths — there is no
+`/instance/stats`).
-### server — Instance information
+### videos — browse the instance's uploads (anonymous)
```bash
-peertube-cli server # instance name, description, stats
-peertube-cli server --json # machine-readable
+scripts/peertube videos # latest 15, offset pagination
+scripts/peertube videos --limit 50 --offset 50
+scripts/peertube videos --sort -views --json # popular first
```
-Shows: instance name, short description, total users, total videos, total views.
+Pagination is `start`/`count` offsets (max count 100) — the API has **no `page`
+parameter**.
-### videos — Browse recent videos
+### search — find videos on THIS instance (anonymous)
```bash
-peertube-cli videos # last 12 videos
-peertube-cli videos --limit 24 # more results
-peertube-cli videos --json # machine-readable
+scripts/peertube search --query "linux" # instance-local (searchTarget=local)
+scripts/peertube search -q "docker" --limit 20 --json
+PEERTUBE_SERVER="https://sepiasearch.org" scripts/peertube search -q "linux" # fediverse-wide
```
-Shows: title, duration, views, author/channel, publish date.
+The bundled CLI performs **instance-local** search only (`searchTarget=local`). For
+fediverse-wide search, point `PEERTUBE_SERVER` at SepiaSearch — same commands, wider
+index. Search results carry `channel.host`/`url`, the origin instance of federated hits.
-### search — Search videos across the fediverse
+### video — full detail for one video (anonymous)
```bash
-peertube-cli search --query "linux tutorial" # search videos
-peertube-cli search -q "peer" --limit 24 # more results
-peertube-cli search -q "docker" --json # machine-readable
+scripts/peertube video --id # numeric id, UUID, or shortUUID all work
+scripts/peertube video --id --json | jq '{name, description, views, url}'
```
-### channels — List video channels
+### comments — top-level comment threads (anonymous)
```bash
-peertube-cli channels # all channels on the instance
-peertube-cli channels --json # machine-readable with subscriber counts
+scripts/peertube comments --id # GET /videos/{id}/comment-threads
+scripts/peertube comments --id --limit 30 --json
```
-Shows: display name, channel handle (@name), video count, subscriber count.
-
-### me — Your profile
+### channels / channel / account — creators (anonymous)
```bash
-peertube-cli me # your account stats
-peertube-cli me --json # machine-readable
+scripts/peertube channels --limit 20 --json # instance channel list
+scripts/peertube channel --handle framasoft@framatube.org # name or name@host
+scripts/peertube account --name chocobozzz@framatube.org
```
-Shows: username, role, video count, view count. Requires authentication.
+`channel` shows metadata plus the channel's uploads (offset-paginated).
-## Global Flags
-
-All flags work in any position:
+### me / my-videos — your account (requires login)
```bash
-peertube-cli --json videos # flag before subcommand
-peertube-cli videos --json # flag after subcommand
-peertube-cli --dry-run search --query "test" # preview (no API call)
-peertube-cli --quiet videos # suppress non-essential output
-peertube-cli --verbose channels # detailed logging
+scripts/peertube me --json | jq '.role.label'
+scripts/peertube my-videos --limit 50 --json
```
+### login / logout — OAuth2 session management
+
+```bash
+scripts/peertube login --username --prompt # hidden prompt
+echo "" | scripts/peertube login --username --password-stdin
+scripts/peertube login --username --otp # 2FA-enabled accounts
+scripts/peertube logout # revoke server-side + delete file
+```
+
+## Global flags
+
+```bash
+scripts/peertube --json videos # flag before or after the subcommand
+scripts/peertube videos --json
+scripts/peertube --dry-run search --query test # request plan, zero network
+scripts/peertube --verbose videos --limit 2 # trace requests on stderr
+scripts/peertube --server https://tilvids.com server # per-invocation instance override
+```
+
+`--dry-run` emits `{"dry_run": true, "method", "path", "params"}` (login adds
+`form_fields` names only, never values) — use it to verify a jq chain before running it
+live. `--help` and `--dry-run` never require credentials.
+
+## Pipeline recipes
+
+### Search, then inspect the top hit
+
+```bash
+scripts/peertube search --query "linux" --limit 5 --json | jq -r '.videos[0].uuid'
+scripts/peertube video --id "$(scripts/peertube search -q linux --limit 1 --json | jq -r '.videos[0].uuid')" --json
+```
+
+### Page through a channel's uploads
+
+```bash
+scripts/peertube channel --handle framasoft@framatube.org --limit 100 --offset 0 --json | jq -r '.videos[].name'
+# loop: advance --offset by the returned count until you reach .total (no page param exists)
+```
+
+### Instance report card
+
+```bash
+scripts/peertube server --json | jq '{name: .instance.name, videos: .stats.totalLocalVideos, users: .stats.totalUsers, views: .stats.totalLocalVideoViews}'
+```
+
+### Log in, check quota, log out
+
+```bash
+scripts/peertube login --username --prompt
+scripts/peertube me --json | jq '{username, role: .role.label, quota_bytes: .videoQuota}'
+scripts/peertube logout
+```
+
+## JSON and jq
+
+`--json` output keys are stable snake_case wrappers around raw API objects: `videos`
+(the API's `{total, data}` list objects), `channels`, `threads` (+ `total_not_deleted`),
+`instance` + `stats`, `channel`, `dry_run`/`method`/`path`/`params` for plans. Video
+objects keep PeerTube's own field names — `uuid`, `shortUUID`, `name`, `duration`
+(seconds), `views`, `publishedAt`, `account{name,displayName,host}`,
+`channel{name,displayName,host}` — so jq selectors transfer directly to raw `curl`
+against `/api/v1`. Example: `jq -r '.videos[] | [.name, .views, .channel.displayName] | @tsv'`.
+
## Known Gotchas
-- **Set PEERTUBE_SERVER first** — Without this env var, the CLI defaults to `https://your-instance.example.com` (which won't resolve). Always export the correct instance URL.
-- **Authentication is required for most commands** — `server` and public video browsing work without auth. `me`, `channels`, and personal video lists require a valid OAuth token. Use `--dry-run` to preview without auth.
-- **Token is persisted automatically** — After `auth login`, the token is saved to `~/.config/peertube-cli/token.json`. No need to login again unless the token expires. Delete this file to force re-login.
-- **Token expiry** — PeerTube OAuth2 tokens have a configurable expiry (default ~24h). Expired tokens cause 401 errors. Re-run `auth login` to refresh.
-- **Cross-instance search** — `search --query` searches across the fediverse, not just the local instance. Results may include videos from remote instances.
-- **API pagination** — PeerTube uses offset-based pagination. The `--limit` flag controls the page size (default: 12 for videos, 15 for channels).
-- **Rate limits** — PeerTube instances have configurable rate limits. The CLI does not auto-retry on 429 responses.
+- **Instances are independent (federated, not one API)** — accounts, tokens, rules,
+ enabled features, and catalogs differ per instance. A token from instance A 401s on
+ instance B; the CLI keys the token file by server URL. Content federated *onto* an
+ instance still belongs to its origin (`channel.host`, video `url`).
+- **Search scope is two different things** — `searchTarget=local` searches the
+ instance's own catalog; `search-index` (or SepiaSearch's base URL) searches the
+ fediverse via an external index. Omitting `searchTarget` gives the instance's own
+ scope on current servers, not the fediverse. The bundled CLI is instance-local unless
+ you point it at sepiasearch.org.
+- **`page` does not exist** — collections paginate with `start`/`count` (max 100).
+ Clients sending `page=` silently re-read the first page forever.
+- **The comments route is `/comment-threads`** (hyphenated) — `/comments` and
+ `/commentthreads` are not routes (they 400 on current servers).
+- **Instance metadata paths are mixed** — stats at `/server/stats` (operation titled
+ "instance stats"), about at `/config/about`, config at `/config`. No
+ `/instance/*` metadata paths exist.
+- **Production masks `client_secret`** — `oauth-clients/local` answers
+ `"********************************"` on current production instances; a token request
+ with the masked value 400s. The CLI detects it and explains the front-end-asset
+ workaround. `response_type=code` appears in old quick-start curls but is not part of
+ the current token schema — the CLI omits it.
+- **Token lifetimes are instance-configurable** — read `expires_in` per response; the
+ CLI persists the absolute `expires_at` and refreshes automatically. Store tokens
+ owner-only, never commit them, revoke on logout (deleting the file alone leaves the
+ session live).
+- **2FA needs an OTP header** — `x-peertube-otp` on the token request; the CLI maps a
+ bare 401 to "pass --otp".
+- **Rate limits** — default 50 calls/10 s per IP (token endpoint tighter); on 429 read
+ `Retry-After` and back off. Errors use RFC7807 `application/problem+json` bodies, and
+ unknown routes answer 400 (not 404) — read the body.
+- **`duration` is seconds**; ids are triple (`id`, `uuid`, `shortUUID` — all accepted by
+ detail endpoints); `role` is an object `{id, label}`; `videoQuota` is bytes.
+- **Anonymous vs authed** — browsing/search/comments/instance-info need no token;
+ `/users/me*` and mutations always do.
-## References
+## When to use
-- [scripts/peertube-cli](scripts/peertube-cli) — The CLI binary. Built following the cli-builder patterns: `--json`, `--dry-run`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth, config file persistence.
-- [PeerTube API Reference](https://docs.joinpeertube.org/api/reference) — Official API reference.
-- [JoinPeerTube.org](https://joinpeertube.org/) — Find instances and learn about the federated video platform.
+Use this skill for read-only interaction with PeerTube instances: browsing and filtering
+videos, instance-local or fediverse-wide search (via SepiaSearch), video detail and
+comments, channel/account exploration, instance stats, and managing your own account
+session with OAuth2 (login, profile, my videos, logout).
+
+## When not to use
+
+Do not use this skill for YouTube, Vimeo, or other platform uploads or any video
+editing/transcoding (route to those platforms' own tooling and ffmpeg); for installing,
+hosting, or administering a PeerTube server (instance administration is out of scope —
+the bundled CLI is read-only plus login/logout); or for generic ActivityPub/Mastodon
+federation questions (use a Mastodon or ActivityPub skill).
+
+## Reference Files
+
+| File | Use it for |
+| ---- | ---------- |
+| [references/auth-and-tokens.md](references/auth-and-tokens.md) | OAuth2 flow (oauth-clients/local, password grant), secret masking, refresh/revocation, token-file hygiene, wire error signatures |
+| [references/search-and-discovery.md](references/search-and-discovery.md) | searchTarget local vs search-index, SepiaSearch semantics, search parameters and sorts |
+| [references/endpoint-catalog.md](references/endpoint-catalog.md) | Every read endpoint's parameters, response shapes, pagination, rate limits |
+| [references/gotchas-field-guide.md](references/gotchas-field-guide.md) | Symptom → cause → fix table for every failure signature and version drift |
+| [references/worked-recipes.md](references/worked-recipes.md) | Multi-step CLI/jq workflows, raw curl auth chain, jq processing patterns |
+
+## Available Scripts and Prerequisites
+
+- `scripts/peertube` — the bundled Python CLI (`--json`, `--dry-run`, `--verbose`,
+ `--server` override). Imports only the standard library and `requests`.
+- `scripts/test_peertube.py` — offline test suite (pytest + unittest compatible); all
+ HTTP is mocked, zero network egress.
+- Requires Python 3.8+ and `requests`. Any reachable PeerTube instance (or SepiaSearch)
+ works; no credentials exist or are required by default. No service is started by this
+ skill.
diff --git a/peertube/evals/evals.json b/peertube/evals/evals.json
new file mode 100644
index 0000000..fa42496
--- /dev/null
+++ b/peertube/evals/evals.json
@@ -0,0 +1,72 @@
+{
+ "schema_version": 1,
+ "skill_name": "peertube",
+ "evals": [
+ {
+ "id": "browse-latest-videos-json",
+ "prompt": "Show me the ten most recent videos on my PeerTube instance (framatube.org) as JSON.",
+ "expected_output": "Set PEERTUBE_SERVER=https://framatube.org (or pass --server) and run scripts/peertube videos --limit 10 --json. No login is needed: /api/v1/videos is anonymous and returns {total, data} with offset pagination (start/count, no page parameter).",
+ "assertions": [
+ "exports PEERTUBE_SERVER or passes --server with the instance host",
+ "runs scripts/peertube videos with --limit 10 and --json",
+ "does not require login because public video listing is anonymous",
+ "pages with start/count offsets, never a page parameter"
+ ]
+ },
+ {
+ "id": "search-then-detail-pipeline",
+ "prompt": "Search my PeerTube instance for videos about linux, then show me the full details of the best result.",
+ "expected_output": "Chain scripts/peertube search --query linux --json (instance-local search, searchTarget=local) to get results, extract .videos[0].uuid with jq, then run scripts/peertube video --id --json for full metadata. The video detail endpoint accepts the numeric id, UUID, or shortUUID.",
+ "assertions": [
+ "starts with scripts/peertube search using --query",
+ "extracts the uuid field from the search output before the next stage",
+ "feeds the extracted id into scripts/peertube video --id",
+ "does not claim the search covered the whole fediverse since the CLI performs instance-local search"
+ ]
+ },
+ {
+ "id": "fediverse-search-via-sepiasearch",
+ "prompt": "I searched my PeerTube instance for a popular video and got no results, but I know it exists on another instance. How do I search across the whole fediverse?",
+ "expected_output": "Instance search (searchTarget=local) only finds objects the instance knows. For fediverse-wide search, point the same CLI at SepiaSearch - the public search index that indexes public PeerTube instances and speaks the identical API shape: PEERTUBE_SERVER=https://sepiasearch.org scripts/peertube search --query ''. Follow results back to their origin instance using the account/channel host or the video url field; an instance may also support searchTarget=search-index if its admin enabled an external index.",
+ "assertions": [
+ "explains the instance-local versus search-index/fediverse search scopes",
+ "uses SepiaSearch as the fediverse-wide search base host with the same API shape",
+ "directs following results to their origin instance via host/url fields",
+ "mentions searchTarget=local as the explicit instance-scope value"
+ ]
+ },
+ {
+ "id": "oauth-client-secret-masked-login",
+ "prompt": "I'm writing a script that logs in to a PeerTube instance. GET /api/v1/oauth-clients/local returns client_secret as '********************************' and my subsequent POST to /users/token fails with 400 invalid client. What is going on?",
+ "expected_output": "Current production instances mask the client_secret in the oauth-clients/local response (the real pair reaches the web front end via its served assets). The correct flow is still: fetch /api/v1/oauth-clients/local (singular 'local'), obtain the unmasked client pair the way the instance's own front end does, then POST /api/v1/users/token with x-www-form-urlencoded fields client_id, client_secret, grant_type=password, username, password - no response_type needed. A 400 can also mean wrong credentials; the bundled CLI detects the masked secret and stops with guidance before sending a doomed token request.",
+ "assertions": [
+ "identifies production client_secret masking as the cause of the invalid-client 400",
+ "names the oauth-clients/local (singular) endpoint as step one",
+ "lists the exact password-grant form fields including grant_type=password",
+ "does not treat the masked asterisk value as a usable secret"
+ ]
+ },
+ {
+ "id": "token-persistence-and-logout-hygiene",
+ "prompt": "How should a PeerTube CLI store the OAuth token after login, and how do I log out properly?",
+ "expected_output": "Persist the token per-instance in an owner-only file (the bundled CLI uses ~/.config/peertube/token.json, override with PEERTUBE_CONFIG_DIR) recording the server URL, access token, refresh token, and absolute expires_at from the token response - lifetimes are instance-configurable, so never hard-code one. Log out with scripts/peertube logout, which calls POST /api/v1/users/revoke-token (revoking the access and refresh tokens server-side) and then deletes the local file; deleting the file alone leaves a live session. Never commit or log tokens.",
+ "assertions": [
+ "stores the token outside the repository in an owner-only location",
+ "records expiry from expires_in instead of assuming a fixed lifetime",
+ "revokes server-side via POST /users/revoke-token before deleting the local file",
+ "keeps tokens per-instance because tokens are not valid across instances"
+ ]
+ },
+ {
+ "id": "youtube-upload-not-peertube",
+ "prompt": "Help me upload my video to YouTube and trim the intro with ffmpeg.",
+ "expected_output": "This must not trigger the peertube skill: YouTube uploading and video editing are outside its read-only PeerTube API scope, and PeerTube is a different federated platform from YouTube. Use YouTube's own upload tooling (e.g. youtube-cli or YouTube Studio) and ffmpeg directly for trimming. The PeerTube skill supports no upload operation at all - it is read-only plus login/logout.",
+ "assertions": [
+ "must not trigger peertube for YouTube uploads or video editing",
+ "routes the upload to YouTube's own tooling instead",
+ "routes the trim to ffmpeg directly",
+ "does not invent a peertube upload command since the bundled CLI is read-only"
+ ]
+ }
+ ]
+}
diff --git a/peertube/references/auth-and-tokens.md b/peertube/references/auth-and-tokens.md
new file mode 100644
index 0000000..6b71455
--- /dev/null
+++ b/peertube/references/auth-and-tokens.md
@@ -0,0 +1,152 @@
+# PeerTube authentication and tokens
+
+How PeerTube's OAuth2 flow actually behaves on the wire, what each failure looks like, and
+how to store tokens without leaking them. Every behavioral claim traces to the official
+REST reference, the official quick start, or the PeerTube server source (Sources footer).
+PeerTube has exactly one authenticated posture: an OAuth2 **bearer access token** minted
+from per-instance client credentials. There is no API-key alternative (unlike Jellyfin) and
+no header scheme beyond standard `Authorization: Bearer `.
+
+## Step 1 — fetch the instance's OAuth client credentials
+
+`GET /api/v1/oauth-clients/local` (singular `local`, not `locals`) returns the
+per-instance client pair. It is anonymous — no authorization block on the operation — and
+PeerTube's own web UI calls it before every login:
+
+```json
+{ "client_id": "", "client_secret": "" }
+```
+
+**Production servers mask the client_secret.** The current server code returns the real
+secret from this endpoint (it is the same secret the web client uses), but production
+instances in recent versions respond with the secret replaced by
+`"********************************"` — observed live on multiple public instances in
+2026 and consistent with the reference page's own masked response example
+(`client_secret: "********************************"`). Practical consequences:
+
+- Never persist the response of `oauth-clients/local` as if it were a working secret.
+- A login attempt using the masked value fails with HTTP 400 (invalid client). This is
+ what you are seeing if your script fetched the client pair and the very next token
+ request 400s on a public instance.
+- The legacy workaround mirrors what the web client does: the client pair is embedded in
+ the instance's front-end JavaScript, and official PeerTube tooling reads it from the
+ served assets when the API masks it. Treat the masked behavior as version-dependent —
+ always attempt the API first, then fall back to scraping the served JS bundle if the
+ secret comes back masked.
+
+The endpoint is also Host-header-guarded: the server compares the request's `Host` header
+against its configured webserver hostname and answers **HTTP 403
+"Getting client tokens for host ... is forbidden"** when they disagree (proxies that
+rewrite the header break clients here; the guard is skipped on test/dev instances).
+
+## Step 2 — the password grant
+
+`POST /api/v1/users/token`, `Content-Type: application/x-www-form-urlencoded`, with form
+fields (names exactly as in the reference):
+
+| Field | Required? | Notes |
+| --- | --- | --- |
+| `client_id` | yes | from step 1 |
+| `client_secret` | yes | from step 1 (unmasked) |
+| `grant_type` | yes | `password` for login |
+| `username` | yes | |
+| `password` | yes | |
+| `response_type` | no | the official quick-start curl sends `response_type=code`; it is absent from the current OpenAPI request schema. Sending it is harmless; omitting it works with `requests` |
+| `x-peertube-otp` | conditional | request header, only when the account has 2FA enabled (server answers 401 without it) |
+
+```bash
+curl -X POST "$BASE/users/token" \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ --data-urlencode 'client_id=' \
+ --data-urlencode 'client_secret=' \
+ --data-urlencode 'grant_type=password' \
+ --data-urlencode 'username=' \
+ --data-urlencode 'password='
+```
+
+Success response fields: `access_token`, `token_type` (`"Bearer"`), `expires_in` (seconds),
+`refresh_token`, and `refresh_token_expires_in` (seconds; present in the current reference
+sample, `1209600` there — a sample value, not a guaranteed default). The quick-start
+example shows `expires_in: 14399` (~4 hours). Sample values are not contract: instances can
+configure token lifetimes server-side, so read `expires_in` from each response and schedule
+refresh from it rather than hard-coding "24 hours" or any other number.
+
+## Refresh, revocation, and lifetime
+
+- **Refresh grant**: `grant_type=refresh_token` is a documented allowed value on the token
+ endpoint. The rendered reference does not display a `refresh_token` form-field row, so
+ the exact refresh request body is not fully specified in official docs; standard OAuth2
+ practice (send `refresh_token` alongside the client pair) is the community-established
+ shape, but verify against your instance's version before relying on it.
+- **Revocation**: `POST /api/v1/users/revoke-token` with `Authorization: Bearer `,
+ no body, returns HTTP 200 and revokes the access token **and** its associated refresh
+ token, destroying the session. This is the correct "logout" operation: revoke before
+ discarding a stored token.
+- **Lifetimes**: no official page documents default lifetime values or the server config
+ keys that change them; the only official evidence is the sample `expires_in: 14399` /
+ `refresh_token_expires_in: 1209600` (~4 h / ~14 d). Server operators can adjust token
+ lifetimes via their production config (all options documented as living in
+ `config/default.yaml` overridable by `production.yaml`), so treat expiry as per-instance
+ and honor `expires_in`.
+
+## Error signatures on the wire
+
+| Symptom | Status | Meaning / response |
+| --- | --- | --- |
+| Bad client_id/client_secret, or the masked secret, or wrong username/password | `400` on `POST /users/token` | Reference documents 400 for invalid client or credentials; bodies are RFC7807-style (`application/problem+json` with `type`, `title`, `status`, `detail`, sometimes `code`) |
+| 2FA enabled, no `x-peertube-otp` header | `401` on `POST /users/token` | header must be supplied on the token request |
+| Expired/revoked token on any authenticated call | `401` | re-run the password grant (or refresh) |
+| Wrong `Host` header reaching `oauth-clients/local` | `403` | proxy/header rewriting problem, not auth |
+| Rate limit exceeded | `429` | all endpoints are rate-limited; the token endpoint is tighter than most (documented sample: 15 calls per 5 minutes). Inspect `Retry-After` (seconds) and `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` (Unix timestamp) and back off |
+| Connection refused / DNS failure | no HTTP response | transport failure — classify separately from API errors; usually `PEERTUBE_SERVER` is wrong or unreachable |
+
+Anonymous-read endpoints (videos, search, channels, `/config`, `/config/about`,
+`/server/stats`) need no token at all. Authenticated-only calls include `/users/me`,
+`/users/me/videos`, and any state-changing operation. The API answers `401` when a call
+needs a token you did not send.
+
+## Token persistence hygiene
+
+PeerTube's docs do not prescribe storage mechanics, so a CLI should follow these
+sanctioned-by-logout-support practices:
+
+1. **Store under the user's own profile, not in the repo.** The bundled CLI defaults to
+ `~/.config/peertube/token.json` (override with `PEERTUBE_CONFIG_DIR` for tests). Never
+ write tokens into a working tree, a shell history, or an eval manifest.
+2. **Restrict file permissions.** Create the directory and file so only the owner can read
+ the token file (e.g. `os.makedirs(..., mode=0o700)` and `0o600` on the file).
+3. **Persist the refresh token alongside the access token and the server base URL**, plus
+ the absolute `expires_at` computed from `expires_in`. A token file is only valid for
+ the instance it was minted by — re-authenticate when `PEERTUBE_SERVER` changes.
+4. **Refresh before expiry; fall back to password re-grant.** Because refresh-request
+ semantics are underspecified in official docs, treat refresh as an optimization: try
+ `grant_type=refresh_token`, and on any failure re-run the password grant.
+5. **Revoke on logout.** `POST /users/revoke-token` invalidates both tokens server-side,
+ then delete the local file. Deleting the file alone leaves a live session behind.
+6. **Never commit or log tokens.** Examples everywhere in this skill use
+ ``-style placeholders. If a token file ever lands in a diff, revoke it —
+ deleting the file does not invalidate the session.
+7. **Multi-instance note**: one token file per server URL (or include the server in the
+ file) avoids "works on instance A, 401 on instance B" confusion when switching
+ `PEERTUBE_SERVER`.
+
+## Detection and headers for API clients
+
+- API responses carry `x-powered-by: PeerTube` and `/api/*` is CORS-enabled; HTML pages
+ include ``; NodeInfo is exposed at
+ `/nodeinfo/2.0.json`. Any of these distinguishes a PeerTube instance from other servers.
+- No special `User-Agent` is required. Use `Accept: application/json`.
+
+## Sources
+
+- https://docs.joinpeertube.org/api-rest-reference.html (Session: getOAuthClient,
+ getOAuthToken, revokeOAuthToken; Errors; Rate-limits; CORS; Config; Stats)
+- https://docs.joinpeertube.org/api/rest-getting-started (client fetch, password grant
+ curl, token response example, instance detection)
+- https://docs.joinpeertube.org/maintain/configuration (config file layering)
+- https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/api/openapi.yaml
+ (generated OpenAPI spec; openapi-generator clients)
+- https://raw.githubusercontent.com/Chocobozzz/PeerTube/develop/server/core/controllers/api/oauth-clients.ts
+ (Host-header guard; response construction)
+- Live anonymous probes of public instances (`oauth-clients/local` masking, 400 on token
+ misuse), 2026-08-29.
diff --git a/peertube/references/endpoint-catalog.md b/peertube/references/endpoint-catalog.md
new file mode 100644
index 0000000..24bd6f2
--- /dev/null
+++ b/peertube/references/endpoint-catalog.md
@@ -0,0 +1,131 @@
+# PeerTube endpoint catalog for CLI clients
+
+The read surface of the PeerTube REST API with exact parameter names, response shapes, and
+pagination semantics — everything a CLI needs to list, filter, and page through videos,
+channels, accounts, and instance metadata. Base path: `/api/v1` on any instance
+(`https:///api/v1`). Sources footer cites the official reference; a few
+shapes were additionally confirmed by live anonymous probes (noted inline).
+
+## The one pagination model: start/count offsets
+
+Every collection endpoint uses **offset pagination**: query params `start` (integer >= 0)
+and `count` (1–100, **default 15**). There is no `page` parameter anywhere in the current
+API — a client sending `page=` silently gets default paging while believing it paginated
+(this bit the original bundled CLI). Responses wrap as:
+
+```json
+{ "total": 23792, "data": [ /* resource objects */ ] }
+```
+
+Loop by advancing `start` by the number of rows received until `start >= total` (or an
+empty page). `skipCount=true` on video collections/search omits the `total` computation —
+faster, but then you must stop on the first short/empty page. Max `count` per request is
+100; a `count` above the allowed range is rejected.
+
+## Videos
+
+| Endpoint | Auth | Notes |
+| --- | --- | --- |
+| `GET /videos` | anonymous | instance-wide video list; filters below |
+| `GET /videos/{id}` | anonymous | full detail; `{id}` accepts **numeric id, UUIDv4, or shortUUID** |
+| `GET /videos/{id}/comment-threads` | anonymous | top-level comment threads; `start`, `count`, `sort` in {-createdAt, -totalReplies}; response `{total, totalNotDeletedComments, data}` |
+
+- The comments route is **`/comment-threads`** (hyphenated). `/comments` and
+ `/commentthreads` are not the route (probes: `/comments` 400s on current servers; the
+ OpenAPI shows `/comment-threads`). A newer `/videos/{id}/comments/{commentId}/replies`
+ route (v8.3 changelog) fetches replies, not top-level threads.
+- Listing filters (current exact names): `start`, `count`, `sort`, `categoryOneOf`,
+ `tagsOneOf`, `tagsAllOf`, `languageOneOf`, `licenceOneOf`, `nsfw`, `nsfwFlagsIncluded`,
+ `nsfwFlagsExcluded`, `isLive`, `isLocal`, `host`, `skipCount`, `search`, plus
+ admin-only `include`/`privacyOneOf`/`stateOneOf` (>=8.2)/`autoTagOneOf` (>=6.2) and
+ file-format filters `hasHLSFiles`/`hasWebVideoFiles`.
+- Sort values: `name`, `-duration`, `-createdAt`, `-publishedAt`, `-views`, `-likes`,
+ `-comments`, `-trending`, `-hot`, `-best`.
+- List-item shape (probe-confirmed field names): `id`, `uuid`, `shortUUID`, `url`, `name`,
+ `category{id,label}`, `licence{id,label}`, `language{id,label}`, `privacy{id,label}`,
+ `nsfw`, `truncatedDescription`, `duration` (**seconds** — sample `1419` is ~23.6 min),
+ `views`, `likes`, `dislikes`, `comments`, `publishedAt`/`originallyPublishedAt`/`createdAt`
+ (ISO-8601), `isLocal`, `isLive`, thumbnail/preview `path`s, and actor summaries:
+ `account{id,name,displayName,host,url,avatars[]}`,
+ `channel{id,name,displayName,host,url,avatars[]}`.
+- `account`/`channel` `host` tells you the **origin instance** of a federated video — on a
+ search-index result this is how you find where the video actually lives.
+- Detail adds full `description`, `files[]`/`streamingPlaylists[]` (resolutions,
+ `fileUrl`/`fileDownloadUrl`, `metadataUrl`s), `commentsEnabled`, `downloadEnabled`,
+ `trackerUrls`, `support`, `tags`, `scheduledUpdate` for scheduled/live videos.
+
+## Channels and accounts
+
+| Endpoint | Auth | Notes |
+| --- | --- | --- |
+| `GET /video-channels` | anonymous | **does exist** (current reference): lists the instance's channels, `start`/`count`/`sort`, `{total,data}` |
+| `GET /video-channels/{channelHandle}` | anonymous | handle format `my_username` or `my_username@example.com` (`name@host` for remote channels) |
+| `GET /video-channels/{channelHandle}/videos` | anonymous | channel's videos, standard video filters + offset pagination |
+| `GET /accounts/{name}` | anonymous | account actor; 404 for unknown; `name` accepts `chocobozzz` or `chocobozzz@example.org` |
+| `GET /accounts/{name}/videos` | anonymous | account's videos, offset pagination |
+| `GET /accounts/{name}/video-channels` | anonymous | an account's channels |
+| `GET /search/video-channels` | anonymous | see search-and-discovery.md |
+
+Channel object fields include `name`, `displayName`, `host`, `url`, `avatars`,
+`followersCount` (subscribers), `videosCount` — but note the **global** `/video-channels`
+list rows additionally observed carrying `videosCount`/`followersCount` per channel in
+list responses (probe 2026-08-29). Historical route drift: pre-1.0 `/videos/channels/*`
+routes became `/video-channels/*` and `/videos/accounts/{id}/channels` became
+`/accounts/{id}/video-channels` (changelog, v1.0.0-beta.4) — ancient wrappers still using
+the old shapes will 404.
+
+## Instance metadata (all anonymous, all public)
+
+| Endpoint | Returns |
+| --- | --- |
+| `GET /config` | public runtime configuration: `client{}`, `defaults{}`, `webadmin{}`, and an `instance{}` block with `name`, `shortDescription`, classifications, customization, avatars/banners |
+| `GET /config/about` | `{instance:{name, shortDescription, description, terms, codeOfConduct, hardwareInformation, administrationInformation, maintenanceInformation, businessInformation, languages, categories, banners}}` |
+| `GET /server/stats` | instance counters: `totalUsers`, `totalLocalVideos`, `totalLocalVideoViews`, `totalLocalVideoDownloads`, `totalLocalVideoComments`, `totalVideos`, `totalVideoComments`, `totalLocalVideoChannels`, `totalLocalDailyActiveVideoChannels`, `totalLocalVideoChannels`, `totalLocalVideoPlaylists`, moderation/registration counters, activity-processing stats. Public and cached by the server. |
+| `GET /nodeinfo/2.0.json` | standard NodeInfo document (software name/version, usage counts) — handy for instance detection |
+
+**Naming trap:** the stats operation is titled "Get instance stats" but the canonical
+current path is **`/server/stats`** (there is no `/instance/stats`), while the config
+endpoints are **`/config`** and **`/config/about`** (there is no `/instance/config` or
+`/instance/about`). Mixed naming is current reality, not a docs bug. A CLI's `server` /
+`info` command should compose `/config/about` + `/server/stats` to give name, description,
+and user/video/view counts in one screenful.
+
+## My user (OAuth2 required)
+
+| Endpoint | Notes |
+| --- | --- |
+| `GET /users/me` | identity + preferences: `id`, `username`, `email`, `role{id,label}`, `videoQuota`, `videoQuotaDaily`, `account{}`, `videoChannels[]`, `twoFactorEnabled`, theme/NSFW/p2p preferences, `createdAt`. The current reference sample is rendered as an array; every live server returns a **single user object** — clients should tolerate both. |
+| `GET /users/me/videos` | `{total, data}` of your uploads with the standard video-list fields and filters (`start`, `count`, `sort`, privacy/scope filters) |
+
+The `role` block is `{id, label}` (e.g. `{id: 1, label: "User"}`); `videoQuota` is bytes.
+Channel rows inside `videoChannels` carry the same `name`/`displayName`/`host` actor shape
+used everywhere else.
+
+## Rate limits (all endpoints)
+
+Default server-side limiter: **50 calls per 10 seconds** per IP across `/*` (the token
+endpoint is documented at a tighter 15 per 5 minutes in its operation docs; administrators
+can customize all values). On exhaustion you get **HTTP 429** with
+`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (Unix timestamp) and
+`Retry-After` (seconds). A CLI should read `Retry-After` and back off; aggressive parallel
+listing (count=100 × many pages) on a small instance will trip the limiter.
+
+## Error bodies
+
+Errors use RFC7807-style `application/problem+json` documents with `type`, `title`,
+`status`, `detail`, and sometimes a `code`. Unknown routes on current servers typically
+answer 400 (not the classic 404) with an `error` body — check the body, not just the
+status, when a route mysteriously "doesn't exist".
+
+## Sources
+
+- https://docs.joinpeertube.org/api-rest-reference.html (getVideos, getVideo,
+ getVideoChannels, getVideoChannel, getVideoChannelVideos, getAccount, getAccountVideos,
+ searchChannels, getConfig, getAbout, getInstanceStats, getUserInfo, comment-threads
+ operations; Errors and Rate-limits sections)
+- https://docs.joinpeertube.org/api/rest-getting-started (pagination/filter basics,
+ instance detection via NodeInfo / x-powered-by / og:platform)
+- https://docs.joinpeertube.org/CHANGELOG (route renames v1.0.0-beta.4; v8.2 stateOneOf;
+ v8.3 comment routes)
+- Live anonymous probes on a public instance (list shapes, channel list fields,
+ `/config/about`, `/server/stats`, `/comment-threads` vs `/comments` status), 2026-08-29.
diff --git a/peertube/references/gotchas-field-guide.md b/peertube/references/gotchas-field-guide.md
new file mode 100644
index 0000000..bcc7f98
--- /dev/null
+++ b/peertube/references/gotchas-field-guide.md
@@ -0,0 +1,125 @@
+# PeerTube gotchas field guide
+
+Failure signatures and behavioral traps, distilled from the official docs, the server
+source, and live probes. Each entry: symptom → cause → what to do.
+
+## Instance plurality (the big one)
+
+- **Symptom**: same CLI command works on one instance and 400s/401s/empty-results on
+ another; or a token that worked on instance A 401s on instance B.
+- **Cause**: PeerTube is federated software, not a single API. Every instance is an
+ independent deployment with its own rules, allowances, moderation policy, enabled
+ features (NSFW policy, search-index support, registration, transcoding) and its own user
+ accounts and OAuth tokens. A token minted by instance A is meaningless to instance B;
+ instance B may have closed registrations, disabled uploads, or set its own NSFW default.
+- **Do**: always configure the instance host per operation (`PEERTUBE_SERVER` or
+ `--server`); keep per-instance token files; never assume an account or video exists on a
+ different instance. Federated content viewed on instance X still **belongs** to the
+ origin instance (`channel.host` / `account.host` / video `url` tell you which).
+
+## Search scope confusion
+
+- **Symptom**: "search across the fediverse" expectations return only a handful of local
+ results; or results reference videos the instance doesn't host.
+- **Cause**: `searchTarget` has two scopes: `local` (instance-known objects only) and
+ `search-index` (external fediverse index, admin-enabled). Omitting the parameter gives
+ the instance's own scope on current servers (observed), not the fediverse.
+- **Do**: pass `searchTarget=local` explicitly for instance scope; use SepiaSearch
+ (`https://sepiasearch.org/api/v1/search/videos`) for fediverse-wide scope. Index results
+ point at origin instances — follow `channel.host`/`url` rather than expecting the
+ queried instance to serve them.
+
+## Pagination: `start`/`count`, never `page`
+
+- **Symptom**: client pages with `page=1&count=15` and gets identical results forever.
+- **Cause**: the API has no `page` parameter; unknown params are ignored, so `page=1`
+ requests silently return the first `count` rows every time.
+- **Do**: advance `start` by the page size until `start >= total` or an empty page. Max
+ `count` is 100 (higher values are rejected). `skipCount=true` trades the `total` field
+ for speed — then you must stop on the first short page.
+
+## Comment route spelling
+
+- **Symptom**: fetching comments with `/videos/{id}/comments` or
+ `/videos/{id}/commentthreads` returns 400 (current servers answer 400, not 404, for bad
+ routes — see below) while other endpoints work.
+- **Cause**: the route is `GET /videos/{id}/comment-threads` (hyphenated). The v8.3
+ `/comments/{commentId}/replies` route is for replies, not top-level threads.
+- **Do**: use `/comment-threads` with `start`/`count`/`sort=-createdAt|-totalReplies`.
+
+## Instance metadata endpoint names
+
+- **Symptom**: `/instance/stats`, `/instance/about`, `/instance/config` all 400/404.
+- **Cause**: mixed current naming: stats live at **`/server/stats`** (operation *titled*
+ "Get instance stats"), about at **`/config/about`**, config at **`/config`**.
+- **Do**: compose `/config/about` + `/server/stats` for a full instance picture.
+
+## oauth-clients/local secret masking
+
+- **Symptom**: `GET /oauth-clients/local` returns
+ `"client_secret": "********************************"`; the following token request 400s
+ with invalid_client.
+- **Cause**: current production servers mask the secret in this response (the value is
+ still delivered to the web client via served front-end assets; the API response masks
+ it). Older instances/versions return the real secret.
+- **Do**: detect the masked value; if masked, obtain the client pair from the instance's
+ served front-end JS (the same source its own web UI uses) before the token request. Never
+ persist the masked string as a secret. The endpoint is also Host-header-guarded (403 if
+ the `Host` header disagrees with the configured webserver hostname — mind reverse
+ proxies).
+
+## Auth error signatures
+
+| Status | Where | Meaning |
+| --- | --- | --- |
+| 400 on `POST /users/token` | invalid client pair (including the masked-secret case) or wrong credentials | RFC7807-style `application/problem+json` body; check `detail` |
+| 401 on `POST /users/token` | account has 2FA and no `x-peertube-otp` header supplied | supply OTP header |
+| 401 on authenticated GETs | token expired/revoked/malformed, or missing | re-run password grant |
+| 403 on `oauth-clients/local` | Host-header mismatch (proxy misconfiguration) | fix the proxy/Host |
+| 429 anywhere | rate limit (default 50 req/10 s; token endpoint tighter) | read `Retry-After` + `X-RateLimit-*` headers, back off |
+| 400 on unknown routes | current servers answer 400 with an error body for unrecognized API routes | read the body; the classic "404 means missing route" assumption misleads here |
+| connection errors | wrong/unreachable `PEERTUBE_SERVER` | no HTTP response at all; classify as transport failure |
+
+## Shape and value traps
+
+- **duration is seconds** (integer). Sample list value `1419` = 23:39, not milliseconds.
+- **ids are triple**: numeric `id`, `uuid` (UUIDv4), and `shortUUID` — all three are
+ accepted by `/videos/{id}` and family; `uuid` is the safest portable choice in scripts.
+- **`users/me` sample is an array in the docs**; live servers return a single object.
+ Tolerate both when writing generic parsers.
+- **`role` is an object** `{id, label}` on `/users/me` — don't stringify the dict.
+- **`videoQuota` is bytes** (large integer).
+- **`{total, data}` everywhere**: collections never wrap in `{"videos": []}` at the API
+ layer (the bundled CLI adds that key in its JSON output; know which layer you're
+ reading).
+- **`nsfw` filter is a string** (`"true"`/`"false"`) in query params.
+- **filter names end in `OneOf`/`AllOf`** (`categoryOneOf`, `tagsAllOf`, ...); bare
+ `category=` from old wrappers is ignored silently.
+- **federated results**: a video listed on instance X may be hosted on instance Y
+ (`account.host`/`channel.host`). Views/likes counters are local-ish and eventually
+ consistent across the federation — don't expect exact global numbers.
+
+## Version drift
+
+- Docs reference page currently identifies PeerTube **8.1.0** while the changelog already
+ carries 8.3.0 material — instance versions vary; validate optional parameters
+ (`stateOneOf` >= 8.2, `autoTagOneOf` >= 6.2) before relying on them.
+- Historical renames worth knowing when reading old code: `/videos/channels/*` →
+ `/video-channels/*`, `/videos/accounts/{id}/channels` → `/accounts/{id}/video-channels`
+ (v1.0.0-beta.4).
+- Refresh-token request fields are underspecified in official docs; don't build
+ refresh-critical logic without testing against your target instance.
+
+## Sources
+
+- https://docs.joinpeertube.org/api-rest-reference.html (operation pages: searchVideos,
+ getVideos, comment-threads, getOAuthToken, revokeOAuthToken, getInstanceStats; Errors,
+ Rate-limits sections)
+- https://docs.joinpeertube.org/api/rest-getting-started
+- https://docs.joinpeertube.org/use/search (scope semantics)
+- https://docs.joinpeertube.org/admin/configuration (global-search admin enablement)
+- https://docs.joinpeertube.org/CHANGELOG (route renames, version additions)
+- https://raw.githubusercontent.com/Chocobozzz/PeerTube/develop/server/core/controllers/api/oauth-clients.ts
+ (Host guard)
+- Live anonymous probes (secret masking, search default scope, route status codes,
+ response shapes), 2026-08-29.
diff --git a/peertube/references/search-and-discovery.md b/peertube/references/search-and-discovery.md
new file mode 100644
index 0000000..00b5f08
--- /dev/null
+++ b/peertube/references/search-and-discovery.md
@@ -0,0 +1,146 @@
+# PeerTube search: instance-local vs the fediverse-wide index
+
+PeerTube search has two distinct scopes, and confusing them is the single most common
+mistake clients make. This file pins down exactly what each scope does, what SepiaSearch
+is, and which one the bundled CLI performs.
+
+## The two scopes
+
+`GET /api/v1/search/videos` accepts `searchTarget` with exactly two documented values:
+
+| `searchTarget` | Scope | What you get |
+| --- | --- | --- |
+| `local` | platform/instance search | Results known to the platform you are querying: its own videos plus objects it has discovered/federated from instances it follows. Same behavior as the instance's web UI search box. |
+| `search-index` | global/fediverse search | Results served through an **external search index** configured by the instance administrator. The result set is not scoped to objects your instance knows. The reference warns these results come from a third-party service, and the instance may not yet know (have copies of) the returned objects. |
+
+Facts that matter operationally:
+
+- `remote` is **not** a current `searchTarget` value (it appears in old blog posts and
+ older wrappers); the current enum is `local` | `search-index`.
+- The current reference does not state what happens when `searchTarget` is omitted.
+ Observed behavior on a public instance (2026-08-29): omitting it returned local results
+ identical to `searchTarget=local`, i.e. **the default scope is the instance's own
+ index**, not the fediverse. Do not assume otherwise; if you need the instance's results,
+ pass `searchTarget=local` explicitly, and if you want the fediverse, use a search-index
+ host (below) rather than an undocumented default.
+- `searchTarget=search-index` only works when the administrator has enabled and configured
+ an external search index (admin config section "Global search"); instances without one
+ cannot serve index results. Errors when the index is unavailable surface as HTTP 500 on
+ search endpoints.
+- Index results may reference videos your instance has never federated. The official
+ recommendation for consuming them: if URI search is enabled, fetch the result's URL into
+ your instance first, then use the classic REST endpoint; otherwise fetch from or redirect
+ to the **origin instance** (every result carries its origin in `account`/`channel.host`
+ and the video `url`).
+
+## SepiaSearch: the fediverse-wide index
+
+[SepiaSearch](https://sepiasearch.org) is Framasoft's public search index for PeerTube: a
+separately hosted service that crawls and indexes public PeerTube instances (its front
+page advertises ~1,700 sites indexed) and exposes **the same REST API shape** under its own
+base URL:
+
+```
+GET https://sepiasearch.org/api/v1/search/videos?search=&start=0&count=15
+```
+
+Verified live (2026-08-29): the response is the standard `{total, data: [...]}` collection
+of PeerTube-shaped video objects (`uuid`, `shortUUID`, `name`, `category`, `language`,
+`privacy`, `publishedAt`, `account`, `channel`, `views`, `duration`, plus a `score` field
+the instance endpoints do not return). Consequences:
+
+- A client only needs to swap the base host from an instance to `https://sepiasearch.org`
+ to get fediverse-wide search — same parameters, same pagination, same parsing.
+- There is no documented indexing-latency guarantee; freshly published videos may take an
+ unspecified time to appear. Treat indexing lag as variable.
+- SepiaSearch is a search service, not a video host: play/upload URLs in results point at
+ the origin instances.
+- PeerTube administrators may instead configure their own index URL (Framasoft also
+ publishes one at `https://search.joinpeertube.org/` built on the same idea); that is what
+ `searchTarget=search-index` talks to on such instances. SepiaSearch is simply the
+ well-known public instance of this concept.
+- SepiaSearch results are not moderated by anyone you are talking to; the official
+ documentation explicitly warns the index content is not moderated.
+
+## Search endpoint catalog
+
+| Endpoint | Notes |
+| --- | --- |
+| `GET /api/v1/search/videos` | required `search`; `searchTarget`, `start`, `count` (1–100, default 15), `sort`, plus video filters below |
+| `GET /api/v1/search/video-channels` | required `search`; optional `handles`, `host`, `searchTarget`, `start`, `count`, `sort`; returns 500 if the search index is unavailable |
+
+### Sort values (search + video listing)
+
+`name`, `-duration`, `-createdAt`, `-publishedAt`, `-views`, `-likes`, `-comments`,
+`-trending`, `-hot`, `-best`. The last three are relevance/popularity orders computed by
+the instance (hot/trending window definitions are instance-side).
+
+### Filter parameters (exact names)
+
+`categoryOneOf`, `licenceOneOf`, `languageOneOf`, `tagsOneOf`, `tagsAllOf`, `nsfw`
+(`"true"`/`"false"` string), `nsfwFlagsIncluded`/`nsfwFlagsExcluded`, `isLive`,
+`durationMin`/`durationMax` (seconds), `startDate`/`endDate` and
+`originallyPublishedStartDate`/`originallyPublishedEndDate` (ISO dates), `host`,
+`uuids`, `skipCount` (`true` avoids computing `total`), plus admin-only
+`autoTagOneOf` (>=6.2), `include` (bitmask), `privacyOneOf`, `stateOneOf` (>=8.2).
+`category` (without `OneOf`) is not the current parameter name — older wrappers using it
+silently drop the filter.
+
+## Which scope does the bundled CLI use?
+
+The bundled `scripts/peertube` performs **instance-local search only**: it issues
+`GET /search/videos` with `searchTarget=local` against `PEERTUBE_SERVER` and never claims
+fediverse-wide coverage. For fediverse-wide search, point the same commands at SepiaSearch
+(`PEERTUBE_SERVER=https://sepiasearch.org scripts/peertube search --query ...`) — the CLI
+is instance-agnostic by design, and SepiaSearch speaks the same API. The CLI's `search
+--help` text states its scope so nobody mistakes local results for the whole fediverse.
+
+## Worked recipes
+
+### Instance-local search, then full video detail
+
+```bash
+BASE="https://"
+curl -G "$BASE/api/v1/search/videos" \
+ --data-urlencode 'search=' \
+ --data-urlencode 'searchTarget=local' \
+ --data-urlencode 'start=0' --data-urlencode 'count=10'
+# data[].uuid / shortUUID / id all work as the {id} path parameter below
+curl "$BASE/api/v1/videos/"
+```
+
+### Fediverse-wide search via SepiaSearch
+
+```bash
+curl -G 'https://sepiasearch.org/api/v1/search/videos' \
+ --data-urlencode 'search=' \
+ --data-urlencode 'start=0' --data-urlencode 'count=10'
+# follow a result to its origin instance:
+# data[0].url / data[0].channel.host tell you where the video lives
+```
+
+### Local search with filters and relevance sort
+
+```bash
+curl -G "$BASE/api/v1/search/videos" \
+ --data-urlencode 'search=' \
+ --data-urlencode 'searchTarget=local' \
+ --data-urlencode 'sort=-views' \
+ --data-urlencode 'durationMin=300' \
+ --data-urlencode 'languageOneOf=en' \
+ --data-urlencode 'count=20'
+```
+
+## Sources
+
+- https://docs.joinpeertube.org/api-rest-reference.html (searchVideos, searchChannels
+ operations: searchTarget enum, parameter tables, third-party-index warning)
+- https://docs.joinpeertube.org/use/search (platform search vs global search semantics)
+- https://docs.joinpeertube.org/admin/configuration (Global search: external index
+ configuration, search.joinpeertube.org, non-moderation warning)
+- https://sepiasearch.org/ (what SepiaSearch is; indexed-site count)
+- https://sepiasearch.org/api/v1/search/videos?search=peertube&start=0&count=1
+ (live response shape, 2026-08-29)
+- https://docs.joinpeertube.org/CHANGELOG (version-drift notes)
+- Live anonymous probe of a public instance's `/search/videos` with and without
+ `searchTarget` (default-scope observation), 2026-08-29.
diff --git a/peertube/references/worked-recipes.md b/peertube/references/worked-recipes.md
new file mode 100644
index 0000000..d971a32
--- /dev/null
+++ b/peertube/references/worked-recipes.md
@@ -0,0 +1,153 @@
+# Worked recipes and CLI workflows
+
+Multi-step workflows for the bundled `scripts/peertube` CLI, plus raw curl/jq equivalents.
+Every stage's output field names and JSON types are what the next stage consumes — the
+pipelines are proven by the CLI's offline test suite. `PEERTUBE_SERVER` must be exported
+for all commands (any instance host works; SepiaSearch works too — see below).
+
+```bash
+export PEERTUBE_SERVER="https://" # e.g. https://tilvids.com
+```
+
+## CLI command map
+
+| Command | Does | Auth needed |
+| --- | --- | --- |
+| `server` | instance name + description (`/config/about`) + stats (`/server/stats`) | no |
+| `videos` | latest instance videos (`/videos`, offset paging) | no |
+| `search --query Q` | **instance-local** search (`/search/videos`, `searchTarget=local`) | no |
+| `video --id ID` | full video detail (id, UUID, or shortUUID) | no |
+| `comments --id ID` | top-level comment threads (`/comment-threads`) | no |
+| `channels` | instance channel list (`/video-channels`) | no |
+| `channel --handle H` | one channel's metadata + recent uploads | no |
+| `account --name N` | account metadata (`/accounts/{name}`) | no |
+| `me` | your profile (`/users/me`) | yes |
+| `my-videos` | your uploads (`/users/me/videos`) | yes |
+| `login` | OAuth2 password grant → persists token file | yes (credentials) |
+| `logout` | revoke token server-side + delete token file | yes (token) |
+
+Global flags: `--json` (machine output), `--dry-run` (print the request plan, zero
+network), `--limit N` (page size, max 100), `--offset N` (start offset). All flags work
+before or after the subcommand. `--help` and `--dry-run` never require credentials.
+
+## Recipe 1 — browse what's new, then inspect one video
+
+```bash
+scripts/peertube videos --limit 5 --json | jq -r '.videos[] | [.name, .uuid, .duration] | @tsv'
+UUID=$(scripts/peertube videos --limit 1 --json | jq -r '.videos[0].uuid')
+scripts/peertube video --id "$UUID" --json | jq '{name, description, views, likes, url}'
+```
+
+`videos` emits `{"total": , "videos": [...each raw video object with uuid/name/
+duration/views/publishedAt/channel/account...]}`; `video` emits the raw detail object
+(fields include `description`, `files[]`, `commentsEnabled`).
+
+## Recipe 2 — instance-local search, then pull the description
+
+```bash
+scripts/peertube search --query "linux" --limit 10 --json | jq -r '.videos[0].uuid'
+scripts/peertube search --query "linux" --limit 5 --json \
+ | jq -r '.videos[] | select(.language.label == "English") | .name'
+# detail for the top hit:
+scripts/peertube video --id "$(scripts/peertube search --query linux --limit 1 --json | jq -r '.videos[0].uuid')" --json
+```
+
+Search results are the same video-object shape as `videos` (plus nothing missing that the
+detail call needs — `uuid` is always present). To search the **whole fediverse** instead of
+one instance, point the same CLI at SepiaSearch:
+
+```bash
+PEERTUBE_SERVER="https://sepiasearch.org" scripts/peertube search --query "linux" --limit 10
+```
+
+## Recipe 3 — channels: find the busy ones, then page their uploads
+
+```bash
+scripts/peertube channels --json | jq -r '.channels[] | [.displayName, .name, .host, .videosCount, .followersCount] | @tsv' \
+ | sort -t$'\t' -k4,4nr | head
+# page through a channel's uploads with offsets (no page param exists):
+scripts/peertube channel --handle "framasoft@framatube.org" --limit 100 --offset 0 --json | jq -r '.videos[].name'
+scripts/peertube channel --handle "framasoft@framatube.org" --limit 100 --offset 100 --json | jq -c '{returned: (.videos | length), total}'
+```
+
+Handles accept `name` (local) or `name@host` (remote). The offset loop is the only
+pagination mechanism — stop when `returned` is 0 or `offset >= total`.
+
+## Recipe 4 — log in, check your quota, upload-aware housekeeping, log out
+
+```bash
+scripts/peertube login --username "" # prompts for password (hidden)
+scripts/peertube me --json | jq '{username, role: .role.label, quota_bytes: .videoQuota}'
+scripts/peertube my-videos --limit 100 --json | jq -r '.videos[] | [.name, .privacy.label, .duration] | @tsv'
+scripts/peertube logout # revokes server-side + deletes local file
+```
+
+The token file lands in `~/.config/peertube/token.json` (owner-only permissions;
+`PEERTUBE_CONFIG_DIR` overrides the directory for tests). It records the server URL,
+access token, refresh token, and absolute `expires_at`; the CLI re-authenticates if the
+server changes or the token is expired. `login --dry-run --json` previews the token
+request (fields only — no secret values) without network.
+
+## Recipe 5 — instance report card (compose three anonymous endpoints)
+
+```bash
+scripts/peertube server --json \
+ | jq '{name: .instance.name, description: .instance.shortDescription,
+ local_videos: .stats.totalLocalVideos, total_videos: .stats.totalVideos,
+ users: .stats.totalUsers, views: .stats.totalLocalVideoViews}'
+```
+
+Equivalent raw curl: `/api/v1/config/about` for identity, `/api/v1/server/stats` for the
+counters (note: the stats path is `/server/stats`, not `/instance/stats`).
+
+## Recipe 6 — jq processing patterns
+
+```bash
+# TSV table of the five most-viewed local videos
+scripts/peertube videos --limit 100 --json \
+ | jq -r '.videos | sort_by(-.views)[:5][] | [.name, .views, .channel.displayName] | @tsv'
+
+# Count videos per origin host on a search-index-style result set
+PEERTUBE_SERVER="https://sepiasearch.org" scripts/peertube search --query "peertube" --limit 100 --json \
+ | jq -r '.videos | group_by(.channel.host) | map({host: .[0].channel.host, n: length}) | sort_by(-.n)[] | "\(.n)\t\(.host)"'
+
+# Comments of a video, flattening thread counts
+scripts/peertube comments --id "" --json | jq '{total, total_not_deleted: .totalNotDeletedComments, threads: (.threads | length)}'
+
+# Verify the request plan before running it live (zero network)
+scripts/peertube --dry-run --json search --query "test" | jq '{path, params: (.params | keys)}'
+```
+
+`--dry-run` output shape: `{"dry_run": true, "method": "GET", "path": "/api/v1/...",
+"params": {...}}` — every plan carries exactly `dry_run`, `method`, `path`, and `params`
+(test-pinned in `scripts/test_peertube.py`); composite commands emit a `requests` array of
+those same keyed steps, and the `login` plan is a `POST /api/v1/users/token` whose
+`form_fields` lists the field NAMES only (never values). One jq pattern audits any
+command.
+
+## Raw curl equivalents (auth chain end-to-end)
+
+```bash
+BASE="$PEERTUBE_SERVER/api/v1"
+CLIENT_ID=$(curl -sS "$BASE/oauth-clients/local" | jq -r .client_id)
+# NOTE: production instances mask client_secret ("****...") in this response; if masked,
+# obtain the secret as the web client does (served front-end assets) before proceeding.
+curl -sS -X POST "$BASE/users/token" \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ --data-urlencode "client_id=$CLIENT_ID" \
+ --data-urlencode 'client_secret=' \
+ --data-urlencode 'grant_type=password' \
+ --data-urlencode 'username=' \
+ --data-urlencode 'password='
+ACCESS_TOKEN=""
+curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE/users/me"
+```
+
+## Sources
+
+- https://docs.joinpeertube.org/api/rest-getting-started (auth chain, pagination basics)
+- https://docs.joinpeertube.org/api-rest-reference.html (endpoint parameter tables and
+ response shapes referenced per recipe)
+- https://sepiasearch.org/api/v1/search/videos (fediverse-wide search base URL)
+- Field names/types corroborated by live anonymous probes and the CLI's offline mocked
+ tests, 2026-08-29.
diff --git a/peertube/scripts/peertube b/peertube/scripts/peertube
new file mode 100755
index 0000000..824b97a
--- /dev/null
+++ b/peertube/scripts/peertube
@@ -0,0 +1,782 @@
+#!/usr/bin/env python3
+"""peertube — browse PeerTube federated video from the terminal.
+
+Read-only client for any PeerTube instance's REST API, plus OAuth2 login.
+Commands: server, videos, video, search, comments, channels, channel,
+account, me, my-videos, login, logout. Set PEERTUBE_SERVER (or pass
+--server) to choose the instance; point it at https://sepiasearch.org
+for fediverse-wide search — the API shape is identical. `--json` emits
+machine-readable output, `--dry-run` prints the exact request plan with
+zero network activity, and `--help` works without credentials.
+"""
+
+import argparse
+import getpass
+import json
+import os
+import sys
+import time
+import warnings
+from typing import Any, Dict, Optional
+
+warnings.simplefilter("ignore")
+
+import requests
+
+ENV_SERVER = os.getenv("PEERTUBE_SERVER", "")
+ENV_CONFIG_DIR = os.getenv("PEERTUBE_CONFIG_DIR", "")
+API_BASE = "/api/v1"
+DEFAULT_CONFIG_DIR = "~/.config/peertube"
+TOKEN_FILE_NAME = "token.json"
+MAX_COUNT = 100
+DEFAULT_COUNT = 15
+
+GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False}
+
+
+def die(message, exit_code=1):
+ print(f"Error: {message}", file=sys.stderr)
+ sys.exit(exit_code)
+
+
+def warn(message):
+ print(f"Warning: {message}", file=sys.stderr)
+
+
+def emit(human, data):
+ if GLOBAL_FLAGS.get("json"):
+ print(json.dumps(data, default=str))
+ else:
+ print(human)
+
+
+def log(message):
+ if GLOBAL_FLAGS.get("verbose") and not GLOBAL_FLAGS.get("json"):
+ print(message, file=sys.stderr)
+
+
+def _preparse_global_flags(argv):
+ """Pull global flags out so they work before or after the subcommand.
+
+ Boolean flags are recorded as True; --server consumes its value. Anything
+ else passes through to the subparsers untouched."""
+ bools = {"--json", "--dry-run", "--quiet", "--verbose"}
+ flags, filtered = {}, [argv[0]]
+ i = 1
+ while i < len(argv):
+ arg = argv[i]
+ if arg in bools:
+ flags[arg.lstrip("-").replace("-", "_")] = True
+ i += 1
+ elif arg == "--server":
+ if i + 1 >= len(argv):
+ die("--server requires a value (the instance URL).")
+ flags["server"] = argv[i + 1]
+ i += 2
+ elif arg in ("--help", "-h"):
+ return flags, argv
+ elif arg == "--":
+ filtered.extend(argv[i:])
+ break
+ else:
+ filtered.append(arg)
+ i += 1
+ return flags, filtered
+
+
+def default_config_dir():
+ if ENV_CONFIG_DIR:
+ return os.path.expanduser(ENV_CONFIG_DIR)
+ return os.path.expanduser(DEFAULT_CONFIG_DIR)
+
+
+def fmt_duration(seconds):
+ try:
+ total = int(seconds or 0)
+ except (TypeError, ValueError):
+ return "?"
+ return f"{total // 60}:{total % 60:02d}"
+
+
+def fmt_date(value):
+ return str(value or "")[:10]
+
+
+def is_masked_secret(value):
+ """Production instances reply to oauth-clients/local with the secret
+ replaced by a run of '*' characters."""
+ return bool(value) and set(value) == {"*"}
+
+
+def request_plan(method, path, params=None, **extra):
+ plan: Dict[str, Any] = {"dry_run": True, "method": method, "path": path, "params": params or {}}
+ plan.update(extra)
+ return plan
+
+
+def channel_label(video):
+ channel = video.get("channel") or {}
+ account = video.get("account") or {}
+ display = channel.get("displayName") or account.get("displayName") or "?"
+ host = channel.get("host") or account.get("host")
+ return f"{display}@{host}" if host else display
+
+
+def video_line(video, index=None):
+ name = str(video.get("name") or "?")[:52]
+ views = video.get("views") or 0
+ when = fmt_date(video.get("publishedAt"))
+ prefix = f"{index:>3}. " if index else " - "
+ return f" {prefix}{name:<52} {fmt_duration(video.get('duration')):>6} {views:>8} views {channel_label(video)} {when}"
+
+
+class PeerTubeClient:
+ """REST client for one PeerTube instance (federated: tokens are per-instance)."""
+
+ def __init__(self, server="", dry_run=False, config_dir=None):
+ self.server = (server or ENV_SERVER).rstrip("/")
+ self.dry_run = dry_run
+ self.config_dir = config_dir or default_config_dir()
+ self._token: Optional[str] = None
+ self._refresh_token: Optional[str] = None
+ self._expires_at: Optional[float] = None
+ self._load_token()
+
+ # ----- instance / URL helpers -------------------------------------
+
+ def base_url(self):
+ if not self.server:
+ die("No instance configured. Export PEERTUBE_SERVER (e.g. https://) "
+ "or pass --server.")
+ return self.server
+
+ def display_server(self):
+ return self.server or "https://"
+
+ def _url(self, path):
+ return f"{self.base_url()}{API_BASE}{path}"
+
+ def _headers(self, with_token=True):
+ headers = {"Accept": "application/json"}
+ if with_token and self._token:
+ headers["Authorization"] = f"Bearer {self._token}"
+ return headers
+
+ # ----- token file persistence (per-instance, owner-only) ----------
+
+ def token_path(self):
+ return os.path.join(self.config_dir, TOKEN_FILE_NAME)
+
+ def _load_token(self):
+ try:
+ with open(self.token_path()) as handle:
+ data = json.load(handle)
+ except (OSError, ValueError):
+ return
+ stored_server = str(data.get("server") or "").rstrip("/")
+ if self.server and stored_server and stored_server != self.server:
+ return # token was minted by a different instance
+ self._token = data.get("access_token") or None
+ self._refresh_token = data.get("refresh_token") or None
+ expires_at = data.get("expires_at")
+ self._expires_at = float(expires_at) if expires_at else None
+
+ def save_session(self, token_response):
+ access = token_response.get("access_token")
+ if not access:
+ die("Login response carried no access_token; nothing to persist.")
+ expires_in = token_response.get("expires_in")
+ expires_at = time.time() + float(expires_in) if expires_in else None
+ record = {
+ "server": self.server,
+ "access_token": access,
+ "refresh_token": token_response.get("refresh_token"),
+ "token_type": token_response.get("token_type", "Bearer"),
+ "expires_at": expires_at,
+ "expires_in": expires_in,
+ }
+ os.makedirs(self.config_dir, mode=0o700, exist_ok=True)
+ fd = os.open(self.token_path(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w") as handle:
+ json.dump(record, handle, indent=2)
+ self._token = access
+ self._refresh_token = record["refresh_token"]
+ self._expires_at = expires_at
+
+ def clear_token(self):
+ try:
+ os.remove(self.token_path())
+ except OSError:
+ pass
+ self._token = None
+ self._refresh_token = None
+ self._expires_at = None
+
+ def token_is_valid(self):
+ if not self._token:
+ return False
+ return self._expires_at is None or time.time() < self._expires_at
+
+ def ensure_token(self):
+ """Authed commands need a live token; try refresh before giving up."""
+ if self.token_is_valid():
+ return
+ if self._refresh_token and not self.dry_run and self.refresh():
+ return
+ die(f"Not authenticated for {self.display_server()}. Run "
+ f"'peertube login --username ' first, or use --dry-run to preview.")
+
+ # ----- OAuth2 flows ------------------------------------------------
+
+ def fetch_oauth_client(self):
+ """Anonymous GET /oauth-clients/local (singular 'local')."""
+ url = self._url("/oauth-clients/local")
+ log(f"--> GET {url}")
+ try:
+ response = requests.get(url, headers={"Accept": "application/json"}, timeout=15)
+ except requests.exceptions.RequestException as exc:
+ die(f"Cannot reach {self.display_server()} ({exc.__class__.__name__}). "
+ f"Check PEERTUBE_SERVER and connectivity.")
+ if response.status_code >= 400:
+ die(f"oauth-clients/local returned {response.status_code}: {response.text[:200]}")
+ try:
+ payload = response.json()
+ except ValueError:
+ die(f"Non-JSON response from oauth-clients/local; is {self.display_server()} a PeerTube instance?")
+ client_id = payload.get("client_id")
+ if not client_id:
+ die("oauth-clients/local response missing client_id.")
+ return client_id, payload.get("client_secret") or ""
+
+ def request_token(self, client_id, client_secret, username, password, otp=None):
+ """POST /users/token with the password grant (x-www-form-urlencoded)."""
+ form = {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "grant_type": "password",
+ "username": username,
+ "password": password,
+ }
+ headers = {"Accept": "application/json"}
+ if otp:
+ headers["x-peertube-otp"] = otp
+ url = self._url("/users/token")
+ log("--> POST /users/token (password grant, values not logged)")
+ try:
+ response = requests.post(url, data=form, headers=headers, timeout=15)
+ except requests.exceptions.RequestException as exc:
+ die(f"Cannot reach {self.display_server()} ({exc.__class__.__name__}) during token request.")
+ if response.status_code == 401:
+ die("Login failed (401): token request rejected. If the account uses two-factor "
+ "authentication, pass --otp.")
+ if response.status_code >= 400:
+ detail = ""
+ try:
+ body = response.json()
+ if isinstance(body, dict):
+ detail = body.get("detail") or body.get("title") or body.get("error") or ""
+ except ValueError:
+ detail = (response.text or "")[:200]
+ die(f"Login failed ({response.status_code}): invalid client credentials or wrong "
+ f"username/password. {detail}".rstrip())
+ try:
+ return response.json()
+ except ValueError:
+ die("Token endpoint returned non-JSON.")
+
+ def refresh(self):
+ """grant_type=refresh_token. The official reference does not render the
+ exact refresh form fields, so this uses the community-established shape
+ and treats any failure as a signal to fall back to a password re-grant."""
+ if not self._refresh_token:
+ return False
+ try:
+ client_id, client_secret = self.fetch_oauth_client()
+ except SystemExit:
+ return False
+ if is_masked_secret(client_secret) or not client_secret:
+ return False
+ form = {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "grant_type": "refresh_token",
+ "refresh_token": self._refresh_token,
+ }
+ url = self._url("/users/token")
+ log("--> POST /users/token (refresh grant, values not logged)")
+ try:
+ response = requests.post(url, data=form, headers={"Accept": "application/json"}, timeout=15)
+ except requests.exceptions.RequestException:
+ return False
+ if response.status_code >= 400:
+ return False
+ try:
+ data = response.json()
+ except ValueError:
+ return False
+ if not data.get("access_token"):
+ return False
+ self.save_session(data)
+ return True
+
+ def revoke_token(self):
+ """POST /users/revoke-token invalidates the access and refresh tokens."""
+ url = self._url("/users/revoke-token")
+ log(f"--> POST {url}")
+ try:
+ return requests.post(url, headers=self._headers(), timeout=15)
+ except requests.exceptions.RequestException as exc:
+ die(f"Cannot reach {self.display_server()} ({exc.__class__.__name__}) during revocation.")
+
+ # ----- read endpoints ----------------------------------------------
+
+ def _get(self, path, params=None):
+ url = self._url(path)
+ if self.dry_run:
+ return request_plan("GET", f"{API_BASE}{path}", params)
+ log(f"--> GET {url}")
+ try:
+ response = requests.get(url, params=params, headers=self._headers(), timeout=30)
+ except requests.exceptions.RequestException as exc:
+ die(f"Cannot reach {self.display_server()} ({exc.__class__.__name__}). "
+ f"Check PEERTUBE_SERVER and connectivity.")
+ return self._handle(response, f"{API_BASE}{path}")
+
+ def _handle(self, response, path):
+ status = response.status_code
+ if status >= 400:
+ detail = ""
+ try:
+ body = response.json()
+ if isinstance(body, dict):
+ detail = body.get("detail") or body.get("title") or body.get("error") or ""
+ except ValueError:
+ detail = (response.text or "")[:200]
+ if status == 401:
+ die(f"401 Unauthorized on {path}: {detail or 'token missing, expired, or revoked'}. "
+ f"Run 'peertube login' or check the instance.")
+ if status == 429:
+ retry = response.headers.get("Retry-After", "?")
+ die(f"429 rate limited on {path}; Retry-After: {retry}s "
+ f"(default server limit: 50 calls per 10 seconds).")
+ die(f"API error {status} on {path}: {detail or 'no detail in body'}")
+ try:
+ return response.json()
+ except ValueError:
+ die(f"Non-JSON response from {path} (status {status}); "
+ f"is {self.display_server()} a PeerTube instance?")
+
+
+# ----- command handlers ------------------------------------------------
+
+
+def validate_page_args(count, start):
+ if count < 1 or count > MAX_COUNT:
+ die(f"--limit must be between 1 and {MAX_COUNT} (server maximum).")
+ if start < 0:
+ die("--offset must be >= 0.")
+
+
+def cmd_server(client, args):
+ if client.dry_run:
+ plan = {"dry_run": True, "requests": [
+ {"method": "GET", "path": f"{API_BASE}/config/about", "params": {}},
+ {"method": "GET", "path": f"{API_BASE}/server/stats", "params": {}},
+ ]}
+ return emit("[dry-run] GET /config/about + /server/stats", plan)
+ about = client._get("/config/about") or {}
+ stats = client._get("/server/stats") or {}
+ instance = about.get("instance") or {}
+ name = instance.get("name", "?")
+ description = instance.get("shortDescription") or ""
+ keys = ("totalUsers", "totalLocalVideos", "totalVideos",
+ "totalLocalVideoViews", "totalLocalVideoDownloads", "totalLocalVideoChannels")
+ payload = {
+ "instance": {
+ "name": name,
+ "shortDescription": instance.get("shortDescription"),
+ "description": instance.get("description"),
+ },
+ "stats": {key: stats.get(key) for key in keys},
+ }
+ stats_line = " ".join(f"{key}={payload['stats'][key]}" for key in keys if payload['stats'][key] is not None)
+ emit(f"Instance {name}\n {description[:100]}\n {stats_line}", payload)
+
+
+def cmd_videos(client, args):
+ parser = argparse.ArgumentParser(prog="peertube videos")
+ parser.add_argument("--limit", type=int, default=DEFAULT_COUNT)
+ parser.add_argument("--offset", type=int, default=0)
+ parser.add_argument("--sort", default="-publishedAt")
+ parsed, _ = parser.parse_known_args(args)
+ validate_page_args(parsed.limit, parsed.offset)
+ params = {"start": parsed.offset, "count": parsed.limit, "sort": parsed.sort}
+ if client.dry_run:
+ return emit("[dry-run] GET /api/v1/videos " + json.dumps(params),
+ request_plan("GET", f"{API_BASE}/videos", params))
+ data = client._get("/videos", params) or {}
+ videos = data.get("data", [])
+ total = data.get("total", len(videos))
+ if not videos:
+ return emit("No videos.", {"total": total, "start": parsed.offset, "count": 0, "videos": []})
+ lines = [video_line(v, i + 1) for i, v in enumerate(videos)]
+ emit(f"{total} video(s) on {client.display_server()}:\n" + "\n".join(lines),
+ {"total": total, "start": parsed.offset, "count": len(videos), "videos": videos})
+
+
+def cmd_video(client, args):
+ parser = argparse.ArgumentParser(prog="peertube video")
+ parser.add_argument("--id", required=True, help="Numeric id, UUID, or shortUUID")
+ parsed, _ = parser.parse_known_args(args)
+ if client.dry_run:
+ return emit(f"[dry-run] GET {API_BASE}/videos/{parsed.id}",
+ request_plan("GET", f"{API_BASE}/videos/{parsed.id}", {}))
+ detail = client._get(f"/videos/{parsed.id}") or {}
+ emit(f"{detail.get('name', '?')} [{fmt_duration(detail.get('duration'))}] "
+ f"{detail.get('views', 0)} views by {channel_label(detail)}", detail)
+
+
+def cmd_search(client, args):
+ parser = argparse.ArgumentParser(
+ prog="peertube search",
+ description="Search the configured instance's OWN catalog (searchTarget=local). "
+ "For fediverse-wide search set PEERTUBE_SERVER=https://sepiasearch.org — "
+ "same commands, wider index.")
+ parser.add_argument("--query", "-q", required=True)
+ parser.add_argument("--limit", type=int, default=DEFAULT_COUNT)
+ parser.add_argument("--offset", type=int, default=0)
+ parser.add_argument("--sort", default=None)
+ parser.add_argument("--search-target", choices=["local", "search-index"], default="local")
+ parsed, _ = parser.parse_known_args(args)
+ validate_page_args(parsed.limit, parsed.offset)
+ params: Dict[str, Any] = {"search": parsed.query, "searchTarget": parsed.search_target,
+ "start": parsed.offset, "count": parsed.limit}
+ if parsed.sort:
+ params["sort"] = parsed.sort
+ if client.dry_run:
+ return emit(f"[dry-run] GET {API_BASE}/search/videos " + json.dumps(params),
+ request_plan("GET", f"{API_BASE}/search/videos", params))
+ data = client._get("/search/videos", params) or {}
+ videos = data.get("data", [])
+ total = data.get("total", len(videos))
+ if not videos:
+ return emit("No results.", {"total": total, "start": parsed.offset, "count": 0, "videos": []})
+ lines = [video_line(v, i + 1) for i, v in enumerate(videos)]
+ scope = ("instance-local" if parsed.search_target == "local" else "search-index")
+ emit(f"{total} result(s) [{scope} search on {client.display_server()}]:\n" + "\n".join(lines),
+ {"total": total, "start": parsed.offset, "count": len(videos), "videos": videos})
+
+
+def cmd_comments(client, args):
+ parser = argparse.ArgumentParser(prog="peertube comments")
+ parser.add_argument("--id", required=True, help="Video id, UUID, or shortUUID")
+ parser.add_argument("--limit", type=int, default=DEFAULT_COUNT)
+ parser.add_argument("--offset", type=int, default=0)
+ parsed, _ = parser.parse_known_args(args)
+ validate_page_args(parsed.limit, parsed.offset)
+ params = {"start": parsed.offset, "count": parsed.limit, "sort": "-createdAt"}
+ if client.dry_run:
+ return emit(f"[dry-run] GET {API_BASE}/videos/{parsed.id}/comment-threads " + json.dumps(params),
+ request_plan("GET", f"{API_BASE}/videos/{parsed.id}/comment-threads", params))
+ data = client._get(f"/videos/{parsed.id}/comment-threads", params) or {}
+ threads = data.get("data", [])
+ total = data.get("total", len(threads))
+ lines = []
+ for thread in threads:
+ comment = thread.get("comment") or {}
+ account = comment.get("account") or {}
+ text = str(comment.get("text") or "")[:70].replace("\n", " ")
+ lines.append(f" {thread.get('totalReplies', 0):>3} replies @{account.get('name', '?')} {text}")
+ emit(f"{total} comment thread(s):\n" + ("\n".join(lines) if lines else " (none)"),
+ {"total": total, "total_not_deleted": data.get("totalNotDeletedComments"),
+ "start": parsed.offset, "threads": threads})
+
+
+def cmd_channels(client, args):
+ parser = argparse.ArgumentParser(prog="peertube channels")
+ parser.add_argument("--limit", type=int, default=DEFAULT_COUNT)
+ parser.add_argument("--offset", type=int, default=0)
+ parsed, _ = parser.parse_known_args(args)
+ validate_page_args(parsed.limit, parsed.offset)
+ params = {"start": parsed.offset, "count": parsed.limit}
+ if client.dry_run:
+ return emit(f"[dry-run] GET {API_BASE}/video-channels " + json.dumps(params),
+ request_plan("GET", f"{API_BASE}/video-channels", params))
+ data = client._get("/video-channels", params) or {}
+ channels = data.get("data", [])
+ total = data.get("total", len(channels))
+ if not channels:
+ return emit("No channels.", {"total": total, "start": parsed.offset, "count": 0, "channels": []})
+ lines = []
+ for channel in channels:
+ display = channel.get("displayName") or channel.get("name") or "?"
+ handle = f"{channel.get('name', '?')}@{channel.get('host', '?')}"
+ lines.append(f" {display:<30} {handle:<40} "
+ f"{channel.get('videosCount', '?')} videos {channel.get('followersCount', '?')} followers")
+ emit(f"{total} channel(s) on {client.display_server()}:\n" + "\n".join(lines),
+ {"total": total, "start": parsed.offset, "count": len(channels), "channels": channels})
+
+
+def cmd_channel(client, args):
+ parser = argparse.ArgumentParser(prog="peertube channel")
+ parser.add_argument("--handle", required=True, help="Channel name or name@host")
+ parser.add_argument("--limit", type=int, default=DEFAULT_COUNT)
+ parser.add_argument("--offset", type=int, default=0)
+ parsed, _ = parser.parse_known_args(args)
+ validate_page_args(parsed.limit, parsed.offset)
+ params = {"start": parsed.offset, "count": parsed.limit}
+ if client.dry_run:
+ plan = {"dry_run": True, "requests": [
+ {"method": "GET", "path": f"{API_BASE}/video-channels/{parsed.handle}", "params": {}},
+ {"method": "GET", "path": f"{API_BASE}/video-channels/{parsed.handle}/videos", "params": params},
+ ]}
+ return emit(f"[dry-run] GET /video-channels/{parsed.handle} (+ /videos)", plan)
+ meta = client._get(f"/video-channels/{parsed.handle}") or {}
+ videos_data = client._get(f"/video-channels/{parsed.handle}/videos", params) or {}
+ videos = videos_data.get("data", [])
+ display = meta.get("displayName") or meta.get("name") or parsed.handle
+ lines = [video_line(v, i + 1) for i, v in enumerate(videos)]
+ emit(f"{display} ({meta.get('followersCount', '?')} followers, {meta.get('videosCount', '?')} videos)\n"
+ + ("\n".join(lines) if lines else " (no videos listed)"),
+ {"channel": meta, "total": videos_data.get("total", len(videos)),
+ "start": parsed.offset, "videos": videos})
+
+
+def cmd_account(client, args):
+ parser = argparse.ArgumentParser(prog="peertube account")
+ parser.add_argument("--name", required=True, help="Account name or name@host")
+ parsed, _ = parser.parse_known_args(args)
+ if client.dry_run:
+ return emit(f"[dry-run] GET {API_BASE}/accounts/{parsed.name}",
+ request_plan("GET", f"{API_BASE}/accounts/{parsed.name}", {}))
+ account = client._get(f"/accounts/{parsed.name}") or {}
+ display = account.get("displayName") or account.get("name") or parsed.name
+ handle = f"{account.get('name', parsed.name)}@{account.get('host', '?')}"
+ emit(f"{display} ({handle}) {account.get('followersCount', '?')} followers", account)
+
+
+def cmd_me(client, args):
+ if client.dry_run:
+ return emit(f"[dry-run] GET {API_BASE}/users/me (Authorization: Bearer )",
+ request_plan("GET", f"{API_BASE}/users/me", {}))
+ client.ensure_token()
+ data = client._get("/users/me") or {}
+ if isinstance(data, list): # docs render an array sample; live servers return one object
+ data = data[0] if data else {}
+ role = data.get("role") if isinstance(data, dict) else {}
+ if not isinstance(role, dict): # some instances/versions may send a scalar role id
+ role = {"id": role, "label": str(role)}
+ quota = data.get("videoQuota")
+ quota_text = f"{quota} bytes" if quota is not None else "?"
+ emit(f"@{data.get('username', '?')} Role: {role.get('label', '?')} Quota: {quota_text}", data)
+
+
+def cmd_my_videos(client, args):
+ parser = argparse.ArgumentParser(prog="peertube my-videos")
+ parser.add_argument("--limit", type=int, default=DEFAULT_COUNT)
+ parser.add_argument("--offset", type=int, default=0)
+ parsed, _ = parser.parse_known_args(args)
+ validate_page_args(parsed.limit, parsed.offset)
+ params = {"start": parsed.offset, "count": parsed.limit}
+ if client.dry_run:
+ return emit(f"[dry-run] GET {API_BASE}/users/me/videos " + json.dumps(params),
+ request_plan("GET", f"{API_BASE}/users/me/videos", params))
+ client.ensure_token()
+ data = client._get("/users/me/videos", params) or {}
+ videos = data.get("data", [])
+ total = data.get("total", len(videos))
+ lines = [video_line(v, i + 1) for i, v in enumerate(videos)]
+ emit(f"{total} of your video(s):\n" + ("\n".join(lines) if lines else " (none)"),
+ {"total": total, "start": parsed.offset, "count": len(videos), "videos": videos})
+
+
+def cmd_login(client, args):
+ parser = argparse.ArgumentParser(
+ prog="peertube login",
+ description="OAuth2 password grant: fetch the instance's client pair, exchange your "
+ "credentials for a bearer token, and persist it (owner-only file, "
+ "per-instance). Password is never echoed.")
+ parser.add_argument("--username", "-u", required=True)
+ parser.add_argument("--server", help="Instance URL override, e.g. https://")
+ password_group = parser.add_mutually_exclusive_group()
+ password_group.add_argument("--password", help="Account password (prefer --password-stdin or --prompt)")
+ password_group.add_argument("--password-stdin", action="store_true", help="Read password from stdin")
+ password_group.add_argument("--prompt", action="store_true", help="Prompt for the password (hidden)")
+ parser.add_argument("--otp", help="One-time password when the account has 2FA enabled")
+ parsed, _ = parser.parse_known_args(args)
+ if client.dry_run:
+ return emit("[dry-run] POST /api/v1/users/token (password grant)",
+ request_plan("POST", f"{API_BASE}/users/token", {},
+ form_fields=["client_id", "client_secret", "grant_type",
+ "username", "password"],
+ note="field values suppressed; run without --dry-run to authenticate"))
+ server = (parsed.server or client.server).rstrip("/")
+ if not server:
+ die("No instance configured. Export PEERTUBE_SERVER or pass --server https://.")
+ login_client = PeerTubeClient(server=server, config_dir=client.config_dir)
+ if not parsed.password_stdin and not parsed.prompt and parsed.password is None:
+ die("No password supplied. Use --password, --password-stdin, or --prompt.")
+ if parsed.password_stdin:
+ password = sys.stdin.readline().rstrip("\n")
+ elif parsed.prompt:
+ password = getpass.getpass(f"Password for {parsed.username}@{server}: ")
+ else:
+ password = parsed.password
+ client_id, client_secret = login_client.fetch_oauth_client()
+ if is_masked_secret(client_secret):
+ die("This instance masks client_secret in oauth-clients/local ('*' characters). "
+ "Production PeerTube front ends obtain the real pair from their own served assets; "
+ "see references/auth-and-tokens.md for the workaround before retrying login.")
+ token_response = login_client.request_token(client_id, client_secret, parsed.username,
+ password, otp=parsed.otp)
+ login_client.save_session(token_response)
+ expires_in = token_response.get("expires_in")
+ expires_text = f", expires in {expires_in}s" if expires_in else ""
+ emit(f"Logged in to {server} as {parsed.username}{expires_text}\n"
+ f"Token file: {login_client.token_path()}",
+ {"status": "logged_in", "server": server, "username": parsed.username,
+ "token_file": login_client.token_path(), "expires_in": expires_in})
+
+
+def cmd_logout(client, args):
+ if client.dry_run:
+ return emit(f"[dry-run] POST {API_BASE}/users/revoke-token",
+ request_plan("POST", f"{API_BASE}/users/revoke-token", {}))
+ if not client.server:
+ die("No instance configured. Export PEERTUBE_SERVER or pass --server.")
+ if not client._token and not client._refresh_token:
+ die(f"No stored token for {client.server}; nothing to revoke.")
+ response = client.revoke_token()
+ status = response.status_code
+ if status == 200:
+ client.clear_token()
+ return emit("Token revoked and local token file removed.",
+ {"status": "logged_out", "revoked": True, "server": client.server})
+ if status == 401:
+ client.clear_token()
+ warn("Server rejected revocation (401); the token was likely already invalid. "
+ "Local token file removed.")
+ return emit("Logged out (token was already invalid server-side).",
+ {"status": "logged_out", "revoked": False, "server": client.server})
+ die(f"Revocation failed ({status}). Local token file kept; retry or remove "
+ f"{client.token_path()} manually.")
+
+
+def build_parser():
+ parser = argparse.ArgumentParser(
+ prog="peertube",
+ description="PeerTube federated video from the terminal.",
+ epilog="Set PEERTUBE_SERVER (any instance host, e.g. https://) or pass "
+ "--server. Fediverse-wide search: PEERTUBE_SERVER=https://sepiasearch.org. "
+ "Login: peertube login --username NAME --prompt")
+ parser.add_argument("--server",
+ help="Instance URL (default: PEERTUBE_SERVER env var)")
+ parser.add_argument("--json", action="store_true", help="Machine-readable JSON output")
+ parser.add_argument("--dry-run", action="store_true",
+ help="Print the request plan without any network call")
+ parser.add_argument("--quiet", action="store_true",
+ help="Accepted for compatibility; no effect")
+ parser.add_argument("--verbose", action="store_true", help="Trace requests on stderr")
+ sub = parser.add_subparsers(dest="command")
+
+ sub.add_parser("server", help="Instance name, description, and stats (anonymous)",
+ description="Compose /config/about and /server/stats for an instance picture.",
+ epilog="Example: peertube server --json")
+ vp = sub.add_parser("videos", help="List the instance's videos (offset pagination)",
+ description="GET /videos with start/count pagination (there is no page parameter).",
+ epilog="Example: peertube videos --limit 5 --json")
+ vp.add_argument("--limit", type=int, default=DEFAULT_COUNT, help="Page size, 1-100")
+ vp.add_argument("--offset", type=int, default=0, help="Zero-based result offset")
+ vp.add_argument("--sort", default="-publishedAt", help="e.g. -publishedAt, -views, -trending")
+ vi = sub.add_parser("video", help="Full detail for one video",
+ description="GET /videos/{id}; id accepts numeric id, UUID, or shortUUID.",
+ epilog="Example: peertube video --id ")
+ vi.add_argument("--id", required=True)
+ sp = sub.add_parser("search", help="Search this instance's own catalog",
+ description="Instance-local search (searchTarget=local). For fediverse-wide "
+ "search point PEERTUBE_SERVER at https://sepiasearch.org.",
+ epilog="Example: peertube search --query linux --limit 10")
+ sp.add_argument("--query", "-q", required=True)
+ sp.add_argument("--limit", type=int, default=DEFAULT_COUNT, help="Page size, 1-100")
+ sp.add_argument("--offset", type=int, default=0)
+ sp.add_argument("--sort", default=None, help="e.g. -views, -match, -publishedAt")
+ sp.add_argument("--search-target", choices=["local", "search-index"], default="local",
+ help="local = this instance's catalog; search-index = external index if the "
+ "instance has one")
+ cp = sub.add_parser("comments", help="Top-level comment threads of a video",
+ description="GET /videos/{id}/comment-threads (hyphenated route).",
+ epilog="Example: peertube comments --id ")
+ cp.add_argument("--id", required=True)
+ cp.add_argument("--limit", type=int, default=DEFAULT_COUNT, help="Page size, 1-100")
+ cp.add_argument("--offset", type=int, default=0)
+ hp = sub.add_parser("channels", help="List the instance's channels",
+ description="GET /video-channels with start/count pagination.",
+ epilog="Example: peertube channels --limit 20 --json")
+ hp.add_argument("--limit", type=int, default=DEFAULT_COUNT, help="Page size, 1-100")
+ hp.add_argument("--offset", type=int, default=0)
+ cv = sub.add_parser("channel", help="One channel's metadata and uploads",
+ description="GET /video-channels/{handle} and its videos; handle is name or "
+ "name@host for remote channels.",
+ epilog="Example: peertube channel --handle framasoft@framatube.org")
+ cv.add_argument("--handle", required=True)
+ cv.add_argument("--limit", type=int, default=DEFAULT_COUNT, help="Page size, 1-100")
+ cv.add_argument("--offset", type=int, default=0)
+ ap = sub.add_parser("account", help="One account's metadata",
+ description="GET /accounts/{name}; name accepts name or name@host.",
+ epilog="Example: peertube account --name chocobozzz@framatube.org")
+ ap.add_argument("--name", required=True)
+ sub.add_parser("me", help="Your profile (requires login)",
+ description="GET /users/me with the persisted bearer token.",
+ epilog="Example: peertube me --json | jq .role.label")
+ mv = sub.add_parser("my-videos", help="Your uploads (requires login)",
+ description="GET /users/me/videos with start/count pagination.",
+ epilog="Example: peertube my-videos --limit 50 --json")
+ mv.add_argument("--limit", type=int, default=DEFAULT_COUNT, help="Page size, 1-100")
+ mv.add_argument("--offset", type=int, default=0)
+ lg = sub.add_parser("login", help="Authenticate (OAuth2 password grant) and persist a token",
+ description="Fetch the instance's OAuth client pair, run the password grant, "
+ "and persist the token per-instance in an owner-only file.",
+ epilog="Example: peertube login --username NAME --prompt")
+ lg.add_argument("--username", "-u", help="PeerTube username")
+ lg_password_group = lg.add_mutually_exclusive_group()
+ lg_password_group.add_argument("--password", help="Account password (prefer --password-stdin or --prompt)")
+ lg_password_group.add_argument("--password-stdin", action="store_true", help="Read the password from stdin")
+ lg_password_group.add_argument("--prompt", action="store_true", help="Prompt for the password (hidden)")
+ lg.add_argument("--otp", help="One-time password when the account has 2FA enabled")
+ lg.add_argument("--server", help="Instance URL override, e.g. https://")
+ lo = sub.add_parser("logout", help="Revoke the token server-side and delete the local token file",
+ description="POST /users/revoke-token, then remove the local token file.",
+ epilog="Example: peertube logout")
+ return parser
+
+
+def main():
+ global GLOBAL_FLAGS
+ GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
+ if GLOBAL_FLAGS.get("json"):
+ warnings.simplefilter("ignore")
+
+ parser = build_parser()
+ args = parser.parse_args(filtered_argv[1:])
+ if not args.command:
+ parser.print_help()
+ sys.exit(1)
+
+ client = PeerTubeClient(server=args.server or GLOBAL_FLAGS.get("server") or "",
+ dry_run=GLOBAL_FLAGS.get("dry_run", False))
+ handlers = {
+ "server": cmd_server, "videos": cmd_videos, "video": cmd_video,
+ "search": cmd_search, "comments": cmd_comments, "channels": cmd_channels,
+ "channel": cmd_channel, "account": cmd_account, "me": cmd_me,
+ "my-videos": cmd_my_videos, "login": cmd_login, "logout": cmd_logout,
+ }
+ handler = handlers.get(args.command)
+ if not handler:
+ parser.print_help()
+ sys.exit(1)
+ remaining = filtered_argv[filtered_argv.index(args.command) + 1:]
+ handler(client, remaining)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/peertube/scripts/peertube-cli b/peertube/scripts/peertube-cli
deleted file mode 100755
index 4e37cf4..0000000
--- a/peertube/scripts/peertube-cli
+++ /dev/null
@@ -1,239 +0,0 @@
-#!/usr/bin/env python3
-"""peertube-cli — PeerTube federated video from the terminal.
-
-Browse videos, channels, and playlists on any PeerTube instance.
-Login with OAuth2 for authenticated operations.
-"""
-
-import argparse, json, os, sys, time, warnings
-from typing import Any, Dict, List, Optional, Tuple
-warnings.simplefilter("ignore")
-import requests
-
-ENV_SERVER = os.getenv("PEERTUBE_SERVER", "")
-CONFIG_DIR = os.path.expanduser(os.getenv("PEERTUBE_CONFIG_DIR", "~/.config/peertube-cli"))
-API_BASE = "/api/v1"
-
-QUIET = False
-GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False}
-def log(m): global QUIET; (not QUIET and not GLOBAL_FLAGS.get("json")) and print(m)
-def warn(m): print(f"Warning: {m}", file=sys.stderr)
-def die(m, c=1): print(f"Error: {m}", file=sys.stderr); sys.exit(c)
-def emit(h, d):
- if GLOBAL_FLAGS.get("json"): print(json.dumps(d, default=str))
- else: print(h)
-
-def _preparse(argv):
- BOOLS = {"--json","--dry-run","--quiet","--verbose"}
- f, fl = {}, [argv[0]]
- i = 1
- while i < len(argv):
- a = argv[i]
- if a in BOOLS: f[a.lstrip("-").replace("-","_")] = True; i += 1
- elif a in ("--help","-h"): return f, argv
- elif a == "--": fl.extend(argv[i:]); break
- else: fl.append(a); i += 1
- return f, fl
-
-class PeerTubeClient:
- def __init__(self, server="", dry_run=False):
- self.server = (server or ENV_SERVER or "https://your-instance.example.com").rstrip("/")
- self.dry_run = dry_run
- self._token = None
- # Try to load saved token
- token_path = os.path.join(CONFIG_DIR, "token.json")
- if os.path.isfile(token_path):
- try:
- with open(token_path) as f: data = json.load(f)
- if data.get("expires_at", 0) > time.time():
- self._token = data.get("access_token")
- except: pass
-
- def _oauth_login(self):
- """Fetch OAuth client creds and exchange for token."""
- try:
- r = requests.get(f"{self.server}{API_BASE}/oauth-clients/local", timeout=15)
- clients = r.json()
- except: die(f"Cannot reach {self.server}. Check PEERTUBE_SERVER.")
- return clients.get("client_id", ""), clients.get("client_secret", "")
-
- def login(self, username, password):
- """Login and persist OAuth token."""
- cid, csec = self._oauth_login()
- try:
- r = requests.post(f"{self.server}{API_BASE}/users/token", data={
- "client_id": cid, "client_secret": csec,
- "grant_type": "password", "username": username,
- "password": password,
- "response_type": "code"}, timeout=15)
- except ConnectionError as e: die(f"Cannot connect: {e}")
- if r.status_code >= 400: die(f"Login failed: {r.text[:200]}")
- data = r.json()
- self._token = data.get("access_token")
- # Persist token
- os.makedirs(CONFIG_DIR, exist_ok=True)
- with open(os.path.join(CONFIG_DIR, "token.json"), "w") as f:
- json.dump({"access_token": self._token, "expires_at": time.time() + data.get("expires_in", 86400)}, f)
- return data
-
- def _headers(self):
- h = {"Accept": "application/json"}
- if self._token: h["Authorization"] = f"Bearer {self._token}"
- return h
-
- def _get(self, path, params=None):
- url = f"{self.server}{API_BASE}{path}"
- if self.dry_run: return {"dry_run":True, "url":url, "params":params, "total":0, "data":[]}
- try:
- r = requests.get(url, params=params, headers=self._headers(), timeout=30)
- except ConnectionError as e: die(f"Cannot connect: {e}")
- if r.status_code == 401:
- die("Not authenticated. Run 'peertube auth login' first.")
- if r.status_code >= 400:
- try: d = r.json()
- except: d = r.text[:200]
- die(f"API error ({r.status_code}): {d}")
- return r.json()
-
- def get_server_info(self): return self._get("/server/")
-
- def list_videos(self, page=1, limit=12, sort="-publishedAt"):
- return self._get("/videos", {"page":page, "count":limit, "sort":sort})
-
- def search_videos(self, query, page=1, limit=12):
- return self._get("/search/videos", {"search":query, "page":page, "count":limit})
-
- def get_video(self, vid): return self._get(f"/videos/{vid}")
-
- def list_video_comments(self, vid, page=1, limit=10):
- return self._get(f"/videos/{vid}/comments", {"page":page, "count":limit})
-
- def list_channels(self, page=1, limit=15):
- return self._get("/video-channels", {"page":page, "count":limit})
-
- def get_channel(self, name): return self._get(f"/video-channels/{name}")
-
- def list_channel_videos(self, name, page=1, limit=12):
- return self._get(f"/video-channels/{name}/videos", {"page":page, "count":limit})
-
- def list_playlists(self, page=1, limit=10):
- return self._get("/video-playlists/user", {"page":page, "count":limit})
-
- def get_playlist(self, pid): return self._get(f"/video-playlists/{pid}")
-
- def my_profile(self): return self._get("/users/me")
- def my_videos(self, page=1, limit=12): return self._get("/users/me/videos", {"page":page, "count":limit})
-
-
-def fmt_video(v, idx=None):
- prefix = f"{idx}. " if idx else ""
- name = v.get("name", "?")
- author = v.get("channel",{}).get("displayName", v.get("account",{}).get("displayName","?"))
- dur = v.get("duration", 0)
- dur_str = f"{int(dur//60)}:{int(dur%60):02d}"
- views = v.get("views", 0)
- pub = (v.get("publishedAt") or "")[:10]
- return f" {prefix}{name:55} {dur_str} {views} views {author} {pub}"
-
-
-def cmd_server(client, args):
- if client.dry_run: return emit("[dry-run] Get server info", {"dry_run":True})
- d = client.get_server_info() or {}
- name = d.get("instance",{}).get("name","?")
- desc = (d.get("instance",{}).get("shortDescription","") or "")[:80]
- users = d.get("users","?"); videos = d.get("videos","?"); views = d.get("views","?")
- emit(f"🖥️ {name}\n {desc}\n Users: {users} Videos: {videos} Views: {views}",
- {"instance":{"name":name,"shortDescription":desc},"users":users,"videos":videos})
-
-def cmd_videos(client, args):
- p = argparse.ArgumentParser(prog="peertube videos")
- p.add_argument("--limit", type=int, default=12)
- parsed, _ = p.parse_known_args(args)
- if client.dry_run: return emit("[dry-run] List videos", {"dry_run":True})
- data = client.list_videos(limit=parsed.limit) or {}
- videos = data.get("data",[])
- if not videos: return emit("No videos.", {"videos":[]})
- lines = [fmt_video(v, i+1) for i, v in enumerate(videos)]
- total = data.get("total", len(videos))
- emit(f"{total} video(s):\n"+"\n".join(lines), {"total":total,"videos":videos})
-
-def cmd_search(client, args):
- p = argparse.ArgumentParser(prog="peertube search")
- p.add_argument("--query", "-q", required=True)
- p.add_argument("--limit", type=int, default=12)
- parsed, _ = p.parse_known_args(args)
- if client.dry_run: return emit(f"[dry-run] Search: {parsed.query}", {"dry_run":True})
- data = client.search_videos(parsed.query, limit=parsed.limit) or {}
- videos = data.get("data",[])
- if not videos: return emit("No results.", {"videos":[]})
- lines = [fmt_video(v, i+1) for i, v in enumerate(videos)]
- total = data.get("total", len(videos))
- emit(f"{total} result(s):\n"+"\n".join(lines), {"total":total,"videos":videos})
-
-def cmd_channels(client, args):
- if client.dry_run: return emit("[dry-run] List channels", {"dry_run":True})
- data = client.list_channels() or {}
- channels = data.get("data",[])
- if not channels: return emit("No channels.", {"channels":[]})
- lines, out = [], []
- for c in channels:
- dn = c.get("displayName","?"); name = c.get("name","?"); vid = c.get("videosCount",0); subs = c.get("subscribersCount",0)
- lines.append(f" {dn:30} @{name} {vid} videos {subs} subscribers")
- out.append({"displayName":dn,"name":name,"videosCount":vid,"subscribersCount":subs})
- emit(f"{len(channels)} channel(s):\n"+"\n".join(lines), {"channels":out})
-
-def cmd_me(client, args):
- if client.dry_run: return emit("[dry-run] Get profile", {"dry_run":True})
- d = client.my_profile() or {}
- uname = d.get("username","?"); role = d.get("role","?")
- vid = d.get("videosCount",0); views = d.get("viewsCount",0)
- emit(f"👤 @{uname} Role: {role} Videos: {vid} Views: {views}",
- {"username":uname,"role":role,"videosCount":vid,"viewsCount":views})
-
-def main():
- global GLOBAL_FLAGS, QUIET
- GLOBAL_FLAGS, filtered_argv = _preparse(sys.argv)
- if GLOBAL_FLAGS.get("quiet"): QUIET = True
- if GLOBAL_FLAGS.get("json"): warnings.simplefilter("ignore")
-
- parser = argparse.ArgumentParser(prog="peertube", description="PeerTube federated video.",
- epilog="Set PEERTUBE_SERVER. Login: peertube auth login --username ... --password ...")
- sub = parser.add_subparsers(dest="command")
-
- # auth
- ap = sub.add_parser("auth", help="Authentication")
- asub = ap.add_subparsers(dest="auth_action")
- lp = asub.add_parser("login", help="Login to PeerTube instance")
- lp.add_argument("--username", required=True); lp.add_argument("--password", required=True)
-
- # other commands
- sub.add_parser("server", help="Server info")
- sub.add_parser("videos", help="List videos").add_argument("--limit",type=int,default=12)
- sub.add_parser("channels", help="List channels")
- sp = sub.add_parser("search", help="Search videos")
- sp.add_argument("--query","-q",required=True); sp.add_argument("--limit",type=int,default=12)
- sub.add_parser("me", help="My profile")
-
- args = parser.parse_args(filtered_argv[1:])
- if not args.command: parser.print_help(); sys.exit(1)
-
- client = PeerTubeClient(dry_run=GLOBAL_FLAGS.get("dry_run",False))
- remaining = filtered_argv[filtered_argv.index(args.command)+1:]
-
- if args.command == "auth":
- if args.auth_action == "login":
- result = client.login(args.username, args.password)
- emit(f"✅ Logged in to {client.server}", {"status":"logged_in","server":client.server})
- else: ap.print_help()
- return
-
- # Commands that need auth
- if not client._token and not GLOBAL_FLAGS.get("dry_run"):
- die("Not logged in. Run 'peertube auth login --username ... --password ...' first, or use --dry-run.")
-
- handlers = {"server":cmd_server,"videos":cmd_videos,"search":cmd_search,"channels":cmd_channels,"me":cmd_me}
- h = handlers.get(args.command)
- if not h: parser.print_help(); sys.exit(1)
- h(client, remaining)
-
-if __name__ == "__main__": main()
diff --git a/peertube/scripts/test_peertube.py b/peertube/scripts/test_peertube.py
new file mode 100644
index 0000000..88174c5
--- /dev/null
+++ b/peertube/scripts/test_peertube.py
@@ -0,0 +1,990 @@
+"""Offline test suite for the bundled peertube CLI.
+
+All HTTP is mocked at the requests seam; the only live call in the file is the
+single anonymous instance probe behind the PEERTUBE_LIVE_TESTS=1 guard (skipped
+by default, so the suite is fully offline and passes the proxy-trap rerun).
+Covers: help output, argument-error paths, dry-run plans, mocked OAuth2 token
+persistence/refresh/revocation, handler output contracts, and the documented
+multi-step pipeline stages (each stage's output fields/types feed the next).
+"""
+
+import contextlib
+import importlib.machinery
+import importlib.util
+import io
+import json
+import os
+import pathlib
+import stat
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+from unittest.mock import patch
+
+SCRIPT = pathlib.Path(__file__).resolve().parent / "peertube"
+LOADER = importlib.machinery.SourceFileLoader("peertube_cli", str(SCRIPT))
+SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER)
+pt = importlib.util.module_from_spec(SPEC)
+LOADER.exec_module(pt)
+
+
+def clean_env():
+ env = os.environ.copy()
+ for var in ("PEERTUBE_SERVER", "PEERTUBE_CONFIG_DIR", "PEERTUBE_LIVE_TESTS"):
+ env.pop(var, None)
+ return env
+
+
+class FakeResponse:
+ def __init__(self, status_code=200, json_body=None, text="", headers=None):
+ self.status_code = status_code
+ self._json = json_body
+ self.text = text if text else (json.dumps(json_body) if json_body is not None else "")
+ self.headers = headers or {}
+
+ def json(self):
+ if self._json is None:
+ raise ValueError("no json body")
+ return self._json
+
+
+VIDEO_ONE = {
+ "id": 1,
+ "uuid": "uuid-one",
+ "shortUUID": "sOne1",
+ "url": "https://inst.example/w/uuid-one",
+ "name": "First video",
+ "duration": 125,
+ "views": 42,
+ "likes": 7,
+ "publishedAt": "2026-08-01T10:00:00.000Z",
+ "privacy": {"id": 1, "label": "Public"},
+ "account": {"name": "alice", "displayName": "Alice", "host": "inst.example"},
+ "channel": {"name": "alice-channel", "displayName": "Alice Channel", "host": "inst.example"},
+}
+VIDEO_TWO = dict(
+ VIDEO_ONE,
+ id=2,
+ uuid="uuid-two",
+ shortUUID="sTwo2",
+ name="Second video",
+ duration=3661,
+ views=5,
+ channel={"name": "bob-channel", "displayName": "Bob Channel", "host": "other.example"},
+)
+
+TOKEN_RESPONSE = {
+ "access_token": "tok-1",
+ "refresh_token": "ref-1",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "refresh_token_expires_in": 7200,
+}
+OAUTH_CLIENT = {"client_id": "cid-1", "client_secret": "client-secret-1"}
+MASKED_OAUTH_CLIENT = {"client_id": "cid-1", "client_secret": "*" * 32}
+
+
+def run_cli(*args, env=None):
+ return subprocess.run(
+ [sys.executable, str(SCRIPT), *args],
+ capture_output=True,
+ text=True,
+ env=env if env is not None else clean_env(),
+ )
+
+
+class ModuleStateTestCase(unittest.TestCase):
+ """Base that restores module globals mutated by in-process tests."""
+
+ def setUp(self):
+ self._flags = dict(pt.GLOBAL_FLAGS)
+ self._env_server = pt.ENV_SERVER
+ self._env_config = pt.ENV_CONFIG_DIR
+ pt.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+
+ def tearDown(self):
+ pt.GLOBAL_FLAGS = self._flags
+ pt.ENV_SERVER = self._env_server
+ pt.ENV_CONFIG_DIR = self._env_config
+
+
+class HelpOutputTests(unittest.TestCase):
+ """Class 1: --help output."""
+
+ def test_help_lists_every_subcommand(self):
+ result = run_cli("--help")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ for noun in (
+ "server",
+ "videos",
+ "video",
+ "search",
+ "comments",
+ "channels",
+ "channel",
+ "account",
+ "me",
+ "my-videos",
+ "login",
+ "logout",
+ ):
+ self.assertIn(noun, result.stdout)
+
+ def test_help_names_the_instance_env_var(self):
+ result = run_cli("--help")
+ self.assertIn("PEERTUBE_SERVER", result.stdout)
+ self.assertIn("sepiasearch.org", result.stdout)
+
+ def test_search_help_states_its_scope(self):
+ result = run_cli("search", "--help")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("searchTarget", result.stdout)
+ self.assertIn("sepiasearch", result.stdout.lower())
+
+ def test_leaf_help_carries_examples(self):
+ for leaf in ("videos", "comments", "login", "logout"):
+ result = run_cli(leaf, "--help")
+ with self.subTest(leaf=leaf):
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("Example:", result.stdout)
+
+
+class ArgumentErrorTests(unittest.TestCase):
+ """Class 2: argument-error paths fail cleanly before any network call."""
+
+ def test_search_requires_query(self):
+ result = run_cli("search")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("--query", result.stderr)
+ self.assertNotIn("Traceback", result.stderr)
+
+ def test_video_requires_id(self):
+ result = run_cli("video")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("--id", result.stderr)
+
+ def test_channel_requires_handle(self):
+ result = run_cli("channel")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("--handle", result.stderr)
+
+ def test_no_subcommand_prints_help_and_exits(self):
+ result = run_cli()
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("usage", result.stdout)
+
+ def test_limit_above_server_maximum_rejected(self):
+ result = run_cli("--dry-run", "--json", "videos", "--limit", "101")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("100", result.stderr)
+
+ def test_limit_zero_rejected(self):
+ result = run_cli("--dry-run", "--json", "videos", "--limit", "0")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("--limit", result.stderr)
+
+ def test_negative_offset_rejected(self):
+ result = run_cli("--dry-run", "--json", "videos", "--offset", "-1")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("--offset", result.stderr)
+
+ def test_login_without_password_errors(self):
+ result = run_cli("login", "--username", "alice", "--server", "https://inst.example")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("password", result.stderr.lower())
+ self.assertNotIn("Traceback", result.stderr)
+
+ def test_missing_server_dies_before_network(self):
+ result = run_cli("videos", "--limit", "1")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("PEERTUBE_SERVER", result.stderr)
+ self.assertNotIn("Traceback", result.stderr)
+
+
+class DryRunPlanTests(ModuleStateTestCase):
+ """Class 3: --dry-run emits valid JSON plans with zero network activity."""
+
+ def run_json(self, *args):
+ pt.GLOBAL_FLAGS = {"json": True, "dry_run": True, "quiet": False, "verbose": False}
+ return io.StringIO()
+
+ def test_single_endpoint_plans_emit_method_path_params(self):
+ cases = (
+ (
+ pt.cmd_videos,
+ ["--limit", "3"],
+ {
+ "method": "GET",
+ "path": "/api/v1/videos",
+ "params.start": 0,
+ "params.count": 3,
+ "params.sort": "-publishedAt",
+ },
+ ),
+ (
+ pt.cmd_search,
+ ["--query", "linux", "--limit", "5"],
+ {
+ "method": "GET",
+ "path": "/api/v1/search/videos",
+ "params.searchTarget": "local",
+ "params.search": "linux",
+ },
+ ),
+ (
+ pt.cmd_video,
+ ["--id", "uuid-one"],
+ {"method": "GET", "path": "/api/v1/videos/uuid-one"},
+ ),
+ (
+ pt.cmd_comments,
+ ["--id", "uuid-one"],
+ {"method": "GET", "path": "/api/v1/videos/uuid-one/comment-threads"},
+ ),
+ (pt.cmd_channels, [], {"method": "GET", "path": "/api/v1/video-channels"}),
+ (pt.cmd_me, [], {"method": "GET", "path": "/api/v1/users/me"}),
+ (pt.cmd_my_videos, [], {"method": "GET", "path": "/api/v1/users/me/videos"}),
+ (pt.cmd_logout, [], {"method": "POST", "path": "/api/v1/users/revoke-token"}),
+ )
+ for handler, args, expectations in cases:
+ with self.subTest(handler=handler.__name__):
+ client = pt.PeerTubeClient(
+ server="https://inst.example",
+ dry_run=True,
+ config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
+ )
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ handler(client, args)
+ plan = json.loads(out.getvalue())
+ self.assertTrue(plan["dry_run"])
+ self.assertEqual(plan["method"], expectations["method"])
+ self.assertEqual(plan["path"], expectations["path"])
+
+ def test_dry_run_videos_plan_never_sends_page_param(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example",
+ dry_run=True,
+ config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
+ )
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_videos(client, ["--limit", "9", "--offset", "18"])
+ plan = json.loads(out.getvalue())
+ self.assertNotIn("page", plan["params"])
+ self.assertEqual(plan["params"]["start"], 18)
+ self.assertEqual(plan["params"]["count"], 9)
+
+ def test_search_plan_defaults_to_instance_local_scope(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example",
+ dry_run=True,
+ config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
+ )
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_search(client, ["--query", "peertube"])
+ plan = json.loads(out.getvalue())
+ self.assertEqual(plan["params"]["searchTarget"], "local")
+
+ def test_composite_commands_plan_every_request(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example",
+ dry_run=True,
+ config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
+ )
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_server(client, [])
+ plan = json.loads(out.getvalue())
+ paths = [req["path"] for req in plan["requests"]]
+ self.assertEqual(paths, ["/api/v1/config/about", "/api/v1/server/stats"])
+
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_channel(client, ["--handle", "alice-channel"])
+ plan = json.loads(out.getvalue())
+ paths = [req["path"] for req in plan["requests"]]
+ self.assertEqual(
+ paths,
+ ["/api/v1/video-channels/alice-channel", "/api/v1/video-channels/alice-channel/videos"],
+ )
+
+ def test_login_dry_run_lists_form_fields_without_values(self):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_login(
+ pt.PeerTubeClient(
+ server="https://inst.example",
+ dry_run=True,
+ config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
+ ),
+ ["--username", "alice"],
+ )
+ plan = json.loads(out.getvalue())
+ self.assertTrue(plan["dry_run"])
+ self.assertEqual(plan["path"], "/api/v1/users/token")
+ self.assertIn("grant_type", plan["form_fields"])
+ self.assertIn("client_secret", plan["form_fields"])
+ self.assertNotIn("form", plan) # no values leak in the plan
+
+ def test_dry_run_works_without_any_server_configured(self):
+ pt.ENV_SERVER = ""
+ client = pt.PeerTubeClient(
+ server="", dry_run=True, config_dir=tempfile.mkdtemp(prefix="pt-dry-")
+ )
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_videos(client, ["--limit", "2"])
+ self.assertTrue(json.loads(out.getvalue())["dry_run"])
+
+ def test_dry_run_never_touches_network(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example",
+ dry_run=True,
+ config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
+ )
+ with (
+ patch.object(pt.requests, "get") as getter,
+ patch.object(pt.requests, "post") as poster,
+ ):
+ for handler, args in (
+ (pt.cmd_videos, ["--limit", "2"]),
+ (pt.cmd_search, ["--query", "x"]),
+ (pt.cmd_server, []),
+ (pt.cmd_me, []),
+ (pt.cmd_login, ["--username", "a"]),
+ (pt.cmd_logout, []),
+ ):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ handler(client, args)
+ getter.assert_not_called()
+ poster.assert_not_called()
+
+ def test_flags_work_before_and_after_subcommand(self):
+ result = run_cli("--json", "--dry-run", "search", "--query", "x", "--limit", "2")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertTrue(json.loads(result.stdout)["dry_run"])
+ result = run_cli("search", "--query", "x", "--limit", "2", "--json", "--dry-run")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertTrue(json.loads(result.stdout)["dry_run"])
+
+
+class ClientContractTests(ModuleStateTestCase):
+ """Class 4a: mocked requests — paths, params, and error handling."""
+
+ def mocked_get(self, responses, server="https://inst.example"):
+ client = pt.PeerTubeClient(server=server, config_dir=tempfile.mkdtemp(prefix="pt-cc-"))
+ return client, patch.object(pt.requests, "get", side_effect=responses)
+
+ def test_video_listing_sends_start_count_sort(self):
+ client, patcher = self.mocked_get(
+ [FakeResponse(200, {"total": 2, "data": [VIDEO_ONE, VIDEO_TWO]})]
+ )
+ with patcher as getter:
+ pt.cmd_videos(client, ["--limit", "2", "--offset", "10"])
+ args, kwargs = getter.call_args
+ self.assertEqual(args[0], "https://inst.example/api/v1/videos")
+ self.assertEqual(kwargs["params"], {"start": 10, "count": 2, "sort": "-publishedAt"})
+ self.assertNotIn("page", kwargs["params"])
+
+ def test_search_defaults_to_local_target_and_omits_empty_sort(self):
+ client, patcher = self.mocked_get([FakeResponse(200, {"total": 0, "data": []})])
+ with patcher as getter:
+ pt.cmd_search(client, ["--query", "linux"])
+ params = getter.call_args[1]["params"]
+ self.assertEqual(params["searchTarget"], "local")
+ self.assertEqual(params["search"], "linux")
+ self.assertNotIn("sort", params)
+
+ def test_comment_threads_route_is_hyphenated(self):
+ client, patcher = self.mocked_get(
+ [FakeResponse(200, {"total": 0, "totalNotDeletedComments": 0, "data": []})]
+ )
+ with patcher as getter:
+ pt.cmd_comments(client, ["--id", "uuid-one"])
+ self.assertEqual(
+ getter.call_args[0][0], "https://inst.example/api/v1/videos/uuid-one/comment-threads"
+ )
+
+ def test_server_composes_about_and_stats(self):
+ about = FakeResponse(
+ 200, {"instance": {"name": "Inst", "shortDescription": "Desc", "description": "Long"}}
+ )
+ stats = FakeResponse(
+ 200,
+ {
+ "totalUsers": 9,
+ "totalLocalVideos": 933,
+ "totalVideos": 23890,
+ "totalLocalVideoViews": 1001751,
+ "totalLocalVideoDownloads": 32569,
+ "totalLocalVideoChannels": 28,
+ },
+ )
+ client, patcher = self.mocked_get([about, stats])
+ with patcher:
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_server(client, [])
+ payload = json.loads(out.getvalue())
+ self.assertEqual(payload["instance"]["name"], "Inst")
+ self.assertEqual(payload["stats"]["totalLocalVideos"], 933)
+ self.assertIsInstance(payload["stats"]["totalUsers"], int)
+
+ def test_401_names_login_remedy(self):
+ client, patcher = self.mocked_get([FakeResponse(401, {"detail": "token expired"})])
+ client._token = "stale-token" # authed command proceeds, then server rejects
+ with patcher:
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_me(client, [])
+ self.assertIn("401", err.getvalue())
+ self.assertIn("login", err.getvalue().lower())
+
+ def test_429_surfaces_retry_after(self):
+ client, patcher = self.mocked_get(
+ [FakeResponse(429, {"detail": "rate limit"}, headers={"Retry-After": "7"})]
+ )
+ with patcher:
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_videos(client, ["--limit", "2"])
+ self.assertIn("429", err.getvalue())
+ self.assertIn("Retry-After", err.getvalue())
+
+ def test_rfc7807_detail_extracted_on_generic_error(self):
+ client, patcher = self.mocked_get(
+ [
+ FakeResponse(
+ 400,
+ {
+ "type": "about:blank",
+ "title": "Bad Request",
+ "status": 400,
+ "detail": "unknown route shape",
+ },
+ )
+ ]
+ )
+ with patcher:
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_videos(client, ["--limit", "2"])
+ self.assertIn("unknown route shape", err.getvalue())
+
+ def test_non_json_instance_response_is_diagnosed(self):
+ client, patcher = self.mocked_get([FakeResponse(200, text="not peertube")])
+ with patcher:
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_videos(client, ["--limit", "2"])
+ self.assertIn("Non-JSON", err.getvalue())
+
+
+class OAuthFlowTests(ModuleStateTestCase):
+ """Class 4b: mocked OAuth2 — client fetch, password grant, persistence,
+ refresh, revocation. Token files live in TemporaryDirectories only."""
+
+ def setUp(self):
+ super().setUp()
+ self.tmp = tempfile.TemporaryDirectory(prefix="pt-oauth-")
+ self.config_dir = self.tmp.name
+ pt.ENV_SERVER = "https://inst.example"
+
+ def tearDown(self):
+ self.tmp.cleanup()
+ super().tearDown()
+
+ def client(self, **kwargs):
+ return pt.PeerTubeClient(
+ server="https://inst.example", config_dir=self.config_dir, **kwargs
+ )
+
+ def test_fetch_oauth_client_hits_singular_local_route(self):
+ client = self.client()
+ with patch.object(
+ pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)
+ ) as getter:
+ client_id, client_secret = client.fetch_oauth_client()
+ self.assertEqual(getter.call_args[0][0], "https://inst.example/api/v1/oauth-clients/local")
+ self.assertEqual((client_id, client_secret), ("cid-1", "client-secret-1"))
+
+ def test_password_grant_sends_form_encoded_fields(self):
+ client = self.client()
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(
+ pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)
+ ) as poster,
+ ):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
+ args, kwargs = poster.call_args
+ self.assertEqual(args[0], "https://inst.example/api/v1/users/token")
+ form = kwargs["data"]
+ self.assertEqual(form["grant_type"], "password")
+ self.assertEqual(form["username"], "alice")
+ self.assertEqual(form["client_id"], "cid-1")
+ self.assertNotIn("response_type", form) # not part of the documented schema
+ payload = json.loads(out.getvalue())
+ self.assertEqual(payload["status"], "logged_in")
+
+ def test_bad_password_400_exits_with_guidance(self):
+ client = self.client()
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(
+ pt.requests, "post", return_value=FakeResponse(400, {"detail": "invalid_grant"})
+ ),
+ ):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_login(client, ["--username", "alice", "--password", "wrong"])
+ self.assertIn("400", err.getvalue())
+ self.assertIn("invalid_grant", err.getvalue())
+
+ def test_two_factor_401_suggests_otp_flag(self):
+ client = self.client()
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(pt.requests, "post", return_value=FakeResponse(401, {})),
+ ):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
+ self.assertIn("--otp", err.getvalue())
+
+ def test_otp_header_attached_when_provided(self):
+ client = self.client()
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(
+ pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)
+ ) as poster,
+ ):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_login(client, ["--username", "alice", "--password", "pw", "--otp", "123456"])
+ self.assertEqual(poster.call_args[1]["headers"]["x-peertube-otp"], "123456")
+
+ def test_masked_client_secret_stops_login_with_guidance(self):
+ client = self.client()
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, MASKED_OAUTH_CLIENT)),
+ patch.object(pt.requests, "post") as poster,
+ ):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
+ self.assertIn("masks client_secret", err.getvalue())
+ poster.assert_not_called()
+
+ def test_is_masked_secret_detection(self):
+ self.assertTrue(pt.is_masked_secret("*" * 32))
+ self.assertFalse(pt.is_masked_secret("client-secret-1"))
+ self.assertFalse(pt.is_masked_secret(""))
+ self.assertFalse(pt.is_masked_secret("*-mixed-*"))
+
+ def test_token_file_persisted_owner_only_with_expiry(self):
+ client = self.client()
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)),
+ ):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
+ token_path = client.token_path()
+ self.assertTrue(os.path.isfile(token_path))
+ mode = stat.S_IMODE(os.stat(token_path).st_mode)
+ self.assertEqual(mode & 0o077, 0, "token file must be owner-only")
+ with open(token_path) as handle:
+ record = json.load(handle)
+ self.assertEqual(record["server"], "https://inst.example")
+ self.assertEqual(record["access_token"], "tok-1")
+ self.assertEqual(record["refresh_token"], "ref-1")
+ self.assertIsNotNone(record["expires_at"])
+ self.assertGreater(record["expires_at"], time.time())
+ self.assertLess(record["expires_at"], time.time() + 7200)
+
+ def test_token_from_another_instance_is_ignored(self):
+ client = self.client()
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)),
+ ):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
+ other = pt.PeerTubeClient(server="https://other.example", config_dir=self.config_dir)
+ self.assertIsNone(other._token)
+
+ def test_expired_token_triggers_refresh_then_success(self):
+ client = self.client()
+ client.save_session(dict(TOKEN_RESPONSE, expires_in=-10)) # already expired
+ refreshed = dict(TOKEN_RESPONSE, access_token="tok-2", expires_in=3600)
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(pt.requests, "post", return_value=FakeResponse(200, refreshed)) as poster,
+ ):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ pt.cmd_me(client, [])
+ form = poster.call_args[1]["data"]
+ self.assertEqual(form["grant_type"], "refresh_token")
+ self.assertEqual(form["refresh_token"], "ref-1")
+ self.assertEqual(client._token, "tok-2")
+ with open(client.token_path()) as handle:
+ self.assertEqual(json.load(handle)["access_token"], "tok-2")
+
+ def test_failed_refresh_falls_back_to_login_guidance(self):
+ client = self.client()
+ client.save_session(dict(TOKEN_RESPONSE, expires_in=-10))
+ with (
+ patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
+ patch.object(pt.requests, "post", return_value=FakeResponse(400, {})),
+ ):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_me(client, [])
+ self.assertIn("Not authenticated", err.getvalue())
+
+ def test_logout_revokes_and_deletes_token_file(self):
+ client = self.client()
+ client.save_session(TOKEN_RESPONSE)
+ self.assertTrue(os.path.isfile(client.token_path()))
+ with patch.object(pt.requests, "post", return_value=FakeResponse(200, {})) as poster:
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_logout(client, [])
+ args, kwargs = poster.call_args
+ self.assertEqual(args[0], "https://inst.example/api/v1/users/revoke-token")
+ self.assertEqual(kwargs["headers"]["Authorization"], "Bearer tok-1")
+ self.assertFalse(os.path.exists(client.token_path()))
+
+ def test_logout_keeps_file_when_revocation_fails(self):
+ client = self.client()
+ client.save_session(TOKEN_RESPONSE)
+ with patch.object(pt.requests, "post", return_value=FakeResponse(500, {})):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_logout(client, [])
+ self.assertTrue(os.path.isfile(client.token_path()))
+
+ def test_logout_without_token_errors_cleanly(self):
+ client = self.client()
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
+ pt.cmd_logout(client, [])
+ self.assertIn("No stored token", err.getvalue())
+
+
+class HandlerOutputTests(ModuleStateTestCase):
+ """Class 4c: handler output contracts consumed by jq pipelines."""
+
+ def test_videos_output_carries_raw_video_objects(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
+ )
+ with patch.object(
+ pt.requests,
+ "get",
+ return_value=FakeResponse(200, {"total": 2, "data": [VIDEO_ONE, VIDEO_TWO]}),
+ ):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_videos(client, ["--limit", "2"])
+ payload = json.loads(out.getvalue())
+ self.assertEqual(payload["total"], 2)
+ self.assertEqual(payload["start"], 0)
+ self.assertEqual(payload["count"], 2)
+ self.assertIsInstance(payload["videos"], list)
+ first = payload["videos"][0]
+ self.assertEqual(first["uuid"], "uuid-one")
+ self.assertIsInstance(first["duration"], int) # seconds
+ self.assertEqual(first["channel"]["host"], "inst.example")
+
+ def test_search_output_marks_scope_and_carries_uuids(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
+ )
+ with patch.object(
+ pt.requests, "get", return_value=FakeResponse(200, {"total": 1, "data": [VIDEO_TWO]})
+ ):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_search(client, ["--query", "x"])
+ payload = json.loads(out.getvalue())
+ self.assertEqual(payload["videos"][0]["uuid"], "uuid-two")
+
+ def test_me_tolerates_docs_array_sample_and_object(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
+ )
+ profile = {
+ "username": "alice",
+ "role": {"id": 1, "label": "User"},
+ "videoQuota": 1073741824,
+ "videoChannels": [],
+ }
+ for body in (profile, [profile]):
+ client.save_session(TOKEN_RESPONSE)
+ with patch.object(pt.requests, "get", return_value=FakeResponse(200, body)):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_me(client, [])
+ payload = json.loads(out.getvalue())
+ self.assertEqual(payload["username"], "alice")
+ self.assertEqual(payload["role"]["label"], "User")
+ client.clear_token()
+
+ def test_me_tolerates_scalar_role_without_attribute_error(self):
+ """VAL-PT-010: a non-dict `role` (scalar id from a version-drifted
+ server) must degrade to a readable line, never AttributeError."""
+ client = pt.PeerTubeClient(
+ server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
+ )
+ profile = {"username": "bob", "role": 2, "videoQuota": None}
+ client.save_session(TOKEN_RESPONSE)
+ with patch.object(pt.requests, "get", return_value=FakeResponse(200, profile)):
+ out = io.StringIO()
+ err = io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ pt.cmd_me(client, [])
+ self.assertNotIn("Traceback", err.getvalue())
+ self.assertEqual(json.loads(out.getvalue())["role"], 2)
+ client.clear_token()
+
+ def test_me_tolerates_missing_role(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
+ )
+ client.save_session(TOKEN_RESPONSE)
+ with patch.object(pt.requests, "get", return_value=FakeResponse(200, {"username": "carol"})):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_me(client, [])
+ self.assertEqual(json.loads(out.getvalue())["username"], "carol")
+ client.clear_token()
+
+ def test_comments_output_exposes_thread_counts(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
+ )
+ body = {
+ "total": 1,
+ "totalNotDeletedComments": 3,
+ "data": [
+ {"totalReplies": 3, "comment": {"text": "nice video", "account": {"name": "bob"}}}
+ ],
+ }
+ with patch.object(pt.requests, "get", return_value=FakeResponse(200, body)):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_comments(client, ["--id", "uuid-one"])
+ payload = json.loads(out.getvalue())
+ self.assertEqual(payload["total"], 1)
+ self.assertEqual(payload["total_not_deleted"], 3)
+ self.assertEqual(payload["threads"][0]["comment"]["text"], "nice video")
+
+ def test_channels_output_carries_handles_and_counts(self):
+ client = pt.PeerTubeClient(
+ server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
+ )
+ body = {
+ "total": 1,
+ "data": [
+ {
+ "name": "alice-channel",
+ "displayName": "Alice Channel",
+ "host": "inst.example",
+ "videosCount": 12,
+ "followersCount": 34,
+ }
+ ],
+ }
+ with patch.object(pt.requests, "get", return_value=FakeResponse(200, body)):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ pt.cmd_channels(client, [])
+ payload = json.loads(out.getvalue())
+ channel = payload["channels"][0]
+ self.assertEqual(channel["name"], "alice-channel")
+ self.assertIsInstance(channel["videosCount"], int)
+
+
+class PipelineChainTests(ModuleStateTestCase):
+ """Documented multi-step recipes must execute stage by stage, each stage's
+ output field names AND JSON types consumable by the next."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.tmpdir = tempfile.TemporaryDirectory(prefix="pt-pipeline-")
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.tmpdir.cleanup()
+
+ def run_cli(self, *args):
+ env = clean_env()
+ env["PEERTUBE_SERVER"] = "https://inst.example"
+ env["PEERTUBE_CONFIG_DIR"] = self.tmpdir.name
+ return subprocess.run(
+ [sys.executable, str(SCRIPT), "--json", "--dry-run", *args],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+
+ def run_jq(self, *jq_args, stdin_text=""):
+ return subprocess.run(
+ ["jq", *jq_args], input=stdin_text, capture_output=True, text=True, env=clean_env()
+ )
+
+ def stage_file(self, name, document):
+ path = pathlib.Path(self.tmpdir.name) / name
+ path.write_text(json.dumps(document))
+ return str(path)
+
+ def test_browse_then_detail_chain_consumability(self):
+ # Stage 1: videos plan; jq proves the path and the start-offset type
+ # (number) that stage two consumes when picking an id from the listing.
+ r1 = self.run_cli("videos", "--limit", "2")
+ self.assertEqual(r1.returncode, 0, r1.stderr)
+ self.stage_file("s1.json", json.loads(r1.stdout))
+ self.assertEqual(
+ self.run_jq("-r", ".path", stdin_text=r1.stdout).stdout.strip(), "/api/v1/videos"
+ )
+ self.assertEqual(
+ self.run_jq("-r", ".params.start | type", stdin_text=r1.stdout).stdout.strip(), "number"
+ )
+ # Stage 2: detail plan consumes an id into the URL path.
+ r2 = self.run_cli("video", "--id", "uuid-one")
+ self.assertEqual(r2.returncode, 0, r2.stderr)
+ self.stage_file("s2.json", json.loads(r2.stdout))
+ self.assertEqual(
+ self.run_jq("-r", ".path", stdin_text=r2.stdout).stdout.strip(),
+ "/api/v1/videos/uuid-one",
+ )
+
+ def test_search_then_video_chain_consumability(self):
+ r1 = self.run_cli("search", "--query", "linux", "--limit", "3")
+ self.assertEqual(r1.returncode, 0, r1.stderr)
+ self.assertEqual(
+ self.run_jq("-r", ".params.searchTarget", stdin_text=r1.stdout).stdout.strip(), "local"
+ )
+ self.assertEqual(
+ self.run_jq("-r", ".params.count | type", stdin_text=r1.stdout).stdout.strip(), "number"
+ )
+ # The documented jq selector .videos[0].uuid maps to detail --id.
+ r2 = self.run_cli("video", "--id", "uuid-from-search")
+ self.assertEqual(r2.returncode, 0, r2.stderr)
+ self.assertEqual(
+ self.run_jq("-r", ".path", stdin_text=r2.stdout).stdout.strip(),
+ "/api/v1/videos/uuid-from-search",
+ )
+
+ def test_channel_offset_paging_chain_consumability(self):
+ r1 = self.run_cli("channels", "--limit", "100", "--offset", "0")
+ self.assertEqual(r1.returncode, 0, r1.stderr)
+ self.assertEqual(
+ self.run_jq("-r", ".path", stdin_text=r1.stdout).stdout.strip(),
+ "/api/v1/video-channels",
+ )
+ r2 = self.run_cli(
+ "channel", "--handle", "alice-channel", "--limit", "100", "--offset", "100"
+ )
+ self.assertEqual(r2.returncode, 0, r2.stderr)
+ plan = json.loads(r2.stdout)
+ video_req = plan["requests"][1]
+ self.assertEqual(video_req["params"]["start"], 100)
+ self.assertEqual(video_req["params"]["count"], 100)
+ self.assertNotIn("page", video_req["params"])
+
+ def test_login_to_me_chain_handoff(self):
+ # Stage 1: login plan lists the form fields (no values).
+ r1 = self.run_cli("login", "--username", "alice")
+ self.assertEqual(r1.returncode, 0, r1.stderr)
+ self.assertEqual(
+ self.run_jq("-r", ".path", stdin_text=r1.stdout).stdout.strip(), "/api/v1/users/token"
+ )
+ fields = json.loads(self.run_jq("-c", ".form_fields", stdin_text=r1.stdout).stdout)
+ self.assertIn("grant_type", fields)
+ # Stage 2: me plan rides the Authorization header the login persisted.
+ r2 = self.run_cli("me")
+ self.assertEqual(r2.returncode, 0, r2.stderr)
+ self.assertEqual(
+ self.run_jq("-r", ".path", stdin_text=r2.stdout).stdout.strip(), "/api/v1/users/me"
+ )
+ # Stage 3: logout plan revokes on the same instance.
+ r3 = self.run_cli("logout")
+ self.assertEqual(r3.returncode, 0, r3.stderr)
+ self.assertEqual(
+ self.run_jq("-r", ".path", stdin_text=r3.stdout).stdout.strip(),
+ "/api/v1/users/revoke-token",
+ )
+
+ def test_server_composition_plan_targets_both_endpoints(self):
+ r1 = self.run_cli("server")
+ self.assertEqual(r1.returncode, 0, r1.stderr)
+ paths = json.loads(self.run_jq("-c", "[.requests[].path]", stdin_text=r1.stdout).stdout)
+ self.assertEqual(paths, ["/api/v1/config/about", "/api/v1/server/stats"])
+
+ def test_mocked_browse_to_detail_stage_types(self):
+ """Live-shape variant of recipe 1: the videos output's uuid (string)
+ feeds video --id, and the detail object carries description/url."""
+ pt.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+ client = pt.PeerTubeClient(server="https://inst.example", config_dir=self.tmpdir.name)
+ detail = dict(VIDEO_ONE, description="full text", commentsEnabled=True)
+ with patch.object(
+ pt.requests,
+ "get",
+ side_effect=[
+ FakeResponse(200, {"total": 1, "data": [VIDEO_ONE]}),
+ FakeResponse(200, detail),
+ ],
+ ):
+ first = io.StringIO()
+ with contextlib.redirect_stdout(first):
+ pt.cmd_videos(client, ["--limit", "1"])
+ listing = json.loads(first.getvalue())
+ consumed_id = listing["videos"][0]["uuid"]
+ self.assertIsInstance(consumed_id, str)
+
+ second = io.StringIO()
+ with contextlib.redirect_stdout(second):
+ pt.cmd_video(client, ["--id", consumed_id])
+ video_detail = json.loads(second.getvalue())
+ self.assertEqual(video_detail["uuid"], consumed_id)
+ self.assertIsInstance(video_detail["description"], str)
+ self.assertIsInstance(video_detail["commentsEnabled"], bool)
+
+
+class EnvGuardedLiveProbeTests(unittest.TestCase):
+ """Optional anonymous instance probe (keyless public endpoint). Runs only
+ with PEERTUBE_LIVE_TESTS=1; skipped cleanly otherwise so the suite stays
+ fully offline under the proxy-trap."""
+
+ def test_public_instance_oauth_client_probe(self):
+ if os.getenv("PEERTUBE_LIVE_TESTS") != "1":
+ self.skipTest("live probe disabled (set PEERTUBE_LIVE_TESTS=1)")
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT), "--json", "server", "--server", "https://framatube.org"],
+ capture_output=True,
+ text=True,
+ env=clean_env(),
+ timeout=60,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ payload = json.loads(result.stdout)
+ self.assertEqual(payload["instance"]["name"], "Framatube")
+ self.assertIsInstance(payload["stats"]["totalLocalVideos"], int)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/pyproject.toml b/pyproject.toml
index 36170f1..f9c1018 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -111,7 +111,7 @@ extend_exclude = [
"life-coach",
"raleigh",
"forgejo-cli",
- "jellyfin-cli",
+ "jellyfin",
"comic-chat",
"fireflies",
"linear",
diff --git a/raleigh/EVIDENCE-LEDGER.md b/raleigh/EVIDENCE-LEDGER.md
index 240196c..b02b93e 100644
--- a/raleigh/EVIDENCE-LEDGER.md
+++ b/raleigh/EVIDENCE-LEDGER.md
@@ -191,6 +191,39 @@ The following public service boundaries were exercised successfully on 2026-07-2
- No model-backed eval run, CI run, commit, push, pull request, deployment, release, or merge is claimed.
- Roll back the aggregate adapter and CLI wiring if the official site removes JSON:API access or publication links cannot be validated without broadening the trust boundary.
+## Current Issue: Upstream Browser Challenge
+
+### Intent and authority
+
+- Keep the scheduled canary truthful when Raleigh's Cloudflare edge blocks
+ non-browser clients, without bypassing the provider's challenge or hiding
+ genuine contract failures.
+- Modify the local Raleigh skill only. No publish, deploy, merge, or provider
+ configuration authority was used.
+
+### Evidence and decision
+
+- Runs `33259202346`, `33211420732`, and `33113598834` each reported HTTP 403
+ for both civic probes, while all other probes passed.
+- Direct probes returned the Cloudflare `cf-mitigated: challenge` marker and
+ challenge HTML. The same endpoints returned valid content from a browser-like
+ local request, establishing an access-policy boundary rather than a Raleigh
+ schema failure.
+- Classify this exact marker as `waf_challenge`, preserve source and target in
+ the report, and keep it as a blocking availability failure. Other 403
+ responses remain `auth_regression` and continue to fail the canary.
+
+### Verification target and follow-up
+
+- Deterministic tests cover the marker-specific classification and the
+ blocking summary accounting.
+- The scheduled workflow remains the delivery-boundary check. A later green
+ run requires Raleigh machine access to be restored; a challenged civic
+ endpoint remains a canary failure.
+- Do not add retries, browser automation, or challenge bypasses. Reclassify only
+ when the provider removes the marker or a new upstream access contract is
+ verified.
+
## Issue 157 Addendum: Restore Live RPD Queries
### Intent and authority
diff --git a/raleigh/references/civic-content-reference.md b/raleigh/references/civic-content-reference.md
index 49fc5fa..917e315 100644
--- a/raleigh/references/civic-content-reference.md
+++ b/raleigh/references/civic-content-reference.md
@@ -40,6 +40,9 @@ on later `--new-only` runs.
## Notes
+- Raleigh's site may present a Cloudflare browser challenge to non-browser
+ clients. The CLI does not attempt to bypass that challenge; the scheduled
+ canary records it as a visible upstream availability failure.
- The CLI preserves canonical page URLs so users can inspect the source presentation.
- Rendered HTML is treated as content, not executable markup.
- Publication status is requested server-side with `filter[status]=1`; the CLI
diff --git a/raleigh/scripts/canary.py b/raleigh/scripts/canary.py
index 7b9d521..8edf4a0 100644
--- a/raleigh/scripts/canary.py
+++ b/raleigh/scripts/canary.py
@@ -13,7 +13,7 @@ the report.
Exit codes:
0 all probes passed (or only empty-but-valid / restricted observations)
- 1 one or more durable contract failures detected
+ 1 one or more durable contract or availability failures detected
2 script-level error (bad arguments, import failure, etc.)
"""
@@ -54,11 +54,18 @@ FAILURE_CLASSES = (
"parser_failure",
"empty_but_valid",
"restricted_folder",
+ "waf_challenge",
)
def _classify_exception(exc: Exception) -> str:
if isinstance(exc, urllib.error.HTTPError):
+ if (
+ exc.code == 403
+ and exc.headers
+ and exc.headers.get("cf-mitigated", "").lower() == "challenge"
+ ):
+ return "waf_challenge"
if exc.code in (401, 403):
return "auth_regression"
if exc.code >= 500:
@@ -78,6 +85,13 @@ def _classify_exception(exc: Exception) -> str:
return "parser_failure"
+def _waf_failure(source: str, target: str, err: dict[str, Any] | None) -> dict[str, Any] | None:
+ """Keep provider browser challenges visible as availability failures."""
+ if err and err.get("failure_class") == "waf_challenge":
+ return {"source": source, "target": target, "status": "fail", **err}
+ return None
+
+
def _is_transient(failure_class: str) -> bool:
return failure_class == "transport_outage"
@@ -90,9 +104,12 @@ def _probe_with_retry(fn, *args, **kwargs) -> tuple[Any, None] | tuple[None, dic
return result, None
except Exception as exc:
fc = _classify_exception(exc)
+ error_text = str(exc)
+ if fc == "waf_challenge":
+ error_text = "Cloudflare managed browser challenge (cf-mitigated: challenge)"
evidence = {
"failure_class": fc,
- "error": str(exc),
+ "error": error_text,
"attempt": attempt,
}
if first_evidence is None:
@@ -278,6 +295,9 @@ def probe_civic_jsonapi() -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
index, err = _probe_with_retry(core.json_request, civic.JSONAPI_ROOT)
if err:
+ failure = _waf_failure("civic", "jsonapi-index", err)
+ if failure:
+ return [failure]
results.append({"source": "civic", "target": "jsonapi-index", "status": "fail", **err})
return results
@@ -297,6 +317,9 @@ def probe_civic_rss() -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
data, err = _probe_with_retry(core.raw_request, civic.RSS_FEED)
if err:
+ failure = _waf_failure("civic", "rss-feed", err)
+ if failure:
+ return [failure]
results.append({"source": "civic", "target": "rss-feed", "status": "fail", **err})
return results
@@ -481,6 +504,7 @@ def run_canary() -> dict[str, Any]:
transient_failures = 0
empty_valid = 0
restricted = 0
+ waf_challenges = 0
for name, probe_fn in ALL_PROBES:
try:
@@ -500,6 +524,8 @@ def run_canary() -> dict[str, Any]:
for r in all_results:
if r.get("target") == "summary":
continue
+ if r.get("failure_class") == "waf_challenge":
+ waf_challenges += 1
if r.get("status") != "fail":
if r.get("failure_class") == "empty_but_valid":
empty_valid += 1
@@ -524,6 +550,7 @@ def run_canary() -> dict[str, Any]:
"transient_failures": transient_failures,
"empty_but_valid": empty_valid,
"restricted_folders": restricted,
+ "waf_challenges": waf_challenges,
},
"results": all_results,
}
@@ -546,6 +573,7 @@ def write_github_summary(report: dict[str, Any]) -> None:
lines.append(f"| Transient failures | {s['transient_failures']} |")
lines.append(f"| Empty-but-valid | {s['empty_but_valid']} |")
lines.append(f"| Restricted folders | {s.get('restricted_folders', 0)} |")
+ lines.append(f"| WAF challenges | {s.get('waf_challenges', 0)} |")
lines.append("")
restricted = [r for r in report["results"] if r.get("failure_class") == "restricted_folder"]
@@ -558,6 +586,19 @@ def write_github_summary(report: dict[str, Any]) -> None:
lines.append(f"| {r.get('source', '?')} | {r.get('target', '?')} | {r.get('error', '')[:120]} |")
lines.append("")
+ challenges = [r for r in report["results"] if r.get("failure_class") == "waf_challenge"]
+ if challenges:
+ lines.append("### Upstream WAF challenges (blocked machine access; failing)")
+ lines.append("")
+ lines.append("| Source | Target | Evidence |")
+ lines.append("|--------|--------|----------|")
+ for r in challenges:
+ lines.append(
+ f"| {r.get('source', '?')} | {r.get('target', '?')} "
+ f"| {r.get('error', '')[:120]} |"
+ )
+ lines.append("")
+
failures = [r for r in report["results"] if r.get("status") == "fail"]
if failures:
lines.append("### Failures")
@@ -592,7 +633,8 @@ def main() -> int:
f"{s['durable_failures']} durable failures, "
f"{s['transient_failures']} transient failures, "
f"{s['empty_but_valid']} empty-but-valid, "
- f"{s.get('restricted_folders', 0)} restricted folders"
+ f"{s.get('restricted_folders', 0)} restricted folders, "
+ f"{s.get('waf_challenges', 0)} WAF challenges"
)
print(f"Report written to {report_path}")
diff --git a/raleigh/tests/test_raleigh.py b/raleigh/tests/test_raleigh.py
index 6ab62c3..4ab8e6a 100644
--- a/raleigh/tests/test_raleigh.py
+++ b/raleigh/tests/test_raleigh.py
@@ -2726,6 +2726,85 @@ class PoliceTests(unittest.TestCase):
self.assertFalse(report["passed"])
self.assertEqual(report["summary"]["transient_failures"], 1)
+ def test_canary_classifies_cloudflare_challenge_as_visible_availability_failure(self):
+ headers = Message()
+ headers["Server"] = "cloudflare"
+ headers["cf-mitigated"] = "challenge"
+ error = urllib.error.HTTPError(
+ "https://raleighnc.gov/jsonapi", 403, "Forbidden", headers, None
+ )
+ with patch("canary.core.json_request", side_effect=error):
+ results = canary_lib.probe_civic_jsonapi()
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0]["status"], "fail")
+ self.assertEqual(results[0]["failure_class"], "waf_challenge")
+ self.assertIn("Cloudflare", results[0]["error"])
+
+ def test_canary_classifies_rss_cloudflare_challenge_as_availability_failure(self):
+ headers = Message()
+ headers["cf-mitigated"] = "challenge"
+ error = urllib.error.HTTPError(
+ "https://raleighnc.gov/rss.xml", 403, "Forbidden", headers, None
+ )
+ with patch("canary.core.raw_request", side_effect=error):
+ results = canary_lib.probe_civic_rss()
+ self.assertEqual(results[0]["status"], "fail")
+ self.assertEqual(results[0]["failure_class"], "waf_challenge")
+
+ def test_canary_summary_renders_waf_challenges_as_failures(self):
+ report = {
+ "passed": False,
+ "summary": {
+ "total_results": 1,
+ "durable_failures": 1,
+ "transient_failures": 0,
+ "empty_but_valid": 0,
+ "restricted_folders": 0,
+ "waf_challenges": 1,
+ },
+ "results": [{
+ "source": "civic",
+ "target": "jsonapi-index",
+ "status": "fail",
+ "failure_class": "waf_challenge",
+ "error": "Cloudflare managed challenge",
+ }],
+ }
+ with tempfile.NamedTemporaryFile(mode="w+", delete=False) as summary:
+ summary_path = summary.name
+ self.addCleanup(os.unlink, summary_path)
+ with patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": summary_path}):
+ canary_lib.write_github_summary(report)
+ contents = pathlib.Path(summary_path).read_text()
+ self.assertIn("WAF challenges | 1", contents)
+ self.assertIn("blocked machine access; failing", contents)
+ self.assertIn("| civic | jsonapi-index | waf_challenge |", contents)
+
+ def test_canary_keeps_plain_forbidden_as_auth_failure(self):
+ headers = Message()
+ error = urllib.error.HTTPError(
+ "https://raleighnc.gov/jsonapi", 403, "Forbidden", headers, None
+ )
+ with patch("canary.core.json_request", side_effect=error):
+ results = canary_lib.probe_civic_jsonapi()
+ self.assertEqual(results[0]["status"], "fail")
+ self.assertEqual(results[0]["failure_class"], "auth_regression")
+
+ def test_canary_summary_counts_waf_challenges_as_failures(self):
+ observations = [{
+ "source": "civic",
+ "target": "jsonapi-index",
+ "status": "fail",
+ "failure_class": "waf_challenge",
+ "error": "Cloudflare managed challenge",
+ "attempt": 1,
+ }]
+ with patch.object(canary_lib, "ALL_PROBES", [("civic", lambda: observations)]):
+ report = canary_lib.run_canary()
+ self.assertFalse(report["passed"])
+ self.assertEqual(report["summary"]["waf_challenges"], 1)
+ self.assertEqual(report["summary"]["durable_failures"], 1)
+
def test_canary_imagery_probe_reports_restricted_folders_as_non_failing(self):
with patch("canary.imagery.list_services", return_value=(
[{"name": "Orthos2025", "type": "ImageServer"}],
diff --git a/references/skill-triggers.md b/references/skill-triggers.md
index 4325f9e..fdad4eb 100644
--- a/references/skill-triggers.md
+++ b/references/skill-triggers.md
@@ -18,8 +18,15 @@ Each skill's `description` field is the canonical routing contract. This conveni
| "self-hosted runner", "github actions runner", "CI runner", "set up a runner", "runner registration", "runner won't register", "autoscaling runners", "runner security", "runner group", "ARC", "Actions Runner Controller", "runner scale set", "myoung34/github-runner", "ephemeral runner", "just-in-time runner", "runner container image", "runner custom image", "runner network", "runner troubleshooting", "runner monitoring" | [github-runner](../github-runner/SKILL.md) |
| "Grafana", "Grafana dashboard", "Grafana panel", "Grafana variable", "Grafana data source", "Grafana alerting", "contact point", "notification policy", "mute timing", "Grafana provisioning", "dashboard as code", "Grafana API", "Grafana service account", "Grafana RBAC", "Grafana plugin", "Grafana troubleshooting", "duplicate dashboard UID" | [grafana](../grafana/SKILL.md) |
| "hugo theme", "hugo cms", "accessible theme", "wcag theme", "theme design", "theme accessibility", "theme UX", "design tokens", "css theme", "theme contrast", "responsive theme", "hugo template", "hugo pipes", "hugo module", "hugo shortcode", "render hook", "tailwindcss hugo", "hugo i18n", "hugo seo", "hugo output format", "hugo site", "hugo static site" | [hugo-theme](../hugo-theme/SKILL.md) |
-| "Jellyfin", "Jellyfin media server", "recently added movies", "recently added episodes", "media library", "JELLYFIN_API_KEY" | [jellyfin-cli](../jellyfin-cli/SKILL.md) |
-| "weather", "forecast", "temperature", "is it raining", "Tempest" | [tempest-cli](../tempest-cli/SKILL.md) |
+| "Ghost", "Ghost CMS", "ghost blog", "create a post on my blog", "blog publishing", "GHOST_ADMIN_KEY" | [ghost](../ghost/SKILL.md) |
+| "Jellyfin", "Jellyfin media server", "recently added movies", "recently added episodes", "media library", "JELLYFIN_API_KEY", "Jellyfin API authentication" | [jellyfin](../jellyfin/SKILL.md) |
+| "Jira", "Atlassian Jira", "JQL", "ticket PROJ-123", "sprint work", "JIRA_API_TOKEN" | [jira](../jira/SKILL.md) |
+| "Open Library", "openlibrary", "book search", "ISBN lookup", "author records", "work details", "book editions", "book ratings", "cover image" | [openlibrary](../openlibrary/SKILL.md) |
+| "PeerTube", "peertube", "federated video", "SepiaSearch", "decentralized video platform", "PEERTUBE_SERVER", "my PeerTube instance" | [peertube](../peertube/SKILL.md) |
+| "weather", "forecast", "temperature", "is it raining", "Tempest", "WeatherFlow", "my station" | [tempest](../tempest/SKILL.md) |
+| "Transistor", "Transistor.fm", "podcast hosting", "podcast episodes", "episode publishing", "private podcast subscribers", "podcast download analytics" | [transistor](../transistor/SKILL.md) |
+| "Trakt", "Trakt.tv", "trending movies", "trending shows", "popular movies", "anticipated movies", "what's trending", "TRAKT_CLIENT_ID" | [trakt](../trakt/SKILL.md) |
+| "TMDb", "The Movie Database", "movie search", "trending movies", "upcoming TV releases", "TMDB_ACCESS_TOKEN" | [tmdb](../tmdb/SKILL.md) |
| "traefik", "reverse proxy", "load balancer", "API gateway", "Let's Encrypt", "ACME", "Docker routing", "traefik.yml", "entry point", "middleware", "TLS termination", "forward auth", "rate limit" | [traefik](../traefik/SKILL.md) |
| "reverse-engineer", "understand this codebase", "PRD from code", "architecture document", "architecture health", "coupling analysis", "modularity", "decomposition readiness", "data ownership map", "distributed workflow analysis", "reconciliation path" | [software-architecture-analysis](../software-architecture-analysis/SKILL.md) |
| "backend service", "service layer", "domain/application/infrastructure", "unit of work", "domain event implementation", "transactional outbox", "inbox deduplication", "idempotent handler", "event replay handler", "message consumer implementation", "service coexistence", "strangler handoff", "service adapter", "anti-corruption adapter", "dual path authority" | [backend-engineering](../backend-engineering/SKILL.md) |
diff --git a/scripts/grandfathered-skills.txt b/scripts/grandfathered-skills.txt
index 71ececb..1d6b17f 100644
--- a/scripts/grandfathered-skills.txt
+++ b/scripts/grandfathered-skills.txt
@@ -29,15 +29,11 @@ flaresolverr
flaresolverr-cli
forgejo-cli
frontend-engineering
-ghost-cli
github-runner
go-to-market
gutenberg
haystack
hugo-theme
-jellyfin-cli
-jira-cli
-jira-jql
kanban-guru
kubernetes
langchain
@@ -51,11 +47,9 @@ meshcore-packet-capture
ml-engineering
nous-branding
open-knowledge-format
-openlibrary-cli
opensource-contributions
operational-design
org-design
-peertube
platform-engineering
product-design-and-ux
product-discovery
@@ -79,12 +73,8 @@ supabase
systematic-debugging
technical-documentation
technology-radar
-tempest-cli
three
-tmdb-cli
traefik
-trakt
-transistor
vercel-eve
web-accessibility
woodpecker-ci
diff --git a/tempest-cli/README.md b/tempest-cli/README.md
deleted file mode 100644
index 85a4d8b..0000000
--- a/tempest-cli/README.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Tempest — Hyper-Local Weather from Your Station
-
-Query live weather data from a WeatherFlow Tempest station. Current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts.
-
-## Why Install This Skill
-
-When your agent loads this skill, it can **check hyper-local weather from your own station** — more accurate than generic services. That means:
-
-- **Current conditions** — temperature, humidity, wind, rain, UV, solar radiation, barometric pressure
-- **7-day forecast** — daily and hourly outlook with precipitation probability
-- **Historical data** — past observations for analysis
-- **Real-time UDP** — local broadcast reception without cloud dependency
-- **Auto-discovery** — finds your station and sensors automatically
-
-## What You Get
-
-| Directory | Purpose |
-|-----------|---------|
-| `SKILL.md` | Complete command reference with examples |
-| `scripts/tempest-cli` | CLI tool for WeatherFlow Tempest API |
-| `references/` | API field layout reference |
-
-## Quick Start
-
-```bash
-export TEMPEST_TOKEN="your-token-here"
-tempest-cli current
-tempest-cli forecast
-```
-
-## Triggers
-
-Load this for weather, temperature, rain, wind, humidity, forecast, or conditions from a specific Tempest station.
-
-## Requirements
-
-Python 3.8+ with `requests` library. Free token from weatherflow.com.
diff --git a/tempest-cli/SKILL.md b/tempest-cli/SKILL.md
deleted file mode 100644
index a140ae1..0000000
--- a/tempest-cli/SKILL.md
+++ /dev/null
@@ -1,157 +0,0 @@
----
-name: tempest-cli
-description: 'Query hyper-local weather from a WeatherFlow Tempest station: current
- conditions, 7-day forecast, historical observations, and real-time UDP broadcasts.
- Use when the user asks about the weather, temperature, rain, wind, humidity, forecast,
- or wants conditions from their own station rather than a generic weather service.'
-license: MIT
-compatibility: Requires TEMPEST_TOKEN env var (free from weatherflow.com), Python
- 3.8+, and the `requests` library.
-metadata:
- tags: weather, tempest, forecast, weatherflow, station, hyper-local
- sources: https://weatherflow.com, https://swd.weatherflow.com/swd/rest
----
-
-# tempest-cli — Hyper-Local Weather from Your Tempest Station
-
-Query live weather data from a WeatherFlow Tempest station. Supports REST API access to current conditions, forecasts, and history via the cloud, plus local UDP broadcast reception from your hub on the same LAN.
-
-## Setup
-
-1. Get a personal access token at [weatherflow.com](https://weatherflow.com) (Account → API Tokens)
-2. Set it in your environment:
-
-```bash
-export TEMPEST_TOKEN="your-token-here"
-```
-
-The CLI reads `TEMPEST_TOKEN` from the environment. It also falls back to reading `~/.tempest.env` if the env var is not set (for agent subprocesses that don't inherit env vars). `--help` and `--dry-run` work without a token.
-
-## Essential Commands
-
-### current — Current conditions
-
-```bash
-tempest-cli current # human-readable
-tempest-cli current --station-id 12345 --device-id 67890 # specific hardware
-tempest-cli current --json # machine-readable
-```
-
-If you have one station, it auto-selects it and picks the best sensor (ST > SKY > AIR, skips the HB hub). Pass `--station-id` or `--device-id` to override.
-
-### forecast — Multi-day forecast (+ current conditions + hourly)
-
-```bash
-tempest-cli forecast # current + 5-day daily + 12-hour hourly
-tempest-cli forecast --days 3 # fewer days
-tempest-cli forecast --station-id 12345 # specific station
-tempest-cli forecast --json # machine-readable
-```
-
-### stations — List your stations and devices
-
-```bash
-tempest-cli stations # shows station names, IDs, device types, serials
-tempest-cli stations --json # full device inventory
-```
-
-Use this first if you don't know your station ID or want to see what sensors are online.
-
-### obs — Historical observations
-
-```bash
-tempest-cli obs --device-id 67890 --days 1 # last 24 hours
-tempest-cli obs --device-id 67890 --days 7 # last week
-tempest-cli obs --device-id 67890 --json # machine-readable
-```
-
-### udp listen — Real-time broadcasts from the hub
-
-```bash
-tempest-cli udp listen # listen indefinitely (Ctrl-C to stop)
-tempest-cli udp listen --timeout 30 # auto-stop after 30s
-tempest-cli udp listen --show-all # include hub_status messages
-```
-
-Requires being on the same LAN as the hub (port 50222 UDP broadcast). Receives observations, rapid wind updates, lightning strike events, and precipitation start events in real time.
-
-## Data Reference
-
-### obs_st field layout (Tempest all-in-one)
-
-Observations from the Tempest sensor arrive as positional arrays. The CLI decodes them, but if you're reading raw JSON output, this map tells you what each index means:
-
-| Index | Field | Units | Notes |
-|-------|-------|-------|-------|
-| 0 | epoch | seconds UTC | |
-| 1 | wind_lull | m/s | Minimum 3-second sample |
-| 2 | wind_avg | m/s | Average over report interval |
-| 3 | wind_gust | m/s | Maximum 3-second sample |
-| 4 | wind_direction | degrees | 0=N |
-| 5 | wind_sample_interval | seconds | |
-| 6 | station_pressure | MB | |
-| 7 | air_temperature | C | CLI converts to °F |
-| 8 | relative_humidity | % | |
-| 9 | illuminance | lux | |
-| 10 | uv | index | |
-| 11 | solar_radiation | W/m² | |
-| 12 | rain_accumulation | mm | Over last interval |
-| 13 | precipitation_type | enum | 0=none 1=rain 2=hail |
-| 14 | avg_strike_distance | km | Lightning |
-| 15 | strike_count | count | Lightning |
-| 16 | battery | volts | ~2.6V normal, ~2.5V low |
-| 17 | report_interval | minutes | |
-| 18 | local_day_rain_accumulation | mm | |
-
-See [references/tempest-api-field-layouts.md](references/tempest-api-field-layouts.md) for the full obs_air and obs_sky field layouts.
-
-### Unit conversions the CLI applies
-
-| Input | Output | Conversion |
-|-------|--------|------------|
-| °C | °F | `c * 9/5 + 32` |
-| m/s | mph | `mps * 2.237` |
-| MB | inHg | `mb * 0.02953` |
-| mm | in | `mm / 25.4` |
-| degrees | cardinal | N, NNE, NE, ..., NNW |
-
-## Known Gotchas
-
-### The API is metric-native
-
-All raw observation data comes in metric (Celsius, m/s, MB, mm). The CLI converts for human display. If you're parsing raw `--json` output, expect metric values. The `better_forecast` endpoint returns unit-converted values based on station preferences — check the `units` key in the response — but **temperatures are always in Celsius** regardless.
-
-### Timestamps are epoch integers, not strings
-
-The API returns Unix epoch timestamps, not ISO 8601 strings. The `daily[].day_start_local` field is an `int`, not `"2026-05-10T00:00:00"`. Hourly objects have `local_hour` (int 0-23) and `local_day` (int) — there is **no** `local_time` or `time_string` field. The CLI handles this, but raw JSON consumers need to convert with `datetime.fromtimestamp(ts)`.
-
-### Nested forecast response
-
-The `better_forecast` endpoint nests daily and hourly arrays under a `forecast` wrapper key, not at the top level:
-
-```python
-# Correct path:
-fc = data.get("forecast", {})
-days = fc.get("daily", [])
-hours = fc.get("hourly", [])
-```
-
-The top-level keys are: `current_conditions` (dict), `forecast` (dict with `daily` + `hourly`), `station` (metadata), `units`, `status`, `timezone`.
-
-### Device type filtering
-
-A station returns all devices including the hub (device_type `HB`). The hub cannot serve observations. The CLI auto-filters HB devices and prefers ST > SKY > AIR. If you're bypassing the CLI and calling the API directly, always filter out device_type `HB` before querying observation endpoints.
-
-### Global flags in any position
-
-`--json`, `--dry-run`, `--quiet`, and `--verbose` work anywhere in the command:
-
-```bash
-tempest-cli --json current --device-id 67890 # flag before subcommand
-tempest-cli current --device-id 67890 --json # flag after subcommand
-```
-
-## References
-
-- [references/tempest-api-field-layouts.md](references/tempest-api-field-layouts.md) — Full field index maps for obs_st, obs_air, and obs_sky observation arrays. Read when decoding raw JSON output or building on top of the Tempest API.
-- [scripts/tempest-cli](scripts/tempest-cli) — The CLI binary itself. Designed following the cli-builder patterns: non-interactive, `--json`, `--dry-run`, `--quiet`, `--verbose`, idempotent, dual-output via `emit()`, and structured logging.
diff --git a/tempest-cli/references/tempest-api-field-layouts.md b/tempest-cli/references/tempest-api-field-layouts.md
deleted file mode 100644
index 16c291e..0000000
--- a/tempest-cli/references/tempest-api-field-layouts.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# Tempest API Field Layouts
-
-Quick reference for the obs_st (Tempest all-in-one) observation array layout.
-The API returns observations as positional arrays — these index maps are
-required for any CLI or script that reads raw observation data.
-
-## obs_st (Tempest Device)
-
-| Index | Field | Units | Notes |
-|-------|-------|-------|-------|
-| 0 | epoch | seconds UTC | |
-| 1 | wind_lull | m/s | Minimum 3-second sample |
-| 2 | wind_avg | m/s | Average over report interval |
-| 3 | wind_gust | m/s | Maximum 3-second sample |
-| 4 | wind_direction | degrees | 0-360 |
-| 5 | wind_sample_interval | seconds | |
-| 6 | station_pressure | MB | |
-| 7 | air_temperature | C | |
-| 8 | relative_humidity | % | |
-| 9 | illuminance | lux | |
-| 10 | uv | index | |
-| 11 | solar_radiation | W/m² | |
-| 12 | rain_accumulation | mm | Over last report interval |
-| 13 | precipitation_type | 0=none 1=rain 2=hail | |
-| 14 | avg_strike_distance | km | |
-| 15 | strike_count | count | |
-| 16 | battery | volts | |
-| 17 | report_interval | minutes | |
-| 18 | local_day_rain_accumulation | mm | |
-| 19 | nc_rain_accumulation | mm | |
-| 20 | local_day_nc_rain_accumulation | mm | |
-| 21 | precip_analysis_type | enum | 0=none, 1=RainCheck display on, 2=off |
-
-## obs_air (Air Sensor)
-
-| Index | Field | Units |
-|-------|-------|-------|
-| 0 | epoch | seconds UTC |
-| 1 | station_pressure | MB |
-| 2 | air_temperature | C |
-| 3 | relative_humidity | % |
-| 4 | lightning_strike_count | count |
-| 5 | lightning_avg_distance | km |
-| 6 | battery | volts |
-| 7 | report_interval | minutes |
-
-## obs_sky (Sky Sensor)
-
-| Index | Field | Units | Notes |
-|-------|-------|-------|-------|
-| 0 | epoch | seconds UTC | |
-| 1 | illuminance | lux | |
-| 2 | uv | index | |
-| 3 | rain_accumulation | mm | |
-| 4 | wind_lull | m/s | |
-| 5 | wind_avg | m/s | |
-| 6 | wind_gust | m/s | |
-| 7 | wind_direction | degrees | |
-| 8 | battery | volts | |
-| 9 | report_interval | minutes | |
-| 10 | solar_radiation | W/m² | |
-| 11 | local_day_rain_accumulation | mm | |
-| 12 | precipitation_type | 0=none 1=rain 2=hail | |
-| 13 | wind_sample_interval | seconds | |
-| 14 | nc_rain | mm | |
-| 15 | local_day_nc_rain | mm | |
-| 16 | precip_analysis_type | 0=none 1=RainCheck on 2=off | |
-
-## API Response Quirks
-
-### better_forecast nesting
-The `daily` and `hourly` arrays live under a `forecast` wrapper key, NOT at the
-top level of the response. If reading from the raw API:
-
-```python
-# Wrong (assumes top-level):
-days = data.get("daily", []) # returns []
-
-# Right (respects nesting):
-fc = data.get("forecast", {})
-days = fc.get("daily", [])
-hours = fc.get("hourly", [])
-```
-
-The top-level keys of `/better_forecast` are:
-- `current_conditions` — dict with air_temperature, conditions, icon, etc.
-- `forecast` — dict containing `daily` (list) and `hourly` (list)
-- `station` — metadata (elevation, agl, station_id)
-- `units` — unit system for the response
-- `status` — status_code, status_message
-- `timezone`, `timezone_offset_minutes`, `latitude`, `longitude`, `location_name`
-
-### Device types in /stations
-Devices within a station have a `device_type` field. Known values:
-- `HB` — Hub (cannot query observations — no `/observations/device/{id}` endpoint)
-- `ST` — Tempest all-in-one (preferred sensor)
-- `SKY` — Sky sensor
-- `AIR` — Air sensor
-
-Always filter out HB devices before auto-selecting a device for observation queries.
-
-### Auth
-Token is passed as query parameter: `?token=XXX`
-No header-based auth for the swd.weatherflow.com REST API.
-Personal access tokens generated at https://weatherflow.com (account → API Tokens).
-
-### Units
-Raw observations use metric (C, m/s, MB, mm).
-The `better_forecast` endpoint returns unit-converted values based on station
-preferences. The `units` key in the response documents which units are in use.
-All temperature values are in **Celsius** regardless of station preference — CLI
-must convert to °F if displaying imperial. Unit labels from the API are authority.
-
-### Field type traps in better_forecast
-
-The forecast endpoint uses epoch integers where you'd expect date strings, and
-field names that differ from what common sense suggests:
-
-| Field | Actual type | Common mistake | Fix |
-|-------|-----------|---------------|-----|
-| `daily[].day_start_local` | epoch int (e.g. 1778385600) | Assumed ISO string "2026-05-10T..." | `datetime.fromtimestamp(ts).strftime(...)` |
-| `hourly[].local_hour` | int (e.g. 10) | Assumed ISO timestamp string | Use directly as `{h:02d}:00` |
-| `hourly[].local_day` | int (e.g. 10 for the 10th) | N/A | Use alongside `local_hour` for time-of-day |
-| `hourly[].local_time` | **does not exist** | Commonly assumed field | Use `local_hour` instead |
-
-The hourly objects do NOT have a `local_time` or `time_string` field — just
-`time` (epoch int), `local_day` (int), and `local_hour` (int, 0-23). Any code
-looking for `local_time` will silently fall back to its default/"?" branch.
diff --git a/tempest-cli/scripts/tempest-cli b/tempest-cli/scripts/tempest-cli
deleted file mode 100755
index b4429f9..0000000
--- a/tempest-cli/scripts/tempest-cli
+++ /dev/null
@@ -1,696 +0,0 @@
-#!/usr/bin/env python3
-"""tempest-cli — Hyper-local weather from your Tempest station.
-
-Two data sources:
- REST API — stations, observations, forecast via WeatherFlow cloud
- UDP/local — real-time broadcast from your hub on port 50222
-
-Requires TEMPEST_TOKEN env var (personal access token from weatherflow.com).
-"""
-
-import argparse
-import json
-import os
-import socket
-import struct
-import sys
-import time
-import warnings
-from datetime import datetime, timezone, timedelta
-from typing import Any, Dict, List, Optional, Tuple
-
-# === Suppress dependency warnings before imports ===
-warnings.simplefilter("ignore")
-
-import requests
-
-# === Config ===
-DEFAULT_SERVER = "https://swd.weatherflow.com/swd/rest"
-DEFAULT_UDP_PORT = 50222
-UDP_BROADCAST_ADDR = "0.0.0.0"
-
-ENV_TOKEN = os.getenv("TEMPEST_TOKEN", "")
-ENV_SERVER = os.getenv("TEMPEST_SERVER", DEFAULT_SERVER)
-
-# === Logging ===
-QUIET = False
-
-
-def log(msg: str) -> None:
- """Log to stdout, suppressed in --json or --quiet mode."""
- if not QUIET and not GLOBAL_FLAGS.get("json", False):
- print(msg)
-
-
-def warn(msg: str) -> None:
- print(f"Warning: {msg}", file=sys.stderr)
-
-
-def die(msg: str, exit_code: int = 1) -> None:
- print(f"Error: {msg}", file=sys.stderr)
- sys.exit(exit_code)
-
-
-def emit(human: str, data: Any) -> None:
- """Dual output — machine JSON or human text."""
- if GLOBAL_FLAGS.get("json", False):
- print(json.dumps(data, default=str))
- else:
- print(human)
-
-
-# === Global flags (pre-parsed from argv) ===
-GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False}
-
-
-def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]:
- """Strip global flags from argv regardless of position."""
- GLOBAL_BOOLS = {"--json", "--dry-run", "--force", "--quiet", "--verbose"}
- flags: Dict[str, Any] = {}
- filtered: List[str] = [argv[0]]
- i = 1
- while i < len(argv):
- arg = argv[i]
- if arg in GLOBAL_BOOLS:
- flags[arg.lstrip("-").replace("-", "_")] = True
- i += 1
- elif arg == "--help" or arg == "-h":
- return flags, argv # let argparse handle help
- elif arg == "--":
- filtered.extend(argv[i:])
- break
- else:
- filtered.append(arg)
- i += 1
- return flags, filtered
-
-
-# === Tempest API Client ===
-class TempestClient:
- """REST API client for WeatherFlow Tempest."""
-
- def __init__(self, token: str = "", server: str = "", dry_run: bool = False):
- self.token = token or ENV_TOKEN
- self.server = (server or ENV_SERVER).rstrip("/")
- self.dry_run = dry_run
-
- def _get(self, path: str, params: Optional[Dict] = None) -> Any:
- """Generic GET with token auth."""
- url = f"{self.server}{path}"
- if params is None:
- params = {}
- params["token"] = self.token
-
- if self.dry_run:
- return {"dry_run": True, "url": url, "params": params}
-
- try:
- resp = requests.get(url, params=params, timeout=30)
- except requests.ConnectionError as e:
- die(f"Cannot connect to {self.server}: {e}\n Is the server reachable?")
-
- if resp.status_code == 401:
- die("Auth failed (401). Check your TEMPEST_TOKEN or generate a new one at weatherflow.com.")
- if resp.status_code == 403:
- die("Forbidden (403). Your token may not have access to this station/device.")
- if resp.status_code == 404:
- die(f"Not found (404) at {path}. Check station/device IDs.")
- if resp.status_code >= 400:
- try:
- detail = resp.json()
- except Exception:
- detail = resp.text[:200]
- die(f"API error ({resp.status_code}): {detail}")
-
- try:
- return resp.json()
- except ValueError:
- return {"raw": resp.text[:500]}
-
- # === Endpoints ===
-
- def get_stations(self) -> List[Dict]:
- """List all stations and their devices."""
- data = self._get("/stations")
- return data if isinstance(data, list) else data.get("stations", [])
-
- def get_observations(self, device_id: int, days_back: int = 0, time_start: Optional[int] = None) -> Dict:
- """Get observations for a device. Use days_back=1 for last day, or time_start epoch."""
- params: Dict[str, Any] = {}
- if days_back > 0:
- params["day_offset"] = days_back
- elif time_start:
- params["time_start"] = time_start
- else:
- params["latest"] = "true"
- return self._get(f"/observations/device/{device_id}", params)
-
- def get_forecast(self, station_id: int) -> Dict:
- """Get better_forecast — current conditions + daily + hourly."""
- return self._get("/better_forecast", {"station_id": station_id})
-
-
-# === Observation decoders ===
-
-OBS_ST_FIELDS = [
- ("epoch", "seconds_utc"),
- ("wind_lull", "m/s"),
- ("wind_avg", "m/s"),
- ("wind_gust", "m/s"),
- ("wind_direction", "degrees"),
- ("wind_sample_interval", "seconds"),
- ("station_pressure", "MB"),
- ("air_temperature", "C"),
- ("relative_humidity", "%"),
- ("illuminance", "lux"),
- ("uv", "index"),
- ("solar_radiation", "W/m^2"),
- ("rain_accumulation", "mm"),
- ("precipitation_type", "0=none 1=rain 2=hail"),
- ("avg_strike_distance", "km"),
- ("strike_count", "count"),
- ("battery", "volts"),
- ("report_interval", "minutes"),
- ("local_day_rain_accumulation", "mm"),
- ("nc_rain_accumulation", "mm"),
- ("local_day_nc_rain_accumulation", "mm"),
- ("precip_analysis_type", "type"),
-]
-
-OBS_AIR_FIELDS = [
- ("epoch", "seconds_utc"),
- ("station_pressure", "MB"),
- ("air_temperature", "C"),
- ("relative_humidity", "%"),
- ("lightning_strike_count", "count"),
- ("lightning_avg_distance", "km"),
- ("battery", "volts"),
- ("report_interval", "minutes"),
-]
-
-OBS_SKY_FIELDS = [
- ("epoch", "seconds_utc"),
- ("illuminance", "lux"),
- ("uv", "index"),
- ("rain_accumulation", "mm"),
- ("wind_lull", "m/s"),
- ("wind_avg", "m/s"),
- ("wind_gust", "m/s"),
- ("wind_direction", "degrees"),
- ("battery", "volts"),
- ("report_interval", "minutes"),
- ("solar_radiation", "W/m^2"),
- ("local_day_rain_accumulation", "mm"),
- ("precipitation_type", "0=none 1=rain 2=hail"),
- ("wind_sample_interval", "seconds"),
-]
-
-
-def decode_obs(obs_array: List, type_str: str) -> Dict:
- """Decode a raw observation array into a dict with field names."""
- if type_str == "obs_st":
- fields = OBS_ST_FIELDS
- elif type_str == "obs_air":
- fields = OBS_AIR_FIELDS
- elif type_str == "obs_sky":
- fields = OBS_SKY_FIELDS
- else:
- return {f"field_{i}": v for i, v in enumerate(obs_array)}
-
- result = {}
- for i, (name, unit) in enumerate(fields):
- val = obs_array[i] if i < len(obs_array) else None
- if name == "epoch" and val is not None:
- result["timestamp"] = datetime.fromtimestamp(val, tz=timezone.utc).isoformat()
- result[name] = val
- result[f"{name}_unit"] = unit if name != "epoch" else None
- return result
-
-
-def wind_dir_to_cardinal(deg: Optional[float]) -> str:
- """Convert wind degrees to cardinal direction."""
- if deg is None:
- return "N/A"
- dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
- "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N"]
- idx = round(deg / 22.5)
- return dirs[idx % 16]
-
-
-def format_current(obs: Dict) -> str:
- """Format current conditions for human display."""
- lines = []
- if obs.get("air_temperature") is not None:
- temp_c = obs["air_temperature"]
- temp_f = temp_c * 9 / 5 + 32
- lines.append(f"🌡️ Temperature: {temp_c:.1f}°C / {temp_f:.1f}°F")
-
- if obs.get("relative_humidity") is not None:
- lines.append(f"💧 Humidity: {obs['relative_humidity']:.0f}%")
-
- if obs.get("station_pressure") is not None:
- press_mb = obs["station_pressure"]
- press_inhg = press_mb * 0.02953
- lines.append(f"🔵 Pressure: {press_mb:.1f} MB / {press_inhg:.2f} inHg")
-
- if obs.get("wind_avg") is not None:
- wind_mps = obs["wind_avg"]
- wind_mph = wind_mps * 2.237
- gust_mps = obs.get("wind_gust")
- gust_mph = gust_mps * 2.237 if gust_mps else None
- dir_deg = obs.get("wind_direction")
- card = wind_dir_to_cardinal(dir_deg)
- gust_str = f" (gust {gust_mph:.1f} mph)" if gust_mph else ""
- lines.append(f"💨 Wind: {wind_mps:.1f} m/s ({wind_mph:.1f} mph){gust_str} from {card} ({dir_deg:.0f}°)")
-
- if obs.get("illuminance") is not None and obs["illuminance"] > 0:
- lux = obs["illuminance"]
- lines.append(f"☀️ Illuminance: {lux:.0f} lux")
- if obs.get("solar_radiation") is not None and obs["solar_radiation"] > 0:
- lines.append(f"⚡ Solar Radiation: {obs['solar_radiation']:.0f} W/m²")
- if obs.get("uv") is not None:
- lines.append(f"🌞 UV Index: {obs['uv']:.1f}")
-
- if obs.get("rain_accumulation") is not None and obs["rain_accumulation"] > 0:
- rain_mm = obs["rain_accumulation"]
- rain_in = rain_mm / 25.4
- lines.append(f"🌧️ Rain (last interval): {rain_mm:.2f} mm / {rain_in:.3f} in")
- if obs.get("local_day_rain_accumulation") is not None and obs["local_day_rain_accumulation"] > 0:
- day_mm = obs["local_day_rain_accumulation"]
- day_in = day_mm / 25.4
- lines.append(f"🌧️ Rain (today): {day_mm:.2f} mm / {day_in:.3f} in")
-
- if obs.get("strike_count") is not None and obs["strike_count"] > 0:
- lines.append(f"⚡ Lightning strikes: {obs['strike_count']} (avg dist {obs.get('avg_strike_distance', '?')} km)")
-
- if obs.get("battery") is not None:
- lines.append(f"🔋 Battery: {obs['battery']:.2f}V")
-
- if obs.get("timestamp"):
- lines.append(f"🕐 Recorded: {obs['timestamp']}")
-
- return "\n".join(lines) if lines else "(no observations)"
-
-
-# === CLI Commands ===
-
-def cmd_stations(client: TempestClient, args: List[str]) -> None:
- """List stations and attached devices."""
- stations = client.get_stations()
- if not stations:
- emit("No stations found for this token.", {"stations": []})
- return
-
- output_human = []
- for s in stations:
- name = s.get("name", s.get("station_name", "Unnamed"))
- sid = s.get("station_id", "?")
- output_human.append(f"Station: {name} (id={sid})")
- for dev in s.get("devices", []):
- dev_id = dev.get("device_id", "?")
- dev_type = dev.get("device_type", "?")
- sn = dev.get("serial_number", "?")
- output_human.append(f" ├─ Device: {dev_type} (id={dev_id}, sn={sn})")
- if dev.get("name"):
- output_human.append(f" │ Name: {dev['name']}")
-
- emit("\n".join(output_human), {"stations": stations})
-
-
-def cmd_current(client: TempestClient, args: List[str]) -> None:
- """Get latest observations from your station."""
- parser = argparse.ArgumentParser(prog="tempest-cli current")
- parser.add_argument("--station-id", type=int, help="Station ID (optional if only one station)")
- parser.add_argument("--device-id", type=int, help="Device ID (default: first Tempest device)")
- parsed, _ = parser.parse_known_args(args)
-
- if client.dry_run:
- emit("[dry-run] Would query latest observations from your station.",
- {"dry_run": True, "command": "current",
- "station_id": parsed.station_id, "device_id": parsed.device_id})
- return
-
- stations = client.get_stations()
- if not stations:
- die("No stations found. Verify your TEMPEST_TOKEN.")
-
- # Pick station
- if parsed.station_id:
- station = next((s for s in stations if s.get("station_id") == parsed.station_id), None)
- if not station:
- die(f"Station {parsed.station_id} not found.")
- else:
- station = stations[0]
-
- devices = station.get("devices", [])
- if not devices:
- die(f"Station '{station.get('name', '?')}' has no devices.")
-
- # Pick device — skip the hub (HB), prefer ST (Tempest) or SKY/AIR
- if parsed.device_id:
- device = next((d for d in devices if d.get("device_id") == parsed.device_id), None)
- else:
- # Filter out the hub (device_type=HB), use first sensor
- sensors = [d for d in devices if d.get("device_type") not in ("HB", "hub")]
- if not sensors:
- die("No sensor devices found on this station (only a hub).")
- # Prefer Tempest (ST), then Sky, then Air
- for preferred in ("ST", "SKY", "AIR"):
- match = next((d for d in sensors if d.get("device_type") == preferred), None)
- if match:
- device = match
- break
- else:
- device = sensors[0]
-
- dev_id = device.get("device_id")
- log(f"Station: {station.get('name', '?')} Device: {device.get('device_type', '?')} (id={dev_id})")
-
- obs_data = client.get_observations(dev_id)
- obs_list = obs_data.get("obs", [])
- obs_type = obs_data.get("type", "obs_st")
-
- if not obs_list:
- emit("No observations available yet.", {"observations": [], "type": obs_type})
- return
-
- latest = obs_list[-1] # newest
- decoded = decode_obs(latest, obs_type)
-
- if GLOBAL_FLAGS.get("json", False):
- emit("", {"station": station.get("name", "?"), "device_id": dev_id, "type": obs_type, "observation": decoded})
- else:
- print(format_current(decoded))
-
-
-def cmd_obs(client: TempestClient, args: List[str]) -> None:
- """Get historical observations."""
- parser = argparse.ArgumentParser(prog="tempest-cli obs")
- parser.add_argument("--device-id", type=int, required=True, help="Device ID (required)")
- parser.add_argument("--days", type=int, default=1, help="Days back to fetch (default: 1)")
- parsed, _ = parser.parse_known_args(args)
-
- if client.dry_run:
- emit("[dry-run] Would fetch historical observations.",
- {"dry_run": True, "command": "obs",
- "device_id": parsed.device_id, "days": parsed.days})
- return
-
- obs_data = client.get_observations(parsed.device_id, days_back=parsed.days)
- obs_list = obs_data.get("obs", [])
- obs_type = obs_data.get("type", "obs_st")
-
- decoded = [decode_obs(o, obs_type) for o in obs_list]
-
- if GLOBAL_FLAGS.get("json", False):
- emit("", {"device_id": parsed.device_id, "type": obs_type, "count": len(decoded), "observations": decoded})
- else:
- print(f"{len(decoded)} observations from the last {parsed.days} day(s) (type: {obs_type}):")
- print("")
- # Show latest 3
- for o in decoded[-3:]:
- print("---")
- print(format_current(o))
- print("")
-
-
-def cmd_forecast(client: TempestClient, args: List[str]) -> None:
- """Get forecast — current conditions + daily + hourly."""
- parser = argparse.ArgumentParser(prog="tempest-cli forecast")
- parser.add_argument("--station-id", type=int, help="Station ID (optional if only one station)")
- parser.add_argument("--days", type=int, default=5, help="Days of daily forecast (default: 5)")
- parsed, _ = parser.parse_known_args(args)
-
- if client.dry_run:
- emit("[dry-run] Would fetch hyper-local forecast for your station.",
- {"dry_run": True, "command": "forecast",
- "station_id": parsed.station_id, "days": parsed.days})
- return
-
- stations = client.get_stations()
- if not stations:
- die("No stations found.")
-
- if parsed.station_id:
- station = next((s for s in stations if s.get("station_id") == parsed.station_id), None)
- else:
- station = stations[0]
- if not station:
- die("Station not found.")
-
- sid = station.get("station_id")
- name = station.get("name", "?")
- log(f"Fetching forecast for station '{name}' (id={sid})...\n")
-
- data = client.get_forecast(sid)
- fc_data = data.get("forecast", data) # prefer nested "forecast" key, fall back to top-level
- if GLOBAL_FLAGS.get("json", False):
- emit("", {"station_id": sid, "station_name": name, "forecast": data})
- return
-
- # Current conditions
- current = data.get("current_conditions", {})
- if current:
- print("── Current Conditions ──")
- icon = current.get("icon", "")
- cond = current.get("conditions", "")
- icon_str = f" ({icon})" if icon else ""
- print(f"Conditions: {cond}{icon_str}")
- for key in ("air_temperature", "temperature"):
- if current.get(key) is not None:
- val = current[key]
- feels = current.get("feels_like")
- if feels is not None:
- feels_f = feels * 9 / 5 + 32
- else:
- feels_f = None
- print(f"Temperature: {val * 9 / 5 + 32:.0f}°F (feels like {feels_f:.0f}°F)" if feels_f is not None else f"Temperature: {val * 9 / 5 + 32:.0f}°F")
- break
- if current.get("relative_humidity") is not None:
- print(f"Humidity: {current['relative_humidity']}%")
- if current.get("station_pressure") is not None:
- print(f"Pressure: {current['station_pressure']} MB")
- if current.get("wind_avg") is not None:
- wd = current.get("wind_direction_cardinal", "")
- print(f"Wind: {current['wind_avg']} mph {wd}")
- if current.get("conditions"):
- print(f" [{current.get('conditions', '')}]")
- print()
-
- # Daily forecast
- daily = fc_data.get("daily", [])
- if daily:
- print(f"── {parsed.days}-Day Forecast ──")
- for day in daily[: parsed.days]:
- day_start = day.get("day_start_local")
- if isinstance(day_start, (int, float)):
- day_str = datetime.fromtimestamp(day_start).strftime("%a %b %d")
- elif day_start:
- day_str = str(day_start).split("T")[0]
- else:
- day_str = "?"
- hi = day.get("air_temp_high")
- lo = day.get("air_temp_low")
- hi_f = hi * 9 / 5 + 32 if hi is not None else None
- lo_f = lo * 9 / 5 + 32 if lo is not None else None
- cond = day.get("conditions", "")
- precip = day.get("precip_probability")
- precip_str = f" {precip}%" if precip is not None else ""
- precip_type = day.get("precip_type", "")
- hi_str = f"{hi_f:.0f}" if hi_f is not None else "?"
- lo_str = f"{lo_f:.0f}" if lo_f is not None else "?"
- print(f" {day_str}: {lo_str}–{hi_str}°F {cond}{precip_str} {precip_type}".strip())
- print()
-
- # Hourly
- hourly = fc_data.get("hourly", [])
- if hourly:
- print("── Next 12 Hours ──")
- for h in hourly[:12]:
- local_hour = h.get("local_hour")
- dt_str = f"{local_hour:02d}:00" if local_hour is not None else "?"
- temp_c = h.get("air_temperature")
- temp_str = f"{temp_c * 9 / 5 + 32:.0f}" if temp_c is not None else "?"
- cond = h.get("conditions", "")
- precip = h.get("precip_probability")
- precip_str = f" {precip}%" if precip is not None else ""
- print(f" {dt_str}: {temp_str}°F {cond}{precip_str}")
- if len(hourly) > 12:
- print(f" ... and {len(hourly) - 12} more hours")
-
-
-# === UDP Commands ===
-
-def udp_listen(args: List[str]) -> None:
- """Listen for local UDP broadcasts from the Tempest hub."""
- parser = argparse.ArgumentParser(prog="tempest-cli udp listen")
- parser.add_argument("--port", type=int, default=DEFAULT_UDP_PORT, help=f"UDP port (default: {DEFAULT_UDP_PORT})")
- parser.add_argument("--timeout", type=int, default=0, help="Listen for N seconds (0 = indefinite)")
- parser.add_argument("--show-all", action="store_true", help="Show raw message type even if unknown")
- parsed, _ = parser.parse_known_args(args)
-
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- sock.bind((UDP_BROADCAST_ADDR, parsed.port))
- sock.settimeout(parsed.timeout if parsed.timeout > 0 else None)
-
- log(f"Listening for Tempest UDP broadcasts on port {parsed.port}...")
- if parsed.timeout > 0:
- log(f"Will stop after {parsed.timeout}s\n")
-
- try:
- start = time.time()
- while True:
- try:
- data, addr = sock.recvfrom(65535)
- except socket.timeout:
- log("Listen timeout reached.")
- break
-
- try:
- msg = json.loads(data.decode("utf-8", errors="replace"))
- except json.JSONDecodeError:
- if parsed.show_all:
- log(f"[raw] from {addr[0]}: {data[:200]}")
- continue
-
- msg_type = msg.get("type", "unknown")
- sn = msg.get("serial_number", msg.get("hub_sn", "?"))
-
- if msg_type == "obs_st":
- for obs_arr in msg.get("obs", []):
- decoded = decode_obs(obs_arr, "obs_st")
- emit(f"\n── Tempest Observation from {sn} ──\n{format_current(decoded)}",
- {"type": "obs_st", "serial_number": sn, "observation": decoded})
- elif msg_type == "obs_air":
- for obs_arr in msg.get("obs", []):
- decoded = decode_obs(obs_arr, "obs_air")
- emit(f"\n── Air Observation from {sn} ──\n{format_current(decoded)}",
- {"type": "obs_air", "serial_number": sn, "observation": decoded})
- elif msg_type == "obs_sky":
- for obs_arr in msg.get("obs", []):
- decoded = decode_obs(obs_arr, "obs_sky")
- emit(f"\n── Sky Observation from {sn} ──\n{format_current(decoded)}",
- {"type": "obs_sky", "serial_number": sn, "observation": decoded})
- elif msg_type == "rapid_wind":
- for ob in msg.get("ob", []):
- ts = datetime.fromtimestamp(ob[0], tz=timezone.utc).isoformat() if len(ob) > 0 else "?"
- speed = ob[1] if len(ob) > 1 else "?"
- direction = ob[2] if len(ob) > 2 else "?"
- card = wind_dir_to_cardinal(direction)
- m_s_mph = f"{speed * 2.237:.1f} mph" if isinstance(speed, (int, float)) else "?"
- emit(f"💨 Rapid Wind: {speed} m/s ({m_s_mph}) from {card} ({direction}°) [{ts}]",
- {"type": "rapid_wind", "serial_number": sn,
- "wind_speed_mps": speed, "wind_direction": direction,
- "timestamp": ts})
- elif msg_type == "evt_strike":
- evt = msg.get("evt", [])
- ts = datetime.fromtimestamp(evt[0], tz=timezone.utc).isoformat() if len(evt) > 0 else "?"
- dist = evt[1] if len(evt) > 1 else "?"
- energy = evt[2] if len(evt) > 2 else "?"
- emit(f"⚡ Lightning Strike — distance: {dist} km, energy: {energy} [{ts}]",
- {"type": "evt_strike", "serial_number": sn,
- "distance_km": dist, "energy": energy, "timestamp": ts})
- elif msg_type == "evt_precip":
- evt = msg.get("evt", [])
- ts = datetime.fromtimestamp(evt[0], tz=timezone.utc).isoformat() if len(evt) > 0 else "?"
- emit(f"🌧️ Rain started [{ts}]",
- {"type": "evt_precip", "serial_number": sn, "timestamp": ts})
- elif msg_type == "hub_status":
- if parsed.show_all:
- emit(f"[hub_status] {sn} — freq: {msg.get('freq', '?')}",
- {"type": "hub_status", "serial_number": sn, "status": msg})
- elif parsed.show_all:
- emit(f"[{msg_type}] from {sn}", {"type": msg_type, "serial_number": sn, "raw": msg})
-
- if parsed.timeout > 0 and time.time() - start > parsed.timeout:
- break
-
- except KeyboardInterrupt:
- log("\nStopped.")
- finally:
- sock.close()
-
-
-# === Main ===
-
-def main() -> None:
- global GLOBAL_FLAGS, QUIET
- GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
- if GLOBAL_FLAGS.get("quiet", False):
- QUIET = True
- if GLOBAL_FLAGS.get("json", False):
- warnings.simplefilter("ignore")
-
- parser = argparse.ArgumentParser(
- prog="tempest-cli",
- description="Hyper-local weather from your Tempest station.",
- epilog="Global flags can appear anywhere: tempest-cli --json current --device-id X"
- )
- sub = parser.add_subparsers(dest="command", help="Available commands")
-
- # stations
- sub.add_parser("stations", help="List stations and devices linked to your token")
-
- # current
- p_current = sub.add_parser("current", help="Latest observations from your station")
- p_current.add_argument("--station-id", type=int, help="Station ID (optional)")
- p_current.add_argument("--device-id", type=int, help="Device ID (optional)")
-
- # obs
- p_obs = sub.add_parser("obs", help="Historical observations")
- p_obs.add_argument("--device-id", type=int, required=True, help="Device ID")
- p_obs.add_argument("--days", type=int, default=1, help="Days back (default: 1)")
-
- # forecast
- p_fcst = sub.add_parser("forecast", help="Hyper-local forecast (current + daily + hourly)")
- p_fcst.add_argument("--station-id", type=int, help="Station ID (optional)")
- p_fcst.add_argument("--days", type=int, default=5, help="Days of daily forecast (default: 5)")
-
- # udp
- p_udp = sub.add_parser("udp", help="Local UDP broadcast commands")
- udp_sub = p_udp.add_subparsers(dest="udp_command")
- p_listen = udp_sub.add_parser("listen", help="Listen for local UDP broadcasts from hub")
- p_listen.add_argument("--port", type=int, default=DEFAULT_UDP_PORT)
- p_listen.add_argument("--timeout", type=int, default=0, help="Listen N seconds (0=indefinite)")
- p_listen.add_argument("--show-all", action="store_true", help="Show all message types incl. hub_status")
-
- args = parser.parse_args(filtered_argv[1:])
-
- if not args.command:
- parser.print_help()
- sys.exit(1)
-
- if args.command == "stations":
- if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
- die("TEMPEST_TOKEN not set. Get one at https://weatherflow.com")
- cmd_stations(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)), [])
-
- elif args.command == "current":
- if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
- die("TEMPEST_TOKEN not set.")
- cmd_current(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
- filtered_argv[filtered_argv.index("current") + 1:])
-
- elif args.command == "obs":
- if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
- die("TEMPEST_TOKEN not set.")
- cmd_obs(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
- filtered_argv[filtered_argv.index("obs") + 1:])
-
- elif args.command == "forecast":
- if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
- die("TEMPEST_TOKEN not set.")
- cmd_forecast(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
- filtered_argv[filtered_argv.index("forecast") + 1:])
-
- elif args.command == "udp":
- if args.udp_command == "listen":
- udp_listen(filtered_argv[filtered_argv.index("listen") + 1:])
- else:
- p_udp.print_help()
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/tempest/README.md b/tempest/README.md
new file mode 100644
index 0000000..3aedf9d
--- /dev/null
+++ b/tempest/README.md
@@ -0,0 +1,51 @@
+# Tempest — Hyper-Local Weather from Your Own Station
+
+Query live weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time broadcasts from your hub's local network — with every positional sensor array and UDP message family decoded for you.
+
+## Why Install This Skill
+
+Generic weather services tell you what the model thinks the sky is doing kilometers away. This skill reads **your actual station**: the Tempest sitting in your yard, via WeatherFlow's documented REST API and the hub's local UDP broadcast. Once installed, your agent can:
+
+- **Current conditions** — temperature, humidity, wind (lull/avg/gust + direction), rain, UV, solar radiation, barometric pressure
+- **7-day forecast** — daily and hourly outlook with precipitation probabilities, unit-aware
+- **Historical observations** — past UTC days of minute-level data for analysis
+- **Real-time UDP stream** — decoded `obs_st`, `rapid_wind`, `evt_precip`, `evt_strike`, and `hub_status` messages straight from the hub on port 50222, no cloud round-trip
+- **Station discovery** — finds your stations and sensors automatically, never mistaking the hub for a sensor
+
+The tricky parts of the Tempest API are handled for you: observations arrive as *positional arrays* whose meaning depends on the index, UDP message families have three different payload shapes, and forecast responses are unit-selectable (a naive script double-converts Fahrenheit data into 172-degree nonsense). The CLI decodes all of it, keeps JSON output in metric-native wire units, and converts only for human display.
+
+## What You Get
+
+| Path | What it provides |
+|------|------------------|
+| `SKILL.md` | Command reference, pipeline recipes, and the gotchas that actually bite |
+| `scripts/tempest` | CLI: `stations`, `current`, `obs`, `forecast`, `udp listen` with `--json`/`--dry-run` |
+| `scripts/test_tempest.py` | Offline test suite (canned datagram bytes + mocked REST, no network) |
+| `references/rest-api-and-auth.md` | Token auth, endpoint catalog, response shapes, error signatures |
+| `references/udp-broadcast-protocol.md` | Port 50222 transport, every message family's exact layout |
+| `references/observation-layouts-and-units.md` | Index-by-index field maps for obs_st/obs_air/obs_sky + conversion tables |
+| `references/cli-worked-recipes.md` | Copy-paste multi-step recipes with jq stages |
+
+## Quick Start
+
+```bash
+# Create a token in the Tempest web app: Settings -> Data Authorizations -> Create Token
+export TEMPEST_TOKEN="your-token-here"
+
+tempest stations # discover your station and device IDs
+tempest current # conditions right now
+tempest forecast # current + daily + hourly outlook
+tempest udp listen --timeout 30 # real-time broadcast from the hub (no token needed)
+```
+
+Every command accepts `--json` for machine-readable output and `--dry-run` to preview the plan offline.
+
+## Triggers
+
+Load this skill when the user mentions Tempest, WeatherFlow, their weather station, hyper-local conditions, station observations, or parsing the hub's UDP port 50222 broadcast — temperature, rain, wind, humidity, lightning, or forecast questions tied to a personal station.
+
+## Requirements
+
+- Python 3.8+ with the `requests` library (the only dependency)
+- A `TEMPEST_TOKEN` (free, personal use) for REST commands — created in the Tempest web app under Settings → Data Authorizations
+- **For UDP listening: a Tempest hub on the same LAN** — the hub broadcasts on UDP port 50222 to the local network only; broadcasts do not cross routers, and no token or cloud account is involved. REST commands work from anywhere with internet access.
diff --git a/tempest/SKILL.md b/tempest/SKILL.md
new file mode 100644
index 0000000..82ac057
--- /dev/null
+++ b/tempest/SKILL.md
@@ -0,0 +1,276 @@
+---
+name: tempest
+description: >-
+ Query hyper-local weather from a WeatherFlow Tempest station over its REST
+ API and the hub's local UDP broadcast: current conditions, forecast,
+ historical observations, and real-time decoded datagrams (obs_st,
+ rapid_wind, evt_precip, evt_strike, hub_status). Use when the user asks
+ about weather, temperature, rain, wind, humidity, or forecast data from
+ their own Tempest/WeatherFlow station, or wants to parse the hub's UDP port
+ 50222 broadcast. Do not use this skill for generic or city forecasts
+ without a Tempest station (public weather services serve those), for
+ Shakespeare's play The Tempest or other literature questions, or for
+ weather hardware from other vendors - the REST endpoints require a
+ personal-use token and the UDP broadcast only exists on a Tempest hub's
+ LAN.
+license: MIT
+compatibility: >-
+ Requires TEMPEST_TOKEN env var for REST (create it in the Tempest web app
+ under Settings -> Data Authorizations), Python 3.8+, and `requests`. UDP
+ listening needs a Tempest hub on the LAN and no token. `--help` and
+ `--dry-run` work without credentials.
+metadata:
+ tags: weather, tempest, weatherflow, forecast, station, udp, hyper-local
+ sources: https://apidocs.tempestwx.com/reference/quick-start, https://weatherflow.github.io/Tempest/api/udp/v171/
+---
+
+# tempest — Hyper-local weather from your Tempest station
+
+Drive a WeatherFlow Tempest station from the terminal. Two transports, both
+first-class: the documented REST API (`swd.weatherflow.com/swd/rest`,
+personal-use token) for conditions, forecast, and history — officially the
+primary data source — and the hub's unauthenticated UDP broadcast on port
+50222 for real-time, lowest-latency readings on your LAN. The bundled CLI
+decodes the positional observation arrays and every UDP message family, keeps
+`--json` output metric-native, and converts units only for human display.
+
+## Setup
+
+1. Create a personal access token: sign in to the Tempest web app
+ (tempestwx.com), then **Settings → Data Authorizations → Create Token**.
+ (This is the documented non-graphical auth method; OAuth exists for web
+ apps but is not what a CLI uses.)
+2. Export it:
+
+```bash
+export TEMPEST_TOKEN=""
+```
+
+The token travels to the API as a **query parameter** (`?token=...`) per the
+official docs — the CLI handles this. If the env var is not set, the CLI
+falls back to reading `TEMPEST_TOKEN=` from `~/.tempest.env` (handy for agent
+subprocesses that skip shell profiles). `--help` and `--dry-run` never need a
+token. UDP listening never needs one either — the hub broadcast is
+unauthenticated and LAN-only.
+
+## Essential Commands
+
+### stations — discover your stations and devices
+
+```bash
+tempest stations # names, station ids, device types, serials
+tempest stations --json | jq '.stations[] | {station_id, name,
+ devices: [.devices[] | {device_id, device_type, serial_number}]}'
+```
+
+Every station response nests a `devices` array: `device_type` is `ST` (the
+Tempest all-in-one), `AR`/`AIR`, `SK`/`SKY`, or `HB` (the hub — it has **no**
+observations; always filter it out before querying observations). Run this
+first when you don't know your ids.
+
+### current — latest conditions
+
+```bash
+tempest current # human-readable, converted
+tempest current --json # metric-native, jq-ready
+tempest current --station-id 12799 --device-id 60526 # pin exact hardware
+```
+
+With one station it auto-selects and picks the best sensor (`ST`, then
+`SKY`/`SK`, then `AIR`/`AR`, skipping `HB`). Output `.observation` carries the
+decoded positional array as named fields with `_unit` companions.
+
+### forecast — current conditions + daily + hourly
+
+```bash
+tempest forecast # current + 5-day daily + next 12 hours
+tempest forecast --days 7 --json
+tempest forecast --station-id 12799 --days 3
+```
+
+The `better_forecast` response nests daily/hourly under a `forecast` wrapper
+key, and it is unit-selectable (`units_temp=c|f` and friends, default metric)
+— the CLI reads the response's `units` before converting anything.
+
+### obs — historical observations
+
+```bash
+tempest obs --device-id 60526 --days 1 # last UTC day (day_offset)
+tempest obs --device-id 60526 --days 7
+tempest obs --device-id 60526 --json
+```
+
+`--days N` maps to the API's `day_offset` (whole UTC days). The underlying
+endpoint also accepts `time_start`/`time_end` epoch ranges (one-minute
+resolution guaranteed up to 5 days) — use raw calls for those; see
+references/rest-api-and-auth.md.
+
+## UDP broadcasts from your hub (port 50222, listen-only)
+
+```bash
+tempest udp listen # live stream until Ctrl-C
+tempest udp listen --timeout 30 # auto-stop after 30s
+tempest udp listen --timeout 60 --json # one JSON object per datagram
+tempest udp listen --show-all # include hub_status/device_status
+```
+
+Requires being on the same LAN as the hub (routed connectivity is not enough
+— broadcasts don't cross routers). No token involved. The listener decodes
+every message family, dispatching on `type` before touching array positions:
+
+| Family | Payload shape | Decoded fields |
+|---|---|---|
+| `obs_st` / `obs_air` / `obs_sky` | list of report rows under `obs` | named observation fields |
+| `rapid_wind` | ONE 3-element array under `ob` | wind_speed_mps, wind_direction |
+| `evt_precip` | ONE array under `evt` | timestamp (rain started) |
+| `evt_strike` | ONE array under `evt` | distance_km, energy |
+| `hub_status`, `device_status` | named fields, no array | uptime, rssi, seq, voltage, sensor_status |
+
+## Multi-step pipeline recipes
+
+### Discover, then observe
+
+```bash
+# Stage 1 -> stage 2: stations --json emits integer ids that current consumes
+tempest stations --json | jq -r '.stations[].devices[]
+ | select(.device_type == "ST") | .device_id' | head -1
+tempest current --device-id --json
+```
+
+### Rain watch: yesterday's total, then live rain events
+
+```bash
+tempest obs --device-id 60526 --days 1 --json \
+ | jq '{samples: (.observations | length),
+ day_rain_mm: .observations[-1].local_day_rain_accumulation}'
+tempest udp listen --timeout 600 --json | jq 'select(.type == "evt_precip")'
+```
+
+`obs --json` ends with decoded observations carrying
+`local_day_rain_accumulation` (mm, number); `evt_precip` datagrams decode to
+`{type, serial_number, timestamp}` — both stages emit typed fields the next
+stage can consume.
+
+### Unit-aware forecast slice
+
+```bash
+tempest forecast --days 7 --json \
+ | jq '{units_temp: .forecast.units.units_temp,
+ highs_f: [.forecast.forecast.daily[] | .air_temp_high * 9 / 5 + 32],
+ rain_hours: [.forecast.forecast.hourly[]
+ | select(.precip_probability > 30) | .local_hour]}'
+```
+
+The jq math here is safe **only because** it checks `units_temp` first — see
+gotcha 2.
+
+## JSON output and jq processing
+
+`--json` output is **metric-native** — the raw wire units (m/s wind, mm rain,
+°C temperature, MB pressure) with `_unit` companion fields naming each.
+Convert at the consumption edge:
+
+```bash
+tempest current --json | jq '{temp_c: .observation.air_temperature,
+ temp_f: (.observation.air_temperature * 9 / 5 + 32),
+ wind_mph: (.observation.wind_avg * 2.237),
+ rain_in: (.observation.rain_accumulation / 25.4)}'
+```
+
+Global flags work in any position: `tempest --json current --device-id 60526`
+and `tempest current --device-id 60526 --json` are identical. `--quiet`
+silences the progress logs (data on stdout, logs on stderr).
+`--dry-run` prints a plan object and exits 0 without touching the network.
+
+## Known Gotchas
+
+1. **Observations are positional arrays, not objects.** Raw `obs` rows have
+ no field names; meaning comes from the index (obs_st: 0 epoch, 2 wind avg
+ m/s, 4 wind direction, 6 pressure MB, 7 temperature °C, 12 rain mm, 16
+ battery V, 17 report interval). Reading index 6 as temperature gives you a
+ plausible-looking wrong number — decode with the CLI or the layout tables
+ in references/observation-layouts-and-units.md.
+2. **`/better_forecast` is unit-selectable, not Celsius-locked.** It defaults
+ to metric but honors `units_temp=f`, `units_wind=mph`, `units_pressure=inhg`,
+ `units_precip=in`. It reports what it used in `response.units`. Converting
+ an already-Fahrenheit response doubles it (25.4 °C → 77.7 °F → 172 "°F").
+ Always read `units` before converting; the CLI does this for you.
+3. **UDP message families differ structurally — dispatch on `type` first.**
+ obs families nest rows under `obs`; `rapid_wind` carries one array under
+ `ob`; `evt_precip`/`evt_strike` carry one array under `evt`;
+ `hub_status`/`device_status` carry named fields with no payload array.
+ Iterating `rapid_wind`'s `ob` element-wise is the classic TypeError; the
+ bundled `decode_message()` shows the correct dispatch.
+4. **UDP obs_st rows stop at index 17; REST rows run to 21.** The four
+ Nearcast/analysis fields (18–21) exist only in REST responses. Decoders
+ must tolerate both lengths — the CLI emits `None` for missing tails.
+5. **Pressure is MB (millibars), numerically hPa — not kPa.** It is also
+ *station* pressure (raw sensor). The Tempest app's "relative pressure"
+ adds an elevation adjustment; don't compare raw station pressure against
+ the app and conclude the sensor drifted.
+6. **Forecast timestamps are epoch integers, never ISO strings.**
+ `day_start_local`, `sunrise`, `sunset`, hourly `time` are epoch seconds;
+ hourly objects carry `local_hour` (0–23) and `local_day` (day of month).
+ There is **no** `local_time` or `time_string` field — code expecting one
+ silently falls back to its default branch.
+7. **The forecast nests under a `forecast` wrapper key.** `data["daily"]` is
+ always empty; read `data["forecast"]["daily"]` and
+ `data["forecast"]["hourly"]` (the CLI's `--json` preserves the full
+ response, wrapper and all).
+8. **Hubs (`HB`) have no observations.** They only relay. Auto-selection
+ skips them; if you call the API directly, filter `device_type == "HB"`
+ out before hitting `/observations/device/{id}` (documented 404 otherwise).
+9. **UDP is LAN-only and unauthenticated.** Broadcasts don't cross routers
+ and can't be token-gated — anyone on the network can read your station.
+ WeatherFlow officially positions REST/WebSocket as primary and UDP as the
+ off-grid/backup interface.
+10. **`obs_sky` UDP day-rain is always null.** Local-day rain accumulation
+ (index 11) is `null` in UDP SKY broadcasts; REST supplies the real value.
+ Don't build day-rain totals from UDP SKY rows.
+
+## When to use
+
+- The user owns or manages a WeatherFlow Tempest / Air / Sky station and asks
+ about its readings, forecast, or history.
+- Parsing or integrating with the hub's local UDP broadcast (port 50222).
+- Rain/wind/lightning monitoring scripts, dashboards, or home-automation
+ hooks fed from the station.
+
+## When not to use
+
+- **Generic city forecasts or users without a station** — every endpoint
+ requires the user's own Tempest station and a personal-use token; use a
+ public weather service instead.
+- **Shakespeare's play *The Tempest*, or any literary/meteorological-theory
+ question** — this is a station-data CLI, not an encyclopedia.
+- **Other vendors' hardware** (Netatmo, Ecowitt, Davis, Ambient) — different
+ APIs entirely; no endpoint here will accept their devices.
+- **Commercial/network-wide data products** — those need WeatherFlow's
+ TempestONE agreements, not a personal token (see the remote developer
+ policy).
+
+## Reference Files
+
+| File | Read when |
+|---|---|
+| [references/rest-api-and-auth.md](references/rest-api-and-auth.md) | Working with REST endpoints directly: token auth, StationSet shapes, observation parameters, forecast units, error signatures |
+| [references/udp-broadcast-protocol.md](references/udp-broadcast-protocol.md) | Parsing raw UDP datagrams: port 50222 transport, every message family's layout, the type-dispatch rule |
+| [references/observation-layouts-and-units.md](references/observation-layouts-and-units.md) | Decoding positional observation arrays by index (obs_st/obs_air/obs_sky, UDP vs REST lengths) and unit conversion tables |
+| [references/cli-worked-recipes.md](references/cli-worked-recipes.md) | Copy-paste multi-step CLI recipes with jq stages, dry-run plans, and expected error paths |
+
+## Available Scripts
+
+- [scripts/tempest](scripts/tempest) — the CLI: `stations`, `current`, `obs`,
+ `forecast`, `udp listen`; global `--json`, `--dry-run`, `--quiet`,
+ `--verbose` accepted in any position; offline dry-run plans for every
+ command.
+- [scripts/test_tempest.py](scripts/test_tempest.py) — offline suite: canned
+ UDP datagram bytes fed to the decoder (no sockets), mocked REST transport,
+ both pytest and unittest runners.
+
+## Prerequisites
+
+- Python 3.8+ with `requests` (the only dependency).
+- `TEMPEST_TOKEN` for REST commands (free, personal use; created in the
+ Tempest web app). UDP listening needs no token, only line-of-sight to the
+ hub's LAN.
diff --git a/tempest/evals/evals.json b/tempest/evals/evals.json
new file mode 100644
index 0000000..6f2e950
--- /dev/null
+++ b/tempest/evals/evals.json
@@ -0,0 +1,92 @@
+{
+ "schema_version": 1,
+ "skill_name": "tempest",
+ "evals": [
+ {
+ "id": "current-conditions-from-station",
+ "prompt": "What's the temperature, wind, and rain at my Tempest station right now? Give it to me as JSON I can pipe to jq.",
+ "expected_output": "Export TEMPEST_TOKEN (create it in the Tempest web app: Settings -> Data Authorizations -> Create Token), then run tempest current --json. Auto-selection picks your first station and its ST device (skipping HB hubs). The .observation object carries metric-native named fields: air_temperature (C), wind_avg (m/s), rain_accumulation (mm), station_pressure (MB), relative_humidity (%). Use --station-id/--device-id only when you own several stations.",
+ "assertions": [
+ "exports TEMPEST_TOKEN and runs tempest current --json",
+ "reads metric-native fields air_temperature, wind_avg, and rain_accumulation from .observation",
+ "does not invent a local_time or hourly.local_time field anywhere",
+ "does not present imperial units as the wire values without converting"
+ ]
+ },
+ {
+ "id": "stations-to-current-pipeline",
+ "prompt": "I have two Tempest stations. Figure out their IDs and then pull the latest reading from the backyard one, chained so I can re-run it.",
+ "expected_output": "Discover first: tempest stations --json emits {\"stations\": [...]} with integer station_id and a devices[] array where each device has device_id, device_type (HB hub, ST Tempest, AR Air, SK Sky), and serial_number. Filter device_type == \"ST\" (never HB - hubs carry no observations) and feed those integers to tempest current --station-id --device-id --json. The two stages compose because stations --json device_id/station_id are the same integer types current's flags accept.",
+ "assertions": [
+ "runs tempest stations --json first and extracts station_id and device_id as integers",
+ "filters out device_type HB hubs before choosing the observation device",
+ "passes the extracted ids to tempest current --station-id/--device-id",
+ "does not call an undocumented /user/devices endpoint"
+ ]
+ },
+ {
+ "id": "forecast-units-double-conversion-gotcha",
+ "prompt": "Why does my script show 172 degrees for my Tempest forecast after I switched the station display to Fahrenheit? The high today is definitely not 172.",
+ "expected_output": "Double conversion. The /better_forecast endpoint is unit-selectable, not Celsius-locked: it honors units_temp=f (default c) and reports what it used in response.units. Your script converted an already-Fahrenheit response with C->F math (77.7 * 9/5 + 32 = 172). Fix: read .forecast.units.units_temp before converting anything, or request explicit units. With the CLI: tempest forecast --json already handles this - its human output converts only Celsius stations, and raw --json values stay in the units the response declared.",
+ "assertions": [
+ "explains the 172 value as a double conversion of an already-Fahrenheit response",
+ "states the endpoint honors units_temp=f and reports units in the response",
+ "instructs reading .forecast.units.units_temp before converting",
+ "does not claim the forecast endpoint is always Celsius regardless of parameters"
+ ]
+ },
+ {
+ "id": "udp-message-family-dispatch",
+ "prompt": "I'm parsing my Tempest hub's UDP broadcast on port 50222 in Python. I keep getting TypeError when a rapid wind message shows up, and my parser never sees rain-start events. What's wrong?",
+ "expected_output": "Message families are structurally different - dispatch on the top-level \"type\" before indexing. obs_st/obs_air/obs_sky nest report rows under \"obs\" (msg[\"obs\"][0][7] is temperature); rapid_wind carries ONE 3-element array under \"ob\" ([epoch, m/s, degrees]) - iterating it element-wise like an obs row list is exactly the TypeError you hit; evt_precip and evt_strike carry ONE array under \"evt\" ([epoch] and [epoch, km, energy]); hub_status and device_status have named fields (uptime, rssi, seq, reset_flags, sensor_status) and no payload array at all. Each UDP datagram is one complete JSON object; bind 0.0.0.0:50222 and listen only - the hub never expects a reply.",
+ "assertions": [
+ "dispatches on the type field before any positional indexing",
+ "reads rapid_wind speed from the single ob array as ob[1], not by iterating it",
+ "distinguishes obs families (list under obs) from evt families (single array under evt) and status families (named fields)",
+ "binds the listener to UDP port 50222 and treats it as listen-only broadcast"
+ ]
+ },
+ {
+ "id": "obs-st-positional-array-decode",
+ "prompt": "Decode this raw obs_st payload from my Tempest: [1588948614, 0.18, 0.22, 0.27, 144, 6, 1017.57, 22.37, 50.26, 328, 0.03, 3, 0.0, 0, 0, 0, 2.410, 1]. What's the temperature and wind?",
+ "expected_output": "obs_st is a positional array - meaning comes from the index. Index 0 epoch 1588948614 (2020-05-07 UTC); index 1-3 wind lull/avg/gust 0.18/0.22/0.27 m/s; index 4 wind direction 144 degrees (SE); index 6 station pressure 1017.57 MB (millibars, same as hPa); index 7 air temperature 22.37 C (72.3 F); index 8 humidity 50.26%; index 12 rain 0.0 mm this minute; index 16 battery 2.410 V (healthy, about 2.4 nominal); index 17 report interval 1 minute. The UDP broadcast record ends at index 17; REST adds Nearcast rain fields 18-21 for 22 positions - tolerate both lengths.",
+ "assertions": [
+ "maps index 7 to air temperature 22.37 C and index 6 to pressure in MB/hPa",
+ "maps indices 1-3 to wind lull/average/gust in m/s and index 4 to direction",
+ "notes the UDP record stops at index 17 while the REST record has 22 positions",
+ "does not misread index 6 pressure as temperature or vice versa"
+ ]
+ },
+ {
+ "id": "metric-native-units-and-conversions",
+ "prompt": "Are the values from my Tempest station in Fahrenheit and mph? I want mph wind and inches of rain in my dashboard.",
+ "expected_output": "No - the wire is metric-native everywhere: wind m/s, rain mm, temperature C, pressure MB (millibars, numerically hPa - not kPa), lightning distance km. Conversion is the caller's job: mph = m/s * 2.237, inches = mm / 25.4, F = C * 9/5 + 32, inHg = MB * 0.02953. The CLI converts only for human display; --json stays metric-native so jq can convert: tempest current --json | jq '{wind_mph: (.observation.wind_avg * 2.237), rain_in: (.observation.rain_accumulation / 25.4)}'.",
+ "assertions": [
+ "states observations are metric-native (m/s, mm, C, MB) with conversion as the caller's job",
+ "provides the m/s-to-mph and mm-to-inches conversion formulas or a jq snippet",
+ "does not claim UDP or raw JSON values arrive in imperial units",
+ "uses MB or hPa for pressure, not kPa"
+ ]
+ },
+ {
+ "id": "not-shakespeare-the-tempest",
+ "prompt": "Analyze the opening storm scene of Shakespeare's play The Tempest and explain how Prospero raises the tempest.",
+ "expected_output": "This must not trigger the tempest skill: it is a literature question about Shakespeare's play, not a request for WeatherFlow weather-station data. The tempest skill operates a personal weather station (REST token auth, UDP port 50222 broadcasts) and has nothing to say about the play. Route this to literary analysis instead.",
+ "assertions": [
+ "must not trigger the tempest skill for the Shakespeare play",
+ "recognizes the question as literary analysis of The Tempest",
+ "does not invoke station APIs, tokens, or UDP ports for this prompt"
+ ]
+ },
+ {
+ "id": "not-generic-weather-forecast",
+ "prompt": "What's the weather forecast for Paris tomorrow? I don't own any weather station.",
+ "expected_output": "This must not trigger the tempest skill: every endpoint it drives requires the user's own WeatherFlow Tempest station and a personal-use token, and UDP listening requires a hub on the LAN. A user with no station asking for a generic city forecast needs a public weather service or forecast skill, not this station tool. Only load tempest when the user owns or manages a Tempest/WeatherFlow station.",
+ "assertions": [
+ "must not trigger the tempest skill for a generic city forecast",
+ "notes the skill requires the user's own Tempest station and token",
+ "routes the request to a public forecast service instead"
+ ]
+ }
+ ]
+}
diff --git a/tempest/references/cli-worked-recipes.md b/tempest/references/cli-worked-recipes.md
new file mode 100644
index 0000000..77908d2
--- /dev/null
+++ b/tempest/references/cli-worked-recipes.md
@@ -0,0 +1,168 @@
+# CLI Worked Recipes (tempest)
+
+Multi-step, executable recipes for the bundled `tempest` CLI. Global flags
+`--json`, `--dry-run`, `--quiet`, `--verbose` work in any position on the
+command line. `--json` output is metric-native (raw wire units); human output
+is converted. `--dry-run` never touches the network and always exits 0 with a
+plan object.
+
+## Recipe 1: Discover the station, then read current conditions
+
+```bash
+# Step 1: find station and device ids (works even before you memorize ids)
+tempest stations --json | jq '.stations[] | {station_id, name,
+ devices: [.devices[] | {device_id, device_type, serial_number}]}'
+
+# Step 2: current conditions, machine-readable
+tempest current --json | jq '{station, device_id, type,
+ temp_c: .observation.air_temperature,
+ wind_mps: .observation.wind_avg,
+ rain_mm: .observation.rain_accumulation}'
+
+# Step 3 (pin a specific station/device when several exist)
+tempest current --station-id 12799 --device-id 60526 --json
+```
+
+Stage compatibility: `stations --json` emits `{"stations": [...]}` with
+integer `station_id`/`device_id` fields — feed those ints to
+`--station-id`/`--device-id` on `current`. `current --json` emits
+`{station, device_id, type, observation}` where `observation` carries the
+decoded positional array as named fields (metric-native types: numbers for
+measurements, `timestamp` as ISO-8601 string).
+
+Auto-selection rules when you don't pass ids: the first station is used; the
+device is the first `ST` (Tempest), then `SKY`/`SK`, then `AIR`/`AR`, always
+skipping `HB` hubs (hubs carry no observations). If only a hub exists the CLI
+dies with a clear error instead of guessing.
+
+## Recipe 2: 7-day forecast slice for scripts
+
+```bash
+tempest forecast --days 7 --json \
+ | jq '{units_temp: .forecast.units.units_temp,
+ today: (.forecast.forecast.daily[0]
+ | {day_start_local, air_temp_high, air_temp_low, precip_probability}),
+ next12: [.forecast.forecast.hourly[:12][]
+ | {local_hour, air_temperature, precip_probability}]}'
+```
+
+Converting highs to °F with jq (read `units` from the same document before
+converting anything):
+
+```bash
+tempest forecast --json \
+ | jq '{units_temp: .forecast.units.units_temp,
+ highs_f: [.forecast.forecast.daily[] | .air_temp_high * 9 / 5 + 32],
+ rain_hours: [.forecast.forecast.hourly[] | select(.precip_probability > 30) | .local_hour]}'
+```
+
+**Convert only after reading `units`:** the endpoint honors unit overrides
+(`units_temp=f` etc.), so hard-coded Celsius math double-converts Fahrenheit
+responses. When the CLI displays forecast values it converts °C→°F only for
+stations whose `units_temp` is `c`. Human output prints current conditions,
+then the daily table, then the next 12 hours.
+
+## Recipe 3: Rain-watch (yesterday's total + live rain events)
+
+```bash
+# What fell yesterday (UTC day): obs from history, day_offset=1
+DEVICE_ID=$(tempest stations --json | jq -r '
+ .stations[].devices[] | select(.device_type == "ST") | .device_id' | head -1)
+tempest obs --device-id "$DEVICE_ID" --days 1 --json \
+ | jq '{type, samples: (.observations | length),
+ day_rain_mm: .observations[-1].local_day_rain_accumulation}'
+
+# Live: rain-start events and rapid wind from the hub broadcast
+tempest udp listen --timeout 600 --json | jq 'select(.type == "evt_precip")'
+```
+
+Stage compatibility: `obs --json` emits `{device_id, type, count,
+observations}` with each decoded observation carrying
+`local_day_rain_accumulation` (mm, number) — the `-1` index grabs the newest
+sample of the day. `udp listen --json` emits one JSON object per datagram;
+`evt_precip` objects carry `{type, serial_number, timestamp}`.
+
+## Recipe 4: Decode any raw UDP datagram positionally
+
+Feed canned datagram bytes to the same decoder the listener uses — no
+sockets, no hub required (this is exactly how `scripts/test_tempest.py`
+exercises the parser):
+
+```python
+# /tmp/decode_one.py
+import importlib.machinery, importlib.util, json
+loader = importlib.machinery.SourceFileLoader("t", "tempest/scripts/tempest")
+spec = importlib.util.spec_from_loader(loader.name, loader)
+mod = importlib.util.module_from_spec(spec)
+loader.exec_module(mod)
+
+datagram = (b'{"serial_number":"ST-00000512","type":"obs_st","hub_sn":"HB-00013030",'
+ b'"obs":[[1588948614,0.18,0.22,0.27,144,6,1017.57,22.37,50.26,328,0.03,3,'
+ b'0.0,0,0,0,2.410,1]],"firmware_revision":129}')
+msg = json.loads(datagram.decode())
+for row in msg["obs"]: # obs families: list of rows
+ decoded = mod.decode_obs(row, msg["type"])
+ print(decoded["air_temperature"], "°C", decoded["air_temperature_unit"])
+
+rapid = json.loads(b'{"type":"rapid_wind","ob":[1493322445,2.3,128],"serial_number":"SK-1"}'.decode())
+speed, direction = rapid["ob"][1], rapid["ob"][2] # rapid_wind: ONE array under "ob"
+```
+
+The three structural keys to remember (see udp-broadcast-protocol.md):
+observation families nest rows under `obs`; `rapid_wind` carries one array
+under `ob`; events (`evt_precip`, `evt_strike`) carry one array under `evt`;
+`hub_status`/`device_status` have named fields and no array at all. Dispatch
+on `type` before indexing.
+
+## Recipe 5: Dry-run previews and flag behavior
+
+```bash
+# Plan, don't execute: valid JSON, exit 0, zero network
+tempest forecast --station-id 12799 --days 3 --dry-run --json
+# -> {"dry_run": true, "command": "forecast", "station_id": 12799, "days": 3}
+
+# Every documented command has a dry-run plan — current, obs, forecast,
+# stations, and udp listen (plans the bind, creates no socket, safe off-LAN)
+tempest obs --device-id 60526 --days 2 --dry-run --json
+tempest udp listen --port 50222 --timeout 30 --dry-run --json
+# -> {"dry_run": true, "command": "udp", "subcommand": "listen",
+# "bind_address": "0.0.0.0", "port": 50222, "timeout_seconds": 30,
+# "show_all": false}
+
+# Quiet/verbose piping: logs on stderr, data on stdout
+tempest current --json --quiet | jq .observation.air_temperature
+```
+
+Behavior contract: `--dry-run` works without `TEMPEST_TOKEN` set (no credential
+needed to see a plan); `--help` and `--dry-run` are always offline. For
+`udp listen`, dry-run describes the listen parameters (bind address, port,
+timeout, show-all) and exits 0 without creating or binding any socket — the
+real listener waits for hub traffic on UDP 50222 and needs the hub's LAN.
+Without `--dry-run`, a missing token exits 1 with
+`Error: TEMPEST_TOKEN not set...` before any request is attempted.
+
+## Recipe 6: JSON error paths you'll actually see
+
+```bash
+tempest current --station-id 99999999
+# Error: Station 99999999 not found. (exit 1)
+
+tempest obs --device-id 123 # hub or wrong device
+# Error: API error (404): ... (exit 1)
+
+unset TEMPEST_TOKEN; tempest stations
+# Error: TEMPEST_TOKEN not set. Get one at https://weatherflow.com (exit 1)
+```
+
+The client maps 401 → token message, 403 → access-denied message, 404 →
+not-found-with-path, and any other ≥400 dumps the response body. In `--json`
+mode errors still go to stderr as text; only success payloads print to stdout,
+so `jq` pipelines fail loudly instead of parsing prose.
+
+## Sources
+
+- https://apidocs.tempestwx.com/reference/quick-start (token setup, REST examples, primary-source guidance)
+- https://apidocs.tempestwx.com/reference/get_stations (StationSet shape feeding the stations command)
+- https://apidocs.tempestwx.com/reference/getobservationsbydeviceid (device observation parameters used by current/obs)
+- https://apidocs.tempestwx.com/reference/get_better-forecast-1 (forecast unit selection used by recipe 2)
+- https://weatherflow.github.io/Tempest/api/udp/v171/ (UDP message families used by recipes 3–4)
diff --git a/tempest/references/observation-layouts-and-units.md b/tempest/references/observation-layouts-and-units.md
new file mode 100644
index 0000000..be40fb9
--- /dev/null
+++ b/tempest/references/observation-layouts-and-units.md
@@ -0,0 +1,175 @@
+# Observation Layouts and Units (obs_st, obs_air, obs_sky)
+
+Observations arrive as **positional arrays**: a list of values whose meaning
+depends on the array index. The `type` field on the containing object selects
+the layout (`obs_st` = Tempest all-in-one, `obs_air` = Air, `obs_sky` = Sky).
+There are no field names on the wire — any decoder is a table like the ones
+below, and reading the wrong index silently yields a wrong value (e.g.
+treating index 6 pressure as index 7 temperature).
+
+Two different record lengths exist for obs_st: REST returns **22 positions**
+and the UDP broadcast returns **18** (the four Nearcast/analysis fields are
+REST-only). obs_air is 8 positions in both transports; obs_sky is 17 over
+REST and 14 over UDP.
+
+## obs_st — Tempest all-in-one (REST record, 22 positions)
+
+| Index | Field | Units | Notes |
+|---:|---|---|---|
+| 0 | timestamp | epoch seconds, UTC | |
+| 1 | wind lull | m/s | minimum 3-second sample |
+| 2 | wind average | m/s | average over report interval |
+| 3 | wind gust | m/s | maximum 3-second sample |
+| 4 | wind direction | degrees | 0 = N |
+| 5 | wind sample interval | seconds | |
+| 6 | station pressure | MB (millibars) | ≡ hPa; raw sensor pressure, not sea-level |
+| 7 | air temperature | °C | |
+| 8 | relative humidity | % | |
+| 9 | illuminance | lux | |
+| 10 | UV | index | |
+| 11 | solar radiation | W/m² | |
+| 12 | rain accumulation | mm | during the reporting interval |
+| 13 | precipitation type | enum | 0 none, 1 rain, 2 hail, 3 rain + hail (experimental) |
+| 14 | lightning strike average distance | km | |
+| 15 | lightning strike count | count | during the reporting interval |
+| 16 | battery | volts | ≈2.4 nominal; below ≈2.3 plan service |
+| 17 | report interval | minutes | |
+| 18 | local day rain accumulation | mm | midnight-to-midnight, station timezone |
+| 19 | Nearcast rain accumulation | mm | REST only |
+| 20 | local day Nearcast rain accumulation | mm | REST only |
+| 21 | precipitation analysis type | enum | 0 none, 1 Nearcast display on, 2 off — REST only |
+
+UDP `obs_st` datagrams end at index 17 (see udp-broadcast-protocol.md).
+
+## obs_air — Air sensor (8 positions, both transports)
+
+| Index | Field | Units | Notes |
+|---:|---|---|---|
+| 0 | timestamp | epoch seconds, UTC | |
+| 1 | station pressure | MB (millibars) | ≡ hPa |
+| 2 | air temperature | °C | |
+| 3 | relative humidity | % | |
+| 4 | lightning strike count | count | during the reporting interval |
+| 5 | lightning strike average distance | km | |
+| 6 | battery | volts | |
+| 7 | report interval | minutes | |
+
+## obs_sky — Sky sensor (REST record, 17 positions)
+
+| Index | Field | Units | Notes |
+|---:|---|---|---|
+| 0 | timestamp | epoch seconds, UTC | |
+| 1 | illuminance | lux | |
+| 2 | UV | index | |
+| 3 | rain accumulation | mm | during the reporting interval |
+| 4 | wind lull | m/s | |
+| 5 | wind average | m/s | |
+| 6 | wind gust | m/s | |
+| 7 | wind direction | degrees | |
+| 8 | battery | volts | |
+| 9 | report interval | minutes | |
+| 10 | solar radiation | W/m² | |
+| 11 | local day rain accumulation | mm | **always null over UDP** — REST supplies it |
+| 12 | precipitation type | enum | 0 none, 1 rain, 2 hail, 3 rain + hail |
+| 13 | wind sample interval | seconds | |
+| 14 | Nearcast rain accumulation | mm | REST only |
+| 15 | local day Nearcast rain accumulation | mm | REST only |
+| 16 | precipitation analysis type | enum | 0 none, 1 Nearcast display on, 2 off — REST only |
+
+UDP `obs_sky` datagrams end at index 13 and always carry `null` at index 11.
+
+## Daily summary records (obs_*_ext)
+
+The API also emits midnight-to-midnight daily summaries with their own
+discriminators: `obs_st_ext` (34 positions — avg/high/low pressure,
+temperature, humidity, illuminance, UV, solar, wind stats, strikes, battery,
+day rain, precipitation minutes), `obs_air_ext` (14), and `obs_sky_ext` (22).
+They appear in stats/history contexts, not in the minute firehose. Decode
+them only from their own `type` — never with the minute-record tables.
+
+## The units story: metric-native, caller converts
+
+Every raw value is metric: wind **m/s**, rain **mm**, temperature **°C**,
+pressure **MB** (millibars — numerically identical to hPa, *not* kPa),
+distance **km**, illuminance **lux**, solar radiation **W/m²**, battery
+**volts**. Nothing on the wire is imperial; conversions are the consumer's
+job:
+
+| Wire unit | Imperial | Formula |
+|---|---|---|
+| °C | °F | `c * 9/5 + 32` |
+| m/s | mph | `mps * 2.23694` (≈ ×2.237) |
+| m/s | km/h | `mps * 3.6` |
+| m/s | knots | `mps * 1.94384` |
+| MB (hPa) | inHg | `mb * 0.02953` |
+| mm | inches | `mm / 25.4` |
+| km | miles | `km / 1.60934` |
+
+Two traps:
+
+1. **`/better_forecast` is unit-selectable, not Celsius-locked.** It defaults
+ to metric (`units_temp=c`), honors overrides (`units_temp=f`,
+ `units_wind=mph`, `units_pressure=inhg`, `units_precip=in`,
+ `units_distance=mi`), and reports what it used in `response.units`. Read
+ `units` before converting anything, or a Fahrenheit response gets
+ double-converted into absurd values.
+2. **Station vs sea-level pressure.** Index 6 / index 1 pressure is the raw
+ station pressure. The Tempest app's "relative pressure" adds an elevation
+ adjustment — don't compare your raw value against the app and conclude the
+ sensor is broken.
+
+The bundled CLI keeps `--json` output in metric-native wire units (raw,
+lossless — convert with your own jq) and converts only in human display.
+Decode positionally with jq like:
+
+```bash
+tempest current --json \
+ | jq '{temp_c: .observation.air_temperature,
+ temp_f: (.observation.air_temperature * 9 / 5 + 32),
+ wind_mps: .observation.wind_avg,
+ wind_mph: (.observation.wind_avg * 2.237),
+ pressure_mb: .observation.station_pressure}'
+```
+
+## Field type traps in /better_forecast
+
+The forecast endpoint uses epoch integers where you'd expect date strings,
+and field names that differ from what common sense suggests:
+
+| Field | Actual type | Common mistake | Fix |
+|---|---|---|---|
+| `daily[].day_start_local` | epoch int (e.g. 1778385600) | assumed ISO string | `datetime.fromtimestamp(ts).strftime(...)` |
+| `hourly[].local_hour` | int (0–23) | assumed timestamp string | format directly `{h:02d}:00` |
+| `hourly[].local_day` | int (day of month) | N/A | use alongside `local_hour` |
+| `hourly[].local_time` | **does not exist** | commonly assumed field | use `local_hour` instead |
+
+Code looking for `local_time` silently falls back to its default/"?" branch —
+no error is raised.
+
+## Decoding recipe (jq, no script needed)
+
+Latest REST observation, positionally decoded to named fields:
+
+```bash
+curl -s "https://swd.weatherflow.com/swd/rest/observations/device/$DEVICE_ID?token=$TEMPEST_TOKEN" \
+ | jq --argjson layout '["timestamp","wind_lull","wind_avg","wind_gust","wind_direction",
+ "wind_sample_interval","station_pressure","air_temperature","relative_humidity",
+ "illuminance","uv","solar_radiation","rain_accumulation","precipitation_type",
+ "avg_strike_distance","strike_count","battery","report_interval",
+ "local_day_rain","nc_rain","local_day_nc_rain","precip_analysis_type"]' '
+ {type: .type,
+ obs: (.obs[-1] | [$layout, .] | transpose | map({(.[0]): .[1]}) | add)}'
+```
+
+The bundled CLI does the same in Python (`decode_obs` in `scripts/tempest`,
+driven by the `OBS_ST_FIELDS`/`OBS_AIR_FIELDS`/`OBS_SKY_FIELDS` tables) and
+tolerates both UDP-length and REST-length rows.
+
+## Sources
+
+- https://apidocs.tempestwx.com/reference/observation-record-format (canonical index tables: obs_st 22, obs_air 8, obs_sky 17, daily _ext records, evt_strike, rapid_wind)
+- https://weatherflow.github.io/Tempest/api/swagger/ (legacy response models; better_forecast field types; obs_sky UDP day-rain null note)
+- https://weatherflow.github.io/Tempest/api/udp/v171/ (UDP obs_st 18-position record; metric-native units)
+- https://apidocs.tempestwx.com/reference/get_better-forecast-1 (unit selection parameters and response `units` object)
+- https://apidocs.tempestwx.com/reference/getobservationsbydeviceid (observation set envelope: `obs` array + `type` discriminator)
+- https://help.weatherflow.com/hc/en-us/articles/360052101413-Tempest-FAQs (station vs sea-level pressure; battery guidance)
diff --git a/tempest/references/rest-api-and-auth.md b/tempest/references/rest-api-and-auth.md
new file mode 100644
index 0000000..3319a91
--- /dev/null
+++ b/tempest/references/rest-api-and-auth.md
@@ -0,0 +1,251 @@
+# Tempest REST API and Authentication
+
+The Tempest REST API is the cloud service at `https://swd.weatherflow.com/swd/rest`.
+It is the primary, recommended data source even for programs running on the same
+LAN as the hub; the local UDP broadcast (see udp-broadcast-protocol.md) is
+officially positioned as an off-grid backup. Base URL used throughout:
+
+```
+https://swd.weatherflow.com/swd/rest
+```
+
+## Authentication: the personal access token
+
+There are exactly two documented authentication methods, and the bundled CLI
+uses the first:
+
+1. **Personal Access Token** — the right choice for scripts and integrations
+ without a graphical interface. Sign in to the Tempest Web App
+ (tempestwx.com), then go to **Settings → Data Authorizations → Create
+ Token**, and copy the generated token. This is what `TEMPEST_TOKEN`
+ carries.
+2. **OAuth 2.0** (Authorization Code, optionally with PKCE) — the documented
+ choice for production apps with a web UI. Apps are registered from the
+ account's Developers page; authorization and token endpoints are documented
+ separately in the OAuth reference. The CLI does not implement OAuth.
+
+On the wire, the token travels as a **query parameter**:
+
+```
+GET https://swd.weatherflow.com/swd/rest/stations?token=
+```
+
+The official quick-start examples use `token=[your_access_token]` and show no
+`Authorization` header alternative for this API. Do not send the token as a
+header or assume bearer syntax is supported. The OpenAPI document describes the
+scheme as `apiKey` with `in: query`, which matches.
+
+Policy notes (remote-developer-policy): personal-use access covers station
+metadata, observations, and forecasts with "rate/volume limits (enough for
+personal use)". No numeric quota is published, and no 429 response behavior is
+documented. Higher-volume or network-wide access requires a commercial
+agreement (TempestONE). Keep personal integrations to your own stations.
+
+## Endpoint catalog (personal-use surface)
+
+### GET /stations — your stations with devices
+
+Parameters: `limit` (int64, default 10000), `next_cursor` (string; present
+when more than 10,000 stations are provisioned), optional geographic filters
+(`lat_min`/`lon_min`/`lat_max`/`lon_max` bounding box, or
+`center_lat`/`center_lon`/`radius` in meters).
+
+Response is a **StationSet wrapper**, not a bare list:
+
+```json
+{
+ "status": { "status_code": 0, "status_message": "SUCCESS" },
+ "stations": [
+ {
+ "station_id": 12799,
+ "location_id": 12799,
+ "name": "Home",
+ "public_name": "Home",
+ "latitude": 42.37,
+ "longitude": -71.06,
+ "timezone": "America/New_York",
+ "timezone_offset_minutes": -300,
+ "station_meta": { "elevation": 1567.65, "share_with_wf": true, "share_with_wu": true },
+ "is_local_mode": false,
+ "devices": [
+ {
+ "device_id": 60526,
+ "serial_number": "ST-00012345",
+ "device_type": "ST",
+ "hardware_revision": "3",
+ "firmware_revision": "165",
+ "device_meta": { "agl": 2.2, "name": "Backyard", "environment": "outdoor" },
+ "device_settings": { "show_precip_final": false },
+ "notes": ""
+ }
+ ],
+ "station_items": [ { "item": "air_temperature_humidity", "device_id": 60526, "sort": 0 } ]
+ }
+ ]
+}
+```
+
+`device_type` values: `HB` (hub — has **no** observation data),
+`ST` (Tempest all-in-one), `AR` (Air sensor), `SK` (Sky sensor). The OpenAPI
+enum lists exactly these four. Note that `AR`/`SK` are metadata codes for the
+Air/Sky hardware; the observation `type` discriminator for the same hardware is
+`obs_air`/`obs_sky`. Always filter `HB` out before auto-selecting a device for
+observation calls — the hub has no `/observations/device/{id}` data. A null or
+missing `serial_number` on a device means inactive hardware per the legacy docs.
+
+### GET /stations/{station_id} — one station
+
+Same Station model; documented responses are 200 and 404 ("Station not found").
+Per the legacy Swagger the body still arrives in the `{stations: [...]}`-style
+wrapper shape with the selected station inside, so unwrap defensively rather
+than assuming a bare station object.
+
+### GET /observations/device/{device_id} — device observations
+
+Query parameters (mutually exclusive modes):
+
+| Parameter | Meaning |
+|---|---|
+| `day_offset` | Whole UTC day: `0` = current UTC day, `1` = yesterday UTC |
+| `time_start` + `time_end` | UTC epoch-seconds range; one-minute resolution guaranteed for ranges ≤ 5 days |
+| `latest=true` | Latest single observation (the CLI's `current` default) |
+| `format=csv` | CSV instead of JSON |
+
+Response is an observation set: `obs` (array of positional arrays, oldest to
+newest), `type` (`obs_st` | `obs_air` | `obs_sky` — the layout discriminator),
+plus device identity/status fields. Field layouts are in
+observation-layouts.md. Documented errors: 404 "Device not found". Passing a
+hub `HB` device id yields no observation data.
+
+### GET /observations/stn/{station_id} — station observations
+
+Note the segment is **`stn`**, not `stations`. Optional parameters:
+`time_start`/`time_end`, `bucket` (`1` | `5` | `30` | `180` minutes; mapped to
+1 day / 5 days / 30 days / 180 days of history, and the docs mention `1440` ≈ 4
+years), `ob_fields` selection, and the standard unit parameters. Station
+observations are **federated from the station's designated primary sensors**;
+device observations are one physical device's raw data. Use station
+observations when you want "the station's" reading, device observations when
+you care about a specific unit.
+
+### GET /better_forecast — conditions + daily + hourly
+
+Parameters: `station_id` (or `lat`/`lon` with optional
+`snap_to_nearest_owned_station=true` for within-5 km snapping), plus unit
+overrides: `units_temp` (`c`|`f`), `units_wind` (`mph`|`kph`|`kts`|`mps`|`bft`|
+`lfm`), `units_pressure` (`mb`|`inhg`|`mmhg`|`hpa`), `units_precip`
+(`mm`|`cm`|`in`), `units_distance` (`km`|`mi`).
+
+Response top level:
+
+```json
+{
+ "status": { "status_code": 0, "status_message": "SUCCESS" },
+ "current_conditions": { "air_temperature": 18.2, "conditions": "Mostly Clear", "icon": "partly-cloudy-day", "relative_humidity": 61, "station_pressure": 1015.4, "wind_avg": 2.1, "wind_direction": 225, "feels_like": 18.2 },
+ "forecast": {
+ "daily": [ { "day_start_local": 1778385600, "air_temp_high": 25.4, "air_temp_low": 15.1, "conditions": "Partly cloudy", "precip_probability": 10, "precip_type": "rain", "sunrise": 1778378400, "sunset": 1778425200 } ],
+ "hourly": [ { "time": 1778388000, "local_hour": 10, "local_day": 10, "air_temperature": 19.8, "precip_probability": 5, "conditions": "Sunny" } ]
+ },
+ "units": { "units_temp": "c", "units_wind": "mps", "units_precip": "mm", "units_pressure": "mb", "units_distance": "km" },
+ "latitude": 42.37, "longitude": -71.06,
+ "timezone": "America/New_York", "timezone_offset_minutes": -300
+}
+```
+
+The critical structural fact: **daily and hourly live under the `forecast`
+wrapper key**, not at top level. Reading `data["daily"]` returns nothing.
+
+Unit behavior: the response honors the requested units and reports what it used
+in `units`. Default is Celsius/m/s/mm/mb, but the endpoint is **unit-selectable
+— not Celsius-locked**. `units_temp=f` is documented and honored. Any consumer
+that hard-codes Celsius conversion must first read `units.units_temp`, or it
+will double-convert Fahrenheit responses (see units-and-conversions.md).
+
+Timestamps: `day_start_local`, `sunrise`, `sunset`, and hourly `time` are
+integer epoch seconds. Hourly objects carry `local_hour` (int 0–23) and
+`local_day` (int day-of-month); there is **no** `local_time` or `time_string`
+field — code expecting one silently falls back to its default branch.
+
+### Other documented endpoints
+
+- `GET /diagnostics/{station_id}` — latest station status; 200/401/404.
+- `GET /stats/station/{station_id}` — daily/weekly/monthly/annual/all-time
+ high-low-average statistics; 200/401.
+- `GET /metadata/network/stations` and `GET /observations/network/stations` —
+ network-wide access governed by the remote data policy (not part of the
+ personal single-station flow).
+- Lightning endpoints exist but documented access is for paid subscribers.
+- The current docs index does not document `/user/devices` for the consumer
+ surface — use `/stations` and its nested `devices` array. There is no
+ `/better_forecast/hourly` route; hourly data is `forecast.hourly` inside the
+ standard `/better_forecast` response.
+
+## Error signatures
+
+| Status | Documented meaning | Practical symptom |
+|---|---|---|
+| 401 | Unauthorized (documented on forecast/diagnostics/stats) | Missing, revoked, or mistyped token — regenerate at tempestwx.com Settings → Data Authorizations |
+| 403 | Not documented for this API | Treat as access-denied to that station/device; verify the token belongs to the station owner |
+| 404 | "Station not found" / "Device not found" (documented) | Wrong station/device id, or an `HB` hub id passed to an observation endpoint |
+
+No JSON error-body schema is published, so parse defensively. No numeric rate
+limit or 429 behavior is documented; the policy only promises personal-use
+volume is acceptable. The CLI maps 401/403/404 to targeted messages and dumps
+the response body for anything else.
+
+## Worked recipes
+
+### Recipe A: stations → pick sensor → latest observation
+
+```bash
+# 1. List stations (StationSet wrapper)
+curl -s "https://swd.weatherflow.com/swd/rest/stations?token=$TEMPEST_TOKEN"
+# 2. Choose a device: devices[].device_type must not be "HB"; prefer ST
+# 3. Latest observation for that device
+curl -s "https://swd.weatherflow.com/swd/rest/observations/device/$DEVICE_ID?token=$TEMPEST_TOKEN"
+```
+
+The observation response's `type` field selects the positional layout
+(`obs_st`: temperature is index 7, epoch is index 0). One command does all
+three steps: `tempest current --json`.
+
+### Recipe B: station forecast with explicit units
+
+```bash
+curl -s "https://swd.weatherflow.com/swd/rest/better_forecast?station_id=$STATION_ID&units_temp=c&units_wind=mps&units_pressure=mb&units_precip=mm&token=$TEMPEST_TOKEN" \
+ | jq '{current: .current_conditions.air_temperature,
+ days: [.forecast.daily[] | {day_start_local, air_temp_high, air_temp_low}],
+ units: .units.units_temp}'
+```
+
+Read `units` instead of assuming units. Extract daily/hourly from
+`.forecast.daily` / `.forecast.hourly`.
+
+### Recipe C: a UTC day of device history
+
+```bash
+# day_offset=1 is yesterday UTC; day_offset=0 is today
+curl -s "https://swd.weatherflow.com/swd/rest/observations/device/$DEVICE_ID?day_offset=1&token=$TEMPEST_TOKEN" \
+ | jq '{type, count: (.obs | length), first: .obs[0], last: .obs[-1]}'
+```
+
+For a custom range, send both `time_start` and `time_end` as epoch seconds and
+keep the span ≤ 5 days to guarantee one-minute resolution. Do not mix
+`day_offset` with `time_start`/`time_end` in one call.
+
+## Sources
+
+- https://apidocs.tempestwx.com/reference/quick-start (auth flows, REST examples, primary-source guidance)
+- https://apidocs.tempestwx.com/reference/oauth (OAuth 2.0 grant types, app registration)
+- https://apidocs.tempestwx.com/reference/get_stations (StationSet/Station/Device OpenAPI schemas)
+- https://apidocs.tempestwx.com/reference/getstationbyid-1 (single station, 404 semantics)
+- https://apidocs.tempestwx.com/reference/getobservationsbydeviceid (device observation parameters, 404)
+- https://apidocs.tempestwx.com/reference/get_observations-stn-station-id (station observations, bucket)
+- https://apidocs.tempestwx.com/reference/station-vs-device (device vs station observation semantics)
+- https://apidocs.tempestwx.com/reference/get_better-forecast-1 (forecast parameters, unit selection)
+- https://apidocs.tempestwx.com/reference/get_diagnostics-station-id-1 (diagnostics endpoint)
+- https://apidocs.tempestwx.com/reference/get_stats-station-station-id-1 (stats endpoint)
+- https://apidocs.tempestwx.com/reference/observation-record-format (type discriminators, record lengths)
+- https://apidocs.tempestwx.com/reference/tempest-udp-broadcast (UDP as backup to REST)
+- https://weatherflow.github.io/Tempest/api/swagger/ (legacy response models: forecast nesting, obs_sky null day-rain)
+- https://weatherflow.github.io/Tempest/api/remote-developer-policy.html (personal-use policy, rate/volume limits)
diff --git a/tempest/references/udp-broadcast-protocol.md b/tempest/references/udp-broadcast-protocol.md
new file mode 100644
index 0000000..3206965
--- /dev/null
+++ b/tempest/references/udp-broadcast-protocol.md
@@ -0,0 +1,294 @@
+# Tempest UDP Broadcast Protocol (Port 50222)
+
+The Tempest hub broadcasts JSON messages to the local network on **UDP port
+50222**. A listener on the same LAN receives every message the hub publishes:
+observations, rapid wind updates, precipitation and lightning events, and
+hub/device status. No subscription, pairing, or token is involved — the hub
+broadcasts regardless; point a listener at port 50222 and read.
+
+Positioning per WeatherFlow: REST/WebSocket are the primary data interfaces,
+and the UDP broadcast is officially recommended for completely off-grid
+applications or as a backup. It is nevertheless the lowest-latency feed on
+your LAN (rapid wind arrives every ~3 seconds; hub status roughly once a
+minute).
+
+## Transport facts
+
+- **Port:** 50222, UDP, local broadcast. Routed/internet reachability is not
+ enough — the listener must share the hub's L2 network (same subnet/VLAN, or
+ a DHCP/helper forwarding broadcasts).
+- **Direction:** the hub sends, listeners receive. The protocol defines no
+ acknowledgement or response message; treat it as listen-only. Bind to
+ `0.0.0.0:50222` with `SO_REUSEADDR` and read datagrams.
+- **Framing:** each UDP datagram carries one complete JSON message (UTF-8).
+ Never concatenate datagrams or expect TCP-style stream framing. (UTF-8 and
+ one-JSON-per-datagram are the interoperable reading of the protocol's JSON
+ examples; the official pages do not spell the encoding out.)
+- **No auth:** the broadcast carries no token and cannot be restricted from
+ the hub; anyone on the LAN can read your station's data. This is why the
+ broadcast is LAN-only.
+
+## THE dispatch rule: message families are structurally different
+
+Every message carries a top-level `"type"`. The payload key and array shape
+**change with the type** — a parser that blindly indexes a position will
+crash or misread. Dispatch on `type` BEFORE indexing:
+
+| `type` | Payload key | Payload shape |
+|---|---|---|
+| `obs_st`, `obs_air`, `obs_sky` | `obs` | list containing observation arrays (one per report): `msg["obs"][0][7]` |
+| `rapid_wind` | `ob` | ONE 3-element array: `msg["ob"][1]` is wind speed |
+| `evt_precip` | `evt` | ONE 1-element array: `msg["evt"][0]` is epoch |
+| `evt_strike` | `evt` | ONE 3-element array: epoch, distance km, energy |
+| `hub_status` | (named fields) | no payload array: `uptime`, `rssi`, `seq`, `fs`, `radio_stats`, `mqtt_stats` |
+| `device_status` | (named fields) | no payload array: `uptime`, `voltage`, `rssi`, `hub_rssi`, `sensor_status` |
+
+The observation families nest arrays inside a list; `rapid_wind` and the
+events carry a single array under a *different key* (`ob` / `evt`); the
+status families carry named scalar fields and small status arrays. Iterating
+`rapid_wind`'s `ob` array element-wise the way you would `obs` rows is a
+classic crash (TypeError on the epoch number) — this is exactly the trap the
+dispatch rule exists for.
+
+## obs_st — Tempest all-in-one observation (UDP form)
+
+Broadcast roughly once per report interval (default 1 minute). The UDP
+datagram carries **18 positions (indices 0–17)**:
+
+```json
+{
+ "serial_number": "ST-00000512",
+ "type": "obs_st",
+ "hub_sn": "HB-00013030",
+ "obs": [[1588948614, 0.18, 0.22, 0.27, 144, 6, 1017.57, 22.37, 50.26, 328, 0.03, 3, 0.000000, 0, 0, 0, 2.410, 1]],
+ "firmware_revision": 129
+}
+```
+
+| Index | Field | Units |
+|---:|---|---|
+| 0 | timestamp | epoch seconds, UTC |
+| 1 | wind lull (min 3-second sample) | m/s |
+| 2 | wind average | m/s |
+| 3 | wind gust (max 3-second sample) | m/s |
+| 4 | wind direction | degrees (0 = N) |
+| 5 | wind sample interval | seconds |
+| 6 | station pressure | MB (millibars; numerically identical to hPa) |
+| 7 | air temperature | °C |
+| 8 | relative humidity | % |
+| 9 | illuminance | lux |
+| 10 | UV | index |
+| 11 | solar radiation | W/m² |
+| 12 | rain accumulation over previous minute | mm |
+| 13 | precipitation type | 0 none, 1 rain, 2 hail, 3 rain + hail (experimental) |
+| 14 | lightning strike average distance | km |
+| 15 | lightning strike count | count |
+| 16 | battery | volts (≈2.4 nominal; low below ≈2.3) |
+| 17 | report interval | minutes |
+
+**UDP vs REST length:** the REST observation record extends the same array
+with four Nearcast/analysis fields — index 18 local-day rain accumulation
+(mm), 19 Nearcast rain accumulation (mm), 20 local-day Nearcast rain
+accumulation (mm), 21 precipitation analysis type (0 none, 1 Nearcast display
+on, 2 off) — for 22 positions total. The UDP broadcast stops at 17. A decoder
+must tolerate both lengths (the bundled `decode_obs` does) and never assume
+the extra fields exist over UDP.
+
+## rapid_wind — 3-second wind snapshot
+
+Broadcast every ~3 seconds between observation reports. Layout differs from
+obs_st: payload key is `ob`, a single 3-element array (speed is already
+m/s — no conversion on the wire, only when displaying mph):
+
+```json
+{
+ "serial_number": "SK-00008453",
+ "type": "rapid_wind",
+ "hub_sn": "HB-00000001",
+ "ob": [1493322445, 2.3, 128]
+}
+```
+
+| Index | Field | Units |
+|---:|---|---|
+| 0 | timestamp | epoch seconds, UTC |
+| 1 | wind speed | m/s |
+| 2 | wind direction | degrees |
+
+## evt_precip — rain-start event
+
+Fires when the haptic rain sensor detects the start of rainfall (more than
+five seconds of continuous rain). Payload key `evt`, one element:
+
+```json
+{
+ "serial_number": "SK-00008453",
+ "type": "evt_precip",
+ "hub_sn": "HB-00000001",
+ "evt": [1493322445]
+}
+```
+
+| Index | Field | Units |
+|---:|---|---|
+| 0 | timestamp | epoch seconds, UTC |
+
+## evt_strike — lightning strike event
+
+Payload key `evt`, three elements. The energy unit is not specified in the
+official reference:
+
+```json
+{
+ "serial_number": "AR-00004049",
+ "type": "evt_strike",
+ "hub_sn": "HB-00000001",
+ "evt": [1493322445, 27, 3848]
+}
+```
+
+| Index | Field | Units |
+|---:|---|---|
+| 0 | timestamp | epoch seconds, UTC |
+| 1 | distance | km |
+| 2 | energy | undocumented unit |
+
+## hub_status — hub heartbeat (roughly once a minute)
+
+**No payload array at all** — named scalar fields plus small status arrays.
+Note `firmware_revision` arrives as a string here (number in observation
+messages):
+
+```json
+{
+ "serial_number": "HB-00000001",
+ "type": "hub_status",
+ "firmware_revision": "35",
+ "uptime": 1670133,
+ "rssi": -62,
+ "timestamp": 1495724691,
+ "reset_flags": "BOR,PIN,POR",
+ "seq": 48,
+ "fs": [1, 0, 15675411, 524288],
+ "radio_stats": [2, 1, 0, 3, 2839],
+ "mqtt_stats": [1, 0]
+}
+```
+
+- `uptime` (s), `rssi` (dBm; closer to 0 is stronger), `timestamp` (epoch
+ seconds), `seq` (monotonic message counter — gaps mean lost datagrams).
+- `reset_flags`: comma-separated reset causes — BOR, PIN, POR, SFT, WDG,
+ WWD, LPW, HRDFLT. Repeated watchdog flags suggest power trouble.
+- `radio_stats`: [version, reboot count, I2C bus error count, radio status,
+ radio network ID]; radio status 0 = off, 1 = on, 3 = active, 7 = BLE
+ connected.
+- `fs` and `mqtt_stats` are documented as internal use.
+- There is no `freq` or `fs_version` field in the current protocol (both
+ appear in old integration notes; do not read them — they are always
+ `None`).
+
+## device_status — sensor device health (roughly once a minute)
+
+Also named fields, no payload array:
+
+```json
+{
+ "serial_number": "AR-00004049",
+ "type": "device_status",
+ "hub_sn": "HB-00000001",
+ "timestamp": 1510855923,
+ "uptime": 2189,
+ "voltage": 3.50,
+ "firmware_revision": 17,
+ "rssi": -17,
+ "hub_rssi": -87,
+ "sensor_status": 0,
+ "debug": 0
+}
+```
+
+`sensor_status` is a decimal **bit flag** field: bits indicate lightning
+failed / noise / disturber, pressure failed, temperature failed, humidity
+failed, wind failed, precipitation failed, light/UV failed, plus power-booster
+flags. `0` means all sensors healthy. Unknown high bits are reserved — ignore
+them rather than erroring.
+
+## Legacy sensors: obs_air and obs_sky
+
+Older Air/Sky hardware still broadcasts with the same envelope:
+
+**obs_air** (`obs` list, 8 positions): 0 epoch · 1 pressure MB · 2 air temp °C
+· 3 relative humidity % · 4 lightning strike count · 5 lightning average
+distance km · 6 battery volts · 7 report interval minutes.
+
+```json
+{"serial_number": "AR-00004049", "type": "obs_air", "hub_sn": "HB-00000001",
+ "obs": [[1493164835, 835.0, 10.0, 45, 0, 0, 3.46, 1]], "firmware_revision": 17}
+```
+
+**obs_sky** (`obs` list, 14 positions): 0 epoch · 1 illuminance lux · 2 UV ·
+3 rain mm · 4 wind lull m/s · 5 wind avg m/s · 6 wind gust m/s · 7 wind
+direction deg · 8 battery volts · 9 report interval min · 10 solar radiation
+W/m² · 11 local-day rain mm (**always null over UDP** — REST provides it) ·
+12 precipitation type · 13 wind sample interval s.
+
+```json
+{"serial_number": "SK-00008453", "type": "obs_sky", "hub_sn": "HB-00000001",
+ "obs": [[1493321340, 9000, 10, 0.0, 2.6, 4.6, 7.4, 187, 3.12, 1, 130, null, 0, 3]],
+ "firmware_revision": 29}
+```
+
+## Units are metric-native — conversion is the caller's job
+
+Every value on the wire is metric: wind **m/s**, rain **mm**, temperature
+**°C**, pressure **MB** (≡ hPa — NOT kPa), distance **km**, illuminance
+**lux**, solar radiation **W/m²**, battery **volts**. The UDP protocol ships
+no unit-selection and no conversion tables; imperial output is entirely your
+code's job. The bundled CLI converts for human display and leaves `--json`
+values in the metric-native wire units. Station pressure (raw sensor) is not
+sea-level pressure — the Tempest app's "relative pressure" applies an
+elevation adjustment you must compute separately if you want it.
+
+## Minimal listener
+
+```bash
+# See the raw firehose before writing any code:
+tempest udp listen --timeout 30 # decodes families, hides hub_status
+tempest udp listen --timeout 30 --show-all # include hub_status and unknown types
+```
+
+```python
+# Zero-dependency decoder skeleton — dispatch on type, then index.
+import json, socket
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+sock.bind(("0.0.0.0", 50222))
+
+while True:
+ msg = json.loads(sock.recvfrom(65535)[0].decode("utf-8", errors="replace"))
+ t = msg.get("type")
+ if t in ("obs_st", "obs_air", "obs_sky"):
+ row = msg["obs"][-1] # list of report rows
+ elif t == "rapid_wind":
+ row = msg["ob"] # ONE array: [epoch, m/s, degrees]
+ elif t in ("evt_precip", "evt_strike"):
+ row = msg["evt"] # ONE array: [epoch] / [epoch, km, energy]
+ elif t in ("hub_status", "device_status"):
+ continue # named fields, nothing to index
+ else:
+ continue # unknown type: skip, don't crash
+ print(t, msg.get("serial_number"), row[0])
+```
+
+The bundled CLI implements this dispatch in `udp_listen` (see
+`scripts/tempest`) with per-family decoders and `--json` output.
+
+## Sources
+
+- https://weatherflow.github.io/Tempest/api/udp/v171/ (current UDP protocol reference: all message families, layouts, examples)
+- https://weatherflow.github.io/Tempest/api/udp/v143/ (prior protocol revision; family set unchanged)
+- https://apidocs.tempestwx.com/reference/tempest-udp-broadcast (UDP documented as backup to REST/WebSocket)
+- https://apidocs.tempestwx.com/reference/observation-record-format (REST obs_st Nearcast fields 18–21; evt_strike and rapid_wind record tables)
+- https://apidocs.tempestwx.com/reference/quick-start (UDP positioned as backup; REST primary guidance)
+- https://help.weatherflow.com/hc/en-us/articles/360052101413-Tempest-FAQs (haptic rain-start behavior, RSSI interpretation, station vs sea-level pressure)
diff --git a/tempest/scripts/tempest b/tempest/scripts/tempest
new file mode 100755
index 0000000..6061107
--- /dev/null
+++ b/tempest/scripts/tempest
@@ -0,0 +1,856 @@
+#!/usr/bin/env python3
+"""tempest — Hyper-local weather from your Tempest station.
+
+Two data sources:
+ REST API — stations, observations, forecast via WeatherFlow cloud
+ (primary, documented source; personal-use token)
+ UDP/local — real-time JSON broadcast from your hub on port 50222
+ (LAN-only backup; no auth, listen-only)
+
+Message families over UDP are structurally different (obs_* nest rows under
+"obs", rapid_wind carries one array under "ob", evt_* under "evt",
+hub_status/device_status use named fields) — decode_message() dispatches on
+"type" before any positional indexing.
+
+Requires TEMPEST_TOKEN env var (personal access token created in the Tempest
+web app: Settings -> Data Authorizations). Falls back to ~/.tempest.env.
+"""
+
+import argparse
+import json
+import os
+import socket
+import sys
+import time
+import warnings
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional, Tuple
+
+# === Suppress dependency warnings before imports ===
+warnings.simplefilter("ignore")
+
+import requests
+
+# === Config ===
+DEFAULT_SERVER = "https://swd.weatherflow.com/swd/rest"
+DEFAULT_UDP_PORT = 50222
+UDP_BROADCAST_ADDR = "0.0.0.0"
+ENV_FILE = os.path.join("~", ".tempest.env")
+
+
+def _load_env_file() -> str:
+ """Fallback token source: ~/.tempest.env with a TEMPEST_TOKEN= line."""
+ path = os.path.expanduser(ENV_FILE)
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if line.startswith("TEMPEST_TOKEN="):
+ value = line.split("=", 1)[1].strip()
+ return value.strip('"').strip("'")
+ except OSError:
+ pass
+ return ""
+
+
+def resolve_token() -> str:
+ """TEMPEST_TOKEN env var wins; ~/.tempest.env is the fallback."""
+ token = os.getenv("TEMPEST_TOKEN", "").strip()
+ if token:
+ return token
+ return _load_env_file()
+
+
+# === Logging ===
+QUIET = False
+
+
+def log(msg: str) -> None:
+ """Log to stdout, suppressed in --json or --quiet mode."""
+ if not QUIET and not GLOBAL_FLAGS.get("json", False):
+ print(msg)
+
+
+def warn(msg: str) -> None:
+ print(f"Warning: {msg}", file=sys.stderr)
+
+
+def die(msg: str, exit_code: int = 1) -> None:
+ print(f"Error: {msg}", file=sys.stderr)
+ sys.exit(exit_code)
+
+
+def emit(human: str, data: Any) -> None:
+ """Dual output — machine JSON or human text."""
+ if GLOBAL_FLAGS.get("json", False):
+ print(json.dumps(data, default=str))
+ else:
+ print(human)
+
+
+# === Global flags (pre-parsed from argv) ===
+GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False}
+
+
+def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]:
+ """Strip global flags from argv regardless of position."""
+ GLOBAL_BOOLS = {"--json", "--dry-run", "--force", "--quiet", "--verbose"}
+ flags: Dict[str, Any] = {}
+ filtered: List[str] = [argv[0]]
+ i = 1
+ while i < len(argv):
+ arg = argv[i]
+ if arg in GLOBAL_BOOLS:
+ flags[arg.lstrip("-").replace("-", "_")] = True
+ i += 1
+ elif arg in ("--help", "-h"):
+ return flags, argv # let argparse handle help
+ elif arg == "--":
+ filtered.extend(argv[i:])
+ break
+ else:
+ filtered.append(arg)
+ i += 1
+ return flags, filtered
+
+
+# === Tempest API Client ===
+class TempestClient:
+ """REST API client for WeatherFlow Tempest (swd.weatherflow.com)."""
+
+ def __init__(self, token: str = "", server: str = "", dry_run: bool = False):
+ self.token = token
+ self.server = (server or os.getenv("TEMPEST_SERVER", DEFAULT_SERVER)).rstrip("/")
+ self.dry_run = dry_run
+
+ def _get(self, path: str, params: Optional[Dict] = None) -> Any:
+ """Generic GET with token auth (query parameter per official docs)."""
+ url = f"{self.server}{path}"
+ if params is None:
+ params = {}
+ params["token"] = self.token
+
+ if self.dry_run:
+ return {"dry_run": True, "url": url, "params": params}
+
+ try:
+ resp = requests.get(url, params=params, timeout=30)
+ except requests.ConnectionError as e:
+ die(f"Cannot connect to {self.server}: {e}\n Is the server reachable?")
+
+ if resp.status_code == 401:
+ die("Auth failed (401). Check your TEMPEST_TOKEN or create a new one "
+ "in the Tempest web app (Settings -> Data Authorizations).")
+ if resp.status_code == 403:
+ die("Forbidden (403). Your token does not have access to this station/device.")
+ if resp.status_code == 404:
+ die(f"Not found (404) at {path}. Check station/device IDs.")
+ if resp.status_code >= 400:
+ try:
+ detail = resp.json()
+ except Exception:
+ detail = resp.text[:200]
+ die(f"API error ({resp.status_code}): {detail}")
+
+ try:
+ return resp.json()
+ except ValueError:
+ return {"raw": resp.text[:500]}
+
+ # === Endpoints ===
+
+ def get_stations(self) -> List[Dict]:
+ """List all stations and their devices.
+
+ The documented response is a StationSet wrapper ({stations: [...],
+ status}); the legacy model used {locations: [...]} and some proxies
+ have returned a bare list — unwrap all three shapes defensively.
+ """
+ data = self._get("/stations")
+ if isinstance(data, list):
+ return data
+ if isinstance(data, dict):
+ for key in ("stations", "locations"):
+ if isinstance(data.get(key), list):
+ return data[key]
+ return []
+
+ def get_observations(self, device_id: int, days_back: int = 0,
+ time_start: Optional[int] = None,
+ time_end: Optional[int] = None) -> Dict:
+ """Get observations for a device.
+
+ day_offset=N fetches whole UTC day N (0 = today); a time_start/time_end
+ epoch range overrides it (both, <= 5 days for minute resolution); with
+ no range parameters the API returns only the latest observation.
+ """
+ params: Dict[str, Any] = {}
+ if days_back > 0:
+ params["day_offset"] = days_back
+ elif time_start:
+ params["time_start"] = time_start
+ if time_end:
+ params["time_end"] = time_end
+ return self._get(f"/observations/device/{device_id}", params)
+
+ def get_forecast(self, station_id: int) -> Dict:
+ """Get better_forecast — current conditions + daily + hourly."""
+ return self._get("/better_forecast", {"station_id": station_id})
+
+
+# === Observation decoders (positional arrays; layout per `type`) ===
+# REST record lengths: obs_st 22, obs_air 8, obs_sky 17. UDP truncates
+# obs_st to 18 and obs_sky to 14 (Nearcast fields are REST-only) — decoders
+# tolerate both lengths and emit None for missing trailing positions.
+
+OBS_ST_FIELDS = [
+ ("epoch", "seconds_utc"),
+ ("wind_lull", "m/s"),
+ ("wind_avg", "m/s"),
+ ("wind_gust", "m/s"),
+ ("wind_direction", "degrees"),
+ ("wind_sample_interval", "seconds"),
+ ("station_pressure", "MB"),
+ ("air_temperature", "C"),
+ ("relative_humidity", "%"),
+ ("illuminance", "lux"),
+ ("uv", "index"),
+ ("solar_radiation", "W/m^2"),
+ ("rain_accumulation", "mm"),
+ ("precipitation_type", "0=none 1=rain 2=hail 3=rain+hail"),
+ ("avg_strike_distance", "km"),
+ ("strike_count", "count"),
+ ("battery", "volts"),
+ ("report_interval", "minutes"),
+ ("local_day_rain_accumulation", "mm"),
+ ("nc_rain_accumulation", "mm"),
+ ("local_day_nc_rain_accumulation", "mm"),
+ ("precip_analysis_type", "type"),
+]
+
+OBS_AIR_FIELDS = [
+ ("epoch", "seconds_utc"),
+ ("station_pressure", "MB"),
+ ("air_temperature", "C"),
+ ("relative_humidity", "%"),
+ ("lightning_strike_count", "count"),
+ ("lightning_avg_distance", "km"),
+ ("battery", "volts"),
+ ("report_interval", "minutes"),
+]
+
+OBS_SKY_FIELDS = [
+ ("epoch", "seconds_utc"),
+ ("illuminance", "lux"),
+ ("uv", "index"),
+ ("rain_accumulation", "mm"),
+ ("wind_lull", "m/s"),
+ ("wind_avg", "m/s"),
+ ("wind_gust", "m/s"),
+ ("wind_direction", "degrees"),
+ ("battery", "volts"),
+ ("report_interval", "minutes"),
+ ("solar_radiation", "W/m^2"),
+ ("local_day_rain_accumulation", "mm"),
+ ("precipitation_type", "0=none 1=rain 2=hail 3=rain+hail"),
+ ("wind_sample_interval", "seconds"),
+ ("nc_rain_accumulation", "mm"),
+ ("local_day_nc_rain_accumulation", "mm"),
+ ("precip_analysis_type", "type"),
+]
+
+_OBS_LAYOUTS = {
+ "obs_st": OBS_ST_FIELDS,
+ "obs_air": OBS_AIR_FIELDS,
+ "obs_sky": OBS_SKY_FIELDS,
+}
+
+
+def decode_obs(obs_array: List, type_str: str) -> Dict:
+ """Decode one positional observation array into named fields.
+
+ Values stay metric-native (m/s, mm, C, MB) — conversion is the caller's
+ job. Rows may be shorter than the full layout (UDP truncates) or longer
+ (REST extends obs_st); missing positions decode as None.
+ """
+ fields = _OBS_LAYOUTS.get(type_str)
+ if fields is None:
+ return {f"field_{i}": v for i, v in enumerate(obs_array)}
+
+ result: Dict[str, Any] = {}
+ for i, (name, unit) in enumerate(fields):
+ val = obs_array[i] if i < len(obs_array) else None
+ if name == "epoch" and val is not None:
+ result["timestamp"] = datetime.fromtimestamp(val, tz=timezone.utc).isoformat()
+ result[name] = val
+ result[f"{name}_unit"] = unit if name != "epoch" else None
+ return result
+
+
+def wind_dir_to_cardinal(deg: Optional[float]) -> str:
+ """Convert wind degrees to cardinal direction."""
+ if deg is None:
+ return "N/A"
+ dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N"]
+ idx = round(deg / 22.5)
+ return dirs[idx % 16]
+
+
+def format_current(obs: Dict) -> str:
+ """Format current conditions for human display (converted from metric)."""
+ lines = []
+ if obs.get("air_temperature") is not None:
+ temp_c = obs["air_temperature"]
+ temp_f = temp_c * 9 / 5 + 32
+ lines.append(f"🌡️ Temperature: {temp_c:.1f}°C / {temp_f:.1f}°F")
+
+ if obs.get("relative_humidity") is not None:
+ lines.append(f"💧 Humidity: {obs['relative_humidity']:.0f}%")
+
+ if obs.get("station_pressure") is not None:
+ press_mb = obs["station_pressure"]
+ press_inhg = press_mb * 0.02953
+ lines.append(f"🔵 Pressure: {press_mb:.1f} MB / {press_inhg:.2f} inHg")
+
+ if obs.get("wind_avg") is not None:
+ wind_mps = obs["wind_avg"]
+ wind_mph = wind_mps * 2.237
+ gust_mps = obs.get("wind_gust")
+ gust_mph = gust_mps * 2.237 if gust_mps else None
+ dir_deg = obs.get("wind_direction")
+ card = wind_dir_to_cardinal(dir_deg)
+ gust_str = f" (gust {gust_mph:.1f} mph)" if gust_mph else ""
+ lines.append(f"💨 Wind: {wind_mps:.1f} m/s ({wind_mph:.1f} mph){gust_str} from {card} ({dir_deg:.0f}°)")
+
+ if obs.get("illuminance") is not None and obs["illuminance"] > 0:
+ lux = obs["illuminance"]
+ lines.append(f"☀️ Illuminance: {lux:.0f} lux")
+ if obs.get("solar_radiation") is not None and obs["solar_radiation"] > 0:
+ lines.append(f"⚡ Solar Radiation: {obs['solar_radiation']:.0f} W/m²")
+ if obs.get("uv") is not None:
+ lines.append(f"🌞 UV Index: {obs['uv']:.1f}")
+
+ if obs.get("rain_accumulation") is not None and obs["rain_accumulation"] > 0:
+ rain_mm = obs["rain_accumulation"]
+ rain_in = rain_mm / 25.4
+ lines.append(f"🌧️ Rain (last interval): {rain_mm:.2f} mm / {rain_in:.3f} in")
+ if obs.get("local_day_rain_accumulation") is not None and obs["local_day_rain_accumulation"] > 0:
+ day_mm = obs["local_day_rain_accumulation"]
+ day_in = day_mm / 25.4
+ lines.append(f"🌧️ Rain (today): {day_mm:.2f} mm / {day_in:.3f} in")
+
+ if obs.get("strike_count") is not None and obs["strike_count"] > 0:
+ lines.append(f"⚡ Lightning strikes: {obs['strike_count']} (avg dist {obs.get('avg_strike_distance', '?')} km)")
+
+ if obs.get("battery") is not None:
+ lines.append(f"🔋 Battery: {obs['battery']:.2f}V")
+
+ if obs.get("timestamp"):
+ lines.append(f"🕐 Recorded: {obs['timestamp']}")
+
+ return "\n".join(lines) if lines else "(no observations)"
+
+
+# === UDP message-family dispatch ===
+# Families differ structurally: obs_* nest report rows under "obs";
+# rapid_wind carries ONE array under "ob"; evt_precip/evt_strike carry ONE
+# array under "evt"; hub_status/device_status carry named fields only.
+# Dispatch on "type" BEFORE any positional indexing.
+
+def _iso(ts: Any) -> Any:
+ """Epoch seconds -> ISO string; pass through anything else."""
+ if isinstance(ts, (int, float)):
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
+ return None
+
+
+def _decode_event(msg: Dict, type_str: str) -> Tuple[str, Dict]:
+ evt = msg.get("evt") or []
+ ts = _iso(evt[0]) if len(evt) > 0 else None
+ payload: Dict[str, Any] = {"type": type_str,
+ "serial_number": msg.get("serial_number", msg.get("hub_sn", "?")),
+ "timestamp": ts}
+ if type_str == "evt_strike":
+ dist = evt[1] if len(evt) > 1 else None
+ energy = evt[2] if len(evt) > 2 else None
+ payload.update({"distance_km": dist, "energy": energy})
+ human = f"⚡ Lightning Strike — distance: {dist} km, energy: {energy} [{ts}]"
+ else: # evt_precip — rain started
+ human = f"🌧️ Rain started [{ts}]"
+ return human, payload
+
+
+def _decode_obs_message(msg: Dict, type_str: str) -> List[Tuple[str, Dict]]:
+ out: List[Tuple[str, Dict]] = []
+ label = {"obs_st": "Tempest Observation", "obs_air": "Air Observation",
+ "obs_sky": "Sky Observation"}.get(type_str, type_str)
+ sn = msg.get("serial_number", msg.get("hub_sn", "?"))
+ for obs_arr in msg.get("obs", []) or []:
+ decoded = decode_obs(obs_arr, type_str)
+ out.append((f"\n── {label} from {sn} ──\n{format_current(decoded)}",
+ {"type": type_str, "serial_number": sn, "observation": decoded}))
+ return out
+
+
+def _decode_rapid_wind(msg: Dict) -> Tuple[str, Dict]:
+ # rapid_wind payload is a SINGLE 3-element array under "ob":
+ # [epoch, wind speed m/s, wind direction degrees]. Do NOT iterate it
+ # element-wise like an obs row list.
+ ob = msg.get("ob") or []
+ sn = msg.get("serial_number", msg.get("hub_sn", "?"))
+ ts = _iso(ob[0]) if len(ob) > 0 else None
+ speed = ob[1] if len(ob) > 1 else None
+ direction = ob[2] if len(ob) > 2 else None
+ if isinstance(speed, (int, float)) and isinstance(direction, (int, float)):
+ card = wind_dir_to_cardinal(direction)
+ mph = speed * 2.237
+ human = f"💨 Rapid Wind: {speed} m/s ({mph:.1f} mph) from {card} ({direction}°) [{ts}]"
+ else:
+ human = f"💨 Rapid Wind: {speed} m/s from {direction}° [{ts}]"
+ payload = {"type": "rapid_wind", "serial_number": sn,
+ "wind_speed_mps": speed, "wind_direction": direction, "timestamp": ts}
+ return human, payload
+
+
+def _decode_hub_status(msg: Dict) -> Tuple[str, Dict]:
+ # hub_status carries named fields (uptime, rssi, seq, reset_flags,
+ # firmware_revision as a string, plus fs/radio_stats/mqtt_stats arrays).
+ # There is no "freq" or "fs_version" field in the current protocol.
+ sn = msg.get("serial_number", "?")
+ payload = {"type": "hub_status", "serial_number": sn,
+ "firmware_revision": msg.get("firmware_revision"),
+ "uptime": msg.get("uptime"), "rssi": msg.get("rssi"),
+ "seq": msg.get("seq"), "reset_flags": msg.get("reset_flags"),
+ "radio_stats": msg.get("radio_stats")}
+ human = (f"[hub_status] {sn} — uptime {payload['uptime']}s, "
+ f"rssi {payload['rssi']}, seq {payload['seq']}, "
+ f"reset_flags {payload['reset_flags']}")
+ return human, payload
+
+
+def _decode_device_status(msg: Dict) -> Tuple[str, Dict]:
+ # device_status: named fields; sensor_status is a decimal bit-flag field
+ # (0 = all sensors healthy).
+ sn = msg.get("serial_number", "?")
+ payload = {"type": "device_status", "serial_number": sn,
+ "uptime": msg.get("uptime"), "voltage": msg.get("voltage"),
+ "firmware_revision": msg.get("firmware_revision"),
+ "rssi": msg.get("rssi"), "hub_rssi": msg.get("hub_rssi"),
+ "sensor_status": msg.get("sensor_status")}
+ human = (f"[device_status] {sn} — voltage {payload['voltage']}V, "
+ f"rssi {payload['rssi']}, hub_rssi {payload['hub_rssi']}, "
+ f"sensor_status {payload['sensor_status']}")
+ return human, payload
+
+
+def decode_message(msg: Dict, show_all: bool = False) -> List[Tuple[str, Dict]]:
+ """Dispatch one decoded UDP datagram on its `type` and decode it.
+
+ Returns a list of (human_text, json_payload) tuples (observations can
+ carry multiple report rows). Unknown types and status families are
+ suppressed unless show_all is set.
+ """
+ msg_type = msg.get("type", "unknown")
+ if msg_type in ("obs_st", "obs_air", "obs_sky"):
+ return _decode_obs_message(msg, msg_type)
+ if msg_type == "rapid_wind":
+ return [_decode_rapid_wind(msg)]
+ if msg_type in ("evt_precip", "evt_strike"):
+ return [_decode_event(msg, msg_type)]
+ if msg_type == "hub_status":
+ return [_decode_hub_status(msg)] if show_all else []
+ if msg_type == "device_status":
+ return [_decode_device_status(msg)] if show_all else []
+ if show_all:
+ sn = msg.get("serial_number", msg.get("hub_sn", "?"))
+ return [(f"[{msg_type}] from {sn}", {"type": msg_type, "serial_number": sn, "raw": msg})]
+ return []
+
+
+def handle_datagram(data: bytes, show_all: bool = False) -> List[Tuple[str, Dict]]:
+ """Decode one raw UDP datagram (bytes) — JSON parse, then family dispatch.
+
+ Bad JSON yields [] (or a raw preview when show_all is set). No sockets
+ are involved, so canned bytes can be fed directly in tests.
+ """
+ try:
+ msg = json.loads(data.decode("utf-8", errors="replace"))
+ except json.JSONDecodeError:
+ if show_all:
+ preview = data[:200].decode("utf-8", errors="replace")
+ return [(f"[raw] {preview}", {"type": "unparseable", "raw": preview})]
+ return []
+ if not isinstance(msg, dict):
+ return []
+ return decode_message(msg, show_all)
+
+
+# === CLI Commands ===
+
+def _client_for() -> TempestClient:
+ return TempestClient(token=resolve_token(), dry_run=GLOBAL_FLAGS.get("dry_run", False))
+
+
+def _pick_device(devices: List[Dict]) -> Dict:
+ """Auto-select a sensor device: skip hubs, prefer ST, then SKY/SK, then AIR/AR.
+
+ device_type values per the OpenAPI schema are HB/AR/SK/ST; SKY and AIR
+ are accepted as legacy aliases for SK and AR.
+ """
+ sensors = [d for d in devices if d.get("device_type") not in ("HB", "hub")]
+ if not sensors:
+ die("No sensor devices found on this station (only a hub). Hubs carry no observations.")
+ for preferred in ("ST", "SKY", "SK", "AIR", "AR"):
+ match = next((d for d in sensors if d.get("device_type") == preferred), None)
+ if match:
+ return match
+ return sensors[0]
+
+
+def cmd_stations(args: argparse.Namespace) -> None: # noqa: ARG001 (uniform handler signature)
+ """List stations and attached devices."""
+ if GLOBAL_FLAGS.get("dry_run", False):
+ emit("[dry-run] Would list stations and devices for your token.",
+ {"dry_run": True, "command": "stations"})
+ return
+
+ client = _client_for()
+ stations = client.get_stations()
+ if not stations:
+ emit("No stations found for this token.", {"stations": []})
+ return
+
+ output_human = []
+ for s in stations:
+ name = s.get("name", s.get("station_name", "Unnamed"))
+ sid = s.get("station_id", "?")
+ output_human.append(f"Station: {name} (id={sid})")
+ for dev in s.get("devices", []):
+ dev_id = dev.get("device_id", "?")
+ dev_type = dev.get("device_type", "?")
+ sn = dev.get("serial_number", "?")
+ output_human.append(f" ├─ Device: {dev_type} (id={dev_id}, sn={sn})")
+ if dev.get("name"):
+ output_human.append(f" │ Name: {dev['name']}")
+
+ emit("\n".join(output_human), {"stations": stations})
+
+
+def cmd_current(args: argparse.Namespace) -> None:
+ """Get latest observations from your station."""
+ if GLOBAL_FLAGS.get("dry_run", False):
+ emit("[dry-run] Would query latest observations from your station.",
+ {"dry_run": True, "command": "current",
+ "station_id": args.station_id, "device_id": args.device_id})
+ return
+
+ client = _client_for()
+ stations = client.get_stations()
+ if not stations:
+ die("No stations found. Verify your TEMPEST_TOKEN.")
+
+ if args.station_id:
+ station = next((s for s in stations if s.get("station_id") == args.station_id), None)
+ if not station:
+ die(f"Station {args.station_id} not found.")
+ else:
+ station = stations[0]
+
+ devices = station.get("devices", [])
+ if not devices:
+ die(f"Station '{station.get('name', '?')}' has no devices.")
+
+ if args.device_id:
+ device = next((d for d in devices if d.get("device_id") == args.device_id), None)
+ if not device:
+ die(f"Device {args.device_id} not found on this station.")
+ else:
+ device = _pick_device(devices)
+
+ dev_id = device.get("device_id")
+ log(f"Station: {station.get('name', '?')} Device: {device.get('device_type', '?')} (id={dev_id})")
+
+ obs_data = client.get_observations(dev_id)
+ obs_list = obs_data.get("obs", [])
+ obs_type = obs_data.get("type", "obs_st")
+
+ if not obs_list:
+ emit("No observations available yet.", {"observations": [], "type": obs_type})
+ return
+
+ latest = obs_list[-1] # newest
+ decoded = decode_obs(latest, obs_type)
+
+ if GLOBAL_FLAGS.get("json", False):
+ emit("", {"station": station.get("name", "?"), "device_id": dev_id,
+ "type": obs_type, "observation": decoded})
+ else:
+ print(format_current(decoded))
+
+
+def cmd_obs(args: argparse.Namespace) -> None:
+ """Get historical observations."""
+ if GLOBAL_FLAGS.get("dry_run", False):
+ emit("[dry-run] Would fetch historical observations.",
+ {"dry_run": True, "command": "obs",
+ "device_id": args.device_id, "days": args.days})
+ return
+
+ client = _client_for()
+ obs_data = client.get_observations(args.device_id, days_back=args.days)
+ obs_list = obs_data.get("obs", [])
+ obs_type = obs_data.get("type", "obs_st")
+
+ decoded = [decode_obs(o, obs_type) for o in obs_list]
+
+ if GLOBAL_FLAGS.get("json", False):
+ emit("", {"device_id": args.device_id, "type": obs_type,
+ "count": len(decoded), "observations": decoded})
+ else:
+ print(f"{len(decoded)} observations from the last {args.days} day(s) (type: {obs_type}):")
+ print("")
+ for o in decoded[-3:]:
+ print("---")
+ print(format_current(o))
+ print("")
+
+
+def cmd_forecast(args: argparse.Namespace) -> None:
+ """Get forecast — current conditions + daily + hourly."""
+ if GLOBAL_FLAGS.get("dry_run", False):
+ emit("[dry-run] Would fetch hyper-local forecast for your station.",
+ {"dry_run": True, "command": "forecast",
+ "station_id": args.station_id, "days": args.days})
+ return
+
+ client = _client_for()
+ stations = client.get_stations()
+ if not stations:
+ die("No stations found.")
+
+ if args.station_id:
+ station = next((s for s in stations if s.get("station_id") == args.station_id), None)
+ else:
+ station = stations[0]
+ if not station:
+ die("Station not found.")
+
+ sid = station.get("station_id")
+ name = station.get("name", "?")
+ log(f"Fetching forecast for station '{name}' (id={sid})...\n")
+
+ data = client.get_forecast(sid)
+ fc_data = data.get("forecast", data) # prefer nested "forecast" key, fall back to top-level
+
+ if GLOBAL_FLAGS.get("json", False):
+ emit("", {"station_id": sid, "station_name": name, "forecast": data})
+ return
+
+ # The response honors unit overrides and reports what it used in `units`.
+ # Never assume Celsius/m/s: converting an already-imperial response
+ # double-converts it into absurd values.
+ units = data.get("units", {}) or {}
+ temp_is_f = units.get("units_temp", "c") == "f"
+ wind_unit = units.get("units_wind", "mps")
+
+ def show_temp(value: Optional[float]) -> Optional[float]:
+ if value is None:
+ return None
+ return value if temp_is_f else value * 9 / 5 + 32
+
+ current = data.get("current_conditions", {})
+ if current:
+ print("── Current Conditions ──")
+ icon = current.get("icon", "")
+ cond = current.get("conditions", "")
+ icon_str = f" ({icon})" if icon else ""
+ print(f"Conditions: {cond}{icon_str}")
+ temp = show_temp(current.get("air_temperature"))
+ if temp is not None:
+ feels = show_temp(current.get("feels_like"))
+ if feels is not None:
+ print(f"Temperature: {temp:.0f}°F (feels like {feels:.0f}°F)")
+ else:
+ print(f"Temperature: {temp:.0f}°F")
+ if current.get("relative_humidity") is not None:
+ print(f"Humidity: {current['relative_humidity']}%")
+ if current.get("station_pressure") is not None:
+ print(f"Pressure: {current['station_pressure']} MB")
+ if current.get("wind_avg") is not None:
+ wd = current.get("wind_direction_cardinal", "")
+ print(f"Wind: {current['wind_avg']} {wind_unit} {wd}".rstrip())
+ print()
+
+ daily = fc_data.get("daily", [])
+ if daily:
+ print(f"── {args.days}-Day Forecast ──")
+ for day in daily[: args.days]:
+ day_start = day.get("day_start_local")
+ if isinstance(day_start, (int, float)):
+ day_str = datetime.fromtimestamp(day_start).strftime("%a %b %d")
+ elif day_start:
+ day_str = str(day_start).split("T")[0]
+ else:
+ day_str = "?"
+ hi = show_temp(day.get("air_temp_high"))
+ lo = show_temp(day.get("air_temp_low"))
+ cond = day.get("conditions", "")
+ precip = day.get("precip_probability")
+ precip_str = f" {precip}%" if precip is not None else ""
+ precip_type = day.get("precip_type", "")
+ hi_str = f"{hi:.0f}" if hi is not None else "?"
+ lo_str = f"{lo:.0f}" if lo is not None else "?"
+ print(f" {day_str}: {lo_str}–{hi_str}°F {cond}{precip_str} {precip_type}".strip())
+ print()
+
+ hourly = fc_data.get("hourly", [])
+ if hourly:
+ print("── Next 12 Hours ──")
+ for h in hourly[:12]:
+ local_hour = h.get("local_hour")
+ dt_str = f"{local_hour:02d}:00" if local_hour is not None else "?"
+ temp = show_temp(h.get("air_temperature"))
+ temp_str = f"{temp:.0f}" if temp is not None else "?"
+ cond = h.get("conditions", "")
+ precip = h.get("precip_probability")
+ precip_str = f" {precip}%" if precip is not None else ""
+ print(f" {dt_str}: {temp_str}°F {cond}{precip_str}")
+ if len(hourly) > 12:
+ print(f" ... and {len(hourly) - 12} more hours")
+
+
+# === UDP Commands ===
+
+def udp_listen(args: argparse.Namespace) -> None:
+ """Listen for local UDP broadcasts from the Tempest hub (port 50222).
+
+ Listen-only: the hub broadcasts, nothing is ever sent back. Requires
+ being on the same LAN as the hub — routed connectivity is not enough.
+ """
+ port = args.port
+ timeout = args.timeout
+ show_all = args.show_all
+
+ if GLOBAL_FLAGS.get("dry_run", False):
+ # Dry-run plans the listen instead of opening it: exits 0 without
+ # creating or binding any socket, so it is safe anywhere (no hub
+ # required, no LAN needed, no hanging on a broadcast port).
+ emit("[dry-run] Would listen for Tempest UDP broadcasts on "
+ f"{UDP_BROADCAST_ADDR}:{port} — binds no socket now.",
+ {"dry_run": True, "command": "udp", "subcommand": "listen",
+ "bind_address": UDP_BROADCAST_ADDR, "port": port,
+ "timeout_seconds": timeout, "show_all": show_all})
+ return
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind((UDP_BROADCAST_ADDR, port))
+ sock.settimeout(timeout if timeout > 0 else None)
+
+ log(f"Listening for Tempest UDP broadcasts on port {port}...")
+ if timeout > 0:
+ log(f"Will stop after {timeout}s\n")
+
+ try:
+ start = time.time()
+ while True:
+ try:
+ data, addr = sock.recvfrom(65535)
+ except socket.timeout:
+ log("Listen timeout reached.")
+ break
+
+ for human, payload in handle_datagram(data, show_all=show_all):
+ emit(human, payload)
+
+ if timeout > 0 and time.time() - start > timeout:
+ break
+
+ except KeyboardInterrupt:
+ log("\nStopped.")
+ finally:
+ sock.close()
+
+
+# === Parser ===
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="tempest",
+ description="Hyper-local weather from your Tempest station.",
+ epilog="Global flags can appear anywhere: tempest --json current --device-id X"
+ )
+ sub = parser.add_subparsers(dest="command", help="Available commands")
+
+ sub.add_parser("stations", help="List stations and devices linked to your token")
+
+ p_current = sub.add_parser("current", help="Latest observations from your station")
+ p_current.add_argument("--station-id", type=int, help="Station ID (optional)")
+ p_current.add_argument("--device-id", type=int, help="Device ID (optional)")
+
+ p_obs = sub.add_parser("obs", help="Historical observations")
+ p_obs.add_argument("--device-id", type=int, required=True, help="Device ID")
+ p_obs.add_argument("--days", type=int, default=1, help="Days back (default: 1)")
+
+ p_fcst = sub.add_parser("forecast", help="Hyper-local forecast (current + daily + hourly)")
+ p_fcst.add_argument("--station-id", type=int, help="Station ID (optional)")
+ p_fcst.add_argument("--days", type=int, default=5, help="Days of daily forecast (default: 5)")
+
+ p_udp = sub.add_parser("udp", help="Local UDP broadcast commands (port 50222, listen-only)")
+ udp_sub = p_udp.add_subparsers(dest="udp_command")
+ p_listen = udp_sub.add_parser("listen", help="Listen for local UDP broadcasts from hub")
+ p_listen.add_argument("--port", type=int, default=DEFAULT_UDP_PORT)
+ p_listen.add_argument("--timeout", type=int, default=0, help="Listen N seconds (0=indefinite)")
+ p_listen.add_argument("--show-all", action="store_true",
+ help="Show all message types incl. hub_status/device_status")
+
+ return parser
+
+
+_HANDLERS = {
+ "stations": cmd_stations,
+ "current": cmd_current,
+ "obs": cmd_obs,
+ "forecast": cmd_forecast,
+ "udp": udp_listen,
+}
+
+
+# === Main ===
+
+def main(argv: Optional[List[str]] = None) -> None:
+ global QUIET
+ base_argv = argv if argv is not None else sys.argv
+ GLOBAL_FLAGS.clear()
+ GLOBAL_FLAGS.update({"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False})
+ flags, filtered_argv = _preparse_global_flags(base_argv)
+ GLOBAL_FLAGS.update(flags)
+ QUIET = bool(GLOBAL_FLAGS.get("quiet", False))
+ if GLOBAL_FLAGS.get("json", False):
+ warnings.simplefilter("ignore")
+
+ parser = build_parser()
+ args = parser.parse_args(filtered_argv[1:])
+
+ if not args.command:
+ parser.print_help()
+ sys.exit(1)
+
+ if args.command == "udp":
+ if args.udp_command != "listen":
+ parser.error("udp requires a subcommand: listen")
+ # UDP listening needs no token — the hub broadcast is unauthenticated.
+ udp_listen(args)
+ return
+
+ # REST commands need a token unless this is a dry-run plan.
+ if not resolve_token() and not GLOBAL_FLAGS.get("dry_run", False):
+ die("TEMPEST_TOKEN not set. Get one at https://weatherflow.com "
+ "(Tempest web app -> Settings -> Data Authorizations) or export TEMPEST_TOKEN.")
+
+ _HANDLERS[args.command](args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tempest/scripts/test_tempest.py b/tempest/scripts/test_tempest.py
new file mode 100644
index 0000000..ab778ac
--- /dev/null
+++ b/tempest/scripts/test_tempest.py
@@ -0,0 +1,696 @@
+"""Offline test suite for the bundled tempest CLI.
+
+All HTTP is mocked at the client seam (TempestClient._get is replaced by a
+FakeTransport that records paths/params and returns canned REST documents),
+and UDP paths are tested by feeding CANNED DATAGRAM BYTES to the pure
+handle_datagram()/decode_message() decoders — no socket is ever created or
+bound (the suite never touches socket.socket). The suite is fully offline and
+passes the proxy-trap rerun. Tempest is a keyed API, so there are deliberately
+NO live-call test cases (the AGENTS.md network policy is mock-everything for
+keyed APIs).
+
+Covers the four contract behavior classes: --help output, argument-error
+paths, --dry-run plans, and mocked-client logic — plus the documented
+multi-step pipelines (stations -> current, stations -> forecast with unit
+conversion, obs day history) and every UDP message family (obs_st UDP 18
+positions vs REST 22, obs_air, obs_sky, rapid_wind's single "ob" array,
+evt_precip/evt_strike's single "evt" arrays, hub_status/device_status named
+fields) dispatched by type.
+"""
+
+import contextlib
+import importlib.machinery
+import importlib.util
+import io
+import json
+import pathlib
+import sys
+import unittest
+from unittest.mock import patch
+
+SCRIPT = pathlib.Path(__file__).resolve().parent / "tempest"
+LOADER = importlib.machinery.SourceFileLoader("tempest_cli", str(SCRIPT))
+SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER)
+ts = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = ts # so unittest.mock.patch("tempest_cli....") resolves
+LOADER.exec_module(ts)
+
+# ---------------------------------------------------------------------------
+# Canned UDP datagrams (bytes, exactly as the hub broadcasts them).
+# obs_st uses the documented 18-position UDP record; REST returns 22.
+# ---------------------------------------------------------------------------
+
+OBS_ST_DATAGRAM = (
+ b'{"serial_number":"ST-00000512","type":"obs_st","hub_sn":"HB-00013030",'
+ b'"obs":[[1588948614,0.18,0.22,0.27,144,6,1017.57,22.37,50.26,328,0.03,3,'
+ b'0.0,0,0,0,2.410,1]],"firmware_revision":129}'
+)
+RAPID_WIND_DATAGRAM = (
+ b'{"serial_number":"SK-00008453","type":"rapid_wind","hub_sn":"HB-00000001",'
+ b'"ob":[1493322445,2.3,128]}'
+)
+EVT_PRECIP_DATAGRAM = (
+ b'{"serial_number":"SK-00008453","type":"evt_precip","hub_sn":"HB-00000001",'
+ b'"evt":[1493322445]}'
+)
+EVT_STRIKE_DATAGRAM = (
+ b'{"serial_number":"AR-00004049","type":"evt_strike","hub_sn":"HB-00000001",'
+ b'"evt":[1493322445,27,3848]}'
+)
+HUB_STATUS_DATAGRAM = (
+ b'{"serial_number":"HB-00000001","type":"hub_status","firmware_revision":"35",'
+ b'"uptime":1670133,"rssi":-62,"timestamp":1495724691,"reset_flags":"BOR,PIN,POR",'
+ b'"seq":48,"fs":[1,0,15675411,524288],"radio_stats":[2,1,0,3,2839],"mqtt_stats":[1,0]}'
+)
+DEVICE_STATUS_DATAGRAM = (
+ b'{"serial_number":"AR-00004049","type":"device_status","hub_sn":"HB-00000001",'
+ b'"timestamp":1510855923,"uptime":2189,"voltage":3.50,"firmware_revision":17,'
+ b'"rssi":-17,"hub_rssi":-87,"sensor_status":0,"debug":0}'
+)
+OBS_AIR_DATAGRAM = (
+ b'{"serial_number":"AR-00004049","type":"obs_air","hub_sn":"HB-00000001",'
+ b'"obs":[[1493164835,835.0,10.0,45,0,0,3.46,1]],"firmware_revision":17}'
+)
+OBS_SKY_DATAGRAM = (
+ b'{"serial_number":"SK-00008453","type":"obs_sky","hub_sn":"HB-00000001",'
+ b'"obs":[[1493321340,9000,10,0.0,2.6,4.6,7.4,187,3.12,1,130,null,0,3]],'
+ b'"firmware_revision":29}'
+)
+GARBAGE_DATAGRAM = b"\x00\x01not-json-at-all"
+
+
+def run_main(argv):
+ """Run the CLI main() with patched stdout; returns (exit_code, stdout).
+
+ SystemExit is caught and converted to a code so error paths can assert
+ on exit codes without exception plumbing.
+ """
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
+ try:
+ ts.main(["tempest"] + argv)
+ code = 0
+ except SystemExit as exc:
+ code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
+ return code, out.getvalue()
+
+
+def run_main_err(argv):
+ """Like run_main but also captures stderr: (exit_code, stdout, stderr)."""
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ try:
+ ts.main(["tempest"] + argv)
+ code = 0
+ except SystemExit as exc:
+ code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
+ return code, out.getvalue(), err.getvalue()
+
+
+# ---------------------------------------------------------------------------
+# Fake REST transport: records requests, replays canned documents
+# ---------------------------------------------------------------------------
+
+STATION_DOC = {
+ "status": {"status_code": 0, "status_message": "SUCCESS"},
+ "stations": [{
+ "station_id": 12799, "name": "Home", "public_name": "Home",
+ "latitude": 42.37, "longitude": -71.06,
+ "timezone": "America/New_York", "timezone_offset_minutes": -300,
+ "station_meta": {"elevation": 1567.65, "share_with_wf": True, "share_with_wu": True},
+ "is_local_mode": False,
+ "devices": [
+ {"device_id": 60526, "serial_number": "ST-00012345", "device_type": "ST",
+ "hardware_revision": "3", "firmware_revision": "165",
+ "device_meta": {"agl": 2.2, "name": "Backyard", "environment": "outdoor"}},
+ {"device_id": 60500, "serial_number": "HB-00000001", "device_type": "HB",
+ "hardware_revision": "3", "firmware_revision": "35",
+ "device_meta": {"name": "Hub"}},
+ {"device_id": 60599, "serial_number": None, "device_type": "SK",
+ "hardware_revision": "2", "firmware_revision": "29",
+ "device_meta": {"name": "Old Sky"}},
+ ],
+ "station_items": [],
+ }],
+}
+
+
+class FakeTransport:
+ """Replaces TempestClient._get; records every request, replays canned docs."""
+
+ def __init__(self, responses=None):
+ self.requests = []
+ self.responses = responses or {}
+
+ def __call__(self, path, params=None):
+ self.requests.append({"path": path, "params": dict(params or {})})
+ if path in self.responses:
+ return self.responses[path]
+ if path.startswith("/observations/device/"):
+ return {"obs": [OBS_ROW_ST], "type": "obs_st"}
+ if path == "/better_forecast":
+ return FORECAST_DOC
+ if path == "/stations":
+ return STATION_DOC
+ raise AssertionError(f"unexpected path {path}")
+
+
+# Canned REST documents
+OBS_ROW_ST = [1650843455, 0.18, 0.22, 0.27, 144, 6, 1017.57, 22.37, 50.26, 328,
+ 0.03, 3, 0.0, 0, 0, 0, 2.410, 1, 5.2, 4.8, 5.2, 1]
+FORECAST_DOC = {
+ "status": {"status_code": 0, "status_message": "SUCCESS"},
+ "current_conditions": {"air_temperature": 18.2, "conditions": "Mostly Clear",
+ "icon": "partly-cloudy-day", "relative_humidity": 61,
+ "station_pressure": 1015.4, "wind_avg": 2.1,
+ "wind_direction": 225, "wind_direction_cardinal": "SW",
+ "feels_like": 18.2},
+ "forecast": {
+ "daily": [
+ {"day_start_local": 1778385600, "air_temp_high": 25.4, "air_temp_low": 15.1,
+ "conditions": "Partly cloudy", "precip_probability": 10, "precip_type": "rain",
+ "sunrise": 1778378400, "sunset": 1778425200},
+ {"day_start_local": 1778472000, "air_temp_high": 22.0, "air_temp_low": 12.0,
+ "conditions": "Rainy", "precip_probability": 80, "precip_type": "rain"},
+ ],
+ "hourly": [
+ {"time": 1778388000, "local_hour": 10, "local_day": 10, "air_temperature": 19.8,
+ "precip_probability": 5, "conditions": "Sunny"},
+ {"time": 1778391600, "local_hour": 11, "local_day": 10, "air_temperature": 20.4,
+ "precip_probability": 45, "conditions": "Cloudy"},
+ ],
+ },
+ "units": {"units_temp": "c", "units_wind": "mps", "units_precip": "mm",
+ "units_pressure": "mb", "units_distance": "km"},
+ "latitude": 42.37, "longitude": -71.06,
+ "timezone": "America/New_York", "timezone_offset_minutes": -300,
+}
+FORECAST_DOC_F = json.loads(json.dumps(FORECAST_DOC))
+FORECAST_DOC_F["units"] = {"units_temp": "f", "units_wind": "mph", "units_precip": "in",
+ "units_pressure": "inhg", "units_distance": "mi"}
+FORECAST_DOC_F["current_conditions"]["air_temperature"] = 64.8
+FORECAST_DOC_F["forecast"]["daily"][0]["air_temp_high"] = 77.7
+
+STATIONS_ONLY = {"/stations": STATION_DOC}
+
+
+def patch_token(token="tok-test"):
+ return patch.object(ts, "resolve_token", return_value=token)
+
+
+class CliTestCase(unittest.TestCase):
+ """Base: fresh GLOBAL_FLAGS per test, stdout captured via run_main."""
+
+ def setUp(self):
+ ts.GLOBAL_FLAGS.clear()
+ ts.GLOBAL_FLAGS.update(
+ {"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False})
+ ts.QUIET = False
+
+
+# ---------------------------------------------------------------------------
+# Class 1: --help output
+# ---------------------------------------------------------------------------
+
+class HelpTests(CliTestCase):
+ def test_help_lists_all_subcommands(self):
+ code, out = run_main(["--help"])
+ self.assertEqual(code, 0)
+ for noun in ("stations", "current", "obs", "forecast", "udp"):
+ self.assertIn(noun, out)
+
+ def test_udp_help_documents_listen(self):
+ code, out = run_main(["udp", "--help"])
+ self.assertEqual(code, 0)
+ self.assertIn("listen", out)
+ self.assertIn("50222", out + ts.build_parser().format_help())
+
+ def test_forecast_help_shows_flags(self):
+ code, out = run_main(["forecast", "--help"])
+ self.assertEqual(code, 0)
+ self.assertIn("--station-id", out)
+ self.assertIn("--days", out)
+
+ def test_main_help_epilog_documents_global_flag_positions(self):
+ code, out = run_main(["--help"])
+ self.assertEqual(code, 0)
+ self.assertIn("anywhere", out)
+
+
+# ---------------------------------------------------------------------------
+# Class 2: argument-error paths
+# ---------------------------------------------------------------------------
+
+class ArgumentErrorsTests(CliTestCase):
+ def test_no_command_prints_help_and_exits_1(self):
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ with self.assertRaises(SystemExit) as ctx:
+ ts.main(["tempest"])
+ self.assertEqual(ctx.exception.code, 1)
+ self.assertIn("usage", out.getvalue())
+
+ def test_udp_without_subcommand_is_an_error(self):
+ code, _, err = run_main_err(["udp"])
+ self.assertEqual(code, 2)
+ self.assertIn("udp requires a subcommand", err)
+
+ def test_obs_requires_device_id(self):
+ code, _, err = run_main_err(["obs"])
+ self.assertEqual(code, 2)
+ self.assertIn("--device-id", err)
+
+ def test_missing_token_dies_with_guidance(self):
+ with patch_token(""):
+ code, _, err = run_main_err(["stations"])
+ self.assertEqual(code, 1)
+ self.assertIn("TEMPEST_TOKEN not set", err)
+
+ def test_missing_token_is_fine_for_dry_run(self):
+ with patch_token(""):
+ code, out = run_main(["stations", "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ self.assertEqual(json.loads(out)["dry_run"], True)
+
+ def test_unknown_station_id_exits_1(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, _, err = run_main_err(["current", "--station-id", "99999999"])
+ self.assertEqual(code, 1)
+ self.assertIn("99999999 not found", err)
+
+
+# ---------------------------------------------------------------------------
+# Class 3: --dry-run behavior (plans are JSON, exit 0, zero network)
+# ---------------------------------------------------------------------------
+
+class DryRunTests(CliTestCase):
+ def test_current_dry_run_plan_shape(self):
+ code, out = run_main(["current", "--station-id", "12799", "--device-id", "60526",
+ "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ self.assertEqual(plan["dry_run"], True)
+ self.assertEqual(plan["command"], "current")
+ self.assertEqual(plan["station_id"], 12799)
+ self.assertEqual(plan["device_id"], 60526)
+
+ def test_forecast_dry_run_plan_shape(self):
+ code, out = run_main(["forecast", "--station-id", "12799", "--days", "3",
+ "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ self.assertEqual(plan["command"], "forecast")
+ self.assertEqual(plan["days"], 3)
+
+ def test_obs_dry_run_plan_shape(self):
+ code, out = run_main(["obs", "--device-id", "60526", "--days", "2", "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ self.assertEqual(plan["command"], "obs")
+ self.assertEqual(plan["device_id"], 60526)
+ self.assertEqual(plan["days"], 2)
+
+ def test_stations_dry_run_plan_shape(self):
+ code, out = run_main(["stations", "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ self.assertEqual(json.loads(out)["command"], "stations")
+
+ def test_udp_listen_dry_run_plan_shape(self):
+ # VAL-TEMP-011: udp listen honors --dry-run — a plan JSON, exit 0,
+ # and (pinned by the socket patch below) NO socket is ever created
+ # or bound, so the dry run cannot hang waiting for hub traffic.
+ code, out = run_main(["udp", "listen", "--port", "50222",
+ "--timeout", "30", "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ self.assertEqual(plan["dry_run"], True)
+ self.assertEqual(plan["command"], "udp")
+ self.assertEqual(plan["subcommand"], "listen")
+ self.assertEqual(plan["bind_address"], ts.UDP_BROADCAST_ADDR)
+ self.assertEqual(plan["port"], 50222)
+ self.assertEqual(plan["timeout_seconds"], 30)
+ self.assertEqual(plan["show_all"], False)
+
+ def test_udp_listen_dry_run_defaults_and_show_all(self):
+ # Defaults land in the plan; --show-all propagates.
+ code, out = run_main(["udp", "listen", "--show-all", "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ plan = json.loads(out)
+ self.assertEqual(plan["port"], ts.DEFAULT_UDP_PORT)
+ self.assertEqual(plan["timeout_seconds"], 0)
+ self.assertEqual(plan["show_all"], True)
+
+ def test_udp_listen_dry_run_creates_no_socket(self):
+ # Prove the "binds no socket" half of the contract: if udp_listen
+ # reached its listen path, socket.socket() would be constructed and
+ # this fake's bind() would blow up the test.
+ bound = []
+
+ class NoBindSock:
+ def bind(self, *a, **k):
+ bound.append(a)
+ raise AssertionError("dry-run udp listen must not bind a socket")
+
+ with patch.object(ts.socket, "socket", side_effect=AssertionError(
+ "dry-run udp listen must not create a socket")):
+ code, out = run_main(["udp", "listen", "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ self.assertEqual(bound, [])
+ self.assertEqual(json.loads(out)["dry_run"], True)
+
+ def test_udp_listen_dry_run_without_token_is_fine(self):
+ # UDP needs no token, and the dry run must not demand one either.
+ with patch_token(""):
+ code, out = run_main(["udp", "listen", "--dry-run", "--json"])
+ self.assertEqual(code, 0)
+ self.assertEqual(json.loads(out)["command"], "udp")
+
+
+# ---------------------------------------------------------------------------
+# Class 4: mocked REST client logic (no real network anywhere)
+# ---------------------------------------------------------------------------
+
+class RestClientTests(CliTestCase):
+ def test_stations_json_unwraps_stationset_wrapper(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, out = run_main(["stations", "--json"])
+ self.assertEqual(code, 0)
+ doc = json.loads(out)
+ self.assertEqual(doc["stations"][0]["station_id"], 12799)
+ self.assertEqual(len(doc["stations"][0]["devices"]), 3)
+
+ def test_current_pipeline_stations_then_latest_observation(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, out = run_main(["current", "--json"])
+ self.assertEqual(code, 0)
+ doc = json.loads(out)
+ # auto-selection picked the ST device and skipped the HB hub
+ self.assertEqual(doc["device_id"], 60526)
+ self.assertEqual(doc["type"], "obs_st")
+ obs = doc["observation"]
+ self.assertIsInstance(obs["air_temperature"], (int, float))
+ self.assertEqual(obs["air_temperature"], 22.37)
+ self.assertEqual(obs["air_temperature_unit"], "C")
+ self.assertEqual(fake.requests[-1]["path"], "/observations/device/60526")
+ # latest-only mode sends no day_offset / time range
+ self.assertNotIn("day_offset", fake.requests[-1]["params"])
+
+ def test_current_positional_flag_consumption_from_handler_argv(self):
+ # handler-owns-flags dispatch: "--device-id 60526" after "current"
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, out = run_main(["current", "--device-id", "60526", "--json"])
+ self.assertEqual(code, 0)
+ self.assertEqual(json.loads(out)["device_id"], 60526)
+
+ def test_obs_pipeline_requests_day_offset_and_decodes_rows(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, out = run_main(["obs", "--device-id", "60526", "--days", "2", "--json"])
+ self.assertEqual(code, 0)
+ doc = json.loads(out)
+ self.assertEqual(doc["count"], 1)
+ self.assertEqual(doc["observations"][0]["local_day_rain_accumulation"], 5.2)
+ self.assertEqual(fake.requests[-1]["params"]["day_offset"], 2)
+
+ def test_forecast_pipeline_reads_nested_forecast_key(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, out = run_main(["forecast", "--json"])
+ self.assertEqual(code, 0)
+ doc = json.loads(out)
+ self.assertEqual(doc["station_id"], 12799)
+ daily = doc["forecast"]["forecast"]["daily"]
+ self.assertEqual(daily[0]["air_temp_high"], 25.4)
+ self.assertEqual(doc["forecast"]["units"]["units_temp"], "c")
+
+ def test_forecast_human_output_celsius_station_converts_to_f(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, out = run_main(["forecast"])
+ self.assertEqual(code, 0)
+ # 25.4C * 9/5 + 32 = 77.72 -> displayed as 78 with :.0f
+ self.assertIn("78", out)
+ # hourly local_hour rendered HH:00
+ self.assertIn("10:00", out)
+
+ def test_forecast_human_output_fahrenheit_station_not_double_converted(self):
+ fake = FakeTransport({**STATIONS_ONLY, "/better_forecast": FORECAST_DOC_F})
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ code, out = run_main(["forecast"])
+ self.assertEqual(code, 0)
+ # units_temp=f: 77.7 stays 77.7 -> displayed 78; a double conversion
+ # would render 172 (77.7*9/5+32), which must not appear.
+ self.assertIn("78", out)
+ self.assertNotIn("172", out)
+
+ def test_client_sends_token_as_query_parameter(self):
+ # Documented auth: token travels as a query parameter (apiKey in:query),
+ # never as a header. Verified at the requests.get seam.
+ class FakeResp:
+ status_code = 200
+ text = ""
+ def json(self):
+ return STATION_DOC
+ recorded = {}
+
+ def fake_get(url, params=None, timeout=None):
+ recorded["url"] = url
+ recorded["params"] = params
+ return FakeResp()
+
+ with patch.object(ts.requests, "get", side_effect=fake_get):
+ ts.TempestClient(token="tok-query").get_stations()
+ self.assertEqual(recorded["params"]["token"], "tok-query")
+ self.assertIn("/stations", recorded["url"])
+ self.assertIn("swd.weatherflow.com", recorded["url"])
+
+ def test_client_401_message_names_token(self):
+ class Err401:
+ status_code = 401
+ text = ""
+ with patch.object(ts.requests, "get", return_value=Err401()):
+ client = ts.TempestClient(token="bad")
+ with contextlib.redirect_stderr(io.StringIO()) as err:
+ with self.assertRaises(SystemExit) as ctx:
+ client._get("/stations")
+ self.assertEqual(ctx.exception.code, 1)
+ self.assertIn("401", err.getvalue())
+
+ def test_env_file_fallback_token(self):
+ with patch.dict("os.environ", {"TEMPEST_TOKEN": ""}), \
+ patch.object(ts, "ENV_FILE", "/nonexistent/.tempest.env"):
+ self.assertEqual(ts.resolve_token(), "")
+ with patch.dict("os.environ", {"TEMPEST_TOKEN": " "}), \
+ patch.object(ts, "ENV_FILE", "/nonexistent/.tempest.env"):
+ self.assertEqual(ts.resolve_token(), "")
+
+
+# ---------------------------------------------------------------------------
+# UDP decoding from canned datagram bytes — no sockets, no binds
+# ---------------------------------------------------------------------------
+
+class UdpDecoderTests(CliTestCase):
+ def test_obs_st_datagram_decodes_all_18_udp_positions(self):
+ results = ts.handle_datagram(OBS_ST_DATAGRAM)
+ self.assertEqual(len(results), 1)
+ _, payload = results[0]
+ self.assertEqual(payload["type"], "obs_st")
+ self.assertEqual(payload["serial_number"], "ST-00000512")
+ obs = payload["observation"]
+ self.assertEqual(obs["epoch"], 1588948614)
+ self.assertEqual(obs["wind_avg"], 0.22) # index 2
+ self.assertEqual(obs["wind_direction"], 144) # index 4
+ self.assertEqual(obs["station_pressure"], 1017.57) # index 6 (MB)
+ self.assertEqual(obs["air_temperature"], 22.37) # index 7 (C)
+ self.assertEqual(obs["rain_accumulation"], 0.0) # index 12 (mm)
+ self.assertEqual(obs["battery"], 2.410) # index 16
+ self.assertEqual(obs["report_interval"], 1) # index 17 (last UDP position)
+ # UDP record ends at index 17: REST-only Nearcast fields decode as None
+ self.assertIsNone(obs["nc_rain_accumulation"])
+ self.assertIsNone(obs["precip_analysis_type"])
+ # metric-native units preserved on the payload
+ self.assertEqual(obs["wind_avg_unit"], "m/s")
+ self.assertEqual(obs["air_temperature_unit"], "C")
+ self.assertEqual(obs["rain_accumulation_unit"], "mm")
+
+ def test_decode_obs_handles_full_rest_22_position_row(self):
+ decoded = ts.decode_obs(OBS_ROW_ST, "obs_st")
+ self.assertEqual(decoded["local_day_rain_accumulation"], 5.2)
+ self.assertEqual(decoded["nc_rain_accumulation"], 4.8)
+ self.assertEqual(decoded["precip_analysis_type"], 1)
+
+ def test_decode_obs_tolerates_short_rows_with_none(self):
+ decoded = ts.decode_obs([1588948614, 0.18, 0.22], "obs_st")
+ self.assertEqual(decoded["wind_avg"], 0.22)
+ self.assertIsNone(decoded["air_temperature"])
+ self.assertIsNone(decoded["battery"])
+
+ def test_rapid_wind_single_ob_array_not_iterated_elementwise(self):
+ # Regression: the old handler iterated msg["ob"] like an obs row list
+ # (TypeError: unsupported operand type(s) for -: 'int' and 'str'-style
+ # crash on the epoch number). rapid_wind carries ONE array under "ob".
+ results = ts.handle_datagram(RAPID_WIND_DATAGRAM)
+ self.assertEqual(len(results), 1)
+ _, payload = results[0]
+ self.assertEqual(payload["type"], "rapid_wind")
+ self.assertEqual(payload["wind_speed_mps"], 2.3)
+ self.assertEqual(payload["wind_direction"], 128)
+ self.assertIsNotNone(payload["timestamp"])
+
+ def test_evt_precip_single_evt_array(self):
+ results = ts.handle_datagram(EVT_PRECIP_DATAGRAM)
+ self.assertEqual(len(results), 1)
+ _, payload = results[0]
+ self.assertEqual(payload["type"], "evt_precip")
+ self.assertIsNotNone(payload["timestamp"])
+
+ def test_evt_strike_distance_and_energy(self):
+ results = ts.handle_datagram(EVT_STRIKE_DATAGRAM)
+ _, payload = results[0]
+ self.assertEqual(payload["type"], "evt_strike")
+ self.assertEqual(payload["distance_km"], 27)
+ self.assertEqual(payload["energy"], 3848)
+
+ def test_hub_status_named_fields_dispatch(self):
+ # hub_status carries named fields (no payload array). The old handler
+ # printed msg["freq"], which does not exist in the current protocol.
+ results = ts.handle_datagram(HUB_STATUS_DATAGRAM, show_all=True)
+ self.assertEqual(len(results), 1)
+ _, payload = results[0]
+ self.assertEqual(payload["type"], "hub_status")
+ self.assertEqual(payload["serial_number"], "HB-00000001")
+ self.assertEqual(payload["uptime"], 1670133)
+ self.assertEqual(payload["reset_flags"], "BOR,PIN,POR")
+ self.assertEqual(payload["radio_stats"], [2, 1, 0, 3, 2839])
+
+ def test_hub_status_hidden_by_default(self):
+ self.assertEqual(ts.handle_datagram(HUB_STATUS_DATAGRAM), [])
+
+ def test_device_status_named_fields(self):
+ results = ts.handle_datagram(DEVICE_STATUS_DATAGRAM, show_all=True)
+ _, payload = results[0]
+ self.assertEqual(payload["type"], "device_status")
+ self.assertEqual(payload["voltage"], 3.50)
+ self.assertEqual(payload["sensor_status"], 0)
+
+ def test_obs_air_and_obs_sky_dispatch(self):
+ air = ts.handle_datagram(OBS_AIR_DATAGRAM)[0][1]
+ self.assertEqual(air["observation"]["station_pressure"], 835.0)
+ self.assertEqual(air["observation"]["air_temperature"], 10.0)
+ sky = ts.handle_datagram(OBS_SKY_DATAGRAM)[0][1]
+ self.assertEqual(sky["observation"]["illuminance"], 9000)
+ self.assertIsNone(sky["observation"]["local_day_rain_accumulation"]) # null over UDP
+
+ def test_garbage_datagram_returns_no_results(self):
+ self.assertEqual(ts.handle_datagram(GARBAGE_DATAGRAM), [])
+ # show_all surfaces a raw preview instead of crashing
+ results = ts.handle_datagram(GARBAGE_DATAGRAM, show_all=True)
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0][1]["type"], "unparseable")
+
+ def test_unknown_type_ignored_by_default_and_listed_with_show_all(self):
+ weird = b'{"type":"something_new","serial_number":"XX-1"}'
+ self.assertEqual(ts.handle_datagram(weird), [])
+ results = ts.handle_datagram(weird, show_all=True)
+ self.assertEqual(results[0][1]["type"], "something_new")
+
+ def test_decode_message_dispatches_on_type_before_indexing(self):
+ # non-obs families must never be routed into the obs positional decoder
+ self.assertEqual(ts.decode_message({"type": "rapid_wind", "ob": [1, 2.3, 128]})[0][1]["wind_speed_mps"], 2.3)
+ self.assertEqual(ts.decode_message({"type": "evt_precip", "evt": [1493322445]})[0][1]["type"], "evt_precip")
+ self.assertEqual(ts.decode_message({"type": "hub_status", "uptime": 5, "seq": 1}, show_all=True)[0][1]["type"], "hub_status")
+
+ def test_listen_handler_consumes_canned_datagrams_without_sockets(self):
+ # udp_listen's socket is fully mocked: canned datagram BYTES are fed
+ # to the decoder through a fake recvfrom, so the suite never creates
+ # or binds a real socket anywhere.
+ canned = [OBS_ST_DATAGRAM, RAPID_WIND_DATAGRAM, EVT_PRECIP_DATAGRAM]
+ fake_sock = unittest.mock.MagicMock()
+ fake_sock.recvfrom.side_effect = [
+ (canned[0], ("127.0.0.1", 50222)),
+ (canned[1], ("127.0.0.1", 50222)),
+ (canned[2], ("127.0.0.1", 50222)),
+ ts.socket.timeout("stop"),
+ ]
+ out = io.StringIO()
+ args = type("A", (), {"port": 50222, "timeout": 1, "show_all": False})()
+ with contextlib.redirect_stdout(out):
+ with patch.object(ts.socket, "socket", return_value=fake_sock):
+ ts.udp_listen(args)
+ text = out.getvalue()
+ self.assertIn("ST-00000512", text)
+ self.assertIn("Rapid Wind", text)
+ self.assertIn("Rain started", text)
+ fake_sock.close.assert_called_once()
+
+ def test_listen_json_stream_carries_family_payloads(self):
+ fake_sock = unittest.mock.MagicMock()
+ fake_sock.recvfrom.side_effect = [
+ (RAPID_WIND_DATAGRAM, ("127.0.0.1", 50222)),
+ ts.socket.timeout("stop"),
+ ]
+ ts.GLOBAL_FLAGS["json"] = True
+ out = io.StringIO()
+ args = type("A", (), {"port": 50222, "timeout": 1, "show_all": False})()
+ with contextlib.redirect_stdout(out):
+ with patch.object(ts.socket, "socket", return_value=fake_sock):
+ ts.udp_listen(args)
+ doc = json.loads(out.getvalue().strip().splitlines()[-1])
+ self.assertEqual(doc["type"], "rapid_wind")
+ self.assertEqual(doc["wind_speed_mps"], 2.3)
+ fake_sock.close.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# Documented pipeline wiring: each stage's output feeds the next
+# ---------------------------------------------------------------------------
+
+class PipelineTests(CliTestCase):
+ def test_station_ids_from_stations_feed_current(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ _, stations_out = run_main(["stations", "--json"])
+ sid = json.loads(stations_out)["stations"][0]["station_id"]
+ did = next(d["device_id"] for d in
+ json.loads(stations_out)["stations"][0]["devices"]
+ if d["device_type"] == "ST")
+ self.assertIsInstance(sid, int)
+ self.assertIsInstance(did, int)
+ _, current_out = run_main(["current", "--station-id", str(sid),
+ "--device-id", str(did), "--json"])
+ doc = json.loads(current_out)
+ self.assertEqual(doc["device_id"], did)
+ # observation dict carries metric-native numeric types for jq math
+ self.assertIsInstance(doc["observation"]["air_temperature"], float)
+ self.assertIsInstance(doc["observation"]["rain_accumulation"], (int, float))
+
+ def test_rain_watch_pipeline_obs_day_total_then_evt_precip_stream(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ _, obs_out = run_main(["obs", "--device-id", "60526", "--days", "1", "--json"])
+ doc = json.loads(obs_out)
+ self.assertEqual(doc["type"], "obs_st")
+ total = doc["observations"][-1]["local_day_rain_accumulation"]
+ self.assertEqual(total, 5.2)
+ # live half: evt_precip datagram decodes with a timestamp for the stream
+ _, payload = ts.handle_datagram(EVT_PRECIP_DATAGRAM)[0]
+ self.assertEqual(payload["type"], "evt_precip")
+ self.assertIsNotNone(payload["timestamp"])
+
+ def test_forecast_json_fields_are_jq_addressable(self):
+ fake = FakeTransport(STATIONS_ONLY)
+ with patch_token(), patch.object(ts.TempestClient, "_get", fake):
+ _, out = run_main(["forecast", "--json"])
+ doc = json.loads(out)
+ # documented nesting: .forecast.forecast.daily / .forecast.units.units_temp
+ self.assertEqual(doc["forecast"]["units"]["units_temp"], "c")
+ self.assertEqual(doc["forecast"]["forecast"]["hourly"][0]["local_hour"], 10)
+ self.assertIsInstance(doc["forecast"]["forecast"]["daily"][0]["air_temp_high"], (int, float))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tmdb-cli/README.md b/tmdb-cli/README.md
deleted file mode 100644
index 68d3b3b..0000000
--- a/tmdb-cli/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# TMDb — Movie & TV Discovery from the Terminal
-
-Search movies and TV shows by keyword, discover by genre/certification/rating/date, check trending and upcoming releases, and browse genre lists.
-
-## Why Install This Skill
-
-When your agent loads this skill, it can **access the entire TMDb catalog** without a browser. That means:
-
-- **Search movies and TV** — by keyword with release year and ratings
-- **Discover by taste** — genre, certification, rating threshold, date range
-- **Find trending content** — what's popular right now
-- **Check upcoming releases** — what's coming to theaters
-- **Browse certifications** — US ratings (G, PG, PG-13, R, NC-17)
-
-## What You Get
-
-| Directory | Purpose |
-|-----------|---------|
-| `SKILL.md` | Complete command reference with compound filter examples |
-| `scripts/tmdb-cli` | CLI tool for TMDb v3 API |
-
-## Quick Start
-
-```bash
-export TMDB_ACCESS_TOKEN="your-tmdb-access-token"
-tmdb-cli movie search --term "dune"
-tmdb-cli movie discover --genre horror --certification R
-```
-
-## Triggers
-
-Load this for movies, TV shows, film discovery, genre browsing, or media recommendations.
-
-## Requirements
-
-Python 3.8+ with `requests` library. Free API key from themoviedb.org.
diff --git a/tmdb-cli/SKILL.md b/tmdb-cli/SKILL.md
deleted file mode 100644
index d853fbb..0000000
--- a/tmdb-cli/SKILL.md
+++ /dev/null
@@ -1,129 +0,0 @@
----
-name: tmdb-cli
-description: Search and discover movies, TV shows, and trending content via The Movie
- Database (TMDb) API v3. Use when the user asks about movies, TV, film, cinema, genres,
- certifications, ratings, cast, upcoming releases, or trending media.
-license: MIT
-compatibility: Requires TMDB_ACCESS_TOKEN or TMDB_API_KEY env var (free at themoviedb.org/settings/api),
- Python 3.8+, and the `requests` library.
-metadata:
- tags: tmdb, movies, tv, film, cinema, entertainment, media-discovery, api-client
- sources: https://developer.themoviedb.org/reference, https://www.themoviedb.org/settings/api
----
-
-# tmdb-cli — Movie & TV Discovery from the Terminal
-
-Search movies and TV shows by keyword, discover by genre/certification/rating/date, check trending and upcoming releases, browse genre lists, and view US certification ratings — all from TMDb's v3 API.
-
-## Setup
-
-1. Get a free API key or access token at [themoviedb.org/settings/api](https://www.themoviedb.org/settings/api)
-2. Set one of these environment variables:
-
-```bash
-export TMDB_ACCESS_TOKEN="your-tmdb-access-token" # preferred
-# OR
-export TMDB_API_KEY="your-tmdb-api-key"
-```
-
-`--help` and `--dry-run` work without credentials (lazy auth).
-
-## Essential Commands
-
-### movie search — Search movies by keyword
-
-```bash
-tmdb-cli movie search --term "dune" # basic search
-tmdb-cli movie search --term "inception" --limit 5 # top 5 results
-tmdb-cli movie search --term "arrival" --json # machine-readable
-```
-
-Shows: title, release year, vote average.
-
-### movie discover — Discover movies by genre, certification, rating, and date
-
-```bash
-tmdb-cli movie discover --genre horror # horror movies
-tmdb-cli movie discover --genre horror --certification R # horror, R-rated
-tmdb-cli movie discover --genre comedy --rating 7 --limit 15 # highly-rated comedy
-tmdb-cli movie discover --from 2024-01-01 --to 2024-12-31 # released in 2024
-tmdb-cli movie discover --genre scifi --from 2026-05-01 # recent sci-fi
-tmdb-cli movie discover --genre thriller --certification R \
- --rating 6 --from 2025-01-01 --limit 20 # compound filter
-```
-
-### movie upcoming — Upcoming movie releases
-
-```bash
-tmdb-cli movie upcoming # next 10 upcoming
-tmdb-cli movie upcoming --limit 20 # more results
-tmdb-cli movie upcoming --json # machine-readable
-```
-
-### tv search — Search TV shows by keyword
-
-```bash
-tmdb-cli tv search --term "severance" # basic TV search
-tmdb-cli tv search --term "the expanse" --limit 5
-tmdb-cli tv search --term "silo" --json
-```
-
-Shows: name, first air year, vote average.
-
-### tv discover — Discover TV shows by genre, rating, and air date
-
-```bash
-tmdb-cli tv discover --genre sci-fi # sci-fi shows
-tmdb-cli tv discover --genre drama --rating 7 # critically-acclaimed drama
-tmdb-cli tv discover --genre comedy --from 2025-01-01 # recent comedy
-```
-
-### trending — Trending content across day or week
-
-```bash
-tmdb-cli trending # trending movies this week
-tmdb-cli trending --type tv # trending TV this week
-tmdb-cli trending --type all --window day # all media trending today
-tmdb-cli trending --limit 20 --json # top 20 as JSON
-```
-
-### genre list — Browse available genres
-
-```bash
-tmdb-cli genre list --type movie # all movie genres
-tmdb-cli genre list --type tv # all TV genres
-tmdb-cli genre list --type movie --json
-```
-
-### certification — View US movie certification ratings
-
-```bash
-tmdb-cli certification # US certification list
-tmdb-cli certification --json # machine-readable
-```
-
-## Global Flags
-
-These flags work in any position before, between, or after subcommands:
-
-```bash
-tmdb-cli --json movie search --term "dune" # JSON output
-tmdb-cli movie search --term "dune" --json # json after subcommand
-tmdb-cli --dry-run movie discover --genre horror # preview without API call
-tmdb-cli --quiet trending # suppress diagnostic output
-tmdb-cli --verbose movie search --term "alien" # verbose logging
-```
-
-## Known Gotchas
-
-- **Genre name matching is case-insensitive** — `--genre Horror`, `--genre horror`, and `--genre HORROR` all work. Names are matched via substring, so `--genre sci` matches "Sci-Fi" and "Science Fiction".
-- **Certifications are US-only** — The `--certification` flag and the `certification` subcommand only return/accept US ratings (G, PG, PG-13, R, NC-17). International certifications are not available.
-- **API version** — This CLI wraps TMDb API v3. Endpoints and response shapes follow the v3 spec.
-- **Pagination defaults** — Every command defaults to 10 results. Use `--limit` to get more. The CLI does not auto-paginate beyond the first page.
-- **Now-playing is defined** — The `movie now-playing` subcommand is registered in argparse and maps to the TMDb `/movie/now_playing` endpoint.
-
-## References
-
-- [scripts/tmdb-cli](scripts/tmdb-cli) — The CLI binary. Built following the cli-builder patterns: non-interactive, `--json`, `--dry-run`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth, structured logging.
-- [TMDb API v3 Reference](https://developer.themoviedb.org/reference) — Official API documentation.
-- [TMDb API Settings (get a key)](https://www.themoviedb.org/settings/api) — Free API key registration.
diff --git a/tmdb/README.md b/tmdb/README.md
new file mode 100644
index 0000000..81031e8
--- /dev/null
+++ b/tmdb/README.md
@@ -0,0 +1,41 @@
+# TMDb Metadata Skill
+
+## Why Install This Skill
+
+Give your agent a dependable terminal workflow for exploring movie and television metadata without hand-building every HTTP request. It can start from a title, an IMDb ID, or a discovery filter, then enrich the result with credits, recommendations, images, and provider metadata.
+
+The skill also makes TMDb's easy-to-miss rules visible: choose one authentication mode, respect the 500-page ceiling, use the correct nested `/find` response, and URL-encode compound provider paths.
+
+## What You Get
+
+| Path | Purpose |
+| --- | --- |
+| `SKILL.md` | Setup, commands, recipes, gotchas, and routing |
+| `scripts/tmdb` | Executable JSON-capable CLI for search, detail, find, discovery, and trends |
+| `scripts/test_tmdb.py` | Offline pytest/unittest coverage with mocked HTTP behavior |
+| `references/auth-pagination-and-errors.md` | Authentication, pagination, errors, rate limits, and image construction |
+| `references/find-and-details.md` | External IDs, IMDb entry points, details, credits, and compound responses |
+| `references/search-discover-trending.md` | Search, discovery filters, trending, genres, and certifications |
+| `evals/evals.json` | Runnable examples covering normal and negative routing |
+
+## Quick Start
+
+```bash
+export TMDB_ACCESS_TOKEN="YOUR_ACCESS_TOKEN"
+tmdb movie search --term "Dune" --limit 5 --json
+tmdb find tt0111161 --source imdb_id --json
+tmdb movie detail 550 --append credits,videos --json
+```
+
+## Triggers
+
+Load this skill when the request involves movie or TV metadata, title search, IMDb/TVDB resolution, credits, release dates, certifications, recommendations, images, trending media, or provider metadata.
+
+## Requirements
+
+- Python 3.8 or newer
+- `requests` Python package
+- A TMDb API Read Access Token or v3 API key
+- `jq` for the shell pipeline examples
+
+This is a read-oriented metadata workflow. It does not play media, search torrents, or maintain personal watch history.
diff --git a/tmdb/SKILL.md b/tmdb/SKILL.md
new file mode 100644
index 0000000..ba3157b
--- /dev/null
+++ b/tmdb/SKILL.md
@@ -0,0 +1,123 @@
+---
+name: tmdb
+description: Query TMDb metadata for films and television, then enrich results with details, credits, providers, and external IDs. Do not use this skill for torrent search, streaming playback, or personal watch-history tracking.
+license: MIT
+compatibility: Requires TMDB_ACCESS_TOKEN or TMDB_API_KEY, Python 3.8+, and requests.
+metadata:
+ tags: tmdb, movies, tv, film, cinema, metadata
+ sources: https://developer.themoviedb.org/reference
+---
+
+# TMDb metadata from the terminal
+
+## Setup
+
+Create credentials at [TMDb API settings](https://www.themoviedb.org/settings/api). Prefer the API Read Access Token:
+
+```bash
+export TMDB_ACCESS_TOKEN="YOUR_ACCESS_TOKEN"
+# Or use the v3 key: export TMDB_API_KEY="YOUR_API_KEY"
+```
+
+The CLI sends either `Authorization: Bearer $TMDB_ACCESS_TOKEN` or `?api_key=$TMDB_API_KEY`. Both forms have the same v3 access level; configure only one. `--help` and `--dry-run` do not need credentials.
+
+## Essential commands
+
+### Search and identify
+
+```bash
+tmdb movie search --term "dune" --limit 5 --json
+tmdb tv search --term "severance" --limit 5
+tmdb find tt0111161 --source imdb_id --json
+```
+
+`--source` accepts one of the official external-source values: `imdb_id`, `facebook_id`, `instagram_id`, `tvdb_id`, `tiktok_id`, `twitter_id`, `wikidata_id`, and `youtube_id`. Freebase lookups are not supported: the retired `freebase_mid` and `freebase_id` values are rejected. The response is split into `movie_results`, `tv_results`, `person_results`, `tv_season_results`, and `tv_episode_results`.
+
+### Details and enrichment
+
+```bash
+tmdb movie detail 550 --append credits,videos --json
+tmdb movie detail 550 --append 'credits,watch/providers,external_ids' --json
+```
+
+Compound responses use the requested names as top-level keys. Encode the slash in `watch/providers` when constructing raw URLs.
+
+### Discover and browse
+
+```bash
+tmdb movie discover --genre horror --rating 7 --limit 10
+tmdb movie discover --genre horror --certification R --from 2024-01-01 --to 2024-12-31
+tmdb trending --type all --window week --limit 20 --json
+tmdb genre list --type movie --json
+tmdb genre list --type tv --json
+tmdb certification --json
+```
+
+Use `vote_count.gte` with `vote_average.desc` in raw discover requests so a title with very few votes does not dominate. In current TMDb docs, comma-separated genre IDs are AND and pipe-separated IDs are OR.
+
+## Pipeline recipes
+
+### IMDb ID to enriched movie
+
+1. Resolve the IMDb identifier:
+
+```bash
+curl -s -H "Authorization: Bearer $TMDB_ACCESS_TOKEN" \
+ 'https://api.themoviedb.org/3/find/tt0111161?external_source=imdb_id' > /tmp/find.json
+id=$(jq -r '.movie_results[0].id' /tmp/find.json)
+```
+
+2. Fetch details and compound resources:
+
+```bash
+curl -s -H "Authorization: Bearer $TMDB_ACCESS_TOKEN" \
+ "https://api.themoviedb.org/3/movie/$id?append_to_response=credits,videos,watch%2Fproviders" \
+ | jq '{title, runtime, director: [.credits.crew[] | select(.job == "Director") | .name], cast: [.credits.cast[0:5][].name], providers: .["watch/providers"].results.US}'
+```
+
+### Search then detail
+
+```bash
+tmdb movie search --term "dune" --limit 1 --json > /tmp/search.json
+id=$(jq -r '.results[0].id' /tmp/search.json)
+tmdb movie detail "$id" --append recommendations,similar --json
+```
+
+### Filter reliable discoveries
+
+For direct API use, combine a date window, pipe-OR or comma-AND genre expression, `vote_count.gte`, and `sort_by=vote_average.desc`. Then retain only the fields needed by the next workflow step with jq.
+
+## JSON and jq
+
+Put `--json` before or after the subcommand. JSON search output has `results` and usually pagination fields `page`, `total_pages`, and `total_results`; the service limits page numbers to 500. Use `jq -r '.results[] | [.id, (.title // .name)] | @tsv'` for stable tabular handoff.
+
+## Known gotchas
+
+- **Credential duality:** `api_key` and Bearer are alternatives, not values to mix. A rejected credential commonly produces HTTP 401, `status_code: 7`, and `Invalid API key: You must be granted a valid key.` Permission failures use code 3. Code 33 means an invalid request token, not this API-key message.
+- **Pagination ceiling:** pages start at 1 and max at 500; over-limit requests fail. Search/discover access is effectively capped at 10,000 results, even where totals look larger. Rate guidance is around 40 requests/second and 429 responses should honor `Retry-After`.
+- **Compound syntax:** append values are comma-separated and limited to 20 calls. `watch/providers` contains a slash, so URL-encode it in curl and use jq's `.\"watch/providers\"` notation.
+- **External-ID shape:** `/find/` does not return one generic `id`; inspect the appropriate nested array before choosing movie or TV detail. Only the eight documented `external_source` values are valid, and the retired Freebase sources (`freebase_mid`, `freebase_id`) are rejected.
+- **Provider filters:** `with_watch_providers` requires `watch_region`; provider data carries JustWatch attribution requirements.
+- **Localization and images:** use `language=en-US` and a market `region` when reproducibility matters. Build image URLs from `/3/configuration`'s secure base URL, a valid size, and the returned path.
+
+## When to use
+
+Use this skill for read-only film and TV metadata discovery, credits, release information, certifications, images, recommendations, and provider metadata.
+
+## When not to use
+
+Do not use it for torrent or piracy searches, playing or downloading a stream, or maintaining personal watched/unwatched state. Use `trakt` for watch-history workflows and a playback/catalog integration for availability actions.
+
+## Reference files
+
+| File | Use it for |
+| --- | --- |
+| [references/auth-pagination-and-errors.md](references/auth-pagination-and-errors.md) | Credentials, pagination, rate limits, errors, language, regions, and images |
+| [references/find-and-details.md](references/find-and-details.md) | IMDb/TVDB lookup, response mapping, detail fields, compound requests |
+| [references/search-discover-trending.md](references/search-discover-trending.md) | Search, discover filters, trending, genre, certification, and release lists |
+
+## Available scripts and prerequisites
+
+- `scripts/tmdb` is an executable Python CLI using only the standard library and `requests`; it preserves `--json`, `--dry-run`, `--quiet`, and `--verbose`.
+- `scripts/test_tmdb.py` is an offline unittest/pytest suite; all HTTP behavior is mocked.
+- Requires Python 3.8+ and `requests`. No service is started by this skill.
diff --git a/tmdb/evals/evals.json b/tmdb/evals/evals.json
new file mode 100644
index 0000000..f988f20
--- /dev/null
+++ b/tmdb/evals/evals.json
@@ -0,0 +1,42 @@
+{
+ "schema_version": 1,
+ "skill_name": "tmdb",
+ "evals": [
+ {
+ "id": "search-movie",
+ "prompt": "Find the top five TMDb movie results for Dune.",
+ "expected_output": "Use tmdb movie search with --term and --limit, then inspect JSON results.",
+ "assertions": ["uses movie search", "limits results"]
+ },
+ {
+ "id": "imdb-find-detail-pipeline",
+ "prompt": "Starting from IMDb tt0111161, find the TMDb movie and fetch credits and providers.",
+ "expected_output": "Call /find/{external_id} with external_source=imdb_id, extract movie_results[0].id, then request movie details with append_to_response.",
+ "assertions": ["documents IMDb entry point", "extracts movie_results id", "uses append_to_response"]
+ },
+ {
+ "id": "auth-mode-gotcha",
+ "prompt": "Explain TMDb API key versus read access token authentication and diagnose a 401.",
+ "expected_output": "Use either api_key query authentication or Authorization Bearer, not both; inspect status_code 7 and status_message.",
+ "assertions": ["distinguishes v3 key and bearer", "names 401 symptom"]
+ },
+ {
+ "id": "discover-rated-movies",
+ "prompt": "Discover highly rated horror movies released in a date window.",
+ "expected_output": "Use discover/movie with genre, vote_average.gte, vote_count.gte, and release date filters, then process JSON with jq.",
+ "assertions": ["uses discover filters", "guards vote averages with vote count"]
+ },
+ {
+ "id": "not-for-torrents",
+ "prompt": "Search torrent sites for a movie download.",
+ "expected_output": "Do not route this to TMDb; it is a piracy or torrent-search request rather than metadata discovery.",
+ "assertions": ["must not trigger tmdb", "refuses torrent search"]
+ },
+ {
+ "id": "trending-json",
+ "prompt": "Show movies trending this week as machine-readable JSON.",
+ "expected_output": "Run tmdb trending with --window week and --json.",
+ "assertions": ["uses trending endpoint", "uses json output"]
+ }
+ ]
+}
diff --git a/tmdb/references/auth-pagination-and-errors.md b/tmdb/references/auth-pagination-and-errors.md
new file mode 100644
index 0000000..8a4d026
--- /dev/null
+++ b/tmdb/references/auth-pagination-and-errors.md
@@ -0,0 +1,36 @@
+# TMDb Authentication, Pagination, and Errors
+
+## Choose one application credential
+
+TMDb v3 accepts either `api_key` as a query parameter or an API Read Access Token in `Authorization: Bearer `. Both methods provide the same access level across v3; the read token also works across v4. Obtain both from the account API settings page. Send one method, not both, so an accidental stale query key cannot obscure a rejected bearer token.
+
+```bash
+curl -H 'accept: application/json' \
+ -H "Authorization: Bearer $TMDB_ACCESS_TOKEN" \
+ 'https://api.themoviedb.org/3/movie/550'
+# Alternative: .../movie/550?api_key=$TMDB_API_KEY
+```
+
+A bad credential commonly returns HTTP 401 with `status_code: 7` and `Invalid API key: You must be granted a valid key.` Permission failures use code 3 and `Authentication failed: You do not have permissions to access the service.` Do not confuse code 33, which is an invalid request token. The CLI reports the 401 response rather than retrying with a second credential.
+
+## Pages and rate limits
+
+Search and discover responses contain `page`, `results`, `total_pages`, and `total_results`; pages contain up to 20 results. Page numbers start at 1 and max out at 500. Requests beyond that limit return a validation error, rather than being silently clamped. Search/discover access is effectively capped at 10,000 items even when totals advertise more. Trending has a larger documented sample ceiling.
+
+TMDb's current guidance describes a soft limit around 40 requests per second, subject to change. On HTTP 429, respect `Retry-After`; the service may also expose `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. Exponential backoff is safer than tight retry loops.
+
+## Parameters and images
+
+Use `language=en-US` (an ISO 639-1 language plus ISO 3166-1 region) for deterministic localized fields. `region=US` selects or filters release dates for that market. Image URLs combine the secure base URL from `/3/configuration`, a valid size, and the returned path: `https://image.tmdb.org/t/p/w500/`. Common poster sizes include `w92`, `w185`, `w342`, `w500`, `w780`, and `original`; backdrop sizes differ.
+
+## Sources
+
+- https://developer.themoviedb.org/docs/authentication-application
+- https://developer.themoviedb.org/reference/authentication
+- https://www.themoviedb.org/documentation/api/status-codes
+- https://developer.themoviedb.org/docs/rate-limiting
+- https://developer.themoviedb.org/reference/search-movie
+- https://developer.themoviedb.org/docs/languages
+- https://developer.themoviedb.org/docs/region-support
+- https://developer.themoviedb.org/docs/image-basics
+- https://developer.themoviedb.org/reference/configuration-details
diff --git a/tmdb/references/find-and-details.md b/tmdb/references/find-and-details.md
new file mode 100644
index 0000000..d7171f0
--- /dev/null
+++ b/tmdb/references/find-and-details.md
@@ -0,0 +1,34 @@
+# External IDs, Details, and Compound Responses
+
+## Start with an IMDb ID
+
+`GET /3/find/{external_id}?external_source=imdb_id` maps a foreign identifier to TMDb objects. The `external_source` value is chosen from exactly eight supported enum entries: `imdb_id`, `facebook_id`, `instagram_id`, `tvdb_id`, `tiktok_id`, `twitter_id`, `wikidata_id`, and `youtube_id`. Freebase lookups are not supported: the retired `freebase_mid` and `freebase_id` sources have been removed from the API and must not be used or documented as valid values. The response has `movie_results`, `person_results`, `tv_results`, `tv_episode_results`, and `tv_season_results` arrays. Unmatched categories are empty arrays. For an IMDb movie, extract `.movie_results[0].id` before calling the movie details endpoint.
+
+```bash
+curl -s -H "Authorization: Bearer $TMDB_ACCESS_TOKEN" \
+ 'https://api.themoviedb.org/3/find/tt0111161?external_source=imdb_id' \
+ | jq -r '.movie_results[0].id'
+```
+
+## Details and append_to_response
+
+Movie details expose fields such as `title`, `overview`, `genres`, `runtime`, `release_date`, `vote_average`, `vote_count`, `budget`, `revenue`, `imdb_id`, and production companies. TV details use `name`, `first_air_date`, `number_of_seasons`, `number_of_episodes`, `created_by`, `networks`, and `genres`.
+
+Details endpoints accept `append_to_response`, a comma-separated list of sub-endpoints within the same namespace, with a maximum of 20 appended calls. Common movie tokens include `credits`, `images`, `videos`, `recommendations`, `similar`, `reviews`, `release_dates`, `watch/providers`, `external_ids`, `alternative_titles`, and `translations`; TV adds `aggregate_credits` and `content_ratings`. Encode the slash when needed (`watch%2Fproviders`). Returned keys mirror the requested token, so jq accesses the provider object as `."watch/providers"`.
+
+```bash
+curl -s -H "Authorization: Bearer $TMDB_ACCESS_TOKEN" \
+ 'https://api.themoviedb.org/3/movie/550?append_to_response=credits,videos,watch%2Fproviders' \
+ | jq '{title, runtime, director: [.credits.crew[] | select(.job == "Director") | .name], cast: [.credits.cast[0:5][].name], us: .["watch/providers"].results.US}'
+```
+
+Credits contain `cast[]` (including `id`, `name`, `character`, `order`) and `crew[]` (including `department`, `job`). Watch-provider regions contain `link`, `flatrate`, `rent`, and `buy` arrays. Release dates nest under `results[].release_dates[]`; content ratings nest under `results[]`. TMDb requires attribution and a link to JustWatch when displaying provider data.
+
+## Sources
+
+- https://developer.themoviedb.org/reference/find-by-id
+- https://developer.themoviedb.org/reference/movie-details
+- https://developer.themoviedb.org/reference/movie-credits
+- https://developer.themoviedb.org/reference/movie-watch-providers
+- https://developer.themoviedb.org/reference/movie-release-dates
+- https://developer.themoviedb.org/reference/tv-content-ratings
diff --git a/tmdb/references/search-discover-trending.md b/tmdb/references/search-discover-trending.md
new file mode 100644
index 0000000..08f6ece
--- /dev/null
+++ b/tmdb/references/search-discover-trending.md
@@ -0,0 +1,27 @@
+# Search, Discover, Trending, and Lists
+
+## Search
+
+Use `/search/movie` with required `query`; `include_adult` defaults to false. `/search/tv` supports `first_air_date_year`, while `/search/multi` combines movie, TV, and person results. Search responses expose `page`, `results`, `total_pages`, and `total_results`. Keep `language=en-US` explicit when scripts need stable output.
+
+## Discover
+
+`/discover/movie` and `/discover/tv` filter catalog metadata. Useful movie filters include `with_genres`, `vote_count.gte`, `vote_average.gte`, `primary_release_date.gte/lte`, and certification fields. TV uses `first_air_date.gte/lte`; discover TV does not expose movie certification filters. The current docs state that comma-separated genre IDs are an AND query and pipe-separated IDs are an OR query. Provider filters such as `with_watch_providers` require `watch_region`.
+
+Avoid sorting only by `vote_average.desc`: require a meaningful `vote_count.gte` threshold or a tiny-vote title can dominate. Upcoming and now-playing lists are specialized release-date views; `region` controls the market.
+
+## Trending and lists
+
+Trending uses `/trending/{all|movie|tv|person}/{day|week}`. `all` results carry `media_type`, which lets a consumer branch to movie or TV detail calls. Genre lists return `{genres: [{id, name}]}`. Certification lists group entries under `certifications.US` (with certification, meaning, and order).
+
+## Sources
+
+- https://developer.themoviedb.org/reference/search-movie
+- https://developer.themoviedb.org/reference/search-tv
+- https://developer.themoviedb.org/reference/search-multi
+- https://developer.themoviedb.org/reference/discover-movie
+- https://developer.themoviedb.org/reference/discover-tv
+- https://developer.themoviedb.org/reference/trending-all
+- https://developer.themoviedb.org/reference/genre-movie-list
+- https://developer.themoviedb.org/reference/certification-movie-list
+- https://developer.themoviedb.org/docs/region-support
diff --git a/tmdb/scripts/test_tmdb.py b/tmdb/scripts/test_tmdb.py
new file mode 100644
index 0000000..b2a4a2a
--- /dev/null
+++ b/tmdb/scripts/test_tmdb.py
@@ -0,0 +1,185 @@
+import importlib.machinery
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+SCRIPT = Path(__file__).with_name("tmdb")
+
+
+def load_cli():
+ loader = importlib.machinery.SourceFileLoader("tmdb_cli", str(SCRIPT))
+ spec = importlib.util.spec_from_loader("tmdb_cli", loader)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+class TmdbCliTests(unittest.TestCase):
+ def run_cli(self, *args):
+ env = os.environ.copy()
+ env.pop("TMDB_ACCESS_TOKEN", None)
+ env.pop("TMDB_API_KEY", None)
+ return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=env)
+
+ def test_help_lists_entry_points(self):
+ result = self.run_cli("--help")
+ self.assertEqual(result.returncode, 0)
+ self.assertIn("find", result.stdout)
+ self.assertIn("movie", result.stdout)
+
+ def test_missing_required_search_term_is_argument_error(self):
+ result = self.run_cli("movie", "search")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("--term", result.stderr)
+
+ def test_dry_run_find_emits_json_without_credentials(self):
+ result = self.run_cli("--dry-run", "--json", "find", "tt0111161")
+ self.assertEqual(result.returncode, 0)
+ self.assertTrue(json.loads(result.stdout)["dry_run"])
+
+ def test_mocked_external_lookup_uses_source_and_parses_results(self):
+ cli = load_cli()
+ client = cli.TMDBClient()
+ client.find_external = Mock(return_value={"movie_results": [{"id": 550, "title": "Fight Club"}]})
+ cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+ with patch("builtins.print") as printed:
+ cli.cmd_find(client, ["tt0137523", "--source", "imdb_id"])
+ payload = json.loads(printed.call_args.args[0])
+ self.assertEqual(payload["movie_results"][0]["id"], 550)
+ client.find_external.assert_called_once_with("tt0137523", "imdb_id")
+
+ def test_mocked_detail_passes_append_to_response(self):
+ cli = load_cli()
+ client = cli.TMDBClient()
+ client.get_movie = Mock(return_value={"id": 550, "title": "Fight Club", "credits": {"cast": []}})
+ cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+ with patch("builtins.print"):
+ cli.cmd_movie_detail(client, ["550", "--append", "credits,videos"])
+ client.get_movie.assert_called_once_with("550", "credits,videos")
+
+
+class GenreListParserTests(unittest.TestCase):
+ """Regression coverage for `tmdb genre list --type movie|tv`."""
+
+ def run_cli(self, *args):
+ env = os.environ.copy()
+ env.pop("TMDB_ACCESS_TOKEN", None)
+ env.pop("TMDB_API_KEY", None)
+ return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=env)
+
+ def test_documented_nested_form_parses_and_dispatches(self):
+ result = self.run_cli("--dry-run", "--json", "genre", "list", "--type", "movie")
+ self.assertEqual(result.returncode, 0)
+ self.assertTrue(json.loads(result.stdout)["dry_run"])
+
+ def test_flat_form_is_clean_rejection_not_crash(self):
+ result = self.run_cli("--json", "genre", "--type", "tv")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertNotIn("Traceback", result.stderr)
+ self.assertIn("invalid choice", result.stderr)
+
+ def test_missing_type_is_argument_error(self):
+ result = self.run_cli("--json", "genre", "list")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("--type", result.stderr)
+
+ def test_dispatch_passes_tail_args_without_raw_argv_token_search(self):
+ cli = load_cli()
+ captured = {}
+ real_client_factory = cli.TMDBClient
+
+ def fake_client(dry_run=False):
+ return real_client_factory(dry_run=dry_run)
+
+ def fake_handler(client, args):
+ captured["args"] = args
+
+ cli.cmd_genre_list = fake_handler
+ with patch.object(cli.sys, "argv", ["tmdb", "--dry-run", "genre", "list", "--type", "tv"]):
+ cli.main()
+ self.assertEqual(captured["args"], ["--type", "tv"])
+ self.assertEqual(cli.GLOBAL_FLAGS.get("dry_run"), True)
+ fake_client # client construction stays credential-free
+
+
+class TvSearchEndpointTests(unittest.TestCase):
+ """TV search must hit /search/tv via client.search_tv and format TV fields."""
+
+ def load_with_flags(self):
+ cli = load_cli()
+ cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+ return cli
+
+ def test_json_output_uses_search_tv_and_preserves_tv_shape(self):
+ cli = self.load_with_flags()
+ client = cli.TMDBClient()
+ client.search_tv = Mock(return_value={
+ "page": 1,
+ "total_results": 1,
+ "results": [{"id": 9626, "name": "Poirot", "first_air_date": "1989-01-08",
+ "vote_average": 7.9}],
+ })
+ client.search_movie = Mock(side_effect=AssertionError("/search/movie must not be used"))
+ with patch("builtins.print") as printed:
+ cli.cmd_tv_search(client, ["--term", "Poirot"])
+ payload = json.loads(printed.call_args.args[0])
+ self.assertEqual(payload["total"], 1)
+ self.assertEqual(payload["results"][0]["name"], "Poirot")
+ self.assertEqual(payload["results"][0]["first_air_date"], "1989-01-08")
+ client.search_tv.assert_called_once_with("Poirot")
+ client.search_movie.assert_not_called()
+
+ def test_human_output_formats_name_and_first_air_date_year(self):
+ cli = load_cli()
+ cli.GLOBAL_FLAGS = {"json": False, "dry_run": False, "quiet": False, "verbose": False}
+ client = cli.TMDBClient()
+ client.search_tv = Mock(return_value={
+ "total_results": 1,
+ "results": [{"id": 9626, "name": "Poirot", "first_air_date": "1989-01-08",
+ "vote_average": 7.9}],
+ })
+ with patch("builtins.print") as printed:
+ cli.cmd_tv_search(client, ["--term", "Poirot"])
+ line = printed.call_args.args[0]
+ self.assertIn("Poirot", line)
+ self.assertIn("(1989)", line)
+
+
+class FindExternalSourceTests(unittest.TestCase):
+ """Exactly the official eight external_source values are accepted."""
+
+ def test_all_official_sources_are_accepted_parameterized(self):
+ cli = load_cli()
+ cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+ self.assertEqual(len(cli.EXTERNAL_SOURCES), 8)
+ for source in cli.EXTERNAL_SOURCES:
+ with self.subTest(source=source):
+ client = cli.TMDBClient()
+ client.find_external = Mock(return_value={})
+ with patch("builtins.print"):
+ cli.cmd_find(client, [f"ext-{source}", "--source", source])
+ client.find_external.assert_called_once_with(f"ext-{source}", source)
+
+ def test_freebase_sources_are_rejected_parameterized(self):
+ for retired in ("freebase_mid", "freebase_id"):
+ with self.subTest(source=retired):
+ result = self.run_cli("--json", "find", "ABC123", "--source", retired)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertNotIn("Traceback", result.stderr)
+ self.assertIn(retired, result.stderr)
+ self.assertIn("invalid choice", result.stderr)
+
+ def run_cli(self, *args):
+ env = os.environ.copy()
+ env.pop("TMDB_ACCESS_TOKEN", None)
+ env.pop("TMDB_API_KEY", None)
+ return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=env)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tmdb-cli/scripts/tmdb-cli b/tmdb/scripts/tmdb
similarity index 74%
rename from tmdb-cli/scripts/tmdb-cli
rename to tmdb/scripts/tmdb
index 3b4b69c..a626ace 100755
--- a/tmdb-cli/scripts/tmdb-cli
+++ b/tmdb/scripts/tmdb
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""tmdb-cli — The Movie Database API for agent-based media discovery.
+"""tmdb — The Movie Database API for agent-based media discovery.
Discover movies and TV shows by genre, rating, certification, and date.
Search by term, get details, check trending, upcoming, and now playing.
@@ -26,6 +26,20 @@ ENV_SERVER = os.getenv("TMDB_SERVER", DEFAULT_SERVER)
QUIET = False
GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False}
+# Official external_source values for GET /find/{external_id}, per the current
+# TMDb developer documentation. Retired Freebase sources (freebase_mid,
+# freebase_id) are deliberately excluded: the API rejects them.
+EXTERNAL_SOURCES: Tuple[str, ...] = (
+ "imdb_id",
+ "facebook_id",
+ "instagram_id",
+ "tvdb_id",
+ "tiktok_id",
+ "twitter_id",
+ "wikidata_id",
+ "youtube_id",
+)
+
def log(msg):
if not QUIET and not GLOBAL_FLAGS.get("json", False):
@@ -127,11 +141,16 @@ class TMDBClient:
def get_trending(self, media_type="movie", window="week"):
return self._get(f"/trending/{media_type}/{window}")
- def get_movie(self, movie_id):
- return self._get(f"/movie/{movie_id}")
+ def get_movie(self, movie_id, append=None):
+ params = {"append_to_response": append} if append else None
+ return self._get(f"/movie/{movie_id}", params)
- def get_tv(self, tv_id):
- return self._get(f"/tv/{tv_id}")
+ def get_tv(self, tv_id, append=None):
+ params = {"append_to_response": append} if append else None
+ return self._get(f"/tv/{tv_id}", params)
+
+ def find_external(self, external_id, source, language="en-US"):
+ return self._get(f"/find/{external_id}", {"external_source": source, "language": language})
def get_movie_genres(self, lang="en-US"):
return self._get("/genre/movie/list", {"language": lang})
@@ -170,7 +189,7 @@ def fmt_tv(t, idx=None):
def cmd_movie_search(client, args):
- p = argparse.ArgumentParser(prog="tmdb-cli movie search")
+ p = argparse.ArgumentParser(prog="tmdb movie search")
p.add_argument("--term", "-t", required=True)
p.add_argument("--limit", type=int, default=10)
parsed, _ = p.parse_known_args(args)
@@ -185,8 +204,47 @@ def cmd_movie_search(client, args):
{"total": data.get("total_results"), "results": results})
+def cmd_tv_search(client, args):
+ p = argparse.ArgumentParser(prog="tmdb tv search")
+ p.add_argument("--term", "-t", required=True)
+ p.add_argument("--limit", type=int, default=10)
+ parsed, _ = p.parse_known_args(args)
+ if client.dry_run:
+ return emit(f"[dry-run] Search TV shows: {parsed.term}", {"dry_run": True})
+ data = client.search_tv(parsed.term) or {}
+ results = data.get("results", [])[:parsed.limit]
+ if not results:
+ return emit("No TV shows found.", {"results": []})
+ lines = [fmt_tv(r, i+1) for i, r in enumerate(results)]
+ emit(f"{data.get('total_results', len(results))} result(s):\n" + "\n".join(lines),
+ {"total": data.get("total_results"), "results": results})
+
+
+def cmd_movie_detail(client, args):
+ p = argparse.ArgumentParser(prog="tmdb movie detail")
+ p.add_argument("movie_id")
+ p.add_argument("--append", default=None, help="comma-separated subresources")
+ parsed, _ = p.parse_known_args(args)
+ if client.dry_run:
+ return emit("[dry-run] Get movie details", {"dry_run": True})
+ data = client.get_movie(parsed.movie_id, parsed.append) or {}
+ emit(data.get("title", "Movie details"), data)
+
+
+def cmd_find(client, args):
+ p = argparse.ArgumentParser(prog="tmdb find")
+ p.add_argument("external_id")
+ p.add_argument("--source", default="imdb_id", choices=EXTERNAL_SOURCES,
+ help="external_source for /find (Freebase sources are retired)")
+ parsed, _ = p.parse_known_args(args)
+ if client.dry_run:
+ return emit("[dry-run] Find external ID", {"dry_run": True})
+ data = client.find_external(parsed.external_id, parsed.source) or {}
+ emit("External ID results", data)
+
+
def cmd_movie_discover(client, args):
- p = argparse.ArgumentParser(prog="tmdb-cli movie discover")
+ p = argparse.ArgumentParser(prog="tmdb movie discover")
p.add_argument("--genre", help="Genre name (e.g. horror, comedy)")
p.add_argument("--certification", help="US certification (G, PG, PG-13, R, NC-17)")
p.add_argument("--rating", type=float, help="Min vote average (0-10)")
@@ -231,7 +289,7 @@ def _resolve_movie_genre(client, name):
def cmd_tv_discover(client, args):
- p = argparse.ArgumentParser(prog="tmdb-cli tv discover")
+ p = argparse.ArgumentParser(prog="tmdb tv discover")
p.add_argument("--genre", help="Genre name")
p.add_argument("--rating", type=float, help="Min vote average")
p.add_argument("--from", dest="air_date_gte", help="Air date from")
@@ -268,7 +326,7 @@ def _resolve_tv_genre(client, name):
def cmd_trending(client, args):
- p = argparse.ArgumentParser(prog="tmdb-cli trending")
+ p = argparse.ArgumentParser(prog="tmdb trending")
p.add_argument("--type", default="movie", choices=["movie", "tv", "all"])
p.add_argument("--window", default="week", choices=["day", "week"])
p.add_argument("--limit", type=int, default=10)
@@ -294,7 +352,7 @@ def cmd_trending(client, args):
def cmd_genre_list(client, args):
- p = argparse.ArgumentParser(prog="tmdb-cli genre list")
+ p = argparse.ArgumentParser(prog="tmdb genre list")
p.add_argument("--type", required=True, choices=["movie", "tv"])
parsed, _ = p.parse_known_args(args)
@@ -322,7 +380,7 @@ def cmd_cert_list(client, args):
def cmd_upcoming(client, args):
- p = argparse.ArgumentParser(prog="tmdb-cli movie upcoming")
+ p = argparse.ArgumentParser(prog="tmdb movie upcoming")
p.add_argument("--limit", type=int, default=10)
parsed, _ = p.parse_known_args(args)
if client.dry_run:
@@ -341,7 +399,7 @@ def main():
if GLOBAL_FLAGS.get("json", False):
warnings.simplefilter("ignore")
- parser = argparse.ArgumentParser(prog="tmdb-cli", description="The Movie Database API CLI.")
+ parser = argparse.ArgumentParser(prog="tmdb", description="The Movie Database API CLI.")
sub = parser.add_subparsers(dest="resource")
# movie
@@ -349,6 +407,7 @@ def main():
msub = mp.add_subparsers(dest="action")
s1 = msub.add_parser("search", help="Search movies"); s1.add_argument("--term", "-t", required=True); s1.add_argument("--limit", type=int, default=10)
s2 = msub.add_parser("discover", help="Discover movies"); s2.add_argument("--genre"); s2.add_argument("--certification"); s2.add_argument("--rating", type=float); s2.add_argument("--from", dest="release_date_gte"); s2.add_argument("--to", dest="release_date_lte"); s2.add_argument("--limit", type=int, default=10)
+ sdetail = msub.add_parser("detail", help="Get movie details"); sdetail.add_argument("movie_id"); sdetail.add_argument("--append")
msub.add_parser("upcoming", help="Upcoming movies").add_argument("--limit", type=int, default=10)
msub.add_parser("now-playing", help="Now playing movies").add_argument("--limit", type=int, default=10)
@@ -356,11 +415,16 @@ def main():
tp = sub.add_parser("tv", help="TV operations")
tsub = tp.add_subparsers(dest="action")
s3 = tsub.add_parser("search", help="Search TV"); s3.add_argument("--term", "-t", required=True); s3.add_argument("--limit", type=int, default=10)
- s4 = tsub.add_parser("discover", help="Discover TV"); s4.add_argument("--genre"); s4.add_argument("--rating", type=float); s4.add_argument("--from", dest="air_date_gte"); s4.add_argument("--limit", type=int, default=10)
-
- # flat
- sub.add_parser("genre", help="List genres").add_argument("--type", required=True, choices=["movie", "tv"])
+ s4 = tsub.add_parser("discover", help="Discover TV"); s4.add_argument("--genre"); s4.add_argument("--rating", type=float); s4.add_argument("--from", dest="air_date_gte"); s4.add_argument("--limit", type=int, default=10) # flat
+ genre_p = sub.add_parser("genre", help="List genres")
+ genre_sub = genre_p.add_subparsers(dest="action")
+ gs = genre_sub.add_parser("list", help="List genres for a media type")
+ gs.add_argument("--type", required=True, choices=["movie", "tv"])
sub.add_parser("certification", help="List certifications")
+ fp = sub.add_parser("find", help="Find by external ID")
+ fp.add_argument("external_id")
+ fp.add_argument("--source", default="imdb_id", choices=EXTERNAL_SOURCES,
+ help="external_source for /find (Freebase sources are retired)")
tr = sub.add_parser("trending", help="Trending content")
tr.add_argument("--type", default="movie", choices=["movie", "tv", "all"])
@@ -373,23 +437,33 @@ def main():
sys.exit(1)
client = TMDBClient(dry_run=GLOBAL_FLAGS.get("dry_run", False))
+ # Dispatch on parsed attributes; positional subcommand names are re-sliced
+ # from argv only so per-command parsers can reject invalid flags locally.
+ def slice_after(token):
+ return filtered_argv[filtered_argv.index(token) + 1:] if token in filtered_argv else []
- # Dispatch
if args.resource == "movie":
- if args.action == "search": cmd_movie_search(client, filtered_argv[filtered_argv.index("search")+1:])
- elif args.action == "discover": cmd_movie_discover(client, filtered_argv[filtered_argv.index("discover")+1:])
- elif args.action == "upcoming": cmd_upcoming(client, filtered_argv[filtered_argv.index("upcoming")+1:])
+ if args.action == "search": cmd_movie_search(client, slice_after("search"))
+ elif args.action == "discover": cmd_movie_discover(client, slice_after("discover"))
+ elif args.action == "detail": cmd_movie_detail(client, slice_after("detail"))
+ elif args.action == "upcoming":
+ cmd_upcoming(client, slice_after("upcoming"))
else: parser.print_help()
elif args.resource == "tv":
- if args.action == "search": cmd_movie_search(client, filtered_argv[filtered_argv.index("search")+1:])
- elif args.action == "discover": cmd_tv_discover(client, filtered_argv[filtered_argv.index("discover")+1:])
+ if args.action == "search": cmd_tv_search(client, slice_after("search"))
+ elif args.action == "discover": cmd_tv_discover(client, slice_after("discover"))
else: parser.print_help()
elif args.resource == "trending":
- cmd_trending(client, filtered_argv[filtered_argv.index("trending")+1:])
+ cmd_trending(client, slice_after("trending"))
elif args.resource == "genre":
- cmd_genre_list(client, filtered_argv[filtered_argv.index("list")+1:])
+ if args.action == "list":
+ cmd_genre_list(client, slice_after("list"))
+ else:
+ genre_p.print_help()
elif args.resource == "certification":
- cmd_cert_list(client, filtered_argv[filtered_argv.index("list")+1:])
+ cmd_cert_list(client, [])
+ elif args.resource == "find":
+ cmd_find(client, slice_after("find"))
else:
parser.print_help()
diff --git a/trakt/README.md b/trakt/README.md
index b27a96a..f176424 100644
--- a/trakt/README.md
+++ b/trakt/README.md
@@ -1,37 +1,40 @@
-# Trakt — Media Discovery from the Terminal
-
-Discover trending, anticipated, and popular movies and TV shows via the Trakt.tv API. Read-only discovery with no user authentication needed.
+# Trakt — Media Discovery Signals in the Terminal
## Why Install This Skill
-When your agent loads this skill, it can **surface what's worth watching** without a browser. That means:
+Give your agent a reliable way to answer "what is everyone watching?" without confusing a current Trakt discovery ranking with a metadata catalog. The skill covers movies and shows that are trending, broadly popular, or anticipated, and produces JSON that can feed media automation.
-- **Trending movies and TV** — what everyone's watching right now
-- **Most anticipated** — upcoming releases with buzz
-- **Popular content** — what's been hot recently
-- **No authentication** — just a Client ID, no OAuth flow
+Public discovery reads need an application Client ID, not a user login. OAuth boundaries, required headers, pagination, rate-limit behavior, and the difference between Trakt IDs and TMDb metadata are documented so workflows fail clearly instead of silently mixing services.
## What You Get
-| Directory | Purpose |
-|-----------|---------|
-| `SKILL.md` | Complete command reference with examples |
-| `scripts/trakt-cli` | CLI tool for Trakt.tv API v2 |
+| Path | Purpose |
+|---|---|
+| `SKILL.md` | Command guide, recipes, gotchas, and routing |
+| `scripts/trakt` | Executable CLI with JSON and dry-run modes |
+| `scripts/test_trakt.py` | Offline pytest and unittest suite, including header injection and pagination |
+| `references/auth-and-request-contract.md` | Required headers, OAuth boundary, and errors |
+| `references/discovery-endpoints.md` | Trending/popular/anticipated semantics and paging |
+| `references/recipes-and-operations.md` | jq pipelines and rate-safe operations |
+| `evals/evals.json` | Six representative usage-quality cases |
## Quick Start
-```bash
-export TRAKT_CLIENT_ID="your-trakt-client-id"
-trakt-cli movie trending
-trakt-cli tv trending
+```sh
+export TRAKT_CLIENT_ID="YOUR_TRAKT_CLIENT_ID"
+trakt movie trending --limit 10
+trakt --json tv anticipated --page 2 | jq '.pagination'
```
-Client ID from trakt.tv/oauth/applications (free, no OAuth needed).
+Create a free Client ID at [trakt.tv/oauth/applications](https://trakt.tv/oauth/applications). Preview commands with `trakt --dry-run --json movie popular` without credentials or network access.
## Triggers
-Load this for what to watch, trending movies, popular shows, or media discovery.
+Load this skill for Trakt API discovery, trending movies or shows, popular rankings, anticipated releases, watch-signal pipelines, or Trakt pagination and authentication questions. Do not use it for TMDb catalog metadata, credits, images, or provider lookups.
## Requirements
-Python 3.8+ with `requests` library. Free Client ID from trakt.tv.
+- Python 3.8 or newer
+- `requests`
+- A Trakt application Client ID in `TRAKT_CLIENT_ID` for live reads
+- No OAuth login is needed for the public discovery commands
diff --git a/trakt/SKILL.md b/trakt/SKILL.md
index cd4d724..7f8ea3e 100644
--- a/trakt/SKILL.md
+++ b/trakt/SKILL.md
@@ -1,105 +1,127 @@
---
name: trakt
-description: Discover trending, anticipated, and popular movies and TV shows via the
- Trakt.tv API from the terminal. No authentication required for read-only discovery.
- Use when the user asks about what to watch, trending movies, popular shows, or media
- discovery.
+description: >-
+ Discover and compare Trakt.tv trending, popular, and anticipated movies and shows
+ from the terminal. Do not use this skill for TMDb catalog metadata, credits, images,
+ or provider lookups; use the tmdb skill for those tasks.
license: MIT
-compatibility: Requires TRAKT_CLIENT_ID env var (free from trakt.tv/oauth/applications),
- Python 3.8+, and the `requests` library. No OAuth or user login needed for discovery
- endpoints.
+compatibility: Requires TRAKT_CLIENT_ID, Python 3.8+, and requests. Public discovery
+ reads use an application Client ID; OAuth is only needed for user-scoped operations.
metadata:
tags: trakt, media-discovery, movies, tv-shows, trending, api-client
- sources: https://trakt.tv/, https://trakt.docs.apiary.io/
+ sources: https://docs.trakt.tv/docs/required-headers
---
-# trakt-cli — Trakt.tv Media Discovery
+# Trakt media discovery
-Discover trending, anticipated, and popular movies and TV shows from the terminal. Uses the Trakt.tv API v2 with a read-only Client ID — no user authentication required.
+Use this skill to inspect what is being watched, what is broadly popular, and what is anticipated. It is a read-only discovery surface, not a catalog metadata service.
-## Setup
+## Setup and authentication
-1. Register an app at [trakt.tv/oauth/applications](https://trakt.tv/oauth/applications) to get a Client ID
-2. Set the environment variable:
+Register an app at [Trakt OAuth applications](https://trakt.tv/oauth/applications) and export its Client ID:
-```bash
-export TRAKT_CLIENT_ID="your-trakt-client-id"
+```sh
+export TRAKT_CLIENT_ID="YOUR_TRAKT_CLIENT_ID"
```
-No OAuth token, no user login needed for any of the commands below. `--help` and `--dry-run` work without credentials.
+Every request must send `trakt-api-key: ` together with the mandatory companion header `trakt-api-version: 2`, plus JSON content type and a descriptive User-Agent. Public discovery endpoints use the key header, not `Authorization: Bearer`. OAuth bearer tokens are for endpoints marked OAuth-required or for user-scoped lists, history, collection, watchlist, and mutations; a bearer token does not replace the key/version pair.
-## Essential Commands
+## Essential commands
-### movie trending — Trending movies
+All six discovery commands accept `--page N` alongside `--limit N`; both default to 1 and 10 respectively and are forwarded to the API's query string.
-```bash
-trakt-cli movie trending # top 10 trending movies
-trakt-cli movie trending --limit 25 # more results
-trakt-cli movie trending --json # machine-readable with TMDb IDs
+### Trending: watched in the last 24 hours
+
+```sh
+trakt movie trending --limit 20
+trakt tv trending --limit 20 --page 2 --json
```
-### movie anticipated — Most anticipated movies
+Trending responses wrap each media object in `movie` or `show` and include a `watchers` count.
-```bash
-trakt-cli movie anticipated # top 10 anticipated
-trakt-cli movie anticipated --limit 5 # top 5
-trakt-cli movie anticipated --json # machine-readable
+### Popular: broad popularity ranking
+
+```sh
+trakt movie popular --limit 25 --json
+trakt tv popular --page 2 --limit 25
```
-### movie popular — Most popular movies
+Popular is a ranking based on rating percentage and number of ratings, not a personalized recommendation.
-```bash
-trakt-cli movie popular # top 10 popular movies
-trakt-cli movie popular --limit 25 # more results
+### Anticipated: upcoming interest
+
+```sh
+trakt movie anticipated --page 3 --limit 10
+trakt tv anticipated --limit 10 --json
```
-### tv trending — Trending TV shows
+Anticipated reflects list appearances and upcoming interest. It is not the same as a release calendar.
-```bash
-trakt-cli tv trending # top 10 trending shows
-trakt-cli tv trending --limit 25 # more results
-trakt-cli tv trending --json # machine-readable with TVDB IDs
+Global flags can appear before or after the resource: `--json`, `--dry-run`, `--quiet`, and `--verbose`.
+
+## Pipeline recipes
+
+### Trending handoff to another tool
+
+1. Run `trakt --json movie trending --limit 20`.
+2. Unwrap `.movie`, retaining `.watchers` as the watch signal.
+3. Pass an available `.movie.ids.tmdb` or `.movie.ids.imdb` to a downstream tool; do not assume a missing ID can be synthesized.
+
+```sh
+trakt --json movie trending --limit 20 |
+ jq '.movies[] | {title: (.movie.title // .title), year: (.movie.year // null), watchers: (.watchers // null), ids: (.movie.ids // .ids)}'
```
-### tv anticipated — Most anticipated TV shows
+### Compare discovery signals
-```bash
-trakt-cli tv anticipated # top 10 anticipated shows
-trakt-cli tv anticipated --limit 5 # top 5
+Fetch matching pages of trending, popular, and anticipated (e.g. `--page 1` for each), then label each dataset before combining it. Trending is recent watching, popular is broad ranking, and anticipated is upcoming interest.
+
+### Page through anticipated until the feed ends
+
+Loop `--page`, read `pagination.page_count` from JSON output to pick the stop page, and break early if a page returns no items:
+
+```sh
+for p in $(seq 1 "$(trakt --json movie anticipated --page 1 --limit 100 | jq -r '.pagination.page_count')"); do
+ trakt --json movie anticipated --page "$p" --limit 100 |
+ jq --arg p "$p" '{page: ($p|tonumber), pagination: .pagination,
+ movies: [.movies[] | {title: (.movie.title // .title), year: (.movie.year // null)}]}'
+done
```
-### tv popular — Most popular TV shows
+Keep per-page output as labeled NDJSON; merge afterwards. On 429, wait out `Retry-After` before continuing the loop.
-```bash
-trakt-cli tv popular # top 10 popular shows
-trakt-cli tv popular --limit 25 # more results
-```
+## JSON and pagination
-Each result shows: title, year, network (for TV), TMDb/TVDB ID, and tagline (for movies).
+`--json` emits an object with a `movies` or `shows` array (trending entries retain their wrapper) plus a `pagination` object whose keys mirror the API's `X-Pagination-*` headers: `page`, `limit`, `page_count`, `item_count`. Pagination keys are ints when the headers were present and the object is empty `{}` when they were absent, so jq like `.pagination.page_count // 1` degrades safely. Human output appends a `Page N of M` line when the headers are present and stays silent otherwise. The API defaults to page 1 with limit 10 for compatibility; set both explicitly for reproducible automation, and stop at `page_count` rather than assuming a short page is the end.
-## Global Flags
+## Known gotchas
-All flags work in any position:
+- **Header pair is mandatory:** sending `trakt-api-key` without `trakt-api-version: 2` (or vice versa) can yield an invalid-request/authentication-style failure. The bundled script injects both on every live request.
+- **401 versus 403:** 401 commonly indicates an OAuth requirement or invalid authorization; 403 indicates an invalid or unapproved application key. Do not retry either blindly.
+- **Rate limits:** on 429, honor `Retry-After` and inspect `X-Ratelimit`. Use bounded retries; transient 502/503/504 responses may be retried with backoff.
+- **OAuth refresh:** access tokens last seven days and refresh tokens are single-use. Replace the stored refresh token after a successful refresh; `invalid_grant` requires reauthorization.
+- **Trakt is not TMDb:** Trakt IDs and discovery rankings are not TMDb metadata. Use the `tmdb` skill for credits, images, provider metadata, and catalog enrichment.
+- **Trending shape:** read `.movie` or `.show` before title/IDs, while preserving `watchers`.
+- **Pagination is per invocation:** one CLI call fetches exactly one page (`--page`); loop invocations reading `pagination.page_count` rather than expecting the script to follow links itself.
-```bash
-trakt-cli --json movie trending # flag before subcommand
-trakt-cli movie trending --json # flag after subcommand
-trakt-cli --dry-run movie trending # preview (no API call)
-trakt-cli --quiet movie trending # suppress non-essential output
-trakt-cli --verbose movie trending # detailed logging
-```
+## When to use
-## Known Gotchas
+Use Trakt for current watching signals, broad popularity, anticipated interest, and identifiers that feed a media workflow.
-- **Read-only by design** — The CLI only uses the Client ID flow. No OAuth, no writing to your Trakt lists. All endpoints are public discovery endpoints.
-- **No auth needed for these commands** — The trending, anticipated, and popular endpoints are public. Skip the setup if you only want to preview with `--dry-run`.
-- **Rate limiting** — Trakt API v2 has rate limits (~1,000 calls per 5 minutes for free apps). The CLI does not auto-retry on 429 responses.
-- **TRAKT_CLIENT_ID is required at runtime** — Unlike `--dry-run` which skips the API call, running live commands without the env var will fail with a clear error message.
-- **Pagination defaults to page 1** — The CLI uses `--limit` for the results per page. Default is 10, max is typically 50.
-- **TMDb/TVDB IDs** — Use `--json` to get the full IDs object (TMDb for movies, TVDB for shows) which is useful for lookups in other tools like Radarr/Sonarr.
+## When not to use
-## References
+Do not use Trakt for TMDb catalog metadata, credits, images, provider availability, or for writing a user's lists without an explicit OAuth-enabled workflow. Use `tmdb` for metadata and a dedicated authenticated operation for mutations.
-- [scripts/trakt-cli](scripts/trakt-cli) — The CLI binary. Built following the cli-builder patterns: `--json`, `--dry-run`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth.
-- [Trakt API Docs](https://trakt.docs.apiary.io/) — Official API reference.
-- [Trakt OAuth Applications](https://trakt.tv/oauth/applications) — Register an app to get your Client ID.
+## Reference files
+
+| File | Topic |
+|---|---|
+| [references/auth-and-request-contract.md](references/auth-and-request-contract.md) | Required headers, OAuth boundary, errors, and rate limits |
+| [references/discovery-endpoints.md](references/discovery-endpoints.md) | Endpoint semantics, filters, response shapes, and pagination |
+| [references/recipes-and-operations.md](references/recipes-and-operations.md) | Pipelines, jq normalization, and operational handling |
+
+## Available script and prerequisites
+
+- `scripts/trakt` is an executable Python CLI using only stdlib and `requests`.
+- `--dry-run` works without a Client ID and never performs network I/O.
+- Live discovery requires `TRAKT_CLIENT_ID`; tests are mock-only.
diff --git a/trakt/evals/evals.json b/trakt/evals/evals.json
new file mode 100644
index 0000000..7eb6a3c
--- /dev/null
+++ b/trakt/evals/evals.json
@@ -0,0 +1,48 @@
+{
+ "schema_version": 1,
+ "skill_name": "trakt",
+ "evals": [
+ {
+ "id": "trending-movies",
+ "prompt": "Show the 20 movies most watched recently using Trakt.",
+ "expected_output": "Use trakt movie trending --limit 20 and explain that trending is a recent-watch signal.",
+ "assertions": ["selects the movie trending command", "sets an explicit limit", "describes the recent watching window"]
+ },
+ {
+ "id": "popular-shows-json",
+ "prompt": "Get popular TV shows as JSON for a jq pipeline.",
+ "expected_output": "Run trakt --json tv popular and process the shows object with jq.",
+ "assertions": ["uses the tv popular command", "enables JSON output", "mentions jq processing"]
+ },
+ {
+ "id": "anticipated-pagination",
+ "prompt": "Explain how to collect anticipated movies across pages using the Trakt CLI without overrunning limits.",
+ "expected_output": "Loop trakt movie anticipated with --page, stop at pagination.page_count from --json output (empty pagination degrades to page 1), and honor Retry-After on 429.",
+ "assertions": ["uses --page with each CLI invocation", "stops at the normalized pagination.page_count value", "handles Retry-After for rate limiting"]
+ },
+ {
+ "id": "second-page-trending",
+ "prompt": "Fetch page 2 of Trakt trending movies as JSON.",
+ "expected_output": "Run trakt --json movie trending --page 2; JSON output keeps the movies array beside a pagination object mirroring X-Pagination headers.",
+ "assertions": ["passes --page 2 to the trending command", "names the pagination object keys page limit page_count item_count", "keeps the movies array intact in JSON output"]
+ },
+ {
+ "id": "header-pair-gotcha",
+ "prompt": "Why does my Trakt request with trakt-api-key still fail?",
+ "expected_output": "Send trakt-api-version: 2 together with trakt-api-key, plus JSON content type; the version header is mandatory.",
+ "assertions": ["documents the trakt-api-key header", "documents the trakt-api-version 2 companion header", "identifies the missing-header failure cause"]
+ },
+ {
+ "id": "tmdb-metadata-not-trigger",
+ "prompt": "Do not route this request to Trakt: enrich a movie with TMDb credits, images, and provider metadata.",
+ "expected_output": "Do not use Trakt; route catalog metadata enrichment to the tmdb skill.",
+ "assertions": ["must not trigger for TMDb metadata work", "names the tmdb skill as the alternative"]
+ },
+ {
+ "id": "oauth-boundary",
+ "prompt": "Do I need OAuth to see public trending and popular feeds, and what changes for my watchlist?",
+ "expected_output": "Public discovery reads use the application key and required version header; user-scoped watchlist operations require OAuth Bearer in addition to the app headers.",
+ "assertions": ["distinguishes public reads from user-scoped operations", "requires OAuth for watchlist work", "retains the application header pair"]
+ }
+ ]
+}
diff --git a/trakt/references/auth-and-request-contract.md b/trakt/references/auth-and-request-contract.md
new file mode 100644
index 0000000..1730ffa
--- /dev/null
+++ b/trakt/references/auth-and-request-contract.md
@@ -0,0 +1,32 @@
+# Trakt authentication and request contract
+
+## Public discovery
+
+Trakt v2 identifies an application with its Client ID in the `trakt-api-key` header. Every request must also send the companion header `trakt-api-version: 2`; sending only one of the pair can produce an invalid request or authentication-style failure. Use `Content-Type: application/json` and an identifying `User-Agent` as well.
+
+```sh
+curl --fail-with-body 'https://api.trakt.tv/movies/trending?page=1&limit=20' \
+ -H 'Content-Type: application/json' \
+ -H 'User-Agent: MyAppName/1.0.0' \
+ -H "trakt-api-key: ${TRAKT_CLIENT_ID}" \
+ -H 'trakt-api-version: 2'
+```
+
+The bundled CLI uses this application-key mode. It does not put the Client ID in `Authorization: Bearer`; that header is reserved for an OAuth access token.
+
+## OAuth boundary
+
+Public trending, popular, and anticipated reads do not require a user login. OAuth is needed by endpoints marked as required and is appropriate for user-scoped list, history, collection, watchlist, or mutation operations. A bearer token does not replace the application key and version header when calling the API.
+
+Trakt supports authorization-code and device-code flows. Access tokens last seven days. Refresh tokens are single-use: persist the replacement returned by a successful refresh and discard the old token. A 400/401 response containing `invalid_grant` means the session is no longer usable and requires reauthorization. Never log client secrets, access tokens, or refresh tokens.
+
+## Failure handling
+
+Treat 401 and 403 as credential or app-approval errors, 400/422 as request validation errors, and 429 as rate limiting. On 429, honor `Retry-After` and inspect `X-Ratelimit`; do not retry forever. Transient 502/503/504 responses can be retried with a bounded backoff. The CLI surfaces status and response details without attempting unsafe retries.
+
+## Sources
+
+- https://docs.trakt.tv/docs/required-headers
+- https://docs.trakt.tv/docs/getting-started
+- https://docs.trakt.tv/docs/authentication-oauth
+- https://trakt.docs.apiary.io/api-description-document
diff --git a/trakt/references/discovery-endpoints.md b/trakt/references/discovery-endpoints.md
new file mode 100644
index 0000000..76ba4bc
--- /dev/null
+++ b/trakt/references/discovery-endpoints.md
@@ -0,0 +1,36 @@
+# Trakt discovery endpoints
+
+All endpoints below are GET requests at `https://api.trakt.tv` and use the request contract in `auth-and-request-contract.md`.
+
+| Endpoint | Meaning | Response shape |
+|---|---|---|
+| `/movies/trending` | Most watched movies in the last 24 hours, ordered by watchers | wrapper objects with `watchers` and nested `movie` |
+| `/movies/popular` | Popularity based on rating percentage and number of ratings | movie objects |
+| `/movies/anticipated` | Upcoming interest based on list appearances | movie objects |
+| `/shows/trending` | Most watched shows in the last 24 hours, ordered by watchers | wrapper objects with `watchers` and nested `show` |
+| `/shows/popular` | Popularity based on rating percentage and number of ratings | show objects |
+| `/shows/anticipated` | Upcoming interest based on list appearances | show objects |
+
+Trending is a short, current watch signal. Popular is a broad popularity ranking, while anticipated is an upcoming-interest signal. Do not treat a trending rank as a release calendar or a popularity score as a personalized recommendation.
+
+## Paging and filters
+
+These feeds accept `page` and `limit`; compatibility defaults are page 1 and limit 10. Set both explicitly for reproducible automation. Responses provide `X-Pagination-Page`, `X-Pagination-Limit`, `X-Pagination-Page-Count`, and `X-Pagination-Item-Count`. Stop at the reported page count instead of assuming a short page means completion.
+
+The bundled CLI forwards `--page` and `--limit` to the query string and normalizes those four headers into a JSON `pagination` object with the keys `page`, `limit`, `page_count`, and `item_count`. Keys are integers when the headers were present; the object is `{}` when the headers are missing, so downstream jq can fall back with `.pagination.page_count // 1`.
+
+Endpoint pages also document filters such as `extended`, `watchnow`, `genres`, `years`, `ratings`, date ranges, countries, and `ignore_watched`, `ignore_collected`, and `ignore_watchlisted` where supported. Encode comma-separated values as query parameters. `watchnow=any` means any service, while `any_all` and the `free_all`/`subscriptions_all` forms have stricter all-country semantics.
+
+## Result normalization
+
+For trending responses, unwrap `movie` or `show` before reading title, year, and IDs, but preserve `watchers` if ranking matters. Popular and anticipated responses are already direct media objects. Trakt IDs are not TMDb metadata: use the returned `ids` object to hand an identifier to another tool, and use TMDb when the task is catalog metadata, credits, images, or provider details.
+
+## Sources
+
+- https://docs.trakt.tv/reference/getmoviestrending
+- https://docs.trakt.tv/reference/getmoviespopular
+- https://docs.trakt.tv/reference/getmoviesanticipated
+- https://docs.trakt.tv/reference/getshowstrending
+- https://docs.trakt.tv/reference/getshowspopular
+- https://docs.trakt.tv/reference/getshowsanticipated
+- https://trakt.docs.apiary.io/reference/movies/trending/get-trending-movies
diff --git a/trakt/references/recipes-and-operations.md b/trakt/references/recipes-and-operations.md
new file mode 100644
index 0000000..393f103
--- /dev/null
+++ b/trakt/references/recipes-and-operations.md
@@ -0,0 +1,40 @@
+# Trakt recipes and operations
+
+## Trending to a handoff
+
+1. Run `trakt movie trending --limit 20 --json`.
+2. For each object, read `.movie` and retain `.watchers` as the current-watch signal.
+3. Pass `.movie.ids.tmdb` or `.movie.ids.imdb` to the next tool only when present; do not mistake a Trakt response for TMDb metadata.
+
+```sh
+trakt --json movie trending --limit 20 |
+ jq '.movies[] | {title: (.movie.title // .title), year: (.movie.year // null), watchers: (.watchers // null), ids: (.movie.ids // .ids)}'
+```
+
+## Compare discovery signals
+
+Fetch the same page of `movie trending`, `movie popular`, and `movie anticipated`. Trending answers "watched recently"; popular answers "high broad popularity"; anticipated answers "appears on many upcoming-interest lists." Keep these datasets labeled when combining them.
+
+## Paginate anticipated releases
+
+The CLI fetches exactly one page per invocation; loop it. Drive the bound from the normalized pagination metadata: `--json` output carries `.pagination.page_count` (empty `{}` if a response lacked the headers, so fall back with jq's `// 1`).
+
+```sh
+pages=$(trakt --json movie anticipated --page 1 --limit 100 | jq -r '.pagination.page_count // 1')
+for p in $(seq 1 "$pages"); do
+ trakt --json movie anticipated --page "$p" --limit 100 > "anticipated-$p.json"
+done
+```
+
+If you call the API directly instead of through the script, inspect the raw `X-Pagination-Page-Count` header and stop at that count; do not stop merely because a page returned fewer items than `--limit`. If the response is 429, wait at least the numeric `Retry-After` value and cap retries before continuing the loop.
+
+## JSON processing
+
+`--json` emits an object with `movies` or `shows` plus a `pagination` object (`page`, `limit`, `page_count`, `item_count`); trending elements retain their wrapper shape, and human output adds a `Page N of M` footer only when the headers were present. Use `jq` for selection and `@csv` only after explicitly handling null IDs. Human output is for inspection, JSON output is for pipelines.
+
+## Sources
+
+- https://docs.trakt.tv/docs/required-headers
+- https://docs.trakt.tv/reference/getmoviestrending
+- https://docs.trakt.tv/reference/getmoviesanticipated
+- https://trakt.docs.apiary.io/reference/movies/anticipated/get-most-anticipated-movies
diff --git a/trakt/scripts/test_trakt.py b/trakt/scripts/test_trakt.py
new file mode 100644
index 0000000..ade19bf
--- /dev/null
+++ b/trakt/scripts/test_trakt.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+"""Offline tests for the Trakt discovery CLI."""
+import importlib.machinery
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+from unittest import TestCase, mock
+
+SCRIPT = Path(__file__).with_name("trakt")
+loader = importlib.machinery.SourceFileLoader("trakt_cli", str(SCRIPT))
+spec = importlib.util.spec_from_loader(loader.name, loader)
+trakt = importlib.util.module_from_spec(spec)
+sys.modules[spec.name] = trakt
+loader.exec_module(trakt)
+
+
+def _response(items, headers):
+ response = mock.Mock(status_code=200)
+ response.json.return_value = items
+ response.headers = headers
+ return response
+
+
+FULL_PAGINATION_HEADERS = {
+ "X-Pagination-Page": "2",
+ "X-Pagination-Limit": "1",
+ "X-Pagination-Page-Count": "3405",
+ "X-Pagination-Item-Count": "10",
+}
+
+
+class TraktCliTests(TestCase):
+ """Original CLI surface coverage: help, errors, dry-run, header injection."""
+
+ def run_cli(self, *args, **kwargs):
+ env = os.environ.copy()
+ env.pop("TRAKT_CLIENT_ID", None)
+ return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True, env=env)
+
+ def test_help_lists_discovery_groups(self):
+ result = self.run_cli("--help")
+ self.assertEqual(result.returncode, 0)
+ self.assertIn("movie", result.stdout)
+ self.assertIn("tv", result.stdout)
+
+ def test_argument_error_is_nonzero(self):
+ result = self.run_cli("movie", "unknown")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("invalid choice", result.stderr)
+
+ def test_dry_run_json_is_valid_without_network(self):
+ result = self.run_cli("--dry-run", "--json", "movie", "trending")
+ self.assertEqual(result.returncode, 0)
+ payload = json.loads(result.stdout)
+ self.assertEqual(payload, {"dry_run": True})
+
+ @mock.patch.object(trakt.requests, "get")
+ def test_client_injects_required_header_pair(self, get):
+ response = _response([{"movie": {"title": "Example"}}], {})
+ get.return_value = response
+ client = trakt.TraktClient(client_id="CLIENT_ID")
+ client.movie_trending(limit=4)
+ headers = get.call_args.kwargs["headers"]
+ self.assertEqual(headers["trakt-api-key"], "CLIENT_ID")
+ self.assertEqual(headers["trakt-api-version"], "2")
+ self.assertEqual(headers["Content-Type"], "application/json")
+
+ @mock.patch.object(trakt, "die")
+ @mock.patch.object(trakt.requests, "get")
+ def test_client_reports_http_error(self, get, die):
+ response = mock.Mock(status_code=403)
+ response.json.return_value = {"message": "forbidden"}
+ response.headers = {}
+ get.return_value = response
+ trakt.TraktClient(client_id="CLIENT_ID").movie_popular()
+ die.assert_called_once()
+ self.assertIn("403", die.call_args.args[0])
+
+
+class PaginationRequestTests(TestCase):
+ """--page/--limit flow from argv into request query parameters."""
+
+ def setUp(self):
+ flags = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+ patcher = mock.patch.object(trakt, "GLOBAL_FLAGS", flags)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_page_two_is_sent_as_query_parameter(self):
+ client = trakt.TraktClient(client_id="CLIENT_ID")
+ client._get = mock.Mock(return_value=([], {"page": 2}))
+ with mock.patch("builtins.print"):
+ trakt.cmd_discovery(client, "movie", "trending", ["--page", "2"])
+ client._get.assert_called_once_with("/movies/trending", {"page": 2, "limit": 10})
+
+ @mock.patch.object(trakt.requests, "get")
+ def test_requests_get_receives_page_and_limit_params(self, get):
+ get.return_value = _response([], {})
+ client = trakt.TraktClient(client_id="CLIENT_ID")
+ client.tv_popular(page=3, limit=25)
+ self.assertEqual(get.call_args.kwargs["params"], {"page": 3, "limit": 25})
+
+ def test_every_discovery_command_accepts_explicit_page_and_limit(self):
+ pairs = [("movie", action) for action in ("trending", "popular", "anticipated")]
+ pairs += [("tv", action) for action in ("trending", "popular", "anticipated")]
+ segments = {"movie": "movies", "tv": "shows"}
+ keys = {"movie": "movies", "tv": "shows"}
+ for resource, action in pairs:
+ with self.subTest(command=f"{resource} {action}"):
+ client = trakt.TraktClient(client_id="CLIENT_ID")
+ client._get = mock.Mock(return_value=(None, {}))
+ with mock.patch("builtins.print") as printed:
+ trakt.cmd_discovery(client, resource, action, ["--page", "3", "--limit", "7"])
+ expected_path = f"/{segments[resource]}/{action}"
+ client._get.assert_called_once_with(expected_path, {"page": 3, "limit": 7})
+ if trakt.GLOBAL_FLAGS["json"]:
+ self.assertEqual(json.loads(printed.call_args.args[0]), {keys[resource]: [], "pagination": {}})
+ else:
+ self.assertIn("No", printed.call_args.args[0])
+
+ def test_dry_run_json_accepts_page_without_network_or_credentials(self):
+ env = os.environ.copy()
+ env.pop("TRAKT_CLIENT_ID", None)
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT), "--dry-run", "--json", "tv", "anticipated", "--page", "2"],
+ capture_output=True, text=True, env=env,
+ )
+ self.assertEqual(result.returncode, 0)
+ self.assertTrue(json.loads(result.stdout)["dry_run"])
+
+
+class PaginationHeaderTests(TestCase):
+ """X-Pagination-* normalization and missing-header degradation."""
+
+ def test_all_four_headers_map_onto_stable_keys(self):
+ pagination = trakt.normalize_pagination(dict(FULL_PAGINATION_HEADERS))
+ self.assertEqual(
+ pagination,
+ {"page": 2, "limit": 1, "page_count": 3405, "item_count": 10},
+ )
+
+ def test_lowercase_header_names_are_normalized(self):
+ lower = {key.lower(): value for key, value in FULL_PAGINATION_HEADERS.items()}
+ self.assertEqual(trakt.normalize_pagination(lower)["page_count"], 3405)
+
+ def test_missing_headers_degrade_to_empty_object(self):
+ self.assertEqual(trakt.normalize_pagination({}), {})
+
+ def test_unparseable_and_partial_values_are_skipped(self):
+ headers = {"X-Pagination-Page": "2", "X-Pagination-Limit": "", "X-Pagination-Item-Count": "not-a-number"}
+ pagination = trakt.normalize_pagination(headers)
+ self.assertEqual(pagination, {"page": 2})
+
+ @mock.patch.object(trakt.requests, "get")
+ def test_response_without_pagination_headers_yields_empty_pagination_object(self, get):
+ get.return_value = _response([{"movie": {"title": "Example"}}], {"Content-Type": "application/json"})
+ _, pagination = trakt.TraktClient(client_id="CLIENT_ID").movie_trending()
+ self.assertEqual(pagination, {})
+
+
+class DiscoveryOutputTests(TestCase):
+ """Stable JSON shapes beside the new pagination metadata."""
+
+ def json_payload_for(self, resource, endpoint, argv, items, headers=None):
+ flags = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
+ client = trakt.TraktClient(client_id="CLIENT_ID")
+ if hasattr(client, f"{resource}_{endpoint}"):
+ setattr(client, f"{resource}_{endpoint}",
+ mock.Mock(return_value=(items, trakt.normalize_pagination(headers or {}))))
+ else:
+ client._get = mock.Mock(return_value=(items, trakt.normalize_pagination(headers or {})))
+ with mock.patch.object(trakt, "GLOBAL_FLAGS", flags), mock.patch("builtins.print") as printed:
+ trakt.cmd_discovery(client, resource, endpoint, argv)
+ return json.loads(printed.call_args.args[0])
+
+ def test_movie_trending_json_keeps_movies_key_beside_pagination(self):
+ payload = self.json_payload_for(
+ "movie", "trending", ["--page", "2"],
+ [{"movie": {"title": "Heat", "year": 1995, "ids": {"tmdb": 949}}}],
+ FULL_PAGINATION_HEADERS,
+ )
+ self.assertIn("movies", payload)
+ self.assertEqual(payload["pagination"],
+ {"page": 2, "limit": 1, "page_count": 3405, "item_count": 10})
+ entry = payload["movies"][0]
+ self.assertEqual(entry["movie"]["title"], "Heat")
+ self.assertEqual(entry["movie"]["ids"]["tmdb"], 949)
+
+ def test_tv_popular_json_keeps_show_objects_directly_nested(self):
+ payload = self.json_payload_for(
+ "tv", "popular", ["--limit", "5"],
+ [{"title": "Poirot", "year": 1989, "ids": {"tvdb": 70739}}][:1],
+ FULL_PAGINATION_HEADERS,
+ )
+ self.assertIn("shows", payload)
+ self.assertEqual(payload["shows"][0]["title"], "Poirot")
+ self.assertEqual(payload["shows"][0]["ids"]["tvdb"], 70739)
+ self.assertEqual(payload["pagination"]["page_count"], 3405)
+
+ def test_tv_trending_keeps_wrapper_shape_in_json(self):
+ payload = self.json_payload_for(
+ "tv", "trending", [],
+ [{"show": {"title": "Severance", "ids": {"tvdb": 365278}}}],
+ FULL_PAGINATION_HEADERS,
+ )
+ self.assertIn("show", payload["shows"][0])
+ self.assertEqual(payload["pagination"]["item_count"], 10)
+
+ def test_human_output_states_current_and_total_pages(self):
+ client = trakt.TraktClient(client_id="CLIENT_ID")
+ client.movie_trending = mock.Mock(return_value=(
+ [{"movie": {"title": "Heat", "year": 1995, "ids": {"tmdb": 949}}}],
+ {"page": 2, "limit": 1, "page_count": 3405, "item_count": 10},
+ ))
+ with mock.patch.object(trakt, "GLOBAL_FLAGS",
+ {"json": False, "dry_run": False, "quiet": False, "verbose": False}), \
+ mock.patch("builtins.print") as printed:
+ trakt.cmd_discovery(client, "movie", "trending", ["--page", "2"])
+ output = printed.call_args.args[0]
+ self.assertIn("Heat", output)
+ self.assertIn("Page 2 of 3405", output)
+
+ def test_human_output_without_pagination_headers_prints_no_page_line(self):
+ client = trakt.TraktClient(client_id="CLIENT_ID")
+ client.tv_popular = mock.Mock(return_value=(
+ [{"show": {"title": "Fargo", "year": 2014, "ids": {"tvdb": 269584}}}], {},
+ ))
+ with mock.patch.object(trakt, "GLOBAL_FLAGS",
+ {"json": False, "dry_run": False, "quiet": False, "verbose": False}), \
+ mock.patch("builtins.print") as printed:
+ trakt.cmd_discovery(client, "tv", "popular", [])
+ output = printed.call_args.args[0]
+ self.assertIn("Fargo", output)
+ self.assertNotIn("Page ", output)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/trakt/scripts/trakt-cli b/trakt/scripts/trakt
similarity index 58%
rename from trakt/scripts/trakt-cli
rename to trakt/scripts/trakt
index 0a7bee2..384b87c 100755
--- a/trakt/scripts/trakt-cli
+++ b/trakt/scripts/trakt
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""trakt-cli — Trakt.tv media discovery from the terminal.
+"""trakt — Trakt.tv media discovery from the terminal.
Discover trending, anticipated, and popular movies and TV shows.
Calendar of upcoming releases. Uses Trakt.tv API with Client ID.
@@ -12,9 +12,20 @@ import requests
ENV_CLIENT_ID = os.getenv("TRAKT_CLIENT_ID", "")
API_BASE = "https://api.trakt.tv"
+USER_AGENT = "agent-skills-trakt/1.0"
QUIET = False
GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False}
+
+# Canonical X-Pagination-* response headers mapped onto the stable JSON keys
+# surfaced as the `pagination` object beside every discovery result.
+PAGINATION_HEADERS: Tuple[Tuple[str, str], ...] = (
+ ("X-Pagination-Page", "page"),
+ ("X-Pagination-Limit", "limit"),
+ ("X-Pagination-Page-Count", "page_count"),
+ ("X-Pagination-Item-Count", "item_count"),
+)
+
def log(m): global QUIET; (not QUIET and not GLOBAL_FLAGS.get("json")) and print(m)
def warn(m): print(f"Warning: {m}", file=sys.stderr)
def die(m, c=1): print(f"Error: {m}", file=sys.stderr); sys.exit(c)
@@ -22,6 +33,25 @@ def emit(h, d):
if GLOBAL_FLAGS.get("json"): print(json.dumps(d, default=str))
else: print(h)
+def normalize_pagination(headers):
+ """Map X-Pagination-* headers onto the stable pagination keys.
+
+ Missing or non-numeric header values fall out of the mapping, so a
+ response without pagination metadata degrades to an empty object
+ instead of failing.
+ """
+ pagination: Dict[str, int] = {}
+ for header, key in PAGINATION_HEADERS:
+ raw = None
+ for name in (header, header.lower()):
+ if name in headers:
+ raw = headers[name]
+ break
+ if raw is None: continue
+ try: pagination[key] = int(str(raw).strip())
+ except (TypeError, ValueError): continue
+ return pagination
+
def _preparse(argv):
BOOLS = {"--json","--dry-run","--quiet","--verbose"}
f, fl = {}, [argv[0]]
@@ -39,20 +69,21 @@ class TraktClient:
self.client_id = client_id or ENV_CLIENT_ID
self.dry_run = dry_run
def _get(self, path, params=None):
+ """Fetch one page; returns (json data, normalized pagination dict)."""
url = f"{API_BASE}{path}"
- if self.dry_run: return [{"dry_run":True, "url":url, "params":params}]
+ if self.dry_run: return [{"dry_run":True, "url":url, "params":params}], {}
if not self.client_id: die("TRAKT_CLIENT_ID not set. Get one from trakt.tv/oauth/applications.")
try:
r = requests.get(url, params=params, headers={
- "Content-Type": "application/json", "trakt-api-version": "2",
- "trakt-api-key": self.client_id}, timeout=30)
+ "Content-Type": "application/json", "User-Agent": USER_AGENT,
+ "trakt-api-version": "2", "trakt-api-key": self.client_id}, timeout=30)
except ConnectionError as e: die(f"Cannot connect: {e}")
if r.status_code == 401: die("Auth failed (401). Check TRAKT_CLIENT_ID.")
if r.status_code >= 400:
try: d = r.json()
except: d = r.text[:200]
die(f"API error ({r.status_code}): {d}")
- return r.json()
+ return r.json(), normalize_pagination(dict(r.headers))
def movie_trending(self, page=1, limit=10):
return self._get("/movies/trending", {"page":page, "limit":limit})
def movie_anticipated(self, page=1, limit=10):
@@ -91,27 +122,28 @@ def fmt_show(d, idx=None):
s_out = f" {'%d. '%idx if idx else ''}{t:35}{net_str} ({y}) tvdb={tvdb}"
return s_out
-def cmd_movie(client, args, endpoint):
- p = argparse.ArgumentParser(prog=f"trakt movie {endpoint}")
- p.add_argument("--limit", type=int, default=10)
+def cmd_discovery(client, resource, endpoint, args):
+ fmt = fmt_movie if resource == "movie" else fmt_show
+ list_key = "movies" if resource == "movie" else "shows"
+ plural = "movies" if resource == "movie" else "TV shows"
+ label = "Movie" if resource == "movie" else "TV"
+ p = argparse.ArgumentParser(prog=f"trakt {resource} {endpoint}")
+ p.add_argument("--page", type=int, default=1,
+ help="1-based page number to fetch (default 1)")
+ p.add_argument("--limit", type=int, default=10,
+ help="items per page (default 10)")
parsed, _ = p.parse_known_args(args)
- if client.dry_run: return emit(f"[dry-run] Movie {endpoint}", {"dry_run":True})
- fn = getattr(client, f"movie_{endpoint}")
- data = fn(limit=parsed.limit) or []
- if not data: return emit(f"No {endpoint} movies.", {"movies":[]})
- lines = [fmt_movie(d, i+1) for i, d in enumerate(data[:parsed.limit])]
- emit(f"{endpoint.title()} movies:\n"+"\n".join(lines), {"movies":data[:parsed.limit]})
-
-def cmd_tv(client, args, endpoint):
- p = argparse.ArgumentParser(prog=f"trakt tv {endpoint}")
- p.add_argument("--limit", type=int, default=10)
- parsed, _ = p.parse_known_args(args)
- if client.dry_run: return emit(f"[dry-run] TV {endpoint}", {"dry_run":True})
- fn = getattr(client, f"tv_{endpoint}")
- data = fn(limit=parsed.limit) or []
- if not data: return emit(f"No {endpoint} TV shows.", {"shows":[]})
- lines = [fmt_show(d, i+1) for i, d in enumerate(data[:parsed.limit])]
- emit(f"{endpoint.title()} TV:\n"+"\n".join(lines), {"shows":data[:parsed.limit]})
+ if client.dry_run: return emit(f"[dry-run] {label} {endpoint}", {"dry_run":True})
+ data, pagination = getattr(client, f"{resource}_{endpoint}")(page=parsed.page, limit=parsed.limit)
+ data = data or []
+ entries = data[:parsed.limit] if isinstance(data, list) else []
+ payload: Dict[str, Any] = {list_key: entries, "pagination": pagination}
+ if not entries: return emit(f"No {endpoint} {plural}.", payload)
+ lines = [fmt(entry, i+1) for i, entry in enumerate(entries)]
+ output = f"{endpoint.title()} {'movies' if resource == 'movie' else 'TV'}:\n"+"\n".join(lines)
+ if pagination.get("page") is not None and pagination.get("page_count"):
+ output += f"\n Page {pagination['page']} of {pagination['page_count']}"
+ emit(output, payload)
def main():
global GLOBAL_FLAGS, QUIET
@@ -124,20 +156,22 @@ def main():
mp = sub.add_parser("movie", help="Movie discovery")
ms = mp.add_subparsers(dest="action")
for a in ["trending","anticipated","popular"]:
- ms.add_parser(a, help=f"{a.title()} movies").add_argument("--limit", type=int, default=10)
+ ap = ms.add_parser(a, help=f"{a.title()} movies")
+ ap.add_argument("--page", type=int, default=1, help="page number to fetch (default 1)")
+ ap.add_argument("--limit", type=int, default=10, help="items per page (default 10)")
tp = sub.add_parser("tv", help="TV discovery")
ts = tp.add_subparsers(dest="action")
for a in ["trending","anticipated","popular"]:
- ts.add_parser(a, help=f"{a.title()} TV").add_argument("--limit", type=int, default=10)
+ ap = ts.add_parser(a, help=f"{a.title()} TV")
+ ap.add_argument("--page", type=int, default=1, help="page number to fetch (default 1)")
+ ap.add_argument("--limit", type=int, default=10, help="items per page (default 10)")
args = parser.parse_args(filtered_argv[1:])
if not args.resource: parser.print_help(); sys.exit(1)
client = TraktClient(dry_run=GLOBAL_FLAGS.get("dry_run",False))
remaining = filtered_argv[filtered_argv.index(args.resource)+1:]
- if args.resource == "movie":
- if args.action: cmd_movie(client, remaining, args.action)
- else: mp.print_help()
- elif args.resource == "tv":
- if args.action: cmd_tv(client, remaining, args.action)
+ if args.resource in ("movie","tv"):
+ if args.action: cmd_discovery(client, args.resource, args.action, remaining)
+ elif args.resource == "movie": mp.print_help()
else: tp.print_help()
else: parser.print_help()
diff --git a/transistor/README.md b/transistor/README.md
index 655bde6..7ead58e 100644
--- a/transistor/README.md
+++ b/transistor/README.md
@@ -1,37 +1,65 @@
# Transistor.fm — Podcast Hosting from the Terminal
-Manage your Transistor.fm podcast shows, episodes, subscribers, and analytics — all from the terminal.
+Manage your Transistor.fm podcast account over its official API: browse
+shows and episodes, publish episodes, pull download analytics, and run
+private-podcast subscriber lists — all from the terminal.
## Why Install This Skill
-When your agent loads this skill, it can **manage your podcast hosting** without the web dashboard. That means:
+When your agent loads this skill, it can **operate your Transistor.fm
+podcast hosting** without the dashboard, including the part no other tool
+gives an agent: the full episode publish lifecycle.
-- **List shows** — all your podcasts with episode and subscriber counts
-- **Browse episodes** — recent episodes with publish dates
-- **Check analytics** — subscriber counts and trends
-- **Filter by show** — drill into a specific podcast's episodes
+- **Publish episodes end to end** — create a draft, attach audio (URL or
+ authorized local-file upload), then publish or schedule it through
+ Transistor's dedicated publish endpoint
+- **Browse your catalog** — shows, episodes, drafts, season/number
+ metadata, with JSON:API compound documents unwrapped for jq
+- **Track downloads** — per-day analytics windows for shows and episodes,
+ summed and ready for reports
+- **Run private podcasts** — list, add (single or batch), and revoke
+ subscribers; register webhooks so you push instead of poll
+- **Stay under the rate limit** — dry-run request plans and clear 429
+ guidance (Transistor allows 10 requests per 10 seconds)
## What You Get
-| Directory | Purpose |
-|-----------|---------|
-| `SKILL.md` | Complete command reference with examples |
-| `scripts/transistor-cli` | CLI tool for Transistor.fm v1 REST API |
+| Path | Purpose |
+|------|---------|
+| `SKILL.md` | Command reference, publish-lifecycle recipe, jq guidance, gotchas |
+| `scripts/transistor` | Bundled Python CLI for the Transistor.fm v1 API (read + write commands) |
+| `scripts/test_transistor.py` | Offline mocked test suite (canned JSON:API documents, zero network) |
+| `references/auth-and-basics.md` | API-key auth, JSON:API envelope and jq patterns, pagination, errors |
+| `references/endpoint-catalog.md` | Every endpoint's method, path, and parameters |
+| `references/episode-publish-lifecycle.md` | Draft → audio → publish/schedule/unpublish, exact request shapes |
+| `references/gotchas-and-recipes.md` | Symptom → cause → fix guide plus multi-step workflows |
## Quick Start
```bash
-export TRANSISTOR_API_KEY="your-transistor-api-key"
-transistor-cli shows
-transistor-cli episodes
+export TRANSISTOR_API_KEY="" # Dashboard -> Account -> API Access
+
+transistor user # verify the key
+transistor shows # list your podcasts
+transistor episodes --status draft # what is not out yet?
+
+# Publish pipeline: create (draft) -> attach audio -> publish
+EP=$(transistor episode-create --show --title "Ep 12" \
+ --audio-url "https://example.com/ep12.mp3" --json | jq -r '.id')
+transistor episode-publish --id "$EP"
```
-API key from Settings → API Keys in the Transistor.fm dashboard.
+`--help` and `--dry-run` work without an API key; preview any request with
+`transistor --dry-run episode-publish --id 123`.
## Triggers
-Load this for Transistor.fm, podcast hosting, podcast analytics, show management, or episode tracking.
+Load this skill when the user mentions Transistor or Transistor.fm, podcast
+hosting, publishing a podcast episode, scheduling or unpublishing episodes,
+podcast download analytics, or private podcast subscribers.
## Requirements
-Python 3.8+ with `requests` library.
+Python 3.8+ with `requests`, plus a Transistor.fm API key (Account page →
+API Access). The key carries your dashboard role per podcast; treat it like
+a password. No other services or credentials are involved.
diff --git a/transistor/SKILL.md b/transistor/SKILL.md
index 7d6b168..a776b08 100644
--- a/transistor/SKILL.md
+++ b/transistor/SKILL.md
@@ -1,98 +1,308 @@
---
name: transistor
-description: 'Manage Transistor.fm podcast hosting from the terminal: view shows,
- list episodes, check analytics, and get subscriber counts. Use when the user mentions
- Transistor, podcast hosting, podcast analytics, show management, or episode tracking.'
+description: >-
+ Operate Transistor.fm podcast hosting from the terminal: verify API access,
+ browse shows and episodes with JSON:API-aware output, run the episode
+ publish lifecycle (create draft, attach audio, publish or schedule via the
+ dedicated publish endpoint), pull download analytics, and manage private
+ podcast subscribers and webhooks. Use when the user mentions Transistor,
+ Transistor.fm, podcast hosting, episode publishing, private podcast
+ subscribers, or podcast download analytics. Do not use this skill for other
+ podcast hosts (Buzzsprout, Libsyn, Megaphone, Spotify for Creators), for
+ editing or producing audio, or for feed/RSS parsing — the bundled CLI
+ manages a Transistor account through its v1 API and cannot create new
+ shows (dashboard-only).
license: MIT
-compatibility: Requires TRANSISTOR_API_KEY env var (from Settings → API Keys in the
- Transistor.fm dashboard), Python 3.8+, and the `requests` library. Uses the Transistor.fm
- v1 REST API with JSON:API format responses.
+compatibility: Requires TRANSISTOR_API_KEY env var (Account page -> API Access
+ at https://dashboard.transistor.fm/account), Python 3.8+, and `requests`.
+ Read commands need a working key; `--help` and `--dry-run` never do.
metadata:
- tags: transistor, podcast, podcast-hosting, analytics, api-client
- sources: https://transistor.fm/, https://developers.transistor.fm/
+ tags: transistor, podcast, podcast-hosting, episodes, analytics, api-client
+ sources: https://developers.transistor.fm/, https://support.transistor.fm/
---
-# transistor-cli — Transistor.fm Podcast Hosting
+# transistor — Transistor.fm podcast hosting from the terminal
-Manage your Transistor.fm podcast shows, episodes, subscribers, and analytics from the terminal. Uses the Transistor.fm v1 REST API.
+Drive a Transistor.fm account over its v1 JSON:API: shows, episodes, the
+draft→publish lifecycle, per-day download analytics, private-podcast
+subscribers, and webhooks. Responses are JSON:API documents; the bundled CLI
+unwraps them (`--json`) while preserving the raw shapes agents need for jq.
+Write commands are guarded: episode creation is always a draft, and
+publishing goes through its own dedicated endpoint.
## Setup
-1. Get your API key from Transistor.fm: **Settings → API Keys** (bottom of the page)
-2. Set the environment variable:
+1. Find your API key on the Transistor dashboard **Account page → API
+ Access** (https://dashboard.transistor.fm/account) and export it:
```bash
-export TRANSISTOR_API_KEY="your-transistor-api-key"
+export TRANSISTOR_API_KEY=""
```
-`--help` and `--dry-run` work without credentials.
+2. Verify the key (GET /v1 — the authorization probe; there is no
+ /v1/user route):
+
+```bash
+transistor user # name and time zone
+transistor user --json | jq '{id, name, time_zone}'
+```
+
+A key carries the dashboard role of its user (owner / admin / team member)
+per podcast. `--help` and `--dry-run` work without credentials. Requests are
+rate-limited to 10 per 10 seconds; the CLI dies with a clear 429 message
+instead of hammering.
## Essential Commands
-### user — Current user info
+### user — authorization probe
```bash
-transistor-cli user # email and timezone
-transistor-cli user --json # machine-readable
+transistor user # who does this key belong to?
+transistor user --json
```
-### shows — List all shows
+### shows / show — browse podcasts
```bash
-transistor-cli shows # all shows with episode/subscriber counts
-transistor-cli shows --json # machine-readable with IDs
+transistor shows # newest-updated first
+transistor shows --private --json # private podcasts only
+transistor show --id # full attributes incl. feed_url
+transistor shows --page 1 --per 20 --json
```
-Shows title, episode count, subscriber count, and ID.
+Show ids and slugs are interchangeable on most show-scoped routes. Show
+resources carry no counts fields — count via `episodes --show ... --json`,
+then `meta.totalCount`.
-### episodes — List episodes
+### episodes / episode — browse episodes
```bash
-transistor-cli episodes # last 20 episodes across all shows
-transistor-cli episodes --show 12345 # filter by show ID
-transistor-cli episodes --limit 50 # more results
-transistor-cli episodes --show 12345 --json # machine-readable
+transistor episodes # newest first, all shows
+transistor episodes --show --status draft # drafts for one show
+transistor episodes --show --per 50 --page 1 --json
+transistor episode --id --include show # compound doc + parent show
```
-Get show IDs from `transistor-cli shows --json`. Shows season/episode numbers, title, status, duration, and publish date.
+`--include show` adds `included[]` (the JSON:API compound document); every
+episode item in `--json` output already carries `show_id` resolved from
+relationships. `--limit` works as an alias for `--per` for old scripts.
-### analytics — Download and play analytics
+### episode-create / episode-update — drafts and metadata
```bash
-transistor-cli analytics # totals across all shows
-transistor-cli analytics --show 12345 # filter by show ID
-transistor-cli analytics --json # machine-readable with full totals
+transistor episode-create --show --title "Ep 12: Roasting" \
+ --season 2 --number 4 --audio-url "https://uploads.example.com/ep12.mp3"
+transistor episode-update --id --title "New title"
+transistor episode-update --id --audio-url "" # attach audio
```
-Shows total downloads and plays (when available).
+`episode-create` ALWAYS produces a draft (`status: "draft"`,
+`published_at: null`) — it never publishes. `episode-update` changes
+metadata or attaches audio and never touches publishing state.
-## Global Flags
-
-All flags work in any position:
+### episode-publish — the lifecycle switch
```bash
-transistor-cli --json shows # flag before subcommand
-transistor-cli shows --json # flag after subcommand
-transistor-cli --dry-run episodes # preview (no API call)
-transistor-cli --force episodes --limit 100 # override safety checks
-transistor-cli --quiet shows # suppress non-essential output
-transistor-cli --verbose episodes # detailed logging
+transistor episode-publish --id # publish now
+transistor episode-publish --id --status scheduled \
+ --published-at "2026-09-03 09:00:00" # schedule
+transistor episode-publish --id --status draft # unpublish
```
-Extra flag vs other CLIs: `--force` overrides internal safety checks (e.g. large limits).
+Hits `PATCH /v1/episodes//publish` with
+`episode[status]=draft|scheduled|published` — the documented dedicated
+endpoint. The CLI refuses to publish an episode whose `media_url` is still
+empty (an unplayable item would hit every subscriber's feed); pass
+`--force` to override.
+
+### authorize-upload — local audio (max 5GB)
+
+```bash
+transistor authorize-upload --filename ep12.mp3 # plan only
+transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3
+```
+
+Returns (and, with `--file`, performs) the signed PUT; the printed
+`audio_url` is what you attach with `episode-create`/`episode-update`. The
+signed URL expires (~600 s in the docs' example).
+
+### analytics / episode-analytics — downloads per day
+
+```bash
+transistor analytics --show # last 14 days
+transistor analytics --show \
+ --start-date 01-08-2026 --end-date 28-08-2026 --json
+transistor episode-analytics --id --json
+```
+
+Dates are dd-mm-yyyy and must come in pairs. Analytics attributes are
+per-day `downloads[]` arrays, not totals; the CLI sums them into
+`downloads_total` and keeps the raw array.
+
+### subscribers — private podcast audience
+
+```bash
+transistor subscribers --show --json
+transistor subscriber-create --show --email "listener@example.com"
+transistor subscriber-batch --show --email "a@example.com" --email "b@example.com"
+transistor subscriber-delete --show --email "a@example.com" # or --id
+```
+
+### webhooks — push instead of poll
+
+```bash
+transistor webhooks --show
+transistor webhook-create --show --event episode_published \
+ --url "https://example.com/hooks/transistor"
+transistor webhook-delete --id
+```
+
+Events: `episode_created`, `episode_published`, `subscriber_created`,
+`subscriber_deleted`. Cap: 50 per account. With a 10 req / 10 s limit,
+webhooks beat polling for freshness.
+
+## Global flags
+
+```bash
+transistor --json shows # flags work in any position
+transistor --dry-run episodes --show # request plan, zero network
+transistor --force episode-publish --id # skip the audio guard
+transistor --quiet shows # suppress non-essential output
+transistor --verbose episodes # detailed stderr logging
+```
+
+`--dry-run` emits `{"dry_run": true, "method", "path", "params"}` (write
+commands add the exact `body` that would be sent — bracket keys and all),
+so you can verify a plan before touching the API. `--help` and `--dry-run`
+never require credentials.
+
+## Pipeline recipes
+
+### Create, attach audio, publish (the core workflow)
+
+```bash
+export TRANSISTOR_API_KEY=""
+SHOW=$(transistor shows --json | jq -r '.shows[0].id') # string id
+AUDIO=$(transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3 --json | jq -r '.audio_url')
+EP=$(transistor episode-create --show "$SHOW" --title "Ep 12" \
+ --audio-url "$AUDIO" --json | jq -r '.id') # draft id
+transistor episode-publish --id "$EP" # dedicated endpoint
+transistor episode --id "$EP" --json | jq '{status, media_url, published_at}'
+```
+
+Each stage's output feeds the next: `shows` → string `id`,
+`authorize-upload` → string `audio_url`, `episode-create` → string draft
+`id`, `episode-publish` → final `status`. Stage 2 is skippable when the
+audio already has a public URL (pass it straight to `episode-create`).
+
+### Draft triage: what is not out yet?
+
+```bash
+transistor episodes --show --status draft --json \
+ | jq -r '.episodes[] | [.id, .title, (if .media_url == "" then "no-audio" else "ready" end)] | @tsv'
+# publish the ready ones (rate limit: 10 req / 10 s — add sleep 1 between calls)
+```
+
+### Weekly downloads report
+
+```bash
+transistor shows --json | jq -r '.shows[].id' | while read -r S; do
+ transistor analytics --show "$S" --json \
+ | jq -r --arg id "$S" '[$id, (.downloads_total|tostring)] | @tsv'
+ sleep 1
+done
+```
+
+## JSON and jq
+
+`--json` keys are stable snake_case wrappers around the JSON:API document:
+`shows`/`episodes`/`subscribers`/`webhooks` (arrays with `meta` attached),
+flat objects for single resources, `dry_run`/`method`/`path`/`params`/`body`
+for plans. Attributes keep Transistor's own names — `status`, `season`,
+`number`, `duration` (seconds), `media_url`, `share_url`, `published_at`,
+`feed_url` — so jq selectors transfer directly to raw `curl` against
+api.transistor.fm. Collection pagination surfaces as
+`meta.currentPage`/`meta.totalPages`/`meta.totalCount`. Example:
+`transistor episodes --show --json | jq -r '.episodes[] |
+[.id, .title, .status] | @tsv'`. For compound documents the CLI resolves
+relationships (`show_id`) and prints `included` show summaries in human
+mode; with raw curl, match `included[]` by `type` and
+`relationships.show.data.id`.
## Known Gotchas
-- **API key from Settings → API Keys** — Not from the user profile or account page. Navigate to Settings → API Keys at the bottom of the Transistor.fm dashboard.
-- **JSON:API format** — Transistor uses the JSON:API spec. All responses are nested under a `data` key, and attributes are under `data[].attributes`. The CLI unwraps these for display, but raw JSON output shows the full JSON:API structure.
-- **Show IDs are required for filtered queries** — Use `transistor-cli shows --json` to get show IDs first, then pass them to `--show` for episodes and analytics.
-- **Pagination uses cursor-based pagination** — The `--limit` flag controls the page size. Default is 20 for episodes. The API returns `meta` with pagination info.
-- **Analytics are totals only** — The analytics endpoint returns aggregate totals (downloads, plays). Per-episode analytics are not available via this CLI.
-- **Transistor API is append-only via this CLI** — The CLI implements GET endpoints for reading. Creating/updating shows or episodes is not covered here.
-- **Rate limits** — Transistor.fm has rate limits. The CLI does not auto-retry on 429 responses.
+- **Publishing is a separate endpoint, never a side effect** — POST
+ /episodes and PATCH /episodes/:id cannot change `status`. If an episode
+ stays draft, the missing step is `PATCH /v1/episodes//publish` with
+ `episode[status]=published`. (The pre-thickening CLI had no publish path
+ at all.)
+- **The user probe is `GET /v1`** — `/v1/user` and `/v1/authorization` are
+ 404s, and the user resource has no email attribute (name and time_zone
+ only).
+- **Pagination is `pagination[page]` + `pagination[per]`** (defaults 0 and
+ 10; docs' examples request page 1). `pagination[limit]` and
+ `page[number]` are silently ignored — loops using them re-read page 1
+ forever. Loop while `meta.currentPage < meta.totalPages`.
+- **Show resources carry no counts** — derive episode/subscriber counts
+ from filtered listings' `meta.totalCount`.
+- **Analytics are per-day arrays, not totals** — sum `attributes.downloads`
+ (the CLI provides `downloads_total`); date bounds are dd-mm-yyyy and
+ come in pairs; do not parse the row date format (docs' examples are
+ inconsistent between sections).
+- **Show creation is dashboard-only** — there is no POST /v1/shows;
+ `show-update` is the only show write.
+- **Rate limit 10 req / 10 s** — a 429 blocks access for 10 seconds. No
+ retry headers; back off, batch subscriber imports, cache responses, and
+ use webhooks for freshness. Transistor explicitly forbids using the API
+ as a website back end (parse the RSS feed for that).
+- **`episode[published_at]` uses the show's time zone** (a show attribute),
+ not UTC; scheduling and backdating both ride the publish endpoint.
+- **Audio processing is asynchronous** — watch `audio_processing` /
+ `processing_failure` after attaching audio; publishing an unprocessed or
+ failed file pushes silence to subscribers.
+- **Signed upload URLs expire** (~600 s in the docs' example): authorize,
+ PUT with the returned `content_type`, attach promptly.
+- **Error bodies are not formally specified** — the CLI handles JSON:API
+ `errors[]` arrays and bare `{"message": ...}` objects, flattening either
+ to one stderr line; 401 (bad key), 403 (role), 404 (bad id/route), 429
+ (rate limit) have distinct hints.
-## References
+## When to use
-- [scripts/transistor-cli](scripts/transistor-cli) — The CLI binary. Built following the cli-builder patterns: `--json`, `--dry-run`, `--force`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth.
-- [Transistor API Docs](https://developers.transistor.fm/) — Official API reference.
-- [Transistor.fm Dashboard](https://dashboard.transistor.fm/) — Settings → API Keys for your API key.
+Use this skill for anything that reads or drives a Transistor.fm account
+through its API: verifying API access, browsing shows/episodes (including
+drafts and compound documents), running the episode lifecycle
+(create → attach audio → publish/schedule/unpublish), pulling download
+analytics windows, importing or revoking private-podcast subscribers, and
+registering webhooks.
+
+## When not to use
+
+Do not use this skill for other podcast hosts (Buzzsprout, Libsyn,
+Megaphone, Spotify for Creators — use their own APIs/tooling); for audio
+production or editing (ffmpeg and DAW territory); for generic RSS feed
+parsing or website rendering (parse the feed XML directly — Transistor says
+the API is not a back-end data source); for creating new shows (the API
+cannot — the dashboard does); or for platform-level distribution questions
+(Apple/Spotify submission is a dashboard and RSS concern).
+
+## Reference Files
+
+| File | Use it for |
+| ---- | ---------- |
+| [references/auth-and-basics.md](references/auth-and-basics.md) | x-api-key auth, key location and role scoping, the JSON:API envelope (data/attributes/relationships/included[]) with jq patterns, pagination params, error surfaces |
+| [references/endpoint-catalog.md](references/endpoint-catalog.md) | Every route's method, path, and parameters (shows, episodes, publish, uploads, analytics, subscribers, webhooks) plus routes that do not exist |
+| [references/episode-publish-lifecycle.md](references/episode-publish-lifecycle.md) | The draft/scheduled/published state machine, the exact publish request/response shapes, create→audio→publish recipes in CLI and curl, authorize-upload detour |
+| [references/gotchas-and-recipes.md](references/gotchas-and-recipes.md) | Symptom → cause → fix field guide (404 user route, silent pagination, 429 storms...) and multi-step workflows (bulk scheduling, analytics reports, subscriber import, webhooks) |
+
+## Available Scripts and Prerequisites
+
+- `scripts/transistor` — the bundled Python CLI (`--json`, `--dry-run`,
+ `--force`, `--quiet`, `--verbose`, `--help` everywhere). Imports only the
+ standard library and `requests`; sends write bodies exactly as documented
+ (bracket-key form fields).
+- `scripts/test_transistor.py` — offline test suite (pytest + unittest
+ compatible); all HTTP mocked with canned JSON:API documents, zero network
+ egress, no live-call cases (Transistor is a keyed API).
+- Requires Python 3.8+, `requests`, and `TRANSISTOR_API_KEY` for live
+ commands (Account page → API Access). No service is started by this skill.
diff --git a/transistor/evals/evals.json b/transistor/evals/evals.json
new file mode 100644
index 0000000..0e3abfe
--- /dev/null
+++ b/transistor/evals/evals.json
@@ -0,0 +1,94 @@
+{
+ "schema_version": 1,
+ "skill_name": "transistor",
+ "evals": [
+ {
+ "id": "browse-shows-and-episodes-json",
+ "prompt": "List my Transistor shows and then the latest episodes of the first one, as JSON I can pipe to jq.",
+ "expected_output": "Export TRANSISTOR_API_KEY (Dashboard -> Account -> API Access), run transistor shows --json to get show ids, then transistor episodes --show --json. Collection output is {shows|episodes: [...], meta: {currentPage, totalPages, totalCount}}; page with --page/--per (pagination[page]/pagination[per] on the wire, API default 10 per page) and loop while meta.currentPage < meta.totalPages.",
+ "assertions": [
+ "exports TRANSISTOR_API_KEY and runs transistor shows --json first",
+ "extracts the show id from the shows output before filtering episodes",
+ "runs transistor episodes with --show and --json",
+ "pages with pagination[page]/pagination[per] and meta.currentPage/totalPages, never pagination[limit] or page[number]"
+ ]
+ },
+ {
+ "id": "episode-create-then-publish-pipeline",
+ "prompt": "I have a finished MP3 at https://cdn.example.com/ep12.mp3. Publish it as episode 12 of my Transistor show, season 2, with the title 'Roasting Coffee'.",
+ "expected_output": "Create the draft: transistor episode-create --show --title 'Roasting Coffee' --season 2 --number 12 --audio-url https://cdn.example.com/ep12.mp3 --json and take .id (creation always yields status draft, published_at null). Then publish on the dedicated endpoint: transistor episode-publish --id , which sends PATCH /v1/episodes//publish with episode[status]=published. Confirm with transistor episode --id --json reading .status and .media_url. Updating episode metadata never publishes; only the /publish endpoint changes status.",
+ "assertions": [
+ "creates the episode as a draft with transistor episode-create including --show, --title, and --audio-url",
+ "publishes via transistor episode-publish on the dedicated PATCH /v1/episodes/:id/publish endpoint with episode[status]=published",
+ "does not claim episode-create or episode-update can publish the episode",
+ "reads the publish result from data.attributes.status / the JSON output .status"
+ ]
+ },
+ {
+ "id": "publish-rejected-because-stays-draft",
+ "prompt": "I keep updating my Transistor episode with PATCH /v1/episodes/:id but it stays in draft and never shows up in the RSS feed. What am I doing wrong?",
+ "expected_output": "Nothing is broken: metadata updates cannot change publishing state. POST /episodes and PATCH /episodes/:id never publish (the docs say publishing is a separate endpoint). Send PATCH /v1/episodes//publish with episode[status]=published (draft/scheduled/published are the only status values; episode[published_at] in the show's time zone schedules or backdates). The bundled CLI path is transistor episode-publish --id .",
+ "assertions": [
+ "explains that PATCH /episodes/:id can never change publishing state",
+ "uses the dedicated /publish endpoint with episode[status]=published",
+ "mentions scheduled/draft states are set on the same publish endpoint",
+ "does not suggest re-sending episode[audio_url] or metadata fields as the fix"
+ ]
+ },
+ {
+ "id": "authorize-upload-attach-audio-workflow",
+ "prompt": "My episode audio is a local file ep12.mp3 on disk and I don't have a public URL. Walk me through getting it into Transistor and published.",
+ "expected_output": "Authorize an upload: transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3 (the CLI GETs /v1/episodes/authorize_upload?filename=..., PUTs the bytes to the signed upload_url with the returned content_type; the URL expires ~600s, max 5GB). Take .audio_url from the output, attach it: transistor episode-update --id --audio-url (or pass it at episode-create), then publish with transistor episode-publish --id . Accepted formats include .mp3, .m4a, .wav.",
+ "assertions": [
+ "starts with transistor authorize-upload and uses the returned audio_url",
+ "attaches audio via episode-create or episode-update with --audio-url",
+ "publishes only after attaching audio, via the publish endpoint",
+ "does not try to PUT the file to api.transistor.fm directly"
+ ]
+ },
+ {
+ "id": "rate-limit-429-webhook-guidance",
+ "prompt": "My script that checks Transistor for new published episodes every few seconds just started failing with 429 errors. Fix it.",
+ "expected_output": "Transistor rate-limits the API to 10 requests per 10 seconds; a 429 blocks access for 10 seconds and no retry headers are documented. Polling every few seconds will keep tripping it. Fix: slow the loop (sleep between calls), cache responses, and better, register a webhook so Transistor pushes events: transistor webhook-create --show --event episode_published --url https://example.com/hooks (events: episode_created, episode_published, subscriber_created, subscriber_deleted; max 50 per account). Transistor also says the API is not meant as a website back end - parse the RSS feed for display use.",
+ "assertions": [
+ "states the 10 requests per 10 seconds limit and the 10 second 429 block",
+ "replaces tight polling with a webhook (episode_published) or cached/less frequent calls",
+ "does not invent retry-after headers or exponential backoff promises from the docs",
+ "mentions the 50-webhook per account cap or the event names"
+ ]
+ },
+ {
+ "id": "private-podcast-subscriber-batch-import",
+ "prompt": "Import a mailing list of 40 people into my private Transistor podcast without spamming each one manually.",
+ "expected_output": "Use the batch endpoint: transistor subscriber-batch --show --email --email ... (POST /v1/subscribers/batch with show_id and emails[]), optionally --skip-welcome-email. Verify with transistor subscribers --show --json reading meta.totalCount. Revoke access later with transistor subscriber-delete --show --email or --id. Each subscriber gets a personal feed_url/subscribe_url - never share one person's feed URL.",
+ "assertions": [
+ "uses subscriber-batch (POST /v1/subscribers/batch) instead of 40 single calls",
+ "lists subscribers and reads meta.totalCount to verify the import",
+ "revokes with subscriber-delete by email or id when needed",
+ "does not share or reuse one subscriber's personal feed_url"
+ ]
+ },
+ {
+ "id": "create-transistor-show-not-api",
+ "prompt": "Use the Transistor API to create a brand new podcast called 'Night Shift' on my account.",
+ "expected_output": "This must not trigger the transistor skill's CLI for creation: show creation is not available via the Transistor API at all (no POST /v1/shows exists; Transistor's support docs say new shows need to be created in the web app). Create the show in the dashboard first; afterwards the skill can manage it (show-update for metadata, episodes, subscribers, analytics).",
+ "assertions": [
+ "must not attempt to create a show through the API",
+ "states that show creation is dashboard-only because POST /v1/shows does not exist",
+ "directs the user to create the show in the Transistor dashboard first",
+ "still offers post-creation management (episode lifecycle, metadata updates) once the show exists"
+ ]
+ },
+ {
+ "id": "audio-editing-not-transistor",
+ "prompt": "Cut the first 30 seconds of silence off my podcast MP3 and normalize the loudness.",
+ "expected_output": "This must not trigger the transistor skill: audio editing/transcoding is outside a hosting-account API skill (ffmpeg or a DAW does the edit), and Transistor's API manages episodes, subscribers, and analytics - not audio files. After the edited file is hosted somewhere reachable (or via authorize-upload), the Transistor skill can attach and publish it.",
+ "assertions": [
+ "must not trigger transistor for audio editing",
+ "routes the edit to ffmpeg or a DAW",
+ "does not invent API endpoints for editing or processing audio",
+ "may mention re-attaching the edited file afterward as the follow-up step"
+ ]
+ }
+ ]
+}
diff --git a/transistor/references/auth-and-basics.md b/transistor/references/auth-and-basics.md
new file mode 100644
index 0000000..4daa842
--- /dev/null
+++ b/transistor/references/auth-and-basics.md
@@ -0,0 +1,165 @@
+# Transistor API: Authentication, JSON:API Envelope, and Request Basics
+
+Everything in this file is from the official API reference
+(developers.transistor.fm) and Transistor's own support pages, verified
+live at authoring time. Transistor.fm's public API is v1 and speaks
+JSON:API on responses; there is exactly one authentication mode.
+
+## Authentication
+
+- Every request carries an HTTP header `x-api-key` whose value is the API
+ key. There is no OAuth, no bearer token, and no signing on the REST API.
+- Keys are created, viewed, and reset in the Transistor Dashboard's Account
+ page, in the section marked **API Access**
+ (https://dashboard.transistor.fm/account). Transistor's support article
+ "Does Transistor have an API?" (updated 2026-07) names exactly this
+ location; the bundled CLI prints it on every auth error.
+- A key grants whatever the associated dashboard user can see: access to
+ podcasts and episodes follows the user's podcast role — **owner**,
+ **admin**, or **regular team member**. There are no narrower per-key
+ scopes: a leaked key is as powerful as its user. Treat it like a
+ password; reset it from the same Account page if it leaks.
+- The authorization probe is `GET /v1` — it returns the authenticated
+ `user` resource and nothing else. There is **no `/v1/user` and no
+ `/v1/authorization` route**; older tutorials that call `/v1/user` get a
+ 404. The `user` resource has `name`, `time_zone`, `image_url`, and
+ timestamps — **it has no email attribute**.
+
+```sh
+curl https://api.transistor.fm/v1 -H "x-api-key: "
+```
+
+## Rate limits
+
+- **10 requests per 10 seconds.** Exceeding the limit returns HTTP `429`
+ and access is blocked for 10 seconds; after that requests flow again.
+- No rate-limit headers (`Retry-After` etc.) are documented — don't parse
+ for them; just back off on 429.
+- Transistor explicitly states the API is not meant to be the main data
+ source for a website or app back end; pull data once, cache it, and parse
+ the public RSS feed XML when you would otherwise hammer the API. For
+ push-style updates, webhooks (see the endpoint catalog) exist for
+ `episode_created`, `episode_published`, `subscriber_created`, and
+ `subscriber_deleted`.
+
+## The JSON:API envelope
+
+Responses are JSON:API documents. Learn four keys and every endpoint is
+readable:
+
+| Key | Shape | Meaning |
+| --- | --- | --- |
+| `data` | object (single resource) or array (collections) | The primary resource(s) of the response |
+| `attributes` | object inside a resource | The resource's fields (title, status, media_url, ...) |
+| `relationships` | object of `{"": {"data": {"id", "type"}}}` | Links to related resources by id and type |
+| `included` | array (only when requested with `include[]`) | The full related resources — a "compound document" |
+
+- Resource `type` values: `user`, `show`, `episode`, `subscriber`,
+ `show_analytics`, `episodes_analytics`, `episode_analytics`,
+ `audio_upload`, `webhook`.
+- Single-resource responses wrap one object: `{"data": {"id": ...,
+ "type": "episode", "attributes": {...}, "relationships": {...}}}`.
+- Collection responses wrap an array plus pagination under `meta`:
+ `{"data": [...], "meta": {"currentPage", "totalPages", "totalCount"}}`.
+- Ids are **strings** even when numeric ("3056098"); analytics ids may be
+ slugs ("the-caffeine-show"). Keep ids as strings end to end.
+- `included[]` appears only when you ask for it. `GET
+ /v1/episodes/3056098?include[]=show` returns the episode plus the parent
+ show in `included`, matched via `data.relationships.show.data.id`.
+
+### jq patterns for the envelope
+
+```sh
+# Single resource: unwrap data.attributes
+curl -s https://api.transistor.fm/v1/episodes/ -H "x-api-key: " \
+ | jq '.data.attributes | {title, status, published_at}'
+
+# Collection: titles plus ids, one per line
+curl -s 'https://api.transistor.fm/v1/episodes?show_id=' -H "x-api-key: " \
+ | jq -r '.data[] | [.id, .attributes.title, .attributes.status] | @tsv'
+
+# Compound document: pull the parent show's title out of included[]
+curl -s 'https://api.transistor.fm/v1/episodes/?include[]=show' -H "x-api-key: " \
+ | jq --arg id "$(curl -s ... | jq -r '.data.relationships.show.data.id')" \
+ '.included[] | select(.type == "show" and .id == $id) | .attributes.title'
+
+# Simpler: match included[] by type when only one show was included
+... | jq '.included[] | select(.type == "show") | .attributes.title'
+
+# Pagination loop values live in meta
+... | jq '{page: .meta.currentPage, last: .meta.totalPages, total: .meta.totalCount}'
+```
+
+The bundled CLI does this unwrapping for `--json` output: collections come
+back as `{"episodes": [...], "meta": {...}}` with each item flattened to the
+fields agents actually need (including `show_id` from relationships), and
+single resources as one flat object.
+
+## Pagination
+
+- Page-based, two parameters: `pagination[page]` (documented default `0`;
+ the doc examples explicitly request page `1`) and `pagination[per]`
+ (default `10`).
+- Every collection returns `meta.currentPage`, `meta.totalPages`, and
+ `meta.totalCount`. Loop while `currentPage < totalPages`, incrementing
+ the page — do not assume the first page is `0` or `1`, read `meta`.
+- There is no cursor, no `page[number]`/`page[size]` JSON:API-style
+ spelling, and no `pagination[limit]` — unknown params are silently
+ ignored, which is exactly how scripts that "paginate" with
+ `pagination[limit]` re-read the first page forever.
+
+## Sparse fieldsets and compound documents
+
+Any endpoint accepts JSON:API's standard extras:
+
+- Sparse fieldsets: `fields[episode][]=title&fields[episode][]=media_url`
+ returns only those attributes (smaller payloads, faster loops).
+- Include related resources: `include[]=show` on an episode, `include[]=show`
+ on analytics, `include[]=episode` on episode analytics. Combine both:
+ `include[]=show&fields[show][]=title&fields[show][]=feed_url`.
+
+## Request bodies: form-encoded bracket keys (documented), JSON accepted
+
+- The reference intro says endpoints accept **JSON or form-encoded** request
+ bodies. Every documented mutation example uses form-encoded bracket keys:
+ `episode[show_id]=...`, `episode[title]=...`, `show[title]=...`,
+ `subscriber[email]=...`, `episode[status]=published`.
+- The docs publish no JSON-body equivalent examples, so the bracket-key
+ shapes above are the contract to copy. The bundled CLI sends form-encoded
+ bodies byte-compatible with the documented curl examples.
+- Required-vs-optional matters: `episode[show_id]` is the only required
+ field on episode creation; `episode[status]` is required on the publish
+ endpoint; `show_id` is required on subscribers/webhooks listings.
+
+## Error surfaces
+
+Responses use standard HTTP codes. The reference does not document a formal
+error schema, so program defensively:
+
+- `401` — key missing/invalid → check `x-api-key` and the Account page.
+- `403` — key valid, role insufficient (owner/admin needed for some
+ operations on a shared podcast).
+- `404` — id/slug not found (and remember: `/v1/user` is not a route).
+- `422` — validation errors (e.g. bad `episode[status]` value).
+- `429` — rate limit (10 requests / 10 s window).
+
+Error bodies seen in practice are JSON; the bundled CLI accepts either a
+JSON:API-style `errors[]` array or a bare `{"message": ...}` object and
+flattens whichever it gets into one stderr line.
+
+## Sources
+
+- https://developers.transistor.fm/ (introduction, JSON:API conformance,
+ authentication, rate limits, sparse fieldsets/include[] sections; all
+ endpoint examples) — fetched live 2026-08-29 (HTTP 200)
+- https://developers.transistor.fm/#authentication (header name, Account
+ Area key management, owner/admin/team-member access levels)
+- https://developers.transistor.fm/#ratelimits (10 requests / 10 s, 429 +
+ 10 s block, caching/RSS guidance)
+- https://developers.transistor.fm/#get-v1 (GET /v1 user resource example)
+- https://developers.transistor.fm/#resources (type list; User resource
+ fields — no email)
+- https://support.transistor.fm/en/article/does-transistor-have-an-api-1b24sjo/
+ (API key location: Account page → API Access) — fetched live 2026-08-29
+- https://support.transistor.fm/en/article/what-automations-are-possible-with-transistor-bi27am/
+ (supported automations, show-creation limitation) — fetched live 2026-08-29
diff --git a/transistor/references/endpoint-catalog.md b/transistor/references/endpoint-catalog.md
new file mode 100644
index 0000000..7d4311e
--- /dev/null
+++ b/transistor/references/endpoint-catalog.md
@@ -0,0 +1,137 @@
+# Transistor API Endpoint Catalog
+
+Method-by-method reference for Transistor API v1. Every row matches the
+official reference at developers.transistor.fm (fetched live 2026-08-29).
+Envelope conventions (`data`/`attributes`/`relationships`/`included[]`),
+pagination (`pagination[page]`, `pagination[per]`, `meta.currentPage`,
+`meta.totalPages`, `meta.totalCount`), and sparse-fieldset/include[] params
+apply everywhere — see [auth-and-basics.md](auth-and-basics.md).
+
+## Root
+
+| Method | Path | Purpose / params |
+| --- | --- | --- |
+| GET | `/v1` | Authenticated user probe. No params. Returns one `user` resource (`name`, `time_zone`, `image_url`, timestamps; **no email**). Use as the "does my key work" check. |
+
+## Shows
+
+| Method | Path | Purpose / params |
+| --- | --- | --- |
+| GET | `/v1/shows` | List shows, descending by updated date. Params: `private` (boolean), `query` (title search), `pagination[page]` (default 0), `pagination[per]` (default 10). |
+| GET | `/v1/shows/:id` | One show. `:id` accepts the show ID **or slug**. |
+| PATCH | `/v1/shows/:id` | Update any of: `show[author]`, `show[category]`, `show[copyright]`, `show[description]`, `show[explicit]`, `show[image_url]`, `show[keywords]`, `show[language]`, `show[owner_email]`, `show[secondary_category]`, `show[show_type]` (`episodic`/`serial`), `show[title]`, `show[time_zone]`, `show[website]`. Category/language/time-zone values are large closed enums — fetch the dashboard values or reuse what GET returns. |
+
+- Show attributes include `title`, `slug`, `description`, `author`,
+ `private`, `show_type`, `feed_url`, `time_zone`, `category`,
+ `secondary_category`, `language`, `owner_email`, `website`, `explicit`,
+ `keywords`, plus per-directory URLs (`apple_podcasts`, `spotify`,
+ `overcast`, ...).
+- **There are no `episodes_count` or `subscribers_count` attributes** —
+ count episodes by listing them (`show_id` filter + `meta.totalCount`).
+- **No POST /v1/shows exists**: show creation is not available via the API
+ (Transistor support, updated 2026-08: "Show creation is not currently
+ available via our API. New shows need to be created in the web app").
+
+## Episodes
+
+| Method | Path | Purpose / params |
+| --- | --- | --- |
+| GET | `/v1/episodes` | List episodes, ordered by published date. Params: `show_id` (ID or slug), `query`, `status` (`draft`/`scheduled`/`published`), `order` (`asc`/`desc`, default `desc`), `pagination[page]`, `pagination[per]`. |
+| GET | `/v1/episodes/:id` | One episode. `include[]=show` supported. `:id` is the Episode ID (slug support not documented here). |
+| POST | `/v1/episodes` | Create an episode. Required: `episode[show_id]`. Optional: `episode[title]`, `episode[summary]`, `episode[description]` (HTML allowed), `episode[audio_url]`, `episode[author]`, `episode[season]`, `episode[number]`, `episode[number]` + `episode[increment_number]` (auto next number in season), `episode[type]` (`full`/`trailer`/`bonus`), `episode[image_url]`, `episode[keywords]`, `episode[explicit]`, `episode[alternate_url]`, `episode[video_url]` (video plan), `episode[youtube_url]`, `episode[transcript_text]`, `episode[email_notifications]`. **Always creates a DRAFT** (`status: "draft"`, `published_at: null`) — publishing is a separate endpoint. |
+| PATCH | `/v1/episodes/:id` | Update metadata/audio. Accepts the same `episode[...]` fields as create (except `show_id`). **Never changes publishing state** — the docs say so explicitly ("publishing or unpublishing an episode involves a separate endpoint"). |
+| PATCH | `/v1/episodes/:id/publish` | Publish / schedule / unpublish. Required: `episode[status]` ∈ `draft`, `scheduled`, `published`. Optional: `episode[published_at]` (show's time zone) to publish in the past, schedule for the future, or backdate. See the publish-lifecycle file for the full recipe. |
+| GET | `/v1/episodes/authorize_upload` | Authorize a local audio/video upload (max **5GB**). Required: `filename`. Returns an `audio_upload` resource: signed `upload_url` (HTTP PUT the bytes, header `Content-Type: `), `content_type` (e.g. `audio/mpeg`), `expires_in` (example: 600 s), and the post-upload `audio_url` to attach via create/update. Skip entirely if you already have a public URL. |
+
+- Episode attributes: `title`, `status`, `season`, `number`,
+ `published_at`, `duration` (seconds), `duration_in_mmss`, `media_url`
+ (trackable MP3), `share_url`, `alternate_url`, `slug`, `summary`,
+ `description` (+ `formatted_*` variants), `author`, `explicit`,
+ `keywords`, `image_url`, `video_url`, `youtube_url`, `embed_html`(+dark),
+ `transcript_url`, `transcripts[]`, `audio_processing`,
+ `video_processing`, `processing_failure`, `hls_manifest_url`, `type`.
+- `audio_processing: true` means Transistor is still processing an upload;
+ `processing_failure` carries the error string when processing failed.
+- Vendor-documented upload formats (mcp.transistor.fm): .mp3, .m4a, .wav,
+ .aif, .aiff, .aifc, .mp4, .mov.
+
+## Analytics
+
+| Method | Path | Purpose / params |
+| --- | --- | --- |
+| GET | `/v1/analytics/:id` | Show downloads per day. `:id` = Show ID or slug. Default window: last 14 days. |
+| GET | `/v1/analytics/:id/episodes` | Per-episode download series for a whole show. `:id` = Show ID or slug. Default window: last 7 days. |
+| GET | `/v1/analytics/episodes/:id` | Single episode downloads per day. `:id` = Episode ID or slug. Default window: last 14 days. |
+
+- Date range params on all three: `start_date` and `end_date`, documented
+ as **dd-mm-yyyy**; if you supply one you must supply both.
+- Analytics resources return a per-day `downloads` **array**
+ (`[{"date": ..., "downloads": N}, ...]`) — not a totals object. Sum the
+ array yourself (or let the bundled CLI do it: `downloads_total`).
+- Doc-format quirk: example responses echo download-row dates
+ inconsistently (`15-08-2026` in show analytics vs `08-15-2026` in
+ episodes analytics). Never parse the row date format; aggregate the
+ numeric `downloads` values keyed by position in your requested window.
+- There is no `/v1/shows/:id/analytics` route — analytics paths live under
+ `/v1/analytics/...`. Downloads are the only analytics exposed by the API
+ (no countries/apps/video stats).
+
+## Subscribers (private podcasts)
+
+| Method | Path | Purpose / params |
+| --- | --- | --- |
+| GET | `/v1/subscribers` | List a private show's subscribers. Required: `show_id`. Optional: `query`, `activated` (boolean), pagination. |
+| GET | `/v1/subscribers/:id` | One subscriber with `email`, `status` (`default`/`subscribed`/`unsubscribed`), per-subscriber `feed_url` and `subscribe_url`, `has_downloads`, `last_notified_at`. |
+| POST | `/v1/subscribers` | Add one subscriber. Required: `show_id`, `email`. Optional: `skip_welcome_email` (default false). |
+| POST | `/v1/subscribers/batch` | Add many. Required: `show_id`, `emails[]` (repeat the key). Optional: `skip_welcome_email`. Response: array of subscriber resources. |
+| PATCH | `/v1/subscribers/:id` | Update. Required: `subscriber[email]`. |
+| DELETE | `/v1/subscribers` | Revoke by address. Required: `show_id`, `email`. |
+| DELETE | `/v1/subscribers/:id` | Revoke by subscriber ID. |
+
+Subscriber routes are top-level (`/v1/subscribers...`), not nested under
+`/v1/shows/:id/`. Each subscriber gets a unique personal feed URL — that is
+how Transistor tracks private-listener downloads.
+
+## Webhooks
+
+| Method | Path | Purpose / params |
+| --- | --- | --- |
+| GET | `/v1/webhooks` | List a show's webhooks. Required: `show_id`. |
+| POST | `/v1/webhooks` | Subscribe. Required: `event_name`, `show_id`, `url`. `event_name` ∈ `episode_created`, `episode_published`, `subscriber_created`, `subscriber_deleted`. |
+| DELETE | `/v1/webhooks/:id` | Unsubscribe by webhook ID. |
+
+Maximum **50 webhooks per user account** (Webhook resource doc). Webhooks
+are the sanctioned alternative to polling given the 10 req / 10 s rate
+limit: register `episode_published` and react, instead of re-reading
+episode lists.
+
+## Routes that do NOT exist (common wrong guesses)
+
+- `GET /v1/user`, `GET /v1/authorization` — the user probe is `GET /v1`.
+- `POST /v1/shows` — show creation is dashboard-only.
+- `/v1/shows/:id/analytics`, `/v1/episodes/:id/analytics` (nested) —
+ analytics lives at `/v1/analytics/...` paths.
+- `/v1/shows/:id/subscribers` — subscribers is top-level with `show_id`.
+- Any `pagination[limit]`-style param — per-page is `pagination[per]`.
+
+## Sources
+
+- https://developers.transistor.fm/ — fetched live 2026-08-29 (HTTP 200);
+ all endpoint tables above correspond to the reference sections:
+ #get-v1, #get-v1-analytics-id, #get-v1-analytics-id-episodes,
+ #get-v1-analytics-episodes-id, #get-v1-shows, #get-v1-shows-id,
+ #patch-v1-shows-id, #get-v1-episodes, #get-v1-episodes-id,
+ #get-v1-episodes-authorize_upload, #post-v1-episodes,
+ #patch-v1-episodes-id, #patch-v1-episodes-id-publish,
+ #get-v1-subscribers, #get-v1-subscribers-id, #post-v1-subscribers,
+ #post-v1-subscribers-batch, #patch-v1-subscribers-id,
+ #delete-v1-subscribers, #delete-v1-subscribers-id, #get-v1-webhooks,
+ #post-v1-webhooks, #delete-v1-webhooks-id, #Show, #Episode,
+ #Subscriber, #ShowAnalytics, #EpisodesAnalytics, #EpisodeAnalytics,
+ #AudioUpload, #Webhook
+- https://support.transistor.fm/en/article/what-automations-are-possible-with-transistor-bi27am/
+ (show-creation limitation, supported automations) — fetched live 2026-08-29
+- https://mcp.transistor.fm/ (accepted upload formats; draft-then-publish
+ semantics as implemented by Transistor's own tooling) — fetched live 2026-08-29
+- https://pkg.go.dev/gitlab.com/flimzy/transistor (independent SDK route
+ inventory corroborating the catalog) — fetched live 2026-08-29
diff --git a/transistor/references/episode-publish-lifecycle.md b/transistor/references/episode-publish-lifecycle.md
new file mode 100644
index 0000000..cc9536b
--- /dev/null
+++ b/transistor/references/episode-publish-lifecycle.md
@@ -0,0 +1,206 @@
+# The Episode Publish Lifecycle (draft → audio → publish)
+
+The single most important behavioral fact of the Transistor API:
+**creating an episode never publishes it, and updating an episode never
+publishes it.** Publishing, scheduling, and unpublishing travel on their
+own dedicated endpoint. Everything below is from the official reference
+(developers.transistor.fm, fetched live 2026-08-29).
+
+## States
+
+`attributes.status` is exactly one of:
+
+| Status | Meaning |
+| --- | --- |
+| `draft` | Not in the RSS feed. New episodes start here (`published_at: null`). |
+| `scheduled` | Will publish at `episode[published_at]` (show's time zone). |
+| `published` | Live in the RSS feed; `published_at` records the publish time. |
+
+Transistor's own tooling describes the same model ("Episodes are always
+created as drafts, and publishing is a separate tool call" — the vendor MCP
+server), and the REST docs describe the publish endpoint's purpose as
+"Publish a single episode now or in the past, schedule for the future, or
+revert to a draft." All three states go through the same endpoint: it is a
+setter, not a one-way transition — you can pull a published episode back to
+draft, or re-publish a draft later.
+
+## The publish request (exact documented shape)
+
+The endpoint is `PATCH /v1/episodes/:id/publish` — a metadata PATCH to
+`/v1/episodes/:id` will **not** publish (the docs say so on the update
+endpoint's own page). The episode ID is the URL path parameter; the body
+carries the status:
+
+```sh
+curl https://api.transistor.fm/v1/episodes//publish -X PATCH \
+ -H "x-api-key: " \
+ -d "episode[status]=published" \
+ -d "fields[episode][]=status"
+```
+
+- Required: `episode[status]` ∈ {`draft`, `scheduled`, `published`}.
+- Optional: `episode[published_at]` — the publish date/time **in the
+ show's time zone**. Combine it with `episode[status]=scheduled` to
+ schedule for the future, or with `published` to backdate an episode
+ (e.g. importing an archive with historical dates).
+- The documented request body is form-encoded bracket keys. The API intro
+ says JSON bodies are accepted generally, but the reference publishes no
+ JSON equivalent for this action — copy the documented shape above.
+- Note what the body does **not** contain: no `id`, no `type`, no JSON:API
+ `data` wrapper. This is not a JSON:API resource-identifier request body;
+ the resource identity lives in the URL. (The *response*, by contrast, is
+ a standard JSON:API document — see below.)
+
+Documented scheduling example (same endpoint):
+
+```sh
+curl https://api.transistor.fm/v1/episodes//publish -X PATCH \
+ -H "x-api-key: " \
+ -d "episode[status]=scheduled" \
+ -d "episode[published_at]=2026-09-03 09:00:00"
+```
+
+## The publish response
+
+With `fields[episode][]=status` the documented response is:
+
+```json
+{
+ "data": {
+ "id": "",
+ "type": "episode",
+ "attributes": {"status": "published"},
+ "relationships": {}
+ }
+}
+```
+
+Read success off the response: `data.id` matches the episode you patched,
+`data.type` is `"episode"`, and `data.attributes.status` carries the new
+state. Without the fieldset you also get `published_at`, `media_url`,
+`share_url`, `duration`, and the rest of the episode resource.
+
+## Worked recipe: create → attach audio → publish (CLI)
+
+```bash
+export TRANSISTOR_API_KEY=""
+
+# 1. Create the episode (always a draft). Audio can ride along now:
+transistor episode-create --show \
+ --title "Episode 12: Roasting" \
+ --summary "A primer on roasting coffee" \
+ --season 2 --number 4 \
+ --audio-url "https://uploads.example.com/ep12.mp3"
+# -> {"id": "", "status": "draft", ...}
+
+# (skip to 3 if you attached audio above)
+# 2. Attach audio later via the metadata PATCH — this does NOT publish:
+transistor episode-update --id \
+ --audio-url "https://uploads.example.com/ep12.mp3"
+
+# 3. Publish on the dedicated endpoint:
+transistor episode-publish --id
+# PATCH /v1/episodes//publish with episode[status]=published
+```
+
+The bundled CLI guards step 3: if `attributes.media_url` is still empty it
+refuses to publish (an audio-less item would go out to every feed reader)
+and prints the attach-audio recipe; `--force` overrides. The guard is scoped
+to `--status published` (the default) — scheduling intentionally precedes
+audio attach, so a `scheduled` publish needs no audio yet — and it performs
+one pre-publish `GET /episodes/:id`, which consumes one of the
+10-requests-per-10-seconds rate-limit slots: worth counting in bulk
+re-publish loops.
+
+## Worked recipe: raw curl
+
+```bash
+# 1. Create a draft
+curl https://api.transistor.fm/v1/episodes -X POST \
+ -H "x-api-key: " \
+ -d "episode[show_id]=" \
+ -d "episode[title]=Example episode" \
+ -d "episode[audio_url]=https://example.com/audio/episode.mp3"
+# -> {"data": {"id": "", "attributes": {"status": "draft", "published_at": null, ...}}}
+
+# 2. (only if audio was omitted) attach through the ordinary metadata PATCH
+curl https://api.transistor.fm/v1/episodes/ -X PATCH \
+ -H "x-api-key: " \
+ -d "episode[audio_url]=https://example.com/audio/episode.mp3"
+
+# 3. Publish through the dedicated endpoint
+curl https://api.transistor.fm/v1/episodes//publish -X PATCH \
+ -H "x-api-key: " \
+ -d "episode[status]=published"
+# -> {"data": {"id": "", "type": "episode",
+# "attributes": {"status": "published"}, "relationships": {}}}
+```
+
+## Local files: the authorize-upload detour
+
+If the audio exists only on disk (no public URL), insert this before step 1
+or 2:
+
+```bash
+# 1. Authorize: get a signed upload URL (max 5GB)
+transistor authorize-upload --filename Episode1.mp3
+# -> {"audio_url": "https://uploads.example.com/ep1.mp3",
+# "upload_url": "https://...r2.cloudflarestorage.com/...",
+# "content_type": "audio/mpeg", "expires_in": 600}
+
+# 2. PUT the file bytes to attributes.upload_url with the returned
+# Content-Type (the CLI does this when you pass --file):
+curl -X PUT -H "Content-Type: audio/mpeg" -T /path/to/Episode1.mp3 ""
+
+# 3. Attach attributes.audio_url via episode-create/episode-update, then publish.
+```
+
+The signed URL expires (`expires_in`, example 600 s) — upload promptly and
+only then attach. Accepted formats (vendor-documented): .mp3, .m4a, .wav,
+.aif, .aiff, .aifc, .mp4, .mov.
+
+## Gotchas in the lifecycle
+
+- **Publishing is never a side effect.** POST /episodes and PATCH
+ /episodes/:id both return `status: "draft"` (or leave it untouched) no
+ matter what fields you send. If your episode stays stubbornly draft,
+ you are missing the `/publish` endpoint call — that is the bug, not a
+ permissions problem.
+- **`published_at` vs status.** Setting `episode[published_at]` alone on
+ the metadata PATCH does nothing to feed visibility; the publish
+ endpoint's `episode[status]` field is what changes state, and
+ `published_at` only qualifies *when*.
+- **Time zone.** `episode[published_at]` is interpreted in the show's
+ configured `time_zone` (a show attribute), not in UTC and not in your
+ machine's zone. Check `transistor show --id --json`.
+- **Watch processing.** After attaching audio, `audio_processing` is true
+ until Transistor finishes; `processing_failure` explains failures.
+ Publishing with an unprocessed/failed file pushes silence to subscribers.
+- **Unpublish = status draft.** Same endpoint, `episode[status]=draft`;
+ the episode drops out of the RSS feed but keeps its id, audio, and
+ metadata.
+- **Rate budget.** The 10 req / 10 s limit applies to the whole lifecycle;
+ a create + audio-attach + publish burst is fine, a loop re-publishing
+ 50 episodes needs sleeps or webhooks.
+
+## Sources
+
+- https://developers.transistor.fm/#patch-v1-episodes-id-publish
+ ("Publish, schedule, or unpublish an episode": required `episode[status]`
+ ∈ draft/scheduled/published, optional `episode[published_at]`, publish
+ request + response examples) — fetched live 2026-08-29
+- https://developers.transistor.fm/#post-v1-episodes ("Create a new draft
+ episode... publishing an episode involves a separate endpoint"; response
+ with `status: "draft"`, `published_at: null`) — fetched live 2026-08-29
+- https://developers.transistor.fm/#patch-v1-episodes-id ("publishing or
+ unpublishing an episode involves a separate endpoint") — fetched live 2026-08-29
+- https://developers.transistor.fm/#get-v1-episodes-authorize_upload
+ (authorize_upload flow, upload_url/content_type/expires_in/audio_url,
+ 5GB max, PUT requirement) — fetched live 2026-08-29
+- https://developers.transistor.fm/#Episode (status values, audio/video
+ processing attributes) — fetched live 2026-08-29
+- https://mcp.transistor.fm/ ("Episodes are always created as drafts, and
+ publishing is a separate tool call"; accepted upload formats) — fetched live 2026-08-29
+- https://pkg.go.dev/gitlab.com/flimzy/transistor (PublishEpisode against
+ PATCH /v1/episodes/:id/publish — independent implementation corroborating
+ the dedicated endpoint) — fetched live 2026-08-29
diff --git a/transistor/references/gotchas-and-recipes.md b/transistor/references/gotchas-and-recipes.md
new file mode 100644
index 0000000..d95285e
--- /dev/null
+++ b/transistor/references/gotchas-and-recipes.md
@@ -0,0 +1,256 @@
+# Gotchas Field Guide and Worked Recipes
+
+Symptom-first troubleshooting for the Transistor API, followed by end-to-end
+recipes. Everything traces to developers.transistor.fm or Transistor's
+support pages (fetched live 2026-08-29); independent-implementation
+corroboration (the flimzy/transistor Go SDK) is cited where noted.
+
+## Symptom → cause → fix
+
+### `404` on the "current user" call
+
+- **Symptom:** `GET /v1/user` (or `/v1/authorization`) returns 404.
+- **Cause:** those routes do not exist. The authenticated-user probe is
+ `GET /v1` — root, no suffix.
+- **Fix:** `transistor user` (sends `GET /v1`). Older tutorials showing
+ `/v1/user` predate the current API surface.
+
+### Writes "succeed" but the episode never appears in the feed
+
+- **Symptom:** episode exists via `GET /v1/episodes/:id`, `status` stays
+ `"draft"` even after updates.
+- **Cause:** POST /episodes and PATCH /episodes/:id never publish; only
+ `PATCH /v1/episodes/:id/publish` changes status. (The docs state this on
+ both the create and update pages.)
+- **Fix:** `transistor episode-publish --id `, or the raw call:
+ `curl .../v1/episodes//publish -X PATCH -d "episode[status]=published"`
+ with `x-api-key`.
+
+### Pagination loop re-reads page 1 forever
+
+- **Symptom:** every page of your loop returns the same items.
+- **Cause:** wrong param name. There is no `pagination[limit]`, no
+ `page[number]`, no `page` — unknown params are ignored. Per-page is
+ `pagination[per]` (default 10) and the page number is `pagination[page]`
+ (docs' default 0, examples request 1).
+- **Fix:** loop on `meta.currentPage < meta.totalPages`, sending
+ `pagination[page]=N`; verify with `meta.totalCount` that you captured
+ everything. In the bundled CLI: `--page N --per M`.
+
+### `meta.totalCount` disagrees with the number of items
+
+- **Symptom:** `totalCount: 25` but only 10 objects in `data`.
+- **Cause:** nothing is wrong — `per` defaults to 10 and the rest are on
+ later pages.
+- **Fix:** raise `pagination[per]` or walk pages. Compare your accumulated
+ item count to `meta.totalCount`, not to `len(data)` of one page.
+
+### Show "counts" fields are missing
+
+- **Symptom:** your script reads `attributes.episodes_count` /
+ `subscribers_count` and gets `null`.
+- **Cause:** show resources do not carry those fields (an older wrapper's
+ display invented them).
+- **Fix:** list episodes with `show_id` and read `meta.totalCount`;
+ subscribers likewise (`GET /v1/subscribers?show_id=...`).
+
+### The user object has no email
+
+- **Symptom:** you expected `data.attributes.email` from the user probe.
+- **Cause:** the `user` resource has `name`, `time_zone`, `image_url`,
+ timestamps — no email.
+- **Fix:** use `name`/`time_zone`; identify accounts by the dashboard, not
+ the API.
+
+### Analytics numbers look "empty"
+
+- **Symptom:** you expected `attributes.totals.downloads.total`; you got an
+ array.
+- **Cause:** analytics resources return per-day arrays:
+ `attributes.downloads = [{"date": ..., "downloads": N}, ...]`.
+- **Fix:** sum the array. The bundled CLI exposes `downloads_total` and
+ keeps the raw `downloads` array in `--json`.
+- **Related:** don't parse the row `date` format — the docs' examples echo
+ dates inconsistently (`15-08-2026` vs `08-15-2026`); your requested
+ `start_date`/`end_date` (dd-mm-yyyy) define the window, and both are
+ required if either is given.
+
+### `429` mid-loop
+
+- **Symptom:** bulk operations fail after ~10 quick calls.
+- **Cause:** rate limit is 10 requests / 10 seconds, and the 429 blocks
+ access for 10 seconds. No retry headers are documented.
+- **Fix:** sleep ≥10 s on 429 and retry; batch what you can
+ (`/v1/subscribers/batch` for imports); prefer webhooks
+ (`episode_published`) over polling; cache — Transistor explicitly says
+ the API is not a website back end.
+
+### `403` on something you can see in the dashboard
+
+- **Symptom:** the key is valid (other calls pass) but one resource 403s.
+- **Cause:** role scoping. Keys inherit the user's per-podcast role
+ (owner/admin/team member); some operations require owner/admin.
+- **Fix:** have a podcast owner/admin run it, or adjust roles in the
+ dashboard.
+
+### Signed upload URL suddenly 403s
+
+- **Symptom:** your PUT to the `upload_url` worked in testing, fails now.
+- **Cause:** `expires_in` (example: 600 s) elapsed.
+- **Fix:** re-run `authorize-upload`, PUT promptly, then attach. The PUT
+ must carry `Content-Type` equal to the returned `content_type`.
+
+### Audio attached but `duration` is null / `media_url` empty in feeds
+
+- **Symptom:** episode created with `episode[audio_url]` but processing
+ fields look stuck.
+- **Cause:** `audio_processing` is true while Transistor processes;
+ `processing_failure` carries an error string on failure.
+- **Fix:** poll `transistor episode --id --json` until
+ `audio_processing` is false (respecting the rate limit) before
+ publishing.
+
+## Recipe: full publish pipeline (CLI)
+
+```bash
+export TRANSISTOR_API_KEY=""
+
+# 1. Verify the key and find the show
+transistor shows --json | jq -r '.shows[] | [.id, .title, .slug] | @tsv'
+
+# 2. Local audio? authorize + upload (skippable if you have a URL)
+transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3 --json | jq -r '.audio_url'
+
+# 3. Create the draft with audio attached
+EP=$(transistor episode-create --show --title "Ep 12" \
+ --audio-url "$(cat /tmp/audio_url)" --json | jq -r '.id')
+echo "$EP" # draft id, type string
+
+# 4. Publish when ready
+transistor episode-publish --id "$EP"
+
+# 5. Confirm state and the trackable media URL
+transistor episode --id "$EP" --json | jq '{status, media_url, published_at}'
+```
+
+Every stage's JSON output feeds the next: `shows` → string `id`;
+`authorize-upload` → string `audio_url`; `episode-create` → string `id`;
+`episode-publish` → new `status`. Same pipeline raw:
+
+```bash
+AUDIO=$(curl -s https://api.transistor.fm/v1/episodes/authorize_upload?filename=ep12.mp3 \
+ -H "x-api-key: " | jq -r '.data.attributes.audio_url')
+EP=$(curl -s https://api.transistor.fm/v1/episodes -X POST \
+ -H "x-api-key: " -d "episode[show_id]=" \
+ -d "episode[title]=Ep 12" -d "episode[audio_url]=$AUDIO" | jq -r '.data.id')
+curl -s "https://api.transistor.fm/v1/episodes/$EP/publish" -X PATCH \
+ -H "x-api-key: