@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/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, 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
|
|
@@ -47,6 +47,11 @@ export async function remove(args, scope) {
|
|
|
47
47
|
const agentFile = agentPath(owner, skill);
|
|
48
48
|
const agentExisted = existsSync(agentFile);
|
|
49
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);
|
|
50
55
|
const hadLockfileEntry = lockEntry !== undefined;
|
|
51
56
|
// A pin whose install left nothing on disk is the defining shape of an
|
|
52
57
|
// mcp-kind install: its only footprint is this pin plus a live .mcp.json
|
|
@@ -55,8 +60,8 @@ export async function remove(args, scope) {
|
|
|
55
60
|
// before that field did (lockfile.ts: "absent for any mcp entry installed
|
|
56
61
|
// before this field existed") and it is computed HERE, above the .mcp.json
|
|
57
62
|
// read -- so it survives the case where that read throws (ahood-cli#143).
|
|
58
|
-
const hadNoOnDiskFootprint = !dirExisted && !agentExisted && hadLockfileEntry;
|
|
59
|
-
if (!dirExisted && !agentExisted && !hadLockfileEntry) {
|
|
63
|
+
const hadNoOnDiskFootprint = !dirExisted && !agentExisted && !pinnedFileExisted && !isPinnedFileKind(lockEntry?.kind) && hadLockfileEntry;
|
|
64
|
+
if (!dirExisted && !agentExisted && !pinnedFileExisted && !hadLockfileEntry) {
|
|
60
65
|
console.error(`${key} was not installed -- nothing to remove.`);
|
|
61
66
|
process.exitCode = 1;
|
|
62
67
|
return;
|
|
@@ -93,6 +98,8 @@ export async function remove(args, scope) {
|
|
|
93
98
|
rmSync(dir, { recursive: true, force: true });
|
|
94
99
|
if (agentExisted)
|
|
95
100
|
rmSync(agentFile, { force: true });
|
|
101
|
+
if (pinnedFileExisted)
|
|
102
|
+
rmSync(pinnedFile, { force: true });
|
|
96
103
|
// An mcp-kind install has no directory or agent file on disk -- its only
|
|
97
104
|
// footprint is the lockfile entry (cleared below, once this block has run)
|
|
98
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,8 +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 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.
|
|
217
220
|
if (kind)
|
|
218
221
|
await add([ownerSlashSkill], scope);
|
|
222
|
+
else if (isPinnedFileKind(meta.kind))
|
|
223
|
+
await add([ownerSlashSkill], { noun: meta.kind, kind: meta.kind });
|
|
219
224
|
else
|
|
220
225
|
await add([ownerSlashSkill]);
|
|
221
226
|
}
|
package/dist/help.js
CHANGED
|
@@ -121,7 +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."
|
|
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`.",
|
|
125
126
|
examples: ["ahood skill add alice/pdf-tools", "ahood skill add alice/pdf-tools@1.2.0"],
|
|
126
127
|
},
|
|
127
128
|
{
|
|
@@ -206,17 +207,17 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
206
207
|
examples: ["ahood skill init pdf-tools", "ahood skill init"],
|
|
207
208
|
},
|
|
208
209
|
{
|
|
209
|
-
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]",
|
|
210
|
-
summary: "Publish a new version of a skill, agent,
|
|
211
|
-
desc: "Publish a new version of a skill, agent,
|
|
212
|
-
"doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent or --kind
|
|
213
|
-
"publishing an AGENT.md
|
|
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). " +
|
|
214
215
|
"--name is required when creating; optionally also pass --tagline/--tags/--license/--homepage/--repository. " +
|
|
215
216
|
"Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
|
|
216
217
|
"The legacy form `ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
|
|
217
218
|
"Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
|
|
218
219
|
flags: [
|
|
219
|
-
"--kind skill
|
|
220
|
+
"--kind <kind> skill, agent, mcp, doc, or project. Auto-detected from the folder's root doc when omitted.",
|
|
220
221
|
"--name <text> Required only when creating the artifact on this publish.",
|
|
221
222
|
"--tagline <text> Short one-line description, used only when creating.",
|
|
222
223
|
"--tags <comma,separated> Initial tags, used only when creating.",
|
|
@@ -240,15 +241,16 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
240
241
|
// rather than hand-copied twice, so the two lists can't drift from each other.
|
|
241
242
|
// SKILL_COMMANDS_HELP above stays hand-written: it documents the legacy,
|
|
242
243
|
// cross-kind behavior `ahood skill` keeps in this release.
|
|
244
|
+
// Placeholders and examples come from the kind table (kind-info.ts KINDS).
|
|
243
245
|
function registryCommandsHelp(kind) {
|
|
244
246
|
const info = KINDS[kind];
|
|
245
|
-
const n =
|
|
247
|
+
const n = info.placeholder;
|
|
246
248
|
const p = `ahood ${kind}`;
|
|
247
249
|
const label = info.label;
|
|
248
250
|
const a = info.article;
|
|
249
251
|
const plural = info.plural;
|
|
250
252
|
const doc = info.rootDoc;
|
|
251
|
-
const ex =
|
|
253
|
+
const ex = info.example.slug;
|
|
252
254
|
const kindRefusal = `Refused, before anything else happens, when the target is not ${a} ${label}.`;
|
|
253
255
|
return [
|
|
254
256
|
{
|
|
@@ -378,11 +380,41 @@ function registryCommandsHelp(kind) {
|
|
|
378
380
|
"--changelog <text> What changed in this version.",
|
|
379
381
|
"--json Print one {version,status,...} JSON object on completion (or {error} on failure).",
|
|
380
382
|
],
|
|
381
|
-
examples: [`${p} publish ${ex}@1.0.0 --name "${
|
|
383
|
+
examples: [`${p} publish ${ex}@1.0.0 --name "${info.example.name}"`],
|
|
382
384
|
},
|
|
383
385
|
];
|
|
384
386
|
}
|
|
385
387
|
export const AGENT_COMMANDS_HELP = registryCommandsHelp("agent");
|
|
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
|
+
];
|
|
386
418
|
// `ahood mcp` is two things (ADR 0007 in the ahood repo separates the hosted
|
|
387
419
|
// registry MCP endpoint from this CLI's local one): with no verb, or with
|
|
388
420
|
// `serve`, it is the local, read-only stdio MCP server that MCP hosts spawn;
|
|
@@ -585,6 +617,8 @@ const ENTITY_COMMANDS_HELP = {
|
|
|
585
617
|
skill: SKILL_COMMANDS_HELP,
|
|
586
618
|
agent: AGENT_COMMANDS_HELP,
|
|
587
619
|
mcp: MCP_COMMANDS_HELP,
|
|
620
|
+
doc: DOC_COMMANDS_HELP,
|
|
621
|
+
project: PROJECT_COMMANDS_HELP,
|
|
588
622
|
group: GROUP_COMMANDS_HELP,
|
|
589
623
|
snap: SNAP_COMMANDS_HELP,
|
|
590
624
|
};
|
|
@@ -709,8 +743,8 @@ export function formatSkillHelp() {
|
|
|
709
743
|
"Legacy, cross-kind: in this release `ahood skill <verb>` still acts on any registry kind",
|
|
710
744
|
"(skills, agents, MCP server manifests) -- `skill list`/`skill search` include every kind, and",
|
|
711
745
|
"`skill add`/`skill update` install whatever kind they resolve. Scripts relying on that keep",
|
|
712
|
-
"working. For one kind only, use `ahood agent
|
|
713
|
-
"`--kind skill|agent|mcp` 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).",
|
|
714
748
|
"A future major version will make `ahood skill` skills-only.",
|
|
715
749
|
"",
|
|
716
750
|
"Commands:",
|
|
@@ -751,12 +785,31 @@ export function formatMcpHelp() {
|
|
|
751
785
|
}
|
|
752
786
|
// One entry point for every registry noun's group help, so dispatch and
|
|
753
787
|
// `ahood help <noun>` can't disagree about which listing a noun gets.
|
|
788
|
+
export function formatDocHelp() {
|
|
789
|
+
return formatRegistryKindHelp("ahood doc -- docs (DOC.md) in the ahood registry", [
|
|
790
|
+
"Every verb here acts on docs only: a target of another kind is refused before anything",
|
|
791
|
+
"is downloaded, prompted for, written, or sent. Installs land in .claude/docs/<owner>@<doc>.md.",
|
|
792
|
+
"To look at a doc without installing it, use `ahood doc read`.",
|
|
793
|
+
], DOC_COMMANDS_HELP, "doc");
|
|
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
|
+
};
|
|
754
811
|
export function formatKindHelp(noun) {
|
|
755
|
-
|
|
756
|
-
return formatAgentHelp();
|
|
757
|
-
if (noun === "mcp")
|
|
758
|
-
return formatMcpHelp();
|
|
759
|
-
return formatSkillHelp();
|
|
812
|
+
return KIND_HELP[noun]();
|
|
760
813
|
}
|
|
761
814
|
// `ahood group --help` -- the group-level listing for every group verb.
|
|
762
815
|
export function formatGroupHelp() {
|
|
@@ -793,6 +846,10 @@ export function formatHelp() {
|
|
|
793
846
|
const agentGroupSummary = "The same verbs, for agent definitions only -- run `ahood agent --help` for the full list.";
|
|
794
847
|
const mcpGroupUsage = "ahood mcp <command>";
|
|
795
848
|
const mcpGroupSummary = "The same verbs, for MCP server manifests only; bare `ahood mcp` or `ahood mcp serve` starts the local MCP server.";
|
|
849
|
+
const docGroupUsage = "ahood doc <command>";
|
|
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.";
|
|
796
853
|
const groupGroupUsage = "ahood group <command>";
|
|
797
854
|
const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
|
|
798
855
|
const snapGroupUsage = "ahood snap <command>";
|
|
@@ -802,6 +859,8 @@ export function formatHelp() {
|
|
|
802
859
|
{ usage: skillGroupUsage, summary: skillGroupSummary },
|
|
803
860
|
{ usage: agentGroupUsage, summary: agentGroupSummary },
|
|
804
861
|
{ usage: mcpGroupUsage, summary: mcpGroupSummary },
|
|
862
|
+
{ usage: docGroupUsage, summary: docGroupSummary },
|
|
863
|
+
{ usage: projectGroupUsage, summary: projectGroupSummary },
|
|
805
864
|
{ usage: groupGroupUsage, summary: groupGroupSummary },
|
|
806
865
|
{ usage: snapGroupUsage, summary: snapGroupSummary },
|
|
807
866
|
]);
|
|
@@ -823,7 +882,7 @@ export function formatHelp() {
|
|
|
823
882
|
"",
|
|
824
883
|
"Run `ahood <command> --help` (or `ahood help <command>`) for a single command's flags and examples.",
|
|
825
884
|
"Run `ahood skill --help` for the full list of skill commands.",
|
|
826
|
-
"Run `ahood agent
|
|
885
|
+
"Run `ahood agent|mcp|doc|project --help` for the kind-scoped agent, MCP, doc and project guide commands.",
|
|
827
886
|
"Run `ahood group --help` for the full list of group commands.",
|
|
828
887
|
"Run `ahood snap --help` for the full list of snap commands.",
|
|
829
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";
|
|
@@ -96,6 +97,8 @@ const COMMANDS = {
|
|
|
96
97
|
mcp: dispatchMcp,
|
|
97
98
|
skill: dispatchSkill,
|
|
98
99
|
agent: dispatchAgent,
|
|
100
|
+
doc: dispatchDoc,
|
|
101
|
+
project: dispatchProject,
|
|
99
102
|
group: dispatchGroup,
|
|
100
103
|
snap: dispatchSnap,
|
|
101
104
|
};
|
|
@@ -107,8 +110,9 @@ const COMMANDS = {
|
|
|
107
110
|
// here just means "a command that owns its own sub-verb dispatch", the same
|
|
108
111
|
// sense "skill" already used before "group" (the entity) existed. "snap"
|
|
109
112
|
// joins this set for the exact same reason (ahood-cli#107).
|
|
110
|
-
//
|
|
111
|
-
|
|
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"]);
|
|
112
116
|
function levenshtein(a, b) {
|
|
113
117
|
const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
114
118
|
for (let i = 0; i <= a.length; i++)
|
|
@@ -146,7 +150,7 @@ function closestGroupVerb(input) {
|
|
|
146
150
|
function closestSnapVerb(input) {
|
|
147
151
|
return closestOf(input, Object.keys(SNAP_VERBS));
|
|
148
152
|
}
|
|
149
|
-
// `ahood <noun> <verb> [...]` for every registry noun (
|
|
153
|
+
// `ahood <noun> <verb> [...]` for every registry noun (kind-info.ts KINDS) --
|
|
150
154
|
// owns its own help handling at both the noun level (`ahood agent` /
|
|
151
155
|
// `ahood agent --help`) and the per-verb level (`ahood agent <verb> --help`),
|
|
152
156
|
// since neither should fall through to main()'s generic top-level --help
|
|
@@ -210,6 +214,21 @@ async function dispatchSkill(args) {
|
|
|
210
214
|
async function dispatchAgent(args) {
|
|
211
215
|
await dispatchRegistry("agent", args);
|
|
212
216
|
}
|
|
217
|
+
async function dispatchDoc(args) {
|
|
218
|
+
await dispatchRegistry("doc", args);
|
|
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
|
+
}
|
|
213
232
|
// `ahood mcp` keeps its original contract byte-for-byte: with NO arguments it
|
|
214
233
|
// is the local stdio MCP server that existing host configs launch -- nothing
|
|
215
234
|
// is printed and nothing else runs first. `ahood mcp serve` is the same
|
|
@@ -312,7 +331,7 @@ async function main() {
|
|
|
312
331
|
process.stdout.write(content);
|
|
313
332
|
return;
|
|
314
333
|
}
|
|
315
|
-
if (sub
|
|
334
|
+
if (isCliKind(sub)) {
|
|
316
335
|
const verb = args[1];
|
|
317
336
|
const entry = verb ? findCommandHelp(sub, verb) : undefined;
|
|
318
337
|
console.log(entry ? formatCommandHelp(entry) : formatKindHelp(sub));
|
|
@@ -394,4 +413,4 @@ if (isEntrypoint) {
|
|
|
394
413
|
main();
|
|
395
414
|
}
|
|
396
415
|
// Exported for tests only -- the CLI itself only ever calls main() above.
|
|
397
|
-
export { main, dispatchSkill, dispatchAgent, 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
|
-
// -- so the kind an entry reports now is the kind it has always had.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// -- so the kind an entry reports now is the kind it has always had. Each
|
|
11
|
+
// kind has a noun group (`ahood skill|agent|mcp|doc <verb>`), all run by one
|
|
12
|
+
// shared dispatcher. `doc` joined as the fourth in ahood-cli#182: it installs
|
|
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,9 +41,39 @@ 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" },
|
|
46
|
+
},
|
|
47
|
+
doc: {
|
|
48
|
+
label: "doc",
|
|
49
|
+
article: "a",
|
|
50
|
+
plural: "docs",
|
|
51
|
+
rootDoc: "DOC.md",
|
|
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",
|
|
35
66
|
},
|
|
36
67
|
};
|
|
37
68
|
export const CLI_KINDS = Object.keys(KINDS);
|
|
38
69
|
export function isCliKind(value) {
|
|
39
70
|
return typeof value === "string" && CLI_KINDS.includes(value);
|
|
40
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,6 +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 or project pin records its kind (ahood-cli#182, #185).
|
|
66
|
+
if (isPinnedFileKind(entry?.kind))
|
|
67
|
+
return entry.kind;
|
|
65
68
|
const hasDir = existsSync(skillDir(owner, skill));
|
|
66
69
|
const hasAgentFile = existsSync(agentPath(owner, skill));
|
|
67
70
|
if (hasDir && !hasAgentFile)
|
|
@@ -102,8 +105,8 @@ export function extractKindFlag(args) {
|
|
|
102
105
|
// Resolves the scope for `ahood <noun> <verb> [--kind <k>]`.
|
|
103
106
|
// skill noun, no --kind or --kind all -> legacy (cross-kind)
|
|
104
107
|
// skill noun, --kind <k> -> strict <k>
|
|
105
|
-
//
|
|
106
|
-
//
|
|
108
|
+
// any other noun, no --kind or --kind <same> -> strict <noun>
|
|
109
|
+
// any other noun, any other --kind -> usage error (contradiction)
|
|
107
110
|
export function resolveScope(noun, kindFlag) {
|
|
108
111
|
const valid = [...CLI_KINDS, "all"].join(", ");
|
|
109
112
|
if (kindFlag !== undefined && kindFlag !== "all" && !isCliKind(kindFlag)) {
|
package/dist/lockfile.js
CHANGED
|
@@ -146,6 +146,12 @@ function resolveWriteTarget(path) {
|
|
|
146
146
|
// file it is creating must get what it asked for regardless of the ambient
|
|
147
147
|
// umask.
|
|
148
148
|
export function writeJsonFileAtomic(path, data, mode) {
|
|
149
|
+
writeFileAtomic(path, JSON.stringify(data, null, 2) + "\n", mode);
|
|
150
|
+
}
|
|
151
|
+
// The same write-then-rename for raw content. A doc install (ahood-cli#182)
|
|
152
|
+
// writes its DOC.md through this, so an interrupted install never leaves a
|
|
153
|
+
// truncated doc behind.
|
|
154
|
+
export function writeFileAtomic(path, content, mode) {
|
|
149
155
|
// Resolved before the mkdir so the directory that actually gets created is
|
|
150
156
|
// the resolved target's, not the link's -- a link set up ahead of the first
|
|
151
157
|
// install can point somewhere that doesn't exist yet -- and so that every
|
|
@@ -189,7 +195,7 @@ export function writeJsonFileAtomic(path, data, mode) {
|
|
|
189
195
|
// 0600 from the moment of creation: for .mcp.json this temp file holds
|
|
190
196
|
// resolved MCP server secrets, and it must never be world-readable, not
|
|
191
197
|
// even for the instant before the rename (ahood-cli#119).
|
|
192
|
-
writeFileSync(tmpPath,
|
|
198
|
+
writeFileSync(tmpPath, content, { mode: 0o600 });
|
|
193
199
|
renameSync(tmpPath, dest);
|
|
194
200
|
try {
|
|
195
201
|
chmodSync(dest, finalMode);
|
|
@@ -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
|
@@ -116,3 +116,25 @@ export function agentPath(owner, skill) {
|
|
|
116
116
|
// file isn't under .claude/ at all, since it's a convention MCP clients
|
|
117
117
|
// broadly share, not one specific to this CLI).
|
|
118
118
|
export const MCP_CONFIG_PATH = ".mcp.json";
|
|
119
|
+
// A doc installs as one flat file, .claude/docs/<owner>@<doc>.md
|
|
120
|
+
// (ahood-cli#182), the same "@"-joined shape as agentPath above. Unlike
|
|
121
|
+
// agentPath, both segments are validated again here: callers have already
|
|
122
|
+
// parsed them through parseOwnerSkill, but this is the path a doc install
|
|
123
|
+
// writes to, so the SEGMENT_RE containment is enforced where the path is
|
|
124
|
+
// built too. `alice/..` can never resolve outside .claude/docs/.
|
|
125
|
+
export const DOCS_ROOT = join(".claude", "docs");
|
|
126
|
+
export function docPath(owner, doc) {
|
|
127
|
+
const spec = `${owner}/${doc}`;
|
|
128
|
+
validateSegment(owner, "owner", spec);
|
|
129
|
+
validateSegment(doc, "skill", spec);
|
|
130
|
+
return join(DOCS_ROOT, `${owner}@${doc}.md`);
|
|
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
|
+
}
|