botverse-mcp 1.0.4 → 1.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.
Files changed (3) hide show
  1. package/README.md +29 -4
  2. package/cli.js +282 -0
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # botverse-mcp
2
2
 
3
- MCP server for [Botverse](https://botverse.cloud) — video transcoding and document conversion for AI agents.
3
+ MCP server **and command-line tool** for [Botverse](https://botverse.cloud) — video transcoding and document conversion for AI agents and the humans who configure them.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/botverse-mcp)](https://www.npmjs.com/package/botverse-mcp)
6
6
 
@@ -9,7 +9,7 @@ MCP server for [Botverse](https://botverse.cloud) — video transcoding and docu
9
9
  - **Video transcoding** — MP4 (H.264), WebM (VP9), ProRes 422, GIF, MP3 extraction · $0.25/job
10
10
  - **Document conversion** — Markdown ↔ DOCX ↔ PDF ↔ HTML ↔ XLSX · $0.05/file
11
11
 
12
- Three tool calls. No AWS. No FFmpeg. No infrastructure.
12
+ Two ways to use it: an **MCP server** for your AI agents, and a **`botverse` CLI** for the shell — evaluation, CI/CD, cron, scripts, and local coding agents. No AWS. No FFmpeg. No infrastructure.
13
13
 
14
14
  ## Setup
15
15
 
@@ -51,16 +51,41 @@ Or with a connector URL (recommended for claude.ai):
51
51
  }
52
52
  ```
53
53
 
54
- ## Tools
54
+ ## Command line (`botverse`)
55
+
56
+ The same package ships a `botverse` CLI for the shell — it reads files from disk and
57
+ streams them straight to the API (no content goes through an LLM), so it's the fast
58
+ path for evaluation, automation, and local coding agents.
59
+
60
+ ```bash
61
+ export BOTVERSE_API_KEY=bv_live_… # or BOTVERSE_CONNECTOR_URL=…?token=bv_sess_…
62
+
63
+ npx botverse convert report.md --to pdf
64
+ npx botverse convert *.md --to docx,pdf -o ./out
65
+ npx botverse transcode clip.mov --to mp4 -o ./out
66
+ npx botverse transcribe call.mp4 --to docx --attendees "Sarah Chen,Mike Torres"
67
+ npx botverse balance
68
+ ```
69
+
70
+ Each job uploads → polls → downloads the finished file to `-o` (default: current dir).
71
+ Globs and multiple `--to` formats run as a batch.
72
+
73
+ > **Sandbox note:** the CLI needs outbound network to `botverse.cloud` and S3, so it does
74
+ > **not** run inside sandboxed agent environments (claude.ai / Claude Desktop), whose
75
+ > egress is allowlisted. There, use the MCP tools (`convert_content` / `get_output_content`).
76
+
77
+ ## Tools (MCP)
55
78
 
56
79
  | Tool | Description |
57
80
  |---|---|
58
81
  | `transcode_from_url` | Transcode video from a public URL |
59
82
  | `transcode_video` | Transcode an uploaded video file |
60
- | `convert_content` | Convert document content inline |
83
+ | `convert_content` | Convert document content inline (up to 4 MB; sandbox-safe) |
61
84
  | `convert_from_url` | Convert a document from a public URL |
85
+ | `convert_file` | Convert an uploaded document |
62
86
  | `get_job_status` | Poll a job until complete |
63
87
  | `get_download_url` | Get the signed download URL |
88
+ | `get_output_content` | Get finished output bytes inline (sandbox-safe download) |
64
89
  | `get_wallet_balance` | Check wallet balance |
65
90
 
66
91
  ## Pricing
package/cli.js ADDED
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * botverse — command-line interface for Botverse.
4
+ *
5
+ * For humans and shell-capable automation (CI, cron, local coding agents) — it
6
+ * reads files from disk and streams them to the API directly, so it never serializes
7
+ * content through an LLM the way the in-chat MCP route must. Talks to the same
8
+ * hosted endpoint (botverse.cloud/mcp) with a bv_live_ key.
9
+ *
10
+ * export BOTVERSE_API_KEY=bv_live_xxx
11
+ * botverse convert report.md --to pdf
12
+ * botverse convert *.md --to docx,pdf -o ./out
13
+ * botverse transcode clip.mov --to mp4 -o ./out
14
+ * botverse transcribe call.mp4 --to docx --attendees "Sarah Chen,Mike Torres"
15
+ * botverse balance
16
+ *
17
+ * NOTE: this needs outbound network to botverse.cloud and S3. It does NOT work inside
18
+ * sandboxed agent environments (claude.ai / Claude Desktop) whose egress is allowlisted —
19
+ * there, use the MCP tools (convert_content / get_output_content) instead.
20
+ */
21
+
22
+ "use strict";
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+ const https = require("https");
26
+ const { URL } = require("url");
27
+
28
+ const VERSION = "1.1.0";
29
+ const BASE_URL = process.env.BOTVERSE_MCP_URL || "https://botverse.cloud/mcp";
30
+
31
+ // ── tiny ANSI helpers ─────────────────────────────────────────────────────────
32
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
33
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
34
+ const dim = (s) => c("2", s), bold = (s) => c("1", s), green = (s) => c("32", s), red = (s) => c("31", s), cyan = (s) => c("36", s);
35
+ const log = (...a) => process.stderr.write(a.join(" ") + "\n");
36
+
37
+ function die(msg) { log(red("error: ") + msg); process.exit(1); }
38
+
39
+ // ── format maps ───────────────────────────────────────────────────────────────
40
+ const CONTENT_TYPES = {
41
+ md: "text/markdown", markdown: "text/markdown", html: "text/html", htm: "text/html",
42
+ rst: "text/x-rst", txt: "text/plain", doc: "application/msword",
43
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
44
+ mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", avi: "video/x-msvideo",
45
+ mkv: "video/x-matroska", m4v: "video/x-m4v", wav: "audio/wav", m4a: "audio/mp4",
46
+ mp3: "audio/mpeg", flac: "audio/flac", wma: "audio/x-ms-wma",
47
+ };
48
+ const TEXT_INPUTS = new Set(["md", "markdown", "html", "htm", "rst", "txt"]);
49
+ const EXT_OF = (f) => (path.extname(f).slice(1) || "").toLowerCase();
50
+ const MAX_INLINE = 4 * 1024 * 1024; // proxy inline ceiling
51
+
52
+ // ── HTTP / JSON-RPC ───────────────────────────────────────────────────────────
53
+ // Auth: either a bv_live_ API key (Authorization: Bearer) or a full connector URL
54
+ // containing ?token=bv_sess_… (BOTVERSE_CONNECTOR_URL). Returns the endpoint + headers.
55
+ function authTarget(contentLength) {
56
+ const connector = argv.flags["connector-url"] || process.env.BOTVERSE_CONNECTOR_URL;
57
+ const key = argv.flags["api-key"] || process.env.BOTVERSE_API_KEY;
58
+ if (!connector && !key) {
59
+ die("no credentials. Set BOTVERSE_API_KEY=bv_live_… (or BOTVERSE_CONNECTOR_URL=…?token=bv_sess_…). Get a key at https://botverse.cloud/dashboard/api-keys");
60
+ }
61
+ const headers = { "Content-Type": "application/json", "Content-Length": contentLength, "User-Agent": "botverse-cli/" + VERSION };
62
+ if (!connector && key) headers["Authorization"] = `Bearer ${key}`;
63
+ return { url: connector || BASE_URL, headers };
64
+ }
65
+
66
+ function request(urlStr, { method = "POST", headers = {}, body }) {
67
+ return new Promise((resolve, reject) => {
68
+ const u = new URL(urlStr);
69
+ const req = https.request(
70
+ { hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, headers },
71
+ (res) => {
72
+ const chunks = [];
73
+ res.on("data", (d) => chunks.push(d));
74
+ res.on("end", () => resolve({ status: res.statusCode, buffer: Buffer.concat(chunks) }));
75
+ }
76
+ );
77
+ req.on("error", reject);
78
+ req.setTimeout(180000, () => req.destroy(new Error("request timed out")));
79
+ if (body) req.write(body);
80
+ req.end();
81
+ });
82
+ }
83
+
84
+ let RPC_ID = 0;
85
+ async function mcp(tool, args) {
86
+ const body = JSON.stringify({ jsonrpc: "2.0", id: ++RPC_ID, method: "tools/call", params: { name: tool, arguments: args } });
87
+ const { url, headers } = authTarget(Buffer.byteLength(body));
88
+ const { status, buffer } = await request(url, { headers, body });
89
+ let json;
90
+ try { json = JSON.parse(buffer.toString()); }
91
+ catch { throw new Error(`HTTP ${status}: ${buffer.toString().slice(0, 200)}`); }
92
+ if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
93
+ const text = json.result?.structuredContent ?? json.result?.content?.[0]?.text;
94
+ if (text == null) throw new Error("unexpected response shape");
95
+ return typeof text === "string" ? JSON.parse(text) : text;
96
+ }
97
+
98
+ // ── S3 multipart upload (presigned POST) ──────────────────────────────────────
99
+ async function uploadFile(filePath) {
100
+ const filename = path.basename(filePath);
101
+ const ext = EXT_OF(filename);
102
+ const ct = CONTENT_TYPES[ext] || "application/octet-stream";
103
+ const up = await mcp("get_upload_url", { filename, content_type: ct });
104
+ const fields = up.upload_fields || {};
105
+ const fileBuf = fs.readFileSync(filePath);
106
+
107
+ const boundary = "----botverse" + Math.random().toString(16).slice(2);
108
+ const pre = [];
109
+ for (const [k, v] of Object.entries(fields)) {
110
+ pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`);
111
+ }
112
+ pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${fields["Content-Type"] || ct}\r\n\r\n`);
113
+ const body = Buffer.concat([Buffer.from(pre.join("")), fileBuf, Buffer.from(`\r\n--${boundary}--\r\n`)]);
114
+
115
+ const { status, buffer } = await request(up.upload_url, {
116
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": body.length },
117
+ body,
118
+ });
119
+ if (status !== 204 && status !== 201 && status !== 200) {
120
+ throw new Error(`S3 upload failed (HTTP ${status}): ${buffer.toString().slice(0, 200)}`);
121
+ }
122
+ return up.object_key;
123
+ }
124
+
125
+ // ── job polling + download ────────────────────────────────────────────────────
126
+ async function poll(jobId) {
127
+ const start = Date.now();
128
+ for (;;) {
129
+ const s = await mcp("get_job_status", { job_id: jobId });
130
+ if (s.status === "complete") return s;
131
+ if (s.status === "failed") throw new Error(s.error || "job failed");
132
+ if (Date.now() - start > 30 * 60 * 1000) throw new Error("timed out waiting for job");
133
+ if (s.stage_message) process.stderr.write("\r" + dim(" " + s.stage_message.padEnd(48)));
134
+ await new Promise((r) => setTimeout(r, 3000));
135
+ }
136
+ }
137
+
138
+ async function downloadOutput(jobId, outPath) {
139
+ const dl = await mcp("get_download_url", { job_id: jobId });
140
+ const { status, buffer } = await request(dl.download_url, { method: "GET" });
141
+ if (status !== 200) throw new Error(`download failed (HTTP ${status})`);
142
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
143
+ fs.writeFileSync(outPath, buffer);
144
+ return buffer.length;
145
+ }
146
+
147
+ // ── submit helpers ────────────────────────────────────────────────────────────
148
+ async function submitConvert(filePath, outFmt) {
149
+ const ext = EXT_OF(filePath);
150
+ const size = fs.statSync(filePath).size;
151
+ // Small text files go inline (no upload round-trip); large or binary go via S3.
152
+ if (TEXT_INPUTS.has(ext) && size <= MAX_INLINE) {
153
+ const r = await mcp("convert_content", {
154
+ content: fs.readFileSync(filePath, "utf8"),
155
+ input_format: ext === "markdown" ? "md" : ext === "htm" ? "html" : ext,
156
+ output_format: outFmt,
157
+ });
158
+ return r.job_id;
159
+ }
160
+ const key = await uploadFile(filePath);
161
+ const r = await mcp("convert_file", { object_key: key, output_format: outFmt });
162
+ return r.job_id;
163
+ }
164
+
165
+ async function submitTranscode(filePath, outFmt, opts) {
166
+ const key = await uploadFile(filePath);
167
+ const r = await mcp("transcode_video", { object_key: key, output_format: outFmt, ...(opts ? { options: opts } : {}) });
168
+ return r.job_id;
169
+ }
170
+
171
+ async function submitTranscribe(filePath, outFmt, opts) {
172
+ const key = await uploadFile(filePath);
173
+ const r = await mcp("transcribe_media", { object_key: key, output_format: outFmt, ...(opts ? { options: opts } : {}) });
174
+ return r.job_id;
175
+ }
176
+
177
+ // ── commands ──────────────────────────────────────────────────────────────────
178
+ async function runBatch(files, formats, submit, outDir) {
179
+ if (!files.length) die("no input files");
180
+ let failures = 0;
181
+ for (const file of files) {
182
+ if (!fs.existsSync(file)) { log(red("✗ ") + file + dim(" — not found")); failures++; continue; }
183
+ for (const fmt of formats) {
184
+ const t0 = Date.now();
185
+ const base = path.basename(file, path.extname(file));
186
+ const outPath = path.join(outDir, `${base}.${fmt}`);
187
+ process.stderr.write(dim(`· ${path.basename(file)} → ${fmt} …`));
188
+ try {
189
+ const jobId = await submit(file, fmt);
190
+ await poll(jobId);
191
+ const bytes = await downloadOutput(jobId, outPath);
192
+ process.stderr.write("\r" + green("✓ ") + outPath + dim(` (${(bytes / 1024).toFixed(0)} KB, ${((Date.now() - t0) / 1000).toFixed(1)}s)`).padEnd(20) + "\n");
193
+ } catch (e) {
194
+ process.stderr.write("\r" + red("✗ ") + `${path.basename(file)} → ${fmt}` + dim(" " + (e.message || e)) + "\n");
195
+ failures++;
196
+ }
197
+ }
198
+ }
199
+ if (failures) process.exitCode = 1;
200
+ }
201
+
202
+ function parseFormats(flag, allowed, label) {
203
+ if (!flag) die(`--to is required (${label}). e.g. --to ${allowed[0]}`);
204
+ const fmts = String(flag).split(",").map((s) => s.trim()).filter(Boolean);
205
+ for (const f of fmts) if (!allowed.includes(f)) die(`unsupported --to "${f}". Allowed: ${allowed.join(", ")}`);
206
+ return fmts;
207
+ }
208
+
209
+ const COMMANDS = {
210
+ async convert() {
211
+ const fmts = parseFormats(argv.flags.to, ["docx", "pdf", "html", "txt", "md", "rst", "xlsx"], "convert");
212
+ await runBatch(argv.files, fmts, (f, fmt) => submitConvert(f, fmt), argv.flags.o || argv.flags.out || ".");
213
+ },
214
+ async transcode() {
215
+ const fmts = parseFormats(argv.flags.to, ["mp4", "webm", "mov_prores", "mp3", "gif"], "transcode");
216
+ const opts = {};
217
+ if (argv.flags.resolution) opts.height = ({ "4k": 2160, "1080p": 1080, "720p": 720, "480p": 480, "360p": 360 }[argv.flags.resolution]) || undefined;
218
+ await runBatch(argv.files, fmts, (f, fmt) => submitTranscode(f, fmt, Object.keys(opts).length ? opts : null), argv.flags.o || argv.flags.out || ".");
219
+ },
220
+ async transcribe() {
221
+ const fmts = parseFormats(argv.flags.to, ["txt", "json", "srt", "vtt", "docx", "pdf"], "transcribe");
222
+ const opts = {};
223
+ if (argv.flags.attendees) opts.attendees = String(argv.flags.attendees).split(",").map((n) => ({ name: n.trim() })).filter((a) => a.name);
224
+ if (argv.flags.language) opts.language = argv.flags.language;
225
+ await runBatch(argv.files, fmts, (f, fmt) => submitTranscribe(f, fmt, Object.keys(opts).length ? opts : null), argv.flags.o || argv.flags.out || ".");
226
+ },
227
+ async balance() {
228
+ const r = await mcp("get_wallet_balance", {});
229
+ log(bold("Wallet: ") + green(`$${Number(r.balance_usd).toFixed(2)}`) + (r.auto_refill_enabled ? dim(" (auto-refill on)") : ""));
230
+ },
231
+ };
232
+
233
+ function usage() {
234
+ log(`${bold("botverse")} ${dim("v" + VERSION)} — Botverse from the command line
235
+
236
+ ${bold("Usage:")}
237
+ botverse convert <files…> --to <fmt[,fmt]> [-o dir]
238
+ botverse transcode <files…> --to <fmt> [--resolution 1080p] [-o dir]
239
+ botverse transcribe <files…> --to <fmt> [--attendees "A,B"] [--language en-US] [-o dir]
240
+ botverse balance
241
+
242
+ ${bold("Auth:")} export BOTVERSE_API_KEY=bv_live_… (or --api-key)
243
+
244
+ ${bold("Examples:")}
245
+ ${cyan("botverse convert report.md --to pdf")}
246
+ ${cyan("botverse convert *.md --to docx,pdf -o ./out")}
247
+ ${cyan("botverse transcode clip.mov --to mp4 -o ./out")}
248
+ ${cyan("botverse transcribe call.mp4 --to docx --attendees \"Sarah Chen,Mike Torres\"")}
249
+
250
+ Docs: https://botverse.cloud/docs/cli`);
251
+ }
252
+
253
+ // ── arg parsing ───────────────────────────────────────────────────────────────
254
+ function parseArgs(args) {
255
+ const flags = {}; const files = [];
256
+ for (let i = 0; i < args.length; i++) {
257
+ const a = args[i];
258
+ if (a.startsWith("--")) {
259
+ const key = a.slice(2);
260
+ const next = args[i + 1];
261
+ if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
262
+ } else if (a.startsWith("-")) {
263
+ const key = a.slice(1);
264
+ const next = args[i + 1];
265
+ if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
266
+ } else files.push(a);
267
+ }
268
+ return { flags, files };
269
+ }
270
+
271
+ const rawArgs = process.argv.slice(2);
272
+ const command = rawArgs[0];
273
+ const argv = parseArgs(rawArgs.slice(1));
274
+
275
+ (async () => {
276
+ if (!command || command === "help" || argv.flags.help || argv.flags.h) return usage();
277
+ if (command === "version" || argv.flags.version || argv.flags.v) return log("botverse " + VERSION);
278
+ const fn = COMMANDS[command];
279
+ if (!fn) { log(red(`unknown command: ${command}`)); usage(); process.exit(1); }
280
+ try { await fn(); }
281
+ catch (e) { die(e.message || String(e)); }
282
+ })();
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "botverse-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "mcpName": "io.github.MkTurner74/botverse",
5
- "description": "MCP server for Botverse — video transcoding and document conversion for AI agents. $0.25/transcode · $0.05/convert · No AWS required.",
5
+ "description": "Botverse for AI agents and the command line — video transcoding and document conversion. MCP server + `botverse` CLI. $0.25/transcode · $0.05/convert · No AWS required.",
6
6
  "main": "index.js",
7
7
  "bin": {
8
- "botverse-mcp": "index.js"
8
+ "botverse-mcp": "index.js",
9
+ "botverse": "cli.js"
9
10
  },
10
11
  "scripts": {
11
12
  "start": "node index.js"