Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
50 changes: 50 additions & 0 deletions packages/cli/src/audio/providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { decideMusic, decideVoice, KOKORO_PIP, MUSICGEN_PIP } from "./providers.js";

describe("decideVoice — mirrors the skill's heygen → elevenlabs → kokoro order", () => {
it("prefers HeyGen when configured", () => {
const r = decideVoice({ hasHeygen: true, elevenlabs: true, kokoro: true });
expect(r.engine).toBe("heygen");
expect(r.ready).toBe(true);
});

it("falls to ElevenLabs only when key + module are both present", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: true, kokoro: true }).engine).toBe(
"elevenlabs",
);
});

it("falls to Kokoro when no cloud provider is usable", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).engine).toBe(
"kokoro",
);
});

it("flags Kokoro as not-ready with a pip hint when deps are missing", () => {
const r = decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: false });
expect(r.engine).toBe("kokoro");
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(KOKORO_PIP);
});

it("omits the hint when Kokoro is ready", () => {
expect(
decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).setupHint,
).toBeUndefined();
});
});

describe("decideMusic — mirrors the skill's heygen → lyria → musicgen order", () => {
it("prefers HeyGen, then Lyria, then MusicGen", () => {
expect(decideMusic({ hasHeygen: true, lyria: true, musicgen: true }).engine).toBe("heygen");
expect(decideMusic({ hasHeygen: false, lyria: true, musicgen: true }).engine).toBe("lyria");
expect(decideMusic({ hasHeygen: false, lyria: false, musicgen: true }).engine).toBe("musicgen");
});

it("flags MusicGen as not-ready with a pip hint when deps are missing", () => {
const r = decideMusic({ hasHeygen: false, lyria: false, musicgen: false });
expect(r.engine).toBe("musicgen");
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(MUSICGEN_PIP);
});
});
102 changes: 102 additions & 0 deletions packages/cli/src/audio/providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Which voice / music engine a workflow will actually use, and whether
* its local dependencies are present. Mirrors the resolution order the
* hyperframes-media skill scripts use, so `auth status` and `doctor`
* report the same engine the render pipeline would pick:
*
* voice: HeyGen Starfish → ElevenLabs (key + `elevenlabs`) → Kokoro (local)
* music: HeyGen library → Lyria (key + `google.genai`) → MusicGen (local)
*
* The decision is split from the probing: `decide*` is pure (unit-tested
* without spawning Python); `gather*` collects the live facts.
*/

import { hasPythonModules } from "../tts/python.js";

/** Python import names probed for each local engine. */
export const KOKORO_MODULES = ["kokoro_onnx", "soundfile"];
export const MUSICGEN_MODULES = ["transformers", "torch", "soundfile", "numpy"];

/** pip one-liners shown when a local engine's deps are missing. */
export const KOKORO_PIP = "pip install kokoro-onnx soundfile";
export const MUSICGEN_PIP = "pip install transformers torch soundfile numpy";

export type VoiceEngine = "heygen" | "elevenlabs" | "kokoro";
export type MusicEngine = "heygen" | "lyria" | "musicgen";

export interface EngineReadiness<E> {
engine: E;
/** Human label, e.g. "Kokoro". */
label: string;
/** A local engine (no account needed) vs a cloud provider keyed by env. */
local: boolean;
/** Usable right now: cloud key present, or local deps installed. */
ready: boolean;
/** Shown when `ready` is false — how to make it ready. */
setupHint?: string;
}

export interface VoiceFacts {
hasHeygen: boolean;
/** ELEVENLABS_API_KEY set AND the `elevenlabs` module importable. */
elevenlabs: boolean;
/** Kokoro's local deps importable. */
kokoro: boolean;
}

export interface MusicFacts {
hasHeygen: boolean;
/** A Gemini/Google key set AND `google.genai` importable. */
lyria: boolean;
/** MusicGen's local deps importable. */
musicgen: boolean;
}

export function decideVoice(f: VoiceFacts): EngineReadiness<VoiceEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen Starfish", local: false, ready: true };
if (f.elevenlabs) return { engine: "elevenlabs", label: "ElevenLabs", local: false, ready: true };
return {
engine: "kokoro",
label: "Kokoro",
local: true,
ready: f.kokoro,
...(f.kokoro ? {} : { setupHint: KOKORO_PIP }),
};
}

export function decideMusic(f: MusicFacts): EngineReadiness<MusicEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen library", local: false, ready: true };
if (f.lyria) return { engine: "lyria", label: "Lyria (Gemini)", local: false, ready: true };
return {
engine: "musicgen",
label: "MusicGen",
local: true,
ready: f.musicgen,
...(f.musicgen ? {} : { setupHint: MUSICGEN_PIP }),
};
}

