carouselbot 0.3.1 → 0.3.3

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 CHANGED
@@ -8,7 +8,9 @@ npx carouselbot@latest setup
8
8
 
9
9
  Setup pins that exact package version in the generated MCP configuration, refreshes
10
10
  the agent skill, and automatically upgrades an older shared daemon. Rerun the
11
- command when you intentionally want to update.
11
+ command when you intentionally want to update. When setup runs inside a named
12
+ Hermes profile, it also installs the skill into that profile's active
13
+ HERMES_HOME and non-interactively enables the discovered CarouselBot tools.
12
14
 
13
15
  For non-interactive agent setup, select the current client explicitly:
14
16
 
@@ -26,13 +28,29 @@ The same validated tool surface is available through the package CLI when a runn
26
28
  npx -y carouselbot@latest call get_design_guidance
27
29
  npx -y carouselbot@latest call list_editors
28
30
  npx -y carouselbot@latest call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
29
- npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","folderPath":"/campaigns"}'
31
+ npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","aspectRatio":"3:4","folderPath":"/campaigns"}'
30
32
  ```
31
33
 
32
34
  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.
33
35
 
34
36
  Folders use exact canonical slash paths such as `/campaigns` and are derived from project membership. Pass `folderPath` to `create_project` to create a project in a folder. Use `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes are part of the virtual path and do not create a nested UI hierarchy. Because folders are implicit, moving the last project out removes the empty folder card automatically. `inspect_editor` returns each project's current folder path.
35
37
 
38
+ ## Canvas formats and backgrounds
39
+
40
+ Choose `aspectRatio` when creating a project to set the default for new slides. The editor offers `9:16`, `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, and `16:9`; MCP callers may also use another positive `W:H` ratio that produces a canvas between 180 and 3840 pixels high at the fixed 1080-pixel export width. Pass `aspectRatio` to `add_slide` when one slide should differ from the project default, or to `update_slide` to change only that slide. Existing text and image layers keep their proportions and relative centers when a slide ratio changes.
41
+
42
+ An image-backed `add_slide` automatically adopts the source image's exact reduced ratio when `backgroundPath` is supplied without `aspectRatio`. The image therefore scales to the 1080-pixel canvas width without adding workspace padding to the output; exports contain only the image-shaped slide canvas and its layers. Pass `aspectRatio` alongside `backgroundPath` only for an intentional crop-to-format slide. Native browser uploads use the same source-ratio behavior.
43
+
44
+ For example, a `3:4` project can contain an inherited `3:4` opener, a square slide, and a custom ultra-wide slide:
45
+
46
+ ```bash
47
+ npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","backgroundColor":"#F4EFE6"}'
48
+ npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","aspectRatio":"1:1","backgroundColor":"#111111"}'
49
+ npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","aspectRatio":"18:5","backgroundColor":"#FFFFFF"}'
50
+ ```
51
+
52
+ Create a flat slide directly with `add_slide({ backgroundColor: "#F4EFE6" })`, or replace a slide background with `update_slide`. A color is stored as project data and rendered natively, so no white or colored placeholder file is needed. `backgroundColor` and `backgroundPath` are mutually exclusive.
53
+
36
54
  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.
37
55
 
38
56
  Browser and MCP-process reconnection are automatic, including after a compatible daemon upgrade. The MCP checks the daemon's advertised internal actions rather than trusting the browser protocol number alone. Retry transient disconnects; use `restart` only when automatic recovery or `doctor` reports a failed daemon health check. An already-running host may still need to refresh its native tool catalog when a release adds entirely new tool names; the CLI fallback works immediately without waiting for that refresh.
@@ -51,7 +69,7 @@ import_font({ editSessionId, projectId, localFontId })
51
69
  add_text({ editSessionId, projectId, slideId, text, fontId })
52
70
  ```
53
71
 
54
- `list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight`, `fontStyle`, and variable-axis settings. The companion returns opaque local font IDs and never exposes font paths. A selected face is transferred over the authenticated loopback connection, persisted only in the browser's local IndexedDB project, and loaded before fitting or rendering. If the exact bytes are missing or invalid, rendering reports `FONT_UNAVAILABLE` instead of silently using a fallback.
72
+ `list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight` and `fontStyle`. The `wght` variable-font axis has editor/export parity; other axes are preserved for compatibility but should not be newly applied until the canvas renderer supports them exactly. The companion returns opaque local font IDs and never exposes font paths. A selected face is transferred over the authenticated loopback connection, persisted only in the browser's local IndexedDB project, and loaded before fitting or rendering. If the exact bytes are missing or invalid, rendering reports `FONT_UNAVAILABLE` instead of silently using a fallback. Ordinary typography remains an editable text layer; agents must not substitute a text-only image to imitate a font.
55
73
 
