@ahood/cli 0.9.0 → 0.10.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
@@ -28,8 +28,10 @@ Installed alice/pdf-tools@1.4.0 to .claude/skills/alice@pdf-tools
28
28
  - [Why ahood](#why-ahood)
29
29
  - [Configuration](#configuration)
30
30
  - [Usage examples](#usage-examples)
31
+ - [Kinds: skills, agents, and MCP servers](#kinds-skills-agents-and-mcp-servers)
31
32
  - [Commands](#commands)
32
33
  - [Exit codes](#exit-codes)
34
+ - [Compatibility](#compatibility)
33
35
  - [Shell completion](#shell-completion)
34
36
  - [Using ahood from an AI agent](#using-ahood-from-an-ai-agent)
35
37
  - [Development](#development)
@@ -187,6 +189,35 @@ Shared alice/pdf-tools with design-team.
187
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.
188
190
  </details>
189
191
 
192
+ ## Kinds: skills, agents, and MCP servers
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:
195
+
196
+ | Kind | Root file | `add` installs to | Group |
197
+ | --- | --- | --- | --- |
198
+ | skill | `SKILL.md` | `.claude/skills/<owner>@<skill>/` | `ahood skill <verb>` |
199
+ | agent | `AGENT.md` | `.claude/agents/<owner>@<agent>.md` | `ahood agent <verb>` |
200
+ | mcp | `server.json` | an entry merged into `.mcp.json` | `ahood mcp <verb>` |
201
+
202
+ ```
203
+ $ ahood agent search review
204
+ $ ahood agent init code-reviewer # ./code-reviewer/AGENT.md
205
+ $ ahood agent publish alice/code-reviewer@1.0.0 --path code-reviewer --name "Code Reviewer"
206
+ $ ahood mcp init github-server # ./github-server/server.json, a safe placeholder starter
207
+ $ ahood mcp add alice/github-server
208
+ $ ahood agent add alice/pdf-tools
209
+ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood skill add alice/pdf-tools` instead.
210
+ ```
211
+
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.
216
+
217
+ **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
+
219
+ **`ahood mcp` has two meanings.** With no arguments, or as `ahood mcp serve` (the preferred spelling), it starts this CLI's local, read-only MCP server over stdio -- unchanged, so existing MCP host configurations keep working with no edits. With a registry verb it manages MCP server manifests. Anything else (`ahood mcp srve`, `ahood mcp --flag`) fails with usage and a suggestion instead of starting a server. Neither is the hosted registry MCP endpoint at `https://ahood.vercel.app/api/mcp`.
220
+
190
221
  ## Commands
191
222
 
192
223
  <!-- Generated from src/help.ts's COMMANDS_HELP by scripts/sync-readme.mjs --
@@ -203,9 +234,9 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
203
234
  | `ahood whoami [--json]` | Reports whether your stored token still authenticates. |
204
235
  | `ahood token create <name>\|list [--json]\|revoke <id> [--yes]` | Manage personal API tokens. |
205
236
  | `ahood completion <bash\|zsh\|fish>` | Print a shell completion script for the command names. |
206
- | `ahood mcp` | Start an MCP server exposing read-only skill commands as tools over stdio. |
237
+ | `ahood help useme` | Print the bundled ahood SKILL.md for an AI agent -- raw, offline, no login needed. |
207
238
 
208
- ### Skill
239
+ ### Skill (legacy, cross-kind)
209
240
 
210
241
  | Command | What it does |
211
242
  | --- | --- |
@@ -228,6 +259,54 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
228
259
  | `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
229
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. |
230
261
 
262
+ ### Agent
263
+
264
+ | Command | What it does |
265
+ | --- | --- |
266
+ | `ahood agent search <query> [--json] [--limit <n>]` | Search published agents. |
267
+ | `ahood agent view\|show <owner>/<agent> [--json] [--web]` | Show a single agent's details without installing it (alias: ahood agent show). |
268
+ | `ahood agent read <owner>/<agent> [--json]` | Print a published agent's AGENT.md, without installing it. |
269
+ | `ahood agent versions <owner>/<agent> [--json]` | List a agent's published-version history. |
270
+ | `ahood agent diff <owner>/<agent> <versionA> <versionB> [--json]` | Show what changed between two published versions of an agent. |
271
+ | `ahood agent list [--json]` | List your own agents, public and private. |
272
+ | `ahood agent add <owner>/<agent>[@version]` | Install an agent into .claude/agents/<owner>@<agent>.md, pinned in the lockfile. |
273
+ | `ahood agent update [<owner>/<agent> ...] [--dry-run] [--json]` | Move agent pins forward to the latest version. |
274
+ | `ahood agent outdated [<owner>/<agent> ...] [--json]` | Read-only staleness check for installed agents. |
275
+ | `ahood agent remove <owner>/<agent> [--yes]` | Uninstall and unpin an agent (local only, prompts unless --yes is passed). |
276
+ | `ahood agent edit <owner>/<agent> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update an agent you own, changing only the flags you pass. |
277
+ | `ahood agent unpublish <owner>/<agent>[@version] [--yes]` | Delete an agent from the registry for every consumer, or yank one version (prompts unless --yes is passed). |
278
+ | `ahood agent star <owner>/<agent>` | Star an agent. |
279
+ | `ahood agent unstar <owner>/<agent>` | Remove your star from an agent. |
280
+ | `ahood agent share <owner>/<agent> --group <group>` | Share an agent you own with a group, without changing its visibility. |
281
+ | `ahood agent unshare <owner>/<agent> --group <group>` | Stop sharing an agent you own with a group. |
282
+ | `ahood agent init [name]` | Scaffold a new agent folder with a minimal, valid AGENT.md. |
283
+ | `ahood agent publish <owner>/<agent>@<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 agent from a folder containing AGENT.md, creating it first if it doesn't exist yet. |
284
+
285
+ ### MCP (local server and server manifests)
286
+
287
+ | Command | What it does |
288
+ | --- | --- |
289
+ | `ahood mcp serve` | Start the local, read-only ahood MCP server over stdio (preferred spelling). |
290
+ | `ahood mcp` | Same as `ahood mcp serve`, kept byte-for-byte so existing MCP host configs keep working. |
291
+ | `ahood mcp search <query> [--json] [--limit <n>]` | Search published MCP server manifests. |
292
+ | `ahood mcp view\|show <owner>/<server> [--json] [--web]` | Show a single MCP server manifest's details without installing it (alias: ahood mcp show). |
293
+ | `ahood mcp read <owner>/<server> [--json]` | Print a published MCP server manifest's server.json, without installing it. |
294
+ | `ahood mcp versions <owner>/<server> [--json]` | List a MCP server manifest's published-version history. |
295
+ | `ahood mcp diff <owner>/<server> <versionA> <versionB> [--json]` | Show what changed between two published versions of an MCP server manifest. |
296
+ | `ahood mcp list [--json]` | List your own MCP server manifests, public and private. |
297
+ | `ahood mcp add <owner>/<server>[@version]` | Install an MCP server manifest into an entry in .mcp.json, pinned in the lockfile. |
298
+ | `ahood mcp update [<owner>/<server> ...] [--dry-run] [--json]` | Move MCP server manifest pins forward to the latest version. |
299
+ | `ahood mcp outdated [<owner>/<server> ...] [--json]` | Read-only staleness check for installed MCP server manifests. |
300
+ | `ahood mcp remove <owner>/<server> [--yes]` | Uninstall and unpin an MCP server manifest (local only, prompts unless --yes is passed). |
301
+ | `ahood mcp edit <owner>/<server> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update an MCP server manifest you own, changing only the flags you pass. |
302
+ | `ahood mcp unpublish <owner>/<server>[@version] [--yes]` | Delete an MCP server manifest from the registry for every consumer, or yank one version (prompts unless --yes is passed). |
303
+ | `ahood mcp star <owner>/<server>` | Star an MCP server manifest. |
304
+ | `ahood mcp unstar <owner>/<server>` | Remove your star from an MCP server manifest. |
305
+ | `ahood mcp share <owner>/<server> --group <group>` | Share an MCP server manifest you own with a group, without changing its visibility. |
306
+ | `ahood mcp unshare <owner>/<server> --group <group>` | Stop sharing an MCP server manifest you own with a group. |
307
+ | `ahood mcp init [name]` | Scaffold a new MCP server manifest folder with a minimal, valid server.json. |
308
+ | `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
+
231
310
  ### Group
232
311
 
233
312
  | Command | What it does |
@@ -255,7 +334,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
255
334
  | `ahood snap tags <id> [tag ...] [--clear] [--json]` | Print a snap's tags, or replace them with the tags given. |
256
335
  <!-- COMMANDS_TABLE_END -->
257
336
 
258
- Run `ahood --help`, `ahood skill --help`, `ahood group --help`, `ahood snap --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help skill <verb>` / `ahood help group <verb>` / `ahood help snap <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
337
+ Run `ahood --help`, `ahood skill --help`, `ahood agent --help`, `ahood mcp --help`, `ahood group --help`, `ahood snap --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help <skill|agent|mcp|group|snap> <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
259
338
 
260
339
  ## Exit codes
261
340
 
@@ -270,6 +349,12 @@ Stable across releases, safe to branch on in a script:
270
349
  | `5` | Not found |
271
350
  | `6` | Network/transport error, or an upstream server (5xx) error |
272
351
 
352
+ ## Compatibility
353
+
354
+ Surfaces that are stable across releases on purpose, because something outside this codebase depends on their exact shape:
355
+
356
+ - **`User-Agent` header.** Every request sends `User-Agent: @ahood/cli/<version>` (e.g. `@ahood/cli/0.9.0`), built in `src/http.ts` from this package's own `name` and `version` in `package.json`. This is deliberately a plain, stable, parseable string rather than an opaque one, because it is exactly what a WAF/firewall allowlist rule needs to scope a rate limit or block rule to browser page traffic without also catching the CLI. **Changing this header's shape (its format, delimiter, or what it's derived from) is a breaking change** for anyone who has written a rule against it, even though nothing in this CLI's own test suite would fail — treat it with the same care as the [exit codes](#exit-codes) below. If it ever needs to carry more than name/version (for example OS or Node version, to make a spoofed string easier to tell apart from a real one), that's a deliberate compatibility change too and belongs in this same section, documented to match exactly what the code sends.
357
+
273
358
  ## Shell completion
274
359
 
275
360
  ```
@@ -290,8 +375,9 @@ ahood completion fish > ~/.config/fish/completions/ahood.fish
290
375
  - **No interactive login required.** Set `AHOOD_TOKEN` once (a personal API token from `ahood token create <name>`) and every command works non-interactively.
291
376
  - **Structured output everywhere.** `--json` is available on every read command and on `publish`/`update`, so an agent never has to scrape human-formatted text.
292
377
  - **Predictable failure.** A stable, documented [exit code](#exit-codes) per failure class, and error messages that never leak raw upstream infrastructure details — safe to surface directly to an agent's reasoning loop.
293
- - **A real "start here."** `ahood skill init <name>` scaffolds a valid `SKILL.md` an agent can extend, rather than requiring it to know the frontmatter format up front.
294
- - **A native MCP server, for agents that prefer tool calls to subprocess parsing.** `ahood mcp` starts a Model Context Protocol server over stdio, exposing `skill_search`, `skill_view`, `skill_read`, `skill_versions`, `skill_list`, and `whoami` as typed tools -- the same data the `--json` flags above already return, reachable as structured tool calls instead. Configure your MCP-aware agent host to run `ahood mcp` (or `npx @ahood/cli@latest mcp`) as a stdio server.
378
+ - **A built-in guide for the agent itself.** `ahood help useme` prints a complete `SKILL.md` teaching an agent when and how to use ahood -- kinds, search -> read -> add -> update, publishing, `--json`, exit codes, `AHOOD_TOKEN`, the MCP options, and which commands are destructive. It is bundled with the installed CLI version, so it needs no login, no network, and writes nothing: stdout is the raw file and nothing else (diagnostics go to stderr), ready to paste into an agent's context or for an agent with a shell to read directly (`ahood help useme > ahood-SKILL.md` saves it). That is separate from `ahood skill add alexkay/ahood`, which optionally installs the registry-published ahood skill into a project as its own pinned skill.
379
+ - **A real "start here."** `ahood skill init <name>`, `ahood agent init <name>`, and `ahood mcp init <name>` scaffold a `SKILL.md`, `AGENT.md`, or `server.json` that passes the registry's publish validation, rather than requiring an agent to know each format up front.
380
+ - **A native MCP server, for agents that prefer tool calls to subprocess parsing.** `ahood mcp serve` (or bare `ahood mcp`, unchanged for existing configs) starts a Model Context Protocol server over stdio, exposing `skill_search`, `skill_view`, `skill_read`, `skill_versions`, `skill_list`, `skill_outdated`, and `whoami` as typed, read-only tools -- the same data the `--json` flags above already return, reachable as structured tool calls instead. Configure your MCP-aware agent host to run `ahood mcp serve` (or `npx @ahood/cli@latest mcp serve`) as a stdio server, e.g. `claude mcp add ahood -- ahood mcp serve`.
295
381
 
296
382
  ## Development
297
383
 
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, renameSync, rmSync, rmdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
2
+ import { mkdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import * as tarStream from "tar-stream";
5
5
  import { gunzipSync } from "node:zlib";
@@ -9,6 +9,7 @@ import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyin
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
13
  const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
13
14
  const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
14
15
  const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
@@ -515,19 +516,51 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
515
516
  const serverConfig = buildMcpServerConfig(manifest, env);
516
517
  return { manifest, serverConfig, secretNames };
517
518
  }
519
+ // Whether the config that was (or is about to be) written to disk actually
520
+ // carries a credential: `env` also holds non-secret configuration now
521
+ // (ahood-cli#120), so only names the manifest declared as secrets count.
522
+ // Load-bearing in two places since ahood-cli#158: the install/update paths
523
+ // consult it to request 0600 for a .mcp.json they are about to CREATE, and
524
+ // warnIfSecretsWereWritten consults it to decide whether anything secret-
525
+ // sensitive was written at all.
526
+ function secretsLanded(serverConfig, secretNames) {
527
+ const env = serverConfig.env;
528
+ if (!env)
529
+ return false;
530
+ return secretNames.some((name) => Object.prototype.hasOwnProperty.call(env, name));
531
+ }
518
532
  // The plaintext-secret warning printed after a successful mcp install/update.
519
533
  // Still derived from the config that actually landed on disk -- the property
520
534
  // the old bare `serverConfig.env` check was chosen for -- but intersected with
521
535
  // the manifest's declared secrets, because `env` now also holds non-secret
522
536
  // configuration (ahood-cli#120) and would otherwise warn about credentials
523
537
  // for a server that declares none.
538
+ //
539
+ // ahood-cli#158 composes a second piece onto it. A brand-new .mcp.json is
540
+ // created 0600 when a secret landed (see installMcpEntry/updateMcpEntry), but
541
+ // an EXISTING file keeps its pre-existing mode (#119) -- deliberately, since
542
+ // tightening it could break another tool or user that reads the shared config.
543
+ // So when the credential landed in a file that is still group/world-readable,
544
+ // the warning says so and suggests chmod 600, leaving the decision with the
545
+ // user who knows their environment, instead of the CLI chmod-ing behind their
546
+ // back.
524
547
  function warnIfSecretsWereWritten(serverConfig, secretNames) {
525
- const env = serverConfig.env;
526
- if (!env)
527
- return;
528
- if (!secretNames.some((name) => Object.prototype.hasOwnProperty.call(env, name)))
548
+ if (!secretsLanded(serverConfig, secretNames))
529
549
  return;
530
- console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
550
+ let warning = `WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`;
551
+ // statSync rather than lstat on purpose: a symlinked .mcp.json is written
552
+ // THROUGH (#144), so the mode that matters is the real file's. Best-effort:
553
+ // the write has already succeeded, so a stat that somehow fails must not
554
+ // surface as a second error -- the plaintext warning above still stands.
555
+ try {
556
+ if (statSync(MCP_CONFIG_PATH).mode & 0o077) {
557
+ warning += ` It is also readable by other users on this machine -- consider \`chmod 600 ${MCP_CONFIG_PATH}\`.`;
558
+ }
559
+ }
560
+ catch {
561
+ // advisory only, see comment above
562
+ }
563
+ console.warn(warning);
531
564
  }
532
565
  // Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
533
566
  // entry, stored in the lockfile alongside the version/checksum pin. Lets
@@ -622,6 +655,11 @@ async function installMcpEntry(owner, skill, meta, buffer) {
622
655
  // collision check specifically rather than the manifest-shape check.
623
656
  assertNoCollision(readMcpConfig(), owner, skill);
624
657
  const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer);
658
+ // Whether a credential landed decides two things now (ahood-cli#158): the
659
+ // mode a brand-new .mcp.json is created with below, and the warning after.
660
+ // 0600 applies to creation only -- an existing file keeps its pre-existing
661
+ // mode (#119) and gets the readability advisory in the warning instead.
662
+ const wroteSecrets = secretsLanded(serverConfig, secretNames);
625
663
  // Read-modify-write under an advisory lock, mirroring lockfile.ts's own
626
664
  // writeLockfileEntry: .mcp.json sits right next to the lockfile and can
627
665
  // hold other servers' secrets, so it gets the same protection against a
@@ -635,7 +673,7 @@ async function installMcpEntry(owner, skill, meta, buffer) {
635
673
  const fileContents = readMcpConfig();
636
674
  assertNoCollision(fileContents, owner, skill);
637
675
  fileContents.mcpServers[skill] = serverConfig;
638
- writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
676
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents, wroteSecrets ? 0o600 : undefined);
639
677
  });
640
678
  const mcpKey = `${owner}/${skill}`;
641
679
  try {
@@ -676,7 +714,7 @@ async function installMcpEntry(owner, skill, meta, buffer) {
676
714
  withLock(MCP_CONFIG_PATH, () => {
677
715
  const fileContents = readMcpConfig();
678
716
  delete fileContents.mcpServers[skill];
679
- writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
717
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents, wroteSecrets ? 0o600 : undefined);
680
718
  });
681
719
  }, `WARNING: could not roll back ${MCP_CONFIG_PATH} after ${mcpKey}'s install failed -- its mcpServers."${skill}" entry (which may contain a secret you entered) may still be present while ${LOCKFILE_PATH} has no pin for it. Delete that entry by hand before re-running \`ahood skill add ${mcpKey}\`.`);
682
720
  if (error instanceof LockfileChecksumConflictError) {
@@ -734,6 +772,11 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
734
772
  // prompt is correct.
735
773
  const existingEnv = readMcpEntryEnv(existingOnDisk);
736
774
  const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
775
+ // Same dual use as installMcpEntry's (ahood-cli#158): the self-heal case can
776
+ // CREATE a .mcp.json that just resolved a secret (the carried-forward env is
777
+ // gone with a hand-deleted entry, so a genuine resolution just happened),
778
+ // and the warning needs the same fact afterward.
779
+ const wroteSecrets = secretsLanded(serverConfig, secretNames);
737
780
  // Read-modify-write under an advisory lock, mirroring installMcpEntry's
738
781
  // own pattern. Re-checks the fingerprint here too (not just above) since
739
782
  // arbitrary time may have passed since the pre-check -- the download, and
@@ -749,7 +792,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
749
792
  }
750
793
  previousOnDisk = freshExisting;
751
794
  freshServers[skill] = serverConfig;
752
- writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
795
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fresh, wroteSecrets ? 0o600 : undefined);
753
796
  });
754
797
  try {
755
798
  writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
@@ -787,7 +830,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
787
830
  else {
788
831
  mcpServers[skill] = previousOnDisk;
789
832
  }
790
- writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
833
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents, wroteSecrets ? 0o600 : undefined);
791
834
  });
792
835
  }, `WARNING: could not roll back ${MCP_CONFIG_PATH} after ${key}'s update failed -- its mcpServers."${skill}" entry may be on the new version while ${LOCKFILE_PATH} still pins the old one. Delete that entry by hand and run \`ahood skill add ${key}\` to reinstall.`);
793
836
  if (error instanceof LockfileChecksumConflictError) {
@@ -822,13 +865,28 @@ export async function downloadVerifiedArchive(owner, skill, meta) {
822
865
  }
823
866
  return buffer;
824
867
  }
825
- export async function add(args) {
868
+ export async function add(args, scope) {
826
869
  const spec = args[0];
827
870
  if (!spec)
828
871
  throw new UsageError(USAGE);
829
872
  const { owner, skill, version: requestedVersion } = parseOwnerSkillVersion(spec, USAGE);
830
873
  const key = `${owner}/${skill}`;
831
874
  const meta = await fetchVersionMeta(owner, skill, requestedVersion);
875
+ // Kind-scoped install (`ahood agent add`, `ahood mcp add`, ahood-cli#172):
876
+ // refused here, before the download, the lockfile and every write below.
877
+ // Both metadata routes report the entry's kind, so this costs no request.
878
+ 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
882
+ // .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\`.`);
889
+ }
832
890
  // A lockfile entry records the checksum this exact version was installed
833
891
  // with; if a later fetch of the "same" version disagrees, either the
834
892
  // published artifact changed after being pinned or the registry response
@@ -1,57 +1,81 @@
1
- import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, COMMAND_ALIASES } from "../help.js";
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";
2
2
  import { UsageError } from "../usage-error.js";
3
3
  const USAGE = "Usage: ahood completion <bash|zsh|fish>";
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
6
+ // and snap were already real nouns but had no completion at all before.
7
+ const NOUNS = [
8
+ ["skill", SKILL_COMMANDS_HELP],
9
+ ["agent", AGENT_COMMANDS_HELP],
10
+ ["mcp", MCP_COMMANDS_HELP],
11
+ ["group", GROUP_COMMANDS_HELP],
12
+ ["snap", SNAP_COMMANDS_HELP],
13
+ ];
14
+ function unique(names) {
15
+ return [...new Set(names.filter(Boolean))];
16
+ }
4
17
  // Position 1 (the word right after "ahood"): every top-level command plus
5
- // the "skill" entity group itself.
18
+ // every entity noun.
6
19
  function topLevelNames() {
7
- return [...TOP_LEVEL_COMMANDS_HELP.map((c) => c.usage.split(" ")[1]), "skill"];
20
+ return unique([...TOP_LEVEL_COMMANDS_HELP.map((c) => c.usage.split(" ")[1]), ...NOUNS.map(([noun]) => noun)]);
21
+ }
22
+ // Position 2+, once a noun has been typed: that noun's verbs, plus alias
23
+ // names (e.g. "show" for "view") wherever the aliased verb exists, so
24
+ // completion offers every name a user might type. A bare `ahood mcp` entry
25
+ // has no verb word and contributes nothing.
26
+ function verbNames(entries) {
27
+ const primary = entries.map((c) => c.usage.split(" ")[2] ?? "");
28
+ const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => primary.includes(COMMAND_ALIASES[alias]));
29
+ return unique([...primary, ...aliases]);
8
30
  }
