@ahood/cli 0.8.0 → 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 +2 -1
- package/dist/commands/add.js +140 -11
- package/dist/commands/remove.js +67 -24
- package/dist/commands/snap.js +62 -5
- package/dist/commands/update.js +19 -10
- package/dist/help.js +26 -6
- package/dist/index.js +2 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -243,13 +243,14 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
|
|
|
243
243
|
|
|
244
244
|
| Command | What it does |
|
|
245
245
|
| --- | --- |
|
|
246
|
-
| `ahood snap create <content> [--json]` | Capture a new private snap, from an argument or piped stdin. |
|
|
246
|
+
| `ahood snap create <content> [--tags tag1,tag2] [--json]` | Capture a new private snap, from an argument or piped stdin. |
|
|
247
247
|
| `ahood snap list [--json] [--limit <n>]` | List your own snaps, most recent first. |
|
|
248
248
|
| `ahood snap search <query> [--json] [--limit <n>]` | Search your own snaps by content. |
|
|
249
249
|
| `ahood snap show <id> [--json]` | Print a single snap's full content. |
|
|
250
250
|
| `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
|
|
251
251
|
| `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
|
|
252
252
|
| `ahood snap unshare <id> [--yes]` | Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed). |
|
|
253
|
+
| `ahood snap tags <id> [tag1,tag2,...] [--json]` | Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags). |
|
|
253
254
|
<!-- COMMANDS_TABLE_END -->
|
|
254
255
|
|
|
255
256
|
Run `ahood --help`, `ahood skill --help`, `ahood group --help`, `ahood snap --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help skill <verb>` / `ahood help group <verb>` / `ahood help snap <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
|
package/dist/commands/add.js
CHANGED
|
@@ -325,7 +325,9 @@ function summarizeConflictingEntry(entry) {
|
|
|
325
325
|
// raw TypeError, and would let `"mcpServers": []` pass the collision check
|
|
326
326
|
// silently (an array's numeric-index write is then dropped entirely by
|
|
327
327
|
// JSON.stringify), reporting a successful install that wrote nothing.
|
|
328
|
-
|
|
328
|
+
// Exported so remove.ts/update.ts can read the same validated shape when
|
|
329
|
+
// deciding whether it's safe to touch an mcp entry (ahood-cli#169).
|
|
330
|
+
export function readMcpConfig() {
|
|
329
331
|
if (!existsSync(MCP_CONFIG_PATH))
|
|
330
332
|
return { mcpServers: {} };
|
|
331
333
|
let parsed;
|
|
@@ -361,18 +363,24 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
361
363
|
`Remove it manually first if you want to reinstall ${owner}/${skill}.`);
|
|
362
364
|
}
|
|
363
365
|
}
|
|
364
|
-
|
|
366
|
+
// Extracts server.json, validates its shape, and resolves every secret
|
|
367
|
+
// environment variable it declares (prompting only for ones not already
|
|
368
|
+
// set in the shell) -- the part of installing an mcp entry that's identical
|
|
369
|
+
// whether this is a fresh `add` or an `update` moving an existing pin
|
|
370
|
+
// forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
|
|
371
|
+
// re-resolves secrets the same way a fresh install does, instead of
|
|
372
|
+
// duplicating this parsing/prompting logic.
|
|
373
|
+
export async function resolveMcpServerConfig(buffer) {
|
|
365
374
|
const content = await extractSingleFileContent(buffer, "server.json");
|
|
366
375
|
const manifest = JSON.parse(content.toString("utf-8"));
|
|
367
376
|
// Validate the manifest's shape (unsupported registry_type/runtime_hint,
|
|
368
|
-
// or neither a single 'packages' nor a single 'remotes' entry)
|
|
369
|
-
// for
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
377
|
+
// or neither a single 'packages' nor a single 'remotes' entry) BEFORE
|
|
378
|
+
// prompting for any secret -- an install/update that's going to be
|
|
379
|
+
// refused anyway must not first cost the user a masked secret prompt.
|
|
380
|
+
// buildMcpServerConfig is called here with an empty env purely to run its
|
|
381
|
+
// validation/throw; the result is discarded and rebuilt below once
|
|
382
|
+
// secretEnv is known.
|
|
374
383
|
buildMcpServerConfig(manifest, {});
|
|
375
|
-
assertNoCollision(readMcpConfig(), owner, skill);
|
|
376
384
|
const envVars = manifest.packages?.[0]?.environment_variables ?? [];
|
|
377
385
|
const secretEnv = {};
|
|
378
386
|
for (const variable of envVars) {
|
|
@@ -389,6 +397,29 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
389
397
|
: await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
|
|
390
398
|
}
|
|
391
399
|
const serverConfig = buildMcpServerConfig(manifest, secretEnv);
|
|
400
|
+
return { manifest, serverConfig };
|
|
401
|
+
}
|
|
402
|
+
// Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
|
|
403
|
+
// entry, stored in the lockfile alongside the version/checksum pin. Lets
|
|
404
|
+
// remove/update (ahood-cli#169) tell "the on-disk entry is still exactly
|
|
405
|
+
// what I last installed" from "something (the user, another tool) has since
|
|
406
|
+
// hand-edited it" -- without this, a real (not just warned-about) removal
|
|
407
|
+
// or update risks silently discarding an intentional local edit, the same
|
|
408
|
+
// class of risk assertNoCollision below exists to prevent on a *fresh*
|
|
409
|
+
// install. JSON.stringify's key order matches insertion order for a plain
|
|
410
|
+
// object, and JSON.parse's key order matches source-text order -- so this
|
|
411
|
+
// is stable between the object this process just built and the same object
|
|
412
|
+
// read back from disk later, as long as nothing reorders keys in between.
|
|
413
|
+
export function hashMcpServerConfig(config) {
|
|
414
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
415
|
+
}
|
|
416
|
+
async function installMcpEntry(owner, skill, meta, buffer) {
|
|
417
|
+
// Check for an existing .mcp.json collision BEFORE resolving secrets --
|
|
418
|
+
// matches resolveMcpServerConfig's own "don't cost the user a prompt for
|
|
419
|
+
// something that's going to be refused anyway" reasoning, just for the
|
|
420
|
+
// collision check specifically rather than the manifest-shape check.
|
|
421
|
+
assertNoCollision(readMcpConfig(), owner, skill);
|
|
422
|
+
const { serverConfig } = await resolveMcpServerConfig(buffer);
|
|
392
423
|
// Read-modify-write under an advisory lock, mirroring lockfile.ts's own
|
|
393
424
|
// writeLockfileEntry: .mcp.json sits right next to the lockfile and can
|
|
394
425
|
// hold other servers' secrets, so it gets the same protection against a
|
|
@@ -406,7 +437,11 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
406
437
|
});
|
|
407
438
|
const mcpKey = `${owner}/${skill}`;
|
|
408
439
|
try {
|
|
409
|
-
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, {
|
|
440
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, {
|
|
441
|
+
version: meta.version,
|
|
442
|
+
checksum_sha256: meta.checksum_sha256,
|
|
443
|
+
mcp_config_hash: hashMcpServerConfig(serverConfig),
|
|
444
|
+
});
|
|
410
445
|
}
|
|
411
446
|
catch (error) {
|
|
412
447
|
if (!(error instanceof LockfileChecksumConflictError))
|
|
@@ -421,7 +456,101 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
421
456
|
throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
|
|
422
457
|
}
|
|
423
458
|
console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
|
|
424
|
-
|
|
459
|
+
// Only the npm+npx package path ever carries secrets into `env` (headers
|
|
460
|
+
// on a remote entry are static per v1's scope, per buildMcpServerConfig) --
|
|
461
|
+
// checking the final serverConfig's own `env` key (rather than threading
|
|
462
|
+
// secretEnv out of resolveMcpServerConfig separately) means this warning
|
|
463
|
+
// can never drift from what was actually written to disk.
|
|
464
|
+
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
465
|
+
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Re-runs the mcp install flow for an already-installed entry, moving its
|
|
469
|
+
// lockfile pin forward -- update.ts's real mcp-update path (ahood-cli#169).
|
|
470
|
+
// Unlike installMcpEntry, "the entry already exists" is expected here (it's
|
|
471
|
+
// the very entry being updated), so the safety check is inverted: rather
|
|
472
|
+
// than refusing because something's already there, this refuses UNLESS the
|
|
473
|
+
// on-disk entry's fingerprint still matches what was recorded at the last
|
|
474
|
+
// install/update -- same posture as assertNoCollision, applied to "is this
|
|
475
|
+
// still mine to touch" instead of "is this mine to create". If the entry is
|
|
476
|
+
// missing entirely, there's nothing to protect -- write fresh (self-heals a
|
|
477
|
+
// lockfile pin whose .mcp.json entry was deleted by hand without going
|
|
478
|
+
// through `ahood skill remove`). If it's present but doesn't match (or no
|
|
479
|
+
// fingerprint was ever recorded, e.g. an entry installed before this field
|
|
480
|
+
// existed), refuse rather than silently overwrite a possibly-intentional
|
|
481
|
+
// local edit.
|
|
482
|
+
export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
483
|
+
const key = `${owner}/${skill}`;
|
|
484
|
+
const recordedHash = currentEntry?.mcp_config_hash;
|
|
485
|
+
const fileContents = readMcpConfig();
|
|
486
|
+
const mcpServers = fileContents.mcpServers;
|
|
487
|
+
const existingOnDisk = Object.prototype.hasOwnProperty.call(mcpServers, skill) ? mcpServers[skill] : undefined;
|
|
488
|
+
// Both refusal messages below point at manually editing .mcp.json, not
|
|
489
|
+
// `ahood skill remove` -- remove refuses to delete an entry under
|
|
490
|
+
// exactly these same two conditions (no recorded fingerprint, or a
|
|
491
|
+
// fingerprint mismatch), so telling the user to "remove then add" would
|
|
492
|
+
// send them into a sequence where remove leaves the entry in place *and*
|
|
493
|
+
// clears the lockfile pin, and the follow-up add then fails on the very
|
|
494
|
+
// same entry as a collision -- pin gone, credential still live, no CLI
|
|
495
|
+
// path forward. Editing .mcp.json by hand and then running `add` is the
|
|
496
|
+
// only sequence that actually resolves the state.
|
|
497
|
+
if (existingOnDisk !== undefined) {
|
|
498
|
+
if (recordedHash === undefined) {
|
|
499
|
+
throw new Error(`Cannot verify ${key}'s .mcp.json entry matches what ahood last installed (no recorded fingerprint) -- delete the "${skill}" entry from mcpServers in .mcp.json by hand, then run \`ahood skill add ${key}\` to reinstall and enable automatic updates going forward.`);
|
|
500
|
+
}
|
|
501
|
+
if (hashMcpServerConfig(existingOnDisk) !== recordedHash) {
|
|
502
|
+
throw new Error(`${key}'s .mcp.json entry appears to have been modified since install -- refusing to overwrite it. Delete the "${skill}" entry from mcpServers in .mcp.json by hand, then run \`ahood skill add ${key}\` to reinstall.`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
506
|
+
const { serverConfig } = await resolveMcpServerConfig(buffer);
|
|
507
|
+
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
508
|
+
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
509
|
+
// arbitrary time may have passed since the pre-check -- the download, and
|
|
510
|
+
// any secret prompt inside resolveMcpServerConfig -- during which another
|
|
511
|
+
// process could have changed the entry.
|
|
512
|
+
let previousOnDisk;
|
|
513
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
514
|
+
const fresh = readMcpConfig();
|
|
515
|
+
const freshServers = fresh.mcpServers;
|
|
516
|
+
const freshExisting = Object.prototype.hasOwnProperty.call(freshServers, skill) ? freshServers[skill] : undefined;
|
|
517
|
+
if (freshExisting !== undefined && (recordedHash === undefined || hashMcpServerConfig(freshExisting) !== recordedHash)) {
|
|
518
|
+
throw new Error(`${key}'s .mcp.json entry changed while updating -- refusing to overwrite it. Re-run \`ahood skill update ${key}\` if this was unexpected.`);
|
|
519
|
+
}
|
|
520
|
+
previousOnDisk = freshExisting;
|
|
521
|
+
freshServers[skill] = serverConfig;
|
|
522
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
|
|
523
|
+
});
|
|
524
|
+
try {
|
|
525
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
|
|
526
|
+
version: meta.version,
|
|
527
|
+
checksum_sha256: meta.checksum_sha256,
|
|
528
|
+
mcp_config_hash: hashMcpServerConfig(serverConfig),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (!(error instanceof LockfileChecksumConflictError))
|
|
533
|
+
throw error;
|
|
534
|
+
// Roll back the .mcp.json overwrite above under the same lock pattern,
|
|
535
|
+
// restoring the entry to what it was before this update touched it --
|
|
536
|
+
// not deleting it, unlike installMcpEntry's rollback: unless this was
|
|
537
|
+
// the self-heal-a-missing-entry case (previousOnDisk undefined), there
|
|
538
|
+
// WAS a working entry here before this update began.
|
|
539
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
540
|
+
const fileContents = readMcpConfig();
|
|
541
|
+
const mcpServers = fileContents.mcpServers;
|
|
542
|
+
if (previousOnDisk === undefined) {
|
|
543
|
+
delete mcpServers[skill];
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
mcpServers[skill] = previousOnDisk;
|
|
547
|
+
}
|
|
548
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
549
|
+
});
|
|
550
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
551
|
+
}
|
|
552
|
+
console.log(`Updated ${key} to ${meta.version}`);
|
|
553
|
+
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
425
554
|
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
426
555
|
}
|
|
427
556
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
|
-
import { readLockfile, removeLockfileEntry } from "../lockfile.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
7
|
const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
|
|
7
8
|
export async function remove(args) {
|
|
@@ -19,7 +20,8 @@ export async function remove(args) {
|
|
|
19
20
|
// never a directory under .claude/skills/ -- distinct from the dir check above.
|
|
20
21
|
const agentFile = agentPath(owner, skill);
|
|
21
22
|
const agentExisted = existsSync(agentFile);
|
|
22
|
-
const
|
|
23
|
+
const lockEntry = readLockfile(LOCKFILE_PATH)[key];
|
|
24
|
+
const hadLockfileEntry = lockEntry !== undefined;
|
|
23
25
|
if (!dirExisted && !agentExisted && !hadLockfileEntry) {
|
|
24
26
|
console.error(`${key} was not installed -- nothing to remove.`);
|
|
25
27
|
process.exitCode = 1;
|
|
@@ -42,30 +44,71 @@ export async function remove(args) {
|
|
|
42
44
|
// An mcp-kind install has no directory or agent file on disk -- its only
|
|
43
45
|
// footprint here is the lockfile entry just cleared above and a live
|
|
44
46
|
// entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
|
|
45
|
-
// holding a resolved secret in its `env`).
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// the
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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;
|
|
62
71
|
}
|
|
63
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
|
+
}
|
|
64
104
|
catch {
|
|
65
|
-
//
|
|
66
|
-
// on -- add.ts's readMcpConfig is the strict validator for that path.
|
|
67
|
-
// Skip the warning rather than throw here.
|
|
105
|
+
// removedMcpEntry stays false -- falls through to the warning below.
|
|
68
106
|
}
|
|
69
107
|
}
|
|
70
|
-
|
|
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}`);
|
|
71
114
|
}
|
package/dist/commands/snap.js
CHANGED
|
@@ -2,13 +2,14 @@ import { apiJson } from "../http.js";
|
|
|
2
2
|
import { flagValue, parseSearchQuery } 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> (or pipe content on stdin)";
|
|
5
|
+
const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin)";
|
|
6
6
|
const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>]";
|
|
7
7
|
const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>]";
|
|
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
13
|
// There's no existing "read stdin to completion" helper in this codebase to
|
|
13
14
|
// reuse -- confirm.ts and secret-prompt.ts both only ever read a single
|
|
14
15
|
// line via readline, never an arbitrary-length paste/pipe. Standard Node
|
|
@@ -50,16 +51,24 @@ function printSnaps(jsonOutput, snaps, emptyMessage) {
|
|
|
50
51
|
// breaks the "one line per snap" contract.
|
|
51
52
|
const flat = snap.content_preview.replace(/\s+/g, " ").trim();
|
|
52
53
|
const preview = flat.length > 60 ? `${flat.slice(0, 60)}...` : flat;
|
|
53
|
-
|
|
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}`);
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
export async function createSnap(args) {
|
|
57
63
|
const jsonOutput = args.includes("--json");
|
|
64
|
+
const tagsArg = flagValue(args, "--tags");
|
|
58
65
|
// Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
|
|
59
66
|
// snap create Debugged the flaky CI step`) arrives as multiple positional
|
|
60
67
|
// tokens, and taking only the first one silently dropped the rest with no
|
|
61
|
-
// error. Mirrors searchSnaps' own query-joining below.
|
|
62
|
-
|
|
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");
|
|
63
72
|
const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
|
|
64
73
|
let content;
|
|
65
74
|
if (positional !== undefined) {
|
|
@@ -79,10 +88,21 @@ export async function createSnap(args) {
|
|
|
79
88
|
}
|
|
80
89
|
if (!content.trim())
|
|
81
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
|
+
}
|
|
82
102
|
const created = await apiJson("/api/v1/snaps", {
|
|
83
103
|
method: "POST",
|
|
84
104
|
headers: { "Content-Type": "application/json" },
|
|
85
|
-
body: JSON.stringify(
|
|
105
|
+
body: JSON.stringify(body),
|
|
86
106
|
});
|
|
87
107
|
if (jsonOutput) {
|
|
88
108
|
console.log(JSON.stringify(created));
|
|
@@ -189,3 +209,40 @@ export async function unshareSnap(args) {
|
|
|
189
209
|
await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
|
|
190
210
|
console.log(`Unshared snap ${id}.`);
|
|
191
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/help.js
CHANGED
|
@@ -100,7 +100,9 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
100
100
|
{
|
|
101
101
|
usage: "ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]",
|
|
102
102
|
summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
|
|
103
|
-
desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest."
|
|
103
|
+
desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest. " +
|
|
104
|
+
"An mcp-kind entry is updated in place (re-resolving any secret environment variables) as long as its .mcp.json entry still matches what " +
|
|
105
|
+
"ahood last installed there -- a hand-edited or unverifiable entry is refused rather than silently overwritten.",
|
|
104
106
|
flags: [
|
|
105
107
|
"--dry-run Preview current vs. latest version (and the changelog for anything behind) without installing anything.",
|
|
106
108
|
"--json With --dry-run, emit structured preview objects instead of a formatted table.",
|
|
@@ -119,7 +121,9 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
119
121
|
{
|
|
120
122
|
usage: "ahood skill remove <owner>/<skill> [--yes]",
|
|
121
123
|
summary: "Uninstall and unpin a skill (local only, prompts for confirmation unless --yes is passed).",
|
|
122
|
-
desc: "Uninstall and unpin (local only). Prompts for confirmation unless --yes is passed."
|
|
124
|
+
desc: "Uninstall and unpin (local only). Prompts for confirmation unless --yes is passed. For an mcp-kind install, " +
|
|
125
|
+
"also deletes its .mcp.json entry as long as it still matches what ahood last installed there -- a hand-edited " +
|
|
126
|
+
"or unverifiable entry is left in place and warned about instead of being silently touched.",
|
|
123
127
|
},
|
|
124
128
|
{
|
|
125
129
|
usage: "ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
|
|
@@ -275,18 +279,25 @@ export const GROUP_COMMANDS_HELP = [
|
|
|
275
279
|
// never public otherwise.
|
|
276
280
|
export const SNAP_COMMANDS_HELP = [
|
|
277
281
|
{
|
|
278
|
-
usage: "ahood snap create <content> [--json]",
|
|
282
|
+
usage: "ahood snap create <content> [--tags tag1,tag2] [--json]",
|
|
279
283
|
summary: "Capture a new private snap, from an argument or piped stdin.",
|
|
280
284
|
desc: "Capture a new private snap. Pass the content as an argument, or omit it and pipe content on stdin " +
|
|
281
285
|
"(e.g. `echo \"...\" | ahood snap create`, or a piped heredoc) -- useful for capturing a whole session's " +
|
|
282
286
|
"worth of freeform text at once. Prints the new snap's id on success.",
|
|
283
|
-
flags: [
|
|
284
|
-
|
|
287
|
+
flags: [
|
|
288
|
+
"--tags <comma,separated> Attach tags to the new snap.",
|
|
289
|
+
"--json Emit {id, created_at} instead of just the bare id.",
|
|
290
|
+
],
|
|
291
|
+
examples: [
|
|
292
|
+
'ahood snap create "Debugged the flaky CI step, root cause was a race in the cache key." --tags deploy,bugfix',
|
|
293
|
+
'echo "..." | ahood snap create',
|
|
294
|
+
],
|
|
285
295
|
},
|
|
286
296
|
{
|
|
287
297
|
usage: "ahood snap list [--json] [--limit <n>]",
|
|
288
298
|
summary: "List your own snaps, most recent first.",
|
|
289
|
-
desc: "List your own snaps: id, a truncated content preview, created_at,
|
|
299
|
+
desc: "List your own snaps: id, a truncated content preview, created_at, a (shared) marker if shared, and " +
|
|
300
|
+
"any tags in brackets.",
|
|
290
301
|
flags: [
|
|
291
302
|
"--json Emit the raw snap objects instead of formatted lines.",
|
|
292
303
|
"--limit <n> Cap the number of results.",
|
|
@@ -329,6 +340,15 @@ export const SNAP_COMMANDS_HELP = [
|
|
|
329
340
|
"immediately.",
|
|
330
341
|
flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
|
|
331
342
|
},
|
|
343
|
+
{
|
|
344
|
+
usage: "ahood snap tags <id> [tag1,tag2,...] [--json]",
|
|
345
|
+
summary: "Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags).",
|
|
346
|
+
desc: "Replace the full set of tags on a snap -- this is not a merge, so any existing tags not listed are " +
|
|
347
|
+
"dropped. Omitting the tag list entirely, or passing an empty string, clears all tags. No confirmation " +
|
|
348
|
+
"prompt: unlike remove/unshare this only touches metadata, never the snap's content or shareability.",
|
|
349
|
+
flags: ["--json Emit {id, tags} instead of a plain-text summary."],
|
|
350
|
+
examples: ["ahood snap tags snap_123 deploy,bugfix", 'ahood snap tags snap_123 ""'],
|
|
351
|
+
},
|
|
332
352
|
];
|
|
333
353
|
// Exported so other consumers of the command list (e.g. shell completion) can
|
|
334
354
|
// surface aliases without needing their own copy of this map. Aliases apply
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { completion } from "./commands/completion.js";
|
|
|
22
22
|
import { mcp } from "./commands/mcp.js";
|
|
23
23
|
import { init } from "./commands/init.js";
|
|
24
24
|
import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
|
|
25
|
-
import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, } from "./commands/snap.js";
|
|
25
|
+
import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, tagsSnap, } from "./commands/snap.js";
|
|
26
26
|
import { formatHelp, formatSkillHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
|
|
27
27
|
import { ApiError } from "./http.js";
|
|
28
28
|
import { exitCodeFor } from "./exit-code.js";
|
|
@@ -80,6 +80,7 @@ const SNAP_VERBS = {
|
|
|
80
80
|
remove: removeSnap,
|
|
81
81
|
share: shareSnap,
|
|
82
82
|
unshare: unshareSnap,
|
|
83
|
+
tags: tagsSnap,
|
|
83
84
|
};
|
|
84
85
|
// Top level is now just account/auth-scoped commands (not entity-specific --
|
|
85
86
|
// same reasoning `gh auth login` isn't `gh account login`) plus the `skill`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahood/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/AlexKay28/ahood-cli",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"dist"
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
|
-
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
29
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
30
30
|
"prepublishOnly": "npm run build",
|
|
31
31
|
"test": "vitest run",
|
|
32
32
|
"sync-readme": "node scripts/sync-readme.mjs"
|