clipoox 0.0.0-stage → 0.2.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clipoox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # clipoox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Clipoox from the terminal: sign in once, then call any Clipoox tool from a script or an automated job. Covers Timeline, media, AI generation, 3D Shot and SEO. These are the same tools your AI gets through the Clipoox connector (https://clipoox.com/mcp).
4
+
5
+ Clipoox trong terminal: đăng nhập một lần, rồi gọi mọi công cụ Clipoox từ script hoặc tác vụ tự động. Gồm Timeline, media, tạo nội dung AI, 3D Shot và SEO. Đây cũng là bộ công cụ mà AI của bạn dùng qua connector Clipoox.
6
+
7
+ Needs Node.js 18 or newer. / Cần Node.js 18 trở lên.
8
+
9
+ ```bash
10
+ npx clipoox login # opens your browser — sign in on clipoox.com
11
+ npx clipoox tools # the tools; ★ = uses credits
12
+ npx clipoox call list_timelines
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
17
+ npx clipoox logout
18
+ ```
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
+
26
+ **Arguments** are a JSON object, `@file.json`, or `-` to read stdin. `--json` prints the full result.
27
+
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, …}'`.
29
+
30
+ **Sign-in.** OAuth with PKCE in your own browser; the CLI never sees your password. The tokens are kept in `~/.config/clipoox/credentials.json` (Windows: `%APPDATA%\clipoox`), readable by your user account only. `CLIPOOX_CONFIG_DIR` puts them elsewhere.
31
+
32
+ **Exit codes:** 0 ok · 1 the tool reported an error · 2 usage, or a paid call that was not confirmed · 3 not signed in.
33
+
34
+ **Export.** `export_video` renders in the Clipoox editor, so the project must be open on the web or in the desktop app.
35
+
36
+ Messages follow your system language (Vietnamese when `LANG` starts with `vi`).
37
+
38
+ Setup for Claude Code, Codex CLI, Gemini CLI, ChatGPT and Cursor: https://platform.clipoox.com/connect
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // The `clipoox` command (npm package "clipoox"): see ../README.md.
3
+ import { readFileSync } from 'node:fs'
4
+ import { main } from '../lib/cli.mjs'
5
+
6
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
7
+ main(process.argv.slice(2), { version }).then((code) => { process.exitCode = code }, (e) => {
8
+ process.stderr.write(`clipoox: ${e?.message || e}\n`)
9
+ process.exitCode = 1
10
+ })
package/lib/cli.mjs ADDED
@@ -0,0 +1,187 @@
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] | upload <file> | export <project> | skill install
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
+ // the request failed, 2 usage / needs confirmation, 3 not signed in.
5
+ import fs from 'node:fs'
6
+ import readline from 'node:readline/promises'
7
+ import { spawn } from 'node:child_process'
8
+ import { login, refresh, expiring } from './oauth.mjs'
9
+ import { mcpClient, spendsCredits, willSpend, quotes, resultText, AuthRequired } from './mcp.mjs'
10
+ import { loadCredentials, saveCredentials, credentialsPath } from './store.mjs'
11
+ import { upload, exportVideo, installSkill } from './commands.mjs'
12
+
13
+ export const DEFAULT_MCP_URL = 'https://clipoox.com/mcp'
14
+ const M = {
15
+ usage: {
16
+ vi: `Cách dùng:
17
+ clipoox login Đăng nhập Clipoox (mở trình duyệt)
18
+ clipoox logout Đăng xuất trên máy này
19
+ clipoox tools [--json] Liệt kê công cụ (★ = tốn credit)
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)
21
+ --yes đồng ý trước với công cụ tốn credit (bắt buộc khi chạy trong script)
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)
29
+ Ví dụ:
30
+ clipoox call list_timelines
31
+ clipoox call get_timeline '{"project_id":"…"}' --json
32
+ clipoox upload intro.mp4 --timeline canvas:… --place
33
+ clipoox export canvas:… --out film.mp4`,
34
+ en: `Usage:
35
+ clipoox login Sign in to Clipoox (opens your browser)
36
+ clipoox logout Sign out on this computer
37
+ clipoox tools [--json] List the tools (★ = uses credits)
38
+ clipoox call <tool> [arguments] Call a tool. Arguments: JSON, @file.json, or - (read stdin)
39
+ --yes agree in advance to a tool that uses credits (required in scripts)
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)
47
+ Examples:
48
+ clipoox call list_timelines
49
+ clipoox call get_timeline '{"project_id":"…"}' --json
50
+ clipoox upload intro.mp4 --timeline canvas:… --place
51
+ clipoox export canvas:… --out film.mp4`,
52
+ },
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:' },
54
+ 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.' },
55
+ signedIn: { vi: 'Đã đăng nhập. Thử: clipoox call get_account_summary', en: 'Signed in. Try: clipoox call get_account_summary' },
56
+ signedOut: { vi: 'Đã đăng xuất trên máy này.', en: 'Signed out on this computer.' },
57
+ 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' },
58
+ noTool: { vi: (n) => `Không có công cụ "${n}". Xem danh sách: clipoox tools`, en: (n) => `No tool "${n}". See the list: clipoox tools` },
59
+ badArgs: { vi: 'Tham số phải là một đối tượng JSON, ví dụ {"limit":5}', en: 'Arguments must be a JSON object, e.g. {"limit":5}' },
60
+ paidAsk: { vi: (n) => `"${n}" tốn credit trong ví Clipoox của bạn. Tiếp tục? [y/N] `, en: (n) => `"${n}" uses credits from your Clipoox wallet. Continue? [y/N] ` },
61
+ 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).` },
62
+ cancelled: { vi: 'Đã huỷ, không gọi công cụ.', en: 'Cancelled — the tool was not called.' },
63
+ 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)' },
64
+ uploaded: { vi: (f) => `Đã đưa ${f} vào Clipoox.`, en: (f) => `${f} is in Clipoox.` },
65
+ 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)}%` : ''})` : ''}…` },
66
+ exported: { vi: (f) => `Đã lưu ${f}`, en: (f) => `Saved ${f}` },
67
+ 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:' },
68
+ needFile: { vi: 'Thiếu tệp: clipoox upload <tệp>', en: 'Missing file: clipoox upload <file>' },
69
+ 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)' },
70
+ 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.` },
71
+ price: { vi: 'Giá (kiểm tra trước, chưa trừ credit):', en: 'Price (checked first, nothing charged yet):' },
72
+ }
73
+
74
+ export function lang(env = process.env) { return /^vi/i.test(env.LC_ALL || env.LC_MESSAGES || env.LANG || '') ? 'vi' : 'en' }
75
+
76
+ // flags that take a value (--out film.mp4 or --out=film.mp4)
77
+ const VALUE_FLAGS = { '--timeline': 'timeline', '--at': 'at', '--track': 'track', '--label': 'label', '--out': 'out', '--quality': 'quality', '--start': 'start', '--end': 'end', '--sheet': 'sheet' }
78
+ /** argv → { cmd, rest, flags }. Flags: --yes / -y, --json, --no-browser, --place, --audio-rights, --help / -h, --version / -v, and VALUE_FLAGS. */
79
+ export function parseArgs(argv) {
80
+ const flags = {}, rest = []
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const a = argv[i], eq = a.indexOf('='), key = eq > 0 ? a.slice(0, eq) : a
83
+ if (VALUE_FLAGS[key]) { flags[VALUE_FLAGS[key]] = eq > 0 ? a.slice(eq + 1) : argv[++i]; continue }
84
+ if (a === '--place') flags.place = true
85
+ else if (a === '--audio-rights') flags.audioRights = true
86
+ else if (a === '--yes' || a === '-y') flags.yes = true
87
+ else if (a === '--json') flags.json = true
88
+ else if (a === '--no-browser') flags.noBrowser = true
89
+ else if (a === '--help' || a === '-h') flags.help = true
90
+ else if (a === '--version' || a === '-v') flags.version = true
91
+ else rest.push(a)
92
+ }
93
+ return { cmd: rest.shift(), rest, flags }
94
+ }
95
+
96
+ /** Tool arguments from the command line: inline JSON, @file, or - (stdin text given as `stdin`). */
97
+ export function readToolArgs(spec, { stdin = '', readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
98
+ if (spec == null || spec === '') return {}
99
+ const text = spec === '-' ? stdin : spec.startsWith('@') ? readFile(spec.slice(1)) : spec
100
+ const v = JSON.parse(text || '{}')
101
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('not an object')
102
+ return v
103
+ }
104
+
105
+ export function openBrowser(url, platform = process.platform) {
106
+ const [cmd, args] = platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]]
107
+ try { const p = spawn(cmd, args, { stdio: 'ignore', detached: true }); p.on('error', () => {}); p.unref() } catch { /* the link is printed anyway */ }
108
+ }
109
+
110
+ /**
111
+ * Run one command. io: { out, err, env, fetch, stdin (async → string), isTTY, ask (async question → answer), open, version }.
112
+ * Returns the exit code.
113
+ */
114
+ export async function main(argv, io = {}) {
115
+ const env = io.env || process.env, L = lang(env), out = io.out || ((s) => process.stdout.write(`${s}\n`)), err = io.err || ((s) => process.stderr.write(`${s}\n`))
116
+ const fetch = io.fetch || globalThis.fetch, server = env.CLIPOOX_MCP_URL || DEFAULT_MCP_URL
117
+ const headers = { 'user-agent': `clipoox-cli/${io.version || '0'} (node ${process.versions.node})` }
118
+ const { cmd, rest, flags } = parseArgs(argv)
119
+ if (flags.version) { out(io.version || '0'); return 0 }
120
+ if (!cmd || flags.help || cmd === 'help') { out(M.usage[L]); return cmd || flags.help ? 0 : 2 }
121
+
122
+ if (cmd === 'login') {
123
+ const creds = await login(server, { fetch, headers, page: M.page[L], open: async (url) => { err(`${M.opening[L]}\n${url}`); if (!flags.noBrowser) (io.open || openBrowser)(url) } })
124
+ saveCredentials(server, creds, env)
125
+ out(M.signedIn[L]); return 0
126
+ }
127
+ if (cmd === 'logout') { saveCredentials(server, null, env); out(M.signedOut[L]); return 0 }
128
+ if (cmd === 'skill' && rest[0] === 'install') { out(M.skillSaved[L](installSkill(env))); return 0 }
129
+ if (!['tools', 'call', 'upload', 'export'].includes(cmd)) { err(M.usage[L]); return 2 }
130
+ if (cmd === 'upload' && !rest[0]) { err(M.needFile[L]); return 2 }
131
+ if (cmd === 'export' && !rest[0]) { err(M.needProject[L]); return 2 }
132
+ const num = (v) => (v == null ? undefined : Number(v))
133
+ 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 }
134
+
135
+ let creds = loadCredentials(server, env)
136
+ if (!creds) { err(M.needLogin[L]); return 3 }
137
+ const renew = async () => { const c = await refresh(creds, { fetch, headers }); if (!c) return false; creds = c; saveCredentials(server, c, env); return true }
138
+ if (expiring(creds)) await renew()
139
+ const client = mcpClient(server, { fetch, headers, token: () => creds.access_token, onUnauthorized: renew, clientInfo: { name: 'clipoox-cli', version: io.version || '0' } })
140
+ try {
141
+ if (cmd === 'upload') {
142
+ 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
143
+ const r = await upload(client, rest[0], { project: flags.timeline, place, label: flags.label, audioRights: flags.audioRights, fetch })
144
+ if (flags.json) out(JSON.stringify(r.structuredContent ?? r, null, 2)); else { err(M.uploaded[L](rest[0])); out(resultText(r)) }
145
+ return 0
146
+ }
147
+ if (cmd === 'export') {
148
+ let last = ''
149
+ try {
150
+ 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 } : {}),
151
+ onProgress: (d) => { const s = M.exporting[L](d); if (s !== last) { err(s); last = s } } })
152
+ if (flags.json) out(JSON.stringify(r, null, 2)); else { out(M.exported[L](r.file)); if (r.summary) err(r.summary) }
153
+ return 0
154
+ } catch (e) { if (e.notOpen) { err(`${M.notOpen[L]}\n${e.url || ''}`); return 1 } throw e }
155
+ }
156
+ const tools = await client.listTools()
157
+ if (cmd === 'tools') {
158
+ 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))
159
+ else { for (const t of tools) out(`${spendsCredits(t) ? '★' : ' '} ${t.name}${t.title ? ` — ${t.title}` : ''}`); out(`\n${M.paidMark[L]}`) }
160
+ return 0
161
+ }
162
+ const name = rest[0], tool = tools.find((t) => t.name === name)
163
+ if (!tool) { err(M.noTool[L](name ?? '')); return 2 }
164
+ let args
165
+ try { args = readToolArgs(rest[1], { stdin: rest[1] === '-' ? await (io.stdin || readStdin)() : '' }) } catch { err(M.badArgs[L]); return 2 }
166
+ if (willSpend(tool, args) && !flags.yes) {
167
+ const tty = io.isTTY ?? (process.stdin.isTTY && process.stderr.isTTY)
168
+ if (!tty) { err(M.paidNeedYes[L](name)); return 2 }
169
+ // the price first when the tool can say it (dry_run): nothing is charged for it
170
+ if (quotes(tool)) { const q = await client.callTool(name, { ...args, dry_run: true }); err(`${M.price[L]}\n${resultText(q) || JSON.stringify(q.structuredContent ?? q, null, 2)}`) }
171
+ const a = await (io.ask || ask)(M.paidAsk[L](name))
172
+ if (!/^(y|yes|c|có|co)$/i.test(String(a).trim())) { err(M.cancelled[L]); return 2 }
173
+ }
174
+ const r = await client.callTool(name, args)
175
+ if (flags.json) out(JSON.stringify(r, null, 2))
176
+ else { const text = resultText(r); out(text || JSON.stringify(r.structuredContent ?? r, null, 2)) }
177
+ return r?.isError ? 1 : 0
178
+ } catch (e) {
179
+ if (e instanceof AuthRequired) { err(M.needLogin[L]); return 3 }
180
+ if (e.tool) { err(e.message); return 1 }
181
+ throw e
182
+ }
183
+ }
184
+
185
+ async function readStdin() { let s = ''; for await (const c of process.stdin) s += c; return s }
186
+ async function ask(q) { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); try { return await rl.question(q) } finally { rl.close() } }
187
+ export { credentialsPath }
@@ -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/mcp.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // A small MCP client over Streamable HTTP (JSON-RPC POSTs; the answer is JSON or a text/event-stream): initialize once,
2
+ // then tools/list and tools/call. No SDK, no dependencies — the CLI installs nothing but itself.
3
+ export const PROTOCOL_VERSION = '2025-06-18'
4
+
5
+ /** The JSON-RPC messages in a text/event-stream body (`data:` lines of each event; comments / keep-alives skipped). */
6
+ export function parseSse(text) {
7
+ const out = []
8
+ for (const block of text.replace(/\r\n/g, '\n').split('\n\n')) {
9
+ const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n')
10
+ if (!data) continue
11
+ try { out.push(JSON.parse(data)) } catch { /* not JSON: ignore */ }
12
+ }
13
+ return out
14
+ }
15
+
16
+ export class AuthRequired extends Error { constructor() { super('sign-in required'); this.name = 'AuthRequired' } }
17
+
18
+ /**
19
+ * `token()` gives the current access token; `onUnauthorized()` may return a new one (refresh) — a 401 is retried once
20
+ * with it, else AuthRequired is thrown.
21
+ */
22
+ export function mcpClient(url, { fetch = globalThis.fetch, token, onUnauthorized, headers = {}, clientInfo = { name: 'clipoox-cli', version: '0' } } = {}) {
23
+ let session = null, nextId = 1, ready = null
24
+ async function post(body, { notify = false, retried = false } = {}) {
25
+ const res = await fetch(url, { method: 'POST', headers: { ...headers, 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${await token()}`, ...(session ? { 'mcp-session-id': session } : {}), 'mcp-protocol-version': PROTOCOL_VERSION }, body: JSON.stringify(body) })
26
+ if (res.status === 401) {
27
+ await res.text().catch(() => {})
28
+ if (!retried && onUnauthorized && await onUnauthorized()) return post(body, { notify, retried: true })
29
+ throw new AuthRequired()
30
+ }
31
+ const sid = res.headers.get('mcp-session-id'); if (sid) session = sid
32
+ if (notify) { await res.text().catch(() => {}); if (!res.ok && res.status !== 202) throw new Error(`MCP: HTTP ${res.status}`); return null }
33
+ const type = res.headers.get('content-type') || '', text = await res.text()
34
+ if (!res.ok) throw new Error(`MCP: HTTP ${res.status}${text ? ` — ${text.slice(0, 300)}` : ''}`)
35
+ const msgs = type.includes('text/event-stream') ? parseSse(text) : [JSON.parse(text)]
36
+ const reply = msgs.flat().find((m) => m && m.id === body.id && ('result' in m || 'error' in m))
37
+ if (!reply) throw new Error('MCP: no answer to the request')
38
+ if (reply.error) throw Object.assign(new Error(`MCP error ${reply.error.code}: ${reply.error.message}`), { rpc: reply.error })
39
+ return reply.result
40
+ }
41
+ const request = (method, params) => post({ jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) })
42
+ async function init() {
43
+ const r = await request('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo })
44
+ await post({ jsonrpc: '2.0', method: 'notifications/initialized' }, { notify: true })
45
+ return r
46
+ }
47
+ const start = () => (ready ??= init().catch((e) => { ready = null; throw e }))
48
+ return {
49
+ initialize: start,
50
+ async listTools() {
51
+ await start()
52
+ const tools = []; let cursor
53
+ do { const r = await request('tools/list', cursor ? { cursor } : {}); tools.push(...(r.tools || [])); cursor = r.nextCursor } while (cursor && tools.length < 2000)
54
+ return tools
55
+ },
56
+ async callTool(name, args = {}) { await start(); return request('tools/call', { name, arguments: args }) },
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Does this tool spend credits? Clipoox marks it in the description ("Spends credits", "PAID (metered…)", "Tốn credit");
62
+ * free tools start "Free — no credits" (edit_timeline's paid ops are then priced with dry_run / max_credits, see
63
+ * willSpend). "does not … spend credits" is not a mark.
64
+ */
65
+ export function spendsCredits(tool) {
66
+ const d = String(tool?.description || '').replace(/\b(does not|doesn'?t|without|never|not)\b[^.]{0,40}?\b(spend|charg|cost|use)\w*\s+credits?/gi, '')
67
+ if (/^\s*free\b/i.test(d)) return false
68
+ return /\b(spends?|uses?|deducts?|costs?|charges?)\s+(the user'?s\s+)?(clipoox\s+)?credits?\b|\bpaid\b|\bmetered\b|tốn credit|trừ credit/i.test(d)
69
+ }
70
+ /**
71
+ * Would this call spend credits? A paid tool unless it is only a price check (dry_run: true); a free tool when the call
72
+ * carries a price it agrees to (max_credits, or run_ai_editor's confirmed: true).
73
+ */
74
+ export function willSpend(tool, args = {}) {
75
+ if (args.dry_run === true) return false
76
+ return spendsCredits(tool) || args.max_credits != null || args.confirmed === true
77
+ }
78
+ /** The tool can say its price first (it takes dry_run). */
79
+ export const quotes = (tool) => !!tool?.inputSchema?.properties?.dry_run
80
+
81
+ /** The text a tool result shows (its text parts), for printing. */
82
+ export const resultText = (r) => (r?.content || []).filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
package/lib/oauth.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // Sign-in as every MCP client does (Claude Code, Codex, Gemini CLI): OAuth 2.1 authorization code + PKCE (S256), the client
2
+ // registered on the fly (RFC 7591), the browser sent to clipoox.com and back to a one-off listener on 127.0.0.1.
3
+ // The CLI never sees a password — the user signs in on clipoox.com in their own browser.
4
+ import crypto from 'node:crypto'
5
+ import http from 'node:http'
6
+
7
+ export const SCOPE = 'clipoox:use'
8
+ const b64url = (buf) => Buffer.from(buf).toString('base64url')
9
+ export function pkcePair() {
10
+ const verifier = b64url(crypto.randomBytes(32))
11
+ return { verifier, challenge: b64url(crypto.createHash('sha256').update(verifier).digest()) }
12
+ }
13
+
14
+ async function json(res, what) {
15
+ const text = await res.text()
16
+ let body; try { body = JSON.parse(text) } catch { body = null }
17
+ if (!res.ok) throw Object.assign(new Error(`${what}: HTTP ${res.status}${body?.error ? ` (${body.error}${body.error_description ? `: ${body.error_description}` : ''})` : ''}`), { status: res.status, body })
18
+ if (!body || typeof body !== 'object') throw new Error(`${what}: not JSON`)
19
+ return body
20
+ }
21
+
22
+ /** The authorization server of an MCP URL (protected resource metadata → authorization server metadata). */
23
+ export async function discover(mcpUrl, { fetch = globalThis.fetch, headers = {} } = {}) {
24
+ const u = new URL(mcpUrl)
25
+ let prm
26
+ for (const p of [`/.well-known/oauth-protected-resource${u.pathname.replace(/\/$/, '')}`, '/.well-known/oauth-protected-resource']) {
27
+ const r = await fetch(new URL(p, u.origin), { headers })
28
+ if (r.ok) { prm = await json(r, 'protected resource metadata'); break }
29
+ }
30
+ if (!prm?.authorization_servers?.length) throw new Error('no authorization server for this MCP URL')
31
+ const issuer = new URL(prm.authorization_servers[0])
32
+ const as = await json(await fetch(new URL(`/.well-known/oauth-authorization-server${issuer.pathname.replace(/\/$/, '')}`, issuer.origin), { headers }), 'authorization server metadata')
33
+ for (const k of ['authorization_endpoint', 'token_endpoint', 'registration_endpoint']) if (typeof as[k] !== 'string') throw new Error(`authorization server: no ${k}`)
34
+ if (!(as.code_challenge_methods_supported || ['S256']).includes('S256')) throw new Error('authorization server: no PKCE S256')
35
+ return { resource: prm.resource || mcpUrl, metadata: as }
36
+ }
37
+
38
+ /** A one-off listener on 127.0.0.1 (random port) that waits for /callback?code&state. */
39
+ export function callbackServer({ page = 'Done — you can close this tab.' } = {}) {
40
+ let resolve, reject
41
+ const result = new Promise((a, b) => { resolve = a; reject = b })
42
+ const server = http.createServer((req, res) => {
43
+ const url = new URL(req.url, 'http://127.0.0.1')
44
+ if (url.pathname !== '/callback') { res.writeHead(404).end(); return }
45
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
46
+ res.end(`<!doctype html><meta charset="utf-8"><title>Clipoox CLI</title><body style="font:16px system-ui;padding:40px">${page}</body>`)
47
+ resolve(Object.fromEntries(url.searchParams))
48
+ })
49
+ const ready = new Promise((ok, bad) => { server.once('error', bad); server.listen(0, '127.0.0.1', () => ok(server.address().port)) })
50
+ return { ready, result, close: () => server.close(), fail: (e) => reject(e) }
51
+ }
52
+
53
+ /**
54
+ * Full sign-in. `open(url)` shows the authorize page to the user (their browser); resolves with the tokens to save.
55
+ * `timeoutMs` bounds the wait for the browser.
56
+ */
57
+ export async function login(mcpUrl, { fetch = globalThis.fetch, open, headers = {}, clientName = 'Clipoox CLI', page, timeoutMs = 300_000 } = {}) {
58
+ const { resource, metadata } = await discover(mcpUrl, { fetch, headers })
59
+ const cb = callbackServer({ page })
60
+ try {
61
+ const port = await cb.ready, redirectUri = `http://127.0.0.1:${port}/callback`
62
+ 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')
63
+ if (typeof reg.client_id !== 'string') throw new Error('client registration: no client_id')
64
+ const { verifier, challenge } = pkcePair(), state = b64url(crypto.randomBytes(16))
65
+ const auth = new URL(metadata.authorization_endpoint)
66
+ for (const [k, v] of Object.entries({ response_type: 'code', client_id: reg.client_id, redirect_uri: redirectUri, scope: SCOPE, state, code_challenge: challenge, code_challenge_method: 'S256', resource })) auth.searchParams.set(k, v)
67
+ await open(auth.toString())
68
+ const timer = setTimeout(() => cb.fail(new Error('sign-in timed out')), timeoutMs)
69
+ let q; try { q = await cb.result } finally { clearTimeout(timer) }
70
+ if (q.state !== state) throw new Error('sign-in answer does not match this request (state)')
71
+ if (q.error) throw new Error(`sign-in refused: ${q.error}${q.error_description ? ` (${q.error_description})` : ''}`)
72
+ if (!q.code) throw new Error('sign-in returned no code')
73
+ const tok = await tokenRequest(metadata.token_endpoint, { grant_type: 'authorization_code', code: q.code, redirect_uri: redirectUri, client_id: reg.client_id, code_verifier: verifier, resource }, { fetch, headers })
74
+ return withMeta(tok, { client_id: reg.client_id, token_endpoint: metadata.token_endpoint, resource })
75
+ } finally { cb.close() }
76
+ }
77
+
78
+ async function tokenRequest(endpoint, form, { fetch, headers }) {
79
+ const tok = await json(await fetch(endpoint, { method: 'POST', headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams(form).toString() }), 'token')
80
+ if (typeof tok.access_token !== 'string') throw new Error('token: no access_token')
81
+ return tok
82
+ }
83
+ const withMeta = (tok, meta) => ({ access_token: tok.access_token, refresh_token: tok.refresh_token, expires_at: Number.isFinite(tok.expires_in) ? Date.now() + tok.expires_in * 1000 : undefined, ...meta })
84
+
85
+ /** A fresh access token from the saved refresh token (null when there is none or the server refuses it). */
86
+ export async function refresh(creds, { fetch = globalThis.fetch, headers = {} } = {}) {
87
+ if (!creds?.refresh_token || !creds.token_endpoint) return null
88
+ try {
89
+ const tok = await tokenRequest(creds.token_endpoint, { grant_type: 'refresh_token', refresh_token: creds.refresh_token, client_id: creds.client_id, ...(creds.resource ? { resource: creds.resource } : {}) }, { fetch, headers })
90
+ return withMeta({ refresh_token: creds.refresh_token, ...tok }, { client_id: creds.client_id, token_endpoint: creds.token_endpoint, resource: creds.resource })
91
+ } catch (e) { if (e.status >= 400 && e.status < 500) return null; throw e }
92
+ }
93
+ /** True when the access token is expired or within a minute of it. */
94
+ export const expiring = (creds, now = Date.now()) => Number.isFinite(creds?.expires_at) && creds.expires_at - now < 60_000
package/lib/store.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // Where the CLI keeps its sign-in: one JSON file readable only by the user (0600 in a 0700 folder).
2
+ // macOS / Linux: $XDG_CONFIG_HOME/clipoox or ~/.config/clipoox · Windows: %APPDATA%\clipoox · CLIPOOX_CONFIG_DIR overrides.
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ export function configDir(env = process.env, platform = process.platform) {
8
+ if (env.CLIPOOX_CONFIG_DIR) return env.CLIPOOX_CONFIG_DIR
9
+ if (platform === 'win32' && env.APPDATA) return path.join(env.APPDATA, 'clipoox')
10
+ return path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'clipoox')
11
+ }
12
+ export const credentialsPath = (env) => path.join(configDir(env), 'credentials.json')
13
+
14
+ /** The saved sign-in for `server` (the MCP URL), or null. */
15
+ export function loadCredentials(server, env) {
16
+ try {
17
+ const all = JSON.parse(fs.readFileSync(credentialsPath(env), 'utf8'))
18
+ const c = all?.[server]
19
+ return c && typeof c.access_token === 'string' ? c : null
20
+ } catch { return null }
21
+ }
22
+ export function saveCredentials(server, creds, env) {
23
+ const file = credentialsPath(env)
24
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
25
+ let all = {}
26
+ try { all = JSON.parse(fs.readFileSync(file, 'utf8')) || {} } catch { /* first sign-in */ }
27
+ if (creds) all[server] = creds; else delete all[server]
28
+ const tmp = `${file}.${process.pid}.tmp`
29
+ fs.writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 })
30
+ fs.renameSync(tmp, file)
31
+ try { fs.chmodSync(file, 0o600) } catch { /* Windows: ACLs of the profile folder */ }
32
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "clipoox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Clipoox from the terminal: sign in once, then call any Clipoox MCP tool (Timeline, media, AI generation, 3D Shot, SEO) from scripts.",
5
+ "type": "module",
6
+ "bin": {
7
+ "clipoox": "bin/clipoox.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.17"
17
+ },
18
+ "keywords": [
19
+ "clipoox",
20
+ "mcp",
21
+ "video",
22
+ "cli"
23
+ ],
24
+ "homepage": "https://platform.clipoox.com/connect",
25
+ "license": "MIT"
26
+ }