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
70 changes: 43 additions & 27 deletions packages/cli/src/auth/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthClient, apiBaseUrl, buildAuthHeaders } from "./client.js";
import {
AuthClient,
HEYGEN_CLI_SOURCE,
HEYGEN_CLI_SOURCE_HEADER,
apiBaseUrl,
buildAuthHeaders,
} from "./client.js";
import { isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";

Expand All @@ -23,6 +29,27 @@ function makeClient(fetchImpl: typeof fetch): AuthClient {
return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl });
}

// getCurrentUser is expected to reject with a specific auth-error code.
async function expectAuthCode(promise: Promise<unknown>, code: string): Promise<void> {
await expect(promise).rejects.toSatisfy(
(err) => isAuthError(err) && (err as { code: string }).code === code,
);
}

// getCurrentUser is expected to reject; assertMessage inspects the scrubbed message.
async function expectRejectionMessage(
client: AuthClient,
assertMessage: (msg: string) => void,
): Promise<void> {
try {
await client.getCurrentUser(apiKeyCred());
} catch (err) {
assertMessage((err as Error).message);
return;
}
throw new Error("expected rejection");
}

describe("auth/client", () => {
const original = process.env["HEYGEN_API_URL"];

Expand Down Expand Up @@ -51,11 +78,16 @@ describe("auth/client", () => {
source: "file_json",
refreshable: false,
};
expect(buildAuthHeaders(cred)).toEqual({ authorization: "Bearer at_123" });
expect(buildAuthHeaders(cred)).toEqual({
authorization: "Bearer at_123",
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
});
});

it("buildAuthHeaders uses x-api-key for api_key", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({ "x-api-key": "hg_x" });
it("buildAuthHeaders uses x-api-key for api_key, without the cli-source header", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({
"x-api-key": "hg_x",
});
});

it("getCurrentUser parses a wrapped {data: {...}} payload", async () => {
Expand Down Expand Up @@ -94,16 +126,12 @@ describe("auth/client", () => {

it("getCurrentUser throws ErrUnauthenticated on 401", async () => {
const client = makeClient(textFetch("invalid token", 401));
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
});
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "UNAUTHENTICATED");
});

it("getCurrentUser throws ErrApi on 5xx", async () => {
const client = makeClient(textFetch("upstream", 503));
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
});
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
});

it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => {
Expand All @@ -113,9 +141,7 @@ describe("auth/client", () => {
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch;
const client = makeClient(fetchImpl);
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
});
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
});

it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => {
Expand All @@ -130,15 +156,10 @@ describe("auth/client", () => {
401,
);
const client = makeClient(fetchImpl);
try {
await client.getCurrentUser(apiKeyCred());
} catch (err) {
const msg = (err as Error).message;
await expectRejectionMessage(client, (msg) => {
expect(msg).not.toContain("hg_supersecret_abc123");
expect(msg).toContain("<redacted>");
return;
}
throw new Error("expected rejection");
});
});

it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => {
Expand All @@ -147,16 +168,11 @@ describe("auth/client", () => {
401,
);
const client = makeClient(fetchImpl);
try {
await client.getCurrentUser(apiKeyCred());
} catch (err) {
const msg = (err as Error).message;
await expectRejectionMessage(client, (msg) => {
expect(msg).not.toContain("at_opaque_secret_999");
expect(msg).not.toContain("Bearer at_opaque_secret_999");
expect(msg).toContain("<redacted>");
return;
}
throw new Error("expected rejection");
});
});

