@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.
- package/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +847 -801
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/chat-view.d.ts +4 -0
- package/dist/clients/attach/chat-view.js +10 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +19 -0
- package/dist/clients/attach/input-controller.js +55 -11
- package/dist/clients/attach/titled-editor.d.ts +21 -0
- package/dist/clients/attach/titled-editor.js +101 -0
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +37 -0
- package/dist/commands/sys/setup-core.js +138 -1
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +915 -171
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +71 -41
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +2 -5
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +90 -59
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/banner.d.ts +13 -0
- package/dist/core/runtime/banner.js +51 -0
- package/dist/core/runtime/broker.js +7 -6
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/recap.d.ts +1 -1
- package/dist/core/runtime/recap.js +50 -25
- package/dist/core/runtime/session-list-cache.d.ts +10 -0
- package/dist/core/runtime/session-list-cache.js +94 -26
- package/dist/core/runtime/spawn.js +5 -17
- package/dist/core/runtime/tmux.js +2 -1
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/canvas-recap.js +43 -17
- package/dist/types.d.ts +6 -13
- package/dist/types.js +0 -3
- 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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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 (
|
|
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 (
|
|
584
|
+
// Quit and move.
|
|
585
|
+
if (action === 'crtr.browse.quit')
|
|
556
586
|
quit();
|
|
557
|
-
|
|
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 (
|
|
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 (
|
|
597
|
+
if (action === 'crtr.browse.first') {
|
|
569
598
|
state.cursor = 0;
|
|
570
599
|
scheduleFlush();
|
|
571
600
|
return;
|
|
572
601
|
}
|
|
573
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
657
|
+
if (action === 'crtr.browse.tab-previous') {
|
|
628
658
|
cycleTab(-1);
|
|
629
659
|
scheduleFlush();
|
|
630
660
|
return;
|
|
631
661
|
}
|
|
632
|
-
if (
|
|
633
|
-
const idx = Number(
|
|
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 (
|
|
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 (
|
|
692
|
+
if (action === 'crtr.browse.sort') {
|
|
663
693
|
cycleSort();
|
|
664
694
|
scheduleFlush();
|
|
665
695
|
return;
|
|
666
696
|
}
|
|
667
|
-
if (
|
|
697
|
+
if (action === 'crtr.browse.scope') {
|
|
668
698
|
toggleScope();
|
|
669
699
|
scheduleFlush();
|
|
670
700
|
return;
|
|
671
701
|
}
|
|
672
|
-
if (
|
|
702
|
+
if (action === 'crtr.browse.residents') {
|
|
673
703
|
toggleResidents();
|
|
674
704
|
scheduleFlush();
|
|
675
705
|
return;
|
|
676
706
|
}
|
|
677
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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;
|