@socialseal/cli 0.1.7 → 0.1.9

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/src/index.js CHANGED
@@ -10,14 +10,16 @@ const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.config', 'socialseal', 'co
10
10
  const DEFAULT_API_BASE = 'https://api.socialseal.co';
11
11
  const CLI_KEY_HEADER = 'X-CLI-Key';
12
12
  const WORKSPACE_HEADER = 'X-Workspace-Id';
13
- const DEFAULT_TIMEOUT_MS = 30000;
13
+ const DEFAULT_TIMEOUT_MS = 300000;
14
14
  const DEFAULT_AGENT_IDLE_TIMEOUT_MS = 300000;
15
15
  const DEFAULT_POLL_INTERVAL_MS = 2000;
16
+ const DEFAULT_STATUS_RESULTS_LIMIT = 10;
16
17
  const DEFAULT_FRAME_COUNT = 3;
17
18
  const MAX_TIMEOUT_MS = 900000;
18
19
  const LEGACY_ENABLED = process.env.SOCIALSEAL_ENABLE_LEGACY === '1';
19
20
  const CLI_VERSION = loadRuntimeVersion();
20
21
  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.';
22
+ const STATIC_TOOL_SCHEMA_NOTE = 'Schema hints are static CLI docs for discoverability. Backend contracts can still evolve.';
21
23
  const EXIT_CODES = {
22
24
  OK: 0,
23
25
  UNKNOWN: 1,
@@ -27,8 +29,12 @@ const EXIT_CODES = {
27
29
  SERVER: 5,
28
30
  };
29
31
  const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
32
+ const ACTIVE_STATUS_VALUES = new Set(['queued', 'pending', 'processing', 'in_progress', 'running']);
33
+ const TOOL_STATUS_KINDS = new Set(['auto', 'agent_job', 'google_ai_run', 'journey_run']);
30
34
  const REPORT_TYPE_SEARCH_RESULTS_ENRICHED = 'search_results_enriched';
31
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;
32
38
  const SUPPORTED_EXPORT_REPORT_TYPES = [
33
39
  'keyword_universe',
34
40
  'cluster_insights',
@@ -55,6 +61,14 @@ const EXPORT_OPTIONS = [
55
61
  bestFor: 'SQL-like ranked-search datasets without using psql.',
56
62
  alias: 'socialseal data export-report --report-type search_results_enriched --format csv --payload @payload.json',
57
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
+ },
58
72
  {
59
73
  id: 'report_templates',
60
74
  command: 'socialseal data export-report --report-type <type> --format <format> --payload @payload.json',
@@ -66,7 +80,17 @@ const EXPORT_OPTIONS = [
66
80
  },
67
81
  ];
68
82
  const KNOWN_TOOLS = [
69
- { name: 'agent-tool-jobs', category: 'agent', description: 'Poll queued agent-backed tool jobs and fetch their results.' },
83
+ {
84
+ name: 'agent-tool-jobs',
85
+ category: 'agent',
86
+ description: 'Queue/poll agent-backed tool jobs.',
87
+ objectType: 'agent_tool_job',
88
+ transport: 'post_edge_function',
89
+ workspaceScoped: true,
90
+ knownLocalDevState: 'enabled',
91
+ actionAliases: ['start', 'status'],
92
+ notes: 'Use action=start to queue and action=status to read a UUID job id.',
93
+ },
70
94
  { name: 'deep-exploration-runs', category: 'agent', description: 'Read or persist deep exploration render runs.' },
71
95
  { name: 'workspace-notes', category: 'agent', description: 'Search, create, update, and pin workspace note memory.' },
72
96
  { name: 'workspace-onboarding', category: 'agent', description: 'Read or update workspace onboarding metadata used by the agent.' },
@@ -123,7 +147,36 @@ const KNOWN_TOOLS = [
123
147
  notes: 'Accepts videoId/videoUid/platformVideoId/searchResultId items; videoId means video_uid or platform-native video id, not a tracking item id.',
124
148
  },
125
149
  { name: 'douyin-geo-api', category: 'search', description: 'Query Douyin search and geo data.' },
126
- { name: 'google-ai-search', category: 'search', description: 'Run Google AI search queries and fetch result snapshots.' },
150
+ {
151
+ name: 'google-ai-search',
152
+ category: 'search',
153
+ description: 'Queue Google AI search runs.',
154
+ objectType: 'google_ai_run',
155
+ transport: 'post_edge_function',
156
+ workspaceScoped: true,
157
+ knownLocalDevState: 'enabled',
158
+ notes: 'Returns numeric runId. Read status/results via get-google-ai-search-runs/get-google-ai-search-results or socialseal tools status <runId>.',
159
+ },
160
+ {
161
+ name: 'get-google-ai-search-runs',
162
+ category: 'search',
163
+ description: 'Read Google AI run queue/progress by numeric run id.',
164
+ objectType: 'google_ai_run',
165
+ transport: 'post_edge_function',
166
+ workspaceScoped: true,
167
+ knownLocalDevState: 'enabled',
168
+ notes: 'Primary status endpoint for numeric Google AI run ids.',
169
+ },
170
+ {
171
+ name: 'get-google-ai-search-results',
172
+ category: 'search',
173
+ description: 'Read Google AI summaries/citations by numeric run id.',
174
+ objectType: 'google_ai_summary',
175
+ transport: 'post_edge_function',
176
+ workspaceScoped: true,
177
+ knownLocalDevState: 'enabled',
178
+ notes: 'Use this after a run reaches succeeded/partial/failed to inspect summary-level output.',
179
+ },
127
180
  { name: 'instagram-geo-api', category: 'search', description: 'Query Instagram search and geo data.' },
128
181
  { name: 'tiktok-geo-api', category: 'search', description: 'Query TikTok search and geo data.' },
129
182
  { name: 'xhs-geo-api', category: 'search', description: 'Query Xiaohongshu search and geo data.' },
@@ -137,7 +190,7 @@ const KNOWN_TOOLS = [
137
190
  workspaceScoped: true,
138
191
  knownLocalDevState: 'disabled_by_default',
139
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'],
140
- 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.',
141
194
  },
142
195
  {
143
196
  name: 'tracking',
@@ -152,7 +205,16 @@ const KNOWN_TOOLS = [
152
205
  },
153
206
  { name: 'journey-feedback', category: 'vnext', description: 'Record acceptance or rejection feedback for opportunity bundles.' },
154
207
  { name: 'opportunity-bundle-approve', category: 'vnext', description: 'Approve an opportunity bundle and create tracking coverage.' },
155
- { name: 'search-journey-run', category: 'vnext', description: 'Run a search journey for a subject across supported platforms.' },
208
+ {
209
+ name: 'search-journey-run',
210
+ category: 'vnext',
211
+ description: 'Run or poll a search journey for a subject across supported platforms.',
212
+ objectType: 'search_journey_run',
213
+ transport: 'post_edge_function',
214
+ workspaceScoped: true,
215
+ knownLocalDevState: 'enabled',
216
+ notes: 'Async start returns runId; poll with action=status or socialseal tools status <runId> --kind journey_run --workspace-id <workspace-id>.',
217
+ },
156
218
  { name: 'vnext-blueprints-create', category: 'vnext', description: 'Create a vNext blueprint from grounded evidence.' },
157
219
  { name: 'vnext-blueprints-generate', category: 'vnext', description: 'Generate a vNext blueprint from workspace opportunity data.' },
158
220
  { name: 'vnext-blueprints-read', category: 'vnext', description: 'Read vNext blueprint history and specific versions.' },
@@ -168,6 +230,245 @@ const KNOWN_TOOLS = [
168
230
  { name: 'vnext-topics-auto-tag', category: 'vnext', description: 'Auto-tag keyword and topic assignments with Gemini-assisted review.' },
169
231
  ];
170
232
 
233
+ const TOOL_SCHEMA_HINTS = {
234
+ 'agent-tool-jobs': {
235
+ summary: 'Queue agent-backed jobs and read UUID job status.',
236
+ operations: [
237
+ {
238
+ action: 'start',
239
+ required: ['action=start', 'toolName', 'payload'],
240
+ optional: [],
241
+ example: {
242
+ action: 'start',
243
+ toolName: 'search_videos',
244
+ payload: {
245
+ query: 'best africa safari itinerary',
246
+ platform: 'tiktok',
247
+ region: 'IN',
248
+ limit: 20,
249
+ },
250
+ },
251
+ },
252
+ {
253
+ action: 'status',
254
+ required: ['action=status', 'jobId (uuid)'],
255
+ optional: [],
256
+ example: {
257
+ action: 'status',
258
+ jobId: '11111111-1111-4111-8111-111111111111',
259
+ },
260
+ },
261
+ ],
262
+ cliExamples: [
263
+ 'socialseal tools call --function agent-tool-jobs --body \'{"action":"start","toolName":"search_videos","payload":{"query":"best africa safari itinerary","platform":"tiktok","region":"IN"}}\'',
264
+ 'socialseal tools status 11111111-1111-4111-8111-111111111111 --kind agent_job',
265
+ ],
266
+ },
267
+ 'search-journey-run': {
268
+ summary: 'Start/poll journey keyword expansion runs.',
269
+ operations: [
270
+ {
271
+ action: 'start',
272
+ required: ['subject', 'subjectType', 'region', 'workspaceId'],
273
+ optional: [
274
+ 'locale',
275
+ 'platformKeys',
276
+ 'seedKeywords',
277
+ 'contentPillars',
278
+ 'contentPillarIds',
279
+ 'maxKeywords',
280
+ 'maxKeywordsPerStage',
281
+ 'includeRejected',
282
+ 'skipCache',
283
+ 'executionMode',
284
+ ],
285
+ example: {
286
+ subject: 'Como Hotels',
287
+ subjectType: 'brand',
288
+ region: 'IN',
289
+ locale: 'en-IN',
290
+ workspaceId: '00000000-0000-4000-8000-000000000000',
291
+ executionMode: 'async',
292
+ },
293
+ },
294
+ {
295
+ action: 'status',
296
+ required: ['action=status', 'workspaceId', 'runId (uuid)'],
297
+ optional: [],
298
+ example: {
299
+ action: 'status',
300
+ workspaceId: '00000000-0000-4000-8000-000000000000',
301
+ runId: '11111111-1111-4111-8111-111111111111',
302
+ },
303
+ },
304
+ ],
305
+ cliExamples: [
306
+ 'socialseal tools call --function search-journey-run --body @journey.json --async --workspace-id <workspace-uuid>',
307
+ 'socialseal tools status 11111111-1111-4111-8111-111111111111 --kind journey_run --workspace-id <workspace-uuid>',
308
+ ],
309
+ },
310
+ 'google-ai-search': {
311
+ summary: 'Queue Google AI runs (returns numeric runId).',
312
+ operations: [
313
+ {
314
+ action: 'start',
315
+ required: ['queries'],
316
+ optional: [
317
+ 'workspaceId',
318
+ 'trackingItemId',
319
+ 'countryCode',
320
+ 'searchLanguage',
321
+ 'brandId',
322
+ 'competitorBrandIds',
323
+ 'brandDomains',
324
+ 'competitorDomains',
325
+ 'aiMode',
326
+ ],
327
+ notes: 'region is commonly used in workflows, but the canonical field is countryCode.',
328
+ example: {
329
+ queries: ['east africa itinerary', 'kenya tanzania itinerary'],
330
+ countryCode: 'in',
331
+ searchLanguage: 'en',
332
+ workspaceId: '00000000-0000-4000-8000-000000000000',
333
+ },
334
+ },
335
+ ],
336
+ cliExamples: [
337
+ 'socialseal tools call --function google-ai-search --body @google-ai-search.json --workspace-id <workspace-uuid>',
338
+ 'socialseal tools status 6809 --kind google_ai_run',
339
+ ],
340
+ },
341
+ 'get-google-ai-search-runs': {
342
+ summary: 'Read Google AI run status/progress.',
343
+ operations: [
344
+ {
345
+ action: 'read',
346
+ required: [],
347
+ optional: ['runId', 'trackingItemId', 'limit', 'offset', 'skipCache'],
348
+ example: {
349
+ runId: 6809,
350
+ limit: 1,
351
+ offset: 0,
352
+ },
353
+ },
354
+ ],
355
+ cliExamples: [
356
+ 'socialseal tools call --function get-google-ai-search-runs --body \'{"runId":6809,"limit":1}\'',
357
+ 'socialseal tools status 6809 --kind google_ai_run',
358
+ ],
359
+ },
360
+ 'get-google-ai-search-results': {
361
+ summary: 'Read Google AI summary/citation rows.',
362
+ operations: [
363
+ {
364
+ action: 'read',
365
+ required: [],
366
+ optional: ['runId', 'query', 'trackingItemId', 'includeCitations', 'limit', 'offset', 'skipCache'],
367
+ example: {
368
+ runId: 6809,
369
+ includeCitations: true,
370
+ limit: 10,
371
+ offset: 0,
372
+ },
373
+ },
374
+ ],
375
+ cliExamples: [
376
+ 'socialseal tools call --function get-google-ai-search-results --body \'{"runId":6809,"includeCitations":true,"limit":10}\'',
377
+ ],
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
+ },
441
+ };
442
+
443
+ function getToolSchemaHint(functionName) {
444
+ if (!functionName) return null;
445
+ return TOOL_SCHEMA_HINTS[functionName] || null;
446
+ }
447
+
448
+ function getKnownTool(functionName) {
449
+ return KNOWN_TOOLS.find((tool) => tool.name === functionName) || null;
450
+ }
451
+
452
+ function buildSchemaAvailabilitySummary(schema) {
453
+ const firstOperation = Array.isArray(schema?.operations) ? schema.operations[0] : null;
454
+ if (!firstOperation || !Array.isArray(firstOperation.required) || firstOperation.required.length === 0) {
455
+ return 'optional body fields vary by read filter';
456
+ }
457
+ return `required: ${firstOperation.required.join(', ')}`;
458
+ }
459
+
460
+ function buildToolRegistry() {
461
+ return KNOWN_TOOLS.map((tool) => {
462
+ const schema = getToolSchemaHint(tool.name);
463
+ if (!schema) return tool;
464
+ return {
465
+ ...tool,
466
+ schemaAvailable: true,
467
+ schemaSummary: buildSchemaAvailabilitySummary(schema),
468
+ };
469
+ });
470
+ }
471
+
171
472
  function getConfigPath() {
172
473
  return process.env.SOCIALSEAL_CONFIG || DEFAULT_CONFIG_PATH;
173
474
  }
@@ -723,6 +1024,65 @@ function isUuidLike(value) {
723
1024
  return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value.trim());
724
1025
  }
725
1026
 
1027
+ function isPositiveIntegerString(value) {
1028
+ return typeof value === 'string' && /^[1-9]\d*$/.test(value.trim());
1029
+ }
1030
+
1031
+ function normalizeStatusIdentifier(rawId) {
1032
+ const id = trimString(rawId);
1033
+ if (!id) {
1034
+ throw new CliError('Missing status identifier.', {
1035
+ code: 'MISSING_ARGUMENT',
1036
+ exitCode: EXIT_CODES.USAGE,
1037
+ hint: 'Pass a numeric Google AI runId or a UUID job/run id.',
1038
+ });
1039
+ }
1040
+
1041
+ if (isPositiveIntegerString(id)) {
1042
+ const parsed = Number(id);
1043
+ if (Number.isSafeInteger(parsed) && parsed > 0) {
1044
+ return { rawId: id, numericId: parsed, uuidId: null };
1045
+ }
1046
+ }
1047
+
1048
+ if (isUuidLike(id)) {
1049
+ return { rawId: id, numericId: null, uuidId: id };
1050
+ }
1051
+
1052
+ throw new CliError(`Unsupported status identifier: ${id}`, {
1053
+ code: 'INVALID_ARGUMENT',
1054
+ exitCode: EXIT_CODES.USAGE,
1055
+ hint: 'Use a numeric Google AI runId (for example 6809) or UUID job/run id.',
1056
+ });
1057
+ }
1058
+
1059
+ function parseToolStatusKind(rawKind) {
1060
+ const normalized = trimString(rawKind || 'auto').toLowerCase();
1061
+ if (TOOL_STATUS_KINDS.has(normalized)) {
1062
+ return normalized;
1063
+ }
1064
+ throw new CliError(`Unsupported tools status kind: ${rawKind}`, {
1065
+ code: 'INVALID_ARGUMENT',
1066
+ exitCode: EXIT_CODES.USAGE,
1067
+ hint: 'Use --kind auto|agent_job|google_ai_run|journey_run.',
1068
+ });
1069
+ }
1070
+
1071
+ function normalizeStatusValue(value) {
1072
+ return trimString(value).toLowerCase();
1073
+ }
1074
+
1075
+ function isTerminalStatusValue(value) {
1076
+ const normalized = normalizeStatusValue(value);
1077
+ if (!normalized) return true;
1078
+ return !ACTIVE_STATUS_VALUES.has(normalized);
1079
+ }
1080
+
1081
+ function isFailedStatusValue(value) {
1082
+ const normalized = normalizeStatusValue(value);
1083
+ return normalized === 'failed' || normalized === 'error';
1084
+ }
1085
+
726
1086
  function coercePositiveInteger(value, label) {
727
1087
  if (value === undefined || value === null || value === '') return undefined;
728
1088
  const parsed = Number(value);
@@ -821,6 +1181,200 @@ function buildSearchResultsEnrichedExportPayload(rawPayload, workspaceId) {
821
1181
  });
822
1182
  }
823
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
+
824
1378
  function buildPathWithQuery(basePath, query) {
825
1379
  const params = new URLSearchParams();
826
1380
  for (const [key, rawValue] of Object.entries(query || {})) {
@@ -891,8 +1445,10 @@ function normalizeGroupManagementPayload(payload, fallbackWorkspaceId) {
891
1445
  const itemId = firstDefined(payload, ['item_id', 'itemId']);
892
1446
  const itemIds = firstDefined(payload, ['item_ids', 'itemIds']);
893
1447
  const items = firstDefined(payload, ['items']);
1448
+ const expectedItems = firstDefined(payload, ['expected_items', 'expectedItems']);
894
1449
  const limit = firstDefined(payload, ['limit']);
895
1450
  const page = firstDefined(payload, ['page']);
1451
+ const force = firstDefined(payload, ['force']);
896
1452
  return stripUndefinedEntries({
897
1453
  action: trimString(firstDefined(payload, ['action'])) || undefined,
898
1454
  workspaceId: resolvePayloadWorkspaceId(payload, fallbackWorkspaceId),
@@ -911,6 +1467,7 @@ function normalizeGroupManagementPayload(payload, fallbackWorkspaceId) {
911
1467
  })
912
1468
  : undefined,
913
1469
  items: Array.isArray(items) ? items : undefined,
1470
+ expected_items: Array.isArray(expectedItems) ? expectedItems : undefined,
914
1471
  name: trimString(firstDefined(payload, ['name'])) || undefined,
915
1472
  description: firstDefined(payload, ['description']),
916
1473
  platform: trimString(firstDefined(payload, ['platform', 'groupPlatform'])) || undefined,
@@ -924,6 +1481,7 @@ function normalizeGroupManagementPayload(payload, fallbackWorkspaceId) {
924
1481
  : firstDefined(payload, ['region']),
925
1482
  limit: limit !== undefined ? Number(limit) : undefined,
926
1483
  page: page !== undefined ? Number(page) : undefined,
1484
+ force: force === true || trimString(force).toLowerCase() === 'true' ? true : undefined,
927
1485
  });
928
1486
  }
929
1487
 
@@ -1413,6 +1971,28 @@ function translateGroupManagementAction(payload, workspaceId, originalMethod) {
1413
1971
  };
1414
1972
  }
1415
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
+
1416
1996
  if (action === 'remove_item' || action === 'group_remove_item') {
1417
1997
  const groupId = coercePositiveInteger(payload.group_id, 'group_id');
1418
1998
  const itemId = coercePositiveInteger(payload.item_id, 'item_id');
@@ -1433,7 +2013,7 @@ function translateGroupManagementAction(payload, workspaceId, originalMethod) {
1433
2013
  throw new CliError(`Unsupported group-management action: ${payload.action}`, {
1434
2014
  code: 'INVALID_ARGUMENT',
1435
2015
  exitCode: EXIT_CODES.USAGE,
1436
- 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.',
1437
2017
  });
1438
2018
  }
1439
2019
 
@@ -1722,6 +2302,145 @@ function emitJsonOutput(value, pretty) {
1722
2302
  process.stdout.write(formatJsonOutput(value, pretty) + '\n');
1723
2303
  }
1724
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
+
1725
2444
  function buildSearchJourneyRunFailure(data) {
1726
2445
  const message = isJsonObject(data) && typeof data.error === 'string' && data.error.trim().length > 0
1727
2446
  ? data.error
@@ -1799,14 +2518,17 @@ async function pollSearchJourneyRun({
1799
2518
 
1800
2519
  const data = await res.json();
1801
2520
  const status = isJsonObject(data) && typeof data.status === 'string' ? data.status : null;
2521
+ const normalizedStatus = normalizeStatusValue(status);
1802
2522
  if (status && status !== lastStatus) {
1803
2523
  emitInfo(opts, `search-journey-run status: ${status}`);
1804
2524
  lastStatus = status;
1805
2525
  }
1806
2526
 
1807
- if (status === 'completed') return data;
1808
- if (status === 'failed') throw buildSearchJourneyRunFailure(data);
1809
- if (status === 'pending' || status === 'processing') continue;
2527
+ if (normalizedStatus === 'completed' || normalizedStatus === 'succeeded') return data;
2528
+ if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
2529
+ throw buildSearchJourneyRunFailure(data);
2530
+ }
2531
+ if (normalizedStatus && ACTIVE_STATUS_VALUES.has(normalizedStatus)) continue;
1810
2532
 
1811
2533
  throw new CliError('search-journey-run status poll returned an unexpected payload.', {
1812
2534
  code: 'INVALID_STATUS_RESPONSE',
@@ -1885,22 +2607,444 @@ async function buildHttpError(res, context = {}) {
1885
2607
  });
1886
2608
  }
1887
2609
 
1888
- function emitError(err, opts = {}) {
1889
- const showDetails = opts.json || opts.verbose;
1890
- const payload = {
1891
- type: 'error',
1892
- error: {
1893
- code: err.code || 'CLI_ERROR',
1894
- message: err.message || 'Unknown error',
1895
- status: err.status ?? null,
1896
- hint: err.hint ?? null,
1897
- details: showDetails ? (err.details ?? null) : null,
1898
- },
1899
- };
1900
-
1901
- if (opts.json) {
1902
- process.stderr.write(`${JSON.stringify(payload)}\n`);
1903
- return;
2610
+ function resolveStatusResultsLimit(rawLimit) {
2611
+ if (rawLimit == null || rawLimit === '') return DEFAULT_STATUS_RESULTS_LIMIT;
2612
+ const parsed = Number(rawLimit);
2613
+ if (!Number.isInteger(parsed) || parsed <= 0) {
2614
+ throw new CliError('Invalid results limit. Use a positive integer.', {
2615
+ code: 'INVALID_ARGUMENT',
2616
+ exitCode: EXIT_CODES.USAGE,
2617
+ hint: 'Use --results-limit <n> where n is between 1 and 50.',
2618
+ });
2619
+ }
2620
+ return Math.min(parsed, 50);
2621
+ }
2622
+
2623
+ async function callToolJson({
2624
+ apiBase,
2625
+ apiKey,
2626
+ useGateway,
2627
+ legacyUrl,
2628
+ functionName,
2629
+ body,
2630
+ workspaceId,
2631
+ timeoutMs,
2632
+ label,
2633
+ }) {
2634
+ const path = useGateway ? `/cli/tools/${functionName}` : `/functions/v1/${functionName}`;
2635
+ const res = await callApi({
2636
+ apiBase: useGateway ? apiBase : legacyUrl,
2637
+ apiKey,
2638
+ path,
2639
+ method: 'POST',
2640
+ body,
2641
+ workspaceId,
2642
+ timeoutMs,
2643
+ });
2644
+
2645
+ if (res.status === 404) {
2646
+ return { notFound: true, data: null };
2647
+ }
2648
+ if (!res.ok) {
2649
+ throw await buildHttpError(res, {
2650
+ label,
2651
+ functionName,
2652
+ method: 'POST',
2653
+ });
2654
+ }
2655
+
2656
+ const contentType = res.headers.get('content-type') || '';
2657
+ if (!contentType.includes('application/json')) {
2658
+ throw new CliError(`${label} returned a non-JSON response.`, {
2659
+ code: 'INVALID_RESPONSE',
2660
+ exitCode: EXIT_CODES.SERVER,
2661
+ });
2662
+ }
2663
+
2664
+ return { notFound: false, data: await res.json() };
2665
+ }
2666
+
2667
+ async function readAgentToolJobStatus({
2668
+ apiBase,
2669
+ apiKey,
2670
+ useGateway,
2671
+ legacyUrl,
2672
+ timeoutMs,
2673
+ jobId,
2674
+ }) {
2675
+ const response = await callToolJson({
2676
+ apiBase,
2677
+ apiKey,
2678
+ useGateway,
2679
+ legacyUrl,
2680
+ functionName: 'agent-tool-jobs',
2681
+ body: { action: 'status', jobId },
2682
+ workspaceId: null,
2683
+ timeoutMs,
2684
+ label: 'Agent tool job status',
2685
+ });
2686
+ if (response.notFound) return null;
2687
+
2688
+ const data = response.data;
2689
+ return {
2690
+ kind: 'agent_tool_job',
2691
+ id: jobId,
2692
+ status: isJsonObject(data) ? trimString(data.status || '') : '',
2693
+ workspaceId: isJsonObject(data) ? (data.workspaceId ?? null) : null,
2694
+ toolName: isJsonObject(data) ? (data.toolName ?? null) : null,
2695
+ result: isJsonObject(data) ? (data.result ?? null) : null,
2696
+ error: isJsonObject(data) ? (data.error ?? null) : null,
2697
+ raw: data,
2698
+ };
2699
+ }
2700
+
2701
+ async function readSearchJourneyRunStatus({
2702
+ apiBase,
2703
+ apiKey,
2704
+ useGateway,
2705
+ legacyUrl,
2706
+ timeoutMs,
2707
+ workspaceId,
2708
+ runId,
2709
+ }) {
2710
+ if (!workspaceId) {
2711
+ throw new CliError('Search journey run status requires a workspace id.', {
2712
+ code: 'WORKSPACE_REQUIRED',
2713
+ exitCode: EXIT_CODES.USAGE,
2714
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace.',
2715
+ });
2716
+ }
2717
+
2718
+ const response = await callToolJson({
2719
+ apiBase,
2720
+ apiKey,
2721
+ useGateway,
2722
+ legacyUrl,
2723
+ functionName: 'search-journey-run',
2724
+ body: { action: 'status', workspaceId, runId },
2725
+ workspaceId,
2726
+ timeoutMs,
2727
+ label: 'Search journey status',
2728
+ });
2729
+ if (response.notFound) return null;
2730
+
2731
+ const data = response.data;
2732
+ return {
2733
+ kind: 'search_journey_run',
2734
+ id: runId,
2735
+ status: isJsonObject(data) ? trimString(data.status || '') : '',
2736
+ workspaceId,
2737
+ journeyId: isJsonObject(data) ? (data.journeyId ?? null) : null,
2738
+ stagedKeywordsCount:
2739
+ isJsonObject(data) && Array.isArray(data.stagedKeywords) ? data.stagedKeywords.length : 0,
2740
+ error: isJsonObject(data) ? (data.error ?? null) : null,
2741
+ raw: data,
2742
+ };
2743
+ }
2744
+
2745
+ async function readGoogleAiRunStatus({
2746
+ apiBase,
2747
+ apiKey,
2748
+ useGateway,
2749
+ legacyUrl,
2750
+ timeoutMs,
2751
+ runId,
2752
+ includeResults,
2753
+ resultsLimit,
2754
+ }) {
2755
+ const runsResponse = await callToolJson({
2756
+ apiBase,
2757
+ apiKey,
2758
+ useGateway,
2759
+ legacyUrl,
2760
+ functionName: 'get-google-ai-search-runs',
2761
+ body: {
2762
+ runId,
2763
+ limit: 1,
2764
+ offset: 0,
2765
+ },
2766
+ workspaceId: null,
2767
+ timeoutMs,
2768
+ label: 'Google AI run status',
2769
+ });
2770
+ if (runsResponse.notFound) return null;
2771
+
2772
+ const runsPayload = runsResponse.data;
2773
+ const runItem = isJsonObject(runsPayload) && Array.isArray(runsPayload.items)
2774
+ ? runsPayload.items.find((item) => isJsonObject(item) && Number(item.id) === runId) || null
2775
+ : null;
2776
+
2777
+ if (!runItem) return null;
2778
+
2779
+ let resultsPayload = null;
2780
+ if (includeResults) {
2781
+ const resultsResponse = await callToolJson({
2782
+ apiBase,
2783
+ apiKey,
2784
+ useGateway,
2785
+ legacyUrl,
2786
+ functionName: 'get-google-ai-search-results',
2787
+ body: {
2788
+ runId,
2789
+ includeCitations: true,
2790
+ limit: resultsLimit,
2791
+ offset: 0,
2792
+ },
2793
+ workspaceId: null,
2794
+ timeoutMs,
2795
+ label: 'Google AI run results',
2796
+ });
2797
+ resultsPayload = resultsResponse.notFound ? null : resultsResponse.data;
2798
+ }
2799
+
2800
+ return {
2801
+ kind: 'google_ai_run',
2802
+ id: runId,
2803
+ status: trimString(runItem.status || ''),
2804
+ progress: {
2805
+ completedQueries: typeof runItem.completedQueries === 'number' ? runItem.completedQueries : null,
2806
+ totalQueries: typeof runItem.totalQueries === 'number' ? runItem.totalQueries : null,
2807
+ progressPercent: typeof runItem.progressPercent === 'number' ? runItem.progressPercent : null,
2808
+ },
2809
+ lastErrorMessage: runItem.lastErrorMessage ?? null,
2810
+ run: runItem,
2811
+ results: resultsPayload,
2812
+ };
2813
+ }
2814
+
2815
+ function buildToolStatusNotFoundError(identifier, kind, workspaceId) {
2816
+ if (kind === 'google_ai_run') {
2817
+ return new CliError(`Google AI run not found: ${identifier.rawId}`, {
2818
+ code: 'STATUS_NOT_FOUND',
2819
+ exitCode: EXIT_CODES.NOT_FOUND,
2820
+ hint: 'Verify the run id and key scope, then retry with --kind google_ai_run.',
2821
+ });
2822
+ }
2823
+ if (kind === 'agent_job') {
2824
+ return new CliError(`Agent tool job not found: ${identifier.rawId}`, {
2825
+ code: 'STATUS_NOT_FOUND',
2826
+ exitCode: EXIT_CODES.NOT_FOUND,
2827
+ hint: 'Verify the UUID job id, then retry with --kind agent_job.',
2828
+ });
2829
+ }
2830
+ if (kind === 'journey_run') {
2831
+ return new CliError(`Search journey run not found: ${identifier.rawId}`, {
2832
+ code: 'STATUS_NOT_FOUND',
2833
+ exitCode: EXIT_CODES.NOT_FOUND,
2834
+ hint: 'Verify --workspace-id and the journey run UUID, then retry.',
2835
+ });
2836
+ }
2837
+ return new CliError(`No matching status record found for ${identifier.rawId}.`, {
2838
+ code: 'STATUS_NOT_FOUND',
2839
+ exitCode: EXIT_CODES.NOT_FOUND,
2840
+ hint: workspaceId
2841
+ ? 'Try --kind agent_job or --kind journey_run explicitly.'
2842
+ : 'Try --kind agent_job or provide --workspace-id to also check journey runs.',
2843
+ });
2844
+ }
2845
+
2846
+ async function resolveUnifiedToolStatus({
2847
+ apiBase,
2848
+ apiKey,
2849
+ useGateway,
2850
+ legacyUrl,
2851
+ timeoutMs,
2852
+ identifier,
2853
+ kind,
2854
+ workspaceId,
2855
+ includeResults,
2856
+ resultsLimit,
2857
+ }) {
2858
+ if (kind === 'google_ai_run') {
2859
+ if (identifier.numericId == null) {
2860
+ throw new CliError('google_ai_run status expects a numeric run id.', {
2861
+ code: 'INVALID_ARGUMENT',
2862
+ exitCode: EXIT_CODES.USAGE,
2863
+ });
2864
+ }
2865
+ const result = await readGoogleAiRunStatus({
2866
+ apiBase,
2867
+ apiKey,
2868
+ useGateway,
2869
+ legacyUrl,
2870
+ timeoutMs,
2871
+ runId: identifier.numericId,
2872
+ includeResults,
2873
+ resultsLimit,
2874
+ });
2875
+ if (result) return result;
2876
+ throw buildToolStatusNotFoundError(identifier, kind, workspaceId);
2877
+ }
2878
+
2879
+ if (kind === 'agent_job') {
2880
+ if (!identifier.uuidId) {
2881
+ throw new CliError('agent_job status expects a UUID id.', {
2882
+ code: 'INVALID_ARGUMENT',
2883
+ exitCode: EXIT_CODES.USAGE,
2884
+ });
2885
+ }
2886
+ const result = await readAgentToolJobStatus({
2887
+ apiBase,
2888
+ apiKey,
2889
+ useGateway,
2890
+ legacyUrl,
2891
+ timeoutMs,
2892
+ jobId: identifier.uuidId,
2893
+ });
2894
+ if (result) return result;
2895
+ throw buildToolStatusNotFoundError(identifier, kind, workspaceId);
2896
+ }
2897
+
2898
+ if (kind === 'journey_run') {
2899
+ if (!identifier.uuidId) {
2900
+ throw new CliError('journey_run status expects a UUID run id.', {
2901
+ code: 'INVALID_ARGUMENT',
2902
+ exitCode: EXIT_CODES.USAGE,
2903
+ });
2904
+ }
2905
+ const result = await readSearchJourneyRunStatus({
2906
+ apiBase,
2907
+ apiKey,
2908
+ useGateway,
2909
+ legacyUrl,
2910
+ timeoutMs,
2911
+ workspaceId,
2912
+ runId: identifier.uuidId,
2913
+ });
2914
+ if (result) return result;
2915
+ throw buildToolStatusNotFoundError(identifier, kind, workspaceId);
2916
+ }
2917
+
2918
+ if (identifier.numericId != null) {
2919
+ const result = await readGoogleAiRunStatus({
2920
+ apiBase,
2921
+ apiKey,
2922
+ useGateway,
2923
+ legacyUrl,
2924
+ timeoutMs,
2925
+ runId: identifier.numericId,
2926
+ includeResults,
2927
+ resultsLimit,
2928
+ });
2929
+ if (result) return result;
2930
+ throw buildToolStatusNotFoundError(identifier, 'google_ai_run', workspaceId);
2931
+ }
2932
+
2933
+ const agentJob = await readAgentToolJobStatus({
2934
+ apiBase,
2935
+ apiKey,
2936
+ useGateway,
2937
+ legacyUrl,
2938
+ timeoutMs,
2939
+ jobId: identifier.uuidId,
2940
+ });
2941
+ if (agentJob) return agentJob;
2942
+
2943
+ if (workspaceId) {
2944
+ const journeyRun = await readSearchJourneyRunStatus({
2945
+ apiBase,
2946
+ apiKey,
2947
+ useGateway,
2948
+ legacyUrl,
2949
+ timeoutMs,
2950
+ workspaceId,
2951
+ runId: identifier.uuidId,
2952
+ });
2953
+ if (journeyRun) return journeyRun;
2954
+ }
2955
+
2956
+ throw buildToolStatusNotFoundError(identifier, 'auto', workspaceId);
2957
+ }
2958
+
2959
+ function buildStatusCommandHint(result, workspaceId) {
2960
+ if (!result || !result.kind) return null;
2961
+ if (result.kind === 'google_ai_run') {
2962
+ return `socialseal tools status ${result.id} --kind google_ai_run`;
2963
+ }
2964
+ if (result.kind === 'agent_tool_job') {
2965
+ return `socialseal tools status ${result.id} --kind agent_job`;
2966
+ }
2967
+ if (result.kind === 'search_journey_run') {
2968
+ const scopedWorkspace = workspaceId || result.workspaceId;
2969
+ if (!scopedWorkspace) return null;
2970
+ return `socialseal tools status ${result.id} --kind journey_run --workspace-id ${scopedWorkspace}`;
2971
+ }
2972
+ return null;
2973
+ }
2974
+
2975
+ function maybeEmitFollowupStatusHint({ functionName, data, workspaceId }) {
2976
+ if (!isJsonObject(data)) return;
2977
+ if (functionName === 'google-ai-search' && Number.isInteger(data.runId)) {
2978
+ process.stderr.write(
2979
+ `[socialseal] Google AI run queued: ${data.runId}. Use: socialseal tools status ${data.runId} --kind google_ai_run\n`,
2980
+ );
2981
+ return;
2982
+ }
2983
+ if (functionName === 'agent-tool-jobs' && typeof data.jobId === 'string' && isUuidLike(data.jobId)) {
2984
+ process.stderr.write(
2985
+ `[socialseal] Agent tool job queued: ${data.jobId}. Use: socialseal tools status ${data.jobId} --kind agent_job\n`,
2986
+ );
2987
+ return;
2988
+ }
2989
+ if (functionName === 'search-journey-run' && typeof data.runId === 'string' && isUuidLike(data.runId)) {
2990
+ const scopedWorkspace = trimString(workspaceId || data.workspaceId || '');
2991
+ const workspaceFlag = scopedWorkspace ? ` --workspace-id ${scopedWorkspace}` : '';
2992
+ process.stderr.write(
2993
+ `[socialseal] Search journey run id: ${data.runId}. Use: socialseal tools status ${data.runId} --kind journey_run${workspaceFlag}\n`,
2994
+ );
2995
+ }
2996
+ }
2997
+
2998
+ async function pollUnifiedStatus({
2999
+ loader,
3000
+ timeoutMs,
3001
+ pollIntervalMs,
3002
+ opts,
3003
+ }) {
3004
+ const deadline = Date.now() + timeoutMs;
3005
+ let current = await loader();
3006
+ let lastStatus = normalizeStatusValue(current?.status);
3007
+
3008
+ while (!isTerminalStatusValue(current?.status)) {
3009
+ const remainingMs = deadline - Date.now();
3010
+ if (remainingMs <= 0) {
3011
+ throw new CliError('Timed out waiting for status completion.', {
3012
+ code: 'ASYNC_WAIT_TIMEOUT',
3013
+ exitCode: EXIT_CODES.SERVER,
3014
+ hint: 'Increase --timeout <ms> or omit --wait to return current status immediately.',
3015
+ details: truncateDetails(current),
3016
+ });
3017
+ }
3018
+
3019
+ emitInfo(opts, `status: ${current.status}`);
3020
+ await sleep(Math.min(pollIntervalMs, remainingMs));
3021
+ current = await loader();
3022
+ const normalized = normalizeStatusValue(current?.status);
3023
+ if (normalized && normalized !== lastStatus) {
3024
+ emitInfo(opts, `status: ${current.status}`);
3025
+ lastStatus = normalized;
3026
+ }
3027
+ }
3028
+
3029
+ return current;
3030
+ }
3031
+
3032
+ function emitError(err, opts = {}) {
3033
+ const showDetails = opts.json || opts.verbose;
3034
+ const payload = {
3035
+ type: 'error',
3036
+ error: {
3037
+ code: err.code || 'CLI_ERROR',
3038
+ message: err.message || 'Unknown error',
3039
+ status: err.status ?? null,
3040
+ hint: err.hint ?? null,
3041
+ details: showDetails ? (err.details ?? null) : null,
3042
+ },
3043
+ };
3044
+
3045
+ if (opts.json) {
3046
+ process.stderr.write(`${JSON.stringify(payload)}\n`);
3047
+ return;
1904
3048
  }
1905
3049
 
1906
3050
  process.stderr.write(`[socialseal] ${payload.error.message}\n`);
@@ -2480,6 +3624,18 @@ async function handleToolsCall(opts) {
2480
3624
  method,
2481
3625
  });
2482
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
+
2483
3639
  const res = await callApi({
2484
3640
  apiBase: useGateway ? resolvedApiBase : legacyUrl,
2485
3641
  apiKey,
@@ -2503,6 +3659,14 @@ async function handleToolsCall(opts) {
2503
3659
  const data = await res.json();
2504
3660
  const shouldPoll = shouldHandleSearchJourneyRunAsync(opts.function, method, payload, opts) && opts.poll !== false;
2505
3661
  if (!shouldPoll) {
3662
+ if (isGroupManagementBulkAddInvocation(opts.function, translated)) {
3663
+ maybeThrowGroupManagementBulkAddPartialFailure(data, translated);
3664
+ }
3665
+ maybeEmitFollowupStatusHint({
3666
+ functionName: opts.function,
3667
+ data,
3668
+ workspaceId: effectiveWorkspaceId,
3669
+ });
2506
3670
  emitJsonOutput(data, opts.pretty);
2507
3671
  return;
2508
3672
  }
@@ -2530,9 +3694,7 @@ async function handleToolsCall(opts) {
2530
3694
  apiBase: useGateway ? resolvedApiBase : legacyUrl,
2531
3695
  apiKey,
2532
3696
  path: useGateway ? `/cli/tools/${opts.function}` : `/functions/v1/${opts.function}`,
2533
- workspaceId: isJsonObject(payload) && typeof payload.workspaceId === 'string'
2534
- ? payload.workspaceId
2535
- : resolvedWorkspaceId,
3697
+ workspaceId: effectiveWorkspaceId,
2536
3698
  timeoutMs,
2537
3699
  pollIntervalMs: resolvePollIntervalMs(opts),
2538
3700
  runId,
@@ -2548,10 +3710,12 @@ async function handleToolsCall(opts) {
2548
3710
  }
2549
3711
 
2550
3712
  function handleToolsList(opts) {
3713
+ const tools = buildToolRegistry();
2551
3714
  const payload = {
2552
3715
  discovery: 'built_in_registry',
2553
- tools: KNOWN_TOOLS,
3716
+ tools,
2554
3717
  note: STATIC_TOOL_REGISTRY_NOTE,
3718
+ schemaNote: STATIC_TOOL_SCHEMA_NOTE,
2555
3719
  };
2556
3720
 
2557
3721
  if (opts.json) {
@@ -2561,9 +3725,10 @@ function handleToolsList(opts) {
2561
3725
 
2562
3726
  process.stdout.write('[socialseal] Built-in tool registry\n');
2563
3727
  process.stdout.write(`[socialseal] ${payload.note}\n`);
3728
+ process.stdout.write(`[socialseal] ${payload.schemaNote}\n`);
2564
3729
 
2565
3730
  let currentCategory = null;
2566
- for (const tool of KNOWN_TOOLS) {
3731
+ for (const tool of tools) {
2567
3732
  if (tool.category !== currentCategory) {
2568
3733
  currentCategory = tool.category;
2569
3734
  process.stdout.write(`\n${currentCategory}\n`);
@@ -2578,9 +3743,156 @@ function handleToolsList(opts) {
2578
3743
  if (tool.notes) {
2579
3744
  process.stdout.write(` note: ${tool.notes}\n`);
2580
3745
  }
3746
+ if (tool.schemaAvailable) {
3747
+ process.stdout.write(` schema: ${tool.schemaSummary}\n`);
3748
+ process.stdout.write(` schema command: socialseal tools schema --function ${tool.name}\n`);
3749
+ }
3750
+ }
3751
+
3752
+ process.stdout.write('\n[socialseal] Call a tool with: socialseal tools call --function <name> --body @payload.json\n');
3753
+ process.stdout.write('[socialseal] Inspect schema examples with: socialseal tools schema --function <name>\n');
3754
+ }
3755
+
3756
+ function handleToolsSchema(opts) {
3757
+ const tools = buildToolRegistry();
3758
+ const functionName = trimString(opts.function || '');
3759
+
3760
+ if (!functionName) {
3761
+ const payload = {
3762
+ note: STATIC_TOOL_SCHEMA_NOTE,
3763
+ schemas: tools
3764
+ .filter((tool) => tool.schemaAvailable)
3765
+ .map((tool) => ({
3766
+ function: tool.name,
3767
+ summary: tool.schemaSummary,
3768
+ details: getToolSchemaHint(tool.name),
3769
+ })),
3770
+ };
3771
+
3772
+ if (opts.json) {
3773
+ emitJsonOutput(payload, opts.pretty);
3774
+ return;
3775
+ }
3776
+
3777
+ process.stdout.write('[socialseal] Tool schema hints\n');
3778
+ process.stdout.write(`[socialseal] ${payload.note}\n`);
3779
+ for (const schemaEntry of payload.schemas) {
3780
+ process.stdout.write(`- ${schemaEntry.function}: ${schemaEntry.summary}\n`);
3781
+ process.stdout.write(` command: socialseal tools schema --function ${schemaEntry.function}\n`);
3782
+ }
3783
+ return;
3784
+ }
3785
+
3786
+ const knownTool = getKnownTool(functionName);
3787
+ if (!knownTool) {
3788
+ throw new CliError(`Unknown tool: ${functionName}`, {
3789
+ code: 'INVALID_ARGUMENT',
3790
+ exitCode: EXIT_CODES.USAGE,
3791
+ hint: 'Run `socialseal tools list` to discover available tool names.',
3792
+ });
3793
+ }
3794
+
3795
+ const schema = getToolSchemaHint(functionName);
3796
+ if (!schema) {
3797
+ throw new CliError(`No schema hint is bundled for ${functionName}.`, {
3798
+ code: 'SCHEMA_NOT_AVAILABLE',
3799
+ exitCode: EXIT_CODES.NOT_FOUND,
3800
+ hint: 'Use `socialseal tools call --function <tool> --body @payload.json` and inspect backend validation errors for this tool.',
3801
+ });
3802
+ }
3803
+
3804
+ const payload = {
3805
+ function: functionName,
3806
+ note: STATIC_TOOL_SCHEMA_NOTE,
3807
+ schema,
3808
+ };
3809
+
3810
+ if (opts.json) {
3811
+ emitJsonOutput(payload, opts.pretty);
3812
+ return;
3813
+ }
3814
+
3815
+ process.stdout.write(`[socialseal] Tool schema: ${functionName}\n`);
3816
+ process.stdout.write(`[socialseal] ${payload.note}\n`);
3817
+ process.stdout.write(`summary: ${schema.summary}\n`);
3818
+ if (Array.isArray(schema.operations) && schema.operations.length > 0) {
3819
+ process.stdout.write('operations:\n');
3820
+ for (const operation of schema.operations) {
3821
+ process.stdout.write(`- ${operation.action}\n`);
3822
+ if (Array.isArray(operation.required) && operation.required.length > 0) {
3823
+ process.stdout.write(` required: ${operation.required.join(', ')}\n`);
3824
+ }
3825
+ if (Array.isArray(operation.optional) && operation.optional.length > 0) {
3826
+ process.stdout.write(` optional: ${operation.optional.join(', ')}\n`);
3827
+ }
3828
+ if (operation.notes) {
3829
+ process.stdout.write(` note: ${operation.notes}\n`);
3830
+ }
3831
+ if (operation.example) {
3832
+ process.stdout.write(` example body: ${JSON.stringify(operation.example)}\n`);
3833
+ }
3834
+ }
2581
3835
  }
3836
+ if (Array.isArray(schema.cliExamples) && schema.cliExamples.length > 0) {
3837
+ process.stdout.write('cli examples:\n');
3838
+ for (const example of schema.cliExamples) {
3839
+ process.stdout.write(`- ${example}\n`);
3840
+ }
3841
+ }
3842
+ }
2582
3843
 
2583
- process.stdout.write('\n[socialseal] Call a tool with: socialseal tools call --function <name> --body @payload.json\n');
3844
+ async function handleToolsStatus(opts) {
3845
+ const config = loadConfig();
3846
+ const apiKey = requireApiKey(opts, config);
3847
+ const apiBase = resolveApiBase(opts, config);
3848
+ const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
3849
+ const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
3850
+ const timeoutMs = resolveTimeoutMs(opts, config);
3851
+ const pollIntervalMs = resolvePollIntervalMs(opts);
3852
+ const includeResults = opts.includeResults === true;
3853
+ const resultsLimit = resolveStatusResultsLimit(opts.resultsLimit);
3854
+ const kind = parseToolStatusKind(opts.kind);
3855
+ const identifier = normalizeStatusIdentifier(opts.id);
3856
+ const { workspaceId } = resolveWorkspaceSelection(opts, config);
3857
+
3858
+ const loadStatus = async () =>
3859
+ await resolveUnifiedToolStatus({
3860
+ apiBase: resolvedApiBase,
3861
+ apiKey,
3862
+ useGateway,
3863
+ legacyUrl,
3864
+ timeoutMs,
3865
+ identifier,
3866
+ kind,
3867
+ workspaceId,
3868
+ includeResults,
3869
+ resultsLimit,
3870
+ });
3871
+
3872
+ let result = await loadStatus();
3873
+ if (opts.wait) {
3874
+ result = await pollUnifiedStatus({
3875
+ loader: loadStatus,
3876
+ timeoutMs,
3877
+ pollIntervalMs,
3878
+ opts,
3879
+ });
3880
+ }
3881
+
3882
+ const commandHint = buildStatusCommandHint(result, workspaceId);
3883
+ const payload = {
3884
+ ...result,
3885
+ hint: commandHint,
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
+ }
3895
+ emitJsonOutput(payload, opts.pretty);
2584
3896
  }
2585
3897
 
2586
3898
  async function handleDataExportTracking(opts) {
@@ -2733,46 +4045,27 @@ async function handleDataExportReport(opts) {
2733
4045
  process.stdout.write(JSON.stringify(json, null, opts.pretty ? 2 : 0) + '\n');
2734
4046
  }
2735
4047
 
2736
- async function handleDataExportSearchResults(opts) {
2737
- const config = loadConfig();
2738
- const apiKey = requireApiKey(opts, config);
2739
- const apiBase = resolveApiBase(opts, config);
2740
- const supabaseUrl = resolveLegacyUrl(resolveSupabaseUrl(opts, config), 'SOCIALSEAL_SUPABASE_URL');
2741
- const { resolvedApiBase, legacyUrl, useGateway } = resolveApiTarget({ apiBase, legacyUrl: supabaseUrl });
2742
- const timeoutMs = resolveTimeoutMs(opts, config);
2743
- const { workspaceId: resolvedWorkspaceId, source: workspaceSource } = resolveWorkspaceSelection(opts, config);
2744
-
2745
- const rawPayload = opts.__rawPayload ?? stripUndefinedEntries({
2746
- groupIds: normalizePositiveIntegerList(opts.groupIds, 'groupIds', { max: 100 }),
2747
- trackingItemIds: normalizePositiveIntegerList(opts.trackingItemIds, 'trackingItemIds', { max: 1000 }),
2748
- dateFrom: opts.dateFrom,
2749
- dateTo: opts.dateTo,
2750
- filename: opts.filename,
2751
- });
2752
-
2753
- const payloadWorkspaceId = resolvePayloadWorkspaceId(rawPayload, null);
2754
- const effectiveWorkspaceId = requireWorkspaceSelection(payloadWorkspaceId || resolvedWorkspaceId, {
2755
- label: 'Search results enriched export',
2756
- hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, or configure a default workspace before exporting.',
2757
- });
2758
- const effectiveWorkspaceSource = payloadWorkspaceId ? 'body' : workspaceSource;
2759
- emitWorkspaceSelectionNotice(opts, {
2760
- workspaceId: effectiveWorkspaceId,
2761
- source: effectiveWorkspaceSource,
2762
- label: 'search_results_enriched export',
2763
- });
2764
-
2765
- const normalizedPayload = buildSearchResultsEnrichedExportPayload(rawPayload, effectiveWorkspaceId);
2766
- const requestedFilename = trimString(normalizedPayload.filename) || undefined;
2767
- delete normalizedPayload.filename;
2768
-
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
+ }) {
2769
4062
  const exportResponse = await callApi({
2770
- apiBase: useGateway ? resolvedApiBase : legacyUrl,
4063
+ apiBase: useGateway ? apiBase : legacyUrl,
2771
4064
  apiKey,
2772
4065
  path: useGateway ? '/cli/tools/export-data' : '/functions/v1/export-data',
2773
4066
  method: 'POST',
2774
4067
  body: {
2775
- template: EXPORT_DATA_TEMPLATE_TRACKING_RANKED_VIDEOS_RAW,
4068
+ template,
2776
4069
  format: 'csv',
2777
4070
  payload: normalizedPayload,
2778
4071
  filename: requestedFilename,
@@ -2783,13 +4076,16 @@ async function handleDataExportSearchResults(opts) {
2783
4076
 
2784
4077
  if (!exportResponse.ok) {
2785
4078
  throw await buildHttpError(exportResponse, {
2786
- label: 'Search results enriched export',
4079
+ label,
2787
4080
  functionName: 'export-data',
2788
4081
  method: 'POST',
2789
4082
  });
2790
4083
  }
2791
4084
 
2792
4085
  const responseJson = await exportResponse.json();
4086
+ const decoratedResponse = typeof decorateResponse === 'function'
4087
+ ? decorateResponse(responseJson)
4088
+ : responseJson;
2793
4089
  const metadata = isJsonObject(responseJson) && isJsonObject(responseJson.metadata)
2794
4090
  ? responseJson.metadata
2795
4091
  : null;
@@ -2804,7 +4100,7 @@ async function handleDataExportSearchResults(opts) {
2804
4100
  } else {
2805
4101
  process.stderr.write('[socialseal] Export did not include a file URL yet. Inspect the JSON metadata and retry if needed.\n');
2806
4102
  }
2807
- emitJsonOutput(responseJson, opts.pretty);
4103
+ emitJsonOutput(decoratedResponse, opts.pretty);
2808
4104
  return;
2809
4105
  }
2810
4106
 
@@ -2815,7 +4111,7 @@ async function handleDataExportSearchResults(opts) {
2815
4111
 
2816
4112
  if (!artifactResponse.ok) {
2817
4113
  throw await buildHttpError(artifactResponse, {
2818
- label: 'Search results enriched artifact download',
4114
+ label: `${label} artifact download`,
2819
4115
  method: 'GET',
2820
4116
  hint: 'The signed file URL may be expired or inaccessible. Re-run the export command to mint a fresh URL.',
2821
4117
  });
@@ -2830,7 +4126,7 @@ async function handleDataExportSearchResults(opts) {
2830
4126
 
2831
4127
  const outPath = opts.stdout
2832
4128
  ? null
2833
- : (opts.out || trimString(metadata?.filename || '') || 'tracking-ranked-videos.csv');
4129
+ : (opts.out || trimString(metadata?.filename || '') || `${template}.csv`);
2834
4130
  if (outPath) {
2835
4131
  await pipeline(artifactResponse.body, fs.createWriteStream(outPath));
2836
4132
  process.stderr.write(`[socialseal] Export written to ${outPath}\n`);
@@ -2839,6 +4135,261 @@ async function handleDataExportSearchResults(opts) {
2839
4135
  }
2840
4136
  }
2841
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
+
2842
4393
  function handleDataExportOptions(opts) {
2843
4394
  const payload = {
2844
4395
  exports: EXPORT_OPTIONS,
@@ -3200,7 +4751,7 @@ if (typeof program.showHelpAfterError === 'function') {
3200
4751
  if (typeof program.showSuggestionAfterError === 'function') {
3201
4752
  program.showSuggestionAfterError(true);
3202
4753
  }
3203
- 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-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`);
3204
4755
 
3205
4756
  program
3206
4757
  .command('agent')
@@ -3273,6 +4824,15 @@ tools
3273
4824
  .option('--verbose', 'Show error details')
3274
4825
  .action((opts) => runCommand(handleToolsList, opts));
3275
4826
 
4827
+ tools
4828
+ .command('schema')
4829
+ .description('Show static payload schema hints and examples for a tool')
4830
+ .option('--function <name>', 'Tool name (omit to list all schema hints)')
4831
+ .option('--json', 'Emit machine-readable output')
4832
+ .option('--pretty', 'Pretty-print JSON')
4833
+ .option('--verbose', 'Show error details')
4834
+ .action((opts) => runCommand(handleToolsSchema, opts));
4835
+
3276
4836
  tools
3277
4837
  .command('call')
3278
4838
  .requiredOption('--function <name>', 'Tool name (see official docs)')
@@ -3290,6 +4850,23 @@ tools
3290
4850
  .option('--verbose', 'Show error details')
3291
4851
  .action((opts) => runCommand(handleToolsCall, opts));
3292
4852
 
4853
+ tools
4854
+ .command('status <id>')
4855
+ .description('Read unified status for UUID jobs, journey run UUIDs, or numeric Google AI run ids')
4856
+ .option('--kind <kind>', 'auto|agent_job|google_ai_run|journey_run', 'auto')
4857
+ .option('--wait', 'Poll until status reaches a terminal state')
4858
+ .option('--poll-interval <ms>', 'Polling interval in milliseconds when --wait is enabled')
4859
+ .option('--include-results', 'Include Google AI summary/citation rows when reading numeric run ids')
4860
+ .option('--results-limit <n>', 'Max Google AI summary rows to include when --include-results is set')
4861
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
4862
+ .option('--api-key <key>', 'CLI API key')
4863
+ .option('--workspace-id <id>', 'Workspace id (required for journey_run status)')
4864
+ .option('--pretty', 'Pretty-print JSON')
4865
+ .option('--json', 'Emit machine-readable errors')
4866
+ .option('--timeout <ms>', 'Request timeout in milliseconds')
4867
+ .option('--verbose', 'Show error details')
4868
+ .action((id, opts) => runCommand(handleToolsStatus, { ...opts, id }));
4869
+
3293
4870
  const data = program.command('data').description('Data exports (provisional)');
3294
4871
 
3295
4872
  data
@@ -3335,6 +4912,40 @@ data
3335
4912
  .option('--verbose', 'Show error details')
3336
4913
  .action((opts) => runCommand(handleDataExportSearchResults, opts));
3337
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
+
3338
4949
  data
3339
4950
  .command('export-report')
3340
4951
  .description('Export report data via export-report (provisional)')