56
74
  Any MCP client can launch it with:
57
75
 
@@ -4,7 +4,9 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
4
4
 
5
5
  ## Defaults that usually look good
6
6
 
7
- - Build for a 9:16 phone canvas and keep one clear idea per slide.
7
+ - Choose a project aspect ratio as the default for new slides. `9:16` is the default; presets also include `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, and `16:9`. Pass `aspectRatio` to `add_slide` or `update_slide` when an individual slide should differ, and use another validated positive `W:H` ratio when the reference demands it. Deliberate format changes can add rhythm and uniqueness; accidental inconsistency cannot. Keep one clear idea per slide.
8
+ - When `add_slide` uses `backgroundPath` without an explicit `aspectRatio`, the slide adopts the image's exact reduced ratio. Use this for source-faithful image slides: the image scales to the fixed 1080-pixel canvas width, workspace space above or below stays outside the slide, and export contains only the visible slide canvas. Pass `aspectRatio` only when deliberate crop-to-format behavior is wanted.
9
+ - For a flat background, call `add_slide` with `backgroundColor` (or `update_slide` with `backgroundColor`). Do not create, upload, or import a bitmap merely to get a solid color.
8
10
  - 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
11
  - `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
12
  - 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.
@@ -21,6 +23,8 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
21
23
  - Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
22
24
  - Reuse a small palette and consistent type scale across the project.
23
25
  - For an installed Mac font, select an exact face with `list_local_fonts`, import its opaque ID with `import_font`, and apply only the returned project `fontId`. Reuse project faces through `list_project_fonts`; never guess family strings.
26
+ - Use `fontWeight` (or the matching `wght` axis) for variable weight. Do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until exported-canvas parity is available.
27
+ - Keep ordinary typography as editable text layers, including typography that uses an imported font. Never generate or import a text-only PNG/SVG and place it with `add_image` just to imitate a font. If the requested face cannot be imported, keep the copy editable in an available face and report the substitution instead of baking the words into pixels.
24
28
 
25
29
  ## Working method
26
30
 
@@ -47,4 +51,5 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
47
51
  - Keep tool responses and progress messages concise.
48
52
  - Prefer IDs returned by tools over guessed names or array positions.
49
53
  - Do not claim a slide looks good until you have inspected a rendered image.
54
+ - Use `add_text` or `update_text` for all ordinary words. Reserve image assets for photographs, illustrations, logos, screenshots, and deliberate artwork that cannot be represented by editable CarouselBot layers.
50
55
  - 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carouselbot",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Local-first MCP companion for the hosted CarouselBot editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,6 +57,14 @@ When the user asks for a font installed on their Mac, use the deterministic two-
57
57
 
58
58
  If listing returns `FONT_PERMISSION_REQUIRED`, ask the user to open the real CarouselBot tab and choose **Allow local fonts** from the text font control, then retry. Do not bypass this with browser automation. Font paths and bytes are intentionally unavailable to agents. If rendering reports `FONT_UNAVAILABLE`, preserve the editable text, report the missing face, and ask the user to replace or re-import it rather than accepting fallback pixels.
59
59
 
60
+ Use `fontWeight` or `fontVariationSettings.wght` when varying an imported face. Preserve existing non-weight variable settings, but do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until CarouselBot advertises exact exported-canvas parity for them.
61
+
62
+ All ordinary words must remain CarouselBot text layers, including words styled with a local font. Never generate or import a text-only PNG/SVG and place it as an image merely to imitate a font. If an exact face cannot be listed, imported, or rendered, keep the copy editable in an available face and tell the user what was substituted. Use image layers only for photographs, illustrations, logos, screenshots, and deliberate artwork that CarouselBot cannot represent as editable layers.
63
+
64
+ Choose a project's default canvas when creating it with the optional `aspectRatio`: `9:16` (default), `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, or `16:9`. MCP callers may also use another validated positive `W:H` ratio when the reference requires it. Pass `aspectRatio` to `add_slide` when a new slide should differ from the project default, or to `update_slide` to change one existing slide while preserving its layers' proportions and relative centers. Mixed ratios should be a deliberate creative choice, not accidental inconsistency. For a flat slide, pass `backgroundColor` directly to `add_slide` or `update_slide`; never create or import a white or colored image just to fill the background. `backgroundColor` and `backgroundPath` are alternatives and must not be supplied together.
65
+
66
+ When `add_slide` uses `backgroundPath` without `aspectRatio`, CarouselBot derives the exact reduced ratio from the source image. Use that default for source-faithful image slides: the image scales to the 1080-pixel canvas width, any workspace space above or below is outside the slide, and export contains only the image-shaped slide canvas plus its editable layers. Supply `aspectRatio` with `backgroundPath` only when the user intentionally wants crop-to-format behavior. Native browser uploads follow the same source-ratio default, and the user can select another ratio afterward.
67
+
60
68
  Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes remain part of one virtual path rather than creating a nested UI hierarchy. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
