@ahood/cli 0.8.1 → 0.8.3
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/dist/commands/add.js +151 -26
- package/dist/commands/remove.js +37 -11
- package/dist/commands/snap.js +8 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -330,9 +330,25 @@ function summarizeConflictingEntry(entry) {
|
|
|
330
330
|
export function readMcpConfig() {
|
|
331
331
|
if (!existsSync(MCP_CONFIG_PATH))
|
|
332
332
|
return { mcpServers: {} };
|
|
333
|
+
// Read and parse are separate try blocks so an I/O failure isn't reported
|
|
334
|
+
// as a syntax error: EACCES/EISDIR/EMFILE used to surface as "is not valid
|
|
335
|
+
// JSON" about a file that often parses fine, sending the user hunting for a
|
|
336
|
+
// syntax error when the fix is chmod or removing a stray directory
|
|
337
|
+
// (ahood-cli#117). No "-- fix or remove it" advice on this branch: the
|
|
338
|
+
// remedy depends on the errno, Node's own message already names the code
|
|
339
|
+
// and the path, and remove.ts deliberately quotes only the text before
|
|
340
|
+
// " -- " (ahood-cli#115).
|
|
341
|
+
let raw;
|
|
342
|
+
try {
|
|
343
|
+
raw = readFileSync(MCP_CONFIG_PATH, "utf-8");
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
347
|
+
throw new Error(`${MCP_CONFIG_PATH} exists but could not be read: ${reason}`);
|
|
348
|
+
}
|
|
333
349
|
let parsed;
|
|
334
350
|
try {
|
|
335
|
-
parsed = JSON.parse(
|
|
351
|
+
parsed = JSON.parse(raw);
|
|
336
352
|
}
|
|
337
353
|
catch {
|
|
338
354
|
throw new Error(`${MCP_CONFIG_PATH} exists but is not valid JSON -- fix or remove it before running this command.`);
|
|
@@ -370,7 +386,23 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
370
386
|
// forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
|
|
371
387
|
// re-resolves secrets the same way a fresh install does, instead of
|
|
372
388
|
// duplicating this parsing/prompting logic.
|
|
373
|
-
|
|
389
|
+
// `existingEnv` carries forward the secret values already sitting in the
|
|
390
|
+
// entry being updated, so a routine `ahood skill update` doesn't re-prompt
|
|
391
|
+
// for a credential the user has already supplied. Only ever passed by
|
|
392
|
+
// updateMcpEntry, and only from an entry whose fingerprint it has just
|
|
393
|
+
// verified -- i.e. proven to be ahood's own unmodified entry, not something
|
|
394
|
+
// hand-edited or written by another tool.
|
|
395
|
+
//
|
|
396
|
+
// `allowNonTtyPrompt` (default true, preserving `echo secret | ahood skill
|
|
397
|
+
// add ...` for a fresh install the user explicitly asked for) is false on
|
|
398
|
+
// the update path: `ahood skill update` with no arguments walks EVERY locked
|
|
399
|
+
// entry, so a prompt there is never something the caller aimed at. Left
|
|
400
|
+
// interactive, an unattended update would hang on an idle pipe with no
|
|
401
|
+
// timeout -- against this CLI's "must never hang" rule -- or, worse, silently
|
|
402
|
+
// bank the first line of unrelated piped input (`echo y | ahood skill
|
|
403
|
+
// update`) as the credential and fingerprint over it.
|
|
404
|
+
export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
405
|
+
const { existingEnv, allowNonTtyPrompt = true } = options;
|
|
374
406
|
const content = await extractSingleFileContent(buffer, "server.json");
|
|
375
407
|
const manifest = JSON.parse(content.toString("utf-8"));
|
|
376
408
|
// Validate the manifest's shape (unsupported registry_type/runtime_hint,
|
|
@@ -392,9 +424,22 @@ export async function resolveMcpServerConfig(buffer) {
|
|
|
392
424
|
// (per the design spec), which uses `if (process.env.AHOOD_TOKEN)`
|
|
393
425
|
// specifically so an empty-string env var falls through to the real
|
|
394
426
|
// source instead of silently installing an empty secret.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
427
|
+
if (fromEnv) {
|
|
428
|
+
secretEnv[variable.name] = fromEnv;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
// Ordered after the env var deliberately: exporting the variable stays
|
|
432
|
+
// the way to rotate a credential during an update, rather than being
|
|
433
|
+
// shadowed by the stale value already on disk.
|
|
434
|
+
const carriedForward = existingEnv?.[variable.name];
|
|
435
|
+
if (carriedForward) {
|
|
436
|
+
secretEnv[variable.name] = carriedForward;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (!allowNonTtyPrompt && !process.stdin.isTTY) {
|
|
440
|
+
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
|
+
}
|
|
442
|
+
secretEnv[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
|
|
398
443
|
}
|
|
399
444
|
const serverConfig = buildMcpServerConfig(manifest, secretEnv);
|
|
400
445
|
return { manifest, serverConfig };
|
|
@@ -406,13 +451,69 @@ export async function resolveMcpServerConfig(buffer) {
|
|
|
406
451
|
// hand-edited it" -- without this, a real (not just warned-about) removal
|
|
407
452
|
// or update risks silently discarding an intentional local edit, the same
|
|
408
453
|
// class of risk assertNoCollision below exists to prevent on a *fresh*
|
|
409
|
-
// install.
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
454
|
+
// install. Hashes a canonical (recursively key-sorted) serialization rather
|
|
455
|
+
// than raw JSON.stringify output, because .mcp.json is shared with other MCP
|
|
456
|
+
// clients by design (see CLAUDE.md) and so gets rewritten by things that are
|
|
457
|
+
// not ahood -- `jq -S`, a format-on-save plugin, another client's serializer,
|
|
458
|
+
// a user retyping the block -- any of which can reorder keys without changing
|
|
459
|
+
// what the entry means. Fingerprinting insertion order turned every such
|
|
460
|
+
// reformat into a permanent refusal from both `update` and `remove`,
|
|
461
|
+
// recoverable only by hand-deleting the entry and re-entering its secret
|
|
462
|
+
// (ahood-cli#116). Arrays keep their order: `args` is order-significant.
|
|
413
463
|
export function hashMcpServerConfig(config) {
|
|
464
|
+
return createHash("sha256").update(JSON.stringify(canonicalizeForHash(config))).digest("hex");
|
|
465
|
+
}
|
|
466
|
+
function canonicalizeForHash(value) {
|
|
467
|
+
if (Array.isArray(value))
|
|
468
|
+
return value.map(canonicalizeForHash);
|
|
469
|
+
if (typeof value !== "object" || value === null)
|
|
470
|
+
return value;
|
|
471
|
+
const sorted = {};
|
|
472
|
+
for (const key of Object.keys(value).sort()) {
|
|
473
|
+
sorted[key] = canonicalizeForHash(value[key]);
|
|
474
|
+
}
|
|
475
|
+
return sorted;
|
|
476
|
+
}
|
|
477
|
+
// LEGACY (ahood-cli#116): the pre-canonicalization fingerprint format. Every
|
|
478
|
+
// mcp entry pinned by an older CLI carries one of these, so changing the
|
|
479
|
+
// algorithm without a fallback would invalidate all of them at once --
|
|
480
|
+
// inflicting the exact permanent-refusal failure #116 is about on every
|
|
481
|
+
// existing user, on upgrade. Only ever consulted as a second chance after the
|
|
482
|
+
// canonical hash misses; drop this function and matchesMcpConfigHash's
|
|
483
|
+
// fallback branch a few releases on.
|
|
484
|
+
function hashMcpServerConfigLegacy(config) {
|
|
414
485
|
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
415
486
|
}
|
|
487
|
+
// The single "is this on-disk entry still what we recorded?" decision, shared
|
|
488
|
+
// by every comparison site (updateMcpEntry's pre-check and its re-check under
|
|
489
|
+
// lock, remove's read and its re-check under lock). Centralized deliberately:
|
|
490
|
+
// a legacy fallback open-coded at four call sites is a fallback that ends up
|
|
491
|
+
// at three of them. A legacy match needs no separate signal to callers --
|
|
492
|
+
// update rewrites the pin with a canonical hash as part of its normal
|
|
493
|
+
// lockfile write, so such an entry self-heals silently on the next version
|
|
494
|
+
// bump, and remove is deleting the entry anyway.
|
|
495
|
+
export function matchesMcpConfigHash(entry, recordedHash) {
|
|
496
|
+
return hashMcpServerConfig(entry) === recordedHash || hashMcpServerConfigLegacy(entry) === recordedHash;
|
|
497
|
+
}
|
|
498
|
+
// Pulls the `env` map out of an .mcp.json entry read off disk, so an update
|
|
499
|
+
// can carry its secrets forward (see resolveMcpServerConfig's `existingEnv`).
|
|
500
|
+
// Written defensively -- the entry is `unknown` here, and only a string value
|
|
501
|
+
// is usable as a credential -- even though the caller has already fingerprint-
|
|
502
|
+
// verified it, so that a shape this function can't read degrades into a
|
|
503
|
+
// prompt rather than a crash.
|
|
504
|
+
function readMcpEntryEnv(entry) {
|
|
505
|
+
if (typeof entry !== "object" || entry === null)
|
|
506
|
+
return undefined;
|
|
507
|
+
const env = entry.env;
|
|
508
|
+
if (typeof env !== "object" || env === null)
|
|
509
|
+
return undefined;
|
|
510
|
+
const result = {};
|
|
511
|
+
for (const [name, value] of Object.entries(env)) {
|
|
512
|
+
if (typeof value === "string")
|
|
513
|
+
result[name] = value;
|
|
514
|
+
}
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
416
517
|
async function installMcpEntry(owner, skill, meta, buffer) {
|
|
417
518
|
// Check for an existing .mcp.json collision BEFORE resolving secrets --
|
|
418
519
|
// matches resolveMcpServerConfig's own "don't cost the user a prompt for
|
|
@@ -498,12 +599,18 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
498
599
|
if (recordedHash === undefined) {
|
|
499
600
|
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.`);
|
|
500
601
|
}
|
|
501
|
-
if (
|
|
602
|
+
if (!matchesMcpConfigHash(existingOnDisk, recordedHash)) {
|
|
502
603
|
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.`);
|
|
503
604
|
}
|
|
504
605
|
}
|
|
505
606
|
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
506
|
-
|
|
607
|
+
// Reuse the secrets already in the entry rather than re-prompting for
|
|
608
|
+
// them. Safe to trust specifically because the fingerprint check above has
|
|
609
|
+
// just proven this is ahood's own unmodified entry; in the self-heal case
|
|
610
|
+
// (no entry on disk at all) there's nothing to carry forward and a genuine
|
|
611
|
+
// prompt is correct.
|
|
612
|
+
const existingEnv = readMcpEntryEnv(existingOnDisk);
|
|
613
|
+
const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
|
|
507
614
|
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
508
615
|
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
509
616
|
// arbitrary time may have passed since the pre-check -- the download, and
|
|
@@ -514,7 +621,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
514
621
|
const fresh = readMcpConfig();
|
|
515
622
|
const freshServers = fresh.mcpServers;
|
|
516
623
|
const freshExisting = Object.prototype.hasOwnProperty.call(freshServers, skill) ? freshServers[skill] : undefined;
|
|
517
|
-
if (freshExisting !== undefined && (recordedHash === undefined ||
|
|
624
|
+
if (freshExisting !== undefined && (recordedHash === undefined || !matchesMcpConfigHash(freshExisting, recordedHash))) {
|
|
518
625
|
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.`);
|
|
519
626
|
}
|
|
520
627
|
previousOnDisk = freshExisting;
|
|
@@ -529,25 +636,43 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
529
636
|
});
|
|
530
637
|
}
|
|
531
638
|
catch (error) {
|
|
532
|
-
if (!(error instanceof LockfileChecksumConflictError))
|
|
533
|
-
throw error;
|
|
534
639
|
// Roll back the .mcp.json overwrite above under the same lock pattern,
|
|
535
640
|
// restoring the entry to what it was before this update touched it --
|
|
536
641
|
// not deleting it, unlike installMcpEntry's rollback: unless this was
|
|
537
642
|
// the self-heal-a-missing-entry case (previousOnDisk undefined), there
|
|
538
643
|
// WAS a working entry here before this update began.
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
644
|
+
//
|
|
645
|
+
// Runs for EVERY lockfile failure, not just a checksum conflict. A
|
|
646
|
+
// rethrow-before-rollback here left .mcp.json holding the new config
|
|
647
|
+
// while the lockfile kept the old version and old mcp_config_hash, after
|
|
648
|
+
// which the fingerprint never matches again and both `update` and
|
|
649
|
+
// `remove` permanently refuse -- the exact dead end the comments above
|
|
650
|
+
// exist to prevent, reachable from nothing worse than withLock's 5s
|
|
651
|
+
// timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
|
|
652
|
+
try {
|
|
653
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
654
|
+
const fileContents = readMcpConfig();
|
|
655
|
+
const mcpServers = fileContents.mcpServers;
|
|
656
|
+
if (previousOnDisk === undefined) {
|
|
657
|
+
delete mcpServers[skill];
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
mcpServers[skill] = previousOnDisk;
|
|
661
|
+
}
|
|
662
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// The rollback can fail for the same reason the write did (lock still
|
|
667
|
+
// contended, disk still full). Warn rather than throw: the original
|
|
668
|
+
// error below is the more actionable one, and masking it with this
|
|
669
|
+
// one would hide why the update failed in the first place.
|
|
670
|
+
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.`);
|
|
671
|
+
}
|
|
672
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
673
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
674
|
+
}
|
|
675
|
+
throw error;
|
|
551
676
|
}
|
|
552
677
|
console.log(`Updated ${key} to ${meta.version}`);
|
|
553
678
|
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
package/dist/commands/remove.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
|
2
2
|
import { confirm } from "../confirm.js";
|
|
3
3
|
import { readLockfile, removeLockfileEntry, withLock, writeJsonFileAtomic } from "../lockfile.js";
|
|
4
4
|
import { LOCKFILE_PATH, parseOwnerSkill, skillDir, agentPath, MCP_CONFIG_PATH } from "../spec.js";
|
|
5
|
-
import { readMcpConfig,
|
|
5
|
+
import { readMcpConfig, matchesMcpConfigHash } from "./add.js";
|
|
6
6
|
import { UsageError } from "../usage-error.js";
|
|
7
7
|
const USAGE = "Usage: ahood skill remove <owner>/<skill> [--yes]";
|
|
8
8
|
export async function remove(args) {
|
|
@@ -58,6 +58,7 @@ export async function remove(args) {
|
|
|
58
58
|
let removedMcpEntry = false;
|
|
59
59
|
let mcpEntryModified = false;
|
|
60
60
|
let mcpEntryPresent = false;
|
|
61
|
+
let mcpConfigUnreadable;
|
|
61
62
|
let recordedHash;
|
|
62
63
|
try {
|
|
63
64
|
const fileContents = readMcpConfig();
|
|
@@ -65,20 +66,28 @@ export async function remove(args) {
|
|
|
65
66
|
mcpEntryPresent = Object.prototype.hasOwnProperty.call(mcpServers, skill);
|
|
66
67
|
if (mcpEntryPresent) {
|
|
67
68
|
recordedHash = lockEntry?.mcp_config_hash;
|
|
68
|
-
const currentHash = hashMcpServerConfig(mcpServers[skill]);
|
|
69
69
|
if (recordedHash !== undefined) {
|
|
70
|
-
mcpEntryModified = recordedHash
|
|
70
|
+
mcpEntryModified = !matchesMcpConfigHash(mcpServers[skill], recordedHash);
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
-
catch {
|
|
75
|
-
// A malformed .mcp.json isn't this command's problem to fix or
|
|
76
|
-
// on -- add.ts's readMcpConfig is the strict validator for that
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
74
|
+
catch (error) {
|
|
75
|
+
// A malformed .mcp.json still isn't this command's problem to fix or
|
|
76
|
+
// crash on -- add.ts's readMcpConfig is the strict validator for that
|
|
77
|
+
// path. But leaving every flag false here meant the unreadable case
|
|
78
|
+
// printed a bare "Removed" with the entry (and its secret) still live,
|
|
79
|
+
// the very false-assurance bug the block above exists to prevent
|
|
80
|
+
// (ahood-cli#115). The lockfile's mcp_config_hash, read at the top of
|
|
81
|
+
// this function before the pin was cleared, is direct evidence that
|
|
82
|
+
// ahood DID install an entry into this file, so record why the read
|
|
83
|
+
// failed and warn off that instead of off a shape we can't inspect.
|
|
84
|
+
mcpConfigUnreadable = error instanceof Error ? error.message : String(error);
|
|
80
85
|
}
|
|
81
86
|
if (mcpEntryPresent && recordedHash !== undefined && !mcpEntryModified) {
|
|
87
|
+
// Rebound to a const so the `!== undefined` narrowing above survives into
|
|
88
|
+
// the closure below -- `recordedHash` is a `let`, which TypeScript widens
|
|
89
|
+
// back to `string | undefined` inside a callback.
|
|
90
|
+
const verifiedHash = recordedHash;
|
|
82
91
|
try {
|
|
83
92
|
// Re-check under lock rather than trusting the read above -- another
|
|
84
93
|
// process could have changed or removed the entry in between,
|
|
@@ -94,7 +103,7 @@ export async function remove(args) {
|
|
|
94
103
|
const fresh = readMcpConfig();
|
|
95
104
|
const freshServers = fresh.mcpServers;
|
|
96
105
|
if (Object.prototype.hasOwnProperty.call(freshServers, skill) &&
|
|
97
|
-
|
|
106
|
+
matchesMcpConfigHash(freshServers[skill], verifiedHash)) {
|
|
98
107
|
delete freshServers[skill];
|
|
99
108
|
writeJsonFileAtomic(MCP_CONFIG_PATH, fresh);
|
|
100
109
|
removedMcpEntry = true;
|
|
@@ -105,7 +114,24 @@ export async function remove(args) {
|
|
|
105
114
|
// removedMcpEntry stays false -- falls through to the warning below.
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
|
-
if (
|
|
117
|
+
if (mcpConfigUnreadable !== undefined && lockEntry?.mcp_config_hash !== undefined) {
|
|
118
|
+
// Its own wording rather than the branch below's: "remove it manually"
|
|
119
|
+
// isn't actionable while the file doesn't parse, and we can't say the
|
|
120
|
+
// entry is definitely still there -- only that one was installed and
|
|
121
|
+
// we couldn't check (ahood-cli#115).
|
|
122
|
+
// Only readMcpConfig's diagnosis, not the "-- fix or remove it before
|
|
123
|
+
// running this command" advice two of its three messages carry: "remove
|
|
124
|
+
// it" means deleting .mcp.json, which is shared with other MCP clients
|
|
125
|
+
// (see CLAUDE.md), so repeating it here would offer to destroy other
|
|
126
|
+
// servers' config in the same breath as this warning's own, correctly
|
|
127
|
+
// scoped advice (ahood-cli#115).
|
|
128
|
+
const diagnosis = mcpConfigUnreadable.split(" -- ")[0];
|
|
129
|
+
const reason = diagnosis.endsWith(".") ? diagnosis : `${diagnosis}.`;
|
|
130
|
+
console.warn(`WARNING: ${key} may still have an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered), ` +
|
|
131
|
+
`but ahood could not check: ${reason}` +
|
|
132
|
+
` Fix the file, then delete the "${skill}" entry from mcpServers by hand.`);
|
|
133
|
+
}
|
|
134
|
+
else if (mcpEntryPresent && !removedMcpEntry) {
|
|
109
135
|
console.warn(`WARNING: ${key} still has an entry in ${MCP_CONFIG_PATH} (which may contain secrets you entered)` +
|
|
110
136
|
(mcpEntryModified ? " -- it appears to have been modified since install" : "") +
|
|
111
137
|
` -- remove it manually.`);
|
package/dist/commands/snap.js
CHANGED
|
@@ -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