@ahood/cli 0.5.0 → 0.5.2
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 +6 -3
- package/dist/commands/add.js +265 -9
- package/dist/commands/completion.js +2 -1
- package/dist/commands/edit.js +5 -4
- package/dist/commands/group.js +8 -7
- package/dist/commands/init.js +8 -7
- package/dist/commands/publish.js +52 -13
- package/dist/commands/read.js +2 -1
- package/dist/commands/remove.js +44 -5
- package/dist/commands/search.js +5 -4
- package/dist/commands/share.js +5 -4
- package/dist/commands/star.js +2 -2
- package/dist/commands/token.js +4 -3
- package/dist/commands/unpublish.js +5 -4
- package/dist/commands/update.js +20 -3
- package/dist/commands/versions.js +3 -2
- package/dist/commands/view.js +4 -3
- package/dist/commands/whoami.js +9 -3
- package/dist/config.js +3 -2
- package/dist/exit-code.js +9 -0
- package/dist/flags.js +2 -1
- package/dist/help.js +87 -18
- package/dist/lockfile.js +16 -7
- package/dist/secret-prompt.js +74 -0
- package/dist/spec.js +33 -9
- package/dist/usage-error.js +8 -0
- package/package.json +1 -1
package/dist/commands/publish.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createGzip } from "node:zlib";
|
|
|
5
5
|
import { apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
|
|
6
6
|
import { flagValue } from "../flags.js";
|
|
7
7
|
import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
|
|
8
|
+
import { UsageError } from "../usage-error.js";
|
|
8
9
|
// Processing (decompress/validate/checksum/scan/upload) now happens in a
|
|
9
10
|
// background workflow, not inline in versions/complete's response (server
|
|
10
11
|
// issue #120) -- complete returns 202 immediately, so this polls
|
|
@@ -24,10 +25,10 @@ async function pollVersionStatus(owner, skill, version) {
|
|
|
24
25
|
return result;
|
|
25
26
|
await sleep(POLL_INTERVAL_MS);
|
|
26
27
|
}
|
|
27
|
-
throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood view ${owner}/${skill}\` later for the result.`);
|
|
28
|
+
throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood skill view ${owner}/${skill}\` later for the result.`);
|
|
28
29
|
}
|
|
29
|
-
const USAGE = "Usage: ahood publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]\n" +
|
|
30
|
-
" or: ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
|
|
30
|
+
const USAGE = "Usage: ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]\n" +
|
|
31
|
+
" or: ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
|
|
31
32
|
// Matched by ENTRY NAME at every depth, not by path prefix, so a nested
|
|
32
33
|
// `vendor/thing/.git` is skipped the same as a top-level one. Not a
|
|
33
34
|
// .gitignore parser (out of scope) -- just the fixed set of names that must
|
|
@@ -109,25 +110,29 @@ function parsePublishArgs(args) {
|
|
|
109
110
|
const license = flagValue(args, "--license");
|
|
110
111
|
const homepage = flagValue(args, "--homepage");
|
|
111
112
|
const repository = flagValue(args, "--repository");
|
|
113
|
+
const kind = flagValue(args, "--kind");
|
|
112
114
|
let legacyPath;
|
|
113
115
|
const first = args[0];
|
|
114
116
|
if (first && !first.startsWith("--")) {
|
|
115
117
|
if (first.includes("/") && first.includes("@")) {
|
|
116
|
-
// Primary form: ahood publish <owner>/<skill>@<version>
|
|
118
|
+
// Primary form: ahood skill publish <owner>/<skill>@<version>
|
|
117
119
|
const parsed = parseOwnerSkill(first.slice(0, first.lastIndexOf("@")), USAGE);
|
|
118
120
|
owner = owner ?? parsed.owner;
|
|
119
121
|
skill = skill ?? parsed.skill;
|
|
120
122
|
version = version ?? first.slice(first.lastIndexOf("@") + 1);
|
|
121
123
|
}
|
|
122
124
|
else {
|
|
123
|
-
// Legacy form: ahood publish <path> --owner ... --slug ... --version ...
|
|
125
|
+
// Legacy form: ahood skill publish <path> --owner ... --slug ... --version ...
|
|
124
126
|
legacyPath = first;
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
129
|
if (!owner || !skill || !version)
|
|
128
|
-
throw new
|
|
130
|
+
throw new UsageError(USAGE);
|
|
129
131
|
if (!SEMVER_RE.test(version)) {
|
|
130
|
-
throw new
|
|
132
|
+
throw new UsageError(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
|
|
133
|
+
}
|
|
134
|
+
if (kind !== undefined && kind !== "skill" && kind !== "agent" && kind !== "mcp") {
|
|
135
|
+
throw new UsageError(`--kind must be "skill", "agent", or "mcp" (got "${kind}").\n${USAGE}`);
|
|
131
136
|
}
|
|
132
137
|
// Validated here, before any network call (including the tar/gzip of the
|
|
133
138
|
// skill directory) -- these only ever reach the server via
|
|
@@ -136,7 +141,7 @@ function parsePublishArgs(args) {
|
|
|
136
141
|
validateExternalUrl(homepage, "--homepage");
|
|
137
142
|
if (repository !== undefined)
|
|
138
143
|
validateExternalUrl(repository, "--repository");
|
|
139
|
-
return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository };
|
|
144
|
+
return { owner, skill, version, path: pathFlag ?? legacyPath ?? ".", name, tagline, tags, license, homepage, repository, kind };
|
|
140
145
|
}
|
|
141
146
|
// Creates the skill under the caller's own account when versions/init 404s
|
|
142
147
|
// -- skill creation is CLI-only now (there is no separate `ahood create`;
|
|
@@ -159,6 +164,8 @@ async function createSkillForPublish(owner, skill, opts, jsonOutput) {
|
|
|
159
164
|
body.homepage = opts.homepage;
|
|
160
165
|
if (opts.repository !== undefined)
|
|
161
166
|
body.repository = opts.repository;
|
|
167
|
+
if (opts.kind !== undefined)
|
|
168
|
+
body.kind = opts.kind;
|
|
162
169
|
const created = await apiJson("/api/v1/skills", {
|
|
163
170
|
method: "POST",
|
|
164
171
|
headers: { "Content-Type": "application/json" },
|
|
@@ -171,7 +178,7 @@ async function createSkillForPublish(owner, skill, opts, jsonOutput) {
|
|
|
171
178
|
if (created.owner !== owner) {
|
|
172
179
|
throw new Error(`Created ${created.owner}/${created.slug}, but this publish targeted "${owner}/${skill}" -- skills ` +
|
|
173
180
|
`are always created under your own account (${created.owner}). Retry with: ` +
|
|
174
|
-
`ahood publish ${created.owner}/${skill}@<version>.`);
|
|
181
|
+
`ahood skill publish ${created.owner}/${skill}@<version>.`);
|
|
175
182
|
}
|
|
176
183
|
if (!jsonOutput) {
|
|
177
184
|
console.log(`Created ${created.owner}/${created.slug} -- publishing its first version now.`);
|
|
@@ -205,13 +212,45 @@ async function initVersion(owner, skill, version, packageSizeBytes, createOpts,
|
|
|
205
212
|
export async function publish(args) {
|
|
206
213
|
const jsonOutput = args.includes("--json");
|
|
207
214
|
try {
|
|
208
|
-
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository } = parsePublishArgs(args);
|
|
215
|
+
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, kind } = parsePublishArgs(args);
|
|
209
216
|
const skillMdPath = join(path, "SKILL.md");
|
|
210
|
-
|
|
211
|
-
|
|
217
|
+
const agentMdPath = join(path, "AGENT.md");
|
|
218
|
+
const serverJsonPath = join(path, "server.json");
|
|
219
|
+
const hasSkillMd = existsSync(skillMdPath);
|
|
220
|
+
const hasAgentMd = existsSync(agentMdPath);
|
|
221
|
+
const hasServerJson = existsSync(serverJsonPath);
|
|
222
|
+
let resolvedKind = kind;
|
|
223
|
+
if (kind === "agent") {
|
|
224
|
+
if (!hasAgentMd)
|
|
225
|
+
throw new Error(`No AGENT.md found at ${agentMdPath} -- publish --kind agent must point at a folder containing AGENT.md.`);
|
|
226
|
+
}
|
|
227
|
+
else if (kind === "skill") {
|
|
228
|
+
if (!hasSkillMd)
|
|
229
|
+
throw new Error(`No SKILL.md found at ${skillMdPath} -- publish --kind skill must point at a folder containing SKILL.md.`);
|
|
230
|
+
}
|
|
231
|
+
else if (kind === "mcp") {
|
|
232
|
+
if (!hasServerJson)
|
|
233
|
+
throw new Error(`No server.json found at ${serverJsonPath} -- publish --kind mcp must point at a folder containing server.json.`);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// kind === undefined -- infer from whichever root file(s) are actually present
|
|
237
|
+
const present = [hasSkillMd && "SKILL.md", hasAgentMd && "AGENT.md", hasServerJson && "server.json"].filter(Boolean);
|
|
238
|
+
if (present.length > 1) {
|
|
239
|
+
throw new Error(`Multiple root files found at ${path} (${present.join(", ")}) -- pass --kind skill, --kind agent, or --kind mcp to disambiguate.`);
|
|
240
|
+
}
|
|
241
|
+
if (present.length === 0) {
|
|
242
|
+
throw new Error(`No SKILL.md, AGENT.md, or server.json found at ${path} -- publish must point at a skill, agent, or mcp folder's root.`);
|
|
243
|
+
}
|
|
244
|
+
if (hasAgentMd) {
|
|
245
|
+
resolvedKind = "agent"; // inferred: only AGENT.md present, no --kind needed
|
|
246
|
+
}
|
|
247
|
+
else if (hasServerJson) {
|
|
248
|
+
resolvedKind = "mcp"; // inferred: only server.json present, no --kind needed
|
|
249
|
+
}
|
|
250
|
+
// else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
|
|
212
251
|
}
|
|
213
252
|
const archive = await tarGzDirectory(path);
|
|
214
|
-
const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository }, jsonOutput);
|
|
253
|
+
const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
|
|
215
254
|
// TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
|
|
216
255
|
// "lib" override, so DOM is included alongside the Node types) type-checks
|
|
217
256
|
// Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
|
package/dist/commands/read.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
+
import { UsageError } from "../usage-error.js";
|
|
3
4
|
const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
|
|
4
5
|
export async function read(args) {
|
|
5
6
|
const jsonOutput = args.includes("--json");
|
|
6
7
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
7
8
|
if (!spec)
|
|
8
|
-
throw new
|
|
9
|
+
throw new UsageError(USAGE);
|
|
9
10
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
10
11
|
const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
11
12
|
if (!detail.skill_versions) {
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { existsSync, readdirSync, rmSync, rmdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { removeLockfileEntry } from "../lockfile.js";
|
|
4
|
-
import { LOCKFILE_PATH, parseOwnerSkill, skillDir } from "../spec.js";
|
|
5
|
-
|
|
4
|
+
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
|
|
5
|
+
import { UsageError } from "../usage-error.js";
|
|
6
|
+
const USAGE = "Usage: ahood skill remove <owner>/<skill>";
|
|
6
7
|
export async function remove(args) {
|
|
7
8
|
const spec = args[0];
|
|
8
9
|
if (!spec)
|
|
9
|
-
throw new
|
|
10
|
+
throw new UsageError(USAGE);
|
|
10
11
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
11
12
|
const key = `${owner}/${skill}`;
|
|
12
13
|
const dir = skillDir(owner, skill);
|
|
@@ -20,11 +21,49 @@ export async function remove(args) {
|
|
|
20
21
|
const ownerDir = dirname(dir);
|
|
21
22
|
if (existsSync(ownerDir) && readdirSync(ownerDir).length === 0)
|
|
22
23
|
rmdirSync(ownerDir);
|
|
24
|
+
// An agent installs as a single flat file (.claude/agents/<owner>@<skill>.md),
|
|
25
|
+
// never a directory under .claude/skills/ -- skillDir's rmSync above never
|
|
26
|
+
// touches it. Without this, removing an installed agent silently left the
|
|
27
|
+
// file on disk (still loaded by Claude Code forever) while reporting
|
|
28
|
+
// success and clearing the lockfile entry, so a later `update` wouldn't
|
|
29
|
+
// catch it either (ahood-cli final review finding #2).
|
|
30
|
+
const agentFile = agentPath(owner, skill);
|
|
31
|
+
const agentExisted = existsSync(agentFile);
|
|
32
|
+
if (agentExisted)
|
|
33
|
+
unlinkSync(agentFile);
|
|
23
34
|
const hadLockfileEntry = removeLockfileEntry(LOCKFILE_PATH, key);
|
|
24
|
-
if (!dirExisted && !hadLockfileEntry) {
|
|
35
|
+
if (!dirExisted && !agentExisted && !hadLockfileEntry) {
|
|
25
36
|
console.error(`${key} was not installed -- nothing to remove.`);
|
|
26
37
|
process.exitCode = 1;
|
|
27
38
|
return;
|
|
28
39
|
}
|
|
40
|
+
// An mcp-kind install has no directory or agent file on disk -- its only
|
|
41
|
+
// footprint here is the lockfile entry just cleared above and a live
|
|
42
|
+
// entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
|
|
43
|
+
// holding a resolved secret in its `env`). remove() doesn't attempt a real
|
|
44
|
+
// mcp removal, but staying silent about that entry repeats the exact
|
|
45
|
+
// false-assurance bug already fixed once for agent installs above: a user
|
|
46
|
+
// who removes an mcp artifact because they no longer trust it would be
|
|
47
|
+
// told it's gone while the MCP server (and its credential) still runs on
|
|
48
|
+
// the next Claude Code start -- and since the lockfile pin is now cleared,
|
|
49
|
+
// nothing will ever surface this again via `list`/`update`. So: warn
|
|
50
|
+
// instead of pretending it's gone.
|
|
51
|
+
if (existsSync(MCP_CONFIG_PATH)) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(MCP_CONFIG_PATH, "utf-8"));
|
|
54
|
+
const mcpServers = parsed?.mcpServers;
|
|
55
|
+
if (mcpServers &&
|
|
56
|
+
typeof mcpServers === "object" &&
|
|
57
|
+
!Array.isArray(mcpServers) &&
|
|
58
|
+
Object.prototype.hasOwnProperty.call(mcpServers, skill)) {
|
|
59
|
+
console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered) -- remove it manually.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// A malformed .mcp.json isn't this command's problem to fix or crash
|
|
64
|
+
// on -- add.ts's readMcpConfig is the strict validator for that path.
|
|
65
|
+
// Skip the warning rather than throw here.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
29
68
|
console.log(`Removed ${key}`);
|
|
30
69
|
}
|
package/dist/commands/search.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { flagValue } from "../flags.js";
|
|
3
|
-
|
|
3
|
+
import { UsageError } from "../usage-error.js";
|
|
4
|
+
const USAGE = "Usage: ahood skill search <query> [--json] [--limit <n>]";
|
|
4
5
|
export async function search(args) {
|
|
5
6
|
const jsonOutput = args.includes("--json");
|
|
6
7
|
const limit = flagValue(args, "--limit");
|
|
7
8
|
const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && args[i - 1] !== "--limit");
|
|
8
9
|
const unknownFlag = queryParts.find((a) => a.startsWith("--"));
|
|
9
10
|
if (unknownFlag)
|
|
10
|
-
throw new
|
|
11
|
+
throw new UsageError(`Unknown flag: ${unknownFlag}\n${USAGE}`);
|
|
11
12
|
const query = queryParts.join(" ");
|
|
12
13
|
if (!query)
|
|
13
|
-
throw new
|
|
14
|
+
throw new UsageError(USAGE);
|
|
14
15
|
if (limit !== undefined && (!/^\d+$/.test(limit) || Number(limit) < 1)) {
|
|
15
|
-
throw new
|
|
16
|
+
throw new UsageError(`--limit must be a positive integer (got "${limit}").\n${USAGE}`);
|
|
16
17
|
}
|
|
17
18
|
const qs = new URLSearchParams({ q: query });
|
|
18
19
|
if (limit !== undefined)
|
package/dist/commands/share.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { flagValue } from "../flags.js";
|
|
3
3
|
import { parseOwnerSkill } from "../spec.js";
|
|
4
|
+
import { UsageError } from "../usage-error.js";
|
|
4
5
|
// These are skill-entity verbs (reached as `ahood skill share`/`ahood skill
|
|
5
6
|
// unshare`, registered in SKILL_COMMANDS in index.ts), not group verbs --
|
|
6
7
|
// they mutate a skill's sharing, not a group. Grouped together in one file
|
|
@@ -16,11 +17,11 @@ const UNSHARE_USAGE = "Usage: ahood skill unshare <owner>/<skill> --group <group
|
|
|
16
17
|
export async function share(args) {
|
|
17
18
|
const spec = args[0];
|
|
18
19
|
if (!spec || spec.startsWith("--"))
|
|
19
|
-
throw new
|
|
20
|
+
throw new UsageError(SHARE_USAGE);
|
|
20
21
|
const { owner, skill } = parseOwnerSkill(spec, SHARE_USAGE);
|
|
21
22
|
const groupSlug = flagValue(args, "--group");
|
|
22
23
|
if (!groupSlug)
|
|
23
|
-
throw new
|
|
24
|
+
throw new UsageError(SHARE_USAGE);
|
|
24
25
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share`, {
|
|
25
26
|
method: "POST",
|
|
26
27
|
headers: { "Content-Type": "application/json" },
|
|
@@ -31,11 +32,11 @@ export async function share(args) {
|
|
|
31
32
|
export async function unshare(args) {
|
|
32
33
|
const spec = args[0];
|
|
33
34
|
if (!spec || spec.startsWith("--"))
|
|
34
|
-
throw new
|
|
35
|
+
throw new UsageError(UNSHARE_USAGE);
|
|
35
36
|
const { owner, skill } = parseOwnerSkill(spec, UNSHARE_USAGE);
|
|
36
37
|
const groupSlug = flagValue(args, "--group");
|
|
37
38
|
if (!groupSlug)
|
|
38
|
-
throw new
|
|
39
|
+
throw new UsageError(UNSHARE_USAGE);
|
|
39
40
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share/${encodeURIComponent(groupSlug)}`, { method: "DELETE" });
|
|
40
41
|
console.log(`Unshared ${owner}/${skill} from ${groupSlug}.`);
|
|
41
42
|
}
|
package/dist/commands/star.js
CHANGED
|
@@ -6,7 +6,7 @@ import { parseOwnerSkill } from "../spec.js";
|
|
|
6
6
|
// deletes any matching row), so starring an already-starred skill or
|
|
7
7
|
// unstarring one you never starred is a no-op 200, not an error.
|
|
8
8
|
export async function star(args) {
|
|
9
|
-
const USAGE = "Usage: ahood star <owner>/<skill>";
|
|
9
|
+
const USAGE = "Usage: ahood skill star <owner>/<skill>";
|
|
10
10
|
const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
|
|
11
11
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
|
|
12
12
|
method: "POST",
|
|
@@ -14,7 +14,7 @@ export async function star(args) {
|
|
|
14
14
|
console.log(`Starred ${owner}/${skill}.`);
|
|
15
15
|
}
|
|
16
16
|
export async function unstar(args) {
|
|
17
|
-
const USAGE = "Usage: ahood unstar <owner>/<skill>";
|
|
17
|
+
const USAGE = "Usage: ahood skill unstar <owner>/<skill>";
|
|
18
18
|
const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
|
|
19
19
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
|
|
20
20
|
method: "DELETE",
|
package/dist/commands/token.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
|
+
import { UsageError } from "../usage-error.js";
|
|
3
4
|
export async function token(args) {
|
|
4
5
|
const [subcommand, ...rest] = args;
|
|
5
6
|
switch (subcommand) {
|
|
@@ -10,13 +11,13 @@ export async function token(args) {
|
|
|
10
11
|
case "revoke":
|
|
11
12
|
return tokenRevoke(rest);
|
|
12
13
|
default:
|
|
13
|
-
throw new
|
|
14
|
+
throw new UsageError("Usage: ahood token create <name>|list [--json]|revoke <id> [--yes]");
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
async function tokenCreate(args) {
|
|
17
18
|
const name = args[0];
|
|
18
19
|
if (!name)
|
|
19
|
-
throw new
|
|
20
|
+
throw new UsageError("Usage: ahood token create <name>");
|
|
20
21
|
// Note: this itself requires an existing session-backed token or a
|
|
21
22
|
// browser login -- per docs/adr/backend/0001-backend-services.md's Phase
|
|
22
23
|
// 3 section, tokens can't mint tokens (POST /auth/tokens is session-only,
|
|
@@ -52,7 +53,7 @@ async function tokenRevoke(args) {
|
|
|
52
53
|
const yes = args.includes("--yes");
|
|
53
54
|
const id = args.find((a) => !a.startsWith("--"));
|
|
54
55
|
if (!id)
|
|
55
|
-
throw new
|
|
56
|
+
throw new UsageError("Usage: ahood token revoke <id> [--yes]");
|
|
56
57
|
// --yes bypasses both the lookup and the prompt entirely, preserving the
|
|
57
58
|
// original one-shot behavior (a single DELETE call) for scripts/CI that
|
|
58
59
|
// already call `ahood token revoke <id> --yes` unattended.
|
|
@@ -2,7 +2,8 @@ import { apiJson } from "../http.js";
|
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
3
|
import { parseOwnerSkill, parseOwnerSkillVersion } from "../spec.js";
|
|
4
4
|
import { fetchVersionMeta } from "./add.js";
|
|
5
|
-
|
|
5
|
+
import { UsageError } from "../usage-error.js";
|
|
6
|
+
const USAGE = "Usage: ahood skill unpublish <owner>/<skill>[@version] [--yes]";
|
|
6
7
|
// A bare "owner/skill" has no "@" at all, so lastIndexOf("@") is -1 and this
|
|
7
8
|
// is a whole-skill unpublish (preserves the exact pre-existing behavior).
|
|
8
9
|
// "owner/skill@1.2.3" has an "@" at index > 0 and is a single-version yank
|
|
@@ -16,7 +17,7 @@ export async function unpublish(args) {
|
|
|
16
17
|
const yes = args.includes("--yes");
|
|
17
18
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
18
19
|
if (!spec)
|
|
19
|
-
throw new
|
|
20
|
+
throw new UsageError(USAGE);
|
|
20
21
|
if (isVersioned(spec)) {
|
|
21
22
|
const { owner, skill, version: parsedVersion } = parseOwnerSkillVersion(spec, USAGE);
|
|
22
23
|
// parseOwnerSkillVersion only validates *syntax* -- an omitted "@version"
|
|
@@ -63,7 +64,7 @@ export async function unpublish(args) {
|
|
|
63
64
|
// the whole request, since the yank already took effect. Discarding the
|
|
64
65
|
// response body would silently drop that signal (ahood-cli#68): the
|
|
65
66
|
// pointer could now be stale with nothing in the terminal to say so, and
|
|
66
|
-
// a later `ahood add owner/skill` trusts latest_version_id completely.
|
|
67
|
+
// a later `ahood skill add owner/skill` trusts latest_version_id completely.
|
|
67
68
|
if (result.latest_version_warning) {
|
|
68
69
|
console.warn(`WARNING: ${result.latest_version_warning}`);
|
|
69
70
|
}
|
|
@@ -82,5 +83,5 @@ export async function unpublish(args) {
|
|
|
82
83
|
return;
|
|
83
84
|
}
|
|
84
85
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, { method: "DELETE" });
|
|
85
|
-
console.log(`Unpublished ${key}. Run \`ahood remove ${key}\` to also remove your local copy.`);
|
|
86
|
+
console.log(`Unpublished ${key}. Run \`ahood skill remove ${key}\` to also remove your local copy.`);
|
|
86
87
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
|
|
2
2
|
import { readLockfile } from "../lockfile.js";
|
|
3
3
|
import { add, fetchVersionMeta } from "./add.js";
|
|
4
|
-
|
|
4
|
+
import { UsageError } from "../usage-error.js";
|
|
5
|
+
const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
|
|
5
6
|
async function previewSkill(ownerSlashSkill, currentVersion) {
|
|
6
7
|
const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
|
|
7
8
|
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
@@ -47,7 +48,7 @@ export async function update(args) {
|
|
|
47
48
|
const dryRun = args.includes("--dry-run");
|
|
48
49
|
const jsonOutput = args.includes("--json");
|
|
49
50
|
if (jsonOutput && !dryRun) {
|
|
50
|
-
throw new
|
|
51
|
+
throw new UsageError(`--json is only supported together with --dry-run.\n${USAGE}`);
|
|
51
52
|
}
|
|
52
53
|
const targets = args.filter((a) => a !== "--dry-run" && a !== "--json");
|
|
53
54
|
const lockfile = readLockfile(LOCKFILE_PATH);
|
|
@@ -88,7 +89,7 @@ export async function update(args) {
|
|
|
88
89
|
for (const ownerSlashSkill of skillKeys) {
|
|
89
90
|
if (!lockfile[ownerSlashSkill]) {
|
|
90
91
|
// Reachable two ways: an explicitly-named skill that was never
|
|
91
|
-
// installed (`ahood add` is the right command for that -- `update`
|
|
92
|
+
// installed (`ahood skill add` is the right command for that -- `update`
|
|
92
93
|
// must not silently install and pin it), or -- when targets came from
|
|
93
94
|
// the lockfile itself -- the lockfile changing out from under us
|
|
94
95
|
// mid-loop. Either way: skip, don't abort.
|
|
@@ -96,6 +97,22 @@ export async function update(args) {
|
|
|
96
97
|
continue;
|
|
97
98
|
}
|
|
98
99
|
try {
|
|
100
|
+
// add() always hits the .mcp.json collision check for an mcp-kind
|
|
101
|
+
// entry that's already installed (that's the very definition of
|
|
102
|
+
// "already installed" for that kind), so calling it unconditionally
|
|
103
|
+
// here would make `ahood skill update` with no arguments permanently
|
|
104
|
+
// report a failure and exit 1 for every user who has ever installed
|
|
105
|
+
// an mcp artifact, even when nothing needs updating. fetchVersionMeta
|
|
106
|
+
// already resolves `kind` as part of the normal "latest" lookup add()
|
|
107
|
+
// itself does, so resolving it here first lets this skip cleanly
|
|
108
|
+
// instead of updating and hitting that guaranteed error. This is a
|
|
109
|
+
// skip-and-report-cleanly fix, not real mcp-update support.
|
|
110
|
+
const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
|
|
111
|
+
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
112
|
+
if (meta.kind === "mcp") {
|
|
113
|
+
console.warn(`Skipping ${ownerSlashSkill}: mcp artifacts aren't updatable via this command yet.`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
99
116
|
await add([ownerSlashSkill]); // no @version -- resolves to latest again
|
|
100
117
|
}
|
|
101
118
|
catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
-
|
|
3
|
+
import { UsageError } from "../usage-error.js";
|
|
4
|
+
const USAGE = "Usage: ahood skill versions <owner>/<skill> [--json]";
|
|
4
5
|
function formatSize(bytes) {
|
|
5
6
|
if (bytes < 1024)
|
|
6
7
|
return `${bytes} B`;
|
|
@@ -17,7 +18,7 @@ export async function versions(args) {
|
|
|
17
18
|
const jsonOutput = args.includes("--json");
|
|
18
19
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
19
20
|
if (!spec)
|
|
20
|
-
throw new
|
|
21
|
+
throw new UsageError(USAGE);
|
|
21
22
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
22
23
|
const { versions: list } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions`);
|
|
23
24
|
if (jsonOutput) {
|
package/dist/commands/view.js
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { apiJson } from "../http.js";
|
|
3
3
|
import { getApiUrl } from "../config.js";
|
|
4
4
|
import { parseOwnerSkill } from "../spec.js";
|
|
5
|
-
|
|
5
|
+
import { UsageError } from "../usage-error.js";
|
|
6
|
+
const USAGE = "Usage: ahood skill view <owner>/<skill> [--json] [--web]";
|
|
6
7
|
// Spawned with argv as an array (not a shell string), so a crafted owner/skill
|
|
7
8
|
// can never inject shell syntax -- and both segments are already restricted
|
|
8
9
|
// to a safe charset by parseOwnerSkill before this ever runs.
|
|
@@ -27,7 +28,7 @@ export async function view(args) {
|
|
|
27
28
|
const web = args.includes("--web");
|
|
28
29
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
29
30
|
if (!spec)
|
|
30
|
-
throw new
|
|
31
|
+
throw new UsageError(USAGE);
|
|
31
32
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
32
33
|
const url = `${getApiUrl()}/${owner}/${skill}`;
|
|
33
34
|
if (web) {
|
|
@@ -41,7 +42,7 @@ export async function view(args) {
|
|
|
41
42
|
}
|
|
42
43
|
const field = (label, value) => console.log(` ${label.padEnd(13)}${value}`);
|
|
43
44
|
console.log(`${detail.owner}/${detail.slug}`);
|
|
44
|
-
// Mirrors the wording/tone of `ahood add`'s yanked warning (src/commands/add.ts)
|
|
45
|
+
// Mirrors the wording/tone of `ahood skill add`'s yanked warning (src/commands/add.ts)
|
|
45
46
|
// and the website's skill detail page banner -- this is the one CLI surface
|
|
46
47
|
// whose entire job is "let me check this out before deciding to install it",
|
|
47
48
|
// so it must not be the one place that stays silent about a yanked version.
|
package/dist/commands/whoami.js
CHANGED
|
@@ -20,12 +20,15 @@ export async function whoami(args = []) {
|
|
|
20
20
|
if (!token) {
|
|
21
21
|
// Previously exited 0 here, defeating whoami's purpose as a scriptable
|
|
22
22
|
// auth check -- "no token configured at all" must fail just like "token
|
|
23
|
-
// rejected by the server" does below.
|
|
23
|
+
// rejected by the server" does below. Exit 4 ("authentication required
|
|
24
|
+
// or rejected"), not the generic 1 -- this is exactly the case the
|
|
25
|
+
// README's own exit-code table names as the canonical example of 4
|
|
26
|
+
// (ahood-cli#80).
|
|
24
27
|
if (wantsJson)
|
|
25
28
|
console.log(JSON.stringify({ authenticated: false }));
|
|
26
29
|
else
|
|
27
30
|
console.error("Not logged in. Run `ahood login` (or set AHOOD_TOKEN).");
|
|
28
|
-
process.exitCode =
|
|
31
|
+
process.exitCode = 4;
|
|
29
32
|
return;
|
|
30
33
|
}
|
|
31
34
|
// There is no endpoint that returns an identity for a bearer caller yet, so
|
|
@@ -62,11 +65,14 @@ export async function whoami(args = []) {
|
|
|
62
65
|
return;
|
|
63
66
|
}
|
|
64
67
|
if (error instanceof ApiError && error.status === 401) {
|
|
68
|
+
// Same exit-4 reasoning as the "no token at all" branch above: the
|
|
69
|
+
// token was rejected by the server, which is the other half of the
|
|
70
|
+
// README's exit-code-4 definition ("or the token was refused").
|
|
65
71
|
if (wantsJson)
|
|
66
72
|
console.log(JSON.stringify({ authenticated: false, reason: "invalid_token" }));
|
|
67
73
|
else
|
|
68
74
|
console.error("Not authenticated -- your token is invalid or has been revoked.");
|
|
69
|
-
process.exitCode =
|
|
75
|
+
process.exitCode = 4;
|
|
70
76
|
return;
|
|
71
77
|
}
|
|
72
78
|
const message = error instanceof Error ? error.message : String(error);
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UsageError } from "./usage-error.js";
|
|
1
2
|
const DEFAULT_API_URL = "https://ahood.vercel.app";
|
|
2
3
|
let warnedAboutOverride = false;
|
|
3
4
|
// AHOOD_API_URL is trusted with the bearer token on every request (see
|
|
@@ -15,7 +16,7 @@ export function getApiUrl() {
|
|
|
15
16
|
url = new URL(trimmed);
|
|
16
17
|
}
|
|
17
18
|
catch {
|
|
18
|
-
throw new
|
|
19
|
+
throw new UsageError(`AHOOD_API_URL is not a valid URL: "${configured}"`);
|
|
19
20
|
}
|
|
20
21
|
// Plain http:// is only allowed for localhost and the RFC 2606 reserved
|
|
21
22
|
// test TLDs (.test/.invalid/.example/.localhost), which can never resolve
|
|
@@ -26,7 +27,7 @@ export function getApiUrl() {
|
|
|
26
27
|
url.hostname === "::1" ||
|
|
27
28
|
/\.(test|invalid|example|localhost)$/i.test(url.hostname);
|
|
28
29
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalHost)) {
|
|
29
|
-
throw new
|
|
30
|
+
throw new UsageError(`AHOOD_API_URL must use https:// (http:// is only allowed for localhost/test hosts); got "${configured}".`);
|
|
30
31
|
}
|
|
31
32
|
if (trimmed !== DEFAULT_API_URL && !warnedAboutOverride) {
|
|
32
33
|
console.error(`warning: using non-default API endpoint from AHOOD_API_URL: ${trimmed}`);
|
package/dist/exit-code.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { ApiError, NetworkError } from "./http.js";
|
|
2
|
+
import { UsageError } from "./usage-error.js";
|
|
2
3
|
// A hung/black-holed connection throws undici's generic TypeError("fetch
|
|
3
4
|
// failed") -- http.ts wraps that into NetworkError so it gets its own exit
|
|
4
5
|
// code here, distinct from a server-returned ApiError. Kept in its own
|
|
5
6
|
// module (rather than inline in index.ts) so it's importable in tests
|
|
6
7
|
// without triggering index.ts's unconditional top-level main() call.
|
|
7
8
|
export function exitCodeFor(error) {
|
|
9
|
+
// A local validation failure (missing/malformed argument, bad flag value)
|
|
10
|
+
// is exactly the "bad arguments" half of exit code 2's documented meaning
|
|
11
|
+
// -- same code the server-rejected branch below already uses, so a caller
|
|
12
|
+
// branching on exit code sees one consistent "fix your input" signal
|
|
13
|
+
// regardless of whether the check ran locally or round-tripped to the
|
|
14
|
+
// server (ahood-cli#80).
|
|
15
|
+
if (error instanceof UsageError)
|
|
16
|
+
return 2;
|
|
8
17
|
if (error instanceof ApiError) {
|
|
9
18
|
if (error.status === 401 || error.status === 403)
|
|
10
19
|
return 4;
|
package/dist/flags.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UsageError } from "./usage-error.js";
|
|
1
2
|
// Shared flag-value parsing. Throws instead of silently treating the next flag
|
|
2
3
|
// as this flag's value (e.g. `--tagline --visibility public` used to send
|
|
3
4
|
// "--visibility" as the tagline and drop --visibility entirely).
|
|
@@ -20,7 +21,7 @@ export function flagValue(args, flag) {
|
|
|
20
21
|
if (arg === flag) {
|
|
21
22
|
const value = args[i + 1];
|
|
22
23
|
if (value === undefined || value.startsWith("--")) {
|
|
23
|
-
throw new
|
|
24
|
+
throw new UsageError(`${flag} requires a value.`);
|
|
24
25
|
}
|
|
25
26
|
return value;
|
|
26
27
|
}
|