@ahood/cli 0.10.1 → 0.12.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 +62 -7
- package/dist/commands/add.js +70 -14
- package/dist/commands/completion.js +7 -5
- package/dist/commands/diff.js +20 -13
- package/dist/commands/init.js +72 -2
- package/dist/commands/project-guides.js +47 -0
- package/dist/commands/publish.js +33 -39
- package/dist/commands/remove.js +11 -4
- package/dist/commands/update.js +6 -1
- package/dist/help.js +77 -18
- package/dist/hosted-mcp.js +36 -0
- package/dist/index.js +25 -6
- package/dist/kind-info.js +45 -6
- package/dist/kinds.js +7 -4
- package/dist/lockfile.js +7 -1
- package/dist/project-remote.js +80 -0
- package/dist/spec.js +22 -0
- package/dist/useme/SKILL.md +23 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -189,15 +189,17 @@ Shared alice/pdf-tools with design-team.
|
|
|
189
189
|
Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private visibility, it just makes it visible to everyone in `design-team` too. On the other side, whoever received the link runs `ahood group join <the link>` to join.
|
|
190
190
|
</details>
|
|
191
191
|
|
|
192
|
-
## Kinds: skills, agents, and
|
|
192
|
+
## Kinds: skills, agents, MCP servers, docs, and project guides
|
|
193
193
|
|
|
194
|
-
The registry holds
|
|
194
|
+
The registry holds five kinds of artifact, each with its own command group. All five take the same verbs (`search`, `list`, `view`/`show`, `read`, `versions`, `diff`, `add`, `update`, `outdated`, `remove`, `edit`, `unpublish`, `star`, `unstar`, `share`, `unshare`, `init`, `publish`), run by the same code:
|
|
195
195
|
|
|
196
196
|
| Kind | Root file | `add` installs to | Group |
|
|
197
197
|
| --- | --- | --- | --- |
|
|
198
198
|
| skill | `SKILL.md` | `.claude/skills/<owner>@<skill>/` | `ahood skill <verb>` |
|
|
199
199
|
| agent | `AGENT.md` | `.claude/agents/<owner>@<agent>.md` | `ahood agent <verb>` |
|
|
200
200
|
| mcp | `server.json` | an entry merged into `.mcp.json` | `ahood mcp <verb>` |
|
|
201
|
+
| doc | `DOC.md` | `.claude/docs/<owner>@<doc>.md` | `ahood doc <verb>` |
|
|
202
|
+
| project | `PROJECT.md` | `.claude/project.md` (one per repository) | `ahood project <verb>` |
|
|
201
203
|
|
|
202
204
|
```
|
|
203
205
|
$ ahood agent search review
|
|
@@ -205,14 +207,20 @@ $ ahood agent init code-reviewer # ./code-reviewer/AGENT.md
|
|
|
205
207
|
$ ahood agent publish alice/code-reviewer@1.0.0 --path code-reviewer --name "Code Reviewer"
|
|
206
208
|
$ ahood mcp init github-server # ./github-server/server.json, a safe placeholder starter
|
|
207
209
|
$ ahood mcp add alice/github-server
|
|
210
|
+
$ ahood doc read alice/setup-guide # print a doc without installing it
|
|
211
|
+
$ ahood doc add alice/setup-guide # ./.claude/docs/alice@setup-guide.md, pinned
|
|
212
|
+
$ ahood project # project guides for this repo's git remote
|
|
213
|
+
$ ahood project add alice/acme-api # ./.claude/project.md, pinned
|
|
214
|
+
$ ahood project init acme-api # ./acme-api/PROJECT.md, `project:` filled from git origin
|
|
208
215
|
$ ahood agent add alice/pdf-tools
|
|
209
216
|
alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood skill add alice/pdf-tools` instead.
|
|
210
217
|
```
|
|
211
218
|
|
|
212
|
-
- **`ahood agent` and `ahood
|
|
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,
|
|
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
|
-
- **
|
|
219
|
+
- **`ahood agent`, `ahood mcp`, `ahood doc` and `ahood project` are strict.** A target of another kind is refused -- exit code `2`, naming the kind it found -- before anything is downloaded, prompted for, written, or sent. That covers `add`, `update`, `remove`, `edit`, `unpublish`, `share`/`unshare`, `star`/`unstar`, and `publish` (an existing entry of another kind is refused before anything is packed or uploaded); the read-only verbs refuse too, rather than show the wrong kind. `search` asks the registry for that kind only, `list` keeps only that kind of your own entries, and `update`/`outdated` with no argument consider only installed pins of that kind. `agent publish`/`mcp publish`/`doc publish`/`project publish` imply their kind and reject a contradictory `--kind`.
|
|
220
|
+
- **Missing or unexpected kind metadata is refused, never guessed.** If the registry's answer carries no kind (or one this CLI doesn't handle), a kind-scoped command stops with exit `1` and points at the legacy form. `remove` reads the kind from the project's own files (the skill directory, the agent file, the mcp fingerprint in the lockfile, or a doc or project pin's recorded `kind`) and asks the registry only when those can't tell; if neither can, nothing is removed.
|
|
221
|
+
- **`ahood skill` is legacy and cross-kind in this release.** `ahood skill search` and `ahood skill list` include every kind, and `ahood skill add`/`update`/`remove`/... act on whatever kind the target is, exactly as before -- including `--json` output and exit codes -- so existing scripts keep working. The exceptions are a doc and a project guide: `ahood skill add` refuses them and points at `ahood doc add` / `ahood project add`. Add `--kind skill|agent|mcp|doc|project` to any `ahood skill` verb (except `publish`, whose `--kind` keeps its original meaning) to scope it, or `--kind all` to spell out the cross-kind behavior.
|
|
222
|
+
- **Docs install as one pinned file.** `ahood doc add` saves a doc's `DOC.md` to `.claude/docs/<owner>@<doc>.md` and pins it in `.claude/skills.lock.json` with `"kind": "doc"`. It refuses to overwrite a file at that path that isn't pinned as that doc. `ahood doc update`/`outdated`/`remove` manage those pins like any other kind. Kind dispatch is table-driven (`src/kind-info.ts`).
|
|
223
|
+
- **A project guide is one pinned file per repository.** `ahood project add` saves the guide's `PROJECT.md` to `.claude/project.md` and pins it with `"kind": "project"`. It never touches `CLAUDE.md` or `AGENTS.md` (point those at `.claude/project.md` yourself), refuses to overwrite an unpinned `.claude/project.md`, and refuses a second guide while another is pinned (`ahood project remove` it first). `ahood project init` fills the optional `project` frontmatter field from `git remote get-url origin`, normalized to `host/owner/repo` exactly as the registry does (credentials stripped; `--project <remote>` or `--no-project` to override). Bare `ahood project` lists the guides you can see for this repository's remote, through the hosted `get_project_guide` MCP tool.
|
|
216
224
|
|
|
217
225
|
**Migration plan for a strict `ahood skill`.** A future major version will make `ahood skill <verb>` skills-only by default, matching `agent` and `mcp`. Until then: (1) this release adds the strict groups and `--kind`, and labels `ahood skill` as legacy in `--help`; (2) a following minor release prints a stderr deprecation note when an `ahood skill` command actually touches a non-skill entry (stdout and `--json` unchanged); (3) the major release flips the default, keeping `ahood skill <verb> --kind all` as the explicit cross-kind escape hatch. Scripts that want today's behavior forever can pass `--kind all` now.
|
|
218
226
|
|
|
@@ -257,7 +265,7 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
|
|
|
257
265
|
| `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
|
|
258
266
|
| `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
|
|
259
267
|
| `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
|
|
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,
|
|
268
|
+
| `ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill\|agent\|mcp\|doc\|project] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a skill, agent, mcp server manifest, doc, or project guide from a folder containing SKILL.md, AGENT.md, server.json, DOC.md, or PROJECT.md, creating the skill first if it doesn't already exist. |
|
|
261
269
|
|
|
262
270
|
### Agent
|
|
263
271
|
|
|
@@ -307,6 +315,53 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
|
|
|
307
315
|
| `ahood mcp init [name]` | Scaffold a new MCP server manifest folder with a minimal, valid server.json. |
|
|
308
316
|
| `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
317
|
|
|
318
|
+
### Doc
|
|
319
|
+
|
|
320
|
+
| Command | What it does |
|
|
321
|
+
| --- | --- |
|
|
322
|
+
| `ahood doc search <query> [--json] [--limit <n>]` | Search published docs. |
|
|
323
|
+
| `ahood doc view\|show <owner>/<doc> [--json] [--web]` | Show a single doc's details without installing it (alias: ahood doc show). |
|
|
324
|
+
| `ahood doc read <owner>/<doc> [--json]` | Print a published doc's DOC.md, without installing it. |
|
|
325
|
+
| `ahood doc versions <owner>/<doc> [--json]` | List a doc's published-version history. |
|
|
326
|
+
| `ahood doc diff <owner>/<doc> <versionA> <versionB> [--json]` | Show what changed between two published versions of a doc. |
|
|
327
|
+
| `ahood doc list [--json]` | List your own docs, public and private. |
|
|
328
|
+
| `ahood doc add <owner>/<doc>[@version]` | Install a doc into .claude/docs/<owner>@<doc>.md, pinned in the lockfile. |
|
|
329
|
+
| `ahood doc update [<owner>/<doc> ...] [--dry-run] [--json]` | Move doc pins forward to the latest version. |
|
|
330
|
+
| `ahood doc outdated [<owner>/<doc> ...] [--json]` | Read-only staleness check for installed docs. |
|
|
331
|
+
| `ahood doc remove <owner>/<doc> [--yes]` | Uninstall and unpin a doc (local only, prompts unless --yes is passed). |
|
|
332
|
+
| `ahood doc edit <owner>/<doc> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a doc you own, changing only the flags you pass. |
|
|
333
|
+
| `ahood doc unpublish <owner>/<doc>[@version] [--yes]` | Delete a doc from the registry for every consumer, or yank one version (prompts unless --yes is passed). |
|
|
334
|
+
| `ahood doc star <owner>/<doc>` | Star a doc. |
|
|
335
|
+
| `ahood doc unstar <owner>/<doc>` | Remove your star from a doc. |
|
|
336
|
+
| `ahood doc share <owner>/<doc> --group <group>` | Share a doc you own with a group, without changing its visibility. |
|
|
337
|
+
| `ahood doc unshare <owner>/<doc> --group <group>` | Stop sharing a doc you own with a group. |
|
|
338
|
+
| `ahood doc init [name]` | Scaffold a new doc folder with a minimal, valid DOC.md. |
|
|
339
|
+
| `ahood doc publish <owner>/<doc>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a doc from a folder containing DOC.md, creating it first if it doesn't exist yet. |
|
|
340
|
+
|
|
341
|
+
### Project
|
|
342
|
+
|
|
343
|
+
| Command | What it does |
|
|
344
|
+
| --- | --- |
|
|
345
|
+
| `ahood project [--remote <git-remote>] [--json]` | List the project guides you can see for this repository's git remote. |
|
|
346
|
+
| `ahood project search <query> [--json] [--limit <n>]` | Search published project guides. |
|
|
347
|
+
| `ahood project view\|show <owner>/<slug> [--json] [--web]` | Show a single project guide's details without installing it (alias: ahood project show). |
|
|
348
|
+
| `ahood project read <owner>/<slug> [--json]` | Print a published project guide's PROJECT.md, without installing it. |
|
|
349
|
+
| `ahood project versions <owner>/<slug> [--json]` | List a project guide's published-version history. |
|
|
350
|
+
| `ahood project diff <owner>/<slug> <versionA> <versionB> [--json]` | Show what changed between two published versions of a project guide. |
|
|
351
|
+
| `ahood project list [--json]` | List your own project guides, public and private. |
|
|
352
|
+
| `ahood project add <owner>/<slug>[@version]` | Install a project guide into .claude/project.md, pinned in the lockfile. |
|
|
353
|
+
| `ahood project update [<owner>/<slug> ...] [--dry-run] [--json]` | Move project guide pins forward to the latest version. |
|
|
354
|
+
| `ahood project outdated [<owner>/<slug> ...] [--json]` | Read-only staleness check for installed project guides. |
|
|
355
|
+
| `ahood project remove <owner>/<slug> [--yes]` | Uninstall and unpin a project guide (local only, prompts unless --yes is passed). |
|
|
356
|
+
| `ahood project edit <owner>/<slug> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a project guide you own, changing only the flags you pass. |
|
|
357
|
+
| `ahood project unpublish <owner>/<slug>[@version] [--yes]` | Delete a project guide from the registry for every consumer, or yank one version (prompts unless --yes is passed). |
|
|
358
|
+
| `ahood project star <owner>/<slug>` | Star a project guide. |
|
|
359
|
+
| `ahood project unstar <owner>/<slug>` | Remove your star from a project guide. |
|
|
360
|
+
| `ahood project share <owner>/<slug> --group <group>` | Share a project guide you own with a group, without changing its visibility. |
|
|
361
|
+
| `ahood project unshare <owner>/<slug> --group <group>` | Stop sharing a project guide you own with a group. |
|
|
362
|
+
| `ahood project init [name] [--project <git-remote> \| --no-project]` | Scaffold a new project guide folder with a minimal, valid PROJECT.md. |
|
|
363
|
+
| `ahood project publish <owner>/<slug>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a project guide from a folder containing PROJECT.md, creating it first if it doesn't exist yet. |
|
|
364
|
+
|
|
310
365
|
### Group
|
|
311
366
|
|
|
312
367
|
| Command | What it does |
|
package/dist/commands/add.js
CHANGED
|
@@ -4,12 +4,12 @@ import { basename, dirname, join, resolve, sep } from "node:path";
|
|
|
4
4
|
import * as tarStream from "tar-stream";
|
|
5
5
|
import { gunzipSync } from "node:zlib";
|
|
6
6
|
import { apiFetch, apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
|
|
7
|
-
import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
|
|
8
|
-
import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
|
|
7
|
+
import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, pinnedFilePath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
|
|
8
|
+
import { readLockfile, withLock, writeFileAtomic, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
|
|
9
9
|
import { promptSecret } from "../secret-prompt.js";
|
|
10
10
|
import { sanitizeForTerminal } from "../terminal-safe.js";
|
|
11
11
|
import { UsageError } from "../usage-error.js";
|
|
12
|
-
import { assertKind } from "../kinds.js";
|
|
12
|
+
import { assertKind, describeKind, isPinnedFileKind, KINDS, strictKind } from "../kinds.js";
|
|
13
13
|
const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
|
|
14
14
|
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
|
|
15
15
|
const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
|
|
@@ -865,6 +865,55 @@ export async function downloadVerifiedArchive(owner, skill, meta) {
|
|
|
865
865
|
}
|
|
866
866
|
return buffer;
|
|
867
867
|
}
|
|
868
|
+
// `ahood doc add` (ahood-cli#182) and `ahood project add` (#185): only the
|
|
869
|
+
// root doc (DOC.md / PROJECT.md) is extracted, written atomically to the
|
|
870
|
+
// kind's pinned file (spec.ts pinnedFilePath), and pinned with its kind so
|
|
871
|
+
// remove/update can tell the pin apart without asking the registry. If the
|
|
872
|
+
// pin can't be written, the file goes back to what it was: the previous
|
|
873
|
+
// version's content on an update, nothing on a fresh install.
|
|
874
|
+
async function installPinnedFile(kind, owner, skill, meta, buffer) {
|
|
875
|
+
const key = `${owner}/${skill}`;
|
|
876
|
+
const content = await extractSingleFileContent(buffer, KINDS[kind].rootDoc);
|
|
877
|
+
const destPath = pinnedFilePath(kind, owner, skill);
|
|
878
|
+
const previous = existsSync(destPath) ? readFileSync(destPath) : undefined;
|
|
879
|
+
writeFileAtomic(destPath, content);
|
|
880
|
+
try {
|
|
881
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
|
|
882
|
+
version: meta.version,
|
|
883
|
+
checksum_sha256: meta.checksum_sha256,
|
|
884
|
+
kind,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
rollbackOrWarn(() => (previous === undefined ? rmSync(destPath, { force: true }) : writeFileAtomic(destPath, previous)), `WARNING: could not restore ${destPath} after ${key}'s install failed -- it may not match its ${LOCKFILE_PATH} pin. Run \`ahood ${kind} remove ${key}\` to clean it up.`);
|
|
889
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
890
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
891
|
+
}
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
console.log(`Installed ${key}@${meta.version} to ${destPath}`);
|
|
895
|
+
}
|
|
896
|
+
// Refuses a pinned-file install before the download when its path is not
|
|
897
|
+
// this key's to write: a file no pin claims was not written by ahood, and a
|
|
898
|
+
// path another pin claims (a second project guide) belongs to that pin.
|
|
899
|
+
function assertPinnedFileIsOurs(kind, owner, skill) {
|
|
900
|
+
const key = `${owner}/${skill}`;
|
|
901
|
+
const destPath = pinnedFilePath(kind, owner, skill);
|
|
902
|
+
const holder = Object.entries(readLockfile(LOCKFILE_PATH)).find(([pinKey, pin]) => {
|
|
903
|
+
if (!isPinnedFileKind(pin.kind))
|
|
904
|
+
return false;
|
|
905
|
+
const [pinOwner, pinSlug] = pinKey.split("/");
|
|
906
|
+
return pinnedFilePath(pin.kind, pinOwner, pinSlug) === destPath;
|
|
907
|
+
})?.[0];
|
|
908
|
+
if (holder !== undefined && holder !== key) {
|
|
909
|
+
throw new Error(`${holder} is already installed at ${destPath} -- refusing to replace it with ${key}. ` +
|
|
910
|
+
`Run \`ahood ${kind} remove ${holder}\` first.`);
|
|
911
|
+
}
|
|
912
|
+
if (holder === undefined && existsSync(destPath)) {
|
|
913
|
+
throw new Error(`${destPath} already exists and is not pinned as ${key} in ${LOCKFILE_PATH} -- refusing to overwrite it. ` +
|
|
914
|
+
"Move or delete it first.");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
868
917
|
export async function add(args, scope) {
|
|
869
918
|
const spec = args[0];
|
|
870
919
|
if (!spec)
|
|
@@ -876,17 +925,20 @@ export async function add(args, scope) {
|
|
|
876
925
|
// refused here, before the download, the lockfile and every write below.
|
|
877
926
|
// Both metadata routes report the entry's kind, so this costs no request.
|
|
878
927
|
assertKind(scope, key, meta.kind, "add", "install");
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
// legacy
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
928
|
+
// Legacy cross-kind `ahood skill add` of a doc or project guide stays
|
|
929
|
+
// refused (ahood-cli#175, #185): it used to fall through to the skill path
|
|
930
|
+
// and extract it into .claude/skills/<owner>@<slug>/ with no SKILL.md --
|
|
931
|
+
// not a working skill, with nothing telling the user. These kinds install
|
|
932
|
+
// only through their own scope (`ahood doc add`, `ahood project add`),
|
|
933
|
+
// which writes the one pinned file below. Refused before the download, the
|
|
934
|
+
// lockfile and any write. Entries with NO kind keep the legacy behaviour.
|
|
935
|
+
const pinnedKind = isPinnedFileKind(meta.kind) ? meta.kind : undefined;
|
|
936
|
+
if (pinnedKind !== undefined && strictKind(scope) !== pinnedKind) {
|
|
937
|
+
throw new UsageError(`${key} is ${describeKind(pinnedKind)} (${KINDS[pinnedKind].about}), not a skill. ` +
|
|
938
|
+
`Install it with \`ahood ${pinnedKind} add ${key}\`, or read it without installing with \`ahood ${pinnedKind} read ${key}\`.`);
|
|
889
939
|
}
|
|
940
|
+
if (pinnedKind !== undefined)
|
|
941
|
+
assertPinnedFileIsOurs(pinnedKind, owner, skill);
|
|
890
942
|
// A lockfile entry records the checksum this exact version was installed
|
|
891
943
|
// with; if a later fetch of the "same" version disagrees, either the
|
|
892
944
|
// published artifact changed after being pinned or the registry response
|
|
@@ -912,7 +964,7 @@ export async function add(args, scope) {
|
|
|
912
964
|
// manifest can never actually land on disk there -- the warning would be
|
|
913
965
|
// both mislabeled ("skill") and about a risk that can't materialize
|
|
914
966
|
// (ahood-cli final review finding #5).
|
|
915
|
-
if (meta.kind !== "agent" && meta.kind !== "mcp" && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
|
|
967
|
+
if (meta.kind !== "agent" && meta.kind !== "mcp" && pinnedKind === undefined && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
|
|
916
968
|
console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
|
|
917
969
|
}
|
|
918
970
|
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
@@ -945,6 +997,10 @@ export async function add(args, scope) {
|
|
|
945
997
|
await installMcpEntry(owner, skill, meta, buffer);
|
|
946
998
|
return;
|
|
947
999
|
}
|
|
1000
|
+
if (pinnedKind !== undefined) {
|
|
1001
|
+
await installPinnedFile(pinnedKind, owner, skill, meta, buffer);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
948
1004
|
// Flat, owner@skill-joined directory name so Claude Code's own one-level-deep
|
|
949
1005
|
// project-skill scan actually discovers it (see skillDir's comment in spec.ts).
|
|
950
1006
|
const destDir = skillDir(owner, skill);
|
|
@@ -1,13 +1,15 @@
|
|
|
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";
|
|
1
|
+
import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, AGENT_COMMANDS_HELP, MCP_COMMANDS_HELP, DOC_COMMANDS_HELP, PROJECT_COMMANDS_HELP, GROUP_COMMANDS_HELP, SNAP_COMMANDS_HELP, COMMAND_ALIASES, } from "../help.js";
|
|
2
2
|
import { UsageError } from "../usage-error.js";
|
|
3
3
|
const USAGE = "Usage: ahood completion <bash|zsh|fish>";
|
|
4
4
|
// Every entity noun with its own verb list, in the order completion offers
|
|
5
|
-
// them. Registry nouns (skill, agent, mcp -- ahood-cli#172) come first; group
|
|
5
|
+
// them. Registry nouns (skill, agent, mcp -- ahood-cli#172; doc -- #182; project -- #185) come first; group
|
|
6
6
|
// and snap were already real nouns but had no completion at all before.
|
|
7
7
|
const NOUNS = [
|
|
8
8
|
["skill", SKILL_COMMANDS_HELP],
|
|
9
9
|
["agent", AGENT_COMMANDS_HELP],
|
|
10
10
|
["mcp", MCP_COMMANDS_HELP],
|
|
11
|
+
["doc", DOC_COMMANDS_HELP],
|
|
12
|
+
["project", PROJECT_COMMANDS_HELP],
|
|
11
13
|
["group", GROUP_COMMANDS_HELP],
|
|
12
14
|
["snap", SNAP_COMMANDS_HELP],
|
|
13
15
|
];
|
|
@@ -21,10 +23,10 @@ function topLevelNames() {
|
|
|
21
23
|
}
|
|
22
24
|
// Position 2+, once a noun has been typed: that noun's verbs, plus alias
|
|
23
25
|
// 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`
|
|
25
|
-
// has no verb word and contributes nothing.
|
|
26
|
+
// completion offers every name a user might type. A bare `ahood mcp` or
|
|
27
|
+
// `ahood project [--remote ...]` entry has no verb word and contributes nothing.
|
|
26
28
|
function verbNames(entries) {
|
|
27
|
-
const primary = entries.map((c) => c.usage.split(" ")[2] ?? "");
|
|
29
|
+
const primary = entries.map((c) => c.usage.split(" ")[2] ?? "").filter((word) => /^[a-z]/.test(word));
|
|
28
30
|
const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => primary.includes(COMMAND_ALIASES[alias]));
|
|
29
31
|
return unique([...primary, ...aliases]);
|
|
30
32
|
}
|
package/dist/commands/diff.js
CHANGED
|
@@ -2,7 +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
|
+
import { assertKind, isCliKind, KINDS } from "../kinds.js";
|
|
6
6
|
const USAGE = "Usage: ahood skill diff <owner>/<skill> <versionA> <versionB> [--json]";
|
|
7
7
|
// Both versions must be explicit semver here, unlike `add`'s
|
|
8
8
|
// parseOwnerSkillVersion which lets "latest" default the version -- a diff
|
|
@@ -20,12 +20,16 @@ function validateVersion(version, spec) {
|
|
|
20
20
|
// SKILL.md's presence from the manifest we already fetched -- checked
|
|
21
21
|
// up front so a missing file produces a clean message instead of
|
|
22
22
|
// extractSingleFileContent's generic "not found in the downloaded archive".
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
//
|
|
24
|
+
// The file is the entry's root document (ahood-cli#182): SKILL.md for a skill
|
|
25
|
+
// or an entry with no kind (unchanged), AGENT.md, server.json or DOC.md for
|
|
26
|
+
// the other kinds, which have no SKILL.md to diff.
|
|
27
|
+
async function fetchSkillMdContent(owner, skill, meta, rootDoc) {
|
|
28
|
+
if (!meta.manifest.some((f) => f.path === rootDoc || f.path === `./${rootDoc}`)) {
|
|
29
|
+
throw new Error(`${owner}/${skill}@${meta.version} has no ${rootDoc} in its manifest.`);
|
|
26
30
|
}
|
|
27
31
|
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
28
|
-
const content = await extractSingleFileContent(buffer,
|
|
32
|
+
const content = await extractSingleFileContent(buffer, rootDoc);
|
|
29
33
|
return content.toString("utf-8");
|
|
30
34
|
}
|
|
31
35
|
// Compares two versions' manifests by path. VersionMeta.manifest only
|
|
@@ -35,14 +39,14 @@ async function fetchSkillMdContent(owner, skill, meta) {
|
|
|
35
39
|
// diffed above) -- there is no way to tell whether some other common path's
|
|
36
40
|
// *content* differs without downloading and comparing it too, which is out
|
|
37
41
|
// of scope for this command (see the resolution-plan comment on issue #88).
|
|
38
|
-
function diffManifests(manifestA, manifestB, skillMdChanged) {
|
|
42
|
+
function diffManifests(manifestA, manifestB, skillMdChanged, rootDoc) {
|
|
39
43
|
const pathsA = new Set(manifestA.map((f) => f.path));
|
|
40
44
|
const pathsB = new Set(manifestB.map((f) => f.path));
|
|
41
45
|
const added = [...pathsB].filter((p) => !pathsA.has(p)).sort();
|
|
42
46
|
const removed = [...pathsA].filter((p) => !pathsB.has(p)).sort();
|
|
43
47
|
const changed = [];
|
|
44
|
-
if (skillMdChanged && pathsA.has(
|
|
45
|
-
changed.push(
|
|
48
|
+
if (skillMdChanged && pathsA.has(rootDoc) && pathsB.has(rootDoc)) {
|
|
49
|
+
changed.push(rootDoc);
|
|
46
50
|
}
|
|
47
51
|
return { added, removed, changed };
|
|
48
52
|
}
|
|
@@ -61,13 +65,16 @@ export async function diff(args, scope) {
|
|
|
61
65
|
]);
|
|
62
66
|
// Both version responses carry the entry's kind; checked before either archive is downloaded.
|
|
63
67
|
assertKind(scope, `${owner}/${skill}`, metaA.kind, "diff", "diff");
|
|
68
|
+
const rootDoc = isCliKind(metaA.kind) ? KINDS[metaA.kind].rootDoc : "SKILL.md";
|
|
64
69
|
const [contentA, contentB] = await Promise.all([
|
|
65
|
-
fetchSkillMdContent(owner, skill, metaA),
|
|
66
|
-
fetchSkillMdContent(owner, skill, metaB),
|
|
70
|
+
fetchSkillMdContent(owner, skill, metaA, rootDoc),
|
|
71
|
+
fetchSkillMdContent(owner, skill, metaB, rootDoc),
|
|
67
72
|
]);
|
|
68
|
-
|
|
73
|
+
// The --json key stays `skillmd_diff` whatever the root document is, so
|
|
74
|
+
// the output shape never changes.
|
|
75
|
+
const skillmdDiff = createPatch(rootDoc, contentA, contentB, versionA, versionB);
|
|
69
76
|
const skillMdChanged = contentA !== contentB;
|
|
70
|
-
const manifest = diffManifests(metaA.manifest, metaB.manifest, skillMdChanged);
|
|
77
|
+
const manifest = diffManifests(metaA.manifest, metaB.manifest, skillMdChanged, rootDoc);
|
|
71
78
|
if (jsonOutput) {
|
|
72
79
|
console.log(JSON.stringify({ skillmd_diff: skillMdChanged ? skillmdDiff : "", manifest }));
|
|
73
80
|
return;
|
|
@@ -78,7 +85,7 @@ export async function diff(args, scope) {
|
|
|
78
85
|
console.log(skillmdDiff);
|
|
79
86
|
}
|
|
80
87
|
else {
|
|
81
|
-
console.log(
|
|
88
|
+
console.log(`${rootDoc} is unchanged between these versions.`);
|
|
82
89
|
}
|
|
83
90
|
const summaryParts = [];
|
|
84
91
|
if (manifest.added.length > 0)
|
package/dist/commands/init.js
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, join, resolve, sep } from "node:path";
|
|
3
3
|
import { UsageError } from "../usage-error.js";
|
|
4
4
|
import { CLI_KINDS, KINDS, strictKind } from "../kinds.js";
|
|
5
|
+
import { flagValue } from "../flags.js";
|
|
6
|
+
import { detectProjectRemote, normalizeProjectRemote } from "../project-remote.js";
|
|
5
7
|
const USAGE = "Usage: ahood skill init [name]";
|
|
6
8
|
// Mirrors the server's slug convention referenced by ahood-cli#60 (and the
|
|
7
9
|
// same shape as spec.ts's SEGMENT_RE, minus "." and "_" -- skill slugs are
|
|
@@ -102,10 +104,47 @@ function buildServerJson(name) {
|
|
|
102
104
|
return `${JSON.stringify(manifest, null, 2)}
|
|
103
105
|
`;
|
|
104
106
|
}
|
|
107
|
+
// DOC.md, for `ahood doc init` (ahood-cli#182). The backend's publish
|
|
108
|
+
// validator (lib/publish/parse-doc-frontmatter.ts in the ahood repo) is the
|
|
109
|
+
// same hand-rolled flat `key: value` reader as AGENT.md's, minus the skills
|
|
110
|
+
// list: the file must START with a `---` line, the closing `---` line must be
|
|
111
|
+
// followed by a newline, and `name` and `description` must be non-empty. So
|
|
112
|
+
// the same rules as buildAgentMd apply: nothing above the opening `---`, and
|
|
113
|
+
// no colon in a comment line inside the frontmatter.
|
|
114
|
+
function buildDocMd(name) {
|
|
115
|
+
return `---
|
|
116
|
+
name: ${name}
|
|
117
|
+
description: TODO -- one line saying what this doc covers and when an agent should read it.
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
# ${name}
|
|
121
|
+
|
|
122
|
+
TODO -- write the documentation here. Plain Markdown; this whole file, frontmatter
|
|
123
|
+
included, is what \`ahood doc read\` prints and \`ahood doc add\` saves.
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
// PROJECT.md, for `ahood project init` (ahood-cli#185). The backend's
|
|
127
|
+
// validator (lib/publish/parse-project-frontmatter.ts) is DOC.md's flat
|
|
128
|
+
// reader plus one optional field, `project`: a git remote, normalized there
|
|
129
|
+
// to host/owner/repo. It is written already normalized, or left out.
|
|
130
|
+
function buildProjectMd(name, project) {
|
|
131
|
+
return `---
|
|
132
|
+
name: ${name}
|
|
133
|
+
description: TODO -- one line saying which repository this guide is for and when an agent should read it.
|
|
134
|
+
${project ? `project: ${project}\n` : ""}---
|
|
135
|
+
|
|
136
|
+
# ${name}
|
|
137
|
+
|
|
138
|
+
TODO -- how to work in this repository: conventions, how to verify a change, and the
|
|
139
|
+
traps. Plain Markdown; \`ahood project add\` saves this whole file to .claude/project.md.
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
105
142
|
const TEMPLATES = {
|
|
106
143
|
skill: buildSkillMd,
|
|
107
144
|
agent: buildAgentMd,
|
|
108
145
|
mcp: buildServerJson,
|
|
146
|
+
doc: buildDocMd,
|
|
147
|
+
project: buildProjectMd,
|
|
109
148
|
};
|
|
110
149
|
const NEXT_STEPS = {
|
|
111
150
|
skill: ["Fill in the description, then flesh out the ## Instructions section."],
|
|
@@ -115,7 +154,34 @@ const NEXT_STEPS = {
|
|
|
115
154
|
'"remotes" with one npm package: "packages": [{"registry_type": "npm", "runtime_hint": "npx",',
|
|
116
155
|
'"identifier": "<npm-package>", "version": "<x.y.z>"}] (plus optional "environment_variables").',
|
|
117
156
|
],
|
|
157
|
+
doc: ["Fill in the description, then write the doc below the frontmatter."],
|
|
158
|
+
project: ["Fill in the description, then write the guide below the frontmatter."],
|
|
118
159
|
};
|
|
160
|
+
function parseProjectFlags(args) {
|
|
161
|
+
const raw = flagValue(args, "--project");
|
|
162
|
+
const skip = args.includes("--no-project");
|
|
163
|
+
if (skip && raw !== undefined)
|
|
164
|
+
throw new UsageError("Pass --project <remote> or --no-project, not both.");
|
|
165
|
+
const rest = args.filter((a, i) => a !== "--no-project" && a !== "--project" && !a.startsWith("--project=") && args[i - 1] !== "--project");
|
|
166
|
+
if (raw === undefined)
|
|
167
|
+
return { rest, skip };
|
|
168
|
+
const normalized = normalizeProjectRemote(raw);
|
|
169
|
+
if (!normalized.ok)
|
|
170
|
+
throw new UsageError(`--project is ${normalized.reason}.`);
|
|
171
|
+
return { rest, explicit: normalized.value, skip };
|
|
172
|
+
}
|
|
173
|
+
// The field's value: the flags' choice, else this repository's origin. Only
|
|
174
|
+
// the normalized remote is ever printed or written.
|
|
175
|
+
function resolveProjectField(flags) {
|
|
176
|
+
if (flags.skip)
|
|
177
|
+
return undefined;
|
|
178
|
+
if (flags.explicit !== undefined)
|
|
179
|
+
return flags.explicit;
|
|
180
|
+
const detected = detectProjectRemote();
|
|
181
|
+
if (detected)
|
|
182
|
+
console.log(`Filled project: ${detected} (from \`git remote get-url origin\`; pass --no-project to leave it out).`);
|
|
183
|
+
return detected;
|
|
184
|
+
}
|
|
119
185
|
// Falls back to "my-skill" when the directory name itself isn't usable as a
|
|
120
186
|
// bare YAML scalar (empty, or starting with a character like "-" or "@" that
|
|
121
187
|
// would need quoting) -- e.g. running `ahood skill init` with no name directly in
|
|
@@ -127,6 +193,9 @@ function skillNameFor(dirPath, fallback = "my-skill") {
|
|
|
127
193
|
export async function init(args, scope) {
|
|
128
194
|
const strict = strictKind(scope);
|
|
129
195
|
const kind = strict ?? "skill";
|
|
196
|
+
const projectFlags = kind === "project" ? parseProjectFlags(args) : undefined;
|
|
197
|
+
if (projectFlags)
|
|
198
|
+
args = projectFlags.rest;
|
|
130
199
|
let name = args[0];
|
|
131
200
|
if (name !== undefined && name.startsWith("-"))
|
|
132
201
|
throw new UsageError(USAGE);
|
|
@@ -175,11 +244,12 @@ export async function init(args, scope) {
|
|
|
175
244
|
}
|
|
176
245
|
}
|
|
177
246
|
}
|
|
247
|
+
const project = projectFlags ? resolveProjectField(projectFlags) : undefined;
|
|
178
248
|
mkdirSync(targetDir, { recursive: true });
|
|
179
|
-
writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`)));
|
|
249
|
+
writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`), project));
|
|
180
250
|
console.log(`Created ${docPath}`);
|
|
181
251
|
for (const line of NEXT_STEPS[kind])
|
|
182
252
|
console.log(line);
|
|
183
|
-
const placeholder = kind
|
|
253
|
+
const placeholder = KINDS[kind].placeholder;
|
|
184
254
|
console.log(`Run \`ahood ${strict ?? "skill"} publish <owner>/${placeholder}@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
|
|
185
255
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bare `ahood project` (ahood-cli#185): the project guides that name this
|
|
2
|
+
// repository's git remote, via the hosted get_project_guide tool. Read-only:
|
|
3
|
+
// nothing is installed or written. The remote is normalized before it is
|
|
4
|
+
// sent or printed, so a token in origin's URL never leaves this machine.
|
|
5
|
+
import { flagValue } from "../flags.js";
|
|
6
|
+
import { callHostedTool } from "../hosted-mcp.js";
|
|
7
|
+
import { detectProjectRemote, normalizeProjectRemote } from "../project-remote.js";
|
|
8
|
+
import { sanitizeForTerminal } from "../terminal-safe.js";
|
|
9
|
+
import { UsageError } from "../usage-error.js";
|
|
10
|
+
const USAGE = "Usage: ahood project [--remote <git-remote>] [--json]";
|
|
11
|
+
function resolveRemote(args) {
|
|
12
|
+
const explicit = flagValue(args, "--remote");
|
|
13
|
+
if (explicit !== undefined) {
|
|
14
|
+
const normalized = normalizeProjectRemote(explicit);
|
|
15
|
+
if (!normalized.ok)
|
|
16
|
+
throw new UsageError(`--remote is ${normalized.reason}.\n${USAGE}`);
|
|
17
|
+
return normalized.value;
|
|
18
|
+
}
|
|
19
|
+
const detected = detectProjectRemote();
|
|
20
|
+
if (!detected) {
|
|
21
|
+
throw new UsageError(`No git remote found: this is not a git repository with an "origin" remote ahood can read. Pass --remote <git-remote>.\n${USAGE}`);
|
|
22
|
+
}
|
|
23
|
+
return detected;
|
|
24
|
+
}
|
|
25
|
+
export async function findProjectGuides(args) {
|
|
26
|
+
const unknown = args.filter((a, i) => a.startsWith("-") && a !== "--json" && a !== "--remote" && !a.startsWith("--remote=") && args[i - 1] !== "--remote");
|
|
27
|
+
if (unknown.length > 0)
|
|
28
|
+
throw new UsageError(`Unknown flag ${unknown[0]}.\n${USAGE}`);
|
|
29
|
+
const remote = resolveRemote(args);
|
|
30
|
+
const result = await callHostedTool("get_project_guide", { remote });
|
|
31
|
+
if (args.includes("--json")) {
|
|
32
|
+
console.log(JSON.stringify(result));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (result.guides.length === 0) {
|
|
36
|
+
console.log(`No project guides found for ${remote}. Write one with \`ahood project init\`.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(`Project guides for ${remote}:`);
|
|
40
|
+
for (const g of result.guides) {
|
|
41
|
+
const tagline = g.tagline ? ` -- ${sanitizeForTerminal(g.tagline)}` : "";
|
|
42
|
+
const mine = g.mine ? " (yours)" : "";
|
|
43
|
+
console.log(` ${g.owner}/${g.slug}@${g.version} ${sanitizeForTerminal(g.name)}${tagline} [${g.visibility}]${mine}`);
|
|
44
|
+
}
|
|
45
|
+
const first = result.guides[0];
|
|
46
|
+
console.log(`Install one with \`ahood project add ${first.owner}/${first.slug}\` (saved to .claude/project.md).`);
|
|
47
|
+
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -6,7 +6,7 @@ import { apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
|
|
|
6
6
|
import { flagValue } from "../flags.js";
|
|
7
7
|
import { parseOwnerSkill, SEMVER_RE, validateExternalUrl } from "../spec.js";
|
|
8
8
|
import { UsageError } from "../usage-error.js";
|
|
9
|
-
import { assertKind, strictKind } from "../kinds.js";
|
|
9
|
+
import { assertKind, CLI_KINDS, isCliKind, KINDS, strictKind } from "../kinds.js";
|
|
10
10
|
// `ahood agent publish` / `ahood mcp publish` (ahood-cli#172): an existing
|
|
11
11
|
// entry of another kind must be refused before anything is packed, created,
|
|
12
12
|
// or uploaded. The upload would fail server-side anyway -- the processing
|
|
@@ -47,7 +47,7 @@ async function pollVersionStatus(owner, skill, version) {
|
|
|
47
47
|
}
|
|
48
48
|
throw new Error(`Still processing after ${POLL_TIMEOUT_MS / 1000}s -- check \`ahood skill view ${owner}/${skill}\` later for the result.`);
|
|
49
49
|
}
|
|
50
|
-
const USAGE = "Usage: ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]\n" +
|
|
50
|
+
const USAGE = "Usage: ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp|doc|project] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]\n" +
|
|
51
51
|
" or: ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z> [--json]";
|
|
52
52
|
// Matched by ENTRY NAME at every depth, not by path prefix, so a nested
|
|
53
53
|
// `vendor/thing/.git` is skipped the same as a top-level one. Not a
|
|
@@ -264,8 +264,8 @@ function parsePublishArgs(args) {
|
|
|
264
264
|
if (!SEMVER_RE.test(version)) {
|
|
265
265
|
throw new UsageError(`--version must be a semver like 1.2.3 (got "${version}").\n${USAGE}`);
|
|
266
266
|
}
|
|
267
|
-
if (kind !== undefined && kind
|
|
268
|
-
throw new UsageError(`--kind must be
|
|
267
|
+
if (kind !== undefined && !isCliKind(kind)) {
|
|
268
|
+
throw new UsageError(`--kind must be ${orList(CLI_KINDS.map((k) => `"${k}"`))} (got "${kind}").\n${USAGE}`);
|
|
269
269
|
}
|
|
270
270
|
// Validated here, before any network call (including the tar/gzip of the
|
|
271
271
|
// skill directory) -- these only ever reach the server via
|
|
@@ -355,6 +355,33 @@ async function initVersion(owner, skill, version, packageSizeBytes, changelog, c
|
|
|
355
355
|
return { init, created };
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
|
+
// "a, b, or c" -- for messages that enumerate every kind.
|
|
359
|
+
function orList(items) {
|
|
360
|
+
return items.length <= 2 ? items.join(" or ") : `${items.slice(0, -1).join(", ")}, or ${items.at(-1)}`;
|
|
361
|
+
}
|
|
362
|
+
// Which kind a publish folder is, from the root docs it holds (KINDS[k].
|
|
363
|
+
// rootDoc), so a new kind needs no branch here. An explicit kind must have
|
|
364
|
+
// its root doc. With none, exactly one root doc must be present; SKILL.md
|
|
365
|
+
// alone returns undefined, the pre-kind behaviour (the server defaults to
|
|
366
|
+
// skill). Inference: agent/mcp (ahood-cli#172), doc (#182), project (#185).
|
|
367
|
+
function resolvePublishKind(path, kind, via) {
|
|
368
|
+
if (kind !== undefined) {
|
|
369
|
+
const rootDocPath = join(path, KINDS[kind].rootDoc);
|
|
370
|
+
if (!existsSync(rootDocPath)) {
|
|
371
|
+
throw new Error(`No ${KINDS[kind].rootDoc} found at ${rootDocPath} -- ${via} must point at a folder containing ${KINDS[kind].rootDoc}.`);
|
|
372
|
+
}
|
|
373
|
+
return kind;
|
|
374
|
+
}
|
|
375
|
+
const present = CLI_KINDS.filter((k) => existsSync(join(path, KINDS[k].rootDoc)));
|
|
376
|
+
const rootDocs = CLI_KINDS.map((k) => KINDS[k].rootDoc);
|
|
377
|
+
if (present.length > 1) {
|
|
378
|
+
throw new Error(`Multiple root files found at ${path} (${present.map((k) => KINDS[k].rootDoc).join(", ")}) -- pass ${orList(CLI_KINDS.map((k) => `--kind ${k}`))} to disambiguate.`);
|
|
379
|
+
}
|
|
380
|
+
if (present.length === 0) {
|
|
381
|
+
throw new Error(`No ${orList(rootDocs)} found at ${path} -- publish must point at a ${orList(CLI_KINDS)} folder's root.`);
|
|
382
|
+
}
|
|
383
|
+
return present[0] === "skill" ? undefined : present[0];
|
|
384
|
+
}
|
|
358
385
|
// --json mode (ahood-cli#57): a CI/script caller needs one machine-readable
|
|
359
386
|
// object on stdout, not the human progress lines below scraped with a
|
|
360
387
|
// regex. jsonOutput therefore gates every intermediate console.log in this
|
|
@@ -374,42 +401,9 @@ export async function publish(args, scope) {
|
|
|
374
401
|
const scopedKind = strictKind(scope);
|
|
375
402
|
const kind = scopedKind ?? parsed.kind;
|
|
376
403
|
const via = scopedKind ? `ahood ${scope.noun} publish` : `publish --kind ${kind}`;
|
|
377
|
-
const skillMdPath = join(path,
|
|
378
|
-
const agentMdPath = join(path, "AGENT.md");
|
|
379
|
-
const serverJsonPath = join(path, "server.json");
|
|
404
|
+
const skillMdPath = join(path, KINDS.skill.rootDoc);
|
|
380
405
|
const hasSkillMd = existsSync(skillMdPath);
|
|
381
|
-
const
|
|
382
|
-
const hasServerJson = existsSync(serverJsonPath);
|
|
383
|
-
let resolvedKind = kind;
|
|
384
|
-
if (kind === "agent") {
|
|
385
|
-
if (!hasAgentMd)
|
|
386
|
-
throw new Error(`No AGENT.md found at ${agentMdPath} -- ${via} must point at a folder containing AGENT.md.`);
|
|
387
|
-
}
|
|
388
|
-
else if (kind === "skill") {
|
|
389
|
-
if (!hasSkillMd)
|
|
390
|
-
throw new Error(`No SKILL.md found at ${skillMdPath} -- ${via} must point at a folder containing SKILL.md.`);
|
|
391
|
-
}
|
|
392
|
-
else if (kind === "mcp") {
|
|
393
|
-
if (!hasServerJson)
|
|
394
|
-
throw new Error(`No server.json found at ${serverJsonPath} -- ${via} must point at a folder containing server.json.`);
|
|
395
|
-
}
|
|
396
|
-
else {
|
|
397
|
-
// kind === undefined -- infer from whichever root file(s) are actually present
|
|
398
|
-
const present = [hasSkillMd && "SKILL.md", hasAgentMd && "AGENT.md", hasServerJson && "server.json"].filter(Boolean);
|
|
399
|
-
if (present.length > 1) {
|
|
400
|
-
throw new Error(`Multiple root files found at ${path} (${present.join(", ")}) -- pass --kind skill, --kind agent, or --kind mcp to disambiguate.`);
|
|
401
|
-
}
|
|
402
|
-
if (present.length === 0) {
|
|
403
|
-
throw new Error(`No SKILL.md, AGENT.md, or server.json found at ${path} -- publish must point at a skill, agent, or mcp folder's root.`);
|
|
404
|
-
}
|
|
405
|
-
if (hasAgentMd) {
|
|
406
|
-
resolvedKind = "agent"; // inferred: only AGENT.md present, no --kind needed
|
|
407
|
-
}
|
|
408
|
-
else if (hasServerJson) {
|
|
409
|
-
resolvedKind = "mcp"; // inferred: only server.json present, no --kind needed
|
|
410
|
-
}
|
|
411
|
-
// else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
|
|
412
|
-
}
|
|
406
|
+
const resolvedKind = resolvePublishKind(path, kind, via);
|
|
413
407
|
// ahood-cli#93: fires for an explicit --kind skill, and for the
|
|
414
408
|
// inferred-skill case above (resolvedKind left undefined because
|
|
415
409
|
// SKILL.md was the only root file present) -- but not when SKILL.md
|