carouselbot 0.2.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 +62 -0
- package/guidance/design.md +49 -0
- package/package.json +37 -0
- package/skill/carouselbot/SKILL.md +53 -0
- package/src/agent-identity.mjs +49 -0
- package/src/call.mjs +124 -0
- package/src/cli.mjs +48 -0
- package/src/companion.mjs +140 -0
- package/src/config.mjs +40 -0
- package/src/daemon.mjs +717 -0
- package/src/mcp-server.mjs +231 -0
- package/src/setup.mjs +108 -0
- package/src/stdio-server.mjs +24 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
+
import * as z from "zod/v4";
|
|
5
|
+
import { EDITOR_URL, GUIDANCE_PATH, PACKAGE_NAME, PACKAGE_VERSION } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const id = z.string().min(1).max(160);
|
|
8
|
+
const optionalId = id.optional();
|
|
9
|
+
const color = z.string().regex(/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, "Use a 3- or 6-digit hex color.");
|
|
10
|
+
const unit = z.number().min(-0.5).max(1.5);
|
|
11
|
+
const positiveUnit = z.number().min(0.01).max(2.4);
|
|
12
|
+
const expectedRevision = z.number().int().min(0).optional().describe("Optional optimistic-concurrency guard from inspect_editor.");
|
|
13
|
+
const editSessionId = optionalId.describe("Edit session from begin_edit_session. Required for coordinated parallel editing.");
|
|
14
|
+
const targetProject = { editSessionId, projectId: optionalId, expectedRevision };
|
|
15
|
+
const targetSlide = { editSessionId, projectId: optionalId, slideId: optionalId, expectedRevision };
|
|
16
|
+
const textFields = {
|
|
17
|
+
text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
|
|
18
|
+
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
|
+
size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
|
|
20
|
+
outlineWidth: z.number().min(0).max(40).optional(), color: color.optional(), background: z.enum(["white", "black"]).optional(),
|
|
21
|
+
backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
|
|
22
|
+
rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
|
|
23
|
+
};
|
|
24
|
+
const imageFields = {
|
|
25
|
+
x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
|
|
26
|
+
rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
|
|
27
|
+
cropX: z.number().min(0).max(0.95).optional(), cropY: z.number().min(0).max(0.95).optional(),
|
|
28
|
+
cropW: z.number().min(0.05).max(1).optional(), cropH: z.number().min(0.05).max(1).optional(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const definitions = new Map();
|
|
32
|
+
|
|
33
|
+
function textResult(value, summary = value) {
|
|
34
|
+
return { content: [{ type: "text", text: typeof summary === "string" ? summary : JSON.stringify(summary) }], structuredContent: value };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
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) => value?.[key] == null ? [] : [[key, value[key]]]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clientIdentity(context, server) {
|
|
43
|
+
return context?.mcpReq?.envelope?.clientInfo || server.server.getClientVersion?.() || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function absolutePath(value) {
|
|
47
|
+
return isAbsolute(value) ? value : resolve(process.cwd(), value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function pathExists(value) {
|
|
51
|
+
try { await stat(value); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function operationLabel(toolName) {
|
|
55
|
+
return ({
|
|
56
|
+
create_project: "Creating a project…", update_project: "Updating the project…", delete_project: "Deleting a project…",
|
|
57
|
+
open_project: "Opening a project…", add_slide: "Adding a slide…", update_slide: "Updating a slide…",
|
|
58
|
+
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…",
|
|
60
|
+
update_asset: "Updating an image asset…", delete_asset: "Deleting an image asset…", add_image: "Placing an image…",
|
|
61
|
+
update_image: "Updating an image…", delete_layers: "Deleting layers…", duplicate_layers: "Duplicating layers…",
|
|
62
|
+
reorder_layers: "Reordering layers…", undo: "Undoing the last edit…", redo: "Redoing the last edit…",
|
|
63
|
+
set_view: "Updating the editor view…", render_slide: "Rendering the slide…",
|
|
64
|
+
})[toolName] || "Editing in CarouselBot…";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function browserOperation(companion, toolName, args) {
|
|
68
|
+
const { editSessionId: sessionId, ...toolArgs } = args;
|
|
69
|
+
const definition = definitions.get(toolName);
|
|
70
|
+
const operation = await prepareOperation(companion, toolName, toolArgs);
|
|
71
|
+
return companion.call("browser", { toolName, operation, label: operationLabel(toolName), editSessionId: sessionId, mutating: Boolean(definition?.mutating) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function prepareOperation(companion, toolName, args) {
|
|
75
|
+
const operation = { ...args };
|
|
76
|
+
if (operation.backgroundPath) {
|
|
77
|
+
const prepared = await companion.call("prepare_media", { path: absolutePath(operation.backgroundPath) });
|
|
78
|
+
operation.mediaId = prepared.mediaId;
|
|
79
|
+
delete operation.backgroundPath;
|
|
80
|
+
}
|
|
81
|
+
if (toolName === "import_asset") {
|
|
82
|
+
const prepared = await companion.call("prepare_media", { path: absolutePath(operation.path) });
|
|
83
|
+
operation.mediaId = prepared.mediaId;
|
|
84
|
+
delete operation.path;
|
|
85
|
+
}
|
|
86
|
+
const type = ({
|
|
87
|
+
create_project: "project.create", open_project: "project.open", update_project: "project.update", delete_project: "project.delete",
|
|
88
|
+
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",
|
|
90
|
+
add_image: "image.add", update_image: "image.update", delete_layers: "layer.delete", duplicate_layers: "layer.duplicate", reorder_layers: "layer.reorder",
|
|
91
|
+
undo: "history.undo", redo: "history.redo", set_view: "view.update", render_slide: "slide.render", inspect_editor: "editor.inspect",
|
|
92
|
+
})[toolName];
|
|
93
|
+
if (!type) throw new Error(`Unsupported operation tool: ${toolName}`);
|
|
94
|
+
return { type, ...operation };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function createCarouselBotMcpServer(companion) {
|
|
98
|
+
const guidance = await readFile(GUIDANCE_PATH, "utf8");
|
|
99
|
+
let guidanceRead = false;
|
|
100
|
+
let identifiedAs = null;
|
|
101
|
+
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. Never restart a healthy companion for a transient editor disconnect; restart only for an explicit protocol mismatch or failed daemon health check. 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
|
+
capabilities: { tools: {}, resources: {} },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
async function identify(context) {
|
|
107
|
+
const info = clientIdentity(context, server);
|
|
108
|
+
const signature = info ? `${info.name || "MCP agent"}@${info.version || "unknown"}` : null;
|
|
109
|
+
if (signature && signature !== identifiedAs) {
|
|
110
|
+
identifiedAs = signature;
|
|
111
|
+
await companion.identify(info.name, info.version);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function register(name, description, inputSchema, handler, annotations = {}) {
|
|
116
|
+
const normalizedAnnotations = { openWorldHint: false, ...annotations };
|
|
117
|
+
const guidanceExempt = new Set(["select_editor", "begin_edit_session", "end_edit_session", "open_project", "set_view", "show_notification"]);
|
|
118
|
+
definitions.set(name, { inputSchema, handler, mutating: !normalizedAnnotations.readOnlyHint && !guidanceExempt.has(name) });
|
|
119
|
+
server.registerTool(name, { title: name.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "), description, inputSchema, annotations: normalizedAnnotations }, async (args, context) => {
|
|
120
|
+
await identify(context);
|
|
121
|
+
if (definitions.get(name).mutating && !guidanceRead) throw new Error("Call get_design_guidance before changing slides. This one-time step prevents avoidable clipping and unattractive defaults.");
|
|
122
|
+
const value = await handler(args, context);
|
|
123
|
+
if (value?.__rawMcpResult) {
|
|
124
|
+
const { __rawMcpResult, ...result } = value;
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
return textResult(value, annotations.readOnlyHint ? value : compactMutation(value));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const readGuidanceResource = async (uri) => {
|
|
132
|
+
guidanceRead = true;
|
|
133
|
+
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: guidance }] };
|
|
134
|
+
};
|
|
135
|
+
server.registerResource("carouselbot-design-guidance", "carouselbot://guidance/design", {
|
|
136
|
+
title: "CarouselBot design guidance", description: "Required visual-quality and text-box safety guidance.", mimeType: "text/markdown",
|
|
137
|
+
}, readGuidanceResource);
|
|
138
|
+
server.registerResource("slide-studio-design-guidance", "slide-studio://guidance/design", {
|
|
139
|
+
title: "CarouselBot design guidance (legacy URI)", description: "Backward-compatible alias for CarouselBot design guidance.", mimeType: "text/markdown",
|
|
140
|
+
}, readGuidanceResource);
|
|
141
|
+
|
|
142
|
+
register("get_design_guidance", "Read the required compact design and clipping guidance. Call once before any mutation.", z.object({}).strict(), async () => {
|
|
143
|
+
guidanceRead = true;
|
|
144
|
+
return { __rawMcpResult: true, content: [{ type: "text", text: guidance }], structuredContent: { read: true } };
|
|
145
|
+
}, { readOnlyHint: true, idempotentHint: true });
|
|
146
|
+
|
|
147
|
+
register("list_editors", "Check the user's real local browser connection and show which registered CarouselBot tab this session targets. Call this instead of opening a sandboxed browser.", z.object({}).strict(), () => companion.call("list_editors"), { readOnlyHint: true });
|
|
148
|
+
register("select_editor", "Select a connected browser tab for this MCP session.", z.object({ editorId: id }).strict(), ({ editorId }) => companion.call("select_editor", { editorId }), { destructiveHint: false, idempotentHint: true });
|
|
149
|
+
register("begin_edit_session", "Atomically reserve one browser tab and optionally one project for an editing agent. Use one session per parallel editing worker and pass editSessionId to every edit.", z.object({ editorId: optionalId, projectId: optionalId, purpose: z.string().min(1).max(160).optional() }).strict(), (args) => companion.call("begin_edit_session", args), { destructiveHint: false });
|
|
150
|
+
register("end_edit_session", "Release a browser-tab/project reservation as soon as an editing task finishes or fails.", z.object({ editSessionId: id }).strict(), (args) => companion.call("end_edit_session", args), { destructiveHint: false, idempotentHint: true });
|
|
151
|
+
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
|
+
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
|
+
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 });
|
|
154
|
+
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
|
+
|
|
156
|
+
register("create_project", "Create an empty project without changing the user's current browser view. Its dashboard card appears live when the dashboard is open.", z.object({ editSessionId, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
|
|
157
|
+
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
|
+
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 });
|
|
159
|
+
register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
|
|
160
|
+
|
|
161
|
+
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 });
|
|
162
|
+
register("update_slide", "Rename a slide, replace its background, or change background pan/zoom. The browser follows it only when that project is already visible.", z.object({ ...targetSlide, name: z.string().max(160).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional(), imageScale: z.number().min(1).max(3).optional(), imageX: unit.optional(), imageY: unit.optional() }).strict(), (args) => browserOperation(companion, "update_slide", args), { destructiveHint: true });
|
|
163
|
+
register("duplicate_slide", "Duplicate a slide with all layers. The browser follows the copy only when that project is already visible.", z.object({ ...targetSlide, name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "duplicate_slide", args), { destructiveHint: false });
|
|
164
|
+
register("reorder_slides", "Set the complete slide order using every slide ID exactly once.", z.object({ ...targetProject, slideIds: z.array(id).min(1) }).strict(), (args) => browserOperation(companion, "reorder_slides", args), { destructiveHint: true });
|
|
165
|
+
register("delete_slide", "Delete one slide.", z.object({ ...targetSlide, slideId: id }).strict(), (args) => browserOperation(companion, "delete_slide", args), { destructiveHint: true });
|
|
166
|
+
|
|
167
|
+
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
|
+
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
|
+
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 });
|
|
170
|
+
|
|
171
|
+
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
|
+
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 });
|
|
173
|
+
register("delete_asset", "Delete an asset and every placed instance that references it.", z.object({ ...targetProject, assetId: id }).strict(), (args) => browserOperation(companion, "delete_asset", args), { destructiveHint: true });
|
|
174
|
+
register("add_image", "Place an imported asset as an image layer and optionally set geometry, crop, rotation, and stacking.", z.object({ ...targetSlide, assetId: id, ...imageFields }).strict(), (args) => browserOperation(companion, "add_image", args), { destructiveHint: false });
|
|
175
|
+
register("update_image", "Update one or more placed image layers, including geometry, crop, rotation, and stacking.", z.object({ ...targetSlide, updates: z.array(z.object({ id, ...imageFields }).strict()).min(1).max(100) }).strict(), (args) => browserOperation(companion, "update_image", args), { destructiveHint: true });
|
|
176
|
+
|
|
177
|
+
register("delete_layers", "Delete text and/or image layers by ID.", z.object({ ...targetSlide, layerIds: z.array(id).min(1).max(200) }).strict(), (args) => browserOperation(companion, "delete_layers", args), { destructiveHint: true });
|
|
178
|
+
register("duplicate_layers", "Duplicate text and/or image layers with an optional normalized offset.", z.object({ ...targetSlide, layerIds: z.array(id).min(1).max(100), offsetX: z.number().min(-1).max(1).optional(), offsetY: z.number().min(-1).max(1).optional() }).strict(), (args) => browserOperation(companion, "duplicate_layers", args), { destructiveHint: false });
|
|
179
|
+
register("reorder_layers", "Set the complete back-to-front layer order using every layer ID exactly once.", z.object({ ...targetSlide, layerIds: z.array(id).min(1).max(300) }).strict(), (args) => browserOperation(companion, "reorder_layers", args), { destructiveHint: true });
|
|
180
|
+
register("undo", "Undo the latest project edit.", z.object(targetSlide).strict(), (args) => browserOperation(companion, "undo", args), { destructiveHint: true });
|
|
181
|
+
register("redo", "Redo the latest undone project edit.", z.object(targetSlide).strict(), (args) => browserOperation(companion, "redo", args), { destructiveHint: true });
|
|
182
|
+
register("set_view", "Open a project/slide and control editor-only canvas zoom or TikTok safe-area overlay.", z.object({ ...targetSlide, canvasZoom: z.number().min(0.2).max(3).optional(), showTikTokOverlay: z.boolean().optional() }).strict(), (args) => browserOperation(companion, "set_view", args), { destructiveHint: false, idempotentHint: true });
|
|
183
|
+
|
|
184
|
+
register("render_slide", "Render and return the actual slide image for visual inspection. This does not persist the rendered file.", z.object({ ...targetSlide, width: z.number().int().min(180).max(1080).default(540), format: z.enum(["png", "jpeg"]).default("png"), quality: z.number().min(0.4).max(1).default(0.9) }).strict(), async (args) => {
|
|
185
|
+
const rendered = await browserOperation(companion, "render_slide", args);
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "image", data: rendered.data, mimeType: rendered.mimeType }, { type: "text", text: JSON.stringify({ slideId: args.slideId || null, width: rendered.width, height: rendered.height, temporary: true }) }],
|
|
188
|
+
structuredContent: { slideId: args.slideId || null, width: rendered.width, height: rendered.height, mimeType: rendered.mimeType, temporary: true },
|
|
189
|
+
__rawMcpResult: true,
|
|
190
|
+
};
|
|
191
|
+
}, { readOnlyHint: true });
|
|
192
|
+
|
|
193
|
+
register("export_slide", "Render a full-resolution PNG and write it to a local path. Existing files are protected unless overwrite=true.", z.object({ ...targetSlide, outputPath: z.string().min(1), overwrite: z.boolean().default(false) }).strict(), async ({ outputPath, overwrite, ...target }) => {
|
|
194
|
+
const rendered = await browserOperation(companion, "render_slide", { ...target, width: 1080, format: "png", quality: 1 });
|
|
195
|
+
const path = absolutePath(outputPath);
|
|
196
|
+
await mkdir(dirname(path), { recursive: true });
|
|
197
|
+
return companion.call("write_export", { path, data: rendered.data, overwrite });
|
|
198
|
+
}, { destructiveHint: true });
|
|
199
|
+
|
|
200
|
+
register("export_project", "Render every slide at full resolution into a local directory. Existing files are protected unless overwrite=true.", z.object({ ...targetProject, outputDirectory: z.string().min(1), overwrite: z.boolean().default(false) }).strict(), async ({ outputDirectory, overwrite, ...target }) => {
|
|
201
|
+
const inspected = await browserOperation(companion, "inspect_editor", { ...target, includeAllProjects: false });
|
|
202
|
+
if (!inspected.project?.slides?.length) throw new Error("The project has no slides to export.");
|
|
203
|
+
const directory = absolutePath(outputDirectory);
|
|
204
|
+
await mkdir(directory, { recursive: true });
|
|
205
|
+
const files = [];
|
|
206
|
+
for (const slide of inspected.project.slides) {
|
|
207
|
+
const rendered = await browserOperation(companion, "render_slide", { projectId: inspected.project.id, slideId: slide.id, width: 1080, format: "png", quality: 1 });
|
|
208
|
+
const path = join(directory, `${String(slide.index + 1).padStart(2, "0")}-${rendered.filename}`);
|
|
209
|
+
if (!overwrite && await pathExists(path)) throw new Error(`Export already exists: ${path}. Set overwrite=true only when intended.`);
|
|
210
|
+
files.push(await companion.call("write_export", { path, data: rendered.data, overwrite }));
|
|
211
|
+
}
|
|
212
|
+
return { projectId: inspected.project.id, outputDirectory: directory, fileCount: files.length, files };
|
|
213
|
+
}, { destructiveHint: true });
|
|
214
|
+
|
|
215
|
+
const batchTools = [...definitions.entries()].filter(([, definition]) => definition.mutating).map(([name]) => name).filter((name) => !["export_slide", "export_project"].includes(name));
|
|
216
|
+
register("apply_operations", "Apply many ordered editing operations in one compact tool call. Each edit still appears live in the browser.", z.object({ editSessionId, operations: z.array(z.object({ tool: z.enum(batchTools), arguments: z.record(z.string(), z.unknown()).default({}) }).strict()).min(1).max(100) }).strict(), async ({ editSessionId: sessionId, operations }) => {
|
|
217
|
+
const items = [];
|
|
218
|
+
for (const item of operations) {
|
|
219
|
+
const definition = definitions.get(item.tool);
|
|
220
|
+
if (!definition?.mutating) throw new Error(`Tool cannot be batched: ${item.tool}`);
|
|
221
|
+
const args = definition.inputSchema.parse(item.arguments);
|
|
222
|
+
const { editSessionId: _ignored, ...toolArgs } = args;
|
|
223
|
+
items.push({ toolName: item.tool, operation: await prepareOperation(companion, item.tool, toolArgs), label: operationLabel(item.tool) });
|
|
224
|
+
}
|
|
225
|
+
return companion.call("batch", { items, editSessionId: sessionId });
|
|
226
|
+
}, { destructiveHint: true });
|
|
227
|
+
|
|
228
|
+
return server;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const createSlideStudioMcpServer = createCarouselBotMcpServer;
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { cp, mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { EDITOR_URL, PACKAGE_NAME, PACKAGE_ROOT, PACKAGE_VERSION } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
const supported = ["claude", "codex", "hermes", "opencode", "openclaw"];
|
|
9
|
+
const serverName = "carouselbot";
|
|
10
|
+
const legacyServerName = "slide-studio";
|
|
11
|
+
|
|
12
|
+
function commandExists(command) {
|
|
13
|
+
return spawnSync(command, ["--version"], { stdio: "ignore" }).error?.code !== "ENOENT";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commandVersion(command) {
|
|
17
|
+
const result = spawnSync(command, ["--version"], { encoding: "utf8" });
|
|
18
|
+
return `${result.stdout || ""}${result.stderr || ""}`.match(/\d+\.\d+(?:\.\d+)?/)?.[0] || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function shellCommand(client, specifier) {
|
|
22
|
+
if (client === "claude") return ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", serverName, "--", "npx", "-y", specifier, "serve", "--agent=claude"];
|
|
23
|
+
if (client === "codex") return ["codex", "mcp", "add", serverName, "--", "npx", "-y", specifier, "serve", "--agent=codex"];
|
|
24
|
+
if (client === "hermes") return ["hermes", "mcp", "add", serverName, "--command", "npx", "--args", "-y", specifier, "serve", "--agent=hermes"];
|
|
25
|
+
if (client === "openclaw") return ["openclaw", "mcp", "add", serverName, "--command", "npx", "--arg", "-y", "--arg", specifier, "--arg", "serve", "--arg", "--agent=openclaw"];
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function removeCommands(client) {
|
|
30
|
+
if (client === "claude") return [serverName, legacyServerName].map((name) => ["claude", "mcp", "remove", "--scope", "user", name]);
|
|
31
|
+
if (["codex", "hermes", "openclaw"].includes(client)) return [serverName, legacyServerName].map((name) => [client, "mcp", "remove", name]);
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function quote(value) {
|
|
36
|
+
return /^[A-Za-z0-9_@./:-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function openCodeSnippet(specifier, version = commandVersion("opencode")) {
|
|
40
|
+
const server = { type: "local", command: ["npx", "-y", specifier, "serve", "--agent=opencode"] };
|
|
41
|
+
return JSON.stringify(Number(version?.split(".")[0]) >= 2
|
|
42
|
+
? { mcp: { servers: { [serverName]: server } } }
|
|
43
|
+
: { mcp: { [serverName]: { ...server, enabled: true } } }, null, 2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function installSkill() {
|
|
47
|
+
const source = join(PACKAGE_ROOT, "skill", "carouselbot");
|
|
48
|
+
const targets = [
|
|
49
|
+
join(homedir(), ".agents", "skills", "carouselbot"),
|
|
50
|
+
join(homedir(), ".claude", "skills", "carouselbot"),
|
|
51
|
+
join(homedir(), ".hermes", "skills", "carouselbot"),
|
|
52
|
+
];
|
|
53
|
+
for (const target of targets) {
|
|
54
|
+
await mkdir(target, { recursive: true });
|
|
55
|
+
await cp(source, target, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
return targets;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runSetup(arguments_) {
|
|
61
|
+
const flags = new Set(arguments_);
|
|
62
|
+
const clientArgument = arguments_.find((value) => value.startsWith("--client="))?.slice("--client=".length);
|
|
63
|
+
const requested = clientArgument ? clientArgument.split(",").map((value) => value.trim().toLowerCase()) : supported.filter(commandExists);
|
|
64
|
+
const clients = [...new Set(requested)].filter((client) => supported.includes(client));
|
|
65
|
+
const releaseTag = PACKAGE_VERSION.includes("-beta.") ? "beta" : "latest";
|
|
66
|
+
const specifier = `${PACKAGE_NAME}@${releaseTag}`;
|
|
67
|
+
const dryRun = flags.has("--dry-run");
|
|
68
|
+
const assumeYes = flags.has("--yes") || flags.has("-y");
|
|
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.");
|
|
70
|
+
|
|
71
|
+
process.stdout.write(`CarouselBot MCP ${PACKAGE_VERSION}\nDetected: ${clients.join(", ")}\nEditor: ${EDITOR_URL}\n\n`);
|
|
72
|
+
for (const client of clients) {
|
|
73
|
+
const command = shellCommand(client, specifier);
|
|
74
|
+
if (command) process.stdout.write(`${client}: ${command.map(quote).join(" ")}\n`);
|
|
75
|
+
else process.stdout.write(`opencode config:\n${openCodeSnippet(specifier)}\n`);
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write(`\nGeneric stdio: npx -y ${specifier} serve\n`);
|
|
78
|
+
if (dryRun) return { clients, dryRun: true };
|
|
79
|
+
|
|
80
|
+
let approved = assumeYes;
|
|
81
|
+
if (!approved && process.stdin.isTTY) {
|
|
82
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
83
|
+
const answer = await prompt.question("\nAdd CarouselBot to the detected agent configs and install its skill? [y/N] ");
|
|
84
|
+
prompt.close();
|
|
85
|
+
approved = /^y(?:es)?$/i.test(answer.trim());
|
|
86
|
+
}
|
|
87
|
+
if (!approved) {
|
|
88
|
+
process.stdout.write("\nNo configuration changed. Re-run with --yes when ready.\n");
|
|
89
|
+
return { clients, installed: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const configured = [];
|
|
93
|
+
for (const client of clients) {
|
|
94
|
+
const command = shellCommand(client, specifier);
|
|
95
|
+
if (!command) continue;
|
|
96
|
+
for (const remove of removeCommands(client)) spawnSync(remove[0], remove.slice(1), { stdio: "ignore" });
|
|
97
|
+
const result = spawnSync(command[0], command.slice(1), { stdio: "inherit" });
|
|
98
|
+
if (result.status === 0) configured.push(client);
|
|
99
|
+
else process.stderr.write(`Could not configure ${client}; its command is printed above for manual setup.\n`);
|
|
100
|
+
}
|
|
101
|
+
const skillTargets = await installSkill();
|
|
102
|
+
process.stdout.write(`\nConfigured: ${configured.join(", ") || "none automatically"}\nSkill installed in:\n${skillTargets.map((value) => ` ${value}`).join("\n")}\n\nOpen ${EDITOR_URL} in your normal local browser and click Connect AI. Do not use a sandboxed agent browser.\n`);
|
|
103
|
+
process.stdout.write(`First connection check (no browser automation): npx -y ${specifier} call list_editors\n`);
|
|
104
|
+
if (clients.includes("hermes")) process.stdout.write("Hermes can also refresh native tools in place with /reload-mcp and /reload-skills.\n");
|
|
105
|
+
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
|
+
if (clients.includes("opencode")) process.stdout.write("OpenCode currently uses its JSON config; merge the snippet printed above into opencode.json.\n");
|
|
107
|
+
return { clients, configured, skillTargets };
|
|
108
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
2
|
+
import { detectHostAgent } from "./agent-identity.mjs";
|
|
3
|
+
import { createCompanion } from "./companion.mjs";
|
|
4
|
+
import { createCarouselBotMcpServer } from "./mcp-server.mjs";
|
|
5
|
+
|
|
6
|
+
export async function serveMcp({ agentName = null } = {}) {
|
|
7
|
+
const companion = await createCompanion(detectHostAgent(agentName));
|
|
8
|
+
const handle = serveStdio(() => createCarouselBotMcpServer(companion), {
|
|
9
|
+
legacy: "serve",
|
|
10
|
+
onerror: (error) => process.stderr.write(`[carouselbot] ${error.message}\n`),
|
|
11
|
+
});
|
|
12
|
+
let closing = false;
|
|
13
|
+
const close = async () => {
|
|
14
|
+
if (closing) return;
|
|
15
|
+
closing = true;
|
|
16
|
+
await handle.close().catch(() => {});
|
|
17
|
+
await companion.close();
|
|
18
|
+
};
|
|
19
|
+
process.once("SIGINT", () => void close().finally(() => process.exit()));
|
|
20
|
+
process.once("SIGTERM", () => void close().finally(() => process.exit()));
|
|
21
|
+
process.once("SIGHUP", () => void close().finally(() => process.exit()));
|
|
22
|
+
process.stdin.once("end", () => void close().finally(() => process.exit()));
|
|
23
|
+
return { handle, companion, close };
|
|
24
|
+
}
|