slidemind 0.2.1 → 0.2.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.
@@ -39,34 +39,34 @@ Two gate invariants you must never break — one per gate, equally binding:
39
39
 
40
40
  ## Bootstrap (set yourself up — don't make the user do it)
41
41
 
42
- **The first step of every session is `slidemind ready`** (continuing an existing deck: `slidemind ready <deck>`). In one call it verifies pairing, connection, and CLI version; resolves the design the add-in has open; downloads or refreshes that design's bundle; and — given a deck arg — binds the deck to it.
42
+ **The first step of every session is `slidemind ready`** (continuing an existing deck: `slidemind ready <deck>`). In one call it verifies authorization, connection, and CLI version; resolves the design the add-in has open; downloads or refreshes that design's bundle; and — given a deck arg — binds the deck to it.
43
43
 
44
44
  - **Exit 0** → you're fully set up. Read `workflow/flow.md` (the stage spine) and `design.md` from the reported `bundleDir` and start planning.
45
45
  - **Non-zero** → the CLI names exactly one blocker and one `nextAction`; do what it says. (The two blockers that ever need the user are handled in *First step* below.)
46
46
 
47
- `ready` drives the `slidemind` CLI, which reaches PowerPoint through the add-in's **cloud relay** — no local server. Patch a missing CLI yourself; **pairing** needs the user once (they hold the add-in's identity):
47
+ `ready` drives the `slidemind` CLI, which reaches PowerPoint through the add-in's **cloud relay** — no local server. Patch a missing CLI yourself; **connecting** needs the user once (they hold the add-in's identity):
48
48
 
