@canvora/cli 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/bin/canvora.js +333 -0
  4. package/package.json +13 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Canvora
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 ADDED
@@ -0,0 +1,102 @@
1
+ # Canvora – visuals for AI agents
2
+
3
+ Turn any idea, URL, doc, or PDF into on-brand visuals: **100+ formats** (posts, carousels, decks, ads, documents), generated natively in **150+ languages**. Brand kits keep colors, fonts, logo, and tone consistent per client.
4
+
5
+ This repo is the agent-facing toolkit for [Canvora](https://canvora.ai/for-agents):
6
+
7
+ - **Remote MCP server** — for Claude, ChatGPT, Cursor, and any MCP client. Nothing to install.
8
+ - **`canvora` CLI** — for OpenClaw, Hermes, cron jobs, CI, and anything that runs shell commands. Zero dependencies.
9
+ - **OpenClaw skill** — in [`skills/canvora/`](skills/canvora/SKILL.md).
10
+
11
+ > The Canvora service itself is hosted; this repo contains client tooling and docs only.
12
+
13
+ ## Option 1 — MCP (Claude, ChatGPT, any MCP client)
14
+
15
+ One remote server, OAuth sign-in, 16 tools your agent discovers on connect:
16
+
17
+ | | |
18
+ |---|---|
19
+ | **Server URL** | `https://api.canvora.ai/mcp` (streamable HTTP) |
20
+ | **Claude** | Settings → Connectors → Add custom connector → OAuth Client ID `claude_mcp` |
21
+ | **ChatGPT** | Settings → Apps → Developer mode → Create app → OAuth Client ID `chatgpt_mcp` |
22
+ | **Registry** | `ai.canvora/canvora` on the [official MCP Registry](https://registry.modelcontextprotocol.io) |
23
+
24
+ Step-by-step: [canvora.ai/help/integrations/mcp-setup](https://canvora.ai/help/integrations/mcp-setup)
25
+
26
+ ## Option 2 — CLI (OpenClaw, Hermes, shell-native agents)
27
+
28
+ ```bash
29
+ npm install -g @canvora/cli
30
+
31
+ # or install the agent skill (Claude Code, Cursor, Codex, OpenCode and 60+ agents):
32
+ npx skills add canvora/canvora-mcp
33
+
34
+ export CANVORA_API_KEY=vd_... # create at canvora.ai → Integrations → API Keys
35
+
36
+ # one atomic command: generates, polls, exits with the output URLs
37
+ canvora generate \
38
+ --idea "5 tips for remote work" \
39
+ --format linkedin_carousel --slides linkedin_carousel=5 \
40
+ --wait --json
41
+ ```
42
+
43
+ ### Commands
44
+
45
+ | Command | What it does |
46
+ |---|---|
47
+ | `canvora generate` | Create visuals from an `--idea` (short concept, Canvora develops it), `--input` (your full content text), `--url`, or `--file-url` (PDF). `--wait` polls to completion and prints output URLs. |
48
+ | `canvora status <id>` | Check a generation and list its output URLs |
49
+ | `canvora download <id> --dir out/` | Save all output files locally |
50
+ | `canvora formats` | All 100+ format IDs with dimensions |
51
+ | `canvora brands` | Your brand kits (pass one via `--brand <uuid>`) |
52
+ | `canvora credits` | Credit balance (10 credits per visual, 15 per carousel/deck slide) |
53
+
54
+ Every command takes `--json` for machine-readable stdout; progress and errors go to stderr, so piping stays clean. Exit codes: `0` success, `1` failure, `2` usage error.
55
+
56
+ ### Generate flags
57
+
58
+ ```
59
+ --idea "concept" short concept - Canvora develops it into content
60
+ --input "text" your full content text, used as the source material
61
+ --url <url> extract content from a webpage
62
+ --file-url <pdf-url> extract content from a PDF
63
+ --format <id>[,<id>...] output formats (see: canvora formats)
64
+ --slides <format>=<n> slide count for carousel/deck formats (1-10)
65
+ --brand <uuid> apply a brand kit
66
+ --style <name> modern | minimal | bold | elegant | playful | corporate | creative | dark
67
+ --resolution 2K|4K 4K on paid plans
68
+ --wait poll until done (--timeout 600, --poll 5 seconds)
69
+ --json machine-readable output
70
+ ```
71
+
72
+ ## Option 3 — Raw REST
73
+
74
+ ```bash
75
+ curl -X POST https://api.canvora.ai/api/generations \
76
+ -H "X-API-Key: vd_YOUR_KEY" \
77
+ -H "Content-Type: application/json" \
78
+ -d '{
79
+ "inputType": "text",
80
+ "inputContent": "5 tips for remote work",
81
+ "outputFormats": ["linkedin_carousel"],
82
+ "carouselCounts": {"linkedin_carousel": 5}
83
+ }'
84
+ ```
85
+
86
+ Full API guide: [canvora.ai/help/integrations/api-keys](https://canvora.ai/help/integrations/api-keys)
87
+
88
+ ## Built to be called by software
89
+
90
+ - **Predictable costs** — flat credit pricing (10/visual, 15/slide); `canvora credits` before big jobs; failed generations auto-refund.
91
+ - **Human in the loop** — everything an agent creates lands in the Canvora dashboard for review, editing, and export.
92
+ - **Scoped, revocable keys** — hashed at rest, per-key usage tracking, one-click revoke.
93
+ - **Outputs on a CDN** — stable URLs your agent can post, embed, or hand to a scheduler.
94
+ - **Works on every plan** — API and MCP access included on Free (free credits, no card).
95
+
96
+ ## Links
97
+
98
+ - [canvora.ai/for-agents](https://canvora.ai/for-agents) — the agent overview
99
+ - [MCP setup guide](https://canvora.ai/help/integrations/mcp-setup) · [API guide](https://canvora.ai/help/integrations/api-keys)
100
+ - [Pricing](https://canvora.ai/pricing)
101
+
102
+ MIT licensed (CLI and docs in this repo).
package/bin/canvora.js ADDED
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * canvora — CLI for the Canvora API (https://canvora.ai)
4
+ *
5
+ * Turn any idea, URL, doc, or PDF into on-brand visuals: 100+ formats,
6
+ * native in 150+ languages. Built for agents and automation: every command
7
+ * supports --json (machine-readable stdout; progress goes to stderr).
8
+ *
9
+ * Auth: CANVORA_API_KEY (vd_... API key from canvora.ai -> Integrations -> API Keys,
10
+ * or an OAuth access token)
11
+ * Server: CANVORA_API_URL (default https://api.canvora.ai)
12
+ *
13
+ * Zero runtime dependencies. Node 18+.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // Agents pipe output constantly (| head, | jq). A closed pipe must be a clean
19
+ // exit, not an EPIPE crash.
20
+ process.stdout.on('error', (e) => { if (e.code === 'EPIPE') process.exit(0); throw e; });
21
+ process.stderr.on('error', (e) => { if (e.code === 'EPIPE') process.exit(0); throw e; });
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const VERSION = '1.0.0';
26
+ const API_URL = (process.env.CANVORA_API_URL || 'https://api.canvora.ai').replace(/\/+$/, '');
27
+ const API_KEY = process.env.CANVORA_API_KEY || '';
28
+ const REQUEST_TIMEOUT_MS = 60000;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // arg parsing (tiny, no deps): flags may be --k v, --k=v, or boolean
32
+ // ---------------------------------------------------------------------------
33
+ function parseArgs(argv) {
34
+ const args = { _: [], flags: {} };
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a.startsWith('--')) {
38
+ const eq = a.indexOf('=');
39
+ if (eq !== -1) {
40
+ addFlag(args.flags, a.slice(2, eq), a.slice(eq + 1));
41
+ } else {
42
+ const key = a.slice(2);
43
+ const next = argv[i + 1];
44
+ if (next !== undefined && !next.startsWith('--')) {
45
+ addFlag(args.flags, key, next);
46
+ i++;
47
+ } else {
48
+ addFlag(args.flags, key, true);
49
+ }
50
+ }
51
+ } else {
52
+ args._.push(a);
53
+ }
54
+ }
55
+ return args;
56
+ }
57
+ function addFlag(flags, key, value) {
58
+ if (flags[key] === undefined) flags[key] = value;
59
+ else if (Array.isArray(flags[key])) flags[key].push(value);
60
+ else flags[key] = [flags[key], value];
61
+ }
62
+ const asArray = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // output helpers: stdout = result, stderr = progress/errors
66
+ // ---------------------------------------------------------------------------
67
+ const say = (msg) => process.stderr.write(msg + '\n');
68
+ const emit = (data, json, human) => {
69
+ if (json) process.stdout.write(JSON.stringify(data, null, 2) + '\n');
70
+ else human(data);
71
+ };
72
+ function fail(message, code = 1) {
73
+ say(`error: ${message}`);
74
+ process.exit(code);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // API client
79
+ // ---------------------------------------------------------------------------
80
+ function authHeaders() {
81
+ if (!API_KEY) return {};
82
+ // vd_ keys go in X-API-Key; anything else (OAuth token) as Bearer
83
+ return API_KEY.startsWith('vd_')
84
+ ? { 'X-API-Key': API_KEY }
85
+ : { Authorization: `Bearer ${API_KEY}` };
86
+ }
87
+
88
+ async function api(pathname, { method = 'GET', body } = {}) {
89
+ const controller = new AbortController();
90
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
91
+ let res;
92
+ try {
93
+ res = await fetch(`${API_URL}${pathname}`, {
94
+ method,
95
+ signal: controller.signal,
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ 'User-Agent': `canvora-cli/${VERSION}`,
99
+ ...authHeaders()
100
+ },
101
+ body: body ? JSON.stringify(body) : undefined
102
+ });
103
+ } catch (e) {
104
+ clearTimeout(timer);
105
+ if (e.name === 'AbortError') throw new Error(`request timed out after ${REQUEST_TIMEOUT_MS / 1000}s: ${method} ${pathname}`);
106
+ throw new Error(`network error: ${e.message}`);
107
+ }
108
+ clearTimeout(timer);
109
+
110
+ let data = null;
111
+ const text = await res.text();
112
+ try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
113
+
114
+ if (!res.ok) {
115
+ const apiMsg = data && (data.message || data.error) ? `${data.error ? data.error + ': ' : ''}${data.message || ''}`.trim() : text.slice(0, 200);
116
+ if (res.status === 401) throw new Error('unauthorized. Set CANVORA_API_KEY (create one at canvora.ai -> Integrations -> API Keys).');
117
+ if (res.status === 402) throw new Error(`insufficient credits. ${apiMsg}`);
118
+ throw new Error(`API ${res.status}: ${apiMsg || 'request failed'}`);
119
+ }
120
+ return data;
121
+ }
122
+
123
+ function requireKey() {
124
+ if (!API_KEY) fail('CANVORA_API_KEY is not set. Create a key at canvora.ai -> Integrations -> API Keys.', 2);
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // commands
129
+ // ---------------------------------------------------------------------------
130
+ async function cmdFormats(flags) {
131
+ const data = await api('/api/formats');
132
+ emit(data, flags.json, (d) => {
133
+ for (const f of d.formats) {
134
+ process.stdout.write(`${f.id.padEnd(34)} ${String(f.dimensions).padEnd(12)} ${f.category}${f.is_carousel ? ' (carousel)' : ''}\n`);
135
+ }
136
+ process.stdout.write(`\n${d.count} formats. Carousel formats accept per-format slide counts via --slides.\n`);
137
+ });
138
+ }
139
+
140
+ async function cmdCredits(flags) {
141
+ requireKey();
142
+ const d = await api('/api/credits/balance');
143
+ emit(d, flags.json, () => {
144
+ process.stdout.write(`balance: ${d.balance}\n`);
145
+ if (d.packBalance) process.stdout.write(` pack: ${d.packBalance}${d.nextPackExpiry ? ` (expires ${d.nextPackExpiry.slice(0, 10)})` : ''}\n`);
146
+ process.stdout.write(` monthly: ${d.monthlyBalance ?? 0}\n bonus: ${d.topupBalance ?? 0}\n`);
147
+ });
148
+ }
149
+
150
+ async function cmdBrands(flags) {
151
+ requireKey();
152
+ const d = await api('/api/brands');
153
+ emit(d, flags.json, () => {
154
+ for (const b of d.brands || []) {
155
+ process.stdout.write(`${b.id} ${b.name || '(unnamed)'}${b.isDefault || b.is_default ? ' [default]' : ''}\n`);
156
+ }
157
+ if (!(d.brands || []).length) process.stdout.write('no brand kits yet. Create one at canvora.ai/brands\n');
158
+ });
159
+ }
160
+
161
+ function parseSlides(flags) {
162
+ // --slides linkedin_carousel=7 (repeatable)
163
+ const counts = {};
164
+ for (const s of asArray(flags.slides)) {
165
+ const [fmt, n] = String(s).split('=');
166
+ const num = parseInt(n, 10);
167
+ if (!fmt || !Number.isInteger(num) || num < 1 || num > 10) fail(`invalid --slides "${s}" (expected format_id=1..10)`, 2);
168
+ counts[fmt] = num;
169
+ }
170
+ return counts;
171
+ }
172
+
173
+ async function cmdGenerate(flags) {
174
+ requireKey();
175
+ const formats = asArray(flags.format).flatMap((f) => String(f).split(',')).map((s) => s.trim()).filter(Boolean);
176
+ if (!formats.length) fail('at least one --format is required (see: canvora formats)', 2);
177
+
178
+ const body = { outputFormats: formats };
179
+ if (flags.url) { body.inputType = 'url'; body.inputUrl = String(flags.url); }
180
+ else if (flags['file-url']) { body.inputType = 'pdf'; body.inputFileUrl = String(flags['file-url']); }
181
+ else if (flags.idea) { body.inputType = 'idea'; body.inputContent = String(flags.idea); }
182
+ else if (flags.input) {
183
+ const text = String(flags.input);
184
+ // The API's text mode expects source MATERIAL (>=50 chars). Short prompts are
185
+ // ideas: the idea path expands them into content instead of rejecting them.
186
+ if (text.trim().length < 50) {
187
+ fail(`--input expects real content (50+ chars of text to visualize). For a short concept like "${text.slice(0, 40)}", use --idea instead - Canvora will develop it into content.`, 2);
188
+ }
189
+ body.inputType = 'text'; body.inputContent = text;
190
+ }
191
+ else fail('provide --idea "concept", --input "content text", --url <url>, or --file-url <pdf-url>', 2);
192
+
193
+ const carouselCounts = parseSlides(flags);
194
+ if (Object.keys(carouselCounts).length) body.carouselCounts = carouselCounts;
195
+ if (flags.brand) body.brandId = String(flags.brand);
196
+ if (flags.style) body.visualStyle = String(flags.style);
197
+ if (flags.resolution) body.resolution = String(flags.resolution);
198
+ if (flags.title) body.title = String(flags.title);
199
+
200
+ const created = await api('/api/generations', { method: 'POST', body });
201
+ const gen = created.generation;
202
+ say(`generation ${gen.id} started (${gen.creditsCharged} credits)`);
203
+
204
+ if (!flags.wait) {
205
+ emit(created, flags.json, () => {
206
+ process.stdout.write(`${gen.id}\n`);
207
+ process.stdout.write(`check progress: canvora status ${gen.id}\n`);
208
+ });
209
+ return;
210
+ }
211
+
212
+ // --wait: poll until terminal state
213
+ const timeoutMs = (parseInt(flags.timeout, 10) || 600) * 1000;
214
+ const pollMs = (parseInt(flags.poll, 10) || 5) * 1000;
215
+ const started = Date.now();
216
+ let last = null;
217
+
218
+ while (Date.now() - started < timeoutMs) {
219
+ await new Promise((r) => setTimeout(r, pollMs));
220
+ last = await api(`/api/generations/${gen.id}`);
221
+ const g = last.generation;
222
+ say(` ${g.status}${g.progress != null ? ` ${g.progress}%` : ''}`);
223
+ if (['completed', 'failed', 'partial', 'cancelled'].includes(g.status)) {
224
+ emit(last, flags.json, () => {
225
+ for (const o of g.outputs || []) {
226
+ if (o.fileUrl) process.stdout.write(`${o.fileUrl}\n`);
227
+ }
228
+ });
229
+ if (g.status === 'failed' || g.status === 'cancelled') {
230
+ fail(`generation ${g.status}${g.errorMessage ? `: ${g.errorMessage}` : ''}`);
231
+ }
232
+ return;
233
+ }
234
+ }
235
+ fail(`timed out after ${timeoutMs / 1000}s waiting for generation ${gen.id} (it may still complete: canvora status ${gen.id})`);
236
+ }
237
+
238
+ async function cmdStatus(flags, id) {
239
+ requireKey();
240
+ if (!id) fail('usage: canvora status <generationId>', 2);
241
+ const d = await api(`/api/generations/${id}`);
242
+ emit(d, flags.json, () => {
243
+ const g = d.generation;
244
+ process.stdout.write(`status: ${g.status}${g.progress != null ? ` (${g.progress}%)` : ''}\n`);
245
+ for (const o of g.outputs || []) {
246
+ if (o.fileUrl) process.stdout.write(`${o.fileUrl}\n`);
247
+ }
248
+ });
249
+ }
250
+
251
+ async function cmdDownload(flags, id) {
252
+ requireKey();
253
+ if (!id) fail('usage: canvora download <generationId> [--dir <path>]', 2);
254
+ const d = await api(`/api/generations/${id}`);
255
+ const g = d.generation;
256
+ if (g.status !== 'completed' && g.status !== 'partial') {
257
+ fail(`generation is ${g.status}, nothing to download yet`);
258
+ }
259
+ const dir = path.resolve(String(flags.dir || '.'));
260
+ fs.mkdirSync(dir, { recursive: true });
261
+ const saved = [];
262
+ for (const o of g.outputs || []) {
263
+ const url = o.fileUrl;
264
+ if (!url) continue;
265
+ // path.basename after decode: a hostile/compromised URL must never be able
266
+ // to write outside --dir (e.g. a segment decoding to "../../.zshrc").
267
+ const rawName = decodeURIComponent(new URL(url).pathname.split('/').pop() || `${o.id}.png`);
268
+ const name = path.basename(rawName).replace(/[^\w.\-]/g, '_') || `${o.id}.png`;
269
+ const dest = path.join(dir, name);
270
+ const res = await fetch(url);
271
+ if (!res.ok) { say(`skip ${name}: HTTP ${res.status}`); continue; }
272
+ fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
273
+ saved.push(dest);
274
+ say(`saved ${dest}`);
275
+ }
276
+ emit({ saved }, flags.json, () => {
277
+ process.stdout.write(saved.map((s) => s + '\n').join(''));
278
+ });
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ const HELP = `canvora ${VERSION} - on-brand visuals for agents and automation
283
+
284
+ usage: canvora <command> [flags]
285
+
286
+ commands:
287
+ generate create visuals --idea "concept" | --input "content text" |
288
+ --url <url> | --file-url <pdf>
289
+ --format <id>[,<id>...] (see: canvora formats)
290
+ [--slides <format>=<n>] [--brand <uuid>]
291
+ [--style modern|minimal|bold|elegant|playful|corporate|creative|dark]
292
+ [--resolution 2K|4K] [--title <t>]
293
+ [--wait [--timeout 600] [--poll 5]]
294
+ status check a generation canvora status <generationId>
295
+ download save output files canvora download <generationId> [--dir out/]
296
+ formats list all format IDs
297
+ brands list your brand kits
298
+ credits show credit balance
299
+ version print version
300
+
301
+ flags: --json on any command prints machine-readable JSON to stdout
302
+ (progress and errors always go to stderr)
303
+
304
+ auth: export CANVORA_API_KEY=vd_... create at canvora.ai -> Integrations -> API Keys
305
+ env: CANVORA_API_URL to override the API host
306
+
307
+ examples:
308
+ canvora generate --idea "5 tips for remote work" --format linkedin_carousel --slides linkedin_carousel=5 --wait --json
309
+ canvora generate --url https://yourblog.com/post --format instagram_post,blog_header --brand <uuid> --wait
310
+ canvora download <generationId> --dir ./visuals
311
+ `;
312
+
313
+ async function main() {
314
+ const { _: pos, flags } = parseArgs(process.argv.slice(2));
315
+ const cmd = pos[0];
316
+ try {
317
+ switch (cmd) {
318
+ case 'generate': return await cmdGenerate(flags);
319
+ case 'status': return await cmdStatus(flags, pos[1]);
320
+ case 'download': return await cmdDownload(flags, pos[1]);
321
+ case 'formats': return await cmdFormats(flags);
322
+ case 'brands': return await cmdBrands(flags);
323
+ case 'credits': return await cmdCredits(flags);
324
+ case 'version': return process.stdout.write(VERSION + '\n');
325
+ case 'help': case undefined: return process.stdout.write(HELP);
326
+ default: fail(`unknown command "${cmd}" (see: canvora help)`, 2);
327
+ }
328
+ } catch (e) {
329
+ fail(e.message);
330
+ }
331
+ }
332
+
333
+ main();
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@canvora/cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI for Canvora (canvora.ai): turn any idea, URL, doc, or PDF into on-brand visuals in 100+ formats, native in 150+ languages. Built for AI agents and automation.",
5
+ "bin": { "canvora": "bin/canvora.js" },
6
+ "files": ["bin", "README.md", "LICENSE"],
7
+ "engines": { "node": ">=18" },
8
+ "keywords": ["canvora", "ai", "agents", "mcp", "image-generation", "visuals", "design", "openclaw", "social-media", "carousel"],
9
+ "homepage": "https://canvora.ai/for-agents",
10
+ "repository": { "type": "git", "url": "git+https://github.com/canvora/canvora-mcp.git" },
11
+ "bugs": { "url": "https://github.com/canvora/canvora-mcp/issues" },
12
+ "license": "MIT"
13
+ }