@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.
@@ -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
  }
@@ -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 = "Usage: ahood snap tags <id> [tag1,tag2,...] [--json] (omit or pass an empty value to clear all tags)";
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
- const jsonOutput = args.includes("--json");
64
- const tagsArg = flagValue(args, "--tags");
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. --tags and its
69
- // value (both "--tags x" and "--tags=x" forms) are stripped the same way
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 positionals = args.filter((a) => a !== "--json");
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
- // PATCH replaces the full tag set (not a merge) -- omitting the tags
244
- // argument, or passing an empty string, both clear every tag, matching
245
- // the "pass [] to clear" contract of the endpoint itself. Joined, not
246
- // just positionals[1] -- an unquoted "tag1, tag2" arrives as multiple
247
- // positional tokens, and taking only the first one silently dropped the
248
- // rest with no error, the same bug createSnap's own content-joining
249
- // above already fixed.
250
- const tagsArg = positionals.slice(1).join(" ");
251
- const tags = tagsArg
252
- ? tagsArg
253
- .split(",")
254
- .map((t) => t.trim())
255
- .filter(Boolean)
256
- : [];
257
- // No confirm() gate, unlike remove/unshare -- this only replaces metadata
258
- // (tags), never the snap's content or its shareability, and re-running
259
- // `tags` with the old set restores it exactly. Per ahood-cli#114's spec.
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
- if (jsonOutput) {
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
  }
@@ -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.
@@ -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
- async function previewSkill(ownerSlashSkill, currentVersion) {
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 in previewSkill().
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
- export async function update(args) {
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 skillKeys = targets.length > 0 ? targets : Object.keys(lockfile);
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("No installed skills to update.");
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
- previews.push(await previewSkill(key, lockfile[key]?.version ?? null));
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
- await add([ownerSlashSkill]); // no @version -- resolves to latest again
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));
@@ -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;