@crouton-kit/crouter 0.3.66 → 0.3.68

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 (86) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  3. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  4. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  6. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  8. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  9. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  10. package/dist/clients/attach/attach-cmd.js +624 -624
  11. package/dist/clients/attach/canvas-panels.js +9 -3
  12. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  13. package/dist/clients/attach/graph-overlay.js +40 -7
  14. package/dist/clients/attach/input-controller.d.ts +9 -0
  15. package/dist/clients/attach/input-controller.js +47 -8
  16. package/dist/clients/attach/slash-commands.d.ts +11 -4
  17. package/dist/clients/attach/slash-commands.js +44 -9
  18. package/dist/clients/attach/transport-relay.d.ts +5 -5
  19. package/dist/clients/attach/transport-relay.js +3 -30
  20. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  21. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  22. package/dist/commands/canvas-browse.js +26 -1
  23. package/dist/commands/canvas-config.d.ts +2 -0
  24. package/dist/commands/canvas-config.js +210 -0
  25. package/dist/commands/canvas-use.d.ts +1 -0
  26. package/dist/commands/canvas-use.js +63 -0
  27. package/dist/commands/canvas.js +4 -2
  28. package/dist/commands/capture.d.ts +2 -0
  29. package/dist/commands/capture.js +28 -0
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  32. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  33. package/dist/core/__tests__/revive.test.js +70 -1
  34. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  35. package/dist/core/__tests__/secrets.test.js +55 -0
  36. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  38. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  40. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  42. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  44. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  46. package/dist/core/canvas/browse/app.d.ts +14 -0
  47. package/dist/core/canvas/browse/app.js +97 -15
  48. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  49. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  50. package/dist/core/canvas/browse/render.d.ts +4 -0
  51. package/dist/core/canvas/browse/render.js +3 -1
  52. package/dist/core/canvas/nav-model.d.ts +19 -10
  53. package/dist/core/canvas/nav-model.js +30 -12
  54. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  55. package/dist/core/canvas/remote-canvas-source.js +222 -0
  56. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  57. package/dist/core/canvas/remote-event-stream.js +94 -0
  58. package/dist/core/canvas/render.d.ts +13 -1
  59. package/dist/core/canvas/render.js +56 -37
  60. package/dist/core/canvas/source.d.ts +9 -0
  61. package/dist/core/canvas/source.js +15 -0
  62. package/dist/core/command.d.ts +15 -0
  63. package/dist/core/command.js +72 -0
  64. package/dist/core/config.js +4 -3
  65. package/dist/core/profiles/select.d.ts +4 -1
  66. package/dist/core/profiles/select.js +356 -81
  67. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  69. package/dist/core/runtime/nodes.js +10 -0
  70. package/dist/core/runtime/revive.js +176 -139
  71. package/dist/core/runtime/tmux.js +4 -1
  72. package/dist/core/secrets.d.ts +25 -0
  73. package/dist/core/secrets.js +55 -0
  74. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  75. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  76. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  77. package/dist/core/view/remote-canvas-target.js +12 -7
  78. package/dist/core/view/transport-local.js +12 -3
  79. package/dist/core/view/transport-remote.d.ts +2 -0
  80. package/dist/core/view/transport-remote.js +53 -0
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
  84. package/dist/web-client/index.html +1 -1
  85. package/dist/web-client/sw.js +1 -1
  86. package/package.json +1 -1
@@ -10,6 +10,8 @@ import { renderResult } from './render.js';
10
10
  import { CrtrError } from './errors.js';
11
11
  import { ExitCode } from '../types.js';
12
12
  import { readFileSync } from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { constants as osConstants } from 'node:os';
13
15
  // ---------------------------------------------------------------------------
14
16
  // Factory functions
15
17
  // ---------------------------------------------------------------------------
@@ -34,6 +36,14 @@ function visibleSubCount(def) {
34
36
  return (def.help.listing ?? []).filter((c) => c.tier !== 'hidden').length;
35
37
  }
36
38
  export function defineBranch(opts) {
39
+ // A passthrough branch must be childless: walk() only stops at the deepest
40
+ // matched node, so a passthrough branch with children would let some
41
+ // invocations descend into a child (tree dispatch) while others hit the
42
+ // passthrough interception — silently inconsistent transparency. Fail loudly
43
+ // at construction instead of letting that split happen at runtime.
44
+ if (opts.passthrough !== undefined && opts.children.length > 0) {
45
+ throw new Error(`defineBranch("${opts.name}"): a passthrough branch must declare no children (found ${opts.children.length})`);
46
+ }
37
47
  // Assemble the parent-level listing straight from the child defs — each node
38
48
  // owns its own description/whenToUse/tier, so the parent copies nothing
39
49
  // (principle 16). Bottom-up construction guarantees a child branch's listing
@@ -57,6 +67,7 @@ export function defineBranch(opts) {
57
67
  help: opts.help,
58
68
  rootEntry: opts.rootEntry,
59
69
  slash: opts.slash,
70
+ passthrough: opts.passthrough,
60
71
  children: opts.children,
61
72
  };
62
73
  }