/** Collect live voice facts. Skips Python probes when HeyGen is configured. */
function gatherVoiceFacts(hasHeygen: boolean): VoiceFacts {
if (hasHeygen) return { hasHeygen, elevenlabs: false, kokoro: false };
const elevenlabs = Boolean(process.env["ELEVENLABS_API_KEY"]) && hasPythonModules(["elevenlabs"]);
const kokoro = hasPythonModules(KOKORO_MODULES);
return { hasHeygen, elevenlabs, kokoro };
}

/** Collect live music facts. Skips Python probes when HeyGen is configured. */
function gatherMusicFacts(hasHeygen: boolean): MusicFacts {
if (hasHeygen) return { hasHeygen, lyria: false, musicgen: false };
const hasLyriaKey = Boolean(process.env["GEMINI_API_KEY"] || process.env["GOOGLE_API_KEY"]);
const lyria = hasLyriaKey && hasPythonModules(["google.genai"]);
const musicgen = hasPythonModules(MUSICGEN_MODULES);
return { hasHeygen, lyria, musicgen };
}

export function resolveVoice(hasHeygen: boolean): EngineReadiness<VoiceEngine> {
return decideVoice(gatherVoiceFacts(hasHeygen));
}

export function resolveMusic(hasHeygen: boolean): EngineReadiness<MusicEngine> {
return decideMusic(gatherMusicFacts(hasHeygen));
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/auth/status-guidance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Onboarding guidance shown by `auth status` when nothing is configured.
*
* Kept separate from `status.ts` so the wording is pure (it depends only
* on colors, not on the credential resolver / API client / system probe)
* and can be unit-tested without booting the whole CLI dependency graph.
* Environment detection lives in `status.ts`; this module only renders.
*/

import { c } from "../../ui/colors.js";

export interface UnconfiguredContext {
/** A human can act on guidance now — a TTY, or a coding agent driving the CLI. */
interactive: boolean;
}

/** The local engine a workflow will fall back to, and whether it's ready. */
export interface OfflineEngineLine {
capability: "voice" | "music";
/** Engine label, e.g. "Kokoro" / "MusicGen". */
label: string;
/** Deps installed (local) or key present (cloud) — usable right now. */
ready: boolean;
/** How to make it ready, shown when `ready` is false. */
setupHint?: string;
}

/** The recommended first step; sign-in and sign-up are the same OAuth flow. */
const RECOMMENDED_ACTION = "hyperframes auth login";

/**
* Render the "what offline will use" block from probed engine readiness.
* Falls back to a generic one-liner when readiness wasn't probed (e.g. a
* caller that didn't want to spawn Python).
*/
function offlineEngineLines(engines?: OfflineEngineLine[]): string[] {
if (!engines || engines.length === 0) {
return [
c.dim("Prefer offline? Just continue — local engines (Kokoro · MusicGen) need no account."),
];
}
const lines = ["Prefer offline? Workflows will use these local engines:"];
for (const e of engines) {
const cap = e.capability.padEnd(5);
if (e.ready) {
lines.push(` ${cap} → ${e.label} ${c.success("✓ ready")}`);
} else {
lines.push(` ${cap} → ${e.label} ${c.warn("⚠ deps missing")}`);
if (e.setupHint) lines.push(` ${c.dim(e.setupHint)}`);
}
}
if (engines.some((e) => !e.ready)) {
lines.push(c.dim(" (or run `hyperframes doctor` to check the local toolchain)"));
}
return lines;
}

/**
* Human guidance for an unconfigured machine — registration-first.
* Recommends signing in via either CLI — `hyperframes auth login` (always
* available here) or `heygen auth login` (if the HeyGen CLI is installed);
* both are the same OAuth login, create an account, and share `~/.heygen`.
* Names the local fallback so "no key" never reads as a failure, and never
* steers users toward a per-repo `.env`. Mirrors the canonical wording in
* the hyperframes-media skill's Preflight section.
*/
export function buildUnconfiguredLines(
ctx: UnconfiguredContext,
engines?: OfflineEngineLine[],
): string[] {
if (!ctx.interactive) {
return [
c.warn("Not signed in to HeyGen (non-interactive)."),
c.dim(
"Set HEYGEN_API_KEY to use HeyGen, or workflows fall back to local engines (Kokoro voice · MusicGen music).",
),
];
}
return [
c.warn("Not signed in to HeyGen — voice & music will use local engines (free, offline)."),
"",
"Sign in or create an account — either CLI works (same shared login, no per-repo .env):",
` ${c.accent("hyperframes auth login")} ${c.dim("# always available via this repo's CLI")}`,
` ${c.accent("heygen auth login")} ${c.dim("# if you use the HeyGen CLI")}`,
` ${c.accent("hyperframes auth login --api-key")} ${c.dim("# paste an existing key instead")}`,
"",
...offlineEngineLines(engines),
];
}

/** Machine-readable form of the unconfigured guidance for `--json`. */
export function buildUnconfiguredJson(
ctx: UnconfiguredContext,
engines?: OfflineEngineLine[],
): Record<string, unknown> {
return {
configured: false,
interactive: ctx.interactive,
recommended_action: RECOMMENDED_ACTION,
fallback: "local",
...(engines ? { offline_engines: engines } : {}),
};
}
119 changes: 119 additions & 0 deletions packages/cli/src/commands/auth/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
buildUnconfiguredJson,
buildUnconfiguredLines,
type OfflineEngineLine,
type UnconfiguredContext,
} from "./status-guidance.js";

