@mevdragon/vidfarm-devcli 0.3.5 → 0.4.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.
package/README.md CHANGED
@@ -48,4 +48,6 @@ npx -y @mevdragon/vidfarm-devcli <template_id>
48
48
 
49
49
  Flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or `VIDFARM_API_KEY`), `--share`, `--refetch`. Use `dev-serve` as an explicit subcommand to skip the fetch step and just serve an existing directory.
50
50
 
51
+ Beyond the local editor loop, the devcli also wraps the **entire director REST flow** as 1:1 commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render`, `approve`, `schedule`, `posts`, `upload`/`download`, plus a raw `vidfarm api <METHOD> <path>` passthrough. Each maps to exactly one REST route and prints the prod frontend URL to open. Run `vidfarm --help` for the full surface, or see `SKILL.director.md` § "`vidfarm-devcli` — full command surface".
52
+
51
53
  See `SKILL.platform.md` § "Local editor dev loop" for the request-rewrite / SSE reload flow.
package/SKILL.director.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: vidfarm-director
3
- description: Use Vidfarm as a director. Fork a template into a composition, edit it in the Trackpad Editor (timeline-based like Premiere/DaVinci), auto-decompose source video into scenes, publish to MP4, and share. Includes login, provider keys, discovery, publishing, versioning, and billing.
3
+ description: Use Vidfarm as a director. Browse/add inspiration videos, fork a template into a composition, edit it in the Trackpad Editor (timeline-based like Premiere/DaVinci), auto-decompose source video into scenes, render to MP4, approve into a shareable post, and schedule it. Includes login, provider keys, discovery, versioning, uploads/downloads, and billing. Every step is available as a raw REST route and as a 1:1 `vidfarm-devcli` command.
4
4
  ---
5
5
 
6
6
  # Vidfarm Director
@@ -82,6 +82,16 @@ Each template exposes a public preview:
82
82
 
83
83
  Directors don't edit templates directly. They fork.
84
84
 
85
+ ### Add your own source (inspiration)
86
+
87
+ To bring a new viral video into the catalog as a **private** template you own, ingest its social URL:
88
+
89
+ - `POST /discover/templates { source_url, tagline?, notes? }` — accepts TikTok / YouTube / Instagram / Twitter-X URLs only. The video download runs async; the response is the inspiration record (`202`, `status: "processing"`). The card finalizes into a private `template_...` the next time `GET /discover/feed` is polled (the feed poll doubles as the completion check).
90
+ - `DELETE /discover/templates/:entryId` — remove a private inspiration/template you own (accepts either the `inspiration_...` or minted `template_...` id).
91
+ - `POST /api/v1/inspirations/:inspirationId/decompose { user_prompt? }` — AI-decompose an inspiration's downloaded video into scenes (requires a saved provider key; same 120s source cap as auto-decompose).
92
+
93
+ devcli: `vidfarm inspiration-add <url>`, `vidfarm inspiration-rm <id>`, `vidfarm inspiration-decompose <id>`, and `vidfarm discover` to browse.
94
+
85
95
  ## Fork a template
86
96
 
87
97
  ```
@@ -193,9 +203,9 @@ Use it whenever you need to know what the video says or shows: writing/translati
193
203
  - **Editor chat (frontend AI)** exposes this as the optional `video_context` tool — the copilot calls it on demand.
194
204
  - **Desktop agents (Claude Code / Codex)**: `vidfarm edit <template_id>` syncs the same payload to `video-context.json` on disk next to `composition.html` (use `--refetch` to refresh it after a new decompose), or fetch the route directly.
195
205
 
196
- ## Publish
206
+ ## Render (publish to MP4)
197
207
 
198
- Publishing renders the fork's current working state to an MP4 using shared Remotion Lambda. The route is `/export`:
208
+ Rendering publishes the fork's current working state to an MP4 using shared Remotion Lambda. In the Trackpad Editor this is the **Render** button; the REST route is still named `/export` (the route name predates the UI rename — treat "Render", "publish", and `/export` as the same operation). The devcli exposes it as `vidfarm render <forkId> [--wait]`.
199
209
 