@@ -169,6 +180,59 @@ function renderNode(node) {
169
180
  function helpRequested(remaining) {
170
181
  return remaining.some((t) => t === '-h' || t === '--help');
171
182
  }
183
+ /** Recover the RAW argv slice after a matched passthrough branch's path,
184
+ * unaffected by the `--json`-stripping done to build `tokens`. `path` is a
185
+ * prefix of non-`--json` tokens in order, so walking `rawTokens` while
186
+ * skipping `--json` occurrences until `path` is fully matched lands the
187
+ * cursor exactly where the branch's own args begin — including a literal
188
+ * `--json` the external binary itself accepts. */
189
+ function rawArgsAfterPath(rawTokens, path) {
190
+ let matched = 0;
191
+ for (let i = 0; i < rawTokens.length; i++) {
192
+ if (matched === path.length)
193
+ return rawTokens.slice(i);
194
+ if (rawTokens[i] === '--json')
195
+ continue;
196
+ if (rawTokens[i] === path[matched])
197
+ matched++;
198
+ }
199
+ return [];
200
+ }
201
+ /** Forward `args` to an external binary verbatim: stdio inherited (streaming
202
+ * preserved), and the child's exit code propagated. Never returns — either
203
+ * exits with the child's status or throws a structured CrtrError (ENOENT ->
204
+ * install hint) for the caller's existing error handler to render. */
205
+ function runPassthrough(passthrough, args) {
206
+ const result = spawnSync(passthrough.bin, args, { stdio: 'inherit' });
207
+ if (result.error !== undefined) {
208
+ // Any launch-time failure (not found, not executable, ...) means this
209
+ // binary isn't usable from here — surface one structured error with the
210
+ // install hint rather than branching on a specific errno. (Node/libuv can
211
+ // report EACCES instead of ENOENT for a genuinely-missing binary when PATH
212
+ // contains malformed entries — e.g. a literal `~/...` segment — so a
213
+ // ENOENT-only check would miss real missing-binary cases on such hosts.)
214
+ const err = result.error;
215
+ throw new CrtrError('binary_missing', `${passthrough.bin} could not be run (${err.code ?? err.message})`, ExitCode.USAGE, {
216
+ received: passthrough.bin,
217
+ next: passthrough.installHint,
218
+ });
219
+ }
220
+ if (result.status !== null) {
221
+ process.exit(result.status);
222
+ }
223
+ if (result.signal !== null) {
224
+ // Signal-terminated: exit(1) would break the transparent exit-propagation
225
+ // contract (a direct shell invocation would show 128+signal, e.g. 143 for
226
+ // SIGTERM). Map to the standard 128+signal-number convention.
227
+ const signalNumber = osConstants.signals[result.signal];
228
+ if (signalNumber !== undefined) {
229
+ process.exit(128 + signalNumber);
230
+ }
231
+ }
232
+ // Truly impossible per Node's spawnSync contract (status and signal are
233
+ // mutually exclusive and one is always set on a completed run) — fallback only.
234
+ process.exit(1);
235
+ }
172
236
  /** Build a structured unknown-path error. Names valid children of the deepest
173
237
  * matched node and names the entry command per the spec. The entry command is
174
238
  * the full path to the matched node (not just its local name), so the recovery
@@ -367,6 +431,14 @@ export async function runCli(root, argv) {
367
431
  }
368
432
  const { node, path, remaining } = walk(root, tokens);
369
433
  try {
434
+ // Passthrough branches (e.g. `crtr capture`) opt out of the tree model
435
+ // entirely: forward every token after the branch verbatim to an external
436
+ // binary before any of the normal unknown-path / help / dispatch logic
437
+ // runs, so `crtr capture -h`, `crtr capture <args>`, and bare `crtr capture`
438
+ // all reach the external CLI untouched.
439
+ if (node.kind === 'branch' && node.passthrough !== undefined) {
440
+ runPassthrough(node.passthrough, rawArgsAfterPath(rawTokens, path));
441
+ }
370
442
  // A root/branch node's `remaining` is only non-empty because walk hit
371
443
  // -h/--help or an unmatched child name (a leaf always consumes the rest
372
444
  // of the path itself). So a non-help leading token here is always the
@@ -38,6 +38,7 @@ export function readState(scope) {
38
38
  plugins: existing.plugins ?? {},
39
39
  last_self_check: existing.last_self_check,
40
40
  bootstrap_done: existing.bootstrap_done,
41
+ activeCanvas: existing.activeCanvas ?? null,
41
42
  };
42
43
  }
43
44
  export function writeConfig(scope, config) {
@@ -245,7 +246,7 @@ function mergeKinds(raw, base = defaultKindsConfig()) {
245
246
  }
246
247
  /** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
247
248
  * null) rather than throwing — same rule `normalizeKindEntry` follows. A
248
- * valid entry needs a non-empty `previewEndpoint` and `relayTokenEnv`; the
249
+ * valid entry needs a non-empty `previewEndpoint` and `relayTokenRef`; the
249
250
  * token itself is never stored here (see `RemoteCanvasTarget`). */
250
251
  function normalizeRemoteCanvasTarget(raw) {
251
252
  if (raw === null || typeof raw !== 'object')
@@ -253,9 +254,9 @@ function normalizeRemoteCanvasTarget(raw) {
253
254
  const r = raw;
254
255
  if (typeof r.previewEndpoint !== 'string' || r.previewEndpoint.trim() === '')
255
256
  return null;
256
- if (typeof r.relayTokenEnv !== 'string' || r.relayTokenEnv.trim() === '')
257
+ if (typeof r.relayTokenRef !== 'string' || r.relayTokenRef.trim() === '')
257
258
  return null;
258
- const out = { previewEndpoint: r.previewEndpoint, relayTokenEnv: r.relayTokenEnv };
259
+ const out = { previewEndpoint: r.previewEndpoint, relayTokenRef: r.relayTokenRef };
259
260
  if (typeof r.cpOrigin === 'string' && r.cpOrigin.trim() !== '')
260
261
  out.cpOrigin = r.cpOrigin;
261
262
  return out;
@@ -27,7 +27,10 @@ export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): bool
27
27
  * is covered). Default is the MRU covering profile.
28
28
  * - Non-interactive: auto-pick the MRU covering profile, no prompt.
29
29
  * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
30
- * create a profile here or use the root profile. Non-interactive (no TTY):
30
+ * create a profile here, select an existing profile (a searchable list of
31
+ * every profile, for when you started somewhere none of them cover and
32
+ * can't recall the exact name — the pick then offers to adopt cwd), or use
33
+ * the root profile. Non-interactive (no TTY):
31
34
  * default to the root profile and print the recovery instruction to STDERR —
32
35
  * never stdout, which the caller may be piping. */
33
36
  export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string>;
@@ -3,14 +3,16 @@
3
3
  // PINNED default (set from the menu with `d`, wins over MRU while it still
4
4
  // covers cwd) > a profile whose project dir IS cwd exactly (auto) > an
5
5
  // interactive menu of profiles covering cwd from a parent/workspace dir +
6
- // create (MRU auto when headless) > synchronous create-or-root decision when
7
- // nothing covers. This is the ONLY place that decision runs; front-door.ts/
6
+ // create (MRU auto when headless) > synchronous create / select-existing /
7
+ // root decision when nothing covers. This is the ONLY place that decision
8
+ // runs; front-door.ts/
8
9
  // spawn.ts call it, never re-derive it.
9
10
  import { existsSync, realpathSync } from 'node:fs';
10
11
  import { homedir } from 'node:os';
11
12
  import { basename, resolve as resolvePath, sep } from 'node:path';
12
13
  import { createInterface } from 'node:readline/promises';
13
- import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, ensureRootProfile, } from './manifest.js';
14
+ import { emitKeypressEvents } from 'node:readline';
15
+ import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, ensureRootProfile, ROOT_PROFILE_ID, } from './manifest.js';
14
16
  import { getDefaultProfileId, setDefaultProfileId, clearDefaultProfile, } from './default-binding.js';
