@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
@@ -5,6 +5,7 @@
5
5
  // 2. a border color that tracks the agent's thinking level (set by attach-cmd
6
6
  // on each state update), mirroring pi's `getThinkingBorderColor`.
7
7
  // Both are pure render-layer chrome; nothing here touches the socket or session.
8
+ import { statSync } from 'node:fs';
8
9
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
9
10
  import { CURSOR_MARKER, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
11
  /** The standard thinking levels form a deliberate cool→warm ramp between blue
@@ -152,6 +153,39 @@ export function outlineCursorLine(line) {
152
153
  const replacement = `${MUTED_CURSOR_ON}${grapheme}\x1b[0m`;
153
154
  return line.slice(0, start) + replacement + line.slice(end + SOLID_CURSOR_RESET.length);
154
155
  }
156
+ /** Image file extensions a dragged path is recognized by. */
157
+ const IMAGE_PATH_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
158
+ /** Recognize a single input chunk as a filesystem path to an image file — how a
159
+ * terminal delivers a file DRAGGED into the pane (the escaped path pasted in
160
+ * bulk, optionally wrapped in bracketed-paste markers). Returns the resolved
161
+ * path, or null when the chunk isn't a lone image path (normal typing, multi-
162
+ * line text, a non-image path, or a path that doesn't point at a real file).
163
+ * Conservative on purpose: it must never transform ordinary typed input. */
164
+ export function detectDroppedImagePath(data) {
165
+ // Unwrap bracketed paste (\x1b[200~ … \x1b[201~) when the terminal uses it.
166
+ const bracketed = data.match(/\x1b\[200~([\s\S]*?)\x1b\[201~/);
167
+ let payload = (bracketed ? bracketed[1] : data).trim();
168
+ if (payload.length < 2)
169
+ return null; // a lone keystroke is never a drop
170
+ if (/[\u0000-\u0008\u000e-\u001f]/.test(payload))
171
+ return null; // control chars → not a path
172
+ // Terminals wrap a dropped path in quotes or backslash-escape its spaces.
173
+ if ((payload.startsWith("'") && payload.endsWith("'")) ||
174
+ (payload.startsWith('"') && payload.endsWith('"'))) {
175
+ payload = payload.slice(1, -1);
176
+ }
177
+ const path = payload.replace(/\\(.)/g, '$1');
178
+ if (/\n/.test(path) || !IMAGE_PATH_RE.test(path))
179
+ return null;
180
+ try {
181
+ if (!statSync(path).isFile())
182
+ return null;
183
+ }
184
+ catch {
185
+ return null; // not a real file → treat as ordinary pasted text
186
+ }
187
+ return path;
188
+ }
155
189
  export class TitledEditor extends CustomEditor {
156
190
  /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
157
191
  * against. We keep a reference so `handleInput` can resolve the newline chord
@@ -175,11 +209,78 @@ export class TitledEditor extends CustomEditor {
175
209
  * override, so matching the chord here makes the newline behavior independent
176
210
  * of cross-instance keybinding mirroring. Skipped while the autocomplete popup
177
211
  * is open so Enter-to-confirm keeps working. */
212
+ /** Set by the input controller: called when a bulk input chunk is recognized
213
+ * as a filesystem path to an image file (a file DRAGGED into the pane, which
214
+ * the terminal delivers as bracketed-paste TEXT, not an image event). Returns
215
+ * true when it consumed the drop, so the raw path is never inserted as text. */
216
+ onPasteImagePath;
217
+ /** Width of an `[Image #N]` token sitting immediately before the cursor on the
218
+ * current line, or 0 when the cursor isn't at the end of such a token — used to
219
+ * delete/skip the token atomically. The token's separating space is a normal,
220
+ * reachable character, deliberately NOT part of the atom. */
221
+ imageTokenWidthBeforeCursor() {
222
+ const { line, col } = this.getCursor();
223
+ const text = this.getLines()[line];
224
+ if (text === undefined)
225
+ return 0;
226
+ const match = text.slice(0, col).match(/\[Image #\d+\]$/);
227
+ return match ? match[0].length : 0;
228
+ }
229
+ /** Width of an `[Image #N]` token sitting immediately after the cursor on the
230
+ * current line, or 0 when the cursor isn't at the start of such a token — used
231
+ * to skip the token with the right arrow. */
232
+ imageTokenWidthAfterCursor() {
233
+ const { line, col } = this.getCursor();
234
+ const text = this.getLines()[line];
235
+ if (text === undefined)
236
+ return 0;
237
+ const match = text.slice(col).match(/^\[Image #\d+\]/);
238
+ return match ? match[0].length : 0;
239
+ }
178
240
  handleInput(data) {
179
241
  if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.input.newLine')) {
180
242
  this.insertTextAtCursor('\n');
181
243
  return;
182
244
  }
245
+ // Backspace over an `[Image #N]` token deletes the WHOLE token (and its
246
+ // trailing space) in one keypress, not one character at a time — the token is
247
+ // a single atom to the user. We replay the backspace across the token's full
248
+ // width so the base editor's cursor/undo bookkeeping stays correct.
249
+ if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.editor.deleteCharBackward')) {
250
+ const width = this.imageTokenWidthBeforeCursor();
251
+ if (width > 1) {
252
+ for (let i = 0; i < width; i++)
253
+ super.handleInput(data);
254
+ return;
255
+ }
256
+ }
257
+ // Left/right arrow steps OVER an `[Image #N]` token in one keypress — the
258
+ // token reads as a single character, so the caret never lands inside it. We
259
+ // replay the arrow across the token's full width so cursor bookkeeping stays
260
+ // correct.
261
+ if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.editor.cursorLeft')) {
262
+ const width = this.imageTokenWidthBeforeCursor();
263
+ if (width > 1) {
264
+ for (let i = 0; i < width; i++)
265
+ super.handleInput(data);
266
+ return;
267
+ }
268
+ }
269
+ if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.editor.cursorRight')) {
270
+ const width = this.imageTokenWidthAfterCursor();
271
+ if (width > 1) {
272
+ for (let i = 0; i < width; i++)
273
+ super.handleInput(data);
274
+ return;
275
+ }
276
+ }
277
+ // A file dragged into the pane arrives as a bulk paste of its (shell-escaped)
278
+ // path, NOT as an image on the clipboard, so onPasteImage never fires. Detect
279
+ // an image-file path here and hand it to the controller so it becomes an
280
+ // `[Image #N]` token like a clipboard paste, instead of the raw temp path.
281
+ const droppedPath = detectDroppedImagePath(data);
282
+ if (droppedPath && this.onPasteImagePath?.(droppedPath))
283
+ return;
183
284
  super.handleInput(data);
184
285
  }
185
286
  /** Session-name chip painted into the LEFT of the top border. Empty → plain. */
@@ -9,7 +9,7 @@ import { appendInbox } from '../../core/feed/inbox.js';
9
9
  import { closeSync, existsSync, linkSync, openSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
- import { inbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
12
+ import { openInbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
13
13
  import { killPane } from './shared.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // stranded-answer healing
@@ -158,8 +158,8 @@ export const humanInbox = defineLeaf({
158
158
  ],
159
159
  },
160
160
  run: async () => {
161
- await inbox([interactionsRoot(process.cwd())]);
162
- // humanloop's inbox() writes response.json but never calls pushFinal — so a
161
+ await openInbox({ roots: [interactionsRoot(process.cwd())] });
162
+ // humanloop's openInbox() writes response.json but never calls pushFinal — so a
163
163
  // deck drained here would strand (no answer-back, leaked bridge node). Heal
164
164
  // every resolved-but-live interaction: deliver its answer to the asker + reap.
165
165
  const delivered = await healStrandedInteractions(process.cwd());
@@ -766,7 +766,6 @@ export const humanRun = defineLeaf({
766
766
  try {
767
767
  const res = await launchReview(rc.file, {
768
768
  output: rc.output,
769
- noTmux: true,
770
769
  });
771
770
  await pushFinal(rc.job_id, JSON.stringify(res));
772
771
  }
@@ -3,6 +3,8 @@ import { notFound } from '../../core/errors.js';
3
3
  import { paginate } from '../../core/pagination.js';
4
4
  import { listInstalledPlugins, findPluginByName, } from '../../core/resolver.js';
5
5
  import { listAllMemoryDocs } from '../../core/memory-resolver.js';
6
+ import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
7
+ import { SUBTREE_NAMES } from '../../build-root.js';
6
8
  import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
7
9
  export const pluginList = defineLeaf({
8
10
  name: 'list',
@@ -89,9 +91,10 @@ export const pluginShow = defineLeaf({
89
91
  { name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
90
92
  { name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
91
93
  { name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
94
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, executable: string | null (null when path/exec-bit validation failed), mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically — the plugin binary is never executed.' },
92
95
  ],
93
96
  outputKind: 'object',
94
- effects: ['None. Read-only.'],
97
+ effects: ['None. Read-only. Command manifests are validated statically; the plugin executable is never run.'],
95
98
  },
96
99
  run: async (input) => {
97
100
  const name = input['name'];
@@ -114,6 +117,23 @@ export const pluginShow = defineLeaf({
114
117
  }
115
118
  const prefix = `${found.name}/`;
116
119
  const docs = listAllMemoryDocs().filter((d) => d.name.startsWith(prefix));
120
+ let commands;
121
+ if (typeof found.manifest.commands === 'string' && found.manifest.commands.length > 0) {
122
+ const reserved = new Set(SUBTREE_NAMES);
123
+ // Validate against the whole effective set so cross-plugin top-level
124
+ // name collisions show up in this plugin's issues (a collided command is
125
+ // NOT mounted). A copy outside the effective set (disabled, or shadowed
126
+ // by a higher-precedence copy) cannot collide, so the per-plugin
127
+ // validator is exact for it.
128
+ const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === found.root) ??
129
+ validatePluginCommands(found, reserved);
130
+ commands = {
131
+ manifestPath: v.manifestPath,
132
+ executable: v.executable ?? null,
133
+ mounts: v.contributions.map((c) => c.node.name),
134
+ issues: v.issues,
135
+ };
136
+ }
117
137
  return {
118
138
  name: found.name,
119
139
  scope: found.scope,
@@ -124,6 +144,7 @@ export const pluginShow = defineLeaf({
124
144
  name: d.name,
125
145
  path: d.path,
126
146
  })),
147
+ ...(commands !== undefined ? { commands } : {}),
127
148
  };
128
149
  },
129
150
  });
@@ -8,7 +8,25 @@ import { updateConfig, updateState, ensureScopeInitialized } from '../../core/co
8
8
  import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink } from '../../core/fs-utils.js';
9
9
  import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
10
10
  import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
11
+ import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
12
+ import { SUBTREE_NAMES } from '../../build-root.js';
11
13
  import { isGitUrl, setPluginEnabled, resolveInstallScope } from './shared.js';
14
+ /** Validate a just-installed/updated plugin's command manifest, if it declares
15
+ * one. Returns undefined for plugins without a `commands` pointer. Never
16
+ * executes the plugin binary — validation is fully static. */
17
+ function commandReport(name, scope) {
18
+ const plugin = findPluginByName(name, scope);
19
+ if (plugin === null || typeof plugin.manifest.commands !== 'string' || plugin.manifest.commands.length === 0) {
20
+ return undefined;
21
+ }
22
+ const reserved = new Set(SUBTREE_NAMES);
23
+ // Report from the effective set so a cross-plugin top-level collision is
24
+ // visible at install time (a collided command is NOT mounted). A copy
25
+ // outside the effective set (disabled, or shadowed) cannot collide.
26
+ const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === plugin.root) ??
27
+ validatePluginCommands(plugin, reserved);
28
+ return { mounts: v.contributions.map((c) => c.node.name), issues: v.issues };
29
+ }
12
30
  function classifyInstallRef(raw) {
13
31
  if (raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('/')) {
14
32
  return { kind: 'local', value: raw };
@@ -203,10 +221,12 @@ function updateOnePlugin(plugin, marketplaceCache, opts) {
203
221
  }
204
222
  const manifest = readPluginManifest(plugin.root);
205
223
  updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? manifest?.version);
224
+ const commands = commandReport(plugin.name, plugin.scope);
206
225
  return {
207
226
  name: plugin.name,
208
227
  updated: contentUpdate.updated,
209
228
  sha: contentUpdate.sha,
229
+ ...(commands !== undefined ? { commands } : {}),
210
230
  };
211
231
  }
212
232
  export const pluginInstall = defineLeaf({
@@ -225,9 +245,13 @@ export const pluginInstall = defineLeaf({
225
245
  { name: 'name', type: 'string', required: true, constraint: 'Plugin name as installed.' },
226
246
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the plugin was installed into.' },
227
247
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the installed plugin directory.' },
248
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; the plugin binary is never executed.' },
228
249
  ],
229
250
  outputKind: 'object',
230
- effects: ['Clones, links, or copies the plugin into the scope plugins directory. Registers the plugin in config.json with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.'],
251
+ effects: [
252
+ 'Clones, links, or copies the plugin into the scope plugins directory. Registers the plugin in config.json with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
253
+ 'If the plugin declares a command manifest, its commands go live on the next crtr invocation. A command leaf runs the plugin\'s own executable as trusted local code with your authority — only when you explicitly invoke that leaf, never on install, help, or discovery.',
254
+ ],
231
255
  },
232
256
  run: async (input) => {
233
257
  const rawRef = input['installRef'];
@@ -235,13 +259,11 @@ export const pluginInstall = defineLeaf({
235
259
  const gitRef = input['ref'];
236
260
  const scope = resolveInstallScope(scopeInput);
237
261
  const classified = classifyInstallRef(rawRef);
238
- if (classified.kind === 'marketplace') {
239
- return installFromMarketplace(classified.value, scope);
240
- }
241
- if (classified.kind === 'local') {
242
- return installFromLocal(classified.value, scope);
243
- }
244
- return installFromGit(classified.value, gitRef, scope);
262
+ const installed = classified.kind === 'marketplace' ? installFromMarketplace(classified.value, scope)
263
+ : classified.kind === 'local' ? installFromLocal(classified.value, scope)
264
+ : installFromGit(classified.value, gitRef, scope);
265
+ const commands = commandReport(installed.name, installed.scope);
266
+ return commands !== undefined ? { ...installed, commands } : installed;
245
267
  },
246
268
  });
247
269
  export const pluginRemove = defineLeaf({
@@ -351,7 +373,7 @@ export const pluginUpdate = defineLeaf({
351
373
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Plugin name to update. Omit to update all installed plugins.' },
352
374
  ],
353
375
  output: [
354
- { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha}.' },
376
+ { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha, commands?}. commands is present only for plugins declaring a command manifest: {mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})} — validated statically, never executing the plugin binary.' },
355
377
  ],
356
378
  outputKind: 'object',
357
379
  effects: ['Runs git pull in plugin or marketplace directories when they are git checkouts. Updates plugin version and last_updated state.'],
@@ -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 = '';
@@ -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';
@@ -58,6 +59,33 @@ export interface SetupInitialState {
58
59
  exaKeyPath: string;
59
60
  composioKeyPath: string;
60
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;
88
+ }
61
89
  export declare function buildSetupManifest(resolveBuiltinPackageDir?: (name: string) => string): SetupPackageManifestEntry[];
62
90
  export declare function normalizePackageIdentity(source: string, settingsDir: string, homeDir: string): SetupPackageIdentity;
63
91
  export declare function detectInstalledSetupPackageIds(settingsJson: unknown, manifest?: SetupPackageManifestEntry[], settingsPath?: string, homeDir?: string): Set<SetupPackageId>;
@@ -68,6 +96,15 @@ export declare function buildSystemToolState(tool: SetupTool, packageManager: Pa
68
96
  export declare function resolveSettingsPath(homeDir?: string): string;
69
97
  export declare function resolveExaKeyPath(homeDir?: string): string;
70
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;
71
108
  export declare function buildSetupInitialState(opts?: {
72
109
  manifest?: SetupPackageManifestEntry[];
73
110
  settingsPath?: string;