@ahood/cli 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -5
- package/dist/commands/add.js +69 -11
- package/dist/commands/completion.js +61 -37
- package/dist/commands/diff.js +4 -1
- package/dist/commands/edit.js +3 -1
- package/dist/commands/init.js +90 -10
- package/dist/commands/list.js +11 -5
- package/dist/commands/login.js +63 -3
- package/dist/commands/mcp.js +6 -3
- package/dist/commands/publish.js +35 -5
- package/dist/commands/read.js +3 -1
- package/dist/commands/remove.js +29 -1
- package/dist/commands/search.js +12 -4
- package/dist/commands/share.js +5 -2
- package/dist/commands/star.js +5 -2
- package/dist/commands/unpublish.js +5 -1
- package/dist/commands/update.js +61 -9
- package/dist/commands/versions.js +3 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +50 -9
- package/dist/help.js +270 -7
- package/dist/http.js +125 -26
- package/dist/index.js +106 -22
- package/dist/kind-info.js +40 -0
- package/dist/kinds.js +122 -0
- package/dist/lockfile.js +142 -17
- package/dist/mcp/safe-tool.js +14 -2
- package/dist/spec.js +8 -5
- package/dist/useme/SKILL.md +170 -0
- package/dist/useme.js +14 -0
- package/package.json +3 -2
package/dist/commands/list.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { KINDS, strictKind } from "../kinds.js";
|
|
3
|
+
// A strict kind is filtered by the registry: its `mine=true` branch honours
|
|
4
|
+
// ?kind= (ahood#657) and, with no page/per_page, still returns every owned
|
|
5
|
+
// row unpaginated -- the same shape as before.
|
|
6
|
+
export async function listOwnSkills(kind) {
|
|
7
|
+
const query = kind ? `mine=true&kind=${encodeURIComponent(kind)}` : "mine=true";
|
|
8
|
+
const { skills } = await apiJson(`/api/v1/skills?${query}`);
|
|
4
9
|
return skills ?? [];
|
|
5
10
|
}
|
|
6
|
-
export async function listSkills(args = []) {
|
|
11
|
+
export async function listSkills(args = [], scope) {
|
|
7
12
|
const jsonOutput = args.includes("--json");
|
|
8
|
-
const
|
|
13
|
+
const kind = strictKind(scope);
|
|
14
|
+
const skills = await listOwnSkills(kind);
|
|
9
15
|
if (jsonOutput) {
|
|
10
16
|
console.log(JSON.stringify(skills));
|
|
11
17
|
return;
|
|
12
18
|
}
|
|
13
19
|
if (skills.length === 0) {
|
|
14
|
-
console.log("You haven't published any skills yet.");
|
|
20
|
+
console.log(kind ? `You haven't published any ${KINDS[kind].plural} yet.` : "You haven't published any skills yet.");
|
|
15
21
|
return;
|
|
16
22
|
}
|
|
17
23
|
for (const skill of skills) {
|
package/dist/commands/login.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { apiJson } from "../http.js";
|
|
1
|
+
import { apiJson, parseRetryAfterMs } from "../http.js";
|
|
2
2
|
import { getApiUrl } from "../config.js";
|
|
3
3
|
import { writeCredentials } from "../credentials.js";
|
|
4
4
|
const DEFAULT_EXPIRES_IN_SECONDS = 600; // 10 minutes, matching the previous timeout loop's real-world duration
|
|
5
5
|
const POLL_TIMEOUT_MS = 10_000;
|
|
6
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
7
|
+
// ahood-cli#159: cap on a single 429 backoff hop, same policy as apiJson's
|
|
8
|
+
// rate-limit retry -- however large the server's Retry-After, one wait stays
|
|
9
|
+
// bounded, and the deadline check below keeps the loop inside expires_in.
|
|
10
|
+
const RATE_LIMIT_MAX_BACKOFF_MS = 30_000;
|
|
6
11
|
function sleep(ms) {
|
|
7
12
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
13
|
}
|
|
@@ -10,6 +15,11 @@ export async function login() {
|
|
|
10
15
|
const { code, verification_url, expires_in } = await apiJson("/api/v1/auth/cli/device", {
|
|
11
16
|
method: "POST",
|
|
12
17
|
});
|
|
18
|
+
// Printed VERBATIM, deliberately not through sanitizeForTerminal the way
|
|
19
|
+
// `add` treats server-supplied text: this line exists so the user can
|
|
20
|
+
// compare it against the code on the /cli-auth page, and a tidied-up
|
|
21
|
+
// rendering would make that comparison meaningless. Pinned by
|
|
22
|
+
// tests/login-device-code-verbatim.test.ts (ahood#357).
|
|
13
23
|
console.log(`First, confirm this code matches what you see in your browser: ${code}`);
|
|
14
24
|
console.log(`Open ${verification_url} to approve.`);
|
|
15
25
|
// expires_in is server-supplied; a missing/non-finite value previously
|
|
@@ -18,8 +28,29 @@ export async function login() {
|
|
|
18
28
|
// failed instantly with "timed out" instead of ever actually polling.
|
|
19
29
|
const seconds = Number.isFinite(expires_in) && expires_in > 0 ? expires_in : DEFAULT_EXPIRES_IN_SECONDS;
|
|
20
30
|
const deadline = Date.now() + seconds * 1000;
|
|
31
|
+
// ahood-cli#159: when the registry rate-limits the poll, `backoffMs` carries
|
|
32
|
+
// the wait for the NEXT poll -- 429 must not `continue` on the normal
|
|
33
|
+
// cadence, which is the retry-storm shape: the client answers "you are
|
|
34
|
+
// asking too often" by asking again on the same schedule, against an
|
|
35
|
+
// endpoint that can least afford it.
|
|
36
|
+
let backoffMs;
|
|
37
|
+
let consecutive429s = 0;
|
|
38
|
+
// Whether the loop ever saw a 429 -- the timeout message below can then say
|
|
39
|
+
// why the window ran out instead of blaming the user's pace.
|
|
40
|
+
let wasRateLimited = false;
|
|
21
41
|
while (Date.now() < deadline) {
|
|
22
|
-
|
|
42
|
+
let waitMs = POLL_INTERVAL_MS;
|
|
43
|
+
if (backoffMs !== undefined) {
|
|
44
|
+
waitMs = backoffMs;
|
|
45
|
+
backoffMs = undefined;
|
|
46
|
+
// The 429 backoff counts against the login deadline like every other
|
|
47
|
+
// wait: a server that keeps answering "wait 60s" must not stretch the
|
|
48
|
+
// loop past expires_in -- and past expiry the device code is dead, so
|
|
49
|
+
// polling after the wait would be pointless anyway.
|
|
50
|
+
if (Date.now() + waitMs >= deadline)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
await sleep(waitMs);
|
|
23
54
|
let res;
|
|
24
55
|
try {
|
|
25
56
|
// Poll against the CONFIGURED API host (getApiUrl()), not a URL derived
|
|
@@ -27,6 +58,17 @@ export async function login() {
|
|
|
27
58
|
// shown to the human, never used to build a request, so a compromised
|
|
28
59
|
// or redirected registry response can't point this at a host that
|
|
29
60
|
// then harvests the device code and hands back an attacker's token.
|
|
61
|
+
//
|
|
62
|
+
// `code` is sent VERBATIM and must stay that way: do not trim it,
|
|
63
|
+
// change its case, strip its separator or otherwise tidy it. The format
|
|
64
|
+
// is defined once, in ahood's lib/auth/device-code.ts, and the server
|
|
65
|
+
// canonicalizes whatever arrives -- so this CLI stays format-agnostic
|
|
66
|
+
// and a server-side format fix needs no release of this package. In
|
|
67
|
+
// ahood#349 that property was all that stood between a bad ten-minute
|
|
68
|
+
// "Login timed out" and a bad error: the hyphen happens to be
|
|
69
|
+
// unreserved, so encodeURIComponent left it alone. Pinned by
|
|
70
|
+
// tests/login-device-code-verbatim.test.ts; contract in ahood's
|
|
71
|
+
// docs/adr/backend/0005-device-code-cross-repo-contract.md (ahood#357).
|
|
30
72
|
res = await apiFetchWithTimeout(`/api/v1/auth/cli/device/${encodeURIComponent(code)}`);
|
|
31
73
|
}
|
|
32
74
|
catch (error) {
|
|
@@ -46,6 +88,22 @@ export async function login() {
|
|
|
46
88
|
if (res.status === 410 || res.status === 404) {
|
|
47
89
|
throw new Error("This login was cancelled or expired. Run `ahood login` again.");
|
|
48
90
|
}
|
|
91
|
+
if (res.status === 429) {
|
|
92
|
+
// ahood-cli#159: the registry has said stop. Back off by Retry-After
|
|
93
|
+
// (exponentially, 1s doubling, when the header is absent/unparseable)
|
|
94
|
+
// instead of polling on -- and let the deadline check at the top of the
|
|
95
|
+
// loop bound the total time, so rate limiting cannot extend the window.
|
|
96
|
+
wasRateLimited = true;
|
|
97
|
+
consecutive429s++;
|
|
98
|
+
// Drain the rejected body so the socket is released while we wait.
|
|
99
|
+
await res.text().catch(() => "");
|
|
100
|
+
const retryAfterMs = parseRetryAfterMs(res.headers.get("Retry-After"));
|
|
101
|
+
const wait = Math.min(retryAfterMs ?? 1_000 * 2 ** (consecutive429s - 1), RATE_LIMIT_MAX_BACKOFF_MS);
|
|
102
|
+
backoffMs = wait;
|
|
103
|
+
console.error(`Rate limited while polling; waiting ${Math.ceil(wait / 1000)}s before trying again...`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
consecutive429s = 0;
|
|
49
107
|
let body;
|
|
50
108
|
try {
|
|
51
109
|
body = (await res.json());
|
|
@@ -65,7 +123,9 @@ export async function login() {
|
|
|
65
123
|
}
|
|
66
124
|
// status === "pending" -- keep polling.
|
|
67
125
|
}
|
|
68
|
-
throw new Error(
|
|
126
|
+
throw new Error(wasRateLimited
|
|
127
|
+
? "Login timed out -- the registry kept rate limiting the poll. Wait a bit and run `ahood login` again."
|
|
128
|
+
: "Login timed out. Run `ahood login` again.");
|
|
69
129
|
}
|
|
70
130
|
// Not apiJson: a non-2xx poll response ("pending", still-provisioning, etc.)
|
|
71
131
|
// is expected and must not throw -- the status code is inspected by the
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { startMcpServer } from "../mcp/server.js";
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Starts the local stdio MCP server -- reached as bare `ahood mcp` (the
|
|
3
|
+
// original v1 contract, kept byte-for-byte for existing host configs) or
|
|
4
|
+
// `ahood mcp serve`; index.ts's dispatchMcp() decides which first words mean
|
|
5
|
+
// "serve" and which are registry verbs (ahood-cli#172). Unlike every other
|
|
6
|
+
// command it never returns until the client disconnects -- see
|
|
7
|
+
// startMcpServer's comment.
|
|
5
8
|
export async function mcp(_args) {
|
|
6
9
|
await startMcpServer();
|
|
7
10
|
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -6,6 +6,26 @@ 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";
|
|
10
|
+
// `ahood agent publish` / `ahood mcp publish` (ahood-cli#172): an existing
|
|
11
|
+
// entry of another kind must be refused before anything is packed, created,
|
|
12
|
+
// or uploaded. The upload would fail server-side anyway -- the processing
|
|
13
|
+
// workflow validates the archive against the entry's stored kind -- but only
|
|
14
|
+
// after a version row was created and a full upload spent. A 404 is the
|
|
15
|
+
// "doesn't exist yet" case publish already handles by creating the entry,
|
|
16
|
+
// now with the scope's kind.
|
|
17
|
+
async function assertPublishTargetKind(scope, owner, skill) {
|
|
18
|
+
let detail;
|
|
19
|
+
try {
|
|
20
|
+
detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof ApiError && error.status === 404)
|
|
24
|
+
return;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
assertKind(scope, `${owner}/${skill}`, detail.kind, "publish", "publish a new version of");
|
|
28
|
+
}
|
|
9
29
|
// Processing (decompress/validate/checksum/scan/upload) now happens in a
|
|
10
30
|
// background workflow, not inline in versions/complete's response (server
|
|
11
31
|
// issue #120) -- complete returns 202 immediately, so this polls
|
|
@@ -342,10 +362,18 @@ async function initVersion(owner, skill, version, packageSizeBytes, changelog, c
|
|
|
342
362
|
// validation, network, or a rejected publish -- still yields exactly one
|
|
343
363
|
// JSON object (`{error}`) rather than a human message, while still
|
|
344
364
|
// rethrowing so index.ts's exit-code mapping (exitCodeFor) is unaffected.
|
|
345
|
-
export async function publish(args) {
|
|
365
|
+
export async function publish(args, scope) {
|
|
346
366
|
const jsonOutput = args.includes("--json");
|
|
347
367
|
try {
|
|
348
|
-
const
|
|
368
|
+
const parsed = parsePublishArgs(args);
|
|
369
|
+
const { owner, skill, version, path, name, tagline, tags, license, homepage, repository, changelog } = parsed;
|
|
370
|
+
// A kind-scoped publish implies its kind. The dispatcher has already
|
|
371
|
+
// refused a contradictory --kind before this runs, so the flag (when the
|
|
372
|
+
// legacy `ahood skill publish` passes one through) and the scope can't
|
|
373
|
+
// disagree here.
|
|
374
|
+
const scopedKind = strictKind(scope);
|
|
375
|
+
const kind = scopedKind ?? parsed.kind;
|
|
376
|
+
const via = scopedKind ? `ahood ${scope.noun} publish` : `publish --kind ${kind}`;
|
|
349
377
|
const skillMdPath = join(path, "SKILL.md");
|
|
350
378
|
const agentMdPath = join(path, "AGENT.md");
|
|
351
379
|
const serverJsonPath = join(path, "server.json");
|
|
@@ -355,15 +383,15 @@ export async function publish(args) {
|
|
|
355
383
|
let resolvedKind = kind;
|
|
356
384
|
if (kind === "agent") {
|
|
357
385
|
if (!hasAgentMd)
|
|
358
|
-
throw new Error(`No AGENT.md found at ${agentMdPath} --
|
|
386
|
+
throw new Error(`No AGENT.md found at ${agentMdPath} -- ${via} must point at a folder containing AGENT.md.`);
|
|
359
387
|
}
|
|
360
388
|
else if (kind === "skill") {
|
|
361
389
|
if (!hasSkillMd)
|
|
362
|
-
throw new Error(`No SKILL.md found at ${skillMdPath} --
|
|
390
|
+
throw new Error(`No SKILL.md found at ${skillMdPath} -- ${via} must point at a folder containing SKILL.md.`);
|
|
363
391
|
}
|
|
364
392
|
else if (kind === "mcp") {
|
|
365
393
|
if (!hasServerJson)
|
|
366
|
-
throw new Error(`No server.json found at ${serverJsonPath} --
|
|
394
|
+
throw new Error(`No server.json found at ${serverJsonPath} -- ${via} must point at a folder containing server.json.`);
|
|
367
395
|
}
|
|
368
396
|
else {
|
|
369
397
|
// kind === undefined -- infer from whichever root file(s) are actually present
|
|
@@ -390,6 +418,8 @@ export async function publish(args) {
|
|
|
390
418
|
if (resolvedKind === "skill" || (resolvedKind === undefined && hasSkillMd)) {
|
|
391
419
|
warnAboutSkillDescription(skillMdPath);
|
|
392
420
|
}
|
|
421
|
+
if (scopedKind !== undefined)
|
|
422
|
+
await assertPublishTargetKind(scope, owner, skill);
|
|
393
423
|
const archive = await tarGzDirectory(path);
|
|
394
424
|
const { init, created } = await initVersion(owner, skill, version, archive.length, changelog, { name, tagline, tags, license, homepage, repository, kind: resolvedKind }, jsonOutput);
|
|
395
425
|
// TS's lib.dom BodyInit (in scope here since tsconfig has no explicit
|
package/dist/commands/read.js
CHANGED
|
@@ -2,6 +2,7 @@ import { apiJson } from "../http.js";
|
|
|
2
2
|
import { parseOwnerSkill } from "../spec.js";
|
|
3
3
|
import { sanitizeDocumentForTerminal, sanitizeForTerminal } from "../terminal-safe.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
+
import { ensureRemoteKind } from "../kinds.js";
|
|
5
6
|
const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
|
|
6
7
|
export async function readSkillMd(owner, skill) {
|
|
7
8
|
const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
@@ -41,12 +42,13 @@ export async function readSkillMd(owner, skill) {
|
|
|
41
42
|
yanked_reason: detail.skill_versions.yanked_reason ?? null,
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
|
-
export async function read(args) {
|
|
45
|
+
export async function read(args, scope) {
|
|
45
46
|
const jsonOutput = args.includes("--json");
|
|
46
47
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
47
48
|
if (!spec)
|
|
48
49
|
throw new UsageError(USAGE);
|
|
49
50
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
51
|
+
await ensureRemoteKind(scope, owner, skill, "read", "read");
|
|
50
52
|
const { version, content } = await readSkillMd(owner, skill);
|
|
51
53
|
if (jsonOutput) {
|
|
52
54
|
console.log(JSON.stringify({ version, content }));
|
package/dist/commands/remove.js
CHANGED
|
@@ -4,8 +4,34 @@ import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from
|
|
|
4
4
|
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, 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";
|
|
8
|
+
// `ahood agent remove` / `ahood mcp remove` (ahood-cli#172): decide whether
|
|
9
|
+
// this install really is the scope's kind BEFORE the prompt and every delete
|
|
10
|
+
// below. Local evidence first -- remove has always worked offline, and the
|
|
11
|
+
// footprint answers it for every install made by a current CLI. Only a pin
|
|
12
|
+
// whose footprint is ambiguous (a pre-fingerprint mcp pin, or a skill whose
|
|
13
|
+
// directory was deleted by hand) asks the registry, and if that can't answer
|
|
14
|
+
// either, nothing is removed.
|
|
15
|
+
async function assertInstalledKind(scope, owner, skill, lockEntry) {
|
|
16
|
+
const key = `${owner}/${skill}`;
|
|
17
|
+
const local = localInstalledKind(owner, skill, lockEntry);
|
|
18
|
+
if (local !== undefined) {
|
|
19
|
+
assertKind(scope, key, local, "remove", "remove");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
await ensureRemoteKind(scope, owner, skill, "remove", "remove");
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof KindMismatchError)
|
|
27
|
+
throw error;
|
|
28
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
29
|
+
throw new Error(`Could not confirm that ${key} is installed as ${describeKind(strictKind(scope))}: its local files don't say, and the registry lookup failed (${reason}). ` +
|
|
30
|
+
`Nothing was removed. Use \`ahood skill remove ${key}\` (legacy, any kind) to remove it anyway.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
7
33
|
const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
|
|
8
|
-
export async function remove(args) {
|
|
34
|
+
export async function remove(args, scope) {
|
|
9
35
|
const spec = args[0];
|
|
10
36
|
if (!spec)
|
|
11
37
|
throw new UsageError(USAGE);
|
|
@@ -35,6 +61,8 @@ export async function remove(args) {
|
|
|
35
61
|
process.exitCode = 1;
|
|
36
62
|
return;
|
|
37
63
|
}
|
|
64
|
+
if (scope && strictKind(scope) !== undefined)
|
|
65
|
+
await assertInstalledKind(scope, owner, skill, lockEntry);
|
|
38
66
|
// Matches unpublish.ts/group.ts's confirm-before-destroy pattern (CLAUDE.md:
|
|
39
67
|
// "Destructive commands ... prompt for confirmation unless --yes is passed").
|
|
40
68
|
// Checked only once something is actually installed, so a no-op remove of an
|
package/dist/commands/search.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { flagValue, parseSearchQuery } from "../flags.js";
|
|
3
3
|
import { UsageError } from "../usage-error.js";
|
|
4
|
+
import { KINDS, strictKind } from "../kinds.js";
|
|
4
5
|
const USAGE = "Usage: ahood skill search <query> [--json] [--limit <n>]";
|
|
5
|
-
|
|
6
|
+
// `kind` narrows the search server-side (GET /api/v1/skills?kind=, which the
|
|
7
|
+
// backend validates against its ALLOWED_KINDS). Omitted -- the legacy
|
|
8
|
+
// `ahood skill search` and the MCP skill_search tool -- the request is
|
|
9
|
+
// byte-for-byte what it always was: every kind.
|
|
10
|
+
export async function searchSkills(query, limit, kind) {
|
|
6
11
|
const qs = new URLSearchParams({ q: query });
|
|
7
12
|
if (limit !== undefined)
|
|
8
13
|
qs.set("per_page", String(limit));
|
|
14
|
+
if (kind !== undefined)
|
|
15
|
+
qs.set("kind", kind);
|
|
9
16
|
const { skills } = await apiJson(`/api/v1/skills?${qs}`);
|
|
10
17
|
return skills ?? [];
|
|
11
18
|
}
|
|
12
|
-
export async function search(args) {
|
|
19
|
+
export async function search(args, scope) {
|
|
20
|
+
const kind = strictKind(scope);
|
|
13
21
|
const jsonOutput = args.includes("--json");
|
|
14
22
|
const limitStr = flagValue(args, "--limit");
|
|
15
23
|
const query = parseSearchQuery(args, USAGE);
|
|
@@ -17,13 +25,13 @@ export async function search(args) {
|
|
|
17
25
|
throw new UsageError(`--limit must be a positive integer (got "${limitStr}").\n${USAGE}`);
|
|
18
26
|
}
|
|
19
27
|
const limit = limitStr !== undefined ? Number(limitStr) : undefined;
|
|
20
|
-
const skills = await searchSkills(query, limit);
|
|
28
|
+
const skills = await searchSkills(query, limit, kind);
|
|
21
29
|
if (jsonOutput) {
|
|
22
30
|
console.log(JSON.stringify(skills));
|
|
23
31
|
return;
|
|
24
32
|
}
|
|
25
33
|
if (skills.length === 0) {
|
|
26
|
-
console.log("No skills found.");
|
|
34
|
+
console.log(kind ? `No ${KINDS[kind].plural} found.` : "No skills found.");
|
|
27
35
|
return;
|
|
28
36
|
}
|
|
29
37
|
for (const skill of skills) {
|
package/dist/commands/share.js
CHANGED
|
@@ -2,6 +2,7 @@ import { apiJson } from "../http.js";
|
|
|
2
2
|
import { flagValue } from "../flags.js";
|
|
3
3
|
import { parseOwnerSkill } from "../spec.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
+
import { ensureRemoteKind } from "../kinds.js";
|
|
5
6
|
// These are skill-entity verbs (reached as `ahood skill share`/`ahood skill
|
|
6
7
|
// unshare`, registered in SKILL_COMMANDS in index.ts), not group verbs --
|
|
7
8
|
// they mutate a skill's sharing, not a group. Grouped together in one file
|
|
@@ -14,7 +15,7 @@ const UNSHARE_USAGE = "Usage: ahood skill unshare <owner>/<skill> --group <group
|
|
|
14
15
|
// first (before any flags), since --group takes a value and a "first
|
|
15
16
|
// non---flag token" scan would misidentify the --group value as the spec
|
|
16
17
|
// if --group happened to come first.
|
|
17
|
-
export async function share(args) {
|
|
18
|
+
export async function share(args, scope) {
|
|
18
19
|
const spec = args[0];
|
|
19
20
|
if (!spec || spec.startsWith("--"))
|
|
20
21
|
throw new UsageError(SHARE_USAGE);
|
|
@@ -22,6 +23,7 @@ export async function share(args) {
|
|
|
22
23
|
const groupSlug = flagValue(args, "--group");
|
|
23
24
|
if (!groupSlug)
|
|
24
25
|
throw new UsageError(SHARE_USAGE);
|
|
26
|
+
await ensureRemoteKind(scope, owner, skill, "share", "share");
|
|
25
27
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share`, {
|
|
26
28
|
method: "POST",
|
|
27
29
|
headers: { "Content-Type": "application/json" },
|
|
@@ -29,7 +31,7 @@ export async function share(args) {
|
|
|
29
31
|
});
|
|
30
32
|
console.log(`Shared ${owner}/${skill} with ${groupSlug}.`);
|
|
31
33
|
}
|
|
32
|
-
export async function unshare(args) {
|
|
34
|
+
export async function unshare(args, scope) {
|
|
33
35
|
const spec = args[0];
|
|
34
36
|
if (!spec || spec.startsWith("--"))
|
|
35
37
|
throw new UsageError(UNSHARE_USAGE);
|
|
@@ -37,6 +39,7 @@ export async function unshare(args) {
|
|
|
37
39
|
const groupSlug = flagValue(args, "--group");
|
|
38
40
|
if (!groupSlug)
|
|
39
41
|
throw new UsageError(UNSHARE_USAGE);
|
|
42
|
+
await ensureRemoteKind(scope, owner, skill, "unshare", "unshare");
|
|
40
43
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/share/${encodeURIComponent(groupSlug)}`, { method: "DELETE" });
|
|
41
44
|
console.log(`Unshared ${owner}/${skill} from ${groupSlug}.`);
|
|
42
45
|
}
|
package/dist/commands/star.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
+
import { ensureRemoteKind } from "../kinds.js";
|
|
3
4
|
// Mirrors POST/DELETE /api/v1/skills/{owner}/{skill}/star -- both are
|
|
4
5
|
// idempotent server-side (POST upserts on the stars table's
|
|
5
6
|
// unique(skill_id, user_id) constraint with ignoreDuplicates, DELETE just
|
|
6
7
|
// deletes any matching row), so starring an already-starred skill or
|
|
7
8
|
// unstarring one you never starred is a no-op 200, not an error.
|
|
8
|
-
export async function star(args) {
|
|
9
|
+
export async function star(args, scope) {
|
|
9
10
|
const USAGE = "Usage: ahood skill star <owner>/<skill>";
|
|
10
11
|
const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
|
|
12
|
+
await ensureRemoteKind(scope, owner, skill, "star", "star");
|
|
11
13
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
|
|
12
14
|
method: "POST",
|
|
13
15
|
});
|
|
14
16
|
console.log(`Starred ${owner}/${skill}.`);
|
|
15
17
|
}
|
|
16
|
-
export async function unstar(args) {
|
|
18
|
+
export async function unstar(args, scope) {
|
|
17
19
|
const USAGE = "Usage: ahood skill unstar <owner>/<skill>";
|
|
18
20
|
const { owner, skill } = parseOwnerSkill(args[0] ?? "", USAGE);
|
|
21
|
+
await ensureRemoteKind(scope, owner, skill, "unstar", "unstar");
|
|
19
22
|
await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/star`, {
|
|
20
23
|
method: "DELETE",
|
|
21
24
|
});
|
|
@@ -3,6 +3,7 @@ import { confirm } from "../confirm.js";
|
|
|
3
3
|
import { parseOwnerSkill, parseOwnerSkillVersion } from "../spec.js";
|
|
4
4
|
import { fetchVersionMeta } from "./add.js";
|
|
5
5
|
import { UsageError } from "../usage-error.js";
|
|
6
|
+
import { ensureRemoteKind } from "../kinds.js";
|
|
6
7
|
const USAGE = "Usage: ahood skill unpublish <owner>/<skill>[@version] [--yes]";
|
|
7
8
|
// A bare "owner/skill" has no "@" at all, so lastIndexOf("@") is -1 and this
|
|
8
9
|
// is a whole-skill unpublish (preserves the exact pre-existing behavior).
|
|
@@ -13,13 +14,15 @@ const USAGE = "Usage: ahood skill unpublish <owner>/<skill>[@version] [--yes]";
|
|
|
13
14
|
function isVersioned(spec) {
|
|
14
15
|
return spec.lastIndexOf("@") > 0;
|
|
15
16
|
}
|
|
16
|
-
export async function unpublish(args) {
|
|
17
|
+
export async function unpublish(args, scope) {
|
|
17
18
|
const yes = args.includes("--yes");
|
|
18
19
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
19
20
|
if (!spec)
|
|
20
21
|
throw new UsageError(USAGE);
|
|
21
22
|
if (isVersioned(spec)) {
|
|
22
23
|
const { owner, skill, version: parsedVersion } = parseOwnerSkillVersion(spec, USAGE);
|
|
24
|
+
// Before resolving "latest", before the prompt, before the DELETE.
|
|
25
|
+
await ensureRemoteKind(scope, owner, skill, "unpublish", "yank a version of");
|
|
23
26
|
// parseOwnerSkillVersion only validates *syntax* -- an omitted "@version"
|
|
24
27
|
// or an explicit "@latest" both parse to the literal string "latest".
|
|
25
28
|
// But the yank request below hits DELETE .../versions/{version}, which
|
|
@@ -72,6 +75,7 @@ export async function unpublish(args) {
|
|
|
72
75
|
}
|
|
73
76
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
74
77
|
const key = `${owner}/${skill}`;
|
|
78
|
+
await ensureRemoteKind(scope, owner, skill, "unpublish", "unpublish");
|
|
75
79
|
// --yes bypasses the prompt for scripts/CI; otherwise a closed stdin (no
|
|
76
80
|
// TTY, no piped answer either) now throws via confirm()'s "close" handling
|
|
77
81
|
// instead of silently exiting 0 with nothing prompted, confirmed, or done.
|
package/dist/commands/update.js
CHANGED
|
@@ -2,10 +2,9 @@ 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
6
|
const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
|
|
6
|
-
|
|
7
|
-
const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
|
|
8
|
-
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
7
|
+
function toPreview(ownerSlashSkill, currentVersion, meta) {
|
|
9
8
|
const upToDate = currentVersion === meta.version;
|
|
10
9
|
return {
|
|
11
10
|
skill: ownerSlashSkill,
|
|
@@ -55,7 +54,7 @@ function padRow(cells, widths) {
|
|
|
55
54
|
// Prints a side-by-side current/latest table for every previewed skill, then
|
|
56
55
|
// -- for only the skills that actually have an update available -- the
|
|
57
56
|
// changelog for the version they'd move to. Nothing here touches disk or the
|
|
58
|
-
// network beyond the read-only resolution already done
|
|
57
|
+
// network beyond the read-only resolution already done by the caller before toPreview().
|
|
59
58
|
function printDryRunTable(previews) {
|
|
60
59
|
const header = ["SKILL", "CURRENT", "LATEST", "STATUS"];
|
|
61
60
|
const rows = previews.map((p) => [
|
|
@@ -78,7 +77,30 @@ function printDryRunTable(previews) {
|
|
|
78
77
|
console.log(p.changelog_md ? p.changelog_md : "(no changelog available)");
|
|
79
78
|
}
|
|
80
79
|
}
|
|
81
|
-
|
|
80
|
+
// Kind scoping for update/outdated (ahood-cli#172). Returns true when `key`
|
|
81
|
+
// should be skipped as out of scope, throws when it must be refused, and
|
|
82
|
+
// returns false when it is in scope.
|
|
83
|
+
//
|
|
84
|
+
// explicit target, wrong kind -> refused (counts as that entry's failure)
|
|
85
|
+
// no-arg run, wrong kind -> silently out of scope: `ahood agent update`
|
|
86
|
+
// only ever considers installed agents
|
|
87
|
+
// no-arg run, kind unknown -> skipped with a warning rather than guessed
|
|
88
|
+
// into (or out of) the run
|
|
89
|
+
function outOfScope(scope, key, actual, implicit) {
|
|
90
|
+
const kind = strictKind(scope);
|
|
91
|
+
if (kind === undefined || actual === kind)
|
|
92
|
+
return false;
|
|
93
|
+
if (!implicit) {
|
|
94
|
+
assertKind(scope, key, actual, "update", "update");
|
|
95
|
+
return false; // unreachable: assertKind throws for every actual !== kind
|
|
96
|
+
}
|
|
97
|
+
if (!isCliKind(actual)) {
|
|
98
|
+
console.warn(`WARNING: skipping ${key} -- the registry did not report a kind ahood can match against ${KINDS[kind].plural}.`);
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
export async function update(args, scope) {
|
|
103
|
+
const kind = strictKind(scope);
|
|
82
104
|
const dryRun = args.includes("--dry-run");
|
|
83
105
|
const jsonOutput = args.includes("--json");
|
|
84
106
|
if (jsonOutput && !dryRun) {
|
|
@@ -86,9 +108,23 @@ export async function update(args) {
|
|
|
86
108
|
}
|
|
87
109
|
const targets = args.filter((a) => a !== "--dry-run" && a !== "--json");
|
|
88
110
|
const lockfile = readLockfile(LOCKFILE_PATH);
|
|
89
|
-
const
|
|
111
|
+
const implicit = targets.length === 0;
|
|
112
|
+
let skillKeys = implicit ? Object.keys(lockfile) : targets;
|
|
113
|
+
if (implicit && kind) {
|
|
114
|
+
// A cheap local pre-filter so a pin this project's own files already
|
|
115
|
+
// show to be another kind costs no request (and can't fail the run).
|
|
116
|
+
// Unknown footprints stay in and are settled by the registry's answer.
|
|
117
|
+
skillKeys = skillKeys.filter((key) => {
|
|
118
|
+
const parts = key.split("/");
|
|
119
|
+
if (parts.length !== 2)
|
|
120
|
+
return true; // let the loop report the bad key as before
|
|
121
|
+
const local = localInstalledKind(parts[0], parts[1], lockfile[key]);
|
|
122
|
+
return local === undefined || local === kind;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const noneMessage = kind ? `No installed ${KINDS[kind].plural} to update.` : "No installed skills to update.";
|
|
90
126
|
if (skillKeys.length === 0) {
|
|
91
|
-
console.log(
|
|
127
|
+
console.log(noneMessage);
|
|
92
128
|
return;
|
|
93
129
|
}
|
|
94
130
|
if (dryRun) {
|
|
@@ -99,7 +135,11 @@ export async function update(args) {
|
|
|
99
135
|
const failures = [];
|
|
100
136
|
for (const key of skillKeys) {
|
|
101
137
|
try {
|
|
102
|
-
|
|
138
|
+
const { owner, skill } = parseOwnerSkill(key, USAGE);
|
|
139
|
+
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
140
|
+
if (outOfScope(scope, key, meta.kind, implicit))
|
|
141
|
+
continue;
|
|
142
|
+
previews.push(toPreview(key, lockfile[key]?.version ?? null, meta));
|
|
103
143
|
}
|
|
104
144
|
catch (error) {
|
|
105
145
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -120,6 +160,7 @@ export async function update(args) {
|
|
|
120
160
|
return;
|
|
121
161
|
}
|
|
122
162
|
const failures = [];
|
|
163
|
+
let inScope = 0;
|
|
123
164
|
for (const ownerSlashSkill of skillKeys) {
|
|
124
165
|
if (!lockfile[ownerSlashSkill]) {
|
|
125
166
|
// Reachable two ways: an explicitly-named skill that was never
|
|
@@ -141,6 +182,9 @@ export async function update(args) {
|
|
|
141
182
|
// mcp-update support) instead.
|
|
142
183
|
const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
|
|
143
184
|
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
185
|
+
if (outOfScope(scope, ownerSlashSkill, meta.kind, implicit))
|
|
186
|
+
continue;
|
|
187
|
+
inScope++;
|
|
144
188
|
if (meta.kind === "mcp") {
|
|
145
189
|
const currentEntry = lockfile[ownerSlashSkill];
|
|
146
190
|
if (currentEntry && currentEntry.version === meta.version && mcpEntryIsIntact(skill)) {
|
|
@@ -168,7 +212,12 @@ export async function update(args) {
|
|
|
168
212
|
await updateMcpEntry(owner, skill, meta, currentEntry);
|
|
169
213
|
continue;
|
|
170
214
|
}
|
|
171
|
-
|
|
215
|
+
// no @version -- resolves to latest again. A strict scope is passed on so
|
|
216
|
+
// add() re-checks the kind it resolves itself before writing anything.
|
|
217
|
+
if (kind)
|
|
218
|
+
await add([ownerSlashSkill], scope);
|
|
219
|
+
else
|
|
220
|
+
await add([ownerSlashSkill]);
|
|
172
221
|
}
|
|
173
222
|
catch (error) {
|
|
174
223
|
// One skill being removed/yanked/unreachable must not stop every other
|
|
@@ -178,6 +227,9 @@ export async function update(args) {
|
|
|
178
227
|
failures.push(ownerSlashSkill);
|
|
179
228
|
}
|
|
180
229
|
}
|
|
230
|
+
if (implicit && kind && inScope === 0 && failures.length === 0) {
|
|
231
|
+
console.log(noneMessage);
|
|
232
|
+
}
|
|
181
233
|
if (failures.length > 0) {
|
|
182
234
|
console.error(`${failures.length} of ${skillKeys.length} skill(s) failed to update: ${failures.join(", ")}`);
|
|
183
235
|
process.exitCode = 1;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { parseOwnerSkill } from "../spec.js";
|
|
3
3
|
import { UsageError } from "../usage-error.js";
|
|
4
|
+
import { ensureRemoteKind } from "../kinds.js";
|
|
4
5
|
const USAGE = "Usage: ahood skill versions <owner>/<skill> [--json]";
|
|
5
6
|
function formatSize(bytes) {
|
|
6
7
|
if (bytes < 1024)
|
|
@@ -18,12 +19,13 @@ export async function listSkillVersions(owner, skill) {
|
|
|
18
19
|
const { versions } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions`);
|
|
19
20
|
return versions ?? [];
|
|
20
21
|
}
|
|
21
|
-
export async function versions(args) {
|
|
22
|
+
export async function versions(args, scope) {
|
|
22
23
|
const jsonOutput = args.includes("--json");
|
|
23
24
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
24
25
|
if (!spec)
|
|
25
26
|
throw new UsageError(USAGE);
|
|
26
27
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
28
|
+
await ensureRemoteKind(scope, owner, skill, "versions", "list the versions of");
|
|
27
29
|
const list = await listSkillVersions(owner, skill);
|
|
28
30
|
if (jsonOutput) {
|
|
29
31
|
console.log(JSON.stringify(list));
|
package/dist/commands/view.js
CHANGED
|
@@ -3,6 +3,7 @@ import { apiJson } from "../http.js";
|
|
|
3
3
|
import { getApiUrl } from "../config.js";
|
|
4
4
|
import { parseOwnerSkill } from "../spec.js";
|
|
5
5
|
import { UsageError } from "../usage-error.js";
|
|
6
|
+
import { assertKind, ensureRemoteKind } from "../kinds.js";
|
|
6
7
|
const USAGE = "Usage: ahood skill view <owner>/<skill> [--json] [--web]";
|
|
7
8
|
// Spawned with argv as an array (not a shell string), so a crafted owner/skill
|
|
8
9
|
// can never inject shell syntax -- and both segments are already restricted
|
|
@@ -26,7 +27,7 @@ function openBrowser(url) {
|
|
|
26
27
|
export async function viewSkill(owner, skill) {
|
|
27
28
|
return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
|
|
28
29
|
}
|
|
29
|
-
export async function view(args) {
|
|
30
|
+
export async function view(args, scope) {
|
|
30
31
|
const jsonOutput = args.includes("--json");
|
|
31
32
|
const web = args.includes("--web");
|
|
32
33
|
const spec = args.find((a) => !a.startsWith("--"));
|
|
@@ -35,10 +36,12 @@ export async function view(args) {
|
|
|
35
36
|
const { owner, skill } = parseOwnerSkill(spec, USAGE);
|
|
36
37
|
const url = `${getApiUrl()}/${owner}/${skill}`;
|
|
37
38
|
if (web) {
|
|
39
|
+
await ensureRemoteKind(scope, owner, skill, "view", "open");
|
|
38
40
|
openBrowser(url);
|
|
39
41
|
return;
|
|
40
42
|
}
|
|
41
43
|
const detail = await viewSkill(owner, skill);
|
|
44
|
+
assertKind(scope, `${owner}/${skill}`, detail.kind, "view", "show");
|
|
42
45
|
if (jsonOutput) {
|
|
43
46
|
console.log(JSON.stringify(detail));
|
|
44
47
|
return;
|