it("getCurrentUser retries once on 401 when refresh hook is configured for OAuth", async () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { scrubCredentials } from "./scrub.js";
import type { OAuthTokens } from "./store.js";

const DEFAULT_BASE_URL = "https://api.heygen.com";
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
export const HEYGEN_CLI_SOURCE = "cli";

export function apiBaseUrl(): string {
const override = process.env["HEYGEN_API_URL"];
Expand Down Expand Up @@ -177,8 +179,14 @@ export class AuthClient {

export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
if (credential.type === "oauth") {
return { authorization: `Bearer ${credential.access_token}` };
return {
authorization: `Bearer ${credential.access_token}`,
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
};
}
// API-key traffic keeps the normal billing path; the backend ignores the
// cli-source header for it, so we don't send it (avoids a contradictory
// "cli-source claim on an API-key request").
return { "x-api-key": credential.key };
}

Expand Down
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": "1bea0c917a66f085",
"files": 113
"hash": "22b54ebaa6f93b5e",
"files": 114
},
"motion-graphics": {
"hash": "96ed2f7d8051b009",
Expand Down
32 changes: 17 additions & 15 deletions skills/media-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,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 | Local Kokoro (free); HeyGen TTS upsell |
| `voice` | TTS voiceover | HeyGen TTS (OAuth free allowance); Kokoro local fallback |
| `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 |

Expand Down Expand Up @@ -233,17 +233,19 @@ ladder, `describeModelLadder`); the agent can see the ladder and override.
| ------------- | --------------------------------------------------------------------------------------- |
| bgm/sfx | heygen catalog (free) |
| image | heygen search, then local mflux (best FLUX for your RAM), then codex `image_gen` upsell |
| voice | local **Kokoro** (free, on-device), then **heygen tts** paid 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 |

Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free,
private, offline once cached). Paid/cloud upsells sit behind them: HeyGen TTS
for voice, the `codex` CLI (ChatGPT sub) for a better image, 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). 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.

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
Expand Down Expand Up @@ -338,15 +340,15 @@ 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.

| 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 --key <key>` (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 + 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 |

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)`
Expand Down
4 changes: 2 additions & 2 deletions skills/media-use/audio/references/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ Run `npx hyperframes auth status` to see what's configured and which engines a w
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |

`hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed.
`hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; both are tagged with `X-HeyGen-Source: cli`. OAuth CLI users can consume the web-plan free allowance for HeyGen TTS (10 min/month); API keys follow the normal API billing path. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed.

## Model caches & system dependencies

Each command downloads its own model on first run and caches it under `~/.cache/hyperframes/`:

- **TTS (HeyGen)** — no local deps; needs a HeyGen credential + `ffmpeg` on PATH (to transcode the mp3 response to `.wav`). Credential resolves like the CLI: `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; run `npx hyperframes auth login`). An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`.
- **TTS (HeyGen)** — no local deps; needs a HeyGen credential + `ffmpeg` on PATH (to transcode the mp3 response to `.wav`). Credential resolves like the CLI: `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; run `npx hyperframes auth login`). An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; both include `X-HeyGen-Source: cli` so the backend can apply CLI OAuth free usage.
- **TTS (ElevenLabs)** — same as HeyGen: API key + `ffmpeg`.
- **TTS (Kokoro)** — Kokoro-82M (~311 MB) + voices (~27 MB) in `tts/`. Requires Python 3.8+ with `kokoro-onnx` and `soundfile` (`pip install kokoro-onnx soundfile`). Non-English text also needs `espeak-ng` system-wide.
- **BGM (Lyria)** — needs `$GEMINI_API_KEY` or `$GOOGLE_API_KEY` + `pip install google-genai`. No local model cache.
Expand Down
7 changes: 5 additions & 2 deletions skills/media-use/audio/references/tts.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ The script resolves a HeyGen credential the same way the CLI does — first sour
wins: `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → a project `.env` (auto-loaded,
walks up ≤5 dirs) → `~/.heygen/credentials` (shared with heygen-cli;
`$HEYGEN_CONFIG_DIR` overrides the dir). An OAuth login is sent as
`Authorization: Bearer`; an API key as `X-Api-Key`. If the only credential is an
expired OAuth token it stops with a hint to run `npx hyperframes auth refresh`.
`Authorization: Bearer`; an API key as `X-Api-Key`; both include
`X-HeyGen-Source: cli`. OAuth CLI users can consume the web-plan free allowance
(10 min/month) before paid usage; API keys follow normal API billing. If the
only credential is an expired OAuth token it stops with a hint to run
`npx hyperframes auth refresh`.

```bash
# Only needed if you haven't run `npx hyperframes auth login`:
Expand Down
2 changes: 1 addition & 1 deletion skills/media-use/audio/scripts/heygen-tts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//
// Flags: -o/--output (.wav → ffmpeg transcode; .mp3 → raw bytes), --words,
// --voice (starfish id), --speed, --lang, --list.
// Requires: $HEYGEN_API_KEY (or ~/.heygen) and ffmpeg for .wav output.
// Requires: $HEYGEN_API_KEY / OAuth ~/.heygen credentials and ffmpeg for .wav output.

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
Expand Down
9 changes: 8 additions & 1 deletion skills/media-use/audio/scripts/lib/heygen.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";

export const HEYGEN_BASE = "https://api.heygen.com/v3";
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };

// Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
export function loadEnvFromDir(startDir) {
Expand Down Expand Up @@ -71,7 +72,13 @@ export function heygenCredential() {
// → auth headers object, or throw with a fix hint.
export function heygenAuthHeaders() {
const cred = heygenCredential();
if (cred?.headers) return cred.headers;
if (cred?.headers) {
// Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
// grant the free allowance for OAuth requests and ignores it for API-key
// (X-Api-Key) traffic, where it's dead metadata.
const isOauth = "Authorization" in cred.headers;
return isOauth ? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS } : { ...cred.headers };
}
if (cred?.expired)
throw new Error(
"HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
Expand Down
Loading
Loading