@clipit-ai/cli 0.2.4 → 0.2.7

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 +24 -6
  3. package/bin/clipit.mjs +1121 -122
  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.4';
12
+ const VERSION = '0.2.7';
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];
@@ -31,7 +32,24 @@ 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;
33
34
  const BYTES_PER_GB = 1024 * 1024 * 1024;
34
- const DIRECT_VIDEO_UPLOAD_MAX_BYTES = readPositiveIntegerEnv('CLIPIT_CLI_DIRECT_UPLOAD_MAX_BYTES', 100 * 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
+ );
35
53
  const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
36
54
  'exports start': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
37
55
  'thumbnails generate': [{ operationType: 'thumbnail_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
@@ -202,9 +220,15 @@ const KNOWN_AGENT_TARGETS = ['codex', 'claude', 'hermes', 'generic'];
202
220
  const TRUSTED_HOSTS = new Set(['clipit.dev', 'www.clipit.dev', 'localhost', '127.0.0.1', '::1', '[::1]']);
203
221
  const AGENT_SKILL_META_FILENAME = 'SKILL.meta.json';
204
222
 
205
- function readPositiveIntegerEnv(name, fallback) {
223
+ function readPositiveIntegerEnv(name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
206
224
  const parsed = Number(process.env[name]);
207
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
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, "'\"'\"'")}'`;
208
232
  }
209
233
 
210
234
  function configDir() {
@@ -239,6 +263,7 @@ function legacyProfile(config) {
239
263
  baseUrl: config.baseUrl,
240
264
  apiKey: config.apiKey,
241
265
  keyInfo: config.keyInfo,
266
+ scope: config.scope,
242
267
  loginSource: config.loginSource,
243
268
  activeContext: config.activeContext,
244
269
  recent: config.recent,
@@ -252,6 +277,14 @@ function profileData(config, options) {
252
277
  return name === 'default' ? { ...legacyProfile(config), ...fromProfiles } : fromProfiles;
253
278
  }
254
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
+
255
288
  function updateProfile(config, options, updates) {
256
289
  const name = profileName(config, options);
257
290
  const profiles = { ...(config.profiles || {}) };
@@ -421,6 +454,7 @@ function usage() {
421
454
  ' clipit auth status [--json]',
422
455
  ' clipit auth set-key --stdin',
423
456
  ' clipit auth profiles',
457
+ ' clipit auth use <profile>',
424
458
  ' clipit context use [--video-id id] [--clip-id id] [--project-id id] [--sequence-id id]',
425
459
  ' clipit context show|clear|build [--json]',
426
460
  ' clipit skills list [--json]',
@@ -431,8 +465,8 @@ function usage() {
431
465
  ' clipit workflow approve <jobId> --approval-id id [--decision approved|cheaper|cancelled]',
432
466
  ' clipit mcp [stdio]',
433
467
  ' clipit run <functionName> [--params @file.json] [--clip-id id] [--video-id id] [--confirm] [--max-credits n]',
434
- ' clipit videos list|get|upload|import-url|transcribe|transcript|suggest-clips|delete ...',
435
- ' 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|initialize-snapshot|render|download|delete ...',
436
470
  ' clipit jobs get|wait <jobId>',
437
471
  ' clipit credits balance|usage|estimate ...',
438
472
  ' clipit billing capabilities|catalog|create-attempt|attempt|receipt|subscription ...',
@@ -451,12 +485,36 @@ function usage() {
451
485
 
452
486
  function getBaseUrl(config, options) {
453
487
  const profile = profileData(config, options);
454
- 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(/\/+$/, '');
455
496
  }
456
497
 
457
- function getApiKey(config, options) {
498
+ function resolveApiCredential(config, options) {
458
499
  const profile = profileData(config, options);
459
- 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;
460
518
  }
461
519
 
462
520
  async function readStdin() {
@@ -465,6 +523,97 @@ async function readStdin() {
465
523
  return Buffer.concat(chunks).toString('utf8');
466
524
  }
467
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
+
468
617
  function boolOption(value) {
469
618
  return value === true || value === 'true' || value === '1' || value === 'yes';
470
619
  }
@@ -581,39 +730,67 @@ async function apiFetch(config, options, method, endpoint, body, extra = {}) {
581
730
  };
582
731
  const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
583
732
  if (body !== undefined && !isFormData && !extra.rawBody) headers['Content-Type'] = 'application/json';
584
- 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);
585
738
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
586
739
 
587
740
  let response;
741
+ let responseTimeout;
588
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?.();
589
752
  try {
590
753
  const request = {
591
754
  method: methodName,
592
755
  headers,
593
756
  body: body === undefined ? undefined : extra.rawBody ? body : isFormData ? body : JSON.stringify(body),
757
+ signal: controller.signal,
594
758
  };
595
759
  if (extra.rawBody && body !== undefined) {
596
760
  request.duplex = 'half';
597
761
  }
598
762
  response = await fetch(`${baseUrl}${endpoint}`, request);
599
763
  } catch (error) {
764
+ clearTimeout(timeout);
600
765
  if (canRetry && attempt < GET_RETRY_DELAYS_MS.length) {
601
766
  await sleep(GET_RETRY_DELAYS_MS[attempt]);
602
767
  continue;
603
768
  }
604
- 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 });
605
773
  }
606
774
 
607
775
  if (canRetry && RETRYABLE_GET_STATUSES.has(response.status) && attempt < GET_RETRY_DELAYS_MS.length) {
608
776
  await response.arrayBuffer().catch(() => undefined);
777
+ clearTimeout(timeout);
609
778
  await sleep(retryDelayMs(response, attempt));
610
779
  continue;
611
780
  }
781
+ responseTimeout = timeout;
612
782
  break;
613
783
  }
614
784
 
615
785
  const requestId = response.headers.get('x-request-id') || response.headers.get('X-Request-Id') || undefined;
616
- 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
+ }
617
794
  let data = null;
618
795
  if (text.trim()) {
619
796
  try {
@@ -638,6 +815,13 @@ async function apiFetch(config, options, method, endpoint, body, extra = {}) {
638
815
  requestId,
639
816
  });
640
817
  }
818
+ if (extra.includeResponseMetadata) {
819
+ return {
820
+ data,
821
+ status: response.status,
822
+ headers: Object.fromEntries(response.headers.entries()),
823
+ };
824
+ }
641
825
  return data;
642
826
  }
643
827
 
@@ -813,15 +997,6 @@ async function fetchClipForEstimate(config, options, clipId) {
813
997
  return apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(clipId)}`);
814
998
  }
815
999
 
816
- function progressTransform(progress) {
817
- return new Transform({
818
- transform(chunk, encoding, callback) {
819
- progress.track(chunk);
820
- callback(null, chunk);
821
- },
822
- });
823
- }
824
-
825
1000
  async function buildMaxCreditsEstimateRequest(config, options, spec, context = {}) {
826
1001
  if (spec.metrics === 'url-import') {
827
1002
  const youtube = isYoutubeUrl(requiredString(context.url, 'URL'));
@@ -1076,6 +1251,13 @@ async function enforceRunMaxCredits(config, options, functionName, parameters =
1076
1251
  const isMetered = Boolean(tool?.costBand && tool.costBand !== 'free');
1077
1252
  const isMeteredExempt = RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName);
1078
1253
 
1254
+ if (tool?.costBand === 'free') {
1255
+ if (tool?.confirmation?.required) {
1256
+ requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
1257
+ }
1258
+ return null;
1259
+ }
1260
+
1079
1261
  let staticEstimates = null;
1080
1262
  if (!runEstimate && (limit !== null || needsConfirmationPreflight)) {
1081
1263
  staticEstimates = await buildStaticRunEstimates(config, options, functionName, parameters, payload);
@@ -1294,7 +1476,7 @@ async function setKey(config, options) {
1294
1476
  if (!options.stdin) {
1295
1477
  throw Object.assign(new Error('Use --stdin to avoid shell history leaks.'), { exitCode: EXIT.USAGE });
1296
1478
  }
1297
- const apiKey = (await readStdin()).trim();
1479
+ const apiKey = (await readSecretLine()).trim();
1298
1480
  if (!apiKey) {
1299
1481
  throw Object.assign(new Error('No API key received on stdin.'), { exitCode: EXIT.USAGE });
1300
1482
  }
@@ -1303,17 +1485,50 @@ async function setKey(config, options) {
1303
1485
  apiKey,
1304
1486
  loginSource: 'manual',
1305
1487
  });
1306
- const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me');
1307
- await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey }));
1308
- output({ success: true, message: 'API key stored', profile: profileName(config, options), account: me.user, apiKey: me.apiKey }, options);
1488
+ const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me', undefined, { authApiKey: apiKey });
1489
+ await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey, scope: me.scope ?? null }));
1490
+ output({
1491
+ success: true,
1492
+ message: 'API key stored',
1493
+ profile: profileName(config, options),
1494
+ account: me.user,
1495
+ apiKey: me.apiKey,
1496
+ scope: me.scope ?? null,
1497
+ }, options);
1309
1498
  }
1310
1499
 
1311
1500
  async function logout(config, options) {
1312
- const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'loginSource']);
1501
+ const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'scope', 'loginSource']);
1313
1502
  await writeConfig(next);
1314
1503
  output({ success: true, message: 'Local ClipIt CLI credentials removed', profile: profileName(config, options) }, options);
1315
1504
  }
1316
1505
 
1506
+ async function useProfile(config, options, requestedName) {
1507
+ const name = String(requestedName || '').trim();
1508
+ if (!name) {
1509
+ throw Object.assign(new Error('Profile name is required.'), { exitCode: EXIT.USAGE });
1510
+ }
1511
+ if (process.env.CLIPIT_PROFILE && process.env.CLIPIT_PROFILE !== name) {
1512
+ throw Object.assign(
1513
+ new Error(`CLIPIT_PROFILE is set to ${process.env.CLIPIT_PROFILE}; unset it or select that profile explicitly.`),
1514
+ { exitCode: EXIT.USAGE },
1515
+ );
1516
+ }
1517
+ const selectedOptions = { ...options, profile: name };
1518
+ const profile = profileData(config, selectedOptions);
1519
+ if (!profile.apiKey) {
1520
+ throw Object.assign(new Error(`Profile ${name} does not have a stored credential.`), { exitCode: EXIT.AUTH });
1521
+ }
1522
+ const next = { ...config, currentProfile: name, updatedAt: new Date().toISOString() };
1523
+ await writeConfig(next);
1524
+ output({
1525
+ success: true,
1526
+ currentProfile: name,
1527
+ keyName: profile.keyInfo?.keyName ?? null,
1528
+ scope: profile.scope ?? null,
1529
+ }, options);
1530
+ }
1531
+
1317
1532
  async function listProfiles(config, options) {
1318
1533
  const profiles = config.profiles || {};
1319
1534
  const names = [...new Set(['default', ...Object.keys(profiles)])];
@@ -1329,6 +1544,7 @@ async function listProfiles(config, options) {
1329
1544
  hasCredential: Boolean(data.apiKey),
1330
1545
  loginSource: data.loginSource || null,
1331
1546
  keyName: data.keyInfo?.keyName || null,
1547
+ scope: data.scope || null,
1332
1548
  updatedAt: data.updatedAt || null,
1333
1549
  };
1334
1550
  }),
@@ -1336,6 +1552,7 @@ async function listProfiles(config, options) {
1336
1552
  }
1337
1553
 
1338
1554
  async function doctor(config, options) {
1555
+ const credential = resolveApiCredential(config, options);
1339
1556
  const checks = {
1340
1557
  version: VERSION,
1341
1558
  node: process.version,
@@ -1343,8 +1560,8 @@ async function doctor(config, options) {
1343
1560
  configPath: configPath(),
1344
1561
  profile: profileName(config, options),
1345
1562
  baseUrl: getBaseUrl(config, options),
1346
- hasCredential: Boolean(getApiKey(config, options)),
1347
- credentialSource: process.env.CLIPPER_API_KEY ? 'env' : getApiKey(config, options) ? 'config' : 'missing',
1563
+ hasCredential: Boolean(credential.apiKey),
1564
+ credentialSource: credential.source,
1348
1565
  auth: null,
1349
1566
  };
1350
1567
  try {
@@ -1650,11 +1867,33 @@ async function pollWorkflow(config, options, jobId) {
1650
1867
  }
1651
1868
  }
1652
1869
 
1870
+ function promptNamesNewSourceUrl(userMessage) {
1871
+ const urls = userMessage.match(/https?:\/\/[^\s<>"'`]+/gi) || [];
1872
+ if (urls.length !== 1) return false;
1873
+ const messageWithoutUrls = userMessage
1874
+ .replace(/https?:\/\/[^\s<>"'`]+/gi, ' ')
1875
+ .replace(/[^a-z0-9]+/gi, ' ')
1876
+ .trim()
1877
+ .toLowerCase();
1878
+ const isUrlOnly = messageWithoutUrls.length === 0;
1879
+ const hasStrongSourceIntent = /\b(import|process|source|transcrib\w*|download|extract)\b/.test(messageWithoutUrls)
1880
+ || /\b(clip|clips|video)\b.*\b(from|using|this|that)\b/.test(messageWithoutUrls)
1881
+ || /\b(use|using)\b.*\b(link|url|source|video|this|that)\b/.test(messageWithoutUrls);
1882
+ const isReferenceOnly = /\b(reference|inspiration|example|style)\b/.test(messageWithoutUrls)
1883
+ && !/\b(import|process|source|transcrib\w*|clip|clips|extract)\b/.test(messageWithoutUrls);
1884
+ return isUrlOnly || (hasStrongSourceIntent && !isReferenceOnly);
1885
+ }
1886
+
1653
1887
  async function askWorkflow(config, options, promptParts) {
1654
1888
  const userMessage = promptParts.filter((part) => part !== undefined).join(' ').trim();
1655
1889
  if (!userMessage) throw Object.assign(new Error('Prompt is required.'), { exitCode: EXIT.USAGE });
1656
1890
 
1657
1891
  const context = await buildContext(config, options);
1892
+ if (promptNamesNewSourceUrl(userMessage)) {
1893
+ if (!options['video-id']) delete context.videoId;
1894
+ if (!options['clip-id']) delete context.clipId;
1895
+ if (!options['selected-clip-ids']) delete context.selectedClipIds;
1896
+ }
1658
1897
  const payload = { userMessage };
1659
1898
  for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
1660
1899
  if (context[field]) payload[field] = context[field];
@@ -1733,16 +1972,19 @@ function mimeForPath(filePath) {
1733
1972
  if (ext === '.webm') return 'video/webm';
1734
1973
  if (ext === '.mkv') return 'video/x-matroska';
1735
1974
  if (ext === '.m4v') return 'video/x-m4v';
1975
+ if (ext === '.avi') return 'video/x-msvideo';
1976
+ if (ext === '.wmv' || ext === '.asf') return 'video/x-ms-asf';
1977
+ if (ext === '.flv') return 'video/x-flv';
1978
+ if (ext === '.mpeg' || ext === '.mpg') return 'video/mpeg';
1979
+ if (ext === '.ts' || ext === '.m2ts') return 'video/mp2t';
1980
+ if (ext === '.mxf') return 'video/mxf';
1981
+ if (ext === '.ogv') return 'video/ogg';
1736
1982
  if (ext === '.mp3') return 'audio/mpeg';
1737
1983
  if (ext === '.m4a') return 'audio/mp4';
1738
1984
  if (ext === '.wav') return 'audio/wav';
1739
1985
  return 'video/mp4';
1740
1986
  }
1741
1987
 
1742
- function multipartFilename(value) {
1743
- return String(value).replace(/["\r\n]/g, '_');
1744
- }
1745
-
1746
1988
  function shouldReportUploadProgress(options) {
1747
1989
  return Boolean(process.stderr.isTTY) && !wantJson(options);
1748
1990
  }
@@ -1751,24 +1993,6 @@ function formatMb(bytes) {
1751
1993
  return (bytes / (1024 * 1024)).toFixed(1);
1752
1994
  }
1753
1995
 
1754
- function assertDirectVideoUploadSize(filePath, sizeBytes) {
1755
- if (sizeBytes <= DIRECT_VIDEO_UPLOAD_MAX_BYTES) return;
1756
- throw Object.assign(
1757
- new Error(
1758
- `Video file is ${formatMb(sizeBytes)} MB, above the direct CLI upload limit of ${formatMb(DIRECT_VIDEO_UPLOAD_MAX_BYTES)} MB. ` +
1759
- 'Use `clipit videos import-url` for URL sources or a server-side/resumable upload path; direct CLI upload is likely to fail with 413 before ClipIt can process it.',
1760
- ),
1761
- {
1762
- exitCode: EXIT.USAGE,
1763
- data: {
1764
- filePath,
1765
- sizeBytes,
1766
- maxBytes: DIRECT_VIDEO_UPLOAD_MAX_BYTES,
1767
- },
1768
- },
1769
- );
1770
- }
1771
-
1772
1996
  function createUploadProgress(options, totalBytes) {
1773
1997
  if (!shouldReportUploadProgress(options) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
1774
1998
  return { track() {}, finish() {} };
@@ -1797,15 +2021,259 @@ function createUploadProgress(options, totalBytes) {
1797
2021
  };
1798
2022
  }
1799
2023
 
1800
- async function* multipartFileBody(filePath, filename, contentType, boundary, progress) {
1801
- yield Buffer.from(
1802
- `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${multipartFilename(filename)}"\r\nContent-Type: ${contentType}\r\n\r\n`,
2024
+ const VIDEO_UPLOAD_RESUME_MAX_AGE_MS = 25 * 60 * 60 * 1000;
2025
+
2026
+ function videoUploadResumeDirectory() {
2027
+ return path.join(configDir(), 'video-upload-resumes');
2028
+ }
2029
+
2030
+ function videoUploadResumePath(idempotencyKey) {
2031
+ const digest = createHash('sha256').update(String(idempotencyKey)).digest('hex');
2032
+ return path.join(videoUploadResumeDirectory(), `${digest}.json`);
2033
+ }
2034
+
2035
+ async function readVideoUploadResumes() {
2036
+ const resumes = {};
2037
+ await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
2038
+ const files = await fs.readdir(videoUploadResumeDirectory()).catch(() => []);
2039
+ for (const file of files) {
2040
+ if (!file.endsWith('.json')) continue;
2041
+ const recordPath = path.join(videoUploadResumeDirectory(), file);
2042
+ try {
2043
+ const value = JSON.parse(await fs.readFile(recordPath, 'utf8'));
2044
+ const updatedAt = Date.parse(value?.updatedAt);
2045
+ if (
2046
+ !value
2047
+ || typeof value !== 'object'
2048
+ || Array.isArray(value)
2049
+ || typeof value.idempotencyKey !== 'string'
2050
+ || !Number.isFinite(updatedAt)
2051
+ || Date.now() - updatedAt > VIDEO_UPLOAD_RESUME_MAX_AGE_MS
2052
+ ) {
2053
+ await fs.rm(recordPath, { force: true });
2054
+ continue;
2055
+ }
2056
+ resumes[value.idempotencyKey] = value;
2057
+ } catch {
2058
+ await fs.rm(recordPath, { force: true }).catch(() => undefined);
2059
+ }
2060
+ }
2061
+
2062
+ const legacyPath = path.join(configDir(), 'video-upload-resume.json');
2063
+ try {
2064
+ const legacy = JSON.parse(await fs.readFile(legacyPath, 'utf8'));
2065
+ if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
2066
+ for (const [idempotencyKey, value] of Object.entries(legacy)) {
2067
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
2068
+ await saveVideoUploadResume(idempotencyKey, value);
2069
+ resumes[idempotencyKey] = { ...value, idempotencyKey };
2070
+ }
2071
+ }
2072
+ await fs.rm(legacyPath, { force: true });
2073
+ } catch {}
2074
+
2075
+ return resumes;
2076
+ }
2077
+
2078
+ async function saveVideoUploadResume(idempotencyKey, value) {
2079
+ await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
2080
+ const destination = videoUploadResumePath(idempotencyKey);
2081
+ const temporary = `${destination}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
2082
+ await fs.writeFile(
2083
+ temporary,
2084
+ `${JSON.stringify({ ...value, idempotencyKey }, null, 2)}\n`,
2085
+ { mode: 0o600 },
1803
2086
  );
1804
- for await (const chunk of createReadStream(filePath)) {
1805
- progress?.track(chunk);
1806
- yield chunk;
2087
+ await fs.rename(temporary, destination);
2088
+ }
2089
+
2090
+ async function removeVideoUploadResume(idempotencyKey) {
2091
+ await fs.rm(videoUploadResumePath(idempotencyKey), { force: true });
2092
+ }
2093
+
2094
+ async function removeVideoUploadResumeByIntent(intentId) {
2095
+ const resumes = await readVideoUploadResumes();
2096
+ await Promise.all(Object.entries(resumes).map(async ([key, value]) => {
2097
+ if (value?.intentId === intentId) await removeVideoUploadResume(key);
2098
+ }));
2099
+ }
2100
+
2101
+ function defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType) {
2102
+ const fingerprint = createHash('sha256').update(JSON.stringify({
2103
+ path: resolved,
2104
+ size: stat.size,
2105
+ mtimeMs: stat.mtimeMs,
2106
+ ctimeMs: stat.ctimeMs,
2107
+ filename,
2108
+ contentType,
2109
+ })).digest('hex');
2110
+ return `cli-video:${fingerprint}`;
2111
+ }
2112
+
2113
+ function createMultipartUploadProgress(options, totalBytes, completedParts) {
2114
+ if (!shouldReportUploadProgress(options) || totalBytes <= 0) {
2115
+ return { updatePart() {}, completePart() {}, resetPart() {}, finish() {} };
2116
+ }
2117
+ const completed = new Map(completedParts.map((part) => [part.partNumber, part.sizeBytes]));
2118
+ const inFlight = new Map();
2119
+ let lastPrintedAt = 0;
2120
+
2121
+ const print = (force = false) => {
2122
+ const now = Date.now();
2123
+ if (!force && now - lastPrintedAt < 500) return;
2124
+ lastPrintedAt = now;
2125
+ const uploaded = Math.min(
2126
+ totalBytes,
2127
+ [...completed.values(), ...inFlight.values()].reduce((sum, value) => sum + value, 0),
2128
+ );
2129
+ const percent = Math.min(100, Math.round((uploaded / totalBytes) * 100));
2130
+ process.stderr.write(`\ruploaded ${formatMb(uploaded)} / ${formatMb(totalBytes)} MB (${percent}%)`);
2131
+ };
2132
+
2133
+ return {
2134
+ updatePart(partNumber, bytes) {
2135
+ inFlight.set(partNumber, bytes);
2136
+ print(false);
2137
+ },
2138
+ completePart(partNumber, bytes) {
2139
+ inFlight.delete(partNumber);
2140
+ completed.set(partNumber, bytes);
2141
+ print(false);
2142
+ },
2143
+ resetPart(partNumber) {
2144
+ inFlight.delete(partNumber);
2145
+ print(false);
2146
+ },
2147
+ finish() {
2148
+ print(true);
2149
+ process.stderr.write('\n');
2150
+ },
2151
+ };
2152
+ }
2153
+
2154
+ function createUploadActivityWatchdog(controller, onTimeout) {
2155
+ let timer;
2156
+ const arm = () => {
2157
+ if (timer) clearTimeout(timer);
2158
+ timer = setTimeout(() => {
2159
+ const error = Object.assign(
2160
+ new Error(`Storage upload made no progress for ${VIDEO_UPLOAD_STALL_TIMEOUT_MS} ms`),
2161
+ { code: 'UPLOAD_STALLED' },
2162
+ );
2163
+ controller.abort(error);
2164
+ onTimeout?.();
2165
+ }, VIDEO_UPLOAD_STALL_TIMEOUT_MS);
2166
+ timer.unref?.();
2167
+ };
2168
+ arm();
2169
+ return {
2170
+ activity: arm,
2171
+ stop() {
2172
+ if (timer) clearTimeout(timer);
2173
+ timer = undefined;
2174
+ },
2175
+ };
2176
+ }
2177
+
2178
+ async function putSignedVideoPart(part, filePath, start, size, progress) {
2179
+ for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
2180
+ let transferred = 0;
2181
+ const controller = new AbortController();
2182
+ let source;
2183
+ let tracker;
2184
+ const watchdog = createUploadActivityWatchdog(controller, () => {
2185
+ source?.destroy();
2186
+ tracker?.destroy();
2187
+ });
2188
+ tracker = new Transform({
2189
+ transform(chunk, encoding, callback) {
2190
+ transferred += Buffer.byteLength(chunk);
2191
+ watchdog.activity();
2192
+ progress.updatePart(part.partNumber, transferred);
2193
+ callback(null, chunk);
2194
+ },
2195
+ });
2196
+ try {
2197
+ source = createReadStream(filePath, { start, end: start + size - 1 });
2198
+ const response = await fetch(part.url, {
2199
+ method: 'PUT',
2200
+ headers: { 'Content-Length': String(size) },
2201
+ body: source.pipe(tracker),
2202
+ duplex: 'half',
2203
+ signal: controller.signal,
2204
+ });
2205
+ if (!response.ok) {
2206
+ const text = await response.text().catch(() => '');
2207
+ throw new Error(`Part ${part.partNumber} failed: ${response.status} ${redact(text || response.statusText)}`);
2208
+ }
2209
+ progress.completePart(part.partNumber, size);
2210
+ return;
2211
+ } catch (error) {
2212
+ progress.resetPart(part.partNumber);
2213
+ const reason = controller.signal.aborted && controller.signal.reason instanceof Error
2214
+ ? controller.signal.reason
2215
+ : error;
2216
+ if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1) throw reason;
2217
+ await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
2218
+ } finally {
2219
+ watchdog.stop();
2220
+ source?.destroy();
2221
+ tracker?.destroy();
2222
+ }
2223
+ }
2224
+ }
2225
+
2226
+ async function uploadVideoMultipart(config, options, resolved, initialized, totalBytes) {
2227
+ const status = await apiFetch(
2228
+ config,
2229
+ options,
2230
+ 'GET',
2231
+ `/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}`,
2232
+ );
2233
+ const uploadedParts = Array.isArray(status.uploadedParts) ? status.uploadedParts : [];
2234
+ const uploadedNumbers = new Set(uploadedParts.map((part) => Number(part.partNumber)));
2235
+ const missing = Array.from({ length: Number(initialized.partCount) }, (_, index) => index + 1)
2236
+ .filter((partNumber) => !uploadedNumbers.has(partNumber));
2237
+ const progress = createMultipartUploadProgress(options, totalBytes, uploadedParts);
2238
+
2239
+ try {
2240
+ for (let offset = 0; offset < missing.length; offset += VIDEO_UPLOAD_PART_SIGN_BATCH) {
2241
+ const partNumbers = missing.slice(offset, offset + VIDEO_UPLOAD_PART_SIGN_BATCH);
2242
+ const signed = await apiFetch(
2243
+ config,
2244
+ options,
2245
+ 'POST',
2246
+ `/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/parts`,
2247
+ { partNumbers },
2248
+ );
2249
+ const queue = [...(signed.signedPartUrls || [])];
2250
+ const signedNumbers = new Set(queue.map((part) => Number(part?.partNumber)));
2251
+ if (
2252
+ queue.length !== partNumbers.length
2253
+ || signedNumbers.size !== partNumbers.length
2254
+ || partNumbers.some((partNumber) => !signedNumbers.has(partNumber))
2255
+ || queue.some((part) => typeof part?.url !== 'string' || !part.url)
2256
+ ) {
2257
+ throw new Error('Server returned an incomplete multipart signing batch.');
2258
+ }
2259
+ let cursor = 0;
2260
+ const worker = async () => {
2261
+ while (cursor < queue.length) {
2262
+ const part = queue[cursor++];
2263
+ const start = (part.partNumber - 1) * initialized.partSizeBytes;
2264
+ const size = Math.min(initialized.partSizeBytes, totalBytes - start);
2265
+ if (size !== Number(part.expectedSizeBytes)) {
2266
+ throw new Error(`Server returned an invalid size for upload part ${part.partNumber}.`);
2267
+ }
2268
+ await putSignedVideoPart(part, resolved, start, size, progress);
2269
+ }
2270
+ };
2271
+ const workerCount = Math.min(VIDEO_UPLOAD_PART_CONCURRENCY, queue.length);
2272
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
2273
+ }
2274
+ } finally {
2275
+ progress.finish();
1807
2276
  }
1808
- yield Buffer.from(`\r\n--${boundary}--\r\n`);
1809
2277
  }
1810
2278
 
1811
2279
  async function uploadVideo(config, options, filePath) {
@@ -1815,26 +2283,147 @@ async function uploadVideo(config, options, filePath) {
1815
2283
  if (!stat.isFile()) {
1816
2284
  throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
1817
2285
  }
1818
- assertDirectVideoUploadSize(resolved, stat.size);
1819
- const filename = options.filename || path.basename(resolved);
2286
+ const filename = String(options.filename || path.basename(resolved));
1820
2287
  const contentType = mimeForPath(resolved);
1821
- const boundary = `clipit-cli-${randomBytes(12).toString('hex')}`;
1822
- const headerLength = Buffer.byteLength(
1823
- `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${multipartFilename(filename)}"\r\nContent-Type: ${contentType}\r\n\r\n`,
2288
+ const title = options.title === undefined || options.title === null
2289
+ ? null
2290
+ : String(options.title);
2291
+ const idempotencyKey = String(
2292
+ options['idempotency-key']
2293
+ || defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType),
1824
2294
  );
1825
- const footerLength = Buffer.byteLength(`\r\n--${boundary}--\r\n`);
1826
- const progress = createUploadProgress(options, stat.size);
1827
- const body = multipartFileBody(resolved, filename, contentType, boundary, progress);
2295
+ const uploadIdentity = {
2296
+ filePath: resolved,
2297
+ filename,
2298
+ title,
2299
+ profile: profileName(config, options),
2300
+ baseUrl: getBaseUrl(config, options),
2301
+ allowCustomHost: allowCustomHost(options),
2302
+ contentType,
2303
+ size: stat.size,
2304
+ mtimeMs: stat.mtimeMs,
2305
+ };
2306
+ const resumes = await readVideoUploadResumes();
2307
+ const existingResume = resumes[idempotencyKey];
2308
+ if (existingResume && typeof existingResume === 'object') {
2309
+ const mismatchedFields = Object.entries(uploadIdentity)
2310
+ .filter(([key, value]) => {
2311
+ const existingValue = existingResume[key];
2312
+ if (key === 'title') return (existingValue ?? null) !== value;
2313
+ return existingValue !== value;
2314
+ })
2315
+ .map(([key]) => key);
2316
+ if (mismatchedFields.length > 0) {
2317
+ const abortArgs = existingResume.intentId
2318
+ ? ['videos', 'abort-upload', String(existingResume.intentId), '--profile', String(existingResume.profile || uploadIdentity.profile), '--base-url', String(existingResume.baseUrl || uploadIdentity.baseUrl), '--confirm']
2319
+ : null;
2320
+ if (abortArgs && existingResume.allowCustomHost === true) abortArgs.push('--allow-custom-host');
2321
+ throw Object.assign(
2322
+ new Error('The file or upload identity changed since this resumable upload began. Abort the prior upload and retry with a new idempotency key.'),
2323
+ {
2324
+ exitCode: EXIT.USAGE,
2325
+ data: {
2326
+ idempotencyKey,
2327
+ uploadIntentId: existingResume.intentId,
2328
+ mismatchedFields,
2329
+ abortArgs,
2330
+ abortCommand: abortArgs ? `clipit ${abortArgs.map(shellQuote).join(' ')}` : undefined,
2331
+ },
2332
+ },
2333
+ );
2334
+ }
2335
+ }
1828
2336
  await enforceMaxCredits(config, options, 'videos upload', { bytes: stat.size });
1829
2337
  confirmPaid(options, 'Uploading a video');
2338
+ await saveVideoUploadResume(idempotencyKey, {
2339
+ ...uploadIdentity,
2340
+ intentId: existingResume?.intentId,
2341
+ jobId: existingResume?.jobId,
2342
+ updatedAt: new Date().toISOString(),
2343
+ });
2344
+
2345
+ let initialized;
1830
2346
  try {
1831
- const result = await apiFetch(config, options, 'POST', '/api/v1/videos', body, {
1832
- rawBody: true,
1833
- headers: {
1834
- 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1835
- 'Content-Length': String(headerLength + stat.size + footerLength),
1836
- },
2347
+ initialized = await apiFetch(config, options, 'POST', '/api/v1/videos/uploads', {
2348
+ filename,
2349
+ contentType,
2350
+ size: stat.size,
2351
+ title: title ?? undefined,
2352
+ idempotencyKey,
1837
2353
  });
2354
+ if (!initialized?.intentId || !initialized?.jobId) {
2355
+ throw Object.assign(new Error('Video upload initialization returned an unexpected response.'), {
2356
+ exitCode: EXIT.SERVER,
2357
+ data: initialized,
2358
+ });
2359
+ }
2360
+ if (!initialized.readyForUpload) {
2361
+ await removeVideoUploadResume(idempotencyKey);
2362
+ if (initialized.videoId) {
2363
+ await persistActiveContext(
2364
+ config,
2365
+ options,
2366
+ { videoId: initialized.videoId },
2367
+ [{ type: 'video', id: initialized.videoId }],
2368
+ );
2369
+ }
2370
+ output(initialized, options);
2371
+ return;
2372
+ }
2373
+
2374
+ await saveVideoUploadResume(idempotencyKey, {
2375
+ ...uploadIdentity,
2376
+ intentId: initialized.intentId,
2377
+ jobId: initialized.jobId,
2378
+ updatedAt: new Date().toISOString(),
2379
+ });
2380
+
2381
+ if (initialized.transport === 'single') {
2382
+ if (!initialized.uploadUrl || Number(initialized.expectedSizeBytes) !== stat.size) {
2383
+ throw new Error('Direct upload initialization returned invalid transfer metadata.');
2384
+ }
2385
+ await putSignedUpload(
2386
+ initialized.uploadUrl,
2387
+ resolved,
2388
+ contentType,
2389
+ stat.size,
2390
+ options,
2391
+ initialized.requiredHeaders,
2392
+ );
2393
+ } else if (initialized.transport === 'multipart') {
2394
+ if (
2395
+ !Number.isInteger(initialized.partCount)
2396
+ || !Number.isInteger(initialized.partSizeBytes)
2397
+ || Number(initialized.expectedSizeBytes) !== stat.size
2398
+ ) {
2399
+ throw new Error('Multipart upload initialization returned invalid transfer metadata.');
2400
+ }
2401
+ await uploadVideoMultipart(config, options, resolved, initialized, stat.size);
2402
+ } else {
2403
+ throw new Error('Video upload initialization returned no supported transport.');
2404
+ }
2405
+
2406
+ const completedStat = await fs.stat(resolved);
2407
+ if (
2408
+ !completedStat.isFile()
2409
+ || completedStat.size !== stat.size
2410
+ || completedStat.mtimeMs !== stat.mtimeMs
2411
+ || completedStat.ctimeMs !== stat.ctimeMs
2412
+ ) {
2413
+ throw Object.assign(
2414
+ new Error('The video file changed while it was uploading. Abort this upload and retry with a new idempotency key.'),
2415
+ { exitCode: EXIT.USAGE, data: { fileChangedDuringUpload: true } },
2416
+ );
2417
+ }
2418
+
2419
+ const result = await apiFetch(
2420
+ config,
2421
+ options,
2422
+ 'POST',
2423
+ `/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/complete`,
2424
+ {},
2425
+ );
2426
+ await removeVideoUploadResume(idempotencyKey);
1838
2427
  if (result?.videoId) {
1839
2428
  await persistActiveContext(
1840
2429
  config,
@@ -1844,8 +2433,42 @@ async function uploadVideo(config, options, filePath) {
1844
2433
  );
1845
2434
  }
1846
2435
  output(result, options);
1847
- } finally {
1848
- progress.finish();
2436
+ } catch (error) {
2437
+ if (error && typeof error === 'object') {
2438
+ const requiresNewIdempotencyKey = error.data?.requiresNewIdempotencyKey === true
2439
+ || error.data?.code === 'IDEMPOTENCY_KEY_TERMINAL';
2440
+ if (requiresNewIdempotencyKey) {
2441
+ await removeVideoUploadResume(idempotencyKey);
2442
+ error.data = {
2443
+ ...(error.data && typeof error.data === 'object' ? error.data : {}),
2444
+ uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
2445
+ idempotencyKey,
2446
+ };
2447
+ throw error;
2448
+ }
2449
+ const resumeArgs = [
2450
+ 'videos',
2451
+ 'upload',
2452
+ resolved,
2453
+ '--idempotency-key',
2454
+ idempotencyKey,
2455
+ ];
2456
+ resumeArgs.push('--filename', filename);
2457
+ if (title) resumeArgs.push('--title', title);
2458
+ resumeArgs.push('--profile', uploadIdentity.profile);
2459
+ resumeArgs.push('--base-url', uploadIdentity.baseUrl);
2460
+ if (uploadIdentity.allowCustomHost) resumeArgs.push('--allow-custom-host');
2461
+ if (boolOption(options.confirm) || boolOption(options.yes)) resumeArgs.push('--confirm');
2462
+ if (wantJson(options)) resumeArgs.push('--json');
2463
+ error.data = {
2464
+ ...(error.data && typeof error.data === 'object' ? error.data : {}),
2465
+ uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
2466
+ idempotencyKey,
2467
+ resumeArgs,
2468
+ resumeCommand: `clipit ${resumeArgs.map(shellQuote).join(' ')}`,
2469
+ };
2470
+ }
2471
+ throw error;
1849
2472
  }
1850
2473
  }
1851
2474
 
@@ -1866,13 +2489,30 @@ async function videos(config, options, action, args) {
1866
2489
  if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
1867
2490
  await enforceMaxCredits(config, options, 'videos import-url', { url });
1868
2491
  confirmPaid(options, 'Importing a video from URL');
1869
- output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', { url, title: options.title }), options);
2492
+ output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', {
2493
+ url,
2494
+ title: options.title,
2495
+ idempotencyKey: options['idempotency-key'] || randomUUID(),
2496
+ }), options);
1870
2497
  return;
1871
2498
  }
1872
2499
  if (action === 'upload') {
1873
2500
  await uploadVideo(config, options, args[0] || options.file);
1874
2501
  return;
1875
2502
  }
2503
+ if (action === 'abort-upload') {
2504
+ const intentId = requiredString(args[0] || options['intent-id'], 'Upload intent id');
2505
+ requireConfirm(options, 'Aborting a video upload');
2506
+ const result = await apiFetch(
2507
+ config,
2508
+ options,
2509
+ 'DELETE',
2510
+ `/api/v1/videos/uploads/${encodeURIComponent(intentId)}`,
2511
+ );
2512
+ await removeVideoUploadResumeByIntent(intentId);
2513
+ output(result, options);
2514
+ return;
2515
+ }
1876
2516
  if (action === 'transcribe') {
1877
2517
  if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
1878
2518
  await enforceMaxCredits(config, options, 'videos transcribe', { videoId: args[0] });
@@ -1911,6 +2551,119 @@ async function videos(config, options, action, args) {
1911
2551
  throw Object.assign(new Error(`Unknown videos command: ${action || ''}`), { exitCode: EXIT.USAGE });
1912
2552
  }
1913
2553
 
2554
+ function formatClipDeliveryState(state) {
2555
+ const editor = state?.editorState;
2556
+ const selected = state?.selectedExport;
2557
+ const candidates = Array.isArray(state?.exports) ? state.exports : [];
2558
+ const blockers = Array.isArray(state?.deliveryBlockers) ? state.deliveryBlockers : [];
2559
+ const lines = [
2560
+ `Clip: ${state?.clipId || 'unknown'}`,
2561
+ editor
2562
+ ? `Editor snapshot: ${editor.snapshotId}`
2563
+ : `Editor: ${state?.editorStateStatus || 'unavailable'}`,
2564
+ editor ? `Editor version: ${editor.editorVersion}` : null,
2565
+ editor ? `Editor state hash: ${editor.editorStateHash}` : null,
2566
+ editor ? `Source object fingerprint: ${editor.sourceObjectFingerprint || 'unavailable'}` : null,
2567
+ editor ? `Saved: ${editor.saveOrigin} at ${editor.savedAt}` : null,
2568
+ editor
2569
+ ? `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'}`
2570
+ : null,
2571
+ `Selection: ${state?.selection?.status || 'unknown'}${state?.selection?.selectedExportId ? ` (${state.selection.selectedExportId})` : ''}`,
2572
+ selected
2573
+ ? `Selected export: ${selected.exportId}; snapshot ${selected.snapshotId || 'unavailable'}; exact current match ${selected.exactlyMatchesEditor === true ? 'yes' : 'no'}`
2574
+ : `Artifacts: ${candidates.length}`,
2575
+ selected ? `Output object fingerprint: ${selected.outputObjectFingerprint || 'unavailable'}` : null,
2576
+ selected ? `Storage item: ${selected.storageItemId || 'unavailable'}` : null,
2577
+ selected
2578
+ ? `Artifact: ${selected.width || '?'}x${selected.height || '?'} ${selected.duration || '?'}s; audio ${selected.hasAudio === true ? 'yes' : selected.hasAudio === false ? 'no' : 'unknown'}; probe ${selected.inspectionStatus}`
2579
+ : null,
2580
+ `Ready to publish: ${state?.readyToPublish === true ? 'yes' : 'no'}`,
2581
+ ].filter(Boolean);
2582
+ if (candidates.length > 1) {
2583
+ lines.push(`Export ids: ${candidates.map((candidate) => candidate.exportId).join(', ')}`);
2584
+ }
2585
+ if (blockers.length) {
2586
+ lines.push('Blockers:');
2587
+ lines.push(...blockers.map((blocker) => `- ${blocker}`));
2588
+ }
2589
+ return lines.join('\n');
2590
+ }
2591
+
2592
+ function requireVerifiedEditorState(deliveryState, clipId) {
2593
+ const editor = deliveryState?.editorState;
2594
+ if (
2595
+ deliveryState?.schema !== 'clipit_clip_delivery_state'
2596
+ || deliveryState?.version !== 2
2597
+ || deliveryState?.clipId !== clipId
2598
+ || deliveryState?.editorStateStatus !== 'verified'
2599
+ || !editor
2600
+ || typeof editor.snapshotId !== 'string'
2601
+ || !editor.snapshotId
2602
+ || !Number.isInteger(editor.editorVersion)
2603
+ || editor.editorVersion < 1
2604
+ || typeof editor.editorStateHash !== 'string'
2605
+ || !/^[a-f0-9]{64}$/i.test(editor.editorStateHash)
2606
+ || editor.stateSource !== 'current_editor_snapshot'
2607
+ ) {
2608
+ throw Object.assign(
2609
+ new Error('Clip does not have a verified canonical editor snapshot. Save and verify the editor state before continuing.'),
2610
+ { exitCode: EXIT.SERVER, data: deliveryState },
2611
+ );
2612
+ }
2613
+ return editor;
2614
+ }
2615
+
2616
+ function requireExactCurrentExport(deliveryState, editorState, options = {}) {
2617
+ const requestedExportId = options.requestedExportId
2618
+ ? String(options.requestedExportId)
2619
+ : null;
2620
+ const selection = deliveryState?.selection;
2621
+ const selected = deliveryState?.selectedExport;
2622
+ const blockers = Array.isArray(deliveryState?.deliveryBlockers)
2623
+ ? deliveryState.deliveryBlockers
2624
+ : [];
2625
+ const selectedBlockers = Array.isArray(selected?.blockers) ? selected.blockers : [];
2626
+ const responseRequestedExportId = selection?.requestedExportId ?? null;
2627
+ const exact = Boolean(
2628
+ selection?.status === 'selected'
2629
+ && selected
2630
+ && typeof selected.exportId === 'string'
2631
+ && selected.exportId
2632
+ && selection.selectedExportId === selected.exportId
2633
+ && responseRequestedExportId === requestedExportId
2634
+ && (!requestedExportId || selected.exportId === requestedExportId)
2635
+ && selected.snapshotId === editorState.snapshotId
2636
+ && selected.editorVersion === editorState.editorVersion
2637
+ && selected.editorStateHash === editorState.editorStateHash
2638
+ && selected.exactlyMatchesEditor === true
2639
+ && selected.inspectionStatus === 'verified'
2640
+ && typeof selected.outputObjectFingerprint === 'string'
2641
+ && /^[a-f0-9]{64}$/i.test(selected.outputObjectFingerprint)
2642
+ && selectedBlockers.length === 0
2643
+ );
2644
+ const publishReady = !options.requireReadyToPublish
2645
+ || (deliveryState?.readyToPublish === true && blockers.length === 0);
2646
+ if (!exact || !publishReady) {
2647
+ const detail = blockers.length
2648
+ ? blockers.join(' ')
2649
+ : `Selection status is ${selection?.status || 'unknown'}.`;
2650
+ throw Object.assign(
2651
+ new Error(`Clip does not have one verified exact-current export: ${detail}`),
2652
+ { exitCode: EXIT.SERVER, data: deliveryState },
2653
+ );
2654
+ }
2655
+ return selected;
2656
+ }
2657
+
2658
+ async function fetchCanonicalClipDeliveryState(config, options, clipId, exportId) {
2659
+ return apiFetch(
2660
+ config,
2661
+ options,
2662
+ 'GET',
2663
+ `/api/v1/clips/${encodeURIComponent(clipId)}/delivery-state${queryString({ exportId })}`,
2664
+ );
2665
+ }
2666
+
1914
2667
  async function clips(config, options, action, args) {
1915
2668
  if (action === 'list') {
1916
2669
  output(await apiFetch(config, options, 'GET', `/api/v1/clips${queryString({
@@ -1927,6 +2680,19 @@ async function clips(config, options, action, args) {
1927
2680
  output(result, options);
1928
2681
  return;
1929
2682
  }
2683
+ if (action === 'delivery-state') {
2684
+ if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
2685
+ const result = await apiFetch(
2686
+ config,
2687
+ options,
2688
+ 'GET',
2689
+ `/api/v1/clips/${encodeURIComponent(args[0])}/delivery-state${queryString({
2690
+ exportId: options['export-id'],
2691
+ })}`,
2692
+ );
2693
+ output(wantJson(options) ? result : formatClipDeliveryState(result), options);
2694
+ return;
2695
+ }
1930
2696
  if (action === 'create') {
1931
2697
  const body = options.params
1932
2698
  ? await readJsonOption(String(options.params))
@@ -1959,6 +2725,12 @@ async function clips(config, options, action, args) {
1959
2725
  }
1960
2726
  if (action === 'update') {
1961
2727
  if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
2728
+ if (options.aspect !== undefined || options['aspect-ratio'] !== undefined) {
2729
+ throw Object.assign(
2730
+ new Error('clips update does not accept --aspect-ratio. Use clipit run setClipAspectRatio --clip-id <id> --params \'{"aspectRatio":"4:5"}\', then start an explicit render.'),
2731
+ { exitCode: EXIT.USAGE },
2732
+ );
2733
+ }
1962
2734
  const body = options.params
1963
2735
  ? await readJsonOption(String(options.params))
1964
2736
  : {
@@ -1973,6 +2745,26 @@ async function clips(config, options, action, args) {
1973
2745
  output(await apiFetch(config, options, 'PATCH', `/api/v1/clips/${encodeURIComponent(args[0])}`, body), options);
1974
2746
  return;
1975
2747
  }
2748
+ if (action === 'initialize-snapshot') {
2749
+ if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
2750
+ const body = options.params
2751
+ ? await readJsonOption(String(options.params))
2752
+ : compactObject({
2753
+ aspectRatio: options.aspect || options['aspect-ratio'],
2754
+ fitBackground: options['fit-background'],
2755
+ quality: options.quality,
2756
+ includeCaptions: options.captions === undefined ? undefined : boolOption(options.captions),
2757
+ captionStyle: options['caption-style'],
2758
+ });
2759
+ output(await apiFetch(
2760
+ config,
2761
+ options,
2762
+ 'POST',
2763
+ `/api/v1/clips/${encodeURIComponent(args[0])}/editor-snapshot/initialize`,
2764
+ body,
2765
+ ), options);
2766
+ return;
2767
+ }
1976
2768
  if (action === 'render') {
1977
2769
  if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
1978
2770
  const body = options.params
@@ -1994,9 +2786,34 @@ async function clips(config, options, action, args) {
1994
2786
  }
1995
2787
  if (action === 'download') {
1996
2788
  if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
1997
- const result = await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(args[0])}/download`);
2789
+ const clipId = args[0];
2790
+ const requestedExportId = options['export-id'];
2791
+ const deliveryState = await fetchCanonicalClipDeliveryState(
2792
+ config,
2793
+ options,
2794
+ clipId,
2795
+ requestedExportId,
2796
+ );
2797
+ const editorState = requireVerifiedEditorState(deliveryState, clipId);
2798
+ const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
2799
+ requestedExportId,
2800
+ });
2801
+ const result = await apiFetch(
2802
+ config,
2803
+ options,
2804
+ 'GET',
2805
+ `/api/v1/exports/${encodeURIComponent(selectedExport.exportId)}/download`,
2806
+ );
1998
2807
  if (options.open && result?.downloadUrl) openBrowser(result.downloadUrl);
1999
- output(result, options);
2808
+ output({
2809
+ ...result,
2810
+ clipId,
2811
+ exportId: selectedExport.exportId,
2812
+ snapshotId: editorState.snapshotId,
2813
+ editorVersion: editorState.editorVersion,
2814
+ editorStateHash: editorState.editorStateHash,
2815
+ outputObjectFingerprint: selectedExport.outputObjectFingerprint,
2816
+ }, options);
2000
2817
  return;
2001
2818
  }
2002
2819
  if (action === 'delete') {
@@ -2139,6 +2956,17 @@ function defaultExportStartBody(clipId) {
2139
2956
  };
2140
2957
  }
2141
2958
 
2959
+ function buildCanonicalExportStartBody(clipId, params, editorState, idempotencyKey) {
2960
+ return {
2961
+ ...defaultExportStartBody(clipId),
2962
+ ...params,
2963
+ clipId,
2964
+ idempotencyKey,
2965
+ expectedEditorVersion: editorState.editorVersion,
2966
+ expectedEditorStateHash: editorState.editorStateHash,
2967
+ };
2968
+ }
2969
+
2142
2970
  async function pollExport(config, options, jobId) {
2143
2971
  const startedAt = Date.now();
2144
2972
  const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
@@ -2161,14 +2989,32 @@ async function exportsCommand(config, options, action, args) {
2161
2989
  if (action === 'start') {
2162
2990
  const clipId = requiredString(options['clip-id'], '--clip-id');
2163
2991
  const params = options.params ? await readJsonOption(String(options.params)) : {};
2164
- const body = {
2165
- ...defaultExportStartBody(clipId),
2166
- ...params,
2992
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
2993
+ throw Object.assign(new Error('--params must contain a JSON object.'), { exitCode: EXIT.USAGE });
2994
+ }
2995
+ const deliveryState = await fetchCanonicalClipDeliveryState(config, options, clipId);
2996
+ const editorState = requireVerifiedEditorState(deliveryState, clipId);
2997
+ const idempotencyKey = requiredString(
2998
+ options['idempotency-key'] || params.idempotencyKey || `cli-export:${randomUUID()}`,
2999
+ '--idempotency-key',
3000
+ );
3001
+ const body = buildCanonicalExportStartBody(
2167
3002
  clipId,
2168
- };
3003
+ params,
3004
+ editorState,
3005
+ idempotencyKey,
3006
+ );
2169
3007
  await enforceMaxCredits(config, options, 'exports start', { clipId, body });
2170
3008
  confirmPaid(options, 'Starting an export');
2171
- output(await apiFetch(config, options, 'POST', '/api/v1/exports', body), options);
3009
+ const result = await apiFetch(config, options, 'POST', '/api/v1/exports', body);
3010
+ output({
3011
+ ...result,
3012
+ clipId,
3013
+ snapshotId: editorState.snapshotId,
3014
+ expectedEditorVersion: body.expectedEditorVersion,
3015
+ expectedEditorStateHash: body.expectedEditorStateHash,
3016
+ idempotencyKey: body.idempotencyKey,
3017
+ }, options);
2172
3018
  return;
2173
3019
  }
2174
3020
  if (action === 'list') {
@@ -2200,32 +3046,76 @@ async function exportsCommand(config, options, action, args) {
2200
3046
  throw Object.assign(new Error(`Unknown exports command: ${action || ''}`), { exitCode: EXIT.USAGE });
2201
3047
  }
2202
3048
 
2203
- function objectPathFromKey(key) {
2204
- return String(key).startsWith('/objects/') ? String(key) : `/objects/${key}`;
2205
- }
2206
-
2207
- async function putSignedUpload(uploadUrl, filePath, contentType, size, options) {
2208
- let response;
2209
- const progress = createUploadProgress(options, size);
2210
- const body = createReadStream(filePath).pipe(progressTransform(progress));
2211
- try {
2212
- response = await fetch(uploadUrl, {
2213
- method: 'PUT',
2214
- headers: {
3049
+ async function putSignedUpload(uploadUrl, filePath, contentType, size, options, requiredHeaders) {
3050
+ const headers = requiredHeaders && typeof requiredHeaders === 'object'
3051
+ ? Object.fromEntries(Object.entries(requiredHeaders).map(([key, value]) => [key, String(value)]))
3052
+ : {
2215
3053
  'Content-Type': contentType,
2216
3054
  'Content-Length': String(size),
2217
- },
2218
- body,
2219
- duplex: 'half',
2220
- });
2221
- } catch (error) {
2222
- throw Object.assign(new Error(`Upload failed: ${error.message}`), { exitCode: EXIT.NETWORK });
2223
- } finally {
2224
- progress.finish();
3055
+ };
3056
+ const requiredContentType = headers['Content-Type'] ?? headers['content-type'];
3057
+ const requiredContentLength = headers['Content-Length'] ?? headers['content-length'];
3058
+ if (requiredContentType !== contentType || requiredContentLength !== String(size)) {
3059
+ throw Object.assign(
3060
+ new Error('Upload signing response did not preserve the requested Content-Type and Content-Length headers.'),
3061
+ { exitCode: EXIT.SERVER },
3062
+ );
2225
3063
  }
2226
- if (!response.ok) {
2227
- const text = await response.text().catch(() => '');
2228
- throw Object.assign(new Error(`Upload failed: ${response.status} ${redact(text || response.statusText)}`), { exitCode: EXIT.SERVER });
3064
+
3065
+ for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
3066
+ const progress = createUploadProgress(options, size);
3067
+ const controller = new AbortController();
3068
+ let source;
3069
+ let tracker;
3070
+ const watchdog = createUploadActivityWatchdog(controller, () => {
3071
+ source?.destroy();
3072
+ tracker?.destroy();
3073
+ });
3074
+ try {
3075
+ tracker = new Transform({
3076
+ transform(chunk, encoding, callback) {
3077
+ watchdog.activity();
3078
+ progress.track(chunk);
3079
+ callback(null, chunk);
3080
+ },
3081
+ });
3082
+ source = createReadStream(filePath);
3083
+ const response = await fetch(uploadUrl, {
3084
+ method: 'PUT',
3085
+ headers,
3086
+ body: source.pipe(tracker),
3087
+ duplex: 'half',
3088
+ signal: controller.signal,
3089
+ });
3090
+ if (!response.ok) {
3091
+ const text = await response.text().catch(() => '');
3092
+ throw Object.assign(
3093
+ new Error(`Upload failed: ${response.status} ${redact(text || response.statusText)}`),
3094
+ { exitCode: EXIT.SERVER, status: response.status },
3095
+ );
3096
+ }
3097
+ return;
3098
+ } catch (error) {
3099
+ const reason = controller.signal.aborted && controller.signal.reason instanceof Error
3100
+ ? controller.signal.reason
3101
+ : error;
3102
+ const retryable = reason?.status === undefined
3103
+ || reason.status === 408
3104
+ || reason.status === 429
3105
+ || reason.status >= 500;
3106
+ if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1 || !retryable) {
3107
+ throw Object.assign(new Error(`Upload failed: ${reason.message}`), {
3108
+ exitCode: reason.exitCode || EXIT.NETWORK,
3109
+ status: reason.status,
3110
+ });
3111
+ }
3112
+ await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
3113
+ } finally {
3114
+ watchdog.stop();
3115
+ source?.destroy();
3116
+ tracker?.destroy();
3117
+ progress.finish();
3118
+ }
2229
3119
  }
2230
3120
  }
2231
3121
 
@@ -2242,28 +3132,35 @@ async function uploadAsset(config, options, filePath) {
2242
3132
  contentType,
2243
3133
  size: stat.size,
2244
3134
  kind: options.kind,
3135
+ idempotencyKey: `cli-library:${randomUUID()}`,
2245
3136
  });
2246
3137
  requireConfirm(options, 'Uploading an asset');
2247
3138
  const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
2248
- if (!signed?.uploadUrl || !signed?.key || !signed?.assetId) {
3139
+ const uploadUrl = signed?.uploadUrl || signed?.url;
3140
+ if (!uploadUrl || !signed?.intentId || !signed?.assetId) {
2249
3141
  throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
2250
3142
  }
2251
- await putSignedUpload(signed.uploadUrl, resolved, contentType, stat.size, options);
2252
- const objectPath = objectPathFromKey(signed.key);
3143
+ await putSignedUpload(uploadUrl, resolved, contentType, stat.size, options);
2253
3144
  output(await apiFetch(config, options, 'POST', `/api/v1/assets/${encodeURIComponent(signed.assetId)}/finalize`, {
2254
- objectPath,
2255
- fileSize: stat.size,
2256
- duration: options.duration === undefined ? null : numberOption(options.duration, '--duration'),
3145
+ uploadIntentId: signed.intentId,
2257
3146
  }), options);
2258
3147
  }
2259
3148
 
2260
3149
  async function assets(config, options, action, args) {
2261
3150
  if (action === 'list') {
2262
- output(await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
3151
+ const response = await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
2263
3152
  type: options.type,
2264
3153
  limit: options.limit,
2265
3154
  offset: options.offset,
2266
- })}`), options);
3155
+ })}`, undefined, { includeResponseMetadata: true });
3156
+ const items = Array.isArray(response.data) ? response.data : [];
3157
+ const headerTotal = Number(response.headers['x-total-count']);
3158
+ output({
3159
+ items,
3160
+ total: Number.isFinite(headerTotal) ? headerTotal : items.length,
3161
+ limit: numberOption(options.limit, '--limit') ?? items.length,
3162
+ offset: numberOption(options.offset, '--offset') ?? 0,
3163
+ }, options);
2267
3164
  return;
2268
3165
  }
2269
3166
  if (action === 'upload') {
@@ -2370,13 +3267,83 @@ function socialPlatformList(value) {
2370
3267
  return platforms.map((platform) => platform.toLowerCase() === 'x' ? 'twitter' : platform);
2371
3268
  }
2372
3269
 
2373
- function socialPostBody(options, scheduled) {
3270
+ function socialAccountIdPins(value) {
3271
+ if (!value) return {};
3272
+ const raw = String(value).trim();
3273
+ if (!raw) return {};
3274
+ if (raw.startsWith('{')) {
3275
+ const parsed = JSON.parse(raw);
3276
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
3277
+ throw Object.assign(new Error('--account-ids JSON must be an object of platform to account id.'), { exitCode: EXIT.USAGE });
3278
+ }
3279
+ return Object.fromEntries(Object.entries(parsed).map(([platform, accountId]) => [
3280
+ platform.toLowerCase() === 'x' ? 'twitter' : platform.toLowerCase(),
3281
+ requiredString(accountId, `account id for ${platform}`),
3282
+ ]));
3283
+ }
3284
+ return Object.fromEntries(raw.split(',').filter(Boolean).map((entry) => {
3285
+ const separator = entry.indexOf('=');
3286
+ if (separator <= 0 || separator === entry.length - 1) {
3287
+ throw Object.assign(new Error('--account-ids must use platform=accountId pairs.'), { exitCode: EXIT.USAGE });
3288
+ }
3289
+ const platform = entry.slice(0, separator).trim().toLowerCase();
3290
+ const accountId = entry.slice(separator + 1).trim();
3291
+ return [platform === 'x' ? 'twitter' : platform, accountId];
3292
+ }));
3293
+ }
3294
+
3295
+ async function socialPostBody(config, options, scheduled) {
3296
+ const clipId = requiredString(options['clip-id'], '--clip-id');
3297
+ const platforms = socialPlatformList(options.platforms);
3298
+ const caption = requiredString(options.caption, '--caption');
3299
+ const requestedExportId = options['export-id'];
3300
+ const deliveryState = await fetchCanonicalClipDeliveryState(
3301
+ config,
3302
+ options,
3303
+ clipId,
3304
+ requestedExportId,
3305
+ );
3306
+ const editorState = requireVerifiedEditorState(deliveryState, clipId);
3307
+ const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
3308
+ requestedExportId,
3309
+ requireReadyToPublish: true,
3310
+ });
3311
+ const accountsResponse = await apiFetch(config, options, 'GET', '/api/v1/social/accounts');
3312
+ const connectedAccounts = Array.isArray(accountsResponse?.accounts)
3313
+ ? accountsResponse.accounts.filter((account) => account?.connected && typeof account?.accountId === 'string')
3314
+ : [];
3315
+ const requestedAccountIds = socialAccountIdPins(options['account-ids']);
3316
+ const expectedAccountIds = Object.fromEntries(platforms.map((platform) => {
3317
+ const choices = Array.from(new Set(connectedAccounts
3318
+ .filter((account) => account.platform === platform)
3319
+ .map((account) => account.accountId)));
3320
+ const requestedAccountId = requestedAccountIds[platform];
3321
+ if (requestedAccountId) {
3322
+ if (!choices.includes(requestedAccountId)) {
3323
+ throw Object.assign(new Error(`The selected ${platform} account id is not connected.`), { exitCode: EXIT.USAGE });
3324
+ }
3325
+ return [platform, requestedAccountId];
3326
+ }
3327
+ if (choices.length !== 1) {
3328
+ const reason = choices.length === 0 ? 'no connected account' : 'multiple connected accounts';
3329
+ throw Object.assign(
3330
+ new Error(`${platform} has ${reason}. Run "clipit social accounts --json" and pass --account-ids ${platform}=<accountId>.`),
3331
+ { exitCode: EXIT.USAGE },
3332
+ );
3333
+ }
3334
+ return [platform, choices[0]];
3335
+ }));
2374
3336
  const body = compactObject({
2375
- clipId: requiredString(options['clip-id'], '--clip-id'),
2376
- platforms: socialPlatformList(options.platforms),
2377
- caption: requiredString(options.caption, '--caption'),
3337
+ clipId,
3338
+ platforms,
3339
+ caption,
2378
3340
  title: options.title,
2379
3341
  hashtags: stringList(options.hashtags),
3342
+ exportId: selectedExport.exportId,
3343
+ expectedSnapshotId: editorState.snapshotId,
3344
+ expectedOutputObjectFingerprint: selectedExport.outputObjectFingerprint,
3345
+ expectedAccountIds,
3346
+ publishExactCurrentArtifact: true,
2380
3347
  });
2381
3348
  if (scheduled) body.scheduledFor = requiredString(options.at, '--at');
2382
3349
  return body;
@@ -2388,14 +3355,14 @@ async function social(config, options, action, args) {
2388
3355
  return;
2389
3356
  }
2390
3357
  if (action === 'post') {
2391
- const body = socialPostBody(options, false);
3358
+ const body = await socialPostBody(config, options, false);
2392
3359
  await enforceMaxCredits(config, options, 'social post', { body });
2393
3360
  confirmPaid(options, 'Publishing a social post');
2394
3361
  output(await apiFetch(config, options, 'POST', '/api/v1/social/post', body), options);
2395
3362
  return;
2396
3363
  }
2397
3364
  if (action === 'schedule') {
2398
- const body = socialPostBody(options, true);
3365
+ const body = await socialPostBody(config, options, true);
2399
3366
  await enforceMaxCredits(config, options, 'social schedule', { body });
2400
3367
  confirmPaid(options, 'Scheduling a social post');
2401
3368
  output(await apiFetch(config, options, 'POST', '/api/v1/social/schedule', body), options);
@@ -2437,6 +3404,19 @@ async function jobs(config, options, action, args) {
2437
3404
  while (true) {
2438
3405
  const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
2439
3406
  if (action === 'get' || TERMINAL_JOB_STATUSES.has(job.status)) {
3407
+ const videoId = job.videoId || job.result?.videoId;
3408
+ const clipId = job.clipId || job.result?.clipId;
3409
+ if (videoId || clipId) {
3410
+ await persistActiveContext(
3411
+ config,
3412
+ options,
3413
+ compactObject({ videoId, clipId }),
3414
+ [
3415
+ videoId ? { type: 'video', id: videoId } : null,
3416
+ clipId ? { type: 'clip', id: clipId } : null,
3417
+ ].filter(Boolean),
3418
+ );
3419
+ }
2440
3420
  output(job, options);
2441
3421
  return;
2442
3422
  }
@@ -3029,7 +4009,7 @@ async function examples(options) {
3029
4009
  uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
3030
4010
  thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
3031
4011
  brollPlan: 'clipit broll plan <clipId> --count 3 --confirm --json',
3032
- socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json',
4012
+ socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json',
3033
4013
  runTool: 'clipit run <functionName> --clip-id <clipId> --params @params.json --json',
3034
4014
  reviewLink: 'clipit links clip <clipId> --json',
3035
4015
  };
@@ -3062,6 +4042,7 @@ Rules:
3062
4042
  - Credits from subscriptions and top-ups stack; paid features are credit-gated, so verify the payment receipt or balance before running paid ClipIt tools.
3063
4043
  - Use \`clipit open clip <id>\` when the user should review work in ClipIt.
3064
4044
  - Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
4045
+ - 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.
3065
4046
  - Do not write API keys into this skill file or any project files.
3066
4047
 
3067
4048
  Useful commands:
@@ -3095,7 +4076,7 @@ clipit billing receipt <attemptId> --json
3095
4076
  clipit analytics overview --days 30 --json
3096
4077
  clipit exports start --clip-id <clipId> --confirm --json
3097
4078
  clipit thumbnails generate --clip-id <clipId> --prompt "High contrast thumbnail" --confirm --json
3098
- clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json
4079
+ clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json
3099
4080
  clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
3100
4081
  \`\`\`
3101
4082
 
@@ -3377,6 +4358,7 @@ async function main() {
3377
4358
  if (command === 'auth' && subcommand === 'set-key') return setKey(config, options);
3378
4359
  if (command === 'auth' && subcommand === 'open-settings') return openCommand(config, options, 'settings');
3379
4360
  if (command === 'auth' && subcommand === 'profiles') return listProfiles(config, options);
4361
+ if (command === 'auth' && subcommand === 'use') return useProfile(config, options, rest[0]);
3380
4362
  if (command === 'context') return contextCommand(config, options, subcommand);
3381
4363
  if (command === 'skills' && subcommand === 'list') return listSkills(config, options);
3382
4364
  if (command === 'tools' && subcommand === 'list') return listTools(config, options);
@@ -3416,6 +4398,9 @@ function handleMainError(error) {
3416
4398
  }), null, 2));
3417
4399
  } else {
3418
4400
  console.error(message);
4401
+ const details = redactDeep(error.data);
4402
+ if (details?.resumeCommand) console.error(`Resume with: ${details.resumeCommand}`);
4403
+ if (details?.abortCommand) console.error(`Abort the prior upload with: ${details.abortCommand}`);
3419
4404
  }
3420
4405
  process.exit(exitCode);
3421
4406
  }
@@ -3438,4 +4423,18 @@ if (await isDirectRun()) {
3438
4423
  main().catch(handleMainError);
3439
4424
  }
3440
4425
 
3441
- export { main, redact, clipCostLabel, handleMcpRequest };
4426
+ export {
4427
+ buildCanonicalExportStartBody,
4428
+ main,
4429
+ redact,
4430
+ clipCostLabel,
4431
+ requireExactCurrentExport,
4432
+ requireVerifiedEditorState,
4433
+ handleMcpRequest,
4434
+ readPositiveIntegerEnv,
4435
+ readSecretLine,
4436
+ readVideoUploadResumes,
4437
+ removeVideoUploadResume,
4438
+ saveVideoUploadResume,
4439
+ shellQuote,
4440
+ };