@mevdragon/vidfarm-devcli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,55 @@
1
- // devcli `vidfarm clips …` — the local clip-curation surface. Uses local
2
- // ffmpeg (near-zero compute) + BYOK Gemini, persisting to a SQLite clip library
3
- // (~/.vidfarm). Every scan shows an estimated cost first; scans over $1 require
4
- // --yes or an interactive confirm. Mirrors the cloud REST surface 1:1.
5
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
1
+ // devcli `vidfarm clips …` — the local clip-curation surface. LOCAL-FIRST:
2
+ // local ffmpeg for compute and, by default, the user's local agent CLI
3
+ // subscription (claude/codex) for scene evaluation no API key needed. BYOK
4
+ // provider keys are the first fallback, and `--cloud` is the explicit backup
5
+ // that runs the hunt on the deployed pipeline instead (never the default).
6
+ // Persists to a SQLite clip library (~/.vidfarm). Every keyed scan shows an
7
+ // estimated cost first; scans over $1 require --yes or an interactive confirm.
8
+ // Mirrors the cloud REST surface 1:1.
9
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
6
10
  import path from "node:path";
7
11
  import { homedir } from "node:os";
8
12
  import { parseArgs } from "node:util";
9
13
  import { createInterface } from "node:readline/promises";
10
14
  import { createIdV7 } from "../lib/ids.js";
11
- import { ClipModelClient, estimateScanCostFromScenes, formatCostEstimate, detectScenes, hasFfmpeg, probeVideo, scanVideo, searchClips } from "../services/clip-curation/index.js";
15
+ import { buildEffectiveGuidance, ClipModelClient, detectLocalAgent, detectScenes, estimateScanCostFromScenes, fitScenesToDurationBand, formatCostEstimate, hasFfmpeg, LocalAgentClipClient, normalizeAspect, normalizeCropFocus, normalizeWindows, parseClipHuntPrompt, parseTimeRanges, probeVideo, resolveDurationBand, scanVideo, searchClips } from "../services/clip-curation/index.js";
12
16
  import { ClipStore } from "./clip-store.js";
13
- export const CLIPS_HELP = `vidfarm clips — build & search a local clip library (local ffmpeg + BYOK Gemini)
17
+ export const CLIPS_HELP = `vidfarm clips — build & search a local clip library (local ffmpeg + local agent by default)
14
18
 
15
19
  clips scan <video-path> Mine a long-form video into tagged, searchable clips
16
- --provider <p> gemini | openai | openrouter (default: gemini)
17
- gemini→gemini-2.5-flash-lite, openai→gpt-5.4-nano,
18
- openrouter→google/gemini-2.5-flash
19
- --tier flash|flash-lite Gemini tier (default: flash-lite; ignored for openai/openrouter)
20
+ --provider <p> agent | gemini | openai | openrouter
21
+ Default: agent when a local claude/codex CLI is installed
22
+ (your subscription, no API key), else gemini.
23
+ --agent claude|codex Which local agent CLI to use for --provider agent (default: auto)
24
+ --tier flash|flash-lite Gemini tier (default: flash-lite; ignored for openai/openrouter/agent)
20
25
  --prompt "<guidance>" Reshape clipping toward your goal: an LLM pass
21
- keeps/drops/merges scenes, and tags are biased to it
26
+ keeps/drops/merges scenes, and tags are biased to it.
27
+ Inline hints are honored too: "between 12:30 and 15:45",
28
+ "30 sec clips", "vertical", "no captions".
29
+ --range "MM:SS-MM:SS" Only hunt inside this source range (repeatable / comma-separated)
30
+ --duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s, …)
31
+ --aspect <a> Crop clips to 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square)
32
+ --crop-focus <f> center | top | bottom | left | right (default: center)
33
+ --no-text Prefer scenes WITHOUT burned-in captions/on-screen text
34
+ (scene selection — NEVER GhostCut on the long-form source)
22
35
  --no-refine Keep prompt tag-bias but skip the keep/drop/merge pass
