@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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/canvora.js +333 -0
- 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
|
+
}
|