Skip to content

Commit 5fa2c12

Browse files
miguel-heygenclaude
andcommitted
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
1 parent e52bfc2 commit 5fa2c12

8 files changed

Lines changed: 402 additions & 58 deletions

File tree

skills-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
"files": 10
4747
},
4848
"media-use": {
49-
"hash": "22d8da57c4af6ddd",
50-
"files": 103
49+
"hash": "009541ac5c6b9e7b",
50+
"files": 105
5151
},
5252
"motion-graphics": {
5353
"hash": "7452272d8da8934d",

skills/media-use/SKILL.md

Lines changed: 69 additions & 50 deletions
Large diffs are not rendered by default.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
export const HEYGEN_MIN_VERSION = "0.1.6";
2+
export const HEYGEN_INSTALL_COMMAND =
3+
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash then heygen auth login --key <key>";
4+
export const HEYGEN_AUTH_COMMAND = "heygen auth login --key <key>";
5+
export const HEYGEN_UPDATE_COMMAND = "heygen update";
6+
7+
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}`;
8+
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
9+
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
10+
11+
const ACTIONABLE_MESSAGES = new Set([
12+
HEYGEN_NOT_FOUND_MESSAGE,
13+
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
14+
HEYGEN_OUTDATED_MESSAGE,
15+
]);
16+
17+
export function classifyHeygenError(err) {
18+
const detail = heygenErrorDetail(err);
19+
const text = [err?.stderr, err?.stdout, err?.message, detail]
20+
.map((value) => textOf(value))
21+
.filter(Boolean)
22+
.join("\n");
23+
const lower = text.toLowerCase();
24+
25+
if (
26+
err?.code === "ENOENT" ||
27+
lower.includes("command not found") ||
28+
lower.includes("not found")
29+
) {
30+
return HEYGEN_NOT_FOUND_MESSAGE;
31+
}
32+
33+
if (
34+
lower.includes("unauthorized") ||
35+
lower.includes("unauthenticated") ||
36+
lower.includes("401") ||
37+
lower.includes("not logged in") ||
38+
lower.includes("no api key") ||
39+
lower.includes("missing api key") ||
40+
lower.includes("invalid api key") ||
41+
lower.includes("login required") ||
42+
lower.includes("auth required") ||
43+
lower.includes("authentication required")
44+
) {
45+
return HEYGEN_NOT_AUTHENTICATED_MESSAGE;
46+
}
47+
48+
const version = firstSemver(text);
49+
if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) {
50+
return HEYGEN_OUTDATED_MESSAGE;
51+
}
52+
53+
return detail;
54+
}
55+
56+
export function reportHeygenFailure(err, context) {
57+
const message = classifyHeygenError(err);
58+
if (ACTIONABLE_MESSAGES.has(message)) {
59+
console.error(message);
60+
} else {
61+
console.error(`media-use: \`${context}\` failed: ${message}`);
62+
}
63+
}
64+
65+
export function firstSemver(text) {
66+
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
67+
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
68+
}
69+
70+
export function versionLessThan(version, minimum) {
71+
const left = versionParts(version);
72+
const right = versionParts(minimum);
73+
if (!left || !right) return false;
74+
for (let i = 0; i < 3; i++) {
75+
if (left[i] < right[i]) return true;
76+
if (left[i] > right[i]) return false;
77+
}
78+
return false;
79+
}
80+
81+
function heygenErrorDetail(err) {
82+
return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err);
83+
}
84+
85+
function textOf(value) {
86+
return value == null ? "" : String(value).trim();
87+
}
88+
89+
function versionParts(version) {
90+
const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/);
91+
return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null;
92+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { strict as assert } from "node:assert";
2+
import { test } from "node:test";
3+
import { classifyHeygenError } from "./heygen-cli.mjs";
4+
5+
test("classifies ENOENT-style missing heygen errors with install instructions", () => {
6+
const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" });
7+
8+
assert.equal(
9+
message,
10+
"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 <key>",
11+
);
12+
});
13+
14+
test("classifies auth failures with login instructions", () => {
15+
const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") });
16+
17+
assert.equal(
18+
message,
19+
"media-use: heygen CLI not authenticated (free usage) — run: heygen auth login --key <key>",
20+
);
21+
});
22+
23+
test("classifies old heygen versions with update instructions", () => {
24+
const message = classifyHeygenError({
25+
stderr: Buffer.from("heygen v0.1.5 does not support --headers"),
26+
});
27+
28+
assert.equal(message, "media-use: heygen CLI is outdated — run: heygen update (need >= v0.1.6)");
29+
});
30+
31+
test("passes through unrelated errors", () => {
32+
const message = classifyHeygenError({
33+
stderr: Buffer.from("rate limit exceeded"),
34+
message: "Command failed",
35+
});
36+
37+
assert.equal(message, "rate limit exceeded");
38+
});