36
+ --cloud BACKUP: run the hunt on the deployed pipeline instead of this
37
+ machine (uploads to your temp folder — auto-deletes in 30 days —
38
+ or pass --url; needs VIDFARM_API_KEY; bills AWS compute only)
39
+ --url <video-url> With --cloud: hunt a YouTube/TikTok/IG/X URL without uploading
40
+ --tracer <id> With --cloud: tag the hunt's billing/observability rollup
41
+ --api-url <base> With --cloud: deployment base URL (or VIDFARM_API_URL)
42
+ --api-key <key> With --cloud: API key (or VIDFARM_API_KEY)
23
43
  --dry-run Print the estimated cost and exit
24
44
  --yes Skip the >$1 confirmation prompt
25
45
  --frames <n> Keyframes per scene for tagging (1-3, default 2)
26
46
  --no-audio Skip per-scene audio (Gemini only; no transcripts; cheaper)
27
- --concurrency <n> Parallel scenes (default 3; respect rate limits)
47
+ --concurrency <n> Parallel scenes (default 3; agent default 2)
28
48
  --threshold <0..1> Scene-cut sensitivity (default 0.3; lower = more cuts)
29
49
  --min-scene <sec> Merge scenes shorter than this (default 0.6)
30
- --max-scene <sec> Split scenes longer than this (default 8)
50
+ --max-scene <sec> Split scenes longer than this (default 8; --duration overrides)
31
51
  --gemini-key / --openai-key / --openrouter-key <key> Provider key (or *_API_KEY env)
32
- --embed-provider gemini|openai --embed-key <key> Embeddings when using openrouter
52
+ --embed-provider gemini|openai --embed-key <key> Embeddings for agent/openrouter scans
33
53
  --home <dir> Library dir (default: ~/.vidfarm or VIDFARM_HOME)
34
54
 
35
55
  clips list List clips in the library
@@ -97,9 +117,20 @@ async function runScan(argv) {
97
117
  allowPositionals: true,
98
118
  options: {
99
119
  provider: { type: "string" },
120
+ agent: { type: "string" },
100
121
  tier: { type: "string" },
101
122
  prompt: { type: "string" },
123
+ range: { type: "string", multiple: true },
124
+ duration: { type: "string" },
125
+ aspect: { type: "string" },
126
+ "crop-focus": { type: "string" },
127
+ "no-text": { type: "boolean" },
102
128
  "no-refine": { type: "boolean" },
129
+ cloud: { type: "boolean" },
130
+ url: { type: "string" },
131
+ tracer: { type: "string" },
132
+ "api-url": { type: "string" },
133
+ "api-key": { type: "string" },
103
134
  "dry-run": { type: "boolean" },
104
135
  yes: { type: "boolean" },
105
136
  frames: { type: "string" },
@@ -116,67 +147,146 @@ async function runScan(argv) {
116
147
  home: { type: "string" }
117
148
  }
118
149
  });
150
+ // ── Hunt spec: explicit flags win; inline prompt hints fill the gaps ──────
151
+ const guidancePromptRaw = values.prompt?.trim() || undefined;
152
+ const parsedPrompt = parseClipHuntPrompt(guidancePromptRaw);
153
+ const rawWindows = values.range?.length
154
+ ? parseTimeRanges(values.range)
155
+ : parsedPrompt.windows;
156
+ const durationBand = values.duration
157
+ ? resolveDurationBand(Number(values.duration))
158
+ : parsedPrompt.target_duration_sec != null
159
+ ? resolveDurationBand(parsedPrompt.target_duration_sec)
160
+ : null;
161
+ const aspect = values.aspect
162
+ ? (normalizeAspect(values.aspect) ?? (() => { throw new Error(`--aspect must be 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square), got "${values.aspect}"`); })())
163
+ : parsedPrompt.aspect;
164
+ const cropFocus = normalizeCropFocus(values["crop-focus"]);
165
+ const avoidText = Boolean(values["no-text"]) || parsedPrompt.avoid_text;
166
+ const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
167
+ // ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
168
+ if (values.cloud) {
169
+ return runScanCloud({
170
+ videoArg: positionals[0],
171
+ sourceUrl: values.url?.trim() || undefined,
172
+ apiUrl: values["api-url"],
173
+ apiKey: values["api-key"],
174
+ tracer: values.tracer,
175
+ prompt: guidancePromptRaw,
176
+ windows: rawWindows,
177
+ durationBand,
178
+ aspect,
179
+ cropFocus,
180
+ avoidText
181
+ });
182
+ }
119
183
  const videoArg = positionals[0];
