@ahood/cli 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -189,15 +189,16 @@ Shared alice/pdf-tools with design-team.
189
189
  Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private visibility, it just makes it visible to everyone in `design-team` too. On the other side, whoever received the link runs `ahood group join <the link>` to join.
190
190
  </details>
191
191
 
192
- ## Kinds: skills, agents, and MCP servers
192
+ ## Kinds: skills, agents, MCP servers, and docs
193
193
 
194
- The registry holds three kinds of artifact this CLI works with, each with its own command group. All three take the same verbs (`search`, `list`, `view`/`show`, `read`, `versions`, `diff`, `add`, `update`, `outdated`, `remove`, `edit`, `unpublish`, `star`, `unstar`, `share`, `unshare`, `init`, `publish`), run by the same code:
194
+ The registry holds four kinds of artifact, each with its own command group. All four take the same verbs (`search`, `list`, `view`/`show`, `read`, `versions`, `diff`, `add`, `update`, `outdated`, `remove`, `edit`, `unpublish`, `star`, `unstar`, `share`, `unshare`, `init`, `publish`), run by the same code:
195
195
 
196
196
  | Kind | Root file | `add` installs to | Group |
197
197
  | --- | --- | --- | --- |
198
198
  | skill | `SKILL.md` | `.claude/skills/<owner>@<skill>/` | `ahood skill <verb>` |
199
199
  | agent | `AGENT.md` | `.claude/agents/<owner>@<agent>.md` | `ahood agent <verb>` |
200
200
  | mcp | `server.json` | an entry merged into `.mcp.json` | `ahood mcp <verb>` |
201
+ | doc | `DOC.md` | `.claude/docs/<owner>@<doc>.md` | `ahood doc <verb>` |
201
202
 
202
203
  ```
203
204
  $ ahood agent search review
@@ -205,14 +206,16 @@ $ ahood agent init code-reviewer # ./code-reviewer/AGENT.md
205
206
  $ ahood agent publish alice/code-reviewer@1.0.0 --path code-reviewer --name "Code Reviewer"
206
207
  $ ahood mcp init github-server # ./github-server/server.json, a safe placeholder starter
207
208
  $ ahood mcp add alice/github-server
209
+ $ ahood doc read alice/setup-guide # print a doc without installing it
210
+ $ ahood doc add alice/setup-guide # ./.claude/docs/alice@setup-guide.md, pinned
208
211
  $ ahood agent add alice/pdf-tools
209
212
  alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood skill add alice/pdf-tools` instead.
210
213
  ```
211
214
 
212
- - **`ahood agent` and `ahood mcp` are strict.** A target of another kind is refused -- exit code `2`, naming the kind it found -- before anything is downloaded, prompted for, written, or sent. That covers `add`, `update`, `remove`, `edit`, `unpublish`, `share`/`unshare`, `star`/`unstar`, and `publish` (an existing entry of another kind is refused before anything is packed or uploaded); the read-only verbs refuse too, rather than show the wrong kind. `search` asks the registry for that kind only, `list` keeps only that kind of your own entries, and `update`/`outdated` with no argument consider only installed pins of that kind. `agent publish`/`mcp publish` imply their kind and reject a contradictory `--kind`.
213
- - **Missing or unexpected kind metadata is refused, never guessed.** If the registry's answer carries no kind (or one this CLI doesn't handle), a kind-scoped command stops with exit `1` and points at the legacy form. `remove` reads the kind from the project's own files (the skill directory, the agent file, or the mcp fingerprint in the lockfile) and asks the registry only when those can't tell; if neither can, nothing is removed.
214
- - **`ahood skill` is legacy and cross-kind in this release.** `ahood skill search` and `ahood skill list` include every kind, and `ahood skill add`/`update`/`remove`/... act on whatever kind the target is, exactly as before -- including `--json` output and exit codes -- so existing scripts keep working. Add `--kind skill|agent|mcp` to any `ahood skill` verb (except `publish`, whose `--kind` keeps its original meaning) to scope it, or `--kind all` to spell out the cross-kind behavior.
215
- - **Not in scope: `doc`.** The registry also has a `doc` kind (documentation pages); this CLI has no `ahood doc` group. Kind dispatch is table-driven (`src/kind-info.ts`), so adding one is an entry there plus its help and `init` template.
215
+ - **`ahood agent`, `ahood mcp` and `ahood doc` are strict.** A target of another kind is refused -- exit code `2`, naming the kind it found -- before anything is downloaded, prompted for, written, or sent. That covers `add`, `update`, `remove`, `edit`, `unpublish`, `share`/`unshare`, `star`/`unstar`, and `publish` (an existing entry of another kind is refused before anything is packed or uploaded); the read-only verbs refuse too, rather than show the wrong kind. `search` asks the registry for that kind only, `list` keeps only that kind of your own entries, and `update`/`outdated` with no argument consider only installed pins of that kind. `agent publish`/`mcp publish`/`doc publish` imply their kind and reject a contradictory `--kind`.
216
+ - **Missing or unexpected kind metadata is refused, never guessed.** If the registry's answer carries no kind (or one this CLI doesn't handle), a kind-scoped command stops with exit `1` and points at the legacy form. `remove` reads the kind from the project's own files (the skill directory, the agent file, the mcp fingerprint in the lockfile, or a doc pin's recorded `kind`) and asks the registry only when those can't tell; if neither can, nothing is removed.
217
+ - **`ahood skill` is legacy and cross-kind in this release.** `ahood skill search` and `ahood skill list` include every kind, and `ahood skill add`/`update`/`remove`/... act on whatever kind the target is, exactly as before -- including `--json` output and exit codes -- so existing scripts keep working. The one exception is a doc: `ahood skill add` refuses it and points at `ahood doc add`. Add `--kind skill|agent|mcp|doc` to any `ahood skill` verb (except `publish`, whose `--kind` keeps its original meaning) to scope it, or `--kind all` to spell out the cross-kind behavior.
218
+ - **Docs install as one pinned file.** `ahood doc add` saves a doc's `DOC.md` to `.claude/docs/<owner>@<doc>.md` and pins it in `.claude/skills.lock.json` with `"kind": "doc"`. It refuses to overwrite a file at that path that isn't pinned as that doc. `ahood doc update`/`outdated`/`remove` manage those pins like any other kind. Kind dispatch is table-driven (`src/kind-info.ts`).
216
219
 
217
220
  **Migration plan for a strict `ahood skill`.** A future major version will make `ahood skill <verb>` skills-only by default, matching `agent` and `mcp`. Until then: (1) this release adds the strict groups and `--kind`, and labels `ahood skill` as legacy in `--help`; (2) a following minor release prints a stderr deprecation note when an `ahood skill` command actually touches a non-skill entry (stdout and `--json` unchanged); (3) the major release flips the default, keeping `ahood skill <verb> --kind all` as the explicit cross-kind escape hatch. Scripts that want today's behavior forever can pass `--kind all` now.
218
221
 
@@ -257,7 +260,7 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
257
260
  | `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
258
261
  | `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
259
262
  | `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
260
- | `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. |
263
+ | `ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill\|agent\|mcp\|doc] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a skill, agent, mcp server manifest, or doc from a folder containing SKILL.md, AGENT.md, server.json, or DOC.md, creating the skill first if it doesn't already exist. |
261
264
 
