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
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# CarouselBot MCP
|
|
2
|
+
|
|
3
|
+
Local-first MCP companion for the hosted [CarouselBot editor](https://carousel.bot). It exposes project, slide, text, image, layer, history, rendering, and export controls to any stdio MCP client while the editor remains in the browser.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx carouselbot@latest setup
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
For non-interactive agent setup, select the current client explicitly:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx carouselbot@latest setup --client=codex --yes
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Replace `codex` with `claude`, `hermes`, `opencode`, or `openclaw`. OpenCode prints version-appropriate JSON to merge into its config. Other detected clients are configured through their official CLI.
|
|
16
|
+
|
|
17
|
+
## Use it immediately without restarting
|
|
18
|
+
|
|
19
|
+
The same validated tool surface is available through the package CLI when a running agent cannot refresh its native MCP tools:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx -y carouselbot@latest call get_design_guidance
|
|
23
|
+
npx -y carouselbot@latest call list_editors
|
|
24
|
+
npx -y carouselbot@latest call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
|
|
25
|
+
npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation"}'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every MCP tool name and JSON argument shape works with `call`. The CLI-only `list_tools` helper lists names compactly or returns selected schemas. `render_slide` writes image output to a temporary `previewPath` instead of dumping base64 into the terminal.
|
|
29
|
+
|
|
30
|
+
Always use `list_editors` to check the browser connection. Do not open CarouselBot or click **Connect AI** through a sandboxed, remote, or agent-controlled browser: that is a different browser session and may not reach the local companion.
|
|
31
|
+
|
|
32
|
+
Browser reconnection is automatic. Retry transient disconnects; use `restart` only for an explicit protocol mismatch or failed daemon health check. Hermes can refresh native tools with `/reload-mcp` and `/reload-skills`. Claude may need a new session to register a newly added server, but the CLI fallback works immediately.
|
|
33
|
+
|
|
34
|
+
The companion binds only to `127.0.0.1`. There is no hosted relay: projects remain in browser IndexedDB and local images remain on the user's computer.
|
|
35
|
+
|
|
36
|
+
Any MCP client can launch it with:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx -y carouselbot@latest serve
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Useful commands:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx carouselbot@latest setup --dry-run
|
|
46
|
+
npx carouselbot@latest doctor
|
|
47
|
+
npx carouselbot@latest restart
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Existing Slide Studio installations
|
|
51
|
+
|
|
52
|
+
`slides-studio-mcp` remains as a compatibility package and delegates to CarouselBot. Existing MCP configurations therefore continue to launch, while running `carouselbot setup` replaces the visible `slide-studio` config key with `carouselbot`.
|
|
53
|
+
|
|
54
|
+
The new companion accepts both `https://carousel.bot` and the legacy `https://slides-editor.pages.dev` origin during migration. Legacy environment variables beginning with `SLIDE_STUDIO_` and the old daemon state directory remain supported.
|
|
55
|
+
|
|
56
|
+
## Parallel agents and browser tabs
|
|
57
|
+
|
|
58
|
+
Call `begin_edit_session` before editing and pass its `editSessionId` to all operations. A session atomically reserves one browser tab and one project, follows that tab instead of global focus, and expires after inactivity. Always call `end_edit_session` when finished.
|
|
59
|
+
|
|
60
|
+
For parallel editing, reserve a different connected editor for each worker. The daemon rejects conflicting claims with `EDITOR_BUSY` or `PROJECT_BUSY` and records a sanitized local audit through `list_recent_operations`; it never records slide text, prompts, file paths, or image bytes.
|
|
61
|
+
|
|
62
|
+
Browser writes use revision-checked IndexedDB transactions and cross-tab synchronization. A stale tab cannot replace a newer project snapshot; it reloads the canonical copy and returns `STALE_PROJECT` so the agent can inspect and retry.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# CarouselBot design guidance
|
|
2
|
+
|
|
3
|
+
Read this before creating or editing slides. Use it as a compact quality bar, then inspect the rendered slide instead of assuming code values look good.
|
|
4
|
+
|
|
5
|
+
## Defaults that usually look good
|
|
6
|
+
|
|
7
|
+
- Build for a 9:16 phone canvas and keep one clear idea per slide.
|
|
8
|
+
- Prefer `boxed` text with `backgroundShape: "lines"` for highlighted copy. Treat per-line boxes as the default; use `full` only for a deliberate card or label.
|
|
9
|
+
- `add_text` and `update_text` automatically preserve width and fit height around all wrapped lines with safety padding. Do not render just to discover clipping or call `fit_text_boxes` after ordinary copy edits.
|
|
10
|
+
- Use `fit_text_boxes` with `mode: "both"` only when you intentionally want width to shrink as well. If automatic fitting rejects copy that cannot fit on one slide, shorten it or split it across slides.
|
|
11
|
+
- After creating or changing any full-box text, call `fit_text_boxes`. A full box must hug its rendered content instead of leaving a large empty rectangle.
|
|
12
|
+
- Use plain or outlined text for supporting copy. Use no more than two text treatments on one slide.
|
|
13
|
+
- Choose size by role rather than one universal value: titles `92–124`, subtitles `68–84`, body copy `54–68`, captions `44–52`. These are ranges, not fixed presets; render and adjust within them.
|
|
14
|
+
- Do not shrink dense copy below the body range to make it fit. Shorten it or split it across slides. Aim for roughly 3–7 body lines on a slide.
|
|
15
|
+
- Keep important content inside roughly `x: 0.06..0.86` and `y: 0.08..0.78` when the TikTok overlay matters. The right and bottom edges are occupied by interface controls and captions.
|
|
16
|
+
- Give text boxes generous width while composing. Per-line backgrounds include protected edge padding; if content or size changes, render again and use `fit_text_boxes` when the box itself should hug the content.
|
|
17
|
+
- Use short lines. Two to four lines for a headline is usually stronger than one dense paragraph.
|
|
18
|
+
- Keep strong contrast between copy and the image. Use black boxed backgrounds with white text or white boxed backgrounds with near-black text.
|
|
19
|
+
- Preserve an obvious focal image. Do not cover faces or the main subject unless the composition intentionally calls for it.
|
|
20
|
+
- Align related text layers consistently. Center is a safe default; use left alignment for editorial layouts.
|
|
21
|
+
- Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
|
|
22
|
+
- Reuse a small palette and consistent type scale across the project.
|
|
23
|
+
|
|
24
|
+
## Working method
|
|
25
|
+
|
|
26
|
+
1. Inspect the editor and use the returned project, slide, asset, and layer IDs.
|
|
27
|
+
2. Create or update one slide at a time. If that project is already visible, the editor follows its most recently changed slide. Work on another project never takes over the user's current view.
|
|
28
|
+
3. Use `apply_operations` when several related edits can be expressed compactly; the browser still shows each operation live.
|
|
29
|
+
4. Call `render_slide` after a meaningful composition change and look at the returned image.
|
|
30
|
+
5. Correct clipping, collisions, weak contrast, unsafe placement, inconsistent spacing, and visual imbalance before continuing.
|
|
31
|
+
6. Render the complete set at least once before exporting.
|
|
32
|
+
|
|
33
|
+
## Text-box clipping checklist
|
|
34
|
+
|
|
35
|
+
- Increase width before shrinking type when a line almost fits.
|
|
36
|
+
- Increase height when multiline text or per-line backgrounds approach the top or bottom edge.
|
|
37
|
+
- Use `fit_text_boxes` after changing full-box copy or font size. Use `mode: "height"` when the chosen width must remain fixed.
|
|
38
|
+
- Keep `x + width` and `y + height` within the canvas unless an off-canvas effect is intentional.
|
|
39
|
+
- With boxed text, keep `backgroundShape: "lines"`; automatic height fitting includes minimum breathing room so rounded pills stay inside the text container.
|
|
40
|
+
- If the result is uncertain, render it. Numeric state is not a visual review.
|
|
41
|
+
|
|
42
|
+
## Agent behavior
|
|
43
|
+
|
|
44
|
+
- Do not delete projects, slides, assets, or layers unless the request calls for it.
|
|
45
|
+
- Do not overwrite an existing export unless explicitly requested.
|
|
46
|
+
- Keep tool responses and progress messages concise.
|
|
47
|
+
- Prefer IDs returned by tools over guessed names or array positions.
|
|
48
|
+
- Do not claim a slide looks good until you have inspected a rendered image.
|
|
49
|
+
- Do not call `open_project` merely to edit or render another project. It intentionally changes the user's browser view; use it only when the user asks to see that project.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "carouselbot",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local-first MCP companion for the hosted CarouselBot editor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/alexgusevski/carouselbot.git",
|
|
10
|
+
"directory": "packages/mcp"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://carousel.bot",
|
|
13
|
+
"bugs": "https://github.com/alexgusevski/carouselbot/issues",
|
|
14
|
+
"bin": {
|
|
15
|
+
"carouselbot": "src/cli.mjs"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
"./cli": "./src/cli.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"guidance",
|
|
23
|
+
"skill",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
31
|
+
"zod": "^4.4.3"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"provenance": true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: carouselbot
|
|
3
|
+
description: Create, edit, visually inspect, and export CarouselBot slides through the local CarouselBot MCP companion.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# CarouselBot
|
|
7
|
+
|
|
8
|
+
Use the CarouselBot MCP tools for all carousel changes. The hosted browser editor is the live visual surface; local files and rendered images pass only through the local companion.
|
|
9
|
+
|
|
10
|
+
Before using any browser or making edits, call `list_editors`. A registered editor is the user's real browser tab and is the only browser surface the agent should use.
|
|
11
|
+
|
|
12
|
+
- If an editor is listed, use the MCP tools against it. Do not open CarouselBot or click **Connect AI** with browser automation.
|
|
13
|
+
- Sandboxed, remote, and agent-controlled browser sessions are separate from the user's local browser and usually cannot reach the local companion. Never use one to test or establish the connection.
|
|
14
|
+
- If no editor is listed, ask the user to open the editor in their normal browser on the same computer, click **Connect AI**, and accept the browser permission. Then retry `list_editors`.
|
|
15
|
+
- A loaded MCP server and a connected browser editor are different states. Determine browser connectivity only from `list_editors`, not from a sandbox browser or the agent's tool catalog.
|
|
16
|
+
|
|
17
|
+
Browser reconnection is automatic. After `EDITOR_DISCONNECTED`, `EDITOR_RELOADED`, a dropped browser request, or an empty `list_editors` result that follows a working connection, wait briefly and retry `list_editors` several times. Do not restart the companion for a transient browser disconnect: restarting invalidates every browser session and makes recovery slower. If the editor does not return, ask the user to keep or reload the real editor tab; preserve completed project work and begin a new edit session after it reconnects.
|
|
18
|
+
|
|
19
|
+
Run `npx -y carouselbot@latest restart` only when the MCP explicitly reports an outdated companion protocol or `doctor` reports that the daemon itself is unhealthy. After a necessary restart, ask the user to reload their real editor tab and retry `list_editors`.
|
|
20
|
+
|
|
21
|
+
If the native MCP tools are not registered in the current session, do not stop or ask for a restart. Use the same validated tools through the local CLI fallback:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx -y carouselbot@latest call get_design_guidance
|
|
25
|
+
npx -y carouselbot@latest call list_editors
|
|
26
|
+
npx -y carouselbot@latest call list_tools --json '{"names":["add_slide","add_text"]}'
|
|
27
|
+
npx -y carouselbot@latest call create_project --json '{"name":"My presentation"}'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Every tool accepts the same JSON arguments as MCP. Use `list_tools` without arguments for compact discovery or pass `{"names":[...]}` to retrieve selected schemas. Prefer `apply_operations` for batches. `render_slide` writes its returned image to a temporary local `previewPath`; inspect that file and remove the temporary directory after the review. Hermes can load the native tools in place with `/reload-mcp` and refresh this skill with `/reload-skills`.
|
|
31
|
+
|
|
32
|
+
Before the first mutation in a task, call `get_design_guidance`. The server intentionally rejects mutations until this guidance has been read.
|
|
33
|
+
|
|
34
|
+
Before editing, reserve a target with `begin_edit_session`. Pass its `editSessionId` to every mutating tool, relevant reads, notifications, and `apply_operations`. Release it with `end_edit_session` as soon as the work finishes or fails. Reservations expire after inactivity, but explicit release is the normal cleanup path.
|
|
35
|
+
|
|
36
|
+
For parallel work, the parent agent owns orchestration:
|
|
37
|
+
|
|
38
|
+
1. Call `list_editors` and `list_edit_sessions` before spawning editing workers.
|
|
39
|
+
2. Reserve one distinct editor per editing worker with `begin_edit_session`, and assign one project per session. Never launch more editing workers than available unassigned editors. Research-only workers do not need a session.
|
|
40
|
+
3. Give each worker its exact `editorId`, `editSessionId`, and `projectId` (once known). A worker must never select or use another worker's editor.
|
|
41
|
+
4. If a worker creates a project, the daemon binds that new project to its session automatically. Report the returned `projectId` to the parent.
|
|
42
|
+
5. Treat `EDITOR_BUSY`, `PROJECT_BUSY`, and `SESSION_PROJECT_MISMATCH` as coordination signals. Do not retry against a different target silently. Re-plan, wait, or use another unassigned editor.
|
|
43
|
+
6. End every worker session in cleanup, including after errors. Use `list_recent_operations` to investigate routing, conflicts, or failed edits without exposing prompts, text, paths, or image bytes.
|
|
44
|
+
|
|
45
|
+
With only one agent and one editor, the server can create an implicit session for compatibility. Explicit sessions are still preferred because they make routing deterministic. Never rely on which tab is focused once multiple editors exist.
|
|
46
|
+
|
|
47
|
+
Inspect the assigned editor before editing and keep the returned IDs and revision. Work on the assigned project and slide. Pass `expectedRevision` for sensitive mutations. If `STALE_PROJECT` is returned, the browser has reloaded the canonical IndexedDB copy; inspect again and retry with current IDs. Prefer `apply_operations` for compact related changes while preserving logical order.
|
|
48
|
+
|
|
49
|
+
Use readable role-based type ranges: title `92–124`, subtitle `68–84`, body `54–68`, caption `44–52`. Do not solve dense copy by dropping below the body range; shorten it or split it across slides. `add_text` and `update_text` automatically preserve width and fit height around every wrapped line with safe padding. For highlighted text, prefer `style: "boxed"` with `backgroundShape: "lines"`. Use `backgroundShape: "full"` only for a deliberate card. Call `fit_text_boxes` with `mode: "both"` only when you intentionally want the width to shrink too.
|
|
50
|
+
|
|
51
|
+
After each meaningful composition or after a short batch, call `render_slide` and inspect the returned image. Fix clipping, spacing, contrast, unsafe overlay placement, and weak hierarchy before claiming the slide is finished. Use `export_slide` or `export_project` only when local files are requested; do not overwrite existing files unless authorized.
|
|
52
|
+
|
|
53
|
+
Edits follow the latest changed slide only when their project is already visible. Creating or editing another project must not take over the user's current browser view. `open_project` and `set_view` intentionally navigate, so call them only when the user asks to see that project. Other tabs synchronize project cards and project state through local browser storage. Use `show_notification` only for short, useful status messages.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const KNOWN_AGENTS = [
|
|
2
|
+
{ label: "Hermes", matches: ["hermes"] },
|
|
3
|
+
{ label: "Claude", matches: ["claude", "anthropic"] },
|
|
4
|
+
{ label: "Codex", matches: ["codex", "openai"] },
|
|
5
|
+
{ label: "OpenCode", matches: ["opencode"] },
|
|
6
|
+
{ label: "OpenClaw", matches: ["openclaw"] },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const GENERIC_CLIENT_NAMES = new Set([
|
|
10
|
+
"ai agent",
|
|
11
|
+
"agent",
|
|
12
|
+
"client",
|
|
13
|
+
"mcp",
|
|
14
|
+
"mcp agent",
|
|
15
|
+
"mcp client",
|
|
16
|
+
"carouselbot cli fallback",
|
|
17
|
+
"slide studio cli fallback",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function canonicalAgentName(value) {
|
|
21
|
+
const name = String(value || "").trim();
|
|
22
|
+
const normalized = name.toLowerCase();
|
|
23
|
+
return KNOWN_AGENTS.find(({ matches }) => matches.some((match) => normalized.includes(match)))?.label || name || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isGenericAgentName(value) {
|
|
27
|
+
return GENERIC_CLIENT_NAMES.has(String(value || "").trim().toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detectHostAgent(explicitName = null, environment = process.env, runtime = process) {
|
|
31
|
+
const configured = canonicalAgentName(explicitName || environment.CAROUSELBOT_AGENT || environment.SLIDE_STUDIO_AGENT);
|
|
32
|
+
if (configured) return configured;
|
|
33
|
+
const environmentKeys = Object.keys(environment).filter((key) => environment[key]).join(" ");
|
|
34
|
+
const evidence = [
|
|
35
|
+
runtime.execPath,
|
|
36
|
+
...(runtime.argv || []),
|
|
37
|
+
environment.PATH,
|
|
38
|
+
environment.TERM_PROGRAM,
|
|
39
|
+
environment.npm_execpath,
|
|
40
|
+
environmentKeys,
|
|
41
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
42
|
+
return KNOWN_AGENTS.find(({ matches }) => matches.some((match) => evidence.includes(match)))?.label || "MCP agent";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function preferHostAgent(reportedName, hostName) {
|
|
46
|
+
const reported = canonicalAgentName(reportedName);
|
|
47
|
+
const host = canonicalAgentName(hostName) || "MCP agent";
|
|
48
|
+
return !reported || isGenericAgentName(reported) ? host : reported;
|
|
49
|
+
}
|
package/src/call.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const CLI_PATH = fileURLToPath(new URL("cli.mjs", import.meta.url));
|
|
8
|
+
|
|
9
|
+
async function stdinText() {
|
|
10
|
+
if (process.stdin.isTTY) return "";
|
|
11
|
+
let value = "";
|
|
12
|
+
process.stdin.setEncoding("utf8");
|
|
13
|
+
for await (const chunk of process.stdin) value += chunk;
|
|
14
|
+
return value.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function parseArguments(arguments_) {
|
|
18
|
+
const [toolName, ...rest] = arguments_;
|
|
19
|
+
if (!toolName) throw new Error("Usage: carouselbot call <tool> [--json '{\"key\":\"value\"}']");
|
|
20
|
+
const inline = rest.find((value) => value.startsWith("--json="));
|
|
21
|
+
const flagIndex = rest.indexOf("--json");
|
|
22
|
+
const raw = inline?.slice("--json=".length)
|
|
23
|
+
|| (flagIndex >= 0 ? rest[flagIndex + 1] : rest.find((value) => !value.startsWith("--")))
|
|
24
|
+
|| (rest.includes("--stdin") ? await stdinText() : "")
|
|
25
|
+
|| "{}";
|
|
26
|
+
let args;
|
|
27
|
+
try { args = JSON.parse(raw); }
|
|
28
|
+
catch { throw new Error("--json must be a valid JSON object."); }
|
|
29
|
+
if (!args || Array.isArray(args) || typeof args !== "object") throw new Error("--json must be a JSON object.");
|
|
30
|
+
return { toolName, args };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function printableResult(toolName, result) {
|
|
34
|
+
const error = result.isError && result.content?.find((item) => item.type === "text")?.text;
|
|
35
|
+
if (error) throw new Error(error);
|
|
36
|
+
if (toolName === "get_design_guidance") return { guidance: result.content?.find((item) => item.type === "text")?.text || "" };
|
|
37
|
+
const image = result.content?.find((item) => item.type === "image" && item.data);
|
|
38
|
+
if (!image) return result.structuredContent ?? { content: result.content || [] };
|
|
39
|
+
const extension = image.mimeType === "image/jpeg" ? "jpg" : "png";
|
|
40
|
+
const directory = await mkdtemp(join(tmpdir(), "carouselbot-preview-"));
|
|
41
|
+
const previewPath = join(directory, `slide.${extension}`);
|
|
42
|
+
await writeFile(previewPath, Buffer.from(image.data, "base64"));
|
|
43
|
+
return { ...(result.structuredContent || {}), previewPath, temporary: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createRpc(child) {
|
|
47
|
+
let input = "";
|
|
48
|
+
let id = 0;
|
|
49
|
+
const pending = new Map();
|
|
50
|
+
child.stdout.setEncoding("utf8");
|
|
51
|
+
child.stdout.on("data", (chunk) => {
|
|
52
|
+
input += chunk;
|
|
53
|
+
let newline;
|
|
54
|
+
while ((newline = input.indexOf("\n")) >= 0) {
|
|
55
|
+
const line = input.slice(0, newline).trim();
|
|
56
|
+
input = input.slice(newline + 1);
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
const message = JSON.parse(line);
|
|
59
|
+
const waiter = pending.get(message.id);
|
|
60
|
+
if (!waiter) continue;
|
|
61
|
+
pending.delete(message.id);
|
|
62
|
+
clearTimeout(waiter.timer);
|
|
63
|
+
message.error ? waiter.reject(new Error(message.error.message)) : waiter.resolve(message.result);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
child.once("exit", (code) => {
|
|
67
|
+
for (const waiter of pending.values()) {
|
|
68
|
+
clearTimeout(waiter.timer);
|
|
69
|
+
waiter.reject(new Error(`Local MCP process exited before replying${code == null ? "" : ` (${code})`}.`));
|
|
70
|
+
}
|
|
71
|
+
pending.clear();
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
notify(method, params = {}) { child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); },
|
|
75
|
+
request(method, params = {}) {
|
|
76
|
+
const requestId = ++id;
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`Timed out waiting for ${method}.`)); }, 120_000);
|
|
79
|
+
pending.set(requestId, { resolve, reject, timer });
|
|
80
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params })}\n`);
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function stopChild(child) {
|
|
87
|
+
if (child.exitCode != null) return;
|
|
88
|
+
child.stdin.end();
|
|
89
|
+
const exited = new Promise((resolve) => child.once("exit", resolve));
|
|
90
|
+
const timeout = new Promise((resolve) => { const timer = setTimeout(resolve, 2000); timer.unref(); });
|
|
91
|
+
await Promise.race([exited, timeout]);
|
|
92
|
+
if (child.exitCode == null) child.kill("SIGTERM");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function runCall(arguments_) {
|
|
96
|
+
const { toolName, args } = await parseArguments(arguments_);
|
|
97
|
+
const child = spawn(process.execPath, [CLI_PATH, "serve"], { cwd: process.cwd(), env: process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
98
|
+
let errors = "";
|
|
99
|
+
child.stderr.setEncoding("utf8");
|
|
100
|
+
child.stderr.on("data", (chunk) => { errors = `${errors}${chunk}`.slice(-4000); });
|
|
101
|
+
const rpc = createRpc(child);
|
|
102
|
+
try {
|
|
103
|
+
await rpc.request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "CarouselBot CLI fallback", version: "1" } });
|
|
104
|
+
rpc.notify("notifications/initialized");
|
|
105
|
+
if (toolName === "list_tools") {
|
|
106
|
+
const listed = await rpc.request("tools/list");
|
|
107
|
+
const requested = Array.isArray(args.names) ? new Set(args.names.map(String)) : null;
|
|
108
|
+
const tools = listed.tools.filter((tool) => !requested || requested.has(tool.name)).map((tool) => ({
|
|
109
|
+
name: tool.name,
|
|
110
|
+
description: tool.description,
|
|
111
|
+
...(requested ? { inputSchema: tool.inputSchema } : {}),
|
|
112
|
+
}));
|
|
113
|
+
process.stdout.write(`${JSON.stringify({ tools }, null, 2)}\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (toolName !== "get_design_guidance") await rpc.request("tools/call", { name: "get_design_guidance", arguments: {} });
|
|
117
|
+
const result = await rpc.request("tools/call", { name: toolName, arguments: args });
|
|
118
|
+
process.stdout.write(`${JSON.stringify(await printableResult(toolName, result), null, 2)}\n`);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new Error(`${error.message}${errors ? `\n${errors.trim()}` : ""}`);
|
|
121
|
+
} finally {
|
|
122
|
+
await stopChild(child);
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { companionDoctor, companionRestart } from "./companion.mjs";
|
|
3
|
+
import { EDITOR_URL, PACKAGE_NAME, PACKAGE_VERSION } from "./config.mjs";
|
|
4
|
+
import { runSetup } from "./setup.mjs";
|
|
5
|
+
import { serveMcp } from "./stdio-server.mjs";
|
|
6
|
+
|
|
7
|
+
const [command = "serve", ...arguments_] = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
if (command === "serve") {
|
|
11
|
+
const agentName = arguments_.find((value) => value.startsWith("--agent="))?.slice("--agent=".length) || null;
|
|
12
|
+
await serveMcp({ agentName });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (command === "setup") {
|
|
16
|
+
await runSetup(arguments_);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (command === "call") {
|
|
20
|
+
const { runCall } = await import("./call.mjs");
|
|
21
|
+
await runCall(arguments_);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (command === "doctor") {
|
|
25
|
+
const health = await companionDoctor();
|
|
26
|
+
process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, packageVersion: PACKAGE_VERSION, editor: EDITOR_URL, daemon: health }, null, 2)}\n`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (command === "restart") {
|
|
30
|
+
const health = await companionRestart();
|
|
31
|
+
process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, packageVersion: PACKAGE_VERSION, daemon: health }, null, 2)}\n`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
35
|
+
process.stdout.write(`${PACKAGE_VERSION}\n`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
39
|
+
process.stdout.write(`CarouselBot MCP ${PACKAGE_VERSION}\n\nUsage:\n carouselbot serve [--agent=claude|codex|hermes|opencode|openclaw]\n carouselbot setup [--client=claude,codex,hermes,opencode,openclaw] [--yes] [--dry-run]\n carouselbot call <tool> [--json '{"key":"value"}'] [--stdin]\n carouselbot doctor\n carouselbot restart\n carouselbot version\n`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Unknown command: ${command}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch((error) => {
|
|
46
|
+
process.stderr.write(`${error.message}\n`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { BRIDGE_URL, DAEMON_STATE_PATH, PACKAGE_VERSION, PROTOCOL_VERSION } from "./config.mjs";
|
|
6
|
+
import { preferHostAgent } from "./agent-identity.mjs";
|
|
7
|
+
|
|
8
|
+
const DAEMON_ENTRY = fileURLToPath(new URL("daemon.mjs", import.meta.url));
|
|
9
|
+
|
|
10
|
+
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
11
|
+
|
|
12
|
+
async function readState() {
|
|
13
|
+
try { return JSON.parse(await readFile(DAEMON_STATE_PATH, "utf8")); }
|
|
14
|
+
catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function daemonRequest(state, path, init = {}) {
|
|
18
|
+
const response = await fetch(`${BRIDGE_URL}${path}`, {
|
|
19
|
+
...init,
|
|
20
|
+
headers: { "Authorization": `Bearer ${state.secret}`, "Content-Type": "application/json", ...(init.headers || {}) },
|
|
21
|
+
});
|
|
22
|
+
const value = await response.json().catch(() => ({}));
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const error = new Error(value.error || `Local companion returned ${response.status}.`);
|
|
25
|
+
error.status = response.status;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function healthyState() {
|
|
32
|
+
const state = await readState();
|
|
33
|
+
if (!state?.secret || state.port == null) return null;
|
|
34
|
+
try {
|
|
35
|
+
const result = await daemonRequest(state, "/internal/health");
|
|
36
|
+
if (result.protocolVersion !== PROTOCOL_VERSION) {
|
|
37
|
+
const error = new Error(`Local companion ${result.version || "unknown"} uses protocol ${result.protocolVersion}; ${PACKAGE_VERSION} requires protocol ${PROTOCOL_VERSION}. Run \`npx -y carouselbot@latest restart\`, then reload the editor.`);
|
|
38
|
+
error.code = "EPROTOCOL";
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return state;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === "EPROTOCOL") throw error;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function ensureDaemon() {
|
|
49
|
+
const existing = await healthyState();
|
|
50
|
+
if (existing) return existing;
|
|
51
|
+
const child = spawn(process.execPath, [DAEMON_ENTRY], {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: "ignore",
|
|
54
|
+
env: process.env,
|
|
55
|
+
});
|
|
56
|
+
child.unref();
|
|
57
|
+
const deadline = Date.now() + 8000;
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
await wait(100);
|
|
60
|
+
const state = await healthyState();
|
|
61
|
+
if (state) return state;
|
|
62
|
+
}
|
|
63
|
+
throw new Error("Could not start the local CarouselBot companion. Run `npx carouselbot doctor` for details.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function createCompanion(initialName = "MCP agent", initialVersion = null) {
|
|
67
|
+
let state = await ensureDaemon();
|
|
68
|
+
const clientId = randomUUID();
|
|
69
|
+
let clientName = initialName;
|
|
70
|
+
let clientVersion = initialVersion;
|
|
71
|
+
let closed = false;
|
|
72
|
+
|
|
73
|
+
const rawPost = (path, body) => daemonRequest(state, path, { method: "POST", body: JSON.stringify(body) });
|
|
74
|
+
const register = () => rawPost("/internal/client/connect", { clientId, name: clientName, version: clientVersion });
|
|
75
|
+
const post = async (path, body) => {
|
|
76
|
+
try {
|
|
77
|
+
return await rawPost(path, body);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (closed || (error.status && error.status !== 401)) throw error;
|
|
80
|
+
state = await ensureDaemon();
|
|
81
|
+
await register();
|
|
82
|
+
return rawPost(path, body);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
await register();
|
|
86
|
+
const heartbeat = setInterval(() => {
|
|
87
|
+
if (!closed) void post("/internal/client/heartbeat", { clientId }).catch(() => {});
|
|
88
|
+
}, 15_000);
|
|
89
|
+
heartbeat.unref();
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
clientId,
|
|
93
|
+
get daemon() { return { pid: state.pid, url: BRIDGE_URL, version: state.version, packageVersion: PACKAGE_VERSION }; },
|
|
94
|
+
async identify(name, version) {
|
|
95
|
+
clientName = preferHostAgent(name, clientName);
|
|
96
|
+
clientVersion = version || clientVersion;
|
|
97
|
+
await register();
|
|
98
|
+
},
|
|
99
|
+
async call(action, body = {}) {
|
|
100
|
+
const response = await post("/internal/call", { clientId, action, ...body });
|
|
101
|
+
return response.result;
|
|
102
|
+
},
|
|
103
|
+
async close() {
|
|
104
|
+
if (closed) return;
|
|
105
|
+
closed = true;
|
|
106
|
+
clearInterval(heartbeat);
|
|
107
|
+
await rawPost("/internal/client/disconnect", { clientId }).catch(() => {});
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function companionDoctor() {
|
|
113
|
+
const state = await ensureDaemon();
|
|
114
|
+
const health = await daemonRequest(state, "/internal/health");
|
|
115
|
+
return { ...health, url: BRIDGE_URL, stateFile: DAEMON_STATE_PATH };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function companionRestart() {
|
|
119
|
+
const previous = await readState();
|
|
120
|
+
if (previous?.secret && Number.isInteger(previous.pid) && previous.pid > 0) {
|
|
121
|
+
const health = await daemonRequest(previous, "/internal/health").catch(() => null);
|
|
122
|
+
if (health?.pid === previous.pid) {
|
|
123
|
+
await daemonRequest(previous, "/internal/shutdown", { method: "POST", body: "{}" }).catch(() => {
|
|
124
|
+
try { process.kill(previous.pid, "SIGTERM"); } catch { /* It already stopped. */ }
|
|
125
|
+
});
|
|
126
|
+
const deadline = Date.now() + 8000;
|
|
127
|
+
while (Date.now() < deadline) {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(previous.pid, 0);
|
|
130
|
+
await wait(100);
|
|
131
|
+
} catch {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const state = await ensureDaemon();
|
|
138
|
+
const health = await daemonRequest(state, "/internal/health");
|
|
139
|
+
return { ...health, url: BRIDGE_URL, stateFile: DAEMON_STATE_PATH, previousPid: previous?.pid || null };
|
|
140
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
|
+
export const PACKAGE_JSON = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
8
|
+
export const PACKAGE_NAME = PACKAGE_JSON.name;
|
|
9
|
+
export const PACKAGE_VERSION = PACKAGE_JSON.version;
|
|
10
|
+
export const PROTOCOL_VERSION = 3;
|
|
11
|
+
export const BRIDGE_HOST = "127.0.0.1";
|
|
12
|
+
export const BRIDGE_PORT = Number(process.env.CAROUSELBOT_BRIDGE_PORT || process.env.SLIDE_STUDIO_BRIDGE_PORT) || 43117;
|
|
13
|
+
export const BRIDGE_URL = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`;
|
|
14
|
+
export const EDITOR_URL = "https://carousel.bot";
|
|
15
|
+
export const LEGACY_EDITOR_URL = "https://slides-editor.pages.dev";
|
|
16
|
+
export const ALLOWED_ORIGINS = new Set((process.env.CAROUSELBOT_ALLOWED_ORIGINS || process.env.SLIDE_STUDIO_ALLOWED_ORIGINS || [
|
|
17
|
+
EDITOR_URL,
|
|
18
|
+
LEGACY_EDITOR_URL,
|
|
19
|
+
"http://127.0.0.1:4173",
|
|
20
|
+
"http://localhost:4173",
|
|
21
|
+
].join(",")).split(",").map((value) => value.trim()).filter(Boolean));
|
|
22
|
+
export const GUIDANCE_PATH = join(PACKAGE_ROOT, "guidance", "design.md");
|
|
23
|
+
|
|
24
|
+
function defaultStateDirectory() {
|
|
25
|
+
if (process.env.CAROUSELBOT_STATE_DIR || process.env.SLIDE_STUDIO_STATE_DIR) return process.env.CAROUSELBOT_STATE_DIR || process.env.SLIDE_STUDIO_STATE_DIR;
|
|
26
|
+
const legacy = platform() === "win32"
|
|
27
|
+
? join(process.env.LOCALAPPDATA || homedir(), "SlideStudioMCP")
|
|
28
|
+
: platform() === "darwin"
|
|
29
|
+
? join(homedir(), "Library", "Caches", "SlideStudioMCP")
|
|
30
|
+
: join(process.env.XDG_RUNTIME_DIR || join(homedir(), ".cache"), "slides-studio-mcp");
|
|
31
|
+
if (existsSync(legacy)) return legacy;
|
|
32
|
+
if (platform() === "win32") return join(process.env.LOCALAPPDATA || homedir(), "CarouselBot");
|
|
33
|
+
if (platform() === "darwin") return join(homedir(), "Library", "Caches", "CarouselBot");
|
|
34
|
+
return join(process.env.XDG_RUNTIME_DIR || join(homedir(), ".cache"), "carouselbot");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const STATE_DIRECTORY = defaultStateDirectory();
|
|
38
|
+
export const DAEMON_STATE_PATH = join(STATE_DIRECTORY, `daemon-${BRIDGE_PORT}.json`);
|
|
39
|
+
export const DAEMON_LOCK_PATH = join(STATE_DIRECTORY, `daemon-${BRIDGE_PORT}.lock`);
|
|
40
|
+
export const AUDIT_LOG_PATH = join(STATE_DIRECTORY, `operations-${BRIDGE_PORT}.jsonl`);
|