@north-light/crouter 0.3.184 → 0.3.185

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 (61) hide show
  1. package/dist/build-root.js +10 -3
  2. package/dist/builtin-memory/internal/plugins.md +1 -1
  3. package/dist/clients/attach/input/capabilities.js +3 -0
  4. package/dist/clients/attach/slash/dispatch.js +12 -0
  5. package/dist/clients/attach/viewer.js +191 -191
  6. package/dist/commands/pkg/browse/actions.d.ts +7 -0
  7. package/dist/commands/pkg/browse/actions.js +199 -0
  8. package/dist/commands/pkg/browse/catalog.d.ts +9 -0
  9. package/dist/commands/pkg/browse/catalog.js +466 -0
  10. package/dist/commands/pkg/browse/command-view.d.ts +3 -0
  11. package/dist/commands/pkg/browse/command-view.js +191 -0
  12. package/dist/commands/pkg/browse/detail-panel.d.ts +66 -0
  13. package/dist/commands/pkg/browse/detail-panel.js +402 -0
  14. package/dist/commands/pkg/browse/doc-view.d.ts +3 -0
  15. package/dist/commands/pkg/browse/doc-view.js +81 -0
  16. package/dist/commands/pkg/browse/list-panel.d.ts +28 -0
  17. package/dist/commands/pkg/browse/list-panel.js +168 -0
  18. package/dist/commands/pkg/browse/model.d.ts +230 -0
  19. package/dist/commands/pkg/browse/model.js +36 -0
  20. package/dist/commands/pkg/browse/shell.d.ts +93 -0
  21. package/dist/commands/pkg/browse/shell.js +640 -0
  22. package/dist/commands/pkg/browse.d.ts +2 -0
  23. package/dist/commands/pkg/browse.js +27 -0
  24. package/dist/commands/pkg/market-manage.js +13 -4
  25. package/dist/commands/pkg/plugin-manage.js +8 -3
  26. package/dist/commands/pkg.js +2 -1
  27. package/dist/commands/sys/doctor.js +6 -4
  28. package/dist/commands/sys/panels/broker-limits-panel.d.ts +1 -1
  29. package/dist/commands/sys/panels/broker-limits-panel.js +1 -1
  30. package/dist/commands/sys/panels/dependencies-panel.d.ts +1 -1
  31. package/dist/commands/sys/panels/dependencies-panel.js +1 -1
  32. package/dist/commands/sys/panels/keybindings-panel.js +1 -1
  33. package/dist/commands/sys/panels/models-panel.d.ts +1 -1
  34. package/dist/commands/sys/panels/models-panel.js +1 -1
  35. package/dist/commands/sys/panels/plugins-panel.d.ts +1 -1
  36. package/dist/commands/sys/panels/plugins-panel.js +1 -1
  37. package/dist/commands/sys/panels/prefs-panel.d.ts +1 -1
  38. package/dist/commands/sys/panels/prefs-panel.js +1 -1
  39. package/dist/commands/sys/panels/provider-panel.d.ts +1 -1
  40. package/dist/commands/sys/panels/provider-panel.js +1 -1
  41. package/dist/commands/sys/settings-shell.js +1 -1
  42. package/dist/commands/sys/setup-settings-runner.d.ts +7 -18
  43. package/dist/commands/sys/setup-settings-runner.js +3 -52
  44. package/dist/commands/sys/setup-wizard.js +1 -1
  45. package/dist/core/__tests__/serial/command-plugins.test.js +21 -40
  46. package/dist/core/command-manifests/registry.d.ts +10 -3
  47. package/dist/core/command-manifests/registry.js +36 -22
  48. package/dist/core/command-plugins/compose.js +1 -1
  49. package/dist/core/command-plugins/discovery.js +15 -7
  50. package/dist/core/keybindings/catalog.d.ts +1 -1
  51. package/dist/core/keybindings/catalog.js +1 -0
  52. package/dist/core/resolver.d.ts +2 -2
  53. package/dist/core/runtime/tmux-bindings.js +2 -0
  54. package/dist/core/tui/page-host.d.ts +25 -0
  55. package/dist/core/tui/page-host.js +65 -0
  56. package/dist/pi-extensions/canvas-bash-valve.js +45 -1
  57. package/dist/types.d.ts +4 -1
  58. package/package.json +1 -1
  59. package/runtime.lock.json +2 -2
  60. /package/dist/{commands/sys/panels → core/tui}/panel.d.ts +0 -0
  61. /package/dist/{commands/sys/panels → core/tui}/panel.js +0 -0
