@socialseal/cli 0.1.8 → 0.1.10

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,18 +2,33 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.10 - 2026-06-10
6
+
7
+ - Clarify ranked search exports now include publish/observed dates and scoped tracked-search resurfacing history fields when the backend export template is deployed.
8
+ - Update `data export-options` metadata to distinguish search capture, metrics capture, platform publish, and SocialSeal observed-history timestamps.
9
+
10
+ ## 0.1.9 - 2026-05-13
11
+
12
+ - Fail `group-management add_items` on backend partial failures, expected-count mismatches, and failed backend verification.
13
+ - Add `data export-group-evidence` to route social ranked evidence and Google AI evidence to the right raw export template with metadata.
14
+ - Add `data group-completeness` with backend-first completeness checks, manifest fallback, and refresh status visibility.
15
+ - Harden `tools status --wait` terminal failure handling and empty group refresh guardrails.
16
+
5
17
  ## 0.1.8 - 2026-04-13
18
+
6
19
  - Increase default CLI timeout from 30s to 5m to reduce false timeout failures on heavy tool/export workflows.
7
20
  - Harden async `search-journey-run` polling to accept additional active/terminal status labels (`queued`, `in_progress`, `running`, `succeeded`, `error`) instead of failing on unexpected variants.
8
21
  - Fix async `search-journey-run` polling workspace propagation to always reuse the resolved effective workspace scope.
9
22
 
10
23
  ## 0.1.7 - 2026-03-20
24
+
11
25
  - Add `socialseal data export-search-results` for CLI-first enriched ranked-search exports, including direct CSV download handling.
12
26
  - Add `search_results_enriched` as an alias on `socialseal data export-report` to map to the ranked-search export template.
13
27
  - Add `socialseal data export-options` to make available export workflows discoverable from the CLI.
14
28
  - Improve export ergonomics with local report-type validation and instructive failure guidance for processing, failed, and expired-download states.
15
29
 
16
30
  ## 0.1.6 - 2026-03-19
31
+
17
32
  - Fix runtime version reporting so `socialseal --version` reads from package metadata instead of a hardcoded source string.
18
33
  - Fix `tracking` create request translation so `--workspace-id` is sent on the REST query path the backend uses for workspace binding.
19
34
  - 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.
@@ -22,28 +37,34 @@
22
37
  - Clarify in workspace discovery output and docs that `workspace_id` and `brand_id` are different identifiers.
23
38
 
24
39
  ## 0.1.5 - 2026-03-19
40
+
25
41
  - Add first-class tracked-video workflows with `video queue-analysis` and `video extract`.
26
42
  - Make `--video-id` the primary ergonomic selector for tracked-video analysis and asset extraction, while keeping `--search-result-id` as a fallback selector.
27
43
  - Support batch queueing/extraction payloads plus optional asset downloads for thumbnails, source video, and extracted key frames.
28
44
 
29
45
  ## 0.1.4 - 2026-03-19
46
+
30
47
  - Add explicit `group_add_item` / `group_add_items` CLI aliases for tracking-group membership workflows.
31
48
  - Add `tracking resolve` / `get_by_value` so existing tracked searches can be resolved by value using the same duplicate-detection semantics as create.
32
49
  - Return operational duplicate metadata for tracking conflicts, including `existing_item_id`, `member_of_group_ids`, platform, region, workspace, and active state.
33
50
 
34
51
  ## 0.1.3 - 2026-03-19
52
+
35
53
  - Republish the current CLI release line after the successful `0.1.2` npm publish, keeping the internal and OSS package versions aligned.
36
54
 
37
55
  ## 0.1.2 - 2026-03-18
56
+
38
57
  - Add `search-journey-run` async CLI ergonomics: `--async` starts the backend async mode, polling is on by default, and `--no-poll` returns the initial `runId` immediately.
39
58
  - Add `--poll-interval <ms>` for async `search-journey-run` status polling.
40
59
  - Treat terminal async `search-journey-run` failures as non-zero CLI exits instead of silent `200` JSON output.
41
60
 
42
61
  ## 0.1.1 - 2026-03-13
62
+
43
63
  - Document public base URL and CLI error output.
44
64
  - Add request timeouts, verbose error output, and OSS-safe tool discovery behavior.
45
65
  - Ship a stable built-in tool registry for `tools list` instead of the hard-disabled discovery message.
46
66
  - Fail fast on agent WebSocket `error` events and surface session/tool progress diagnostics in `--verbose` mode.
47
67
 
48
68
  ## 0.1.0
69
+
49
70
  - Initial CLI with agent streaming, tools calls, and provisional data exports.
package/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # SocialSeal CLI
2
2
 
3
3
  ## Setup
4
+
4
5
  - Requires Node 18+
5
6
  - Global install: `npm install -g @socialseal/cli`
6
7
  - Dev install: `npm install`
7
8
 
8
9
  ## Configuration
10
+
9
11
  Environment variables:
12
+
10
13
  - `SOCIALSEAL_API_KEY`
11
14
  - `SOCIALSEAL_API_BASE` (default `https://api.socialseal.co`)
12
15
  - `SOCIALSEAL_WORKSPACE_ID` (optional workspace override; takes precedence over config)
@@ -14,6 +17,7 @@ Environment variables:
14
17
  - `SOCIALSEAL_AGENT_IDLE_TIMEOUT_MS` (optional agent WebSocket inactivity timeout override; default 300000)
15
18
 
16
19
  Optional config file:
20
+
17
21
  - `~/.config/socialseal/config.json`
18
22
 
19
23
  ```json
@@ -27,6 +31,7 @@ Optional config file:
27
31
  ```
28
32
 
29
33
  ## Commands
34
+
30
35
  - Workspace discovery/defaults:
31
36
  - `socialseal workspace list`
32
37
  - `socialseal workspace current`
@@ -68,10 +73,17 @@ Optional config file:
68
73
  - `socialseal data export-report --report-type keyword_universe --format csv --payload @payload.json --out out.csv`
69
74
 
70
75
  ## Notes
76
+
71
77
  - `export-report`, `export_tracking_data`, and `export-data`-backed exports are provisional until CLI export specs are finalized.
72
78
  - `tools list` ships a stable built-in registry of supported direct-call function targets. It is not live backend enumeration.
73
79
  - `tools schema --function <name>` prints static required/optional payload fields and example bodies for high-friction tools.
74
- - `data export-search-results` maps to `export-data` template `tracking_ranked_videos_raw` and returns enriched ranked-search rows (search fields + video metadata + latest metrics + analysis). It downloads the signed CSV artifact when available.
80
+ - `data export-search-results` maps to `export-data` template `tracking_ranked_videos_raw` and returns enriched ranked-search rows (search fields + video metadata + publish/observed dates + latest metrics + scoped tracked-search history + analysis). It downloads the signed CSV artifact when available.
81
+ - Timestamp meanings in ranked search exports:
82
+ - `search_timestamp`: when SocialSeal captured that ranked search row.
83
+ - `latest_metrics_ts`: when the latest exported engagement metrics snapshot was captured.
84
+ - `published_at`: platform publish/upload time when available from the video record; blank if unavailable.
85
+ - `observed_at`: when SocialSeal first observed/ingested the video record when available; blank if unavailable.
86
+ - `first_seen_at` / `last_seen_at`: earliest/latest `search_timestamp` for the video within the exported tracking-group/search-row scope. Use these for “resurfacing in tracked search” language, not platform-age claims when `published_at` is absent.
75
87
  - `data export-report --report-type search_results_enriched` is a compatibility alias to the same `export-data` template flow.
76
88
  - `data export-report` now validates report types locally and shows the allowed list immediately; run `socialseal data export-options` when choosing between export flows.
