@ahood/cli 0.8.1 → 0.8.2

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.
@@ -370,7 +370,23 @@ function assertNoCollision(fileContents, owner, skill) {
370
370
  // forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
371
371
  // re-resolves secrets the same way a fresh install does, instead of
372
372
  // duplicating this parsing/prompting logic.
373
- export async function resolveMcpServerConfig(buffer) {
373
+ // `existingEnv` carries forward the secret values already sitting in the
374
+ // entry being updated, so a routine `ahood skill update` doesn't re-prompt
375
+ // for a credential the user has already supplied. Only ever passed by
376
+ // updateMcpEntry, and only from an entry whose fingerprint it has just
377
+ // verified -- i.e. proven to be ahood's own unmodified entry, not something
378
+ // hand-edited or written by another tool.
379
+ //
380
+ // `allowNonTtyPrompt` (default true, preserving `echo secret | ahood skill
381
+ // add ...` for a fresh install the user explicitly asked for) is false on
382
+ // the update path: `ahood skill update` with no arguments walks EVERY locked
383
+ // entry, so a prompt there is never something the caller aimed at. Left
384
+ // interactive, an unattended update would hang on an idle pipe with no
385
+ // timeout -- against this CLI's "must never hang" rule -- or, worse, silently
386
+ // bank the first line of unrelated piped input (`echo y | ahood skill
387
+ // update`) as the credential and fingerprint over it.
388
+ export async function resolveMcpServerConfig(buffer, options = {}) {
389
+ const { existingEnv, allowNonTtyPrompt = true } = options;
374
390
  const content = await extractSingleFileContent(buffer, "server.json");
375
391
  const manifest = JSON.parse(content.toString("utf-8"));
376
392
  // Validate the manifest's shape (unsupported registry_type/runtime_hint,
@@ -392,9 +408,22 @@ export async function resolveMcpServerConfig(buffer) {
392
408
  // (per the design spec), which uses `if (process.env.AHOOD_TOKEN)`
393
409
  // specifically so an empty-string env var falls through to the real
394
410
  // source instead of silently installing an empty secret.
395
- secretEnv[variable.name] = fromEnv
396
- ? fromEnv
397
- : await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
411
+ if (fromEnv) {
412
+ secretEnv[variable.name] = fromEnv;
413
+ continue;
414
+ }
415
+ // Ordered after the env var deliberately: exporting the variable stays
416
+ // the way to rotate a credential during an update, rather than being
417
+ // shadowed by the stale value already on disk.
418
+ const carriedForward = existingEnv?.[variable.name];
419
+ if (carriedForward) {
420
+ secretEnv[variable.name] = carriedForward;
421
+ continue;
422
+ }
423
+ if (!allowNonTtyPrompt && !process.stdin.isTTY) {
424
+ 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.`);
425
+ }
426
+ secretEnv[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
398
427
  }
399
428
  const serverConfig = buildMcpServerConfig(manifest, secretEnv);
400
429
  return { manifest, serverConfig };
@@ -413,6 +442,25 @@ export async function resolveMcpServerConfig(buffer) {
413
442
  export function hashMcpServerConfig(config) {
414
443
  return createHash("sha256").update(JSON.stringify(config)).digest("hex");
415
444
  }
445
+ // Pulls the `env` map out of an .mcp.json entry read off disk, so an update
446
+ // can carry its secrets forward (see resolveMcpServerConfig's `existingEnv`).
447
+ // Written defensively -- the entry is `unknown` here, and only a string value
448
+ // is usable as a credential -- even though the caller has already fingerprint-
449
+ // verified it, so that a shape this function can't read degrades into a
450
+ // prompt rather than a crash.
451
+ function readMcpEntryEnv(entry) {
452
+ if (typeof entry !== "object" || entry === null)
453
+ return undefined;
454
+ const env = entry.env;
455
+ if (typeof env !== "object" || env === null)
456
+ return undefined;
457
+ const result = {};
458
+ for (const [name, value] of Object.entries(env)) {
459
+ if (typeof value === "string")
460
+ result[name] = value;
461
+ }
462
+ return result;
463
+ }
416
464
  async function installMcpEntry(owner, skill, meta, buffer) {
417
465
  // Check for an existing .mcp.json collision BEFORE resolving secrets --
418
466
  // matches resolveMcpServerConfig's own "don't cost the user a prompt for
@@ -503,7 +551,13 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
503
551
  }
504
552
  }
505
553
  const buffer = await downloadVerifiedArchive(owner, skill, meta);
506
- const { serverConfig } = await resolveMcpServerConfig(buffer);
554
+ // Reuse the secrets already in the entry rather than re-prompting for
555
+ // them. Safe to trust specifically because the fingerprint check above has
556
+ // just proven this is ahood's own unmodified entry; in the self-heal case
557
+ // (no entry on disk at all) there's nothing to carry forward and a genuine
558
+ // prompt is correct.
559
+ const existingEnv = readMcpEntryEnv(existingOnDisk);
560
+ const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
507
561
  // Read-modify-write under an advisory lock, mirroring installMcpEntry's
508
562
  // own pattern. Re-checks the fingerprint here too (not just above) since
509
563
  // arbitrary time may have passed since the pre-check -- the download, and
@@ -529,25 +583,43 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
529
583
  });
530
584
  }
531
585
  catch (error) {
532
- if (!(error instanceof LockfileChecksumConflictError))
533
- throw error;
534
586
  // Roll back the .mcp.json overwrite above under the same lock pattern,
535
587
  // restoring the entry to what it was before this update touched it --
536
588
  // not deleting it, unlike installMcpEntry's rollback: unless this was
537
589
  // the self-heal-a-missing-entry case (previousOnDisk undefined), there
538
590
  // WAS a working entry here before this update began.
539
- withLock(MCP_CONFIG_PATH, () => {
540
- const fileContents = readMcpConfig();
541
- const mcpServers = fileContents.mcpServers;
542
- if (previousOnDisk === undefined) {
543
- delete mcpServers[skill];
544
- }
545
- else {
546
- mcpServers[skill] = previousOnDisk;
547
- }
548
- writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
549
- });
550
- throw new Error(checksumConflictMessage(key, meta, error.existing));
591
+ //
592
+ // Runs for EVERY lockfile failure, not just a checksum conflict. A
593
+ // rethrow-before-rollback here left .mcp.json holding the new config
594
+ // while the lockfile kept the old version and old mcp_config_hash, after
595
+ // which the fingerprint never matches again and both `update` and
596
+ // `remove` permanently refuse -- the exact dead end the comments above
597
+ // exist to prevent, reachable from nothing worse than withLock's 5s
598
+ // timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
599
+ try {
600
+ withLock(MCP_CONFIG_PATH, () => {
601
+ const fileContents = readMcpConfig();
602
+ const mcpServers = fileContents.mcpServers;
603
+ if (previousOnDisk === undefined) {
604
+ delete mcpServers[skill];
605
+ }
606
+ else {
607
+ mcpServers[skill] = previousOnDisk;
608
+ }
609
+ writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
610
+ });
611
+ }
612
+ catch {
613
+ // The rollback can fail for the same reason the write did (lock still
614
+ // contended, disk still full). Warn rather than throw: the original
615
+ // error below is the more actionable one, and masking it with this
616
+ // one would hide why the update failed in the first place.
617
+ 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.`);
618
+ }
619
+ if (error instanceof LockfileChecksumConflictError) {
620
+ throw new Error(checksumConflictMessage(key, meta, error.existing));
621
+ }
622
+ throw error;
551
623
  }
552
624
  console.log(`Updated ${key} to ${meta.version}`);
553
625
  if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
@@ -212,6 +212,14 @@ export async function unshareSnap(args) {
212
212
  export async function tagsSnap(args) {
213
213
  const jsonOutput = args.includes("--json");
214
214
  const positionals = args.filter((a) => a !== "--json");
215
+ // Reject stray "--" flags rather than folding them into the tag list
216
+ // (parseSearchQuery in flags.ts does the same). Without this, the natural
217
+ // mistake `snap tags <id> --tags a,b` -- natural because `snap create`
218
+ // really does spell it `--tags a,b` -- silently PATCHed the tag set to
219
+ // ["--tags a", "b"], and `snap tags --yes <id>` used "--yes" as the id.
220
+ const unknownFlag = positionals.find((a) => a.startsWith("--"));
221
+ if (unknownFlag)
222
+ throw new UsageError(`Unknown flag: ${unknownFlag}\n${TAGS_USAGE}`);
215
223
  const id = positionals[0];
216
224
  if (!id)
217
225
  throw new UsageError(TAGS_USAGE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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",