@clipit-ai/cli 0.2.3 → 0.2.6

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 (4) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +46 -14
  3. package/bin/clipit.mjs +2335 -166
  4. package/package.json +1 -1
package/bin/clipit.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { createHash, randomBytes } from 'node:crypto';
2
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { createReadStream } from 'node:fs';
5
5
  import { Transform } from 'node:stream';
@@ -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.6';
13
13
  const DEFAULT_BASE_URL = 'https://clipit.dev';
14
14
  const DEFAULT_SCOPES = [
15
15
  'clippy_agent',
@@ -23,6 +23,7 @@ const DEFAULT_SCOPES = [
23
23
  'broll_generation',
24
24
  'audio_generation',
25
25
  'video_alteration',
26
+ 'social_publishing',
26
27
  ];
27
28
 
28
29
  const GET_RETRY_DELAYS_MS = [500, 2000];
@@ -30,6 +31,25 @@ const RETRY_AFTER_CAP_MS = 10_000;
30
31
  const RETRYABLE_GET_STATUSES = new Set([429, 502, 503, 504]);
31
32
  const RECENT_LIMIT = 20;
32
33
  const REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND = 0.0015;
34
+ const BYTES_PER_GB = 1024 * 1024 * 1024;
35
+ const VIDEO_UPLOAD_PART_CONCURRENCY = readPositiveIntegerEnv('CLIPIT_CLI_UPLOAD_CONCURRENCY', 4, 100);
36
+ const VIDEO_UPLOAD_PART_ATTEMPTS = 3;
37
+ const VIDEO_UPLOAD_PART_SIGN_BATCH = 100;
38
+ const VIDEO_UPLOAD_STALL_TIMEOUT_MS = readPositiveIntegerEnv(
39
+ 'CLIPIT_CLI_UPLOAD_STALL_TIMEOUT_MS',
40
+ 60_000,
41
+ 10 * 60_000,
42
+ );
43
+ const VIDEO_UPLOAD_RETRY_BASE_MS = readPositiveIntegerEnv(
44
+ 'CLIPIT_CLI_UPLOAD_RETRY_BASE_MS',
45
+ 1_000,
46
+ 10_000,
47
+ );
48
+ const API_REQUEST_TIMEOUT_MS = readPositiveIntegerEnv(
49
+ 'CLIPIT_CLI_REQUEST_TIMEOUT_MS',
50
+ 60_000,
51
+ 5 * 60_000,
52
+ );
33
53
  const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
34
54
  'exports start': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
35
55
  'thumbnails generate': [{ operationType: 'thumbnail_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
@@ -38,12 +58,151 @@ const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
38
58
  { operationType: 'image_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'broll-images' },
39
59
  { operationType: 'video_generation', provider: 'replicate', modelId: 'alibaba/happyhorse-1.0', metrics: 'broll-video' },
40
60
  ],
61
+ 'clips create': [{ operationType: 'clip', provider: 'deepgram', metrics: 'clip-create' }],
41
62
  'clips render': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
63
+ 'videos upload': [{ operationType: 'video_storage', provider: 'railway_s3', metrics: 'video-upload-storage' }],
42
64
  'videos import-url': [{ operationType: null, provider: null, metrics: 'url-import' }],
65
+ 'videos transcribe': [{ operationType: 'transcription', provider: 'deepgram', modelId: 'nova-3', metrics: 'transcription-video' }],
66
+ 'videos suggest-clips': [{ operationType: 'ai_chat', provider: 'openrouter', modelId: 'x-ai/grok-4.20-beta', metrics: 'suggest-clips' }],
43
67
  'social post': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
44
68
  'social schedule': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
45
69
  });
46
70
 
71
+ const REMOTION_RUN_ESTIMATE = MAX_CREDITS_ESTIMATE_MAP['clips render'];
72
+
73
+ const RUN_MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
74
+ planBRoll: MAX_CREDITS_ESTIMATE_MAP['broll plan'],
75
+ createThumbnail: MAX_CREDITS_ESTIMATE_MAP['thumbnails generate'],
76
+ generateThumbnails: MAX_CREDITS_ESTIMATE_MAP['thumbnails generate'],
77
+ generateImage: [{ operationType: 'image_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
78
+ generateBRoll: MAX_CREDITS_ESTIMATE_MAP['broll generate'],
79
+ generateVoiceover: [{ operationType: 'tts_generation', provider: 'replicate', modelId: 'google/gemini-3.1-flash-tts', metrics: 'tts-provider-cost' }],
80
+ generateMusicBed: [{ operationType: 'music_generation', provider: 'replicate', modelId: 'minimax/music-2.5', metrics: 'one-generation' }],
81
+ generateVideoAlter: [{ operationType: 'video_alter_generation', provider: 'replicate', modelId: 'kwaivgi/kling-v3-omni-video', metrics: 'video-alter-provider-cost' }],
82
+ generatePlatformCaption: [{ operationType: 'ai_chat', provider: 'openrouter', modelId: 'anthropic/claude-opus-4.7', metrics: 'platform-caption' }],
83
+ applyHookToFront: REMOTION_RUN_ESTIMATE,
84
+ addLibraryAssetToClip: REMOTION_RUN_ESTIMATE,
85
+ renderClipWithRemotion: REMOTION_RUN_ESTIMATE,
86
+ updateClipBounds: REMOTION_RUN_ESTIMATE,
87
+ setCropPosition: REMOTION_RUN_ESTIMATE,
88
+ applyRemotionCaptionPreset: REMOTION_RUN_ESTIMATE,
89
+ setCaptionAnimation: REMOTION_RUN_ESTIMATE,
90
+ setClipAspectRatio: REMOTION_RUN_ESTIMATE,
91
+ setCropLayout: REMOTION_RUN_ESTIMATE,
92
+ setTimedCropLayouts: REMOTION_RUN_ESTIMATE,
93
+ applyCropPreset: REMOTION_RUN_ESTIMATE,
94
+ setWatermark: REMOTION_RUN_ESTIMATE,
95
+ applyTextOverlayToClip: REMOTION_RUN_ESTIMATE,
96
+ applyBRoll: REMOTION_RUN_ESTIMATE,
97
+ removeBRoll: REMOTION_RUN_ESTIMATE,
98
+ });
99
+
100
+ const RUN_CONFIRMATION_LABELS = Object.freeze({
101
+ renderClipWithRemotion: 'Rendering a clip',
102
+ });
103
+
104
+ const RUN_METERED_CONFIRMATION_EXEMPTIONS = new Set([
105
+ 'planBRoll',
106
+ 'planVideoAlter',
107
+ 'planVoiceover',
108
+ 'planMusicBed',
109
+ ]);
110
+
111
+ const CLIP_IDS_CONTEXT_TOOLS = new Set([
112
+ 'runDeliveryReadinessQA',
113
+ 'runBlueprintExportQA',
114
+ ]);
115
+
116
+ const BILLING_PROVIDER_PREFERENCES = new Set(['x402_direct', 'stripe_mpp', 'stripe_x402']);
117
+
118
+ const LOCAL_MCP_BILLING_TOOLS = Object.freeze([
119
+ {
120
+ name: 'getPaymentCapabilities',
121
+ description: 'Discover ClipIt machine-payment capabilities, agent connection paths, safety policy, and direct x402, Stripe x402, or Stripe MPP rail readiness.',
122
+ costBand: 'free',
123
+ skill: 'billing',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {},
127
+ additionalProperties: false,
128
+ },
129
+ },
130
+ {
131
+ name: 'getBillingCatalog',
132
+ description: 'List ClipIt catalog products, prices, credit amounts, and enabled machine-payment rails.',
133
+ costBand: 'free',
134
+ skill: 'billing',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: {},
138
+ additionalProperties: false,
139
+ },
140
+ },
141
+ {
142
+ name: 'createPaymentAttempt',
143
+ 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.',
144
+ costBand: 'free',
145
+ skill: 'billing',
146
+ requiresConfirmation: true,
147
+ confirmation: {
148
+ required: true,
149
+ riskLevel: 'costly',
150
+ reason: 'Creates a payable billing attempt. Continue only after the human approved the product, amount, rail, and budget policy.',
151
+ actionLabel: 'create machine-payment attempt',
152
+ allowAutonomous: false,
153
+ },
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ productKey: { type: 'string' },
158
+ providerPreference: { type: 'string', enum: ['x402_direct', 'stripe_mpp', 'stripe_x402'] },
159
+ idempotencyKey: { type: 'string' },
160
+ },
161
+ required: ['productKey'],
162
+ additionalProperties: false,
163
+ },
164
+ },
165
+ {
166
+ name: 'getPaymentAttempt',
167
+ description: 'Get the status of an API-key-owned machine-payment attempt.',
168
+ costBand: 'free',
169
+ skill: 'billing',
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: {
173
+ attemptId: { type: 'string' },
174
+ },
175
+ required: ['attemptId'],
176
+ additionalProperties: false,
177
+ },
178
+ },
179
+ {
180
+ name: 'getPaymentReceipt',
181
+ description: 'Get the receipt and fulfillment records for an API-key-owned machine-payment attempt.',
182
+ costBand: 'free',
183
+ skill: 'billing',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: {
187
+ attemptId: { type: 'string' },
188
+ },
189
+ required: ['attemptId'],
190
+ additionalProperties: false,
191
+ },
192
+ },
193
+ {
194
+ name: 'getBillingSubscription',
195
+ description: 'Get the effective ClipIt billing access source for the API key owner.',
196
+ costBand: 'free',
197
+ skill: 'billing',
198
+ inputSchema: {
199
+ type: 'object',
200
+ properties: {},
201
+ additionalProperties: false,
202
+ },
203
+ },
204
+ ]);
205
+
47
206
  const EXIT = {
48
207
  OK: 0,
49
208
  USAGE: 2,
@@ -61,6 +220,17 @@ const KNOWN_AGENT_TARGETS = ['codex', 'claude', 'hermes', 'generic'];
61
220
  const TRUSTED_HOSTS = new Set(['clipit.dev', 'www.clipit.dev', 'localhost', '127.0.0.1', '::1', '[::1]']);
62
221
  const AGENT_SKILL_META_FILENAME = 'SKILL.meta.json';
63
222
 
223
+ function readPositiveIntegerEnv(name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
224
+ const parsed = Number(process.env[name]);
225
+ return Number.isFinite(parsed) && parsed > 0
226
+ ? Math.min(maximum, Math.max(1, Math.floor(parsed)))
227
+ : fallback;
228
+ }
229
+
230
+ function shellQuote(value) {
231
+ return `'${String(value).replace(/'/g, "'\"'\"'")}'`;
232
+ }
233
+
64
234
  function configDir() {
65
235
  if (process.env.CLIPIT_CONFIG_DIR) return process.env.CLIPIT_CONFIG_DIR;
66
236
  if (process.platform === 'win32' && process.env.APPDATA) return path.join(process.env.APPDATA, 'ClipIt');
@@ -93,6 +263,7 @@ function legacyProfile(config) {
93
263
  baseUrl: config.baseUrl,
94
264
  apiKey: config.apiKey,
95
265
  keyInfo: config.keyInfo,
266
+ scope: config.scope,
96
267
  loginSource: config.loginSource,
97
268
  activeContext: config.activeContext,
98
269
  recent: config.recent,
@@ -106,6 +277,14 @@ function profileData(config, options) {
106
277
  return name === 'default' ? { ...legacyProfile(config), ...fromProfiles } : fromProfiles;
107
278
  }
108
279
 
280
+ function profileIsAuthoritative(config, options) {
281
+ return Boolean(
282
+ options.profile
283
+ || process.env.CLIPIT_PROFILE
284
+ || (config.currentProfile && config.currentProfile !== 'default'),
285
+ );
286
+ }
287
+
109
288
  function updateProfile(config, options, updates) {
110
289
  const name = profileName(config, options);
111
290
  const profiles = { ...(config.profiles || {}) };
@@ -146,6 +325,32 @@ function appendRecentEntries(config, options, entries) {
146
325
  return entries.reduce((nextConfig, entry) => appendRecentEntry(nextConfig, options, entry.type, entry.id), config);
147
326
  }
148
327
 
328
+ function withProfileActiveContext(config, options, activeContext) {
329
+ const name = profileName(config, options);
330
+ const profiles = { ...(config.profiles || {}) };
331
+ profiles[name] = {
332
+ ...profileData(config, options),
333
+ activeContext,
334
+ };
335
+
336
+ const next = {
337
+ ...config,
338
+ profiles,
339
+ };
340
+
341
+ if (name === 'default') {
342
+ next.activeContext = activeContext;
343
+ }
344
+
345
+ return next;
346
+ }
347
+
348
+ async function persistActiveContext(config, options, activeContext, recent = []) {
349
+ let next = updateProfile(config, options, { activeContext: compactObject({ ...activeContext }) });
350
+ next = appendRecentEntries(next, options, recent);
351
+ await writeConfig(next);
352
+ }
353
+
149
354
  function removeProfileFields(config, options, fields) {
150
355
  const name = profileName(config, options);
151
356
  const profiles = { ...(config.profiles || {}) };
@@ -201,21 +406,26 @@ function wantJson(options) {
201
406
  function redact(value) {
202
407
  if (typeof value !== 'string') return value;
203
408
  return value
409
+ .replace(/https?:\/\/(?=[^\s"'<>]*[?&](?:X-Amz-|Signature=|token=|key=))[^\s"'<>]+/gi, '[signed-url-redacted]')
410
+ .replace(/https?:\/\/replicate\.delivery\/[^\s"'<>]+/gi, '[generated-media-url-redacted]')
411
+ .replace(/https?:\/\/stream\.replicate\.com\/v1\/files\/[^\s"'<>]+/gi, '[generated-media-url-redacted]')
412
+ .replace(/(^|[\s"'=:(,])\/objects\/[^\s"'<>),]+/g, '$1[object-url-redacted]')
204
413
  .replace(/clipper_[a-f0-9]{24,}/gi, 'clipper_[redacted]')
205
414
  .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]')
206
- .replace(/([?&](?:X-Amz-Signature|Signature|token|key)=)[^&\s]+/gi, '$1[redacted]');
415
+ .replace(/([?&](?:X-Amz-[A-Za-z-]+|Signature|token|key)=)[^&\s]+/gi, '$1[redacted]');
207
416
  }
208
417
 
209
418
  function output(data, options) {
419
+ const safeData = redactDeep(data);
210
420
  if (wantJson(options)) {
211
- console.log(JSON.stringify(data, null, 2));
421
+ console.log(JSON.stringify(safeData, null, 2));
212
422
  return;
213
423
  }
214
- if (typeof data === 'string') {
215
- console.log(data);
424
+ if (typeof safeData === 'string') {
425
+ console.log(safeData);
216
426
  return;
217
427
  }
218
- console.log(JSON.stringify(data, null, 2));
428
+ console.log(JSON.stringify(safeData, null, 2));
219
429
  }
220
430
 
221
431
  function redactDeep(value) {
@@ -227,6 +437,10 @@ function redactDeep(value) {
227
437
  return value;
228
438
  }
229
439
 
440
+ function hashText(value) {
441
+ return createHash('sha256').update(String(value)).digest('hex');
442
+ }
443
+
230
444
  function usage() {
231
445
  return [
232
446
  'ClipIt CLI',
@@ -240,19 +454,22 @@ function usage() {
240
454
  ' clipit auth status [--json]',
241
455
  ' clipit auth set-key --stdin',
242
456
  ' clipit auth profiles',
457
+ ' clipit auth use <profile>',
243
458
  ' clipit context use [--video-id id] [--clip-id id] [--project-id id] [--sequence-id id]',
244
459
  ' clipit context show|clear|build [--json]',
245
460
  ' clipit skills list [--json]',
246
461
  ' clipit tools list [--skill clip] [--json]',
247
462
  ' clipit tools describe <functionName>',
248
- ' clipit ask "<prompt>" [--video-id id] [--clip-id id] [--conversation-id id] [--quick] [--stream] [--json]',
463
+ ' clipit ask "<prompt>" [--video-id id] [--clip-id id] [--conversation-id id] [--quick] [--auto-confirm-costly] [--stream] [--json]',
249
464
  ' clipit workflow status|wait <jobId> [--stream] [--json]',
250
465
  ' clipit workflow approve <jobId> --approval-id id [--decision approved|cheaper|cancelled]',
466
+ ' clipit mcp [stdio]',
251
467
  ' clipit run <functionName> [--params @file.json] [--clip-id id] [--video-id id] [--confirm] [--max-credits n]',
252
- ' clipit videos list|get|upload|import-url|transcribe|transcript|suggest-clips|delete ...',
253
- ' clipit clips list|get|create|update|render|download|delete ...',
468
+ ' clipit videos list|get|upload|abort-upload|import-url|transcribe|transcript|suggest-clips|delete ...',
469
+ ' clipit clips list|get|delivery-state|create|update|render|download|delete ...',
254
470
  ' clipit jobs get|wait <jobId>',
255
471
  ' clipit credits balance|usage|estimate ...',
472
+ ' clipit billing capabilities|catalog|create-attempt|attempt|receipt|subscription ...',
256
473
  ' clipit analytics overview|top-clips|post ...',
257
474
  ' clipit exports start|list|get|wait|download|cancel ...',
258
475
  ' clipit assets list|upload|delete ...',
@@ -268,12 +485,36 @@ function usage() {
268
485
 
269
486
  function getBaseUrl(config, options) {
270
487
  const profile = profileData(config, options);
271
- return String(options['base-url'] || process.env.CLIPPER_BASE_URL || profile.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
488
+ const selected = profileIsAuthoritative(config, options);
489
+ return String(
490
+ options['base-url']
491
+ || (selected ? profile.baseUrl : process.env.CLIPPER_BASE_URL)
492
+ || profile.baseUrl
493
+ || (!selected ? process.env.CLIPPER_BASE_URL : undefined)
494
+ || DEFAULT_BASE_URL,
495
+ ).replace(/\/+$/, '');
272
496
  }
273
497
 
274
- function getApiKey(config, options) {
498
+ function resolveApiCredential(config, options) {
275
499
  const profile = profileData(config, options);
276
- return process.env.CLIPPER_API_KEY || options['api-key'] || profile.apiKey || null;
500
+ if (typeof options['api-key'] === 'string' && options['api-key'].trim()) {
501
+ return { apiKey: options['api-key'].trim(), source: 'argument' };
502
+ }
503
+ if (profileIsAuthoritative(config, options)) {
504
+ return profile.apiKey
505
+ ? { apiKey: profile.apiKey, source: 'profile' }
506
+ : { apiKey: null, source: 'missing' };
507
+ }
508
+ if (process.env.CLIPPER_API_KEY) {
509
+ return { apiKey: process.env.CLIPPER_API_KEY, source: 'environment' };
510
+ }
511
+ return profile.apiKey
512
+ ? { apiKey: profile.apiKey, source: 'profile' }
513
+ : { apiKey: null, source: 'missing' };
514
+ }
515
+
516
+ function getApiKey(config, options) {
517
+ return resolveApiCredential(config, options).apiKey;
277
518
  }
278
519
 
279
520
  async function readStdin() {
@@ -282,6 +523,97 @@ async function readStdin() {
282
523
  return Buffer.concat(chunks).toString('utf8');
283
524
  }
284
525
 
526
+ async function readSecretLine(input = process.stdin, promptOutput = process.stderr) {
527
+ if (!input.isTTY || typeof input.setRawMode !== 'function') {
528
+ return new Promise((resolve, reject) => {
529
+ let value = '';
530
+ let settled = false;
531
+ const cleanup = () => {
532
+ input.off('data', onData);
533
+ input.off('end', onEnd);
534
+ input.off('error', onError);
535
+ };
536
+ const finish = (result) => {
537
+ if (settled) return;
538
+ settled = true;
539
+ cleanup();
540
+ input.pause?.();
541
+ resolve(result);
542
+ };
543
+ const onData = (chunk) => {
544
+ value += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
545
+ const newline = value.search(/[\r\n]/);
546
+ if (newline !== -1) finish(value.slice(0, newline));
547
+ };
548
+ const onEnd = () => finish(value);
549
+ const onError = (error) => {
550
+ if (settled) return;
551
+ settled = true;
552
+ cleanup();
553
+ reject(error);
554
+ };
555
+ input.on('data', onData);
556
+ input.once('end', onEnd);
557
+ input.once('error', onError);
558
+ input.resume?.();
559
+ });
560
+ }
561
+
562
+ return new Promise((resolve, reject) => {
563
+ let value = '';
564
+ let settled = false;
565
+ const wasRaw = Boolean(input.isRaw);
566
+ const wasPaused = input.isPaused?.() ?? false;
567
+ const cleanup = () => {
568
+ input.off('data', onData);
569
+ input.off('end', onEnd);
570
+ input.off('error', onError);
571
+ input.setRawMode(wasRaw);
572
+ if (wasPaused) input.pause?.();
573
+ promptOutput.write('\n');
574
+ };
575
+ const finish = () => {
576
+ if (settled) return;
577
+ settled = true;
578
+ cleanup();
579
+ resolve(value);
580
+ };
581
+ const onData = (chunk) => {
582
+ for (const character of (Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk))) {
583
+ if (character === '\u0003') {
584
+ if (settled) return;
585
+ settled = true;
586
+ cleanup();
587
+ reject(Object.assign(new Error('API key entry cancelled.'), { exitCode: 130 }));
588
+ return;
589
+ }
590
+ if (character === '\r' || character === '\n' || character === '\u0004') {
591
+ finish();
592
+ return;
593
+ }
594
+ if (character === '\u007f' || character === '\b') {
595
+ value = value.slice(0, -1);
596
+ continue;
597
+ }
598
+ if (character >= ' ') value += character;
599
+ }
600
+ };
601
+ const onEnd = () => finish();
602
+ const onError = (error) => {
603
+ if (settled) return;
604
+ settled = true;
605
+ cleanup();
606
+ reject(error);
607
+ };
608
+ promptOutput.write('API key: ');
609
+ input.setRawMode(true);
610
+ input.on('data', onData);
611
+ input.once('end', onEnd);
612
+ input.once('error', onError);
613
+ input.resume?.();
614
+ });
615
+ }
616
+
285
617
  function boolOption(value) {
286
618
  return value === true || value === 'true' || value === '1' || value === 'yes';
287
619
  }
@@ -398,39 +730,67 @@ async function apiFetch(config, options, method, endpoint, body, extra = {}) {
398
730
  };
399
731
  const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
400
732
  if (body !== undefined && !isFormData && !extra.rawBody) headers['Content-Type'] = 'application/json';
401
- const apiKey = extra.noAuth ? null : getApiKey(config, options);
733
+ const apiKey = extra.noAuth
734
+ ? null
735
+ : extra.authApiKey !== undefined
736
+ ? extra.authApiKey
737
+ : getApiKey(config, options);
402
738
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
403
739
 
404
740
  let response;
741
+ let responseTimeout;
405
742
  for (let attempt = 0; attempt <= GET_RETRY_DELAYS_MS.length; attempt++) {
743
+ const controller = new AbortController();
744
+ const timeoutMs = Math.min(
745
+ readPositiveIntegerEnv('CLIPIT_CLI_REQUEST_TIMEOUT_MS', API_REQUEST_TIMEOUT_MS, 5 * 60_000),
746
+ 5 * 60_000,
747
+ );
748
+ const timeout = setTimeout(() => {
749
+ controller.abort(new Error(`ClipIt API request timed out after ${timeoutMs} ms`));
750
+ }, timeoutMs);
751
+ timeout.unref?.();
406
752
  try {
407
753
  const request = {
408
754
  method: methodName,
409
755
  headers,
410
756
  body: body === undefined ? undefined : extra.rawBody ? body : isFormData ? body : JSON.stringify(body),
757
+ signal: controller.signal,
411
758
  };
412
759
  if (extra.rawBody && body !== undefined) {
413
760
  request.duplex = 'half';
414
761
  }
415
762
  response = await fetch(`${baseUrl}${endpoint}`, request);
416
763
  } catch (error) {
764
+ clearTimeout(timeout);
417
765
  if (canRetry && attempt < GET_RETRY_DELAYS_MS.length) {
418
766
  await sleep(GET_RETRY_DELAYS_MS[attempt]);
419
767
  continue;
420
768
  }
421
- throw Object.assign(new Error(`Network error: ${error.message}`), { exitCode: EXIT.NETWORK });
769
+ const reason = controller.signal.aborted && controller.signal.reason instanceof Error
770
+ ? controller.signal.reason
771
+ : error;
772
+ throw Object.assign(new Error(`Network error: ${reason.message}`), { exitCode: EXIT.NETWORK });
422
773
  }
423
774
 
424
775
  if (canRetry && RETRYABLE_GET_STATUSES.has(response.status) && attempt < GET_RETRY_DELAYS_MS.length) {
425
776
  await response.arrayBuffer().catch(() => undefined);
777
+ clearTimeout(timeout);
426
778
  await sleep(retryDelayMs(response, attempt));
427
779
  continue;
428
780
  }
781
+ responseTimeout = timeout;
429
782
  break;
430
783
  }
431
784
 
432
785
  const requestId = response.headers.get('x-request-id') || response.headers.get('X-Request-Id') || undefined;
433
- const text = await response.text();
786
+ let text;
787
+ try {
788
+ text = await response.text();
789
+ } catch (error) {
790
+ throw Object.assign(new Error(`Network error: ${error.message}`), { exitCode: EXIT.NETWORK });
791
+ } finally {
792
+ if (responseTimeout) clearTimeout(responseTimeout);
793
+ }
434
794
  let data = null;
435
795
  if (text.trim()) {
436
796
  try {
@@ -455,6 +815,13 @@ async function apiFetch(config, options, method, endpoint, body, extra = {}) {
455
815
  requestId,
456
816
  });
457
817
  }
818
+ if (extra.includeResponseMetadata) {
819
+ return {
820
+ data,
821
+ status: response.status,
822
+ headers: Object.fromEntries(response.headers.entries()),
823
+ };
824
+ }
458
825
  return data;
459
826
  }
460
827
 
@@ -486,7 +853,11 @@ function maxCreditsLimit(options) {
486
853
  }
487
854
 
488
855
  function clipCostLabel(value) {
489
- return `${Number(value).toFixed(2).replace(/\.00$/, '')} $CLIP`;
856
+ const amount = Number(value);
857
+ if (!Number.isFinite(amount)) return `${value} $CLIP`;
858
+ if (amount === 0) return '0 $CLIP';
859
+ const fixed = amount.toFixed(Math.abs(amount) < 0.01 ? 5 : 2).replace(/\.?0+$/, '');
860
+ return `${fixed} $CLIP`;
490
861
  }
491
862
 
492
863
  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 +889,20 @@ function warnIfEstimateUnaffordable(estimates) {
518
889
  }
519
890
  }
520
891
 
892
+ function summarizeRunEstimates(estimates) {
893
+ if (!Array.isArray(estimates) || !estimates.length) return null;
894
+ const estimatedCostClip = estimates.reduce((sum, estimate) => {
895
+ const value = Number(estimate?.estimatedCostClip ?? 0);
896
+ return sum + (Number.isFinite(value) ? value : 0);
897
+ }, 0);
898
+ return {
899
+ estimatedCostClip,
900
+ estimatedCostLabel: clipCostLabel(estimatedCostClip),
901
+ affordable: estimates.every((estimate) => estimate?.affordable !== false),
902
+ estimates,
903
+ };
904
+ }
905
+
521
906
  function throwSpendLimitExceeded(commandKey, spendLimitViolation, estimates) {
522
907
  throw Object.assign(new Error(SPEND_LIMIT_EXCEEDED_MESSAGE), {
523
908
  exitCode: EXIT.CREDITS,
@@ -548,11 +933,52 @@ function remotionProviderCostUsd(durationSeconds, quality) {
548
933
  return Math.max(0, durationSeconds * REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND * qualityMultiplier(quality));
549
934
  }
550
935
 
936
+ function normalizeCreditEstimateRequest(request) {
937
+ if (
938
+ request.operationType === 'lambda_render'
939
+ && (request.provider === 'aws_lambda' || request.provider === 'remotion')
940
+ && request.metrics
941
+ && typeof request.metrics.videoSeconds === 'number'
942
+ && typeof request.metrics.providerCostUsd !== 'number'
943
+ ) {
944
+ return {
945
+ ...request,
946
+ metrics: {
947
+ ...request.metrics,
948
+ providerCostUsd: remotionProviderCostUsd(request.metrics.videoSeconds, 'high'),
949
+ },
950
+ };
951
+ }
952
+
953
+ return request;
954
+ }
955
+
551
956
  function brollVideoProviderCostUsd(durationSeconds, resolution) {
552
957
  const perSecondUsd = resolution === '1080p' ? 0.28 : 0.14;
553
958
  return Math.max(0, durationSeconds * perSecondUsd);
554
959
  }
555
960
 
961
+ function brollImageProviderCostUsd(quality) {
962
+ if (quality === 'low') return 0.012;
963
+ if (quality === 'medium') return 0.047;
964
+ return 0.128;
965
+ }
966
+
967
+ function ttsProviderCostUsd(text, prompt) {
968
+ const textBytes = Buffer.byteLength(String(text || ''), 'utf8');
969
+ const promptBytes = Buffer.byteLength(String(prompt || 'Say the following.'), 'utf8');
970
+ const estimatedInputTokens = Math.ceil((textBytes + promptBytes) / 4);
971
+ const estimatedOutputTokens = Math.ceil(textBytes / 3);
972
+ return (estimatedInputTokens / 1_000_000) * 2 + (estimatedOutputTokens / 1_000) * 0.04;
973
+ }
974
+
975
+ function videoAlterProviderCostUsd(durationSeconds, mode) {
976
+ const perSecond = mode === 'pro'
977
+ ? Number(process.env.KLING_OMNI_PRO_COST_PER_SECOND_USD ?? '0.56')
978
+ : Number(process.env.KLING_OMNI_STANDARD_COST_PER_SECOND_USD ?? '0.28');
979
+ return Math.max(0, durationSeconds * (Number.isFinite(perSecond) ? perSecond : 0.28));
980
+ }
981
+
556
982
  function durationFromClip(clip, body = {}) {
557
983
  const bodyStart = Number(body.startTime);
558
984
  const bodyEnd = Number(body.endTime);
@@ -571,15 +997,6 @@ async function fetchClipForEstimate(config, options, clipId) {
571
997
  return apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(clipId)}`);
572
998
  }
573
999
 
574
- function progressTransform(progress) {
575
- return new Transform({
576
- transform(chunk, encoding, callback) {
577
- progress.track(chunk);
578
- callback(null, chunk);
579
- },
580
- });
581
- }
582
-
583
1000
  async function buildMaxCreditsEstimateRequest(config, options, spec, context = {}) {
584
1001
  if (spec.metrics === 'url-import') {
585
1002
  const youtube = isYoutubeUrl(requiredString(context.url, 'URL'));
@@ -594,17 +1011,57 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
594
1011
  return { ...spec, metrics: { generationCount: 1 } };
595
1012
  }
596
1013
 
1014
+ if (spec.metrics === 'tts-provider-cost') {
1015
+ const text = requiredString(context.body?.text, 'text');
1016
+ return { ...spec, metrics: { providerCostUsd: ttsProviderCostUsd(text, context.body?.prompt) } };
1017
+ }
1018
+
597
1019
  if (spec.metrics === 'broll-plan') {
598
1020
  return { ...spec, metrics: { inputTokens: 4000, outputTokens: 1000 } };
599
1021
  }
600
1022
 
1023
+ if (spec.metrics === 'platform-caption') {
1024
+ return { ...spec, metrics: { inputTokens: 1500, outputTokens: 600, totalTokens: 2100 } };
1025
+ }
1026
+
1027
+ if (spec.metrics === 'suggest-clips') {
1028
+ return { ...spec, metrics: { inputTokens: 40000, outputTokens: 10000, totalTokens: 50000 } };
1029
+ }
1030
+
1031
+ if (spec.metrics === 'clip-create') {
1032
+ const audioSeconds = durationFromClip(context.body, context.body);
1033
+ if (!Number.isFinite(audioSeconds) || audioSeconds <= 0) return null;
1034
+ return { ...spec, metrics: { audioSeconds } };
1035
+ }
1036
+
1037
+ if (spec.metrics === 'transcription-video') {
1038
+ const videoId = requiredString(context.videoId, 'Video id');
1039
+ const video = context.video || await apiFetch(config, options, 'GET', `/api/v1/videos/${encodeURIComponent(videoId)}`);
1040
+ const audioSeconds = Number(video?.durationSeconds ?? video?.duration);
1041
+ if (!Number.isFinite(audioSeconds) || audioSeconds <= 0) return null;
1042
+ return { ...spec, metrics: { audioSeconds } };
1043
+ }
1044
+
1045
+ if (spec.metrics === 'video-upload-storage') {
1046
+ const bytes = Number(context.bytes);
1047
+ if (!Number.isFinite(bytes) || bytes <= 0) return null;
1048
+ return { ...spec, metrics: { generationCount: bytes / BYTES_PER_GB } };
1049
+ }
1050
+
601
1051
  if (spec.metrics === 'broll-images') {
602
1052
  const mode = context.body?.mode || 'single_image';
603
- return { ...spec, metrics: { generationCount: mode === 'start_end_frame' ? 3 : 1 } };
1053
+ const generationCount = mode === 'start_end_frame' ? 3 : 1;
1054
+ return {
1055
+ ...spec,
1056
+ metrics: {
1057
+ generationCount,
1058
+ providerCostUsd: brollImageProviderCostUsd(context.body?.imageQuality) * generationCount,
1059
+ },
1060
+ };
604
1061
  }
605
1062
 
606
1063
  if (spec.metrics === 'broll-video') {
607
- const durationSeconds = Number(context.body?.durationSeconds ?? 6);
1064
+ const durationSeconds = Number(context.body?.durationSeconds ?? context.body?.duration ?? 6);
608
1065
  const resolution = context.body?.resolution || '720p';
609
1066
  if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return null;
610
1067
  return {
@@ -616,6 +1073,21 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
616
1073
  };
617
1074
  }
618
1075
 
1076
+ if (spec.metrics === 'video-alter-provider-cost') {
1077
+ const durationSeconds = Number(
1078
+ context.body?.duration
1079
+ ?? (Number(context.body?.sourceEnd) - Number(context.body?.sourceStart)),
1080
+ );
1081
+ if (!Number.isFinite(durationSeconds) || durationSeconds < 3 || durationSeconds > 10) return null;
1082
+ return {
1083
+ ...spec,
1084
+ metrics: {
1085
+ videoSeconds: durationSeconds,
1086
+ providerCostUsd: videoAlterProviderCostUsd(durationSeconds, context.body?.mode),
1087
+ },
1088
+ };
1089
+ }
1090
+
619
1091
  if (spec.metrics === 'social-platforms') {
620
1092
  const platforms = Array.isArray(context.body?.platforms) ? context.body.platforms : [];
621
1093
  if (!platforms.length) return null;
@@ -640,8 +1112,70 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
640
1112
  return null;
641
1113
  }
642
1114
 
1115
+ async function buildStaticRunEstimates(config, options, functionName, parameters, payload) {
1116
+ const specs = RUN_MAX_CREDITS_ESTIMATE_MAP[functionName];
1117
+ if (!specs) return null;
1118
+
1119
+ const body = parameters && typeof parameters === 'object' && !Array.isArray(parameters) ? parameters : {};
1120
+ const requests = [];
1121
+ for (const spec of specs) {
1122
+ const request = await buildMaxCreditsEstimateRequest(config, options, spec, {
1123
+ body,
1124
+ clipId: payload?.clipId || body.clipId,
1125
+ videoId: payload?.videoId || body.videoId,
1126
+ });
1127
+ if (!request) return null;
1128
+ requests.push(compactObject({
1129
+ operationType: request.operationType,
1130
+ provider: request.provider,
1131
+ modelId: request.modelId,
1132
+ metrics: request.metrics,
1133
+ }));
1134
+ }
1135
+
1136
+ const estimates = [];
1137
+ for (const request of requests) {
1138
+ const estimate = await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', request);
1139
+ estimates.push({
1140
+ operationType: request.operationType,
1141
+ provider: request.provider,
1142
+ modelId: request.modelId,
1143
+ estimatedCostClip: Number(estimate?.estimatedCostClip ?? 0),
1144
+ affordable: estimate?.affordable,
1145
+ spendLimitViolation: estimate?.spendLimitViolation ?? null,
1146
+ });
1147
+ }
1148
+
1149
+ return estimates;
1150
+ }
1151
+
1152
+ function currentInvocationWantsJson() {
1153
+ return process.argv.some((arg) => arg === '--json' || arg === '--json=true');
1154
+ }
1155
+
643
1156
  function printEstimateUnavailable(commandKey) {
644
- console.error(`estimate unavailable for ${commandKey}; --confirm is required.`);
1157
+ if (!currentInvocationWantsJson()) {
1158
+ console.error(`estimate unavailable for ${commandKey}; --confirm is required.`);
1159
+ }
1160
+ }
1161
+
1162
+ function throwMaxCreditsEstimateUnavailable(commandKey) {
1163
+ if (!currentInvocationWantsJson()) {
1164
+ console.error(`estimate unavailable for ${commandKey}; --max-credits cannot be enforced.`);
1165
+ }
1166
+ throw Object.assign(
1167
+ new Error(`Cannot enforce --max-credits for ${commandKey} because no cost estimate is available.`),
1168
+ { exitCode: EXIT.CONFIRMATION },
1169
+ );
1170
+ }
1171
+
1172
+ function estimatedClipCostFromData(data) {
1173
+ if (!data || typeof data !== 'object') return null;
1174
+ for (const key of ['estimatedCostClip', 'totalEstimatedCostClip', 'costClip', 'totalCostClip']) {
1175
+ const value = Number(data[key]);
1176
+ if (Number.isFinite(value)) return value;
1177
+ }
1178
+ return null;
645
1179
  }
646
1180
 
647
1181
  async function enforceMaxCredits(config, options, commandKey, context = {}) {
@@ -705,20 +1239,119 @@ async function enforceMaxCredits(config, options, commandKey, context = {}) {
705
1239
  }
706
1240
  }
707
1241
 
708
- async function enforceRunMaxCredits(config, options, functionName) {
709
- if (maxCreditsLimit(options) === null) return;
1242
+ async function enforceRunMaxCredits(config, options, functionName, parameters = {}, payload = {}) {
1243
+ const limit = maxCreditsLimit(options);
1244
+ const needsConfirmationPreflight = !boolOption(options.confirm);
1245
+ if (limit === null && !needsConfirmationPreflight) return null;
1246
+
710
1247
  const catalog = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
711
1248
  const tools = Array.isArray(catalog?.tools) ? catalog.tools : Array.isArray(catalog) ? catalog : [];
712
1249
  const tool = tools.find((item) => item.name === functionName);
713
1250
  const runEstimate = tool?.estimate ?? tool?.confirmation?.estimate ?? tool?.confirmation?.costEstimate ?? null;
1251
+ const isMetered = Boolean(tool?.costBand && tool.costBand !== 'free');
1252
+ const isMeteredExempt = RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName);
1253
+
1254
+ let staticEstimates = null;
1255
+ if (!runEstimate && (limit !== null || needsConfirmationPreflight)) {
1256
+ staticEstimates = await buildStaticRunEstimates(config, options, functionName, parameters, payload);
1257
+ }
1258
+
1259
+ if (limit === null) {
1260
+ const estimates = staticEstimates || (runEstimate ? [runEstimate] : null);
1261
+ const estimateSummary = summarizeRunEstimates(estimates);
1262
+ if (estimateSummary && (tool?.confirmation?.required || (isMetered && !isMeteredExempt))) {
1263
+ warnIfEstimateUnaffordable(estimates);
1264
+ throw Object.assign(
1265
+ new Error(`Estimated cost ${estimateSummary.estimatedCostLabel} for run ${functionName}; --confirm is required.`),
1266
+ {
1267
+ exitCode: EXIT.CONFIRMATION,
1268
+ data: {
1269
+ command: `run ${functionName}`,
1270
+ estimate: estimateSummary,
1271
+ },
1272
+ },
1273
+ );
1274
+ }
1275
+ if (tool?.confirmation?.required) {
1276
+ if (isMetered) printEstimateUnavailable(`run ${functionName}`);
1277
+ requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
1278
+ return;
1279
+ }
1280
+ if (isMetered && !isMeteredExempt) {
1281
+ printEstimateUnavailable(`run ${functionName}`);
1282
+ requireConfirm(options, `Running metered tool ${functionName}`);
1283
+ return;
1284
+ }
1285
+ return null;
1286
+ }
1287
+
1288
+ if (staticEstimates) {
1289
+ const spendLimitViolation = staticEstimates.find((estimate) => estimate.spendLimitViolation)?.spendLimitViolation;
1290
+ if (spendLimitViolation) throwSpendLimitExceeded(`run ${functionName}`, spendLimitViolation, staticEstimates);
1291
+ warnIfEstimateUnaffordable(staticEstimates);
1292
+
1293
+ const estimatedCostClip = staticEstimates.reduce((sum, estimate) => sum + estimate.estimatedCostClip, 0);
1294
+ if (estimatedCostClip > limit) {
1295
+ throw Object.assign(
1296
+ new Error(`Estimated cost ${clipCostLabel(estimatedCostClip)} exceeds --max-credits ${clipCostLabel(limit)} for run ${functionName}.`),
1297
+ {
1298
+ exitCode: EXIT.CONFIRMATION,
1299
+ data: {
1300
+ command: `run ${functionName}`,
1301
+ maxCredits: limit,
1302
+ estimatedCostClip,
1303
+ estimates: staticEstimates,
1304
+ },
1305
+ },
1306
+ );
1307
+ }
1308
+ if (tool?.confirmation?.required || (isMetered && !isMeteredExempt)) {
1309
+ requireConfirm(
1310
+ options,
1311
+ tool?.confirmation?.required ? `Running confirmation-gated tool ${functionName}` : `Running metered tool ${functionName}`,
1312
+ );
1313
+ }
1314
+ return summarizeRunEstimates(staticEstimates);
1315
+ }
1316
+
714
1317
  const spendLimitViolation = spendLimitViolationFromData(runEstimate) ?? spendLimitViolationFromData(tool?.confirmation);
715
1318
  if (spendLimitViolation) throwSpendLimitExceeded(`run ${functionName}`, spendLimitViolation, runEstimate ? [runEstimate] : []);
716
1319
  if (runEstimate?.affordable === false || tool?.confirmation?.affordable === false) {
717
1320
  console.error('Warning: estimated cost exceeds your current balance.');
718
1321
  }
719
- if (!tool?.confirmation?.required) return;
720
- printEstimateUnavailable(`run ${functionName}`);
721
- requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
1322
+ if (!runEstimate && isMetered && !isMeteredExempt) {
1323
+ throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
1324
+ }
1325
+ if (!runEstimate && tool?.confirmation?.required) {
1326
+ throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
1327
+ }
1328
+ if (runEstimate) {
1329
+ const estimatedCostClip = estimatedClipCostFromData(runEstimate);
1330
+ if (estimatedCostClip === null) {
1331
+ throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
1332
+ }
1333
+ if (estimatedCostClip > limit) {
1334
+ throw Object.assign(
1335
+ new Error(`Estimated cost ${clipCostLabel(estimatedCostClip)} exceeds --max-credits ${clipCostLabel(limit)} for run ${functionName}.`),
1336
+ {
1337
+ exitCode: EXIT.CONFIRMATION,
1338
+ data: {
1339
+ command: `run ${functionName}`,
1340
+ maxCredits: limit,
1341
+ estimatedCostClip,
1342
+ estimates: [runEstimate],
1343
+ },
1344
+ },
1345
+ );
1346
+ }
1347
+ }
1348
+ if (tool?.confirmation?.required || (isMetered && !isMeteredExempt)) {
1349
+ requireConfirm(
1350
+ options,
1351
+ tool?.confirmation?.required ? `Running confirmation-gated tool ${functionName}` : `Running metered tool ${functionName}`,
1352
+ );
1353
+ }
1354
+ return runEstimate ? summarizeRunEstimates([runEstimate]) : null;
722
1355
  }
723
1356
 
724
1357
  async function login(config, options) {
@@ -836,7 +1469,7 @@ async function setKey(config, options) {
836
1469
  if (!options.stdin) {
837
1470
  throw Object.assign(new Error('Use --stdin to avoid shell history leaks.'), { exitCode: EXIT.USAGE });
838
1471
  }
839
- const apiKey = (await readStdin()).trim();
1472
+ const apiKey = (await readSecretLine()).trim();
840
1473
  if (!apiKey) {
841
1474
  throw Object.assign(new Error('No API key received on stdin.'), { exitCode: EXIT.USAGE });
842
1475
  }
@@ -845,17 +1478,50 @@ async function setKey(config, options) {
845
1478
  apiKey,
846
1479
  loginSource: 'manual',
847
1480
  });
848
- const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me');
849
- await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey }));
850
- output({ success: true, message: 'API key stored', profile: profileName(config, options), account: me.user, apiKey: me.apiKey }, options);
1481
+ const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me', undefined, { authApiKey: apiKey });
1482
+ await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey, scope: me.scope ?? null }));
1483
+ output({
1484
+ success: true,
1485
+ message: 'API key stored',
1486
+ profile: profileName(config, options),
1487
+ account: me.user,
1488
+ apiKey: me.apiKey,
1489
+ scope: me.scope ?? null,
1490
+ }, options);
851
1491
  }
852
1492
 
853
1493
  async function logout(config, options) {
854
- const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'loginSource']);
1494
+ const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'scope', 'loginSource']);
855
1495
  await writeConfig(next);
856
1496
  output({ success: true, message: 'Local ClipIt CLI credentials removed', profile: profileName(config, options) }, options);
857
1497
  }
858
1498
 
1499
+ async function useProfile(config, options, requestedName) {
1500
+ const name = String(requestedName || '').trim();
1501
+ if (!name) {
1502
+ throw Object.assign(new Error('Profile name is required.'), { exitCode: EXIT.USAGE });
1503
+ }
1504
+ if (process.env.CLIPIT_PROFILE && process.env.CLIPIT_PROFILE !== name) {
1505
+ throw Object.assign(
1506
+ new Error(`CLIPIT_PROFILE is set to ${process.env.CLIPIT_PROFILE}; unset it or select that profile explicitly.`),
1507
+ { exitCode: EXIT.USAGE },
1508
+ );
1509
+ }
1510
+ const selectedOptions = { ...options, profile: name };
1511
+ const profile = profileData(config, selectedOptions);
1512
+ if (!profile.apiKey) {
1513
+ throw Object.assign(new Error(`Profile ${name} does not have a stored credential.`), { exitCode: EXIT.AUTH });
1514
+ }
1515
+ const next = { ...config, currentProfile: name, updatedAt: new Date().toISOString() };
1516
+ await writeConfig(next);
1517
+ output({
1518
+ success: true,
1519
+ currentProfile: name,
1520
+ keyName: profile.keyInfo?.keyName ?? null,
1521
+ scope: profile.scope ?? null,
1522
+ }, options);
1523
+ }
1524
+
859
1525
  async function listProfiles(config, options) {
860
1526
  const profiles = config.profiles || {};
861
1527
  const names = [...new Set(['default', ...Object.keys(profiles)])];
@@ -871,6 +1537,7 @@ async function listProfiles(config, options) {
871
1537
  hasCredential: Boolean(data.apiKey),
872
1538
  loginSource: data.loginSource || null,
873
1539
  keyName: data.keyInfo?.keyName || null,
1540
+ scope: data.scope || null,
874
1541
  updatedAt: data.updatedAt || null,
875
1542
  };
876
1543
  }),
@@ -878,6 +1545,7 @@ async function listProfiles(config, options) {
878
1545
  }
879
1546
 
880
1547
  async function doctor(config, options) {
1548
+ const credential = resolveApiCredential(config, options);
881
1549
  const checks = {
882
1550
  version: VERSION,
883
1551
  node: process.version,
@@ -885,8 +1553,8 @@ async function doctor(config, options) {
885
1553
  configPath: configPath(),
886
1554
  profile: profileName(config, options),
887
1555
  baseUrl: getBaseUrl(config, options),
888
- hasCredential: Boolean(getApiKey(config, options)),
889
- credentialSource: process.env.CLIPPER_API_KEY ? 'env' : getApiKey(config, options) ? 'config' : 'missing',
1556
+ hasCredential: Boolean(credential.apiKey),
1557
+ credentialSource: credential.source,
890
1558
  auth: null,
891
1559
  };
892
1560
  try {
@@ -967,7 +1635,7 @@ async function contextCommand(config, options, action) {
967
1635
  }
968
1636
 
969
1637
  if (action === 'use') {
970
- const activeContext = await buildContext({ ...config, activeContext: {} }, options);
1638
+ const activeContext = await buildContext(withProfileActiveContext(config, options, {}), options);
971
1639
  let next = updateProfile(config, options, { activeContext });
972
1640
  next = appendRecentEntries(next, options, [
973
1641
  { type: 'video', id: activeContext.videoId },
@@ -988,6 +1656,51 @@ async function contextCommand(config, options, action) {
988
1656
  throw Object.assign(new Error(`Unknown context command: ${action || ''}`), { exitCode: EXIT.USAGE });
989
1657
  }
990
1658
 
1659
+ function applyContextToAgentPayload(payload, parameters, context) {
1660
+ const canMutateParameters = parameters && typeof parameters === 'object' && !Array.isArray(parameters);
1661
+ const hadExplicitVideoId = canMutateParameters && parameters.videoId !== undefined;
1662
+ for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
1663
+ if (context[field] && !payload[field]) {
1664
+ payload[field] = context[field];
1665
+ if (canMutateParameters && parameters[field] === undefined) {
1666
+ parameters[field] = context[field];
1667
+ }
1668
+ }
1669
+ }
1670
+ if (canMutateParameters
1671
+ && CLIP_IDS_CONTEXT_TOOLS.has(payload.functionName)
1672
+ && parameters.clipIds === undefined
1673
+ && !hadExplicitVideoId) {
1674
+ const selectedClipIds = Array.isArray(context.selectedClipIds)
1675
+ ? context.selectedClipIds.filter(Boolean).map(String)
1676
+ : [];
1677
+ const clipId = parameters.clipId || (selectedClipIds.length ? null : context.clipId);
1678
+ if (clipId) parameters.clipIds = [String(clipId)];
1679
+ else if (selectedClipIds.length) parameters.clipIds = selectedClipIds;
1680
+ }
1681
+ if (context.selectedClipIds) payload.selectedClipIds = context.selectedClipIds;
1682
+ if (context.playheadPosition !== undefined) payload.playheadPosition = context.playheadPosition;
1683
+ if (Object.keys(context).length) payload.context = context.context || context;
1684
+ }
1685
+
1686
+ function normalizeAgentExecuteResult(result) {
1687
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return result;
1688
+ const nested = result.result;
1689
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested) || nested.requiresConfirmation !== true) {
1690
+ return result;
1691
+ }
1692
+
1693
+ return compactObject({
1694
+ ...result,
1695
+ requiresConfirmation: true,
1696
+ confirmationTool: result.confirmationTool ?? nested.confirmationTool ?? nested.functionName,
1697
+ confirmationParams: result.confirmationParams ?? nested.confirmationParams ?? nested.parameters,
1698
+ confirmation: result.confirmation ?? nested.confirmation,
1699
+ estimate: result.estimate ?? nested.estimate,
1700
+ preview: result.preview ?? nested.preview,
1701
+ });
1702
+ }
1703
+
991
1704
  async function runTool(config, options, functionName) {
992
1705
  if (!functionName) throw Object.assign(new Error('Function name is required.'), { exitCode: EXIT.USAGE });
993
1706
  const parameters = await readJsonOption(options.params || options['params-json']);
@@ -1003,23 +1716,29 @@ async function runTool(config, options, functionName) {
1003
1716
  ['project-id', 'projectId'],
1004
1717
  ['sequence-id', 'sequenceId'],
1005
1718
  ]) {
1006
- if (options[flag]) payload[field] = String(options[flag]);
1719
+ if (options[flag]) {
1720
+ const value = String(options[flag]);
1721
+ payload[field] = value;
1722
+ if (parameters && typeof parameters === 'object' && !Array.isArray(parameters) && parameters[field] === undefined) {
1723
+ parameters[field] = value;
1724
+ }
1725
+ }
1007
1726
  }
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);
1727
+ applyContextToAgentPayload(payload, parameters, context);
1728
+ if (RUN_CONFIRMATION_LABELS[functionName]) {
1729
+ confirmPaid(options, RUN_CONFIRMATION_LABELS[functionName]);
1730
+ }
1731
+ const estimate = await enforceRunMaxCredits(config, options, functionName, parameters, payload);
1732
+ const result = normalizeAgentExecuteResult(await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload));
1733
+ const outputResult = estimate && result && typeof result === 'object' && !Array.isArray(result) && result.estimate === undefined
1734
+ ? { ...result, estimate }
1735
+ : result;
1736
+ if (result?.requiresConfirmation && !payload.confirmed && !RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName)) {
1737
+ output(outputResult, options);
1019
1738
  process.exitCode = EXIT.CONFIRMATION;
1020
1739
  return;
1021
1740
  }
1022
- output(result, options);
1741
+ output(outputResult, options);
1023
1742
  }
1024
1743
 
1025
1744
  function workflowEndpoint(jobId) {
@@ -1141,17 +1860,40 @@ async function pollWorkflow(config, options, jobId) {
1141
1860
  }
1142
1861
  }
1143
1862
 
1863
+ function promptNamesNewSourceUrl(userMessage) {
1864
+ const urls = userMessage.match(/https?:\/\/[^\s<>"'`]+/gi) || [];
1865
+ if (urls.length !== 1) return false;
1866
+ const messageWithoutUrls = userMessage
1867
+ .replace(/https?:\/\/[^\s<>"'`]+/gi, ' ')
1868
+ .replace(/[^a-z0-9]+/gi, ' ')
1869
+ .trim()
1870
+ .toLowerCase();
1871
+ const isUrlOnly = messageWithoutUrls.length === 0;
1872
+ const hasStrongSourceIntent = /\b(import|process|source|transcrib\w*|download|extract)\b/.test(messageWithoutUrls)
1873
+ || /\b(clip|clips|video)\b.*\b(from|using|this|that)\b/.test(messageWithoutUrls)
1874
+ || /\b(use|using)\b.*\b(link|url|source|video|this|that)\b/.test(messageWithoutUrls);
1875
+ const isReferenceOnly = /\b(reference|inspiration|example|style)\b/.test(messageWithoutUrls)
1876
+ && !/\b(import|process|source|transcrib\w*|clip|clips|extract)\b/.test(messageWithoutUrls);
1877
+ return isUrlOnly || (hasStrongSourceIntent && !isReferenceOnly);
1878
+ }
1879
+
1144
1880
  async function askWorkflow(config, options, promptParts) {
1145
1881
  const userMessage = promptParts.filter((part) => part !== undefined).join(' ').trim();
1146
1882
  if (!userMessage) throw Object.assign(new Error('Prompt is required.'), { exitCode: EXIT.USAGE });
1147
1883
 
1148
1884
  const context = await buildContext(config, options);
1885
+ if (promptNamesNewSourceUrl(userMessage)) {
1886
+ if (!options['video-id']) delete context.videoId;
1887
+ if (!options['clip-id']) delete context.clipId;
1888
+ if (!options['selected-clip-ids']) delete context.selectedClipIds;
1889
+ }
1149
1890
  const payload = { userMessage };
1150
1891
  for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
1151
1892
  if (context[field]) payload[field] = context[field];
1152
1893
  }
1153
1894
  if (options['conversation-id']) payload.conversationId = String(options['conversation-id']);
1154
1895
  if (options.quick !== undefined) payload.quickMode = boolOption(options.quick);
1896
+ if (options['auto-confirm-costly'] !== undefined) payload.autoConfirmCostlyTools = boolOption(options['auto-confirm-costly']);
1155
1897
 
1156
1898
  const accepted = await apiFetch(config, options, 'POST', '/api/v1/agent/orchestrate', payload);
1157
1899
  if (boolOption(options['no-wait'])) {
@@ -1199,12 +1941,13 @@ async function workflow(config, options, action, args) {
1199
1941
  }
1200
1942
 
1201
1943
  const redactedAccepted = redactDeep(accepted);
1202
- if (wantJson(options)) {
1944
+ if (boolOption(options['no-wait'])) {
1203
1945
  output(redactedAccepted, options);
1204
- } else {
1946
+ return;
1947
+ }
1948
+ if (!wantJson(options)) {
1205
1949
  console.log(`Continuation workflow queued: ${redactedAccepted.jobId}`);
1206
1950
  }
1207
- if (boolOption(options['no-wait'])) return;
1208
1951
  await pollWorkflow(config, options, accepted.jobId);
1209
1952
  return;
1210
1953
  }
@@ -1222,16 +1965,19 @@ function mimeForPath(filePath) {
1222
1965
  if (ext === '.webm') return 'video/webm';
1223
1966
  if (ext === '.mkv') return 'video/x-matroska';
1224
1967
  if (ext === '.m4v') return 'video/x-m4v';
1968
+ if (ext === '.avi') return 'video/x-msvideo';
1969
+ if (ext === '.wmv' || ext === '.asf') return 'video/x-ms-asf';
1970
+ if (ext === '.flv') return 'video/x-flv';
1971
+ if (ext === '.mpeg' || ext === '.mpg') return 'video/mpeg';
1972
+ if (ext === '.ts' || ext === '.m2ts') return 'video/mp2t';
1973
+ if (ext === '.mxf') return 'video/mxf';
1974
+ if (ext === '.ogv') return 'video/ogg';
1225
1975
  if (ext === '.mp3') return 'audio/mpeg';
1226
1976
  if (ext === '.m4a') return 'audio/mp4';
1227
1977
  if (ext === '.wav') return 'audio/wav';
1228
1978
  return 'video/mp4';
1229
1979
  }
1230
1980
 
1231
- function multipartFilename(value) {
1232
- return String(value).replace(/["\r\n]/g, '_');
1233
- }
1234
-
1235
1981
  function shouldReportUploadProgress(options) {
1236
1982
  return Boolean(process.stderr.isTTY) && !wantJson(options);
1237
1983
  }
@@ -1268,46 +2014,457 @@ function createUploadProgress(options, totalBytes) {
1268
2014
  };
1269
2015
  }
1270
2016
 
1271
- async function* multipartFileBody(filePath, filename, contentType, boundary, progress) {
1272
- yield Buffer.from(
1273
- `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${multipartFilename(filename)}"\r\nContent-Type: ${contentType}\r\n\r\n`,
1274
- );
1275
- for await (const chunk of createReadStream(filePath)) {
1276
- progress?.track(chunk);
1277
- yield chunk;
1278
- }
1279
- yield Buffer.from(`\r\n--${boundary}--\r\n`);
2017
+ const VIDEO_UPLOAD_RESUME_MAX_AGE_MS = 25 * 60 * 60 * 1000;
2018
+
2019
+ function videoUploadResumeDirectory() {
2020
+ return path.join(configDir(), 'video-upload-resumes');
1280
2021
  }
1281
2022
 
1282
- async function uploadVideo(config, options, filePath) {
1283
- if (!filePath) throw Object.assign(new Error('Video file path is required.'), { exitCode: EXIT.USAGE });
1284
- const resolved = path.resolve(filePath);
1285
- const stat = await fs.stat(resolved);
1286
- if (!stat.isFile()) {
1287
- throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
2023
+ function videoUploadResumePath(idempotencyKey) {
2024
+ const digest = createHash('sha256').update(String(idempotencyKey)).digest('hex');
2025
+ return path.join(videoUploadResumeDirectory(), `${digest}.json`);
2026
+ }
2027
+
2028
+ async function readVideoUploadResumes() {
2029
+ const resumes = {};
2030
+ await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
2031
+ const files = await fs.readdir(videoUploadResumeDirectory()).catch(() => []);
2032
+ for (const file of files) {
2033
+ if (!file.endsWith('.json')) continue;
2034
+ const recordPath = path.join(videoUploadResumeDirectory(), file);
2035
+ try {
2036
+ const value = JSON.parse(await fs.readFile(recordPath, 'utf8'));
2037
+ const updatedAt = Date.parse(value?.updatedAt);
2038
+ if (
2039
+ !value
2040
+ || typeof value !== 'object'
2041
+ || Array.isArray(value)
2042
+ || typeof value.idempotencyKey !== 'string'
2043
+ || !Number.isFinite(updatedAt)
2044
+ || Date.now() - updatedAt > VIDEO_UPLOAD_RESUME_MAX_AGE_MS
2045
+ ) {
2046
+ await fs.rm(recordPath, { force: true });
2047
+ continue;
2048
+ }
2049
+ resumes[value.idempotencyKey] = value;
2050
+ } catch {
2051
+ await fs.rm(recordPath, { force: true }).catch(() => undefined);
2052
+ }
1288
2053
  }
1289
- const filename = options.filename || path.basename(resolved);
1290
- const contentType = mimeForPath(resolved);
1291
- const boundary = `clipit-cli-${randomBytes(12).toString('hex')}`;
1292
- const headerLength = Buffer.byteLength(
1293
- `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${multipartFilename(filename)}"\r\nContent-Type: ${contentType}\r\n\r\n`,
1294
- );
1295
- const footerLength = Buffer.byteLength(`\r\n--${boundary}--\r\n`);
1296
- const progress = createUploadProgress(options, stat.size);
1297
- const body = multipartFileBody(resolved, filename, contentType, boundary, progress);
2054
+
2055
+ const legacyPath = path.join(configDir(), 'video-upload-resume.json');
1298
2056
  try {
1299
- output(await apiFetch(config, options, 'POST', '/api/v1/videos', body, {
1300
- rawBody: true,
1301
- headers: {
1302
- 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1303
- 'Content-Length': String(headerLength + stat.size + footerLength),
2057
+ const legacy = JSON.parse(await fs.readFile(legacyPath, 'utf8'));
2058
+ if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
2059
+ for (const [idempotencyKey, value] of Object.entries(legacy)) {
2060
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
2061
+ await saveVideoUploadResume(idempotencyKey, value);
2062
+ resumes[idempotencyKey] = { ...value, idempotencyKey };
2063
+ }
2064
+ }
2065
+ await fs.rm(legacyPath, { force: true });
2066
+ } catch {}
2067
+
2068
+ return resumes;
2069
+ }
2070
+
2071
+ async function saveVideoUploadResume(idempotencyKey, value) {
2072
+ await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
2073
+ const destination = videoUploadResumePath(idempotencyKey);
2074
+ const temporary = `${destination}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
2075
+ await fs.writeFile(
2076
+ temporary,
2077
+ `${JSON.stringify({ ...value, idempotencyKey }, null, 2)}\n`,
2078
+ { mode: 0o600 },
2079
+ );
2080
+ await fs.rename(temporary, destination);
2081
+ }
2082
+
2083
+ async function removeVideoUploadResume(idempotencyKey) {
2084
+ await fs.rm(videoUploadResumePath(idempotencyKey), { force: true });
2085
+ }
2086
+
2087
+ async function removeVideoUploadResumeByIntent(intentId) {
2088
+ const resumes = await readVideoUploadResumes();
2089
+ await Promise.all(Object.entries(resumes).map(async ([key, value]) => {
2090
+ if (value?.intentId === intentId) await removeVideoUploadResume(key);
2091
+ }));
2092
+ }
2093
+
2094
+ function defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType) {
2095
+ const fingerprint = createHash('sha256').update(JSON.stringify({
2096
+ path: resolved,
2097
+ size: stat.size,
2098
+ mtimeMs: stat.mtimeMs,
2099
+ ctimeMs: stat.ctimeMs,
2100
+ filename,
2101
+ contentType,
2102
+ })).digest('hex');
2103
+ return `cli-video:${fingerprint}`;
2104
+ }
2105
+
2106
+ function createMultipartUploadProgress(options, totalBytes, completedParts) {
2107
+ if (!shouldReportUploadProgress(options) || totalBytes <= 0) {
2108
+ return { updatePart() {}, completePart() {}, resetPart() {}, finish() {} };
2109
+ }
2110
+ const completed = new Map(completedParts.map((part) => [part.partNumber, part.sizeBytes]));
2111
+ const inFlight = new Map();
2112
+ let lastPrintedAt = 0;
2113
+
2114
+ const print = (force = false) => {
2115
+ const now = Date.now();
2116
+ if (!force && now - lastPrintedAt < 500) return;
2117
+ lastPrintedAt = now;
2118
+ const uploaded = Math.min(
2119
+ totalBytes,
2120
+ [...completed.values(), ...inFlight.values()].reduce((sum, value) => sum + value, 0),
2121
+ );
2122
+ const percent = Math.min(100, Math.round((uploaded / totalBytes) * 100));
2123
+ process.stderr.write(`\ruploaded ${formatMb(uploaded)} / ${formatMb(totalBytes)} MB (${percent}%)`);
2124
+ };
2125
+
2126
+ return {
2127
+ updatePart(partNumber, bytes) {
2128
+ inFlight.set(partNumber, bytes);
2129
+ print(false);
2130
+ },
2131
+ completePart(partNumber, bytes) {
2132
+ inFlight.delete(partNumber);
2133
+ completed.set(partNumber, bytes);
2134
+ print(false);
2135
+ },
2136
+ resetPart(partNumber) {
2137
+ inFlight.delete(partNumber);
2138
+ print(false);
2139
+ },
2140
+ finish() {
2141
+ print(true);
2142
+ process.stderr.write('\n');
2143
+ },
2144
+ };
2145
+ }
2146
+
2147
+ function createUploadActivityWatchdog(controller, onTimeout) {
2148
+ let timer;
2149
+ const arm = () => {
2150
+ if (timer) clearTimeout(timer);
2151
+ timer = setTimeout(() => {
2152
+ const error = Object.assign(
2153
+ new Error(`Storage upload made no progress for ${VIDEO_UPLOAD_STALL_TIMEOUT_MS} ms`),
2154
+ { code: 'UPLOAD_STALLED' },
2155
+ );
2156
+ controller.abort(error);
2157
+ onTimeout?.();
2158
+ }, VIDEO_UPLOAD_STALL_TIMEOUT_MS);
2159
+ timer.unref?.();
2160
+ };
2161
+ arm();
2162
+ return {
2163
+ activity: arm,
2164
+ stop() {
2165
+ if (timer) clearTimeout(timer);
2166
+ timer = undefined;
2167
+ },
2168
+ };
2169
+ }
2170
+
2171
+ async function putSignedVideoPart(part, filePath, start, size, progress) {
2172
+ for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
2173
+ let transferred = 0;
2174
+ const controller = new AbortController();
2175
+ let source;
2176
+ let tracker;
2177
+ const watchdog = createUploadActivityWatchdog(controller, () => {
2178
+ source?.destroy();
2179
+ tracker?.destroy();
2180
+ });
2181
+ tracker = new Transform({
2182
+ transform(chunk, encoding, callback) {
2183
+ transferred += Buffer.byteLength(chunk);
2184
+ watchdog.activity();
2185
+ progress.updatePart(part.partNumber, transferred);
2186
+ callback(null, chunk);
1304
2187
  },
1305
- }), options);
2188
+ });
2189
+ try {
2190
+ source = createReadStream(filePath, { start, end: start + size - 1 });
2191
+ const response = await fetch(part.url, {
2192
+ method: 'PUT',
2193
+ headers: { 'Content-Length': String(size) },
2194
+ body: source.pipe(tracker),
2195
+ duplex: 'half',
2196
+ signal: controller.signal,
2197
+ });
2198
+ if (!response.ok) {
2199
+ const text = await response.text().catch(() => '');
2200
+ throw new Error(`Part ${part.partNumber} failed: ${response.status} ${redact(text || response.statusText)}`);
2201
+ }
2202
+ progress.completePart(part.partNumber, size);
2203
+ return;
2204
+ } catch (error) {
2205
+ progress.resetPart(part.partNumber);
2206
+ const reason = controller.signal.aborted && controller.signal.reason instanceof Error
2207
+ ? controller.signal.reason
2208
+ : error;
2209
+ if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1) throw reason;
2210
+ await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
2211
+ } finally {
2212
+ watchdog.stop();
2213
+ source?.destroy();
2214
+ tracker?.destroy();
2215
+ }
2216
+ }
2217
+ }
2218
+
2219
+ async function uploadVideoMultipart(config, options, resolved, initialized, totalBytes) {
2220
+ const status = await apiFetch(
2221
+ config,
2222
+ options,
2223
+ 'GET',
2224
+ `/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}`,
2225
+ );
2226
+ const uploadedParts = Array.isArray(status.uploadedParts) ? status.uploadedParts : [];
2227
+ const uploadedNumbers = new Set(uploadedParts.map((part) => Number(part.partNumber)));
2228
+ const missing = Array.from({ length: Number(initialized.partCount) }, (_, index) => index + 1)
2229
+ .filter((partNumber) => !uploadedNumbers.has(partNumber));
2230
+ const progress = createMultipartUploadProgress(options, totalBytes, uploadedParts);
2231
+
2232
+ try {
2233
+ for (let offset = 0; offset < missing.length; offset += VIDEO_UPLOAD_PART_SIGN_BATCH) {
2234
+ const partNumbers = missing.slice(offset, offset + VIDEO_UPLOAD_PART_SIGN_BATCH);
2235
+ const signed = await apiFetch(
2236
+ config,
2237
+ options,
2238
+ 'POST',
2239
+ `/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/parts`,
2240
+ { partNumbers },
2241
+ );
2242
+ const queue = [...(signed.signedPartUrls || [])];
2243
+ const signedNumbers = new Set(queue.map((part) => Number(part?.partNumber)));
2244
+ if (
2245
+ queue.length !== partNumbers.length
2246
+ || signedNumbers.size !== partNumbers.length
2247
+ || partNumbers.some((partNumber) => !signedNumbers.has(partNumber))
2248
+ || queue.some((part) => typeof part?.url !== 'string' || !part.url)
2249
+ ) {
2250
+ throw new Error('Server returned an incomplete multipart signing batch.');
2251
+ }
2252
+ let cursor = 0;
2253
+ const worker = async () => {
2254
+ while (cursor < queue.length) {
2255
+ const part = queue[cursor++];
2256
+ const start = (part.partNumber - 1) * initialized.partSizeBytes;
2257
+ const size = Math.min(initialized.partSizeBytes, totalBytes - start);
2258
+ if (size !== Number(part.expectedSizeBytes)) {
2259
+ throw new Error(`Server returned an invalid size for upload part ${part.partNumber}.`);
2260
+ }
2261
+ await putSignedVideoPart(part, resolved, start, size, progress);
2262
+ }
2263
+ };
2264
+ const workerCount = Math.min(VIDEO_UPLOAD_PART_CONCURRENCY, queue.length);
2265
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
2266
+ }
1306
2267
  } finally {
1307
2268
  progress.finish();
1308
2269
  }
1309
2270
  }
1310
2271
 
2272
+ async function uploadVideo(config, options, filePath) {
2273
+ if (!filePath) throw Object.assign(new Error('Video file path is required.'), { exitCode: EXIT.USAGE });
2274
+ const resolved = path.resolve(filePath);
2275
+ const stat = await fs.stat(resolved);
2276
+ if (!stat.isFile()) {
2277
+ throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
2278
+ }
2279
+ const filename = String(options.filename || path.basename(resolved));
2280
+ const contentType = mimeForPath(resolved);
2281
+ const title = options.title === undefined || options.title === null
2282
+ ? null
2283
+ : String(options.title);
2284
+ const idempotencyKey = String(
2285
+ options['idempotency-key']
2286
+ || defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType),
2287
+ );
2288
+ const uploadIdentity = {
2289
+ filePath: resolved,
2290
+ filename,
2291
+ title,
2292
+ profile: profileName(config, options),
2293
+ baseUrl: getBaseUrl(config, options),
2294
+ allowCustomHost: allowCustomHost(options),
2295
+ contentType,
2296
+ size: stat.size,
2297
+ mtimeMs: stat.mtimeMs,
2298
+ };
2299
+ const resumes = await readVideoUploadResumes();
2300
+ const existingResume = resumes[idempotencyKey];
2301
+ if (existingResume && typeof existingResume === 'object') {
2302
+ const mismatchedFields = Object.entries(uploadIdentity)
2303
+ .filter(([key, value]) => {
2304
+ const existingValue = existingResume[key];
2305
+ if (key === 'title') return (existingValue ?? null) !== value;
2306
+ return existingValue !== value;
2307
+ })
2308
+ .map(([key]) => key);
2309
+ if (mismatchedFields.length > 0) {
2310
+ const abortArgs = existingResume.intentId
2311
+ ? ['videos', 'abort-upload', String(existingResume.intentId), '--profile', String(existingResume.profile || uploadIdentity.profile), '--base-url', String(existingResume.baseUrl || uploadIdentity.baseUrl), '--confirm']
2312
+ : null;
2313
+ if (abortArgs && existingResume.allowCustomHost === true) abortArgs.push('--allow-custom-host');
2314
+ throw Object.assign(
2315
+ new Error('The file or upload identity changed since this resumable upload began. Abort the prior upload and retry with a new idempotency key.'),
2316
+ {
2317
+ exitCode: EXIT.USAGE,
2318
+ data: {
2319
+ idempotencyKey,
2320
+ uploadIntentId: existingResume.intentId,
2321
+ mismatchedFields,
2322
+ abortArgs,
2323
+ abortCommand: abortArgs ? `clipit ${abortArgs.map(shellQuote).join(' ')}` : undefined,
2324
+ },
2325
+ },
2326
+ );
2327
+ }
2328
+ }
2329
+ await enforceMaxCredits(config, options, 'videos upload', { bytes: stat.size });
2330
+ confirmPaid(options, 'Uploading a video');
2331
+ await saveVideoUploadResume(idempotencyKey, {
2332
+ ...uploadIdentity,
2333
+ intentId: existingResume?.intentId,
2334
+ jobId: existingResume?.jobId,
2335
+ updatedAt: new Date().toISOString(),
2336
+ });
2337
+
2338
+ let initialized;
2339
+ try {
2340
+ initialized = await apiFetch(config, options, 'POST', '/api/v1/videos/uploads', {
2341
+ filename,
2342
+ contentType,
2343
+ size: stat.size,
2344
+ title: title ?? undefined,
2345
+ idempotencyKey,
2346
+ });
2347
+ if (!initialized?.intentId || !initialized?.jobId) {
2348
+ throw Object.assign(new Error('Video upload initialization returned an unexpected response.'), {
2349
+ exitCode: EXIT.SERVER,
2350
+ data: initialized,
2351
+ });
2352
+ }
2353
+ if (!initialized.readyForUpload) {
2354
+ await removeVideoUploadResume(idempotencyKey);
2355
+ if (initialized.videoId) {
2356
+ await persistActiveContext(
2357
+ config,
2358
+ options,
2359
+ { videoId: initialized.videoId },
2360
+ [{ type: 'video', id: initialized.videoId }],
2361
+ );
2362
+ }
2363
+ output(initialized, options);
2364
+ return;
2365
+ }
2366
+
2367
+ await saveVideoUploadResume(idempotencyKey, {
2368
+ ...uploadIdentity,
2369
+ intentId: initialized.intentId,
2370
+ jobId: initialized.jobId,
2371
+ updatedAt: new Date().toISOString(),
2372
+ });
2373
+
2374
+ if (initialized.transport === 'single') {
2375
+ if (!initialized.uploadUrl || Number(initialized.expectedSizeBytes) !== stat.size) {
2376
+ throw new Error('Direct upload initialization returned invalid transfer metadata.');
2377
+ }
2378
+ await putSignedUpload(
2379
+ initialized.uploadUrl,
2380
+ resolved,
2381
+ contentType,
2382
+ stat.size,
2383
+ options,
2384
+ initialized.requiredHeaders,
2385
+ );
2386
+ } else if (initialized.transport === 'multipart') {
2387
+ if (
2388
+ !Number.isInteger(initialized.partCount)
2389
+ || !Number.isInteger(initialized.partSizeBytes)
2390
+ || Number(initialized.expectedSizeBytes) !== stat.size
2391
+ ) {
2392
+ throw new Error('Multipart upload initialization returned invalid transfer metadata.');
2393
+ }
2394
+ await uploadVideoMultipart(config, options, resolved, initialized, stat.size);
2395
+ } else {
2396
+ throw new Error('Video upload initialization returned no supported transport.');
2397
+ }
2398
+
2399
+ const completedStat = await fs.stat(resolved);
2400
+ if (
2401
+ !completedStat.isFile()
2402
+ || completedStat.size !== stat.size
2403
+ || completedStat.mtimeMs !== stat.mtimeMs
2404
+ || completedStat.ctimeMs !== stat.ctimeMs
2405
+ ) {
2406
+ throw Object.assign(
2407
+ new Error('The video file changed while it was uploading. Abort this upload and retry with a new idempotency key.'),
2408
+ { exitCode: EXIT.USAGE, data: { fileChangedDuringUpload: true } },
2409
+ );
2410
+ }
2411
+
2412
+ const result = await apiFetch(
2413
+ config,
2414
+ options,
2415
+ 'POST',
2416
+ `/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/complete`,
2417
+ {},
2418
+ );
2419
+ await removeVideoUploadResume(idempotencyKey);
2420
+ if (result?.videoId) {
2421
+ await persistActiveContext(
2422
+ config,
2423
+ options,
2424
+ { videoId: result.videoId },
2425
+ [{ type: 'video', id: result.videoId }],
2426
+ );
2427
+ }
2428
+ output(result, options);
2429
+ } catch (error) {
2430
+ if (error && typeof error === 'object') {
2431
+ const requiresNewIdempotencyKey = error.data?.requiresNewIdempotencyKey === true
2432
+ || error.data?.code === 'IDEMPOTENCY_KEY_TERMINAL';
2433
+ if (requiresNewIdempotencyKey) {
2434
+ await removeVideoUploadResume(idempotencyKey);
2435
+ error.data = {
2436
+ ...(error.data && typeof error.data === 'object' ? error.data : {}),
2437
+ uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
2438
+ idempotencyKey,
2439
+ };
2440
+ throw error;
2441
+ }
2442
+ const resumeArgs = [
2443
+ 'videos',
2444
+ 'upload',
2445
+ resolved,
2446
+ '--idempotency-key',
2447
+ idempotencyKey,
2448
+ ];
2449
+ resumeArgs.push('--filename', filename);
2450
+ if (title) resumeArgs.push('--title', title);
2451
+ resumeArgs.push('--profile', uploadIdentity.profile);
2452
+ resumeArgs.push('--base-url', uploadIdentity.baseUrl);
2453
+ if (uploadIdentity.allowCustomHost) resumeArgs.push('--allow-custom-host');
2454
+ if (boolOption(options.confirm) || boolOption(options.yes)) resumeArgs.push('--confirm');
2455
+ if (wantJson(options)) resumeArgs.push('--json');
2456
+ error.data = {
2457
+ ...(error.data && typeof error.data === 'object' ? error.data : {}),
2458
+ uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
2459
+ idempotencyKey,
2460
+ resumeArgs,
2461
+ resumeCommand: `clipit ${resumeArgs.map(shellQuote).join(' ')}`,
2462
+ };
2463
+ }
2464
+ throw error;
2465
+ }
2466
+ }
2467
+
1311
2468
  async function videos(config, options, action, args) {
1312
2469
  if (action === 'list') {
1313
2470
  output(await apiFetch(config, options, 'GET', `/api/v1/videos${queryString({ limit: options.limit, offset: options.offset })}`), options);
@@ -1324,15 +2481,35 @@ async function videos(config, options, action, args) {
1324
2481
  const url = args[0] || options.url;
1325
2482
  if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
1326
2483
  await enforceMaxCredits(config, options, 'videos import-url', { url });
1327
- output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', { url, title: options.title }), options);
2484
+ confirmPaid(options, 'Importing a video from URL');
2485
+ output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', {
2486
+ url,
2487
+ title: options.title,
2488
+ idempotencyKey: options['idempotency-key'] || randomUUID(),
2489
+ }), options);
1328
2490
  return;
1329
2491
  }
1330
2492
  if (action === 'upload') {
1331
2493
  await uploadVideo(config, options, args[0] || options.file);
1332
2494
  return;
1333
2495
  }
2496
+ if (action === 'abort-upload') {
2497
+ const intentId = requiredString(args[0] || options['intent-id'], 'Upload intent id');
2498
+ requireConfirm(options, 'Aborting a video upload');
2499
+ const result = await apiFetch(
2500
+ config,
2501
+ options,
2502
+ 'DELETE',
2503
+ `/api/v1/videos/uploads/${encodeURIComponent(intentId)}`,
2504
+ );
2505
+ await removeVideoUploadResumeByIntent(intentId);
2506
+ output(result, options);
2507
+ return;
2508
+ }
1334
2509
  if (action === 'transcribe') {
1335
2510
  if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
2511
+ await enforceMaxCredits(config, options, 'videos transcribe', { videoId: args[0] });
2512
+ confirmPaid(options, 'Transcribing a video');
1336
2513
  output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/transcribe`, {}), options);
1337
2514
  return;
1338
2515
  }
@@ -1352,6 +2529,8 @@ async function videos(config, options, action, args) {
1352
2529
  targetPlatforms: stringList(options.platforms),
1353
2530
  themes: stringList(options.themes),
1354
2531
  };
2532
+ await enforceMaxCredits(config, options, 'videos suggest-clips', { videoId: args[0], body });
2533
+ confirmPaid(options, 'Suggesting clips');
1355
2534
  output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/suggest-clips`, body), options);
1356
2535
  return;
1357
2536
  }
@@ -1365,6 +2544,119 @@ async function videos(config, options, action, args) {
1365
2544
  throw Object.assign(new Error(`Unknown videos command: ${action || ''}`), { exitCode: EXIT.USAGE });
1366
2545
  }
1367
2546
 
2547
+ function formatClipDeliveryState(state) {
2548
+ const editor = state?.editorState;
2549
+ const selected = state?.selectedExport;
2550
+ const candidates = Array.isArray(state?.exports) ? state.exports : [];
2551
+ const blockers = Array.isArray(state?.deliveryBlockers) ? state.deliveryBlockers : [];
2552
+ const lines = [
2553
+ `Clip: ${state?.clipId || 'unknown'}`,
2554
+ editor
2555
+ ? `Editor snapshot: ${editor.snapshotId}`
2556
+ : `Editor: ${state?.editorStateStatus || 'unavailable'}`,
2557
+ editor ? `Editor version: ${editor.editorVersion}` : null,
2558
+ editor ? `Editor state hash: ${editor.editorStateHash}` : null,
2559
+ editor ? `Source object fingerprint: ${editor.sourceObjectFingerprint || 'unavailable'}` : null,
2560
+ editor ? `Saved: ${editor.saveOrigin} at ${editor.savedAt}` : null,
2561
+ editor
2562
+ ? `Edit: ${editor.aspectRatio}; captions ${editor.caption?.enabled ? 'on' : 'off'}${editor.caption?.presetId ? `/${editor.caption.presetId}` : ''}; crop ${editor.cropSegmentCount}; keyframes ${editor.keyframeCount}; layout ${editor.layout || 'none'}`
2563
+ : null,
2564
+ `Selection: ${state?.selection?.status || 'unknown'}${state?.selection?.selectedExportId ? ` (${state.selection.selectedExportId})` : ''}`,
2565
+ selected
2566
+ ? `Selected export: ${selected.exportId}; snapshot ${selected.snapshotId || 'unavailable'}; exact current match ${selected.exactlyMatchesEditor === true ? 'yes' : 'no'}`
2567
+ : `Artifacts: ${candidates.length}`,
2568
+ selected ? `Output object fingerprint: ${selected.outputObjectFingerprint || 'unavailable'}` : null,
2569
+ selected ? `Storage item: ${selected.storageItemId || 'unavailable'}` : null,
2570
+ selected
2571
+ ? `Artifact: ${selected.width || '?'}x${selected.height || '?'} ${selected.duration || '?'}s; audio ${selected.hasAudio === true ? 'yes' : selected.hasAudio === false ? 'no' : 'unknown'}; probe ${selected.inspectionStatus}`
2572
+ : null,
2573
+ `Ready to publish: ${state?.readyToPublish === true ? 'yes' : 'no'}`,
2574
+ ].filter(Boolean);
2575
+ if (candidates.length > 1) {
2576
+ lines.push(`Export ids: ${candidates.map((candidate) => candidate.exportId).join(', ')}`);
2577
+ }
2578
+ if (blockers.length) {
2579
+ lines.push('Blockers:');
2580
+ lines.push(...blockers.map((blocker) => `- ${blocker}`));
2581
+ }
2582
+ return lines.join('\n');
2583
+ }
2584
+
2585
+ function requireVerifiedEditorState(deliveryState, clipId) {
2586
+ const editor = deliveryState?.editorState;
2587
+ if (
2588
+ deliveryState?.schema !== 'clipit_clip_delivery_state'
2589
+ || deliveryState?.version !== 2
2590
+ || deliveryState?.clipId !== clipId
2591
+ || deliveryState?.editorStateStatus !== 'verified'
2592
+ || !editor
2593
+ || typeof editor.snapshotId !== 'string'
2594
+ || !editor.snapshotId
2595
+ || !Number.isInteger(editor.editorVersion)
2596
+ || editor.editorVersion < 1
2597
+ || typeof editor.editorStateHash !== 'string'
2598
+ || !/^[a-f0-9]{64}$/i.test(editor.editorStateHash)
2599
+ || editor.stateSource !== 'current_editor_snapshot'
2600
+ ) {
2601
+ throw Object.assign(
2602
+ new Error('Clip does not have a verified canonical editor snapshot. Save and verify the editor state before continuing.'),
2603
+ { exitCode: EXIT.SERVER, data: deliveryState },
2604
+ );
2605
+ }
2606
+ return editor;
2607
+ }
2608
+
2609
+ function requireExactCurrentExport(deliveryState, editorState, options = {}) {
2610
+ const requestedExportId = options.requestedExportId
2611
+ ? String(options.requestedExportId)
2612
+ : null;
2613
+ const selection = deliveryState?.selection;
2614
+ const selected = deliveryState?.selectedExport;
2615
+ const blockers = Array.isArray(deliveryState?.deliveryBlockers)
2616
+ ? deliveryState.deliveryBlockers
2617
+ : [];
2618
+ const selectedBlockers = Array.isArray(selected?.blockers) ? selected.blockers : [];
2619
+ const responseRequestedExportId = selection?.requestedExportId ?? null;
2620
+ const exact = Boolean(
2621
+ selection?.status === 'selected'
2622
+ && selected
2623
+ && typeof selected.exportId === 'string'
2624
+ && selected.exportId
2625
+ && selection.selectedExportId === selected.exportId
2626
+ && responseRequestedExportId === requestedExportId
2627
+ && (!requestedExportId || selected.exportId === requestedExportId)
2628
+ && selected.snapshotId === editorState.snapshotId
2629
+ && selected.editorVersion === editorState.editorVersion
2630
+ && selected.editorStateHash === editorState.editorStateHash
2631
+ && selected.exactlyMatchesEditor === true
2632
+ && selected.inspectionStatus === 'verified'
2633
+ && typeof selected.outputObjectFingerprint === 'string'
2634
+ && /^[a-f0-9]{64}$/i.test(selected.outputObjectFingerprint)
2635
+ && selectedBlockers.length === 0
2636
+ );
2637
+ const publishReady = !options.requireReadyToPublish
2638
+ || (deliveryState?.readyToPublish === true && blockers.length === 0);
2639
+ if (!exact || !publishReady) {
2640
+ const detail = blockers.length
2641
+ ? blockers.join(' ')
2642
+ : `Selection status is ${selection?.status || 'unknown'}.`;
2643
+ throw Object.assign(
2644
+ new Error(`Clip does not have one verified exact-current export: ${detail}`),
2645
+ { exitCode: EXIT.SERVER, data: deliveryState },
2646
+ );
2647
+ }
2648
+ return selected;
2649
+ }
2650
+
2651
+ async function fetchCanonicalClipDeliveryState(config, options, clipId, exportId) {
2652
+ return apiFetch(
2653
+ config,
2654
+ options,
2655
+ 'GET',
2656
+ `/api/v1/clips/${encodeURIComponent(clipId)}/delivery-state${queryString({ exportId })}`,
2657
+ );
2658
+ }
2659
+
1368
2660
  async function clips(config, options, action, args) {
1369
2661
  if (action === 'list') {
1370
2662
  output(await apiFetch(config, options, 'GET', `/api/v1/clips${queryString({
@@ -1381,6 +2673,19 @@ async function clips(config, options, action, args) {
1381
2673
  output(result, options);
1382
2674
  return;
1383
2675
  }
2676
+ if (action === 'delivery-state') {
2677
+ if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
2678
+ const result = await apiFetch(
2679
+ config,
2680
+ options,
2681
+ 'GET',
2682
+ `/api/v1/clips/${encodeURIComponent(args[0])}/delivery-state${queryString({
2683
+ exportId: options['export-id'],
2684
+ })}`,
2685
+ );
2686
+ output(wantJson(options) ? result : formatClipDeliveryState(result), options);
2687
+ return;
2688
+ }
1384
2689
  if (action === 'create') {
1385
2690
  const body = options.params
1386
2691
  ? await readJsonOption(String(options.params))
@@ -1394,7 +2699,21 @@ async function clips(config, options, action, args) {
1394
2699
  if (!body.videoId) throw Object.assign(new Error('--video-id is required.'), { exitCode: EXIT.USAGE });
1395
2700
  if (body.startTime === undefined) throw Object.assign(new Error('--start is required.'), { exitCode: EXIT.USAGE });
1396
2701
  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);
2702
+ await enforceMaxCredits(config, options, 'clips create', { body });
2703
+ confirmPaid(options, 'Creating a clip');
2704
+ const result = await apiFetch(config, options, 'POST', '/api/v1/clips', body);
2705
+ if (result?.id) {
2706
+ await persistActiveContext(
2707
+ config,
2708
+ options,
2709
+ { videoId: result.videoId || body.videoId, clipId: result.id },
2710
+ [
2711
+ { type: 'video', id: result.videoId || body.videoId },
2712
+ { type: 'clip', id: result.id },
2713
+ ],
2714
+ );
2715
+ }
2716
+ output(result, options);
1398
2717
  return;
1399
2718
  }
1400
2719
  if (action === 'update') {
@@ -1428,14 +2747,40 @@ async function clips(config, options, action, args) {
1428
2747
  if (body[key] === undefined) delete body[key];
1429
2748
  }
1430
2749
  await enforceMaxCredits(config, options, 'clips render', { clipId: args[0], body });
2750
+ confirmPaid(options, 'Rendering a clip');
1431
2751
  output(await apiFetch(config, options, 'POST', `/api/v1/clips/${encodeURIComponent(args[0])}/render`, body), options);
1432
2752
  return;
1433
2753
  }
1434
2754
  if (action === 'download') {
1435
2755
  if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
1436
- const result = await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(args[0])}/download`);
2756
+ const clipId = args[0];
2757
+ const requestedExportId = options['export-id'];
2758
+ const deliveryState = await fetchCanonicalClipDeliveryState(
2759
+ config,
2760
+ options,
2761
+ clipId,
2762
+ requestedExportId,
2763
+ );
2764
+ const editorState = requireVerifiedEditorState(deliveryState, clipId);
2765
+ const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
2766
+ requestedExportId,
2767
+ });
2768
+ const result = await apiFetch(
2769
+ config,
2770
+ options,
2771
+ 'GET',
2772
+ `/api/v1/exports/${encodeURIComponent(selectedExport.exportId)}/download`,
2773
+ );
1437
2774
  if (options.open && result?.downloadUrl) openBrowser(result.downloadUrl);
1438
- output(result, options);
2775
+ output({
2776
+ ...result,
2777
+ clipId,
2778
+ exportId: selectedExport.exportId,
2779
+ snapshotId: editorState.snapshotId,
2780
+ editorVersion: editorState.editorVersion,
2781
+ editorStateHash: editorState.editorStateHash,
2782
+ outputObjectFingerprint: selectedExport.outputObjectFingerprint,
2783
+ }, options);
1439
2784
  return;
1440
2785
  }
1441
2786
  if (action === 'delete') {
@@ -1460,18 +2805,78 @@ async function credits(config, options, action) {
1460
2805
  if (action === 'estimate') {
1461
2806
  const operationType = requiredString(options['operation-type'], '--operation-type');
1462
2807
  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({
2808
+ const metrics = await readMetricsOption(options.metrics ?? options.metadata);
2809
+ const request = normalizeCreditEstimateRequest(compactObject({
1465
2810
  operationType,
1466
2811
  provider,
1467
2812
  modelId: options['model-id'],
1468
2813
  metrics,
1469
- })), options);
2814
+ }));
2815
+ output(await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', request), options);
1470
2816
  return;
1471
2817
  }
1472
2818
  throw Object.assign(new Error(`Unknown credits command: ${action || ''}`), { exitCode: EXIT.USAGE });
1473
2819
  }
1474
2820
 
2821
+ function normalizeBillingProvider(value) {
2822
+ if (value === undefined || value === null || value === '') return undefined;
2823
+ const provider = String(value);
2824
+ if (!BILLING_PROVIDER_PREFERENCES.has(provider)) {
2825
+ throw Object.assign(
2826
+ new Error(`--provider must be one of: ${Array.from(BILLING_PROVIDER_PREFERENCES).join(', ')}.`),
2827
+ { exitCode: EXIT.USAGE },
2828
+ );
2829
+ }
2830
+ return provider;
2831
+ }
2832
+
2833
+ function buildBillingAttemptBody(input = {}) {
2834
+ return compactObject({
2835
+ productKey: requiredString(input.productKey ?? input['product-key'] ?? input.product ?? input.key, '--product-key'),
2836
+ providerPreference: normalizeBillingProvider(input.providerPreference ?? input.provider ?? input.rail),
2837
+ idempotencyKey: input.idempotencyKey ?? input['idempotency-key'],
2838
+ });
2839
+ }
2840
+
2841
+ function billingAttemptId(args, options) {
2842
+ return requiredString(args[0] ?? options['attempt-id'] ?? options.attemptId, 'Attempt id');
2843
+ }
2844
+
2845
+ async function billing(config, options, action, args = []) {
2846
+ if (action === 'capabilities') {
2847
+ output(await apiFetch(config, options, 'GET', '/api/v1/agent/payment-capabilities', undefined, { noAuth: true }), options);
2848
+ return;
2849
+ }
2850
+ if (action === 'catalog') {
2851
+ output(await apiFetch(config, options, 'GET', '/api/v1/billing/catalog', undefined, { noAuth: true }), options);
2852
+ return;
2853
+ }
2854
+ if (action === 'create-attempt') {
2855
+ requireConfirm(options, 'Creating a machine-payment attempt');
2856
+ const body = buildBillingAttemptBody({
2857
+ ...options,
2858
+ productKey: options['product-key'] ?? options.productKey ?? args[0],
2859
+ });
2860
+ output(await apiFetch(config, options, 'POST', '/api/v1/billing/agent-payments', body), options);
2861
+ return;
2862
+ }
2863
+ if (action === 'attempt') {
2864
+ const attemptId = billingAttemptId(args, options);
2865
+ output(await apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}`), options);
2866
+ return;
2867
+ }
2868
+ if (action === 'receipt') {
2869
+ const attemptId = billingAttemptId(args, options);
2870
+ output(await apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}/receipt`), options);
2871
+ return;
2872
+ }
2873
+ if (action === 'subscription') {
2874
+ output(await apiFetch(config, options, 'GET', '/api/v1/billing/subscription'), options);
2875
+ return;
2876
+ }
2877
+ throw Object.assign(new Error(`Unknown billing command: ${action || ''}`), { exitCode: EXIT.USAGE });
2878
+ }
2879
+
1475
2880
  async function analytics(config, options, action, args) {
1476
2881
  if (action === 'overview') {
1477
2882
  const suffix = queryString({ days: options.days ?? 30 });
@@ -1518,6 +2923,17 @@ function defaultExportStartBody(clipId) {
1518
2923
  };
1519
2924
  }
1520
2925
 
2926
+ function buildCanonicalExportStartBody(clipId, params, editorState, idempotencyKey) {
2927
+ return {
2928
+ ...defaultExportStartBody(clipId),
2929
+ ...params,
2930
+ clipId,
2931
+ idempotencyKey,
2932
+ expectedEditorVersion: editorState.editorVersion,
2933
+ expectedEditorStateHash: editorState.editorStateHash,
2934
+ };
2935
+ }
2936
+
1521
2937
  async function pollExport(config, options, jobId) {
1522
2938
  const startedAt = Date.now();
1523
2939
  const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
@@ -1540,18 +2956,36 @@ async function exportsCommand(config, options, action, args) {
1540
2956
  if (action === 'start') {
1541
2957
  const clipId = requiredString(options['clip-id'], '--clip-id');
1542
2958
  const params = options.params ? await readJsonOption(String(options.params)) : {};
1543
- const body = {
1544
- ...defaultExportStartBody(clipId),
1545
- ...params,
2959
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
2960
+ throw Object.assign(new Error('--params must contain a JSON object.'), { exitCode: EXIT.USAGE });
2961
+ }
2962
+ const deliveryState = await fetchCanonicalClipDeliveryState(config, options, clipId);
2963
+ const editorState = requireVerifiedEditorState(deliveryState, clipId);
2964
+ const idempotencyKey = requiredString(
2965
+ options['idempotency-key'] || params.idempotencyKey || `cli-export:${randomUUID()}`,
2966
+ '--idempotency-key',
2967
+ );
2968
+ const body = buildCanonicalExportStartBody(
1546
2969
  clipId,
1547
- };
2970
+ params,
2971
+ editorState,
2972
+ idempotencyKey,
2973
+ );
1548
2974
  await enforceMaxCredits(config, options, 'exports start', { clipId, body });
1549
2975
  confirmPaid(options, 'Starting an export');
1550
- output(await apiFetch(config, options, 'POST', '/api/v1/exports', body), options);
2976
+ const result = await apiFetch(config, options, 'POST', '/api/v1/exports', body);
2977
+ output({
2978
+ ...result,
2979
+ clipId,
2980
+ snapshotId: editorState.snapshotId,
2981
+ expectedEditorVersion: body.expectedEditorVersion,
2982
+ expectedEditorStateHash: body.expectedEditorStateHash,
2983
+ idempotencyKey: body.idempotencyKey,
2984
+ }, options);
1551
2985
  return;
1552
2986
  }
1553
2987
  if (action === 'list') {
1554
- output(await apiFetch(config, options, 'GET', `/api/v1/exports${queryString({ limit: options.limit, offset: options.offset })}`), options);
2988
+ output(await apiFetch(config, options, 'GET', `/api/v1/exports${queryString({ limit: options.limit, offset: options.offset, clipId: options['clip-id'] })}`), options);
1555
2989
  return;
1556
2990
  }
1557
2991
  if (action === 'get') {
@@ -1572,38 +3006,83 @@ async function exportsCommand(config, options, action, args) {
1572
3006
  }
1573
3007
  if (action === 'cancel') {
1574
3008
  const jobId = requiredString(args[0], 'Export job id');
3009
+ requireConfirm(options, 'Cancelling an export');
1575
3010
  output(await apiFetch(config, options, 'POST', `/api/v1/exports/${encodeURIComponent(jobId)}/cancel`, {}), options);
1576
3011
  return;
1577
3012
  }
1578
3013
  throw Object.assign(new Error(`Unknown exports command: ${action || ''}`), { exitCode: EXIT.USAGE });
1579
3014
  }
1580
3015
 
1581
- function objectPathFromKey(key) {
1582
- return String(key).startsWith('/objects/') ? String(key) : `/objects/${key}`;
1583
- }
1584
-
1585
- async function putSignedUpload(uploadUrl, filePath, contentType, size, options) {
1586
- let response;
1587
- const progress = createUploadProgress(options, size);
1588
- const body = createReadStream(filePath).pipe(progressTransform(progress));
1589
- try {
1590
- response = await fetch(uploadUrl, {
1591
- method: 'PUT',
1592
- headers: {
3016
+ async function putSignedUpload(uploadUrl, filePath, contentType, size, options, requiredHeaders) {
3017
+ const headers = requiredHeaders && typeof requiredHeaders === 'object'
3018
+ ? Object.fromEntries(Object.entries(requiredHeaders).map(([key, value]) => [key, String(value)]))
3019
+ : {
1593
3020
  'Content-Type': contentType,
1594
3021
  'Content-Length': String(size),
1595
- },
1596
- body,
1597
- duplex: 'half',
1598
- });
1599
- } catch (error) {
1600
- throw Object.assign(new Error(`Upload failed: ${error.message}`), { exitCode: EXIT.NETWORK });
1601
- } finally {
1602
- progress.finish();
3022
+ };
3023
+ const requiredContentType = headers['Content-Type'] ?? headers['content-type'];
3024
+ const requiredContentLength = headers['Content-Length'] ?? headers['content-length'];
3025
+ if (requiredContentType !== contentType || requiredContentLength !== String(size)) {
3026
+ throw Object.assign(
3027
+ new Error('Upload signing response did not preserve the requested Content-Type and Content-Length headers.'),
3028
+ { exitCode: EXIT.SERVER },
3029
+ );
1603
3030
  }
1604
- if (!response.ok) {
1605
- const text = await response.text().catch(() => '');
1606
- throw Object.assign(new Error(`Upload failed: ${response.status} ${redact(text || response.statusText)}`), { exitCode: EXIT.SERVER });
3031
+
3032
+ for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
3033
+ const progress = createUploadProgress(options, size);
3034
+ const controller = new AbortController();
3035
+ let source;
3036
+ let tracker;
3037
+ const watchdog = createUploadActivityWatchdog(controller, () => {
3038
+ source?.destroy();
3039
+ tracker?.destroy();
3040
+ });
3041
+ try {
3042
+ tracker = new Transform({
3043
+ transform(chunk, encoding, callback) {
3044
+ watchdog.activity();
3045
+ progress.track(chunk);
3046
+ callback(null, chunk);
3047
+ },
3048
+ });
3049
+ source = createReadStream(filePath);
3050
+ const response = await fetch(uploadUrl, {
3051
+ method: 'PUT',
3052
+ headers,
3053
+ body: source.pipe(tracker),
3054
+ duplex: 'half',
3055
+ signal: controller.signal,
3056
+ });
3057
+ if (!response.ok) {
3058
+ const text = await response.text().catch(() => '');
3059
+ throw Object.assign(
3060
+ new Error(`Upload failed: ${response.status} ${redact(text || response.statusText)}`),
3061
+ { exitCode: EXIT.SERVER, status: response.status },
3062
+ );
3063
+ }
3064
+ return;
3065
+ } catch (error) {
3066
+ const reason = controller.signal.aborted && controller.signal.reason instanceof Error
3067
+ ? controller.signal.reason
3068
+ : error;
3069
+ const retryable = reason?.status === undefined
3070
+ || reason.status === 408
3071
+ || reason.status === 429
3072
+ || reason.status >= 500;
3073
+ if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1 || !retryable) {
3074
+ throw Object.assign(new Error(`Upload failed: ${reason.message}`), {
3075
+ exitCode: reason.exitCode || EXIT.NETWORK,
3076
+ status: reason.status,
3077
+ });
3078
+ }
3079
+ await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
3080
+ } finally {
3081
+ watchdog.stop();
3082
+ source?.destroy();
3083
+ tracker?.destroy();
3084
+ progress.finish();
3085
+ }
1607
3086
  }
1608
3087
  }
1609
3088
 
@@ -1620,27 +3099,35 @@ async function uploadAsset(config, options, filePath) {
1620
3099
  contentType,
1621
3100
  size: stat.size,
1622
3101
  kind: options.kind,
3102
+ idempotencyKey: `cli-library:${randomUUID()}`,
1623
3103
  });
3104
+ requireConfirm(options, 'Uploading an asset');
1624
3105
  const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
1625
- if (!signed?.uploadUrl || !signed?.key || !signed?.assetId) {
3106
+ const uploadUrl = signed?.uploadUrl || signed?.url;
3107
+ if (!uploadUrl || !signed?.intentId || !signed?.assetId) {
1626
3108
  throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
1627
3109
  }
1628
- await putSignedUpload(signed.uploadUrl, resolved, contentType, stat.size, options);
1629
- const objectPath = objectPathFromKey(signed.key);
3110
+ await putSignedUpload(uploadUrl, resolved, contentType, stat.size, options);
1630
3111
  output(await apiFetch(config, options, 'POST', `/api/v1/assets/${encodeURIComponent(signed.assetId)}/finalize`, {
1631
- objectPath,
1632
- fileSize: stat.size,
1633
- duration: options.duration === undefined ? null : numberOption(options.duration, '--duration'),
3112
+ uploadIntentId: signed.intentId,
1634
3113
  }), options);
1635
3114
  }
1636
3115
 
1637
3116
  async function assets(config, options, action, args) {
1638
3117
  if (action === 'list') {
1639
- output(await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
3118
+ const response = await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
1640
3119
  type: options.type,
1641
3120
  limit: options.limit,
1642
3121
  offset: options.offset,
1643
- })}`), options);
3122
+ })}`, undefined, { includeResponseMetadata: true });
3123
+ const items = Array.isArray(response.data) ? response.data : [];
3124
+ const headerTotal = Number(response.headers['x-total-count']);
3125
+ output({
3126
+ items,
3127
+ total: Number.isFinite(headerTotal) ? headerTotal : items.length,
3128
+ limit: numberOption(options.limit, '--limit') ?? items.length,
3129
+ offset: numberOption(options.offset, '--offset') ?? 0,
3130
+ }, options);
1644
3131
  return;
1645
3132
  }
1646
3133
  if (action === 'upload') {
@@ -1747,13 +3234,83 @@ function socialPlatformList(value) {
1747
3234
  return platforms.map((platform) => platform.toLowerCase() === 'x' ? 'twitter' : platform);
1748
3235
  }
1749
3236
 
1750
- function socialPostBody(options, scheduled) {
3237
+ function socialAccountIdPins(value) {
3238
+ if (!value) return {};
3239
+ const raw = String(value).trim();
3240
+ if (!raw) return {};
3241
+ if (raw.startsWith('{')) {
3242
+ const parsed = JSON.parse(raw);
3243
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
3244
+ throw Object.assign(new Error('--account-ids JSON must be an object of platform to account id.'), { exitCode: EXIT.USAGE });
3245
+ }
3246
+ return Object.fromEntries(Object.entries(parsed).map(([platform, accountId]) => [
3247
+ platform.toLowerCase() === 'x' ? 'twitter' : platform.toLowerCase(),
3248
+ requiredString(accountId, `account id for ${platform}`),
3249
+ ]));
3250
+ }
3251
+ return Object.fromEntries(raw.split(',').filter(Boolean).map((entry) => {
3252
+ const separator = entry.indexOf('=');
3253
+ if (separator <= 0 || separator === entry.length - 1) {
3254
+ throw Object.assign(new Error('--account-ids must use platform=accountId pairs.'), { exitCode: EXIT.USAGE });
3255
+ }
3256
+ const platform = entry.slice(0, separator).trim().toLowerCase();
3257
+ const accountId = entry.slice(separator + 1).trim();
3258
+ return [platform === 'x' ? 'twitter' : platform, accountId];
3259
+ }));
3260
+ }
3261
+
3262
+ async function socialPostBody(config, options, scheduled) {
3263
+ const clipId = requiredString(options['clip-id'], '--clip-id');
3264
+ const platforms = socialPlatformList(options.platforms);
3265
+ const caption = requiredString(options.caption, '--caption');
3266
+ const requestedExportId = options['export-id'];
3267
+ const deliveryState = await fetchCanonicalClipDeliveryState(
3268
+ config,
3269
+ options,
3270
+ clipId,
3271
+ requestedExportId,
3272
+ );
3273
+ const editorState = requireVerifiedEditorState(deliveryState, clipId);
3274
+ const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
3275
+ requestedExportId,
3276
+ requireReadyToPublish: true,
3277
+ });
3278
+ const accountsResponse = await apiFetch(config, options, 'GET', '/api/v1/social/accounts');
3279
+ const connectedAccounts = Array.isArray(accountsResponse?.accounts)
3280
+ ? accountsResponse.accounts.filter((account) => account?.connected && typeof account?.accountId === 'string')
3281
+ : [];
3282
+ const requestedAccountIds = socialAccountIdPins(options['account-ids']);
3283
+ const expectedAccountIds = Object.fromEntries(platforms.map((platform) => {
3284
+ const choices = Array.from(new Set(connectedAccounts
3285
+ .filter((account) => account.platform === platform)
3286
+ .map((account) => account.accountId)));
3287
+ const requestedAccountId = requestedAccountIds[platform];
3288
+ if (requestedAccountId) {
3289
+ if (!choices.includes(requestedAccountId)) {
3290
+ throw Object.assign(new Error(`The selected ${platform} account id is not connected.`), { exitCode: EXIT.USAGE });
3291
+ }
3292
+ return [platform, requestedAccountId];
3293
+ }
3294
+ if (choices.length !== 1) {
3295
+ const reason = choices.length === 0 ? 'no connected account' : 'multiple connected accounts';
3296
+ throw Object.assign(
3297
+ new Error(`${platform} has ${reason}. Run "clipit social accounts --json" and pass --account-ids ${platform}=<accountId>.`),
3298
+ { exitCode: EXIT.USAGE },
3299
+ );
3300
+ }
3301
+ return [platform, choices[0]];
3302
+ }));
1751
3303
  const body = compactObject({
1752
- clipId: requiredString(options['clip-id'], '--clip-id'),
1753
- platforms: socialPlatformList(options.platforms),
1754
- caption: requiredString(options.caption, '--caption'),
3304
+ clipId,
3305
+ platforms,
3306
+ caption,
1755
3307
  title: options.title,
1756
3308
  hashtags: stringList(options.hashtags),
3309
+ exportId: selectedExport.exportId,
3310
+ expectedSnapshotId: editorState.snapshotId,
3311
+ expectedOutputObjectFingerprint: selectedExport.outputObjectFingerprint,
3312
+ expectedAccountIds,
3313
+ publishExactCurrentArtifact: true,
1757
3314
  });
1758
3315
  if (scheduled) body.scheduledFor = requiredString(options.at, '--at');
1759
3316
  return body;
@@ -1765,14 +3322,14 @@ async function social(config, options, action, args) {
1765
3322
  return;
1766
3323
  }
1767
3324
  if (action === 'post') {
1768
- const body = socialPostBody(options, false);
3325
+ const body = await socialPostBody(config, options, false);
1769
3326
  await enforceMaxCredits(config, options, 'social post', { body });
1770
3327
  confirmPaid(options, 'Publishing a social post');
1771
3328
  output(await apiFetch(config, options, 'POST', '/api/v1/social/post', body), options);
1772
3329
  return;
1773
3330
  }
1774
3331
  if (action === 'schedule') {
1775
- const body = socialPostBody(options, true);
3332
+ const body = await socialPostBody(config, options, true);
1776
3333
  await enforceMaxCredits(config, options, 'social schedule', { body });
1777
3334
  confirmPaid(options, 'Scheduling a social post');
1778
3335
  output(await apiFetch(config, options, 'POST', '/api/v1/social/schedule', body), options);
@@ -1809,19 +3366,41 @@ async function jobs(config, options, action, args) {
1809
3366
  const jobId = args[0];
1810
3367
  if (!jobId) throw Object.assign(new Error('Job id is required.'), { exitCode: EXIT.USAGE });
1811
3368
  const startedAt = Date.now();
1812
- const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
1813
- const intervalMs = numberOption(options.interval, '--interval') || 3000;
3369
+ const requestedTimeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
3370
+ const requestedIntervalMs = numberOption(options.interval, '--interval');
1814
3371
  while (true) {
1815
3372
  const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
1816
3373
  if (action === 'get' || TERMINAL_JOB_STATUSES.has(job.status)) {
3374
+ const videoId = job.videoId || job.result?.videoId;
3375
+ const clipId = job.clipId || job.result?.clipId;
3376
+ if (videoId || clipId) {
3377
+ await persistActiveContext(
3378
+ config,
3379
+ options,
3380
+ compactObject({ videoId, clipId }),
3381
+ [
3382
+ videoId ? { type: 'video', id: videoId } : null,
3383
+ clipId ? { type: 'clip', id: clipId } : null,
3384
+ ].filter(Boolean),
3385
+ );
3386
+ }
1817
3387
  output(job, options);
1818
3388
  return;
1819
3389
  }
1820
- if (options.stream) console.log(JSON.stringify({ type: 'job.progress', job }));
3390
+ if (options.stream) console.log(JSON.stringify({ type: 'job.progress', job: redactDeep(job) }));
3391
+ const serverMinimumWaitMs = typeof job.minimumWaitSeconds === 'number'
3392
+ ? Math.max(0, job.minimumWaitSeconds * 1000)
3393
+ : 0;
3394
+ const timeoutMs = requestedTimeoutMs
3395
+ ? Math.max(requestedTimeoutMs, serverMinimumWaitMs)
3396
+ : undefined;
1821
3397
  if (timeoutMs && Date.now() - startedAt > timeoutMs) {
1822
3398
  throw Object.assign(new Error(`Timed out waiting for job ${jobId}.`), { exitCode: EXIT.SERVER });
1823
3399
  }
1824
- await sleep(intervalMs);
3400
+ const serverIntervalMs = typeof job.recommendedPollIntervalSeconds === 'number'
3401
+ ? Math.max(1000, job.recommendedPollIntervalSeconds * 1000)
3402
+ : 0;
3403
+ await sleep(requestedIntervalMs || serverIntervalMs || 3000);
1825
3404
  }
1826
3405
  }
1827
3406
 
@@ -1865,9 +3444,8 @@ async function appUrl(config, options, kind, id) {
1865
3444
  params.set('clip', id);
1866
3445
  return `${baseUrl}/clips/review?${params.toString()}`;
1867
3446
  }
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)}` : ''}`;
3447
+ // A video-level agent result should land in the Agent Content library tab.
3448
+ if (kind === 'video') return `${baseUrl}/clips${id ? `?tab=agent&video=${encodeURIComponent(id)}` : '?tab=agent'}`;
1871
3449
  // The editor lives at /editor and reads ?project= (NOT /editor/projects, which
1872
3450
  // redirects to /clips and drops the query — the same dead-end class as the
1873
3451
  // clip/video link bug). There is no surface that consumes a raw sequenceId, so
@@ -1904,27 +3482,501 @@ async function links(config, options, kind, id) {
1904
3482
  output(result, options);
1905
3483
  }
1906
3484
 
3485
+ function mcpToolInputSchema(tool) {
3486
+ const schema = tool?.inputSchema || tool?.parametersSchema || tool?.parameterSchema || tool?.parameters;
3487
+ if (schema && typeof schema === 'object' && !Array.isArray(schema)) return schema;
3488
+ return { type: 'object', additionalProperties: true };
3489
+ }
3490
+
3491
+ function mcpToolConfirmationDescription(tool) {
3492
+ if (!mcpToolRequiresConfirmation(tool, tool?.name)) return null;
3493
+ const parts = [
3494
+ tool?.confirmation?.riskLevel ? `risk=${tool.confirmation.riskLevel}` : null,
3495
+ tool?.confirmation?.reason || null,
3496
+ ].filter(Boolean);
3497
+ if (parts.length) return parts.join('; ');
3498
+
3499
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
3500
+ if (costBand && costBand !== 'free' && costBand !== 'none') {
3501
+ return `cost=${costBand}; may spend credits or mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.`;
3502
+ }
3503
+ return 'may mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.';
3504
+ }
3505
+
3506
+ function mcpToolDescription(tool) {
3507
+ const confirmationDescription = mcpToolConfirmationDescription(tool);
3508
+ return [
3509
+ tool?.description,
3510
+ tool?.skill ? `Skill: ${tool.skill}` : null,
3511
+ tool?.costBand ? `Cost: ${tool.costBand}` : null,
3512
+ confirmationDescription ? `Confirmation required: ${confirmationDescription}` : null,
3513
+ ].filter(Boolean).join('\n');
3514
+ }
3515
+
3516
+ function mcpToolRequiresConfirmation(tool, name) {
3517
+ if (RUN_METERED_CONFIRMATION_EXEMPTIONS.has(name)) return false;
3518
+ if (RUN_CONFIRMATION_LABELS[name]) return true;
3519
+ if (tool?.confirmation?.required) return true;
3520
+ if (tool?.requiresConfirmation === true) return true;
3521
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
3522
+ return Boolean(costBand && costBand !== 'free' && costBand !== 'none');
3523
+ }
3524
+
3525
+ function readMcpMaxCredits(parameters) {
3526
+ if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters)) return null;
3527
+ let raw;
3528
+ for (const key of ['maxCredits', 'max_credits', 'maxCreditsClip']) {
3529
+ if (raw === undefined && parameters[key] !== undefined) raw = parameters[key];
3530
+ delete parameters[key];
3531
+ }
3532
+ if (raw === undefined) return null;
3533
+ const limit = Number(raw);
3534
+ if (!Number.isFinite(limit) || limit < 0) {
3535
+ throw new Error('MCP tools/call maxCredits must be 0 or greater.');
3536
+ }
3537
+ return limit;
3538
+ }
3539
+
3540
+ async function mcpEstimatePayload(config, options, tool, name, parameters, payload) {
3541
+ const runEstimate = tool?.estimate ?? tool?.confirmation?.estimate ?? tool?.confirmation?.costEstimate ?? null;
3542
+ if (runEstimate) {
3543
+ const estimatedCostClip = estimatedClipCostFromData(runEstimate);
3544
+ return compactObject({
3545
+ estimate: compactObject({
3546
+ estimatedCostClip,
3547
+ estimatedCostLabel: estimatedCostClip === null ? undefined : clipCostLabel(estimatedCostClip),
3548
+ affordable: runEstimate.affordable,
3549
+ estimates: [runEstimate],
3550
+ }),
3551
+ });
3552
+ }
3553
+
3554
+ try {
3555
+ const estimates = await buildStaticRunEstimates(config, options, name, parameters, payload);
3556
+ if (estimates?.length) {
3557
+ const estimatedCostClip = estimates.reduce((sum, estimate) => sum + Number(estimate.estimatedCostClip ?? 0), 0);
3558
+ return {
3559
+ estimate: {
3560
+ estimatedCostClip,
3561
+ estimatedCostLabel: clipCostLabel(estimatedCostClip),
3562
+ affordable: estimates.every((estimate) => estimate.affordable !== false),
3563
+ estimates,
3564
+ },
3565
+ };
3566
+ }
3567
+ } catch (error) {
3568
+ return { estimateUnavailable: redact(error.message || String(error)) };
3569
+ }
3570
+
3571
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
3572
+ if (costBand && costBand !== 'free' && costBand !== 'none') {
3573
+ return { estimateUnavailable: 'No local cost estimate is available for this tool; use estimateOperationCost before approval when possible.' };
3574
+ }
3575
+ return {};
3576
+ }
3577
+
3578
+ async function mcpMaxCreditsResult(config, options, tool, name, parameters, payload, maxCredits) {
3579
+ if (maxCredits === null) return null;
3580
+ const estimatePayload = await mcpEstimatePayload(config, options, tool, name, parameters, payload);
3581
+ if (!estimatePayload.estimate) {
3582
+ const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
3583
+ if (!mcpToolRequiresConfirmation(tool, name) && (!costBand || costBand === 'free' || costBand === 'none')) {
3584
+ return null;
3585
+ }
3586
+ return {
3587
+ error: 'max_credits_unenforceable',
3588
+ functionName: name,
3589
+ maxCredits,
3590
+ maxCreditsLabel: clipCostLabel(maxCredits),
3591
+ estimateUnavailable: estimatePayload.estimateUnavailable || 'No local cost estimate is available for this tool.',
3592
+ };
3593
+ }
3594
+ const estimatedCostClip = Number(estimatePayload.estimate.estimatedCostClip);
3595
+ if (!Number.isFinite(estimatedCostClip)) {
3596
+ return {
3597
+ error: 'max_credits_unenforceable',
3598
+ functionName: name,
3599
+ maxCredits,
3600
+ maxCreditsLabel: clipCostLabel(maxCredits),
3601
+ estimate: estimatePayload.estimate,
3602
+ estimateUnavailable: 'The available estimate did not include an estimatedCostClip value.',
3603
+ };
3604
+ }
3605
+ if (estimatedCostClip > maxCredits) {
3606
+ return {
3607
+ error: 'max_credits_exceeded',
3608
+ functionName: name,
3609
+ maxCredits,
3610
+ maxCreditsLabel: clipCostLabel(maxCredits),
3611
+ estimatedCostClip,
3612
+ estimatedCostLabel: clipCostLabel(estimatedCostClip),
3613
+ estimate: estimatePayload.estimate,
3614
+ };
3615
+ }
3616
+ return null;
3617
+ }
3618
+
3619
+ async function mcpConfirmationPayload(config, options, tool, name, parameters, payload, maxCredits = null) {
3620
+ const reason = tool?.confirmation?.reason || `${name} may spend credits or mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.`;
3621
+ const confirmation = compactObject({
3622
+ requiresConfirmation: true,
3623
+ functionName: name,
3624
+ reason,
3625
+ confirmation: tool?.confirmation || undefined,
3626
+ confirmationParams: parameters,
3627
+ retryArguments: {
3628
+ ...(parameters || {}),
3629
+ confirmed: true,
3630
+ maxCredits: maxCredits ?? undefined,
3631
+ },
3632
+ });
3633
+
3634
+ Object.assign(confirmation, await mcpEstimatePayload(config, options, tool, name, parameters, payload));
3635
+
3636
+ return confirmation;
3637
+ }
3638
+
3639
+ function mcpTextResult(value, isError = false) {
3640
+ const text = typeof value === 'string' ? value : JSON.stringify(redactDeep(value), null, 2);
3641
+ return compactObject({
3642
+ content: [{ type: 'text', text }],
3643
+ isError: isError || undefined,
3644
+ });
3645
+ }
3646
+
3647
+ async function fetchMcpTools(config, options) {
3648
+ const response = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
3649
+ return mergeMcpTools(Array.isArray(response?.tools) ? response.tools : []);
3650
+ }
3651
+
3652
+ function mergeMcpTools(serverTools) {
3653
+ const seen = new Set();
3654
+ const merged = [];
3655
+ for (const tool of [...LOCAL_MCP_BILLING_TOOLS, ...serverTools]) {
3656
+ if (typeof tool?.name !== 'string' || seen.has(tool.name)) continue;
3657
+ seen.add(tool.name);
3658
+ merged.push(tool);
3659
+ }
3660
+ return merged;
3661
+ }
3662
+
3663
+ function localMcpBillingTool(name) {
3664
+ return LOCAL_MCP_BILLING_TOOLS.find((tool) => tool.name === name) || null;
3665
+ }
3666
+
3667
+ async function handleLocalMcpBillingTool(config, options, name, parameters = {}) {
3668
+ if (name === 'getPaymentCapabilities') {
3669
+ return apiFetch(config, options, 'GET', '/api/v1/agent/payment-capabilities', undefined, { noAuth: true });
3670
+ }
3671
+ if (name === 'getBillingCatalog') {
3672
+ return apiFetch(config, options, 'GET', '/api/v1/billing/catalog', undefined, { noAuth: true });
3673
+ }
3674
+ if (name === 'createPaymentAttempt') {
3675
+ return apiFetch(config, options, 'POST', '/api/v1/billing/agent-payments', buildBillingAttemptBody(parameters));
3676
+ }
3677
+ if (name === 'getPaymentAttempt') {
3678
+ const attemptId = requiredString(parameters.attemptId ?? parameters.attempt_id, 'attemptId');
3679
+ return apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}`);
3680
+ }
3681
+ if (name === 'getPaymentReceipt') {
3682
+ const attemptId = requiredString(parameters.attemptId ?? parameters.attempt_id, 'attemptId');
3683
+ return apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}/receipt`);
3684
+ }
3685
+ if (name === 'getBillingSubscription') {
3686
+ return apiFetch(config, options, 'GET', '/api/v1/billing/subscription');
3687
+ }
3688
+ throw Object.assign(new Error(`Unknown local billing tool: ${name}`), { exitCode: EXIT.USAGE });
3689
+ }
3690
+
3691
+ async function handleMcpRequest(config, options, message) {
3692
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
3693
+ return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid JSON-RPC request.' } };
3694
+ }
3695
+
3696
+ const id = message.id ?? null;
3697
+ const method = message.method;
3698
+ const isNotification = message.id === undefined;
3699
+
3700
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
3701
+ if (method === 'initialize') {
3702
+ return {
3703
+ jsonrpc: '2.0',
3704
+ id,
3705
+ result: {
3706
+ protocolVersion: message.params?.protocolVersion || '2024-11-05',
3707
+ capabilities: { tools: {} },
3708
+ serverInfo: { name: 'clipit', version: VERSION },
3709
+ },
3710
+ };
3711
+ }
3712
+ if (method === 'ping') return isNotification ? null : { jsonrpc: '2.0', id, result: {} };
3713
+ if (isNotification) return null;
3714
+
3715
+ if (method === 'tools/list') {
3716
+ const tools = await fetchMcpTools(config, options);
3717
+ return {
3718
+ jsonrpc: '2.0',
3719
+ id,
3720
+ result: {
3721
+ tools: tools.map((tool) => ({
3722
+ name: tool.name,
3723
+ description: mcpToolDescription(tool),
3724
+ inputSchema: mcpToolInputSchema(tool),
3725
+ })).filter((tool) => typeof tool.name === 'string' && tool.name.length > 0),
3726
+ },
3727
+ };
3728
+ }
3729
+
3730
+ if (method === 'tools/call') {
3731
+ const name = typeof message.params?.name === 'string' ? message.params.name : null;
3732
+ if (!name) {
3733
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: 'tools/call requires params.name.' } };
3734
+ }
3735
+ const parameters = message.params?.arguments && typeof message.params.arguments === 'object' && !Array.isArray(message.params.arguments)
3736
+ ? { ...message.params.arguments }
3737
+ : {};
3738
+ const confirmed = parameters.confirmed === true || parameters.confirm === true;
3739
+ delete parameters.confirm;
3740
+ if (confirmed) parameters.confirmed = true;
3741
+ else delete parameters.confirmed;
3742
+ let maxCredits = null;
3743
+ try {
3744
+ maxCredits = readMcpMaxCredits(parameters);
3745
+ } catch (error) {
3746
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: error.message } };
3747
+ }
3748
+ const tools = await fetchMcpTools(config, options);
3749
+ const tool = tools.find((candidate) => candidate.name === name);
3750
+ if (!tool) {
3751
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown ClipIt tool: ${name}` } };
3752
+ }
3753
+ const localBillingTool = localMcpBillingTool(name);
3754
+ const payload = {
3755
+ functionName: name,
3756
+ parameters,
3757
+ confirmed,
3758
+ };
3759
+ if (localBillingTool) {
3760
+ if (!confirmed && mcpToolRequiresConfirmation(localBillingTool, name)) {
3761
+ return {
3762
+ jsonrpc: '2.0',
3763
+ id,
3764
+ result: mcpTextResult(await mcpConfirmationPayload(config, options, localBillingTool, name, parameters, payload, maxCredits)),
3765
+ };
3766
+ }
3767
+ const result = await handleLocalMcpBillingTool(config, options, name, parameters);
3768
+ return { jsonrpc: '2.0', id, result: mcpTextResult(result, Boolean(result?.error)) };
3769
+ }
3770
+ applyContextToAgentPayload(payload, parameters, await buildContext(config, options));
3771
+ if (!confirmed && mcpToolRequiresConfirmation(tool, name)) {
3772
+ return {
3773
+ jsonrpc: '2.0',
3774
+ id,
3775
+ result: mcpTextResult(await mcpConfirmationPayload(config, options, tool, name, parameters, payload, maxCredits)),
3776
+ };
3777
+ }
3778
+ const maxCreditsResult = await mcpMaxCreditsResult(config, options, tool, name, parameters, payload, maxCredits);
3779
+ if (maxCreditsResult) {
3780
+ return {
3781
+ jsonrpc: '2.0',
3782
+ id,
3783
+ result: mcpTextResult(maxCreditsResult, true),
3784
+ };
3785
+ }
3786
+ const result = normalizeAgentExecuteResult(await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload));
3787
+ return { jsonrpc: '2.0', id, result: mcpTextResult(result, Boolean(result?.error)) };
3788
+ }
3789
+
3790
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unsupported MCP method: ${method || ''}` } };
3791
+ }
3792
+
3793
+ function mcpFrame(message) {
3794
+ const body = JSON.stringify(redactDeep(message));
3795
+ return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
3796
+ }
3797
+
3798
+ function mcpHeaderEnd(buffer) {
3799
+ const crlf = buffer.indexOf('\r\n\r\n');
3800
+ const lf = buffer.indexOf('\n\n');
3801
+ if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
3802
+ if (lf === -1) return { index: crlf, length: 4 };
3803
+ return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
3804
+ }
3805
+
3806
+ function mcpLooksFramed(buffer) {
3807
+ const prefix = 'Content-Length:';
3808
+ const sample = buffer.toString('utf8', 0, Math.min(buffer.length, prefix.length));
3809
+ if (prefix.startsWith(sample)) return buffer.length < prefix.length ? null : true;
3810
+ return false;
3811
+ }
3812
+
3813
+ function readMcpFrame(buffer) {
3814
+ const headerEnd = mcpHeaderEnd(buffer);
3815
+ if (!headerEnd) return { incomplete: true, buffer };
3816
+ const header = buffer.subarray(0, headerEnd.index).toString('utf8');
3817
+ const match = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
3818
+ if (!match) {
3819
+ return {
3820
+ message: null,
3821
+ error: { code: -32600, message: 'Invalid MCP frame: missing Content-Length header.' },
3822
+ buffer: Buffer.alloc(0),
3823
+ };
3824
+ }
3825
+ const contentLength = Number(match[1]);
3826
+ const bodyStart = headerEnd.index + headerEnd.length;
3827
+ const bodyEnd = bodyStart + contentLength;
3828
+ if (buffer.length < bodyEnd) return { incomplete: true, buffer };
3829
+ const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');
3830
+ try {
3831
+ return {
3832
+ message: JSON.parse(body),
3833
+ buffer: buffer.subarray(bodyEnd),
3834
+ };
3835
+ } catch (error) {
3836
+ return {
3837
+ message: null,
3838
+ error: { code: -32700, message: `Parse error: ${error.message}` },
3839
+ buffer: buffer.subarray(bodyEnd),
3840
+ };
3841
+ }
3842
+ }
3843
+
3844
+ async function respondToMcpMessage(config, options, message, send) {
3845
+ try {
3846
+ send(await handleMcpRequest(config, options, message));
3847
+ } catch (error) {
3848
+ send({
3849
+ jsonrpc: '2.0',
3850
+ id: message?.id ?? null,
3851
+ error: {
3852
+ code: -32000,
3853
+ message: redact(error.message || String(error)),
3854
+ data: redactDeep(compactObject({ status: error.status, requestId: error.requestId, details: error.data })),
3855
+ },
3856
+ });
3857
+ }
3858
+ }
3859
+
3860
+ async function runMcpStdio(config, options) {
3861
+ let framed = null;
3862
+ let frameBuffer = Buffer.alloc(0);
3863
+ let lineBuffer = '';
3864
+ const send = (message) => {
3865
+ if (!message) return;
3866
+ if (framed) {
3867
+ process.stdout.write(mcpFrame(message));
3868
+ return;
3869
+ }
3870
+ process.stdout.write(`${JSON.stringify(redactDeep(message))}\n`);
3871
+ };
3872
+
3873
+ const sendTransportError = (error) => send({ jsonrpc: '2.0', id: null, error });
3874
+ const processLineBuffer = async (flush = false) => {
3875
+ let newlineIndex = lineBuffer.indexOf('\n');
3876
+ while (newlineIndex !== -1) {
3877
+ const line = lineBuffer.slice(0, newlineIndex).trim();
3878
+ lineBuffer = lineBuffer.slice(newlineIndex + 1);
3879
+ newlineIndex = lineBuffer.indexOf('\n');
3880
+ if (!line) continue;
3881
+ let message;
3882
+ try {
3883
+ message = JSON.parse(line);
3884
+ } catch (error) {
3885
+ sendTransportError({ code: -32700, message: `Parse error: ${error.message}` });
3886
+ continue;
3887
+ }
3888
+ await respondToMcpMessage(config, options, message, send);
3889
+ }
3890
+ if (flush && lineBuffer.trim()) {
3891
+ const line = lineBuffer.trim();
3892
+ lineBuffer = '';
3893
+ let message;
3894
+ try {
3895
+ message = JSON.parse(line);
3896
+ } catch (error) {
3897
+ sendTransportError({ code: -32700, message: `Parse error: ${error.message}` });
3898
+ return;
3899
+ }
3900
+ await respondToMcpMessage(config, options, message, send);
3901
+ }
3902
+ };
3903
+
3904
+ for await (const chunk of process.stdin) {
3905
+ const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3906
+ let appendedToFrame = false;
3907
+ if (framed === null) {
3908
+ frameBuffer = Buffer.concat([frameBuffer, chunkBuffer]);
3909
+ appendedToFrame = true;
3910
+ const decision = mcpLooksFramed(frameBuffer);
3911
+ if (decision === null) continue;
3912
+ framed = decision;
3913
+ if (!framed) {
3914
+ lineBuffer += frameBuffer.toString('utf8');
3915
+ frameBuffer = Buffer.alloc(0);
3916
+ await processLineBuffer();
3917
+ continue;
3918
+ }
3919
+ }
3920
+
3921
+ if (!framed) {
3922
+ lineBuffer += chunkBuffer.toString('utf8');
3923
+ await processLineBuffer();
3924
+ continue;
3925
+ }
3926
+
3927
+ if (framed && !appendedToFrame) {
3928
+ frameBuffer = Buffer.concat([frameBuffer, chunkBuffer]);
3929
+ }
3930
+ while (framed) {
3931
+ const parsed = readMcpFrame(frameBuffer);
3932
+ frameBuffer = parsed.buffer;
3933
+ if (parsed.incomplete) break;
3934
+ if (parsed.error) {
3935
+ sendTransportError(parsed.error);
3936
+ continue;
3937
+ }
3938
+ await respondToMcpMessage(config, options, parsed.message, send);
3939
+ }
3940
+ }
3941
+
3942
+ if (framed === false) await processLineBuffer(true);
3943
+ }
3944
+
3945
+ async function mcp(config, options, action) {
3946
+ if (action && action !== 'stdio') {
3947
+ throw Object.assign(new Error(`Unknown mcp command: ${action}`), { exitCode: EXIT.USAGE });
3948
+ }
3949
+ await runMcpStdio(config, options);
3950
+ }
3951
+
1907
3952
  async function examples(options) {
1908
3953
  const data = {
1909
3954
  login: 'clipit login',
1910
3955
  installCodexSkill: 'clipit agent install codex',
1911
3956
  validate: 'clipit doctor --json',
1912
- importUrl: 'clipit videos import-url "https://www.youtube.com/watch?v=..." --json',
3957
+ uploadVideo: 'clipit videos upload ./source.mp4 --confirm --json',
3958
+ importUrl: 'clipit videos import-url "https://www.youtube.com/watch?v=..." --confirm --json',
1913
3959
  waitForJob: 'clipit jobs wait <jobId> --json',
1914
3960
  ask: 'clipit ask "Find the strongest clip in this video" --video-id <videoId>',
1915
3961
  approveWorkflow: 'clipit workflow approve <jobId> --approval-id <approvalId> --decision approved',
1916
3962
  waitForWorkflow: 'clipit workflow wait <jobId> --stream',
1917
3963
  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',
3964
+ mcp: 'clipit mcp stdio',
3965
+ suggestClips: 'clipit videos suggest-clips <videoId> --count 5 --confirm --json',
3966
+ createClip: 'clipit clips create --video-id <videoId> --start 12 --end 42 --title "Strong hook" --confirm --json',
3967
+ renderClip: 'clipit clips render <clipId> --aspect 9:16 --quality high --confirm --json',
1921
3968
  creditsBalance: 'clipit credits balance --json',
3969
+ billingCapabilities: 'clipit billing capabilities --json',
3970
+ billingCatalog: 'clipit billing catalog --json',
3971
+ billingX402Attempt: 'clipit billing create-attempt --product-key boost --provider x402_direct --confirm --json',
3972
+ billingStripeX402Attempt: 'clipit billing create-attempt --product-key boost --provider stripe_x402 --confirm --json',
3973
+ billingLinkAttempt: 'clipit billing create-attempt --product-key boost --provider stripe_mpp --confirm --json',
1922
3974
  analyticsOverview: 'clipit analytics overview --days 30 --json',
1923
3975
  exportClip: 'clipit exports start --clip-id <clipId> --confirm --json',
1924
- uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --json',
3976
+ uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
1925
3977
  thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
1926
3978
  brollPlan: 'clipit broll plan <clipId> --count 3 --confirm --json',
1927
- socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json',
3979
+ socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json',
1928
3980
  runTool: 'clipit run <functionName> --clip-id <clipId> --params @params.json --json',
1929
3981
  reviewLink: 'clipit links clip <clipId> --json',
1930
3982
  };
@@ -1945,13 +3997,19 @@ Rules:
1945
3997
  - If not connected, ask the user to run \`clipit login\` and approve the browser link.
1946
3998
  - Never ask the user to paste API keys into chat.
1947
3999
  - Use \`clipit skills list --json\` and \`clipit tools list --json\` to discover capability.
4000
+ - Use \`clipit mcp stdio\` when an MCP-compatible client can launch local stdio servers; it speaks standard \`Content-Length\` framed JSON-RPC.
1948
4001
  - Prefer friendly commands such as \`clipit videos list --json\`, \`clipit clips list --json\`, \`clipit credits balance --json\`, and \`clipit jobs wait <jobId> --json\`.
1949
4002
  - 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.
4003
+ - 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
4004
  - Use \`clipit run <functionName> --params @file.json --json\` for exact Clippy tools.
1951
4005
  - Treat paid generation, publishing, deletion, broad mutation, and \`requiresConfirmation\` responses as user approval checkpoints.
1952
4006
  - 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.
4007
+ - When credits are insufficient, discover payment rails with \`clipit billing capabilities --json\` and \`clipit billing catalog --json\`.
4008
+ - 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\`.
4009
+ - 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
4010
  - Use \`clipit open clip <id>\` when the user should review work in ClipIt.
1954
4011
  - Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
4012
+ - Run \`clipit social accounts --json\` before posting. If a platform has multiple connected accounts, pin the intended one with \`--account-ids platform=accountId\`; ClipIt will not guess.
1955
4013
  - Do not write API keys into this skill file or any project files.
1956
4014
 
1957
4015
  Useful commands:
@@ -1961,24 +4019,32 @@ clipit auth status --json
1961
4019
  clipit skills list --json
1962
4020
  clipit tools list --json
1963
4021
  clipit tools describe <functionName> --json
4022
+ clipit mcp stdio
1964
4023
  clipit ask "Find the strongest clip in this video" --video-id <videoId> --json
1965
4024
  clipit workflow wait <jobId> --stream
1966
4025
  clipit videos list --json
1967
- clipit videos import-url "https://example.com/video" --json
4026
+ clipit videos upload ./source.mp4 --confirm --json
4027
+ clipit videos import-url "https://example.com/video" --confirm --json
1968
4028
  clipit videos transcript <videoId> --json
1969
- clipit videos suggest-clips <videoId> --count 5 --json
4029
+ clipit videos suggest-clips <videoId> --count 5 --confirm --json
1970
4030
  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
4031
+ clipit clips create --video-id <videoId> --start 12 --end 42 --title "Hook" --confirm --json
4032
+ clipit clips render <clipId> --aspect 9:16 --quality high --confirm --json
1973
4033
  clipit jobs wait <jobId> --stream
1974
4034
  clipit credits balance --json
1975
4035
  clipit credits usage --json
1976
4036
  clipit credits estimate --operation-type transcription --provider deepgram --metrics @metrics.json --json
4037
+ clipit billing capabilities --json
4038
+ clipit billing catalog --json
4039
+ clipit billing create-attempt --product-key boost --provider x402_direct --confirm --json
4040
+ clipit billing create-attempt --product-key boost --provider stripe_x402 --confirm --json
4041
+ clipit billing create-attempt --product-key boost --provider stripe_mpp --confirm --json
4042
+ clipit billing receipt <attemptId> --json
1977
4043
  clipit analytics overview --days 30 --json
1978
4044
  clipit exports start --clip-id <clipId> --confirm --json
1979
4045
  clipit thumbnails generate --clip-id <clipId> --prompt "High contrast thumbnail" --confirm --json
1980
- 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
4046
+ clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json
4047
+ clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
1982
4048
  \`\`\`
1983
4049
 
1984
4050
  Common workflow:
@@ -1992,6 +4058,28 @@ Agent target: ${agent}
1992
4058
  `;
1993
4059
  }
1994
4060
 
4061
+ function mcpSkillAddendum() {
4062
+ return [
4063
+ '## MCP Stdio Bridge',
4064
+ '- If your agent runtime supports MCP stdio servers, prefer launching `clipit mcp stdio` from the user\'s machine instead of manually shelling every command.',
4065
+ '- 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`.',
4066
+ '- `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.',
4067
+ '- The bridge also exposes local billing discovery tools: `getPaymentCapabilities`, `getBillingCatalog`, `createPaymentAttempt`, `getPaymentAttempt`, `getPaymentReceipt`, and `getBillingSubscription`.',
4068
+ '- `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.',
4069
+ '- If the MCP client cannot launch local commands, fall back to the CLI/API commands above.',
4070
+ ].join('\n');
4071
+ }
4072
+
4073
+ function withLocalCliSkillAddenda(markdown) {
4074
+ const addenda = [];
4075
+ let next = String(markdown || '');
4076
+ if (!/clipit\s+mcp\s+stdio/i.test(next) && !/MCP Stdio Bridge/i.test(next)) {
4077
+ next = `${next.trimEnd()}\n\n${mcpSkillAddendum()}\n`;
4078
+ addenda.push('mcp-stdio-bridge');
4079
+ }
4080
+ return { markdown: next, addenda };
4081
+ }
4082
+
1995
4083
  function fallbackSkillResult(target, reason) {
1996
4084
  const generatedAt = new Date().toISOString();
1997
4085
  return {
@@ -2041,10 +4129,14 @@ async function resolveAgentSkill(config, options, target) {
2041
4129
  });
2042
4130
  }
2043
4131
 
4132
+ const augmented = withLocalCliSkillAddenda(response.markdown);
2044
4133
  return {
2045
- markdown: response.markdown,
4134
+ markdown: augmented.markdown,
2046
4135
  source: 'server',
2047
- meta: response.meta,
4136
+ meta: compactObject({
4137
+ ...response.meta,
4138
+ localCliAddenda: augmented.addenda.length ? augmented.addenda : undefined,
4139
+ }),
2048
4140
  };
2049
4141
  }
2050
4142
 
@@ -2065,6 +4157,7 @@ function agentSkillSidecar(target, skill) {
2065
4157
  target,
2066
4158
  source: skill.source,
2067
4159
  generatedAt: skill.meta?.generatedAt,
4160
+ markdownHash: hashText(skill.markdown),
2068
4161
  serverMeta: skill.source === 'server' ? skill.meta : undefined,
2069
4162
  fallbackReason: skill.fallbackReason,
2070
4163
  });
@@ -2088,7 +4181,11 @@ function agentInstallDir(target, options) {
2088
4181
  async function installedAgentStatus(target, options) {
2089
4182
  const baseDir = agentInstallDir(target, options);
2090
4183
  try {
2091
- const stat = await fs.stat(path.join(baseDir, 'SKILL.md'));
4184
+ const skillPath = path.join(baseDir, 'SKILL.md');
4185
+ const [stat, markdown] = await Promise.all([
4186
+ fs.stat(skillPath),
4187
+ fs.readFile(skillPath, 'utf8'),
4188
+ ]);
2092
4189
  const sidecar = await readAgentSkillMeta(baseDir);
2093
4190
  return {
2094
4191
  target,
@@ -2098,6 +4195,7 @@ async function installedAgentStatus(target, options) {
2098
4195
  skillSource: sidecar?.source || 'unknown',
2099
4196
  generatedAt: sidecar?.generatedAt || null,
2100
4197
  fallbackReason: sidecar?.fallbackReason || null,
4198
+ markdownHash: sidecar?.markdownHash || hashText(markdown),
2101
4199
  serverMeta: sidecar?.serverMeta || null,
2102
4200
  };
2103
4201
  } catch {
@@ -2105,6 +4203,24 @@ async function installedAgentStatus(target, options) {
2105
4203
  }
2106
4204
  }
2107
4205
 
4206
+ function agentFreshnessReason(status, currentSkill) {
4207
+ const currentMeta = currentSkill.source === 'server' ? currentSkill.meta : null;
4208
+ if (status.skillSource !== currentSkill.source) {
4209
+ return `installed source ${status.skillSource || 'unknown'} differs from current source ${currentSkill.source}`;
4210
+ }
4211
+ if (status.serverMeta?.toolCount !== undefined
4212
+ && currentMeta?.toolCount !== undefined
4213
+ && status.serverMeta.toolCount !== currentMeta.toolCount) {
4214
+ return `installed tool count ${status.serverMeta.toolCount} differs from current tool count ${currentMeta.toolCount}`;
4215
+ }
4216
+ if (status.serverMeta?.skillCount !== undefined
4217
+ && currentMeta?.skillCount !== undefined
4218
+ && status.serverMeta.skillCount !== currentMeta.skillCount) {
4219
+ return `installed skill count ${status.serverMeta.skillCount} differs from current skill count ${currentMeta.skillCount}`;
4220
+ }
4221
+ return 'installed skill content differs from current generated instructions';
4222
+ }
4223
+
2108
4224
  async function agent(config, options, action, args) {
2109
4225
  const target = args[0] || 'generic';
2110
4226
  const baseDir = agentInstallDir(target, options);
@@ -2121,12 +4237,41 @@ async function agent(config, options, action, args) {
2121
4237
  }
2122
4238
  if (action === 'doctor') {
2123
4239
  const status = await installedAgentStatus(target, options);
4240
+ const hasCredential = Boolean(getApiKey(config, options));
2124
4241
  status.cli = {
2125
4242
  version: VERSION,
2126
4243
  configPath: configPath(),
2127
4244
  profile: profileName(config, options),
2128
- hasCredential: Boolean(getApiKey(config, options)),
4245
+ hasCredential,
2129
4246
  };
4247
+ if (status.installed) {
4248
+ if (!hasCredential) {
4249
+ status.upToDate = null;
4250
+ status.stale = null;
4251
+ status.freshnessError = 'Cannot verify installed skill freshness without credentials.';
4252
+ } else {
4253
+ const currentSkill = await resolveAgentSkill(config, options, target);
4254
+ status.latest = compactObject({
4255
+ skillSource: currentSkill.source,
4256
+ generatedAt: currentSkill.meta?.generatedAt || null,
4257
+ serverMeta: currentSkill.source === 'server' ? currentSkill.meta : undefined,
4258
+ fallbackReason: currentSkill.fallbackReason,
4259
+ });
4260
+ if (currentSkill.source === 'fallback') {
4261
+ status.upToDate = null;
4262
+ status.stale = null;
4263
+ status.freshnessError = `Cannot verify installed skill freshness: ${currentSkill.fallbackReason || 'current instructions unavailable'}`;
4264
+ } else {
4265
+ const currentHash = hashText(currentSkill.markdown);
4266
+ status.upToDate = status.markdownHash === currentHash;
4267
+ status.stale = !status.upToDate;
4268
+ if (status.stale) {
4269
+ status.staleReason = agentFreshnessReason(status, currentSkill);
4270
+ status.updateCommand = `clipit agent update ${target}`;
4271
+ }
4272
+ }
4273
+ }
4274
+ }
2130
4275
  output(status, options);
2131
4276
  return;
2132
4277
  }
@@ -2160,6 +4305,10 @@ async function main() {
2160
4305
  const config = await readConfig();
2161
4306
  const [command, subcommand, ...rest] = positionals;
2162
4307
 
4308
+ if (options.version === true || options.version === 'true') {
4309
+ output({ version: VERSION }, options);
4310
+ return;
4311
+ }
2163
4312
  if (!command || options.help) {
2164
4313
  console.log(usage());
2165
4314
  return;
@@ -2176,17 +4325,20 @@ async function main() {
2176
4325
  if (command === 'auth' && subcommand === 'set-key') return setKey(config, options);
2177
4326
  if (command === 'auth' && subcommand === 'open-settings') return openCommand(config, options, 'settings');
2178
4327
  if (command === 'auth' && subcommand === 'profiles') return listProfiles(config, options);
4328
+ if (command === 'auth' && subcommand === 'use') return useProfile(config, options, rest[0]);
2179
4329
  if (command === 'context') return contextCommand(config, options, subcommand);
2180
4330
  if (command === 'skills' && subcommand === 'list') return listSkills(config, options);
2181
4331
  if (command === 'tools' && subcommand === 'list') return listTools(config, options);
2182
4332
  if (command === 'tools' && subcommand === 'describe') return describeTool(config, options, rest[0]);
2183
4333
  if (command === 'ask') return askWorkflow(config, options, [subcommand, ...rest]);
2184
4334
  if (command === 'workflow') return workflow(config, options, subcommand, rest);
4335
+ if (command === 'mcp') return mcp(config, options, subcommand);
2185
4336
  if (command === 'run') return runTool(config, options, subcommand);
2186
4337
  if (command === 'videos') return videos(config, options, subcommand, rest);
2187
4338
  if (command === 'clips') return clips(config, options, subcommand, rest);
2188
4339
  if (command === 'jobs') return jobs(config, options, subcommand, rest);
2189
4340
  if (command === 'credits') return credits(config, options, subcommand, rest);
4341
+ if (command === 'billing') return billing(config, options, subcommand, rest);
2190
4342
  if (command === 'analytics') return analytics(config, options, subcommand, rest);
2191
4343
  if (command === 'exports') return exportsCommand(config, options, subcommand, rest);
2192
4344
  if (command === 'assets') return assets(config, options, subcommand, rest);
@@ -2213,6 +4365,9 @@ function handleMainError(error) {
2213
4365
  }), null, 2));
2214
4366
  } else {
2215
4367
  console.error(message);
4368
+ const details = redactDeep(error.data);
4369
+ if (details?.resumeCommand) console.error(`Resume with: ${details.resumeCommand}`);
4370
+ if (details?.abortCommand) console.error(`Abort the prior upload with: ${details.abortCommand}`);
2216
4371
  }
2217
4372
  process.exit(exitCode);
2218
4373
  }
@@ -2235,4 +4390,18 @@ if (await isDirectRun()) {
2235
4390
  main().catch(handleMainError);
2236
4391
  }
2237
4392
 
2238
- export { main, redact };
4393
+ export {
4394
+ buildCanonicalExportStartBody,
4395
+ main,
4396
+ redact,
4397
+ clipCostLabel,
4398
+ requireExactCurrentExport,
4399
+ requireVerifiedEditorState,
4400
+ handleMcpRequest,
4401
+ readPositiveIntegerEnv,
4402
+ readSecretLine,
4403
+ readVideoUploadResumes,
4404
+ removeVideoUploadResume,
4405
+ saveVideoUploadResume,
4406
+ shellQuote,
4407
+ };