@crouton-kit/crouter 0.3.78 → 0.3.80

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 (80) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +847 -801
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/chat-view.d.ts +4 -0
  16. package/dist/clients/attach/chat-view.js +10 -0
  17. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  18. package/dist/clients/attach/graph-overlay.js +83 -33
  19. package/dist/clients/attach/input-controller.d.ts +19 -0
  20. package/dist/clients/attach/input-controller.js +55 -11
  21. package/dist/clients/attach/titled-editor.d.ts +21 -0
  22. package/dist/clients/attach/titled-editor.js +101 -0
  23. package/dist/commands/human/queue.js +3 -4
  24. package/dist/commands/pkg/plugin-inspect.js +22 -1
  25. package/dist/commands/pkg/plugin-manage.js +31 -9
  26. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  27. package/dist/commands/sys/doctor.js +42 -4
  28. package/dist/commands/sys/setup-core.d.ts +37 -0
  29. package/dist/commands/sys/setup-core.js +138 -1
  30. package/dist/commands/sys/setup.d.ts +88 -0
  31. package/dist/commands/sys/setup.js +915 -171
  32. package/dist/commands/view-pick.d.ts +4 -0
  33. package/dist/commands/view-pick.js +17 -7
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  35. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  36. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  37. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  38. package/dist/core/__tests__/command-plugins.test.js +444 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  40. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  41. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  42. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  43. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  44. package/dist/core/canvas/browse/app.d.ts +6 -0
  45. package/dist/core/canvas/browse/app.js +71 -41
  46. package/dist/core/command-plugins/adapter.d.ts +15 -0
  47. package/dist/core/command-plugins/adapter.js +145 -0
  48. package/dist/core/command-plugins/compose.d.ts +5 -0
  49. package/dist/core/command-plugins/compose.js +56 -0
  50. package/dist/core/command-plugins/discovery.d.ts +104 -0
  51. package/dist/core/command-plugins/discovery.js +565 -0
  52. package/dist/core/config.d.ts +2 -5
  53. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  54. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  55. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  56. package/dist/core/keybindings/catalog.d.ts +3 -3
  57. package/dist/core/keybindings/catalog.js +2 -1
  58. package/dist/core/profiles/select.d.ts +6 -0
  59. package/dist/core/profiles/select.js +90 -59
  60. package/dist/core/provider-management.d.ts +12 -0
  61. package/dist/core/provider-management.js +24 -0
  62. package/dist/core/runtime/banner.d.ts +13 -0
  63. package/dist/core/runtime/banner.js +51 -0
  64. package/dist/core/runtime/broker.js +7 -6
  65. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  66. package/dist/core/runtime/pi-vendored.js +14 -0
  67. package/dist/core/runtime/recap.d.ts +1 -1
  68. package/dist/core/runtime/recap.js +50 -25
  69. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  70. package/dist/core/runtime/session-list-cache.js +94 -26
  71. package/dist/core/runtime/spawn.js +5 -17
  72. package/dist/core/runtime/tmux.js +2 -1
  73. package/dist/core/scope.js +27 -4
  74. package/dist/core/subscription-state.d.ts +90 -0
  75. package/dist/core/subscription-state.js +762 -0
  76. package/dist/daemon/crtrd.js +253 -12
  77. package/dist/pi-extensions/canvas-recap.js +43 -17
  78. package/dist/types.d.ts +6 -13
  79. package/dist/types.js +0 -3
  80. package/package.json +7 -3
@@ -17,6 +17,28 @@ import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from
17
17
  import { buildTree, flatten, pruneNode, TABS } from './model.js';
18
18
  import { loadPins, savePins } from './pins.js';
19
19
  import { createCoalescedRebuilder } from './rebuild-coalescer.js';
