feat(media-use): fast heygen CLI onboarding — actionable diagnostics, --doctor, free-usage framing (#2065)

* feat(media-use): fast heygen CLI onboarding — actionable diagnostics, --doctor, free-usage framing

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(media-use): address #2065 review — classifier blocker, doctor contract, telemetry

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(media-use): require OAuth-capable heygen CLI (v0.3.0), fix auth-status probe

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(media-use): address #2065 review nits — one root cause on old CLI, floor policy

- --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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(media-use): doctor prints one heygen row per fact

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-07-09 18:30:53 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 3b93f516b4
commit cdb8d736f1
8 changed files with 500 additions and 62 deletions
+71 -53
View File
@@ -7,24 +7,38 @@ 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 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). **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 <SKILL_DIR>/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 +83,7 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
| `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 +134,7 @@ node <SKILL_DIR>/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 +240,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.
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 (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 |
| 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 +335,7 @@ node <SKILL_DIR>/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 <SKILL_DIR>/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,30 +351,32 @@ 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.
`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 + 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 |
| 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)`
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
(v0.1.6+).
(v0.3.0+).
## Telemetry
+100
View File
@@ -0,0 +1,100 @@
// 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 --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}`;
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();
// 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") ||
// \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") ||
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;
}
@@ -0,0 +1,66 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
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, HEYGEN_NOT_FOUND_MESSAGE);
});
test("classifies auth failures with login instructions", () => {
const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") });
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") }),
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, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
});
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, HEYGEN_OUTDATED_MESSAGE);
});
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"),
message: "Command failed",
});
assert.equal(message, "rate limit exceeded");
});
@@ -1,10 +1,11 @@
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
// 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",
@@ -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;
}
@@ -1,8 +1,9 @@
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).
// 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;
@@ -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 {
+222
View File
@@ -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,19 @@ 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";
// 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: {
@@ -30,6 +44,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 +74,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 <sha> Import a specific global-cache asset (by content sha/prefix,
from --candidates) into this project
--from <file> Freeze a local file or direct public URL (ingest)
@@ -99,6 +115,25 @@ if (args.candidates || args["dry-run"]) {
process.exit(0);
}
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 {
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 +744,193 @@ 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() {
// `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
// 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"]);
const heygenOnPath = heygenVersionProbe.status === 0;
const heygenVersionText = commandText(heygenVersionProbe);
const heygenVersion = firstSemver(heygenVersionText);
checks.push({
name: "heygen on PATH",
ok: heygenOnPath,
// 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,
});
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);
// 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: 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,
});
// 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
// green check that implies a real version comparison happened.
checks.push({
name: "heygen version",
ok: true,
detail: "heygen present; version unverifiable (no semver in --version output)",
fix: "",
});
checks.push(heygenAuthCheck());
}
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",
});
const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION);
checks.push({
name: "node version",
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");
const ffprobe = checks.find((check) => check.name === "ffprobe on PATH");
return { ok: !!ffmpeg?.ok && !!ffprobe?.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) {
// 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();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed);
return parsed?.data?.email || parsed?.email || null;
} catch {
return null;
}
}
async function reuseGlobal(shaArg) {
const projectDir = resolve(args.project);
const type = args.type;
+33
View File
@@ -330,6 +330,39 @@ test("--json returns error JSON on stub provider failure", () => {
cleanup();
});
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);
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");
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", () => {
setup();
const record = makeRecord({ provenance: { prompt: "format test", provider: "test" } });