61
69
 
62
70
  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.
@@ -10,6 +10,21 @@ const folderPath = z.string().min(2).max(160)
10
10
  .regex(/^\/(?!\/)\S(?:[\s\S]*\S)?$/, "Use a canonical folder path with one leading slash and no surrounding whitespace, for example /my-folder.")
11
11
  .refine((value) => value !== "/." && value !== "/..", "Folder paths cannot use the reserved names /. or /..");
12
12
  const color = z.string().regex(/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, "Use a 3- or 6-digit hex color.");
13
+ const ASPECT_RATIO_INPUT_MAX_LENGTH = 80;
14
+ const aspectRatio = z.string().max(ASPECT_RATIO_INPUT_MAX_LENGTH)
15
+ .regex(/^\d{1,39}:\d{1,39}$/, "Use a positive width:height ratio such as 4:5 or 16:9.")
16
+ .refine((value) => {
17
+ try {
18
+ if (value.length > ASPECT_RATIO_INPUT_MAX_LENGTH) return false;
19
+ const [width, height] = value.split(":").map((part) => BigInt(part));
20
+ if (width <= 0n || height <= 0n) return false;
21
+ const canvasHeight = Number((1080n * height + width / 2n) / width);
22
+ return canvasHeight >= 180 && canvasHeight <= 3840;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }, "The ratio must produce a canvas height between 180 and 3840 pixels at 1080 pixels wide.")
27
+ .describe("Canvas ratio. Presets: 9:16, 2:3, 3:4, 4:5, 1:1, 4:3, 16:9. Other positive W:H ratios are reduced to canonical form and must yield a 180–3840 px canvas height at 1080 px wide. A project ratio is the default for new slides; an add_slide or update_slide ratio applies to that slide only.");
13
28
  const unit = z.number().min(-0.5).max(1.5);
14
29
  const positiveUnit = z.number().min(0.01).max(2.4);
15
30
  const expectedRevision = z.number().int().min(0).optional().describe("Optional optimistic-concurrency guard from inspect_editor.");
@@ -26,7 +41,8 @@ const textFields = {
26
41
  fontId,
27
42
  fontWeight: z.number().int().min(1).max(1000).optional(),
28
43
  fontStyle: z.enum(["normal", "italic"]).optional(),
29
- fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional(),
44
+ fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional()
45
+ .describe("Variable-font axis settings. Only wght currently has guaranteed DOM, fitting, and exported-canvas parity; preserve but do not newly apply other axes."),
30
46
  rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
31
47
  };
32
48
  const imageFields = {
@@ -36,6 +52,13 @@ const imageFields = {
36
52
  cropW: z.number().min(0.05).max(1).optional(), cropH: z.number().min(0.05).max(1).optional(),
37
53
  };
38
54
 
55
+ function backgroundSourceSchema(shape) {
56
+ return z.object(shape).strict().refine(
57
+ ({ backgroundColor, backgroundPath }) => !(backgroundColor && backgroundPath),
58
+ { message: "backgroundColor and backgroundPath are mutually exclusive; choose one background source." },
59
+ );
60
+ }
61
+
39
62
  const definitions = new Map();
40
63
 
41
64
  function textResult(value, summary = value) {
@@ -43,7 +66,7 @@ function textResult(value, summary = value) {
43
66
  }
44
67
 
45
68
  function compactMutation(value) {
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"];
69
+ const keys = ["id", "editSessionId", "editorId", "projectId", "aspectRatio", "canvasWidth", "canvasHeight", "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
70
  return Object.fromEntries(keys.flatMap((key) => {
48
71
  if (key === "folderPath" && Object.hasOwn(value || {}, key)) return [[key, value[key] ?? null]];
49
72
  return value?.[key] == null ? [] : [[key, value[key]]];
@@ -171,14 +194,14 @@ export async function createCarouselBotMcpServer(companion) {
171
194
  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 });
172
195
  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 });
173
196
 
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 });
197
+ register("create_project", "Create an empty project without changing the user's current browser view. Choose an optional aspect ratio: use a documented preset or a positive integer W:H value; legacy/default projects use 9:16. 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), aspectRatio: aspectRatio.optional(), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
175
198
  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 });
176
199
  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
200
  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 });
178
201
  register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
