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