@ahood/cli 0.6.0 → 0.7.1
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 +3 -1
- package/dist/commands/add.js +24 -12
- package/dist/commands/diff.js +92 -0
- package/dist/commands/init.js +15 -7
- package/dist/commands/publish.js +142 -6
- package/dist/help.js +25 -4
- package/dist/index.js +8 -0
- package/dist/mcp/tools.js +50 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -211,9 +211,11 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
|
|
|
211
211
|
| `ahood skill view\|show <owner>/<skill> [--json] [--web]` | Show a single skill's details -- tags, license, homepage, repository, dates, and more -- without installing it (alias: ahood skill show). |
|
|
212
212
|
| `ahood skill read <owner>/<skill> [--json]` | Print a skill's full SKILL.md content, without installing it. |
|
|
213
213
|
| `ahood skill versions <owner>/<skill> [--json]` | List a skill's published-version history -- version, changelog, size, and publish date. |
|
|
214
|
+
| `ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]` | Show what changed between two published versions -- a SKILL.md diff plus an added/removed/changed file summary. |
|
|
214
215
|
| `ahood skill list [--json]` | List your own skills, public and private. |
|
|
215
216
|
| `ahood skill add <owner>/<skill>[@version]` | Install a skill into .claude/skills/, pinned in the lockfile. |
|
|
216
217
|
| `ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]` | Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once. |
|
|
218
|
+
| `ahood skill outdated [<owner>/<skill> ...] [--json]` | Read-only staleness check comparing current and latest versions (with changelog) for installed skills. |
|
|
217
219
|
| `ahood skill remove <owner>/<skill>` | Uninstall and unpin a skill (local only). |
|
|
218
220
|
| `ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own, changing only the flags you pass. |
|
|
219
221
|
| `ahood skill unpublish <owner>/<skill>[@version] [--yes]` | Delete a skill from the registry for every consumer, or yank a single version, not just your local install (prompts for confirmation unless --yes is passed). |
|
|
@@ -222,7 +224,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
|
|
|
222
224
|
| `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
|
|
223
225
|
| `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
|
|
224
226
|
| `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
|
|
225
|
-
| `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]` | Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist. |
|
|
227
|
+
| `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>] [--changelog <text>] [--json]` | Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist. |
|
|
226
228
|
|
|
227
229
|
### Group
|
|
228
230
|
|
package/dist/commands/add.js
CHANGED
|
@@ -174,8 +174,11 @@ async function extractFreshVersion(buffer, destDir) {
|
|
|
174
174
|
// Agent packages install as one file, not a directory (Claude Code's own
|
|
175
175
|
// .claude/agents/*.md convention is flat, non-recursive) -- extracts just
|
|
176
176
|
// AGENT.md's content from the downloaded tarball rather than writing every
|
|
177
|
-
// entry to disk the way extractFreshVersion does for skills.
|
|
178
|
-
|
|
177
|
+
// entry to disk the way extractFreshVersion does for skills. Exported so
|
|
178
|
+
// `diff.ts` can reuse the exact same in-memory single-file extraction to
|
|
179
|
+
// pull SKILL.md out of two downloaded version archives without ever writing
|
|
180
|
+
// either to disk (ahood-cli#88).
|
|
181
|
+
export async function extractSingleFileContent(buffer, entryName) {
|
|
179
182
|
let decompressed;
|
|
180
183
|
try {
|
|
181
184
|
// Same decompression-bomb guard as extractTarGz above: a highly-
|
|
@@ -401,6 +404,24 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
401
404
|
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
402
405
|
}
|
|
403
406
|
}
|
|
407
|
+
// Downloads a specific version's archive and verifies it against the
|
|
408
|
+
// checksum in `meta`, WITHOUT extracting anything -- the shared first half
|
|
409
|
+
// of add()'s download flow below, pulled out so `diff.ts` (ahood-cli#88) can
|
|
410
|
+
// fetch two versions' archives the same verified way without duplicating
|
|
411
|
+
// the download/checksum logic.
|
|
412
|
+
export async function downloadVerifiedArchive(owner, skill, meta) {
|
|
413
|
+
const downloadRes = await apiFetch(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/download?version=${encodeURIComponent(meta.version)}`, { headers: { "X-Ahood-Source": "cli" }, redirect: "follow" });
|
|
414
|
+
if (!downloadRes.ok) {
|
|
415
|
+
const body = await downloadRes.text().catch(() => "");
|
|
416
|
+
throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
|
|
417
|
+
}
|
|
418
|
+
const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
|
|
419
|
+
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
420
|
+
if (actualChecksum !== meta.checksum_sha256) {
|
|
421
|
+
throw new Error(`Checksum mismatch for ${owner}/${skill}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
|
|
422
|
+
}
|
|
423
|
+
return buffer;
|
|
424
|
+
}
|
|
404
425
|
export async function add(args) {
|
|
405
426
|
const spec = args[0];
|
|
406
427
|
if (!spec)
|
|
@@ -431,16 +452,7 @@ export async function add(args) {
|
|
|
431
452
|
if (meta.kind !== "agent" && meta.kind !== "mcp" && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
|
|
432
453
|
console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
|
|
433
454
|
}
|
|
434
|
-
const
|
|
435
|
-
if (!downloadRes.ok) {
|
|
436
|
-
const body = await downloadRes.text().catch(() => "");
|
|
437
|
-
throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
|
|
438
|
-
}
|
|
439
|
-
const buffer = await readBoundedBody(downloadRes, MAX_DOWNLOAD_BYTES);
|
|
440
|
-
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
441
|
-
if (actualChecksum !== meta.checksum_sha256) {
|
|
442
|
-
throw new Error(`Checksum mismatch for ${key}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
|
|
443
|
-
}
|
|
455
|
+
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
444
456
|
if (meta.kind === "agent") {
|
|
445
457
|
const content = await extractSingleFileContent(buffer, "AGENT.md");
|
|
446
458
|
mkdirSync(AGENTS_ROOT, { recursive: true });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createPatch } from "diff";
|
|
2
|
+
import { fetchVersionMeta, downloadVerifiedArchive, extractSingleFileContent } from "./add.js";
|
|
3
|
+
import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
|
|
4
|
+
import { UsageError } from "../usage-error.js";
|
|
5
|
+
const USAGE = "Usage: ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]";
|
|
6
|
+
// Both versions must be explicit semver here, unlike `add`'s
|
|
7
|
+
// parseOwnerSkillVersion which lets "latest" default the version -- a diff
|
|
8
|
+
// is meaningless without two concrete, unambiguous versions to compare, and
|
|
9
|
+
// "latest" would silently mean two different things if the registry gained
|
|
10
|
+
// a new version between resolving vA and vB.
|
|
11
|
+
function validateVersion(version, spec) {
|
|
12
|
+
if (!SEMVER_RE.test(version)) {
|
|
13
|
+
throw new UsageError(`Invalid version "${version}" in "${spec}" -- expected a semver like 1.2.3.\n${USAGE}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Extracts SKILL.md's content from a downloaded version archive. Unlike an
|
|
17
|
+
// agent's AGENT.md (always present, since that's the only file an agent
|
|
18
|
+
// package ever installs), a skill package could in principle omit
|
|
19
|
+
// SKILL.md's presence from the manifest we already fetched -- checked
|
|
20
|
+
// up front so a missing file produces a clean message instead of
|
|
21
|
+
// extractSingleFileContent's generic "not found in the downloaded archive".
|
|
22
|
+
async function fetchSkillMdContent(owner, skill, meta) {
|
|
23
|
+
if (!meta.manifest.some((f) => f.path === "SKILL.md" || f.path === "./SKILL.md")) {
|
|
24
|
+
throw new Error(`${owner}/${skill}@${meta.version} has no SKILL.md in its manifest.`);
|
|
25
|
+
}
|
|
26
|
+
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
27
|
+
const content = await extractSingleFileContent(buffer, "SKILL.md");
|
|
28
|
+
return content.toString("utf-8");
|
|
29
|
+
}
|
|
30
|
+
// Compares two versions' manifests by path. VersionMeta.manifest only
|
|
31
|
+
// carries `{path}` (no per-file size or checksum), so "added"/"removed" are
|
|
32
|
+
// fully determined by path presence, but "changed" can only be reported for
|
|
33
|
+
// SKILL.md itself (the one file whose content we've already downloaded and
|
|
34
|
+
// diffed above) -- there is no way to tell whether some other common path's
|
|
35
|
+
// *content* differs without downloading and comparing it too, which is out
|
|
36
|
+
// of scope for this command (see the resolution-plan comment on issue #88).
|
|
37
|
+
function diffManifests(manifestA, manifestB, skillMdChanged) {
|
|
38
|
+
const pathsA = new Set(manifestA.map((f) => f.path));
|
|
39
|
+
const pathsB = new Set(manifestB.map((f) => f.path));
|
|
40
|
+
const added = [...pathsB].filter((p) => !pathsA.has(p)).sort();
|
|
41
|
+
const removed = [...pathsA].filter((p) => !pathsB.has(p)).sort();
|
|
42
|
+
const changed = [];
|
|
43
|
+
if (skillMdChanged && pathsA.has("SKILL.md") && pathsB.has("SKILL.md")) {
|
|
44
|
+
changed.push("SKILL.md");
|
|
45
|
+
}
|
|
46
|
+
return { added, removed, changed };
|
|
47
|
+
}
|
|
48
|
+
export async function diff(args) {
|
|
49
|
+
const jsonOutput = args.includes("--json");
|
|
50
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
51
|
+
const [spec, versionA, versionB] = positional;
|
|
52
|
+
if (!spec || !versionA || !versionB)
|
|
53
|
+
throw new UsageError(USAGE);
|
|
54
|
+
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
55
|
+
validateVersion(versionA, `${spec} ${versionA}`);
|
|
56
|
+
validateVersion(versionB, `${spec} ${versionB}`);
|
|
57
|
+
const [metaA, metaB] = await Promise.all([
|
|
58
|
+
fetchVersionMeta(owner, skill, versionA),
|
|
59
|
+
fetchVersionMeta(owner, skill, versionB),
|
|
60
|
+
]);
|
|
61
|
+
const [contentA, contentB] = await Promise.all([
|
|
62
|
+
fetchSkillMdContent(owner, skill, metaA),
|
|
63
|
+
fetchSkillMdContent(owner, skill, metaB),
|
|
64
|
+
]);
|
|
65
|
+
const skillmdDiff = createPatch("SKILL.md", contentA, contentB, versionA, versionB);
|
|
66
|
+
const skillMdChanged = contentA !== contentB;
|
|
67
|
+
const manifest = diffManifests(metaA.manifest, metaB.manifest, skillMdChanged);
|
|
68
|
+
if (jsonOutput) {
|
|
69
|
+
console.log(JSON.stringify({ skillmd_diff: skillMdChanged ? skillmdDiff : "", manifest }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
console.log(`${owner}/${skill}: ${versionA} -> ${versionB}`);
|
|
73
|
+
console.log("");
|
|
74
|
+
if (skillMdChanged) {
|
|
75
|
+
console.log(skillmdDiff);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
console.log("SKILL.md is unchanged between these versions.");
|
|
79
|
+
}
|
|
80
|
+
const summaryParts = [];
|
|
81
|
+
if (manifest.added.length > 0)
|
|
82
|
+
summaryParts.push(`Files added: ${manifest.added.join(", ")}`);
|
|
83
|
+
if (manifest.removed.length > 0)
|
|
84
|
+
summaryParts.push(`Files removed: ${manifest.removed.join(", ")}`);
|
|
85
|
+
if (manifest.changed.length > 0)
|
|
86
|
+
summaryParts.push(`Files changed: ${manifest.changed.join(", ")}`);
|
|
87
|
+
if (summaryParts.length > 0) {
|
|
88
|
+
console.log("");
|
|
89
|
+
for (const line of summaryParts)
|
|
90
|
+
console.log(line);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -18,14 +18,17 @@ function normalizeToSlug(name) {
|
|
|
18
18
|
.replace(/[^a-z0-9]+/g, "-")
|
|
19
19
|
.replace(/^-+|-+$/g, "");
|
|
20
20
|
}
|
|
21
|
-
// publish.ts's only client-side check on SKILL.md is that the file
|
|
22
|
-
// (see publish.ts's `existsSync(skillMdPath)`) -- actual frontmatter
|
|
21
|
+
// publish.ts's only hard client-side check on SKILL.md is that the file
|
|
22
|
+
// exists (see publish.ts's `existsSync(skillMdPath)`) -- actual frontmatter
|
|
23
23
|
// validation happens server-side, after upload, as part of the processing
|
|
24
|
-
// workflow described in publish.ts's POLL_INTERVAL_MS comment.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
24
|
+
// workflow described in publish.ts's POLL_INTERVAL_MS comment. (Publish does
|
|
25
|
+
// also do a best-effort, non-blocking scan of `description` at publish time
|
|
26
|
+
// -- ahood-cli#93 -- but that's a warning, not a parser, and this scaffold's
|
|
27
|
+
// job is to steer authors clear of it in the first place.) There's no real
|
|
28
|
+
// YAML parser to match locally, so this template instead follows the
|
|
29
|
+
// documented Claude Code skill format: YAML frontmatter delimited by `---`
|
|
30
|
+
// lines, with a `name` and a `description` field -- the same shape every
|
|
31
|
+
// real SKILL.md in this ecosystem uses (see e.g. any installed skill under
|
|
29
32
|
// .claude/skills/<owner>/<skill>/SKILL.md). Keeping to exactly those two
|
|
30
33
|
// fields, rather than inventing extra ones, means nothing here needs
|
|
31
34
|
// updating just because publish.ts's own validation happens to be looser
|
|
@@ -40,6 +43,11 @@ name: ${name}
|
|
|
40
43
|
# description: one or two sentences describing what this skill does and
|
|
41
44
|
# when Claude should use it. This is the primary text Claude reads to
|
|
42
45
|
# decide whether to invoke the skill, so be specific rather than generic.
|
|
46
|
+
# Keep it on a single line, under 1024 characters, and free of "<"/">"
|
|
47
|
+
# characters -- ahood skill publish warns about all three, because in
|
|
48
|
+
# registry-first usage (no local file, loaded via the registry/MCP server)
|
|
49
|
+
# this description is the *only* signal deciding whether the skill is ever
|
|
50
|
+
# triggered at all.
|
|
43
51
|
description: TODO -- describe what this skill does and when Claude should use it.
|
|
44
52
|
---
|
|
45
53
|
|
package/dist/commands/publish.js
CHANGED
|
@@ -27,7 +27,7 @@ async function pollVersionStatus(owner, skill, version) {
|
|
|
27
27
|
}
|
|
28
28
|
throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood skill view ${owner}/${skill}\` later for the result.`);
|
|
29
29
|
}
|
|
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" +
|
|
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>] [--changelog <text>] [--json]\n" +
|
|
31
31
|
" or: ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
|
|
32
32
|
// Matched by ENTRY NAME at every depth, not by path prefix, so a nested
|
|
33
33
|
// `vendor/thing/.git` is skipped the same as a top-level one. Not a
|
|
@@ -63,6 +63,113 @@ function isExcluded(name) {
|
|
|
63
63
|
return true;
|
|
64
64
|
return EXCLUDED_PREFIXES.some((prefix) => name === prefix || name.startsWith(`${prefix}.`));
|
|
65
65
|
}
|
|
66
|
+
// ahood-cli#93: in registry-first usage (an agent consuming this skill via
|
|
67
|
+
// the MCP server/registry, with no local SKILL.md ever loaded) the
|
|
68
|
+
// frontmatter `description` is the *only* signal an agent sees to decide
|
|
69
|
+
// whether to trigger this skill at all. A missing, oversized, or
|
|
70
|
+
// malformed-enough-to-mis-parse description means the skill silently never
|
|
71
|
+
// fires, and nothing today tells the author. This is a best-effort, targeted
|
|
72
|
+
// text scan -- not a real YAML parser (see init.ts's comment above
|
|
73
|
+
// buildSkillMd for why this codebase doesn't carry one) -- so it only needs
|
|
74
|
+
// to catch what a real skill author would plausibly write, not every legal
|
|
75
|
+
// YAML shape for `description:`.
|
|
76
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
77
|
+
// Pulls just the `description:` value out of a SKILL.md's `---`-delimited
|
|
78
|
+
// frontmatter block. Returns undefined when the frontmatter or the key
|
|
79
|
+
// itself can't be found -- callers treat that the same as an empty
|
|
80
|
+
// description rather than throwing, since this scan is advisory only.
|
|
81
|
+
function extractSkillDescription(skillMd) {
|
|
82
|
+
const frontmatterMatch = skillMd.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
83
|
+
if (!frontmatterMatch)
|
|
84
|
+
return undefined;
|
|
85
|
+
const lines = frontmatterMatch[1].split(/\r?\n/);
|
|
86
|
+
const descIndex = lines.findIndex((line) => /^description:/.test(line));
|
|
87
|
+
if (descIndex === -1)
|
|
88
|
+
return undefined;
|
|
89
|
+
const firstLineValue = lines[descIndex].slice("description:".length).trim();
|
|
90
|
+
// YAML block scalar ("description: >" folded, or "description: |"
|
|
91
|
+
// literal), optionally with a chomping/indentation indicator like ">-" or
|
|
92
|
+
// "|2". Everything indented under it, until a dedented or blank line, is
|
|
93
|
+
// the value -- this is the *correct* way to write a genuinely multi-line
|
|
94
|
+
// description, so it does not trip the rawMultiLine heuristic below.
|
|
95
|
+
const blockScalarMatch = firstLineValue.match(/^([>|])[+-]?\d*$/);
|
|
96
|
+
if (blockScalarMatch) {
|
|
97
|
+
const folded = blockScalarMatch[1] === ">";
|
|
98
|
+
const collected = [];
|
|
99
|
+
for (let i = descIndex + 1; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
if (line.trim() === "") {
|
|
102
|
+
collected.push("");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!/^\s/.test(line))
|
|
106
|
+
break; // dedented back to frontmatter's top level -- block scalar is over
|
|
107
|
+
collected.push(line.trim());
|
|
108
|
+
}
|
|
109
|
+
const value = folded ? collected.filter(Boolean).join(" ") : collected.join("\n");
|
|
110
|
+
return { value, rawMultiLine: false };
|
|
111
|
+
}
|
|
112
|
+
// Plain (or quoted) single-line form. Strip a matching pair of wrapping
|
|
113
|
+
// quotes for the length/character checks below -- not a full YAML string
|
|
114
|
+
// unescape, just enough to not count the quote characters themselves.
|
|
115
|
+
let value = firstLineValue;
|
|
116
|
+
if (value.length >= 2) {
|
|
117
|
+
const first = value[0];
|
|
118
|
+
const last = value[value.length - 1];
|
|
119
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
120
|
+
value = value.slice(1, -1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Heuristic for "the author typed real newlines into what should be one
|
|
124
|
+
// value": any non-blank line right after the description line that isn't
|
|
125
|
+
// itself a new top-level `key:` entry. Depending on its indentation, real
|
|
126
|
+
// YAML either folds this into the same scalar or fails to parse outright --
|
|
127
|
+
// neither is what a naive frontmatter-reading agent gets right, so either
|
|
128
|
+
// way it's worth flagging. This can't distinguish every edge case (e.g. a
|
|
129
|
+
// value that legitimately continues a nested structure), but skill
|
|
130
|
+
// frontmatter in practice is flat enough that it doesn't need to.
|
|
131
|
+
let rawMultiLine = false;
|
|
132
|
+
const extraLines = [];
|
|
133
|
+
for (let i = descIndex + 1; i < lines.length; i++) {
|
|
134
|
+
const line = lines[i];
|
|
135
|
+
if (line.trim() === "")
|
|
136
|
+
break;
|
|
137
|
+
if (!/^\s/.test(line) && /^[^\s:]+:/.test(line))
|
|
138
|
+
break; // next top-level key -- description's value ended
|
|
139
|
+
rawMultiLine = true;
|
|
140
|
+
extraLines.push(line.trim());
|
|
141
|
+
}
|
|
142
|
+
if (extraLines.length > 0)
|
|
143
|
+
value = [value, ...extraLines].filter(Boolean).join(" ");
|
|
144
|
+
return { value, rawMultiLine };
|
|
145
|
+
}
|
|
146
|
+
// Non-blocking: this only ever calls console.warn (never sets
|
|
147
|
+
// process.exitCode, never throws) so a weak description warns the author
|
|
148
|
+
// without affecting the exit code or --json mode's structured stdout output.
|
|
149
|
+
function warnAboutSkillDescription(skillMdPath) {
|
|
150
|
+
let content;
|
|
151
|
+
try {
|
|
152
|
+
content = readFileSync(skillMdPath, "utf8");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return; // publish's own existsSync check already guards this path being missing entirely
|
|
156
|
+
}
|
|
157
|
+
const extracted = extractSkillDescription(content);
|
|
158
|
+
const value = extracted?.value ?? "";
|
|
159
|
+
if (!value.trim()) {
|
|
160
|
+
console.warn(`WARNING: ${skillMdPath} has no (or an empty) description. In registry-first usage (an agent pulling this skill via the registry/MCP server, without ever loading this file) the description is the only signal deciding whether the skill is ever triggered -- without one, it effectively never will be.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (value.length > MAX_DESCRIPTION_LENGTH) {
|
|
164
|
+
console.warn(`WARNING: ${skillMdPath}'s description is ${value.length} characters, over the ${MAX_DESCRIPTION_LENGTH}-character recommended cap. An oversized description crowds out an agent's other context and is less likely to be read in full when it's deciding whether to trigger this skill.`);
|
|
165
|
+
}
|
|
166
|
+
if (extracted?.rawMultiLine) {
|
|
167
|
+
console.warn(`WARNING: ${skillMdPath}'s description spans multiple raw lines without a YAML block-scalar indicator (">" or "|"). This is easy to write by accident and breaks simple frontmatter parsers that only read a single line -- keep the description on one line, or use an explicit ">" (folded) or "|" (literal) block scalar if it genuinely needs to span multiple lines.`);
|
|
168
|
+
}
|
|
169
|
+
if (value.includes("<") || value.includes(">")) {
|
|
170
|
+
console.warn(`WARNING: ${skillMdPath}'s description contains a literal "<" or ">" character. These can be misread as markup or YAML block-scalar syntax by downstream parsers and renderers -- rewrite the description without them.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
66
173
|
async function tarGzDirectory(dir) {
|
|
67
174
|
const tar = pack();
|
|
68
175
|
const gzip = createGzip();
|
|
@@ -111,6 +218,7 @@ function parsePublishArgs(args) {
|
|
|
111
218
|
const homepage = flagValue(args, "--homepage");
|
|
112
219
|
const repository = flagValue(args, "--repository");
|
|
113
220
|
const kind = flagValue(args, "--kind");
|
|
221
|
+
const changelog = flagValue(args, "--changelog");
|
|
114
222
|
let legacyPath;
|
|
115
223
|
const first = args[0];
|
|
116
224
|
if (first && !first.startsWith("--")) {
|
|
@@ -141,7 +249,20 @@ function parsePublishArgs(args) {
|
|
|
141
249
|
validateExternalUrl(homepage, "--homepage");
|
|
142
250
|
if (repository !== undefined)
|
|
143
251
|
validateExternalUrl(repository, "--repository");
|
|
144
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
owner,
|
|
254
|
+
skill,
|
|
255
|
+
version,
|
|
256
|
+
path: pathFlag ?? legacyPath ?? ".",
|
|
257
|
+
name,
|
|
258
|
+
tagline,
|
|
259
|
+
tags,
|
|
260
|
+
license,
|
|
261
|
+
homepage,
|
|
262
|
+
repository,
|
|
263
|
+
kind,
|
|
264
|
+
changelog,
|
|
265
|
+
};
|
|
145
266
|
}
|
|
146
267
|
// Creates the skill under the caller's own account when versions/init 404s
|
|
147
268
|
// -- skill creation is CLI-only now (there is no separate `ahood create`;
|
|
@@ -185,9 +306,16 @@ async function createSkillForPublish(owner, skill, opts, jsonOutput) {
|
|
|
185
306
|
}
|
|
186
307
|
return created;
|
|
187
308
|
}
|
|
188
|
-
async function initVersion(owner, skill, version, packageSizeBytes, createOpts, jsonOutput) {
|
|
309
|
+
async function initVersion(owner, skill, version, packageSizeBytes, changelog, createOpts, jsonOutput) {
|
|
189
310
|
const path = `/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/init`;
|
|
190
|
-
|
|
311
|
+
// changelog_md matches the field name versions.ts's SkillVersion type already
|
|
312
|
+
// reads on the GET side (ahood-cli#87) -- only sent when --changelog was
|
|
313
|
+
// passed, same "omit rather than null" convention as every other optional
|
|
314
|
+
// flag in this file (see createSkillForPublish's body above).
|
|
315
|
+
const initBody = { version, package_size_bytes: packageSizeBytes };
|
|
316
|
+
if (changelog !== undefined)
|
|
317
|
+
initBody.changelog_md = changelog;
|
|
318
|
+
const body = JSON.stringify(initBody);
|
|
191
319
|
try {
|
|
192
320
|
const init = await apiJson(path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
|
|
193
321
|
return { init };
|
|
@@ -212,7 +340,7 @@ async function initVersion(owner, skill, version, packageSizeBytes, createOpts,
|
|
|
212
340
|
export async function publish(args) {
|
|
213
341
|
const jsonOutput = args.includes("--json");
|
|
214
342
|
try {
|
|
215
|
-
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, kind } = parsePublishArgs(args);
|
|
343
|
+
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, kind, changelog } = parsePublishArgs(args);
|
|
216
344
|
const skillMdPath = join(path, "SKILL.md");
|
|
217
345
|
const agentMdPath = join(path, "AGENT.md");
|
|
218
346
|
const serverJsonPath = join(path, "server.json");
|
|
@@ -249,8 +377,16 @@ export async function publish(args) {
|
|
|
249
377
|
}
|
|
250
378
|
// else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
|
|
251
379
|
}
|
|
380
|
+
// ahood-cli#93: fires for an explicit --kind skill, and for the
|
|
381
|
+
// inferred-skill case above (resolvedKind left undefined because
|
|
382
|
+
// SKILL.md was the only root file present) -- but not when SKILL.md
|
|
383
|
+
// merely happens to sit alongside an AGENT.md/server.json being
|
|
384
|
+
// published as --kind agent/mcp instead.
|
|
385
|
+
if (resolvedKind === "skill" || (resolvedKind === undefined && hasSkillMd)) {
|
|
386
|
+
warnAboutSkillDescription(skillMdPath);
|
|
387
|
+
}
|
|
252
388
|
const archive = await tarGzDirectory(path);
|
|
253
|
-
const { init, created } = await initVersion(owner, skill, version, archive.length, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
|
|
389
|
+
const { init, created } = await initVersion(owner, skill, version, archive.length, changelog, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
|
|
254
390
|
// TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
|
|
255
391
|
// "lib" override, so DOM is included alongside the Node types) type-checks
|
|
256
392
|
// Buffer/Uint8Array against `Uint8Array<ArrayBuffer>` specifically as of
|
package/dist/help.js
CHANGED
|
@@ -30,9 +30,9 @@ export const TOP_LEVEL_COMMANDS_HELP = [
|
|
|
30
30
|
usage: "ahood mcp",
|
|
31
31
|
summary: "Start an MCP server exposing read-only skill commands as tools over stdio.",
|
|
32
32
|
desc: "Start a Model Context Protocol server over stdio, exposing skill_search, skill_view, skill_read, " +
|
|
33
|
-
"skill_versions, skill_list, and whoami as MCP tools -- the same data ahood's --json
|
|
34
|
-
"return, reachable as typed tool calls instead of parsed CLI output. Meant to be
|
|
35
|
-
"MCP-aware agent host (e.g. Claude Code's MCP server configuration), not run interactively.",
|
|
33
|
+
"skill_versions, skill_list, skill_outdated, and whoami as MCP tools -- the same data ahood's --json " +
|
|
34
|
+
"commands already return, reachable as typed tool calls instead of parsed CLI output. Meant to be " +
|
|
35
|
+
"launched by an MCP-aware agent host (e.g. Claude Code's MCP server configuration), not run interactively.",
|
|
36
36
|
},
|
|
37
37
|
];
|
|
38
38
|
export const SKILL_COMMANDS_HELP = [
|
|
@@ -70,6 +70,17 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
70
70
|
desc: "List a skill's published-version history: version, changelog, size, and publish date. Most-recent first.",
|
|
71
71
|
flags: ["--json Emit the raw version objects instead of formatted text."],
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
usage: "ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]",
|
|
75
|
+
summary: "Show what changed between two published versions -- a SKILL.md diff plus an added/removed/changed file summary.",
|
|
76
|
+
desc: "Show what changed between two published versions of a skill: a unified diff of SKILL.md's content, plus a " +
|
|
77
|
+
"summary of which files were added or removed based on the two versions' manifests (a common file's content " +
|
|
78
|
+
"can only be confirmed changed for SKILL.md itself, since manifests don't carry per-file checksums). Both " +
|
|
79
|
+
"versions must be explicit semver -- \"latest\" isn't accepted, since it could silently mean two different " +
|
|
80
|
+
"things if a new version is published between resolving each side.",
|
|
81
|
+
flags: ["--json Emit {skillmd_diff, manifest: {added, removed, changed}} instead of formatted text."],
|
|
82
|
+
examples: ["ahood skill diff alice/pdf-tools 1.0.0 1.1.0", "ahood skill diff alice/pdf-tools 1.0.0 1.1.0 --json"],
|
|
83
|
+
},
|
|
73
84
|
{
|
|
74
85
|
usage: "ahood skill list [--json]",
|
|
75
86
|
summary: "List your own skills, public and private.",
|
|
@@ -96,6 +107,15 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
96
107
|
],
|
|
97
108
|
examples: ["ahood skill update --dry-run", "ahood skill update alice/pdf-tools --dry-run --json"],
|
|
98
109
|
},
|
|
110
|
+
{
|
|
111
|
+
usage: "ahood skill outdated [<owner>/<skill> ...] [--json]",
|
|
112
|
+
summary: "Read-only staleness check comparing current and latest versions (with changelog) for installed skills.",
|
|
113
|
+
desc: "Read-only staleness check across installed skills -- current vs. latest version, plus the changelog for " +
|
|
114
|
+
"anything behind. With no argument, checks every installed skill; with one or more given, checks just " +
|
|
115
|
+
"those. Never moves a lockfile pin or installs anything; equivalent to `ahood skill update --dry-run`.",
|
|
116
|
+
flags: ["--json Emit structured preview objects instead of a formatted table."],
|
|
117
|
+
examples: ["ahood skill outdated", "ahood skill outdated alice/pdf-tools --json"],
|
|
118
|
+
},
|
|
99
119
|
{
|
|
100
120
|
usage: "ahood skill remove <owner>/<skill>",
|
|
101
121
|
summary: "Uninstall and unpin a skill (local only).",
|
|
@@ -155,7 +175,7 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
155
175
|
examples: ["ahood skill init pdf-tools", "ahood skill init"],
|
|
156
176
|
},
|
|
157
177
|
{
|
|
158
|
-
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]",
|
|
178
|
+
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>] [--changelog <text>] [--json]",
|
|
159
179
|
summary: "Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist.",
|
|
160
180
|
desc: "Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json. If the artifact " +
|
|
161
181
|
"doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent or --kind mcp if " +
|
|
@@ -172,6 +192,7 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
172
192
|
"--license <id> An SPDX license identifier, e.g. MIT, used only when creating.",
|
|
173
193
|
"--homepage <url> The artifact's homepage URL, used only when creating.",
|
|
174
194
|
"--repository <url> The artifact's source repository URL, used only when creating.",
|
|
195
|
+
"--changelog <text> What changed in this version, shown by `ahood skill versions`.",
|
|
175
196
|
"--json Suppress human progress lines; print one {version,status,...} JSON object on completion (or {error} on failure).",
|
|
176
197
|
],
|
|
177
198
|
examples: [
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { share, unshare } from "./commands/share.js";
|
|
|
17
17
|
import { view } from "./commands/view.js";
|
|
18
18
|
import { read } from "./commands/read.js";
|
|
19
19
|
import { versions } from "./commands/versions.js";
|
|
20
|
+
import { diff } from "./commands/diff.js";
|
|
20
21
|
import { completion } from "./commands/completion.js";
|
|
21
22
|
import { mcp } from "./commands/mcp.js";
|
|
22
23
|
import { init } from "./commands/init.js";
|
|
@@ -33,9 +34,16 @@ const SKILL_COMMANDS = {
|
|
|
33
34
|
show: view,
|
|
34
35
|
read,
|
|
35
36
|
versions,
|
|
37
|
+
diff,
|
|
36
38
|
list: listSkills,
|
|
37
39
|
add,
|
|
38
40
|
update,
|
|
41
|
+
// A discoverable name for what `update --dry-run` already does (ahood-cli#91)
|
|
42
|
+
// -- forces --dry-run regardless of what the caller passes, so `outdated`
|
|
43
|
+
// can never accidentally move a pin forward. Reuses update()'s existing
|
|
44
|
+
// "no targets = every installed skill, explicit targets = just those"
|
|
45
|
+
// behavior as-is; no new logic.
|
|
46
|
+
outdated: (args) => update([...args, "--dry-run"]),
|
|
39
47
|
remove,
|
|
40
48
|
edit,
|
|
41
49
|
unpublish,
|
package/dist/mcp/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { readSkillMd } from "../commands/read.js";
|
|
|
6
6
|
import { listSkillVersions } from "../commands/versions.js";
|
|
7
7
|
import { listOwnSkills } from "../commands/list.js";
|
|
8
8
|
import { checkAuth } from "../commands/whoami.js";
|
|
9
|
+
import { fetchVersionMeta } from "../commands/add.js";
|
|
9
10
|
import { validateSegment } from "../spec.js";
|
|
10
11
|
// MCP tool inputs arrive as separate owner/skill zod fields (not a single
|
|
11
12
|
// "<owner>/<skill>" spec string like the CLI's argv), so they bypass
|
|
@@ -54,6 +55,55 @@ export function registerTools(server) {
|
|
|
54
55
|
description: "List the authenticated caller's own skills, public and private.",
|
|
55
56
|
inputSchema: {},
|
|
56
57
|
}, safeTool(async () => listOwnSkills()));
|
|
58
|
+
server.registerTool("skill_outdated", {
|
|
59
|
+
description: "Compare an explicit list of {owner, skill, version} pins (e.g. from an agent's own memory of what it " +
|
|
60
|
+
"installed via MCP calls) against the registry's latest published version for each, returning " +
|
|
61
|
+
"up-to-date status and changelog -- without reading or requiring any local lockfile. One entry " +
|
|
62
|
+
"failing to resolve (e.g. a 404 for an unpublished skill) doesn't fail the whole batch; that entry's " +
|
|
63
|
+
"result carries an `error` field instead.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
skills: z
|
|
66
|
+
.array(z.object({
|
|
67
|
+
owner: z.string().min(1),
|
|
68
|
+
skill: z.string().min(1),
|
|
69
|
+
version: z.string().min(1),
|
|
70
|
+
}))
|
|
71
|
+
.min(1),
|
|
72
|
+
},
|
|
73
|
+
}, safeTool(async ({ skills }) => {
|
|
74
|
+
const results = [];
|
|
75
|
+
for (const { owner, skill, version } of skills) {
|
|
76
|
+
const label = `${owner}/${skill}`;
|
|
77
|
+
try {
|
|
78
|
+
validateOwnerAndSkill(owner, skill);
|
|
79
|
+
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
80
|
+
const upToDate = version === meta.version;
|
|
81
|
+
results.push({
|
|
82
|
+
skill: label,
|
|
83
|
+
current_version: version,
|
|
84
|
+
latest_version: meta.version,
|
|
85
|
+
up_to_date: upToDate,
|
|
86
|
+
changelog_md: upToDate ? null : meta.changelog_md ?? null,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// One skill being removed/yanked/unreachable/invalid must not abort
|
|
91
|
+
// resolution of the rest of the batch -- that's the whole point of
|
|
92
|
+
// accepting an array instead of a single owner/skill pair. Mirrors
|
|
93
|
+
// update.ts's own per-skill try/catch in its --dry-run loop.
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
results.push({
|
|
96
|
+
skill: label,
|
|
97
|
+
current_version: version,
|
|
98
|
+
latest_version: null,
|
|
99
|
+
up_to_date: false,
|
|
100
|
+
changelog_md: null,
|
|
101
|
+
error: message,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return results;
|
|
106
|
+
}));
|
|
57
107
|
server.registerTool("whoami", {
|
|
58
108
|
description: "Report whether the configured token (AHOOD_TOKEN or a stored login) still authenticates.",
|
|
59
109
|
inputSchema: {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahood/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/AlexKay28/ahood-cli",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
36
|
+
"diff": "^9.0.0",
|
|
36
37
|
"tar-stream": "^3.2.1",
|
|
37
38
|
"zod": "^4.5.4"
|
|
38
39
|
},
|