@ahood/cli 0.8.5 → 0.8.7
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 +5 -3
- package/dist/commands/add.js +108 -27
- package/dist/commands/snap.js +20 -3
- package/dist/flags.js +12 -2
- package/dist/help.js +14 -9
- package/dist/lockfile.js +120 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,13 +81,15 @@ Requires Node.js 18 or later.
|
|
|
81
81
|
| `AHOOD_API_URL` | Overrides the registry endpoint (default: `https://ahood.vercel.app`). Must be `https://`, except for `localhost`/`127.0.0.1` or a `.test`/`.invalid`/`.example`/`.localhost` host, which may use plain `http://` for local development. |
|
|
82
82
|
| `XDG_CONFIG_HOME` | If set, browser-login credentials are stored under `$XDG_CONFIG_HOME/ahood/credentials.json` instead of the default `~/.config/ahood/credentials.json`. |
|
|
83
83
|
|
|
84
|
-
Paths `ahood` reads and writes in your project
|
|
84
|
+
Paths `ahood` reads and writes in your project. The lockfile is meant to be committed, same as `package-lock.json`:
|
|
85
85
|
|
|
86
86
|
| Path | What's in it |
|
|
87
87
|
| --- | --- |
|
|
88
88
|
| `.claude/skills/<owner>@<skill>/` | Installed skill files. |
|
|
89
89
|
| `.claude/agents/<owner>@<skill>.md` | An installed agent, as a single file (`@` owner/skill separator, since Claude Code's own subagent loader scans `.claude/agents/*.md` flat, non-recursively). |
|
|
90
90
|
| `.claude/skills.lock.json` | Exact installed version + checksum per skill or agent, written by `add`/`update`, read by every install to verify integrity. |
|
|
91
|
+
| `.mcp.json` | MCP server entries, for `mcp`-kind artifacts only. Merged into rather than owned — other MCP clients share this file. **May contain secrets in plaintext**, since a server manifest can declare credentials that `add` prompts for; `add` warns when it writes one. |
|
|
92
|
+
| `*.json.tmp-<pid>-<hrtime>` | Never, in normal operation. Writes to the two `.json` files above go through a temp file that is renamed into place, and only a hard kill (SIGKILL, OOM, power loss) mid-write can strand one. A stranded copy of `.mcp.json` carries the same plaintext secrets, so **gitignore this pattern**. The next successful write removes any left by a process that is no longer running. |
|
|
91
93
|
|
|
92
94
|
## Usage examples
|
|
93
95
|
|
|
@@ -244,8 +246,8 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
|
|
|
244
246
|
| Command | What it does |
|
|
245
247
|
| --- | --- |
|
|
246
248
|
| `ahood snap create <content> [--tags tag1,tag2] [--json]` | Capture a new private snap, from an argument or piped stdin. |
|
|
247
|
-
| `ahood snap list [--json] [--limit <n>]` | List your own snaps, most recent first. |
|
|
248
|
-
| `ahood snap search <query> [--json] [--limit <n>]` | Search your own snaps by content. |
|
|
249
|
+
| `ahood snap list [--json] [--limit <n>] [--tags tag1,tag2]` | List your own snaps, most recent first. |
|
|
250
|
+
| `ahood snap search <query> [--json] [--limit <n>] [--tags tag1,tag2]` | Search your own snaps by content. |
|
|
249
251
|
| `ahood snap show <id> [--json]` | Print a single snap's full content. |
|
|
250
252
|
| `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
|
|
251
253
|
| `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
|
package/dist/commands/add.js
CHANGED
|
@@ -114,7 +114,7 @@ export async function extractTarGz(buffer, destDir) {
|
|
|
114
114
|
const fullPath = resolve(destDir, entryPath);
|
|
115
115
|
if (fullPath !== resolvedDest && !fullPath.startsWith(resolvedDest + sep)) {
|
|
116
116
|
stream.resume();
|
|
117
|
-
reject(new Error(`Refusing to extract unsafe archive entry: ${header.name}`));
|
|
117
|
+
reject(new Error(`Refusing to extract unsafe archive entry: ${sanitizeForTerminal(header.name)}`));
|
|
118
118
|
// next() is deliberately NOT called -- nothing further in this archive
|
|
119
119
|
// should be processed. destroy() tears the paused extractor down
|
|
120
120
|
// rather than leaving it stalled mid-entry.
|
|
@@ -237,17 +237,24 @@ export async function extractSingleFileContent(buffer, entryName) {
|
|
|
237
237
|
throw new Error(`${entryName} not found in the downloaded archive`);
|
|
238
238
|
return found;
|
|
239
239
|
}
|
|
240
|
-
// Every manifest field used below
|
|
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
|
|
241
242
|
// -- worse -- the text printed immediately before a masked secret prompt)
|
|
242
|
-
// but comes verbatim from a third-party-published
|
|
243
|
+
// but comes verbatim from a third-party-published archive: server-side
|
|
243
244
|
// validation only checks these are strings, not that they're free of
|
|
244
245
|
// control/escape characters. A malicious description could otherwise smuggle
|
|
245
246
|
// a terminal control sequence (cursor movement, line-clear) that repaints
|
|
246
247
|
// what the user sees at the exact moment they're about to type a credential.
|
|
247
248
|
// Strips C0/C1 control characters (including ESC, \x1b) and caps length so a
|
|
248
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).
|
|
249
256
|
function sanitizeForTerminal(text) {
|
|
250
|
-
return text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
|
|
257
|
+
return String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
|
|
251
258
|
}
|
|
252
259
|
// Only npm+npx is supported in v1 (docs/superpowers/specs/2026-09-03-mcp-server-artifacts-design.md) --
|
|
253
260
|
// server-side publish validation (lib/publish/parse-server-manifest.ts) already
|
|
@@ -260,7 +267,7 @@ function buildMcpServerConfig(manifest, env) {
|
|
|
260
267
|
if (manifest.packages && manifest.packages.length === 1) {
|
|
261
268
|
const pkg = manifest.packages[0];
|
|
262
269
|
if (pkg.registry_type !== "npm" || pkg.runtime_hint !== "npx") {
|
|
263
|
-
throw new Error(`${manifestName}'s server.json uses registry_type "${pkg.registry_type}"/runtime_hint "${pkg.runtime_hint}", which this version of ahood does not know how to install (only npm+npx is supported).`);
|
|
270
|
+
throw new Error(`${manifestName}'s server.json uses registry_type "${sanitizeForTerminal(pkg.registry_type)}"/runtime_hint "${sanitizeForTerminal(pkg.runtime_hint)}", which this version of ahood does not know how to install (only npm+npx is supported).`);
|
|
264
271
|
}
|
|
265
272
|
const config = { command: "npx", args: ["-y", `${pkg.identifier}@${pkg.version}`] };
|
|
266
273
|
if (Object.keys(env).length > 0)
|
|
@@ -379,8 +386,8 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
379
386
|
`Remove it manually first if you want to reinstall ${owner}/${skill}.`);
|
|
380
387
|
}
|
|
381
388
|
}
|
|
382
|
-
// Extracts server.json, validates its shape, and resolves every
|
|
383
|
-
// environment variable it declares (prompting only for
|
|
389
|
+
// Extracts server.json, validates its shape, and resolves every
|
|
390
|
+
// environment variable it declares (prompting only for secrets not already
|
|
384
391
|
// set in the shell) -- the part of installing an mcp entry that's identical
|
|
385
392
|
// whether this is a fresh `add` or an `update` moving an existing pin
|
|
386
393
|
// forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
|
|
@@ -401,10 +408,38 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
401
408
|
// timeout -- against this CLI's "must never hang" rule -- or, worse, silently
|
|
402
409
|
// bank the first line of unrelated piped input (`echo y | ahood skill
|
|
403
410
|
// update`) as the credential and fingerprint over it.
|
|
411
|
+
//
|
|
412
|
+
// `secretNames` comes back alongside the config because `env` also carries
|
|
413
|
+
// non-secret configuration now (ahood-cli#120): callers that need to know
|
|
414
|
+
// whether what they just wrote to disk is actually a credential can no longer
|
|
415
|
+
// infer it from `env` being non-empty.
|
|
404
416
|
export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
405
417
|
const { existingEnv, allowNonTtyPrompt = true } = options;
|
|
406
418
|
const content = await extractSingleFileContent(buffer, "server.json");
|
|
407
|
-
|
|
419
|
+
// Guarded the same way readMcpConfig guards .mcp.json: an unguarded parse
|
|
420
|
+
// surfaced a raw V8 SyntaxError with a stack instead of this CLI's usual
|
|
421
|
+
// single-line error (ahood-cli#121). The parser's own message is dropped
|
|
422
|
+
// rather than run through sanitizeForTerminal, because V8 quotes a snippet
|
|
423
|
+
// of the offending source in it -- that source is third-party archive
|
|
424
|
+
// content, and sanitizing would still paste it into the terminal, just
|
|
425
|
+
// with its control characters flattened.
|
|
426
|
+
let parsed;
|
|
427
|
+
try {
|
|
428
|
+
parsed = JSON.parse(content.toString("utf-8"));
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
throw new Error("The downloaded archive's server.json is not valid JSON -- this published version is malformed and cannot be installed.");
|
|
432
|
+
}
|
|
433
|
+
// Checked before the cast because buildMcpServerConfig immediately reads
|
|
434
|
+
// properties off the result: valid JSON that isn't an object (`[]`, `"x"`,
|
|
435
|
+
// `3`, `null`) would otherwise reach it and surface as an unactionable raw
|
|
436
|
+
// TypeError (ahood-cli#121). Deliberately stops at "is it an object" -- the
|
|
437
|
+
// manifest's actual shape is buildMcpServerConfig's job just below, and a
|
|
438
|
+
// second implementation of that check here would be free to drift from it.
|
|
439
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
440
|
+
throw new Error("The downloaded archive's server.json is valid JSON but its top level is not an object -- this published version is malformed and cannot be installed.");
|
|
441
|
+
}
|
|
442
|
+
const manifest = parsed;
|
|
408
443
|
// Validate the manifest's shape (unsupported registry_type/runtime_hint,
|
|
409
444
|
// or neither a single 'packages' nor a single 'remotes' entry) BEFORE
|
|
410
445
|
// prompting for any secret -- an install/update that's going to be
|
|
@@ -414,10 +449,46 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
|
414
449
|
// secretEnv is known.
|
|
415
450
|
buildMcpServerConfig(manifest, {});
|
|
416
451
|
const envVars = manifest.packages?.[0]?.environment_variables ?? [];
|
|
417
|
-
const
|
|
452
|
+
const env = {};
|
|
453
|
+
// Non-secret variables used to be skipped outright, so a server declaring
|
|
454
|
+
// one installed into a config that could not start it, with no diagnostic
|
|
455
|
+
// (ahood-cli#120). They're configuration, not credentials: read them from
|
|
456
|
+
// the shell, but never prompt -- a prompt here would reintroduce exactly
|
|
457
|
+
// the unattended-use hazard ahood-cli#169/0.8.2 removed from the update
|
|
458
|
+
// path, and there is nothing to mask anyway.
|
|
459
|
+
//
|
|
460
|
+
// Resolved in a first pass, ahead of any masked prompt below, for the same
|
|
461
|
+
// reason buildMcpServerConfig is dry-run above and assertNoCollision runs
|
|
462
|
+
// before installMcpEntry's resolve: an install that's going to be refused
|
|
463
|
+
// for a missing required variable must not first cost the user a secret
|
|
464
|
+
// prompt.
|
|
465
|
+
for (const variable of envVars) {
|
|
466
|
+
if (variable.is_secret)
|
|
467
|
+
continue;
|
|
468
|
+
// Same truthiness rule as the secret branch below: an empty-string env
|
|
469
|
+
// var reads as "not set" rather than installing an empty value.
|
|
470
|
+
const fromEnv = process.env[variable.name];
|
|
471
|
+
if (fromEnv) {
|
|
472
|
+
env[variable.name] = fromEnv;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// `is_required` was parsed and validated but never consulted at install
|
|
476
|
+
// time (ahood-cli#120). Optional means optional -- install without it.
|
|
477
|
+
// Required means the server cannot start without it, so refusing beats
|
|
478
|
+
// writing a config that is broken in a way nothing reports.
|
|
479
|
+
if (variable.is_required) {
|
|
480
|
+
throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Required-ness needs no separate check on the secret path: every branch
|
|
484
|
+
// below either resolves a value or throws, so a required secret with
|
|
485
|
+
// nothing to fall back on already fails loudly (the non-TTY guard) or is
|
|
486
|
+
// supplied at the prompt (ahood-cli#120).
|
|
487
|
+
const secretNames = [];
|
|
418
488
|
for (const variable of envVars) {
|
|
419
489
|
if (!variable.is_secret)
|
|
420
490
|
continue;
|
|
491
|
+
secretNames.push(variable.name);
|
|
421
492
|
const fromEnv = process.env[variable.name];
|
|
422
493
|
// Truthiness, not `!== undefined` -- matches credentials.ts's
|
|
423
494
|
// resolveToken(), the precedent this feature was explicitly modeled on
|
|
@@ -425,24 +496,41 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
|
425
496
|
// specifically so an empty-string env var falls through to the real
|
|
426
497
|
// source instead of silently installing an empty secret.
|
|
427
498
|
if (fromEnv) {
|
|
428
|
-
|
|
499
|
+
env[variable.name] = fromEnv;
|
|
429
500
|
continue;
|
|
430
501
|
}
|
|
431
502
|
// Ordered after the env var deliberately: exporting the variable stays
|
|
432
503
|
// the way to rotate a credential during an update, rather than being
|
|
433
|
-
// shadowed by the stale value already on disk.
|
|
504
|
+
// shadowed by the stale value already on disk. Deliberately not extended
|
|
505
|
+
// to non-secret variables (ahood-cli#120): carrying a value forward from
|
|
506
|
+
// .mcp.json is credential-preservation machinery, and a non-secret is
|
|
507
|
+
// plain configuration that the shell should stay authoritative for.
|
|
434
508
|
const carriedForward = existingEnv?.[variable.name];
|
|
435
509
|
if (carriedForward) {
|
|
436
|
-
|
|
510
|
+
env[variable.name] = carriedForward;
|
|
437
511
|
continue;
|
|
438
512
|
}
|
|
439
513
|
if (!allowNonTtyPrompt && !process.stdin.isTTY) {
|
|
440
514
|
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.`);
|
|
441
515
|
}
|
|
442
|
-
|
|
516
|
+
env[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
|
|
443
517
|
}
|
|
444
|
-
const serverConfig = buildMcpServerConfig(manifest,
|
|
445
|
-
return { manifest, serverConfig };
|
|
518
|
+
const serverConfig = buildMcpServerConfig(manifest, env);
|
|
519
|
+
return { manifest, serverConfig, secretNames };
|
|
520
|
+
}
|
|
521
|
+
// The plaintext-secret warning printed after a successful mcp install/update.
|
|
522
|
+
// Still derived from the config that actually landed on disk -- the property
|
|
523
|
+
// the old bare `serverConfig.env` check was chosen for -- but intersected with
|
|
524
|
+
// the manifest's declared secrets, because `env` now also holds non-secret
|
|
525
|
+
// configuration (ahood-cli#120) and would otherwise warn about credentials
|
|
526
|
+
// for a server that declares none.
|
|
527
|
+
function warnIfSecretsWereWritten(serverConfig, secretNames) {
|
|
528
|
+
const env = serverConfig.env;
|
|
529
|
+
if (!env)
|
|
530
|
+
return;
|
|
531
|
+
if (!secretNames.some((name) => Object.prototype.hasOwnProperty.call(env, name)))
|
|
532
|
+
return;
|
|
533
|
+
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
446
534
|
}
|
|
447
535
|
// Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
|
|
448
536
|
// entry, stored in the lockfile alongside the version/checksum pin. Lets
|
|
@@ -520,7 +608,7 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
520
608
|
// something that's going to be refused anyway" reasoning, just for the
|
|
521
609
|
// collision check specifically rather than the manifest-shape check.
|
|
522
610
|
assertNoCollision(readMcpConfig(), owner, skill);
|
|
523
|
-
const { serverConfig } = await resolveMcpServerConfig(buffer);
|
|
611
|
+
const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer);
|
|
524
612
|
// Read-modify-write under an advisory lock, mirroring lockfile.ts's own
|
|
525
613
|
// writeLockfileEntry: .mcp.json sits right next to the lockfile and can
|
|
526
614
|
// hold other servers' secrets, so it gets the same protection against a
|
|
@@ -558,13 +646,8 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
558
646
|
}
|
|
559
647
|
console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
|
|
560
648
|
// Only the npm+npx package path ever carries secrets into `env` (headers
|
|
561
|
-
// on a remote entry are static per v1's scope, per buildMcpServerConfig)
|
|
562
|
-
|
|
563
|
-
// secretEnv out of resolveMcpServerConfig separately) means this warning
|
|
564
|
-
// can never drift from what was actually written to disk.
|
|
565
|
-
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
566
|
-
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
567
|
-
}
|
|
649
|
+
// on a remote entry are static per v1's scope, per buildMcpServerConfig).
|
|
650
|
+
warnIfSecretsWereWritten(serverConfig, secretNames);
|
|
568
651
|
}
|
|
569
652
|
// Re-runs the mcp install flow for an already-installed entry, moving its
|
|
570
653
|
// lockfile pin forward -- update.ts's real mcp-update path (ahood-cli#169).
|
|
@@ -610,7 +693,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
610
693
|
// (no entry on disk at all) there's nothing to carry forward and a genuine
|
|
611
694
|
// prompt is correct.
|
|
612
695
|
const existingEnv = readMcpEntryEnv(existingOnDisk);
|
|
613
|
-
const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
|
|
696
|
+
const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
|
|
614
697
|
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
615
698
|
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
616
699
|
// arbitrary time may have passed since the pre-check -- the download, and
|
|
@@ -675,9 +758,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
675
758
|
throw error;
|
|
676
759
|
}
|
|
677
760
|
console.log(`Updated ${key} to ${meta.version}`);
|
|
678
|
-
|
|
679
|
-
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
680
|
-
}
|
|
761
|
+
warnIfSecretsWereWritten(serverConfig, secretNames);
|
|
681
762
|
}
|
|
682
763
|
// Downloads a specific version's archive and verifies it against the
|
|
683
764
|
// checksum in `meta`, WITHOUT extracting anything -- the shared first half
|
package/dist/commands/snap.js
CHANGED
|
@@ -3,8 +3,8 @@ import { flagValue, parseSearchQuery } from "../flags.js";
|
|
|
3
3
|
import { confirm } from "../confirm.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
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>]";
|
|
6
|
+
const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>] [--tags tag1,tag2]";
|
|
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]";
|
|
@@ -113,10 +113,19 @@ export async function createSnap(args) {
|
|
|
113
113
|
export async function listSnaps(args) {
|
|
114
114
|
const jsonOutput = args.includes("--json");
|
|
115
115
|
const limitStr = flagValue(args, "--limit");
|
|
116
|
+
const tagsStr = flagValue(args, "--tags");
|
|
116
117
|
validateLimit(limitStr, LIST_USAGE);
|
|
117
118
|
const qs = new URLSearchParams();
|
|
118
119
|
if (limitStr !== undefined)
|
|
119
120
|
qs.set("limit", limitStr);
|
|
121
|
+
// Passed through verbatim rather than split/trimmed the way createSnap's
|
|
122
|
+
// own --tags is: the server owns what a tag filter means (comma-separated,
|
|
123
|
+
// ANDed, case-insensitive, 400 past 8 terms) and deliberately keeps an
|
|
124
|
+
// unstorable term instead of dropping it, since dropping one would silently
|
|
125
|
+
// broaden the result set. Re-parsing here would be a second implementation
|
|
126
|
+
// of that contract, free to drift from it (ahood-cli#118).
|
|
127
|
+
if (tagsStr !== undefined)
|
|
128
|
+
qs.set("tags", tagsStr);
|
|
120
129
|
const query = qs.toString();
|
|
121
130
|
const { snaps } = await apiJson(`/api/v1/snaps${query ? `?${query}` : ""}`);
|
|
122
131
|
// ?? [], not a bare destructure -- a degraded response (snaps: null)
|
|
@@ -127,11 +136,19 @@ export async function listSnaps(args) {
|
|
|
127
136
|
export async function searchSnaps(args) {
|
|
128
137
|
const jsonOutput = args.includes("--json");
|
|
129
138
|
const limitStr = flagValue(args, "--limit");
|
|
130
|
-
const
|
|
139
|
+
const tagsStr = flagValue(args, "--tags");
|
|
140
|
+
// --tags declared here so its VALUE isn't swallowed into the joined query
|
|
141
|
+
// string -- `snap search deploy --tags ci` must search for "deploy" filtered
|
|
142
|
+
// to tag "ci", not for "deploy ci" (ahood-cli#118).
|
|
143
|
+
const query = parseSearchQuery(args, SEARCH_USAGE, ["--tags"]);
|
|
131
144
|
validateLimit(limitStr, SEARCH_USAGE);
|
|
132
145
|
const qs = new URLSearchParams({ q: query });
|
|
133
146
|
if (limitStr !== undefined)
|
|
134
147
|
qs.set("limit", limitStr);
|
|
148
|
+
// Verbatim -- see listSnaps' identical pass-through above (ahood-cli#118).
|
|
149
|
+
// The server ANDs a tag filter with `q`, so this narrows the text results.
|
|
150
|
+
if (tagsStr !== undefined)
|
|
151
|
+
qs.set("tags", tagsStr);
|
|
135
152
|
const { snaps } = await apiJson(`/api/v1/snaps?${qs}`);
|
|
136
153
|
// ?? [] -- see listSnaps' identical guard above (ahood-cli#106).
|
|
137
154
|
printSnaps(jsonOutput, snaps ?? [], "No snaps found.");
|
package/dist/flags.js
CHANGED
|
@@ -36,8 +36,18 @@ export function flagValue(args, flag) {
|
|
|
36
36
|
// --limit (both "--limit N" and "--limit=N" forms) from the positional
|
|
37
37
|
// args, rejects any other unrecognized "--" flag, and joins what's left
|
|
38
38
|
// into a single query string.
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// `valueFlags` names ADDITIONAL "--flag value" flags this particular command
|
|
40
|
+
// accepts (snap search's --tags, ahood-cli#118), on top of --limit, which
|
|
41
|
+
// every caller takes. Passed per-caller rather than stripping a shared union
|
|
42
|
+
// of every search-ish flag, so `ahood skill search --tags x` still errors on
|
|
43
|
+
// a flag that command doesn't implement instead of silently ignoring it and
|
|
44
|
+
// returning unfiltered results.
|
|
45
|
+
export function parseSearchQuery(args, usage, valueFlags = []) {
|
|
46
|
+
const stripped = ["--limit", ...valueFlags];
|
|
47
|
+
const queryParts = args.filter((a, i) => a !== "--json" &&
|
|
48
|
+
!stripped.includes(a) &&
|
|
49
|
+
!stripped.some((f) => a.startsWith(`${f}=`)) &&
|
|
50
|
+
!stripped.includes(args[i - 1]));
|
|
41
51
|
const unknownFlag = queryParts.find((a) => a.startsWith("--"));
|
|
42
52
|
if (unknownFlag)
|
|
43
53
|
throw new UsageError(`Unknown flag: ${unknownFlag}\n${usage}`);
|
package/dist/help.js
CHANGED
|
@@ -294,24 +294,29 @@ export const SNAP_COMMANDS_HELP = [
|
|
|
294
294
|
],
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
|
-
usage: "ahood snap list [--json] [--limit <n>]",
|
|
297
|
+
usage: "ahood snap list [--json] [--limit <n>] [--tags tag1,tag2]",
|
|
298
298
|
summary: "List your own snaps, most recent first.",
|
|
299
299
|
desc: "List your own snaps: id, a truncated content preview, created_at, a (shared) marker if shared, and " +
|
|
300
|
-
"any tags in brackets."
|
|
300
|
+
"any tags in brackets. --tags narrows the list to snaps carrying every tag given (not any of them), " +
|
|
301
|
+
"matched case-insensitively.",
|
|
301
302
|
flags: [
|
|
302
|
-
"--json
|
|
303
|
-
"--limit <n>
|
|
303
|
+
"--json Emit the raw snap objects instead of formatted lines.",
|
|
304
|
+
"--limit <n> Cap the number of results.",
|
|
305
|
+
"--tags tag1,tag2 Only snaps carrying ALL of these tags. Case-insensitive; at most 8.",
|
|
304
306
|
],
|
|
307
|
+
examples: ["ahood snap list --tags deploy", "ahood snap list --tags deploy,bugfix --limit 5"],
|
|
305
308
|
},
|
|
306
309
|
{
|
|
307
|
-
usage: "ahood snap search <query> [--json] [--limit <n>]",
|
|
310
|
+
usage: "ahood snap search <query> [--json] [--limit <n>] [--tags tag1,tag2]",
|
|
308
311
|
summary: "Search your own snaps by content.",
|
|
309
|
-
desc: "Search your own snaps by content. Same output shape as `ahood snap list`."
|
|
312
|
+
desc: "Search your own snaps by content. Same output shape as `ahood snap list`. The query matches snap " +
|
|
313
|
+
"CONTENT only and never a tag -- use --tags to filter by tag, which narrows the text results further.",
|
|
310
314
|
flags: [
|
|
311
|
-
"--json
|
|
312
|
-
"--limit <n>
|
|
315
|
+
"--json Emit the raw snap objects instead of formatted lines.",
|
|
316
|
+
"--limit <n> Cap the number of results.",
|
|
317
|
+
"--tags tag1,tag2 Only snaps carrying ALL of these tags. Case-insensitive; at most 8.",
|
|
313
318
|
],
|
|
314
|
-
examples: ["ahood snap search flaky-ci"],
|
|
319
|
+
examples: ["ahood snap search flaky-ci", "ahood snap search deploy --tags ci"],
|
|
315
320
|
},
|
|
316
321
|
{
|
|
317
322
|
usage: "ahood snap show <id> [--json]",
|
package/dist/lockfile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
3
|
export function readLockfile(path) {
|
|
4
4
|
if (!existsSync(path))
|
|
5
5
|
return {};
|
|
@@ -15,6 +15,63 @@ export function readLockfile(path) {
|
|
|
15
15
|
throw new Error(`Lockfile at ${path} is corrupted and could not be parsed as JSON. Fix or delete it before continuing.`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// Signal 0 doesn't deliver anything, it just probes whether the process could
|
|
19
|
+
// be signaled. ESRCH means it's gone; EPERM means it's alive but owned by
|
|
20
|
+
// someone else, which is conservatively treated as still alive since that
|
|
21
|
+
// can't be disproven. Shared by the stale-lock reclaim (ahood-cli#100) and the
|
|
22
|
+
// stale-temp sweep (ahood-cli#125) so both answer "is the process that left
|
|
23
|
+
// this behind still around?" the same way, rather than growing two divergent
|
|
24
|
+
// staleness rules.
|
|
25
|
+
function isPidDead(pid) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return error.code === "ESRCH";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Exactly what the temp name below produces: a decimal pid and a decimal
|
|
35
|
+
// hrtime, nothing else. Anything the user merely happened to name
|
|
36
|
+
// `<basename>.tmp-something` fails this and is left alone -- deleting a file
|
|
37
|
+
// that was never ours is far worse than leaving a stale temp file behind
|
|
38
|
+
// (ahood-cli#125).
|
|
39
|
+
const TEMP_SUFFIX_RE = /^([1-9][0-9]{0,9})-[0-9]{1,25}$/;
|
|
40
|
+
// The finally in writeJsonFileAtomic can't run on SIGKILL, an OOM kill or
|
|
41
|
+
// power loss, so a temp file can still be orphaned in the window between the
|
|
42
|
+
// write and the rename -- and for .mcp.json that orphan holds resolved MCP
|
|
43
|
+
// server secrets under a name nothing else ever removes (ahood-cli#125). The
|
|
44
|
+
// next successful write to the same destination collects them.
|
|
45
|
+
//
|
|
46
|
+
// withLock serializes the writers this CLI itself starts, but a temp file is
|
|
47
|
+
// only provably collectable once the process that created it is gone, so the
|
|
48
|
+
// pid embedded in the name is checked rather than trusting the lock alone. A
|
|
49
|
+
// pid recycled since the last boot can only make a dead writer look alive,
|
|
50
|
+
// which just defers the cleanup; it can never make a live writer look dead, so
|
|
51
|
+
// this never deletes a file still being written.
|
|
52
|
+
function sweepStaleTempFiles(path, ownTempName) {
|
|
53
|
+
const dir = dirname(path);
|
|
54
|
+
const prefix = `${basename(path)}.tmp-`;
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
if (!entry.isFile())
|
|
57
|
+
continue;
|
|
58
|
+
if (entry.name === ownTempName)
|
|
59
|
+
continue;
|
|
60
|
+
if (!entry.name.startsWith(prefix))
|
|
61
|
+
continue;
|
|
62
|
+
const match = TEMP_SUFFIX_RE.exec(entry.name.slice(prefix.length));
|
|
63
|
+
if (!match)
|
|
64
|
+
continue;
|
|
65
|
+
if (!isPidDead(Number(match[1])))
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
rmSync(join(dir, entry.name), { force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// One unremovable orphan must not stop the rest of the sweep.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
18
75
|
// Generalized so callers other than the lockfile itself (e.g. add.ts's
|
|
19
76
|
// .mcp.json merge, which sits right next to the lockfile and can hold other
|
|
20
77
|
// servers' secrets) get the same crash-safety guarantee instead of a bare
|
|
@@ -25,22 +82,70 @@ export function writeJsonFileAtomic(path, data) {
|
|
|
25
82
|
// truncated file on disk -- a reader always sees either the old or the new
|
|
26
83
|
// complete content, never a partial one.
|
|
27
84
|
const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
|
|
28
|
-
|
|
29
|
-
|
|
85
|
+
// The mode the destination must end up with. Preserving an existing file's
|
|
86
|
+
// permissions matters because .mcp.json is a project-shared config other MCP
|
|
87
|
+
// clients read: whether it should be 0644 or tighter is its own decision
|
|
88
|
+
// (ahood#169), not something this write path gets to change as a side effect
|
|
89
|
+
// of the 0600 temp file below (ahood-cli#119). With no destination yet there
|
|
90
|
+
// is nothing to preserve, so fall back to what a plain writeFileSync would
|
|
91
|
+
// have produced -- 0666 masked by the process umask.
|
|
92
|
+
let finalMode;
|
|
93
|
+
try {
|
|
94
|
+
finalMode = statSync(path).mode & 0o777;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
finalMode = 0o666 & ~process.umask();
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
// 0600 from the moment of creation: for .mcp.json this temp file holds
|
|
101
|
+
// resolved MCP server secrets, and it must never be world-readable, not
|
|
102
|
+
// even for the instant before the rename (ahood-cli#119).
|
|
103
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
104
|
+
renameSync(tmpPath, path);
|
|
105
|
+
try {
|
|
106
|
+
chmodSync(path, finalMode);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Best-effort, and deliberately after the rename rather than on the temp
|
|
110
|
+
// file: widening the temp file first would reopen the world-readable
|
|
111
|
+
// window this fix closes. A failure here leaves the file MORE restrictive
|
|
112
|
+
// than intended, never less, so it isn't worth failing a write that has
|
|
113
|
+
// already landed -- the caller would roll back a successful install.
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
sweepStaleTempFiles(path, basename(tmpPath));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Swallowed for the same reason as the chmod above and #119's unlink:
|
|
120
|
+
// the rename has already landed, so failing to tidy up somebody else's
|
|
121
|
+
// abandoned temp file must never turn a successful write into an error
|
|
122
|
+
// the caller rolls back (ahood-cli#125).
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
// The rename consumes the temp file on success; this only bites when the
|
|
127
|
+
// write or the rename threw, where an orphaned *.tmp-* copy of .mcp.json
|
|
128
|
+
// would strand plaintext secrets under a name no .gitignore rule matches
|
|
129
|
+
// (ahood-cli#119). Swallowed because it must never mask the real reason
|
|
130
|
+
// the write failed -- that error is what the caller has to report.
|
|
131
|
+
try {
|
|
132
|
+
rmSync(tmpPath, { force: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// best effort, see comment above
|
|
136
|
+
}
|
|
137
|
+
}
|
|
30
138
|
}
|
|
31
139
|
function writeLockfile(path, lockfile) {
|
|
32
140
|
writeJsonFileAtomic(path, lockfile);
|
|
33
141
|
}
|
|
34
142
|
// A lock directory is stale if the PID recorded inside it (written by the
|
|
35
|
-
// acquirer below) belongs to a process that's no longer running
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// process is still mid-way through acquiring (mkdirSync succeeded, the pid
|
|
42
|
-
// write hasn't landed yet) -- safer to wait it out than to reclaim a lock
|
|
43
|
-
// that's actually still being set up.
|
|
143
|
+
// acquirer below) belongs to a process that's no longer running, per
|
|
144
|
+
// isPidDead above. A missing/unparseable pid file is treated as "not stale"
|
|
145
|
+
// -- either a lock from before this file existed, or another process is
|
|
146
|
+
// still mid-way through acquiring (mkdirSync succeeded, the pid write hasn't
|
|
147
|
+
// landed yet) -- safer to wait it out than to reclaim a lock that's actually
|
|
148
|
+
// still being set up.
|
|
44
149
|
function isLockStale(pidFile) {
|
|
45
150
|
let pidText;
|
|
46
151
|
try {
|
|
@@ -52,13 +157,7 @@ function isLockStale(pidFile) {
|
|
|
52
157
|
const pid = Number(pidText);
|
|
53
158
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
54
159
|
return false;
|
|
55
|
-
|
|
56
|
-
process.kill(pid, 0);
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
return error.code === "ESRCH";
|
|
61
|
-
}
|
|
160
|
+
return isPidDead(pid);
|
|
62
161
|
}
|
|
63
162
|
// Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
|
|
64
163
|
// so two concurrent `ahood skill add`/`remove` invocations against the same
|
|
@@ -109,7 +208,7 @@ export function withLock(path, fn) {
|
|
|
109
208
|
continue;
|
|
110
209
|
}
|
|
111
210
|
if (Date.now() > deadline) {
|
|
112
|
-
throw new Error(`Timed out waiting for the
|
|
211
|
+
throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
|
|
113
212
|
}
|
|
114
213
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
115
214
|
}
|
package/package.json
CHANGED