20
+ import { matchesTerminalInput, resolveUserKeybindings, } from '../../keybindings/index.js';
21
+ const BROWSE_NAV_ACTIONS = [
22
+ 'crtr.browse.quit', 'crtr.browse.down', 'crtr.browse.up', 'crtr.browse.first', 'crtr.browse.last',
23
+ 'crtr.browse.expand-or-child', 'crtr.browse.collapse-or-parent', 'crtr.browse.toggle-fold',
24
+ 'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
25
+ 'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
26
+ 'crtr.browse.scope', 'crtr.browse.residents', 'crtr.browse.preview', 'crtr.browse.search',
27
+ 'crtr.browse.revive-now', 'crtr.browse.focus',
28
+ ];
29
+ const BROWSE_SEARCH_ACTIONS = [
30
+ 'crtr.browse.search.cancel', 'crtr.browse.search.commit', 'crtr.browse.search.delete',
31
+ 'crtr.browse.search.quit',
32
+ ];
33
+ function firstMatching(bindings, ids, raw) {
34
+ return ids.find((id) => matchesTerminalInput(bindings, id, raw)) ?? null;
35
+ }
36
+ export function resolveBrowseNavAction(bindings, raw) {
37
+ return firstMatching(bindings, BROWSE_NAV_ACTIONS, raw);
38
+ }
39
+ export function resolveBrowseSearchAction(bindings, raw) {
40
+ return firstMatching(bindings, BROWSE_SEARCH_ACTIONS, raw);
41
+ }
20
42
  // Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
21
43
  // press restores the structural `tree` view, then relevance/recency, then back.
22
44
  const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
