@ahood/cli 0.11.0 → 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 +35 -6
- package/dist/commands/add.js +47 -30
- package/dist/commands/completion.js +6 -5
- package/dist/commands/init.js +51 -3
- package/dist/commands/project-guides.js +47 -0
- package/dist/commands/publish.js +33 -53
- package/dist/commands/remove.js +11 -9
- package/dist/commands/update.js +6 -6
- package/dist/help.js +65 -27
- package/dist/hosted-mcp.js +36 -0
- package/dist/index.js +21 -7
- package/dist/kind-info.js +34 -2
- package/dist/kinds.js +7 -7
- package/dist/project-remote.js +80 -0
- package/dist/spec.js +9 -0
- package/dist/useme/SKILL.md +16 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -189,9 +189,9 @@ Shared alice/pdf-tools with design-team.
|
|
|
189
189
|
Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private visibility, it just makes it visible to everyone in `design-team` too. On the other side, whoever received the link runs `ahood group join <the link>` to join.
|
|
190
190
|
</details>
|
|
191
191
|
|
|
192
|
-
## Kinds: skills, agents, MCP servers, and
|
|
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
|
| --- | --- | --- | --- |
|
|
@@ -199,6 +199,7 @@ The registry holds four kinds of artifact, each with its own command group. All
|
|
|
199
199
|
| agent | `AGENT.md` | `.claude/agents/<owner>@<agent>.md` | `ahood agent <verb>` |
|
|
200
200
|
| mcp | `server.json` | an entry merged into `.mcp.json` | `ahood mcp <verb>` |
|
|
201
201
|
| doc | `DOC.md` | `.claude/docs/<owner>@<doc>.md` | `ahood doc <verb>` |
|
|
202
|
+
| project | `PROJECT.md` | `.claude/project.md` (one per repository) | `ahood project <verb>` |
|
|
202
203
|
|
|
203
204
|
```
|
|
204
205
|
$ ahood agent search review
|
|
@@ -208,14 +209,18 @@ $ ahood mcp init github-server # ./github-server/server.json, a safe pl
|
|
|
208
209
|
$ ahood mcp add alice/github-server
|
|
209
210
|
$ ahood doc read alice/setup-guide # print a doc without installing it
|
|
210
211
|
$ ahood doc add alice/setup-guide # ./.claude/docs/alice@setup-guide.md, pinned
|
|
212
|
+
$ ahood project # project guides for this repo's git remote
|
|
213
|
+
$ ahood project add alice/acme-api # ./.claude/project.md, pinned
|
|
214
|
+
$ ahood project init acme-api # ./acme-api/PROJECT.md, `project:` filled from git origin
|
|
211
215
|
$ ahood agent add alice/pdf-tools
|
|
212
216
|
alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood skill add alice/pdf-tools` instead.
|
|
213
217
|
```
|
|
214
218
|
|
|
215
|
-
- **`ahood agent`, `ahood mcp` and `ahood
|
|
216
|
-
- **Missing or unexpected kind metadata is refused, never guessed.** If the registry's answer carries no kind (or one this CLI doesn't handle), a kind-scoped command stops with exit `1` and points at the legacy form. `remove` reads the kind from the project's own files (the skill directory, the agent file, the mcp fingerprint in the lockfile, or a doc pin's recorded `kind`) and asks the registry only when those can't tell; if neither can, nothing is removed.
|
|
217
|
-
- **`ahood skill` is legacy and cross-kind in this release.** `ahood skill search` and `ahood skill list` include every kind, and `ahood skill add`/`update`/`remove`/... act on whatever kind the target is, exactly as before -- including `--json` output and exit codes -- so existing scripts keep working. The
|
|
219
|
+
- **`ahood agent`, `ahood mcp`, `ahood doc` and `ahood project` are strict.** A target of another kind is refused -- exit code `2`, naming the kind it found -- before anything is downloaded, prompted for, written, or sent. That covers `add`, `update`, `remove`, `edit`, `unpublish`, `share`/`unshare`, `star`/`unstar`, and `publish` (an existing entry of another kind is refused before anything is packed or uploaded); the read-only verbs refuse too, rather than show the wrong kind. `search` asks the registry for that kind only, `list` keeps only that kind of your own entries, and `update`/`outdated` with no argument consider only installed pins of that kind. `agent publish`/`mcp publish`/`doc publish`/`project publish` imply their kind and reject a contradictory `--kind`.
|
|
220
|
+
- **Missing or unexpected kind metadata is refused, never guessed.** If the registry's answer carries no kind (or one this CLI doesn't handle), a kind-scoped command stops with exit `1` and points at the legacy form. `remove` reads the kind from the project's own files (the skill directory, the agent file, the mcp fingerprint in the lockfile, or a doc or project pin's recorded `kind`) and asks the registry only when those can't tell; if neither can, nothing is removed.
|
|
221
|
+
- **`ahood skill` is legacy and cross-kind in this release.** `ahood skill search` and `ahood skill list` include every kind, and `ahood skill add`/`update`/`remove`/... act on whatever kind the target is, exactly as before -- including `--json` output and exit codes -- so existing scripts keep working. The exceptions are a doc and a project guide: `ahood skill add` refuses them and points at `ahood doc add` / `ahood project add`. Add `--kind skill|agent|mcp|doc|project` to any `ahood skill` verb (except `publish`, whose `--kind` keeps its original meaning) to scope it, or `--kind all` to spell out the cross-kind behavior.
|
|
218
222
|
- **Docs install as one pinned file.** `ahood doc add` saves a doc's `DOC.md` to `.claude/docs/<owner>@<doc>.md` and pins it in `.claude/skills.lock.json` with `"kind": "doc"`. It refuses to overwrite a file at that path that isn't pinned as that doc. `ahood doc update`/`outdated`/`remove` manage those pins like any other kind. Kind dispatch is table-driven (`src/kind-info.ts`).
|
|
223
|
+
- **A project guide is one pinned file per repository.** `ahood project add` saves the guide's `PROJECT.md` to `.claude/project.md` and pins it with `"kind": "project"`. It never touches `CLAUDE.md` or `AGENTS.md` (point those at `.claude/project.md` yourself), refuses to overwrite an unpinned `.claude/project.md`, and refuses a second guide while another is pinned (`ahood project remove` it first). `ahood project init` fills the optional `project` frontmatter field from `git remote get-url origin`, normalized to `host/owner/repo` exactly as the registry does (credentials stripped; `--project <remote>` or `--no-project` to override). Bare `ahood project` lists the guides you can see for this repository's remote, through the hosted `get_project_guide` MCP tool.
|
|
219
224
|
|
|
220
225
|
**Migration plan for a strict `ahood skill`.** A future major version will make `ahood skill <verb>` skills-only by default, matching `agent` and `mcp`. Until then: (1) this release adds the strict groups and `--kind`, and labels `ahood skill` as legacy in `--help`; (2) a following minor release prints a stderr deprecation note when an `ahood skill` command actually touches a non-skill entry (stdout and `--json` unchanged); (3) the major release flips the default, keeping `ahood skill <verb> --kind all` as the explicit cross-kind escape hatch. Scripts that want today's behavior forever can pass `--kind all` now.
|
|
221
226
|
|
|
@@ -260,7 +265,7 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
|
|
|
260
265
|
| `ahood skill share <owner>/<skill> --group <group>` | Share a skill you own with a group, without changing its public/private visibility. |
|
|
261
266
|
| `ahood skill unshare <owner>/<skill> --group <group>` | Stop sharing a skill you own with a group. |
|
|
262
267
|
| `ahood skill init [name]` | Scaffold a new skill folder with a minimal, valid SKILL.md. |
|
|
263
|
-
| `ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill\|agent\|mcp\|doc] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a skill, agent, mcp server manifest, or
|
|
268
|
+
| `ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill\|agent\|mcp\|doc\|project] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a skill, agent, mcp server manifest, doc, or project guide from a folder containing SKILL.md, AGENT.md, server.json, DOC.md, or PROJECT.md, creating the skill first if it doesn't already exist. |
|
|
264
269
|
|
|
265
270
|
### Agent
|
|
266
271
|
|
|
@@ -333,6 +338,30 @@ alice/pdf-tools is a skill, not an agent -- refusing to install it. Use `ahood s
|
|
|
333
338
|
| `ahood doc init [name]` | Scaffold a new doc folder with a minimal, valid DOC.md. |
|
|
334
339
|
| `ahood doc publish <owner>/<doc>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a doc from a folder containing DOC.md, creating it first if it doesn't exist yet. |
|
|
335
340
|
|
|
341
|
+
### Project
|
|
342
|
+
|
|
343
|
+
| Command | What it does |
|
|
344
|
+
| --- | --- |
|
|
345
|
+
| `ahood project [--remote <git-remote>] [--json]` | List the project guides you can see for this repository's git remote. |
|
|
346
|
+
| `ahood project search <query> [--json] [--limit <n>]` | Search published project guides. |
|
|
347
|
+
| `ahood project view\|show <owner>/<slug> [--json] [--web]` | Show a single project guide's details without installing it (alias: ahood project show). |
|
|
348
|
+
| `ahood project read <owner>/<slug> [--json]` | Print a published project guide's PROJECT.md, without installing it. |
|
|
349
|
+
| `ahood project versions <owner>/<slug> [--json]` | List a project guide's published-version history. |
|
|
350
|
+
| `ahood project diff <owner>/<slug> <versionA> <versionB> [--json]` | Show what changed between two published versions of a project guide. |
|
|
351
|
+
| `ahood project list [--json]` | List your own project guides, public and private. |
|
|
352
|
+
| `ahood project add <owner>/<slug>[@version]` | Install a project guide into .claude/project.md, pinned in the lockfile. |
|
|
353
|
+
| `ahood project update [<owner>/<slug> ...] [--dry-run] [--json]` | Move project guide pins forward to the latest version. |
|
|
354
|
+
| `ahood project outdated [<owner>/<slug> ...] [--json]` | Read-only staleness check for installed project guides. |
|
|
355
|
+
| `ahood project remove <owner>/<slug> [--yes]` | Uninstall and unpin a project guide (local only, prompts unless --yes is passed). |
|
|
356
|
+
| `ahood project edit <owner>/<slug> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a project guide you own, changing only the flags you pass. |
|
|
357
|
+
| `ahood project unpublish <owner>/<slug>[@version] [--yes]` | Delete a project guide from the registry for every consumer, or yank one version (prompts unless --yes is passed). |
|
|
358
|
+
| `ahood project star <owner>/<slug>` | Star a project guide. |
|
|
359
|
+
| `ahood project unstar <owner>/<slug>` | Remove your star from a project guide. |
|
|
360
|
+
| `ahood project share <owner>/<slug> --group <group>` | Share a project guide you own with a group, without changing its visibility. |
|
|
361
|
+
| `ahood project unshare <owner>/<slug> --group <group>` | Stop sharing a project guide you own with a group. |
|
|
362
|
+
| `ahood project init [name] [--project <git-remote> \| --no-project]` | Scaffold a new project guide folder with a minimal, valid PROJECT.md. |
|
|
363
|
+
| `ahood project publish <owner>/<slug>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]` | Publish a new version of a project guide from a folder containing PROJECT.md, creating it first if it doesn't exist yet. |
|
|
364
|
+
|
|
336
365
|
### Group
|
|
337
366
|
|
|
338
367
|
| Command | What it does |
|
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,
|
|
7
|
+
import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, pinnedFilePath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
|
|
8
8
|
import { readLockfile, withLock, writeFileAtomic, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
|
|
9
9
|
import { promptSecret } from "../secret-prompt.js";
|
|
10
10
|
import { sanitizeForTerminal } from "../terminal-safe.js";
|
|
11
11
|
import { UsageError } from "../usage-error.js";
|
|
12
|
-
import { assertKind, strictKind } 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,26 +865,27 @@ export async function downloadVerifiedArchive(owner, skill, meta) {
|
|
|
865
865
|
}
|
|
866
866
|
return buffer;
|
|
867
867
|
}
|
|
868
|
-
// `ahood doc add` (ahood-cli#182)
|
|
869
|
-
//
|
|
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
|
|
870
871
|
// remove/update can tell the pin apart without asking the registry. If the
|
|
871
872
|
// pin can't be written, the file goes back to what it was: the previous
|
|
872
873
|
// version's content on an update, nothing on a fresh install.
|
|
873
|
-
async function
|
|
874
|
+
async function installPinnedFile(kind, owner, skill, meta, buffer) {
|
|
874
875
|
const key = `${owner}/${skill}`;
|
|
875
|
-
const content = await extractSingleFileContent(buffer,
|
|
876
|
-
const destPath =
|
|
876
|
+
const content = await extractSingleFileContent(buffer, KINDS[kind].rootDoc);
|
|
877
|
+
const destPath = pinnedFilePath(kind, owner, skill);
|
|
877
878
|
const previous = existsSync(destPath) ? readFileSync(destPath) : undefined;
|
|
878
879
|
writeFileAtomic(destPath, content);
|
|
879
880
|
try {
|
|
880
881
|
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
|
|
881
882
|
version: meta.version,
|
|
882
883
|
checksum_sha256: meta.checksum_sha256,
|
|
883
|
-
kind
|
|
884
|
+
kind,
|
|
884
885
|
});
|
|
885
886
|
}
|
|
886
887
|
catch (error) {
|
|
887
|
-
rollbackOrWarn(() => (previous === undefined ? rmSync(destPath, { force: true }) : writeFileAtomic(destPath, previous)), `WARNING: could not restore ${destPath} after ${key}'s install failed -- it may not match its ${LOCKFILE_PATH} pin. Run \`ahood
|
|
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.`);
|
|
888
889
|
if (error instanceof LockfileChecksumConflictError) {
|
|
889
890
|
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
890
891
|
}
|
|
@@ -892,6 +893,27 @@ async function installDoc(owner, skill, meta, buffer) {
|
|
|
892
893
|
}
|
|
893
894
|
console.log(`Installed ${key}@${meta.version} to ${destPath}`);
|
|
894
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
|
+
}
|
|
895
917
|
export async function add(args, scope) {
|
|
896
918
|
const spec = args[0];
|
|
897
919
|
if (!spec)
|
|
@@ -903,25 +925,20 @@ export async function add(args, scope) {
|
|
|
903
925
|
// refused here, before the download, the lockfile and every write below.
|
|
904
926
|
// Both metadata routes report the entry's kind, so this costs no request.
|
|
905
927
|
assertKind(scope, key, meta.kind, "add", "install");
|
|
906
|
-
// Legacy cross-kind `ahood skill add` of a doc
|
|
907
|
-
// it used to fall through to the skill path
|
|
908
|
-
// .claude/skills/<owner>@<slug>/ with no SKILL.md --
|
|
909
|
-
// with nothing telling the user.
|
|
910
|
-
// (`ahood doc add`, ahood
|
|
911
|
-
//
|
|
912
|
-
// kind keep the legacy
|
|
913
|
-
const
|
|
914
|
-
if (
|
|
915
|
-
throw new UsageError(`${key} is
|
|
916
|
-
`Install it with \`ahood
|
|
917
|
-
}
|
|
918
|
-
// No-overwrite: a file already at the doc's path that this project's
|
|
919
|
-
// lockfile does not pin as this doc was not written by ahood, so it is
|
|
920
|
-
// left alone. Checked before the download, like every refusal above.
|
|
921
|
-
if (docInstall && existsSync(docPath(owner, skill)) && readLockfile(LOCKFILE_PATH)[key]?.kind !== "doc") {
|
|
922
|
-
throw new Error(`${docPath(owner, skill)} already exists and is not pinned as ${key} in ${LOCKFILE_PATH} -- refusing to overwrite it. ` +
|
|
923
|
-
"Move or delete it first.");
|
|
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}\`.`);
|
|
924
939
|
}
|
|
940
|
+
if (pinnedKind !== undefined)
|
|
941
|
+
assertPinnedFileIsOurs(pinnedKind, owner, skill);
|
|
925
942
|
// A lockfile entry records the checksum this exact version was installed
|
|
926
943
|
// with; if a later fetch of the "same" version disagrees, either the
|
|
927
944
|
// published artifact changed after being pinned or the registry response
|
|
@@ -947,7 +964,7 @@ export async function add(args, scope) {
|
|
|
947
964
|
// manifest can never actually land on disk there -- the warning would be
|
|
948
965
|
// both mislabeled ("skill") and about a risk that can't materialize
|
|
949
966
|
// (ahood-cli final review finding #5).
|
|
950
|
-
if (meta.kind !== "agent" && meta.kind !== "mcp" &&
|
|
967
|
+
if (meta.kind !== "agent" && meta.kind !== "mcp" && pinnedKind === undefined && meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
|
|
951
968
|
console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
|
|
952
969
|
}
|
|
953
970
|
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
@@ -980,8 +997,8 @@ export async function add(args, scope) {
|
|
|
980
997
|
await installMcpEntry(owner, skill, meta, buffer);
|
|
981
998
|
return;
|
|
982
999
|
}
|
|
983
|
-
if (
|
|
984
|
-
await
|
|
1000
|
+
if (pinnedKind !== undefined) {
|
|
1001
|
+
await installPinnedFile(pinnedKind, owner, skill, meta, buffer);
|
|
985
1002
|
return;
|
|
986
1003
|
}
|
|
987
1004
|
// Flat, owner@skill-joined directory name so Claude Code's own one-level-deep
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, AGENT_COMMANDS_HELP, MCP_COMMANDS_HELP, DOC_COMMANDS_HELP, GROUP_COMMANDS_HELP, SNAP_COMMANDS_HELP, COMMAND_ALIASES, } from "../help.js";
|
|
1
|
+
import { TOP_LEVEL_COMMANDS_HELP, SKILL_COMMANDS_HELP, AGENT_COMMANDS_HELP, MCP_COMMANDS_HELP, DOC_COMMANDS_HELP, PROJECT_COMMANDS_HELP, GROUP_COMMANDS_HELP, SNAP_COMMANDS_HELP, COMMAND_ALIASES, } from "../help.js";
|
|
2
2
|
import { UsageError } from "../usage-error.js";
|
|
3
3
|
const USAGE = "Usage: ahood completion <bash|zsh|fish>";
|
|
4
4
|
// Every entity noun with its own verb list, in the order completion offers
|
|
5
|
-
// them. Registry nouns (skill, agent, mcp -- ahood-cli#172; doc -- #182) come first; group
|
|
5
|
+
// them. Registry nouns (skill, agent, mcp -- ahood-cli#172; doc -- #182; project -- #185) come first; group
|
|
6
6
|
// and snap were already real nouns but had no completion at all before.
|
|
7
7
|
const NOUNS = [
|
|
8
8
|
["skill", SKILL_COMMANDS_HELP],
|
|
9
9
|
["agent", AGENT_COMMANDS_HELP],
|
|
10
10
|
["mcp", MCP_COMMANDS_HELP],
|
|
11
11
|
["doc", DOC_COMMANDS_HELP],
|
|
12
|
+
["project", PROJECT_COMMANDS_HELP],
|
|
12
13
|
["group", GROUP_COMMANDS_HELP],
|
|
13
14
|
["snap", SNAP_COMMANDS_HELP],
|
|
14
15
|
];
|
|
@@ -22,10 +23,10 @@ function topLevelNames() {
|
|
|
22
23
|
}
|
|
23
24
|
// Position 2+, once a noun has been typed: that noun's verbs, plus alias
|
|
24
25
|
// names (e.g. "show" for "view") wherever the aliased verb exists, so
|
|
25
|
-
// completion offers every name a user might type. A bare `ahood mcp`
|
|
26
|
-
// has no verb word and contributes nothing.
|
|
26
|
+
// completion offers every name a user might type. A bare `ahood mcp` or
|
|
27
|
+
// `ahood project [--remote ...]` entry has no verb word and contributes nothing.
|
|
27
28
|
function verbNames(entries) {
|
|
28
|
-
const primary = entries.map((c) => c.usage.split(" ")[2] ?? "");
|
|
29
|
+
const primary = entries.map((c) => c.usage.split(" ")[2] ?? "").filter((word) => /^[a-z]/.test(word));
|
|
29
30
|
const aliases = Object.keys(COMMAND_ALIASES).filter((alias) => primary.includes(COMMAND_ALIASES[alias]));
|
|
30
31
|
return unique([...primary, ...aliases]);
|
|
31
32
|
}
|
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
|
|
@@ -121,11 +123,28 @@ TODO -- write the documentation here. Plain Markdown; this whole file, frontmatt
|
|
|
121
123
|
included, is what \`ahood doc read\` prints and \`ahood doc add\` saves.
|
|
122
124
|
`;
|
|
123
125
|
}
|
|
126
|
+
// PROJECT.md, for `ahood project init` (ahood-cli#185). The backend's
|
|
127
|
+
// validator (lib/publish/parse-project-frontmatter.ts) is DOC.md's flat
|
|
128
|
+
// reader plus one optional field, `project`: a git remote, normalized there
|
|
129
|
+
// to host/owner/repo. It is written already normalized, or left out.
|
|
130
|
+
function buildProjectMd(name, project) {
|
|
131
|
+
return `---
|
|
132
|
+
name: ${name}
|
|
133
|
+
description: TODO -- one line saying which repository this guide is for and when an agent should read it.
|
|
134
|
+
${project ? `project: ${project}\n` : ""}---
|
|
135
|
+
|
|
136
|
+
# ${name}
|
|
137
|
+
|
|
138
|
+
TODO -- how to work in this repository: conventions, how to verify a change, and the
|
|
139
|
+
traps. Plain Markdown; \`ahood project add\` saves this whole file to .claude/project.md.
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
124
142
|
const TEMPLATES = {
|
|
125
143
|
skill: buildSkillMd,
|
|
126
144
|
agent: buildAgentMd,
|
|
127
145
|
mcp: buildServerJson,
|
|
128
146
|
doc: buildDocMd,
|
|
147
|
+
project: buildProjectMd,
|
|
129
148
|
};
|
|
130
149
|
const NEXT_STEPS = {
|
|
131
150
|
skill: ["Fill in the description, then flesh out the ## Instructions section."],
|
|
@@ -136,8 +155,33 @@ const NEXT_STEPS = {
|
|
|
136
155
|
'"identifier": "<npm-package>", "version": "<x.y.z>"}] (plus optional "environment_variables").',
|
|
137
156
|
],
|
|
138
157
|
doc: ["Fill in the description, then write the doc below the frontmatter."],
|
|
158
|
+
project: ["Fill in the description, then write the guide below the frontmatter."],
|
|
139
159
|
};
|
|
140
|
-
|
|
160
|
+
function parseProjectFlags(args) {
|
|
161
|
+
const raw = flagValue(args, "--project");
|
|
162
|
+
const skip = args.includes("--no-project");
|
|
163
|
+
if (skip && raw !== undefined)
|
|
164
|
+
throw new UsageError("Pass --project <remote> or --no-project, not both.");
|
|
165
|
+
const rest = args.filter((a, i) => a !== "--no-project" && a !== "--project" && !a.startsWith("--project=") && args[i - 1] !== "--project");
|
|
166
|
+
if (raw === undefined)
|
|
167
|
+
return { rest, skip };
|
|
168
|
+
const normalized = normalizeProjectRemote(raw);
|
|
169
|
+
if (!normalized.ok)
|
|
170
|
+
throw new UsageError(`--project is ${normalized.reason}.`);
|
|
171
|
+
return { rest, explicit: normalized.value, skip };
|
|
172
|
+
}
|
|
173
|
+
// The field's value: the flags' choice, else this repository's origin. Only
|
|
174
|
+
// the normalized remote is ever printed or written.
|
|
175
|
+
function resolveProjectField(flags) {
|
|
176
|
+
if (flags.skip)
|
|
177
|
+
return undefined;
|
|
178
|
+
if (flags.explicit !== undefined)
|
|
179
|
+
return flags.explicit;
|
|
180
|
+
const detected = detectProjectRemote();
|
|
181
|
+
if (detected)
|
|
182
|
+
console.log(`Filled project: ${detected} (from \`git remote get-url origin\`; pass --no-project to leave it out).`);
|
|
183
|
+
return detected;
|
|
184
|
+
}
|
|
141
185
|
// Falls back to "my-skill" when the directory name itself isn't usable as a
|
|
142
186
|
// bare YAML scalar (empty, or starting with a character like "-" or "@" that
|
|
143
187
|
// would need quoting) -- e.g. running `ahood skill init` with no name directly in
|
|
@@ -149,6 +193,9 @@ function skillNameFor(dirPath, fallback = "my-skill") {
|
|
|
149
193
|
export async function init(args, scope) {
|
|
150
194
|
const strict = strictKind(scope);
|
|
151
195
|
const kind = strict ?? "skill";
|
|
196
|
+
const projectFlags = kind === "project" ? parseProjectFlags(args) : undefined;
|
|
197
|
+
if (projectFlags)
|
|
198
|
+
args = projectFlags.rest;
|
|
152
199
|
let name = args[0];
|
|
153
200
|
if (name !== undefined && name.startsWith("-"))
|
|
154
201
|
throw new UsageError(USAGE);
|
|
@@ -197,11 +244,12 @@ export async function init(args, scope) {
|
|
|
197
244
|
}
|
|
198
245
|
}
|
|
199
246
|
}
|
|
247
|
+
const project = projectFlags ? resolveProjectField(projectFlags) : undefined;
|
|
200
248
|
mkdirSync(targetDir, { recursive: true });
|
|
201
|
-
writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`)));
|
|
249
|
+
writeFileSync(docPath, TEMPLATES[kind](name ?? skillNameFor(targetDir, `my-${kind}`), project));
|
|
202
250
|
console.log(`Created ${docPath}`);
|
|
203
251
|
for (const line of NEXT_STEPS[kind])
|
|
204
252
|
console.log(line);
|
|
205
|
-
const placeholder =
|
|
253
|
+
const placeholder = KINDS[kind].placeholder;
|
|
206
254
|
console.log(`Run \`ahood ${strict ?? "skill"} publish <owner>/${placeholder}@<version>${name ? ` --path ${name}` : ""}\` when ready.`);
|
|
207
255
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bare `ahood project` (ahood-cli#185): the project guides that name this
|
|
2
|
+
// repository's git remote, via the hosted get_project_guide tool. Read-only:
|
|
3
|
+
// nothing is installed or written. The remote is normalized before it is
|
|
4
|
+
// sent or printed, so a token in origin's URL never leaves this machine.
|
|
5
|
+
import { flagValue } from "../flags.js";
|
|
6
|
+
import { callHostedTool } from "../hosted-mcp.js";
|
|
7
|
+
import { detectProjectRemote, normalizeProjectRemote } from "../project-remote.js";
|
|
8
|
+
import { sanitizeForTerminal } from "../terminal-safe.js";
|
|
9
|
+
import { UsageError } from "../usage-error.js";
|
|
10
|
+
const USAGE = "Usage: ahood project [--remote <git-remote>] [--json]";
|
|
11
|
+
function resolveRemote(args) {
|
|
12
|
+
const explicit = flagValue(args, "--remote");
|
|
13
|
+
if (explicit !== undefined) {
|
|
14
|
+
const normalized = normalizeProjectRemote(explicit);
|
|
15
|
+
if (!normalized.ok)
|
|
16
|
+
throw new UsageError(`--remote is ${normalized.reason}.\n${USAGE}`);
|
|
17
|
+
return normalized.value;
|
|
18
|
+
}
|
|
19
|
+
const detected = detectProjectRemote();
|
|
20
|
+
if (!detected) {
|
|
21
|
+
throw new UsageError(`No git remote found: this is not a git repository with an "origin" remote ahood can read. Pass --remote <git-remote>.\n${USAGE}`);
|
|
22
|
+
}
|
|
23
|
+
return detected;
|
|
24
|
+
}
|
|
25
|
+
export async function findProjectGuides(args) {
|
|
26
|
+
const unknown = args.filter((a, i) => a.startsWith("-") && a !== "--json" && a !== "--remote" && !a.startsWith("--remote=") && args[i - 1] !== "--remote");
|
|
27
|
+
if (unknown.length > 0)
|
|
28
|
+
throw new UsageError(`Unknown flag ${unknown[0]}.\n${USAGE}`);
|
|
29
|
+
const remote = resolveRemote(args);
|
|
30
|
+
const result = await callHostedTool("get_project_guide", { remote });
|
|
31
|
+
if (args.includes("--json")) {
|
|
32
|
+
console.log(JSON.stringify(result));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (result.guides.length === 0) {
|
|
36
|
+
console.log(`No project guides found for ${remote}. Write one with \`ahood project init\`.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(`Project guides for ${remote}:`);
|
|
40
|
+
for (const g of result.guides) {
|
|
41
|
+
const tagline = g.tagline ? ` -- ${sanitizeForTerminal(g.tagline)}` : "";
|
|
42
|
+
const mine = g.mine ? " (yours)" : "";
|
|
43
|
+
console.log(` ${g.owner}/${g.slug}@${g.version} ${sanitizeForTerminal(g.name)}${tagline} [${g.visibility}]${mine}`);
|
|
44
|
+
}
|
|
45
|
+
const first = result.guides[0];
|
|
46
|
+
console.log(`Install one with \`ahood project add ${first.owner}/${first.slug}\` (saved to .claude/project.md).`);
|
|
47
|
+
}
|
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|doc] [--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,56 +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");
|
|
380
|
-
const docMdPath = join(path, "DOC.md");
|
|
404
|
+
const skillMdPath = join(path, KINDS.skill.rootDoc);
|
|
381
405
|
const hasSkillMd = existsSync(skillMdPath);
|
|
382
|
-
const
|
|
383
|
-
const hasServerJson = existsSync(serverJsonPath);
|
|
384
|
-
const hasDocMd = existsSync(docMdPath);
|
|
385
|
-
let resolvedKind = kind;
|
|
386
|
-
if (kind === "doc") {
|
|
387
|
-
if (!hasDocMd)
|
|
388
|
-
throw new Error(`No DOC.md found at ${docMdPath} -- ${via} must point at a folder containing DOC.md.`);
|
|
389
|
-
}
|
|
390
|
-
else if (kind === "agent") {
|
|
391
|
-
if (!hasAgentMd)
|
|
392
|
-
throw new Error(`No AGENT.md found at ${agentMdPath} -- ${via} must point at a folder containing AGENT.md.`);
|
|
393
|
-
}
|
|
394
|
-
else if (kind === "skill") {
|
|
395
|
-
if (!hasSkillMd)
|
|
396
|
-
throw new Error(`No SKILL.md found at ${skillMdPath} -- ${via} must point at a folder containing SKILL.md.`);
|
|
397
|
-
}
|
|
398
|
-
else if (kind === "mcp") {
|
|
399
|
-
if (!hasServerJson)
|
|
400
|
-
throw new Error(`No server.json found at ${serverJsonPath} -- ${via} must point at a folder containing server.json.`);
|
|
401
|
-
}
|
|
402
|
-
else {
|
|
403
|
-
// kind === undefined -- infer from whichever root file(s) are actually present
|
|
404
|
-
const present = [
|
|
405
|
-
hasSkillMd && "SKILL.md",
|
|
406
|
-
hasAgentMd && "AGENT.md",
|
|
407
|
-
hasServerJson && "server.json",
|
|
408
|
-
hasDocMd && "DOC.md",
|
|
409
|
-
].filter(Boolean);
|
|
410
|
-
if (present.length > 1) {
|
|
411
|
-
throw new Error(`Multiple root files found at ${path} (${present.join(", ")}) -- pass --kind skill, --kind agent, --kind mcp, or --kind doc to disambiguate.`);
|
|
412
|
-
}
|
|
413
|
-
if (present.length === 0) {
|
|
414
|
-
throw new Error(`No SKILL.md, AGENT.md, server.json, or DOC.md found at ${path} -- publish must point at a skill, agent, mcp, or doc folder's root.`);
|
|
415
|
-
}
|
|
416
|
-
if (hasAgentMd) {
|
|
417
|
-
resolvedKind = "agent"; // inferred: only AGENT.md present, no --kind needed
|
|
418
|
-
}
|
|
419
|
-
else if (hasServerJson) {
|
|
420
|
-
resolvedKind = "mcp"; // inferred: only server.json present, no --kind needed
|
|
421
|
-
}
|
|
422
|
-
else if (hasDocMd) {
|
|
423
|
-
resolvedKind = "doc"; // inferred: only DOC.md present (ahood-cli#182)
|
|
424
|
-
}
|
|
425
|
-
// else: hasSkillMd only -- resolvedKind stays undefined (unchanged existing behavior, server defaults to 'skill')
|
|
426
|
-
}
|
|
406
|
+
const resolvedKind = resolvePublishKind(path, kind, via);
|
|
427
407
|
// ahood-cli#93: fires for an explicit --kind skill, and for the
|
|
428
408
|
// inferred-skill case above (resolvedKind left undefined because
|
|
429
409
|
// SKILL.md was the only root file present) -- but not when SKILL.md
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "node:fs";
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
3
|
import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
|
|
4
|
-
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, docPath, MCP_CONFIG_PATH } from "../spec.js";
|
|
4
|
+
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, docPath, pinnedFilePath, MCP_CONFIG_PATH } from "../spec.js";
|
|
5
5
|
import { readMcpConfig, matchesMcpConfigHash } from "./add.js";
|
|
6
6
|
import { UsageError } from "../usage-error.js";
|
|
7
|
-
import { assertKind, ensureRemoteKind, KindMismatchError, describeKind, localInstalledKind, strictKind, } from "../kinds.js";
|
|
7
|
+
import { assertKind, ensureRemoteKind, KindMismatchError, describeKind, isPinnedFileKind, localInstalledKind, strictKind, } from "../kinds.js";
|
|
8
8
|
// `ahood agent remove` / `ahood mcp remove` (ahood-cli#172): decide whether
|
|
9
9
|
// this install really is the scope's kind BEFORE the prompt and every delete
|
|
10
10
|
// below. Local evidence first -- remove has always worked offline, and the
|
|
@@ -46,10 +46,12 @@ export async function remove(args, scope) {
|
|
|
46
46
|
// never a directory under .claude/skills/ -- distinct from the dir check above.
|
|
47
47
|
const agentFile = agentPath(owner, skill);
|
|
48
48
|
const agentExisted = existsSync(agentFile);
|
|
49
|
-
// A doc installs as one flat file under .claude/docs/ (ahood-cli#182).
|
|
50
|
-
const docFile = docPath(owner, skill);
|
|
51
|
-
const docExisted = existsSync(docFile);
|
|
52
49
|
const lockEntry = readLockfile(LOCKFILE_PATH)[key];
|
|
50
|
+
// A doc or project guide installs as one pinned file (ahood-cli#182, #185).
|
|
51
|
+
// A doc's path is derived from its own key, so it is found even without a
|
|
52
|
+
// pin; the project guide's path is shared, so only this key's pin claims it.
|
|
53
|
+
const pinnedFile = isPinnedFileKind(lockEntry?.kind) ? pinnedFilePath(lockEntry.kind, owner, skill) : docPath(owner, skill);
|
|
54
|
+
const pinnedFileExisted = existsSync(pinnedFile);
|
|
53
55
|
const hadLockfileEntry = lockEntry !== undefined;
|
|
54
56
|
// A pin whose install left nothing on disk is the defining shape of an
|
|
55
57
|
// mcp-kind install: its only footprint is this pin plus a live .mcp.json
|
|
@@ -58,8 +60,8 @@ export async function remove(args, scope) {
|
|
|
58
60
|
// before that field did (lockfile.ts: "absent for any mcp entry installed
|
|
59
61
|
// before this field existed") and it is computed HERE, above the .mcp.json
|
|
60
62
|
// read -- so it survives the case where that read throws (ahood-cli#143).
|
|
61
|
-
const hadNoOnDiskFootprint = !dirExisted && !agentExisted && !
|
|
62
|
-
if (!dirExisted && !agentExisted && !
|
|
63
|
+
const hadNoOnDiskFootprint = !dirExisted && !agentExisted && !pinnedFileExisted && !isPinnedFileKind(lockEntry?.kind) && hadLockfileEntry;
|
|
64
|
+
if (!dirExisted && !agentExisted && !pinnedFileExisted && !hadLockfileEntry) {
|
|
63
65
|
console.error(`${key} was not installed -- nothing to remove.`);
|
|
64
66
|
process.exitCode = 1;
|
|
65
67
|
return;
|
|
@@ -96,8 +98,8 @@ export async function remove(args, scope) {
|
|
|
96
98
|
rmSync(dir, { recursive: true, force: true });
|
|
97
99
|
if (agentExisted)
|
|
98
100
|
rmSync(agentFile, { force: true });
|
|
99
|
-
if (
|
|
100
|
-
rmSync(
|
|
101
|
+
if (pinnedFileExisted)
|
|
102
|
+
rmSync(pinnedFile, { force: true });
|
|
101
103
|
// An mcp-kind install has no directory or agent file on disk -- its only
|
|
102
104
|
// footprint is the lockfile entry (cleared below, once this block has run)
|
|
103
105
|
// and a live entry in .mcp.json (which add.ts's installMcpEntry merged in,
|
package/dist/commands/update.js
CHANGED
|
@@ -2,7 +2,7 @@ import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
|
|
|
2
2
|
import { readLockfile } from "../lockfile.js";
|
|
3
3
|
import { add, fetchVersionMeta, readMcpConfig, updateMcpEntry } from "./add.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
-
import { assertKind, isCliKind, KINDS, localInstalledKind, strictKind } from "../kinds.js";
|
|
5
|
+
import { assertKind, isCliKind, isPinnedFileKind, KINDS, localInstalledKind, strictKind } from "../kinds.js";
|
|
6
6
|
const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
|
|
7
7
|
function toPreview(ownerSlashSkill, currentVersion, meta) {
|
|
8
8
|
const upToDate = currentVersion === meta.version;
|
|
@@ -214,13 +214,13 @@ export async function update(args, scope) {
|
|
|
214
214
|
}
|
|
215
215
|
// no @version -- resolves to latest again. A strict scope is passed on so
|
|
216
216
|
// add() re-checks the kind it resolves itself before writing anything.
|
|
217
|
-
// A doc pin under legacy `ahood skill update` is moved
|
|
218
|
-
//
|
|
219
|
-
// update has always covered every
|
|
217
|
+
// A doc or project pin under legacy `ahood skill update` is moved
|
|
218
|
+
// forward through its own scope (ahood-cli#182, #185): legacy add
|
|
219
|
+
// refuses those kinds, but legacy update has always covered every pin.
|
|
220
220
|
if (kind)
|
|
221
221
|
await add([ownerSlashSkill], scope);
|
|
222
|
-
else if (meta.kind
|
|
223
|
-
await add([ownerSlashSkill], { noun:
|
|
222
|
+
else if (isPinnedFileKind(meta.kind))
|
|
223
|
+
await add([ownerSlashSkill], { noun: meta.kind, kind: meta.kind });
|
|
224
224
|
else
|
|
225
225
|
await add([ownerSlashSkill]);
|
|
226
226
|
}
|
package/dist/help.js
CHANGED
|
@@ -121,8 +121,8 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
121
121
|
"installs instead as a single file at .claude/agents/<owner>@<skill>.md (Claude Code's own subagent " +
|
|
122
122
|
"loader scans .claude/agents/*.md as flat files), not a .claude/skills/ directory. An artifact published " +
|
|
123
123
|
"with --kind mcp installs by merging an entry into .mcp.json instead, prompting for any secret " +
|
|
124
|
-
"environment variables its server.json declares that aren't already set in your shell. A doc
|
|
125
|
-
"here; install it with `ahood doc add`.",
|
|
124
|
+
"environment variables its server.json declares that aren't already set in your shell. A doc or project " +
|
|
125
|
+
"guide is refused here; install it with `ahood doc add` or `ahood project add`.",
|
|
126
126
|
examples: ["ahood skill add alice/pdf-tools", "ahood skill add alice/pdf-tools@1.2.0"],
|
|
127
127
|
},
|
|
128
128
|
{
|
|
@@ -207,17 +207,17 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
207
207
|
examples: ["ahood skill init pdf-tools", "ahood skill init"],
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
|
-
usage: "ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp|doc] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--changelog <text>] [--json]",
|
|
211
|
-
summary: "Publish a new version of a skill, agent, mcp server manifest, or
|
|
212
|
-
desc: "Publish a new version of a skill, agent, mcp server manifest, or
|
|
213
|
-
"doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent, --kind mcp, or --kind
|
|
214
|
-
"publishing an AGENT.md, server.json, or
|
|
210
|
+
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]",
|
|
211
|
+
summary: "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.",
|
|
212
|
+
desc: "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. If the artifact " +
|
|
213
|
+
"doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent, --kind mcp, --kind doc, or --kind project if " +
|
|
214
|
+
"publishing an AGENT.md, server.json, DOC.md, or PROJECT.md (auto-detected from the folder contents when only one of SKILL.md/AGENT.md/server.json/DOC.md/PROJECT.md is present). " +
|
|
215
215
|
"--name is required when creating; optionally also pass --tagline/--tags/--license/--homepage/--repository. " +
|
|
216
216
|
"Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
|
|
217
217
|
"The legacy form `ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
|
|
218
218
|
"Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
|
|
219
219
|
flags: [
|
|
220
|
-
"--kind skill
|
|
220
|
+
"--kind <kind> skill, agent, mcp, doc, or project. Auto-detected from the folder's root doc when omitted.",
|
|
221
221
|
"--name <text> Required only when creating the artifact on this publish.",
|
|
222
222
|
"--tagline <text> Short one-line description, used only when creating.",
|
|
223
223
|
"--tags <comma,separated> Initial tags, used only when creating.",
|
|
@@ -241,21 +241,16 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
241
241
|
// rather than hand-copied twice, so the two lists can't drift from each other.
|
|
242
242
|
// SKILL_COMMANDS_HELP above stays hand-written: it documents the legacy,
|
|
243
243
|
// cross-kind behavior `ahood skill` keeps in this release.
|
|
244
|
-
|
|
245
|
-
const EXAMPLE = {
|
|
246
|
-
agent: { slug: "alice/code-reviewer", name: "Code Reviewer" },
|
|
247
|
-
mcp: { slug: "alice/github-server", name: "GitHub Server" },
|
|
248
|
-
doc: { slug: "alice/setup-guide", name: "Setup Guide" },
|
|
249
|
-
};
|
|
244
|
+
// Placeholders and examples come from the kind table (kind-info.ts KINDS).
|
|
250
245
|
function registryCommandsHelp(kind) {
|
|
251
246
|
const info = KINDS[kind];
|
|
252
|
-
const n =
|
|
247
|
+
const n = info.placeholder;
|
|
253
248
|
const p = `ahood ${kind}`;
|
|
254
249
|
const label = info.label;
|
|
255
250
|
const a = info.article;
|
|
256
251
|
const plural = info.plural;
|
|
257
252
|
const doc = info.rootDoc;
|
|
258
|
-
const ex =
|
|
253
|
+
const ex = info.example.slug;
|
|
259
254
|
const kindRefusal = `Refused, before anything else happens, when the target is not ${a} ${label}.`;
|
|
260
255
|
return [
|
|
261
256
|
{
|
|
@@ -385,12 +380,41 @@ function registryCommandsHelp(kind) {
|
|
|
385
380
|
"--changelog <text> What changed in this version.",
|
|
386
381
|
"--json Print one {version,status,...} JSON object on completion (or {error} on failure).",
|
|
387
382
|
],
|
|
388
|
-
examples: [`${p} publish ${ex}@1.0.0 --name "${
|
|
383
|
+
examples: [`${p} publish ${ex}@1.0.0 --name "${info.example.name}"`],
|
|
389
384
|
},
|
|
390
385
|
];
|
|
391
386
|
}
|
|
392
387
|
export const AGENT_COMMANDS_HELP = registryCommandsHelp("agent");
|
|
393
388
|
export const DOC_COMMANDS_HELP = registryCommandsHelp("doc");
|
|
389
|
+
// `ahood project` (ahood-cli#185): the shared verbs, with init's `project`
|
|
390
|
+
// field flags, plus bare `ahood project` -- the guides for this repository.
|
|
391
|
+
export const PROJECT_COMMANDS_HELP = [
|
|
392
|
+
{
|
|
393
|
+
usage: "ahood project [--remote <git-remote>] [--json]",
|
|
394
|
+
summary: "List the project guides you can see for this repository's git remote.",
|
|
395
|
+
desc: "List the project guides you can see that name this repository's git remote (`git remote get-url origin`, " +
|
|
396
|
+
"normalized to host/owner/repo -- any credentials in it are stripped and never sent), your own first. Read-only; " +
|
|
397
|
+
"needs `ahood login`. Asks the hosted get_project_guide tool.",
|
|
398
|
+
flags: [
|
|
399
|
+
"--remote <git-remote> Look up this remote instead of origin's.",
|
|
400
|
+
"--json Emit {project, guides} (each guide with its PROJECT.md content) instead of formatted lines.",
|
|
401
|
+
],
|
|
402
|
+
examples: ["ahood project", "ahood project --remote github.com/alice/acme-api"],
|
|
403
|
+
},
|
|
404
|
+
...registryCommandsHelp("project").map((entry) => entry.usage.startsWith("ahood project init")
|
|
405
|
+
? {
|
|
406
|
+
...entry,
|
|
407
|
+
usage: "ahood project init [name] [--project <git-remote> | --no-project]",
|
|
408
|
+
desc: entry.desc +
|
|
409
|
+
" The optional `project` field (the repository's git remote, which is how get_project_guide finds the guide) " +
|
|
410
|
+
"is filled from `git remote get-url origin`, normalized to host/owner/repo.",
|
|
411
|
+
flags: [
|
|
412
|
+
"--project <git-remote> Set the `project` field to this remote (normalized).",
|
|
413
|
+
"--no-project Leave the `project` field out.",
|
|
414
|
+
],
|
|
415
|
+
}
|
|
416
|
+
: entry),
|
|
417
|
+
];
|
|
394
418
|
// `ahood mcp` is two things (ADR 0007 in the ahood repo separates the hosted
|
|
395
419
|
// registry MCP endpoint from this CLI's local one): with no verb, or with
|
|
396
420
|
// `serve`, it is the local, read-only stdio MCP server that MCP hosts spawn;
|
|
@@ -594,6 +618,7 @@ const ENTITY_COMMANDS_HELP = {
|
|
|
594
618
|
agent: AGENT_COMMANDS_HELP,
|
|
595
619
|
mcp: MCP_COMMANDS_HELP,
|
|
596
620
|
doc: DOC_COMMANDS_HELP,
|
|
621
|
+
project: PROJECT_COMMANDS_HELP,
|
|
597
622
|
group: GROUP_COMMANDS_HELP,
|
|
598
623
|
snap: SNAP_COMMANDS_HELP,
|
|
599
624
|
};
|
|
@@ -718,8 +743,8 @@ export function formatSkillHelp() {
|
|
|
718
743
|
"Legacy, cross-kind: in this release `ahood skill <verb>` still acts on any registry kind",
|
|
719
744
|
"(skills, agents, MCP server manifests) -- `skill list`/`skill search` include every kind, and",
|
|
720
745
|
"`skill add`/`skill update` install whatever kind they resolve. Scripts relying on that keep",
|
|
721
|
-
"working. For one kind only, use `ahood agent|mcp|doc <verb>`, or add",
|
|
722
|
-
"`--kind skill|agent|mcp|doc` to any verb below (`--kind all` spells out the legacy behavior).",
|
|
746
|
+
"working. For one kind only, use `ahood agent|mcp|doc|project <verb>`, or add",
|
|
747
|
+
"`--kind skill|agent|mcp|doc|project` to any verb below (`--kind all` spells out the legacy behavior).",
|
|
723
748
|
"A future major version will make `ahood skill` skills-only.",
|
|
724
749
|
"",
|
|
725
750
|
"Commands:",
|
|
@@ -767,14 +792,24 @@ export function formatDocHelp() {
|
|
|
767
792
|
"To look at a doc without installing it, use `ahood doc read`.",
|
|
768
793
|
], DOC_COMMANDS_HELP, "doc");
|
|
769
794
|
}
|
|
795
|
+
export function formatProjectHelp() {
|
|
796
|
+
return formatRegistryKindHelp("ahood project -- project guides (PROJECT.md) in the ahood registry", [
|
|
797
|
+
"A project guide is how to work in one repository: conventions, verification, traps.",
|
|
798
|
+
"Every verb here acts on project guides only: a target of another kind is refused before anything",
|
|
799
|
+
"is downloaded, prompted for, written, or sent. `add` saves the guide to .claude/project.md, one per",
|
|
800
|
+
"repository, and never touches CLAUDE.md or AGENTS.md. Bare `ahood project` lists the guides for",
|
|
801
|
+
"this repository's git remote.",
|
|
802
|
+
], PROJECT_COMMANDS_HELP, "project");
|
|
803
|
+
}
|
|
804
|
+
const KIND_HELP = {
|
|
805
|
+
skill: formatSkillHelp,
|
|
806
|
+
agent: formatAgentHelp,
|
|
807
|
+
mcp: formatMcpHelp,
|
|
808
|
+
doc: formatDocHelp,
|
|
809
|
+
project: formatProjectHelp,
|
|
810
|
+
};
|
|
770
811
|
export function formatKindHelp(noun) {
|
|
771
|
-
|
|
772
|
-
return formatAgentHelp();
|
|
773
|
-
if (noun === "mcp")
|
|
774
|
-
return formatMcpHelp();
|
|
775
|
-
if (noun === "doc")
|
|
776
|
-
return formatDocHelp();
|
|
777
|
-
return formatSkillHelp();
|
|
812
|
+
return KIND_HELP[noun]();
|
|
778
813
|
}
|
|
779
814
|
// `ahood group --help` -- the group-level listing for every group verb.
|
|
780
815
|
export function formatGroupHelp() {
|
|
@@ -813,6 +848,8 @@ export function formatHelp() {
|
|
|
813
848
|
const mcpGroupSummary = "The same verbs, for MCP server manifests only; bare `ahood mcp` or `ahood mcp serve` starts the local MCP server.";
|
|
814
849
|
const docGroupUsage = "ahood doc <command>";
|
|
815
850
|
const docGroupSummary = "The same verbs, for docs (DOC.md) only -- run `ahood doc --help` for the full list.";
|
|
851
|
+
const projectGroupUsage = "ahood project <command>";
|
|
852
|
+
const projectGroupSummary = "The same verbs, for project guides (PROJECT.md) only; bare `ahood project` lists this repository's guides.";
|
|
816
853
|
const groupGroupUsage = "ahood group <command>";
|
|
817
854
|
const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
|
|
818
855
|
const snapGroupUsage = "ahood snap <command>";
|
|
@@ -823,6 +860,7 @@ export function formatHelp() {
|
|
|
823
860
|
{ usage: agentGroupUsage, summary: agentGroupSummary },
|
|
824
861
|
{ usage: mcpGroupUsage, summary: mcpGroupSummary },
|
|
825
862
|
{ usage: docGroupUsage, summary: docGroupSummary },
|
|
863
|
+
{ usage: projectGroupUsage, summary: projectGroupSummary },
|
|
826
864
|
{ usage: groupGroupUsage, summary: groupGroupSummary },
|
|
827
865
|
{ usage: snapGroupUsage, summary: snapGroupSummary },
|
|
828
866
|
]);
|
|
@@ -844,7 +882,7 @@ export function formatHelp() {
|
|
|
844
882
|
"",
|
|
845
883
|
"Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
|
|
846
884
|
"Run `ahood skill --help` for the full list of skill commands.",
|
|
847
|
-
"Run `ahood agent
|
|
885
|
+
"Run `ahood agent|mcp|doc|project --help` for the kind-scoped agent, MCP, doc and project guide commands.",
|
|
848
886
|
"Run `ahood group --help` for the full list of group commands.",
|
|
849
887
|
"Run `ahood snap --help` for the full list of snap commands.",
|
|
850
888
|
"Run `ahood --version` to print the installed CLI version.",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// One call to a tool on the hosted registry MCP endpoint (/api/mcp), for the
|
|
2
|
+
// reads that have no REST route -- today only get_project_guide
|
|
3
|
+
// (ahood-cli#185). The endpoint is stateless and answers plain JSON
|
|
4
|
+
// (enableJsonResponse), so a bare JSON-RPC `tools/call` through apiJson -- the
|
|
5
|
+
// same auth, error mapping and retry as every REST call -- is the whole
|
|
6
|
+
// client; no session or initialize handshake is needed.
|
|
7
|
+
import { apiJson } from "./http.js";
|
|
8
|
+
import { sanitizeForTerminal } from "./terminal-safe.js";
|
|
9
|
+
function parseJson(text) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function callHostedTool(name, args) {
|
|
18
|
+
const response = await apiJson("/api/mcp", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
21
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
22
|
+
});
|
|
23
|
+
if (response.error)
|
|
24
|
+
throw new Error(`${name} failed: ${sanitizeForTerminal(response.error.message ?? "unknown error")}`);
|
|
25
|
+
const text = response.result?.content?.find((c) => c.type === "text")?.text;
|
|
26
|
+
if (text === undefined)
|
|
27
|
+
throw new Error(`${name} returned no content.`);
|
|
28
|
+
const payload = parseJson(text);
|
|
29
|
+
if (response.result?.isError) {
|
|
30
|
+
const reason = typeof payload?.error === "string" ? payload.error : text;
|
|
31
|
+
throw new Error(`${name} failed: ${sanitizeForTerminal(reason)}`);
|
|
32
|
+
}
|
|
33
|
+
if (payload === undefined)
|
|
34
|
+
throw new Error(`${name} returned a response that is not JSON.`);
|
|
35
|
+
return payload;
|
|
36
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,8 @@ import { versions } from "./commands/versions.js";
|
|
|
20
20
|
import { diff } from "./commands/diff.js";
|
|
21
21
|
import { completion } from "./commands/completion.js";
|
|
22
22
|
import { mcp } from "./commands/mcp.js";
|
|
23
|
-
import { extractKindFlag, resolveScope } from "./kinds.js";
|
|
23
|
+
import { CLI_KINDS, extractKindFlag, isCliKind, resolveScope } from "./kinds.js";
|
|
24
|
+
import { findProjectGuides } from "./commands/project-guides.js";
|
|
24
25
|
import { readUseme } from "./useme.js";
|
|
25
26
|
import { UsageError } from "./usage-error.js";
|
|
26
27
|
import { init } from "./commands/init.js";
|
|
@@ -97,6 +98,7 @@ const COMMANDS = {
|
|
|
97
98
|
skill: dispatchSkill,
|
|
98
99
|
agent: dispatchAgent,
|
|
99
100
|
doc: dispatchDoc,
|
|
101
|
+
project: dispatchProject,
|
|
100
102
|
group: dispatchGroup,
|
|
101
103
|
snap: dispatchSnap,
|
|
102
104
|
};
|
|
@@ -108,9 +110,9 @@ const COMMANDS = {
|
|
|
108
110
|
// here just means "a command that owns its own sub-verb dispatch", the same
|
|
109
111
|
// sense "skill" already used before "group" (the entity) existed. "snap"
|
|
110
112
|
// joins this set for the exact same reason (ahood-cli#107).
|
|
111
|
-
//
|
|
112
|
-
// (
|
|
113
|
-
const GROUP_COMMANDS = new Set([
|
|
113
|
+
// Every registry noun joins it (ahood-cli#172), taken from the kind table so
|
|
114
|
+
// a new kind (doc #182, project #185) needs no edit here.
|
|
115
|
+
const GROUP_COMMANDS = new Set([...CLI_KINDS, "group", "snap"]);
|
|
114
116
|
function levenshtein(a, b) {
|
|
115
117
|
const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
116
118
|
for (let i = 0; i <= a.length; i++)
|
|
@@ -148,7 +150,7 @@ function closestGroupVerb(input) {
|
|
|
148
150
|
function closestSnapVerb(input) {
|
|
149
151
|
return closestOf(input, Object.keys(SNAP_VERBS));
|
|
150
152
|
}
|
|
151
|
-
// `ahood <noun> <verb> [...]` for every registry noun (
|
|
153
|
+
// `ahood <noun> <verb> [...]` for every registry noun (kind-info.ts KINDS) --
|
|
152
154
|
// owns its own help handling at both the noun level (`ahood agent` /
|
|
153
155
|
// `ahood agent --help`) and the per-verb level (`ahood agent <verb> --help`),
|
|
154
156
|
// since neither should fall through to main()'s generic top-level --help
|
|
@@ -215,6 +217,18 @@ async function dispatchAgent(args) {
|
|
|
215
217
|
async function dispatchDoc(args) {
|
|
216
218
|
await dispatchRegistry("doc", args);
|
|
217
219
|
}
|
|
220
|
+
// `ahood project` (ahood-cli#185): with no verb -- nothing, or only its own
|
|
221
|
+
// flags -- it lists the project guides for this repository's git remote
|
|
222
|
+
// (commands/project-guides.ts). Everything else is the shared registry
|
|
223
|
+
// dispatch, help included.
|
|
224
|
+
async function dispatchProject(args) {
|
|
225
|
+
const first = args[0];
|
|
226
|
+
if (first === undefined || (first.startsWith("--") && first !== "--help")) {
|
|
227
|
+
await findProjectGuides(args);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
await dispatchRegistry("project", args);
|
|
231
|
+
}
|
|
218
232
|
// `ahood mcp` keeps its original contract byte-for-byte: with NO arguments it
|
|
219
233
|
// is the local stdio MCP server that existing host configs launch -- nothing
|
|
220
234
|
// is printed and nothing else runs first. `ahood mcp serve` is the same
|
|
@@ -317,7 +331,7 @@ async function main() {
|
|
|
317
331
|
process.stdout.write(content);
|
|
318
332
|
return;
|
|
319
333
|
}
|
|
320
|
-
if (sub
|
|
334
|
+
if (isCliKind(sub)) {
|
|
321
335
|
const verb = args[1];
|
|
322
336
|
const entry = verb ? findCommandHelp(sub, verb) : undefined;
|
|
323
337
|
console.log(entry ? formatCommandHelp(entry) : formatKindHelp(sub));
|
|
@@ -399,4 +413,4 @@ if (isEntrypoint) {
|
|
|
399
413
|
main();
|
|
400
414
|
}
|
|
401
415
|
// Exported for tests only -- the CLI itself only ever calls main() above.
|
|
402
|
-
export { main, dispatchSkill, dispatchAgent, dispatchDoc, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
|
|
416
|
+
export { main, dispatchSkill, dispatchAgent, dispatchDoc, dispatchProject, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
|
package/dist/kind-info.js
CHANGED
|
@@ -4,13 +4,18 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Kind-aware registry commands (ahood-cli#172).
|
|
6
6
|
//
|
|
7
|
-
// The registry stores
|
|
8
|
-
// lib/skills/validation.ts ALLOWED_KINDS: skill, agent, mcp, doc), and a
|
|
7
|
+
// The registry stores five artifact kinds (the backend's
|
|
8
|
+
// lib/skills/validation.ts ALLOWED_KINDS: skill, agent, mcp, doc, project), and a
|
|
9
9
|
// kind is fixed when an entry is created -- PATCH's allow-list has no `kind`
|
|
10
10
|
// -- so the kind an entry reports now is the kind it has always had. Each
|
|
11
11
|
// kind has a noun group (`ahood skill|agent|mcp|doc <verb>`), all run by one
|
|
12
12
|
// shared dispatcher. `doc` joined as the fourth in ahood-cli#182: it installs
|
|
13
13
|
// as one flat Markdown file, its DOC.md, the same shape an agent has.
|
|
14
|
+
// `project` joined as the fifth in ahood-cli#185: one PROJECT.md per
|
|
15
|
+
// repository, installed to the fixed path .claude/project.md.
|
|
16
|
+
//
|
|
17
|
+
// Per-kind facts live here, not in per-command branches: `placeholder` and
|
|
18
|
+
// `example` feed help and init, `about` the legacy `skill add` refusal.
|
|
14
19
|
export const KINDS = {
|
|
15
20
|
skill: {
|
|
16
21
|
label: "skill",
|
|
@@ -18,6 +23,8 @@ export const KINDS = {
|
|
|
18
23
|
plural: "skills",
|
|
19
24
|
rootDoc: "SKILL.md",
|
|
20
25
|
installsTo: ".claude/skills/<owner>@<skill>/",
|
|
26
|
+
placeholder: "<skill>",
|
|
27
|
+
example: { slug: "alice/pdf-tools", name: "PDF Tools" },
|
|
21
28
|
},
|
|
22
29
|
agent: {
|
|
23
30
|
label: "agent",
|
|
@@ -25,6 +32,8 @@ export const KINDS = {
|
|
|
25
32
|
plural: "agents",
|
|
26
33
|
rootDoc: "AGENT.md",
|
|
27
34
|
installsTo: ".claude/agents/<owner>@<agent>.md",
|
|
35
|
+
placeholder: "<agent>",
|
|
36
|
+
example: { slug: "alice/code-reviewer", name: "Code Reviewer" },
|
|
28
37
|
},
|
|
29
38
|
mcp: {
|
|
30
39
|
label: "MCP server manifest",
|
|
@@ -32,6 +41,8 @@ export const KINDS = {
|
|
|
32
41
|
plural: "MCP server manifests",
|
|
33
42
|
rootDoc: "server.json",
|
|
34
43
|
installsTo: "an entry in .mcp.json",
|
|
44
|
+
placeholder: "<server>",
|
|
45
|
+
example: { slug: "alice/github-server", name: "GitHub Server" },
|
|
35
46
|
},
|
|
36
47
|
doc: {
|
|
37
48
|
label: "doc",
|
|
@@ -39,9 +50,30 @@ export const KINDS = {
|
|
|
39
50
|
plural: "docs",
|
|
40
51
|
rootDoc: "DOC.md",
|
|
41
52
|
installsTo: ".claude/docs/<owner>@<doc>.md",
|
|
53
|
+
placeholder: "<doc>",
|
|
54
|
+
example: { slug: "alice/setup-guide", name: "Setup Guide" },
|
|
55
|
+
about: "a documentation page",
|
|
56
|
+
},
|
|
57
|
+
project: {
|
|
58
|
+
label: "project guide",
|
|
59
|
+
article: "a",
|
|
60
|
+
plural: "project guides",
|
|
61
|
+
rootDoc: "PROJECT.md",
|
|
62
|
+
installsTo: ".claude/project.md",
|
|
63
|
+
placeholder: "<slug>",
|
|
64
|
+
example: { slug: "alice/acme-api", name: "Acme API" },
|
|
65
|
+
about: "a guide to working in one repository",
|
|
42
66
|
},
|
|
43
67
|
};
|
|
44
68
|
export const CLI_KINDS = Object.keys(KINDS);
|
|
45
69
|
export function isCliKind(value) {
|
|
46
70
|
return typeof value === "string" && CLI_KINDS.includes(value);
|
|
47
71
|
}
|
|
72
|
+
// Kinds that install as one root-doc file pinned with its kind in the
|
|
73
|
+
// lockfile (doc: ahood-cli#182, project: #185). The recorded kind is how
|
|
74
|
+
// remove/update recognise the pin offline, and how a project guide's shared
|
|
75
|
+
// path is claimed by exactly one pin. Legacy `skill add` refuses these.
|
|
76
|
+
export const PINNED_FILE_KINDS = ["doc", "project"];
|
|
77
|
+
export function isPinnedFileKind(value) {
|
|
78
|
+
return typeof value === "string" && PINNED_FILE_KINDS.includes(value);
|
|
79
|
+
}
|
package/dist/kinds.js
CHANGED
|
@@ -5,8 +5,8 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { apiJson } from "./http.js";
|
|
6
6
|
import { agentPath, skillDir } from "./spec.js";
|
|
7
7
|
import { UsageError } from "./usage-error.js";
|
|
8
|
-
import { KINDS, CLI_KINDS, isCliKind } from "./kind-info.js";
|
|
9
|
-
export { KINDS, CLI_KINDS, isCliKind };
|
|
8
|
+
import { KINDS, CLI_KINDS, isCliKind, isPinnedFileKind } from "./kind-info.js";
|
|
9
|
+
export { KINDS, CLI_KINDS, isCliKind, isPinnedFileKind };
|
|
10
10
|
export const LEGACY_SCOPE = { noun: "skill", kind: "all" };
|
|
11
11
|
export function strictKind(scope) {
|
|
12
12
|
return scope && scope.kind !== "all" ? scope.kind : undefined;
|
|
@@ -62,9 +62,9 @@ export async function ensureRemoteKind(scope, owner, skill, verb, action) {
|
|
|
62
62
|
// skill whose directory was deleted by hand) is undefined: unknown, and the
|
|
63
63
|
// caller must ask the registry rather than guess.
|
|
64
64
|
export function localInstalledKind(owner, skill, entry) {
|
|
65
|
-
// A doc pin records its kind (ahood-cli#182
|
|
66
|
-
if (entry?.kind
|
|
67
|
-
return
|
|
65
|
+
// A doc or project pin records its kind (ahood-cli#182, #185).
|
|
66
|
+
if (isPinnedFileKind(entry?.kind))
|
|
67
|
+
return entry.kind;
|
|
68
68
|
const hasDir = existsSync(skillDir(owner, skill));
|
|
69
69
|
const hasAgentFile = existsSync(agentPath(owner, skill));
|
|
70
70
|
if (hasDir && !hasAgentFile)
|
|
@@ -105,8 +105,8 @@ export function extractKindFlag(args) {
|
|
|
105
105
|
// Resolves the scope for `ahood <noun> <verb> [--kind <k>]`.
|
|
106
106
|
// skill noun, no --kind or --kind all -> legacy (cross-kind)
|
|
107
107
|
// skill noun, --kind <k> -> strict <k>
|
|
108
|
-
//
|
|
109
|
-
//
|
|
108
|
+
// any other noun, no --kind or --kind <same> -> strict <noun>
|
|
109
|
+
// any other noun, any other --kind -> usage error (contradiction)
|
|
110
110
|
export function resolveScope(noun, kindFlag) {
|
|
111
111
|
const valid = [...CLI_KINDS, "all"].join(", ");
|
|
112
112
|
if (kindFlag !== undefined && kindFlag !== "all" && !isCliKind(kindFlag)) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Git remotes for project guides (ahood-cli#185): reading this repository's
|
|
2
|
+
// origin, and normalizing a remote to the `host/owner/repo` form the ahood
|
|
3
|
+
// server stores and matches on. normalizeProjectRemote is a line-for-line port
|
|
4
|
+
// of the server's lib/projects/normalize-project-remote.ts (ahood#778) -- the
|
|
5
|
+
// two must agree, or `project init` writes a remote the lookup never matches.
|
|
6
|
+
// tests/project-remote.test.ts runs the server's own test table against it.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
9
|
+
const PATH_SEGMENT = /^[a-z0-9._-]+$/;
|
|
10
|
+
const ALLOWED_SCHEMES = ["https", "http", "ssh", "git"];
|
|
11
|
+
const MAX_PROJECT_REMOTE_LENGTH = 255;
|
|
12
|
+
const MAX_PATH_SEGMENTS = 8;
|
|
13
|
+
// Never echoes the input back: a rejected remote can still carry a
|
|
14
|
+
// credential (`https://user:token@...`).
|
|
15
|
+
const INVALID = {
|
|
16
|
+
ok: false,
|
|
17
|
+
reason: "not a git remote; expected host/owner/repo, e.g. github.com/owner/repo",
|
|
18
|
+
};
|
|
19
|
+
// Strips the scheme, any userinfo (`user:token@`), the port, a trailing
|
|
20
|
+
// `.git` and trailing slashes, then lowercases everything.
|
|
21
|
+
export function normalizeProjectRemote(raw) {
|
|
22
|
+
const input = raw.trim();
|
|
23
|
+
if (!input || input.length > MAX_PROJECT_REMOTE_LENGTH)
|
|
24
|
+
return INVALID;
|
|
25
|
+
if (/[\s?#\\]/.test(input))
|
|
26
|
+
return INVALID;
|
|
27
|
+
let rest;
|
|
28
|
+
const scheme = input.match(/^([a-z][a-z0-9+.-]*):\/\//i);
|
|
29
|
+
if (scheme) {
|
|
30
|
+
if (!ALLOWED_SCHEMES.includes(scheme[1].toLowerCase()))
|
|
31
|
+
return INVALID;
|
|
32
|
+
rest = input.slice(scheme[0].length);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// scp-like form: [user@]host:path, where the path does not start with "/".
|
|
36
|
+
const scp = input.match(/^(?:[^@/:]+@)?([^@/:]+):(?!\/)(.+)$/);
|
|
37
|
+
rest = scp ? `${scp[1]}/${scp[2]}` : input;
|
|
38
|
+
}
|
|
39
|
+
const slash = rest.indexOf("/");
|
|
40
|
+
if (slash <= 0)
|
|
41
|
+
return INVALID;
|
|
42
|
+
let host = rest.slice(0, slash);
|
|
43
|
+
const at = host.lastIndexOf("@");
|
|
44
|
+
if (at !== -1)
|
|
45
|
+
host = host.slice(at + 1);
|
|
46
|
+
host = host.replace(/:\d+$/, "").toLowerCase();
|
|
47
|
+
const labels = host.split(".");
|
|
48
|
+
if (labels.length < 2 || !labels.every((label) => HOST_LABEL.test(label)))
|
|
49
|
+
return INVALID;
|
|
50
|
+
const path = rest
|
|
51
|
+
.slice(slash + 1)
|
|
52
|
+
.replace(/\/+$/, "")
|
|
53
|
+
.replace(/\.git$/i, "")
|
|
54
|
+
.replace(/\/+$/, "")
|
|
55
|
+
.toLowerCase();
|
|
56
|
+
const segments = path.split("/");
|
|
57
|
+
if (segments.length < 2 || segments.length > MAX_PATH_SEGMENTS)
|
|
58
|
+
return INVALID;
|
|
59
|
+
if (!segments.every((s) => s !== "." && s !== ".." && PATH_SEGMENT.test(s)))
|
|
60
|
+
return INVALID;
|
|
61
|
+
return { ok: true, value: `${host}/${segments.join("/")}` };
|
|
62
|
+
}
|
|
63
|
+
// The current repository's origin, normalized, or undefined when there is no
|
|
64
|
+
// git, no repository, no origin, or an origin that is not a shareable remote.
|
|
65
|
+
// The raw URL never leaves this function: it can carry a token.
|
|
66
|
+
export function detectProjectRemote() {
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
72
|
+
timeout: 5000,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const normalized = normalizeProjectRemote(raw);
|
|
79
|
+
return normalized.ok ? normalized.value : undefined;
|
|
80
|
+
}
|
package/dist/spec.js
CHANGED
|
@@ -129,3 +129,12 @@ export function docPath(owner, doc) {
|
|
|
129
129
|
validateSegment(doc, "skill", spec);
|
|
130
130
|
return join(DOCS_ROOT, `${owner}@${doc}.md`);
|
|
131
131
|
}
|
|
132
|
+
// A project guide installs to one fixed path per repository (ahood-cli#185),
|
|
133
|
+
// beside CLAUDE.md/AGENTS.md rather than inside them, so ahood never edits a
|
|
134
|
+
// file the user owns. Being shared, the path is claimed by exactly one pin:
|
|
135
|
+
// add.ts refuses a second guide while another is pinned.
|
|
136
|
+
export const PROJECT_GUIDE_PATH = join(".claude", "project.md");
|
|
137
|
+
// Where a pinned-file kind's install lives (kind-info.ts PINNED_FILE_KINDS).
|
|
138
|
+
export function pinnedFilePath(kind, owner, slug) {
|
|
139
|
+
return kind === "doc" ? docPath(owner, slug) : PROJECT_GUIDE_PATH;
|
|
140
|
+
}
|
package/dist/useme/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ahood
|
|
3
|
-
description: How to use the ahood CLI (@ahood/cli), the registry for reusable AI-agent skills, subagent definitions (AGENT.md), MCP server manifests (server.json),
|
|
3
|
+
description: How to use the ahood CLI (@ahood/cli), the registry for reusable AI-agent skills, subagent definitions (AGENT.md), MCP server manifests (server.json), docs (DOC.md), and project guides (PROJECT.md). Load BEFORE running any ahood command; before writing a non-trivial reusable skill, subagent, or MCP integration from scratch (search the registry first); when installing, updating, removing, publishing, sharing, or unpublishing one; when wiring ahood into an MCP host; or when setting up headless/CI access with AHOOD_TOKEN. Triggers - ahood, skill registry, is there a skill for this, install a skill, install an agent, add an MCP server, read a doc, project guide, publish a skill, ahood mcp, ahood doc, ahood project, AHOOD_TOKEN.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# ahood
|
|
@@ -13,7 +13,7 @@ and only moves when someone runs an update.
|
|
|
13
13
|
This guide ships inside the CLI itself (`ahood help useme`), so it matches the installed
|
|
14
14
|
version (`ahood --version`). It needs no login or network to print.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Five kinds, five command groups
|
|
17
17
|
|
|
18
18
|
| Kind | Root file | Installs to | Commands |
|
|
19
19
|
|---|---|---|---|
|
|
@@ -21,23 +21,29 @@ version (`ahood --version`). It needs no login or network to print.
|
|
|
21
21
|
| agent | `AGENT.md` | `.claude/agents/<owner>@<agent>.md` | `ahood agent <verb>` |
|
|
22
22
|
| mcp | `server.json` | an entry merged into `.mcp.json` | `ahood mcp <verb>` |
|
|
23
23
|
| doc | `DOC.md` | `.claude/docs/<owner>@<doc>.md` | `ahood doc <verb>` |
|
|
24
|
+
| project | `PROJECT.md` | `.claude/project.md` (one per repository) | `ahood project <verb>` |
|
|
24
25
|
|
|
25
|
-
All
|
|
26
|
+
All five groups take the same verbs: `search`, `list`, `view`/`show`, `read`, `versions`,
|
|
26
27
|
`diff`, `add`, `update`, `outdated`, `remove`, `edit`, `unpublish`, `star`, `unstar`,
|
|
27
28
|
`share`, `unshare`, `init`, `publish`.
|
|
28
29
|
|
|
29
|
-
- `ahood agent <verb>`, `ahood mcp <verb>` and `ahood
|
|
30
|
+
- `ahood agent <verb>`, `ahood mcp <verb>`, `ahood doc <verb>` and `ahood project <verb>` act on their own kind only.
|
|
30
31
|
A target of another kind is refused (exit 2) before anything is downloaded, prompted for,
|
|
31
32
|
written, or sent, and the error names the kind it found and the right command.
|
|
32
33
|
- `ahood skill <verb>` is the **legacy, cross-kind** group in this CLI release: `skill search`
|
|
33
34
|
and `skill list` include every kind, and `skill add`/`skill update` install whatever kind
|
|
34
|
-
they resolve, except a doc: `skill add` refuses one and points at
|
|
35
|
-
|
|
35
|
+
they resolve, except a doc or project guide: `skill add` refuses one and points at
|
|
36
|
+
`ahood doc add` / `ahood project add`.
|
|
37
|
+
Add `--kind skill` (or `agent`, `mcp`, `doc`, `project`) to any verb to scope it to one kind;
|
|
36
38
|
`--kind all` spells out the legacy behavior. Prefer the kind-specific form in anything new --
|
|
37
39
|
a future major version makes `ahood skill` skills-only.
|
|
38
40
|
- A doc is a documentation page. `ahood doc read alice/setup-guide` prints it without
|
|
39
41
|
installing anything; `ahood doc add alice/setup-guide` saves it as one pinned file under
|
|
40
42
|
`.claude/docs/`, which `ahood doc update` and `ahood doc remove` manage like any other pin.
|
|
43
|
+
- A project guide is how to work in one repository: conventions, verification, traps. Run bare
|
|
44
|
+
`ahood project` inside a repository to list the guides naming its git remote;
|
|
45
|
+
`ahood project add alice/acme-api` saves one to `.claude/project.md` (pinned, one per
|
|
46
|
+
repository) and never touches `CLAUDE.md` or `AGENTS.md` -- point those at it yourself.
|
|
41
47
|
|
|
42
48
|
## Find, inspect, then install
|
|
43
49
|
|
|
@@ -87,7 +93,7 @@ ahood skill update --dry-run --json # preview, machine-readable
|
|
|
87
93
|
ahood agent remove alice/code-reviewer # local uninstall; prompts unless --yes
|
|
88
94
|
```
|
|
89
95
|
|
|
90
|
-
With no target, `agent`/`mcp`/`doc` `update` and `outdated` consider only installed pins of that
|
|
96
|
+
With no target, `agent`/`mcp`/`doc`/`project` `update` and `outdated` consider only installed pins of that
|
|
91
97
|
kind; the legacy `skill update` covers every pin. Updates only affect the current project.
|
|
92
98
|
An `.mcp.json` entry you edited by hand is refused rather than overwritten.
|
|
93
99
|
|
|
@@ -100,13 +106,14 @@ ahood skill init pdf-tools # ./pdf-tools/SKILL.md
|
|
|
100
106
|
ahood agent init code-reviewer # ./code-reviewer/AGENT.md
|
|
101
107
|
ahood mcp init github-server # ./github-server/server.json (placeholder URL)
|
|
102
108
|
ahood doc init setup-guide # ./setup-guide/DOC.md
|
|
109
|
+
ahood project init acme-api # ./acme-api/PROJECT.md, `project:` filled from git origin
|
|
103
110
|
|
|
104
111
|
ahood agent publish alice/code-reviewer@1.0.0 --path code-reviewer --name "Code Reviewer"
|
|
105
112
|
ahood skill publish alice/pdf-tools@1.1.0 --path pdf-tools --changelog "Adds split"
|
|
106
113
|
```
|
|
107
114
|
|
|
108
115
|
- `--name` is required only on the first publish, which creates the entry under your
|
|
109
|
-
account. `agent publish`/`mcp publish`/`doc publish` imply the kind and refuse a contradictory `--kind`
|
|
116
|
+
account. `agent publish`/`mcp publish`/`doc publish`/`project publish` imply the kind and refuse a contradictory `--kind`
|
|
110
117
|
or an existing entry of another kind before uploading anything.
|
|
111
118
|
- **New entries are private.** Make one discoverable explicitly:
|
|
112
119
|
`ahood skill edit alice/pdf-tools --visibility public`.
|
|
@@ -153,7 +160,7 @@ ahood skill publish alice/pdf-tools@1.1.0 --path pdf-tools --changelog "Adds spl
|
|
|
153
160
|
3. **The hosted registry MCP endpoint**, `https://www.ahood.dev/api/mcp`: Streamable HTTP,
|
|
154
161
|
stateless (POST only), `Authorization: Bearer <token>`. It has write tools too:
|
|
155
162
|
`search_skills`, `get_skill`, `fetch_skill`, `read_skill`, `list_skill_versions`,
|
|
156
|
-
`list_my_skills`, `search_collections`, `get_collection`, `create_skill`, `update_skill`,
|
|
163
|
+
`list_my_skills`, `search_collections`, `get_collection`, `get_project_guide`, `create_skill`, `update_skill`,
|
|
157
164
|
`report_skill`, `delete_skill`, `yank_version`, and snap tools (`create_snap`,
|
|
158
165
|
`list_snaps`, `get_snap`, `set_snap_tags`, `delete_snap`, `share_snap`, `unshare_snap`).
|
|
159
166
|
A client that opens with a legacy-SSE `GET` gets a 405 -- use a Streamable HTTP client.
|
package/package.json
CHANGED