@ahood/cli 0.7.3 → 0.8.1

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.
@@ -1,69 +1,114 @@
1
- import { existsSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { removeLockfileEntry } from "../lockfile.js";
1
+ import { existsSync, rmSync, unlinkSync } from "node:fs";
2
+ import { confirm } from "../confirm.js";
3
+ import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
4
4
  import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
5
+ import { readMcpConfig, hashMcpServerConfig } from "./add.js";
5
6
  import { UsageError } from "../usage-error.js";
6
- const USAGE = "Usage: ahood skill remove <owner>/<skill>";
7
+ const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
7
8
  export async function remove(args) {
8
9
  const spec = args[0];
9
10
  if (!spec)
10
11
  throw new UsageError(USAGE);
12
+ const yes = args.includes("--yes");
11
13
  const { owner, skill } = parseOwnerSkill(spec, USAGE);
12
14
  const key = `${owner}/${skill}`;
15
+ // Flat owner@skill directory (see skillDir's comment in spec.ts) -- no
16
+ // owner-namespace folder to sweep afterward, unlike the old nested layout.
13
17
  const dir = skillDir(owner, skill);
14
18
  const dirExisted = existsSync(dir);
15
- if (dirExisted)
16
- rmSync(dir, { recursive: true, force: true });
17
- // Sweep the owner directory once it holds nothing, so uninstalling an
18
- // owner's last skill doesn't leave an empty namespace folder behind (npm
19
- // does the same with node_modules/@scope). Guarded on emptiness, so another
20
- // skill by the same owner is never touched.
21
- const ownerDir = dirname(dir);
22
- if (existsSync(ownerDir) && readdirSync(ownerDir).length === 0)
23
- rmdirSync(ownerDir);
24
19
  // An agent installs as a single flat file (.claude/agents/<owner>@<skill>.md),
25
- // never a directory under .claude/skills/ -- skillDir's rmSync above never
26
- // touches it. Without this, removing an installed agent silently left the
27
- // file on disk (still loaded by Claude Code forever) while reporting
28
- // success and clearing the lockfile entry, so a later `update` wouldn't
29
- // catch it either (ahood-cli final review finding #2).
20
+ // never a directory under .claude/skills/ -- distinct from the dir check above.
30
21
  const agentFile = agentPath(owner, skill);
31
22
  const agentExisted = existsSync(agentFile);
32
- if (agentExisted)
33
- unlinkSync(agentFile);
34
- const hadLockfileEntry = removeLockfileEntry(LOCKFILE_PATH, key);
23
+ const lockEntry = readLockfile(LOCKFILE_PATH)[key];
24
+ const hadLockfileEntry = lockEntry !== undefined;
35
25
  if (!dirExisted && !agentExisted && !hadLockfileEntry) {
36
26
  console.error(`${key} was not installed -- nothing to remove.`);
37
27
  process.exitCode = 1;
38
28
  return;
39
29
  }
30
+ // Matches unpublish.ts/group.ts's confirm-before-destroy pattern (CLAUDE.md:
31
+ // "Destructive commands ... prompt for confirmation unless --yes is passed").
32
+ // Checked only once something is actually installed, so a no-op remove of an
33
+ // uninstalled skill never blocks on a prompt (ahood-cli#98).
34
+ const confirmed = yes ? true : await confirm(`Remove ${key} from this project? Type "yes" to confirm: `);
35
+ if (!confirmed) {
36
+ console.log("Aborted.");
37
+ return;
38
+ }
39
+ if (dirExisted)
40
+ rmSync(dir, { recursive: true, force: true });
41
+ if (agentExisted)
42
+ unlinkSync(agentFile);
43
+ removeLockfileEntry(LOCKFILE_PATH, key);
40
44
  // An mcp-kind install has no directory or agent file on disk -- its only
41
45
  // footprint here is the lockfile entry just cleared above and a live
42
46
  // entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
43
- // holding a resolved secret in its `env`). remove() doesn't attempt a real
44
- // mcp removal, but staying silent about that entry repeats the exact
45
- // false-assurance bug already fixed once for agent installs above: a user
46
- // who removes an mcp artifact because they no longer trust it would be
47
- // told it's gone while the MCP server (and its credential) still runs on
48
- // the next Claude Code start -- and since the lockfile pin is now cleared,
49
- // nothing will ever surface this again via `list`/`update`. So: warn
50
- // instead of pretending it's gone.
51
- if (existsSync(MCP_CONFIG_PATH)) {
52
- try {
53
- const parsed = JSON.parse(readFileSync(MCP_CONFIG_PATH, "utf-8"));
54
- const mcpServers = parsed?.mcpServers;
55
- if (mcpServers &&
56
- typeof mcpServers === "object" &&
57
- !Array.isArray(mcpServers) &&
58
- Object.prototype.hasOwnProperty.call(mcpServers, skill)) {
59
- console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered) -- remove it manually.`);
47
+ // holding a resolved secret in its `env`). Real removal (delete that one
48
+ // key) only happens when the on-disk entry's fingerprint still matches
49
+ // what was recorded at install/update time -- same posture as add.ts's
50
+ // own assertNoCollision: never blind-write/delete something this process
51
+ // didn't verify it still owns. Without SOME check here, "Removed" would
52
+ // repeat the exact false-assurance bug already fixed once for agent
53
+ // installs above (a user told it's gone while the MCP server, and its
54
+ // credential, still runs on the next Claude Code start) -- but a hand-
55
+ // edited entry (fingerprint mismatch, or no fingerprint recorded at all,
56
+ // e.g. an mcp entry installed before this field existed) is left in
57
+ // place and warned about instead, exactly as before this fix.
58
+ let removedMcpEntry = false;
59
+ let mcpEntryModified = false;
60
+ let mcpEntryPresent = false;
61
+ let recordedHash;
62
+ try {
63
+ const fileContents = readMcpConfig();
64
+ const mcpServers = fileContents.mcpServers;
65
+ mcpEntryPresent = Object.prototype.hasOwnProperty.call(mcpServers, skill);
66
+ if (mcpEntryPresent) {
67
+ recordedHash = lockEntry?.mcp_config_hash;
68
+ const currentHash = hashMcpServerConfig(mcpServers[skill]);
69
+ if (recordedHash !== undefined) {
70
+ mcpEntryModified = recordedHash !== currentHash;
60
71
  }
61
72
  }
73
+ }
74
+ catch {
75
+ // A malformed .mcp.json isn't this command's problem to fix or crash
76
+ // on -- add.ts's readMcpConfig is the strict validator for that path.
77
+ // mcpEntryPresent stays false, so there's nothing to warn about either:
78
+ // if we can't even read the file, we can't know whether this skill has
79
+ // an entry in it.
80
+ }
81
+ if (mcpEntryPresent && recordedHash !== undefined && !mcpEntryModified) {
82
+ try {
83
+ // Re-check under lock rather than trusting the read above -- another
84
+ // process could have changed or removed the entry in between,
85
+ // mirroring add.ts's own re-check-under-lock before it writes. This
86
+ // try/catch is deliberately separate from the read above: a failure
87
+ // HERE (a lock-acquisition timeout, an EACCES/ENOSPC/etc. write
88
+ // failure) must still fall through to the warning below rather than
89
+ // being silently swallowed -- letting it share the read's catch block
90
+ // was the exact bug that let a failed delete print a bare "Removed"
91
+ // with the credential still live (the false-assurance case this
92
+ // whole mechanism exists to prevent).
93
+ withLock(MCP_CONFIG_PATH, () => {
94
+ const fresh = readMcpConfig();
95
+ const freshServers = fresh.mcpServers;
96
+ if (Object.prototype.hasOwnProperty.call(freshServers, skill) &&
97
+ hashMcpServerConfig(freshServers[skill]) === recordedHash) {
98
+ delete freshServers[skill];
99
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
100
+ removedMcpEntry = true;
101
+ }
102
+ });
103
+ }
62
104
  catch {
63
- // A malformed .mcp.json isn't this command's problem to fix or crash
64
- // on -- add.ts's readMcpConfig is the strict validator for that path.
65
- // Skip the warning rather than throw here.
105
+ // removedMcpEntry stays false -- falls through to the warning below.
66
106
  }
67
107
  }
68
- console.log(`Removed ${key}`);
108
+ if (mcpEntryPresent && !removedMcpEntry) {
109
+ console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered)` +
110
+ (mcpEntryModified ? " -- it appears to have been modified since install" : "") +
111
+ ` -- remove it manually.`);
112
+ }
113
+ console.log(removedMcpEntry ? `Removed ${key} (including its ${MCP_CONFIG_PATH} entry)` : `Removed ${key}`);
69
114
  }
@@ -1,5 +1,5 @@
1
1
  import { apiJson } from "../http.js";
2
- import { flagValue } from "../flags.js";
2
+ import { flagValue, parseSearchQuery } from "../flags.js";
3
3
  import { UsageError } from "../usage-error.js";
4
4
  const USAGE = "Usage: ahood skill search <query> [--json] [--limit <n>]";
5
5
  export async function searchSkills(query, limit) {
@@ -12,13 +12,7 @@ export async function searchSkills(query, limit) {
12
12
  export async function search(args) {
13
13
  const jsonOutput = args.includes("--json");
14
14
  const limitStr = flagValue(args, "--limit");
15
- const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && args[i - 1] !== "--limit");
16
- const unknownFlag = queryParts.find((a) => a.startsWith("--"));
17
- if (unknownFlag)
18
- throw new UsageError(`Unknown flag: ${unknownFlag}\n${USAGE}`);
19
- const query = queryParts.join(" ");
20
- if (!query)
21
- throw new UsageError(USAGE);
15
+ const query = parseSearchQuery(args, USAGE);
22
16
  if (limitStr !== undefined && (!/^\d+$/.test(limitStr) || Number(limitStr) < 1)) {
23
17
  throw new UsageError(`--limit must be a positive integer (got "${limitStr}").\n${USAGE}`);
24
18
  }
@@ -33,7 +27,11 @@ export async function search(args) {
33
27
  return;
34
28
  }
35
29
  for (const skill of skills) {
36
- console.log(`${skill.profiles.username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
30
+ // profiles comes from a server-side join that can plausibly be null for
31
+ // an individual row (orphaned skill, deleted owner account) -- degrade
32
+ // that one row instead of crashing the whole command (ahood-cli#106).
33
+ const username = skill.profiles?.username ?? "(unknown)";
34
+ console.log(`${username}/${skill.slug} - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads)`);
37
35
  }
38
36
  if (limit !== undefined && skills.length >= limit) {
39
37
  console.log(`(showing up to ${limit} results -- pass a higher --limit for more)`);
@@ -0,0 +1,248 @@
1
+ import { apiJson } from "../http.js";
2
+ import { flagValue, parseSearchQuery } from "../flags.js";
3
+ import { confirm } from "../confirm.js";
4
+ import { UsageError } from "../usage-error.js";
5
+ const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin)";
6
+ const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>]";
7
+ const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>]";
8
+ const SHOW_USAGE = "Usage: ahood snap show <id> [--json]";
9
+ const REMOVE_USAGE = "Usage: ahood snap remove <id> [--yes]";
10
+ const SHARE_USAGE = "Usage: ahood snap share <id> [--json]";
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)";
13
+ // There's no existing "read stdin to completion" helper in this codebase to
14
+ // reuse -- confirm.ts and secret-prompt.ts both only ever read a single
15
+ // line via readline, never an arbitrary-length paste/pipe. Standard Node
16
+ // read-to-end pattern: accumulate 'data' chunks until 'end'.
17
+ function readStdin() {
18
+ return new Promise((resolve, reject) => {
19
+ let data = "";
20
+ process.stdin.setEncoding("utf8");
21
+ process.stdin.on("data", (chunk) => {
22
+ data += chunk;
23
+ });
24
+ process.stdin.on("end", () => resolve(data));
25
+ process.stdin.on("error", reject);
26
+ });
27
+ }
28
+ function validateLimit(limitStr, usage) {
29
+ if (limitStr !== undefined && (!/^\d+$/.test(limitStr) || Number(limitStr) < 1)) {
30
+ throw new UsageError(`--limit must be a positive integer (got "${limitStr}").\n${usage}`);
31
+ }
32
+ }
33
+ // Shared by list/search -- both print the same one-line-per-snap shape and
34
+ // the same --json passthrough (the raw `snaps` array, matching
35
+ // search.ts/list.ts's convention of emitting just the result array, not the
36
+ // whole {snaps, next_cursor} envelope -- neither command exposes cursor-based
37
+ // pagination today, only --limit, per ahood-cli#107's spec).
38
+ function printSnaps(jsonOutput, snaps, emptyMessage) {
39
+ if (jsonOutput) {
40
+ console.log(JSON.stringify(snaps));
41
+ return;
42
+ }
43
+ if (snaps.length === 0) {
44
+ console.log(emptyMessage);
45
+ return;
46
+ }
47
+ for (const snap of snaps) {
48
+ // content_preview can plausibly contain the note's own internal
49
+ // newlines (it's freeform text) -- collapsed to spaces and re-truncated
50
+ // here so one snap never spills across multiple terminal lines and
51
+ // breaks the "one line per snap" contract.
52
+ const flat = snap.content_preview.replace(/\s+/g, " ").trim();
53
+ const preview = flat.length > 60 ? `${flat.slice(0, 60)}...` : flat;
54
+ // ?? [] -- see listSnaps/searchSnaps' identical degrade-on-null guard
55
+ // below; a snap predating the tags column, or a degraded response,
56
+ // should just show no tags rather than crashing on undefined.length.
57
+ const tags = snap.tags ?? [];
58
+ const tagsSuffix = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
59
+ console.log(`${snap.id} - ${preview} (${snap.created_at})${snap.shared ? " (shared)" : ""}${tagsSuffix}`);
60
+ }
61
+ }
62
+ export async function createSnap(args) {
63
+ const jsonOutput = args.includes("--json");
64
+ const tagsArg = flagValue(args, "--tags");
65
+ // Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
66
+ // snap create Debugged the flaky CI step`) arrives as multiple positional
67
+ // 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");
72
+ const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
73
+ let content;
74
+ if (positional !== undefined) {
75
+ content = positional;
76
+ }
77
+ else {
78
+ // No positional content -- fall back to stdin (echo "..." | ahood snap
79
+ // create, or a piped heredoc), same convention as e.g. `git commit -F
80
+ // -`. A TTY with nothing piped in would otherwise hang forever waiting
81
+ // for input that will never arrive -- this CLI must never hang
82
+ // indefinitely for unattended/agent use (same reasoning as http.ts's
83
+ // 30s request timeout) -- so that case fails fast with the usage error
84
+ // instead of blocking on readStdin().
85
+ if (process.stdin.isTTY)
86
+ throw new UsageError(CREATE_USAGE);
87
+ content = await readStdin();
88
+ }
89
+ if (!content.trim())
90
+ throw new UsageError(CREATE_USAGE);
91
+ // Same comma-split convention as `ahood skill publish --tags`/`edit
92
+ // --tags`. Omitted entirely when --tags wasn't passed at all, so the
93
+ // request body matches the "omitting tags is equivalent to []" contract
94
+ // exactly instead of always sending an (empty) tags array.
95
+ const body = { content };
96
+ if (tagsArg !== undefined) {
97
+ body.tags = tagsArg
98
+ .split(",")
99
+ .map((t) => t.trim())
100
+ .filter(Boolean);
101
+ }
102
+ const created = await apiJson("/api/v1/snaps", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify(body),
106
+ });
107
+ if (jsonOutput) {
108
+ console.log(JSON.stringify(created));
109
+ return;
110
+ }
111
+ console.log(created.id);
112
+ }
113
+ export async function listSnaps(args) {
114
+ const jsonOutput = args.includes("--json");
115
+ const limitStr = flagValue(args, "--limit");
116
+ validateLimit(limitStr, LIST_USAGE);
117
+ const qs = new URLSearchParams();
118
+ if (limitStr !== undefined)
119
+ qs.set("limit", limitStr);
120
+ const query = qs.toString();
121
+ const { snaps } = await apiJson(`/api/v1/snaps${query ? `?${query}` : ""}`);
122
+ // ?? [], not a bare destructure -- a degraded response (snaps: null)
123
+ // must degrade to "no snaps" instead of crashing, matching list.ts/
124
+ // search.ts's own guard for the identical shape (ahood-cli#106).
125
+ printSnaps(jsonOutput, snaps ?? [], "You have no snaps yet. Run `ahood snap create <content>` to make one.");
126
+ }
127
+ export async function searchSnaps(args) {
128
+ const jsonOutput = args.includes("--json");
129
+ const limitStr = flagValue(args, "--limit");
130
+ const query = parseSearchQuery(args, SEARCH_USAGE);
131
+ validateLimit(limitStr, SEARCH_USAGE);
132
+ const qs = new URLSearchParams({ q: query });
133
+ if (limitStr !== undefined)
134
+ qs.set("limit", limitStr);
135
+ const { snaps } = await apiJson(`/api/v1/snaps?${qs}`);
136
+ // ?? [] -- see listSnaps' identical guard above (ahood-cli#106).
137
+ printSnaps(jsonOutput, snaps ?? [], "No snaps found.");
138
+ }
139
+ export async function showSnap(args) {
140
+ const jsonOutput = args.includes("--json");
141
+ const id = args.find((a) => !a.startsWith("--"));
142
+ if (!id)
143
+ throw new UsageError(SHOW_USAGE);
144
+ const snap = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`);
145
+ if (jsonOutput) {
146
+ console.log(JSON.stringify(snap));
147
+ return;
148
+ }
149
+ // Plain mode prints the raw content verbatim -- no formatting/labels/
150
+ // trailing decoration -- mirroring read.ts's own plain-mode contract
151
+ // exactly, process.stdout.write and not console.log: console.log
152
+ // unconditionally appends its own "\n", which double-adds a trailing
153
+ // blank line when the content already ends in one (ahood-cli#108).
154
+ process.stdout.write(snap.content);
155
+ }
156
+ export async function removeSnap(args) {
157
+ const id = args.find((a) => !a.startsWith("--"));
158
+ if (!id)
159
+ throw new UsageError(REMOVE_USAGE);
160
+ const yes = args.includes("--yes");
161
+ // Matches remove.ts/group.ts's confirm-before-destroy pattern (CLAUDE.md:
162
+ // "Destructive commands ... prompt for confirmation unless --yes is
163
+ // passed") -- deletion is irreversible per the API contract, same class
164
+ // of mistake ahood-cli#98/#99 already fixed for other commands.
165
+ const confirmed = yes ? true : await confirm(`Delete snap ${id}? Type "yes" to confirm: `);
166
+ if (!confirmed) {
167
+ console.log("Aborted.");
168
+ return;
169
+ }
170
+ await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, { method: "DELETE" });
171
+ console.log(`Deleted snap ${id}.`);
172
+ }
173
+ export async function shareSnap(args) {
174
+ const jsonOutput = args.includes("--json");
175
+ const id = args.find((a) => !a.startsWith("--"));
176
+ if (!id)
177
+ throw new UsageError(SHARE_USAGE);
178
+ // POST .../share is documented idempotent -- mints a link on first call,
179
+ // returns the same existing one on any later call -- so this never needs
180
+ // a confirm() gate: re-running it can't clobber or duplicate anything.
181
+ const { share_url } = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, {
182
+ method: "POST",
183
+ });
184
+ if (jsonOutput) {
185
+ console.log(JSON.stringify({ share_url }));
186
+ return;
187
+ }
188
+ console.log(share_url);
189
+ }
190
+ export async function unshareSnap(args) {
191
+ const id = args.find((a) => !a.startsWith("--"));
192
+ if (!id)
193
+ throw new UsageError(UNSHARE_USAGE);
194
+ const yes = args.includes("--yes");
195
+ // Not destructive in the data-loss sense -- the snap itself is untouched,
196
+ // and re-sharing mints/returns a link exactly as before (share is
197
+ // idempotent). But whoever the old link was handed to loses access the
198
+ // instant this runs, with no warning otherwise -- a real effect even
199
+ // though it's recoverable by re-running `share`. Gated the same way as
200
+ // every other snap verb with a live, external side effect (remove,
201
+ // and by analogy group.ts's removeMember/leaveGroup) for consistency
202
+ // across this command group, rather than carving out a silent exception
203
+ // here just because the data itself survives.
204
+ const confirmed = yes ? true : await confirm(`Revoke the share link for snap ${id}? Type "yes" to confirm: `);
205
+ if (!confirmed) {
206
+ console.log("Aborted.");
207
+ return;
208
+ }
209
+ await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
210
+ console.log(`Unshared snap ${id}.`);
211
+ }
212
+ export async function tagsSnap(args) {
213
+ const jsonOutput = args.includes("--json");
214
+ const positionals = args.filter((a) => a !== "--json");
215
+ const id = positionals[0];
216
+ if (!id)
217
+ throw new UsageError(TAGS_USAGE);
218
+ // PATCH replaces the full tag set (not a merge) -- omitting the tags
219
+ // argument, or passing an empty string, both clear every tag, matching
220
+ // the "pass [] to clear" contract of the endpoint itself. Joined, not
221
+ // just positionals[1] -- an unquoted "tag1, tag2" arrives as multiple
222
+ // positional tokens, and taking only the first one silently dropped the
223
+ // rest with no error, the same bug createSnap's own content-joining
224
+ // above already fixed.
225
+ const tagsArg = positionals.slice(1).join(" ");
226
+ const tags = tagsArg
227
+ ? tagsArg
228
+ .split(",")
229
+ .map((t) => t.trim())
230
+ .filter(Boolean)
231
+ : [];
232
+ // No confirm() gate, unlike remove/unshare -- this only replaces metadata
233
+ // (tags), never the snap's content or its shareability, and re-running
234
+ // `tags` with the old set restores it exactly. Per ahood-cli#114's spec.
235
+ const updated = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, {
236
+ method: "PATCH",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({ tags }),
239
+ });
240
+ if (jsonOutput) {
241
+ console.log(JSON.stringify(updated));
242
+ return;
243
+ }
244
+ // ?? [] -- see printSnaps' identical degrade-on-null guard above
245
+ // (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
246
+ const updatedTags = updated.tags ?? [];
247
+ console.log(updatedTags.length > 0 ? `Tags for ${updated.id}: ${updatedTags.join(", ")}` : `Cleared tags for ${updated.id}.`);
248
+ }
@@ -1,6 +1,6 @@
1
1
  import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
2
2
  import { readLockfile } from "../lockfile.js";
3
- import { add, fetchVersionMeta } from "./add.js";
3
+ import { add, fetchVersionMeta, updateMcpEntry } from "./add.js";
4
4
  import { UsageError } from "../usage-error.js";
5
5
  const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
6
6
  async function previewSkill(ownerSlashSkill, currentVersion) {
@@ -99,18 +99,27 @@ export async function update(args) {
99
99
  try {
100
100
  // add() always hits the .mcp.json collision check for an mcp-kind
101
101
  // entry that's already installed (that's the very definition of
102
- // "already installed" for that kind), so calling it unconditionally
103
- // here would make `ahood skill update` with no arguments permanently
104
- // report a failure and exit 1 for every user who has ever installed
105
- // an mcp artifact, even when nothing needs updating. fetchVersionMeta
106
- // already resolves `kind` as part of the normal "latest" lookup add()
107
- // itself does, so resolving it here first lets this skip cleanly
108
- // instead of updating and hitting that guaranteed error. This is a
109
- // skip-and-report-cleanly fix, not real mcp-update support.
102
+ // "already installed" for that kind), so it can never be called
103
+ // unconditionally here the way it is for skill/agent kinds below --
104
+ // fetchVersionMeta resolves `kind` as part of the normal "latest"
105
+ // lookup add() itself does, so resolving it here first lets an
106
+ // mcp-kind entry route to updateMcpEntry (ahood-cli#169's real
107
+ // mcp-update support) instead.
110
108
  const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
111
109
  const meta = await fetchVersionMeta(owner, skill, "latest");
112
110
  if (meta.kind === "mcp") {
113
- console.warn(`Skipping ${ownerSlashSkill}: mcp artifacts aren't updatable via this command yet.`);
111
+ const currentEntry = lockfile[ownerSlashSkill];
112
+ if (currentEntry && currentEntry.version === meta.version) {
113
+ // Matches skill/agent update's own "nothing to do" case for a
114
+ // pin that's already at latest -- not a warning (nothing's
115
+ // wrong), and, unlike skill/agent (which always blindly
116
+ // re-extracts even when nothing changed), skipped here
117
+ // specifically so an up-to-date mcp entry with a secret in its
118
+ // env never re-triggers a masked prompt for no reason.
119
+ console.log(`${ownerSlashSkill} is already up to date (v${meta.version}).`);
120
+ continue;
121
+ }
122
+ await updateMcpEntry(owner, skill, meta, currentEntry);
114
123
  continue;
115
124
  }
116
125
  await add([ownerSlashSkill]); // no @version -- resolves to latest again
package/dist/flags.js CHANGED
@@ -28,3 +28,21 @@ export function flagValue(args, flag) {
28
28
  }
29
29
  return undefined;
30
30
  }
31
+ // Shared by every "<verb> <query> [--json] [--limit <n>]" command (skill
32
+ // search, snap search) -- factored out after this exact logic was
33
+ // duplicated verbatim between them, which is exactly how ahood-cli#105's
34
+ // "--limit=5 misparsed as an unknown flag" bug could have silently come
35
+ // back in one copy while being fixed in the other. Strips --json and
36
+ // --limit (both "--limit N" and "--limit=N" forms) from the positional
37
+ // args, rejects any other unrecognized "--" flag, and joins what's left
38
+ // into a single query string.
39
+ export function parseSearchQuery(args, usage) {
40
+ const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && !a.startsWith("--limit=") && args[i - 1] !== "--limit");
41
+ const unknownFlag = queryParts.find((a) => a.startsWith("--"));
42
+ if (unknownFlag)
43
+ throw new UsageError(`Unknown flag: ${unknownFlag}\n${usage}`);
44
+ const query = queryParts.join(" ");
45
+ if (!query)
46
+ throw new UsageError(usage);
47
+ return query;
48
+ }