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 +21 -0
- package/README.md +28 -2
- package/bin/clipoox.mjs +10 -0
- package/lib/cli.mjs +136 -0
- package/lib/mcp.mjs +82 -0
- package/lib/oauth.mjs +94 -0
- package/lib/store.mjs +32 -0
- package/package.json +24 -4
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
|
-
#
|
|
1
|
+
# clipoox
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/bin/clipoox.mjs
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|