akm-cli 0.4.1 → 0.5.0-rc2

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.
@@ -4,8 +4,8 @@ import path from "node:path";
4
4
  import { getAssetTypes } from "./asset-spec";
5
5
  // ── Known flag values ────────────────────────────────────────────────────────
6
6
  const FLAG_VALUES = {
7
- "--format": ["json", "text", "yaml"],
8
- "--detail": ["brief", "normal", "full"],
7
+ "--format": ["json", "text", "yaml", "jsonl"],
8
+ "--detail": ["brief", "normal", "full", "summary"],
9
9
  "--type": () => [...getAssetTypes(), "any"],
10
10
  "--source": ["stash", "registry", "both"],
11
11
  "--shell": ["bash"],
@@ -36,6 +36,8 @@ export function parseConfigValue(key, value) {
36
36
  return { security: { installAudit: { registryAllowlist: parseStringArrayValue(value, key) } } };
37
37
  case "security.installAudit.registryWhitelist":
38
38
  return { security: { installAudit: { registryAllowlist: parseStringArrayValue(value, key) } } };
39
+ case "security.installAudit.allowedFindings":
40
+ return { security: { installAudit: { allowedFindings: parseAllowedFindingsValue(value, key) } } };
39
41
  default:
40
42
  throw new UsageError(`Unknown config key: ${key}`);
41
43
  }
@@ -70,6 +72,8 @@ export function getConfigValue(config, key) {
70
72
  return getInstallAuditAllowlist(config);
71
73
  case "security.installAudit.registryWhitelist":
72
74
  return getInstallAuditAllowlist(config);
75
+ case "security.installAudit.allowedFindings":
76
+ return config.security?.installAudit?.allowedFindings ?? null;
73
77
  default:
74
78
  throw new UsageError(`Unknown config key: ${key}`);
75
79
  }
@@ -89,6 +93,7 @@ export function setConfigValue(config, key, rawValue) {
89
93
  case "security.installAudit.blockUnlistedRegistries":
90
94
  case "security.installAudit.registryAllowlist":
91
95
  case "security.installAudit.registryWhitelist":
96
+ case "security.installAudit.allowedFindings":
92
97
  return mergeConfigValue(config, parseConfigValue(key, rawValue));
93
98
  default:
94
99
  throw new UsageError(`Unknown config key: ${key}`);
@@ -132,6 +137,13 @@ export function unsetConfigValue(config, key) {
132
137
  installAudit: { registryAllowlist: undefined, registryWhitelist: undefined },
133
138
  }),
134
139
  };
140
+ case "security.installAudit.allowedFindings":
141
+ return {
142
+ ...config,
143
+ security: mergeSecurityConfig(config.security, {
144
+ installAudit: { allowedFindings: undefined },
145
+ }),
146
+ };
135
147
  default:
136
148
  throw new UsageError(`Unknown or unsupported unset key: ${key}`);
137
149
  }
