@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 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, none of which need to be gitignored (the lockfile is meant to be committed, same as `package-lock.json`):
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. |
@@ -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 reaches the terminal (an error message, or
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 server.json: server-side
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 secret
383
- // environment variable it declares (prompting only for ones not already
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
- const manifest = JSON.parse(content.toString("utf-8"));
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 secretEnv = {};
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
- secretEnv[variable.name] = fromEnv;
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
- secretEnv[variable.name] = carriedForward;
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
- secretEnv[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
516
+ env[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
443
517
  }
444
- const serverConfig = buildMcpServerConfig(manifest, secretEnv);
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
- // checking the final serverConfig's own `env` key (rather than threading
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
- if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
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
@@ -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 query = parseSearchQuery(args, SEARCH_USAGE);
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
- export function parseSearchQuery(args, usage) {
40
- const queryParts = args.filter((a, i) => a !== "--json" && a !== "--limit" && !a.startsWith("--limit=") && args[i - 1] !== "--limit");
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 Emit the raw snap objects instead of formatted lines.",
303
- "--limit <n> Cap the number of results.",
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 Emit the raw snap objects instead of formatted lines.",
312
- "--limit <n> Cap the number of results.",
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
- writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
29
- renameSync(tmpPath, path);
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 -- signal 0
36
- // doesn't actually deliver a signal, just probes whether the process could
37
- // be signaled. ESRCH means it's dead; EPERM means it's alive but owned by
38
- // someone else, which is conservatively treated as still alive since that
39
- // can't be disproven. A missing/unparseable pid file is also treated as
40
- // "not stale" -- either a lock from before this file existed, or another
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
- try {
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 lockfile lock at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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",