Skip to content

Commit 8465bb5

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 89b908e commit 8465bb5

8 files changed

Lines changed: 400 additions & 59 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": "785f83e0431671e8",
50-
"files": 115
49+
"hash": "26e552b366e86195",
50+
"files": 117
5151
},
5252
"motion-graphics": {
5353
"hash": "96ed2f7d8051b009",

skills/media-use/SKILL.md

Lines changed: 71 additions & 52 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, writeFileSync, rmSync } from "node:fs";
45
import { resolve, join, extname, basename } from "node:path";
56
import { parseArgs } from "node:util";
@@ -17,6 +18,14 @@ import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs";
1718
import { validateCubeFile } from "./lib/cube-validate.mjs";
1819
import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs";
1920
import { freezeLibraryLut, matchColorLook } from "./lib/lut-preset-provider.mjs";
21+
import {
22+
HEYGEN_AUTH_COMMAND,
23+
HEYGEN_INSTALL_COMMAND,
24+
HEYGEN_MIN_VERSION,
25+
HEYGEN_UPDATE_COMMAND,
26+
firstSemver,
27+
versionLessThan,
28+
} from "./lib/heygen-cli.mjs";
2029

2130
const { values: args } = parseArgs({
2231
options: {
@@ -26,6 +35,7 @@ const { values: args } = parseArgs({
2635
project: { type: "string", short: "p", default: "." },
2736
adopt: { type: "boolean", default: false },
2837
candidates: { type: "boolean", default: false },
38+
doctor: { type: "boolean", default: false },
2939
"dry-run": { type: "boolean", default: false },
3040
reuse: { type: "string" },
3141
from: { type: "string" },
@@ -55,6 +65,7 @@ Options:
5565
--adopt Adopt all existing assets/ files into the manifest
5666
--candidates List reusable assets (project + global cache) for --type; no
5767
download, no mutation. Read them and decide reuse yourself.
68+
--doctor Check local CLI dependencies; no manifest changes.
5869
--reuse <sha> Import a specific global-cache asset (by content sha/prefix,
5970
from --candidates) into this project
6071
--from <file> Freeze a local file or direct public URL (ingest)
@@ -95,6 +106,16 @@ if (args.candidates || args["dry-run"]) {
95106
process.exit(0);
96107
}
97108

109+
if (args.doctor) {
110+
const doctor = runDoctor();
111+
if (args.json) {
112+
console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks }));
113+
} else {
114+
printDoctor(doctor.checks);
115+
}
116+
process.exit(doctor.ok ? 0 : 1);
117+
}
118+
98119
// Reuse: import a specific global-cache asset (by content sha/prefix, taken
99120
// from --candidates) into this project. `!== undefined` so an empty --reuse ""
100121
// still routes here (and gets a clear empty-sha error) instead of falling
@@ -679,6 +700,143 @@ async function showCandidates() {
679700
}
680701
}
681702

703+
function runDoctor() {
704+
const checks = [];
705+
const heygenVersionProbe = runCommand("heygen", ["--version"]);
706+
const heygenOnPath = heygenVersionProbe.status === 0;
707+
const heygenVersionText = commandText(heygenVersionProbe);
708+
const heygenVersion = firstSemver(heygenVersionText);
709+
710+
checks.push({
711+
name: "heygen on PATH",
712+
ok: heygenOnPath,
713+
detail: heygenOnPath
714+
? `heygen ${heygenVersion ? `v${heygenVersion}` : "found"}`
715+
: "heygen not found",
716+
fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND,
717+
});
718+
719+
if (!heygenOnPath) {
720+
checks.push({
721+
name: "heygen version",
722+
ok: false,
723+
detail: "heygen version unavailable",
724+
fix: HEYGEN_INSTALL_COMMAND,
725+
});
726+
checks.push({
727+
name: "heygen authenticated",
728+
ok: false,
729+
detail: "heygen auth status unavailable",
730+
fix: HEYGEN_INSTALL_COMMAND,
731+
});
732+
} else if (heygenVersion) {
733+
const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION);
734+
checks.push({
735+
name: "heygen version",
736+
ok: versionOk,
737+
detail: `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`,
738+
fix: versionOk ? "" : HEYGEN_UPDATE_COMMAND,
739+
});
740+
741+
const authProbe = runCommand("heygen", ["auth", "status"]);
742+
const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null;
743+
checks.push({
744+
name: "heygen authenticated",
745+
ok: !!email,
746+
detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated",
747+
fix: email ? "" : HEYGEN_AUTH_COMMAND,
748+
});
749+
} else {
750+
checks.push({
751+
name: "heygen version",
752+
ok: true,
753+
detail: "heygen version did not include semver",
754+
fix: "",
755+
});
756+
757+
const authProbe = runCommand("heygen", ["auth", "status"]);
758+
const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null;
759+
checks.push({
760+
name: "heygen authenticated",
761+
ok: !!email,
762+
detail: email ? `heygen authenticated as ${email}` : "heygen not authenticated",
763+
fix: email ? "" : HEYGEN_AUTH_COMMAND,
764+
});
765+
}
766+
767+
const ffmpegProbe = runCommand("ffmpeg", ["-version"]);
768+
checks.push({
769+
name: "ffmpeg on PATH",
770+
ok: ffmpegProbe.status === 0,
771+
detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found",
772+
fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg",
773+
});
774+
775+
const ffprobeProbe = runCommand("ffprobe", ["-version"]);
776+
checks.push({
777+
name: "ffprobe on PATH",
778+
ok: ffprobeProbe.status === 0,
779+
detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found",
780+
fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg",
781+
});
782+
783+
checks.push({
784+
name: "node version",
785+
ok: true,
786+
detail: process.version,
787+
fix: "",
788+
});
789+
790+
const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH");
791+
return { ok: !!ffmpeg?.ok, checks };
792+
}
793+
794+
function printDoctor(checks) {
795+
const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]);
796+
for (const check of checks) {
797+
const prefix = check.ok ? "✓" : "✗";
798+
const freePath = heygenChecks.has(check.name)
799+
? " — free-usage path: bgm/image/voice/avatar-video"
800+
: "";
801+
const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`;
802+
console.log(`${prefix} ${check.detail}${freePath}${fix}`);
803+
}
804+
}
805+
806+
function runCommand(bin, argv) {
807+
return spawnSync(bin, argv, {
808+
encoding: "utf8",
809+
timeout: 15000,
810+
});
811+
}
812+
813+
function commandText(result) {
814+
return [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
815+
}
816+
817+
function firstLine(text) {
818+
return (
819+
String(text || "")
820+
.trim()
821+
.split(/\r?\n/)[0] || ""
822+
);
823+
}
824+
825+
function emailFromAuthStatus(text) {
826+
const trimmed = String(text || "").trim();
827+
if (!trimmed) return null;
828+
if (trimmed.startsWith("{")) {
829+
try {
830+
const parsed = JSON.parse(trimmed);
831+
return parsed?.data?.email || parsed?.email || null;
832+
} catch {
833+
return null;
834+
}
835+
}
836+
const match = trimmed.match(/[^\s@]+@[^\s@]+\.[^\s@]+/);
837+
return match ? match[0] : null;
838+
}
839+
682840
async function reuseGlobal(shaArg) {
683841
const projectDir = resolve(args.project);
684842
const type = args.type;

0 commit comments

Comments
 (0)