promptdock 1.0.1 → 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prompt Dock, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -53,10 +53,29 @@ Install options: `-g/--global` (per-user dir), `--target <tool>`, `--dir <path>`
53
53
  | `kimi` | `.kimi/skills/<slug>/` | `~/.kimi/skills/<slug>/` | experimental |
54
54
  | `openai` (OpenAI/GPT) | `.openai/skills/<slug>/` | `~/.openai/skills/<slug>/` | experimental |
55
55
 
56
- Tools detected in your project (or home dir with `-g`) float to the top of the
57
- picker and are preselected. `--dir <path>` installs anywhere. After installing,
58
- **restart/reopen the tool** — most agents only scan skills at session start
59
- (Claude Code: start a *new* session, then type `/<slug>`).
56
+ Interactively, `install` asks **two** questions:
57
+
58
+ 1. **Which tool** — tools already set up (in either scope) float to the top,
59
+ tagged with where they were found; the first is preselected.
60
+ 2. **This directory or your user account** — shown as the two real destination
61
+ paths, preselecting whichever scope the tool is already set up in.
62
+
63
+ Both prompts (and every confirm) are **arrow-key driven**: `↑`/`↓` to move
64
+ (`j`/`k` and `^p`/`^n` work too), `Enter` or `Space` to select, `Esc` to cancel
65
+ (`Ctrl-C` exits `130`, the shell convention). Yes/No confirms also take **`y`
66
+ and `n` directly** — they commit immediately, never fall through to a default.
67
+ **Typing a number still works** — it moves the highlight as you type, `Enter`
68
+ confirms, `Backspace` edits. A pipe or CI session never prompts at all (pass
69
+ `--target`/`--dir` + `-y`); a terminal without raw-mode/ANSI support
70
+ (`TERM=dumb`) gets the plain numbered reader.
71
+
72
+ Shortcuts: `-g` answers question 2 up front (per-user install, no prompt);
73
+ `--target <tool>` answers question 1; `--dir <path>` answers both and installs
74
+ anywhere. `-y` and non-interactive sessions never prompt and stay
75
+ **project-local** unless `-g` is passed.
76
+
77
+ After installing, **restart/reopen the tool** — most agents only scan skills at
78
+ session start (Claude Code: start a *new* session, then type `/<slug>`).
60
79
 
61
80
  ## CI / non-interactive use
62
81
 
@@ -107,6 +126,7 @@ path is re-validated before any write; a failed check aborts **all-or-nothing**
107
126
  | 4 | integrity (sha mismatch, unsafe manifest path, receipt schema) |
108
127
  | 5 | filesystem (permissions, disk space, non-empty directory) |
109
128
  | 6 | network |
129
+ | 130 | interrupted (`Ctrl-C` during an interactive prompt; `Esc` — a deliberate in-UI cancel — exits 1) |
110
130
 
111
131
  Every named error links `https://promptdock.ai/docs/cli/errors#<code>`.
112
132
 
@@ -118,3 +138,16 @@ Every named error links `https://promptdock.ai/docs/cli/errors#<code>`.
118
138
  Review is a screening step, not a warranty — read what you install.
119
139
  - Skills are plain files on your machine after install; premium gates access,
120
140
  not copying.
