From 620f464373d892004aa4a9513a6c815aa9407cc7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 13:48:44 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(media-use):=20fast=20heygen=20CLI=20on?= =?UTF-8?q?boarding=20=E2=80=94=20actionable=20diagnostics,=20--doctor,=20?= =?UTF-8?q?free-usage=20framing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media-use resolves bgm/sfx/image/icon (catalog), voice (TTS), and avatar video through the heygen CLI — the free-usage path. Agents hit a dead end when it's missing/unauthed. This guides them to install it fast, at the point of need. - Centralized actionable diagnostics (lib/heygen-cli.mjs): every heygen-backed resolve, on failure, prints the exact fix on stderr — not-installed (curl install one-liner), not-authenticated (heygen auth login), outdated (heygen update). Routed through heygen-search + voice-provider. stdout stays clean JSON. - resolve --doctor preflight (human + --json): checks heygen present/version/ auth, ffmpeg, ffprobe, node, a fix per gap. Exit 0 unless ffmpeg missing. - SKILL reframe: install-first callout; heygen as the free-usage gateway for bgm/image/voice/avatar-video; removed the false "degrades gracefully" claim. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --- skills-manifest.json | 4 +- skills/media-use/SKILL.md | 125 ++++++++------ skills/media-use/scripts/lib/heygen-cli.mjs | 92 ++++++++++ .../media-use/scripts/lib/heygen-cli.test.mjs | 38 +++++ .../media-use/scripts/lib/heygen-search.mjs | 4 +- .../media-use/scripts/lib/voice-provider.mjs | 5 +- skills/media-use/scripts/resolve.mjs | 158 ++++++++++++++++++ skills/media-use/scripts/resolve.test.mjs | 35 ++++ 8 files changed, 400 insertions(+), 61 deletions(-) create mode 100644 skills/media-use/scripts/lib/heygen-cli.mjs create mode 100644 skills/media-use/scripts/lib/heygen-cli.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index 0d2a4ec2cd..c561bf6680 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,8 +46,8 @@ "files": 10 }, "media-use": { - "hash": "22b54ebaa6f93b5e", - "files": 114 + "hash": "31d810f68d961409", + "files": 116 }, "motion-graphics": { "hash": "96ed2f7d8051b009", diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index 86e863a534..501a7a4dd5 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -7,24 +7,37 @@ description: Agent Media OS, the single skill for every media need in a HyperFra The media OS for HyperFrames: resolve · generate · operate · remember, every media type, one skill, zero context noise. +## Setup — install heygen first (free-usage path) + +```bash +curl -fsSL https://static.heygen.ai/cli/install.sh | bash +heygen auth login --key +``` + +This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Before resolving anything, verify setup with: + +```bash +node /scripts/resolve.mjs --doctor +``` + ## What it owns (the gaps HyperFrames leaves) HyperFrames owns media _playback_; media-use owns everything else. Each row is enforced by `scripts/lib/coverage.test.mjs` so the claim can't rot. -| HyperFrames gap | media-use owns it via | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -| Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) | -| No third-party brand logos | `resolve --type logo` (svgl → simple-icons → GitHub org avatar → domain favicon) | -| No voice / audio generation | `resolve --type voice` + the audio engine (`audio/scripts/audio.mjs`) | -| Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) | -| No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs | -| No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists | -| No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes | -| No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) | -| No color-grade authoring | `resolve --type grade` emits a paste-ready `data-color-grading` block; `resolve --type lut` freezes validated `.cube` files | -| No image generation | RAM-graded local mflux (FLUX) via `scripts/lib/mflux-provider.mjs`, codex `image_gen` upsell (`scripts/lib/codex-provider.mjs`) | -| No video generation | spec-gated local LTX (`videogen` in `scripts/lib/local-models.mjs`); `heygen video create` avatar upsell | -| Weak local-model defaults | free-usage HeyGen first (TTS, bg-removal) via the `heygen` CLI; local open-source only as an opt-out fallback (`scripts/lib/local-run.mjs`) | +| HyperFrames gap | media-use owns it via | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) | +| No third-party brand logos | `resolve --type logo` (svgl → simple-icons → GitHub org avatar → domain favicon) | +| No voice / audio generation | `resolve --type voice` (HeyGen TTS free-usage path; optional local Kokoro) + the audio engine (`audio/scripts/audio.mjs`) | +| Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) | +| No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs | +| No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists | +| No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes | +| No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) | +| No color-grade authoring | `resolve --type grade` emits a paste-ready `data-color-grading` block; `resolve --type lut` freezes validated `.cube` files | +| No image generation | RAM-graded local mflux (FLUX) via `scripts/lib/mflux-provider.mjs`, codex `image_gen` upsell (`scripts/lib/codex-provider.mjs`) | +| No video generation | HeyGen avatar video free-usage path; optional spec-gated local LTX (`videogen` in `scripts/lib/local-models.mjs`) | +| Weak local-model defaults | HeyGen free-usage path via the `heygen` CLI; local open-source tools only as opt-in alternatives (`scripts/lib/local-run.mjs`) | ## When to use @@ -69,7 +82,7 @@ Returns one line: `resolved (, )` | `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) | | `icon` | Icons, symbols | HeyGen asset search (type=icon) | | `logo` | Official brand marks | svgl → simple-icons → GitHub org avatar → domain favicon | -| `voice` | TTS voiceover | HeyGen TTS (OAuth free allowance); Kokoro local fallback | +| `voice` | TTS voiceover | HeyGen TTS free-usage path; optional local Kokoro | | `grade` | HyperFrames color-grading blocks | Core preset → look index params/CDN LUT → deterministic cube | | `lut` | Reusable `.cube` LUT files | Look index params/CDN LUT → deterministic cube | @@ -120,6 +133,7 @@ node /scripts/resolve.mjs --type lut --intent "teal orange blockbuste | `--local-only` | Offline: skip every network provider (cache + local only) | | `--provider` | Force one generator (e.g. `codex`, `mflux`, `kokoro`, `heygen`) | | `--adopt` | Bulk-import existing assets/ into manifest | +| `--doctor` | Check local CLI dependencies; no manifest changes | | `--json` | Output JSON instead of one-line result | ## Reuse before you resolve @@ -225,35 +239,36 @@ node skills/media-use/scripts/lib/cube-validate.mjs .media/luts/lut_001.cube ## Providers media-use holds no keys; every external tool owns its auth. Generation is -local-first with a cloud upsell where one helps. `resolve` spec-checks -AVAILABLE RAM and auto-picks the best local model that fits (a RAM-graded -ladder, `describeModelLadder`); the agent can see the ladder and override. - -| Type | Provider (in order) | -| ------------- | --------------------------------------------------------------------------------------- | -| bgm/sfx | heygen catalog (free) | -| image | heygen search, then local mflux (best FLUX for your RAM), then codex `image_gen` upsell | -| voice | **heygen tts** via OAuth/web-plan allowance, then local **Kokoro** fallback | -| icon | heygen asset search | -| logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) | -| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback | -| video (local) | local LTX (`videogen` ladder); `heygen video create` avatar upsell | +centered on the HeyGen CLI free-usage path. Install and authenticate `heygen` +before resolving bgm/sfx/image/icon/voice/avatar-video. Local tools are opt-in +alternatives where they exist: mflux for image, Kokoro for voice, Parakeet for +transcription, and LTX for local video generation. `resolve` spec-checks +AVAILABLE RAM for those local ladders (`describeModelLadder`); the agent can +see the ladder and override. + +| Type | Provider / path | +| --------- | -------------------------------------------------------------------------------- | +| bgm/sfx | heygen catalog free-usage path | +| image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell | +| voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) | +| icon | heygen asset search free-usage path | +| logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) | +| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback | +| video | heygen avatar video free-usage path; optional local LTX (`videogen` ladder) | Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free, -private, offline once cached). Credentialed HeyGen TTS should be tried first for -voice so OAuth CLI users consume the free web-plan allowance (10 min/month); -API-key usage and overage follow the user's HeyGen billing path. Paid/cloud -upsells remain the `codex` CLI (ChatGPT sub) for a better image and the -`heygen` CLI for avatar video. Cost rule (X4): the agent confirms before an -agent-initiated paid call; a user-requested one just runs. +private, offline once cached). The `codex` CLI remains the ChatGPT-sub image +upsell. Cost rule (X4): the agent confirms before an agent-initiated paid call; +a user-requested one just runs. To force a specific generator (e.g. a user says "make this image with codex"), pass `--provider codex`: it pins resolution to that provider and skips the -free-first default. See `references/operations.md` for the RAM ladders and -upsell recipes. +free-usage default. See `references/operations.md` for the RAM ladders and +provider recipes. `--local-only` skips every network provider, including the free HeyGen ones, -leaving the project + global cache and any local provider. +leaving the project + global cache and any installed local provider. For +HeyGen-only types, that means no fresh resolve. ## How it works @@ -319,7 +334,7 @@ node /audio/scripts/audio.mjs --request ./audio_request.json --out ./ - **Request** `{ provider?, lang?, speed?, lines: [{ id, text, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`. - **Output** `audio_meta.json` (id-keyed): `voices[].{path,duration_s,words[]}` (word timestamps for captions), `sfx[]`, `bgm`, `total_duration_s`. -- **Auto-degrades on one switch**: HeyGen credential present → HeyGen TTS + music/SFX retrieval; absent → ElevenLabs/Kokoro TTS, Lyria/MusicGen BGM generation, and the bundled SFX library (no credential needed). +- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `node /scripts/resolve.mjs --doctor` before assuming retrieval or TTS will work. - If BGM took the generate path (`bgm_pending: true`), run `audio/scripts/wait-bgm.mjs` before final render. Single-shot helpers: `audio/scripts/heygen-tts.mjs` (one voice file). Transcription / background removal / captions use the `hyperframes` CLI (`transcribe`, `remove-background`), see the per-topic guides in `audio/references/` (`tts.md`, `bgm.md`, `sfx.md`, `transcribe.md`, `remove-background.md`, `captions/`). @@ -335,26 +350,28 @@ cache. ## CLI tools used (what to run, and how to enable each) -`resolve` auto-cascades; each provider shells one CLI. Local tools are OPT-IN: -if a local tool is absent, resolve degrades gracefully to the free/cloud path, -so nothing here is strictly required except `ffmpeg`/`ffprobe`. Install a local -tool to unlock its free, private, on-device path. media-use holds no keys. - -| Tool | Serves | Install | -| ------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) | -| `heygen` | catalog (bgm/sfx/image/icon), TTS + avatar upsell | `curl -fsSL https://static.heygen.ai/cli/install.sh \| bash` then `heygen auth login --oauth` or API-key login (needs >= v0.1.6) | -| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` | -| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) | -| `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` | -| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` | -| `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | bundled with the hyperframes CLI | +`resolve` auto-cascades; each provider shells one CLI. HeyGen is the +free-usage path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar +video, so those capabilities need `heygen` installed and authenticated. Local +tools are OPT-IN alternatives where they exist; install one to unlock its free, +private, on-device path instead of or ahead of HeyGen for that type. Only +`ffmpeg`/`ffprobe` are strictly required for the tool to run at all. + +| Tool | Serves | Install | +| ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) | +| `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | `curl -fsSL https://static.heygen.ai/cli/install.sh \| bash` then `heygen auth login --key ` (needs >= v0.1.6) | +| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` | +| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) | +| `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` | +| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` | +| `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | bundled with the hyperframes CLI | The RAM-graded local-model shortlist + exact per-tier install/invoke lives in `scripts/lib/local-models.mjs` (the agent can read `describeModelLadder(cap, specs)` to see which model fits this machine). Without a tool on PATH, its provider -prints a one-line diagnostic to stderr and resolve falls through to the next -provider (e.g. no `mflux` -> codex image upsell; no `parakeet-mlx` -> whisper.cpp). +prints a one-line diagnostic to stderr and resolve falls through where another +provider exists (e.g. no `mflux` -> codex image upsell; no `parakeet-mlx` -> whisper.cpp). `heygen asset search` is a pre-launch command hidden from `heygen --help`, but it runs; providers tag requests with the allowlisted `X-HeyGen-Client-Source` header diff --git a/skills/media-use/scripts/lib/heygen-cli.mjs b/skills/media-use/scripts/lib/heygen-cli.mjs new file mode 100644 index 0000000000..a071a18969 --- /dev/null +++ b/skills/media-use/scripts/lib/heygen-cli.mjs @@ -0,0 +1,92 @@ +export const HEYGEN_MIN_VERSION = "0.1.6"; +export const HEYGEN_INSTALL_COMMAND = + "curl -fsSL https://static.heygen.ai/cli/install.sh | bash then heygen auth login --key "; +export const HEYGEN_AUTH_COMMAND = "heygen auth login --key "; +export const HEYGEN_UPDATE_COMMAND = "heygen update"; + +export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: ${HEYGEN_INSTALL_COMMAND}`; +export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`; +export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`; + +const ACTIONABLE_MESSAGES = new Set([ + HEYGEN_NOT_FOUND_MESSAGE, + HEYGEN_NOT_AUTHENTICATED_MESSAGE, + HEYGEN_OUTDATED_MESSAGE, +]); + +export function classifyHeygenError(err) { + const detail = heygenErrorDetail(err); + const text = [err?.stderr, err?.stdout, err?.message, detail] + .map((value) => textOf(value)) + .filter(Boolean) + .join("\n"); + const lower = text.toLowerCase(); + + if ( + err?.code === "ENOENT" || + lower.includes("command not found") || + lower.includes("not found") + ) { + return HEYGEN_NOT_FOUND_MESSAGE; + } + + if ( + lower.includes("unauthorized") || + lower.includes("unauthenticated") || + lower.includes("401") || + lower.includes("not logged in") || + lower.includes("no api key") || + lower.includes("missing api key") || + lower.includes("invalid api key") || + lower.includes("login required") || + lower.includes("auth required") || + lower.includes("authentication required") + ) { + return HEYGEN_NOT_AUTHENTICATED_MESSAGE; + } + + const version = firstSemver(text); + if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) { + return HEYGEN_OUTDATED_MESSAGE; + } + + return detail; +} + +export function reportHeygenFailure(err, context) { + const message = classifyHeygenError(err); + if (ACTIONABLE_MESSAGES.has(message)) { + console.error(message); + } else { + console.error(`media-use: \`${context}\` failed: ${message}`); + } +} + +export function firstSemver(text) { + const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/); + return match ? `${match[1]}.${match[2]}.${match[3]}` : null; +} + +export function versionLessThan(version, minimum) { + const left = versionParts(version); + const right = versionParts(minimum); + if (!left || !right) return false; + for (let i = 0; i < 3; i++) { + if (left[i] < right[i]) return true; + if (left[i] > right[i]) return false; + } + return false; +} + +function heygenErrorDetail(err) { + return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err); +} + +function textOf(value) { + return value == null ? "" : String(value).trim(); +} + +function versionParts(version) { + const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/); + return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null; +} diff --git a/skills/media-use/scripts/lib/heygen-cli.test.mjs b/skills/media-use/scripts/lib/heygen-cli.test.mjs new file mode 100644 index 0000000000..88767658ff --- /dev/null +++ b/skills/media-use/scripts/lib/heygen-cli.test.mjs @@ -0,0 +1,38 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { classifyHeygenError } from "./heygen-cli.mjs"; + +test("classifies ENOENT-style missing heygen errors with install instructions", () => { + const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" }); + + assert.equal( + message, + "media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: curl -fsSL https://static.heygen.ai/cli/install.sh | bash then heygen auth login --key ", + ); +}); + +test("classifies auth failures with login instructions", () => { + const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") }); + + assert.equal( + message, + "media-use: heygen CLI not authenticated (free usage) — run: heygen auth login --key ", + ); +}); + +test("classifies old heygen versions with update instructions", () => { + const message = classifyHeygenError({ + stderr: Buffer.from("heygen v0.1.5 does not support --headers"), + }); + + assert.equal(message, "media-use: heygen CLI is outdated — run: heygen update (need >= v0.1.6)"); +}); + +test("passes through unrelated errors", () => { + const message = classifyHeygenError({ + stderr: Buffer.from("rate limit exceeded"), + message: "Command failed", + }); + + assert.equal(message, "rate limit exceeded"); +}); diff --git a/skills/media-use/scripts/lib/heygen-search.mjs b/skills/media-use/scripts/lib/heygen-search.mjs index d873504eae..943ef7a29c 100644 --- a/skills/media-use/scripts/lib/heygen-search.mjs +++ b/skills/media-use/scripts/lib/heygen-search.mjs @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { reportHeygenFailure } from "./heygen-cli.mjs"; export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) { // execFileSync with an argv array (no shell), so query/type/etc. are passed as @@ -28,8 +29,7 @@ export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = } catch (err) { // Don't swallow a broken command / auth failure as "no results" — that turns // a typo or expired key into a silent dead end. Surface it, then give up. - const detail = err.stderr?.toString().trim() || err.stdout?.toString().trim() || err.message; - console.error(`media-use: \`heygen ${subcommand}\` failed: ${detail}`); + reportHeygenFailure(err, `heygen ${subcommand}`); return null; } diff --git a/skills/media-use/scripts/lib/voice-provider.mjs b/skills/media-use/scripts/lib/voice-provider.mjs index 985ff718b9..11867bb063 100644 --- a/skills/media-use/scripts/lib/voice-provider.mjs +++ b/skills/media-use/scripts/lib/voice-provider.mjs @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { reportHeygenFailure } from "./heygen-cli.mjs"; // Voice / TTS generation via the HeyGen CLI — the only external CLI media-use // shells (CLI-only invariant: media-use holds no keys; the CLI owns auth). @@ -13,9 +14,7 @@ function runJson(bin, argv, label) { stdio: ["pipe", "pipe", "pipe"], }); } catch (err) { - console.error( - `media-use: \`${bin}\` ${label} failed: ${err.stderr?.toString().trim() || err.message}`, - ); + reportHeygenFailure(err, `${bin} ${label}`); return null; } try { diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 59918f5333..727dc6a791 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; import { existsSync, statSync, writeFileSync, renameSync, rmSync } from "node:fs"; import { resolve, join, extname, basename } from "node:path"; import { parseArgs } from "node:util"; @@ -21,6 +22,14 @@ import { isLibraryLutOfflineMiss, matchColorLook, } from "./lib/lut-preset-provider.mjs"; +import { + HEYGEN_AUTH_COMMAND, + HEYGEN_INSTALL_COMMAND, + HEYGEN_MIN_VERSION, + HEYGEN_UPDATE_COMMAND, + firstSemver, + versionLessThan, +} from "./lib/heygen-cli.mjs"; const { values: args } = parseArgs({ options: { @@ -30,6 +39,7 @@ const { values: args } = parseArgs({ project: { type: "string", short: "p", default: "." }, adopt: { type: "boolean", default: false }, candidates: { type: "boolean", default: false }, + doctor: { type: "boolean", default: false }, "dry-run": { type: "boolean", default: false }, reuse: { type: "string" }, from: { type: "string" }, @@ -59,6 +69,7 @@ Options: --adopt Adopt all existing assets/ files into the manifest --candidates List reusable assets (project + global cache) for --type; no download, no mutation. Read them and decide reuse yourself. + --doctor Check local CLI dependencies; no manifest changes. --reuse Import a specific global-cache asset (by content sha/prefix, from --candidates) into this project --from Freeze a local file or direct public URL (ingest) @@ -99,6 +110,16 @@ if (args.candidates || args["dry-run"]) { process.exit(0); } +if (args.doctor) { + const doctor = runDoctor(); + if (args.json) { + console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks })); + } else { + printDoctor(doctor.checks); + } + process.exit(doctor.ok ? 0 : 1); +} + // Reuse: import a specific global-cache asset (by content sha/prefix, taken // from --candidates) into this project. `!== undefined` so an empty --reuse "" // still routes here (and gets a clear empty-sha error) instead of falling @@ -709,6 +730,143 @@ async function showCandidates() { } } +function runDoctor() { + const checks = []; + const heygenVersionProbe = runCommand("heygen", ["--version"]); + const heygenOnPath = heygenVersionProbe.status === 0; + const heygenVersionText = commandText(heygenVersionProbe); + const heygenVersion = firstSemver(heygenVersionText); + + checks.push({ + name: "heygen on PATH", + ok: heygenOnPath, + detail: heygenOnPath + ? `heygen ${heygenVersion ? `v${heygenVersion}` : "found"}` + : "heygen not found", + fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND, + }); + + if (!heygenOnPath) { + checks.push({ + name: "heygen version", + ok: false, + detail: "heygen version unavailable", + fix: HEYGEN_INSTALL_COMMAND, + }); + checks.push({ + name: "heygen authenticated", + ok: false, + detail: "heygen auth status unavailable", + fix: HEYGEN_INSTALL_COMMAND, + }); + } else if (heygenVersion) { + const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION); + checks.push({ + name: "heygen version", + ok: versionOk, + detail: `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`, + fix: versionOk ? "" : HEYGEN_UPDATE_COMMAND, + }); + + const authProbe = runCommand("heygen", ["auth", "status"]); + const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null; + checks.push({ + name: "heygen authenticated", + ok: !!email, + detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated", + fix: email ? "" : HEYGEN_AUTH_COMMAND, + }); + } else { + checks.push({ + name: "heygen version", + ok: true, + detail: "heygen version did not include semver", + fix: "", + }); + + const authProbe = runCommand("heygen", ["auth", "status"]); + const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null; + checks.push({ + name: "heygen authenticated", + ok: !!email, + detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated", + fix: email ? "" : HEYGEN_AUTH_COMMAND, + }); + } + + const ffmpegProbe = runCommand("ffmpeg", ["-version"]); + checks.push({ + name: "ffmpeg on PATH", + ok: ffmpegProbe.status === 0, + detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found", + fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg", + }); + + const ffprobeProbe = runCommand("ffprobe", ["-version"]); + checks.push({ + name: "ffprobe on PATH", + ok: ffprobeProbe.status === 0, + detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found", + fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg", + }); + + checks.push({ + name: "node version", + ok: true, + detail: process.version, + fix: "", + }); + + const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH"); + return { ok: !!ffmpeg?.ok, checks }; +} + +function printDoctor(checks) { + const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]); + for (const check of checks) { + const prefix = check.ok ? "✓" : "✗"; + const freePath = heygenChecks.has(check.name) + ? " — free-usage path: bgm/image/voice/avatar-video" + : ""; + const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`; + console.log(`${prefix} ${check.detail}${freePath}${fix}`); + } +} + +function runCommand(bin, argv) { + return spawnSync(bin, argv, { + encoding: "utf8", + timeout: 15000, + }); +} + +function commandText(result) { + return [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); +} + +function firstLine(text) { + return ( + String(text || "") + .trim() + .split(/\r?\n/)[0] || "" + ); +} + +function emailFromAuthStatus(text) { + const trimmed = String(text || "").trim(); + if (!trimmed) return null; + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + return parsed?.data?.email || parsed?.email || null; + } catch { + return null; + } + } + const match = trimmed.match(/[^\s@]+@[^\s@]+\.[^\s@]+/); + return match ? match[0] : null; +} + async function reuseGlobal(shaArg) { const projectDir = resolve(args.project); const type = args.type; diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs index 2f08fb310e..2d0a6fc581 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/skills/media-use/scripts/resolve.test.mjs @@ -118,6 +118,10 @@ function normalizeWithCoreSource(grading) { ); } +function runResolveStatus(args) { + return spawnResolve(args); +} + const tests = []; function test(name, fn) { tests.push({ name, fn }); @@ -330,6 +334,37 @@ test("--json returns error JSON on stub provider failure", () => { cleanup(); }); +test("--doctor --json reports dependency checks and top-level ok follows ffmpeg only", () => { + const result = runResolveStatus(["--doctor", "--json"]); + assert.match(result.stdout.trim(), /^\{/); + assert.equal(result.stderr, ""); + assert.ok(result.status === 0 || result.status === 1); + + const parsed = JSON.parse(result.stdout.trim()); + assert.ok(Array.isArray(parsed.checks)); + + const expected = [ + "heygen on PATH", + "heygen version", + "heygen authenticated", + "ffmpeg on PATH", + "ffprobe on PATH", + "node version", + ]; + const byName = new Map(parsed.checks.map((check) => [check.name, check])); + for (const name of expected) { + assert.ok(byName.has(name), `missing check: ${name}`); + const check = byName.get(name); + assert.equal(typeof check.ok, "boolean", `${name}.ok`); + assert.equal(typeof check.detail, "string", `${name}.detail`); + assert.ok("fix" in check, `${name}.fix`); + } + + const ffmpeg = byName.get("ffmpeg on PATH"); + assert.equal(parsed.ok, ffmpeg.ok); + assert.equal(result.status, ffmpeg.ok ? 0 : 1); +}); + test("one-line output format matches contract", () => { setup(); const record = makeRecord({ provenance: { prompt: "format test", provider: "test" } }); From bc61dc6607bef45759bae04cf6a3deb5ac71547d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 20:05:22 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix(media-use):=20address=20#2065=20review?= =?UTF-8?q?=20=E2=80=94=20classifier=20blocker,=20doctor=20contract,=20tel?= =?UTF-8?q?emetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blocker: classifyHeygenError no longer treats a bare "not found" as CLI-missing (a stale voiceId → "voice not found" was sending users to reinstall a working CLI); keep only ENOENT + "command not found". Regression test added. - 401 now matches \b401\b, not any "401" substring (request IDs no longer misread). - --doctor: top-level ok requires ffmpeg AND ffprobe (matches SKILL.md); emits media_use_doctor_run telemetry; auth status queried with --json + JSON-only parse; auth timeout softened (network issue, not a false "unauthenticated"); node version gated on >= 18; version-without-semver labeled, not silently green. - Nits: install cmd uses && ; dropped the runResolveStatus alias. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --- skills-manifest.json | 2 +- skills/media-use/scripts/lib/heygen-cli.mjs | 17 ++-- .../media-use/scripts/lib/heygen-cli.test.mjs | 36 +++++++- skills/media-use/scripts/resolve.mjs | 88 ++++++++++++------- skills/media-use/scripts/resolve.test.mjs | 14 ++- 5 files changed, 108 insertions(+), 49 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index c561bf6680..cc1049ef2f 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "31d810f68d961409", + "hash": "e7d884dd01ef0a3e", "files": 116 }, "motion-graphics": { diff --git a/skills/media-use/scripts/lib/heygen-cli.mjs b/skills/media-use/scripts/lib/heygen-cli.mjs index a071a18969..f2faaa2ac7 100644 --- a/skills/media-use/scripts/lib/heygen-cli.mjs +++ b/skills/media-use/scripts/lib/heygen-cli.mjs @@ -1,6 +1,6 @@ export const HEYGEN_MIN_VERSION = "0.1.6"; export const HEYGEN_INSTALL_COMMAND = - "curl -fsSL https://static.heygen.ai/cli/install.sh | bash then heygen auth login --key "; + "curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --key "; export const HEYGEN_AUTH_COMMAND = "heygen auth login --key "; export const HEYGEN_UPDATE_COMMAND = "heygen update"; @@ -22,18 +22,21 @@ export function classifyHeygenError(err) { .join("\n"); const lower = text.toLowerCase(); - if ( - err?.code === "ENOENT" || - lower.includes("command not found") || - lower.includes("not found") - ) { + // Only ENOENT (spawn of a missing binary) or a shell's "command not found" + // mean the CLI itself is absent. A bare "not found" would misfire on the CLI's + // own resource errors (e.g. a stale voiceId → "voice not found"), whose message + // embeds the `heygen ...` command line — sending users to reinstall a CLI they + // just ran successfully. Keep this narrow. + if (err?.code === "ENOENT" || lower.includes("command not found")) { return HEYGEN_NOT_FOUND_MESSAGE; } if ( lower.includes("unauthorized") || lower.includes("unauthenticated") || - lower.includes("401") || + // \b401\b, not a bare "401" substring — otherwise request IDs (req-401abc), + // URLs, and retry-after headers would misclassify as an auth failure. + /\b401\b/.test(lower) || lower.includes("not logged in") || lower.includes("no api key") || lower.includes("missing api key") || diff --git a/skills/media-use/scripts/lib/heygen-cli.test.mjs b/skills/media-use/scripts/lib/heygen-cli.test.mjs index 88767658ff..ab641b19ae 100644 --- a/skills/media-use/scripts/lib/heygen-cli.test.mjs +++ b/skills/media-use/scripts/lib/heygen-cli.test.mjs @@ -1,13 +1,13 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; -import { classifyHeygenError } from "./heygen-cli.mjs"; +import { classifyHeygenError, HEYGEN_NOT_FOUND_MESSAGE } from "./heygen-cli.mjs"; test("classifies ENOENT-style missing heygen errors with install instructions", () => { const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" }); assert.equal( message, - "media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: curl -fsSL https://static.heygen.ai/cli/install.sh | bash then heygen auth login --key ", + "media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --key ", ); }); @@ -20,6 +20,19 @@ test("classifies auth failures with login instructions", () => { ); }); +test("classifies a real 401 as auth, but not a bare 401 substring in prose", () => { + assert.equal( + classifyHeygenError({ stderr: Buffer.from("HTTP 401 Unauthorized") }), + "media-use: heygen CLI not authenticated (free usage) — run: heygen auth login --key ", + ); + // A request id that merely contains "401" must NOT read as an auth failure. + const noise = classifyHeygenError({ stderr: Buffer.from("upload failed (request req-401abc)") }); + assert.notEqual( + noise, + "media-use: heygen CLI not authenticated (free usage) — run: heygen auth login --key ", + ); +}); + test("classifies old heygen versions with update instructions", () => { const message = classifyHeygenError({ stderr: Buffer.from("heygen v0.1.5 does not support --headers"), @@ -28,6 +41,25 @@ test("classifies old heygen versions with update instructions", () => { assert.equal(message, "media-use: heygen CLI is outdated — run: heygen update (need >= v0.1.6)"); }); +test("does not misclassify a resource 'not found' error as a missing CLI", () => { + // A stale voiceId makes `heygen voice speech create` fail with "voice not + // found"; the error message embeds the `heygen ...` command line. This must + // pass through as detail, not send the user to reinstall a working CLI. + const message = classifyHeygenError({ + stderr: Buffer.from("Error: voice not found (id: stale-123)"), + message: "Command failed: heygen voice speech create --voice stale-123", + }); + + assert.notEqual(message, HEYGEN_NOT_FOUND_MESSAGE); + assert.equal(message, "Error: voice not found (id: stale-123)"); +}); + +test("classifies a shell 'command not found' as a missing CLI", () => { + const message = classifyHeygenError({ stderr: Buffer.from("bash: heygen: command not found") }); + + assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE); +}); + test("passes through unrelated errors", () => { const message = classifyHeygenError({ stderr: Buffer.from("rate limit exceeded"), diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 727dc6a791..534daab547 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -31,6 +31,11 @@ import { versionLessThan, } from "./lib/heygen-cli.mjs"; +// resolve shells `fetch`/`freezeUrl` and modern ESM; 18 is the floor where those +// exist without flags. Named so the --doctor node check verifies something real +// (O2). Declared before the top-level `--doctor` branch that calls runDoctor(). +const MIN_NODE_VERSION = "18.0.0"; + const { values: args } = parseArgs({ options: { type: { type: "string", short: "t" }, @@ -112,6 +117,15 @@ if (args.candidates || args["dry-run"]) { if (args.doctor) { const doctor = runDoctor(); + const failed = doctor.checks.filter((check) => !check.ok); + // Non-PII: instrument the exact question the feature exists to answer — how + // often is --doctor run and which check fails most. Awaited so a short-lived + // run flushes before exit. + await track("media_use_doctor_run", { + ok: doctor.ok, + checks_failed: failed.length, + failed: failed.map((check) => check.name), + }); if (args.json) { console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks })); } else { @@ -730,6 +744,25 @@ async function showCandidates() { } } +function heygenAuthCheck() { + const authProbe = runCommand("heygen", ["auth", "status", "--json"]); + // spawnSync sets .error/.signal on a timeout or spawn failure (status then + // null). A stalled auth endpoint (transient network/DNS) must not be reported + // as an authoritative "not authenticated" with a re-login fix. + const timedOut = authProbe.error?.code === "ETIMEDOUT" || authProbe.signal != null; + const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null; + return { + name: "heygen authenticated", + ok: !!email, + detail: email + ? `heygen authenticated as ${email}` + : timedOut + ? "heygen auth status timed out — possible network issue, not proof of sign-out" + : "heygen not authenticated", + fix: email ? "" : timedOut ? "check network, then re-run --doctor" : HEYGEN_AUTH_COMMAND, + }; +} + function runDoctor() { const checks = []; const heygenVersionProbe = runCommand("heygen", ["--version"]); @@ -768,30 +801,19 @@ function runDoctor() { fix: versionOk ? "" : HEYGEN_UPDATE_COMMAND, }); - const authProbe = runCommand("heygen", ["auth", "status"]); - const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null; - checks.push({ - name: "heygen authenticated", - ok: !!email, - detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated", - fix: email ? "" : HEYGEN_AUTH_COMMAND, - }); + checks.push(heygenAuthCheck()); } else { + // Fail-open: heygen ran but printed no semver (dev/stripped build). We can't + // verify the version, so we don't block on it — but say so rather than a bare + // green check that implies a real version comparison happened. checks.push({ name: "heygen version", ok: true, - detail: "heygen version did not include semver", + detail: "heygen present; version unverifiable (no semver in --version output)", fix: "", }); - const authProbe = runCommand("heygen", ["auth", "status"]); - const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null; - checks.push({ - name: "heygen authenticated", - ok: !!email, - detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated", - fix: email ? "" : HEYGEN_AUTH_COMMAND, - }); + checks.push(heygenAuthCheck()); } const ffmpegProbe = runCommand("ffmpeg", ["-version"]); @@ -810,15 +832,20 @@ function runDoctor() { fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg", }); + const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION); checks.push({ name: "node version", - ok: true, - detail: process.version, - fix: "", + ok: nodeOk, + detail: `${process.version} (need >= v${MIN_NODE_VERSION})`, + fix: nodeOk ? "" : `upgrade Node to >= v${MIN_NODE_VERSION}`, }); + // ffmpeg AND ffprobe are both strictly required (see SKILL.md); the exit code + // must reflect that so a script gating on `--doctor` doesn't pass with ffprobe + // missing and then break at the first probe call. const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH"); - return { ok: !!ffmpeg?.ok, checks }; + const ffprobe = checks.find((check) => check.name === "ffprobe on PATH"); + return { ok: !!ffmpeg?.ok && !!ffprobe?.ok, checks }; } function printDoctor(checks) { @@ -853,18 +880,17 @@ function firstLine(text) { } function emailFromAuthStatus(text) { + // JSON only (auth status is queried with --json). No prose regex fallback: a + // human-format body like "Session expired. Contact support@heygen.ai" would + // otherwise report the user as authenticated as support@heygen.ai. const trimmed = String(text || "").trim(); - if (!trimmed) return null; - if (trimmed.startsWith("{")) { - try { - const parsed = JSON.parse(trimmed); - return parsed?.data?.email || parsed?.email || null; - } catch { - return null; - } + if (!trimmed.startsWith("{")) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed?.data?.email || parsed?.email || null; + } catch { + return null; } - const match = trimmed.match(/[^\s@]+@[^\s@]+\.[^\s@]+/); - return match ? match[0] : null; } async function reuseGlobal(shaArg) { diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs index 2d0a6fc581..770432792a 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/skills/media-use/scripts/resolve.test.mjs @@ -118,10 +118,6 @@ function normalizeWithCoreSource(grading) { ); } -function runResolveStatus(args) { - return spawnResolve(args); -} - const tests = []; function test(name, fn) { tests.push({ name, fn }); @@ -334,8 +330,8 @@ test("--json returns error JSON on stub provider failure", () => { cleanup(); }); -test("--doctor --json reports dependency checks and top-level ok follows ffmpeg only", () => { - const result = runResolveStatus(["--doctor", "--json"]); +test("--doctor --json reports dependency checks and top-level ok requires ffmpeg and ffprobe", () => { + const result = spawnResolve(["--doctor", "--json"]); assert.match(result.stdout.trim(), /^\{/); assert.equal(result.stderr, ""); assert.ok(result.status === 0 || result.status === 1); @@ -361,8 +357,10 @@ test("--doctor --json reports dependency checks and top-level ok follows ffmpeg } const ffmpeg = byName.get("ffmpeg on PATH"); - assert.equal(parsed.ok, ffmpeg.ok); - assert.equal(result.status, ffmpeg.ok ? 0 : 1); + const ffprobe = byName.get("ffprobe on PATH"); + const strictOk = ffmpeg.ok && ffprobe.ok; + assert.equal(parsed.ok, strictOk); + assert.equal(result.status, strictOk ? 0 : 1); }); test("one-line output format matches contract", () => { From 64c760949338905b37ba03a861c36c95b8aeb83f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 15:35:14 -0400 Subject: [PATCH 3/5] fix(media-use): require OAuth-capable heygen CLI (v0.3.0), fix auth-status probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E against the live free-usage backend surfaced three issues: - HEYGEN_MIN_VERSION was 0.1.6, but that CLI can't use OAuth ("heygen-cli can't use OAuth yet") — free usage needs >= v0.3.0. Bumped the floor; --doctor now also nudges `heygen update` when a newer stable exists (always-latest). - Onboarding pointed at `heygen auth login --key` (API credits / billing); the free path is `--oauth` (subscription/free credits). Fixed install + auth guidance and SKILL.md accordingly. - `heygen auth status --json` is an unknown flag on v0.3.0 (JSON is the default output) — the added --json broke auth detection. Dropped it; verified --doctor reports authenticated on a real free (OAuth) account. Tests assert against the exported message constants instead of brittle literals. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --- skills-manifest.json | 2 +- skills/media-use/SKILL.md | 25 ++++++++-------- skills/media-use/scripts/lib/heygen-cli.mjs | 11 +++++-- .../media-use/scripts/lib/heygen-cli.test.mjs | 26 +++++++--------- .../media-use/scripts/lib/heygen-search.mjs | 2 +- .../media-use/scripts/lib/voice-provider.mjs | 2 +- skills/media-use/scripts/resolve.mjs | 30 ++++++++++++++++--- 7 files changed, 61 insertions(+), 37 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index cc1049ef2f..22b79d9e7d 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "e7d884dd01ef0a3e", + "hash": "17767103b7d13a8b", "files": 116 }, "motion-graphics": { diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index 501a7a4dd5..c511e21e40 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -11,10 +11,11 @@ The media OS for HyperFrames: resolve · generate · operate · remember, every ```bash curl -fsSL https://static.heygen.ai/cli/install.sh | bash -heygen auth login --key +heygen update # free usage needs the OAuth-capable CLI (v0.3.0+) +heygen auth login --oauth # OAuth = free subscription credits; --api-key bills API credits ``` -This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Before resolving anything, verify setup with: +This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). Before resolving anything, verify setup with: ```bash node /scripts/resolve.mjs --doctor @@ -357,15 +358,15 @@ tools are OPT-IN alternatives where they exist; install one to unlock its free, private, on-device path instead of or ahead of HeyGen for that type. Only `ffmpeg`/`ffprobe` are strictly required for the tool to run at all. -| Tool | Serves | Install | -| ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) | -| `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | `curl -fsSL https://static.heygen.ai/cli/install.sh \| bash` then `heygen auth login --key ` (needs >= v0.1.6) | -| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` | -| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) | -| `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` | -| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` | -| `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | bundled with the hyperframes CLI | +| Tool | Serves | Install | +| ------------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) | +| `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | `curl -fsSL https://static.heygen.ai/cli/install.sh \| bash` then `heygen auth login --oauth` (needs >= v0.3.0) | +| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` | +| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) | +| `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` | +| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` | +| `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | bundled with the hyperframes CLI | The RAM-graded local-model shortlist + exact per-tier install/invoke lives in `scripts/lib/local-models.mjs` (the agent can read `describeModelLadder(cap, specs)` @@ -375,7 +376,7 @@ provider exists (e.g. no `mflux` -> codex image upsell; no `parakeet-mlx` -> whi `heygen asset search` is a pre-launch command hidden from `heygen --help`, but it runs; providers tag requests with the allowlisted `X-HeyGen-Client-Source` header -(v0.1.6+). +(v0.3.0+). ## Telemetry diff --git a/skills/media-use/scripts/lib/heygen-cli.mjs b/skills/media-use/scripts/lib/heygen-cli.mjs index f2faaa2ac7..e3db8ceb2b 100644 --- a/skills/media-use/scripts/lib/heygen-cli.mjs +++ b/skills/media-use/scripts/lib/heygen-cli.mjs @@ -1,7 +1,12 @@ -export const HEYGEN_MIN_VERSION = "0.1.6"; +// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it +// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path +// needs — so anything below this can't authenticate for free usage at all. +export const HEYGEN_MIN_VERSION = "0.3.0"; +// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key` +// bills API credits, so the onboarding steers to OAuth. export const HEYGEN_INSTALL_COMMAND = - "curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --key "; -export const HEYGEN_AUTH_COMMAND = "heygen auth login --key "; + "curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth"; +export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth"; export const HEYGEN_UPDATE_COMMAND = "heygen update"; export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: ${HEYGEN_INSTALL_COMMAND}`; diff --git a/skills/media-use/scripts/lib/heygen-cli.test.mjs b/skills/media-use/scripts/lib/heygen-cli.test.mjs index ab641b19ae..c58acd6d8e 100644 --- a/skills/media-use/scripts/lib/heygen-cli.test.mjs +++ b/skills/media-use/scripts/lib/heygen-cli.test.mjs @@ -1,36 +1,32 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; -import { classifyHeygenError, HEYGEN_NOT_FOUND_MESSAGE } from "./heygen-cli.mjs"; +import { + classifyHeygenError, + HEYGEN_NOT_AUTHENTICATED_MESSAGE, + HEYGEN_NOT_FOUND_MESSAGE, + HEYGEN_OUTDATED_MESSAGE, +} from "./heygen-cli.mjs"; test("classifies ENOENT-style missing heygen errors with install instructions", () => { const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" }); - assert.equal( - message, - "media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --key ", - ); + assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE); }); test("classifies auth failures with login instructions", () => { const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") }); - assert.equal( - message, - "media-use: heygen CLI not authenticated (free usage) — run: heygen auth login --key ", - ); + assert.equal(message, HEYGEN_NOT_AUTHENTICATED_MESSAGE); }); test("classifies a real 401 as auth, but not a bare 401 substring in prose", () => { assert.equal( classifyHeygenError({ stderr: Buffer.from("HTTP 401 Unauthorized") }), - "media-use: heygen CLI not authenticated (free usage) — run: heygen auth login --key ", + HEYGEN_NOT_AUTHENTICATED_MESSAGE, ); // A request id that merely contains "401" must NOT read as an auth failure. const noise = classifyHeygenError({ stderr: Buffer.from("upload failed (request req-401abc)") }); - assert.notEqual( - noise, - "media-use: heygen CLI not authenticated (free usage) — run: heygen auth login --key ", - ); + assert.notEqual(noise, HEYGEN_NOT_AUTHENTICATED_MESSAGE); }); test("classifies old heygen versions with update instructions", () => { @@ -38,7 +34,7 @@ test("classifies old heygen versions with update instructions", () => { stderr: Buffer.from("heygen v0.1.5 does not support --headers"), }); - assert.equal(message, "media-use: heygen CLI is outdated — run: heygen update (need >= v0.1.6)"); + assert.equal(message, HEYGEN_OUTDATED_MESSAGE); }); test("does not misclassify a resource 'not found' error as a missing CLI", () => { diff --git a/skills/media-use/scripts/lib/heygen-search.mjs b/skills/media-use/scripts/lib/heygen-search.mjs index 943ef7a29c..097aded62b 100644 --- a/skills/media-use/scripts/lib/heygen-search.mjs +++ b/skills/media-use/scripts/lib/heygen-search.mjs @@ -5,7 +5,7 @@ export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = // execFileSync with an argv array (no shell), so query/type/etc. are passed as // literal arguments — no quoting tricks, no command injection. subcommand is a // hardcoded multi-word string (e.g. "audio sounds list"), split into tokens. - // Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.1.6). + // Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0). const args = [ "--headers", "X-HeyGen-Client-Source: media-use", diff --git a/skills/media-use/scripts/lib/voice-provider.mjs b/skills/media-use/scripts/lib/voice-provider.mjs index 11867bb063..1bcc7f0272 100644 --- a/skills/media-use/scripts/lib/voice-provider.mjs +++ b/skills/media-use/scripts/lib/voice-provider.mjs @@ -3,7 +3,7 @@ import { reportHeygenFailure } from "./heygen-cli.mjs"; // Voice / TTS generation via the HeyGen CLI — the only external CLI media-use // shells (CLI-only invariant: media-use holds no keys; the CLI owns auth). -// Flags verified against `heygen voice speech create --help` (v0.1.6). +// Flags verified against `heygen voice speech create --help` (v0.3.0). function runJson(bin, argv, label) { let out; diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 534daab547..557de55691 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -744,8 +744,23 @@ async function showCandidates() { } } +// Best-effort latest stable CLI tag from the CDN (the install script's source of +// truth). null on any failure (offline, no curl) — treated as "unknown", never fatal. +function latestHeygenStable() { + const probe = runCommand("curl", [ + "-fsSL", + "--max-time", + "4", + "https://static.heygen.ai/cli/stable", + ]); + return probe.status === 0 ? firstSemver(commandText(probe)) : null; +} + function heygenAuthCheck() { - const authProbe = runCommand("heygen", ["auth", "status", "--json"]); + // `heygen auth status` already emits JSON by default (only `--human` opts out + // to a table) — there is no `--json`/`--output` flag; passing one errors with + // "unknown flag". emailFromAuthStatus parses that default JSON. + const authProbe = runCommand("heygen", ["auth", "status"]); // spawnSync sets .error/.signal on a timeout or spawn failure (status then // null). A stalled auth endpoint (transient network/DNS) must not be reported // as an authoritative "not authenticated" with a re-login fix. @@ -794,11 +809,18 @@ function runDoctor() { }); } else if (heygenVersion) { const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION); + // Keep it latest: even when the installed version clears the floor, nudge + // `heygen update` if a newer stable exists. Best-effort — silently skipped + // when the CDN is unreachable, so it never blocks the check. + const latest = versionOk ? latestHeygenStable() : null; + const behind = latest && versionLessThan(heygenVersion, latest); checks.push({ name: "heygen version", ok: versionOk, - detail: `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`, - fix: versionOk ? "" : HEYGEN_UPDATE_COMMAND, + detail: versionOk + ? `heygen v${heygenVersion}${behind ? ` (latest v${latest} available)` : ""}` + : `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`, + fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND, }); checks.push(heygenAuthCheck()); @@ -880,7 +902,7 @@ function firstLine(text) { } function emailFromAuthStatus(text) { - // JSON only (auth status is queried with --json). No prose regex fallback: a + // JSON only (auth status emits JSON by default). No prose regex fallback: a // human-format body like "Session expired. Contact support@heygen.ai" would // otherwise report the user as authenticated as support@heygen.ai. const trimmed = String(text || "").trim(); From b6836c7006058686964453d33bf6c19a9aafe095 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 17:55:55 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(media-use):=20address=20#2065=20review?= =?UTF-8?q?=20nits=20=E2=80=94=20one=20root=20cause=20on=20old=20CLI,=20fl?= =?UTF-8?q?oor=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --doctor skips the auth check when the version check fails (below v0.3.0): an old CLI's auth probe fails for the same root cause, so users no longer see two errors ("outdated" + "not authenticated") — one root cause, one fix. - Comment links the auth-status probe's JSON-default assumption to HEYGEN_MIN_VERSION >= 0.3.0 so the floor isn't silently lowered later. - SKILL.md states the uniform v0.3.0 requirement (nudged even for API-key use). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --- skills-manifest.json | 2 +- skills/media-use/SKILL.md | 2 +- skills/media-use/scripts/resolve.mjs | 18 +++++++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 22b79d9e7d..453a1d767b 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "17767103b7d13a8b", + "hash": "f27d2c3799d92d77", "files": 116 }, "motion-graphics": { diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index c511e21e40..c00040dcfe 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -15,7 +15,7 @@ heygen update # free usage needs the OAuth-capable CLI (v0.3.0+) heygen auth login --oauth # OAuth = free subscription credits; --api-key bills API credits ``` -This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). Before resolving anything, verify setup with: +This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with: ```bash node /scripts/resolve.mjs --doctor diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 557de55691..376029d932 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -760,6 +760,9 @@ function heygenAuthCheck() { // `heygen auth status` already emits JSON by default (only `--human` opts out // to a table) — there is no `--json`/`--output` flag; passing one errors with // "unknown flag". emailFromAuthStatus parses that default JSON. + // NOTE: JSON-by-default is a v0.3.0 behavior — this probe assumes it, which + // HEYGEN_MIN_VERSION >= 0.3.0 (+ the version gate above) guarantees. If that + // floor is ever lowered, auth detection on an older CLI would silently break. const authProbe = runCommand("heygen", ["auth", "status"]); // spawnSync sets .error/.signal on a timeout or spawn failure (status then // null). A stalled auth endpoint (transient network/DNS) must not be reported @@ -823,7 +826,20 @@ function runDoctor() { fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND, }); - checks.push(heygenAuthCheck()); + // Below the OAuth-capable floor the auth probe fails for the SAME root cause + // (an old CLI can't OAuth and doesn't emit JSON auth status), which would + // read as a confusing second "not authenticated" error. Skip it — one root + // cause, one fix. + checks.push( + versionOk + ? heygenAuthCheck() + : { + name: "heygen authenticated", + ok: false, + detail: "skipped — update heygen first", + fix: HEYGEN_UPDATE_COMMAND, + }, + ); } else { // Fail-open: heygen ran but printed no semver (dev/stripped build). We can't // verify the version, so we don't block on it — but say so rather than a bare From ad9f8b219dd87738d9093e19239299e5d06328d9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 18:11:06 -0400 Subject: [PATCH 5/5] fix(media-use): doctor prints one heygen row per fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'heygen on PATH' and 'heygen version' checks both rendered their detail as `heygen v0.3.0`, so --doctor printed two byte-identical green lines. Make the PATH row report presence ("heygen found on PATH") and let the version row own the version string — one row per fact, no duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --- skills-manifest.json | 2 +- skills/media-use/scripts/resolve.mjs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 453a1d767b..d430d372e7 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "f27d2c3799d92d77", + "hash": "14ea99a7f4d750cd", "files": 116 }, "motion-graphics": { diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 376029d932..3bf43d42af 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -791,9 +791,9 @@ function runDoctor() { checks.push({ name: "heygen on PATH", ok: heygenOnPath, - detail: heygenOnPath - ? `heygen ${heygenVersion ? `v${heygenVersion}` : "found"}` - : "heygen not found", + // Just "is the binary here" — the version row below owns the version string, + // so this row must not also render `heygen v0.3.0` (two byte-identical lines). + detail: heygenOnPath ? "heygen found on PATH" : "heygen not found", fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND, });