Skip to content

Commit cdb8d73

Browse files
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>
1 parent 3b93f51 commit cdb8d73

8 files changed

Lines changed: 502 additions & 64 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": "22b54ebaa6f93b5e",
50-
"files": 114
49+
"hash": "14ea99a7f4d750cd",
50+
"files": 116
5151
},
5252
"motion-graphics": {
5353
"hash": "96ed2f7d8051b009",

skills/media-use/SKILL.md

Lines changed: 73 additions & 55 deletions
Large diffs are not rendered by default.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
2+
// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path
3+
// needs — so anything below this can't authenticate for free usage at all.
4+
export const HEYGEN_MIN_VERSION = "0.3.0";
5+
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
6+
// bills API credits, so the onboarding steers to OAuth.
7+
export const HEYGEN_INSTALL_COMMAND =
8+
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth";
9+
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
10+
export const HEYGEN_UPDATE_COMMAND = "heygen update";
11+
12+
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}`;
13+
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
14+
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
15+
16+
const ACTIONABLE_MESSAGES = new Set([
17+
HEYGEN_NOT_FOUND_MESSAGE,
18+
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
19+
HEYGEN_OUTDATED_MESSAGE,
20+
]);
21+
22+
export function classifyHeygenError(err) {
23+
const detail = heygenErrorDetail(err);
24+
const text = [err?.stderr, err?.stdout, err?.message, detail]
25+
.map((value) => textOf(value))
26+
.filter(Boolean)
27+
.join("\n");
28+
const lower = text.toLowerCase();
29+
30+
// Only ENOENT (spawn of a missing binary) or a shell's "command not found"
31+
// mean the CLI itself is absent. A bare "not found" would misfire on the CLI's
32+
// own resource errors (e.g. a stale voiceId → "voice not found"), whose message
33+
// embeds the `heygen ...` command line — sending users to reinstall a CLI they
34+
// just ran successfully. Keep this narrow.
35+
if (err?.code === "ENOENT" || lower.includes("command not found")) {
36+
return HEYGEN_NOT_FOUND_MESSAGE;
37+
}
38+
39+
if (
40+
lower.includes("unauthorized") ||
41+
lower.includes("unauthenticated") ||
42+
// \b401\b, not a bare "401" substring — otherwise request IDs (req-401abc),
43+
// URLs, and retry-after headers would misclassify as an auth failure.
44+
/\b401\b/.test(lower) ||
45+
lower.includes("not logged in") ||
46+
lower.includes("no api key") ||
47+
lower.includes("missing api key") ||
48+
lower.includes("invalid api key") ||
49+
lower.includes("login required") ||
50+
lower.includes("auth required") ||
51+
lower.includes("authentication required")
52+
) {
53+
return HEYGEN_NOT_AUTHENTICATED_MESSAGE;
54+
}
55+
56+
const version = firstSemver(text);
57+
if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) {
58+
return HEYGEN_OUTDATED_MESSAGE;
59+
}
60+
61+
return detail;
62+
}
63+
64+
export function reportHeygenFailure(err, context) {
65+
const message = classifyHeygenError(err);
66+
if (ACTIONABLE_MESSAGES.has(message)) {
67+
console.error(message);
68+
} else {
69+
console.error(`media-use: \`${context}\` failed: ${message}`);
70+
}
71+
}
72+
73+
export function firstSemver(text) {
74+
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
75+
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
76+
}
77+
78+
export function versionLessThan(version, minimum) {
79+
const left = versionParts(version);
80+
const right = versionParts(minimum);
81+
if (!left || !right) return false;
82+
for (let i = 0; i < 3; i++) {
83+
if (left[i] < right[i]) return true;
84+
if (left[i] > right[i]) return false;
85+
}
86+
return false;
87+
}
88+
89+
function heygenErrorDetail(err) {
90+
return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err);
91+
}
92+
93+
function textOf(value) {
94+
return value == null ? "" : String(value).trim();
95+
}
96+
97+
function versionParts(version) {
98+
const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/);
99+
return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null;
100+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { strict as assert } from "node:assert";
2+
import { test } from "node:test";
3+
import {
4+
classifyHeygenError,
5+
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
6+
HEYGEN_NOT_FOUND_MESSAGE,
7+
HEYGEN_OUTDATED_MESSAGE,
8+
} from "./heygen-cli.mjs";
9+
10+
test("classifies ENOENT-style missing heygen errors with install instructions", () => {
11+
const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" });
12+
13+
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
14+
});
15+
16+
test("classifies auth failures with login instructions", () => {
17+
const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") });
18+
19+
assert.equal(message, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
20+
});
21+
22+
test("classifies a real 401 as auth, but not a bare 401 substring in prose", () => {
23+
assert.equal(
24+
classifyHeygenError({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
25+
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
26+
);
27+
// A request id that merely contains "401" must NOT read as an auth failure.
28+
const noise = classifyHeygenError({ stderr: Buffer.from("upload failed (request req-401abc)") });
29+
assert.notEqual(noise, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
30+
});
31+
32+
test("classifies old heygen versions with update instructions", () => {
33+
const message = classifyHeygenError({
34+
stderr: Buffer.from("heygen v0.1.5 does not support --headers"),
35+
});
36+
37+
assert.equal(message, HEYGEN_OUTDATED_MESSAGE);
38+
});
39+
40+
test("does not misclassify a resource 'not found' error as a missing CLI", () => {
41+
// A stale voiceId makes `heygen voice speech create` fail with "voice not
42+
// found"; the error message embeds the `heygen ...` command line. This must
43+
// pass through as detail, not send the user to reinstall a working CLI.
44+
const message = classifyHeygenError({
45+
stderr: Buffer.from("Error: voice not found (id: stale-123)"),
46+
message: "Command failed: heygen voice speech create --voice stale-123",
47+
});
48+
49+
assert.notEqual(message, HEYGEN_NOT_FOUND_MESSAGE);
50+
assert.equal(message, "Error: voice not found (id: stale-123)");
51+
});
52+
53+
test("classifies a shell 'command not found' as a missing CLI", () => {
54+
const message = classifyHeygenError({ stderr: Buffer.from("bash: heygen: command not found") });
55+
56+
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
57+
});
58+
59+
test("passes through unrelated errors", () => {
60+
const message = classifyHeygenError({
61+
stderr: Buffer.from("rate limit exceeded"),
62+
message: "Command failed",
63+
});
64+
65+
assert.equal(message, "rate limit exceeded");
66+
});

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
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
56
// literal arguments — no quoting tricks, no command injection. subcommand is a
67
// hardcoded multi-word string (e.g. "audio sounds list"), split into tokens.
7-
// Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.1.6).
8+
// Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0).
89
const args = [
910
"--headers",
1011
"X-HeyGen-Client-Source: media-use",
@@ -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: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
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).
5-
// Flags verified against `heygen voice speech create --help` (v0.1.6).
6+
// Flags verified against `heygen voice speech create --help` (v0.3.0).
67

78
function runJson(bin, argv, label) {
89
let out;
@@ -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 {

0 commit comments

Comments
 (0)