@ahood/cli 0.11.0 → 0.12.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.
@@ -6,7 +6,7 @@ import { apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
6
6
  import { flagValue } from "../flags.js";
7
7
  import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
8
8
  import { UsageError } from "../usage-error.js";
9
- import { assertKind, strictKind } from "../kinds.js";
9
+ import { assertKind, CLI_KINDS, isCliKind, KINDS, strictKind } from "../kinds.js";
10
10
  // `ahood agent publish` / `ahood mcp publish` (ahood-cli#172): an existing
11
11
  // entry of another kind must be refused before anything is packed, created,
12
12
  // or uploaded. The upload would fail server-side anyway -- the processing
@@ -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|doc] [--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|project] [--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" && kind !== "doc") {
268
- throw new UsageError(`--kind must be "skill", "agent", "mcp", or "doc" (got "${kind}").\n${USAGE}`);
267
+ if (kind !== undefined && !isCliKind(kind)) {
268
+ throw new UsageError(`--kind must be ${orList(CLI_KINDS.map((k) => `"${k}"`))} (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
@@ -355,6 +355,33 @@ async function initVersion(owner, skill, version, packageSizeBytes, changelog, c
355
355
  return { init, created };
356
356
  }
357
357
  }
358
+ // "a, b, or c" -- for messages that enumerate every kind.
359
+ function orList(items) {
360
+ return items.length <= 2 ? items.join(" or ") : `${items.slice(0, -1).join(", ")}, or ${items.at(-1)}`;
361
+ }
362
+ // Which kind a publish folder is, from the root docs it holds (KINDS[k].
363
+ // rootDoc), so a new kind needs no branch here. An explicit kind must have
364
+ // its root doc. With none, exactly one root doc must be present; SKILL.md
365
+ // alone returns undefined, the pre-kind behaviour (the server defaults to
366
+ // skill). Inference: agent/mcp (ahood-cli#172), doc (#182), project (#185).
367
+ function resolvePublishKind(path, kind, via) {
368
+ if (kind !== undefined) {
369
+ const rootDocPath = join(path, KINDS[kind].rootDoc);
370
+ if (!existsSync(rootDocPath)) {
371
+ throw new Error(`No ${KINDS[kind].rootDoc} found at ${rootDocPath} -- ${via} must point at a folder containing ${KINDS[kind].rootDoc}.`);
372
+ }
373
+ return kind;
374
+ }
375
+ const present = CLI_KINDS.filter((k) => existsSync(join(path, KINDS[k].rootDoc)));
376
+ const rootDocs = CLI_KINDS.map((k) => KINDS[k].rootDoc);
377
+ if (present.length > 1) {
378
+ throw new Error(`Multiple root files found at ${path} (${present.map((k) => KINDS[k].rootDoc).join(", ")}) -- pass ${orList(CLI_KINDS.map((k) => `--kind ${k}`))} to disambiguate.`);
379
+ }
380
+ if (present.length === 0) {
381
+ throw new Error(`No ${orList(rootDocs)} found at ${path} -- publish must point at a ${orList(CLI_KINDS)} folder's root.`);
382
+ }
383
+ return present[0] === "skill" ? undefined : present[0];
384
+ }
358
385
  // --json mode (ahood-cli#57): a CI/script caller needs one machine-readable
359
386
  // object on stdout, not the human progress lines below scraped with a
360
387
  // regex. jsonOutput therefore gates every intermediate console.log in this
@@ -374,56 +401,9 @@ export async function publish(args, scope) {
374
401
  const scopedKind = strictKind(scope);
375
402
  const kind = scopedKind ?? parsed.kind;
376
403
  const via = scopedKind ? `ahood ${scope.noun} publish` : `publish --kind ${kind}`;
377
- const skillMdPath = join(path, "SKILL.md");
378
- const agentMdPath = join(path, "AGENT.md");
379
- const serverJsonPath = join(path, "server.json");
380
- const docMdPath = join(path, "DOC.md");
404
+ const skillMdPath = join(path, KINDS.skill.rootDoc);
381
405
  const hasSkillMd = existsSync(skillMdPath);
382
- const hasAgentMd = existsSync(agentMdPath);
383
- const hasServerJson = existsSync(serverJsonPath);
384
- const hasDocMd = existsSync(docMdPath);
385
- let resolvedKind = kind;
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") {
391
- if (!hasAgentMd)
392
- throw new Error(`No AGENT.md found at ${agentMdPath} -- ${via} must point at a folder containing AGENT.md.`);
393
- }
394
- else if (kind === "skill") {
395
- if (!hasSkillMd)
396
- throw new Error(`No SKILL.md found at ${skillMdPath} -- ${via} must point at a folder containing SKILL.md.`);
397
- }
398
- else if (kind === "mcp") {
399
- if (!hasServerJson)
400
- throw new Error(`No server.json found at ${serverJsonPath} -- ${via} must point at a folder containing server.json.`);
401
- }
402
- else {
403
- // kind === undefined -- infer from whichever root file(s) are actually present
404
- const present = [
405
- hasSkillMd && "SKILL.md",
406
- hasAgentMd && "AGENT.md",
407
- hasServerJson && "server.json",
408
- hasDocMd && "DOC.md",
409
- ].filter(Boolean);
410
- if (present.length > 1) {
411
- throw new Error(`Multiple root files found at ${path} (${present.join(", ")}) -- pass --kind skill, --kind agent, --kind mcp, or --kind doc to disambiguate.`);
412
- }
413
- if (present.length === 0) {
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.`);
415
- }
416
- if (hasAgentMd) {
417
- resolvedKind = "agent"; // inferred: only AGENT.md present, no --kind needed
418
- }
419
- else if (hasServerJson) {
420
- resolvedKind = "mcp"; // inferred: only server.json present, no --kind needed
421
- }
422
- else if (hasDocMd) {
423
- resolvedKind = "doc"; // inferred: only DOC.md present (ahood-cli#182)
424
- }
425
- // else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
426
- }
406
+ const resolvedKind = resolvePublishKind(path, kind, via);
427
407
  // ahood-cli#93: fires for an explicit --kind skill, and for the
428
408
  // inferred-skill case above (resolvedKind left undefined because
429
409
  // SKILL.md was the only root file present) -- but not when SKILL.md
@@ -1,10 +1,10 @@
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, docPath, MCP_CONFIG_PATH } from "../spec.js";
4
+ import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, docPath, pinnedFilePath, MCP_CONFIG_PATH } from "../spec.js";
5
5
  import { readMcpConfig, matchesMcpConfigHash } from "./add.js";
6
6
  import { UsageError } from "../usage-error.js";
7
- import { assertKind, ensureRemoteKind, KindMismatchError, describeKind, localInstalledKind, strictKind, } from "../kinds.js";
7
+ import { assertKind, ensureRemoteKind, KindMismatchError, describeKind, isPinnedFileKind, localInstalledKind, strictKind, } from "../kinds.js";
8
8
  // `ahood agent remove` / `ahood mcp remove` (ahood-cli#172): decide whether
9
9
  // this install really is the scope's kind BEFORE the prompt and every delete
10
10
  // below. Local evidence first -- remove has always worked offline, and the
@@ -46,10 +46,12 @@ 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);
52
49
  const lockEntry = readLockfile(LOCKFILE_PATH)[key];
50
+ // A doc or project guide installs as one pinned file (ahood-cli#182, #185).
51
+ // A doc's path is derived from its own key, so it is found even without a
52
+ // pin; the project guide's path is shared, so only this key's pin claims it.
53
+ const pinnedFile = isPinnedFileKind(lockEntry?.kind) ? pinnedFilePath(lockEntry.kind, owner, skill) : docPath(owner, skill);
54
+ const pinnedFileExisted = existsSync(pinnedFile);
53
55
  const hadLockfileEntry = lockEntry !== undefined;
54
56
  // A pin whose install left nothing on disk is the defining shape of an
55
57
  // mcp-kind install: its only footprint is this pin plus a live .mcp.json
@@ -58,8 +60,8 @@ export async function remove(args, scope) {
58
60
  // before that field did (lockfile.ts: "absent for any mcp entry installed
59
61
  // before this field existed") and it is computed HERE, above the .mcp.json
60
62
  // read -- so it survives the case where that read throws (ahood-cli#143).
61
- const hadNoOnDiskFootprint = !dirExisted && !agentExisted && !docExisted && lockEntry?.kind !== "doc" && hadLockfileEntry;
62
- if (!dirExisted && !agentExisted && !docExisted && !hadLockfileEntry) {
63
+ const hadNoOnDiskFootprint = !dirExisted && !agentExisted && !pinnedFileExisted && !isPinnedFileKind(lockEntry?.kind) && hadLockfileEntry;
64
+ if (!dirExisted && !agentExisted && !pinnedFileExisted && !hadLockfileEntry) {
63
65
  console.error(`${key} was not installed -- nothing to remove.`);
64
66
  process.exitCode = 1;
65
67
  return;
@@ -96,8 +98,8 @@ export async function remove(args, scope) {
96
98
  rmSync(dir, { recursive: true, force: true });
97
99
  if (agentExisted)
98
100
  rmSync(agentFile, { force: true });
99
- if (docExisted)
100
- rmSync(docFile, { force: true });
101
+ if (pinnedFileExisted)
102
+ rmSync(pinnedFile, { force: true });
101
103
  // An mcp-kind install has no directory or agent file on disk -- its only
102
104
  // footprint is the lockfile entry (cleared below, once this block has run)
103
105
  // and a live entry in .mcp.json (which add.ts's installMcpEntry merged in,
@@ -2,7 +2,7 @@ import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
2
2
  import { readLockfile } from "../lockfile.js";
3
3
  import { add, fetchVersionMeta, readMcpConfig, updateMcpEntry } from "./add.js";
4
4
  import { UsageError } from "../usage-error.js";
5
- import { assertKind, isCliKind, KINDS, localInstalledKind, strictKind } from "../kinds.js";
5
+ import { assertKind, isCliKind, isPinnedFileKind, KINDS, localInstalledKind, strictKind } from "../kinds.js";
6
6
  const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
7
7
  function toPreview(ownerSlashSkill, currentVersion, meta) {
8
8
  const upToDate = currentVersion === meta.version;
@@ -214,13 +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
+ // A doc or project pin under legacy `ahood skill update` is moved
218
+ // forward through its own scope (ahood-cli#182, #185): legacy add
219
+ // refuses those kinds, but legacy update has always covered every pin.
220
220
  if (kind)
221
221
  await add([ownerSlashSkill], scope);
222
- else if (meta.kind === "doc")
223
- await add([ownerSlashSkill], { noun: "doc", kind: "doc" });
222
+ else if (isPinnedFileKind(meta.kind))
223
+ await add([ownerSlashSkill], { noun: meta.kind, kind: meta.kind });
224
224
  else
225
225
  await add([ownerSlashSkill]);
226
226
  }
package/dist/help.js CHANGED
@@ -121,8 +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. A doc is refused " +
125
- "here; install it with `ahood doc add`.",
124
+ "environment variables its server.json declares that aren't already set in your shell. A doc or project " +
125
+ "guide is refused here; install it with `ahood doc add` or `ahood project add`.",
126
126
  examples: ["ahood skill add alice/pdf-tools", "ahood skill add alice/pdf-tools@1.2.0"],
127
127
  },
128
128
  {
@@ -207,17 +207,17 @@ export const SKILL_COMMANDS_HELP = [
207
207
  examples: ["ahood skill init pdf-tools", "ahood skill init"],
208
208
  },
209
209
  {
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). " +
210
+ usage: "ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp|doc|project] [--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, doc, or project guide from a folder containing SKILL.md, AGENT.md, server.json, DOC.md, or PROJECT.md, creating the skill first if it doesn't already exist.",
212
+ desc: "Publish a new version of a skill, agent, mcp server manifest, doc, or project guide from a folder containing SKILL.md, AGENT.md, server.json, DOC.md, or PROJECT.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, --kind doc, or --kind project if " +
214
+ "publishing an AGENT.md, server.json, DOC.md, or PROJECT.md (auto-detected from the folder contents when only one of SKILL.md/AGENT.md/server.json/DOC.md/PROJECT.md is present). " +
215
215
  "--name is required when creating; optionally also pass --tagline/--tags/--license/--homepage/--repository. " +
216
216
  "Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
217
217
  "The legacy form `ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
218
218
  "Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
219
219
  flags: [
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
+ "--kind <kind> skill, agent, mcp, doc, or project. Auto-detected from the folder's root doc when omitted.",
221
221
  "--name <text> Required only when creating the artifact on this publish.",
222
222
  "--tagline <text> Short one-line description, used only when creating.",
223
223
  "--tags <comma,separated> Initial tags, used only when creating.",
@@ -241,21 +241,16 @@ export const SKILL_COMMANDS_HELP = [
241
241
  // rather than hand-copied twice, so the two lists can't drift from each other.
242
242
  // SKILL_COMMANDS_HELP above stays hand-written: it documents the legacy,
243
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
- };
244
+ // Placeholders and examples come from the kind table (kind-info.ts KINDS).
250
245
  function registryCommandsHelp(kind) {
251
246
  const info = KINDS[kind];
252
- const n = PLACEHOLDER[kind];
247
+ const n = info.placeholder;
253
248
  const p = `ahood ${kind}`;
254
249
  const label = info.label;
255
250
  const a = info.article;
256
251
  const plural = info.plural;
257
252
  const doc = info.rootDoc;
258
- const ex = EXAMPLE[kind].slug;
253
+ const ex = info.example.slug;
259
254
  const kindRefusal = `Refused, before anything else happens, when the target is not ${a} ${label}.`;
260
255
  return [
261
256
  {
@@ -385,12 +380,41 @@ function registryCommandsHelp(kind) {
385
380
  "--changelog <text> What changed in this version.",
386
381
  "--json Print one {version,status,...} JSON object on completion (or {error} on failure).",
387
382
  ],
388
- examples: [`${p} publish ${ex}@1.0.0 --name "${EXAMPLE[kind].name}"`],
383
+ examples: [`${p} publish ${ex}@1.0.0 --name "${info.example.name}"`],
389
384
  },
390
385
  ];
391
386
  }
392
387
  export const AGENT_COMMANDS_HELP = registryCommandsHelp("agent");
393
388
  export const DOC_COMMANDS_HELP = registryCommandsHelp("doc");
389
+ // `ahood project` (ahood-cli#185): the shared verbs, with init's `project`
390
+ // field flags, plus bare `ahood project` -- the guides for this repository.
391
+ export const PROJECT_COMMANDS_HELP = [
392
+ {
393
+ usage: "ahood project [--remote <git-remote>] [--json]",
394
+ summary: "List the project guides you can see for this repository's git remote.",
395
+ desc: "List the project guides you can see that name this repository's git remote (`git remote get-url origin`, " +
396
+ "normalized to host/owner/repo -- any credentials in it are stripped and never sent), your own first. Read-only; " +
397
+ "needs `ahood login`. Asks the hosted get_project_guide tool.",
398
+ flags: [
399
+ "--remote <git-remote> Look up this remote instead of origin's.",
400
+ "--json Emit {project, guides} (each guide with its PROJECT.md content) instead of formatted lines.",
401
+ ],
402
+ examples: ["ahood project", "ahood project --remote github.com/alice/acme-api"],
403
+ },
404
+ ...registryCommandsHelp("project").map((entry) => entry.usage.startsWith("ahood project init")
405
+ ? {
406
+ ...entry,
407
+ usage: "ahood project init [name] [--project <git-remote> | --no-project]",
408
+ desc: entry.desc +
409
+ " The optional `project` field (the repository's git remote, which is how get_project_guide finds the guide) " +
410
+ "is filled from `git remote get-url origin`, normalized to host/owner/repo.",
411
+ flags: [
412
+ "--project <git-remote> Set the `project` field to this remote (normalized).",
413
+ "--no-project Leave the `project` field out.",
414
+ ],
415
+ }
416
+ : entry),
417
+ ];
394
418
  // `ahood mcp` is two things (ADR 0007 in the ahood repo separates the hosted
395
419
  // registry MCP endpoint from this CLI's local one): with no verb, or with
396
420
  // `serve`, it is the local, read-only stdio MCP server that MCP hosts spawn;
@@ -594,6 +618,7 @@ const ENTITY_COMMANDS_HELP = {
594
618
  agent: AGENT_COMMANDS_HELP,
595
619
  mcp: MCP_COMMANDS_HELP,
596
620
  doc: DOC_COMMANDS_HELP,
621
+ project: PROJECT_COMMANDS_HELP,
597
622
  group: GROUP_COMMANDS_HELP,
598
623
  snap: SNAP_COMMANDS_HELP,
599
624
  };
@@ -718,8 +743,8 @@ export function formatSkillHelp() {
718
743
  "Legacy, cross-kind: in this release `ahood skill <verb>` still acts on any registry kind",
719
744
  "(skills, agents, MCP server manifests) -- `skill list`/`skill search` include every kind, and",
720
745
  "`skill add`/`skill update` install whatever kind they resolve. Scripts relying on that keep",
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).",
746
+ "working. For one kind only, use `ahood agent|mcp|doc|project <verb>`, or add",
747
+ "`--kind skill|agent|mcp|doc|project` to any verb below (`--kind all` spells out the legacy behavior).",
723
748
  "A future major version will make `ahood skill` skills-only.",
724
749
  "",
725
750
  "Commands:",
@@ -767,14 +792,24 @@ export function formatDocHelp() {
767
792
  "To look at a doc without installing it, use `ahood doc read`.",
768
793
  ], DOC_COMMANDS_HELP, "doc");
769
794
  }
795
+ export function formatProjectHelp() {
796
+ return formatRegistryKindHelp("ahood project -- project guides (PROJECT.md) in the ahood registry", [
797
+ "A project guide is how to work in one repository: conventions, verification, traps.",
798
+ "Every verb here acts on project guides only: a target of another kind is refused before anything",
799
+ "is downloaded, prompted for, written, or sent. `add` saves the guide to .claude/project.md, one per",
800
+ "repository, and never touches CLAUDE.md or AGENTS.md. Bare `ahood project` lists the guides for",
801
+ "this repository's git remote.",
802
+ ], PROJECT_COMMANDS_HELP, "project");
803
+ }
804
+ const KIND_HELP = {
805
+ skill: formatSkillHelp,
806
+ agent: formatAgentHelp,
807
+ mcp: formatMcpHelp,
808
+ doc: formatDocHelp,
809
+ project: formatProjectHelp,
810
+ };
770
811
  export function formatKindHelp(noun) {
771
- if (noun === "agent")
772
- return formatAgentHelp();
773
- if (noun === "mcp")
774
- return formatMcpHelp();
775
- if (noun === "doc")
776
- return formatDocHelp();
777
- return formatSkillHelp();
812
+ return KIND_HELP[noun]();
778
813
  }
779
814
  // `ahood group --help` -- the group-level listing for every group verb.
780
815
  export function formatGroupHelp() {
@@ -813,6 +848,8 @@ export function formatHelp() {
813
848
  const mcpGroupSummary = "The same verbs, for MCP server manifests only; bare `ahood mcp` or `ahood mcp serve` starts the local MCP server.";
814
849
  const docGroupUsage = "ahood doc <command>";
815
850
  const docGroupSummary = "The same verbs, for docs (DOC.md) only -- run `ahood doc --help` for the full list.";
851
+ const projectGroupUsage = "ahood project <command>";
852
+ const projectGroupSummary = "The same verbs, for project guides (PROJECT.md) only; bare `ahood project` lists this repository's guides.";
816
853
  const groupGroupUsage = "ahood group <command>";
817
854
  const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
818
855
  const snapGroupUsage = "ahood snap <command>";
@@ -823,6 +860,7 @@ export function formatHelp() {
823
860
  { usage: agentGroupUsage, summary: agentGroupSummary },
824
861
  { usage: mcpGroupUsage, summary: mcpGroupSummary },
825
862
  { usage: docGroupUsage, summary: docGroupSummary },
863
+ { usage: projectGroupUsage, summary: projectGroupSummary },
826
864
  { usage: groupGroupUsage, summary: groupGroupSummary },
827
865
  { usage: snapGroupUsage, summary: snapGroupSummary },
828
866
  ]);
@@ -844,7 +882,7 @@ export function formatHelp() {
844
882
  "",
845
883
  "Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
846
884
  "Run `ahood skill --help` for the full list of skill commands.",
847
- "Run `ahood agent --help` / `ahood mcp --help` / `ahood doc --help` for the kind-scoped agent, MCP and doc commands.",
885
+ "Run `ahood agent|mcp|doc|project --help` for the kind-scoped agent, MCP, doc and project guide commands.",
848
886
  "Run `ahood group --help` for the full list of group commands.",
849
887
  "Run `ahood snap --help` for the full list of snap commands.",
850
888
  "Run `ahood --version` to print the installed CLI version.",
@@ -0,0 +1,36 @@
1
+ // One call to a tool on the hosted registry MCP endpoint (/api/mcp), for the
2
+ // reads that have no REST route -- today only get_project_guide
3
+ // (ahood-cli#185). The endpoint is stateless and answers plain JSON
4
+ // (enableJsonResponse), so a bare JSON-RPC `tools/call` through apiJson -- the
5
+ // same auth, error mapping and retry as every REST call -- is the whole
6
+ // client; no session or initialize handshake is needed.
7
+ import { apiJson } from "./http.js";
8
+ import { sanitizeForTerminal } from "./terminal-safe.js";
9
+ function parseJson(text) {
10
+ try {
11
+ return JSON.parse(text);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ export async function callHostedTool(name, args) {
18
+ const response = await apiJson("/api/mcp", {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
21
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
22
+ });
23
+ if (response.error)
24
+ throw new Error(`${name} failed: ${sanitizeForTerminal(response.error.message ?? "unknown error")}`);
25
+ const text = response.result?.content?.find((c) => c.type === "text")?.text;
26
+ if (text === undefined)
27
+ throw new Error(`${name} returned no content.`);
28
+ const payload = parseJson(text);
29
+ if (response.result?.isError) {
30
+ const reason = typeof payload?.error === "string" ? payload.error : text;
31
+ throw new Error(`${name} failed: ${sanitizeForTerminal(reason)}`);
32
+ }
33
+ if (payload === undefined)
34
+ throw new Error(`${name} returned a response that is not JSON.`);
35
+ return payload;
36
+ }
package/dist/index.js CHANGED
@@ -20,7 +20,8 @@ import { versions } from "./commands/versions.js";
20
20
  import { diff } from "./commands/diff.js";
21
21
  import { completion } from "./commands/completion.js";
22
22
  import { mcp } from "./commands/mcp.js";
23
- import { extractKindFlag, resolveScope } from "./kinds.js";
23
+ import { CLI_KINDS, extractKindFlag, isCliKind, resolveScope } from "./kinds.js";
24
+ import { findProjectGuides } from "./commands/project-guides.js";
24
25
  import { readUseme } from "./useme.js";
25
26
  import { UsageError } from "./usage-error.js";
26
27
  import { init } from "./commands/init.js";
@@ -97,6 +98,7 @@ const COMMANDS = {
97
98
  skill: dispatchSkill,
98
99
  agent: dispatchAgent,
99
100
  doc: dispatchDoc,
101
+ project: dispatchProject,
100
102
  group: dispatchGroup,
101
103
  snap: dispatchSnap,
102
104
  };
@@ -108,9 +110,9 @@ const COMMANDS = {
108
110
  // here just means "a command that owns its own sub-verb dispatch", the same
109
111
  // sense "skill" already used before "group" (the entity) existed. "snap"
110
112
  // joins this set for the exact same reason (ahood-cli#107).
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"]);
113
+ // Every registry noun joins it (ahood-cli#172), taken from the kind table so
114
+ // a new kind (doc #182, project #185) needs no edit here.
115
+ const GROUP_COMMANDS = new Set([...CLI_KINDS, "group", "snap"]);
114
116
  function levenshtein(a, b) {
115
117
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
116
118
  for (let i = 0; i <= a.length; i++)
@@ -148,7 +150,7 @@ function closestGroupVerb(input) {
148
150
  function closestSnapVerb(input) {
149
151
  return closestOf(input, Object.keys(SNAP_VERBS));
150
152
  }
151
- // `ahood <noun> <verb> [...]` for every registry noun (skill, agent, mcp, doc) --
153
+ // `ahood <noun> <verb> [...]` for every registry noun (kind-info.ts KINDS) --
152
154
  // owns its own help handling at both the noun level (`ahood agent` /
153
155
  // `ahood agent --help`) and the per-verb level (`ahood agent <verb> --help`),
154
156
  // since neither should fall through to main()'s generic top-level --help
@@ -215,6 +217,18 @@ async function dispatchAgent(args) {
215
217
  async function dispatchDoc(args) {
216
218
  await dispatchRegistry("doc", args);
217
219
  }
220
+ // `ahood project` (ahood-cli#185): with no verb -- nothing, or only its own
221
+ // flags -- it lists the project guides for this repository's git remote
222
+ // (commands/project-guides.ts). Everything else is the shared registry
223
+ // dispatch, help included.
224
+ async function dispatchProject(args) {
225
+ const first = args[0];
226
+ if (first === undefined || (first.startsWith("--") && first !== "--help")) {
227
+ await findProjectGuides(args);
228
+ return;
229
+ }
230
+ await dispatchRegistry("project", args);
231
+ }
218
232
  // `ahood mcp` keeps its original contract byte-for-byte: with NO arguments it
219
233
  // is the local stdio MCP server that existing host configs launch -- nothing
220
234
  // is printed and nothing else runs first. `ahood mcp serve` is the same
@@ -317,7 +331,7 @@ async function main() {
317
331
  process.stdout.write(content);
318
332
  return;
319
333
  }
320
- if (sub === "skill" || sub === "agent" || sub === "mcp" || sub === "doc") {
334
+ if (isCliKind(sub)) {
321
335
  const verb = args[1];
322
336
  const entry = verb ? findCommandHelp(sub, verb) : undefined;
323
337
  console.log(entry ? formatCommandHelp(entry) : formatKindHelp(sub));
@@ -399,4 +413,4 @@ if (isEntrypoint) {
399
413
  main();
400
414
  }
401
415
  // Exported for tests only -- the CLI itself only ever calls main() above.
402
- export { main, dispatchSkill, dispatchAgent, dispatchDoc, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
416
+ export { main, dispatchSkill, dispatchAgent, dispatchDoc, dispatchProject, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
package/dist/kind-info.js CHANGED
@@ -4,13 +4,18 @@
4
4
  //
5
5
  // Kind-aware registry commands (ahood-cli#172).
6
6
  //
7
- // The registry stores four artifact kinds (the backend's
8
- // lib/skills/validation.ts ALLOWED_KINDS: skill, agent, mcp, doc), and a
7
+ // The registry stores five artifact kinds (the backend's
8
+ // lib/skills/validation.ts ALLOWED_KINDS: skill, agent, mcp, doc, project), and a
9
9
  // kind is fixed when an entry is created -- PATCH's allow-list has no `kind`
10
10
  // -- so the kind an entry reports now is the kind it has always had. Each
11
11
  // kind has a noun group (`ahood skill|agent|mcp|doc <verb>`), all run by one
12
12
  // shared dispatcher. `doc` joined as the fourth in ahood-cli#182: it installs
13
13
  // as one flat Markdown file, its DOC.md, the same shape an agent has.
14
+ // `project` joined as the fifth in ahood-cli#185: one PROJECT.md per
15
+ // repository, installed to the fixed path .claude/project.md.
16
+ //
17
+ // Per-kind facts live here, not in per-command branches: `placeholder` and
18
+ // `example` feed help and init, `about` the legacy `skill add` refusal.
14
19
  export const KINDS = {
15
20
  skill: {
16
21
  label: "skill",
@@ -18,6 +23,8 @@ export const KINDS = {
18
23
  plural: "skills",
19
24
  rootDoc: "SKILL.md",
20
25
  installsTo: ".claude/skills/<owner>@<skill>/",
26
+ placeholder: "<skill>",
27
+ example: { slug: "alice/pdf-tools", name: "PDF Tools" },
21
28
  },
22
29
  agent: {
23
30
  label: "agent",
@@ -25,6 +32,8 @@ export const KINDS = {
25
32
  plural: "agents",
26
33
  rootDoc: "AGENT.md",
27
34
  installsTo: ".claude/agents/<owner>@<agent>.md",
35
+ placeholder: "<agent>",
36
+ example: { slug: "alice/code-reviewer", name: "Code Reviewer" },
28
37
  },
29
38
  mcp: {
30
39
  label: "MCP server manifest",
@@ -32,6 +41,8 @@ export const KINDS = {
32
41
  plural: "MCP server manifests",
33
42
  rootDoc: "server.json",
34
43
  installsTo: "an entry in .mcp.json",
44
+ placeholder: "<server>",
45
+ example: { slug: "alice/github-server", name: "GitHub Server" },
35
46
  },
36
47
  doc: {
37
48
  label: "doc",
@@ -39,9 +50,30 @@ export const KINDS = {
39
50
  plural: "docs",
40
51
  rootDoc: "DOC.md",
41
52
  installsTo: ".claude/docs/<owner>@<doc>.md",
53
+ placeholder: "<doc>",
54
+ example: { slug: "alice/setup-guide", name: "Setup Guide" },
55
+ about: "a documentation page",
56
+ },
57
+ project: {
58
+ label: "project guide",
59
+ article: "a",
60
+ plural: "project guides",
61
+ rootDoc: "PROJECT.md",
62
+ installsTo: ".claude/project.md",
63
+ placeholder: "<slug>",
64
+ example: { slug: "alice/acme-api", name: "Acme API" },
65
+ about: "a guide to working in one repository",
42
66
  },
43
67
  };
44
68
  export const CLI_KINDS = Object.keys(KINDS);
45
69
  export function isCliKind(value) {
46
70
  return typeof value === "string" && CLI_KINDS.includes(value);
47
71
  }
72
+ // Kinds that install as one root-doc file pinned with its kind in the
73
+ // lockfile (doc: ahood-cli#182, project: #185). The recorded kind is how
74
+ // remove/update recognise the pin offline, and how a project guide's shared
75
+ // path is claimed by exactly one pin. Legacy `skill add` refuses these.
76
+ export const PINNED_FILE_KINDS = ["doc", "project"];
77
+ export function isPinnedFileKind(value) {
78
+ return typeof value === "string" && PINNED_FILE_KINDS.includes(value);
79
+ }
package/dist/kinds.js CHANGED
@@ -5,8 +5,8 @@ import { existsSync } from "node:fs";
5
5
  import { apiJson } from "./http.js";
6
6
  import { agentPath, skillDir } from "./spec.js";
7
7
  import { UsageError } from "./usage-error.js";
8
- import { KINDS, CLI_KINDS, isCliKind } from "./kind-info.js";
9
- export { KINDS, CLI_KINDS, isCliKind };
8
+ import { KINDS, CLI_KINDS, isCliKind, isPinnedFileKind } from "./kind-info.js";
9
+ export { KINDS, CLI_KINDS, isCliKind, isPinnedFileKind };
10
10
  export const LEGACY_SCOPE = { noun: "skill", kind: "all" };
11
11
  export function strictKind(scope) {
12
12
  return scope && scope.kind !== "all" ? scope.kind : undefined;
@@ -62,9 +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
+ // A doc or project pin records its kind (ahood-cli#182, #185).
66
+ if (isPinnedFileKind(entry?.kind))
67
+ return entry.kind;
68
68
  const hasDir = existsSync(skillDir(owner, skill));
69
69
  const hasAgentFile = existsSync(agentPath(owner, skill));
70
70
  if (hasDir && !hasAgentFile)
@@ -105,8 +105,8 @@ export function extractKindFlag(args) {
105
105
  // Resolves the scope for `ahood <noun> <verb> [--kind <k>]`.
106
106
  // skill noun, no --kind or --kind all -> legacy (cross-kind)
107
107
  // skill noun, --kind <k> -> strict <k>
108
- // agent/mcp/doc noun, no --kind or --kind <same> -> strict <noun>
109
- // agent/mcp/doc noun, any other --kind -> usage error (contradiction)
108
+ // any other noun, no --kind or --kind <same> -> strict <noun>
109
+ // any other noun, any other --kind -> usage error (contradiction)
110
110
  export function resolveScope(noun, kindFlag) {
111
111
  const valid = [...CLI_KINDS, "all"].join(", ");
112
112
  if (kindFlag !== undefined && kindFlag !== "all" && !isCliKind(kindFlag)) {