clipoox 0.0.0-stage → 0.1.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,29 @@
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 logout
15
+ ```
16
+
17
+ **Arguments** are a JSON object, `@file.json`, or `-` to read stdin. `--json` prints the full result.
18
+
19
+ **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, …}'`.
20
+
21
+ **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.
22
+
23
+ **Exit codes:** 0 ok · 1 the tool reported an error · 2 usage, or a paid call that was not confirmed · 3 not signed in.
24
+
25
+ **Export.** `export_video` renders in the Clipoox editor, so the project must be open on the web or in the desktop app.
26
+
27
+ Messages follow your system language (Vietnamese when `LANG` starts with `vi`).
28
+
29
+ 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,136 @@
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]
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
+
12
+ export const DEFAULT_MCP_URL = 'https://clipoox.com/mcp'
13
+ const M = {
14
+ usage: {
15
+ vi: `Cách dùng:
16
+ clipoox login Đăng nhập Clipoox (mở trình duyệt)
17
+ clipoox logout Đăng xuất trên máy này
18
+ clipoox tools [--json] Liệt kê công cụ (★ = tốn credit)
19
+ 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
+ --yes đồng ý trước với công cụ tốn credit (bắt buộc khi chạy trong script)
21
+ --json in kết quả đầy đủ dạng JSON
22
+ Ví dụ:
23
+ clipoox call list_timelines
24
+ clipoox call get_timeline '{"timeline_id":"…"}' --json`,
25
+ en: `Usage:
26
+ clipoox login Sign in to Clipoox (opens your browser)
27
+ clipoox logout Sign out on this computer
28
+ clipoox tools [--json] List the tools (★ = uses credits)
29
+ clipoox call <tool> [arguments] Call a tool. Arguments: JSON, @file.json, or - (read stdin)
30
+ --yes agree in advance to a tool that uses credits (required in scripts)
31
+ --json print the full result as JSON
32
+ Examples:
33
+ clipoox call list_timelines
34
+ clipoox call get_timeline '{"timeline_id":"…"}' --json`,
35
+ },
36
+ 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
+ signedIn: { vi: 'Đã đăng nhập. Thử: clipoox call get_account_summary', en: 'Signed in. Try: clipoox call get_account_summary' },
39
+ signedOut: { vi: 'Đã đăng xuất trên máy này.', en: 'Signed out on this computer.' },
40
+ 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' },
41
+ 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` },
42
+ 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}' },
43
+ 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] ` },
44
+ 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
+ cancelled: { vi: 'Đã huỷ, không gọi công cụ.', en: 'Cancelled — the tool was not called.' },
46
+ 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)' },
47
+ price: { vi: 'Giá (kiểm tra trước, chưa trừ credit):', en: 'Price (checked first, nothing charged yet):' },
48
+ }
49
+
50
+ export function lang(env = process.env) { return /^vi/i.test(env.LC_ALL || env.LC_MESSAGES || env.LANG || '') ? 'vi' : 'en' }
51
+
52
+ /** argv → { cmd, rest, flags }. Flags: --yes / -y, --json, --no-browser, --help / -h, --version / -v. */
53
+ export function parseArgs(argv) {
54
+ const flags = {}, rest = []
55
+ for (const a of argv) {
56
+ if (a === '--yes' || a === '-y') flags.yes = true
57
+ else if (a === '--json') flags.json = true
58
+ else if (a === '--no-browser') flags.noBrowser = true
59
+ else if (a === '--help' || a === '-h') flags.help = true
60
+ else if (a === '--version' || a === '-v') flags.version = true
61
+ else rest.push(a)
62
+ }
63
+ return { cmd: rest.shift(), rest, flags }
64
+ }
65
+
66
+ /** Tool arguments from the command line: inline JSON, @file, or - (stdin text given as `stdin`). */
67
+ export function readToolArgs(spec, { stdin = '', readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
68
+ if (spec == null || spec === '') return {}
69
+ const text = spec === '-' ? stdin : spec.startsWith('@') ? readFile(spec.slice(1)) : spec
70
+ const v = JSON.parse(text || '{}')
71
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('not an object')
72
+ return v
73
+ }
74
+
75
+ export function openBrowser(url, platform = process.platform) {
76
+ const [cmd, args] = platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]]
77
+ try { const p = spawn(cmd, args, { stdio: 'ignore', detached: true }); p.on('error', () => {}); p.unref() } catch { /* the link is printed anyway */ }
78
+ }
79
+
80
+ /**
81
+ * Run one command. io: { out, err, env, fetch, stdin (async → string), isTTY, ask (async question → answer), open, version }.
82
+ * Returns the exit code.
83
+ */
84
+ export async function main(argv, io = {}) {
85
+ 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`))
86
+ const fetch = io.fetch || globalThis.fetch, server = env.CLIPOOX_MCP_URL || DEFAULT_MCP_URL
87
+ const headers = { 'user-agent': `clipoox-cli/${io.version || '0'} (node ${process.versions.node})` }
88
+ const { cmd, rest, flags } = parseArgs(argv)
89
+ if (flags.version) { out(io.version || '0'); return 0 }
90
+ if (!cmd || flags.help || cmd === 'help') { out(M.usage[L]); return cmd || flags.help ? 0 : 2 }
91
+
92
+ if (cmd === 'login') {
93
+ 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) } })
94
+ saveCredentials(server, creds, env)
95
+ out(M.signedIn[L]); return 0
96
+ }
97
+ 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 }
99
+
100
+ let creds = loadCredentials(server, env)
101
+ if (!creds) { err(M.needLogin[L]); return 3 }
102
+ const renew = async () => { const c = await refresh(creds, { fetch, headers }); if (!c) return false; creds = c; saveCredentials(server, c, env); return true }
103
+ if (expiring(creds)) await renew()
104
+ const client = mcpClient(server, { fetch, headers, token: () => creds.access_token, onUnauthorized: renew, clientInfo: { name: 'clipoox-cli', version: io.version || '0' } })
105
+ try {
106
+ const tools = await client.listTools()
107
+ if (cmd === 'tools') {
108
+ 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))
109
+ else { for (const t of tools) out(`${spendsCredits(t) ? '★' : ' '} ${t.name}${t.title ? ` — ${t.title}` : ''}`); out(`\n${M.paidMark[L]}`) }
110
+ return 0
111
+ }
112
+ const name = rest[0], tool = tools.find((t) => t.name === name)
113
+ if (!tool) { err(M.noTool[L](name ?? '')); return 2 }
114
+ let args
115
+ try { args = readToolArgs(rest[1], { stdin: rest[1] === '-' ? await (io.stdin || readStdin)() : '' }) } catch { err(M.badArgs[L]); return 2 }
116
+ if (willSpend(tool, args) && !flags.yes) {
117
+ const tty = io.isTTY ?? (process.stdin.isTTY && process.stderr.isTTY)
118
+ if (!tty) { err(M.paidNeedYes[L](name)); return 2 }
119
+ // the price first when the tool can say it (dry_run): nothing is charged for it
120
+ 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)}`) }
121
+ const a = await (io.ask || ask)(M.paidAsk[L](name))
122
+ if (!/^(y|yes|c|có|co)$/i.test(String(a).trim())) { err(M.cancelled[L]); return 2 }
123
+ }
124
+ const r = await client.callTool(name, args)
125
+ if (flags.json) out(JSON.stringify(r, null, 2))
126
+ else { const text = resultText(r); out(text || JSON.stringify(r.structuredContent ?? r, null, 2)) }
127
+ return r?.isError ? 1 : 0
128
+ } catch (e) {
129
+ if (e instanceof AuthRequired) { err(M.needLogin[L]); return 3 }
130
+ throw e
131
+ }
132
+ }
133
+
134
+ async function readStdin() { let s = ''; for await (const c of process.stdin) s += c; return s }
135
+ async function ask(q) { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); try { return await rl.question(q) } finally { rl.close() } }
136
+ export { credentialsPath }
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.1.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
+ }