@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 +91 -5
- package/dist/commands/add.js +69 -11
- 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 +3 -1
- package/dist/commands/remove.js +29 -1
- package/dist/commands/search.js +12 -4
- package/dist/commands/share.js +5 -2
- package/dist/commands/star.js +5 -2
- package/dist/commands/unpublish.js +5 -1
- package/dist/commands/update.js +61 -9
- package/dist/commands/versions.js +3 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +50 -9
- package/dist/help.js +270 -7
- package/dist/http.js +125 -26
- package/dist/index.js +106 -22
- package/dist/kind-info.js +40 -0
- package/dist/kinds.js +122 -0
- package/dist/lockfile.js +142 -17
- package/dist/mcp/safe-tool.js +14 -2
- package/dist/spec.js +8 -5
- 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 |
|
|
@@ -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
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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),
|
package/dist/commands/edit.js
CHANGED
|
@@ -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" },
|
package/dist/commands/init.js
CHANGED
|
@@ -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 :
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
102
|
-
console.log(`Created ${
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|