@clipit-ai/cli 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- package/bin/clipit.mjs +1265 -62
- package/package.json +1 -1
package/bin/clipit.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import process from 'node:process';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
|
|
12
|
-
const VERSION = '0.2.
|
|
12
|
+
const VERSION = '0.2.4';
|
|
13
13
|
const DEFAULT_BASE_URL = 'https://clipit.dev';
|
|
14
14
|
const DEFAULT_SCOPES = [
|
|
15
15
|
'clippy_agent',
|
|
@@ -30,6 +30,8 @@ const RETRY_AFTER_CAP_MS = 10_000;
|
|
|
30
30
|
const RETRYABLE_GET_STATUSES = new Set([429, 502, 503, 504]);
|
|
31
31
|
const RECENT_LIMIT = 20;
|
|
32
32
|
const REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND = 0.0015;
|
|
33
|
+
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
|
34
|
+
const DIRECT_VIDEO_UPLOAD_MAX_BYTES = readPositiveIntegerEnv('CLIPIT_CLI_DIRECT_UPLOAD_MAX_BYTES', 100 * 1024 * 1024);
|
|
33
35
|
const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
|
|
34
36
|
'exports start': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
|
|
35
37
|
'thumbnails generate': [{ operationType: 'thumbnail_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
|
|
@@ -38,12 +40,151 @@ const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
|
|
|
38
40
|
{ operationType: 'image_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'broll-images' },
|
|
39
41
|
{ operationType: 'video_generation', provider: 'replicate', modelId: 'alibaba/happyhorse-1.0', metrics: 'broll-video' },
|
|
40
42
|
],
|
|
43
|
+
'clips create': [{ operationType: 'clip', provider: 'deepgram', metrics: 'clip-create' }],
|
|
41
44
|
'clips render': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
|
|
45
|
+
'videos upload': [{ operationType: 'video_storage', provider: 'railway_s3', metrics: 'video-upload-storage' }],
|
|
42
46
|
'videos import-url': [{ operationType: null, provider: null, metrics: 'url-import' }],
|
|
47
|
+
'videos transcribe': [{ operationType: 'transcription', provider: 'deepgram', modelId: 'nova-3', metrics: 'transcription-video' }],
|
|
48
|
+
'videos suggest-clips': [{ operationType: 'ai_chat', provider: 'openrouter', modelId: 'x-ai/grok-4.20-beta', metrics: 'suggest-clips' }],
|
|
43
49
|
'social post': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
|
|
44
50
|
'social schedule': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
|
|
45
51
|
});
|
|
46
52
|
|
|
53
|
+
const REMOTION_RUN_ESTIMATE = MAX_CREDITS_ESTIMATE_MAP['clips render'];
|
|
54
|
+
|
|
55
|
+
const RUN_MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
|
|
56
|
+
planBRoll: MAX_CREDITS_ESTIMATE_MAP['broll plan'],
|
|
57
|
+
createThumbnail: MAX_CREDITS_ESTIMATE_MAP['thumbnails generate'],
|
|
58
|
+
generateThumbnails: MAX_CREDITS_ESTIMATE_MAP['thumbnails generate'],
|
|
59
|
+
generateImage: [{ operationType: 'image_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
|
|
60
|
+
generateBRoll: MAX_CREDITS_ESTIMATE_MAP['broll generate'],
|
|
61
|
+
generateVoiceover: [{ operationType: 'tts_generation', provider: 'replicate', modelId: 'google/gemini-3.1-flash-tts', metrics: 'tts-provider-cost' }],
|
|
62
|
+
generateMusicBed: [{ operationType: 'music_generation', provider: 'replicate', modelId: 'minimax/music-2.5', metrics: 'one-generation' }],
|
|
63
|
+
generateVideoAlter: [{ operationType: 'video_alter_generation', provider: 'replicate', modelId: 'kwaivgi/kling-v3-omni-video', metrics: 'video-alter-provider-cost' }],
|
|
64
|
+
generatePlatformCaption: [{ operationType: 'ai_chat', provider: 'openrouter', modelId: 'anthropic/claude-opus-4.7', metrics: 'platform-caption' }],
|
|
65
|
+
applyHookToFront: REMOTION_RUN_ESTIMATE,
|
|
66
|
+
addLibraryAssetToClip: REMOTION_RUN_ESTIMATE,
|
|
67
|
+
renderClipWithRemotion: REMOTION_RUN_ESTIMATE,
|
|
68
|
+
updateClipBounds: REMOTION_RUN_ESTIMATE,
|
|
69
|
+
setCropPosition: REMOTION_RUN_ESTIMATE,
|
|
70
|
+
applyRemotionCaptionPreset: REMOTION_RUN_ESTIMATE,
|
|
71
|
+
setCaptionAnimation: REMOTION_RUN_ESTIMATE,
|
|
72
|
+
setClipAspectRatio: REMOTION_RUN_ESTIMATE,
|
|
73
|
+
setCropLayout: REMOTION_RUN_ESTIMATE,
|
|
74
|
+
setTimedCropLayouts: REMOTION_RUN_ESTIMATE,
|
|
75
|
+
applyCropPreset: REMOTION_RUN_ESTIMATE,
|
|
76
|
+
setWatermark: REMOTION_RUN_ESTIMATE,
|
|
77
|
+
applyTextOverlayToClip: REMOTION_RUN_ESTIMATE,
|
|
78
|
+
applyBRoll: REMOTION_RUN_ESTIMATE,
|
|
79
|
+
removeBRoll: REMOTION_RUN_ESTIMATE,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const RUN_CONFIRMATION_LABELS = Object.freeze({
|
|
83
|
+
renderClipWithRemotion: 'Rendering a clip',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const RUN_METERED_CONFIRMATION_EXEMPTIONS = new Set([
|
|
87
|
+
'planBRoll',
|
|
88
|
+
'planVideoAlter',
|
|
89
|
+
'planVoiceover',
|
|
90
|
+
'planMusicBed',
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const CLIP_IDS_CONTEXT_TOOLS = new Set([
|
|
94
|
+
'runDeliveryReadinessQA',
|
|
95
|
+
'runBlueprintExportQA',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const BILLING_PROVIDER_PREFERENCES = new Set(['x402_direct', 'stripe_mpp', 'stripe_x402']);
|
|
99
|
+
|
|
100
|
+
const LOCAL_MCP_BILLING_TOOLS = Object.freeze([
|
|
101
|
+
{
|
|
102
|
+
name: 'getPaymentCapabilities',
|
|
103
|
+
description: 'Discover ClipIt machine-payment capabilities, agent connection paths, safety policy, and direct x402, Stripe x402, or Stripe MPP rail readiness.',
|
|
104
|
+
costBand: 'free',
|
|
105
|
+
skill: 'billing',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {},
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'getBillingCatalog',
|
|
114
|
+
description: 'List ClipIt catalog products, prices, credit amounts, and enabled machine-payment rails.',
|
|
115
|
+
costBand: 'free',
|
|
116
|
+
skill: 'billing',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {},
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'createPaymentAttempt',
|
|
125
|
+
description: 'Create an API-key-owned machine-payment attempt and return the direct x402, Stripe x402, or Stripe MPP payment URL. This prepares a payment but does not sign a wallet transaction or supply a Stripe SPT credential.',
|
|
126
|
+
costBand: 'free',
|
|
127
|
+
skill: 'billing',
|
|
128
|
+
requiresConfirmation: true,
|
|
129
|
+
confirmation: {
|
|
130
|
+
required: true,
|
|
131
|
+
riskLevel: 'costly',
|
|
132
|
+
reason: 'Creates a payable billing attempt. Continue only after the human approved the product, amount, rail, and budget policy.',
|
|
133
|
+
actionLabel: 'create machine-payment attempt',
|
|
134
|
+
allowAutonomous: false,
|
|
135
|
+
},
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
productKey: { type: 'string' },
|
|
140
|
+
providerPreference: { type: 'string', enum: ['x402_direct', 'stripe_mpp', 'stripe_x402'] },
|
|
141
|
+
idempotencyKey: { type: 'string' },
|
|
142
|
+
},
|
|
143
|
+
required: ['productKey'],
|
|
144
|
+
additionalProperties: false,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'getPaymentAttempt',
|
|
149
|
+
description: 'Get the status of an API-key-owned machine-payment attempt.',
|
|
150
|
+
costBand: 'free',
|
|
151
|
+
skill: 'billing',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
attemptId: { type: 'string' },
|
|
156
|
+
},
|
|
157
|
+
required: ['attemptId'],
|
|
158
|
+
additionalProperties: false,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'getPaymentReceipt',
|
|
163
|
+
description: 'Get the receipt and fulfillment records for an API-key-owned machine-payment attempt.',
|
|
164
|
+
costBand: 'free',
|
|
165
|
+
skill: 'billing',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
attemptId: { type: 'string' },
|
|
170
|
+
},
|
|
171
|
+
required: ['attemptId'],
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'getBillingSubscription',
|
|
177
|
+
description: 'Get the effective ClipIt billing access source for the API key owner.',
|
|
178
|
+
costBand: 'free',
|
|
179
|
+
skill: 'billing',
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {},
|
|
183
|
+
additionalProperties: false,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
]);
|
|
187
|
+
|
|
47
188
|
const EXIT = {
|
|
48
189
|
OK: 0,
|
|
49
190
|
USAGE: 2,
|
|
@@ -61,6 +202,11 @@ const KNOWN_AGENT_TARGETS = ['codex', 'claude', 'hermes', 'generic'];
|
|
|
61
202
|
const TRUSTED_HOSTS = new Set(['clipit.dev', 'www.clipit.dev', 'localhost', '127.0.0.1', '::1', '[::1]']);
|
|
62
203
|
const AGENT_SKILL_META_FILENAME = 'SKILL.meta.json';
|
|
63
204
|
|
|
205
|
+
function readPositiveIntegerEnv(name, fallback) {
|
|
206
|
+
const parsed = Number(process.env[name]);
|
|
207
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
208
|
+
}
|
|
209
|
+
|
|
64
210
|
function configDir() {
|
|
65
211
|
if (process.env.CLIPIT_CONFIG_DIR) return process.env.CLIPIT_CONFIG_DIR;
|
|
66
212
|
if (process.platform === 'win32' && process.env.APPDATA) return path.join(process.env.APPDATA, 'ClipIt');
|
|
@@ -146,6 +292,32 @@ function appendRecentEntries(config, options, entries) {
|
|
|
146
292
|
return entries.reduce((nextConfig, entry) => appendRecentEntry(nextConfig, options, entry.type, entry.id), config);
|
|
147
293
|
}
|
|
148
294
|
|
|
295
|
+
function withProfileActiveContext(config, options, activeContext) {
|
|
296
|
+
const name = profileName(config, options);
|
|
297
|
+
const profiles = { ...(config.profiles || {}) };
|
|
298
|
+
profiles[name] = {
|
|
299
|
+
...profileData(config, options),
|
|
300
|
+
activeContext,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const next = {
|
|
304
|
+
...config,
|
|
305
|
+
profiles,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (name === 'default') {
|
|
309
|
+
next.activeContext = activeContext;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return next;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function persistActiveContext(config, options, activeContext, recent = []) {
|
|
316
|
+
let next = updateProfile(config, options, { activeContext: compactObject({ ...activeContext }) });
|
|
317
|
+
next = appendRecentEntries(next, options, recent);
|
|
318
|
+
await writeConfig(next);
|
|
319
|
+
}
|
|
320
|
+
|
|
149
321
|
function removeProfileFields(config, options, fields) {
|
|
150
322
|
const name = profileName(config, options);
|
|
151
323
|
const profiles = { ...(config.profiles || {}) };
|
|
@@ -201,21 +373,26 @@ function wantJson(options) {
|
|
|
201
373
|
function redact(value) {
|
|
202
374
|
if (typeof value !== 'string') return value;
|
|
203
375
|
return value
|
|
376
|
+
.replace(/https?:\/\/(?=[^\s"'<>]*[?&](?:X-Amz-|Signature=|token=|key=))[^\s"'<>]+/gi, '[signed-url-redacted]')
|
|
377
|
+
.replace(/https?:\/\/replicate\.delivery\/[^\s"'<>]+/gi, '[generated-media-url-redacted]')
|
|
378
|
+
.replace(/https?:\/\/stream\.replicate\.com\/v1\/files\/[^\s"'<>]+/gi, '[generated-media-url-redacted]')
|
|
379
|
+
.replace(/(^|[\s"'=:(,])\/objects\/[^\s"'<>),]+/g, '$1[object-url-redacted]')
|
|
204
380
|
.replace(/clipper_[a-f0-9]{24,}/gi, 'clipper_[redacted]')
|
|
205
381
|
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]')
|
|
206
|
-
.replace(/([?&](?:X-Amz-Signature|
|
|
382
|
+
.replace(/([?&](?:X-Amz-[A-Za-z-]+|Signature|token|key)=)[^&\s]+/gi, '$1[redacted]');
|
|
207
383
|
}
|
|
208
384
|
|
|
209
385
|
function output(data, options) {
|
|
386
|
+
const safeData = redactDeep(data);
|
|
210
387
|
if (wantJson(options)) {
|
|
211
|
-
console.log(JSON.stringify(
|
|
388
|
+
console.log(JSON.stringify(safeData, null, 2));
|
|
212
389
|
return;
|
|
213
390
|
}
|
|
214
|
-
if (typeof
|
|
215
|
-
console.log(
|
|
391
|
+
if (typeof safeData === 'string') {
|
|
392
|
+
console.log(safeData);
|
|
216
393
|
return;
|
|
217
394
|
}
|
|
218
|
-
console.log(JSON.stringify(
|
|
395
|
+
console.log(JSON.stringify(safeData, null, 2));
|
|
219
396
|
}
|
|
220
397
|
|
|
221
398
|
function redactDeep(value) {
|
|
@@ -227,6 +404,10 @@ function redactDeep(value) {
|
|
|
227
404
|
return value;
|
|
228
405
|
}
|
|
229
406
|
|
|
407
|
+
function hashText(value) {
|
|
408
|
+
return createHash('sha256').update(String(value)).digest('hex');
|
|
409
|
+
}
|
|
410
|
+
|
|
230
411
|
function usage() {
|
|
231
412
|
return [
|
|
232
413
|
'ClipIt CLI',
|
|
@@ -245,14 +426,16 @@ function usage() {
|
|
|
245
426
|
' clipit skills list [--json]',
|
|
246
427
|
' clipit tools list [--skill clip] [--json]',
|
|
247
428
|
' clipit tools describe <functionName>',
|
|
248
|
-
' clipit ask "<prompt>" [--video-id id] [--clip-id id] [--conversation-id id] [--quick] [--stream] [--json]',
|
|
429
|
+
' clipit ask "<prompt>" [--video-id id] [--clip-id id] [--conversation-id id] [--quick] [--auto-confirm-costly] [--stream] [--json]',
|
|
249
430
|
' clipit workflow status|wait <jobId> [--stream] [--json]',
|
|
250
431
|
' clipit workflow approve <jobId> --approval-id id [--decision approved|cheaper|cancelled]',
|
|
432
|
+
' clipit mcp [stdio]',
|
|
251
433
|
' clipit run <functionName> [--params @file.json] [--clip-id id] [--video-id id] [--confirm] [--max-credits n]',
|
|
252
434
|
' clipit videos list|get|upload|import-url|transcribe|transcript|suggest-clips|delete ...',
|
|
253
435
|
' clipit clips list|get|create|update|render|download|delete ...',
|
|
254
436
|
' clipit jobs get|wait <jobId>',
|
|
255
437
|
' clipit credits balance|usage|estimate ...',
|
|
438
|
+
' clipit billing capabilities|catalog|create-attempt|attempt|receipt|subscription ...',
|
|
256
439
|
' clipit analytics overview|top-clips|post ...',
|
|
257
440
|
' clipit exports start|list|get|wait|download|cancel ...',
|
|
258
441
|
' clipit assets list|upload|delete ...',
|
|
@@ -486,7 +669,11 @@ function maxCreditsLimit(options) {
|
|
|
486
669
|
}
|
|
487
670
|
|
|
488
671
|
function clipCostLabel(value) {
|
|
489
|
-
|
|
672
|
+
const amount = Number(value);
|
|
673
|
+
if (!Number.isFinite(amount)) return `${value} $CLIP`;
|
|
674
|
+
if (amount === 0) return '0 $CLIP';
|
|
675
|
+
const fixed = amount.toFixed(Math.abs(amount) < 0.01 ? 5 : 2).replace(/\.?0+$/, '');
|
|
676
|
+
return `${fixed} $CLIP`;
|
|
490
677
|
}
|
|
491
678
|
|
|
492
679
|
const SPEND_LIMIT_EXCEEDED_MESSAGE = 'Spend limit exceeded for this API key \u2014 raise the key\'s spend limit in ClipIt Settings or use a different key.';
|
|
@@ -518,6 +705,20 @@ function warnIfEstimateUnaffordable(estimates) {
|
|
|
518
705
|
}
|
|
519
706
|
}
|
|
520
707
|
|
|
708
|
+
function summarizeRunEstimates(estimates) {
|
|
709
|
+
if (!Array.isArray(estimates) || !estimates.length) return null;
|
|
710
|
+
const estimatedCostClip = estimates.reduce((sum, estimate) => {
|
|
711
|
+
const value = Number(estimate?.estimatedCostClip ?? 0);
|
|
712
|
+
return sum + (Number.isFinite(value) ? value : 0);
|
|
713
|
+
}, 0);
|
|
714
|
+
return {
|
|
715
|
+
estimatedCostClip,
|
|
716
|
+
estimatedCostLabel: clipCostLabel(estimatedCostClip),
|
|
717
|
+
affordable: estimates.every((estimate) => estimate?.affordable !== false),
|
|
718
|
+
estimates,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
521
722
|
function throwSpendLimitExceeded(commandKey, spendLimitViolation, estimates) {
|
|
522
723
|
throw Object.assign(new Error(SPEND_LIMIT_EXCEEDED_MESSAGE), {
|
|
523
724
|
exitCode: EXIT.CREDITS,
|
|
@@ -548,11 +749,52 @@ function remotionProviderCostUsd(durationSeconds, quality) {
|
|
|
548
749
|
return Math.max(0, durationSeconds * REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND * qualityMultiplier(quality));
|
|
549
750
|
}
|
|
550
751
|
|
|
752
|
+
function normalizeCreditEstimateRequest(request) {
|
|
753
|
+
if (
|
|
754
|
+
request.operationType === 'lambda_render'
|
|
755
|
+
&& (request.provider === 'aws_lambda' || request.provider === 'remotion')
|
|
756
|
+
&& request.metrics
|
|
757
|
+
&& typeof request.metrics.videoSeconds === 'number'
|
|
758
|
+
&& typeof request.metrics.providerCostUsd !== 'number'
|
|
759
|
+
) {
|
|
760
|
+
return {
|
|
761
|
+
...request,
|
|
762
|
+
metrics: {
|
|
763
|
+
...request.metrics,
|
|
764
|
+
providerCostUsd: remotionProviderCostUsd(request.metrics.videoSeconds, 'high'),
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return request;
|
|
770
|
+
}
|
|
771
|
+
|
|
551
772
|
function brollVideoProviderCostUsd(durationSeconds, resolution) {
|
|
552
773
|
const perSecondUsd = resolution === '1080p' ? 0.28 : 0.14;
|
|
553
774
|
return Math.max(0, durationSeconds * perSecondUsd);
|
|
554
775
|
}
|
|
555
776
|
|
|
777
|
+
function brollImageProviderCostUsd(quality) {
|
|
778
|
+
if (quality === 'low') return 0.012;
|
|
779
|
+
if (quality === 'medium') return 0.047;
|
|
780
|
+
return 0.128;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function ttsProviderCostUsd(text, prompt) {
|
|
784
|
+
const textBytes = Buffer.byteLength(String(text || ''), 'utf8');
|
|
785
|
+
const promptBytes = Buffer.byteLength(String(prompt || 'Say the following.'), 'utf8');
|
|
786
|
+
const estimatedInputTokens = Math.ceil((textBytes + promptBytes) / 4);
|
|
787
|
+
const estimatedOutputTokens = Math.ceil(textBytes / 3);
|
|
788
|
+
return (estimatedInputTokens / 1_000_000) * 2 + (estimatedOutputTokens / 1_000) * 0.04;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function videoAlterProviderCostUsd(durationSeconds, mode) {
|
|
792
|
+
const perSecond = mode === 'pro'
|
|
793
|
+
? Number(process.env.KLING_OMNI_PRO_COST_PER_SECOND_USD ?? '0.56')
|
|
794
|
+
: Number(process.env.KLING_OMNI_STANDARD_COST_PER_SECOND_USD ?? '0.28');
|
|
795
|
+
return Math.max(0, durationSeconds * (Number.isFinite(perSecond) ? perSecond : 0.28));
|
|
796
|
+
}
|
|
797
|
+
|
|
556
798
|
function durationFromClip(clip, body = {}) {
|
|
557
799
|
const bodyStart = Number(body.startTime);
|
|
558
800
|
const bodyEnd = Number(body.endTime);
|
|
@@ -594,17 +836,57 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
|
|
|
594
836
|
return { ...spec, metrics: { generationCount: 1 } };
|
|
595
837
|
}
|
|
596
838
|
|
|
839
|
+
if (spec.metrics === 'tts-provider-cost') {
|
|
840
|
+
const text = requiredString(context.body?.text, 'text');
|
|
841
|
+
return { ...spec, metrics: { providerCostUsd: ttsProviderCostUsd(text, context.body?.prompt) } };
|
|
842
|
+
}
|
|
843
|
+
|
|
597
844
|
if (spec.metrics === 'broll-plan') {
|
|
598
845
|
return { ...spec, metrics: { inputTokens: 4000, outputTokens: 1000 } };
|
|
599
846
|
}
|
|
600
847
|
|
|
848
|
+
if (spec.metrics === 'platform-caption') {
|
|
849
|
+
return { ...spec, metrics: { inputTokens: 1500, outputTokens: 600, totalTokens: 2100 } };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (spec.metrics === 'suggest-clips') {
|
|
853
|
+
return { ...spec, metrics: { inputTokens: 40000, outputTokens: 10000, totalTokens: 50000 } };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (spec.metrics === 'clip-create') {
|
|
857
|
+
const audioSeconds = durationFromClip(context.body, context.body);
|
|
858
|
+
if (!Number.isFinite(audioSeconds) || audioSeconds <= 0) return null;
|
|
859
|
+
return { ...spec, metrics: { audioSeconds } };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (spec.metrics === 'transcription-video') {
|
|
863
|
+
const videoId = requiredString(context.videoId, 'Video id');
|
|
864
|
+
const video = context.video || await apiFetch(config, options, 'GET', `/api/v1/videos/${encodeURIComponent(videoId)}`);
|
|
865
|
+
const audioSeconds = Number(video?.durationSeconds ?? video?.duration);
|
|
866
|
+
if (!Number.isFinite(audioSeconds) || audioSeconds <= 0) return null;
|
|
867
|
+
return { ...spec, metrics: { audioSeconds } };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (spec.metrics === 'video-upload-storage') {
|
|
871
|
+
const bytes = Number(context.bytes);
|
|
872
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
|
873
|
+
return { ...spec, metrics: { generationCount: bytes / BYTES_PER_GB } };
|
|
874
|
+
}
|
|
875
|
+
|
|
601
876
|
if (spec.metrics === 'broll-images') {
|
|
602
877
|
const mode = context.body?.mode || 'single_image';
|
|
603
|
-
|
|
878
|
+
const generationCount = mode === 'start_end_frame' ? 3 : 1;
|
|
879
|
+
return {
|
|
880
|
+
...spec,
|
|
881
|
+
metrics: {
|
|
882
|
+
generationCount,
|
|
883
|
+
providerCostUsd: brollImageProviderCostUsd(context.body?.imageQuality) * generationCount,
|
|
884
|
+
},
|
|
885
|
+
};
|
|
604
886
|
}
|
|
605
887
|
|
|
606
888
|
if (spec.metrics === 'broll-video') {
|
|
607
|
-
const durationSeconds = Number(context.body?.durationSeconds ?? 6);
|
|
889
|
+
const durationSeconds = Number(context.body?.durationSeconds ?? context.body?.duration ?? 6);
|
|
608
890
|
const resolution = context.body?.resolution || '720p';
|
|
609
891
|
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return null;
|
|
610
892
|
return {
|
|
@@ -616,6 +898,21 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
|
|
|
616
898
|
};
|
|
617
899
|
}
|
|
618
900
|
|
|
901
|
+
if (spec.metrics === 'video-alter-provider-cost') {
|
|
902
|
+
const durationSeconds = Number(
|
|
903
|
+
context.body?.duration
|
|
904
|
+
?? (Number(context.body?.sourceEnd) - Number(context.body?.sourceStart)),
|
|
905
|
+
);
|
|
906
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds < 3 || durationSeconds > 10) return null;
|
|
907
|
+
return {
|
|
908
|
+
...spec,
|
|
909
|
+
metrics: {
|
|
910
|
+
videoSeconds: durationSeconds,
|
|
911
|
+
providerCostUsd: videoAlterProviderCostUsd(durationSeconds, context.body?.mode),
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
619
916
|
if (spec.metrics === 'social-platforms') {
|
|
620
917
|
const platforms = Array.isArray(context.body?.platforms) ? context.body.platforms : [];
|
|
621
918
|
if (!platforms.length) return null;
|
|
@@ -640,8 +937,70 @@ async function buildMaxCreditsEstimateRequest(config, options, spec, context = {
|
|
|
640
937
|
return null;
|
|
641
938
|
}
|
|
642
939
|
|
|
940
|
+
async function buildStaticRunEstimates(config, options, functionName, parameters, payload) {
|
|
941
|
+
const specs = RUN_MAX_CREDITS_ESTIMATE_MAP[functionName];
|
|
942
|
+
if (!specs) return null;
|
|
943
|
+
|
|
944
|
+
const body = parameters && typeof parameters === 'object' && !Array.isArray(parameters) ? parameters : {};
|
|
945
|
+
const requests = [];
|
|
946
|
+
for (const spec of specs) {
|
|
947
|
+
const request = await buildMaxCreditsEstimateRequest(config, options, spec, {
|
|
948
|
+
body,
|
|
949
|
+
clipId: payload?.clipId || body.clipId,
|
|
950
|
+
videoId: payload?.videoId || body.videoId,
|
|
951
|
+
});
|
|
952
|
+
if (!request) return null;
|
|
953
|
+
requests.push(compactObject({
|
|
954
|
+
operationType: request.operationType,
|
|
955
|
+
provider: request.provider,
|
|
956
|
+
modelId: request.modelId,
|
|
957
|
+
metrics: request.metrics,
|
|
958
|
+
}));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const estimates = [];
|
|
962
|
+
for (const request of requests) {
|
|
963
|
+
const estimate = await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', request);
|
|
964
|
+
estimates.push({
|
|
965
|
+
operationType: request.operationType,
|
|
966
|
+
provider: request.provider,
|
|
967
|
+
modelId: request.modelId,
|
|
968
|
+
estimatedCostClip: Number(estimate?.estimatedCostClip ?? 0),
|
|
969
|
+
affordable: estimate?.affordable,
|
|
970
|
+
spendLimitViolation: estimate?.spendLimitViolation ?? null,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return estimates;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function currentInvocationWantsJson() {
|
|
978
|
+
return process.argv.some((arg) => arg === '--json' || arg === '--json=true');
|
|
979
|
+
}
|
|
980
|
+
|
|
643
981
|
function printEstimateUnavailable(commandKey) {
|
|
644
|
-
|
|
982
|
+
if (!currentInvocationWantsJson()) {
|
|
983
|
+
console.error(`estimate unavailable for ${commandKey}; --confirm is required.`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function throwMaxCreditsEstimateUnavailable(commandKey) {
|
|
988
|
+
if (!currentInvocationWantsJson()) {
|
|
989
|
+
console.error(`estimate unavailable for ${commandKey}; --max-credits cannot be enforced.`);
|
|
990
|
+
}
|
|
991
|
+
throw Object.assign(
|
|
992
|
+
new Error(`Cannot enforce --max-credits for ${commandKey} because no cost estimate is available.`),
|
|
993
|
+
{ exitCode: EXIT.CONFIRMATION },
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function estimatedClipCostFromData(data) {
|
|
998
|
+
if (!data || typeof data !== 'object') return null;
|
|
999
|
+
for (const key of ['estimatedCostClip', 'totalEstimatedCostClip', 'costClip', 'totalCostClip']) {
|
|
1000
|
+
const value = Number(data[key]);
|
|
1001
|
+
if (Number.isFinite(value)) return value;
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
645
1004
|
}
|
|
646
1005
|
|
|
647
1006
|
async function enforceMaxCredits(config, options, commandKey, context = {}) {
|
|
@@ -705,20 +1064,119 @@ async function enforceMaxCredits(config, options, commandKey, context = {}) {
|
|
|
705
1064
|
}
|
|
706
1065
|
}
|
|
707
1066
|
|
|
708
|
-
async function enforceRunMaxCredits(config, options, functionName) {
|
|
709
|
-
|
|
1067
|
+
async function enforceRunMaxCredits(config, options, functionName, parameters = {}, payload = {}) {
|
|
1068
|
+
const limit = maxCreditsLimit(options);
|
|
1069
|
+
const needsConfirmationPreflight = !boolOption(options.confirm);
|
|
1070
|
+
if (limit === null && !needsConfirmationPreflight) return null;
|
|
1071
|
+
|
|
710
1072
|
const catalog = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
|
|
711
1073
|
const tools = Array.isArray(catalog?.tools) ? catalog.tools : Array.isArray(catalog) ? catalog : [];
|
|
712
1074
|
const tool = tools.find((item) => item.name === functionName);
|
|
713
1075
|
const runEstimate = tool?.estimate ?? tool?.confirmation?.estimate ?? tool?.confirmation?.costEstimate ?? null;
|
|
1076
|
+
const isMetered = Boolean(tool?.costBand && tool.costBand !== 'free');
|
|
1077
|
+
const isMeteredExempt = RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName);
|
|
1078
|
+
|
|
1079
|
+
let staticEstimates = null;
|
|
1080
|
+
if (!runEstimate && (limit !== null || needsConfirmationPreflight)) {
|
|
1081
|
+
staticEstimates = await buildStaticRunEstimates(config, options, functionName, parameters, payload);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (limit === null) {
|
|
1085
|
+
const estimates = staticEstimates || (runEstimate ? [runEstimate] : null);
|
|
1086
|
+
const estimateSummary = summarizeRunEstimates(estimates);
|
|
1087
|
+
if (estimateSummary && (tool?.confirmation?.required || (isMetered && !isMeteredExempt))) {
|
|
1088
|
+
warnIfEstimateUnaffordable(estimates);
|
|
1089
|
+
throw Object.assign(
|
|
1090
|
+
new Error(`Estimated cost ${estimateSummary.estimatedCostLabel} for run ${functionName}; --confirm is required.`),
|
|
1091
|
+
{
|
|
1092
|
+
exitCode: EXIT.CONFIRMATION,
|
|
1093
|
+
data: {
|
|
1094
|
+
command: `run ${functionName}`,
|
|
1095
|
+
estimate: estimateSummary,
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
if (tool?.confirmation?.required) {
|
|
1101
|
+
if (isMetered) printEstimateUnavailable(`run ${functionName}`);
|
|
1102
|
+
requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (isMetered && !isMeteredExempt) {
|
|
1106
|
+
printEstimateUnavailable(`run ${functionName}`);
|
|
1107
|
+
requireConfirm(options, `Running metered tool ${functionName}`);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (staticEstimates) {
|
|
1114
|
+
const spendLimitViolation = staticEstimates.find((estimate) => estimate.spendLimitViolation)?.spendLimitViolation;
|
|
1115
|
+
if (spendLimitViolation) throwSpendLimitExceeded(`run ${functionName}`, spendLimitViolation, staticEstimates);
|
|
1116
|
+
warnIfEstimateUnaffordable(staticEstimates);
|
|
1117
|
+
|
|
1118
|
+
const estimatedCostClip = staticEstimates.reduce((sum, estimate) => sum + estimate.estimatedCostClip, 0);
|
|
1119
|
+
if (estimatedCostClip > limit) {
|
|
1120
|
+
throw Object.assign(
|
|
1121
|
+
new Error(`Estimated cost ${clipCostLabel(estimatedCostClip)} exceeds --max-credits ${clipCostLabel(limit)} for run ${functionName}.`),
|
|
1122
|
+
{
|
|
1123
|
+
exitCode: EXIT.CONFIRMATION,
|
|
1124
|
+
data: {
|
|
1125
|
+
command: `run ${functionName}`,
|
|
1126
|
+
maxCredits: limit,
|
|
1127
|
+
estimatedCostClip,
|
|
1128
|
+
estimates: staticEstimates,
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
if (tool?.confirmation?.required || (isMetered && !isMeteredExempt)) {
|
|
1134
|
+
requireConfirm(
|
|
1135
|
+
options,
|
|
1136
|
+
tool?.confirmation?.required ? `Running confirmation-gated tool ${functionName}` : `Running metered tool ${functionName}`,
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
return summarizeRunEstimates(staticEstimates);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
714
1142
|
const spendLimitViolation = spendLimitViolationFromData(runEstimate) ?? spendLimitViolationFromData(tool?.confirmation);
|
|
715
1143
|
if (spendLimitViolation) throwSpendLimitExceeded(`run ${functionName}`, spendLimitViolation, runEstimate ? [runEstimate] : []);
|
|
716
1144
|
if (runEstimate?.affordable === false || tool?.confirmation?.affordable === false) {
|
|
717
1145
|
console.error('Warning: estimated cost exceeds your current balance.');
|
|
718
1146
|
}
|
|
719
|
-
if (!
|
|
720
|
-
|
|
721
|
-
|
|
1147
|
+
if (!runEstimate && isMetered && !isMeteredExempt) {
|
|
1148
|
+
throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (!runEstimate && tool?.confirmation?.required) {
|
|
1151
|
+
throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
|
|
1152
|
+
}
|
|
1153
|
+
if (runEstimate) {
|
|
1154
|
+
const estimatedCostClip = estimatedClipCostFromData(runEstimate);
|
|
1155
|
+
if (estimatedCostClip === null) {
|
|
1156
|
+
throwMaxCreditsEstimateUnavailable(`run ${functionName}`);
|
|
1157
|
+
}
|
|
1158
|
+
if (estimatedCostClip > limit) {
|
|
1159
|
+
throw Object.assign(
|
|
1160
|
+
new Error(`Estimated cost ${clipCostLabel(estimatedCostClip)} exceeds --max-credits ${clipCostLabel(limit)} for run ${functionName}.`),
|
|
1161
|
+
{
|
|
1162
|
+
exitCode: EXIT.CONFIRMATION,
|
|
1163
|
+
data: {
|
|
1164
|
+
command: `run ${functionName}`,
|
|
1165
|
+
maxCredits: limit,
|
|
1166
|
+
estimatedCostClip,
|
|
1167
|
+
estimates: [runEstimate],
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (tool?.confirmation?.required || (isMetered && !isMeteredExempt)) {
|
|
1174
|
+
requireConfirm(
|
|
1175
|
+
options,
|
|
1176
|
+
tool?.confirmation?.required ? `Running confirmation-gated tool ${functionName}` : `Running metered tool ${functionName}`,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
return runEstimate ? summarizeRunEstimates([runEstimate]) : null;
|
|
722
1180
|
}
|
|
723
1181
|
|
|
724
1182
|
async function login(config, options) {
|
|
@@ -967,7 +1425,7 @@ async function contextCommand(config, options, action) {
|
|
|
967
1425
|
}
|
|
968
1426
|
|
|
969
1427
|
if (action === 'use') {
|
|
970
|
-
const activeContext = await buildContext(
|
|
1428
|
+
const activeContext = await buildContext(withProfileActiveContext(config, options, {}), options);
|
|
971
1429
|
let next = updateProfile(config, options, { activeContext });
|
|
972
1430
|
next = appendRecentEntries(next, options, [
|
|
973
1431
|
{ type: 'video', id: activeContext.videoId },
|
|
@@ -988,6 +1446,51 @@ async function contextCommand(config, options, action) {
|
|
|
988
1446
|
throw Object.assign(new Error(`Unknown context command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
989
1447
|
}
|
|
990
1448
|
|
|
1449
|
+
function applyContextToAgentPayload(payload, parameters, context) {
|
|
1450
|
+
const canMutateParameters = parameters && typeof parameters === 'object' && !Array.isArray(parameters);
|
|
1451
|
+
const hadExplicitVideoId = canMutateParameters && parameters.videoId !== undefined;
|
|
1452
|
+
for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
|
|
1453
|
+
if (context[field] && !payload[field]) {
|
|
1454
|
+
payload[field] = context[field];
|
|
1455
|
+
if (canMutateParameters && parameters[field] === undefined) {
|
|
1456
|
+
parameters[field] = context[field];
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (canMutateParameters
|
|
1461
|
+
&& CLIP_IDS_CONTEXT_TOOLS.has(payload.functionName)
|
|
1462
|
+
&& parameters.clipIds === undefined
|
|
1463
|
+
&& !hadExplicitVideoId) {
|
|
1464
|
+
const selectedClipIds = Array.isArray(context.selectedClipIds)
|
|
1465
|
+
? context.selectedClipIds.filter(Boolean).map(String)
|
|
1466
|
+
: [];
|
|
1467
|
+
const clipId = parameters.clipId || (selectedClipIds.length ? null : context.clipId);
|
|
1468
|
+
if (clipId) parameters.clipIds = [String(clipId)];
|
|
1469
|
+
else if (selectedClipIds.length) parameters.clipIds = selectedClipIds;
|
|
1470
|
+
}
|
|
1471
|
+
if (context.selectedClipIds) payload.selectedClipIds = context.selectedClipIds;
|
|
1472
|
+
if (context.playheadPosition !== undefined) payload.playheadPosition = context.playheadPosition;
|
|
1473
|
+
if (Object.keys(context).length) payload.context = context.context || context;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function normalizeAgentExecuteResult(result) {
|
|
1477
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return result;
|
|
1478
|
+
const nested = result.result;
|
|
1479
|
+
if (!nested || typeof nested !== 'object' || Array.isArray(nested) || nested.requiresConfirmation !== true) {
|
|
1480
|
+
return result;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return compactObject({
|
|
1484
|
+
...result,
|
|
1485
|
+
requiresConfirmation: true,
|
|
1486
|
+
confirmationTool: result.confirmationTool ?? nested.confirmationTool ?? nested.functionName,
|
|
1487
|
+
confirmationParams: result.confirmationParams ?? nested.confirmationParams ?? nested.parameters,
|
|
1488
|
+
confirmation: result.confirmation ?? nested.confirmation,
|
|
1489
|
+
estimate: result.estimate ?? nested.estimate,
|
|
1490
|
+
preview: result.preview ?? nested.preview,
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
991
1494
|
async function runTool(config, options, functionName) {
|
|
992
1495
|
if (!functionName) throw Object.assign(new Error('Function name is required.'), { exitCode: EXIT.USAGE });
|
|
993
1496
|
const parameters = await readJsonOption(options.params || options['params-json']);
|
|
@@ -1003,23 +1506,29 @@ async function runTool(config, options, functionName) {
|
|
|
1003
1506
|
['project-id', 'projectId'],
|
|
1004
1507
|
['sequence-id', 'sequenceId'],
|
|
1005
1508
|
]) {
|
|
1006
|
-
if (options[flag])
|
|
1509
|
+
if (options[flag]) {
|
|
1510
|
+
const value = String(options[flag]);
|
|
1511
|
+
payload[field] = value;
|
|
1512
|
+
if (parameters && typeof parameters === 'object' && !Array.isArray(parameters) && parameters[field] === undefined) {
|
|
1513
|
+
parameters[field] = value;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1007
1516
|
}
|
|
1008
|
-
|
|
1009
|
-
if (
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
if (result?.requiresConfirmation && !payload.confirmed) {
|
|
1018
|
-
output(
|
|
1517
|
+
applyContextToAgentPayload(payload, parameters, context);
|
|
1518
|
+
if (RUN_CONFIRMATION_LABELS[functionName]) {
|
|
1519
|
+
confirmPaid(options, RUN_CONFIRMATION_LABELS[functionName]);
|
|
1520
|
+
}
|
|
1521
|
+
const estimate = await enforceRunMaxCredits(config, options, functionName, parameters, payload);
|
|
1522
|
+
const result = normalizeAgentExecuteResult(await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload));
|
|
1523
|
+
const outputResult = estimate && result && typeof result === 'object' && !Array.isArray(result) && result.estimate === undefined
|
|
1524
|
+
? { ...result, estimate }
|
|
1525
|
+
: result;
|
|
1526
|
+
if (result?.requiresConfirmation && !payload.confirmed && !RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName)) {
|
|
1527
|
+
output(outputResult, options);
|
|
1019
1528
|
process.exitCode = EXIT.CONFIRMATION;
|
|
1020
1529
|
return;
|
|
1021
1530
|
}
|
|
1022
|
-
output(
|
|
1531
|
+
output(outputResult, options);
|
|
1023
1532
|
}
|
|
1024
1533
|
|
|
1025
1534
|
function workflowEndpoint(jobId) {
|
|
@@ -1152,6 +1661,7 @@ async function askWorkflow(config, options, promptParts) {
|
|
|
1152
1661
|
}
|
|
1153
1662
|
if (options['conversation-id']) payload.conversationId = String(options['conversation-id']);
|
|
1154
1663
|
if (options.quick !== undefined) payload.quickMode = boolOption(options.quick);
|
|
1664
|
+
if (options['auto-confirm-costly'] !== undefined) payload.autoConfirmCostlyTools = boolOption(options['auto-confirm-costly']);
|
|
1155
1665
|
|
|
1156
1666
|
const accepted = await apiFetch(config, options, 'POST', '/api/v1/agent/orchestrate', payload);
|
|
1157
1667
|
if (boolOption(options['no-wait'])) {
|
|
@@ -1199,12 +1709,13 @@ async function workflow(config, options, action, args) {
|
|
|
1199
1709
|
}
|
|
1200
1710
|
|
|
1201
1711
|
const redactedAccepted = redactDeep(accepted);
|
|
1202
|
-
if (
|
|
1712
|
+
if (boolOption(options['no-wait'])) {
|
|
1203
1713
|
output(redactedAccepted, options);
|
|
1204
|
-
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (!wantJson(options)) {
|
|
1205
1717
|
console.log(`Continuation workflow queued: ${redactedAccepted.jobId}`);
|
|
1206
1718
|
}
|
|
1207
|
-
if (boolOption(options['no-wait'])) return;
|
|
1208
1719
|
await pollWorkflow(config, options, accepted.jobId);
|
|
1209
1720
|
return;
|
|
1210
1721
|
}
|
|
@@ -1240,6 +1751,24 @@ function formatMb(bytes) {
|
|
|
1240
1751
|
return (bytes / (1024 * 1024)).toFixed(1);
|
|
1241
1752
|
}
|
|
1242
1753
|
|
|
1754
|
+
function assertDirectVideoUploadSize(filePath, sizeBytes) {
|
|
1755
|
+
if (sizeBytes <= DIRECT_VIDEO_UPLOAD_MAX_BYTES) return;
|
|
1756
|
+
throw Object.assign(
|
|
1757
|
+
new Error(
|
|
1758
|
+
`Video file is ${formatMb(sizeBytes)} MB, above the direct CLI upload limit of ${formatMb(DIRECT_VIDEO_UPLOAD_MAX_BYTES)} MB. ` +
|
|
1759
|
+
'Use `clipit videos import-url` for URL sources or a server-side/resumable upload path; direct CLI upload is likely to fail with 413 before ClipIt can process it.',
|
|
1760
|
+
),
|
|
1761
|
+
{
|
|
1762
|
+
exitCode: EXIT.USAGE,
|
|
1763
|
+
data: {
|
|
1764
|
+
filePath,
|
|
1765
|
+
sizeBytes,
|
|
1766
|
+
maxBytes: DIRECT_VIDEO_UPLOAD_MAX_BYTES,
|
|
1767
|
+
},
|
|
1768
|
+
},
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1243
1772
|
function createUploadProgress(options, totalBytes) {
|
|
1244
1773
|
if (!shouldReportUploadProgress(options) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
|
|
1245
1774
|
return { track() {}, finish() {} };
|
|
@@ -1286,6 +1815,7 @@ async function uploadVideo(config, options, filePath) {
|
|
|
1286
1815
|
if (!stat.isFile()) {
|
|
1287
1816
|
throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
|
|
1288
1817
|
}
|
|
1818
|
+
assertDirectVideoUploadSize(resolved, stat.size);
|
|
1289
1819
|
const filename = options.filename || path.basename(resolved);
|
|
1290
1820
|
const contentType = mimeForPath(resolved);
|
|
1291
1821
|
const boundary = `clipit-cli-${randomBytes(12).toString('hex')}`;
|
|
@@ -1295,14 +1825,25 @@ async function uploadVideo(config, options, filePath) {
|
|
|
1295
1825
|
const footerLength = Buffer.byteLength(`\r\n--${boundary}--\r\n`);
|
|
1296
1826
|
const progress = createUploadProgress(options, stat.size);
|
|
1297
1827
|
const body = multipartFileBody(resolved, filename, contentType, boundary, progress);
|
|
1828
|
+
await enforceMaxCredits(config, options, 'videos upload', { bytes: stat.size });
|
|
1829
|
+
confirmPaid(options, 'Uploading a video');
|
|
1298
1830
|
try {
|
|
1299
|
-
|
|
1831
|
+
const result = await apiFetch(config, options, 'POST', '/api/v1/videos', body, {
|
|
1300
1832
|
rawBody: true,
|
|
1301
1833
|
headers: {
|
|
1302
1834
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1303
1835
|
'Content-Length': String(headerLength + stat.size + footerLength),
|
|
1304
1836
|
},
|
|
1305
|
-
})
|
|
1837
|
+
});
|
|
1838
|
+
if (result?.videoId) {
|
|
1839
|
+
await persistActiveContext(
|
|
1840
|
+
config,
|
|
1841
|
+
options,
|
|
1842
|
+
{ videoId: result.videoId },
|
|
1843
|
+
[{ type: 'video', id: result.videoId }],
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
output(result, options);
|
|
1306
1847
|
} finally {
|
|
1307
1848
|
progress.finish();
|
|
1308
1849
|
}
|
|
@@ -1324,6 +1865,7 @@ async function videos(config, options, action, args) {
|
|
|
1324
1865
|
const url = args[0] || options.url;
|
|
1325
1866
|
if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
|
|
1326
1867
|
await enforceMaxCredits(config, options, 'videos import-url', { url });
|
|
1868
|
+
confirmPaid(options, 'Importing a video from URL');
|
|
1327
1869
|
output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', { url, title: options.title }), options);
|
|
1328
1870
|
return;
|
|
1329
1871
|
}
|
|
@@ -1333,6 +1875,8 @@ async function videos(config, options, action, args) {
|
|
|
1333
1875
|
}
|
|
1334
1876
|
if (action === 'transcribe') {
|
|
1335
1877
|
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1878
|
+
await enforceMaxCredits(config, options, 'videos transcribe', { videoId: args[0] });
|
|
1879
|
+
confirmPaid(options, 'Transcribing a video');
|
|
1336
1880
|
output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/transcribe`, {}), options);
|
|
1337
1881
|
return;
|
|
1338
1882
|
}
|
|
@@ -1352,6 +1896,8 @@ async function videos(config, options, action, args) {
|
|
|
1352
1896
|
targetPlatforms: stringList(options.platforms),
|
|
1353
1897
|
themes: stringList(options.themes),
|
|
1354
1898
|
};
|
|
1899
|
+
await enforceMaxCredits(config, options, 'videos suggest-clips', { videoId: args[0], body });
|
|
1900
|
+
confirmPaid(options, 'Suggesting clips');
|
|
1355
1901
|
output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/suggest-clips`, body), options);
|
|
1356
1902
|
return;
|
|
1357
1903
|
}
|
|
@@ -1394,7 +1940,21 @@ async function clips(config, options, action, args) {
|
|
|
1394
1940
|
if (!body.videoId) throw Object.assign(new Error('--video-id is required.'), { exitCode: EXIT.USAGE });
|
|
1395
1941
|
if (body.startTime === undefined) throw Object.assign(new Error('--start is required.'), { exitCode: EXIT.USAGE });
|
|
1396
1942
|
if (body.endTime === undefined) throw Object.assign(new Error('--end is required.'), { exitCode: EXIT.USAGE });
|
|
1397
|
-
|
|
1943
|
+
await enforceMaxCredits(config, options, 'clips create', { body });
|
|
1944
|
+
confirmPaid(options, 'Creating a clip');
|
|
1945
|
+
const result = await apiFetch(config, options, 'POST', '/api/v1/clips', body);
|
|
1946
|
+
if (result?.id) {
|
|
1947
|
+
await persistActiveContext(
|
|
1948
|
+
config,
|
|
1949
|
+
options,
|
|
1950
|
+
{ videoId: result.videoId || body.videoId, clipId: result.id },
|
|
1951
|
+
[
|
|
1952
|
+
{ type: 'video', id: result.videoId || body.videoId },
|
|
1953
|
+
{ type: 'clip', id: result.id },
|
|
1954
|
+
],
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
output(result, options);
|
|
1398
1958
|
return;
|
|
1399
1959
|
}
|
|
1400
1960
|
if (action === 'update') {
|
|
@@ -1428,6 +1988,7 @@ async function clips(config, options, action, args) {
|
|
|
1428
1988
|
if (body[key] === undefined) delete body[key];
|
|
1429
1989
|
}
|
|
1430
1990
|
await enforceMaxCredits(config, options, 'clips render', { clipId: args[0], body });
|
|
1991
|
+
confirmPaid(options, 'Rendering a clip');
|
|
1431
1992
|
output(await apiFetch(config, options, 'POST', `/api/v1/clips/${encodeURIComponent(args[0])}/render`, body), options);
|
|
1432
1993
|
return;
|
|
1433
1994
|
}
|
|
@@ -1460,18 +2021,78 @@ async function credits(config, options, action) {
|
|
|
1460
2021
|
if (action === 'estimate') {
|
|
1461
2022
|
const operationType = requiredString(options['operation-type'], '--operation-type');
|
|
1462
2023
|
const provider = requiredString(options.provider, '--provider');
|
|
1463
|
-
const metrics = await readMetricsOption(options.metrics);
|
|
1464
|
-
|
|
2024
|
+
const metrics = await readMetricsOption(options.metrics ?? options.metadata);
|
|
2025
|
+
const request = normalizeCreditEstimateRequest(compactObject({
|
|
1465
2026
|
operationType,
|
|
1466
2027
|
provider,
|
|
1467
2028
|
modelId: options['model-id'],
|
|
1468
2029
|
metrics,
|
|
1469
|
-
}))
|
|
2030
|
+
}));
|
|
2031
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', request), options);
|
|
1470
2032
|
return;
|
|
1471
2033
|
}
|
|
1472
2034
|
throw Object.assign(new Error(`Unknown credits command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1473
2035
|
}
|
|
1474
2036
|
|
|
2037
|
+
function normalizeBillingProvider(value) {
|
|
2038
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
2039
|
+
const provider = String(value);
|
|
2040
|
+
if (!BILLING_PROVIDER_PREFERENCES.has(provider)) {
|
|
2041
|
+
throw Object.assign(
|
|
2042
|
+
new Error(`--provider must be one of: ${Array.from(BILLING_PROVIDER_PREFERENCES).join(', ')}.`),
|
|
2043
|
+
{ exitCode: EXIT.USAGE },
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
return provider;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function buildBillingAttemptBody(input = {}) {
|
|
2050
|
+
return compactObject({
|
|
2051
|
+
productKey: requiredString(input.productKey ?? input['product-key'] ?? input.product ?? input.key, '--product-key'),
|
|
2052
|
+
providerPreference: normalizeBillingProvider(input.providerPreference ?? input.provider ?? input.rail),
|
|
2053
|
+
idempotencyKey: input.idempotencyKey ?? input['idempotency-key'],
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
function billingAttemptId(args, options) {
|
|
2058
|
+
return requiredString(args[0] ?? options['attempt-id'] ?? options.attemptId, 'Attempt id');
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
async function billing(config, options, action, args = []) {
|
|
2062
|
+
if (action === 'capabilities') {
|
|
2063
|
+
output(await apiFetch(config, options, 'GET', '/api/v1/agent/payment-capabilities', undefined, { noAuth: true }), options);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (action === 'catalog') {
|
|
2067
|
+
output(await apiFetch(config, options, 'GET', '/api/v1/billing/catalog', undefined, { noAuth: true }), options);
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
if (action === 'create-attempt') {
|
|
2071
|
+
requireConfirm(options, 'Creating a machine-payment attempt');
|
|
2072
|
+
const body = buildBillingAttemptBody({
|
|
2073
|
+
...options,
|
|
2074
|
+
productKey: options['product-key'] ?? options.productKey ?? args[0],
|
|
2075
|
+
});
|
|
2076
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/billing/agent-payments', body), options);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
if (action === 'attempt') {
|
|
2080
|
+
const attemptId = billingAttemptId(args, options);
|
|
2081
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}`), options);
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (action === 'receipt') {
|
|
2085
|
+
const attemptId = billingAttemptId(args, options);
|
|
2086
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}/receipt`), options);
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
if (action === 'subscription') {
|
|
2090
|
+
output(await apiFetch(config, options, 'GET', '/api/v1/billing/subscription'), options);
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
throw Object.assign(new Error(`Unknown billing command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
2094
|
+
}
|
|
2095
|
+
|
|
1475
2096
|
async function analytics(config, options, action, args) {
|
|
1476
2097
|
if (action === 'overview') {
|
|
1477
2098
|
const suffix = queryString({ days: options.days ?? 30 });
|
|
@@ -1551,7 +2172,7 @@ async function exportsCommand(config, options, action, args) {
|
|
|
1551
2172
|
return;
|
|
1552
2173
|
}
|
|
1553
2174
|
if (action === 'list') {
|
|
1554
|
-
output(await apiFetch(config, options, 'GET', `/api/v1/exports${queryString({ limit: options.limit, offset: options.offset })}`), options);
|
|
2175
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/exports${queryString({ limit: options.limit, offset: options.offset, clipId: options['clip-id'] })}`), options);
|
|
1555
2176
|
return;
|
|
1556
2177
|
}
|
|
1557
2178
|
if (action === 'get') {
|
|
@@ -1572,6 +2193,7 @@ async function exportsCommand(config, options, action, args) {
|
|
|
1572
2193
|
}
|
|
1573
2194
|
if (action === 'cancel') {
|
|
1574
2195
|
const jobId = requiredString(args[0], 'Export job id');
|
|
2196
|
+
requireConfirm(options, 'Cancelling an export');
|
|
1575
2197
|
output(await apiFetch(config, options, 'POST', `/api/v1/exports/${encodeURIComponent(jobId)}/cancel`, {}), options);
|
|
1576
2198
|
return;
|
|
1577
2199
|
}
|
|
@@ -1621,6 +2243,7 @@ async function uploadAsset(config, options, filePath) {
|
|
|
1621
2243
|
size: stat.size,
|
|
1622
2244
|
kind: options.kind,
|
|
1623
2245
|
});
|
|
2246
|
+
requireConfirm(options, 'Uploading an asset');
|
|
1624
2247
|
const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
|
|
1625
2248
|
if (!signed?.uploadUrl || !signed?.key || !signed?.assetId) {
|
|
1626
2249
|
throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
|
|
@@ -1809,19 +2432,28 @@ async function jobs(config, options, action, args) {
|
|
|
1809
2432
|
const jobId = args[0];
|
|
1810
2433
|
if (!jobId) throw Object.assign(new Error('Job id is required.'), { exitCode: EXIT.USAGE });
|
|
1811
2434
|
const startedAt = Date.now();
|
|
1812
|
-
const
|
|
1813
|
-
const
|
|
2435
|
+
const requestedTimeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
|
|
2436
|
+
const requestedIntervalMs = numberOption(options.interval, '--interval');
|
|
1814
2437
|
while (true) {
|
|
1815
2438
|
const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
|
|
1816
2439
|
if (action === 'get' || TERMINAL_JOB_STATUSES.has(job.status)) {
|
|
1817
2440
|
output(job, options);
|
|
1818
2441
|
return;
|
|
1819
2442
|
}
|
|
1820
|
-
if (options.stream) console.log(JSON.stringify({ type: 'job.progress', job }));
|
|
2443
|
+
if (options.stream) console.log(JSON.stringify({ type: 'job.progress', job: redactDeep(job) }));
|
|
2444
|
+
const serverMinimumWaitMs = typeof job.minimumWaitSeconds === 'number'
|
|
2445
|
+
? Math.max(0, job.minimumWaitSeconds * 1000)
|
|
2446
|
+
: 0;
|
|
2447
|
+
const timeoutMs = requestedTimeoutMs
|
|
2448
|
+
? Math.max(requestedTimeoutMs, serverMinimumWaitMs)
|
|
2449
|
+
: undefined;
|
|
1821
2450
|
if (timeoutMs && Date.now() - startedAt > timeoutMs) {
|
|
1822
2451
|
throw Object.assign(new Error(`Timed out waiting for job ${jobId}.`), { exitCode: EXIT.SERVER });
|
|
1823
2452
|
}
|
|
1824
|
-
|
|
2453
|
+
const serverIntervalMs = typeof job.recommendedPollIntervalSeconds === 'number'
|
|
2454
|
+
? Math.max(1000, job.recommendedPollIntervalSeconds * 1000)
|
|
2455
|
+
: 0;
|
|
2456
|
+
await sleep(requestedIntervalMs || serverIntervalMs || 3000);
|
|
1825
2457
|
}
|
|
1826
2458
|
}
|
|
1827
2459
|
|
|
@@ -1865,9 +2497,8 @@ async function appUrl(config, options, kind, id) {
|
|
|
1865
2497
|
params.set('clip', id);
|
|
1866
2498
|
return `${baseUrl}/clips/review?${params.toString()}`;
|
|
1867
2499
|
}
|
|
1868
|
-
// A video
|
|
1869
|
-
|
|
1870
|
-
if (kind === 'video') return `${baseUrl}/clips/review${id ? `?video=${encodeURIComponent(id)}` : ''}`;
|
|
2500
|
+
// A video-level agent result should land in the Agent Content library tab.
|
|
2501
|
+
if (kind === 'video') return `${baseUrl}/clips${id ? `?tab=agent&video=${encodeURIComponent(id)}` : '?tab=agent'}`;
|
|
1871
2502
|
// The editor lives at /editor and reads ?project= (NOT /editor/projects, which
|
|
1872
2503
|
// redirects to /clips and drops the query — the same dead-end class as the
|
|
1873
2504
|
// clip/video link bug). There is no surface that consumes a raw sequenceId, so
|
|
@@ -1904,24 +2535,498 @@ async function links(config, options, kind, id) {
|
|
|
1904
2535
|
output(result, options);
|
|
1905
2536
|
}
|
|
1906
2537
|
|
|
2538
|
+
function mcpToolInputSchema(tool) {
|
|
2539
|
+
const schema = tool?.inputSchema || tool?.parametersSchema || tool?.parameterSchema || tool?.parameters;
|
|
2540
|
+
if (schema && typeof schema === 'object' && !Array.isArray(schema)) return schema;
|
|
2541
|
+
return { type: 'object', additionalProperties: true };
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function mcpToolConfirmationDescription(tool) {
|
|
2545
|
+
if (!mcpToolRequiresConfirmation(tool, tool?.name)) return null;
|
|
2546
|
+
const parts = [
|
|
2547
|
+
tool?.confirmation?.riskLevel ? `risk=${tool.confirmation.riskLevel}` : null,
|
|
2548
|
+
tool?.confirmation?.reason || null,
|
|
2549
|
+
].filter(Boolean);
|
|
2550
|
+
if (parts.length) return parts.join('; ');
|
|
2551
|
+
|
|
2552
|
+
const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
|
|
2553
|
+
if (costBand && costBand !== 'free' && costBand !== 'none') {
|
|
2554
|
+
return `cost=${costBand}; may spend credits or mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.`;
|
|
2555
|
+
}
|
|
2556
|
+
return 'may mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.';
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function mcpToolDescription(tool) {
|
|
2560
|
+
const confirmationDescription = mcpToolConfirmationDescription(tool);
|
|
2561
|
+
return [
|
|
2562
|
+
tool?.description,
|
|
2563
|
+
tool?.skill ? `Skill: ${tool.skill}` : null,
|
|
2564
|
+
tool?.costBand ? `Cost: ${tool.costBand}` : null,
|
|
2565
|
+
confirmationDescription ? `Confirmation required: ${confirmationDescription}` : null,
|
|
2566
|
+
].filter(Boolean).join('\n');
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
function mcpToolRequiresConfirmation(tool, name) {
|
|
2570
|
+
if (RUN_METERED_CONFIRMATION_EXEMPTIONS.has(name)) return false;
|
|
2571
|
+
if (RUN_CONFIRMATION_LABELS[name]) return true;
|
|
2572
|
+
if (tool?.confirmation?.required) return true;
|
|
2573
|
+
if (tool?.requiresConfirmation === true) return true;
|
|
2574
|
+
const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
|
|
2575
|
+
return Boolean(costBand && costBand !== 'free' && costBand !== 'none');
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function readMcpMaxCredits(parameters) {
|
|
2579
|
+
if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters)) return null;
|
|
2580
|
+
let raw;
|
|
2581
|
+
for (const key of ['maxCredits', 'max_credits', 'maxCreditsClip']) {
|
|
2582
|
+
if (raw === undefined && parameters[key] !== undefined) raw = parameters[key];
|
|
2583
|
+
delete parameters[key];
|
|
2584
|
+
}
|
|
2585
|
+
if (raw === undefined) return null;
|
|
2586
|
+
const limit = Number(raw);
|
|
2587
|
+
if (!Number.isFinite(limit) || limit < 0) {
|
|
2588
|
+
throw new Error('MCP tools/call maxCredits must be 0 or greater.');
|
|
2589
|
+
}
|
|
2590
|
+
return limit;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
async function mcpEstimatePayload(config, options, tool, name, parameters, payload) {
|
|
2594
|
+
const runEstimate = tool?.estimate ?? tool?.confirmation?.estimate ?? tool?.confirmation?.costEstimate ?? null;
|
|
2595
|
+
if (runEstimate) {
|
|
2596
|
+
const estimatedCostClip = estimatedClipCostFromData(runEstimate);
|
|
2597
|
+
return compactObject({
|
|
2598
|
+
estimate: compactObject({
|
|
2599
|
+
estimatedCostClip,
|
|
2600
|
+
estimatedCostLabel: estimatedCostClip === null ? undefined : clipCostLabel(estimatedCostClip),
|
|
2601
|
+
affordable: runEstimate.affordable,
|
|
2602
|
+
estimates: [runEstimate],
|
|
2603
|
+
}),
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
try {
|
|
2608
|
+
const estimates = await buildStaticRunEstimates(config, options, name, parameters, payload);
|
|
2609
|
+
if (estimates?.length) {
|
|
2610
|
+
const estimatedCostClip = estimates.reduce((sum, estimate) => sum + Number(estimate.estimatedCostClip ?? 0), 0);
|
|
2611
|
+
return {
|
|
2612
|
+
estimate: {
|
|
2613
|
+
estimatedCostClip,
|
|
2614
|
+
estimatedCostLabel: clipCostLabel(estimatedCostClip),
|
|
2615
|
+
affordable: estimates.every((estimate) => estimate.affordable !== false),
|
|
2616
|
+
estimates,
|
|
2617
|
+
},
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
} catch (error) {
|
|
2621
|
+
return { estimateUnavailable: redact(error.message || String(error)) };
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
|
|
2625
|
+
if (costBand && costBand !== 'free' && costBand !== 'none') {
|
|
2626
|
+
return { estimateUnavailable: 'No local cost estimate is available for this tool; use estimateOperationCost before approval when possible.' };
|
|
2627
|
+
}
|
|
2628
|
+
return {};
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
async function mcpMaxCreditsResult(config, options, tool, name, parameters, payload, maxCredits) {
|
|
2632
|
+
if (maxCredits === null) return null;
|
|
2633
|
+
const estimatePayload = await mcpEstimatePayload(config, options, tool, name, parameters, payload);
|
|
2634
|
+
if (!estimatePayload.estimate) {
|
|
2635
|
+
const costBand = String(tool?.costBand || tool?.cost_band || '').toLowerCase();
|
|
2636
|
+
if (!mcpToolRequiresConfirmation(tool, name) && (!costBand || costBand === 'free' || costBand === 'none')) {
|
|
2637
|
+
return null;
|
|
2638
|
+
}
|
|
2639
|
+
return {
|
|
2640
|
+
error: 'max_credits_unenforceable',
|
|
2641
|
+
functionName: name,
|
|
2642
|
+
maxCredits,
|
|
2643
|
+
maxCreditsLabel: clipCostLabel(maxCredits),
|
|
2644
|
+
estimateUnavailable: estimatePayload.estimateUnavailable || 'No local cost estimate is available for this tool.',
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
const estimatedCostClip = Number(estimatePayload.estimate.estimatedCostClip);
|
|
2648
|
+
if (!Number.isFinite(estimatedCostClip)) {
|
|
2649
|
+
return {
|
|
2650
|
+
error: 'max_credits_unenforceable',
|
|
2651
|
+
functionName: name,
|
|
2652
|
+
maxCredits,
|
|
2653
|
+
maxCreditsLabel: clipCostLabel(maxCredits),
|
|
2654
|
+
estimate: estimatePayload.estimate,
|
|
2655
|
+
estimateUnavailable: 'The available estimate did not include an estimatedCostClip value.',
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
if (estimatedCostClip > maxCredits) {
|
|
2659
|
+
return {
|
|
2660
|
+
error: 'max_credits_exceeded',
|
|
2661
|
+
functionName: name,
|
|
2662
|
+
maxCredits,
|
|
2663
|
+
maxCreditsLabel: clipCostLabel(maxCredits),
|
|
2664
|
+
estimatedCostClip,
|
|
2665
|
+
estimatedCostLabel: clipCostLabel(estimatedCostClip),
|
|
2666
|
+
estimate: estimatePayload.estimate,
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
async function mcpConfirmationPayload(config, options, tool, name, parameters, payload, maxCredits = null) {
|
|
2673
|
+
const reason = tool?.confirmation?.reason || `${name} may spend credits or mutate user-visible ClipIt state. Ask the user before retrying with confirmed:true.`;
|
|
2674
|
+
const confirmation = compactObject({
|
|
2675
|
+
requiresConfirmation: true,
|
|
2676
|
+
functionName: name,
|
|
2677
|
+
reason,
|
|
2678
|
+
confirmation: tool?.confirmation || undefined,
|
|
2679
|
+
confirmationParams: parameters,
|
|
2680
|
+
retryArguments: {
|
|
2681
|
+
...(parameters || {}),
|
|
2682
|
+
confirmed: true,
|
|
2683
|
+
maxCredits: maxCredits ?? undefined,
|
|
2684
|
+
},
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
Object.assign(confirmation, await mcpEstimatePayload(config, options, tool, name, parameters, payload));
|
|
2688
|
+
|
|
2689
|
+
return confirmation;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function mcpTextResult(value, isError = false) {
|
|
2693
|
+
const text = typeof value === 'string' ? value : JSON.stringify(redactDeep(value), null, 2);
|
|
2694
|
+
return compactObject({
|
|
2695
|
+
content: [{ type: 'text', text }],
|
|
2696
|
+
isError: isError || undefined,
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
async function fetchMcpTools(config, options) {
|
|
2701
|
+
const response = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
|
|
2702
|
+
return mergeMcpTools(Array.isArray(response?.tools) ? response.tools : []);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
function mergeMcpTools(serverTools) {
|
|
2706
|
+
const seen = new Set();
|
|
2707
|
+
const merged = [];
|
|
2708
|
+
for (const tool of [...LOCAL_MCP_BILLING_TOOLS, ...serverTools]) {
|
|
2709
|
+
if (typeof tool?.name !== 'string' || seen.has(tool.name)) continue;
|
|
2710
|
+
seen.add(tool.name);
|
|
2711
|
+
merged.push(tool);
|
|
2712
|
+
}
|
|
2713
|
+
return merged;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
function localMcpBillingTool(name) {
|
|
2717
|
+
return LOCAL_MCP_BILLING_TOOLS.find((tool) => tool.name === name) || null;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
async function handleLocalMcpBillingTool(config, options, name, parameters = {}) {
|
|
2721
|
+
if (name === 'getPaymentCapabilities') {
|
|
2722
|
+
return apiFetch(config, options, 'GET', '/api/v1/agent/payment-capabilities', undefined, { noAuth: true });
|
|
2723
|
+
}
|
|
2724
|
+
if (name === 'getBillingCatalog') {
|
|
2725
|
+
return apiFetch(config, options, 'GET', '/api/v1/billing/catalog', undefined, { noAuth: true });
|
|
2726
|
+
}
|
|
2727
|
+
if (name === 'createPaymentAttempt') {
|
|
2728
|
+
return apiFetch(config, options, 'POST', '/api/v1/billing/agent-payments', buildBillingAttemptBody(parameters));
|
|
2729
|
+
}
|
|
2730
|
+
if (name === 'getPaymentAttempt') {
|
|
2731
|
+
const attemptId = requiredString(parameters.attemptId ?? parameters.attempt_id, 'attemptId');
|
|
2732
|
+
return apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}`);
|
|
2733
|
+
}
|
|
2734
|
+
if (name === 'getPaymentReceipt') {
|
|
2735
|
+
const attemptId = requiredString(parameters.attemptId ?? parameters.attempt_id, 'attemptId');
|
|
2736
|
+
return apiFetch(config, options, 'GET', `/api/v1/billing/agent-payments/${encodeURIComponent(attemptId)}/receipt`);
|
|
2737
|
+
}
|
|
2738
|
+
if (name === 'getBillingSubscription') {
|
|
2739
|
+
return apiFetch(config, options, 'GET', '/api/v1/billing/subscription');
|
|
2740
|
+
}
|
|
2741
|
+
throw Object.assign(new Error(`Unknown local billing tool: ${name}`), { exitCode: EXIT.USAGE });
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
async function handleMcpRequest(config, options, message) {
|
|
2745
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
2746
|
+
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid JSON-RPC request.' } };
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
const id = message.id ?? null;
|
|
2750
|
+
const method = message.method;
|
|
2751
|
+
const isNotification = message.id === undefined;
|
|
2752
|
+
|
|
2753
|
+
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
|
|
2754
|
+
if (method === 'initialize') {
|
|
2755
|
+
return {
|
|
2756
|
+
jsonrpc: '2.0',
|
|
2757
|
+
id,
|
|
2758
|
+
result: {
|
|
2759
|
+
protocolVersion: message.params?.protocolVersion || '2024-11-05',
|
|
2760
|
+
capabilities: { tools: {} },
|
|
2761
|
+
serverInfo: { name: 'clipit', version: VERSION },
|
|
2762
|
+
},
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
if (method === 'ping') return isNotification ? null : { jsonrpc: '2.0', id, result: {} };
|
|
2766
|
+
if (isNotification) return null;
|
|
2767
|
+
|
|
2768
|
+
if (method === 'tools/list') {
|
|
2769
|
+
const tools = await fetchMcpTools(config, options);
|
|
2770
|
+
return {
|
|
2771
|
+
jsonrpc: '2.0',
|
|
2772
|
+
id,
|
|
2773
|
+
result: {
|
|
2774
|
+
tools: tools.map((tool) => ({
|
|
2775
|
+
name: tool.name,
|
|
2776
|
+
description: mcpToolDescription(tool),
|
|
2777
|
+
inputSchema: mcpToolInputSchema(tool),
|
|
2778
|
+
})).filter((tool) => typeof tool.name === 'string' && tool.name.length > 0),
|
|
2779
|
+
},
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
if (method === 'tools/call') {
|
|
2784
|
+
const name = typeof message.params?.name === 'string' ? message.params.name : null;
|
|
2785
|
+
if (!name) {
|
|
2786
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: 'tools/call requires params.name.' } };
|
|
2787
|
+
}
|
|
2788
|
+
const parameters = message.params?.arguments && typeof message.params.arguments === 'object' && !Array.isArray(message.params.arguments)
|
|
2789
|
+
? { ...message.params.arguments }
|
|
2790
|
+
: {};
|
|
2791
|
+
const confirmed = parameters.confirmed === true || parameters.confirm === true;
|
|
2792
|
+
delete parameters.confirm;
|
|
2793
|
+
if (confirmed) parameters.confirmed = true;
|
|
2794
|
+
else delete parameters.confirmed;
|
|
2795
|
+
let maxCredits = null;
|
|
2796
|
+
try {
|
|
2797
|
+
maxCredits = readMcpMaxCredits(parameters);
|
|
2798
|
+
} catch (error) {
|
|
2799
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: error.message } };
|
|
2800
|
+
}
|
|
2801
|
+
const tools = await fetchMcpTools(config, options);
|
|
2802
|
+
const tool = tools.find((candidate) => candidate.name === name);
|
|
2803
|
+
if (!tool) {
|
|
2804
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown ClipIt tool: ${name}` } };
|
|
2805
|
+
}
|
|
2806
|
+
const localBillingTool = localMcpBillingTool(name);
|
|
2807
|
+
const payload = {
|
|
2808
|
+
functionName: name,
|
|
2809
|
+
parameters,
|
|
2810
|
+
confirmed,
|
|
2811
|
+
};
|
|
2812
|
+
if (localBillingTool) {
|
|
2813
|
+
if (!confirmed && mcpToolRequiresConfirmation(localBillingTool, name)) {
|
|
2814
|
+
return {
|
|
2815
|
+
jsonrpc: '2.0',
|
|
2816
|
+
id,
|
|
2817
|
+
result: mcpTextResult(await mcpConfirmationPayload(config, options, localBillingTool, name, parameters, payload, maxCredits)),
|
|
2818
|
+
};
|
|
2819
|
+
}
|
|
2820
|
+
const result = await handleLocalMcpBillingTool(config, options, name, parameters);
|
|
2821
|
+
return { jsonrpc: '2.0', id, result: mcpTextResult(result, Boolean(result?.error)) };
|
|
2822
|
+
}
|
|
2823
|
+
applyContextToAgentPayload(payload, parameters, await buildContext(config, options));
|
|
2824
|
+
if (!confirmed && mcpToolRequiresConfirmation(tool, name)) {
|
|
2825
|
+
return {
|
|
2826
|
+
jsonrpc: '2.0',
|
|
2827
|
+
id,
|
|
2828
|
+
result: mcpTextResult(await mcpConfirmationPayload(config, options, tool, name, parameters, payload, maxCredits)),
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
const maxCreditsResult = await mcpMaxCreditsResult(config, options, tool, name, parameters, payload, maxCredits);
|
|
2832
|
+
if (maxCreditsResult) {
|
|
2833
|
+
return {
|
|
2834
|
+
jsonrpc: '2.0',
|
|
2835
|
+
id,
|
|
2836
|
+
result: mcpTextResult(maxCreditsResult, true),
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
const result = normalizeAgentExecuteResult(await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload));
|
|
2840
|
+
return { jsonrpc: '2.0', id, result: mcpTextResult(result, Boolean(result?.error)) };
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unsupported MCP method: ${method || ''}` } };
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
function mcpFrame(message) {
|
|
2847
|
+
const body = JSON.stringify(redactDeep(message));
|
|
2848
|
+
return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
function mcpHeaderEnd(buffer) {
|
|
2852
|
+
const crlf = buffer.indexOf('\r\n\r\n');
|
|
2853
|
+
const lf = buffer.indexOf('\n\n');
|
|
2854
|
+
if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 };
|
|
2855
|
+
if (lf === -1) return { index: crlf, length: 4 };
|
|
2856
|
+
return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 };
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
function mcpLooksFramed(buffer) {
|
|
2860
|
+
const prefix = 'Content-Length:';
|
|
2861
|
+
const sample = buffer.toString('utf8', 0, Math.min(buffer.length, prefix.length));
|
|
2862
|
+
if (prefix.startsWith(sample)) return buffer.length < prefix.length ? null : true;
|
|
2863
|
+
return false;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
function readMcpFrame(buffer) {
|
|
2867
|
+
const headerEnd = mcpHeaderEnd(buffer);
|
|
2868
|
+
if (!headerEnd) return { incomplete: true, buffer };
|
|
2869
|
+
const header = buffer.subarray(0, headerEnd.index).toString('utf8');
|
|
2870
|
+
const match = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
|
|
2871
|
+
if (!match) {
|
|
2872
|
+
return {
|
|
2873
|
+
message: null,
|
|
2874
|
+
error: { code: -32600, message: 'Invalid MCP frame: missing Content-Length header.' },
|
|
2875
|
+
buffer: Buffer.alloc(0),
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
const contentLength = Number(match[1]);
|
|
2879
|
+
const bodyStart = headerEnd.index + headerEnd.length;
|
|
2880
|
+
const bodyEnd = bodyStart + contentLength;
|
|
2881
|
+
if (buffer.length < bodyEnd) return { incomplete: true, buffer };
|
|
2882
|
+
const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');
|
|
2883
|
+
try {
|
|
2884
|
+
return {
|
|
2885
|
+
message: JSON.parse(body),
|
|
2886
|
+
buffer: buffer.subarray(bodyEnd),
|
|
2887
|
+
};
|
|
2888
|
+
} catch (error) {
|
|
2889
|
+
return {
|
|
2890
|
+
message: null,
|
|
2891
|
+
error: { code: -32700, message: `Parse error: ${error.message}` },
|
|
2892
|
+
buffer: buffer.subarray(bodyEnd),
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
async function respondToMcpMessage(config, options, message, send) {
|
|
2898
|
+
try {
|
|
2899
|
+
send(await handleMcpRequest(config, options, message));
|
|
2900
|
+
} catch (error) {
|
|
2901
|
+
send({
|
|
2902
|
+
jsonrpc: '2.0',
|
|
2903
|
+
id: message?.id ?? null,
|
|
2904
|
+
error: {
|
|
2905
|
+
code: -32000,
|
|
2906
|
+
message: redact(error.message || String(error)),
|
|
2907
|
+
data: redactDeep(compactObject({ status: error.status, requestId: error.requestId, details: error.data })),
|
|
2908
|
+
},
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
async function runMcpStdio(config, options) {
|
|
2914
|
+
let framed = null;
|
|
2915
|
+
let frameBuffer = Buffer.alloc(0);
|
|
2916
|
+
let lineBuffer = '';
|
|
2917
|
+
const send = (message) => {
|
|
2918
|
+
if (!message) return;
|
|
2919
|
+
if (framed) {
|
|
2920
|
+
process.stdout.write(mcpFrame(message));
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
process.stdout.write(`${JSON.stringify(redactDeep(message))}\n`);
|
|
2924
|
+
};
|
|
2925
|
+
|
|
2926
|
+
const sendTransportError = (error) => send({ jsonrpc: '2.0', id: null, error });
|
|
2927
|
+
const processLineBuffer = async (flush = false) => {
|
|
2928
|
+
let newlineIndex = lineBuffer.indexOf('\n');
|
|
2929
|
+
while (newlineIndex !== -1) {
|
|
2930
|
+
const line = lineBuffer.slice(0, newlineIndex).trim();
|
|
2931
|
+
lineBuffer = lineBuffer.slice(newlineIndex + 1);
|
|
2932
|
+
newlineIndex = lineBuffer.indexOf('\n');
|
|
2933
|
+
if (!line) continue;
|
|
2934
|
+
let message;
|
|
2935
|
+
try {
|
|
2936
|
+
message = JSON.parse(line);
|
|
2937
|
+
} catch (error) {
|
|
2938
|
+
sendTransportError({ code: -32700, message: `Parse error: ${error.message}` });
|
|
2939
|
+
continue;
|
|
2940
|
+
}
|
|
2941
|
+
await respondToMcpMessage(config, options, message, send);
|
|
2942
|
+
}
|
|
2943
|
+
if (flush && lineBuffer.trim()) {
|
|
2944
|
+
const line = lineBuffer.trim();
|
|
2945
|
+
lineBuffer = '';
|
|
2946
|
+
let message;
|
|
2947
|
+
try {
|
|
2948
|
+
message = JSON.parse(line);
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
sendTransportError({ code: -32700, message: `Parse error: ${error.message}` });
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
await respondToMcpMessage(config, options, message, send);
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
|
|
2957
|
+
for await (const chunk of process.stdin) {
|
|
2958
|
+
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2959
|
+
let appendedToFrame = false;
|
|
2960
|
+
if (framed === null) {
|
|
2961
|
+
frameBuffer = Buffer.concat([frameBuffer, chunkBuffer]);
|
|
2962
|
+
appendedToFrame = true;
|
|
2963
|
+
const decision = mcpLooksFramed(frameBuffer);
|
|
2964
|
+
if (decision === null) continue;
|
|
2965
|
+
framed = decision;
|
|
2966
|
+
if (!framed) {
|
|
2967
|
+
lineBuffer += frameBuffer.toString('utf8');
|
|
2968
|
+
frameBuffer = Buffer.alloc(0);
|
|
2969
|
+
await processLineBuffer();
|
|
2970
|
+
continue;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
if (!framed) {
|
|
2975
|
+
lineBuffer += chunkBuffer.toString('utf8');
|
|
2976
|
+
await processLineBuffer();
|
|
2977
|
+
continue;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
if (framed && !appendedToFrame) {
|
|
2981
|
+
frameBuffer = Buffer.concat([frameBuffer, chunkBuffer]);
|
|
2982
|
+
}
|
|
2983
|
+
while (framed) {
|
|
2984
|
+
const parsed = readMcpFrame(frameBuffer);
|
|
2985
|
+
frameBuffer = parsed.buffer;
|
|
2986
|
+
if (parsed.incomplete) break;
|
|
2987
|
+
if (parsed.error) {
|
|
2988
|
+
sendTransportError(parsed.error);
|
|
2989
|
+
continue;
|
|
2990
|
+
}
|
|
2991
|
+
await respondToMcpMessage(config, options, parsed.message, send);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
if (framed === false) await processLineBuffer(true);
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
async function mcp(config, options, action) {
|
|
2999
|
+
if (action && action !== 'stdio') {
|
|
3000
|
+
throw Object.assign(new Error(`Unknown mcp command: ${action}`), { exitCode: EXIT.USAGE });
|
|
3001
|
+
}
|
|
3002
|
+
await runMcpStdio(config, options);
|
|
3003
|
+
}
|
|
3004
|
+
|
|
1907
3005
|
async function examples(options) {
|
|
1908
3006
|
const data = {
|
|
1909
3007
|
login: 'clipit login',
|
|
1910
3008
|
installCodexSkill: 'clipit agent install codex',
|
|
1911
3009
|
validate: 'clipit doctor --json',
|
|
1912
|
-
|
|
3010
|
+
uploadVideo: 'clipit videos upload ./source.mp4 --confirm --json',
|
|
3011
|
+
importUrl: 'clipit videos import-url "https://www.youtube.com/watch?v=..." --confirm --json',
|
|
1913
3012
|
waitForJob: 'clipit jobs wait <jobId> --json',
|
|
1914
3013
|
ask: 'clipit ask "Find the strongest clip in this video" --video-id <videoId>',
|
|
1915
3014
|
approveWorkflow: 'clipit workflow approve <jobId> --approval-id <approvalId> --decision approved',
|
|
1916
3015
|
waitForWorkflow: 'clipit workflow wait <jobId> --stream',
|
|
1917
3016
|
setContext: 'clipit context use --video-id <videoId>',
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
3017
|
+
mcp: 'clipit mcp stdio',
|
|
3018
|
+
suggestClips: 'clipit videos suggest-clips <videoId> --count 5 --confirm --json',
|
|
3019
|
+
createClip: 'clipit clips create --video-id <videoId> --start 12 --end 42 --title "Strong hook" --confirm --json',
|
|
3020
|
+
renderClip: 'clipit clips render <clipId> --aspect 9:16 --quality high --confirm --json',
|
|
1921
3021
|
creditsBalance: 'clipit credits balance --json',
|
|
3022
|
+
billingCapabilities: 'clipit billing capabilities --json',
|
|
3023
|
+
billingCatalog: 'clipit billing catalog --json',
|
|
3024
|
+
billingX402Attempt: 'clipit billing create-attempt --product-key boost --provider x402_direct --confirm --json',
|
|
3025
|
+
billingStripeX402Attempt: 'clipit billing create-attempt --product-key boost --provider stripe_x402 --confirm --json',
|
|
3026
|
+
billingLinkAttempt: 'clipit billing create-attempt --product-key boost --provider stripe_mpp --confirm --json',
|
|
1922
3027
|
analyticsOverview: 'clipit analytics overview --days 30 --json',
|
|
1923
3028
|
exportClip: 'clipit exports start --clip-id <clipId> --confirm --json',
|
|
1924
|
-
uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --json',
|
|
3029
|
+
uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
|
|
1925
3030
|
thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
|
|
1926
3031
|
brollPlan: 'clipit broll plan <clipId> --count 3 --confirm --json',
|
|
1927
3032
|
socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json',
|
|
@@ -1945,11 +3050,16 @@ Rules:
|
|
|
1945
3050
|
- If not connected, ask the user to run \`clipit login\` and approve the browser link.
|
|
1946
3051
|
- Never ask the user to paste API keys into chat.
|
|
1947
3052
|
- Use \`clipit skills list --json\` and \`clipit tools list --json\` to discover capability.
|
|
3053
|
+
- Use \`clipit mcp stdio\` when an MCP-compatible client can launch local stdio servers; it speaks standard \`Content-Length\` framed JSON-RPC.
|
|
1948
3054
|
- Prefer friendly commands such as \`clipit videos list --json\`, \`clipit clips list --json\`, \`clipit credits balance --json\`, and \`clipit jobs wait <jobId> --json\`.
|
|
1949
3055
|
- Use \`clipit ask "..."\` for natural-language Clippy workflows, and \`clipit workflow wait <jobId> --json\` or \`clipit workflow approve <jobId> --approval-id <id>\` for workflow follow-through.
|
|
3056
|
+
- Use \`clipit ask "..." --auto-confirm-costly\` only when the human has asked you to work autonomously on ClipIt-only cost-spending tasks. It never authorizes social publishing/scheduling or destructive actions.
|
|
1950
3057
|
- Use \`clipit run <functionName> --params @file.json --json\` for exact Clippy tools.
|
|
1951
3058
|
- Treat paid generation, publishing, deletion, broad mutation, and \`requiresConfirmation\` responses as user approval checkpoints.
|
|
1952
3059
|
- Treat exit code 13 as insufficient credits or an API key spend-limit block; top up billing or adjust the key's spend limit in ClipIt Settings.
|
|
3060
|
+
- When credits are insufficient, discover payment rails with \`clipit billing capabilities --json\` and \`clipit billing catalog --json\`.
|
|
3061
|
+
- Only create direct x402, Stripe-managed x402, or Stripe Link/SPT payment attempts after explicit human approval or a configured budget policy: \`clipit billing create-attempt --product-key <key> --provider x402_direct|stripe_x402|stripe_mpp --confirm --json\`.
|
|
3062
|
+
- Credits from subscriptions and top-ups stack; paid features are credit-gated, so verify the payment receipt or balance before running paid ClipIt tools.
|
|
1953
3063
|
- Use \`clipit open clip <id>\` when the user should review work in ClipIt.
|
|
1954
3064
|
- Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
|
|
1955
3065
|
- Do not write API keys into this skill file or any project files.
|
|
@@ -1961,24 +3071,32 @@ clipit auth status --json
|
|
|
1961
3071
|
clipit skills list --json
|
|
1962
3072
|
clipit tools list --json
|
|
1963
3073
|
clipit tools describe <functionName> --json
|
|
3074
|
+
clipit mcp stdio
|
|
1964
3075
|
clipit ask "Find the strongest clip in this video" --video-id <videoId> --json
|
|
1965
3076
|
clipit workflow wait <jobId> --stream
|
|
1966
3077
|
clipit videos list --json
|
|
1967
|
-
clipit videos
|
|
3078
|
+
clipit videos upload ./source.mp4 --confirm --json
|
|
3079
|
+
clipit videos import-url "https://example.com/video" --confirm --json
|
|
1968
3080
|
clipit videos transcript <videoId> --json
|
|
1969
|
-
clipit videos suggest-clips <videoId> --count 5 --json
|
|
3081
|
+
clipit videos suggest-clips <videoId> --count 5 --confirm --json
|
|
1970
3082
|
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
|
|
3083
|
+
clipit clips create --video-id <videoId> --start 12 --end 42 --title "Hook" --confirm --json
|
|
3084
|
+
clipit clips render <clipId> --aspect 9:16 --quality high --confirm --json
|
|
1973
3085
|
clipit jobs wait <jobId> --stream
|
|
1974
3086
|
clipit credits balance --json
|
|
1975
3087
|
clipit credits usage --json
|
|
1976
3088
|
clipit credits estimate --operation-type transcription --provider deepgram --metrics @metrics.json --json
|
|
3089
|
+
clipit billing capabilities --json
|
|
3090
|
+
clipit billing catalog --json
|
|
3091
|
+
clipit billing create-attempt --product-key boost --provider x402_direct --confirm --json
|
|
3092
|
+
clipit billing create-attempt --product-key boost --provider stripe_x402 --confirm --json
|
|
3093
|
+
clipit billing create-attempt --product-key boost --provider stripe_mpp --confirm --json
|
|
3094
|
+
clipit billing receipt <attemptId> --json
|
|
1977
3095
|
clipit analytics overview --days 30 --json
|
|
1978
3096
|
clipit exports start --clip-id <clipId> --confirm --json
|
|
1979
3097
|
clipit thumbnails generate --clip-id <clipId> --prompt "High contrast thumbnail" --confirm --json
|
|
1980
3098
|
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
|
|
3099
|
+
clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
|
|
1982
3100
|
\`\`\`
|
|
1983
3101
|
|
|
1984
3102
|
Common workflow:
|
|
@@ -1992,6 +3110,28 @@ Agent target: ${agent}
|
|
|
1992
3110
|
`;
|
|
1993
3111
|
}
|
|
1994
3112
|
|
|
3113
|
+
function mcpSkillAddendum() {
|
|
3114
|
+
return [
|
|
3115
|
+
'## MCP Stdio Bridge',
|
|
3116
|
+
'- If your agent runtime supports MCP stdio servers, prefer launching `clipit mcp stdio` from the user\'s machine instead of manually shelling every command.',
|
|
3117
|
+
'- The bridge uses the authenticated CLI profile, speaks standard `Content-Length` framed JSON-RPC over stdio, and exposes the same live tools as `clipit tools list --json` / `GET /api/v1/agent/tools`.',
|
|
3118
|
+
'- `tools/call` delegates to `/api/v1/agent/execute`, inherits the active CLI context from `clipit context use`, and preserves confirmation gates for paid generation, publishing, deletion, export, and other mutating calls.',
|
|
3119
|
+
'- The bridge also exposes local billing discovery tools: `getPaymentCapabilities`, `getBillingCatalog`, `createPaymentAttempt`, `getPaymentAttempt`, `getPaymentReceipt`, and `getBillingSubscription`.',
|
|
3120
|
+
'- `createPaymentAttempt` returns a payment URL for direct x402, Stripe-managed x402, or Stripe MPP/Link SPT settlement and requires `confirmed:true`; do not retry it until the human approves the product, amount, rail, and budget policy.',
|
|
3121
|
+
'- If the MCP client cannot launch local commands, fall back to the CLI/API commands above.',
|
|
3122
|
+
].join('\n');
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
function withLocalCliSkillAddenda(markdown) {
|
|
3126
|
+
const addenda = [];
|
|
3127
|
+
let next = String(markdown || '');
|
|
3128
|
+
if (!/clipit\s+mcp\s+stdio/i.test(next) && !/MCP Stdio Bridge/i.test(next)) {
|
|
3129
|
+
next = `${next.trimEnd()}\n\n${mcpSkillAddendum()}\n`;
|
|
3130
|
+
addenda.push('mcp-stdio-bridge');
|
|
3131
|
+
}
|
|
3132
|
+
return { markdown: next, addenda };
|
|
3133
|
+
}
|
|
3134
|
+
|
|
1995
3135
|
function fallbackSkillResult(target, reason) {
|
|
1996
3136
|
const generatedAt = new Date().toISOString();
|
|
1997
3137
|
return {
|
|
@@ -2041,10 +3181,14 @@ async function resolveAgentSkill(config, options, target) {
|
|
|
2041
3181
|
});
|
|
2042
3182
|
}
|
|
2043
3183
|
|
|
3184
|
+
const augmented = withLocalCliSkillAddenda(response.markdown);
|
|
2044
3185
|
return {
|
|
2045
|
-
markdown:
|
|
3186
|
+
markdown: augmented.markdown,
|
|
2046
3187
|
source: 'server',
|
|
2047
|
-
meta:
|
|
3188
|
+
meta: compactObject({
|
|
3189
|
+
...response.meta,
|
|
3190
|
+
localCliAddenda: augmented.addenda.length ? augmented.addenda : undefined,
|
|
3191
|
+
}),
|
|
2048
3192
|
};
|
|
2049
3193
|
}
|
|
2050
3194
|
|
|
@@ -2065,6 +3209,7 @@ function agentSkillSidecar(target, skill) {
|
|
|
2065
3209
|
target,
|
|
2066
3210
|
source: skill.source,
|
|
2067
3211
|
generatedAt: skill.meta?.generatedAt,
|
|
3212
|
+
markdownHash: hashText(skill.markdown),
|
|
2068
3213
|
serverMeta: skill.source === 'server' ? skill.meta : undefined,
|
|
2069
3214
|
fallbackReason: skill.fallbackReason,
|
|
2070
3215
|
});
|
|
@@ -2088,7 +3233,11 @@ function agentInstallDir(target, options) {
|
|
|
2088
3233
|
async function installedAgentStatus(target, options) {
|
|
2089
3234
|
const baseDir = agentInstallDir(target, options);
|
|
2090
3235
|
try {
|
|
2091
|
-
const
|
|
3236
|
+
const skillPath = path.join(baseDir, 'SKILL.md');
|
|
3237
|
+
const [stat, markdown] = await Promise.all([
|
|
3238
|
+
fs.stat(skillPath),
|
|
3239
|
+
fs.readFile(skillPath, 'utf8'),
|
|
3240
|
+
]);
|
|
2092
3241
|
const sidecar = await readAgentSkillMeta(baseDir);
|
|
2093
3242
|
return {
|
|
2094
3243
|
target,
|
|
@@ -2098,6 +3247,7 @@ async function installedAgentStatus(target, options) {
|
|
|
2098
3247
|
skillSource: sidecar?.source || 'unknown',
|
|
2099
3248
|
generatedAt: sidecar?.generatedAt || null,
|
|
2100
3249
|
fallbackReason: sidecar?.fallbackReason || null,
|
|
3250
|
+
markdownHash: sidecar?.markdownHash || hashText(markdown),
|
|
2101
3251
|
serverMeta: sidecar?.serverMeta || null,
|
|
2102
3252
|
};
|
|
2103
3253
|
} catch {
|
|
@@ -2105,6 +3255,24 @@ async function installedAgentStatus(target, options) {
|
|
|
2105
3255
|
}
|
|
2106
3256
|
}
|
|
2107
3257
|
|
|
3258
|
+
function agentFreshnessReason(status, currentSkill) {
|
|
3259
|
+
const currentMeta = currentSkill.source === 'server' ? currentSkill.meta : null;
|
|
3260
|
+
if (status.skillSource !== currentSkill.source) {
|
|
3261
|
+
return `installed source ${status.skillSource || 'unknown'} differs from current source ${currentSkill.source}`;
|
|
3262
|
+
}
|
|
3263
|
+
if (status.serverMeta?.toolCount !== undefined
|
|
3264
|
+
&& currentMeta?.toolCount !== undefined
|
|
3265
|
+
&& status.serverMeta.toolCount !== currentMeta.toolCount) {
|
|
3266
|
+
return `installed tool count ${status.serverMeta.toolCount} differs from current tool count ${currentMeta.toolCount}`;
|
|
3267
|
+
}
|
|
3268
|
+
if (status.serverMeta?.skillCount !== undefined
|
|
3269
|
+
&& currentMeta?.skillCount !== undefined
|
|
3270
|
+
&& status.serverMeta.skillCount !== currentMeta.skillCount) {
|
|
3271
|
+
return `installed skill count ${status.serverMeta.skillCount} differs from current skill count ${currentMeta.skillCount}`;
|
|
3272
|
+
}
|
|
3273
|
+
return 'installed skill content differs from current generated instructions';
|
|
3274
|
+
}
|
|
3275
|
+
|
|
2108
3276
|
async function agent(config, options, action, args) {
|
|
2109
3277
|
const target = args[0] || 'generic';
|
|
2110
3278
|
const baseDir = agentInstallDir(target, options);
|
|
@@ -2121,12 +3289,41 @@ async function agent(config, options, action, args) {
|
|
|
2121
3289
|
}
|
|
2122
3290
|
if (action === 'doctor') {
|
|
2123
3291
|
const status = await installedAgentStatus(target, options);
|
|
3292
|
+
const hasCredential = Boolean(getApiKey(config, options));
|
|
2124
3293
|
status.cli = {
|
|
2125
3294
|
version: VERSION,
|
|
2126
3295
|
configPath: configPath(),
|
|
2127
3296
|
profile: profileName(config, options),
|
|
2128
|
-
hasCredential
|
|
3297
|
+
hasCredential,
|
|
2129
3298
|
};
|
|
3299
|
+
if (status.installed) {
|
|
3300
|
+
if (!hasCredential) {
|
|
3301
|
+
status.upToDate = null;
|
|
3302
|
+
status.stale = null;
|
|
3303
|
+
status.freshnessError = 'Cannot verify installed skill freshness without credentials.';
|
|
3304
|
+
} else {
|
|
3305
|
+
const currentSkill = await resolveAgentSkill(config, options, target);
|
|
3306
|
+
status.latest = compactObject({
|
|
3307
|
+
skillSource: currentSkill.source,
|
|
3308
|
+
generatedAt: currentSkill.meta?.generatedAt || null,
|
|
3309
|
+
serverMeta: currentSkill.source === 'server' ? currentSkill.meta : undefined,
|
|
3310
|
+
fallbackReason: currentSkill.fallbackReason,
|
|
3311
|
+
});
|
|
3312
|
+
if (currentSkill.source === 'fallback') {
|
|
3313
|
+
status.upToDate = null;
|
|
3314
|
+
status.stale = null;
|
|
3315
|
+
status.freshnessError = `Cannot verify installed skill freshness: ${currentSkill.fallbackReason || 'current instructions unavailable'}`;
|
|
3316
|
+
} else {
|
|
3317
|
+
const currentHash = hashText(currentSkill.markdown);
|
|
3318
|
+
status.upToDate = status.markdownHash === currentHash;
|
|
3319
|
+
status.stale = !status.upToDate;
|
|
3320
|
+
if (status.stale) {
|
|
3321
|
+
status.staleReason = agentFreshnessReason(status, currentSkill);
|
|
3322
|
+
status.updateCommand = `clipit agent update ${target}`;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
2130
3327
|
output(status, options);
|
|
2131
3328
|
return;
|
|
2132
3329
|
}
|
|
@@ -2160,6 +3357,10 @@ async function main() {
|
|
|
2160
3357
|
const config = await readConfig();
|
|
2161
3358
|
const [command, subcommand, ...rest] = positionals;
|
|
2162
3359
|
|
|
3360
|
+
if (options.version === true || options.version === 'true') {
|
|
3361
|
+
output({ version: VERSION }, options);
|
|
3362
|
+
return;
|
|
3363
|
+
}
|
|
2163
3364
|
if (!command || options.help) {
|
|
2164
3365
|
console.log(usage());
|
|
2165
3366
|
return;
|
|
@@ -2182,11 +3383,13 @@ async function main() {
|
|
|
2182
3383
|
if (command === 'tools' && subcommand === 'describe') return describeTool(config, options, rest[0]);
|
|
2183
3384
|
if (command === 'ask') return askWorkflow(config, options, [subcommand, ...rest]);
|
|
2184
3385
|
if (command === 'workflow') return workflow(config, options, subcommand, rest);
|
|
3386
|
+
if (command === 'mcp') return mcp(config, options, subcommand);
|
|
2185
3387
|
if (command === 'run') return runTool(config, options, subcommand);
|
|
2186
3388
|
if (command === 'videos') return videos(config, options, subcommand, rest);
|
|
2187
3389
|
if (command === 'clips') return clips(config, options, subcommand, rest);
|
|
2188
3390
|
if (command === 'jobs') return jobs(config, options, subcommand, rest);
|
|
2189
3391
|
if (command === 'credits') return credits(config, options, subcommand, rest);
|
|
3392
|
+
if (command === 'billing') return billing(config, options, subcommand, rest);
|
|
2190
3393
|
if (command === 'analytics') return analytics(config, options, subcommand, rest);
|
|
2191
3394
|
if (command === 'exports') return exportsCommand(config, options, subcommand, rest);
|
|
2192
3395
|
if (command === 'assets') return assets(config, options, subcommand, rest);
|
|
@@ -2235,4 +3438,4 @@ if (await isDirectRun()) {
|
|
|
2235
3438
|
main().catch(handleMainError);
|
|
2236
3439
|
}
|
|
2237
3440
|
|
|
2238
|
-
export { main, redact };
|
|
3441
|
+
export { main, redact, clipCostLabel, handleMcpRequest };
|