200
210
  ```
201
211
  POST /api/v1/compositions/:forkId/export
@@ -214,6 +224,41 @@ Response includes `{ ok, renderId, status: "RUNNING" | "SUCCEEDED" | "FAILED", p
214
224
 
215
225
  Every publish creates an immutable version snapshot at `versions/<N>/composition.html` and `versions/<N>/composition.json`.
216
226
 
227
+ ## Approve a finished post
228
+
229
+ A render produces a bare MP4 URL. **Approving** wraps that MP4 (plus caption, title, pinned comment, and any carousel slides) into a shareable preview page — the phone-mockup page a human opens to review and copy the post.
230
+
231
+ ```
232
+ POST /api/v1/approved/posts
233
+ Content-Type: application/json
234
+
235
+ { "caption": "required", "title": "optional", "pinned_comment": "optional", "tracer": "optional",
236
+ "media": [ { "url": "https://.../output.mp4", "kind": "video", "role": "primary" } ] }
237
+ ```
238
+
239
+ `caption` is required. `media[]` items take `{ url, kind?: "image"|"video"|..., role?: "primary"|"slide"|... }`. Response (`201`) is the approved post including **`share_url`** — the prod frontend page for previewing/sharing. Surface that URL to the user; it is the headline output of this step.
240
+
241
+ - `GET /api/v1/approved/posts` — list your approved posts
242
+ - `GET /api/v1/approved/posts/:postId` — read one (returns `share_url`, `download_zip_url`)
243
+
244
+ devcli: `vidfarm approve --video <mp4-url> --caption "..."` prints the `share_url` as a first-class openable link; `vidfarm posts` lists, `vidfarm post <id>` reads one.
245
+
246
+ ## Schedule a post
247
+
248
+ Schedule an approved post to a connected destination channel (FlockPoster social account or email) at one ISO timestamp:
249
+
250
+ ```
251
+ POST /api/v1/approved/posts/:postId/schedules
252
+ Content-Type: application/json
253
+
254
+ { "destination_type": "flockposter" | "email", "destination_id": "<channel or email>",
255
+ "scheduled_at": "2026-07-10T14:00:00Z", "timezone": "America/New_York", "additional_notes": "optional" }
256
+ ```
257
+
258
+ Minimum 10-minute lead time. Response (`201`) is the schedule record. Browse existing schedules with `GET /api/v1/approved/posts/:postId/schedules`.
259
+
260
+ devcli: `vidfarm schedule <postId> --at <iso> --to <destinationId> [--type flockposter|email]`, and `vidfarm schedules <postId>` to browse.
261
+
217
262
  ## Version history
218
263
 
219
264
  ```
@@ -300,17 +345,65 @@ For agents that operate Vidfarm headlessly, the typical loop is:
300
345
 
301
346
  Send a stable `tracer` on export so retries are traceable and filterable in job history — but note that submission is **not idempotent**: every POST creates a new job (and a new charge) even with the same tracer. Do not blind-retry expensive submissions; check the render status first.
302
347
 
303
- ## Local dev loop with `vidfarm-devcli`
348
+ ## `vidfarm-devcli` — full command surface
349
+
350
+ `@mevdragon/vidfarm-devcli` wraps the **entire director REST flow** as CLI commands. It is a thin shell over the REST API, not a second implementation: **every named command maps 1:1 to exactly one REST route**, and all it adds is fork auto-resolution, the `vidfarm-api-key` header, prod frontend links in the output, render-poll waiting, and the multi-step upload/download flows. Auth via `--api-key <key>` or `VIDFARM_API_KEY`.
351
+
352
+ **Raw REST vs devcli — your choice.** If you want full control, call the REST API directly (or use the raw passthrough `vidfarm api <METHOD> <path> [--data <json>] [--query k=v]`). If you want ergonomics + openable frontend URLs, use the named commands. They are interchangeable — pick per call.
353
+
354
+ | Command | REST route | Flow step |
355
+ |---|---|---|
356
+ | `vidfarm discover` | `GET /discover/feed` | browse inspiration/templates |
357
+ | `vidfarm inspiration-add <url>` | `POST /discover/templates` | add your own source |
358
+ | `vidfarm inspiration-rm <id>` | `DELETE /discover/templates/:id` | remove a private one |
359
+ | `vidfarm inspiration-decompose <id>` | `POST /api/v1/inspirations/:id/decompose` | AI-decompose an inspiration |
360
+ | `vidfarm fork <template_id>` | `POST /api/v1/compositions` | fork a template |
361
+ | `vidfarm decompose <forkId>` | `POST .../compositions/:forkId/auto-decompose` | split source into scenes |
362
+ | `vidfarm ghostcut <forkId>` | `GET .../compositions/:forkId/ghostcut` | subtitle-removal status |
363
+ | `vidfarm snapshot <forkId>` | `POST .../compositions/:forkId/versions` | save composition fork version |
364
+ | `vidfarm versions <forkId>` | `GET .../compositions/:forkId/versions` | list versions |
365
+ | `vidfarm render <forkId> [--wait]` | `POST .../compositions/:forkId/export` | render video to MP4 |
366
+ | `vidfarm render-status <forkId> <renderId>` | `GET .../compositions/:forkId/renders/:renderId` | poll a render |
367
+ | `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
368
+ | `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
369
+ | `vidfarm share-link <forkId>` | `POST .../compositions/:forkId/share-links` | mint a share URL |
370
+ | `vidfarm approve --video <url> --caption "…"` | `POST /api/v1/approved/posts` | approve post (prints `share_url`) |
371
+ | `vidfarm posts` / `vidfarm post <id>` | `GET /api/v1/approved/posts[/:id]` | browse approved posts |
372
+ | `vidfarm schedule <postId> --at <iso> --to <dest>` | `POST /api/v1/approved/posts/:postId/schedules` | schedule a post |
373
+ | `vidfarm schedules <postId>` | `GET /api/v1/approved/posts/:postId/schedules` | browse scheduled posts |
374
+ | `vidfarm login <email>` / `vidfarm verify <email> <code>` | `POST /api/v1/user/request-otp` · `verify-otp` | get an API key |
375
+ | `vidfarm whoami` | `GET /api/v1/user/me` | who am I |
376
+ | `vidfarm provider-keys` / `vidfarm add-provider-key <p> <secret>` | `GET`·`POST /api/v1/user/me/provider-keys` | manage AI keys |
377
+ | `vidfarm upload <file>` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL |
378
+ | `vidfarm download <url> [dest]` | (streams any URL to disk) | download media |
379
+
380
+ **Frontend URLs are first-class output.** Every command that has a human-openable page (editor, discover, approved-post preview, share link) prints that prod frontend URL as a highlighted line. `render --wait` polls to completion and prints the final MP4 URL; `approve` prints the approved-post `share_url`. Add `--json` to any command for pure JSON (agent-friendly, no banners).
381
+
382
+ **Uploads/downloads live in the devcli** because they are genuine multi-step / streaming flows: `upload` posts the file as multipart to the temporary-files route and prints the durable URL to drop into a composition or approved post; `download` streams any Vidfarm/media URL to disk.
383
+
384
+ ## Local dev loop (edit on disk)
304
385
 
305
386
  For iterating on a composition on disk (e.g. Claude Code editing HTML directly), pair the hosted editor with a local composition directory:
306
387
 
307
388
  ```bash
308
389
  npx -y @mevdragon/vidfarm-devcli <template_id>
309
- # → downloads composition.html/json + manifest.json into .vidfarm/<template_id>/
390
+ # → downloads composition.html/json + manifest.json + video-context.json into .vidfarm/<template_id>/
391
+ # → downloads the videos + per-scene frames for local analysis (see below)
310
392
  # → starts dev-serve on http://localhost:4321
311
393
  # → prints https://vidfarm.cc/editor/<template_id>?fork=<forkId>&dev=http%3A%2F%2Flocalhost%3A4321
312
394
  ```
313
395
 
396
+ ### Local media for analysis (`sources.json`, videos, frames)
397
+
398
+ So a coding agent can *see* what it is editing — not just read URLs — `vidfarm edit` also drops the actual media next to the composition:
399
+
400
+ - **`original.mp4`** — the raw source video, before any processing.
401
+ - **`source.mp4`** — the video the composition timeline actually renders from. This differs from `original.mp4` only when the fork was ghostcut/subtitle-removed; otherwise `sources.json` marks them `same_as_decomposed: true` and only `source.mp4` is written.
402
+ - **`frames/<scene>.png`** — one still per timeline scene, sampled at the scene midpoint from `source.mp4`. Open these as images to analyze what each scene shows. Requires `ffmpeg` (bundled via `ffmpeg-static`); if unavailable the videos still download and frames are skipped.
403
+ - **`sources.json`** — the manifest tying it together: each video's role/url/local path/byte size, the total duration, and per-scene `{ slug, start, duration, label, description, transcript_excerpt, frame }`.
404
+
405
+ This is best-effort and never blocks the editor session. Re-run with `--refetch` to refresh the media after a new decompose or ghostcut. For deeper text grounding (full transcript, per-scene visual descriptions) read `video-context.json` — the media files complement it with the pixels.
406
+
314
407
  Open the printed URL in your browser. An orange **LOCAL DEV MODE** banner pins to the top with an Exit button. All fork API fetches now route to your local dir instead of the deployed API; disk edits push an SSE reload event and the editor re-fetches automatically.
315
408
 
316
409
  Flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or set `VIDFARM_API_KEY`), `--share`, `--refetch`. For a fork that already has composition files on disk (or that you've populated manually), use `vidfarm-devcli dev-serve --dir ./my-fork --port 4321` to skip the fetch step.
package/SKILL.platform.md CHANGED
@@ -184,7 +184,7 @@ The editor is a single monolithic React component (~9k lines). It builds cleanly
184
184
 
185
185
  Publish flow:
186
186
 
187
- 1. Client `POST /api/v1/compositions/:forkId/export` (the route is named `export`; the concept is "publish")
187
+ 1. Client `POST /api/v1/compositions/:forkId/export` (the route is named `export`; the editor button and devcli command are both **Render**; treat "render", "publish", and `/export` as the same operation)
188
188
  2. API stamps `data-composition-id="<forkId>@v<N>"` into the composition HTML, PUTs the stamped HTML to working, and reads composition.json
189
189
  3. API resolves the caller's provider keys (preflight check for required models)
190
190
  4. API creates a `job` record and invokes the `hyperframes_render` primitive Lambda
@@ -353,7 +353,7 @@ Legacy but still active:
353
353
  - Composition runtime script — `src/composition-runtime.ts`
354
354
  - Trackpad Editor — `demo/src/HyperframesStudioEditor.tsx`
355
355
  - Editor local-mode bootstrap — `demo/src/dev-mode.ts`
356
- - Devcli (`vidfarm-devcli`) — `src/cli.ts` (edit-mode fetch + banner), `src/dev-serve.ts` (raw HTTP shim)
356
+ - Devcli (`vidfarm-devcli`) — `src/cli.ts` (edit-mode fetch + banner, the REST-wrapper commands, and the shared `apiRequest()` helper), `src/dev-serve.ts` (raw HTTP shim)
357
357
  - Auto-decompose logic — `src/hyperframes/composition.ts` (time-slice), `src/services/hyperframes.ts` (smart)
358
358
  - Publish flow — `src/app.ts` `publishCompositionToRenderer()`
359
359
  - Ghostcut — `src/services/ghostcut.ts`
@@ -361,7 +361,7 @@ Legacy but still active:
361
361
 
362
362
  ## Local editor dev loop
363
363
 
364
- `@mevdragon/vidfarm-devcli` (`src/cli.ts` + `src/dev-serve.ts`) pairs a local directory of composition files with the hosted Trackpad Editor so Claude Code, Codex, or a human can edit `composition.html` on disk and see instant preview reload in the browser.
364
+ `@mevdragon/vidfarm-devcli` (`src/cli.ts` + `src/dev-serve.ts`) has two roles. First, it pairs a local directory of composition files with the hosted Trackpad Editor so Claude Code, Codex, or a human can edit `composition.html` on disk and see instant preview reload in the browser (the `edit`/`publish`/`dev-serve` commands, detailed below). Second, it wraps the **entire director REST flow** as 1:1 CLI commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render` (→ `/export`), `approve`, `schedule`, `posts`/`schedules`, `upload`/`download`, `login`/`whoami`/`provider-keys`, plus a raw `api <METHOD> <path>` passthrough. Every wrapper routes through a single `apiRequest()` helper (auth + host + JSON), so it never duplicates route logic; the command↔route table and the raw-REST-vs-devcli guidance live in `SKILL.director.md`. Each command prints its prod frontend URL (editor / discover / approved-post `share_url`) as a first-class output, and `--json` gives pure JSON for agents.
365
365
 
366
366
  ```bash
367
367
  # One-shot: fetch composition, start dev-serve, print editor URL.
package/dist/src/cli.js CHANGED
@@ -1,41 +1,98 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parseArgs } from "node:util";
5
+ import { spawnSync } from "node:child_process";
6
+ import { Readable } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
5
8
  // vidfarm-devcli — thin CLI that pairs a local composition directory with the
6
9
  // hosted Trackpad Editor at https://vidfarm.cc/editor/. Point it at a template
7
10
  // id, and it: resolves the user's fork, downloads the current composition,
8
11
  // starts a local dev-serve, and prints the editor URL to open in the browser.
9
12
  const DEFAULT_HOST = "https://vidfarm.cc";
10
13
  const DEFAULT_PORT = 4321;
11
- const HELP = `vidfarm-devcli local editor bridge for the Vidfarm Trackpad Editor
14
+ // Every command below is a thin wrapper over ONE Vidfarm REST call (the `→`
15
+ // line documents the exact route). The devcli adds nothing the raw API can't
16
+ // do — it just resolves your fork, sets the `vidfarm-api-key` header, prints
17
+ // the prod frontend URL to open, and handles the multi-step upload/download +
18
+ // render-polling flows that are awkward to do by hand. Prefer the raw REST API
19
+ // (or `vidfarm api <METHOD> <path>`) whenever you want full control; reach for
20
+ // the named commands when you want the ergonomics and the frontend links.
21
+ const HELP = `vidfarm-devcli — command-line bridge for the Vidfarm video studio
22
+
23
+ Every named command maps 1:1 to a single REST route (shown as → below). Use
24
+ raw REST (or \`vidfarm api\`) when you want control; use named commands for the
25
+ ergonomics + prod frontend links. Auth: --api-key <key> or VIDFARM_API_KEY.
12
26
 
13
27
  Usage:
14
- vidfarm-devcli <template_id> [options] Start local editor session
15
- vidfarm-devcli publish <template_id> [opts] Push local composition to your fork
16
- vidfarm-devcli dev-serve [options] Serve an existing local dir only
17
- vidfarm-devcli --help Show this help
28
+ vidfarm <template_id> [opts] Start local editor session (default)
29
+ vidfarm <command> [args] [opts]
30
+ vidfarm --help
18
31
 
19
- Options (edit mode):
20
- --port <n> Local dev-serve port (default: ${DEFAULT_PORT})
21
- --dir <path> Local composition directory (default: .vidfarm/<template_id>)
22
- --host <url> Vidfarm host to fetch from (default: ${DEFAULT_HOST})
23
- --fork <id> Fork id to fetch (default: auto-resolve for the current user)
32
+ Local editor loop:
33
+ <template_id> [opts] Fetch composition + media, dev-serve, print editor URL
34
+ publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
35
+ dev-serve Serve an existing local dir only (no fetch)
36
+
37
+ Discover & inspiration (browse the viral-video catalog, add your own source):
38
+ discover List the template/inspiration feed → GET /discover/feed
39
+ inspiration-add <url> Ingest a TikTok/YT/IG/X video as a template → POST /discover/templates
40
+ inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
41
+ inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
42
+
43
+ Composition lifecycle (fork → decompose → snapshot → render):
44
+ fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
45
+ decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
46
+ ghostcut <forkId> Read subtitle-removal status (non-billing) → GET .../compositions/:forkId/ghostcut
47
+ versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
48
+ snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
49
+ render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/export
50
+ render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
51
+ visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
52
+ clone <forkId> Clone a fork as a new starting point → POST .../compositions/:forkId/clone
53
+ share-link <forkId> Mint a share-token URL for a fork → POST .../compositions/:forkId/share-links
54
+
55
+ Approve & schedule (publish a finished MP4 as a shareable post):
56
+ approve Approve media into a preview/share page → POST /api/v1/approved/posts
57
+ posts List your approved posts → GET /api/v1/approved/posts
58
+ post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
59
+ schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
60
+ schedules <postId> Browse an approved post's schedules → GET /api/v1/approved/posts/:postId/schedules
61
+
62
+ Account:
63
+ login <email> Send an OTP code to email → POST /api/v1/user/request-otp
64
+ verify <email> <code> Verify the OTP, print a fresh API key → POST /api/v1/user/verify-otp
65
+ whoami Show the authenticated account → GET /api/v1/user/me
66
+ provider-keys List saved AI provider keys → GET /api/v1/user/me/provider-keys
67
+ add-provider-key <provider> <secret> Save an AI provider key → POST /api/v1/user/me/provider-keys
68
+
69
+ Files (multi-step flows the devcli handles for you):
70
+ upload <file> Upload a local file, print its durable URL → POST /api/v1/user/me/temporary-files/upload
71
+ download <url> [dest] Stream any Vidfarm/media URL to disk
72
+
73
+ Escape hatch — call ANY route directly:
74
+ api <METHOD> <path> Raw REST call with auth + pretty errors
75
+ --data <json> JSON request body (or --data-file <path>)
76
+ --query k=v Repeatable query param
77
+ Examples:
78
+ vidfarm api GET /discover/feed
79
+ vidfarm api POST /api/v1/compositions --data '{"template_id":"template_..."}'
80
+
81
+ Common options (any command):
82
+ --host <url> Vidfarm host (default: ${DEFAULT_HOST})
24
83
  --api-key <key> Vidfarm API key (or set VIDFARM_API_KEY)
25
84
  --share <token> Share token for a shared fork (public read)
26
- --refetch Overwrite local composition files with the latest remote copy
85
+ --json Print only the raw JSON response (agent-friendly)
27
86
 
28
- Options (publish mode):
87
+ Editor-mode options:
88
+ --port <n> Local dev-serve port (default: ${DEFAULT_PORT})
29
89
  --dir <path> Local composition directory (default: .vidfarm/<template_id>)
30
- --host <url> Vidfarm host to publish to (default: ${DEFAULT_HOST})
31
- --fork <id> Fork id to publish into (default: auto-resolve for the current user)
32
- --api-key <key> Vidfarm API key (or set VIDFARM_API_KEY) — required to write
33
- --message <text> Optional message attached to the published version snapshot
34
- --no-snapshot Only update the working copy; skip the version snapshot
90
+ --fork <id> Fork id (default: auto-resolve for the current user)
91
+ --refetch Overwrite local composition files with the latest remote copy
35
92
 
36
- Options (dev-serve mode):
37
- --dir <path> Directory to serve (default: current dir)
38
- --port <n> Port to listen on (default: ${DEFAULT_PORT})
93
+ Publish-mode options:
94
+ --message <text> Message attached to the published version snapshot
95
+ --no-snapshot Only update the working copy; skip the version snapshot
39
96
  `;
40
97
  void main().catch((error) => {
41
98
  console.error(error instanceof Error ? error.stack ?? error.message : String(error));
@@ -48,25 +105,218 @@ async function main() {
48
105
  return;
49
106
  }
50
107
  const command = argv[0];
51
- if (command === "dev-serve") {
52
- const { runDevServeCommand } = await import("./dev-serve.js");
53
- await runDevServeCommand(argv.slice(1));
54
- return;
108
+ const rest = argv.slice(1);
109
+ switch (command) {
110
+ case "dev-serve": {
111
+ const { runDevServeCommand } = await import("./dev-serve.js");
112
+ await runDevServeCommand(rest);
113
+ return;
114
+ }
115
+ case "edit":
116
+ await runEditCommand(rest);
117
+ return;
118
+ case "publish":
119
+ await runPublishCommand(rest);
120
+ return;
121
+ // Raw escape hatch + all the 1:1 REST wrappers.
122
+ case "api":
123
+ await runApiCommand(rest);
124
+ return;
125
+ case "discover":
126
+ await runDiscoverCommand(rest);
127
+ return;
128
+ case "inspiration-add":
129
+ await runInspirationAddCommand(rest);
130
+ return;
131
+ case "inspiration-rm":
132
+ await runInspirationRemoveCommand(rest);
133
+ return;
134
+ case "inspiration-decompose":
135
+ await runInspirationDecomposeCommand(rest);
136
+ return;
137
+ case "fork":
138
+ await runForkCommand(rest);
139
+ return;
140
+ case "decompose":
141
+ await runDecomposeCommand(rest);
142
+ return;
143
+ case "ghostcut":
144
+ await runGhostcutCommand(rest);
145
+ return;
146
+ case "versions":
147
+ await runVersionsCommand(rest);
148
+ return;
149
+ case "snapshot":
150
+ await runSnapshotCommand(rest);
151
+ return;
152
+ case "render":
153
+ await runRenderCommand(rest);
154
+ return;
155
+ case "render-status":
156
+ await runRenderStatusCommand(rest);
157
+ return;
158
+ case "visibility":
159
+ await runVisibilityCommand(rest);
160
+ return;
161
+ case "clone":
162
+ await runCloneCommand(rest);
163
+ return;
164
+ case "share-link":
165
+ await runShareLinkCommand(rest);
166
+ return;
167
+ case "approve":
168
+ await runApproveCommand(rest);
169
+ return;
170
+ case "posts":
171
+ await runPostsCommand(rest);
172
+ return;
173
+ case "post":
174
+ await runPostCommand(rest);
175
+ return;
176
+ case "schedule":
177
+ await runScheduleCommand(rest);
178
+ return;
179
+ case "schedules":
180
+ await runSchedulesCommand(rest);
181
+ return;
182
+ case "login":
183
+ await runLoginCommand(rest);
184
+ return;
185
+ case "verify":
186
+ await runVerifyCommand(rest);
187
+ return;
188
+ case "whoami":
189
+ await runWhoamiCommand(rest);
190
+ return;
191
+ case "provider-keys":
192
+ await runProviderKeysCommand(rest);
193
+ return;
194
+ case "add-provider-key":
195
+ await runAddProviderKeyCommand(rest);
196
+ return;
197
+ case "upload":
198
+ await runUploadCommand(rest);
199
+ return;
200
+ case "download":
201
+ await runDownloadCommand(rest);
202
+ return;
203
+ default:
204
+ if (!command.startsWith("-")) {
205
+ // Positional template id → default `edit` command.
206
+ await runEditCommand(argv);
207
+ return;
208
+ }
209
+ throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
55
210
  }
56
- if (command === "edit") {
57
- await runEditCommand(argv.slice(1));
58
- return;
211
+ }
212
+ // ── REST plumbing shared by every wrapper command ───────────────────────────
213
+ // One code path for auth + host + query + JSON. This is what makes the devcli a
214
+ // thin shell over the REST API rather than a second implementation: the named
215
+ // commands below just assemble a method/path/body and hand it here.
216
+ const FRONTEND = "\x1b[1m\x1b[36m"; // bold cyan — reserved for openable URLs
217
+ const BOLD = "\x1b[1m";
218
+ const DIM = "\x1b[2m";
219
+ const GREEN = "\x1b[32m";
220
+ const RED = "\x1b[31m";
221
+ const RESET = "\x1b[0m";
222
+ async function apiRequest(input) {
223
+ const url = new URL(input.path, input.host);
224
+ for (const [key, value] of Object.entries(input.query ?? {})) {
225
+ if (value !== undefined && value !== null && value !== "")
226
+ url.searchParams.set(key, value);
59
227
  }
60
- if (command === "publish") {
61
- await runPublishCommand(argv.slice(1));
62
- return;
228
+ const headers = { ...buildAuthHeaders(input.auth), accept: "application/json" };
229
+ let body;
230
+ if (input.body !== undefined) {
231
+ headers["content-type"] = "application/json";
232
+ body = JSON.stringify(input.body);
63
233
  }
64
- if (!command.startsWith("-")) {
65
- // Positional template id → default `edit` command.
66
- await runEditCommand(argv);
234
+ const res = await fetch(url, { method: input.method.toUpperCase(), headers, body });
235
+ const text = await res.text();
236
+ let json = null;
237
+ try {
238
+ json = text ? JSON.parse(text) : null;
239
+ }
240
+ catch {
241
+ json = null;
242
+ }
243
+ return { status: res.status, ok: res.ok, json, text };
244
+ }
245
+ // Named commands should fail loudly (unlike the read-tolerant edit fetch).
246
+ function assertApiOk(result, action) {
247
+ if (result.ok)
248
+ return;
249
+ const detail = result.json && typeof result.json === "object" && typeof result.json.error === "string"
250
+ ? ` — ${result.json.error}`
251
+ : result.text
252
+ ? ` — ${result.text.slice(0, 300)}`
253
+ : "";
254
+ const hint = result.status === 401 || result.status === 403
255
+ ? " Check --api-key / VIDFARM_API_KEY and that you own this resource."
256
+ : result.status === 402
257
+ ? " The account is not on a paid plan (or the wallet is empty)."
258
+ : result.status === 429
259
+ ? " Rate-limited; wait and retry."
260
+ : "";
261
+ throw new Error(`${action} failed: ${result.status}${detail}.${hint}`);
262
+ }
263
+ // Resolve host + auth + output mode shared by every wrapper command. Adding
264
+ // these to a parseArgs `options` block keeps the flags consistent everywhere.
265
+ function commonOptions() {
266
+ return {
267
+ host: { type: "string", default: DEFAULT_HOST },
268
+ "api-key": { type: "string" },
269
+ share: { type: "string" },
270
+ json: { type: "boolean", default: false }
271
+ };
272
+ }
273
+ function commonContext(values) {
274
+ return {
275
+ host: trimTrailingSlash(String(values.host ?? DEFAULT_HOST)),
276
+ auth: {
277
+ apiKey: values["api-key"] ?? process.env.VIDFARM_API_KEY,
278
+ shareToken: values.share
279
+ },
280
+ json: Boolean(values.json)
281
+ };
282
+ }
283
+ function printJson(value) {
284
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
285
+ }
286
+ // Print the JSON result and, when not in --json mode, a highlighted list of the
287
+ // prod frontend URLs a human can open. Frontend links are a first-class output.
288
+ function emitResult(result, json, frontend) {
289
+ if (json) {
290
+ printJson(result.json ?? result.text);
67
291
  return;
68
292
  }
69
- throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
293
+ printJson(result.json ?? result.text);
294
+ const links = (frontend ?? []).filter((entry) => Boolean(entry[1]));
295
+ if (links.length > 0) {
296
+ console.log("");
297
+ for (const [label, url] of links) {
298
+ console.log(` ${BOLD}${label}${RESET} ${FRONTEND}${url}${RESET}`);
299
+ }
300
+ }
301
+ }
302
+ // Frontend URL builders — these are the pages a director opens in a browser.
303
+ function editorFrontendUrl(host, templateId, forkId) {
304
+ const base = `${host}/editor/${encodeURIComponent(templateId)}`;
305
+ return forkId ? `${base}?fork=${encodeURIComponent(forkId)}` : base;
306
+ }
307
+ function discoverFrontendUrl(host) {
308
+ return `${host}/discover`;
309
+ }
310
+ // Small arg helper: split "k=v" pairs for --query.
311
+ function collectKeyValues(entries) {
312
+ const out = {};
313
+ for (const entry of entries ?? []) {
314
+ const eq = entry.indexOf("=");
315
+ if (eq === -1)
316
+ throw new Error(`--query expects k=v, got: ${entry}`);
317
+ out[entry.slice(0, eq)] = entry.slice(eq + 1);
318
+ }
319
+ return out;
70
320
  }
71
321
  async function runEditCommand(argv) {
72
322
  const parsed = parseArgs({
@@ -107,6 +357,17 @@ async function runEditCommand(argv) {
107
357
  shareToken,
108
358
  refetch: parsed.values.refetch
109
359
  });
360
+ // Give local coding agents (Claude Code / Codex / opencode) the actual media
361
+ // to look at: the original source video, the composition's decomposed source
362
+ // (which may differ after ghostcut subtitle-removal), one still frame per
363
+ // scene, and a sources.json manifest tying it all together. Best-effort —
364
+ // never blocks the editor session if a download or frame extraction fails.
365
+ try {
366
+ await fetchVideoAssets({ dir: rootDir, apiKey, shareToken, refetch: parsed.values.refetch });
367
+ }
368
+ catch (error) {
369
+ console.warn(`[vidfarm] video assets skipped: ${error instanceof Error ? error.message : String(error)}`);
370
+ }
110
371
  const devSource = `http://localhost:${port}`;
111
372
  const editorUrl = `${host}/editor/${encodeURIComponent(templateId)}?fork=${encodeURIComponent(forkId)}&dev=${encodeURIComponent(devSource)}`;
112
373
  printEditorBanner({ templateId, forkId, dir: rootDir, port, editorUrl });
@@ -302,6 +563,232 @@ async function fetchCompositionFiles(input) {
302
563
  console.log(`[vidfarm] fetched ${filename} (${body.length} bytes)`);
303
564
  }
304
565
  }
566
+ async function fetchVideoAssets(input) {
567
+ const refs = readSourceRefs(input.dir);
568
+ if (!refs.compositionSourceUrl && !refs.originalSourceUrl) {
569
+ console.log("[vidfarm] no source video URL found in composition — skipping video assets");
570
+ return;
571
+ }
572
+ // "decomposed video in composition" = the source the composition currently
573
+ // renders from (post-ghostcut if applicable). "original" = the raw source the
574
+ // decompose ran against, before any processing. They coincide when the fork
575
+ // was never ghostcut/subtitle-removed.
576
+ const compositionUrl = refs.compositionSourceUrl ?? refs.originalSourceUrl;
577
+ const originalUrl = refs.originalSourceUrl ?? refs.compositionSourceUrl;
578
+ const sameSource = compositionUrl === originalUrl;
579
+ const headers = buildAuthHeaders(input);
580
+ const decomposedName = "source.mp4";
581
+ const originalName = "original.mp4";
582
+ const decomposed = await downloadVideo({
583
+ url: compositionUrl,
584
+ target: path.join(input.dir, decomposedName),
585
+ headers,
586
+ refetch: input.refetch,
587
+ label: "decomposed source"
588
+ });
589
+ let original = decomposed && sameSource ? { ...decomposed, path: decomposedName } : null;
590
+ if (!sameSource) {
591
+ original = await downloadVideo({
592
+ url: originalUrl,
593
+ target: path.join(input.dir, originalName),
594
+ headers,
595
+ refetch: input.refetch,
596
+ label: "original source"
597
+ });
598
+ }
599
+ // One representative still per scene, taken from the decomposed source (what
600
+ // the composition actually renders). Falls back to the original if that's the
601
+ // only video that came down.
602
+ const frameBase = decomposed ? decomposedName : original ? originalName : null;
603
+ const frames = frameBase
604
+ ? await extractSceneFrames({
605
+ dir: input.dir,
606
+ videoName: frameBase,
607
+ scenes: refs.scenes,
608
+ durationSeconds: refs.durationSeconds,
609
+ refetch: input.refetch
610
+ })
611
+ : new Map();
612
+ const manifest = {
613
+ generated_by: "vidfarm-devcli",
614
+ note: "Local media references for coding agents. `original.mp4` is the raw source video; " +
615
+ "`source.mp4` is the video the composition timeline renders from (may differ from the " +
616
+ "original after ghostcut subtitle-removal). Scene entries map timeline segments to a still " +
617
+ "frame you can open as an image. Run `--refetch` to refresh.",
618
+ videos: {
619
+ decomposed: {
620
+ role: "video the composition timeline renders from (decomposed source)",
621
+ url: compositionUrl,
622
+ path: decomposed ? decomposedName : null,
623
+ bytes: decomposed?.bytes ?? null
624
+ },
625
+ original: {
626
+ role: "raw original source video (before any processing)",
627
+ url: originalUrl,
628
+ path: original ? original.path : null,
629
+ bytes: original?.bytes ?? null,
630
+ same_as_decomposed: sameSource
631
+ }
632
+ },
633
+ duration_seconds: refs.durationSeconds,
634
+ scenes: refs.scenes.map((scene, index) => ({
635
+ slug: scene.slug,
636
+ start: scene.start,
637
+ duration: scene.duration,
638
+ label: scene.label,
639
+ description: scene.description,
640
+ transcript_excerpt: scene.transcript_excerpt,
641
+ frame: frames.get(index) ?? null,
642
+ frame_source: frameBase
643
+ }))
644
+ };
645
+ writeFileSync(path.join(input.dir, "sources.json"), JSON.stringify(manifest, null, 2));
646
+ console.log(`[vidfarm] wrote sources.json (${refs.scenes.length} scenes, ${frames.size} frames)`);
647
+ }
648
+ // Derive the source URLs and scene list from the files already on disk
649
+ // (composition.html + video-context.json) — no extra network round-trips.
650
+ function readSourceRefs(dir) {
651
+ let compositionSourceUrl = null;
652
+ const htmlPath = path.join(dir, "composition.html");
653
+ if (existsSync(htmlPath)) {
654
+ const html = readFileSync(htmlPath, "utf8");
655
+ compositionSourceUrl =
656
+ matchAttr(html, "data-source-video") ??
657
+ matchPlaybackSourceSrc(html) ??
658
+ null;
659
+ }
660
+ let originalSourceUrl = null;
661
+ let scenes = [];
662
+ let durationSeconds = null;
663
+ const ctxPath = path.join(dir, "video-context.json");
664
+ if (existsSync(ctxPath)) {
665
+ try {
666
+ const ctx = JSON.parse(readFileSync(ctxPath, "utf8"));
667
+ if (typeof ctx.source_url === "string" && ctx.source_url)
668
+ originalSourceUrl = ctx.source_url;
669
+ if (typeof ctx.duration_seconds === "number")
670
+ durationSeconds = ctx.duration_seconds;
671
+ if (Array.isArray(ctx.scenes)) {
672
+ scenes = ctx.scenes
673
+ .map((raw) => {
674
+ if (!raw || typeof raw !== "object")
675
+ return null;
676
+ const s = raw;
677
+ return {
678
+ slug: typeof s.slug === "string" ? s.slug : null,
679
+ start: Number(s.start ?? 0),
680
+ duration: Number(s.duration ?? 0),
681
+ label: typeof s.label === "string" ? s.label : null,
682
+ description: typeof s.description === "string" ? s.description : "",
683
+ transcript_excerpt: typeof s.transcript_excerpt === "string" ? s.transcript_excerpt : ""
684
+ };
685
+ })
686
+ .filter((s) => s !== null);
687
+ }
688
+ }
689
+ catch {
690
+ // video-context.json absent/status:none/malformed → fall back to composition source only
691
+ }
692
+ }
693
+ return { compositionSourceUrl, originalSourceUrl, scenes, durationSeconds };
694
+ }
695
+ function matchAttr(html, attr) {
696
+ const match = html.match(new RegExp(`${attr}="([^"]+)"`));
697
+ return match ? decodeHtmlEntities(match[1]) : null;
698
+ }
699
+ function matchPlaybackSourceSrc(html) {
700
+ const match = html.match(/data-vf-playback-source="true"[^>]*\ssrc="([^"]+)"/) ??
701
+ html.match(/<video[^>]*\ssrc="([^"]+)"/);
702
+ return match ? decodeHtmlEntities(match[1]) : null;
703
+ }
704
+ function decodeHtmlEntities(value) {
705
+ return value
706
+ .replace(/&amp;/g, "&")
707
+ .replace(/&quot;/g, '"')
708
+ .replace(/&#39;/g, "'")
709
+ .replace(/&lt;/g, "<")
710
+ .replace(/&gt;/g, ">");
711
+ }
712
+ async function downloadVideo(input) {
713
+ const name = path.basename(input.target);
714
+ if (existsSync(input.target) && !input.refetch) {
715
+ console.log(`[vidfarm] keep local ${name} (use --refetch to overwrite)`);
716
+ return { path: name, bytes: safeSize(input.target) };
717
+ }
718
+ try {
719
+ const res = await fetch(input.url, { headers: input.headers });
720
+ if (!res.ok || !res.body) {
721
+ console.warn(`[vidfarm] skip ${name} (${input.label}): ${res.status} ${res.statusText}`);
722
+ return null;
723
+ }
724
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(input.target));
725
+ const bytes = safeSize(input.target);
726
+ console.log(`[vidfarm] fetched ${name} (${input.label}, ${formatBytes(bytes)})`);
727
+ return { path: name, bytes };
728
+ }
729
+ catch (error) {
730
+ console.warn(`[vidfarm] skip ${name} (${input.label}): ${error instanceof Error ? error.message : String(error)}`);
731
+ return null;
732
+ }
733
+ }
734
+ async function extractSceneFrames(input) {
735
+ const out = new Map();
736
+ if (input.scenes.length === 0)
737
+ return out;
738
+ const ffmpeg = await resolveFfmpegPath();
739
+ if (!ffmpeg) {
740
+ console.log("[vidfarm] ffmpeg unavailable — skipping scene frames (videos still downloaded)");
741
+ return out;
742
+ }
743
+ const framesDir = path.join(input.dir, "frames");
744
+ mkdirSync(framesDir, { recursive: true });
745
+ const videoPath = path.join(input.dir, input.videoName);
746
+ input.scenes.forEach((scene, index) => {
747
+ const slug = scene.slug || `scene-${index + 1}`;
748
+ const rel = path.join("frames", `${slug}.png`);
749
+ const target = path.join(input.dir, rel);
750
+ if (existsSync(target) && !input.refetch) {
751
+ out.set(index, rel);
752
+ return;
753
+ }
754
+ // Sample the scene midpoint, clamped inside the clip.
755
+ const mid = scene.start + Math.max(0, scene.duration) / 2;
756
+ const seek = Number.isFinite(mid) && mid > 0 ? mid : Math.max(0, scene.start);
757
+ const result = spawnSync(ffmpeg, ["-y", "-ss", seek.toFixed(3), "-i", videoPath, "-frames:v", "1", "-q:v", "3", target], { stdio: "ignore" });
758
+ if (result.status === 0 && existsSync(target)) {
759
+ out.set(index, rel);
760
+ }
761
+ });
762
+ console.log(`[vidfarm] extracted ${out.size}/${input.scenes.length} scene frames from ${input.videoName}`);
763
+ return out;
764
+ }
765
+ async function resolveFfmpegPath() {
766
+ // ffmpeg-static is already a dependency; import lazily so the common path
767
+ // (no frame extraction) never pays for it, and degrade gracefully if absent.
768
+ try {
769
+ const mod = (await import("ffmpeg-static"));
770
+ const resolved = typeof mod === "string" ? mod : mod.default;
771
+ return typeof resolved === "string" && resolved.length > 0 && existsSync(resolved) ? resolved : null;
772
+ }
773
+ catch {
774
+ return null;
775
+ }
776
+ }
777
+ function safeSize(target) {
778
+ try {
779
+ return statSync(target).size;
780
+ }
781
+ catch {
782
+ return 0;
783
+ }
784
+ }
785
+ function formatBytes(bytes) {
786
+ if (bytes >= 1024 * 1024)
787
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
788
+ if (bytes >= 1024)
789
+ return `${(bytes / 1024).toFixed(1)} KB`;
790
+ return `${bytes} bytes`;
791
+ }
305
792
  function buildAuthHeaders(input) {
306
793
  const headers = { accept: "text/html,application/json" };
307
794
  // The server reads exactly these header names (see requireAuth and
@@ -328,6 +815,8 @@ function printEditorBanner(input) {
328
815
  console.log(` local dir ${input.dir}`);
329
816
  console.log(` dev-serve http://localhost:${input.port}`);
330
817
  console.log("");
818
+ console.log(` ${dim}Agent media: original.mp4, source.mp4, frames/*.png, sources.json${reset}`);
819
+ console.log("");
331
820
  console.log(` ${bold}Open in browser:${reset}`);
332
821
  console.log(` ${cyan}${input.editorUrl}${reset}`);
333
822
  console.log(line);
@@ -336,4 +825,476 @@ function printEditorBanner(input) {
336
825
  function trimTrailingSlash(value) {
337
826
  return value.replace(/\/+$/, "");
338
827
  }
828
+ function sleep(ms) {
829
+ return new Promise((resolve) => setTimeout(resolve, ms));
830
+ }
831
+ // ── Raw escape hatch: call any REST route ───────────────────────────────────
832
+ async function runApiCommand(argv) {
833
+ const parsed = parseArgs({
834
+ args: argv,
835
+ allowPositionals: true,
836
+ options: { ...commonOptions(), data: { type: "string" }, "data-file": { type: "string" }, query: { type: "string", multiple: true } }
837
+ });
838
+ const method = parsed.positionals[0];
839
+ const routePath = parsed.positionals[1];
840
+ if (!method || !routePath) {
841
+ throw new Error(`api requires <METHOD> <path>.\n\nExample: vidfarm api GET /discover/feed`);
842
+ }
843
+ const ctx = commonContext(parsed.values);
844
+ let body;
845
+ if (parsed.values["data-file"]) {
846
+ body = JSON.parse(readFileSync(path.resolve(process.cwd(), String(parsed.values["data-file"])), "utf8"));
847
+ }
848
+ else if (parsed.values.data) {
849
+ body = JSON.parse(String(parsed.values.data));
850
+ }
851
+ const result = await apiRequest({
852
+ method,
853
+ host: ctx.host,
854
+ path: routePath,
855
+ auth: ctx.auth,
856
+ query: collectKeyValues(parsed.values.query),
857
+ body
858
+ });
859
+ if (!ctx.json) {
860
+ const color = result.ok ? GREEN : RED;
861
+ console.log(`${color}${method.toUpperCase()} ${routePath} → ${result.status}${RESET}`);
862
+ }
863
+ printJson(result.json ?? result.text);
864
+ if (!result.ok)
865
+ process.exitCode = 1;
866
+ }
867
+ // ── Discover & inspiration ──────────────────────────────────────────────────
868
+ async function runDiscoverCommand(argv) {
869
+ const parsed = parseArgs({ args: argv, allowPositionals: false, options: { ...commonOptions(), limit: { type: "string" } } });
870
+ const ctx = commonContext(parsed.values);
871
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: "/discover/feed", auth: ctx.auth, query: { limit: parsed.values.limit } });
872
+ assertApiOk(result, "discover");
873
+ emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
874
+ }
875
+ async function runInspirationAddCommand(argv) {
876
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
877
+ const sourceUrl = parsed.positionals[0];
878
+ if (!sourceUrl)
879
+ throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X).");
880
+ const ctx = commonContext(parsed.values);
881
+ const result = await apiRequest({
882
+ method: "POST",
883
+ host: ctx.host,
884
+ path: "/discover/templates",
885
+ auth: ctx.auth,
886
+ body: { source_url: sourceUrl, tagline: parsed.values.tagline, notes: parsed.values.notes }
887
+ });
888
+ assertApiOk(result, "inspiration-add");
889
+ emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
890
+ }
891
+ async function runInspirationRemoveCommand(argv) {
892
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
893
+ const entryId = parsed.positionals[0];
894
+ if (!entryId)
895
+ throw new Error("inspiration-rm requires an inspiration_/template_ id.");
896
+ const ctx = commonContext(parsed.values);
897
+ const result = await apiRequest({ method: "DELETE", host: ctx.host, path: `/discover/templates/${encodeURIComponent(entryId)}`, auth: ctx.auth });
898
+ assertApiOk(result, "inspiration-rm");
899
+ emitResult(result, ctx.json);
900
+ }
901
+ async function runInspirationDecomposeCommand(argv) {
902
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), prompt: { type: "string" } } });
903
+ const inspirationId = parsed.positionals[0];
904
+ if (!inspirationId)
905
+ throw new Error("inspiration-decompose requires an inspiration id.");
906
+ const ctx = commonContext(parsed.values);
907
+ const result = await apiRequest({
908
+ method: "POST",
909
+ host: ctx.host,
910
+ path: `/api/v1/inspirations/${encodeURIComponent(inspirationId)}/decompose`,
911
+ auth: ctx.auth,
912
+ body: { user_prompt: parsed.values.prompt ?? null }
913
+ });
914
+ assertApiOk(result, "inspiration-decompose");
915
+ emitResult(result, ctx.json);
916
+ }
917
+ // ── Composition lifecycle ───────────────────────────────────────────────────
918
+ async function runForkCommand(argv) {
919
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" } } });
920
+ const templateId = parsed.positionals[0];
921
+ if (!templateId)
922
+ throw new Error("fork requires a template id (template_...).");
923
+ const ctx = commonContext(parsed.values);
924
+ const result = await apiRequest({
925
+ method: "POST",
926
+ host: ctx.host,
927
+ path: "/api/v1/compositions",
928
+ auth: ctx.auth,
929
+ body: { template_id: templateId, title: parsed.values.title }
930
+ });
931
+ assertApiOk(result, "fork");
932
+ const forkId = result.json?.fork_id;
933
+ const tId = result.json?.template_id ?? templateId;
934
+ emitResult(result, ctx.json, [["Open editor ", forkId ? editorFrontendUrl(ctx.host, tId, forkId) : null]]);
935
+ }
936
+ async function runDecomposeCommand(argv) {
937
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), mode: { type: "string", default: "smart" }, prompt: { type: "string" } } });
938
+ const forkId = parsed.positionals[0];
939
+ if (!forkId)
940
+ throw new Error("decompose requires a fork id.");
941
+ const ctx = commonContext(parsed.values);
942
+ const result = await apiRequest({
943
+ method: "POST",
944
+ host: ctx.host,
945
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose`,
946
+ auth: ctx.auth,
947
+ body: { mode: parsed.values.mode, user_prompt: parsed.values.prompt ?? null }
948
+ });
949
+ assertApiOk(result, "decompose");
950
+ if (!ctx.json && result.json?.ghostcut_pending) {
951
+ console.log(`${DIM}GhostCut subtitle-removal is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/ghostcut-poll\` or GET .../ghostcut until done.${RESET}`);
952
+ }
953
+ emitResult(result, ctx.json);
954
+ }
955
+ async function runGhostcutCommand(argv) {
956
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
957
+ const forkId = parsed.positionals[0];
958
+ if (!forkId)
959
+ throw new Error("ghostcut requires a fork id.");
960
+ const ctx = commonContext(parsed.values);
961
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/ghostcut`, auth: ctx.auth });
962
+ assertApiOk(result, "ghostcut");
963
+ emitResult(result, ctx.json);
964
+ }
965
+ async function runVersionsCommand(argv) {
966
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), limit: { type: "string" } } });
967
+ const forkId = parsed.positionals[0];
968
+ if (!forkId)
969
+ throw new Error("versions requires a fork id.");
970
+ const ctx = commonContext(parsed.values);
971
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/versions`, auth: ctx.auth, query: { limit: parsed.values.limit } });
972
+ assertApiOk(result, "versions");
973
+ emitResult(result, ctx.json);
974
+ }
975
+ async function runSnapshotCommand(argv) {
976
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), message: { type: "string" } } });
977
+ const forkId = parsed.positionals[0];
978
+ if (!forkId)
979
+ throw new Error("snapshot requires a fork id.");
980
+ const ctx = commonContext(parsed.values);
981
+ const result = await apiRequest({ method: "POST", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/versions`, auth: ctx.auth, body: { message: parsed.values.message ?? null } });
982
+ assertApiOk(result, "snapshot");
983
+ emitResult(result, ctx.json);
984
+ }
985
+ async function runRenderCommand(argv) {
986
+ const parsed = parseArgs({
987
+ args: argv,
988
+ allowPositionals: true,
989
+ options: { ...commonOptions(), title: { type: "string" }, tracer: { type: "string" }, wait: { type: "boolean", default: false } }
990
+ });
991
+ const forkId = parsed.positionals[0];
992
+ if (!forkId)
993
+ throw new Error("render requires a fork id.");
994
+ const ctx = commonContext(parsed.values);
995
+ const result = await apiRequest({
996
+ method: "POST",
997
+ host: ctx.host,
998
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/export`,
999
+ auth: ctx.auth,
1000
+ body: { title: parsed.values.title, tracer: parsed.values.tracer }
1001
+ });
1002
+ assertApiOk(result, "render");
1003
+ const renderId = result.json?.renderId;
1004
+ if (!parsed.values.wait || !renderId) {
1005
+ if (!ctx.json && renderId) {
1006
+ console.log(`${DIM}Poll with: vidfarm render-status ${forkId} ${renderId} (or add --wait next time).${RESET}`);
1007
+ }
1008
+ emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
1009
+ return;
1010
+ }
1011
+ // --wait: poll the render job until it settles.
1012
+ if (!ctx.json)
1013
+ console.log(`${DIM}Rendering ${renderId}… polling every 5s.${RESET}`);
1014
+ let last = result;
1015
+ for (;;) {
1016
+ await sleep(5000);
1017
+ last = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/renders/${encodeURIComponent(renderId)}`, auth: ctx.auth });
1018
+ assertApiOk(last, "render-status");
1019
+ const status = String(last.json?.status ?? "");
1020
+ if (!ctx.json) {
1021
+ const pct = Math.round((Number(last.json?.progress ?? 0)) * 100);
1022
+ console.log(`${DIM} ${status} ${pct}%${RESET}`);
1023
+ }
1024
+ if (status === "SUCCEEDED" || status === "FAILED")
1025
+ break;
1026
+ }
1027
+ emitResult(last, ctx.json, [["Rendered MP4 ", last.json?.outputUrl]]);
1028
+ if (String(last.json?.status) === "FAILED")
1029
+ process.exitCode = 1;
1030
+ }
1031
+ async function runRenderStatusCommand(argv) {
1032
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1033
+ const forkId = parsed.positionals[0];
1034
+ const renderId = parsed.positionals[1];
1035
+ if (!forkId || !renderId)
1036
+ throw new Error("render-status requires <forkId> <renderId>.");
1037
+ const ctx = commonContext(parsed.values);
1038
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/renders/${encodeURIComponent(renderId)}`, auth: ctx.auth });
1039
+ assertApiOk(result, "render-status");
1040
+ emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
1041
+ }
1042
+ async function runVisibilityCommand(argv) {
1043
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1044
+ const forkId = parsed.positionals[0];
1045
+ const visibility = parsed.positionals[1];
1046
+ if (!forkId || !visibility)
1047
+ throw new Error("visibility requires <forkId> <private|public>.");
1048
+ const ctx = commonContext(parsed.values);
1049
+ const result = await apiRequest({ method: "PATCH", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/visibility`, auth: ctx.auth, body: { visibility } });
1050
+ assertApiOk(result, "visibility");
1051
+ const templateId = result.json?.template_id;
1052
+ emitResult(result, ctx.json, templateId ? [["Open editor ", editorFrontendUrl(ctx.host, templateId, forkId)]] : undefined);
1053
+ }
1054
+ async function runCloneCommand(argv) {
1055
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), version: { type: "string" }, title: { type: "string" } } });
1056
+ const forkId = parsed.positionals[0];
1057
+ if (!forkId)
1058
+ throw new Error("clone requires a fork id.");
1059
+ const ctx = commonContext(parsed.values);
1060
+ const result = await apiRequest({
1061
+ method: "POST",
1062
+ host: ctx.host,
1063
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/clone`,
1064
+ auth: ctx.auth,
1065
+ body: { parent_version: parsed.values.version, title: parsed.values.title }
1066
+ });
1067
+ assertApiOk(result, "clone");
1068
+ const newForkId = result.json?.fork_id;
1069
+ const templateId = result.json?.template_id;
1070
+ emitResult(result, ctx.json, newForkId && templateId ? [["Open editor ", editorFrontendUrl(ctx.host, templateId, newForkId)]] : undefined);
1071
+ }
1072
+ async function runShareLinkCommand(argv) {
1073
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), role: { type: "string", default: "viewer" }, expires: { type: "string" } } });
1074
+ const forkId = parsed.positionals[0];
1075
+ if (!forkId)
1076
+ throw new Error("share-link requires a fork id.");
1077
+ const ctx = commonContext(parsed.values);
1078
+ const result = await apiRequest({
1079
+ method: "POST",
1080
+ host: ctx.host,
1081
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/share-links`,
1082
+ auth: ctx.auth,
1083
+ body: { role: parsed.values.role, expires_at: parsed.values.expires ?? null }
1084
+ });
1085
+ assertApiOk(result, "share-link");
1086
+ emitResult(result, ctx.json, [["Share ", result.json?.share_url]]);
1087
+ }
1088
+ // ── Approve & schedule ──────────────────────────────────────────────────────
1089
+ async function runApproveCommand(argv) {
1090
+ const parsed = parseArgs({
1091
+ args: argv,
1092
+ allowPositionals: false,
1093
+ options: {
1094
+ ...commonOptions(),
1095
+ video: { type: "string" },
1096
+ media: { type: "string", multiple: true },
1097
+ caption: { type: "string" },
1098
+ title: { type: "string" },
1099
+ pinned: { type: "string" },
1100
+ tracer: { type: "string" }
1101
+ }
1102
+ });
1103
+ const ctx = commonContext(parsed.values);
1104
+ const caption = parsed.values.caption;
1105
+ if (!caption)
1106
+ throw new Error("approve requires --caption <text>.");
1107
+ const media = [];
1108
+ if (parsed.values.video)
1109
+ media.push({ url: parsed.values.video, kind: "video", role: "primary" });
1110
+ for (const url of parsed.values.media ?? [])
1111
+ media.push({ url });
1112
+ if (media.length === 0) {
1113
+ console.warn(`${DIM}[vidfarm] no --video / --media given; approving a text-only post.${RESET}`);
1114
+ }
1115
+ const result = await apiRequest({
1116
+ method: "POST",
1117
+ host: ctx.host,
1118
+ path: "/api/v1/approved/posts",
1119
+ auth: ctx.auth,
1120
+ body: {
1121
+ caption,
1122
+ title: parsed.values.title,
1123
+ pinned_comment: parsed.values.pinned,
1124
+ tracer: parsed.values.tracer,
1125
+ media
1126
+ }
1127
+ });
1128
+ assertApiOk(result, "approve");
1129
+ // The approved-post preview page is the headline output: it's the prod
1130
+ // frontend URL a human opens to see/share the finished post.
1131
+ emitResult(result, ctx.json, [["Preview post ", result.json?.post?.share_url]]);
1132
+ }
1133
+ async function runPostsCommand(argv) {
1134
+ const parsed = parseArgs({ args: argv, allowPositionals: false, options: { ...commonOptions(), limit: { type: "string" }, tracer: { type: "string" } } });
1135
+ const ctx = commonContext(parsed.values);
1136
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/approved/posts", auth: ctx.auth, query: { limit: parsed.values.limit, tracer: parsed.values.tracer } });
1137
+ assertApiOk(result, "posts");
1138
+ emitResult(result, ctx.json);
1139
+ }
1140
+ async function runPostCommand(argv) {
1141
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1142
+ const postId = parsed.positionals[0];
1143
+ if (!postId)
1144
+ throw new Error("post requires a post id.");
1145
+ const ctx = commonContext(parsed.values);
1146
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/approved/posts/${encodeURIComponent(postId)}`, auth: ctx.auth });
1147
+ assertApiOk(result, "post");
1148
+ emitResult(result, ctx.json, [["Preview post ", result.json?.post?.share_url]]);
1149
+ }
1150
+ async function runScheduleCommand(argv) {
1151
+ const parsed = parseArgs({
1152
+ args: argv,
1153
+ allowPositionals: true,
1154
+ options: { ...commonOptions(), at: { type: "string" }, to: { type: "string" }, type: { type: "string", default: "flockposter" }, timezone: { type: "string" }, notes: { type: "string" } }
1155
+ });
1156
+ const postId = parsed.positionals[0];
1157
+ if (!postId)
1158
+ throw new Error("schedule requires a post id.");
1159
+ if (!parsed.values.at)
1160
+ throw new Error("schedule requires --at <ISO 8601 timestamp>.");
1161
+ if (!parsed.values.to)
1162
+ throw new Error("schedule requires --to <destination_id> (a FlockPoster channel or email).");
1163
+ const ctx = commonContext(parsed.values);
1164
+ const result = await apiRequest({
1165
+ method: "POST",
1166
+ host: ctx.host,
1167
+ path: `/api/v1/approved/posts/${encodeURIComponent(postId)}/schedules`,
1168
+ auth: ctx.auth,
1169
+ body: {
1170
+ destination_type: parsed.values.type,
1171
+ destination_id: parsed.values.to,
1172
+ scheduled_at: parsed.values.at,
1173
+ timezone: parsed.values.timezone,
1174
+ additional_notes: parsed.values.notes
1175
+ }
1176
+ });
1177
+ assertApiOk(result, "schedule");
1178
+ emitResult(result, ctx.json);
1179
+ }
1180
+ async function runSchedulesCommand(argv) {
1181
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1182
+ const postId = parsed.positionals[0];
1183
+ if (!postId)
1184
+ throw new Error("schedules requires a post id.");
1185
+ const ctx = commonContext(parsed.values);
1186
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/approved/posts/${encodeURIComponent(postId)}/schedules`, auth: ctx.auth });
1187
+ assertApiOk(result, "schedules");
1188
+ emitResult(result, ctx.json);
1189
+ }
1190
+ // ── Account ─────────────────────────────────────────────────────────────────
1191
+ async function runLoginCommand(argv) {
1192
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1193
+ const email = parsed.positionals[0];
1194
+ if (!email)
1195
+ throw new Error("login requires an email address.");
1196
+ const ctx = commonContext(parsed.values);
1197
+ const result = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/user/request-otp", auth: ctx.auth, body: { email } });
1198
+ assertApiOk(result, "login");
1199
+ if (!ctx.json)
1200
+ console.log(`${GREEN}OTP requested for ${email}. Run: vidfarm verify ${email} <code>${RESET}`);
1201
+ emitResult(result, ctx.json);
1202
+ }
1203
+ async function runVerifyCommand(argv) {
1204
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), name: { type: "string" } } });
1205
+ const email = parsed.positionals[0];
1206
+ const code = parsed.positionals[1];
1207
+ if (!email || !code)
1208
+ throw new Error("verify requires <email> <code>.");
1209
+ const ctx = commonContext(parsed.values);
1210
+ const result = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/user/verify-otp", auth: ctx.auth, body: { email, code, name: parsed.values.name } });
1211
+ assertApiOk(result, "verify");
1212
+ const apiKey = result.json?.apiKey;
1213
+ if (!ctx.json && apiKey) {
1214
+ console.log(`${GREEN}${BOLD}Save this API key:${RESET} ${apiKey}`);
1215
+ console.log(`${DIM}export VIDFARM_API_KEY=${apiKey}${RESET}`);
1216
+ }
1217
+ emitResult(result, ctx.json);
1218
+ }
1219
+ async function runWhoamiCommand(argv) {
1220
+ const parsed = parseArgs({ args: argv, allowPositionals: false, options: commonOptions() });
1221
+ const ctx = commonContext(parsed.values);
1222
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me", auth: ctx.auth });
1223
+ assertApiOk(result, "whoami");
1224
+ emitResult(result, ctx.json);
1225
+ }
1226
+ async function runProviderKeysCommand(argv) {
1227
+ const parsed = parseArgs({ args: argv, allowPositionals: false, options: commonOptions() });
1228
+ const ctx = commonContext(parsed.values);
1229
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/provider-keys", auth: ctx.auth });
1230
+ assertApiOk(result, "provider-keys");
1231
+ emitResult(result, ctx.json);
1232
+ }
1233
+ async function runAddProviderKeyCommand(argv) {
1234
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), label: { type: "string" } } });
1235
+ const provider = parsed.positionals[0];
1236
+ const secret = parsed.positionals[1];
1237
+ if (!provider || !secret)
1238
+ throw new Error("add-provider-key requires <provider> <secret> (provider: openai|gemini|openrouter|perplexity).");
1239
+ const ctx = commonContext(parsed.values);
1240
+ const result = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/user/me/provider-keys", auth: ctx.auth, body: { provider, secret, label: parsed.values.label } });
1241
+ assertApiOk(result, "add-provider-key");
1242
+ emitResult(result, ctx.json);
1243
+ }
1244
+ // ── Files ───────────────────────────────────────────────────────────────────
1245
+ // Upload is a genuine multi-step flow, so the devcli earns its keep here: it
1246
+ // posts the file as multipart/form-data to the temporary-files route and prints
1247
+ // the durable URL you can drop into a composition or an approved post.
1248
+ async function runUploadCommand(argv) {
1249
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" } } });
1250
+ const filePath = parsed.positionals[0];
1251
+ if (!filePath)
1252
+ throw new Error("upload requires a local file path.");
1253
+ const ctx = commonContext(parsed.values);
1254
+ const abs = path.resolve(process.cwd(), filePath);
1255
+ if (!existsSync(abs))
1256
+ throw new Error(`No such file: ${abs}`);
1257
+ const buffer = readFileSync(abs);
1258
+ const form = new FormData();
1259
+ form.append("file", new Blob([buffer]), path.basename(abs));
1260
+ if (parsed.values.folder)
1261
+ form.append("folder_path", String(parsed.values.folder));
1262
+ const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), {
1263
+ method: "POST",
1264
+ headers: buildAuthHeaders(ctx.auth),
1265
+ body: form
1266
+ });
1267
+ const text = await res.text();
1268
+ let json = null;
1269
+ try {
1270
+ json = text ? JSON.parse(text) : null;
1271
+ }
1272
+ catch {
1273
+ json = null;
1274
+ }
1275
+ const result = { status: res.status, ok: res.ok, json, text };
1276
+ assertApiOk(result, "upload");
1277
+ emitResult(result, ctx.json, [["Durable URL ", result.json?.file?.view_url ?? result.json?.file?.s3_url]]);
1278
+ }
1279
+ async function runDownloadCommand(argv) {
1280
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1281
+ const url = parsed.positionals[0];
1282
+ if (!url)
1283
+ throw new Error("download requires a URL.");
1284
+ const ctx = commonContext(parsed.values);
1285
+ const dest = parsed.positionals[1]
1286
+ ? path.resolve(process.cwd(), parsed.positionals[1])
1287
+ : path.resolve(process.cwd(), path.basename(new URL(url).pathname) || "download.bin");
1288
+ const res = await fetch(url, { headers: buildAuthHeaders(ctx.auth) });
1289
+ if (!res.ok || !res.body)
1290
+ throw new Error(`download failed: ${res.status} ${res.statusText} (${url}).`);
1291
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
1292
+ const bytes = safeSize(dest);
1293
+ if (ctx.json) {
1294
+ printJson({ ok: true, url, path: dest, bytes });
1295
+ }
1296
+ else {
1297
+ console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
1298
+ }
1299
+ }
339
1300
  //# sourceMappingURL=cli.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. Point it at a template id, edit composition.html on disk (Claude Code, Codex, etc.), preview live in the browser at https://vidfarm.cc/editor/.",
5
5
  "type": "module",
6
6
  "bin": {