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 +21 -0
- package/README.md +58 -5
- package/dist/api.js +40 -6
- package/dist/auth.js +24 -3
- package/dist/commands/install.d.ts +7 -0
- package/dist/commands/install.js +68 -15
- package/dist/commands/lifecycle.js +51 -15
- package/dist/commands/login.js +31 -2
- package/dist/config.js +35 -2
- package/dist/context.d.ts +19 -0
- package/dist/context.js +100 -0
- package/dist/contract.d.ts +7 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +5 -0
- package/dist/generated/constants.d.ts +11 -1
- package/dist/generated/constants.js +6 -1
- package/dist/help.js +11 -7
- package/dist/installer.d.ts +12 -2
- package/dist/installer.js +88 -8
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +65 -0
- package/dist/select-keys.d.ts +64 -0
- package/dist/select-keys.js +113 -0
- package/dist/ui.d.ts +16 -6
- package/dist/ui.js +158 -7
- package/dist/verdicts.d.ts +1 -1
- package/dist/verdicts.js +43 -1
- package/package.json +28 -10
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key handling for the interactive picker, as a PURE reducer.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of ui.ts on purpose: the rendering half needs a real TTY (raw mode,
|
|
5
|
+
* cursor moves) and cannot be unit-tested, but the DECISIONS — what wraps, what
|
|
6
|
+
* commits, what cancels — are the part that regresses. Pure in, pure out, so
|
|
7
|
+
* every key path is pinned by a table test with no terminal involved.
|
|
8
|
+
*/
|
|
9
|
+
const wrap = (i, count) => ((i % count) + count) % count;
|
|
10
|
+
/**
|
|
11
|
+
* One keypress → the next state, or a terminal outcome.
|
|
12
|
+
*
|
|
13
|
+
* Both input styles stay live at once, which is the whole point: arrows move the
|
|
14
|
+
* highlight, digits ALSO move it (so typing is visible before you commit), and
|
|
15
|
+
* Enter commits whichever the user last expressed. Space commits the highlight
|
|
16
|
+
* only — it is a "this one" gesture, and treating it as a number-confirm would
|
|
17
|
+
* make a half-typed "1" + Space silently pick option 1 when the user meant 12.
|
|
18
|
+
*
|
|
19
|
+
* Digits never auto-commit. `3` in a 4-item list is unambiguous and a fancier
|
|
20
|
+
* picker would fire immediately, but the user asked to KEEP number entry, and
|
|
21
|
+
* number entry means "type it, then press Enter" — auto-firing would make a
|
|
22
|
+
* two-digit list behave differently from a one-digit list.
|
|
23
|
+
*
|
|
24
|
+
* `hotkeys` (letter → option index) commit IMMEDIATELY — the confirm path
|
|
25
|
+
* passes {y:0, n:1}. Without this, `n`+Enter at a default-Yes "Install?"
|
|
26
|
+
* committed the DEFAULT: the muscle-memory answer this CLI's own [y/N]
|
|
27
|
+
* fallback trains was silently ignored and the UI executed the opposite of
|
|
28
|
+
* what the user typed (dual-review find, PTY-proven).
|
|
29
|
+
*/
|
|
30
|
+
export function reduceSelectKey(state, key, count, hotkeys) {
|
|
31
|
+
const name = key.name;
|
|
32
|
+
const seq = key.sequence;
|
|
33
|
+
const keep = (s) => ({ kind: "state", state: s });
|
|
34
|
+
// Raw mode swallows SIGINT, so Ctrl-C is ours to honour or the picker hangs.
|
|
35
|
+
// It carries the `signal` mark → exit 130; Esc/^D are deliberate cancels.
|
|
36
|
+
if (key.ctrl && name === "c")
|
|
37
|
+
return { kind: "cancel", signal: true };
|
|
38
|
+
if (key.ctrl && name === "d")
|
|
39
|
+
return { kind: "cancel" };
|
|
40
|
+
if (name === "escape")
|
|
41
|
+
return { kind: "cancel" };
|
|
42
|
+
// Letter hotkeys beat every other mapping except cancel — `n` must never be
|
|
43
|
+
// read as "ignored key" while the footer implies typing works.
|
|
44
|
+
if (name && !key.ctrl && !key.meta && hotkeys && hotkeys[name] !== undefined) {
|
|
45
|
+
const idx = hotkeys[name];
|
|
46
|
+
if (idx >= 0 && idx < count)
|
|
47
|
+
return { kind: "commit", index: idx };
|
|
48
|
+
}
|
|
49
|
+
if (name === "return" || name === "enter") {
|
|
50
|
+
if (state.buffer === "")
|
|
51
|
+
return { kind: "commit", index: state.index };
|
|
52
|
+
const n = Number(state.buffer);
|
|
53
|
+
if (Number.isInteger(n) && n >= 1 && n <= count)
|
|
54
|
+
return { kind: "commit", index: n - 1 };
|
|
55
|
+
return {
|
|
56
|
+
kind: "reject",
|
|
57
|
+
state: { index: state.index, buffer: "" },
|
|
58
|
+
message: `Enter a number between 1 and ${count}.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (name === "space") {
|
|
62
|
+
// With a live digit buffer, Space means what Enter means — validate the
|
|
63
|
+
// number. Committing the stale highlight would discard the visible buffer
|
|
64
|
+
// ("Number: 12") for a row the user never named (review find).
|
|
65
|
+
if (state.buffer !== "") {
|
|
66
|
+
const n = Number(state.buffer);
|
|
67
|
+
if (Number.isInteger(n) && n >= 1 && n <= count)
|
|
68
|
+
return { kind: "commit", index: n - 1 };
|
|
69
|
+
return {
|
|
70
|
+
kind: "reject",
|
|
71
|
+
state: { index: state.index, buffer: "" },
|
|
72
|
+
message: `Enter a number between 1 and ${count}.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { kind: "commit", index: state.index };
|
|
76
|
+
}
|
|
77
|
+
if (name === "up" || name === "k" || (key.ctrl && name === "p"))
|
|
78
|
+
return keep({ index: wrap(state.index - 1, count), buffer: "" });
|
|
79
|
+
if (name === "down" || name === "j" || (key.ctrl && name === "n"))
|
|
80
|
+
return keep({ index: wrap(state.index + 1, count), buffer: "" });
|
|
81
|
+
if (name === "home")
|
|
82
|
+
return keep({ index: 0, buffer: "" });
|
|
83
|
+
if (name === "end")
|
|
84
|
+
return keep({ index: count - 1, buffer: "" });
|
|
85
|
+
if (name === "backspace") {
|
|
86
|
+
const buffer = state.buffer.slice(0, -1);
|
|
87
|
+
const n = Number(buffer);
|
|
88
|
+
// Re-point at whatever the shortened buffer now names; keep the highlight if it names nothing.
|
|
89
|
+
const index = buffer !== "" && n >= 1 && n <= count ? n - 1 : state.index;
|
|
90
|
+
return keep({ index, buffer });
|
|
91
|
+
}
|
|
92
|
+
if (seq !== undefined && /^[0-9]$/.test(seq)) {
|
|
93
|
+
// Bounded: 3 digits names any option a 20-row picker can hold; an unbounded
|
|
94
|
+
// buffer + full-frame repaint per digit is quadratic under a digit-spam
|
|
95
|
+
// paste (codex find). Extra digits are ignored, never mis-parsed.
|
|
96
|
+
if (state.buffer.length >= 3)
|
|
97
|
+
return keep(state);
|
|
98
|
+
// Leading zeros are dropped so "0" then "3" is 3, not a dead buffer.
|
|
99
|
+
const buffer = (state.buffer + seq).replace(/^0+(?=\d)/, "");
|
|
100
|
+
const n = Number(buffer);
|
|
101
|
+
const index = n >= 1 && n <= count ? n - 1 : state.index;
|
|
102
|
+
return keep({ index, buffer });
|
|
103
|
+
}
|
|
104
|
+
return keep(state);
|
|
105
|
+
}
|
|
106
|
+
/** Footer hint. Mentions BOTH input styles — neither is discoverable otherwise.
|
|
107
|
+
* `hotkeyLabel` (e.g. "y/n") leads when the picker has letter hotkeys. */
|
|
108
|
+
export function selectHint(count, buffer, hotkeyLabel) {
|
|
109
|
+
if (buffer !== "")
|
|
110
|
+
return `Number: ${buffer} ↵ confirm · ⌫ clear · esc cancel`;
|
|
111
|
+
const hot = hotkeyLabel ? `${hotkeyLabel} · ` : "";
|
|
112
|
+
return `${hot}↑↓ move · ↵/space select · 1-${count} then ↵ · esc cancel`;
|
|
113
|
+
}
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CliIo } from "./context.js";
|
|
2
|
-
/** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
|
|
2
|
+
/** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
|
|
3
|
+
* TERM=dumb too — a terminal that can't render ANSI gets plain text. */
|
|
3
4
|
export declare function colors(env: Record<string, string | undefined>, isTTY: boolean): {
|
|
4
5
|
bold: (s: string) => string;
|
|
5
6
|
dim: (s: string) => string;
|
|
@@ -14,9 +15,18 @@ export declare function formatCountdown(totalSecs: number): string;
|
|
|
14
15
|
/** ~Nh retry copy for the cap verdict (mirrors the app's premium-cap copy). */
|
|
15
16
|
export declare function retryHours(retryAfterSecs: number): string;
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* Picker. Arrow-key driven when the terminal can do raw mode, numbered otherwise.
|
|
19
|
+
* Returns the chosen index.
|
|
20
|
+
*
|
|
21
|
+
* BOTH input styles are live in the interactive path: ↑↓ (and j/k, ^p/^n) move,
|
|
22
|
+
* ↵/space select, and typing a number still works — it moves the highlight as you
|
|
23
|
+
* type and ↵ confirms it. The numbered path below is not a lesser fallback, it is
|
|
24
|
+
* the contract for every non-TTY caller (CI, pipes, tests) and its behaviour is
|
|
25
|
+
* unchanged: Enter alone accepts the preselect.
|
|
19
26
|
*/
|
|
20
|
-
export declare function promptSelect(io: CliIo, title: string, options: string[], preselect: number): Promise<number>;
|
|
21
|
-
/**
|
|
22
|
-
|
|
27
|
+
export declare function promptSelect(io: CliIo, title: string, options: string[], preselect: number, c?: Colors, hotkeys?: Record<string, number>, hotkeyLabel?: string): Promise<number>;
|
|
28
|
+
/**
|
|
29
|
+
* Confirm. Arrow-driven Yes/No when the terminal allows, y/N text otherwise.
|
|
30
|
+
* (TTY only — callers gate; -y bypasses upstream.)
|
|
31
|
+
*/
|
|
32
|
+
export declare function confirm(io: CliIo, prompt: string, def?: boolean, c?: Colors): Promise<boolean>;
|
package/dist/ui.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { CliError, EXIT } from "./errors.js";
|
|
2
|
+
import { reduceSelectKey, selectHint } from "./select-keys.js";
|
|
3
|
+
/** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
|
|
4
|
+
* TERM=dumb too — a terminal that can't render ANSI gets plain text. */
|
|
2
5
|
export function colors(env, isTTY) {
|
|
3
|
-
const on = isTTY && !env.NO_COLOR;
|
|
6
|
+
const on = isTTY && !env.NO_COLOR && env.TERM !== "dumb";
|
|
4
7
|
const wrap = (open, close) => (s) => (on ? `${open}${s}${close}` : s);
|
|
5
8
|
return {
|
|
6
9
|
bold: wrap("\u001b[1m", "\u001b[22m"),
|
|
@@ -28,11 +31,31 @@ export function formatCountdown(totalSecs) {
|
|
|
28
31
|
export function retryHours(retryAfterSecs) {
|
|
29
32
|
return `~${Math.max(1, Math.ceil(retryAfterSecs / 3600))}h`;
|
|
30
33
|
}
|
|
34
|
+
const HIDE_CURSOR = "[?25l";
|
|
35
|
+
const SHOW_CURSOR = "[?25h";
|
|
36
|
+
/** Above this, cursor-up repainting fights terminal scrollback — use the numbered reader. */
|
|
37
|
+
const MAX_INTERACTIVE_ROWS = 20;
|
|
38
|
+
const passthrough = (s) => s;
|
|
31
39
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
40
|
+
* Picker. Arrow-key driven when the terminal can do raw mode, numbered otherwise.
|
|
41
|
+
* Returns the chosen index.
|
|
42
|
+
*
|
|
43
|
+
* BOTH input styles are live in the interactive path: ↑↓ (and j/k, ^p/^n) move,
|
|
44
|
+
* ↵/space select, and typing a number still works — it moves the highlight as you
|
|
45
|
+
* type and ↵ confirms it. The numbered path below is not a lesser fallback, it is
|
|
46
|
+
* the contract for every non-TTY caller (CI, pipes, tests) and its behaviour is
|
|
47
|
+
* unchanged: Enter alone accepts the preselect.
|
|
34
48
|
*/
|
|
35
|
-
export async function promptSelect(io, title, options, preselect) {
|
|
49
|
+
export async function promptSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
|
|
50
|
+
// Height gate: the frame is title + options + hint. If it doesn't fit the
|
|
51
|
+
// terminal, `ESC[NA` clamps at the top edge and repaints mangle scrollback
|
|
52
|
+
// (an 8-row tmux pane was the review's concrete case) — the numbered reader
|
|
53
|
+
// is the honest fallback there.
|
|
54
|
+
const rows = io.size?.().rows ?? 24;
|
|
55
|
+
const fits = options.length + 2 <= rows - 1;
|
|
56
|
+
if (io.rawKeys && options.length > 0 && options.length <= MAX_INTERACTIVE_ROWS && fits) {
|
|
57
|
+
return interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel);
|
|
58
|
+
}
|
|
36
59
|
io.out(title);
|
|
37
60
|
options.forEach((opt, i) => {
|
|
38
61
|
const marker = i === preselect ? "❯" : " ";
|
|
@@ -48,8 +71,136 @@ export async function promptSelect(io, title, options, preselect) {
|
|
|
48
71
|
io.out(`Enter a number between 1 and ${options.length}.`);
|
|
49
72
|
}
|
|
50
73
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
74
|
+
/** Clamp a PLAIN (uncolored) line to the terminal width. Load-bearing for the
|
|
75
|
+
* in-place repaint: `ESC[NA` moves PHYSICAL rows while `painted` counts
|
|
76
|
+
* logical lines, so one soft-wrapped line desyncs every later repaint into a
|
|
77
|
+
* trail of stale frames (3 voices + codex converged on this; the scope
|
|
78
|
+
* prompt's absolute paths wrap on a stock 80-col terminal). Clamping before
|
|
79
|
+
* colorization keeps logical lines == physical rows by construction. */
|
|
80
|
+
function clampLine(line, columns) {
|
|
81
|
+
const max = Math.max(8, columns - 1);
|
|
82
|
+
if (line.length <= max)
|
|
83
|
+
return line;
|
|
84
|
+
return line.slice(0, max - 1) + "…";
|
|
85
|
+
}
|
|
86
|
+
async function interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
|
|
87
|
+
const count = options.length;
|
|
88
|
+
const bold = c?.bold ?? passthrough;
|
|
89
|
+
const cyan = c?.cyan ?? passthrough;
|
|
90
|
+
const dim = c?.dim ?? passthrough;
|
|
91
|
+
const width = String(count).length;
|
|
92
|
+
let state = { index: Math.min(Math.max(preselect, 0), count - 1), buffer: "" };
|
|
93
|
+
let notice = "";
|
|
94
|
+
let painted = 0;
|
|
95
|
+
const frame = () => {
|
|
96
|
+
// Re-read the width each paint so a mid-picker resize clamps the NEXT
|
|
97
|
+
// frame correctly (past frames are already committed to scrollback).
|
|
98
|
+
const columns = io.size?.().columns ?? 80;
|
|
99
|
+
const rows = options.map((opt, i) => {
|
|
100
|
+
const num = `${i + 1}.`.padStart(width + 1);
|
|
101
|
+
const body = clampLine(`${i === state.index ? "❯" : " "} ${num} ${opt}`, columns);
|
|
102
|
+
return i === state.index ? cyan(bold(body)) : body;
|
|
103
|
+
});
|
|
104
|
+
return [
|
|
105
|
+
clampLine(title, columns),
|
|
106
|
+
...rows,
|
|
107
|
+
dim(clampLine(notice || selectHint(count, state.buffer, hotkeyLabel), columns)),
|
|
108
|
+
];
|
|
109
|
+
};
|
|
110
|
+
const paint = () => {
|
|
111
|
+
// Redraw in place: jump back over the previous frame, then clear-and-rewrite
|
|
112
|
+
// each line. Clearing matters — a shorter line would otherwise leave the tail
|
|
113
|
+
// of the longer one it replaced (a stale path fragment) on screen.
|
|
114
|
+
const lines = frame();
|
|
115
|
+
let out = painted > 0 ? `[${painted}A` : "";
|
|
116
|
+
for (const line of lines)
|
|
117
|
+
out += `\r[2K${line}\n`;
|
|
118
|
+
io.write(out);
|
|
119
|
+
painted = lines.length;
|
|
120
|
+
};
|
|
121
|
+
/** On commit, COLLAPSE the frame to one summary line ("Install? Yes") instead
|
|
122
|
+
* of leaving the whole option list + a stale hint in scrollback (review
|
|
123
|
+
* polish item). Clears every previously painted row, writes the summary,
|
|
124
|
+
* and parks the cursor directly under it so the CLI's next output flows on. */
|
|
125
|
+
const collapse = (chosen) => {
|
|
126
|
+
const columns = io.size?.().columns ?? 80;
|
|
127
|
+
const summary = clampLine(`${title} ${chosen}`, columns);
|
|
128
|
+
let out = painted > 0 ? `[${painted}A` : "";
|
|
129
|
+
out += `\r[2K${cyan(summary)}\n`;
|
|
130
|
+
for (let i = 0; i < painted - 1; i++)
|
|
131
|
+
out += `[2K[1B`;
|
|
132
|
+
if (painted > 1)
|
|
133
|
+
out += `[${painted - 1}A\r`;
|
|
134
|
+
io.write(out);
|
|
135
|
+
painted = 1;
|
|
136
|
+
};
|
|
137
|
+
io.write(HIDE_CURSOR);
|
|
138
|
+
// Seeded with a no-op rather than null: the executor below runs synchronously so
|
|
139
|
+
// this is always replaced before the await, and a non-nullable release means the
|
|
140
|
+
// finally can never be skipped by a narrowing accident. Leaving raw mode on is
|
|
141
|
+
// the one failure here that wrecks the user's terminal after we exit.
|
|
142
|
+
let release = () => { };
|
|
143
|
+
try {
|
|
144
|
+
return await new Promise((resolve, reject) => {
|
|
145
|
+
paint();
|
|
146
|
+
// Settled guard + immediate release: `finally` runs a microtask AFTER
|
|
147
|
+
// resolve, so keys buffered in the SAME tick (a paste like "2\n1") would
|
|
148
|
+
// otherwise keep reducing and repainting a highlight that diverges from
|
|
149
|
+
// the resolved index (codex find, verified). Settle → release NOW; the
|
|
150
|
+
// finally's idempotent release stays as the belt.
|
|
151
|
+
let settled = false;
|
|
152
|
+
const settle = () => {
|
|
153
|
+
settled = true;
|
|
154
|
+
release();
|
|
155
|
+
};
|
|
156
|
+
release = io.rawKeys((key) => {
|
|
157
|
+
if (settled)
|
|
158
|
+
return;
|
|
159
|
+
const res = reduceSelectKey(state, key, count, hotkeys);
|
|
160
|
+
if (res.kind === "cancel") {
|
|
161
|
+
settle();
|
|
162
|
+
reject(res.signal
|
|
163
|
+
? new CliError("Interrupted.", EXIT.INTERRUPT)
|
|
164
|
+
: new CliError("Cancelled.", EXIT.USAGE));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (res.kind === "commit") {
|
|
168
|
+
state = { index: res.index, buffer: "" };
|
|
169
|
+
notice = "";
|
|
170
|
+
collapse(options[res.index]);
|
|
171
|
+
settle();
|
|
172
|
+
resolve(res.index);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (res.kind === "reject") {
|
|
176
|
+
state = res.state;
|
|
177
|
+
notice = res.message;
|
|
178
|
+
paint();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
state = res.state;
|
|
182
|
+
notice = "";
|
|
183
|
+
paint();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
// Order matters: stop consuming keys BEFORE restoring the cursor, so a key
|
|
189
|
+
// pressed during teardown cannot repaint over the restored terminal.
|
|
190
|
+
release();
|
|
191
|
+
io.write(SHOW_CURSOR);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Confirm. Arrow-driven Yes/No when the terminal allows, y/N text otherwise.
|
|
196
|
+
* (TTY only — callers gate; -y bypasses upstream.)
|
|
197
|
+
*/
|
|
198
|
+
export async function confirm(io, prompt, def = false, c) {
|
|
199
|
+
// y/n commit IMMEDIATELY (hotkeys) — the [y/N] muscle memory this CLI's own
|
|
200
|
+
// fallback trains must never be silently ignored (dual-review find: `n`+Enter
|
|
201
|
+
// at a default-Yes confirm used to run the install against a typed no).
|
|
202
|
+
if (io.rawKeys)
|
|
203
|
+
return (await promptSelect(io, prompt, ["Yes", "No"], def ? 0 : 1, c, { y: 0, n: 1 }, "y/n")) === 0;
|
|
53
204
|
const suffix = def ? "[Y/n]" : "[y/N]";
|
|
54
205
|
const answer = (await io.question(`${prompt} ${suffix} `)).trim().toLowerCase();
|
|
55
206
|
if (answer === "")
|
package/dist/verdicts.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ResolveResponse } from "./contract.js";
|
|
2
2
|
export declare const UPGRADE_URL = "https://promptdock.ai/pricing";
|
|
3
3
|
/** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
|
|
4
|
-
export declare function assertInstallable(resolve: ResolveResponse, refString: string): void;
|
|
4
|
+
export declare function assertInstallable(resolve: ResolveResponse, refString: string, cliVersion: string): void;
|
package/dist/verdicts.js
CHANGED
|
@@ -1,11 +1,53 @@
|
|
|
1
1
|
import { CliError, EXIT } from "./errors.js";
|
|
2
2
|
import { retryHours } from "./ui.js";
|
|
3
|
+
import { CANONICAL_INSTALL_COMMAND, CLI_DOS_MAX_SKILL_FILES, CLI_DOS_MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
|
|
3
4
|
export const UPGRADE_URL = "https://promptdock.ai/pricing";
|
|
5
|
+
/** Numeric semver compare; mirrors compareCliVersions in lib/validation/skills.ts. */
|
|
6
|
+
function cmpVersion(a, b) {
|
|
7
|
+
const pa = a.split(".").map((n) => Number(n) || 0);
|
|
8
|
+
const pb = b.split(".").map((n) => Number(n) || 0);
|
|
9
|
+
return (pa[0] - pb[0]) || (pa[1] - pb[1]) || (pa[2] - pb[2]);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* PACKAGE-SCOPED compatibility, checked BEFORE the metered install POST.
|
|
13
|
+
*
|
|
14
|
+
* Two reasons this lives here and not only in `assertSafeManifest`:
|
|
15
|
+
* 1. `POST /cli/skills/{id}/install` mints an entitlement ticket under the shared
|
|
16
|
+
* 15/day premium cap. Failing after it burns one of the user's daily slots on a
|
|
17
|
+
* provably-doomed install.
|
|
18
|
+
* 2. A package this CLI is too old to install is a POLICY refusal (EXIT.DENIED — the
|
|
19
|
+
* code whose own doc string reads "tier, cap, denial, rate limit, version floor"),
|
|
20
|
+
* not a supply-chain signal. `assertSafeManifest` throws EXIT.INTEGRITY because
|
|
21
|
+
* there it means the server sent something impossible; here it means "update me".
|
|
22
|
+
*
|
|
23
|
+
* `assertSafeManifest` stays as the untrusted-server backstop — this check makes it
|
|
24
|
+
* unreachable in practice, never redundant.
|
|
25
|
+
*/
|
|
26
|
+
function assertPackageCompatible(resolve, cliVersion) {
|
|
27
|
+
const upgrade = `Update: ${CANONICAL_INSTALL_COMMAND} … (or: npm i -g promptdock@latest)`;
|
|
28
|
+
const title = resolve.title ? `"${resolve.title}"` : "This skill";
|
|
29
|
+
const min = resolve.min_cli_version;
|
|
30
|
+
if (typeof min === "string" && min.length > 0 && cmpVersion(cliVersion, min) < 0) {
|
|
31
|
+
throw new CliError(`${title} needs promptdock CLI ${min} or newer — you are on ${cliVersion}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
|
|
32
|
+
}
|
|
33
|
+
// Belt and braces for a server too old to send min_cli_version: compare the shape
|
|
34
|
+
// the resolve response already reports against this CLI's own fuses.
|
|
35
|
+
const files = Number(resolve.file_count);
|
|
36
|
+
if (Number.isFinite(files) && files > CLI_DOS_MAX_SKILL_FILES) {
|
|
37
|
+
throw new CliError(`${title} has ${files} files — this CLI installs at most ${CLI_DOS_MAX_SKILL_FILES}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
|
|
38
|
+
}
|
|
39
|
+
const total = Number(resolve.total_bytes);
|
|
40
|
+
if (Number.isFinite(total) && total > CLI_DOS_MAX_SKILL_TOTAL_BYTES) {
|
|
41
|
+
const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
42
|
+
throw new CliError(`${title} is ${mb(total)} — this CLI installs at most ${mb(CLI_DOS_MAX_SKILL_TOTAL_BYTES)}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
4
45
|
/** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
|
|
5
|
-
export function assertInstallable(resolve, refString) {
|
|
46
|
+
export function assertInstallable(resolve, refString, cliVersion) {
|
|
6
47
|
switch (resolve.verdict) {
|
|
7
48
|
case "ok":
|
|
8
49
|
case "already_entitled":
|
|
50
|
+
assertPackageCompatible(resolve, cliVersion);
|
|
9
51
|
return;
|
|
10
52
|
case "not_found":
|
|
11
53
|
throw new CliError(`skill not found: ${refString} — check the ref (the format is handle/slug; a pasted promptdock.ai skill URL also works)`, EXIT.DENIED, { footer: "not_found" });
|
package/package.json
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptdock",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Install AI agent skills from PromptDock — npx promptdock@latest install <handle>/<slug>",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"promptdock",
|
|
7
|
+
"skills",
|
|
8
|
+
"ai",
|
|
9
|
+
"agents",
|
|
10
|
+
"claude",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
6
13
|
"homepage": "https://promptdock.ai",
|
|
7
14
|
"repository": {
|
|
8
15
|
"type": "git",
|
|
9
16
|
"url": "git+https://github.com/klicklabs/promptdock.ai.git",
|
|
10
17
|
"directory": "packages/cli"
|
|
11
18
|
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://promptdock.ai/docs/cli/errors"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
14
23
|
"type": "module",
|
|
15
|
-
"bin": {
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
"bin": {
|
|
25
|
+
"promptdock": "dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
18
34
|
"scripts": {
|
|
19
35
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "vitest run",
|
|
21
|
-
"prepublishOnly": "npm run build"
|
|
36
|
+
"test": "npm run typecheck && vitest run",
|
|
37
|
+
"prepublishOnly": "npm run build",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.test.json"
|
|
22
39
|
},
|
|
23
40
|
"devDependencies": {
|
|
24
41
|
"@types/node": "^20",
|
|
25
42
|
"typescript": "^5",
|
|
26
43
|
"vitest": "^4.1.8"
|
|
27
|
-
}
|
|
44
|
+
},
|
|
45
|
+
"gitHead": "53545d74fc0baeec63684c96b49134e93625d63a"
|
|
28
46
|
}
|