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.
@@ -14,7 +14,7 @@ import { deriveCanonicalAssetNameFromStashRoot } from "./asset-spec";
14
14
  import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, getUtilityScoresByIds, openDatabase, searchFts, searchVec, } from "./db";
15
15
  import { getRenderer } from "./file-context";
16
16
  import { buildSearchText } from "./indexer";
17
- import { generateMetadataFlat, loadStashFile } from "./metadata";
17
+ import { generateMetadataFlat, loadStashFile, shouldIndexStashFile } from "./metadata";
18
18
  import { getDbPath } from "./paths";
19
19
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
20
20
  import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
@@ -619,6 +619,8 @@ async function indexAssets(stashDir, type) {
619
619
  fileBasenameMap.get(entry.name.split("/").pop() ?? "") ??
620
620
  (files[0] || dirPath);
621
621
  }
622
+ if (!shouldIndexStashFile(stashDir, entryPath))
623
+ continue;
622
624
  assets.push({ entry, path: entryPath });
623
625
  }
624
626
  }
package/dist/matchers.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Built-in asset matchers for the akm file classification system.
3
3
  *
4
- * Four matchers are registered at module load time, each at a different
4
+ * Five matchers are registered at module load time, each at a different
5
5
  * specificity level. Extension and content determine type; directories are
6
6
  * optional specificity boosts, not requirements.
7
7
  *
@@ -15,6 +15,8 @@
15
15
  * and body content for agent/command signals; falls back to "knowledge"
16
16
  * at specificity 5 when no signals are found. Command signals (`agent`
17
17
  * frontmatter, `$ARGUMENTS`/`$1`-`$3` placeholders) return 18.
18
+ * - `wikiMatcher` (20) -- classifies any `.md` under `wikis/<name>/…` as
19
+ * `wiki`. Registered last so the later-wins tiebreaker beats agent at 20.
18
20
  */
19
21
  import { SCRIPT_EXTENSIONS } from "./asset-spec";
20
22
  import { registerMatcher } from "./file-context";