77
89
  - If an export returns metadata without a file URL (for example status `processing`), the CLI prints an explicit retry hint and returns the metadata JSON so automation can branch on status.
@@ -91,11 +103,13 @@ Optional config file:
91
103
  - 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.
92
104
 
93
105
  ## Errors and exit codes
106
+
94
107
  - Exit codes: `2` (usage), `3` (auth), `4` (not found), `5` (server), `1` (unknown)
95
108
  - Add `--json` to `tools call` or `data` commands to emit machine-readable errors.
96
109
  - Add `--verbose` to print error details plus agent session/tool progress diagnostics.
97
110
 
98
111
  ## Troubleshooting
112
+
99
113
  - `SUPABASE_ANON_KEY not configured`
100
114
  - This comes from the CLI gateway, not the local CLI install.
101
115
  - The deployed gateway is missing its `SUPABASE_ANON_KEY` secret, so `/cli/tools/*` cannot proxy to Supabase Edge Functions.
@@ -106,8 +120,10 @@ Optional config file:
106
120
  - If this reproduces from a supported Google AI region, treat it as an infrastructure/runtime issue. Practical workarounds are to run the agent from a worker placement/egress region that Google accepts, or switch the agent runtime to Vertex AI for server-side calls.
107
121
 
108
122
  ## Smoke Test (manual)
123
+
109
124
  1. `SOCIALSEAL_API_KEY=... socialseal agent run --message "ping"`
110
125
  2. `SOCIALSEAL_API_KEY=... socialseal tools call --function <tool> --body @payload.json`
111
126
 
112
127
  ## Maintainers
128
+
113
129
  - The public CLI base (`api.socialseal.co`) must route to the CLI gateway service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socialseal/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SocialSeal CLI (non-interactive)",
@@ -13,7 +13,7 @@
13
13
  "license": "Apache-2.0",
14
14
  "repository": {
15
15
  "type": "git",
16
- "url": "https://github.com/OpenSealAI/socialseal-cli.git"
16
+ "url": "git+https://github.com/OpenSealAI/socialseal-cli.git"
17
17
  },