@@ -0,0 +1,466 @@
1
+ // Catalog reads for `crtr pkg browse`. Everything here is read-only; mutations
2
+ // live in actions.ts.
3
+ import { statSync } from "node:fs";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { SUBTREE_NAMES, coreCommandPaths } from "../../../build-root.js";
6
+ import { discoverPluginCommandCandidates, validateEffectiveCommandPlugins, validatePluginCommands } from "../../../core/command-plugins/discovery.js";
7
+ import { qualifiedTopLevelCommandName } from "../../../core/command-manifests/registry.js";
8
+ import { listDirs, pathExists, walkFiles } from "../../../core/fs-utils.js";
9
+ import { currentSha, gitSync } from "../../../core/git.js";
10
+ import { listPluginMemoryDocs } from "../../../core/memory-resolver.js";
11
+ import { readMarketplaceManifest, readPluginManifest } from "../../../core/manifest.js";
12
+ import { readConfig } from "../../../core/config.js";
13
+ import { parseSubstrateDoc } from "../../../core/substrate/schema.js";
14
+ import { projectScopeRoot, userScopeRoot } from "../../../core/scope.js";
15
+ import { isGitUrl } from "../shared.js";
16
+ import { INSTALLED_SOURCE_ID, marketplaceSourceId, } from "./model.js";
17
+ function parseSemver(version) {
18
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
19
+ return match === null
20
+ ? null
21
+ : { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4]?.split(".") ?? [] };
22
+ }
23
+ function compareSemver(left, right) {
24
+ for (let index = 0; index < left.core.length; index += 1) {
25
+ const difference = left.core[index] - right.core[index];
26
+ if (difference !== 0)
27
+ return difference;
28
+ }
29
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
30
+ return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1;
31
+ }
32
+ for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index += 1) {
33
+ const leftPart = left.prerelease[index];
34
+ const rightPart = right.prerelease[index];
35
+ if (leftPart === undefined || rightPart === undefined)
36
+ return leftPart === undefined ? -1 : 1;
37
+ const leftNumber = /^\d+$/.test(leftPart);
38
+ const rightNumber = /^\d+$/.test(rightPart);
39
+ if (leftNumber && rightNumber) {
40
+ const difference = Number(leftPart) - Number(rightPart);
41
+ if (difference !== 0)
42
+ return difference;
43
+ }
44
+ else if (leftNumber !== rightNumber) {
45
+ return leftNumber ? -1 : 1;
46
+ }
47
+ else if (leftPart !== rightPart) {
48
+ return leftPart < rightPart ? -1 : 1;
49
+ }
50
+ }
51
+ return 0;
52
+ }
53
+ function offeredVersionIsNewer(installed, offered) {
54
+ if (installed === undefined || offered === undefined)
55
+ return false;
56
+ const current = parseSemver(installed);
57
+ const available = parseSemver(offered);
58
+ return current !== null && available !== null && compareSemver(available, current) > 0;
59
+ }
60
+ function isMarketplaceRelativeSource(source) {
61
+ return source.startsWith("./") || source.startsWith("../") || (!isAbsolute(source) && !isGitUrl(source));
62
+ }
63
+ function pluginInstallState(plugin, offeredVersion) {
64
+ return {
65
+ kind: "installed",
66
+ scope: plugin.scope,
67
+ enabled: plugin.enabled,
68
+ version: plugin.version,
69
+ path: plugin.root,
70
+ updateAvailable: offeredVersionIsNewer(plugin.version, offeredVersion),
71
+ };
72
+ }
73
+ function installedEntry(plugin, offeredVersion) {
74
+ return {
75
+ key: `${INSTALLED_SOURCE_ID}:${plugin.scope}:${plugin.name}`,
76
+ name: plugin.name,
77
+ ref: plugin.name,
78
+ sourceId: INSTALLED_SOURCE_ID,
79
+ ...(plugin.sourceMarketplace !== undefined ? { marketplace: plugin.sourceMarketplace } : {}),
80
+ // The version the marketplace OFFERS, per PluginEntry.version. The version
81
+ // actually installed lives in `install.version`, and the dossier renders
82
+ // the pair as "installed → offered" — filling both from the installed side
83
+ // renders every update as "1.0.0 → 1.0.0".
84
+ ...(offeredVersion === undefined ? {} : { version: offeredVersion }),
85
+ description: plugin.manifest.description,
86
+ keywords: [],
87
+ owner: plugin.manifest.owner,
88
+ source: plugin.manifest.source,
89
+ install: pluginInstallState(plugin, offeredVersion),
90
+ root: plugin.root,
91
+ };
92
+ }
93
+ /** `source_marketplace` records a marketplace name, not its registration
94
+ * scope. Installation scope is independent, so this join deliberately keys
95
+ * provenance by marketplace name alone. */
96
+ function marketplacePluginKey(marketplace, name) {
97
+ return `${marketplace}:${name}`;
98
+ }
99
+ function marketplaceDetail(marketplace, installed) {
100
+ const revision = currentSha(marketplace.root) ?? undefined;
101
+ const date = gitSync(["show", "-s", "--format=%cI", "HEAD"], marketplace.root);
102
+ const revisionDate = date.status === 0 ? date.stdout.trim() || undefined : undefined;
103
+ const offered = marketplace.manifest.plugins;
104
+ return {
105
+ name: marketplace.name,
106
+ scope: marketplace.scope,
107
+ url: marketplace.url,
108
+ ref: marketplace.ref,
109
+ path: marketplace.root,
110
+ owner: marketplace.manifest.owner,
111
+ version: marketplace.manifest.version,
112
+ pluginCount: offered.length,
113
+ installedCount: offered.filter((entry) => installed.has(marketplacePluginKey(marketplace.name, entry.name))).length,
114
+ updatableCount: offered.filter((entry) => {
115
+ const plugin = installed.get(marketplacePluginKey(marketplace.name, entry.name));
116
+ return plugin !== undefined && offeredVersionIsNewer(plugin.version, entry.version);
117
+ }).length,
118
+ revision,
119
+ revisionDate,
120
+ };
121
+ }
122
+ function marketplaceEntry(marketplace, entry, installed) {
123
+ const installedPlugin = installed.get(marketplacePluginKey(marketplace.name, entry.name));
124
+ const vendoredRoot = isMarketplaceRelativeSource(entry.source) ? join(marketplace.root, entry.source) : undefined;
125
+ return {
126
+ key: `${marketplaceSourceId(marketplace.name, marketplace.scope)}:${entry.name}`,
127
+ name: entry.name,
128
+ ref: `${marketplace.name}/${entry.name}`,
129
+ sourceId: marketplaceSourceId(marketplace.name, marketplace.scope),
130
+ marketplace: marketplace.name,
131
+ version: entry.version,
132
+ description: entry.description,
133
+ keywords: entry.keywords ?? [],
134
+ owner: marketplace.manifest.owner,
135
+ source: entry.source,
136
+ install: installedPlugin === undefined ? { kind: "absent" } : pluginInstallState(installedPlugin, entry.version),
137
+ ...(installedPlugin !== undefined ? { root: installedPlugin.root } : vendoredRoot !== undefined && pathExists(vendoredRoot) ? { root: vendoredRoot } : {}),
138
+ };
139
+ }
140
+ function readableConfig(scope, warnings) {
141
+ try {
142
+ return readConfig(scope);
143
+ }
144
+ catch (error) {
145
+ warnings.push(`Could not read ${scope} config: ${errorMessage(error)}`);
146
+ return null;
147
+ }
148
+ }
149
+ function readInstalledPlugins(warnings) {
150
+ const roots = [
151
+ ...(projectScopeRoot() === null ? [] : [{ scope: "project", root: projectScopeRoot() }]),
152
+ { scope: "user", root: userScopeRoot() },
153
+ ];
154
+ const plugins = [];
155
+ for (const { scope, root } of roots) {
156
+ const config = readableConfig(scope, warnings);
157
+ for (const name of listDirs(join(root, "plugins"))) {
158
+ const pluginRoot = join(root, "plugins", name);
159
+ try {
160
+ const manifest = readPluginManifest(pluginRoot);
161
+ if (manifest === null) {
162
+ warnings.push(`Plugin "${name}" has no readable manifest: ${pluginRoot}`);
163
+ continue;
164
+ }
165
+ const entry = config?.plugins[name];
166
+ plugins.push({
167
+ name,
168
+ scope,
169
+ root: pluginRoot,
170
+ manifest,
171
+ enabled: typeof entry?.enabled === "boolean" ? entry.enabled : true,
172
+ sourceMarketplace: typeof entry?.source_marketplace === "string" ? entry.source_marketplace : undefined,
173
+ version: typeof entry?.version === "string" ? entry.version : manifest.version,
174
+ });
175
+ }
176
+ catch (error) {
177
+ warnings.push(`Could not read plugin "${name}": ${errorMessage(error)}`);
178
+ }
179
+ }
180
+ }
181
+ return plugins;
182
+ }
183
+ function marketplaceEntries(raw, marketplaceName, warnings) {
184
+ if (!Array.isArray(raw)) {
185
+ warnings.push(`Marketplace "${marketplaceName}" has an invalid plugins list.`);
186
+ return [];
187
+ }
188
+ const entries = [];
189
+ for (const [index, entry] of raw.entries()) {
190
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
191
+ warnings.push(`Marketplace "${marketplaceName}" has an invalid plugin entry at index ${index}.`);
192
+ continue;
193
+ }
194
+ const value = entry;
195
+ if (typeof value["name"] !== "string" || value["name"] === "" || typeof value["source"] !== "string" || value["source"] === "") {
196
+ warnings.push(`Marketplace "${marketplaceName}" has a plugin entry without a name and source at index ${index}.`);
197
+ continue;
198
+ }
199
+ entries.push({
200
+ name: value["name"],
201
+ source: value["source"],
202
+ ...(typeof value["version"] === "string" ? { version: value["version"] } : {}),
203
+ ...(typeof value["description"] === "string" ? { description: value["description"] } : {}),
204
+ ...(Array.isArray(value["keywords"]) && value["keywords"].every((keyword) => typeof keyword === "string") ? { keywords: value["keywords"] } : {}),
205
+ });
206
+ }
207
+ return entries;
208
+ }
209
+ function readableMarketplaceManifest(raw, directoryName, warnings) {
210
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
211
+ warnings.push(`Marketplace "${directoryName}" has an invalid manifest.`);
212
+ return null;
213
+ }
214
+ const value = raw;
215
+ if (typeof value["name"] !== "string" || value["name"] === "") {
216
+ warnings.push(`Marketplace "${directoryName}" has a manifest without a name.`);
217
+ return null;
218
+ }
219
+ const owner = value["owner"];
220
+ const ownerRef = typeof owner === "object" && owner !== null && !Array.isArray(owner)
221
+ ? { ...(typeof owner["name"] === "string" ? { name: owner["name"] } : {}), ...(typeof owner["email"] === "string" ? { email: owner["email"] } : {}) }
222
+ : undefined;
223
+ return {
224
+ name: value["name"],
225
+ plugins: marketplaceEntries(value["plugins"], value["name"], warnings),
226
+ ...(typeof value["version"] === "string" ? { version: value["version"] } : {}),
227
+ ...(ownerRef !== undefined ? { owner: ownerRef } : {}),
228
+ };
229
+ }
230
+ function readMarketplaces(warnings) {
231
+ const roots = [
232
+ ...(projectScopeRoot() === null ? [] : [{ scope: "project", root: projectScopeRoot() }]),
233
+ { scope: "user", root: userScopeRoot() },
234
+ ];
235
+ const marketplaces = [];
236
+ for (const { scope, root } of roots) {
237
+ const config = readableConfig(scope, warnings);
238
+ for (const name of listDirs(join(root, "marketplaces"))) {
239
+ const marketplaceRoot = join(root, "marketplaces", name);
240
+ try {
241
+ const rawManifest = readMarketplaceManifest(marketplaceRoot);
242
+ if (rawManifest === null) {
243
+ warnings.push(`Marketplace "${name}" has no readable manifest: ${marketplaceRoot}`);
244
+ continue;
245
+ }
246
+ const manifest = readableMarketplaceManifest(rawManifest, name, warnings);
247
+ if (manifest === null)
248
+ continue;
249
+ const entry = config?.marketplaces[name];
250
+ marketplaces.push({
251
+ name,
252
+ scope,
253
+ root: marketplaceRoot,
254
+ manifest,
255
+ url: entry?.url ?? "",
256
+ ref: entry?.ref ?? "main",
257
+ });
258
+ }
259
+ catch (error) {
260
+ warnings.push(`Could not read marketplace "${name}": ${errorMessage(error)}`);
261
+ }
262
+ }
263
+ }
264
+ return marketplaces;
265
+ }
266
+ /** Every rail source with its rows: the installed library first, then each
267
+ * registered marketplace. Called on open and after any action that could
268
+ * change install state. Must never throw for a broken marketplace — record it
269
+ * in `warnings` and keep the rest of the catalog. */
270
+ export function loadCatalog() {
271
+ const warnings = [];
272
+ const installed = readInstalledPlugins(warnings);
273
+ const marketplaces = readMarketplaces(warnings);
274
+ const installedByMarketplace = new Map(installed
275
+ .filter((plugin) => plugin.sourceMarketplace !== undefined)
276
+ .map((plugin) => [marketplacePluginKey(plugin.sourceMarketplace, plugin.name), plugin]));
277
+ const offeredVersions = new Map(marketplaces.flatMap((marketplace) => marketplace.manifest.plugins.map((entry) => [
278
+ marketplacePluginKey(marketplace.name, entry.name),
279
+ entry.version,
280
+ ])));
281
+ const sources = [{
282
+ id: INSTALLED_SOURCE_ID,
283
+ kind: "installed",
284
+ label: "Installed",
285
+ entries: installed
286
+ .map((plugin) => installedEntry(plugin, plugin.sourceMarketplace === undefined ? undefined : offeredVersions.get(marketplacePluginKey(plugin.sourceMarketplace, plugin.name))))
287
+ .sort((a, b) => a.name.localeCompare(b.name)),
288
+ }];
289
+ for (const marketplace of marketplaces.sort((a, b) => a.name.localeCompare(b.name))) {
290
+ try {
291
+ sources.push({
292
+ id: marketplaceSourceId(marketplace.name, marketplace.scope),
293
+ kind: "marketplace",
294
+ label: marketplace.name,
295
+ marketplace: marketplaceDetail(marketplace, installedByMarketplace),
296
+ entries: marketplace.manifest.plugins
297
+ .map((entry) => marketplaceEntry(marketplace, entry, installedByMarketplace))
298
+ .sort((a, b) => a.name.localeCompare(b.name)),
299
+ });
300
+ }
301
+ catch (error) {
302
+ warnings.push(`Could not load marketplace "${marketplace.name}": ${errorMessage(error)}`);
303
+ }
304
+ }
305
+ return {
306
+ sources,
307
+ installScopes: ["user", ...(projectScopeRoot() === null ? [] : ["project"])],
308
+ warnings,
309
+ };
310
+ }
311
+ function commandNode(node, parent = []) {
312
+ const path = [...parent, node.name];
313
+ if (node.kind === "branch") {
314
+ return {
315
+ path,
316
+ kind: "branch",
317
+ description: node.description,
318
+ whenToUse: node.whenToUse,
319
+ summary: node.summary,
320
+ ...(node.tier !== undefined ? { tier: node.tier } : {}),
321
+ ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}),
322
+ ...(node.rootEntry !== undefined ? { rootEntry: node.rootEntry } : {}),
323
+ children: node.children.map((child) => commandNode(child, path)),
324
+ params: [],
325
+ output: [],
326
+ effects: [],
327
+ };
328
+ }
329
+ return {
330
+ path,
331
+ kind: "leaf",
332
+ description: node.description,
333
+ whenToUse: node.whenToUse,
334
+ summary: node.summary,
335
+ ...(node.tier !== undefined ? { tier: node.tier } : {}),
336
+ children: [],
337
+ params: node.params,
338
+ output: node.output,
339
+ effects: node.effects,
340
+ ...("rest" in node ? { rest: { method: node.rest.method, path: node.rest.path, streaming: node.rest.streaming === true } } : {}),
341
+ };
342
+ }
343
+ function docDetails(plugin) {
344
+ return listPluginMemoryDocs(plugin, plugin.scope, true).flatMap((doc) => {
345
+ const parsed = parseSubstrateDoc(doc);
346
+ if (parsed === null)
347
+ return [];
348
+ let sizeBytes;
349
+ try {
350
+ sizeBytes = statSync(parsed.path).size;
351
+ }
352
+ catch {
353
+ // The memory resolver already isolated this document; its size is
354
+ // supplementary and must not make the dossier unreadable.
355
+ }
356
+ return [{
357
+ name: parsed.name,
358
+ kind: parsed.kind,
359
+ ...(parsed.shortForm !== "" ? { shortForm: parsed.shortForm } : {}),
360
+ ...(parsed.whenAndWhyToRead !== "" ? { routingLine: parsed.whenAndWhyToRead } : {}),
361
+ path: parsed.path,
362
+ ...(sizeBytes !== undefined ? { sizeBytes } : {}),
363
+ systemPromptVisibility: parsed.systemPromptVisibility,
364
+ fileReadVisibility: parsed.fileReadVisibility,
365
+ slash: parsed.slash,
366
+ ...(parsed.appliesTo !== undefined ? { appliesTo: parsed.appliesTo } : {}),
367
+ ...(parsed.gate !== undefined ? { gate: parsed.gate } : {}),
368
+ ...(parsed.readWhen !== undefined ? { readWhenPredicate: parsed.readWhen } : {}),
369
+ ...(parsed.rationale !== undefined ? { rationale: parsed.rationale } : {}),
370
+ body: parsed.body,
371
+ }];
372
+ });
373
+ }
374
+ function directorySize(root) {
375
+ try {
376
+ return walkFiles(root).reduce((bytes, path) => bytes + statSync(path).size, 0);
377
+ }
378
+ catch {
379
+ return undefined;
380
+ }
381
+ }
382
+ /** What the focused plugin adds to this machine. Resolved from `entry.root`;
383
+ * null when the plugin has no directory on disk yet. */
384
+ export async function loadCapability(entry) {
385
+ if (entry.root === undefined || !pathExists(entry.root))
386
+ return null;
387
+ try {
388
+ const manifest = readPluginManifest(entry.root);
389
+ if (manifest === null)
390
+ return null;
391
+ const scope = entry.install.kind === "installed" ? entry.install.scope : "user";
392
+ const plugin = { name: entry.name, scope, root: entry.root, manifest, enabled: entry.install.kind !== "installed" || entry.install.enabled };
393
+ const reserved = new Set(SUBTREE_NAMES);
394
+ const corePaths = await coreCommandPaths();
395
+ let validation = validatePluginCommands(plugin, reserved, corePaths);
396
+ const registryIssues = [];
397
+ // Effective validation resolves collisions for the command that is mounted
398
+ // today. An uninstalled or disabled plugin is not in that registry, so its
399
+ // dossier must validate the declared tree and preview the future result.
400
+ if (entry.install.kind === "installed" && entry.install.enabled) {
401
+ try {
402
+ const effective = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths);
403
+ validation = effective.find((candidate) => candidate.plugin.root === entry.root) ?? validation;
404
+ }
405
+ catch (error) {
406
+ registryIssues.push({
407
+ message: `Could not resolve the effective command registry: ${errorMessage(error)}`,
408
+ next: "Repair the unreadable installed plugin manifest, then reopen the dossier.",
409
+ });
410
+ }
411
+ }
412
+ if (entry.install.kind !== "installed" || !entry.install.enabled) {
413
+ // Who already claims each bare top-level name. Ask unresolved candidates,
414
+ // not the effective registry: resolution has already rewritten collided
415
+ // names. A disabled installed plugin is excluded from candidates, so the
416
+ // preview also describes exactly what enabling it changes.
417
+ const claimants = new Map();
418
+ for (const candidate of discoverPluginCommandCandidates()) {
419
+ if (candidate.plugin.root === entry.root)
420
+ continue;
421
+ for (const contribution of candidate.contributions) {
422
+ const plugins = claimants.get(contribution.node.name) ?? [];
423
+ plugins.push(candidate.plugin);
424
+ claimants.set(contribution.node.name, plugins);
425
+ }
426
+ }
427
+ const verb = entry.install.kind === "installed" ? "enabling" : "installing";
428
+ validation.contributions = validation.contributions.map((contribution) => {
429
+ const claimantsForName = claimants.get(contribution.node.name);
430
+ if (claimantsForName === undefined || claimantsForName.length === 0)
431
+ return contribution;
432
+ const qualified = qualifiedTopLevelCommandName({ name: entry.name, sourceMarketplace: entry.marketplace }, contribution.node.name);
433
+ const otherMounts = claimantsForName.map((claimant) => `moves ${claimant.name}'s to "${qualifiedTopLevelCommandName(claimant, contribution.node.name)}"`);
434
+ validation.issues.push({
435
+ code: "command_collision",
436
+ plugin: plugin.name,
437
+ path: contribution.node.name,
438
+ message: `top-level command "${contribution.node.name}" is already claimed by ${claimantsForName.map((claimant) => claimant.name).join(", ")} — ${verb} mounts this one as "${qualified}" and ${otherMounts.join(" and ")}`,
439
+ received: [...claimantsForName.map((claimant) => claimant.name), plugin.name].join(", "),
440
+ expected: "an origin-qualified top-level command for each claimant",
441
+ next: `After ${verb}, invoke "${qualified}".`,
442
+ });
443
+ return { ...contribution, node: { ...contribution.node, name: qualified } };
444
+ });
445
+ }
446
+ const transport = manifest.transport;
447
+ return {
448
+ root: entry.root,
449
+ transport: transport?.kind ?? null,
450
+ ...(transport?.kind === "exec" && transport.executable !== undefined ? { transportTarget: transport.executable } : {}),
451
+ ...(transport?.kind === "http" ? { transportTarget: transport.endpoint } : {}),
452
+ commands: validation.contributions.map((contribution) => commandNode(contribution.node)),
453
+ commandIssues: [...validation.issues.map(({ message, next }) => ({ message, ...(next !== undefined ? { next } : {}) })), ...registryIssues],
454
+ docs: docDetails(plugin),
455
+ sizeBytes: directorySize(entry.root),
456
+ };
457
+ }
458
+ catch {
459
+ // Marketplace content is third-party input. Its failure makes this one
460
+ // dossier unavailable, never the whole browser process.
461
+ return null;
462
+ }
463
+ }
464
+ function errorMessage(error) {
465
+ return error instanceof Error ? error.message : String(error);
466
+ }
@@ -0,0 +1,3 @@
1
+ import type { CommandNode, PluginCapability } from "./model.js";
2
+ /** Every line of one command's contract view, wrapped to `width`. */
3
+ export declare function renderCommandView(node: CommandNode, capability: PluginCapability, width: number): string[];
@@ -0,0 +1,191 @@
1
+ // The plugin browser's command-contract view: one node of a plugin's command
2
+ // tree rendered as the whole contract an agent would read from `-h`.
3
+ //
4
+ // Pure rendering. It returns styled lines already wrapped to `width`; the
5
+ // dossier owns scrolling and never truncates what comes back.
6
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
+ import { theme, wrapText } from "../../../core/tui/panel.js";
8
+ import { commandLabel } from "./model.js";
9
+ /** Every line of one command's contract view, wrapped to `width`. */
10
+ export function renderCommandView(node, capability, width) {
11
+ const out = [];
12
+ out.push(...wrapped(commandLabel(node), width).map((line) => theme.fg("accent", theme.bold(line))));
13
+ out.push(...wrapped(`${node.kind.toUpperCase()} COMMAND · ${(node.tier ?? "normal").toUpperCase()} TIER`, width).map((line) => theme.fg("dim", line)));
14
+ out.push("");
15
+ out.push(...paragraph(node.description, width));
16
+ section(out, "WHEN TO USE", width);
17
+ out.push(...paragraph(node.whenToUse, width));
18
+ section(out, "SUMMARY", width);
19
+ out.push(...paragraph(node.summary, width));
20
+ if (node.kind === "leaf")
21
+ renderLeaf(out, node, capability, width);
22
+ else
23
+ renderBranch(out, node, width);
24
+ return out;
25
+ }
26
+ function renderLeaf(out, node, capability, width) {
27
+ section(out, "PARAMS", width);
28
+ if (node.params.length === 0)
29
+ out.push(...paragraph("No input parameters.", width));
30
+ else
31
+ for (const param of node.params)
32
+ out.push(...renderParam(param, width));
33
+ section(out, "OUTPUT", width);
34
+ if (node.output.length === 0)
35
+ out.push(...paragraph("No output fields declared.", width));
36
+ else
37
+ for (const field of node.output)
38
+ out.push(...renderField(field, width));
39
+ section(out, "EFFECTS", width);
40
+ if (node.effects.length === 0)
41
+ out.push(...paragraph("No effects declared.", width));
42
+ else
43
+ for (const effect of node.effects)
44
+ out.push(...hanging(" • ", effect, width));
45
+ section(out, "INVOKED VIA", width);
46
+ if (capability.transport === null) {
47
+ out.push(...wrapped("This plugin declares no command transport.", width).map((line) => theme.fg("warning", line)));
48
+ return;
49
+ }
50
+ const target = capability.transportTarget ?? "No target declared.";
51
+ out.push(...hanging(` ${capability.transport} `, target, width));
52
+ if (capability.transport === "http" && node.rest !== undefined) {
53
+ out.push(...hanging(" REST ", `${node.rest.method} ${node.rest.path} · ${node.rest.streaming ? "streaming response" : "single response"}`, width));
54
+ }
55
+ }
56
+ function renderBranch(out, node, width) {
57
+ if (node.rootEntry !== undefined) {
58
+ section(out, "ROOT ENTRY", width);
59
+ out.push(...hanging(" Concept ", node.rootEntry.concept, width));
60
+ out.push(...hanging(" Description ", node.rootEntry.description, width));
61
+ out.push(...hanging(" When to use ", node.rootEntry.whenToUse, width));
62
+ }
63
+ if (node.passthrough !== undefined) {
64
+ section(out, "PASSES THROUGH", width);
65
+ out.push(...hanging(" Binary ", node.passthrough.bin, width));
66
+ out.push(...hanging(" Install ", node.passthrough.installHint, width));
67
+ }
68
+ section(out, "CHILDREN", width);
69
+ if (node.children.length === 0) {
70
+ out.push(...paragraph(node.passthrough === undefined ? "No subcommands." : "No subcommands — every argument is forwarded to the binary.", width));
71
+ return;
72
+ }
73
+ for (const child of node.children)
74
+ out.push(...hanging(` ${commandLabel(child)} `, child.description, width));
75
+ }
76
+ function renderParam(param, width) {
77
+ const label = paramLabel(param);
78
+ const lines = hanging(` ${label} `, paramMeta(param), width);
79
+ // Each extra fact starts at one fixed column rather than under its own
80
+ // label: aligning to the label width makes a param list as ragged as its
81
+ // longest name, and `--target-pattern` would push its constraint 21 columns in.
82
+ const detailPrefix = DETAIL_INDENT;
83
+ if (param.kind === "positional") {
84
+ if (param.repeatable === true)
85
+ lines.push(...hanging(detailPrefix, "Repeatable; supply multiple values in argv order.", width));
86
+ if (param.encoding !== undefined)
87
+ lines.push(...hanging(detailPrefix, encodingDetail(param.encoding), width));
88
+ if (param.defaultFromEnv !== undefined)
89
+ lines.push(...hanging(detailPrefix, envDefaultDetail(param.defaultFromEnv), width));
90
+ }
91
+ else if (param.kind === "flag") {
92
+ if (param.choices !== undefined)
93
+ lines.push(...hanging(detailPrefix, `Choices: ${param.choices.join(", ")}.`, width));
94
+ if (param.default !== undefined)
95
+ lines.push(...hanging(detailPrefix, `Default: ${formatValue(param.default)}.`, width));
96
+ if (param.repeatable === true)
97
+ lines.push(...hanging(detailPrefix, "Repeatable; supply the flag multiple times to accumulate values.", width));
98
+ if (param.encoding !== undefined)
99
+ lines.push(...hanging(detailPrefix, encodingDetail(param.encoding), width));
100
+ if (param.defaultFromEnv !== undefined)
101
+ lines.push(...hanging(detailPrefix, envDefaultDetail(param.defaultFromEnv), width));
102
+ if (param.hidden === true)
103
+ lines.push(...hanging(detailPrefix, "Hidden from the command's ordinary -h output.", width, "warning"));
104
+ }
105
+ else if (param.kind === "context-file") {
106
+ lines.push(...hanging(detailPrefix, `Mapped input: ${param.name}.`, width));
107
+ if (param.shape !== undefined)
108
+ lines.push(...hanging(detailPrefix, `Expected JSON: ${param.shape}`, width));
109
+ }
110
+ lines.push(...hanging(detailPrefix, `Constraint: ${param.constraint}`, width));
111
+ return lines;
112
+ }
113
+ function renderField(field, width) {
114
+ return [
115
+ ...hanging(` ${field.name} `, `${field.type} · ${field.required ? "required" : "optional"}`, width),
116
+ ...hanging(DETAIL_INDENT, `Constraint: ${field.constraint}`, width),
117
+ ];
118
+ }
119
+ /** Where a param's or field's supporting facts start, whatever its name. */
120
+ const DETAIL_INDENT = " ";
121
+ function paramLabel(param) {
122
+ switch (param.kind) {
123
+ case "positional":
124
+ return `${param.name}${param.repeatable === true ? "…" : ""}`;
125
+ case "flag":
126
+ return `--${param.name}`;
127
+ case "stdin":
128
+ return param.name;
129
+ case "context-file":
130
+ return "--context-file PATH";
131
+ }
132
+ }
133
+ function paramMeta(param) {
134
+ const requirement = param.required ? "required" : "optional";
135
+ switch (param.kind) {
136
+ case "positional":
137
+ return `positional · ${param.type ?? "string"} · ${requirement}`;
138
+ case "flag":
139
+ return `flag · ${param.type} · ${requirement}`;
140
+ case "stdin":
141
+ return `stdin · raw text · ${requirement}`;
142
+ case "context-file":
143
+ return `context file · JSON file · ${requirement}`;
144
+ }
145
+ }
146
+ function encodingDetail(encoding) {
147
+ return encoding === "base64"
148
+ ? "Encoding: reads the local file's raw bytes and sends base64; the path itself is never sent."
149
+ : "Encoding: reads the local file as UTF-8 text and sends its content; the path itself is never sent.";
150
+ }
151
+ function formatValue(value) {
152
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
153
+ }
154
+ function envDefaultDetail(envVar) {
155
+ return `When $${envVar} is set and non-empty, it supplies the value and counts as supplied.`;
156
+ }
157
+ /** A section heading always has one blank line above it, never a trailing one. */
158
+ function section(out, heading, width) {
159
+ out.push("");
160
+ out.push(...wrapped(heading, width).map((line) => theme.fg("dim", line)));
161
+ }
162
+ /** Prose sits two columns in, like the labelled rows beneath every other
163
+ * heading here and in the doc view — a flush-left paragraph between indented
164
+ * blocks reads as a different document. */
165
+ function paragraph(text, width) {
166
+ const indent = " ";
167
+ return wrapped(text, Math.max(1, width - indent.length)).map((line) => `${indent}${theme.fg("dim", line)}`);
168
+ }
169
+ /** Wrap text after a styled label; every continuation starts under its text. */
170
+ function hanging(prefix, text, width, role = "dim") {
171
+ const prefixWidth = visibleWidth(prefix);
172
+ if (prefixWidth >= width) {
173
+ return [
174
+ ...wrapped(prefix.trimEnd(), width).map((line) => theme.fg("accent", line)),
175
+ ...wrapped(text, Math.max(1, width - 2)).map((line) => ` ${theme.fg(role, line)}`),
176
+ ];
177
+ }
178
+ const contentWidth = Math.max(1, width - prefixWidth);
179
+ const continuation = " ".repeat(prefixWidth);
180
+ return wrapped(text, contentWidth).map((line, index) => index === 0
181
+ ? `${theme.fg("accent", prefix)}${theme.fg(role, line)}`
182
+ : `${continuation}${theme.fg(role, line)}`);
183
+ }
184
+ /** Keep normal prose on the shared word wrapper, but never lose an over-wide token. */
185
+ function wrapped(text, width) {
186
+ const clean = text.trim();
187
+ const safeWidth = Math.max(1, width);
188
+ return clean.split(/\s+/).some((word) => visibleWidth(word) > safeWidth)
189
+ ? wrapTextWithAnsi(clean, safeWidth)
190
+ : wrapText(clean, safeWidth);
191
+ }