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