@officexapp/vidfarm-devcli 0.21.42 → 0.21.45

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/dist/src/cli.js CHANGED
@@ -29,7 +29,7 @@ import { runDoctorCommand } from "./devcli/doctor.js";
29
29
  import { findFreePort } from "./devcli/port-utils.js";
30
30
  import { scanLocalServers } from "./devcli/process-scan.js";
31
31
  import { runSkillsCommand } from "./devcli/skills.js";
32
- import { AmbiguousDocRef, DEFAULT_PACK, bundledPackDir, listPackDocs, listPackTopics, loadIdeaBank, readPackDoc, readPackTopic, resolvePackTopic, searchPackDocs } from "./devcli/skill-docs.js";
32
+ import { AmbiguousDocRef, DEFAULT_PACK, bundledPackDir, listPackDocs, listPackTopics, loadAngleBank, loadAwarenessLadder, loadIdeaBank, readPackDoc, readPackTopic, resolvePackTopic, searchPackDocs } from "./devcli/skill-docs.js";
33
33
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
34
34
  import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError, localApiRequest } from "./devcli/local-backend.js";
35
35
  import { startLocalFrontendServer, serveShellsPresent } from "./devcli/local-frontend-server.js";
@@ -37,6 +37,8 @@ import { readStoredAuth, writeStoredAuth, clearStoredAuth, hostsMatch } from "./
37
37
  import { CostModeBlockedError, assertBilledAllowed, clearStoredCostMode, costModeExplainer, costModeSummaryLine, normalizeCostMode, resolveCostMode, writeStoredCostMode, COST_MODE_BLURB, COST_MODE_DISPLAY_LIST, costModeDisplayName } from "./devcli/cost-mode.js";
38
38
  import { INTERACTION_MODES, INTERACTION_MODE_BLURB, clearStoredInteractionMode, interactionModeExplainer, interactionModeSummaryLine, interactiveOfferLine, normalizeInteractionMode, resolveInteractionMode, writeStoredInteractionMode } from "./devcli/interaction-mode.js";
39
39
  import { buildImageHandoff, buildRawsHandoff, formatHandoff } from "./devcli/handoff.js";
40
+ import { buildConsultBrief, CONSULT_ARTIFACTS, consultContextState, consultCountRange, defaultConsultCount, findOfferFile, formatConsultBrief, looksLikeOfferPath, planConsultation, resolveConsultStep, resolveOfferPath } from "./devcli/consult.js";
41
+ import { resolveRootFileCandidates } from "./lib/package-root.js";
40
42
  import { STORYBOARD_FILENAME, readStoryboard, renderStoryboardScaffold, storyboardStage } from "./devcli/storyboard.js";
41
43
  import { CORE_METRICS, DEFAULT_FORMAT, DEFAULT_METRIC, EASY_FORMATS, EXPERIMENTS_FILENAME, EXPERIMENT_MODES, analyzeRound, appendResult, appendRound, capacityOf, epochsNeeded, findRoundIndex, lintDiary, markPosted, readDiary, renderDiaryScaffold, writeDiary } from "./devcli/experiments.js";