const INTERACTIVE: UnconfiguredContext = { interactive: true };
const NON_INTERACTIVE: UnconfiguredContext = { interactive: false };

function joined(ctx: UnconfiguredContext, engines?: OfflineEngineLine[]): string {
return buildUnconfiguredLines(ctx, engines).join("\n");
}

describe("buildUnconfiguredLines — interactive (TTY / agent-driven)", () => {
const text = joined(INTERACTIVE);

it("recommends both CLIs (sign-in == sign-up via either)", () => {
expect(text).toContain("hyperframes auth login");
expect(text).toContain("heygen auth login");
expect(text).toMatch(/sign in or create an account/i);
});

it("never steers users toward a per-repo .env", () => {
// The improvised flow recommended writing keys into videos/<project>/.env;
// this guidance must actively rule that out, not suggest it.
expect(text).toContain("no per-repo .env");
expect(text).not.toMatch(/paste keys.*\.env/i);
});

it("names the local fallback so 'no key' never reads as a failure", () => {
expect(text).toMatch(/Kokoro/);
expect(text).toMatch(/MusicGen/);
expect(text).toMatch(/free, offline/i);
});

it("presents the HeyGen CLI login as a shared-credential alternative", () => {
expect(text).toMatch(/heygen auth login/);
expect(text).toMatch(/shared login/i);
});

it("offers the --api-key path as a secondary option", () => {
expect(text).toContain("hyperframes auth login --api-key");
});
});

describe("buildUnconfiguredLines — non-interactive (CI / piped)", () => {
const lines = buildUnconfiguredLines(NON_INTERACTIVE);
const text = lines.join("\n");

it("is terse — two lines, no browser walkthrough", () => {
expect(lines).toHaveLength(2);
expect(text).not.toMatch(/opens your browser/i);
});

it("points at HEYGEN_API_KEY and the local fallback", () => {
expect(text).toContain("HEYGEN_API_KEY");
expect(text).toMatch(/local engines/i);
});
});

describe("buildUnconfiguredLines — offline engine readiness", () => {
const ready: OfflineEngineLine[] = [
{ capability: "voice", label: "Kokoro", ready: true },
{ capability: "music", label: "MusicGen", ready: true },
];
const missing: OfflineEngineLine[] = [
{ capability: "voice", label: "Kokoro", ready: true },
{
capability: "music",
label: "MusicGen",
ready: false,
setupHint: "pip install transformers torch soundfile numpy",
},
];

it("shows the resolved engine per capability when ready", () => {
const text = joined(INTERACTIVE, ready);
expect(text).toMatch(/voice .*Kokoro/);
expect(text).toMatch(/music .*MusicGen/);
expect(text).toMatch(/ready/);
});

it("surfaces the pip setup hint and doctor pointer when a dep is missing", () => {
const text = joined(INTERACTIVE, missing);
expect(text).toContain("pip install transformers torch soundfile numpy");
expect(text).toMatch(/deps missing/);
expect(text).toContain("hyperframes doctor");
});

it("falls back to a generic line when readiness wasn't probed", () => {
const text = joined(INTERACTIVE);
expect(text).toMatch(/Kokoro/);
expect(text).toMatch(/MusicGen/);
});
});

describe("buildUnconfiguredJson", () => {
it("recommends auth login and reports the local fallback", () => {
for (const ctx of [INTERACTIVE, NON_INTERACTIVE]) {
const payload = buildUnconfiguredJson(ctx);
expect(payload).toMatchObject({
configured: false,
interactive: ctx.interactive,
recommended_action: "hyperframes auth login",
fallback: "local",
});
}
});

it("includes probed engines when provided", () => {
const engines: OfflineEngineLine[] = [
{ capability: "voice", label: "Kokoro", ready: true },
{ capability: "music", label: "MusicGen", ready: false, setupHint: "pip install ..." },
];
expect(buildUnconfiguredJson(INTERACTIVE, engines)).toMatchObject({ offline_engines: engines });
});
});
Loading
Loading