storyforge 0.2.0 → 0.2.2

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 (2) hide show
  1. package/dist/index.js +401 -14
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ import * as fs2 from "fs";
5
5
  import * as os2 from "os";
6
6
  import * as path2 from "path";
7
7
  import * as http from "http";
8
- import { execFile } from "child_process";
8
+ import { execFile, exec as execCb, execSync } from "child_process";
9
+ import { promisify } from "util";
9
10
 
10
11
  // src/utils/log.ts
11
12
  var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
@@ -42,6 +43,7 @@ function loadCredentials() {
42
43
  }
43
44
 
44
45
  // src/commands/dev.ts
46
+ var exec = promisify(execCb);
45
47
  var PORT = 4444;
46
48
  var WEB_URL = "https://forge.algo-thinker.com";
47
49
  function getApiConfig() {
@@ -184,6 +186,54 @@ async function findOrCreateProject(dir) {
184
186
  log.success(`Saved ${metaPath(dir)}`);
185
187
  return meta;
186
188
  }
189
+ function findMonorepoRoot(startDir) {
190
+ let d = startDir;
191
+ for (let i = 0; i < 6; i++) {
192
+ const parent = path2.dirname(d);
193
+ if (parent === d) break;
194
+ if (fs2.existsSync(path2.join(parent, "turbo.json")) || fs2.existsSync(path2.join(parent, "apps")) && fs2.existsSync(path2.join(parent, "packages"))) return parent;
195
+ d = parent;
196
+ }
197
+ return startDir;
198
+ }
199
+ function findProjectCompositions(dir) {
200
+ const SKIP = /* @__PURE__ */ new Set(["Root.tsx", "index.tsx", "DocumentaryVideo.tsx", "DocumentaryVideoV2.tsx"]);
201
+ const seen = /* @__PURE__ */ new Set();
202
+ const result = [];
203
+ function scanDir(absoluteDir, base) {
204
+ if (!fs2.existsSync(absoluteDir)) return;
205
+ function scan(d, rel) {
206
+ if (!fs2.existsSync(d)) return;
207
+ for (const entry of fs2.readdirSync(d, { withFileTypes: true })) {
208
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
209
+ const full = path2.join(d, entry.name);
210
+ const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
211
+ if (entry.isDirectory()) {
212
+ scan(full, entryRel);
213
+ continue;
214
+ }
215
+ if (entry.name.endsWith(".tsx") && !SKIP.has(entry.name) && !seen.has(entry.name)) {
216
+ seen.add(entry.name);
217
+ result.push({ name: entry.name.replace(/\.tsx$/, ""), source: "project", relPath: entryRel, basePath: base, absolutePath: full });
218
+ }
219
+ }
220
+ }
221
+ scan(absoluteDir, "");
222
+ }
223
+ scanDir(path2.join(dir, "src", "remotion"), dir);
224
+ const root = findMonorepoRoot(dir);
225
+ if (root !== dir) {
226
+ scanDir(path2.join(root, "src", "remotion"), root);
227
+ const packagesDir = path2.join(root, "packages");
228
+ if (fs2.existsSync(packagesDir)) {
229
+ for (const pkg of fs2.readdirSync(packagesDir, { withFileTypes: true })) {
230
+ if (!pkg.isDirectory() || pkg.name.startsWith(".")) continue;
231
+ scanDir(path2.join(packagesDir, pkg.name, "src"), path2.join(packagesDir, pkg.name));
232
+ }
233
+ }
234
+ }
235
+ return result;
236
+ }
187
237
  function getMime(file) {
188
238
  const ext = path2.extname(file).toLowerCase();
189
239
  const types = {
@@ -219,6 +269,9 @@ function collectFiles(dir, base = "") {
219
269
  }
220
270
  return result;
221
271
  }
272
+ function stripFences(text) {
273
+ return text.replace(/^```(?:tsx?|jsx?|typescript|javascript)?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
274
+ }
222
275
  function openBrowser(url) {
223
276
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
224
277
  execFile(cmd, [url], (err) => {
@@ -273,9 +326,15 @@ async function devCommand(options) {
273
326
  return;
274
327
  }
275
328
  if (pathname === "/api/assets") {
329
+ const imgExts = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
330
+ const legacyH = collectFiles(path2.join(dir, "images-horizontal"), "images-horizontal");
331
+ const geminiScenes = collectFiles(
332
+ path2.join(dir, "public", "tesla-fsd", "gemini-scenes"),
333
+ "public/tesla-fsd/gemini-scenes"
334
+ ).filter((f) => imgExts.has(path2.extname(f.name).toLowerCase()));
276
335
  const assets = {
277
336
  audio: collectFiles(path2.join(dir, "audio"), "audio"),
278
- imagesH: collectFiles(path2.join(dir, "images-horizontal"), "images-horizontal"),
337
+ imagesH: [...legacyH, ...geminiScenes],
279
338
  imagesV: collectFiles(path2.join(dir, "images-vertical"), "images-vertical"),
280
339
  clips: collectFiles(path2.join(dir, "clips"), "clips"),
281
340
  scripts: collectFiles(path2.join(dir, "scripts"), "scripts"),
@@ -289,30 +348,37 @@ async function devCommand(options) {
289
348
  if (pathname === "/api/compositions") {
290
349
  const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
291
350
  fs2.mkdirSync(compositionsDir, { recursive: true });
292
- const files = fs2.readdirSync(compositionsDir).filter((f) => f.endsWith(".tsx"));
293
- const list = files.map((f) => ({ name: f.replace(/\.tsx$/, ""), path: path2.join(compositionsDir, f) }));
351
+ const savedFiles = fs2.readdirSync(compositionsDir).filter((f) => f.endsWith(".tsx"));
352
+ const saved = savedFiles.map((f) => ({ name: f.replace(/\.tsx$/, ""), source: "saved" }));
353
+ const project = findProjectCompositions(dir);
294
354
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
295
- res.end(JSON.stringify(list));
355
+ res.end(JSON.stringify([...saved, ...project]));
296
356
  return;
297
357
  }
298
358
  const compGetMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
299
359
  if (req.method === "GET" && compGetMatch) {
300
- const name = compGetMatch[1].replace(/[^a-zA-Z0-9_-]/g, "");
360
+ const name = decodeURIComponent(compGetMatch[1]).replace(/[^a-zA-Z0-9_-]/g, "");
301
361
  if (!name) {
302
362
  res.writeHead(400, CORS_HEADERS);
303
363
  res.end("Invalid name");
304
364
  return;
305
365
  }
306
366
  const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
307
- const filePath = path2.join(compositionsDir, `${name}.tsx`);
308
- if (!fs2.existsSync(filePath)) {
309
- res.writeHead(404, CORS_HEADERS);
310
- res.end("Not found");
367
+ const savedPath = path2.join(compositionsDir, `${name}.tsx`);
368
+ if (fs2.existsSync(savedPath)) {
369
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
370
+ res.end(fs2.readFileSync(savedPath, "utf-8"));
371
+ return;
372
+ }
373
+ const projectComps = findProjectCompositions(dir);
374
+ const found = projectComps.find((c2) => c2.name === name);
375
+ if (found) {
376
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
377
+ res.end(fs2.readFileSync(found.absolutePath, "utf-8"));
311
378
  return;
312
379
  }
313
- const content = fs2.readFileSync(filePath, "utf-8");
314
- res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
315
- res.end(content);
380
+ res.writeHead(404, CORS_HEADERS);
381
+ res.end("Not found");
316
382
  return;
317
383
  }
318
384
  const compPostMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
@@ -334,6 +400,241 @@ async function devCommand(options) {
334
400
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
335
401
  res.end(JSON.stringify({ ok: true, path: filePath }));
336
402
  });
403
+ req.on("error", () => {
404
+ res.writeHead(500, CORS_HEADERS);
405
+ res.end("Upload error");
406
+ });
407
+ return;
408
+ }
409
+ if (pathname === "/api/ai-edit" && req.method === "POST") {
410
+ const bodyChunks = [];
411
+ req.on("data", (c2) => bodyChunks.push(c2));
412
+ req.on("end", async () => {
413
+ let parsed;
414
+ try {
415
+ parsed = JSON.parse(Buffer.concat(bodyChunks).toString("utf-8"));
416
+ } catch {
417
+ res.writeHead(400, CORS_HEADERS);
418
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
419
+ return;
420
+ }
421
+ const { code, instruction, context } = parsed;
422
+ const prompt2 = `You are editing a Remotion TSX composition for video production.
423
+
424
+ SANDBOX RULES \u2014 strictly followed:
425
+ - Only 'react' and 'remotion' can be imported
426
+ - From remotion: AbsoluteFill, Img, Audio, OffthreadVideo, Sequence, useCurrentFrame, useVideoConfig, interpolate, spring
427
+ - Use inline styles only (no className / CSS)
428
+ - Always export a default function as the composition
429
+
430
+ PROPS injected by the player at runtime:
431
+ images \u2014 Array<{ url: string; name: string }> (the chunk's images, 16:9)
432
+ narrationText \u2014 string | null
433
+ masterAudioUrl \u2014 string | null
434
+ startSec \u2014 number (offset into master audio where this chunk begins)
435
+ fps \u2014 number (always 30)
436
+
437
+ Chunk context:
438
+ title: ${context?.chunkTitle ?? ""}
439
+ narration: ${(context?.narrationText ?? "").slice(0, 500)}
440
+ images: ${context?.imageCount ?? 0} horizontal images
441
+ duration: ~${(context?.durationSec ?? 0).toFixed(1)}s
442
+
443
+ Current composition:
444
+ ${code}
445
+
446
+ User instruction: ${instruction}
447
+
448
+ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
449
+ const tmpFile = path2.join(os2.tmpdir(), `forge-ai-${Date.now()}.txt`);
450
+ fs2.writeFileSync(tmpFile, prompt2, "utf-8");
451
+ const cleanup = () => {
452
+ if (fs2.existsSync(tmpFile)) fs2.unlinkSync(tmpFile);
453
+ };
454
+ const respond = (code2, model) => {
455
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
456
+ res.end(JSON.stringify({ code: code2, model }));
457
+ };
458
+ try {
459
+ const { stdout } = await exec(
460
+ `cat "${tmpFile}" | claude -p --model claude-sonnet-4-6 --no-session-persistence`,
461
+ { maxBuffer: 4 * 1024 * 1024, timeout: 9e4 }
462
+ );
463
+ const cleaned = stripFences(stdout.trim());
464
+ if (cleaned) {
465
+ cleanup();
466
+ respond(cleaned, "claude-sonnet-4-6 (CLI)");
467
+ return;
468
+ }
469
+ } catch {
470
+ log.warn("Claude CLI unavailable \u2014 trying Codex CLI...");
471
+ }
472
+ try {
473
+ const { stdout } = await exec(
474
+ `codex -q ${JSON.stringify(prompt2)}`,
475
+ { maxBuffer: 4 * 1024 * 1024, timeout: 9e4 }
476
+ );
477
+ const cleaned = stripFences(stdout.trim());
478
+ if (cleaned && cleaned.includes("export default")) {
479
+ cleanup();
480
+ respond(cleaned, "gpt-5.4 (codex CLI)");
481
+ return;
482
+ }
483
+ } catch {
484
+ log.warn("Codex CLI unavailable \u2014 trying API keys...");
485
+ }
486
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
487
+ if (anthropicKey) {
488
+ try {
489
+ const aResp = await fetch("https://api.anthropic.com/v1/messages", {
490
+ method: "POST",
491
+ headers: {
492
+ "x-api-key": anthropicKey,
493
+ "anthropic-version": "2023-06-01",
494
+ "content-type": "application/json"
495
+ },
496
+ body: JSON.stringify({
497
+ model: "claude-sonnet-4-6",
498
+ max_tokens: 4096,
499
+ messages: [{ role: "user", content: prompt2 }]
500
+ })
501
+ });
502
+ if (aResp.ok) {
503
+ const aData = await aResp.json();
504
+ const cleaned = stripFences((aData.content?.[0]?.text ?? "").trim());
505
+ if (cleaned) {
506
+ cleanup();
507
+ respond(cleaned, "claude-sonnet-4-6 (API)");
508
+ return;
509
+ }
510
+ }
511
+ } catch {
512
+ log.warn("Anthropic API failed \u2014 trying OpenAI API...");
513
+ }
514
+ }
515
+ const openAiKey = process.env.OPENAI_API_KEY;
516
+ if (openAiKey) {
517
+ try {
518
+ const oResp = await fetch("https://api.openai.com/v1/chat/completions", {
519
+ method: "POST",
520
+ headers: { "Authorization": `Bearer ${openAiKey}`, "Content-Type": "application/json" },
521
+ body: JSON.stringify({
522
+ model: "gpt-5.4",
523
+ max_tokens: 4096,
524
+ messages: [{ role: "user", content: prompt2 }]
525
+ })
526
+ });
527
+ if (oResp.ok) {
528
+ const oData = await oResp.json();
529
+ const cleaned = stripFences((oData.choices?.[0]?.message?.content ?? "").trim());
530
+ if (cleaned) {
531
+ cleanup();
532
+ respond(cleaned, "gpt-5.4 (API)");
533
+ return;
534
+ }
535
+ }
536
+ } catch {
537
+ log.warn("OpenAI API failed.");
538
+ }
539
+ }
540
+ cleanup();
541
+ res.writeHead(500, CORS_HEADERS);
542
+ res.end(JSON.stringify({
543
+ error: [
544
+ "No AI provider succeeded. Configure at least one:",
545
+ " \u2022 Install Claude CLI: https://claude.ai/code (free with Claude subscription)",
546
+ " \u2022 Install Codex CLI: npm install -g @openai/codex",
547
+ " \u2022 Set ANTHROPIC_API_KEY in your shell",
548
+ " \u2022 Set OPENAI_API_KEY in your shell"
549
+ ].join("\n")
550
+ }));
551
+ });
552
+ req.on("error", () => {
553
+ res.writeHead(500, CORS_HEADERS);
554
+ res.end("Request error");
555
+ });
556
+ return;
557
+ }
558
+ if (pathname === "/api/chunk-compositions") {
559
+ const compPath = path2.join(dir, "chunk-compositions.json");
560
+ if (!fs2.existsSync(compPath)) {
561
+ res.writeHead(404, CORS_HEADERS);
562
+ res.end("chunk-compositions.json not found");
563
+ return;
564
+ }
565
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
566
+ res.end(fs2.readFileSync(compPath));
567
+ return;
568
+ }
569
+ const listMatch = pathname.match(/^\/api\/list\/(.+)$/);
570
+ if (listMatch) {
571
+ const relPath = decodeURIComponent(listMatch[1]);
572
+ const fullPath = path2.resolve(path2.join(dir, relPath));
573
+ if (!fullPath.startsWith(path2.resolve(dir))) {
574
+ res.writeHead(403, CORS_HEADERS);
575
+ res.end("Forbidden");
576
+ return;
577
+ }
578
+ if (!fs2.existsSync(fullPath) || !fs2.statSync(fullPath).isDirectory()) {
579
+ res.writeHead(404, CORS_HEADERS);
580
+ res.end("Not a directory");
581
+ return;
582
+ }
583
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
584
+ const baseUrl = `http://localhost:${port}`;
585
+ const files = fs2.readdirSync(fullPath).filter((f) => !f.startsWith(".") && IMAGE_EXTS.has(path2.extname(f).toLowerCase())).filter((f) => fs2.statSync(path2.join(fullPath, f)).isFile()).sort().map((f) => {
586
+ const normalized = relPath.endsWith("/") ? relPath : relPath + "/";
587
+ const url2 = normalized.startsWith("public/") ? `${baseUrl}/api/public/${normalized.slice("public/".length)}${f}` : `${baseUrl}/api/file/${normalized}${f}`;
588
+ return { name: f, url: url2 };
589
+ });
590
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
591
+ res.end(JSON.stringify(files));
592
+ return;
593
+ }
594
+ const publicFileMatch = pathname.match(/^\/api\/public\/(.+)$/);
595
+ if (publicFileMatch) {
596
+ const filePath = decodeURIComponent(publicFileMatch[1]);
597
+ const publicDir = path2.join(dir, "public");
598
+ if (!fs2.existsSync(publicDir)) {
599
+ res.writeHead(404, CORS_HEADERS);
600
+ res.end("No public directory");
601
+ return;
602
+ }
603
+ const fullPath = path2.resolve(path2.join(publicDir, filePath));
604
+ if (!fullPath.startsWith(path2.resolve(publicDir))) {
605
+ res.writeHead(403, CORS_HEADERS);
606
+ res.end("Forbidden");
607
+ return;
608
+ }
609
+ if (!fs2.existsSync(fullPath)) {
610
+ res.writeHead(404, CORS_HEADERS);
611
+ res.end("Not found");
612
+ return;
613
+ }
614
+ const stat = fs2.statSync(fullPath);
615
+ const mime = getMime(fullPath);
616
+ const range = req.headers.range;
617
+ if (range && (mime.startsWith("audio/") || mime.startsWith("video/"))) {
618
+ const parts = range.replace(/bytes=/, "").split("-");
619
+ const start = parseInt(parts[0], 10);
620
+ const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
621
+ res.writeHead(206, {
622
+ ...CORS_HEADERS,
623
+ "Content-Range": `bytes ${start}-${end}/${stat.size}`,
624
+ "Accept-Ranges": "bytes",
625
+ "Content-Length": end - start + 1,
626
+ "Content-Type": mime
627
+ });
628
+ fs2.createReadStream(fullPath, { start, end }).pipe(res);
629
+ return;
630
+ }
631
+ res.writeHead(200, {
632
+ ...CORS_HEADERS,
633
+ "Content-Type": mime,
634
+ "Content-Length": stat.size,
635
+ "Cache-Control": "public, max-age=3600"
636
+ });
637
+ fs2.createReadStream(fullPath).pipe(res);
337
638
  return;
338
639
  }
339
640
  const fileMatch = pathname.match(/^\/api\/file\/(.+)$/);
@@ -375,11 +676,73 @@ async function devCommand(options) {
375
676
  fs2.createReadStream(fullPath).pipe(res);
376
677
  return;
377
678
  }
679
+ const uploadMatch = pathname.match(/^\/api\/upload\/(.+)$/);
680
+ if (uploadMatch && req.method === "POST") {
681
+ const relDir = decodeURIComponent(uploadMatch[1]);
682
+ const projectDir = path2.resolve(dir);
683
+ const fullDir = path2.resolve(path2.join(dir, relDir));
684
+ if (!fullDir.startsWith(projectDir)) {
685
+ res.writeHead(400, CORS_HEADERS);
686
+ res.end(JSON.stringify({ error: "path traversal" }));
687
+ return;
688
+ }
689
+ try {
690
+ const contentType = req.headers["content-type"] ?? "";
691
+ const boundaryM = contentType.match(/boundary=([^\s;]+)/);
692
+ if (!boundaryM) {
693
+ res.writeHead(400, CORS_HEADERS);
694
+ res.end(JSON.stringify({ error: "no boundary" }));
695
+ return;
696
+ }
697
+ const boundary = boundaryM[1];
698
+ const chunks = [];
699
+ await new Promise((resolve2, reject) => {
700
+ req.on("data", (c2) => chunks.push(Buffer.from(c2)));
701
+ req.on("end", resolve2);
702
+ req.on("error", reject);
703
+ });
704
+ const body = Buffer.concat(chunks);
705
+ const headerEnd = body.indexOf(Buffer.from("\r\n\r\n"));
706
+ if (headerEnd === -1) {
707
+ res.writeHead(400, CORS_HEADERS);
708
+ res.end(JSON.stringify({ error: "bad multipart" }));
709
+ return;
710
+ }
711
+ const headerSection = body.slice(0, headerEnd).toString();
712
+ const filenameM = headerSection.match(/filename="([^"]+)"/);
713
+ if (!filenameM) {
714
+ res.writeHead(400, CORS_HEADERS);
715
+ res.end(JSON.stringify({ error: "no filename" }));
716
+ return;
717
+ }
718
+ const filename = path2.basename(filenameM[1]);
719
+ const dataStart = headerEnd + 4;
720
+ const endMarker = Buffer.from(`\r
721
+ --${boundary}--`);
722
+ const endIdx = body.indexOf(endMarker, dataStart);
723
+ const data = endIdx !== -1 ? body.slice(dataStart, endIdx) : body.slice(dataStart);
724
+ if (!fs2.existsSync(fullDir)) fs2.mkdirSync(fullDir, { recursive: true });
725
+ const writePath = path2.resolve(path2.join(fullDir, filename));
726
+ if (!writePath.startsWith(projectDir)) {
727
+ res.writeHead(400, CORS_HEADERS);
728
+ res.end(JSON.stringify({ error: "path traversal" }));
729
+ return;
730
+ }
731
+ fs2.writeFileSync(writePath, data);
732
+ const relativePath = path2.relative(dir, writePath).replace(/\\/g, "/");
733
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
734
+ res.end(JSON.stringify({ path: relativePath }));
735
+ } catch (e) {
736
+ res.writeHead(500, CORS_HEADERS);
737
+ res.end(JSON.stringify({ error: e.message }));
738
+ }
739
+ return;
740
+ }
378
741
  res.writeHead(404, CORS_HEADERS);
379
742
  res.end(JSON.stringify({ error: "Not found" }));
380
743
  });