262
265
  ### Agent
263
266
 
@@ -307,6 +310,29 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
307
310
  | `ahood mcp init [name]` | Scaffold a new MCP server manifest folder with a minimal, valid server.json. |
308
311
  | `ahood mcp publish <owner>/<server>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of an MCP server manifest from a folder containing server.json, creating it first if it doesn't exist yet. |
309
312
 
313
+ ### Doc
314
+
315
+ | Command | What it does |
316
+ | --- | --- |
317
+ | `ahood doc search <query> [--json] [--limit <n>]` | Search published docs. |
318
+ | `ahood doc view\|show <owner>/<doc> [--json] [--web]` | Show a single doc's details without installing it (alias: ahood doc show). |
319
+ | `ahood doc read <owner>/<doc> [--json]` | Print a published doc's DOC.md, without installing it. |
320
+ | `ahood doc versions <owner>/<doc> [--json]` | List a doc's published-version history. |
321
+ | `ahood doc diff <owner>/<doc> <versionA> <versionB> [--json]` | Show what changed between two published versions of a doc. |
322
+ | `ahood doc list [--json]` | List your own docs, public and private. |
323
+ | `ahood doc add <owner>/<doc>[@version]` | Install a doc into .claude/docs/<owner>@<doc>.md, pinned in the lockfile. |
324
+ | `ahood doc update [<owner>/<doc> ...] [--dry-run] [--json]` | Move doc pins forward to the latest version. |
325
+ | `ahood doc outdated [<owner>/<doc> ...] [--json]` | Read-only staleness check for installed docs. |
326
+ | `ahood doc remove <owner>/<doc> [--yes]` | Uninstall and unpin a doc (local only, prompts unless --yes is passed). |
327
+ | `ahood doc edit <owner>/<doc> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a doc you own, changing only the flags you pass. |
328
+ | `ahood doc unpublish <owner>/<doc>[@version] [--yes]` | Delete a doc from the registry for every consumer, or yank one version (prompts unless --yes is passed). |
329
+ | `ahood doc star <owner>/<doc>` | Star a doc. |
330
+ | `ahood doc unstar <owner>/<doc>` | Remove your star from a doc. |
331
+ | `ahood doc share <owner>/<doc> --group <group>` | Share a doc you own with a group, without changing its visibility. |
332
+ | `ahood doc unshare <owner>/<doc> --group <group>` | Stop sharing a doc you own with a group. |
333
+ | `ahood doc init [name]` | Scaffold a new doc folder with a minimal, valid DOC.md. |
334
+ | `ahood doc publish <owner>/<doc>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a doc from a folder containing DOC.md, creating it first if it doesn't exist yet. |
335
+
310
336
  ### Group
311
337
 
312
338
  | Command | What it does |
@@ -4,12 +4,12 @@ import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import * as tarStream from "tar-stream";
5
5
  import { gunzipSync } from "node:zlib";
6
6
  import { apiFetch, apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
7
- import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
8
- import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
7
+ import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, docPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
8
+ import { readLockfile, withLock, writeFileAtomic, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
9
9
  import { promptSecret } from "../secret-prompt.js";
10
10
  import { sanitizeForTerminal } from "../terminal-safe.js";
11
11
  import { UsageError } from "../usage-error.js";
12
- import { assertKind } from "../kinds.js";
12
+ import { assertKind, strictKind } from "../kinds.js";
13
13
  const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
14
14
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
15
15
  const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
@@ -865,6 +865,33 @@ export async function downloadVerifiedArchive(owner, skill, meta) {
865
865
  }
866
866
  return buffer;
867
867
  }
868
+ // `ahood doc add` (ahood-cli#182): only DOC.md is extracted, written
869
+ // atomically to .claude/docs/<owner>@<doc>.md, and pinned with kind "doc" so
870
+ // remove/update can tell the pin apart without asking the registry. If the
871
+ // pin can't be written, the file goes back to what it was: the previous
872
+ // version's content on an update, nothing on a fresh install.
873
+ async function installDoc(owner, skill, meta, buffer) {
874
+ const key = `${owner}/${skill}`;
875
+ const content = await extractSingleFileContent(buffer, "DOC.md");
876
+ const destPath = docPath(owner, skill);
877
+ const previous = existsSync(destPath) ? readFileSync(destPath) : undefined;
878
+ writeFileAtomic(destPath, content);
879
+ try {
880
+ writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
881
+ version: meta.version,
882
+ checksum_sha256: meta.checksum_sha256,
883
+ kind: "doc",
884
+ });
885
+ }
886
+ catch (error) {
887
+ rollbackOrWarn(() => (previous === undefined ? rmSync(destPath, { force: true }) : writeFileAtomic(destPath, previous)), `WARNING: could not restore ${destPath} after ${key}'s install failed -- it may not match its ${LOCKFILE_PATH} pin. Run \`ahood doc remove ${key}\` to clean it up.`);
888
+ if (error instanceof LockfileChecksumConflictError) {
889
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
890
+ }
891
+ throw error;
892
+ }
893
+ console.log(`Installed ${key}@${meta.version} to ${destPath}`);
894
+ }
868
895
  export async function add(args, scope) {
869
896
  const spec = args[0];
870
897
  if (!spec)
@@ -876,16 +903,24 @@ export async function add(args, scope) {
876
903
  // refused here, before the download, the lockfile and every write below.
877
904
  // Both metadata routes report the entry's kind, so this costs no request.
878
905
  assertKind(scope, key, meta.kind, "add", "install");
879
- // A doc has no local install target (ahood-cli#175): only skill, agent and
880
- // mcp entries map to a place on disk. Legacy cross-kind `ahood skill add`
881
- // used to fall through to the skill path and extract a doc into
906
+ // Legacy cross-kind `ahood skill add` of a doc stays refused (ahood-cli#175):
907
+ // it used to fall through to the skill path and extract a doc into
882
908
  // .claude/skills/<owner>@<slug>/ with no SKILL.md -- not a working skill,
883
- // with nothing telling the user. Refused here, on every path, before the
884
- // download, the lockfile and any write. Entries with NO kind keep the
885
- // legacy skill behaviour.
886
- if (meta.kind === "doc") {
887
- throw new UsageError(`${key} is a doc (a documentation page), which has no local install target. ` +
888
- `Read it with \`ahood skill read ${key}\`, or open it with \`ahood skill view ${key} --web\`.`);
909
+ // with nothing telling the user. Docs install only through the doc scope
910
+ // (`ahood doc add`, ahood-cli#182), which writes the one flat file below.
911
+ // Refused before the download, the lockfile and any write. Entries with NO
912
+ // kind keep the legacy skill behaviour.
913
+ const docInstall = meta.kind === "doc" && strictKind(scope) === "doc";
914
+ if (meta.kind === "doc" && !docInstall) {
915
+ throw new UsageError(`${key} is a doc (a documentation page), not a skill. ` +
916
+ `Install it with \`ahood doc add ${key}\`, or read it without installing with \`ahood doc read ${key}\`.`);
917
+ }
918
+ // No-overwrite: a file already at the doc's path that this project's
919
+ // lockfile does not pin as this doc was not written by ahood, so it is
920
+ // left alone. Checked before the download, like every refusal above.
921
+ if (docInstall && existsSync(docPath(owner, skill)) && readLockfile(LOCKFILE_PATH)[key]?.kind !== "doc") {
922
+ throw new Error(`${docPath(owner, skill)} already exists and is not pinned as ${key} in ${LOCKFILE_PATH} -- refusing to overwrite it. ` +
923
+ "Move or delete it first.");
889
924
  }
890
925
  // A lockfile entry records the checksum this exact version was installed
891
926
  // with; if a later fetch of the "same" version disagrees, either the
@@ -912,7 +947,7 @@ export async function add(args, scope) {
912
947
  // manifest can never actually land on disk there -- the warning would be
913
948
  // both mislabeled ("skill") and about a risk that can't materialize
914
949
  // (ahood-cli final review finding #5).
915
- if (meta.kind !== "agent" && meta.kind !== "mcp" && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
950
+ if (meta.kind !== "agent" && meta.kind !== "mcp" && meta.kind !== "doc" && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
916
951
  console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
917
952
  }
918
953
  const buffer = await downloadVerifiedArchive(owner, skill, meta);
@@ -945,6 +980,10 @@ export async function add(args, scope) {
945
980
  await installMcpEntry(owner, skill, meta, buffer);
946
981
  return;
947
982
  }
983
+ if (docInstall) {
984
+ await installDoc(owner, skill, meta, buffer);
985
+ return;
986
+ }
948
987
  // Flat, owner@skill-joined directory name so Claude Code's own one-level-deep
949
988
  // project-skill scan actually discovers it (see skillDir's comment in spec.ts).
950
989
  const destDir = skillDir(owner, skill);
@@ -1,13 +1,14 @@
1
- import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, AGENT_COMMANDS_HELP, MCP_COMMANDS_HELP, GROUP_COMMANDS_HELP, SNAP_COMMANDS_HELP, COMMAND_ALIASES, } from "../help.js";
1
+ import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, AGENT_COMMANDS_HELP, MCP_COMMANDS_HELP, DOC_COMMANDS_HELP, GROUP_COMMANDS_HELP, SNAP_COMMANDS_HELP, COMMAND_ALIASES, } from "../help.js";
2
2
  import { UsageError } from "../usage-error.js";
3
3
  const USAGE = "Usage: ahood completion <bash|zsh|fish>";
4
4
  // Every entity noun with its own verb list, in the order completion offers
5
- // them. Registry nouns (skill, agent, mcp -- ahood-cli#172) come first; group
5
+ // them. Registry nouns (skill, agent, mcp -- ahood-cli#172; doc -- #182) come first; group
6
6
  // and snap were already real nouns but had no completion at all before.
7
7
  const NOUNS = [
8
8
  ["skill", SKILL_COMMANDS_HELP],
9
9
  ["agent", AGENT_COMMANDS_HELP],
10
10
  ["mcp", MCP_COMMANDS_HELP],
11
+ ["doc", DOC_COMMANDS_HELP],
11
12
  ["group", GROUP_COMMANDS_HELP],
12
13
  ["snap", SNAP_COMMANDS_HELP],
13
14
  ];
@@ -2,7 +2,7 @@ import { createPatch } from "diff";
2
2
  import { fetchVersionMeta, downloadVerifiedArchive, extractSingleFileContent } from "./add.js";
3
3
  import { parseOwnerSkill, SEMVER_RE } from "../spec.js";
4
4
  import { UsageError } from "../usage-error.js";
5
- import { assertKind } from "../kinds.js";
5
+ import { assertKind, isCliKind, KINDS } from "../kinds.js";
6
6
  const USAGE = "Usage: ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]";
7
7
  // Both versions must be explicit semver here, unlike `add`'s
8
8
  // parseOwnerSkillVersion which lets "latest" default the version -- a diff
@@ -20,12 +20,16 @@ function validateVersion(version, spec) {
20
20
  // SKILL.md's presence from the manifest we already fetched -- checked
21
21
  // up front so a missing file produces a clean message instead of
22
22
  // extractSingleFileContent's generic "not found in the downloaded archive".
23
- async function fetchSkillMdContent(owner, skill, meta) {
24
- if (!meta.manifest.some((f) => f.path === "SKILL.md" || f.path === "./SKILL.md")) {
25
- throw new Error(`${owner}/${skill}@${meta.version} has no SKILL.md in its manifest.`);
23
+ //
24
+ // The file is the entry's root document (ahood-cli#182): SKILL.md for a skill
25
+ // or an entry with no kind (unchanged), AGENT.md, server.json or DOC.md for
26
+ // the other kinds, which have no SKILL.md to diff.
27
+ async function fetchSkillMdContent(owner, skill, meta, rootDoc) {
28
+ if (!meta.manifest.some((f) => f.path === rootDoc || f.path === `./${rootDoc}`)) {
29
+ throw new Error(`${owner}/${skill}@${meta.version} has no ${rootDoc} in its manifest.`);
26
30
  }
27
31
  const buffer = await downloadVerifiedArchive(owner, skill, meta);
28
- const content = await extractSingleFileContent(buffer, "SKILL.md");
32
+ const content = await extractSingleFileContent(buffer, rootDoc);
29
33
  return content.toString("utf-8");
30
34
  }
31
35
  // Compares two versions' manifests by path. VersionMeta.manifest only
@@ -35,14 +39,14 @@ async function fetchSkillMdContent(owner, skill, meta) {
35
39
  // diffed above) -- there is no way to tell whether some other common path's
36
40
  // *content* differs without downloading and comparing it too, which is out
37
41
  // of scope for this command (see the resolution-plan comment on issue #88).
38
- function diffManifests(manifestA, manifestB, skillMdChanged) {
42
+ function diffManifests(manifestA, manifestB, skillMdChanged, rootDoc) {
39
43
  const pathsA = new Set(manifestA.map((f) => f.path));
40
44
  const pathsB = new Set(manifestB.map((f) => f.path));
41
45
  const added = [...pathsB].filter((p) => !pathsA.has(p)).sort();
42
46
  const removed = [...pathsA].filter((p) => !pathsB.has(p)).sort();
43
47
  const changed = [];
44
- if (skillMdChanged && pathsA.has("SKILL.md") && pathsB.has("SKILL.md")) {
45
- changed.push("SKILL.md");
48
+ if (skillMdChanged && pathsA.has(rootDoc) && pathsB.has(rootDoc)) {
49
+ changed.push(rootDoc);
46
50
  }
47
51
  return { added, removed, changed };
48
52
  }
@@ -61,13 +65,16 @@ export async function diff(args, scope) {
61
65
  ]);
62
66
  // Both version responses carry the entry's kind; checked before either archive is downloaded.
63
67
  assertKind(scope, `${owner}/${skill}`, metaA.kind, "diff", "diff");
68
+ const rootDoc = isCliKind(metaA.kind) ? KINDS[metaA.kind].rootDoc : "SKILL.md";
64
69
  const [contentA, contentB] = await Promise.all([
65
- fetchSkillMdContent(owner, skill, metaA),
66
- fetchSkillMdContent(owner, skill, metaB),
70
+ fetchSkillMdContent(owner, skill, metaA, rootDoc),
71
+ fetchSkillMdContent(owner, skill, metaB, rootDoc),
67
72
  ]);
68
- const skillmdDiff = createPatch("SKILL.md", contentA, contentB, versionA, versionB);
73
+ // The --json key stays `skillmd_diff` whatever the root document is, so
74
+ // the output shape never changes.
75
+ const skillmdDiff = createPatch(rootDoc, contentA, contentB, versionA, versionB);
69
76
  const skillMdChanged = contentA !== contentB;
70
- const manifest = diffManifests(metaA.manifest, metaB.manifest, skillMdChanged);
77
+ const manifest = diffManifests(metaA.manifest, metaB.manifest, skillMdChanged, rootDoc);
71
78
  if (jsonOutput) {
72
79
  console.log(JSON.stringify({ skillmd_diff: skillMdChanged ? skillmdDiff : "", manifest }));
73
80
  return;
@@ -78,7 +85,7 @@ export async function diff(args, scope) {
78
85
  console.log(skillmdDiff);
79
86
  }
80
87
  else {
81
- console.log("SKILL.md is unchanged between these versions.");
88
+ console.log(`${rootDoc} is unchanged between these versions.`);
82
89
  }
83
90
  const summaryParts = [];
84
91
  if (manifest.added.length > 0)
@@ -102,10 +102,30 @@ function buildServerJson(name) {
102
102
  return `${JSON.stringify(manifest, null, 2)}
103
103
  `;
104
104
  }
105
+ // DOC.md, for `ahood doc init` (ahood-cli#182). The backend's publish
106
+ // validator (lib/publish/parse-doc-frontmatter.ts in the ahood repo) is the
107
+ // same hand-rolled flat `key: value` reader as AGENT.md's, minus the skills
108
+ // list: the file must START with a `---` line, the closing `---` line must be
109
+ // followed by a newline, and `name` and `description` must be non-empty. So
110
+ // the same rules as buildAgentMd apply: nothing above the opening `---`, and
111
+ // no colon in a comment line inside the frontmatter.
112
+ function buildDocMd(name) {
113
+ return `---
114
+ name: ${name}
115
+ description: TODO -- one line saying what this doc covers and when an agent should read it.
116
+ ---
117
+
118
+ # ${name}
119
+
120
+ TODO -- write the documentation here. Plain Markdown; this whole file, frontmatter
121
+ included, is what \`ahood doc read\` prints and \`ahood doc add\` saves.
122
+ `;
123
+ }
105
124
  const TEMPLATES = {
106
125
  skill: buildSkillMd,
107
126
  agent: buildAgentMd,
108
127
  mcp: buildServerJson,
128
+ doc: buildDocMd,
109
129
  };
110
130
  const NEXT_STEPS = {
111
131
  skill: ["Fill in the description, then flesh out the ## Instructions section."],
@@ -115,7 +135,9 @@ const NEXT_STEPS = {
115
135
  '"remotes" with one npm package: "packages": [{"registry_type": "npm", "runtime_hint": "npx",',
116
136
  '"identifier": "<npm-package>", "version": "<x.y.z>"}] (plus optional "environment_variables").',
117
137
  ],
138
+ doc: ["Fill in the description, then write the doc below the frontmatter."],
118
139
  };
140
+ const PLACEHOLDERS = { skill: "<skill>", agent: "<agent>", mcp: "<server>", doc: "<doc>" };
119
141
  // Falls back to "my-skill" when the directory name itself isn't usable as a
120
142
  // bare YAML scalar (empty, or starting with a character like "-" or "@" that
121
143
  // would need quoting) -- e.g. running `ahood skill init` with no name directly in
@@ -180,6 +202,6 @@ export async function init(args, scope) {
180
202
  console.log(`Created ${docPath}`);
181
203
  for (const line of NEXT_STEPS[kind])
182
204
  console.log(line);
183
- const placeholder = kind === "skill" ? "<skill>" : kind === "agent" ? "<agent>" : "<server>";
205
+ const placeholder = PLACEHOLDERS[kind];
184
206
  console.log(`Run \`ahood ${strict ?? "skill"} publish <owner>/${placeholder}@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
185
207
  }
@@ -47,7 +47,7 @@ async function pollVersionStatus(owner, skill, version) {
47
47
  }
48
48
  throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood skill view ${owner}/${skill}\` later for the result.`);
49
49
  }
50
- 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" +
50
+ const USAGE = "Usage: ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp|doc] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]\n" +
51
51
  " or: ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
52
52
  // Matched by ENTRY NAME at every depth, not by path prefix, so a nested
53
53
  // `vendor/thing/.git` is skipped the same as a top-level one. Not a
@@ -264,8 +264,8 @@ function parsePublishArgs(args) {
264
264
  if (!SEMVER_RE.test(version)) {
265
265
  throw new UsageError(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
266
266
  }
267
- if (kind !== undefined && kind !== "skill" && kind !== "agent" && kind !== "mcp") {
268
- throw new UsageError(`--kind must be "skill", "agent", or "mcp" (got "${kind}").\n${USAGE}`);
267
+ if (kind !== undefined && kind !== "skill" && kind !== "agent" && kind !== "mcp" && kind !== "doc") {
268
+ throw new UsageError(`--kind must be "skill", "agent", "mcp", or "doc" (got "${kind}").\n${USAGE}`);
269
269
  }
270
270
  // Validated here, before any network call (including the tar/gzip of the
271
271
  // skill directory) -- these only ever reach the server via
@@ -377,11 +377,17 @@ export async function publish(args, scope) {
377
377
  const skillMdPath = join(path, "SKILL.md");
378
378
  const agentMdPath = join(path, "AGENT.md");
379
379
  const serverJsonPath = join(path, "server.json");
380
+ const docMdPath = join(path, "DOC.md");
380
381
  const hasSkillMd = existsSync(skillMdPath);
381
382
  const hasAgentMd = existsSync(agentMdPath);
382
383
  const hasServerJson = existsSync(serverJsonPath);
384
+ const hasDocMd = existsSync(docMdPath);
383
385
  let resolvedKind = kind;
384
- if (kind === "agent") {
386
+ if (kind === "doc") {
387
+ if (!hasDocMd)
388
+ throw new Error(`No DOC.md found at ${docMdPath} -- ${via} must point at a folder containing DOC.md.`);
389
+ }
390
+ else if (kind === "agent") {
385
391
  if (!hasAgentMd)
386
392
  throw new Error(`No AGENT.md found at ${agentMdPath} -- ${via} must point at a folder containing AGENT.md.`);
387
393
  }
@@ -395,12 +401,17 @@ export async function publish(args, scope) {
395
401
  }
396
402
  else {
397
403
  // kind === undefined -- infer from whichever root file(s) are actually present
398
- const present = [hasSkillMd && "SKILL.md", hasAgentMd && "AGENT.md", hasServerJson && "server.json"].filter(Boolean);
404
+ const present = [
405
+ hasSkillMd && "SKILL.md",
406
+ hasAgentMd && "AGENT.md",
407
+ hasServerJson && "server.json",
408
+ hasDocMd && "DOC.md",
409
+ ].filter(Boolean);
399
410
  if (present.length > 1) {
400
- throw new Error(`Multiple root files found at ${path} (${present.join(", ")}) -- pass --kind skill, --kind agent, or --kind mcp to disambiguate.`);
411
+ throw new Error(`Multiple root files found at ${path} (${present.join(", ")}) -- pass --kind skill, --kind agent, --kind mcp, or --kind doc to disambiguate.`);
401
412
  }
402
413
  if (present.length === 0) {
403
- 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.`);
414
+ throw new Error(`No SKILL.md, AGENT.md, server.json, or DOC.md found at ${path} -- publish must point at a skill, agent, mcp, or doc folder's root.`);
404
415
  }
405
416
  if (hasAgentMd) {
406
417
  resolvedKind = "agent"; // inferred: only AGENT.md present, no --kind needed
@@ -408,6 +419,9 @@ export async function publish(args, scope) {
408
419
  else if (hasServerJson) {
409
420
  resolvedKind = "mcp"; // inferred: only server.json present, no --kind needed
410
421
  }
422
+ else if (hasDocMd) {
423
+ resolvedKind = "doc"; // inferred: only DOC.md present (ahood-cli#182)
424
+ }
411
425
  // else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
412
426
  }
413
427
  // ahood-cli#93: fires for an explicit --kind skill, and for the
@@ -1,7 +1,7 @@
1
1
  import { existsSync, rmSync } from "node:fs";
2
2
  import { confirm } from "../confirm.js";
3
3
  import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
4
- import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
4
+ import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, docPath, MCP_CONFIG_PATH } from "../spec.js";
5
5
  import { readMcpConfig, matchesMcpConfigHash } from "./add.js";
6
6
  import { UsageError } from "../usage-error.js";
7
7
  import { assertKind, ensureRemoteKind, KindMismatchError, describeKind, localInstalledKind, strictKind, } from "../kinds.js";
@@ -46,6 +46,9 @@ export async function remove(args, scope) {
46
46
  // never a directory under .claude/skills/ -- distinct from the dir check above.
47
47
  const agentFile = agentPath(owner, skill);
48
48
  const agentExisted = existsSync(agentFile);
49
+ // A doc installs as one flat file under .claude/docs/ (ahood-cli#182).
50
+ const docFile = docPath(owner, skill);
51
+ const docExisted = existsSync(docFile);
49
52
  const lockEntry = readLockfile(LOCKFILE_PATH)[key];
50
53
  const hadLockfileEntry = lockEntry !== undefined;
51
54
  // A pin whose install left nothing on disk is the defining shape of an
@@ -55,8 +58,8 @@ export async function remove(args, scope) {
55
58
  // before that field did (lockfile.ts: "absent for any mcp entry installed
56
59
  // before this field existed") and it is computed HERE, above the .mcp.json
57
60
  // read -- so it survives the case where that read throws (ahood-cli#143).
58
- const hadNoOnDiskFootprint = !dirExisted && !agentExisted && hadLockfileEntry;
59
- if (!dirExisted && !agentExisted && !hadLockfileEntry) {
61
+ const hadNoOnDiskFootprint = !dirExisted && !agentExisted && !docExisted && lockEntry?.kind !== "doc" && hadLockfileEntry;
62
+ if (!dirExisted && !agentExisted && !docExisted && !hadLockfileEntry) {
60
63
  console.error(`${key} was not installed -- nothing to remove.`);
61
64
  process.exitCode = 1;
62
65
  return;
@@ -93,6 +96,8 @@ export async function remove(args, scope) {
93
96
  rmSync(dir, { recursive: true, force: true });
94
97
  if (agentExisted)
95
98
  rmSync(agentFile, { force: true });
99
+ if (docExisted)
100
+ rmSync(docFile, { force: true });
96
101
  // An mcp-kind install has no directory or agent file on disk -- its only
97
102
  // footprint is the lockfile entry (cleared below, once this block has run)
98
103
  // and a live entry in .mcp.json (which add.ts's installMcpEntry merged in,
@@ -214,8 +214,13 @@ export async function update(args, scope) {
214
214
  }
215
215
  // no @version -- resolves to latest again. A strict scope is passed on so
216
216
  // add() re-checks the kind it resolves itself before writing anything.
217
+ // A doc pin under legacy `ahood skill update` is moved forward through
218
+ // the doc scope (ahood-cli#182): legacy add refuses docs, but legacy
219
+ // update has always covered every installed pin.
217
220
  if (kind)
218
221
  await add([ownerSlashSkill], scope);
222
+ else if (meta.kind === "doc")
223
+ await add([ownerSlashSkill], { noun: "doc", kind: "doc" });
219
224
  else
220
225
  await add([ownerSlashSkill]);
221
226
  }
package/dist/help.js CHANGED
@@ -121,7 +121,8 @@ export const SKILL_COMMANDS_HELP = [
121
121
  "installs instead as a single file at .claude/agents/<owner>@<skill>.md (Claude Code's own subagent " +
122
122
  "loader scans .claude/agents/*.md as flat files), not a .claude/skills/ directory. An artifact published " +
123
123
  "with --kind mcp installs by merging an entry into .mcp.json instead, prompting for any secret " +
124
- "environment variables its server.json declares that aren't already set in your shell.",
124
+ "environment variables its server.json declares that aren't already set in your shell. A doc is refused " +
125
+ "here; install it with `ahood doc add`.",
125
126
  examples: ["ahood skill add alice/pdf-tools", "ahood skill add alice/pdf-tools@1.2.0"],
126
127
  },
127
128
  {
@@ -206,17 +207,17 @@ export const SKILL_COMMANDS_HELP = [
206
207
  examples: ["ahood skill init pdf-tools", "ahood skill init"],
207
208
  },
208
209
  {
209
- 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]",
210
- 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.",
211
- 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 " +
212
- "doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent or --kind mcp if " +
213
- "publishing an AGENT.md or server.json (auto-detected from the folder contents when only one of SKILL.md/AGENT.md/server.json is present). " +
210
+ usage: "ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp|doc] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]",
211
+ summary: "Publish a new version of a skill, agent, mcp server manifest, or doc from a folder containing SKILL.md, AGENT.md, server.json, or DOC.md, creating the skill first if it doesn't already exist.",
212
+ desc: "Publish a new version of a skill, agent, mcp server manifest, or doc from a folder containing SKILL.md, AGENT.md, server.json, or DOC.md. If the artifact " +
213
+ "doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent, --kind mcp, or --kind doc if " +
214
+ "publishing an AGENT.md, server.json, or DOC.md (auto-detected from the folder contents when only one of SKILL.md/AGENT.md/server.json/DOC.md is present). " +
214
215
  "--name is required when creating; optionally also pass --tagline/--tags/--license/--homepage/--repository. " +
215
216
  "Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
216
217
  "The legacy form `ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
217
218
  "Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
218
219
  flags: [
219
- "--kind skill|agent|mcp What kind of artifact this is. Auto-detected from SKILL.md/AGENT.md/server.json when omitted.",
220
+ "--kind skill|agent|mcp|doc What kind of artifact this is. Auto-detected from SKILL.md/AGENT.md/server.json/DOC.md when omitted.",
220
221
  "--name <text> Required only when creating the artifact on this publish.",
221
222
  "--tagline <text> Short one-line description, used only when creating.",
222
223
  "--tags <comma,separated> Initial tags, used only when creating.",
@@ -240,15 +241,21 @@ export const SKILL_COMMANDS_HELP = [
240
241
  // rather than hand-copied twice, so the two lists can't drift from each other.
241
242
  // SKILL_COMMANDS_HELP above stays hand-written: it documents the legacy,
242
243
  // cross-kind behavior `ahood skill` keeps in this release.
244
+ const PLACEHOLDER = { agent: "<agent>", mcp: "<server>", doc: "<doc>" };
245
+ const EXAMPLE = {
246
+ agent: { slug: "alice/code-reviewer", name: "Code Reviewer" },
247
+ mcp: { slug: "alice/github-server", name: "GitHub Server" },
248
+ doc: { slug: "alice/setup-guide", name: "Setup Guide" },
249
+ };
243
250
  function registryCommandsHelp(kind) {
244
251
  const info = KINDS[kind];
245
- const n = kind === "agent" ? "<agent>" : "<server>";
252
+ const n = PLACEHOLDER[kind];
246
253
  const p = `ahood ${kind}`;
247
254
  const label = info.label;
248
255
  const a = info.article;
249
256
  const plural = info.plural;
250
257
  const doc = info.rootDoc;
251
- const ex = kind === "agent" ? "alice/code-reviewer" : "alice/github-server";
258
+ const ex = EXAMPLE[kind].slug;
252
259
  const kindRefusal = `Refused, before anything else happens, when the target is not ${a} ${label}.`;
253
260
  return [
254
261
  {
@@ -378,11 +385,12 @@ function registryCommandsHelp(kind) {
378
385
  "--changelog <text> What changed in this version.",
379
386
  "--json Print one {version,status,...} JSON object on completion (or {error} on failure).",
380
387
  ],
381
- examples: [`${p} publish ${ex}@1.0.0 --name "${kind === "agent" ? "Code Reviewer" : "GitHub Server"}"`],
388
+ examples: [`${p} publish ${ex}@1.0.0 --name "${EXAMPLE[kind].name}"`],
382
389
  },
383
390
  ];
384
391
  }
385
392
  export const AGENT_COMMANDS_HELP = registryCommandsHelp("agent");
393
+ export const DOC_COMMANDS_HELP = registryCommandsHelp("doc");
386
394
  // `ahood mcp` is two things (ADR 0007 in the ahood repo separates the hosted
387
395
  // registry MCP endpoint from this CLI's local one): with no verb, or with
388
396
  // `serve`, it is the local, read-only stdio MCP server that MCP hosts spawn;
@@ -585,6 +593,7 @@ const ENTITY_COMMANDS_HELP = {
585
593
  skill: SKILL_COMMANDS_HELP,
586
594
  agent: AGENT_COMMANDS_HELP,
587
595
  mcp: MCP_COMMANDS_HELP,
596
+ doc: DOC_COMMANDS_HELP,
588
597
  group: GROUP_COMMANDS_HELP,
589
598
  snap: SNAP_COMMANDS_HELP,
590
599
  };
@@ -709,8 +718,8 @@ export function formatSkillHelp() {
709
718
  "Legacy, cross-kind: in this release `ahood skill <verb>` still acts on any registry kind",
710
719
  "(skills, agents, MCP server manifests) -- `skill list`/`skill search` include every kind, and",
711
720
  "`skill add`/`skill update` install whatever kind they resolve. Scripts relying on that keep",
712
- "working. For one kind only, use `ahood agent <verb>` / `ahood mcp <verb>`, or add",
713
- "`--kind skill|agent|mcp` to any verb below (`--kind all` spells out the legacy behavior).",
721
+ "working. For one kind only, use `ahood agent|mcp|doc <verb>`, or add",
722
+ "`--kind skill|agent|mcp|doc` to any verb below (`--kind all` spells out the legacy behavior).",
714
723
  "A future major version will make `ahood skill` skills-only.",
715
724
  "",
716
725
  "Commands:",
@@ -751,11 +760,20 @@ export function formatMcpHelp() {
751
760
  }
752
761
  // One entry point for every registry noun's group help, so dispatch and
753
762
  // `ahood help <noun>` can't disagree about which listing a noun gets.
763
+ export function formatDocHelp() {
764
+ return formatRegistryKindHelp("ahood doc -- docs (DOC.md) in the ahood registry", [
765
+ "Every verb here acts on docs only: a target of another kind is refused before anything",
766
+ "is downloaded, prompted for, written, or sent. Installs land in .claude/docs/<owner>@<doc>.md.",
767
+ "To look at a doc without installing it, use `ahood doc read`.",
768
+ ], DOC_COMMANDS_HELP, "doc");
769
+ }
754
770
  export function formatKindHelp(noun) {
755
771
  if (noun === "agent")
756
772
  return formatAgentHelp();
757
773
  if (noun === "mcp")
758
774
  return formatMcpHelp();
775
+ if (noun === "doc")
776
+ return formatDocHelp();
759
777
  return formatSkillHelp();
760
778
  }
761
779
  // `ahood group --help` -- the group-level listing for every group verb.
@@ -793,6 +811,8 @@ export function formatHelp() {
793
811
  const agentGroupSummary = "The same verbs, for agent definitions only -- run `ahood agent --help` for the full list.";
794
812
  const mcpGroupUsage = "ahood mcp <command>";
795
813
  const mcpGroupSummary = "The same verbs, for MCP server manifests only; bare `ahood mcp` or `ahood mcp serve` starts the local MCP server.";
814
+ const docGroupUsage = "ahood doc <command>";
815
+ const docGroupSummary = "The same verbs, for docs (DOC.md) only -- run `ahood doc --help` for the full list.";
796
816
  const groupGroupUsage = "ahood group <command>";
797
817
  const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
798
818
  const snapGroupUsage = "ahood snap <command>";
@@ -802,6 +822,7 @@ export function formatHelp() {
802
822
  { usage: skillGroupUsage, summary: skillGroupSummary },
803
823
  { usage: agentGroupUsage, summary: agentGroupSummary },
804
824
  { usage: mcpGroupUsage, summary: mcpGroupSummary },
825
+ { usage: docGroupUsage, summary: docGroupSummary },
805
826
  { usage: groupGroupUsage, summary: groupGroupSummary },
806
827
  { usage: snapGroupUsage, summary: snapGroupSummary },
807
828
  ]);
@@ -823,7 +844,7 @@ export function formatHelp() {
823
844
  "",
824
845
  "Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
825
846
  "Run `ahood skill --help` for the full list of skill commands.",
826
- "Run `ahood agent --help` / `ahood mcp --help` for the kind-scoped agent and MCP commands.",
847
+ "Run `ahood agent --help` / `ahood mcp --help` / `ahood doc --help` for the kind-scoped agent, MCP and doc commands.",
827
848
  "Run `ahood group --help` for the full list of group commands.",
828
849
  "Run `ahood snap --help` for the full list of snap commands.",
829
850
  "Run `ahood --version` to print the installed CLI version.",
package/dist/index.js CHANGED
@@ -96,6 +96,7 @@ const COMMANDS = {
96
96
  mcp: dispatchMcp,
97
97
  skill: dispatchSkill,
98
98
  agent: dispatchAgent,
99
+ doc: dispatchDoc,
99
100
  group: dispatchGroup,
100
101
  snap: dispatchSnap,
101
102
  };
@@ -107,8 +108,9 @@ const COMMANDS = {
107
108
  // here just means "a command that owns its own sub-verb dispatch", the same
108
109
  // sense "skill" already used before "group" (the entity) existed. "snap"
109
110
  // joins this set for the exact same reason (ahood-cli#107).
110
- // "agent" and "mcp" join it as registry nouns (ahood-cli#172).
111
- const GROUP_COMMANDS = new Set(["skill", "agent", "mcp", "group", "snap"]);
111
+ // "agent" and "mcp" join it as registry nouns (ahood-cli#172), "doc" too
112
+ // (ahood-cli#182).
113
+ const GROUP_COMMANDS = new Set(["skill", "agent", "mcp", "doc", "group", "snap"]);
112
114
  function levenshtein(a, b) {
113
115
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
114
116
  for (let i = 0; i <= a.length; i++)
@@ -146,7 +148,7 @@ function closestGroupVerb(input) {
146
148
  function closestSnapVerb(input) {
147
149
  return closestOf(input, Object.keys(SNAP_VERBS));
148
150
  }
149
- // `ahood <noun> <verb> [...]` for every registry noun (skill, agent, mcp) --
151
+ // `ahood <noun> <verb> [...]` for every registry noun (skill, agent, mcp, doc) --
150
152
  // owns its own help handling at both the noun level (`ahood agent` /
151
153
  // `ahood agent --help`) and the per-verb level (`ahood agent <verb> --help`),
152
154
  // since neither should fall through to main()'s generic top-level --help
@@ -210,6 +212,9 @@ async function dispatchSkill(args) {
210
212
  async function dispatchAgent(args) {
211
213
  await dispatchRegistry("agent", args);
212
214
  }
215
+ async function dispatchDoc(args) {
216
+ await dispatchRegistry("doc", args);
217
+ }
213
218
  // `ahood mcp` keeps its original contract byte-for-byte: with NO arguments it
214
219
  // is the local stdio MCP server that existing host configs launch -- nothing
215
220
  // is printed and nothing else runs first. `ahood mcp serve` is the same
@@ -312,7 +317,7 @@ async function main() {
312
317
  process.stdout.write(content);
313
318
  return;
314
319
  }
315
- if (sub === "skill" || sub === "agent" || sub === "mcp") {
320
+ if (sub === "skill" || sub === "agent" || sub === "mcp" || sub === "doc") {
316
321
  const verb = args[1];
317
322
  const entry = verb ? findCommandHelp(sub, verb) : undefined;
318
323
  console.log(entry ? formatCommandHelp(entry) : formatKindHelp(sub));
@@ -394,4 +399,4 @@ if (isEntrypoint) {
394
399
  main();
395
400
  }
396
401
  // Exported for tests only -- the CLI itself only ever calls main() above.
397
- export { main, dispatchSkill, dispatchAgent, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
402
+ export { main, dispatchSkill, dispatchAgent, dispatchDoc, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
package/dist/kind-info.js CHANGED
@@ -7,10 +7,10 @@
7
7
  // The registry stores four artifact kinds (the backend's
8
8
  // lib/skills/validation.ts ALLOWED_KINDS: skill, agent, mcp, doc), and a
9
9
  // kind is fixed when an entry is created -- PATCH's allow-list has no `kind`
10
- // -- so the kind an entry reports now is the kind it has always had. This
11
- // CLI has a noun group for three of them. `doc` is a real registry kind left
12
- // out of this CLI's scope on purpose; adding it means one entry in KINDS
13
- // below plus its help/init template, not a new dispatcher.
10
+ // -- so the kind an entry reports now is the kind it has always had. Each
11
+ // kind has a noun group (`ahood skill|agent|mcp|doc <verb>`), all run by one
12
+ // shared dispatcher. `doc` joined as the fourth in ahood-cli#182: it installs
13
+ // as one flat Markdown file, its DOC.md, the same shape an agent has.
14
14
  export const KINDS = {
15
15
  skill: {
16
16
  label: "skill",
@@ -33,6 +33,13 @@ export const KINDS = {
33
33
  rootDoc: "server.json",
34
34
  installsTo: "an entry in .mcp.json",
35
35
  },
36
+ doc: {
37
+ label: "doc",
38
+ article: "a",
39
+ plural: "docs",
40
+ rootDoc: "DOC.md",
41
+ installsTo: ".claude/docs/<owner>@<doc>.md",
42
+ },
36
43
  };
37
44
  export const CLI_KINDS = Object.keys(KINDS);
38
45
  export function isCliKind(value) {
package/dist/kinds.js CHANGED
@@ -62,6 +62,9 @@ export async function ensureRemoteKind(scope, owner, skill, verb, action) {
62
62
  // skill whose directory was deleted by hand) is undefined: unknown, and the
63
63
  // caller must ask the registry rather than guess.
64
64
  export function localInstalledKind(owner, skill, entry) {
65
+ // A doc pin records its kind (ahood-cli#182), the one kind that does.
66
+ if (entry?.kind === "doc")
67
+ return "doc";
65
68
  const hasDir = existsSync(skillDir(owner, skill));
66
69
  const hasAgentFile = existsSync(agentPath(owner, skill));
67
70
  if (hasDir && !hasAgentFile)
@@ -102,8 +105,8 @@ export function extractKindFlag(args) {
102
105
  // Resolves the scope for `ahood <noun> <verb> [--kind <k>]`.
103
106
  // skill noun, no --kind or --kind all -> legacy (cross-kind)
104
107
  // skill noun, --kind <k> -> strict <k>
105
- // agent/mcp noun, no --kind or --kind <same> -> strict <noun>
106
- // agent/mcp noun, any other --kind -> usage error (contradiction)
108
+ // agent/mcp/doc noun, no --kind or --kind <same> -> strict <noun>
109
+ // agent/mcp/doc noun, any other --kind -> usage error (contradiction)
107
110
  export function resolveScope(noun, kindFlag) {
108
111
  const valid = [...CLI_KINDS, "all"].join(", ");
109
112
  if (kindFlag !== undefined && kindFlag !== "all" && !isCliKind(kindFlag)) {
package/dist/lockfile.js CHANGED
@@ -146,6 +146,12 @@ function resolveWriteTarget(path) {
146
146
  // file it is creating must get what it asked for regardless of the ambient
147
147
  // umask.
148
148
  export function writeJsonFileAtomic(path, data, mode) {
149
+ writeFileAtomic(path, JSON.stringify(data, null, 2) + "\n", mode);
150
+ }
151
+ // The same write-then-rename for raw content. A doc install (ahood-cli#182)
152
+ // writes its DOC.md through this, so an interrupted install never leaves a
153
+ // truncated doc behind.
154
+ export function writeFileAtomic(path, content, mode) {
149
155
  // Resolved before the mkdir so the directory that actually gets created is
150
156
  // the resolved target's, not the link's -- a link set up ahead of the first
151
157
  // install can point somewhere that doesn't exist yet -- and so that every
@@ -189,7 +195,7 @@ export function writeJsonFileAtomic(path, data, mode) {
189
195
  // 0600 from the moment of creation: for .mcp.json this temp file holds
190
196
  // resolved MCP server secrets, and it must never be world-readable, not
191
197
  // even for the instant before the rename (ahood-cli#119).
192
- writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
198
+ writeFileSync(tmpPath, content, { mode: 0o600 });
193
199
  renameSync(tmpPath, dest);
194
200
  try {
195
201
  chmodSync(dest, finalMode);
package/dist/spec.js CHANGED
@@ -116,3 +116,16 @@ export function agentPath(owner, skill) {
116
116
  // file isn't under .claude/ at all, since it's a convention MCP clients
117
117
  // broadly share, not one specific to this CLI).
118
118
  export const MCP_CONFIG_PATH = ".mcp.json";
119
+ // A doc installs as one flat file, .claude/docs/<owner>@<doc>.md
120
+ // (ahood-cli#182), the same "@"-joined shape as agentPath above. Unlike
121
+ // agentPath, both segments are validated again here: callers have already
122
+ // parsed them through parseOwnerSkill, but this is the path a doc install
123
+ // writes to, so the SEGMENT_RE containment is enforced where the path is
124
+ // built too. `alice/..` can never resolve outside .claude/docs/.
125
+ export const DOCS_ROOT = join(".claude", "docs");
126
+ export function docPath(owner, doc) {
127
+ const spec = `${owner}/${doc}`;
128
+ validateSegment(owner, "owner", spec);
129
+ validateSegment(doc, "skill", spec);
130
+ return join(DOCS_ROOT, `${owner}@${doc}.md`);
131
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ahood
3
- description: How to use the ahood CLI (@ahood/cli), the registry for reusable AI-agent skills, subagent definitions (AGENT.md), and MCP server manifests (server.json). Load BEFORE running any ahood command; before writing a non-trivial reusable skill, subagent, or MCP integration from scratch (search the registry first); when installing, updating, removing, publishing, sharing, or unpublishing one; when wiring ahood into an MCP host; or when setting up headless/CI access with AHOOD_TOKEN. Triggers - ahood, skill registry, is there a skill for this, install a skill, install an agent, add an MCP server, publish a skill, ahood mcp, AHOOD_TOKEN.
3
+ description: How to use the ahood CLI (@ahood/cli), the registry for reusable AI-agent skills, subagent definitions (AGENT.md), MCP server manifests (server.json), and docs (DOC.md). Load BEFORE running any ahood command; before writing a non-trivial reusable skill, subagent, or MCP integration from scratch (search the registry first); when installing, updating, removing, publishing, sharing, or unpublishing one; when wiring ahood into an MCP host; or when setting up headless/CI access with AHOOD_TOKEN. Triggers - ahood, skill registry, is there a skill for this, install a skill, install an agent, add an MCP server, read a doc, publish a skill, ahood mcp, ahood doc, AHOOD_TOKEN.
4
4
  ---
5
5
 
6
6
  # ahood
@@ -13,28 +13,31 @@ and only moves when someone runs an update.
13
13
  This guide ships inside the CLI itself (`ahood help useme`), so it matches the installed
14
14
  version (`ahood --version`). It needs no login or network to print.
15
15
 
16
- ## Three kinds, three command groups
16
+ ## Four kinds, four command groups
17
17
 
18
18
  | Kind | Root file | Installs to | Commands |
19
19
  |---|---|---|---|
20
20
  | skill | `SKILL.md` | `.claude/skills/<owner>@<skill>/` | `ahood skill <verb>` |
21
21
  | agent | `AGENT.md` | `.claude/agents/<owner>@<agent>.md` | `ahood agent <verb>` |
22
22
  | mcp | `server.json` | an entry merged into `.mcp.json` | `ahood mcp <verb>` |
23
+ | doc | `DOC.md` | `.claude/docs/<owner>@<doc>.md` | `ahood doc <verb>` |
23
24
 
24
- All three groups take the same verbs: `search`, `list`, `view`/`show`, `read`, `versions`,
25
+ All four groups take the same verbs: `search`, `list`, `view`/`show`, `read`, `versions`,
25
26
  `diff`, `add`, `update`, `outdated`, `remove`, `edit`, `unpublish`, `star`, `unstar`,
26
27
  `share`, `unshare`, `init`, `publish`.
27
28
 
28
- - `ahood agent <verb>` and `ahood mcp <verb>` act on their own kind only. A target of another
29
- kind is refused (exit 2) before anything is downloaded, prompted for, written, or sent,
30
- and the error names the kind it found and the right command.
29
+ - `ahood agent <verb>`, `ahood mcp <verb>` and `ahood doc <verb>` act on their own kind only.
30
+ A target of another kind is refused (exit 2) before anything is downloaded, prompted for,
31
+ written, or sent, and the error names the kind it found and the right command.
31
32
  - `ahood skill <verb>` is the **legacy, cross-kind** group in this CLI release: `skill search`
32
33
  and `skill list` include every kind, and `skill add`/`skill update` install whatever kind
33
- they resolve. Add `--kind skill` (or `agent`, `mcp`) to any verb to scope it to one kind;
34
+ they resolve, except a doc: `skill add` refuses one and points at `ahood doc add`.
35
+ Add `--kind skill` (or `agent`, `mcp`, `doc`) to any verb to scope it to one kind;
34
36
  `--kind all` spells out the legacy behavior. Prefer the kind-specific form in anything new --
35
37
  a future major version makes `ahood skill` skills-only.
36
- - The registry also has a `doc` kind (documentation pages). This CLI has no `doc` group;
37
- browse those on the website.
38
+ - A doc is a documentation page. `ahood doc read alice/setup-guide` prints it without
39
+ installing anything; `ahood doc add alice/setup-guide` saves it as one pinned file under
40
+ `.claude/docs/`, which `ahood doc update` and `ahood doc remove` manage like any other pin.
38
41
 
39
42
  ## Find, inspect, then install
40
43
 
@@ -84,7 +87,7 @@ ahood skill update --dry-run --json # preview, machine-readable
84
87
  ahood agent remove alice/code-reviewer # local uninstall; prompts unless --yes
85
88
  ```
86
89
 
87
- With no target, `agent`/`mcp` `update` and `outdated` consider only installed pins of that
90
+ With no target, `agent`/`mcp`/`doc` `update` and `outdated` consider only installed pins of that
88
91
  kind; the legacy `skill update` covers every pin. Updates only affect the current project.
89
92
  An `.mcp.json` entry you edited by hand is refused rather than overwritten.
90
93
 
@@ -96,13 +99,14 @@ Scaffold a valid starter, edit it, then publish an immutable semver version:
96
99
  ahood skill init pdf-tools # ./pdf-tools/SKILL.md
97
100
  ahood agent init code-reviewer # ./code-reviewer/AGENT.md
98
101
  ahood mcp init github-server # ./github-server/server.json (placeholder URL)
102
+ ahood doc init setup-guide # ./setup-guide/DOC.md
99
103
 
100
104
  ahood agent publish alice/code-reviewer@1.0.0 --path code-reviewer --name "Code Reviewer"
101
105
  ahood skill publish alice/pdf-tools@1.1.0 --path pdf-tools --changelog "Adds split"
102
106
  ```
103
107
 
104
108
  - `--name` is required only on the first publish, which creates the entry under your
105
- account. `agent publish`/`mcp publish` imply the kind and refuse a contradictory `--kind`
109
+ account. `agent publish`/`mcp publish`/`doc publish` imply the kind and refuse a contradictory `--kind`
106
110
  or an existing entry of another kind before uploading anything.
107
111
  - **New entries are private.** Make one discoverable explicitly:
108
112
  `ahood skill edit alice/pdf-tools --visibility public`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
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",