49
- - **CLI missing** (`slidemind` not on `PATH`): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` ((re)deploys this skill). `npx slidemind <command>` runs one-off without a global install; `slidemind --version` confirms PATH; `slidemind update` upgrades (`--ref <version>` to pin).
50
- - **Not paired** (`ready` blocks with `not_paired`, or a command says *slidemind is not paired*): the one step you can't do for the user. Walk them through **Pairing the CLI with the add-in** below (it carries the restart step the in-app wizard never mentions). `slidemind whoami` lists every paired environment.
49
+ - **CLI missing** (`slidemind` not on `PATH`): `npm install -g slidemind` — that alone is the fix. Only run `slidemind init --for <claude|codex|opencode|all>` ((re)deploys this skill) when this skill did **not** come from a plugin: if the `SKILL.md` you are reading sits under a harness plugin cache (`~/.claude/plugins/…`, `~/.codex/plugins/…`), the plugin already supplies it and `init` would plant a second copy that drifts on the next plugin update. `npx slidemind <command>` runs one-off without a global install; `slidemind --version` confirms PATH; `slidemind update` upgrades (`--ref <version>` to pin) — unless the CLI itself came from a plugin, in which case update it there.
50
+ - **Not connected:** `ready` exits with `not_connected` — the CLI cannot start a connection by itself, because the claim that carries the deployment's address is minted in the add-in. Ask the user to open PowerPoint → slidemind → **Settings → General → Connect CLI**, press the **link button**, and copy the command shown (press **CLI already installed?** for the short `slidemind setup …` form). Have them paste it to you and run it **verbatim** — it connects, refreshes the skills, and runs `ready` itself. Add `--no-install --for none` only if their install must not be touched. `slidemind whoami` lists connected environments.
51
51
 
52
- The CLI is **multi-environment**: pair once per backend (local / dev / prod) and every command fans out across all of them, tags each open window with its `env`, and routes to the right backend automatically — no manual switching. (Scope to one with `--backend <url|env>` when you need to.) A normal user pairs exactly one.
52
+ The CLI is **multi-environment**: connect once per backend (local / dev / prod) and every command fans out across all of them, tags each open window with its `env`, and routes to the right backend automatically — no manual switching. (Scope first-use authorization with `--backend <url>`; scope later calls with `--backend <url|env>`.) A normal user connects to the baked-in production backend only.
53
53
 
54
- ## Pairing the CLI with the add-in (one-time; you guide the user)
54
+ ## Connecting the CLI (one-time; you guide the user)
55
55
 
56
- Pairing is the **only** step that genuinely needs the user — the pairing code carries the add-in's own session, so only they can mint it. When a command fails with *slidemind is not paired* (or `slidemind whoami` lists nothing for this machine), don't retry blindly: walk the user through the steps below, then resume. It's one-time per environment; once paired, `slidemind whoami` shows it.
56
+ Connecting is the **only** step that genuinely needs the user, and it starts in
57
+ PowerPoint — not here. Every organization runs its own slidemind backend, so an
58
+ unconnected CLI has no address to reach and nothing to discover it from; the
59
+ authenticated add-in knows both, and mints a one-time claim carrying its address.
57
60
 
58
- **1 — Mint a pairing code in the add-in.** Tell the user: in PowerPoint, open the **slidemind add-in** task pane → **Settings** → **General** → the **Connect CLI** section (it's marked *Preview*) → press **Pair**. That opens a small 3-step wizard with copy buttons:
59
- 1. `npm install -g slidemind` — skip if the CLI is already installed.
60
- 2. pick the coding agent(s) → the wizard prints the exact `slidemind init --for <…>` — skip if you already ran `init`.
61
- 3. a **one-time** `slidemind pair <code>` command — this is the one that matters.
61
+ **1 — Ask for the command.** Tell the user to open the **slidemind add-in** task pane → **Settings** → **General** → **Connect CLI**, press the **link button**, and press **Copy command**. If they already have the CLI, **CLI already installed?** swaps it to the shorter `slidemind setup …` form; either works.
62
62
 
63
- **2 — Run the pair command in this terminal.** Have the user copy the step-3 `slidemind pair <code>` line and paste it here; run it. The code is single-use and expires (~10 min) — if it fails as expired/invalid, have them press **Pair** again for a fresh one. On success the add-in's Connect CLI panel flips to **Connected**.
63
+ **2 — Run it verbatim.** Paste it exactly as given. It redeems the claim, saves the session, installs the CLI globally if needed, deploys the skills into every coding agent on the machine, and finishes by running `ready` — so no `init` and no add-in restart belong to this flow.
64
64
 
65
- **3 — Restart the add-in once (the wizard does NOT tell the user this).** Pairing flips CLI integration on, but the task pane that's *already running* often won't pick up the live connection — so the freshly-paired window may not show up yet. **If, right after pairing, `slidemind ready` still reports no window** (a `no_window` blocker) even though PowerPoint is open with the add-in loaded and a corporate design active, tell the user to **restart the add-in**: close the slidemind task pane and reopen it from the ribbon; if the window still doesn't appear, close and reopen the presentation (or PowerPoint). A fresh pane re-reads the now-enabled pairing and registers itself so the CLI can see it. Then re-run `slidemind ready`. This is a one-time nudge right after the first pairing — once the window appears it stays, and the add-in can't trigger this restart itself, so you have to ask for it.
65
+ **3 — If it fails.** A claim works once and expires in ~10 minutes; on *already used or expired*, ask for a fresh one. The pane flips to **Connected** by itself once the claim is spent.
66
66
 
67
67
  ## First step — `slidemind ready` (before anything else, every time)
68
68
 
69
- Everything about how a deck is authored, styled, and rendered is shaped by the corporate design **the add-in has open**, and every brand rule you follow is read from that design's **materialized bundle**. `slidemind ready` gets you there in one call: it walks the connection ladder (pairing → reachable backend → CLI version → an open render-ready window), resolves which open design to build against, downloads or refreshes that design's bundle (incremental — unchanged files cost nothing), and — when you pass a deck — binds the deck to it by writing `<deck>/.bundle-ref.json`.
69
+ Everything about how a deck is authored, styled, and rendered is shaped by the corporate design **the add-in has open**, and every brand rule you follow is read from that design's **materialized bundle**. `slidemind ready` gets you there in one call: it walks the connection ladder (a saved session → reachable backend → CLI version → an open render-ready window), resolves which open design to build against, downloads or refreshes that design's bundle (incremental — unchanged files cost nothing), and — when you pass a deck — binds the deck to it by writing `<deck>/.bundle-ref.json`.
70
70
 
71
71
  - **New work:** `slidemind ready` — resolves + materializes the open design's bundle so you can read its `design.md` while planning.
72
72
  - **Continuing a deck:** `slidemind ready <deck>` — same, and (re)binds the deck to whatever design is open.
@@ -80,7 +80,7 @@ Everything about how a deck is authored, styled, and rendered is shaped by the c
80
80
  **On non-zero** the CLI names exactly one blocker `{code, actor, message}` and one `nextAction`. **The CLI owns the remediation text — relay `nextAction`, don't restate it.** Only two blockers ever need the user:
81
81
 
82
82
  - **`ambiguous_design`** — several windows with **different** corporate designs are open, so `ready` can't choose. The CLI lists the candidates (each with its `env` and window `title`/`fileName`, plus a design fingerprint); if the deck was last built against one of them it's marked `suggested`. **Ask the user which design to build against, naming the windows by `title`/`fileName` (and `env`) — never by raw id** — and mention the suggested one if present ("this deck was last built against X — continue with it?"). Then re-run `slidemind ready --to <presId>` for the window they pick.
83
- - **Connection blockers** (`not_paired`, `all_unreachable`, `cli_update_required`, `no_window`, `not_render_ready`) — relay the CLI's `nextAction` verbatim (pair / open PowerPoint / click the pane / run `slidemind update`). Pairing is walked through in *Pairing the CLI with the add-in* above. (`no_window` only ever surfaces for an **unbound** deck or deckless `ready` — a bound deck rides the pane-free rung above instead.)
83
+ - **Connection blockers** (`not_connected`, `all_unreachable`, `cli_update_required`, `no_window`, `not_render_ready`) — relay the CLI's `nextAction` verbatim (copy the setup command / open PowerPoint / click the pane / run `slidemind update`). Connecting is walked through above. (`no_window` only ever surfaces for an **unbound** deck or deckless `ready` — a bound deck rides the pane-free rung above instead.)
84
84
 
85
85
  **The session spine** (= the stage todos you created up front): `slidemind ready` → read `workflow/flow.md` + `design.md` from `bundleDir` → intake/plan (below) → `slidemind new <slug>` (scaffolds **and** auto-binds the deck to the open design — zero extra download, because `ready` already materialized it) → `slidemind plan <slug>` (plan sign-off gate for decks; single slides stay in chat) → `slidemind concepts <slug>` (mandatory concept-mockup pass for a deck, then rebuild toward each) → author → `view-draft` → `review` (mandatory, the moment the deck builds clean) → deliver (`push` / `build-pptx`). `slidemind new` best-effort binds when the open design is unambiguous and the bundle is already materialized; if it can't (ambiguous, or no host), it scaffolds anyway and hints to run `slidemind ready <slug>`.
86
86
 
@@ -13,20 +13,20 @@ So this file owns only what is genuinely CLI-specific: the **host precondition**
13
13
 
14
14
  ## Bootstrap (set yourself up — don't make the user do it)
15
15
 
16
- **The first step of a search session is `slidemind ready --connect-only`** (see *Precondition gate* below). It drives the `slidemind` CLI, which reaches PowerPoint through the add-in's **cloud relay** — no local server. Patch a missing CLI yourself; **pairing** needs the user once (they hold the add-in's identity):
16
+ **The first step of a search session is `slidemind ready --connect-only`** (see *Precondition gate* below). It drives the `slidemind` CLI, which reaches PowerPoint through the add-in's **cloud relay** — no local server. Patch a missing CLI yourself; **connecting** needs the user once (they hold the add-in's identity):
17
17
 
18
- - **CLI missing** (`slidemind` not on `PATH`): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` ((re)deploys this skill).
19
- - **Not paired** (`ready` blocks with `not_paired`, or a command says *slidemind is not paired*): the one step you can't do for the user. Tell them: in the slidemind add-in open **Settings → General → Connect CLI**, press **Pair**, and run the shown one-time `slidemind pair <code>` command in this terminal (the code expires in ~10 min — re-press **Pair** for a fresh one). `slidemind whoami` lists every paired environment.
18
+ - **CLI missing** (`slidemind` not on `PATH`): `npm install -g slidemind` — that alone is the fix. Add `slidemind init --for <claude|codex|opencode|all>` ((re)deploys this skill) only when this skill did **not** come from a plugin: a `SKILL.md` under a harness plugin cache (`~/.claude/plugins/…`, `~/.codex/plugins/…`) is already deployed, and `init` would just plant a second, drifting copy.
19
+ - **Not connected:** `ready --connect-only` exits with `not_connected` — the CLI cannot start a connection by itself, because the claim carrying the deployment's address is minted in the add-in. Ask the user to open PowerPoint → slidemind → **Settings → General → Connect CLI**, press the **link button**, and copy the command shown (**CLI already installed?** gives the short `slidemind setup …` form). Have them paste it to you and run it **verbatim**. `slidemind whoami` lists connected environments.
20
20
 
