akm-cli 0.8.0 → 0.8.2

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/dist/assets/profiles/default.json +15 -0
  3. package/dist/assets/profiles/graph-refresh.json +13 -0
  4. package/dist/assets/profiles/memory-focus.json +12 -0
  5. package/dist/assets/profiles/quick.json +15 -0
  6. package/dist/assets/profiles/thorough.json +15 -0
  7. package/dist/assets/stash-skeleton/README.md +76 -0
  8. package/dist/assets/tasks/graph-refresh-weekly.yml +10 -0
  9. package/dist/cli.js +8 -3
  10. package/dist/commands/consolidate.js +36 -15
  11. package/dist/commands/extract-prompt.js +14 -1
  12. package/dist/commands/health.js +89 -8
  13. package/dist/commands/improve-cli.js +2 -2
  14. package/dist/commands/improve-profiles.js +13 -59
  15. package/dist/commands/improve-result-file.js +9 -4
  16. package/dist/commands/improve.js +86 -65
  17. package/dist/commands/info.js +23 -28
  18. package/dist/commands/init.js +6 -1
  19. package/dist/commands/{proposal-drain-policies.js → proposal/drain-policies.js} +2 -2
  20. package/dist/commands/{proposal-drain.js → proposal/drain.js} +10 -10
  21. package/dist/commands/show.js +47 -0
  22. package/dist/commands/stash-skeleton.js +78 -0
  23. package/dist/{setup/ripgrep-install.js → core/ripgrep/install.js} +2 -2
  24. package/dist/{setup/ripgrep-resolve.js → core/ripgrep/resolve.js} +2 -2
  25. package/dist/core/stash-meta.js +110 -0
  26. package/dist/indexer/indexer.js +2 -2
  27. package/dist/llm/graph-extract.js +1 -1
  28. package/dist/output/cli-hints.js +2 -2
  29. package/dist/setup/detect.js +27 -0
  30. package/dist/setup/harness-config-import.js +170 -0
  31. package/dist/setup/registry-stash-loader.js +99 -0
  32. package/dist/setup/setup.js +229 -72
  33. package/dist/tasks/backends/launchd.js +1 -1
  34. package/dist/tasks/backends/schtasks.js +1 -1
  35. package/dist/wiki/wiki-templates.js +3 -3
  36. package/dist/wiki/wiki.js +1 -1
  37. package/dist/workflows/authoring.js +1 -1
  38. package/package.json +1 -1
  39. /package/dist/{tasks → assets}/backends/launchd-template.xml +0 -0
  40. /package/dist/{tasks → assets}/backends/schtasks-template.xml +0 -0
  41. /package/dist/{commands → assets}/help/help-accept.md +0 -0
  42. /package/dist/{commands → assets}/help/help-improve.md +0 -0
  43. /package/dist/{commands → assets}/help/help-proposals.md +0 -0
  44. /package/dist/{commands → assets}/help/help-propose.md +0 -0
  45. /package/dist/{commands → assets}/help/help-reject.md +0 -0
  46. /package/dist/{output → assets/hints}/cli-hints-full.md +0 -0
  47. /package/dist/{output → assets/hints}/cli-hints-short.md +0 -0
  48. /package/dist/{llm → assets}/prompts/extract-session.md +0 -0
  49. /package/dist/{llm → assets}/prompts/graph-extract-user-prompt.md +0 -0
  50. /package/dist/{wiki → assets/wiki}/index-template.md +0 -0
  51. /package/dist/{wiki → assets/wiki}/ingest-workflow-template.md +0 -0
  52. /package/dist/{wiki → assets/wiki}/log-template.md +0 -0
  53. /package/dist/{wiki → assets/wiki}/schema-template.md +0 -0
  54. /package/dist/{workflows → assets/workflows}/workflow-template.md +0 -0
@@ -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
+ `;
@@ -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
+ }
@@ -322,8 +322,8 @@ export async function akmIndex(options) {
322
322
  totalMs: Date.now() - timing.t0,
323
323
  walkMs: timing.tWalkEnd - timing.tWalkStart,
324
324
  llmMs: timing.tLlmEnd - timing.tWalkEnd,
325
- embedMs: timing.tEmbedEnd - timing.tFtsEnd,
326
- ftsMs: timing.tFtsEnd - timing.tLlmEnd,
325
+ embedMs: timing.tEmbedEnd - timing.tLlmEnd,
326
+ ftsMs: timing.tFtsEnd - timing.tEmbedEnd,
327
327
  },
328
328
  ...(cleanResult !== undefined ? { clean: cleanResult } : {}),
329
329
  };
@@ -20,11 +20,11 @@
20
20
  * the connection via `resolveIndexPassLLM("graph", config)` and pass it
21
21
  * straight through.
22
22
  */
23
+ import userPromptTemplate from "../assets/prompts/graph-extract-user-prompt.md" with { type: "text" };
23
24
  import { toErrorMessage } from "../core/common";
24
25
  import { warn, warnVerbose } from "../core/warn";
25
26
  import { chatCompletion, parseEmbeddedJsonResponse } from "./client";
26
27
  import { tryLlmFeature } from "./feature-gate";
27
- import userPromptTemplate from "./prompts/graph-extract-user-prompt.md" with { type: "text" };
28
28
  /**
29
29
  * Separator token used between assets in a batch prompt.
30
30
  * Chosen to be visually clear and unlikely to appear verbatim in asset bodies.
@@ -10,6 +10,6 @@
10
10
  * `EMBEDDED_HINTS` (`--detail brief`, short reference, ~40 lines) and
11
11
  * `EMBEDDED_HINTS_FULL` (`--detail normal|full`, ~250 lines).
12
12
  */
13
- import EMBEDDED_HINTS_FULL from "./cli-hints-full.md" with { type: "text" };
14
- import EMBEDDED_HINTS from "./cli-hints-short.md" with { type: "text" };
13
+ import EMBEDDED_HINTS_FULL from "../assets/hints/cli-hints-full.md" with { type: "text" };
14
+ import EMBEDDED_HINTS from "../assets/hints/cli-hints-short.md" with { type: "text" };
15
15
  export { EMBEDDED_HINTS, EMBEDDED_HINTS_FULL };
@@ -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
+ }