promptdock 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/context.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
3
  import { createInterface } from "node:readline/promises";
4
+ import { emitKeypressEvents } from "node:readline";
4
5
  import { readFileSync } from "node:fs";
5
6
  /**
6
7
  * argv joined for the `X-Promptdock-Cli-Args` header, with bearer tokens removed.
@@ -13,6 +14,12 @@ import { readFileSync } from "node:fs";
13
14
  * still needs to show that `--token` was passed.
14
15
  */
15
16
  export function redactArgs(argv) {
17
+ // ⚠️ COUPLING: this is a DENYLIST keyed on the flags that carry sensitive
18
+ // values. Any NEW value-carrying flag added to args.ts COMMAND_SPECS must be
19
+ // assessed here in the same change — a missed one leaks silently (the header
20
+ // rides every request and every proxy logs it). Current set: --token (the
21
+ // live bearer) and --dir (absolute local paths carry the OS username +
22
+ // client/project names — data the 426 remedy does not need; review find).
16
23
  return argv
17
24
  .map((a, i) => {
18
25
  if (/^pdk_/.test(a))
@@ -22,10 +29,55 @@ export function redactArgs(argv) {
22
29
  return "--token=***";
23
30
  if (argv[i - 1] === "--token")
24
31
  return "***";
32
+ if (/^--dir=/.test(a))
33
+ return "--dir=***";
34
+ if (argv[i - 1] === "--dir")
35
+ return "***";
25
36
  return a;
26
37
  })
27
38
  .join(" ");
28
39
  }
40
+ /**
41
+ * Process-level terminal safety net. The picker's `finally` restores raw mode
42
+ * + cursor on every JS path (resolve/reject/throw, and ^C arrives as a
43
+ * keypress under raw mode) — but an EXTERNAL SIGTERM/SIGHUP or `kill` runs no
44
+ * finally: the process dies with the cursor hidden (`?25l` persists until
45
+ * `reset`; 4 review voices converged on this). One-shot handlers restore the
46
+ * terminal and re-exit with the conventional 128+signal code.
47
+ */
48
+ let activeRawRelease = null;
49
+ let terminalSafetyInstalled = false;
50
+ function installTerminalSafety() {
51
+ if (terminalSafetyInstalled)
52
+ return;
53
+ terminalSafetyInstalled = true;
54
+ const restore = () => {
55
+ try {
56
+ activeRawRelease?.();
57
+ }
58
+ catch {
59
+ /* stdin already torn down */
60
+ }
61
+ try {
62
+ process.stdout.write("[?25h");
63
+ }
64
+ catch {
65
+ /* stdout already torn down */
66
+ }
67
+ };
68
+ // 'exit' covers process.exit() and normal termination; fatal signals with
69
+ // default handlers do NOT fire it, hence the explicit signal hooks.
70
+ process.on("exit", restore);
71
+ for (const [sig, code] of [
72
+ ["SIGTERM", 143],
73
+ ["SIGHUP", 129],
74
+ ]) {
75
+ process.on(sig, () => {
76
+ restore();
77
+ process.exit(code);
78
+ });
79
+ }
80
+ }
29
81
  /** Read the package's own version (dist/ and src/ both sit one level under the root). */
30
82
  export function readOwnVersion() {
31
83
  try {
@@ -70,6 +122,54 @@ export function realContext(argv) {
70
122
  rl.close();
71
123
  }
72
124
  },
125
+ // Only offered when raw mode is genuinely available AND the terminal can
126
+ // render ANSI. `setRawMode` is absent whenever stdin is not a TTY (a
127
+ // pipe, a spawned child, CI); TERM=dumb (Emacs M-x shell, some IDE
128
+ // consoles) IS a pty but cannot render the escapes the picker paints —
129
+ // it gets the numbered reader, as the README promises (review find).
130
+ ...(isTTY &&
131
+ typeof process.stdin.setRawMode === "function" &&
132
+ process.env.TERM !== "dumb"
133
+ ? {
134
+ rawKeys: (onKey) => {
135
+ installTerminalSafety();
136
+ const stdin = process.stdin;
137
+ emitKeypressEvents(stdin);
138
+ const wasRaw = stdin.isRaw === true;
139
+ stdin.setRawMode(true);
140
+ stdin.resume();
141
+ const handler = (_s, key) => {
142
+ if (key)
143
+ onKey(key);
144
+ };
145
+ stdin.on("keypress", handler);
146
+ let released = false;
147
+ // Idempotent: the picker releases in a `finally`, and a second call
148
+ // during process teardown must not throw on an already-closed stdin.
149
+ const release = () => {
150
+ if (released)
151
+ return;
152
+ released = true;
153
+ if (activeRawRelease === release)
154
+ activeRawRelease = null;
155
+ stdin.off("keypress", handler);
156
+ try {
157
+ stdin.setRawMode(wasRaw);
158
+ }
159
+ catch {
160
+ /* stdin already torn down */
161
+ }
162
+ stdin.pause();
163
+ };
164
+ activeRawRelease = release;
165
+ return release;
166
+ },
167
+ size: () => ({
168
+ rows: process.stdout.rows ?? 24,
169
+ columns: process.stdout.columns ?? 80,
170
+ }),
171
+ }
172
+ : {}),
73
173
  },
74
174
  env: process.env,
75
175
  cwd: process.cwd(),
@@ -31,6 +31,13 @@ export type ResolveResponse = {
31
31
  is_free?: boolean;
32
32
  file_count?: number;
33
33
  total_bytes?: number;
34
+ /**
35
+ * The oldest CLI generation whose install-safety fuses admit this package, or
36
+ * null/absent when every published CLI can install it. Lets an old client refuse
37
+ * ONE oversized package with an upgrade prompt instead of the server bumping a
38
+ * global version floor that would 426 every request from every old client.
39
+ */
40
+ min_cli_version?: string | null;
34
41
  description?: string;
35
42
  license?: string;
36
43
  /** insufficient_tier */
package/dist/errors.d.ts CHANGED
@@ -12,6 +12,11 @@ export declare const EXIT: {
12
12
  readonly IO: 5;
13
13
  /** couldn't reach the API */
14
14
  readonly NETWORK: 6;
15
+ /** Ctrl-C during an interactive picker — 128+SIGINT, the shell convention.
16
+ * Raw mode swallows the real signal (it arrives as a keypress), so the CLI
17
+ * exits with the code a signal death would have produced. Esc (a deliberate
18
+ * in-UI cancel) stays USAGE=1. Scripts never reach pickers (TTY-only). */
19
+ readonly INTERRUPT: 130;
15
20
  };
16
21
  export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
17
22
  /** DX3: every named error footer links its docs anchor. */
package/dist/errors.js CHANGED
@@ -13,6 +13,11 @@ export const EXIT = {
13
13
  IO: 5,
14
14
  /** couldn't reach the API */
15
15
  NETWORK: 6,
16
+ /** Ctrl-C during an interactive picker — 128+SIGINT, the shell convention.
17
+ * Raw mode swallows the real signal (it arrives as a keypress), so the CLI
18
+ * exits with the code a signal death would have produced. Esc (a deliberate
19
+ * in-UI cancel) stays USAGE=1. Scripts never reach pickers (TTY-only). */
20
+ INTERRUPT: 130,
16
21
  };
17
22
  /** DX3: every named error footer links its docs anchor. */
18
23
  export function footerUrl(code) {
@@ -1,9 +1,19 @@
1
1
  export declare const MAX_SKILL_FILES = 25;
2
2
  export declare const MAX_SKILL_TOTAL_BYTES = 5242880;
3
3
  export declare const MAX_SKILL_FILE_BYTES = 1048576;
4
+ export declare const CLI_DOS_MAX_SKILL_FILES = 200;
5
+ export declare const CLI_DOS_MAX_SKILL_TOTAL_BYTES = 52428800;
6
+ export declare const CLI_DOS_MAX_SKILL_FILE_BYTES = 5242880;
7
+ export declare const CLI_FUSE_HISTORY: {
8
+ cli_version: string;
9
+ max_files: number;
10
+ max_file_bytes: number;
11
+ max_total_bytes: number;
12
+ }[];
4
13
  export declare const MAX_SKILL_IMAGES = 5;
5
14
  export declare const SKILL_REVEAL_DAILY_CAP = 15;
6
- export declare const CLI_MIN_VERSION = "0.1.0";
15
+ export declare const CLI_MIN_VERSION = "1.0.1";
16
+ export declare const PUBLISHED_CLI_VERSION = "1.1.0";
7
17
  export declare const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
8
18
  export declare const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
9
19
  export declare const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
@@ -3,9 +3,14 @@
3
3
  export const MAX_SKILL_FILES = 25;
4
4
  export const MAX_SKILL_TOTAL_BYTES = 5242880;
5
5
  export const MAX_SKILL_FILE_BYTES = 1048576;
6
+ export const CLI_DOS_MAX_SKILL_FILES = 200;
7
+ export const CLI_DOS_MAX_SKILL_TOTAL_BYTES = 52428800;
8
+ export const CLI_DOS_MAX_SKILL_FILE_BYTES = 5242880;
9
+ export const CLI_FUSE_HISTORY = [{ "cli_version": "0.0.0", "max_files": 25, "max_file_bytes": 1048576, "max_total_bytes": 5242880 }, { "cli_version": "1.2.0", "max_files": 200, "max_file_bytes": 5242880, "max_total_bytes": 52428800 }];
6
10
  export const MAX_SKILL_IMAGES = 5;
7
11
  export const SKILL_REVEAL_DAILY_CAP = 15;
8
- export const CLI_MIN_VERSION = "0.1.0";
12
+ export const CLI_MIN_VERSION = "1.0.1";
13
+ export const PUBLISHED_CLI_VERSION = "1.1.0";
9
14
  export const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
10
15
  export const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
11
16
  export const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
package/dist/help.js CHANGED
@@ -21,8 +21,8 @@ export function globalHelp(version) {
21
21
  help this map
22
22
 
23
23
  Install options
24
- -g, --global install into the tool's per-user dir instead of the project
25
- --target <tool> skip the picker (${TARGETS.map((t) => t.id).join(", ")})
24
+ -g, --global install into the tool's per-user dir (skips the scope prompt)
25
+ --target <tool> pick the tool (${TARGETS.map((t) => t.id).join(", ")}); scope is still asked — add -g or -y to skip
26
26
  --dir <path> install into an explicit directory
27
27
  -y, --yes skip prompts (needs an unambiguous target; NEVER implies --force)
28
28
  --force replace a non-empty foreign directory / overwrite local edits
@@ -62,11 +62,15 @@ session stays valid until revoked in Settings → Account → CLI sessions.`,
62
62
  Shows the signed-in handle, role, and token expiry.`,
63
63
  install: `promptdock install <handle>/<slug> [-g] [--target <tool>] [--dir <path>] [-y] [--force] [--dry-run] [--json]
64
64
 
65
- Installs a skill. Non-interactive sessions (CI) must pass --target or --dir
66
- plus -y, and authenticate via PROMPTDOCK_TOKEN. -y skips the target picker
67
- ONLY a non-empty foreign directory still requires --force. --dry-run
68
- resolves and prints the plan without installing (and without spending any
69
- premium-unlock slot).`,
65
+ Installs a skill. Interactively it asks two things: which TOOL, then whether
66
+ to install for THIS PROJECT or for your USER ACCOUNT (each shown as its real
67
+ destination path). -g answers the second one up front; --dir answers both.
68
+
69
+ Non-interactive sessions (CI) must pass --target or --dir plus -y, and
70
+ authenticate via PROMPTDOCK_TOKEN. -y skips the prompts ONLY — a non-empty
71
+ foreign directory still requires --force, and scope stays project-local
72
+ unless -g is passed. --dry-run resolves and prints the plan without
73
+ installing (and without spending any premium-unlock slot).`,
70
74
  uninstall: `promptdock uninstall <handle>/<slug> | --all [-g] [--target <tool>] [--dir <path>] [--force] [-y] [--json]
71
75
 
72
76
  Removes an installed skill using its .promptdock.json receipt — only files the
@@ -5,8 +5,18 @@ import { type Receipt } from "./receipts.js";
5
5
  /** Narrow the untrusted install response; throws INTEGRITY on a bad shape. */
6
6
  export declare function checkInstallResponse(resp: unknown): InstallResponse;
7
7
  /**
8
- * E8 client-side re-validation + the shared D7 bounds. A server compromise must
9
- * not become an arbitrary file write OR a disk-filling download.
8
+ * E8 client-side re-validation against this CLI's own INSTALL-SAFETY FUSES.
9
+ *
10
+ * ⚠️ These bounds are SELF-PROTECTION, not a mirror of the platform's policy: "a
11
+ * server compromise must not become an arbitrary file write OR a disk-filling
12
+ * download." They are sized from memory/disk/runtime budgets and are deliberately far
13
+ * above any policy the platform would set, so a legitimate policy raise never needs a
14
+ * CLI release. Reaching one of them means the server sent something this CLI considers
15
+ * impossible — hence EXIT.INTEGRITY.
16
+ *
17
+ * The FRIENDLY, package-scoped refusal ("this skill needs a newer CLI") lives in
18
+ * verdicts.ts::assertInstallable and fires BEFORE the metered install POST. This
19
+ * function is the untrusted-server backstop behind it and must never be removed.
10
20
  */
11
21
  export declare function assertSafeManifest(manifest: ManifestEntry[]): void;
12
22
  export type StagedInstall = {
package/dist/installer.js CHANGED
@@ -9,7 +9,7 @@ import { randomBytes } from "node:crypto";
9
9
  import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { CliError, EXIT, mapFsError, networkError } from "./errors.js";
12
- import { MAX_SKILL_FILES, MAX_SKILL_FILE_BYTES, MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
12
+ import { CLI_DOS_MAX_SKILL_FILES as MAX_SKILL_FILES, CLI_DOS_MAX_SKILL_FILE_BYTES as MAX_SKILL_FILE_BYTES, CLI_DOS_MAX_SKILL_TOTAL_BYTES as MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
13
13
  import { sha256Hex, sha256Matches } from "./integrity.js";
14
14
  import { validateSkillPaths } from "./paths.js";
15
15
  import { writeReceipt, RECEIPT_SCHEMA } from "./receipts.js";
@@ -37,8 +37,62 @@ export function checkInstallResponse(resp) {
37
37
  return r;
38
38
  }
39
39
  /**
40
- * E8 client-side re-validation + the shared D7 bounds. A server compromise must
41
- * not become an arbitrary file write OR a disk-filling download.
40
+ * Read a response body, aborting the moment it exceeds `limit`.
41
+ *
42
+ * `res.arrayBuffer()` has no ceiling: it reads whatever the server sends. Streaming and
43
+ * cancelling means a hostile or broken server cannot make this process hold more than
44
+ * one file's worth of bytes, which is the property the install-safety fuse is claiming.
45
+ *
46
+ * Falls back to `arrayBuffer()` only when the runtime gives us no readable stream (older
47
+ * Node fetch shims) — with the post-hoc size check still applied, so the bound holds
48
+ * either way.
49
+ */
50
+ async function readBounded(res, limit, path) {
51
+ const tooBig = () => new CliError(`${INTEGRITY_ABORT} ("${path}" oversized)`, EXIT.INTEGRITY, { footer: "integrity" });
52
+ const body = res.body;
53
+ if (!body || typeof body.getReader !== "function") {
54
+ const buf = Buffer.from(await res.arrayBuffer());
55
+ if (buf.byteLength > limit)
56
+ throw tooBig();
57
+ return buf;
58
+ }
59
+ const reader = body.getReader();
60
+ const chunks = [];
61
+ let total = 0;
62
+ try {
63
+ for (;;) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ if (!value)
68
+ continue;
69
+ total += value.byteLength;
70
+ if (total > limit) {
71
+ // Stop pulling bytes immediately — this is the whole point.
72
+ await reader.cancel().catch(() => { });
73
+ throw tooBig();
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ }
78
+ finally {
79
+ reader.releaseLock?.();
80
+ }
81
+ return Buffer.concat(chunks.map((c) => Buffer.from(c)), total);
82
+ }
83
+ /**
84
+ * E8 client-side re-validation against this CLI's own INSTALL-SAFETY FUSES.
85
+ *
86
+ * ⚠️ These bounds are SELF-PROTECTION, not a mirror of the platform's policy: "a
87
+ * server compromise must not become an arbitrary file write OR a disk-filling
88
+ * download." They are sized from memory/disk/runtime budgets and are deliberately far
89
+ * above any policy the platform would set, so a legitimate policy raise never needs a
90
+ * CLI release. Reaching one of them means the server sent something this CLI considers
91
+ * impossible — hence EXIT.INTEGRITY.
92
+ *
93
+ * The FRIENDLY, package-scoped refusal ("this skill needs a newer CLI") lives in
94
+ * verdicts.ts::assertInstallable and fires BEFORE the metered install POST. This
95
+ * function is the untrusted-server backstop behind it and must never be removed.
42
96
  */
43
97
  export function assertSafeManifest(manifest) {
44
98
  const issues = validateSkillPaths(manifest.map((e) => e.path));
@@ -79,6 +133,18 @@ export async function downloadAndStage(ctx, api, resp, targetDir) {
79
133
  }
80
134
  const tempDir = join(parent, `.promptdock-staging-${randomBytes(6).toString("hex")}`);
81
135
  const files = [];
136
+ /**
137
+ * Bytes actually RECEIVED, not bytes the server said it would send.
138
+ *
139
+ * ⚠️ `assertSafeManifest` enforces the aggregate fuse against `entry.bytes` — a field
140
+ * the untrusted server supplies and that the response check does not even require. So
141
+ * before this counter the only bound the wire really carried was PER FILE, and a
142
+ * hostile server could declare 1 KB per entry and stream the per-file maximum for every
143
+ * one of them: at the raised fuses that is 200 × 5 MB ≈ 1 GB written to disk, which is
144
+ * precisely the "disk-filling download" this module's own docstring says it prevents.
145
+ * The manifest check is a fast pre-flight; THIS is the enforcement.
146
+ */
147
+ let received = 0;
82
148
  try {
83
149
  mkdirSync(tempDir, { recursive: true });
84
150
  for (const entry of resp.manifest) {
@@ -99,11 +165,25 @@ export async function downloadAndStage(ctx, api, resp, targetDir) {
99
165
  // A lapsed signed URL is a free re-request (audit row 13) — say so.
100
166
  throw new CliError(`download failed for "${entry.path}" (HTTP ${res.status}) — re-run the install (the download link may have expired; retrying is free)`, EXIT.NETWORK, { footer: "download" });
101
167
  }
102
- const buf = Buffer.from(await res.arrayBuffer());
103
- if (buf.byteLength > MAX_SKILL_FILE_BYTES) {
104
- throw new CliError(`${INTEGRITY_ABORT} ("${entry.path}" oversized)`, EXIT.INTEGRITY, {
105
- footer: "integrity",
106
- });
168
+ // ⚠️ CHECK BEFORE BUFFERING, then check again after.
169
+ //
170
+ // The fuse exists so a compromised server cannot fill your disk, and
171
+ // `await res.arrayBuffer()` reads the ENTIRE body into memory first — so a check
172
+ // that only runs afterwards has already let the download happen. A hostile server
173
+ // could stream gigabytes and the guard would fire, too late, on a machine that had
174
+ // already paid for it.
175
+ //
176
+ // So: refuse on a declared `content-length` over the fuse without reading a byte,
177
+ // and refuse again on the real length (content-length is server-supplied and may
178
+ // be absent or lie, which is exactly why the second check stays).
179
+ const declared = Number(res.headers.get("content-length"));
180
+ if (Number.isFinite(declared) && declared > MAX_SKILL_FILE_BYTES) {
181
+ throw new CliError(`${INTEGRITY_ABORT} ("${entry.path}" declares ${declared} bytes, over the limit)`, EXIT.INTEGRITY, { footer: "integrity" });
182
+ }
183
+ const buf = await readBounded(res, MAX_SKILL_FILE_BYTES, entry.path);
184
+ received += buf.byteLength;
185
+ if (received > MAX_SKILL_TOTAL_BYTES) {
186
+ throw new CliError(`${INTEGRITY_ABORT} (the package sent more than the ${MAX_SKILL_TOTAL_BYTES}-byte install limit)`, EXIT.INTEGRITY, { footer: "integrity" });
107
187
  }
108
188
  if (!sha256Matches(buf, entry.sha256)) {
109
189
  throw new CliError(`${INTEGRITY_ABORT} (sha256 mismatch on "${entry.path}")`, EXIT.INTEGRITY, {
@@ -26,6 +26,62 @@ export declare function targetInstallDir(t: TargetDef, opts: {
26
26
  global: boolean;
27
27
  slug: string;
28
28
  }): string;
29
+ /** Is this tool set up in each scope? Path knowledge stays in this module. */
30
+ export declare function detectScopes(t: TargetDef, opts: {
31
+ cwd: string;
32
+ home: string;
33
+ exists?: (p: string) => boolean;
34
+ }): {
35
+ local: boolean;
36
+ global: boolean;
37
+ };
38
+ export type ScopedTarget = TargetDef & {
39
+ detectedLocal: boolean;
40
+ detectedGlobal: boolean;
41
+ detected: boolean;
42
+ };
43
+ /**
44
+ * Scope-agnostic detection for the interactive picker, which asks for the TOOL
45
+ * first and the SCOPE second — so at list time there is no single scope to
46
+ * probe. `detected` (either scope) drives the D-UX11 float ordering; the
47
+ * per-scope flags let the row say WHERE it was found, which the old
48
+ * one-scope-only tag could not.
49
+ */
50
+ export declare function detectTargetsAnyScope(opts: {
51
+ cwd: string;
52
+ home: string;
53
+ exists?: (p: string) => boolean;
54
+ }): ScopedTarget[];
55
+ /** Where a tool was found, for the picker row ("" when nowhere). */
56
+ export declare function detectionLabel(t: {
57
+ detectedLocal: boolean;
58
+ detectedGlobal: boolean;
59
+ }): string;
60
+ export type ScopeChoice = {
61
+ global: boolean;
62
+ label: string;
63
+ dir: string;
64
+ detected: boolean;
65
+ };
66
+ /**
67
+ * The two concrete destinations for a chosen tool, in prompt order (project,
68
+ * then global), plus the preselect index. Pure so the prompt's content is
69
+ * unit-testable without a TTY.
70
+ *
71
+ * Preselect prefers the scope where the tool is ALREADY set up. When it is set
72
+ * up in both — or in neither — it stays project-local, which is exactly what
73
+ * `-y` and non-TTY runs default to, so the interactive and non-interactive
74
+ * paths can never disagree about the same repo.
75
+ */
76
+ export declare function scopeChoices(t: TargetDef, opts: {
77
+ cwd: string;
78
+ home: string;
79
+ slug: string;
80
+ exists?: (p: string) => boolean;
81
+ }): {
82
+ choices: [ScopeChoice, ScopeChoice];
83
+ preselect: 0 | 1;
84
+ };
29
85
  export type DetectedTarget = TargetDef & {
30
86
  detected: boolean;
31
87
  };
package/dist/registry.js CHANGED
@@ -80,6 +80,71 @@ export function targetBaseDir(t, opts) {
80
80
  export function targetInstallDir(t, opts) {
81
81
  return join(targetBaseDir(t, opts), opts.slug);
82
82
  }
83
+ /** Is this tool set up in each scope? Path knowledge stays in this module. */
84
+ export function detectScopes(t, opts) {
85
+ const exists = opts.exists ?? existsSync;
86
+ return {
87
+ local: exists(join(opts.cwd, t.detectLocal)),
88
+ global: exists(join(opts.home, t.detectGlobal)),
89
+ };
90
+ }
91
+ /**
92
+ * Scope-agnostic detection for the interactive picker, which asks for the TOOL
93
+ * first and the SCOPE second — so at list time there is no single scope to
94
+ * probe. `detected` (either scope) drives the D-UX11 float ordering; the
95
+ * per-scope flags let the row say WHERE it was found, which the old
96
+ * one-scope-only tag could not.
97
+ */
98
+ export function detectTargetsAnyScope(opts) {
99
+ const all = TARGETS.map((t) => {
100
+ const found = detectScopes(t, opts);
101
+ return {
102
+ ...t,
103
+ detectedLocal: found.local,
104
+ detectedGlobal: found.global,
105
+ detected: found.local || found.global,
106
+ };
107
+ });
108
+ return [...all.filter((t) => t.detected), ...all.filter((t) => !t.detected)];
109
+ }
110
+ /** Where a tool was found, for the picker row ("" when nowhere). */
111
+ export function detectionLabel(t) {
112
+ if (t.detectedLocal && t.detectedGlobal)
113
+ return "(detected: project + global)";
114
+ if (t.detectedLocal)
115
+ return "(detected: project)";
116
+ if (t.detectedGlobal)
117
+ return "(detected: global)";
118
+ return "";
119
+ }
120
+ /**
121
+ * The two concrete destinations for a chosen tool, in prompt order (project,
122
+ * then global), plus the preselect index. Pure so the prompt's content is
123
+ * unit-testable without a TTY.
124
+ *
125
+ * Preselect prefers the scope where the tool is ALREADY set up. When it is set
126
+ * up in both — or in neither — it stays project-local, which is exactly what
127
+ * `-y` and non-TTY runs default to, so the interactive and non-interactive
128
+ * paths can never disagree about the same repo.
129
+ */
130
+ export function scopeChoices(t, opts) {
131
+ const found = detectScopes(t, opts);
132
+ const choices = [
133
+ {
134
+ global: false,
135
+ label: "This directory",
136
+ dir: targetInstallDir(t, { ...opts, global: false }),
137
+ detected: found.local,
138
+ },
139
+ {
140
+ global: true,
141
+ label: "Global",
142
+ dir: targetInstallDir(t, { ...opts, global: true }),
143
+ detected: found.global,
144
+ },
145
+ ];
146
+ return { choices, preselect: !found.local && found.global ? 1 : 0 };
147
+ }
83
148
  /**
84
149
  * D-UX11 ordering: detected tools float to the top (registry order preserved
85
150
  * within each group); the preselect is index 0 (first detected, else Claude).
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Key handling for the interactive picker, as a PURE reducer.
3
+ *
4
+ * Kept out of ui.ts on purpose: the rendering half needs a real TTY (raw mode,
5
+ * cursor moves) and cannot be unit-tested, but the DECISIONS — what wraps, what
6
+ * commits, what cancels — are the part that regresses. Pure in, pure out, so
7
+ * every key path is pinned by a table test with no terminal involved.
8
+ */
9
+ /** The subset of node:readline's keypress event this reducer reads. */
10
+ export type Key = {
11
+ name?: string;
12
+ sequence?: string;
13
+ ctrl?: boolean;
14
+ meta?: boolean;
15
+ };
16
+ export type SelectState = {
17
+ /** highlighted option */
18
+ index: number;
19
+ /** digits typed so far — the "type a number" path, empty when unused */
20
+ buffer: string;
21
+ };
22
+ export type SelectResult = {
23
+ kind: "state";
24
+ state: SelectState;
25
+ } | {
26
+ kind: "commit";
27
+ index: number;
28
+ }
29
+ /** `signal` marks Ctrl-C (exit 130, the 128+SIGINT convention); Esc/Ctrl-D
30
+ * are deliberate in-UI cancels (exit 1). */
31
+ | {
32
+ kind: "cancel";
33
+ signal?: boolean;
34
+ }
35
+ /** a typed number that names no option — caller flashes a hint, keeps the prompt */
36
+ | {
37
+ kind: "reject";
38
+ state: SelectState;
39
+ message: string;
40
+ };
41
+ /**
42
+ * One keypress → the next state, or a terminal outcome.
43
+ *
44
+ * Both input styles stay live at once, which is the whole point: arrows move the
45
+ * highlight, digits ALSO move it (so typing is visible before you commit), and
46
+ * Enter commits whichever the user last expressed. Space commits the highlight
47
+ * only — it is a "this one" gesture, and treating it as a number-confirm would
48
+ * make a half-typed "1" + Space silently pick option 1 when the user meant 12.
49
+ *
50
+ * Digits never auto-commit. `3` in a 4-item list is unambiguous and a fancier
51
+ * picker would fire immediately, but the user asked to KEEP number entry, and
52
+ * number entry means "type it, then press Enter" — auto-firing would make a
53
+ * two-digit list behave differently from a one-digit list.
54
+ *
55
+ * `hotkeys` (letter → option index) commit IMMEDIATELY — the confirm path
56
+ * passes {y:0, n:1}. Without this, `n`+Enter at a default-Yes "Install?"
57
+ * committed the DEFAULT: the muscle-memory answer this CLI's own [y/N]
58
+ * fallback trains was silently ignored and the UI executed the opposite of
59
+ * what the user typed (dual-review find, PTY-proven).
60
+ */
61
+ export declare function reduceSelectKey(state: SelectState, key: Key, count: number, hotkeys?: Record<string, number>): SelectResult;
62
+ /** Footer hint. Mentions BOTH input styles — neither is discoverable otherwise.
63
+ * `hotkeyLabel` (e.g. "y/n") leads when the picker has letter hotkeys. */
64
+ export declare function selectHint(count: number, buffer: string, hotkeyLabel?: string): string;