makeables 0.4.1 → 0.5.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/README.md +43 -4
- package/dist/chunk-WVXA65H6.js +20223 -0
- package/dist/cli.js +459 -20469
- package/dist/mcp-LKLESPJC.js +398 -0
- package/dist/mcp-app.html +180 -0
- package/package.json +3 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InputError,
|
|
3
|
+
applyDesignEdits,
|
|
4
|
+
badgeDesignObjectSchema,
|
|
5
|
+
badgeDesignSchema,
|
|
6
|
+
cardStyles,
|
|
7
|
+
designCatalog,
|
|
8
|
+
designEditsSchema,
|
|
9
|
+
draftFromPackage,
|
|
10
|
+
getSkill,
|
|
11
|
+
localSubmissionDraft,
|
|
12
|
+
packageJson,
|
|
13
|
+
readPackageJson,
|
|
14
|
+
renderSubmission,
|
|
15
|
+
stickerStyles,
|
|
16
|
+
version,
|
|
17
|
+
writeAtomic
|
|
18
|
+
} from "./chunk-WVXA65H6.js";
|
|
19
|
+
|
|
20
|
+
// src/mcp.ts
|
|
21
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
22
|
+
import {
|
|
23
|
+
getUiCapability,
|
|
24
|
+
RESOURCE_MIME_TYPE,
|
|
25
|
+
registerAppResource,
|
|
26
|
+
registerAppTool
|
|
27
|
+
} from "@modelcontextprotocol/ext-apps/server";
|
|
28
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
29
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
32
|
+
|
|
33
|
+
// src/mcp-documents.ts
|
|
34
|
+
import { createHash } from "crypto";
|
|
35
|
+
import { constants } from "fs";
|
|
36
|
+
import { lstat, open, readFile, realpath } from "fs/promises";
|
|
37
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from "path";
|
|
38
|
+
import lockfile from "proper-lockfile";
|
|
39
|
+
var maxBytes = 32e6;
|
|
40
|
+
var hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
41
|
+
var McpDocuments = class _McpDocuments {
|
|
42
|
+
constructor(root) {
|
|
43
|
+
this.root = root;
|
|
44
|
+
}
|
|
45
|
+
root;
|
|
46
|
+
queue = Promise.resolve();
|
|
47
|
+
static async open(root) {
|
|
48
|
+
const directory = await realpath(resolve(root));
|
|
49
|
+
if (!(await lstat(directory)).isDirectory())
|
|
50
|
+
throw new InputError("--root must be a directory.");
|
|
51
|
+
return new _McpDocuments(directory);
|
|
52
|
+
}
|
|
53
|
+
inside(path) {
|
|
54
|
+
const child = relative(this.root, path);
|
|
55
|
+
if (child === ".." || child.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(child))
|
|
56
|
+
throw new InputError("Choose a file inside the configured Makeables root.");
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
async path(file2, create = false) {
|
|
60
|
+
const path = this.inside(resolve(this.root, file2));
|
|
61
|
+
if (extname(path).toLowerCase() !== ".json")
|
|
62
|
+
throw new InputError("Use a design .json or portable .makeables.json file.");
|
|
63
|
+
const parent = this.inside(await realpath(dirname(path)));
|
|
64
|
+
const target = join(parent, relative(dirname(path), path));
|
|
65
|
+
try {
|
|
66
|
+
if ((await lstat(target)).isSymbolicLink())
|
|
67
|
+
throw new InputError("Design files must not be symbolic links.");
|
|
68
|
+
this.inside(await realpath(target));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!create || error.code !== "ENOENT") throw error;
|
|
71
|
+
}
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
async read(file2) {
|
|
75
|
+
const path = await this.path(file2);
|
|
76
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
77
|
+
let bytes;
|
|
78
|
+
try {
|
|
79
|
+
const info = await handle.stat();
|
|
80
|
+
if (!info.isFile() || info.size > maxBytes)
|
|
81
|
+
throw new InputError("Choose a regular JSON file below 32 MB.");
|
|
82
|
+
bytes = await handle.readFile();
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.close();
|
|
85
|
+
}
|
|
86
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
87
|
+
if (value && typeof value === "object" && "format" in value && value.format === "makeables-bundle")
|
|
88
|
+
throw new InputError(
|
|
89
|
+
"Export this legacy badge bundle as a portable .makeables.json package before editing it here."
|
|
90
|
+
);
|
|
91
|
+
const portable = Boolean(
|
|
92
|
+
value && typeof value === "object" && "format" in value && value.format === "makeables-package"
|
|
93
|
+
);
|
|
94
|
+
const pkg = portable ? await readPackageJson(value) : void 0;
|
|
95
|
+
const draft = pkg ? draftFromPackage(pkg) : await localSubmissionDraft(value);
|
|
96
|
+
if (draft.design.product !== "sticker" && draft.design.front.layers.some((layer) => layer.kind === "portrait") && (!draft.participant?.name || !draft.portrait))
|
|
97
|
+
throw new InputError(
|
|
98
|
+
"Include the user's name and portrait in a portable badge package. No example person is used."
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
file: relative(this.root, path),
|
|
102
|
+
path,
|
|
103
|
+
revision: hash(bytes),
|
|
104
|
+
draft,
|
|
105
|
+
...pkg ? { package: pkg } : {},
|
|
106
|
+
wrapped: Boolean(value && typeof value === "object" && "design" in value)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async preview(document, side2 = "front") {
|
|
110
|
+
if (side2 === "back" && document.draft.design.product === "sticker")
|
|
111
|
+
throw new InputError("A sticker has one editable front.");
|
|
112
|
+
const pkg = document.package ?? await renderSubmission(document.draft);
|
|
113
|
+
const path = side2 === "front" ? pkg.manifest.front : pkg.manifest.back;
|
|
114
|
+
if (!path) throw new InputError("This design has no back preview.");
|
|
115
|
+
return { png: Buffer.from(pkg.files[path]), package: pkg };
|
|
116
|
+
}
|
|
117
|
+
/** Serialize cooperating writes and compare revisions while holding the file lock. */
|
|
118
|
+
async edit(file2, expected, change, side2 = "front") {
|
|
119
|
+
const operation = this.queue.catch(() => {
|
|
120
|
+
}).then(async () => {
|
|
121
|
+
const path = await this.path(file2);
|
|
122
|
+
const release = await lockfile.lock(path, { realpath: false, retries: 0 });
|
|
123
|
+
try {
|
|
124
|
+
const current = await this.read(file2);
|
|
125
|
+
if (current.revision !== expected)
|
|
126
|
+
throw new InputError(
|
|
127
|
+
"STALE_REVISION: Inspect the file again before applying this edit."
|
|
128
|
+
);
|
|
129
|
+
if (side2 === "back" && current.draft.design.product === "sticker")
|
|
130
|
+
throw new InputError("A sticker has one editable front.");
|
|
131
|
+
const next = change.design ? badgeDesignSchema.parse(change.design) : applyDesignEdits(current.draft.design, change.edits);
|
|
132
|
+
if ((next.product ?? "badge") !== (current.draft.design.product ?? "badge"))
|
|
133
|
+
throw new InputError("Create a new file to change the product.");
|
|
134
|
+
const draft = { ...current.draft, design: next };
|
|
135
|
+
const pkg = await renderSubmission(draft);
|
|
136
|
+
const body = `${JSON.stringify(
|
|
137
|
+
current.package ? packageJson(pkg) : current.wrapped ? {
|
|
138
|
+
design: next,
|
|
139
|
+
...draft.participant ? { participant: draft.participant } : {}
|
|
140
|
+
} : next,
|
|
141
|
+
null,
|
|
142
|
+
2
|
|
143
|
+
)}
|
|
144
|
+
`;
|
|
145
|
+
if (hash(await readFile(path)) !== expected)
|
|
146
|
+
throw new InputError("STALE_REVISION: The file changed while preparing this edit.");
|
|
147
|
+
await writeAtomic(path, body);
|
|
148
|
+
return { ...current, revision: hash(body), draft, package: pkg };
|
|
149
|
+
} finally {
|
|
150
|
+
await release();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
this.queue = operation;
|
|
154
|
+
return operation;
|
|
155
|
+
}
|
|
156
|
+
async create(file2, product, style, design) {
|
|
157
|
+
const path = await this.path(file2, true);
|
|
158
|
+
const catalog = product === "card" ? cardStyles : product === "sticker" ? stickerStyles : designCatalog;
|
|
159
|
+
const preset = catalog.find((entry) => entry.source === style);
|
|
160
|
+
if (!design && !preset) throw new InputError("Unknown style. Inspect the catalog first.");
|
|
161
|
+
const doc = badgeDesignSchema.parse(design ?? (preset ? structuredClone(preset) : void 0));
|
|
162
|
+
if ((doc.product ?? "badge") !== product)
|
|
163
|
+
throw new InputError("The selected design must match the requested product.");
|
|
164
|
+
const draft = await localSubmissionDraft(doc);
|
|
165
|
+
const pkg = await renderSubmission(draft);
|
|
166
|
+
await writeAtomic(path, `${JSON.stringify(doc, null, 2)}
|
|
167
|
+
`, true);
|
|
168
|
+
const result = await this.read(file2);
|
|
169
|
+
return { ...result, package: pkg };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// src/mcp.ts
|
|
174
|
+
var makeablesAppUri = "ui://makeables/studio-v1.html";
|
|
175
|
+
var file = z.string().min(1).max(4096).describe("Design JSON path inside the configured root.");
|
|
176
|
+
var side = z.enum(["front", "back"]).default("front");
|
|
177
|
+
var revision = z.string().regex(/^[a-f0-9]{64}$/).describe("Revision from the latest preview or inspection.");
|
|
178
|
+
var ui = { resourceUri: makeablesAppUri, visibility: ["model", "app"] };
|
|
179
|
+
var readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
180
|
+
var write = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
181
|
+
async function createMakeablesMcp(root, html) {
|
|
182
|
+
const documents = await McpDocuments.open(root);
|
|
183
|
+
const server = new McpServer(
|
|
184
|
+
{ name: "makeables", version },
|
|
185
|
+
{
|
|
186
|
+
capabilities: { tools: {}, resources: {} },
|
|
187
|
+
instructions: "Create and edit local Makeables documents. Load the core guide with makeables_inspect(section: guide, guide: core), then the relevant product guide. Prefer the embedded app when available. Inspect before editing and use the returned revision. These tools never publish or open a browser. If UI is unavailable, show the PNG result; use the CLI or an available built-in browser for further work."
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
const supportsUi = () => Boolean(
|
|
191
|
+
getUiCapability(server.server.getClientCapabilities())?.mimeTypes?.includes(
|
|
192
|
+
RESOURCE_MIME_TYPE
|
|
193
|
+
)
|
|
194
|
+
);
|
|
195
|
+
async function result(document, face) {
|
|
196
|
+
const preview = await documents.preview(document, face);
|
|
197
|
+
const design = document.draft.design;
|
|
198
|
+
const data = {
|
|
199
|
+
file: document.file,
|
|
200
|
+
revision: document.revision,
|
|
201
|
+
name: design.name,
|
|
202
|
+
product: design.product ?? "badge",
|
|
203
|
+
side: face,
|
|
204
|
+
preview: design.product === "sticker" ? "material" : "flat artwork",
|
|
205
|
+
layers: { front: design.front.layers.length, back: design.back.layers.length },
|
|
206
|
+
surface: supportsUi() ? "mcp-app" : "image"
|
|
207
|
+
};
|
|
208
|
+
const png = preview.png.toString("base64");
|
|
209
|
+
return {
|
|
210
|
+
content: [
|
|
211
|
+
{ type: "text", text: JSON.stringify(data) },
|
|
212
|
+
...!supportsUi() ? [{ type: "image", mimeType: "image/png", data: png }] : []
|
|
213
|
+
],
|
|
214
|
+
structuredContent: data,
|
|
215
|
+
_meta: { makeables: { ...data, design, png } }
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function guarded(action) {
|
|
219
|
+
try {
|
|
220
|
+
return await action();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return {
|
|
223
|
+
isError: true,
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "text",
|
|
227
|
+
text: error instanceof Error ? error.message : "The operation failed. Inspect before retrying."
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
server.registerTool(
|
|
234
|
+
"makeables_inspect",
|
|
235
|
+
{
|
|
236
|
+
description: "Inspect a local design and its revision, or load the bundled guides, catalog and shared document/patch schema.",
|
|
237
|
+
inputSchema: {
|
|
238
|
+
section: z.enum(["document", "catalog", "schema", "guide"]).default("document"),
|
|
239
|
+
guide: z.enum(["core", "cards", "badges", "stickers", "design", "images", "wallet"]).optional(),
|
|
240
|
+
file: file.optional()
|
|
241
|
+
},
|
|
242
|
+
annotations: readOnly
|
|
243
|
+
},
|
|
244
|
+
({ section, file: path, guide }) => guarded(async () => {
|
|
245
|
+
let data;
|
|
246
|
+
if (section === "guide") {
|
|
247
|
+
const name = guide ?? "core";
|
|
248
|
+
const content = getSkill(name);
|
|
249
|
+
return {
|
|
250
|
+
content: [{ type: "text", text: content }],
|
|
251
|
+
structuredContent: { name, content }
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (section === "catalog")
|
|
255
|
+
data = {
|
|
256
|
+
styles: [...cardStyles, ...stickerStyles, ...designCatalog].map(
|
|
257
|
+
({ source, name, product }) => ({ id: source, name, product: product ?? "badge" })
|
|
258
|
+
)
|
|
259
|
+
};
|
|
260
|
+
else if (section === "schema")
|
|
261
|
+
data = {
|
|
262
|
+
design: zodToJsonSchema(badgeDesignObjectSchema),
|
|
263
|
+
edits: zodToJsonSchema(designEditsSchema)
|
|
264
|
+
};
|
|
265
|
+
else {
|
|
266
|
+
if (!path) throw new Error("Supply file for document inspection.");
|
|
267
|
+
const current = await documents.read(path);
|
|
268
|
+
data = { file: current.file, revision: current.revision, design: current.draft.design };
|
|
269
|
+
}
|
|
270
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
registerAppTool(
|
|
274
|
+
server,
|
|
275
|
+
"makeables_preview",
|
|
276
|
+
{
|
|
277
|
+
description: "Show a local design in the embedded Makeables widget, or return a PNG when the host has no MCP Apps support. Opens no browser.",
|
|
278
|
+
inputSchema: { file, side },
|
|
279
|
+
annotations: readOnly,
|
|
280
|
+
_meta: { ui }
|
|
281
|
+
},
|
|
282
|
+
({ file: file2, side: side2 }) => guarded(async () => result(await documents.read(file2), side2))
|
|
283
|
+
);
|
|
284
|
+
registerAppTool(
|
|
285
|
+
server,
|
|
286
|
+
"makeables_create",
|
|
287
|
+
{
|
|
288
|
+
description: "Create a new editable card or sticker file from a catalog style or supplied design. Existing files are never replaced. For a personalized badge, open a complete portable package containing the user's portrait and name.",
|
|
289
|
+
inputSchema: {
|
|
290
|
+
file,
|
|
291
|
+
product: z.enum(["card", "sticker"]),
|
|
292
|
+
style: z.string().default(""),
|
|
293
|
+
design: badgeDesignObjectSchema.optional()
|
|
294
|
+
},
|
|
295
|
+
annotations: write,
|
|
296
|
+
_meta: { ui }
|
|
297
|
+
},
|
|
298
|
+
({ file: file2, product, style, design }) => guarded(async () => result(await documents.create(file2, product, style, design), "front"))
|
|
299
|
+
);
|
|
300
|
+
registerAppTool(
|
|
301
|
+
server,
|
|
302
|
+
"makeables_edit",
|
|
303
|
+
{
|
|
304
|
+
description: "Apply the same material/layer patch as the website to a local design. The expectedRevision must match the latest inspection. Upserts are complete layers. Returns the updated widget or PNG.",
|
|
305
|
+
inputSchema: { file, expectedRevision: revision, edits: designEditsSchema, side },
|
|
306
|
+
annotations: write,
|
|
307
|
+
_meta: { ui }
|
|
308
|
+
},
|
|
309
|
+
({ file: file2, expectedRevision, edits, side: side2 }) => guarded(
|
|
310
|
+
async () => result(await documents.edit(file2, expectedRevision, { edits }, side2), side2)
|
|
311
|
+
)
|
|
312
|
+
);
|
|
313
|
+
registerAppTool(
|
|
314
|
+
server,
|
|
315
|
+
"makeables_replace",
|
|
316
|
+
{
|
|
317
|
+
description: "Replace a local design's full editable document with a validated document, preserving portable image assets. Requires the latest revision and the same product.",
|
|
318
|
+
inputSchema: { file, expectedRevision: revision, design: badgeDesignObjectSchema, side },
|
|
319
|
+
annotations: write,
|
|
320
|
+
_meta: { ui }
|
|
321
|
+
},
|
|
322
|
+
({ file: file2, expectedRevision, design, side: side2 }) => guarded(
|
|
323
|
+
async () => result(await documents.edit(file2, expectedRevision, { design }, side2), side2)
|
|
324
|
+
)
|
|
325
|
+
);
|
|
326
|
+
server.registerTool(
|
|
327
|
+
"makeables_export",
|
|
328
|
+
{
|
|
329
|
+
description: "Prepare the current PNG, editable JSON or complete portable package for download. The widget can use the host's download capability; the CLI can also export the same document. Does not publish.",
|
|
330
|
+
inputSchema: {
|
|
331
|
+
file,
|
|
332
|
+
expectedRevision: revision,
|
|
333
|
+
format: z.enum(["png", "json", "package"]),
|
|
334
|
+
side
|
|
335
|
+
},
|
|
336
|
+
annotations: readOnly,
|
|
337
|
+
_meta: { ui: { visibility: ["app", "model"] } }
|
|
338
|
+
},
|
|
339
|
+
({ file: file2, expectedRevision, format, side: side2 }) => guarded(async () => {
|
|
340
|
+
const current = await documents.read(file2);
|
|
341
|
+
if (current.revision !== expectedRevision)
|
|
342
|
+
throw new Error("STALE_REVISION: Refresh the preview before exporting.");
|
|
343
|
+
const preview = await documents.preview(current, side2);
|
|
344
|
+
const bytes = format === "png" ? preview.png : Buffer.from(
|
|
345
|
+
JSON.stringify(
|
|
346
|
+
format === "package" ? packageJson(preview.package) : current.draft.design,
|
|
347
|
+
null,
|
|
348
|
+
2
|
|
349
|
+
)
|
|
350
|
+
);
|
|
351
|
+
const stem = current.file.split(/[\\/]/).pop()?.replace(/(?:\.makeables)?\.json$/i, "") ?? "design";
|
|
352
|
+
const name = `${stem}${format === "png" ? `-${side2}.png` : format === "package" ? ".makeables.json" : ".json"}`;
|
|
353
|
+
const data = {
|
|
354
|
+
name,
|
|
355
|
+
mimeType: format === "png" ? "image/png" : "application/json",
|
|
356
|
+
bytes: bytes.length
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
360
|
+
structuredContent: data,
|
|
361
|
+
_meta: { download: { ...data, blob: bytes.toString("base64") } }
|
|
362
|
+
};
|
|
363
|
+
})
|
|
364
|
+
);
|
|
365
|
+
registerAppResource(
|
|
366
|
+
server,
|
|
367
|
+
"Makeables Studio",
|
|
368
|
+
makeablesAppUri,
|
|
369
|
+
{
|
|
370
|
+
_meta: { ui: { prefersBorder: true, csp: { connectDomains: [], resourceDomains: [] } } }
|
|
371
|
+
},
|
|
372
|
+
async () => ({
|
|
373
|
+
contents: [
|
|
374
|
+
{
|
|
375
|
+
uri: makeablesAppUri,
|
|
376
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
377
|
+
text: html ?? await readFile2(new URL("./mcp-app.html", import.meta.url), "utf8"),
|
|
378
|
+
_meta: { ui: { prefersBorder: true, csp: { connectDomains: [], resourceDomains: [] } } }
|
|
379
|
+
}
|
|
380
|
+
]
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
return server;
|
|
384
|
+
}
|
|
385
|
+
async function runMcp(root) {
|
|
386
|
+
const server = await createMakeablesMcp(root);
|
|
387
|
+
await server.connect(new StdioServerTransport());
|
|
388
|
+
const stop = () => {
|
|
389
|
+
void server.close();
|
|
390
|
+
};
|
|
391
|
+
process.once("SIGINT", stop);
|
|
392
|
+
process.once("SIGTERM", stop);
|
|
393
|
+
}
|
|
394
|
+
export {
|
|
395
|
+
createMakeablesMcp,
|
|
396
|
+
makeablesAppUri,
|
|
397
|
+
runMcp
|
|
398
|
+
};
|