120
184
  if (!videoArg)
121
- throw new Error("Usage: vidfarm clips scan <video-path> [--tier flash|flash-lite] [--dry-run]");
185
+ throw new Error("Usage: vidfarm clips scan <video-path> [--range MM:SS-MM:SS] [--duration 30] [--aspect 9:16] [--dry-run]");
122
186
  const videoPath = path.resolve(process.cwd(), videoArg);
123
187
  if (!existsSync(videoPath))
124
188
  throw new Error(`No such video: ${videoPath}`);
125
189
  if (!(await hasFfmpeg())) {
126
190
  throw new Error("ffmpeg is required for `clips scan` but was not found (bundled ffmpeg-static missing and none on PATH).");
127
191
  }
128
- const provider = normalizeProvider(values.provider);
192
+ // ── Model resolution: LOCAL-FIRST ─────────────────────────────────────────
193
+ // 1. --provider agent (or no --provider + a local claude/codex CLI installed)
194
+ // → the user's local agent subscription evaluates scenes; no API key.
195
+ // 2. --provider gemini|openai|openrouter (or a key in env/config) → BYOK API.
196
+ // 3. --cloud (handled above) → the deployed pipeline, as an explicit backup.
197
+ const provider = resolveScanProvider(values);
129
198
  const tier = normalizeTier(values.tier);
130
199
  const framesPerScene = clampInt(values.frames, 2, 1, 3);
131
- // Audio is Gemini-only (chat models on openai/openrouter don't take the clip).
200
+ // Audio is Gemini-only (chat models on openai/openrouter — and CLI agents — don't take the clip).
132
201
  const includeAudio = !values["no-audio"] && provider === "gemini";
133
- const guidancePrompt = values.prompt?.trim() || undefined;
134
- const concurrency = clampInt(values.concurrency, 3, 1, 12);
202
+ // Local agents run one process per call — keep parallelism modest by default.
203
+ const concurrency = clampInt(values.concurrency, provider === "agent" ? 2 : 3, 1, 12);
135
204
  const sceneOptions = {
136
205
  threshold: values.threshold ? Number(values.threshold) : undefined,
137
206
  minSceneSec: values["min-scene"] ? Number(values["min-scene"]) : undefined,
138
- maxSceneSec: values["max-scene"] ? Number(values["max-scene"]) : undefined
207
+ // A duration band lifts the split ceiling; an explicit --max-scene still wins.
208
+ maxSceneSec: values["max-scene"] ? Number(values["max-scene"]) : durationBand?.max_sec
139
209
  };
140
210
  // Resolve the tagging key + optional embedding config. Optional here so
141
- // --dry-run can show the cost without a key; the key is required below to scan.
142
- const apiKey = resolveProviderKey(provider, values, { optional: true });
143
- const embedding = apiKey ? resolveEmbeddingConfig(provider, apiKey, values) : null;
211
+ // --dry-run can show the cost without a key; required below to scan (API providers).
212
+ const apiKey = provider === "agent" ? "" : resolveProviderKey(provider, values, { optional: true });
213
+ const embedding = provider === "agent"
214
+ ? resolveAgentEmbeddingConfig(values)
215
+ : apiKey ? resolveEmbeddingConfig(provider, apiKey, values) : null;
144
216
  console.log(`[clips] probing ${path.basename(videoPath)} …`);
145
217
  const probe = await probeVideo(videoPath);
146
218
  console.log(`[clips] ${fmtDuration(probe.duration_sec)} · ${probe.width ?? "?"}x${probe.height ?? "?"} · ${probe.codec ?? "?"}`);