179
202
 
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 });
181
- 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 });
203
+ register("add_slide", "Add a slide using a solid backgroundColor or local backgroundPath. A backgroundPath with no aspectRatio adopts the source image's exact reduced ratio, scales it to the 1080-pixel canvas width, and exports only that canvas. A solid slide with no aspectRatio uses the project's default; omit both background sources to use #EEEDE7. Pass aspectRatio to override either default. The browser follows it only when that project is already visible.", backgroundSourceSchema({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), aspectRatio: aspectRatio.optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }), (args) => browserOperation(companion, "add_slide", args), { destructiveHint: false });
204
+ register("update_slide", "Rename a slide, change only that slide's optional aspectRatio, replace its background with either a solid backgroundColor or local backgroundPath, or change background pan/zoom. Ratio changes preserve layer proportions and centers. The two background sources are mutually exclusive. The browser follows it only when that project is already visible.", backgroundSourceSchema({ ...targetSlide, name: z.string().max(160).optional(), aspectRatio: aspectRatio.optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional(), imageScale: z.number().min(1).max(3).optional(), imageX: unit.optional(), imageY: unit.optional() }), (args) => browserOperation(companion, "update_slide", args), { destructiveHint: true });
182
205
  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 });
183
206
  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 });
184
207
  register("delete_slide", "Delete one slide.", z.object({ ...targetSlide, slideId: id }).strict(), (args) => browserOperation(companion, "delete_slide", args), { destructiveHint: true });
package/src/setup.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { cp, mkdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, resolve } 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
7
  import { companionUpgrade } from "./companion.mjs";
@@ -44,13 +44,35 @@ function openCodeSnippet(specifier, version = commandVersion("opencode")) {
44
44
  : { mcp: { [serverName]: { ...server, enabled: true } } }, null, 2);
45
45
  }
46
46
 
47
- async function installSkill() {
48
- const source = join(PACKAGE_ROOT, "skill", "carouselbot");
47
+ export function setupSkillTargets(homeDirectory = homedir(), hermesHome = process.env.HERMES_HOME) {
49
48
  const targets = [
50
- join(homedir(), ".agents", "skills", "carouselbot"),
51
- join(homedir(), ".claude", "skills", "carouselbot"),
52
- join(homedir(), ".hermes", "skills", "carouselbot"),
49
+ join(homeDirectory, ".agents", "skills", "carouselbot"),
50
+ join(homeDirectory, ".claude", "skills", "carouselbot"),
51
+ join(homeDirectory, ".hermes", "skills", "carouselbot"),
53
52
  ];
53
+ if (hermesHome?.trim()) targets.push(join(resolve(hermesHome.trim()), "skills", "carouselbot"));
54
+ return [...new Set(targets)];
55
+ }
56
+
57
+ export function clientSpawnOptions(client) {
58
+ if (client === "hermes") {
59
+ // Hermes tool discovery prompts even after the caller approved CarouselBot
60
+ // setup, and an existing entry adds an overwrite prompt first. Feed both
61
+ // approvals explicitly; EOF otherwise looks like a successful cancel.
62
+ return { input: "y\ny\n", stdio: ["pipe", "inherit", "inherit"] };
63
+ }
64
+ return { stdio: "inherit" };
65
+ }
66
+
67
+ function clientConfigurationPresent(client) {
68
+ if (client !== "hermes") return true;
69
+ const result = spawnSync("hermes", ["mcp", "list"], { encoding: "utf8" });
70
+ return result.status === 0 && /(?:^|\s)carouselbot(?:\s|$)/m.test(String(result.stdout || "") + String(result.stderr || ""));
71
+ }
72
+
73
+ async function installSkill() {
74
+ const source = join(PACKAGE_ROOT, "skill", "carouselbot");
75
+ const targets = setupSkillTargets();
54
76
  for (const target of targets) {
55
77
  await mkdir(target, { recursive: true });
56
78
  await cp(source, target, { recursive: true, force: true });
@@ -93,10 +115,17 @@ export async function runSetup(arguments_) {
93
115
  for (const client of clients) {
94
116
  const command = shellCommand(client, specifier);
95
117
  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`);
118
+ if (client !== "hermes") {
119
+ for (const remove of removeCommands(client)) spawnSync(remove[0], remove.slice(1), { stdio: "ignore" });
120
+ }
121
+ const result = spawnSync(command[0], command.slice(1), clientSpawnOptions(client));
122
+ if (result.status === 0 && clientConfigurationPresent(client)) {
123
+ configured.push(client);
124
+ if (client === "hermes") {
125
+ const legacyRemove = removeCommands(client).find((remove) => remove.at(-1) === legacyServerName);
126
+ if (legacyRemove) spawnSync(legacyRemove[0], legacyRemove.slice(1), { input: "y\n", stdio: ["pipe", "ignore", "ignore"] });
127
+ }
128
+ } else process.stderr.write(`Could not verify ${client}'s CarouselBot config; the existing config was preserved and its command is printed above for manual setup.\n`);
100
129
  }
101
130
  const skillTargets = await installSkill();
102
131
  const companion = await companionUpgrade();