21
- This is the same setup as `slidemind-create-slides`, whose **Pairing the CLI with the add-in** section carries the full step-by-step (including the restart-the-add-in-once nudge after a fresh pairing). If you set it up there it's already set up here.
21
+ This is the same connection as `slidemind-create-slides`, whose **Connecting the CLI** section carries the full step-by-step. If it was approved there, it is already available here.
22
22
 
23
23
  ## Precondition gate (check this first, every time)
24
24
 
25
25
  Engine search and slide reuse are **host-required** operations — without an open PowerPoint host with the slidemind add-in loaded and an active corporate design, there are no engines to query and nowhere to insert. Check the connection in one call:
26
26
 
27
- 1. Run `slidemind ready --connect-only`. It walks the connection ladder (pairing → reachable backend → CLI version → an open window) but does **not** resolve a design or download a bundle — search doesn't need one.
27
+ 1. Run `slidemind ready --connect-only`. It walks the connection ladder (a saved session → reachable backend → CLI version → an open window) but does **not** resolve a design or download a bundle — search doesn't need one.
28
28
  - **Exit 0 → you're set.** Proceed to `slidemind ls engines` (the live catalog is authoritative — the first real-work step).
29
- - **Non-zero →** the CLI names exactly one blocker and one `nextAction`; relay it (pair / open PowerPoint / click the pane / run `slidemind update`). The CLI owns the remediation text — don't restate it.
29
+ - **Non-zero →** the CLI names exactly one blocker and one `nextAction`; relay it (copy the setup command / open PowerPoint / click the pane / run `slidemind update`). The CLI owns the remediation text — don't restate it.
30
30
  2. **Never pick a window for discovery.** The engine catalog is **per-user, not bound to a window**, so search and discovery attach to **any** connected window automatically. **Never ask the user which window to search with**, and don't pass `--to` to search just to disambiguate — leave it off and let the CLI grab one. (A window only matters when you *insert a slide live*, and even then it's simply the window the search already ran on — see Reuse.)
