@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.
- package/README.md +20 -7
- package/dist/commands/add.js +201 -23
- package/dist/commands/group.js +23 -2
- package/dist/commands/init.js +1 -1
- package/dist/commands/list.js +5 -1
- package/dist/commands/login.js +24 -10
- package/dist/commands/publish.js +5 -0
- package/dist/commands/read.js +7 -1
- package/dist/commands/remove.js +87 -42
- package/dist/commands/search.js +7 -9
- package/dist/commands/snap.js +248 -0
- package/dist/commands/update.js +19 -10
- package/dist/flags.js +18 -0
- package/dist/help.js +126 -12
- package/dist/http.js +10 -1
- package/dist/index.js +62 -9
- package/dist/lockfile.js +96 -3
- package/dist/spec.js +26 -15
- package/package.json +2 -2
package/dist/commands/remove.js
CHANGED
|
@@ -1,69 +1,114 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
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/ --
|
|
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
|
-
|
|
33
|
-
|
|
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`).
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// the
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/search.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/update.js
CHANGED
|
@@ -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
|
|
103
|
-
// here
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|