219
+ const windows = normalizeWindows(rawWindows, probe.duration_sec);
220
+ if (rawWindows.length && !windows.length) {
221
+ throw new Error("--range produced no usable window inside the video's duration.");
222
+ }
223
+ if (windows.length) {
224
+ console.log(`[clips] hunting only ${windows.map((w) => `${fmtClock(w.start_sec)}–${fmtClock(w.end_sec)}`).join(", ")} (${fmtDuration(windows.reduce((a, w) => a + w.end_sec - w.start_sec, 0))} of ${fmtDuration(probe.duration_sec)}).`);
225
+ }
226
+ if (durationBand) {
227
+ console.log(`[clips] target clip length ~${durationBand.target_sec}s (soft range ${durationBand.min_sec}–${durationBand.max_sec}s).`);
228
+ }
229
+ if (aspect && aspect !== "original")
230
+ console.log(`[clips] cropping clips to ${aspect} (${cropFocus}).`);
231
+ if (avoidText)
232
+ console.log(`[clips] preferring scenes without on-screen text/captions (selection only — GhostCut is never run on the source).`);
147
233
  console.log(`[clips] detecting scenes …`);
148
- const scenes = await detectScenes(videoPath, { durationSec: probe.duration_sec, ...sceneOptions });
234
+ const detected = await detectScenes(videoPath, { durationSec: probe.duration_sec, windows, ...sceneOptions });
235
+ const scenes = fitScenesToDurationBand(detected, durationBand);
149
236
  if (scenes.length === 0)
150
237
  throw new Error("No scenes detected (is the video readable / non-empty?).");
151
238
  const store = new ClipStore(values.home);
152
- const client = apiKey ? new ClipModelClient({ provider, apiKey, tier, embedding }) : null;
239
+ let client = null;
240
+ if (provider === "agent") {
241
+ client = new LocalAgentClipClient({
242
+ agent: values.agent === "claude" || values.agent === "codex" ? values.agent : "auto",
243
+ embedding,
244
+ tier
245
+ });
246
+ console.log(`[clips] evaluating scenes with your local ${client.tagModelId} subscription (no API cost).`);
247
+ }
248
+ else if (apiKey) {
249
+ client = new ClipModelClient({ provider, apiKey, tier, embedding });
250
+ }
153
251
  if (client && !client.canEmbed) {
154
252
  console.warn(`[clips] no embedding provider for "${provider}" — clips will be structured-searchable only.`);
155
253
  console.warn(` Add --embed-provider gemini|openai --embed-key <key> for semantic search.`);
156
254
  }
157
- const estimate = estimateScanCostFromScenes(scenes, {
158
- tier,
159
- provider,
160
- tagModelId: client?.tagModelId,
161
- embedModelId: client ? client.embeddingModelId : defaultEmbedModelForEstimate(provider),
162
- framesPerScene,
163
- includeAudio
164
- });
165
- console.log(`\n Cost estimate: ${formatCostEstimate(estimate)}`);
166
- console.log(` Target: $0.05–0.10 per source hour (${withinTarget(estimate.usd_per_source_hour) ? "on target" : "note"}).`);
167
- if (guidancePrompt)
168
- console.log(` Guidance: "${guidancePrompt}"`);
255
+ // Cost estimate: local-agent scans ride the user's subscription, so only the
256
+ // optional embedding spend applies; API scans estimate the full BYOK cost.
257
+ const estimate = provider === "agent"
258
+ ? null
259
+ : estimateScanCostFromScenes(scenes, {
260
+ tier,
261
+ provider,
262
+ tagModelId: client?.tagModelId,
263
+ embedModelId: client ? client.embeddingModelId : defaultEmbedModelForEstimate(provider),
264
+ framesPerScene,
265
+ includeAudio
266
+ });
267
+ if (estimate) {
268
+ console.log(`\n Cost estimate: ${formatCostEstimate(estimate)}`);
269
+ console.log(` Target: $0.05–0.10 per source hour (${withinTarget(estimate.usd_per_source_hour) ? "on target" : "note"}).`);
270
+ }
271
+ else {
272
+ console.log(`\n Cost: local agent subscription (${scenes.length} scenes; embeddings ${embedding ? `via ${embedding.provider}` : "off"}).`);
273
+ }
274
+ if (guidance)
275
+ console.log(` Guidance: "${guidance.replace(/\n/g, " · ")}"`);
169
276
  console.log("");
170
277
  if (values["dry-run"]) {
171
278
  console.log("[clips] --dry-run: not scanning. Re-run without --dry-run to build the library.");
172
279
  return;
173
280
  }
