@calo-design/cli 0.6.0 → 0.8.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 +9 -1
- package/bin/feedback.js +193 -0
- package/bin/imagegen.js +69 -14
- package/bin/refsheet.js +107 -0
- package/package.json +3 -1
package/bin/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ const { cmdArtPush } = require("./art-push");
|
|
|
20
20
|
const { cmdShare } = require("./share");
|
|
21
21
|
const { cmdImage } = require("./imagegen");
|
|
22
22
|
const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken, reportEvent, ensureSession, loadSession, BROKER } = require("./login");
|
|
23
|
+
const { cmdFeedback, cmdTasks } = require("./feedback");
|
|
23
24
|
|
|
24
25
|
const ORG = "Calo-Design";
|
|
25
26
|
const SKILL_REPO = `${ORG}/calo-design`;
|
|
@@ -773,12 +774,17 @@ function help() {
|
|
|
773
774
|
${c.dim("art push <dir>")} upload a bulk image set to the CDN + emit a remote-source manifest (login only)
|
|
774
775
|
${c.dim(" art push ./art/byo --set byo --manifest src/byo-art.ts --dry-run --force --direct")}
|
|
775
776
|
${c.dim("image \"<prompt>\"")} AI image generation via the broker (metered per user; login only)
|
|
776
|
-
${c.dim(" image \"…\" --out hero.png --ref photo
|
|
777
|
+
${c.dim(" image \"…\" --out hero.png --ref photo-or-url --model google/gemini-3-pro-image --json")}
|
|
778
|
+
${c.dim(" image \"…\" --ref <vessel-url> --ref-sheet <comp1> --ref-sheet <comp2> food refs: vessel + composed component sheet")}
|
|
777
779
|
${c.dim("image usage")} your monthly image-generation metering (images + USD)
|
|
778
780
|
${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
|
|
779
781
|
${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
|
|
780
782
|
${c.dim("gallery")} serve the @calo/design-system component gallery locally (live from the runtime)
|
|
781
783
|
${c.dim(" gallery --port 8091")}
|
|
784
|
+
${c.dim("feedback [slug]")} feedback inbox for shared prototypes (open threads; --all, --json)
|
|
785
|
+
${c.dim(" feedback new <slug> <text> comment|resolve|reopen <threadId> shot <slug> <png>")}
|
|
786
|
+
${c.dim("tasks [slug]")} triaged work items (--status todo|in_progress|done|wontfix, --json)
|
|
787
|
+
${c.dim(" tasks new <slug> <title> --from <threadId> start|done|wontfix <id> done <id> --version v9")}
|
|
782
788
|
|
|
783
789
|
Login is required once before init; the session refreshes automatically.
|
|
784
790
|
init always installs the skill and ensures the shared ds/flows runtime on your machine;
|
|
@@ -803,6 +809,8 @@ function help() {
|
|
|
803
809
|
else if (cmd === "insights") await cmdInsights();
|
|
804
810
|
else if (cmd === "whoami") await cmdWhoami();
|
|
805
811
|
else if (cmd === "gallery") await cmdGallery();
|
|
812
|
+
else if (cmd === "feedback") await cmdFeedback(args.slice(1));
|
|
813
|
+
else if (cmd === "tasks") await cmdTasks(args.slice(1));
|
|
806
814
|
else help();
|
|
807
815
|
} catch (err) {
|
|
808
816
|
// 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
CHANGED
|
@@ -5,11 +5,20 @@
|
|
|
5
5
|
*
|
|
6
6
|
* calo-design image "prompt" generate → writes ./<slug>.png, prints path + URL + cost
|
|
7
7
|
* calo-design image "prompt" --out hero.png choose the output path
|
|
8
|
-
* calo-design image "prompt" --ref photo.jpg reference/edit input (repeatable
|
|
8
|
+
* calo-design image "prompt" --ref photo.jpg reference/edit input (repeatable; file or https URL)
|
|
9
|
+
* calo-design image "prompt" --ref-sheet a.jpg --ref-sheet b.jpg
|
|
10
|
+
* composite component photos into ONE tile-grid
|
|
11
|
+
* reference sheet (row-major order — the food
|
|
12
|
+
* register's component sheet); counts as one ref
|
|
13
|
+
* calo-design image "prompt" --sheet-out s.jpg also save the composed sheet for inspection
|
|
9
14
|
* calo-design image "prompt" --model <id> pick an allowed model (default: broker's)
|
|
10
15
|
* calo-design image usage your monthly metering (images + USD, caps)
|
|
11
16
|
* --json on either form for machine callers (the design/marketing skills use this)
|
|
12
17
|
*
|
|
18
|
+
* Refs cap at 4 per generation (broker limit). https refs are fetched (≤6 MB) —
|
|
19
|
+
* this is how CDN reference art (e.g. the food-refs set) attaches without a
|
|
20
|
+
* local copy.
|
|
21
|
+
*
|
|
13
22
|
* The OpenRouter key lives on the broker only; identity is the normal CLI session,
|
|
14
23
|
* and the broker meters every generated image per user per month. The broker also
|
|
15
24
|
* mirrors each image to the CDN (content-hashed, immutable) and returns that URL —
|
|
@@ -52,19 +61,40 @@ async function apiGet(pathname, session) {
|
|
|
52
61
|
return json;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
const MAGIC_MIMES = [
|
|
65
|
+
[(b) => b.length > 2 && b[0] === 0xff && b[1] === 0xd8, "image/jpeg"],
|
|
66
|
+
[(b) => b.length > 7 && b.readUInt32BE(0) === 0x89504e47, "image/png"],
|
|
67
|
+
[(b) => b.length > 11 && b.toString("ascii", 8, 12) === "WEBP", "image/webp"],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// A ref is a local file or an https URL (CDN reference art). Returns { buf, mime }.
|
|
71
|
+
async function loadRef(spec, flagName = "--ref") {
|
|
58
72
|
let buf;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
if (/^https?:\/\//i.test(spec)) {
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetch(spec, { signal: AbortSignal.timeout(20000) });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
throw new Error(`${flagName} ${spec}: fetch failed (${e.message})`);
|
|
79
|
+
}
|
|
80
|
+
if (!res.ok) throw new Error(`${flagName} ${spec}: HTTP ${res.status}`);
|
|
81
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
82
|
+
} else {
|
|
83
|
+
try {
|
|
84
|
+
buf = fs.readFileSync(spec);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error(`${flagName} ${spec}: can't read file`);
|
|
87
|
+
}
|
|
63
88
|
}
|
|
64
|
-
if (buf.length > 6e6) throw new Error(
|
|
65
|
-
|
|
89
|
+
if (buf.length > 6e6) throw new Error(`${flagName} ${spec}: over 6 MB — resize it first`);
|
|
90
|
+
const magic = MAGIC_MIMES.find(([test]) => test(buf));
|
|
91
|
+
const mime = magic ? magic[1] : MIMES[path.extname(new URL(spec, "file:///").pathname).toLowerCase()];
|
|
92
|
+
if (!mime) throw new Error(`${flagName} ${spec}: must be a png/jpg/webp image`);
|
|
93
|
+
return { buf, mime };
|
|
66
94
|
}
|
|
67
95
|
|
|
96
|
+
const toDataUrl = ({ buf, mime }) => `data:${mime};base64,${buf.toString("base64")}`;
|
|
97
|
+
|
|
68
98
|
// "A hero shot of a grain bowl!" → "a-hero-shot-of-a-grain"
|
|
69
99
|
const promptSlug = (p) =>
|
|
70
100
|
p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 5).join("-") || "image";
|
|
@@ -105,7 +135,7 @@ async function cmdImage(args) {
|
|
|
105
135
|
|
|
106
136
|
// First positional = the prompt. Walk the args so a space-separated flag value
|
|
107
137
|
// (`--ref img.png`) is never mistaken for it.
|
|
108
|
-
const VALUE_FLAGS = new Set(["--out", "--model", "--ref"]);
|
|
138
|
+
const VALUE_FLAGS = new Set(["--out", "--model", "--ref", "--ref-sheet", "--sheet-out"]);
|
|
109
139
|
let prompt;
|
|
110
140
|
for (let i = 0; i < args.length; i++) {
|
|
111
141
|
if (VALUE_FLAGS.has(args[i])) i++; // skip the flag's value
|
|
@@ -113,14 +143,39 @@ async function cmdImage(args) {
|
|
|
113
143
|
}
|
|
114
144
|
if (!prompt) {
|
|
115
145
|
throw new Error(
|
|
116
|
-
'usage: calo-design image "<prompt>" [--out file.png] [--ref img
|
|
146
|
+
'usage: calo-design image "<prompt>" [--out file.png] [--ref img-or-url]... [--ref-sheet img-or-url]... [--model id] [--json]\n or: calo-design image usage'
|
|
117
147
|
);
|
|
118
148
|
}
|
|
119
|
-
const refImages =
|
|
149
|
+
const refImages = [];
|
|
150
|
+
for (const spec of flagAll(args, "ref")) refImages.push(toDataUrl(await loadRef(spec)));
|
|
151
|
+
|
|
152
|
+
// --ref-sheet: composite N component photos into ONE row-major tile grid that
|
|
153
|
+
// costs a single ref slot. Order of the flags = tile order = the order the
|
|
154
|
+
// prompt's sheet map must bind.
|
|
155
|
+
const sheetSpecs = flagAll(args, "ref-sheet");
|
|
156
|
+
let sheetInfo = null;
|
|
157
|
+
if (sheetSpecs.length) {
|
|
158
|
+
const { composeSheet } = require("./refsheet");
|
|
159
|
+
const inputs = [];
|
|
160
|
+
for (const spec of sheetSpecs) inputs.push({ ...(await loadRef(spec, "--ref-sheet")), label: spec });
|
|
161
|
+
const sheet = composeSheet(inputs);
|
|
162
|
+
if (sheet.buf.length > 6e6) throw new Error(`--ref-sheet: composed sheet is over 6 MB (${sheetSpecs.length} tiles) — send fewer tiles`);
|
|
163
|
+
const sheetOut = flag(args, "sheet-out");
|
|
164
|
+
if (sheetOut) {
|
|
165
|
+
fs.mkdirSync(path.dirname(path.resolve(sheetOut)), { recursive: true });
|
|
166
|
+
fs.writeFileSync(sheetOut, sheet.buf);
|
|
167
|
+
}
|
|
168
|
+
refImages.push(toDataUrl({ buf: sheet.buf, mime: "image/jpeg" }));
|
|
169
|
+
sheetInfo = { tiles: sheetSpecs.length, cols: sheet.cols, rows: sheet.rows, ...(sheetOut ? { file: path.resolve(sheetOut) } : {}) };
|
|
170
|
+
}
|
|
171
|
+
if (refImages.length > 4) {
|
|
172
|
+
throw new Error(`${refImages.length} reference images — the broker caps a generation at 4. Move component refs onto --ref-sheet (one composed sheet = one ref).`);
|
|
173
|
+
}
|
|
120
174
|
const model = flag(args, "model");
|
|
121
175
|
const out = flag(args, "out");
|
|
122
176
|
|
|
123
177
|
const session = await ensureSession();
|
|
178
|
+
if (!jsonOut && sheetInfo) log(c.dim(`Composed reference sheet: ${sheetInfo.tiles} tiles, ${sheetInfo.cols}×${sheetInfo.rows} grid (row-major)${sheetInfo.file ? ` → ${sheetInfo.file}` : ""}`));
|
|
124
179
|
if (!jsonOut) log(c.dim(`Generating${model ? ` with ${model}` : ""}… (typically 10–30s)`));
|
|
125
180
|
let res;
|
|
126
181
|
try {
|
|
@@ -156,7 +211,7 @@ async function cmdImage(args) {
|
|
|
156
211
|
|
|
157
212
|
if (jsonOut) {
|
|
158
213
|
const { images: _images, ...rest } = data; // drop the base64 payload from machine output
|
|
159
|
-
process.stdout.write("\n" + JSON.stringify({ ...rest, files }) + "\n");
|
|
214
|
+
process.stdout.write("\n" + JSON.stringify({ ...rest, files, ...(sheetInfo ? { refSheet: sheetInfo } : {}) }) + "\n");
|
|
160
215
|
return;
|
|
161
216
|
}
|
|
162
217
|
for (const f of files) {
|
package/bin/refsheet.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Component reference sheet compositor for `calo-design image --ref-sheet`.
|
|
5
|
+
*
|
|
6
|
+
* Ported from the imagegen-lab collage builder: N component photos → one
|
|
7
|
+
* tile-grid JPEG (512px tiles, off-white gutterless grid, row-major order).
|
|
8
|
+
* The grid exists because the broker caps a generation at 4 reference images —
|
|
9
|
+
* a meal with more components sends ONE composed sheet instead, and the prompt
|
|
10
|
+
* binds each tile to its named component ("REFERENCE COMPONENT SHEET MAP").
|
|
11
|
+
*
|
|
12
|
+
* Pure-JS on purpose (jpeg-js + pngjs): the CLI stays native-dep-free so
|
|
13
|
+
* install never needs a compiler toolchain.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const jpeg = require("jpeg-js");
|
|
17
|
+
const { PNG } = require("pngjs");
|
|
18
|
+
|
|
19
|
+
const TILE_PX = 512; // longest edge of each tile (matches the lab's collage)
|
|
20
|
+
const BG = [248, 248, 246];
|
|
21
|
+
|
|
22
|
+
// Same grid ladder as the lab's _pick_grid: near-square, fills row-major.
|
|
23
|
+
function pickGrid(n) {
|
|
24
|
+
if (n <= 1) return [1, 1];
|
|
25
|
+
if (n <= 2) return [2, 1];
|
|
26
|
+
if (n <= 4) return [2, 2];
|
|
27
|
+
if (n <= 6) return [3, 2];
|
|
28
|
+
if (n <= 9) return [3, 3];
|
|
29
|
+
const cols = Math.ceil(Math.sqrt(n));
|
|
30
|
+
return [cols, Math.ceil(n / cols)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decode(buf, label) {
|
|
34
|
+
if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
35
|
+
const { width, height, data } = jpeg.decode(buf, { maxMemoryUsageInMB: 256 });
|
|
36
|
+
return { width, height, data };
|
|
37
|
+
}
|
|
38
|
+
if (buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47) {
|
|
39
|
+
const { width, height, data } = PNG.sync.read(buf);
|
|
40
|
+
return { width, height, data };
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`--ref-sheet ${label}: only JPEG/PNG images can go on a reference sheet`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Bilinear resize, RGBA in/out. Never upscales (scale capped at 1).
|
|
46
|
+
function resize(img, maxEdge) {
|
|
47
|
+
const scale = Math.min(maxEdge / img.width, maxEdge / img.height, 1);
|
|
48
|
+
const w = Math.max(1, Math.round(img.width * scale));
|
|
49
|
+
const h = Math.max(1, Math.round(img.height * scale));
|
|
50
|
+
if (w === img.width && h === img.height) return img;
|
|
51
|
+
const out = Buffer.alloc(w * h * 4);
|
|
52
|
+
for (let y = 0; y < h; y++) {
|
|
53
|
+
const sy = ((y + 0.5) * img.height) / h - 0.5;
|
|
54
|
+
const y0 = Math.max(0, Math.floor(sy));
|
|
55
|
+
const y1 = Math.min(img.height - 1, y0 + 1);
|
|
56
|
+
const fy = sy - y0;
|
|
57
|
+
for (let x = 0; x < w; x++) {
|
|
58
|
+
const sx = ((x + 0.5) * img.width) / w - 0.5;
|
|
59
|
+
const x0 = Math.max(0, Math.floor(sx));
|
|
60
|
+
const x1 = Math.min(img.width - 1, x0 + 1);
|
|
61
|
+
const fx = sx - x0;
|
|
62
|
+
const di = (y * w + x) * 4;
|
|
63
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
64
|
+
const p00 = img.data[(y0 * img.width + x0) * 4 + ch];
|
|
65
|
+
const p10 = img.data[(y0 * img.width + x1) * 4 + ch];
|
|
66
|
+
const p01 = img.data[(y1 * img.width + x0) * 4 + ch];
|
|
67
|
+
const p11 = img.data[(y1 * img.width + x1) * 4 + ch];
|
|
68
|
+
out[di + ch] = Math.round(
|
|
69
|
+
p00 * (1 - fx) * (1 - fy) + p10 * fx * (1 - fy) + p01 * (1 - fx) * fy + p11 * fx * fy
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { width: w, height: h, data: out };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compose the sheet. `inputs` = [{ buf, label }] in the order the caller wants
|
|
79
|
+
* the tiles bound (row-major). Returns { buf (JPEG), width, height, cols, rows }.
|
|
80
|
+
*/
|
|
81
|
+
function composeSheet(inputs) {
|
|
82
|
+
if (!inputs.length) throw new Error("--ref-sheet: no images to compose");
|
|
83
|
+
const tiles = inputs.map((i) => resize(decode(i.buf, i.label), TILE_PX));
|
|
84
|
+
const [cols, rows] = pickGrid(tiles.length);
|
|
85
|
+
const W = cols * TILE_PX;
|
|
86
|
+
const H = rows * TILE_PX;
|
|
87
|
+
const canvas = Buffer.alloc(W * H * 4);
|
|
88
|
+
for (let p = 0; p < W * H; p++) {
|
|
89
|
+
canvas[p * 4] = BG[0];
|
|
90
|
+
canvas[p * 4 + 1] = BG[1];
|
|
91
|
+
canvas[p * 4 + 2] = BG[2];
|
|
92
|
+
canvas[p * 4 + 3] = 255;
|
|
93
|
+
}
|
|
94
|
+
tiles.forEach((tile, idx) => {
|
|
95
|
+
const cx = (idx % cols) * TILE_PX + Math.floor((TILE_PX - tile.width) / 2);
|
|
96
|
+
const cy = Math.floor(idx / cols) * TILE_PX + Math.floor((TILE_PX - tile.height) / 2);
|
|
97
|
+
for (let y = 0; y < tile.height; y++) {
|
|
98
|
+
// Alpha is ignored on paste: reference photos are opaque; PNG alpha
|
|
99
|
+
// lands on the off-white background anyway after JPEG encode.
|
|
100
|
+
tile.data.copy(canvas, ((cy + y) * W + cx) * 4, y * tile.width * 4, (y + 1) * tile.width * 4);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
const out = jpeg.encode({ data: canvas, width: W, height: H }, 85);
|
|
104
|
+
return { buf: out.data, width: W, height: H, cols, rows };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { composeSheet, _pickGrid: pickGrid };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calo-design/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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"
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"node": ">=18"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"jpeg-js": "^0.4.4",
|
|
20
|
+
"pngjs": "^7.0.0",
|
|
19
21
|
"qrcode-terminal": "^0.12.0"
|
|
20
22
|
}
|
|
21
23
|
}
|