42
44
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
@@ -120,6 +122,34 @@ Account (persisted login — points the CLI + \`serve\` at cloud prod):
120
122
  download (a downloader SEARCH, not a rotting link), and
121
123
  the vidfarm clipper / raws scan follow-up. Try browser control
122
124
  or "vidfarm raws scan" first.
125
+ consult [step] THE CONSULTATION / BRAINSTORM CHAIN, KEYLESS AND LOCAL.
126
+ The cloud runs these as brainstorm/* primitives that need
127
+ a provider key or wallet credits. This prints the IDENTICAL
128
+ prompt for YOU (the agent already in this terminal) to
129
+ answer — $0, no key, no network. Bare "consult" = the map
130
+ of the four steps + which artifacts already exist.
131
+ Every step reads and updates CONTEXT.md in --dir, the
132
+ durable answer file, so a later session starts warm.
133
+ Run the whole chain from ONE working folder.
134
+ OFFER "vidfarm ideas" FIRST — 20+ video ideas for one
135
+ line of input is an easier first win than an interview,
136
+ and the picks make the interview answers better.
137
+ (aliases: consultation, brainstorm)
138
+ coldstart [--short] The interview → OFFER.md. Ask the human the questions a
139
+ few at a time; never invent their answers. --short is a
140
+ fixed 6-question form for a director in a hurry, and it
141
+ still feeds every later step. (alias: interview)
142
+ awareness Which awareness stage to sell to → awareness-levels.md
143
+ angles Persuasive angles → persuasive-angles.md
144
+ [--problem-awareness problem_unaware|problem_aware]
145
+ [--solution-awareness solution_unaware|solution_aware]
146
+ hooks Openings to test → ad-hooks.md (grade them before showing)
147
+ placement --video <p> Native product-placement moments in an existing video
148
+ --offer <text|path> The offer. Auto-reads OFFER.md / OFFER_<NAME>.md from --dir.
149
+ --count <n> How many questions/angles/hooks (default 12)
150
+ --refs Inline the full SELLING_*.md references, exactly as the
151
+ cloud prompt does. Off by default: ~34KB of your context.
152
+ --json The brief as data (prompt, steps, artifact, next)
123
153
  storyboard [dir] The PLAN pass: read the project's STORYBOARD.md (ordered
124
154
  frames + duration/status/src/scene/voiceover, plus SCRIPT.md).
125
155
  The editor renders this file in its Storyboard view — a
@@ -700,6 +730,31 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
700
730
  key adds photos/vectors/stock video.
701
731
  --provider <p> Force pixabay|openverse|iconify|iconscout
702
732
  --limit <n> | --json
733
+ video-search "<query>" GOOGLE VIDEO SEARCH — find the actual SHOT anywhere on the web
734
+ (TikTok, YouTube, Pexels/Pixabay/Mixkit, news, archive.org).
735
+ This is how you SOURCE raws: take a result url straight into
736
+ 'vidfarm raws scan <url>' (mine clips) or 'download-video <url>'
737
+ (collect the file). Search the SHOT, not the topic:
738
+ [subject]+[action]+[shot/style]+[quality/orientation], e.g.
739
+ "office worker typing laptop close up 4K". Google operators work:
740
+ site:pexels.com/videos · site:tiktok.com · "exact phrase" · -dance
741
+ PAID PLANS ONLY · flat $0.0003 per call whatever the result count,
742
+ so ask for a WIDE page instead of paging twice.
743
+ --limit <n> Results (default 25, max 50)
744
+ --fresh <d|w|m|y> Only the last day/week/month/year
745
+ --duration <d> short|medium|long --resolution high|standard
746
+ --region <r> wt-wt (default)|us-en|ph-en|… --json
747
+ image-search "<query>" GOOGLE IMAGE SEARCH — reference stills, textures, logos, product
748
+ shots. Same operators. PAID PLANS ONLY · flat $0.0003 per call.
749
+ For licence-checked, royalty-free assets prefer 'vidfarm media'
750
+ (free): image-search returns LINKS with no licence attached.
751
+ --limit <n> (default 25, max 100) --type-image photo|clipart|gif|transparent
752
+ --color <c> --size <s> --layout Square|Tall|Wide --region <r> --json
753
+ news-search "<query>" GOOGLE NEWS — recent real events to build TIMELY content on.
754
+ News finds the STORY; run video-search after for the VISUALS.
755
+ PAID PLANS ONLY · flat $0.0003 per call.
756
+ Method: https://vidfarm.cc/experimental/google-news-to-video.md
757
+ --limit <n> (default 25, max 100) --fresh <d|w|m|y> --region <r> --json
703
758
  iconscout "<query>" DESIGNER icons, STICKERS, illustrations, 3D and Lottie —
704
759
  THE CHEAP ALTERNATIVE TO AI IMAGE GENERATION. Reach for this
705
760
  BEFORE 'generate' / 'image': an AI sticker costs cents per
@@ -894,6 +949,9 @@ Approve & schedule (publish a finished MP4 as a shareable post):
894
949
  approve Approve media into a preview/share page → POST /api/v1/approved/posts
895
950
  --video <url|file> Primary MP4 (URL, /files path, or local file → durable My Files)
896
951
  --media <url|file> Extra media (repeatable; local files → durable My Files)
952
+ --thumbnail <url|file> Poster image for the share link's social card. Omit it and
953
+ the server extracts a frame (~1s in) from --video and files
954
+ it under /files/approved
897
955
  --folder <path> Destination subfolder for uploaded local files (default: approved)
898
956
  --temp Upload local files to the 30-day TEMP store instead of
899
957
  durable My Files (throwaway preview; share video dies at day 30)
@@ -929,18 +987,28 @@ Account:
929
987
  (iconscout takes BOTH values as one secret: <client_id>:<client_secret>)
930
988
 
931
989
  Agent skill (the director knowledge — a full copy SHIPS INSIDE this CLI):
932
- ideas [topic] "What should I post?" — the 50-frame ANGLE BANK (local — offline, free, no AI)
933
- (the rise of · what everyone gets wrong · then vs
934
- now · one decision that changed everything · …).
935
- One offer poured into 50 frames is 50 DIFFERENT
936
- videos, not 50 rewrites of one. It hands over the
937
- frames; you fill them with the director's offer,
938
- then write hook/loop/payoff/bait per pick
990
+ ideas [topic] "What should I post?" — the THREE IDEA BANKS (local — offline, free, no AI)
991
+ 50 frames (the rise of · then vs now · …), 5
992
+ AWARENESS STAGES (what the viewer already knows,
993
+ and what the video may ask for), and 44 PROBLEM
994
+ ANGLES (which side of the problem it comes from).
995
+ An idea is one point in that grid; vary TWO axes
996
+ and 30 videos never read as repeats. It hands over
997
+ the banks — you fill them with the director's
998
+ offer, then write hook/loop/payoff/bait per pick.
999
+ RUN THIS BEFORE "consult" — one line of input buys
1000
+ 20+ ideas, and it is the easiest first win a new
1001
+ director can get. The interview comes after.
939
1002
  --topic "<offer>" Print each frame already filled with your topic
940
- --family <name> One family only (arc, contrast, pivot, …)
941
- --families List the families and their sizes
1003
+ --family <name> One frame family only (arc, contrast, pivot, …)
1004
+ --families List the frame families and their sizes
1005
+ --stages The awareness ladder — all 5 stages in full
1006
+ --stage <name|n> One stage (unaware · problem-aware · … · most-aware)
1007
+ --angles The 44 problem angles, by family
1008
+ --angle <name> One angle family or one named angle
1009
+ --grid Stage × angle × frame — one row per video
942
1010
  --count <n> Sample N, spread across families (default 20 with --topic)
943
- --json Machine-readable frames (+ starter lines)
1011
+ --json Machine-readable banks (+ starter lines)
944
1012
  skill topics The craft this pack knows, by SPOKEN name — (local — offline, no account)
945
1013
  meme-recaption, product-explainer, captions,
946
1014
  first-frame, blurred-plate, density, avatar, …
@@ -1052,6 +1120,12 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
1052
1120
  directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
1053
1121
  --folder <path> Destination folder under /files (e.g. inpaints, promos)
1054
1122
  --as <name> Name the saved file · --notes <text> vector-embedded notes
1123
+ directory share <path> Mint a PUBLIC link onto one subfolder (any root) → POST /api/v1/user/me/directory/shares
1124
+ --mode read|upload|edit read = browse + vector search · upload = + add files (never delete) · edit = + rename/delete
1125
+ --label <text> Name the link. PAID to mint; visiting one needs no account.
1126
+ directory shares [path] List your share links → GET /api/v1/user/me/directory/shares
1127
+ directory share-update <token> --mode/--label/--disable/--enable → PATCH /api/v1/user/me/directory/shares/:token
1128
+ directory unshare <token> Delete a share link for good → DELETE /api/v1/user/me/directory/shares/:token
1055
1129
  (alias: dir · save-url|from-url|import-url — CLI twin of the "Save to Files" picker)
1056
1130
  put-file / get-file / files / annotate-file are the My Files (persistent) set;
1057
1131
  upload is the throwaway temp store for dropping media into a composition.
@@ -1120,9 +1194,8 @@ Publish-mode options:
1120
1194
  --message <text> Message attached to the published version snapshot
1121
1195
  --no-snapshot Only update the working copy; skip the version snapshot
1122
1196
  `;
1123
- // ANSI palette declared BEFORE main() is invoked because command handlers may
1124
- // reference these synchronously (before their first await) while the rest of
1125
- // this module is still evaluating.
1197
+ // ANSI palette. (Its position no longer matters — main() now runs as the last
1198
+ // statement in this file but it stays up here because everything below reads it.)
1126
1199
  const FRONTEND = "\x1b[1m\x1b[36m"; // bold cyan — reserved for openable URLs
1127
1200
  const BOLD = "\x1b[1m";
1128
1201
  const DIM = "\x1b[2m";
@@ -1130,24 +1203,16 @@ const GREEN = "\x1b[32m";
1130
1203
  const YELLOW = "\x1b[33m";
1131
1204
  const RED = "\x1b[31m";
1132
1205
  const RESET = "\x1b[0m";
1133
- // Referenced by runMarketplaceCommand's SYNCHRONOUS browse path, so it must be
1134
- // initialized before `void main()` starts dispatching below.
1206
+ // Referenced by runMarketplaceCommand's SYNCHRONOUS browse path.
1135
1207
  const DOLLARPLATOON_SKILL_HINT = `Gig inbox / proofs / payouts live on Dollar Platoon's own API — grab the skill:
1136
1208
  npx skills add https://github.com/OfficeXApp/dollarplatoon-skill
1137
1209
  (or from the vidfarm mirror: vidfarm skills add dollarplatoon-skill)`;
1138
- void main().catch(async (error) => {
1139
- // Deliberate, expected user-facing conditions (e.g. local mode in the
1140
- // published cloud-only CLI) print just the message no stack, no crash report.
1141
- if (error instanceof LocalModeUnavailableError || error instanceof CostModeBlockedError) {
1142
- console.error(error.message);
1143
- process.exit(1);
1144
- }
1145
- console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1146
- // Report only unexpected crashes (bugs), never the deliberate user-facing
1147
- // `throw new Error(...)` above; no-op unless a devcli DSN is configured.
1148
- await reportCliCrash(error);
1149
- process.exit(1);
1150
- });
1210
+ // main() is INVOKED AT THE BOTTOM OF THIS FILE, not here. Command handlers read
1211
+ // module-level `const`s (help texts, tables, palettes) synchronously, and a
1212
+ // const declared below the invocation point is still in its temporal dead zone
1213
+ // when the handler runs `vidfarm directory help` used to die with "Cannot
1214
+ // access 'DIRECTORY_HELP' before initialization". Starting main() last means
1215
+ // every declaration in this module is initialized before any command dispatches.
1151
1216
  // AI agents habitually save credentials to a project-local .env instead of
1152
1217
  // exporting them, so honor VIDFARM_API_KEY (and VIDFARM_TARGET/VIDFARM_HOST)
1153
1218
  // from ./.env when the variable isn't already in the environment. Real env
@@ -1296,6 +1361,22 @@ async function main() {
1296
1361
  case "media":
1297
1362
  await runMediaCommand(rest);
1298
1363
  return;
1364
+ case "video-search":
1365
+ case "videosearch":
1366
+ case "search-video":
1367
+ await runWebSearchCommand("video", rest);
1368
+ return;
1369
+ case "image-search":
1370
+ case "imagesearch":
1371
+ case "search-image":
1372
+ await runWebSearchCommand("image", rest);
1373
+ return;
1374
+ case "news-search":
1375
+ case "newssearch":
1376
+ case "news":
1377
+ case "search-news":
1378
+ await runWebSearchCommand("news", rest);
1379
+ return;
1299
1380
  case "iconscout":
1300
1381
  case "icons":
1301
1382
  case "stickers":
@@ -1396,6 +1477,13 @@ async function main() {
1396
1477
  case "hand-off":
1397
1478
  await runHandoffCommand(rest);
1398
1479
  return;
1480
+ // The brainstorm chain with no AI key: the prompt goes to the agent that is
1481
+ // already here, not to a provider. "consultation" is the director's word.
1482
+ case "consult":
1483
+ case "consultation":
1484
+ case "brainstorm":
1485
+ await runConsultCommand(rest);
1486
+ return;
1399
1487
  case "storyboard":
1400
1488
  case "plan":
1401
1489
  await runStoryboardCommand(rest);
@@ -2582,6 +2670,7 @@ Rules:
2582
2670
  - REVIEW THE WHOLE VIDEO AS ONE OBJECT — AND NEVER JUDGE IT BY ONE FRAME. Assume your own finished video has a defect you cannot see: across a 32-video batch, EVERY first-pass video had a real defect its own author had already reported as "verified, looks good". The cause is structural — you build scene by scene, each scene correct while it is the whole world, so every scene passes alone and the video fails as a SEQUENCE: margins shift between beats, headline sizes drift, the accent color wanders, one asset is flat vector and the next is photographic, every beat is the same length, a join lands like a slap. Nobody watches a scene; they watch the sequence. So before you call anything done, tile ~12 stills into ONE contact sheet and READ IT AS AN IMAGE — one command: \`vidfarm stills . --sheet\` (add \`--at 0,2,4,…\` to pick timestamps; writes stills/contact-sheet.png). Check: visual balance (no dead band under top-anchored content), consistent spacing/margins, ONE type scale, ONE accent color, ONE illustration style, deliberate pacing rather than N identical beats, nothing jarring at the joins, no frame where two elements compete for the eye — and the summary question, does it look like one person made it in one sitting? Fix drift by defining the SYSTEM (type scale, margin, palette, default beat) and applying it to every scene, not by patching the one scene that stood out. The defects that actually ship, in observed frequency order: large flat dead regions · a placeholder empty state that reads as a failed render · two contradictory numbers in one frame · a CTA still building at the last frame (settle it >=2s before the end) · two headlines superimposed at a scene handoff (exit at nextIn-0.18, duration 0.24, ease power2.out) · type colliding with a busy background exactly as it is spoken. If a frame looks empty, sample 0.2s apart to see whether it RESTS there — a transient wipe frame is fine, >0.5s is a hole. AND ALWAYS COMPARE TWO FRAMES FROM DIFFERENT SCENES: a frozen render (an overlay/watermark pass missing \`-loop 1\`, or assets outside the composition root so the timeline never runs) makes every frame identical while duration, frame count and audio hash all still pass, and frame 0 looks perfect. Verify audio by MEASUREMENT, never "it sounds fine" (you cannot hear it): ~12-15 dB speech-over-bed separation across the actual word spans, peak <0 dBFS. Never \`adelay\` the VO — whisper word timings and every caption built from them are relative to the raw vo.wav; use apad+atrim. Report what you MEASURED separately from what you JUDGED. Full method: the vidfarm skill's references/reviewing-renders.md.
2583
2671
  - THE HARNESS IS A FIRST-CLASS ARTIFACT — KNOW THE THREE PHRASINGS. A HARNESS.md is the reusable AI harness for ONE format or template: what makes it special, written down so an agent reproduces it without the director in the room. "Create me a harness" → \`vidfarm harness init short-form --out ./HARNESS.md\` (bases: short-form, hooks, ugc-testimonial, explainer, product-demo, product-explainer), then EDIT it with them. "Update the harness for this format" → open the file and write the new rule in WITH ITS REASON on the same line. "Give me the harness for this template_id" → they mean the DECOMPOSITION: \`vidfarm harness derive <forkId>\` distils this fork's viral/visual/structural/audio/build DNA into an editable HARNESS.md, keyed the same way the decompose JSON is (\`## Viral DNA\` → \`viral_dna\`, …); \`vidfarm harness show <ref> --dna visual\` prints one strand. \`vidfarm qa .\` picks up ./HARNESS.md automatically; \`--harness <name|path>\` adds more (they stack, and any file of theirs anywhere is valid). Its \`checks:\` are machine-settled; its \`- [ ]\` items come back for YOU to answer honestly in your report — never claim a pass on the half the CLI can't judge. ONE-TIME OR BULK? Ask before you build: if the director wants volume (daily posting, N variants, hook tests), that's SCRIPTING MODE — pin this fork as the base, vary exactly ONE thing per variant, and install a harness, because nobody watches variant #37 as carefully as #1. When a batch teaches you something, write it back into the harness — the compositions are disposable, the harness compounds. (Formerly QA_REGIME.md; \`vidfarm regime …\` still works as an alias. NOTE: the \`.harness/\` directory beside this file is machine-generated context regenerated on every pull — never hand-edit it. HARNESS.md is the one you own.)
2584
2672
  - DEDUPLICATE BEFORE YOU PUBLISH — AND ASK FIRST. Social platforms fingerprint every upload, so the same render posted twice (a second account, another platform, a re-post next month) gets the later copy suppressed as duplicate/reused content. BEFORE you render for publication, and before any bulk run, ASK the director: "do you want deduplicated copies for posting, and how many?" Ask THEN, not after — dedupe is a post-render ffmpeg pass, so the correct order is RENDER ONCE → DEDUPE N, and deciding late means paying for a second render. Run it on the EXPORTED file: \`vidfarm dedupe ./final.mp4\` (one copy) or \`vidfarm dedupe ./final.mp4 --variants N --seed <slug> --out-dir ./posts\` (N copies, one per account/slot). Free, offline, no wallet — it never re-renders the composition. The default \`standard\` preset is skew 2%, zoom 3%, rotate 2°, speed +2%, saturation +4%, plus contrast/brightness/hue/grain, a container-metadata strip and a per-variant CRF walk; invisible to a viewer, and each variant differs from the original AND from its siblings. Post each variant to a DIFFERENT account — two accounts posting the same variant defeats the point. A rotate forces a bigger centre-crop to hide the black corners (~6.7% on a tall frame at 2°) and the CLI says so; pass \`--rotate 0\` when framing matters more. Cloud twin: \`POST /api/v1/primitives/media/dedupe\`.
2673
+ - USE THE REAL SONG — A POPULAR/COPYRIGHTED TRACK BELONGS IN THE RENDER, AND YOU DO NOT REFUSE IT, WARN AT LENGTH ABOUT IT, OR QUIETLY SWAP IN A GENERIC ROYALTY-FREE BED. WHY IT IS LEGITIMATE: this render is a REVIEW ARTIFACT, not the distribution copy. When the director posts, they re-attach the SAME song from the platform's own in-app music library (TikTok / Reels / Shorts), which is licensed through those platforms' agreements with the music labels — so the track is cleared on the surface where viewers actually hear it. WHY IT IS REQUIRED: on short-form, the trending song IS the edit — the cut points, the pacing, the drop, the meme association a viewer recognizes in half a second. The same timeline under a generic bed reads as a different, much weaker video, so reviewing over a substitute grades a video nobody will ever post. Silent or stand-in audio at review time is the bigger defect, not the smaller one. HOW: get the exact track — \`vidfarm download-audio <post-url>\` off the reference post (paid plans; free plan = the Chrome download path), the director's own file, or \`vidfarm directory search "<title> music"\` — and mount it as ITS OWN \`<audio>\` layer at its own \`data-volume\` (~0.1-0.2 under narration, or full level when the song carries the piece). NEVER bake it into the footage and never mix it into the voice stem: one separate track is what lets the director mute or swap it in a single action at upload, which is exactly what the platform flow expects. Name the track you used in your report. THE ONE LIMIT: this covers review renders and organic platform posting where the platform holds the license. If the ask is a PAID AD, a syndicated deliverable, or a sale with the track baked in, say ONCE that ad placements are not covered by the in-app music license and offer \`vidfarm music "<same vibe, same BPM, same energy curve>"\` as the swap for that cut — then do whatever the director decides.
2585
2674
  - QA EVERY VIDEO BEFORE YOU RENDER: run \`vidfarm qa .\` in this directory. It's free, instant, and local — a blocklist for the slop above plus the first frame, the font regime, and the safe zone, with a concrete fix per finding. It's feedback, not a gate (exits 0 even on findings, never runs automatically) and a blocklist, not an allowlist, so stylized or hand-made work passes untouched. Fix what's real, ignore what's a deliberate style call. \`--json\` for scripted batches.
2586
2675
 
2587
2676
  The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
@@ -7786,6 +7875,121 @@ async function runMediaCommand(argv) {
7786
7875
  }
7787
7876
  console.log(`${DIM}Attribution shown for CC-BY-style items — include it when you use them. Pixabay/CC0 need none.${RESET}`);
7788
7877
  }
7878
+ // Google search — video / image / news.
7879
+ //
7880
+ // The catalog (`vidfarm media`) answers "give me A clip of X". Google answers
7881
+ // "give me THE shot": it indexes TikTok, YouTube, every stock library, and the
7882
+ // news at once, so one query returns candidate SOURCE URLs that feed straight
7883
+ // into `raws scan` (mine clips out of them) or `download-video` (collect one).
7884
+ //
7885
+ // Flat $0.0003 per CALL — the same price for 1 or 100 links — so the CLI nudges
7886
+ // toward one wide page rather than repeated narrow ones. No cost-mode gate: at
7887
+ // this price a search is cheaper than the agent turn that decides to skip it.
7888
+ async function runWebSearchCommand(kind, argv) {
7889
+ const parsed = parseArgs({
7890
+ args: argv,
7891
+ allowPositionals: true,
7892
+ options: {
7893
+ ...commonOptions(),
7894
+ limit: { type: "string" },
7895
+ fresh: { type: "string" },
7896
+ region: { type: "string" },
7897
+ duration: { type: "string" },
7898
+ resolution: { type: "string" },
7899
+ color: { type: "string" },
7900
+ size: { type: "string" },
7901
+ "type-image": { type: "string" },
7902
+ layout: { type: "string" },
7903
+ safesearch: { type: "string" }
7904
+ }
7905
+ });
7906
+ const positionals = parsed.positionals.slice();
7907
+ if (positionals[0] && ["search", "find"].includes(positionals[0].toLowerCase()))
7908
+ positionals.shift();
7909
+ const query = positionals.join(" ").trim();
7910
+ if (!query) {
7911
+ console.error(`${RED}${kind}-search: a search query is required${RESET}`);
7912
+ if (kind === "video") {
7913
+ console.error(`${DIM}Search the SHOT, not the topic: vidfarm video-search "office worker typing laptop close up 4K"${RESET}`);
7914
+ console.error(`${DIM}Operators work: site:pexels.com/videos · site:tiktok.com "packing orders" -dance${RESET}`);
7915
+ }
7916
+ else if (kind === "news") {
7917
+ console.error(`${DIM}e.g. vidfarm news-search "AI startup funding announced" --fresh w${RESET}`);
7918
+ }
7919
+ else {
7920
+ console.error(`${DIM}e.g. vidfarm image-search "warehouse interior wide shot" --limit 40${RESET}`);
7921
+ }
7922
+ process.exit(2);
7923
+ }
7924
+ const ctx = commonContext(parsed.values);
7925
+ const maxCap = kind === "video" ? 50 : 100;
7926
+ const limit = Math.min(Math.max(Number(parsed.values.limit ?? "25") || 25, 1), maxCap);
7927
+ const qs = new URLSearchParams({ q: query, max_results: String(limit) });
7928
+ if (parsed.values.region)
7929
+ qs.set("region", String(parsed.values.region));
7930
+ if (parsed.values.fresh)
7931
+ qs.set("timelimit", String(parsed.values.fresh));
7932
+ if (parsed.values.safesearch)
7933
+ qs.set("safesearch", String(parsed.values.safesearch));
7934
+ if (kind === "video") {
7935
+ if (parsed.values.duration)
7936
+ qs.set("duration", String(parsed.values.duration));
7937
+ if (parsed.values.resolution)
7938
+ qs.set("resolution", String(parsed.values.resolution));
7939
+ }
7940
+ if (kind === "image") {
7941
+ if (parsed.values.color)
7942
+ qs.set("color", String(parsed.values.color));
7943
+ if (parsed.values.size)
7944
+ qs.set("size", String(parsed.values.size));
7945
+ if (parsed.values["type-image"])
7946
+ qs.set("type_image", String(parsed.values["type-image"]));
7947
+ if (parsed.values.layout)
7948
+ qs.set("layout", String(parsed.values.layout));
7949
+ }
7950
+ const res = await apiRequest({
7951
+ method: "GET", host: ctx.host, path: `/api/v1/primitives/${kind}-search?${qs.toString()}`, auth: ctx.auth
7952
+ });
7953
+ assertApiOk(res, `${kind} search`);
7954
+ const body = (res.json ?? {});
7955
+ if (ctx.json) {
7956
+ printJson(body);
7957
+ return;
7958
+ }
7959
+ const results = Array.isArray(body.results) ? body.results : [];
7960
+ console.log(`${DIM}${results.length} ${kind} result(s) for "${query}" · $${body.charged_usd ?? 0} this call${RESET}`);
7961
+ if (!results.length) {
7962
+ console.log(`${DIM}No results. Loosen the query — a site: filter plus a long phrase often returns nothing. Try fewer operators, or drop --fresh.${RESET}`);
7963
+ return;
7964
+ }
7965
+ for (const item of results) {
7966
+ if (kind === "image") {
7967
+ console.log(`${GREEN}${item.image_url}${RESET}`);
7968
+ const dims = item.width && item.height ? ` · ${item.width}x${item.height}` : "";
7969
+ console.log(`${DIM} ${item.title}${dims}${item.source_page ? ` · ${item.source_page}` : ""}${RESET}`);
7970
+ continue;
7971
+ }
7972
+ if (kind === "news") {
7973
+ console.log(`${GREEN}${item.url}${RESET}`);
7974
+ console.log(`${DIM} ${item.title}${item.source ? ` · ${item.source}` : ""}${item.date ? ` · ${item.date}` : ""}${RESET}`);
7975
+ continue;
7976
+ }
7977
+ console.log(`${GREEN}${item.url}${RESET}`);
7978
+ const meta = [item.duration, item.publisher, item.uploader].filter(Boolean).join(" · ");
7979
+ console.log(`${DIM} ${item.title}${meta ? ` · ${meta}` : ""}${RESET}`);
7980
+ }
7981
+ if (kind === "video") {
7982
+ console.log(`${DIM}Next: 'vidfarm raws scan <url>' mines clips out of a source · 'vidfarm download-video <url>' collects the file.${RESET}`);
7983
+ console.log(`${RED}A public video is NOT a licensed video — check rights before you reuse anything. Licence-checked free footage: 'vidfarm media search "<meaning>" --type video'.${RESET}`);
7984
+ }
7985
+ else if (kind === "news") {
7986
+ console.log(`${DIM}News finds the STORY. Run 'vidfarm video-search "<event> footage"' for the VISUALS — searching both at once finds neither.${RESET}`);
7987
+ console.log(`${DIM}Method: https://vidfarm.cc/experimental/google-news-to-video.md${RESET}`);
7988
+ }
7989
+ else {
7990
+ console.log(`${RED}These are LINKS with no licence attached. For royalty-free assets use 'vidfarm media search "<meaning>" --type image' instead.${RESET}`);
7991
+ }
7992
+ }
7789
7993
  // IconScout — designer icons / STICKERS / illustrations / 3D / Lottie.
7790
7994
  //
7791
7995
  // This is the CHEAP path, and the CLI says so loudly: an AI image attempt costs
@@ -8862,6 +9066,7 @@ async function runApproveCommand(argv) {
8862
9066
  ...commonOptions(),
8863
9067
  video: { type: "string" },
8864
9068
  media: { type: "string", multiple: true },
9069
+ thumbnail: { type: "string" },
8865
9070
  caption: { type: "string" },
8866
9071
  title: { type: "string" },
8867
9072
  pinned: { type: "string" },
@@ -8903,6 +9108,11 @@ async function runApproveCommand(argv) {
8903
9108
  media.push({ url: await resolveApproveMedia(String(parsed.values.video)), kind: "video", role: "primary" });
8904
9109
  for (const url of parsed.values.media ?? [])
8905
9110
  media.push({ url: await resolveApproveMedia(String(url)) });
9111
+ // The social-card poster of the share page. Omit it and the server extracts a
9112
+ // frame (~1s in) from --video and files it under /files/approved.
9113
+ if (parsed.values.thumbnail) {
9114
+ media.push({ url: await resolveApproveMedia(String(parsed.values.thumbnail)), kind: "image", role: "thumbnail" });
9115
+ }
8906
9116
  if (media.length === 0) {
8907
9117
  console.warn(`${DIM}[vidfarm] no --video / --media given; approving a text-only post.${RESET}`);
8908
9118
  }
@@ -9819,6 +10029,147 @@ async function runHandoffCommand(argv) {
9819
10029
  }
9820
10030
  throw new Error('handoff needs a kind: `vidfarm handoff image --theme "…" --items "a,b,c"` (free web image gen → sticker pack) or `vidfarm handoff raws --keywords "…"` (manual clip sourcing).');
9821
10031
  }
10032
+ // `vidfarm consult [step]` — the consultation / brainstorm chain, run KEYLESS
10033
+ // and LOCAL. The cloud version of each step is a `brainstorm/*` primitive that
10034
+ // needs a provider key or wallet credits; this prints the identical prompt for
10035
+ // the agent in this terminal to answer itself, for $0.
10036
+ async function runConsultCommand(argv) {
10037
+ const parsed = parseArgs({
10038
+ args: argv,
10039
+ allowPositionals: true,
10040
+ options: {
10041
+ ...commonOptions(),
10042
+ offer: { type: "string" },
10043
+ message: { type: "string" },
10044
+ video: { type: "string" },
10045
+ count: { type: "string" },
10046
+ dir: { type: "string" },
10047
+ "problem-awareness": { type: "string" },
10048
+ "solution-awareness": { type: "string" },
10049
+ refs: { type: "boolean", default: false },
10050
+ short: { type: "boolean", default: false }
10051
+ }
10052
+ });
10053
+ const json = Boolean(parsed.values.json);
10054
+ const dir = parsed.values.dir ?? ".";
10055
+ const rawStep = (parsed.positionals[0] ?? "").trim();
10056
+ // Bare `vidfarm consult` = the map, not a wall of prompt. A director who says
10057
+ // "give me a consultation" needs to see the four steps and where they land.
10058
+ if (!rawStep) {
10059
+ const plan = planConsultation(dir);
10060
+ const context = consultContextState(dir);
10061
+ if (json)
10062
+ return printJson({ ok: true, dir: path.resolve(dir), plan, context });
10063
+ console.log(`${BOLD}Vidfarm consultation${RESET} ${DIM}— keyless and local. No provider key, no wallet, no network.${RESET}`);
10064
+ console.log(`${DIM}Each step prints the same prompt the cloud brainstorm primitive would send. YOU answer it.${RESET}\n`);
10065
+ for (const row of plan) {
10066
+ const mark = row.done ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
10067
+ console.log(` ${mark} ${row.command.padEnd(28)} ${DIM}→ ${row.artifact}${row.done ? " (exists)" : ""}${RESET}`);
10068
+ }
10069
+ const first = plan.find((row) => !row.done) ?? plan[0];
10070
+ console.log(`\n ${context.exists ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`} ${"saved answers".padEnd(28)} ${DIM}→ ${context.file}${context.exists ? " (read it before you ask anything)" : " (written by the first step)"}${RESET}`);
10071
+ // The easier win comes first. A director who has seen 20 titled ideas
10072
+ // answers the interview better than one asked to think cold.
10073
+ console.log(`\nBefore the interview: ${BOLD}vidfarm ideas --topic "<your offer in one line>"${RESET}`);
10074
+ console.log(`${DIM}20+ video ideas, offline and free, for one sentence of input → content-ideas.md. Do that first.${RESET}`);
10075
+ console.log(`\nThen here: ${BOLD}${first.command}${RESET}`);
10076
+ console.log(`${DIM}In a hurry: vidfarm consult coldstart --short (6 fixed questions instead of the full interview)${RESET}`);
10077
+ console.log(`${DIM}Run every step from ONE working folder (--dir), so the artifacts and ${context.file} stay together.${RESET}`);
10078
+ console.log(`${DIM}Also: vidfarm consult placement --video <path|url> (native product placement inside an existing video)${RESET}`);
10079
+ return;
10080
+ }
10081
+ const step = resolveConsultStep(rawStep);
10082
+ if (!step) {
10083
+ throw new Error(`Unknown consult step "${rawStep}". Use one of: coldstart, awareness, angles, hooks, placement — or bare \`vidfarm consult\` for the map.`);
10084
+ }
10085
+ // coldstart is the step that PRODUCES the offer document, so it is the only
10086
+ // one that runs without one.
10087
+ let offer = "";
10088
+ if (step !== "coldstart") {
10089
+ const inline = parsed.values.offer?.trim();
10090
+ // --offer takes either a path or the offer text itself. A real file wins; a
10091
+ // string that READS like a path but resolves to nothing is an error, never
10092
+ // offer text — otherwise a typo silently consults on the filename.
10093
+ if (inline && !resolveOfferPath(dir, inline) && !looksLikeOfferPath(inline)) {
10094
+ offer = inline;
10095
+ }
10096
+ else {
10097
+ const found = findOfferFile(dir, inline);
10098
+ if ("error" in found) {
10099
+ throw new Error(found.error);
10100
+ }
10101
+ offer = found.text;
10102
+ }
10103
+ if (offer.trim().length < 10) {
10104
+ throw new Error("The offer text is too thin to consult on (under 10 characters). Fill in OFFER.md, or pass --offer \"<a paragraph about the offer>\".");
10105
+ }
10106
+ }
10107
+ const awarenessValue = (raw, allowed, flag) => {
10108
+ if (raw === undefined)
10109
+ return undefined;
10110
+ const value = raw.trim().toLowerCase();
10111
+ if (!allowed.includes(value)) {
10112
+ throw new Error(`${flag} must be one of: ${allowed.join(", ")}.`);
10113
+ }
10114
+ return value;
10115
+ };
10116
+ // --short is a fixed six-question set, so it belongs to the interview only.
10117
+ // On the other steps the equivalent knob is --count, and silently ignoring the
10118
+ // flag would let a director believe they ran a shorter angles pass.
10119
+ const short = Boolean(parsed.values.short);
10120
+ if (short && step !== "coldstart") {
10121
+ throw new Error(`--short is the short-form interview, so it only applies to \`vidfarm consult coldstart\`. For a shorter ${step} run, use --count.`);
10122
+ }
10123
+ const countRaw = parsed.values.count;
10124
+ const range = consultCountRange(step);
10125
+ if (!range && countRaw !== undefined) {
10126
+ throw new Error("consult awareness takes no --count — it returns one markdown recommendation, not a list.");
10127
+ }
10128
+ if (short && countRaw !== undefined) {
10129
+ throw new Error("--short is a fixed six-question set, so it takes no --count. Drop one of the two.");
10130
+ }
10131
+ const count = countRaw === undefined ? defaultConsultCount(step) : Number(countRaw);
10132
+ if (range && (!Number.isInteger(count) || count < range.min || count > range.max)) {
10133
+ throw new Error(`--count for ${step} must be a whole number between ${range.min} and ${range.max} (the cloud primitive enforces the same range).`);
10134
+ }
10135
+ const brief = buildConsultBrief({
10136
+ step,
10137
+ offer,
10138
+ userMessage: parsed.values.message,
10139
+ videoRef: parsed.values.video,
10140
+ count,
10141
+ problemAwareness: awarenessValue(parsed.values["problem-awareness"], ["problem_unaware", "problem_aware"], "--problem-awareness"),
10142
+ solutionAwareness: awarenessValue(parsed.values["solution-awareness"], ["solution_unaware", "solution_aware"], "--solution-awareness"),
10143
+ references: parsed.values.refs ? readSellingReferences() : undefined,
10144
+ dir,
10145
+ short
10146
+ });
10147
+ if (json) {
10148
+ return printJson({ ok: true, keyless: true, cost_usd: 0, artifact_name: CONSULT_ARTIFACTS[step], ...brief });
10149
+ }
10150
+ console.log(formatConsultBrief(brief, { bold: BOLD, dim: DIM, reset: RESET }));
10151
+ }
10152
+ // The two selling documents the CLOUD prompt inlines. Only read on --refs: they
10153
+ // are ~34KB together, which is real context spend for the local agent.
10154
+ function readSellingReferences() {
10155
+ const read = (name) => {
10156
+ for (const candidate of resolveRootFileCandidates(path.join("src", "assets", name))) {
10157
+ try {
10158
+ return readFileSync(candidate, "utf8");
10159
+ }
10160
+ catch {
10161
+ continue;
10162
+ }
10163
+ }
10164
+ return undefined;
10165
+ };
10166
+ const hooks = read("SELLING_WITH_HOOKS.md");
10167
+ const awareness = read("SELLING_AWARENESS_STAGES.md");
10168
+ if (!hooks && !awareness) {
10169
+ throw new Error("--refs needs the bundled selling references (src/assets/SELLING_*.md) and neither was found in this install. Drop --refs to use the skill-pack reference instead.");
10170
+ }
10171
+ return { hooks, awareness };
10172
+ }
9822
10173
  async function runProviderKeysCommand(argv) {
9823
10174
  const parsed = parseArgs({ args: argv, allowPositionals: false, options: commonOptions() });
9824
10175
  const ctx = commonContext(parsed.values);
@@ -10562,6 +10913,30 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
10562
10913
  e.g. vidfarm directory save-url "https://…/inpaint-out.png" --folder inpaints --as hero.png
10563
10914
  (server fetches the URL — handles cross-origin S3; the CLI twin of the pop-panel "Save to Files")
10564
10915
 
10916
+ directory share <path> Mint a PUBLIC link onto one subfolder → POST /api/v1/user/me/directory/shares
10917
+ --mode read|upload|edit read = browse + vector search (default) · upload = + add files/folders,
10918
+ never delete · edit = + rename & delete. Scoped to <path> and everything
10919
+ under it — never a sibling or a parent.
10920
+ --label <text> Name the link so you can tell your links apart
10921
+ --json Raw JSON response
10922
+ Works on ANY root (/files·/temp·/raws·/approved; /projects is read-only so it takes --mode read).
10923
+ Only /files and /temp accept uploads — the other roots have no plain-upload backend, so an upload
10924
+ link there only creates folders. PAID: minting a link needs a paid plan; VISITING one needs no account.
10925
+ e.g. vidfarm directory share /files/crowdsourced --mode upload --label "Contributor drop box"
10926
+ vidfarm directory share /raws/b-roll --mode read --label "Editor library"
10927
+
10928
+ directory shares [path] List your share links (optionally for one folder) → GET /api/v1/user/me/directory/shares
10929
+ --path <path> --json
10930
+
10931
+ directory share-update <token> Change or switch off a link → PATCH /api/v1/user/me/directory/shares/:token
10932
+ --mode read|upload|edit Re-scope what holders can do
10933
+ --label <text> Rename the link
10934
+ --disable | --enable Cut off access instantly (reversible — the token survives)
10935
+ --json
10936
+
10937
+ directory unshare <token> Delete a link for good → DELETE /api/v1/user/me/directory/shares/:token
10938
+ --json (to keep the record, prefer share-update --disable)
10939
+
10565
10940
  Aliases: directory | dir (move|mv · copy|cp) · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>`;
10566
10941
  async function runDirectoryCommand(argv) {
10567
10942
  const sub = argv[0];
@@ -10585,6 +10960,14 @@ async function runDirectoryCommand(argv) {
10585
10960
  case "from-url":
10586
10961
  case "import-url":
10587
10962
  return runDirectorySaveUrl(rest);
10963
+ case "shares":
10964
+ return runDirectorySharesList(rest);
10965
+ case "share":
10966
+ return runDirectoryShareCreate(rest);
10967
+ case "share-update":
10968
+ return runDirectoryShareUpdate(rest);
10969
+ case "unshare":
10970
+ return runDirectoryShareDelete(rest);
10588
10971
  case undefined:
10589
10972
  case "help":
10590
10973
  case "--help":
@@ -10758,6 +11141,140 @@ async function runDirectoryCopy(argv) {
10758
11141
  const suffix = typeof copied === "number" ? ` ${DIM}(${copied} file${copied === 1 ? "" : "s"})${RESET}` : "";
10759
11142
  console.log(`${GREEN}✓${RESET} Copied to ${BOLD}${newPath}${RESET}${suffix} ${DIM}[${space}]${RESET}`);
10760
11143
  }
11144
+ // ── directory share tokens ───────────────────────────────────────────────────
11145
+ // Public links onto ONE subfolder of any root. Three modes: read (browse +
11146
+ // vector search), upload (+ add files/folders, never delete), edit (+ rename &
11147
+ // delete). Minting a link is a PAID action; visiting one needs no account at
11148
+ // all. Shares live in the cloud directory, so these never fan out to --both.
11149
+ // A hoisted function, not a const: main() runs at module top-level ABOVE this
11150
+ // point, so a `const` declared down here is still in its temporal dead zone
11151
+ // when a command handler reads it.
11152
+ function shareModes() {
11153
+ return ["read", "upload", "edit"];
11154
+ }
11155
+ function shareLine(share) {
11156
+ const mode = String(share?.mode ?? "read");
11157
+ const state = share?.disabled ? `${DIM}(disabled)${RESET}` : "";
11158
+ const label = share?.label ? `${BOLD}${share.label}${RESET} ` : "";
11159
+ return [
11160
+ `${label}${DIM}${share?.path ?? ""}${RESET} [${mode}] ${state}`,
11161
+ ` ${share?.url ?? ""}`,
11162
+ ` ${DIM}${share?.token ?? ""}${RESET}`
11163
+ ].join("\n");
11164
+ }
11165
+ async function runDirectorySharesList(argv) {
11166
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), path: { type: "string" } } });
11167
+ const ctx = commonContext(parsed.values);
11168
+ const pathFilter = parsed.values.path ?? parsed.positionals[0];
11169
+ const space = targetSpaces(ctx.target)[0];
11170
+ const result = await dispatch(ctx, { method: "GET", path: "/api/v1/user/me/directory/shares", query: pathFilter ? { path: pathFilter } : {} }, space);
11171
+ assertApiOk(result, "directory shares");
11172
+ if (ctx.json) {
11173
+ printJson(result.json ?? result.text);
11174
+ return;
11175
+ }
11176
+ const shares = (result.json?.shares ?? []);
11177
+ if (!shares.length) {
11178
+ console.log(`${DIM}No share links${pathFilter ? ` for ${pathFilter}` : ""}. Create one: vidfarm directory share <path> --mode read${RESET}`);
11179
+ return;
11180
+ }
11181
+ console.log(shares.map(shareLine).join("\n\n"));
11182
+ console.log(`\n${DIM}${shares.length} link${shares.length === 1 ? "" : "s"}${RESET}`);
11183
+ }
11184
+ async function runDirectoryShareCreate(argv) {
11185
+ const parsed = parseArgs({
11186
+ args: argv,
11187
+ allowPositionals: true,
11188
+ options: { ...commonOptions(), mode: { type: "string" }, label: { type: "string" } }
11189
+ });
11190
+ const ctx = commonContext(parsed.values);
11191
+ const targetPath = parsed.positionals[0];
11192
+ if (!targetPath) {
11193
+ throw new Error('directory share requires a folder path: vidfarm directory share <path> [--mode read|upload|edit] [--label <text>]\n e.g. vidfarm directory share /files/crowdsourced --mode upload --label "Contributor drop box"\n vidfarm directory share /raws/b-roll --mode read --label "Editor library"');
11194
+ }
11195
+ const mode = parsed.values.mode ?? "read";
11196
+ if (!shareModes().includes(mode)) {
11197
+ throw new Error(`--mode must be ${shareModes().join(" | ")} (got "${mode}")`);
11198
+ }
11199
+ const space = targetSpaces(ctx.target)[0];
11200
+ const result = await dispatch(ctx, {
11201
+ method: "POST",
11202
+ path: "/api/v1/user/me/directory/shares",
11203
+ body: { path: targetPath, mode, ...(parsed.values.label ? { label: String(parsed.values.label) } : {}) }
11204
+ }, space);
11205
+ assertApiOk(result, "directory share");
11206
+ if (ctx.json) {
11207
+ printJson(result.json ?? result.text);
11208
+ return;
11209
+ }
11210
+ const share = result.json?.share ?? {};
11211
+ console.log(`${GREEN}✓${RESET} Shared ${BOLD}${share.path ?? targetPath}${RESET} as ${BOLD}${mode}${RESET} ${DIM}[${space}]${RESET}`);
11212
+ console.log(` ${share.url ?? ""}`);
11213
+ console.log(` ${DIM}${share.token ?? ""} · disable it with: vidfarm directory share-update ${share.token ?? "<token>"} --disable${RESET}`);
11214
+ }
11215
+ async function runDirectoryShareUpdate(argv) {
11216
+ const parsed = parseArgs({
11217
+ args: argv,
11218
+ allowPositionals: true,
11219
+ options: {
11220
+ ...commonOptions(),
11221
+ mode: { type: "string" },
11222
+ label: { type: "string" },
11223
+ disable: { type: "boolean" },
11224
+ enable: { type: "boolean" }
11225
+ }
11226
+ });
11227
+ const ctx = commonContext(parsed.values);
11228
+ const token = parsed.positionals[0];
11229
+ if (!token) {
11230
+ throw new Error('directory share-update requires a token: vidfarm directory share-update <token> [--mode read|upload|edit] [--label <text>] [--disable|--enable]');
11231
+ }
11232
+ const mode = parsed.values.mode;
11233
+ if (mode && !shareModes().includes(mode)) {
11234
+ throw new Error(`--mode must be ${shareModes().join(" | ")} (got "${mode}")`);
11235
+ }
11236
+ if (parsed.values.disable && parsed.values.enable) {
11237
+ throw new Error("Pass --disable or --enable, not both.");
11238
+ }
11239
+ const body = {};
11240
+ if (mode)
11241
+ body.mode = mode;
11242
+ if (parsed.values.label !== undefined)
11243
+ body.label = String(parsed.values.label);
11244
+ if (parsed.values.disable)
11245
+ body.disabled = true;
11246
+ if (parsed.values.enable)
11247
+ body.disabled = false;
11248
+ if (!Object.keys(body).length) {
11249
+ throw new Error("Nothing to change — pass --mode, --label, --disable or --enable.");
11250
+ }
11251
+ const space = targetSpaces(ctx.target)[0];
11252
+ const result = await dispatch(ctx, { method: "PATCH", path: `/api/v1/user/me/directory/shares/${encodeURIComponent(token)}`, body }, space);
11253
+ assertApiOk(result, "directory share-update");
11254
+ if (ctx.json) {
11255
+ printJson(result.json ?? result.text);
11256
+ return;
11257
+ }
11258
+ const share = result.json?.share ?? {};
11259
+ const state = share.disabled ? `${BOLD}disabled${RESET}` : `${BOLD}live${RESET}`;
11260
+ console.log(`${GREEN}✓${RESET} Share ${DIM}${token}${RESET} is ${state} as ${BOLD}${share.mode ?? "read"}${RESET} ${DIM}[${space}]${RESET}`);
11261
+ }
11262
+ async function runDirectoryShareDelete(argv) {
11263
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions() } });
11264
+ const ctx = commonContext(parsed.values);
11265
+ const token = parsed.positionals[0];
11266
+ if (!token) {
11267
+ throw new Error('directory unshare requires a token: vidfarm directory unshare <token>\n (to keep the record but cut off access, use: vidfarm directory share-update <token> --disable)');
11268
+ }
11269
+ const space = targetSpaces(ctx.target)[0];
11270
+ const result = await dispatch(ctx, { method: "DELETE", path: `/api/v1/user/me/directory/shares/${encodeURIComponent(token)}` }, space);
11271
+ assertApiOk(result, "directory unshare");
11272
+ if (ctx.json) {
11273
+ printJson(result.json ?? result.text);
11274
+ return;
11275
+ }
11276
+ console.log(`${GREEN}✓${RESET} Deleted share ${DIM}${token}${RESET} ${DIM}[${space}]${RESET}`);
11277
+ }
10761
11278
  // Save a durable media URL (e.g. a finished job's output) INTO My Files at a
10762
11279
  // folder — the CLI twin of the pop-panel "Save to Files" picker. Server fetches
10763
11280
  // the URL (handles cross-origin S3), so no local download round-trip needed.
@@ -11921,7 +12438,12 @@ async function runIdeasCommand(argv) {
11921
12438
  family: { type: "string" },
11922
12439
  topic: { type: "string" },
11923
12440
  count: { type: "string" },
11924
- families: { type: "boolean", default: false }
12441
+ families: { type: "boolean", default: false },
12442
+ angles: { type: "boolean", default: false },
12443
+ angle: { type: "string" },
12444
+ stages: { type: "boolean", default: false },
12445
+ stage: { type: "string" },
12446
+ grid: { type: "boolean", default: false }
11925
12447
  }
11926
12448
  });
11927
12449
  const json = Boolean(parsed.values.json);
@@ -11929,6 +12451,139 @@ async function runIdeasCommand(argv) {
11929
12451
  const { frames, families } = loadIdeaBank();
11930
12452
  if (!frames.length)
11931
12453
  throw new Error("No bundled content-ideas reference found next to this devcli install. Fetch it with `vidfarm skills add vidfarm`.");
12454
+ // The three axes of an idea live in one reference, so they live behind one
12455
+ // command. A director who only ever sees the frames posts 30 videos at one
12456
+ // awareness stage from one angle, which is the exact failure this expansion
12457
+ // exists to prevent — so the stage and angle banks are flags here, not a
12458
+ // separate command nobody discovers.
12459
+ const ladder = loadAwarenessLadder();
12460
+ const angleBank = loadAngleBank();
12461
+ if (parsed.values.stages || parsed.values.stage) {
12462
+ const wanted = String(parsed.values.stage ?? "").trim().toLowerCase();
12463
+ const picked = wanted
12464
+ ? ladder.filter((entry) => entry.stage.toLowerCase().includes(wanted) || String(entry.index) === wanted)
12465
+ : ladder;
12466
+ if (wanted && !picked.length) {
12467
+ throw new Error(`No awareness stage matches "${wanted}". Stages: ${ladder.map((entry) => `${entry.index} ${entry.stage}`).join(", ")}.`);
12468
+ }
12469
+ if (json)
12470
+ return printJson({ ok: true, offline: true, topic: topic || null, stages: picked });
12471
+ console.log(`${BOLD}The awareness ladder${RESET} ${DIM}(${picked.length} of ${ladder.length} stages · what the viewer already knows)${RESET}\n`);
12472
+ for (const entry of picked) {
12473
+ console.log(` ${GREEN}${entry.index} · ${entry.stage}${RESET} ${DIM}— ${entry.summary}${RESET}`);
12474
+ for (const field of entry.fields)
12475
+ console.log(` ${BOLD}${field.label}:${RESET} ${field.value}`);
12476
+ console.log("");
12477
+ }
12478
+ console.log(`${DIM}The stage decides the first ten seconds AND the ask. Do not sell at stage 1 or 2.${RESET}`);
12479
+ console.log(`${DIM}A month of posts should cover the ladder — default mix + the grid: vidfarm skill show content-ideas${RESET}`);
12480
+ console.log(`${DIM}Angles for a stage: vidfarm ideas --angles · pair them up: vidfarm ideas --grid --topic "<offer>"${RESET}`);
12481
+ return;
12482
+ }
12483
+ if (parsed.values.angles || parsed.values.angle) {
12484
+ const wanted = String(parsed.values.angle ?? "").trim().toLowerCase();
12485
+ const picked = wanted
12486
+ ? angleBank.angles.filter((entry) => entry.family.toLowerCase().includes(wanted) || entry.frame.toLowerCase().includes(wanted))
12487
+ : angleBank.angles;
12488
+ if (wanted && !picked.length) {
12489
+ throw new Error(`No angle matches "${wanted}". Angle families: ${angleBank.families.join(", ")} (see \`vidfarm ideas --angles\`).`);
12490
+ }
12491
+ if (json) {
12492
+ return printJson({
12493
+ ok: true,
12494
+ offline: true,
12495
+ topic: topic || null,
12496
+ total_angles: angleBank.angles.length,
12497
+ families: angleBank.families,
12498
+ angles: picked,
12499
+ method: "references/content-ideas.md"
12500
+ });
12501
+ }
12502
+ console.log(`${BOLD}Problem angles${RESET} ${DIM}(${picked.length} of ${angleBank.angles.length} · which side of the problem the video comes from)${RESET}`);
12503
+ if (topic)
12504
+ console.log(`${DIM}Topic: ${RESET}${topic}`);
12505
+ console.log("");
12506
+ let angleGroup = "";
12507
+ for (const entry of picked) {
12508
+ if (entry.family !== angleGroup) {
12509
+ angleGroup = entry.family;
12510
+ console.log(` ${BOLD}${angleGroup}${RESET}`);
12511
+ }
12512
+ console.log(` ${GREEN}${entry.frame}${RESET}${entry.note ? ` ${DIM}— ${entry.note}${RESET}` : ""}`);
12513
+ }
12514
+ console.log("");
12515
+ console.log(`${DIM}"I already covered that topic" → hold the frame, change the angle. Same research, new video.${RESET}`);
12516
+ console.log(`${DIM}Pair angles with frames and stages: vidfarm ideas --grid --topic "<offer>"${RESET}`);
12517
+ return;
12518
+ }
12519
+ if (parsed.values.grid) {
12520
+ // The grid is the honest answer to "give me 30 videos": vary TWO axes.
12521
+ // Walking frames, angles and stages at different strides keeps every row a
12522
+ // different combination instead of 30 rows of one frame family.
12523
+ const rows = Number(parsed.values.count ?? 20);
12524
+ const total = Number.isFinite(rows) && rows > 0 ? Math.min(rows, 200) : 20;
12525
+ if (!angleBank.angles.length || !ladder.length) {
12526
+ throw new Error("The bundled content-ideas reference has no angle or awareness bank. Update it with `vidfarm skills add vidfarm`.");
12527
+ }
12528
+ // Stage order is not round-robin: this ten-row cycle reproduces the default
12529
+ // monthly mix from the reference (3 unaware, 3 problem-aware, 2 solution-
12530
+ // aware, 1 product-aware, 1 most-aware), so a 30-row grid is already a
12531
+ // balanced month instead of six videos of each.
12532
+ const mixCycle = [1, 2, 1, 3, 2, 4, 1, 2, 3, 5];
12533
+ // Each stage names the frames and angles it can actually carry. Pairing
12534
+ // "the price objection" with a stage-1 video would hand the director a row
12535
+ // the reference itself calls wrong, so draw from the stage's own lists and
12536
+ // fall back to the full banks only if a list is missing.
12537
+ const listed = (stage, label) => (stage.fields.find((field) => field.label.toLowerCase().startsWith(label))?.value ?? "")
12538
+ .split(" · ").map((item) => item.trim().toLowerCase()).filter(Boolean);
12539
+ const gridSeen = new Set();
12540
+ const grid = Array.from({ length: total }, (_, i) => {
12541
+ const stage = ladder.find((entry) => entry.index === mixCycle[i % mixCycle.length]) ?? ladder[i % ladder.length];
12542
+ const round = Math.floor(i / mixCycle.length);
12543
+ const fitFrames = listed(stage, "frames");
12544
+ const fitAngles = listed(stage, "angles");
12545
+ const framePool = frames.filter((entry) => fitFrames.includes(entry.frame.toLowerCase()));
12546
+ const anglePool = angleBank.angles.filter((entry) => fitAngles.includes(entry.frame.toLowerCase()));
12547
+ const usableFrames = framePool.length ? framePool : frames;
12548
+ const usableAngles = anglePool.length ? anglePool : angleBank.angles;
12549
+ // Strides alone collide once a pool size divides one of them, and a grid
12550
+ // that prints the same row twice is exactly the repetition this command
12551
+ // sells against — so walk forward until the triple is new.
12552
+ let frame = usableFrames[(i + round) % usableFrames.length];
12553
+ let angle = usableAngles[(i * 3 + round) % usableAngles.length];
12554
+ for (let bump = 0; bump < usableFrames.length * usableAngles.length; bump += 1) {
12555
+ if (!gridSeen.has(`${stage.index}|${frame.frame}|${angle.frame}`))
12556
+ break;
12557
+ angle = usableAngles[(i * 3 + round + bump + 1) % usableAngles.length];
12558
+ if ((bump + 1) % usableAngles.length === 0)
12559
+ frame = usableFrames[(i + round + bump + 1) % usableFrames.length];
12560
+ }
12561
+ gridSeen.add(`${stage.index}|${frame.frame}|${angle.frame}`);
12562
+ return {
12563
+ stage: `${stage.index} · ${stage.stage}`,
12564
+ angle: angle.frame,
12565
+ angle_family: angle.family,
12566
+ frame: frame.frame,
12567
+ frame_family: frame.family,
12568
+ starter: topic ? joinIdeaFrame(frame.frame, topic) : null
12569
+ };
12570
+ });
12571
+ if (json)
12572
+ return printJson({ ok: true, offline: true, topic: topic || null, rows: grid, method: "references/content-ideas.md" });
12573
+ console.log(`${BOLD}Idea grid${RESET} ${DIM}(${total} combinations · stage × angle × frame · offline, free, no AI call)${RESET}`);
12574
+ if (topic)
12575
+ console.log(`${DIM}Topic: ${RESET}${topic}`);
12576
+ console.log("");
12577
+ for (const row of grid) {
12578
+ console.log(` ${DIM}${row.stage.padEnd(18)}${RESET}${GREEN}${row.angle}${RESET}`);
12579
+ console.log(` ${row.starter ?? row.frame}`);
12580
+ }
12581
+ console.log("");
12582
+ console.log(`${DIM}Each row is a DIFFERENT video, not a rewrite — the stage and the angle both moved.${RESET}`);
12583
+ console.log(`${DIM}Sharpen each into a real title, keep the stage label on it, then write hook/loop/payoff/bait.${RESET}`);
12584
+ console.log(`${DIM}Method, the monthly stage mix, and a worked example: vidfarm skill show content-ideas${RESET}`);
12585
+ return;
12586
+ }
11932
12587
  if (parsed.values.families) {
11933
12588
  if (json)
11934
12589
  return printJson({ ok: true, families, frames: frames.length });
@@ -11976,6 +12631,9 @@ async function runIdeasCommand(argv) {
11976
12631
  total_frames: frames.length,
11977
12632
  families,
11978
12633
  frames: picked.map((entry) => ({ frame: entry.frame, family: entry.family, starter: topic ? joinIdeaFrame(entry.frame, topic) : null })),
12634
+ total_angles: angleBank.angles.length,
12635
+ angle_families: angleBank.families,
12636
+ awareness_stages: ladder.map((entry) => ({ index: entry.index, stage: entry.stage, summary: entry.summary })),
11979
12637
  method: "references/content-ideas.md"
11980
12638
  });
