promptdock 1.2.0 → 1.2.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # promptdock
2
2
 
3
- Install AI agent **skills** from [PromptDock](https://promptdock.ai) into Claude Code,
3
+ Install AI agent **skills** from [PromptDock](https://www.promptdock.ai) into Claude Code,
4
4
  Codex, Gemini CLI, Cursor, GitHub Copilot, and more.
5
5
 
6
6
  ```bash
@@ -9,13 +9,21 @@ npx promptdock@latest install <handle>/<slug>
9
9
 
10
10
  ## 60-second quickstart
11
11
 
12
+ Replace `<handle>/<slug>` with a real skill's ref — every skill page on
13
+ [promptdock.ai/skills](https://www.promptdock.ai/skills) shows its own install command,
14
+ ready to copy.
15
+
12
16
  ```bash
13
17
  # 1) Install a skill into this project (the CLI walks you through login + target)
14
- npx promptdock@latest install garry/seo-content-audit
18
+ npx promptdock@latest install <handle>/<slug>
15
19
 
16
20
  # 2) Approve the login code in your browser when prompted (one time per machine)
21
+ # No account yet? The page it opens has a sign-up link: create the account,
22
+ # confirm the email it sends, then approve. The code lasts 15 minutes for
23
+ # exactly this, the terminal waits, and if it does run out the CLI issues a
24
+ # fresh one once.
17
25
 
18
- # 3) Start a NEW session in Claude Code and type /seo-content-audit
26
+ # 3) Start a NEW session in Claude Code and type /<slug>
19
27
  ```
20
28
 
21
29
  **Auth model, stated plainly: login is required.** A free PromptDock account
@@ -29,8 +37,8 @@ profile's ACL is the boundary).
29
37
 
30
38
  | Command | What it does |
31
39
  | --- | --- |
32
- | `login [--token pdk_…]` | Browser hand-off login, or store a CI token from Settings → CLI sessions |
33
- | `logout` | Remove the local token (revoke in Settings to kill the session server-side) |
40
+ | `login [--token pdk_…]` | Browser hand-off login, or store a CI token from Settings → Account → CLI sessions |
41
+ | `logout` | Revoke the session server-side, then remove the local token |
34
42
  | `whoami [--json]` | Show the signed-in account + token expiry |
35
43
  | `install <ref> [options]` | Install a skill (`ref` = `handle/slug`, `@handle/slug`, or a pasted URL) |
36
44
  | `uninstall <ref \| --all>` | Remove an installed skill (receipt-driven — see below) |
@@ -41,6 +49,16 @@ Install options: `-g/--global` (per-user dir), `--target <tool>`, `--dir <path>`
41
49
  `-y/--yes` (skip prompts — **never** implies `--force`), `--force`, `--dry-run`
42
50
  (print the plan, install nothing), `--json`.
43
51
 
52
+ Update/uninstall options: `--check` (update only — report, apply nothing), `--all`,
53
+ `-g/--global`, `--force`, `--allow-partial` (see
54
+ [batch exit codes](#batch-exit-codes)), `--json`.
55
+
56
+ `logout` calls the server first: the token this machine holds stops working
57
+ immediately, and only then is the local copy deleted. If that call cannot get
58
+ through (offline), the local token is still removed and the CLI warns you on
59
+ stderr — that session stays valid until it expires, so revoke it under
60
+ **Settings → Account → CLI sessions**.
61
+
44
62
  ## Install targets
45
63
 
46
64
  | Target | Project install | Global (`-g`) | Status |
@@ -83,7 +101,7 @@ Non-TTY sessions never open a browser or show a picker. The contract:
83
101
 
84
102
  ```bash
85
103
  export PROMPTDOCK_TOKEN=pdk_… # Settings → Account → CLI sessions → Generate token
86
- npx promptdock@latest install garry/seo-content-audit --target claude -y
104
+ npx promptdock@latest install <handle>/<slug> --target claude -y
87
105
  ```
88
106
 
89
107
  - `PROMPTDOCK_TOKEN` — bearer token (overrides any stored login).
@@ -94,6 +112,71 @@ npx promptdock@latest install garry/seo-content-audit --target claude -y
94
112
  - Without `--target`/`--dir` **and** `-y`, a non-interactive install exits `1`
95
113
  with the exact flags to add. Without a token it exits `2`.
96
114
  - `NO_COLOR` is honored.
115
+ - Installs are **latest-only** — there is no version pinning. A build that must be
116
+ byte-reproducible should vendor the installed files, not re-resolve them.
117
+ - Node's built-in `fetch` does **not** read `HTTPS_PROXY`/`HTTP_PROXY` by itself.
118
+ Recent Node versions opt in with `NODE_USE_ENV_PROXY=1`; if yours does not
119
+ support it, the CLI cannot use your proxy. When a request fails it names the
120
+ proxy variable it found, so the cause is not a mystery.
121
+
122
+ ### Batch exit codes
123
+
124
+ `update` and `uninstall` can act on several skills at once (`--all`, or a scope
125
+ holding more than one). One bad item no longer aborts the rest — the others are
126
+ still processed — and the command then **exits with that item's code**:
127
+
128
+ ```bash
129
+ npx promptdock@latest update --all -y # exits 4 if one skill has local edits
130
+ npx promptdock@latest update --all -y --allow-partial # same report, exits 0
131
+ ```
132
+
133
+ What counts as a failure: unreachable server, a tier/cap refusal, a package this
134
+ CLI is too old for, an unreadable receipt, or local edits blocking an overwrite.
135
+ What does **not**: a skill already up to date, an available update under
136
+ `--check`, or a human answering "no" to a confirm.
137
+
138
+ ⚠️ Before 1.2.1 every one of those was reported as `skipped` and the command
139
+ exited `0`, so a CI step could go green having updated nothing.
140
+
141
+ ### `--json` output
142
+
143
+ Every shape below is printed to **stdout** as a single line; human progress text
144
+ and warnings go to stderr. Log in first — an interactive login hand-off prints to
145
+ stdout, so in CI set `PROMPTDOCK_TOKEN` and pass `-y`, which also stops the
146
+ confirms from painting a picker over your JSON.
147
+
148
+ | Command | Shape |
149
+ | --- | --- |
150
+ | `whoami --json` | `{ handle, role, expires_at }` |
151
+ | `list --json` | `[{ dir, target, scope, ref, version, version_id, installed_at, schema }]` — an unreadable receipt yields `{ dir, target, scope, receipt: "newer_schema" \| "invalid" }` instead |
152
+ | `install --json` | `{ installed: true, update, ref, version, version_id, target, path, files }` |
153
+ | `install --json` (nothing to do) | `{ installed: false, up_to_date: true, update: true, ref, version, version_id, target, path }` |
154
+ | `install --dry-run --json` | `{ dry_run: true, ref, skill_id, version_id, version, title, author, is_free, file_count, total_bytes, target, path, update }` |
155
+ | `update --json` | `{ results: [ … ] }` — always, including when nothing is installed (`{ results: [] }`) |
156
+ | `uninstall --json` | `{ uninstalled: [{ ref, dir }], results: [ … ] }` |
157
+
158
+ A `results` row is `{ ref, dir, status, reason?, failed?, exit?, from?, to? }`:
159
+
160
+ - `status` — `updated` · `up_to_date` · `available` (`--check`) · `uninstalled` ·
161
+ `skipped` (declined, or failed).
162
+ - `failed` — present and `true` **only** for a real failure. This is the field to
163
+ branch on; `status` keeps the vocabulary 1.2.0 shipped.
164
+ - `exit` — that failure's own exit code. The process exits with the first one.
165
+ - `from`/`to` — version numbers, on `update` rows only.
166
+
167
+ A **failure** that produced no report of its own is written to stdout as one document:
168
+
169
+ ```json
170
+ { "error": { "code": "existing_dir", "message": "…", "exit_code": 5, "doc_url": "https://www.promptdock.ai/docs/cli/errors#existing_dir" } }
171
+ ```
172
+
173
+ `code` is the docs anchor (`"error"` when the failure has none, and `doc_url` then
174
+ points at the index). `update`/`uninstall` do **not** add this — their `results` report
175
+ already answers the same question per item — so stdout is always exactly one JSON
176
+ value, on every path.
177
+
178
+ New keys may be added in a patch release; existing keys and `status` values will
179
+ not change meaning.
97
180
 
98
181
  ## Receipts, update, uninstall
99
182
 
@@ -108,8 +191,9 @@ skill id, ref, version, and the sha256 of every installed file. It is the
108
191
  any emptied directories. Files you added are never touched.
109
192
  - If you **edited** installed files, update/uninstall refuse without `--force`
110
193
  ("copy your changes out first") — your local changes never die to a default.
111
- - A receipt written by a newer CLI version is left alone with a
112
- "run `npx promptdock@latest`" notice — never a guessy uninstall.
194
+ - A receipt written by a newer CLI version is left alone with a notice naming
195
+ the exact `npx promptdock@latest …` command to re-run — never a guessy
196
+ uninstall. It counts as a failed item (see [batch exit codes](#batch-exit-codes)).
113
197
 
114
198
  Downloads are verified against the server manifest (per-file sha256) and every
115
199
  path is re-validated before any write; a failed check aborts **all-or-nothing**
@@ -128,9 +212,19 @@ path is re-validated before any write; a failed check aborts **all-or-nothing**
128
212
  | 6 | network |
129
213
  | 130 | interrupted (`Ctrl-C` during an interactive prompt; `Esc` — a deliberate in-UI cancel — exits 1) |
130
214
 
131
- Every named error links `https://promptdock.ai/docs/cli/errors#<code>`. That page
215
+ Every named error links `https://www.promptdock.ai/docs/cli/errors#<code>`. That page
132
216
  lists every code with what happened, why, and what to do.
133
217
 
218
+ ## Getting help
219
+
220
+ - **A named error, an exit code:** [promptdock.ai/docs/cli/errors](https://www.promptdock.ai/docs/cli/errors) —
221
+ every code, with what happened and what to do. The link the CLI prints goes straight
222
+ to the entry.
223
+ - **Anything else:** [promptdock.ai/contact](https://www.promptdock.ai/contact) names the
224
+ right mailbox for the question (account, billing, the CLI). Include the command you
225
+ ran, `promptdock --version`, and the error text.
226
+ - `npm bugs promptdock` opens the errors page from your terminal.
227
+
134
228
  ## Package compatibility
135
229
 
136
230
  This CLI carries its own **install-safety limits** — how many files it will write and how
@@ -143,12 +237,16 @@ When that happens you get, for **that skill only**:
143
237
 
144
238
  ```
145
239
  "Big Skill" needs promptdock CLI 1.2.0 or newer — you are on 1.0.1.
146
- Update: npx promptdock@latest install
240
+ Update: npx promptdock@latest install <handle>/<slug> (or: npm i -g promptdock@latest)
147
241
  ```
148
242
 
243
+ (the real message names the skill you asked for, so the second line is a command
244
+ you can paste as-is)
245
+
149
246
  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.
247
+ than it. Your login and your other installs are unaffected: `promptdock update`
248
+ updates everything else and then exits `3` for the one it could not do (see
249
+ [batch exit codes](#batch-exit-codes)).
152
250
 
153
251
  ## Notes
154
252
 
@@ -169,5 +267,5 @@ website, or the rest of the repository, which are proprietary.
169
267
 
170
268
  It also does not cover **the skills you install**. Those are third-party content
171
269
  licensed by their authors under the
172
- [PromptDock Terms](https://promptdock.ai/terms) — installing a skill grants you
270
+ [PromptDock Terms](https://www.promptdock.ai/terms) — installing a skill grants you
173
271
  access to use it, not the rights this MIT license grants over the CLI itself.
package/dist/api.d.ts CHANGED
@@ -9,6 +9,29 @@ export type RawResponse = {
9
9
  body: unknown;
10
10
  headers: Headers;
11
11
  };
12
+ /**
13
+ * The server ErrorCodes that have their OWN anchor on /docs/cli/errors.
14
+ *
15
+ * `env.code` is a string lifted off an untrusted response body, and it used to become
16
+ * the docs anchor verbatim (`footer: env?.code ?? "api"`). Anything a proxy, a captive
17
+ * portal, a future server or a plain typo put there therefore became
18
+ * `…/docs/cli/errors#whatever` — a link printed to a stuck user that scrolls nowhere.
19
+ * The fallbacks are the point of the `??`, and they were unreachable for every string
20
+ * that was not literally absent.
21
+ *
22
+ * ⚠️ TWO CODES ARE DELIBERATELY ABSENT FROM THE APP'S VOCABULARY HERE, and it is not
23
+ * an oversight: `unauthenticated` / `invalid_token` are ALIASED to the `auth` entry in
24
+ * lib/cli/error-docs.ts, and `idempotency_key_required` is
25
+ * aliased to `api`. An alias has no anchor of its own, so passing one through would
26
+ * mint exactly the dead link this list exists to prevent — they fall back instead,
27
+ * which is where their doc entries already say they belong.
28
+ *
29
+ * ⚠️ COUPLING (guarded by test/footer-codes.test.ts, which reads both files as text):
30
+ * every entry must be an app ErrorCode (lib/errors.ts) AND carry its own `slug` in
31
+ * lib/cli/error-docs.ts. The CLI cannot import either — packages/cli ships to npm on
32
+ * its own — so a test is the only seam that can hold the two vocabularies together.
33
+ */
34
+ export declare const DOCUMENTED_SERVER_CODES: ReadonlySet<string>;
12
35
  export declare class Api {
13
36
  private readonly ctx;
14
37
  readonly baseUrl: string;
@@ -19,7 +42,27 @@ export declare class Api {
19
42
  raw(method: string, path: string, body?: unknown): Promise<RawResponse>;
20
43
  /** Envelope-aware request: 2xx → typed body; anything else → a mapped CliError. */
21
44
  request<T>(method: string, path: string, body?: unknown): Promise<T>;
22
- /** Extract {error:{code,message,details}} defensively (untrusted input). */
45
+ /**
46
+ * Extract {error:{code,message,details}} defensively (untrusted input).
47
+ *
48
+ * ⚠️ THIS IS THE TRUST BOUNDARY, and `message` is sanitized HERE rather than at the
49
+ * five sites that print it. Every one of those sites reads this function's output —
50
+ * and so does auth.ts's device-flow poll, which builds its own CliError and never
51
+ * touches `mapEnvelopeError` at all. Sanitizing per call site would have covered
52
+ * five of six and left the sixth to be found later, which is exactly how the
53
+ * original gap (one covered site, five raw) came about.
54
+ *
55
+ * ⚠️ `details` is deliberately NOT sanitized: it is an opaque bag whose values are
56
+ * read for meaning, not just printed (`min_version`, `retry_after_secs`), and
57
+ * blanket-sanitizing a `Record<string, unknown>` would coerce non-strings. Every
58
+ * read site that PRINTS a details value owes it a `terminalLine` — see the 426
59
+ * remedy in `mapEnvelopeError`, the only such site today.
60
+ *
61
+ * ⚠️ `code` is not sanitized either, because it never reaches the terminal on its
62
+ * own: `documentedFooter` gates it against a frozen 12-slug allowlist before it can
63
+ * become a docs anchor. The allowlist is what makes that safe, so do not relax it
64
+ * into a passthrough.
65
+ */
23
66
  static envelope(body: unknown): ApiEnvelopeError | null;
24
67
  private mapEnvelopeError;
25
68
  }
package/dist/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_API_BASE } from "./config.js";
2
2
  import { CliError, EXIT, networkError } from "./errors.js";
3
+ import { LOCATION_MAX, SERVER_MESSAGE_MAX, SERVER_REMEDY_MAX, terminalLine, } from "./terminal-text.js";
3
4
  /**
4
5
  * The redirect target may only become an ACTIONABLE hint when it is trustworthy
5
6
  * (review CRITICAL): a 3xx can come from a captive portal, hijacked DNS, or a
@@ -25,17 +26,56 @@ function trustedRedirectOrigin(location, baseUrl) {
25
26
  return null; // relative/unparseable Location — never actionable
26
27
  }
27
28
  }
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;
37
- }
38
- return out.slice(0, 200);
29
+ /**
30
+ * The server ErrorCodes that have their OWN anchor on /docs/cli/errors.
31
+ *
32
+ * `env.code` is a string lifted off an untrusted response body, and it used to become
33
+ * the docs anchor verbatim (`footer: env?.code ?? "api"`). Anything a proxy, a captive
34
+ * portal, a future server or a plain typo put there therefore became
35
+ * `…/docs/cli/errors#whatever` a link printed to a stuck user that scrolls nowhere.
36
+ * The fallbacks are the point of the `??`, and they were unreachable for every string
37
+ * that was not literally absent.
38
+ *
39
+ * ⚠️ TWO CODES ARE DELIBERATELY ABSENT FROM THE APP'S VOCABULARY HERE, and it is not
40
+ * an oversight: `unauthenticated` / `invalid_token` are ALIASED to the `auth` entry in
41
+ * lib/cli/error-docs.ts, and `idempotency_key_required` is
42
+ * aliased to `api`. An alias has no anchor of its own, so passing one through would
43
+ * mint exactly the dead link this list exists to prevent — they fall back instead,
44
+ * which is where their doc entries already say they belong.
45
+ *
46
+ * ⚠️ COUPLING (guarded by test/footer-codes.test.ts, which reads both files as text):
47
+ * every entry must be an app ErrorCode (lib/errors.ts) AND carry its own `slug` in
48
+ * lib/cli/error-docs.ts. The CLI cannot import either — packages/cli ships to npm on
49
+ * its own — so a test is the only seam that can hold the two vocabularies together.
50
+ */
51
+ export const DOCUMENTED_SERVER_CODES = new Set([
52
+ // Web-only today: no /api/v1/cli route carries `refuseWhenClosing`, because installing a
53
+ // skill takes on no obligation to settle. Passed through anyway — the day one does, the
54
+ // entry is already reachable, and a code that cannot arrive costs nothing here
55
+ // (`documentedFooter` only ever maps a code a response actually carried).
56
+ "account_closing",
57
+ "account_disabled",
58
+ // Reachable from EVERY cli-token route: the spine refuses a suspended account before the
59
+ // handler runs and no /cli route is `allowDisabled`, so this is the one a stuck user hits
60
+ // on `whoami` as readily as on `install`.
61
+ "account_suspended",
62
+ "conflict",
63
+ "insufficient_tier",
64
+ "internal",
65
+ "mfa_required",
66
+ "not_found",
67
+ "not_owner",
68
+ "rate_limited",
69
+ // Web-only today (payout re-authentication); passed through so the entry is reachable if it ever arrives.
70
+ "reauth_required",
71
+ "service_unavailable",
72
+ "upgrade_required",
73
+ "validation_error",
74
+ "version_conflict",
75
+ ]);
76
+ /** The server's code as a docs anchor, or undefined so the caller's fallback wins. */
77
+ function documentedFooter(code) {
78
+ return code !== undefined && DOCUMENTED_SERVER_CODES.has(code) ? code : undefined;
39
79
  }
40
80
  export class Api {
41
81
  ctx;
@@ -82,7 +122,7 @@ export class Api {
82
122
  // ctx.fetch directly and must keep following them — storage hands out redirects.
83
123
  if (res.status >= 300 && res.status < 400) {
84
124
  const to = res.headers.get("location") ?? "(no location header)";
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, {
125
+ throw new CliError(`${this.baseUrl} redirected to ${terminalLine(to, LOCATION_MAX)} — the API must be called on its canonical origin, because a redirect drops the request body and the login token.`, EXIT.NETWORK, {
86
126
  // An UNTRUSTED target (cross-domain / non-https — a captive portal or
87
127
  // MITM can mint a 3xx) gets the STATIC known-good hint, never its own
88
128
  // origin echoed back as an instruction. DEFAULT_API_BASE is imported,
@@ -108,7 +148,27 @@ export class Api {
108
148
  return res.body;
109
149
  throw this.mapEnvelopeError(res);
110
150
  }
111
- /** Extract {error:{code,message,details}} defensively (untrusted input). */
151
+ /**
152
+ * Extract {error:{code,message,details}} defensively (untrusted input).
153
+ *
154
+ * ⚠️ THIS IS THE TRUST BOUNDARY, and `message` is sanitized HERE rather than at the
155
+ * five sites that print it. Every one of those sites reads this function's output —
156
+ * and so does auth.ts's device-flow poll, which builds its own CliError and never
157
+ * touches `mapEnvelopeError` at all. Sanitizing per call site would have covered
158
+ * five of six and left the sixth to be found later, which is exactly how the
159
+ * original gap (one covered site, five raw) came about.
160
+ *
161
+ * ⚠️ `details` is deliberately NOT sanitized: it is an opaque bag whose values are
162
+ * read for meaning, not just printed (`min_version`, `retry_after_secs`), and
163
+ * blanket-sanitizing a `Record<string, unknown>` would coerce non-strings. Every
164
+ * read site that PRINTS a details value owes it a `terminalLine` — see the 426
165
+ * remedy in `mapEnvelopeError`, the only such site today.
166
+ *
167
+ * ⚠️ `code` is not sanitized either, because it never reaches the terminal on its
168
+ * own: `documentedFooter` gates it against a frozen 12-slug allowlist before it can
169
+ * become a docs anchor. The allowlist is what makes that safe, so do not relax it
170
+ * into a passthrough.
171
+ */
112
172
  static envelope(body) {
113
173
  if (typeof body !== "object" || body === null)
114
174
  return null;
@@ -118,9 +178,14 @@ export class Api {
118
178
  const e = err;
119
179
  if (typeof e.code !== "string")
120
180
  return null;
181
+ // An all-control message sanitizes to "", which would print a blank line where a
182
+ // reason belongs — so fall back to the code, and note that every call site below
183
+ // uses `||` (not `??`) so an empty result still reaches the CLI's own sentence.
184
+ const raw = typeof e.message === "string" ? e.message : e.code;
185
+ const message = terminalLine(raw, SERVER_MESSAGE_MAX) || terminalLine(e.code, SERVER_MESSAGE_MAX);
121
186
  return {
122
187
  code: e.code,
123
- message: typeof e.message === "string" ? e.message : e.code,
188
+ message,
124
189
  details: typeof e.details === "object" && e.details !== null
125
190
  ? e.details
126
191
  : undefined,
@@ -130,15 +195,31 @@ export class Api {
130
195
  const env = Api.envelope(res.body);
131
196
  // 426 version floor: print the SERVER's remedy verbatim (DX3/F8) — the
132
197
  // server knows the canonical command better than a stale client does.
198
+ //
199
+ // ⚠️ "VERBATIM" MEANS "DO NOT REWRITE THE SERVER'S WORDS", NOT "DO NOT SANITIZE".
200
+ // The reason the comment above gives is that a stale client must not substitute
201
+ // its own command — an argument about WHICH command, not about which bytes. Our
202
+ // own server already strips escapes and newlines from this string before sending
203
+ // it (lib/cli-auth/protocol.ts `upgradeRemedy`, a strict allowlist), so sanitizing
204
+ // here changes nothing an honest server sends and only alters a hostile one's.
205
+ // Stripping control characters preserves copy-pasteability; it is truncation and
206
+ // newline-collapsing that would destroy it, which is why the two halves are
207
+ // sanitized SEPARATELY and joined afterwards.
208
+ //
209
+ // ⚠️ SANITIZE BEFORE THE JOIN, NEVER AFTER. The `\n` below is the CLIENT's, not
210
+ // the server's, so a single pass over the joined string would delete the deliberate
211
+ // line break AND measure the cap against ~215 characters worst case, cutting the
212
+ // tail off the very command the remedy exists to provide.
133
213
  if (res.status === 426) {
134
- const remedy = typeof env?.details?.remedy === "string" ? env.details.remedy : null;
135
- const msg = env?.message ?? "This promptdock CLI version is no longer supported.";
214
+ const rawRemedy = typeof env?.details?.remedy === "string" ? env.details.remedy : null;
215
+ const remedy = rawRemedy ? terminalLine(rawRemedy, SERVER_REMEDY_MAX) : "";
216
+ const msg = env?.message || "This promptdock CLI version is no longer supported.";
136
217
  return new CliError(remedy ? `${msg}\n${remedy}` : msg, EXIT.DENIED, {
137
218
  footer: "upgrade_required",
138
219
  });
139
220
  }
140
221
  if (res.status === 401) {
141
- return new CliError(env?.message ?? "Not authenticated.", EXIT.AUTH, {
222
+ return new CliError(env?.message || "Not authenticated.", EXIT.AUTH, {
142
223
  footer: "auth",
143
224
  hint: "run: npx promptdock@latest login",
144
225
  });
@@ -147,12 +228,12 @@ export class Api {
147
228
  const retryAfter = res.headers.get("retry-after");
148
229
  const secs = retryAfter ? Number(retryAfter) : null;
149
230
  const wait = secs && Number.isFinite(secs) ? ` Retry in ~${Math.ceil(secs / 60)} min.` : "";
150
- return new CliError((env?.message ?? "Rate limited.") + wait, EXIT.DENIED, {
151
- footer: env?.code ?? "rate_limited",
231
+ return new CliError((env?.message || "Rate limited.") + wait, EXIT.DENIED, {
232
+ footer: documentedFooter(env?.code) ?? "rate_limited",
152
233
  });
153
234
  }
154
- return new CliError(env?.message ?? `request failed (HTTP ${res.status})`, EXIT.DENIED, {
155
- footer: env?.code ?? "api",
235
+ return new CliError(env?.message || `request failed (HTTP ${res.status})`, EXIT.DENIED, {
236
+ footer: documentedFooter(env?.code) ?? "api",
156
237
  });
157
238
  }
158
239
  }
package/dist/args.js CHANGED
@@ -16,11 +16,11 @@ export const COMMAND_SPECS = {
16
16
  values: ["target", "dir"],
17
17
  },
18
18
  uninstall: {
19
- booleans: ["help", "json", "global", "yes", "force", "all"],
19
+ booleans: ["help", "json", "global", "yes", "force", "all", "allow-partial"],
20
20
  values: ["target", "dir"],
21
21
  },
22
22
  update: {
23
- booleans: ["help", "json", "global", "yes", "force", "all", "check"],
23
+ booleans: ["help", "json", "global", "yes", "force", "all", "check", "allow-partial"],
24
24
  values: ["target", "dir"],
25
25
  },
26
26
  list: { booleans: ["help", "json", "global", "all"], values: [] },
package/dist/auth.js CHANGED
@@ -5,6 +5,7 @@ import { hostname } from "node:os";
5
5
  import { Api } from "./api.js";
6
6
  import { loadConfig, resolveApiBase, resolveToken, saveConfig } from "./config.js";
7
7
  import { CliError, EXIT } from "./errors.js";
8
+ import { LOCATION_MAX, SERVER_LABEL_MAX, terminalLine } from "./terminal-text.js";
8
9
  import { formatCountdown } from "./ui.js";
9
10
  /**
10
11
  * Run the browser hand-off and return the bearer token. NEVER logs the token.
@@ -19,12 +20,16 @@ export async function deviceFlowLogin(ctx, baseUrl, opts) {
19
20
  device_label: safeDeviceLabel(),
20
21
  long: opts.long,
21
22
  });
23
+ // ⚠️ PRE-AUTH SINK. `/cli/auth/start` needs no token, so anything that can answer
24
+ // this request — a hostile origin, a captive portal, a MITM — chooses these two
25
+ // strings, and `api.request` returns `res.body as T` with no runtime validation.
26
+ // Sanitized for DISPLAY only; the raw `verification_url` is what gets opened.
22
27
  ctx.io.out("");
23
28
  ctx.io.out("Confirm this code in your browser:");
24
29
  ctx.io.out("");
25
- ctx.io.out(` ${start.user_code}`);
30
+ ctx.io.out(` ${terminalLine(start.user_code, SERVER_LABEL_MAX)}`);
26
31
  ctx.io.out("");
27
- ctx.io.out(`Open: ${start.verification_url}`);
32
+ ctx.io.out(`Open: ${terminalLine(start.verification_url, LOCATION_MAX)}`);
28
33
  if (ctx.io.isTTY) {
29
34
  ctx.openUrl(start.verification_url);
30
35
  ctx.io.out("(if the page didn't open, paste the URL into your browser)");
@@ -5,7 +5,9 @@
5
5
  import { existsSync, readdirSync } from "node:fs";
6
6
  import { isAbsolute, resolve as resolvePath } from "node:path";
7
7
  import { ensureAuth } from "../auth.js";
8
+ import { newerSchemaError, rerunCommand } from "./lifecycle.js";
8
9
  import { CliError, EXIT, usageError } from "../errors.js";
10
+ import { SERVER_LABEL_MAX, SERVER_PATH_MAX, terminalLine } from "../terminal-text.js";
9
11
  import { checkInstallResponse, performInstall } from "../installer.js";
10
12
  import { formatRef, parseSkillRef } from "../ref.js";
11
13
  import { detectionLabel, detectTargets, detectTargetsAnyScope, nextStepLine, scopeChoices, targetById, targetInstallDir, TARGETS, } from "../registry.js";
@@ -38,7 +40,7 @@ export async function runInstall(ctx, positionals, flags) {
38
40
  // ── receipt awareness (DX3/DX5) ────────────────────────────────────────────
39
41
  const existing = readReceipt(picked.dir);
40
42
  if (existing.kind === "newer_schema") {
41
- throw new CliError(`${picked.dir} was installed by a newer promptdock CLI (receipt schema ${existing.schema}). Run: npx promptdock@latest ${ctx.argsLine}`, EXIT.INTEGRITY, { footer: "receipt_schema" });
43
+ throw newerSchemaError(picked.dir, existing.schema, rerunCommand("install", positionals, flags));
42
44
  }
43
45
  const matching = existing.kind === "ok" && existing.receipt.skill_id === resolve.skill_id
44
46
  ? existing.receipt
@@ -51,25 +53,50 @@ export async function runInstall(ctx, positionals, flags) {
51
53
  }
52
54
  if (isUpdate && matching.version_id === resolve.version_id && flags.force !== true && !dryRun) {
53
55
  ctx.io.out(json
54
- ? JSON.stringify({ installed: false, up_to_date: true, version: matching.version, path: picked.dir })
55
- : `"${resolve.title ?? refString}" is already up to date (v${matching.version}) at ${picked.dir} — use --force to reinstall.`);
56
+ ? JSON.stringify({
57
+ installed: false,
58
+ up_to_date: true,
59
+ update: true,
60
+ ref: refString,
61
+ version: matching.version,
62
+ version_id: matching.version_id,
63
+ target: picked.id,
64
+ path: picked.dir,
65
+ })
66
+ : `"${terminalLine(resolve.title, SERVER_LABEL_MAX) || refString}" is already up to date (v${matching.version}) at ${picked.dir} — use --force to reinstall.`);
56
67
  return;
57
68
  }
69
+ // ⚠️ SANITIZE THE FRAGMENTS, NOT THE LINES. The summary pads its labels into
70
+ // columns (" version: v3"), and `terminalLine` collapses whitespace runs — one
71
+ // pass over a finished line would flatten every one of them.
72
+ const safeTitle = terminalLine(resolve.title, SERVER_LABEL_MAX) || refString;
73
+ const safeAuthor = terminalLine(resolve.author, SERVER_LABEL_MAX) || ref.handle;
74
+ const safeLicense = terminalLine(resolve.license, SERVER_LABEL_MAX);
75
+ // ⚠️ `version` IS TYPED `number` AND IS NOT ONE. It arrives through the same bare
76
+ // `res.body as T` cast as everything else, and `checkInstallResponse` validates
77
+ // `skill.version_id` and `skill.slug` but NOT `skill.version` — so a hostile string
78
+ // used to render raw right beside the title this block sanitizes.
79
+ const ver = (v) => terminalLine(String(v), SERVER_LABEL_MAX);
80
+ // A filesystem path, not a server string — but `readdirSync` entry names may contain
81
+ // anything except `/` and NUL, and this is the ONE line that tells the user where
82
+ // files are about to be written. A crafted directory name could erase it, which
83
+ // defeats confirm-before-write itself.
84
+ const safeDir = terminalLine(picked.dir, SERVER_PATH_MAX);
58
85
  // ── summary block (D-UX11 confirm-before-write) ────────────────────────────
59
86
  const summary = [];
60
87
  if (isUpdate) {
61
- summary.push(`Update ${c.bold(`"${resolve.title ?? refString}"`)} by @${resolve.author ?? ref.handle}`);
62
- summary.push(` version: v${matching.version} → v${resolve.version}`);
88
+ summary.push(`Update ${c.bold(`"${safeTitle}"`)} by @${safeAuthor}`);
89
+ summary.push(` version: v${ver(matching.version)} → v${ver(resolve.version)}`);
63
90
  }
64
91
  else {
65
- summary.push(`Install ${c.bold(`"${resolve.title ?? refString}"`)} by @${resolve.author ?? ref.handle}`);
66
- summary.push(` version: v${resolve.version}`);
92
+ summary.push(`Install ${c.bold(`"${safeTitle}"`)} by @${safeAuthor}`);
93
+ summary.push(` version: v${ver(resolve.version)}`);
67
94
  }
68
95
  summary.push(` files: ${resolve.file_count ?? "?"} files · ${formatBytes(Number(resolve.total_bytes) || 0)}`);
69
- if (resolve.license)
70
- summary.push(` license: ${resolve.license}`);
96
+ if (safeLicense)
97
+ summary.push(` license: ${safeLicense}`);
71
98
  summary.push(` access: ${resolve.is_free === false ? "premium" : "free"}`);
72
- summary.push(` to: ${picked.dir}`);
99
+ summary.push(` to: ${safeDir}`);
73
100
  if (dryRun) {
74
101
  if (json) {
75
102
  ctx.io.out(JSON.stringify({
@@ -114,8 +141,15 @@ export async function runInstall(ctx, positionals, flags) {
114
141
  if (!json) {
115
142
  ctx.io.out(`Downloading ${resp.manifest.length} files…`);
116
143
  const shown = resp.manifest.slice(0, 8);
117
- for (const e of shown)
118
- ctx.io.out(` ${e.path} ${c.dim(`(${formatBytes(e.bytes)})`)}`);
144
+ // ⚠️ DISPLAY ONLY. `e.path` names the file `performInstall` writes and the sha256
145
+ // it verifies against, so the value itself must stay byte-identical — sanitizing
146
+ // it at the source would change what the integrity check runs on. Note these are
147
+ // printed BEFORE `assertSafeManifest` validates them (checkInstallResponse →
148
+ // print → performInstall → validate), so a path that is about to be REFUSED is
149
+ // rendered here; that ordering is what makes sanitizing this line necessary.
150
+ for (const e of shown) {
151
+ ctx.io.out(` ${terminalLine(e.path, SERVER_PATH_MAX)} ${c.dim(`(${formatBytes(e.bytes)})`)}`);
152
+ }
119
153
  if (resp.manifest.length > shown.length) {
120
154
  ctx.io.out(c.dim(` +${resp.manifest.length - shown.length} more`));
121
155
  }
@@ -141,7 +175,7 @@ export async function runInstall(ctx, positionals, flags) {
141
175
  return;
142
176
  }
143
177
  ctx.io.out("");
144
- ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${resp.skill.title}" v${resp.skill.version} → ${picked.dir}`);
178
+ ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${terminalLine(resp.skill.title, SERVER_LABEL_MAX)}" v${terminalLine(String(resp.skill.version), SERVER_LABEL_MAX)} → ${terminalLine(picked.dir, SERVER_PATH_MAX)}`);
145
179
  ctx.io.out(nextStepLine(picked.id, resp.skill.slug));
146
180
  }
147
181
  /**
@@ -1,5 +1,19 @@
1
1
  import type { CliContext } from "../context.js";
2
+ import { CliError } from "../errors.js";
2
3
  type Flags = Record<string, string | boolean>;
4
+ /**
5
+ * A re-run line built from the PARSED invocation — never from `ctx.argsLine`.
6
+ *
7
+ * `argsLine` is the REDACTED copy that rides `X-Promptdock-Cli-Args`: context.ts
8
+ * replaces `--token` and `--dir` values with `***` so a live bearer and an absolute
9
+ * home path never reach the server or a proxy log. Echoing it back into a "Run: …"
10
+ * remedy printed `npx promptdock@latest install a/b --dir ***` — a command that
11
+ * cannot be pasted, handed to the user at the one moment they need a command they
12
+ * can paste. The parsed flags hold the REAL values, so the remedy is built from
13
+ * those and the redaction stays where it belongs: on the wire.
14
+ */
15
+ export declare function rerunCommand(command: string, positionals: string[], flags: Flags): string;
16
+ export declare function newerSchemaError(dir: string, schema: number, rerun: string): CliError;
3
17
  export declare function runUninstall(ctx: CliContext, positionals: string[], flags: Flags): Promise<void>;
4
18
  export declare function runUpdate(ctx: CliContext, positionals: string[], flags: Flags): Promise<void>;
5
19
  export declare function runList(ctx: CliContext, flags: Flags): Promise<void>;