@@ -213,6 +225,35 @@ function parseStringArrayValue(value, key) {
213
225
  function getInstallAuditAllowlist(config) {
214
226
  return config.security?.installAudit?.registryAllowlist ?? config.security?.installAudit?.registryWhitelist ?? null;
215
227
  }
228
+ function parseAllowedFindingsValue(value, key) {
229
+ let parsed;
230
+ try {
231
+ parsed = JSON.parse(value);
232
+ }
233
+ catch {
234
+ throw new UsageError(`Invalid value for ${key}: expected a JSON array of {id, ref?, path?, reason?} objects`);
235
+ }
236
+ if (!Array.isArray(parsed)) {
237
+ throw new UsageError(`Invalid value for ${key}: expected a JSON array`);
238
+ }
239
+ return parsed.map((entry, i) => {
240
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
241
+ throw new UsageError(`Invalid value for ${key}[${i}]: expected an object with an "id" field`);
242
+ }
243
+ const obj = entry;
244
+ if (typeof obj.id !== "string" || !obj.id) {
245
+ throw new UsageError(`Invalid value for ${key}[${i}]: "id" is required`);
246
+ }
247
+ const result = { id: obj.id };
248
+ if (typeof obj.ref === "string" && obj.ref)
249
+ result.ref = obj.ref;
250
+ if (typeof obj.path === "string" && obj.path)
251
+ result.path = obj.path;
252
+ if (typeof obj.reason === "string" && obj.reason)
253
+ result.reason = obj.reason;
254
+ return result;
255
+ });
256
+ }
216
257
  function parseRegistriesValue(value) {
217
258
  if (value === "null" || value === "")
218
259
  return undefined;
package/dist/config.js CHANGED
@@ -187,6 +187,9 @@ function pickKnownKeys(raw) {
187
187
  const output = parseOutputConfig(raw.output);
188
188
  if (output)
189
189
  config.output = output;
190
+ if (typeof raw.writable === "boolean") {
191
+ config.writable = raw.writable;
192
+ }
190
193
  return config;
191
194
  }
192
195
  function readNormalizedConfig(configPath) {
@@ -401,6 +404,24 @@ function parseLlmConfig(value) {
401
404
  if (typeof obj.apiKey === "string" && obj.apiKey) {
402
405
  result.apiKey = obj.apiKey;
403
406
  }
407
+ if (typeof obj.contextWindow === "number" &&
408
+ Number.isFinite(obj.contextWindow) &&
409
+ Number.isInteger(obj.contextWindow) &&
410
+ obj.contextWindow > 0) {
411
+ result.contextWindow = obj.contextWindow;
412
+ }
413
+ if (typeof obj.capabilities === "object" && obj.capabilities !== null && !Array.isArray(obj.capabilities)) {
414
+ const capsRaw = obj.capabilities;
415
+ const caps = {};
416
+ if (typeof capsRaw.structuredOutput === "boolean")
417
+ caps.structuredOutput = capsRaw.structuredOutput;
418
+ if (typeof capsRaw.longContext === "boolean")
419
+ caps.longContext = capsRaw.longContext;
420
+ if (typeof capsRaw.toolUse === "boolean")
421
+ caps.toolUse = capsRaw.toolUse;
422
+ if (Object.keys(caps).length > 0)
423
+ result.capabilities = caps;
424
+ }
404
425
  return result;
405
426
  }
406
427
  function parseInstalledEntries(value) {
@@ -433,12 +454,17 @@ function parseInstalledKitEntry(value) {
433
454
  cacheDir,
434
455
  installedAt,
435
456
  };
457
+ if (typeof obj.writable === "boolean")
458
+ entry.writable = obj.writable;
436
459
  const resolvedVersion = asNonEmptyString(obj.resolvedVersion);
437
460
  if (resolvedVersion)
438
461
  entry.resolvedVersion = resolvedVersion;
439
462
  const resolvedRevision = asNonEmptyString(obj.resolvedRevision);
440
463
  if (resolvedRevision)
441
464
  entry.resolvedRevision = resolvedRevision;
465
+ const wikiName = asNonEmptyString(obj.wikiName);
466
+ if (wikiName)
467
+ entry.wikiName = wikiName;
442
468
  return entry;
443
469
  }
444
470
  function asNonEmptyString(value) {
@@ -491,8 +517,39 @@ function parseInstallAuditConfig(value) {
491
517
  if (rawAllowlist) {
492
518
  config.registryAllowlist = rawAllowlist;
493
519
  }
520
+ const allowedFindings = parseInstallAuditAllowedFindings(obj.allowedFindings);
521
+ if (allowedFindings) {
522
+ config.allowedFindings = allowedFindings;
523
+ }
494
524
  return Object.keys(config).length > 0 ? config : undefined;
495
525
  }
526
+ function parseInstallAuditAllowedFindings(value) {
527
+ if (!Array.isArray(value))
528
+ return undefined;
529
+ const findings = value
530
+ .map((entry) => parseInstallAuditAllowedFinding(entry))
531
+ .filter((entry) => entry !== undefined);
532
+ return findings.length > 0 ? findings : undefined;
533
+ }
534
+ function parseInstallAuditAllowedFinding(value) {
535
+ if (typeof value !== "object" || value === null || Array.isArray(value))
536
+ return undefined;
537
+ const obj = value;
538
+ const id = asNonEmptyString(obj.id);
539
+ if (!id)
540
+ return undefined;
541
+ const finding = { id };
542
+ const ref = asNonEmptyString(obj.ref);
543
+ if (ref)
544
+ finding.ref = ref;
545
+ const entryPath = asNonEmptyString(obj.path);
546
+ if (entryPath)
547
+ finding.path = entryPath;
548
+ const reason = asNonEmptyString(obj.reason);
549
+ if (reason)
550
+ finding.reason = reason;
551
+ return finding;
552
+ }
496
553
  function parseStashConfigEntry(value) {
497
554
  if (typeof value !== "object" || value === null || Array.isArray(value))
498
555
  return undefined;
@@ -512,9 +569,14 @@ function parseStashConfigEntry(value) {
512
569
  entry.name = name;
513
570
  if (typeof obj.enabled === "boolean")
514
571
  entry.enabled = obj.enabled;
572
+ if (typeof obj.writable === "boolean")
573
+ entry.writable = obj.writable;
515
574
  if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
516
575
  entry.options = obj.options;
517
576
  }
577
+ const wikiName = asNonEmptyString(obj.wikiName);
578
+ if (wikiName)
579
+ entry.wikiName = wikiName;
518
580
  return entry;
519
581
  }
520
582
  function parseRegistryConfigEntry(value) {
@@ -155,10 +155,11 @@ export async function runMatchers(ctx) {
155
155
  * Build a RenderContext by merging a FileContext with its winning MatchResult
156
156
  * and the list of stash search paths.
157
157
  */
158
- export function buildRenderContext(ctx, match, stashDirs) {
158
+ export function buildRenderContext(ctx, match, stashDirs, origin) {
159
159
  return {
160
160
  ...ctx,
161
161
  matchResult: match,
162
162
  stashDirs,
163
+ origin,
163
164
  };
164
165
  }
package/dist/github.js CHANGED
@@ -1,5 +1,24 @@
1
+ import * as childProcess from "node:child_process";
1
2
  export const GITHUB_API_BASE = "https://api.github.com";
2
3
  const GITHUB_TOKEN_DOMAINS = new Set(["api.github.com", "github.com", "uploads.github.com"]);
4
+ function readGithubTokenFromEnv() {
5
+ const token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
6
+ return token || undefined;
7
+ }
8
+ function readGithubTokenFromGhCli() {
9
+ const result = childProcess.spawnSync("gh", ["auth", "token"], {
10
+ encoding: "utf8",
11
+ timeout: 5_000,
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ });
14
+ if (result.status !== 0)
15
+ return undefined;
16
+ const token = result.stdout.trim();
17
+ return token || undefined;
18
+ }
19
+ function resolveGithubToken() {
20
+ return readGithubTokenFromEnv() ?? readGithubTokenFromGhCli();
21
+ }
3
22
  /**
4
23
  * Build headers for GitHub API requests.
5
24
  * When a `url` is provided, the Authorization header is only included if the
@@ -7,7 +26,7 @@ const GITHUB_TOKEN_DOMAINS = new Set(["api.github.com", "github.com", "uploads.g
7
26
  * to third-party hosts.
8
27
  */
9
28
  export function githubHeaders(url) {
10
- const token = process.env.GITHUB_TOKEN?.trim();
29
+ const token = resolveGithubToken();
11
30
  const headers = {
12
31
  Accept: "application/vnd.github+json",
13
32
  "User-Agent": "akm-registry",
package/dist/indexer.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isHttpUrl, resolveStashDir } from "./common";
4
4
  import { closeDatabase, deleteEntriesByDir, deleteEntriesByStashDir, getEmbeddingCount, getEntriesByDir, getEntryCount, getMeta, isVecAvailable, openDatabase, rebuildFts, setMeta, upsertEmbedding, upsertEntry, upsertUtilityScore, warnIfVecMissing, } from "./db";
5
- import { generateMetadataFlat, loadStashFile } from "./metadata";
5
+ import { generateMetadataFlat, loadStashFile, shouldIndexStashFile } from "./metadata";
6
6
  import { getDbPath } from "./paths";
7
7
  import { buildSearchText } from "./search-fields";
8
8
  import { classifySemanticFailure, clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "./semantic-status";
@@ -18,9 +18,10 @@ export async function akmIndex(options) {
18
18
  const config = loadConfig();
19
19
  // Ensure git stash caches are extracted before resolving stash dirs,
20
20
  // so their content directories exist on disk for the walker to discover.
21
- const { ensureStashCaches, resolveAllStashDirs } = await import("./search-source.js");
21
+ const { ensureStashCaches, resolveStashSources } = await import("./search-source.js");
22
22
  await ensureStashCaches(config);
23
- const allStashDirs = resolveAllStashDirs(stashDir);
23
+ const allStashSources = resolveStashSources(stashDir, config);
24
+ const allStashDirs = allStashSources.map((s) => s.path);
24
25
  const t0 = Date.now();
25
26
  // Open database — pass embedding dimension from config if available
26
27
  const dbPath = getDbPath();
@@ -79,7 +80,7 @@ export async function akmIndex(options) {
79
80
  // doFullDelete=true merges the wipe into the same transaction as the
80
81
  // inserts so readers never see an empty database mid-rebuild.
81
82
  const doFullDelete = options?.full || !isIncremental;
82
- const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm } = await indexEntries(db, allStashDirs, isIncremental, builtAtMs, doFullDelete);
83
+ const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm } = await indexEntries(db, allStashSources, isIncremental, builtAtMs, doFullDelete);
83
84
  onProgress({
84
85
  phase: "scan",
85
86
  message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
@@ -117,6 +118,18 @@ export async function akmIndex(options) {
117
118
  }
118
119
  // Recompute utility scores from usage_events after FTS rebuild
119
120
  recomputeUtilityScores(db);
121
+ // Regenerate each wiki's index.md from its pages' frontmatter. Best-effort
122
+ // — errors are caught inside regenerateAllWikiIndexes and never block the
123
+ // index run. The primary stash is the only target: additional sources
124
+ // are read-only caches, and regenerating their indexes would mutate
125
+ // cache content.
126
+ try {
127
+ const { regenerateAllWikiIndexes } = await import("./wiki.js");
128
+ regenerateAllWikiIndexes(stashDir);
129
+ }
130
+ catch {
131
+ /* best-effort */
132
+ }
120
133
  // Generate embeddings if semantic search is enabled
121
134
  const embeddingResult = await generateEmbeddingsForDb(db, config, onProgress);
122
135
  const tEmbedEnd = Date.now();
@@ -168,15 +181,54 @@ export async function akmIndex(options) {
168
181
  }
169
182
  }
170
183
  // ── Extracted helpers for indexing ────────────────────────────────────────────
171
- async function indexEntries(db, allStashDirs, isIncremental, builtAtMs, doFullDelete = false) {
184
+ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFullDelete = false) {
172
185
  let scannedDirs = 0;
173
186
  let skippedDirs = 0;
174
187
  let generatedCount = 0;
175
188
  const seenPaths = new Set();
176
189
  const dirsNeedingLlm = [];
177
190
  const dirRecords = [];
178
- for (const currentStashDir of allStashDirs) {
191
+ for (const stashSource of allStashSources) {
192
+ const currentStashDir = stashSource.path;
179
193
  const fileContexts = walkStashFlat(currentStashDir);
194
+ // Wiki-root stashes: all .md files are indexed as wiki pages under wikiName
195
+ if (stashSource.wikiName) {
196
+ const wikiName = stashSource.wikiName;
197
+ const wikiDirGroups = new Map();
198
+ for (const ctx of fileContexts) {
199
+ if (ctx.ext !== ".md")
200
+ continue;
201
+ if (!shouldIndexStashFile(currentStashDir, ctx.absPath, { treatStashRootAsWikiRoot: true }))
202
+ continue;
203
+ const relNoExt = ctx.relPath.replace(/\.md$/, "");
204
+ const entry = {
205
+ name: `${wikiName}/${relNoExt}`,
206
+ type: "wiki",
207
+ filename: ctx.fileName,
208
+ description: ctx.frontmatter()?.description,
209
+ source: "frontmatter",
210
+ };
211
+ const dir = ctx.parentDirAbs;
212
+ const group = wikiDirGroups.get(dir);
213
+ if (group) {
214
+ group.files.push(ctx.absPath);
215
+ group.entries.push(entry);
216
+ }
217
+ else {
218
+ wikiDirGroups.set(dir, { files: [ctx.absPath], entries: [entry] });
219
+ }
220
+ }
221
+ for (const [dirPath, { files, entries }] of wikiDirGroups) {
222
+ if (seenPaths.has(path.resolve(dirPath))) {
223
+ dirRecords.push({ dirPath, currentStashDir, files, stash: null, skip: true });
224
+ continue;
225
+ }
226
+ seenPaths.add(path.resolve(dirPath));
227
+ scannedDirs++;
228
+ dirRecords.push({ dirPath, currentStashDir, files, stash: { entries }, skip: false });
229
+ }
230
+ continue;
231
+ }
180
232
  const dirGroups = new Map();
181
233
  for (const ctx of fileContexts) {
182
234
  const dir = ctx.parentDirAbs;
@@ -278,6 +330,8 @@ async function indexEntries(db, allStashDirs, isIncremental, builtAtMs, doFullDe
278
330
  : matchEntryToFile(entry.name, fileBasenameMap, files);
279
331
  if (!entryPath)
280
332
  continue; // skip unresolvable entries
333
+ if (!shouldIndexStashFile(currentStashDir, entryPath))
334
+ continue;
281
335
  // Skip if a higher-priority stash root already indexed this asset
282
336
  const basename = path.basename(entryPath);
283
337
  const identityKey = `${entry.type}\0${basename}\0${entry.description ?? ""}`;
package/dist/init.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Creates the working stash directory structure, persists the stashDir
5
5
  * in config.json, and ensures ripgrep is available.
6
6
  */
7
+ import { spawnSync } from "node:child_process";
7
8
  import fs from "node:fs";
8
9
  import path from "node:path";
9
10
  import { TYPE_DIRS } from "./asset-spec";
@@ -23,6 +24,8 @@ export async function akmInit(options) {
23
24
  fs.mkdirSync(subDir, { recursive: true });
24
25
  }
25
26
  }
27
+ // Ensure the default stash is a local git repo (no remote required)
28
+ ensureGitRepo(stashDir);
26
29
  // Persist stashDir in config.json
27
30
  const configPath = getConfigPath();
28
31
  const existing = loadUserConfig();
@@ -41,3 +44,11 @@ export async function akmInit(options) {
41
44
  }
42
45
  return { stashDir, created, configPath, ripgrep };
43
46
  }
47
+ /** Initialise `dir` as a git repository if it is not already one. */
48
+ function ensureGitRepo(dir) {
49
+ const gitDir = path.join(dir, ".git");
50
+ if (fs.existsSync(gitDir))
51
+ return;
52
+ // Non-fatal: git may not be available in all environments
53
+ spawnSync("git", ["init", dir], { encoding: "utf8", timeout: 15_000 });
54
+ }
@@ -6,6 +6,7 @@ const DEFAULT_INSTALL_AUDIT_CONFIG = {
6
6
  blockOnCritical: true,
7
7
  blockUnlistedRegistries: false,
8
8
  registryAllowlist: [],
9
+ allowedFindings: [],
9
10
  };
10
11
  const MAX_SCANNED_FILE_BYTES = 256 * 1024;
11
12
  const LIFECYCLE_SCRIPT_NAMES = new Set([
@@ -36,6 +37,7 @@ const TEXT_FILE_EXTENSIONS = new Set([
36
37
  ".yaml",
37
38
  ".yml",
38
39
  ]);
40
+ const BLOCKED_PACKAGE_DIRECTORIES = new Set(["node_modules", "venv", ".venv", "site-packages"]);
39
41
  const CONTENT_RULES = [
40
42
  {
41
43
  id: "prompt-ignore-previous-instructions",
@@ -49,7 +51,7 @@ const CONTENT_RULES = [
49
51
  severity: "critical",
50
52
  category: "prompt-injection",
51
53
  message: "Contains instructions to reveal hidden prompts or secrets.",
52
- pattern: /\b(reveal|print|dump|show|exfiltrat(?:e|ion))\b[^.\n]{0,120}\b(system prompt|hidden instructions?|developer message|api key|token|secret|password)\b/i,
54
+ pattern: /\b(?:reveal|print|dump|show|output|return|exfiltrat(?:e|ion))\b[^.\n]{0,60}\b(?:your|the)\b[^.\n]{0,40}\b(system prompt|hidden instructions?|developer message|api key|token|secret|password)\b/i,
53
55
  },
54
56
  {
55
57
  id: "prompt-bypass-guardrails",
@@ -97,6 +99,7 @@ export function resolveInstallAuditConfig(config) {
97
99
  blockOnCritical: installAudit?.blockOnCritical ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockOnCritical,
98
100
  blockUnlistedRegistries: installAudit?.blockUnlistedRegistries ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockUnlistedRegistries,
99
101
  registryAllowlist: allowlist.map((entry) => entry.trim().toLowerCase()),
102
+ allowedFindings: installAudit?.allowedFindings ?? DEFAULT_INSTALL_AUDIT_CONFIG.allowedFindings,
100
103
  };
101
104
  }
102
105
  export function enforceRegistryInstallPolicy(registryLabels, config, ref) {
@@ -118,6 +121,7 @@ export function auditInstallCandidate(input) {
118
121
  enabled: false,
119
122
  passed: true,
120
123
  blocked: false,
124
+ trusted: false,
121
125
  registryLabels: [...input.registryLabels],
122
126
  findings: [],
123
127
  scannedFiles: 0,
@@ -128,17 +132,20 @@ export function auditInstallCandidate(input) {
128
132
  const findings = [];
129
133
  const counters = { scannedFiles: 0, scannedBytes: 0 };
130
134
  scanDirectory(input.rootDir, input.rootDir, findings, counters);
131
- const summary = buildSummary(findings);
132
- const blocked = resolved.blockOnCritical && summary.critical > 0;
135
+ const { findings: activeFindings, waivedFindings } = splitAllowedFindings(findings, input.ref, resolved.allowedFindings);
136
+ const summary = buildSummary(activeFindings);
137
+ const blocked = !input.trustThisInstall && resolved.blockOnCritical && summary.critical > 0;
133
138
  return {
134
139
  enabled: true,
135
- passed: findings.length === 0,
140
+ passed: activeFindings.length === 0,
136
141
  blocked,
142
+ trusted: Boolean(input.trustThisInstall),
137
143
  registryLabels: [...input.registryLabels],
138
- findings,
144
+ findings: activeFindings,
139
145
  scannedFiles: counters.scannedFiles,
140
146
  scannedBytes: counters.scannedBytes,
141
147
  summary,
148
+ ...(waivedFindings.length > 0 ? { waivedFindings } : {}),
142
149
  };
143
150
  }
144
151
  export function formatInstallAuditFailure(ref, report) {
@@ -149,7 +156,8 @@ export function formatInstallAuditFailure(ref, report) {
149
156
  if (report.findings.length > 5) {
150
157
  lines.push(`- ${report.findings.length - 5} more finding(s) omitted`);
151
158
  }
152
- lines.push("Disable blocking with `security.installAudit.blockOnCritical = false`, or disable audits with `security.installAudit.enabled = false`.");
159
+ lines.push("Disable blocking with `security.installAudit.blockOnCritical = false`, or disable audits with `security.installAudit.enabled = false`." +
160
+ " Or pass --trust on a one-off 'akm add' to bypass this audit for this install only.");
153
161
  return lines.join("\n");
154
162
  }
155
163
  export function formatInstallAuditSummary(report) {
@@ -165,7 +173,9 @@ export function formatInstallAuditSummary(report) {
165
173
  if (report.summary.low > 0)
166
174
  severitySummary.push(`${report.summary.low} low`);
167
175
  const detail = severitySummary.length > 0 ? severitySummary.join(", ") : "no findings";
168
- return `Audit: ${report.blocked ? "blocked" : report.passed ? "passed" : "warnings"} (${detail}; scanned ${report.scannedFiles} file${report.scannedFiles === 1 ? "" : "s"})`;
176
+ const status = report.blocked ? "blocked" : report.passed ? "passed" : report.trusted ? "trusted" : "warnings";
177
+ const waived = report.waivedFindings?.length ? `; waived ${report.waivedFindings.length}` : "";
178
+ return `Audit: ${status} (${detail}; scanned ${report.scannedFiles} file${report.scannedFiles === 1 ? "" : "s"}${waived})`;
169
179
  }
170
180
  export function deriveRegistryLabels(input) {
171
181
  const labels = new Set();
@@ -190,10 +200,22 @@ function scanDirectory(dir, rootDir, findings, counters) {
190
200
  return;
191
201
  }
192
202
  for (const entry of entries) {
193
- if (entry.name === ".git" || entry.name === "node_modules")
203
+ if (entry.name === ".git")
194
204
  continue;
195
205
  const fullPath = path.join(dir, entry.name);
196
206
  if (entry.isDirectory()) {
207
+ if (BLOCKED_PACKAGE_DIRECTORIES.has(entry.name)) {
208
+ const relativePath = path.relative(rootDir, fullPath) || entry.name;
209
+ findings.push({
210
+ id: "bundled-package-directory",
211
+ severity: "critical",
212
+ category: "vendored-dependency",
213
+ message: `Contains bundled dependency directory "${entry.name}".`,
214
+ file: relativePath,
215
+ snippet: relativePath,
216
+ });
217
+ continue;
218
+ }
197
219
  scanDirectory(fullPath, rootDir, findings, counters);
198
220
  continue;
199
221
  }
@@ -311,6 +333,29 @@ function buildSummary(findings) {
311
333
  }
312
334
  return summary;
313
335
  }
336
+ function splitAllowedFindings(findings, ref, allowedFindings) {
337
+ const active = [];
338
+ const waived = [];
339
+ for (const finding of findings) {
340
+ if (matchesAllowedFinding(finding, ref, allowedFindings)) {
341
+ waived.push(finding);
342
+ continue;
343
+ }
344
+ active.push(finding);
345
+ }
346
+ return { findings: active, waivedFindings: waived };
347
+ }
348
+ function matchesAllowedFinding(finding, ref, allowedFindings) {
349
+ return allowedFindings.some((allowed) => {
350
+ if (allowed.id !== finding.id)
351
+ return false;
352
+ if (allowed.ref && allowed.ref !== ref)
353
+ return false;
354
+ if (allowed.path && allowed.path !== finding.file)
355
+ return false;
356
+ return true;
357
+ });
358
+ }
314
359
  function addUrlLabels(labels, rawUrl) {
315
360
  if (!rawUrl)
316
361
  return;
@@ -32,6 +32,7 @@ export async function akmListSources(input) {
32
32
  path: stash.path,
33
33
  provider: isRemote ? stash.type : undefined,
34
34
  updatable: false,
35
+ writable: stash.writable === true,
35
36
  status: { exists: stash.path ? directoryExists(stash.path) : true },
36
37
  });
37
38
  }
@@ -47,6 +48,7 @@ export async function akmListSources(input) {
47
48
  ref: entry.ref,
48
49
  version: entry.resolvedVersion,
49
50
  updatable: true,
51
+ writable: entry.writable === true,
50
52
  status: { exists: directoryExists(entry.stashRoot) },
51
53
  });
52
54
  }
package/dist/llm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fetchWithTimeout } from "./common";
2
- async function chatCompletion(config, messages) {
2
+ export async function chatCompletion(config, messages, options) {
3
3
  const headers = { "Content-Type": "application/json" };
4
4
  if (config.apiKey) {
5
5
  headers.Authorization = `Bearer ${config.apiKey}`;
@@ -10,8 +10,8 @@ async function chatCompletion(config, messages) {
10
10
  body: JSON.stringify({
11
11
  model: config.model,
12
12
  messages,
13
- temperature: config.temperature ?? 0.3,
14
- max_tokens: config.maxTokens ?? 512,
13
+ temperature: options?.temperature ?? config.temperature ?? 0.3,
14
+ max_tokens: options?.maxTokens ?? config.maxTokens ?? 512,
15
15
  }),
16
16
  });
17
17
  if (!response.ok) {
@@ -21,6 +21,23 @@ async function chatCompletion(config, messages) {
21
21
  const json = (await response.json());
22
22
  return json.choices?.[0]?.message?.content?.trim() ?? "";
23
23
  }
24
+ /** Strip leading/trailing markdown code fences from an LLM response. */
25
+ function stripJsonFences(raw) {
26
+ return raw
27
+ .trim()
28
+ .replace(/^```(?:json)?\s*\n?/i, "")
29
+ .replace(/\n?```\s*$/i, "")
30
+ .trim();
31
+ }
32
+ /** Parse a possibly-fenced JSON response. Returns undefined if invalid. */
33
+ export function parseJsonResponse(raw) {
34
+ try {
35
+ return JSON.parse(stripJsonFences(raw));
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
24
41
  // ── Metadata Enhancement ────────────────────────────────────────────────────
25
42
  const SYSTEM_PROMPT = `You are a metadata generator for a developer asset registry. Given a script/skill/command/agent entry, generate improved metadata. Respond with ONLY valid JSON, no markdown fencing.`;
26
43
  /**
@@ -50,28 +67,22 @@ Return ONLY the JSON object, no explanation.`;
50
67
  { role: "system", content: SYSTEM_PROMPT },
51
68
  { role: "user", content: userPrompt },
52
69
  ]);
53
- try {
54
- // Strip markdown code fences if present
55
- const cleaned = raw.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
56
- const parsed = JSON.parse(cleaned);
57
- const result = {};
58
- if (typeof parsed.description === "string" && parsed.description) {
59
- result.description = parsed.description;
60
- }
61
- if (Array.isArray(parsed.searchHints)) {
62
- result.searchHints = parsed.searchHints
63
- .filter((s) => typeof s === "string" && s.trim().length > 0)
64
- .slice(0, 8);
65
- }
66
- if (Array.isArray(parsed.tags)) {
67
- result.tags = parsed.tags.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 10);
68
- }
69
- return result;
70
- }
71
- catch {
72
- // LLM returned unparseable output, return empty
70
+ const parsed = parseJsonResponse(raw);
71
+ if (!parsed)
73
72
  return {};
73
+ const result = {};
74
+ if (typeof parsed.description === "string" && parsed.description) {
75
+ result.description = parsed.description;
74
76
  }
77
+ if (Array.isArray(parsed.searchHints)) {
78
+ result.searchHints = parsed.searchHints
79
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
80
+ .slice(0, 8);
81
+ }
82
+ if (Array.isArray(parsed.tags)) {
83
+ result.tags = parsed.tags.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 10);
84
+ }
85
+ return result;
75
86
  }
76
87
  /**
77
88
  * Check if the LLM endpoint is reachable.
@@ -85,3 +96,33 @@ export async function isLlmAvailable(config) {
85
96
  return false;
86
97
  }
87
98
  }
99
+ // ── Capability probe ────────────────────────────────────────────────────────
100
+ /**
101
+ * Ask the model to emit a strict JSON object so we know whether the knowledge
102
+ * wiki ingest/lint flows can rely on structured output. Failure is non-fatal —
103
+ * the caller can fall back to assist-only mode.
104
+ */
105
+ export async function probeLlmCapabilities(config) {
106
+ try {
107
+ const raw = await chatCompletion(config, [
108
+ {
109
+ role: "system",
110
+ content: "You return only valid JSON. No prose, no markdown fences.",
111
+ },
112
+ {
113
+ role: "user",
114
+ content: 'Return exactly this JSON object and nothing else: {"ok": true, "ingest": true, "lint": true}',
115
+ },
116
+ ], { maxTokens: 64, temperature: 0 });
117
+ if (!raw)
118
+ return { reachable: false, structuredOutput: false, error: "empty response" };
119
+ const parsed = parseJsonResponse(raw);
120
+ return {
121
+ reachable: true,
122
+ structuredOutput: Boolean(parsed && parsed.ok === true),
123
+ };
124
+ }
125
+ catch (err) {
126
+ return { reachable: false, structuredOutput: false, error: err instanceof Error ? err.message : String(err) };
127
+ }
128
+ }