@ahood/cli 0.2.2 → 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/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/flags.js +23 -7
- package/dist/help.js +51 -10
- package/dist/spec.js +38 -1
- package/package.json +1 -1
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
|
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
|
"",
|
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