@@ -32,7 +34,9 @@ import { registerMatcher } from "./file-context";
32
34
  export function extensionMatcher(ctx) {
33
35
  // SKILL.md is a skill regardless of location — high specificity beats
34
36
  // smartMdMatcher's knowledge fallback and all directory-based matchers.
35
- if (ctx.fileName === "SKILL.md") {
37
+ // Exception: files under wikis/<name>/… are always wiki pages; the wiki
38
+ // directory is an authoritative signal that outranks the filename.
39
+ if (ctx.fileName === "SKILL.md" && !ctx.ancestorDirs.includes("wikis")) {
36
40
  return { type: "skill", specificity: 25, renderer: "skill-md" };
37
41
  }
38
42
  // Known script extensions (excluding .md, handled by smartMdMatcher)
@@ -68,9 +72,15 @@ export function directoryMatcher(ctx) {
68
72
  if (dir === "knowledge" && ext === ".md") {
69
73
  return { type: "knowledge", specificity: 10, renderer: "knowledge-md" };
70
74
  }
75
+ if (dir === "workflows" && ext === ".md") {
76
+ return { type: "workflow", specificity: 10, renderer: "workflow-md" };
77
+ }
71
78
  if (dir === "memories" && ext === ".md") {
72
79
  return { type: "memory", specificity: 10, renderer: "memory-md" };
73
80
  }
81
+ if (dir === "vaults" && (ctx.fileName === ".env" || ctx.fileName.endsWith(".env"))) {
82
+ return { type: "vault", specificity: 10, renderer: "vault-env" };
83
+ }
74
84
  }
75
85
  return null;
76
86
  }
@@ -98,9 +108,15 @@ export function parentDirHintMatcher(ctx) {
98
108
  if (parentDir === "knowledge" && ext === ".md") {
99
109
  return { type: "knowledge", specificity: 15, renderer: "knowledge-md" };
100
110
  }
111
+ if (parentDir === "workflows" && ext === ".md") {
112
+ return { type: "workflow", specificity: 15, renderer: "workflow-md" };
113
+ }
101
114
  if (parentDir === "memories" && ext === ".md") {
102
115
  return { type: "memory", specificity: 15, renderer: "memory-md" };
103
116
  }
117
+ if (parentDir === "vaults" && (fileName === ".env" || fileName.endsWith(".env"))) {
118
+ return { type: "vault", specificity: 15, renderer: "vault-env" };
119
+ }
104
120
  return null;
105
121
  }
106
122
  // ── smartMdMatcher (specificity: 20 / 18 / 8 / 5) ──────────────────────────
@@ -123,6 +139,14 @@ const COMMAND_PLACEHOLDER_RE = /\$ARGUMENTS|\$[123]\b/;
123
139
  export function smartMdMatcher(ctx) {
124
140
  if (ctx.ext !== ".md")
125
141
  return null;
142
+ const body = ctx.content();
143
+ const hasWorkflowSignals = /^#\s+Workflow:\s+/m.test(body) &&
144
+ /^##\s+Step:\s+/m.test(body) &&
145
+ /^Step ID:\s+/m.test(body) &&
146
+ /^###\s+Instructions\s*$/m.test(body);
147
+ if (hasWorkflowSignals) {
148
+ return { type: "workflow", specificity: 19, renderer: "workflow-md" };
149
+ }
126
150
  const fm = ctx.frontmatter();
127
151
  if (fm) {
128
152
  // Agent-exclusive indicators: toolPolicy or tools
@@ -138,7 +162,6 @@ export function smartMdMatcher(ctx) {
138
162
  }
139
163
  // Command signal: body contains $ARGUMENTS or $1/$2/$3 placeholders.
140
164
  // These are definitively command template patterns (OpenCode convention).
141
- const body = ctx.content();
142
165
  if (COMMAND_PLACEHOLDER_RE.test(body)) {
143
166
  return { type: "command", specificity: 18, renderer: "command-md" };
144
167
  }
@@ -154,9 +177,38 @@ export function smartMdMatcher(ctx) {
154
177
  // Weak fallback: any .md file is assumed to be knowledge
155
178
  return { type: "knowledge", specificity: 5, renderer: "knowledge-md" };
156
179
  }
180
+ // ── wikiMatcher (specificity: 20) ──────────────────────────────────────────
181
+ /**
182
+ * Classify any `.md` file that lives under `wikis/<name>/…` as `wiki`.
183
+ *
184
+ * Registered AFTER `smartMdMatcher` so the registered-later-wins tiebreaker
185
+ * puts wiki ahead of agent at specificity 20. That means a wiki page with
186
+ * agent-style frontmatter (e.g. `tools:`) still classifies as a wiki page,
187
+ * not an agent. That's intentional — the directory is the authoritative
188
+ * signal: files under `wikis/` are wiki content.
189
+ *
190
+ * Requires at least one path segment after `wikis/` (the wiki name) — a
191
+ * stray `.md` at the bare `wikis/` root is not a wiki page.
192
+ */
193
+ export function wikiMatcher(ctx) {
194
+ if (ctx.ext !== ".md")
195
+ return null;
196
+ const idx = ctx.ancestorDirs.indexOf("wikis");
197
+ if (idx < 0)
198
+ return null;
199
+ if (idx + 1 >= ctx.ancestorDirs.length)
200
+ return null;
201
+ return { type: "wiki", specificity: 20, renderer: "wiki-md" };
202
+ }
157
203
  // ── Registration ────────────────────────────────────────────────────────────
158
204
  /** All built-in matchers in registration order (later wins ties). */
159
- const builtinMatchers = [extensionMatcher, directoryMatcher, parentDirHintMatcher, smartMdMatcher];
205
+ const builtinMatchers = [
206
+ extensionMatcher,
207
+ directoryMatcher,
208
+ parentDirHintMatcher,
209
+ smartMdMatcher,
210
+ wikiMatcher,
211
+ ];
160
212
  /**
161
213
  * Register all built-in matchers with the file-context registry.
162
214
  * Called once from the CLI entry point (or ensureBuiltinsRegistered).
package/dist/metadata.js CHANGED
@@ -133,6 +133,30 @@ export function validateStashEntry(entry) {
133
133
  result.cwd = e.cwd.trim();
134
134
  if (typeof e.fileSize === "number" && Number.isFinite(e.fileSize) && e.fileSize >= 0)
135
135
  result.fileSize = e.fileSize;
136
+ if (e.wikiRole === "schema" ||
137
+ e.wikiRole === "index" ||
138
+ e.wikiRole === "log" ||
139
+ e.wikiRole === "raw" ||
140
+ e.wikiRole === "page") {
141
+ result.wikiRole = e.wikiRole;
142
+ }
143
+ if (typeof e.pageKind === "string" && e.pageKind.trim().length > 0) {
144
+ result.pageKind = e.pageKind.trim();
145
+ }
146
+ if (Array.isArray(e.xrefs)) {
147
+ const filtered = e.xrefs
148
+ .filter((x) => typeof x === "string" && x.trim().length > 0)
149
+ .map((x) => x.trim());
150
+ if (filtered.length > 0)
151
+ result.xrefs = filtered;
152
+ }
153
+ if (Array.isArray(e.sources)) {
154
+ const filtered = e.sources
155
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
156
+ .map((s) => s.trim());
157
+ if (filtered.length > 0)
158
+ result.sources = filtered;
159
+ }
136
160
  if (Array.isArray(e.parameters)) {
137
161
  const validated = e.parameters
138
162
  .filter((p) => {
@@ -196,6 +220,68 @@ export function extractCommandParameters(template) {
196
220
  }
197
221
  return params.length > 0 ? params : undefined;
198
222
  }
223
+ /**
224
+ * Extract wiki frontmatter fields (wikiRole, pageKind, xrefs, sources) from a parsed
225
+ * frontmatter block and apply them to the entry. Tolerates missing or malformed values.
226
+ */
227
+ export function applyWikiFrontmatter(entry, fmData) {
228
+ const role = fmData.wikiRole;
229
+ if (role === "schema" || role === "index" || role === "log" || role === "raw" || role === "page") {
230
+ entry.wikiRole = role;
231
+ }
232
+ const pageKind = fmData.pageKind;
233
+ if (typeof pageKind === "string" && pageKind.trim().length > 0) {
234
+ entry.pageKind = pageKind.trim();
235
+ }
236
+ const xrefs = fmData.xrefs;
237
+ if (Array.isArray(xrefs)) {
238
+ const filtered = xrefs
239
+ .filter((x) => typeof x === "string" && x.trim().length > 0)
240
+ .map((x) => x.trim());
241
+ if (filtered.length > 0)
242
+ entry.xrefs = filtered;
243
+ }
244
+ const sources = fmData.sources;
245
+ if (Array.isArray(sources)) {
246
+ const filtered = sources
247
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
248
+ .map((s) => s.trim());
249
+ if (filtered.length > 0)
250
+ entry.sources = filtered;
251
+ }
252
+ }
253
+ const WIKI_INFRA_FILES = new Set(["schema.md", "index.md", "log.md"]);
254
+ /**
255
+ * Apply wiki-specific index exclusions while leaving all other stash files
256
+ * untouched.
257
+ *
258
+ * - In a normal stash, excludes `wikis/<name>/raw/**` and wiki-root
259
+ * `schema.md`, `index.md`, `log.md`.
260
+ * - In a wiki-root stash source (`wikiName`), excludes `raw/**` and those same
261
+ * root-level infrastructure files.
262
+ */
263
+ export function shouldIndexStashFile(stashRoot, file, options) {
264
+ const relPath = path.relative(stashRoot, file);
265
+ if (!relPath || relPath.startsWith("..") || path.isAbsolute(relPath))
266
+ return true;
267
+ const segments = relPath.split(/[\\/]+/).filter(Boolean);
268
+ if (segments.length === 0)
269
+ return true;
270
+ if (options?.treatStashRootAsWikiRoot) {
271
+ if (segments[0] === "raw")
272
+ return false;
273
+ return !(segments.length === 1 && WIKI_INFRA_FILES.has(segments[0]));
274
+ }
275
+ const wikisIdx = segments.indexOf("wikis");
276
+ if (wikisIdx < 0 || wikisIdx + 1 >= segments.length)
277
+ return true;
278
+ const wikiRelativeSegments = segments.slice(wikisIdx + 2);
279
+ if (wikiRelativeSegments.length === 0)
280
+ return true;
281
+ if (wikiRelativeSegments[0] === "raw")
282
+ return false;
283
+ return !(wikiRelativeSegments.length === 1 && WIKI_INFRA_FILES.has(wikiRelativeSegments[0]));
284
+ }
199
285
  /**
200
286
  * Extract `@param` JSDoc tags from a script file's leading comment block.
201
287
  *
@@ -316,6 +402,8 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
316
402
  const fmParams = extractFrontmatterParameters(parsed.data);
317
403
  if (fmParams)
318
404
  entry.parameters = fmParams;
405
+ // Pass wiki-pattern frontmatter through onto the entry
406
+ applyWikiFrontmatter(entry, parsed.data);
319
407
  // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
320
408
  if (entry.type === "command") {
321
409
  const cmdParams = extractCommandParameters(parsed.content);
@@ -324,8 +412,11 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
324
412
  }
325
413
  }
326
414
  }
327
- // Extract @param from script files
328
- if (ext !== ".md") {
415
+ // Extract @param from script files.
416
+ // Vault files (.env) are deliberately excluded — their contents are secrets
417
+ // and must never be parsed for @param or any other metadata that could
418
+ // embed a value into the entry.
419
+ if (ext !== ".md" && assetType !== "vault") {
329
420
  const scriptParams = extractScriptParameters(file);
330
421
  if (scriptParams)
331
422
  entry.parameters = scriptParams;
@@ -369,6 +460,8 @@ export async function generateMetadataFlat(stashRoot, files) {
369
460
  const entries = [];
370
461
  const pkgMetaCache = new Map();
371
462
  for (const file of files) {
463
+ if (!shouldIndexStashFile(stashRoot, file))
464
+ continue;
372
465
  const ctx = buildFileContext(stashRoot, file);
373
466
  const match = await runMatchers(ctx);
374
467
  if (!match)
@@ -418,6 +511,8 @@ export async function generateMetadataFlat(stashRoot, files) {
418
511
  const fmParams = extractFrontmatterParameters(parsed.data);
419
512
  if (fmParams)
420
513
  entry.parameters = fmParams;
514
+ // Pass wiki-pattern frontmatter through onto the entry
515
+ applyWikiFrontmatter(entry, parsed.data);
421
516
  // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
422
517
  if (entry.type === "command") {
423
518
  const cmdParams = extractCommandParameters(parsed.content);
@@ -426,8 +521,11 @@ export async function generateMetadataFlat(stashRoot, files) {
426
521
  }
427
522
  }
428
523
  }
429
- // Extract @param from script files
430
- if (ext !== ".md") {
524
+ // Extract @param from script files.
525
+ // Vault files (.env) are deliberately excluded — their contents are secrets
526
+ // and must never be parsed for @param or any other metadata that could
527
+ // embed a value into the entry.
528
+ if (ext !== ".md" && assetType !== "vault") {
431
529
  const scriptParams = extractScriptParameters(file, ctx.content());
432
530
  if (scriptParams)
433
531
  entry.parameters = scriptParams;
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
4
+ const EMBEDDED_MIGRATION_GUIDES = {
5
+ "0.5.0": `Migration notes for akm v0.5.0
6
+
7
+ - New top-level surfaces: \`akm wiki …\`, \`akm workflow …\`, \`akm vault …\`, and \`akm save\`.
8
+ - If you tried the unreleased single-wiki LLM prototype, move to the new \`akm wiki …\` workflow.
9
+ - Removed from the prototype surface: \`akm lint\`, \`akm import --llm\`, \`akm import --dry-run\`, \`knowledge.pageKinds\`, and the old ingest/lint LLM prompts.
10
+ - Existing raw wiki-like content should be moved into \`wikis/<name>/raw/\` and then managed with the new wiki commands.
11
+ `,
12
+ "0.3.0": `Migration notes for akm v0.3.0
13
+
14
+ - The old \`stash\` and \`kit\` command groups were folded into the top-level CLI.
15
+ - Use \`akm add\`, \`akm list\`, and \`akm remove\` instead of the older split command surfaces.
16
+ - Documentation and examples from older releases should be updated to the unified source model.
17
+ `,
18
+ "0.2.0": `Migration notes for akm v0.2.0
19
+
20
+ - Asset refs are user-facing \`type:name\` values; do not rely on URI-style refs.
21
+ - The old fixed asset-type union was replaced by an extensible asset type system.
22
+ - \`tool\` assets were removed; use \`script\` assets instead.
23
+ - Config and docs should treat remote provider scores and local scores as part of one shared search pipeline.
24
+ `,
25
+ "0.1.0": `Migration notes for akm v0.1.0
26
+
27
+ - The package and project were rebranded from Agent-i-Kit to akm.
28
+ - Update package references from \`agent-i-kit\` to \`akm-cli\`.
29
+ - Update config, registry, plugin, path, and environment-variable references from \`agent-i-kit\` / \`AGENT_I_KIT_*\` to \`akm\` / \`AKM_*\`.
30
+ - The \`tool\` asset type and \`submit\` command were removed.
31
+ `,
32
+ "0.0.13": `Migration notes for akm v0.0.13
33
+
34
+ - Initial public release.
35
+ - No migration steps are required for earlier akm versions.
36
+ `,
37
+ };
38
+ function loadChangelog() {
39
+ try {
40
+ const changelogPath = path.resolve(import.meta.dir, "../CHANGELOG.md");
41
+ if (fs.existsSync(changelogPath)) {
42
+ return fs.readFileSync(changelogPath, "utf8");
43
+ }
44
+ }
45
+ catch {
46
+ // fall through to embedded notes
47
+ }
48
+ return undefined;
49
+ }
50
+ function escapeRegexString(value) {
51
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ }
53
+ function normalizeRequestedVersion(input) {
54
+ const value = input.trim();
55
+ if (!value)
56
+ return value;
57
+ if (value.toLowerCase() === "latest")
58
+ return "latest";
59
+ const withoutV = value.replace(/^v/i, "");
60
+ return withoutV;
61
+ }
62
+ function versionCandidates(requested) {
63
+ if (requested === "latest")
64
+ return ["latest"];
65
+ const exact = requested;
66
+ const stable = requested.replace(/[-+].*$/, "");
67
+ return stable === exact ? [exact] : [exact, stable];
68
+ }
69
+ function resolveLatestVersion(changelog) {
70
+ for (const match of changelog.matchAll(/^## \[([^\]]+)\]/gm)) {
71
+ const version = match[1];
72
+ if (version !== "Unreleased")
73
+ return version;
74
+ }
75
+ return undefined;
76
+ }
77
+ function extractChangelogSection(changelog, version) {
78
+ const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|\\Z)`, "m");
79
+ const match = changelog.match(pattern);
80
+ if (!match)
81
+ return undefined;
82
+ return `## [${version}]\n${match[1].trim()}\n`;
83
+ }
84
+ function fallbackGuide(version) {
85
+ const embedded = EMBEDDED_MIGRATION_GUIDES[version];
86
+ if (embedded)
87
+ return `${embedded.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
88
+ return `No dedicated migration note is bundled for akm v${version}.\n\nSee the full changelog: ${CHANGELOG_URL}\n`;
89
+ }
90
+ export function renderMigrationHelp(versionInput, changelogText = loadChangelog()) {
91
+ const requested = normalizeRequestedVersion(versionInput);
92
+ if (!requested) {
93
+ return `Version is required.\n\nUsage: akm help migrate <version>\n`;
94
+ }
95
+ const resolvedLatest = changelogText ? resolveLatestVersion(changelogText) : undefined;
96
+ const candidates = requested === "latest" && resolvedLatest ? [resolvedLatest] : versionCandidates(requested);
97
+ if (changelogText) {
98
+ for (const candidate of candidates) {
99
+ const section = extractChangelogSection(changelogText, candidate);
100
+ if (section) {
101
+ const embedded = EMBEDDED_MIGRATION_GUIDES[candidate];
102
+ if (!embedded)
103
+ return `${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
104
+ return `${embedded.trim()}\n\nRelease notes\n-------------\n${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
105
+ }
106
+ }
107
+ }
108
+ const fallbackVersion = candidates.find((candidate) => candidate !== "latest") ?? requested;
109
+ return fallbackGuide(fallbackVersion);
110
+ }
package/dist/paths.js CHANGED
@@ -68,6 +68,9 @@ export function getCacheDir() {
68
68
  export function getDbPath() {
69
69
  return path.join(getCacheDir(), "index.db");
70
70
  }
71
+ export function getWorkflowDbPath() {
72
+ return path.join(getCacheDir(), "workflow.db");
73
+ }
71
74
  export function getSemanticStatusPath() {
72
75
  return path.join(getCacheDir(), "semantic-status.json");
73
76
  }
@@ -20,6 +20,9 @@ export async function installRegistryRef(ref, options) {
20
20
  if (parsed.source === "git") {
21
21
  return installGitRegistryRef(parsed, config, options);
22
22
  }
23
+ if (parsed.source === "github") {
24
+ return installGithubRegistryRef(parsed, config, options);
25
+ }
23
26
  const resolved = await resolveRegistryArtifact(parsed);
24
27
  const registryLabels = deriveRegistryLabels({
25
28
  source: resolved.source,
@@ -38,7 +41,7 @@ export async function installRegistryRef(ref, options) {
38
41
  const cachedStashRoot = detectStashRoot(extractedDir);
39
42
  if (cachedStashRoot) {
40
43
  const integrity = fs.existsSync(archivePath) ? await computeFileHash(archivePath) : undefined;
41
- const audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config);
44
+ const audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config, options);
42
45
  return {
43
46
  id: resolved.id,
44
47
  source: resolved.source,
@@ -51,6 +54,7 @@ export async function installRegistryRef(ref, options) {
51
54
  extractedDir,
52
55
  stashRoot: cachedStashRoot,
53
56
  integrity,
57
+ writable: options?.writable,
54
58
  audit,
55
59
  };
56
60
  }
@@ -70,7 +74,7 @@ export async function installRegistryRef(ref, options) {
70
74
  verifyArchiveIntegrity(archivePath, resolved.resolvedRevision, resolved.source);
71
75
  integrity = await computeFileHash(archivePath);
72
76
  extractTarGzSecure(archivePath, extractedDir);
73
- audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config);
77
+ audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config, options);
74
78
  provisionalKitRoot = detectStashRoot(extractedDir);
75
79
  installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
76
80
  stashRoot = detectStashRoot(installRoot);
@@ -98,9 +102,24 @@ export async function installRegistryRef(ref, options) {
98
102
  extractedDir,
99
103
  stashRoot,
100
104
  integrity,
105
+ writable: options?.writable,
101
106
  audit,
102
107
  };
103
108
  }
109
+ async function installGithubRegistryRef(parsed, config, options) {
110
+ const gitParsed = {
111
+ source: "git",
112
+ ref: parsed.ref,
113
+ id: parsed.id,
114
+ url: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
115
+ requestedRef: parsed.requestedRef,
116
+ };
117
+ const installed = await installGitRegistryRef(gitParsed, config, options);
118
+ return {
119
+ ...installed,
120
+ source: "github",
121
+ };
122
+ }
104
123
  async function installLocalRegistryRef(parsed, config, options) {
105
124
  const resolved = await resolveRegistryArtifact(parsed);
106
125
  const installedAt = (options?.now ?? new Date()).toISOString();
@@ -109,7 +128,7 @@ async function installLocalRegistryRef(parsed, config, options) {
109
128
  ref: resolved.ref,
110
129
  artifactUrl: resolved.artifactUrl,
111
130
  });
112
- const audit = runInstallAuditOrThrow(parsed.sourcePath, resolved.source, resolved.ref, registryLabels, config);
131
+ const audit = runInstallAuditOrThrow(parsed.sourcePath, resolved.source, resolved.ref, registryLabels, config, options);
113
132
  // For local directories, detect the stash root within the source path.
114
133
  // If no nested stash is found, the source path itself is used.
115
134
  const stashRoot = detectStashRoot(parsed.sourcePath);
@@ -124,6 +143,7 @@ async function installLocalRegistryRef(parsed, config, options) {
124
143
  cacheDir: parsed.sourcePath,
125
144
  extractedDir: parsed.sourcePath,
126
145
  stashRoot,
146
+ writable: options?.writable,
127
147
  audit,
128
148
  };
129
149
  }
@@ -148,7 +168,7 @@ async function installGitRegistryRef(parsed, config, options) {
148
168
  const installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
149
169
  const stashRoot = detectStashRoot(installRoot);
150
170
  if (stashRoot) {
151
- const audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config);
171
+ const audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config, options);
152
172
  return {
153
173
  id: resolved.id,
154
174
  source: resolved.source,
@@ -160,6 +180,7 @@ async function installGitRegistryRef(parsed, config, options) {
160
180
  cacheDir,
161
181
  extractedDir,
162
182
  stashRoot,
183
+ writable: options?.writable,
163
184
  audit,
164
185
  };
165
186
  }
@@ -193,7 +214,7 @@ async function installGitRegistryRef(parsed, config, options) {
193
214
  copyDirectoryContents(cloneDir, extractedDir);
194
215
  // Clean up the clone dir
195
216
  fs.rmSync(cloneDir, { recursive: true, force: true });
196
- audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config);
217
+ audit = runInstallAuditOrThrow(extractedDir, resolved.source, resolved.ref, registryLabels, config, options);
197
218
  provisionalKitRoot = detectStashRoot(extractedDir);
198
219
  installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
199
220
  stashRoot = detectStashRoot(installRoot);
@@ -220,6 +241,7 @@ async function installGitRegistryRef(parsed, config, options) {
220
241
  cacheDir,
221
242
  extractedDir,
222
243
  stashRoot,
244
+ writable: options?.writable,
223
245
  audit,
224
246
  };
225
247
  }
@@ -494,8 +516,15 @@ async function computeFileHash(filePath) {
494
516
  const hash = createHash("sha256").update(data).digest("hex");
495
517
  return `sha256:${hash}`;
496
518
  }
497
- function runInstallAuditOrThrow(rootDir, source, ref, registryLabels, config) {
498
- const audit = auditInstallCandidate({ rootDir, source, ref, registryLabels, config });
519
+ function runInstallAuditOrThrow(rootDir, source, ref, registryLabels, config, options) {
520
+ const audit = auditInstallCandidate({
521
+ rootDir,
522
+ source,
523
+ ref,
524
+ registryLabels,
525
+ config,
526
+ trustThisInstall: options?.trustThisInstall,
527
+ });
499
528
  if (audit.blocked) {
500
529
  throw new Error(formatInstallAuditFailure(ref, audit));
501
530
  }
@@ -273,6 +273,20 @@ async function resolveNpmArtifact(parsed) {
273
273
  };
274
274
  }
275
275
  async function resolveGithubArtifact(parsed) {
276
+ const gitUrl = `https://github.com/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}.git`;
277
+ // Prefer git-backed installs so private GitHub repos work with the user's
278
+ // normal git credential helper rather than requiring API-specific auth.
279
+ const gitResolvedRevision = resolveGitRevisionFromRemote(gitUrl, parsed.requestedRef);
280
+ if (gitResolvedRevision) {
281
+ return {
282
+ id: parsed.id,
283
+ source: parsed.source,
284
+ ref: parsed.ref,
285
+ artifactUrl: gitUrl,
286
+ resolvedVersion: parsed.requestedRef,
287
+ resolvedRevision: gitResolvedRevision,
288
+ };
289
+ }
276
290
  const headers = githubHeaders();
277
291
  if (parsed.requestedRef) {
278
292
  const commit = await tryFetchJson(`${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/commits/${encodeURIComponent(parsed.requestedRef)}`, headers);
@@ -315,6 +329,17 @@ async function resolveGithubArtifact(parsed) {
315
329
  resolvedRevision: asString(commit?.sha) ?? defaultBranch,
316
330
  };
317
331
  }
332
+ function resolveGitRevisionFromRemote(url, requestedRef) {
333
+ validateGitUrl(url);
334
+ const ref = requestedRef ?? "HEAD";
335
+ if (requestedRef)
336
+ validateGitRef(requestedRef);
337
+ const result = spawnSync("git", ["ls-remote", url, ref], { encoding: "utf8", timeout: 30_000 });
338
+ if (result.status !== 0)
339
+ return undefined;
340
+ const firstLine = result.stdout.trim().split(/\r?\n/)[0];
341
+ return firstLine?.split(/\s/)[0] || undefined;
342
+ }
318
343
  async function resolveGitArtifact(parsed) {
319
344
  validateGitUrl(parsed.url);
320
345
  const ref = parsed.requestedRef ?? "HEAD";