@clipit-ai/cli 0.2.4 → 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 +24 -6
  3. package/bin/clipit.mjs +1088 -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.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];
@@ -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|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'));
@@ -1294,7 +1469,7 @@ async function setKey(config, options) {
1294
1469
  if (!options.stdin) {
1295
1470
  throw Object.assign(new Error('Use --stdin to avoid shell history leaks.'), { exitCode: EXIT.USAGE });
1296
1471
  }
1297
- const apiKey = (await readStdin()).trim();
1472
+ const apiKey = (await readSecretLine()).trim();
1298
1473
  if (!apiKey) {
1299
1474
  throw Object.assign(new Error('No API key received on stdin.'), { exitCode: EXIT.USAGE });
1300
1475
  }
@@ -1303,17 +1478,50 @@ async function setKey(config, options) {
1303
1478
  apiKey,
1304
1479
  loginSource: 'manual',
1305
1480
  });
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);
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);
1309
1491
  }
1310
1492
 
1311
1493
  async function logout(config, options) {
1312
- const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'loginSource']);
1494
+ const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'scope', 'loginSource']);
1313
1495
  await writeConfig(next);
1314
1496
  output({ success: true, message: 'Local ClipIt CLI credentials removed', profile: profileName(config, options) }, options);
1315
1497
  }
1316
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
+
1317
1525
  async function listProfiles(config, options) {
1318
1526
  const profiles = config.profiles || {};
1319
1527
  const names = [...new Set(['default', ...Object.keys(profiles)])];
@@ -1329,6 +1537,7 @@ async function listProfiles(config, options) {
1329
1537
  hasCredential: Boolean(data.apiKey),
1330
1538
  loginSource: data.loginSource || null,
1331
1539
  keyName: data.keyInfo?.keyName || null,
1540
+ scope: data.scope || null,
1332
1541
  updatedAt: data.updatedAt || null,
1333
1542
  };
1334
1543
  }),
@@ -1336,6 +1545,7 @@ async function listProfiles(config, options) {
1336
1545
  }
1337
1546
 