@@ -45,6 +67,7 @@ export function wireEventStreamRebuild(eventStream, rebuild) {
45
67
  export async function runBrowse(opts = {}) {
46
68
  const source = opts.source ?? localCanvasSource;
47
69
  const isRemote = source instanceof RemoteCanvasSource;
70
+ const bindings = resolveUserKeybindings();
48
71
  // No TTY → print the static forest and exit 0 (no raw mode).
49
72
  if (!process.stdin.isTTY) {
50
73
  process.stdout.write(await renderForestFromSource(source) + '\n');
@@ -310,6 +333,13 @@ export async function runBrowse(opts = {}) {
310
333
  n.row.status = m.status; // real post-close status (done / canceled)
311
334
  n.row.streaming = false;
312
335
  n.row.viewed = false;
336
+ // A finalized node is no longer hanging and owes no answer — clear both so it
337
+ // actually LEAVES the Attention tab (asks>0 || hanging) and stops rendering the
338
+ // stale `⚠ down` rail. Without this, closing a faulted/asking node from the
339
+ // Attention tab looks like a no-op: the row was finalized in the db but its
340
+ // stale fault/ask kept it matching the tab, so it never disappeared.
341
+ n.row.hanging = null;
342
+ n.row.asks = 0;
313
343
  }
314
344
  // Keep the cursor at the same INDEX (the row that shifts up into the closed
315
345
  // slot), not pinned to the now-gone node — recompute() leaves state.cursor
@@ -389,7 +419,8 @@ export async function runBrowse(opts = {}) {
389
419
  });
390
420
  };
391
421
  const onKeySearch = (input, key) => {
392
- if (key.escape) {
422
+ const action = resolveBrowseSearchAction(bindings, { input, ...key });
423
+ if (action === 'crtr.browse.search.cancel') {
393
424
  // Cancel the search: drop the query AND the relevance ranking it switched
394
425
  // on, returning to the tree. Synchronous: we're leaving search mode.
395
426
  state.search = false;
@@ -399,7 +430,7 @@ export async function runBrowse(opts = {}) {
399
430
  scheduleFlush();
400
431
  return;
401
432
  }
402
- if (key.return) {
433
+ if (action === 'crtr.browse.search.commit') {
403
434
  // Commit: keep the filter, drop search mode, land on the first match. Must
404
435
  // recompute synchronously first so the match index reflects the final query
405
436
  // even if a coalesced repaint is still pending.
@@ -411,17 +442,16 @@ export async function runBrowse(opts = {}) {
411
442
  scheduleFlush();
412
443
  return;
413
444
  }
414
- if (key.backspace) {
445
+ if (action === 'crtr.browse.search.delete') {
415
446
  state.query = state.query.slice(0, -1);
416
447
  scheduleSearchRepaint();
417
448
  return;
418
449
  }
419
- // Any ctrl-combo: Ctrl+C quits; everything else is swallowed (never typed).
420
- if (key.ctrl) {
421
- if (input === 'c')
422
- quit();
450
+ // Bound quit acts before text capture; other ctrl-combos are swallowed.
451
+ if (action === 'crtr.browse.search.quit')
452
+ quit();
453
+ if (key.ctrl)
423
454
  return;
424
- }
425
455
  // Append every printable char in the chunk. Fast typing batches multiple
426
456
  // chars into one stdin read, so we must take them all (not just a length-1
427
457
  // chunk) or keystrokes get dropped. Filtering to >= ' ' strips control/escape
@@ -530,17 +560,17 @@ export async function runBrowse(opts = {}) {
530
560
  scheduleFlush();
531
561
  };
532
562
  const onKeyNav = (input, key) => {
533
- // Ctrl-combos first: only Ctrl+C is meaningful (quit); swallow the rest so
534
- // Ctrl+L / Ctrl+J / Ctrl+Q etc. don't masquerade as l/j/q commands.
535
- if (key.ctrl) {
536
- if (input === 'c')
537
- quit();
538
- return;
539
- }
540
- // Close-out confirm sub-state: a streaming node is awaiting y/n. Swallow every
541
- // other key so the confirm is modal.
563
+ const raw = { input, ...key };
564
+ const action = resolveBrowseNavAction(bindings, raw);
565
+ // Unbound ctrl-combos are swallowed so they never masquerade as printable commands.
566
+ if (key.ctrl && action === null)
567
+ return;
568
+ // Preserve an explicitly bound control-combo quit while the close confirmation is open.
569
+ // Other non-confirming input dismisses the modal without also running navigation actions.
570
+ if (state.pendingClose !== null && action === 'crtr.browse.quit' && key.ctrl)
571
+ quit();
542
572
  if (state.pendingClose !== null) {
543
- if (input === 'y' || input === 'Y') {
573
+ if (matchesTerminalInput(bindings, 'crtr.browse.confirm-close', raw)) {
544
574
  const target = state.pendingClose;
545
575
  void doClose(target).then(() => scheduleFlush()).catch(() => scheduleFlush());
546
576
  return;
@@ -551,32 +581,31 @@ export async function runBrowse(opts = {}) {
551
581
  return;
552
582
  }
553
583
  const row = curRow();
554
- // Quit.
555
- if (input === 'q' || key.escape)
584
+ // Quit and move.
585
+ if (action === 'crtr.browse.quit')
556
586
  quit();
557
- // Move.
558
- if (key.upArrow || input === 'k') {
587
+ if (action === 'crtr.browse.up') {
559
588
  state.cursor = Math.max(0, state.cursor - 1);
560
589
  scheduleFlush();
561
590
  return;
562
591
  }
563
- if (key.downArrow || input === 'j') {
592
+ if (action === 'crtr.browse.down') {
564
593
  state.cursor = Math.max(0, Math.min(visible.length - 1, state.cursor + 1));
565
594
  scheduleFlush();
566
595
  return;
567
596
  }
568
- if (input === 'g') {
597
+ if (action === 'crtr.browse.first') {
569
598
  state.cursor = 0;
570
599
  scheduleFlush();
571
600
  return;
572
601
  }
573
- if (input === 'G') {
602
+ if (action === 'crtr.browse.last') {
574
603
  state.cursor = Math.max(0, visible.length - 1);
575
604
  scheduleFlush();
576
605
  return;
577
606
  }
578
607
  // Expand / descend.
579
- if (key.rightArrow || input === 'l') {
608
+ if (action === 'crtr.browse.expand-or-child') {
580
609
  if (row !== undefined && row.hasChildren) {
581
610
  if (!isExpanded(row.id)) {
582
611
  state.collapsed.delete(row.id);
@@ -590,7 +619,7 @@ export async function runBrowse(opts = {}) {
590
619
  return;
591
620
  }
592
621
  // Collapse / ascend.
593
- if (key.leftArrow || input === 'h') {
622
+ if (action === 'crtr.browse.collapse-or-parent') {
594
623
  if (row !== undefined && row.hasChildren && isExpanded(row.id)) {
595
624
  state.collapsed.add(row.id);
596
625
  recompute(row.id);
@@ -607,7 +636,7 @@ export async function runBrowse(opts = {}) {
607
636
  return;
608
637
  }
609
638
  // Toggle collapse.
610
- if (input === ' ') {
639
+ if (action === 'crtr.browse.toggle-fold') {
611
640
  if (row !== undefined && row.hasChildren) {
612
641
  if (isExpanded(row.id))
613
642
  state.collapsed.add(row.id);
@@ -618,19 +647,20 @@ export async function runBrowse(opts = {}) {
618
647
  scheduleFlush();
619
648
  return;
620
649
  }
621
- // Tabs.
622
- if (key.tab || input === ']') {
650
+ // Tabs. next/previous and tab.1–5 (including Pinned) all resolve through the
651
+ // catalog, so each numbered tab is user-remappable like any other binding.
652
+ if (action === 'crtr.browse.tab-next') {
623
653
  cycleTab(1);
624
654
  scheduleFlush();
625
655
  return;
626
656
  }
627
- if (key.shiftTab || input === '[') {
657
+ if (action === 'crtr.browse.tab-previous') {
628
658
  cycleTab(-1);
629
659
  scheduleFlush();
630
660
  return;
631
661
  }
632
- if (input >= '1' && input <= '5') {
633
- const idx = Number(input) - 1;
662
+ if (action?.startsWith('crtr.browse.tab.')) {
663
+ const idx = Number(action.slice(-1)) - 1;
634
664
  if (idx < TABS.length) {
635
665
  state.tab = TABS[idx];
636
666
  state.cursor = 0;
@@ -643,7 +673,7 @@ export async function runBrowse(opts = {}) {
643
673
  // Close-out the selected node (+ its exclusive subtree). If anything in that
644
674
  // subtree is actively streaming, confirm first (y/n); otherwise close at once.
645
675
  // No-op against a remote canvas — mutations are disabled (footer says so).
646
- if (input === 'x') {
676
+ if (action === 'crtr.browse.close-subtree') {
647
677
  if (isRemote)
648
678
  return;
649
679
  if (row === undefined) {
@@ -659,22 +689,22 @@ export async function runBrowse(opts = {}) {
659
689
  return;
660
690
  }
661
691
  // Sort / scope / residents / preview.
662
- if (input === 's') {
692
+ if (action === 'crtr.browse.sort') {
663
693
  cycleSort();
664
694
  scheduleFlush();
665
695
  return;
666
696
  }
667
- if (input === 'c') {
697
+ if (action === 'crtr.browse.scope') {
668
698
  toggleScope();
669
699
  scheduleFlush();
670
700
  return;
671
701
  }
672
- if (input === 'r') {
702
+ if (action === 'crtr.browse.residents') {
673
703
  toggleResidents();
674
704
  scheduleFlush();
675
705
  return;
676
706
  }
677
- if (input === 'p') {
707
+ if (action === 'crtr.browse.preview') {
678
708
  state.preview = !state.preview;
679
709
  scheduleFlush();
680
710
  return;
@@ -724,7 +754,7 @@ export async function runBrowse(opts = {}) {
724
754
  }
725
755
  // Search. Starting a search ranks by relevance (decision) so the best prompt/
726
756
  // name match floats to the top as you type.
727
- if (input === '/') {
757
+ if (action === 'crtr.browse.search') {
728
758
  state.search = true;
729
759
  state.query = '';
730
760
  state.sort = 'relevance';
@@ -740,7 +770,7 @@ export async function runBrowse(opts = {}) {
740
770
  // --now` (browse goes through commands, never touching the runtime directly). 'k' is
741
771
  // vim-up here, so the kick is on SHIFT-K.
742
772
  // No-op against a remote canvas — mutations are disabled (footer says so).
743
- if (input === 'K') {
773
+ if (action === 'crtr.browse.revive-now') {
744
774
  if (isRemote)
745
775
  return;
746
776
  if (row !== undefined) {
@@ -753,7 +783,7 @@ export async function runBrowse(opts = {}) {
753
783
  }
754
784
  // Resume. No-op against a remote canvas — mutations/focus are disabled
755
785
  // (footer says so).
756
- if (key.return) {
786
+ if (action === 'crtr.browse.focus') {
757
787
  if (isRemote)
758
788
  return;
759
789
  if (row !== undefined)
@@ -0,0 +1,15 @@
1
+ import type { InstalledPlugin } from '../../types.js';
2
+ import type { Field } from '../help.js';
3
+ export interface ExternalLeafSpec {
4
+ plugin: InstalledPlugin;
5
+ /** Absolute, resolved executable path. */
6
+ executable: string;
7
+ /** Full canonical command path, e.g. ['app', 'show']. */
8
+ commandPath: string[];
9
+ /** Declared leaf output fields — validated against the result before render. */
10
+ output: Field[];
11
+ }
12
+ /** Execute one external leaf and return its result object (crtr renders it via
13
+ * the generic renderer; `--json` mirrors it). Throws a CrtrError for any
14
+ * protocol violation or the plugin's own recoverable error. */
15
+ export declare function executeExternalLeaf(spec: ExternalLeafSpec, input: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,145 @@
1
+ // External leaf adapter — the execution seam for a command plugin's leaf.
2
+ //
3
+ // On explicit leaf invocation only (never on discovery/help), direct-spawn the
4
+ // plugin's canonical executable with `--crtr-command-protocol 1`, write one
5
+ // JSON request to stdin, and parse exactly one JSON response envelope from
6
+ // stdout. The envelope's `ok` is the source of truth; crtr owns rendering and
7
+ // error normalization. The executable inherits the caller's cwd + full
8
+ // environment and owns its own time budget (no crtr timeout) — this is a trust
9
+ // boundary, not a sandbox (installed code can already read the caller's files).
10
+ import { spawnSync } from 'node:child_process';
11
+ import { CrtrError } from '../errors.js';
12
+ import { diag } from '../io.js';
13
+ import { ExitCode } from '../../types.js';
14
+ const MAX_STDOUT = 10 * 1024 * 1024; // 10 MiB hard cap on plugin stdout.
15
+ const RESERVED_CODES = new Set(['internal', 'unknown_path', 'command_collision', 'plugin_protocol_error']);
16
+ // Strict snake_case: no leading/trailing underscore, no consecutive underscores.
17
+ const SNAKE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
18
+ const NEXT = 'Run `crtr pkg plugin show` or `crtr sys doctor` to inspect the plugin.';
19
+ /** Execute one external leaf and return its result object (crtr renders it via
20
+ * the generic renderer; `--json` mirrors it). Throws a CrtrError for any
21
+ * protocol violation or the plugin's own recoverable error. */
22
+ export function executeExternalLeaf(spec, input) {
23
+ const request = {
24
+ protocolVersion: 1,
25
+ command: spec.commandPath,
26
+ input,
27
+ context: {
28
+ cwd: process.cwd(),
29
+ plugin: {
30
+ name: spec.plugin.name,
31
+ version: spec.plugin.version ?? spec.plugin.manifest.version ?? null,
32
+ scope: spec.plugin.scope,
33
+ root: spec.plugin.root,
34
+ },
35
+ },
36
+ };
37
+ const res = spawnSync(spec.executable, ['--crtr-command-protocol', '1'], {
38
+ input: JSON.stringify(request),
39
+ cwd: process.cwd(),
40
+ env: process.env,
41
+ maxBuffer: MAX_STDOUT,
42
+ stdio: ['pipe', 'pipe', 'inherit'],
43
+ encoding: 'utf8',
44
+ });
45
+ if (res.error !== undefined) {
46
+ const err = res.error;
47
+ // spawnSync signals a maxBuffer overrun as ENOBUFS (and kills the child);
48
+ // the exec-family code is accepted too for robustness across Node versions.
49
+ if (err.code === 'ENOBUFS' || err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
50
+ throw protocolError(spec, `stdout exceeded the 10 MiB cap`, 'one JSON envelope under 10 MiB on stdout');
51
+ }
52
+ throw protocolError(spec, `executable could not be run (${err.code ?? err.message})`, 'a runnable command executable');
53
+ }
54
+ if (res.signal !== null) {
55
+ throw protocolError(spec, `executable was killed by signal ${res.signal}`, 'a clean exit with one JSON envelope on stdout');
56
+ }
57
+ const stdout = res.stdout ?? '';
58
+ let envelope;
59
+ try {
60
+ envelope = JSON.parse(stdout.trim());
61
+ }
62
+ catch {
63
+ throw protocolError(spec, `stdout was not a single JSON envelope`, 'exactly one JSON envelope on stdout');
64
+ }
65
+ if (!isObject(envelope)) {
66
+ throw protocolError(spec, `stdout was not a JSON object`, 'a { protocolVersion: 1, ok: boolean, ... } envelope');
67
+ }
68
+ if (envelope['protocolVersion'] !== 1) {
69
+ throw protocolError(spec, `unsupported protocolVersion ${String(envelope['protocolVersion'])}`, 'protocolVersion exactly 1');
70
+ }
71
+ if (typeof envelope['ok'] !== 'boolean') {
72
+ throw protocolError(spec, `envelope is missing a boolean "ok"`, 'a boolean ok field');
73
+ }
74
+ const ok = envelope['ok'];
75
+ // `ok` is the source of truth; an exit-code mismatch is a soft diagnostic.
76
+ if (ok && res.status !== 0) {
77
+ diag(`crtr: ${label(spec)} returned ok:true but exited ${res.status} (honoring the envelope)`);
78
+ }
79
+ else if (!ok && res.status === 0) {
80
+ diag(`crtr: ${label(spec)} returned ok:false but exited 0 (honoring the envelope)`);
81
+ }
82
+ if (ok) {
83
+ const result = envelope['result'];
84
+ if (!isObject(result)) {
85
+ throw protocolError(spec, `success envelope has no result object`, 'result: an object matching the declared output');
86
+ }
87
+ validateResult(spec, result);
88
+ return result;
89
+ }
90
+ // Recoverable command failure → structured CrtrError with the plugin's code.
91
+ const error = envelope['error'];
92
+ if (!isObject(error) || typeof error['code'] !== 'string' || typeof error['message'] !== 'string') {
93
+ throw protocolError(spec, `error envelope is missing a { code, message }`, 'error: { code, message, ... }');
94
+ }
95
+ const code = error['code'];
96
+ if (!SNAKE.test(code) || RESERVED_CODES.has(code)) {
97
+ throw protocolError(spec, `error code "${code}" is invalid or reserved`, `a lowercase snake_case code not in {${[...RESERVED_CODES].join(', ')}}`);
98
+ }
99
+ throw new CrtrError(code, error['message'], ExitCode.GENERAL, {
100
+ received: error['received'],
101
+ field: typeof error['field'] === 'string' ? error['field'] : undefined,
102
+ next: typeof error['next'] === 'string' ? error['next'] : NEXT,
103
+ });
104
+ }
105
+ /** Enforce declared top-level output field presence + primitive/container
106
+ * type. A missing required field or a type mismatch is a protocol error. */
107
+ function validateResult(spec, result) {
108
+ for (const f of spec.output) {
109
+ const present = Object.prototype.hasOwnProperty.call(result, f.name) && result[f.name] !== undefined && result[f.name] !== null;
110
+ if (f.required && !present) {
111
+ throw protocolError(spec, `result is missing required output field "${f.name}"`, `a "${f.name}" field of type ${f.type}`);
112
+ }
113
+ if (present && !typeMatches(f.type, result[f.name])) {
114
+ throw protocolError(spec, `output field "${f.name}" has the wrong type`, `${f.name}: ${f.type}`);
115
+ }
116
+ }
117
+ }
118
+ /** Loose type check over the declared type vocabulary. Only the recognized
119
+ * primitive/container categories are enforced; an unrecognized declared type
120
+ * falls back to presence-only. */
121
+ function typeMatches(type, v) {
122
+ const t = type.toLowerCase();
123
+ if (t === 'string' || t === 'path')
124
+ return typeof v === 'string';
125
+ if (t === 'int' || t === 'integer')
126
+ return typeof v === 'number' && Number.isInteger(v);
127
+ if (t === 'number' || t === 'float')
128
+ return typeof v === 'number';
129
+ if (t === 'bool' || t === 'boolean')
130
+ return typeof v === 'boolean';
131
+ if (t === 'array' || t.endsWith('[]'))
132
+ return Array.isArray(v);
133
+ if (t === 'object')
134
+ return isObject(v);
135
+ return true;
136
+ }
137
+ function protocolError(spec, received, expected) {
138
+ return new CrtrError('plugin_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, { received, next: NEXT });
139
+ }
140
+ function label(spec) {
141
+ return `plugin "${spec.plugin.name}" command \`${spec.commandPath.join(' ')}\``;
142
+ }
143
+ function isObject(v) {
144
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
145
+ }
@@ -0,0 +1,5 @@
1
+ import type { BranchDef } from '../command.js';
2
+ import type { CommandRegistrySnapshot } from './discovery.js';
3
+ /** Map an accepted registry snapshot to real top-level BranchDefs, ready to
4
+ * concatenate beside core subtrees in `buildRoot()`. */
5
+ export declare function composeExternalSubtrees(snapshot: CommandRegistrySnapshot): BranchDef[];
@@ -0,0 +1,56 @@
1
+ // Tree composer — adapt validated declarative nodes into ordinary crtr
2
+ // BranchDef/LeafDef values via the SAME defineBranch/defineRoot path core uses,
3
+ // so external contributions get child-owned parent representations and the
4
+ // existing help renderer with no second help engine. External nodes are
5
+ // static-only (no dynamicState, no custom renderer); each leaf's run closure is
6
+ // the external adapter. Composition is deterministic: contributions arrive
7
+ // already sorted by name from discovery.
8
+ import { defineBranch, defineLeaf } from '../command.js';
9
+ import { executeExternalLeaf } from './adapter.js';
10
+ /** Map an accepted registry snapshot to real top-level BranchDefs, ready to
11
+ * concatenate beside core subtrees in `buildRoot()`. */
12
+ export function composeExternalSubtrees(snapshot) {
13
+ return snapshot.contributions.map((c) => buildBranch(c, c.node, [c.node.name]));
14
+ }
15
+ function buildBranch(contribution, node, path) {
16
+ const rootEntry = node.rootEntry !== undefined
17
+ ? {
18
+ concept: node.rootEntry.concept,
19
+ // Explicit field mapping: declarative description→desc, whenToUse→useWhen.
20
+ desc: node.rootEntry.description,
21
+ useWhen: node.rootEntry.whenToUse,
22
+ }
23
+ : undefined;
24
+ return defineBranch({
25
+ name: node.name,
26
+ description: node.description,
27
+ whenToUse: node.whenToUse,
28
+ ...(node.tier !== undefined ? { tier: node.tier } : {}),
29
+ ...(rootEntry !== undefined ? { rootEntry } : {}),
30
+ help: {
31
+ name: path.join(' '),
32
+ summary: node.summary,
33
+ ...(node.model !== undefined ? { model: node.model } : {}),
34
+ },
35
+ children: node.children.map((child) => child.kind === 'branch'
36
+ ? buildBranch(contribution, child, [...path, child.name])
37
+ : buildLeaf(contribution, child, [...path, child.name])),
38
+ });
39
+ }
40
+ function buildLeaf(contribution, node, path) {
41
+ return defineLeaf({
42
+ name: node.name,
43
+ description: node.description,
44
+ whenToUse: node.whenToUse,
45
+ ...(node.tier !== undefined ? { tier: node.tier } : {}),
46
+ help: {
47
+ name: path.join(' '),
48
+ summary: node.summary,
49
+ params: node.params,
50
+ output: node.output,
51
+ outputKind: 'object',
52
+ effects: node.effects,
53
+ },
54
+ run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable: contribution.executable, commandPath: path, output: node.output }, input),
55
+ });
56
+ }
@@ -0,0 +1,104 @@
1
+ import type { InstalledPlugin } from '../../types.js';
2
+ import type { InputParam, Field } from '../help.js';
3
+ /** A stable, doctor-renderable validation issue. Never thrown — accumulated
4
+ * and surfaced through `pkg plugin show` / `sys doctor`. */
5
+ export interface CommandDiscoveryIssue {
6
+ /** Stable code the surfaces branch on. */
7
+ code: CommandIssueCode;
8
+ /** The offending plugin's name. */
9
+ plugin: string;
10
+ /** Dotted path to the offending field/node within the manifest, when known. */
11
+ path?: string;
12
+ message: string;
13
+ received: string;
14
+ expected: string;
15
+ next: string;
16
+ }
17
+ export type CommandIssueCode = 'command_manifest_unreadable' | 'command_manifest_invalid' | 'command_schema_version' | 'command_path_unsafe' | 'command_not_executable' | 'command_parent_invalid' | 'command_node_invalid' | 'command_collision';
18
+ /** The validated declarative node schema (static-only — no closures, no
19
+ * dynamicState, no renderers). Params/output reuse the existing public help
20
+ * vocabulary so composition maps them straight through. */
21
+ export interface DeclRootEntry {
22
+ concept: string;
23
+ description: string;
24
+ whenToUse: string;
25
+ }
26
+ export interface DeclBranch {
27
+ kind: 'branch';
28
+ name: string;
29
+ description: string;
30
+ whenToUse: string;
31
+ tier?: 'normal' | 'common' | 'important';
32
+ /** Required on a top-level branch, forbidden on a nested one. */
33
+ rootEntry?: DeclRootEntry;
34
+ summary: string;
35
+ model?: string;
36
+ children: DeclNode[];
37
+ }
38
+ export interface DeclLeaf {
39
+ kind: 'leaf';
40
+ name: string;
41
+ description: string;
42
+ whenToUse: string;
43
+ tier?: 'normal' | 'common' | 'important';
44
+ summary: string;
45
+ params: InputParam[];
46
+ output: Field[];
47
+ outputKind: 'object';
48
+ effects: string[];
49
+ }
50
+ export type DeclNode = DeclBranch | DeclLeaf;
51
+ /** One accepted top-level contribution: a validated branch node plus the
52
+ * plugin identity and canonical executable the adapter needs at run time. */
53
+ export interface ValidatedContribution {
54
+ plugin: InstalledPlugin;
55
+ /** Absolute, realpath-resolved executable path. */
56
+ executable: string;
57
+ /** The validated top-level branch node (always kind 'branch'). */
58
+ node: DeclBranch;
59
+ }
60
+ /** Per-plugin validation result — the Phase 2 lifecycle entry point. Reports
61
+ * accepted contributions and every issue without executing the binary. */
62
+ export interface PluginCommandValidation {
63
+ plugin: InstalledPlugin;
64
+ /** Absolute path to the plugin's commands.json (whether or not it loaded). */
65
+ manifestPath: string;
66
+ /** Absolute, resolved executable path when it passed path/exec-bit safety. */
67
+ executable?: string;
68
+ contributions: ValidatedContribution[];
69
+ issues: CommandDiscoveryIssue[];
70
+ }
71
+ /** The invocation-local registry snapshot the tree composer consumes. */
72
+ export interface CommandRegistrySnapshot {
73
+ /** Accepted top-level contributions, deterministically ordered by name. */
74
+ contributions: ValidatedContribution[];
75
+ /** Every discovery issue across all effective command plugins. */
76
+ issues: CommandDiscoveryIssue[];
77
+ }
78
+ /** The effective, deduped set of enabled plugins that declare a command
79
+ * manifest, in scope-precedence order. Enumerated with existing machinery
80
+ * only: `projectScopeRoots()` (profile-widened, nearest-first) via
81
+ * `listInstalledPluginsInRoot('project', root)`, then user scope. Dedup is by
82
+ * plugin NAME across ALL copies (highest precedence wins) BEFORE the enabled
83
+ * filter — so a disabled higher-precedence copy suppresses lower-precedence
84
+ * copies of the same name. */
85
+ export declare function effectiveCommandPlugins(startDir?: string, profileId?: string | null): InstalledPlugin[];
86
+ /** Validate every effective command plugin and compose an invocation-local
87
+ * registry snapshot. `reservedNames` are the core top-level subtree names —
88
+ * a contribution claiming one is silently skipped from the tree and recorded
89
+ * as a `command_collision` issue (core always wins). A plugin-vs-plugin
90
+ * top-level name collision drops all claimants with an issue. Accepted
91
+ * contributions are sorted by name for deterministic help/order. */
92
+ export declare function discoverCommandContributions(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): CommandRegistrySnapshot;
93
+ /** Validate every effective command plugin, including the cross-plugin
94
+ * top-level name collision pass: a name claimed by more than one plugin is
95
+ * dropped from EVERY claimant's contributions and a `command_collision`
96
+ * issue is recorded on EACH claimant's validation, so per-plugin surfaces
97
+ * (`sys doctor`, `pkg plugin show`) see it too. This is the entry point for
98
+ * any surface that reports on mounted commands — the per-plugin
99
+ * `validatePluginCommands` cannot see other plugins' claims. */
100
+ export declare function validateEffectiveCommandPlugins(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): PluginCommandValidation[];
101
+ /** Validate one plugin's command manifest without executing its binary.
102
+ * `reservedNames` are the core top-level names used for the core-collision
103
+ * check; pass an empty set to skip it. Never throws. */
104
+ export declare function validatePluginCommands(plugin: InstalledPlugin, reservedNames?: ReadonlySet<string>): PluginCommandValidation;