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.
- package/CHANGELOG.md +91 -0
- package/dist/assets/profiles/quick.json +2 -1
- package/dist/assets/stash-skeleton/README.md +76 -0
- package/dist/cli.js +8 -3
- package/dist/commands/consolidate.js +4 -4
- package/dist/commands/health.js +20 -0
- package/dist/commands/improve-cli.js +1 -1
- package/dist/commands/improve-result-file.js +9 -4
- package/dist/commands/improve.js +67 -26
- package/dist/commands/init.js +6 -1
- package/dist/commands/{proposal-drain-policies.js → proposal/drain-policies.js} +2 -2
- package/dist/commands/{proposal-drain.js → proposal/drain.js} +10 -10
- package/dist/commands/show.js +47 -0
- package/dist/commands/stash-skeleton.js +78 -0
- package/dist/core/file-lock.js +22 -0
- package/dist/{setup/ripgrep-install.js → core/ripgrep/install.js} +2 -2
- package/dist/{setup/ripgrep-resolve.js → core/ripgrep/resolve.js} +2 -2
- package/dist/core/stash-meta.js +110 -0
- package/dist/setup/detect.js +27 -0
- package/dist/setup/harness-config-import.js +170 -0
- package/dist/setup/registry-stash-loader.js +99 -0
- package/dist/setup/setup.js +229 -72
- package/package.json +1 -1
package/dist/commands/show.js
CHANGED
|
@@ -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
|
+
`;
|
package/dist/core/file-lock.js
CHANGED
|
@@ -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 "../
|
|
8
|
-
import { RG_BINARY, resolveRg } from "./
|
|
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 "../
|
|
7
|
-
import { getBinDir } from "../
|
|
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
|
+
}
|
package/dist/setup/detect.js
CHANGED
|
@@ -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
|
+
}
|