31
31
 
32
32
  The pane-touching ops here (`search`, `view_slide`, `insert-slide`, `download-result`) wait for the pane themselves: the CLI waits up to ~2 min, prints one *"click the slidemind pane"* line when user help is genuinely needed, and retries safely. Relay that line once and keep waiting — don't re-plan or babysit.
@@ -1,7 +1,8 @@
1
1
  // Client for the slidemind cloud relay.
2
2
  //
3
3
  // The CLI talks to `<backendURL>/api/cli/*` with a Bearer session token — both
4
- // established once by `slidemind pair` (see config.mjs). The add-in's cloud
4
+ // established by redeeming a one-time setup claim minted in the authenticated add-in.
5
+ // The add-in's cloud
5
6
  // backend runs render/bundle in-process and relays insert/replace/engine to the
6
7
  // open PowerPoint pane. No localhost, no shared loopback token, no cert.
7
8
 
@@ -121,11 +122,11 @@ function readinessTimeoutMs() {
121
122
  }
122
123
 
123
124
  const NOT_PAIRED_MESSAGE =
124
- 'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
125
- "and run the shown `slidemind pair …` command, then retry.";
125
+ "slidemind is not connected. In PowerPoint open slidemind → Settings → " +
126
+ "General → Connect CLI, press the link button, and run the command it shows.";
126
127
 
127
128
  // The one-command remedy every host-precondition error now points at:
128
- // `slidemind ready` runs the whole pairing → reachability → version → window →
129
+ // `slidemind ready` runs authorization → reachability → version → window →
129
130
  // design ladder and materializes the bundle, so a stuck author has a single
130
131
  // next step instead of a scavenger hunt across doctor/presentations/download.
131
132
  // Appended (never substituted) to the existing prose so callers' assert.match
@@ -230,7 +231,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
230
231
  if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
231
232
  throw new Error(
232
233
  `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
233
- "if it persists, re-pair with `slidemind pair`.",
234
+ "if it persists, reconnect from the add-in's Connect CLI panel.",
234
235
  );
235
236
  }
236
237
  throw err;
@@ -238,8 +239,8 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
238
239
  enforceVersionFloor(response);
239
240
  if (response.status === 401) {
240
241
  throw new Error(
241
- "slidemind session is invalid or expired. Re-pair: open the add-in, " +
242
- 'click "Connect CLI", and run the shown `slidemind pair …`.',
242
+ "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
243
+ "reconnect with a fresh command from the add-in's Connect CLI panel.",
243
244
  );
244
245
  }
245
246
  if (response.status < 200 || response.status >= 300) {
@@ -447,7 +448,7 @@ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
447
448
  ) {
448
449
  throw new Error(
449
450
  `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
450
- "if it persists, re-pair with `slidemind pair`.",
451
+ "if it persists, reconnect from the add-in's Connect CLI panel.",
451
452
  );
452
453
  }
453
454
  throw err;
@@ -455,8 +456,8 @@ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
455
456
  enforceVersionFloor(response);
456
457
  if (response.status === 401) {
457
458
  throw new Error(
458
- "slidemind session is invalid or expired. Re-pair: open the add-in, " +
459
- 'click "Connect CLI", and run the shown `slidemind pair …`.',
459
+ "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
460
+ "reconnect with a fresh command from the add-in's Connect CLI panel.",
460
461
  );
461
462
  }
