@calo-design/cli 0.5.1 → 0.7.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/bin/cli.js +12 -0
- package/bin/feedback.js +193 -0
- package/bin/imagegen.js +171 -0
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -18,7 +18,9 @@ const path = require("node:path");
|
|
|
18
18
|
const { cmdPush } = require("./mirror-push");
|
|
19
19
|
const { cmdArtPush } = require("./art-push");
|
|
20
20
|
const { cmdShare } = require("./share");
|
|
21
|
+
const { cmdImage } = require("./imagegen");
|
|
21
22
|
const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken, reportEvent, ensureSession, loadSession, BROKER } = require("./login");
|
|
23
|
+
const { cmdFeedback, cmdTasks } = require("./feedback");
|
|
22
24
|
|
|
23
25
|
const ORG = "Calo-Design";
|
|
24
26
|
const SKILL_REPO = `${ORG}/calo-design`;
|
|
@@ -771,10 +773,17 @@ function help() {
|
|
|
771
773
|
${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run --direct")}
|
|
772
774
|
${c.dim("art push <dir>")} upload a bulk image set to the CDN + emit a remote-source manifest (login only)
|
|
773
775
|
${c.dim(" art push ./art/byo --set byo --manifest src/byo-art.ts --dry-run --force --direct")}
|
|
776
|
+
${c.dim("image \"<prompt>\"")} AI image generation via the broker (metered per user; login only)
|
|
777
|
+
${c.dim(" image \"…\" --out hero.png --ref photo.jpg --model google/gemini-3-pro-image --json")}
|
|
778
|
+
${c.dim("image usage")} your monthly image-generation metering (images + USD)
|
|
774
779
|
${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
|
|
775
780
|
${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
|
|
776
781
|
${c.dim("gallery")} serve the @calo/design-system component gallery locally (live from the runtime)
|
|
777
782
|
${c.dim(" gallery --port 8091")}
|
|
783
|
+
${c.dim("feedback [slug]")} feedback inbox for shared prototypes (open threads; --all, --json)
|
|
784
|
+
${c.dim(" feedback new <slug> <text> comment|resolve|reopen <threadId> shot <slug> <png>")}
|
|
785
|
+
${c.dim("tasks [slug]")} triaged work items (--status todo|in_progress|done|wontfix, --json)
|
|
786
|
+
${c.dim(" tasks new <slug> <title> --from <threadId> start|done|wontfix <id> done <id> --version v9")}
|
|
778
787
|
|
|
779
788
|
Login is required once before init; the session refreshes automatically.
|
|
780
789
|
init always installs the skill and ensures the shared ds/flows runtime on your machine;
|
|
@@ -794,10 +803,13 @@ function help() {
|
|
|
794
803
|
else if (cmd === "art" && args[1] === "push") await cmdArtPush(args.slice(2));
|
|
795
804
|
else if (cmd === "art") throw new Error("unknown art command — did you mean `calo-design art push <dir>`?");
|
|
796
805
|
else if (cmd === "share") await cmdShare(args.slice(1));
|
|
806
|
+
else if (cmd === "image") await cmdImage(args.slice(1));
|
|
797
807
|
else if (cmd === "feed") await cmdFeed();
|
|
798
808
|
else if (cmd === "insights") await cmdInsights();
|
|
799
809
|
else if (cmd === "whoami") await cmdWhoami();
|
|
800
810
|
else if (cmd === "gallery") await cmdGallery();
|
|
811
|
+
else if (cmd === "feedback") await cmdFeedback(args.slice(1));
|
|
812
|
+
else if (cmd === "tasks") await cmdTasks(args.slice(1));
|
|
801
813
|
else help();
|
|
802
814
|
} catch (err) {
|
|
803
815
|
// Diagnostics go to stderr so stdout stays pure for machine callers (The Pass parses
|
package/bin/feedback.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `calo-design feedback …` / `calo-design tasks …` — prototype feedback threads and
|
|
5
|
+
* triaged tasks, stored broker-side (calo-broker docs/feedback-plan.md).
|
|
6
|
+
*
|
|
7
|
+
* Triple duty: humans in the terminal, Claude working the task queue, and Design
|
|
8
|
+
* Kitchen shelling out with --json (same bridge pattern as `feed --json`). All calls
|
|
9
|
+
* ride the existing broker session (ensureSession → Bearer).
|
|
10
|
+
*
|
|
11
|
+
* feedback [slug] inbox (open threads; --all for resolved too)
|
|
12
|
+
* feedback new <slug> <text…> plain thread (no pin)
|
|
13
|
+
* feedback comment <threadId> <text…>
|
|
14
|
+
* feedback resolve <threadId> | reopen <threadId>
|
|
15
|
+
* feedback shot <slug> <file.png> upload a pin screenshot → prints URL
|
|
16
|
+
* tasks [slug] list (--status todo|in_progress|done|wontfix)
|
|
17
|
+
* tasks new <slug> <title…> (--from <threadId> converts a thread, --body <text>)
|
|
18
|
+
* tasks start|done|wontfix <id> (done: --version <v> stamps closed_in_version)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
const { ensureSession, BROKER } = require("./login");
|
|
23
|
+
|
|
24
|
+
const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m` };
|
|
25
|
+
const log = (s = "") => console.log(s);
|
|
26
|
+
|
|
27
|
+
// Bounded fetch that speaks the broker's JSON envelope. GET+POST (login.js api() is
|
|
28
|
+
// POST-only); --json callers get the raw response text so the bridge never re-encodes.
|
|
29
|
+
async function call(method, pathname, { body, raw } = {}) {
|
|
30
|
+
const session = await ensureSession();
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
res = await fetch(BROKER + pathname, {
|
|
34
|
+
method,
|
|
35
|
+
headers: {
|
|
36
|
+
authorization: `Bearer ${session}`,
|
|
37
|
+
...(body !== undefined && !Buffer.isBuffer(body) ? { "content-type": "application/json" } : {}),
|
|
38
|
+
...(Buffer.isBuffer(body) ? { "content-type": "image/png" } : {}),
|
|
39
|
+
},
|
|
40
|
+
body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
|
|
41
|
+
signal: AbortSignal.timeout(30000),
|
|
42
|
+
});
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
|
|
45
|
+
}
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
let json;
|
|
48
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
49
|
+
if (!res.ok) throw new Error(json.error || `${pathname} → HTTP ${res.status}`);
|
|
50
|
+
return raw ? text : json;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const flagVal = (args, name) => {
|
|
54
|
+
const i = args.indexOf(`--${name}`);
|
|
55
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : undefined;
|
|
56
|
+
};
|
|
57
|
+
const stripFlags = (args) => args.filter((a, i, all) => !a.startsWith("--") && !(all[i - 1] || "").match(/^--(status|version|from|body|route|anchor|shot)$/));
|
|
58
|
+
|
|
59
|
+
const ago = (ts) => {
|
|
60
|
+
const s = Math.max(1, Math.floor(Date.now() / 1000) - ts);
|
|
61
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
62
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
63
|
+
return `${Math.floor(s / 86400)}d`;
|
|
64
|
+
};
|
|
65
|
+
const short = (id) => id.slice(0, 8);
|
|
66
|
+
// A thread's version is the deploy it was left on; slugs.last_version is current.
|
|
67
|
+
const stale = (t) => t.version && t.current_version && t.version !== t.current_version;
|
|
68
|
+
|
|
69
|
+
function printThread(t, { comments = false } = {}) {
|
|
70
|
+
const state = t.status === "resolved" ? c.g("✓ resolved") : c.y("● open");
|
|
71
|
+
const pin = t.anchor_json ? " 📌" : "";
|
|
72
|
+
const staleTag = stale(t) ? c.y(" [stale]") : "";
|
|
73
|
+
const task = t.task ? c.dim(` → task ${short(t.task.id)} (${t.task.status})`) : "";
|
|
74
|
+
log(`${state} ${c.b(t.slug)}${t.route ? c.dim(" " + t.route) : ""}${pin}${staleTag} ${c.dim(short(t.id))} ${c.dim(ago(t.created_at) + " ago")}${task}`);
|
|
75
|
+
const list = comments ? t.comments : t.comments.slice(0, 1);
|
|
76
|
+
for (const cm of list) {
|
|
77
|
+
const who = cm.kind === "system" ? c.dim("system") : cm.author.split("@")[0];
|
|
78
|
+
log(` ${who}: ${cm.kind === "system" ? c.dim(cm.body) : cm.body}`);
|
|
79
|
+
}
|
|
80
|
+
if (!comments && t.comments.length > 1) log(c.dim(` … ${t.comments.length - 1} more — calo-design feedback ${t.slug}`));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function cmdFeedback(args = []) {
|
|
84
|
+
const json = args.includes("--json");
|
|
85
|
+
const sub = args[0] && !args[0].startsWith("--") ? args[0] : "";
|
|
86
|
+
|
|
87
|
+
if (sub === "new") {
|
|
88
|
+
const [slug, ...words] = stripFlags(args.slice(1));
|
|
89
|
+
const body = words.join(" ").trim();
|
|
90
|
+
if (!slug || !body) throw new Error("usage: calo-design feedback new <slug> <text…> [--shot <png>] [--anchor <json>] [--route <path>] [--version <v>]");
|
|
91
|
+
// Pin threads (Kitchen Review view): upload the frozen frame first, then attach
|
|
92
|
+
// its URL — /v1/feedback/thread only accepts screenshot URLs minted by /shot.
|
|
93
|
+
let screenshotUrl;
|
|
94
|
+
const shotFile = flagVal(args, "shot");
|
|
95
|
+
if (shotFile) {
|
|
96
|
+
const png = fs.readFileSync(shotFile);
|
|
97
|
+
const up = await call("POST", `/v1/feedback/shot?slug=${encodeURIComponent(slug)}`, { body: png });
|
|
98
|
+
screenshotUrl = up.url;
|
|
99
|
+
}
|
|
100
|
+
let anchor;
|
|
101
|
+
const anchorRaw = flagVal(args, "anchor");
|
|
102
|
+
if (anchorRaw) {
|
|
103
|
+
try { anchor = JSON.parse(anchorRaw); } catch { throw new Error("--anchor must be a JSON object"); }
|
|
104
|
+
}
|
|
105
|
+
const r = await call("POST", "/v1/feedback/thread", {
|
|
106
|
+
body: { slug, body, screenshotUrl, anchor, route: flagVal(args, "route"), version: flagVal(args, "version") },
|
|
107
|
+
raw: json,
|
|
108
|
+
});
|
|
109
|
+
if (json) return process.stdout.write(r + "\n");
|
|
110
|
+
log(`${c.g("✓")} thread ${short(r.thread.id)} opened on ${c.b(slug)}${screenshotUrl ? c.dim(" (with screenshot)") : ""}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (sub === "comment" || sub === "resolve" || sub === "reopen") {
|
|
115
|
+
const [threadId, ...words] = stripFlags(args.slice(1));
|
|
116
|
+
if (!threadId) throw new Error(`usage: calo-design feedback ${sub} <threadId>${sub === "comment" ? " <text…>" : ""}`);
|
|
117
|
+
const path = { comment: "/v1/feedback/comment", resolve: "/v1/feedback/resolve", reopen: "/v1/feedback/reopen" }[sub];
|
|
118
|
+
const body = sub === "comment" ? { threadId, body: words.join(" ").trim() } : { threadId };
|
|
119
|
+
if (sub === "comment" && !body.body) throw new Error("comment text required");
|
|
120
|
+
const r = await call("POST", path, { body, raw: json });
|
|
121
|
+
if (json) return process.stdout.write(r + "\n");
|
|
122
|
+
log(`${c.g("✓")} ${sub === "comment" ? "commented" : sub + "ed"}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (sub === "shot") {
|
|
127
|
+
const [slug, file] = stripFlags(args.slice(1));
|
|
128
|
+
if (!slug || !file) throw new Error("usage: calo-design feedback shot <slug> <file.png>");
|
|
129
|
+
const png = fs.readFileSync(file);
|
|
130
|
+
const r = await call("POST", `/v1/feedback/shot?slug=${encodeURIComponent(slug)}`, { body: png, raw: json });
|
|
131
|
+
if (json) return process.stdout.write(r + "\n");
|
|
132
|
+
log(`${c.g("✓")} ${r.url}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// list: `feedback` (inbox: open everywhere) or `feedback <slug>` (--all incl. resolved)
|
|
137
|
+
const slug = sub || undefined;
|
|
138
|
+
const all = args.includes("--all");
|
|
139
|
+
const qs = new URLSearchParams();
|
|
140
|
+
if (slug) qs.set("slug", slug);
|
|
141
|
+
if (!all) qs.set("status", "open");
|
|
142
|
+
const r = await call("GET", `/v1/feedback?${qs}`, { raw: json });
|
|
143
|
+
if (json) return process.stdout.write(r + "\n");
|
|
144
|
+
const threads = r.threads;
|
|
145
|
+
if (!threads.length) return log(c.dim(slug ? `no ${all ? "" : "open "}feedback on ${slug}` : "inbox zero 🎉"));
|
|
146
|
+
log(c.b(`\n${threads.length} thread(s)${slug ? ` on ${slug}` : ""}${all ? "" : " (open)"}\n`));
|
|
147
|
+
for (const t of threads) printThread(t, { comments: !!slug });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function cmdTasks(args = []) {
|
|
151
|
+
const json = args.includes("--json");
|
|
152
|
+
const sub = args[0] && !args[0].startsWith("--") ? args[0] : "";
|
|
153
|
+
|
|
154
|
+
if (sub === "new") {
|
|
155
|
+
const [slug, ...words] = stripFlags(args.slice(1));
|
|
156
|
+
const title = words.join(" ").trim();
|
|
157
|
+
if (!slug || !title) throw new Error("usage: calo-design tasks new <slug> <title…> [--from <threadId>] [--body <text>]");
|
|
158
|
+
const body = { slug, title, sourceThread: flagVal(args, "from"), body: flagVal(args, "body") };
|
|
159
|
+
const r = await call("POST", "/v1/tasks", { body, raw: json });
|
|
160
|
+
if (json) return process.stdout.write(r + "\n");
|
|
161
|
+
log(`${c.g("✓")} task ${short(r.task.id)}: ${r.task.title}${r.task.source_thread ? c.dim(` (from thread ${short(r.task.source_thread)})`) : ""}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (["start", "done", "wontfix"].includes(sub)) {
|
|
166
|
+
const [id] = stripFlags(args.slice(1));
|
|
167
|
+
if (!id) throw new Error(`usage: calo-design tasks ${sub} <id>`);
|
|
168
|
+
const status = sub === "start" ? "in_progress" : sub;
|
|
169
|
+
const body = { id, status, ...(sub === "done" && flagVal(args, "version") ? { closedInVersion: flagVal(args, "version") } : {}) };
|
|
170
|
+
const r = await call("POST", "/v1/tasks/update", { body, raw: json });
|
|
171
|
+
if (json) return process.stdout.write(r + "\n");
|
|
172
|
+
log(`${c.g("✓")} ${r.task.title} → ${c.b(r.task.status)}${r.task.closed_in_version ? c.dim(` (${r.task.closed_in_version})`) : ""}`);
|
|
173
|
+
if (sub === "done") log(c.dim(" source thread (if any) auto-resolved"));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const slug = sub || undefined;
|
|
178
|
+
const qs = new URLSearchParams();
|
|
179
|
+
if (slug) qs.set("slug", slug);
|
|
180
|
+
const status = flagVal(args, "status");
|
|
181
|
+
if (status) qs.set("status", status);
|
|
182
|
+
const r = await call("GET", `/v1/tasks?${qs}`, { raw: json });
|
|
183
|
+
if (json) return process.stdout.write(r + "\n");
|
|
184
|
+
const list = r.tasks;
|
|
185
|
+
if (!list.length) return log(c.dim("no tasks" + (slug ? ` on ${slug}` : "")));
|
|
186
|
+
log(c.b(`\n${list.length} task(s)${slug ? ` on ${slug}` : ""}\n`));
|
|
187
|
+
const badge = { todo: c.y("○ todo"), in_progress: c.b("◐ in progress"), done: c.g("● done"), wontfix: c.dim("✕ wontfix") };
|
|
188
|
+
for (const t of list) {
|
|
189
|
+
log(`${badge[t.status] || t.status} ${c.b(t.slug)} ${t.title} ${c.dim(short(t.id))}${t.source_thread ? c.dim(" ← " + short(t.source_thread)) : ""}${t.closed_in_version ? c.dim(" " + t.closed_in_version) : ""}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { cmdFeedback, cmdTasks };
|
package/bin/imagegen.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `calo-design image` — metered AI image generation through the Calo broker.
|
|
5
|
+
*
|
|
6
|
+
* calo-design image "prompt" generate → writes ./<slug>.png, prints path + URL + cost
|
|
7
|
+
* calo-design image "prompt" --out hero.png choose the output path
|
|
8
|
+
* calo-design image "prompt" --ref photo.jpg reference/edit input (repeatable, ≤4)
|
|
9
|
+
* calo-design image "prompt" --model <id> pick an allowed model (default: broker's)
|
|
10
|
+
* calo-design image usage your monthly metering (images + USD, caps)
|
|
11
|
+
* --json on either form for machine callers (the design/marketing skills use this)
|
|
12
|
+
*
|
|
13
|
+
* The OpenRouter key lives on the broker only; identity is the normal CLI session,
|
|
14
|
+
* and the broker meters every generated image per user per month. The broker also
|
|
15
|
+
* mirrors each image to the CDN (content-hashed, immutable) and returns that URL —
|
|
16
|
+
* use it directly in prototypes; the local file is for creatives/mockups.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require("node:fs");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
const { ensureSession, BROKER } = require("./login");
|
|
22
|
+
|
|
23
|
+
const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, g: (s) => `\x1b[32m${s}\x1b[0m` };
|
|
24
|
+
const log = (s = "") => console.log(s);
|
|
25
|
+
|
|
26
|
+
const MIMES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
27
|
+
const EXTS = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
|
|
28
|
+
|
|
29
|
+
// --flag value (both `--flag=v` and `--flag v`), collecting repeats.
|
|
30
|
+
function flagAll(args, name) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let i = 0; i < args.length; i++) {
|
|
33
|
+
if (args[i] === `--${name}` && args[i + 1] != null) out.push(args[++i]);
|
|
34
|
+
else if (args[i].startsWith(`--${name}=`)) out.push(args[i].split("=").slice(1).join("="));
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
const flag = (args, name) => flagAll(args, name)[0];
|
|
39
|
+
|
|
40
|
+
async function apiGet(pathname, session) {
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await fetch(BROKER + pathname, {
|
|
44
|
+
headers: { authorization: `Bearer ${session}` },
|
|
45
|
+
signal: AbortSignal.timeout(15000),
|
|
46
|
+
});
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
|
|
49
|
+
}
|
|
50
|
+
const json = await res.json().catch(() => ({}));
|
|
51
|
+
if (!res.ok) throw new Error(json.error || `${pathname} → HTTP ${res.status}`);
|
|
52
|
+
return json;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function refToDataUrl(file) {
|
|
56
|
+
const mime = MIMES[path.extname(file).toLowerCase()];
|
|
57
|
+
if (!mime) throw new Error(`--ref ${file}: must be a .png/.jpg/.jpeg/.webp file`);
|
|
58
|
+
let buf;
|
|
59
|
+
try {
|
|
60
|
+
buf = fs.readFileSync(file);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(`--ref ${file}: can't read file`);
|
|
63
|
+
}
|
|
64
|
+
if (buf.length > 6e6) throw new Error(`--ref ${file}: over 6 MB — resize it first`);
|
|
65
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "A hero shot of a grain bowl!" → "a-hero-shot-of-a-grain"
|
|
69
|
+
const promptSlug = (p) =>
|
|
70
|
+
p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 5).join("-") || "image";
|
|
71
|
+
|
|
72
|
+
// Unique output path: <base>.<ext>, <base>-2.<ext>, ... (never overwrite silently).
|
|
73
|
+
function outPath(base, ext, taken) {
|
|
74
|
+
for (let n = 1; ; n++) {
|
|
75
|
+
const p = n === 1 ? `${base}.${ext}` : `${base}-${n}.${ext}`;
|
|
76
|
+
if (!taken.has(p) && !fs.existsSync(p)) {
|
|
77
|
+
taken.add(p);
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const money = (v) => `$${(Number(v) || 0).toFixed(v >= 0.1 ? 2 : 4)}`;
|
|
84
|
+
|
|
85
|
+
async function cmdImageUsage(jsonOut) {
|
|
86
|
+
const session = await ensureSession();
|
|
87
|
+
const u = await apiGet("/v1/imagegen/usage", session);
|
|
88
|
+
if (jsonOut) {
|
|
89
|
+
process.stdout.write("\n" + JSON.stringify(u) + "\n"); // own line — machine callers parse the last non-empty line
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const cur = u.months[u.month] || { images: 0, cost: 0, byModel: {} };
|
|
93
|
+
log(`${c.b(u.email)} — image generation for ${c.b(u.month)}`);
|
|
94
|
+
log(` ${cur.images}/${u.limits.monthlyImages || "∞"} images · ${money(cur.cost)}/$${u.limits.monthlyCostUsd || "∞"}`);
|
|
95
|
+
for (const [m, v] of Object.entries(cur.byModel || {})) log(c.dim(` ${m}: ${v.images} images · ${money(v.cost)}`));
|
|
96
|
+
log(c.dim(` org this month: ${u.orgMonth.images} images · ${money(u.orgMonth.cost)} (budget $${u.limits.globalMonthlyCostUsd || "∞"})`));
|
|
97
|
+
const past = Object.keys(u.months).filter((m) => m !== u.month).sort().reverse();
|
|
98
|
+
for (const m of past.slice(0, 6)) log(c.dim(` ${m}: ${u.months[m].images} images · ${money(u.months[m].cost)}`));
|
|
99
|
+
if (!u.configured) log(c.dim(" (generation currently disabled on the broker — no OPENROUTER_API_KEY)"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function cmdImage(args) {
|
|
103
|
+
const jsonOut = args.includes("--json");
|
|
104
|
+
if (args[0] === "usage") return cmdImageUsage(jsonOut);
|
|
105
|
+
|
|
106
|
+
// First positional = the prompt. Walk the args so a space-separated flag value
|
|
107
|
+
// (`--ref img.png`) is never mistaken for it.
|
|
108
|
+
const VALUE_FLAGS = new Set(["--out", "--model", "--ref"]);
|
|
109
|
+
let prompt;
|
|
110
|
+
for (let i = 0; i < args.length; i++) {
|
|
111
|
+
if (VALUE_FLAGS.has(args[i])) i++; // skip the flag's value
|
|
112
|
+
else if (!args[i].startsWith("--")) { prompt = args[i]; break; }
|
|
113
|
+
}
|
|
114
|
+
if (!prompt) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'usage: calo-design image "<prompt>" [--out file.png] [--ref img.png]... [--model id] [--json]\n or: calo-design image usage'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const refImages = flagAll(args, "ref").map(refToDataUrl);
|
|
120
|
+
const model = flag(args, "model");
|
|
121
|
+
const out = flag(args, "out");
|
|
122
|
+
|
|
123
|
+
const session = await ensureSession();
|
|
124
|
+
if (!jsonOut) log(c.dim(`Generating${model ? ` with ${model}` : ""}… (typically 10–30s)`));
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
res = await fetch(BROKER + "/v1/imagegen", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session}` },
|
|
130
|
+
body: JSON.stringify({ prompt, ...(model ? { model } : {}), ...(refImages.length ? { refImages } : {}) }),
|
|
131
|
+
signal: AbortSignal.timeout(180000),
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
|
|
135
|
+
}
|
|
136
|
+
const data = await res.json().catch(() => ({}));
|
|
137
|
+
if (!res.ok) throw new Error(data.error || `imagegen → HTTP ${res.status}`);
|
|
138
|
+
|
|
139
|
+
// Write files. --out names the first image exactly (extension from the actual
|
|
140
|
+
// mime wins over a mismatched one); extras and the no---out case get slug names.
|
|
141
|
+
const taken = new Set();
|
|
142
|
+
const files = [];
|
|
143
|
+
for (const [i, img] of data.images.entries()) {
|
|
144
|
+
const ext = EXTS[img.mime] || "png";
|
|
145
|
+
let file;
|
|
146
|
+
if (out && i === 0) {
|
|
147
|
+
const wanted = out.replace(/\.(png|jpe?g|webp)$/i, "");
|
|
148
|
+
file = outPath(wanted, ext, taken);
|
|
149
|
+
} else {
|
|
150
|
+
file = outPath(path.join(process.cwd(), promptSlug(prompt)), ext, taken);
|
|
151
|
+
}
|
|
152
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
153
|
+
fs.writeFileSync(file, Buffer.from(img.b64, "base64"));
|
|
154
|
+
files.push({ file: path.resolve(file), url: img.url, mime: img.mime });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (jsonOut) {
|
|
158
|
+
const { images: _images, ...rest } = data; // drop the base64 payload from machine output
|
|
159
|
+
process.stdout.write("\n" + JSON.stringify({ ...rest, files }) + "\n");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const f of files) {
|
|
163
|
+
log(`${c.g("✓")} ${f.file}`);
|
|
164
|
+
if (f.url) log(c.dim(` hosted: ${f.url}`));
|
|
165
|
+
}
|
|
166
|
+
if (data.text) log(c.dim(` model note: ${data.text}`));
|
|
167
|
+
const u = data.usage;
|
|
168
|
+
log(c.dim(` ${data.model} · this call ${money(data.cost)} · ${u.month}: ${u.images}/${u.limits.monthlyImages || "∞"} images, ${money(u.cost)}/$${u.limits.monthlyCostUsd || "∞"}`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { cmdImage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calo-design/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "One-line setup for Calo design tooling: logs in with your Calo email and installs the calo-design skill + design-system packages. No GitHub account needed.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"calo-design": "bin/cli.js"
|