promptdock 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -4
- 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 +67 -14
- package/dist/commands/lifecycle.js +2 -2
- 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/errors.d.ts +5 -0
- package/dist/errors.js +5 -0
- package/dist/generated/constants.d.ts +1 -1
- package/dist/generated/constants.js +1 -1
- package/dist/help.js +11 -7
- 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/package.json +28 -10
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prompt Dock, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -53,10 +53,29 @@ Install options: `-g/--global` (per-user dir), `--target <tool>`, `--dir <path>`
|
|
|
53
53
|
| `kimi` | `.kimi/skills/<slug>/` | `~/.kimi/skills/<slug>/` | experimental |
|
|
54
54
|
| `openai` (OpenAI/GPT) | `.openai/skills/<slug>/` | `~/.openai/skills/<slug>/` | experimental |
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
**
|
|
59
|
-
|
|
56
|
+
Interactively, `install` asks **two** questions:
|
|
57
|
+
|
|
58
|
+
1. **Which tool** — tools already set up (in either scope) float to the top,
|
|
59
|
+
tagged with where they were found; the first is preselected.
|
|
60
|
+
2. **This directory or your user account** — shown as the two real destination
|
|
61
|
+
paths, preselecting whichever scope the tool is already set up in.
|
|
62
|
+
|
|
63
|
+
Both prompts (and every confirm) are **arrow-key driven**: `↑`/`↓` to move
|
|
64
|
+
(`j`/`k` and `^p`/`^n` work too), `Enter` or `Space` to select, `Esc` to cancel
|
|
65
|
+
(`Ctrl-C` exits `130`, the shell convention). Yes/No confirms also take **`y`
|
|
66
|
+
and `n` directly** — they commit immediately, never fall through to a default.
|
|
67
|
+
**Typing a number still works** — it moves the highlight as you type, `Enter`
|
|
68
|
+
confirms, `Backspace` edits. A pipe or CI session never prompts at all (pass
|
|
69
|
+
`--target`/`--dir` + `-y`); a terminal without raw-mode/ANSI support
|
|
70
|
+
(`TERM=dumb`) gets the plain numbered reader.
|
|
71
|
+
|
|
72
|
+
Shortcuts: `-g` answers question 2 up front (per-user install, no prompt);
|
|
73
|
+
`--target <tool>` answers question 1; `--dir <path>` answers both and installs
|
|
74
|
+
anywhere. `-y` and non-interactive sessions never prompt and stay
|
|
75
|
+
**project-local** unless `-g` is passed.
|
|
76
|
+
|
|
77
|
+
After installing, **restart/reopen the tool** — most agents only scan skills at
|
|
78
|
+
session start (Claude Code: start a *new* session, then type `/<slug>`).
|
|
60
79
|
|
|
61
80
|
## CI / non-interactive use
|
|
62
81
|
|
|
@@ -107,6 +126,7 @@ path is re-validated before any write; a failed check aborts **all-or-nothing**
|
|
|
107
126
|
| 4 | integrity (sha mismatch, unsafe manifest path, receipt schema) |
|
|
108
127
|
| 5 | filesystem (permissions, disk space, non-empty directory) |
|
|
109
128
|
| 6 | network |
|
|
129
|
+
| 130 | interrupted (`Ctrl-C` during an interactive prompt; `Esc` — a deliberate in-UI cancel — exits 1) |
|
|
110
130
|
|
|
111
131
|
Every named error links `https://promptdock.ai/docs/cli/errors#<code>`.
|
|
112
132
|
|
|
@@ -118,3 +138,16 @@ Every named error links `https://promptdock.ai/docs/cli/errors#<code>`.
|
|
|
118
138
|
Review is a screening step, not a warranty — read what you install.
|
|
119
139
|
- Skills are plain files on your machine after install; premium gates access,
|
|
120
140
|
not copying.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
[MIT](./LICENSE) © Prompt Dock, Inc.
|
|
145
|
+
|
|
146
|
+
Scope: the MIT license covers **this package (`packages/cli/`) only** — the CLI
|
|
147
|
+
client source published to npm. It does not cover the PromptDock service, the
|
|
148
|
+
website, or the rest of the repository, which are proprietary.
|
|
149
|
+
|
|
150
|
+
It also does not cover **the skills you install**. Those are third-party content
|
|
151
|
+
licensed by their authors under the
|
|
152
|
+
[PromptDock Terms](https://promptdock.ai/terms) — installing a skill grants you
|
|
153
|
+
access to use it, not the rights this MIT license grants over the CLI itself.
|
package/dist/api.js
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE } from "./config.js";
|
|
1
2
|
import { CliError, EXIT, networkError } from "./errors.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The redirect target may only become an ACTIONABLE hint when it is trustworthy
|
|
5
|
+
* (review CRITICAL): a 3xx can come from a captive portal, hijacked DNS, or a
|
|
6
|
+
* MITM proxy, and the old hint rendered its Location verbatim as a
|
|
7
|
+
* copy-pasteable `PROMPTDOCK_API_BASE=<attacker origin>` instruction — the
|
|
8
|
+
* commit that fixed redirect-STRIPS-Authorization had introduced
|
|
9
|
+
* redirect-HANDS-OVER-Authorization. Trust rule: https only, AND the same
|
|
10
|
+
* registrable domain as the origin we were already talking to (apex↔www and
|
|
11
|
+
* sibling subdomains pass; anything else gets the STATIC default hint).
|
|
12
|
+
*/
|
|
13
|
+
function trustedRedirectOrigin(location, baseUrl) {
|
|
4
14
|
try {
|
|
5
|
-
|
|
15
|
+
const to = new URL(location);
|
|
16
|
+
if (to.protocol !== "https:")
|
|
17
|
+
return null;
|
|
18
|
+
const base = new URL(baseUrl);
|
|
19
|
+
const tail = (h) => h.split(".").slice(-2).join(".");
|
|
20
|
+
if (tail(to.hostname) !== tail(base.hostname))
|
|
21
|
+
return null;
|
|
22
|
+
return to.origin;
|
|
6
23
|
}
|
|
7
24
|
catch {
|
|
8
|
-
return null; // relative Location —
|
|
25
|
+
return null; // relative/unparseable Location — never actionable
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Strip control chars (incl. ESC) before a server-supplied string reaches the
|
|
29
|
+
* terminal — a Location carrying ANSI sequences could rewrite what the user
|
|
30
|
+
* sees (review find). */
|
|
31
|
+
function sanitizeForTerminal(s) {
|
|
32
|
+
let out = "";
|
|
33
|
+
for (const ch of s) {
|
|
34
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
35
|
+
if (c >= 0x20 && c !== 0x7f && !(c >= 0x80 && c <= 0x9f))
|
|
36
|
+
out += ch;
|
|
9
37
|
}
|
|
38
|
+
return out.slice(0, 200);
|
|
10
39
|
}
|
|
11
40
|
export class Api {
|
|
12
41
|
ctx;
|
|
@@ -53,8 +82,13 @@ export class Api {
|
|
|
53
82
|
// ctx.fetch directly and must keep following them — storage hands out redirects.
|
|
54
83
|
if (res.status >= 300 && res.status < 400) {
|
|
55
84
|
const to = res.headers.get("location") ?? "(no location header)";
|
|
56
|
-
throw new CliError(`${this.baseUrl} redirected to ${to} — the API must be called on its canonical origin, because a redirect drops the request body and the login token.`, EXIT.NETWORK, {
|
|
57
|
-
|
|
85
|
+
throw new CliError(`${this.baseUrl} redirected to ${sanitizeForTerminal(to)} — the API must be called on its canonical origin, because a redirect drops the request body and the login token.`, EXIT.NETWORK, {
|
|
86
|
+
// An UNTRUSTED target (cross-domain / non-https — a captive portal or
|
|
87
|
+
// MITM can mint a 3xx) gets the STATIC known-good hint, never its own
|
|
88
|
+
// origin echoed back as an instruction. DEFAULT_API_BASE is imported,
|
|
89
|
+
// not re-typed: the hardcoded literal here was a second copy of the
|
|
90
|
+
// exact constant whose staleness caused the original apex outage.
|
|
91
|
+
hint: `set PROMPTDOCK_API_BASE to the canonical origin, e.g. PROMPTDOCK_API_BASE=${trustedRedirectOrigin(to, this.baseUrl) ?? DEFAULT_API_BASE}`,
|
|
58
92
|
footer: "api",
|
|
59
93
|
});
|
|
60
94
|
}
|
package/dist/auth.js
CHANGED
|
@@ -30,8 +30,14 @@ export async function deviceFlowLogin(ctx, baseUrl, opts) {
|
|
|
30
30
|
ctx.io.out("(if the page didn't open, paste the URL into your browser)");
|
|
31
31
|
}
|
|
32
32
|
ctx.io.out("");
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Clamp through Number.isFinite (review find): a missing/renamed numeric in
|
|
34
|
+
// the start response made `deadline` NaN, and `remainMs <= 0` is FALSE for
|
|
35
|
+
// NaN — an infinite countdown spin. A malformed numeric now degrades to the
|
|
36
|
+
// protocol defaults instead of hanging the login.
|
|
37
|
+
const expiresIn = Number(start.expires_in);
|
|
38
|
+
const deadline = ctx.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 600) * 1000;
|
|
39
|
+
const interval0 = Number(start.interval);
|
|
40
|
+
let intervalSecs = Number.isFinite(interval0) && interval0 > 0 ? interval0 : 5;
|
|
35
41
|
poll: for (;;) {
|
|
36
42
|
if (ctx.now() >= deadline)
|
|
37
43
|
break poll; // local expiry — restart below
|
|
@@ -61,7 +67,22 @@ export async function deviceFlowLogin(ctx, baseUrl, opts) {
|
|
|
61
67
|
footer: "access_denied",
|
|
62
68
|
});
|
|
63
69
|
}
|
|
64
|
-
|
|
70
|
+
if (verb === "expired_token")
|
|
71
|
+
break poll; // genuine expiry — restart below
|
|
72
|
+
// NOT an RFC verb (review find): the wrapper's own limiter answers this
|
|
73
|
+
// route in the app ENVELOPE ({error:{code,…}} — e.g. rate_limited when
|
|
74
|
+
// several devs share one NAT), and a 500 arrives enveloped too. Both used
|
|
75
|
+
// to collapse into "expired" → a restart → "login timed out", hiding the
|
|
76
|
+
// real cause. Surface the truth instead of calling it a timeout.
|
|
77
|
+
const env = Api.envelope(res.body);
|
|
78
|
+
if (env) {
|
|
79
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
80
|
+
const wait = env.code === "rate_limited" && Number.isFinite(retryAfter) && retryAfter > 0
|
|
81
|
+
? ` — retry in ~${Math.ceil(retryAfter)}s`
|
|
82
|
+
: "";
|
|
83
|
+
throw new CliError(`login failed: ${env.message}${wait}`, res.status >= 500 ? EXIT.NETWORK : EXIT.DENIED, { footer: "api" });
|
|
84
|
+
}
|
|
85
|
+
break poll; // genuinely unrecognized body — restart below
|
|
65
86
|
}
|
|
66
87
|
clearCountdown(ctx);
|
|
67
88
|
restarts += 1;
|
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
import type { CliContext } from "../context.js";
|
|
2
|
+
import { type SkillRef } from "../ref.js";
|
|
2
3
|
export declare function runInstall(ctx: CliContext, positionals: string[], flags: Record<string, string | boolean>): Promise<void>;
|
|
4
|
+
type PickedTarget = {
|
|
5
|
+
id: string;
|
|
6
|
+
dir: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function resolveTarget(ctx: CliContext, flags: Record<string, string | boolean>, ref: SkillRef): Promise<PickedTarget>;
|
|
9
|
+
export {};
|
package/dist/commands/install.js
CHANGED
|
@@ -8,7 +8,7 @@ import { ensureAuth } from "../auth.js";
|
|
|
8
8
|
import { CliError, EXIT, usageError } from "../errors.js";
|
|
9
9
|
import { checkInstallResponse, performInstall } from "../installer.js";
|
|
10
10
|
import { formatRef, parseSkillRef } from "../ref.js";
|
|
11
|
-
import { detectTargets, nextStepLine, targetById, targetInstallDir, TARGETS, } from "../registry.js";
|
|
11
|
+
import { detectionLabel, detectTargets, detectTargetsAnyScope, nextStepLine, scopeChoices, targetById, targetInstallDir, TARGETS, } from "../registry.js";
|
|
12
12
|
import { readReceipt } from "../receipts.js";
|
|
13
13
|
import { colors, confirm, formatBytes, promptSelect } from "../ui.js";
|
|
14
14
|
import { assertInstallable } from "../verdicts.js";
|
|
@@ -102,7 +102,7 @@ export async function runInstall(ctx, positionals, flags) {
|
|
|
102
102
|
}
|
|
103
103
|
for (const line of summary)
|
|
104
104
|
ctx.io.out(line);
|
|
105
|
-
const go = await confirm(ctx.io, isUpdate ? "Update?" : "Install?", true);
|
|
105
|
+
const go = await confirm(ctx.io, isUpdate ? "Update?" : "Install?", true, c);
|
|
106
106
|
if (!go) {
|
|
107
107
|
ctx.io.out("Cancelled — nothing installed.");
|
|
108
108
|
return;
|
|
@@ -144,9 +144,47 @@ export async function runInstall(ctx, positionals, flags) {
|
|
|
144
144
|
ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${resp.skill.title}" v${resp.skill.version} → ${picked.dir}`);
|
|
145
145
|
ctx.io.out(nextStepLine(picked.id, resp.skill.slug));
|
|
146
146
|
}
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Project-vs-global, asked AFTER the tool is known so the two options can be
|
|
149
|
+
* shown as their real destination paths rather than an abstraction.
|
|
150
|
+
*
|
|
151
|
+
* Never prompts when the answer is already settled: `-g` IS the answer, and
|
|
152
|
+
* `-y`/non-TTY runs are contractually promptless (they keep the flag default,
|
|
153
|
+
* project-local). So the prompt appears exactly when a human is present and
|
|
154
|
+
* scope is genuinely unstated — which is where it used to be skipped silently.
|
|
155
|
+
*/
|
|
156
|
+
async function resolveScope(ctx, def, ref, opts) {
|
|
157
|
+
if (opts.scopeGiven || opts.yes || !ctx.io.isTTY)
|
|
158
|
+
return opts.global;
|
|
159
|
+
const { choices, preselect } = scopeChoices(def, {
|
|
160
|
+
cwd: ctx.cwd,
|
|
161
|
+
home: ctx.home,
|
|
162
|
+
slug: ref.slug,
|
|
163
|
+
});
|
|
164
|
+
// Standing in $HOME, "this project" and "global" are the SAME directory —
|
|
165
|
+
// a two-row prompt showing one path twice reads as a bug (review find).
|
|
166
|
+
// The answer is settled: skip the question.
|
|
167
|
+
if (choices[0].dir === choices[1].dir)
|
|
168
|
+
return opts.global;
|
|
169
|
+
// Display dirs ~-abbreviated: full absolute paths wrap a stock 80-col
|
|
170
|
+
// terminal, and a wrapped line desyncs the picker's in-place repaint.
|
|
171
|
+
// The RETURNED dir stays absolute — only the label is shortened.
|
|
172
|
+
const tildify = (d) => (d.startsWith(ctx.home) ? `~${d.slice(ctx.home.length)}` : d);
|
|
173
|
+
const idx = await promptSelect(ctx.io, `Install ${def.label} skill in this directory or for your user account?`, choices.map((c) => `${c.label.padEnd(14)} ${tildify(c.dir)}${c.detected ? " (detected)" : ""}`), preselect, colors(ctx.env, ctx.io.isTTY));
|
|
174
|
+
return choices[idx].global;
|
|
175
|
+
}
|
|
176
|
+
// Exported for the wiring tests: the pure path maths lives in registry.ts, but
|
|
177
|
+
// the ORDER and CONDITIONS of the two prompts are what regressed before (the
|
|
178
|
+
// registry always supported global; this function simply never asked).
|
|
179
|
+
export async function resolveTarget(ctx, flags, ref) {
|
|
180
|
+
// `-g` is an explicit answer to the scope question; its absence is NOT an
|
|
181
|
+
// answer, it just means "unstated" (which is why we now ask instead of
|
|
182
|
+
// defaulting silently to project-local).
|
|
183
|
+
const scopeGiven = flags.global === true;
|
|
184
|
+
const global = scopeGiven;
|
|
185
|
+
const yes = flags.yes === true;
|
|
149
186
|
if (typeof flags.dir === "string") {
|
|
187
|
+
// An explicit path IS the scope — nothing left to ask.
|
|
150
188
|
const dir = isAbsolute(flags.dir) ? flags.dir : resolvePath(ctx.cwd, flags.dir);
|
|
151
189
|
return { id: "custom", dir };
|
|
152
190
|
}
|
|
@@ -155,16 +193,24 @@ async function resolveTarget(ctx, flags, ref) {
|
|
|
155
193
|
if (!def) {
|
|
156
194
|
throw usageError(`unknown --target "${flags.target}"`, `known targets: ${TARGETS.map((t) => t.id).join(", ")} (or use --dir <path>)`);
|
|
157
195
|
}
|
|
196
|
+
// --target picks the TOOL, not the scope: an interactive run still asks.
|
|
197
|
+
const useGlobal = await resolveScope(ctx, def, ref, { scopeGiven, global, yes });
|
|
158
198
|
return {
|
|
159
199
|
id: def.id,
|
|
160
|
-
dir: targetInstallDir(def, {
|
|
200
|
+
dir: targetInstallDir(def, {
|
|
201
|
+
cwd: ctx.cwd,
|
|
202
|
+
home: ctx.home,
|
|
203
|
+
global: useGlobal,
|
|
204
|
+
slug: ref.slug,
|
|
205
|
+
}),
|
|
161
206
|
};
|
|
162
207
|
}
|
|
163
|
-
const detected = detectTargets({ cwd: ctx.cwd, home: ctx.home, global });
|
|
164
208
|
if (!ctx.io.isTTY)
|
|
165
209
|
throw usageError(NON_TTY_TARGET_HINT);
|
|
166
|
-
if (
|
|
210
|
+
if (yes) {
|
|
167
211
|
// -y skips the PICKER only, and only when detection is unambiguous (D-UX11).
|
|
212
|
+
// Scope is known here (the flag), so this stays single-scope detection.
|
|
213
|
+
const detected = detectTargets({ cwd: ctx.cwd, home: ctx.home, global });
|
|
168
214
|
const found = detected.filter((t) => t.detected);
|
|
169
215
|
if (found.length === 1) {
|
|
170
216
|
return {
|
|
@@ -176,16 +222,17 @@ async function resolveTarget(ctx, flags, ref) {
|
|
|
176
222
|
? "-y needs a detectable target and none was found — pass --target <tool> or --dir <path>"
|
|
177
223
|
: `-y needs an unambiguous target and ${found.length} tools were detected — pass --target <tool> or --dir <path>`);
|
|
178
224
|
}
|
|
179
|
-
|
|
225
|
+
// Interactive: TOOL first, then SCOPE. Detection spans both scopes here
|
|
226
|
+
// because the scope is not yet chosen.
|
|
227
|
+
const detected = detectTargetsAnyScope({ cwd: ctx.cwd, home: ctx.home });
|
|
180
228
|
const options = detected.map((t) => {
|
|
181
|
-
const
|
|
182
|
-
const tags = [t.detected ? "(detected)" : null, t.experimental ? "(experimental)" : null]
|
|
229
|
+
const tags = [detectionLabel(t), t.experimental ? "(experimental)" : null]
|
|
183
230
|
.filter(Boolean)
|
|
184
231
|
.join(" ");
|
|
185
|
-
return `${t.label.padEnd(16)}
|
|
232
|
+
return `${t.label.padEnd(16)}${tags ? ` ${tags}` : ""}`;
|
|
186
233
|
});
|
|
187
|
-
options.push("Custom path…
|
|
188
|
-
const idx = await promptSelect(ctx.io,
|
|
234
|
+
options.push("Custom path… (type any directory)");
|
|
235
|
+
const idx = await promptSelect(ctx.io, "Which tool is this skill for?", options, 0, colors(ctx.env, ctx.io.isTTY));
|
|
189
236
|
if (idx === options.length - 1) {
|
|
190
237
|
const answer = (await ctx.io.question("Directory: ")).trim();
|
|
191
238
|
if (!answer)
|
|
@@ -193,9 +240,15 @@ async function resolveTarget(ctx, flags, ref) {
|
|
|
193
240
|
return { id: "custom", dir: isAbsolute(answer) ? answer : resolvePath(ctx.cwd, answer) };
|
|
194
241
|
}
|
|
195
242
|
const chosen = detected[idx];
|
|
243
|
+
const useGlobal = await resolveScope(ctx, chosen, ref, { scopeGiven, global, yes });
|
|
196
244
|
return {
|
|
197
245
|
id: chosen.id,
|
|
198
|
-
dir: targetInstallDir(chosen, {
|
|
246
|
+
dir: targetInstallDir(chosen, {
|
|
247
|
+
cwd: ctx.cwd,
|
|
248
|
+
home: ctx.home,
|
|
249
|
+
global: useGlobal,
|
|
250
|
+
slug: ref.slug,
|
|
251
|
+
}),
|
|
199
252
|
};
|
|
200
253
|
}
|
|
201
254
|
function dirIsNonEmpty(dir) {
|
|
@@ -70,7 +70,7 @@ export async function runUninstall(ctx, positionals, flags) {
|
|
|
70
70
|
if (flags.yes !== true) {
|
|
71
71
|
if (!ctx.io.isTTY)
|
|
72
72
|
throw usageError("confirmation needed in a non-interactive session", "re-run with -y");
|
|
73
|
-
const go = await confirm(ctx.io, `Uninstall ${receipt.ref} from ${item.dir}?`, false);
|
|
73
|
+
const go = await confirm(ctx.io, `Uninstall ${receipt.ref} from ${item.dir}?`, false, c);
|
|
74
74
|
if (!go) {
|
|
75
75
|
ctx.io.out("Skipped.");
|
|
76
76
|
continue;
|
|
@@ -193,7 +193,7 @@ export async function runUpdate(ctx, positionals, flags) {
|
|
|
193
193
|
if (flags.yes !== true) {
|
|
194
194
|
if (!ctx.io.isTTY)
|
|
195
195
|
throw usageError("confirmation needed in a non-interactive session", "re-run with -y (or use --check)");
|
|
196
|
-
const go = await confirm(ctx.io, `Update ${receipt.ref} v${receipt.version} → v${toVersion}?`, true);
|
|
196
|
+
const go = await confirm(ctx.io, `Update ${receipt.ref} v${receipt.version} → v${toVersion}?`, true, c);
|
|
197
197
|
if (!go) {
|
|
198
198
|
report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason: "declined" });
|
|
199
199
|
continue;
|
package/dist/commands/login.js
CHANGED
|
@@ -34,12 +34,41 @@ export async function runLogin(ctx, flags) {
|
|
|
34
34
|
export async function runLogout(ctx) {
|
|
35
35
|
const config = loadConfig(ctx.home);
|
|
36
36
|
const had = typeof config.token === "string";
|
|
37
|
+
// REVOKE server-side FIRST (review CRITICAL: logout used to delete one copy
|
|
38
|
+
// of a 90-day sliding bearer and merely mention the Settings page — a
|
|
39
|
+
// security control named after ending a session that could not end it).
|
|
40
|
+
// Self-scoped: DELETE /cli/tokens/current kills exactly the presented token.
|
|
41
|
+
// Best-effort — offline logout must still clear local state, but a failed
|
|
42
|
+
// revoke is a WARNING, not a footnote.
|
|
43
|
+
let revoked = false;
|
|
44
|
+
if (had) {
|
|
45
|
+
try {
|
|
46
|
+
const api = new Api(ctx, resolveApiBase(ctx.env, config), config.token);
|
|
47
|
+
const res = await api.raw("DELETE", "/api/v1/cli/tokens/current");
|
|
48
|
+
revoked = res.status >= 200 && res.status < 300;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
revoked = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
37
54
|
if (had) {
|
|
38
55
|
delete config.token;
|
|
56
|
+
// api_base travels WITH the token (they are a pair — see config.ts): logout
|
|
57
|
+
// returns the client to the known-good default, so a hostile or stale
|
|
58
|
+
// origin set earlier does not survive the trust reset (review find).
|
|
59
|
+
delete config.api_base;
|
|
39
60
|
saveConfig(ctx.home, config, ctx.platform);
|
|
40
61
|
}
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
if (!had) {
|
|
63
|
+
ctx.io.out("Not logged in — nothing to remove.");
|
|
64
|
+
}
|
|
65
|
+
else if (revoked) {
|
|
66
|
+
ctx.io.out("Logged out — the session was revoked server-side and the local token removed.");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
ctx.io.out("Logged out (local token removed).");
|
|
70
|
+
ctx.io.err("warning: could not revoke the session server-side (offline?). The token stays valid until it expires — revoke it at promptdock.ai → Settings → Account → CLI sessions.");
|
|
71
|
+
}
|
|
43
72
|
if (ctx.env.PROMPTDOCK_TOKEN) {
|
|
44
73
|
ctx.io.out("note: PROMPTDOCK_TOKEN is set in this environment and still authenticates requests.");
|
|
45
74
|
}
|
package/dist/config.js
CHANGED
|
@@ -79,12 +79,45 @@ const trimBase = (s) => s.replace(/\/+$/, "");
|
|
|
79
79
|
// so the token and the origin are a PAIR. Storing the token without its origin means
|
|
80
80
|
// the next run sends a staging token to the default origin and 401s. The apex-pinning
|
|
81
81
|
// problem that motivated the idea is handled by LEGACY_API_BASES below instead.
|
|
82
|
+
/** Loopback hosts — the ONE carve-out from the TLS floor (local dev + the test
|
|
83
|
+
* mock servers). Everything else that receives the bearer must be https. */
|
|
84
|
+
function isLoopbackHost(host) {
|
|
85
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* THE TLS FLOOR (review find): every resolved base carries a 90-day bearer in
|
|
89
|
+
* an Authorization header, and nothing previously constrained the scheme — a
|
|
90
|
+
* stored `http://` origin (written once by a plaintext invocation, or by the
|
|
91
|
+
* api.ts redirect hint before it was hardened) was honoured forever, silently.
|
|
92
|
+
* Enforced on the env override AND the stored value: a config written by an
|
|
93
|
+
* older or hostile release must not survive an upgrade. Throws — a plaintext
|
|
94
|
+
* API base is a misconfiguration to fix, never something to quietly use.
|
|
95
|
+
*/
|
|
96
|
+
function assertSecureBase(base, source) {
|
|
97
|
+
let u;
|
|
98
|
+
try {
|
|
99
|
+
u = new URL(base);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new CliError(`${source === "env" ? "PROMPTDOCK_API_BASE" : "the stored api_base"} is not a valid URL: set it to an origin like https://www.promptdock.ai`, EXIT.USAGE);
|
|
103
|
+
}
|
|
104
|
+
if (u.username || u.password || u.search || u.hash) {
|
|
105
|
+
throw new CliError(`${source === "env" ? "PROMPTDOCK_API_BASE" : "the stored api_base"} must be a bare origin (no credentials, query, or fragment)`, EXIT.USAGE);
|
|
106
|
+
}
|
|
107
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && isLoopbackHost(u.hostname))) {
|
|
108
|
+
throw new CliError(`${source === "env" ? "PROMPTDOCK_API_BASE" : "the stored api_base"} is ${u.protocol}// — the CLI sends your login token on every request, so only https:// (or http://localhost for dev) is allowed. ` +
|
|
109
|
+
(source === "config"
|
|
110
|
+
? "Run `promptdock logout`, then log in again against the https origin."
|
|
111
|
+
: "Set PROMPTDOCK_API_BASE to the https origin."), EXIT.USAGE);
|
|
112
|
+
}
|
|
113
|
+
return base;
|
|
114
|
+
}
|
|
82
115
|
export function resolveApiBase(env, config) {
|
|
83
116
|
if (env.PROMPTDOCK_API_BASE)
|
|
84
|
-
return trimBase(env.PROMPTDOCK_API_BASE);
|
|
117
|
+
return assertSecureBase(trimBase(env.PROMPTDOCK_API_BASE), "env");
|
|
85
118
|
const stored = typeof config.api_base === "string" ? trimBase(config.api_base) : null;
|
|
86
119
|
// Drop a base a broken release pinned here; anything else the user set is honoured.
|
|
87
120
|
if (stored && !LEGACY_API_BASES.has(stored))
|
|
88
|
-
return stored;
|
|
121
|
+
return assertSecureBase(stored, "config");
|
|
89
122
|
return trimBase(DEFAULT_API_BASE);
|
|
90
123
|
}
|
package/dist/context.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Key } from "./select-keys.js";
|
|
1
2
|
/**
|
|
2
3
|
* argv joined for the `X-Promptdock-Cli-Args` header, with bearer tokens removed.
|
|
3
4
|
*
|
|
@@ -21,6 +22,24 @@ export type CliIo = {
|
|
|
21
22
|
isTTY: boolean;
|
|
22
23
|
/** readline question (TTY only — callers must gate on isTTY) */
|
|
23
24
|
question: (prompt: string) => Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Raw keypress stream for the arrow-key picker. OPTIONAL by design: when it is
|
|
27
|
+
* absent, `promptSelect` falls back to the numbered reader built on `question`.
|
|
28
|
+
* That keeps one code path for every environment that cannot do raw mode — a
|
|
29
|
+
* piped stdin, a CI runner, a dumb terminal, and every test fake — without
|
|
30
|
+
* those callers needing to know the picker exists.
|
|
31
|
+
*
|
|
32
|
+
* Returns an unsubscribe that MUST restore the previous terminal mode.
|
|
33
|
+
*/
|
|
34
|
+
rawKeys?: (onKey: (key: Key) => void) => () => void;
|
|
35
|
+
/** Live terminal dimensions — the picker clamps line width to `columns`
|
|
36
|
+
* (wrapped lines desync the in-place repaint) and falls back to the
|
|
37
|
+
* numbered reader when the frame exceeds `rows`. Optional: fakes and
|
|
38
|
+
* non-TTY contexts omit it and the picker assumes 80x24. */
|
|
39
|
+
size?: () => {
|
|
40
|
+
rows: number;
|
|
41
|
+
columns: number;
|
|
42
|
+
};
|
|
24
43
|
};
|
|
25
44
|
export type CliContext = {
|
|
26
45
|
io: CliIo;
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { emitKeypressEvents } from "node:readline";
|
|
4
5
|
import { readFileSync } from "node:fs";
|
|
5
6
|
/**
|
|
6
7
|
* argv joined for the `X-Promptdock-Cli-Args` header, with bearer tokens removed.
|
|
@@ -13,6 +14,12 @@ import { readFileSync } from "node:fs";
|
|
|
13
14
|
* still needs to show that `--token` was passed.
|
|
14
15
|
*/
|
|
15
16
|
export function redactArgs(argv) {
|
|
17
|
+
// ⚠️ COUPLING: this is a DENYLIST keyed on the flags that carry sensitive
|
|
18
|
+
// values. Any NEW value-carrying flag added to args.ts COMMAND_SPECS must be
|
|
19
|
+
// assessed here in the same change — a missed one leaks silently (the header
|
|
20
|
+
// rides every request and every proxy logs it). Current set: --token (the
|
|
21
|
+
// live bearer) and --dir (absolute local paths carry the OS username +
|
|
22
|
+
// client/project names — data the 426 remedy does not need; review find).
|
|
16
23
|
return argv
|
|
17
24
|
.map((a, i) => {
|
|
18
25
|
if (/^pdk_/.test(a))
|
|
@@ -22,10 +29,55 @@ export function redactArgs(argv) {
|
|
|
22
29
|
return "--token=***";
|
|
23
30
|
if (argv[i - 1] === "--token")
|
|
24
31
|
return "***";
|
|
32
|
+
if (/^--dir=/.test(a))
|
|
33
|
+
return "--dir=***";
|
|
34
|
+
if (argv[i - 1] === "--dir")
|
|
35
|
+
return "***";
|
|
25
36
|
return a;
|
|
26
37
|
})
|
|
27
38
|
.join(" ");
|
|
28
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Process-level terminal safety net. The picker's `finally` restores raw mode
|
|
42
|
+
* + cursor on every JS path (resolve/reject/throw, and ^C arrives as a
|
|
43
|
+
* keypress under raw mode) — but an EXTERNAL SIGTERM/SIGHUP or `kill` runs no
|
|
44
|
+
* finally: the process dies with the cursor hidden (`?25l` persists until
|
|
45
|
+
* `reset`; 4 review voices converged on this). One-shot handlers restore the
|
|
46
|
+
* terminal and re-exit with the conventional 128+signal code.
|
|
47
|
+
*/
|
|
48
|
+
let activeRawRelease = null;
|
|
49
|
+
let terminalSafetyInstalled = false;
|
|
50
|
+
function installTerminalSafety() {
|
|
51
|
+
if (terminalSafetyInstalled)
|
|
52
|
+
return;
|
|
53
|
+
terminalSafetyInstalled = true;
|
|
54
|
+
const restore = () => {
|
|
55
|
+
try {
|
|
56
|
+
activeRawRelease?.();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* stdin already torn down */
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
process.stdout.write("[?25h");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* stdout already torn down */
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// 'exit' covers process.exit() and normal termination; fatal signals with
|
|
69
|
+
// default handlers do NOT fire it, hence the explicit signal hooks.
|
|
70
|
+
process.on("exit", restore);
|
|
71
|
+
for (const [sig, code] of [
|
|
72
|
+
["SIGTERM", 143],
|
|
73
|
+
["SIGHUP", 129],
|
|
74
|
+
]) {
|
|
75
|
+
process.on(sig, () => {
|
|
76
|
+
restore();
|
|
77
|
+
process.exit(code);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
29
81
|
/** Read the package's own version (dist/ and src/ both sit one level under the root). */
|
|
30
82
|
export function readOwnVersion() {
|
|
31
83
|
try {
|
|
@@ -70,6 +122,54 @@ export function realContext(argv) {
|
|
|
70
122
|
rl.close();
|
|
71
123
|
}
|
|
72
124
|
},
|
|
125
|
+
// Only offered when raw mode is genuinely available AND the terminal can
|
|
126
|
+
// render ANSI. `setRawMode` is absent whenever stdin is not a TTY (a
|
|
127
|
+
// pipe, a spawned child, CI); TERM=dumb (Emacs M-x shell, some IDE
|
|
128
|
+
// consoles) IS a pty but cannot render the escapes the picker paints —
|
|
129
|
+
// it gets the numbered reader, as the README promises (review find).
|
|
130
|
+
...(isTTY &&
|
|
131
|
+
typeof process.stdin.setRawMode === "function" &&
|
|
132
|
+
process.env.TERM !== "dumb"
|
|
133
|
+
? {
|
|
134
|
+
rawKeys: (onKey) => {
|
|
135
|
+
installTerminalSafety();
|
|
136
|
+
const stdin = process.stdin;
|
|
137
|
+
emitKeypressEvents(stdin);
|
|
138
|
+
const wasRaw = stdin.isRaw === true;
|
|
139
|
+
stdin.setRawMode(true);
|
|
140
|
+
stdin.resume();
|
|
141
|
+
const handler = (_s, key) => {
|
|
142
|
+
if (key)
|
|
143
|
+
onKey(key);
|
|
144
|
+
};
|
|
145
|
+
stdin.on("keypress", handler);
|
|
146
|
+
let released = false;
|
|
147
|
+
// Idempotent: the picker releases in a `finally`, and a second call
|
|
148
|
+
// during process teardown must not throw on an already-closed stdin.
|
|
149
|
+
const release = () => {
|
|
150
|
+
if (released)
|
|
151
|
+
return;
|
|
152
|
+
released = true;
|
|
153
|
+
if (activeRawRelease === release)
|
|
154
|
+
activeRawRelease = null;
|
|
155
|
+
stdin.off("keypress", handler);
|
|
156
|
+
try {
|
|
157
|
+
stdin.setRawMode(wasRaw);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* stdin already torn down */
|
|
161
|
+
}
|
|
162
|
+
stdin.pause();
|
|
163
|
+
};
|
|
164
|
+
activeRawRelease = release;
|
|
165
|
+
return release;
|
|
166
|
+
},
|
|
167
|
+
size: () => ({
|
|
168
|
+
rows: process.stdout.rows ?? 24,
|
|
169
|
+
columns: process.stdout.columns ?? 80,
|
|
170
|
+
}),
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
73
173
|
},
|
|
74
174
|
env: process.env,
|
|
75
175
|
cwd: process.cwd(),
|
package/dist/errors.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export declare const EXIT: {
|
|
|
12
12
|
readonly IO: 5;
|
|
13
13
|
/** couldn't reach the API */
|
|
14
14
|
readonly NETWORK: 6;
|
|
15
|
+
/** Ctrl-C during an interactive picker — 128+SIGINT, the shell convention.
|
|
16
|
+
* Raw mode swallows the real signal (it arrives as a keypress), so the CLI
|
|
17
|
+
* exits with the code a signal death would have produced. Esc (a deliberate
|
|
18
|
+
* in-UI cancel) stays USAGE=1. Scripts never reach pickers (TTY-only). */
|
|
19
|
+
readonly INTERRUPT: 130;
|
|
15
20
|
};
|
|
16
21
|
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
17
22
|
/** DX3: every named error footer links its docs anchor. */
|
package/dist/errors.js
CHANGED
|
@@ -13,6 +13,11 @@ export const EXIT = {
|
|
|
13
13
|
IO: 5,
|
|
14
14
|
/** couldn't reach the API */
|
|
15
15
|
NETWORK: 6,
|
|
16
|
+
/** Ctrl-C during an interactive picker — 128+SIGINT, the shell convention.
|
|
17
|
+
* Raw mode swallows the real signal (it arrives as a keypress), so the CLI
|
|
18
|
+
* exits with the code a signal death would have produced. Esc (a deliberate
|
|
19
|
+
* in-UI cancel) stays USAGE=1. Scripts never reach pickers (TTY-only). */
|
|
20
|
+
INTERRUPT: 130,
|
|
16
21
|
};
|
|
17
22
|
/** DX3: every named error footer links its docs anchor. */
|
|
18
23
|
export function footerUrl(code) {
|
|
@@ -3,7 +3,7 @@ export declare const MAX_SKILL_TOTAL_BYTES = 5242880;
|
|
|
3
3
|
export declare const MAX_SKILL_FILE_BYTES = 1048576;
|
|
4
4
|
export declare const MAX_SKILL_IMAGES = 5;
|
|
5
5
|
export declare const SKILL_REVEAL_DAILY_CAP = 15;
|
|
6
|
-
export declare const CLI_MIN_VERSION = "0.1
|
|
6
|
+
export declare const CLI_MIN_VERSION = "1.0.1";
|
|
7
7
|
export declare const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
|
|
8
8
|
export declare const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
|
|
9
9
|
export declare const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
|
|
@@ -5,7 +5,7 @@ export const MAX_SKILL_TOTAL_BYTES = 5242880;
|
|
|
5
5
|
export const MAX_SKILL_FILE_BYTES = 1048576;
|
|
6
6
|
export const MAX_SKILL_IMAGES = 5;
|
|
7
7
|
export const SKILL_REVEAL_DAILY_CAP = 15;
|
|
8
|
-
export const CLI_MIN_VERSION = "0.1
|
|
8
|
+
export const CLI_MIN_VERSION = "1.0.1";
|
|
9
9
|
export const SKILL_SLUG_RE_SOURCE = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$";
|
|
10
10
|
export const SKILL_HANDLE_RE_SOURCE = "^[a-z0-9][a-z0-9_-]{0,62}$";
|
|
11
11
|
export const CANONICAL_INSTALL_COMMAND = "npx promptdock@latest install";
|
package/dist/help.js
CHANGED
|
@@ -21,8 +21,8 @@ export function globalHelp(version) {
|
|
|
21
21
|
help this map
|
|
22
22
|
|
|
23
23
|
Install options
|
|
24
|
-
-g, --global install into the tool's per-user dir
|
|
25
|
-
--target <tool>
|
|
24
|
+
-g, --global install into the tool's per-user dir (skips the scope prompt)
|
|
25
|
+
--target <tool> pick the tool (${TARGETS.map((t) => t.id).join(", ")}); scope is still asked — add -g or -y to skip
|
|
26
26
|
--dir <path> install into an explicit directory
|
|
27
27
|
-y, --yes skip prompts (needs an unambiguous target; NEVER implies --force)
|
|
28
28
|
--force replace a non-empty foreign directory / overwrite local edits
|
|
@@ -62,11 +62,15 @@ session stays valid until revoked in Settings → Account → CLI sessions.`,
|
|
|
62
62
|
Shows the signed-in handle, role, and token expiry.`,
|
|
63
63
|
install: `promptdock install <handle>/<slug> [-g] [--target <tool>] [--dir <path>] [-y] [--force] [--dry-run] [--json]
|
|
64
64
|
|
|
65
|
-
Installs a skill.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
Installs a skill. Interactively it asks two things: which TOOL, then whether
|
|
66
|
+
to install for THIS PROJECT or for your USER ACCOUNT (each shown as its real
|
|
67
|
+
destination path). -g answers the second one up front; --dir answers both.
|
|
68
|
+
|
|
69
|
+
Non-interactive sessions (CI) must pass --target or --dir plus -y, and
|
|
70
|
+
authenticate via PROMPTDOCK_TOKEN. -y skips the prompts ONLY — a non-empty
|
|
71
|
+
foreign directory still requires --force, and scope stays project-local
|
|
72
|
+
unless -g is passed. --dry-run resolves and prints the plan without
|
|
73
|
+
installing (and without spending any premium-unlock slot).`,
|
|
70
74
|
uninstall: `promptdock uninstall <handle>/<slug> | --all [-g] [--target <tool>] [--dir <path>] [--force] [-y] [--json]
|
|
71
75
|
|
|
72
76
|
Removes an installed skill using its .promptdock.json receipt — only files the
|
package/dist/registry.d.ts
CHANGED
|
@@ -26,6 +26,62 @@ export declare function targetInstallDir(t: TargetDef, opts: {
|
|
|
26
26
|
global: boolean;
|
|
27
27
|
slug: string;
|
|
28
28
|
}): string;
|
|
29
|
+
/** Is this tool set up in each scope? Path knowledge stays in this module. */
|
|
30
|
+
export declare function detectScopes(t: TargetDef, opts: {
|
|
31
|
+
cwd: string;
|
|
32
|
+
home: string;
|
|
33
|
+
exists?: (p: string) => boolean;
|
|
34
|
+
}): {
|
|
35
|
+
local: boolean;
|
|
36
|
+
global: boolean;
|
|
37
|
+
};
|
|
38
|
+
export type ScopedTarget = TargetDef & {
|
|
39
|
+
detectedLocal: boolean;
|
|
40
|
+
detectedGlobal: boolean;
|
|
41
|
+
detected: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Scope-agnostic detection for the interactive picker, which asks for the TOOL
|
|
45
|
+
* first and the SCOPE second — so at list time there is no single scope to
|
|
46
|
+
* probe. `detected` (either scope) drives the D-UX11 float ordering; the
|
|
47
|
+
* per-scope flags let the row say WHERE it was found, which the old
|
|
48
|
+
* one-scope-only tag could not.
|
|
49
|
+
*/
|
|
50
|
+
export declare function detectTargetsAnyScope(opts: {
|
|
51
|
+
cwd: string;
|
|
52
|
+
home: string;
|
|
53
|
+
exists?: (p: string) => boolean;
|
|
54
|
+
}): ScopedTarget[];
|
|
55
|
+
/** Where a tool was found, for the picker row ("" when nowhere). */
|
|
56
|
+
export declare function detectionLabel(t: {
|
|
57
|
+
detectedLocal: boolean;
|
|
58
|
+
detectedGlobal: boolean;
|
|
59
|
+
}): string;
|
|
60
|
+
export type ScopeChoice = {
|
|
61
|
+
global: boolean;
|
|
62
|
+
label: string;
|
|
63
|
+
dir: string;
|
|
64
|
+
detected: boolean;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* The two concrete destinations for a chosen tool, in prompt order (project,
|
|
68
|
+
* then global), plus the preselect index. Pure so the prompt's content is
|
|
69
|
+
* unit-testable without a TTY.
|
|
70
|
+
*
|
|
71
|
+
* Preselect prefers the scope where the tool is ALREADY set up. When it is set
|
|
72
|
+
* up in both — or in neither — it stays project-local, which is exactly what
|
|
73
|
+
* `-y` and non-TTY runs default to, so the interactive and non-interactive
|
|
74
|
+
* paths can never disagree about the same repo.
|
|
75
|
+
*/
|
|
76
|
+
export declare function scopeChoices(t: TargetDef, opts: {
|
|
77
|
+
cwd: string;
|
|
78
|
+
home: string;
|
|
79
|
+
slug: string;
|
|
80
|
+
exists?: (p: string) => boolean;
|
|
81
|
+
}): {
|
|
82
|
+
choices: [ScopeChoice, ScopeChoice];
|
|
83
|
+
preselect: 0 | 1;
|
|
84
|
+
};
|
|
29
85
|
export type DetectedTarget = TargetDef & {
|
|
30
86
|
detected: boolean;
|
|
31
87
|
};
|
package/dist/registry.js
CHANGED
|
@@ -80,6 +80,71 @@ export function targetBaseDir(t, opts) {
|
|
|
80
80
|
export function targetInstallDir(t, opts) {
|
|
81
81
|
return join(targetBaseDir(t, opts), opts.slug);
|
|
82
82
|
}
|
|
83
|
+
/** Is this tool set up in each scope? Path knowledge stays in this module. */
|
|
84
|
+
export function detectScopes(t, opts) {
|
|
85
|
+
const exists = opts.exists ?? existsSync;
|
|
86
|
+
return {
|
|
87
|
+
local: exists(join(opts.cwd, t.detectLocal)),
|
|
88
|
+
global: exists(join(opts.home, t.detectGlobal)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Scope-agnostic detection for the interactive picker, which asks for the TOOL
|
|
93
|
+
* first and the SCOPE second — so at list time there is no single scope to
|
|
94
|
+
* probe. `detected` (either scope) drives the D-UX11 float ordering; the
|
|
95
|
+
* per-scope flags let the row say WHERE it was found, which the old
|
|
96
|
+
* one-scope-only tag could not.
|
|
97
|
+
*/
|
|
98
|
+
export function detectTargetsAnyScope(opts) {
|
|
99
|
+
const all = TARGETS.map((t) => {
|
|
100
|
+
const found = detectScopes(t, opts);
|
|
101
|
+
return {
|
|
102
|
+
...t,
|
|
103
|
+
detectedLocal: found.local,
|
|
104
|
+
detectedGlobal: found.global,
|
|
105
|
+
detected: found.local || found.global,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
return [...all.filter((t) => t.detected), ...all.filter((t) => !t.detected)];
|
|
109
|
+
}
|
|
110
|
+
/** Where a tool was found, for the picker row ("" when nowhere). */
|
|
111
|
+
export function detectionLabel(t) {
|
|
112
|
+
if (t.detectedLocal && t.detectedGlobal)
|
|
113
|
+
return "(detected: project + global)";
|
|
114
|
+
if (t.detectedLocal)
|
|
115
|
+
return "(detected: project)";
|
|
116
|
+
if (t.detectedGlobal)
|
|
117
|
+
return "(detected: global)";
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The two concrete destinations for a chosen tool, in prompt order (project,
|
|
122
|
+
* then global), plus the preselect index. Pure so the prompt's content is
|
|
123
|
+
* unit-testable without a TTY.
|
|
124
|
+
*
|
|
125
|
+
* Preselect prefers the scope where the tool is ALREADY set up. When it is set
|
|
126
|
+
* up in both — or in neither — it stays project-local, which is exactly what
|
|
127
|
+
* `-y` and non-TTY runs default to, so the interactive and non-interactive
|
|
128
|
+
* paths can never disagree about the same repo.
|
|
129
|
+
*/
|
|
130
|
+
export function scopeChoices(t, opts) {
|
|
131
|
+
const found = detectScopes(t, opts);
|
|
132
|
+
const choices = [
|
|
133
|
+
{
|
|
134
|
+
global: false,
|
|
135
|
+
label: "This directory",
|
|
136
|
+
dir: targetInstallDir(t, { ...opts, global: false }),
|
|
137
|
+
detected: found.local,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
global: true,
|
|
141
|
+
label: "Global",
|
|
142
|
+
dir: targetInstallDir(t, { ...opts, global: true }),
|
|
143
|
+
detected: found.global,
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
return { choices, preselect: !found.local && found.global ? 1 : 0 };
|
|
147
|
+
}
|
|
83
148
|
/**
|
|
84
149
|
* D-UX11 ordering: detected tools float to the top (registry order preserved
|
|
85
150
|
* within each group); the preselect is index 0 (first detected, else Claude).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key handling for the interactive picker, as a PURE reducer.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of ui.ts on purpose: the rendering half needs a real TTY (raw mode,
|
|
5
|
+
* cursor moves) and cannot be unit-tested, but the DECISIONS — what wraps, what
|
|
6
|
+
* commits, what cancels — are the part that regresses. Pure in, pure out, so
|
|
7
|
+
* every key path is pinned by a table test with no terminal involved.
|
|
8
|
+
*/
|
|
9
|
+
/** The subset of node:readline's keypress event this reducer reads. */
|
|
10
|
+
export type Key = {
|
|
11
|
+
name?: string;
|
|
12
|
+
sequence?: string;
|
|
13
|
+
ctrl?: boolean;
|
|
14
|
+
meta?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type SelectState = {
|
|
17
|
+
/** highlighted option */
|
|
18
|
+
index: number;
|
|
19
|
+
/** digits typed so far — the "type a number" path, empty when unused */
|
|
20
|
+
buffer: string;
|
|
21
|
+
};
|
|
22
|
+
export type SelectResult = {
|
|
23
|
+
kind: "state";
|
|
24
|
+
state: SelectState;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "commit";
|
|
27
|
+
index: number;
|
|
28
|
+
}
|
|
29
|
+
/** `signal` marks Ctrl-C (exit 130, the 128+SIGINT convention); Esc/Ctrl-D
|
|
30
|
+
* are deliberate in-UI cancels (exit 1). */
|
|
31
|
+
| {
|
|
32
|
+
kind: "cancel";
|
|
33
|
+
signal?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** a typed number that names no option — caller flashes a hint, keeps the prompt */
|
|
36
|
+
| {
|
|
37
|
+
kind: "reject";
|
|
38
|
+
state: SelectState;
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* One keypress → the next state, or a terminal outcome.
|
|
43
|
+
*
|
|
44
|
+
* Both input styles stay live at once, which is the whole point: arrows move the
|
|
45
|
+
* highlight, digits ALSO move it (so typing is visible before you commit), and
|
|
46
|
+
* Enter commits whichever the user last expressed. Space commits the highlight
|
|
47
|
+
* only — it is a "this one" gesture, and treating it as a number-confirm would
|
|
48
|
+
* make a half-typed "1" + Space silently pick option 1 when the user meant 12.
|
|
49
|
+
*
|
|
50
|
+
* Digits never auto-commit. `3` in a 4-item list is unambiguous and a fancier
|
|
51
|
+
* picker would fire immediately, but the user asked to KEEP number entry, and
|
|
52
|
+
* number entry means "type it, then press Enter" — auto-firing would make a
|
|
53
|
+
* two-digit list behave differently from a one-digit list.
|
|
54
|
+
*
|
|
55
|
+
* `hotkeys` (letter → option index) commit IMMEDIATELY — the confirm path
|
|
56
|
+
* passes {y:0, n:1}. Without this, `n`+Enter at a default-Yes "Install?"
|
|
57
|
+
* committed the DEFAULT: the muscle-memory answer this CLI's own [y/N]
|
|
58
|
+
* fallback trains was silently ignored and the UI executed the opposite of
|
|
59
|
+
* what the user typed (dual-review find, PTY-proven).
|
|
60
|
+
*/
|
|
61
|
+
export declare function reduceSelectKey(state: SelectState, key: Key, count: number, hotkeys?: Record<string, number>): SelectResult;
|
|
62
|
+
/** Footer hint. Mentions BOTH input styles — neither is discoverable otherwise.
|
|
63
|
+
* `hotkeyLabel` (e.g. "y/n") leads when the picker has letter hotkeys. */
|
|
64
|
+
export declare function selectHint(count: number, buffer: string, hotkeyLabel?: string): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key handling for the interactive picker, as a PURE reducer.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of ui.ts on purpose: the rendering half needs a real TTY (raw mode,
|
|
5
|
+
* cursor moves) and cannot be unit-tested, but the DECISIONS — what wraps, what
|
|
6
|
+
* commits, what cancels — are the part that regresses. Pure in, pure out, so
|
|
7
|
+
* every key path is pinned by a table test with no terminal involved.
|
|
8
|
+
*/
|
|
9
|
+
const wrap = (i, count) => ((i % count) + count) % count;
|
|
10
|
+
/**
|
|
11
|
+
* One keypress → the next state, or a terminal outcome.
|
|
12
|
+
*
|
|
13
|
+
* Both input styles stay live at once, which is the whole point: arrows move the
|
|
14
|
+
* highlight, digits ALSO move it (so typing is visible before you commit), and
|
|
15
|
+
* Enter commits whichever the user last expressed. Space commits the highlight
|
|
16
|
+
* only — it is a "this one" gesture, and treating it as a number-confirm would
|
|
17
|
+
* make a half-typed "1" + Space silently pick option 1 when the user meant 12.
|
|
18
|
+
*
|
|
19
|
+
* Digits never auto-commit. `3` in a 4-item list is unambiguous and a fancier
|
|
20
|
+
* picker would fire immediately, but the user asked to KEEP number entry, and
|
|
21
|
+
* number entry means "type it, then press Enter" — auto-firing would make a
|
|
22
|
+
* two-digit list behave differently from a one-digit list.
|
|
23
|
+
*
|
|
24
|
+
* `hotkeys` (letter → option index) commit IMMEDIATELY — the confirm path
|
|
25
|
+
* passes {y:0, n:1}. Without this, `n`+Enter at a default-Yes "Install?"
|
|
26
|
+
* committed the DEFAULT: the muscle-memory answer this CLI's own [y/N]
|
|
27
|
+
* fallback trains was silently ignored and the UI executed the opposite of
|
|
28
|
+
* what the user typed (dual-review find, PTY-proven).
|
|
29
|
+
*/
|
|
30
|
+
export function reduceSelectKey(state, key, count, hotkeys) {
|
|
31
|
+
const name = key.name;
|
|
32
|
+
const seq = key.sequence;
|
|
33
|
+
const keep = (s) => ({ kind: "state", state: s });
|
|
34
|
+
// Raw mode swallows SIGINT, so Ctrl-C is ours to honour or the picker hangs.
|
|
35
|
+
// It carries the `signal` mark → exit 130; Esc/^D are deliberate cancels.
|
|
36
|
+
if (key.ctrl && name === "c")
|
|
37
|
+
return { kind: "cancel", signal: true };
|
|
38
|
+
if (key.ctrl && name === "d")
|
|
39
|
+
return { kind: "cancel" };
|
|
40
|
+
if (name === "escape")
|
|
41
|
+
return { kind: "cancel" };
|
|
42
|
+
// Letter hotkeys beat every other mapping except cancel — `n` must never be
|
|
43
|
+
// read as "ignored key" while the footer implies typing works.
|
|
44
|
+
if (name && !key.ctrl && !key.meta && hotkeys && hotkeys[name] !== undefined) {
|
|
45
|
+
const idx = hotkeys[name];
|
|
46
|
+
if (idx >= 0 && idx < count)
|
|
47
|
+
return { kind: "commit", index: idx };
|
|
48
|
+
}
|
|
49
|
+
if (name === "return" || name === "enter") {
|
|
50
|
+
if (state.buffer === "")
|
|
51
|
+
return { kind: "commit", index: state.index };
|
|
52
|
+
const n = Number(state.buffer);
|
|
53
|
+
if (Number.isInteger(n) && n >= 1 && n <= count)
|
|
54
|
+
return { kind: "commit", index: n - 1 };
|
|
55
|
+
return {
|
|
56
|
+
kind: "reject",
|
|
57
|
+
state: { index: state.index, buffer: "" },
|
|
58
|
+
message: `Enter a number between 1 and ${count}.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (name === "space") {
|
|
62
|
+
// With a live digit buffer, Space means what Enter means — validate the
|
|
63
|
+
// number. Committing the stale highlight would discard the visible buffer
|
|
64
|
+
// ("Number: 12") for a row the user never named (review find).
|
|
65
|
+
if (state.buffer !== "") {
|
|
66
|
+
const n = Number(state.buffer);
|
|
67
|
+
if (Number.isInteger(n) && n >= 1 && n <= count)
|
|
68
|
+
return { kind: "commit", index: n - 1 };
|
|
69
|
+
return {
|
|
70
|
+
kind: "reject",
|
|
71
|
+
state: { index: state.index, buffer: "" },
|
|
72
|
+
message: `Enter a number between 1 and ${count}.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { kind: "commit", index: state.index };
|
|
76
|
+
}
|
|
77
|
+
if (name === "up" || name === "k" || (key.ctrl && name === "p"))
|
|
78
|
+
return keep({ index: wrap(state.index - 1, count), buffer: "" });
|
|
79
|
+
if (name === "down" || name === "j" || (key.ctrl && name === "n"))
|
|
80
|
+
return keep({ index: wrap(state.index + 1, count), buffer: "" });
|
|
81
|
+
if (name === "home")
|
|
82
|
+
return keep({ index: 0, buffer: "" });
|
|
83
|
+
if (name === "end")
|
|
84
|
+
return keep({ index: count - 1, buffer: "" });
|
|
85
|
+
if (name === "backspace") {
|
|
86
|
+
const buffer = state.buffer.slice(0, -1);
|
|
87
|
+
const n = Number(buffer);
|
|
88
|
+
// Re-point at whatever the shortened buffer now names; keep the highlight if it names nothing.
|
|
89
|
+
const index = buffer !== "" && n >= 1 && n <= count ? n - 1 : state.index;
|
|
90
|
+
return keep({ index, buffer });
|
|
91
|
+
}
|
|
92
|
+
if (seq !== undefined && /^[0-9]$/.test(seq)) {
|
|
93
|
+
// Bounded: 3 digits names any option a 20-row picker can hold; an unbounded
|
|
94
|
+
// buffer + full-frame repaint per digit is quadratic under a digit-spam
|
|
95
|
+
// paste (codex find). Extra digits are ignored, never mis-parsed.
|
|
96
|
+
if (state.buffer.length >= 3)
|
|
97
|
+
return keep(state);
|
|
98
|
+
// Leading zeros are dropped so "0" then "3" is 3, not a dead buffer.
|
|
99
|
+
const buffer = (state.buffer + seq).replace(/^0+(?=\d)/, "");
|
|
100
|
+
const n = Number(buffer);
|
|
101
|
+
const index = n >= 1 && n <= count ? n - 1 : state.index;
|
|
102
|
+
return keep({ index, buffer });
|
|
103
|
+
}
|
|
104
|
+
return keep(state);
|
|
105
|
+
}
|
|
106
|
+
/** Footer hint. Mentions BOTH input styles — neither is discoverable otherwise.
|
|
107
|
+
* `hotkeyLabel` (e.g. "y/n") leads when the picker has letter hotkeys. */
|
|
108
|
+
export function selectHint(count, buffer, hotkeyLabel) {
|
|
109
|
+
if (buffer !== "")
|
|
110
|
+
return `Number: ${buffer} ↵ confirm · ⌫ clear · esc cancel`;
|
|
111
|
+
const hot = hotkeyLabel ? `${hotkeyLabel} · ` : "";
|
|
112
|
+
return `${hot}↑↓ move · ↵/space select · 1-${count} then ↵ · esc cancel`;
|
|
113
|
+
}
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CliIo } from "./context.js";
|
|
2
|
-
/** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
|
|
2
|
+
/** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
|
|
3
|
+
* TERM=dumb too — a terminal that can't render ANSI gets plain text. */
|
|
3
4
|
export declare function colors(env: Record<string, string | undefined>, isTTY: boolean): {
|
|
4
5
|
bold: (s: string) => string;
|
|
5
6
|
dim: (s: string) => string;
|
|
@@ -14,9 +15,18 @@ export declare function formatCountdown(totalSecs: number): string;
|
|
|
14
15
|
/** ~Nh retry copy for the cap verdict (mirrors the app's premium-cap copy). */
|
|
15
16
|
export declare function retryHours(retryAfterSecs: number): string;
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
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/package.json
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptdock",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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": "a2f9bd83dfba6c7f73a116687fede2e7b2f3c8cb"
|
|
28
46
|
}
|