462
463
  if (response.status < 200 || response.status >= 300) {
@@ -1,5 +1,5 @@
1
- // CLI session config: where `slidemind pair` stores the cloud relay endpoint(s)
2
- // + the opaque session token each was issued. The CLI talks to
1
+ // CLI session config: connecting stores the cloud relay endpoint(s) + the
2
+ // opaque session token each was issued. The CLI talks to
3
3
  // <backendURL>/api/cli/* with `Authorization: Bearer <sessionToken>`; no
4
4
  // localhost, no cert.
5
5
  //
@@ -1,18 +1,14 @@
1
- // Pairing + session helpers for the CLI side of the cloud relay.
2
- //
3
- // `slidemind pair <blob>` decodes {u: backendURL, s: secret} and redeems the
4
- // one-time secret for an opaque session token, stored by config.mjs. whoami/
5
- // logout call the session-gated endpoints with that token.
1
+ // Minimal JSON-over-HTTP transport shared by setup-claim redemption and session
2
+ // management. Validates real TLS certificates; loopback development may use a
3
+ // self-signed HTTPS certificate or plain HTTP.
6
4
 
7
5
  import http from "node:http";
8
6
  import https from "node:https";
9
7
 
10
- function isLoopbackHostname(h) {
11
- return h === "127.0.0.1" || h === "localhost" || h === "::1";
8
+ function isLoopbackHostname(hostname) {
9
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
12
10
  }
13
11
 
14
- // Minimal JSON request. Validates the cloud's real cert; skips only for a
15
- // loopback dev backend (self-signed).
16
12
  export function httpJson(method, urlStr, { body = null, token = null } = {}) {
17
13
  const url = new URL(urlStr);
18
14
  const transport = url.protocol === "https:" ? https : http;
@@ -30,11 +26,13 @@ export function httpJson(method, urlStr, { body = null, token = null } = {}) {
30
26
  method,
31
27
  headers,
32
28
  rejectUnauthorized:
33
- url.protocol === "https:" ? !isLoopbackHostname(url.hostname) : undefined,
29
+ url.protocol === "https:"
30
+ ? !isLoopbackHostname(url.hostname)
31
+ : undefined,
34
32
  },
35
33
  (res) => {
36
34
  const chunks = [];
37
- res.on("data", (c) => chunks.push(Buffer.from(c)));
35
+ res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
38
36
  res.on("end", () => {
39
37
  const text = Buffer.concat(chunks).toString("utf8");
40
38
  let json = null;
@@ -52,16 +50,3 @@ export function httpJson(method, urlStr, { body = null, token = null } = {}) {
52
50
  req.end();
53
51
  });
54
52
  }
55
-
56
- // Decode a `slidemind pair <blob>` code → { backendURL, secret } or null.
57
- export function decodePairingCode(code) {
58
- try {
59
- const obj = JSON.parse(Buffer.from(code, "base64url").toString("utf8"));
60
- if (obj && typeof obj.u === "string" && typeof obj.s === "string") {
61
- return { backendURL: obj.u, secret: obj.s };
62
- }
63
- } catch {
64
- /* malformed */
65
- }
66
- return null;
67
- }
@@ -0,0 +1,127 @@
1
+ // One-command setup claims — the CLI half of "click Connect CLI in PowerPoint,
2
+ // paste one command".
3
+ //
4
+ // Why the add-in mints this rather than the CLI: the CLI cannot discover WHICH slidemind
5
+ // deployment a user belongs to. Every customer runs its own backend, so an
6
+ // anonymous CLI has no address to start an approval at. The authenticated
7
+ // add-in already knows its deployment, so it mints a claim that carries the
8
+ // backend URL alongside a one-time secret; the user pastes it once and the CLI
9
+ // learns both at the same moment. This is the ONLY way a CLI gets a session.
10
+ //
11
+ // Wire shape: `smc1_<base64url({ v: 1, u: <backendURL>, s: <secret> })>`.
12
+ // The secret is the credential — it is one-time, short-lived, and bound
13
+ // server-side to the identity that minted it. Nothing here is a bearer token
14
+ // for anything else, and the claim is worthless once redeemed.
15
+
16
+ import os from "node:os";
17
+
18
+ import { httpJson } from "./http-json.mjs";
19
+
20
+ export const SETUP_CLAIM_PREFIX = "smc1_";
21
+
22
+ function isLoopback(hostname) {
23
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
24
+ }
25
+
26
+ function invalid(detail) {
27
+ return new Error(
28
+ `${detail}\n\n` +
29
+ "Open PowerPoint → slidemind → Settings → General → Connect CLI and copy a fresh setup command.",
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Decode + validate a setup claim into { backendURL, secret }.
35
+ *
36
+ * Tolerates the shapes a copied terminal command actually arrives in: wrapping
37
+ * whitespace, surrounding quotes, and the whole `npx --yes slidemind@latest
38
+ * setup <claim>` line pasted as one argument.
39
+ */
40
+ export function decodeSetupClaim(raw) {
41
+ let text = String(raw ?? "").trim();
42
+ // A user who quotes the argument, or pastes the entire command as one shell
43
+ // word, still gets what they meant.
44
+ text = text.replace(/^['"]|['"]$/g, "").trim();
45
+ if (text.includes(SETUP_CLAIM_PREFIX) && !text.startsWith(SETUP_CLAIM_PREFIX)) {
46
+ const match = text.match(/smc1_[A-Za-z0-9_-]+/);
47
+ if (match) text = match[0];
48
+ }
49
+
50
+ if (!text.startsWith(SETUP_CLAIM_PREFIX)) {
51
+ throw invalid(
52
+ "that does not look like a slidemind setup command (it should end with a smc1_… claim).",
53
+ );
54
+ }
55
+
56
+ const body = text.slice(SETUP_CLAIM_PREFIX.length);
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
60
+ } catch {
61
+ throw invalid("this setup command is damaged — it was probably copied only in part.");
62
+ }
63
+
64
+ if (!parsed || parsed.v !== 1) {
65
+ throw invalid(
66
+ "this setup command was made by a newer slidemind add-in than this CLI understands. Run `npm install -g slidemind@latest` and try again.",
67
+ );
68
+ }
69
+
70
+ const secret = typeof parsed.s === "string" ? parsed.s : "";
71
+ if (!secret) throw invalid("this setup command carries no connection secret.");
72
+
73
+ let url;
74
+ try {
75
+ url = new URL(String(parsed.u ?? ""));
76
+ } catch {
77
+ throw invalid("this setup command carries no valid slidemind address.");
78
+ }
79
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
80
+ throw invalid(
81
+ "this setup command points at a non-HTTPS slidemind address (HTTP is allowed only for loopback development).",
82
+ );
83
+ }
84
+
85
+ return { backendURL: url.toString().replace(/\/$/, ""), secret };
86
+ }
87
+
88
+ /**
89
+ * Spend the one-time secret at its own deployment and receive the CLI session.
90
+ * The claim is consumed server-side whether or not this process survives, so a
91
+ * failure here always means "get a fresh command", never "retry".
92
+ */
93
+ export async function redeemSetupClaim({ backendURL, secret, cliVersion }) {
94
+ let result;
95
+ try {
96
+ result = await httpJson("POST", `${backendURL}/api/cli/connect/redeem`, {
97
+ body: {
98
+ secret,
99
+ deviceName: os.hostname() || "CLI device",
100
+ platform: `${process.platform}-${process.arch}`,
101
+ cliVersion,
102
+ },
103
+ });
104
+ } catch (error) {
105
+ throw new Error(
106
+ `could not reach slidemind to complete setup: ${error?.message || error}`,
107
+ );
108
+ }
109
+
110
+ if (result.status === 200 && result.json?.sessionToken) {
111
+ return String(result.json.sessionToken);
112
+ }
113
+ // Discriminate on the error CODE, not the status. A deployment that predates
114
+ // one-command setup has no such route, so the request falls through to that
115
+ // router's session guard and comes back 401 `no_session` — the same status a
116
+ // spent claim returns. Only the body tells the two apart.
117
+ if (result.status === 401 && result.json?.error === "setup_claim_invalid_or_expired") {
118
+ throw invalid("this setup command has already been used or has expired.");
119
+ }
120
+ if (result.status === 401 || result.status === 404 || result.status === 405) {
121
+ throw new Error(
122
+ "this slidemind add-in is too old for one-command setup — it needs an add-in update before the CLI can connect to it.",
123
+ );
124
+ }
125
+ const detail = result.json?.error || result.text || `HTTP ${result.status}`;
126
+ throw new Error(`slidemind could not complete setup: ${detail}`);
127
+ }
@@ -840,10 +840,10 @@ export async function buildDeck(deckDir, opts = {}) {
840
840
  if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
841
841
  throw new Error(`Expected a 'slides/' folder in ${deckDir}`);
842
842
  }
843
- if (skill.mode === "fill-template" && !opts.internalProposalFreeformRender) {
844
- // Proposal decks are PPTX-native. The exporter may render freeform JSX
843
+ if (skill.mode === "fill-template" && !opts.internalFillFreeformRender) {
844
+ // fill-template decks are PPTX-native. The exporter may render freeform JSX
845
845
  // inserts internally, but `slidemind build` should not create a lossy
846
- // presentation.html artifact for proposal decks.
846
+ // presentation.html artifact for them.
847
847
  throw new Error(
848
848
  `Deck ${path.basename(deckDir)} uses skill '${skill.skillName}' (mode ` +
849
849
  `fill-template); it has no HTML preview. Run 'slidemind build-pptx ` +
@@ -92,12 +92,15 @@ export function resolveDeckMode(deckDir) {
92
92
  }
93
93
  }
94
94
 
95
- // Skill packs can live outside the npm package: confidential template packs
96
- // (e.g. proposal masters) install to the user-level skills dir and are found
97
- // FIRST, so the public package never has to carry them. Lookup order:
95
+ // Skill packs can live outside the npm package: private, org-internal packs
96
+ // (templates, brand-specific assets) are distributed on their own and this
97
+ // package never carries them. Lookup order:
98
98
  // 1. $SLIDEMIND_SKILLS_DIR/<name> (tests / non-standard installs)
99
99
  // 2. ~/.config/slidemind/skills/<name>
100
- // 3. <package>/skills/<name>
100
+ // 3. <package>/skills/<name> (this CLI's own skills win on their names)
101
+ // 4. the harness skill + plugin dirs (see harnessSkillDirs)
102
+ // 3 precedes 4 so adding 4 is strictly additive: it is reached only for names
103
+ // this package does not ship, i.e. exactly the externally-distributed packs.
101
104
  export function userSkillsRoot() {
102
105
  return (
103
106
  process.env.SLIDEMIND_SKILLS_DIR ||
@@ -109,12 +112,69 @@ export function userSkillsRoot() {
109
112
  );
110
113
  }
111
114
 
112
- function locateSkillDir(skillName) {
115
+ // Every distribution channel a pack can arrive through — a Claude Code or
116
+ // Codex plugin, or a workspace/admin-pushed skill — installs it into a HARNESS
117
+ // directory, not ours. Without these the agent reads the pack's SKILL.md and
118
+ // the build then fails to find the pack's own templates, which looks like a
119
+ // broken pack rather than a lookup gap.
120
+ //
121
+ // Installers namespace copied skills as `slidemind-<name>`, so both spellings
122
+ // are tried. Plugin caches nest as <root>/<marketplace>/<plugin>/skills/<name>.
123
+ function harnessRoots() {
124
+ const home = os.homedir();
125
+ return [
126
+ path.join(home, ".claude"),
127
+ path.join(home, ".codex"),
128
+ // opencode keeps its config under XDG.
129
+ path.join(
130
+ process.env.XDG_CONFIG_HOME || path.join(home, ".config"),
131
+ "opencode",
132
+ ),
133
+ ];
134
+ }
135
+
136
+ function harnessSkillDirs(skillName) {
137
+ const names = [skillName, `slidemind-${skillName}`];
138
+ const dirs = [];
139
+ for (const root of harnessRoots()) {
140
+ for (const name of names) dirs.push(path.join(root, "skills", name));
141
+ // Plugin caches: <root>/plugins/cache/<marketplace>/<plugin>/skills/<name>
142
+ const cacheRoot = path.join(root, "plugins", "cache");
143
+ let marketplaces = [];
144
+ try {
145
+ marketplaces = fs.readdirSync(cacheRoot);
146
+ } catch {
147
+ continue;
148
+ }
149
+ for (const mp of marketplaces) {
150
+ let plugins = [];
151
+ try {
152
+ plugins = fs.readdirSync(path.join(cacheRoot, mp));
153
+ } catch {
154
+ continue;
155
+ }
156
+ for (const plugin of plugins) {
157
+ for (const name of names) {
158
+ dirs.push(path.join(cacheRoot, mp, plugin, "skills", name));
159
+ }
160
+ }
161
+ }
162
+ }
163
+ return dirs;
164
+ }
165
+
166
+ // Exported for tests: the pack-location rules are the contract every external
167
+ // distribution channel depends on, and they deserve assertions that do not
168
+ // also have to satisfy design resolution.
169
+ export function locateSkillDir(skillName) {
113
170
  const candidates = [
114
171
  path.join(userSkillsRoot(), skillName),
115
172
  path.join(PACKAGE_ROOT, "skills", skillName),
173
+ ...harnessSkillDirs(skillName),
116
174
  ];
117
- return firstExisting(candidates) ?? candidates[candidates.length - 1];
175
+ // The fallback stays the package path, so a missing pack still reports the
176
+ // location a bundled skill would live at rather than a harness cache dir.
177
+ return firstExisting(candidates) ?? path.join(PACKAGE_ROOT, "skills", skillName);
118
178
  }
119
179
 
120
180
  export function resolveSkill(skillName, planMeta = {}, opts = {}) {
@@ -14,6 +14,7 @@
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import {
17
+ binaryOnPath,
17
18
  expandHome,
18
19
  installDir,
19
20
  isForce,
@@ -91,6 +92,29 @@ function pluginInstallDetected() {
91
92
  }
92
93
  }
93
94
 
95
+ // Is Claude Code on this machine, and would deploying here duplicate a plugin?
96
+ // `--for auto` (what `slidemind setup` uses) installs only where the harness
97
+ // actually exists AND its plugin has not already supplied the skills — copying
98
+ // over a plugin's skills plants a second set that drifts on the next plugin
99
+ // update, which is the one thing plugin users must not do.
100
+ //
101
+ // Two signals, because either alone misses a real install: the config dir is
102
+ // created the first time the agent RUNS (so a just-installed, never-launched
103
+ // Claude Code has none), while the binary is missing for a desktop-app-only
104
+ // install. Present if either says so.
105
+ export function detect() {
106
+ const root = claudeRoot();
107
+ const configured = fs.existsSync(root);
108
+ const onPath = binaryOnPath("claude");
109
+ return {
110
+ root,
111
+ configured,
112
+ onPath,
113
+ present: configured || onPath,
114
+ pluginInstalled: pluginInstallDetected(),
115
+ };
116
+ }
117
+
94
118
  export async function install({ packageRoot, flags }) {
95
119
  const link = isLinkMode(flags);
96
120
  const force = isForce(flags);
@@ -25,6 +25,7 @@ import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { spawnSync } from "node:child_process";
27
27
  import {
28
+ binaryOnPath,
28
29
  expandHome,
29
30
  installDir,
30
31
  isForce,
@@ -81,6 +82,33 @@ function agentsFile() {
81
82
  return path.join(codexRoot(), "AGENTS.md");
82
83
  }
83
84
 
85
+ // `$CODEX_HOME/plugins/cache/<marketplace>/slidemind/<version>/` — a non-empty
86
+ // plugin directory means the Codex plugin is also installed, so the skills
87
+ // copied by `init` duplicate the ones the plugin already carries (and will
88
+ // drift the next time either side updates). Mirrors the Claude installer's
89
+ // check. A marketplace-only `cache/slidemind/` (added but never installed from)
90
+ // holds no `slidemind` child, so it correctly reads as "not installed".
91
+ export function pluginInstallDetected() {
92
+ const cacheRoot = path.join(codexRoot(), "plugins", "cache");
93
+ try {
94
+ return fs.readdirSync(cacheRoot).some((mp) => {
95
+ const dir = path.join(cacheRoot, mp, NAMESPACE);
96
+ try {
97
+ return (
98
+ fs.statSync(dir).isDirectory() &&
99
+ fs
100
+ .readdirSync(dir)
101
+ .some((entry) => !entry.startsWith("."))
102
+ );
103
+ } catch {
104
+ return false;
105
+ }
106
+ });
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
84
112
  function configTomlFile() {
85
113
  return path.join(codexRoot(), "config.toml");
86
114
  }
@@ -119,13 +147,18 @@ function writeAgentsBlock() {
119
147
  const block =
120
148
  `${AGENTS_MARK_BEGIN}\n` +
121
149
  `## slidemind\n` +
122
- `Slide-deck authoring skill installed via slidemind.\n` +
150
+ `Slide-deck authoring and slide-reuse skills installed via slidemind.\n` +
123
151
  `\n` +
124
152
  `When the user asks to create, plan, or edit a slide deck or presentation, load\n` +
125
153
  `the \`slidemind-create-slides\` skill (auto-discovered from\n` +
126
- `${skillTarget("create-slides")}/SKILL.md) and follow it. The skill drives the\n` +
127
- `slidemind PowerPoint add-in and reads every brand rule — tokens, layout\n` +
128
- `palette, brand imagery — from the design bundle the add-in serves.\n` +
154
+ `${skillTarget("create-slides")}/SKILL.md) and follow it.\n` +
155
+ `\n` +
156
+ `When the user asks to find or reuse an existing slide or brand image — from the\n` +
157
+ `corporate design's engines or from a \`.pptx\` on disk — load the\n` +
158
+ `\`slidemind-search\` skill (${skillTarget("search")}/SKILL.md) instead.\n` +
159
+ `\n` +
160
+ `Both drive the slidemind PowerPoint add-in and read every brand rule — tokens,\n` +
161
+ `layout palette, brand imagery — from the design bundle the add-in serves.\n` +
129
162
  `\n` +
130
163
  `Build with: \`slidemind build <deck>\` (HTML) or \`slidemind build-pptx <deck>\` (PPTX).\n` +
131
164
  `${AGENTS_MARK_END}\n`;
@@ -155,6 +188,20 @@ function clearAgentsBlock() {
155
188
  fs.writeFileSync(file, existing);
156
189
  }
157
190
 
191
+ // See claude.mjs `detect()` — same two-signal contract, Codex roots.
192
+ export function detect() {
193
+ const root = codexRoot();
194
+ const configured = fs.existsSync(root);
195
+ const onPath = binaryOnPath("codex");
196
+ return {
197
+ root,
198
+ configured,
199
+ onPath,
200
+ present: configured || onPath,
201
+ pluginInstalled: pluginInstallDetected(),
202
+ };
203
+ }
204
+
158
205
  export async function install({ packageRoot, flags }) {
159
206
  const link = isLinkMode(flags);
160
207
  const force = isForce(flags);
@@ -202,6 +249,15 @@ export async function install({ packageRoot, flags }) {
202
249
  ui.step({ status: "remove", label: "mcp server", value: mcpNote });
203
250
  }
204
251
 
252
+ if (pluginInstallDetected()) {
253
+ ui.step({
254
+ status: "info",
255
+ label: "plugin",
256
+ value:
257
+ "the slidemind Codex plugin is also installed — these copied skills duplicate its skills; plugin users only need `npm install -g slidemind` and can drop them with `slidemind uninstall --for codex`",
258
+ });
259
+ }
260
+
205
261
  ui.note("restart Codex to pick up the skill");
206
262
  }
207
263