@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -1,7 +1,164 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
4
6
  import { resolveBundledPiCliPath } from '../../../core/runtime/pi-cli.js';
7
+ import { BINDING_CATALOG, resolveKeybindings } from '../../../core/keybindings/index.js';
8
+ import { SetupKeybindingsDraft, buildSetupManifest } from '../setup-core.js';
9
+ import { SetupWizard } from '../setup.js';
10
+ import { applyTheme } from '../../../clients/attach/config-load.js';
11
+ import { stripVTControlCharacters } from 'node:util';
12
+ // The provider panel only touches the TUI on async OAuth transitions (never in
13
+ // these keybindings-tab render/input tests) and reads auth from `agentDir`; a
14
+ // no-op TUI stub and an empty temp agent dir keep the wizard headless.
15
+ const stubTui = { requestRender() { } };
16
+ const agentDir = mkdtempSync(join(tmpdir(), 'setup-wizard-test-'));
17
+ test('setup keybinding draft edits, disables, resets, and discards transactionally', () => {
18
+ const draft = new SetupKeybindingsDraft({ 'crtr.attach.graph.toggle': ['alt+x'] });
19
+ assert.equal(draft.expression('crtr.attach.graph.toggle'), 'alt+x');
20
+ assert.equal(draft.dirty, false);
21
+ assert.equal(draft.edit('crtr.attach.graph.toggle', 'M-Y, ctrl+g').ok, true);
22
+ assert.deepEqual(draft.overrides()['crtr.attach.graph.toggle'], ['alt+shift+y', 'ctrl+g']);
23
+ assert.equal(draft.dirty, true);
24
+ assert.equal(draft.unbind('crtr.attach.graph.toggle').ok, true);
25
+ assert.deepEqual(draft.overrides()['crtr.attach.graph.toggle'], []);
26
+ draft.resetRow('crtr.attach.graph.toggle');
27
+ assert.equal(Object.hasOwn(draft.overrides(), 'crtr.attach.graph.toggle'), false);
28
+ draft.edit('crtr.attach.graph.toggle', 'alt+y');
29
+ draft.discard();
30
+ assert.deepEqual(draft.overrides(), { 'crtr.attach.graph.toggle': ['alt+x'] });
31
+ assert.equal(draft.dirty, false);
32
+ });
33
+ test('setup keybinding draft drops a member when the committed value equals the catalog default (N2 sparseness)', () => {
34
+ const draft = new SetupKeybindingsDraft(undefined);
35
+ const definition = BINDING_CATALOG.find((entry) => entry.id === 'crtr.attach.graph.toggle');
36
+ const defaultExpression = draft.expression(definition.id);
37
+ // Committing the seeded default verbatim must NOT record an override: the row
38
+ // stays sparse (no member) and the draft stays clean — no spurious unsaved/dot.
39
+ const result = draft.edit(definition.id, defaultExpression);
40
+ assert.equal(result.ok, true);
41
+ assert.equal(Object.hasOwn(draft.overrides(), definition.id), false);
42
+ assert.equal(draft.dirty, false);
43
+ // Editing to a real value records the member; re-committing the default drops it.
44
+ assert.equal(draft.edit(definition.id, 'ctrl+g').ok, true);
45
+ assert.deepEqual(draft.overrides()[definition.id], ['ctrl+g']);
46
+ assert.equal(draft.dirty, true);
47
+ assert.equal(draft.edit(definition.id, defaultExpression).ok, true);
48
+ assert.equal(Object.hasOwn(draft.overrides(), definition.id), false);
49
+ assert.equal(draft.dirty, false);
50
+ });
51
+ test('setup keybinding draft keeps the last valid cell value on syntax, collision, and safety errors', () => {
52
+ const draft = new SetupKeybindingsDraft(undefined);
53
+ const syntax = draft.edit('crtr.attach.graph.toggle', 'ctrl+not-a-key');
54
+ assert.equal(syntax.ok, false);
55
+ assert.deepEqual(draft.overrides(), {});
56
+ const collision = draft.edit('crtr.attach.graph.toggle', 'alt+m');
57
+ assert.equal(collision.ok, false);
58
+ assert.match(collision.diagnostics[0].message, /crtr\.mode\.cycle/);
59
+ assert.deepEqual(draft.overrides(), {});
60
+ const unsafe = draft.unbind('crtr.setup.keybindings.save');
61
+ assert.equal(unsafe.ok, false);
62
+ assert.match(unsafe.diagnostics[0].message, /save-keybindings/);
63
+ assert.deepEqual(draft.overrides(), {});
64
+ });
65
+ test('setup reset-all removes invalid and unknown raw members without cataloging pi bindings', () => {
66
+ const invalidRoot = new SetupKeybindingsDraft('alt+x');
67
+ assert.equal(invalidRoot.resolution().diagnostics[0]?.code, 'invalid-keybindings');
68
+ invalidRoot.resetAll();
69
+ assert.equal(invalidRoot.dirty, true);
70
+ assert.equal(invalidRoot.resolution().diagnostics.length, 0);
71
+ const draft = new SetupKeybindingsDraft({
72
+ 'crtr.unknown': ['x'],
73
+ 'crtr.attach.graph.toggle': 'alt+x',
74
+ });
75
+ assert.equal(draft.resolution().diagnostics.length, 2);
76
+ draft.resetAll();
77
+ assert.deepEqual(draft.overrides(), {});
78
+ assert.equal(draft.resolution().diagnostics.length, 0);
79
+ assert.equal(BINDING_CATALOG.some((entry) => entry.id.includes('app.') || entry.id.includes('tui.')), false);
80
+ });
81
+ test('setup keybinding draft refuses edits while the raw root is invalid until reset-all', () => {
82
+ const draft = new SetupKeybindingsDraft('alt+x');
83
+ assert.equal(draft.hasInvalidRoot(), true);
84
+ // An edit against an invalid root must NOT commit (the prior bug: it returned
85
+ // ok:true against an empty `current` while the row kept showing the default).
86
+ const rejected = draft.edit('crtr.attach.graph.toggle', 'ctrl+g');
87
+ assert.equal(rejected.ok, false);
88
+ assert.equal(rejected.diagnostics[0].code, 'invalid-keybindings');
89
+ assert.deepEqual(draft.overrides(), {});
90
+ // reset-all clears the invalid root; edits then succeed and display.
91
+ draft.resetAll();
92
+ assert.equal(draft.hasInvalidRoot(), false);
93
+ const accepted = draft.edit('crtr.attach.graph.toggle', 'ctrl+g');
94
+ assert.equal(accepted.ok, true);
95
+ assert.deepEqual(draft.overrides()['crtr.attach.graph.toggle'], ['ctrl+g']);
96
+ });
97
+ test('setup keybindings editor stays visible while paging moves the selection (m3)', () => {
98
+ applyTheme({ cwd: process.cwd() });
99
+ const openingBindings = resolveKeybindings(undefined);
100
+ // A short terminal forces a small scroll window so paging past the edit row
101
+ // is the reproducible m3 scenario (editor was blind-typing off-screen before).
102
+ const wizard = new SetupWizard(buildSetupManifest(), new Set(), [], stubTui, agentDir, openingBindings, undefined, () => 14);
103
+ wizard.focused = true;
104
+ const render = () => stripVTControlCharacters(wizard.render(100).join('\n'));
105
+ // Drive to the Keybindings tab (tab-next = Tab), open the editor on row 0 (Enter).
106
+ for (let i = 0; i < 5; i += 1)
107
+ wizard.handleInput('\t');
108
+ assert.match(render(), /Keybindings/);
109
+ wizard.handleInput('\r');
110
+ // Type a distinctive marker into the live editor buffer.
111
+ wizard.handleInput('zzz');
112
+ assert.match(render(), /zzz/, 'editor should echo typed input before paging');
113
+ // PageDown twice: selection moves off, but the editor row must remain on screen.
114
+ wizard.handleInput('\x1b[6~');
115
+ wizard.handleInput('\x1b[6~');
116
+ assert.match(render(), /zzz/, 'editor must stay visible (and keep capturing) after paging');
117
+ });
118
+ test('setup keybindings q-cancel with a dirty draft routes through discard-confirm, not a silent exit (N3)', () => {
119
+ applyTheme({ cwd: process.cwd() });
120
+ const wizard = new SetupWizard(buildSetupManifest(), new Set(), [], stubTui, agentDir, resolveKeybindings(undefined), undefined, () => 40);
121
+ wizard.focused = true;
122
+ let cancelled = false;
123
+ wizard.onCancel = () => { cancelled = true; };
124
+ const render = () => stripVTControlCharacters(wizard.render(100).join('\n'));
125
+ for (let i = 0; i < 5; i += 1)
126
+ wizard.handleInput('\t');
127
+ // Make the draft dirty: unbind the selected row (delete key) so it has a real
128
+ // pending override.
129
+ wizard.handleInput('\x1b[3~');
130
+ assert.match(render(), /unsaved/, 'draft should be dirty after unbind');
131
+ // q (crtr.setup.cancel) in list mode must NOT drop the edits: it opens the same
132
+ // discard-confirm the Escape path uses.
133
+ wizard.handleInput('q');
134
+ assert.equal(cancelled, false, 'dirty draft must not silently exit on q');
135
+ assert.match(render(), /Discard every unsaved keybinding change\?/);
136
+ // A clean draft still exits on q (cancel elsewhere is unchanged).
137
+ const clean = new SetupWizard(buildSetupManifest(), new Set(), [], stubTui, agentDir, resolveKeybindings(undefined), undefined, () => 40);
138
+ let cleanCancelled = false;
139
+ clean.onCancel = () => { cleanCancelled = true; };
140
+ clean.focused = true;
141
+ for (let i = 0; i < 5; i += 1)
142
+ clean.handleInput('\t');
143
+ clean.handleInput('q');
144
+ assert.equal(cleanCancelled, true, 'clean draft still exits on q');
145
+ });
146
+ test('setup keybindings selection always renders across group boundaries in a short window (m4 residual)', () => {
147
+ applyTheme({ cwd: process.cwd() });
148
+ // Small viewport so a window spans several group headings + a diagnostic line —
149
+ // the fixed-reserve page math used to let the selected row fail to render here.
150
+ const wizard = new SetupWizard(buildSetupManifest(), new Set(), [], stubTui, agentDir, resolveKeybindings(undefined), undefined, () => 13);
151
+ wizard.focused = true;
152
+ const render = () => stripVTControlCharacters(wizard.render(100).join('\n'));
153
+ for (let i = 0; i < 5; i += 1)
154
+ wizard.handleInput('\t');
155
+ // Page/step down through the whole catalog; the selection marker must always render.
156
+ for (let step = 0; step < BINDING_CATALOG.length + 5; step += 1) {
157
+ assert.match(render(), /\u25b6/, `selection marker must render at step ${step}`);
158
+ wizard.handleInput('\x1b[6~'); // PageDown
159
+ wizard.handleInput('j'); // and a single-row step to hit every boundary alignment
160
+ }
161
+ });
5
162
  test('resolveBundledPiCliPath uses crouter bundled pi instead of a standalone pi on PATH', () => {
6
163
  const prevPath = process.env.PATH;
7
164
  process.env.PATH = '';
@@ -1,5 +1,5 @@
1
1
  import { defineBranch, defineLeaf } from '../../core/command.js';
2
- import { readConfig, writeConfig, configPath as coreConfigPath } from '../../core/config.js';
2
+ import { readConfig, updateConfig, configPath as coreConfigPath } from '../../core/config.js';
3
3
  import { usage, notFound } from '../../core/errors.js';
4
4
  import { scopeRoot, listScopes } from '../../core/scope.js';
5
5
  import { resolveScope } from './shared.js';
@@ -11,7 +11,6 @@ const TOP_LEVEL_KEYS = new Set([
11
11
  'marketplaces',
12
12
  'plugins',
13
13
  'max_panes_per_window',
14
- 'canvasNav',
15
14
  'modelLadders',
16
15
  ]);
17
16
  const MODEL_PROVIDERS = new Set(['anthropic', 'openai']);
@@ -44,6 +43,9 @@ function parseConfigValue(raw) {
44
43
  function setNestedValue(cfg, key, rawValue, value) {
45
44
  const parts = key.split('.');
46
45
  const topKey = parts[0];
46
+ if (topKey === 'keybindings') {
47
+ throw usage('keybindings are edited only through `crtr sys setup`');
48
+ }
47
49
  if (!TOP_LEVEL_KEYS.has(topKey)) {
48
50
  throw usage(`unknown config key: ${topKey} (expected: ${[...TOP_LEVEL_KEYS].join('|')})`);
49
51
  }
@@ -69,16 +71,6 @@ function setNestedValue(cfg, key, rawValue, value) {
69
71
  cfg.max_panes_per_window = Math.floor(value);
70
72
  return;
71
73
  }
72
- if (topKey === 'canvasNav') {
73
- // Only the scalar prefixKey is settable here; the prefixBinds/graphBinds
74
- // record tables are edited directly in config.json (run `crtr sys config
75
- // path` to locate it).
76
- if (key === 'canvasNav.prefixKey') {
77
- cfg.canvasNav.prefixKey = rawValue;
78
- return;
79
- }
80
- throw usage(`canvasNav.${parts.slice(1).join('.') || '*'} is a record table — edit config.json directly (run \`crtr sys config path\`). Only canvasNav.prefixKey is settable via this command.`);
81
- }
82
74
  if (topKey === 'modelLadders') {
83
75
  if (parts.length === 2 && parts[1] === 'defaultProvider') {
84
76
  if (!isModelProvider(rawValue)) {
@@ -121,12 +113,12 @@ function setNestedValue(cfg, key, rawValue, value) {
121
113
  const configGet = defineLeaf({
122
114
  name: 'get',
123
115
  description: 'read a config value by key',
124
- whenToUse: 'you want to read a single config value by its dotted key the current auto_update policy, max_panes_per_window, the canvasNav prefix key, or model ladders — optionally from a specific scope. Use sys config set instead to change a value, sys config path to locate the file for hand-editing.',
116
+ whenToUse: 'you want to inspect one config value — including the whole user keybindings object — optionally from a specific scope. Use sys setup to edit keybindings and sys config set for supported scalar settings.',
125
117
  help: {
126
118
  name: 'sys config get',
127
119
  summary: 'read a config value by dotted key',
128
120
  params: [
129
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, canvasNav, modelLadders (read whole; edit canvasNav.prefixBinds/graphBinds in config.json directly).' },
121
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, modelLadders, or the whole keybindings object. Keybindings are edited only through sys setup.' },
130
122
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to read from. Default: user.' },
131
123
  ],
132
124
  output: [
@@ -140,6 +132,9 @@ const configGet = defineLeaf({
140
132
  run: async (input) => {
141
133
  const key = input['key'];
142
134
  const scope = resolveScope(input['scope']);
135
+ if (key.startsWith('keybindings.') || (key === 'keybindings' && scope !== 'user')) {
136
+ throw notFound('keybindings are available only as the whole user-scope object');
137
+ }
143
138
  const cfg = readConfig(scope);
144
139
  const value = getNestedValue(cfg, key);
145
140
  if (value === undefined) {
@@ -151,12 +146,12 @@ const configGet = defineLeaf({
151
146
  const configSet = defineLeaf({
152
147
  name: 'set',
153
148
  description: 'write a config value by key',
154
- whenToUse: 'you want to change a crtr setting — flip auto_update.crtr or auto_update.content to notify, apply, or off, raise max_panes_per_window, rebind canvasNav.prefixKey, or set model ladders — written to the user or project scope. Use sys config get instead to read a value; the canvasNav record tables are not settable here, so edit config.json directly (sys config path) for those.',
149
+ whenToUse: 'you want to change a supported scalar crtr setting in user or project scope. Use sys setup instead for keybindings, and sys config get to inspect values.',
155
150
  help: {
156
151
  name: 'sys config set',
157
152
  summary: 'write a config value by dotted key',
158
153
  params: [
159
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, canvasNav.prefixKey, modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. The canvasNav.prefixBinds/graphBinds record tables are not settable here — edit config.json directly (`crtr sys config path`).' },
154
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys setup.' },
160
155
  { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. Stored as-is if quoted; coerced to number or boolean when unambiguous.' },
161
156
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user.' },
162
157
  ],
@@ -174,9 +169,11 @@ const configSet = defineLeaf({
174
169
  const scope = resolveScope(input['scope']);
175
170
  // Flags are stringly-typed; coerce to number or boolean when unambiguous
176
171
  const parsed = parseConfigValue(rawValue);
177
- const cfg = readConfig(scope);
178
- setNestedValue(cfg, key, rawValue, parsed);
179
- writeConfig(scope, cfg);
172
+ // Mutate + persist through the central raw-partial path. `updateConfig`
173
+ // writes only the keys this set actually touched -- a non-ladder set never
174
+ // freezes a materialized `modelLadders`, and a direct
175
+ // `modelLadders.<provider>.<strength>` set persists only the touched rung.
176
+ const cfg = updateConfig(scope, (c) => setNestedValue(c, key, rawValue, parsed));
180
177
  // Read back the written value for echo
181
178
  const written = getNestedValue(cfg, key);
182
179
  return { key, value: written, scope };
@@ -185,7 +182,7 @@ const configSet = defineLeaf({
185
182
  const configPath = defineLeaf({
186
183
  name: 'path',
187
184
  description: 'print path(s) to config.json',
188
- whenToUse: 'you need the absolute path to config.json typically to hand-edit settings sys config set cannot reach, like the canvasNav.prefixBinds and graphBinds record tables.',
185
+ whenToUse: 'you need the absolute path to a user or project config.json for inspection or backup.',
189
186
  help: {
190
187
  name: 'sys config path',
191
188
  summary: 'print absolute path(s) to config.json',
@@ -225,7 +222,7 @@ const configPath = defineLeaf({
225
222
  export const configBranch = defineBranch({
226
223
  name: 'config',
227
224
  description: 'read and write configuration',
228
- whenToUse: 'inspecting or changing crtr settings — read a value with sys config get, change one with sys config set (auto_update policy, max_panes_per_window, canvasNav.prefixKey, model ladders), or locate config.json with sys config path to hand-edit the record tables set cannot reach.',
225
+ whenToUse: 'inspecting crtr settings, changing supported scalar values, or locating config.json. Keybinding changes belong in sys setup.',
229
226
  help: {
230
227
  name: 'sys config',
231
228
  summary: 'read and write crtr configuration, including model ladders',
@@ -8,6 +8,8 @@ import { lsRemote } from '../../core/git.js';
8
8
  import { listNodes } from '../../core/canvas/index.js';
9
9
  import { activeFaultForDisplay } from '../../core/canvas/render.js';
10
10
  import { readFault } from '../../core/runtime/fault.js';
11
+ import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
12
+ import { SUBTREE_NAMES } from '../../build-root.js';
11
13
  function pass(scope, name, message) {
12
14
  return { scope, name, status: 'pass', message };
13
15
  }
@@ -89,6 +91,41 @@ function faultRemediation(fault) {
89
91
  logPath: fault.logPath,
90
92
  };
91
93
  }
94
+ /**
95
+ * Validate the command manifest + executable path of every effective command
96
+ * plugin (the deduped, enabled set that actually composes into the tree).
97
+ * Purely static — the plugin binary is NEVER executed, and no remediation ever
98
+ * chmods or rewrites third-party plugin content (every fix is a human_action:
99
+ * disable/update/remove the plugin). Only plugins whose scope is in `scopes`
100
+ * are reported, so `--scope` narrows these checks too.
101
+ */
102
+ function runCommandPluginChecks(scopes) {
103
+ const results = [];
104
+ const inScope = new Set(scopes);
105
+ const reserved = new Set(SUBTREE_NAMES);
106
+ // Validate the whole effective set together so cross-plugin top-level name
107
+ // collisions surface here — a collided command is NOT mounted, and every
108
+ // claimant must fail, not pass.
109
+ for (const v of validateEffectiveCommandPlugins(reserved)) {
110
+ const plugin = v.plugin;
111
+ if (!inScope.has(plugin.scope))
112
+ continue;
113
+ if (v.issues.length === 0) {
114
+ const mounts = v.contributions.map((c) => c.node.name).join(', ');
115
+ results.push(pass(plugin.scope, `plugin:${plugin.name}:commands`, mounts.length > 0 ? `contributes top-level command(s): ${mounts}` : `command manifest valid`));
116
+ continue;
117
+ }
118
+ v.issues.forEach((issue, i) => {
119
+ const remediation = {
120
+ kind: 'human_action',
121
+ description: `${issue.next} If unfixable, disable (pkg plugin disable ${plugin.name}), update (pkg plugin update --name ${plugin.name}), or remove (pkg plugin remove ${plugin.name}) the plugin.`,
122
+ };
123
+ const detail = issue.path !== undefined ? ` at ${issue.path}` : '';
124
+ results.push(failCheck(plugin.scope, `plugin:${plugin.name}:commands[${i}]`, `${issue.code}${detail}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, remediation));
125
+ });
126
+ }
127
+ return results;
128
+ }
92
129
  function runFaultChecks() {
93
130
  const results = [];
94
131
  for (const node of listNodes()) {
@@ -240,7 +277,7 @@ function runChecksForScope(scope, opts) {
240
277
  export const sysDoctorLeaf = defineLeaf({
241
278
  name: 'doctor',
242
279
  description: 'diagnose installation health',
243
- whenToUse: 'something in your crtr install looks off and you want it diagnosed — a plugin or marketplace manifest is missing, or a config entry points at a directory that no longer exists. Reports each problem with a structured remediation, and can apply the repairs for you.',
280
+ whenToUse: 'something in your crtr install looks off and you want it diagnosed — a plugin or marketplace manifest is missing, a config entry points at a directory that no longer exists, or a command-plugin manifest/executable is malformed. Reports each problem with a structured remediation, and can apply the safe repairs for you.',
244
281
  help: {
245
282
  name: 'sys doctor',
246
283
  summary: 'diagnose missing manifests and broken config entries',
@@ -250,13 +287,13 @@ export const sysDoctorLeaf = defineLeaf({
250
287
  { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
251
288
  ],
252
289
  output: [
253
- { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action. Sorted by scope then name.' },
290
+ { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. Command-plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action. Sorted by scope then name.' },
254
291
  { name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
255
292
  ],
256
293
  outputKind: 'object',
257
294
  effects: [
258
- 'Read-only unless --fix is passed.',
259
- 'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries and deletes dangling plugin/marketplace directories; canvas fault findings stay read-only.',
295
+ 'Read-only unless --fix is passed. Command-plugin manifests are validated statically — the plugin executable is never run, at any time.',
296
+ 'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries and deletes dangling plugin/marketplace directories; canvas fault findings and command-plugin findings stay read-only (--fix never chmods or rewrites third-party plugin content).',
260
297
  'Each non-pass result carries a structured `remediation` describing the fix action (absolute paths, exact config keys, or a human_action with logPath) so callers can apply it directly without --fix.',
261
298
  ],
262
299
  },
@@ -270,6 +307,7 @@ export const sysDoctorLeaf = defineLeaf({
270
307
  const results = runChecksForScope(scope, { fix, remote });
271
308
  allResults.push(...results);
272
309
  }
310
+ allResults.push(...runCommandPluginChecks(scopes));
273
311
  allResults.push(...runFaultChecks());
274
312
  // Sort by scope then name (mirrors old printResults grouping)
275
313
  const scopeOrder = { user: 0, project: 1, builtin: 2, canvas: 3 };
@@ -1,3 +1,4 @@
1
+ import { type BindingId, type BindingResolution, type KeybindingDiagnostic, type SparseKeybindingOverrides } from '../../core/keybindings/index.js';
1
2
  export type SetupPackageId = 'crtr-extensions' | 'mode-switch' | 'oauth-adapter';
2
3
  export type SetupTool = 'tmux' | 'nvim';
3
4
  export type SetupPackageSourceKind = 'local-path' | 'npm';
@@ -31,6 +32,7 @@ export interface SetupStaticInstructions {
31
32
  packageLines: string[];
32
33
  systemLines: string[];
33
34
  exaLine: string;
35
+ composioLine: string;
34
36
  footer: string;
35
37
  }
36
38
  export interface SetupSubmission {
@@ -39,18 +41,50 @@ export interface SetupSubmission {
39
41
  systemDeps: SetupSystemInstallPlan[];
40
42
  exaKey: string;
41
43
  exaKeyPath: string;
44
+ composioKey: string;
45
+ composioKeyPath: string;
42
46
  }
43
47
  export interface SetupExecutionSummary {
44
48
  installed: string[];
45
49
  skipped: string[];
46
50
  exaKeyWritten: boolean;
47
51
  exaKeyPath: string;
52
+ composioKeyWritten: boolean;
53
+ composioKeyPath: string;
48
54
  }
49
55
  export interface SetupInitialState {
50
56
  manifest: SetupPackageManifestEntry[];
51
57
  installedPackageIds: Set<SetupPackageId>;
52
58
  systemTools: SetupSystemToolState[];
53
59
  exaKeyPath: string;
60
+ composioKeyPath: string;
61
+ }
62
+ export interface KeybindingEditResult {
63
+ readonly ok: boolean;
64
+ readonly diagnostics: readonly KeybindingDiagnostic[];
65
+ }
66
+ /** Transactional sparse keybinding draft used by the full-screen setup tab. */
67
+ export declare class SetupKeybindingsDraft {
68
+ private expected;
69
+ private baseline;
70
+ private current;
71
+ private baselineInvalidRoot;
72
+ private invalidRoot;
73
+ constructor(rawKeybindings: unknown);
74
+ get dirty(): boolean;
75
+ overrides(): Record<string, unknown>;
76
+ expectedKeybindings(): unknown;
77
+ resolution(): BindingResolution<BindingId>;
78
+ /** True while the persisted `keybindings` root is a non-object (e.g. a string):
79
+ * edits cannot be layered onto an invalid root, so the tab must reset-all first. */
80
+ hasInvalidRoot(): boolean;
81
+ expression(id: BindingId): string;
82
+ edit(id: BindingId, expression: string): KeybindingEditResult;
83
+ unbind(id: BindingId): KeybindingEditResult;
84
+ resetRow(id: BindingId): void;
85
+ resetAll(): void;
86
+ discard(): void;
87
+ markSaved(overrides: SparseKeybindingOverrides<BindingId>): void;
54
88
  }
55
89
  export declare function buildSetupManifest(resolveBuiltinPackageDir?: (name: string) => string): SetupPackageManifestEntry[];
56
90
  export declare function normalizePackageIdentity(source: string, settingsDir: string, homeDir: string): SetupPackageIdentity;
@@ -61,6 +95,16 @@ export declare function buildSystemInstallPlan(tool: SetupTool, packageManager:
61
95
  export declare function buildSystemToolState(tool: SetupTool, packageManager: PackageManager, commandExists?: (command: string) => boolean): SetupSystemToolState;
62
96
  export declare function resolveSettingsPath(homeDir?: string): string;
63
97
  export declare function resolveExaKeyPath(homeDir?: string): string;
98
+ export declare function resolveComposioKeyPath(homeDir?: string): string;
99
+ /** Where an already-present API key is resolved from — env var wins over the
100
+ * key file, matching the runtime resolvers (search/exa.ts, connectors). */
101
+ export type ApiKeySource = 'env' | 'file';
102
+ export interface ApiKeyPresence {
103
+ present: boolean;
104
+ source?: ApiKeySource;
105
+ }
106
+ export declare function detectExaKey(homeDir?: string): ApiKeyPresence;
107
+ export declare function detectComposioKey(homeDir?: string): ApiKeyPresence;
64
108
  export declare function buildSetupInitialState(opts?: {
65
109
  manifest?: SetupPackageManifestEntry[];
66
110
  settingsPath?: string;
@@ -73,9 +117,11 @@ export declare function buildSetupStaticInstructions(opts: {
73
117
  installedPackageIds: Set<SetupPackageId>;
74
118
  systemPlans: SetupSystemInstallPlan[];
75
119
  exaKeyPath: string;
120
+ composioKeyPath: string;
76
121
  }): SetupStaticInstructions;
77
- export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath'>): string;
122
+ export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath' | 'composioKeyPath'>): string;
78
123
  export declare function writeExaKey(exaKeyPath: string, exaKey: string): Promise<void>;
124
+ export declare function writeComposioKey(composioKeyPath: string, composioKey: string): Promise<void>;
79
125
  export declare function summarizeSetupExecution(result: {
80
126
  installedPackages: string[];
81
127
  skippedPackages: string[];
@@ -83,6 +129,8 @@ export declare function summarizeSetupExecution(result: {
83
129
  skippedSystemDeps: string[];
84
130
  exaKeyWritten: boolean;
85
131
  exaKeyPath: string;
132
+ composioKeyWritten: boolean;
133
+ composioKeyPath: string;
86
134
  }): SetupExecutionSummary;
87
135
  export declare function formatSetupExecutionSummary(summary: SetupExecutionSummary): string;
88
136
  export declare function executeSetupSubmission(submission: SetupSubmission): Promise<SetupExecutionSummary>;