@socialseal/cli 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.6 - 2026-03-19
6
+ - Fix runtime version reporting so `socialseal --version` reads from package metadata instead of a hardcoded source string.
7
+ - Fix `tracking` create request translation so `--workspace-id` is sent on the REST query path the backend uses for workspace binding.
8
+ - Improve tracked-video extraction failure messages by avoiding `[object Object]` item errors and returning explicit guidance when `videoId` is actually a search-result id or tracking item id.
9
+ - Fail fast for `group-management` and `export_tracking_data` when no workspace is selected, instead of silently relying on backend personal-workspace fallback.
10
+ - Warn when `tracking create` runs without a workspace and when short numeric `--video-id` values look like internal row ids.
11
+ - Clarify in workspace discovery output and docs that `workspace_id` and `brand_id` are different identifiers.
12
+
13
+ ## 0.1.5 - 2026-03-19
14
+ - Add first-class tracked-video workflows with `video queue-analysis` and `video extract`.
15
+ - Make `--video-id` the primary ergonomic selector for tracked-video analysis and asset extraction, while keeping `--search-result-id` as a fallback selector.
16
+ - Support batch queueing/extraction payloads plus optional asset downloads for thumbnails, source video, and extracted key frames.
17
+
5
18
  ## 0.1.4 - 2026-03-19
6
19
  - Add explicit `group_add_item` / `group_add_items` CLI aliases for tracking-group membership workflows.
7
20
  - Add `tracking resolve` / `get_by_value` so existing tracked searches can be resolved by value using the same duplicate-detection semantics as create.
package/README.md CHANGED
@@ -49,8 +49,14 @@ Optional config file:
49
49
  - `socialseal tools call --function search-journey-run --body @payload.json --async --workspace-id <uuid>`
50
50
  - `socialseal tools call --function search-journey-run --body @payload.json --async --no-poll --workspace-id <uuid>`
51
51
 
52
+ - Tracked video extraction:
53
+ - `socialseal video queue-analysis --video-id 734829384 --workspace-id <uuid>`
54
+ - `socialseal video extract --video-id 734829384 --workspace-id <uuid>`
55
+ - `socialseal video extract --video-uid <video_uid> --wait --workspace-id <uuid>`
56
+ - `socialseal video extract --body @payload.json --out-dir ./video-assets`
57
+
52
58
  - Data exports (provisional):
53
- - `socialseal data export-tracking --group-id 123 --time-period 30d --out out.csv`
59
+ - `socialseal data export-tracking --group-id 123 --time-period 30d --workspace-id <uuid> --out out.csv`
54
60
  - `socialseal data export-report --report-type keyword_universe --format csv --payload @payload.json --out out.csv`
55
61
 
56
62
  ## Notes
@@ -58,10 +64,16 @@ Optional config file:
58
64
  - `tools list` ships a stable built-in registry of supported direct-call function targets. It is not live backend enumeration.
59
65
  - `--timeout <ms>` controls HTTP request timeouts. Agent runs default to a 5-minute WebSocket inactivity timeout unless you set `--idle-timeout <ms>` (or the matching env/config value).
60
66
  - `search-journey-run` supports CLI-managed async polling: `--async` starts backend async mode, polling is on by default, `--no-poll` returns the initial `runId`, and `--poll-interval <ms>` controls the status polling cadence.
67
+ - `video queue-analysis` wraps the tracked-video extraction backend in queue-only mode so you can queue one or many tracked videos without downloading assets first.
68
+ - `video extract` wraps the same backend in extraction mode and returns a normalized JSON payload with resolved tracking context, structured analysis, thumbnail/frame assets, and optional local downloads under `--out-dir`.
69
+ - `--video-id` is the primary ergonomic selector for video workflows. The backend tries it as `video_uid` first, then as platform video id. It does not accept tracking item ids. `--search-result-id` remains available when you are starting from a specific tracked rank row.
70
+ - `group-management` and `export_tracking_data` now fail fast when no workspace is selected, instead of letting the backend silently fall back to the personal workspace.
71
+ - `tracking create` without a workspace now prints a warning that the backend may create a personal/null-scope item.
72
+ - Short numeric `--video-id` inputs now print a warning that they may be internal row ids and that `--search-result-id` is often the intended selector.
61
73
  - `socialseal agent run` now defaults to a fresh conversation. The CLI prints a continuation token to `stderr`; pass it back with `--continue <token>` to resume the same agent conversation explicitly.
62
- - Effective workspace precedence is: `--workspace-id` → `SOCIALSEAL_WORKSPACE_ID` → config `workspaceId` backend personal-workspace fallback.
74
+ - Effective workspace precedence is: `--workspace-id` → `SOCIALSEAL_WORKSPACE_ID` → config `workspaceId`. For commands that are easy to misuse (`group-management`, `export_tracking_data`, tracked-video workflows), the CLI now requires an explicit or preconfigured workspace instead of relying on backend fallback.
63
75
  - `socialseal workspace use ...` writes a local default workspace into `~/.config/socialseal/config.json`, which the CLI reuses for `agent`, `tools`, and `data` commands.
64
- - `socialseal workspace list` discovers the workspaces accessible to the current CLI key and marks the active/suggested default.
76
+ - `socialseal workspace list` discovers the workspaces accessible to the current CLI key, marks the active/suggested default, and reminds you that `workspace_id` and `brand_id` are different identifiers.
65
77
  - If a scoped CLI key cannot safely infer a workspace, `agent run` now fails closed and tells you to set `--workspace-id` or configure a local default first.
66
78
 
67
79
  ## Errors and exit codes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socialseal/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SocialSeal CLI (non-interactive)",
