@socialseal/cli 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.12 - 2026-06-23
6
+
7
+ - Expose Asset Studio and video production tool surfaces through CLI discovery/schema output.
8
+ - Add category-filtered tool discovery for users and agents.
9
+
5
10
  ## 0.1.11 - 2026-06-12
6
11
 
7
12
  - Add ad hoc public video URL analysis parity for queue and extract workflows, including `--url` and `--allow-untracked` support.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socialseal/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SocialSeal CLI (non-interactive)",
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { spawn } from 'node:child_process';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
@@ -8,6 +9,7 @@ import WebSocket from 'ws';
8
9
 
9
10
  const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.config', 'socialseal', 'config.json');
10
11
  const DEFAULT_API_BASE = 'https://api.socialseal.co';
12
+ const DEFAULT_WEB_BASE = 'https://app.socialseal.co';
11
13
  const CLI_KEY_HEADER = 'X-CLI-Key';
12
14
  const WORKSPACE_HEADER = 'X-Workspace-Id';
13
15
  const DEFAULT_TIMEOUT_MS = 300000;
@@ -146,6 +148,94 @@ const KNOWN_TOOLS = [
146
148
  knownLocalDevState: 'enabled',
147
149
  notes: 'Accepts videoId/videoUid/platformVideoId/searchResultId items and public URL items with allowUntracked=true; videoId means video_uid or platform-native video id, not a tracking item id.',
148
150
  },
151
+ {
152
+ name: 'vnext-clips-read',
153
+ category: 'asset-studio',
154
+ description: 'List workspace clip-library items and optionally sign selected source videos.',
155
+ objectType: 'workspace_clip',
156
+ transport: 'post_edge_function',
157
+ workspaceScoped: true,
158
+ knownLocalDevState: 'enabled',
159
+ },
160
+ {
161
+ name: 'vnext-clips-create',
162
+ category: 'asset-studio',
163
+ description: 'Create signed clip upload targets and finalize uploaded clip metadata.',
164
+ objectType: 'workspace_clip',
165
+ transport: 'post_edge_function',
166
+ workspaceScoped: true,
167
+ knownLocalDevState: 'enabled',
168
+ actionAliases: ['create', 'finalize'],
169
+ notes: 'The create action returns signed upload URLs; upload bytes to storage before calling finalize.',
170
+ },
171
+ {
172
+ name: 'vnext-clip-shot-mappings-read',
173
+ category: 'asset-studio',
174
+ description: 'Read clip-to-blueprint shot mappings for Asset Studio.',
175
+ objectType: 'clip_shot_mapping',
176
+ transport: 'post_edge_function',
177
+ workspaceScoped: true,
178
+ knownLocalDevState: 'enabled',
179
+ },
180
+ {
181
+ name: 'vnext-clip-shot-mappings-write',
182
+ category: 'asset-studio',
183
+ description: 'Upsert or delete clip-to-blueprint shot mappings for Asset Studio.',
184
+ objectType: 'clip_shot_mapping',
185
+ transport: 'post_edge_function',
186
+ workspaceScoped: true,
187
+ knownLocalDevState: 'enabled',
188
+ actionAliases: ['upsert', 'delete'],
189
+ },
190
+ {
191
+ name: 'vnext-generated-assets-read',
192
+ category: 'asset-studio',
193
+ description: 'List generated rough cuts for a blueprint or read one generated asset.',
194
+ objectType: 'generated_asset',
195
+ transport: 'post_edge_function',
196
+ workspaceScoped: true,
197
+ knownLocalDevState: 'enabled',
198
+ actionAliases: ['list', 'detail'],
199
+ },
200
+ {
201
+ name: 'vnext-generated-asset-create',
202
+ category: 'asset-studio',
203
+ description: 'Create a generated rough-cut asset from an edit spec.',
204
+ objectType: 'generated_asset',
205
+ transport: 'post_edge_function',
206
+ workspaceScoped: true,
207
+ knownLocalDevState: 'enabled',
208
+ },
209
+ {
210
+ name: 'vnext-generated-asset-optimize',
211
+ category: 'asset-studio',
212
+ description: 'Optimize a generated asset or create a new revision.',
213
+ objectType: 'generated_asset_revision',
214
+ transport: 'post_edge_function',
215
+ workspaceScoped: true,
216
+ knownLocalDevState: 'enabled',
217
+ actionAliases: ['optimize', 'create-revision'],
218
+ },
219
+ {
220
+ name: 'vnext-generated-asset-export',
221
+ category: 'asset-studio',
222
+ description: 'Export a generated rough cut as FCPXML.',
223
+ objectType: 'generated_asset_export',
224
+ transport: 'post_edge_function',
225
+ workspaceScoped: true,
226
+ knownLocalDevState: 'enabled',
227
+ },
228
+ {
229
+ name: 'vnext-generated-asset-share',
230
+ category: 'asset-studio',
231
+ description: 'Create, read, or revoke generated-asset share links.',
232
+ objectType: 'generated_asset_share',
233
+ transport: 'post_edge_function',
234
+ workspaceScoped: false,
235
+ knownLocalDevState: 'enabled',
236
+ actionAliases: ['create', 'read', 'revoke'],
237
+ notes: 'create/revoke require workspaceId in the body; read uses shareToken and does not require workspace scope.',
238
+ },
149
239
  { name: 'douyin-geo-api', category: 'search', description: 'Query Douyin search and geo data.' },
150
240
  {
151
241
  name: 'google-ai-search',
@@ -218,7 +308,34 @@ const KNOWN_TOOLS = [
218
308
  { name: 'vnext-blueprints-create', category: 'vnext', description: 'Create a vNext blueprint from grounded evidence.' },
219
309
  { name: 'vnext-blueprints-generate', category: 'vnext', description: 'Generate a vNext blueprint from workspace opportunity data.' },
220
310
  { name: 'vnext-blueprints-read', category: 'vnext', description: 'Read vNext blueprint history and specific versions.' },
311
+ {
312
+ name: 'vnext-blueprints-shots-read',
313
+ category: 'video-production',
314
+ description: 'Read shot-lift and pinned shot assets for a blueprint.',
315
+ objectType: 'blueprint_shot_asset',
316
+ transport: 'post_edge_function',
317
+ workspaceScoped: true,
318
+ knownLocalDevState: 'enabled',
319
+ },
320
+ {
321
+ name: 'vnext-blueprints-shots-refresh',
322
+ category: 'video-production',
323
+ description: 'Queue a refresh for blueprint shot assets.',
324
+ objectType: 'blueprint_shots_job',
325
+ transport: 'post_edge_function',
326
+ workspaceScoped: true,
327
+ knownLocalDevState: 'enabled',
328
+ },
221
329
  { name: 'vnext-briefs-create', category: 'vnext', description: 'Create a vNext brief record.' },
330
+ {
331
+ name: 'vnext-briefs-export',
332
+ category: 'video-production',
333
+ description: 'Export a generated vNext brief as markdown.',
334
+ objectType: 'vnext_brief_export',
335
+ transport: 'post_edge_function',
336
+ workspaceScoped: true,
337
+ knownLocalDevState: 'enabled',
338
+ },
222
339
  { name: 'vnext-briefs-generate', category: 'vnext', description: 'Generate a vNext brief from a blueprint or opportunity.' },
223
340
  { name: 'vnext-briefs-read', category: 'vnext', description: 'Read generated vNext briefs and version history.' },
224
341
  { name: 'vnext-intents', category: 'vnext', description: 'List, create, update, or delete vNext intents.' },
@@ -451,6 +568,311 @@ const TOOL_SCHEMA_HINTS = {
451
568
  'socialseal tools call --function tracked-video-extract --workspace-id <workspace-uuid> --body \'{"allowUntracked":true,"items":[{"url":"https://www.instagram.com/reel/SHORTCODE/"}]}\'',
452
569
  ],
453
570
  },
571
+ 'vnext-clips-read': {
572
+ summary: 'List Asset Studio clip-library items and optionally sign selected video URLs.',
573
+ operations: [
574
+ {
575
+ action: 'list',
576
+ required: ['workspaceId or --workspace-id'],
577
+ optional: ['videoClipIds[] to include signed source video URLs'],
578
+ example: {
579
+ workspaceId: '00000000-0000-4000-8000-000000000000',
580
+ videoClipIds: ['11111111-1111-4111-8111-111111111111'],
581
+ },
582
+ },
583
+ ],
584
+ cliExamples: [
585
+ 'socialseal tools call --function vnext-clips-read --workspace-id <workspace-uuid> --body \'{"videoClipIds":["<clip-uuid>"]}\'',
586
+ ],
587
+ },
588
+ 'vnext-clips-create': {
589
+ summary: 'Create signed upload targets for clips and finalize uploaded clip metadata.',
590
+ operations: [
591
+ {
592
+ action: 'create',
593
+ required: ['action=create', 'workspaceId or --workspace-id', 'fileName', 'mimeType'],
594
+ optional: [],
595
+ example: {
596
+ action: 'create',
597
+ workspaceId: '00000000-0000-4000-8000-000000000000',
598
+ fileName: 'hero-shot.mp4',
599
+ mimeType: 'video/mp4',
600
+ },
601
+ },
602
+ {
603
+ action: 'finalize',
604
+ required: ['action=finalize', 'workspaceId or --workspace-id', 'clipId', 'fileName', 'storagePath', 'mimeType', 'sizeBytes', 'rightsAttested=true'],
605
+ optional: ['durationSeconds', 'width', 'height', 'posterPath'],
606
+ example: {
607
+ action: 'finalize',
608
+ workspaceId: '00000000-0000-4000-8000-000000000000',
609
+ clipId: '11111111-1111-4111-8111-111111111111',
610
+ fileName: 'hero-shot.mp4',
611
+ storagePath: 'workspace-00000000-0000-4000-8000-000000000000/11111111-1111-4111-8111-111111111111.mp4',
612
+ mimeType: 'video/mp4',
613
+ sizeBytes: 1048576,
614
+ rightsAttested: true,
615
+ },
616
+ },
617
+ ],
618
+ cliExamples: [
619
+ 'socialseal tools call --function vnext-clips-create --workspace-id <workspace-uuid> --body \'{"action":"create","fileName":"hero-shot.mp4","mimeType":"video/mp4"}\'',
620
+ 'socialseal tools call --function vnext-clips-create --workspace-id <workspace-uuid> --body @clip-finalize.json',
621
+ ],
622
+ },
623
+ 'vnext-clip-shot-mappings-read': {
624
+ summary: 'Read Asset Studio clip-to-shot mappings for a blueprint.',
625
+ operations: [
626
+ {
627
+ action: 'read',
628
+ required: ['workspaceId or --workspace-id', 'blueprintId'],
629
+ optional: [],
630
+ example: {
631
+ workspaceId: '00000000-0000-4000-8000-000000000000',
632
+ blueprintId: '22222222-2222-4222-8222-222222222222',
633
+ },
634
+ },
635
+ ],
636
+ cliExamples: [
637
+ 'socialseal tools call --function vnext-clip-shot-mappings-read --workspace-id <workspace-uuid> --body \'{"blueprintId":"<blueprint-uuid>"}\'',
638
+ ],
639
+ },
640
+ 'vnext-clip-shot-mappings-write': {
641
+ summary: 'Upsert or delete Asset Studio clip-to-shot mappings.',
642
+ operations: [
643
+ {
644
+ action: 'upsert',
645
+ required: ['action=upsert', 'workspaceId or --workspace-id', 'blueprintId', 'panelId', 'clipId'],
646
+ optional: ['source (suggested|override)', 'score'],
647
+ example: {
648
+ action: 'upsert',
649
+ workspaceId: '00000000-0000-4000-8000-000000000000',
650
+ blueprintId: '22222222-2222-4222-8222-222222222222',
651
+ panelId: 'panel-1',
652
+ clipId: '11111111-1111-4111-8111-111111111111',
653
+ source: 'override',
654
+ },
655
+ },
656
+ {
657
+ action: 'delete',
658
+ required: ['action=delete', 'workspaceId or --workspace-id', 'blueprintId', 'panelId'],
659
+ optional: [],
660
+ example: {
661
+ action: 'delete',
662
+ workspaceId: '00000000-0000-4000-8000-000000000000',
663
+ blueprintId: '22222222-2222-4222-8222-222222222222',
664
+ panelId: 'panel-1',
665
+ },
666
+ },
667
+ ],
668
+ cliExamples: [
669
+ 'socialseal tools call --function vnext-clip-shot-mappings-write --workspace-id <workspace-uuid> --body \'{"action":"upsert","blueprintId":"<blueprint-uuid>","panelId":"panel-1","clipId":"<clip-uuid>"}\'',
670
+ ],
671
+ },
672
+ 'vnext-generated-assets-read': {
673
+ summary: 'List generated rough cuts for a blueprint or read one generated asset.',
674
+ operations: [
675
+ {
676
+ action: 'list',
677
+ required: ['action=list', 'workspaceId or --workspace-id', 'blueprintId'],
678
+ optional: [],
679
+ example: {
680
+ action: 'list',
681
+ workspaceId: '00000000-0000-4000-8000-000000000000',
682
+ blueprintId: '22222222-2222-4222-8222-222222222222',
683
+ },
684
+ },
685
+ {
686
+ action: 'detail',
687
+ required: ['action=detail', 'workspaceId or --workspace-id', 'assetId'],
688
+ optional: [],
689
+ example: {
690
+ action: 'detail',
691
+ workspaceId: '00000000-0000-4000-8000-000000000000',
692
+ assetId: '33333333-3333-4333-8333-333333333333',
693
+ },
694
+ },
695
+ ],
696
+ cliExamples: [
697
+ 'socialseal tools call --function vnext-generated-assets-read --workspace-id <workspace-uuid> --body \'{"action":"list","blueprintId":"<blueprint-uuid>"}\'',
698
+ 'socialseal tools call --function vnext-generated-assets-read --workspace-id <workspace-uuid> --body \'{"action":"detail","assetId":"<asset-uuid>"}\'',
699
+ ],
700
+ },
701
+ 'vnext-generated-asset-create': {
702
+ summary: 'Create a generated rough cut from an Asset Studio edit spec.',
703
+ operations: [
704
+ {
705
+ action: 'create',
706
+ required: ['workspaceId or --workspace-id', 'blueprintId', 'title', 'editSpec'],
707
+ optional: [],
708
+ example: {
709
+ workspaceId: '00000000-0000-4000-8000-000000000000',
710
+ blueprintId: '22222222-2222-4222-8222-222222222222',
711
+ title: 'Homepage rough cut',
712
+ editSpec: {
713
+ version: 1,
714
+ fps: 30,
715
+ width: 1080,
716
+ height: 1920,
717
+ totalDurationSeconds: 3,
718
+ shots: [
719
+ {
720
+ panelId: 'panel-1',
721
+ clipId: '11111111-1111-4111-8111-111111111111',
722
+ title: 'Opening hook',
723
+ kind: 'hook',
724
+ shotLabel: 'Hero exterior',
725
+ sourceStartSeconds: 0,
726
+ durationSeconds: 3,
727
+ evidenceIds: [],
728
+ },
729
+ ],
730
+ },
731
+ },
732
+ },
733
+ ],
734
+ cliExamples: [
735
+ 'socialseal tools call --function vnext-generated-asset-create --workspace-id <workspace-uuid> --body @edit-spec.json',
736
+ ],
737
+ },
738
+ 'vnext-generated-asset-optimize': {
739
+ summary: 'Optimize a generated asset or create a new revision.',
740
+ operations: [
741
+ {
742
+ action: 'optimize',
743
+ required: ['action=optimize', 'workspaceId or --workspace-id', 'assetId'],
744
+ optional: [],
745
+ example: {
746
+ action: 'optimize',
747
+ workspaceId: '00000000-0000-4000-8000-000000000000',
748
+ assetId: '33333333-3333-4333-8333-333333333333',
749
+ },
750
+ },
751
+ {
752
+ action: 'create-revision',
753
+ required: ['action=create-revision', 'workspaceId or --workspace-id', 'assetId'],
754
+ optional: [],
755
+ example: {
756
+ action: 'create-revision',
757
+ workspaceId: '00000000-0000-4000-8000-000000000000',
758
+ assetId: '33333333-3333-4333-8333-333333333333',
759
+ },
760
+ },
761
+ ],
762
+ cliExamples: [
763
+ 'socialseal tools call --function vnext-generated-asset-optimize --workspace-id <workspace-uuid> --body \'{"action":"optimize","assetId":"<asset-uuid>"}\'',
764
+ ],
765
+ },
766
+ 'vnext-generated-asset-export': {
767
+ summary: 'Export a generated rough cut as FCPXML.',
768
+ operations: [
769
+ {
770
+ action: 'export',
771
+ required: ['workspaceId or --workspace-id', 'assetId'],
772
+ optional: ['format=fcpxml'],
773
+ example: {
774
+ workspaceId: '00000000-0000-4000-8000-000000000000',
775
+ assetId: '33333333-3333-4333-8333-333333333333',
776
+ format: 'fcpxml',
777
+ },
778
+ },
779
+ ],
780
+ cliExamples: [
781
+ 'socialseal tools call --function vnext-generated-asset-export --workspace-id <workspace-uuid> --body \'{"assetId":"<asset-uuid>","format":"fcpxml"}\'',
782
+ ],
783
+ },
784
+ 'vnext-generated-asset-share': {
785
+ summary: 'Create, read, or revoke generated rough-cut share links.',
786
+ operations: [
787
+ {
788
+ action: 'create',
789
+ required: ['action=create', 'workspaceId', 'assetId'],
790
+ optional: ['ttlSeconds', 'shareBaseUrl'],
791
+ example: {
792
+ action: 'create',
793
+ workspaceId: '00000000-0000-4000-8000-000000000000',
794
+ assetId: '33333333-3333-4333-8333-333333333333',
795
+ ttlSeconds: 604800,
796
+ },
797
+ },
798
+ {
799
+ action: 'read',
800
+ required: ['action=read', 'shareToken'],
801
+ optional: [],
802
+ example: {
803
+ action: 'read',
804
+ shareToken: '0123456789abcdef0123456789abcdef',
805
+ },
806
+ },
807
+ {
808
+ action: 'revoke',
809
+ required: ['action=revoke', 'workspaceId', 'shareLinkId'],
810
+ optional: [],
811
+ example: {
812
+ action: 'revoke',
813
+ workspaceId: '00000000-0000-4000-8000-000000000000',
814
+ shareLinkId: '44444444-4444-4444-8444-444444444444',
815
+ },
816
+ },
817
+ ],
818
+ cliExamples: [
819
+ 'socialseal tools call --function vnext-generated-asset-share --body \'{"action":"create","workspaceId":"<workspace-uuid>","assetId":"<asset-uuid>"}\'',
820
+ 'socialseal tools call --function vnext-generated-asset-share --body \'{"action":"read","shareToken":"<share-token>"}\'',
821
+ ],
822
+ },
823
+ 'vnext-blueprints-shots-read': {
824
+ summary: 'Read blueprint shot-lift rows and pinned shot assets with signed URLs.',
825
+ operations: [
826
+ {
827
+ action: 'read',
828
+ required: ['workspaceId or --workspace-id', 'blueprintId'],
829
+ optional: ['signedUrlSeconds'],
830
+ example: {
831
+ workspaceId: '00000000-0000-4000-8000-000000000000',
832
+ blueprintId: '22222222-2222-4222-8222-222222222222',
833
+ signedUrlSeconds: 3600,
834
+ },
835
+ },
836
+ ],
837
+ cliExamples: [
838
+ 'socialseal tools call --function vnext-blueprints-shots-read --workspace-id <workspace-uuid> --body \'{"blueprintId":"<blueprint-uuid>"}\'',
839
+ ],
840
+ },
841
+ 'vnext-blueprints-shots-refresh': {
842
+ summary: 'Queue a refresh for blueprint shot assets.',
843
+ operations: [
844
+ {
845
+ action: 'refresh',
846
+ required: ['workspaceId or --workspace-id', 'blueprintId'],
847
+ optional: [],
848
+ example: {
849
+ workspaceId: '00000000-0000-4000-8000-000000000000',
850
+ blueprintId: '22222222-2222-4222-8222-222222222222',
851
+ },
852
+ },
853
+ ],
854
+ cliExamples: [
855
+ 'socialseal tools call --function vnext-blueprints-shots-refresh --workspace-id <workspace-uuid> --body \'{"blueprintId":"<blueprint-uuid>"}\'',
856
+ ],
857
+ },
858
+ 'vnext-briefs-export': {
859
+ summary: 'Export the latest or selected generated vNext brief as markdown.',
860
+ operations: [
861
+ {
862
+ action: 'export',
863
+ required: ['workspaceId or --workspace-id', 'opportunityKey'],
864
+ optional: ['version'],
865
+ example: {
866
+ workspaceId: '00000000-0000-4000-8000-000000000000',
867
+ opportunityKey: 'opportunity-key',
868
+ version: 1,
869
+ },
870
+ },
871
+ ],
872
+ cliExamples: [
873
+ 'socialseal tools call --function vnext-briefs-export --workspace-id <workspace-uuid> --body \'{"opportunityKey":"<opportunity-key>"}\'',
874
+ ],
875
+ },
454
876
  'group-management': {
455
877
  summary: 'Manage single-platform tracking groups and memberships.',
456
878
  operations: [
@@ -544,6 +966,18 @@ function buildToolRegistry() {
544
966
  });
545
967
  }
546
968
 
969
+ function filterToolRegistry(tools, category) {
970
+ const normalizedCategory = trimString(category).toLowerCase();
971
+ const filtered = normalizedCategory
972
+ ? tools.filter((tool) => trimString(tool.category).toLowerCase() === normalizedCategory)
973
+ : tools;
974
+ return [...filtered].sort((a, b) => {
975
+ const categoryCompare = trimString(a.category).localeCompare(trimString(b.category));
976
+ if (categoryCompare !== 0) return categoryCompare;
977
+ return trimString(a.name).localeCompare(trimString(b.name));
978
+ });
979
+ }
980
+
547
981
  function getConfigPath() {
548
982
  return process.env.SOCIALSEAL_CONFIG || DEFAULT_CONFIG_PATH;
549
983
  }
@@ -586,9 +1020,36 @@ function saveConfig(config) {
586
1020
  Object.entries(config || {}).filter(([, value]) => value !== undefined),
587
1021
  );
588
1022
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
589
- fs.writeFileSync(configPath, `${JSON.stringify(normalizedConfig, null, 2)}\n`);
1023
+ fs.writeFileSync(configPath, `${JSON.stringify(normalizedConfig, null, 2)}\n`, {
1024
+ mode: 0o600,
1025
+ });
1026
+ fs.chmodSync(configPath, 0o600);
590
1027
  }
591
1028
 
1029
+ function assertConfigWritable() {
1030
+ const configPath = getConfigPath();
1031
+ const configDir = path.dirname(configPath);
1032
+ fs.mkdirSync(configDir, { recursive: true });
1033
+ const probePath = path.join(configDir, `.socialseal-write-test-${process.pid}-${Date.now()}`);
1034
+ try {
1035
+ fs.writeFileSync(probePath, '', { mode: 0o600 });
1036
+ fs.unlinkSync(probePath);
1037
+ } catch (error) {
1038
+ try {
1039
+ if (fs.existsSync(probePath)) fs.unlinkSync(probePath);
1040
+ } catch {
1041
+ // best effort cleanup only
1042
+ }
1043
+ throw new CliError(`Cannot write SocialSeal config at ${configPath}.`, {
1044
+ code: 'CONFIG_NOT_WRITABLE',
1045
+ exitCode: EXIT_CODES.USAGE,
1046
+ hint: 'Set SOCIALSEAL_CONFIG to a writable path, or set SOCIALSEAL_API_KEY manually.',
1047
+ details: error?.message || String(error),
1048
+ });
1049
+ }
1050
+ }
1051
+
1052
+
592
1053
  function resolveApiKey(opts, config) {
593
1054
  return opts.apiKey || process.env.SOCIALSEAL_API_KEY || config.apiKey;
594
1055
  }
@@ -605,6 +1066,10 @@ function resolveSupabaseUrl(opts, config) {
605
1066
  return opts.supabaseUrl || process.env.SOCIALSEAL_SUPABASE_URL || config.supabaseUrl;
606
1067
  }
607
1068
 
1069
+ function resolveWebBase(opts = {}, config = {}) {
1070
+ return opts.webBase || process.env.SOCIALSEAL_WEB_BASE || config.webBase || DEFAULT_WEB_BASE;
1071
+ }
1072
+
608
1073
  function resolveWorkspaceSelection(opts, config) {
609
1074
  if (typeof opts.workspaceId === 'string' && opts.workspaceId.trim().length > 0) {
610
1075
  return { workspaceId: opts.workspaceId.trim(), source: 'flag' };
@@ -1164,6 +1629,23 @@ function resolvePayloadWorkspaceId(payload, fallbackWorkspaceId) {
1164
1629
  return fallbackWorkspaceId || null;
1165
1630
  }
1166
1631
 
1632
+ function isGeneratedAssetShareScopedAction(functionName, payload) {
1633
+ if (functionName !== 'vnext-generated-asset-share' || !isJsonObject(payload)) {
1634
+ return false;
1635
+ }
1636
+ const action = trimString(payload.action).toLowerCase();
1637
+ return action === 'create' || action === 'revoke';
1638
+ }
1639
+
1640
+ function shouldRequireToolWorkspace(functionName, payload) {
1641
+ const tool = getKnownTool(functionName);
1642
+ const category = trimString(tool?.category).toLowerCase();
1643
+ return (
1644
+ Boolean(tool?.workspaceScoped) &&
1645
+ (category === 'asset-studio' || category === 'video-production')
1646
+ ) || isGeneratedAssetShareScopedAction(functionName, payload);
1647
+ }
1648
+
1167
1649
  function isUuidLike(value) {
1168
1650
  return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value.trim());
1169
1651
  }
@@ -2700,7 +3182,9 @@ function buildStatusHint(status, context = {}) {
2700
3182
  switch (status) {
2701
3183
  case 401:
2702
3184
  case 403:
2703
- return 'Check your CLI key and workspace access.';
3185
+ return 'Authentication failed. Run `socialseal login`, or check your CLI key and workspace access.';
3186
+ case 402:
3187
+ return 'Your free credits or quota may be exhausted. Run `socialseal billing` to open billing and credits options.';
2704
3188
  case 404:
2705
3189
  if (context.functionName) {
2706
3190
  if (isLocallyDisabledByDefaultFunction(context.functionName)) {
@@ -2714,6 +3198,9 @@ function buildStatusHint(status, context = {}) {
2714
3198
  case 422:
2715
3199
  return 'Validation error. Review the JSON payload schema. For tracking/group tools, prefer the CLI action aliases or the documented REST semantics.';
2716
3200
  default:
3201
+ if (context.billingRelated) {
3202
+ return 'Run `socialseal billing` to open billing and credits options.';
3203
+ }
2717
3204
  return null;
2718
3205
  }
2719
3206
  }
@@ -2740,7 +3227,9 @@ async function buildHttpError(res, context = {}) {
2740
3227
 
2741
3228
  const label = context.label || 'Request';
2742
3229
  const statusText = res.statusText ? ` ${res.statusText}` : '';
2743
- const hint = context.hint || buildStatusHint(status, context);
3230
+ const serializedDetails = typeof details === 'string' ? details : JSON.stringify(details);
3231
+ const billingRelated = /\b(credit|credits|quota|billing|entitlement|payment|plan)\b/i.test(serializedDetails || '');
3232
+ const hint = context.hint || buildStatusHint(status, { ...context, billingRelated });
2744
3233
 
2745
3234
  return new CliError(`${label} failed: ${status}${statusText}`.trim(), {
2746
3235
  code: 'HTTP_ERROR',
@@ -3224,9 +3713,10 @@ function coerceCliError(err, fallbackMessage = 'Command failed') {
3224
3713
  function requireApiKey(opts, config) {
3225
3714
  const apiKey = resolveApiKey(opts, config);
3226
3715
  if (!apiKey) {
3227
- throw new CliError('Missing API key. Set SOCIALSEAL_API_KEY or --api-key.', {
3716
+ throw new CliError('Missing API key. Run `socialseal login` to connect this CLI.', {
3228
3717
  code: 'MISSING_API_KEY',
3229
- exitCode: EXIT_CODES.USAGE,
3718
+ exitCode: EXIT_CODES.AUTH,
3719
+ hint: 'Run `socialseal login`, or set SOCIALSEAL_API_KEY if you already have a key.',
3230
3720
  });
3231
3721
  }
3232
3722
  return apiKey;
@@ -3756,6 +4246,26 @@ async function handleToolsCall(opts) {
3756
4246
  });
3757
4247
  }
3758
4248
 
4249
+ const scopedPayload = isJsonObject(translated.normalizedPayload)
4250
+ ? translated.normalizedPayload
4251
+ : (isJsonObject(translated.body) ? translated.body : payload);
4252
+ const hasSpecialWorkspaceHandling = new Set([
4253
+ 'group-management',
4254
+ 'export_tracking_data',
4255
+ 'tracked-video-extract',
4256
+ ]).has(opts.function);
4257
+ if (!hasSpecialWorkspaceHandling && shouldRequireToolWorkspace(opts.function, scopedPayload)) {
4258
+ requireWorkspaceSelection(effectiveWorkspaceId, {
4259
+ label: opts.function,
4260
+ hint: 'Pass --workspace-id, set SOCIALSEAL_WORKSPACE_ID, include workspaceId in the body, or configure a default workspace.',
4261
+ });
4262
+ emitWorkspaceSelectionNotice(opts, {
4263
+ workspaceId: effectiveWorkspaceId,
4264
+ source: effectiveWorkspaceSource,
4265
+ label: opts.function,
4266
+ });
4267
+ }
4268
+
3759
4269
  emitTrackingCreateScopeWarning(
3760
4270
  isJsonObject(translated.normalizedPayload) ? trimString(translated.normalizedPayload.action).toLowerCase() : '',
3761
4271
  effectiveWorkspaceId,
@@ -3854,9 +4364,10 @@ async function handleToolsCall(opts) {
3854
4364
  }
3855
4365
 
3856
4366
  function handleToolsList(opts) {
3857
- const tools = buildToolRegistry();
4367
+ const tools = filterToolRegistry(buildToolRegistry(), opts.category);
3858
4368
  const payload = {
3859
4369
  discovery: 'built_in_registry',
4370
+ category: trimString(opts.category) || null,
3860
4371
  tools,
3861
4372
  note: STATIC_TOOL_REGISTRY_NOTE,
3862
4373
  schemaNote: STATIC_TOOL_SCHEMA_NOTE,
@@ -3868,6 +4379,9 @@ function handleToolsList(opts) {
3868
4379
  }
3869
4380
 
3870
4381
  process.stdout.write('[socialseal] Built-in tool registry\n');
4382
+ if (payload.category) {
4383
+ process.stdout.write(`[socialseal] Category filter: ${payload.category}\n`);
4384
+ }
3871
4385
  process.stdout.write(`[socialseal] ${payload.note}\n`);
3872
4386
  process.stdout.write(`[socialseal] ${payload.schemaNote}\n`);
3873
4387
 
@@ -4756,6 +5270,258 @@ async function handleVideoQueueAnalysis(opts) {
4756
5270
  emitJsonOutput(payload, opts.pretty);
4757
5271
  }
4758
5272
 
5273
+ function maskApiKey(apiKey) {
5274
+ const key = typeof apiKey === 'string' ? apiKey.trim() : '';
5275
+ if (!key) return null;
5276
+ return `…${key.slice(-6)}`;
5277
+ }
5278
+
5279
+ function openBrowser(url, onError) {
5280
+ const platform = process.platform;
5281
+ const command = platform === 'darwin'
5282
+ ? 'open'
5283
+ : platform === 'win32'
5284
+ ? 'cmd'
5285
+ : 'xdg-open';
5286
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
5287
+ const child = spawn(command, args, {
5288
+ detached: true,
5289
+ stdio: 'ignore',
5290
+ });
5291
+ child.on('error', (error) => {
5292
+ if (typeof onError === 'function') onError(error);
5293
+ });
5294
+ child.unref();
5295
+ }
5296
+
5297
+ async function callPublicApi({ apiBase, path: requestPath, method = 'POST', body, timeoutMs }) {
5298
+ if (!apiBase) {
5299
+ throw new CliError('Missing API base. Set SOCIALSEAL_API_BASE or --api-base.', {
5300
+ code: 'MISSING_API_BASE',
5301
+ exitCode: EXIT_CODES.USAGE,
5302
+ });
5303
+ }
5304
+ const normalizedMethod = normalizeMethod(method);
5305
+ const url = `${apiBase.replace(/\/$/, '')}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`;
5306
+ const hasBody = body !== undefined && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD';
5307
+ return fetchWithTimeout(url, {
5308
+ method: normalizedMethod,
5309
+ headers: {
5310
+ Accept: 'application/json',
5311
+ ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
5312
+ },
5313
+ body: hasBody ? JSON.stringify(body ?? {}) : undefined,
5314
+ }, timeoutMs ?? DEFAULT_TIMEOUT_MS);
5315
+ }
5316
+
5317
+ async function readJsonResponse(res, label) {
5318
+ const contentType = res.headers.get('content-type') || '';
5319
+ if (!contentType.includes('application/json')) {
5320
+ throw new CliError(`${label} returned a non-JSON response.`, {
5321
+ code: 'INVALID_RESPONSE',
5322
+ exitCode: EXIT_CODES.SERVER,
5323
+ });
5324
+ }
5325
+ return res.json();
5326
+ }
5327
+
5328
+ async function handleLogin(opts) {
5329
+ const config = loadConfig();
5330
+ const apiBase = resolveApiBase(opts, config) || DEFAULT_API_BASE;
5331
+ const timeoutMs = resolveTimeoutMs(opts, config);
5332
+ assertConfigWritable();
5333
+ const authorizeRes = await callPublicApi({
5334
+ apiBase,
5335
+ path: '/cli/device/authorize',
5336
+ body: {
5337
+ clientId: '@socialseal/cli',
5338
+ clientName: 'SocialSeal CLI',
5339
+ scopes: { cli: true },
5340
+ },
5341
+ timeoutMs,
5342
+ });
5343
+
5344
+ if (!authorizeRes.ok) {
5345
+ throw await buildHttpError(authorizeRes, { label: 'Device authorization start' });
5346
+ }
5347
+
5348
+ const authorizePayload = await readJsonResponse(authorizeRes, 'Device authorization start');
5349
+ const verificationUrl = authorizePayload.verification_uri_complete || authorizePayload.verification_uri;
5350
+ const deviceCode = authorizePayload.device_code;
5351
+ const userCode = authorizePayload.user_code;
5352
+ if (!verificationUrl || !deviceCode || !userCode) {
5353
+ throw new CliError('Device authorization start returned an incomplete response.', {
5354
+ code: 'INVALID_RESPONSE',
5355
+ exitCode: EXIT_CODES.SERVER,
5356
+ });
5357
+ }
5358
+
5359
+ if (!opts.json) {
5360
+ process.stdout.write(`[socialseal] Open this URL to approve login: ${verificationUrl}\n`);
5361
+ process.stdout.write(`[socialseal] Confirm code: ${userCode}\n`);
5362
+ }
5363
+
5364
+ if (opts.open !== false) {
5365
+ openBrowser(String(verificationUrl), (error) => {
5366
+ if (opts.verbose) {
5367
+ process.stderr.write(`[socialseal] Could not open browser automatically: ${error.message || error}\n`);
5368
+ }
5369
+ });
5370
+ }
5371
+
5372
+ const startedAt = Date.now();
5373
+ let intervalMs = Math.max(1000, Number(authorizePayload.interval || 5) * 1000);
5374
+ if (opts.pollInterval) {
5375
+ intervalMs = parseTimeoutMs(opts.pollInterval, { defaultValue: intervalMs, label: 'poll interval' });
5376
+ }
5377
+
5378
+ while (Date.now() - startedAt < timeoutMs) {
5379
+ await sleep(intervalMs);
5380
+ const tokenRes = await callPublicApi({
5381
+ apiBase,
5382
+ path: '/cli/device/token',
5383
+ body: { device_code: deviceCode },
5384
+ timeoutMs: Math.max(1000, timeoutMs - (Date.now() - startedAt)),
5385
+ });
5386
+ const tokenPayload = await readJsonResponse(tokenRes, 'Device token poll');
5387
+
5388
+ if (tokenRes.ok) {
5389
+ const apiKey = typeof tokenPayload.api_key === 'string' ? tokenPayload.api_key : '';
5390
+ if (!apiKey) {
5391
+ throw new CliError('Device token poll returned no API key.', {
5392
+ code: 'INVALID_RESPONSE',
5393
+ exitCode: EXIT_CODES.SERVER,
5394
+ });
5395
+ }
5396
+
5397
+ const workspaceId = typeof tokenPayload.workspace_id === 'string' ? tokenPayload.workspace_id : config.workspaceId;
5398
+ saveConfig({
5399
+ ...config,
5400
+ apiBase,
5401
+ apiKey,
5402
+ workspaceId,
5403
+ });
5404
+
5405
+ const payload = {
5406
+ success: true,
5407
+ apiBase,
5408
+ keySuffix: apiKey.slice(-6),
5409
+ key: maskApiKey(apiKey),
5410
+ workspaceId: workspaceId || null,
5411
+ configPath: getConfigPath(),
5412
+ };
5413
+ if (opts.json) {
5414
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5415
+ return;
5416
+ }
5417
+
5418
+ process.stdout.write(`[socialseal] Login complete. Stored key ${maskApiKey(apiKey)} in ${getConfigPath()}\n`);
5419
+ if (workspaceId) {
5420
+ process.stdout.write(`[socialseal] Default workspace set to ${workspaceId}\n`);
5421
+ }
5422
+ return;
5423
+ }
5424
+
5425
+ if (tokenPayload?.error === 'authorization_pending') {
5426
+ if (!opts.json) process.stdout.write('[socialseal] Waiting for browser approval…\n');
5427
+ continue;
5428
+ }
5429
+ if (tokenPayload?.error === 'slow_down') {
5430
+ intervalMs = Math.min(intervalMs + 5000, 60000);
5431
+ continue;
5432
+ }
5433
+
5434
+ throw await buildHttpError(new Response(JSON.stringify(tokenPayload), {
5435
+ status: tokenRes.status,
5436
+ statusText: tokenRes.statusText,
5437
+ headers: { 'Content-Type': 'application/json' },
5438
+ }), { label: 'Device token poll' });
5439
+ }
5440
+
5441
+ throw new CliError('Timed out waiting for browser approval.', {
5442
+ code: 'DEVICE_LOGIN_TIMEOUT',
5443
+ exitCode: EXIT_CODES.AUTH,
5444
+ hint: 'Run `socialseal login` again when you are ready to approve in the browser.',
5445
+ });
5446
+ }
5447
+
5448
+ function handleLogout(opts) {
5449
+ const config = loadConfig();
5450
+ const hadApiKey = Boolean(resolveApiKey({}, config));
5451
+ const nextConfig = { ...config };
5452
+ delete nextConfig.apiKey;
5453
+ saveConfig(nextConfig);
5454
+
5455
+ const payload = {
5456
+ success: true,
5457
+ removedLocalKey: hadApiKey,
5458
+ configPath: getConfigPath(),
5459
+ };
5460
+ if (opts.json) {
5461
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5462
+ return;
5463
+ }
5464
+ process.stdout.write('[socialseal] Logged out locally. Any server-side key remains revocable from SocialSeal settings.\n');
5465
+ }
5466
+
5467
+ async function handleWhoami(opts) {
5468
+ const config = loadConfig();
5469
+ const apiKey = requireApiKey(opts, config);
5470
+ const apiBase = resolveApiBase(opts, config);
5471
+ const { resolvedApiBase } = resolveApiTarget({ apiBase, legacyUrl: null });
5472
+ const timeoutMs = resolveTimeoutMs(opts, config);
5473
+ const directory = await fetchWorkspaceDirectory({
5474
+ apiBase: resolvedApiBase,
5475
+ apiKey,
5476
+ timeoutMs,
5477
+ });
5478
+ const selection = resolveWorkspaceSelection({}, config);
5479
+ const workspaces = Array.isArray(directory.workspaces) ? directory.workspaces : [];
5480
+ const workspace = selection.workspaceId
5481
+ ? workspaces.find((entry) => entry.id === selection.workspaceId) || null
5482
+ : null;
5483
+ const payload = {
5484
+ authenticated: true,
5485
+ apiBase: resolvedApiBase,
5486
+ key: maskApiKey(apiKey),
5487
+ keySuffix: apiKey.slice(-6),
5488
+ effectiveWorkspaceId: selection.workspaceId,
5489
+ effectiveWorkspaceSource: selection.source,
5490
+ workspace,
5491
+ workspaceCount: workspaces.length,
5492
+ };
5493
+
5494
+ if (opts.json) {
5495
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5496
+ return;
5497
+ }
5498
+
5499
+ process.stdout.write(`[socialseal] Authenticated with key ${maskApiKey(apiKey)}\n`);
5500
+ if (workspace) {
5501
+ process.stdout.write(`[socialseal] Workspace: ${workspace.name} (${workspace.id})\n`);
5502
+ } else if (directory.defaultWorkspaceId) {
5503
+ process.stdout.write(`[socialseal] Suggested workspace: ${directory.defaultWorkspaceId}\n`);
5504
+ }
5505
+ }
5506
+
5507
+ function handleBilling(opts) {
5508
+ const config = loadConfig();
5509
+ const webBase = resolveWebBase(opts, config);
5510
+ const billingUrl = `${webBase.replace(/\/$/, '')}/settings/billing`;
5511
+ const payload = {
5512
+ billingUrl,
5513
+ note: 'SocialSeal starts on the free tier. Use billing only when credits or quotas are exhausted.',
5514
+ };
5515
+
5516
+ if (opts.json) {
5517
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5518
+ return;
5519
+ }
5520
+
5521
+ process.stdout.write(`[socialseal] Billing and credits: ${billingUrl}\n`);
5522
+ process.stdout.write('[socialseal] SocialSeal starts on the free tier. Add billing only when you need more capacity.\n');
5523
+ }
5524
+
4759
5525
  async function handleWorkspaceList(opts) {
4760
5526
  const config = loadConfig();
4761
5527
  const apiKey = requireApiKey(opts, config);
@@ -4915,7 +5681,47 @@ if (typeof program.showHelpAfterError === 'function') {
4915
5681
  if (typeof program.showSuggestionAfterError === 'function') {
4916
5682
  program.showSuggestionAfterError(true);
4917
5683
  }
4918
- 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`);
5684
+ program.addHelpText('after', `\nExamples:\n socialseal login\n socialseal whoami\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`);
5685
+
5686
+ program
5687
+ .command('login')
5688
+ .description('Start browser-based device login and store a local CLI key')
5689
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
5690
+ .option('--no-open', 'Print the approval URL without opening a browser')
5691
+ .option('--json', 'Emit machine-readable output')
5692
+ .option('--pretty', 'Pretty-print JSON')
5693
+ .option('--timeout <ms>', 'Overall login timeout in milliseconds')
5694
+ .option('--poll-interval <ms>', 'Polling interval in milliseconds')
5695
+ .option('--verbose', 'Show error details')
5696
+ .action((opts) => runCommand(handleLogin, opts));
5697
+
5698
+ program
5699
+ .command('logout')
5700
+ .description('Remove the locally stored SocialSeal CLI key')
5701
+ .option('--json', 'Emit machine-readable output')
5702
+ .option('--pretty', 'Pretty-print JSON')
5703
+ .option('--verbose', 'Show error details')
5704
+ .action((opts) => runCommand(handleLogout, opts));
5705
+
5706
+ program
5707
+ .command('whoami')
5708
+ .description('Show the current SocialSeal CLI authentication and workspace')
5709
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
5710
+ .option('--api-key <key>', 'CLI API key')
5711
+ .option('--json', 'Emit machine-readable output')
5712
+ .option('--pretty', 'Pretty-print JSON')
5713
+ .option('--timeout <ms>', 'Request timeout in milliseconds')
5714
+ .option('--verbose', 'Show error details')
5715
+ .action((opts) => runCommand(handleWhoami, opts));
5716
+
5717
+ program
5718
+ .command('billing')
5719
+ .description('Show where to manage SocialSeal billing and credits')
5720
+ .option('--web-base <url>', 'Web app base URL')
5721
+ .option('--json', 'Emit machine-readable output')
5722
+ .option('--pretty', 'Pretty-print JSON')
5723
+ .option('--verbose', 'Show error details')
5724
+ .action((opts) => runCommand(handleBilling, opts));
4919
5725
 
4920
5726
  program
4921
5727
  .command('agent')
@@ -4983,6 +5789,7 @@ const tools = program.command('tools').description('Call edge functions directly
4983
5789
  tools
4984
5790
  .command('list')
4985
5791
  .description('List built-in tool registry entries')
5792
+ .option('--category <name>', 'Filter tools by category')
4986
5793
  .option('--json', 'Emit machine-readable output')
4987
5794
  .option('--pretty', 'Pretty-print JSON')
4988
5795
  .option('--verbose', 'Show error details')