@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.
package/README.md CHANGED
@@ -189,9 +189,9 @@ 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, MCP servers, and docs
192
+ ## Kinds: skills, agents, MCP servers, docs, and project guides
193
193
 
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:
194
+ The registry holds five kinds of artifact, each with its own command group. All five 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
  | --- | --- | --- | --- |
@@ -199,6 +199,7 @@ The registry holds four kinds of artifact, each with its own command group. All
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
201
  | doc | `DOC.md` | `.claude/docs/<owner>@<doc>.md` | `ahood doc <verb>` |
202
+ | project | `PROJECT.md` | `.claude/project.md` (one per repository) | `ahood project <verb>` |
202
203
 
203
204
  ```
204
205
  $ ahood agent search review
@@ -208,14 +209,18 @@ $ ahood mcp init github-server # ./github-server/server.json, a safe pl
208
209
  $ ahood mcp add alice/github-server
209
210
  $ ahood doc read alice/setup-guide # print a doc without installing it
210
211
  $ ahood doc add alice/setup-guide # ./.claude/docs/alice@setup-guide.md, pinned
212
+ $ ahood project # project guides for this repo's git remote
213
+ $ ahood project add alice/acme-api # ./.claude/project.md, pinned
214
+ $ ahood project init acme-api # ./acme-api/PROJECT.md, `project:` filled from git origin
211
215
  $ ahood agent add alice/pdf-tools
212
216
  alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood skill add alice/pdf-tools` instead.
213
217
  ```
214
218
 
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.
219
+ - **`ahood agent`, `ahood mcp`, `ahood doc` and `ahood project` 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`/`project publish` imply their kind and reject a contradictory `--kind`.
220
+ - **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 or project pin's recorded `kind`) and asks the registry only when those can't tell; if neither can, nothing is removed.
221
+ - **`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 exceptions are a doc and a project guide: `ahood skill add` refuses them and points at `ahood doc add` / `ahood project add`. Add `--kind skill|agent|mcp|doc|project` 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
222
  - **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`).
223
+ - **A project guide is one pinned file per repository.** `ahood project add` saves the guide's `PROJECT.md` to `.claude/project.md` and pins it with `"kind": "project"`. It never touches `CLAUDE.md` or `AGENTS.md` (point those at `.claude/project.md` yourself), refuses to overwrite an unpinned `.claude/project.md`, and refuses a second guide while another is pinned (`ahood project remove` it first). `ahood project init` fills the optional `project` frontmatter field from `git remote get-url origin`, normalized to `host/owner/repo` exactly as the registry does (credentials stripped; `--project <remote>` or `--no-project` to override). Bare `ahood project` lists the guides you can see for this repository's remote, through the hosted `get_project_guide` MCP tool.
219
224
 
220
225
  **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.
221
226
 
@@ -260,7 +265,7 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
260
265
  | `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
261
266
  | `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
262
267
  | `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
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. |
268
+ | `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]` | 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. |
264
269
 
265
270
  ### Agent
266
271
 
@@ -333,6 +338,30 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
333
338
  | `ahood doc init [name]` | Scaffold a new doc folder with a minimal, valid DOC.md. |
334
339
  | `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
340
 
