@ahood/cli 0.8.0 → 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.
- package/README.md +2 -1
- package/dist/commands/add.js +215 -14
- package/dist/commands/remove.js +67 -24
- package/dist/commands/snap.js +70 -5
- package/dist/commands/update.js +19 -10
- package/dist/help.js +26 -6
- package/dist/index.js +2 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -243,13 +243,14 @@ Sharing is additive -- it doesn't change `alice/pdf-tools`'s own public/private
|
|
|
243
243
|
|
|
244
244
|
| Command | What it does |
|
|
245
245
|
| --- | --- |
|
|
246
|
-
| `ahood snap create <content> [--json]` | Capture a new private snap, from an argument or piped stdin. |
|
|
246
|
+
| `ahood snap create <content> [--tags tag1,tag2] [--json]` | Capture a new private snap, from an argument or piped stdin. |
|
|
247
247
|
| `ahood snap list [--json] [--limit <n>]` | List your own snaps, most recent first. |
|
|
248
248
|
| `ahood snap search <query> [--json] [--limit <n>]` | Search your own snaps by content. |
|
|
249
249
|
| `ahood snap show <id> [--json]` | Print a single snap's full content. |
|
|
250
250
|
| `ahood snap remove <id> [--yes]` | Permanently delete a snap (prompts for confirmation unless --yes is passed). |
|
|
251
251
|
| `ahood snap share <id> [--json]` | Mint (or return the existing) shareable link for a snap. |
|
|
252
252
|
| `ahood snap unshare <id> [--yes]` | Revoke a snap's share link (the snap itself is untouched; prompts for confirmation unless --yes is passed). |
|
|
253
|
+
| `ahood snap tags <id> [tag1,tag2,...] [--json]` | Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags). |
|
|
253
254
|
<!-- COMMANDS_TABLE_END -->
|
|
254
255
|
|
|
255
256
|
Run `ahood --help`, `ahood skill --help`, `ahood group --help`, `ahood snap --help`, or `ahood <command> --help` (also `ahood help <command>` / `ahood help skill <verb>` / `ahood help group <verb>` / `ahood help snap <verb>`) for the same reference — including per-command flags and examples — directly in your terminal. `ahood --version` prints the installed CLI version.
|
package/dist/commands/add.js
CHANGED
|
@@ -325,7 +325,9 @@ function summarizeConflictingEntry(entry) {
|
|
|
325
325
|
// raw TypeError, and would let `"mcpServers": []` pass the collision check
|
|
326
326
|
// silently (an array's numeric-index write is then dropped entirely by
|
|
327
327
|
// JSON.stringify), reporting a successful install that wrote nothing.
|
|
328
|
-
|
|
328
|
+
// Exported so remove.ts/update.ts can read the same validated shape when
|
|
329
|
+
// deciding whether it's safe to touch an mcp entry (ahood-cli#169).
|
|
330
|
+
export function readMcpConfig() {
|
|
329
331
|
if (!existsSync(MCP_CONFIG_PATH))
|
|
330
332
|
return { mcpServers: {} };
|
|
331
333
|
let parsed;
|
|
@@ -361,18 +363,40 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
361
363
|
`Remove it manually first if you want to reinstall ${owner}/${skill}.`);
|
|
362
364
|
}
|
|
363
365
|
}
|
|
364
|
-
|
|
366
|
+
// Extracts server.json, validates its shape, and resolves every secret
|
|
367
|
+
// environment variable it declares (prompting only for ones not already
|
|
368
|
+
// set in the shell) -- the part of installing an mcp entry that's identical
|
|
369
|
+
// whether this is a fresh `add` or an `update` moving an existing pin
|
|
370
|
+
// forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
|
|
371
|
+
// re-resolves secrets the same way a fresh install does, instead of
|
|
372
|
+
// duplicating this parsing/prompting logic.
|
|
373
|
+
// `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;
|
|
365
390
|
const content = await extractSingleFileContent(buffer, "server.json");
|
|
366
391
|
const manifest = JSON.parse(content.toString("utf-8"));
|
|
367
392
|
// Validate the manifest's shape (unsupported registry_type/runtime_hint,
|
|
368
|
-
// or neither a single 'packages' nor a single 'remotes' entry)
|
|
369
|
-
// for
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
393
|
+
// or neither a single 'packages' nor a single 'remotes' entry) BEFORE
|
|
394
|
+
// prompting for any secret -- an install/update that's going to be
|
|
395
|
+
// refused anyway must not first cost the user a masked secret prompt.
|
|
396
|
+
// buildMcpServerConfig is called here with an empty env purely to run its
|
|
397
|
+
// validation/throw; the result is discarded and rebuilt below once
|
|
398
|
+
// secretEnv is known.
|
|
374
399
|
buildMcpServerConfig(manifest, {});
|
|
375
|
-
assertNoCollision(readMcpConfig(), owner, skill);
|
|
376
400
|
const envVars = manifest.packages?.[0]?.environment_variables ?? [];
|
|
377
401
|
const secretEnv = {};
|
|
378
402
|
for (const variable of envVars) {
|
|
@@ -384,11 +408,66 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
384
408
|
// (per the design spec), which uses `if (process.env.AHOOD_TOKEN)`
|
|
385
409
|
// specifically so an empty-string env var falls through to the real
|
|
386
410
|
// source instead of silently installing an empty secret.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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)}): `);
|
|
390
427
|
}
|
|
391
428
|
const serverConfig = buildMcpServerConfig(manifest, secretEnv);
|
|
429
|
+
return { manifest, serverConfig };
|
|
430
|
+
}
|
|
431
|
+
// Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
|
|
432
|
+
// entry, stored in the lockfile alongside the version/checksum pin. Lets
|
|
433
|
+
// remove/update (ahood-cli#169) tell "the on-disk entry is still exactly
|
|
434
|
+
// what I last installed" from "something (the user, another tool) has since
|
|
435
|
+
// hand-edited it" -- without this, a real (not just warned-about) removal
|
|
436
|
+
// or update risks silently discarding an intentional local edit, the same
|
|
437
|
+
// class of risk assertNoCollision below exists to prevent on a *fresh*
|
|
438
|
+
// install. JSON.stringify's key order matches insertion order for a plain
|
|
439
|
+
// object, and JSON.parse's key order matches source-text order -- so this
|
|
440
|
+
// is stable between the object this process just built and the same object
|
|
441
|
+
// read back from disk later, as long as nothing reorders keys in between.
|
|
442
|
+
export function hashMcpServerConfig(config) {
|
|
443
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
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
|
+
}
|
|
464
|
+
async function installMcpEntry(owner, skill, meta, buffer) {
|
|
465
|
+
// Check for an existing .mcp.json collision BEFORE resolving secrets --
|
|
466
|
+
// matches resolveMcpServerConfig's own "don't cost the user a prompt for
|
|
467
|
+
// something that's going to be refused anyway" reasoning, just for the
|
|
468
|
+
// collision check specifically rather than the manifest-shape check.
|
|
469
|
+
assertNoCollision(readMcpConfig(), owner, skill);
|
|
470
|
+
const { serverConfig } = await resolveMcpServerConfig(buffer);
|
|
392
471
|
// Read-modify-write under an advisory lock, mirroring lockfile.ts's own
|
|
393
472
|
// writeLockfileEntry: .mcp.json sits right next to the lockfile and can
|
|
394
473
|
// hold other servers' secrets, so it gets the same protection against a
|
|
@@ -406,7 +485,11 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
406
485
|
});
|
|
407
486
|
const mcpKey = `${owner}/${skill}`;
|
|
408
487
|
try {
|
|
409
|
-
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, {
|
|
488
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, mcpKey, {
|
|
489
|
+
version: meta.version,
|
|
490
|
+
checksum_sha256: meta.checksum_sha256,
|
|
491
|
+
mcp_config_hash: hashMcpServerConfig(serverConfig),
|
|
492
|
+
});
|
|
410
493
|
}
|
|
411
494
|
catch (error) {
|
|
412
495
|
if (!(error instanceof LockfileChecksumConflictError))
|
|
@@ -421,7 +504,125 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
421
504
|
throw new Error(checksumConflictMessage(mcpKey, meta, error.existing));
|
|
422
505
|
}
|
|
423
506
|
console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
|
|
424
|
-
|
|
507
|
+
// Only the npm+npx package path ever carries secrets into `env` (headers
|
|
508
|
+
// on a remote entry are static per v1's scope, per buildMcpServerConfig) --
|
|
509
|
+
// checking the final serverConfig's own `env` key (rather than threading
|
|
510
|
+
// secretEnv out of resolveMcpServerConfig separately) means this warning
|
|
511
|
+
// can never drift from what was actually written to disk.
|
|
512
|
+
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
513
|
+
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Re-runs the mcp install flow for an already-installed entry, moving its
|
|
517
|
+
// lockfile pin forward -- update.ts's real mcp-update path (ahood-cli#169).
|
|
518
|
+
// Unlike installMcpEntry, "the entry already exists" is expected here (it's
|
|
519
|
+
// the very entry being updated), so the safety check is inverted: rather
|
|
520
|
+
// than refusing because something's already there, this refuses UNLESS the
|
|
521
|
+
// on-disk entry's fingerprint still matches what was recorded at the last
|
|
522
|
+
// install/update -- same posture as assertNoCollision, applied to "is this
|
|
523
|
+
// still mine to touch" instead of "is this mine to create". If the entry is
|
|
524
|
+
// missing entirely, there's nothing to protect -- write fresh (self-heals a
|
|
525
|
+
// lockfile pin whose .mcp.json entry was deleted by hand without going
|
|
526
|
+
// through `ahood skill remove`). If it's present but doesn't match (or no
|
|
527
|
+
// fingerprint was ever recorded, e.g. an entry installed before this field
|
|
528
|
+
// existed), refuse rather than silently overwrite a possibly-intentional
|
|
529
|
+
// local edit.
|
|
530
|
+
export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
531
|
+
const key = `${owner}/${skill}`;
|
|
532
|
+
const recordedHash = currentEntry?.mcp_config_hash;
|
|
533
|
+
const fileContents = readMcpConfig();
|
|
534
|
+
const mcpServers = fileContents.mcpServers;
|
|
535
|
+
const existingOnDisk = Object.prototype.hasOwnProperty.call(mcpServers, skill) ? mcpServers[skill] : undefined;
|
|
536
|
+
// Both refusal messages below point at manually editing .mcp.json, not
|
|
537
|
+
// `ahood skill remove` -- remove refuses to delete an entry under
|
|
538
|
+
// exactly these same two conditions (no recorded fingerprint, or a
|
|
539
|
+
// fingerprint mismatch), so telling the user to "remove then add" would
|
|
540
|
+
// send them into a sequence where remove leaves the entry in place *and*
|
|
541
|
+
// clears the lockfile pin, and the follow-up add then fails on the very
|
|
542
|
+
// same entry as a collision -- pin gone, credential still live, no CLI
|
|
543
|
+
// path forward. Editing .mcp.json by hand and then running `add` is the
|
|
544
|
+
// only sequence that actually resolves the state.
|
|
545
|
+
if (existingOnDisk !== undefined) {
|
|
546
|
+
if (recordedHash === undefined) {
|
|
547
|
+
throw new Error(`Cannot verify ${key}'s .mcp.json entry matches what ahood last installed (no recorded fingerprint) -- delete the "${skill}" entry from mcpServers in .mcp.json by hand, then run \`ahood skill add ${key}\` to reinstall and enable automatic updates going forward.`);
|
|
548
|
+
}
|
|
549
|
+
if (hashMcpServerConfig(existingOnDisk) !== recordedHash) {
|
|
550
|
+
throw new Error(`${key}'s .mcp.json entry appears to have been modified since install -- refusing to overwrite it. Delete the "${skill}" entry from mcpServers in .mcp.json by hand, then run \`ahood skill add ${key}\` to reinstall.`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
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 });
|
|
561
|
+
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
562
|
+
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
563
|
+
// arbitrary time may have passed since the pre-check -- the download, and
|
|
564
|
+
// any secret prompt inside resolveMcpServerConfig -- during which another
|
|
565
|
+
// process could have changed the entry.
|
|
566
|
+
let previousOnDisk;
|
|
567
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
568
|
+
const fresh = readMcpConfig();
|
|
569
|
+
const freshServers = fresh.mcpServers;
|
|
570
|
+
const freshExisting = Object.prototype.hasOwnProperty.call(freshServers, skill) ? freshServers[skill] : undefined;
|
|
571
|
+
if (freshExisting !== undefined && (recordedHash === undefined || hashMcpServerConfig(freshExisting) !== recordedHash)) {
|
|
572
|
+
throw new Error(`${key}'s .mcp.json entry changed while updating -- refusing to overwrite it. Re-run \`ahood skill update ${key}\` if this was unexpected.`);
|
|
573
|
+
}
|
|
574
|
+
previousOnDisk = freshExisting;
|
|
575
|
+
freshServers[skill] = serverConfig;
|
|
576
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
|
|
577
|
+
});
|
|
578
|
+
try {
|
|
579
|
+
writeLockfileEntryVerifyingChecksum(LOCKFILE_PATH, key, {
|
|
580
|
+
version: meta.version,
|
|
581
|
+
checksum_sha256: meta.checksum_sha256,
|
|
582
|
+
mcp_config_hash: hashMcpServerConfig(serverConfig),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
// Roll back the .mcp.json overwrite above under the same lock pattern,
|
|
587
|
+
// restoring the entry to what it was before this update touched it --
|
|
588
|
+
// not deleting it, unlike installMcpEntry's rollback: unless this was
|
|
589
|
+
// the self-heal-a-missing-entry case (previousOnDisk undefined), there
|
|
590
|
+
// WAS a working entry here before this update began.
|
|
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;
|
|
623
|
+
}
|
|
624
|
+
console.log(`Updated ${key} to ${meta.version}`);
|
|
625
|
+
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
425
626
|
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
426
627
|
}
|
|
427
628
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
|
-
import { readLockfile, removeLockfileEntry } from "../lockfile.js";
|
|
3
|
+
import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
|
|
4
4
|
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
|
|
5
|
+
import { readMcpConfig, hashMcpServerConfig } from "./add.js";
|
|
5
6
|
import { UsageError } from "../usage-error.js";
|
|
6
7
|
const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
|
|
7
8
|
export async function remove(args) {
|
|
@@ -19,7 +20,8 @@ export async function remove(args) {
|
|
|
19
20
|
// never a directory under .claude/skills/ -- distinct from the dir check above.
|
|
20
21
|
const agentFile = agentPath(owner, skill);
|
|
21
22
|
const agentExisted = existsSync(agentFile);
|
|
22
|
-
const
|
|
23
|
+
const lockEntry = readLockfile(LOCKFILE_PATH)[key];
|
|
24
|
+
const hadLockfileEntry = lockEntry !== undefined;
|
|
23
25
|
if (!dirExisted && !agentExisted && !hadLockfileEntry) {
|
|
24
26
|
console.error(`${key} was not installed -- nothing to remove.`);
|
|
25
27
|
process.exitCode = 1;
|
|
@@ -42,30 +44,71 @@ export async function remove(args) {
|
|
|
42
44
|
// An mcp-kind install has no directory or agent file on disk -- its only
|
|
43
45
|
// footprint here is the lockfile entry just cleared above and a live
|
|
44
46
|
// entry in .mcp.json (which add.ts's installMcpEntry merged in, possibly
|
|
45
|
-
// holding a resolved secret in its `env`).
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// the
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
47
|
+
// holding a resolved secret in its `env`). Real removal (delete that one
|
|
48
|
+
// key) only happens when the on-disk entry's fingerprint still matches
|
|
49
|
+
// what was recorded at install/update time -- same posture as add.ts's
|
|
50
|
+
// own assertNoCollision: never blind-write/delete something this process
|
|
51
|
+
// didn't verify it still owns. Without SOME check here, "Removed" would
|
|
52
|
+
// repeat the exact false-assurance bug already fixed once for agent
|
|
53
|
+
// installs above (a user told it's gone while the MCP server, and its
|
|
54
|
+
// credential, still runs on the next Claude Code start) -- but a hand-
|
|
55
|
+
// edited entry (fingerprint mismatch, or no fingerprint recorded at all,
|
|
56
|
+
// e.g. an mcp entry installed before this field existed) is left in
|
|
57
|
+
// place and warned about instead, exactly as before this fix.
|
|
58
|
+
let removedMcpEntry = false;
|
|
59
|
+
let mcpEntryModified = false;
|
|
60
|
+
let mcpEntryPresent = false;
|
|
61
|
+
let recordedHash;
|
|
62
|
+
try {
|
|
63
|
+
const fileContents = readMcpConfig();
|
|
64
|
+
const mcpServers = fileContents.mcpServers;
|
|
65
|
+
mcpEntryPresent = Object.prototype.hasOwnProperty.call(mcpServers, skill);
|
|
66
|
+
if (mcpEntryPresent) {
|
|
67
|
+
recordedHash = lockEntry?.mcp_config_hash;
|
|
68
|
+
const currentHash = hashMcpServerConfig(mcpServers[skill]);
|
|
69
|
+
if (recordedHash !== undefined) {
|
|
70
|
+
mcpEntryModified = recordedHash !== currentHash;
|
|
62
71
|
}
|
|
63
72
|
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A malformed .mcp.json isn't this command's problem to fix or crash
|
|
76
|
+
// on -- add.ts's readMcpConfig is the strict validator for that path.
|
|
77
|
+
// mcpEntryPresent stays false, so there's nothing to warn about either:
|
|
78
|
+
// if we can't even read the file, we can't know whether this skill has
|
|
79
|
+
// an entry in it.
|
|
80
|
+
}
|
|
81
|
+
if (mcpEntryPresent && recordedHash !== undefined && !mcpEntryModified) {
|
|
82
|
+
try {
|
|
83
|
+
// Re-check under lock rather than trusting the read above -- another
|
|
84
|
+
// process could have changed or removed the entry in between,
|
|
85
|
+
// mirroring add.ts's own re-check-under-lock before it writes. This
|
|
86
|
+
// try/catch is deliberately separate from the read above: a failure
|
|
87
|
+
// HERE (a lock-acquisition timeout, an EACCES/ENOSPC/etc. write
|
|
88
|
+
// failure) must still fall through to the warning below rather than
|
|
89
|
+
// being silently swallowed -- letting it share the read's catch block
|
|
90
|
+
// was the exact bug that let a failed delete print a bare "Removed"
|
|
91
|
+
// with the credential still live (the false-assurance case this
|
|
92
|
+
// whole mechanism exists to prevent).
|
|
93
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
94
|
+
const fresh = readMcpConfig();
|
|
95
|
+
const freshServers = fresh.mcpServers;
|
|
96
|
+
if (Object.prototype.hasOwnProperty.call(freshServers, skill) &&
|
|
97
|
+
hashMcpServerConfig(freshServers[skill]) === recordedHash) {
|
|
98
|
+
delete freshServers[skill];
|
|
99
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
|
|
100
|
+
removedMcpEntry = true;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
64
104
|
catch {
|
|
65
|
-
//
|
|
66
|
-
// on -- add.ts's readMcpConfig is the strict validator for that path.
|
|
67
|
-
// Skip the warning rather than throw here.
|
|
105
|
+
// removedMcpEntry stays false -- falls through to the warning below.
|
|
68
106
|
}
|
|
69
107
|
}
|
|
70
|
-
|
|
108
|
+
if (mcpEntryPresent && !removedMcpEntry) {
|
|
109
|
+
console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered)` +
|
|
110
|
+
(mcpEntryModified ? " -- it appears to have been modified since install" : "") +
|
|
111
|
+
` -- remove it manually.`);
|
|
112
|
+
}
|
|
113
|
+
console.log(removedMcpEntry ? `Removed ${key} (including its ${MCP_CONFIG_PATH} entry)` : `Removed ${key}`);
|
|
71
114
|
}
|
package/dist/commands/snap.js
CHANGED
|
@@ -2,13 +2,14 @@ import { apiJson } from "../http.js";
|
|
|
2
2
|
import { flagValue, parseSearchQuery } from "../flags.js";
|
|
3
3
|
import { confirm } from "../confirm.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
|
-
const CREATE_USAGE = "Usage: ahood snap create <content> (or pipe content on stdin)";
|
|
5
|
+
const CREATE_USAGE = "Usage: ahood snap create <content> [--tags tag1,tag2] (or pipe content on stdin)";
|
|
6
6
|
const LIST_USAGE = "Usage: ahood snap list [--json] [--limit <n>]";
|
|
7
7
|
const SEARCH_USAGE = "Usage: ahood snap search <query> [--json] [--limit <n>]";
|
|
8
8
|
const SHOW_USAGE = "Usage: ahood snap show <id> [--json]";
|
|
9
9
|
const REMOVE_USAGE = "Usage: ahood snap remove <id> [--yes]";
|
|
10
10
|
const SHARE_USAGE = "Usage: ahood snap share <id> [--json]";
|
|
11
11
|
const UNSHARE_USAGE = "Usage: ahood snap unshare <id> [--yes]";
|
|
12
|
+
const TAGS_USAGE = "Usage: ahood snap tags <id> [tag1,tag2,...] [--json] (omit or pass an empty value to clear all tags)";
|
|
12
13
|
// There's no existing "read stdin to completion" helper in this codebase to
|
|
13
14
|
// reuse -- confirm.ts and secret-prompt.ts both only ever read a single
|
|
14
15
|
// line via readline, never an arbitrary-length paste/pipe. Standard Node
|
|
@@ -50,16 +51,24 @@ function printSnaps(jsonOutput, snaps, emptyMessage) {
|
|
|
50
51
|
// breaks the "one line per snap" contract.
|
|
51
52
|
const flat = snap.content_preview.replace(/\s+/g, " ").trim();
|
|
52
53
|
const preview = flat.length > 60 ? `${flat.slice(0, 60)}...` : flat;
|
|
53
|
-
|
|
54
|
+
// ?? [] -- see listSnaps/searchSnaps' identical degrade-on-null guard
|
|
55
|
+
// below; a snap predating the tags column, or a degraded response,
|
|
56
|
+
// should just show no tags rather than crashing on undefined.length.
|
|
57
|
+
const tags = snap.tags ?? [];
|
|
58
|
+
const tagsSuffix = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
|
|
59
|
+
console.log(`${snap.id} - ${preview} (${snap.created_at})${snap.shared ? " (shared)" : ""}${tagsSuffix}`);
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
export async function createSnap(args) {
|
|
57
63
|
const jsonOutput = args.includes("--json");
|
|
64
|
+
const tagsArg = flagValue(args, "--tags");
|
|
58
65
|
// Joined, not just args[0] -- an unquoted multi-word note (e.g. `ahood
|
|
59
66
|
// snap create Debugged the flaky CI step`) arrives as multiple positional
|
|
60
67
|
// tokens, and taking only the first one silently dropped the rest with no
|
|
61
|
-
// error. Mirrors searchSnaps' own query-joining below.
|
|
62
|
-
|
|
68
|
+
// error. Mirrors searchSnaps' own query-joining below. --tags and its
|
|
69
|
+
// value (both "--tags x" and "--tags=x" forms) are stripped the same way
|
|
70
|
+
// --json is, so they never leak into the joined content.
|
|
71
|
+
const positionals = args.filter((a, i) => a !== "--json" && a !== "--tags" && !a.startsWith("--tags=") && args[i - 1] !== "--tags");
|
|
63
72
|
const positional = positionals.length > 0 ? positionals.join(" ") : undefined;
|
|
64
73
|
let content;
|
|
65
74
|
if (positional !== undefined) {
|
|
@@ -79,10 +88,21 @@ export async function createSnap(args) {
|
|
|
79
88
|
}
|
|
80
89
|
if (!content.trim())
|
|
81
90
|
throw new UsageError(CREATE_USAGE);
|
|
91
|
+
// Same comma-split convention as `ahood skill publish --tags`/`edit
|
|
92
|
+
// --tags`. Omitted entirely when --tags wasn't passed at all, so the
|
|
93
|
+
// request body matches the "omitting tags is equivalent to []" contract
|
|
94
|
+
// exactly instead of always sending an (empty) tags array.
|
|
95
|
+
const body = { content };
|
|
96
|
+
if (tagsArg !== undefined) {
|
|
97
|
+
body.tags = tagsArg
|
|
98
|
+
.split(",")
|
|
99
|
+
.map((t) => t.trim())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
}
|
|
82
102
|
const created = await apiJson("/api/v1/snaps", {
|
|
83
103
|
method: "POST",
|
|
84
104
|
headers: { "Content-Type": "application/json" },
|
|
85
|
-
body: JSON.stringify(
|
|
105
|
+
body: JSON.stringify(body),
|
|
86
106
|
});
|
|
87
107
|
if (jsonOutput) {
|
|
88
108
|
console.log(JSON.stringify(created));
|
|
@@ -189,3 +209,48 @@ export async function unshareSnap(args) {
|
|
|
189
209
|
await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}/share`, { method: "DELETE" });
|
|
190
210
|
console.log(`Unshared snap ${id}.`);
|
|
191
211
|
}
|
|
212
|
+
export async function tagsSnap(args) {
|
|
213
|
+
const jsonOutput = args.includes("--json");
|
|
214
|
+
const positionals = args.filter((a) => a !== "--json");
|
|
215
|
+
// 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}`);
|
|
223
|
+
const id = positionals[0];
|
|
224
|
+
if (!id)
|
|
225
|
+
throw new UsageError(TAGS_USAGE);
|
|
226
|
+
// PATCH replaces the full tag set (not a merge) -- omitting the tags
|
|
227
|
+
// argument, or passing an empty string, both clear every tag, matching
|
|
228
|
+
// the "pass [] to clear" contract of the endpoint itself. Joined, not
|
|
229
|
+
// just positionals[1] -- an unquoted "tag1, tag2" arrives as multiple
|
|
230
|
+
// positional tokens, and taking only the first one silently dropped the
|
|
231
|
+
// rest with no error, the same bug createSnap's own content-joining
|
|
232
|
+
// above already fixed.
|
|
233
|
+
const tagsArg = positionals.slice(1).join(" ");
|
|
234
|
+
const tags = tagsArg
|
|
235
|
+
? tagsArg
|
|
236
|
+
.split(",")
|
|
237
|
+
.map((t) => t.trim())
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
: [];
|
|
240
|
+
// No confirm() gate, unlike remove/unshare -- this only replaces metadata
|
|
241
|
+
// (tags), never the snap's content or its shareability, and re-running
|
|
242
|
+
// `tags` with the old set restores it exactly. Per ahood-cli#114's spec.
|
|
243
|
+
const updated = await apiJson(`/api/v1/snaps/${encodeURIComponent(id)}`, {
|
|
244
|
+
method: "PATCH",
|
|
245
|
+
headers: { "Content-Type": "application/json" },
|
|
246
|
+
body: JSON.stringify({ tags }),
|
|
247
|
+
});
|
|
248
|
+
if (jsonOutput) {
|
|
249
|
+
console.log(JSON.stringify(updated));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// ?? [] -- see printSnaps' identical degrade-on-null guard above
|
|
253
|
+
// (ahood-cli#106): a degraded response shouldn't crash on undefined.length.
|
|
254
|
+
const updatedTags = updated.tags ?? [];
|
|
255
|
+
console.log(updatedTags.length > 0 ? `Tags for ${updated.id}: ${updatedTags.join(", ")}` : `Cleared tags for ${updated.id}.`);
|
|
256
|
+
}
|
package/dist/commands/update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
|
|
2
2
|
import { readLockfile } from "../lockfile.js";
|
|
3
|
-
import { add, fetchVersionMeta } from "./add.js";
|
|
3
|
+
import { add, fetchVersionMeta, updateMcpEntry } from "./add.js";
|
|
4
4
|
import { UsageError } from "../usage-error.js";
|
|
5
5
|
const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
|
|
6
6
|
async function previewSkill(ownerSlashSkill, currentVersion) {
|
|
@@ -99,18 +99,27 @@ export async function update(args) {
|
|
|
99
99
|
try {
|
|
100
100
|
// add() always hits the .mcp.json collision check for an mcp-kind
|
|
101
101
|
// entry that's already installed (that's the very definition of
|
|
102
|
-
// "already installed" for that kind), so
|
|
103
|
-
// here
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// instead of updating and hitting that guaranteed error. This is a
|
|
109
|
-
// skip-and-report-cleanly fix, not real mcp-update support.
|
|
102
|
+
// "already installed" for that kind), so it can never be called
|
|
103
|
+
// unconditionally here the way it is for skill/agent kinds below --
|
|
104
|
+
// fetchVersionMeta resolves `kind` as part of the normal "latest"
|
|
105
|
+
// lookup add() itself does, so resolving it here first lets an
|
|
106
|
+
// mcp-kind entry route to updateMcpEntry (ahood-cli#169's real
|
|
107
|
+
// mcp-update support) instead.
|
|
110
108
|
const { owner, skill } = parseOwnerSkill(ownerSlashSkill, USAGE);
|
|
111
109
|
const meta = await fetchVersionMeta(owner, skill, "latest");
|
|
112
110
|
if (meta.kind === "mcp") {
|
|
113
|
-
|
|
111
|
+
const currentEntry = lockfile[ownerSlashSkill];
|
|
112
|
+
if (currentEntry && currentEntry.version === meta.version) {
|
|
113
|
+
// Matches skill/agent update's own "nothing to do" case for a
|
|
114
|
+
// pin that's already at latest -- not a warning (nothing's
|
|
115
|
+
// wrong), and, unlike skill/agent (which always blindly
|
|
116
|
+
// re-extracts even when nothing changed), skipped here
|
|
117
|
+
// specifically so an up-to-date mcp entry with a secret in its
|
|
118
|
+
// env never re-triggers a masked prompt for no reason.
|
|
119
|
+
console.log(`${ownerSlashSkill} is already up to date (v${meta.version}).`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
await updateMcpEntry(owner, skill, meta, currentEntry);
|
|
114
123
|
continue;
|
|
115
124
|
}
|
|
116
125
|
await add([ownerSlashSkill]); // no @version -- resolves to latest again
|
package/dist/help.js
CHANGED
|
@@ -100,7 +100,9 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
100
100
|
{
|
|
101
101
|
usage: "ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]",
|
|
102
102
|
summary: "Move the lockfile pin(s) forward to the latest version, for one skill or all installed skills at once.",
|
|
103
|
-
desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest."
|
|
103
|
+
desc: "Move the lockfile pin(s) forward to the latest version. With no argument, updates every installed skill; one failure doesn't stop the rest. " +
|
|
104
|
+
"An mcp-kind entry is updated in place (re-resolving any secret environment variables) as long as its .mcp.json entry still matches what " +
|
|
105
|
+
"ahood last installed there -- a hand-edited or unverifiable entry is refused rather than silently overwritten.",
|
|
104
106
|
flags: [
|
|
105
107
|
"--dry-run Preview current vs. latest version (and the changelog for anything behind) without installing anything.",
|
|
106
108
|
"--json With --dry-run, emit structured preview objects instead of a formatted table.",
|
|
@@ -119,7 +121,9 @@ export const SKILL_COMMANDS_HELP = [
|
|
|
119
121
|
{
|
|
120
122
|
usage: "ahood skill remove <owner>/<skill> [--yes]",
|
|
121
123
|
summary: "Uninstall and unpin a skill (local only, prompts for confirmation unless --yes is passed).",
|
|
122
|
-
desc: "Uninstall and unpin (local only). Prompts for confirmation unless --yes is passed."
|
|
124
|
+
desc: "Uninstall and unpin (local only). Prompts for confirmation unless --yes is passed. For an mcp-kind install, " +
|
|
125
|
+
"also deletes its .mcp.json entry as long as it still matches what ahood last installed there -- a hand-edited " +
|
|
126
|
+
"or unverifiable entry is left in place and warned about instead of being silently touched.",
|
|
123
127
|
},
|
|
124
128
|
{
|
|
125
129
|
usage: "ahood skill edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]",
|
|
@@ -275,18 +279,25 @@ export const GROUP_COMMANDS_HELP = [
|
|
|
275
279
|
// never public otherwise.
|
|
276
280
|
export const SNAP_COMMANDS_HELP = [
|
|
277
281
|
{
|
|
278
|
-
usage: "ahood snap create <content> [--json]",
|
|
282
|
+
usage: "ahood snap create <content> [--tags tag1,tag2] [--json]",
|
|
279
283
|
summary: "Capture a new private snap, from an argument or piped stdin.",
|
|
280
284
|
desc: "Capture a new private snap. Pass the content as an argument, or omit it and pipe content on stdin " +
|
|
281
285
|
"(e.g. `echo \"...\" | ahood snap create`, or a piped heredoc) -- useful for capturing a whole session's " +
|
|
282
286
|
"worth of freeform text at once. Prints the new snap's id on success.",
|
|
283
|
-
flags: [
|
|
284
|
-
|
|
287
|
+
flags: [
|
|
288
|
+
"--tags <comma,separated> Attach tags to the new snap.",
|
|
289
|
+
"--json Emit {id, created_at} instead of just the bare id.",
|
|
290
|
+
],
|
|
291
|
+
examples: [
|
|
292
|
+
'ahood snap create "Debugged the flaky CI step, root cause was a race in the cache key." --tags deploy,bugfix',
|
|
293
|
+
'echo "..." | ahood snap create',
|
|
294
|
+
],
|
|
285
295
|
},
|
|
286
296
|
{
|
|
287
297
|
usage: "ahood snap list [--json] [--limit <n>]",
|
|
288
298
|
summary: "List your own snaps, most recent first.",
|
|
289
|
-
desc: "List your own snaps: id, a truncated content preview, created_at,
|
|
299
|
+
desc: "List your own snaps: id, a truncated content preview, created_at, a (shared) marker if shared, and " +
|
|
300
|
+
"any tags in brackets.",
|
|
290
301
|
flags: [
|
|
291
302
|
"--json Emit the raw snap objects instead of formatted lines.",
|
|
292
303
|
"--limit <n> Cap the number of results.",
|
|
@@ -329,6 +340,15 @@ export const SNAP_COMMANDS_HELP = [
|
|
|
329
340
|
"immediately.",
|
|
330
341
|
flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
|
|
331
342
|
},
|
|
343
|
+
{
|
|
344
|
+
usage: "ahood snap tags <id> [tag1,tag2,...] [--json]",
|
|
345
|
+
summary: "Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags).",
|
|
346
|
+
desc: "Replace the full set of tags on a snap -- this is not a merge, so any existing tags not listed are " +
|
|
347
|
+
"dropped. Omitting the tag list entirely, or passing an empty string, clears all tags. No confirmation " +
|
|
348
|
+
"prompt: unlike remove/unshare this only touches metadata, never the snap's content or shareability.",
|
|
349
|
+
flags: ["--json Emit {id, tags} instead of a plain-text summary."],
|
|
350
|
+
examples: ["ahood snap tags snap_123 deploy,bugfix", 'ahood snap tags snap_123 ""'],
|
|
351
|
+
},
|
|
332
352
|
];
|
|
333
353
|
// Exported so other consumers of the command list (e.g. shell completion) can
|
|
334
354
|
// surface aliases without needing their own copy of this map. Aliases apply
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { completion } from "./commands/completion.js";
|
|
|
22
22
|
import { mcp } from "./commands/mcp.js";
|
|
23
23
|
import { init } from "./commands/init.js";
|
|
24
24
|
import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
|
|
25
|
-
import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, } from "./commands/snap.js";
|
|
25
|
+
import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, tagsSnap, } from "./commands/snap.js";
|
|
26
26
|
import { formatHelp, formatSkillHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
|
|
27
27
|
import { ApiError } from "./http.js";
|
|
28
28
|
import { exitCodeFor } from "./exit-code.js";
|
|
@@ -80,6 +80,7 @@ const SNAP_VERBS = {
|
|
|
80
80
|
remove: removeSnap,
|
|
81
81
|
share: shareSnap,
|
|
82
82
|
unshare: unshareSnap,
|
|
83
|
+
tags: tagsSnap,
|
|
83
84
|
};
|
|
84
85
|
// Top level is now just account/auth-scoped commands (not entity-specific --
|
|
85
86
|
// same reasoning `gh auth login` isn't `gh account login`) plus the `skill`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahood/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"dist"
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
|
-
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
29
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
30
30
|
"prepublishOnly": "npm run build",
|
|
31
31
|
"test": "vitest run",
|
|
32
32
|
"sync-readme": "node scripts/sync-readme.mjs"
|