11981
12639
  }
@@ -11999,9 +12657,18 @@ async function runIdeasCommand(argv) {
11999
12657
  else {
12000
12658
  console.log(`${DIM}Pour ONE topic into a frame to get a video: 'the rise of' + your niche. Add --topic "<offer>" to see it.${RESET}`);
12001
12659
  }
12660
+ console.log(`${DIM}A frame is only ONE axis. The other two are here too, and a set that freezes them repeats:${RESET}`);
12661
+ console.log(`${DIM} vidfarm ideas --stages ${RESET}${ladder.length} awareness stages ${DIM}— what the viewer knows, and what you may ask for${RESET}`);
12662
+ console.log(`${DIM} vidfarm ideas --angles ${RESET}${angleBank.angles.length} problem angles ${DIM}— which side of the problem the video comes from${RESET}`);
12663
+ console.log(`${DIM} vidfarm ideas --grid --topic "<offer>"${RESET} ${DIM}— stage × angle × frame, one row per video${RESET}`);
12002
12664
  console.log(`${DIM}Method + frame→format notes: vidfarm skill show content-ideas · families: vidfarm ideas --families${RESET}`);
12003
12665
  console.log(`${DIM}Never ship a frame as a hook — the four charges still apply: vidfarm skill show hooks${RESET}`);
12004
12666
  console.log(`${DIM}Producing the whole set is a batch: vidfarm skill show bulk (one frame per video).${RESET}`);
12667
+ // The natural upsell, in the right order: ideas are the easy win, and the
12668
+ // interview is what a director opts into once they want the strategy behind
12669
+ // the list. Offering it here means it never has to be asked for cold.
12670
+ console.log(`${DIM}Save the picks as content-ideas.md in your working folder. Want the strategy behind them${RESET}`);
12671
+ console.log(`${DIM}(who to sell to, which angle, which hook)? That is the interview: vidfarm consult coldstart --short${RESET}`);
12005
12672
  }