174
- // Actually scanning now — a key is required.
175
- if (!client)
176
- resolveProviderKey(provider, values); // throws the "No <provider> API key" error
281
+ // Actually scanning now — an evaluator is required.
282
+ if (!client) {
283
+ if (provider !== "agent")
284
+ resolveProviderKey(provider, values); // throws the "No <provider> API key" error
285
+ throw new Error("No scene evaluator available. Install Claude Code (`claude`) or Codex (`codex`), pass a provider key, or run with --cloud.");
286
+ }
177
287
  const activeClient = client;
178
- // $1 confirmation gate (handoff Target 1).
179
- if (estimate.usd > 1 && !values.yes) {
288
+ // $1 confirmation gate (handoff Target 1) — keyed scans only.
289
+ if (estimate && estimate.usd > 1 && !values.yes) {
180
290
  const ok = await confirm(`This scan is estimated at $${estimate.usd.toFixed(2)}. Proceed? [y/N] `);
181
291
  if (!ok) {
182
292
  console.log("[clips] aborted.");
@@ -200,9 +310,10 @@ async function runScan(argv) {
200
310
  thumbsDir: store.paths.thumbsDir,
201
311
  framesPerScene,
202
312
  includeAudio,
203
- guidancePrompt,
313
+ guidancePrompt: guidance,
204
314
  refineSegmentation: !values["no-refine"],
205
- reencodeClips: true
315
+ reencodeClips: true,
316
+ crop: aspect && aspect !== "original" ? { aspect, focus: cropFocus } : undefined
206
317
  };
207
318
  console.log(`[clips] scanning ${scenes.length} scenes (${provider}/${activeClient.tagModelId}, ${concurrency}x) …`);
208
319
  let errors = 0;
@@ -212,6 +323,8 @@ async function runScan(argv) {
212
323
  videoPath,
213
324
  scenes,
214
325
  probe,
326
+ windows,
327
+ durationBand,
215
328
  sourceFilename: path.basename(videoPath),
216
329
  concurrency,
217
330
  onRefine: (r) => {
@@ -253,6 +366,139 @@ async function runScan(argv) {
253
366
  console.log(` db → ${store.paths.db}`);
254
367
  console.log(` try: vidfarm clips search "…" | vidfarm clips preset run "funny reaction gifs"`);
255
368
  }
369
+ // ── clips scan --cloud (explicit backup: the deployed pipeline) ─────────────
370
+ // Uploads the local file into the user's temp folder (30-day TTL, auto-removed)
371
+ // — or passes --url straight through — then starts POST /clips/scan and polls.
372
+ // The hunt bills AWS compute only; AI runs on the account's saved BYOK keys.
373
+ async function runScanCloud(input) {
374
+ const baseUrl = (input.apiUrl || process.env.VIDFARM_API_URL || process.env.VIDFARM_UPSTREAM_HOST || "https://vidfarm.cc").replace(/\/$/, "");
375
+ const apiKey = input.apiKey || process.env.VIDFARM_API_KEY || "";
376
+ if (!apiKey)
377
+ throw new Error("--cloud needs an API key: pass --api-key or set VIDFARM_API_KEY.");
378
+ const headers = { "vidfarm-api-key": apiKey, "content-type": "application/json", accept: "application/json" };
379
+ let tempFileId;
380
+ let fileName;
381
+ if (!input.sourceUrl) {
382
+ if (!input.videoArg)
383
+ throw new Error("Usage: vidfarm clips scan --cloud <video-path> | --cloud --url <video-url>");
384
+ const videoPath = path.resolve(process.cwd(), input.videoArg);
385
+ if (!existsSync(videoPath))
386
+ throw new Error(`No such video: ${videoPath}`);
387
+ fileName = path.basename(videoPath);
388
+ const sizeBytes = statSync(videoPath).size;
389
+ console.log(`[clips] uploading ${fileName} (${(sizeBytes / (1024 * 1024)).toFixed(1)} MB) to your temp folder (auto-deletes in 30 days) …`);
390
+ const presignRes = await fetch(`${baseUrl}/api/v1/user/me/temporary-files/presign`, {
391
+ method: "POST",
392
+ headers,
393
+ body: JSON.stringify({ file_name: fileName, content_type: "video/mp4", size_bytes: sizeBytes, folder_path: "clip-sources" })
394
+ });
395
+ const presign = await presignRes.json();
396
+ if (!presignRes.ok || !presign.upload || !presign.file_id) {
397
+ throw new Error(`Temp upload presign failed (${presignRes.status}): ${presign.error ?? "unexpected response"}`);
398
+ }
399
+ if (presign.transport !== "presigned") {
400
+ throw new Error("The deployment does not support presigned uploads — upload via the web UI, then re-run with --url or temp_file_id.");
401
+ }
402
+ const putRes = await fetch(presign.upload.url, {
403
+ method: presign.upload.method,
404
+ headers: presign.upload.headers,
405
+ body: readFileSync(path.resolve(process.cwd(), input.videoArg))
406
+ });
407
+ if (!putRes.ok)
408
+ throw new Error(`Upload failed (${putRes.status}).`);
409
+ const finalizeRes = await fetch(`${baseUrl}/api/v1/user/me/temporary-files`, {
410
+ method: "POST",
411
+ headers,
412
+ body: JSON.stringify({
413
+ file_id: presign.file_id,
414
+ file_name: fileName,
415
+ content_type: "video/mp4",
416
+ size_bytes: sizeBytes,
417
+ storage_key: presign.storage_key,
418
+ folder_path: "clip-sources"
419
+ })
420
+ });
421
+ if (!finalizeRes.ok) {
422
+ const body = await finalizeRes.text().catch(() => "");
423
+ throw new Error(`Temp upload finalize failed (${finalizeRes.status}): ${body.slice(0, 300)}`);
424
+ }
425
+ tempFileId = presign.file_id;
426
+ }
427
+ console.log(`[clips] starting cloud hunt …`);
428
+ const scanRes = await fetch(`${baseUrl}/clips/scan`, {
429
+ method: "POST",
430
+ headers,
431
+ body: JSON.stringify({
432
+ ...(input.sourceUrl ? { source_url: input.sourceUrl } : { temp_file_id: tempFileId, filename: fileName }),
433
+ prompt: input.prompt ?? "",
434
+ ...(input.tracer ? { tracer: input.tracer } : {}),
435
+ hunt_spec: {
436
+ ...(input.windows.length ? { windows: input.windows } : {}),
437
+ ...(input.durationBand ? { duration_band: input.durationBand } : {}),
438
+ ...(input.aspect ? { aspect: input.aspect } : {}),
439
+ crop_focus: input.cropFocus,
440
+ ...(input.avoidText ? { avoid_text: true } : {})
441
+ }
442
+ })
443
+ });
444
+ const scan = await scanRes.json();
445
+ if (!scanRes.ok || !scan.scan_id) {
446
+ throw new Error(`Cloud scan failed to start (${scanRes.status}): ${scan.error ?? "unexpected response"}`);
447
+ }
448
+ console.log(`[clips] scan ${scan.scan_id} ${scan.status ?? "queued"}${scan.tracer ? ` (tracer ${scan.tracer})` : ""}.`);
449
+ if (scan.compute_estimate?.estimated_charge_usd != null) {
450
+ console.log(`[clips] estimated AWS compute charge ~$${scan.compute_estimate.estimated_charge_usd.toFixed(4)} (AI runs on your saved provider keys).`);
451
+ }
452
+ // Async by design — poll until the pipeline finishes.
453
+ const started = timestamp();
454
+ for (;;) {
455
+ await new Promise((r) => setTimeout(r, 5000));
456
+ const pollRes = await fetch(`${baseUrl}/clips/scan/${encodeURIComponent(scan.scan_id)}`, { headers });
457
+ if (!pollRes.ok)
458
+ continue;
459
+ const poll = await pollRes.json();
460
+ const status = poll.source?.status ?? poll.scan?.status ?? "running";
461
+ if (status === "complete") {
462
+ console.log(`\n[clips] cloud hunt done in ${elapsed(started)}: ${poll.source?.clip_count ?? 0} clips.`);
463
+ console.log(` browse → ${baseUrl}/clips`);
464
+ return;
465
+ }
466
+ if (status === "failed") {
467
+ throw new Error(`Cloud hunt failed: ${poll.scan?.error ?? "unknown error"}`);
468
+ }
469
+ process.stdout.write(`\r[clips] ${status} … ${elapsed(started)} `);
470
+ }
471
+ }
472
+ /**
473
+ * LOCAL-FIRST provider pick: an explicit --provider always wins; otherwise use
474
+ * the local agent CLI when one is installed (subscription-powered, keyless),
475
+ * else fall back to gemini BYOK. The cloud pipeline is never a silent default
476
+ * — it's the explicit `--cloud` backup.
477
+ */
478
+ function resolveScanProvider(values) {
479
+ if (values.provider === "agent" || values.provider === "local" || values.provider === "local-agent")
480
+ return "agent";
481
+ if (values.provider)
482
+ return normalizeProvider(values.provider);
483
+ if (values.agent)
484
+ return "agent";
485
+ if (detectLocalAgent())
486
+ return "agent";
487
+ return "gemini";
488
+ }
489
+ /** Embeddings for a local-agent scan: any gemini/openai key found makes the library semantically searchable. */
490
+ function resolveAgentEmbeddingConfig(flags) {
491
+ const explicit = flags["embed-provider"]?.trim();
492
+ const tryProvider = (p) => {
493
+ const key = flags["embed-key"]?.trim() || resolveProviderKey(p, flags, { optional: true });
494
+ return key ? { provider: p, apiKey: key } : null;
495
+ };
496
+ if (explicit === "gemini")
497
+ return tryProvider("gemini");
498
+ if (explicit === "openai")
499
+ return tryProvider("openai");
500
+ return tryProvider("gemini") ?? tryProvider("openai");
501
+ }
256
502
  // ── clips list ──────────────────────────────────────────────────────────────
257
503
  async function runList(argv) {
258
504
  const { values } = parseArgs({
@@ -320,8 +566,15 @@ async function runSearch(argv) {
320
566
  queryEmbedding = await client.embedQuery(criteria.semantic_text ?? query);
321
567
  }
322
568
  }
569
+ else if (detectLocalAgent()) {
570
+ // Local-first: no key, but a local agent CLI can still turn the natural-
571
+ // language query into a structured filter (vector re-rank needs a key).
572
+ const agent = new LocalAgentClipClient({});
573
+ console.log(`[clips] structuring query with your local ${agent.tagModelId} …`);
574
+ criteria = await agent.naturalLanguageToCriteria(query);
575
+ }
323
576
  else {
324
- console.warn(`[clips] no ${provider} key — structured/keyword search only (set the provider key for semantic search).`);
577
+ console.warn(`[clips] no ${provider} key or local agent CLI — structured/keyword search only.`);
325
578
  }
326
579
  const t0 = timestamp();
327
580
  const hits = store.search({ criteria, queryEmbedding, limit });
@@ -550,10 +803,20 @@ async function runPresetSave(argv) {
550
803
  criteria = asJson;
551
804
  }
552
805
  else {
553
- // Treat as natural language → structured (needs a provider key).
806
+ // Natural language → structured: provider key when present, else the
807
+ // local agent CLI (local-first), else the usual missing-key error.
554
808
  const provider = normalizeProvider(values.provider);
555
- const apiKey = resolveProviderKey(provider, values);
556
- criteria = await new ClipModelClient({ provider, apiKey }).naturalLanguageToCriteria(fromQuery);
809
+ const apiKey = resolveProviderKey(provider, values, { optional: true });
810
+ if (apiKey) {
811
+ criteria = await new ClipModelClient({ provider, apiKey }).naturalLanguageToCriteria(fromQuery);
812
+ }
813
+ else if (detectLocalAgent()) {
814
+ criteria = await new LocalAgentClipClient({}).naturalLanguageToCriteria(fromQuery);
815
+ }
816
+ else {
817
+ resolveProviderKey(provider, values); // throws the "No <provider> API key" error
818
+ throw new Error("unreachable");
819
+ }
557
820
  }
558
821
  const store = new ClipStore(values.home);
559
822
  const preset = store.savePreset({ name, criteria, description: values.description });