From 356266792ba0adb4696eecadf2c182550605d004 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 19 May 2026 12:46:56 -0700 Subject: [PATCH 1/2] docs(phase-1): output schema v1 and exit code conventions Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/exit-codes.md | 263 +++++++++++++++++++++++++++++++++++++ docs/output-schema.md | 293 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 docs/exit-codes.md create mode 100644 docs/output-schema.md diff --git a/docs/exit-codes.md b/docs/exit-codes.md new file mode 100644 index 0000000..506c0a1 --- /dev/null +++ b/docs/exit-codes.md @@ -0,0 +1,263 @@ +# Exit codes + +watch-cli scripts (`watch`, `dl-video`, `extract-frames`, `transcribe`, +`audio-q`, `models`) follow a small, documented set of exit codes. +Wrapping scripts and agents can branch on the code to distinguish a +missing dependency from a transient network failure from a usage +mistake, and can recover from partial success (frames extracted, +transcribe failed) without re-running the whole pipeline. + +This document is the source of truth for those codes. The same number +appears as the process exit code, the `EXIT:` line at the bottom of the +text output, and the `exit_code` field of the JSON output — see +[`output-schema.md`](output-schema.md). + +--- + +## Why callers care + +A composable CLI is one a wrapping script can call without writing a +parser around its stderr. Documented exit codes are the first step: + +- **Branch on partial success.** Frame extraction succeeded but the + transcribe step failed (`exit 4`)? An agent can still read the + frames and skip the transcript step, instead of treating the whole + run as lost. +- **Distinguish "your tool is broken" from "my environment is broken".** + `exit 2` (missing dependency) tells the wrapper "install yt-dlp/ffmpeg + and retry"; `exit 1` tells it "this is a real failure, surface it". +- **Surface usage mistakes early.** `exit 64` matches `sysexits.h` — + wrappers and shell completion can treat it as "bad invocation, do + not retry". +- **Stay language-neutral.** Exit codes are the lowest-common-denominator + signal between Bash, Python, Node, Go, and any agent runtime. They + work with `if ! watch …; then …; fi`, with `subprocess.run().returncode`, + with `child_process.spawnSync().status`, with anything. + +--- + +## Code table + +| Code | Name | Emitted when | Example stderr | Recommended caller action | +|---|---|---|---|---| +| `0` | success | The pipeline completed without errors. In `watch`, both frame extraction and transcribe succeeded. | (no error output) | Consume the stdout payload. | +| `1` | general error | An uncategorized error that doesn't fit one of the more specific codes below. Should be rare; if you see it often, the case probably deserves its own row. | `[watch] error: ` | Log the stderr, treat as a real failure. | +| `2` | missing dependency | A required binary (`yt-dlp`, `ffmpeg`, `ffprobe`, `jq`, `curl`, `python3`) is not on `PATH`. | `[watch] error: ffmpeg not found on PATH. Install via 'brew install ffmpeg' or 'apt install ffmpeg'.` | Install the missing tool, then retry. Do not auto-retry. | +| `3` | download failed | `yt-dlp` returned a non-zero exit. The pipeline cannot continue without a video file. The stderr line includes a `tag=…` token so callers can grep without needing extra exit codes (see below). | `[watch] error: download failed for tag=download-auth — sign in to the platform in your browser and re-run, or pass --cookies ` | Inspect the `tag=…` value. Auth → retry with cookies. Region → try VPN. Network → wait and retry. Other → file an issue. | +| `4` | transcribe failed | Frame extraction succeeded but the transcribe step failed. Output is partial: `frame_paths` is populated, `transcript` is `null`. See *Partial success* below. The stderr line includes a `tag=…` token. | `[watch] error: transcribe failed tag=transcribe-quota — top up Kyma credit at https://kymaapi.com/billing or set GROQ_API_KEY for BYOK` | Read the partial output and decide. Quota → top up. Timeout → retry with shorter audio. Silent-audio → expected, fall through to frames only. | +| `64` | usage error | Bad invocation: missing required argument, unknown flag, malformed URL, or `-h` / `--help` was passed in a context where the caller wants a non-zero. Mirrors `sysexits.h` `EX_USAGE`. | `usage: watch [frame-count] [--cookies ]` | Do not retry. Fix the invocation. | + +### Stderr tag tokens + +For codes `3` and `4`, the stderr line includes a `tag=` token +so callers can match on a stable, language-neutral string instead of +parsing prose. Tags ship inside v1 and follow the same stability +promise as the rest of the schema (append-only, no rename, new tags +allowed). + +**`exit 3` (download) tags:** + +| Tag | Meaning | +|---|---| +| `download-auth` | The platform returned a 401/403 or yt-dlp reported the URL is login-walled. The caller's recovery path is cookies — see [`cookies.md`](cookies.md). | +| `download-region` | The video is region-locked. yt-dlp reported a geo-restriction. A VPN session in the right region is the only fix. | +| `download-network` | A transient network failure: DNS resolution, TCP reset, TLS handshake, timeout against the platform CDN. Worth retrying after a short backoff. | +| `download-other` | Anything else yt-dlp emitted: extractor breakage, deleted post, malformed URL, format unavailable. Caller should surface the raw yt-dlp stderr to the user. | + +**`exit 4` (transcribe) tags:** + +| Tag | Meaning | +|---|---| +| `transcribe-quota` | The transcribe backend returned a billing/quota error: out of credit, monthly cap hit, or BYOK key exhausted. Caller should not retry without action. | +| `transcribe-timeout` | The transcribe backend did not return within the script's timeout. The audio file is probably too long, or the backend is slow. Retry with split audio or wait. | +| `transcribe-silent-audio` | The audio decoded successfully but the backend returned an empty transcript. Common for music videos, screen-recordings of code without narration, and very short clips. Not a true failure — caller should fall through to frames-only consumption. | +| `transcribe-other` | Anything else: backend 5xx, malformed response, audio file rejected as too large after downsample, unknown error. Surface raw stderr. | + +--- + +## Why stderr tags instead of more exit codes + +POSIX exit codes are 0–255. The standard `sysexits.h` convention +reserves 64–113 for usage-style errors, leaving little room to safely +sub-code without colliding with shell, kernel, or upstream tool +conventions (`130` for SIGINT, `137` for SIGKILL, `139` for SIGSEGV, and +so on). Sub-coding watch-cli's failures as `3.1`, `3.2`, `3.3` is not +expressible in a single exit code, and using `131`, `132`, `133` for +download sub-cases would collide with signal codes on Linux. + +The agreed convention across well-behaved CLIs is: keep the exit code +coarse, put the precise signal in a greppable stderr token. Callers +that need the sub-case `grep -o 'tag=[a-z-]*'`. Callers that don't, +just branch on the exit code. Both work in any language and any shell. + +--- + +## Partial success + +The `watch` pipeline runs three steps in order: download → frame +extraction → transcribe. If the first two succeed but the third fails: + +- The script exits with `4`. +- The `frame_paths` field in JSON output (or the `FRAMES:` block in + text output) is fully populated. +- The `transcript` field is `null` in JSON, or the literal token + `null` inside the `TRANSCRIPT:` block in text. +- The `EXIT: 4` line still appears at the bottom of text output. +- The `exit_code` field in JSON output is `4`. + +A calling agent can detect this case and still use the frames: + +```bash +output=$(watch --format json "$url") +rc=$? +case $rc in + 0) echo "$output" | jq -r .transcript ;; + 4) echo "transcribe failed, using frames only" >&2 + echo "$output" | jq -r '.frame_paths[]' ;; + *) echo "fatal: exit $rc" >&2; exit $rc ;; +esac +``` + +This is the headline reason exit codes exist as a separate spec: any +caller that hard-fails on every non-zero exit loses the recoverable +case. + +If frame extraction itself failed, the script exits with `1` (general +error) and stdout is not guaranteed to contain a parseable v1 block. +Callers should check `exit_code` first, then parse. + +--- + +## Behavior under `set -e` + +A wrapping shell script that uses `set -e` (abort on any non-zero) will +treat *every* non-zero watch-cli exit as a fatal error, including +recoverable partial-success cases like `exit 4`. The default shell +behavior swallows the chance to inspect the code. + +The portable pattern is to inspect the code without aborting: + +```bash +set -euo pipefail + +if ! output=$(watch --format json "$url"); then + rc=$? + case $rc in + 2) echo "missing dependency, install yt-dlp/ffmpeg" >&2; exit 2 ;; + 3) echo "download failed, check cookies or VPN" >&2; exit 3 ;; + 4) echo "transcribe failed, consuming frames only" >&2 + # do not exit; fall through to use $output ;; + 64) echo "usage error, fix invocation" >&2; exit 64 ;; + *) echo "watch failed: $rc" >&2; exit $rc ;; + esac +fi + +# process $output here +``` + +The `if ! …; then …; fi` form is the idiomatic Bash workaround. The +`exit-code-aware` body runs regardless of whether `watch` succeeded, +and `$?` is preserved across the `if` block. + +For Python callers: + +```python +import json, subprocess + +result = subprocess.run( + ["watch", "--format", "json", url], + capture_output=True, text=True, check=False, +) + +if result.returncode == 0: + data = json.loads(result.stdout) +elif result.returncode == 4: + data = json.loads(result.stdout) # partial: frame_paths populated, transcript None +else: + raise RuntimeError(f"watch failed: rc={result.returncode}\n{result.stderr}") +``` + +`check=False` is the equivalent of avoiding `set -e`: it suppresses the +implicit raise on non-zero, so the caller can branch on `returncode` +itself. + +--- + +## Example wrapper script + +A small Bash wrapper that handles every documented code: + +```bash +#!/usr/bin/env bash +# wrap-watch — call watch and act on the exit code. +set -uo pipefail # note: no -e, we inspect codes ourselves + +URL="$1" + +output=$(watch --format json "$URL") +rc=$? + +case $rc in + 0) + # Full success: frames + transcript. + transcript=$(echo "$output" | jq -r .transcript) + echo "OK — transcript length: ${#transcript} chars" + echo "$output" | jq -r '.frame_paths[]' | while read -r f; do + echo "frame: $f" + done + ;; + + 2) + echo "Missing dependency. Install yt-dlp, ffmpeg, ffprobe, jq." >&2 + exit 2 + ;; + + 3) + # Inspect the tag in stderr if you want fine-grained recovery. + # The stderr line looks like: tag=download-auth, tag=download-region, etc. + echo "Download failed. Try signing in to your browser or passing --cookies." >&2 + exit 3 + ;; + + 4) + # Partial success: frames OK, transcript missing. + echo "Transcribe failed — using frames only." >&2 + echo "$output" | jq -r '.frame_paths[]' | while read -r f; do + echo "frame: $f" + done + ;; + + 64) + echo "Usage error. Fix the invocation." >&2 + exit 64 + ;; + + *) + echo "Unexpected exit $rc." >&2 + exit "$rc" + ;; +esac +``` + +A few notes on this script: + +- `set -uo pipefail` without `-e` is deliberate. We catch errors via + the exit code, not via aborting. +- The `case` lists every documented code so a future watch-cli release + that adds new behavior (always within the v1 contract) does not + silently get treated as the catch-all. +- The `download-auth` vs `download-region` distinction lives in the + stderr tag, not the exit code. A wrapper that wants to auto-retry + with cookies can `grep -o 'tag=[a-z-]*' < stderr-capture` and + branch on the token. + +--- + +## Cross-references + +- The JSON / text shape the exit code appears in: + [`output-schema.md`](output-schema.md). +- Why a download might fail with `download-auth` and how to fix it: + [`cookies.md`](cookies.md). +- Per-platform "is this reachable at all" matrix that affects exit 3: + [`platforms.md`](platforms.md). diff --git a/docs/output-schema.md b/docs/output-schema.md new file mode 100644 index 0000000..a07ca5f --- /dev/null +++ b/docs/output-schema.md @@ -0,0 +1,293 @@ +# Output schema (v1) + +watch-cli emits a single, structured payload on stdout. That payload is +a contract. Agents, MCP servers, shell wrappers, and CI scripts read it +and branch on its fields. Once a consumer has been written against this +shape, it should keep working across watch-cli patch and minor releases +without code changes. + +This document is the source of truth for that contract: every field, the +stability promise around it, and the things consumers must *not* depend +on. The shipping `bin/watch` script and the future MCP server both +conform to this spec — if any of them drift, this document wins and the +implementation gets a bug fix. + +--- + +## Two output formats, one schema + +watch-cli supports two output formats in v1: + +- **Text format** (default) — a human-skimmable, agent-parseable block. + This is what `watch ` prints today. +- **JSON format** (opt-in via `--format json`) — a single compact JSON + object on one line. Designed for `jq`, language-native parsers, and + the MCP tool response shape. + +Both formats carry the same data. Pick by audience: humans + Claude +Code read the text block fine; anything that wants to programmatically +extract a single field should use JSON. + +--- + +## Version 1 — text format + +The text block is a sequence of labeled lines and indented sub-blocks. +Line markers appear in this exact order: + +``` +WATCH_OUTPUT_VERSION: 1 +VIDEO: +DURATION: +FRAMES: + + + … +TRANSCRIPT: + + + … +EXIT: +``` + +### Line markers, in order + +| Marker | Required | Meaning | +|---|---|---| +| `WATCH_OUTPUT_VERSION: 1` | yes | First line of stdout. Lets a consumer detect the schema version before parsing anything else. A future v2 increments this number. | +| `VIDEO: ` | yes | One line. Absolute filesystem path to the downloaded video file. Always exists when this line appears — download succeeded. | +| `DURATION: ` | yes | Video length in whole seconds, derived from `ffprobe`. Integer, not float; consumers that need sub-second precision should re-probe the file. | +| `FRAMES:` | yes | Header line. Followed by N indented lines, one per extracted frame. | +| ` ` (under `FRAMES:`) | yes | Two-space-indented absolute paths to JPG frames. Order is "earliest-in-video first". The number of lines matches the requested frame count (default 8). | +| `TRANSCRIPT:` | yes | Header line. Followed by the indented transcript body. | +| ` ` (under `TRANSCRIPT:`) | yes | Two-space-indented transcript lines. If the transcribe step failed, this block contains the single literal token `null` on one indented line — see *Partial success* below. | +| `EXIT: ` | yes | Final line of stdout. Mirrors the process exit code. Documented in [`exit-codes.md`](exit-codes.md). | + +### Whitespace and trailing newline + +- Indentation under `FRAMES:` and `TRANSCRIPT:` is exactly two ASCII + spaces. Consumers that need to be defensive can strip leading + whitespace; do not depend on the exact count beyond "at least one". +- The block may or may not end with a trailing newline. Treat presence + of trailing newline as undefined. +- Blank lines inside the transcript body are preserved with their + indent intact. + +### What appears on stderr + +Progress lines (`[watch] downloading …`, `[watch] transcribing +audio …`) and any error messages go to **stderr**, not stdout. The +stdout block is for consumers; stderr is for humans. See *What +consumers should not depend on* below. + +--- + +## Version 1 — JSON format + +`watch --format json ` emits exactly one line on stdout: a UTF-8 +JSON object terminated by a single `\n`. The object has the following +fields. + +### Field reference + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `version` | integer | yes | Always `1` in this schema. A future incompatible change ships as `2`. | +| `video_path` | string | yes | Absolute filesystem path to the downloaded video file. Same value as the text-format `VIDEO:` line. | +| `duration_sec` | number | yes | Video length in seconds. Emitted as a JSON number; may be integer or float depending on what `ffprobe` returned. | +| `frame_paths` | array of string | yes | Absolute paths to extracted JPG frames. Ordered earliest-in-video first. Length matches the requested frame count. | +| `transcript` | string or null | yes | Full transcript text, or `null` if the transcribe step failed (in which case `exit_code` will be `4`). The field is always present — its value, not its presence, signals failure. | +| `exit_code` | integer | yes | Final exit code from `bin/watch`. Mirrors the text-format `EXIT:` line and the process exit. See [`exit-codes.md`](exit-codes.md). | +| `transcribe_cost_usd` | number | no | Per-call transcribe cost in US dollars, when the backend reports it. Absent (key not present) when unknown — for example, when running with a BYOK key against a provider that does not return cost metadata. | + +### Required vs optional + +"Required" means the key is guaranteed to be present in every v1 JSON +output, regardless of success or failure. "Optional" means the key may +be absent. Consumers should treat absence as "value unknown", not as +zero or empty. + +A consumer that wants the cost field should check key presence +explicitly (`if "transcribe_cost_usd" in obj` in Python, `.transcribe_cost_usd +// empty` in `jq`) rather than defaulting absent values to `0`. A `0` +cost is meaningful (cached transcript, free tier); an absent cost is +"the backend did not tell us". + +--- + +## Stability promise + +The shape above is **v1**. The promise inside v1 is: + +- **Append-only.** Adding a new optional JSON field, or a new text-block + marker that consumers can ignore, is a **minor** release. Existing + consumers keep working without changes. +- **No renames within v1.** A field will not be renamed inside v1. If + the field is misnamed, it is corrected in v2. +- **No type changes within v1.** A field will not change its JSON type + inside v1. `duration_sec` stays numeric; `frame_paths` stays an array + of strings; `transcript` stays "string or null". +- **No removals within v1.** A required field will not be removed inside + v1. An optional field can be deprecated in a minor release with a + changelog note but stays parseable. + +Anything outside that promise — renaming a field, removing a field, +changing a field type, changing the meaning of a value — is a +**major** version bump. v2 will increment `WATCH_OUTPUT_VERSION:` (text) +and `"version": 2` (JSON). v1 output continues to be available via an +opt-in flag for at least one major release after v2 ships. + +### How a consumer detects the version + +- **Text mode:** parse the first line of stdout. It is always + `WATCH_OUTPUT_VERSION: `. Switch on the integer. +- **JSON mode:** parse the line as JSON, read `obj.version`. Switch on + the integer. + +Do not detect the version by sniffing for fields. A field that exists +in v1 today may exist with different semantics in v3. The version +number is the only correct signal. + +--- + +## Worked examples + +Same hypothetical input for both: a 218-second YouTube video at +`https://www.youtube.com/watch?v=abc123`, requesting the default 8 +frames. + +### Example 1 — text format + +```text +$ watch https://www.youtube.com/watch?v=abc123 +WATCH_OUTPUT_VERSION: 1 +VIDEO: /tmp/dl-video/abc123.mp4 +DURATION: 218 +FRAMES: + /tmp/frames_abc123/frame_01.jpg + /tmp/frames_abc123/frame_02.jpg + /tmp/frames_abc123/frame_03.jpg + /tmp/frames_abc123/frame_04.jpg + /tmp/frames_abc123/frame_05.jpg + /tmp/frames_abc123/frame_06.jpg + /tmp/frames_abc123/frame_07.jpg + /tmp/frames_abc123/frame_08.jpg +TRANSCRIPT: + Today I want to talk about how decomposition unlocks ten times cost + reduction in multimodal pipelines. The core idea is that a video is + just frames plus audio, and each of those already has a fast, + near-free primitive that has existed for years. +EXIT: 0 +``` + +Stderr during the same run (informational, not part of the contract): + +```text +[watch] downloading https://www.youtube.com/watch?v=abc123 … +[watch] video: /tmp/dl-video/abc123.mp4 +[watch] extracting 8 frames … +[watch] transcribing audio … +``` + +### Example 2 — JSON format + +```text +$ watch --format json https://www.youtube.com/watch?v=abc123 +``` + +Resulting stdout (formatted across multiple lines for readability — the +actual output is one line): + +```json +{ + "version": 1, + "video_path": "/tmp/dl-video/abc123.mp4", + "duration_sec": 218, + "frame_paths": [ + "/tmp/frames_abc123/frame_01.jpg", + "/tmp/frames_abc123/frame_02.jpg", + "/tmp/frames_abc123/frame_03.jpg", + "/tmp/frames_abc123/frame_04.jpg", + "/tmp/frames_abc123/frame_05.jpg", + "/tmp/frames_abc123/frame_06.jpg", + "/tmp/frames_abc123/frame_07.jpg", + "/tmp/frames_abc123/frame_08.jpg" + ], + "transcript": "Today I want to talk about how decomposition unlocks ten times cost reduction in multimodal pipelines. The core idea is that a video is just frames plus audio, and each of those already has a fast, near-free primitive that has existed for years.", + "exit_code": 0, + "transcribe_cost_usd": 0.00018 +} +``` + +To extract a single field: + +```bash +watch --format json https://www.youtube.com/watch?v=abc123 | jq -r .transcript +watch --format json https://www.youtube.com/watch?v=abc123 | jq -r '.frame_paths[]' +``` + +--- + +## Partial success + +A run can extract frames successfully but fail the transcribe step (the +backend timed out, the audio is silent, the quota is exhausted). v1 +represents this by: + +- Setting `exit_code` / `EXIT:` to `4` (transcribe failed — see + [`exit-codes.md`](exit-codes.md)). +- Keeping `frame_paths` populated with the frames that did extract. +- Setting `transcript` to JSON `null` (JSON mode), or printing the + single literal token `null` inside the `TRANSCRIPT:` block (text + mode). + +This lets a calling agent still consume the frames even when the +transcript is unavailable. Branching by `exit_code` is documented in +the exit-codes spec. + +--- + +## What consumers should *not* depend on + +The contract above is what watch-cli promises to keep stable. Anything +else is implementation detail and is allowed to change in any release, +including patch releases. + +Concretely, **do not** write parsers that depend on: + +- **Raw stderr formatting.** The `[watch] downloading …` lines, their + prefix, their wording, and their presence at all can change. Stderr + is for human eyes. Programmatic consumers should ignore it. +- **Exact whitespace inside the `TRANSCRIPT:` block.** Indentation is + guaranteed to be "at least one space"; the count, the use of tabs vs + spaces in future versions, and the line-wrapping policy are not part + of the contract. +- **Frame path names beyond their type.** Today frames are named + `frame_NN.jpg` under `/tmp/frames_/`. The directory name, the + numeric prefix, the zero-padding, and even the `.jpg` extension are + implementation details. The promise is "absolute paths to extracted + frames, ordered earliest-first". Consume them by reading the file + bytes, not by parsing the filename. +- **Order of optional JSON fields.** JSON object key order is unstable + across releases; required fields are always present but their order + inside the object is undefined. Use a real JSON parser, not regex. +- **Presence of a trailing newline.** May or may not be there. Strip if + you care. +- **Stderr being empty on success.** It is not. Progress lines always + print to stderr regardless of success or failure. +- **The presence of an extra blank line before `EXIT:`.** Allowed. + +If a consumer needs a behavior that is not in the *required* field +table above, file an issue. Working around the implementation by +parsing fragile details guarantees breakage on the next release. + +--- + +## Cross-references + +- Exit code semantics, stderr tag conventions, partial-success rule + details: [`exit-codes.md`](exit-codes.md). +- Platform-level "did the download succeed at all" matters: + [`platforms.md`](platforms.md). +- Cookie-walled sources before parsing failures get blamed on schema: + [`cookies.md`](cookies.md). From a773755c1a6b10e868bc36893192a9b2b8f6effa Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 19 May 2026 12:54:03 -0700 Subject: [PATCH 2/2] feat(phase-1): implement v1 output schema + structured exit codes + CI - watch text mode now emits WATCH_OUTPUT_VERSION: 1 header and EXIT: trailer - watch --format json emits compact single-line JSON per docs/output-schema.md - All bin/ scripts refactored to use the exit code table in docs/exit-codes.md - POSIX wc -c replaces stat -f%z / stat -c%s in transcribe and audio-q - New .github/workflows/ci.yml matrix on macOS + Ubuntu - tests/test-output-schema.sh covers help, version, usage-error, and JSON shape Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 96 +++++++++++++++++++++ README.md | 2 + bin/audio-q | 162 ++++++++++++++++++++++++++---------- bin/dl-video | 70 ++++++++++++---- bin/extract-frames | 76 ++++++++++++----- bin/models | 29 +++++-- bin/transcribe | 127 ++++++++++++++++++++++------ bin/watch | 126 ++++++++++++++++++++++++---- tests/test-output-schema.sh | 100 ++++++++++++++++++++++ 9 files changed, 656 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100755 tests/test-output-schema.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf8d69e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + smoke: + name: smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install deps (macOS) + if: matrix.os == 'macos-latest' + run: | + brew update + brew install yt-dlp ffmpeg jq + + - name: Install deps (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y yt-dlp ffmpeg jq curl python3 + + - name: Verify required binaries + run: | + for b in yt-dlp ffmpeg ffprobe jq curl python3 bash; do + command -v "$b" >/dev/null 2>&1 || { echo "missing $b"; exit 1; } + done + + - name: Bash syntax check + run: | + bash -n bin/watch bin/dl-video bin/extract-frames bin/transcribe bin/audio-q bin/models + + - name: Make bins executable + run: chmod +x bin/* + + - name: Install symlinks + run: | + # Run install.sh in an isolated dir so it doesn't pull a fresh clone. + # We just want the symlink step to validate, but install.sh is geared + # to clone-and-link from GitHub. Skip it and replicate the symlink + # step inline against the checked-out tree. + mkdir -p "$HOME/.local/bin" + for bin in watch dl-video extract-frames transcribe audio-q models; do + ln -sf "$GITHUB_WORKSPACE/bin/$bin" "$HOME/.local/bin/$bin" + done + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Smoke — watch --help + run: watch --help + + - name: Smoke — transcribe --help + run: transcribe --help + + - name: Smoke — extract-frames --help + run: extract-frames --help + + - name: Smoke — dl-video --help + run: dl-video --help + + - name: Smoke — audio-q --help + run: audio-q --help + + - name: Smoke — models --help + run: models --help + + - name: Negative — watch with no args must exit 64 + run: | + set +e + watch + rc=$? + set -e + if [[ $rc -ne 64 ]]; then + echo "expected exit 64 from 'watch' with no args, got $rc" >&2 + exit 1 + fi + echo "OK: rc=$rc" + + - name: Version — watch --version must exit 0 and print a version string + run: | + out="$(watch --version)" + echo "$out" + [[ "$out" =~ watch-cli ]] || { echo "expected 'watch-cli' in version output" >&2; exit 1; } + + - name: Output schema test + run: bash tests/test-output-schema.sh diff --git a/README.md b/README.md index 9b40654..8d175a3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # watch-cli +![CI](https://github.com/sonpiaz/watch-cli/actions/workflows/ci.yml/badge.svg) + **Watch any social video → get an architecture diagram, working component, runnable notebook, or step-by-step cheat sheet — automatically.** Eyes and ears for your AI agent. watch-cli composes `yt-dlp` + `ffmpeg` + a Whisper-class ASR into a single command that hands an agent the raw materials to "watch" any video: VIDEO + FRAMES + TRANSCRIPT, ready for an LLM to read frames as images and transcript as text. diff --git a/bin/audio-q b/bin/audio-q index a260a00..776028d 100755 --- a/bin/audio-q +++ b/bin/audio-q @@ -8,8 +8,11 @@ # - Kyma mode (KYMA_API_KEY set): POST api.kymaapi.com/v1/audio/understand # Get a Kyma key at https://kymaapi.com. # - Direct mode (GOOGLE_AI_KEY set): POST Gemini API directly (BYO). +# +# Exit codes (see docs/exit-codes.md): +# 0 success · 1 general · 2 missing-dep · 4 transcribe fail · 64 usage error -set -euo pipefail +set -uo pipefail SELF="${BASH_SOURCE[0]}" SELF_DIR="$(cd "$(dirname "$SELF")" && pwd)" @@ -17,39 +20,67 @@ ROOT_DIR="$(cd "$SELF_DIR/.." && pwd)" # shellcheck source=../lib/env.sh source "$ROOT_DIR/lib/env.sh" -INPUT="${1:-}" -QUESTION="${2:-}" +INPUT="" +QUESTION="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -V|--version) + echo "watch-cli v0.2.0" + exit 0 + ;; + *) + if [[ -z "$INPUT" ]]; then + INPUT="$1" + elif [[ -z "$QUESTION" ]]; then + QUESTION="$1" + fi + shift + ;; + esac +done -if [[ -z "$INPUT" || ! -f "$INPUT" || -z "$QUESTION" ]]; then - echo 'usage: audio-q ""' >&2 +if [[ -z "$INPUT" || -z "$QUESTION" ]]; then + echo 'usage: audio-q "" tag=usage-error' >&2 exit 64 fi -watch_cli_audio_mode_check "understand" || exit 1 - -if ! command -v ffmpeg >/dev/null 2>&1; then - echo "[audio-q] ffmpeg required. brew install ffmpeg / apt install ffmpeg" >&2 - exit 1 +if [[ ! -f "$INPUT" ]]; then + echo "[audio-q] error: file not found: $INPUT tag=usage-error" >&2 + exit 64 fi -if ! command -v jq >/dev/null 2>&1; then - echo "[audio-q] jq required. brew install jq / apt install jq" >&2 - exit 1 + +if ! watch_cli_audio_mode_check "understand"; then + echo "[audio-q] error: no usable audio backend tag=transcribe-other" >&2 + exit 4 fi +for dep in ffmpeg ffprobe jq curl base64; do + if ! command -v "$dep" >/dev/null 2>&1; then + echo "[audio-q] error: $dep not found on PATH tag=missing-dep:$dep" >&2 + exit 2 + fi +done + HASH="$(echo -n "$INPUT" | shasum | cut -c1-10)" AUDIO="/tmp/audioq_${HASH}.mp3" if [[ ! -s "$AUDIO" ]]; then - ffmpeg -hide_banner -loglevel error -y -i "$INPUT" \ - -vn -ac 1 -ar 16000 -b:a 48k -f mp3 "$AUDIO" 2>&1 >&2 || { - echo "[audio-q] ffmpeg audio extraction failed" >&2 - exit 1 - } + if ! ffmpeg -hide_banner -loglevel error -y -i "$INPUT" \ + -vn -ac 1 -ar 16000 -b:a 48k -f mp3 "$AUDIO" >&2 2>&1; then + echo "[audio-q] error: ffmpeg audio extraction failed tag=transcribe-other" >&2 + exit 4 + fi fi -SIZE=$(stat -f%z "$AUDIO" 2>/dev/null || stat -c%s "$AUDIO") +# POSIX size check: wc -c works on both macOS and Linux without a flag. +SIZE="$(wc -c < "$AUDIO" | tr -d ' ')" if (( SIZE > 19 * 1024 * 1024 )); then - echo "[audio-q] audio is $((SIZE / 1024 / 1024))MB — exceeds 20MB inline cap. Trim source first." >&2 - exit 1 + echo "[audio-q] error: audio is $((SIZE / 1024 / 1024))MB — exceeds 20MB inline cap. Trim source first. tag=transcribe-other" >&2 + exit 4 fi # Silence guard: multimodal LLMs hallucinate plausible-sounding scene @@ -60,9 +91,8 @@ MAX_DB="$(ffmpeg -i "$AUDIO" -af volumedetect -f null /dev/null 2>&1 | \ if [[ -n "$MAX_DB" ]]; then IS_SILENT="$(awk -v v="$MAX_DB" 'BEGIN { print (v < -60.0) ? 1 : 0 }')" if [[ "$IS_SILENT" == "1" ]]; then - echo "[audio-q] audio is silent (max_volume ${MAX_DB} dB). The video has no audible content to analyze." >&2 - echo "(silent audio: ${MAX_DB} dB)" - exit 0 + echo "[audio-q] error: audio is silent (max_volume ${MAX_DB} dB) tag=transcribe-silent-audio" >&2 + exit 4 fi fi @@ -74,27 +104,47 @@ case "$WATCH_AUDIO_MODE" in kyma) # Use the "audio-understand" alias rather than a concrete SKU. # Kyma can swap the underlying model without breaking watch-cli. - RESP="$(curl -sS -X POST \ + BODY="$(curl -sS -X POST \ + -w "%{http_code}" \ + -o /dev/stdout \ + --max-time 300 \ -H "Authorization: Bearer $KYMA_API_KEY" \ -H "User-Agent: $WATCH_CLI_USER_AGENT" \ -F "file=@$AUDIO;type=audio/mpeg" \ -F "model=audio-understand" \ -F "question=$QUESTION" \ -F "duration_sec=$DUR_SEC" \ - "$WATCH_KYMA_BASE/v1/audio/understand")" - - TEXT="$(echo "$RESP" | jq -r '.answer // empty')" - if [[ -z "$TEXT" ]]; then - ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" - echo "[audio-q] Kyma call failed: $ERR" >&2 - exit 1 - fi - echo "$TEXT" + "$WATCH_KYMA_BASE/v1/audio/understand" 2>/dev/null)" || { + echo "[audio-q] error: Kyma request failed tag=transcribe-other" >&2 + exit 4 + } + HTTP_CODE="${BODY: -3}" + RESP="${BODY:0:${#BODY}-3}" + case "$HTTP_CODE" in + 200) + TEXT="$(echo "$RESP" | jq -r '.answer // empty')" + if [[ -z "$TEXT" ]]; then + ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" + echo "[audio-q] error: Kyma call returned empty answer: $ERR tag=transcribe-other" >&2 + exit 4 + fi + echo "$TEXT" + ;; + 402|429) + echo "[audio-q] error: Kyma quota or rate limit (HTTP $HTTP_CODE) tag=transcribe-quota" >&2 + exit 4 ;; + 408|504) + echo "[audio-q] error: Kyma timeout (HTTP $HTTP_CODE) tag=transcribe-timeout" >&2 + exit 4 ;; + *) + echo "[audio-q] error: Kyma returned HTTP $HTTP_CODE tag=transcribe-other" >&2 + exit 4 ;; + esac ;; direct) B64="$(base64 < "$AUDIO" | tr -d '\n')" - BODY="$(jq -n \ + REQ_BODY="$(jq -n \ --arg q "$QUESTION" \ --arg data "$B64" \ '{ @@ -107,19 +157,39 @@ case "$WATCH_AUDIO_MODE" in generationConfig: { temperature: 0.3, maxOutputTokens: 2048 } }')" - RESP="$(curl -sS -X POST \ + BODY="$(curl -sS -X POST \ + -w "%{http_code}" \ + -o /dev/stdout \ + --max-time 300 \ -H "Content-Type: application/json" \ -H "x-goog-api-key: $GOOGLE_AI_KEY" \ -H "User-Agent: $WATCH_CLI_USER_AGENT" \ - -d "$BODY" \ - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent")" - - TEXT="$(echo "$RESP" | jq -r '.candidates[0].content.parts[0].text // empty')" - if [[ -z "$TEXT" ]]; then - ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" - echo "[audio-q] Gemini call failed: $ERR" >&2 - exit 1 - fi - echo "$TEXT" + -d "$REQ_BODY" \ + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" 2>/dev/null)" || { + echo "[audio-q] error: Gemini request failed tag=transcribe-other" >&2 + exit 4 + } + HTTP_CODE="${BODY: -3}" + RESP="${BODY:0:${#BODY}-3}" + case "$HTTP_CODE" in + 200) + TEXT="$(echo "$RESP" | jq -r '.candidates[0].content.parts[0].text // empty')" + if [[ -z "$TEXT" ]]; then + ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" + echo "[audio-q] error: Gemini call returned empty answer: $ERR tag=transcribe-other" >&2 + exit 4 + fi + echo "$TEXT" + ;; + 402|429) + echo "[audio-q] error: Gemini quota or rate limit (HTTP $HTTP_CODE) tag=transcribe-quota" >&2 + exit 4 ;; + 408|504) + echo "[audio-q] error: Gemini timeout (HTTP $HTTP_CODE) tag=transcribe-timeout" >&2 + exit 4 ;; + *) + echo "[audio-q] error: Gemini returned HTTP $HTTP_CODE tag=transcribe-other" >&2 + exit 4 ;; + esac ;; esac diff --git a/bin/dl-video b/bin/dl-video index d836640..ca33631 100755 --- a/bin/dl-video +++ b/bin/dl-video @@ -10,8 +10,11 @@ # Override via WATCH_BROWSER=chrome|firefox|safari|edge|brave|chromium. # 3. Manual cookies file via --cookies (Netscape format). # 4. Graceful error with guidance — no silent failure. +# +# Exit codes (see docs/exit-codes.md): +# 0 success · 2 missing-dep · 3 download fail · 64 usage error · 1 other -set -euo pipefail +set -uo pipefail URL="" OUTDIR="/tmp/dl-video" @@ -28,7 +31,11 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -V|--version) + echo "watch-cli v0.2.0" exit 0 ;; *) @@ -43,15 +50,13 @@ while [[ $# -gt 0 ]]; do done if [[ -z "$URL" ]]; then - echo "usage: dl-video [out-dir] [--cookies ]" >&2 + echo "usage: dl-video [out-dir] [--cookies ] tag=usage-error" >&2 exit 64 fi if ! command -v yt-dlp >/dev/null 2>&1; then - echo "[dl-video] yt-dlp not found. Install:" >&2 - echo " brew install yt-dlp # macOS" >&2 - echo " pipx install yt-dlp # Linux/Windows" >&2 - exit 1 + echo "[dl-video] error: yt-dlp not found on PATH. Install via 'brew install yt-dlp' or 'pipx install yt-dlp'. tag=missing-dep:yt-dlp" >&2 + exit 2 fi mkdir -p "$OUTDIR" @@ -81,6 +86,24 @@ run_dl() { "$URL" >&2 } +# Classify yt-dlp's last stderr log into one of the documented tags. +# Falls through to download-other when nothing matches. +classify_failure() { + local log="/tmp/dl-video.last.log" + [[ -f "$log" ]] || { echo "download-other"; return; } + local body + body="$(tr '[:upper:]' '[:lower:]' < "$log")" + if grep -qE 'sign in|login required|authentication|private|members[- ]only|http error 401|http error 403' <<< "$body"; then + echo "download-auth" + elif grep -qE 'geo[- ]restrict|not available in your country|region' <<< "$body"; then + echo "download-region" + elif grep -qE 'timed out|timeout|connection reset|temporary failure|network is unreachable|name or service not known|getaddrinfo|tls handshake' <<< "$body"; then + echo "download-network" + else + echo "download-other" + fi +} + # Tier 1: anonymous. Public videos succeed here. if run_dl 2>/tmp/dl-video.last.log; then if [[ -s "$OUT" ]]; then @@ -93,17 +116,18 @@ fi if [[ -n "$COOKIES_FILE" ]]; then echo "[dl-video] anonymous fetch failed, retrying with $COOKIES_FILE…" >&2 if [[ ! -f "$COOKIES_FILE" ]]; then - echo "[dl-video] cookies file not found: $COOKIES_FILE" >&2 - exit 1 + echo "[dl-video] error: cookies file not found: $COOKIES_FILE tag=usage-error" >&2 + exit 64 fi - if run_dl --cookies "$COOKIES_FILE"; then + if run_dl --cookies "$COOKIES_FILE" 2>/tmp/dl-video.last.log; then if [[ -s "$OUT" ]]; then echo "$OUT" exit 0 fi fi - echo "[dl-video] failed even with provided cookies" >&2 - exit 1 + TAG="$(classify_failure)" + echo "[dl-video] error: download failed for $URL even with provided cookies tag=$TAG" >&2 + exit 3 fi # Tier 2: auto-detect signed-in browser. @@ -126,9 +150,12 @@ for BROWSER in "${BROWSERS[@]}"; do fi done -# Tier 4: graceful error with actionable guidance. -cat >&2 <<'ERR' -[dl-video] Could not download — this video appears to require authentication. +# Tier 4: classify the final failure and emit a tag-tagged stderr line. +TAG="$(classify_failure)" +case "$TAG" in + download-auth) + cat >&2 < Three ways to fix: 1. Sign in to the platform in Chrome (or Firefox/Safari/Edge), then re-run. @@ -141,4 +168,15 @@ Three ways to fix: 3. Try a public URL — most YouTube, TikTok, and Reddit videos work without cookies. ERR -exit 1 + ;; + download-region) + echo "[dl-video] error: download failed for $URL tag=download-region — video is geo-restricted, try a VPN in the supported region" >&2 + ;; + download-network) + echo "[dl-video] error: download failed for $URL tag=download-network — transient network failure, wait and retry" >&2 + ;; + *) + echo "[dl-video] error: download failed for $URL tag=download-other — surface yt-dlp stderr above for details" >&2 + ;; +esac +exit 3 diff --git a/bin/extract-frames b/bin/extract-frames index 2163664..fbf80c1 100755 --- a/bin/extract-frames +++ b/bin/extract-frames @@ -1,36 +1,66 @@ #!/usr/bin/env bash # extract-frames