141
+
142
+ ## License
143
+
144
+ [MIT](./LICENSE) © Prompt Dock, Inc.
145
+
146
+ Scope: the MIT license covers **this package (`packages/cli/`) only** — the CLI
147
+ client source published to npm. It does not cover the PromptDock service, the
148
+ website, or the rest of the repository, which are proprietary.
149
+
150
+ It also does not cover **the skills you install**. Those are third-party content
151
+ licensed by their authors under the
152
+ [PromptDock Terms](https://promptdock.ai/terms) — installing a skill grants you
153
+ access to use it, not the rights this MIT license grants over the CLI itself.
package/dist/api.js CHANGED
@@ -1,12 +1,41 @@
1
+ import { DEFAULT_API_BASE } from "./config.js";
1
2
  import { CliError, EXIT, networkError } from "./errors.js";
2
- /** Origin of a Location header, for the "set PROMPTDOCK_API_BASE to …" hint. */
3
- function originOf(location) {
3
+ /**
4
+ * The redirect target may only become an ACTIONABLE hint when it is trustworthy
5
+ * (review CRITICAL): a 3xx can come from a captive portal, hijacked DNS, or a
6
+ * MITM proxy, and the old hint rendered its Location verbatim as a
7
+ * copy-pasteable `PROMPTDOCK_API_BASE=<attacker origin>` instruction — the
8
+ * commit that fixed redirect-STRIPS-Authorization had introduced
9
+ * redirect-HANDS-OVER-Authorization. Trust rule: https only, AND the same
10
+ * registrable domain as the origin we were already talking to (apex↔www and
11
+ * sibling subdomains pass; anything else gets the STATIC default hint).
12
+ */
13
+ function trustedRedirectOrigin(location, baseUrl) {
4
14
  try {
5
- return new URL(location).origin;
15
+ const to = new URL(location);
16
+ if (to.protocol !== "https:")
17
+ return null;
18
+ const base = new URL(baseUrl);
19
+ const tail = (h) => h.split(".").slice(-2).join(".");
20
+ if (tail(to.hostname) !== tail(base.hostname))
21
+ return null;
22
+ return to.origin;
6
23
  }
7
24
  catch {
8
- return null; // relative Location — can't name an origin, fall back to the default
25
+ return null; // relative/unparseable Location — never actionable
26
+ }
27
+ }
28
+ /** Strip control chars (incl. ESC) before a server-supplied string reaches the
29
+ * terminal — a Location carrying ANSI sequences could rewrite what the user
30
+ * sees (review find). */
31
+ function sanitizeForTerminal(s) {
32
+ let out = "";
33
+ for (const ch of s) {
34
+ const c = ch.codePointAt(0) ?? 0;
35
+ if (c >= 0x20 && c !== 0x7f && !(c >= 0x80 && c <= 0x9f))
36
+ out += ch;
9
37
  }
38
+ return out.slice(0, 200);
10
39
  }
11
40
  export class Api {
12
41
  ctx;
@@ -53,8 +82,13 @@ export class Api {
53
82
  // ctx.fetch directly and must keep following them — storage hands out redirects.
54
83
  if (res.status >= 300 && res.status < 400) {
55
84
  const to = res.headers.get("location") ?? "(no location header)";
56
- throw new CliError(`${this.baseUrl} redirected to ${to} — the API must be called on its canonical origin, because a redirect drops the request body and the login token.`, EXIT.NETWORK, {
57
- hint: `set PROMPTDOCK_API_BASE to the redirect target, e.g. PROMPTDOCK_API_BASE=${originOf(to) ?? "https://www.promptdock.ai"}`,
85
+ throw new CliError(`${this.baseUrl} redirected to ${sanitizeForTerminal(to)} — the API must be called on its canonical origin, because a redirect drops the request body and the login token.`, EXIT.NETWORK, {
86
+ // An UNTRUSTED target (cross-domain / non-https a captive portal or
87
+ // MITM can mint a 3xx) gets the STATIC known-good hint, never its own
88
+ // origin echoed back as an instruction. DEFAULT_API_BASE is imported,
89
+ // not re-typed: the hardcoded literal here was a second copy of the
90
+ // exact constant whose staleness caused the original apex outage.
91
+ hint: `set PROMPTDOCK_API_BASE to the canonical origin, e.g. PROMPTDOCK_API_BASE=${trustedRedirectOrigin(to, this.baseUrl) ?? DEFAULT_API_BASE}`,
58
92
  footer: "api",
59
93
  });
60
94
  }
package/dist/auth.js CHANGED
@@ -30,8 +30,14 @@ export async function deviceFlowLogin(ctx, baseUrl, opts) {
30
30
  ctx.io.out("(if the page didn't open, paste the URL into your browser)");
31
31
  }
32
32
  ctx.io.out("");
33
- const deadline = ctx.now() + Math.max(1, start.expires_in) * 1000;
34
- let intervalSecs = Math.max(0, Number(start.interval) || 0);
33
+ // Clamp through Number.isFinite (review find): a missing/renamed numeric in
34
+ // the start response made `deadline` NaN, and `remainMs <= 0` is FALSE for
35
+ // NaN — an infinite countdown spin. A malformed numeric now degrades to the
36
+ // protocol defaults instead of hanging the login.
37
+ const expiresIn = Number(start.expires_in);
38
+ const deadline = ctx.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 600) * 1000;
39
+ const interval0 = Number(start.interval);
40
+ let intervalSecs = Number.isFinite(interval0) && interval0 > 0 ? interval0 : 5;
35
41
  poll: for (;;) {
36
42
  if (ctx.now() >= deadline)
37
43
  break poll; // local expiry — restart below
@@ -61,7 +67,22 @@ export async function deviceFlowLogin(ctx, baseUrl, opts) {
61
67
  footer: "access_denied",
62
68
  });
63
69
  }
64
- break poll; // expired_token (or unknown) — restart below
70
+ if (verb === "expired_token")
71
+ break poll; // genuine expiry — restart below
72
+ // NOT an RFC verb (review find): the wrapper's own limiter answers this
73
+ // route in the app ENVELOPE ({error:{code,…}} — e.g. rate_limited when
74
+ // several devs share one NAT), and a 500 arrives enveloped too. Both used
75
+ // to collapse into "expired" → a restart → "login timed out", hiding the
76
+ // real cause. Surface the truth instead of calling it a timeout.
77
+ const env = Api.envelope(res.body);
78
+ if (env) {
79
+ const retryAfter = Number(res.headers.get("retry-after"));
80
+ const wait = env.code === "rate_limited" && Number.isFinite(retryAfter) && retryAfter > 0
81
+ ? ` — retry in ~${Math.ceil(retryAfter)}s`
82
+ : "";
83
+ throw new CliError(`login failed: ${env.message}${wait}`, res.status >= 500 ? EXIT.NETWORK : EXIT.DENIED, { footer: "api" });
84
+ }
85
+ break poll; // genuinely unrecognized body — restart below
65
86
  }
66
87
  clearCountdown(ctx);
67
88
  restarts += 1;
@@ -1,2 +1,9 @@
1
1
  import type { CliContext } from "../context.js";
2
+ import { type SkillRef } from "../ref.js";
2
3
  export declare function runInstall(ctx: CliContext, positionals: string[], flags: Record<string, string | boolean>): Promise<void>;
4
+ type PickedTarget = {
5
+ id: string;
6
+ dir: string;
7
+ };
8
+ export declare function resolveTarget(ctx: CliContext, flags: Record<string, string | boolean>, ref: SkillRef): Promise<PickedTarget>;
9
+ export {};
@@ -8,7 +8,7 @@ import { ensureAuth } from "../auth.js";
8
8
  import { CliError, EXIT, usageError } from "../errors.js";
9
9
  import { checkInstallResponse, performInstall } from "../installer.js";
10
10
  import { formatRef, parseSkillRef } from "../ref.js";
11
- import { detectTargets, nextStepLine, targetById, targetInstallDir, TARGETS, } from "../registry.js";
11
+ import { detectionLabel, detectTargets, detectTargetsAnyScope, nextStepLine, scopeChoices, targetById, targetInstallDir, TARGETS, } from "../registry.js";
12
12
  import { readReceipt } from "../receipts.js";
13
13
  import { colors, confirm, formatBytes, promptSelect } from "../ui.js";
14
14
  import { assertInstallable } from "../verdicts.js";
@@ -102,7 +102,7 @@ export async function runInstall(ctx, positionals, flags) {
102
102
  }
103
103
  for (const line of summary)
104
104
  ctx.io.out(line);
105
- const go = await confirm(ctx.io, isUpdate ? "Update?" : "Install?", true);
105
+ const go = await confirm(ctx.io, isUpdate ? "Update?" : "Install?", true, c);
106
106
  if (!go) {
107
107
  ctx.io.out("Cancelled — nothing installed.");
108
108
  return;
@@ -144,9 +144,47 @@ export async function runInstall(ctx, positionals, flags) {
144
144
  ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${resp.skill.title}" v${resp.skill.version} → ${picked.dir}`);
145
145
  ctx.io.out(nextStepLine(picked.id, resp.skill.slug));
146
146
  }
147
- async function resolveTarget(ctx, flags, ref) {
148
- const global = flags.global === true;
147
+ /**
148
+ * Project-vs-global, asked AFTER the tool is known so the two options can be
149
+ * shown as their real destination paths rather than an abstraction.
150
+ *
151
+ * Never prompts when the answer is already settled: `-g` IS the answer, and
152
+ * `-y`/non-TTY runs are contractually promptless (they keep the flag default,
153
+ * project-local). So the prompt appears exactly when a human is present and
154
+ * scope is genuinely unstated — which is where it used to be skipped silently.
155
+ */
156
+ async function resolveScope(ctx, def, ref, opts) {
157
+ if (opts.scopeGiven || opts.yes || !ctx.io.isTTY)
158
+ return opts.global;
159
+ const { choices, preselect } = scopeChoices(def, {
160
+ cwd: ctx.cwd,
161
+ home: ctx.home,
162
+ slug: ref.slug,
163
+ });
164
+ // Standing in $HOME, "this project" and "global" are the SAME directory —
165
+ // a two-row prompt showing one path twice reads as a bug (review find).
166
+ // The answer is settled: skip the question.
167
+ if (choices[0].dir === choices[1].dir)
168
+ return opts.global;
169
+ // Display dirs ~-abbreviated: full absolute paths wrap a stock 80-col
170
+ // terminal, and a wrapped line desyncs the picker's in-place repaint.
171
+ // The RETURNED dir stays absolute — only the label is shortened.
172
+ const tildify = (d) => (d.startsWith(ctx.home) ? `~${d.slice(ctx.home.length)}` : d);
173
+ const idx = await promptSelect(ctx.io, `Install ${def.label} skill in this directory or for your user account?`, choices.map((c) => `${c.label.padEnd(14)} ${tildify(c.dir)}${c.detected ? " (detected)" : ""}`), preselect, colors(ctx.env, ctx.io.isTTY));
174
+ return choices[idx].global;
175
+ }
176
+ // Exported for the wiring tests: the pure path maths lives in registry.ts, but
177
+ // the ORDER and CONDITIONS of the two prompts are what regressed before (the
178
+ // registry always supported global; this function simply never asked).
179
+ export async function resolveTarget(ctx, flags, ref) {
180
+ // `-g` is an explicit answer to the scope question; its absence is NOT an
181
+ // answer, it just means "unstated" (which is why we now ask instead of
182
+ // defaulting silently to project-local).
183
+ const scopeGiven = flags.global === true;
184
+ const global = scopeGiven;
185
+ const yes = flags.yes === true;
149
186
  if (typeof flags.dir === "string") {
187
+ // An explicit path IS the scope — nothing left to ask.
150
188
  const dir = isAbsolute(flags.dir) ? flags.dir : resolvePath(ctx.cwd, flags.dir);
151
189
  return { id: "custom", dir };
152
190
  }
@@ -155,16 +193,24 @@ async function resolveTarget(ctx, flags, ref) {
155
193
  if (!def) {
156
194
  throw usageError(`unknown --target "${flags.target}"`, `known targets: ${TARGETS.map((t) => t.id).join(", ")} (or use --dir <path>)`);
157
195
  }
196
+ // --target picks the TOOL, not the scope: an interactive run still asks.
197
+ const useGlobal = await resolveScope(ctx, def, ref, { scopeGiven, global, yes });
158
198
  return {
159
199
  id: def.id,
160
- dir: targetInstallDir(def, { cwd: ctx.cwd, home: ctx.home, global, slug: ref.slug }),
200
+ dir: targetInstallDir(def, {
201
+ cwd: ctx.cwd,
202
+ home: ctx.home,
203
+ global: useGlobal,
204
+ slug: ref.slug,
205
+ }),
161
206
  };
162
207
  }
163
- const detected = detectTargets({ cwd: ctx.cwd, home: ctx.home, global });
164
208
  if (!ctx.io.isTTY)
165
209
  throw usageError(NON_TTY_TARGET_HINT);
166
- if (flags.yes === true) {
210
+ if (yes) {
167
211
  // -y skips the PICKER only, and only when detection is unambiguous (D-UX11).
212
+ // Scope is known here (the flag), so this stays single-scope detection.
213
+ const detected = detectTargets({ cwd: ctx.cwd, home: ctx.home, global });
168
214
  const found = detected.filter((t) => t.detected);
169
215
  if (found.length === 1) {
170
216
  return {
@@ -176,16 +222,17 @@ async function resolveTarget(ctx, flags, ref) {
176
222
  ? "-y needs a detectable target and none was found — pass --target <tool> or --dir <path>"
177
223
  : `-y needs an unambiguous target and ${found.length} tools were detected — pass --target <tool> or --dir <path>`);
178
224
  }
179
- const scopeNote = global ? " (global)" : "";
225
+ // Interactive: TOOL first, then SCOPE. Detection spans both scopes here
226
+ // because the scope is not yet chosen.
227
+ const detected = detectTargetsAnyScope({ cwd: ctx.cwd, home: ctx.home });
180
228
  const options = detected.map((t) => {
181
- const dir = targetInstallDir(t, { cwd: ctx.cwd, home: ctx.home, global, slug: ref.slug });
182
- const tags = [t.detected ? "(detected)" : null, t.experimental ? "(experimental)" : null]
229
+ const tags = [detectionLabel(t), t.experimental ? "(experimental)" : null]
183
230
  .filter(Boolean)
184
231
  .join(" ");
185
- return `${t.label.padEnd(16)} ${dir}${tags ? ` ${tags}` : ""}`;
232
+ return `${t.label.padEnd(16)}${tags ? ` ${tags}` : ""}`;
186
233
  });
187
- options.push("Custom path… (type any directory)");
188
- const idx = await promptSelect(ctx.io, `Where should this skill be installed?${scopeNote}`, options, 0);
234
+ options.push("Custom path… (type any directory)");
235
+ const idx = await promptSelect(ctx.io, "Which tool is this skill for?", options, 0, colors(ctx.env, ctx.io.isTTY));
189
236
  if (idx === options.length - 1) {
190
237
  const answer = (await ctx.io.question("Directory: ")).trim();
191
238
  if (!answer)
@@ -193,9 +240,15 @@ async function resolveTarget(ctx, flags, ref) {
193
240
  return { id: "custom", dir: isAbsolute(answer) ? answer : resolvePath(ctx.cwd, answer) };
194
241
  }
195
242
  const chosen = detected[idx];
243
+ const useGlobal = await resolveScope(ctx, chosen, ref, { scopeGiven, global, yes });
196
244
  return {
197
245
  id: chosen.id,
198
- dir: targetInstallDir(chosen, { cwd: ctx.cwd, home: ctx.home, global, slug: ref.slug }),
246
+ dir: targetInstallDir(chosen, {
247
+ cwd: ctx.cwd,
248
+ home: ctx.home,
249
+ global: useGlobal,
250
+ slug: ref.slug,
251
+ }),
199
252
  };
200
253
  }
201
254
  function dirIsNonEmpty(dir) {
@@ -70,7 +70,7 @@ export async function runUninstall(ctx, positionals, flags) {
70
70
  if (flags.yes !== true) {
71
71
  if (!ctx.io.isTTY)
72
72
  throw usageError("confirmation needed in a non-interactive session", "re-run with -y");
73
- const go = await confirm(ctx.io, `Uninstall ${receipt.ref} from ${item.dir}?`, false);
73
+ const go = await confirm(ctx.io, `Uninstall ${receipt.ref} from ${item.dir}?`, false, c);
74
74
  if (!go) {
75
75
  ctx.io.out("Skipped.");
76
76
  continue;
@@ -193,7 +193,7 @@ export async function runUpdate(ctx, positionals, flags) {
193
193
  if (flags.yes !== true) {
194
194
  if (!ctx.io.isTTY)
195
195
  throw usageError("confirmation needed in a non-interactive session", "re-run with -y (or use --check)");
196
- const go = await confirm(ctx.io, `Update ${receipt.ref} v${receipt.version} → v${toVersion}?`, true);
196
+ const go = await confirm(ctx.io, `Update ${receipt.ref} v${receipt.version} → v${toVersion}?`, true, c);
197
197
  if (!go) {
198
198
  report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason: "declined" });
199
199
  continue;
@@ -34,12 +34,41 @@ export async function runLogin(ctx, flags) {
34
34
  export async function runLogout(ctx) {
35
35
  const config = loadConfig(ctx.home);
36
36
  const had = typeof config.token === "string";
37
+ // REVOKE server-side FIRST (review CRITICAL: logout used to delete one copy
38
+ // of a 90-day sliding bearer and merely mention the Settings page — a
39
+ // security control named after ending a session that could not end it).
40
+ // Self-scoped: DELETE /cli/tokens/current kills exactly the presented token.
41
+ // Best-effort — offline logout must still clear local state, but a failed
42
+ // revoke is a WARNING, not a footnote.
43
+ let revoked = false;
44
+ if (had) {
45
+ try {
46
+ const api = new Api(ctx, resolveApiBase(ctx.env, config), config.token);
47
+ const res = await api.raw("DELETE", "/api/v1/cli/tokens/current");
48
+ revoked = res.status >= 200 && res.status < 300;
49
+ }
50
+ catch {
51
+ revoked = false;
52
+ }
53
+ }
37
54
  if (had) {
38
55
  delete config.token;
56
+ // api_base travels WITH the token (they are a pair — see config.ts): logout
57
+ // returns the client to the known-good default, so a hostile or stale
58
+ // origin set earlier does not survive the trust reset (review find).
59
+ delete config.api_base;
39
60
  saveConfig(ctx.home, config, ctx.platform);
40
61
  }
41
- ctx.io.out(had ? "Logged out (local token removed)." : "Not logged in — nothing to remove.");
42
- ctx.io.out("To invalidate the session server-side too: promptdock.ai → Settings → Account → CLI sessions → Revoke.");
62
+ if (!had) {
63
+ ctx.io.out("Not logged in nothing to remove.");
64
+ }
65
+ else if (revoked) {
66
+ ctx.io.out("Logged out — the session was revoked server-side and the local token removed.");
67
+ }
68
+ else {
69
+ ctx.io.out("Logged out (local token removed).");
70
+ ctx.io.err("warning: could not revoke the session server-side (offline?). The token stays valid until it expires — revoke it at promptdock.ai → Settings → Account → CLI sessions.");
71
+ }
43
72
  if (ctx.env.PROMPTDOCK_TOKEN) {
44
73
  ctx.io.out("note: PROMPTDOCK_TOKEN is set in this environment and still authenticates requests.");
45
74
  }
package/dist/config.js CHANGED
@@ -79,12 +79,45 @@ const trimBase = (s) => s.replace(/\/+$/, "");
79
79
  // so the token and the origin are a PAIR. Storing the token without its origin means
80
80
  // the next run sends a staging token to the default origin and 401s. The apex-pinning
81
81
  // problem that motivated the idea is handled by LEGACY_API_BASES below instead.
82
+ /** Loopback hosts — the ONE carve-out from the TLS floor (local dev + the test
83
+ * mock servers). Everything else that receives the bearer must be https. */
84
+ function isLoopbackHost(host) {
85
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
86
+ }
87
+ /**
88
+ * THE TLS FLOOR (review find): every resolved base carries a 90-day bearer in
89
+ * an Authorization header, and nothing previously constrained the scheme — a
90
+ * stored `http://` origin (written once by a plaintext invocation, or by the
91
+ * api.ts redirect hint before it was hardened) was honoured forever, silently.
92
+ * Enforced on the env override AND the stored value: a config written by an
93
+ * older or hostile release must not survive an upgrade. Throws — a plaintext
94
+ * API base is a misconfiguration to fix, never something to quietly use.
95
+ */
96
+ function assertSecureBase(base, source) {
97
+ let u;
98
+ try {
99
+ u = new URL(base);
100
+ }
101
+ catch {
102
+ throw new CliError(`${source === "env" ? "PROMPTDOCK_API_BASE" : "the stored api_base"} is not a valid URL: set it to an origin like https://www.promptdock.ai`, EXIT.USAGE);
103
+ }
104
+ if (u.username || u.password || u.search || u.hash) {
105
+ throw new CliError(`${source === "env" ? "PROMPTDOCK_API_BASE" : "the stored api_base"} must be a bare origin (no credentials, query, or fragment)`, EXIT.USAGE);
106
+ }
107
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && isLoopbackHost(u.hostname))) {
108
+ throw new CliError(`${source === "env" ? "PROMPTDOCK_API_BASE" : "the stored api_base"} is ${u.protocol}// — the CLI sends your login token on every request, so only https:// (or http://localhost for dev) is allowed. ` +
109
+ (source === "config"
110
+ ? "Run `promptdock logout`, then log in again against the https origin."
111
+ : "Set PROMPTDOCK_API_BASE to the https origin."), EXIT.USAGE);
112
+ }
113
+ return base;
114
+ }
82
115
  export function resolveApiBase(env, config) {
83
116
  if (env.PROMPTDOCK_API_BASE)
84
- return trimBase(env.PROMPTDOCK_API_BASE);
117
+ return assertSecureBase(trimBase(env.PROMPTDOCK_API_BASE), "env");
85
118
  const stored = typeof config.api_base === "string" ? trimBase(config.api_base) : null;
86
119
  // Drop a base a broken release pinned here; anything else the user set is honoured.
87
120
  if (stored && !LEGACY_API_BASES.has(stored))
88
- return stored;
121
+ return assertSecureBase(stored, "config");
89
122
  return trimBase(DEFAULT_API_BASE);
90
123
  }
package/dist/context.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Key } from "./select-keys.js";
1
2
  /**
2
3
  * argv joined for the `X-Promptdock-Cli-Args` header, with bearer tokens removed.
3
4
  *
@@ -21,6 +22,24 @@ export type CliIo = {
21
22
  isTTY: boolean;
22
23
  /** readline question (TTY only — callers must gate on isTTY) */
23
24
  question: (prompt: string) => Promise<string>;
25
+ /**
26
+ * Raw keypress stream for the arrow-key picker. OPTIONAL by design: when it is
27
+ * absent, `promptSelect` falls back to the numbered reader built on `question`.
28
+ * That keeps one code path for every environment that cannot do raw mode — a
29
+ * piped stdin, a CI runner, a dumb terminal, and every test fake — without
30
+ * those callers needing to know the picker exists.
31
+ *
32
+ * Returns an unsubscribe that MUST restore the previous terminal mode.
33
+ */
34
+ rawKeys?: (onKey: (key: Key) => void) => () => void;
35
+ /** Live terminal dimensions — the picker clamps line width to `columns`
36
+ * (wrapped lines desync the in-place repaint) and falls back to the
37
+ * numbered reader when the frame exceeds `rows`. Optional: fakes and
38
+ * non-TTY contexts omit it and the picker assumes 80x24. */
39
+ size?: () => {
40
+ rows: number;
41
+ columns: number;
42
+ };
24
43
  };
25
44
  export type CliContext = {
26
45
  io: CliIo;
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(),
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) {
@@ -3,7 +3,7 @@ export declare const MAX_SKILL_TOTAL_BYTES = 5242880;
3
3
  export declare const MAX_SKILL_FILE_BYTES = 1048576;
4
4
  export declare const MAX_SKILL_IMAGES = 5;
5
5
  export declare const SKILL_REVEAL_DAILY_CAP = 15;
6
- export declare const CLI_MIN_VERSION = "0.1.0";
6
+ export declare const CLI_MIN_VERSION = "1.0.1";
7
7
  export declare const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
8
8
  export declare const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
9
9
  export declare const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
@@ -5,7 +5,7 @@ export const MAX_SKILL_TOTAL_BYTES = 5242880;
5
5
  export const MAX_SKILL_FILE_BYTES = 1048576;
6
6
  export const MAX_SKILL_IMAGES = 5;
7
7
  export const SKILL_REVEAL_DAILY_CAP = 15;
8
- export const CLI_MIN_VERSION = "0.1.0";
8
+ export const CLI_MIN_VERSION = "1.0.1";
9
9
  export const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
10
10
  export const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
11
11
  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
@@ -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;
@@ -0,0 +1,113 @@
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
+ const wrap = (i, count) => ((i % count) + count) % count;
10
+ /**
11
+ * One keypress → the next state, or a terminal outcome.
12
+ *
13
+ * Both input styles stay live at once, which is the whole point: arrows move the
14
+ * highlight, digits ALSO move it (so typing is visible before you commit), and
15
+ * Enter commits whichever the user last expressed. Space commits the highlight
16
+ * only — it is a "this one" gesture, and treating it as a number-confirm would
17
+ * make a half-typed "1" + Space silently pick option 1 when the user meant 12.
18
+ *
19
+ * Digits never auto-commit. `3` in a 4-item list is unambiguous and a fancier
20
+ * picker would fire immediately, but the user asked to KEEP number entry, and
21
+ * number entry means "type it, then press Enter" — auto-firing would make a
22
+ * two-digit list behave differently from a one-digit list.
23
+ *
24
+ * `hotkeys` (letter → option index) commit IMMEDIATELY — the confirm path
25
+ * passes {y:0, n:1}. Without this, `n`+Enter at a default-Yes "Install?"
26
+ * committed the DEFAULT: the muscle-memory answer this CLI's own [y/N]
27
+ * fallback trains was silently ignored and the UI executed the opposite of
28
+ * what the user typed (dual-review find, PTY-proven).
29
+ */
30
+ export function reduceSelectKey(state, key, count, hotkeys) {
31
+ const name = key.name;
32
+ const seq = key.sequence;
33
+ const keep = (s) => ({ kind: "state", state: s });
34
+ // Raw mode swallows SIGINT, so Ctrl-C is ours to honour or the picker hangs.
35
+ // It carries the `signal` mark → exit 130; Esc/^D are deliberate cancels.
36
+ if (key.ctrl && name === "c")
37
+ return { kind: "cancel", signal: true };
38
+ if (key.ctrl && name === "d")
39
+ return { kind: "cancel" };
40
+ if (name === "escape")
41
+ return { kind: "cancel" };
42
+ // Letter hotkeys beat every other mapping except cancel — `n` must never be
43
+ // read as "ignored key" while the footer implies typing works.
44
+ if (name && !key.ctrl && !key.meta && hotkeys && hotkeys[name] !== undefined) {
45
+ const idx = hotkeys[name];
46
+ if (idx >= 0 && idx < count)
47
+ return { kind: "commit", index: idx };
48
+ }
49
+ if (name === "return" || name === "enter") {
50
+ if (state.buffer === "")
51
+ return { kind: "commit", index: state.index };
52
+ const n = Number(state.buffer);
53
+ if (Number.isInteger(n) && n >= 1 && n <= count)
54
+ return { kind: "commit", index: n - 1 };
55
+ return {
56
+ kind: "reject",
57
+ state: { index: state.index, buffer: "" },
58
+ message: `Enter a number between 1 and ${count}.`,
59
+ };
60
+ }
61
+ if (name === "space") {
62
+ // With a live digit buffer, Space means what Enter means — validate the
63
+ // number. Committing the stale highlight would discard the visible buffer
64
+ // ("Number: 12") for a row the user never named (review find).
65
+ if (state.buffer !== "") {
66
+ const n = Number(state.buffer);
67
+ if (Number.isInteger(n) && n >= 1 && n <= count)
68
+ return { kind: "commit", index: n - 1 };
69
+ return {
70
+ kind: "reject",
71
+ state: { index: state.index, buffer: "" },
72
+ message: `Enter a number between 1 and ${count}.`,
73
+ };
74
+ }
75
+ return { kind: "commit", index: state.index };
76
+ }
77
+ if (name === "up" || name === "k" || (key.ctrl && name === "p"))
78
+ return keep({ index: wrap(state.index - 1, count), buffer: "" });
79
+ if (name === "down" || name === "j" || (key.ctrl && name === "n"))
80
+ return keep({ index: wrap(state.index + 1, count), buffer: "" });
81
+ if (name === "home")
82
+ return keep({ index: 0, buffer: "" });
83
+ if (name === "end")
84
+ return keep({ index: count - 1, buffer: "" });
85
+ if (name === "backspace") {
86
+ const buffer = state.buffer.slice(0, -1);
87
+ const n = Number(buffer);
88
+ // Re-point at whatever the shortened buffer now names; keep the highlight if it names nothing.
89
+ const index = buffer !== "" && n >= 1 && n <= count ? n - 1 : state.index;
90
+ return keep({ index, buffer });
91
+ }
92
+ if (seq !== undefined && /^[0-9]$/.test(seq)) {
93
+ // Bounded: 3 digits names any option a 20-row picker can hold; an unbounded
94
+ // buffer + full-frame repaint per digit is quadratic under a digit-spam
95
+ // paste (codex find). Extra digits are ignored, never mis-parsed.
96
+ if (state.buffer.length >= 3)
97
+ return keep(state);
98
+ // Leading zeros are dropped so "0" then "3" is 3, not a dead buffer.
99
+ const buffer = (state.buffer + seq).replace(/^0+(?=\d)/, "");
100
+ const n = Number(buffer);
101
+ const index = n >= 1 && n <= count ? n - 1 : state.index;
102
+ return keep({ index, buffer });
103
+ }
104
+ return keep(state);
105
+ }
106
+ /** Footer hint. Mentions BOTH input styles — neither is discoverable otherwise.
107
+ * `hotkeyLabel` (e.g. "y/n") leads when the picker has letter hotkeys. */
108
+ export function selectHint(count, buffer, hotkeyLabel) {
109
+ if (buffer !== "")
110
+ return `Number: ${buffer} ↵ confirm · ⌫ clear · esc cancel`;
111
+ const hot = hotkeyLabel ? `${hotkeyLabel} · ` : "";
112
+ return `${hot}↑↓ move · ↵/space select · 1-${count} then ↵ · esc cancel`;
113
+ }
package/dist/ui.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CliIo } from "./context.js";
2
- /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color. */
2
+ /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
3
+ * TERM=dumb too — a terminal that can't render ANSI gets plain text. */
3
4
  export declare function colors(env: Record<string, string | undefined>, isTTY: boolean): {
4
5
  bold: (s: string) => string;
5
6
  dim: (s: string) => string;
@@ -14,9 +15,18 @@ export declare function formatCountdown(totalSecs: number): string;
14
15
  /** ~Nh retry copy for the cap verdict (mirrors the app's premium-cap copy). */
15
16
  export declare function retryHours(retryAfterSecs: number): string;
16
17
  /**
17
- * Numbered-list picker (TTY only callers gate). Returns the chosen index.
18
- * Enter accepts the preselected default.
18
+ * Picker. Arrow-key driven when the terminal can do raw mode, numbered otherwise.
19
+ * Returns the chosen index.
20
+ *
21
+ * BOTH input styles are live in the interactive path: ↑↓ (and j/k, ^p/^n) move,
22
+ * ↵/space select, and typing a number still works — it moves the highlight as you
23
+ * type and ↵ confirms it. The numbered path below is not a lesser fallback, it is
24
+ * the contract for every non-TTY caller (CI, pipes, tests) and its behaviour is
25
+ * unchanged: Enter alone accepts the preselect.
19
26
  */
20
- export declare function promptSelect(io: CliIo, title: string, options: string[], preselect: number): Promise<number>;
21
- /** y/N confirm (TTY only — callers gate; -y bypasses upstream). */
22
- export declare function confirm(io: CliIo, prompt: string, def?: boolean): Promise<boolean>;
27
+ export declare function promptSelect(io: CliIo, title: string, options: string[], preselect: number, c?: Colors, hotkeys?: Record<string, number>, hotkeyLabel?: string): Promise<number>;
28
+ /**
29
+ * Confirm. Arrow-driven Yes/No when the terminal allows, y/N text otherwise.
30
+ * (TTY only — callers gate; -y bypasses upstream.)
31
+ */
32
+ export declare function confirm(io: CliIo, prompt: string, def?: boolean, c?: Colors): Promise<boolean>;
package/dist/ui.js CHANGED
@@ -1,6 +1,9 @@
1
- /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color. */
1
+ import { CliError, EXIT } from "./errors.js";
2
+ import { reduceSelectKey, selectHint } from "./select-keys.js";
3
+ /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
4
+ * TERM=dumb too — a terminal that can't render ANSI gets plain text. */
2
5
  export function colors(env, isTTY) {
3
- const on = isTTY && !env.NO_COLOR;
6
+ const on = isTTY && !env.NO_COLOR && env.TERM !== "dumb";
4
7
  const wrap = (open, close) => (s) => (on ? `${open}${s}${close}` : s);
5
8
  return {
6
9
  bold: wrap("\u001b[1m", "\u001b[22m"),
@@ -28,11 +31,31 @@ export function formatCountdown(totalSecs) {
28
31
  export function retryHours(retryAfterSecs) {
29
32
  return `~${Math.max(1, Math.ceil(retryAfterSecs / 3600))}h`;
30
33
  }
34
+ const HIDE_CURSOR = "[?25l";
35
+ const SHOW_CURSOR = "[?25h";
36
+ /** Above this, cursor-up repainting fights terminal scrollback — use the numbered reader. */
37
+ const MAX_INTERACTIVE_ROWS = 20;
38
+ const passthrough = (s) => s;
31
39
  /**
32
- * Numbered-list picker (TTY only callers gate). Returns the chosen index.
33
- * Enter accepts the preselected default.
40
+ * Picker. Arrow-key driven when the terminal can do raw mode, numbered otherwise.
41
+ * Returns the chosen index.
42
+ *
43
+ * BOTH input styles are live in the interactive path: ↑↓ (and j/k, ^p/^n) move,
44
+ * ↵/space select, and typing a number still works — it moves the highlight as you
45
+ * type and ↵ confirms it. The numbered path below is not a lesser fallback, it is
46
+ * the contract for every non-TTY caller (CI, pipes, tests) and its behaviour is
47
+ * unchanged: Enter alone accepts the preselect.
34
48
  */
35
- export async function promptSelect(io, title, options, preselect) {
49
+ export async function promptSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
50
+ // Height gate: the frame is title + options + hint. If it doesn't fit the
51
+ // terminal, `ESC[NA` clamps at the top edge and repaints mangle scrollback
52
+ // (an 8-row tmux pane was the review's concrete case) — the numbered reader
53
+ // is the honest fallback there.
54
+ const rows = io.size?.().rows ?? 24;
55
+ const fits = options.length + 2 <= rows - 1;
56
+ if (io.rawKeys && options.length > 0 && options.length <= MAX_INTERACTIVE_ROWS && fits) {
57
+ return interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel);
58
+ }
36
59
  io.out(title);
37
60
  options.forEach((opt, i) => {
38
61
  const marker = i === preselect ? "❯" : " ";
@@ -48,8 +71,136 @@ export async function promptSelect(io, title, options, preselect) {
48
71
  io.out(`Enter a number between 1 and ${options.length}.`);
49
72
  }
50
73
  }
51
- /** y/N confirm (TTY only callers gate; -y bypasses upstream). */
52
- export async function confirm(io, prompt, def = false) {
74
+ /** Clamp a PLAIN (uncolored) line to the terminal width. Load-bearing for the
75
+ * in-place repaint: `ESC[NA` moves PHYSICAL rows while `painted` counts
76
+ * logical lines, so one soft-wrapped line desyncs every later repaint into a
77
+ * trail of stale frames (3 voices + codex converged on this; the scope
78
+ * prompt's absolute paths wrap on a stock 80-col terminal). Clamping before
79
+ * colorization keeps logical lines == physical rows by construction. */
80
+ function clampLine(line, columns) {
81
+ const max = Math.max(8, columns - 1);
82
+ if (line.length <= max)
83
+ return line;
84
+ return line.slice(0, max - 1) + "…";
85
+ }
86
+ async function interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
87
+ const count = options.length;
88
+ const bold = c?.bold ?? passthrough;
89
+ const cyan = c?.cyan ?? passthrough;
90
+ const dim = c?.dim ?? passthrough;
91
+ const width = String(count).length;
92
+ let state = { index: Math.min(Math.max(preselect, 0), count - 1), buffer: "" };
93
+ let notice = "";
94
+ let painted = 0;
95
+ const frame = () => {
96
+ // Re-read the width each paint so a mid-picker resize clamps the NEXT
97
+ // frame correctly (past frames are already committed to scrollback).
98
+ const columns = io.size?.().columns ?? 80;
99
+ const rows = options.map((opt, i) => {
100
+ const num = `${i + 1}.`.padStart(width + 1);
101
+ const body = clampLine(`${i === state.index ? "❯" : " "} ${num} ${opt}`, columns);
102
+ return i === state.index ? cyan(bold(body)) : body;
103
+ });
104
+ return [
105
+ clampLine(title, columns),
106
+ ...rows,
107
+ dim(clampLine(notice || selectHint(count, state.buffer, hotkeyLabel), columns)),
108
+ ];
109
+ };
110
+ const paint = () => {
111
+ // Redraw in place: jump back over the previous frame, then clear-and-rewrite
112
+ // each line. Clearing matters — a shorter line would otherwise leave the tail
113
+ // of the longer one it replaced (a stale path fragment) on screen.
114
+ const lines = frame();
115
+ let out = painted > 0 ? `[${painted}A` : "";
116
+ for (const line of lines)
117
+ out += `\r${line}\n`;
118
+ io.write(out);
119
+ painted = lines.length;
120
+ };
121
+ /** On commit, COLLAPSE the frame to one summary line ("Install? Yes") instead
122
+ * of leaving the whole option list + a stale hint in scrollback (review
123
+ * polish item). Clears every previously painted row, writes the summary,
124
+ * and parks the cursor directly under it so the CLI's next output flows on. */
125
+ const collapse = (chosen) => {
126
+ const columns = io.size?.().columns ?? 80;
127
+ const summary = clampLine(`${title} ${chosen}`, columns);
128
+ let out = painted > 0 ? `[${painted}A` : "";
129
+ out += `\r${cyan(summary)}\n`;
130
+ for (let i = 0; i < painted - 1; i++)
131
+ out += ``;
132
+ if (painted > 1)
133
+ out += `[${painted - 1}A\r`;
134
+ io.write(out);
135
+ painted = 1;
136
+ };
137
+ io.write(HIDE_CURSOR);
138
+ // Seeded with a no-op rather than null: the executor below runs synchronously so
139
+ // this is always replaced before the await, and a non-nullable release means the
140
+ // finally can never be skipped by a narrowing accident. Leaving raw mode on is
141
+ // the one failure here that wrecks the user's terminal after we exit.
142
+ let release = () => { };
143
+ try {
144
+ return await new Promise((resolve, reject) => {
145
+ paint();
146
+ // Settled guard + immediate release: `finally` runs a microtask AFTER
147
+ // resolve, so keys buffered in the SAME tick (a paste like "2\n1") would
148
+ // otherwise keep reducing and repainting a highlight that diverges from
149
+ // the resolved index (codex find, verified). Settle → release NOW; the
150
+ // finally's idempotent release stays as the belt.
151
+ let settled = false;
152
+ const settle = () => {
153
+ settled = true;
154
+ release();
155
+ };
156
+ release = io.rawKeys((key) => {
157
+ if (settled)
158
+ return;
159
+ const res = reduceSelectKey(state, key, count, hotkeys);
160
+ if (res.kind === "cancel") {
161
+ settle();
162
+ reject(res.signal
163
+ ? new CliError("Interrupted.", EXIT.INTERRUPT)
164
+ : new CliError("Cancelled.", EXIT.USAGE));
165
+ return;
166
+ }
167
+ if (res.kind === "commit") {
168
+ state = { index: res.index, buffer: "" };
169
+ notice = "";
170
+ collapse(options[res.index]);
171
+ settle();
172
+ resolve(res.index);
173
+ return;
174
+ }
175
+ if (res.kind === "reject") {
176
+ state = res.state;
177
+ notice = res.message;
178
+ paint();
179
+ return;
180
+ }
181
+ state = res.state;
182
+ notice = "";
183
+ paint();
184
+ });
185
+ });
186
+ }
187
+ finally {
188
+ // Order matters: stop consuming keys BEFORE restoring the cursor, so a key
189
+ // pressed during teardown cannot repaint over the restored terminal.
190
+ release();
191
+ io.write(SHOW_CURSOR);
192
+ }
193
+ }
194
+ /**
195
+ * Confirm. Arrow-driven Yes/No when the terminal allows, y/N text otherwise.
196
+ * (TTY only — callers gate; -y bypasses upstream.)
197
+ */
198
+ export async function confirm(io, prompt, def = false, c) {
199
+ // y/n commit IMMEDIATELY (hotkeys) — the [y/N] muscle memory this CLI's own
200
+ // fallback trains must never be silently ignored (dual-review find: `n`+Enter
201
+ // at a default-Yes confirm used to run the install against a typed no).
202
+ if (io.rawKeys)
203
+ return (await promptSelect(io, prompt, ["Yes", "No"], def ? 0 : 1, c, { y: 0, n: 1 }, "y/n")) === 0;
53
204
  const suffix = def ? "[Y/n]" : "[y/N]";
54
205
  const answer = (await io.question(`${prompt} ${suffix} `)).trim().toLowerCase();
55
206
  if (answer === "")
package/package.json CHANGED
@@ -1,28 +1,46 @@
1
1
  {
2
2
  "name": "promptdock",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Install AI agent skills from PromptDock — npx promptdock@latest install <handle>/<slug>",
5
- "keywords": ["promptdock", "skills", "ai", "agents", "claude", "cli"],
5
+ "keywords": [
6
+ "promptdock",
7
+ "skills",
8
+ "ai",
9
+ "agents",
10
+ "claude",
11
+ "cli"
12
+ ],
6
13
  "homepage": "https://promptdock.ai",
7
14
  "repository": {
8
15
  "type": "git",
9
16
  "url": "git+https://github.com/klicklabs/promptdock.ai.git",
10
17
  "directory": "packages/cli"
11
18
  },
12
- "bugs": { "url": "https://promptdock.ai/docs/cli/errors" },
13
- "license": "UNLICENSED",
19
+ "bugs": {
20
+ "url": "https://promptdock.ai/docs/cli/errors"
21
+ },
22
+ "license": "MIT",
14
23
  "type": "module",
15
- "bin": { "promptdock": "dist/index.js" },
16
- "files": ["dist", "README.md"],
17
- "engines": { "node": ">=18" },
24
+ "bin": {
25
+ "promptdock": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
18
34
  "scripts": {
19
35
  "build": "tsc -p tsconfig.json",
20
- "test": "vitest run",
21
- "prepublishOnly": "npm run build"
36
+ "test": "npm run typecheck && vitest run",
37
+ "prepublishOnly": "npm run build",
38
+ "typecheck": "tsc -p tsconfig.test.json"
22
39
  },
23
40
  "devDependencies": {
24
41
  "@types/node": "^20",
25
42
  "typescript": "^5",
26
43
  "vitest": "^4.1.8"
27
- }
44
+ },
45
+ "gitHead": "a2f9bd83dfba6c7f73a116687fede2e7b2f3c8cb"
28
46
  }