381
744
  server.listen(port, () => {
382
- const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/assets`;
745
+ const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/pipeline`;
383
746
  console.log("");
384
747
  log.success("Running");
385
748
  console.log("");
@@ -392,6 +755,30 @@ async function devCommand(options) {
392
755
  log.info("Opening browser...");
393
756
  openBrowser(webUrl);
394
757
  }
758
+ const aiMethods = [];
759
+ try {
760
+ execSync("which claude", { stdio: "ignore" });
761
+ aiMethods.push("Claude CLI \u2192 claude-sonnet-4-6");
762
+ } catch {
763
+ }
764
+ try {
765
+ execSync("which codex", { stdio: "ignore" });
766
+ aiMethods.push("Codex CLI \u2192 gpt-5.4");
767
+ } catch {
768
+ }
769
+ if (process.env.ANTHROPIC_API_KEY) aiMethods.push("Anthropic API \u2192 claude-sonnet-4-6");
770
+ if (process.env.OPENAI_API_KEY) aiMethods.push("OpenAI API \u2192 gpt-5.4");
771
+ console.log("");
772
+ if (aiMethods.length > 0) {
773
+ console.log(" AI edit chain (tried in order):");
774
+ aiMethods.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
775
+ } else {
776
+ console.log(" No AI providers found. To enable composition AI editing:");
777
+ console.log(" Install Claude CLI: https://claude.ai/code (free with subscription)");
778
+ console.log(" Install Codex CLI: npm install -g @openai/codex");
779
+ console.log(" Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in your shell");
780
+ }
781
+ console.log("");
395
782
  console.log(" Ctrl+C to stop");
396
783
  console.log("");
397
784
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {