@clipit-ai/cli 0.2.3 → 0.2.4

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.
Files changed (3) hide show
  1. package/README.md +23 -9
  2. package/bin/clipit.mjs +1265 -62
  3. package/package.json +1 -1
package/bin/clipit.mjs CHANGED
@@ -9,7 +9,7 @@ import path from 'node:path';
9
9
  import process from 'node:process';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
- const VERSION = '0.2.3';
12
+ const VERSION = '0.2.4';
13
13
  const DEFAULT_BASE_URL = 'https://clipit.dev';
14
14
  const DEFAULT_SCOPES = [
15
15
  'clippy_agent',
@@ -30,6 +30,8 @@ const RETRY_AFTER_CAP_MS = 10_000;
30
30
  const RETRYABLE_GET_STATUSES = new Set([429, 502, 503, 504]);
31
31
  const RECENT_LIMIT = 20;
32
32
  const REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND = 0.0015;
33
+ const BYTES_PER_GB = 1024 * 1024 * 1024;
34
+ const DIRECT_VIDEO_UPLOAD_MAX_BYTES = readPositiveIntegerEnv('CLIPIT_CLI_DIRECT_UPLOAD_MAX_BYTES', 100 * 1024 * 1024);
33
35
  const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
34
36
  'exports start': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
35
37
  'thumbnails generate': [{ operationType: 'thumbnail_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
@@ -38,12 +40,151 @@ const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
38
40
  { operationType: 'image_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'broll-images' },
39
41
  { operationType: 'video_generation', provider: 'replicate', modelId: 'alibaba/happyhorse-1.0', metrics: 'broll-video' },
40
42
  ],
43
+ 'clips create': [{ operationType: 'clip', provider: 'deepgram', metrics: 'clip-create' }],
41
44
  'clips render': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
45
+ 'videos upload': [{ operationType: 'video_storage', provider: 'railway_s3', metrics: 'video-upload-storage' }],
42
46
  'videos import-url': [{ operationType: null, provider: null, metrics: 'url-import' }],
47
+ 'videos transcribe': [{ operationType: 'transcription', provider: 'deepgram', modelId: 'nova-3', metrics: 'transcription-video' }],
48
+ 'videos suggest-clips': [{ operationType: 'ai_chat', provider: 'openrouter', modelId: 'x-ai/grok-4.20-beta', metrics: 'suggest-clips' }],
43
49
  'social post': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
44
50
  'social schedule': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
45
51
  });
46
52
 
53
+ const REMOTION_RUN_ESTIMATE = MAX_CREDITS_ESTIMATE_MAP['clips render'];
54
+
55
+ const RUN_MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
56
+ planBRoll: MAX_CREDITS_ESTIMATE_MAP['broll plan'],
57
+ createThumbnail: MAX_CREDITS_ESTIMATE_MAP['thumbnails generate'],
58
+ generateThumbnails: MAX_CREDITS_ESTIMATE_MAP['thumbnails generate'],
59
+ generateImage: [{ operationType: 'image_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
60
+ generateBRoll: MAX_CREDITS_ESTIMATE_MAP['broll generate'],
61
+ generateVoiceover: [{ operationType: 'tts_generation', provider: 'replicate', modelId: 'google/gemini-3.1-flash-tts', metrics: 'tts-provider-cost' }],
62
+ generateMusicBed: [{ operationType: 'music_generation', provider: 'replicate', modelId: 'minimax/music-2.5', metrics: 'one-generation' }],
63
+ generateVideoAlter: [{ operationType: 'video_alter_generation', provider: 'replicate', modelId: 'kwaivgi/kling-v3-omni-video', metrics: 'video-alter-provider-cost' }],
64
+ generatePlatformCaption: [{ operationType: 'ai_chat', provider: 'openrouter', modelId: 'anthropic/claude-opus-4.7', metrics: 'platform-caption' }],
65
+ applyHookToFront: REMOTION_RUN_ESTIMATE,
66
+ addLibraryAssetToClip: REMOTION_RUN_ESTIMATE,
67
+ renderClipWithRemotion: REMOTION_RUN_ESTIMATE,
68
+ updateClipBounds: REMOTION_RUN_ESTIMATE,
69
+ setCropPosition: REMOTION_RUN_ESTIMATE,
70
+ applyRemotionCaptionPreset: REMOTION_RUN_ESTIMATE,
71
+ setCaptionAnimation: REMOTION_RUN_ESTIMATE,
72
+ setClipAspectRatio: REMOTION_RUN_ESTIMATE,
73
+ setCropLayout: REMOTION_RUN_ESTIMATE,
74
+ setTimedCropLayouts: REMOTION_RUN_ESTIMATE,
75
+ applyCropPreset: REMOTION_RUN_ESTIMATE,
76
+ setWatermark: REMOTION_RUN_ESTIMATE,
77
+ applyTextOverlayToClip: REMOTION_RUN_ESTIMATE,
78
+ applyBRoll: REMOTION_RUN_ESTIMATE,
79
+ removeBRoll: REMOTION_RUN_ESTIMATE,
80
+ });
81
+
82
+ const RUN_CONFIRMATION_LABELS = Object.freeze({
83
+ renderClipWithRemotion: 'Rendering a clip',
84
+ });
85
+
86
+ const RUN_METERED_CONFIRMATION_EXEMPTIONS = new Set([
87
+ 'planBRoll',
88
+ 'planVideoAlter',
89
+ 'planVoiceover',
90
+ 'planMusicBed',
91
+ ]);
92
+
93
+ const CLIP_IDS_CONTEXT_TOOLS = new Set([
94
+ 'runDeliveryReadinessQA',
95
+ 'runBlueprintExportQA',
96
+ ]);
97
+
98
+ const BILLING_PROVIDER_PREFERENCES = new Set(['x402_direct', 'stripe_mpp', 'stripe_x402']);
99
+
100
+ const LOCAL_MCP_BILLING_TOOLS = Object.freeze([
101
+ {
102
+ name: 'getPaymentCapabilities',
103
+ description: 'Discover ClipIt machine-payment capabilities, agent connection paths, safety policy, and direct x402, Stripe x402, or Stripe MPP rail readiness.',
104
+ costBand: 'free',
105
+ skill: 'billing',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {},
109
+ additionalProperties: false,
110
+ },
111
+ },
112
+ {
113
+ name: 'getBillingCatalog',
114
+ description: 'List ClipIt catalog products, prices, credit amounts, and enabled machine-payment rails.',
115
+ costBand: 'free',
116
+ skill: 'billing',
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {},
120
+ additionalProperties: false,
121
+ },
122
+ },
123
+ {
124
+ name: 'createPaymentAttempt',
125
+ description: 'Create an API-key-owned machine-payment attempt and return the direct x402, Stripe x402, or Stripe MPP payment URL. This prepares a payment but does not sign a wallet transaction or supply a Stripe SPT credential.',
126
+ costBand: 'free',
127
+ skill: 'billing',
128
+ requiresConfirmation: true,
129
+ confirmation: {
130
+ required: true,
131
+ riskLevel: 'costly',
132
+ reason: 'Creates a payable billing attempt. Continue only after the human approved the product, amount, rail, and budget policy.',
133
+ actionLabel: 'create machine-payment attempt',
134
+ allowAutonomous: false,
135
+ },
136
+ inputSchema: {
137
+ type: 'object',
138
+ properties: {
139
+ productKey: { type: 'string' },
140
+ providerPreference: { type: 'string', enum: ['x402_direct', 'stripe_mpp', 'stripe_x402'] },
141
+ idempotencyKey: { type: 'string' },
142
+ },
143
+ required: ['productKey'],
144
+ additionalProperties: false,
145
+ },
146
+ },
147
+ {
148
+ name: 'getPaymentAttempt',
149
+ description: 'Get the status of an API-key-owned machine-payment attempt.',
150
+ costBand: 'free',
151
+ skill: 'billing',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ attemptId: { type: 'string' },
156
+ },
157
+ required: ['attemptId'],
158
+ additionalProperties: false,
159
+ },
160
+ },
161
+ {
162
+ name: 'getPaymentReceipt',
163
+ description: 'Get the receipt and fulfillment records for an API-key-owned machine-payment attempt.',
164
+ costBand: 'free',
165
+ skill: 'billing',
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ attemptId: { type: 'string' },
170
+ },
171
+ required: ['attemptId'],
172
+ additionalProperties: false,
173
+ },
174
+ },
175
+ {
176
+ name: 'getBillingSubscription',
177
+ description: 'Get the effective ClipIt billing access source for the API key owner.',
178
+ costBand: 'free',
179
+ skill: 'billing',
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {},
183
+ additionalProperties: false,
184
+ },
185
+ },
186
+ ]);
187
+
47
188
  const EXIT = {
48
189
  OK: 0,
49
190
  USAGE: 2,
@@ -61,6 +202,11 @@ const KNOWN_AGENT_TARGETS = ['codex', 'claude', 'hermes', 'generic'];
61
202
  const TRUSTED_HOSTS = new Set(['clipit.dev', 'www.clipit.dev', 'localhost', '127.0.0.1', '::1', '[::1]']);
62
203
  const AGENT_SKILL_META_FILENAME = 'SKILL.meta.json';
63
204
 
205
+ function readPositiveIntegerEnv(name, fallback) {
206
+ const parsed = Number(process.env[name]);
207
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
208
+ }
209
+
64
210
  function configDir() {
65
211
  if (process.env.CLIPIT_CONFIG_DIR) return process.env.CLIPIT_CONFIG_DIR;
66
212
  if (process.platform === 'win32' && process.env.APPDATA) return path.join(process.env.APPDATA, 'ClipIt');
@@ -146,6 +292,32 @@ function appendRecentEntries(config, options, entries) {
146
292
  return entries.reduce((nextConfig, entry) => appendRecentEntry(nextConfig, options, entry.type, entry.id), config);
147
293
  }
148
294
 
295
+ function withProfileActiveContext(config, options, activeContext) {
296
+ const name = profileName(config, options);
297
+ const profiles = { ...(config.profiles || {}) };
298
+ profiles[name] = {
299
+ ...profileData(config, options),
300
+ activeContext,
301
+ };
302
+
303
+ const next = {
304
+ ...config,
305
+ profiles,
306
+ };
307
+
308
+ if (name === 'default') {
309
+ next.activeContext = activeContext;
310
+ }
311
+
312
+ return next;
313
+ }
314
+
315
+ async function persistActiveContext(config, options, activeContext, recent = []) {
316
+ let next = updateProfile(config, options, { activeContext: compactObject({ ...activeContext }) });
317
+ next = appendRecentEntries(next, options, recent);
318
+ await writeConfig(next);
319
+ }
320
+
149
321
  function removeProfileFields(config, options, fields) {
150
322
  const name = profileName(config, options);
151
323
  const profiles = { ...(config.profiles || {}) };
@@ -201,21 +373,26 @@ function wantJson(options) {
201
373
  function redact(value) {
202
374
  if (typeof value !== 'string') return value;
203
375
  return value
376
+ .replace(/https?:\/\/(?=[^\s"'<>]*[?&](?:X-Amz-|Signature=|token=|key=))[^\s"'<>]+/gi, '[signed-url-redacted]')
377
+ .replace(/https?:\/\/replicate\.delivery\/[^\s"'<>]+/gi, '[generated-media-url-redacted]')
378
+ .replace(/https?:\/\/stream\.replicate\.com\/v1\/files\/[^\s"'<>]+/gi, '[generated-media-url-redacted]')
379
+ .replace(/(^|[\s"'=:(,])\/objects\/[^\s"'<>),]+/g, '$1[object-url-redacted]')
204
380
  .replace(/clipper_[a-f0-9]{24,}/gi, 'clipper_[redacted]')
205
381
  .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]')
206
- .replace(/([?&](?:X-Amz-Signature|Signature|token|key)=)[^&\s]+/gi, '$1[redacted]');
382
+ .replace(/([?&](?:X-Amz-[A-Za-z-]+|Signature|token|key)=)[^&\s]+/gi, '$1[redacted]');
207
383
  }
208
384
 
209
385
  function output(data, options) {
386
+ const safeData = redactDeep(data);
210
387
  if (wantJson(options)) {
211
- console.log(JSON.stringify(data, null, 2));
388
+ console.log(JSON.stringify(safeData, null, 2));
212
389
  return;
213
390
  }
214
- if (typeof data === 'string') {
215
- console.log(data);
391
+ if (typeof safeData === 'string') {
392
+ console.log(safeData);
216
393
  return;
217
394
  }
218
- console.log(JSON.stringify(data, null, 2));
395
+ console.log(JSON.stringify(safeData, null, 2));
219
396
  }
220
397
 
221
398
  function redactDeep(value) {
@@ -227,6 +404,10 @@ function redactDeep(value) {
227
404
  return value;
228
405
  }
229
406
 
407
+ function hashText(value) {
408
+ return createHash('sha256').update(String(value)).digest('hex');
409
+ }
410
+
230
411
  function usage() {
231
412
  return [
232
413
  'ClipIt CLI',
@@ -245,14 +426,16 @@ function usage() {
245
426
  ' clipit skills list [--json]',
246
427
  ' clipit tools list [--skill clip] [--json]',
247
428
  ' clipit tools describe <functionName>',
248
- ' clipit ask "<prompt>" [--video-id id] [--clip-id id] [--conversation-id id] [--quick] [--stream] [--json]',
429
+ ' clipit ask "<prompt>" [--video-id id] [--clip-id id] [--conversation-id id] [--quick] [--auto-confirm-costly] [--stream] [--json]',
249
430
  ' clipit workflow status|wait <jobId> [--stream] [--json]',
250
431
  ' clipit workflow approve <jobId> --approval-id id [--decision approved|cheaper|cancelled]',
432
+ ' clipit mcp [stdio]',
251
433
  ' clipit run <functionName> [--params @file.json] [--clip-id id] [--video-id id] [--confirm] [--max-credits n]',
252
434
  ' clipit videos list|get|upload|import-url|transcribe|transcript|suggest-clips|delete ...',
253
435
  ' clipit clips list|get|create|update|render|download|delete ...',
254
436
  ' clipit jobs get|wait <jobId>',
255
437
  ' clipit credits balance|usage|estimate ...',
438
+ ' clipit billing capabilities|catalog|create-attempt|attempt|receipt|subscription ...',
256
439
  ' clipit analytics overview|top-clips|post ...',
257
440
  ' clipit exports start|list|get|wait|download|cancel ...',
258
441
  ' clipit assets list|upload|delete ...',
@@ -486,7 +669,11 @@ function maxCreditsLimit(options) {
486
669
  }
487
670
 
488
671
  function clipCostLabel(value) {
489
- return `${Number(value).toFixed(2).replace(/\.00$/, '')} $CLIP`;
672
+ const amount = Number(value);
673
+ if (!Number.isFinite(amount)) return `${value} $CLIP`;
674
+ if (amount === 0) return '0 $CLIP';
675
+ const fixed = amount.toFixed(Math.abs(amount) < 0.01 ? 5 : 2).replace(/\.?0+$/, '');
676
+ return `${fixed} $CLIP`;
490
677
  }
491
678
 
492
679
  const SPEND_LIMIT_EXCEEDED_MESSAGE = 'Spend limit exceeded for this API key \u2014 raise the key\'s spend limit in ClipIt Settings or use a different key.';
@@ -518,6 +705,20 @@ function warnIfEstimateUnaffordable(estimates) {
518
705
  }
519
706
  }
520
707
 
708
+ function summarizeRunEstimates(estimates) {
709
+ if (!Array.isArray(estimates) || !estimates.length) return null;
710
+ const estimatedCostClip = estimates.reduce((sum, estimate) => {
711
+ const value = Number(estimate?.estimatedCostClip ?? 0);
712
+ return sum + (Number.isFinite(value) ? value : 0);
713
+ }, 0);
714
+ return {
715
+ estimatedCostClip,
716
+ estimatedCostLabel: clipCostLabel(estimatedCostClip),
717
+ affordable: estimates.every((estimate) => estimate?.affordable !== false),
718
+ estimates,
719
+ };
720
+ }
721
+
521
722
  function throwSpendLimitExceeded(commandKey, spendLimitViolation, estimates) {
522
723
  throw Object.assign(new Error(SPEND_LIMIT_EXCEEDED_MESSAGE), {
523
724
  exitCode: EXIT.CREDITS,
@@ -548,11 +749,52 @@ function remotionProviderCostUsd(durationSeconds, quality) {
548
749
  return Math.max(0, durationSeconds * REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND * qualityMultiplier(quality));
549
750
  }
550
751
 
752
+ function normalizeCreditEstimateRequest(request) {
753
+ if (
754
+ request.operationType === 'lambda_render'
755
+ && (request.provider === 'aws_lambda' || request.provider === 'remotion')
756
+ && request.metrics
757
+ && typeof request.metrics.videoSeconds === 'number'
758
+ && typeof request.metrics.providerCostUsd !== 'number'
759
+ ) {
760
+ return {
761
+ ...request,
762
+ metrics: {
763
+ ...request.metrics,
764
+ providerCostUsd: remotionProviderCostUsd(request.metrics.videoSeconds, 'high'),
765
+ },
766
+ };
767
+ }
768
+
769
+ return request;
770
+ }
771
+
551
772
  function brollVideoProviderCostUsd(durationSeconds, resolution) {
552
773
  const perSecondUsd = resolution === '1080p' ? 0.28 : 0.14;
553
774
  return Math.max(0, durationSeconds * perSecondUsd);
554
775
  }
555
776
 
777
+ function brollImageProviderCostUsd(quality) {
778
+ if (quality === 'low') return 0.012;
779
+ if (quality === 'medium') return 0.047;
780
+ return 0.128;
781
+ }
782
+
783
+ function ttsProviderCostUsd(text, prompt) {
784
+ const textBytes = Buffer.byteLength(String(text || ''), 'utf8');
785
+ const promptBytes = Buffer.byteLength(String(prompt || 'Say the following.'), 'utf8');
786
+ const estimatedInputTokens = Math.ceil((textBytes + promptBytes) / 4);
787
+ const estimatedOutputTokens = Math.ceil(textBytes / 3);
788
+ return (estimatedInputTokens / 1_000_000) * 2 + (estimatedOutputTokens / 1_000) * 0.04;
789
+ }
790
+
791
+ function videoAlterProviderCostUsd(durationSeconds, mode) {
792
+ const perSecond = mode === 'pro'
793
+ ? Number(process.env.KLING_OMNI_PRO_COST_PER_SECOND_USD ?? '0.56')
794
+ : Number(process.env.KLING_OMNI_STANDARD_COST_PER_SECOND_USD ?? '0.28');
795
+ return Math.max(0, durationSeconds * (Number.isFinite(perSecond) ? perSecond : 0.28));
796
+ }
797
+
556
798
  function durationFromClip(clip, body = {}) {
557
799
  const bodyStart = Number(body.startTime);
558
800
  const bodyEnd = Number(body.endTime);
@@ -594,17 +836,57 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
594
836
  return { ...spec, metrics: { generationCount: 1 } };
595
837
  }
596
838
 
839
+ if (spec.metrics === 'tts-provider-cost') {
840
+ const text = requiredString(context.body?.text, 'text');
841
+ return { ...spec, metrics: { providerCostUsd: ttsProviderCostUsd(text, context.body?.prompt) } };
842
+ }
843
+
597
844
  if (spec.metrics === 'broll-plan') {
598
845
  return { ...spec, metrics: { inputTokens: 4000, outputTokens: 1000 } };
599
846
  }
600
847
 
848
+ if (spec.metrics === 'platform-caption') {
849
+ return { ...spec, metrics: { inputTokens: 1500, outputTokens: 600, totalTokens: 2100 } };
850
+ }
851
+
852
+ if (spec.metrics === 'suggest-clips') {
853
+ return { ...spec, metrics: { inputTokens: 40000, outputTokens: 10000, totalTokens: 50000 } };
854
+ }
855
+
856
+ if (spec.metrics === 'clip-create') {
857
+ const audioSeconds = durationFromClip(context.body, context.body);
858
+ if (!Number.isFinite(audioSeconds) || audioSeconds <= 0) return null;
859
+ return { ...spec, metrics: { audioSeconds } };
860
+ }
861
+
862
+ if (spec.metrics === 'transcription-video') {
863
+ const videoId = requiredString(context.videoId, 'Video id');
864
+ const video = context.video || await apiFetch(config, options, 'GET', `/api/v1/videos/${encodeURIComponent(videoId)}`);
865
+ const audioSeconds = Number(video?.durationSeconds ?? video?.duration);
866
+ if (!Number.isFinite(audioSeconds) || audioSeconds <= 0) return null;
867
+ return { ...spec, metrics: { audioSeconds } };
868
+ }
869
+
870
+ if (spec.metrics === 'video-upload-storage') {
871
+ const bytes = Number(context.bytes);
872
+ if (!Number.isFinite(bytes) || bytes <= 0) return null;
873
+ return { ...spec, metrics: { generationCount: bytes / BYTES_PER_GB } };
874
+ }
875
+
601
876
  if (spec.metrics === 'broll-images') {
602
877
  const mode = context.body?.mode || 'single_image';
603
- return { ...spec, metrics: { generationCount: mode === 'start_end_frame' ? 3 : 1 } };
878
+ const generationCount = mode === 'start_end_frame' ? 3 : 1;
879
+ return {
880
+ ...spec,
881
+ metrics: {
882
+ generationCount,
883
+ providerCostUsd: brollImageProviderCostUsd(context.body?.imageQuality) * generationCount,
884
+ },
885
+ };
604
886
  }
605
887
 
606
888
  if (spec.metrics === 'broll-video') {
607
- const durationSeconds = Number(context.body?.durationSeconds ?? 6);
889
+ const durationSeconds = Number(context.body?.durationSeconds ?? context.body?.duration ?? 6);
608
890
  const resolution = context.body?.resolution || '720p';
609
891
  if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return null;
610
892
  return {
@@ -616,6 +898,21 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
616
898
  };
617
899
  }
618
900
 
901
+ if (spec.metrics === 'video-alter-provider-cost') {
902
+ const durationSeconds = Number(
903
+ context.body?.duration
904
+ ?? (Number(context.body?.sourceEnd) - Number(context.body?.sourceStart)),
905
+ );
906
+ if (!Number.isFinite(durationSeconds) || durationSeconds < 3 || durationSeconds > 10) return null;
907
+ return {
908
+ ...spec,
909
+ metrics: {
910
+ videoSeconds: durationSeconds,
911
+ providerCostUsd: videoAlterProviderCostUsd(durationSeconds, context.body?.mode),
912
+ },
913
+ };
914
+ }
915
+
619
916
  if (spec.metrics === 'social-platforms') {
620
917
  const platforms = Array.isArray(context.body?.platforms) ? context.body.platforms : [];
621
918
  if (!platforms.length) return null;
@@ -640,8 +937,70 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
640
937
  return null;
641
938
  }
642
939
 
940
+ async function buildStaticRunEstimates(config, options, functionName, parameters, payload) {
941
+ const specs = RUN_MAX_CREDITS_ESTIMATE_MAP[functionName];
942
+ if (!specs) return null;
943
+
944
+ const body = parameters && typeof parameters === 'object' && !Array.isArray(parameters) ? parameters : {};
945
+ const requests = [];
946
+ for (const spec of specs) {
947
+ const request = await buildMaxCreditsEstimateRequest(config, options, spec, {
948
+ body,
949
+ clipId: payload?.clipId || body.clipId,
950
+ videoId: payload?.videoId || body.videoId,
951
+ });
952
+ if (!request) return null;
953
+ requests.push(compactObject({
954
+ operationType: request.operationType,
955
+ provider: request.provider,
956
+ modelId: request.modelId,
957
+ metrics: request.metrics,
958
+ }));
959
+ }
960
+
961
+ const estimates = [];
962
+ for (const request of requests) {
963
+ const estimate = await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', request);
964
+ estimates.push({
965
+ operationType: request.operationType,
966
+ provider: request.provider,
967
+ modelId: request.modelId,
968
+ estimatedCostClip: Number(estimate?.estimatedCostClip ?? 0),
969
+ affordable: estimate?.affordable,
970
+ spendLimitViolation: estimate?.spendLimitViolation ?? null,
971
+ });
972
+ }
973
+
974
+ return estimates;
975
+ }
976
+
977
+ function currentInvocationWantsJson() {
978
+ return process.argv.some((arg) => arg === '--json' || arg === '--json=true');
979
+ }
980
+
643
981
  function printEstimateUnavailable(commandKey) {
644
- console.error(`estimate unavailable for ${commandKey}; --confirm is required.`);
982
+ if (!currentInvocationWantsJson()) {
983
+ console.error(`estimate unavailable for ${commandKey}; --confirm is required.`);
984
+ }
985
+ }
986
+
987
+ function throwMaxCreditsEstimateUnavailable(commandKey) {
988
+ if (!currentInvocationWantsJson()) {
989
+ console.error(`estimate unavailable for ${commandKey}; --max-credits cannot be enforced.`);
990
+ }
991
+ throw Object.assign(
992
+ new Error(`Cannot enforce --max-credits for ${commandKey} because no cost estimate is available.`),
993
+ { exitCode: EXIT.CONFIRMATION },
994
+ );
995
+ }
996
+
997
+ function estimatedClipCostFromData(data) {
998
+ if (!data || typeof data !== 'object') return null;
999
+ for (const key of ['estimatedCostClip', 'totalEstimatedCostClip', 'costClip', 'totalCostClip']) {
1000
+ const value = Number(data[key]);
1001
+ if (Number.isFinite(value)) return value;
1002
+ }
1003
+ return null;
645
1004
  }
646
1005
 
647
1006
  async function enforceMaxCredits(config, options, commandKey, context = {}) {
@@ -705,20 +1064,119 @@ async function enforceMaxCredits(config, options, commandKey, context = {}) {
705
1064
  }
706
1065
  }
707
1066
 
708
- async function enforceRunMaxCredits(config, options, functionName) {
709
- if (maxCreditsLimit(options) === null) return;
1067
+ async function enforceRunMaxCredits(config, options, functionName, parameters = {}, payload = {}) {
1068
+ const limit = maxCreditsLimit(options);
1069
+ const needsConfirmationPreflight = !boolOption(options.confirm);
1070
+ if (limit === null && !needsConfirmationPreflight) return null;
1071
+
710
1072
  const catalog = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
711
1073
  const tools = Array.isArray(catalog?.tools) ? catalog.tools : Array.isArray(catalog) ? catalog : [];
712
1074
  const tool = tools.find((item) => item.name === functionName);
713
1075
  const runEstimate = tool?.estimate ?? tool?.confirmation?.estimate ?? tool?.confirmation?.costEstimate ?? null;
1076
+ const isMetered = Boolean(tool?.costBand && tool.costBand !== 'free');
1077
+ const isMeteredExempt = RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName);
1078
+
1079
+ let staticEstimates = null;
1080
+ if (!runEstimate && (limit !== null || needsConfirmationPreflight)) {
1081
+ staticEstimates = await buildStaticRunEstimates(config, options, functionName, parameters, payload);
1082
+ }
1083
+
1084
+ if (limit === null) {
1085
+ const estimates = staticEstimates || (runEstimate ? [runEstimate] : null);
1086
+ const estimateSummary = summarizeRunEstimates(estimates);
1087
+ if (estimateSummary && (tool?.confirmation?.required || (isMetered && !isMeteredExempt))) {
1088
+ warnIfEstimateUnaffordable(estimates);
1089
+ throw Object.assign(
1090
+ new Error(`Estimated cost ${estimateSummary.estimatedCostLabel} for run ${functionName}; --confirm is required.`),
1091
+ {
1092
+ exitCode: EXIT.CONFIRMATION,
1093
+ data: {
1094
+ command: `run ${functionName}`,
1095
+ estimate: estimateSummary,
1096
+ },
1097
+ },
1098
+ );
1099
+ }
1100
+ if (tool?.confirmation?.required) {
1101
+ if (isMetered) printEstimateUnavailable(`run ${functionName}`);
1102
+ requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
1103
+ return;
1104
+ }
1105
+ if (isMetered && !isMeteredExempt) {
1106
+ printEstimateUnavailable(`run ${functionName}`);
1107
+ requireConfirm(options, `Running metered tool ${functionName}`);
1108
+ return;
1109
+ }
1110
+ return null;
1111
+ }
1112
+
1113
+ if (staticEstimates) {
1114
+ const spendLimitViolation = staticEstimates.find((estimate) => estimate.spendLimitViolation)?.spendLimitViolation;
1115
+ if (spendLimitViolation) throwSpendLimitExceeded(`run ${functionName}`, spendLimitViolation, staticEstimates);
1116
+ warnIfEstimateUnaffordable(staticEstimates);
1117
+
1118
+ const estimatedCostClip = staticEstimates.reduce((sum, estimate) => sum + estimate.estimatedCostClip, 0);
1119
+ if (estimatedCostClip > limit) {
1120
+ throw Object.assign(
1121
+ new Error(`Estimated cost ${clipCostLabel(estimatedCostClip)} exceeds --max-credits ${clipCostLabel(limit)} for run ${functionName}.`),
1122
+ {
1123
+ exitCode: EXIT.CONFIRMATION,
1124
+ data: {
1125
+ command: `run ${functionName}`,
1126
+ maxCredits: limit,
1127
+ estimatedCostClip,
1128
+ estimates: staticEstimates,
1129
+ },
1130
+ },
1131
+ );
1132
+ }
1133
+ if (tool?.confirmation?.required || (isMetered && !isMeteredExempt)) {
1134
+ requireConfirm(
1135
+ options,
1136
+ tool?.confirmation?.required ? `Running confirmation-gated tool ${functionName}` : `Running metered tool ${functionName}`,
1137
+ );
1138
+ }
1139
+ return summarizeRunEstimates(staticEstimates);
1140
+ }
1141
+
714
1142
  const spendLimitViolation = spendLimitViolationFromData(runEstimate) ?? spendLimitViolationFromData(tool?.confirmation);
715
1143
  if (spendLimitViolation) throwSpendLimitExceeded(`run ${functionName}`, spendLimitViolation, runEstimate ? [runEstimate] : []);
716
1144
  if (runEstimate?.affordable === false || tool?.confirmation?.affordable === false) {
717
1145
  console.error('Warning: estimated cost exceeds your current balance.');
718
1146
  }
719
- if (!tool?.confirmation?.required) return;
720
- printEstimateUnavailable(`run ${functionName}`);
721
- requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
1147
+ if (!runEstimate && isMetered && !isMeteredExempt) {
1148
+ throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
1149
+ }
1150
+ if (!runEstimate && tool?.confirmation?.required) {
1151
+ throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
1152
+ }
1153
+ if (runEstimate) {
1154
+ const estimatedCostClip = estimatedClipCostFromData(runEstimate);
1155
+ if (estimatedCostClip === null) {
1156
+ throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
1157
+ }
1158
+ if (estimatedCostClip > limit) {
1159
+ throw Object.assign(
1160
+ new Error(`Estimated cost ${clipCostLabel(estimatedCostClip)} exceeds --max-credits ${clipCostLabel(limit)} for run ${functionName}.`),
1161
+ {
1162
+ exitCode: EXIT.CONFIRMATION,
1163
+ data: {
1164
+ command: `run ${functionName}`,
1165
+ maxCredits: limit,
1166
+ estimatedCostClip,
1167
+ estimates: [runEstimate],
1168
+ },
1169
+ },
1170
+ );
1171
+ }
1172
+ }
1173
+ if (tool?.confirmation?.required || (isMetered && !isMeteredExempt)) {
1174
+ requireConfirm(
1175
+ options,
1176
+ tool?.confirmation?.required ? `Running confirmation-gated tool ${functionName}` : `Running metered tool ${functionName}`,
1177
+ );
1178
+ }
1179
+ return runEstimate ? summarizeRunEstimates([runEstimate]) : null;
722
1180
  }
723
1181
 
724
1182
  async function login(config, options) {
@@ -967,7 +1425,7 @@ async function contextCommand(config, options, action) {
967
1425
  }
968
1426
 
969
1427
  if (action === 'use') {
970
- const activeContext = await buildContext({ ...config, activeContext: {} }, options);
1428
+ const activeContext = await buildContext(withProfileActiveContext(config, options, {}), options);
971
1429
  let next = updateProfile(config, options, { activeContext });
972
1430
  next = appendRecentEntries(next, options, [
973
1431
  { type: 'video', id: activeContext.videoId },
@@ -988,6 +1446,51 @@ async function contextCommand(config, options, action) {
988
1446
  throw Object.assign(new Error(`Unknown context command: ${action || ''}`), { exitCode: EXIT.USAGE });
989
1447
  }
990
1448
 
1449
+ function applyContextToAgentPayload(payload, parameters, context) {
1450
+ const canMutateParameters = parameters && typeof parameters === 'object' && !Array.isArray(parameters);
1451
+ const hadExplicitVideoId = canMutateParameters && parameters.videoId !== undefined;
1452
+ for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
1453
+ if (context[field] && !payload[field]) {
1454
+ payload[field] = context[field];
1455
+ if (canMutateParameters && parameters[field] === undefined) {
1456
+ parameters[field] = context[field];
1457
+ }
1458
+ }
1459
+ }
1460
+ if (canMutateParameters
1461
+ && CLIP_IDS_CONTEXT_TOOLS.has(payload.functionName)
1462
+ && parameters.clipIds === undefined
1463
+ && !hadExplicitVideoId) {
1464
+ const selectedClipIds = Array.isArray(context.selectedClipIds)
1465
+ ? context.selectedClipIds.filter(Boolean).map(String)
1466
+ : [];
1467
+ const clipId = parameters.clipId || (selectedClipIds.length ? null : context.clipId);
1468
+ if (clipId) parameters.clipIds = [String(clipId)];
1469
+ else if (selectedClipIds.length) parameters.clipIds = selectedClipIds;
1470
+ }
1471
+ if (context.selectedClipIds) payload.selectedClipIds = context.selectedClipIds;
1472
+ if (context.playheadPosition !== undefined) payload.playheadPosition = context.playheadPosition;
1473
+ if (Object.keys(context).length) payload.context = context.context || context;
1474
+ }
1475
+
1476
+ function normalizeAgentExecuteResult(result) {
1477
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return result;
1478
+ const nested = result.result;
1479
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested) || nested.requiresConfirmation !== true) {
1480
+ return result;
1481
+ }
1482
+
1483
+ return compactObject({
1484
+ ...result,
1485
+ requiresConfirmation: true,
1486
+ confirmationTool: result.confirmationTool ?? nested.confirmationTool ?? nested.functionName,
1487
+ confirmationParams: result.confirmationParams ?? nested.confirmationParams ?? nested.parameters,
1488
+ confirmation: result.confirmation ?? nested.confirmation,
1489
+ estimate: result.estimate ?? nested.estimate,
1490
+ preview: result.preview ?? nested.preview,
1491
+ });
1492
+ }
1493
+
991
1494
  async function runTool(config, options, functionName) {
992
1495
  if (!functionName) throw Object.assign(new Error('Function name is required.'), { exitCode: EXIT.USAGE });
993
1496
  const parameters = await readJsonOption(options.params || options['params-json']);
@@ -1003,23 +1506,29 @@ async function runTool(config, options, functionName) {
1003
1506
  ['project-id', 'projectId'],
1004
1507
  ['sequence-id', 'sequenceId'],
1005
1508
  ]) {
1006
- if (options[flag]) payload[field] = String(options[flag]);
1509
+ if (options[flag]) {
1510
+ const value = String(options[flag]);
1511
+ payload[field] = value;
1512
+ if (parameters && typeof parameters === 'object' && !Array.isArray(parameters) && parameters[field] === undefined) {
1513
+ parameters[field] = value;
1514
+ }
1515
+ }
1007
1516
  }
1008
- if (context.videoId && !payload.videoId) payload.videoId = context.videoId;
1009
- if (context.clipId && !payload.clipId) payload.clipId = context.clipId;
1010
- if (context.projectId && !payload.projectId) payload.projectId = context.projectId;
1011
- if (context.sequenceId && !payload.sequenceId) payload.sequenceId = context.sequenceId;
1012
- if (context.selectedClipIds) payload.selectedClipIds = context.selectedClipIds;
1013
- if (context.playheadPosition !== undefined) payload.playheadPosition = context.playheadPosition;
1014
- if (Object.keys(context).length) payload.context = context.context || context;
1015
- await enforceRunMaxCredits(config, options, functionName);
1016
- const result = await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload);
1017
- if (result?.requiresConfirmation && !payload.confirmed) {
1018
- output(result, options);
1517
+ applyContextToAgentPayload(payload, parameters, context);
1518
+ if (RUN_CONFIRMATION_LABELS[functionName]) {
1519
+ confirmPaid(options, RUN_CONFIRMATION_LABELS[functionName]);
1520
+ }
1521
+ const estimate = await enforceRunMaxCredits(config, options, functionName, parameters, payload);
1522
+ const result = normalizeAgentExecuteResult(await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload));
1523
+ const outputResult = estimate && result && typeof result === 'object' && !Array.isArray(result) && result.estimate === undefined
1524
+ ? { ...result, estimate }
1525
+ : result;
1526
+ if (result?.requiresConfirmation && !payload.confirmed && !RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName)) {
1527
+ output(outputResult, options);
1019
1528
  process.exitCode = EXIT.CONFIRMATION;
1020
1529
  return;
1021
1530
  }
1022
- output(result, options);
1531
+ output(outputResult, options);
1023
1532
  }
1024
1533
 
1025
1534
  function workflowEndpoint(jobId) {
@@ -1152,6 +1661,7 @@ async function askWorkflow(config, options, promptParts) {
1152
1661
  }
1153
1662
  if (options['conversation-id']) payload.conversationId = String(options['conversation-id']);
1154
1663
  if (options.quick !== undefined) payload.quickMode = boolOption(options.quick);
1664
+ if (options['auto-confirm-costly'] !== undefined) payload.autoConfirmCostlyTools = boolOption(options['auto-confirm-costly']);
1155
1665
 
1156
1666
  const accepted = await apiFetch(config, options, 'POST', '/api/v1/agent/orchestrate', payload);
1157
1667
  if (boolOption(options['no-wait'])) {
@@ -1199,12 +1709,13 @@ async function workflow(config, options, action, args) {
1199
1709
  }
1200
1710
 
1201
1711
  const redactedAccepted = redactDeep(accepted);
1202
- if (wantJson(options)) {
1712
+ if (boolOption(options['no-wait'])) {
1203
1713
  output(redactedAccepted, options);
1204
- } else {
1714
+ return;
1715
+ }
1716
+ if (!wantJson(options)) {
1205
1717
  console.log(`Continuation workflow queued: ${redactedAccepted.jobId}`);
1206
1718
  }
1207
- if (boolOption(options['no-wait'])) return;
1208
1719
  await pollWorkflow(config, options, accepted.jobId);
1209
1720
  return;
1210
1721
  }
@@ -1240,6 +1751,24 @@ function formatMb(bytes) {
1240
1751
  return (bytes / (1024 * 1024)).toFixed(1);
1241
1752
  }
1242
1753
 
1754
+ function assertDirectVideoUploadSize(filePath, sizeBytes) {
1755
+ if (sizeBytes <= DIRECT_VIDEO_UPLOAD_MAX_BYTES) return;
1756
+ throw Object.assign(
1757
+ new Error(
1758
+ `Video file is ${formatMb(sizeBytes)} MB, above the direct CLI upload limit of ${formatMb(DIRECT_VIDEO_UPLOAD_MAX_BYTES)} MB. ` +
1759
+ 'Use `clipit videos import-url` for URL sources or a server-side/resumable upload path; direct CLI upload is likely to fail with 413 before ClipIt can process it.',
1760
+ ),
1761
+ {
1762
+ exitCode: EXIT.USAGE,
1763
+ data: {
1764
+ filePath,
1765
+ sizeBytes,
1766
+ maxBytes: DIRECT_VIDEO_UPLOAD_MAX_BYTES,
1767
+ },
1768
+ },
1769
+ );
1770
+ }
1771
+
1243
1772
  function createUploadProgress(options, totalBytes) {
1244
1773
  if (!shouldReportUploadProgress(options) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
1245
1774
  return { track() {}, finish() {} };
@@ -1286,6 +1815,7 @@ async function uploadVideo(config, options, filePath) {
1286
1815
  if (!stat.isFile()) {
1287
1816
  throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
1288
1817
  }
1818
+ assertDirectVideoUploadSize(resolved, stat.size);
1289
1819
  const filename = options.filename || path.basename(resolved);
1290
1820
  const contentType = mimeForPath(resolved);
1291
1821
  const boundary = `clipit-cli-${randomBytes(12).toString('hex')}`;
@@ -1295,14 +1825,25 @@ async function uploadVideo(config, options, filePath) {
1295
1825
  const footerLength = Buffer.byteLength(`\r\n--${boundary}--\r\n`);
1296
1826
  const progress = createUploadProgress(options, stat.size);
1297
1827
  const body = multipartFileBody(resolved, filename, contentType, boundary, progress);
1828
+ await enforceMaxCredits(config, options, 'videos upload', { bytes: stat.size });
1829
+ confirmPaid(options, 'Uploading a video');
1298
1830
  try {
1299
- output(await apiFetch(config, options, 'POST', '/api/v1/videos', body, {
1831
+ const result = await apiFetch(config, options, 'POST', '/api/v1/videos', body, {
1300
1832
  rawBody: true,
1301
1833
  headers: {
1302
1834
  'Content-Type': `multipart/form-data; boundary=${boundary}`,
1303
1835
  'Content-Length': String(headerLength + stat.size + footerLength),
1304
1836
  },
1305
- }), options);
1837
+ });
1838
+ if (result?.videoId) {
1839
+ await persistActiveContext(
1840
+ config,
1841
+ options,
1842
+ { videoId: result.videoId },
1843
+ [{ type: 'video', id: result.videoId }],
1844
+ );
1845
+ }
1846
+ output(result, options);
1306
1847
  } finally {
1307
1848
  progress.finish();
1308
1849
  }
@@ -1324,6 +1865,7 @@ async function videos(config, options, action, args) {
1324
1865
  const url = args[0] || options.url;
1325
1866
  if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
1326
1867
  await enforceMaxCredits(config, options, 'videos import-url', { url });
1868
+ confirmPaid(options, 'Importing a video from URL');
1327
1869
  output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', { url, title: options.title }), options);
1328
1870
  return;
1329
1871
  }
@@ -1333,6 +1875,8 @@ async function videos(config, options, action, args) {
1333
1875
  }
1334
1876
  if (action === 'transcribe') {
1335
1877
  if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
1878
+ await enforceMaxCredits(config, options, 'videos transcribe', { videoId: args[0] });
1879
+ confirmPaid(options, 'Transcribing a video');
1336
1880
  output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/transcribe`, {}), options);
1337
1881
  return;
1338
1882
  }
@@ -1352,6 +1896,8 @@ async function videos(config, options, action, args) {
1352
1896
  targetPlatforms: stringList(options.platforms),
1353
1897
  themes: stringList(options.themes),
1354
1898
  };
1899
+ await enforceMaxCredits(config, options, 'videos suggest-clips', { videoId: args[0], body });
1900
+ confirmPaid(options, 'Suggesting clips');
1355
1901
  output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/suggest-clips`, body), options);
1356
1902
  return;
1357
1903
  }
@@ -1394,7 +1940,21 @@ async function clips(config, options, action, args) {
1394
1940
  if (!body.videoId) throw Object.assign(new Error('--video-id is required.'), { exitCode: EXIT.USAGE });
1395
1941
  if (body.startTime === undefined) throw Object.assign(new Error('--start is required.'), { exitCode: EXIT.USAGE });
1396
1942
  if (body.endTime === undefined) throw Object.assign(new Error('--end is required.'), { exitCode: EXIT.USAGE });
1397
- output(await apiFetch(config, options, 'POST', '/api/v1/clips', body), options);
1943
+ await enforceMaxCredits(config, options, 'clips create', { body });
1944
+ confirmPaid(options, 'Creating a clip');
1945
+ const result = await apiFetch(config, options, 'POST', '/api/v1/clips', body);
1946
+ if (result?.id) {
1947
+ await persistActiveContext(
1948
+ config,
1949
+ options,
1950
+ { videoId: result.videoId || body.videoId, clipId: result.id },
1951
+ [
1952
+ { type: 'video', id: result.videoId || body.videoId },
1953
+ { type: 'clip', id: result.id },
1954
+ ],
1955
+ );
1956
+ }
1957
+ output(result, options);
1398
1958
  return;
1399
1959
  }
1400
1960
  if (action === 'update') {
@@ -1428,6 +1988,7 @@ async function clips(config, options, action, args) {
1428
1988
  if (body[key] === undefined) delete body[key];
1429
1989
  }
1430
1990
  await enforceMaxCredits(config, options, 'clips render', { clipId: args[0], body });
1991
+ confirmPaid(options, 'Rendering a clip');
1431
1992
  output(await apiFetch(config, options, 'POST', `/api/v1/clips/${encodeURIComponent(args[0])}/render`, body), options);
1432
1993
  return;
1433
1994
  }
@@ -1460,18 +2021,78 @@ async function credits(config, options, action) {
1460
2021
  if (action === 'estimate') {
1461
2022
  const operationType = requiredString(options['operation-type'], '--operation-type');
1462
2023
  const provider = requiredString(options.provider, '--provider');
1463
- const metrics = await readMetricsOption(options.metrics);
1464
- output(await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', compactObject({
2024
+ const metrics = await readMetricsOption(options.metrics ?? options.metadata);
2025
+ const request = normalizeCreditEstimateRequest(compactObject({
1465
2026
  operationType,
1466
2027
  provider,
1467
2028
  modelId: options['model-id'],
1468
2029
  metrics,
1469
- })), options);
2030
+ }));
2031
+ output(await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', request), options);
1470
2032
  return;
1471
2033
  }
1472
2034
  throw Object.assign(new Error(`Unknown credits command: ${action || ''}`), { exitCode: EXIT.USAGE });
1473
2035
  }
1474
2036
 
2037
+ function normalizeBillingProvider(value) {
2038
+ if (value === undefined || value === null || value === '') return undefined;
2039
+ const provider = String(value);
2040
+ if (!BILLING_PROVIDER_PREFERENCES.has(provider)) {
2041
+ throw Object.assign(
2042
+ new Error(`--provider must be one of: ${Array.from(BILLING_PROVIDER_PREFERENCES).join(', ')}.`),
2043
+ { exitCode: EXIT.USAGE },
2044
+ );
2045
+ }
2046
+ return provider;
2047
+ }
2048
+
2049
+ function buildBillingAttemptBody(input = {}) {
2050
+ return compactObject({
2051
+ productKey: requiredString(input.productKey ?? input['product-key'] ?? input.product ?? input.key, '--product-key'),
2052
+ providerPreference: normalizeBillingProvider(input.providerPreference ?? input.provider ?? input.rail),
2053
+ idempotencyKey: input.idempotencyKey ?? input['idempotency-key'],
2054
+ });
2055
+ }
2056
+
2057
+ function billingAttemptId(args, options) {
2058
+ return requiredString(args[0] ?? options['attempt-id'] ?? options.attemptId, 'Attempt id');
2059
+ }
2060
+
2061
+ async function billing(config, options, action, args = []) {
2062
+ if (action === 'capabilities') {
2063
+ output(await apiFetch(config, options, 'GET', '/api/v1/agent/payment-capabilities', undefined, { noAuth: true }), options);
2064
+ return;
2065
+ }
2066
+ if (action === 'catalog') {
2067
+ output(await apiFetch(config, options, 'GET', '/api/v1/billing/catalog', undefined, { noAuth: true }), options);
2068
+ return;
2069
+ }
2070
+ if (action === 'create-attempt') {
2071
+ requireConfirm(options, 'Creating a machine-payment attempt');
2072
+ const body = buildBillingAttemptBody({
2073
+ ...options,
2074
+ productKey: options['product-key'] ?? options.productKey ?? args[0],
2075
+ });
2076
+ output(await apiFetch(config, options, 'POST', '/api/v1/billing/agent-payments', body), options);
2077
+ return;
2078
+ }
2079
+ if (action === 'attempt') {
2080
+ const attemptId = billingAttemptId(args, options);
2081
+ output(await apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}`), options);
2082
+ return;
2083
+ }
2084
+ if (action === 'receipt') {
2085
+ const attemptId = billingAttemptId(args, options);
2086
+ output(await apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}/receipt`), options);
2087
+ return;
2088
+ }
2089
+ if (action === 'subscription') {
2090
+ output(await apiFetch(config, options, 'GET', '/api/v1/billing/subscription'), options);
2091
+ return;
2092
+ }
2093
+ throw Object.assign(new Error(`Unknown billing command: ${action || ''}`), { exitCode: EXIT.USAGE });
2094
+ }
2095
+
1475
2096
  async function analytics(config, options, action, args) {
1476
2097
  if (action === 'overview') {
1477
2098
  const suffix = queryString({ days: options.days ?? 30 });
@@ -1551,7 +2172,7 @@ async function exportsCommand(config, options, action, args) {
1551
2172
  return;
1552
2173
  }
1553
2174
  if (action === 'list') {
1554
- output(await apiFetch(config, options, 'GET', `/api/v1/exports${queryString({ limit: options.limit, offset: options.offset })}`), options);
2175
+ output(await apiFetch(config, options, 'GET', `/api/v1/exports${queryString({ limit: options.limit, offset: options.offset, clipId: options['clip-id'] })}`), options);
1555
2176
  return;
1556
2177
  }
1557
2178
  if (action === 'get') {
@@ -1572,6 +2193,7 @@ async function exportsCommand(config, options, action, args) {
1572
2193
  }
1573
2194
  if (action === 'cancel') {
1574
2195
  const jobId = requiredString(args[0], 'Export job id');
2196
+ requireConfirm(options, 'Cancelling an export');
1575
2197
  output(await apiFetch(config, options, 'POST', `/api/v1/exports/${encodeURIComponent(jobId)}/cancel`, {}), options);
1576
2198
  return;
1577
2199
  }
@@ -1621,6 +2243,7 @@ async function uploadAsset(config, options, filePath) {
1621
2243
  size: stat.size,
1622
2244
  kind: options.kind,
1623
2245
  });
2246
+ requireConfirm(options, 'Uploading an asset');
1624
2247
  const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
1625
2248
  if (!signed?.uploadUrl || !signed?.key || !signed?.assetId) {
1626
2249
  throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
@@ -1809,19 +2432,28 @@ async function jobs(config, options, action, args) {
1809
2432
  const jobId = args[0];
1810
2433
  if (!jobId) throw Object.assign(new Error('Job id is required.'), { exitCode: EXIT.USAGE });
1811
2434
  const startedAt = Date.now();
1812
- const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
1813
- const intervalMs = numberOption(options.interval, '--interval') || 3000;
2435
+ const requestedTimeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
2436
+ const requestedIntervalMs = numberOption(options.interval, '--interval');
1814
2437
  while (true) {
1815
2438
  const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
1816
2439
  if (action === 'get' || TERMINAL_JOB_STATUSES.has(job.status)) {
1817
2440
  output(job, options);
1818
2441
  return;
1819
2442
  }
1820
- if (options.stream) console.log(JSON.stringify({ type: 'job.progress', job }));
2443
+ if (options.stream) console.log(JSON.stringify({ type: 'job.progress', job: redactDeep(job) }));
2444
+ const serverMinimumWaitMs = typeof job.minimumWaitSeconds === 'number'
2445
+ ? Math.max(0, job.minimumWaitSeconds * 1000)
2446
+ : 0;
2447
+ const timeoutMs = requestedTimeoutMs
2448
+ ? Math.max(requestedTimeoutMs, serverMinimumWaitMs)
2449
+ : undefined;
1821
2450
  if (timeoutMs && Date.now() - startedAt > timeoutMs) {
1822
2451
  throw Object.assign(new Error(`Timed out waiting for job ${jobId}.`), { exitCode: EXIT.SERVER });
1823
2452
  }
1824
- await sleep(intervalMs);
2453
+ const serverIntervalMs = typeof job.recommendedPollIntervalSeconds === 'number'
2454
+ ? Math.max(1000, job.recommendedPollIntervalSeconds * 1000)
2455
+ : 0;
2456
+ await sleep(requestedIntervalMs || serverIntervalMs || 3000);
1825
2457
  }
1826
2458
  }
1827
2459
 
@@ -1865,9 +2497,8 @@ async function appUrl(config, options, kind, id) {
1865
2497
  params.set('clip', id);
1866
2498
  return `${baseUrl}/clips/review?${params.toString()}`;
1867
2499
  }
1868
- // A video link should open that video's clips in the Review page (which keys
1869
- // off ?video=), not the generic /clips library list which has no selection.
1870
- if (kind === 'video') return `${baseUrl}/clips/review${id ? `?video=${encodeURIComponent(id)}` : ''}`;
2500
+ // A video-level agent result should land in the Agent Content library tab.
2501
+ if (kind === 'video') return `${baseUrl}/clips${id ? `?tab=agent&video=${encodeURIComponent(id)}` : '?tab=agent'}`;
1871
2502
  // The editor lives at /editor and reads ?project= (NOT /editor/projects, which
1872
2503
  // redirects to /clips and drops the query — the same dead-end class as the
1873
2504
  // clip/video link bug). There is no surface that consumes a raw sequenceId, so
@@ -1904,24 +2535,498 @@ async function links(config, options, kind, id) {
1904
2535
  output(result, options);
1905
2536
  }
1906
2537
 
2538
+ function mcpToolInputSchema(tool) {
2539
+ const schema = tool?.inputSchema || tool?.parametersSchema || tool?.parameterSchema || tool?.parameters;
2540
+ if (schema && typeof schema === 'object' && !Array.isArray(schema)) return schema;
2541
+ return { type: 'object', additionalProperties: true };
2542
+ }
2543
+
2544
+ function mcpToolConfirmationDescription(tool) {
2545
+ if (!mcpToolRequiresConfirmation(tool, tool?.name)) return null;
2546
+ const parts = [
2547
+ tool?.confirmation?.riskLevel ? `risk=${tool.confirmation.riskLevel}` : null,
2548
+ tool?.confirmation?.reason || null,
2549
+ ].filter(Boolean);
2550
+ if (parts.length) return parts.join('; ');
2551
+
2552
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
2553
+ if (costBand && costBand !== 'free' && costBand !== 'none') {
2554
+ return `cost=${costBand}; may spend credits or mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.`;
2555
+ }
2556
+ return 'may mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.';
2557
+ }
2558
+
2559
+ function mcpToolDescription(tool) {
2560
+ const confirmationDescription = mcpToolConfirmationDescription(tool);
2561
+ return [
2562
+ tool?.description,
2563
+ tool?.skill ? `Skill: ${tool.skill}` : null,
2564
+ tool?.costBand ? `Cost: ${tool.costBand}` : null,
2565
+ confirmationDescription ? `Confirmation required: ${confirmationDescription}` : null,
2566
+ ].filter(Boolean).join('\n');
2567
+ }
2568
+
2569
+ function mcpToolRequiresConfirmation(tool, name) {
2570
+ if (RUN_METERED_CONFIRMATION_EXEMPTIONS.has(name)) return false;
2571
+ if (RUN_CONFIRMATION_LABELS[name]) return true;
2572
+ if (tool?.confirmation?.required) return true;
2573
+ if (tool?.requiresConfirmation === true) return true;
2574
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
2575
+ return Boolean(costBand && costBand !== 'free' && costBand !== 'none');
2576
+ }
2577
+
2578
+ function readMcpMaxCredits(parameters) {
2579
+ if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters)) return null;
2580
+ let raw;
2581
+ for (const key of ['maxCredits', 'max_credits', 'maxCreditsClip']) {
2582
+ if (raw === undefined && parameters[key] !== undefined) raw = parameters[key];
2583
+ delete parameters[key];
2584
+ }
2585
+ if (raw === undefined) return null;
2586
+ const limit = Number(raw);
2587
+ if (!Number.isFinite(limit) || limit < 0) {
2588
+ throw new Error('MCP tools/call maxCredits must be 0 or greater.');
2589
+ }
2590
+ return limit;
2591
+ }
2592
+
2593
+ async function mcpEstimatePayload(config, options, tool, name, parameters, payload) {
2594
+ const runEstimate = tool?.estimate ?? tool?.confirmation?.estimate ?? tool?.confirmation?.costEstimate ?? null;
2595
+ if (runEstimate) {
2596
+ const estimatedCostClip = estimatedClipCostFromData(runEstimate);
2597
+ return compactObject({
2598
+ estimate: compactObject({
2599
+ estimatedCostClip,
2600
+ estimatedCostLabel: estimatedCostClip === null ? undefined : clipCostLabel(estimatedCostClip),
2601
+ affordable: runEstimate.affordable,
2602
+ estimates: [runEstimate],
2603
+ }),
2604
+ });
2605
+ }
2606
+
2607
+ try {
2608
+ const estimates = await buildStaticRunEstimates(config, options, name, parameters, payload);
2609
+ if (estimates?.length) {
2610
+ const estimatedCostClip = estimates.reduce((sum, estimate) => sum + Number(estimate.estimatedCostClip ?? 0), 0);
2611
+ return {
2612
+ estimate: {
2613
+ estimatedCostClip,
2614
+ estimatedCostLabel: clipCostLabel(estimatedCostClip),
2615
+ affordable: estimates.every((estimate) => estimate.affordable !== false),
2616
+ estimates,
2617
+ },
2618
+ };
2619
+ }
2620
+ } catch (error) {
2621
+ return { estimateUnavailable: redact(error.message || String(error)) };
2622
+ }
2623
+
2624
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
2625
+ if (costBand && costBand !== 'free' && costBand !== 'none') {
2626
+ return { estimateUnavailable: 'No local cost estimate is available for this tool; use estimateOperationCost before approval when possible.' };
2627
+ }
2628
+ return {};
2629
+ }
2630
+
2631
+ async function mcpMaxCreditsResult(config, options, tool, name, parameters, payload, maxCredits) {
2632
+ if (maxCredits === null) return null;
2633
+ const estimatePayload = await mcpEstimatePayload(config, options, tool, name, parameters, payload);
2634
+ if (!estimatePayload.estimate) {
2635
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
2636
+ if (!mcpToolRequiresConfirmation(tool, name) && (!costBand || costBand === 'free' || costBand === 'none')) {
2637
+ return null;
2638
+ }
2639
+ return {
2640
+ error: 'max_credits_unenforceable',
2641
+ functionName: name,
2642
+ maxCredits,
2643
+ maxCreditsLabel: clipCostLabel(maxCredits),
2644
+ estimateUnavailable: estimatePayload.estimateUnavailable || 'No local cost estimate is available for this tool.',
2645
+ };
2646
+ }
2647
+ const estimatedCostClip = Number(estimatePayload.estimate.estimatedCostClip);
2648
+ if (!Number.isFinite(estimatedCostClip)) {
2649
+ return {
2650
+ error: 'max_credits_unenforceable',
2651
+ functionName: name,
2652
+ maxCredits,
2653
+ maxCreditsLabel: clipCostLabel(maxCredits),
2654
+ estimate: estimatePayload.estimate,
2655
+ estimateUnavailable: 'The available estimate did not include an estimatedCostClip value.',
2656
+ };
2657
+ }
2658
+ if (estimatedCostClip > maxCredits) {
2659
+ return {
2660
+ error: 'max_credits_exceeded',
2661
+ functionName: name,
2662
+ maxCredits,
2663
+ maxCreditsLabel: clipCostLabel(maxCredits),
2664
+ estimatedCostClip,
2665
+ estimatedCostLabel: clipCostLabel(estimatedCostClip),
2666
+ estimate: estimatePayload.estimate,
2667
+ };
2668
+ }
2669
+ return null;
2670
+ }
2671
+
2672
+ async function mcpConfirmationPayload(config, options, tool, name, parameters, payload, maxCredits = null) {
2673
+ const reason = tool?.confirmation?.reason || `${name} may spend credits or mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.`;
2674
+ const confirmation = compactObject({
2675
+ requiresConfirmation: true,
2676
+ functionName: name,
2677
+ reason,
2678
+ confirmation: tool?.confirmation || undefined,
2679
+ confirmationParams: parameters,
2680
+ retryArguments: {
2681
+ ...(parameters || {}),
2682
+ confirmed: true,
2683
+ maxCredits: maxCredits ?? undefined,
2684
+ },
2685
+ });
2686
+
2687
+ Object.assign(confirmation, await mcpEstimatePayload(config, options, tool, name, parameters, payload));
2688
+
2689
+ return confirmation;
2690
+ }
2691
+
2692
+ function mcpTextResult(value, isError = false) {
2693
+ const text = typeof value === 'string' ? value : JSON.stringify(redactDeep(value), null, 2);
2694
+ return compactObject({
2695
+ content: [{ type: 'text', text }],
2696
+ isError: isError || undefined,
2697
+ });
2698
+ }
2699
+
2700
+ async function fetchMcpTools(config, options) {
2701
+ const response = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
2702
+ return mergeMcpTools(Array.isArray(response?.tools) ? response.tools : []);
2703
+ }
2704
+
2705
+ function mergeMcpTools(serverTools) {
2706
+ const seen = new Set();
2707
+ const merged = [];
2708
+ for (const tool of [...LOCAL_MCP_BILLING_TOOLS, ...serverTools]) {
2709
+ if (typeof tool?.name !== 'string' || seen.has(tool.name)) continue;
2710
+ seen.add(tool.name);
2711
+ merged.push(tool);
2712
+ }
2713
+ return merged;
2714
+ }
2715
+
2716
+ function localMcpBillingTool(name) {
2717
+ return LOCAL_MCP_BILLING_TOOLS.find((tool) => tool.name === name) || null;
2718
+ }
2719
+
2720
+ async function handleLocalMcpBillingTool(config, options, name, parameters = {}) {
2721
+ if (name === 'getPaymentCapabilities') {
2722
+ return apiFetch(config, options, 'GET', '/api/v1/agent/payment-capabilities', undefined, { noAuth: true });
2723
+ }
2724
+ if (name === 'getBillingCatalog') {
2725
+ return apiFetch(config, options, 'GET', '/api/v1/billing/catalog', undefined, { noAuth: true });
2726
+ }
2727
+ if (name === 'createPaymentAttempt') {
2728
+ return apiFetch(config, options, 'POST', '/api/v1/billing/agent-payments', buildBillingAttemptBody(parameters));
2729
+ }
2730
+ if (name === 'getPaymentAttempt') {
2731
+ const attemptId = requiredString(parameters.attemptId ?? parameters.attempt_id, 'attemptId');
2732
+ return apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}`);
2733
+ }
2734
+ if (name === 'getPaymentReceipt') {
2735
+ const attemptId = requiredString(parameters.attemptId ?? parameters.attempt_id, 'attemptId');
2736
+ return apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}/receipt`);
2737
+ }
2738
+ if (name === 'getBillingSubscription') {
2739
+ return apiFetch(config, options, 'GET', '/api/v1/billing/subscription');
2740
+ }
2741
+ throw Object.assign(new Error(`Unknown local billing tool: ${name}`), { exitCode: EXIT.USAGE });
2742
+ }
2743
+
2744
+ async function handleMcpRequest(config, options, message) {
2745
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
2746
+ return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid JSON-RPC request.' } };
2747
+ }
2748
+
2749
+ const id = message.id ?? null;
2750
+ const method = message.method;
2751
+ const isNotification = message.id === undefined;
2752
+
2753
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
2754
+ if (method === 'initialize') {
2755
+ return {
2756
+ jsonrpc: '2.0',
2757
+ id,
2758
+ result: {
2759
+ protocolVersion: message.params?.protocolVersion || '2024-11-05',
2760
+ capabilities: { tools: {} },
2761
+ serverInfo: { name: 'clipit', version: VERSION },
2762
+ },
2763
+ };
2764
+ }
2765
+ if (method === 'ping') return isNotification ? null : { jsonrpc: '2.0', id, result: {} };
2766
+ if (isNotification) return null;
2767
+
2768
+ if (method === 'tools/list') {
2769
+ const tools = await fetchMcpTools(config, options);
2770
+ return {
2771
+ jsonrpc: '2.0',
2772
+ id,
2773
+ result: {
2774
+ tools: tools.map((tool) => ({
2775
+ name: tool.name,
2776
+ description: mcpToolDescription(tool),
2777
+ inputSchema: mcpToolInputSchema(tool),
2778
+ })).filter((tool) => typeof tool.name === 'string' && tool.name.length > 0),
2779
+ },
2780
+ };
2781
+ }
2782
+
2783
+ if (method === 'tools/call') {
2784
+ const name = typeof message.params?.name === 'string' ? message.params.name : null;
2785
+ if (!name) {
2786
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: 'tools/call requires params.name.' } };
2787
+ }
2788
+ const parameters = message.params?.arguments && typeof message.params.arguments === 'object' && !Array.isArray(message.params.arguments)
2789
+ ? { ...message.params.arguments }
2790
+ : {};
2791
+ const confirmed = parameters.confirmed === true || parameters.confirm === true;
2792
+ delete parameters.confirm;
2793
+ if (confirmed) parameters.confirmed = true;
2794
+ else delete parameters.confirmed;
2795
+ let maxCredits = null;
2796
+ try {
2797
+ maxCredits = readMcpMaxCredits(parameters);
2798
+ } catch (error) {
2799
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: error.message } };
2800
+ }
2801
+ const tools = await fetchMcpTools(config, options);
2802
+ const tool = tools.find((candidate) => candidate.name === name);
2803
+ if (!tool) {
2804
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown ClipIt tool: ${name}` } };
2805
+ }
2806
+ const localBillingTool = localMcpBillingTool(name);
2807
+ const payload = {
2808
+ functionName: name,
2809
+ parameters,
2810
+ confirmed,
2811
+ };
2812
+ if (localBillingTool) {
2813
+ if (!confirmed && mcpToolRequiresConfirmation(localBillingTool, name)) {
2814
+ return {
2815
+ jsonrpc: '2.0',
2816
+ id,
2817
+ result: mcpTextResult(await mcpConfirmationPayload(config, options, localBillingTool, name, parameters, payload, maxCredits)),
2818
+ };
2819
+ }
2820
+ const result = await handleLocalMcpBillingTool(config, options, name, parameters);
2821
+ return { jsonrpc: '2.0', id, result: mcpTextResult(result, Boolean(result?.error)) };
2822
+ }
2823
+ applyContextToAgentPayload(payload, parameters, await buildContext(config, options));
2824
+ if (!confirmed && mcpToolRequiresConfirmation(tool, name)) {
2825
+ return {
2826
+ jsonrpc: '2.0',
2827
+ id,
2828
+ result: mcpTextResult(await mcpConfirmationPayload(config, options, tool, name, parameters, payload, maxCredits)),
2829
+ };
2830
+ }
2831
+ const maxCreditsResult = await mcpMaxCreditsResult(config, options, tool, name, parameters, payload, maxCredits);
2832
+ if (maxCreditsResult) {
2833
+ return {
2834
+ jsonrpc: '2.0',
2835
+ id,
2836
+ result: mcpTextResult(maxCreditsResult, true),
2837
+ };
2838
+ }
2839
+ const result = normalizeAgentExecuteResult(await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload));
2840
+ return { jsonrpc: '2.0', id, result: mcpTextResult(result, Boolean(result?.error)) };
2841
+ }
2842
+
2843
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unsupported MCP method: ${method || ''}` } };
2844
+ }
2845
+
2846
+ function mcpFrame(message) {
2847
+ const body = JSON.stringify(redactDeep(message));
2848
+ return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
2849
+ }
2850
+
2851
+ function mcpHeaderEnd(buffer) {
2852
+ const crlf = buffer.indexOf('\r\n\r\n');
2853
+ const lf = buffer.indexOf('\n\n');
2854
+ if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
2855
+ if (lf === -1) return { index: crlf, length: 4 };
2856
+ return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
2857
+ }
2858
+
2859
+ function mcpLooksFramed(buffer) {
2860
+ const prefix = 'Content-Length:';
2861
+ const sample = buffer.toString('utf8', 0, Math.min(buffer.length, prefix.length));
2862
+ if (prefix.startsWith(sample)) return buffer.length < prefix.length ? null : true;
2863
+ return false;
2864
+ }
2865
+
2866
+ function readMcpFrame(buffer) {
2867
+ const headerEnd = mcpHeaderEnd(buffer);
2868
+ if (!headerEnd) return { incomplete: true, buffer };
2869
+ const header = buffer.subarray(0, headerEnd.index).toString('utf8');
2870
+ const match = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
2871
+ if (!match) {
2872
+ return {
2873
+ message: null,
2874
+ error: { code: -32600, message: 'Invalid MCP frame: missing Content-Length header.' },
2875
+ buffer: Buffer.alloc(0),
2876
+ };
2877
+ }
2878
+ const contentLength = Number(match[1]);
2879
+ const bodyStart = headerEnd.index + headerEnd.length;
2880
+ const bodyEnd = bodyStart + contentLength;
2881
+ if (buffer.length < bodyEnd) return { incomplete: true, buffer };
2882
+ const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');
2883
+ try {
2884
+ return {
2885
+ message: JSON.parse(body),
2886
+ buffer: buffer.subarray(bodyEnd),
2887
+ };
2888
+ } catch (error) {
2889
+ return {
2890
+ message: null,
2891
+ error: { code: -32700, message: `Parse error: ${error.message}` },
2892
+ buffer: buffer.subarray(bodyEnd),
2893
+ };
2894
+ }
2895
+ }
2896
+
2897
+ async function respondToMcpMessage(config, options, message, send) {
2898
+ try {
2899
+ send(await handleMcpRequest(config, options, message));
2900
+ } catch (error) {
2901
+ send({
2902
+ jsonrpc: '2.0',
2903
+ id: message?.id ?? null,
2904
+ error: {
2905
+ code: -32000,
2906
+ message: redact(error.message || String(error)),
2907
+ data: redactDeep(compactObject({ status: error.status, requestId: error.requestId, details: error.data })),
2908
+ },
2909
+ });
2910
+ }
2911
+ }
2912
+
2913
+ async function runMcpStdio(config, options) {
2914
+ let framed = null;
2915
+ let frameBuffer = Buffer.alloc(0);
2916
+ let lineBuffer = '';
2917
+ const send = (message) => {
2918
+ if (!message) return;
2919
+ if (framed) {
2920
+ process.stdout.write(mcpFrame(message));
2921
+ return;
2922
+ }
2923
+ process.stdout.write(`${JSON.stringify(redactDeep(message))}\n`);
2924
+ };
2925
+
2926
+ const sendTransportError = (error) => send({ jsonrpc: '2.0', id: null, error });
2927
+ const processLineBuffer = async (flush = false) => {
2928
+ let newlineIndex = lineBuffer.indexOf('\n');
2929
+ while (newlineIndex !== -1) {
2930
+ const line = lineBuffer.slice(0, newlineIndex).trim();
2931
+ lineBuffer = lineBuffer.slice(newlineIndex + 1);
2932
+ newlineIndex = lineBuffer.indexOf('\n');
2933
+ if (!line) continue;
2934
+ let message;
2935
+ try {
2936
+ message = JSON.parse(line);
2937
+ } catch (error) {
2938
+ sendTransportError({ code: -32700, message: `Parse error: ${error.message}` });
2939
+ continue;
2940
+ }
2941
+ await respondToMcpMessage(config, options, message, send);
2942
+ }
2943
+ if (flush && lineBuffer.trim()) {
2944
+ const line = lineBuffer.trim();
2945
+ lineBuffer = '';
2946
+ let message;
2947
+ try {
2948
+ message = JSON.parse(line);
2949
+ } catch (error) {
2950
+ sendTransportError({ code: -32700, message: `Parse error: ${error.message}` });
2951
+ return;
2952
+ }
2953
+ await respondToMcpMessage(config, options, message, send);
2954
+ }
2955
+ };
2956
+
2957
+ for await (const chunk of process.stdin) {
2958
+ const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2959
+ let appendedToFrame = false;
2960
+ if (framed === null) {
2961
+ frameBuffer = Buffer.concat([frameBuffer, chunkBuffer]);
2962
+ appendedToFrame = true;
2963
+ const decision = mcpLooksFramed(frameBuffer);
2964
+ if (decision === null) continue;
2965
+ framed = decision;
2966
+ if (!framed) {
2967
+ lineBuffer += frameBuffer.toString('utf8');
2968
+ frameBuffer = Buffer.alloc(0);
2969
+ await processLineBuffer();
2970
+ continue;
2971
+ }
2972
+ }
2973
+
2974
+ if (!framed) {
2975
+ lineBuffer += chunkBuffer.toString('utf8');
2976
+ await processLineBuffer();
2977
+ continue;
2978
+ }
2979
+
2980
+ if (framed && !appendedToFrame) {
2981
+ frameBuffer = Buffer.concat([frameBuffer, chunkBuffer]);
2982
+ }
2983
+ while (framed) {
2984
+ const parsed = readMcpFrame(frameBuffer);
2985
+ frameBuffer = parsed.buffer;
2986
+ if (parsed.incomplete) break;
2987
+ if (parsed.error) {
2988
+ sendTransportError(parsed.error);
2989
+ continue;
2990
+ }
2991
+ await respondToMcpMessage(config, options, parsed.message, send);
2992
+ }
2993
+ }
2994
+
2995
+ if (framed === false) await processLineBuffer(true);
2996
+ }
2997
+
2998
+ async function mcp(config, options, action) {
2999
+ if (action && action !== 'stdio') {
3000
+ throw Object.assign(new Error(`Unknown mcp command: ${action}`), { exitCode: EXIT.USAGE });
3001
+ }
3002
+ await runMcpStdio(config, options);
3003
+ }
3004
+
1907
3005
  async function examples(options) {
1908
3006
  const data = {
1909
3007
  login: 'clipit login',
1910
3008
  installCodexSkill: 'clipit agent install codex',
1911
3009
  validate: 'clipit doctor --json',
1912
- importUrl: 'clipit videos import-url "https://www.youtube.com/watch?v=..." --json',
3010
+ uploadVideo: 'clipit videos upload ./source.mp4 --confirm --json',
3011
+ importUrl: 'clipit videos import-url "https://www.youtube.com/watch?v=..." --confirm --json',
1913
3012
  waitForJob: 'clipit jobs wait <jobId> --json',
1914
3013
  ask: 'clipit ask "Find the strongest clip in this video" --video-id <videoId>',
1915
3014
  approveWorkflow: 'clipit workflow approve <jobId> --approval-id <approvalId> --decision approved',
1916
3015
  waitForWorkflow: 'clipit workflow wait <jobId> --stream',
1917
3016
  setContext: 'clipit context use --video-id <videoId>',
1918
- suggestClips: 'clipit videos suggest-clips <videoId> --count 5 --json',
1919
- createClip: 'clipit clips create --video-id <videoId> --start 12 --end 42 --title "Strong hook" --json',
1920
- renderClip: 'clipit clips render <clipId> --aspect 9:16 --quality high --json',
3017
+ mcp: 'clipit mcp stdio',
3018
+ suggestClips: 'clipit videos suggest-clips <videoId> --count 5 --confirm --json',
3019
+ createClip: 'clipit clips create --video-id <videoId> --start 12 --end 42 --title "Strong hook" --confirm --json',
3020
+ renderClip: 'clipit clips render <clipId> --aspect 9:16 --quality high --confirm --json',
1921
3021
  creditsBalance: 'clipit credits balance --json',
3022
+ billingCapabilities: 'clipit billing capabilities --json',
3023
+ billingCatalog: 'clipit billing catalog --json',
3024
+ billingX402Attempt: 'clipit billing create-attempt --product-key boost --provider x402_direct --confirm --json',
3025
+ billingStripeX402Attempt: 'clipit billing create-attempt --product-key boost --provider stripe_x402 --confirm --json',
3026
+ billingLinkAttempt: 'clipit billing create-attempt --product-key boost --provider stripe_mpp --confirm --json',
1922
3027
  analyticsOverview: 'clipit analytics overview --days 30 --json',
1923
3028
  exportClip: 'clipit exports start --clip-id <clipId> --confirm --json',
1924
- uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --json',
3029
+ uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
1925
3030
  thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
1926
3031
  brollPlan: 'clipit broll plan <clipId> --count 3 --confirm --json',
1927
3032
  socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json',
@@ -1945,11 +3050,16 @@ Rules:
1945
3050
  - If not connected, ask the user to run \`clipit login\` and approve the browser link.
1946
3051
  - Never ask the user to paste API keys into chat.
1947
3052
  - Use \`clipit skills list --json\` and \`clipit tools list --json\` to discover capability.
3053
+ - Use \`clipit mcp stdio\` when an MCP-compatible client can launch local stdio servers; it speaks standard \`Content-Length\` framed JSON-RPC.
1948
3054
  - Prefer friendly commands such as \`clipit videos list --json\`, \`clipit clips list --json\`, \`clipit credits balance --json\`, and \`clipit jobs wait <jobId> --json\`.
1949
3055
  - Use \`clipit ask "..."\` for natural-language Clippy workflows, and \`clipit workflow wait <jobId> --json\` or \`clipit workflow approve <jobId> --approval-id <id>\` for workflow follow-through.
3056
+ - Use \`clipit ask "..." --auto-confirm-costly\` only when the human has asked you to work autonomously on ClipIt-only cost-spending tasks. It never authorizes social publishing/scheduling or destructive actions.
1950
3057
  - Use \`clipit run <functionName> --params @file.json --json\` for exact Clippy tools.
1951
3058
  - Treat paid generation, publishing, deletion, broad mutation, and \`requiresConfirmation\` responses as user approval checkpoints.
1952
3059
  - Treat exit code 13 as insufficient credits or an API key spend-limit block; top up billing or adjust the key's spend limit in ClipIt Settings.
3060
+ - When credits are insufficient, discover payment rails with \`clipit billing capabilities --json\` and \`clipit billing catalog --json\`.
3061
+ - Only create direct x402, Stripe-managed x402, or Stripe Link/SPT payment attempts after explicit human approval or a configured budget policy: \`clipit billing create-attempt --product-key <key> --provider x402_direct|stripe_x402|stripe_mpp --confirm --json\`.
3062
+ - Credits from subscriptions and top-ups stack; paid features are credit-gated, so verify the payment receipt or balance before running paid ClipIt tools.
1953
3063
  - Use \`clipit open clip <id>\` when the user should review work in ClipIt.
1954
3064
  - Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
1955
3065
  - Do not write API keys into this skill file or any project files.
@@ -1961,24 +3071,32 @@ clipit auth status --json
1961
3071
  clipit skills list --json
1962
3072
  clipit tools list --json
1963
3073
  clipit tools describe <functionName> --json
3074
+ clipit mcp stdio
1964
3075
  clipit ask "Find the strongest clip in this video" --video-id <videoId> --json
1965
3076
  clipit workflow wait <jobId> --stream
1966
3077
  clipit videos list --json
1967
- clipit videos import-url "https://example.com/video" --json
3078
+ clipit videos upload ./source.mp4 --confirm --json
3079
+ clipit videos import-url "https://example.com/video" --confirm --json
1968
3080
  clipit videos transcript <videoId> --json
1969
- clipit videos suggest-clips <videoId> --count 5 --json
3081
+ clipit videos suggest-clips <videoId> --count 5 --confirm --json
1970
3082
  clipit clips list --json
1971
- clipit clips create --video-id <videoId> --start 12 --end 42 --title "Hook" --json
1972
- clipit clips render <clipId> --aspect 9:16 --quality high --json
3083
+ clipit clips create --video-id <videoId> --start 12 --end 42 --title "Hook" --confirm --json
3084
+ clipit clips render <clipId> --aspect 9:16 --quality high --confirm --json
1973
3085
  clipit jobs wait <jobId> --stream
1974
3086
  clipit credits balance --json
1975
3087
  clipit credits usage --json
1976
3088
  clipit credits estimate --operation-type transcription --provider deepgram --metrics @metrics.json --json
3089
+ clipit billing capabilities --json
3090
+ clipit billing catalog --json
3091
+ clipit billing create-attempt --product-key boost --provider x402_direct --confirm --json
3092
+ clipit billing create-attempt --product-key boost --provider stripe_x402 --confirm --json
3093
+ clipit billing create-attempt --product-key boost --provider stripe_mpp --confirm --json
3094
+ clipit billing receipt <attemptId> --json
1977
3095
  clipit analytics overview --days 30 --json
1978
3096
  clipit exports start --clip-id <clipId> --confirm --json
1979
3097
  clipit thumbnails generate --clip-id <clipId> --prompt "High contrast thumbnail" --confirm --json
1980
3098
  clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json
1981
- clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --json
3099
+ clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
1982
3100
  \`\`\`
1983
3101
 
1984
3102
  Common workflow:
@@ -1992,6 +3110,28 @@ Agent target: ${agent}
1992
3110
  `;
1993
3111
  }
1994
3112
 
3113
+ function mcpSkillAddendum() {
3114
+ return [
3115
+ '## MCP Stdio Bridge',
3116
+ '- If your agent runtime supports MCP stdio servers, prefer launching `clipit mcp stdio` from the user\'s machine instead of manually shelling every command.',
3117
+ '- The bridge uses the authenticated CLI profile, speaks standard `Content-Length` framed JSON-RPC over stdio, and exposes the same live tools as `clipit tools list --json` / `GET /api/v1/agent/tools`.',
3118
+ '- `tools/call` delegates to `/api/v1/agent/execute`, inherits the active CLI context from `clipit context use`, and preserves confirmation gates for paid generation, publishing, deletion, export, and other mutating calls.',
3119
+ '- The bridge also exposes local billing discovery tools: `getPaymentCapabilities`, `getBillingCatalog`, `createPaymentAttempt`, `getPaymentAttempt`, `getPaymentReceipt`, and `getBillingSubscription`.',
3120
+ '- `createPaymentAttempt` returns a payment URL for direct x402, Stripe-managed x402, or Stripe MPP/Link SPT settlement and requires `confirmed:true`; do not retry it until the human approves the product, amount, rail, and budget policy.',
3121
+ '- If the MCP client cannot launch local commands, fall back to the CLI/API commands above.',
3122
+ ].join('\n');
3123
+ }
3124
+
3125
+ function withLocalCliSkillAddenda(markdown) {
3126
+ const addenda = [];
3127
+ let next = String(markdown || '');
3128
+ if (!/clipit\s+mcp\s+stdio/i.test(next) && !/MCP Stdio Bridge/i.test(next)) {
3129
+ next = `${next.trimEnd()}\n\n${mcpSkillAddendum()}\n`;
3130
+ addenda.push('mcp-stdio-bridge');
3131
+ }
3132
+ return { markdown: next, addenda };
3133
+ }
3134
+
1995
3135
  function fallbackSkillResult(target, reason) {
1996
3136
  const generatedAt = new Date().toISOString();
1997
3137
  return {
@@ -2041,10 +3181,14 @@ async function resolveAgentSkill(config, options, target) {
2041
3181
  });
2042
3182
  }
2043
3183
 
3184
+ const augmented = withLocalCliSkillAddenda(response.markdown);
2044
3185
  return {
2045
- markdown: response.markdown,
3186
+ markdown: augmented.markdown,
2046
3187
  source: 'server',
2047
- meta: response.meta,
3188
+ meta: compactObject({
3189
+ ...response.meta,
3190
+ localCliAddenda: augmented.addenda.length ? augmented.addenda : undefined,
3191
+ }),
2048
3192
  };
2049
3193
  }
2050
3194
 
@@ -2065,6 +3209,7 @@ function agentSkillSidecar(target, skill) {
2065
3209
  target,
2066
3210
  source: skill.source,
2067
3211
  generatedAt: skill.meta?.generatedAt,
3212
+ markdownHash: hashText(skill.markdown),
2068
3213
  serverMeta: skill.source === 'server' ? skill.meta : undefined,
2069
3214
  fallbackReason: skill.fallbackReason,
2070
3215
  });
@@ -2088,7 +3233,11 @@ function agentInstallDir(target, options) {
2088
3233
  async function installedAgentStatus(target, options) {
2089
3234
  const baseDir = agentInstallDir(target, options);
2090
3235
  try {
2091
- const stat = await fs.stat(path.join(baseDir, 'SKILL.md'));
3236
+ const skillPath = path.join(baseDir, 'SKILL.md');
3237
+ const [stat, markdown] = await Promise.all([
3238
+ fs.stat(skillPath),
3239
+ fs.readFile(skillPath, 'utf8'),
3240
+ ]);
2092
3241
  const sidecar = await readAgentSkillMeta(baseDir);
2093
3242
  return {
2094
3243
  target,
@@ -2098,6 +3247,7 @@ async function installedAgentStatus(target, options) {
2098
3247
  skillSource: sidecar?.source || 'unknown',
2099
3248
  generatedAt: sidecar?.generatedAt || null,
2100
3249
  fallbackReason: sidecar?.fallbackReason || null,
3250
+ markdownHash: sidecar?.markdownHash || hashText(markdown),
2101
3251
  serverMeta: sidecar?.serverMeta || null,
2102
3252
  };
2103
3253
  } catch {
@@ -2105,6 +3255,24 @@ async function installedAgentStatus(target, options) {
2105
3255
  }
2106
3256
  }
2107
3257
 
3258
+ function agentFreshnessReason(status, currentSkill) {
3259
+ const currentMeta = currentSkill.source === 'server' ? currentSkill.meta : null;
3260
+ if (status.skillSource !== currentSkill.source) {
3261
+ return `installed source ${status.skillSource || 'unknown'} differs from current source ${currentSkill.source}`;
3262
+ }
3263
+ if (status.serverMeta?.toolCount !== undefined
3264
+ && currentMeta?.toolCount !== undefined
3265
+ && status.serverMeta.toolCount !== currentMeta.toolCount) {
3266
+ return `installed tool count ${status.serverMeta.toolCount} differs from current tool count ${currentMeta.toolCount}`;
3267
+ }
3268
+ if (status.serverMeta?.skillCount !== undefined
3269
+ && currentMeta?.skillCount !== undefined
3270
+ && status.serverMeta.skillCount !== currentMeta.skillCount) {
3271
+ return `installed skill count ${status.serverMeta.skillCount} differs from current skill count ${currentMeta.skillCount}`;
3272
+ }
3273
+ return 'installed skill content differs from current generated instructions';
3274
+ }
3275
+
2108
3276
  async function agent(config, options, action, args) {
2109
3277
  const target = args[0] || 'generic';
2110
3278
  const baseDir = agentInstallDir(target, options);
@@ -2121,12 +3289,41 @@ async function agent(config, options, action, args) {
2121
3289
  }
2122
3290
  if (action === 'doctor') {
2123
3291
  const status = await installedAgentStatus(target, options);
3292
+ const hasCredential = Boolean(getApiKey(config, options));
2124
3293
  status.cli = {
2125
3294
  version: VERSION,
2126
3295
  configPath: configPath(),
2127
3296
  profile: profileName(config, options),
2128
- hasCredential: Boolean(getApiKey(config, options)),
3297
+ hasCredential,
2129
3298
  };
3299
+ if (status.installed) {
3300
+ if (!hasCredential) {
3301
+ status.upToDate = null;
3302
+ status.stale = null;
3303
+ status.freshnessError = 'Cannot verify installed skill freshness without credentials.';
3304
+ } else {
3305
+ const currentSkill = await resolveAgentSkill(config, options, target);
3306
+ status.latest = compactObject({
3307
+ skillSource: currentSkill.source,
3308
+ generatedAt: currentSkill.meta?.generatedAt || null,
3309
+ serverMeta: currentSkill.source === 'server' ? currentSkill.meta : undefined,
3310
+ fallbackReason: currentSkill.fallbackReason,
3311
+ });
3312
+ if (currentSkill.source === 'fallback') {
3313
+ status.upToDate = null;
3314
+ status.stale = null;
3315
+ status.freshnessError = `Cannot verify installed skill freshness: ${currentSkill.fallbackReason || 'current instructions unavailable'}`;
3316
+ } else {
3317
+ const currentHash = hashText(currentSkill.markdown);
3318
+ status.upToDate = status.markdownHash === currentHash;
3319
+ status.stale = !status.upToDate;
3320
+ if (status.stale) {
3321
+ status.staleReason = agentFreshnessReason(status, currentSkill);
3322
+ status.updateCommand = `clipit agent update ${target}`;
3323
+ }
3324
+ }
3325
+ }
3326
+ }
2130
3327
  output(status, options);
2131
3328
  return;
2132
3329
  }
@@ -2160,6 +3357,10 @@ async function main() {
2160
3357
  const config = await readConfig();
2161
3358
  const [command, subcommand, ...rest] = positionals;
2162
3359
 
3360
+ if (options.version === true || options.version === 'true') {
3361
+ output({ version: VERSION }, options);
3362
+ return;
3363
+ }
2163
3364
  if (!command || options.help) {
2164
3365
  console.log(usage());
2165
3366
  return;
@@ -2182,11 +3383,13 @@ async function main() {
2182
3383
  if (command === 'tools' && subcommand === 'describe') return describeTool(config, options, rest[0]);
2183
3384
  if (command === 'ask') return askWorkflow(config, options, [subcommand, ...rest]);
2184
3385
  if (command === 'workflow') return workflow(config, options, subcommand, rest);
3386
+ if (command === 'mcp') return mcp(config, options, subcommand);
2185
3387
  if (command === 'run') return runTool(config, options, subcommand);
2186
3388
  if (command === 'videos') return videos(config, options, subcommand, rest);
2187
3389
  if (command === 'clips') return clips(config, options, subcommand, rest);
2188
3390
  if (command === 'jobs') return jobs(config, options, subcommand, rest);
2189
3391
  if (command === 'credits') return credits(config, options, subcommand, rest);
3392
+ if (command === 'billing') return billing(config, options, subcommand, rest);
2190
3393
  if (command === 'analytics') return analytics(config, options, subcommand, rest);
2191
3394
  if (command === 'exports') return exportsCommand(config, options, subcommand, rest);
2192
3395
  if (command === 'assets') return assets(config, options, subcommand, rest);
@@ -2235,4 +3438,4 @@ if (await isDirectRun()) {
2235
3438
  main().catch(handleMainError);
2236
3439
  }
2237
3440
 
2238
- export { main, redact };
3441
+ export { main, redact, clipCostLabel, handleMcpRequest };