akm-cli 0.3.1 → 0.4.0

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/cli.js CHANGED
@@ -4,13 +4,14 @@ import path from "node:path";
4
4
  import { defineCommand, runMain } from "citty";
5
5
  import { resolveStashDir } from "./common";
6
6
  import { generateBashCompletions, installBashCompletions } from "./completions";
7
- import { DEFAULT_CONFIG, getConfigPath, loadConfig, saveConfig } from "./config";
7
+ import { DEFAULT_CONFIG, getConfigPath, loadConfig, loadUserConfig, saveConfig } from "./config";
8
8
  import { getConfigValue, listConfig, setConfigValue, unsetConfigValue } from "./config-cli";
9
9
  import { closeDatabase, openDatabase } from "./db";
10
10
  import { ConfigError, NotFoundError, UsageError } from "./errors";
11
11
  import { akmIndex } from "./indexer";
12
12
  import { assembleInfo } from "./info";
13
13
  import { akmInit } from "./init";
14
+ import { formatInstallAuditSummary } from "./install-audit";
14
15
  import { akmListSources, akmRemove, akmUpdate } from "./installed-kits";
15
16
  import { getCacheDir, getDbPath, getDefaultStashDir } from "./paths";
16
17
  import { buildRegistryIndex, writeRegistryIndex } from "./registry-build-index";