15
17
  import { stdoutColor } from '../output.js';
16
18
  /** Resolve cwd to an absolute, realpath'd form so it compares against the
@@ -120,13 +122,134 @@ function writeHeader(title, subtitle) {
120
122
  const sub = subtitle !== undefined ? ` ${dim(subtitle)}\n` : '';
121
123
  process.stdout.write(`\n ${bold(title)}\n${sub}\n`);
122
124
  }
123
- /** One option row: ` <key> <label> <dim detail> · default`. `label` is
124
- * passed pre-styled; `detail` is dimmed here; the default row gets a marker
125
- * whose text (`default` vs `default (pinned)`) the caller supplies. */
126
- function optionRow(k, label, detail, isDefault, defaultText = 'default') {
127
- const det = detail !== '' ? ' ' + dim(detail) : '';
128
- const def = isDefault ? ' ' + accent('\u00b7 ' + defaultText) : '';
129
- return ` ${key(k)} ${label}${det}${def}\n`;
125
+ /** Compact one-line summary of a profile's whole purview: its first project
126
+ * dir plus a `· N dirs total` tail when it owns more than one. Used where
127
+ * there is no cwd-match to point at (the searchable select list). */
128
+ function purviewSummary(entry) {
129
+ const n = entry.manifest.projects.length;
130
+ const first = entry.manifest.projects[0];
131
+ if (first === undefined)
132
+ return relativeUsed(entry.manifest.last_used_at);
133
+ return n > 1 ? `${tildify(first)} \u00b7 ${n} dirs total` : tildify(first);
134
+ }
135
+ /** ▸ marks the highlighted row. */
136
+ const HILITE = accent('\u25b8');
137
+ const cursorUp = (n) => (n > 0 ? `\u001b[${n}A\r` : '');
138
+ const CLEAR_DOWN = '\u001b[0J';
139
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
140
+ /** Terminal rows a just-written block occupies — i.e. how far the cursor
141
+ * advanced, so a redraw can move exactly that far back up. Counts each '\n'
142
+ * as one row AND adds the extra rows a long line wraps into at the current
143
+ * terminal width (ANSI color codes stripped so they don't count toward width).
144
+ * A logical-line count alone undershoots in a narrow pane, stacking redraws. */
145
+ function blockRows(block) {
146
+ const cols = process.stdout.columns ?? 80;
147
+ const segs = block.split('\n');
148
+ const newlines = segs.length - 1; // block ends with '\n' → last seg is ''
149
+ let extra = 0;
150
+ for (let i = 0; i < newlines; i++) {
151
+ const w = segs[i].replace(ANSI_RE, '').length;
152
+ if (cols > 0 && w > cols)
153
+ extra += Math.ceil(w / cols) - 1;
154
+ }
155
+ return newlines + extra;
156
+ }
157
+ function renderMenu(title, subtitle, rows, sel, hint) {
158
+ let out = `\n ${bold(title)}\n`;
159
+ if (subtitle !== undefined)
160
+ out += ` ${dim(subtitle)}\n`;
161
+ out += '\n';
162
+ let num = 0;
163
+ for (const [i, r] of rows.entries()) {
164
+ const tag = r.hotkey ?? String(++num);
165
+ const mark = i === sel ? HILITE : ' ';
166
+ const det = r.detail !== '' ? ' ' + dim(r.detail) : '';
167
+ const def = r.isDefault ? ' ' + accent('\u00b7 ' + (r.defaultText ?? 'default')) : '';
168
+ out += ` ${mark} ${key(tag)} ${r.label}${det}${def}\n`;
169
+ }
170
+ if (hint !== undefined)
171
+ out += `\n ${dim(hint)}\n`;
172
+ out += `\n ${dim('\u2191\u2193/jk move \u21b5 select esc default ^C quit')}\n`;
173
+ return out;
174
+ }
175
+ /** Drive a highlighted keypress menu to a resolution. Returns the selected row
176
+ * index, plus the action letter when one of `actionKeys` was pressed on the
177
+ * highlighted row. */
178
+ function runMenu(title, subtitle, rows, opts = {}) {
179
+ const numbered = [];
180
+ rows.forEach((r, i) => {
181
+ if (r.hotkey === undefined)
182
+ numbered.push(i);
183
+ });
184
+ let sel = opts.initialIndex ?? 0;
185
+ return new Promise((resolve) => {
186
+ let prevLines = 0;
187
+ const draw = () => {
188
+ const block = renderMenu(title, subtitle, rows, sel, opts.hint);
189
+ process.stdout.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
190
+ prevLines = blockRows(block);
191
+ };
192
+ const cleanup = () => {
193
+ process.stdin.off('keypress', onKey);
194
+ if (process.stdin.isTTY)
195
+ process.stdin.setRawMode(false);
196
+ process.stdin.pause();
197
+ };
198
+ const onKey = (str, k) => {
199
+ if (k.ctrl === true && k.name === 'c') {
200
+ cleanup();
201
+ process.stdout.write('\n');
202
+ process.exit(130);
203
+ }
204
+ if (k.name === 'up' || k.name === 'k' || str === 'k') {
205
+ sel = (sel - 1 + rows.length) % rows.length;
206
+ draw();
207
+ return;
208
+ }
209
+ if (k.name === 'down' || k.name === 'j' || str === 'j') {
210
+ sel = (sel + 1) % rows.length;
211
+ draw();
212
+ return;
213
+ }
214
+ if (k.name === 'return' || k.name === 'enter') {
215
+ cleanup();
216
+ resolve({ index: sel });
217
+ return;
218
+ }
219
+ // Esc cancels navigation and accepts the default row (the MRU/pinned
220
+ // profile, or the root row on the no-coverage menu) — a safe exit, never
221
+ // a dead key. Ctrl-C still aborts crtr entirely.
222
+ if (k.name === 'escape') {
223
+ cleanup();
224
+ resolve({ index: opts.initialIndex ?? 0 });
225
+ return;
226
+ }
227
+ if (str !== undefined && /^[0-9]$/.test(str)) {
228
+ const d = Number.parseInt(str, 10);
229
+ if (d >= 1 && d <= numbered.length) {
230
+ sel = numbered[d - 1];
231
+ draw();
232
+ }
233
+ return;
234
+ }
235
+ const hk = rows.findIndex((r) => r.hotkey !== undefined && r.hotkey === str);
236
+ if (hk >= 0) {
237
+ cleanup();
238
+ resolve({ index: hk });
239
+ return;
240
+ }
241
+ if (str !== undefined && opts.actionKeys?.includes(str) === true) {
242
+ cleanup();
243
+ resolve({ index: sel, action: str });
244
+ }
245
+ };
246
+ emitKeypressEvents(process.stdin);
247
+ if (process.stdin.isTTY)
248
+ process.stdin.setRawMode(true);
249
+ process.stdin.resume();
250
+ process.stdin.on('keypress', onKey);
251
+ draw();
252
+ });
130
253
  }
131
254
  /** The trailing readline prompt: ` › <dim hint> `. */
132
255
  function caret(hint) {
@@ -156,17 +279,131 @@ async function promptAddDirToProfile(entry, cwd) {
156
279
  const rl = createInterface({ input: process.stdin, output: process.stdout });
157
280
  try {
158
281
  writeHeader(`Profile "${entry.manifest.name}" doesn't cover this directory`);
159
- const choice = (await rl.question(caret('add it to the profile? [Y/n]')))
160
- .trim()
161
- .toLowerCase();
162
- if (choice === 'n' || choice === 'no')
163
- return;
164
- addProfileProject(entry.profileId, cwd);
282
+ await offerAddDirToProfile(rl, entry, cwd);
165
283
  }
166
284
  finally {
167
285
  rl.close();
168
286
  }
169
287
  }
288
+ /** Ask (default yes) whether to widen `entry`'s purview to include `cwd`, on an
289
+ * ALREADY-OPEN readline. Shared by the explicit-`--profile` path
290
+ * (`promptAddDirToProfile`) and the select-existing path in
291
+ * `promptCreateOrRoot`, so a profile chosen for a directory it doesn't cover
292
+ * can adopt that directory without a separate `crtr profile project add`. */
293
+ async function offerAddDirToProfile(rl, entry, cwd) {
294
+ const choice = (await rl.question(caret(`add this directory to "${entry.manifest.name}"? [Y/n]`)))
295
+ .trim()
296
+ .toLowerCase();
297
+ if (choice === 'n' || choice === 'no')
298
+ return;
299
+ addProfileProject(entry.profileId, cwd);
300
+ process.stdout.write(` ${accent('\u2713')} ${dim(`added this directory to "${entry.manifest.name}"`)}\n`);
301
+ }
302
+ /** Search-and-pick among EVERY existing profile (root excluded — it is the
303
+ * separate `[r]` option). Reached from the no-coverage prompt so the user can
304
+ * run under a profile they can't fully name when they've started crtr in a
305
+ * directory none of their profiles cover. A live search box: type to filter
306
+ * (matched against name AND project paths — you may recall the directory, not
307
+ * the name), ↑/↓ (or Ctrl-N/P) move the highlight, Enter picks it, Esc backs
308
+ * out (returns null → caller falls back to root). Because typing filters,
309
+ * navigation here is arrows-only — j/k are literal filter characters (fzf
310
+ * convention), unlike the non-search menus where j/k move. */
311
+ async function promptSelectExistingProfile() {
312
+ const all = [...listProfiles()]
313
+ .filter((e) => e.profileId !== ROOT_PROFILE_ID)
314
+ .sort((a, b) => (isMoreRecent(a.manifest.last_used_at, b.manifest.last_used_at) ? -1 : 1));
315
+ if (all.length === 0) {
316
+ process.stdout.write(` ${dim('no existing profiles to select')}\n`);
317
+ return null;
318
+ }
319
+ let filter = '';
320
+ let sel = 0;
321
+ const matchesOf = () => filter === ''
322
+ ? all
323
+ : all.filter((e) => e.manifest.name.toLowerCase().includes(filter) ||
324
+ e.manifest.projects.some((p) => tildify(p).toLowerCase().includes(filter)));
325
+ return new Promise((resolve) => {
326
+ let prevLines = 0;
327
+ const draw = () => {
328
+ const matches = matchesOf();
329
+ if (sel >= matches.length)
330
+ sel = Math.max(0, matches.length - 1);
331
+ let block = `\n ${bold('Select an existing profile')}\n`;
332
+ block += ` ${dim('across every profile, not just this directory')}\n\n`;
333
+ if (matches.length === 0) {
334
+ block += ` ${dim(`no match for "${filter}"`)}\n`;
335
+ }
336
+ else {
337
+ const nameWidth = Math.max(...matches.map((e) => e.manifest.name.length));
338
+ matches.forEach((e, i) => {
339
+ const mark = i === sel ? HILITE : ' ';
340
+ block += ` ${mark} ${bold(e.manifest.name.padEnd(nameWidth))} ${dim(purviewSummary(e))}\n`;
341
+ });
342
+ }
343
+ block += `\n ${accent('\u203a')} ${filter}${dim('\u2588')}\n`;
344
+ block += ` ${dim('type to filter \u2191\u2193 move \u21b5 pick esc back')}\n`;
345
+ process.stdout.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
346
+ prevLines = blockRows(block);
347
+ };
348
+ const cleanup = () => {
349
+ process.stdin.off('keypress', onKey);
350
+ if (process.stdin.isTTY)
351
+ process.stdin.setRawMode(false);
352
+ process.stdin.pause();
353
+ };
354
+ const onKey = (str, k) => {
355
+ if (k.ctrl === true && k.name === 'c') {
356
+ cleanup();
357
+ process.stdout.write('\n');
358
+ process.exit(130);
359
+ }
360
+ if (k.name === 'escape') {
361
+ cleanup();
362
+ resolve(null);
363
+ return;
364
+ }
365
+ const matches = matchesOf();
366
+ if (k.name === 'return' || k.name === 'enter') {
367
+ if (matches.length > 0) {
368
+ cleanup();
369
+ resolve(matches[sel].profileId);
370
+ }
371
+ return;
372
+ }
373
+ if (k.name === 'up' || (k.ctrl === true && k.name === 'p')) {
374
+ if (matches.length > 0) {
375
+ sel = (sel - 1 + matches.length) % matches.length;
376
+ draw();
377
+ }
378
+ return;
379
+ }
380
+ if (k.name === 'down' || (k.ctrl === true && k.name === 'n')) {
381
+ if (matches.length > 0) {
382
+ sel = (sel + 1) % matches.length;
383
+ draw();
384
+ }
385
+ return;
386
+ }
387
+ if (k.name === 'backspace') {
388
+ filter = filter.slice(0, -1);
389
+ sel = 0;
390
+ draw();
391
+ return;
392
+ }
393
+ if (str !== undefined && str.length === 1 && str >= ' ' && k.ctrl !== true) {
394
+ filter += str.toLowerCase();
395
+ sel = 0;
396
+ draw();
397
+ }
398
+ };
399
+ emitKeypressEvents(process.stdin);
400
+ if (process.stdin.isTTY)
401
+ process.stdin.setRawMode(true);
402
+ process.stdin.resume();
403
+ process.stdin.on('keypress', onKey);
404
+ draw();
405
+ });
406
+ }
170
407
  /** Ask for a name (defaulting to cwd's basename) and create a profile pointing
171
408
  * at cwd on the SAME open readline — shared by both interactive create paths
172
409
  * so the name prompt reads identically whether reached via the covering menu
@@ -180,19 +417,47 @@ async function createProfileHere(rl, cwd) {
180
417
  return profileId;
181
418
  }
182
419
  async function promptCreateOrRoot(cwd) {
183
- const rl = createInterface({ input: process.stdin, output: process.stdout });
184
- try {
185
- writeHeader('No profile covers this directory');
186
- process.stdout.write(optionRow('c', 'create a profile here', '', false));
187
- process.stdout.write(optionRow('r', dim('use the root profile'), '', true));
188
- process.stdout.write('\n');
189
- const choice = (await rl.question(caret('[c/r]'))).trim().toLowerCase();
190
- if (choice !== 'c' && choice !== 'create')
191
- return ensureRootProfile().profileId;
192
- return await createProfileHere(rl, cwd);
193
- }
194
- finally {
195
- rl.close();
420
+ // Root is the highlighted default so a bare Enter (or Esc) still lands on the
421
+ // safe no-op, exactly as the old `[c/s/r]` prompt defaulted. Looped so Esc
422
+ // out of the search sub-list returns HERE rather than silently dropping to
423
+ // root — the create/select/root choice is always the fallback, never skipped.
424
+ const rows = [
425
+ { label: 'create a profile here', detail: '', hotkey: 'c' },
426
+ { label: 'select an existing profile', detail: '', hotkey: 's' },
427
+ { label: dim('use the root profile'), detail: '', hotkey: 'r', isDefault: true },
428
+ ];
429
+ for (;;) {
430
+ const res = await runMenu('No profile covers this directory', undefined, rows, {
431
+ initialIndex: 2,
432
+ });
433
+ if (res.index === 0) {
434
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
435
+ try {
436
+ return await createProfileHere(rl, cwd);
437
+ }
438
+ finally {
439
+ rl.close();
440
+ }
441
+ }
442
+ if (res.index === 1) {
443
+ const selected = await promptSelectExistingProfile();
444
+ // Esc/back out of the search list (or no profiles to pick) → re-show this
445
+ // create/select/root menu, not a silent fall-through to root.
446
+ if (selected === null)
447
+ continue;
448
+ const entry = loadProfileManifest(selected);
449
+ // The picked profile does not cover cwd (nothing does here) — offer to
450
+ // widen its purview so the next node started here is covered too.
451
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
452
+ try {
453
+ await offerAddDirToProfile(rl, entry, cwd);
454
+ }
455
+ finally {
456
+ rl.close();
457
+ }
458
+ return entry.profileId;
459
+ }
460
+ return ensureRootProfile().profileId;
196
461
  }
197
462
  }
198
463
  /** Choose among a set of candidate profiles for `cwd`, or create a new one.
@@ -215,63 +480,70 @@ async function promptPickProfileOrCreate(candidates, cwd, exact, pinnedId) {
215
480
  const ordered = pinned !== null ? [pinned, ...mru.filter((e) => e !== pinned)] : mru;
216
481
  // Pad names to a shared column so the dim detail lines up down the menu.
217
482
  const nameWidth = Math.max(...ordered.map((e) => e.manifest.name.length));
218
- const rl = createInterface({ input: process.stdin, output: process.stdout });
219
- try {
220
- writeHeader(exact ? 'Choose a profile' : 'Select a profile', exact
221
- ? 'Several profiles claim this directory as their project'
222
- : 'This directory falls under existing profile(s)');
223
- ordered.forEach((entry, i) => {
224
- // For exact ties the covered path is identical, so surface last-used to
225
- // tell them apart; for parent/workspace coverage, WHICH dir covers cwd
226
- // is the useful differentiator.
227
- const detail = exact
228
- ? relativeUsed(entry.manifest.last_used_at)
229
- : `covers ${tildify(entry.manifest.projects.find((p) => projectCovers(cwd, p)) ?? entry.manifest.projects[0])}`;
230
- const label = bold(entry.manifest.name.padEnd(nameWidth));
231
- const defaultText = pinned !== null && ordered[0] === pinned ? 'default (pinned)' : 'default';
232
- process.stdout.write(optionRow(String(i + 1), label, detail, i === 0, defaultText));
233
- });
234
- process.stdout.write(optionRow('c', dim('create a new profile here'), '', false));
235
- process.stdout.write('\n');
236
- // Action suffixes on a pick: `<n>d` also pins #n as this directory's
237
- // default; `<n>a` also widens #n's purview to include cwd (meaningless when
238
- // the candidates already claim cwd exactly, so hidden there). `u` clears an
239
- // existing pin. A bare letter applies to the default row (choice 1).
240
- const parts = ['d=set default'];
241
- if (!exact)
242
- parts.push('a=add dir here');
243
- if (pinned !== null)
244
- parts.push('u=unpin');
245
- process.stdout.write(` ${dim(parts.join(' '))}\n\n`);
246
- const answer = (await rl.question(caret('[1]'))).trim().toLowerCase();
247
- if (answer === 'c' || answer === 'create')
483
+ const rows = ordered.map((entry, i) => {
484
+ const n = entry.manifest.projects.length;
485
+ // For exact ties the matched path is cwd itself, so surface last-used to
486
+ // tell them apart; for parent/workspace coverage, WHICH of the profile's
487
+ // dirs matches cwd is the differentiator. Either way append the total dir
488
+ // count so "covers X" is never mistaken for the profile's full purview.
489
+ const detail = exact
490
+ ? `${relativeUsed(entry.manifest.last_used_at)}${n > 1 ? ` \u00b7 ${n} dirs total` : ''}`
491
+ : (() => {
492
+ const md = entry.manifest.projects.find((p) => projectCovers(cwd, p)) ??
493
+ entry.manifest.projects[0];
494
+ return n > 1 ? `matches ${tildify(md)} \u00b7 ${n} dirs total` : `matches ${tildify(md)}`;
495
+ })();
496
+ const defaultText = pinned !== null && ordered[0] === pinned ? 'default (pinned)' : 'default';
497
+ return {
498
+ label: bold(entry.manifest.name.padEnd(nameWidth)),
499
+ detail,
500
+ isDefault: i === 0,
501
+ defaultText,
502
+ };
503
+ });
504
+ rows.push({ label: dim('create a new profile here'), detail: '', hotkey: 'c' });
505
+ // Action letters resolve on the highlighted row: `d` pins it as this
506
+ // directory's default; `a` widens its purview to include cwd (meaningless
507
+ // when the candidates already claim cwd exactly, so hidden there); `u` clears
508
+ // an existing pin.
509
+ const actionKeys = ['d'];
510
+ if (!exact)
511
+ actionKeys.push('a');
512
+ if (pinned !== null)
513
+ actionKeys.push('u');
514
+ const hintParts = ['d=set default'];
515
+ if (!exact)
516
+ hintParts.push('a=add dir here');
517
+ if (pinned !== null)
518
+ hintParts.push('u=unpin');
519
+ const res = await runMenu(exact ? 'Choose a profile' : 'Select a profile', exact
520
+ ? 'Several profiles claim this directory as their project'
521
+ : 'This directory falls under existing profile(s)', rows, { actionKeys, hint: hintParts.join(' ') });
522
+ // The create row is last; selecting it (Enter on it, or `c`) creates here.
523
+ if (res.index >= ordered.length) {
524
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
525
+ try {
248
526
  return await createProfileHere(rl, cwd);
249
- if (answer === 'u' || answer === 'unpin') {
250
- clearDefaultProfile(cwd);
251
- process.stdout.write(` ${accent('\u2713')} ${dim('cleared the default for this directory')}\n`);
252
- return ordered[0].profileId;
253
- }
254
- const m = /^(\d+)?([da])?$/.exec(answer);
255
- // Unrecognized input → the safe default (choice 1); never loop, since this
256
- // prompt is gating pi's boot on the shared TTY.
257
- if (m === null)
258
- return ordered[0].profileId;
259
- const [, numStr, action] = m;
260
- const idx = numStr !== undefined ? Number.parseInt(numStr, 10) - 1 : 0;
261
- const picked = idx >= 0 && idx < ordered.length ? ordered[idx] : ordered[0];
262
- if (action === 'd') {
263
- setDefaultProfileId(cwd, picked.profileId);
264
- process.stdout.write(` ${accent('\u2713')} ${dim(`"${picked.manifest.name}" is now the default here`)}\n`);
265
527
  }
266
- else if (action === 'a') {
267
- addProfileProject(picked.profileId, cwd);
268
- process.stdout.write(` ${accent('\u2713')} ${dim(`added this directory to "${picked.manifest.name}"`)}\n`);
528
+ finally {
529
+ rl.close();
269
530
  }
531
+ }
532
+ const picked = ordered[res.index];
533
+ if (res.action === 'u') {
534
+ clearDefaultProfile(cwd);
535
+ process.stdout.write(` ${accent('\u2713')} ${dim('cleared the default for this directory')}\n`);
270
536
  return picked.profileId;
271
537
  }
272
- finally {
273
- rl.close();
538
+ if (res.action === 'd') {
539
+ setDefaultProfileId(cwd, picked.profileId);
540
+ process.stdout.write(` ${accent('\u2713')} ${dim(`"${picked.manifest.name}" is now the default here`)}\n`);
541
+ }
542
+ else if (res.action === 'a') {
543
+ addProfileProject(picked.profileId, cwd);
544
+ process.stdout.write(` ${accent('\u2713')} ${dim(`added this directory to "${picked.manifest.name}"`)}\n`);
274
545
  }
546
+ return picked.profileId;
275
547
  }
276
548
  /** Select the profile a node about to boot at `cwd` should run under.
277
549
  *
@@ -296,7 +568,10 @@ async function promptPickProfileOrCreate(candidates, cwd, exact, pinnedId) {
296
568
  * is covered). Default is the MRU covering profile.
297
569
  * - Non-interactive: auto-pick the MRU covering profile, no prompt.
298
570
  * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
299
- * create a profile here or use the root profile. Non-interactive (no TTY):
571
+ * create a profile here, select an existing profile (a searchable list of
572
+ * every profile, for when you started somewhere none of them cover and
573
+ * can't recall the exact name — the pick then offers to adopt cwd), or use
574
+ * the root profile. Non-interactive (no TTY):
300
575
  * default to the root profile and print the recovery instruction to STDERR —
301
576
  * never stdout, which the caller may be piping. */
302
577
  export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = false) {
@@ -0,0 +1 @@
1
+ export {};