9
- // Position 2+, once "skill" has been typed: every skill verb, plus alias
10
- // names (e.g. "show" for "view") so completion offers every name a user
11
- // might type, not just the ones with their own SKILL_COMMANDS_HELP entry.
12
- function skillNames() {
13
- const primary = SKILL_COMMANDS_HELP.map((c) => c.usage.split(" ")[2]);
14
- return [...primary, ...Object.keys(COMMAND_ALIASES)];
31
+ // After `ahood help`: the self-skill guide plus every noun.
32
+ function helpNames() {
33
+ return ["useme", ...NOUNS.map(([noun]) => noun)];
15
34
  }
16
35
  function bashCompletion() {
17
- const top = topLevelNames().join(" ");
18
- const skill = skillNames().join(" ");
19
- return [
36
+ const lines = [
20
37
  "_ahood_completions() {",
21
38
  ' local cur="${COMP_WORDS[COMP_CWORD]}"',
22
- ` local top_words="${top}"`,
23
- ` local skill_words="${skill}"`,
24
- ' if [[ "${COMP_WORDS[1]}" == "skill" && $COMP_CWORD -ge 2 ]]; then',
25
- ' COMPREPLY=($(compgen -W "$skill_words" -- "$cur"))',
26
- " else",
27
- ' COMPREPLY=($(compgen -W "$top_words" -- "$cur"))',
28
- " fi",
29
- "}",
30
- "complete -F _ahood_completions ahood",
31
- ].join("\n");
39
+ ` local top_words="${topLevelNames().join(" ")}"`,
40
+ ...NOUNS.map(([noun, entries]) => ` local ${noun}_words="${verbNames(entries).join(" ")}"`),
41
+ ` local help_words="${helpNames().join(" ")}"`,
42
+ ];
43
+ NOUNS.forEach(([noun], i) => {
44
+ lines.push(` ${i === 0 ? "if" : "elif"} [[ "\${COMP_WORDS[1]}" == "${noun}" && $COMP_CWORD -ge 2 ]]; then`);
45
+ lines.push(` COMPREPLY=($(compgen -W "$${noun}_words" -- "$cur"))`);
46
+ });
47
+ lines.push(' elif [[ "${COMP_WORDS[1]}" == "help" && $COMP_CWORD -eq 2 ]]; then');
48
+ lines.push(' COMPREPLY=($(compgen -W "$help_words" -- "$cur"))');
49
+ lines.push(" else");
50
+ lines.push(' COMPREPLY=($(compgen -W "$top_words" -- "$cur"))');
51
+ lines.push(" fi");
52
+ lines.push("}");
53
+ lines.push("complete -F _ahood_completions ahood");
54
+ return lines.join("\n");
32
55
  }
33
56
  function zshCompletion() {
34
- const top = topLevelNames().join(" ");
35
- const skill = skillNames().join(" ");
36
- return [
57
+ const lines = [
37
58
  "#compdef ahood",
38
- "local -a top_cmds skill_cmds",
39
- `top_cmds=(${top})`,
40
- `skill_cmds=(${skill})`,
59
+ `local -a top_cmds ${NOUNS.map(([noun]) => `${noun}_cmds`).join(" ")} help_cmds`,
60
+ `top_cmds=(${topLevelNames().join(" ")})`,
61
+ ...NOUNS.map(([noun, entries]) => `${noun}_cmds=(${verbNames(entries).join(" ")})`),
62
+ `help_cmds=(${helpNames().join(" ")})`,
41
63
  "case $words[2] in",
42
- " skill)",
43
- ' _describe "skill command" skill_cmds',
44
- " ;;",
45
- " *)",
46
- ' _describe "command" top_cmds',
47
- " ;;",
48
- "esac",
49
- ].join("\n");
64
+ ];
65
+ for (const [noun] of NOUNS) {
66
+ lines.push(` ${noun})`);
67
+ lines.push(` _describe "${noun} command" ${noun}_cmds`);
68
+ lines.push(" ;;");
69
+ }
70
+ lines.push(" help)", ' _describe "help topic" help_cmds', " ;;");
71
+ lines.push(" *)", ' _describe "command" top_cmds', " ;;", "esac");
72
+ return lines.join("\n");
50
73
  }
51
74
  function fishCompletion() {
52
75
  return [
53
76
  ...topLevelNames().map((name) => `complete -c ahood -n "__fish_use_subcommand" -a "${name}"`),
54
- ...skillNames().map((name) => `complete -c ahood -n "__fish_seen_subcommand_from skill" -a "${name}"`),
77
+ ...NOUNS.flatMap(([noun, entries]) => verbNames(entries).map((name) => `complete -c ahood -n "__fish_seen_subcommand_from ${noun}" -a "${name}"`)),
78
+ ...helpNames().map((name) => `complete -c ahood -n "__fish_seen_subcommand_from help" -a "${name}"`),
55
79
  ].join("\n");
56
80
  }
57
81
  // Position-aware command-name completion (not per-command flags) -- still
@@ -2,6 +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
6
  const USAGE = "Usage: ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]";
6
7
  // Both versions must be explicit semver here, unlike `add`'s
7
8
  // parseOwnerSkillVersion which lets "latest" default the version -- a diff
@@ -45,7 +46,7 @@ function diffManifests(manifestA, manifestB, skillMdChanged) {
45
46
  }
46
47
  return { added, removed, changed };
47
48
  }
