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/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,8 +126,29 @@ 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
- Every named error links `https://promptdock.ai/docs/cli/errors#<code>`.
131
+ Every named error links `https://promptdock.ai/docs/cli/errors#<code>`. That page
132
+ lists every code with what happened, why, and what to do.
133
+
134
+ ## Package compatibility
135
+
136
+ This CLI carries its own **install-safety limits** — how many files it will write and how
137
+ much it will download for one skill. They exist so a compromised server cannot fill your
138
+ disk or write outside the target directory, and they are deliberately far above anything
139
+ the platform accepts, so a normal skill never comes near them.
140
+
141
+ Occasionally a skill is published that is larger than the version of the CLI you have.
142
+ When that happens you get, for **that skill only**:
143
+
144
+ ```
145
+ "Big Skill" needs promptdock CLI 1.2.0 or newer — you are on 1.0.1.
146
+ Update: npx promptdock@latest install …
147
+ ```
148
+
149
+ It exits `3` (denied), not `4` (integrity) — the package is fine, your CLI is just older
150
+ than it. Your login, your other installs and `promptdock update` are unaffected;
151
+ `promptdock update` skips that one skill and continues with the rest.
112
152
 
113
153
  ## Notes
114
154
 
@@ -118,3 +158,16 @@ Every named error links `https://promptdock.ai/docs/cli/errors#<code>`.
118
158
  Review is a screening step, not a warranty — read what you install.
119
159
  - Skills are plain files on your machine after install; premium gates access,
120
160
  not copying.
161
+
162
+ ## License
163
+
164
+ [MIT](./LICENSE) © Prompt Dock, Inc.
165
+
166
+ Scope: the MIT license covers **this package (`packages/cli/`) only** — the CLI
167
+ client source published to npm. It does not cover the PromptDock service, the
168
+ website, or the rest of the repository, which are proprietary.
169
+
170
+ It also does not cover **the skills you install**. Those are third-party content
171
+ licensed by their authors under the
172
+ [PromptDock Terms](https://promptdock.ai/terms) — installing a skill grants you
173
+ 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";
@@ -27,7 +27,7 @@ export async function runInstall(ctx, positionals, flags) {
27
27
  const c = colors(ctx.env, ctx.io.isTTY);
28
28
  const { api } = await ensureAuth(ctx);
29
29
  const resolve = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(refString)}`);
30
- assertInstallable(resolve, refString);
30
+ assertInstallable(resolve, refString, ctx.version);
31
31
  if (typeof resolve.version_id !== "string" || typeof resolve.skill_id !== "string") {
32
32
  throw new CliError("malformed resolve response — update the CLI and retry", EXIT.DENIED, {
33
33
  footer: "verdict",
@@ -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;
@@ -138,6 +138,17 @@ function removeReceiptOwned(dir, receipt) {
138
138
  }
139
139
  }
140
140
  /* ── update ─────────────────────────────────────────────────────────────────── */
141
+ /**
142
+ * Errors no later item can recover from either — these still abort the batch.
143
+ *
144
+ * USAGE means the invocation itself is wrong (a confirmation needed in a non-interactive
145
+ * session), so every subsequent item would fail identically. AUTH means the token is gone.
146
+ * Everything else — an integrity refusal, an over-fuse package, a network blip on one
147
+ * download — is this item's problem and the next item deserves its turn.
148
+ */
149
+ function isFatalForBatch(err) {
150
+ return err instanceof CliError && (err.exitCode === EXIT.USAGE || err.exitCode === EXIT.AUTH);
151
+ }
141
152
  export async function runUpdate(ctx, positionals, flags) {
142
153
  const c = colors(ctx.env, ctx.io.isTTY);
143
154
  const checkOnly = flags.check === true;
@@ -159,7 +170,7 @@ export async function runUpdate(ctx, positionals, flags) {
159
170
  let resolved;
160
171
  try {
161
172
  resolved = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(receipt.ref)}`);
162
- assertInstallable(resolved, receipt.ref);
173
+ assertInstallable(resolved, receipt.ref, ctx.version);
163
174
  }
164
175
  catch (err) {
165
176
  const reason = err instanceof CliError ? err.message : String(err);
@@ -193,7 +204,7 @@ export async function runUpdate(ctx, positionals, flags) {
193
204
  if (flags.yes !== true) {
194
205
  if (!ctx.io.isTTY)
195
206
  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);
207
+ const go = await confirm(ctx.io, `Update ${receipt.ref} v${receipt.version} → v${toVersion}?`, true, c);
197
208
  if (!go) {
198
209
  report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason: "declined" });
199
210
  continue;
@@ -201,18 +212,43 @@ export async function runUpdate(ctx, positionals, flags) {
201
212
  }
202
213
  if (typeof resolved.skill_id !== "string" || typeof resolved.version_id !== "string")
203
214
  continue;
204
- const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolved.skill_id}/install`, { version_id: resolved.version_id });
205
- const resp = checkInstallResponse(respRaw);
206
- await performInstall(ctx, api, {
207
- resp,
208
- targetDir: item.dir,
209
- targetId: item.targetId,
210
- refString: receipt.ref,
211
- prior: receipt,
212
- });
213
- report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: resp.skill.version, status: "updated" });
214
- if (!json)
215
- ctx.io.out(`${c.green("✓")} ${receipt.ref}: v${receipt.version} → v${resp.skill.version}`);
215
+ /**
216
+ * ⚠️ THE INSTALL MUST BE INSIDE THE PER-ITEM CATCH TOO, or "one bad package skips and
217
+ * the rest continue" is false for the population that matters.
218
+ *
219
+ * The catch above wraps only resolve + `assertInstallable` — and `assertInstallable`
220
+ * is exactly what OLDER CLIs do not have. On those, the only thing that refuses an
221
+ * over-fuse package is `assertSafeManifest`, deep inside `performInstall`, i.e. out
222
+ * here beyond the guard. So the CliError propagated out of the loop and killed the
223
+ * whole run: one oversized skill in a directory of twenty aborted the other nineteen.
224
+ * That is the population the package-scoped `min_cli_version` design exists to serve,
225
+ * so the guarantee was false precisely where it was load-bearing.
226
+ *
227
+ * Genuinely fatal classes still propagate — a usage error or a newer-schema receipt
228
+ * is not something the next item can recover from either.
229
+ */
230
+ try {
231
+ const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolved.skill_id}/install`, { version_id: resolved.version_id });
232
+ const resp = checkInstallResponse(respRaw);
233
+ await performInstall(ctx, api, {
234
+ resp,
235
+ targetDir: item.dir,
236
+ targetId: item.targetId,
237
+ refString: receipt.ref,
238
+ prior: receipt,
239
+ });
240
+ report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: resp.skill.version, status: "updated" });
241
+ if (!json)
242
+ ctx.io.out(`${c.green("✓")} ${receipt.ref}: v${receipt.version} → v${resp.skill.version}`);
243
+ }
244
+ catch (err) {
245
+ if (isFatalForBatch(err))
246
+ throw err;
247
+ const reason = err instanceof CliError ? err.message : String(err);
248
+ report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason });
249
+ if (!json)
250
+ ctx.io.out(`${c.yellow("!")} ${receipt.ref}: ${reason}`);
251
+ }
216
252
  }
217
253
  if (json)
218
254
  ctx.io.out(JSON.stringify({ results: report }));
@@ -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;