@ahood/cli 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/add.js +34 -8
- package/dist/commands/completion.js +6 -2
- package/dist/commands/edit.js +7 -3
- package/dist/commands/publish.js +8 -1
- package/dist/exit-code.js +26 -0
- package/dist/flags.js +23 -7
- package/dist/help.js +53 -11
- package/dist/http.js +18 -1
- package/dist/index.js +2 -16
- package/dist/spec.js +38 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,6 @@ Run `ahood help`, `ahood help <command>`, or `ahood <command> --help` for the sa
|
|
|
63
63
|
| `2` | Usage or validation error (bad arguments, or the server rejected the request as invalid) |
|
|
64
64
|
| `4` | Authentication required or rejected (not logged in, or the token was refused) |
|
|
65
65
|
| `5` | Not found |
|
|
66
|
-
| `6` | Network/transport error |
|
|
66
|
+
| `6` | Network/transport error, or an upstream server (5xx) error |
|
|
67
67
|
|
|
68
68
|
Full reference, including personal API tokens, CI usage, the public REST API, and the MCP server: **https://ahood.vercel.app/docs**
|
package/dist/commands/add.js
CHANGED
|
@@ -31,6 +31,39 @@ async function fetchVersionMeta(owner, skill, version) {
|
|
|
31
31
|
}
|
|
32
32
|
return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`);
|
|
33
33
|
}
|
|
34
|
+
// Reads a fetch Response body while enforcing MAX_DOWNLOAD_BYTES AS bytes
|
|
35
|
+
// arrive, instead of buffering the whole thing via arrayBuffer() and checking
|
|
36
|
+
// afterwards (ahood-cli#37). A content-length header, when present and
|
|
37
|
+
// already over cap, lets us bail before reading a single byte; but the header
|
|
38
|
+
// is attacker/server-controlled and sometimes just absent (chunked transfer,
|
|
39
|
+
// a mutated/redirected presigned URL), so it's a fast-path optimization only
|
|
40
|
+
// -- the streaming check below is what actually bounds memory in every case.
|
|
41
|
+
async function readBoundedBody(res, maxBytes) {
|
|
42
|
+
const contentLength = Number(res.headers.get("content-length") ?? NaN);
|
|
43
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
44
|
+
await res.body?.cancel().catch(() => { });
|
|
45
|
+
throw new Error(`Download is ${contentLength} bytes, over the ${maxBytes / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
46
|
+
}
|
|
47
|
+
if (!res.body)
|
|
48
|
+
return Buffer.alloc(0);
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let total = 0;
|
|
51
|
+
// Throwing out of a `for await` loop over a ReadableStream runs the async
|
|
52
|
+
// iterator's implicit `return()`, which cancels the underlying stream and
|
|
53
|
+
// releases its reader lock -- no separate res.body.cancel() call needed
|
|
54
|
+
// (and calling one here would fail anyway: the stream is locked to this
|
|
55
|
+
// loop's internal reader). This is what actually stops reading mid-download
|
|
56
|
+
// rather than draining the rest of a huge/malicious body first.
|
|
57
|
+
for await (const chunk of res.body) {
|
|
58
|
+
const buf = Buffer.from(chunk);
|
|
59
|
+
total += buf.length;
|
|
60
|
+
if (total > maxBytes) {
|
|
61
|
+
throw new Error(`Downloaded archive is over the ${maxBytes / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
62
|
+
}
|
|
63
|
+
chunks.push(buf);
|
|
64
|
+
}
|
|
65
|
+
return Buffer.concat(chunks);
|
|
66
|
+
}
|
|
34
67
|
export async function extractTarGz(buffer, destDir) {
|
|
35
68
|
let decompressed;
|
|
36
69
|
try {
|
|
@@ -160,14 +193,7 @@ export async function add(args) {
|
|
|
160
193
|
const body = await downloadRes.text().catch(() => "");
|
|
161
194
|
throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
|
|
162
195
|
}
|
|
163
|
-
const
|
|
164
|
-
if (Number.isFinite(contentLength) && contentLength > MAX_DOWNLOAD_BYTES) {
|
|
165
|
-
throw new Error(`Download is ${contentLength} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
166
|
-
}
|
|
167
|
-
const buffer = Buffer.from(await downloadRes.arrayBuffer());
|
|
168
|
-
if (buffer.length > MAX_DOWNLOAD_BYTES) {
|
|
169
|
-
throw new Error(`Downloaded archive is ${buffer.length} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
|
|
170
|
-
}
|
|
196
|
+
const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
|
|
171
197
|
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
172
198
|
if (actualChecksum !== meta.checksum_sha256) {
|
|
173
199
|
throw new Error(`Checksum mismatch for ${key}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { COMMANDS_HELP } from "../help.js";
|
|
1
|
+
import { COMMANDS_HELP, COMMAND_ALIASES } from "../help.js";
|
|
2
2
|
const USAGE = "Usage: ahood completion <bash|zsh|fish>";
|
|
3
|
+
// Includes alias names (e.g. "show" for "view") alongside the primary
|
|
4
|
+
// command names, so shell completion offers every name a user might type,
|
|
5
|
+
// not just the ones with their own COMMANDS_HELP entry.
|
|
3
6
|
function commandNames() {
|
|
4
|
-
|
|
7
|
+
const primary = COMMANDS_HELP.map((c) => c.usage.split(" ")[1]);
|
|
8
|
+
return [...primary, ...Object.keys(COMMAND_ALIASES)];
|
|
5
9
|
}
|
|
6
10
|
function bashCompletion() {
|
|
7
11
|
const names = commandNames().join(" ");
|
package/dist/commands/edit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { flagValue } from "../flags.js";
|
|
3
|
-
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
+
import { parseOwnerSkill, validateExternalUrl } from "../spec.js";
|
|
4
4
|
const USAGE = "Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
|
|
5
5
|
// Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
|
|
6
6
|
// (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
|
|
@@ -30,11 +30,15 @@ export async function edit(args) {
|
|
|
30
30
|
body.visibility = visibility;
|
|
31
31
|
}
|
|
32
32
|
const homepage = flagValue(args, "--homepage");
|
|
33
|
-
if (homepage !== undefined)
|
|
33
|
+
if (homepage !== undefined) {
|
|
34
|
+
validateExternalUrl(homepage, "--homepage");
|
|
34
35
|
body.homepage = homepage;
|
|
36
|
+
}
|
|
35
37
|
const repository = flagValue(args, "--repository");
|
|
36
|
-
if (repository !== undefined)
|
|
38
|
+
if (repository !== undefined) {
|
|
39
|
+
validateExternalUrl(repository, "--repository");
|
|
37
40
|
body.repository = repository;
|
|
41
|
+
}
|
|
38
42
|
if (Object.keys(body).length === 0) {
|
|
39
43
|
throw new Error("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
|
|
40
44
|
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -4,7 +4,7 @@ import { pack } from "tar-stream";
|
|
|
4
4
|
import { createGzip } from "node:zlib";
|
|
5
5
|
import { apiJson, ApiError } from "../http.js";
|
|
6
6
|
import { flagValue } from "../flags.js";
|
|
7
|
-
import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
|
|
7
|
+
import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
|
|
8
8
|
// Processing (decompress/validate/checksum/scan/upload) now happens in a
|
|
9
9
|
// background workflow, not inline in versions/complete's response (server
|
|
10
10
|
// issue #120) -- complete returns 202 immediately, so this polls
|
|
@@ -129,6 +129,13 @@ function parsePublishArgs(args) {
|
|
|
129
129
|
if (!SEMVER_RE.test(version)) {
|
|
130
130
|
throw new Error(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
|
|
131
131
|
}
|
|
132
|
+
// Validated here, before any network call (including the tar/gzip of the
|
|
133
|
+
// skill directory) -- these only ever reach the server via
|
|
134
|
+
// createSkillForPublish's create-skill body (ahood-cli#34).
|
|
135
|
+
if (homepage !== undefined)
|
|
136
|
+
validateExternalUrl(homepage, "--homepage");
|
|
137
|
+
if (repository !== undefined)
|
|
138
|
+
validateExternalUrl(repository, "--repository");
|
|
132
139
|
return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository };
|
|
133
140
|
}
|
|
134
141
|
// Creates the skill under the caller's own account when versions/init 404s
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ApiError, NetworkError } from "./http.js";
|
|
2
|
+
// A hung/black-holed connection throws undici's generic TypeError("fetch
|
|
3
|
+
// failed") -- http.ts wraps that into NetworkError so it gets its own exit
|
|
4
|
+
// code here, distinct from a server-returned ApiError. Kept in its own
|
|
5
|
+
// module (rather than inline in index.ts) so it's importable in tests
|
|
6
|
+
// without triggering index.ts's unconditional top-level main() call.
|
|
7
|
+
export function exitCodeFor(error) {
|
|
8
|
+
if (error instanceof ApiError) {
|
|
9
|
+
if (error.status === 401 || error.status === 403)
|
|
10
|
+
return 4;
|
|
11
|
+
if (error.status === 404)
|
|
12
|
+
return 5;
|
|
13
|
+
// A 5xx is an upstream/server-side failure, not something wrong with the
|
|
14
|
+
// request -- previously fell through to 2 ("usage/validation error"),
|
|
15
|
+
// which told scripts/agents to fix their input when the actual problem
|
|
16
|
+
// was on the server (ahood-cli#31). Treated like a network/transport
|
|
17
|
+
// failure (6) since neither is something retrying with different input
|
|
18
|
+
// would fix.
|
|
19
|
+
if (error.status >= 500)
|
|
20
|
+
return 6;
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
if (error instanceof NetworkError)
|
|
24
|
+
return 6;
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
package/dist/flags.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
// Shared flag-value parsing. Throws instead of silently treating the next flag
|
|
2
2
|
// as this flag's value (e.g. `--tagline --visibility public` used to send
|
|
3
3
|
// "--visibility" as the tagline and drop --visibility entirely).
|
|
4
|
+
//
|
|
5
|
+
// Two forms are accepted:
|
|
6
|
+
// --flag value Space-separated. If `value` itself looks like a flag
|
|
7
|
+
// (starts with "--"), that's treated as "no value given"
|
|
8
|
+
// per the swallow-protection above, and this throws.
|
|
9
|
+
// --flag=value Explicit-equals. Everything after the first "=" is the
|
|
10
|
+
// value, verbatim, even if it starts with "--" -- this is
|
|
11
|
+
// the unambiguous escape hatch for values that genuinely
|
|
12
|
+
// start with "--" (e.g. a tagline like "--fast and cheap").
|
|
4
13
|
export function flagValue(args, flag) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
const prefix = `${flag}=`;
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i];
|
|
17
|
+
if (arg.startsWith(prefix)) {
|
|
18
|
+
return arg.slice(prefix.length);
|
|
19
|
+
}
|
|
20
|
+
if (arg === flag) {
|
|
21
|
+
const value = args[i + 1];
|
|
22
|
+
if (value === undefined || value.startsWith("--")) {
|
|
23
|
+
throw new Error(`${flag} requires a value.`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
11
27
|
}
|
|
12
|
-
return
|
|
28
|
+
return undefined;
|
|
13
29
|
}
|
package/dist/help.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
export const COMMANDS_HELP = [
|
|
2
|
-
{
|
|
3
|
-
|
|
2
|
+
{
|
|
3
|
+
usage: "ahood login",
|
|
4
|
+
summary: "Device-code browser login, stores a token locally.",
|
|
5
|
+
desc: "Device-code browser login, stores a token locally.",
|
|
6
|
+
},
|
|
7
|
+
{ usage: "ahood logout", summary: "Removes the stored token.", desc: "Removes the stored token." },
|
|
4
8
|
{
|
|
5
9
|
usage: "ahood whoami [--json]",
|
|
10
|
+
summary: "Reports whether your stored token still authenticates.",
|
|
6
11
|
desc: "Reports whether your stored token still authenticates.",
|
|
7
12
|
flags: ["--json Emit a machine-readable result instead of prose."],
|
|
8
13
|
},
|
|
9
14
|
{
|
|
10
15
|
usage: "ahood search <query> [--json] [--limit <n>]",
|
|
16
|
+
summary: "Search published skills.",
|
|
11
17
|
desc: "Search published skills.",
|
|
12
18
|
flags: [
|
|
13
19
|
"--json Emit the raw skill objects instead of formatted lines.",
|
|
@@ -17,6 +23,7 @@ export const COMMANDS_HELP = [
|
|
|
17
23
|
},
|
|
18
24
|
{
|
|
19
25
|
usage: "ahood view <owner>/<skill> [--json] [--web]",
|
|
26
|
+
summary: "Show a single skill's details -- tags, license, homepage, repository, dates, and more -- without installing it (alias: ahood show).",
|
|
20
27
|
desc: "Show a single skill's details (tags, license, homepage, repository, dates, etc.) without installing it. Alias: ahood show.",
|
|
21
28
|
flags: [
|
|
22
29
|
"--json Emit the raw skill object instead of formatted lines.",
|
|
@@ -25,22 +32,31 @@ export const COMMANDS_HELP = [
|
|
|
25
32
|
},
|
|
26
33
|
{
|
|
27
34
|
usage: "ahood list-mine [--json]",
|
|
35
|
+
summary: "List your own skills, public and private.",
|
|
28
36
|
desc: "List your own skills, public and private.",
|
|
29
37
|
flags: ["--json Emit the raw skill objects instead of formatted lines."],
|
|
30
38
|
},
|
|
31
39
|
{
|
|
32
40
|
usage: "ahood add <owner>/<skill>[@version]",
|
|
41
|
+
summary: "Install a skill into .claude/skills/, pinned in the lockfile.",
|
|
33
42
|
desc: "Install a skill into .claude/skills/, pinned in the lockfile.",
|
|
34
43
|
examples: ["ahood add alice/pdf-tools", "ahood add alice/pdf-tools@1.2.0"],
|
|
35
44
|
},
|
|
36
45
|
{
|
|
37
46
|
usage: "ahood update [<owner>/<skill> ...]",
|
|
47
|
+
summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
|
|
38
48
|
desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest.",
|
|
39
49
|
},
|
|
40
|
-
{
|
|
50
|
+
{
|
|
51
|
+
usage: "ahood remove <owner>/<skill>",
|
|
52
|
+
summary: "Uninstall and unpin a skill (local only).",
|
|
53
|
+
desc: "Uninstall and unpin (local only).",
|
|
54
|
+
},
|
|
41
55
|
{
|
|
42
56
|
usage: "ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
|
|
43
|
-
|
|
57
|
+
summary: "Update a skill you own, changing only the flags you pass.",
|
|
58
|
+
desc: "Update a skill you own. Only the flags you pass are changed. Every flag also accepts " +
|
|
59
|
+
"the --flag=value form (e.g. --tagline=--fast and cheap), needed when a value itself starts with --.",
|
|
44
60
|
flags: [
|
|
45
61
|
"--tagline <text> Short one-line description.",
|
|
46
62
|
"--tags <comma,separated> Replaces the skill's tag list.",
|
|
@@ -53,17 +69,24 @@ export const COMMANDS_HELP = [
|
|
|
53
69
|
},
|
|
54
70
|
{
|
|
55
71
|
usage: "ahood unpublish <owner>/<skill> [--yes]",
|
|
72
|
+
summary: "Delete a skill from the registry for every consumer, not just your local install (prompts for confirmation unless --yes is passed).",
|
|
56
73
|
desc: "Delete a skill from the registry for every consumer (not just your local install). Prompts for a typed \"yes\" unless --yes is passed.",
|
|
57
74
|
flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
|
|
58
75
|
},
|
|
59
|
-
{ usage: "ahood star <owner>/<skill>", desc: "Star a skill." },
|
|
60
|
-
{
|
|
76
|
+
{ usage: "ahood star <owner>/<skill>", summary: "Star a skill.", desc: "Star a skill." },
|
|
77
|
+
{
|
|
78
|
+
usage: "ahood unstar <owner>/<skill>",
|
|
79
|
+
summary: "Remove your star from a skill.",
|
|
80
|
+
desc: "Remove your star from a skill.",
|
|
81
|
+
},
|
|
61
82
|
{
|
|
62
83
|
usage: "ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>]",
|
|
84
|
+
summary: "Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist.",
|
|
63
85
|
desc: "Publish a new version of a skill from a folder containing SKILL.md. If the skill doesn't exist yet, this " +
|
|
64
86
|
"creates it first -- pass --name (required in that case) and optionally --tagline/--tags/--license/--homepage/--repository. " +
|
|
65
87
|
"Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
|
|
66
|
-
"The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted."
|
|
88
|
+
"The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
|
|
89
|
+
"Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
|
|
67
90
|
flags: [
|
|
68
91
|
"--name <text> Required only when creating the skill on this publish.",
|
|
69
92
|
"--tagline <text> Short one-line description, used only when creating.",
|
|
@@ -80,20 +103,38 @@ export const COMMANDS_HELP = [
|
|
|
80
103
|
},
|
|
81
104
|
{
|
|
82
105
|
usage: "ahood token create <name>|list [--json]|revoke <id>",
|
|
106
|
+
summary: "Manage personal API tokens.",
|
|
83
107
|
desc: "Manage personal API tokens. `create` requires an existing browser-backed session.",
|
|
84
108
|
examples: ["ahood token create ci-runner", "ahood token list --json", "ahood token revoke <id>"],
|
|
85
109
|
},
|
|
86
110
|
{
|
|
87
111
|
usage: "ahood completion <bash|zsh|fish>",
|
|
112
|
+
summary: "Print a shell completion script for the command names.",
|
|
88
113
|
desc: "Print a shell completion script for the command names.",
|
|
89
114
|
examples: ["ahood completion bash >> ~/.bashrc"],
|
|
90
115
|
},
|
|
91
116
|
];
|
|
92
|
-
|
|
117
|
+
// Exported so other consumers of the command list (e.g. shell completion) can
|
|
118
|
+
// surface aliases without needing their own copy of this map.
|
|
119
|
+
export const COMMAND_ALIASES = { show: "view" };
|
|
93
120
|
export function findCommandHelp(command) {
|
|
94
121
|
const resolved = COMMAND_ALIASES[command] ?? command;
|
|
95
122
|
return COMMANDS_HELP.find((c) => c.usage.startsWith(`ahood ${resolved} `) || c.usage === `ahood ${resolved}`);
|
|
96
123
|
}
|
|
124
|
+
// Renders a COMMANDS_HELP entry's usage line with any known aliases folded in
|
|
125
|
+
// after the primary command name, e.g. "ahood view <owner>/<skill> ..." ->
|
|
126
|
+
// "ahood view|show <owner>/<skill> ...". Used by the top-level --help listing
|
|
127
|
+
// so aliases (like "show" for "view") aren't invisible to users who only
|
|
128
|
+
// skim `ahood --help`.
|
|
129
|
+
export function usageWithAliases(entry) {
|
|
130
|
+
const parts = entry.usage.split(" ");
|
|
131
|
+
const name = parts[1];
|
|
132
|
+
const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => COMMAND_ALIASES[alias] === name);
|
|
133
|
+
if (aliases.length === 0)
|
|
134
|
+
return entry.usage;
|
|
135
|
+
parts[1] = [name, ...aliases].join("|");
|
|
136
|
+
return parts.join(" ");
|
|
137
|
+
}
|
|
97
138
|
export function formatCommandHelp(entry) {
|
|
98
139
|
const lines = [entry.usage, "", entry.desc];
|
|
99
140
|
if (entry.flags?.length)
|
|
@@ -103,8 +144,8 @@ export function formatCommandHelp(entry) {
|
|
|
103
144
|
return lines.join("\n");
|
|
104
145
|
}
|
|
105
146
|
export function formatHelp() {
|
|
106
|
-
const width = Math.max(...COMMANDS_HELP.map((c) => c.
|
|
107
|
-
const lines = COMMANDS_HELP.map((c) => ` ${c.
|
|
147
|
+
const width = Math.max(...COMMANDS_HELP.map((c) => usageWithAliases(c).length));
|
|
148
|
+
const lines = COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
|
|
108
149
|
return [
|
|
109
150
|
"ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
|
|
110
151
|
"",
|
|
@@ -120,7 +161,8 @@ export function formatHelp() {
|
|
|
120
161
|
"Run `ahood --version` to print the installed CLI version.",
|
|
121
162
|
"",
|
|
122
163
|
"Exit codes: 0 success, 1 general error, 2 usage/validation error,",
|
|
123
|
-
" 4 authentication required or rejected, 5 not found,
|
|
164
|
+
" 4 authentication required or rejected, 5 not found,",
|
|
165
|
+
" 6 network/transport error or upstream server (5xx) error.",
|
|
124
166
|
"",
|
|
125
167
|
"Full reference: https://ahood.vercel.app/docs",
|
|
126
168
|
].join("\n");
|
package/dist/http.js
CHANGED
|
@@ -36,6 +36,23 @@ export async function apiFetch(path, init = {}) {
|
|
|
36
36
|
throw new NetworkError(`Request to ${getApiUrl()}${path} failed (${message}${cause})`);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
|
40
|
+
// Defense in depth (ahood-cli#31): the API is expected to never forward a raw
|
|
41
|
+
// upstream error body (an HTML block page from a proxy/WAF, a giant stack
|
|
42
|
+
// trace, etc.) into `error`, but this CLI shouldn't trust that unconditionally
|
|
43
|
+
// -- a regression on any endpoint would otherwise dump multi-KB infra details
|
|
44
|
+
// straight to a user's terminal. Anything HTML-shaped or implausibly long for
|
|
45
|
+
// a normal error string is replaced with a short, safe summary instead.
|
|
46
|
+
function sanitizeErrorMessage(message) {
|
|
47
|
+
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(message);
|
|
48
|
+
if (!looksLikeHtml && message.length <= MAX_ERROR_MESSAGE_LENGTH)
|
|
49
|
+
return message;
|
|
50
|
+
// No preview of the raw content: an HTML-shaped body's first bytes are
|
|
51
|
+
// exactly where infra details (e.g. a Cloudflare block page's title, Ray
|
|
52
|
+
// ID) live, so a "helpful" excerpt would leak the same details this
|
|
53
|
+
// exists to suppress. Length alone is safe to report.
|
|
54
|
+
return `Request failed with an unexpected, oversized, or HTML-shaped error response (${message.length} bytes) -- this usually means an upstream proxy/WAF failure, not a problem with your request.`;
|
|
55
|
+
}
|
|
39
56
|
export async function apiJson(path, init = {}) {
|
|
40
57
|
const res = await apiFetch(path, init);
|
|
41
58
|
if (!res.ok) {
|
|
@@ -44,7 +61,7 @@ export async function apiJson(path, init = {}) {
|
|
|
44
61
|
// rather than crashing on `body.error` of something that isn't an object.
|
|
45
62
|
const body = await res.json().catch(() => undefined);
|
|
46
63
|
const message = body && typeof body === "object" && typeof body.error === "string"
|
|
47
|
-
? body.error
|
|
64
|
+
? sanitizeErrorMessage(body.error)
|
|
48
65
|
: `Request failed with status ${res.status}`;
|
|
49
66
|
throw new ApiError(res.status, message);
|
|
50
67
|
}
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,8 @@ import { star, unstar } from "./commands/star.js";
|
|
|
15
15
|
import { view } from "./commands/view.js";
|
|
16
16
|
import { completion } from "./commands/completion.js";
|
|
17
17
|
import { formatHelp, formatCommandHelp, findCommandHelp } from "./help.js";
|
|
18
|
-
import { ApiError
|
|
18
|
+
import { ApiError } from "./http.js";
|
|
19
|
+
import { exitCodeFor } from "./exit-code.js";
|
|
19
20
|
import { CLI_NAME, CLI_VERSION } from "./version.js";
|
|
20
21
|
const COMMANDS = {
|
|
21
22
|
login: () => login(),
|
|
@@ -61,21 +62,6 @@ function closestCommand(input) {
|
|
|
61
62
|
}
|
|
62
63
|
return bestDistance <= 2 ? best : undefined;
|
|
63
64
|
}
|
|
64
|
-
// A hung/black-holed connection throws undici's generic TypeError("fetch
|
|
65
|
-
// failed") -- http.ts wraps that into NetworkError so it gets its own exit
|
|
66
|
-
// code here, distinct from a server-returned ApiError.
|
|
67
|
-
function exitCodeFor(error) {
|
|
68
|
-
if (error instanceof ApiError) {
|
|
69
|
-
if (error.status === 401 || error.status === 403)
|
|
70
|
-
return 4;
|
|
71
|
-
if (error.status === 404)
|
|
72
|
-
return 5;
|
|
73
|
-
return 2;
|
|
74
|
-
}
|
|
75
|
-
if (error instanceof NetworkError)
|
|
76
|
-
return 6;
|
|
77
|
-
return 1;
|
|
78
|
-
}
|
|
79
65
|
async function main() {
|
|
80
66
|
const [command, ...args] = process.argv.slice(2);
|
|
81
67
|
if (command === "--version" || command === "-v") {
|
package/dist/spec.js
CHANGED
|
@@ -4,11 +4,25 @@ import { join } from "node:path";
|
|
|
4
4
|
// "/", "\", or a leading dot -- this is what closes the path-traversal hole in add/remove
|
|
5
5
|
// (a spec like "alice/.." previously resolved outside .claude/skills/ entirely).
|
|
6
6
|
const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
7
|
-
export const SEMVER_RE =
|
|
7
|
+
export const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
8
|
+
// The backend enforces these same caps on write (app/api/v1/profile/route.ts's
|
|
9
|
+
// MAX_USERNAME_LENGTH for owner usernames; lib/skills/mutations.ts's
|
|
10
|
+
// createSkill 3-64 check for skill slugs), so a segment over these lengths
|
|
11
|
+
// can never match a real owner/skill anyway. Rejecting it here -- before any
|
|
12
|
+
// request is built -- turns a validly-charset-formatted but absurdly long
|
|
13
|
+
// segment into a clean local error instead of a network round trip that, at
|
|
14
|
+
// a truly pathological length (thousands of characters), hits a raw infra/
|
|
15
|
+
// CDN-layer URL-length limit and 502s before the backend's own routing ever
|
|
16
|
+
// runs (ahood-cli#38).
|
|
17
|
+
const MAX_SEGMENT_LENGTH = { owner: 32, skill: 64 };
|
|
8
18
|
export function validateSegment(value, kind, spec) {
|
|
9
19
|
if (!SEGMENT_RE.test(value) || value === "." || value === "..") {
|
|
10
20
|
throw new Error(`Invalid ${kind} "${value}" in "${spec}" -- must start with a letter/digit and contain only letters, digits, ".", "_", or "-".`);
|
|
11
21
|
}
|
|
22
|
+
const maxLength = MAX_SEGMENT_LENGTH[kind];
|
|
23
|
+
if (value.length > maxLength) {
|
|
24
|
+
throw new Error(`Invalid ${kind} in "${spec.slice(0, 60)}${spec.length > 60 ? "..." : ""}" -- ${kind} segment is too long (${value.length} characters; must be at most ${maxLength}).`);
|
|
25
|
+
}
|
|
12
26
|
}
|
|
13
27
|
export function parseOwnerSkill(spec, usage) {
|
|
14
28
|
if (!spec)
|
|
@@ -36,6 +50,29 @@ export function parseOwnerSkillVersion(spec, usage) {
|
|
|
36
50
|
const { owner, skill } = parseOwnerSkill(ownerSkill, usage);
|
|
37
51
|
return { owner, skill, version };
|
|
38
52
|
}
|
|
53
|
+
// Applied to --homepage/--repository in edit and publish (ahood-cli#34):
|
|
54
|
+
// these are arbitrary user-supplied external links that the API stores
|
|
55
|
+
// verbatim and the web frontend renders as a raw <a href> -- a
|
|
56
|
+
// javascript:/data: value would be a stored-XSS vector the moment that
|
|
57
|
+
// page renders it. Rejecting anything but http(s) here, before any network
|
|
58
|
+
// call, mirrors config.ts's getApiUrl() validation pattern but with no
|
|
59
|
+
// localhost special-casing (there's no "trusted local dev" case for a
|
|
60
|
+
// public link). An empty string is allowed through un-validated so
|
|
61
|
+
// `edit --homepage ""` can still be used to clear a previously-set value.
|
|
62
|
+
export function validateExternalUrl(value, flag) {
|
|
63
|
+
if (value === "")
|
|
64
|
+
return;
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(value);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error(`${flag} must be a valid http:// or https:// URL (got "${value}").`);
|
|
71
|
+
}
|
|
72
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
73
|
+
throw new Error(`${flag} must use http:// or https:// (got scheme "${url.protocol}" from "${value}").`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
39
76
|
export const SKILLS_ROOT = join(".claude", "skills");
|
|
40
77
|
export const LOCKFILE_PATH = join(".claude", "skills.lock.json");
|
|
41
78
|
export function skillDir(owner, skill) {
|
package/package.json
CHANGED