@ahood/cli 0.8.8 → 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 +92 -6
- package/dist/commands/add.js +147 -54
- package/dist/commands/completion.js +61 -37
- package/dist/commands/diff.js +4 -1
- package/dist/commands/edit.js +3 -1
- package/dist/commands/init.js +90 -10
- package/dist/commands/list.js +11 -5
- package/dist/commands/login.js +63 -3
- package/dist/commands/mcp.js +6 -3
- package/dist/commands/publish.js +35 -5
- package/dist/commands/read.js +30 -2
- package/dist/commands/remove.js +83 -12
- package/dist/commands/search.js +12 -4
- package/dist/commands/share.js +5 -2
- package/dist/commands/snap.js +140 -35
- package/dist/commands/star.js +5 -2
- package/dist/commands/unpublish.js +5 -1
- package/dist/commands/update.js +109 -11
- package/dist/commands/versions.js +3 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +50 -9
- package/dist/flags.js +100 -8
- package/dist/help.js +292 -15
- package/dist/http.js +137 -45
- package/dist/index.js +106 -22
- package/dist/kind-info.js +40 -0
- package/dist/kinds.js +122 -0
- package/dist/lockfile.js +397 -38
- package/dist/mcp/safe-tool.js +14 -2
- package/dist/spec.js +8 -5
- package/dist/terminal-safe.js +62 -0
- package/dist/useme/SKILL.md +170 -0
- package/dist/useme.js +14 -0
- package/package.json +3 -2
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
|
|
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 |
|
|
@@ -252,10 +331,10 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
|
|
|
252
331
|
| `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
|
|
253
332
|
| `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
|
|
254
333
|
| `ahood snap unshare <id> [--yes]` | Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed). |
|
|
255
|
-
| `ahood snap tags <id> [
|
|
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
|
|
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
|
|
294
|
-
- **A
|
|
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
|
|
package/dist/commands/add.js
CHANGED
|
@@ -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";
|
|
@@ -7,7 +7,9 @@ import { apiFetch, apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
|
|
|
7
7
|
import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
|
|
8
8
|
import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
|
|
9
9
|
import { promptSecret } from "../secret-prompt.js";
|
|
10
|
+
import { sanitizeForTerminal } from "../terminal-safe.js";
|
|
10
11
|
import { UsageError } from "../usage-error.js";
|
|
12
|
+
import { assertKind } from "../kinds.js";
|
|
11
13
|
const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
|
|
12
14
|
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
|
|
13
15
|
const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
|
|
@@ -237,25 +239,6 @@ export async function extractSingleFileContent(buffer, entryName) {
|
|
|
237
239
|
throw new Error(`${entryName} not found in the downloaded archive`);
|
|
238
240
|
return found;
|
|
239
241
|
}
|
|
240
|
-
// Every manifest field used below -- and every tar entry name extractTarGz
|
|
241
|
-
// quotes back in an error above -- reaches the terminal (an error message, or
|
|
242
|
-
// -- worse -- the text printed immediately before a masked secret prompt)
|
|
243
|
-
// but comes verbatim from a third-party-published archive: server-side
|
|
244
|
-
// validation only checks these are strings, not that they're free of
|
|
245
|
-
// control/escape characters. A malicious description could otherwise smuggle
|
|
246
|
-
// a terminal control sequence (cursor movement, line-clear) that repaints
|
|
247
|
-
// what the user sees at the exact moment they're about to type a credential.
|
|
248
|
-
// Strips C0/C1 control characters (including ESC, \x1b) and caps length so a
|
|
249
|
-
// single field can't also flood the terminal.
|
|
250
|
-
//
|
|
251
|
-
// Takes `unknown` rather than `string` because every caller's "string" is a
|
|
252
|
-
// declared type over an unchecked `as ServerManifest` cast of downloaded JSON,
|
|
253
|
-
// not a guarantee: calling .replace() directly on a `registry_type: 123` would
|
|
254
|
-
// turn a diagnosable message into the raw TypeError ahood-cli#121 removed from
|
|
255
|
-
// this same path (ahood-cli#122).
|
|
256
|
-
function sanitizeForTerminal(text) {
|
|
257
|
-
return String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
|
|
258
|
-
}
|
|
259
242
|
// Only npm+npx is supported in v1 (docs/superpowers/specs/2026-09-03-mcp-server-artifacts-design.md) --
|
|
260
243
|
// server-side publish validation (lib/publish/parse-server-manifest.ts) already
|
|
261
244
|
// rejects anything else, so this check is defense-in-depth against a
|
|
@@ -533,19 +516,51 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
|
533
516
|
const serverConfig = buildMcpServerConfig(manifest, env);
|
|
534
517
|
return { manifest, serverConfig, secretNames };
|
|
535
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
|
+
}
|
|
536
532
|
// The plaintext-secret warning printed after a successful mcp install/update.
|
|
537
533
|
// Still derived from the config that actually landed on disk -- the property
|
|
538
534
|
// the old bare `serverConfig.env` check was chosen for -- but intersected with
|
|
539
535
|
// the manifest's declared secrets, because `env` now also holds non-secret
|
|
540
536
|
// configuration (ahood-cli#120) and would otherwise warn about credentials
|
|
541
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.
|
|
542
547
|
function warnIfSecretsWereWritten(serverConfig, secretNames) {
|
|
543
|
-
|
|
544
|
-
if (!env)
|
|
545
|
-
return;
|
|
546
|
-
if (!secretNames.some((name) => Object.prototype.hasOwnProperty.call(env, name)))
|
|
548
|
+
if (!secretsLanded(serverConfig, secretNames))
|
|
547
549
|
return;
|
|
548
|
-
|
|
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);
|
|
549
564
|
}
|
|
550
565
|
// Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
|
|
551
566
|
// entry, stored in the lockfile alongside the version/checksum pin. Lets
|
|
@@ -617,6 +632,22 @@ function readMcpEntryEnv(entry) {
|
|
|
617
632
|
}
|
|
618
633
|
return result;
|
|
619
634
|
}
|
|
635
|
+
// Every install path below undoes its own on-disk write when the lockfile
|
|
636
|
+
// write that would have pinned it fails. This runs that undo so that a
|
|
637
|
+
// failure IN the undo can never replace the original error: a rollback fails
|
|
638
|
+
// for the same reasons the write did (the lock still contended, the disk
|
|
639
|
+
// still full), and letting that escape would hand the user an EACCES or a
|
|
640
|
+
// lock timeout in place of the checksum-conflict message that actually
|
|
641
|
+
// explains what happened (ahood-cli#132). The warning is the consolation
|
|
642
|
+
// prize -- it has to name what was left behind, since nothing else will.
|
|
643
|
+
function rollbackOrWarn(rollback, warning) {
|
|
644
|
+
try {
|
|
645
|
+
rollback();
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
console.warn(warning);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
620
651
|
async function installMcpEntry(owner, skill, meta, buffer) {
|
|
621
652
|
// Check for an existing .mcp.json collision BEFORE resolving secrets --
|
|
622
653
|
// matches resolveMcpServerConfig's own "don't cost the user a prompt for
|
|
@@ -624,6 +655,11 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
624
655
|
// collision check specifically rather than the manifest-shape check.
|
|
625
656
|
assertNoCollision(readMcpConfig(), owner, skill);
|
|
626
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);
|
|
627
663
|
// Read-modify-write under an advisory lock, mirroring lockfile.ts's own
|
|
628
664
|
// writeLockfileEntry: .mcp.json sits right next to the lockfile and can
|
|
629
665
|
// hold other servers' secrets, so it gets the same protection against a
|
|
@@ -637,7 +673,7 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
637
673
|
const fileContents = readMcpConfig();
|
|
638
674
|
assertNoCollision(fileContents, owner, skill);
|
|
639
675
|
fileContents.mcpServers[skill] = serverConfig;
|
|
640
|
-
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
676
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents, wroteSecrets ? 0o600 : undefined);
|
|
641
677
|
});
|
|
642
678
|
const mcpKey = `${owner}/${skill}`;
|
|
643
679
|
try {
|
|
@@ -648,16 +684,43 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
648
684
|
});
|
|
649
685
|
}
|
|
650
686
|
catch (error) {
|
|
651
|
-
if (!(error instanceof LockfileChecksumConflictError))
|
|
652
|
-
throw error;
|
|
653
687
|
// Roll back the .mcp.json merge above under the same lock pattern --
|
|
654
688
|
// re-read+delete rather than assuming nothing else changed it since.
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
689
|
+
//
|
|
690
|
+
// Runs for EVERY lockfile failure, not just a checksum conflict
|
|
691
|
+
// (ahood-cli#132) -- the same widening updateMcpEntry below already got,
|
|
692
|
+
// for the same reason. A rethrow-before-rollback here left the entry
|
|
693
|
+
// this function just merged in -- including, on the npm+npx path, the
|
|
694
|
+
// plaintext secret the user had just typed at the prompt -- sitting in
|
|
695
|
+
// .mcp.json with no lockfile pin at all. `remove` then bails at its
|
|
696
|
+
// `!dirExisted && !agentExisted && !hadLockfileEntry` check ("was not
|
|
697
|
+
// installed -- nothing to remove") before it ever reaches its .mcp.json
|
|
698
|
+
// cleanup, and a retried `add` refuses forever at assertNoCollision:
|
|
699
|
+
// the credential is removable only by hand-editing .mcp.json, and
|
|
700
|
+
// nothing tells the user it is there. Reachable from nothing worse than
|
|
701
|
+
// withLock's 5s timeout against a concurrent ahood, or an EACCES/ENOSPC
|
|
702
|
+
// on the write.
|
|
703
|
+
//
|
|
704
|
+
// Deletes, where updateMcpEntry's rollback restores: assertNoCollision
|
|
705
|
+
// has already established there was no entry under this key before this
|
|
706
|
+
// install wrote one, so there is nothing to put back. Deletes it
|
|
707
|
+
// unconditionally rather than re-fingerprinting it first the way
|
|
708
|
+
// remove.ts does -- remove is deciding about an entry of unknown age
|
|
709
|
+
// that may hold an intentional local edit, while this one was written
|
|
710
|
+
// by this same process moments ago and cannot be anyone else's work.
|
|
711
|
+
// Re-verifying here would only reintroduce the stranded-secret dead end
|
|
712
|
+
// above for whatever raced us.
|
|
713
|
+
rollbackOrWarn(() => {
|
|
714
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
715
|
+
const fileContents = readMcpConfig();
|
|
716
|
+
delete fileContents.mcpServers[skill];
|
|
717
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents, wroteSecrets ? 0o600 : undefined);
|
|
718
|
+
});
|
|
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}\`.`);
|
|
720
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
721
|
+
throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
|
|
722
|
+
}
|
|
723
|
+
throw error;
|
|
661
724
|
}
|
|
662
725
|
console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
|
|
663
726
|
// Only the npm+npx package path ever carries secrets into `env` (headers
|
|
@@ -709,6 +772,11 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
709
772
|
// prompt is correct.
|
|
710
773
|
const existingEnv = readMcpEntryEnv(existingOnDisk);
|
|
711
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);
|
|
712
780
|
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
713
781
|
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
714
782
|
// arbitrary time may have passed since the pre-check -- the download, and
|
|
@@ -724,7 +792,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
724
792
|
}
|
|
725
793
|
previousOnDisk = freshExisting;
|
|
726
794
|
freshServers[skill] = serverConfig;
|
|
727
|
-
writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
|
|
795
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fresh, wroteSecrets ? 0o600 : undefined);
|
|
728
796
|
});
|
|
729
797
|
try {
|
|
730
798
|
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
|
|
@@ -747,7 +815,12 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
747
815
|
// `remove` permanently refuse -- the exact dead end the comments above
|
|
748
816
|
// exist to prevent, reachable from nothing worse than withLock's 5s
|
|
749
817
|
// timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
|
|
750
|
-
|
|
818
|
+
//
|
|
819
|
+
// The rollback itself can fail for the same reason the write did (lock
|
|
820
|
+
// still contended, disk still full), so it goes through rollbackOrWarn:
|
|
821
|
+
// the original error below is the more actionable one, and masking it
|
|
822
|
+
// with this one would hide why the update failed in the first place.
|
|
823
|
+
rollbackOrWarn(() => {
|
|
751
824
|
withLock(MCP_CONFIG_PATH, () => {
|
|
752
825
|
const fileContents = readMcpConfig();
|
|
753
826
|
const mcpServers = fileContents.mcpServers;
|
|
@@ -757,16 +830,9 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
757
830
|
else {
|
|
758
831
|
mcpServers[skill] = previousOnDisk;
|
|
759
832
|
}
|
|
760
|
-
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
833
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents, wroteSecrets ? 0o600 : undefined);
|
|
761
834
|
});
|
|
762
|
-
}
|
|
763
|
-
catch {
|
|
764
|
-
// The rollback can fail for the same reason the write did (lock still
|
|
765
|
-
// contended, disk still full). Warn rather than throw: the original
|
|
766
|
-
// error below is the more actionable one, and masking it with this
|
|
767
|
-
// one would hide why the update failed in the first place.
|
|
768
|
-
console.warn(`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.`);
|
|
769
|
-
}
|
|
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.`);
|
|
770
836
|
if (error instanceof LockfileChecksumConflictError) {
|
|
771
837
|
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
772
838
|
}
|
|
@@ -799,13 +865,28 @@ export async function downloadVerifiedArchive(owner, skill, meta) {
|
|
|
799
865
|
}
|
|
800
866
|
return buffer;
|
|
801
867
|
}
|
|
802
|
-
export async function add(args) {
|
|
868
|
+
export async function add(args, scope) {
|
|
803
869
|
const spec = args[0];
|
|
804
870
|
if (!spec)
|
|
805
871
|
throw new UsageError(USAGE);
|
|
806
872
|
const { owner, skill, version: requestedVersion } = parseOwnerSkillVersion(spec, USAGE);
|
|
807
873
|
const key = `${owner}/${skill}`;
|
|
808
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
|
+
}
|
|
809
890
|
// A lockfile entry records the checksum this exact version was installed
|
|
810
891
|
// with; if a later fetch of the "same" version disagrees, either the
|
|
811
892
|
// published artifact changed after being pinned or the registry response
|
|
@@ -844,10 +925,18 @@ export async function add(args) {
|
|
|
844
925
|
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
|
|
845
926
|
}
|
|
846
927
|
catch (error) {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
928
|
+
// Widened past the checksum-conflict case with installMcpEntry's own
|
|
929
|
+
// rollback (ahood-cli#132): an agent file left behind by a lock
|
|
930
|
+
// timeout is less dangerous than a stranded mcp secret -- `remove`
|
|
931
|
+
// finds it via agentExisted even with no pin -- but "a failed install
|
|
932
|
+
// installs nothing" is the invariant worth having at all three sites,
|
|
933
|
+
// and the previous version's content is gone either way, overwritten
|
|
934
|
+
// by the writeFileSync above before the pin was ever attempted.
|
|
935
|
+
rollbackOrWarn(() => rmSync(destPath, { force: true }), `WARNING: could not remove ${destPath} after ${key}'s install failed -- it may be left behind with no ${LOCKFILE_PATH} pin. Run \`ahood skill remove ${key}\` to clean it up.`);
|
|
936
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
937
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
938
|
+
}
|
|
939
|
+
throw error;
|
|
851
940
|
}
|
|
852
941
|
console.log(`Installed ${key}@${meta.version} to ${destPath}`);
|
|
853
942
|
return;
|
|
@@ -867,10 +956,14 @@ export async function add(args) {
|
|
|
867
956
|
});
|
|
868
957
|
}
|
|
869
958
|
catch (error) {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
959
|
+
// Same widening as the agent path above (ahood-cli#132). extractFreshVersion
|
|
960
|
+
// has already cleared any previous version out of this directory, so the
|
|
961
|
+
// only thing this deletes is what this failed install just put there.
|
|
962
|
+
rollbackOrWarn(() => rmSync(destDir, { recursive: true, force: true }), `WARNING: could not remove ${destDir} after ${key}'s install failed -- it may be left behind with no ${LOCKFILE_PATH} pin. Run \`ahood skill remove ${key}\` to clean it up.`);
|
|
963
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
964
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
965
|
+
}
|
|
966
|
+
throw error;
|
|
874
967
|
}
|
|
875
968
|
console.log(`Installed ${key}@${meta.version} to ${destDir}`);
|
|
876
969
|
}
|
|
@@ -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
|
-
//
|
|
18
|
+
// every entity noun.
|
|
6
19
|
function topLevelNames() {
|
|
7
|
-
return [...TOP_LEVEL_COMMANDS_HELP.map((c) => c.usage.split(" ")[1]),
|
|
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
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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="${
|
|
23
|
-
` local
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
35
|
-
const skill = skillNames().join(" ");
|
|
36
|
-
return [
|
|
57
|
+
const lines = [
|
|
37
58
|
"#compdef ahood",
|
|
38
|
-
|
|
39
|
-
`top_cmds=(${
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
...
|
|
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
|
package/dist/commands/diff.js
CHANGED
|
@@ -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),
|