@vibeframe/cli 0.27.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agent/adapters/index.d.ts +1 -0
  3. package/dist/agent/adapters/index.d.ts.map +1 -1
  4. package/dist/agent/adapters/index.js +5 -0
  5. package/dist/agent/adapters/index.js.map +1 -1
  6. package/dist/agent/adapters/openrouter.d.ts +16 -0
  7. package/dist/agent/adapters/openrouter.d.ts.map +1 -0
  8. package/dist/agent/adapters/openrouter.js +100 -0
  9. package/dist/agent/adapters/openrouter.js.map +1 -0
  10. package/dist/agent/types.d.ts +1 -1
  11. package/dist/agent/types.d.ts.map +1 -1
  12. package/dist/commands/agent.d.ts.map +1 -1
  13. package/dist/commands/agent.js +3 -1
  14. package/dist/commands/agent.js.map +1 -1
  15. package/dist/commands/ai-edit-cli.d.ts.map +1 -1
  16. package/dist/commands/ai-edit-cli.js +18 -0
  17. package/dist/commands/ai-edit-cli.js.map +1 -1
  18. package/dist/commands/generate.js +14 -0
  19. package/dist/commands/generate.js.map +1 -1
  20. package/dist/commands/schema.d.ts +1 -0
  21. package/dist/commands/schema.d.ts.map +1 -1
  22. package/dist/commands/schema.js +122 -21
  23. package/dist/commands/schema.js.map +1 -1
  24. package/dist/commands/setup.js +5 -2
  25. package/dist/commands/setup.js.map +1 -1
  26. package/dist/config/schema.d.ts +2 -1
  27. package/dist/config/schema.d.ts.map +1 -1
  28. package/dist/config/schema.js +2 -0
  29. package/dist/config/schema.js.map +1 -1
  30. package/dist/index.js +0 -0
  31. package/package.json +16 -12
  32. package/.turbo/turbo-build.log +0 -4
  33. package/.turbo/turbo-lint.log +0 -21
  34. package/.turbo/turbo-test.log +0 -689
  35. package/src/agent/adapters/claude.ts +0 -143
  36. package/src/agent/adapters/gemini.ts +0 -159
  37. package/src/agent/adapters/index.ts +0 -61
  38. package/src/agent/adapters/ollama.ts +0 -231
  39. package/src/agent/adapters/openai.ts +0 -116
  40. package/src/agent/adapters/xai.ts +0 -119
  41. package/src/agent/index.ts +0 -251
  42. package/src/agent/memory/index.ts +0 -151
  43. package/src/agent/prompts/system.ts +0 -106
  44. package/src/agent/tools/ai-editing.ts +0 -845
  45. package/src/agent/tools/ai-generation.ts +0 -1073
  46. package/src/agent/tools/ai-pipeline.ts +0 -1055
  47. package/src/agent/tools/ai.ts +0 -21
  48. package/src/agent/tools/batch.ts +0 -429
  49. package/src/agent/tools/e2e.test.ts +0 -545
  50. package/src/agent/tools/export.ts +0 -184
  51. package/src/agent/tools/filesystem.ts +0 -237
  52. package/src/agent/tools/index.ts +0 -150
  53. package/src/agent/tools/integration.test.ts +0 -775
  54. package/src/agent/tools/media.ts +0 -697
  55. package/src/agent/tools/project.ts +0 -313
  56. package/src/agent/tools/timeline.ts +0 -951
  57. package/src/agent/types.ts +0 -68
  58. package/src/commands/agent.ts +0 -340
  59. package/src/commands/ai-analyze.ts +0 -429
  60. package/src/commands/ai-animated-caption.ts +0 -390
  61. package/src/commands/ai-audio.ts +0 -941
  62. package/src/commands/ai-broll.ts +0 -490
  63. package/src/commands/ai-edit-cli.ts +0 -658
  64. package/src/commands/ai-edit.ts +0 -1542
  65. package/src/commands/ai-fill-gaps.ts +0 -566
  66. package/src/commands/ai-helpers.ts +0 -65
  67. package/src/commands/ai-highlights.ts +0 -1303
  68. package/src/commands/ai-image.ts +0 -761
  69. package/src/commands/ai-motion.ts +0 -347
  70. package/src/commands/ai-narrate.ts +0 -451
  71. package/src/commands/ai-review.ts +0 -309
  72. package/src/commands/ai-script-pipeline-cli.ts +0 -1710
  73. package/src/commands/ai-script-pipeline.ts +0 -1365
  74. package/src/commands/ai-suggest-edit.ts +0 -264
  75. package/src/commands/ai-video-fx.ts +0 -445
  76. package/src/commands/ai-video.ts +0 -915
  77. package/src/commands/ai-viral.ts +0 -595
  78. package/src/commands/ai-visual-fx.ts +0 -601
  79. package/src/commands/ai.test.ts +0 -627
  80. package/src/commands/ai.ts +0 -307
  81. package/src/commands/analyze.ts +0 -282
  82. package/src/commands/audio.ts +0 -644
  83. package/src/commands/batch.test.ts +0 -279
  84. package/src/commands/batch.ts +0 -440
  85. package/src/commands/detect.ts +0 -329
  86. package/src/commands/doctor.ts +0 -237
  87. package/src/commands/edit-cmd.ts +0 -1014
  88. package/src/commands/export.ts +0 -918
  89. package/src/commands/generate.ts +0 -2146
  90. package/src/commands/media.ts +0 -177
  91. package/src/commands/output.ts +0 -142
  92. package/src/commands/pipeline.ts +0 -398
  93. package/src/commands/project.test.ts +0 -127
  94. package/src/commands/project.ts +0 -149
  95. package/src/commands/sanitize.ts +0 -60
  96. package/src/commands/schema.ts +0 -130
  97. package/src/commands/setup.ts +0 -509
  98. package/src/commands/timeline.test.ts +0 -499
  99. package/src/commands/timeline.ts +0 -529
  100. package/src/commands/validate.ts +0 -77
  101. package/src/config/config.test.ts +0 -197
  102. package/src/config/index.ts +0 -125
  103. package/src/config/schema.ts +0 -82
  104. package/src/engine/index.ts +0 -2
  105. package/src/engine/project.test.ts +0 -702
  106. package/src/engine/project.ts +0 -439
  107. package/src/index.ts +0 -146
  108. package/src/utils/api-key.test.ts +0 -41
  109. package/src/utils/api-key.ts +0 -247
  110. package/src/utils/audio.ts +0 -83
  111. package/src/utils/exec-safe.ts +0 -75
  112. package/src/utils/first-run.ts +0 -52
  113. package/src/utils/provider-resolver.ts +0 -56
  114. package/src/utils/remotion.ts +0 -951
  115. package/src/utils/subtitle.test.ts +0 -227
  116. package/src/utils/subtitle.ts +0 -169
  117. package/src/utils/tty.ts +0 -196
  118. package/tsconfig.json +0 -20