skills/media-use/scripts/lib/heygen-search.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execFileSync } from "node:child_process";
2+
import { reportHeygenFailure } from "./heygen-cli.mjs";
23

34
export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) {
45
// 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 } =
2829
} catch (err) {
2930
// Don't swallow a broken command / auth failure as "no results" — that turns
3031
// a typo or expired key into a silent dead end. Surface it, then give up.
31-
const detail = err.stderr?.toString().trim() || err.stdout?.toString().trim() || err.message;
32-
console.error(`media-use: \`heygen ${subcommand}\` failed: ${detail}`);
32+
reportHeygenFailure(err, `heygen ${subcommand}`);
3333
return null;
3434
}
3535

skills/media-use/scripts/lib/voice-provider.mjs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execFileSync } from "node:child_process";
2+
import { reportHeygenFailure } from "./heygen-cli.mjs";
23

34
// Voice / TTS generation via the HeyGen CLI — the only external CLI media-use
45
// shells (CLI-only invariant: media-use holds no keys; the CLI owns auth).
@@ -13,9 +14,7 @@ function runJson(bin, argv, label) {
1314
stdio: ["pipe", "pipe", "pipe"],
1415
});
1516
} catch (err) {
16-
console.error(
17-
`media-use: \`${bin}\` ${label} failed: ${err.stderr?.toString().trim() || err.message}`,
18-
);
17+
reportHeygenFailure(err, `${bin} ${label}`);
1918
return null;
2019
}
2120
try {

skills/media-use/scripts/resolve.mjs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env node
22

3+
import { spawnSync } from "node:child_process";
34
import { existsSync, statSync } from "node:fs";
45
import { resolve, join, extname, basename } from "node:path";
56
import { parseArgs } from "node:util";
@@ -13,6 +14,14 @@ import { track } from "./lib/telemetry.mjs";
1314
import { typesMatch } from "./lib/match.mjs";
1415
import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs";
1516
import { findGlobalBySha } from "./lib/cache.mjs";
17+
import {
18+
HEYGEN_AUTH_COMMAND,
19+
HEYGEN_INSTALL_COMMAND,
20+
HEYGEN_MIN_VERSION,
21+
HEYGEN_UPDATE_COMMAND,
22+
firstSemver,
23+
versionLessThan,
24+
} from "./lib/heygen-cli.mjs";
1625

1726
const { values: args } = parseArgs({
1827
options: {
@@ -22,6 +31,7 @@ const { values: args } = parseArgs({
2231
project: { type: "string", short: "p", default: "." },
2332
adopt: { type: "boolean", default: false },
2433
candidates: { type: "boolean", default: false },
34+
doctor: { type: "boolean", default: false },
2535
"dry-run": { type: "boolean", default: false },
2636
reuse: { type: "string" },
2737
from: { type: "string" },
@@ -49,6 +59,7 @@ Options:
4959
--adopt Adopt all existing assets/ files into the manifest
5060
--candidates List reusable assets (project + global cache) for --type; no
5161
download, no mutation. Read them and decide reuse yourself.
62+
--doctor Check local CLI dependencies; no manifest changes.
5263
--reuse <sha> Import a specific global-cache asset (by content sha/prefix,
5364
from --candidates) into this project
5465
--provider Force one generator (e.g. codex, mflux, kokoro, heygen)
@@ -80,6 +91,16 @@ if (args.candidates || args["dry-run"]) {
8091
process.exit(0);
8192
}
8293

94+
if (args.doctor) {
95+
const doctor = runDoctor();
96+
if (args.json) {
97+
console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks }));
98+
} else {
99+
printDoctor(doctor.checks);
100+
}
101+
process.exit(doctor.ok ? 0 : 1);
102+
}
103+
83104
// Reuse: import a specific global-cache asset (by content sha/prefix, taken
84105
// from --candidates) into this project. `!== undefined` so an empty --reuse ""
85106
// still routes here (and gets a clear empty-sha error) instead of falling
@@ -377,6 +398,143 @@ async function showCandidates() {
377398
}
378399
}
379400

401+
function runDoctor() {
402+
const checks = [];
403+
const heygenVersionProbe = runCommand("heygen", ["--version"]);
404+
const heygenOnPath = heygenVersionProbe.status === 0;
405+
const heygenVersionText = commandText(heygenVersionProbe);
406+
const heygenVersion = firstSemver(heygenVersionText);
407+
408+
checks.push({
409+
name: "heygen on PATH",
410+
ok: heygenOnPath,
411+
detail: heygenOnPath
412+
? `heygen ${heygenVersion ? `v${heygenVersion}` : "found"}`
413+
: "heygen not found",
414+
fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND,
415+
});
416+
417+
if (!heygenOnPath) {
418+
checks.push({
419+
name: "heygen version",
420+
ok: false,
421+
detail: "heygen version unavailable",
422+
fix: HEYGEN_INSTALL_COMMAND,
423+
});
424+
checks.push({
425+
name: "heygen authenticated",
426+
ok: false,
427+
detail: "heygen auth status unavailable",
428+
fix: HEYGEN_INSTALL_COMMAND,
429+
});
430+
} else if (heygenVersion) {
431+
const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION);
432+
checks.push({
433+
name: "heygen version",
434+
ok: versionOk,
435+
detail: `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`,
436+
fix: versionOk ? "" : HEYGEN_UPDATE_COMMAND,
437+
});
438+
439+
const authProbe = runCommand("heygen", ["auth", "status"]);
440+
const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null;
441+
checks.push({
442+
name: "heygen authenticated",
443+
ok: !!email,
444+
detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated",
445+
fix: email ? "" : HEYGEN_AUTH_COMMAND,
446+
});
447+
} else {
448+
checks.push({
449+
name: "heygen version",
450+
ok: true,
451+
detail: "heygen version did not include semver",
452+
fix: "",
453+
});
454+
455+
const authProbe = runCommand("heygen", ["auth", "status"]);
456+
const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null;
457+
checks.push({
458+
name: "heygen authenticated",
459+
ok: !!email,
460+
detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated",
461+
fix: email ? "" : HEYGEN_AUTH_COMMAND,
462+
});
463+
}
464+
465+
const ffmpegProbe = runCommand("ffmpeg", ["-version"]);
466+
checks.push({
467+
name: "ffmpeg on PATH",
468+
ok: ffmpegProbe.status === 0,
469+
detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found",
470+
fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg",
471+
});
472+
473+
const ffprobeProbe = runCommand("ffprobe", ["-version"]);
474+
checks.push({
475+
name: "ffprobe on PATH",
476+
ok: ffprobeProbe.status === 0,
477+
detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found",
478+
fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg",
479+
});
480+
481+
checks.push({
482+
name: "node version",
483+
ok: true,
484+
detail: process.version,
485+
fix: "",
486+
});
487+
488+
const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH");
489+
return { ok: !!ffmpeg?.ok, checks };
490+
}
491+
492+
function printDoctor(checks) {
493+
const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]);
494+
for (const check of checks) {
495+
const prefix = check.ok ? "✓" : "✗";
496+
const freePath = heygenChecks.has(check.name)
497+
? " — free-usage path: bgm/image/voice/avatar-video"
498+
: "";
499+
const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`;
500+
console.log(`${prefix} ${check.detail}${freePath}${fix}`);
501+
}
502+
}
503+
504+
function runCommand(bin, argv) {
505+
return spawnSync(bin, argv, {
506+
encoding: "utf8",
507+
timeout: 15000,
508+
});
509+
}
510+
511+
function commandText(result) {
512+
return [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
513+
}
514+
515+
function firstLine(text) {
516+
return (
517+
String(text || "")
518+
.trim()
519+
.split(/\r?\n/)[0] || ""
520+
);
521+
}
522+
523+
function emailFromAuthStatus(text) {
524+
const trimmed = String(text || "").trim();
525+
if (!trimmed) return null;
526+
if (trimmed.startsWith("{")) {
527+
try {
528+
const parsed = JSON.parse(trimmed);
529+
return parsed?.data?.email || parsed?.email || null;
530+
} catch {
531+
return null;
532+
}
533+
}
534+
const match = trimmed.match(/[^\s@]+@[^\s@]+\.[^\s@]+/);
535+
return match ? match[0] : null;
536+
}
537+
380538
async function reuseGlobal(shaArg) {
381539
const projectDir = resolve(args.project);
382540
const type = args.type;

0 commit comments

Comments
 (0)