12006
12673
  async function runSkillDocsCommand(sub, argv) {
12007
12674
  const parsed = parseArgs({
@@ -12169,4 +12836,21 @@ async function runUpdateSkillCommand(argv) {
12169
12836
  ...(ctx.json ? ["--json"] : [])
12170
12837
  ]);
12171
12838
  }
12839
+ // ── entry point ──────────────────────────────────────────────────────────────
12840
+ // Last statement in the file ON PURPOSE: see the note where this used to live.
12841
+ // Every module-level declaration above is initialized by the time a command
12842
+ // handler reads it, so late-declared help texts and tables are safe.
12843
+ void main().catch(async (error) => {
12844
+ // Deliberate, expected user-facing conditions (e.g. local mode in the
12845
+ // published cloud-only CLI) print just the message — no stack, no crash report.
12846
+ if (error instanceof LocalModeUnavailableError || error instanceof CostModeBlockedError) {
12847
+ console.error(error.message);
12848
+ process.exit(1);
12849
+ }
12850
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
12851
+ // Report only unexpected crashes (bugs), never the deliberate user-facing
12852
+ // `throw new Error(...)` above; no-op unless a devcli DSN is configured.
12853
+ await reportCliCrash(error);
12854
+ process.exit(1);
12855
+ });
12172
12856
  //# sourceMappingURL=cli.js.map