@@ -338,6 +339,9 @@ function formatPlain(command, result, detail) {
338
339
  case "search": {
339
340
  return formatSearchPlain(r, detail);
340
341
  }
342
+ case "curate": {
343
+ return formatCuratePlain(r, detail);
344
+ }
341
345
  case "list": {
342
346
  const sources = Array.isArray(r.sources) ? r.sources : [];
343
347
  if (sources.length === 0)
@@ -356,7 +360,13 @@ function formatPlain(command, result, detail) {
356
360
  const index = r.index;
357
361
  const scanned = index?.directoriesScanned ?? 0;
358
362
  const total = index?.totalEntries ?? 0;
359
- return `Installed ${r.ref} (${scanned} directories scanned, ${total} total assets indexed)`;
363
+ const lines = [`Installed ${r.ref} (${scanned} directories scanned, ${total} total assets indexed)`];
364
+ const installed = r.installed;
365
+ const audit = installed?.audit;
366
+ if (audit && typeof audit === "object") {
367
+ lines.push(formatInstallAuditSummary(audit));
368
+ }
369
+ return lines.join("\n");
360
370
  }
361
371
  case "remove": {
362
372
  const target = r.target ?? r.ref ?? "";
@@ -464,6 +474,275 @@ function formatSearchPlain(r, detail) {
464
474
  }
465
475
  return lines.join("\n").trimEnd();
466
476
  }
477
+ function formatCuratePlain(r, detail) {
478
+ const query = typeof r.query === "string" ? r.query : "";
479
+ const summary = typeof r.summary === "string" ? r.summary : "";
480
+ const items = Array.isArray(r.items) ? r.items : [];
481
+ const lines = [`Curated results for "${query}"`];
482
+ if (summary)
483
+ lines.push(summary);
484
+ if (items.length === 0) {
485
+ if (r.tip)
486
+ lines.push(String(r.tip));
487
+ return lines.join("\n");
488
+ }
489
+ for (const item of items) {
490
+ const type = typeof item.type === "string" ? item.type : "unknown";
491
+ const name = typeof item.name === "string" ? item.name : "unnamed";
492
+ lines.push("");
493
+ lines.push(`[${type}] ${name}`);
494
+ if (item.description)
495
+ lines.push(` ${String(item.description)}`);
496
+ if (item.preview)
497
+ lines.push(` preview: ${String(item.preview)}`);
498
+ if (item.ref)
499
+ lines.push(` ref: ${String(item.ref)}`);
500
+ if (item.id)
501
+ lines.push(` id: ${String(item.id)}`);
502
+ if (Array.isArray(item.parameters) && item.parameters.length > 0) {
503
+ lines.push(` parameters: ${item.parameters.join(", ")}`);
504
+ }
505
+ if (item.run)
506
+ lines.push(` run: ${String(item.run)}`);
507
+ if (item.followUp)
508
+ lines.push(` show: ${String(item.followUp)}`);
509
+ if (detail !== "brief" && item.reason)
510
+ lines.push(` why: ${String(item.reason)}`);
511
+ }
512
+ const warnings = Array.isArray(r.warnings) ? r.warnings : [];
513
+ if (warnings.length > 0) {
514
+ lines.push("");
515
+ lines.push("Warnings:");
516
+ for (const warning of warnings) {
517
+ lines.push(`- ${String(warning)}`);
518
+ }
519
+ }
520
+ return lines.join("\n");
521
+ }
522
+ const CURATE_FALLBACK_FILTER_WORDS = new Set([
523
+ "a",
524
+ "an",
525
+ "and",
526
+ "for",
527
+ "how",
528
+ "i",
529
+ "in",
530
+ "of",
531
+ "or",
532
+ "the",
533
+ "to",
534
+ "with",
535
+ ]);
536
+ const CURATED_TYPE_FALLBACK_ORDER = ["skill", "command", "script", "knowledge", "agent", "memory"];
537
+ const CURATED_TYPE_FALLBACK_INDEX = new Map(CURATED_TYPE_FALLBACK_ORDER.map((type, index) => [type, index]));
538
+ const MIN_CURATE_FALLBACK_TOKEN_LENGTH = 3;
539
+ const MAX_CURATE_FALLBACK_KEYWORDS = 6;
540
+ const CURATE_SEARCH_LIMIT_MULTIPLIER = 4;
541
+ const MIN_CURATE_SEARCH_LIMIT = 12;
542
+ async function curateSearchResults(query, result, limit, selectedType) {
543
+ const stashHits = result.hits.filter((hit) => hit.type !== "registry");
544
+ const registryHits = result.registryHits ?? [];
545
+ let selectedStashHits;
546
+ if (selectedType && selectedType !== "any") {
547
+ selectedStashHits = stashHits.slice(0, limit);
548
+ }
549
+ else {
550
+ const bestByType = new Map();
551
+ for (const hit of stashHits) {
552
+ if (!bestByType.has(hit.type))
553
+ bestByType.set(hit.type, hit);
554
+ }
555
+ const orderedTypes = orderCuratedTypes(query, Array.from(bestByType.keys()));
556
+ selectedStashHits = orderedTypes
557
+ .map((type) => bestByType.get(type))
558
+ .filter((hit) => Boolean(hit));
559
+ }
560
+ const selectedRegistryHits = selectedStashHits.length >= limit ? [] : registryHits.slice(0, Math.min(2, limit - selectedStashHits.length));
561
+ const items = [
562
+ ...(await Promise.all(selectedStashHits.slice(0, limit).map((hit) => enrichCuratedStashHit(query, hit)))),
563
+ ...selectedRegistryHits.map((hit) => buildCuratedRegistryItem(query, hit)),
564
+ ].slice(0, limit);
565
+ return {
566
+ query,
567
+ summary: buildCurateSummary(query, items),
568
+ items,
569
+ ...(result.warnings?.length ? { warnings: result.warnings } : {}),
570
+ ...(result.tip ? { tip: result.tip } : {}),
571
+ };
572
+ }
573
+ function orderCuratedTypes(query, types) {
574
+ const lower = query.toLowerCase();
575
+ const boosts = new Map();
576
+ const addBoost = (type, amount) => boosts.set(type, (boosts.get(type) ?? 0) + amount);
577
+ if (/(run|script|bash|shell|cli|execute|automation|deploy|build|test|lint)/.test(lower)) {
578
+ addBoost("script", 6);
579
+ addBoost("command", 4);
580
+ }
581
+ if (/(guide|docs?|readme|reference|how|explain|learn|why)/.test(lower)) {
582
+ addBoost("knowledge", 6);
583
+ addBoost("skill", 4);
584
+ }
585
+ if (/(agent|assistant|planner|review|analy[sz]e|architect|prompt)/.test(lower)) {
586
+ addBoost("agent", 6);
587
+ addBoost("skill", 3);
588
+ }
589
+ if (/(config|template|release|generate|command)/.test(lower)) {
590
+ addBoost("command", 5);
591
+ }
592
+ if (/(memory|context|recall|remember)/.test(lower)) {
593
+ addBoost("memory", 6);
594
+ }
595
+ return [...types].sort((a, b) => {
596
+ const boostDiff = (boosts.get(b) ?? 0) - (boosts.get(a) ?? 0);
597
+ if (boostDiff !== 0)
598
+ return boostDiff;
599
+ return ((CURATED_TYPE_FALLBACK_INDEX.get(a) ?? Number.MAX_SAFE_INTEGER) -
600
+ (CURATED_TYPE_FALLBACK_INDEX.get(b) ?? Number.MAX_SAFE_INTEGER));
601
+ });
602
+ }
603
+ async function enrichCuratedStashHit(query, hit) {
604
+ let shown;
605
+ try {
606
+ shown = await akmShowUnified({ ref: hit.ref });
607
+ }
608
+ catch {
609
+ shown = undefined;
610
+ }
611
+ const description = shown?.description ?? hit.description;
612
+ const preview = buildCuratedPreview(shown, hit);
613
+ return {
614
+ source: "stash",
615
+ type: shown?.type ?? hit.type,
616
+ name: shown?.name ?? hit.name,
617
+ ref: hit.ref,
618
+ ...(description ? { description } : {}),
619
+ ...(preview ? { preview } : {}),
620
+ ...(shown?.parameters?.length ? { parameters: shown.parameters } : {}),
621
+ ...(shown?.run ? { run: shown.run } : {}),
622
+ followUp: `akm show ${hit.ref}`,
623
+ reason: buildCuratedReason(query, shown?.type ?? hit.type),
624
+ ...(hit.score !== undefined ? { score: hit.score } : {}),
625
+ };
626
+ }
627
+ function buildCuratedRegistryItem(query, hit) {
628
+ return {
629
+ source: "registry",
630
+ type: "registry",
631
+ name: hit.name,
632
+ id: hit.id,
633
+ ...(hit.description ? { description: hit.description } : {}),
634
+ followUp: hit.action ?? `akm add ${hit.id}`,
635
+ reason: `Useful external source to explore for ${query}.`,
636
+ ...(hit.score !== undefined ? { score: hit.score } : {}),
637
+ };
638
+ }
639
+ function firstNonEmpty(values) {
640
+ return values.find((value) => typeof value === "string" && value.trim().length > 0);
641
+ }
642
+ function buildCuratedPreview(shown, hit) {
643
+ if (shown?.run)
644
+ return truncateDescription(`run ${shown.run}`, 160);
645
+ const payload = firstNonEmpty([shown?.template, shown?.prompt, shown?.content, hit.description])
646
+ ?.replace(/\s+/g, " ")
647
+ .trim();
648
+ return payload ? truncateDescription(payload, 160) : undefined;
649
+ }
650
+ function buildCuratedReason(query, type) {
651
+ switch (type) {
652
+ case "script":
653
+ return `Best runnable script match for "${query}".`;
654
+ case "command":
655
+ return `Best reusable command/template match for "${query}".`;
656
+ case "knowledge":
657
+ return `Best reference document match for "${query}".`;
658
+ case "skill":
659
+ return `Best instructions/workflow match for "${query}".`;
660
+ case "agent":
661
+ return `Best specialized agent prompt match for "${query}".`;
662
+ case "memory":
663
+ return `Best saved context match for "${query}".`;
664
+ default:
665
+ return `Best ${type} match for "${query}".`;
666
+ }
667
+ }
668
+ function buildCurateSummary(query, items) {
669
+ if (items.length === 0) {
670
+ return `No curated assets were selected for "${query}".`;
671
+ }
672
+ const labels = items.map((item) => `${item.type}:${item.name}`);
673
+ return `Selected ${items.length} high-signal result${items.length === 1 ? "" : "s"}: ${labels.join(", ")}.`;
674
+ }
675
+ function hasSearchResults(result) {
676
+ return result.hits.length > 0 || (result.registryHits?.length ?? 0) > 0;
677
+ }
678
+ /**
679
+ * Extract a small set of fallback keywords when a prompt-style curate query
680
+ * returns no hits as a whole phrase.
681
+ *
682
+ * We keep up to MAX_CURATE_FALLBACK_KEYWORDS distinct keywords and drop short
683
+ * or common filler words so follow-up searches stay inexpensive while focusing
684
+ * on higher-signal terms.
685
+ */
686
+ function deriveCurateFallbackQueries(query) {
687
+ return Array.from(new Set(query
688
+ .toLowerCase()
689
+ .split(/[^a-z0-9]+/)
690
+ .map((token) => token.trim())
691
+ // Keep longer tokens so fallback stays focused on higher-signal terms
692
+ // and avoids broad one- and two-letter matches that overwhelm curation.
693
+ .filter((token) => token.length >= MIN_CURATE_FALLBACK_TOKEN_LENGTH && !CURATE_FALLBACK_FILTER_WORDS.has(token)))).slice(0, MAX_CURATE_FALLBACK_KEYWORDS);
694
+ }
695
+ function mergeCurateSearchResponses(base, extras) {
696
+ const hitsByRef = new Map();
697
+ for (const hit of base.hits.filter((entry) => entry.type !== "registry")) {
698
+ hitsByRef.set(hit.ref, hit);
699
+ }
700
+ for (const result of extras) {
701
+ for (const hit of result.hits.filter((entry) => entry.type !== "registry")) {
702
+ const existing = hitsByRef.get(hit.ref);
703
+ if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
704
+ hitsByRef.set(hit.ref, hit);
705
+ }
706
+ }
707
+ }
708
+ const registryById = new Map();
709
+ for (const hit of base.registryHits ?? []) {
710
+ registryById.set(hit.id, hit);
711
+ }
712
+ for (const result of extras) {
713
+ for (const hit of result.registryHits ?? []) {
714
+ const existing = registryById.get(hit.id);
715
+ if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
716
+ registryById.set(hit.id, hit);
717
+ }
718
+ }
719
+ }
720
+ const warnings = Array.from(new Set([...(base.warnings ?? []), ...extras.flatMap((result) => result.warnings ?? [])]));
721
+ const mergedHits = [...hitsByRef.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
722
+ const mergedRegistryHits = [...registryById.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
723
+ return {
724
+ ...base,
725
+ hits: mergedHits,
726
+ ...(mergedRegistryHits.length > 0 ? { registryHits: mergedRegistryHits } : {}),
727
+ ...(warnings.length > 0 ? { warnings } : {}),
728
+ ...(mergedHits.length > 0 || mergedRegistryHits.length > 0 ? { tip: undefined } : {}),
729
+ };
730
+ }
731
+ async function searchForCuration(input) {
732
+ const initial = await akmSearch(input);
733
+ if (hasSearchResults(initial))
734
+ return initial;
735
+ const fallbackQueries = deriveCurateFallbackQueries(input.query);
736
+ if (fallbackQueries.length <= 1)
737
+ return initial;
738
+ const fallbackResults = await Promise.all(fallbackQueries.map((token) => akmSearch({
739
+ query: token,
740
+ type: input.type,
741
+ limit: input.limit,
742
+ source: input.source,
743
+ })));
744
+ return mergeCurateSearchResponses(initial, fallbackResults);
745
+ }
467
746
  /**
468
747
  * Module Naming:
469
748
  * - stash-* : Asset operations (search, show, add, clone)
@@ -550,6 +829,39 @@ const searchCommand = defineCommand({
550
829
  });
551
830
  },
552
831
  });
832
+ const curateCommand = defineCommand({
833
+ meta: { name: "curate", description: "Curate the best matching assets for a task or prompt" },
834
+ args: {
835
+ query: { type: "positional", description: "Task or prompt to curate assets for", required: true },
836
+ type: {
837
+ type: "string",
838
+ description: "Asset type filter (e.g. skill, command, agent, knowledge, script, memory, or any).",
839
+ },
840
+ limit: { type: "string", description: "Maximum number of curated results", default: "4" },
841
+ source: { type: "string", description: "Search source (stash|registry|both)", default: "stash" },
842
+ },
843
+ async run({ args }) {
844
+ await runWithJsonErrors(async () => {
845
+ const type = args.type;
846
+ const limitRaw = args.limit ? parseInt(args.limit, 10) : undefined;
847
+ if (limitRaw !== undefined && Number.isNaN(limitRaw)) {
848
+ throw new UsageError(`Invalid --limit value: "${args.limit}". Must be a positive integer.`);
849
+ }
850
+ const limit = limitRaw && limitRaw > 0 ? limitRaw : 4;
851
+ const source = parseSearchSource(args.source ?? "stash");
852
+ const searchResult = await searchForCuration({
853
+ query: args.query,
854
+ type,
855
+ // Search deeper than the final curated count so we can pick one strong
856
+ // match per type and still have room for fallback retries.
857
+ limit: Math.max(limit * CURATE_SEARCH_LIMIT_MULTIPLIER, MIN_CURATE_SEARCH_LIMIT),
858
+ source,
859
+ });
860
+ const curated = await curateSearchResults(args.query, searchResult, limit, type);
861
+ output("curate", curated);
862
+ });
863
+ },
864
+ });
553
865
  const addCommand = defineCommand({
554
866
  meta: {
555
867
  name: "add",
@@ -826,7 +1138,7 @@ const configCommand = defineCommand({
826
1138
  },
827
1139
  run({ args }) {
828
1140
  return runWithJsonErrors(() => {
829
- const updated = setConfigValue(loadConfig(), args.key, args.value);
1141
+ const updated = setConfigValue(loadUserConfig(), args.key, args.value);
830
1142
  saveConfig(updated);
831
1143
  output("config", listConfig(updated));
832
1144
  });
@@ -839,7 +1151,7 @@ const configCommand = defineCommand({
839
1151
  },
840
1152
  run({ args }) {
841
1153
  return runWithJsonErrors(() => {
842
- const updated = unsetConfigValue(loadConfig(), args.key);
1154
+ const updated = unsetConfigValue(loadUserConfig(), args.key);
843
1155
  saveConfig(updated);
844
1156
  output("config", listConfig(updated));
845
1157
  });
@@ -888,7 +1200,7 @@ const registryCommand = defineCommand({
888
1200
  meta: { name: "list", description: "List configured registries" },
889
1201
  run() {
890
1202
  return runWithJsonErrors(() => {
891
- const config = loadConfig();
1203
+ const config = loadUserConfig();
892
1204
  const registries = config.registries ?? DEFAULT_CONFIG.registries;
893
1205
  output("registry-list", { registries });
894
1206
  });
@@ -910,7 +1222,7 @@ const registryCommand = defineCommand({
910
1222
  if (args.url.startsWith("http://")) {
911
1223
  warn("Warning: registry URL uses plain HTTP (not HTTPS). For security, prefer https:// to protect against eavesdropping and tampering.");
912
1224
  }
913
- const config = loadConfig();
1225
+ const config = loadUserConfig();
914
1226
  const registries = [...(config.registries ?? [])];
915
1227
  // Deduplicate by URL
916
1228
  if (registries.some((r) => r.url === args.url)) {
@@ -943,7 +1255,7 @@ const registryCommand = defineCommand({
943
1255
  },
944
1256
  run({ args }) {
945
1257
  return runWithJsonErrors(() => {
946
- const config = loadConfig();
1258
+ const config = loadUserConfig();
947
1259
  const registries = [...(config.registries ?? [])];
948
1260
  const idx = registries.findIndex((r) => r.url === args.target || r.name === args.target);
949
1261
  if (idx === -1) {
@@ -1111,6 +1423,7 @@ const main = defineCommand({
1111
1423
  update: updateCommand,
1112
1424
  upgrade: upgradeCommand,
1113
1425
  search: searchCommand,
1426
+ curate: curateCommand,
1114
1427
  show: showCommand,
1115
1428
  clone: cloneCommand,
1116
1429
  registry: registryCommand,
@@ -1275,6 +1588,7 @@ You have access to a searchable library of scripts, skills, commands, agents, an
1275
1588
 
1276
1589
  \`\`\`sh
1277
1590
  akm search "<query>" # Search all sources
1591
+ akm curate "<task>" # Curate the best matches for a task
1278
1592
  akm search "<query>" --type skill # Filter by type
1279
1593
  akm search "<query>" --source both # Also search registries
1280
1594
  akm show <ref> # View asset details
@@ -1303,6 +1617,7 @@ You have access to a searchable library of scripts, skills, commands, agents, an
1303
1617
 
1304
1618
  \`\`\`sh
1305
1619
  akm search "<query>" # Search all sources
1620
+ akm curate "<task>" # Curate the best matches for a task
1306
1621
  akm search "<query>" --type skill # Filter by asset type
1307
1622
  akm search "<query>" --source both # Also search registries
1308
1623
  akm search "<query>" --source registry # Search registries only
@@ -1319,6 +1634,16 @@ akm search "<query>" --detail full # Include scores, paths, timing
1319
1634
  | \`--detail\` | \`brief\`, \`normal\`, \`full\`, \`summary\` | \`brief\` |
1320
1635
  | \`--for-agent\` | boolean | \`false\` |
1321
1636
 
1637
+ ## Curate
1638
+
1639
+ Combine search + follow-up hints into a dense summary for a task or prompt.
1640
+
1641
+ \`\`\`sh
1642
+ akm curate "plan a release" # Pick top matches across asset types
1643
+ akm curate "deploy a Bun app" --limit 3 # Keep the summary shorter
1644
+ akm curate "review architecture" --type skill # Restrict to one asset type
1645
+ \`\`\`
1646
+
1322
1647
  ## Show
1323
1648
 
1324
1649
  Display an asset by ref. Knowledge assets support view modes as positional arguments.
package/dist/common.js CHANGED
@@ -8,6 +8,11 @@ export const IS_WINDOWS = process.platform === "win32";
8
8
  export function isHttpUrl(value) {
9
9
  return !!value && /^https?:\/\//.test(value);
10
10
  }
11
+ export function filterNonEmptyStrings(value) {
12
+ if (!Array.isArray(value))
13
+ return undefined;
14
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
15
+ }
11
16
  // ── Validators ──────────────────────────────────────────────────────────────
12
17
  export function isAssetType(type) {
13
18
  return Object.hasOwn(TYPE_DIRS, type);
@@ -26,6 +26,16 @@ export function parseConfigValue(key, value) {
26
26
  return { output: { format: parseOutputFormat(value) } };
27
27
  case "output.detail":
28
28
  return { output: { detail: parseOutputDetail(value) } };
29
+ case "security.installAudit.enabled":
30
+ return { security: { installAudit: { enabled: parseBooleanValue(value, key) } } };
31
+ case "security.installAudit.blockOnCritical":
32
+ return { security: { installAudit: { blockOnCritical: parseBooleanValue(value, key) } } };
33
+ case "security.installAudit.blockUnlistedRegistries":
34
+ return { security: { installAudit: { blockUnlistedRegistries: parseBooleanValue(value, key) } } };
35
+ case "security.installAudit.registryAllowlist":
36
+ return { security: { installAudit: { registryAllowlist: parseStringArrayValue(value, key) } } };
37
+ case "security.installAudit.registryWhitelist":
38
+ return { security: { installAudit: { registryAllowlist: parseStringArrayValue(value, key) } } };
29
39
  default:
30
40
  throw new UsageError(`Unknown config key: ${key}`);
31
41
  }
@@ -48,6 +58,18 @@ export function getConfigValue(config, key) {
48
58
  return config.output?.format ?? null;
49
59
  case "output.detail":
50
60
  return config.output?.detail ?? null;
61
+ case "security":
62
+ return config.security ?? null;
63
+ case "security.installAudit.enabled":
64
+ return config.security?.installAudit?.enabled ?? null;
65
+ case "security.installAudit.blockOnCritical":
66
+ return config.security?.installAudit?.blockOnCritical ?? null;
67
+ case "security.installAudit.blockUnlistedRegistries":
68
+ return config.security?.installAudit?.blockUnlistedRegistries ?? null;
69
+ case "security.installAudit.registryAllowlist":
70
+ return getInstallAuditAllowlist(config);
71
+ case "security.installAudit.registryWhitelist":
72
+ return getInstallAuditAllowlist(config);
51
73
  default:
52
74
  throw new UsageError(`Unknown config key: ${key}`);
53
75
  }
@@ -62,6 +84,11 @@ export function setConfigValue(config, key, rawValue) {
62
84
  case "stashes":
63
85
  case "output.format":
64
86
  case "output.detail":
87
+ case "security.installAudit.enabled":
88
+ case "security.installAudit.blockOnCritical":
89
+ case "security.installAudit.blockUnlistedRegistries":
90
+ case "security.installAudit.registryAllowlist":
91
+ case "security.installAudit.registryWhitelist":
65
92
  return mergeConfigValue(config, parseConfigValue(key, rawValue));
66
93
  default:
67
94
  throw new UsageError(`Unknown config key: ${key}`);
@@ -83,6 +110,28 @@ export function unsetConfigValue(config, key) {
83
110
  return { ...config, output: mergeOutputConfig(config.output, { format: undefined }) };
84
111
  case "output.detail":
85
112
  return { ...config, output: mergeOutputConfig(config.output, { detail: undefined }) };
113
+ case "security":
114
+ return { ...config, security: undefined };
115
+ case "security.installAudit.enabled":
116
+ return { ...config, security: mergeSecurityConfig(config.security, { installAudit: { enabled: undefined } }) };
117
+ case "security.installAudit.blockOnCritical":
118
+ return {
119
+ ...config,
120
+ security: mergeSecurityConfig(config.security, { installAudit: { blockOnCritical: undefined } }),
121
+ };
122
+ case "security.installAudit.blockUnlistedRegistries":
123
+ return {
124
+ ...config,
125
+ security: mergeSecurityConfig(config.security, { installAudit: { blockUnlistedRegistries: undefined } }),
126
+ };
127
+ case "security.installAudit.registryAllowlist":
128
+ case "security.installAudit.registryWhitelist":
129
+ return {
130
+ ...config,
131
+ security: mergeSecurityConfig(config.security, {
132
+ installAudit: { registryAllowlist: undefined, registryWhitelist: undefined },
133
+ }),
134
+ };
86
135
  default:
87
136
  throw new UsageError(`Unknown or unsupported unset key: ${key}`);
88
137
  }
@@ -100,6 +149,8 @@ export function listConfig(config) {
100
149
  result.embedding = config.embedding;
101
150
  if (config.llm)
102
151
  result.llm = config.llm;
152
+ if (config.security)
153
+ result.security = config.security;
103
154
  return result;
104
155
  }
105
156
  function mergeConfigValue(config, partial) {
@@ -107,6 +158,7 @@ function mergeConfigValue(config, partial) {
107
158
  ...config,
108
159
  ...partial,
109
160
  output: mergeOutputConfig(config.output, partial.output),
161
+ security: mergeSecurityConfig(config.security, partial.security),
110
162
  };
111
163
  }
112
164
  function mergeOutputConfig(base, override) {
@@ -116,6 +168,18 @@ function mergeOutputConfig(base, override) {
116
168
  };
117
169
  return merged.format || merged.detail ? merged : undefined;
118
170
  }
171
+ function mergeSecurityConfig(base, override) {
172
+ const mergedInstallAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
173
+ return mergedInstallAudit ? { installAudit: mergedInstallAudit } : undefined;
174
+ }
175
+ function mergeInstallAuditConfig(base, override) {
176
+ const merged = {
177
+ ...(base ?? {}),
178
+ ...(override ?? {}),
179
+ };
180
+ const hasValue = Object.values(merged).some((value) => value !== undefined);
181
+ return hasValue ? merged : undefined;
182
+ }
119
183
  function parseOutputFormat(value) {
120
184
  if (value === "json" || value === "yaml" || value === "text")
121
185
  return value;
@@ -126,6 +190,29 @@ function parseOutputDetail(value) {
126
190
  return value;
127
191
  throw new UsageError(`Invalid value for output.detail: expected one of brief|normal|full`);
128
192
  }
193
+ function parseBooleanValue(value, key) {
194
+ if (value === "true")
195
+ return true;
196
+ if (value === "false")
197
+ return false;
198
+ throw new UsageError(`Invalid value for ${key}: expected true or false`);
199
+ }
200
+ function parseStringArrayValue(value, key) {
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(value);
204
+ }
205
+ catch {
206
+ throw new UsageError(`Invalid value for ${key}: expected a JSON array of strings`);
207
+ }
208
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
209
+ throw new UsageError(`Invalid value for ${key}: expected a JSON array of strings`);
210
+ }
211
+ return parsed;
212
+ }
213
+ function getInstallAuditAllowlist(config) {
214
+ return config.security?.installAudit?.registryAllowlist ?? config.security?.installAudit?.registryWhitelist ?? null;
215
+ }
129
216
  function parseRegistriesValue(value) {
130
217
  if (value === "null" || value === "")
131
218
  return undefined;