akm-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.
@@ -24,6 +24,7 @@ import { loadConfig } from "../core/config";
24
24
  import { NotFoundError, rethrowIfTestIsolationError, UsageError } from "../core/errors";
25
25
  import { appendEvent, readEvents } from "../core/events";
26
26
  import { parseFrontmatter } from "../core/frontmatter";
27
+ import { META_DIR, parseMetaRef, resolveMetaFilePath } from "../core/stash-meta";
27
28
  import { closeDatabase, findEntryIdByRef, openExistingDatabase } from "../indexer/db";
28
29
  import { ensureIndex } from "../indexer/ensure-index";
29
30
  import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../indexer/file-context";
@@ -105,6 +106,16 @@ function resolveRegisteredWikiAssetPath(wikiRoot, wikiName, assetName) {
105
106
  */
106
107
  export async function akmShowUnified(input) {
107
108
  const ref = input.ref.trim();
109
+ // 0a. Stash `.meta/` convention: `[origin//]meta[:name]` direct-reads a
110
+ // human-authored orientation doc from the stash's `.meta/` directory.
111
+ // These files are not indexed (the walker skips dot-dirs), so they are
112
+ // resolved here before the index lookup and the `type:name` parser,
113
+ // which would otherwise reject the non-asset-type `meta`.
114
+ {
115
+ const metaRef = parseMetaRef(ref);
116
+ if (metaRef)
117
+ return showStashMeta(metaRef);
118
+ }
108
119
  // 0. Wiki-root shortcut: `wiki:<name>` with no page path routes to the
109
120
  // wiki summary (same payload as `akm wiki show <name>`). Honour
110
121
  // `parsed.origin` by resolving against the matching stash source(s),
@@ -152,6 +163,42 @@ export async function akmShowUnified(input) {
152
163
  }
153
164
  return result;
154
165
  }
166
+ /**
167
+ * Resolve a stash `.meta/` doc and return it as a lightweight ShowResponse.
168
+ *
169
+ * With no origin the working stash (and other configured sources, in order)
170
+ * is searched and the first hit wins. With an origin the lookup is narrowed
171
+ * to that stash; an uninstalled origin yields an actionable "not installed"
172
+ * error. The file is read directly from disk — `.meta/` is never indexed.
173
+ */
174
+ async function showStashMeta(metaRef) {
175
+ const allSources = resolveSourceEntries();
176
+ const sources = resolveSourcesForOrigin(metaRef.origin, allSources);
177
+ if (metaRef.origin && sources.length === 0) {
178
+ throw new NotFoundError(`Stash "${metaRef.origin}" is not installed, so its ${META_DIR}/ docs are unavailable. ` +
179
+ `Run: akm add ${metaRef.origin}`);
180
+ }
181
+ const config = loadConfig();
182
+ for (const source of sources) {
183
+ const filePath = resolveMetaFilePath(source.path, metaRef.name);
184
+ if (!filePath)
185
+ continue;
186
+ const content = fs.readFileSync(filePath, "utf8");
187
+ const editable = isEditable(filePath, config);
188
+ appendEvent({ eventType: "show", ref: `meta:${metaRef.name}`, metadata: { type: "meta", name: metaRef.name } });
189
+ return {
190
+ type: "meta",
191
+ name: metaRef.name,
192
+ path: filePath,
193
+ content,
194
+ origin: source.registryId ?? null,
195
+ editable,
196
+ };
197
+ }
198
+ throw new NotFoundError(`No ${META_DIR}/${metaRef.name} doc found${metaRef.origin ? ` in "${metaRef.origin}"` : ""}. ` +
199
+ `Stash maintainers can create ${META_DIR}/${metaRef.name}.md to describe this stash ` +
200
+ `(purpose, key assets, conventions, maintainer).`);
201
+ }
155
202
  function hasAnyScopeKey(scope) {
156
203
  return Boolean(scope.user || scope.agent || scope.run || scope.channel);
157
204
  }
@@ -0,0 +1,78 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ const SKELETON_DIR = path.join(import.meta.dir, "../assets/stash-skeleton");
7
+ /**
8
+ * Copy the default stash skeleton into a newly created stash directory.
9
+ *
10
+ * Each file in src/assets/stash-skeleton/ is written to the stash root only
11
+ * if the destination does not already exist — existing files are never
12
+ * overwritten. Non-fatal: if the skeleton directory is missing or a copy
13
+ * fails the caller continues normally.
14
+ */
15
+ export function copyStashSkeleton(stashDir) {
16
+ let entries;
17
+ try {
18
+ entries = fs.readdirSync(SKELETON_DIR);
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ for (const entry of entries) {
24
+ const src = path.join(SKELETON_DIR, entry);
25
+ const dest = path.join(stashDir, entry);
26
+ if (fs.existsSync(dest))
27
+ continue;
28
+ try {
29
+ fs.copyFileSync(src, dest);
30
+ }
31
+ catch {
32
+ // Non-fatal — stash is usable without skeleton files
33
+ }
34
+ }
35
+ }
36
+ /**
37
+ * Scaffold the optional `.meta/index.md` orientation doc for the stash
38
+ * `.meta/` convention. Written only when absent — an existing `.meta/index.md`
39
+ * is never overwritten. Non-fatal: a stash works fine without it.
40
+ *
41
+ * `.meta/` is a dot-directory, so the indexer skips it; the template is
42
+ * written here (rather than shipped under `src/assets/`) because the
43
+ * build-time asset glob excludes dotfiles.
44
+ */
45
+ export function scaffoldStashMeta(stashDir) {
46
+ const metaDir = path.join(stashDir, ".meta");
47
+ const indexPath = path.join(metaDir, "index.md");
48
+ if (fs.existsSync(indexPath))
49
+ return;
50
+ try {
51
+ fs.mkdirSync(metaDir, { recursive: true });
52
+ fs.writeFileSync(indexPath, STASH_META_INDEX_TEMPLATE);
53
+ }
54
+ catch {
55
+ // Non-fatal — stash is usable without the .meta orientation doc
56
+ }
57
+ }
58
+ const STASH_META_INDEX_TEMPLATE = `---
59
+ # Optional, human-authored orientation for this stash. Not indexed; surfaced
60
+ # on demand via \`akm show meta\` (this file) or \`akm show <stash>//meta\`.
61
+ # Every field is optional — delete what you don't need.
62
+ purpose:
63
+ - Describe what this stash is for.
64
+ entry_points:
65
+ # Refs an agent should start from, e.g. skill:code-review, workflow:ship-release
66
+ conventions:
67
+ # House rules an agent should follow when working in this stash.
68
+ maintainer:
69
+ ---
70
+ # About this stash
71
+
72
+ Replace this with a short orientation for agents and humans: what lives here,
73
+ where to start, and the conventions to follow.
74
+
75
+ Extend the \`.meta/\` directory with more docs as needed — \`.meta/about.md\`,
76
+ \`.meta/conventions.md\`, \`.meta/license\` — and read any of them with
77
+ \`akm show meta:<name>\` (or \`akm show <stash>//meta:<name>\`).
78
+ `;
@@ -79,6 +79,28 @@ export function releaseLock(lockPath) {
79
79
  // Sentinel already gone — fine.
80
80
  }
81
81
  }
82
+ /**
83
+ * Release a lock ONLY if it is still owned by `ownerPid`. Safe to call from a
84
+ * `process.exit()` / `'exit'` handler as a backstop: `process.exit()` skips
85
+ * `finally` blocks — so the normal lock-release never runs on signal death
86
+ * (SIGTERM/SIGINT) — but it DOES fire `'exit'` listeners synchronously. Checking
87
+ * ownership first means that if the lock was already released and re-acquired by
88
+ * a different process, this leaves that process's lock intact (no cross-run
89
+ * deletion / PID-reuse footgun). Synchronous so it is valid inside an exit handler.
90
+ */
91
+ export function releaseLockIfOwned(lockPath, ownerPid) {
92
+ let rawContent;
93
+ try {
94
+ rawContent = fs.readFileSync(lockPath, "utf8");
95
+ }
96
+ catch {
97
+ // Absent or unreadable — nothing of ours to release.
98
+ return;
99
+ }
100
+ if (extractHolderPid(rawContent) === ownerPid) {
101
+ releaseLock(lockPath);
102
+ }
103
+ }
82
104
  /**
83
105
  * Extract a PID from a sentinel body. Accepts the two shapes used across
84
106
  * the codebase: a bare numeric string (config-io, vault, lockfile) and
@@ -4,8 +4,8 @@
4
4
  import { spawnSync } from "node:child_process";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
- import { IS_WINDOWS } from "../core/common";
8
- import { RG_BINARY, resolveRg } from "./ripgrep-resolve";
7
+ import { IS_WINDOWS } from "../common";
8
+ import { RG_BINARY, resolveRg } from "./resolve";
9
9
  /**
10
10
  * Platform and architecture detection for ripgrep binary downloads.
11
11
  */
@@ -3,8 +3,8 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { IS_WINDOWS } from "../core/common";
7
- import { getBinDir } from "../core/paths";
6
+ import { IS_WINDOWS } from "../common";
7
+ import { getBinDir } from "../paths";
8
8
  export const RG_BINARY = IS_WINDOWS ? "rg.exe" : "rg";
9
9
  function canExecute(filePath) {
10
10
  if (!fs.existsSync(filePath))
@@ -0,0 +1,110 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Stash `.meta/` convention.
6
+ *
7
+ * A stash may carry an optional, human-authored `.meta/` directory at its
8
+ * root holding orientation docs for the stash as a whole: purpose, key
9
+ * assets, conventions, maintainer info. Because `.meta/` is a dot-directory,
10
+ * the indexer's walker already skips it (see `src/indexer/walker.ts`), so
11
+ * these files never pollute the search corpus. They are surfaced on demand
12
+ * via `akm show [<origin>//]meta[:<name>]`, which direct-reads the file
13
+ * rather than going through the index.
14
+ *
15
+ * This is deliberately a *convention* enabled by a thin resolver: stash
16
+ * owners extend it by dropping new files (`.meta/about.md`, `.meta/license`,
17
+ * `.meta/conventions.md`) with zero further code changes.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { UsageError } from "./errors";
22
+ /** Root-relative directory holding a stash's meta docs. */
23
+ export const META_DIR = ".meta";
24
+ /** Default meta doc shown when no name is given (`akm show <ref>//meta`). */
25
+ export const META_DEFAULT_NAME = "index";
26
+ /**
27
+ * Parse a `meta` show target. Returns `null` when `ref` is not a meta ref so
28
+ * callers can fall through to normal asset resolution.
29
+ *
30
+ * Accepted shapes (the leading `[origin//]` is optional):
31
+ * - `meta` → { name: "index" }
32
+ * - `meta:about` → { name: "about" }
33
+ * - `local//meta` → { origin: "local", name: "index" }
34
+ * - `github:o/r//meta:conventions` → { origin: "github:o/r", name: "conventions" }
35
+ */
36
+ export function parseMetaRef(ref) {
37
+ const trimmed = ref.trim();
38
+ if (!trimmed)
39
+ return null;
40
+ let origin;
41
+ let body = trimmed;
42
+ const boundary = trimmed.indexOf("//");
43
+ if (boundary >= 0) {
44
+ origin = trimmed.slice(0, boundary) || undefined;
45
+ body = trimmed.slice(boundary + 2);
46
+ }
47
+ if (body === "meta")
48
+ return { origin, name: META_DEFAULT_NAME };
49
+ if (body.startsWith("meta:")) {
50
+ const name = body.slice("meta:".length).trim();
51
+ return { origin, name: name || META_DEFAULT_NAME };
52
+ }
53
+ return null;
54
+ }
55
+ /**
56
+ * Reject meta names that would escape the `.meta/` directory. Mirrors the
57
+ * traversal guards in `parseAssetRef`'s `validateName`.
58
+ */
59
+ function assertSafeMetaName(name) {
60
+ if (name.includes("\0")) {
61
+ throw new UsageError("Null byte in meta name.", "PATH_ESCAPE_VIOLATION");
62
+ }
63
+ if (/^[A-Za-z]:/.test(name)) {
64
+ throw new UsageError("Windows drive path in meta name.", "PATH_ESCAPE_VIOLATION");
65
+ }
66
+ const normalized = path.posix.normalize(name.replace(/\\/g, "/"));
67
+ if (path.posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../")) {
68
+ throw new UsageError("Path traversal in meta name.", "PATH_ESCAPE_VIOLATION");
69
+ }
70
+ if (normalized.split("/").some((seg) => seg === "." || seg === "..")) {
71
+ throw new UsageError("Meta name cannot contain relative path segments.", "PATH_ESCAPE_VIOLATION");
72
+ }
73
+ }
74
+ /**
75
+ * Candidate filenames for a meta doc, in resolution order. Markdown is
76
+ * preferred so `meta:about` resolves `.meta/about.md` ahead of `.meta/about`,
77
+ * while names that already carry an extension (`license.txt`) are tried
78
+ * verbatim first.
79
+ */
80
+ function metaCandidates(name) {
81
+ if (path.posix.extname(name))
82
+ return [name, `${name}.md`];
83
+ return [`${name}.md`, name];
84
+ }
85
+ /**
86
+ * Resolve a meta doc to an absolute file path under `<sourceRoot>/.meta/`,
87
+ * or `null` when no candidate file exists. Guards against path traversal
88
+ * both before and after resolution (symlink containment).
89
+ */
90
+ export function resolveMetaFilePath(sourceRoot, name) {
91
+ assertSafeMetaName(name);
92
+ const metaRoot = path.resolve(sourceRoot, META_DIR);
93
+ for (const candidate of metaCandidates(name)) {
94
+ const resolved = path.resolve(metaRoot, candidate);
95
+ if (resolved !== metaRoot && !resolved.startsWith(metaRoot + path.sep)) {
96
+ throw new UsageError("Meta ref resolves outside the stash .meta directory.", "PATH_ESCAPE_VIOLATION");
97
+ }
98
+ if (isRegularFile(resolved))
99
+ return resolved;
100
+ }
101
+ return null;
102
+ }
103
+ function isRegularFile(p) {
104
+ try {
105
+ return fs.statSync(p).isFile();
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
@@ -64,6 +64,33 @@ export async function detectOllama() {
64
64
  }
65
65
  return result;
66
66
  }
67
+ const LMSTUDIO_BASE = "http://localhost:1234";
68
+ /**
69
+ * Detect if LM Studio is running and list available models.
70
+ * Probes the OpenAI-compatible /v1/models endpoint.
71
+ */
72
+ export async function detectLMStudio() {
73
+ const result = { available: false, models: [], endpoint: LMSTUDIO_BASE };
74
+ try {
75
+ const response = await fetch(`${LMSTUDIO_BASE}/v1/models`, {
76
+ signal: AbortSignal.timeout(2000),
77
+ });
78
+ if (response.ok) {
79
+ const data = (await response.json());
80
+ if (Array.isArray(data.data)) {
81
+ result.models = data.data
82
+ .map((m) => (typeof m.id === "string" ? m.id : ""))
83
+ .filter(Boolean)
84
+ .sort();
85
+ result.available = true;
86
+ }
87
+ }
88
+ }
89
+ catch {
90
+ // LM Studio not running or not accessible
91
+ }
92
+ return result;
93
+ }
67
94
  // ── Agent Platform Detection ────────────────────────────────────────────────
68
95
  const AGENT_PLATFORMS = [
69
96
  { name: "Claude Code", relPath: ".claude" },
@@ -0,0 +1,170 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Pluggable registry of LLM config importers for supported agent harnesses.
6
+ *
7
+ * Each importer detects whether a harness is installed (filesystem only,
8
+ * no network) and, if so, reads its config to extract LLM connection details.
9
+ * API key VALUES are never stored — only the env var names that hold them.
10
+ *
11
+ * To add a new harness: implement {@link HarnessConfigImporter} and append it
12
+ * to {@link HARNESS_CONFIG_IMPORTERS}.
13
+ *
14
+ * NOTE: The `detect()` method in each importer overlaps intentionally with
15
+ * `detectAgentPlatforms()` in `detect.ts`. That function scans for harness
16
+ * presence to display installed platforms to the user; these importers go
17
+ * further by reading and parsing the harness config. They serve different
18
+ * purposes and should not be deduplicated.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ // ── Helpers ──────────────────────────────────────────────────────────────────
23
+ function homeDir() {
24
+ return process.env.HOME ?? process.env.USERPROFILE ?? "";
25
+ }
26
+ // ── Claude Code Importer ─────────────────────────────────────────────────────
27
+ /**
28
+ * Imports LLM config from a Claude Code installation.
29
+ *
30
+ * Claude Code stores settings in `~/.claude/settings.json` or `~/.claude.json`.
31
+ * The model field may appear at the root or under `env.ANTHROPIC_MODEL`.
32
+ * The API key is always `ANTHROPIC_API_KEY`.
33
+ */
34
+ const claudeCodeImporter = {
35
+ harnessName: "Claude Code",
36
+ detect() {
37
+ const home = homeDir();
38
+ // Claude Code is installed if the ~/.claude/ directory exists
39
+ return fs.existsSync(path.join(home, ".claude"));
40
+ },
41
+ importConfig() {
42
+ const home = homeDir();
43
+ // Try ~/.claude/settings.json, then ~/.claude.json
44
+ const candidates = [path.join(home, ".claude", "settings.json"), path.join(home, ".claude.json")];
45
+ for (const filePath of candidates) {
46
+ try {
47
+ if (!fs.existsSync(filePath))
48
+ continue;
49
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
50
+ // Claude Code settings: model may be at root or nested under env
51
+ const envBlock = raw.env;
52
+ const model = typeof raw.model === "string"
53
+ ? raw.model
54
+ : typeof envBlock?.ANTHROPIC_MODEL === "string"
55
+ ? String(envBlock.ANTHROPIC_MODEL)
56
+ : undefined;
57
+ return {
58
+ harnessName: "Claude Code",
59
+ provider: "anthropic",
60
+ model: model ?? "claude-sonnet-4-5",
61
+ apiKeyEnvVar: "ANTHROPIC_API_KEY",
62
+ };
63
+ }
64
+ catch {
65
+ // try next candidate
66
+ }
67
+ }
68
+ // ~/.claude exists but no readable settings — still return basic Anthropic config
69
+ return {
70
+ harnessName: "Claude Code",
71
+ provider: "anthropic",
72
+ model: "claude-sonnet-4-5",
73
+ apiKeyEnvVar: "ANTHROPIC_API_KEY",
74
+ };
75
+ },
76
+ };
77
+ // ── OpenCode Importer ────────────────────────────────────────────────────────
78
+ /**
79
+ * Imports LLM config from an OpenCode installation.
80
+ *
81
+ * OpenCode stores config in `~/.config/opencode/config.json` or
82
+ * `~/.opencode/config.json`. Its schema has a `providers` array and a
83
+ * `model` field. API keys in providers appear as `$ENV_VAR_NAME` references.
84
+ */
85
+ const openCodeImporter = {
86
+ harnessName: "OpenCode",
87
+ detect() {
88
+ const home = homeDir();
89
+ return fs.existsSync(path.join(home, ".config", "opencode")) || fs.existsSync(path.join(home, ".opencode"));
90
+ },
91
+ importConfig() {
92
+ const home = homeDir();
93
+ const candidates = [
94
+ path.join(home, ".config", "opencode", "config.json"),
95
+ path.join(home, ".opencode", "config.json"),
96
+ path.join(process.cwd(), ".opencode", "config.json"),
97
+ ];
98
+ for (const filePath of candidates) {
99
+ try {
100
+ if (!fs.existsSync(filePath))
101
+ continue;
102
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
103
+ // OpenCode config shape: { model?: string, providers?: Array<{id, apiKey, baseUrl, ...}> }
104
+ const model = typeof raw.model === "string" ? raw.model : undefined;
105
+ // Extract provider info from the first entry in the providers array
106
+ let provider;
107
+ let baseUrl;
108
+ let apiKeyEnvVar;
109
+ const providers = Array.isArray(raw.providers) ? raw.providers : [];
110
+ if (providers.length > 0) {
111
+ const first = providers[0];
112
+ provider = typeof first?.id === "string" ? first.id : undefined;
113
+ baseUrl =
114
+ typeof first?.baseUrl === "string"
115
+ ? first.baseUrl
116
+ : typeof first?.base_url === "string"
117
+ ? first.base_url
118
+ : undefined;
119
+ // apiKey is an env var reference like "$OPENAI_API_KEY" — extract the var name
120
+ const apiKeyVal = typeof first?.apiKey === "string" ? first.apiKey : "";
121
+ if (apiKeyVal.startsWith("$")) {
122
+ apiKeyEnvVar = apiKeyVal.slice(1);
123
+ }
124
+ }
125
+ return {
126
+ harnessName: "OpenCode",
127
+ provider,
128
+ model,
129
+ baseUrl,
130
+ apiKeyEnvVar,
131
+ };
132
+ }
133
+ catch {
134
+ // try next candidate
135
+ }
136
+ }
137
+ return null;
138
+ },
139
+ };
140
+ // ── Registry ─────────────────────────────────────────────────────────────────
141
+ /**
142
+ * Registry of all supported harness config importers.
143
+ * To add a new harness: implement {@link HarnessConfigImporter} and append here.
144
+ */
145
+ export const HARNESS_CONFIG_IMPORTERS = [claudeCodeImporter, openCodeImporter];
146
+ /**
147
+ * Run all importers whose `detect()` returns `true` and collect their configs.
148
+ *
149
+ * Pure function — filesystem reads only, no network, no side effects.
150
+ * Individual importer failures are swallowed so one broken harness never
151
+ * blocks the setup wizard.
152
+ *
153
+ * @returns List of detected harness configs (may be empty).
154
+ */
155
+ export function detectHarnessConfigs() {
156
+ const results = [];
157
+ for (const importer of HARNESS_CONFIG_IMPORTERS) {
158
+ try {
159
+ if (!importer.detect())
160
+ continue;
161
+ const config = importer.importConfig();
162
+ if (config)
163
+ results.push(config);
164
+ }
165
+ catch {
166
+ // Never let one importer crash the whole detection
167
+ }
168
+ }
169
+ return results;
170
+ }
@@ -0,0 +1,99 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Registry-driven stash discovery for the setup wizard.
6
+ *
7
+ * Fetches the list of available stashes from the official AKM registry,
8
+ * using a cached result when available. Falls back to FALLBACK_STASHES
9
+ * when the registry is unreachable or returns no results.
10
+ *
11
+ * Adding a new default-selected stash: append its registry ID to
12
+ * DEFAULT_SELECTED_STASH_IDS below. No other change required.
13
+ */
14
+ // ── Default selections ──────────────────────────────────────────────────────
15
+ /**
16
+ * Registry stash IDs that are pre-selected by default during setup.
17
+ * To add a new default stash: append its registry ID here.
18
+ * IDs must match the `id` field in the official registry index.
19
+ *
20
+ * This is the single source of truth for which stashes are pre-checked
21
+ * in the setup wizard. No other change is required to adjust defaults.
22
+ */
23
+ export const DEFAULT_SELECTED_STASH_IDS = ["itlackey/akm-stash"];
24
+ // ── Fallback list ───────────────────────────────────────────────────────────
25
+ /**
26
+ * Hardcoded stash list used when the registry is unreachable.
27
+ * Mirrors the previous RECOMMENDED_GITHUB_REPOS constant.
28
+ */
29
+ const FALLBACK_STASHES = [
30
+ {
31
+ id: "itlackey/akm-stash",
32
+ name: "itlackey/akm-stash",
33
+ description: "Official AKM onboarding stash",
34
+ url: "https://github.com/itlackey/akm-stash",
35
+ source: "fallback",
36
+ defaultSelected: true,
37
+ },
38
+ {
39
+ id: "andrewyng/context-hub",
40
+ name: "andrewyng/context-hub",
41
+ description: "Optional community prompt and context stash",
42
+ url: "https://github.com/andrewyng/context-hub",
43
+ source: "fallback",
44
+ defaultSelected: false,
45
+ },
46
+ ];
47
+ // ── Loader ──────────────────────────────────────────────────────────────────
48
+ /**
49
+ * Fetch available stashes from the registry and map to SetupStashEntry[].
50
+ *
51
+ * Falls back to FALLBACK_STASHES on network failure, parse error, or
52
+ * empty response — setup never crashes due to a registry outage.
53
+ *
54
+ * @param registryUrl URL of the registry index JSON.
55
+ * @param timeoutMs Fetch timeout in ms (default: 4000).
56
+ */
57
+ export async function loadSetupStashes(registryUrl, timeoutMs = 4000) {
58
+ try {
59
+ const response = await fetch(registryUrl, {
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ headers: { Accept: "application/json" },
62
+ });
63
+ if (!response.ok)
64
+ return FALLBACK_STASHES;
65
+ const raw = (await response.json());
66
+ if (!Array.isArray(raw.stashes) || raw.stashes.length === 0)
67
+ return FALLBACK_STASHES;
68
+ const entries = raw.stashes.flatMap((item) => {
69
+ if (!item || typeof item !== "object")
70
+ return [];
71
+ const s = item;
72
+ const id = typeof s.id === "string" ? s.id : "";
73
+ const name = typeof s.name === "string" ? s.name : id;
74
+ const description = typeof s.description === "string" ? s.description : "";
75
+ // Prefer github/git source URL built from the ref; fall back to homepage
76
+ const url = (s.source === "github" || s.source === "git") && typeof s.ref === "string"
77
+ ? `https://github.com/${s.ref.replace(/^github:/, "")}`
78
+ : typeof s.homepage === "string"
79
+ ? s.homepage
80
+ : "";
81
+ if (!id || !url)
82
+ return [];
83
+ return [
84
+ {
85
+ id,
86
+ name,
87
+ description,
88
+ url,
89
+ source: "registry",
90
+ defaultSelected: DEFAULT_SELECTED_STASH_IDS.includes(id),
91
+ },
92
+ ];
93
+ });
94
+ return entries.length > 0 ? entries : FALLBACK_STASHES;
95
+ }
96
+ catch {
97
+ return FALLBACK_STASHES;
98
+ }
99
+ }