clipoox 0.1.0 → 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/README.md +9 -0
- package/lib/cli.mjs +59 -9
- package/lib/commands.mjs +100 -0
- package/lib/oauth.mjs +24 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,9 +11,18 @@ npx clipoox login # opens your browser — sign in on clipoox.com
|
|
|
11
11
|
npx clipoox tools # the tools; ★ = uses credits
|
|
12
12
|
npx clipoox call list_timelines
|
|
13
13
|
npx clipoox call get_timeline '{"project_id":"…"}' --json
|
|
14
|
+
npx clipoox upload intro.mp4 --timeline canvas:… --place # a file from this computer → the project (and the timeline)
|
|
15
|
+
npx clipoox export canvas:… --out film.mp4 # export and download the MP4
|
|
16
|
+
npx clipoox skill install # teach Claude Code when to use this CLI
|
|
14
17
|
npx clipoox logout
|
|
15
18
|
```
|
|
16
19
|
|
|
20
|
+
**upload** `<file> [--timeline <project>] [--place] [--at <s>] [--track <n>] [--label <name>] [--audio-rights]` — up to 100 MB. With `--timeline` it goes to the project's Media (`--place` also puts it on the timeline, `--at` / `--track` say where); without it, onto the canvas open in Clipoox. Audio needs `--audio-rights` (you confirm you may use it).
|
|
21
|
+
|
|
22
|
+
**export** `<project> [--out <file.mp4>] [--quality final|review] [--start <s>] [--end <s>] [--sheet <frames.jpg>]` — waits for the export and saves the MP4 (and, with `--sheet`, the contact sheet of frames). The project must be open on the web or in the desktop app; otherwise it prints the link to open.
|
|
23
|
+
|
|
24
|
+
**skill install** — writes `~/.claude/skills/clipoox-cli/SKILL.md` (or under `CLAUDE_CONFIG_DIR`): when Claude Code should use this CLI instead of the MCP tools (large results to a file, batch jobs, local files, MP4 on disk), and that credits are always confirmed with you first.
|
|
25
|
+
|
|
17
26
|
**Arguments** are a JSON object, `@file.json`, or `-` to read stdin. `--json` prints the full result.
|
|
18
27
|
|
|
19
28
|
**Credits.** A tool that uses credits shows its price first when it can (a `dry_run` call, nothing charged), then asks `[y/N]`. Without a keyboard (a script, CI), it needs `--yes`; otherwise it stops without calling anything. Ask for the price yourself with `'{"dry_run":true, …}'`.
|
package/lib/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// clipoox — the Clipoox MCP tools from a terminal or a script, without an AI in between:
|
|
2
|
-
// clipoox login | logout | tools | call <tool> [json | @file.json | -] [--yes] [--json]
|
|
2
|
+
// clipoox login | logout | tools | call <tool> [json | @file.json | -] [--yes] [--json] | upload <file> | export <project> | skill install
|
|
3
3
|
// Messages follow the system language (vi when LANG / LC_ALL starts with "vi", else en). Exit codes: 0 ok, 1 the tool or
|
|
4
4
|
// the request failed, 2 usage / needs confirmation, 3 not signed in.
|
|
5
5
|
import fs from 'node:fs'
|
|
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process'
|
|
|
8
8
|
import { login, refresh, expiring } from './oauth.mjs'
|
|
9
9
|
import { mcpClient, spendsCredits, willSpend, quotes, resultText, AuthRequired } from './mcp.mjs'
|
|
10
10
|
import { loadCredentials, saveCredentials, credentialsPath } from './store.mjs'
|
|
11
|
+
import { upload, exportVideo, installSkill } from './commands.mjs'
|
|
11
12
|
|
|
12
13
|
export const DEFAULT_MCP_URL = 'https://clipoox.com/mcp'
|
|
13
14
|
const M = {
|
|
@@ -19,9 +20,17 @@ const M = {
|
|
|
19
20
|
clipoox call <công cụ> [tham số] Gọi một công cụ. Tham số: JSON, @tệp.json, hoặc - (đọc từ stdin)
|
|
20
21
|
--yes đồng ý trước với công cụ tốn credit (bắt buộc khi chạy trong script)
|
|
21
22
|
--json in kết quả đầy đủ dạng JSON
|
|
23
|
+
clipoox upload <tệp> [--timeline <dự án>] [--place] [--at <giây>] [--track <số>] [--label <tên>] [--audio-rights]
|
|
24
|
+
Đưa tệp trên máy (≤ 100 MB) vào Media của dự án (--place: đặt luôn lên timeline),
|
|
25
|
+
hoặc lên Canvas đang mở khi không có --timeline. Âm thanh cần --audio-rights.
|
|
26
|
+
clipoox export <dự án> [--out <tệp.mp4>] [--quality final|review] [--start <giây>] [--end <giây>] [--sheet <ảnh.jpg>]
|
|
27
|
+
Xuất MP4 rồi tải về máy (dự án phải đang mở trên web hoặc app desktop Clipoox)
|
|
28
|
+
clipoox skill install Cài skill cho Claude Code (~/.claude/skills/clipoox-cli)
|
|
22
29
|
Ví dụ:
|
|
23
30
|
clipoox call list_timelines
|
|
24
|
-
clipoox call get_timeline '{"
|
|
31
|
+
clipoox call get_timeline '{"project_id":"…"}' --json
|
|
32
|
+
clipoox upload intro.mp4 --timeline canvas:… --place
|
|
33
|
+
clipoox export canvas:… --out film.mp4`,
|
|
25
34
|
en: `Usage:
|
|
26
35
|
clipoox login Sign in to Clipoox (opens your browser)
|
|
27
36
|
clipoox logout Sign out on this computer
|
|
@@ -29,12 +38,19 @@ Ví dụ:
|
|
|
29
38
|
clipoox call <tool> [arguments] Call a tool. Arguments: JSON, @file.json, or - (read stdin)
|
|
30
39
|
--yes agree in advance to a tool that uses credits (required in scripts)
|
|
31
40
|
--json print the full result as JSON
|
|
41
|
+
clipoox upload <file> [--timeline <project>] [--place] [--at <s>] [--track <n>] [--label <name>] [--audio-rights]
|
|
42
|
+
Put a file from this computer (≤ 100 MB) in a project's Media (--place: on the
|
|
43
|
+
timeline too), or on the canvas open in Clipoox without --timeline. Audio needs --audio-rights.
|
|
44
|
+
clipoox export <project> [--out <file.mp4>] [--quality final|review] [--start <s>] [--end <s>] [--sheet <frames.jpg>]
|
|
45
|
+
Export an MP4 and download it (the project must be open on the web or in the desktop app)
|
|
46
|
+
clipoox skill install Install the Claude Code skill (~/.claude/skills/clipoox-cli)
|
|
32
47
|
Examples:
|
|
33
48
|
clipoox call list_timelines
|
|
34
|
-
clipoox call get_timeline '{"
|
|
49
|
+
clipoox call get_timeline '{"project_id":"…"}' --json
|
|
50
|
+
clipoox upload intro.mp4 --timeline canvas:… --place
|
|
51
|
+
clipoox export canvas:… --out film.mp4`,
|
|
35
52
|
},
|
|
36
53
|
opening: { vi: 'Mở trình duyệt để đăng nhập Clipoox. Nếu trình duyệt không mở, dán link này vào trình duyệt:', en: 'Opening your browser to sign in to Clipoox. If it does not open, paste this link into your browser:' },
|
|
37
|
-
page: { vi: 'Đã đăng nhập Clipoox CLI. Bạn có thể đóng tab này và quay lại terminal.', en: 'Clipoox CLI is signed in. You can close this tab and go back to the terminal.' },
|
|
38
54
|
signedIn: { vi: 'Đã đăng nhập. Thử: clipoox call get_account_summary', en: 'Signed in. Try: clipoox call get_account_summary' },
|
|
39
55
|
signedOut: { vi: 'Đã đăng xuất trên máy này.', en: 'Signed out on this computer.' },
|
|
40
56
|
needLogin: { vi: 'Chưa đăng nhập (hoặc phiên đã hết hạn). Chạy: clipoox login', en: 'Not signed in (or the sign-in expired). Run: clipoox login' },
|
|
@@ -44,16 +60,29 @@ Examples:
|
|
|
44
60
|
paidNeedYes: { vi: (n) => `"${n}" tốn credit. Thêm --yes để đồng ý khi chạy không có bàn phím (script, CI).`, en: (n) => `"${n}" uses credits. Add --yes to agree when running without a keyboard (scripts, CI).` },
|
|
45
61
|
cancelled: { vi: 'Đã huỷ, không gọi công cụ.', en: 'Cancelled — the tool was not called.' },
|
|
46
62
|
paidMark: { vi: '★ tốn credit (gọi với "dry_run":true để xem giá, nếu công cụ có)', en: '★ uses credits (call with "dry_run":true for the price, when the tool has it)' },
|
|
63
|
+
uploaded: { vi: (f) => `Đã đưa ${f} vào Clipoox.`, en: (f) => `${f} is in Clipoox.` },
|
|
64
|
+
exporting: { vi: (d) => `Đang xuất${d.stage ? ` (${d.stage}${d.progress != null ? ` ${Math.round(d.progress * 100)}%` : ''})` : ''}…`, en: (d) => `Exporting${d.stage ? ` (${d.stage}${d.progress != null ? ` ${Math.round(d.progress * 100)}%` : ''})` : ''}…` },
|
|
65
|
+
exported: { vi: (f) => `Đã lưu ${f}`, en: (f) => `Saved ${f}` },
|
|
66
|
+
notOpen: { vi: 'Dự án chưa mở trong Clipoox nên chưa xuất được. Mở link này (web hoặc app desktop), để nguyên tab đó, rồi chạy lại:', en: 'The project is not open in Clipoox, so nothing was exported. Open this link (web or the desktop app), keep that tab open, then run again:' },
|
|
67
|
+
needFile: { vi: 'Thiếu tệp: clipoox upload <tệp>', en: 'Missing file: clipoox upload <file>' },
|
|
68
|
+
needProject: { vi: 'Thiếu dự án: clipoox export <dự án> (xem: clipoox call list_timelines)', en: 'Missing project: clipoox export <project> (see: clipoox call list_timelines)' },
|
|
69
|
+
skillSaved: { vi: (f) => `Đã cài skill cho Claude Code: ${f}\nMở phiên Claude Code mới để skill được nạp.`, en: (f) => `Installed the Claude Code skill: ${f}\nStart a new Claude Code session to load it.` },
|
|
47
70
|
price: { vi: 'Giá (kiểm tra trước, chưa trừ credit):', en: 'Price (checked first, nothing charged yet):' },
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
export function lang(env = process.env) { return /^vi/i.test(env.LC_ALL || env.LC_MESSAGES || env.LANG || '') ? 'vi' : 'en' }
|
|
51
74
|
|
|
52
|
-
|
|
75
|
+
// flags that take a value (--out film.mp4 or --out=film.mp4)
|
|
76
|
+
const VALUE_FLAGS = { '--timeline': 'timeline', '--at': 'at', '--track': 'track', '--label': 'label', '--out': 'out', '--quality': 'quality', '--start': 'start', '--end': 'end', '--sheet': 'sheet' }
|
|
77
|
+
/** argv → { cmd, rest, flags }. Flags: --yes / -y, --json, --no-browser, --place, --audio-rights, --help / -h, --version / -v, and VALUE_FLAGS. */
|
|
53
78
|
export function parseArgs(argv) {
|
|
54
79
|
const flags = {}, rest = []
|
|
55
|
-
for (
|
|
56
|
-
|
|
80
|
+
for (let i = 0; i < argv.length; i++) {
|
|
81
|
+
const a = argv[i], eq = a.indexOf('='), key = eq > 0 ? a.slice(0, eq) : a
|
|
82
|
+
if (VALUE_FLAGS[key]) { flags[VALUE_FLAGS[key]] = eq > 0 ? a.slice(eq + 1) : argv[++i]; continue }
|
|
83
|
+
if (a === '--place') flags.place = true
|
|
84
|
+
else if (a === '--audio-rights') flags.audioRights = true
|
|
85
|
+
else if (a === '--yes' || a === '-y') flags.yes = true
|
|
57
86
|
else if (a === '--json') flags.json = true
|
|
58
87
|
else if (a === '--no-browser') flags.noBrowser = true
|
|
59
88
|
else if (a === '--help' || a === '-h') flags.help = true
|
|
@@ -90,12 +119,17 @@ export async function main(argv, io = {}) {
|
|
|
90
119
|
if (!cmd || flags.help || cmd === 'help') { out(M.usage[L]); return cmd || flags.help ? 0 : 2 }
|
|
91
120
|
|
|
92
121
|
if (cmd === 'login') {
|
|
93
|
-
const creds = await login(server, { fetch, headers,
|
|
122
|
+
const creds = await login(server, { fetch, headers, lang: L, open: async (url) => { err(`${M.opening[L]}\n${url}`); if (!flags.noBrowser) (io.open || openBrowser)(url) } })
|
|
94
123
|
saveCredentials(server, creds, env)
|
|
95
124
|
out(M.signedIn[L]); return 0
|
|
96
125
|
}
|
|
97
126
|
if (cmd === 'logout') { saveCredentials(server, null, env); out(M.signedOut[L]); return 0 }
|
|
98
|
-
if (cmd
|
|
127
|
+
if (cmd === 'skill' && rest[0] === 'install') { out(M.skillSaved[L](installSkill(env))); return 0 }
|
|
128
|
+
if (!['tools', 'call', 'upload', 'export'].includes(cmd)) { err(M.usage[L]); return 2 }
|
|
129
|
+
if (cmd === 'upload' && !rest[0]) { err(M.needFile[L]); return 2 }
|
|
130
|
+
if (cmd === 'export' && !rest[0]) { err(M.needProject[L]); return 2 }
|
|
131
|
+
const num = (v) => (v == null ? undefined : Number(v))
|
|
132
|
+
if ([flags.at, flags.track, flags.start, flags.end].some((v) => v != null && !Number.isFinite(Number(v))) || (flags.quality && !['final', 'review'].includes(flags.quality))) { err(M.usage[L]); return 2 }
|
|
99
133
|
|
|
100
134
|
let creds = loadCredentials(server, env)
|
|
101
135
|
if (!creds) { err(M.needLogin[L]); return 3 }
|
|
@@ -103,6 +137,21 @@ export async function main(argv, io = {}) {
|
|
|
103
137
|
if (expiring(creds)) await renew()
|
|
104
138
|
const client = mcpClient(server, { fetch, headers, token: () => creds.access_token, onUnauthorized: renew, clientInfo: { name: 'clipoox-cli', version: io.version || '0' } })
|
|
105
139
|
try {
|
|
140
|
+
if (cmd === 'upload') {
|
|
141
|
+
const place = flags.place || flags.at != null || flags.track != null ? { ...(flags.track != null ? { track: num(flags.track) } : {}), ...(flags.at != null ? { start: num(flags.at) } : {}) } : undefined
|
|
142
|
+
const r = await upload(client, rest[0], { project: flags.timeline, place, label: flags.label, audioRights: flags.audioRights, fetch })
|
|
143
|
+
if (flags.json) out(JSON.stringify(r.structuredContent ?? r, null, 2)); else { err(M.uploaded[L](rest[0])); out(resultText(r)) }
|
|
144
|
+
return 0
|
|
145
|
+
}
|
|
146
|
+
if (cmd === 'export') {
|
|
147
|
+
let last = ''
|
|
148
|
+
try {
|
|
149
|
+
const r = await exportVideo(client, rest[0], { out: flags.out, sheet: flags.sheet, quality: flags.quality, start: num(flags.start), end: num(flags.end), fetch, ...(io.sleep ? { sleep: io.sleep } : {}),
|
|
150
|
+
onProgress: (d) => { const s = M.exporting[L](d); if (s !== last) { err(s); last = s } } })
|
|
151
|
+
if (flags.json) out(JSON.stringify(r, null, 2)); else { out(M.exported[L](r.file)); if (r.summary) err(r.summary) }
|
|
152
|
+
return 0
|
|
153
|
+
} catch (e) { if (e.notOpen) { err(`${M.notOpen[L]}\n${e.url || ''}`); return 1 } throw e }
|
|
154
|
+
}
|
|
106
155
|
const tools = await client.listTools()
|
|
107
156
|
if (cmd === 'tools') {
|
|
108
157
|
if (flags.json) out(JSON.stringify(tools.map((t) => ({ name: t.name, title: t.title, description: t.description, usesCredits: spendsCredits(t), inputSchema: t.inputSchema })), null, 2))
|
|
@@ -127,6 +176,7 @@ export async function main(argv, io = {}) {
|
|
|
127
176
|
return r?.isError ? 1 : 0
|
|
128
177
|
} catch (e) {
|
|
129
178
|
if (e instanceof AuthRequired) { err(M.needLogin[L]); return 3 }
|
|
179
|
+
if (e.tool) { err(e.message); return 1 }
|
|
130
180
|
throw e
|
|
131
181
|
}
|
|
132
182
|
}
|
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The everyday jobs in one command each (0.2): upload a file from this computer, export a Timeline to an MP4 file,
|
|
2
|
+
// install the Claude Code skill. Each is a short sequence of the same MCP tools `clipoox call` reaches.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { Readable } from 'node:stream'
|
|
7
|
+
import { resultText } from './mcp.mjs'
|
|
8
|
+
|
|
9
|
+
export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
|
10
|
+
const data = (r) => r?.structuredContent ?? {}
|
|
11
|
+
/** A tool error as an Error (the tool's own words). */
|
|
12
|
+
const fail = (r, what) => { if (r?.isError) throw Object.assign(new Error(resultText(r) || what), { tool: true }); return r }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* upload: request_upload → PUT the bytes to the private link (its exact headers) → add_media (a Timeline's Media bin,
|
|
16
|
+
* `place` also puts it on the timeline) or, without a project, push_to_canvas (the canvas open in Clipoox).
|
|
17
|
+
*/
|
|
18
|
+
export async function upload(client, file, { project, place, label, audioRights, fetch = globalThis.fetch } = {}) {
|
|
19
|
+
const st = fs.statSync(file)
|
|
20
|
+
if (!st.isFile()) throw new Error(`${file}: not a file`)
|
|
21
|
+
if (st.size < 1 || st.size > MAX_UPLOAD_BYTES) throw new Error(`${file}: ${st.size} bytes (1 byte – 100 MB)`)
|
|
22
|
+
const name = path.basename(file)
|
|
23
|
+
const link = data(fail(await client.callTool('request_upload', { file_name: name }), 'request_upload failed'))
|
|
24
|
+
if (!link.upload_url || !link.upload_id) throw new Error('request_upload: no upload link')
|
|
25
|
+
// the whole file in one body (≤ 100 MB): storage checks its length against the signed range
|
|
26
|
+
const put = await fetch(link.upload_url, { method: link.method || 'PUT', headers: link.headers || {}, body: fs.readFileSync(file) })
|
|
27
|
+
if (!put.ok) throw new Error(`upload: HTTP ${put.status}`)
|
|
28
|
+
const common = { upload_id: link.upload_id, ...(audioRights ? { audio_rights_confirmed: true } : {}) }
|
|
29
|
+
const r = project
|
|
30
|
+
? await client.callTool('add_media', { project_id: project, ...common, label: label || name, ...(place ? { place } : {}) })
|
|
31
|
+
: await client.callTool('push_to_canvas', { ...common, caption: label || name })
|
|
32
|
+
return fail(r, 'adding the media failed')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* export: export_video (waits up to 50 s) → get_export until it is done → the MP4 downloaded to `out`. `sheet` also saves
|
|
37
|
+
* the contact sheet (the frames the export shows). Stops after `timeoutMs`.
|
|
38
|
+
*/
|
|
39
|
+
export async function exportVideo(client, project, { out, sheet, quality, start, end, timeoutMs = 30 * 60_000, onProgress = () => {}, fetch = globalThis.fetch, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
40
|
+
const t0 = now()
|
|
41
|
+
let r = fail(await client.callTool('export_video', { project_id: project, ...(quality ? { quality } : {}), ...(start != null ? { start_s: start } : {}), ...(end != null ? { end_s: end } : {}) }), 'export failed')
|
|
42
|
+
let d = data(r)
|
|
43
|
+
if (d.rendered === false) throw Object.assign(new Error(resultText(r).split('\n')[0] || 'the Timeline is not open in Clipoox'), { notOpen: true, url: d.url })
|
|
44
|
+
while (d.status !== 'done') {
|
|
45
|
+
if (!d.export_id) throw new Error(resultText(r).split('\n')[0] || 'export: no export id')
|
|
46
|
+
if (now() - t0 > timeoutMs) throw new Error(`export still running after ${Math.round(timeoutMs / 60000)} min (export_id ${d.export_id}: clipoox call get_export '{"export_id":"${d.export_id}"}')`)
|
|
47
|
+
onProgress(d)
|
|
48
|
+
// get_export waits up to 50 s on the server; a quick "not yet" is spaced out
|
|
49
|
+
const t = now(); r = fail(await client.callTool('get_export', { export_id: d.export_id }), 'export failed'); d = data(r)
|
|
50
|
+
if (d.status !== 'done' && now() - t < 2000) await sleep(2000)
|
|
51
|
+
}
|
|
52
|
+
if (!d.download_url) throw new Error('export: no download link')
|
|
53
|
+
const file = out || `clipoox-${d.export_id}.mp4`
|
|
54
|
+
const res = await fetch(d.download_url)
|
|
55
|
+
if (!res.ok || !res.body) throw new Error(`download: HTTP ${res.status}`)
|
|
56
|
+
const tmp = `${file}.part`
|
|
57
|
+
await new Promise((ok, bad) => { const w = fs.createWriteStream(tmp); Readable.fromWeb(res.body).on('error', bad).pipe(w).on('finish', ok).on('error', bad) })
|
|
58
|
+
fs.renameSync(tmp, file)
|
|
59
|
+
const img = (r.content || []).find((c) => c?.type === 'image' && c.data)
|
|
60
|
+
if (sheet && img) fs.writeFileSync(sheet, Buffer.from(img.data, 'base64'))
|
|
61
|
+
return { file, sheet: sheet && img ? sheet : null, summary: resultText(r).split('\n')[0], ...d }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Where Claude Code looks for personal skills (CLAUDE_CONFIG_DIR, else ~/.claude). */
|
|
65
|
+
export const skillDir = (env = process.env) => path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'skills', 'clipoox-cli')
|
|
66
|
+
export const SKILL = `---
|
|
67
|
+
name: clipoox-cli
|
|
68
|
+
description: Use the \`clipoox\` command (npm package clipoox) from the shell when working with Clipoox in Claude Code — big Timeline reads kept out of the chat (write JSON to a file, read parts with jq), batch jobs over many files or projects, uploading local files, exporting MP4s to disk, scheduled / CI runs. Prefer the Clipoox MCP tools for single small calls when they are connected.
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
# Clipoox from the shell (\`clipoox\`)
|
|
72
|
+
|
|
73
|
+
The same tools as the Clipoox MCP connector, as a command. Sign-in is saved on this computer (\`npx clipoox login\` once, in the user's browser — never ask for a password).
|
|
74
|
+
|
|
75
|
+
## When to use it instead of the MCP tools
|
|
76
|
+
- **Large results:** \`npx clipoox call get_timeline '{"project_id":"…"}' --json > tl.json\`, then \`jq\` only what you need. The MCP call would put the whole timeline into the conversation.
|
|
77
|
+
- **Many items:** a shell loop over files / projects (\`upload\`, \`call edit_timeline\`) instead of one tool call per item.
|
|
78
|
+
- **Local files:** \`npx clipoox upload <file> --timeline <project_id> [--place]\` (≤ 100 MB; without --timeline it goes to the canvas open in Clipoox). Audio: add \`--audio-rights\` only after the user confirms they may use it.
|
|
79
|
+
- **An MP4 on disk:** \`npx clipoox export <project_id> --out film.mp4 [--quality review] [--sheet frames.jpg]\` — the project must be open in Clipoox (web or desktop app); otherwise it says so with the link to open.
|
|
80
|
+
- **No MCP:** the connector is not loaded in this session, or a script / cron job / CI runs without an AI.
|
|
81
|
+
|
|
82
|
+
## Commands
|
|
83
|
+
- \`npx clipoox tools [--json]\` — every tool (★ = uses credits) and, with --json, its input schema.
|
|
84
|
+
- \`npx clipoox call <tool> [json | @file.json | -] [--json]\` — one tool. Read \`get_timeline_guide\` for edit_timeline ops.
|
|
85
|
+
- \`npx clipoox upload <file> [--timeline <id>] [--place] [--at <s>] [--track <n>] [--label <text>] [--audio-rights]\`
|
|
86
|
+
- \`npx clipoox export <id> [--out <file.mp4>] [--quality final|review] [--start <s>] [--end <s>] [--sheet <file.jpg>]\`
|
|
87
|
+
|
|
88
|
+
## Credits — always ask the user first
|
|
89
|
+
A ★ tool (or a call with max_credits / confirmed) spends the user's credits. Get the price with \`'{"dry_run":true, …}'\` where the tool has it, tell the user, and only after they agree run it with \`--yes\` (without a keyboard the CLI refuses paid calls without --yes). Never add --yes on your own.
|
|
90
|
+
|
|
91
|
+
## Exit codes
|
|
92
|
+
0 ok · 1 the tool reported an error (read stderr) · 2 usage / a paid call not confirmed · 3 not signed in → ask the user to run \`npx clipoox login\`.
|
|
93
|
+
`
|
|
94
|
+
export function installSkill(env) {
|
|
95
|
+
const dir = skillDir(env)
|
|
96
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
97
|
+
const file = path.join(dir, 'SKILL.md')
|
|
98
|
+
fs.writeFileSync(file, SKILL)
|
|
99
|
+
return file
|
|
100
|
+
}
|
package/lib/oauth.mjs
CHANGED
|
@@ -35,16 +35,35 @@ export async function discover(mcpUrl, { fetch = globalThis.fetch, headers = {}
|
|
|
35
35
|
return { resource: prm.resource || mcpUrl, metadata: as }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// The page the browser lands on after clipoox.com sends it back here: authorized, cancelled (the user pressed Cancel →
|
|
39
|
+
// error=access_denied) or failed. Language: the browser's first Accept-Language entry, else the CLI's own.
|
|
40
|
+
const PAGE = {
|
|
41
|
+
vi: { ok: ['Đã cấp quyền cho thiết bị', 'Bạn có thể đóng tab này và quay lại Clipoox CLI.'], no: ['Đã huỷ kết nối', 'Clipoox CLI chưa được cấp quyền. Bạn có thể đóng tab này và chạy lại clipoox login khi cần.'], bad: ['Không đăng nhập được', 'Đóng tab này và xem lỗi trong terminal.'] },
|
|
42
|
+
en: { ok: ['Device authorized', 'You can close this tab and return to the Clipoox CLI.'], no: ['Connection cancelled', 'Clipoox CLI was not given access. You can close this tab and run clipoox login again when you are ready.'], bad: ['Sign-in failed', 'Close this tab and check the terminal for the error.'] },
|
|
43
|
+
}
|
|
44
|
+
const ICON = {
|
|
45
|
+
ok: ['#2f5a3a', '#5fd47a', '<path d="M17 25.5l6 6 12-13"/>'],
|
|
46
|
+
no: ['#3a3d42', '#b9bec6', '<path d="M19 19l14 14M33 19L19 33"/>'],
|
|
47
|
+
bad: ['#5a2f2f', '#ff8b7c', '<path d="M26 16v12M26 35v.5"/>'],
|
|
48
|
+
}
|
|
49
|
+
export function callbackPage(query = {}, lang = 'en') {
|
|
50
|
+
const kind = query.error === 'access_denied' ? 'no' : query.error || !query.code ? 'bad' : 'ok'
|
|
51
|
+
const [title, text] = (PAGE[lang] || PAGE.en)[kind], [bg, fg, path] = ICON[kind]
|
|
52
|
+
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title} · Clipoox CLI</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;box-sizing:border-box;background:#141618 radial-gradient(rgba(255,255,255,.035) 1px,transparent 1px) 0 0/28px 28px;color:#f4f6f1;font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}main{width:min(560px,100%);padding:48px 32px;border-radius:16px;text-align:center;background:linear-gradient(180deg,#303337,#25272b);border:1px solid #3a3d42;box-shadow:0 30px 80px rgba(0,0,0,.4)}svg{width:104px;height:104px}h1{margin:22px 0 10px;font-size:40px;line-height:1.1;letter-spacing:-.01em}p{margin:0;color:#a8adb4;font-size:18px}</style></head><body><main><svg viewBox="0 0 52 52" aria-hidden="true"><circle cx="26" cy="26" r="26" fill="${bg}"/><g fill="none" stroke="${fg}" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">${path}</g></svg><h1>${title}</h1><p>${text}</p></main></body></html>`
|
|
53
|
+
}
|
|
54
|
+
const pageLang = (header, fallback) => { const first = String(header || '').split(',')[0].trim(); return /^[a-z]{2}/i.test(first) ? (/^vi\b/i.test(first) ? 'vi' : 'en') : fallback }
|
|
55
|
+
|
|
38
56
|
/** A one-off listener on 127.0.0.1 (random port) that waits for /callback?code&state. */
|
|
39
|
-
export function callbackServer({
|
|
57
|
+
export function callbackServer({ lang = 'en' } = {}) {
|
|
40
58
|
let resolve, reject
|
|
41
59
|
const result = new Promise((a, b) => { resolve = a; reject = b })
|
|
42
60
|
const server = http.createServer((req, res) => {
|
|
43
61
|
const url = new URL(req.url, 'http://127.0.0.1')
|
|
44
62
|
if (url.pathname !== '/callback') { res.writeHead(404).end(); return }
|
|
63
|
+
const query = Object.fromEntries(url.searchParams)
|
|
45
64
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
46
|
-
res.end(
|
|
47
|
-
resolve(
|
|
65
|
+
res.end(callbackPage(query, pageLang(req.headers['accept-language'], lang)))
|
|
66
|
+
resolve(query)
|
|
48
67
|
})
|
|
49
68
|
const ready = new Promise((ok, bad) => { server.once('error', bad); server.listen(0, '127.0.0.1', () => ok(server.address().port)) })
|
|
50
69
|
return { ready, result, close: () => server.close(), fail: (e) => reject(e) }
|
|
@@ -54,9 +73,9 @@ export function callbackServer({ page = 'Done — you can close this tab.' } = {
|
|
|
54
73
|
* Full sign-in. `open(url)` shows the authorize page to the user (their browser); resolves with the tokens to save.
|
|
55
74
|
* `timeoutMs` bounds the wait for the browser.
|
|
56
75
|
*/
|
|
57
|
-
export async function login(mcpUrl, { fetch = globalThis.fetch, open, headers = {}, clientName = 'Clipoox CLI',
|
|
76
|
+
export async function login(mcpUrl, { fetch = globalThis.fetch, open, headers = {}, clientName = 'Clipoox CLI', lang = 'en', timeoutMs = 300_000 } = {}) {
|
|
58
77
|
const { resource, metadata } = await discover(mcpUrl, { fetch, headers })
|
|
59
|
-
const cb = callbackServer({
|
|
78
|
+
const cb = callbackServer({ lang })
|
|
60
79
|
try {
|
|
61
80
|
const port = await cb.ready, redirectUri = `http://127.0.0.1:${port}/callback`
|
|
62
81
|
const reg = await json(await fetch(metadata.registration_endpoint, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ client_name: clientName, redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', scope: SCOPE }) }), 'client registration')
|
package/package.json
CHANGED