clipoox 0.1.0 → 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/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 '{"timeline_id":"…"}' --json`,
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,9 +38,17 @@ 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 '{"timeline_id":"…"}' --json`,
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
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.' },
@@ -44,16 +61,29 @@ Examples:
44
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).` },
45
62
  cancelled: { vi: 'Đã huỷ, không gọi công cụ.', en: 'Cancelled — the tool was not called.' },
46
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.` },
47
71
  price: { vi: 'Giá (kiểm tra trước, chưa trừ credit):', en: 'Price (checked first, nothing charged yet):' },
48
72
  }
49
73
 
50
74
  export function lang(env = process.env) { return /^vi/i.test(env.LC_ALL || env.LC_MESSAGES || env.LANG || '') ? 'vi' : 'en' }
51
75
 
52
- /** argv → { cmd, rest, flags }. Flags: --yes / -y, --json, --no-browser, --help / -h, --version / -v. */
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. */
53
79
  export function parseArgs(argv) {
54
80
  const flags = {}, rest = []
55
- for (const a of argv) {
56
- if (a === '--yes' || a === '-y') flags.yes = true
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
57
87
  else if (a === '--json') flags.json = true
58
88
  else if (a === '--no-browser') flags.noBrowser = true
59
89
  else if (a === '--help' || a === '-h') flags.help = true
@@ -95,7 +125,12 @@ export async function main(argv, io = {}) {
95
125
  out(M.signedIn[L]); return 0
96
126
  }
97
127
  if (cmd === 'logout') { saveCredentials(server, null, env); out(M.signedOut[L]); return 0 }
98
- if (cmd !== 'tools' && cmd !== 'call') { err(M.usage[L]); return 2 }
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 }
99
134
 
100
135
  let creds = loadCredentials(server, env)
101
136
  if (!creds) { err(M.needLogin[L]); return 3 }
@@ -103,6 +138,21 @@ export async function main(argv, io = {}) {
103
138
  if (expiring(creds)) await renew()
104
139
  const client = mcpClient(server, { fetch, headers, token: () => creds.access_token, onUnauthorized: renew, clientInfo: { name: 'clipoox-cli', version: io.version || '0' } })
105
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
+ }
106
156
  const tools = await client.listTools()
107
157
  if (cmd === 'tools') {
108
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))
@@ -127,6 +177,7 @@ export async function main(argv, io = {}) {
127
177
  return r?.isError ? 1 : 0
128
178
  } catch (e) {
129
179
  if (e instanceof AuthRequired) { err(M.needLogin[L]); return 3 }
180
+ if (e.tool) { err(e.message); return 1 }
130
181
  throw e
131
182
  }
132
183
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipoox",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "bin": {