carouselbot 0.2.0 → 0.3.1
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 +23 -3
- package/guidance/design.md +1 -0
- package/package.json +2 -1
- package/skill/carouselbot/SKILL.md +17 -4
- package/src/companion.mjs +159 -44
- package/src/config.mjs +20 -0
- package/src/daemon.mjs +200 -12
- package/src/local-fonts.mjs +574 -0
- package/src/mcp-server.mjs +31 -11
- package/src/setup.mjs +5 -4
package/src/mcp-server.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import { EDITOR_URL, GUIDANCE_PATH, PACKAGE_NAME, PACKAGE_VERSION } from "./conf
|
|
|
6
6
|
|
|
7
7
|
const id = z.string().min(1).max(160);
|
|
8
8
|
const optionalId = id.optional();
|
|
9
|
+
const folderPath = z.string().min(2).max(160)
|
|
10
|
+
.regex(/^\/(?!\/)\S(?:[\s\S]*\S)?$/, "Use a canonical folder path with one leading slash and no surrounding whitespace, for example /my-folder.")
|
|
11
|
+
.refine((value) => value !== "/." && value !== "/..", "Folder paths cannot use the reserved names /. or /..");
|
|
9
12
|
const color = z.string().regex(/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, "Use a 3- or 6-digit hex color.");
|
|
10
13
|
const unit = z.number().min(-0.5).max(1.5);
|
|
11
14
|
const positiveUnit = z.number().min(0.01).max(2.4);
|
|
@@ -13,12 +16,17 @@ const expectedRevision = z.number().int().min(0).optional().describe("Optional o
|
|
|
13
16
|
const editSessionId = optionalId.describe("Edit session from begin_edit_session. Required for coordinated parallel editing.");
|
|
14
17
|
const targetProject = { editSessionId, projectId: optionalId, expectedRevision };
|
|
15
18
|
const targetSlide = { editSessionId, projectId: optionalId, slideId: optionalId, expectedRevision };
|
|
19
|
+
const fontId = z.union([id, z.null()]).optional().describe("Project font ID returned by import_font. Use null to restore the built-in font.");
|
|
16
20
|
const textFields = {
|
|
17
21
|
text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
|
|
18
22
|
role: z.enum(["title", "subtitle", "body", "caption"]).optional().describe("Semantic size role. Recommended ranges: title 92-124, subtitle 68-84, body 54-68, caption 44-52."),
|
|
19
23
|
size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
|
|
20
24
|
outlineWidth: z.number().min(0).max(40).optional(), color: color.optional(), background: z.enum(["white", "black"]).optional(),
|
|
21
25
|
backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
|
|
26
|
+
fontId,
|
|
27
|
+
fontWeight: z.number().int().min(1).max(1000).optional(),
|
|
28
|
+
fontStyle: z.enum(["normal", "italic"]).optional(),
|
|
29
|
+
fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional(),
|
|
22
30
|
rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
|
|
23
31
|
};
|
|
24
32
|
const imageFields = {
|
|
@@ -35,8 +43,11 @@ function textResult(value, summary = value) {
|
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
function compactMutation(value) {
|
|
38
|
-
const keys = ["id", "editSessionId", "editorId", "projectId", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
|
|
39
|
-
return Object.fromEntries(keys.flatMap((key) =>
|
|
46
|
+
const keys = ["id", "editSessionId", "editorId", "projectId", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
|
|
47
|
+
return Object.fromEntries(keys.flatMap((key) => {
|
|
48
|
+
if (key === "folderPath" && Object.hasOwn(value || {}, key)) return [[key, value[key] ?? null]];
|
|
49
|
+
return value?.[key] == null ? [] : [[key, value[key]]];
|
|
50
|
+
}));
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
function clientIdentity(context, server) {
|
|
@@ -53,10 +64,10 @@ async function pathExists(value) {
|
|
|
53
64
|
|
|
54
65
|
function operationLabel(toolName) {
|
|
55
66
|
return ({
|
|
56
|
-
create_project: "Creating a project…", update_project: "Updating the project…", delete_project: "Deleting a project…",
|
|
67
|
+
create_project: "Creating a project…", update_project: "Updating the project…", move_project: "Moving the project…", delete_project: "Deleting a project…",
|
|
57
68
|
open_project: "Opening a project…", add_slide: "Adding a slide…", update_slide: "Updating a slide…",
|
|
58
69
|
duplicate_slide: "Duplicating a slide…", reorder_slides: "Reordering slides…", delete_slide: "Deleting a slide…",
|
|
59
|
-
add_text: "Adding text…", update_text: "Updating text…", fit_text_boxes: "Fitting text boxes…", import_asset: "Importing a local image…",
|
|
70
|
+
add_text: "Adding text…", update_text: "Updating text…", fit_text_boxes: "Fitting text boxes…", import_font: "Adding a local font…", import_asset: "Importing a local image…",
|
|
60
71
|
update_asset: "Updating an image asset…", delete_asset: "Deleting an image asset…", add_image: "Placing an image…",
|
|
61
72
|
update_image: "Updating an image…", delete_layers: "Deleting layers…", duplicate_layers: "Duplicating layers…",
|
|
62
73
|
reorder_layers: "Reordering layers…", undo: "Undoing the last edit…", redo: "Redoing the last edit…",
|
|
@@ -67,11 +78,11 @@ function operationLabel(toolName) {
|
|
|
67
78
|
async function browserOperation(companion, toolName, args) {
|
|
68
79
|
const { editSessionId: sessionId, ...toolArgs } = args;
|
|
69
80
|
const definition = definitions.get(toolName);
|
|
70
|
-
const operation = await prepareOperation(companion, toolName, toolArgs);
|
|
81
|
+
const operation = await prepareOperation(companion, toolName, toolArgs, sessionId);
|
|
71
82
|
return companion.call("browser", { toolName, operation, label: operationLabel(toolName), editSessionId: sessionId, mutating: Boolean(definition?.mutating) });
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
async function prepareOperation(companion, toolName, args) {
|
|
85
|
+
async function prepareOperation(companion, toolName, args, editSessionId = null) {
|
|
75
86
|
const operation = { ...args };
|
|
76
87
|
if (operation.backgroundPath) {
|
|
77
88
|
const prepared = await companion.call("prepare_media", { path: absolutePath(operation.backgroundPath) });
|
|
@@ -83,10 +94,15 @@ async function prepareOperation(companion, toolName, args) {
|
|
|
83
94
|
operation.mediaId = prepared.mediaId;
|
|
84
95
|
delete operation.path;
|
|
85
96
|
}
|
|
97
|
+
if (toolName === "import_font") {
|
|
98
|
+
const prepared = await companion.call("prepare_font", { localFontId: operation.localFontId, editSessionId });
|
|
99
|
+
operation.font = prepared.font;
|
|
100
|
+
operation.fontMediaId = prepared.fontMediaId;
|
|
101
|
+
}
|
|
86
102
|
const type = ({
|
|
87
|
-
create_project: "project.create", open_project: "project.open", update_project: "project.update", delete_project: "project.delete",
|
|
103
|
+
create_project: "project.create", open_project: "project.open", update_project: "project.update", move_project: "project.move", delete_project: "project.delete",
|
|
88
104
|
add_slide: "slide.add", update_slide: "slide.update", duplicate_slide: "slide.duplicate", reorder_slides: "slide.reorder", delete_slide: "slide.delete",
|
|
89
|
-
add_text: "text.add", update_text: "text.update", fit_text_boxes: "text.fit", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
|
|
105
|
+
add_text: "text.add", update_text: "text.update", fit_text_boxes: "text.fit", import_font: "font.import", list_project_fonts: "font.list", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
|
|
90
106
|
add_image: "image.add", update_image: "image.update", delete_layers: "layer.delete", duplicate_layers: "layer.duplicate", reorder_layers: "layer.reorder",
|
|
91
107
|
undo: "history.undo", redo: "history.redo", set_view: "view.update", render_slide: "slide.render", inspect_editor: "editor.inspect",
|
|
92
108
|
})[toolName];
|
|
@@ -99,7 +115,7 @@ export async function createCarouselBotMcpServer(companion) {
|
|
|
99
115
|
let guidanceRead = false;
|
|
100
116
|
let identifiedAs = null;
|
|
101
117
|
const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, {
|
|
102
|
-
instructions: `First call list_editors and use the registered local browser tab. Never open or connect CarouselBot through a sandboxed agent browser. If no editor is listed, retry briefly because browser reconnection is automatic, then ask the user to open ${EDITOR_URL} in their normal browser and click Connect AI.
|
|
118
|
+
instructions: `First call list_editors and use the registered local browser tab. Never open or connect CarouselBot through a sandboxed agent browser. If no editor is listed, retry briefly because browser reconnection is automatic, then ask the user to open ${EDITOR_URL} in their normal browser and click Connect AI. Companion compatibility and reconnects are automatic. Do not parallel-retry an action that reports an unsupported internal action; retry once after list_editors so automatic recovery can finish. Never restart a healthy companion for a transient editor disconnect. Before edits call get_design_guidance, then begin_edit_session; pass editSessionId to every edit and end it in cleanup. Parallel editing workers require distinct editor sessions. Use render_slide to inspect actual pixels.`,
|
|
103
119
|
capabilities: { tools: {}, resources: {} },
|
|
104
120
|
});
|
|
105
121
|
|
|
@@ -151,11 +167,14 @@ export async function createCarouselBotMcpServer(companion) {
|
|
|
151
167
|
register("list_edit_sessions", "List active edit reservations, their owners, projects, and lease expirations.", z.object({}).strict(), () => companion.call("list_edit_sessions"), { readOnlyHint: true });
|
|
152
168
|
register("list_recent_operations", "Read the local sanitized operation audit. Text, prompts, paths, and image bytes are never logged.", z.object({ limit: z.number().int().min(1).max(200).default(50), projectId: optionalId, status: z.enum(["started", "ok", "error", "blocked"]).optional() }).strict(), (args) => companion.call("list_recent_operations", args), { readOnlyHint: true });
|
|
153
169
|
register("inspect_editor", "Inspect projects, slides, assets, and every text/image layer without returning image bytes.", z.object({ ...targetSlide, includeAllProjects: z.boolean().default(true) }).strict(), (args) => browserOperation(companion, "inspect_editor", args), { readOnlyHint: true });
|
|
170
|
+
register("list_local_fonts", "Search fonts installed on this computer. Returns opaque local font IDs and never filesystem paths. The user must enable local fonts in CarouselBot first.", z.object({ editSessionId, query: z.string().max(200).optional(), limit: z.number().int().min(1).max(200).default(80), cursor: z.string().max(2048).optional(), sort: z.enum(["recent_then_alphabetical", "alphabetical"]).default("recent_then_alphabetical") }).strict(), (args) => companion.call("list_local_fonts", args), { readOnlyHint: true });
|
|
171
|
+
register("list_project_fonts", "List fonts already imported into one project, including whether each face is currently available.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "list_project_fonts", args), { readOnlyHint: true });
|
|
154
172
|
register("show_notification", "Show a short visual notification in a connected editor for status or marketing demos.", z.object({ editSessionId, message: z.string().min(1).max(240), tone: z.enum(["agent", "success", "info", "error"]).default("agent") }).strict(), ({ editSessionId, ...args }) => companion.call("notify", { ...args, editSessionId }), { destructiveHint: false, idempotentHint: false });
|
|
155
173
|
|
|
156
|
-
register("create_project", "Create an empty project without changing the user's current browser view.
|
|
174
|
+
register("create_project", "Create an empty project without changing the user's current browser view. Pass a canonical folderPath such as /my-folder to create it inside that folder; omit it or use null for the dashboard root.", z.object({ editSessionId, name: z.string().min(1).max(160), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
|
|
157
175
|
register("open_project", "Explicitly navigate the browser to a project and optionally a specific slide without changing content. Use only when the user asks to show it.", z.object({ editSessionId, projectId: id, slideId: optionalId }).strict(), (args) => browserOperation(companion, "open_project", args), { destructiveHint: false, idempotentHint: true });
|
|
158
176
|
register("update_project", "Rename a project.", z.object({ ...targetProject, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_project", args), { destructiveHint: true });
|
|
177
|
+
register("move_project", "Move a project into a folder by canonical slash path, move it between folders, or move it back to the dashboard root with folderPath=null. Folder cards are derived from project membership, so empty folders disappear.", z.object({ ...targetProject, projectId: id, folderPath: folderPath.nullable() }).strict(), (args) => browserOperation(companion, "move_project", args), { destructiveHint: true });
|
|
159
178
|
register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
|
|
160
179
|
|
|
161
180
|
register("add_slide", "Add a slide using a solid color or local background image path. The browser follows it only when that project is already visible.", z.object({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }).strict(), (args) => browserOperation(companion, "add_slide", args), { destructiveHint: false });
|
|
@@ -167,6 +186,7 @@ export async function createCarouselBotMcpServer(companion) {
|
|
|
167
186
|
register("add_text", "Add a text layer. Choose a semantic role and a size within its readable range. Width is preserved while height is fitted automatically with safe padding; boxed text defaults to the preferred per-line background.", z.object({ ...targetSlide, ...textFields, text: z.string().min(1).max(4000) }).strict(), (args) => browserOperation(companion, "add_text", args), { destructiveHint: false });
|
|
168
187
|
register("update_text", "Update one or more text layers. Every updated layer automatically keeps its width and refits its height with safe padding, so a render-fit-render loop is unnecessary.", z.object({ ...targetSlide, updates: z.array(z.object({ id, ...textFields }).strict()).min(1).max(100) }).strict(), (args) => browserOperation(companion, "update_text", args), { destructiveHint: true });
|
|
169
188
|
register("fit_text_boxes", "Explicitly resize text boxes to their rendered content. add_text and update_text already fit height automatically; use mode=both only when you also want to shrink width.", z.object({ ...targetSlide, textIds: z.array(id).min(1).max(100), mode: z.enum(["height", "both"]).default("both") }).strict(), (args) => browserOperation(companion, "fit_text_boxes", args), { destructiveHint: true });
|
|
189
|
+
register("import_font", "Add one installed font face to a project using a localFontId returned by list_local_fonts. Exact face bytes remain on this computer and duplicate imports are reused.", z.object({ ...targetProject, projectId: id, localFontId: id }).strict(), (args) => browserOperation(companion, "import_font", args), { destructiveHint: false });
|
|
170
190
|
|
|
171
191
|
register("import_asset", "Import a local image file into the active project's reusable asset library. Image bytes stay local.", z.object({ ...targetSlide, path: z.string().min(1), name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "import_asset", args), { destructiveHint: false });
|
|
172
192
|
register("update_asset", "Rename a reusable image asset.", z.object({ ...targetProject, assetId: id, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_asset", args), { destructiveHint: true });
|
|
@@ -220,7 +240,7 @@ export async function createCarouselBotMcpServer(companion) {
|
|
|
220
240
|
if (!definition?.mutating) throw new Error(`Tool cannot be batched: ${item.tool}`);
|
|
221
241
|
const args = definition.inputSchema.parse(item.arguments);
|
|
222
242
|
const { editSessionId: _ignored, ...toolArgs } = args;
|
|
223
|
-
items.push({ toolName: item.tool, operation: await prepareOperation(companion, item.tool, toolArgs), label: operationLabel(item.tool) });
|
|
243
|
+
items.push({ toolName: item.tool, operation: await prepareOperation(companion, item.tool, toolArgs, sessionId), label: operationLabel(item.tool) });
|
|
224
244
|
}
|
|
225
245
|
return companion.call("batch", { items, editSessionId: sessionId });
|
|
226
246
|
}, { destructiveHint: true });
|
package/src/setup.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { EDITOR_URL, PACKAGE_NAME, PACKAGE_ROOT, PACKAGE_VERSION } from "./config.mjs";
|
|
7
|
+
import { companionUpgrade } from "./companion.mjs";
|
|
7
8
|
|
|
8
9
|
const supported = ["claude", "codex", "hermes", "opencode", "openclaw"];
|
|
9
10
|
const serverName = "carouselbot";
|
|
@@ -62,8 +63,7 @@ export async function runSetup(arguments_) {
|
|
|
62
63
|
const clientArgument = arguments_.find((value) => value.startsWith("--client="))?.slice("--client=".length);
|
|
63
64
|
const requested = clientArgument ? clientArgument.split(",").map((value) => value.trim().toLowerCase()) : supported.filter(commandExists);
|
|
64
65
|
const clients = [...new Set(requested)].filter((client) => supported.includes(client));
|
|
65
|
-
const
|
|
66
|
-
const specifier = `${PACKAGE_NAME}@${releaseTag}`;
|
|
66
|
+
const specifier = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
|
|
67
67
|
const dryRun = flags.has("--dry-run");
|
|
68
68
|
const assumeYes = flags.has("--yes") || flags.has("-y");
|
|
69
69
|
if (!clients.length) throw new Error("No supported agent CLI was detected. Use --client=claude,codex,hermes,opencode,openclaw or copy the generic stdio config below.");
|
|
@@ -99,9 +99,10 @@ export async function runSetup(arguments_) {
|
|
|
99
99
|
else process.stderr.write(`Could not configure ${client}; its command is printed above for manual setup.\n`);
|
|
100
100
|
}
|
|
101
101
|
const skillTargets = await installSkill();
|
|
102
|
-
|
|
102
|
+
const companion = await companionUpgrade();
|
|
103
|
+
process.stdout.write(`\nConfigured: ${configured.join(", ") || "none automatically"}\nSkill installed in:\n${skillTargets.map((value) => ` ${value}`).join("\n")}\nCompanion: ${companion.version} (${companion.upgraded ? "upgraded automatically" : "already current"})\n\nOpen ${EDITOR_URL} in your normal local browser and click Connect AI. Do not use a sandboxed agent browser.\n`);
|
|
103
104
|
process.stdout.write(`First connection check (no browser automation): npx -y ${specifier} call list_editors\n`);
|
|
104
|
-
if (clients.includes("hermes")) process.stdout.write("
|
|
105
|
+
if (clients.includes("hermes")) process.stdout.write("The companion and browser reconnect automatically. Hermes only needs /reload-mcp when adding entirely new native tool names to an already-running session; the CLI fallback remains available immediately.\n");
|
|
105
106
|
if (clients.includes("claude")) process.stdout.write("Claude may require a new session for native MCP registration; use the CLI fallback immediately instead of stopping.\n");
|
|
106
107
|
if (clients.includes("opencode")) process.stdout.write("OpenCode currently uses its JSON config; merge the snippet printed above into opencode.json.\n");
|
|
107
108
|
return { clients, configured, skillTargets };
|