slidemind 0.3.13 → 0.3.14

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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +19 -0
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +11 -804
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,113 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { envTag } from "../bridge/config.mjs";
4
+
5
+ // Best-effort human name of a materialized design. Prefers the live `name`
6
+ // the wire carries (persisted into bundle.json by materializeBundle) — the
7
+ // owner-set brand name, which survives renames. Falls back to the first `# `
8
+ // heading in design.md (frozen at authoring time, so it may still show the
9
+ // original auto codename), with the " — design rules" suffix stripped.
10
+ // Returns null on any failure so callers fall back to the raw presFP.
11
+ // Shared by `ready` and `new`'s auto-bind.
12
+ export function readDesignName(bundleDir) {
13
+ if (!bundleDir) return null;
14
+ try {
15
+ const meta = JSON.parse(
16
+ fs.readFileSync(path.join(bundleDir, "bundle.json"), "utf8"),
17
+ );
18
+ if (typeof meta.name === "string" && meta.name.trim()) {
19
+ return meta.name.trim();
20
+ }
21
+ } catch {
22
+ /* no meta or unreadable — fall through to the design.md heading */
23
+ }
24
+ try {
25
+ const md = fs.readFileSync(path.join(bundleDir, "design.md"), "utf8");
26
+ for (const raw of md.split("\n")) {
27
+ const line = raw.trim();
28
+ if (line.startsWith("# ")) {
29
+ const name = line
30
+ .slice(2)
31
+ .trim()
32
+ .replace(/\s+[—-]\s+design rules\s*$/i, "")
33
+ .trim();
34
+ return name || null;
35
+ }
36
+ }
37
+ return null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ // The slide master's theme colours, from the one line design.md carries them
44
+ // on (`Theme colors: dk1=#000000, lt1=#FFFFFF, …, accent1=#0000FF, …`). This
45
+ // is what lets the agent SHOW a design to the user instead of naming it —
46
+ // {dk1, lt1, dk2, lt2, accent1..6} → "#RRGGBB", or null when the bundle has
47
+ // no such line.
48
+ export function readThemeColors(bundleDir) {
49
+ if (!bundleDir) return null;
50
+ try {
51
+ const md = fs.readFileSync(path.join(bundleDir, "design.md"), "utf8");
52
+ const line = md.split("\n").find((l) => /^\s*Theme colors:/i.test(l));
53
+ if (!line) return null;
54
+ const colors = {};
55
+ for (const [, key, hex] of line.matchAll(/(\w+)=(#[0-9a-fA-F]{6})/g)) {
56
+ colors[key] = hex.toUpperCase();
57
+ }
58
+ return Object.keys(colors).length ? colors : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Every design materialized on this machine, the way `ready` reports it: name
65
+ // + theme colours per design so the agent can put the choice to the user when
66
+ // more than one is at hand. `current` marks the one this run resolved.
67
+ export function cachedDesigns(bundle, currentPresFP) {
68
+ return bundle.listCachedBundles().map((b) => ({
69
+ presFP: b.presFP,
70
+ name: readDesignName(b.dir),
71
+ colors: readThemeColors(b.dir),
72
+ current: b.presFP === currentPresFP,
73
+ }));
74
+ }
75
+
76
+ // One menu row per design: id, name, theme swatch, and the verified windows
77
+ // open on it. `(not cached)` marks a design only a window or the backend knows.
78
+ export function designRows(designs) {
79
+ return designs.map((d) => {
80
+ const c = d.colors ?? {};
81
+ const swatch = ["accent1", "accent2", "dk1", "lt1"]
82
+ .filter((k) => c[k])
83
+ .map((k) => `${k} ${c[k]}`)
84
+ .join(", ");
85
+ const open = (d.windows ?? [])
86
+ .map((w) => `${w.title || w.fileName || "(untitled)"}${envTag(w.env)}`)
87
+ .join(", ");
88
+ return (
89
+ ` ${d.current ? "*" : " "} ${d.presFP} ${d.name ?? "(unnamed)"}` +
90
+ `${swatch ? ` [${swatch}]` : ""}` +
91
+ `${d.cached === false ? " (not cached)" : ""}` +
92
+ `${open ? ` open: ${open}` : ""}`
93
+ );
94
+ });
95
+ }
96
+
97
+ export function designsHumanLines(designs) {
98
+ if (designs.length < 2) return "";
99
+ return (
100
+ ` ${designs.length} designs at hand — ask the user which to build against (* = current):\n` +
101
+ `${designRows(designs).join("\n")}\n` +
102
+ " Switch with: slidemind ready [deck] --brand <presFP>\n"
103
+ );
104
+ }
105
+
106
+ export function windowsHumanLine(windows) {
107
+ if (windows.length === 0) return "";
108
+ const rows = windows.map(
109
+ (w) =>
110
+ `${w.title || w.fileName || "(untitled)"}${envTag(w.env)}${w.paneReady ? "" : " (suspended)"}`,
111
+ );
112
+ return ` windows: ${rows.join(", ")}\n`;
113
+ }
@@ -0,0 +1,141 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { fail, loadBridgeModule, SRC_DIR } from "./common.mjs";
4
+ import { publicConnectionStatus, backendBlocker, paneBlocker, connectionNextAction } from "./connection.mjs";
5
+
6
+ export async function runDiagnose(rest) {
7
+ const asJson = rest.includes("--json");
8
+ const cfg = await loadBridgeModule("config");
9
+ const sessions = cfg.readSessions();
10
+ const client = await import(
11
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
12
+ );
13
+ // The diagnostic reports skew — it must never be killed BY skew. The version floor
14
+ // still reaches the reader, as the `cli_update_required` rung of the ladder.
15
+ client.setDiagnosticMode(true);
16
+ if (sessions.length === 0) {
17
+ const blocker = backendBlocker({ paired: false });
18
+ const pub = {
19
+ paired: false,
20
+ backendCount: 0,
21
+ backends: [],
22
+ presentations: [],
23
+ authoring: { ready: false, blocker, degraded: null, engines: null },
24
+ host: { ready: false, blocker: paneBlocker({ presentations: [] }) },
25
+ nextAction: connectionNextAction({ paired: false }),
26
+ };
27
+ process[asJson ? "stdout" : "stderr"].write(
28
+ asJson ? `${JSON.stringify(pub, null, 2)}\n` : `${pub.nextAction}\n`,
29
+ );
30
+ return;
31
+ }
32
+ const statusRaw = await client.connectionStatus();
33
+ const status = publicConnectionStatus(statusRaw, client);
34
+ // The two ladders are INDEPENDENT: authoring (session → reachable backend →
35
+ // CLI version → engine reachability) is what render, bundle and the pane-free
36
+ // engine routes ride on, while the live-PowerPoint ladder only governs
37
+ // push/insert-slide and reading an open presentation. Reporting them
38
+ // separately is the point — a dead pane is not "cannot work".
39
+ const authoringBlocker = backendBlocker(status);
40
+ let engines = { available: false, envs: [] };
41
+ if (!authoringBlocker) {
42
+ try {
43
+ engines = await client.engineReachability({ backends: statusRaw.backends });
44
+ } catch {
45
+ /* leave it as unavailable — never fail a diagnosis on a probe */
46
+ }
47
+ }
48
+ status.authoring = {
49
+ ready: !authoringBlocker,
50
+ blocker: authoringBlocker,
51
+ engines,
52
+ };
53
+ const hostBlocker = paneBlocker(status);
54
+ status.host = { ready: !hostBlocker, blocker: hostBlocker };
55
+ status.nextAction = connectionNextAction(status);
56
+ if (asJson) {
57
+ process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
58
+ return;
59
+ }
60
+ process.stdout.write(`Paired backends: ${status.backendCount}\n`);
61
+ for (const b of status.backends) {
62
+ process.stdout.write(
63
+ ` ${b.env}: ${b.ok ? "reachable" : `unreachable — ${b.error}`}` +
64
+ (b.readinessV1 ? " (readiness)" : " (legacy)") +
65
+ `\n`,
66
+ );
67
+ }
68
+ process.stdout.write(
69
+ `Backend authoring ready: ${status.authoring.ready ? "yes" : "no"}` +
70
+ `${status.authoring.ready ? "" : ` — ${authoringBlocker.message}`}\n`,
71
+ );
72
+ process.stdout.write(
73
+ ` engine search (no window needed): ${
74
+ engines.available
75
+ ? `yes [${engines.envs.join(", ")}]`
76
+ : "no — relayed through an open pane"
77
+ }\n`,
78
+ );
79
+ // Human output only — the --json shape is a contract and the m365 state
80
+ // already rides `ready --json`. Printed only when a backend can actually
81
+ // spend a sign-in (graphDelegated), so old deployments stay noise-free.
82
+ const graphAdvertising = (statusRaw.backends ?? []).filter(
83
+ (b) => b.ok && b.capabilities?.graphDelegated === true && b.backendURL,
84
+ );
85
+ if (graphAdvertising.length > 0) {
86
+ const graph = await loadBridgeModule("graph-auth");
87
+ const signedOut = graphAdvertising.filter(
88
+ (b) => !graph.hasGraphSession(b.backendURL),
89
+ );
90
+ const signedOutEnvs = signedOut.map((b) => cfg.deriveEnvLabel(b.backendURL));
91
+ process.stdout.write(
92
+ signedOut.length === 0
93
+ ? " Microsoft 365: signed in\n"
94
+ : ` Microsoft 365: not signed in [${signedOutEnvs.join(", ")}] — engine search and ` +
95
+ "downloads run as the user; the next engine command opens a browser " +
96
+ "sign-in for them to complete (or run 'slidemind login' now)\n",
97
+ );
98
+ }
99
+ process.stdout.write(
100
+ `Live PowerPoint ready: ${status.host.ready ? "yes" : "no"}` +
101
+ `${status.host.ready ? "" : ` — ${hostBlocker.message}`}\n`,
102
+ );
103
+ if (status.presentations.length === 0) {
104
+ process.stdout.write("Open presentations: none\n");
105
+ } else {
106
+ process.stdout.write("Open presentations:\n");
107
+ // Pane-ready windows first, then suspended/stale ones (stable sort keeps
108
+ // each group's incoming order) — a reader picking a target wants the
109
+ // windows that can take live ops at the top.
110
+ const rows = [...status.presentations].sort(
111
+ (a, b) =>
112
+ (client.isPaneReadyPresentation(b) ? 1 : 0) -
113
+ (client.isPaneReadyPresentation(a) ? 1 : 0),
114
+ );
115
+ for (const p of rows) {
116
+ const label = p.title || p.fileName || "(untitled)";
117
+ const state =
118
+ p.connectionState ||
119
+ (p.connected === false || p.suspendedAt ? "suspended" : "connected");
120
+ const design = p.designState || (p.brandId ? "ready" : "unknown");
121
+ const caps = p.capabilities
122
+ ? ` render:${p.capabilities.render === true ? "yes" : "no"} pane:${p.capabilities.paneOps === true ? "yes" : "no"}`
123
+ : "";
124
+ process.stdout.write(
125
+ ` ${p.presId}\t${p.env ? `[${p.env}] ` : ""}${label}\t${state}\tcd:${design}${caps}\n`,
126
+ );
127
+ // A window whose pane can't take live ops yet is still listed — with the
128
+ // one actionable line to wake it, so the reader sees an honest state.
129
+ if (!client.isPaneReadyPresentation(p)) {
130
+ process.stdout.write(
131
+ `\t${
132
+ p.nextAction && typeof p.nextAction.message === "string"
133
+ ? p.nextAction.message
134
+ : "bring PowerPoint forward and click the slidemind pane"
135
+ }\n`,
136
+ );
137
+ }
138
+ }
139
+ }
140
+ process.stdout.write(`Next action: ${status.nextAction}\n`);
141
+ }
@@ -0,0 +1,144 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { fail, resolveDeckArg, SRC_DIR } from "./common.mjs";
4
+ import { readDesignName } from "./designs.mjs";
5
+
6
+ export async function cmdDownloadBundle(rest) {
7
+ let to = null;
8
+ let brand = null;
9
+ let force = false;
10
+ let all = false;
11
+ const positional = [];
12
+ for (let i = 0; i < rest.length; i++) {
13
+ const a = rest[i];
14
+ if (a === "--to") {
15
+ to = rest[++i];
16
+ if (!to) fail("--to expects a presId");
17
+ } else if (a === "--brand") {
18
+ brand = rest[++i];
19
+ if (!brand) fail("--brand expects a design fingerprint (presFP)");
20
+ } else if (a === "--force") {
21
+ force = true;
22
+ } else if (a === "--all") {
23
+ all = true;
24
+ } else if (a.startsWith("-")) {
25
+ fail(`unknown flag: ${a}`);
26
+ } else {
27
+ positional.push(a);
28
+ }
29
+ }
30
+ if (all && (to || brand || positional.length > 0)) {
31
+ fail("--all refreshes every cached design — not with a deck, --brand or --to");
32
+ }
33
+ const client = await import(
34
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
35
+ );
36
+ const bundle = await import(
37
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
38
+ );
39
+ // --all: refresh every design already materialized here, over the pane-free
40
+ // brand route — the "update my bundles" half of bundle management, so a
41
+ // corporate-design change lands locally without opening PowerPoint or naming
42
+ // each design. One failure (a design the connected backends no longer serve)
43
+ // never stops the rest.
44
+ if (all) {
45
+ const cached = bundle.listCachedBundles();
46
+ if (cached.length === 0) {
47
+ fail(
48
+ "No design bundle is cached here yet. Run `slidemind ready` (PowerPoint open) or `slidemind download-bundle --brand <presFP>` first.",
49
+ );
50
+ }
51
+ let ok = 0;
52
+ for (const entry of cached) {
53
+ const name = readDesignName(entry.dir) ?? entry.presFP;
54
+ try {
55
+ const res = await bundle.materializeBundle(null, {
56
+ force,
57
+ brandId: entry.presFP,
58
+ });
59
+ ok += 1;
60
+ process.stdout.write(
61
+ `${name} (${res.presFP}) — etag ${res.etag ?? "?"}, ${res.downloaded} downloaded, ${res.pruned} pruned\n`,
62
+ );
63
+ for (const w of res.warnings ?? []) {
64
+ process.stderr.write(` warning: ${w}\n`);
65
+ }
66
+ } catch (err) {
67
+ process.stderr.write(
68
+ ` ${name} (${entry.presFP}) — not refreshed: ${err?.message || err}\n`,
69
+ );
70
+ }
71
+ }
72
+ if (ok === 0) {
73
+ fail(
74
+ `No cached design could be refreshed (${cached.length} tried) — check the connection with \`slidemind ready --diagnose\`.`,
75
+ );
76
+ }
77
+ process.stdout.write(
78
+ `Refreshed ${ok} of ${cached.length} cached design${cached.length === 1 ? "" : "s"}.\n`,
79
+ );
80
+ return;
81
+ }
82
+
83
+ // When a deck is being (re-)bound, its existing bundle ref breaks ties for
84
+ // a presId open on several paired backends (prefer the one whose connected
85
+ // pane has THIS corporate design active).
86
+ const deckRef = positional[0]
87
+ ? bundle.readDeckBundleRef(resolveDeckArg(positional[0]))
88
+ : null;
89
+ // A design we can already name — `--brand`, or the deck's own binding —
90
+ // materializes over the pane-free brand route, same as `ready` does. Only an
91
+ // explicit `--to` (the user named a window) or a nameless design still needs
92
+ // the registry. A miss (old add-in, revoked design) falls through.
93
+ const brandId = to
94
+ ? null
95
+ : (brand ??
96
+ deckRef?.presFP ??
97
+ (positional[0] ? await bundle.resolveWindowlessBrand(resolveDeckArg(positional[0])) : null));
98
+ let result = brandId
99
+ ? await bundle
100
+ .materializeBundle(null, { force, brandId })
101
+ .catch(() => null)
102
+ : null;
103
+ if (!result) {
104
+ if (brand) {
105
+ fail(
106
+ `No connected slidemind add-in serves design '${brand}'. Run 'slidemind ready' to see which designs are available.`,
107
+ );
108
+ }
109
+ const registry = await client.requireRenderHost();
110
+ const entry = client.pickPresentationEntry(registry, to, {
111
+ preferBrandId: deckRef?.presFP ?? null,
112
+ });
113
+ result = await bundle.materializeBundle(entry.presId, {
114
+ force,
115
+ backendURL: entry.backendURL ?? null,
116
+ });
117
+ }
118
+
119
+ // Optionally bind a deck to this bundle so build-pptx/push pass the right
120
+ // etag and the local HTML/editor read the right design files.
121
+ const deckArg = positional[0];
122
+ if (deckArg) {
123
+ const deckDir = resolveDeckArg(deckArg);
124
+ bundle.writeDeckBundleRef(deckDir, {
125
+ presFP: result.presFP,
126
+ etag: result.etag,
127
+ });
128
+ }
129
+ process.stdout.write(
130
+ `Synced bundle for ${result.presFP} (etag ${result.etag ?? "?"}) — ` +
131
+ `${result.downloaded} downloaded, ${result.pruned} pruned\n ${result.dir}\n`,
132
+ );
133
+ if (result.engineUpdated) {
134
+ process.stdout.write(" lint engine updated\n");
135
+ }
136
+ if (result.captureEngineUpdated) {
137
+ process.stdout.write(" capture engine updated\n");
138
+ }
139
+ // Asset downloads are best-effort (a signed URL may have expired); surface any
140
+ // failures so the user knows the local preview bundle is incomplete.
141
+ for (const w of result.warnings ?? []) {
142
+ process.stderr.write(` warning: ${w}\n`);
143
+ }
144
+ }