@officexapp/vidfarm-devcli 0.21.52 → 0.21.53

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.
@@ -305,7 +305,7 @@ async function cmdMachines(auth, values) {
305
305
  }));
306
306
  out(Boolean(values.json), { machines }, () => {
307
307
  if (!machines.length) {
308
- console.log(`${DIM}No vending machines on this key yet. A paid vidfarm account gets two; open ${RESET}https://vidfarm.cc/marketplace/buyer${DIM} once to create them.${RESET}`);
308
+ console.log(`${DIM}No vending machines on this key yet. A paid vidfarm account gets two; open ${RESET}https://vidfarm.cc/marketplace${DIM} once to create them.${RESET}`);
309
309
  return;
310
310
  }
311
311
  for (const machine of machines) {
@@ -461,7 +461,7 @@ async function cmdRingBell(auth, values) {
461
461
  destination = hasInviteToken(registered.invite_url) ? registered.invite_url : "";
462
462
  }
463
463
  if (!destination)
464
- throw new Error("That machine has no invite link yet — open it once on vidfarm.cc/marketplace/buyer.");
464
+ throw new Error("That machine has no invite link yet — open it once on vidfarm.cc/marketplace.");
465
465
  const result = await dp(auth, `/feeds/${VIDFARM_FEED_ID}/notifications`, {
466
466
  method: "POST",
467
467
  body: {
@@ -655,7 +655,7 @@ export function qaCompositionHtml(html) {
655
655
  ? `Text layer in "${family}" — a website body font. This alone makes a frame read as a screenshot of a web page.`
656
656
  : `Text layer in "${family}", outside the composition's imported font regime — it will silently fall back at render.`,
657
657
  where: label(node, "text layer"),
658
- fix: `Use an imported display family: Montserrat (default), TikTok Sans, Abel, Source Code Pro, or Yesteryear — e.g. \`vidfarm set-style <dir> --layer <key> --font-family Montserrat\`. Local renders auto-coerce this, but the editor preview will not match until you fix it.`
658
+ fix: `Use an imported display family: Montserrat (default), TikTok Sans, Abel, Source Code Pro, or Yesteryear — e.g. \`vidfarm set-style <dir> --layer <key> --font-family Montserrat\`. Local renders auto-coerce this, but the editor preview will not match until you fix it. The five families, each rendered as a real caption: https://vidfarm.cc/fonts`
659
659
  });
660
660
  }
661
661
  // Weight: the TikTok caption look is heavy. Light weights are a legitimate
@@ -31,30 +31,53 @@ const GREEN = "\x1b[32m";
31
31
  const RESET = "\x1b[0m";
32
32
  export const SHARED_HELP = `vidfarm shared — use a folder someone shared with you (no account, no API key)
33
33
 
34
+ THE FAST PATH for a gigworker's agent: one command finds the footage by meaning
35
+ and puts it on disk.
36
+
37
+ vidfarm shared grab <link> "founder talking head, kitchen, no captions" --out ./assets
38
+
34
39
  shared info <link> What the link is: folder, mode, what you may do
35
40
  shared ls <link> [subfolder] Browse the shared folder → GET /api/v1/share/:token/directory
41
+ --tree Walk every subfolder, not just this one
36
42
  --limit <n> --offset <n> --json
37
- shared search <link> "<query>" Find files by MEANING inside the share → POST /api/v1/share/:token/directory/search
38
- --subfolder <name> --limit <n> --json
43
+ shared search <link> "<query>" Find files AND folders by MEANING → POST /api/v1/share/:token/directory/search
44
+ --subfolder <name> Search inside one subfolder only
45
+ --limit <n> --json
46
+ shared grab <link> "<query>" Search by meaning, then DOWNLOAD the hits → search + download
47
+ --limit <n> How many to take (default 5)
48
+ --out <dir> Where to write them (default ./)
49
+ --json
50
+ shared note <link> <file|folder> Read the VECTOR NOTE — what the thing is → GET /api/v1/share/:token/directory/note
51
+ --set "<text>" Write it (upload links may FILL an empty note;
52
+ only edit links / the owner may overwrite one)
53
+ --subfolder <name> --json
39
54
  shared mkdir <link> <subfolder> Make a subfolder (upload/edit links only) → POST /api/v1/share/:token/directory/folders
40
55
  shared put <link> <file…> Upload files into the share (upload/edit) → presign → PUT → finalize
41
56
  --subfolder <name> Land them in a subfolder of the shared folder
57
+ --note "<text>" Describe what you uploaded (vector-searchable)
42
58
  --json
43
- shared get <link> <file-name> Download one file out of the share
59
+ shared get <link> <name|path> Download one file out of the share
44
60
  --subfolder <name> Look inside a subfolder
45
61
  --out <dir|file> Where to write it (default: this folder)
46
62
  --all Download every file in the folder instead
63
+ --tree With --all: walk subfolders too
47
64
 
48
65
  <link> is the URL you were given, or just the dsh_… token (then --host <url>).
49
66
 
50
67
  e.g. vidfarm shared info https://vidfarm.cc/directory/preview/dsh_abc/files/project1
51
- vidfarm shared mkdir <link> yvette-batch-01
52
- vidfarm shared put <link> clip-01.mp4 clip-02.mp4 --subfolder yvette-batch-01
53
- vidfarm shared search <link> "founder talking head, no captions"
54
- vidfarm shared get <link> brief.md --out ./work
68
+ vidfarm shared ls <link> --tree
69
+ vidfarm shared grab <link> "product close-up on a white desk" --limit 3 --out ./b-roll
70
+ vidfarm shared note <link> clip-01.mp4
71
+ vidfarm shared put <link> cut-v2.mp4 --subfolder yvette-batch-01 --note "Final cut, 9:16, captions burned in"
55
72
 
56
73
  Modes: read = browse + search · upload = + add files and subfolders (never delete)
57
- edit = + rename and delete. Only /files and /temp shares accept uploads.`;
74
+ edit = + rename and delete. /files, /temp and /raws shares accept uploads;
75
+ a /raws share is a drop-box for footage. /projects and /approved are read-only.
76
+
77
+ VECTOR NOTES: every file and folder carries a plain-text note saying what it IS.
78
+ That note is what \`search\` and \`grab\` match on, so a folder full of IMG_4821.mp4
79
+ is only as findable as its notes. Read them before you pick; write one when you
80
+ upload, so the next agent can find your work.`;
58
81
  // Accepts a preview URL, an API URL, or a bare token. The trailing path of a
59
82
  // preview URL is kept as the default subfolder so a task brief can point at the
60
83
  // exact folder the worker should use.
@@ -125,6 +148,17 @@ async function sharePost(link, route, payload) {
125
148
  fail(`share ${route}`, response.status, body);
126
149
  return body;
127
150
  }
151
+ async function sharePut(link, route, payload) {
152
+ const response = await fetch(apiBase(link) + route, {
153
+ method: "PUT",
154
+ headers: { "content-type": "application/json", accept: "application/json" },
155
+ body: JSON.stringify(payload)
156
+ });
157
+ const body = await readJson(response);
158
+ if (!response.ok)
159
+ fail(`share ${route}`, response.status, body);
160
+ return body;
161
+ }
128
162
  // The path a command works on: the link's own folder, plus an optional
129
163
  // --subfolder. Never an absolute path — a token cannot leave its own subtree.
130
164
  function targetPath(link, basePath, subfolder) {
@@ -144,6 +178,71 @@ async function loadShare(link, subfolder) {
144
178
  const listing = await shareGet(link, "/directory", { path: wanted });
145
179
  return { listing, basePath, path: wanted };
146
180
  }
181
+ // Walk the share depth-first and return every folder + file under `startPath`.
182
+ // A share is a small working folder, so a bounded full walk is cheaper for the
183
+ // agent than N round-trips of guessing folder names.
184
+ const SHARE_WALK_MAX_FOLDERS = 200;
185
+ async function walkShare(link, startPath) {
186
+ const folders = [];
187
+ const files = [];
188
+ const queue = [startPath];
189
+ const seen = new Set();
190
+ while (queue.length && seen.size < SHARE_WALK_MAX_FOLDERS) {
191
+ const current = queue.shift();
192
+ if (seen.has(current))
193
+ continue;
194
+ seen.add(current);
195
+ const listing = await shareGet(link, "/directory", { path: current, limit: "500" });
196
+ for (const file of listing.files ?? [])
197
+ files.push(file);
198
+ for (const folder of listing.folders ?? []) {
199
+ folders.push(folder);
200
+ if (folder.path)
201
+ queue.push(folder.path);
202
+ }
203
+ }
204
+ return { folders, files };
205
+ }
206
+ /** One-line note preview for list output. */
207
+ function noteLine(item) {
208
+ const note = typeof item?.note === "string" ? item.note.replace(/\s+/g, " ").trim() : "";
209
+ if (!note)
210
+ return "";
211
+ return note.length > 100 ? note.slice(0, 99) + "…" : note;
212
+ }
213
+ /** The "does this have a note?" marker, so a gap is visible at a glance. */
214
+ function noteMark(item) {
215
+ if (item?.note === undefined)
216
+ return " "; // backend carries no note
217
+ return item.note ? `${GREEN}✓${RESET} ` : `${DIM}✎${RESET} `;
218
+ }
219
+ function fileSize(item) {
220
+ return typeof item?.sizeBytes === "number" && item.sizeBytes > 0
221
+ ? `${(item.sizeBytes / (1024 * 1024)).toFixed(1)} MB`
222
+ : "";
223
+ }
224
+ /**
225
+ * Find one entry in the share by exact path, by file name, or by a name
226
+ * fragment — looking in the current folder first, then the whole subtree. This
227
+ * is what lets an agent act on a name it read out of a search result.
228
+ */
229
+ async function resolveShareEntry(link, basePath, wanted, subfolder) {
230
+ const target = (wanted || "").trim();
231
+ if (!target)
232
+ throw new Error("Name a file or folder from the share.");
233
+ const here = targetPath(link, basePath, subfolder);
234
+ const local = await shareGet(link, "/directory", { path: here, limit: "500" });
235
+ const pools = [...(local.files ?? []), ...(local.folders ?? [])];
236
+ const exact = pools.find((it) => it.path === target || it.name === target);
237
+ if (exact)
238
+ return exact;
239
+ const walked = await walkShare(link, here);
240
+ const all = [...walked.files, ...walked.folders];
241
+ return (all.find((it) => it.path === target)
242
+ ?? all.find((it) => it.name === target)
243
+ ?? all.find((it) => String(it.name ?? "").toLowerCase().includes(target.toLowerCase()))
244
+ ?? (() => { throw new Error(`Nothing named "${target}" in ${here}. Browse it with: vidfarm shared ls <link> --tree`); })());
245
+ }
147
246
  // ── commands ─────────────────────────────────────────────────────────────────
148
247
  function options() {
149
248
  return {
@@ -154,15 +253,19 @@ function options() {
154
253
  out: { type: "string" },
155
254
  limit: { type: "string" },
156
255
  offset: { type: "string" },
157
- all: { type: "boolean" }
256
+ all: { type: "boolean" },
257
+ tree: { type: "boolean" },
258
+ set: { type: "string" },
259
+ note: { type: "string" },
260
+ "file-id": { type: "string" }
158
261
  };
159
262
  }
160
263
  function modeVerbs(mode) {
161
264
  if (mode === "edit")
162
- return "browse · search · upload · make folders · rename · delete";
265
+ return "browse · search · upload · make folders · rename · delete · write any note";
163
266
  if (mode === "upload")
164
- return "browse · search · upload · make folders (no delete)";
165
- return "browse · search (read only)";
267
+ return "browse · search · upload · make folders · fill an EMPTY note (no delete, no overwrite)";
268
+ return "browse · search · read notes (read only)";
166
269
  }
167
270
  async function runInfo(argv) {
168
271
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
@@ -177,6 +280,11 @@ async function runInfo(argv) {
177
280
  console.log(` folder : ${basePath}`);
178
281
  console.log(` mode : ${mode} ${DIM}(${modeVerbs(mode)})${RESET}`);
179
282
  console.log(` files : ${(listing.files ?? []).length} folders: ${(listing.folders ?? []).length}`);
283
+ const noted = (listing.files ?? []).filter((f) => f?.note).length;
284
+ const notable = (listing.files ?? []).filter((f) => f?.note !== undefined).length;
285
+ if (notable)
286
+ console.log(` notes : ${noted}/${notable} files described ${DIM}(search matches on these)${RESET}`);
287
+ console.log(` ${DIM}Find footage by meaning : vidfarm shared grab <link> "<what you need>" --out ./assets${RESET}`);
180
288
  if (mode !== "read") {
181
289
  console.log(` ${DIM}Make your own subfolder first: vidfarm shared mkdir <link> <your-name>${RESET}`);
182
290
  }
@@ -186,19 +294,42 @@ async function runLs(argv) {
186
294
  const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
187
295
  const subfolder = parsed.values.subfolder ?? parsed.positionals[1];
188
296
  const { listing, path: shownPath } = await loadShare(link, subfolder);
297
+ const walked = parsed.values.tree ? await walkShare(link, shownPath) : null;
298
+ const folders = walked ? walked.folders : (listing.folders ?? []);
299
+ const files = walked ? walked.files : (listing.files ?? []);
189
300
  if (parsed.values.json) {
190
- console.log(JSON.stringify(listing, null, 2));
301
+ console.log(JSON.stringify(walked ? { path: shownPath, share: listing.share, ...walked } : listing, null, 2));
191
302
  return;
192
303
  }
193
304
  console.log(`${BOLD}${shownPath}${RESET} ${DIM}(${listing.share?.mode ?? "read"} link)${RESET}`);
194
- for (const folder of listing.folders ?? [])
195
- console.log(` ${BOLD}${folder.name}/${RESET}`);
196
- for (const file of listing.files ?? []) {
197
- const size = typeof file.sizeBytes === "number" ? `${(file.sizeBytes / (1024 * 1024)).toFixed(1)} MB` : "";
198
- console.log(` ${file.name} ${DIM}${size}${RESET}`);
305
+ for (const folder of folders) {
306
+ console.log(` ${noteMark(folder)}${BOLD}${walked ? folder.path : folder.name + "/"}${RESET}`);
307
+ const note = noteLine(folder);
308
+ if (note)
309
+ console.log(` ${DIM}${note}${RESET}`);
199
310
  }
200
- if (!(listing.folders ?? []).length && !(listing.files ?? []).length)
311
+ for (const file of files) {
312
+ const size = fileSize(file);
313
+ console.log(` ${noteMark(file)}${walked ? file.path : file.name} ${DIM}${size}${RESET}`);
314
+ const note = noteLine(file);
315
+ if (note)
316
+ console.log(` ${DIM}${note}${RESET}`);
317
+ }
318
+ if (!folders.length && !files.length)
201
319
  console.log(` ${DIM}empty${RESET}`);
320
+ else
321
+ console.log(`\n ${DIM}${GREEN}✓${DIM} = has a vector note · ✎ = no note yet (read one with: vidfarm shared note <link> <name>)${RESET}`);
322
+ }
323
+ // Search the share by MEANING. Returns files AND folders — a well-noted folder
324
+ // is a legitimate hit ("all the kitchen b-roll is in here").
325
+ async function searchShare(link, argv) {
326
+ const scope = targetPath(link, argv.basePath, argv.subfolder);
327
+ const body = await sharePost(link, "/directory/search", {
328
+ query: argv.query,
329
+ path: scope,
330
+ ...(argv.limit != null && Number.isFinite(argv.limit) ? { limit: argv.limit } : {})
331
+ });
332
+ return { scope, body, results: body.results ?? body.items ?? [] };
202
333
  }
203
334
  async function runSearch(argv) {
204
335
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
@@ -207,25 +338,112 @@ async function runSearch(argv) {
207
338
  if (!query)
208
339
  throw new Error('shared search needs a query: vidfarm shared search <link> "founder talking head"');
209
340
  const { basePath } = await loadShare(link);
210
- const scope = targetPath(link, basePath, parsed.values.subfolder);
211
- const limit = parsed.values.limit != null ? Number(parsed.values.limit) : undefined;
212
- const body = await sharePost(link, "/directory/search", {
341
+ const { scope, body, results } = await searchShare(link, {
213
342
  query,
214
- path: scope,
215
- ...(limit != null && Number.isFinite(limit) ? { limit } : {})
343
+ basePath,
344
+ subfolder: parsed.values.subfolder,
345
+ limit: parsed.values.limit != null ? Number(parsed.values.limit) : undefined
216
346
  });
217
347
  if (parsed.values.json) {
218
348
  console.log(JSON.stringify(body, null, 2));
219
349
  return;
220
350
  }
221
- const results = body.results ?? body.items ?? [];
351
+ const how = body.semantic === true ? "by meaning" : "keyword/path only (the owner has no embedding key)";
352
+ console.log(`${BOLD}"${query}"${RESET} ${DIM}in ${scope} · ${how}${RESET} → ${results.length} result(s)\n`);
222
353
  if (!results.length) {
223
- console.log(`${DIM}No matches in ${scope}.${RESET}`);
354
+ console.log(` ${DIM}No matches. Try \`vidfarm shared ls <link> --tree\` to see everything.${RESET}`);
355
+ return;
356
+ }
357
+ results.forEach((hit, i) => {
358
+ const score = typeof hit.score === "number" ? ` ${DIM}score=${hit.score.toFixed(3)}${RESET}` : "";
359
+ const kind = hit.kind === "folder" ? "dir " : "file";
360
+ console.log(` ${String(i + 1).padStart(2)}. ${kind} ${noteMark(hit)}${hit.path ?? hit.name}${score}`);
361
+ const note = noteLine(hit);
362
+ if (note)
363
+ console.log(` ${DIM}${note}${RESET}`);
364
+ const size = fileSize(hit);
365
+ if (size)
366
+ console.log(` ${DIM}${size}${RESET}`);
367
+ });
368
+ console.log(`\n ${DIM}Take them: vidfarm shared grab <link> "${query}" --out ./assets${RESET}`);
369
+ }
370
+ // search + download, in one command. This is the command a gigworker's agent
371
+ // actually runs: "give me the footage that looks like X".
372
+ async function runGrab(argv) {
373
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
374
+ const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
375
+ const query = parsed.positionals.slice(1).join(" ").trim();
376
+ if (!query)
377
+ throw new Error('shared grab needs a query: vidfarm shared grab <link> "kitchen b-roll" --out ./assets');
378
+ const limit = parsed.values.limit != null ? Number(parsed.values.limit) : 5;
379
+ const out = parsed.values.out || ".";
380
+ const { basePath } = await loadShare(link);
381
+ const { scope, results } = await searchShare(link, {
382
+ query,
383
+ basePath,
384
+ subfolder: parsed.values.subfolder,
385
+ limit: Number.isFinite(limit) ? limit : 5
386
+ });
387
+ const downloadable = results.filter((hit) => hit.kind !== "folder" && hit.viewUrl);
388
+ if (!downloadable.length) {
389
+ const folders = results.filter((hit) => hit.kind === "folder");
390
+ if (folders.length) {
391
+ console.log(`${DIM}Only folders matched. Look inside one: vidfarm shared ls <link> ${folders[0].path}${RESET}`);
392
+ }
393
+ else {
394
+ console.log(`${DIM}Nothing to download for "${query}" in ${scope}.${RESET}`);
395
+ }
224
396
  return;
225
397
  }
226
- for (const hit of results) {
227
- console.log(` ${hit.name ?? hit.path} ${DIM}${hit.path ?? ""}${RESET}`);
398
+ const taken = [];
399
+ for (const hit of downloadable) {
400
+ const dest = path.join(out, hit.name);
401
+ await downloadTo(hit.viewUrl, dest, link);
402
+ taken.push({ path: hit.path, name: hit.name, note: hit.note ?? null, file: dest, score: hit.score });
403
+ if (!parsed.values.json) {
404
+ console.log(`${GREEN}✓${RESET} ${dest} ${DIM}${hit.path}${RESET}`);
405
+ const note = noteLine(hit);
406
+ if (note)
407
+ console.log(` ${DIM}${note}${RESET}`);
408
+ }
228
409
  }
410
+ if (parsed.values.json)
411
+ console.log(JSON.stringify({ ok: true, query, scope, taken }, null, 2));
412
+ else
413
+ console.log(`\n ${DIM}${taken.length} file(s) for "${query}"${RESET}`);
414
+ }
415
+ // Read or write the vector note of one entry in the share.
416
+ async function runNote(argv) {
417
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
418
+ const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
419
+ const wanted = parsed.positionals.slice(1).join(" ").trim();
420
+ const { listing, basePath } = await loadShare(link);
421
+ const mode = listing.share?.mode ?? "read";
422
+ const entry = await resolveShareEntry(link, basePath, wanted, parsed.values.subfolder);
423
+ const setValue = parsed.values.set;
424
+ if (setValue == null) {
425
+ const body = await shareGet(link, "/directory/note", { path: entry.path, id: entry.id });
426
+ if (parsed.values.json) {
427
+ console.log(JSON.stringify(body, null, 2));
428
+ return;
429
+ }
430
+ console.log(`${BOLD}${entry.path}${RESET}`);
431
+ console.log(body.note ? ` ${body.note}` : ` ${DIM}(no vector note yet)${RESET}`);
432
+ if (body.can_edit)
433
+ console.log(` ${DIM}Write one: vidfarm shared note <link> ${entry.name} --set "what this is"${RESET}`);
434
+ else if (mode === "upload")
435
+ console.log(` ${DIM}This note is already written — an upload link may only fill an EMPTY note.${RESET}`);
436
+ return;
437
+ }
438
+ const body = await sharePut(link, "/directory/note", { path: entry.path, id: entry.id ?? null, note: setValue });
439
+ if (parsed.values.json) {
440
+ console.log(JSON.stringify(body, null, 2));
441
+ return;
442
+ }
443
+ console.log(`${GREEN}✓${RESET} note saved on ${BOLD}${entry.path}${RESET}`);
444
+ console.log(body.embedded
445
+ ? ` ${DIM}searchable by meaning${RESET}`
446
+ : ` ${DIM}saved, but keyword-search only — the folder's owner has no embedding key saved${RESET}`);
229
447
  }
230
448
  async function runMkdir(argv) {
231
449
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
@@ -309,52 +527,118 @@ async function runPut(argv) {
309
527
  // The API wants the folder RELATIVE to the root (no /files prefix).
310
528
  const wanted = targetPath(link, basePath, subfolder);
311
529
  const folderPath = wanted.replace(/^\/(files|temp|raws|approved|projects)\/?/, "");
530
+ const note = parsed.values.note?.trim();
312
531
  const saved = [];
313
532
  for (const file of files) {
314
533
  const record = await uploadOne(link, file, folderPath);
315
534
  saved.push(record);
316
535
  if (!parsed.values.json)
317
536
  console.log(`${GREEN}✓${RESET} ${path.basename(file)} → ${BOLD}${wanted}${RESET}`);
537
+ // Say what you uploaded, so the next agent can find it by meaning. An
538
+ // upload link may fill an empty note, which a fresh upload always has —
539
+ // unless the server already auto-described it on the owner's AI key.
540
+ if (note) {
541
+ const uploadedPath = `${wanted.replace(/\/+$/, "")}/${record?.fileName ?? path.basename(file)}`;
542
+ await sharePut(link, "/directory/note", { path: uploadedPath, id: record?.id ?? null, note })
543
+ .then(() => { if (!parsed.values.json)
544
+ console.log(` ${DIM}note saved${RESET}`); })
545
+ .catch((error) => {
546
+ if (!parsed.values.json)
547
+ console.log(` ${DIM}note not saved: ${error.message}${RESET}`);
548
+ });
549
+ }
318
550
  }
319
551
  if (parsed.values.json)
320
552
  console.log(JSON.stringify({ ok: true, uploaded: saved }, null, 2));
553
+ else if (!note) {
554
+ console.log(`\n ${DIM}Tip: add --note "what this is" so the owner's agent can find it by meaning.${RESET}`);
555
+ }
321
556
  }
322
- async function downloadTo(url, outPath) {
323
- const response = await fetch(url);
324
- if (!response.ok)
325
- throw new Error(`download failed: HTTP ${response.status}`);
326
- const bytes = Buffer.from(await response.arrayBuffer());
327
- mkdirSync(path.dirname(outPath), { recursive: true });
328
- writeFileSync(outPath, bytes);
557
+ // Fetch one file out of the share. A stored view URL is not always reachable as
558
+ // written: it may be relative, or absolute against the host the owner's server
559
+ // had configured when the file was saved (a self-hosted box, a moved domain).
560
+ // The link we were handed is the host we KNOW answers, so fall back to it,
561
+ // keeping the URL's own path.
562
+ async function downloadTo(url, outPath, link) {
563
+ const candidates = [];
564
+ if (/^https?:\/\//i.test(url)) {
565
+ candidates.push(url);
566
+ if (link) {
567
+ try {
568
+ const parsed = new URL(url);
569
+ const viaLink = link.host + parsed.pathname + parsed.search;
570
+ if (viaLink !== url)
571
+ candidates.push(viaLink);
572
+ }
573
+ catch { /* unparseable — the direct attempt is all we have */ }
574
+ }
575
+ }
576
+ else if (link) {
577
+ candidates.push(link.host + (url.startsWith("/") ? url : `/${url}`));
578
+ }
579
+ else {
580
+ candidates.push(url);
581
+ }
582
+ let lastError = "";
583
+ for (const candidate of candidates) {
584
+ try {
585
+ const response = await fetch(candidate);
586
+ if (!response.ok) {
587
+ lastError = `HTTP ${response.status}`;
588
+ continue;
589
+ }
590
+ const bytes = Buffer.from(await response.arrayBuffer());
591
+ mkdirSync(path.dirname(outPath), { recursive: true });
592
+ writeFileSync(outPath, bytes);
593
+ return;
594
+ }
595
+ catch (error) {
596
+ lastError = error instanceof Error ? error.message : String(error);
597
+ }
598
+ }
599
+ throw new Error(`download failed (${lastError}): ${url}`);
329
600
  }
330
601
  async function runGet(argv) {
331
602
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
332
603
  const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
333
604
  const wantedName = parsed.positionals[1];
334
- const { listing, path: shownPath } = await loadShare(link, parsed.values.subfolder);
335
- const files = listing.files ?? [];
605
+ const { listing, basePath, path: shownPath } = await loadShare(link, parsed.values.subfolder);
336
606
  const out = parsed.values.out || ".";
337
607
  if (parsed.values.all || !wantedName) {
338
608
  if (!parsed.values.all)
339
609
  throw new Error("shared get needs a file name, or --all to take the whole folder.");
610
+ // --tree takes the whole subtree, mirroring the folder layout on disk so a
611
+ // deep share doesn't collapse into one flat directory of colliding names.
612
+ const files = parsed.values.tree ? (await walkShare(link, shownPath)).files : (listing.files ?? []);
613
+ let taken = 0;
340
614
  for (const file of files) {
341
615
  if (!file.viewUrl)
342
616
  continue;
343
- const dest = path.join(out, file.name);
344
- await downloadTo(file.viewUrl, dest);
617
+ const relative = parsed.values.tree && typeof file.path === "string" && file.path.startsWith(shownPath)
618
+ ? file.path.slice(shownPath.length).replace(/^\/+/, "")
619
+ : file.name;
620
+ const dest = path.join(out, relative);
621
+ await downloadTo(file.viewUrl, dest, link);
622
+ taken += 1;
345
623
  console.log(`${GREEN}✓${RESET} ${dest}`);
346
624
  }
347
- console.log(`${DIM}${files.length} file(s) from ${shownPath}${RESET}`);
625
+ console.log(`${DIM}${taken} file(s) from ${shownPath}${RESET}`);
348
626
  return;
349
627
  }
350
- const match = files.find((f) => f.name === wantedName) || files.find((f) => String(f.name).includes(wantedName));
351
- if (!match)
352
- throw new Error(`No file named "${wantedName}" in ${shownPath}.`);
628
+ // Accepts a bare name, a name fragment, or the full canonical path a search
629
+ // result printed — an agent should never have to translate between them.
630
+ const match = await resolveShareEntry(link, basePath, wantedName, parsed.values.subfolder);
631
+ if (match.kind === "folder") {
632
+ throw new Error(`"${match.path}" is a folder. Take it with: vidfarm shared get <link> --all --tree --subfolder ${match.name}`);
633
+ }
353
634
  if (!match.viewUrl)
354
635
  throw new Error(`"${match.name}" has no downloadable URL.`);
355
636
  const dest = /\.[A-Za-z0-9]+$/.test(out) ? out : path.join(out, match.name);
356
- await downloadTo(match.viewUrl, dest);
637
+ await downloadTo(match.viewUrl, dest, link);
357
638
  console.log(`${GREEN}✓${RESET} ${dest}`);
639
+ const note = noteLine(match);
640
+ if (note)
641
+ console.log(` ${DIM}${note}${RESET}`);
358
642
  }
359
643
  export async function runSharedCommand(argv) {
360
644
  const sub = argv[0];
@@ -366,6 +650,11 @@ export async function runSharedCommand(argv) {
366
650
  case "list": return runLs(rest);
367
651
  case "search":
368
652
  case "find": return runSearch(rest);
653
+ case "grab":
654
+ case "fetch": return runGrab(rest);
655
+ case "note":
656
+ case "notes": return runNote(rest);
657
+ case "tree": return runLs([...rest, "--tree"]);
369
658
  case "mkdir":
370
659
  case "folder": return runMkdir(rest);
371
660
  case "put":
@@ -117,8 +117,8 @@ export const PACK_TOPICS = [
117
117
  blurb: "The 5-stage ladder — what the viewer knows, what the video must do, and what it may ask for" },
118
118
  { topic: "problem-angles", aliases: ["angle", "lenses", "problem-angle"], doc: "references/content-ideas.md", heading: "The problem angles",
119
119
  blurb: "44 angles on the problem — hold the frame, change the angle when a topic is \"already covered\"" },
120
- { topic: "meme-recaption", aliases: ["meme", "recaption", "meme-caption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
121
- blurb: "Recaption a meme at a pain or a win the niche knows — the cold-viewer test" },
120
+ { topic: "meme-recaption", aliases: ["meme", "recaption", "meme-caption", "meme_recaption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
121
+ blurb: "Recaption a meme at a pain or a win the niche knows — the cold-viewer test. Building one from scratch? the full format is vidfarm.cc/experimental/meme-recaption.md" },
122
122
  { topic: "product-explainer", aliases: ["product-explainers"], doc: "harnesses/product-explainer.HARNESS.md",
123
123
  blurb: "The product-explainer harness — the bundled base for explaining what a product does" },
124
124
  { topic: "product-demo", aliases: ["demo"], doc: "harnesses/product-demo.HARNESS.md",