48
- export async function diff(args) {
49
+ export async function diff(args, scope) {
49
50
  const jsonOutput = args.includes("--json");
50
51
  const positional = args.filter((a) => !a.startsWith("--"));
51
52
  const [spec, versionA, versionB] = positional;
@@ -58,6 +59,8 @@ export async function diff(args) {
58
59
  fetchVersionMeta(owner, skill, versionA),
59
60
  fetchVersionMeta(owner, skill, versionB),
60
61
  ]);
62
+ // Both version responses carry the entry's kind; checked before either archive is downloaded.
63
+ assertKind(scope, `${owner}/${skill}`, metaA.kind, "diff", "diff");
61
64
  const [contentA, contentB] = await Promise.all([
62
65
  fetchSkillMdContent(owner, skill, metaA),
63
66
  fetchSkillMdContent(owner, skill, metaB),
@@ -2,13 +2,14 @@ import { apiJson } from "../http.js";
2
2
  import { flagValue } from "../flags.js";
3
3
  import { parseOwnerSkill, validateExternalUrl } from "../spec.js";
4
4
  import { UsageError } from "../usage-error.js";
5
+ import { ensureRemoteKind } from "../kinds.js";
5
6
  const USAGE = "Usage: ahood skill edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
6
7
  // Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
7
8
  // (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
8
9
  // explicitly passed a flag for is included in the body, so an omitted flag
9
10
  // never clobbers an existing value (the route only validates/writes keys
10
11
  // that are present on the body at all).
11
- export async function edit(args) {
12
+ export async function edit(args, scope) {
12
13
  const spec = args[0];
13
14
  if (!spec || spec.startsWith("--"))
14
15
  throw new UsageError(USAGE);
@@ -43,6 +44,7 @@ export async function edit(args) {
43
44
  if (Object.keys(body).length === 0) {
44
45
  throw new UsageError("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
45
46
  }
47
+ await ensureRemoteKind(scope, owner, skill, "edit", "edit");
46
48
  const updated = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, {
47
49
  method: "PATCH",
48
50
  headers: { "Content-Type": "application/json" },
@@ -1,6 +1,7 @@
1
1
  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
+ import { CLI_KINDS, KINDS, strictKind } from "../kinds.js";
4
5
  const USAGE = "Usage: ahood skill init [name]";
5
6
  // Mirrors the server's slug convention referenced by ahood-cli#60 (and the
6
7
  // same shape as spec.ts's SEGMENT_RE, minus "." and "_" -- skill slugs are
@@ -56,15 +57,76 @@ description: TODO -- describe what this skill does and when Claude should use it
56
57
  TODO: describe, step by step, what Claude should do when this skill is invoked.
57
58
  `;
58
59
  }
60
+ // AGENT.md, for `ahood agent init` (ahood-cli#172). The backend's publish
61
+ // validator (lib/publish/parse-agent-frontmatter.ts in the ahood repo) is a
62
+ // hand-rolled flat `key: value` reader, not YAML: it requires the file to
63
+ // START with a `---` line, a closing `---` line followed by a newline, and
64
+ // non-empty `name` and `description`; an optional `skills: [owner/slug, ...]`
65
+ // flow list must hold owner/slug entries. Claude Code's own subagent loader
66
+ // reads the same frontmatter (name, description, and optionally tools and
67
+ // model). So: no leading comment above the opening `---` (that alone fails
68
+ // publish), and the optional keys are left as commented-out examples, with
69
+ // no colon in any prose comment, so neither reader can mistake a comment for
70
+ // a key.
71
+ function buildAgentMd(name) {
72
+ return `---
73
+ name: ${name}
74
+ description: TODO -- describe what this agent does and when Claude should delegate to it.
75
+ # Optional. Uncomment to restrict the tools this agent may use (omit to inherit all).
76
+ # tools: Read, Grep, Glob
77
+ # Optional. Uncomment to pin a model for this agent.
78
+ # model: sonnet
79
+ ---
80
+
81
+ TODO -- write the system prompt for this agent. Say what it is responsible for,
82
+ the steps it should take, and what it should hand back when it is done.
83
+ `;
84
+ }
85
+ // server.json, for `ahood mcp init` (ahood-cli#172). Mirrors exactly what the
86
+ // backend's publish validator (lib/publish/parse-server-manifest.ts in the
87
+ // ahood repo) accepts: a JSON object with non-empty `name` and `description`
88
+ // and exactly one of `packages` (one npm/npx entry) or `remotes` (one http(s)
89
+ // url). The starter uses `remotes`, pointed at a reserved example.com host
90
+ // (RFC 2606), deliberately: a `packages` placeholder would name an npm
91
+ // package that `ahood mcp add` turns into `npx -y <identifier>@<version>` --
92
+ // an install of whatever someone later publishes under that name if the
93
+ // starter were ever published unedited. A reserved host can never resolve to
94
+ // anyone's server. JSON has no comments, so the editing guidance is printed
95
+ // by init instead.
96
+ function buildServerJson(name) {
97
+ const manifest = {
98
+ name,
99
+ description: "TODO -- describe what this MCP server does and when an agent should use it.",
100
+ remotes: [{ url: "https://mcp.example.com/mcp" }],
101
+ };
102
+ return `${JSON.stringify(manifest, null, 2)}
103
+ `;
104
+ }
105
+ const TEMPLATES = {
106
+ skill: buildSkillMd,
107
+ agent: buildAgentMd,
108
+ mcp: buildServerJson,
109
+ };
110
+ const NEXT_STEPS = {
111
+ skill: ["Fill in the description, then flesh out the ## Instructions section."],
112
+ agent: ["Fill in the description, then write the agent's system prompt below the frontmatter."],
113
+ mcp: [
114
+ "Fill in the description and point remotes[0].url at your server's https endpoint, or replace",
115
+ '"remotes" with one npm package: "packages": [{"registry_type": "npm", "runtime_hint": "npx",',
116
+ '"identifier": "<npm-package>", "version": "<x.y.z>"}] (plus optional "environment_variables").',
117
+ ],
118
+ };
59
119
  // Falls back to "my-skill" when the directory name itself isn't usable as a
60
120
  // bare YAML scalar (empty, or starting with a character like "-" or "@" that
61
121
  // would need quoting) -- e.g. running `ahood skill init` with no name directly in
62
122
  // "/" (basename "") or in a directory whose name starts with punctuation.
63
- function skillNameFor(dirPath) {
123
+ function skillNameFor(dirPath, fallback = "my-skill") {
64
124
  const base = basename(dirPath);
65
- return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base) ? base : "my-skill";
125
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base) ? base : fallback;
66
126
  }
67
- export async function init(args) {
127
+ export async function init(args, scope) {
128
+ const strict = strictKind(scope);
129
+ const kind = strict ?? "skill";
68
130
  let name = args[0];
69
131
  if (name !== undefined && name.startsWith("-"))
70
132
  throw new UsageError(USAGE);
@@ -93,13 +155,31 @@ export async function init(args) {
93
155
  }
94
156
  }
95
157
  const targetDir = name ? resolve(name) : process.cwd();
96
- const skillMdPath = join(targetDir, "SKILL.md");
97
- if (existsSync(skillMdPath)) {
98
- throw new Error(`SKILL.md already exists at ${skillMdPath} -- refusing to overwrite it.`);
158
+ const rootDoc = KINDS[kind].rootDoc;
159
+ const docPath = join(targetDir, rootDoc);
160
+ if (existsSync(docPath)) {
161
+ throw new Error(`${rootDoc} already exists at ${docPath} -- refusing to overwrite it.`);
162
+ }
163
+ // A kind-scoped init also refuses a folder that already holds ANOTHER
164
+ // kind's root document: one folder is one artifact, and a second root doc
165
+ // makes a plain `ahood skill publish` of that folder ambiguous. The legacy
166
+ // `ahood skill init` keeps its exact pre-#172 behavior.
167
+ if (strict !== undefined) {
168
+ for (const other of CLI_KINDS) {
169
+ if (other === kind)
170
+ continue;
171
+ const otherPath = join(targetDir, KINDS[other].rootDoc);
172
+ if (existsSync(otherPath)) {
173
+ throw new Error(`${otherPath} already exists -- that folder is already ${KINDS[other].article} ${KINDS[other].label}. ` +
174
+ `Refusing to add ${rootDoc} next to it; pick another name.`);
175
+ }
176
+ }
99
177
  }
100
178
  mkdirSync(targetDir, { recursive: true });
101
- writeFileSync(skillMdPath, buildSkillMd(name ?? skillNameFor(targetDir)));
102
- console.log(`Created ${skillMdPath}`);
103
- console.log("Fill in the description, then flesh out the ## Instructions section.");
104
- console.log(`Run \`ahood skill publish <owner>/<skill>@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
179
+ writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`)));
180
+ console.log(`Created ${docPath}`);
181
+ for (const line of NEXT_STEPS[kind])
182
+ console.log(line);
183
+ const placeholder = kind === "skill" ? "<skill>" : kind === "agent" ? "<agent>" : "<server>";
184
+ console.log(`Run \`ahood ${strict ?? "skill"} publish <owner>/${placeholder}@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
105
185
  }