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,608 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { envTag, isMultiEnv } from "../bridge/config.mjs";
4
+ import { fail, resolveDeckArg, SRC_DIR } from "./common.mjs";
5
+ import { readDesignName, readThemeColors, designRows } from "./designs.mjs";
6
+ import { searchFailureHint, writeEmptySearchDiagnostic, writeSearchGrid, resolveEngineRouting, deckBrandId, failOnEngineBlocker, paneRelayForLoginRequired } from "./engine-helpers.mjs";
7
+
8
+ export async function cmdSearch(rest) {
9
+ let asJson = false;
10
+ let to = null;
11
+ let deck = null;
12
+ let engineId = null;
13
+ let resultType = null;
14
+ let intent = null;
15
+ let top = null;
16
+ const queryParts = [];
17
+ for (let i = 0; i < rest.length; i++) {
18
+ const a = rest[i];
19
+ if (a === "--json") {
20
+ asJson = true;
21
+ } else if (a === "--to") {
22
+ to = rest[++i];
23
+ if (!to) fail("--to expects a presId");
24
+ } else if (a === "--deck") {
25
+ deck = rest[++i];
26
+ if (!deck) fail("--deck expects a deck name or path");
27
+ } else if (a === "--engine" || a === "-e" || a === "--engineId") {
28
+ engineId = rest[++i];
29
+ if (!engineId) fail("--engine expects an engineId");
30
+ } else if (a === "--result-type" || a === "--resultType") {
31
+ resultType = rest[++i];
32
+ if (!resultType) fail("--result-type expects slide|image");
33
+ if (!["slide", "image"].includes(resultType)) {
34
+ fail(`--result-type expects slide|image, got '${resultType}'`);
35
+ }
36
+ } else if (a === "--intent") {
37
+ intent = rest[++i];
38
+ if (!intent) fail("--intent expects auto|content|visual");
39
+ if (!["auto", "content", "visual"].includes(intent)) {
40
+ fail(`--intent expects auto|content|visual, got '${intent}'`);
41
+ }
42
+ } else if (a === "--top") {
43
+ const next = rest[++i];
44
+ const n = parseInt(next, 10);
45
+ if (!Number.isFinite(n) || n <= 0) {
46
+ fail(`--top expects a positive integer, got '${next}'`);
47
+ }
48
+ top = n;
49
+ } else if (a.startsWith("-")) {
50
+ fail(`unknown flag: ${a}`);
51
+ } else {
52
+ queryParts.push(a);
53
+ }
54
+ }
55
+ const query = queryParts.join(" ").trim();
56
+ if (!query) {
57
+ fail(
58
+ `usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--deck <deck>] [--to <presId>] [--json]`,
59
+ );
60
+ }
61
+
62
+ // Engine search runs against the add-in's live engine over the bridge —
63
+ // pane-free where the add-in serves the direct routes, relayed through an open
64
+ // window (host required) where it doesn't.
65
+ const client = await import(
66
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
67
+ );
68
+ const bundle = await import(
69
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
70
+ );
71
+ const { resolveAssetExt } = await import(
72
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
73
+ );
74
+ // The engine catalog is per-user, not window-bound. On the pane path search
75
+ // attaches to any connected window (preferring a live one) and the chosen
76
+ // window is printed below, because the searchId lives on that pane; on the
77
+ // direct path the searchId is the backend's and no window is involved.
78
+ const routing = await resolveEngineRouting(client, {
79
+ to,
80
+ brandId: await deckBrandId(deck ? resolveDeckArg(deck) : null),
81
+ pick: "any",
82
+ });
83
+ const presId = routing.presId;
84
+ const backendURL = routing.backendURL;
85
+ // Mutable, because a login_required refusal can push a direct search onto
86
+ // the pane relay below — and once it does, the searchId belongs to a WINDOW
87
+ // and every next-step hint has to name it.
88
+ let direct = routing.direct;
89
+ let registry = routing.registry;
90
+ let entry = routing.entry;
91
+
92
+ // An explicit content/visual intent means "search slides", not imagery.
93
+ const slideIntent = intent === "content" || intent === "visual";
94
+ // Thread the engine subtype on the wire only when the CLI actually KNOWS it:
95
+ // `--intent content` is slide-only by contract (image engines are
96
+ // visual-only), the pickImageEngine default branch is an image engine by
97
+ // construction, and a scope search names its index below. `--intent visual`
98
+ // asserts NOTHING — it is a valid intent for image AND slide engines, and
99
+ // asserting "slide" from it made the backend record an icon-engine search
100
+ // as slide results (the footer then taught add-slide instead of
101
+ // download-result). Omitted subtype → the backend derives it from what the
102
+ // results actually are.
103
+ let subtype = intent === "content" ? "slide" : null;
104
+ // `--engine Icons` — a display name straight off `ls engines` — would reach
105
+ // the backend verbatim and 404 as "Engine not found". Humans type names,
106
+ // agents paste ids; when the value cannot be an id (spaces, non-hex), look it
107
+ // up in the live catalog by label. Best-effort on purpose: no unique match
108
+ // passes the value through so the existing unknown-engine hint still fires.
109
+ if (
110
+ engineId &&
111
+ !bundle.isSearchScopeId(engineId) &&
112
+ !/^[0-9a-f-]{8,}$/i.test(engineId)
113
+ ) {
114
+ const live = await client.listEngines({ backendURL }).catch(() => null);
115
+ const wanted = engineId.toLowerCase();
116
+ const ids = new Set(
117
+ (live?.engines ?? [])
118
+ .filter((e) => String(e.label ?? "").toLowerCase() === wanted)
119
+ .map((e) => e.id),
120
+ );
121
+ if (ids.size === 1) {
122
+ const [id] = ids;
123
+ process.stderr.write(`engine '${engineId}' resolved to ${id}\n`);
124
+ engineId = id;
125
+ } else if (ids.size > 1) {
126
+ fail(
127
+ `--engine '${engineId}' names ${ids.size} different engines across your connected environments — pass the id from 'slidemind ls engines', or scope with --backend.`,
128
+ );
129
+ }
130
+ }
131
+ if (!engineId) {
132
+ if (slideIntent) {
133
+ // Slide search with no engine named: search the corpus UNFILTERED rather
134
+ // than refusing. An engine is a saved folder filter over the add-in's
135
+ // shared corpus, so a workspace can hold a full corpus and zero slide
136
+ // engines — demanding --engine here dead-ended exactly that case.
137
+ engineId = bundle.SCOPE_ALL;
138
+ resultType = resultType ?? "slide";
139
+ } else {
140
+ // Imagery: resolve a real image engine (a scope is not a brand image
141
+ // source). Ask the LIVE catalog first — the same one the add-in's own
142
+ // design agent reads — and fall back to the materialized bundle only when
143
+ // the host can't answer. Bundle-first would be stale: engine ids can be
144
+ // retired server-side, and a dead id surfaces as "Engine not found".
145
+ const live = await client
146
+ .listEngines({ subtype: "image", backendURL })
147
+ .catch(() => ({ engines: [] }));
148
+ let pick = bundle.pickImageEngine(live.engines);
149
+ if (!pick) {
150
+ // Best-effort: a bundle that can't be materialized (e.g. an incomplete
151
+ // one) must not replace the actionable "wire an image engine" message
152
+ // below with a bundle error — the live catalog is the real answer here.
153
+ // Pane path: fetch by the window's presId. Direct path: there is no
154
+ // window, so only a bound deck (--deck) can name the design; without one
155
+ // there is nothing to fall back to and the live catalog's answer stands.
156
+ const brandId = presId
157
+ ? null
158
+ : await deckBrandId(deck ? resolveDeckArg(deck) : null);
159
+ const mat =
160
+ presId || brandId
161
+ ? await bundle
162
+ .materializeBundle(presId, { backendURL, brandId })
163
+ .catch(() => null)
164
+ : null;
165
+ if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
166
+ }
167
+ if (!pick) {
168
+ fail(
169
+ "This corporate design has no image engines to search. Run 'slidemind ls engines' to see what exists, or ask the add-in owner to wire an image/icon engine.",
170
+ );
171
+ }
172
+ engineId = pick.id;
173
+ subtype = "image";
174
+ }
175
+ }
176
+
177
+ // A scope has no subtype of its own, so the index must be named explicitly —
178
+ // the add-in rejects a scope search without it. Default to slides: reusing an
179
+ // existing slide is what an unfiltered corpus search is overwhelmingly for.
180
+ if (bundle.isSearchScopeId(engineId)) {
181
+ resultType = resultType ?? "slide";
182
+ subtype = resultType;
183
+ } else if (resultType) {
184
+ // Harmless but meaningless on an engine, whose own subtype is authoritative.
185
+ process.stderr.write(
186
+ `note: --result-type is ignored for engine '${engineId}' (it applies to scope:* targets); the engine's own type decides.\n`,
187
+ );
188
+ resultType = null;
189
+ }
190
+
191
+ // Direct: one pane-free call against the backend that owns the searchId.
192
+ // Pane: window-agnostic — tries the picked pane, then fails over to any other
193
+ // live window on the same backend if it's suspended/frozen. The answering
194
+ // window (res.presId) is what reuse must target there, since the searchId is
195
+ // bound to it.
196
+ const relaySearch = (reg) =>
197
+ client.engineSearchAnyPane({
198
+ registry: reg,
199
+ explicitPresId: to,
200
+ engineId,
201
+ query,
202
+ subtype,
203
+ resultType,
204
+ intent: intent ?? null,
205
+ top: top ?? null,
206
+ });
207
+ let res;
208
+ try {
209
+ res = direct
210
+ ? await client.engineSearch({
211
+ engineId,
212
+ query,
213
+ subtype,
214
+ resultType,
215
+ intent: intent ?? null,
216
+ top: top ?? null,
217
+ backendURL,
218
+ })
219
+ : await relaySearch(routing.registry);
220
+ } catch (err) {
221
+ // login_required stops the PANE-FREE path only — an open pane signs each
222
+ // request with the browser's live token. Creating a new search is exactly
223
+ // the operation the pane can replay (it mints its own searchId), so relay
224
+ // it rather than parking the search on a sign-in the user hasn't finished.
225
+ const relay = direct ? await paneRelayForLoginRequired(client, err, { to }) : null;
226
+ if (!relay) await failOnEngineBlocker(client, err, { to });
227
+ process.stderr.write(
228
+ `note: this CLI is not signed in to Microsoft 365, so the search ran through the open ` +
229
+ `slidemind pane${envTag(relay.entry.env)} instead (it signs each request live).\n`,
230
+ );
231
+ direct = false;
232
+ registry = relay.registry;
233
+ entry = relay.entry;
234
+ try {
235
+ res = await relaySearch(registry);
236
+ } catch (relayErr) {
237
+ await failOnEngineBlocker(client, relayErr, { to });
238
+ }
239
+ }
240
+ const ranPresId = res?.presId ?? entry?.presId ?? presId;
241
+ const ranEntry = direct
242
+ ? { env: res?.env ?? null }
243
+ : (registry.find((p) => p.presId === ranPresId) ?? entry);
244
+ if (asJson) {
245
+ // The searchId rides the JSON so an interrupted agent can resume its picks
246
+ // without re-searching. The backendURL never does — env label only.
247
+ const { backendURL: _routingOnly, ...pub } = res ?? {};
248
+ process.stdout.write(`${JSON.stringify(pub, null, 2)}\n`);
249
+ return;
250
+ }
251
+ if (!res?.ok) {
252
+ process.stderr.write(`search failed: ${searchFailureHint(res?.error, engineId)}\n`);
253
+ return;
254
+ }
255
+ // Prefer the new `results` array (image + slide engines); fall back to the
256
+ // legacy `thumbnails` shape older add-ins still return for image engines.
257
+ const results = Array.isArray(res.results)
258
+ ? res.results
259
+ : (res.thumbnails ?? []).map((t, i) => ({ selection: i + 1, title: t.title }));
260
+ const count = res.resultCount ?? results.length;
261
+ const targetLabel = bundle.isSearchScopeId(engineId)
262
+ ? `scope ${engineId}`
263
+ : `engine ${engineId}`;
264
+ process.stdout.write(
265
+ `searchId ${res.searchId} — ${count} result(s) from ${targetLabel}` +
266
+ (res.intent ? ` (${res.intent})` : "") +
267
+ `\n`,
268
+ );
269
+ if (count === 0) {
270
+ writeEmptySearchDiagnostic(engineId, bundle);
271
+ return;
272
+ }
273
+ const isContent = res.intent === "content";
274
+ // A SharePoint-indexed result carries the source file's browser link. Print
275
+ // it under the title so the user can open the original presentation — the
276
+ // add-in only stamps it where the index has one, so its absence is normal.
277
+ const writeSourceLink = (r) => {
278
+ if (typeof r.sourceUrl === "string" && r.sourceUrl) {
279
+ process.stdout.write(` source: ${r.sourceUrl}\n`);
280
+ }
281
+ };
282
+ results.forEach((r, i) => {
283
+ const sel = r.selection ?? i + 1;
284
+ if (isContent) {
285
+ // Content results: per-result header + the bare extracted text block.
286
+ const score = typeof r.score === "number" ? ` · ${r.score.toFixed(3)}` : "";
287
+ process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}${score}\n`);
288
+ writeSourceLink(r);
289
+ if (r.text) {
290
+ for (const line of String(r.text).split("\n")) {
291
+ process.stdout.write(` ${line}\n`);
292
+ }
293
+ }
294
+ } else {
295
+ // Visual / image results: the numbered list. An image result carries its
296
+ // intrinsic size from the engine index — show it, so the box can be sized
297
+ // to the real aspect ratio at authoring time rather than after the fetch.
298
+ const dims =
299
+ r.kind === "image" && Number.isFinite(r.width) && Number.isFinite(r.height)
300
+ ? ` ${r.width}×${r.height}`
301
+ : "";
302
+ process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}${dims}\n`);
303
+ writeSourceLink(r);
304
+ }
305
+ });
306
+ // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
307
+ // add-slide) for slide results, the image-download hint otherwise. The
308
+ // per-result `kind`s are authoritative — the top-level subtype echo has been
309
+ // observed WRONG on live backends (icon-engine searches echoing
310
+ // subtype:"slide" over kind:"image" results, which pointed agents at
311
+ // add-slide instead of download-result) — with the subtype as the fallback
312
+ // for add-ins that don't stamp per-result kinds.
313
+ const resultKinds = results.map((r) => r.kind).filter(Boolean);
314
+ const isSlideResults = resultKinds.length
315
+ ? resultKinds.some((k) => k === "slide")
316
+ : res.subtype === "slide";
317
+ // Visual results: point at the ONE collage rather than making the caller pull
318
+ // each preview separately. The numbers burned into it match the list above.
319
+ if (!isContent) {
320
+ const gridPath = writeSearchGrid(res.gridDataUri, res.searchId, resolveAssetExt);
321
+ if (gridPath) {
322
+ process.stdout.write(
323
+ `Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
324
+ `don't fetch previews one by one: ${gridPath}\n`,
325
+ );
326
+ } else if (direct && isSlideResults) {
327
+ // The pane composes the collage in a DOM; the pane-free route may not
328
+ // have one to hand back (`gridUnavailable`, or simply no grid field on an
329
+ // add-in that hasn't grown it yet). Say where previews come from instead
330
+ // — otherwise an agent that has learned to expect a contact sheet quietly
331
+ // issues N thumbnail fetches to rebuild it.
332
+ process.stdout.write(
333
+ `No contact sheet on this path — preview candidates one at a time instead: ` +
334
+ `slidemind view-slide --search ${res.searchId} --pick <n>\n`,
335
+ );
336
+ } else if (direct) {
337
+ // Image/icon results carry NO preview on the pane-free path (view-slide
338
+ // answers "no image returned" for them). The download IS the preview:
339
+ // fetch the pick, look at the saved file, re-search once if wrong.
340
+ process.stdout.write(
341
+ `No preview for image results on this path — download the pick(s) and look at the saved file(s) ` +
342
+ `(cheap; re-search once if wrong; two misses = mark it (no result)).\n`,
343
+ );
344
+ }
345
+ }
346
+ const envLabel = envTag(ranEntry?.env);
347
+ if (direct) {
348
+ // The searchId belongs to the BACKEND here, not to a window: picks work with
349
+ // PowerPoint closed, so the follow-up commands carry no --to. Live insert is
350
+ // the one step that still names a window (it types into an open deck).
351
+ if (isSlideResults) {
352
+ process.stdout.write(
353
+ `searchId ${res.searchId}${envLabel} is held by the add-in — reuse it with no PowerPoint window:\n` +
354
+ ` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck>\n` +
355
+ ` slidemind insert-slide --search ${res.searchId} --pick <n> --to <presId> (live insert needs an open window)\n`,
356
+ );
357
+ } else {
358
+ process.stdout.write(
359
+ `searchId ${res.searchId}${envLabel} is held by the add-in — download image result(s) into a deck (no window needed):\n` +
360
+ ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck>\n`,
361
+ );
362
+ }
363
+ } else if (isSlideResults) {
364
+ // The searchId is bound to the window the search ran on — reuse must target
365
+ // that same window, so spell out its presId in the next-step commands.
366
+ process.stdout.write(
367
+ `searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — reuse from THIS window:\n` +
368
+ ` slidemind insert-slide --search ${res.searchId} --pick <n> --to ${ranPresId}\n` +
369
+ ` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck> --to ${ranPresId}\n`,
370
+ );
371
+ } else {
372
+ // The searchId is bound to the window the search ran on — download must
373
+ // target that same window, so spell out its presId in the next-step command.
374
+ process.stdout.write(
375
+ `searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — download image result(s) into a deck:\n` +
376
+ ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> --to ${ranPresId}\n`,
377
+ );
378
+ }
379
+ }
380
+
381
+ // Every corporate design this machine can materialize a bundle for: the ones
382
+ // already cached here, plus the ones the connected backends list. This is the
383
+ // discovery half of bundle management — without it the only way to learn a
384
+ // presFP was to run `ready` and read its menu (or its ambiguous blocker), which
385
+ // also resolves and binds. Listing is read-only: no design is picked, no bundle
386
+ // is fetched, no deck is touched. Pair it with `download-bundle --brand <fp>`.
387
+ export async function cmdLsDesigns(rest) {
388
+ let asJson = false;
389
+ for (const a of rest) {
390
+ if (a === "--json") asJson = true;
391
+ else if (a.startsWith("-")) fail(`unknown flag: ${a}`);
392
+ else fail(`unexpected argument: ${a}`);
393
+ }
394
+ const bundle = await import(
395
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
396
+ );
397
+ const rows = new Map();
398
+ for (const b of bundle.listCachedBundles()) {
399
+ rows.set(b.presFP, {
400
+ presFP: b.presFP,
401
+ name: readDesignName(b.dir),
402
+ colors: readThemeColors(b.dir),
403
+ cached: true,
404
+ etag: b.meta?.etag ?? null,
405
+ dir: b.dir,
406
+ envs: [],
407
+ });
408
+ }
409
+ // The backend list is a bonus, never a precondition: an unreachable or older
410
+ // add-in (no brandsList capability) still leaves every cached design listed.
411
+ let listed = [];
412
+ try {
413
+ const client = await import(
414
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
415
+ );
416
+ client.setDiagnosticMode(true);
417
+ listed = await client.listBrands();
418
+ } catch {
419
+ listed = [];
420
+ }
421
+ for (const b of listed) {
422
+ if (!rows.has(b.presFP)) {
423
+ rows.set(b.presFP, {
424
+ presFP: b.presFP,
425
+ name: b.name ?? null,
426
+ colors: null,
427
+ cached: false,
428
+ etag: null,
429
+ dir: null,
430
+ envs: [],
431
+ });
432
+ }
433
+ const row = rows.get(b.presFP);
434
+ row.name ??= b.name ?? null;
435
+ if (b.env && !row.envs.includes(b.env)) row.envs.push(b.env);
436
+ }
437
+ const designs = [...rows.values()];
438
+ if (asJson) {
439
+ process.stdout.write(`${JSON.stringify(designs, null, 2)}\n`);
440
+ return;
441
+ }
442
+ if (designs.length === 0) {
443
+ process.stderr.write(
444
+ "No corporate design is cached here and no connected add-in lists one. " +
445
+ "Run `slidemind ready` with PowerPoint open to materialize the active design.\n",
446
+ );
447
+ return;
448
+ }
449
+ process.stdout.write(`${designRows(designs).join("\n")}\n`);
450
+ process.stdout.write(
451
+ "Materialize or refresh one with: slidemind download-bundle --brand <presFP>\n" +
452
+ "Refresh every cached design with: slidemind download-bundle --all\n",
453
+ );
454
+ }
455
+
456
+ export async function cmdLs(rest) {
457
+ const [sub, ...subRest] = rest;
458
+ if (sub === "designs" || sub === "brands") {
459
+ await cmdLsDesigns(subRest);
460
+ return;
461
+ }
462
+ if (sub !== "engines") {
463
+ fail(
464
+ "usage: slidemind ls engines [--type collection|search|scope] [--subtype image|slide|template|content] [--json]\n" +
465
+ " slidemind ls designs [--json]",
466
+ );
467
+ }
468
+ let type = null;
469
+ let subtype = null;
470
+ let to = null;
471
+ let all = false;
472
+ let asJson = false;
473
+ for (let i = 0; i < subRest.length; i++) {
474
+ const a = subRest[i];
475
+ if (a === "--type") {
476
+ type = subRest[++i];
477
+ if (!type) fail("--type expects collection|search|scope");
478
+ // "scope" narrows to the non-engine targets (scope:all / scope:sharepoint).
479
+ if (!["collection", "search", "scope"].includes(type)) {
480
+ fail(`--type expects collection|search|scope, got '${type}'`);
481
+ }
482
+ } else if (a === "--subtype") {
483
+ subtype = subRest[++i];
484
+ if (!subtype) fail("--subtype expects image|slide|template|content");
485
+ if (!["image", "slide", "template", "content"].includes(subtype)) {
486
+ fail(`--subtype expects image|slide|template|content, got '${subtype}'`);
487
+ }
488
+ } else if (a === "--to") {
489
+ to = subRest[++i];
490
+ if (!to) fail("--to expects a presId");
491
+ } else if (a === "--all") {
492
+ all = true;
493
+ } else if (a === "--json") {
494
+ asJson = true;
495
+ } else if (a.startsWith("-")) {
496
+ fail(`unknown flag: ${a}`);
497
+ } else {
498
+ fail(`unexpected argument: ${a}`);
499
+ }
500
+ }
501
+ const client = await import(
502
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
503
+ );
504
+ if (all && to) fail("--all cannot be combined with --to");
505
+ let backendURL = null;
506
+ if (!all) {
507
+ // Pane-free where the add-in serves the direct routes; otherwise the catalog
508
+ // is relayed through a window, so a live pane is still the precondition.
509
+ try {
510
+ backendURL = (await resolveEngineRouting(client, { to, pick: "any" }))
511
+ .backendURL;
512
+ } catch (err) {
513
+ // Listing is the one engine op that can simply show EVERY environment
514
+ // (each engine is tagged with its env), so an ambiguous multi-environment
515
+ // session fans out here rather than demanding --backend.
516
+ if (err?.code !== "engine_backend_ambiguous") throw err;
517
+ backendURL = null;
518
+ }
519
+ }
520
+ let engines;
521
+ // The pane-free route's summary of the catalog BEFORE the type/subtype
522
+ // filter; null on older add-ins and on the pane-relay path.
523
+ let catalog = null;
524
+ // Which environments answered. The catalog is POOLED, and pooling dedupes the
525
+ // scopes every environment repeats — so a second environment can contribute
526
+ // no row of its own, and the surviving rows can no longer be counted to learn
527
+ // whether more than one answered.
528
+ let envs = [];
529
+ let failures = [];
530
+ try {
531
+ ({ engines, catalog, envs = [], failures = [] } = await client.listEngines({
532
+ type,
533
+ subtype,
534
+ backendURL,
535
+ }));
536
+ } catch (err) {
537
+ // No pane fallback here: `GET /api/cli/engines` is the catalog route on both
538
+ // add-in generations (newer ones answer it directly, older ones relay it
539
+ // themselves), so there is no second route to retry on. The message does
540
+ // name the open pane, because searching THROUGH it still works.
541
+ await failOnEngineBlocker(client, err, { to });
542
+ }
543
+ if (asJson) {
544
+ process.stdout.write(`${JSON.stringify({ engines, catalog, envs, failures }, null, 2)}\n`);
545
+ return;
546
+ }
547
+ // A fanned-out listing never opens the browser sign-in for an environment
548
+ // nobody asked about; an environment that refused for that reason is named
549
+ // here instead of silently missing from the pooled catalog.
550
+ for (const f of failures ?? []) {
551
+ process.stderr.write(` note: ${f.env} did not answer — ${f.message}\n`);
552
+ }
553
+ if (engines.length === 0) {
554
+ // A filter that matched nothing is NOT an empty deployment — the summary is
555
+ // the only thing that tells the two apart, so say which one this is and the
556
+ // next move follows (drop the filter vs. search the corpus unfiltered).
557
+ const unfiltered = catalog ? catalog.totalEngines + catalog.totalScopes : 0;
558
+ if (catalog?.filtered && unfiltered > 0) {
559
+ process.stderr.write(
560
+ `No engine or scope matched this filter, but ${catalog.totalEngines} engine(s) ` +
561
+ `and ${catalog.totalScopes} scope(s) exist. Re-run 'slidemind ls engines' ` +
562
+ "without --type/--subtype to see them.\n",
563
+ );
564
+ return;
565
+ }
566
+ // Deliberately not "nothing to search": an engine is a saved folder filter
567
+ // over the add-in's shared corpus, so an empty catalog says only that nobody
568
+ // saved a filter. An older add-in that predates scopes lands here too.
569
+ const { SCOPE_ALL } = await import(
570
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
571
+ );
572
+ process.stderr.write(
573
+ "No engines or scopes matched. An engine is a saved folder filter over the corpus, " +
574
+ "so an empty list does NOT mean there is nothing to search — try " +
575
+ `'slidemind search "<query>" --engine ${SCOPE_ALL} --result-type slide' ` +
576
+ "to search it unfiltered.\n",
577
+ );
578
+ return;
579
+ }
580
+ // Show the env tag only when more than one environment contributed. Two
581
+ // environments can carry identically-named engines, and without the tag the
582
+ // pooled list gives no way to tell them apart. Deduped scope rows carry no env
583
+ // of their own (they exist on every environment), so they stay untagged.
584
+ const multiEnv =
585
+ isMultiEnv() || envs.length > 1 || new Set(engines.map((e) => e.env)).size > 1;
586
+ for (const e of engines) {
587
+ const kind = [e.type, e.subtype].filter(Boolean).join("/");
588
+ const env = multiEnv && e.env ? `[${e.env}] ` : "";
589
+ const desc = e.description ? ` — ${e.description}` : "";
590
+ // A scope takes --result-type instead of having a subtype of its own; spell
591
+ // that out so the agent doesn't have to infer it from the missing subtype.
592
+ const hint =
593
+ e.type === "scope"
594
+ ? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
595
+ : "";
596
+ // An engine that exists but has nothing indexed yet returns zero hits for
597
+ // every query; say so here so an empty search isn't read as a bad engine id.
598
+ const state =
599
+ e.content?.state === "empty"
600
+ ? " (nothing indexed yet)"
601
+ : e.content?.state === "indexing"
602
+ ? " (indexing)"
603
+ : "";
604
+ process.stdout.write(
605
+ `${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${state}${desc}\n`,
606
+ );
607
+ }
608
+ }