storyforge 0.1.2 → 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 +441 -3
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  // src/commands/dev.ts
4
4
  import * as fs2 from "fs";
5
+ import * as os2 from "os";
5
6
  import * as path2 from "path";
6
7
  import * as http from "http";
7
- import { execFile } from "child_process";
8
+ import { execFile, exec as execCb, execSync } from "child_process";
9
+ import { promisify } from "util";
8
10
 
9
11
  // src/utils/log.ts
10
12
  var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
@@ -41,6 +43,7 @@ function loadCredentials() {
41
43
  }
42
44
 
43
45
  // src/commands/dev.ts
46
+ var exec = promisify(execCb);
44
47
  var PORT = 4444;
45
48
  var WEB_URL = "https://forge.algo-thinker.com";
46
49
  function getApiConfig() {
@@ -183,6 +186,54 @@ async function findOrCreateProject(dir) {
183
186
  log.success(`Saved ${metaPath(dir)}`);
184
187
  return meta;
185
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
+ }
186
237
  function getMime(file) {
187
238
  const ext = path2.extname(file).toLowerCase();
188
239
  const types = {
@@ -218,6 +269,9 @@ function collectFiles(dir, base = "") {
218
269
  }
219
270
  return result;
220
271
  }
272
+ function stripFences(text) {
273
+ return text.replace(/^```(?:tsx?|jsx?|typescript|javascript)?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
274
+ }
221
275
  function openBrowser(url) {
222
276
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
223
277
  execFile(cmd, [url], (err) => {
@@ -272,9 +326,15 @@ async function devCommand(options) {
272
326
  return;
273
327
  }
274
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()));
275
335
  const assets = {
276
336
  audio: collectFiles(path2.join(dir, "audio"), "audio"),
277
- imagesH: collectFiles(path2.join(dir, "images-horizontal"), "images-horizontal"),
337
+ imagesH: [...legacyH, ...geminiScenes],
278
338
  imagesV: collectFiles(path2.join(dir, "images-vertical"), "images-vertical"),
279
339
  clips: collectFiles(path2.join(dir, "clips"), "clips"),
280
340
  scripts: collectFiles(path2.join(dir, "scripts"), "scripts"),
@@ -285,6 +345,298 @@ async function devCommand(options) {
285
345
  res.end(JSON.stringify(assets));
286
346
  return;
287
347
  }
348
+ if (pathname === "/api/compositions") {
349
+ const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
350
+ fs2.mkdirSync(compositionsDir, { recursive: true });
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);
354
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
355
+ res.end(JSON.stringify([...saved, ...project]));
356
+ return;
357
+ }
358
+ const compGetMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
359
+ if (req.method === "GET" && compGetMatch) {
360
+ const name = decodeURIComponent(compGetMatch[1]).replace(/[^a-zA-Z0-9_-]/g, "");
361
+ if (!name) {
362
+ res.writeHead(400, CORS_HEADERS);
363
+ res.end("Invalid name");
364
+ return;
365
+ }
366
+ const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
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"));
378
+ return;
379
+ }
380
+ res.writeHead(404, CORS_HEADERS);
381
+ res.end("Not found");
382
+ return;
383
+ }
384
+ const compPostMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
385
+ if (req.method === "POST" && compPostMatch) {
386
+ const name = compPostMatch[1].replace(/[^a-zA-Z0-9_-]/g, "");
387
+ if (!name) {
388
+ res.writeHead(400, CORS_HEADERS);
389
+ res.end("Invalid name");
390
+ return;
391
+ }
392
+ const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
393
+ fs2.mkdirSync(compositionsDir, { recursive: true });
394
+ const filePath = path2.join(compositionsDir, `${name}.tsx`);
395
+ const chunks = [];
396
+ req.on("data", (chunk) => chunks.push(chunk));
397
+ req.on("end", () => {
398
+ const body = Buffer.concat(chunks).toString("utf-8");
399
+ fs2.writeFileSync(filePath, body, "utf-8");
400
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
401
+ res.end(JSON.stringify({ ok: true, path: filePath }));
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);
638
+ return;
639
+ }
288
640
  const fileMatch = pathname.match(/^\/api\/file\/(.+)$/);
289
641
  if (fileMatch) {
290
642
  const filePath = decodeURIComponent(fileMatch[1]);
@@ -324,11 +676,73 @@ async function devCommand(options) {
324
676
  fs2.createReadStream(fullPath).pipe(res);
325
677
  return;
326
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
+ }
327
741
  res.writeHead(404, CORS_HEADERS);
328
742
  res.end(JSON.stringify({ error: "Not found" }));
329
743
  });
330
744
  server.listen(port, () => {
331
- const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/assets`;
745
+ const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/pipeline`;
332
746
  console.log("");
333
747
  log.success("Running");
334
748
  console.log("");
@@ -341,6 +755,30 @@ async function devCommand(options) {
341
755
  log.info("Opening browser...");
342
756
  openBrowser(webUrl);
343
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("");
344
782
  console.log(" Ctrl+C to stop");
345
783
  console.log("");
346
784
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.1.2",
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": {