341
+ ### Project
342
+
343
+ | Command | What it does |
344
+ | --- | --- |
345
+ | `ahood project [--remote <git-remote>] [--json]` | List the project guides you can see for this repository's git remote. |
346
+ | `ahood project search <query> [--json] [--limit <n>]` | Search published project guides. |
347
+ | `ahood project view\|show <owner>/<slug> [--json] [--web]` | Show a single project guide's details without installing it (alias: ahood project show). |
348
+ | `ahood project read <owner>/<slug> [--json]` | Print a published project guide's PROJECT.md, without installing it. |
349
+ | `ahood project versions <owner>/<slug> [--json]` | List a project guide's published-version history. |
350
+ | `ahood project diff <owner>/<slug> <versionA> <versionB> [--json]` | Show what changed between two published versions of a project guide. |
351
+ | `ahood project list [--json]` | List your own project guides, public and private. |
352
+ | `ahood project add <owner>/<slug>[@version]` | Install a project guide into .claude/project.md, pinned in the lockfile. |
353
+ | `ahood project update [<owner>/<slug> ...] [--dry-run] [--json]` | Move project guide pins forward to the latest version. |
354
+ | `ahood project outdated [<owner>/<slug> ...] [--json]` | Read-only staleness check for installed project guides. |
355
+ | `ahood project remove <owner>/<slug> [--yes]` | Uninstall and unpin a project guide (local only, prompts unless --yes is passed). |
356
+ | `ahood project edit <owner>/<slug> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a project guide you own, changing only the flags you pass. |
357
+ | `ahood project unpublish <owner>/<slug>[@version] [--yes]` | Delete a project guide from the registry for every consumer, or yank one version (prompts unless --yes is passed). |
358
+ | `ahood project star <owner>/<slug>` | Star a project guide. |
359
+ | `ahood project unstar <owner>/<slug>` | Remove your star from a project guide. |
360
+ | `ahood project share <owner>/<slug> --group <group>` | Share a project guide you own with a group, without changing its visibility. |
361
+ | `ahood project unshare <owner>/<slug> --group <group>` | Stop sharing a project guide you own with a group. |
362
+ | `ahood project init [name] [--project <git-remote> \| --no-project]` | Scaffold a new project guide folder with a minimal, valid PROJECT.md. |
363
+ | `ahood project publish <owner>/<slug>@<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 project guide from a folder containing PROJECT.md, creating it first if it doesn't exist yet. |
364
+
336
365
  ### Group
337
366
 
338
367
  | Command | What it does |
@@ -0,0 +1,59 @@
1
+ import { posix } from "node:path";
2
+ import * as tarStream from "tar-stream";
3
+ import { sanitizeForTerminal } from "./terminal-safe.js";
4
+ // AlexKay28/ahood#792 -- the one archive-entry path rule, mirroring the
5
+ // server's lib/publish/entry-path.ts. `SKILL.md` and `././SKILL.md` used to be
6
+ // two entries to the server (which reads the FIRST) but one file to
7
+ // extractTarGz (resolve() collapsed them and the LAST write won), so a hidden
8
+ // later duplicate could overwrite the reviewed file at install. Every
9
+ // extractor now refuses such an archive up front instead.
10
+ /** True when `path` is already a canonical relative POSIX path: non-empty,
11
+ * no backslash or NUL, not absolute, no empty / "." / ".." segment, and
12
+ * unchanged by posix.normalize. */
13
+ export function isCanonicalEntryPath(path) {
14
+ if (path === "" || path.includes("\\") || path.includes("\0"))
15
+ return false;
16
+ if (posix.isAbsolute(path) || posix.normalize(path) !== path)
17
+ return false;
18
+ return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
19
+ }
20
+ /** A tar header name relative to the archive root. `tar -C dir .` names
21
+ * every entry `./x`, so exactly ONE leading "./" is dropped; whatever
22
+ * remains must already be canonical (`././x` is rejected, not collapsed). */
23
+ export function tarEntryPath(headerName) {
24
+ return headerName.startsWith("./") ? headerName.slice(2) : headerName;
25
+ }
26
+ /** Walks every header of an already-decompressed tar and throws unless each
27
+ * non-directory entry has a canonical, unique path. Runs before an extractor
28
+ * writes or returns anything, so a bad entry anywhere refuses the whole
29
+ * archive. Directory entries (`./`, `./scripts/`) are not files and are
30
+ * skipped. */
31
+ export async function assertCanonicalEntryPaths(tar) {
32
+ const seen = new Set();
33
+ const extract = tarStream.extract();
34
+ await new Promise((resolvePromise, reject) => {
35
+ extract.on("entry", (header, stream, next) => {
36
+ stream.resume();
37
+ if (header.type === "directory") {
38
+ next();
39
+ return;
40
+ }
41
+ const path = tarEntryPath(header.name);
42
+ const problem = !isCanonicalEntryPath(path)
43
+ ? `Refusing to extract unsafe archive entry: ${sanitizeForTerminal(header.name)} (not a canonical relative path)`
44
+ : seen.has(path)
45
+ ? `Refusing to extract archive: it has more than one entry for "${sanitizeForTerminal(path)}"`
46
+ : null;
47
+ if (problem) {
48
+ reject(new Error(problem));
49
+ extract.destroy();
50
+ return;
51
+ }
52
+ seen.add(path);
53
+ next();
54
+ });
55
+ extract.on("finish", () => resolvePromise());
56
+ extract.on("error", reject);
57
+ extract.end(tar);
58
+ });
59
+ }
@@ -4,12 +4,13 @@ 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, docPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
7
+ import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, pinnedFilePath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
8
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
+ import { assertCanonicalEntryPaths, tarEntryPath } from "../archive-entries.js";
11
12
  import { UsageError } from "../usage-error.js";
