@clipit-ai/cli 0.2.2 → 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 +1306 -67
  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.2';
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,41 +2432,85 @@ 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
 
1828
- function appUrl(config, options, kind, id) {
2460
+ async function appUrl(config, options, kind, id) {
1829
2461
  const baseUrl = getBaseUrl(config, options);
1830
2462
  if (!kind || kind === 'home') return baseUrl;
1831
2463
  if (kind === 'settings') return `${baseUrl}/settings?tab=${encodeURIComponent(options.tab || 'api-keys')}`;
1832
2464
  if (kind === 'dashboard') return `${baseUrl}/dashboard`;
1833
2465
  if (kind === 'library') return `${baseUrl}/clips`;
1834
- if (kind === 'editor') return `${baseUrl}/editor`;
2466
+ if (kind === 'editor') {
2467
+ // The editor reads ?project=, ?video=, ?clip= (VideoEditorPage). Thread any
2468
+ // provided context so `open editor --video <id>` lands on real content rather
2469
+ // than the empty "select a video" state.
2470
+ const params = new URLSearchParams();
2471
+ const project = id || options.project || null;
2472
+ if (project) params.set('project', project);
2473
+ if (options.video || options.videoId) params.set('video', options.video || options.videoId);
2474
+ if (options.clip || options.clipId) params.set('clip', options.clip || options.clipId);
2475
+ const qs = params.toString();
2476
+ return `${baseUrl}/editor${qs ? `?${qs}` : ''}`;
2477
+ }
1835
2478
  if (kind === 'pricing') return `${baseUrl}/pricing`;
1836
2479
  if (kind === 'credits') return `${baseUrl}/settings/credits`;
1837
- if (kind === 'clip') return `${baseUrl}/clips/review${id ? `?clipId=${encodeURIComponent(id)}` : ''}`;
1838
- if (kind === 'video') return `${baseUrl}/clips${id ? `?videoId=${encodeURIComponent(id)}` : ''}`;
1839
- if (kind === 'project') return `${baseUrl}/editor/projects${id ? `?projectId=${encodeURIComponent(id)}` : ''}`;
1840
- if (kind === 'sequence') return `${baseUrl}/editor/projects${id ? `?sequenceId=${encodeURIComponent(id)}` : ''}`;
2480
+ if (kind === 'clip') {
2481
+ // A clip lives inside a video. The Clips Review page keys off ?video=<videoId>
2482
+ // without it the page renders "No video selected", so a bare clip link
2483
+ // dead-ends. Resolve the clip's parent video so the link lands on the clip,
2484
+ // selected. ?clip=<clipId> focuses it (the page reads `clip`, not `clipId`).
2485
+ if (!id) return `${baseUrl}/clips/review`;
2486
+ let videoId = options.video || options.videoId || null;
2487
+ if (!videoId) {
2488
+ try {
2489
+ const clip = await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(id)}`);
2490
+ videoId = clip?.videoId || clip?.video?.id || null;
2491
+ } catch {
2492
+ // Network/auth/not-found — fall through to a clip-only link below.
2493
+ }
2494
+ }
2495
+ const params = new URLSearchParams();
2496
+ if (videoId) params.set('video', videoId);
2497
+ params.set('clip', id);
2498
+ return `${baseUrl}/clips/review?${params.toString()}`;
2499
+ }
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'}`;
2502
+ // The editor lives at /editor and reads ?project= (NOT /editor/projects, which
2503
+ // redirects to /clips and drops the query — the same dead-end class as the
2504
+ // clip/video link bug). There is no surface that consumes a raw sequenceId, so
2505
+ // a sequence link just opens the editor.
2506
+ if (kind === 'project') return `${baseUrl}/editor${id ? `?project=${encodeURIComponent(id)}` : ''}`;
2507
+ if (kind === 'sequence') return `${baseUrl}/editor`;
1841
2508
  if (kind === 'route') return `${baseUrl}/${String(id || '').replace(/^\/+/, '')}`;
1842
2509
  return baseUrl;
1843
2510
  }
1844
2511
 
1845
2512
  async function openCommand(config, options, kind, id) {
1846
- const url = appUrl(config, options, kind, id);
2513
+ const url = await appUrl(config, options, kind, id);
1847
2514
  if (options.print) {
1848
2515
  output(url, options);
1849
2516
  return;
@@ -1856,7 +2523,7 @@ async function links(config, options, kind, id) {
1856
2523
  const result = {
1857
2524
  kind: kind || 'home',
1858
2525
  id: id || null,
1859
- appUrl: appUrl(config, options, kind, id),
2526
+ appUrl: await appUrl(config, options, kind, id),
1860
2527
  };
1861
2528
  if (kind === 'clip' && id && boolOption(options.download)) {
1862
2529
  try {
@@ -1868,24 +2535,498 @@ async function links(config, options, kind, id) {
1868
2535
  output(result, options);
1869
2536
  }
1870
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
+
1871
3005
  async function examples(options) {
1872
3006
  const data = {
1873
3007
  login: 'clipit login',
1874
3008
  installCodexSkill: 'clipit agent install codex',
1875
3009
  validate: 'clipit doctor --json',
1876
- 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',
1877
3012
  waitForJob: 'clipit jobs wait <jobId> --json',
1878
3013
  ask: 'clipit ask "Find the strongest clip in this video" --video-id <videoId>',
1879
3014
  approveWorkflow: 'clipit workflow approve <jobId> --approval-id <approvalId> --decision approved',
1880
3015
  waitForWorkflow: 'clipit workflow wait <jobId> --stream',
1881
3016
  setContext: 'clipit context use --video-id <videoId>',
1882
- suggestClips: 'clipit videos suggest-clips <videoId> --count 5 --json',
1883
- createClip: 'clipit clips create --video-id <videoId> --start 12 --end 42 --title "Strong hook" --json',
1884
- 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',
1885
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',
1886
3027
  analyticsOverview: 'clipit analytics overview --days 30 --json',
1887
3028
  exportClip: 'clipit exports start --clip-id <clipId> --confirm --json',
1888
- uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --json',
3029
+ uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
1889
3030
  thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
1890
3031
  brollPlan: 'clipit broll plan <clipId> --count 3 --confirm --json',
1891
3032
  socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json',
@@ -1909,11 +3050,16 @@ Rules:
1909
3050
  - If not connected, ask the user to run \`clipit login\` and approve the browser link.
1910
3051
  - Never ask the user to paste API keys into chat.
1911
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.
1912
3054
  - Prefer friendly commands such as \`clipit videos list --json\`, \`clipit clips list --json\`, \`clipit credits balance --json\`, and \`clipit jobs wait <jobId> --json\`.
1913
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.
1914
3057
  - Use \`clipit run <functionName> --params @file.json --json\` for exact Clippy tools.
1915
3058
  - Treat paid generation, publishing, deletion, broad mutation, and \`requiresConfirmation\` responses as user approval checkpoints.
1916
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.
1917
3063
  - Use \`clipit open clip <id>\` when the user should review work in ClipIt.
1918
3064
  - Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
1919
3065
  - Do not write API keys into this skill file or any project files.
@@ -1925,24 +3071,32 @@ clipit auth status --json
1925
3071
  clipit skills list --json
1926
3072
  clipit tools list --json
1927
3073
  clipit tools describe <functionName> --json
3074
+ clipit mcp stdio
1928
3075
  clipit ask "Find the strongest clip in this video" --video-id <videoId> --json
1929
3076
  clipit workflow wait <jobId> --stream
1930
3077
  clipit videos list --json
1931
- 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
1932
3080
  clipit videos transcript <videoId> --json
1933
- clipit videos suggest-clips <videoId> --count 5 --json
3081
+ clipit videos suggest-clips <videoId> --count 5 --confirm --json
1934
3082
  clipit clips list --json
1935
- clipit clips create --video-id <videoId> --start 12 --end 42 --title "Hook" --json
1936
- 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
1937
3085
  clipit jobs wait <jobId> --stream
1938
3086
  clipit credits balance --json
1939
3087
  clipit credits usage --json
1940
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
1941
3095
  clipit analytics overview --days 30 --json
1942
3096
  clipit exports start --clip-id <clipId> --confirm --json
1943
3097
  clipit thumbnails generate --clip-id <clipId> --prompt "High contrast thumbnail" --confirm --json
1944
3098
  clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json
1945
- clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --json
3099
+ clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
1946
3100
  \`\`\`
1947
3101
 
1948
3102
  Common workflow:
@@ -1956,6 +3110,28 @@ Agent target: ${agent}
1956
3110
  `;
1957
3111
  }
1958
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
+
1959
3135
  function fallbackSkillResult(target, reason) {
1960
3136
  const generatedAt = new Date().toISOString();
1961
3137
  return {
@@ -2005,10 +3181,14 @@ async function resolveAgentSkill(config, options, target) {
2005
3181
  });
2006
3182
  }
2007
3183
 
3184
+ const augmented = withLocalCliSkillAddenda(response.markdown);
2008
3185
  return {
2009
- markdown: response.markdown,
3186
+ markdown: augmented.markdown,
2010
3187
  source: 'server',
2011
- meta: response.meta,
3188
+ meta: compactObject({
3189
+ ...response.meta,
3190
+ localCliAddenda: augmented.addenda.length ? augmented.addenda : undefined,
3191
+ }),
2012
3192
  };
2013
3193
  }
2014
3194
 
@@ -2029,6 +3209,7 @@ function agentSkillSidecar(target, skill) {
2029
3209
  target,
2030
3210
  source: skill.source,
2031
3211
  generatedAt: skill.meta?.generatedAt,
3212
+ markdownHash: hashText(skill.markdown),
2032
3213
  serverMeta: skill.source === 'server' ? skill.meta : undefined,
2033
3214
  fallbackReason: skill.fallbackReason,
2034
3215
  });
@@ -2052,7 +3233,11 @@ function agentInstallDir(target, options) {
2052
3233
  async function installedAgentStatus(target, options) {
2053
3234
  const baseDir = agentInstallDir(target, options);
2054
3235
  try {
2055
- 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
+ ]);
2056
3241
  const sidecar = await readAgentSkillMeta(baseDir);
2057
3242
  return {
2058
3243
  target,
@@ -2062,6 +3247,7 @@ async function installedAgentStatus(target, options) {
2062
3247
  skillSource: sidecar?.source || 'unknown',
2063
3248
  generatedAt: sidecar?.generatedAt || null,
2064
3249
  fallbackReason: sidecar?.fallbackReason || null,
3250
+ markdownHash: sidecar?.markdownHash || hashText(markdown),
2065
3251
  serverMeta: sidecar?.serverMeta || null,
2066
3252
  };
2067
3253
  } catch {
@@ -2069,6 +3255,24 @@ async function installedAgentStatus(target, options) {
2069
3255
  }
2070
3256
  }
2071
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
+
2072
3276
  async function agent(config, options, action, args) {
2073
3277
  const target = args[0] || 'generic';
2074
3278
  const baseDir = agentInstallDir(target, options);
@@ -2085,12 +3289,41 @@ async function agent(config, options, action, args) {
2085
3289
  }
2086
3290
  if (action === 'doctor') {
2087
3291
  const status = await installedAgentStatus(target, options);
3292
+ const hasCredential = Boolean(getApiKey(config, options));
2088
3293
  status.cli = {
2089
3294
  version: VERSION,
2090
3295
  configPath: configPath(),
2091
3296
  profile: profileName(config, options),
2092
- hasCredential: Boolean(getApiKey(config, options)),
3297
+ hasCredential,
2093
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
+ }
2094
3327
  output(status, options);
2095
3328
  return;
2096
3329
  }
@@ -2124,6 +3357,10 @@ async function main() {
2124
3357
  const config = await readConfig();
2125
3358
  const [command, subcommand, ...rest] = positionals;
2126
3359
 
3360
+ if (options.version === true || options.version === 'true') {
3361
+ output({ version: VERSION }, options);
3362
+ return;
3363
+ }
2127
3364
  if (!command || options.help) {
2128
3365
  console.log(usage());
2129
3366
  return;
@@ -2146,11 +3383,13 @@ async function main() {
2146
3383
  if (command === 'tools' && subcommand === 'describe') return describeTool(config, options, rest[0]);
2147
3384
  if (command === 'ask') return askWorkflow(config, options, [subcommand, ...rest]);
2148
3385
  if (command === 'workflow') return workflow(config, options, subcommand, rest);
3386
+ if (command === 'mcp') return mcp(config, options, subcommand);
2149
3387
  if (command === 'run') return runTool(config, options, subcommand);
2150
3388
  if (command === 'videos') return videos(config, options, subcommand, rest);
2151
3389
  if (command === 'clips') return clips(config, options, subcommand, rest);
2152
3390
  if (command === 'jobs') return jobs(config, options, subcommand, rest);
2153
3391
  if (command === 'credits') return credits(config, options, subcommand, rest);
3392
+ if (command === 'billing') return billing(config, options, subcommand, rest);
2154
3393
  if (command === 'analytics') return analytics(config, options, subcommand, rest);
2155
3394
  if (command === 'exports') return exportsCommand(config, options, subcommand, rest);
2156
3395
  if (command === 'assets') return assets(config, options, subcommand, rest);
@@ -2199,4 +3438,4 @@ if (await isDirectRun()) {
2199
3438
  main().catch(handleMainError);
2200
3439
  }
2201
3440
 
2202
- export { main, redact };
3441
+ export { main, redact, clipCostLabel, handleMcpRequest };