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/dist/context.js CHANGED
@@ -88,15 +88,87 @@ export function readOwnVersion() {
88
88
  return "0.0.0";
89
89
  }
90
90
  }
91
- /** Best-effort platform browser open (darwin `open`, win32 `start`, else xdg-open). */
91
+ /**
92
+ * The ONLY shape allowed to reach a platform opener, or null to refuse.
93
+ *
94
+ * ⚠️ THE URL IS UNTRUSTED. `verification_url` comes from the UNAUTHENTICATED
95
+ * `/api/v1/cli/auth/start` response through `Api.request`, which is `res.body as T` — a
96
+ * bare cast, as contract.ts says in its own header. The OS opener is a far more
97
+ * dangerous sink than the terminal: it is the one path in this CLI with an effect
98
+ * outside the process.
99
+ *
100
+ * Measured on darwin: `open` treats a non-URL argument as a FILE PATH (so a value like
101
+ * `/Applications/Calculator.app` launches it), reads a leading `-` as a FLAG, and
102
+ * dispatches any scheme to its registered handler — which is how `file:`, `smb:` and
103
+ * `ms-msdt:` become interesting.
104
+ */
105
+ export function openableUrl(raw) {
106
+ let u;
107
+ try {
108
+ u = new URL(raw);
109
+ }
110
+ catch {
111
+ return null; // not a URL at all — a UNC path and a bare `-h` both land here
112
+ }
113
+ const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
114
+ // Mirrors config.ts's TLS floor (`assertSecureBase`). Blocks file:, smb:, javascript:,
115
+ // vbscript: and every app-registered scheme from reaching the platform opener.
116
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback))
117
+ return null;
118
+ return u.toString();
119
+ }
120
+ /**
121
+ * The `[command, args]` a platform open would spawn, or null when it is refused.
122
+ *
123
+ * Split out from the spawn PURELY so the decision is testable: the whole matrix can be
124
+ * asserted with no child process, on any host OS. There was no test referencing
125
+ * `openUrlBestEffort` at all, which is how the win32 defect below survived.
126
+ */
127
+ export function openUrlCommand(url, platform) {
128
+ const safe = openableUrl(url);
129
+ if (!safe)
130
+ return null;
131
+ if (platform === "darwin") {
132
+ // `--` ends option parsing: measured, `open` reads a leading-dash first argument as
133
+ // a flag. Belt-and-braces over the scheme gate above, which a future edit could drift
134
+ // from.
135
+ return ["open", ["--", safe]];
136
+ }
137
+ if (platform === "win32") {
138
+ // ⚠️ NOT `cmd /c start`, WHICH WAS A COMMAND-INJECTION PRIMITIVE.
139
+ //
140
+ // libuv quotes arguments for the MSVCRT argv parser (`quote_cmd_arg`, src/win/
141
+ // process.c) and copies any argument containing no space, tab or quote VERBATIM.
142
+ // cmd.exe then parses that string by ITS OWN rules, where `&`, `|` and `^` are
143
+ // command separators and `\"` is not an escape. So a perfectly well-formed https URL
144
+ // — `https://host/cli-auth?code=A&calc` — reached cmd.exe as two commands.
145
+ //
146
+ // ⚠️ AND A SCHEME CHECK ALONE DOES NOT CLOSE IT: `new URL(u).toString()`
147
+ // percent-encodes space, quotes and control characters but PRESERVES `&`, `|` and
148
+ // `^`, and `&` is ordinary in a query string — so no URL-shape validation short of a
149
+ // character allowlist (which would break legitimate multi-parameter URLs) helps. The
150
+ // fix has to be not going through cmd.exe.
151
+ //
152
+ // This is the BatBadBut root cause (CVE-2024-27980). Node's fix does NOT cover it:
153
+ // that throws EINVAL only when the spawn TARGET is a .bat/.cmd file, and the target
154
+ // here was cmd.exe itself — so no Node version protected this call shape.
155
+ //
156
+ // explorer.exe is a normal PE, so libuv's CRT-style quoting is the CORRECT quoting
157
+ // for it and no second parser ever sees the string. (It always exits 1; the status
158
+ // was already ignored.)
159
+ return ["explorer.exe", [safe]];
160
+ }
161
+ return ["xdg-open", ["--", safe]];
162
+ }
163
+ /** Best-effort platform browser open (darwin `open`, win32 `explorer`, else xdg-open). */
92
164
  export function openUrlBestEffort(url, platform) {
165
+ const decided = openUrlCommand(url, platform);
166
+ // Refusing is SAFE and silent by design: auth.ts has already PRINTED the URL and tells
167
+ // the user to paste it if the browser did not open.
168
+ if (!decided)
169
+ return;
93
170
  try {
94
- const [cmd, args] = platform === "darwin"
95
- ? ["open", [url]]
96
- : platform === "win32"
97
- ? // `start` is a cmd.exe builtin; the empty "" is its window-title slot.
98
- ["cmd", ["/c", "start", "", url]]
99
- : ["xdg-open", [url]];
171
+ const [cmd, args] = decided;
100
172
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
101
173
  child.on("error", () => undefined);
102
174
  child.unref();
package/dist/errors.d.ts CHANGED
@@ -19,6 +19,14 @@ export declare const EXIT: {
19
19
  readonly INTERRUPT: 130;
20
20
  };
21
21
  export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
22
+ /** The error catalogue itself — the anchor-less landing page for a failure with no slug.
23
+ *
24
+ * ⚠️ DERIVED from `CANONICAL_ORIGIN`, not a literal, and the reason is not tidiness:
25
+ * this URL is printed under EVERY error — including the one that says the apex redirect
26
+ * drops your body and your token. It shipped naming the apex, so that error handed the
27
+ * user a redirecting link while explaining that redirects break things. It also rides
28
+ * `toFailureJson`'s `doc_url`, which a `--json` consumer can fetch. */
29
+ export declare const CLI_ERRORS_URL = "https://www.promptdock.ai/docs/cli/errors";
22
30
  /** DX3: every named error footer links its docs anchor. */
23
31
  export declare function footerUrl(code: string): string;
24
32
  export declare class CliError extends Error {
@@ -27,11 +35,90 @@ export declare class CliError extends Error {
27
35
  readonly footer?: string;
28
36
  /** one-line "what to do" printed after the message */
29
37
  readonly hint?: string;
38
+ /**
39
+ * Stable machine-readable slug for `--json` consumers (`error.code`).
40
+ *
41
+ * ⚠️ DERIVED from `footer`, with no constructor option of its own — deliberately.
42
+ * Two independently-set fields could disagree, and one of them is the docs anchor a
43
+ * stuck user clicks. It also keeps ONE source of slugs in this package, which is what
44
+ * `lib/cli/error-docs.test.ts` scans for (it reads the literal slug off every
45
+ * construction site) to prove every printed anchor has a page entry; a second slug
46
+ * channel would be invisible to that guard.
47
+ *
48
+ * The 9 CliErrors that carry no footer (base-url config, the batch roll-up, the
49
+ * picker's Cancelled/Interrupted) report `"error"` and point at the index page.
50
+ */
51
+ readonly code: string;
52
+ /**
53
+ * ⚠️ `message` and `hint` are passed through {@link terminalBlock} — the
54
+ * defence-in-depth BACKSTOP for terminal-control injection, not the primary control.
55
+ *
56
+ * The primary control is `terminalLine` at the trust boundary (api.ts's
57
+ * `Api.envelope`, and the 426 remedy), which is where an untrusted fragment is still
58
+ * identifiable as untrusted and can be bounded and collapsed to one line. This
59
+ * constructor cannot tell a server's prose from the CLI's own, so it only strips what
60
+ * is never legitimate ANYWHERE: escape introducers (C0 except newline, DEL, C1) and
61
+ * bidi/zero-width code points.
62
+ *
63
+ * It earns its place by covering what a boundary fix structurally cannot — the NEXT
64
+ * call site. A future command that reads a new server field and throws a CliError
65
+ * built from it is repaint-safe on the day it is written, which is precisely the
66
+ * failure this whole change exists to close.
67
+ *
68
+ * ⚠️ NEWLINE SURVIVES AND THERE IS NO LENGTH CAP, both deliberate and both load-
69
+ * bearing — see `terminalBlock`. Adding either here silently breaks the 426's
70
+ * two-line layout and truncates the `--json` batch roll-up.
71
+ */
30
72
  constructor(message: string, exitCode: ExitCode, opts?: {
31
73
  footer?: string;
32
74
  hint?: string;
33
75
  });
34
76
  }
77
+ /**
78
+ * The `--json` shape of a TOP-LEVEL failure (Vercel's `{status, reason, hint, next}`
79
+ * lineage, keyed to this CLI's vocabulary). Stable: keys may be added in a patch,
80
+ * existing ones never change meaning — the same promise README makes for `results` rows.
81
+ */
82
+ export type CliFailure = {
83
+ error: {
84
+ /** the docs anchor slug, or "error" when the failure has none */
85
+ code: string;
86
+ message: string;
87
+ exit_code: ExitCode;
88
+ /** always present — the anchor when there is one, else the errors index */
89
+ doc_url: string;
90
+ };
91
+ };
92
+ /**
93
+ * ONE place that turns any thrown value into the failure envelope, so the exit code a
94
+ * script reads and the exit code the process returns cannot drift (index.ts returns
95
+ * `failure.error.exit_code`).
96
+ *
97
+ * ⚠️ The `footerUrl` call below, with its inline `internal` slug, is load-bearing beyond
98
+ * this function: `lib/cli/error-docs.test.ts` scans packages/cli/src for that call shape
99
+ * and asserts it finds `internal` — an assertion that guards the SCANNER itself. If this
100
+ * moves, the literal call must stay somewhere under src/.
101
+ *
102
+ * ⚠️ AND THAT SCANNER IS A PLAIN TEXT SCAN, COMMENTS INCLUDED. Spelling either slug
103
+ * pattern out in prose registers a slug that has no docs entry and fails the guard the
104
+ * prose is explaining (it cost one red run writing this file). Name the channels
105
+ * indirectly, as above — never paste the shape.
106
+ */
107
+ export declare function toFailureJson(err: unknown): CliFailure;
108
+ /**
109
+ * Does this invocation want machine-readable output? Read from RAW argv, never from the
110
+ * parsed flags.
111
+ *
112
+ * ⚠️ `parseArgs` runs INSIDE index.ts's `try`, so `flags` does not exist in the `catch`
113
+ * at all — and a malformed invocation (`install a/b --json --bogus`) throws before it
114
+ * ever could. Those are precisely the failures a CI job most needs machine-readable, so
115
+ * the detection has to be independent of parsing succeeding.
116
+ *
117
+ * `--json` is boolean-only in COMMAND_SPECS (`--json=true` is a usage error), and
118
+ * everything after a bare `--` is a positional, so an exact scan of the pre-`--` slice
119
+ * is the whole rule.
120
+ */
121
+ export declare function wantsJsonOutput(argv: readonly string[]): boolean;
35
122
  export declare function usageError(message: string, hint?: string): CliError;
36
123
  /**
37
124
  * DX3 per-OS filesystem error mapping. THREE distinct messages; the --force
package/dist/errors.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { CANONICAL_ORIGIN } from "./generated/constants.js";
2
+ import { SERVER_MESSAGE_MAX, terminalBlock, terminalLine } from "./terminal-text.js";
1
3
  // Stable exit codes (DX3) — scripts and CI depend on these; never renumber.
2
4
  export const EXIT = {
3
5
  OK: 0,
@@ -19,9 +21,17 @@ export const EXIT = {
19
21
  * in-UI cancel) stays USAGE=1. Scripts never reach pickers (TTY-only). */
20
22
  INTERRUPT: 130,
21
23
  };
24
+ /** The error catalogue itself — the anchor-less landing page for a failure with no slug.
25
+ *
26
+ * ⚠️ DERIVED from `CANONICAL_ORIGIN`, not a literal, and the reason is not tidiness:
27
+ * this URL is printed under EVERY error — including the one that says the apex redirect
28
+ * drops your body and your token. It shipped naming the apex, so that error handed the
29
+ * user a redirecting link while explaining that redirects break things. It also rides
30
+ * `toFailureJson`'s `doc_url`, which a `--json` consumer can fetch. */
31
+ export const CLI_ERRORS_URL = `${CANONICAL_ORIGIN}/docs/cli/errors`;
22
32
  /** DX3: every named error footer links its docs anchor. */
23
33
  export function footerUrl(code) {
24
- return `https://promptdock.ai/docs/cli/errors#${code}`;
34
+ return `${CLI_ERRORS_URL}#${code}`;
25
35
  }
26
36
  export class CliError extends Error {
27
37
  exitCode;
@@ -29,14 +39,108 @@ export class CliError extends Error {
29
39
  footer;
30
40
  /** one-line "what to do" printed after the message */
31
41
  hint;
42
+ /**
43
+ * Stable machine-readable slug for `--json` consumers (`error.code`).
44
+ *
45
+ * ⚠️ DERIVED from `footer`, with no constructor option of its own — deliberately.
46
+ * Two independently-set fields could disagree, and one of them is the docs anchor a
47
+ * stuck user clicks. It also keeps ONE source of slugs in this package, which is what
48
+ * `lib/cli/error-docs.test.ts` scans for (it reads the literal slug off every
49
+ * construction site) to prove every printed anchor has a page entry; a second slug
50
+ * channel would be invisible to that guard.
51
+ *
52
+ * The 9 CliErrors that carry no footer (base-url config, the batch roll-up, the
53
+ * picker's Cancelled/Interrupted) report `"error"` and point at the index page.
54
+ */
55
+ code;
56
+ /**
57
+ * ⚠️ `message` and `hint` are passed through {@link terminalBlock} — the
58
+ * defence-in-depth BACKSTOP for terminal-control injection, not the primary control.
59
+ *
60
+ * The primary control is `terminalLine` at the trust boundary (api.ts's
61
+ * `Api.envelope`, and the 426 remedy), which is where an untrusted fragment is still
62
+ * identifiable as untrusted and can be bounded and collapsed to one line. This
63
+ * constructor cannot tell a server's prose from the CLI's own, so it only strips what
64
+ * is never legitimate ANYWHERE: escape introducers (C0 except newline, DEL, C1) and
65
+ * bidi/zero-width code points.
66
+ *
67
+ * It earns its place by covering what a boundary fix structurally cannot — the NEXT
68
+ * call site. A future command that reads a new server field and throws a CliError
69
+ * built from it is repaint-safe on the day it is written, which is precisely the
70
+ * failure this whole change exists to close.
71
+ *
72
+ * ⚠️ NEWLINE SURVIVES AND THERE IS NO LENGTH CAP, both deliberate and both load-
73
+ * bearing — see `terminalBlock`. Adding either here silently breaks the 426's
74
+ * two-line layout and truncates the `--json` batch roll-up.
75
+ */
32
76
  constructor(message, exitCode, opts) {
33
- super(message);
77
+ super(terminalBlock(message));
34
78
  this.name = "CliError";
35
79
  this.exitCode = exitCode;
36
80
  this.footer = opts?.footer;
37
- this.hint = opts?.hint;
81
+ this.hint = opts?.hint === undefined ? undefined : terminalBlock(opts.hint);
82
+ this.code = opts?.footer ?? "error";
38
83
  }
39
84
  }
85
+ /**
86
+ * ONE place that turns any thrown value into the failure envelope, so the exit code a
87
+ * script reads and the exit code the process returns cannot drift (index.ts returns
88
+ * `failure.error.exit_code`).
89
+ *
90
+ * ⚠️ The `footerUrl` call below, with its inline `internal` slug, is load-bearing beyond
91
+ * this function: `lib/cli/error-docs.test.ts` scans packages/cli/src for that call shape
92
+ * and asserts it finds `internal` — an assertion that guards the SCANNER itself. If this
93
+ * moves, the literal call must stay somewhere under src/.
94
+ *
95
+ * ⚠️ AND THAT SCANNER IS A PLAIN TEXT SCAN, COMMENTS INCLUDED. Spelling either slug
96
+ * pattern out in prose registers a slug that has no docs entry and fails the guard the
97
+ * prose is explaining (it cost one red run writing this file). Name the channels
98
+ * indirectly, as above — never paste the shape.
99
+ */
100
+ export function toFailureJson(err) {
101
+ if (err instanceof CliError) {
102
+ return {
103
+ error: {
104
+ code: err.code,
105
+ message: err.message,
106
+ exit_code: err.exitCode,
107
+ doc_url: err.footer ? footerUrl(err.footer) : CLI_ERRORS_URL,
108
+ },
109
+ };
110
+ }
111
+ // ⚠️ THE ONE SINK WITH NO BACKSTOP. Everything else that reaches the terminal is
112
+ // either a CliError (sanitized in its constructor) or a display site that now calls
113
+ // `terminalLine`. This branch is neither: the thrown value is arbitrary — a library's
114
+ // Error message, a rejected fetch, a string from anywhere — and index.ts prints it.
115
+ // Bounded as well as stripped, since a stack-shaped message can run for pages.
116
+ const detail = terminalLine(err instanceof Error ? err.message : String(err), SERVER_MESSAGE_MAX);
117
+ return {
118
+ error: {
119
+ code: "internal",
120
+ message: `unexpected error: ${detail}`,
121
+ exit_code: EXIT.USAGE,
122
+ doc_url: footerUrl("internal"),
123
+ },
124
+ };
125
+ }
126
+ /**
127
+ * Does this invocation want machine-readable output? Read from RAW argv, never from the
128
+ * parsed flags.
129
+ *
130
+ * ⚠️ `parseArgs` runs INSIDE index.ts's `try`, so `flags` does not exist in the `catch`
131
+ * at all — and a malformed invocation (`install a/b --json --bogus`) throws before it
132
+ * ever could. Those are precisely the failures a CI job most needs machine-readable, so
133
+ * the detection has to be independent of parsing succeeding.
134
+ *
135
+ * `--json` is boolean-only in COMMAND_SPECS (`--json=true` is a usage error), and
136
+ * everything after a bare `--` is a positional, so an exact scan of the pre-`--` slice
137
+ * is the whole rule.
138
+ */
139
+ export function wantsJsonOutput(argv) {
140
+ const sep = argv.indexOf("--");
141
+ const scanned = sep === -1 ? argv : argv.slice(0, sep);
142
+ return scanned.includes("--json");
143
+ }
40
144
  export function usageError(message, hint) {
41
145
  return new CliError(message, EXIT.USAGE, { hint, footer: "usage" });
42
146
  }
@@ -13,7 +13,9 @@ export declare const CLI_FUSE_HISTORY: {
13
13
  export declare const MAX_SKILL_IMAGES = 5;
14
14
  export declare const SKILL_REVEAL_DAILY_CAP = 15;
15
15
  export declare const CLI_MIN_VERSION = "1.0.1";
16
- export declare const PUBLISHED_CLI_VERSION = "1.1.0";
16
+ export declare const PUBLISHED_CLI_VERSION = "1.2.0";
17
17
  export declare const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
18
18
  export declare const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
19
19
  export declare const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
20
+ export declare const CANONICAL_ORIGIN = "https://www.promptdock.ai";
21
+ export declare const CANONICAL_CONTACT_PATH = "/contact";
@@ -10,7 +10,9 @@ export const CLI_FUSE_HISTORY = [{ "cli_version": "0.0.0", "max_files": 25, "max
10
10
  export const MAX_SKILL_IMAGES = 5;
11
11
  export const SKILL_REVEAL_DAILY_CAP = 15;
12
12
  export const CLI_MIN_VERSION = "1.0.1";
13
- export const PUBLISHED_CLI_VERSION = "1.1.0";
13
+ export const PUBLISHED_CLI_VERSION = "1.2.0";
14
14
  export const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
15
15
  export const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
16
16
  export const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
17
+ export const CANONICAL_ORIGIN = "https://www.promptdock.ai";
18
+ export const CANONICAL_CONTACT_PATH = "/contact";
package/dist/help.js CHANGED
@@ -1,7 +1,25 @@
1
1
  // DX4: `promptdock --help` is a COMPLETE zero-config map; every command also
2
2
  // answers `promptdock <cmd> --help`.
3
- import { CANONICAL_INSTALL_COMMAND } from "./generated/constants.js";
3
+ import { CANONICAL_CONTACT_PATH, CANONICAL_INSTALL_COMMAND, CANONICAL_ORIGIN } from "./generated/constants.js";
4
4
  import { TARGETS } from "./registry.js";
5
+ /**
6
+ * ⚠️ THE HELP FOOTER NAMES URLs, NEVER AN EMAIL ADDRESS — and that is the whole design,
7
+ * not a style preference. A published binary is frozen on someone's disk forever: an
8
+ * address baked into 1.2.1 is still being printed years after the mailbox moves, with no
9
+ * way to correct it. A URL is indirection: the PAGE decides which mailbox is current, so
10
+ * support routing changes without an npm publish. (The single exception is
11
+ * package.json's `bugs.email`, which npm's metadata schema has no URL form for — and
12
+ * that field IS re-published with every release.)
13
+ *
14
+ * `CANONICAL_CONTACT_PATH` comes from lib/validation/skills-constants.json via
15
+ * `pnpm run gen:cli-constants`, so the web route and this footer cannot drift apart.
16
+ * These lines are also captured verbatim into lib/cli/reference.generated.json when
17
+ * PUBLISHED_CLI_VERSION catches up — /docs/cli/reference keeps showing 1.2.0's footer
18
+ * (no Support line) until then, by design.
19
+ */
20
+ /** ⚠️ DERIVED from `CANONICAL_ORIGIN` (same shared JSON as CANONICAL_CONTACT_PATH above),
21
+ * so the Docs / Errors / Support lines cannot name a host the API is not served on. */
22
+ const SITE = CANONICAL_ORIGIN;
5
23
  export function globalHelp(version) {
6
24
  const targets = TARGETS.map((t) => ` ${t.id.padEnd(9)} ${t.localBase}/<slug> (-g: ~/${t.globalBase}/<slug>)${t.experimental ? " (experimental)" : ""}`).join("\n");
7
25
  return `promptdock v${version} — install AI agent skills from promptdock.ai
@@ -11,7 +29,7 @@ export function globalHelp(version) {
11
29
 
12
30
  Commands
13
31
  login [--token pdk_…] log in (browser hand-off, or store a CI token)
14
- logout remove the local token (revoke in Settings to kill it server-side)
32
+ logout log out (revokes the session server-side, clears the local token)
15
33
  whoami [--json] show the signed-in account
16
34
  install <ref> [options] install a skill (ref = handle/slug, @handle/slug, or a pasted URL)
17
35
  uninstall <ref|--all> remove an installed skill (receipt-driven; never touches unmanaged files)
@@ -33,19 +51,23 @@ export function globalHelp(version) {
33
51
  --check (update) report available updates without applying
34
52
  --all operate across project-local AND global scopes
35
53
  -g, --global operate on the global scope (bare commands are project-local)
54
+ --allow-partial exit 0 even when an item failed (default: exit non-zero)
36
55
 
37
56
  Targets
38
57
  ${targets}
39
58
 
40
59
  Environment
41
- PROMPTDOCK_TOKEN bearer token for CI/non-interactive use (Settings → CLI sessions Generate token)
60
+ PROMPTDOCK_TOKEN CI bearer token (Settings → AccountCLI sessions)
42
61
  PROMPTDOCK_API_BASE API origin override (default https://www.promptdock.ai)
43
62
  NO_COLOR disable colored output
44
63
 
45
64
  Exit codes
46
- 0 ok · 1 usage · 2 auth · 3 denied/gated · 4 integrity · 5 filesystem · 6 network
65
+ 0 ok · 1 usage · 2 auth · 3 denied/gated · 4 integrity · 5 filesystem
66
+ 6 network · 130 interrupted (Ctrl-C in a prompt; Esc cancels with 1)
47
67
 
48
- Docs: https://promptdock.ai/docs/cli`;
68
+ Docs: ${SITE}/docs/cli
69
+ Errors: ${SITE}/docs/cli/errors
70
+ Support: ${SITE}${CANONICAL_CONTACT_PATH}`;
49
71
  }
50
72
  export const COMMAND_HELP = {
51
73
  login: `promptdock login [--token pdk_…]
@@ -55,8 +77,12 @@ you approve there, and the terminal continues. --token stores a token minted in
55
77
  Settings → Account → CLI sessions (the CI path; or set PROMPTDOCK_TOKEN).`,
56
78
  logout: `promptdock logout
57
79
 
58
- Removes the locally-stored token (~/.promptdock/config.json). The server-side
59
- session stays valid until revoked in Settings Account → CLI sessions.`,
80
+ Revokes the session server-side FIRST — the token this machine holds stops
81
+ working immediately then removes the local copy from ~/.promptdock/config.json.
82
+
83
+ If the revoke cannot reach the server (offline), the local token is still
84
+ removed and the CLI says so on stderr: that session stays valid until it
85
+ expires, so revoke it at Settings → Account → CLI sessions.`,
60
86
  whoami: `promptdock whoami [--json]
61
87
 
62
88
  Shows the signed-in handle, role, and token expiry.`,
@@ -71,16 +97,26 @@ authenticate via PROMPTDOCK_TOKEN. -y skips the prompts ONLY — a non-empty
71
97
  foreign directory still requires --force, and scope stays project-local
72
98
  unless -g is passed. --dry-run resolves and prints the plan without
73
99
  installing (and without spending any premium-unlock slot).`,
74
- uninstall: `promptdock uninstall <handle>/<slug> | --all [-g] [--target <tool>] [--dir <path>] [--force] [-y] [--json]
100
+ uninstall: `promptdock uninstall <handle>/<slug> | --all [-g] [--target <tool>] [--dir <path>] [--force] [-y] [--allow-partial] [--json]
75
101
 
76
102
  Removes an installed skill using its .promptdock.json receipt — only files the
77
103
  receipt lists are ever deleted. Locally-modified files block removal without
78
- --force (copy your changes out first).`,
79
- update: `promptdock update [<handle>/<slug>] [--all] [--check] [-g] [-y] [--force] [--json]
104
+ --force (copy your changes out first).
105
+
106
+ Across a batch (--all, or a scope holding several skills) one bad item no
107
+ longer aborts the rest: the others are still removed and the command exits
108
+ with that item's code at the end. --allow-partial exits 0 instead. Declining
109
+ a confirm is not a failure.`,
110
+ update: `promptdock update [<handle>/<slug>] [--all] [--check] [-g] [-y] [--force] [--allow-partial] [--json]
80
111
 
81
112
  Updates installed skills to the latest approved version. Bare \`update\` covers
82
113
  the project-local scope; -g the global scope; --all both. --check reports
83
- without applying. Locally-modified files block an update without --force.`,
114
+ without applying. Locally-modified files block an update without --force.
115
+
116
+ An item that FAILS (unreachable, gated, blocked by local edits) no longer
117
+ stops the batch — the rest still update — but the command exits non-zero at
118
+ the end so CI cannot go green on an update that did nothing. --allow-partial
119
+ exits 0 instead. "Up to date" and a declined confirm are not failures.`,
84
120
  list: `promptdock list [--json] [-g] [--all]
85
121
 
86
122
  Lists installed skills from their receipts: ref, version, target, installed-at.`,
package/dist/index.js CHANGED
@@ -3,15 +3,46 @@
3
3
  // Contract: app/api/v1/cli/** (server), docs/plans/skills-marketplace.md.
4
4
  import { parseArgs } from "./args.js";
5
5
  import { realContext } from "./context.js";
6
- import { CliError, EXIT, footerUrl } from "./errors.js";
6
+ import { CliError, EXIT, footerUrl, toFailureJson, wantsJsonOutput } from "./errors.js";
7
7
  import { COMMAND_HELP, globalHelp } from "./help.js";
8
8
  import { colors } from "./ui.js";
9
9
  import { runInstall } from "./commands/install.js";
10
10
  import { runLogin, runLogout, runWhoami } from "./commands/login.js";
11
11
  import { runList, runUninstall, runUpdate } from "./commands/lifecycle.js";
12
+ /**
13
+ * SINGLE-OWNER stdout serialization for `--json`.
14
+ *
15
+ * ⚠️ THE BUG THIS PREVENTS: `update`/`uninstall` flush their complete report from a
16
+ * `finally` and THEN throw (lifecycle.ts — a failing item must never cost the caller the
17
+ * list of what did succeed). A top-level catch that unconditionally printed its own
18
+ * `{error:…}` would put TWO JSON documents on stdout, and `jq` reads exactly one — so
19
+ * the fix for "failures are invisible under --json" would have broken every batch
20
+ * consumer that already works (`batch-exit.test.ts` does a bare `JSON.parse(stdout)`).
21
+ *
22
+ * So stdout has one owner per run: whoever writes first keeps it. This wrapper records
23
+ * whether anything reached stdout at all, which is exactly the right predicate because
24
+ * under `--json` every stdout write in this CLI IS a complete document followed by a
25
+ * return (install.ts, lifecycle.ts, login.ts all gate their human progress lines on
26
+ * `!json`). `write` is tracked too: the countdown repaint is TTY-only, but a partial
27
+ * line on stdout means the same thing — the stream is no longer ours to own.
28
+ */
29
+ function trackStdout(base) {
30
+ let used = false;
31
+ const mark = (fn) => (s) => {
32
+ used = true;
33
+ return fn(s);
34
+ };
35
+ return {
36
+ ctx: { ...base, io: { ...base.io, out: mark(base.io.out), write: mark(base.io.write) } },
37
+ used: () => used,
38
+ };
39
+ }
12
40
  async function main() {
13
41
  const argv = process.argv.slice(2);
14
- const ctx = realContext(argv);
42
+ // From RAW argv: `flags` is scoped to the try below and a parse error throws before it
43
+ // exists, yet a malformed invocation is exactly what CI needs machine-readable.
44
+ const json = wantsJsonOutput(argv);
45
+ const { ctx, used: stdoutUsed } = trackStdout(realContext(argv));
15
46
  const c = colors(ctx.env, Boolean(process.stderr.isTTY));
16
47
  try {
17
48
  const { command, positionals, flags } = parseArgs(argv);
@@ -55,18 +86,26 @@ async function main() {
55
86
  }
56
87
  }
57
88
  catch (err) {
89
+ const failure = toFailureJson(err);
90
+ // Human text ALWAYS goes to stderr, `--json` or not: under --json stdout is reserved
91
+ // for at most one machine-readable document, and a person watching a piped run still
92
+ // needs to see what happened. Byte-identical to the pre-1.2.1 output.
58
93
  if (err instanceof CliError) {
59
94
  ctx.io.err(c.red(err.message));
60
95
  if (err.hint)
61
96
  ctx.io.err(err.hint);
62
97
  if (err.footer)
63
98
  ctx.io.err(c.dim(footerUrl(err.footer)));
64
- return err.exitCode;
65
99
  }
66
- const detail = err instanceof Error ? err.message : String(err);
67
- ctx.io.err(c.red(`unexpected error: ${detail}`));
68
- ctx.io.err(c.dim(footerUrl("internal")));
69
- return EXIT.USAGE;
100
+ else {
101
+ ctx.io.err(c.red(failure.error.message));
102
+ ctx.io.err(c.dim(failure.error.doc_url));
103
+ }
104
+ // The report a batch command already flushed IS the machine-readable answer (its
105
+ // rows carry `failed`/`exit` per item). Appending a second document would corrupt it.
106
+ if (json && !stdoutUsed())
107
+ ctx.io.out(JSON.stringify(failure));
108
+ return failure.error.exit_code;
70
109
  }
71
110
  }
72
111
  main().then((code) => process.exit(code), () => process.exit(EXIT.USAGE));
package/dist/installer.js CHANGED
@@ -9,6 +9,7 @@ import { randomBytes } from "node:crypto";
9
9
  import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { CliError, EXIT, mapFsError, networkError } from "./errors.js";
12
+ import { SERVER_PATH_MAX, terminalLine } from "./terminal-text.js";
12
13
  import { CLI_DOS_MAX_SKILL_FILES as MAX_SKILL_FILES, CLI_DOS_MAX_SKILL_FILE_BYTES as MAX_SKILL_FILE_BYTES, CLI_DOS_MAX_SKILL_TOTAL_BYTES as MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
13
14
  import { sha256Hex, sha256Matches } from "./integrity.js";
14
15
  import { validateSkillPaths } from "./paths.js";
@@ -98,7 +99,14 @@ export function assertSafeManifest(manifest) {
98
99
  const issues = validateSkillPaths(manifest.map((e) => e.path));
99
100
  if (issues.length > 0) {
100
101
  const first = issues[0];
101
- throw new CliError(`unsafe file path in manifest ("${first.path}": ${first.reason}) — refusing to install`, EXIT.INTEGRITY, { footer: "manifest_path" });
102
+ throw new CliError(
103
+ // ⚠️ THE SECURITY CONTROL'S OWN MESSAGE WAS THE INJECTION CHANNEL.
104
+ // `validateSkillPaths` checks length → backslash → absolute → control chars in
105
+ // that order, so a path rejected on any of the first three is echoed here having
106
+ // never been control-char checked. The CliError backstop strips escapes, but only
107
+ // this bounds it to one line — and 256 is validateSkillPaths' own length rule, so
108
+ // it can never clamp a path this function would have accepted.
109
+ `unsafe file path in manifest ("${terminalLine(first.path, SERVER_PATH_MAX)}": ${first.reason}) — refusing to install`, EXIT.INTEGRITY, { footer: "manifest_path" });
102
110
  }
103
111
  if (manifest.length === 0 || manifest.length > MAX_SKILL_FILES) {
104
112
  throw new CliError(`manifest lists ${manifest.length} files (limit ${MAX_SKILL_FILES}) — refusing to install`, EXIT.INTEGRITY, { footer: "manifest_bounds" });
@@ -96,4 +96,4 @@ export declare function detectTargets(opts: {
96
96
  exists?: (p: string) => boolean;
97
97
  }): DetectedTarget[];
98
98
  /** DX1/DX F2/F16 — the per-target next step, incl. the RELOAD caveat. */
99
- export declare function nextStepLine(targetId: string, slug: string): string;
99
+ export declare function nextStepLine(targetId: string, rawSlug: string): string;
package/dist/registry.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // floats detected tools to the top and preselects the first (D-UX11).
5
5
  import { existsSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { SERVER_LABEL_MAX, terminalLine } from "./terminal-text.js";
7
8
  export const TARGETS = [
8
9
  {
9
10
  id: "claude",
@@ -158,7 +159,10 @@ export function detectTargets(opts) {
158
159
  return [...all.filter((t) => t.detected), ...all.filter((t) => !t.detected)];
159
160
  }
160
161
  /** DX1/DX F2/F16 — the per-target next step, incl. the RELOAD caveat. */
161
- export function nextStepLine(targetId, slug) {
162
+ export function nextStepLine(targetId, rawSlug) {
163
+ // Sanitized INSIDE rather than at the call site: this function's whole output is
164
+ // display, so every present and future caller is covered by one line.
165
+ const slug = terminalLine(rawSlug, SERVER_LABEL_MAX);
162
166
  switch (targetId) {
163
167
  case "claude":
164
168
  return `Next: start a NEW session in Claude Code (open sessions don't see new skills) and type /${slug}`;