@ahood/cli 0.8.7 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/add.js +98 -48
- package/dist/commands/read.js +27 -1
- package/dist/commands/remove.js +54 -11
- package/dist/commands/snap.js +140 -35
- package/dist/commands/update.js +48 -2
- package/dist/flags.js +100 -8
- package/dist/help.js +22 -8
- package/dist/http.js +20 -3
- package/dist/lockfile.js +268 -34
- package/dist/terminal-safe.js +62 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -252,7 +252,7 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
|
|
|
252
252
|
| `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
|
|
253
253
|
| `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
|
|
254
254
|
| `ahood snap unshare <id> [--yes]` | Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed). |
|
|
255
|
-
| `ahood snap tags <id> [
|
|
255
|
+
| `ahood snap tags <id> [tag ...] [--clear] [--json]` | Print a snap's tags, or replace them with the tags given. |
|
|
256
256
|
<!-- COMMANDS_TABLE_END -->
|
|
257
257
|
|
|
258
258
|
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
|
@@ -7,6 +7,7 @@ import { apiFetch, apiJson, ApiError, sanitizeErrorMessage } from "../http.js";
|
|
|
7
7
|
import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir, agentPath, AGENTS_ROOT, MCP_CONFIG_PATH } from "../spec.js";
|
|
8
8
|
import { readLockfile, withLock, writeJsonFileAtomic, writeLockfileEntryVerifyingChecksum, LockfileChecksumConflictError, } from "../lockfile.js";
|
|
9
9
|
import { promptSecret } from "../secret-prompt.js";
|
|
10
|
+
import { sanitizeForTerminal } from "../terminal-safe.js";
|
|
10
11
|
import { UsageError } from "../usage-error.js";
|
|
11
12
|
const USAGE = "Usage: ahood skill add <owner>/<skill>[@version]";
|
|
12
13
|
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
|
|
@@ -237,25 +238,6 @@ export async function extractSingleFileContent(buffer, entryName) {
|
|
|
237
238
|
throw new Error(`${entryName} not found in the downloaded archive`);
|
|
238
239
|
return found;
|
|
239
240
|
}
|
|
240
|
-
// Every manifest field used below -- and every tar entry name extractTarGz
|
|
241
|
-
// quotes back in an error above -- reaches the terminal (an error message, or
|
|
242
|
-
// -- worse -- the text printed immediately before a masked secret prompt)
|
|
243
|
-
// but comes verbatim from a third-party-published archive: server-side
|
|
244
|
-
// validation only checks these are strings, not that they're free of
|
|
245
|
-
// control/escape characters. A malicious description could otherwise smuggle
|
|
246
|
-
// a terminal control sequence (cursor movement, line-clear) that repaints
|
|
247
|
-
// what the user sees at the exact moment they're about to type a credential.
|
|
248
|
-
// Strips C0/C1 control characters (including ESC, \x1b) and caps length so a
|
|
249
|
-
// single field can't also flood the terminal.
|
|
250
|
-
//
|
|
251
|
-
// Takes `unknown` rather than `string` because every caller's "string" is a
|
|
252
|
-
// declared type over an unchecked `as ServerManifest` cast of downloaded JSON,
|
|
253
|
-
// not a guarantee: calling .replace() directly on a `registry_type: 123` would
|
|
254
|
-
// turn a diagnosable message into the raw TypeError ahood-cli#121 removed from
|
|
255
|
-
// this same path (ahood-cli#122).
|
|
256
|
-
function sanitizeForTerminal(text) {
|
|
257
|
-
return String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
|
|
258
|
-
}
|
|
259
241
|
// Only npm+npx is supported in v1 (docs/superpowers/specs/2026-09-03-mcp-server-artifacts-design.md) --
|
|
260
242
|
// server-side publish validation (lib/publish/parse-server-manifest.ts) already
|
|
261
243
|
// rejects anything else, so this check is defense-in-depth against a
|
|
@@ -480,10 +462,10 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
|
480
462
|
throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
|
|
481
463
|
}
|
|
482
464
|
}
|
|
483
|
-
// Required-ness
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
465
|
+
// Required-ness is consulted on the secret path only in the one branch
|
|
466
|
+
// where a secret can still end up unresolved: the unattended update guard
|
|
467
|
+
// below. Every other branch either resolves a value or prompts for one, so
|
|
468
|
+
// an `is_required` check there would change nothing (ahood-cli#120).
|
|
487
469
|
const secretNames = [];
|
|
488
470
|
for (const variable of envVars) {
|
|
489
471
|
if (!variable.is_secret)
|
|
@@ -511,6 +493,21 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
|
511
493
|
continue;
|
|
512
494
|
}
|
|
513
495
|
if (!allowNonTtyPrompt && !process.stdin.isTTY) {
|
|
496
|
+
// Optional means optional here too, exactly as it does on the
|
|
497
|
+
// non-secret pass above (ahood-cli#126). This guard used to fire for
|
|
498
|
+
// ANY unresolved secret, so a single artifact declaring an optional
|
|
499
|
+
// token that nothing had exported aborted the whole `ahood skill
|
|
500
|
+
// update` walk -- and said the variable "is required" while the
|
|
501
|
+
// manifest that declared it said otherwise. Announced rather than
|
|
502
|
+
// silent because skipping and carrying forward leave different entries
|
|
503
|
+
// in .mcp.json and nothing else distinguishes them; the notice is
|
|
504
|
+
// confined to this branch, so an interactive `add` (which still prompts
|
|
505
|
+
// for optional secrets) and a routine update whose credential carries
|
|
506
|
+
// forward both stay quiet.
|
|
507
|
+
if (!variable.is_required) {
|
|
508
|
+
console.warn(`${sanitizeForTerminal(variable.name)} is optional and is not set -- updating ${sanitizeForTerminal(manifest.name)} without it.`);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
514
511
|
throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set, and there is no terminal to prompt on -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
|
|
515
512
|
}
|
|
516
513
|
env[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
|
|
@@ -602,6 +599,22 @@ function readMcpEntryEnv(entry) {
|
|
|
602
599
|
}
|
|
603
600
|
return result;
|
|
604
601
|
}
|
|
602
|
+
// Every install path below undoes its own on-disk write when the lockfile
|
|
603
|
+
// write that would have pinned it fails. This runs that undo so that a
|
|
604
|
+
// failure IN the undo can never replace the original error: a rollback fails
|
|
605
|
+
// for the same reasons the write did (the lock still contended, the disk
|
|
606
|
+
// still full), and letting that escape would hand the user an EACCES or a
|
|
607
|
+
// lock timeout in place of the checksum-conflict message that actually
|
|
608
|
+
// explains what happened (ahood-cli#132). The warning is the consolation
|
|
609
|
+
// prize -- it has to name what was left behind, since nothing else will.
|
|
610
|
+
function rollbackOrWarn(rollback, warning) {
|
|
611
|
+
try {
|
|
612
|
+
rollback();
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
console.warn(warning);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
605
618
|
async function installMcpEntry(owner, skill, meta, buffer) {
|
|
606
619
|
// Check for an existing .mcp.json collision BEFORE resolving secrets --
|
|
607
620
|
// matches resolveMcpServerConfig's own "don't cost the user a prompt for
|
|
@@ -633,16 +646,43 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
633
646
|
});
|
|
634
647
|
}
|
|
635
648
|
catch (error) {
|
|
636
|
-
if (!(error instanceof LockfileChecksumConflictError))
|
|
637
|
-
throw error;
|
|
638
649
|
// Roll back the .mcp.json merge above under the same lock pattern --
|
|
639
650
|
// re-read+delete rather than assuming nothing else changed it since.
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
651
|
+
//
|
|
652
|
+
// Runs for EVERY lockfile failure, not just a checksum conflict
|
|
653
|
+
// (ahood-cli#132) -- the same widening updateMcpEntry below already got,
|
|
654
|
+
// for the same reason. A rethrow-before-rollback here left the entry
|
|
655
|
+
// this function just merged in -- including, on the npm+npx path, the
|
|
656
|
+
// plaintext secret the user had just typed at the prompt -- sitting in
|
|
657
|
+
// .mcp.json with no lockfile pin at all. `remove` then bails at its
|
|
658
|
+
// `!dirExisted && !agentExisted && !hadLockfileEntry` check ("was not
|
|
659
|
+
// installed -- nothing to remove") before it ever reaches its .mcp.json
|
|
660
|
+
// cleanup, and a retried `add` refuses forever at assertNoCollision:
|
|
661
|
+
// the credential is removable only by hand-editing .mcp.json, and
|
|
662
|
+
// nothing tells the user it is there. Reachable from nothing worse than
|
|
663
|
+
// withLock's 5s timeout against a concurrent ahood, or an EACCES/ENOSPC
|
|
664
|
+
// on the write.
|
|
665
|
+
//
|
|
666
|
+
// Deletes, where updateMcpEntry's rollback restores: assertNoCollision
|
|
667
|
+
// has already established there was no entry under this key before this
|
|
668
|
+
// install wrote one, so there is nothing to put back. Deletes it
|
|
669
|
+
// unconditionally rather than re-fingerprinting it first the way
|
|
670
|
+
// remove.ts does -- remove is deciding about an entry of unknown age
|
|
671
|
+
// that may hold an intentional local edit, while this one was written
|
|
672
|
+
// by this same process moments ago and cannot be anyone else's work.
|
|
673
|
+
// Re-verifying here would only reintroduce the stranded-secret dead end
|
|
674
|
+
// above for whatever raced us.
|
|
675
|
+
rollbackOrWarn(() => {
|
|
676
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
677
|
+
const fileContents = readMcpConfig();
|
|
678
|
+
delete fileContents.mcpServers[skill];
|
|
679
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
680
|
+
});
|
|
681
|
+
}, `WARNING: could not roll back ${MCP_CONFIG_PATH} after ${mcpKey}'s install failed -- its mcpServers."${skill}" entry (which may contain a secret you entered) may still be present while ${LOCKFILE_PATH} has no pin for it. Delete that entry by hand before re-running \`ahood skill add ${mcpKey}\`.`);
|
|
682
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
683
|
+
throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
|
|
684
|
+
}
|
|
685
|
+
throw error;
|
|
646
686
|
}
|
|
647
687
|
console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
|
|
648
688
|
// Only the npm+npx package path ever carries secrets into `env` (headers
|
|
@@ -732,7 +772,12 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
732
772
|
// `remove` permanently refuse -- the exact dead end the comments above
|
|
733
773
|
// exist to prevent, reachable from nothing worse than withLock's 5s
|
|
734
774
|
// timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
|
|
735
|
-
|
|
775
|
+
//
|
|
776
|
+
// The rollback itself can fail for the same reason the write did (lock
|
|
777
|
+
// still contended, disk still full), so it goes through rollbackOrWarn:
|
|
778
|
+
// the original error below is the more actionable one, and masking it
|
|
779
|
+
// with this one would hide why the update failed in the first place.
|
|
780
|
+
rollbackOrWarn(() => {
|
|
736
781
|
withLock(MCP_CONFIG_PATH, () => {
|
|
737
782
|
const fileContents = readMcpConfig();
|
|
738
783
|
const mcpServers = fileContents.mcpServers;
|
|
@@ -744,14 +789,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
744
789
|
}
|
|
745
790
|
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
746
791
|
});
|
|
747
|
-
}
|
|
748
|
-
catch {
|
|
749
|
-
// The rollback can fail for the same reason the write did (lock still
|
|
750
|
-
// contended, disk still full). Warn rather than throw: the original
|
|
751
|
-
// error below is the more actionable one, and masking it with this
|
|
752
|
-
// one would hide why the update failed in the first place.
|
|
753
|
-
console.warn(`WARNING: could not roll back ${MCP_CONFIG_PATH} after ${key}'s update failed -- its mcpServers."${skill}" entry may be on the new version while ${LOCKFILE_PATH} still pins the old one. Delete that entry by hand and run \`ahood skill add ${key}\` to reinstall.`);
|
|
754
|
-
}
|
|
792
|
+
}, `WARNING: could not roll back ${MCP_CONFIG_PATH} after ${key}'s update failed -- its mcpServers."${skill}" entry may be on the new version while ${LOCKFILE_PATH} still pins the old one. Delete that entry by hand and run \`ahood skill add ${key}\` to reinstall.`);
|
|
755
793
|
if (error instanceof LockfileChecksumConflictError) {
|
|
756
794
|
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
757
795
|
}
|
|
@@ -829,10 +867,18 @@ export async function add(args) {
|
|
|
829
867
|
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, { version: meta.version, checksum_sha256: meta.checksum_sha256 });
|
|
830
868
|
}
|
|
831
869
|
catch (error) {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
870
|
+
// Widened past the checksum-conflict case with installMcpEntry's own
|
|
871
|
+
// rollback (ahood-cli#132): an agent file left behind by a lock
|
|
872
|
+
// timeout is less dangerous than a stranded mcp secret -- `remove`
|
|
873
|
+
// finds it via agentExisted even with no pin -- but "a failed install
|
|
874
|
+
// installs nothing" is the invariant worth having at all three sites,
|
|
875
|
+
// and the previous version's content is gone either way, overwritten
|
|
876
|
+
// by the writeFileSync above before the pin was ever attempted.
|
|
877
|
+
rollbackOrWarn(() => rmSync(destPath, { force: true }), `WARNING: could not remove ${destPath} after ${key}'s install failed -- it may be left behind with no ${LOCKFILE_PATH} pin. Run \`ahood skill remove ${key}\` to clean it up.`);
|
|
878
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
879
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
880
|
+
}
|
|
881
|
+
throw error;
|
|
836
882
|
}
|
|
837
883
|
console.log(`Installed ${key}@${meta.version} to ${destPath}`);
|
|
838
884
|
return;
|
|
@@ -852,10 +898,14 @@ export async function add(args) {
|
|
|
852
898
|
});
|
|
853
899
|
}
|
|
854
900
|
catch (error) {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
901
|
+
// Same widening as the agent path above (ahood-cli#132). extractFreshVersion
|
|
902
|
+
// has already cleared any previous version out of this directory, so the
|
|
903
|
+
// only thing this deletes is what this failed install just put there.
|
|
904
|
+
rollbackOrWarn(() => rmSync(destDir, { recursive: true, force: true }), `WARNING: could not remove ${destDir} after ${key}'s install failed -- it may be left behind with no ${LOCKFILE_PATH} pin. Run \`ahood skill remove ${key}\` to clean it up.`);
|
|
905
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
906
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
907
|
+
}
|
|
908
|
+
throw error;
|
|
859
909
|
}
|
|
860
910
|
console.log(`Installed ${key}@${meta.version} to ${destDir}`);
|
|
861
911
|
}
|
package/dist/commands/read.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
2
|
import { parseOwnerSkill } from "../spec.js";
|
|
3
|
+
import { sanitizeDocumentForTerminal, sanitizeForTerminal } from "../terminal-safe.js";
|
|
3
4
|
import { UsageError } from "../usage-error.js";
|
|
4
5
|
const USAGE = "Usage: ahood skill read <owner>/<skill> [--json]";
|
|
5
6
|
export async function readSkillMd(owner, skill) {
|
|
@@ -12,8 +13,16 @@ export async function readSkillMd(owner, skill) {
|
|
|
12
13
|
// be silent about handing back a yanked version's content. Written to
|
|
13
14
|
// stderr, not stdout -- safe for MCP callers too (only stdout is reserved
|
|
14
15
|
// for the JSON-RPC stream).
|
|
16
|
+
//
|
|
17
|
+
// yanked_reason is publisher-controlled free text interpolated into a
|
|
18
|
+
// CLI-authored line, which is exactly sanitizeForTerminal's case (and
|
|
19
|
+
// exactly what add.ts does with manifest fields) -- not the document case
|
|
20
|
+
// below, so it keeps the flatten-newlines/200-char defaults rather than
|
|
21
|
+
// sanitizeDocumentForTerminal. Unconditional, not TTY-gated: nothing
|
|
22
|
+
// round-trips a warning, so there is no byte-exactness to preserve here
|
|
23
|
+
// (ahood-cli#133).
|
|
15
24
|
if (detail.skill_versions.yanked_at) {
|
|
16
|
-
console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${detail.skill_versions.yanked_reason}` : "."}`);
|
|
25
|
+
console.warn(`WARNING: ${detail.owner}/${detail.slug}@${detail.skill_versions.version} has been yanked${detail.skill_versions.yanked_reason ? `: ${sanitizeForTerminal(detail.skill_versions.yanked_reason)}` : "."}`);
|
|
17
26
|
}
|
|
18
27
|
const content = detail.skill_versions.skill_md_content;
|
|
19
28
|
if (!content) {
|
|
@@ -52,5 +61,22 @@ export async function read(args) {
|
|
|
52
61
|
// a real, reported round-trip mismatch between "ahood skill read" and the
|
|
53
62
|
// source file, even though the registry itself stores/serves the content
|
|
54
63
|
// byte-exact (ahood-cli#90).
|
|
64
|
+
//
|
|
65
|
+
// That byte-exactness is a promise made to a *pipe*, though, and #90 is the
|
|
66
|
+
// only reason the raw write exists. A human looking at a terminal gets no
|
|
67
|
+
// value from it and pays for it with every control sequence the publisher
|
|
68
|
+
// chose to embed, because skill_md_content is publisher-controlled free text
|
|
69
|
+
// and `skill read` is the command the CLI advertises for inspecting a skill
|
|
70
|
+
// *before* installing it -- the cautious path. An embedded OSC 52
|
|
71
|
+
// ("\x1b]52;c;<base64>\x07") silently overwrites the reader's system
|
|
72
|
+
// clipboard on xterm/kitty/wezterm/foot; cheaper variants retitle the window
|
|
73
|
+
// (OSC 0), clear the screen and forge "verified safe" output (ESC[2J ESC[H),
|
|
74
|
+
// or switch charsets (ESC(0) and corrupt every later line in the session. So
|
|
75
|
+
// strip on the TTY path only, leaving the redirected path the exact bytes
|
|
76
|
+
// #90 guaranteed (ahood-cli#133).
|
|
77
|
+
if (process.stdout.isTTY) {
|
|
78
|
+
process.stdout.write(sanitizeDocumentForTerminal(content));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
55
81
|
process.stdout.write(content);
|
|
56
82
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, rmSync
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
3
|
import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
|
|
4
4
|
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
|
|
@@ -22,6 +22,14 @@ export async function remove(args) {
|
|
|
22
22
|
const agentExisted = existsSync(agentFile);
|
|
23
23
|
const lockEntry = readLockfile(LOCKFILE_PATH)[key];
|
|
24
24
|
const hadLockfileEntry = lockEntry !== undefined;
|
|
25
|
+
// A pin whose install left nothing on disk is the defining shape of an
|
|
26
|
+
// mcp-kind install: its only footprint is this pin plus a live .mcp.json
|
|
27
|
+
// entry. Kept as evidence for the unreadable-.mcp.json warning at the
|
|
28
|
+
// bottom because, unlike mcp_config_hash, it exists for entries installed
|
|
29
|
+
// before that field did (lockfile.ts: "absent for any mcp entry installed
|
|
30
|
+
// before this field existed") and it is computed HERE, above the .mcp.json
|
|
31
|
+
// read -- so it survives the case where that read throws (ahood-cli#143).
|
|
32
|
+
const hadNoOnDiskFootprint = !dirExisted && !agentExisted && hadLockfileEntry;
|
|
25
33
|
if (!dirExisted && !agentExisted && !hadLockfileEntry) {
|
|
26
34
|
console.error(`${key} was not installed -- nothing to remove.`);
|
|
27
35
|
process.exitCode = 1;
|
|
@@ -36,16 +44,32 @@ export async function remove(args) {
|
|
|
36
44
|
console.log("Aborted.");
|
|
37
45
|
return;
|
|
38
46
|
}
|
|
47
|
+
// Every artifact the pin points at is deleted BEFORE the pin itself, which
|
|
48
|
+
// is cleared exactly once at the end of this function. The two interruption
|
|
49
|
+
// states are not symmetric, which is the whole reason for the ordering: a
|
|
50
|
+
// pin left behind after its artifact is gone is a dangling reference that a
|
|
51
|
+
// re-run of this same command finds (hadLockfileEntry) and clears, while an
|
|
52
|
+
// artifact left behind after its pin is gone is invisible to list/update
|
|
53
|
+
// forever -- and for an mcp-kind install that artifact is a live .mcp.json
|
|
54
|
+
// entry holding a resolved secret (ahood-cli#142). The rule is applied
|
|
55
|
+
// uniformly rather than per-kind: the directory and agent file are lower
|
|
56
|
+
// stakes (no secret, and a re-run's own existsSync rediscovers them), but
|
|
57
|
+
// ordering them by stakes would just make the invariant harder to keep.
|
|
58
|
+
//
|
|
59
|
+
// `force: true` on both, rather than trusting the existsSync taken at the
|
|
60
|
+
// top: an unbounded confirm() prompt sits between that check and here, so
|
|
61
|
+
// the path may legitimately be gone by now. Without it, unlinkSync threw a
|
|
62
|
+
// raw ENOENT that propagated out and skipped the pin clear below, leaving
|
|
63
|
+
// the pin behind for a file that no longer existed (ahood-cli#142).
|
|
39
64
|
if (dirExisted)
|
|
40
65
|
rmSync(dir, { recursive: true, force: true });
|
|
41
66
|
if (agentExisted)
|
|
42
|
-
|
|
43
|
-
removeLockfileEntry(LOCKFILE_PATH, key);
|
|
67
|
+
rmSync(agentFile, { force: true });
|
|
44
68
|
// An mcp-kind install has no directory or agent file on disk -- its only
|
|
45
|
-
// footprint
|
|
46
|
-
// entry in .mcp.json (which add.ts's installMcpEntry merged in,
|
|
47
|
-
// holding a resolved secret in its `env`). Real removal (delete
|
|
48
|
-
// key) only happens when the on-disk entry's fingerprint
|
|
69
|
+
// footprint is the lockfile entry (cleared below, once this block has run)
|
|
70
|
+
// and a live entry in .mcp.json (which add.ts's installMcpEntry merged in,
|
|
71
|
+
// possibly holding a resolved secret in its `env`). Real removal (delete
|
|
72
|
+
// that one key) only happens when the on-disk entry's fingerprint matches
|
|
49
73
|
// what was recorded at install/update time -- same posture as add.ts's
|
|
50
74
|
// own assertNoCollision: never blind-write/delete something this process
|
|
51
75
|
// didn't verify it still owns. Without SOME check here, "Removed" would
|
|
@@ -77,9 +101,9 @@ export async function remove(args) {
|
|
|
77
101
|
// path. But leaving every flag false here meant the unreadable case
|
|
78
102
|
// printed a bare "Removed" with the entry (and its secret) still live,
|
|
79
103
|
// the very false-assurance bug the block above exists to prevent
|
|
80
|
-
// (ahood-cli#115).
|
|
81
|
-
//
|
|
82
|
-
// ahood DID install an entry into this file, so record why the read
|
|
104
|
+
// (ahood-cli#115). Both the lockfile's mcp_config_hash and
|
|
105
|
+
// hadNoOnDiskFootprint, read at the top of this function, are evidence
|
|
106
|
+
// that ahood DID install an entry into this file, so record why the read
|
|
83
107
|
// failed and warn off that instead of off a shape we can't inspect.
|
|
84
108
|
mcpConfigUnreadable = error instanceof Error ? error.message : String(error);
|
|
85
109
|
}
|
|
@@ -114,7 +138,26 @@ export async function remove(args) {
|
|
|
114
138
|
// removedMcpEntry stays false -- falls through to the warning below.
|
|
115
139
|
}
|
|
116
140
|
}
|
|
117
|
-
|
|
141
|
+
// Last, per the ordering note above: everything this pin could point at has
|
|
142
|
+
// now either been deleted or been diagnosed and warned about below, so the
|
|
143
|
+
// pin has no surviving work left to surface (ahood-cli#142).
|
|
144
|
+
removeLockfileEntry(LOCKFILE_PATH, key);
|
|
145
|
+
if (mcpConfigUnreadable !== undefined && (lockEntry?.mcp_config_hash !== undefined || hadNoOnDiskFootprint)) {
|
|
146
|
+
// Two independent pieces of evidence, because mcp_config_hash alone is
|
|
147
|
+
// not sufficient: it is absent for any mcp entry installed before that
|
|
148
|
+
// field existed, and for such a legacy pin the unreadable read also
|
|
149
|
+
// leaves mcpEntryPresent false -- so the `else if` below could not fire
|
|
150
|
+
// either and #115's fix silently did not apply, printing the bare
|
|
151
|
+
// "Removed" with the entry and its credential still live (ahood-cli#143).
|
|
152
|
+
// hadNoOnDiskFootprint closes that hole without needing the missing
|
|
153
|
+
// field. It cannot be narrowed further from the lockfile alone (LockEntry
|
|
154
|
+
// records no `kind`), so a skill whose directory was deleted by hand
|
|
155
|
+
// before this ran looks identical to a legacy mcp pin and warns too --
|
|
156
|
+
// deliberately, since the only thing that could tell them apart is the
|
|
157
|
+
// .mcp.json read that just failed, and a hedged sentence is the cheaper
|
|
158
|
+
// error of the two. A plain skill install still has its directory, so
|
|
159
|
+
// that far more common shape stays silent as ahood-cli#115 requires.
|
|
160
|
+
//
|
|
118
161
|
// Its own wording rather than the branch below's: "remove it manually"
|
|
119
162
|
// isn't actionable while the file doesn't parse, and we can't say the
|
|
120
163
|
// entry is definitely still there -- only that one was installed and
|
package/dist/commands/snap.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { apiJson } from "../http.js";
|
|
2
|
-
import { flagValue, parseSearchQuery } from "../flags.js";
|
|
2
|
+
import { flagValue, parseSearchQuery, unrecognizedArgs, unrecognizedIndices } from "../flags.js";
|
|
3
3
|
import { confirm } from "../confirm.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
-
const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin)";
|
|
5
|
+
const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin; put -- before content that starts with --)";
|
|
6
6
|
const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>] [--tags tag1,tag2]";
|
|
7
7
|
const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>] [--tags tag1,tag2]";
|
|
8
8
|
const SHOW_USAGE = "Usage: ahood snap show <id> [--json]";
|
|
9
9
|
const REMOVE_USAGE = "Usage: ahood snap remove <id> [--yes]";
|
|
10
10
|
const SHARE_USAGE = "Usage: ahood snap share <id> [--json]";
|
|
11
11
|
const UNSHARE_USAGE = "Usage: ahood snap unshare <id> [--yes]";
|
|
12
|
-
const TAGS_USAGE =
|
|
12
|
+
const TAGS_USAGE = 'Usage: ahood snap tags <id> [tag ...] [--clear] [--json] (with no tag list, prints the snap\'s current tags; pass --clear or "" to remove them all)';
|
|
13
13
|
// There's no existing "read stdin to completion" helper in this codebase to
|
|
14
14
|
// reuse -- confirm.ts and secret-prompt.ts both only ever read a single
|
|
15
15
|
// line via readline, never an arbitrary-length paste/pipe. Standard Node
|
|
@@ -60,15 +60,61 @@ function printSnaps(jsonOutput, snaps, emptyMessage) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
export async function createSnap(args) {
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// POSIX end-of-options: everything after a bare "--" is content, verbatim,
|
|
64
|
+
// never a flag. Snap content is freeform, so a note that legitimately begins
|
|
65
|
+
// with "--" would otherwise be unexpressible once the unknown-flag check
|
|
66
|
+
// below lands -- `--tags=value` is exactly that escape hatch for the flag
|
|
67
|
+
// (flags.ts), and content had none (ahood-cli#134).
|
|
68
|
+
//
|
|
69
|
+
// Scoped to `create` rather than made group-wide on purpose: it's the only
|
|
70
|
+
// snap verb whose argument is freeform text. `show`/`remove`/`share`/
|
|
71
|
+
// `unshare` take an id, `tags` a tag list and `search` a query -- all
|
|
72
|
+
// constrained enough that a leading "--" is always a mistake there, so "--"
|
|
73
|
+
// would buy them nothing -- and `search`'s parsing lives in the shared
|
|
74
|
+
// parseSearchQuery, which `skill search` also uses.
|
|
75
|
+
const endOfOptions = args.indexOf("--");
|
|
76
|
+
const flagArgs = endOfOptions === -1 ? args : args.slice(0, endOfOptions);
|
|
77
|
+
const literalArgs = endOfOptions === -1 ? [] : args.slice(endOfOptions + 1);
|
|
78
|
+
const jsonOutput = flagArgs.includes("--json");
|
|
79
|
+
const tagsArg = flagValue(flagArgs, "--tags");
|
|
80
|
+
// Whatever this command doesn't consume. --tags and its value (both
|
|
81
|
+
// "--tags x" and "--tags=x" forms) are stripped the same way --json is, so
|
|
82
|
+
// they never leak into the joined content.
|
|
83
|
+
const leftover = unrecognizedIndices(flagArgs, ["--json"], ["--tags"]);
|
|
84
|
+
// Every leftover used to be kept and folded into the note body, because
|
|
85
|
+
// content is freeform and a blanket "--" rejection would break a note that
|
|
86
|
+
// starts with a dash. That made a typo silently rewrite what got stored, at
|
|
87
|
+
// exit 0: `snap create "my note" --tag deploy` (singular) posted content
|
|
88
|
+
// "my note --tag deploy" with no tags at all, and `--limit 5` likewise
|
|
89
|
+
// (ahood-cli#134). With "--" above providing the escape hatch, refuse them
|
|
90
|
+
// instead -- the same rejection `snap tags` already does below.
|
|
91
|
+
const unknownFlag = leftover.map((i) => flagArgs[i]).find((a) => a.startsWith("--"));
|
|
92
|
+
if (unknownFlag)
|
|
93
|
+
throw new UsageError(`Unknown flag: ${unknownFlag}\n${CREATE_USAGE}`);
|
|
94
|
+
// The other half of ahood-cli#134, and the part no "--" check catches:
|
|
95
|
+
// `snap create "note" --tags deploy bugfix` posted content "note bugfix",
|
|
96
|
+
// because only the token immediately after --tags is consumed and the second
|
|
97
|
+
// word fell through into the body. A bare token is indistinguishable from a
|
|
98
|
+
// note word on its own -- `--tags deploy Debugged the CI` is a legitimate
|
|
99
|
+
// flags-first invocation -- so the signal isn't the token, it's that content
|
|
100
|
+
// ends up on BOTH sides of a flag. Nobody writes a note with a flag wedged
|
|
101
|
+
// mid-sentence; erroring there refuses the typo without outlawing either
|
|
102
|
+
// ordering. Not silently retagging "bugfix" either: guessing wrong steals a
|
|
103
|
+
// word out of the note, which is the same corruption in the other direction.
|
|
104
|
+
const gap = leftover.findIndex((at, n) => n > 0 && at !== leftover[n - 1] + 1);
|
|
105
|
+
if (gap !== -1) {
|
|
106
|
+
const stray = flagArgs[leftover[gap]];
|
|
107
|
+
const consumed = flagArgs[leftover[gap - 1] + 1];
|
|
108
|
+
throw new UsageError(`Note content is split across ${consumed}: "${stray}" comes after it and would be folded into the note. ` +
|
|
109
|
+
`Quote the whole note, or move every flag after it` +
|
|
110
|
+
(consumed === "--tags" ? `; pass multiple tags as one comma-separated value (--tags a,b)` : "") +
|
|
111
|
+
`.\n${CREATE_USAGE}`);
|
|
112
|
+
}
|
|
65
113
|
// Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
|
|
66
114
|
// snap create Debugged the flaky CI step`) arrives as multiple positional
|
|
67
115
|
// tokens, and taking only the first one silently dropped the rest with no
|
|
68
|
-
// error. Mirrors searchSnaps' own query-joining below.
|
|
69
|
-
|
|
70
|
-
// --json is, so they never leak into the joined content.
|
|
71
|
-
const positionals = args.filter((a, i) => a !== "--json" && a !== "--tags" && !a.startsWith("--tags=") && args[i - 1] !== "--tags");
|
|
116
|
+
// error. Mirrors searchSnaps' own query-joining below.
|
|
117
|
+
const positionals = [...leftover.map((i) => flagArgs[i]), ...literalArgs];
|
|
72
118
|
const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
|
|
73
119
|
let content;
|
|
74
120
|
if (positional !== undefined) {
|
|
@@ -114,6 +160,22 @@ export async function listSnaps(args) {
|
|
|
114
160
|
const jsonOutput = args.includes("--json");
|
|
115
161
|
const limitStr = flagValue(args, "--limit");
|
|
116
162
|
const tagsStr = flagValue(args, "--tags");
|
|
163
|
+
// Reject whatever this command doesn't accept instead of issuing an
|
|
164
|
+
// unfiltered request and presenting the result as the filtered set:
|
|
165
|
+
// `snap list --tag ci` (singular typo) used to send no tags param at all and
|
|
166
|
+
// print every snap, while the sibling `snap search --tag ci` refused the
|
|
167
|
+
// identical typo -- so a user filtering for "deploy" and seeing three snaps
|
|
168
|
+
// couldn't tell them from the three most recent (ahood-cli#135). That's the
|
|
169
|
+
// same silent-broadening this command's verbatim --tags pass-through below
|
|
170
|
+
// was chosen to avoid; it was defended at the small end and left open at the
|
|
171
|
+
// large one. `snap list` takes no positional argument either, and nothing
|
|
172
|
+
// upstream gives a stray token a meaning (index.ts's dispatchSnap consumes
|
|
173
|
+
// only the verb, and intercepts --help/-h before the handler runs), so an
|
|
174
|
+
// extra positional is a mistake too rather than something to ignore.
|
|
175
|
+
const extra = unrecognizedArgs(args, ["--json"], ["--limit", "--tags"])[0];
|
|
176
|
+
if (extra !== undefined) {
|
|
177
|
+
throw new UsageError(`${extra.startsWith("--") ? "Unknown flag" : "Unexpected argument"}: ${extra}\n${LIST_USAGE}`);
|
|
178
|
+
}
|
|
117
179
|
validateLimit(limitStr, LIST_USAGE);
|
|
118
180
|
const qs = new URLSearchParams();
|
|
119
181
|
if (limitStr !== undefined)
|
|
@@ -226,9 +288,38 @@ export async function unshareSnap(args) {
|
|
|
226
288
|
await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
|
|
227
289
|
console.log(`Unshared snap ${id}.`);
|
|
228
290
|
}
|
|
291
|
+
// Shared by both of tagsSnap's paths so reading a snap's tags and the echo
|
|
292
|
+
// after replacing them print the identical line -- the read is meant to be the
|
|
293
|
+
// way a user checks what a write did, which only works if the two agree.
|
|
294
|
+
//
|
|
295
|
+
// Each tag is printed quoted. Unquoted, `Tags for X: deploy bugfix` is
|
|
296
|
+
// character-for-character what ONE tag "deploy bugfix" and TWO tags "deploy"
|
|
297
|
+
// and "bugfix" both printed, so a tag list that had silently collapsed into a
|
|
298
|
+
// single multi-word tag looked exactly like the correct result and the mistake
|
|
299
|
+
// left no trace on screen (ahood-cli#137). JSON.stringify rather than manual
|
|
300
|
+
// quoting so a tag that itself contains a quote or a newline stays unambiguous.
|
|
301
|
+
function printTags(jsonOutput, id, tags, emptyMessage) {
|
|
302
|
+
// ?? [] -- see printSnaps' identical degrade-on-null guard above
|
|
303
|
+
// (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
|
|
304
|
+
// Normalized before the --json branch too, so the emitted shape is the same
|
|
305
|
+
// {id, tags: []} whether the server said [] or null.
|
|
306
|
+
const list = tags ?? [];
|
|
307
|
+
if (jsonOutput) {
|
|
308
|
+
console.log(JSON.stringify({ id, tags: list }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
console.log(list.length > 0 ? `Tags for ${id}: ${list.map((t) => JSON.stringify(t)).join(", ")}` : emptyMessage);
|
|
312
|
+
}
|
|
229
313
|
export async function tagsSnap(args) {
|
|
230
314
|
const jsonOutput = args.includes("--json");
|
|
231
|
-
const
|
|
315
|
+
const clear = args.includes("--clear");
|
|
316
|
+
// Folded onto flags.ts's shared stripping helper, which this command was the
|
|
317
|
+
// last caller still hand-rolling its own copy of (see unrecognizedArgs'
|
|
318
|
+
// comment, ahood-cli#135/#136). The `args.filter(a => a !== "--json")` it
|
|
319
|
+
// replaces knew about exactly one flag, so --clear below would have meant
|
|
320
|
+
// growing a third variant of the same rules -- the duplication that let
|
|
321
|
+
// `snap list` ship with no unknown-flag check at all.
|
|
322
|
+
const positionals = unrecognizedArgs(args, ["--json", "--clear"], []);
|
|
232
323
|
// Reject stray "--" flags rather than folding them into the tag list
|
|
233
324
|
// (parseSearchQuery in flags.ts does the same). Without this, the natural
|
|
234
325
|
// mistake `snap tags <id> --tags a,b` -- natural because `snap create`
|
|
@@ -240,34 +331,48 @@ export async function tagsSnap(args) {
|
|
|
240
331
|
const id = positionals[0];
|
|
241
332
|
if (!id)
|
|
242
333
|
throw new UsageError(TAGS_USAGE);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
334
|
+
const tagArgs = positionals.slice(1);
|
|
335
|
+
if (clear && tagArgs.length > 0) {
|
|
336
|
+
throw new UsageError(`--clear takes no tag list, but got "${tagArgs[0]}". Pass --clear to remove every tag, or the tags to set -- not both.\n${TAGS_USAGE}`);
|
|
337
|
+
}
|
|
338
|
+
// The bare `snap tags <id>` form READS instead of clearing (ahood-cli#138).
|
|
339
|
+
// It used to PATCH {"tags":[]} unconfirmed, and it is the form a user reaches
|
|
340
|
+
// for to ASK what a snap's tags are -- `git tag`, `docker tag` and `hg tags`
|
|
341
|
+
// all read on their bare form, and nothing else in this CLI showed one snap's
|
|
342
|
+
// tags (`snap show` prints content only, `snap list` truncates across all
|
|
343
|
+
// snaps), so the answer was destroyed by the act of asking. Destroying a tag
|
|
344
|
+
// set now takes saying so: --clear, or the documented explicit "" form below.
|
|
345
|
+
// That's why there's still no confirm() gate here, unlike remove/unshare --
|
|
346
|
+
// the accident it would have guarded against can no longer be typed by
|
|
347
|
+
// accident, and #114's original reasoning (metadata only, and re-running
|
|
348
|
+
// `tags` with the old set restores it exactly) holds once the user can
|
|
349
|
+
// actually find out what the old set was.
|
|
350
|
+
if (!clear && tagArgs.length === 0) {
|
|
351
|
+
const snap = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`);
|
|
352
|
+
printTags(jsonOutput, snap.id, snap.tags, `${snap.id} has no tags.`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Each positional is parsed on its own, then concatenated -- NOT joined into
|
|
356
|
+
// one string first. The join existed to rescue an unquoted `tags <id>
|
|
357
|
+
// "deploy, bugfix"`, which the shell hands over as two argv entries and which
|
|
358
|
+
// taking only positionals[1] silently truncated (ahood-cli#114). But joining
|
|
359
|
+
// cannot tell that apart from a user typing space separators, so `tags <id>
|
|
360
|
+
// deploy bugfix` became the single tag "deploy bugfix" (ahood-cli#137).
|
|
361
|
+
// Splitting per token serves both: ["deploy,", "bugfix"] and ["deploy",
|
|
362
|
+
// "bugfix"] each yield ["deploy", "bugfix"], while a tag that genuinely
|
|
363
|
+
// contains a space stays expressible by quoting it as one argument -- which
|
|
364
|
+
// treating whitespace as a separator would have taken away.
|
|
365
|
+
const tags = tagArgs.flatMap((arg) => arg
|
|
366
|
+
.split(",")
|
|
367
|
+
.map((t) => t.trim())
|
|
368
|
+
.filter(Boolean));
|
|
369
|
+
// PATCH replaces the full tag set (not a merge) -- passing --clear, or an
|
|
370
|
+
// empty string, sends [] and clears every tag, matching the "pass [] to
|
|
371
|
+
// clear" contract of the endpoint itself.
|
|
260
372
|
const updated = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, {
|
|
261
373
|
method: "PATCH",
|
|
262
374
|
headers: { "Content-Type": "application/json" },
|
|
263
375
|
body: JSON.stringify({ tags }),
|
|
264
376
|
});
|
|
265
|
-
|
|
266
|
-
console.log(JSON.stringify(updated));
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
// ?? [] -- see printSnaps' identical degrade-on-null guard above
|
|
270
|
-
// (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
|
|
271
|
-
const updatedTags = updated.tags ?? [];
|
|
272
|
-
console.log(updatedTags.length > 0 ? `Tags for ${updated.id}: ${updatedTags.join(", ")}` : `Cleared tags for ${updated.id}.`);
|
|
377
|
+
printTags(jsonOutput, updated.id, updated.tags, `Cleared tags for ${updated.id}.`);
|
|
273
378
|
}
|