12
- import { assertKind, strictKind } from "../kinds.js";
13
+ import { assertKind, describeKind, isPinnedFileKind, KINDS, strictKind } from "../kinds.js";
13
14
  const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
14
15
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
15
16
  const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
@@ -92,6 +93,10 @@ export async function extractTarGz(buffer, destDir) {
92
93
  catch (error) {
93
94
  throw new Error(`Archive decompresses to more than ${MAX_EXTRACTED_BYTES / (1024 * 1024)} MB, or is not valid gzip -- refusing to extract.`, { cause: error });
94
95
  }
96
+ // Every entry path is canonical and unique before the first write
97
+ // (AlexKay28/ahood#792): otherwise `././SKILL.md` lands on SKILL.md and the
98
+ // last of the two wins, while the server validated the first.
99
+ await assertCanonicalEntryPaths(decompressed);
95
100
  const extract = tarStream.extract();
96
101
  const resolvedDest = resolve(destDir);
97
102
  let entryCount = 0;
@@ -112,7 +117,7 @@ export async function extractTarGz(buffer, destDir) {
112
117
  // segments (and, on Windows, drive-absolute and backslash-separated
113
118
  // names) so the comparison catches every escape shape, not just a
114
119
  // literal leading "../".
115
- const entryPath = header.name.replace(/^\.\//, "");
120
+ const entryPath = tarEntryPath(header.name);
116
121
  const fullPath = resolve(destDir, entryPath);
117
122
  if (fullPath !== resolvedDest && !fullPath.startsWith(resolvedDest + sep)) {
118
123
  stream.resume();
@@ -198,27 +203,20 @@ export async function extractSingleFileContent(buffer, entryName) {
198
203
  catch (error) {
199
204
  throw new Error(`Archive decompresses to more than ${MAX_EXTRACTED_BYTES / (1024 * 1024)} MB, or is not valid gzip -- refusing to extract.`, { cause: error });
200
205
  }
206
+ // A duplicate or non-canonical entry anywhere refuses the archive, rather
207
+ // than this reader and extractTarGz picking different copies of one file
208
+ // (AlexKay28/ahood#792; this used to take the first, extractTarGz the last).
209
+ await assertCanonicalEntryPaths(decompressed);
201
210
  const extract = tarStream.extract();
202
211
  let found = null;
203
212
  await new Promise((resolvePromise, reject) => {
204
213
  extract.on("entry", (header, stream, next) => {
205
- // Normalize a leading "./" the same way extractTarGz and the server's
206
- // extract-and-checksum.ts normalizeEntryPath do -- a plain `tar czf`
207
- // commonly prefixes entries with "./", which would otherwise pass
208
- // server-side validation/publish but then fail to install here with
209
- // "AGENT.md not found" (ahood-cli final review finding #3).
210
- const normalizedName = header.name.replace(/^\.\//, "");
211
- if (normalizedName !== entryName) {
212
- stream.resume();
213
- next();
214
- return;
215
- }
216
- // Only take the FIRST matching entry, mirroring the server side's
217
- // `files.find(...)` lookup -- otherwise a tar with two entries at the
218
- // same normalized name would resolve to whichever one happens to come
219
- // last here, diverging from what the server validated against
220
- // (ahood-cli final review finding #4).
221
- if (found !== null) {
214
+ // Drop tar's one leading "./" the same way extractTarGz and the server
215
+ // do -- a plain `tar czf` commonly prefixes entries with "./", which
216
+ // would otherwise pass server-side validation/publish but then fail to
217
+ // install here with "AGENT.md not found" (ahood-cli final review
218
+ // finding #3).
219
+ if (header.type === "directory" || tarEntryPath(header.name) !== entryName) {
222
220
  stream.resume();
223
221
  next();
224
222
  return;
@@ -865,26 +863,27 @@ export async function downloadVerifiedArchive(owner, skill, meta) {
865
863
  }
866
864
  return buffer;
867
865
  }
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
866
+ // `ahood doc add` (ahood-cli#182) and `ahood project add` (#185): only the
867
+ // root doc (DOC.md / PROJECT.md) is extracted, written atomically to the
868
+ // kind's pinned file (spec.ts pinnedFilePath), and pinned with its kind so
870
869
  // remove/update can tell the pin apart without asking the registry. If the
871
870
  // pin can't be written, the file goes back to what it was: the previous
872
871
  // version's content on an update, nothing on a fresh install.
873
- async function installDoc(owner, skill, meta, buffer) {
872
+ async function installPinnedFile(kind, owner, skill, meta, buffer) {
874
873
  const key = `${owner}/${skill}`;
875
- const content = await extractSingleFileContent(buffer, "DOC.md");
876
- const destPath = docPath(owner, skill);
874
+ const content = await extractSingleFileContent(buffer, KINDS[kind].rootDoc);
875
+ const destPath = pinnedFilePath(kind, owner, skill);
877
876
  const previous = existsSync(destPath) ? readFileSync(destPath) : undefined;
878
877
  writeFileAtomic(destPath, content);
879
878
  try {
880
879
  writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
881
880
  version: meta.version,
882
881
  checksum_sha256: meta.checksum_sha256,
883
- kind: "doc",
882
+ kind,
884
883
  });
885
884
  }
886
885
  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.`);
886
+ 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 ${kind} remove ${key}\` to clean it up.`);
888
887
  if (error instanceof LockfileChecksumConflictError) {
889
888
  throw new Error(checksumConflictMessage(key, meta, error.existing));
890
889
  }
@@ -892,6 +891,27 @@ async function installDoc(owner, skill, meta, buffer) {
892
891
  }
893
892
  console.log(`Installed ${key}@${meta.version} to ${destPath}`);
894
893
  }
894
+ // Refuses a pinned-file install before the download when its path is not
895
+ // this key's to write: a file no pin claims was not written by ahood, and a
896
+ // path another pin claims (a second project guide) belongs to that pin.
897
+ function assertPinnedFileIsOurs(kind, owner, skill) {
898
+ const key = `${owner}/${skill}`;
899
+ const destPath = pinnedFilePath(kind, owner, skill);
900
+ const holder = Object.entries(readLockfile(LOCKFILE_PATH)).find(([pinKey, pin]) => {
901
+ if (!isPinnedFileKind(pin.kind))
902
+ return false;
903
+ const [pinOwner, pinSlug] = pinKey.split("/");
904
+ return pinnedFilePath(pin.kind, pinOwner, pinSlug) === destPath;
905
+ })?.[0];
906
+ if (holder !== undefined && holder !== key) {
907
+ throw new Error(`${holder} is already installed at ${destPath} -- refusing to replace it with ${key}. ` +
908
+ `Run \`ahood ${kind} remove ${holder}\` first.`);
909
+ }
910
+ if (holder === undefined && existsSync(destPath)) {
911
+ throw new Error(`${destPath} already exists and is not pinned as ${key} in ${LOCKFILE_PATH} -- refusing to overwrite it. ` +
912
+ "Move or delete it first.");
913
+ }
914
+ }
895
915
  export async function add(args, scope) {
896
916
  const spec = args[0];
897
917
  if (!spec)
@@ -903,25 +923,20 @@ export async function add(args, scope) {
903
923
  // refused here, before the download, the lockfile and every write below.
904
924
  // Both metadata routes report the entry's kind, so this costs no request.
905
925
  assertKind(scope, key, meta.kind, "add", "install");
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
908
- // .claude/skills/<owner>@<slug>/ with no SKILL.md -- not a working skill,
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.");
924
- }
926
+ // Legacy cross-kind `ahood skill add` of a doc or project guide stays
927
+ // refused (ahood-cli#175, #185): it used to fall through to the skill path
928
+ // and extract it into .claude/skills/<owner>@<slug>/ with no SKILL.md --
929
+ // not a working skill, with nothing telling the user. These kinds install
930
+ // only through their own scope (`ahood doc add`, `ahood project add`),
931
+ // which writes the one pinned file below. Refused before the download, the
932
+ // lockfile and any write. Entries with NO kind keep the legacy behaviour.
933
+ const pinnedKind = isPinnedFileKind(meta.kind) ? meta.kind : undefined;
934
+ if (pinnedKind !== undefined && strictKind(scope) !== pinnedKind) {
935
+ throw new UsageError(`${key} is ${describeKind(pinnedKind)} (${KINDS[pinnedKind].about}), not a skill. ` +
936
+ `Install it with \`ahood ${pinnedKind} add ${key}\`, or read it without installing with \`ahood ${pinnedKind} read ${key}\`.`);
937
+ }
938
+ if (pinnedKind !== undefined)
939
+ assertPinnedFileIsOurs(pinnedKind, owner, skill);
925
940
  // A lockfile entry records the checksum this exact version was installed
926
941
  // with; if a later fetch of the "same" version disagrees, either the
927
942
  // published artifact changed after being pinned or the registry response
@@ -947,7 +962,7 @@ export async function add(args, scope) {
947
962
  // manifest can never actually land on disk there -- the warning would be
948
963
  // both mislabeled ("skill") and about a risk that can't materialize
949
964
  // (ahood-cli final review finding #5).
950
- if (meta.kind !== "agent" && meta.kind !== "mcp" && meta.kind !== "doc" && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
965
+ if (meta.kind !== "agent" && meta.kind !== "mcp" && pinnedKind === undefined && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
951
966
  console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
952
967
  }
953
968
  const buffer = await downloadVerifiedArchive(owner, skill, meta);
@@ -980,8 +995,8 @@ export async function add(args, scope) {
980
995
  await installMcpEntry(owner, skill, meta, buffer);
981
996
  return;
982
997
  }
983
- if (docInstall) {
984
- await installDoc(owner, skill, meta, buffer);
998
+ if (pinnedKind !== undefined) {
999
+ await installPinnedFile(pinnedKind, owner, skill, meta, buffer);
985
1000
  return;
986
1001
  }
987
1002
  // Flat, owner@skill-joined directory name so Claude Code's own one-level-deep
@@ -1,14 +1,15 @@
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";
1
+ import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, AGENT_COMMANDS_HELP, MCP_COMMANDS_HELP, DOC_COMMANDS_HELP, PROJECT_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; doc -- #182) come first; group
5
+ // them. Registry nouns (skill, agent, mcp -- ahood-cli#172; doc -- #182; project -- #185) 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
11
  ["doc", DOC_COMMANDS_HELP],
12
+ ["project", PROJECT_COMMANDS_HELP],
12
13
  ["group", GROUP_COMMANDS_HELP],
13
14
  ["snap", SNAP_COMMANDS_HELP],
14
15
  ];
@@ -22,10 +23,10 @@ function topLevelNames() {
22
23
  }
23
24
  // Position 2+, once a noun has been typed: that noun's verbs, plus alias
24
25
  // names (e.g. "show" for "view") wherever the aliased verb exists, so
25
- // completion offers every name a user might type. A bare `ahood mcp` entry
26
- // has no verb word and contributes nothing.
26
+ // completion offers every name a user might type. A bare `ahood mcp` or
27
+ // `ahood project [--remote ...]` entry has no verb word and contributes nothing.
27
28
  function verbNames(entries) {
28
- const primary = entries.map((c) => c.usage.split(" ")[2] ?? "");
29
+ const primary = entries.map((c) => c.usage.split(" ")[2] ?? "").filter((word) => /^[a-z]/.test(word));
29
30
  const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => primary.includes(COMMAND_ALIASES[alias]));
30
31
  return unique([...primary, ...aliases]);
31
32
  }
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve, sep } from "node:path";
3
3
  import { UsageError } from "../usage-error.js";
4
4
  import { CLI_KINDS, KINDS, strictKind } from "../kinds.js";
5
+ import { flagValue } from "../flags.js";
6
+ import { detectProjectRemote, normalizeProjectRemote } from "../project-remote.js";
5
7
  const USAGE = "Usage: ahood skill init [name]";
6
8
  // Mirrors the server's slug convention referenced by ahood-cli#60 (and the
7
9
  // same shape as spec.ts's SEGMENT_RE, minus "." and "_" -- skill slugs are
@@ -121,11 +123,28 @@ TODO -- write the documentation here. Plain Markdown; this whole file, frontmatt
121
123
  included, is what \`ahood doc read\` prints and \`ahood doc add\` saves.
122
124
  `;
123
125
  }
126
+ // PROJECT.md, for `ahood project init` (ahood-cli#185). The backend's
127
+ // validator (lib/publish/parse-project-frontmatter.ts) is DOC.md's flat
128
+ // reader plus one optional field, `project`: a git remote, normalized there
129
+ // to host/owner/repo. It is written already normalized, or left out.
130
+ function buildProjectMd(name, project) {
131
+ return `---
132
+ name: ${name}
133
+ description: TODO -- one line saying which repository this guide is for and when an agent should read it.
134
+ ${project ? `project: ${project}\n` : ""}---
135
+
136
+ # ${name}
137
+
138
+ TODO -- how to work in this repository: conventions, how to verify a change, and the
139
+ traps. Plain Markdown; \`ahood project add\` saves this whole file to .claude/project.md.
140
+ `;
141
+ }
124
142
  const TEMPLATES = {
125
143
  skill: buildSkillMd,
126
144
  agent: buildAgentMd,
127
145
  mcp: buildServerJson,
128
146
  doc: buildDocMd,
147
+ project: buildProjectMd,
129
148
  };
130
149
  const NEXT_STEPS = {
131
150
  skill: ["Fill in the description, then flesh out the ## Instructions section."],
@@ -136,8 +155,33 @@ const NEXT_STEPS = {
136
155
  '"identifier": "<npm-package>", "version": "<x.y.z>"}] (plus optional "environment_variables").',
137
156
  ],
138
157
  doc: ["Fill in the description, then write the doc below the frontmatter."],
158
+ project: ["Fill in the description, then write the guide below the frontmatter."],
139
159
  };
140
- const PLACEHOLDERS = { skill: "<skill>", agent: "<agent>", mcp: "<server>", doc: "<doc>" };
160
+ function parseProjectFlags(args) {
161
+ const raw = flagValue(args, "--project");
162
+ const skip = args.includes("--no-project");
163
+ if (skip && raw !== undefined)
164
+ throw new UsageError("Pass --project <remote> or --no-project, not both.");
165
+ const rest = args.filter((a, i) => a !== "--no-project" && a !== "--project" && !a.startsWith("--project=") && args[i - 1] !== "--project");
166
+ if (raw === undefined)
167
+ return { rest, skip };
168
+ const normalized = normalizeProjectRemote(raw);
169
+ if (!normalized.ok)
170
+ throw new UsageError(`--project is ${normalized.reason}.`);
171
+ return { rest, explicit: normalized.value, skip };
172
+ }
173
+ // The field's value: the flags' choice, else this repository's origin. Only
174
+ // the normalized remote is ever printed or written.
175
+ function resolveProjectField(flags) {
176
+ if (flags.skip)
177
+ return undefined;
178
+ if (flags.explicit !== undefined)
179
+ return flags.explicit;
180
+ const detected = detectProjectRemote();
181
+ if (detected)
182
+ console.log(`Filled project: ${detected} (from \`git remote get-url origin\`; pass --no-project to leave it out).`);
183
+ return detected;
184
+ }
141
185
  // Falls back to "my-skill" when the directory name itself isn't usable as a
142
186
  // bare YAML scalar (empty, or starting with a character like "-" or "@" that
143
187
  // would need quoting) -- e.g. running `ahood skill init` with no name directly in
@@ -149,6 +193,9 @@ function skillNameFor(dirPath, fallback = "my-skill") {
149
193
  export async function init(args, scope) {
150
194
  const strict = strictKind(scope);
151
195
  const kind = strict ?? "skill";
196
+ const projectFlags = kind === "project" ? parseProjectFlags(args) : undefined;
197
+ if (projectFlags)
198
+ args = projectFlags.rest;
152
199
  let name = args[0];
153
200
  if (name !== undefined && name.startsWith("-"))
154
201
  throw new UsageError(USAGE);
@@ -197,11 +244,12 @@ export async function init(args, scope) {
197
244
  }
198
245
  }
199
246
  }
247
+ const project = projectFlags ? resolveProjectField(projectFlags) : undefined;
200
248
  mkdirSync(targetDir, { recursive: true });
201
- writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`)));
249
+ writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`), project));
202
250
  console.log(`Created ${docPath}`);
203
251
  for (const line of NEXT_STEPS[kind])
204
252
  console.log(line);
205
- const placeholder = PLACEHOLDERS[kind];
253
+ const placeholder = KINDS[kind].placeholder;
206
254
  console.log(`Run \`ahood ${strict ?? "skill"} publish <owner>/${placeholder}@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
207
255
  }
@@ -0,0 +1,47 @@
1
+ // Bare `ahood project` (ahood-cli#185): the project guides that name this
2
+ // repository's git remote, via the hosted get_project_guide tool. Read-only:
3
+ // nothing is installed or written. The remote is normalized before it is
4
+ // sent or printed, so a token in origin's URL never leaves this machine.
5
+ import { flagValue } from "../flags.js";
6
+ import { callHostedTool } from "../hosted-mcp.js";
7
+ import { detectProjectRemote, normalizeProjectRemote } from "../project-remote.js";
8
+ import { sanitizeForTerminal } from "../terminal-safe.js";
9
+ import { UsageError } from "../usage-error.js";
10
+ const USAGE = "Usage: ahood project [--remote <git-remote>] [--json]";
11
+ function resolveRemote(args) {
12
+ const explicit = flagValue(args, "--remote");
13
+ if (explicit !== undefined) {
14
+ const normalized = normalizeProjectRemote(explicit);
15
+ if (!normalized.ok)
16
+ throw new UsageError(`--remote is ${normalized.reason}.\n${USAGE}`);
17
+ return normalized.value;
18
+ }
19
+ const detected = detectProjectRemote();
20
+ if (!detected) {
21
+ throw new UsageError(`No git remote found: this is not a git repository with an "origin" remote ahood can read. Pass --remote <git-remote>.\n${USAGE}`);
22
+ }
23
+ return detected;
24
+ }
25
+ export async function findProjectGuides(args) {
26
+ const unknown = args.filter((a, i) => a.startsWith("-") && a !== "--json" && a !== "--remote" && !a.startsWith("--remote=") && args[i - 1] !== "--remote");
27
+ if (unknown.length > 0)
28
+ throw new UsageError(`Unknown flag ${unknown[0]}.\n${USAGE}`);
29
+ const remote = resolveRemote(args);
30
+ const result = await callHostedTool("get_project_guide", { remote });
31
+ if (args.includes("--json")) {
32
+ console.log(JSON.stringify(result));
33
+ return;
34
+ }
35
+ if (result.guides.length === 0) {
36
+ console.log(`No project guides found for ${remote}. Write one with \`ahood project init\`.`);
37
+ return;
38
+ }
39
+ console.log(`Project guides for ${remote}:`);
40
+ for (const g of result.guides) {
41
+ const tagline = g.tagline ? ` -- ${sanitizeForTerminal(g.tagline)}` : "";
42
+ const mine = g.mine ? " (yours)" : "";
43
+ console.log(` ${g.owner}/${g.slug}@${g.version} ${sanitizeForTerminal(g.name)}${tagline} [${g.visibility}]${mine}`);
44
+ }
45
+ const first = result.guides[0];
46
+ console.log(`Install one with \`ahood project add ${first.owner}/${first.slug}\` (saved to .claude/project.md).`);
47
+ }