@@ -1,247 +0,0 @@
1
- import { createInterface } from "node:readline";
2
- import { readFile, writeFile, access } from "node:fs/promises";
3
- import { resolve } from "node:path";
4
- import { config } from "dotenv";
5
- import chalk from "chalk";
6
- import { getApiKeyFromConfig } from "../config/index.js";
7
-
8
- /**
9
- * Load environment variables from .env files.
10
- * Priority: CWD .env (project-scoped) > monorepo root .env (development)
11
- * Later loads don't override earlier values, so CWD takes precedence.
12
- */
13
- export function loadEnv(): void {
14
- // 1. Load from current working directory (project-scoped, highest priority)
15
- config({ path: resolve(process.cwd(), ".env"), debug: false });
16
-
17
- // 2. Load from monorepo root if in development (won't override existing vars)
18
- const monorepoRoot = findMonorepoRoot();
19
- if (monorepoRoot && monorepoRoot !== process.cwd()) {
20
- config({ path: resolve(monorepoRoot, ".env"), debug: false });
21
- }
22
- }
23
-
24
- // Find monorepo root for development environments
25
- function findMonorepoRoot(): string | null {
26
- let dir = process.cwd();
27
- while (dir !== "/") {
28
- try {
29
- require.resolve(resolve(dir, "pnpm-workspace.yaml"));
30
- return dir;
31
- } catch {
32
- dir = resolve(dir, "..");
33
- }
34
- }
35
- return null;
36
- }
37
-
38
- /**
39
- * Prompt user for input (hidden for API keys)
40
- */
41
- async function prompt(question: string, hidden = false): Promise<string> {
42
- const rl = createInterface({
43
- input: process.stdin,
44
- output: process.stdout,
45
- });
46
-
47
- return new Promise((resolve) => {
48
- // For hidden input, we need to handle it differently
49
- if (hidden && process.stdin.isTTY) {
50
- process.stdout.write(question);
51
-
52
- let input = "";
53
- process.stdin.setRawMode(true);
54
- process.stdin.resume();
55
- process.stdin.setEncoding("utf8");
56
-
57
- const onData = (char: string) => {
58
- if (char === "\n" || char === "\r" || char === "\u0004") {
59
- process.stdin.setRawMode(false);
60
- process.stdin.pause();
61
- process.stdin.removeListener("data", onData);
62
- process.stdout.write("\n");
63
- rl.close();
64
- resolve(input);
65
- } else if (char === "\u0003") {
66
- // Ctrl+C
67
- process.exit(1);
68
- } else if (char === "\u007F" || char === "\b") {
69
- // Backspace
70
- if (input.length > 0) {
71
- input = input.slice(0, -1);
72
- }
73
- } else {
74
- input += char;
75
- }
76
- };
77
-
78
- process.stdin.on("data", onData);
79
- } else {
80
- rl.question(question, (answer) => {
81
- rl.close();
82
- resolve(answer);
83
- });
84
- }
85
- });
86
- }
87
-
88
- /**
89
- * Get API key from config, environment, or prompt
90
- */
91
- export async function getApiKey(
92
- envVar: string,
93
- providerName: string,
94
- optionValue?: string
95
- ): Promise<string | null> {
96
- // 1. Check command line option
97
- if (optionValue) {
98
- return optionValue;
99
- }
100
-
101
- // 2. Check ~/.vibeframe/config.yaml
102
- // Map env var to provider key
103
- const providerKeyMap: Record<string, string> = {
104
- ANTHROPIC_API_KEY: "anthropic",
105
- OPENAI_API_KEY: "openai",
106
- GOOGLE_API_KEY: "google",
107
- ELEVENLABS_API_KEY: "elevenlabs",
108
- RUNWAY_API_SECRET: "runway",
109
- KLING_API_KEY: "kling",
110
- IMGBB_API_KEY: "imgbb",
111
- REPLICATE_API_TOKEN: "replicate",
112
- };
113
- const providerKey = providerKeyMap[envVar];
114
- if (providerKey) {
115
- const configKey = await getApiKeyFromConfig(providerKey);
116
- if (configKey) {
117
- return configKey;
118
- }
119
- }
120
-
121
- // 3. Load .env and check environment
122
- loadEnv();
123
- const envValue = process.env[envVar];
124
- if (envValue) {
125
- return envValue;
126
- }
127
-
128
- // 4. Check if running in TTY (interactive terminal)
129
- if (!process.stdin.isTTY) {
130
- return null;
131
- }
132
-
133
- // 5. Prompt for API key
134
- console.log();
135
- console.log(chalk.yellow(`${providerName} API key not found.`));
136
- console.log(chalk.dim(`Set ${envVar} in .env (current directory), run 'vibe setup', or enter below.`));
137
- console.log();
138
-
139
- const apiKey = await prompt(chalk.cyan(`Enter ${providerName} API key: `), true);
140
-
141
- if (!apiKey || apiKey.trim() === "") {
142
- return null;
143
- }
144
-
145
- // 6. Ask if user wants to save to .env
146
- const save = await prompt(chalk.cyan("Save to .env for future use? (y/N): "));
147
-
148
- if (save.toLowerCase() === "y" || save.toLowerCase() === "yes") {
149
- await saveApiKeyToEnv(envVar, apiKey.trim());
150
- console.log(chalk.green("API key saved to .env"));
151
- }
152
-
153
- return apiKey.trim();
154
- }
155
-
156
- /**
157
- * Error thrown when a required API key is missing (non-interactive mode)
158
- */
159
- export class ApiKeyError extends Error {
160
- public envVar: string;
161
- public providerName: string;
162
-
163
- constructor(envVar: string, providerName: string) {
164
- super(
165
- `${providerName} API key required.\n` +
166
- ` Set ${envVar} in .env, or run: vibe setup`
167
- );
168
- this.name = "ApiKeyError";
169
- this.envVar = envVar;
170
- this.providerName = providerName;
171
- }
172
-
173
- toStructured(): {
174
- success: false;
175
- error: string;
176
- code: string;
177
- exitCode: number;
178
- suggestion: string;
179
- retryable: false;
180
- } {
181
- return {
182
- success: false as const,
183
- error: `${this.providerName} API key required.`,
184
- code: "API_KEY_MISSING",
185
- exitCode: 4,
186
- suggestion: `Set ${this.envVar} in .env, or run: vibe setup`,
187
- retryable: false as const,
188
- };
189
- }
190
- }
191
-
192
- /**
193
- * Check if an API key is available without prompting or side effects.
194
- */
195
- export function hasApiKey(envVar: string): boolean {
196
- loadEnv();
197
- if (process.env[envVar]) return true;
198
- const key = getApiKeyFromConfig(envVar);
199
- return !!key;
200
- }
201
-
202
- /**
203
- * Get API key or throw ApiKeyError if not found.
204
- * Use this instead of getApiKey() + manual null check.
205
- */
206
- export async function requireApiKey(
207
- envVar: string,
208
- providerName: string,
209
- cliOverride?: string
210
- ): Promise<string> {
211
- const key = await getApiKey(envVar, providerName, cliOverride);
212
- if (!key) {
213
- throw new ApiKeyError(envVar, providerName);
214
- }
215
- return key;
216
- }
217
-
218
- /**
219
- * Save API key to .env file
220
- */
221
- async function saveApiKeyToEnv(envVar: string, apiKey: string): Promise<void> {
222
- const envPath = resolve(process.cwd(), ".env");
223
-
224
- let content = "";
225
-
226
- try {
227
- await access(envPath);
228
- content = await readFile(envPath, "utf-8");
229
- } catch {
230
- // File doesn't exist, will create new
231
- }
232
-
233
- // Check if variable already exists
234
- const regex = new RegExp(`^${envVar}=.*$`, "m");
235
- if (regex.test(content)) {
236
- // Replace existing
237
- content = content.replace(regex, `${envVar}=${apiKey}`);
238
- } else {
239
- // Append new
240
- if (content && !content.endsWith("\n")) {
241
- content += "\n";
242
- }
243
- content += `${envVar}=${apiKey}\n`;
244
- }
245
-
246
- await writeFile(envPath, content, "utf-8");
247
- }
@@ -1,83 +0,0 @@
1
- import { execSafe, ffprobeDuration } from "./exec-safe.js";
2
-
3
- /**
4
- * Get the duration of an audio file using ffprobe
5
- * @param filePath - Path to the audio file
6
- * @returns Duration in seconds
7
- */
8
- export async function getAudioDuration(filePath: string): Promise<number> {
9
- try {
10
- return await ffprobeDuration(filePath);
11
- } catch (error) {
12
- const message = error instanceof Error ? error.message : String(error);
13
- throw new Error(`Failed to get audio duration: ${message}`);
14
- }
15
- }
16
-
17
- /**
18
- * Get the duration of a video file using ffprobe
19
- * @param filePath - Path to the video file
20
- * @returns Duration in seconds
21
- */
22
- export async function getVideoDuration(filePath: string): Promise<number> {
23
- try {
24
- return await ffprobeDuration(filePath);
25
- } catch (error) {
26
- const message = error instanceof Error ? error.message : String(error);
27
- throw new Error(`Failed to get video duration: ${message}`);
28
- }
29
- }
30
-
31
- /**
32
- * Extend a video naturally to match target duration using progressive techniques.
33
- * Uses slowdown, frame interpolation, and freeze frames based on extension ratio.
34
- *
35
- * @param videoPath - Path to the source video
36
- * @param targetDuration - Target duration in seconds
37
- * @param outputPath - Path for the extended video output
38
- * @returns Promise that resolves when extension is complete
39
- */
40
- export async function extendVideoNaturally(
41
- videoPath: string,
42
- targetDuration: number,
43
- outputPath: string
44
- ): Promise<void> {
45
- const videoDuration = await getVideoDuration(videoPath);
46
- const ratio = targetDuration / videoDuration;
47
-
48
- if (ratio <= 1.0) {
49
- // No extension needed, just copy
50
- const { copyFile } = await import("node:fs/promises");
51
- await copyFile(videoPath, outputPath);
52
- return;
53
- }
54
-
55
- if (ratio <= 1.15) {
56
- // 0-15% extension: Simple slowdown using setpts
57
- // setpts factor = 1/ratio to slow down the video
58
- const slowFactor = (1 / ratio).toFixed(4);
59
- await execSafe("ffmpeg", ["-y", "-i", videoPath, "-filter:v", `setpts=${slowFactor}*PTS`, "-an", outputPath]);
60
- } else if (ratio <= 1.4) {
61
- // 15-40% extension: Frame interpolation + slowdown
62
- // minterpolate creates smooth slow-motion effect
63
- const slowFactor = (1 / ratio).toFixed(4);
64
- await execSafe("ffmpeg", ["-y", "-i", videoPath, "-filter:v", `minterpolate=fps=60:mi_mode=mci,setpts=${slowFactor}*PTS`, "-an", outputPath]);
65
- } else {
66
- // 40%+ extension: Slowdown to 0.7x speed + freeze last frame for remainder
67
- // First, slow down to get ~43% extension
68
- const slowRatio = 0.7;
69
- const slowedDuration = videoDuration / slowRatio;
70
- const freezeDuration = targetDuration - slowedDuration;
71
-
72
- if (freezeDuration <= 0) {
73
- // Can achieve target with slowdown alone
74
- const slowFactor = (1 / ratio).toFixed(4);
75
- await execSafe("ffmpeg", ["-y", "-i", videoPath, "-filter:v", `minterpolate=fps=60:mi_mode=mci,setpts=${slowFactor}*PTS`, "-an", outputPath]);
76
- } else {
77
- // Need slowdown + freeze frame
78
- // Use tpad to extend the last frame
79
- const slowFactor = (1 / slowRatio).toFixed(4); // ~1.43 for 0.7x speed
80
- await execSafe("ffmpeg", ["-y", "-i", videoPath, "-filter:v", `setpts=${slowFactor}*PTS,tpad=stop_mode=clone:stop_duration=${freezeDuration.toFixed(2)}`, "-an", outputPath]);
81
- }
82
- }
83
- }
@@ -1,75 +0,0 @@
1
- import { execFile, execFileSync } from "node:child_process";
2
- import { promisify } from "node:util";
3
-
4
- const execFileAsync = promisify(execFile);
5
-
6
- /** Safe async exec — no shell, args as array */
7
- export async function execSafe(
8
- cmd: string,
9
- args: string[],
10
- options?: { timeout?: number; maxBuffer?: number },
11
- ): Promise<{ stdout: string; stderr: string }> {
12
- return execFileAsync(cmd, args, {
13
- timeout: options?.timeout,
14
- maxBuffer: options?.maxBuffer ?? 50 * 1024 * 1024,
15
- });
16
- }
17
-
18
- /** Safe sync exec — no shell, args as array */
19
- export function execSafeSync(
20
- cmd: string,
21
- args: string[],
22
- options?: { stdio?: "pipe" | "ignore" },
23
- ): string {
24
- return execFileSync(cmd, args, {
25
- encoding: "utf-8",
26
- stdio: options?.stdio ?? "pipe",
27
- });
28
- }
29
-
30
- /** Shorthand: ffprobe duration query */
31
- export async function ffprobeDuration(filePath: string): Promise<number> {
32
- const { stdout } = await execSafe("ffprobe", [
33
- "-v",
34
- "error",
35
- "-show_entries",
36
- "format=duration",
37
- "-of",
38
- "default=noprint_wrappers=1:nokey=1",
39
- filePath,
40
- ]);
41
- const duration = parseFloat(stdout.trim());
42
- if (isNaN(duration)) throw new Error(`Invalid duration: ${stdout}`);
43
- return duration;
44
- }
45
-
46
- /** Shorthand: ffprobe video dimensions */
47
- export async function ffprobeVideoSize(
48
- filePath: string,
49
- ): Promise<{ width: number; height: number }> {
50
- const { stdout } = await execSafe("ffprobe", [
51
- "-v",
52
- "error",
53
- "-select_streams",
54
- "v:0",
55
- "-show_entries",
56
- "stream=width,height",
57
- "-of",
58
- "csv=p=0:s=x",
59
- filePath,
60
- ]);
61
- const [w, h] = stdout.trim().split("x").map(Number);
62
- if (isNaN(w) || isNaN(h))
63
- throw new Error(`Invalid dimensions: ${stdout.trim()}`);
64
- return { width: w, height: h };
65
- }
66
-
67
- /** Shorthand: check if a command exists */
68
- export function commandExists(cmd: string): boolean {
69
- try {
70
- execFileSync("which", [cmd], { stdio: "ignore" });
71
- return true;
72
- } catch {
73
- return false;
74
- }
75
- }
@@ -1,52 +0,0 @@
1
- /**
2
- * First-run detection for VibeFrame CLI
3
- * Shows a welcome banner when user has never configured the tool
4
- */
5
-
6
- import { access } from "node:fs/promises";
7
- import chalk from "chalk";
8
- import { CONFIG_PATH } from "../config/index.js";
9
- import { PROVIDER_ENV_VARS } from "../config/schema.js";
10
- import { loadEnv } from "./api-key.js";
11
-
12
- /**
13
- * Check if this is the user's first run (no config and no env vars set)
14
- */
15
- export async function isFirstRun(): Promise<boolean> {
16
- // Check if config file exists
17
- try {
18
- await access(CONFIG_PATH);
19
- return false;
20
- } catch {
21
- // Config doesn't exist, check env vars
22
- }
23
-
24
- // Load .env files
25
- loadEnv();
26
-
27
- // Check if any provider API key is set in environment
28
- for (const envVar of Object.values(PROVIDER_ENV_VARS)) {
29
- if (process.env[envVar]) {
30
- return false;
31
- }
32
- }
33
-
34
- return true;
35
- }
36
-
37
- /**
38
- * Show a friendly welcome banner for first-time users
39
- */
40
- export function showFirstRunBanner(): void {
41
- console.log();
42
- console.log(chalk.cyan.bold(" Welcome to VibeFrame!"));
43
- console.log();
44
- console.log(` Get started:`);
45
- console.log(` ${chalk.green("vibe setup")} Configure API keys ${chalk.dim("(1 min)")}`);
46
- console.log(` ${chalk.green("vibe doctor")} Check what's ready`);
47
- console.log(` ${chalk.green("vibe --help")} See all commands`);
48
- console.log();
49
- console.log(chalk.dim(" Tip: some commands work without API keys (silence-cut, fade, noise-reduce)."));
50
- console.log(chalk.dim(" Run 'vibe doctor' to see everything available."));
51
- console.log();
52
- }
@@ -1,56 +0,0 @@
1
- /**
2
- * Smart provider auto-resolution
3
- * Picks the best available provider based on configured API keys
4
- */
5
-
6
- import { hasApiKey } from "./api-key.js";
7
-
8
- interface ProviderCandidate {
9
- name: string;
10
- envVar: string;
11
- label: string;
12
- }
13
-
14
- const IMAGE_PROVIDERS: ProviderCandidate[] = [
15
- { name: "gemini", envVar: "GOOGLE_API_KEY", label: "Gemini" },
16
- { name: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" },
17
- { name: "grok", envVar: "XAI_API_KEY", label: "Grok" },
18
- ];
19
-
20
- const VIDEO_PROVIDERS: ProviderCandidate[] = [
21
- { name: "grok", envVar: "XAI_API_KEY", label: "Grok" },
22
- { name: "veo", envVar: "GOOGLE_API_KEY", label: "Veo" },
23
- { name: "kling", envVar: "KLING_API_KEY", label: "Kling" },
24
- { name: "runway", envVar: "RUNWAY_API_SECRET", label: "Runway" },
25
- ];
26
-
27
- const SPEECH_PROVIDERS: ProviderCandidate[] = [
28
- { name: "elevenlabs", envVar: "ELEVENLABS_API_KEY", label: "ElevenLabs" },
29
- ];
30
-
31
- const PROVIDER_MAP: Record<string, ProviderCandidate[]> = {
32
- image: IMAGE_PROVIDERS,
33
- video: VIDEO_PROVIDERS,
34
- speech: SPEECH_PROVIDERS,
35
- };
36
-
37
- /**
38
- * Resolve the best available provider for a given category.
39
- * Uses hasApiKey() for side-effect-free checking (no prompts).
40
- * Returns { name, label } of the first provider with a configured API key,
41
- * or null if none are available.
42
- */
43
- export function resolveProvider(
44
- category: "image" | "video" | "speech"
45
- ): { name: string; label: string } | null {
46
- const candidates = PROVIDER_MAP[category];
47
- if (!candidates) return null;
48
-
49
- for (const candidate of candidates) {
50
- if (hasApiKey(candidate.envVar)) {
51
- return { name: candidate.name, label: candidate.label };
52
- }
53
- }
54
-
55
- return null;
56
- }