@clipit-ai/cli 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +249 -0
- package/bin/clipit.mjs +2183 -0
- package/package.json +39 -0
package/bin/clipit.mjs
ADDED
|
@@ -0,0 +1,2183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { Transform } from 'node:stream';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const VERSION = '0.2.1';
|
|
13
|
+
const DEFAULT_BASE_URL = 'https://clipit.dev';
|
|
14
|
+
const DEFAULT_SCOPES = [
|
|
15
|
+
'clippy_agent',
|
|
16
|
+
'video_processing',
|
|
17
|
+
'url_extraction',
|
|
18
|
+
'file_upload',
|
|
19
|
+
'transcription',
|
|
20
|
+
'clip_generation',
|
|
21
|
+
'caption_generation',
|
|
22
|
+
'thumbnail_generation',
|
|
23
|
+
'broll_generation',
|
|
24
|
+
'audio_generation',
|
|
25
|
+
'video_alteration',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const GET_RETRY_DELAYS_MS = [500, 2000];
|
|
29
|
+
const RETRY_AFTER_CAP_MS = 10_000;
|
|
30
|
+
const RETRYABLE_GET_STATUSES = new Set([429, 502, 503, 504]);
|
|
31
|
+
const RECENT_LIMIT = 20;
|
|
32
|
+
const REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND = 0.0015;
|
|
33
|
+
const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
|
|
34
|
+
'exports start': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
|
|
35
|
+
'thumbnails generate': [{ operationType: 'thumbnail_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
|
|
36
|
+
'broll plan': [{ operationType: 'ai_chat', provider: 'openrouter', modelId: 'minimax/minimax-m2.7', metrics: 'broll-plan' }],
|
|
37
|
+
'broll generate': [
|
|
38
|
+
{ operationType: 'image_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'broll-images' },
|
|
39
|
+
{ operationType: 'video_generation', provider: 'replicate', modelId: 'alibaba/happyhorse-1.0', metrics: 'broll-video' },
|
|
40
|
+
],
|
|
41
|
+
'clips render': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
|
|
42
|
+
'videos import-url': [{ operationType: null, provider: null, metrics: 'url-import' }],
|
|
43
|
+
'social post': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
|
|
44
|
+
'social schedule': [{ operationType: 'social_post', provider: 'zernio', metrics: 'social-platforms' }],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const EXIT = {
|
|
48
|
+
OK: 0,
|
|
49
|
+
USAGE: 2,
|
|
50
|
+
AUTH: 10,
|
|
51
|
+
PERMISSION: 11,
|
|
52
|
+
CONFIRMATION: 12,
|
|
53
|
+
CREDITS: 13,
|
|
54
|
+
NETWORK: 20,
|
|
55
|
+
SERVER: 21,
|
|
56
|
+
};
|
|
57
|
+
const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
58
|
+
const TERMINAL_WORKFLOW_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
59
|
+
const WORKFLOW_APPROVAL_DECISIONS = new Set(['approved', 'cheaper', 'cancelled']);
|
|
60
|
+
const KNOWN_AGENT_TARGETS = ['codex', 'claude', 'hermes', 'generic'];
|
|
61
|
+
const TRUSTED_HOSTS = new Set(['clipit.dev', 'www.clipit.dev', 'localhost', '127.0.0.1', '::1', '[::1]']);
|
|
62
|
+
const AGENT_SKILL_META_FILENAME = 'SKILL.meta.json';
|
|
63
|
+
|
|
64
|
+
function configDir() {
|
|
65
|
+
if (process.env.CLIPIT_CONFIG_DIR) return process.env.CLIPIT_CONFIG_DIR;
|
|
66
|
+
if (process.platform === 'win32' && process.env.APPDATA) return path.join(process.env.APPDATA, 'ClipIt');
|
|
67
|
+
return path.join(os.homedir(), '.config', 'clipit');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function configPath() {
|
|
71
|
+
return path.join(configDir(), 'config.json');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readConfig() {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(await fs.readFile(configPath(), 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function writeConfig(config) {
|
|
83
|
+
await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
84
|
+
await fs.writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function profileName(config, options) {
|
|
88
|
+
return String(options.profile || process.env.CLIPIT_PROFILE || config.currentProfile || 'default');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function legacyProfile(config) {
|
|
92
|
+
return {
|
|
93
|
+
baseUrl: config.baseUrl,
|
|
94
|
+
apiKey: config.apiKey,
|
|
95
|
+
keyInfo: config.keyInfo,
|
|
96
|
+
loginSource: config.loginSource,
|
|
97
|
+
activeContext: config.activeContext,
|
|
98
|
+
recent: config.recent,
|
|
99
|
+
updatedAt: config.updatedAt,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function profileData(config, options) {
|
|
104
|
+
const name = profileName(config, options);
|
|
105
|
+
const fromProfiles = config.profiles?.[name] || {};
|
|
106
|
+
return name === 'default' ? { ...legacyProfile(config), ...fromProfiles } : fromProfiles;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function updateProfile(config, options, updates) {
|
|
110
|
+
const name = profileName(config, options);
|
|
111
|
+
const profiles = { ...(config.profiles || {}) };
|
|
112
|
+
const current = profileData(config, options);
|
|
113
|
+
const nextProfile = { ...current, ...updates, updatedAt: new Date().toISOString() };
|
|
114
|
+
profiles[name] = nextProfile;
|
|
115
|
+
|
|
116
|
+
const next = {
|
|
117
|
+
...config,
|
|
118
|
+
profiles,
|
|
119
|
+
currentProfile: name,
|
|
120
|
+
updatedAt: nextProfile.updatedAt,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (name === 'default') {
|
|
124
|
+
Object.assign(next, nextProfile);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function recentEntries(config, options) {
|
|
131
|
+
const recent = profileData(config, options).recent;
|
|
132
|
+
return Array.isArray(recent) ? recent : [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function appendRecentEntry(config, options, type, id) {
|
|
136
|
+
if (!id) return config;
|
|
137
|
+
const entry = { type, id: String(id), at: new Date().toISOString() };
|
|
138
|
+
const recent = [
|
|
139
|
+
entry,
|
|
140
|
+
...recentEntries(config, options).filter((item) => item?.type !== entry.type || item?.id !== entry.id),
|
|
141
|
+
].slice(0, RECENT_LIMIT);
|
|
142
|
+
return updateProfile(config, options, { recent });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function appendRecentEntries(config, options, entries) {
|
|
146
|
+
return entries.reduce((nextConfig, entry) => appendRecentEntry(nextConfig, options, entry.type, entry.id), config);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function removeProfileFields(config, options, fields) {
|
|
150
|
+
const name = profileName(config, options);
|
|
151
|
+
const profiles = { ...(config.profiles || {}) };
|
|
152
|
+
const nextProfile = { ...profileData(config, options), updatedAt: new Date().toISOString() };
|
|
153
|
+
for (const field of fields) delete nextProfile[field];
|
|
154
|
+
profiles[name] = nextProfile;
|
|
155
|
+
|
|
156
|
+
const next = {
|
|
157
|
+
...config,
|
|
158
|
+
profiles,
|
|
159
|
+
currentProfile: name,
|
|
160
|
+
updatedAt: nextProfile.updatedAt,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (name === 'default') {
|
|
164
|
+
for (const field of fields) delete next[field];
|
|
165
|
+
Object.assign(next, nextProfile);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseArgv(argv) {
|
|
172
|
+
const options = {};
|
|
173
|
+
const positionals = [];
|
|
174
|
+
for (let i = 0; i < argv.length; i++) {
|
|
175
|
+
const arg = argv[i];
|
|
176
|
+
if (!arg.startsWith('--')) {
|
|
177
|
+
positionals.push(arg);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const eq = arg.indexOf('=');
|
|
181
|
+
if (eq !== -1) {
|
|
182
|
+
options[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const key = arg.slice(2);
|
|
186
|
+
const next = argv[i + 1];
|
|
187
|
+
if (next && !next.startsWith('--')) {
|
|
188
|
+
options[key] = next;
|
|
189
|
+
i++;
|
|
190
|
+
} else {
|
|
191
|
+
options[key] = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { options, positionals };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function wantJson(options) {
|
|
198
|
+
return options.json === true || options.json === 'true';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function redact(value) {
|
|
202
|
+
if (typeof value !== 'string') return value;
|
|
203
|
+
return value
|
|
204
|
+
.replace(/clipper_[a-f0-9]{24,}/gi, 'clipper_[redacted]')
|
|
205
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]')
|
|
206
|
+
.replace(/([?&](?:X-Amz-Signature|Signature|token|key)=)[^&\s]+/gi, '$1[redacted]');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function output(data, options) {
|
|
210
|
+
if (wantJson(options)) {
|
|
211
|
+
console.log(JSON.stringify(data, null, 2));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (typeof data === 'string') {
|
|
215
|
+
console.log(data);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
console.log(JSON.stringify(data, null, 2));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function redactDeep(value) {
|
|
222
|
+
if (typeof value === 'string') return redact(value);
|
|
223
|
+
if (Array.isArray(value)) return value.map((item) => redactDeep(item));
|
|
224
|
+
if (value && typeof value === 'object') {
|
|
225
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactDeep(entry)]));
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function usage() {
|
|
231
|
+
return [
|
|
232
|
+
'ClipIt CLI',
|
|
233
|
+
'',
|
|
234
|
+
'Global flags: --json --profile <name> --base-url <url> --allow-custom-host --no-retry --max-credits <n>',
|
|
235
|
+
'',
|
|
236
|
+
'Usage:',
|
|
237
|
+
' clipit login [--base-url URL] [--profile NAME] [--no-browser]',
|
|
238
|
+
' clipit setup [--agent codex|claude|hermes|openclaw|<any-framework>]',
|
|
239
|
+
' clipit doctor [--json]',
|
|
240
|
+
' clipit auth status [--json]',
|
|
241
|
+
' clipit auth set-key --stdin',
|
|
242
|
+
' clipit auth profiles',
|
|
243
|
+
' clipit context use [--video-id id] [--clip-id id] [--project-id id] [--sequence-id id]',
|
|
244
|
+
' clipit context show|clear|build [--json]',
|
|
245
|
+
' clipit skills list [--json]',
|
|
246
|
+
' clipit tools list [--skill clip] [--json]',
|
|
247
|
+
' clipit tools describe <functionName>',
|
|
248
|
+
' clipit ask "<prompt>" [--video-id id] [--clip-id id] [--conversation-id id] [--quick] [--stream] [--json]',
|
|
249
|
+
' clipit workflow status|wait <jobId> [--stream] [--json]',
|
|
250
|
+
' clipit workflow approve <jobId> --approval-id id [--decision approved|cheaper|cancelled]',
|
|
251
|
+
' clipit run <functionName> [--params @file.json] [--clip-id id] [--video-id id] [--confirm] [--max-credits n]',
|
|
252
|
+
' clipit videos list|get|upload|import-url|transcribe|transcript|suggest-clips|delete ...',
|
|
253
|
+
' clipit clips list|get|create|update|render|download|delete ...',
|
|
254
|
+
' clipit jobs get|wait <jobId>',
|
|
255
|
+
' clipit credits balance|usage|estimate ...',
|
|
256
|
+
' clipit analytics overview|top-clips|post ...',
|
|
257
|
+
' clipit exports start|list|get|wait|download|cancel ...',
|
|
258
|
+
' clipit assets list|upload|delete ...',
|
|
259
|
+
' clipit thumbnails generate|get|list ...',
|
|
260
|
+
' clipit broll plan|generate|list|get ...',
|
|
261
|
+
' clipit social accounts|post|schedule|posts|get|cancel ...',
|
|
262
|
+
' clipit open clip|video|settings ...',
|
|
263
|
+
' clipit links clip|video|settings ...',
|
|
264
|
+
' clipit agent install|update|uninstall|doctor|print-skill|list codex|claude|hermes|openclaw|<any-framework>',
|
|
265
|
+
' clipit examples',
|
|
266
|
+
].join('\n');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getBaseUrl(config, options) {
|
|
270
|
+
const profile = profileData(config, options);
|
|
271
|
+
return String(options['base-url'] || process.env.CLIPPER_BASE_URL || profile.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getApiKey(config, options) {
|
|
275
|
+
const profile = profileData(config, options);
|
|
276
|
+
return process.env.CLIPPER_API_KEY || options['api-key'] || profile.apiKey || null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function readStdin() {
|
|
280
|
+
const chunks = [];
|
|
281
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
282
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function boolOption(value) {
|
|
286
|
+
return value === true || value === 'true' || value === '1' || value === 'yes';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function configuredTrustedHosts() {
|
|
290
|
+
return String(process.env.CLIPIT_TRUSTED_HOSTS || '')
|
|
291
|
+
.split(',')
|
|
292
|
+
.map((host) => host.trim().toLowerCase())
|
|
293
|
+
.filter(Boolean);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isTrustedBaseUrl(baseUrl) {
|
|
297
|
+
try {
|
|
298
|
+
const parsed = new URL(baseUrl);
|
|
299
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
300
|
+
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && TRUSTED_HOSTS.has(hostname))) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
return TRUSTED_HOSTS.has(hostname)
|
|
304
|
+
|| hostname.endsWith('.clipit.dev')
|
|
305
|
+
|| configuredTrustedHosts().includes(hostname);
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function allowCustomHost(options) {
|
|
312
|
+
return boolOption(options['allow-custom-host']) || boolOption(process.env.CLIPIT_ALLOW_CUSTOM_HOST);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function assertTrustedBaseUrl(baseUrl, options) {
|
|
316
|
+
if (isTrustedBaseUrl(baseUrl) || allowCustomHost(options)) return;
|
|
317
|
+
throw Object.assign(
|
|
318
|
+
new Error(`Refusing to contact untrusted ClipIt API host: ${baseUrl}. Pass --allow-custom-host or set CLIPIT_ALLOW_CUSTOM_HOST=true for staging/self-hosted use.`),
|
|
319
|
+
{ exitCode: EXIT.USAGE },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function requireConfirm(options, action) {
|
|
324
|
+
if (!boolOption(options.confirm) && !boolOption(options.yes)) {
|
|
325
|
+
throw Object.assign(new Error(`${action} requires --confirm.`), { exitCode: EXIT.CONFIRMATION });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function confirmPaid(options, label) {
|
|
330
|
+
requireConfirm(options, label);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function requiredString(value, label) {
|
|
334
|
+
if (value === undefined || value === null || String(value).trim() === '') {
|
|
335
|
+
throw Object.assign(new Error(`${label} is required.`), { exitCode: EXIT.USAGE });
|
|
336
|
+
}
|
|
337
|
+
return String(value);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function numberOption(value, label) {
|
|
341
|
+
if (value === undefined) return undefined;
|
|
342
|
+
if (value === true || value === false) {
|
|
343
|
+
throw Object.assign(new Error(`${label} must be a number.`), { exitCode: EXIT.USAGE });
|
|
344
|
+
}
|
|
345
|
+
const parsed = Number(value);
|
|
346
|
+
if (!Number.isFinite(parsed)) {
|
|
347
|
+
throw Object.assign(new Error(`${label} must be a number.`), { exitCode: EXIT.USAGE });
|
|
348
|
+
}
|
|
349
|
+
return parsed;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function stringList(value) {
|
|
353
|
+
if (!value) return undefined;
|
|
354
|
+
return String(value)
|
|
355
|
+
.split(',')
|
|
356
|
+
.map((item) => item.trim())
|
|
357
|
+
.filter(Boolean);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function compactObject(value) {
|
|
361
|
+
for (const key of Object.keys(value)) {
|
|
362
|
+
if (value[key] === undefined) delete value[key];
|
|
363
|
+
}
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function queryString(params) {
|
|
368
|
+
const query = new URLSearchParams();
|
|
369
|
+
for (const [key, value] of Object.entries(params)) {
|
|
370
|
+
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
|
|
371
|
+
}
|
|
372
|
+
const suffix = query.toString();
|
|
373
|
+
return suffix ? `?${suffix}` : '';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function retryAfterMs(response) {
|
|
377
|
+
const retryAfter = response.headers.get('retry-after');
|
|
378
|
+
if (!retryAfter) return null;
|
|
379
|
+
const seconds = Number(retryAfter);
|
|
380
|
+
if (Number.isFinite(seconds)) return Math.min(Math.max(0, seconds * 1000), RETRY_AFTER_CAP_MS);
|
|
381
|
+
const dateMs = Date.parse(retryAfter);
|
|
382
|
+
if (Number.isFinite(dateMs)) return Math.min(Math.max(0, dateMs - Date.now()), RETRY_AFTER_CAP_MS);
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function retryDelayMs(response, retryIndex) {
|
|
387
|
+
return retryAfterMs(response) ?? GET_RETRY_DELAYS_MS[retryIndex] ?? GET_RETRY_DELAYS_MS.at(-1);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function apiFetch(config, options, method, endpoint, body, extra = {}) {
|
|
391
|
+
const baseUrl = getBaseUrl(config, options);
|
|
392
|
+
assertTrustedBaseUrl(baseUrl, options);
|
|
393
|
+
const methodName = String(method).toUpperCase();
|
|
394
|
+
const canRetry = methodName === 'GET' && !boolOption(options['no-retry']);
|
|
395
|
+
const headers = {
|
|
396
|
+
'User-Agent': `clipit-cli/${VERSION} (${process.platform}; ${process.arch})`,
|
|
397
|
+
...(extra.headers || {}),
|
|
398
|
+
};
|
|
399
|
+
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
400
|
+
if (body !== undefined && !isFormData && !extra.rawBody) headers['Content-Type'] = 'application/json';
|
|
401
|
+
const apiKey = extra.noAuth ? null : getApiKey(config, options);
|
|
402
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
403
|
+
|
|
404
|
+
let response;
|
|
405
|
+
for (let attempt = 0; attempt <= GET_RETRY_DELAYS_MS.length; attempt++) {
|
|
406
|
+
try {
|
|
407
|
+
const request = {
|
|
408
|
+
method: methodName,
|
|
409
|
+
headers,
|
|
410
|
+
body: body === undefined ? undefined : extra.rawBody ? body : isFormData ? body : JSON.stringify(body),
|
|
411
|
+
};
|
|
412
|
+
if (extra.rawBody && body !== undefined) {
|
|
413
|
+
request.duplex = 'half';
|
|
414
|
+
}
|
|
415
|
+
response = await fetch(`${baseUrl}${endpoint}`, request);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (canRetry && attempt < GET_RETRY_DELAYS_MS.length) {
|
|
418
|
+
await sleep(GET_RETRY_DELAYS_MS[attempt]);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
throw Object.assign(new Error(`Network error: ${error.message}`), { exitCode: EXIT.NETWORK });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (canRetry && RETRYABLE_GET_STATUSES.has(response.status) && attempt < GET_RETRY_DELAYS_MS.length) {
|
|
425
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
426
|
+
await sleep(retryDelayMs(response, attempt));
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const requestId = response.headers.get('x-request-id') || response.headers.get('X-Request-Id') || undefined;
|
|
433
|
+
const text = await response.text();
|
|
434
|
+
let data = null;
|
|
435
|
+
if (text.trim()) {
|
|
436
|
+
try {
|
|
437
|
+
data = JSON.parse(text);
|
|
438
|
+
} catch {
|
|
439
|
+
data = { raw: text };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
const message = response.status === 402 ? creditsErrorMessage(data) : data?.error || data?.message || response.statusText;
|
|
444
|
+
const exitCode = response.status === 401
|
|
445
|
+
? EXIT.AUTH
|
|
446
|
+
: response.status === 403
|
|
447
|
+
? EXIT.PERMISSION
|
|
448
|
+
: response.status === 402
|
|
449
|
+
? EXIT.CREDITS
|
|
450
|
+
: EXIT.SERVER;
|
|
451
|
+
throw Object.assign(new Error(`${response.status}: ${message}${requestId ? ` (request id: ${requestId})` : ''}`), {
|
|
452
|
+
exitCode,
|
|
453
|
+
status: response.status,
|
|
454
|
+
data,
|
|
455
|
+
requestId,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return data;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function pkcePair() {
|
|
462
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
463
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
464
|
+
return { verifier, challenge };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function openBrowser(url) {
|
|
468
|
+
const platform = process.platform;
|
|
469
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
470
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
471
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
472
|
+
child.unref();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function sleep(ms) {
|
|
476
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function maxCreditsLimit(options) {
|
|
480
|
+
if (options['max-credits'] === undefined) return null;
|
|
481
|
+
const limit = numberOption(options['max-credits'], '--max-credits');
|
|
482
|
+
if (limit < 0) {
|
|
483
|
+
throw Object.assign(new Error('--max-credits must be 0 or greater.'), { exitCode: EXIT.USAGE });
|
|
484
|
+
}
|
|
485
|
+
return limit;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function clipCostLabel(value) {
|
|
489
|
+
return `${Number(value).toFixed(2).replace(/\.00$/, '')} $CLIP`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
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.';
|
|
493
|
+
const INSUFFICIENT_CREDITS_MESSAGE = 'Insufficient credits \u2014 top up at https://clipit.dev/settings (Billing).';
|
|
494
|
+
|
|
495
|
+
function isSpendLimitCode(value) {
|
|
496
|
+
return value === 'SPEND_LIMIT_EXCEEDED';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function spendLimitViolationFromData(data) {
|
|
500
|
+
if (!data || typeof data !== 'object') return null;
|
|
501
|
+
if (data.spendLimitViolation) return data.spendLimitViolation;
|
|
502
|
+
if (isSpendLimitCode(data.code) || isSpendLimitCode(data.errorCode) || isSpendLimitCode(data.error_code)) return data;
|
|
503
|
+
const details = data.details;
|
|
504
|
+
if (details && typeof details === 'object') {
|
|
505
|
+
if (details.spendLimitViolation) return details.spendLimitViolation;
|
|
506
|
+
if (isSpendLimitCode(details.code) || isSpendLimitCode(details.errorCode) || isSpendLimitCode(details.error_code)) return details;
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function creditsErrorMessage(data) {
|
|
512
|
+
return spendLimitViolationFromData(data) ? SPEND_LIMIT_EXCEEDED_MESSAGE : INSUFFICIENT_CREDITS_MESSAGE;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function warnIfEstimateUnaffordable(estimates) {
|
|
516
|
+
if (estimates.some((estimate) => estimate.affordable === false)) {
|
|
517
|
+
console.error('Warning: estimated cost exceeds your current balance.');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function throwSpendLimitExceeded(commandKey, spendLimitViolation, estimates) {
|
|
522
|
+
throw Object.assign(new Error(SPEND_LIMIT_EXCEEDED_MESSAGE), {
|
|
523
|
+
exitCode: EXIT.CREDITS,
|
|
524
|
+
data: {
|
|
525
|
+
command: commandKey,
|
|
526
|
+
spendLimitViolation,
|
|
527
|
+
estimates,
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function isYoutubeUrl(value) {
|
|
533
|
+
try {
|
|
534
|
+
const hostname = new URL(value).hostname.toLowerCase();
|
|
535
|
+
return hostname.includes('youtube.com') || hostname.includes('youtu.be');
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function qualityMultiplier(quality) {
|
|
542
|
+
if (quality === '4k') return 2;
|
|
543
|
+
if (quality === 'standard') return 0.75;
|
|
544
|
+
return 1;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function remotionProviderCostUsd(durationSeconds, quality) {
|
|
548
|
+
return Math.max(0, durationSeconds * REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND * qualityMultiplier(quality));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function brollVideoProviderCostUsd(durationSeconds, resolution) {
|
|
552
|
+
const perSecondUsd = resolution === '1080p' ? 0.28 : 0.14;
|
|
553
|
+
return Math.max(0, durationSeconds * perSecondUsd);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function durationFromClip(clip, body = {}) {
|
|
557
|
+
const bodyStart = Number(body.startTime);
|
|
558
|
+
const bodyEnd = Number(body.endTime);
|
|
559
|
+
if (Number.isFinite(bodyStart) && Number.isFinite(bodyEnd) && bodyEnd > bodyStart) return bodyEnd - bodyStart;
|
|
560
|
+
|
|
561
|
+
const direct = Number(clip?.durationSeconds ?? clip?.duration);
|
|
562
|
+
if (Number.isFinite(direct) && direct > 0) return direct;
|
|
563
|
+
|
|
564
|
+
const start = Number(clip?.startTime);
|
|
565
|
+
const end = Number(clip?.endTime);
|
|
566
|
+
if (Number.isFinite(start) && Number.isFinite(end) && end > start) return end - start;
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function fetchClipForEstimate(config, options, clipId) {
|
|
571
|
+
return apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(clipId)}`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function progressTransform(progress) {
|
|
575
|
+
return new Transform({
|
|
576
|
+
transform(chunk, encoding, callback) {
|
|
577
|
+
progress.track(chunk);
|
|
578
|
+
callback(null, chunk);
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function buildMaxCreditsEstimateRequest(config, options, spec, context = {}) {
|
|
584
|
+
if (spec.metrics === 'url-import') {
|
|
585
|
+
const youtube = isYoutubeUrl(requiredString(context.url, 'URL'));
|
|
586
|
+
return {
|
|
587
|
+
operationType: youtube ? 'proxy_extraction' : 'url_extraction',
|
|
588
|
+
provider: youtube ? 'webshare' : 'internal',
|
|
589
|
+
metrics: { generationCount: 1 },
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (spec.metrics === 'one-generation') {
|
|
594
|
+
return { ...spec, metrics: { generationCount: 1 } };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (spec.metrics === 'broll-plan') {
|
|
598
|
+
return { ...spec, metrics: { inputTokens: 4000, outputTokens: 1000 } };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (spec.metrics === 'broll-images') {
|
|
602
|
+
const mode = context.body?.mode || 'single_image';
|
|
603
|
+
return { ...spec, metrics: { generationCount: mode === 'start_end_frame' ? 3 : 1 } };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (spec.metrics === 'broll-video') {
|
|
607
|
+
const durationSeconds = Number(context.body?.durationSeconds ?? 6);
|
|
608
|
+
const resolution = context.body?.resolution || '720p';
|
|
609
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return null;
|
|
610
|
+
return {
|
|
611
|
+
...spec,
|
|
612
|
+
metrics: {
|
|
613
|
+
videoSeconds: durationSeconds,
|
|
614
|
+
providerCostUsd: brollVideoProviderCostUsd(durationSeconds, resolution),
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (spec.metrics === 'social-platforms') {
|
|
620
|
+
const platforms = Array.isArray(context.body?.platforms) ? context.body.platforms : [];
|
|
621
|
+
if (!platforms.length) return null;
|
|
622
|
+
return { ...spec, metrics: { generationCount: platforms.length } };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (spec.metrics === 'remotion-render') {
|
|
626
|
+
const clipId = requiredString(context.clipId, '--clip-id');
|
|
627
|
+
const clip = context.clip || await fetchClipForEstimate(config, options, clipId);
|
|
628
|
+
const durationSeconds = durationFromClip(clip, context.body);
|
|
629
|
+
if (!durationSeconds) return null;
|
|
630
|
+
const quality = context.body?.quality || context.body?.qualitySettings?.qualityPreset || 'high';
|
|
631
|
+
return {
|
|
632
|
+
...spec,
|
|
633
|
+
metrics: {
|
|
634
|
+
videoSeconds: durationSeconds,
|
|
635
|
+
providerCostUsd: remotionProviderCostUsd(durationSeconds, quality),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function printEstimateUnavailable(commandKey) {
|
|
644
|
+
console.error(`estimate unavailable for ${commandKey}; --confirm is required.`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function enforceMaxCredits(config, options, commandKey, context = {}) {
|
|
648
|
+
const limit = maxCreditsLimit(options);
|
|
649
|
+
if (limit === null) return;
|
|
650
|
+
|
|
651
|
+
const specs = MAX_CREDITS_ESTIMATE_MAP[commandKey];
|
|
652
|
+
if (!specs) {
|
|
653
|
+
printEstimateUnavailable(commandKey);
|
|
654
|
+
requireConfirm(options, commandKey);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const requests = [];
|
|
659
|
+
for (const spec of specs) {
|
|
660
|
+
const request = await buildMaxCreditsEstimateRequest(config, options, spec, context);
|
|
661
|
+
if (!request) {
|
|
662
|
+
printEstimateUnavailable(commandKey);
|
|
663
|
+
requireConfirm(options, commandKey);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
requests.push(compactObject({
|
|
667
|
+
operationType: request.operationType,
|
|
668
|
+
provider: request.provider,
|
|
669
|
+
modelId: request.modelId,
|
|
670
|
+
metrics: request.metrics,
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const estimates = [];
|
|
675
|
+
for (const request of requests) {
|
|
676
|
+
const estimate = await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', request);
|
|
677
|
+
estimates.push({
|
|
678
|
+
operationType: request.operationType,
|
|
679
|
+
provider: request.provider,
|
|
680
|
+
modelId: request.modelId,
|
|
681
|
+
estimatedCostClip: Number(estimate?.estimatedCostClip ?? 0),
|
|
682
|
+
affordable: estimate?.affordable,
|
|
683
|
+
spendLimitViolation: estimate?.spendLimitViolation ?? null,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const spendLimitViolation = estimates.find((estimate) => estimate.spendLimitViolation)?.spendLimitViolation;
|
|
688
|
+
if (spendLimitViolation) throwSpendLimitExceeded(commandKey, spendLimitViolation, estimates);
|
|
689
|
+
warnIfEstimateUnaffordable(estimates);
|
|
690
|
+
|
|
691
|
+
const estimatedCostClip = estimates.reduce((sum, estimate) => sum + estimate.estimatedCostClip, 0);
|
|
692
|
+
if (estimatedCostClip > limit) {
|
|
693
|
+
throw Object.assign(
|
|
694
|
+
new Error(`Estimated cost ${clipCostLabel(estimatedCostClip)} exceeds --max-credits ${clipCostLabel(limit)} for ${commandKey}.`),
|
|
695
|
+
{
|
|
696
|
+
exitCode: EXIT.CONFIRMATION,
|
|
697
|
+
data: {
|
|
698
|
+
command: commandKey,
|
|
699
|
+
maxCredits: limit,
|
|
700
|
+
estimatedCostClip,
|
|
701
|
+
estimates,
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function enforceRunMaxCredits(config, options, functionName) {
|
|
709
|
+
if (maxCreditsLimit(options) === null) return;
|
|
710
|
+
const catalog = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
|
|
711
|
+
const tools = Array.isArray(catalog?.tools) ? catalog.tools : Array.isArray(catalog) ? catalog : [];
|
|
712
|
+
const tool = tools.find((item) => item.name === functionName);
|
|
713
|
+
const runEstimate = tool?.estimate ?? tool?.confirmation?.estimate ?? tool?.confirmation?.costEstimate ?? null;
|
|
714
|
+
const spendLimitViolation = spendLimitViolationFromData(runEstimate) ?? spendLimitViolationFromData(tool?.confirmation);
|
|
715
|
+
if (spendLimitViolation) throwSpendLimitExceeded(`run ${functionName}`, spendLimitViolation, runEstimate ? [runEstimate] : []);
|
|
716
|
+
if (runEstimate?.affordable === false || tool?.confirmation?.affordable === false) {
|
|
717
|
+
console.error('Warning: estimated cost exceeds your current balance.');
|
|
718
|
+
}
|
|
719
|
+
if (!tool?.confirmation?.required) return;
|
|
720
|
+
printEstimateUnavailable(`run ${functionName}`);
|
|
721
|
+
requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function login(config, options) {
|
|
725
|
+
const { verifier, challenge } = pkcePair();
|
|
726
|
+
const baseUrl = getBaseUrl(config, options);
|
|
727
|
+
const started = await apiFetch(config, options, 'POST', '/api/v1/cli-auth/start', {
|
|
728
|
+
codeChallenge: challenge,
|
|
729
|
+
codeChallengeMethod: 'S256',
|
|
730
|
+
requestedScopes: DEFAULT_SCOPES,
|
|
731
|
+
deviceName: os.hostname(),
|
|
732
|
+
cliVersion: VERSION,
|
|
733
|
+
platform: `${process.platform}/${process.arch}`,
|
|
734
|
+
}, { noAuth: true });
|
|
735
|
+
|
|
736
|
+
if (!options['no-browser']) {
|
|
737
|
+
try {
|
|
738
|
+
openBrowser(started.verificationUrlComplete);
|
|
739
|
+
} catch {}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (!wantJson(options)) {
|
|
743
|
+
console.log('Approve ClipIt CLI in your browser:');
|
|
744
|
+
console.log(` ${started.verificationUrlComplete}`);
|
|
745
|
+
console.log(`Code: ${started.userCode}`);
|
|
746
|
+
console.log('');
|
|
747
|
+
console.log('Waiting for approval...');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const expiresAt = new Date(started.expiresAt).getTime();
|
|
751
|
+
let lastStatus = 'pending';
|
|
752
|
+
while (Date.now() < expiresAt) {
|
|
753
|
+
await sleep((started.pollIntervalSeconds || 3) * 1000);
|
|
754
|
+
const poll = await apiFetch(config, options, 'POST', '/api/v1/cli-auth/poll', {
|
|
755
|
+
deviceCode: started.deviceCode,
|
|
756
|
+
}, { noAuth: true });
|
|
757
|
+
lastStatus = poll.status;
|
|
758
|
+
if (poll.status === 'approved') break;
|
|
759
|
+
if (['denied', 'expired', 'exchanged'].includes(poll.status)) {
|
|
760
|
+
throw Object.assign(new Error(`CLI login ${poll.status}`), { exitCode: EXIT.AUTH });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (lastStatus !== 'approved') {
|
|
765
|
+
throw Object.assign(new Error('CLI login expired before approval'), { exitCode: EXIT.AUTH });
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const exchanged = await apiFetch(config, options, 'POST', '/api/v1/cli-auth/exchange', {
|
|
769
|
+
deviceCode: started.deviceCode,
|
|
770
|
+
codeVerifier: verifier,
|
|
771
|
+
}, { noAuth: true });
|
|
772
|
+
|
|
773
|
+
const nextConfig = updateProfile(config, options, {
|
|
774
|
+
baseUrl,
|
|
775
|
+
apiKey: exchanged.apiKey,
|
|
776
|
+
keyInfo: exchanged.keyInfo,
|
|
777
|
+
loginSource: 'browser',
|
|
778
|
+
});
|
|
779
|
+
await writeConfig(nextConfig);
|
|
780
|
+
|
|
781
|
+
output({
|
|
782
|
+
success: true,
|
|
783
|
+
message: 'ClipIt CLI connected',
|
|
784
|
+
profile: profileName(config, options),
|
|
785
|
+
baseUrl,
|
|
786
|
+
keyName: exchanged.keyInfo?.keyName,
|
|
787
|
+
permissions: exchanged.keyInfo?.permissions,
|
|
788
|
+
}, options);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function setup(config, options) {
|
|
792
|
+
await login(config, options);
|
|
793
|
+
if (options.agent) {
|
|
794
|
+
const updatedConfig = await readConfig();
|
|
795
|
+
await agent(updatedConfig, options, 'install', [String(options.agent)]);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (!wantJson(options)) {
|
|
799
|
+
console.log('');
|
|
800
|
+
console.log('Next steps:');
|
|
801
|
+
console.log(' clipit agent install codex');
|
|
802
|
+
console.log(' clipit doctor');
|
|
803
|
+
console.log(' clipit skills list');
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function authStatus(config, options) {
|
|
808
|
+
const apiKey = getApiKey(config, options);
|
|
809
|
+
if (!apiKey) {
|
|
810
|
+
throw Object.assign(new Error('Not logged in. Run clipit login.'), { exitCode: EXIT.AUTH });
|
|
811
|
+
}
|
|
812
|
+
const me = await apiFetch(config, options, 'GET', '/api/v1/agent/me');
|
|
813
|
+
output(me, options);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function setKey(config, options) {
|
|
817
|
+
if (!options.stdin) {
|
|
818
|
+
throw Object.assign(new Error('Use --stdin to avoid shell history leaks.'), { exitCode: EXIT.USAGE });
|
|
819
|
+
}
|
|
820
|
+
const apiKey = (await readStdin()).trim();
|
|
821
|
+
if (!apiKey) {
|
|
822
|
+
throw Object.assign(new Error('No API key received on stdin.'), { exitCode: EXIT.USAGE });
|
|
823
|
+
}
|
|
824
|
+
const nextConfig = updateProfile(config, options, {
|
|
825
|
+
baseUrl: getBaseUrl(config, options),
|
|
826
|
+
apiKey,
|
|
827
|
+
loginSource: 'manual',
|
|
828
|
+
});
|
|
829
|
+
const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me');
|
|
830
|
+
await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey }));
|
|
831
|
+
output({ success: true, message: 'API key stored', profile: profileName(config, options), account: me.user, apiKey: me.apiKey }, options);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function logout(config, options) {
|
|
835
|
+
const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'loginSource']);
|
|
836
|
+
await writeConfig(next);
|
|
837
|
+
output({ success: true, message: 'Local ClipIt CLI credentials removed', profile: profileName(config, options) }, options);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function listProfiles(config, options) {
|
|
841
|
+
const profiles = config.profiles || {};
|
|
842
|
+
const names = [...new Set(['default', ...Object.keys(profiles)])];
|
|
843
|
+
output({
|
|
844
|
+
currentProfile: profileName(config, options),
|
|
845
|
+
profiles: names.map((name) => {
|
|
846
|
+
const data = name === 'default'
|
|
847
|
+
? { ...legacyProfile(config), ...(profiles.default || {}) }
|
|
848
|
+
: profiles[name] || {};
|
|
849
|
+
return {
|
|
850
|
+
name,
|
|
851
|
+
baseUrl: data.baseUrl || DEFAULT_BASE_URL,
|
|
852
|
+
hasCredential: Boolean(data.apiKey),
|
|
853
|
+
loginSource: data.loginSource || null,
|
|
854
|
+
keyName: data.keyInfo?.keyName || null,
|
|
855
|
+
updatedAt: data.updatedAt || null,
|
|
856
|
+
};
|
|
857
|
+
}),
|
|
858
|
+
}, options);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function doctor(config, options) {
|
|
862
|
+
const checks = {
|
|
863
|
+
version: VERSION,
|
|
864
|
+
node: process.version,
|
|
865
|
+
platform: `${process.platform}/${process.arch}`,
|
|
866
|
+
configPath: configPath(),
|
|
867
|
+
profile: profileName(config, options),
|
|
868
|
+
baseUrl: getBaseUrl(config, options),
|
|
869
|
+
hasCredential: Boolean(getApiKey(config, options)),
|
|
870
|
+
credentialSource: process.env.CLIPPER_API_KEY ? 'env' : getApiKey(config, options) ? 'config' : 'missing',
|
|
871
|
+
auth: null,
|
|
872
|
+
};
|
|
873
|
+
try {
|
|
874
|
+
checks.auth = await apiFetch(config, options, 'GET', '/api/v1/agent/me');
|
|
875
|
+
} catch (error) {
|
|
876
|
+
checks.auth = { ok: false, error: redact(error.message), status: error.status };
|
|
877
|
+
}
|
|
878
|
+
output(checks, options);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function listSkills(config, options) {
|
|
882
|
+
output(await apiFetch(config, options, 'GET', '/api/v1/agent/skills'), options);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function listTools(config, options) {
|
|
886
|
+
const params = new URLSearchParams();
|
|
887
|
+
if (options.skill) params.set('skill', String(options.skill));
|
|
888
|
+
if (options.category) params.set('category', String(options.category));
|
|
889
|
+
const suffix = params.toString() ? `?${params}` : '';
|
|
890
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/agent/tools${suffix}`), options);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function describeTool(config, options, name) {
|
|
894
|
+
if (!name) throw Object.assign(new Error('Tool name is required.'), { exitCode: EXIT.USAGE });
|
|
895
|
+
const catalog = await apiFetch(config, options, 'GET', '/api/v1/agent/tools');
|
|
896
|
+
const tool = catalog.tools?.find((item) => item.name === name);
|
|
897
|
+
if (!tool) throw Object.assign(new Error(`Tool not found: ${name}`), { exitCode: EXIT.USAGE });
|
|
898
|
+
output(tool, options);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function readJsonOption(value) {
|
|
902
|
+
if (!value) return {};
|
|
903
|
+
if (value === '@-') {
|
|
904
|
+
return JSON.parse(await readStdin());
|
|
905
|
+
}
|
|
906
|
+
if (value.startsWith('@')) {
|
|
907
|
+
return JSON.parse(await fs.readFile(value.slice(1), 'utf8'));
|
|
908
|
+
}
|
|
909
|
+
return JSON.parse(value);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function readMetricsOption(value) {
|
|
913
|
+
return value ? readJsonOption(String(value)) : {};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function buildContext(config, options) {
|
|
917
|
+
const activeContext = profileData(config, options).activeContext || {};
|
|
918
|
+
const context = { ...activeContext };
|
|
919
|
+
for (const [flag, field] of [
|
|
920
|
+
['video-id', 'videoId'],
|
|
921
|
+
['clip-id', 'clipId'],
|
|
922
|
+
['project-id', 'projectId'],
|
|
923
|
+
['sequence-id', 'sequenceId'],
|
|
924
|
+
]) {
|
|
925
|
+
if (options[flag]) context[field] = String(options[flag]);
|
|
926
|
+
}
|
|
927
|
+
if (options['selected-clip-ids']) context.selectedClipIds = stringList(options['selected-clip-ids']);
|
|
928
|
+
if (options.playhead !== undefined) context.playheadPosition = numberOption(options.playhead, '--playhead');
|
|
929
|
+
if (options.context) {
|
|
930
|
+
const fromFile = await readJsonOption(String(options.context));
|
|
931
|
+
Object.assign(context, fromFile);
|
|
932
|
+
}
|
|
933
|
+
return context;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function contextCommand(config, options, action) {
|
|
937
|
+
if (action === 'show') {
|
|
938
|
+
const profile = profileData(config, options);
|
|
939
|
+
output({ ...(profile.activeContext || {}), recent: recentEntries(config, options) }, options);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (action === 'clear') {
|
|
944
|
+
const next = updateProfile(config, options, { activeContext: {} });
|
|
945
|
+
await writeConfig(next);
|
|
946
|
+
output({ success: true, profile: profileName(config, options), activeContext: {} }, options);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (action === 'use') {
|
|
951
|
+
const activeContext = await buildContext({ ...config, activeContext: {} }, options);
|
|
952
|
+
let next = updateProfile(config, options, { activeContext });
|
|
953
|
+
next = appendRecentEntries(next, options, [
|
|
954
|
+
{ type: 'video', id: activeContext.videoId },
|
|
955
|
+
{ type: 'clip', id: activeContext.clipId },
|
|
956
|
+
{ type: 'project', id: activeContext.projectId },
|
|
957
|
+
{ type: 'sequence', id: activeContext.sequenceId },
|
|
958
|
+
]);
|
|
959
|
+
await writeConfig(next);
|
|
960
|
+
output({ success: true, profile: profileName(config, options), activeContext }, options);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (action === 'build') {
|
|
965
|
+
output(await buildContext(config, options), options);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
throw Object.assign(new Error(`Unknown context command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function runTool(config, options, functionName) {
|
|
973
|
+
if (!functionName) throw Object.assign(new Error('Function name is required.'), { exitCode: EXIT.USAGE });
|
|
974
|
+
const parameters = await readJsonOption(options.params || options['params-json']);
|
|
975
|
+
const context = await buildContext(config, options);
|
|
976
|
+
const payload = {
|
|
977
|
+
functionName,
|
|
978
|
+
parameters,
|
|
979
|
+
confirmed: boolOption(options.confirm),
|
|
980
|
+
};
|
|
981
|
+
for (const [flag, field] of [
|
|
982
|
+
['video-id', 'videoId'],
|
|
983
|
+
['clip-id', 'clipId'],
|
|
984
|
+
['project-id', 'projectId'],
|
|
985
|
+
['sequence-id', 'sequenceId'],
|
|
986
|
+
]) {
|
|
987
|
+
if (options[flag]) payload[field] = String(options[flag]);
|
|
988
|
+
}
|
|
989
|
+
if (context.videoId && !payload.videoId) payload.videoId = context.videoId;
|
|
990
|
+
if (context.clipId && !payload.clipId) payload.clipId = context.clipId;
|
|
991
|
+
if (context.projectId && !payload.projectId) payload.projectId = context.projectId;
|
|
992
|
+
if (context.sequenceId && !payload.sequenceId) payload.sequenceId = context.sequenceId;
|
|
993
|
+
if (context.selectedClipIds) payload.selectedClipIds = context.selectedClipIds;
|
|
994
|
+
if (context.playheadPosition !== undefined) payload.playheadPosition = context.playheadPosition;
|
|
995
|
+
if (Object.keys(context).length) payload.context = context.context || context;
|
|
996
|
+
await enforceRunMaxCredits(config, options, functionName);
|
|
997
|
+
const result = await apiFetch(config, options, 'POST', '/api/v1/agent/execute', payload);
|
|
998
|
+
if (result?.requiresConfirmation && !payload.confirmed) {
|
|
999
|
+
output(result, options);
|
|
1000
|
+
process.exitCode = EXIT.CONFIRMATION;
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
output(result, options);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function workflowEndpoint(jobId) {
|
|
1007
|
+
return `/api/v1/agent/workflows/${encodeURIComponent(jobId)}`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function workflowValue(value) {
|
|
1011
|
+
if (value === undefined || value === null) return null;
|
|
1012
|
+
if (typeof value === 'string') return value;
|
|
1013
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
1014
|
+
return JSON.stringify(value);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function workflowStreamEvent(workflow) {
|
|
1018
|
+
return redactDeep({
|
|
1019
|
+
type: 'workflow.status',
|
|
1020
|
+
jobId: workflow.jobId,
|
|
1021
|
+
status: workflow.status,
|
|
1022
|
+
progress: workflow.progress,
|
|
1023
|
+
name: workflow.name,
|
|
1024
|
+
pendingApproval: workflow.pendingApproval,
|
|
1025
|
+
finalResponse: workflow.finalResponse,
|
|
1026
|
+
error: workflow.error,
|
|
1027
|
+
createdAt: workflow.createdAt,
|
|
1028
|
+
completedAt: workflow.completedAt,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function workflowFailureMessage(workflow, jobId) {
|
|
1033
|
+
const error = workflow.error;
|
|
1034
|
+
if (typeof error === 'string' && error.trim()) return `Workflow ${jobId} failed: ${redact(error)}`;
|
|
1035
|
+
if (error && typeof error === 'object' && typeof error.message === 'string') {
|
|
1036
|
+
return `Workflow ${jobId} failed: ${redact(error.message)}`;
|
|
1037
|
+
}
|
|
1038
|
+
return `Workflow ${jobId} failed.`;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function approvalTaskLine(task, index) {
|
|
1042
|
+
const label = workflowValue(task.label ?? task.title ?? task.name ?? task.id ?? `Task ${index + 1}`);
|
|
1043
|
+
const tool = workflowValue(task.tool ?? task.toolName ?? task.functionName);
|
|
1044
|
+
const estimatedCost = workflowValue(task.estimatedCost ?? task.costEstimate ?? task.cost);
|
|
1045
|
+
const details = [];
|
|
1046
|
+
if (tool) details.push(`tool: ${tool}`);
|
|
1047
|
+
if (estimatedCost) details.push(`estimated cost: ${estimatedCost}`);
|
|
1048
|
+
return ` ${index + 1}. ${label}${details.length ? ` (${details.join(', ')})` : ''}`;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function printApprovalTranscript(workflow, jobId) {
|
|
1052
|
+
const pending = workflow.pendingApproval || {};
|
|
1053
|
+
console.log(`Workflow awaiting approval: ${workflow.jobId || jobId}`);
|
|
1054
|
+
console.log(`Approval id: ${pending.approvalId || ''}`);
|
|
1055
|
+
console.log('');
|
|
1056
|
+
console.log('Pending tasks:');
|
|
1057
|
+
const tasks = Array.isArray(pending.tasks) ? pending.tasks : [];
|
|
1058
|
+
if (tasks.length) {
|
|
1059
|
+
for (const [index, task] of tasks.entries()) console.log(approvalTaskLine(task, index));
|
|
1060
|
+
} else {
|
|
1061
|
+
console.log(' (No task details returned.)');
|
|
1062
|
+
}
|
|
1063
|
+
if (pending.totalEstimatedCost !== undefined) {
|
|
1064
|
+
console.log(`Total estimated cost: ${workflowValue(pending.totalEstimatedCost)}`);
|
|
1065
|
+
}
|
|
1066
|
+
if (pending.expiresAt) console.log(`Expires at: ${pending.expiresAt}`);
|
|
1067
|
+
console.log('');
|
|
1068
|
+
console.log('Approve and continue:');
|
|
1069
|
+
console.log(` clipit workflow approve ${workflow.jobId || jobId} --approval-id ${pending.approvalId || '<approvalId>'} --decision approved`);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function pollWorkflow(config, options, jobId) {
|
|
1073
|
+
if (!jobId) throw Object.assign(new Error('Workflow job id is required.'), { exitCode: EXIT.USAGE });
|
|
1074
|
+
const startedAt = Date.now();
|
|
1075
|
+
const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms') ?? 15 * 60 * 1000;
|
|
1076
|
+
const intervalMs = numberOption(options.interval, '--interval') || 3000;
|
|
1077
|
+
let lastStreamEvent = null;
|
|
1078
|
+
|
|
1079
|
+
while (true) {
|
|
1080
|
+
const workflow = await apiFetch(config, options, 'GET', workflowEndpoint(jobId));
|
|
1081
|
+
const redactedWorkflow = redactDeep(workflow);
|
|
1082
|
+
|
|
1083
|
+
if (options.stream) {
|
|
1084
|
+
const event = workflowStreamEvent(workflow);
|
|
1085
|
+
const serialized = JSON.stringify(event);
|
|
1086
|
+
if (serialized !== lastStreamEvent) {
|
|
1087
|
+
console.log(serialized);
|
|
1088
|
+
lastStreamEvent = serialized;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (workflow.status === 'awaiting_approval') {
|
|
1093
|
+
if (wantJson(options)) {
|
|
1094
|
+
output({
|
|
1095
|
+
jobId: workflow.jobId || jobId,
|
|
1096
|
+
status: workflow.status,
|
|
1097
|
+
pendingApproval: redactedWorkflow.pendingApproval,
|
|
1098
|
+
}, options);
|
|
1099
|
+
} else {
|
|
1100
|
+
printApprovalTranscript(redactedWorkflow, jobId);
|
|
1101
|
+
}
|
|
1102
|
+
process.exitCode = EXIT.CONFIRMATION;
|
|
1103
|
+
return workflow;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (workflow.status === 'failed') {
|
|
1107
|
+
throw Object.assign(new Error(workflowFailureMessage(redactedWorkflow, jobId)), {
|
|
1108
|
+
exitCode: EXIT.SERVER,
|
|
1109
|
+
data: redactedWorkflow,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (TERMINAL_WORKFLOW_STATUSES.has(workflow.status)) {
|
|
1114
|
+
output(redactedWorkflow, options);
|
|
1115
|
+
return workflow;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
1119
|
+
throw Object.assign(new Error(`Timed out waiting for workflow ${jobId}.`), { exitCode: EXIT.SERVER });
|
|
1120
|
+
}
|
|
1121
|
+
await sleep(intervalMs);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function askWorkflow(config, options, promptParts) {
|
|
1126
|
+
const userMessage = promptParts.filter((part) => part !== undefined).join(' ').trim();
|
|
1127
|
+
if (!userMessage) throw Object.assign(new Error('Prompt is required.'), { exitCode: EXIT.USAGE });
|
|
1128
|
+
|
|
1129
|
+
const context = await buildContext(config, options);
|
|
1130
|
+
const payload = { userMessage };
|
|
1131
|
+
for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
|
|
1132
|
+
if (context[field]) payload[field] = context[field];
|
|
1133
|
+
}
|
|
1134
|
+
if (options['conversation-id']) payload.conversationId = String(options['conversation-id']);
|
|
1135
|
+
if (options.quick !== undefined) payload.quickMode = boolOption(options.quick);
|
|
1136
|
+
|
|
1137
|
+
const accepted = await apiFetch(config, options, 'POST', '/api/v1/agent/orchestrate', payload);
|
|
1138
|
+
if (boolOption(options['no-wait'])) {
|
|
1139
|
+
output(redactDeep(accepted), options);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
await pollWorkflow(config, options, accepted.jobId);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async function workflow(config, options, action, args) {
|
|
1146
|
+
if (action === 'status') {
|
|
1147
|
+
const jobId = args[0];
|
|
1148
|
+
if (!jobId) throw Object.assign(new Error('Workflow job id is required.'), { exitCode: EXIT.USAGE });
|
|
1149
|
+
output(redactDeep(await apiFetch(config, options, 'GET', workflowEndpoint(jobId))), options);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (action === 'wait') {
|
|
1154
|
+
await pollWorkflow(config, options, args[0]);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (action === 'approve') {
|
|
1159
|
+
const jobId = args[0];
|
|
1160
|
+
if (!jobId) throw Object.assign(new Error('Workflow job id is required.'), { exitCode: EXIT.USAGE });
|
|
1161
|
+
if (!options['approval-id']) throw Object.assign(new Error('--approval-id is required.'), { exitCode: EXIT.USAGE });
|
|
1162
|
+
const decision = String(options.decision || 'approved');
|
|
1163
|
+
if (!WORKFLOW_APPROVAL_DECISIONS.has(decision)) {
|
|
1164
|
+
throw Object.assign(new Error('--decision must be approved, cheaper, or cancelled.'), { exitCode: EXIT.USAGE });
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
let accepted;
|
|
1168
|
+
try {
|
|
1169
|
+
accepted = await apiFetch(config, options, 'POST', `${workflowEndpoint(jobId)}/approval`, {
|
|
1170
|
+
approvalId: String(options['approval-id']),
|
|
1171
|
+
decision,
|
|
1172
|
+
});
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
if (error.status === 409) {
|
|
1175
|
+
output(redactDeep(error.data || { error: error.message }), options);
|
|
1176
|
+
process.exitCode = EXIT.CONFIRMATION;
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const redactedAccepted = redactDeep(accepted);
|
|
1183
|
+
if (wantJson(options)) {
|
|
1184
|
+
output(redactedAccepted, options);
|
|
1185
|
+
} else {
|
|
1186
|
+
console.log(`Continuation workflow queued: ${redactedAccepted.jobId}`);
|
|
1187
|
+
}
|
|
1188
|
+
if (boolOption(options['no-wait'])) return;
|
|
1189
|
+
await pollWorkflow(config, options, accepted.jobId);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
throw Object.assign(new Error(`Unknown workflow command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function mimeForPath(filePath) {
|
|
1197
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1198
|
+
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
|
1199
|
+
if (ext === '.png') return 'image/png';
|
|
1200
|
+
if (ext === '.webp') return 'image/webp';
|
|
1201
|
+
if (ext === '.gif') return 'image/gif';
|
|
1202
|
+
if (ext === '.mov') return 'video/quicktime';
|
|
1203
|
+
if (ext === '.webm') return 'video/webm';
|
|
1204
|
+
if (ext === '.mkv') return 'video/x-matroska';
|
|
1205
|
+
if (ext === '.m4v') return 'video/x-m4v';
|
|
1206
|
+
if (ext === '.mp3') return 'audio/mpeg';
|
|
1207
|
+
if (ext === '.m4a') return 'audio/mp4';
|
|
1208
|
+
if (ext === '.wav') return 'audio/wav';
|
|
1209
|
+
return 'video/mp4';
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function multipartFilename(value) {
|
|
1213
|
+
return String(value).replace(/["\r\n]/g, '_');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function shouldReportUploadProgress(options) {
|
|
1217
|
+
return Boolean(process.stderr.isTTY) && !wantJson(options);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function formatMb(bytes) {
|
|
1221
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function createUploadProgress(options, totalBytes) {
|
|
1225
|
+
if (!shouldReportUploadProgress(options) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
|
|
1226
|
+
return { track() {}, finish() {} };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
let uploaded = 0;
|
|
1230
|
+
let lastPrintedAt = 0;
|
|
1231
|
+
|
|
1232
|
+
function print(force = false) {
|
|
1233
|
+
const now = Date.now();
|
|
1234
|
+
if (!force && now - lastPrintedAt < 2000 && uploaded < totalBytes) return;
|
|
1235
|
+
lastPrintedAt = now;
|
|
1236
|
+
const percent = Math.min(100, Math.round((uploaded / totalBytes) * 100));
|
|
1237
|
+
process.stderr.write(`\ruploaded ${formatMb(uploaded)} / ${formatMb(totalBytes)} MB (${percent}%)`);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
return {
|
|
1241
|
+
track(chunk) {
|
|
1242
|
+
uploaded = Math.min(totalBytes, uploaded + Buffer.byteLength(chunk));
|
|
1243
|
+
print(false);
|
|
1244
|
+
},
|
|
1245
|
+
finish() {
|
|
1246
|
+
print(true);
|
|
1247
|
+
process.stderr.write('\n');
|
|
1248
|
+
},
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async function* multipartFileBody(filePath, filename, contentType, boundary, progress) {
|
|
1253
|
+
yield Buffer.from(
|
|
1254
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${multipartFilename(filename)}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
|
1255
|
+
);
|
|
1256
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
1257
|
+
progress?.track(chunk);
|
|
1258
|
+
yield chunk;
|
|
1259
|
+
}
|
|
1260
|
+
yield Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function uploadVideo(config, options, filePath) {
|
|
1264
|
+
if (!filePath) throw Object.assign(new Error('Video file path is required.'), { exitCode: EXIT.USAGE });
|
|
1265
|
+
const resolved = path.resolve(filePath);
|
|
1266
|
+
const stat = await fs.stat(resolved);
|
|
1267
|
+
if (!stat.isFile()) {
|
|
1268
|
+
throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
|
|
1269
|
+
}
|
|
1270
|
+
const filename = options.filename || path.basename(resolved);
|
|
1271
|
+
const contentType = mimeForPath(resolved);
|
|
1272
|
+
const boundary = `clipit-cli-${randomBytes(12).toString('hex')}`;
|
|
1273
|
+
const headerLength = Buffer.byteLength(
|
|
1274
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${multipartFilename(filename)}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
|
1275
|
+
);
|
|
1276
|
+
const footerLength = Buffer.byteLength(`\r\n--${boundary}--\r\n`);
|
|
1277
|
+
const progress = createUploadProgress(options, stat.size);
|
|
1278
|
+
const body = multipartFileBody(resolved, filename, contentType, boundary, progress);
|
|
1279
|
+
try {
|
|
1280
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/videos', body, {
|
|
1281
|
+
rawBody: true,
|
|
1282
|
+
headers: {
|
|
1283
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1284
|
+
'Content-Length': String(headerLength + stat.size + footerLength),
|
|
1285
|
+
},
|
|
1286
|
+
}), options);
|
|
1287
|
+
} finally {
|
|
1288
|
+
progress.finish();
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
async function videos(config, options, action, args) {
|
|
1293
|
+
if (action === 'list') {
|
|
1294
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/videos${queryString({ limit: options.limit, offset: options.offset })}`), options);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (action === 'get') {
|
|
1298
|
+
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1299
|
+
const result = await apiFetch(config, options, 'GET', `/api/v1/videos/${encodeURIComponent(args[0])}`);
|
|
1300
|
+
await writeConfig(appendRecentEntry(config, options, 'video', args[0]));
|
|
1301
|
+
output(result, options);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (action === 'import-url') {
|
|
1305
|
+
const url = args[0] || options.url;
|
|
1306
|
+
if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
|
|
1307
|
+
await enforceMaxCredits(config, options, 'videos import-url', { url });
|
|
1308
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', { url, title: options.title }), options);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (action === 'upload') {
|
|
1312
|
+
await uploadVideo(config, options, args[0] || options.file);
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
if (action === 'transcribe') {
|
|
1316
|
+
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1317
|
+
output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/transcribe`, {}), options);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (action === 'transcript') {
|
|
1321
|
+
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1322
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/videos/${encodeURIComponent(args[0])}/transcript`), options);
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
if (action === 'suggest-clips') {
|
|
1326
|
+
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1327
|
+
const body = options.params
|
|
1328
|
+
? await readJsonOption(String(options.params))
|
|
1329
|
+
: {
|
|
1330
|
+
count: numberOption(options.count, '--count') || 5,
|
|
1331
|
+
minDuration: numberOption(options['min-duration'], '--min-duration'),
|
|
1332
|
+
maxDuration: numberOption(options['max-duration'], '--max-duration'),
|
|
1333
|
+
targetPlatforms: stringList(options.platforms),
|
|
1334
|
+
themes: stringList(options.themes),
|
|
1335
|
+
};
|
|
1336
|
+
output(await apiFetch(config, options, 'POST', `/api/v1/videos/${encodeURIComponent(args[0])}/suggest-clips`, body), options);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (action === 'delete') {
|
|
1340
|
+
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1341
|
+
requireConfirm(options, 'Deleting a video');
|
|
1342
|
+
await apiFetch(config, options, 'DELETE', `/api/v1/videos/${encodeURIComponent(args[0])}`);
|
|
1343
|
+
output({ success: true, deleted: args[0] }, options);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
throw Object.assign(new Error(`Unknown videos command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
async function clips(config, options, action, args) {
|
|
1350
|
+
if (action === 'list') {
|
|
1351
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/clips${queryString({
|
|
1352
|
+
videoId: options['video-id'],
|
|
1353
|
+
limit: options.limit,
|
|
1354
|
+
offset: options.offset,
|
|
1355
|
+
})}`), options);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (action === 'get') {
|
|
1359
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1360
|
+
const result = await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(args[0])}`);
|
|
1361
|
+
await writeConfig(appendRecentEntry(config, options, 'clip', args[0]));
|
|
1362
|
+
output(result, options);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (action === 'create') {
|
|
1366
|
+
const body = options.params
|
|
1367
|
+
? await readJsonOption(String(options.params))
|
|
1368
|
+
: {
|
|
1369
|
+
videoId: options['video-id'],
|
|
1370
|
+
startTime: numberOption(options.start, '--start'),
|
|
1371
|
+
endTime: numberOption(options.end, '--end'),
|
|
1372
|
+
title: options.title,
|
|
1373
|
+
caption: options.caption,
|
|
1374
|
+
};
|
|
1375
|
+
if (!body.videoId) throw Object.assign(new Error('--video-id is required.'), { exitCode: EXIT.USAGE });
|
|
1376
|
+
if (body.startTime === undefined) throw Object.assign(new Error('--start is required.'), { exitCode: EXIT.USAGE });
|
|
1377
|
+
if (body.endTime === undefined) throw Object.assign(new Error('--end is required.'), { exitCode: EXIT.USAGE });
|
|
1378
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/clips', body), options);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (action === 'update') {
|
|
1382
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1383
|
+
const body = options.params
|
|
1384
|
+
? await readJsonOption(String(options.params))
|
|
1385
|
+
: {
|
|
1386
|
+
startTime: numberOption(options.start, '--start'),
|
|
1387
|
+
endTime: numberOption(options.end, '--end'),
|
|
1388
|
+
title: options.title,
|
|
1389
|
+
caption: options.caption,
|
|
1390
|
+
};
|
|
1391
|
+
for (const key of Object.keys(body)) {
|
|
1392
|
+
if (body[key] === undefined) delete body[key];
|
|
1393
|
+
}
|
|
1394
|
+
output(await apiFetch(config, options, 'PATCH', `/api/v1/clips/${encodeURIComponent(args[0])}`, body), options);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
if (action === 'render') {
|
|
1398
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1399
|
+
const body = options.params
|
|
1400
|
+
? await readJsonOption(String(options.params))
|
|
1401
|
+
: {
|
|
1402
|
+
aspectRatio: options.aspect || options['aspect-ratio'],
|
|
1403
|
+
quality: options.quality,
|
|
1404
|
+
includeCaptions: options.captions === undefined ? undefined : boolOption(options.captions),
|
|
1405
|
+
captionStyle: options['caption-style'],
|
|
1406
|
+
watermark: options.watermark === undefined ? undefined : boolOption(options.watermark),
|
|
1407
|
+
};
|
|
1408
|
+
for (const key of Object.keys(body)) {
|
|
1409
|
+
if (body[key] === undefined) delete body[key];
|
|
1410
|
+
}
|
|
1411
|
+
await enforceMaxCredits(config, options, 'clips render', { clipId: args[0], body });
|
|
1412
|
+
output(await apiFetch(config, options, 'POST', `/api/v1/clips/${encodeURIComponent(args[0])}/render`, body), options);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (action === 'download') {
|
|
1416
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1417
|
+
const result = await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(args[0])}/download`);
|
|
1418
|
+
if (options.open && result?.downloadUrl) openBrowser(result.downloadUrl);
|
|
1419
|
+
output(result, options);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
if (action === 'delete') {
|
|
1423
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1424
|
+
requireConfirm(options, 'Deleting a clip');
|
|
1425
|
+
await apiFetch(config, options, 'DELETE', `/api/v1/clips/${encodeURIComponent(args[0])}`);
|
|
1426
|
+
output({ success: true, deleted: args[0] }, options);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
throw Object.assign(new Error(`Unknown clips command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async function credits(config, options, action) {
|
|
1433
|
+
if (action === 'balance') {
|
|
1434
|
+
output(await apiFetch(config, options, 'GET', '/api/v1/credits/balance'), options);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (action === 'usage') {
|
|
1438
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/credits/usage${queryString({ period: options.period })}`), options);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
if (action === 'estimate') {
|
|
1442
|
+
const operationType = requiredString(options['operation-type'], '--operation-type');
|
|
1443
|
+
const provider = requiredString(options.provider, '--provider');
|
|
1444
|
+
const metrics = await readMetricsOption(options.metrics);
|
|
1445
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/credits/estimate', compactObject({
|
|
1446
|
+
operationType,
|
|
1447
|
+
provider,
|
|
1448
|
+
modelId: options['model-id'],
|
|
1449
|
+
metrics,
|
|
1450
|
+
})), options);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
throw Object.assign(new Error(`Unknown credits command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function analytics(config, options, action, args) {
|
|
1457
|
+
if (action === 'overview') {
|
|
1458
|
+
const suffix = queryString({ days: options.days ?? 30 });
|
|
1459
|
+
const [aggregate, byPlatform] = await Promise.all([
|
|
1460
|
+
apiFetch(config, options, 'GET', `/api/v1/analytics/aggregate${suffix}`),
|
|
1461
|
+
apiFetch(config, options, 'GET', `/api/v1/analytics/by-platform${suffix}`),
|
|
1462
|
+
]);
|
|
1463
|
+
output({ aggregate, byPlatform }, options);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if (action === 'top-clips') {
|
|
1467
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/analytics/top-clips${queryString({
|
|
1468
|
+
days: options.days ?? 30,
|
|
1469
|
+
limit: options.limit ?? 10,
|
|
1470
|
+
metric: options.metric,
|
|
1471
|
+
})}`), options);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (action === 'post') {
|
|
1475
|
+
const socialPostId = requiredString(args[0], 'Social post id');
|
|
1476
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/analytics/posts/${encodeURIComponent(socialPostId)}/metrics`), options);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
throw Object.assign(new Error(`Unknown analytics command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function defaultExportStartBody(clipId) {
|
|
1483
|
+
return {
|
|
1484
|
+
clipId,
|
|
1485
|
+
qualitySettings: {
|
|
1486
|
+
resolution: '1080p',
|
|
1487
|
+
bitrate: 8000000,
|
|
1488
|
+
framerate: 30,
|
|
1489
|
+
codec: 'h264',
|
|
1490
|
+
qualityPreset: 'medium',
|
|
1491
|
+
hardwareAcceleration: true,
|
|
1492
|
+
colorSpace: 'sdr',
|
|
1493
|
+
audioCodec: 'aac',
|
|
1494
|
+
audioBitrate: 192000,
|
|
1495
|
+
audioSampleRate: 48000,
|
|
1496
|
+
},
|
|
1497
|
+
format: 'mp4',
|
|
1498
|
+
includeAudio: true,
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
async function pollExport(config, options, jobId) {
|
|
1503
|
+
const startedAt = Date.now();
|
|
1504
|
+
const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
|
|
1505
|
+
const intervalMs = numberOption(options.interval, '--interval') || 3000;
|
|
1506
|
+
while (true) {
|
|
1507
|
+
const job = await apiFetch(config, options, 'GET', `/api/v1/exports/${encodeURIComponent(jobId)}`);
|
|
1508
|
+
if (options.stream) console.log(JSON.stringify({ type: 'export.progress', job }));
|
|
1509
|
+
if (TERMINAL_JOB_STATUSES.has(job.status)) {
|
|
1510
|
+
output(job, options);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
if (timeoutMs && Date.now() - startedAt > timeoutMs) {
|
|
1514
|
+
throw Object.assign(new Error(`Timed out waiting for export ${jobId}.`), { exitCode: EXIT.SERVER });
|
|
1515
|
+
}
|
|
1516
|
+
await sleep(intervalMs);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
async function exportsCommand(config, options, action, args) {
|
|
1521
|
+
if (action === 'start') {
|
|
1522
|
+
const clipId = requiredString(options['clip-id'], '--clip-id');
|
|
1523
|
+
const params = options.params ? await readJsonOption(String(options.params)) : {};
|
|
1524
|
+
const body = {
|
|
1525
|
+
...defaultExportStartBody(clipId),
|
|
1526
|
+
...params,
|
|
1527
|
+
clipId,
|
|
1528
|
+
};
|
|
1529
|
+
await enforceMaxCredits(config, options, 'exports start', { clipId, body });
|
|
1530
|
+
confirmPaid(options, 'Starting an export');
|
|
1531
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/exports', body), options);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (action === 'list') {
|
|
1535
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/exports${queryString({ limit: options.limit, offset: options.offset })}`), options);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
if (action === 'get') {
|
|
1539
|
+
const jobId = requiredString(args[0], 'Export job id');
|
|
1540
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/exports/${encodeURIComponent(jobId)}`), options);
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
if (action === 'wait') {
|
|
1544
|
+
await pollExport(config, options, requiredString(args[0], 'Export job id'));
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (action === 'download') {
|
|
1548
|
+
const jobId = requiredString(args[0], 'Export job id');
|
|
1549
|
+
const result = await apiFetch(config, options, 'GET', `/api/v1/exports/${encodeURIComponent(jobId)}/download`);
|
|
1550
|
+
if (options.open && result?.downloadUrl) openBrowser(result.downloadUrl);
|
|
1551
|
+
output(result, options);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (action === 'cancel') {
|
|
1555
|
+
const jobId = requiredString(args[0], 'Export job id');
|
|
1556
|
+
output(await apiFetch(config, options, 'POST', `/api/v1/exports/${encodeURIComponent(jobId)}/cancel`, {}), options);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
throw Object.assign(new Error(`Unknown exports command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function objectPathFromKey(key) {
|
|
1563
|
+
return String(key).startsWith('/objects/') ? String(key) : `/objects/${key}`;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async function putSignedUpload(uploadUrl, filePath, contentType, size, options) {
|
|
1567
|
+
let response;
|
|
1568
|
+
const progress = createUploadProgress(options, size);
|
|
1569
|
+
const body = createReadStream(filePath).pipe(progressTransform(progress));
|
|
1570
|
+
try {
|
|
1571
|
+
response = await fetch(uploadUrl, {
|
|
1572
|
+
method: 'PUT',
|
|
1573
|
+
headers: {
|
|
1574
|
+
'Content-Type': contentType,
|
|
1575
|
+
'Content-Length': String(size),
|
|
1576
|
+
},
|
|
1577
|
+
body,
|
|
1578
|
+
duplex: 'half',
|
|
1579
|
+
});
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
throw Object.assign(new Error(`Upload failed: ${error.message}`), { exitCode: EXIT.NETWORK });
|
|
1582
|
+
} finally {
|
|
1583
|
+
progress.finish();
|
|
1584
|
+
}
|
|
1585
|
+
if (!response.ok) {
|
|
1586
|
+
const text = await response.text().catch(() => '');
|
|
1587
|
+
throw Object.assign(new Error(`Upload failed: ${response.status} ${redact(text || response.statusText)}`), { exitCode: EXIT.SERVER });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
async function uploadAsset(config, options, filePath) {
|
|
1592
|
+
const resolved = path.resolve(requiredString(filePath, 'Asset file path'));
|
|
1593
|
+
const stat = await fs.stat(resolved);
|
|
1594
|
+
if (!stat.isFile()) {
|
|
1595
|
+
throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
|
|
1596
|
+
}
|
|
1597
|
+
const filename = String(options.filename || path.basename(resolved));
|
|
1598
|
+
const contentType = String(options['content-type'] || mimeForPath(resolved));
|
|
1599
|
+
const signBody = compactObject({
|
|
1600
|
+
filename,
|
|
1601
|
+
contentType,
|
|
1602
|
+
size: stat.size,
|
|
1603
|
+
kind: options.kind,
|
|
1604
|
+
});
|
|
1605
|
+
const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
|
|
1606
|
+
if (!signed?.uploadUrl || !signed?.key || !signed?.assetId) {
|
|
1607
|
+
throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
|
|
1608
|
+
}
|
|
1609
|
+
await putSignedUpload(signed.uploadUrl, resolved, contentType, stat.size, options);
|
|
1610
|
+
const objectPath = objectPathFromKey(signed.key);
|
|
1611
|
+
output(await apiFetch(config, options, 'POST', `/api/v1/assets/${encodeURIComponent(signed.assetId)}/finalize`, {
|
|
1612
|
+
objectPath,
|
|
1613
|
+
fileSize: stat.size,
|
|
1614
|
+
duration: options.duration === undefined ? null : numberOption(options.duration, '--duration'),
|
|
1615
|
+
}), options);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
async function assets(config, options, action, args) {
|
|
1619
|
+
if (action === 'list') {
|
|
1620
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
|
|
1621
|
+
type: options.type,
|
|
1622
|
+
limit: options.limit,
|
|
1623
|
+
offset: options.offset,
|
|
1624
|
+
})}`), options);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
if (action === 'upload') {
|
|
1628
|
+
await uploadAsset(config, options, args[0] || options.file);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (action === 'delete') {
|
|
1632
|
+
const assetId = requiredString(args[0], 'Asset id');
|
|
1633
|
+
requireConfirm(options, 'Deleting an asset');
|
|
1634
|
+
output(await apiFetch(config, options, 'DELETE', `/api/v1/assets/${encodeURIComponent(assetId)}`), options);
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
throw Object.assign(new Error(`Unknown assets command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
async function thumbnails(config, options, action, args) {
|
|
1641
|
+
if (action === 'generate') {
|
|
1642
|
+
const prompt = requiredString(options.prompt, '--prompt');
|
|
1643
|
+
const body = compactObject({
|
|
1644
|
+
clipId: options['clip-id'],
|
|
1645
|
+
prompt,
|
|
1646
|
+
aspectRatio: options.aspect || options['aspect-ratio'],
|
|
1647
|
+
resolution: options.resolution,
|
|
1648
|
+
quality: options.quality,
|
|
1649
|
+
useExistingThumbnail: options['use-existing-thumbnail'] === undefined ? undefined : boolOption(options['use-existing-thumbnail']),
|
|
1650
|
+
});
|
|
1651
|
+
await enforceMaxCredits(config, options, 'thumbnails generate', { body });
|
|
1652
|
+
confirmPaid(options, 'Generating a thumbnail');
|
|
1653
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/thumbnails', body), options);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
if (action === 'get') {
|
|
1657
|
+
const thumbnailId = requiredString(args[0], 'Thumbnail id');
|
|
1658
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/thumbnails/${encodeURIComponent(thumbnailId)}`), options);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
if (action === 'list') {
|
|
1662
|
+
const clipId = requiredString(options['clip-id'] || args[0], '--clip-id');
|
|
1663
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(clipId)}/thumbnails`), options);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
throw Object.assign(new Error(`Unknown thumbnails command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
async function broll(config, options, action, args) {
|
|
1670
|
+
if (action === 'plan') {
|
|
1671
|
+
const clipId = requiredString(args[0], 'Clip id');
|
|
1672
|
+
const body = compactObject({
|
|
1673
|
+
clipId,
|
|
1674
|
+
numberOfConcepts: numberOption(options.count, '--count') ?? 3,
|
|
1675
|
+
theme: options.theme,
|
|
1676
|
+
});
|
|
1677
|
+
await enforceMaxCredits(config, options, 'broll plan', { clipId, body });
|
|
1678
|
+
confirmPaid(options, 'Planning B-Roll');
|
|
1679
|
+
output(await apiFetch(config, options, 'POST', `/api/v1/clips/${encodeURIComponent(clipId)}/broll/plan`, body), options);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (action === 'generate') {
|
|
1683
|
+
const clipId = requiredString(args[0], 'Clip id');
|
|
1684
|
+
const conceptIndex = numberOption(options['concept-index'], '--concept-index');
|
|
1685
|
+
const promptOverride = options.prompt || options['prompt-override'];
|
|
1686
|
+
if (conceptIndex === undefined && !promptOverride) {
|
|
1687
|
+
throw Object.assign(new Error('--concept-index or --prompt is required.'), { exitCode: EXIT.USAGE });
|
|
1688
|
+
}
|
|
1689
|
+
const durationSeconds = numberOption(options.duration || options['duration-seconds'], '--duration') ?? 6;
|
|
1690
|
+
const startTimeInClip = numberOption(options.start || options['start-time'], '--start') ?? 0;
|
|
1691
|
+
const endTimeInClip = numberOption(options.end || options['end-time'], '--end') ?? startTimeInClip + durationSeconds;
|
|
1692
|
+
const body = compactObject({
|
|
1693
|
+
clipId,
|
|
1694
|
+
conceptIndex,
|
|
1695
|
+
promptOverride,
|
|
1696
|
+
mode: options.mode,
|
|
1697
|
+
endFrameDescription: options['end-frame-description'],
|
|
1698
|
+
transitionDescription: options['transition-description'],
|
|
1699
|
+
durationSeconds,
|
|
1700
|
+
resolution: options.resolution,
|
|
1701
|
+
imageQuality: options['image-quality'],
|
|
1702
|
+
startTimeInClip,
|
|
1703
|
+
endTimeInClip,
|
|
1704
|
+
});
|
|
1705
|
+
await enforceMaxCredits(config, options, 'broll generate', { clipId, body });
|
|
1706
|
+
confirmPaid(options, 'Generating B-Roll');
|
|
1707
|
+
output(await apiFetch(config, options, 'POST', `/api/v1/clips/${encodeURIComponent(clipId)}/broll/generate`, body), options);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (action === 'list') {
|
|
1711
|
+
const clipId = requiredString(options['clip-id'] || args[0], '--clip-id');
|
|
1712
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(clipId)}/broll`), options);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
if (action === 'get') {
|
|
1716
|
+
const brollId = requiredString(args[0], 'B-Roll id');
|
|
1717
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/broll/${encodeURIComponent(brollId)}`), options);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
throw Object.assign(new Error(`Unknown broll command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function socialPlatformList(value) {
|
|
1724
|
+
const platforms = stringList(value);
|
|
1725
|
+
if (!platforms?.length) {
|
|
1726
|
+
throw Object.assign(new Error('--platforms is required.'), { exitCode: EXIT.USAGE });
|
|
1727
|
+
}
|
|
1728
|
+
return platforms.map((platform) => platform.toLowerCase() === 'x' ? 'twitter' : platform);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function socialPostBody(options, scheduled) {
|
|
1732
|
+
const body = compactObject({
|
|
1733
|
+
clipId: requiredString(options['clip-id'], '--clip-id'),
|
|
1734
|
+
platforms: socialPlatformList(options.platforms),
|
|
1735
|
+
caption: requiredString(options.caption, '--caption'),
|
|
1736
|
+
title: options.title,
|
|
1737
|
+
hashtags: stringList(options.hashtags),
|
|
1738
|
+
});
|
|
1739
|
+
if (scheduled) body.scheduledFor = requiredString(options.at, '--at');
|
|
1740
|
+
return body;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
async function social(config, options, action, args) {
|
|
1744
|
+
if (action === 'accounts') {
|
|
1745
|
+
output(await apiFetch(config, options, 'GET', '/api/v1/social/accounts'), options);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
if (action === 'post') {
|
|
1749
|
+
const body = socialPostBody(options, false);
|
|
1750
|
+
await enforceMaxCredits(config, options, 'social post', { body });
|
|
1751
|
+
confirmPaid(options, 'Publishing a social post');
|
|
1752
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/social/post', body), options);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (action === 'schedule') {
|
|
1756
|
+
const body = socialPostBody(options, true);
|
|
1757
|
+
await enforceMaxCredits(config, options, 'social schedule', { body });
|
|
1758
|
+
confirmPaid(options, 'Scheduling a social post');
|
|
1759
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/social/schedule', body), options);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (action === 'posts') {
|
|
1763
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/social/posts${queryString({
|
|
1764
|
+
clipId: options['clip-id'],
|
|
1765
|
+
status: options.status,
|
|
1766
|
+
limit: options.limit,
|
|
1767
|
+
offset: options.offset,
|
|
1768
|
+
})}`), options);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (action === 'get') {
|
|
1772
|
+
const postId = requiredString(args[0], 'Social post id');
|
|
1773
|
+
output(await apiFetch(config, options, 'GET', `/api/v1/social/posts/${encodeURIComponent(postId)}`), options);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (action === 'cancel') {
|
|
1777
|
+
const postId = requiredString(args[0], 'Social post id');
|
|
1778
|
+
requireConfirm(options, 'Cancelling a social post');
|
|
1779
|
+
await apiFetch(config, options, 'DELETE', `/api/v1/social/posts/${encodeURIComponent(postId)}`);
|
|
1780
|
+
output({ success: true, cancelled: postId }, options);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
throw Object.assign(new Error(`Unknown social command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
async function jobs(config, options, action, args) {
|
|
1787
|
+
if (!['get', 'wait'].includes(action)) {
|
|
1788
|
+
throw Object.assign(new Error(`Unknown jobs command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1789
|
+
}
|
|
1790
|
+
const jobId = args[0];
|
|
1791
|
+
if (!jobId) throw Object.assign(new Error('Job id is required.'), { exitCode: EXIT.USAGE });
|
|
1792
|
+
const startedAt = Date.now();
|
|
1793
|
+
const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
|
|
1794
|
+
const intervalMs = numberOption(options.interval, '--interval') || 3000;
|
|
1795
|
+
while (true) {
|
|
1796
|
+
const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
|
|
1797
|
+
if (action === 'get' || TERMINAL_JOB_STATUSES.has(job.status)) {
|
|
1798
|
+
output(job, options);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (options.stream) console.log(JSON.stringify({ type: 'job.progress', job }));
|
|
1802
|
+
if (timeoutMs && Date.now() - startedAt > timeoutMs) {
|
|
1803
|
+
throw Object.assign(new Error(`Timed out waiting for job ${jobId}.`), { exitCode: EXIT.SERVER });
|
|
1804
|
+
}
|
|
1805
|
+
await sleep(intervalMs);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function appUrl(config, options, kind, id) {
|
|
1810
|
+
const baseUrl = getBaseUrl(config, options);
|
|
1811
|
+
if (!kind || kind === 'home') return baseUrl;
|
|
1812
|
+
if (kind === 'settings') return `${baseUrl}/settings?tab=${encodeURIComponent(options.tab || 'api-keys')}`;
|
|
1813
|
+
if (kind === 'dashboard') return `${baseUrl}/dashboard`;
|
|
1814
|
+
if (kind === 'library') return `${baseUrl}/clips`;
|
|
1815
|
+
if (kind === 'editor') return `${baseUrl}/editor`;
|
|
1816
|
+
if (kind === 'pricing') return `${baseUrl}/pricing`;
|
|
1817
|
+
if (kind === 'credits') return `${baseUrl}/settings/credits`;
|
|
1818
|
+
if (kind === 'clip') return `${baseUrl}/clips/review${id ? `?clipId=${encodeURIComponent(id)}` : ''}`;
|
|
1819
|
+
if (kind === 'video') return `${baseUrl}/clips${id ? `?videoId=${encodeURIComponent(id)}` : ''}`;
|
|
1820
|
+
if (kind === 'project') return `${baseUrl}/editor/projects${id ? `?projectId=${encodeURIComponent(id)}` : ''}`;
|
|
1821
|
+
if (kind === 'sequence') return `${baseUrl}/editor/projects${id ? `?sequenceId=${encodeURIComponent(id)}` : ''}`;
|
|
1822
|
+
if (kind === 'route') return `${baseUrl}/${String(id || '').replace(/^\/+/, '')}`;
|
|
1823
|
+
return baseUrl;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
async function openCommand(config, options, kind, id) {
|
|
1827
|
+
const url = appUrl(config, options, kind, id);
|
|
1828
|
+
if (options.print) {
|
|
1829
|
+
output(url, options);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
openBrowser(url);
|
|
1833
|
+
output({ opened: url }, options);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
async function links(config, options, kind, id) {
|
|
1837
|
+
const result = {
|
|
1838
|
+
kind: kind || 'home',
|
|
1839
|
+
id: id || null,
|
|
1840
|
+
appUrl: appUrl(config, options, kind, id),
|
|
1841
|
+
};
|
|
1842
|
+
if (kind === 'clip' && id && boolOption(options.download)) {
|
|
1843
|
+
try {
|
|
1844
|
+
Object.assign(result, await apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(id)}/download`));
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
result.downloadError = redact(error.message);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
output(result, options);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
async function examples(options) {
|
|
1853
|
+
const data = {
|
|
1854
|
+
login: 'clipit login',
|
|
1855
|
+
installCodexSkill: 'clipit agent install codex',
|
|
1856
|
+
validate: 'clipit doctor --json',
|
|
1857
|
+
importUrl: 'clipit videos import-url "https://www.youtube.com/watch?v=..." --json',
|
|
1858
|
+
waitForJob: 'clipit jobs wait <jobId> --json',
|
|
1859
|
+
ask: 'clipit ask "Find the strongest clip in this video" --video-id <videoId>',
|
|
1860
|
+
approveWorkflow: 'clipit workflow approve <jobId> --approval-id <approvalId> --decision approved',
|
|
1861
|
+
waitForWorkflow: 'clipit workflow wait <jobId> --stream',
|
|
1862
|
+
setContext: 'clipit context use --video-id <videoId>',
|
|
1863
|
+
suggestClips: 'clipit videos suggest-clips <videoId> --count 5 --json',
|
|
1864
|
+
createClip: 'clipit clips create --video-id <videoId> --start 12 --end 42 --title "Strong hook" --json',
|
|
1865
|
+
renderClip: 'clipit clips render <clipId> --aspect 9:16 --quality high --json',
|
|
1866
|
+
creditsBalance: 'clipit credits balance --json',
|
|
1867
|
+
analyticsOverview: 'clipit analytics overview --days 30 --json',
|
|
1868
|
+
exportClip: 'clipit exports start --clip-id <clipId> --confirm --json',
|
|
1869
|
+
uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --json',
|
|
1870
|
+
thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
|
|
1871
|
+
brollPlan: 'clipit broll plan <clipId> --count 3 --confirm --json',
|
|
1872
|
+
socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json',
|
|
1873
|
+
runTool: 'clipit run <functionName> --clip-id <clipId> --params @params.json --json',
|
|
1874
|
+
reviewLink: 'clipit links clip <clipId> --json',
|
|
1875
|
+
};
|
|
1876
|
+
if (wantJson(options)) {
|
|
1877
|
+
output(data, options);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
output(Object.entries(data).map(([name, command]) => `${name}: ${command}`).join('\n'), options);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function skillMarkdown(agent) {
|
|
1884
|
+
return `# ClipIt CLI
|
|
1885
|
+
|
|
1886
|
+
Use the ClipIt CLI to operate ClipIt through the user's authenticated account. This is the generic offline fallback skill; when possible, run \`clipit agent update ${agent}\` after login to fetch server-rendered instructions scoped to the current API key.
|
|
1887
|
+
|
|
1888
|
+
Rules:
|
|
1889
|
+
- Run \`clipit auth status --json\` before assuming ClipIt is connected.
|
|
1890
|
+
- If not connected, ask the user to run \`clipit login\` and approve the browser link.
|
|
1891
|
+
- Never ask the user to paste API keys into chat.
|
|
1892
|
+
- Use \`clipit skills list --json\` and \`clipit tools list --json\` to discover capability.
|
|
1893
|
+
- Prefer friendly commands such as \`clipit videos list --json\`, \`clipit clips list --json\`, \`clipit credits balance --json\`, and \`clipit jobs wait <jobId> --json\`.
|
|
1894
|
+
- 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.
|
|
1895
|
+
- Use \`clipit run <functionName> --params @file.json --json\` for exact Clippy tools.
|
|
1896
|
+
- Treat paid generation, publishing, deletion, broad mutation, and \`requiresConfirmation\` responses as user approval checkpoints.
|
|
1897
|
+
- 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.
|
|
1898
|
+
- Use \`clipit open clip <id>\` when the user should review work in ClipIt.
|
|
1899
|
+
- Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
|
|
1900
|
+
- Do not write API keys into this skill file or any project files.
|
|
1901
|
+
|
|
1902
|
+
Useful commands:
|
|
1903
|
+
\`\`\`bash
|
|
1904
|
+
clipit doctor --json
|
|
1905
|
+
clipit auth status --json
|
|
1906
|
+
clipit skills list --json
|
|
1907
|
+
clipit tools list --json
|
|
1908
|
+
clipit tools describe <functionName> --json
|
|
1909
|
+
clipit ask "Find the strongest clip in this video" --video-id <videoId> --json
|
|
1910
|
+
clipit workflow wait <jobId> --stream
|
|
1911
|
+
clipit videos list --json
|
|
1912
|
+
clipit videos import-url "https://example.com/video" --json
|
|
1913
|
+
clipit videos transcript <videoId> --json
|
|
1914
|
+
clipit videos suggest-clips <videoId> --count 5 --json
|
|
1915
|
+
clipit clips list --json
|
|
1916
|
+
clipit clips create --video-id <videoId> --start 12 --end 42 --title "Hook" --json
|
|
1917
|
+
clipit clips render <clipId> --aspect 9:16 --quality high --json
|
|
1918
|
+
clipit jobs wait <jobId> --stream
|
|
1919
|
+
clipit credits balance --json
|
|
1920
|
+
clipit credits usage --json
|
|
1921
|
+
clipit credits estimate --operation-type transcription --provider deepgram --metrics @metrics.json --json
|
|
1922
|
+
clipit analytics overview --days 30 --json
|
|
1923
|
+
clipit exports start --clip-id <clipId> --confirm --json
|
|
1924
|
+
clipit thumbnails generate --clip-id <clipId> --prompt "High contrast thumbnail" --confirm --json
|
|
1925
|
+
clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json
|
|
1926
|
+
clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --json
|
|
1927
|
+
\`\`\`
|
|
1928
|
+
|
|
1929
|
+
Common workflow:
|
|
1930
|
+
1. Run \`clipit doctor --json\`.
|
|
1931
|
+
2. Use \`clipit context show --json\` to see whether a target video or clip is already active.
|
|
1932
|
+
3. Discover available tools before using \`clipit run\`.
|
|
1933
|
+
4. For long-running jobs, wait with \`clipit jobs wait <jobId> --json\`, \`clipit exports wait <jobId> --json\`, or stream NDJSON progress with \`--stream\`.
|
|
1934
|
+
5. Report review links with \`clipit links clip <clipId> --json\` or open the web app with \`clipit open clip <clipId>\`.
|
|
1935
|
+
|
|
1936
|
+
Agent target: ${agent}
|
|
1937
|
+
`;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function fallbackSkillResult(target, reason) {
|
|
1941
|
+
const generatedAt = new Date().toISOString();
|
|
1942
|
+
return {
|
|
1943
|
+
markdown: skillMarkdown(target),
|
|
1944
|
+
source: 'fallback',
|
|
1945
|
+
fallbackReason: reason,
|
|
1946
|
+
meta: {
|
|
1947
|
+
generatedAt,
|
|
1948
|
+
target,
|
|
1949
|
+
},
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
async function resolveAgentSkill(config, options, target) {
|
|
1954
|
+
if (!getApiKey(config, options)) {
|
|
1955
|
+
return fallbackSkillResult(target, 'not authenticated');
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
let response;
|
|
1959
|
+
try {
|
|
1960
|
+
response = await apiFetch(config, options, 'GET', `/api/v1/agent/instructions${queryString({ target, format: 'json' })}`);
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
if (error.status === 400 && !KNOWN_AGENT_TARGETS.includes(target)) {
|
|
1963
|
+
// Older servers reject unknown framework names; their generic rendering
|
|
1964
|
+
// is identical to what newer servers return for this target.
|
|
1965
|
+
try {
|
|
1966
|
+
response = await apiFetch(config, options, 'GET', `/api/v1/agent/instructions${queryString({ target: 'generic', format: 'json' })}`);
|
|
1967
|
+
} catch (retryError) {
|
|
1968
|
+
return fallbackSkillResult(target, redact(retryError.message));
|
|
1969
|
+
}
|
|
1970
|
+
} else {
|
|
1971
|
+
return fallbackSkillResult(target, redact(error.message));
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (
|
|
1976
|
+
!response ||
|
|
1977
|
+
typeof response.markdown !== 'string' ||
|
|
1978
|
+
!response.meta ||
|
|
1979
|
+
typeof response.meta !== 'object' ||
|
|
1980
|
+
Array.isArray(response.meta) ||
|
|
1981
|
+
typeof response.meta.generatedAt !== 'string'
|
|
1982
|
+
) {
|
|
1983
|
+
throw Object.assign(new Error('Instructions endpoint returned unexpected JSON shape; expected { markdown, meta }.'), {
|
|
1984
|
+
exitCode: EXIT.SERVER,
|
|
1985
|
+
data: response,
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
return {
|
|
1990
|
+
markdown: response.markdown,
|
|
1991
|
+
source: 'server',
|
|
1992
|
+
meta: response.meta,
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function agentSkillMetaPath(baseDir) {
|
|
1997
|
+
return path.join(baseDir, AGENT_SKILL_META_FILENAME);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
async function readAgentSkillMeta(baseDir) {
|
|
2001
|
+
try {
|
|
2002
|
+
return JSON.parse(await fs.readFile(agentSkillMetaPath(baseDir), 'utf8'));
|
|
2003
|
+
} catch {
|
|
2004
|
+
return null;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function agentSkillSidecar(target, skill) {
|
|
2009
|
+
return compactObject({
|
|
2010
|
+
target,
|
|
2011
|
+
source: skill.source,
|
|
2012
|
+
generatedAt: skill.meta?.generatedAt,
|
|
2013
|
+
serverMeta: skill.source === 'server' ? skill.meta : undefined,
|
|
2014
|
+
fallbackReason: skill.fallbackReason,
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function printFallbackNotice(skill, installed = false) {
|
|
2019
|
+
if (skill.source === 'fallback') {
|
|
2020
|
+
const prefix = installed ? 'Installed' : 'Using';
|
|
2021
|
+
console.error(`${prefix} generic offline ClipIt skill; server-rendered instructions unavailable (${skill.fallbackReason}).`);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function agentInstallDir(target, options) {
|
|
2026
|
+
if (options.dir) return path.resolve(String(options.dir));
|
|
2027
|
+
if (target === 'codex') return path.join(os.homedir(), '.codex', 'skills', 'clipit-cli');
|
|
2028
|
+
if (target === 'claude') return path.join(os.homedir(), '.claude', 'skills', 'clipit-cli');
|
|
2029
|
+
if (target === 'hermes') return path.join(os.homedir(), '.hermes', 'skills', 'clipit-cli');
|
|
2030
|
+
return path.join(configDir(), 'agent-skills', target, 'clipit-cli');
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
async function installedAgentStatus(target, options) {
|
|
2034
|
+
const baseDir = agentInstallDir(target, options);
|
|
2035
|
+
try {
|
|
2036
|
+
const stat = await fs.stat(path.join(baseDir, 'SKILL.md'));
|
|
2037
|
+
const sidecar = await readAgentSkillMeta(baseDir);
|
|
2038
|
+
return {
|
|
2039
|
+
target,
|
|
2040
|
+
installed: true,
|
|
2041
|
+
path: baseDir,
|
|
2042
|
+
updatedAt: stat.mtime.toISOString(),
|
|
2043
|
+
skillSource: sidecar?.source || 'unknown',
|
|
2044
|
+
generatedAt: sidecar?.generatedAt || null,
|
|
2045
|
+
fallbackReason: sidecar?.fallbackReason || null,
|
|
2046
|
+
serverMeta: sidecar?.serverMeta || null,
|
|
2047
|
+
};
|
|
2048
|
+
} catch {
|
|
2049
|
+
return { target, installed: false, path: baseDir };
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
async function agent(config, options, action, args) {
|
|
2054
|
+
const target = args[0] || 'generic';
|
|
2055
|
+
const baseDir = agentInstallDir(target, options);
|
|
2056
|
+
|
|
2057
|
+
if (action === 'print-skill') {
|
|
2058
|
+
const skill = await resolveAgentSkill(config, options, target);
|
|
2059
|
+
printFallbackNotice(skill);
|
|
2060
|
+
output(skill.markdown, options);
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
if (action === 'list') {
|
|
2064
|
+
output({ agents: await Promise.all(KNOWN_AGENT_TARGETS.map((name) => installedAgentStatus(name, options))) }, options);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (action === 'doctor') {
|
|
2068
|
+
const status = await installedAgentStatus(target, options);
|
|
2069
|
+
status.cli = {
|
|
2070
|
+
version: VERSION,
|
|
2071
|
+
configPath: configPath(),
|
|
2072
|
+
profile: profileName(config, options),
|
|
2073
|
+
hasCredential: Boolean(getApiKey(config, options)),
|
|
2074
|
+
};
|
|
2075
|
+
output(status, options);
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
if (action === 'install' || action === 'update') {
|
|
2079
|
+
const skill = await resolveAgentSkill(config, options, target);
|
|
2080
|
+
printFallbackNotice(skill, true);
|
|
2081
|
+
await fs.mkdir(baseDir, { recursive: true });
|
|
2082
|
+
await fs.writeFile(path.join(baseDir, 'SKILL.md'), skill.markdown, 'utf8');
|
|
2083
|
+
await fs.writeFile(agentSkillMetaPath(baseDir), `${JSON.stringify(agentSkillSidecar(target, skill), null, 2)}\n`, 'utf8');
|
|
2084
|
+
output({
|
|
2085
|
+
success: true,
|
|
2086
|
+
action,
|
|
2087
|
+
target,
|
|
2088
|
+
path: baseDir,
|
|
2089
|
+
skillSource: skill.source,
|
|
2090
|
+
generatedAt: skill.meta?.generatedAt || null,
|
|
2091
|
+
fallback: skill.source === 'fallback',
|
|
2092
|
+
}, options);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
if (action === 'uninstall') {
|
|
2096
|
+
await fs.rm(baseDir, { recursive: true, force: true });
|
|
2097
|
+
output({ success: true, action, target, path: baseDir }, options);
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
throw Object.assign(new Error(`Unknown agent command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
async function main() {
|
|
2104
|
+
const { options, positionals } = parseArgv(process.argv.slice(2));
|
|
2105
|
+
const config = await readConfig();
|
|
2106
|
+
const [command, subcommand, ...rest] = positionals;
|
|
2107
|
+
|
|
2108
|
+
if (!command || options.help) {
|
|
2109
|
+
console.log(usage());
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (command === 'version' || command === '--version' || command === '-v') {
|
|
2113
|
+
output({ version: VERSION }, options);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (command === 'login') return login(config, options);
|
|
2117
|
+
if (command === 'setup') return setup(config, options);
|
|
2118
|
+
if (command === 'logout') return logout(config, options);
|
|
2119
|
+
if (command === 'doctor') return doctor(config, options);
|
|
2120
|
+
if (command === 'auth' && subcommand === 'status') return authStatus(config, options);
|
|
2121
|
+
if (command === 'auth' && subcommand === 'set-key') return setKey(config, options);
|
|
2122
|
+
if (command === 'auth' && subcommand === 'open-settings') return openCommand(config, options, 'settings');
|
|
2123
|
+
if (command === 'auth' && subcommand === 'profiles') return listProfiles(config, options);
|
|
2124
|
+
if (command === 'context') return contextCommand(config, options, subcommand);
|
|
2125
|
+
if (command === 'skills' && subcommand === 'list') return listSkills(config, options);
|
|
2126
|
+
if (command === 'tools' && subcommand === 'list') return listTools(config, options);
|
|
2127
|
+
if (command === 'tools' && subcommand === 'describe') return describeTool(config, options, rest[0]);
|
|
2128
|
+
if (command === 'ask') return askWorkflow(config, options, [subcommand, ...rest]);
|
|
2129
|
+
if (command === 'workflow') return workflow(config, options, subcommand, rest);
|
|
2130
|
+
if (command === 'run') return runTool(config, options, subcommand);
|
|
2131
|
+
if (command === 'videos') return videos(config, options, subcommand, rest);
|
|
2132
|
+
if (command === 'clips') return clips(config, options, subcommand, rest);
|
|
2133
|
+
if (command === 'jobs') return jobs(config, options, subcommand, rest);
|
|
2134
|
+
if (command === 'credits') return credits(config, options, subcommand, rest);
|
|
2135
|
+
if (command === 'analytics') return analytics(config, options, subcommand, rest);
|
|
2136
|
+
if (command === 'exports') return exportsCommand(config, options, subcommand, rest);
|
|
2137
|
+
if (command === 'assets') return assets(config, options, subcommand, rest);
|
|
2138
|
+
if (command === 'thumbnails') return thumbnails(config, options, subcommand, rest);
|
|
2139
|
+
if (command === 'broll') return broll(config, options, subcommand, rest);
|
|
2140
|
+
if (command === 'social') return social(config, options, subcommand, rest);
|
|
2141
|
+
if (command === 'open') return openCommand(config, options, subcommand, rest[0]);
|
|
2142
|
+
if (command === 'links') return links(config, options, subcommand, rest[0]);
|
|
2143
|
+
if (command === 'agent') return agent(config, options, subcommand, rest);
|
|
2144
|
+
if (command === 'examples') return examples(options);
|
|
2145
|
+
|
|
2146
|
+
throw Object.assign(new Error(`Unknown command: ${command}`), { exitCode: EXIT.USAGE });
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function handleMainError(error) {
|
|
2150
|
+
const exitCode = error.exitCode || EXIT.SERVER;
|
|
2151
|
+
const message = redact(error.message || String(error));
|
|
2152
|
+
if (process.argv.includes('--json')) {
|
|
2153
|
+
console.error(JSON.stringify(compactObject({
|
|
2154
|
+
error: message,
|
|
2155
|
+
status: error.status,
|
|
2156
|
+
requestId: error.requestId,
|
|
2157
|
+
details: redactDeep(error.data),
|
|
2158
|
+
}), null, 2));
|
|
2159
|
+
} else {
|
|
2160
|
+
console.error(message);
|
|
2161
|
+
}
|
|
2162
|
+
process.exit(exitCode);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
async function isDirectRun() {
|
|
2166
|
+
if (!process.argv[1]) return false;
|
|
2167
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
2168
|
+
try {
|
|
2169
|
+
const [invoked, current] = await Promise.all([
|
|
2170
|
+
fs.realpath(process.argv[1]),
|
|
2171
|
+
fs.realpath(modulePath),
|
|
2172
|
+
]);
|
|
2173
|
+
return invoked === current;
|
|
2174
|
+
} catch {
|
|
2175
|
+
return path.resolve(process.argv[1]) === modulePath;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
if (await isDirectRun()) {
|
|
2180
|
+
main().catch(handleMainError);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
export { main, redact };
|