18
18
  "bugs": {
19
19
  "url": "https://github.com/OpenSealAI/socialseal-cli/issues"
package/src/index.js CHANGED
@@ -33,6 +33,8 @@ const ACTIVE_STATUS_VALUES = new Set(['queued', 'pending', 'processing', 'in_pro
33
33
  const TOOL_STATUS_KINDS = new Set(['auto', 'agent_job', 'google_ai_run', 'journey_run']);
34
34
  const REPORT_TYPE_SEARCH_RESULTS_ENRICHED = 'search_results_enriched';
35
35
  const EXPORT_DATA_TEMPLATE_TRACKING_RANKED_VIDEOS_RAW = 'tracking_ranked_videos_raw';
36
+ const EXPORT_DATA_TEMPLATE_GOOGLE_AI_SEARCH_SUMMARIES_RAW = 'google_ai_search_summaries_raw';
37
+ const PLATFORM_ID_GOOGLE_AI = 11;
36
38
  const SUPPORTED_EXPORT_REPORT_TYPES = [
37
39
  'keyword_universe',
38
40
  'cluster_insights',
@@ -53,12 +55,20 @@ const EXPORT_OPTIONS = [
53
55
  {
54
56
  id: 'search_results_enriched',
55
57
  command: 'socialseal data export-search-results --group-ids <id,id,...>',
56
- summary: 'Enriched ranked search rows (search results + video + latest metrics + analysis).',
58
+ summary: 'Enriched ranked search rows (search capture + video publish/observed dates + latest metrics + scoped resurfacing history + analysis).',
57
59
  formats: ['csv'],
58
60
  required: ['workspace id', '--group-ids'],
59
- bestFor: 'SQL-like ranked-search datasets without using psql.',
61
+ bestFor: 'SQL-like ranked-search datasets and deck evidence that must distinguish capture, metrics, publish, and tracked-search resurfacing timestamps.',
60
62
  alias: 'socialseal data export-report --report-type search_results_enriched --format csv --payload @payload.json',
61
63
  },
64
+ {
65
+ id: 'group_evidence',
66
+ command: 'socialseal data export-group-evidence --group-id <id> --workspace-id <uuid>',
67
+ summary: 'Unified group evidence export that routes social groups and Google AI groups to the right raw export template.',
68
+ formats: ['csv'],
69
+ required: ['workspace id', '--group-id'],
70
+ bestFor: 'Automation that needs usable evidence without knowing whether the group is social ranked search or Google AI.',
71
+ },
62
72
  {
63
73
  id: 'report_templates',
64
74
  command: 'socialseal data export-report --report-type <type> --format <format> --payload @payload.json',
@@ -114,7 +124,7 @@ const KNOWN_TOOLS = [
114
124
  transport: 'post_edge_function',
115
125
  workspaceScoped: true,
116
126
  knownLocalDevState: 'disabled_by_default',
117
- notes: 'Includes template `tracking_ranked_videos_raw` for ranked search results with video + metrics + analysis enrichment.',
127
+ notes: 'Includes template `tracking_ranked_videos_raw` for ranked search results with video publish/observed dates, latest metrics, scoped first/last seen, and analysis enrichment.',
118
128
  },
119
129
  {
120
130
  name: 'export_tracking_data',
@@ -180,7 +190,7 @@ const KNOWN_TOOLS = [
180
190
  workspaceScoped: true,
181
191
  knownLocalDevState: 'disabled_by_default',
182
192
  actionAliases: ['list', 'get', 'create', 'update', 'delete', 'refresh', 'list_items', 'add_item', 'group_add_item', 'add_items', 'group_add_items', 'remove_item', 'group_remove_item'],
183
- 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.',
193
+ 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. `completeness` checks expected memberships and refresh queue visibility. Always pass a workspace id or configure a default workspace so the backend does not fall back to the personal workspace.',
184
194
  },
185
195
  {
186
196
  name: 'tracking',
@@ -366,6 +376,68 @@ const TOOL_SCHEMA_HINTS = {
366
376
  'socialseal tools call --function get-google-ai-search-results --body \'{"runId":6809,"includeCitations":true,"limit":10}\'',
367
377
  ],
368
378
  },
379
+ 'group-management': {
380
+ summary: 'Manage single-platform tracking groups and memberships.',
381
+ operations: [
382
+ {
383
+ action: 'create',
384
+ required: ['action=create', 'name', 'workspaceId or --workspace-id'],
385
+ optional: [
386
+ 'platform (defaults to tiktok)',
387
+ 'description',
388
+ 'refresh_frequency',
389
+ 'next_refresh_at',
390
+ 'brand_id',
391
+ ],
392
+ notes: 'Supported platform values: tiktok, instagram, youtube, ig_reels, yt_shorts, douyin, xhs, google_ai.',
393
+ example: {
394
+ action: 'create',
395
+ name: 'YouTube competitor searches',
396
+ platform: 'youtube',
397
+ },
398
+ },
399
+ {
400
+ action: 'add_items',
401
+ required: ['action=add_items', 'group_id', 'workspaceId or --workspace-id'],
402
+ optional: ['item_ids', 'items', 'platform/groupPlatform for item payload defaults'],
403
+ notes: 'When adding item payloads, omit item platform to inherit the group platform, or pass platform explicitly.',
404
+ example: {
405
+ action: 'add_items',
406
+ group_id: 123,
407
+ items: [
408
+ {
409
+ name: 'best kenya safari',
410
+ type: 'keyword',
411
+ value: 'best kenya safari',
412
+ region: 'US',
413
+ },
414
+ ],
415
+ },
416
+ },
417
+ {
418
+ action: 'completeness',
419
+ required: ['action=completeness', 'group_id', 'items or expected_items', 'workspaceId or --workspace-id'],
420
+ optional: ['include_refresh_status'],
421
+ notes: 'Returns durable setup completeness and aggregate refresh queue status for the group.',
422
+ example: {
423
+ action: 'completeness',
424
+ group_id: 123,
425
+ expected_items: [
426
+ {
427
+ track_type: 'search',
428
+ track_value: 'best kenya safari',
429
+ region: 'US',
430
+ },
431
+ ],
432
+ },
433
+ },
434
+ ],
435
+ cliExamples: [
436
+ 'socialseal tools call --function group-management --workspace-id <workspace-uuid> --body \'{"action":"create","name":"YouTube group","platform":"youtube"}\'',
437
+ 'socialseal tools call --function group-management --workspace-id <workspace-uuid> --body \'{"action":"create","name":"Instagram group","platform":"instagram"}\'',
438
+ 'socialseal tools call --function group-management --workspace-id <workspace-uuid> --body \'{"action":"add_items","group_id":123,"items":[{"name":"best kenya safari","type":"keyword","value":"best kenya safari","region":"US"}]}\'',
439
+ ],
440
+ },
369
441
  };
370
442
 
371
443
  function getToolSchemaHint(functionName) {
@@ -1006,6 +1078,11 @@ function isTerminalStatusValue(value) {
1006
1078
  return !ACTIVE_STATUS_VALUES.has(normalized);
1007
1079
  }
1008
1080
 
1081
+ function isFailedStatusValue(value) {
1082
+ const normalized = normalizeStatusValue(value);
1083
+ return normalized === 'failed' || normalized === 'error';
1084
+ }
1085
+
1009
1086
  function coercePositiveInteger(value, label) {
1010
1087
  if (value === undefined || value === null || value === '') return undefined;
1011
1088
  const parsed = Number(value);
@@ -1104,6 +1181,200 @@ function buildSearchResultsEnrichedExportPayload(rawPayload, workspaceId) {
1104
1181
  });
1105
1182
  }
1106
1183
 
1184
+ function buildGoogleAiSearchSummariesExportPayload(rawPayload, workspaceId) {
1185
+ const payload = isJsonObject(rawPayload) ? rawPayload : {};
1186
+ const groupId = coercePositiveInteger(firstDefined(payload, ['groupId', 'group_id']), 'groupId');
1187
+ if (!groupId) {
1188
+ throw new CliError('Google AI evidence export requires a group id.', {
1189
+ code: 'MISSING_ARGUMENT',
1190
+ exitCode: EXIT_CODES.USAGE,
1191
+ hint: 'Provide --group-id or groupId in the payload.',
1192
+ });
1193
+ }
1194
+
1195
+ const trackingItemIds = normalizePositiveIntegerList(
1196
+ firstDefined(payload, ['trackingItemIds', 'tracking_item_ids']),
1197
+ 'trackingItemIds',
1198
+ { max: 1000 },
1199
+ );
1200
+ const filename = trimString(firstDefined(payload, ['filename'])) || undefined;
1201
+
1202
+ return stripUndefinedEntries({
1203
+ workspaceId,
1204
+ groupId,
1205
+ trackingItemIds: trackingItemIds.length > 0 ? trackingItemIds : undefined,
1206
+ filename,
1207
+ });
1208
+ }
1209
+
1210
+ function normalizeEvidenceSurface(value) {
1211
+ const normalized = trimString(value || 'auto').toLowerCase().replace(/-/g, '_').replace(/\s+/g, '_');
1212
+ if (['auto', 'social', 'ranked', 'ranked_search', 'google_ai', 'google'].includes(normalized)) {
1213
+ if (normalized === 'ranked' || normalized === 'ranked_search') return 'social';
1214
+ if (normalized === 'google') return 'google_ai';
1215
+ return normalized;
1216
+ }
1217
+ throw new CliError(`Unsupported evidence surface: ${value}`, {
1218
+ code: 'INVALID_ARGUMENT',
1219
+ exitCode: EXIT_CODES.USAGE,
1220
+ hint: 'Use --surface auto|social|google_ai.',
1221
+ });
1222
+ }
1223
+
1224
+ function normalizePlatformKey(value) {
1225
+ const normalized = trimString(value).toLowerCase().replace(/-/g, '_').replace(/\s+/g, '_');
1226
+ if (!normalized) return null;
1227
+ if (['google', 'google_ai', 'google_ai_overview', 'ai_search'].includes(normalized)) return 'google_ai';
1228
+ return normalized;
1229
+ }
1230
+
1231
+ function resolveEvidenceSurfaceFromGroup(group) {
1232
+ if (!isJsonObject(group)) return 'social';
1233
+ const platformId = firstDefined(group, ['platform_id', 'platformId']);
1234
+ if (Number(platformId) === PLATFORM_ID_GOOGLE_AI) return 'google_ai';
1235
+ const platform = normalizePlatformKey(firstDefined(group, ['platform', 'platform_key', 'platformKey']));
1236
+ return platform === 'google_ai' ? 'google_ai' : 'social';
1237
+ }
1238
+
1239
+ function buildGroupEvidenceMetadata({ surface, groupId, workspaceId, responseJson }) {
1240
+ const metadata = isJsonObject(responseJson) && isJsonObject(responseJson.metadata)
1241
+ ? responseJson.metadata
1242
+ : {};
1243
+ const rowCount = Number.isFinite(metadata.row_count) ? metadata.row_count : null;
1244
+ return {
1245
+ group_id: groupId,
1246
+ workspace_id: workspaceId,
1247
+ platform: surface === 'google_ai' ? 'google_ai' : 'social',
1248
+ surface,
1249
+ row_count: rowCount,
1250
+ generated_at: new Date().toISOString(),
1251
+ header_only: rowCount === 0,
1252
+ template: metadata.template ?? (surface === 'google_ai'
1253
+ ? EXPORT_DATA_TEMPLATE_GOOGLE_AI_SEARCH_SUMMARIES_RAW
1254
+ : EXPORT_DATA_TEMPLATE_TRACKING_RANKED_VIDEOS_RAW),
1255
+ };
1256
+ }
1257
+
1258
+ function normalizeCompletenessPlatform(rawItem) {
1259
+ const platformId = firstDefined(rawItem, ['platform_id', 'platformId']);
1260
+ if (Number.isFinite(Number(platformId))) return `id:${Number(platformId)}`;
1261
+ const platform = normalizePlatformKey(firstDefined(rawItem, ['platform', 'platform_key', 'platformKey']));
1262
+ return platform ? `key:${platform}` : '';
1263
+ }
1264
+
1265
+ function normalizeCompletenessItem(rawItem, index, source) {
1266
+ if (!isJsonObject(rawItem)) {
1267
+ throw new CliError(`Invalid ${source} item at index ${index}: expected an object.`, {
1268
+ code: 'INVALID_MANIFEST',
1269
+ exitCode: EXIT_CODES.USAGE,
1270
+ });
1271
+ }
1272
+
1273
+ const id = coercePositiveInteger(firstDefined(rawItem, ['item_id', 'itemId', 'id']), `${source}[${index}].item_id`);
1274
+ const rawValue = firstDefined(rawItem, ['track_value', 'trackValue', 'value', 'name']);
1275
+ const value = trimString(rawValue).toLowerCase();
1276
+ const rawType = firstDefined(rawItem, ['track_type', 'trackType', 'type']);
1277
+ const type = normalizeTrackingType(rawType) || trimString(rawType).toLowerCase() || '';
1278
+ const region = trimString(firstDefined(rawItem, ['region'])).toUpperCase();
1279
+ const platform = normalizeCompletenessPlatform(rawItem);
1280
+ const hasSemanticKey = Boolean(value && type);
1281
+ const key = hasSemanticKey ? `${type}|${value}|${region}|${platform}` : `id:${id}`;
1282
+ const looseKey = hasSemanticKey ? `${type}|${value}|${region}` : `id:${id}`;
1283
+
1284
+ if (!id && (!value || !type)) {
1285
+ throw new CliError(`Invalid ${source} item at index ${index}: expected item_id or track_type + track_value.`, {
1286
+ code: 'INVALID_MANIFEST',
1287
+ exitCode: EXIT_CODES.USAGE,
1288
+ });
1289
+ }
1290
+
1291
+ return {
1292
+ key,
1293
+ looseKey,
1294
+ item_id: id ?? null,
1295
+ track_type: type || null,
1296
+ track_value: value || null,
1297
+ region: region || null,
1298
+ platform: platform || null,
1299
+ raw: rawItem,
1300
+ };
1301
+ }
1302
+
1303
+ function extractExpectedManifestItems(manifest) {
1304
+ if (Array.isArray(manifest)) return manifest;
1305
+ if (!isJsonObject(manifest)) {
1306
+ throw new CliError('Invalid completeness manifest: expected an array or object with items.', {
1307
+ code: 'INVALID_MANIFEST',
1308
+ exitCode: EXIT_CODES.USAGE,
1309
+ });
1310
+ }
1311
+ const items = firstDefined(manifest, ['items', 'expectedItems', 'expected_items']);
1312
+ if (!Array.isArray(items)) {
1313
+ throw new CliError('Invalid completeness manifest: expected items, expectedItems, or expected_items array.', {
1314
+ code: 'INVALID_MANIFEST',
1315
+ exitCode: EXIT_CODES.USAGE,
1316
+ });
1317
+ }
1318
+ return items;
1319
+ }
1320
+
1321
+ function normalizeActualGroupItem(rawItem, index) {
1322
+ const tracking = isJsonObject(rawItem?.user_tracking) ? rawItem.user_tracking : rawItem;
1323
+ return normalizeCompletenessItem(tracking, index, 'actual_items');
1324
+ }
1325
+
1326
+ function buildCompletenessDiff({ expectedItems, actualItems, groupId, workspaceId }) {
1327
+ const expected = expectedItems.map((item, index) =>
1328
+ normalizeCompletenessItem(item, index, 'expected_items')
1329
+ );
1330
+ const actual = actualItems.map((item, index) => normalizeActualGroupItem(item, index));
1331
+ const actualByKey = new Map(actual.map((item) => [item.key, item]));
1332
+ const expectedByKey = new Map(expected.map((item) => [item.key, item]));
1333
+ const actualByLooseKey = new Map(actual.map((item) => [item.looseKey, item]));
1334
+ const expectedByLooseKey = new Map(expected.map((item) => [item.looseKey, item]));
1335
+
1336
+ const missing = [];
1337
+ const platformMismatches = [];
1338
+ for (const expectedItem of expected) {
1339
+ if (actualByKey.has(expectedItem.key)) continue;
1340
+ const looseMatch = actualByLooseKey.get(expectedItem.looseKey);
1341
+ if (looseMatch && expectedItem.platform !== looseMatch.platform) {
1342
+ platformMismatches.push({
1343
+ expected: expectedItem.raw,
1344
+ actual: looseMatch.raw,
1345
+ expected_platform: expectedItem.platform,
1346
+ actual_platform: looseMatch.platform,
1347
+ });
1348
+ continue;
1349
+ }
1350
+ missing.push(expectedItem.raw);
1351
+ }
1352
+
1353
+ const extra = [];
1354
+ for (const actualItem of actual) {
1355
+ if (expectedByKey.has(actualItem.key)) continue;
1356
+ const looseMatch = expectedByLooseKey.get(actualItem.looseKey);
1357
+ if (looseMatch && looseMatch.platform !== actualItem.platform) continue;
1358
+ extra.push(actualItem.raw);
1359
+ }
1360
+
1361
+ const complete = missing.length === 0 && extra.length === 0 && platformMismatches.length === 0;
1362
+ return {
1363
+ success: complete,
1364
+ complete,
1365
+ group_id: groupId,
1366
+ workspace_id: workspaceId,
1367
+ expected_count: expected.length,
1368
+ actual_count: actual.length,
1369
+ missing_count: missing.length,
1370
+ extra_count: extra.length,
1371
+ platform_mismatch_count: platformMismatches.length,
1372
+ missing,
1373
+ extra,
1374
+ platform_mismatches: platformMismatches,
1375
+ };
1376
+ }
1377
+
1107
1378
  function buildPathWithQuery(basePath, query) {
1108
1379
  const params = new URLSearchParams();
1109
1380
  for (const [key, rawValue] of Object.entries(query || {})) {
@@ -1174,8 +1445,10 @@ function normalizeGroupManagementPayload(payload, fallbackWorkspaceId) {
1174
1445
  const itemId = firstDefined(payload, ['item_id', 'itemId']);
1175
1446
  const itemIds = firstDefined(payload, ['item_ids', 'itemIds']);
1176
1447
  const items = firstDefined(payload, ['items']);
1448
+ const expectedItems = firstDefined(payload, ['expected_items', 'expectedItems']);
1177
1449
  const limit = firstDefined(payload, ['limit']);
1178
1450
  const page = firstDefined(payload, ['page']);
1451
+ const force = firstDefined(payload, ['force']);
1179
1452
  return stripUndefinedEntries({
1180
1453
  action: trimString(firstDefined(payload, ['action'])) || undefined,
1181
1454
  workspaceId: resolvePayloadWorkspaceId(payload, fallbackWorkspaceId),
@@ -1194,6 +1467,7 @@ function normalizeGroupManagementPayload(payload, fallbackWorkspaceId) {
1194
1467
  })
1195
1468
  : undefined,
1196
1469
  items: Array.isArray(items) ? items : undefined,
1470
+ expected_items: Array.isArray(expectedItems) ? expectedItems : undefined,
1197
1471
  name: trimString(firstDefined(payload, ['name'])) || undefined,
1198
1472
  description: firstDefined(payload, ['description']),
1199
1473
  platform: trimString(firstDefined(payload, ['platform', 'groupPlatform'])) || undefined,
@@ -1207,6 +1481,7 @@ function normalizeGroupManagementPayload(payload, fallbackWorkspaceId) {
1207
1481
  : firstDefined(payload, ['region']),
1208
1482
  limit: limit !== undefined ? Number(limit) : undefined,
1209
1483
  page: page !== undefined ? Number(page) : undefined,
1484
+ force: force === true || trimString(force).toLowerCase() === 'true' ? true : undefined,
1210
1485
  });
1211
1486
  }
1212
1487
 
@@ -1696,6 +1971,28 @@ function translateGroupManagementAction(payload, workspaceId, originalMethod) {
1696
1971
  };
1697
1972
  }
1698
1973
 
1974
+ if (action === 'completeness' || action === 'group_completeness') {
1975
+ const groupId = coercePositiveInteger(payload.group_id, 'group_id');
1976
+ if (!groupId) {
1977
+ throw new CliError('group_id is required for group completeness.', {
1978
+ code: 'MISSING_ARGUMENT',
1979
+ exitCode: EXIT_CODES.USAGE,
1980
+ });
1981
+ }
1982
+ const expectedItems = Array.isArray(payload.expected_items)
1983
+ ? payload.expected_items
1984
+ : (Array.isArray(payload.items) ? payload.items : undefined);
1985
+ return {
1986
+ method: 'POST',
1987
+ pathSuffix: buildPathWithQuery(`/groups/${groupId}/completeness`, { workspace_id: workspaceId || undefined }),
1988
+ body: stripUndefinedEntries({
1989
+ expected_items: expectedItems,
1990
+ include_refresh_status: firstDefined(payload, ['include_refresh_status', 'includeRefreshStatus']),
1991
+ }),
1992
+ workspaceId,
1993
+ };
1994
+ }
1995
+
1699
1996
  if (action === 'remove_item' || action === 'group_remove_item') {
1700
1997
  const groupId = coercePositiveInteger(payload.group_id, 'group_id');
1701
1998
  const itemId = coercePositiveInteger(payload.item_id, 'item_id');
@@ -1716,7 +2013,7 @@ function translateGroupManagementAction(payload, workspaceId, originalMethod) {
1716
2013
  throw new CliError(`Unsupported group-management action: ${payload.action}`, {
1717
2014
  code: 'INVALID_ARGUMENT',
1718
2015
  exitCode: EXIT_CODES.USAGE,
1719
- hint: 'Supported group-management actions: list, get, create, update, delete, refresh, list_items, add_item, add_items, remove_item.',
2016
+ hint: 'Supported group-management actions: list, get, create, update, delete, refresh, list_items, add_item, add_items, completeness, remove_item.',
1720
2017
  });
1721
2018
  }
1722
2019
 
@@ -2005,6 +2302,145 @@ function emitJsonOutput(value, pretty) {
2005
2302
  process.stdout.write(formatJsonOutput(value, pretty) + '\n');
2006
2303
  }
2007
2304
 
2305
+ function isGroupManagementBulkAddInvocation(functionName, translated) {
2306
+ if (functionName !== 'group-management') return false;
2307
+ const action = isJsonObject(translated.normalizedPayload)
2308
+ ? trimString(translated.normalizedPayload.action).toLowerCase()
2309
+ : '';
2310
+ return action === 'add_items' || action === 'group_add_items';
2311
+ }
2312
+
2313
+ function getBulkAddExpectedCount(translated) {
2314
+ return Array.isArray(translated.body) ? translated.body.length : null;
2315
+ }
2316
+
2317
+ function maybeThrowGroupManagementBulkAddPartialFailure(data, translated) {
2318
+ if (!isJsonObject(data)) return;
2319
+
2320
+ const errors = Array.isArray(data.errors) ? data.errors : [];
2321
+ const expected = getBulkAddExpectedCount(translated);
2322
+ const processed = Number.isFinite(data.items_processed) ? data.items_processed : null;
2323
+ const added = Number.isFinite(data.items_added) ? data.items_added : 0;
2324
+ const linked = Number.isFinite(data.items_linked) ? data.items_linked : 0;
2325
+ const alreadyLinked = Number.isFinite(data.items_already_linked) ? data.items_already_linked : 0;
2326
+ const successful = added + linked + alreadyLinked;
2327
+ const failed = errors.length;
2328
+ const hasErrors = failed > 0;
2329
+ const hasProcessedMismatch = expected !== null && processed !== null && processed !== expected;
2330
+ const hasSuccessMismatch = expected !== null && successful !== expected;
2331
+ const hasVerificationFailure = data.verified === false;
2332
+
2333
+ if (!hasErrors && !hasProcessedMismatch && !hasSuccessMismatch && !hasVerificationFailure) return;
2334
+
2335
+ throw new CliError('group-management add_items partially failed.', {
2336
+ code: 'PARTIAL_FAILURE',
2337
+ exitCode: EXIT_CODES.SERVER,
2338
+ hint: 'Inspect errors[] and re-run add_items after fixing failed items; automation should treat this as an unsuccessful setup.',
2339
+ details: truncateDetails({
2340
+ expected,
2341
+ processed,
2342
+ added,
2343
+ linked,
2344
+ alreadyLinked,
2345
+ successful,
2346
+ failed,
2347
+ verified: data.verified,
2348
+ finalGroupItemCount: data.final_group_item_count,
2349
+ expectedFinalGroupItemCount: data.expected_final_group_item_count,
2350
+ errors,
2351
+ response: data,
2352
+ }),
2353
+ });
2354
+ }
2355
+
2356
+ function getGroupManagementAction(translated) {
2357
+ return isJsonObject(translated.normalizedPayload)
2358
+ ? trimString(translated.normalizedPayload.action).toLowerCase()
2359
+ : '';
2360
+ }
2361
+
2362
+ function isGroupManagementRefreshInvocation(functionName, translated) {
2363
+ if (functionName !== 'group-management') return false;
2364
+ const action = getGroupManagementAction(translated);
2365
+ return action === 'refresh' || action === 'group_refresh';
2366
+ }
2367
+
2368
+ function getGroupManagementGroupId(translated) {
2369
+ return isJsonObject(translated.normalizedPayload)
2370
+ ? coercePositiveInteger(translated.normalizedPayload.group_id, 'group_id')
2371
+ : undefined;
2372
+ }
2373
+
2374
+ function getGroupItemsCount(data) {
2375
+ if (Array.isArray(data)) return data.length;
2376
+ if (!isJsonObject(data)) return null;
2377
+ if (Number.isFinite(data.total)) return data.total;
2378
+ if (Array.isArray(data.items)) return data.items.length;
2379
+ if (Array.isArray(data.data)) return data.data.length;
2380
+ return null;
2381
+ }
2382
+
2383
+ async function preflightGroupRefreshNotEmpty({
2384
+ opts,
2385
+ translated,
2386
+ apiBase,
2387
+ apiKey,
2388
+ pathPrefix,
2389
+ workspaceId,
2390
+ timeoutMs,
2391
+ }) {
2392
+ if (!isGroupManagementRefreshInvocation(opts.function, translated)) return;
2393
+ if (translated.normalizedPayload?.force === true) return;
2394
+
2395
+ const groupId = getGroupManagementGroupId(translated);
2396
+ if (!groupId) return;
2397
+
2398
+ const res = await callApi({
2399
+ apiBase,
2400
+ apiKey,
2401
+ path: `${pathPrefix}/groups/${groupId}/items?${new URLSearchParams({
2402
+ workspace_id: workspaceId,
2403
+ page: '1',
2404
+ limit: '1',
2405
+ }).toString()}`,
2406
+ method: 'GET',
2407
+ workspaceId,
2408
+ timeoutMs,
2409
+ });
2410
+
2411
+ if (!res.ok) {
2412
+ throw await buildHttpError(res, {
2413
+ label: 'Group refresh preflight',
2414
+ functionName: 'group-management',
2415
+ method: 'GET',
2416
+ });
2417
+ }
2418
+
2419
+ const contentType = res.headers.get('content-type') || '';
2420
+ if (!contentType.includes('application/json')) {
2421
+ throw new CliError('Group refresh preflight returned a non-JSON response.', {
2422
+ code: 'INVALID_RESPONSE',
2423
+ exitCode: EXIT_CODES.SERVER,
2424
+ });
2425
+ }
2426
+
2427
+ const data = await res.json();
2428
+ const itemCount = getGroupItemsCount(data);
2429
+ if (itemCount === 0) {
2430
+ throw new CliError('Refusing to refresh an empty tracking group.', {
2431
+ code: 'EMPTY_GROUP_REFRESH',
2432
+ exitCode: EXIT_CODES.USAGE,
2433
+ hint: 'Add items to the group first, or pass force:true in the group-management payload to override.',
2434
+ details: truncateDetails({
2435
+ groupId,
2436
+ workspaceId,
2437
+ itemCount,
2438
+ response: data,
2439
+ }),
2440
+ });
2441
+ }
2442
+ }
2443
+
2008
2444
  function buildSearchJourneyRunFailure(data) {
2009
2445
  const message = isJsonObject(data) && typeof data.error === 'string' && data.error.trim().length > 0
2010
2446
  ? data.error
@@ -3188,6 +3624,18 @@ async function handleToolsCall(opts) {
3188
3624
  method,
3189
3625
  });
3190
3626
 
3627
+ if (isGroupManagementRefreshInvocation(opts.function, translated)) {
3628
+ await preflightGroupRefreshNotEmpty({
3629
+ opts,
3630
+ translated,
3631
+ apiBase: useGateway ? resolvedApiBase : legacyUrl,
3632
+ apiKey,
3633
+ pathPrefix: useGateway ? '/cli/tools/group-management' : '/functions/v1/group-management',
3634
+ workspaceId: effectiveWorkspaceId,
3635
+ timeoutMs,
3636
+ });
3637
+ }
3638
+
3191
3639
  const res = await callApi({
3192
3640
  apiBase: useGateway ? resolvedApiBase : legacyUrl,
3193
3641
  apiKey,
@@ -3211,6 +3659,9 @@ async function handleToolsCall(opts) {
3211
3659
  const data = await res.json();
3212
3660
  const shouldPoll = shouldHandleSearchJourneyRunAsync(opts.function, method, payload, opts) && opts.poll !== false;
3213
3661
  if (!shouldPoll) {
3662
+ if (isGroupManagementBulkAddInvocation(opts.function, translated)) {
3663
+ maybeThrowGroupManagementBulkAddPartialFailure(data, translated);
3664
+ }
3214
3665
  maybeEmitFollowupStatusHint({
3215
3666
  functionName: opts.function,
3216
3667
  data,
@@ -3433,6 +3884,14 @@ async function handleToolsStatus(opts) {
3433
3884
  ...result,
3434
3885
  hint: commandHint,
3435
3886
  };
3887
+ if (opts.wait && isFailedStatusValue(result.status)) {
3888
+ throw new CliError(`${result.kind} reached terminal ${result.status} status.`, {
3889
+ code: 'STATUS_FAILED',
3890
+ exitCode: EXIT_CODES.SERVER,
3891
+ hint: commandHint,
3892
+ details: truncateDetails(payload),
3893
+ });
3894
+ }
3436
3895
  emitJsonOutput(payload, opts.pretty);
3437
3896
  }
3438
3897
 
@@ -3586,46 +4045,27 @@ async function handleDataExportReport(opts) {
3586
4045
  process.stdout.write(JSON.stringify(json, null, opts.pretty ? 2 : 0) + '\n');
3587
4046
  }
3588
4047
 
3589
- async function handleDataExportSearchResults(opts) {
3590
- const config = loadConfig();
3591
- const apiKey = requireApiKey(opts, config);
3592
- const apiBase = resolveApiBase(opts, config);
3593
- const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
3594
- const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
3595
- const timeoutMs = resolveTimeoutMs(opts, config);
3596
- const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
3597
-
3598
- const rawPayload = opts.__rawPayload ?? stripUndefinedEntries({
3599
- groupIds: normalizePositiveIntegerList(opts.groupIds, 'groupIds', { max: 100 }),
3600
- trackingItemIds: normalizePositiveIntegerList(opts.trackingItemIds, 'trackingItemIds', { max: 1000 }),
3601
- dateFrom: opts.dateFrom,
3602
- dateTo: opts.dateTo,
3603
- filename: opts.filename,
3604
- });
3605
-
3606
- const payloadWorkspaceId = resolvePayloadWorkspaceId(rawPayload, null);
3607
- const effectiveWorkspaceId = requireWorkspaceSelection(payloadWorkspaceId || resolvedWorkspaceId, {
3608
- label: 'Search results enriched export',
3609
- hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace before exporting.',
3610
- });
3611
- const effectiveWorkspaceSource = payloadWorkspaceId ? 'body' : workspaceSource;
3612
- emitWorkspaceSelectionNotice(opts, {
3613
- workspaceId: effectiveWorkspaceId,
3614
- source: effectiveWorkspaceSource,
3615
- label: 'search_results_enriched export',
3616
- });
3617
-
3618
- const normalizedPayload = buildSearchResultsEnrichedExportPayload(rawPayload, effectiveWorkspaceId);
3619
- const requestedFilename = trimString(normalizedPayload.filename) || undefined;
3620
- delete normalizedPayload.filename;
3621
-
4048
+ async function runExportDataCsv({
4049
+ opts,
4050
+ label,
4051
+ template,
4052
+ normalizedPayload,
4053
+ requestedFilename,
4054
+ effectiveWorkspaceId,
4055
+ apiBase,
4056
+ apiKey,
4057
+ useGateway,
4058
+ legacyUrl,
4059
+ timeoutMs,
4060
+ decorateResponse,
4061
+ }) {
3622
4062
  const exportResponse = await callApi({
3623
- apiBase: useGateway ? resolvedApiBase : legacyUrl,
4063
+ apiBase: useGateway ? apiBase : legacyUrl,
3624
4064
  apiKey,
3625
4065
  path: useGateway ? '/cli/tools/export-data' : '/functions/v1/export-data',
3626
4066
  method: 'POST',
3627
4067
  body: {
3628
- template: EXPORT_DATA_TEMPLATE_TRACKING_RANKED_VIDEOS_RAW,
4068
+ template,
3629
4069
  format: 'csv',
3630
4070
  payload: normalizedPayload,
3631
4071
  filename: requestedFilename,
@@ -3636,13 +4076,16 @@ async function handleDataExportSearchResults(opts) {
3636
4076
 
3637
4077
  if (!exportResponse.ok) {
3638
4078
  throw await buildHttpError(exportResponse, {
3639
- label: 'Search results enriched export',
4079
+ label,
3640
4080
  functionName: 'export-data',
3641
4081
  method: 'POST',
3642
4082
  });
3643
4083
  }
3644
4084
 
3645
4085
  const responseJson = await exportResponse.json();
4086
+ const decoratedResponse = typeof decorateResponse === 'function'
4087
+ ? decorateResponse(responseJson)
4088
+ : responseJson;
3646
4089
  const metadata = isJsonObject(responseJson) && isJsonObject(responseJson.metadata)
3647
4090
  ? responseJson.metadata
3648
4091
  : null;
@@ -3657,7 +4100,7 @@ async function handleDataExportSearchResults(opts) {
3657
4100
  } else {
3658
4101
  process.stderr.write('[socialseal] Export did not include a file URL yet. Inspect the JSON metadata and retry if needed.\n');
3659
4102
  }
3660
- emitJsonOutput(responseJson, opts.pretty);
4103
+ emitJsonOutput(decoratedResponse, opts.pretty);
3661
4104
  return;
3662
4105
  }
3663
4106
 
@@ -3668,7 +4111,7 @@ async function handleDataExportSearchResults(opts) {
3668
4111
 
3669
4112
  if (!artifactResponse.ok) {
3670
4113
  throw await buildHttpError(artifactResponse, {
3671
- label: 'Search results enriched artifact download',
4114
+ label: `${label} artifact download`,
3672
4115
  method: 'GET',
3673
4116
  hint: 'The signed file URL may be expired or inaccessible. Re-run the export command to mint a fresh URL.',
3674
4117
  });
@@ -3683,7 +4126,7 @@ async function handleDataExportSearchResults(opts) {
3683
4126
 
3684
4127
  const outPath = opts.stdout
3685
4128
  ? null
3686
- : (opts.out || trimString(metadata?.filename || '') || 'tracking-ranked-videos.csv');
4129
+ : (opts.out || trimString(metadata?.filename || '') || `${template}.csv`);
3687
4130
  if (outPath) {
3688
4131
  await pipeline(artifactResponse.body, fs.createWriteStream(outPath));
3689
4132
  process.stderr.write(`[socialseal] Export written to ${outPath}\n`);
@@ -3692,6 +4135,261 @@ async function handleDataExportSearchResults(opts) {
3692
4135
  }
3693
4136
  }
3694
4137
 
4138
+ async function handleDataExportSearchResults(opts) {
4139
+ const config = loadConfig();
4140
+ const apiKey = requireApiKey(opts, config);
4141
+ const apiBase = resolveApiBase(opts, config);
4142
+ const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
4143
+ const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
4144
+ const timeoutMs = resolveTimeoutMs(opts, config);
4145
+ const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
4146
+
4147
+ const rawPayload = opts.__rawPayload ?? stripUndefinedEntries({
4148
+ groupIds: normalizePositiveIntegerList(opts.groupIds, 'groupIds', { max: 100 }),
4149
+ trackingItemIds: normalizePositiveIntegerList(opts.trackingItemIds, 'trackingItemIds', { max: 1000 }),
4150
+ dateFrom: opts.dateFrom,
4151
+ dateTo: opts.dateTo,
4152
+ filename: opts.filename,
4153
+ });
4154
+
4155
+ const payloadWorkspaceId = resolvePayloadWorkspaceId(rawPayload, null);
4156
+ const effectiveWorkspaceId = requireWorkspaceSelection(payloadWorkspaceId || resolvedWorkspaceId, {
4157
+ label: 'Search results enriched export',
4158
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace before exporting.',
4159
+ });
4160
+ const effectiveWorkspaceSource = payloadWorkspaceId ? 'body' : workspaceSource;
4161
+ emitWorkspaceSelectionNotice(opts, {
4162
+ workspaceId: effectiveWorkspaceId,
4163
+ source: effectiveWorkspaceSource,
4164
+ label: 'search_results_enriched export',
4165
+ });
4166
+
4167
+ const normalizedPayload = buildSearchResultsEnrichedExportPayload(rawPayload, effectiveWorkspaceId);
4168
+ const requestedFilename = trimString(normalizedPayload.filename) || undefined;
4169
+ delete normalizedPayload.filename;
4170
+
4171
+ await runExportDataCsv({
4172
+ opts,
4173
+ label: 'Search results enriched export',
4174
+ template: EXPORT_DATA_TEMPLATE_TRACKING_RANKED_VIDEOS_RAW,
4175
+ normalizedPayload,
4176
+ requestedFilename,
4177
+ effectiveWorkspaceId,
4178
+ apiBase: resolvedApiBase,
4179
+ apiKey,
4180
+ useGateway,
4181
+ legacyUrl,
4182
+ timeoutMs,
4183
+ });
4184
+ }
4185
+
4186
+ async function handleDataExportGroupEvidence(opts) {
4187
+ const config = loadConfig();
4188
+ const apiKey = requireApiKey(opts, config);
4189
+ const apiBase = resolveApiBase(opts, config);
4190
+ const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
4191
+ const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
4192
+ const timeoutMs = resolveTimeoutMs(opts, config);
4193
+ const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
4194
+
4195
+ const groupId = coercePositiveInteger(opts.groupId, 'group_id');
4196
+ const effectiveWorkspaceId = requireWorkspaceSelection(resolvedWorkspaceId, {
4197
+ label: 'Group evidence export',
4198
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace before exporting group evidence.',
4199
+ });
4200
+ emitWorkspaceSelectionNotice(opts, {
4201
+ workspaceId: effectiveWorkspaceId,
4202
+ source: workspaceSource,
4203
+ label: 'group evidence export',
4204
+ });
4205
+
4206
+ const requestedSurface = normalizeEvidenceSurface(opts.surface);
4207
+ let surface = requestedSurface;
4208
+ if (requestedSurface === 'auto') {
4209
+ const groupResponse = await callApi({
4210
+ apiBase: useGateway ? resolvedApiBase : legacyUrl,
4211
+ apiKey,
4212
+ path: useGateway
4213
+ ? `/cli/tools/group-management/groups/${groupId}?workspace_id=${encodeURIComponent(effectiveWorkspaceId)}`
4214
+ : `/functions/v1/group-management/groups/${groupId}?workspace_id=${encodeURIComponent(effectiveWorkspaceId)}`,
4215
+ method: 'GET',
4216
+ workspaceId: effectiveWorkspaceId,
4217
+ timeoutMs,
4218
+ });
4219
+
4220
+ if (!groupResponse.ok) {
4221
+ throw await buildHttpError(groupResponse, {
4222
+ label: 'Group evidence platform detection',
4223
+ functionName: 'group-management',
4224
+ method: 'GET',
4225
+ });
4226
+ }
4227
+
4228
+ const groupData = await groupResponse.json();
4229
+ surface = resolveEvidenceSurfaceFromGroup(groupData);
4230
+ }
4231
+
4232
+ const rawPayload = stripUndefinedEntries({
4233
+ groupId,
4234
+ groupIds: surface === 'social' ? [groupId] : undefined,
4235
+ trackingItemIds: normalizePositiveIntegerList(opts.trackingItemIds, 'trackingItemIds', { max: 1000 }),
4236
+ dateFrom: opts.dateFrom,
4237
+ dateTo: opts.dateTo,
4238
+ filename: opts.filename,
4239
+ });
4240
+ const normalizedPayload = surface === 'google_ai'
4241
+ ? buildGoogleAiSearchSummariesExportPayload(rawPayload, effectiveWorkspaceId)
4242
+ : buildSearchResultsEnrichedExportPayload(rawPayload, effectiveWorkspaceId);
4243
+ const requestedFilename = trimString(normalizedPayload.filename) || undefined;
4244
+ delete normalizedPayload.filename;
4245
+ const template = surface === 'google_ai'
4246
+ ? EXPORT_DATA_TEMPLATE_GOOGLE_AI_SEARCH_SUMMARIES_RAW
4247
+ : EXPORT_DATA_TEMPLATE_TRACKING_RANKED_VIDEOS_RAW;
4248
+
4249
+ await runExportDataCsv({
4250
+ opts,
4251
+ label: 'Group evidence export',
4252
+ template,
4253
+ normalizedPayload,
4254
+ requestedFilename,
4255
+ effectiveWorkspaceId,
4256
+ apiBase: resolvedApiBase,
4257
+ apiKey,
4258
+ useGateway,
4259
+ legacyUrl,
4260
+ timeoutMs,
4261
+ decorateResponse: (responseJson) => ({
4262
+ ...responseJson,
4263
+ evidence: buildGroupEvidenceMetadata({
4264
+ surface,
4265
+ groupId,
4266
+ workspaceId: effectiveWorkspaceId,
4267
+ responseJson,
4268
+ }),
4269
+ }),
4270
+ });
4271
+ }
4272
+
4273
+ async function handleDataGroupCompleteness(opts) {
4274
+ const config = loadConfig();
4275
+ const apiKey = requireApiKey(opts, config);
4276
+ const apiBase = resolveApiBase(opts, config);
4277
+ const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
4278
+ const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
4279
+ const timeoutMs = resolveTimeoutMs(opts, config);
4280
+ const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
4281
+
4282
+ const groupId = coercePositiveInteger(opts.groupId, 'group_id');
4283
+ const effectiveWorkspaceId = requireWorkspaceSelection(resolvedWorkspaceId, {
4284
+ label: 'Group completeness check',
4285
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace before checking group completeness.',
4286
+ });
4287
+ emitWorkspaceSelectionNotice(opts, {
4288
+ workspaceId: effectiveWorkspaceId,
4289
+ source: workspaceSource,
4290
+ label: 'group completeness check',
4291
+ });
4292
+
4293
+ const manifest = parseJsonInput(opts.manifest, { label: 'manifest' });
4294
+ const expectedItems = extractExpectedManifestItems(manifest);
4295
+ const backendPath = useGateway
4296
+ ? `/cli/tools/group-management/groups/${groupId}/completeness?${new URLSearchParams({
4297
+ workspace_id: effectiveWorkspaceId,
4298
+ }).toString()}`
4299
+ : `/functions/v1/group-management/groups/${groupId}/completeness?${new URLSearchParams({
4300
+ workspace_id: effectiveWorkspaceId,
4301
+ }).toString()}`;
4302
+
4303
+ const backendRes = await callApi({
4304
+ apiBase: useGateway ? resolvedApiBase : legacyUrl,
4305
+ apiKey,
4306
+ path: backendPath,
4307
+ method: 'POST',
4308
+ body: {
4309
+ expected_items: expectedItems,
4310
+ include_refresh_status: true,
4311
+ },
4312
+ workspaceId: effectiveWorkspaceId,
4313
+ timeoutMs,
4314
+ });
4315
+
4316
+ if (backendRes.ok) {
4317
+ const contentType = backendRes.headers.get('content-type') || '';
4318
+ if (!contentType.includes('application/json')) {
4319
+ throw new CliError('Group completeness check returned a non-JSON response.', {
4320
+ code: 'INVALID_RESPONSE',
4321
+ exitCode: EXIT_CODES.SERVER,
4322
+ });
4323
+ }
4324
+ const backendData = await backendRes.json();
4325
+ emitJsonOutput(backendData, opts.pretty);
4326
+ if (isJsonObject(backendData) && backendData.complete === false) {
4327
+ process.exitCode = EXIT_CODES.SERVER;
4328
+ }
4329
+ return;
4330
+ }
4331
+
4332
+ if (backendRes.status !== 404) {
4333
+ throw await buildHttpError(backendRes, {
4334
+ label: 'Group completeness check',
4335
+ functionName: 'group-management',
4336
+ method: 'POST',
4337
+ });
4338
+ }
4339
+
4340
+ const res = await callApi({
4341
+ apiBase: useGateway ? resolvedApiBase : legacyUrl,
4342
+ apiKey,
4343
+ path: useGateway
4344
+ ? `/cli/tools/group-management/groups/${groupId}/items?${new URLSearchParams({
4345
+ workspace_id: effectiveWorkspaceId,
4346
+ page: '1',
4347
+ limit: String(Math.max(expectedItems.length + 100, 1000)),
4348
+ }).toString()}`
4349
+ : `/functions/v1/group-management/groups/${groupId}/items?${new URLSearchParams({
4350
+ workspace_id: effectiveWorkspaceId,
4351
+ page: '1',
4352
+ limit: String(Math.max(expectedItems.length + 100, 1000)),
4353
+ }).toString()}`,
4354
+ method: 'GET',
4355
+ workspaceId: effectiveWorkspaceId,
4356
+ timeoutMs,
4357
+ });
4358
+
4359
+ if (!res.ok) {
4360
+ throw await buildHttpError(res, {
4361
+ label: 'Group completeness check',
4362
+ functionName: 'group-management',
4363
+ method: 'GET',
4364
+ });
4365
+ }
4366
+
4367
+ const contentType = res.headers.get('content-type') || '';
4368
+ if (!contentType.includes('application/json')) {
4369
+ throw new CliError('Group completeness check returned a non-JSON response.', {
4370
+ code: 'INVALID_RESPONSE',
4371
+ exitCode: EXIT_CODES.SERVER,
4372
+ });
4373
+ }
4374
+
4375
+ const data = await res.json();
4376
+ const actualItems = isJsonObject(data) && Array.isArray(data.items)
4377
+ ? data.items
4378
+ : (Array.isArray(data) ? data : []);
4379
+ const diff = buildCompletenessDiff({
4380
+ expectedItems,
4381
+ actualItems,
4382
+ groupId,
4383
+ workspaceId: effectiveWorkspaceId,
4384
+ });
4385
+ diff.source = 'manifest_fallback';
4386
+
4387
+ emitJsonOutput(diff, opts.pretty);
4388
+ if (!diff.complete) {
4389
+ process.exitCode = EXIT_CODES.SERVER;
4390
+ }
4391
+ }
4392
+
3695
4393
  function handleDataExportOptions(opts) {
3696
4394
  const payload = {
3697
4395
  exports: EXPORT_OPTIONS,
@@ -4053,7 +4751,7 @@ if (typeof program.showHelpAfterError === 'function') {
4053
4751
  if (typeof program.showSuggestionAfterError === 'function') {
4054
4752
  program.showSuggestionAfterError(true);
4055
4753
  }
4056
- 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 schema --function search-journey-run\n socialseal tools call --function <tool> --body @payload.json\n socialseal tools status 6809 --kind google_ai_run\n socialseal tools status <run-uuid> --kind journey_run --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-options\n socialseal data export-tracking --group-id 123 --time-period 30d\n socialseal data export-search-results --group-ids 123,124 --workspace-id <uuid> --out ranked.csv\n`);
4754
+ 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 schema --function search-journey-run\n socialseal tools call --function <tool> --body @payload.json\n socialseal tools status 6809 --kind google_ai_run\n socialseal tools status <run-uuid> --kind journey_run --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-options\n socialseal data export-tracking --group-id 123 --time-period 30d\n socialseal data export-search-results --group-ids 123,124 --workspace-id <uuid> --out ranked.csv\n socialseal data export-group-evidence --group-id 123 --workspace-id <uuid> --out evidence.csv\n`);
4057
4755
 
4058
4756
  program
4059
4757
  .command('agent')
@@ -4214,6 +4912,40 @@ data
4214
4912
  .option('--verbose', 'Show error details')
4215
4913
  .action((opts) => runCommand(handleDataExportSearchResults, opts));
4216
4914
 
4915
+ data
4916
+ .command('export-group-evidence')
4917
+ .description('Export usable group evidence, routing social groups and Google AI groups to the correct CSV export')
4918
+ .requiredOption('--group-id <id>', 'Tracking group id')
4919
+ .option('--surface <surface>', 'auto|social|google_ai', 'auto')
4920
+ .option('--tracking-item-ids <ids>', 'Optional comma-separated tracking item ids')
4921
+ .option('--date-from <iso>', 'Optional ISO datetime lower bound for social ranked exports')
4922
+ .option('--date-to <iso>', 'Optional ISO datetime upper bound for social ranked exports')
4923
+ .option('--filename <name>', 'Optional export filename stem (without extension)')
4924
+ .option('--out <path>', 'Output file path')
4925
+ .option('--stdout', 'Write to stdout')
4926
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
4927
+ .option('--api-key <key>', 'CLI API key')
4928
+ .option('--workspace-id <id>', 'Workspace id (for scoped keys)')
4929
+ .option('--pretty', 'Pretty-print JSON metadata when no file is ready')
4930
+ .option('--json', 'Emit machine-readable errors')
4931
+ .option('--timeout <ms>', 'Request timeout in milliseconds')
4932
+ .option('--verbose', 'Show error details')
4933
+ .action((opts) => runCommand(handleDataExportGroupEvidence, opts));
4934
+
4935
+ data
4936
+ .command('group-completeness')
4937
+ .description('Compare an expected tracking-item manifest against current group items')
4938
+ .requiredOption('--group-id <id>', 'Tracking group id')
4939
+ .requiredOption('--manifest <jsonOrFile>', 'Expected items manifest JSON or @file.json')
4940
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
4941
+ .option('--api-key <key>', 'CLI API key')
4942
+ .option('--workspace-id <id>', 'Workspace id (for scoped keys)')
4943
+ .option('--pretty', 'Pretty-print JSON')
4944
+ .option('--json', 'Emit machine-readable errors')
4945
+ .option('--timeout <ms>', 'Request timeout in milliseconds')
4946
+ .option('--verbose', 'Show error details')
4947
+ .action((opts) => runCommand(handleDataGroupCompleteness, opts));
4948
+
4217
4949
  data
4218
4950
  .command('export-report')
4219
4951
  .description('Export report data via export-report (provisional)')