@ahood/cli 0.8.8 → 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 +92 -6
- package/dist/commands/add.js +147 -54
- 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 +30 -2
- package/dist/commands/remove.js +83 -12
- package/dist/commands/search.js +12 -4
- package/dist/commands/share.js +5 -2
- package/dist/commands/snap.js +140 -35
- package/dist/commands/star.js +5 -2
- package/dist/commands/unpublish.js +5 -1
- package/dist/commands/update.js +109 -11
- package/dist/commands/versions.js +3 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +50 -9
- package/dist/flags.js +100 -8
- package/dist/help.js +292 -15
- package/dist/http.js +137 -45
- package/dist/index.js +106 -22
- package/dist/kind-info.js +40 -0
- package/dist/kinds.js +122 -0
- package/dist/lockfile.js +397 -38
- package/dist/mcp/safe-tool.js +14 -2
- package/dist/spec.js +8 -5
- package/dist/terminal-safe.js +62 -0
- package/dist/useme/SKILL.md +170 -0
- package/dist/useme.js +14 -0
- package/package.json +3 -2
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/snap.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
|
-
import { flagValue, parseSearchQuery } from "../flags.js";
|
|
2
|
+
import { flagValue, parseSearchQuery, unrecognizedArgs, unrecognizedIndices } from "../flags.js";
|
|
3
3
|
import { confirm } from "../confirm.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
-
const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin)";
|
|
5
|
+
const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin; put -- before content that starts with --)";
|
|
6
6
|
const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>] [--tags tag1,tag2]";
|
|
7
7
|
const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>] [--tags tag1,tag2]";
|
|
8
8
|
const SHOW_USAGE = "Usage: ahood snap show <id> [--json]";
|
|
9
9
|
const REMOVE_USAGE = "Usage: ahood snap remove <id> [--yes]";
|
|
10
10
|
const SHARE_USAGE = "Usage: ahood snap share <id> [--json]";
|
|
11
11
|
const UNSHARE_USAGE = "Usage: ahood snap unshare <id> [--yes]";
|
|
12
|
-
const TAGS_USAGE =
|
|
12
|
+
const TAGS_USAGE = 'Usage: ahood snap tags <id> [tag ...] [--clear] [--json] (with no tag list, prints the snap\'s current tags; pass --clear or "" to remove them all)';
|
|
13
13
|
// There's no existing "read stdin to completion" helper in this codebase to
|
|
14
14
|
// reuse -- confirm.ts and secret-prompt.ts both only ever read a single
|
|
15
15
|
// line via readline, never an arbitrary-length paste/pipe. Standard Node
|
|
@@ -60,15 +60,61 @@ function printSnaps(jsonOutput, snaps, emptyMessage) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
export async function createSnap(args) {
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// POSIX end-of-options: everything after a bare "--" is content, verbatim,
|
|
64
|
+
// never a flag. Snap content is freeform, so a note that legitimately begins
|
|
65
|
+
// with "--" would otherwise be unexpressible once the unknown-flag check
|
|
66
|
+
// below lands -- `--tags=value` is exactly that escape hatch for the flag
|
|
67
|
+
// (flags.ts), and content had none (ahood-cli#134).
|
|
68
|
+
//
|
|
69
|
+
// Scoped to `create` rather than made group-wide on purpose: it's the only
|
|
70
|
+
// snap verb whose argument is freeform text. `show`/`remove`/`share`/
|
|
71
|
+
// `unshare` take an id, `tags` a tag list and `search` a query -- all
|
|
72
|
+
// constrained enough that a leading "--" is always a mistake there, so "--"
|
|
73
|
+
// would buy them nothing -- and `search`'s parsing lives in the shared
|
|
74
|
+
// parseSearchQuery, which `skill search` also uses.
|
|
75
|
+
const endOfOptions = args.indexOf("--");
|
|
76
|
+
const flagArgs = endOfOptions === -1 ? args : args.slice(0, endOfOptions);
|
|
77
|
+
const literalArgs = endOfOptions === -1 ? [] : args.slice(endOfOptions + 1);
|
|
78
|
+
const jsonOutput = flagArgs.includes("--json");
|
|
79
|
+
const tagsArg = flagValue(flagArgs, "--tags");
|
|
80
|
+
// Whatever this command doesn't consume. --tags and its value (both
|
|
81
|
+
// "--tags x" and "--tags=x" forms) are stripped the same way --json is, so
|
|
82
|
+
// they never leak into the joined content.
|
|
83
|
+
const leftover = unrecognizedIndices(flagArgs, ["--json"], ["--tags"]);
|
|
84
|
+
// Every leftover used to be kept and folded into the note body, because
|
|
85
|
+
// content is freeform and a blanket "--" rejection would break a note that
|
|
86
|
+
// starts with a dash. That made a typo silently rewrite what got stored, at
|
|
87
|
+
// exit 0: `snap create "my note" --tag deploy` (singular) posted content
|
|
88
|
+
// "my note --tag deploy" with no tags at all, and `--limit 5` likewise
|
|
89
|
+
// (ahood-cli#134). With "--" above providing the escape hatch, refuse them
|
|
90
|
+
// instead -- the same rejection `snap tags` already does below.
|
|
91
|
+
const unknownFlag = leftover.map((i) => flagArgs[i]).find((a) => a.startsWith("--"));
|
|
92
|
+
if (unknownFlag)
|
|
93
|
+
throw new UsageError(`Unknown flag: ${unknownFlag}\n${CREATE_USAGE}`);
|
|
94
|
+
// The other half of ahood-cli#134, and the part no "--" check catches:
|
|
95
|
+
// `snap create "note" --tags deploy bugfix` posted content "note bugfix",
|
|
96
|
+
// because only the token immediately after --tags is consumed and the second
|
|
97
|
+
// word fell through into the body. A bare token is indistinguishable from a
|
|
98
|
+
// note word on its own -- `--tags deploy Debugged the CI` is a legitimate
|
|
99
|
+
// flags-first invocation -- so the signal isn't the token, it's that content
|
|
100
|
+
// ends up on BOTH sides of a flag. Nobody writes a note with a flag wedged
|
|
101
|
+
// mid-sentence; erroring there refuses the typo without outlawing either
|
|
102
|
+
// ordering. Not silently retagging "bugfix" either: guessing wrong steals a
|
|
103
|
+
// word out of the note, which is the same corruption in the other direction.
|
|
104
|
+
const gap = leftover.findIndex((at, n) => n > 0 && at !== leftover[n - 1] + 1);
|
|
105
|
+
if (gap !== -1) {
|
|
106
|
+
const stray = flagArgs[leftover[gap]];
|
|
107
|
+
const consumed = flagArgs[leftover[gap - 1] + 1];
|
|
108
|
+
throw new UsageError(`Note content is split across ${consumed}: "${stray}" comes after it and would be folded into the note. ` +
|
|
109
|
+
`Quote the whole note, or move every flag after it` +
|
|
110
|
+
(consumed === "--tags" ? `; pass multiple tags as one comma-separated value (--tags a,b)` : "") +
|
|
111
|
+
`.\n${CREATE_USAGE}`);
|
|
112
|
+
}
|
|
65
113
|
// Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
|
|
66
114
|
// snap create Debugged the flaky CI step`) arrives as multiple positional
|
|
67
115
|
// tokens, and taking only the first one silently dropped the rest with no
|
|
68
|
-
// error. Mirrors searchSnaps' own query-joining below.
|
|
69
|
-
|
|
70
|
-
// --json is, so they never leak into the joined content.
|
|
71
|
-
const positionals = args.filter((a, i) => a !== "--json" && a !== "--tags" && !a.startsWith("--tags=") && args[i - 1] !== "--tags");
|
|
116
|
+
// error. Mirrors searchSnaps' own query-joining below.
|
|
117
|
+
const positionals = [...leftover.map((i) => flagArgs[i]), ...literalArgs];
|
|
72
118
|
const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
|
|
73
119
|
let content;
|
|
74
120
|
if (positional !== undefined) {
|
|
@@ -114,6 +160,22 @@ export async function listSnaps(args) {
|
|
|
114
160
|
const jsonOutput = args.includes("--json");
|
|
115
161
|
const limitStr = flagValue(args, "--limit");
|
|
116
162
|
const tagsStr = flagValue(args, "--tags");
|
|
163
|
+
// Reject whatever this command doesn't accept instead of issuing an
|
|
164
|
+
// unfiltered request and presenting the result as the filtered set:
|
|
165
|
+
// `snap list --tag ci` (singular typo) used to send no tags param at all and
|
|
166
|
+
// print every snap, while the sibling `snap search --tag ci` refused the
|
|
167
|
+
// identical typo -- so a user filtering for "deploy" and seeing three snaps
|
|
168
|
+
// couldn't tell them from the three most recent (ahood-cli#135). That's the
|
|
169
|
+
// same silent-broadening this command's verbatim --tags pass-through below
|
|
170
|
+
// was chosen to avoid; it was defended at the small end and left open at the
|
|
171
|
+
// large one. `snap list` takes no positional argument either, and nothing
|
|
172
|
+
// upstream gives a stray token a meaning (index.ts's dispatchSnap consumes
|
|
173
|
+
// only the verb, and intercepts --help/-h before the handler runs), so an
|
|
174
|
+
// extra positional is a mistake too rather than something to ignore.
|
|
175
|
+
const extra = unrecognizedArgs(args, ["--json"], ["--limit", "--tags"])[0];
|
|
176
|
+
if (extra !== undefined) {
|
|
177
|
+
throw new UsageError(`${extra.startsWith("--") ? "Unknown flag" : "Unexpected argument"}: ${extra}\n${LIST_USAGE}`);
|
|
178
|
+
}
|
|
117
179
|
validateLimit(limitStr, LIST_USAGE);
|
|
118
180
|
const qs = new URLSearchParams();
|
|
119
181
|
if (limitStr !== undefined)
|
|
@@ -226,9 +288,38 @@ export async function unshareSnap(args) {
|
|
|
226
288
|
await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
|
|
227
289
|
console.log(`Unshared snap ${id}.`);
|
|
228
290
|
}
|
|
291
|
+
// Shared by both of tagsSnap's paths so reading a snap's tags and the echo
|
|
292
|
+
// after replacing them print the identical line -- the read is meant to be the
|
|
293
|
+
// way a user checks what a write did, which only works if the two agree.
|
|
294
|
+
//
|
|
295
|
+
// Each tag is printed quoted. Unquoted, `Tags for X: deploy bugfix` is
|
|
296
|
+
// character-for-character what ONE tag "deploy bugfix" and TWO tags "deploy"
|
|
297
|
+
// and "bugfix" both printed, so a tag list that had silently collapsed into a
|
|
298
|
+
// single multi-word tag looked exactly like the correct result and the mistake
|
|
299
|
+
// left no trace on screen (ahood-cli#137). JSON.stringify rather than manual
|
|
300
|
+
// quoting so a tag that itself contains a quote or a newline stays unambiguous.
|
|
301
|
+
function printTags(jsonOutput, id, tags, emptyMessage) {
|
|
302
|
+
// ?? [] -- see printSnaps' identical degrade-on-null guard above
|
|
303
|
+
// (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
|
|
304
|
+
// Normalized before the --json branch too, so the emitted shape is the same
|
|
305
|
+
// {id, tags: []} whether the server said [] or null.
|
|
306
|
+
const list = tags ?? [];
|
|
307
|
+
if (jsonOutput) {
|
|
308
|
+
console.log(JSON.stringify({ id, tags: list }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
console.log(list.length > 0 ? `Tags for ${id}: ${list.map((t) => JSON.stringify(t)).join(", ")}` : emptyMessage);
|
|
312
|
+
}
|
|
229
313
|
export async function tagsSnap(args) {
|
|
230
314
|
const jsonOutput = args.includes("--json");
|
|
231
|
-
const
|
|
315
|
+
const clear = args.includes("--clear");
|
|
316
|
+
// Folded onto flags.ts's shared stripping helper, which this command was the
|
|
317
|
+
// last caller still hand-rolling its own copy of (see unrecognizedArgs'
|
|
318
|
+
// comment, ahood-cli#135/#136). The `args.filter(a => a !== "--json")` it
|
|
319
|
+
// replaces knew about exactly one flag, so --clear below would have meant
|
|
320
|
+
// growing a third variant of the same rules -- the duplication that let
|
|
321
|
+
// `snap list` ship with no unknown-flag check at all.
|
|
322
|
+
const positionals = unrecognizedArgs(args, ["--json", "--clear"], []);
|
|
232
323
|
// Reject stray "--" flags rather than folding them into the tag list
|
|
233
324
|
// (parseSearchQuery in flags.ts does the same). Without this, the natural
|
|
234
325
|
// mistake `snap tags <id> --tags a,b` -- natural because `snap create`
|
|
@@ -240,34 +331,48 @@ export async function tagsSnap(args) {
|
|
|
240
331
|
const id = positionals[0];
|
|
241
332
|
if (!id)
|
|
242
333
|
throw new UsageError(TAGS_USAGE);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
334
|
+
const tagArgs = positionals.slice(1);
|
|
335
|
+
if (clear && tagArgs.length > 0) {
|
|
336
|
+
throw new UsageError(`--clear takes no tag list, but got "${tagArgs[0]}". Pass --clear to remove every tag, or the tags to set -- not both.\n${TAGS_USAGE}`);
|
|
337
|
+
}
|
|
338
|
+
// The bare `snap tags <id>` form READS instead of clearing (ahood-cli#138).
|
|
339
|
+
// It used to PATCH {"tags":[]} unconfirmed, and it is the form a user reaches
|
|
340
|
+
// for to ASK what a snap's tags are -- `git tag`, `docker tag` and `hg tags`
|
|
341
|
+
// all read on their bare form, and nothing else in this CLI showed one snap's
|
|
342
|
+
// tags (`snap show` prints content only, `snap list` truncates across all
|
|
343
|
+
// snaps), so the answer was destroyed by the act of asking. Destroying a tag
|
|
344
|
+
// set now takes saying so: --clear, or the documented explicit "" form below.
|
|
345
|
+
// That's why there's still no confirm() gate here, unlike remove/unshare --
|
|
346
|
+
// the accident it would have guarded against can no longer be typed by
|
|
347
|
+
// accident, and #114's original reasoning (metadata only, and re-running
|
|
348
|
+
// `tags` with the old set restores it exactly) holds once the user can
|
|
349
|
+
// actually find out what the old set was.
|
|
350
|
+
if (!clear && tagArgs.length === 0) {
|
|
351
|
+
const snap = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`);
|
|
352
|
+
printTags(jsonOutput, snap.id, snap.tags, `${snap.id} has no tags.`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Each positional is parsed on its own, then concatenated -- NOT joined into
|
|
356
|
+
// one string first. The join existed to rescue an unquoted `tags <id>
|
|
357
|
+
// "deploy, bugfix"`, which the shell hands over as two argv entries and which
|
|
358
|
+
// taking only positionals[1] silently truncated (ahood-cli#114). But joining
|
|
359
|
+
// cannot tell that apart from a user typing space separators, so `tags <id>
|
|
360
|
+
// deploy bugfix` became the single tag "deploy bugfix" (ahood-cli#137).
|
|
361
|
+
// Splitting per token serves both: ["deploy,", "bugfix"] and ["deploy",
|
|
362
|
+
// "bugfix"] each yield ["deploy", "bugfix"], while a tag that genuinely
|
|
363
|
+
// contains a space stays expressible by quoting it as one argument -- which
|
|
364
|
+
// treating whitespace as a separator would have taken away.
|
|
365
|
+
const tags = tagArgs.flatMap((arg) => arg
|
|
366
|
+
.split(",")
|
|
367
|
+
.map((t) => t.trim())
|
|
368
|
+
.filter(Boolean));
|
|
369
|
+
// PATCH replaces the full tag set (not a merge) -- passing --clear, or an
|
|
370
|
+
// empty string, sends [] and clears every tag, matching the "pass [] to
|
|
371
|
+
// clear" contract of the endpoint itself.
|
|
260
372
|
const updated = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, {
|
|
261
373
|
method: "PATCH",
|
|
262
374
|
headers: { "Content-Type": "application/json" },
|
|
263
375
|
body: JSON.stringify({ tags }),
|
|
264
376
|
});
|
|
265
|
-
|
|
266
|
-
console.log(JSON.stringify(updated));
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
// ?? [] -- see printSnaps' identical degrade-on-null guard above
|
|
270
|
-
// (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
|
|
271
|
-
const updatedTags = updated.tags ?? [];
|
|
272
|
-
console.log(updatedTags.length > 0 ? `Tags for ${updated.id}: ${updatedTags.join(", ")}` : `Cleared tags for ${updated.id}.`);
|
|
377
|
+
printTags(jsonOutput, updated.id, updated.tags, `Cleared tags for ${updated.id}.`);
|
|
273
378
|
}
|
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
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
|
|
2
2
|
import { readLockfile } from "../lockfile.js";
|
|
3
|
-
import { add, fetchVersionMeta, updateMcpEntry } from "./add.js";
|
|
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,
|
|
@@ -15,13 +14,47 @@ async function previewSkill(ownerSlashSkill, currentVersion) {
|
|
|
15
14
|
changelog_md: upToDate ? null : meta.changelog_md ?? null,
|
|
16
15
|
};
|
|
17
16
|
}
|
|
17
|
+
// Whether .mcp.json currently holds something that could actually start this
|
|
18
|
+
// skill's server (ahood-cli#139). Anything ahood itself writes is a non-empty
|
|
19
|
+
// JSON object -- buildMcpServerConfig always emits at least a `command` or a
|
|
20
|
+
// `url` -- so a missing key, `null`, `{}`, or a bare scalar all mean the same
|
|
21
|
+
// thing operationally: the server is gone, whatever the lockfile still pins.
|
|
22
|
+
// An emptied entry is reported broken but is NOT self-healed by overwriting:
|
|
23
|
+
// falling through hands it to updateMcpEntry, whose fingerprint check refuses
|
|
24
|
+
// it (before any download or prompt) with the "delete it by hand, then
|
|
25
|
+
// `ahood skill add`" message. ahood never writes `{}` or `null`, so something
|
|
26
|
+
// else did, and quietly stamping over an entry ahood cannot prove is its own
|
|
27
|
+
// is exactly what that check exists to prevent -- an actionable refusal beats
|
|
28
|
+
// both a silent overwrite and today's false "already up to date".
|
|
29
|
+
//
|
|
30
|
+
// Deliberately narrow: an entry that is present and non-empty but doesn't
|
|
31
|
+
// match its recorded fingerprint is NOT reported as broken here. That's a
|
|
32
|
+
// hand-edit, `update` has always left those alone when the pin was already at
|
|
33
|
+
// latest, and turning every locally-tweaked entry into a per-entry failure
|
|
34
|
+
// would be a regression of its own.
|
|
35
|
+
//
|
|
36
|
+
// readMcpConfig() throws on an unreadable or malformed .mcp.json rather than
|
|
37
|
+
// being caught and treated as "no entry": update.ts's per-entry catch turns
|
|
38
|
+
// that into one `Failed to update <skill>: ...` line and a non-zero exit
|
|
39
|
+
// without stopping the rest of the batch, which is what the version-differs
|
|
40
|
+
// path has always done (updateMcpEntry reads the same file through the same
|
|
41
|
+
// function). Swallowing it here would instead print "already up to date"
|
|
42
|
+
// about an entry sitting in a file nothing can parse -- the same false
|
|
43
|
+
// all-clear #139 is about.
|
|
44
|
+
function mcpEntryIsIntact(skill) {
|
|
45
|
+
const mcpServers = readMcpConfig().mcpServers;
|
|
46
|
+
if (!Object.prototype.hasOwnProperty.call(mcpServers, skill))
|
|
47
|
+
return false;
|
|
48
|
+
const entry = mcpServers[skill];
|
|
49
|
+
return typeof entry === "object" && entry !== null && Object.keys(entry).length > 0;
|
|
50
|
+
}
|
|
18
51
|
function padRow(cells, widths) {
|
|
19
52
|
return cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
|
|
20
53
|
}
|
|
21
54
|
// Prints a side-by-side current/latest table for every previewed skill, then
|
|
22
55
|
// -- for only the skills that actually have an update available -- the
|
|
23
56
|
// changelog for the version they'd move to. Nothing here touches disk or the
|
|
24
|
-
// network beyond the read-only resolution already done
|
|
57
|
+
// network beyond the read-only resolution already done by the caller before toPreview().
|
|
25
58
|
function printDryRunTable(previews) {
|
|
26
59
|
const header = ["SKILL", "CURRENT", "LATEST", "STATUS"];
|
|
27
60
|
const rows = previews.map((p) => [
|
|
@@ -44,7 +77,30 @@ function printDryRunTable(previews) {
|
|
|
44
77
|
console.log(p.changelog_md ? p.changelog_md : "(no changelog available)");
|
|
45
78
|
}
|
|
46
79
|
}
|
|
47
|
-
|
|
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);
|
|
48
104
|
const dryRun = args.includes("--dry-run");
|
|
49
105
|
const jsonOutput = args.includes("--json");
|
|
50
106
|
if (jsonOutput && !dryRun) {
|
|
@@ -52,9 +108,23 @@ export async function update(args) {
|
|
|
52
108
|
}
|
|
53
109
|
const targets = args.filter((a) => a !== "--dry-run" && a !== "--json");
|
|
54
110
|
const lockfile = readLockfile(LOCKFILE_PATH);
|
|
55
|
-
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.";
|
|
56
126
|
if (skillKeys.length === 0) {
|
|
57
|
-
console.log(
|
|
127
|
+
console.log(noneMessage);
|
|
58
128
|
return;
|
|
59
129
|
}
|
|
60
130
|
if (dryRun) {
|
|
@@ -65,7 +135,11 @@ export async function update(args) {
|
|
|
65
135
|
const failures = [];
|
|
66
136
|
for (const key of skillKeys) {
|
|
67
137
|
try {
|
|
68
|
-
|
|
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));
|
|
69
143
|
}
|
|
70
144
|
catch (error) {
|
|
71
145
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -86,6 +160,7 @@ export async function update(args) {
|
|
|
86
160
|
return;
|
|
87
161
|
}
|
|
88
162
|
const failures = [];
|
|
163
|
+
let inScope = 0;
|
|
89
164
|
for (const ownerSlashSkill of skillKeys) {
|
|
90
165
|
if (!lockfile[ownerSlashSkill]) {
|
|
91
166
|
// Reachable two ways: an explicitly-named skill that was never
|
|
@@ -107,22 +182,42 @@ export async function update(args) {
|
|
|
107
182
|
// mcp-update support) instead.
|
|
108
183
|
const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
|
|
109
184
|
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
185
|
+
if (outOfScope(scope, ownerSlashSkill, meta.kind, implicit))
|
|
186
|
+
continue;
|
|
187
|
+
inScope++;
|
|
110
188
|
if (meta.kind === "mcp") {
|
|
111
189
|
const currentEntry = lockfile[ownerSlashSkill];
|
|
112
|
-
if (currentEntry && currentEntry.version === meta.version) {
|
|
190
|
+
if (currentEntry && currentEntry.version === meta.version && mcpEntryIsIntact(skill)) {
|
|
113
191
|
// Matches skill/agent update's own "nothing to do" case for a
|
|
114
192
|
// pin that's already at latest -- not a warning (nothing's
|
|
115
193
|
// wrong), and, unlike skill/agent (which always blindly
|
|
116
194
|
// re-extracts even when nothing changed), skipped here
|
|
117
195
|
// specifically so an up-to-date mcp entry with a secret in its
|
|
118
196
|
// env never re-triggers a masked prompt for no reason.
|
|
197
|
+
//
|
|
198
|
+
// Conditioned on the entry actually being there (ahood-cli#139):
|
|
199
|
+
// .mcp.json is merged into rather than owned (see CLAUDE.md), so
|
|
200
|
+
// losing an entry to a merge or a hand cleanup is ordinary, and
|
|
201
|
+
// matching versions alone said "already up to date" about a server
|
|
202
|
+
// that no longer existed -- the one kind `update` could not repair,
|
|
203
|
+
// where skill/agent are restored by add() below regardless of
|
|
204
|
+
// version. Falling through runs updateMcpEntry's self-heal (see the
|
|
205
|
+
// "If the entry is missing entirely" case in add.ts), which does
|
|
206
|
+
// have to prompt for secrets there is no on-disk entry to carry
|
|
207
|
+
// forward; on the unattended path its allowNonTtyPrompt: false
|
|
208
|
+
// turns that into a named-variable error instead of a hang.
|
|
119
209
|
console.log(`${ownerSlashSkill} is already up to date (v${meta.version}).`);
|
|
120
210
|
continue;
|
|
121
211
|
}
|
|
122
212
|
await updateMcpEntry(owner, skill, meta, currentEntry);
|
|
123
213
|
continue;
|
|
124
214
|
}
|
|
125
|
-
|
|
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]);
|
|
126
221
|
}
|
|
127
222
|
catch (error) {
|
|
128
223
|
// One skill being removed/yanked/unreachable must not stop every other
|
|
@@ -132,6 +227,9 @@ export async function update(args) {
|
|
|
132
227
|
failures.push(ownerSlashSkill);
|
|
133
228
|
}
|
|
134
229
|
}
|
|
230
|
+
if (implicit && kind && inScope === 0 && failures.length === 0) {
|
|
231
|
+
console.log(noneMessage);
|
|
232
|
+
}
|
|
135
233
|
if (failures.length > 0) {
|
|
136
234
|
console.error(`${failures.length} of ${skillKeys.length} skill(s) failed to update: ${failures.join(", ")}`);
|
|
137
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;
|