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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +86 -21
- package/README.md +14 -17
- package/bin/slidemind.mjs +65 -4969
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +113 -74
- package/skills/search/SKILL.md +25 -7
- package/src/bridge/build-and-push.mjs +108 -15
- package/src/bridge/bundle.mjs +69 -0
- package/src/bridge/capture.mjs +19 -0
- package/src/bridge/client/brands.mjs +622 -0
- package/src/bridge/client/engine-routing.mjs +237 -0
- package/src/bridge/client/engine-search.mjs +310 -0
- package/src/bridge/client/engine-slides.mjs +435 -0
- package/src/bridge/client/pane-ops.mjs +335 -0
- package/src/bridge/client/presentations.mjs +587 -0
- package/src/bridge/client/render.mjs +317 -0
- package/src/bridge/client/transport.mjs +695 -0
- package/src/bridge/client.mjs +80 -3452
- package/src/bridge/config.mjs +1 -1
- package/src/bridge/engine-download.mjs +15 -2
- package/src/builders/build.mjs +11 -804
- package/src/builders/draft-check.mjs +376 -0
- package/src/builders/lint.mjs +38 -2
- package/src/builders/preview.html +799 -0
- package/src/builders/review.mjs +23 -4
- package/src/builders/visual-families.mjs +6 -8
- package/src/cli/common.mjs +179 -0
- package/src/cli/concepts.mjs +310 -0
- package/src/cli/connection.mjs +147 -0
- package/src/cli/deck.mjs +608 -0
- package/src/cli/designs.mjs +113 -0
- package/src/cli/diagnose.mjs +141 -0
- package/src/cli/download-bundle.mjs +144 -0
- package/src/cli/engine-helpers.mjs +429 -0
- package/src/cli/engine-results.mjs +320 -0
- package/src/cli/help.mjs +201 -0
- package/src/cli/install.mjs +257 -0
- package/src/cli/read.mjs +120 -0
- package/src/cli/ready.mjs +805 -0
- package/src/cli/search.mjs +608 -0
- package/src/cli/session.mjs +177 -0
- package/src/cli/setup.mjs +282 -0
- package/src/cli/slide-reuse.mjs +334 -0
- package/src/cli/status.mjs +118 -0
- package/src/cli/workspace.mjs +302 -0
- package/src/installers/codex.mjs +1 -1
- package/src/update-check.mjs +231 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { envTag } from "../bridge/config.mjs";
|
|
5
|
+
import { fail, resolveDeckArg, loadBridgeModule, SRC_DIR } from "./common.mjs";
|
|
6
|
+
import { publicConnectionStatus, backendBlocker, connectionBlocker } from "./connection.mjs";
|
|
7
|
+
import { readDesignName, cachedDesigns, designRows, designsHumanLines, windowsHumanLine } from "./designs.mjs";
|
|
8
|
+
import { runDiagnose } from "./diagnose.mjs";
|
|
9
|
+
import { deckStage, stageHumanLines } from "./status.mjs";
|
|
10
|
+
|
|
11
|
+
// The one-command agent preflight. Collapses the old 4-call session start
|
|
12
|
+
// (list windows → pick → download-bundle → download-bundle-to-bind) into a
|
|
13
|
+
// single verb: check pairing/reachability/CLI-version/windows, resolve the OPEN
|
|
14
|
+
// design (the add-in is the source of truth), materialize its bundle, bind the
|
|
15
|
+
// deck, and print exactly one actionable blocker when it can't. Exit 0 iff
|
|
16
|
+
// ready; every blocker → exit 1 with a machine-copeable code. Never prints a
|
|
17
|
+
// backendURL (env label only).
|
|
18
|
+
export async function cmdReady(rest) {
|
|
19
|
+
// --- Local arg parse: unknown flags fail BEFORE any network (cli-dispatch
|
|
20
|
+
// convention). ---
|
|
21
|
+
let to = null;
|
|
22
|
+
let asJson = false;
|
|
23
|
+
let force = false;
|
|
24
|
+
let connectOnly = false;
|
|
25
|
+
let diagnose = false;
|
|
26
|
+
let brand = null;
|
|
27
|
+
const positional = [];
|
|
28
|
+
for (let i = 0; i < rest.length; i++) {
|
|
29
|
+
const a = rest[i];
|
|
30
|
+
if (a === "--to") {
|
|
31
|
+
to = rest[++i];
|
|
32
|
+
if (!to) fail("--to expects a presId");
|
|
33
|
+
} else if (a === "--json") {
|
|
34
|
+
asJson = true;
|
|
35
|
+
} else if (a === "--force") {
|
|
36
|
+
force = true;
|
|
37
|
+
} else if (a === "--connect-only") {
|
|
38
|
+
connectOnly = true;
|
|
39
|
+
} else if (a === "--diagnose") {
|
|
40
|
+
diagnose = true;
|
|
41
|
+
} else if (a === "--brand") {
|
|
42
|
+
brand = rest[++i];
|
|
43
|
+
if (!brand) fail("--brand expects a design id (presFP)");
|
|
44
|
+
} else if (a.startsWith("-")) {
|
|
45
|
+
fail(`unknown flag: ${a}`);
|
|
46
|
+
} else {
|
|
47
|
+
positional.push(a);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (brand && to) {
|
|
51
|
+
fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --diagnose: the read-only report — both ladders, every paired backend, the
|
|
55
|
+
// Microsoft 365 state and every registered window with its pane state. It
|
|
56
|
+
// resolves no design, materializes nothing and binds nothing, which is why it
|
|
57
|
+
// is a mode of `ready` rather than the separate `doctor` + `presentations`
|
|
58
|
+
// commands it replaced.
|
|
59
|
+
if (diagnose) {
|
|
60
|
+
await runDiagnose(rest);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Local: resolve the deck dir (never create it) and read its existing
|
|
65
|
+
// bundle ref. deckPresFP feeds ONLY the mirror tie-break + `suggested`; it
|
|
66
|
+
// never auto-picks a design. ---
|
|
67
|
+
const deckArg = positional[0] ?? null;
|
|
68
|
+
const bundle = await import(
|
|
69
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
70
|
+
);
|
|
71
|
+
let deckDir = null;
|
|
72
|
+
let deckSlug = null;
|
|
73
|
+
let deckPresFP = null;
|
|
74
|
+
if (deckArg) {
|
|
75
|
+
deckDir = resolveDeckArg(deckArg);
|
|
76
|
+
if (!fs.existsSync(deckDir)) {
|
|
77
|
+
fail(
|
|
78
|
+
`deck '${deckArg}' not found — run \`slidemind ready\` (no deck) to set up, then \`slidemind new ${deckArg}\``,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
deckSlug = path.basename(deckDir);
|
|
82
|
+
deckPresFP = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Emit a structured blocker (JSON to stdout / human to stderr) and exit 1.
|
|
86
|
+
// `humanLines` overrides the default `slidemind: <message>` for the richer
|
|
87
|
+
// ambiguous / unknown-window shapes. Mirrors the diagnostic's stream conventions:
|
|
88
|
+
// machine JSON on stdout, human prose on stderr.
|
|
89
|
+
const finishBlocker = ({ blocker, extras = {}, humanLines = null }) => {
|
|
90
|
+
if (asJson) {
|
|
91
|
+
process.stdout.write(
|
|
92
|
+
JSON.stringify(
|
|
93
|
+
{ ready: false, blocker, nextAction: blocker.message, ...extras },
|
|
94
|
+
null,
|
|
95
|
+
2,
|
|
96
|
+
) + "\n",
|
|
97
|
+
);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
if (humanLines) {
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
humanLines.endsWith("\n") ? humanLines : `${humanLines}\n`,
|
|
103
|
+
);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
fail(blocker.message);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// --- First use: there is nothing this process can do on its own. Connecting
|
|
110
|
+
// starts in the add-in because the claim it mints carries the deployment's
|
|
111
|
+
// address, and an unconnected CLI has no way to learn which backend its user
|
|
112
|
+
// belongs to. So name the one action that works and stop. ---
|
|
113
|
+
const cfg = await loadBridgeModule("config");
|
|
114
|
+
if (cfg.readSessions().length === 0) {
|
|
115
|
+
finishBlocker({
|
|
116
|
+
blocker: {
|
|
117
|
+
code: "not_connected",
|
|
118
|
+
actor: "user",
|
|
119
|
+
message:
|
|
120
|
+
"Connect this CLI first: in PowerPoint open slidemind → Settings → General → Connect CLI, press the link button, copy the command it shows, and run it here.",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const client = await import(
|
|
127
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
128
|
+
);
|
|
129
|
+
// Same opt-out as `--diagnose`: `ready` OWNS the cli_update_required blocker (it is
|
|
130
|
+
// terminal, `actor: "agent"`, with the update command as its nextAction), so
|
|
131
|
+
// the raw header throw would only replace a structured answer with a worse one.
|
|
132
|
+
client.setDiagnosticMode(true);
|
|
133
|
+
|
|
134
|
+
// --- Network (one parallel round): readiness ladder + the merged registry. ---
|
|
135
|
+
const [statusRaw, detailed] = await Promise.all([
|
|
136
|
+
client.connectionStatus(),
|
|
137
|
+
client.listOpenPresentationsDetailed(),
|
|
138
|
+
]);
|
|
139
|
+
const pub = publicConnectionStatus(statusRaw, client);
|
|
140
|
+
let registry = detailed.presentations;
|
|
141
|
+
|
|
142
|
+
// Terminal blockers: waiting can't fix an unreachable backend or a stale CLI.
|
|
143
|
+
const terminal = connectionBlocker(pub);
|
|
144
|
+
if (
|
|
145
|
+
terminal &&
|
|
146
|
+
(terminal.code === "all_unreachable" || terminal.code === "cli_update_required")
|
|
147
|
+
) {
|
|
148
|
+
finishBlocker({ blocker: terminal });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Backend authoring readiness beyond the connection itself: which
|
|
153
|
+
// environments serve the pane-free engine routes. Probed at most once (and
|
|
154
|
+
// only when a payload actually reports it), and never fatal — an add-in
|
|
155
|
+
// without them still authors, it just relays search through a window. ---
|
|
156
|
+
let engineInfo = null;
|
|
157
|
+
const engines = async () => {
|
|
158
|
+
if (!engineInfo) {
|
|
159
|
+
try {
|
|
160
|
+
engineInfo = await client.engineReachability({
|
|
161
|
+
backends: statusRaw.backends,
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
engineInfo = { available: false, envs: [] };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return engineInfo;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Microsoft 365 sign-in, surfaced HERE because ready is the agent's
|
|
171
|
+
// session-start preflight and browser SSO is the one step an agent cannot do
|
|
172
|
+
// itself — it has to ask its human, and this is where it learns to. Reported
|
|
173
|
+
// only when a backend advertises graphDelegated (older backends have no
|
|
174
|
+
// window-free SharePoint path, so there is nothing to sign in FOR).
|
|
175
|
+
// On a cliLoginV1 backend the sign-in is REQUIRED for the whole pane-free
|
|
176
|
+
// engine surface (catalog, search, downloads, extraction answer 401
|
|
177
|
+
// login_required without it) — so the degradation says that plainly and up
|
|
178
|
+
// front, before the agent's first search meets the 401. Render and bundle
|
|
179
|
+
// still never need it. null = no backend advertises it.
|
|
180
|
+
let m365Info;
|
|
181
|
+
const m365 = async () => {
|
|
182
|
+
if (m365Info !== undefined) return m365Info;
|
|
183
|
+
const advertising = (statusRaw.backends ?? []).filter(
|
|
184
|
+
(b) => b.ok && b.capabilities?.graphDelegated === true && b.backendURL,
|
|
185
|
+
);
|
|
186
|
+
if (advertising.length === 0) {
|
|
187
|
+
m365Info = null;
|
|
188
|
+
return m365Info;
|
|
189
|
+
}
|
|
190
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
191
|
+
const signedOut = advertising.filter(
|
|
192
|
+
(b) => !graph.hasGraphSession(b.backendURL),
|
|
193
|
+
);
|
|
194
|
+
const signedOutEnvs = signedOut.map((b) => cfg.deriveEnvLabel(b.backendURL));
|
|
195
|
+
m365Info =
|
|
196
|
+
signedOut.length === 0
|
|
197
|
+
? { signedIn: true, degradation: null, line: "Microsoft 365: signed in" }
|
|
198
|
+
: {
|
|
199
|
+
signedIn: false,
|
|
200
|
+
degradation: {
|
|
201
|
+
code: "login_required",
|
|
202
|
+
actor: "user",
|
|
203
|
+
envs: signedOutEnvs,
|
|
204
|
+
message:
|
|
205
|
+
"Microsoft 365: not signed in — engine search, downloads and slide " +
|
|
206
|
+
"extraction run as the USER. The first engine command opens a browser " +
|
|
207
|
+
"sign-in AUTOMATICALLY and waits; tell the user to complete it there " +
|
|
208
|
+
"(you cannot). Render, build and bundle work without it.",
|
|
209
|
+
},
|
|
210
|
+
line:
|
|
211
|
+
`Microsoft 365: not signed in${envTag(signedOutEnvs.join(", "))} — the next engine ` +
|
|
212
|
+
"command opens a browser sign-in for the user to complete (automatic; " +
|
|
213
|
+
"`slidemind login` also works any time).",
|
|
214
|
+
};
|
|
215
|
+
return m365Info;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const isRR = client.isRenderReadyPresentation;
|
|
219
|
+
|
|
220
|
+
// The render-ready windows as the payloads report them — after the zombie
|
|
221
|
+
// prune below, so a listed window is one PowerPoint actually has. One row
|
|
222
|
+
// per window: a presId mirrored across backends is still one window.
|
|
223
|
+
const publicWindows = () => {
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
return registry.filter(isRR).filter((p) => !seen.has(p.presId) && seen.add(p.presId)).map((p) => {
|
|
226
|
+
const q = client.toPublicPresentation(p);
|
|
227
|
+
return {
|
|
228
|
+
presId: q.presId,
|
|
229
|
+
env: q.env ?? null,
|
|
230
|
+
title: q.title ?? null,
|
|
231
|
+
fileName: q.fileName ?? null,
|
|
232
|
+
presFP: p.brandId ?? null,
|
|
233
|
+
paneReady: client.isPaneReadyPresentation(p),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// The design menu: every design at hand — cached here, open in a verified
|
|
239
|
+
// window, or (when asked) listed by the backend — one row per presFP with
|
|
240
|
+
// name, theme colours and the windows on it. Success payloads and the
|
|
241
|
+
// ambiguous blocker report the same shape, so the agent always has the
|
|
242
|
+
// menu to put to the user.
|
|
243
|
+
const designMenu = async ({ current = null, withBackend = false } = {}) => {
|
|
244
|
+
const blank = (presFP) => ({
|
|
245
|
+
presFP,
|
|
246
|
+
name: null,
|
|
247
|
+
colors: null,
|
|
248
|
+
current: presFP === current,
|
|
249
|
+
cached: false,
|
|
250
|
+
windows: [],
|
|
251
|
+
});
|
|
252
|
+
const rows = new Map(
|
|
253
|
+
cachedDesigns(bundle, current).map((d) => [
|
|
254
|
+
d.presFP,
|
|
255
|
+
{ ...d, cached: true, windows: [] },
|
|
256
|
+
]),
|
|
257
|
+
);
|
|
258
|
+
for (const w of publicWindows()) {
|
|
259
|
+
if (!w.presFP) continue;
|
|
260
|
+
if (!rows.has(w.presFP)) rows.set(w.presFP, blank(w.presFP));
|
|
261
|
+
rows.get(w.presFP).windows.push(w);
|
|
262
|
+
}
|
|
263
|
+
if (withBackend) {
|
|
264
|
+
try {
|
|
265
|
+
for (const b of await client.listBrands()) {
|
|
266
|
+
if (!rows.has(b.presFP)) rows.set(b.presFP, blank(b.presFP));
|
|
267
|
+
rows.get(b.presFP).name ??= b.name ?? null;
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
/* no brand list — the menu is what this machine knows */
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return [...rows.values()];
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// The one question ready cannot answer: several designs at hand and neither
|
|
277
|
+
// a deck binding, a --brand, nor a single verified window says which.
|
|
278
|
+
const finishAmbiguous = async () => {
|
|
279
|
+
const designs = await designMenu({ withBackend: true });
|
|
280
|
+
const suggested =
|
|
281
|
+
deckPresFP && designs.some((d) => d.presFP === deckPresFP) ? deckPresFP : null;
|
|
282
|
+
const rerun = `slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`;
|
|
283
|
+
const blocker = {
|
|
284
|
+
code: "ambiguous_design",
|
|
285
|
+
actor: "user",
|
|
286
|
+
message: `Several corporate designs are at hand and nothing says which — ask the user which to build against (by name and colours), then re-run: ${rerun}.`,
|
|
287
|
+
};
|
|
288
|
+
let human =
|
|
289
|
+
"slidemind: several corporate designs are at hand — ask the user which to build against:\n" +
|
|
290
|
+
`${designRows(designs).join("\n")}\n`;
|
|
291
|
+
if (suggested) {
|
|
292
|
+
const s = designs.find((d) => d.presFP === suggested);
|
|
293
|
+
human += ` (this deck was last built against ${s?.name ?? suggested} (${suggested}))\n`;
|
|
294
|
+
}
|
|
295
|
+
human += `Then re-run: ${rerun}`;
|
|
296
|
+
finishBlocker({
|
|
297
|
+
blocker,
|
|
298
|
+
extras: { designs, suggested, windows: publicWindows() },
|
|
299
|
+
humanLines: human,
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// --- A design resolved WITHOUT a window — the deck's own binding, an
|
|
304
|
+
// explicit --brand, or the backend's brand list. Same payload either way:
|
|
305
|
+
// ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
|
|
306
|
+
// the missing window actually costs depends on the engine routes: with them
|
|
307
|
+
// only push/insert-slide and reading an open presentation need PowerPoint.
|
|
308
|
+
//
|
|
309
|
+
// "Windowless" is about how the DESIGN was resolved, not about the registry:
|
|
310
|
+
// --brand runs before the registry is consulted at all, and the later rungs
|
|
311
|
+
// fire on "nothing render-ready", which a connected-but-still-matching window
|
|
312
|
+
// satisfies. So the pane status is DERIVED from the registry as it stands at
|
|
313
|
+
// call time (the hydration wait reassigns it), and the prose only claims
|
|
314
|
+
// "no PowerPoint window" when there genuinely is none. ---
|
|
315
|
+
const finishWindowless = async (result) => {
|
|
316
|
+
if (deckDir) {
|
|
317
|
+
bundle.writeDeckBundleRef(deckDir, {
|
|
318
|
+
presFP: result.presFP,
|
|
319
|
+
etag: result.etag,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const designName = readDesignName(result.dir);
|
|
323
|
+
const env = result.backendURL ? cfg.deriveEnvLabel(result.backendURL) : null;
|
|
324
|
+
const eng = await engines();
|
|
325
|
+
const m = await m365();
|
|
326
|
+
const paneReady = registry.some(client.isPaneReadyPresentation);
|
|
327
|
+
const noWindow = registry.length === 0;
|
|
328
|
+
const stage = deckDir ? await deckStage(deckDir) : null;
|
|
329
|
+
const nextAction = noWindow
|
|
330
|
+
? eng.available
|
|
331
|
+
? "Ready (no PowerPoint window — authoring, engine search and build-pptx all work; only push/insert-slide and reading an open presentation need one)."
|
|
332
|
+
: "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
|
|
333
|
+
: paneReady
|
|
334
|
+
? "Ready (design resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide)."
|
|
335
|
+
: eng.available
|
|
336
|
+
? "Ready (design resolved without targeting a window; pane operations — push/insert-slide/read of a window — will wait for a pane click; engine search does not)."
|
|
337
|
+
: "Ready (design resolved without targeting a window; pane operations — push/search/read — will wait for a pane click).";
|
|
338
|
+
if (asJson) {
|
|
339
|
+
process.stdout.write(
|
|
340
|
+
JSON.stringify(
|
|
341
|
+
{
|
|
342
|
+
ready: true,
|
|
343
|
+
windowless: true,
|
|
344
|
+
deck: deckSlug,
|
|
345
|
+
bound: Boolean(deckDir),
|
|
346
|
+
env,
|
|
347
|
+
presId: null,
|
|
348
|
+
presFP: result.presFP,
|
|
349
|
+
designName,
|
|
350
|
+
designs: await designMenu({ current: result.presFP }),
|
|
351
|
+
windows: publicWindows(),
|
|
352
|
+
bundleDir: result.dir,
|
|
353
|
+
bundleEtag: result.etag ?? null,
|
|
354
|
+
paneSuspended: !paneReady,
|
|
355
|
+
engines: eng,
|
|
356
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
357
|
+
warnings: result.warnings ?? [],
|
|
358
|
+
...(stage ? { stage } : {}),
|
|
359
|
+
nextAction,
|
|
360
|
+
},
|
|
361
|
+
null,
|
|
362
|
+
2,
|
|
363
|
+
) + "\n",
|
|
364
|
+
);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const designLabel = designName
|
|
368
|
+
? `${designName} (${result.presFP})`
|
|
369
|
+
: result.presFP;
|
|
370
|
+
process.stdout.write(
|
|
371
|
+
`Ready — design ${designLabel}${envTag(env)}${noWindow ? " (no PowerPoint window)" : ""}\n`,
|
|
372
|
+
);
|
|
373
|
+
process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
|
|
374
|
+
if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
375
|
+
if (stage) process.stdout.write(stageHumanLines(stage));
|
|
376
|
+
process.stdout.write(windowsHumanLine(publicWindows()));
|
|
377
|
+
process.stdout.write(designsHumanLines(await designMenu({ current: result.presFP })));
|
|
378
|
+
process.stdout.write(
|
|
379
|
+
noWindow
|
|
380
|
+
? eng.available
|
|
381
|
+
? " Note: authoring, engine search and build-pptx need no window; push/insert-slide and reading an open presentation do.\n"
|
|
382
|
+
: " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n"
|
|
383
|
+
: paneReady
|
|
384
|
+
? " Note: the design was resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide.\n"
|
|
385
|
+
: eng.available
|
|
386
|
+
? " Note: pane operations (push/insert-slide/read of a window) will wait for a pane click; engine search does not.\n"
|
|
387
|
+
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
388
|
+
);
|
|
389
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
390
|
+
for (const w of result.warnings ?? []) {
|
|
391
|
+
process.stderr.write(` warning: ${w}\n`);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// Materialize a design by id over the pane-free brand route. Returns null on
|
|
396
|
+
// any miss (old add-in, unknown/revoked design) so the caller can fall
|
|
397
|
+
// through to the window ladder instead of dead-ending.
|
|
398
|
+
const materializeBrand = async (brandId) => {
|
|
399
|
+
try {
|
|
400
|
+
return await bundle.materializeBundle(null, { force, brandId });
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// --- --brand <presFP>: the user (or the agent, after an ambiguous brand
|
|
407
|
+
// list) named the design outright. It resolves with or without a window, so
|
|
408
|
+
// it runs before the registry is consulted at all. ---
|
|
409
|
+
if (brand) {
|
|
410
|
+
const result = await materializeBrand(brand);
|
|
411
|
+
if (!result) {
|
|
412
|
+
finishBlocker({
|
|
413
|
+
blocker: {
|
|
414
|
+
code: "unknown_brand",
|
|
415
|
+
actor: "user",
|
|
416
|
+
message: `No connected slidemind add-in serves design '${brand}'. Run 'slidemind ready' with no --brand to see which designs are available.`,
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
await finishWindowless(result);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --- Bound-deck ladder: session → bundle freshness, no pane precondition.
|
|
426
|
+
// The design identity (presFP == brandId) lives in the deck's
|
|
427
|
+
// .bundle-ref.json and the bundle syncs over the pane-free brand route, so
|
|
428
|
+
// with PowerPoint CLOSED (an empty registry) a bound deck readies straight
|
|
429
|
+
// away instead of sitting out the hydration wait for a window that isn't
|
|
430
|
+
// coming. A registry that holds windows still hydrates first — the open
|
|
431
|
+
// add-in stays the source of truth whenever there IS one. Skipped for
|
|
432
|
+
// --connect-only (which resolves no design at all) and for --to (an explicit
|
|
433
|
+
// window must resolve through the registry); a miss (old add-in, legacy ref,
|
|
434
|
+
// revoked grant) falls through to the window ladder. ---
|
|
435
|
+
const boundDeckLadder = deckDir && deckPresFP && !to && !connectOnly;
|
|
436
|
+
let boundRungTried = false;
|
|
437
|
+
if (boundDeckLadder && registry.length === 0) {
|
|
438
|
+
boundRungTried = true;
|
|
439
|
+
const result = await materializeBrand(deckPresFP);
|
|
440
|
+
if (result) {
|
|
441
|
+
await finishWindowless(result);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- Bounded hydration wait when nothing is render-ready yet: covers the
|
|
447
|
+
// just-clicked / just-woke pane still hydrating brand metadata. Polls even on
|
|
448
|
+
// an empty registry (that's the flaky window this papers over). ---
|
|
449
|
+
if (!registry.some(isRR)) {
|
|
450
|
+
const waitMs =
|
|
451
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS || "6000", 10) ||
|
|
452
|
+
6000;
|
|
453
|
+
const pollMs =
|
|
454
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_READY_POLL_MS || "1500", 10) || 1500;
|
|
455
|
+
const deadline = Date.now() + waitMs;
|
|
456
|
+
while (!registry.some(isRR) && Date.now() < deadline) {
|
|
457
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
458
|
+
registry = (await client.listOpenPresentationsDetailed()).presentations;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Everything below terminates the command: it is the whole "no render-ready
|
|
462
|
+
// window" resolution (connect-only engine answer → bound-deck rung → brand
|
|
463
|
+
// list → late blocker). A named closure because TWO paths land here: the
|
|
464
|
+
// registry genuinely holding no render-ready window, and the ping check
|
|
465
|
+
// below pruning the last one as a zombie.
|
|
466
|
+
const finishNoRenderReady = async () => {
|
|
467
|
+
// --connect-only asks one question — "can I work?" — and since the engine
|
|
468
|
+
// routes answer off the session, a backend that serves them IS the answer:
|
|
469
|
+
// discovery, search and every per-result fetch run with PowerPoint closed.
|
|
470
|
+
// Without them search is still relayed through a pane, so the window rung
|
|
471
|
+
// below stands.
|
|
472
|
+
if (connectOnly && (await engines()).available) {
|
|
473
|
+
const eng = await engines();
|
|
474
|
+
const m = await m365();
|
|
475
|
+
if (asJson) {
|
|
476
|
+
process.stdout.write(
|
|
477
|
+
JSON.stringify(
|
|
478
|
+
{
|
|
479
|
+
ready: true,
|
|
480
|
+
connectOnly: true,
|
|
481
|
+
windowless: true,
|
|
482
|
+
engines: eng,
|
|
483
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
484
|
+
nextAction:
|
|
485
|
+
"Ready (no PowerPoint window — engine search runs off the connection; only insert-slide needs one).",
|
|
486
|
+
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
487
|
+
},
|
|
488
|
+
null,
|
|
489
|
+
2,
|
|
490
|
+
) + "\n",
|
|
491
|
+
);
|
|
492
|
+
} else {
|
|
493
|
+
process.stdout.write(
|
|
494
|
+
`Connected. ${pub.backendCount} backend(s), 0 window(s) registered — ` +
|
|
495
|
+
`engine search runs without one${envTag(eng.envs.join(", "))}.\n`,
|
|
496
|
+
);
|
|
497
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// The bound-deck ladder again, for the windows-registered-but-never-ready
|
|
502
|
+
// case the pre-poll rung deliberately left to the hydration wait.
|
|
503
|
+
if (boundDeckLadder && !boundRungTried) {
|
|
504
|
+
boundRungTried = true;
|
|
505
|
+
const result = await materializeBrand(deckPresFP);
|
|
506
|
+
if (result) {
|
|
507
|
+
await finishWindowless(result);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// --- Unbound + no window: the ONE design cached here is the answer, else
|
|
512
|
+
// the ONE design the backend lists; several of either genuinely need a
|
|
513
|
+
// human choice, which --brand records. A backend without the brand route
|
|
514
|
+
// (or a deck already bound to a design it just refused) falls through
|
|
515
|
+
// unchanged — as does a connection with every backend down, where a
|
|
516
|
+
// design menu would only hide the real blocker. ---
|
|
517
|
+
if (!connectOnly && !to && !deckPresFP && !boundRungTried && !backendBlocker(pub)) {
|
|
518
|
+
const cached = bundle.listCachedBundles();
|
|
519
|
+
if (cached.length === 1) {
|
|
520
|
+
const result = await materializeBrand(cached[0].presFP);
|
|
521
|
+
if (result) {
|
|
522
|
+
await finishWindowless(result);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
let brands = [];
|
|
527
|
+
try {
|
|
528
|
+
brands = await client.listBrands();
|
|
529
|
+
} catch {
|
|
530
|
+
/* no brand list — the window ladder below is the answer */
|
|
531
|
+
}
|
|
532
|
+
if (cached.length === 0 && brands.length === 1) {
|
|
533
|
+
const result = await materializeBrand(brands[0].presFP);
|
|
534
|
+
if (result) {
|
|
535
|
+
await finishWindowless(result);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
} else if (cached.length > 1 || brands.length > 1) {
|
|
539
|
+
await finishAmbiguous();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Only the no-window / not-render-ready rungs are reachable here (terminal
|
|
544
|
+
// blockers already returned; a render-ready window would have exited the
|
|
545
|
+
// poll). Reuse the ladder's exact strings over the freshly-polled registry.
|
|
546
|
+
const latePub = {
|
|
547
|
+
paired: true,
|
|
548
|
+
backendCount: pub.backendCount,
|
|
549
|
+
backends: pub.backends,
|
|
550
|
+
presentations: registry.map(client.toPublicPresentation),
|
|
551
|
+
};
|
|
552
|
+
const b =
|
|
553
|
+
connectionBlocker(latePub) ?? {
|
|
554
|
+
code: "not_render_ready",
|
|
555
|
+
actor: "user",
|
|
556
|
+
message:
|
|
557
|
+
"PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.",
|
|
558
|
+
};
|
|
559
|
+
finishBlocker({ blocker: b });
|
|
560
|
+
return;
|
|
561
|
+
};
|
|
562
|
+
// --- Zombie prune, BEFORE any resolution: a webview whose document was
|
|
563
|
+
// closed keeps its socket — and thus a "connected" registry lease — alive
|
|
564
|
+
// indefinitely, and only a host round-trip inside the pane can tell. Every
|
|
565
|
+
// window claiming a LIVE pane gets one truth ping (in parallel); a dead one
|
|
566
|
+
// leaves the registry, so it can neither win silently nor fake a second
|
|
567
|
+
// design. Narrow on purpose: an explicit --to is the user's call, a
|
|
568
|
+
// suspended window keeps its silent-adopt semantics (backgrounded
|
|
569
|
+
// PowerPoint must not start asking questions), and a backend without
|
|
570
|
+
// paneLivenessV1 fails open. ---
|
|
571
|
+
const backendAdvertisesPing = (entry) =>
|
|
572
|
+
(statusRaw.backends ?? []).some(
|
|
573
|
+
(b) =>
|
|
574
|
+
b.ok &&
|
|
575
|
+
b.backendURL === entry.backendURL &&
|
|
576
|
+
b.capabilities?.paneLivenessV1 === true,
|
|
577
|
+
);
|
|
578
|
+
const entryIsAlive = async (entry) => {
|
|
579
|
+
try {
|
|
580
|
+
const ack = await client.pingPresentation({
|
|
581
|
+
presId: entry.presId,
|
|
582
|
+
backendURL: entry.backendURL ?? null,
|
|
583
|
+
});
|
|
584
|
+
return ack?.ok !== false;
|
|
585
|
+
} catch (err) {
|
|
586
|
+
// Dead only on the typed pane verdicts; anything else (404 route on an
|
|
587
|
+
// older backend, a transport blip) fails open — adopt as before.
|
|
588
|
+
return ![
|
|
589
|
+
"pane_suspended",
|
|
590
|
+
"pane_timeout",
|
|
591
|
+
"pane_disconnected",
|
|
592
|
+
"bridge_timeout",
|
|
593
|
+
].includes(err?.code);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
if (!to) {
|
|
597
|
+
const suspects = registry.filter(
|
|
598
|
+
(p) => isRR(p) && client.isPaneReadyPresentation(p) && backendAdvertisesPing(p),
|
|
599
|
+
);
|
|
600
|
+
const alive = await Promise.all(suspects.map(entryIsAlive));
|
|
601
|
+
const dead = new Set(suspects.filter((_, i) => !alive[i]));
|
|
602
|
+
if (dead.size > 0) registry = registry.filter((p) => !dead.has(p));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!registry.some(isRR)) {
|
|
606
|
+
await finishNoRenderReady();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// --- --connect-only: the connection is up with ≥1 render-ready window; the
|
|
611
|
+
// search skill needs nothing more (no design resolution, no bundle). ---
|
|
612
|
+
if (connectOnly) {
|
|
613
|
+
const m = await m365();
|
|
614
|
+
if (asJson) {
|
|
615
|
+
process.stdout.write(
|
|
616
|
+
JSON.stringify(
|
|
617
|
+
{
|
|
618
|
+
ready: true,
|
|
619
|
+
connectOnly: true,
|
|
620
|
+
engines: await engines(),
|
|
621
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
622
|
+
nextAction: "Ready.",
|
|
623
|
+
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
624
|
+
},
|
|
625
|
+
null,
|
|
626
|
+
2,
|
|
627
|
+
) + "\n",
|
|
628
|
+
);
|
|
629
|
+
} else {
|
|
630
|
+
const windows = registry.filter(isRR).length;
|
|
631
|
+
process.stdout.write(
|
|
632
|
+
`Connected. ${pub.backendCount} backend(s), ${windows} window(s) registered.\n`,
|
|
633
|
+
);
|
|
634
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// --- Local: pick WHICH design. The order is the user's, not the window's:
|
|
640
|
+
// an explicit --to, else the deck's own binding (a window on that design is
|
|
641
|
+
// targeted, none at all readies windowless over the brand route), else the
|
|
642
|
+
// ONE design the verified windows agree on. Anything else is a choice only
|
|
643
|
+
// the user can make — the ambiguous blocker puts the menu in front of them. ---
|
|
644
|
+
let resolution;
|
|
645
|
+
if (to) {
|
|
646
|
+
resolution = client.resolveDesignSource(registry, {
|
|
647
|
+
explicitPresId: to,
|
|
648
|
+
deckPresFP,
|
|
649
|
+
});
|
|
650
|
+
} else {
|
|
651
|
+
const onDeckDesign = deckPresFP
|
|
652
|
+
? registry.filter((p) => isRR(p) && p.brandId === deckPresFP)
|
|
653
|
+
: [];
|
|
654
|
+
if (boundDeckLadder && onDeckDesign.length === 0) {
|
|
655
|
+
boundRungTried = true;
|
|
656
|
+
const result = await materializeBrand(deckPresFP);
|
|
657
|
+
if (result) {
|
|
658
|
+
await finishWindowless(result);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
resolution = client.resolveDesignSource(
|
|
663
|
+
onDeckDesign.length > 0 ? onDeckDesign : registry,
|
|
664
|
+
{ deckPresFP },
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (resolution.status === "none") {
|
|
669
|
+
// Defensive: a render-ready window exists (poll passed) so this is rare.
|
|
670
|
+
finishBlocker({
|
|
671
|
+
blocker: {
|
|
672
|
+
code: "not_render_ready",
|
|
673
|
+
actor: "user",
|
|
674
|
+
message:
|
|
675
|
+
"PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.",
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (resolution.status === "unknown_presid") {
|
|
682
|
+
const windows = registry.filter(isRR).map(client.toPublicPresentation);
|
|
683
|
+
const candidates = windows.map((p) => ({
|
|
684
|
+
presId: p.presId,
|
|
685
|
+
env: p.env ?? null,
|
|
686
|
+
title: p.title ?? null,
|
|
687
|
+
fileName: p.fileName ?? null,
|
|
688
|
+
}));
|
|
689
|
+
const blocker = {
|
|
690
|
+
code: "unknown_presid",
|
|
691
|
+
actor: "user",
|
|
692
|
+
message: `No render-ready PowerPoint window has presId='${to}'. Re-run 'slidemind ready --to <presId>' with one of the open windows.`,
|
|
693
|
+
};
|
|
694
|
+
const rows = windows.map(
|
|
695
|
+
(p) =>
|
|
696
|
+
` ${p.presId} ${envTag(p.env)} ${p.title || p.fileName || "(untitled)"}`,
|
|
697
|
+
);
|
|
698
|
+
finishBlocker({
|
|
699
|
+
blocker,
|
|
700
|
+
extras: { candidates },
|
|
701
|
+
humanLines:
|
|
702
|
+
`slidemind: ${blocker.message}\n` +
|
|
703
|
+
`${rows.join("\n")}`,
|
|
704
|
+
});
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (resolution.status === "ambiguous") {
|
|
709
|
+
await finishAmbiguous();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// --- Network: materialize the resolved design's bundle. Always called —
|
|
714
|
+
// incremental etag-gated download makes an unchanged bundle near-free and a
|
|
715
|
+
// changed one refreshes. ---
|
|
716
|
+
const entry = resolution.entry;
|
|
717
|
+
const result = await bundle.materializeBundle(entry.presId, {
|
|
718
|
+
force,
|
|
719
|
+
backendURL: entry.backendURL ?? null,
|
|
720
|
+
preferBrandId: entry.brandId ?? null,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// --- Local: bind the deck (kills the second download-bundle). ---
|
|
724
|
+
let bound = false;
|
|
725
|
+
if (deckDir) {
|
|
726
|
+
bundle.writeDeckBundleRef(deckDir, {
|
|
727
|
+
presFP: result.presFP,
|
|
728
|
+
etag: result.etag,
|
|
729
|
+
});
|
|
730
|
+
bound = true;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const designName = readDesignName(result.dir);
|
|
734
|
+
const paneSuspended = !client.isPaneReadyPresentation(entry);
|
|
735
|
+
const engineInfoForWindow = await engines();
|
|
736
|
+
const m365ForWindow = await m365();
|
|
737
|
+
const stageForWindow = deckDir ? await deckStage(deckDir) : null;
|
|
738
|
+
|
|
739
|
+
if (asJson) {
|
|
740
|
+
process.stdout.write(
|
|
741
|
+
JSON.stringify(
|
|
742
|
+
{
|
|
743
|
+
ready: true,
|
|
744
|
+
deck: deckSlug,
|
|
745
|
+
bound,
|
|
746
|
+
env: entry.env ?? null,
|
|
747
|
+
presId: entry.presId,
|
|
748
|
+
presFP: result.presFP,
|
|
749
|
+
designName,
|
|
750
|
+
designs: await designMenu({ current: result.presFP }),
|
|
751
|
+
windows: publicWindows(),
|
|
752
|
+
bundleDir: result.dir,
|
|
753
|
+
bundleEtag: result.etag ?? null,
|
|
754
|
+
presentation: {
|
|
755
|
+
title: entry.title ?? null,
|
|
756
|
+
fileName: entry.fileName ?? null,
|
|
757
|
+
},
|
|
758
|
+
paneSuspended,
|
|
759
|
+
engines: engineInfoForWindow,
|
|
760
|
+
...(m365ForWindow
|
|
761
|
+
? {
|
|
762
|
+
m365: {
|
|
763
|
+
signedIn: m365ForWindow.signedIn,
|
|
764
|
+
degradation: m365ForWindow.degradation,
|
|
765
|
+
},
|
|
766
|
+
}
|
|
767
|
+
: {}),
|
|
768
|
+
warnings: result.warnings ?? [],
|
|
769
|
+
...(stageForWindow ? { stage: stageForWindow } : {}),
|
|
770
|
+
nextAction: "Ready.",
|
|
771
|
+
},
|
|
772
|
+
null,
|
|
773
|
+
2,
|
|
774
|
+
) + "\n",
|
|
775
|
+
);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// --- Human success: compact multi-line summary on stdout. ---
|
|
780
|
+
const entryEnv = envTag(entry.env);
|
|
781
|
+
const presLabel = entry.title || entry.fileName || "(untitled)";
|
|
782
|
+
const designLabel = designName
|
|
783
|
+
? `${designName} (${result.presFP})`
|
|
784
|
+
: result.presFP;
|
|
785
|
+
process.stdout.write(`Ready — design ${designLabel}\n`);
|
|
786
|
+
process.stdout.write(` presentation: ${presLabel}${entryEnv}\n`);
|
|
787
|
+
process.stdout.write(
|
|
788
|
+
` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
|
|
789
|
+
);
|
|
790
|
+
if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
791
|
+
if (stageForWindow) process.stdout.write(stageHumanLines(stageForWindow));
|
|
792
|
+
process.stdout.write(windowsHumanLine(publicWindows()));
|
|
793
|
+
process.stdout.write(designsHumanLines(await designMenu({ current: result.presFP })));
|
|
794
|
+
if (paneSuspended) {
|
|
795
|
+
process.stdout.write(
|
|
796
|
+
engineInfoForWindow.available
|
|
797
|
+
? " Note: pane operations (push/insert-slide/read of this window) will wait for a pane click; engine search does not.\n"
|
|
798
|
+
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
if (m365ForWindow) process.stdout.write(` ${m365ForWindow.line}\n`);
|
|
802
|
+
for (const w of result.warnings ?? []) {
|
|
803
|
+
process.stderr.write(` warning: ${w}\n`);
|
|
804
|
+
}
|
|
805
|
+
}
|