Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
"files": 10
},
"media-use": {
"hash": "22b54ebaa6f93b5e",
"files": 114
"hash": "14ea99a7f4d750cd",
"files": 116
},
"motion-graphics": {
"hash": "96ed2f7d8051b009",
Expand Down
128 changes: 73 additions & 55 deletions skills/media-use/SKILL.md

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions skills/media-use/scripts/lib/heygen-cli.mjs
Original file line number Diff line number Diff line change
@@ -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";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The bump from 0.1.60.3.0 fires HEYGEN_OUTDATED_MESSAGE on --doctor for anyone on 0.1.6-0.2.x, including users who never touch OAuth (API-key auth still works fine on those versions). The commit rationale is precise ("free usage needs OAuth needs v0.3.0"), but runDoctor doesn't distinguish OAuth-first from API-key-first users — everyone gets nudged to update. Two ways to read it: (a) team policy is "we require 0.3.0 uniformly, including for API-key users" — reasonable, but worth stating in SKILL.md since it's a stronger claim than the commit body implies; (b) the check should branch on credential type (heygenCredential()?.type === "oauth") and only require 0.3.0 for the OAuth path. Non-blocking either way.

Review by Rames D Jusso

// 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;
}
66 changes: 66 additions & 0 deletions skills/media-use/scripts/lib/heygen-cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
6 changes: 3 additions & 3 deletions skills/media-use/scripts/lib/heygen-search.mjs
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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;
}

Expand Down
7 changes: 3 additions & 4 deletions skills/media-use/scripts/lib/voice-provider.mjs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down
Loading
Loading