1338
1547
  async function doctor(config, options) {
1548
+ const credential = resolveApiCredential(config, options);
1339
1549
  const checks = {
1340
1550
  version: VERSION,
1341
1551
  node: process.version,
@@ -1343,8 +1553,8 @@ async function doctor(config, options) {
1343
1553
  configPath: configPath(),
1344
1554
  profile: profileName(config, options),
1345
1555
  baseUrl: getBaseUrl(config, options),
1346
- hasCredential: Boolean(getApiKey(config, options)),
1347
- credentialSource: process.env.CLIPPER_API_KEY ? 'env' : getApiKey(config, options) ? 'config' : 'missing',
1556
+ hasCredential: Boolean(credential.apiKey),
1557
+ credentialSource: credential.source,
1348
1558
  auth: null,
1349
1559
  };
1350
1560
  try {
@@ -1650,11 +1860,33 @@ async function pollWorkflow(config, options, jobId) {
1650
1860
  }
1651
1861
  }
1652
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
+
1653
1880
  async function askWorkflow(config, options, promptParts) {
1654
1881
  const userMessage = promptParts.filter((part) => part !== undefined).join(' ').trim();
1655
1882
  if (!userMessage) throw Object.assign(new Error('Prompt is required.'), { exitCode: EXIT.USAGE });
1656
1883
 
1657
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
+ }
1658
1890
  const payload = { userMessage };
1659
1891
  for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
1660
1892
  if (context[field]) payload[field] = context[field];
@@ -1733,16 +1965,19 @@ function mimeForPath(filePath) {
1733
1965
  if (ext === '.webm') return 'video/webm';
1734
1966
  if (ext === '.mkv') return 'video/x-matroska';
1735
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';
1736
1975
  if (ext === '.mp3') return 'audio/mpeg';
1737
1976
  if (ext === '.m4a') return 'audio/mp4';
1738
1977
  if (ext === '.wav') return 'audio/wav';
1739
1978
  return 'video/mp4';
1740
1979
  }
1741
1980
 
1742
- function multipartFilename(value) {
1743
- return String(value).replace(/["\r\n]/g, '_');
1744
- }
1745
-
1746
1981
  function shouldReportUploadProgress(options) {
1747
1982
  return Boolean(process.stderr.isTTY) && !wantJson(options);
1748
1983
  }
@@ -1751,24 +1986,6 @@ function formatMb(bytes) {
1751
1986
  return (bytes / (1024 * 1024)).toFixed(1);
1752
1987
  }
1753
1988
 
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
1989
  function createUploadProgress(options, totalBytes) {
1773
1990
  if (!shouldReportUploadProgress(options) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
1774
1991
  return { track() {}, finish() {} };
@@ -1797,15 +2014,259 @@ function createUploadProgress(options, totalBytes) {
1797
2014
  };
1798
2015
  }
1799
2016
 
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`,
2017
+ const VIDEO_UPLOAD_RESUME_MAX_AGE_MS = 25 * 60 * 60 * 1000;
2018
+
2019
+ function videoUploadResumeDirectory() {
2020
+ return path.join(configDir(), 'video-upload-resumes');
2021
+ }
2022
+
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
+ }
2053
+ }
2054
+
2055
+ const legacyPath = path.join(configDir(), 'video-upload-resume.json');
2056
+ try {
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);
2187
+ },
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)}`,
1803
2225
  );
1804
- for await (const chunk of createReadStream(filePath)) {
1805
- progress?.track(chunk);
1806
- yield chunk;
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
+ }
2267
+ } finally {
2268
+ progress.finish();
1807
2269
  }
1808
- yield Buffer.from(`\r\n--${boundary}--\r\n`);
1809
2270
  }
1810
2271
 
1811
2272
  async function uploadVideo(config, options, filePath) {
@@ -1815,26 +2276,147 @@ async function uploadVideo(config, options, filePath) {
1815
2276
  if (!stat.isFile()) {
1816
2277
  throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
1817
2278
  }
1818
- assertDirectVideoUploadSize(resolved, stat.size);
1819
- const filename = options.filename || path.basename(resolved);
2279
+ const filename = String(options.filename || path.basename(resolved));
1820
2280
  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`,
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),
1824
2287
  );
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);
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
+ }
1828
2329
  await enforceMaxCredits(config, options, 'videos upload', { bytes: stat.size });
1829
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;
1830
2339
  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
- },
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,
1837
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);
1838
2420
  if (result?.videoId) {
1839
2421
  await persistActiveContext(
1840
2422
  config,
@@ -1844,8 +2426,42 @@ async function uploadVideo(config, options, filePath) {
1844
2426
  );
1845
2427
  }
1846
2428
  output(result, options);
1847
- } finally {
1848
- progress.finish();
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;
1849
2465
  }
1850
2466
  }
1851
2467
 
@@ -1866,13 +2482,30 @@ async function videos(config, options, action, args) {
1866
2482
  if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
1867
2483
  await enforceMaxCredits(config, options, 'videos import-url', { url });
1868
2484
  confirmPaid(options, 'Importing a video from URL');
1869
- output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', { url, title: options.title }), options);
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);
1870
2490
  return;
1871
2491
  }
1872
2492
  if (action === 'upload') {
1873
2493
  await uploadVideo(config, options, args[0] || options.file);
1874
2494
  return;
1875
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
+ }
1876
2509
  if (action === 'transcribe') {
1877
2510
  if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
1878
2511
  await enforceMaxCredits(config, options, 'videos transcribe', { videoId: args[0] });
@@ -1911,6 +2544,119 @@ async function videos(config, options, action, args) {
1911
2544
  throw Object.assign(new Error(`Unknown videos command: ${action || ''}`), { exitCode: EXIT.USAGE });
1912
2545
  }
1913
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
+
1914
2660
  async function clips(config, options, action, args) {
1915
2661
  if (action === 'list') {
1916
2662
  output(await apiFetch(config, options, 'GET', `/api/v1/clips${queryString({
@@ -1927,6 +2673,19 @@ async function clips(config, options, action, args) {
1927
2673
  output(result, options);
1928
2674
  return;
1929
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
+ }
1930
2689
  if (action === 'create') {
1931
2690
  const body = options.params
1932
2691
  ? await readJsonOption(String(options.params))
@@ -1994,9 +2753,34 @@ async function clips(config, options, action, args) {
1994
2753
  }
1995
2754
  if (action === 'download') {
1996
2755
  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`);
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
+ );
1998
2774
  if (options.open && result?.downloadUrl) openBrowser(result.downloadUrl);
1999
- 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);
2000
2784
  return;
2001
2785
  }
2002
2786
  if (action === 'delete') {
@@ -2139,6 +2923,17 @@ function defaultExportStartBody(clipId) {
2139
2923
  };
2140
2924
  }
2141
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
+
2142
2937
  async function pollExport(config, options, jobId) {
2143
2938
  const startedAt = Date.now();
2144
2939
  const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
@@ -2161,14 +2956,32 @@ async function exportsCommand(config, options, action, args) {
2161
2956
  if (action === 'start') {
2162
2957
  const clipId = requiredString(options['clip-id'], '--clip-id');
2163
2958
  const params = options.params ? await readJsonOption(String(options.params)) : {};
2164
- const body = {
2165
- ...defaultExportStartBody(clipId),
2166
- ...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(
2167
2969
  clipId,
2168
- };
2970
+ params,
2971
+ editorState,
2972
+ idempotencyKey,
2973
+ );
2169
2974
  await enforceMaxCredits(config, options, 'exports start', { clipId, body });
2170
2975
  confirmPaid(options, 'Starting an export');
2171
- 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);
2172
2985
  return;
2173
2986
  }
2174
2987
  if (action === 'list') {
@@ -2200,32 +3013,76 @@ async function exportsCommand(config, options, action, args) {
2200
3013
  throw Object.assign(new Error(`Unknown exports command: ${action || ''}`), { exitCode: EXIT.USAGE });
2201
3014
  }
2202
3015
 
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: {
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
+ : {
2215
3020
  'Content-Type': contentType,
2216
3021
  '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();
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
+ );
2225
3030
  }
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 });
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
+ }
2229
3086
  }
2230
3087
  }
2231
3088
 
@@ -2242,28 +3099,35 @@ async function uploadAsset(config, options, filePath) {
2242
3099
  contentType,
2243
3100
  size: stat.size,
2244
3101
  kind: options.kind,
3102
+ idempotencyKey: `cli-library:${randomUUID()}`,
2245
3103
  });
2246
3104
  requireConfirm(options, 'Uploading an asset');
2247
3105
  const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
2248
- if (!signed?.uploadUrl || !signed?.key || !signed?.assetId) {
3106
+ const uploadUrl = signed?.uploadUrl || signed?.url;
3107
+ if (!uploadUrl || !signed?.intentId || !signed?.assetId) {
2249
3108
  throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
2250
3109
  }
2251
- await putSignedUpload(signed.uploadUrl, resolved, contentType, stat.size, options);
2252
- const objectPath = objectPathFromKey(signed.key);
3110
+ await putSignedUpload(uploadUrl, resolved, contentType, stat.size, options);
2253
3111
  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'),
3112
+ uploadIntentId: signed.intentId,
2257
3113
  }), options);
2258
3114
  }
2259
3115
 
2260
3116
  async function assets(config, options, action, args) {
2261
3117
  if (action === 'list') {
2262
- output(await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
3118
+ const response = await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
2263
3119
  type: options.type,
2264
3120
  limit: options.limit,
2265
3121
  offset: options.offset,
2266
- })}`), 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);
2267
3131
  return;
2268
3132
  }
2269
3133
  if (action === 'upload') {
@@ -2370,13 +3234,83 @@ function socialPlatformList(value) {
2370
3234
  return platforms.map((platform) => platform.toLowerCase() === 'x' ? 'twitter' : platform);
2371
3235
  }
2372
3236
 
2373
- 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
+ }));
2374
3303
  const body = compactObject({
2375
- clipId: requiredString(options['clip-id'], '--clip-id'),
2376
- platforms: socialPlatformList(options.platforms),
2377
- caption: requiredString(options.caption, '--caption'),
3304
+ clipId,
3305
+ platforms,
3306
+ caption,
2378
3307
  title: options.title,
2379
3308
  hashtags: stringList(options.hashtags),
3309
+ exportId: selectedExport.exportId,
3310
+ expectedSnapshotId: editorState.snapshotId,
3311
+ expectedOutputObjectFingerprint: selectedExport.outputObjectFingerprint,
3312
+ expectedAccountIds,
3313
+ publishExactCurrentArtifact: true,
2380
3314
  });
2381
3315
  if (scheduled) body.scheduledFor = requiredString(options.at, '--at');
2382
3316
  return body;
@@ -2388,14 +3322,14 @@ async function social(config, options, action, args) {
2388
3322
  return;
2389
3323
  }
2390
3324
  if (action === 'post') {
2391
- const body = socialPostBody(options, false);
3325
+ const body = await socialPostBody(config, options, false);
2392
3326
  await enforceMaxCredits(config, options, 'social post', { body });
2393
3327
  confirmPaid(options, 'Publishing a social post');
2394
3328
  output(await apiFetch(config, options, 'POST', '/api/v1/social/post', body), options);
2395
3329
  return;
2396
3330
  }
2397
3331
  if (action === 'schedule') {
2398
- const body = socialPostBody(options, true);
3332
+ const body = await socialPostBody(config, options, true);
2399
3333
  await enforceMaxCredits(config, options, 'social schedule', { body });
2400
3334
  confirmPaid(options, 'Scheduling a social post');
2401
3335
  output(await apiFetch(config, options, 'POST', '/api/v1/social/schedule', body), options);
@@ -2437,6 +3371,19 @@ async function jobs(config, options, action, args) {
2437
3371
  while (true) {
2438
3372
  const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
2439
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
+ }
2440
3387
  output(job, options);
2441
3388
  return;
2442
3389
  }
@@ -3029,7 +3976,7 @@ async function examples(options) {
3029
3976
  uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
3030
3977
  thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
3031
3978
  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',
3979
+ socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json',
3033
3980
  runTool: 'clipit run <functionName> --clip-id <clipId> --params @params.json --json',
3034
3981
  reviewLink: 'clipit links clip <clipId> --json',
3035
3982
  };
@@ -3062,6 +4009,7 @@ Rules:
3062
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.
3063
4010
  - Use \`clipit open clip <id>\` when the user should review work in ClipIt.
3064
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.
3065
4013
  - Do not write API keys into this skill file or any project files.
3066
4014
 
3067
4015
  Useful commands:
@@ -3095,7 +4043,7 @@ clipit billing receipt <attemptId> --json
3095
4043
  clipit analytics overview --days 30 --json
3096
4044
  clipit exports start --clip-id <clipId> --confirm --json
3097
4045
  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
4046
+ clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json
3099
4047
  clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
3100
4048
  \`\`\`
3101
4049
 
@@ -3377,6 +4325,7 @@ async function main() {
3377
4325
  if (command === 'auth' && subcommand === 'set-key') return setKey(config, options);
3378
4326
  if (command === 'auth' && subcommand === 'open-settings') return openCommand(config, options, 'settings');
3379
4327
  if (command === 'auth' && subcommand === 'profiles') return listProfiles(config, options);
4328
+ if (command === 'auth' && subcommand === 'use') return useProfile(config, options, rest[0]);
3380
4329
  if (command === 'context') return contextCommand(config, options, subcommand);
3381
4330
  if (command === 'skills' && subcommand === 'list') return listSkills(config, options);
3382
4331
  if (command === 'tools' && subcommand === 'list') return listTools(config, options);
@@ -3416,6 +4365,9 @@ function handleMainError(error) {
3416
4365
  }), null, 2));
3417
4366
  } else {
3418
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}`);
3419
4371
  }
3420
4372
  process.exit(exitCode);
3421
4373
  }
@@ -3438,4 +4390,18 @@ if (await isDirectRun()) {
3438
4390
  main().catch(handleMainError);
3439
4391
  }
3440
4392
 
3441
- export { main, redact, clipCostLabel, handleMcpRequest };
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
+ };