package/src/index.js CHANGED
@@ -13,8 +13,10 @@ const WORKSPACE_HEADER = 'X-Workspace-Id';
13
13
  const DEFAULT_TIMEOUT_MS = 30000;
14
14
  const DEFAULT_AGENT_IDLE_TIMEOUT_MS = 300000;
15
15
  const DEFAULT_POLL_INTERVAL_MS = 2000;
16
+ const DEFAULT_FRAME_COUNT = 3;
16
17
  const MAX_TIMEOUT_MS = 900000;
17
18
  const LEGACY_ENABLED = process.env.SOCIALSEAL_ENABLE_LEGACY === '1';
19
+ const CLI_VERSION = loadRuntimeVersion();
18
20
  const STATIC_TOOL_REGISTRY_NOTE = 'This registry is shipped with the CLI for stable discovery. It is not live backend enumeration, so environment-specific availability can drift.';
19
21
  const EXIT_CODES = {
20
22
  OK: 0,
@@ -60,7 +62,17 @@ const KNOWN_TOOLS = [
60
62
  transport: 'post_edge_function',
61
63
  workspaceScoped: true,
62
64
  knownLocalDevState: 'disabled_by_default',
63
- notes: 'group_id expects a numeric tracking_group id, not a brand_group UUID.',
65
+ notes: 'group_id expects a numeric tracking_group id, not a brand_group UUID. Always pass a workspace id or configure a default workspace so the export does not silently target the personal workspace.',
66
+ },
67
+ {
68
+ name: 'tracked-video-extract',
69
+ category: 'video',
70
+ description: 'Resolve tracked videos/results into structured analysis plus reference assets.',
71
+ objectType: 'tracked_video_extract',
72
+ transport: 'post_edge_function',
73
+ workspaceScoped: true,
74
+ knownLocalDevState: 'enabled',
75
+ notes: 'Accepts videoId/videoUid/platformVideoId/searchResultId items; videoId means video_uid or platform-native video id, not a tracking item id.',
64
76
  },
65
77
  { name: 'douyin-geo-api', category: 'search', description: 'Query Douyin search and geo data.' },
66
78
  { name: 'google-ai-search', category: 'search', description: 'Run Google AI search queries and fetch result snapshots.' },
@@ -77,7 +89,7 @@ const KNOWN_TOOLS = [
77
89
  workspaceScoped: true,
78
90
  knownLocalDevState: 'disabled_by_default',
79
91
  actionAliases: ['list', 'get', 'create', 'update', 'delete', 'refresh', 'list_items', 'add_item', 'group_add_item', 'add_items', 'group_add_items', 'remove_item', 'group_remove_item'],
80
- notes: 'REST-style surface under /groups. `add_item`/`group_add_item` accepts an existing `item_id`; `add_items`/`group_add_items` accepts `item_ids` or item payloads for bulk membership adds.',
92
+ notes: 'REST-style surface under /groups. `add_item`/`group_add_item` accepts an existing `item_id`; `add_items`/`group_add_items` accepts `item_ids` or item payloads for bulk membership adds. Always pass a workspace id or configure a default workspace so the backend does not fall back to the personal workspace.',
81
93
  },
82
94
  {
83
95
  name: 'tracking',
@@ -112,6 +124,26 @@ function getConfigPath() {
112
124
  return process.env.SOCIALSEAL_CONFIG || DEFAULT_CONFIG_PATH;
113
125
  }
114
126
 
127
+ function loadRuntimeVersion() {
128
+ const envVersion = typeof process.env.npm_package_version === 'string'
129
+ ? process.env.npm_package_version.trim()
130
+ : '';
131
+ if (envVersion) return envVersion;
132
+
133
+ try {
134
+ const packageJsonPath = new URL('../package.json', import.meta.url);
135
+ const raw = fs.readFileSync(packageJsonPath, 'utf8');
136
+ const parsed = JSON.parse(raw);
137
+ if (typeof parsed?.version === 'string' && parsed.version.trim().length > 0) {
138
+ return parsed.version.trim();
139
+ }
140
+ } catch {
141
+ // fall through to the safe fallback below
142
+ }
143
+
144
+ return '0.0.0';
145
+ }
146
+
115
147
  function loadConfig() {
116
148
  const configPath = getConfigPath();
117
149
  try {
@@ -339,6 +371,274 @@ function mergeWorkspaceIdIntoPayload(payload, workspaceId) {
339
371
  return { ...payload, workspaceId };
340
372
  }
341
373
 
374
+ function sanitizeFileComponent(value, fallback = 'item') {
375
+ const normalized = String(value || '')
376
+ .trim()
377
+ .replace(/[^a-z0-9._-]+/gi, '-')
378
+ .replace(/-+/g, '-')
379
+ .replace(/^-|-$/g, '');
380
+ return normalized || fallback;
381
+ }
382
+
383
+ function inferExtension(urlValue, contentType, fallback = '.bin') {
384
+ const normalizedType = String(contentType || '').toLowerCase();
385
+ if (normalizedType.includes('jpeg')) return '.jpg';
386
+ if (normalizedType.includes('png')) return '.png';
387
+ if (normalizedType.includes('webp')) return '.webp';
388
+ if (normalizedType.includes('gif')) return '.gif';
389
+ if (normalizedType.includes('mp4')) return '.mp4';
390
+ if (normalizedType.includes('quicktime')) return '.mov';
391
+
392
+ try {
393
+ const pathname = new URL(urlValue).pathname || '';
394
+ const ext = path.extname(pathname);
395
+ if (ext) return ext;
396
+ } catch {
397
+ // ignore parse failures
398
+ }
399
+ return fallback;
400
+ }
401
+
402
+ function normalizeVideoExtractBody(body) {
403
+ const normalized = { ...body };
404
+ const hasInlineIdentifier =
405
+ normalized.videoId !== undefined ||
406
+ normalized.searchResultId !== undefined ||
407
+ normalized.videoUid !== undefined ||
408
+ normalized.platformVideoId !== undefined;
409
+
410
+ if (!Array.isArray(normalized.items) && hasInlineIdentifier) {
411
+ normalized.items = [{
412
+ videoId: normalized.videoId,
413
+ searchResultId: normalized.searchResultId,
414
+ videoUid: normalized.videoUid,
415
+ platformVideoId: normalized.platformVideoId,
416
+ platformId: normalized.platformId,
417
+ }];
418
+ delete normalized.videoId;
419
+ delete normalized.searchResultId;
420
+ delete normalized.videoUid;
421
+ delete normalized.platformVideoId;
422
+ delete normalized.platformId;
423
+ }
424
+
425
+ return normalized;
426
+ }
427
+
428
+ function buildVideoExtractBody(opts, workspaceId) {
429
+ const parsed = opts.body
430
+ ? ensureJsonObject(parseJsonInput(opts.body, { label: 'body' }), 'body')
431
+ : {};
432
+ const normalized = normalizeVideoExtractBody(parsed);
433
+
434
+ if (!Array.isArray(normalized.items) || normalized.items.length === 0) {
435
+ const inlineItem = stripUndefinedEntries({
436
+ videoId: trimString(opts.videoId) || undefined,
437
+ searchResultId: opts.searchResultId !== undefined
438
+ ? coercePositiveInteger(opts.searchResultId, 'searchResultId')
439
+ : undefined,
440
+ videoUid: trimString(opts.videoUid) || undefined,
441
+ platformVideoId: trimString(opts.platformVideoId) || undefined,
442
+ });
443
+
444
+ if (Object.keys(inlineItem).length === 0) {
445
+ throw new CliError('Provide --body or one of --video-id, --video-uid, --platform-video-id, or --search-result-id.', {
446
+ code: 'MISSING_ARGUMENT',
447
+ exitCode: EXIT_CODES.USAGE,
448
+ hint: '--video-id accepts a video_uid or platform video id. It does not accept tracking item ids.',
449
+ });
450
+ }
451
+
452
+ normalized.items = [inlineItem];
453
+ }
454
+
455
+ const bodyWithWorkspace = mergeWorkspaceIdIntoPayload(normalized, workspaceId);
456
+ if (!bodyWithWorkspace.workspaceId) {
457
+ throw new CliError('tracked-video extraction requires a workspace id.', {
458
+ code: 'WORKSPACE_REQUIRED',
459
+ exitCode: EXIT_CODES.USAGE,
460
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace.',
461
+ });
462
+ }
463
+
464
+ const nextBody = { ...bodyWithWorkspace };
465
+ if (opts.wait) {
466
+ nextBody.ensureAnalysis = true;
467
+ } else if (opts.ensureAnalysis === true) {
468
+ nextBody.ensureAnalysis = true;
469
+ } else if (nextBody.ensureAnalysis === undefined) {
470
+ nextBody.ensureAnalysis = false;
471
+ }
472
+
473
+ if (opts.skipAssets === true) {
474
+ nextBody.includeAssets = false;
475
+ } else if (nextBody.includeAssets === undefined) {
476
+ nextBody.includeAssets = true;
477
+ }
478
+
479
+ if (opts.includeSourceVideo === true) {
480
+ nextBody.includeSourceVideo = true;
481
+ } else if (nextBody.includeSourceVideo === undefined) {
482
+ nextBody.includeSourceVideo = false;
483
+ }
484
+
485
+ if (opts.frameStrategy && nextBody.frameStrategy === undefined) {
486
+ nextBody.frameStrategy = opts.frameStrategy;
487
+ }
488
+ if (nextBody.frameStrategy === undefined) {
489
+ nextBody.frameStrategy = 'brief_shots';
490
+ }
491
+
492
+ if (nextBody.frameCount === undefined) {
493
+ nextBody.frameCount = opts.frameCount !== undefined
494
+ ? Number(opts.frameCount)
495
+ : DEFAULT_FRAME_COUNT;
496
+ }
497
+
498
+ if (nextBody.signedUrlSeconds === undefined) {
499
+ nextBody.signedUrlSeconds = opts.signedUrlSeconds !== undefined
500
+ ? Number(opts.signedUrlSeconds)
501
+ : 3600;
502
+ }
503
+
504
+ return nextBody;
505
+ }
506
+
507
+ function buildVideoQueueBody(opts, workspaceId) {
508
+ const body = buildVideoExtractBody(opts, workspaceId);
509
+ return {
510
+ ...body,
511
+ ensureAnalysis: true,
512
+ queueOnly: true,
513
+ includeAssets: false,
514
+ includeRawAnalysis: false,
515
+ includeSourceVideo: false,
516
+ };
517
+ }
518
+
519
+ function hasPendingVideoExtractResults(payload) {
520
+ const results = Array.isArray(payload?.results) ? payload.results : [];
521
+ return results.some((result) => {
522
+ const status = String(result?.analysis?.status || '').trim().toLowerCase();
523
+ return status === 'pending' || status === 'processing';
524
+ });
525
+ }
526
+
527
+ async function downloadAssetToFile({ url, outDir, stem, timeoutMs }) {
528
+ const response = await fetchWithTimeout(url, {
529
+ method: 'GET',
530
+ headers: { Accept: '*/*' },
531
+ }, timeoutMs);
532
+
533
+ if (!response.ok) {
534
+ throw new CliError(`Asset download failed: ${response.status}`, {
535
+ code: 'ASSET_DOWNLOAD_FAILED',
536
+ exitCode: EXIT_CODES.SERVER,
537
+ details: await response.text().catch(() => null),
538
+ });
539
+ }
540
+
541
+ const extension = inferExtension(url, response.headers.get('content-type'));
542
+ const absolutePath = path.resolve(outDir, `${stem}${extension}`);
543
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
544
+ const buffer = Buffer.from(await response.arrayBuffer());
545
+ fs.writeFileSync(absolutePath, buffer);
546
+ return {
547
+ path: absolutePath,
548
+ bytes: buffer.length,
549
+ };
550
+ }
551
+
552
+ async function downloadVideoExtractAssets(payload, outDir, timeoutMs) {
553
+ const results = Array.isArray(payload?.results) ? payload.results : [];
554
+ const absoluteBaseDir = path.resolve(outDir);
555
+ fs.mkdirSync(absoluteBaseDir, { recursive: true });
556
+
557
+ for (let index = 0; index < results.length; index += 1) {
558
+ const result = results[index];
559
+ const resolved = result?.resolved || {};
560
+ const videoKey = sanitizeFileComponent(
561
+ resolved.videoUid || resolved.platformVideoId || resolved.searchResultId || `item-${index + 1}`,
562
+ `item-${index + 1}`,
563
+ );
564
+ const itemDir = path.join(absoluteBaseDir, `${String(index + 1).padStart(2, '0')}-${videoKey}`);
565
+ const downloads = {
566
+ directory: itemDir,
567
+ thumbnail: null,
568
+ sourceVideo: null,
569
+ frames: [],
570
+ };
571
+
572
+ const thumbnailUrl = result?.assets?.thumbnail?.url;
573
+ if (typeof thumbnailUrl === 'string' && thumbnailUrl.length > 0) {
574
+ try {
575
+ downloads.thumbnail = await downloadAssetToFile({
576
+ url: thumbnailUrl,
577
+ outDir: itemDir,
578
+ stem: 'thumbnail',
579
+ timeoutMs,
580
+ });
581
+ } catch (error) {
582
+ downloads.thumbnail = {
583
+ error: error instanceof Error ? error.message : String(error),
584
+ };
585
+ }
586
+ }
587
+
588
+ const sourceVideoUrl = result?.assets?.sourceVideo?.signedUrl;
589
+ if (typeof sourceVideoUrl === 'string' && sourceVideoUrl.length > 0) {
590
+ try {
591
+ downloads.sourceVideo = await downloadAssetToFile({
592
+ url: sourceVideoUrl,
593
+ outDir: itemDir,
594
+ stem: 'source-video',
595
+ timeoutMs,
596
+ });
597
+ } catch (error) {
598
+ downloads.sourceVideo = {
599
+ error: error instanceof Error ? error.message : String(error),
600
+ };
601
+ }
602
+ }
603
+
604
+ const frames = Array.isArray(result?.assets?.frames) ? result.assets.frames : [];
605
+ for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) {
606
+ const frame = frames[frameIndex];
607
+ const frameUrl = frame?.signedUrl;
608
+ if (typeof frameUrl !== 'string' || frameUrl.length === 0) continue;
609
+ const timestampToken = sanitizeFileComponent(frame?.timestamp || `frame-${frameIndex + 1}`, `frame-${frameIndex + 1}`);
610
+ try {
611
+ const download = await downloadAssetToFile({
612
+ url: frameUrl,
613
+ outDir: itemDir,
614
+ stem: `${frame?.kind || 'frame'}-${String(frameIndex + 1).padStart(2, '0')}-${timestampToken}`,
615
+ timeoutMs,
616
+ });
617
+ downloads.frames.push({
618
+ kind: frame?.kind || null,
619
+ timestamp: frame?.timestamp || null,
620
+ path: download.path,
621
+ bytes: download.bytes,
622
+ });
623
+ } catch (error) {
624
+ downloads.frames.push({
625
+ kind: frame?.kind || null,
626
+ timestamp: frame?.timestamp || null,
627
+ error: error instanceof Error ? error.message : String(error),
628
+ });
629
+ }
630
+ }
631
+
632
+ result.downloads = downloads;
633
+ }
634
+
635
+ return {
636
+ ...payload,
637
+ downloadsRoot: absoluteBaseDir,
638
+ results,
639
+ };
640
+ }
641
+
342
642
  function hasOwn(value, key) {
343
643
  return Boolean(value) && Object.prototype.hasOwnProperty.call(value, key);
344
644
  }
@@ -655,7 +955,7 @@ function translateTrackingAction(payload, workspaceId) {
655
955
  if (!action) {
656
956
  return {
657
957
  method: 'POST',
658
- pathSuffix: '',
958
+ pathSuffix: buildPathWithQuery('', { workspace_id: workspaceId || undefined }),
659
959
  body: stripUndefinedEntries({
660
960
  name: payload.name,
661
961
  track_type: payload.track_type,
@@ -735,7 +1035,7 @@ function translateTrackingAction(payload, workspaceId) {
735
1035
  if (action === 'create' || action === 'item_create') {
736
1036
  return {
737
1037
  method: 'POST',
738
- pathSuffix: '',
1038
+ pathSuffix: buildPathWithQuery('', { workspace_id: workspaceId || undefined }),
739
1039
  body: stripUndefinedEntries({
740
1040
  name: payload.name,
741
1041
  track_type: payload.track_type,
@@ -1202,6 +1502,61 @@ function emitWorkspaceContext(opts, { workspaceId, source, functionName, method
1202
1502
  );
1203
1503
  }
1204
1504
 
1505
+ function describeWorkspaceSource(source) {
1506
+ switch (source) {
1507
+ case 'flag':
1508
+ return '--workspace-id';
1509
+ case 'env':
1510
+ return 'SOCIALSEAL_WORKSPACE_ID';
1511
+ case 'config':
1512
+ return 'the saved default workspace';
1513
+ case 'body':
1514
+ return 'the request body';
1515
+ default:
1516
+ return 'implicit selection';
1517
+ }
1518
+ }
1519
+
1520
+ function emitWorkspaceSelectionNotice(opts, { workspaceId, source, label }) {
1521
+ if (!workspaceId || !source || source === 'flag' || source === 'body') return;
1522
+ process.stderr.write(
1523
+ `[socialseal] Using workspace ${workspaceId} from ${describeWorkspaceSource(source)} for ${label}. Pass --workspace-id to override.\n`,
1524
+ );
1525
+ }
1526
+
1527
+ function requireWorkspaceSelection(workspaceId, { label, hint }) {
1528
+ if (workspaceId) return workspaceId;
1529
+ throw new CliError(`${label} requires a workspace id.`, {
1530
+ code: 'WORKSPACE_REQUIRED',
1531
+ exitCode: EXIT_CODES.USAGE,
1532
+ hint,
1533
+ });
1534
+ }
1535
+
1536
+ function emitTrackingCreateScopeWarning(action, workspaceId) {
1537
+ if (action !== 'create' || workspaceId) return;
1538
+ process.stderr.write(
1539
+ '[socialseal] tracking create is running without a workspace id. The backend may create a personal/null-scope item that is not attached to a workspace or group.\n',
1540
+ );
1541
+ }
1542
+
1543
+ function looksLikeShortNumericVideoId(value) {
1544
+ return typeof value === 'string' && /^\d{1,7}$/.test(value.trim());
1545
+ }
1546
+
1547
+ function emitAmbiguousVideoIdWarnings(items) {
1548
+ const references = Array.isArray(items) ? items : [];
1549
+ for (const item of references) {
1550
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
1551
+ if (looksLikeShortNumericVideoId(item.videoId)) {
1552
+ process.stderr.write(
1553
+ `[socialseal] videoId "${item.videoId}" looks like a short internal row id. If you meant a ranked result row, use --search-result-id. If you meant a tracking item id, resolve it first and retry with --video-uid or --platform-video-id.\n`,
1554
+ );
1555
+ return;
1556
+ }
1557
+ }
1558
+ }
1559
+
1205
1560
  function sleep(ms) {
1206
1561
  return new Promise((resolve) => setTimeout(resolve, ms));
1207
1562
  }
@@ -1351,7 +1706,7 @@ function buildStatusHint(status, context = {}) {
1351
1706
  case 404:
1352
1707
  if (context.functionName) {
1353
1708
  if (isLocallyDisabledByDefaultFunction(context.functionName)) {
1354
- return `Unknown function "${context.functionName}". This tool is listed in the static registry, but it is disabled by default in some local Supabase environments. Check the deployment or enable it in supabase/config.toml.`;
1709
+ return `Unknown function "${context.functionName}". The CLI ships a static registry, but availability depends on the backend you are calling. Verify the tool is deployed on the current API base; for local direct Supabase calls, enable it in supabase/config.toml.`;
1355
1710
  }
1356
1711
  return `Unknown function "${context.functionName}". Double-check the name and API base.`;
1357
1712
  }
@@ -1919,14 +2274,64 @@ async function handleToolsCall(opts) {
1919
2274
  resolvedWorkspaceId,
1920
2275
  });
1921
2276
  const method = normalizeMethod(translated.method);
1922
- const effectiveWorkspaceId = translated.workspaceId ?? resolvedWorkspaceId ?? null;
2277
+ const payloadWorkspaceId = isJsonObject(translated.normalizedPayload)
2278
+ ? resolvePayloadWorkspaceId(translated.normalizedPayload, resolvedWorkspaceId)
2279
+ : (isJsonObject(translated.body)
2280
+ ? resolvePayloadWorkspaceId(translated.body, resolvedWorkspaceId)
2281
+ : (resolvedWorkspaceId ?? null));
2282
+ const effectiveWorkspaceId = translated.workspaceId ?? payloadWorkspaceId ?? null;
2283
+ const effectiveWorkspaceSource =
2284
+ translated.workspaceId && translated.workspaceId !== resolvedWorkspaceId
2285
+ ? 'body'
2286
+ : (payloadWorkspaceId && payloadWorkspaceId !== resolvedWorkspaceId ? 'body' : workspaceSource);
1923
2287
  const path = useGateway
1924
2288
  ? `/cli/tools/${opts.function}${translated.pathSuffix || ''}`
1925
2289
  : `/functions/v1/${opts.function}${translated.pathSuffix || ''}`;
1926
2290
 
2291
+ if (opts.function === 'group-management') {
2292
+ requireWorkspaceSelection(effectiveWorkspaceId, {
2293
+ label: 'group-management',
2294
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace. Omitting workspace lets the backend fall back to the personal workspace.',
2295
+ });
2296
+ emitWorkspaceSelectionNotice(opts, {
2297
+ workspaceId: effectiveWorkspaceId,
2298
+ source: effectiveWorkspaceSource,
2299
+ label: 'group-management',
2300
+ });
2301
+ }
2302
+
2303
+ if (opts.function === 'export_tracking_data') {
2304
+ requireWorkspaceSelection(effectiveWorkspaceId, {
2305
+ label: 'export_tracking_data',
2306
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace before exporting tracking data.',
2307
+ });
2308
+ emitWorkspaceSelectionNotice(opts, {
2309
+ workspaceId: effectiveWorkspaceId,
2310
+ source: effectiveWorkspaceSource,
2311
+ label: 'export_tracking_data',
2312
+ });
2313
+ }
2314
+
2315
+ if (opts.function === 'tracked-video-extract') {
2316
+ requireWorkspaceSelection(effectiveWorkspaceId, {
2317
+ label: 'tracked-video-extract',
2318
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace.',
2319
+ });
2320
+ emitWorkspaceSelectionNotice(opts, {
2321
+ workspaceId: effectiveWorkspaceId,
2322
+ source: effectiveWorkspaceSource,
2323
+ label: 'tracked-video-extract',
2324
+ });
2325
+ }
2326
+
2327
+ emitTrackingCreateScopeWarning(
2328
+ isJsonObject(translated.normalizedPayload) ? trimString(translated.normalizedPayload.action).toLowerCase() : '',
2329
+ effectiveWorkspaceId,
2330
+ );
2331
+
1927
2332
  emitWorkspaceContext(opts, {
1928
2333
  workspaceId: effectiveWorkspaceId,
1929
- source: effectiveWorkspaceId === resolvedWorkspaceId ? workspaceSource : 'body',
2334
+ source: effectiveWorkspaceSource,
1930
2335
  functionName: opts.function,
1931
2336
  method,
1932
2337
  });
@@ -2041,7 +2446,7 @@ async function handleDataExportTracking(opts) {
2041
2446
  const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
2042
2447
  const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
2043
2448
  const timeoutMs = resolveTimeoutMs(opts, config);
2044
- const { workspaceId: resolvedWorkspaceId } = resolveWorkspaceSelection(opts, config);
2449
+ const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
2045
2450
 
2046
2451
  if (!opts.groupId && !opts.itemId) {
2047
2452
  throw new CliError('Provide --group-id or --item-id.', {
@@ -2063,6 +2468,16 @@ async function handleDataExportTracking(opts) {
2063
2468
  time_period: opts.timePeriod,
2064
2469
  };
2065
2470
 
2471
+ requireWorkspaceSelection(resolvedWorkspaceId, {
2472
+ label: 'Tracking export',
2473
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace before exporting tracking data.',
2474
+ });
2475
+ emitWorkspaceSelectionNotice(opts, {
2476
+ workspaceId: resolvedWorkspaceId,
2477
+ source: workspaceSource,
2478
+ label: 'tracking export',
2479
+ });
2480
+
2066
2481
  const res = await callApi({
2067
2482
  apiBase: useGateway ? resolvedApiBase : legacyUrl,
2068
2483
  apiKey,
@@ -2156,6 +2571,178 @@ async function handleDataExportReport(opts) {
2156
2571
  process.stdout.write(JSON.stringify(json, null, opts.pretty ? 2 : 0) + '\n');
2157
2572
  }
2158
2573
 
2574
+ async function handleVideoExtract(opts) {
2575
+ const config = loadConfig();
2576
+ const apiKey = requireApiKey(opts, config);
2577
+ const apiBase = resolveApiBase(opts, config);
2578
+ const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
2579
+ const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
2580
+ const timeoutMs = resolveTimeoutMs(opts, config);
2581
+ const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
2582
+
2583
+ const body = buildVideoExtractBody(opts, resolvedWorkspaceId);
2584
+ const path = useGateway ? '/cli/tools/tracked-video-extract' : '/functions/v1/tracked-video-extract';
2585
+ const effectiveWorkspaceId = body.workspaceId || resolvedWorkspaceId;
2586
+ const effectiveWorkspaceSource = body.workspaceId && body.workspaceId !== resolvedWorkspaceId ? 'body' : workspaceSource;
2587
+
2588
+ emitWorkspaceSelectionNotice(opts, {
2589
+ workspaceId: effectiveWorkspaceId,
2590
+ source: effectiveWorkspaceSource,
2591
+ label: 'tracked-video extract',
2592
+ });
2593
+ emitAmbiguousVideoIdWarnings(body.items);
2594
+
2595
+ emitWorkspaceContext(opts, {
2596
+ workspaceId: effectiveWorkspaceId,
2597
+ source: effectiveWorkspaceSource,
2598
+ functionName: 'tracked-video-extract',
2599
+ method: 'POST',
2600
+ });
2601
+
2602
+ const requestOnce = async (remainingTimeoutMs) => {
2603
+ const res = await callApi({
2604
+ apiBase: useGateway ? resolvedApiBase : legacyUrl,
2605
+ apiKey,
2606
+ path,
2607
+ method: 'POST',
2608
+ body,
2609
+ workspaceId: effectiveWorkspaceId,
2610
+ timeoutMs: remainingTimeoutMs,
2611
+ });
2612
+
2613
+ if (!res.ok) {
2614
+ throw await buildHttpError(res, {
2615
+ label: 'Tracked video extract',
2616
+ functionName: 'tracked-video-extract',
2617
+ method: 'POST',
2618
+ });
2619
+ }
2620
+
2621
+ const contentType = res.headers.get('content-type') || '';
2622
+ if (!contentType.includes('application/json')) {
2623
+ throw new CliError('tracked-video-extract returned a non-JSON response.', {
2624
+ code: 'INVALID_RESPONSE',
2625
+ exitCode: EXIT_CODES.SERVER,
2626
+ });
2627
+ }
2628
+
2629
+ return await res.json();
2630
+ };
2631
+
2632
+ let payload = await requestOnce(timeoutMs);
2633
+
2634
+ if (opts.wait) {
2635
+ const pollIntervalMs = resolvePollIntervalMs(opts);
2636
+ const deadline = Date.now() + timeoutMs;
2637
+
2638
+ while (hasPendingVideoExtractResults(payload)) {
2639
+ const remainingMs = deadline - Date.now();
2640
+ if (remainingMs <= 0) {
2641
+ throw new CliError('Timed out waiting for tracked video analysis completion.', {
2642
+ code: 'ASYNC_WAIT_TIMEOUT',
2643
+ exitCode: EXIT_CODES.SERVER,
2644
+ hint: 'Increase --timeout <ms> or omit --wait to return the current status immediately.',
2645
+ details: truncateDetails(payload),
2646
+ });
2647
+ }
2648
+
2649
+ emitInfo(opts, 'tracked-video-extract pending; polling for completion.');
2650
+ await sleep(Math.min(pollIntervalMs, remainingMs));
2651
+ payload = await requestOnce(Math.max(1000, deadline - Date.now()));
2652
+ }
2653
+ }
2654
+
2655
+ if (opts.outDir) {
2656
+ payload = await downloadVideoExtractAssets(payload, opts.outDir, timeoutMs);
2657
+ }
2658
+
2659
+ emitJsonOutput(payload, opts.pretty);
2660
+ }
2661
+
2662
+ async function handleVideoQueueAnalysis(opts) {
2663
+ const config = loadConfig();
2664
+ const apiKey = requireApiKey(opts, config);
2665
+ const apiBase = resolveApiBase(opts, config);
2666
+ const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
2667
+ const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
2668
+ const timeoutMs = resolveTimeoutMs(opts, config);
2669
+ const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
2670
+
2671
+ const body = buildVideoQueueBody(opts, resolvedWorkspaceId);
2672
+ const effectiveWorkspaceId = body.workspaceId || resolvedWorkspaceId;
2673
+ const effectiveWorkspaceSource = body.workspaceId && body.workspaceId !== resolvedWorkspaceId ? 'body' : workspaceSource;
2674
+
2675
+ emitWorkspaceSelectionNotice(opts, {
2676
+ workspaceId: effectiveWorkspaceId,
2677
+ source: effectiveWorkspaceSource,
2678
+ label: 'tracked-video queue-analysis',
2679
+ });
2680
+ emitAmbiguousVideoIdWarnings(body.items);
2681
+ const path = useGateway ? '/cli/tools/tracked-video-extract' : '/functions/v1/tracked-video-extract';
2682
+
2683
+ emitWorkspaceContext(opts, {
2684
+ workspaceId: effectiveWorkspaceId,
2685
+ source: effectiveWorkspaceSource,
2686
+ functionName: 'tracked-video-extract',
2687
+ method: 'POST',
2688
+ });
2689
+
2690
+ const requestOnce = async (remainingTimeoutMs) => {
2691
+ const res = await callApi({
2692
+ apiBase: useGateway ? resolvedApiBase : legacyUrl,
2693
+ apiKey,
2694
+ path,
2695
+ method: 'POST',
2696
+ body,
2697
+ workspaceId: effectiveWorkspaceId,
2698
+ timeoutMs: remainingTimeoutMs,
2699
+ });
2700
+
2701
+ if (!res.ok) {
2702
+ throw await buildHttpError(res, {
2703
+ label: 'Tracked video queue-analysis',
2704
+ functionName: 'tracked-video-extract',
2705
+ method: 'POST',
2706
+ });
2707
+ }
2708
+
2709
+ const contentType = res.headers.get('content-type') || '';
2710
+ if (!contentType.includes('application/json')) {
2711
+ throw new CliError('tracked-video-extract returned a non-JSON response.', {
2712
+ code: 'INVALID_RESPONSE',
2713
+ exitCode: EXIT_CODES.SERVER,
2714
+ });
2715
+ }
2716
+
2717
+ return await res.json();
2718
+ };
2719
+
2720
+ let payload = await requestOnce(timeoutMs);
2721
+
2722
+ if (opts.wait) {
2723
+ const pollIntervalMs = resolvePollIntervalMs(opts);
2724
+ const deadline = Date.now() + timeoutMs;
2725
+
2726
+ while (hasPendingVideoExtractResults(payload)) {
2727
+ const remainingMs = deadline - Date.now();
2728
+ if (remainingMs <= 0) {
2729
+ throw new CliError('Timed out waiting for queued video analysis completion.', {
2730
+ code: 'ASYNC_WAIT_TIMEOUT',
2731
+ exitCode: EXIT_CODES.SERVER,
2732
+ hint: 'Increase --timeout <ms> or omit --wait to return queue status immediately.',
2733
+ details: truncateDetails(payload),
2734
+ });
2735
+ }
2736
+
2737
+ emitInfo(opts, 'tracked-video queue-analysis pending; polling for completion.');
2738
+ await sleep(Math.min(pollIntervalMs, remainingMs));
2739
+ payload = await requestOnce(Math.max(1000, deadline - Date.now()));
2740
+ }
2741
+ }
2742
+
2743
+ emitJsonOutput(payload, opts.pretty);
2744
+ }
2745
+
2159
2746
  async function handleWorkspaceList(opts) {
2160
2747
  const config = loadConfig();
2161
2748
  const apiKey = requireApiKey(opts, config);
@@ -2192,6 +2779,8 @@ async function handleWorkspaceList(opts) {
2192
2779
  process.stdout.write(`${formatWorkspaceLine(workspace, { isEffective, source: selection.source, isSuggested })}\n`);
2193
2780
  }
2194
2781
 
2782
+ process.stdout.write('\n[socialseal] Note: workspace ids are not brand ids. When a payload includes both workspace_id and brand_id, pass the workspace id to --workspace-id.\n');
2783
+
2195
2784
  if (!selection.workspaceId && directory.defaultWorkspaceId) {
2196
2785
  process.stdout.write('\n[socialseal] No local default is configured. Set one with: socialseal workspace use <id>\n');
2197
2786
  }
@@ -2236,6 +2825,7 @@ async function handleWorkspaceCurrent(opts) {
2236
2825
 
2237
2826
  if (effectiveWorkspace) {
2238
2827
  process.stdout.write(`[socialseal] Effective workspace: ${effectiveWorkspace.name} (${effectiveWorkspace.id}) via ${selection.source}\n`);
2828
+ process.stdout.write('[socialseal] Note: workspace ids are not brand ids. Use the workspace id, not brand_id, with --workspace-id.\n');
2239
2829
  return;
2240
2830
  }
2241
2831
 
@@ -2304,7 +2894,7 @@ const program = new Command();
2304
2894
  program
2305
2895
  .name('socialseal')
2306
2896
  .description('SocialSeal CLI (non-interactive)')
2307
- .version('0.1.1');
2897
+ .version(CLI_VERSION);
2308
2898
 
2309
2899
  if (typeof program.showHelpAfterError === 'function') {
2310
2900
  program.showHelpAfterError(true);
@@ -2312,7 +2902,7 @@ if (typeof program.showHelpAfterError === 'function') {
2312
2902
  if (typeof program.showSuggestionAfterError === 'function') {
2313
2903
  program.showSuggestionAfterError(true);
2314
2904
  }
2315
- program.addHelpText('after', `\nExamples:\n socialseal workspace list\n socialseal workspace use <workspace-id>\n socialseal agent run --message \"ping\"\n socialseal tools list\n socialseal tools call --function <tool> --body @payload.json\n socialseal tools call --function search-journey-run --body @payload.json --async --workspace-id <uuid>\n socialseal data export-tracking --group-id 123 --time-period 30d\n`);
2905
+ program.addHelpText('after', `\nExamples:\n socialseal workspace list\n socialseal workspace use <workspace-id>\n socialseal agent run --message \"ping\"\n socialseal tools list\n socialseal tools call --function <tool> --body @payload.json\n socialseal tools call --function search-journey-run --body @payload.json --async --workspace-id <uuid>\n socialseal video queue-analysis --video-id 734829384 --workspace-id <uuid>\n socialseal video extract --video-id 734829384 --wait --out-dir ./video-assets\n socialseal data export-tracking --group-id 123 --time-period 30d\n`);
2316
2906
 
2317
2907
  program
2318
2908
  .command('agent')
@@ -2437,4 +3027,51 @@ data
2437
3027
  .option('--verbose', 'Show error details')
2438
3028
  .action((opts) => runCommand(handleDataExportReport, opts));
2439
3029
 
3030
+ const video = program.command('video').description('Tracked video extraction workflows');
3031
+
3032
+ video
3033
+ .command('queue-analysis')
3034
+ .description('Queue video analysis for tracked videos or tracked search results')
3035
+ .option('--video-id <id>', 'Tracked video identifier (video_uid first, then platform video id; not a tracking item id)')
3036
+ .option('--search-result-id <id>', 'Tracked search result id for a ranked result row')
3037
+ .option('--video-uid <id>', 'Canonical tracked video_uid')
3038
+ .option('--platform-video-id <id>', 'Platform-native video id')
3039
+ .option('--body <jsonOrFile>', 'JSON body or @payload.json for batch queueing')
3040
+ .option('--wait', 'Poll until queued/completing analyses settle')
3041
+ .option('--poll-interval <ms>', 'Polling interval in milliseconds when --wait is enabled')
3042
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
3043
+ .option('--api-key <key>', 'CLI API key')
3044
+ .option('--workspace-id <id>', 'Workspace id (for scoped keys)')
3045
+ .option('--pretty', 'Pretty-print JSON')
3046
+ .option('--json', 'Emit machine-readable errors')
3047
+ .option('--timeout <ms>', 'Request timeout in milliseconds')
3048
+ .option('--verbose', 'Show error details')
3049
+ .action((opts) => runCommand(handleVideoQueueAnalysis, opts));
3050
+
3051
+ video
3052
+ .command('extract')
3053
+ .description('Resolve tracked videos/results into structured analysis plus reference assets')
3054
+ .option('--video-id <id>', 'Tracked video identifier (video_uid first, then platform video id; not a tracking item id)')
3055
+ .option('--search-result-id <id>', 'Tracked search result id for a ranked result row')
3056
+ .option('--video-uid <id>', 'Canonical tracked video_uid')
3057
+ .option('--platform-video-id <id>', 'Platform-native video id')
3058
+ .option('--body <jsonOrFile>', 'JSON body or @payload.json for batch extraction')
3059
+ .option('--ensure-analysis', 'Queue analysis when it is missing')
3060
+ .option('--wait', 'Poll until queued/completing analyses settle')
3061
+ .option('--poll-interval <ms>', 'Polling interval in milliseconds when --wait is enabled')
3062
+ .option('--skip-assets', 'Skip asset URL generation')
3063
+ .option('--include-source-video', 'Include a signed source MP4 URL when available')
3064
+ .option('--frame-strategy <strategy>', 'brief_shots|quartiles')
3065
+ .option('--frame-count <n>', 'Number of still frames to return (1-5)')
3066
+ .option('--signed-url-seconds <n>', 'Signed URL TTL in seconds')
3067
+ .option('--out-dir <path>', 'Download returned assets into this local directory')
3068
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
3069
+ .option('--api-key <key>', 'CLI API key')
3070
+ .option('--workspace-id <id>', 'Workspace id (for scoped keys)')
3071
+ .option('--pretty', 'Pretty-print JSON')
3072
+ .option('--json', 'Emit machine-readable errors')
3073
+ .option('--timeout <ms>', 'Request timeout in milliseconds')
3074
+ .option('--verbose', 'Show error details')
3075
+ .action((opts) => runCommand(handleVideoExtract, opts));
3076
+
2440
3077
  program.parseAsync(process.argv);