akm-cli 0.5.0 → 0.6.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.
- package/CHANGELOG.md +53 -5
- package/README.md +9 -9
- package/dist/cli.js +379 -1448
- package/dist/{completions.js → commands/completions.js} +1 -1
- package/dist/{config-cli.js → commands/config-cli.js} +109 -11
- package/dist/commands/curate.js +263 -0
- package/dist/{info.js → commands/info.js} +17 -11
- package/dist/{init.js → commands/init.js} +4 -4
- package/dist/{install-audit.js → commands/install-audit.js} +14 -2
- package/dist/{installed-kits.js → commands/installed-stashes.js} +122 -50
- package/dist/commands/migration-help.js +141 -0
- package/dist/{registry-search.js → commands/registry-search.js} +68 -9
- package/dist/commands/remember.js +178 -0
- package/dist/{stash-search.js → commands/search.js} +28 -69
- package/dist/{self-update.js → commands/self-update.js} +3 -3
- package/dist/{stash-show.js → commands/show.js} +106 -81
- package/dist/{stash-add.js → commands/source-add.js} +133 -67
- package/dist/{stash-clone.js → commands/source-clone.js} +15 -13
- package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
- package/dist/{vault.js → commands/vault.js} +43 -0
- package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
- package/dist/{asset-registry.js → core/asset-registry.js} +30 -6
- package/dist/{asset-spec.js → core/asset-spec.js} +13 -6
- package/dist/{common.js → core/common.js} +147 -50
- package/dist/{config.js → core/config.js} +288 -29
- package/dist/core/errors.js +90 -0
- package/dist/{frontmatter.js → core/frontmatter.js} +64 -8
- package/dist/{paths.js → core/paths.js} +4 -4
- package/dist/core/write-source.js +280 -0
- package/dist/{local-search.js → indexer/db-search.js} +49 -32
- package/dist/{db.js → indexer/db.js} +210 -81
- package/dist/{file-context.js → indexer/file-context.js} +3 -3
- package/dist/{indexer.js → indexer/indexer.js} +153 -30
- package/dist/{manifest.js → indexer/manifest.js} +10 -10
- package/dist/{matchers.js → indexer/matchers.js} +4 -7
- package/dist/{metadata.js → indexer/metadata.js} +9 -5
- package/dist/{search-source.js → indexer/search-source.js} +97 -55
- package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
- package/dist/{walker.js → indexer/walker.js} +1 -1
- package/dist/{lockfile.js → integrations/lockfile.js} +29 -2
- package/dist/{llm.js → llm/client.js} +12 -48
- package/dist/llm/embedder.js +127 -0
- package/dist/llm/embedders/cache.js +47 -0
- package/dist/llm/embedders/local.js +152 -0
- package/dist/llm/embedders/remote.js +121 -0
- package/dist/llm/embedders/types.js +39 -0
- package/dist/llm/metadata-enhance.js +53 -0
- package/dist/output/cli-hints.js +301 -0
- package/dist/output/context.js +95 -0
- package/dist/{renderers.js → output/renderers.js} +57 -61
- package/dist/output/shapes.js +212 -0
- package/dist/output/text.js +520 -0
- package/dist/{registry-build-index.js → registry/build-index.js} +48 -32
- package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
- package/dist/registry/factory.js +33 -0
- package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
- package/dist/registry/providers/index.js +11 -0
- package/dist/{providers → registry/providers}/skills-sh.js +60 -4
- package/dist/{providers → registry/providers}/static-index.js +126 -56
- package/dist/registry/providers/types.js +25 -0
- package/dist/{registry-resolve.js → registry/resolve.js} +10 -6
- package/dist/{detect.js → setup/detect.js} +0 -27
- package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
- package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
- package/dist/{setup.js → setup/setup.js} +162 -129
- package/dist/setup/steps.js +45 -0
- package/dist/{kit-include.js → sources/include.js} +1 -1
- package/dist/sources/provider-factory.js +36 -0
- package/dist/sources/provider.js +21 -0
- package/dist/sources/providers/filesystem.js +35 -0
- package/dist/{stash-providers → sources/providers}/git.js +218 -28
- package/dist/{stash-providers → sources/providers}/index.js +4 -4
- package/dist/sources/providers/install-types.js +14 -0
- package/dist/sources/providers/npm.js +160 -0
- package/dist/sources/providers/provider-utils.js +173 -0
- package/dist/sources/providers/sync-from-ref.js +45 -0
- package/dist/sources/providers/tar-utils.js +154 -0
- package/dist/{stash-providers → sources/providers}/website.js +60 -20
- package/dist/{stash-resolve.js → sources/resolve.js} +13 -12
- package/dist/{wiki.js → wiki/wiki.js} +18 -17
- package/dist/{workflow-authoring.js → workflows/authoring.js} +48 -17
- package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
- package/dist/{workflow-db.js → workflows/db.js} +1 -1
- package/dist/workflows/document-cache.js +20 -0
- package/dist/workflows/parser.js +379 -0
- package/dist/workflows/renderer.js +78 -0
- package/dist/{workflow-runs.js → workflows/runs.js} +84 -30
- package/dist/workflows/schema.js +11 -0
- package/dist/workflows/validator.js +48 -0
- package/docs/README.md +30 -0
- package/docs/migration/release-notes/0.0.13.md +4 -0
- package/docs/migration/release-notes/0.1.0.md +6 -0
- package/docs/migration/release-notes/0.2.0.md +6 -0
- package/docs/migration/release-notes/0.3.0.md +5 -0
- package/docs/migration/release-notes/0.5.0.md +6 -0
- package/docs/migration/release-notes/0.6.0.md +75 -0
- package/docs/migration/release-notes/README.md +21 -0
- package/package.json +3 -2
- package/dist/embedder.js +0 -351
- package/dist/errors.js +0 -34
- package/dist/migration-help.js +0 -110
- package/dist/registry-factory.js +0 -19
- package/dist/registry-install.js +0 -532
- package/dist/ripgrep.js +0 -2
- package/dist/stash-provider-factory.js +0 -35
- package/dist/stash-provider.js +0 -1
- package/dist/stash-providers/filesystem.js +0 -41
- package/dist/stash-providers/openviking.js +0 -348
- package/dist/stash-providers/provider-utils.js +0 -11
- package/dist/stash-types.js +0 -1
- package/dist/workflow-markdown.js +0 -251
- /package/dist/{markdown.js → core/markdown.js} +0 -0
- /package/dist/{warn.js → core/warn.js} +0 -0
- /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
- /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
- /package/dist/{github.js → integrations/github.js} +0 -0
- /package/dist/{registry-provider.js → registry/types.js} +0 -0
- /package/dist/{registry-types.js → sources/types.js} +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { filterNonEmptyStrings } from "./common";
|
|
5
|
+
import { ConfigError } from "./errors";
|
|
4
6
|
import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath } from "./paths";
|
|
7
|
+
import { warn } from "./warn";
|
|
5
8
|
// ── Defaults ────────────────────────────────────────────────────────────────
|
|
6
9
|
export const DEFAULT_CONFIG = {
|
|
7
10
|
semanticSearchMode: "auto",
|
|
@@ -29,22 +32,56 @@ export function resetConfigCache() {
|
|
|
29
32
|
cachedConfig = undefined;
|
|
30
33
|
cachedUserConfig = undefined;
|
|
31
34
|
}
|
|
35
|
+
function hashString(text) {
|
|
36
|
+
// Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
|
|
37
|
+
// content changes between config writes when filesystem mtime resolution is
|
|
38
|
+
// too coarse to reflect rapid back-to-back writes (common in tests).
|
|
39
|
+
let hash = 0x811c9dc5;
|
|
40
|
+
for (let i = 0; i < text.length; i++) {
|
|
41
|
+
hash ^= text.charCodeAt(i);
|
|
42
|
+
hash = Math.imul(hash, 0x01000193);
|
|
43
|
+
}
|
|
44
|
+
return (hash >>> 0).toString(16);
|
|
45
|
+
}
|
|
32
46
|
export function loadUserConfig() {
|
|
33
47
|
const configPath = getConfigPath();
|
|
34
48
|
let stat;
|
|
35
49
|
try {
|
|
36
50
|
stat = fs.statSync(configPath);
|
|
37
|
-
if (cachedUserConfig && cachedUserConfig.path === configPath && cachedUserConfig.mtime === stat.mtimeMs) {
|
|
38
|
-
return cachedUserConfig.config;
|
|
39
|
-
}
|
|
40
51
|
}
|
|
41
52
|
catch {
|
|
42
53
|
cachedUserConfig = undefined;
|
|
43
54
|
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
44
55
|
}
|
|
45
|
-
|
|
56
|
+
// Cache key combines mtimeMs + size + content hash. mtimeMs alone is unreliable
|
|
57
|
+
// when tests write multiple times within the filesystem mtime resolution
|
|
58
|
+
// window (often 1ms+). Reading + hashing on cache miss is cheap and ensures
|
|
59
|
+
// we never serve stale config.
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = fs.readFileSync(configPath, "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
cachedUserConfig = undefined;
|
|
66
|
+
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
67
|
+
}
|
|
68
|
+
const contentHash = hashString(text);
|
|
69
|
+
if (cachedUserConfig &&
|
|
70
|
+
cachedUserConfig.path === configPath &&
|
|
71
|
+
cachedUserConfig.mtime === stat.mtimeMs &&
|
|
72
|
+
cachedUserConfig.size === stat.size &&
|
|
73
|
+
cachedUserConfig.contentHash === contentHash) {
|
|
74
|
+
return cachedUserConfig.config;
|
|
75
|
+
}
|
|
76
|
+
const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfigFromText(configPath, text));
|
|
46
77
|
const finalConfig = applyRuntimeEnvApiKeys(config);
|
|
47
|
-
cachedUserConfig = {
|
|
78
|
+
cachedUserConfig = {
|
|
79
|
+
config: finalConfig,
|
|
80
|
+
path: configPath,
|
|
81
|
+
mtime: stat.mtimeMs,
|
|
82
|
+
size: stat.size,
|
|
83
|
+
contentHash,
|
|
84
|
+
};
|
|
48
85
|
return finalConfig;
|
|
49
86
|
}
|
|
50
87
|
export function loadConfig() {
|
|
@@ -66,6 +103,7 @@ export function loadConfig() {
|
|
|
66
103
|
}
|
|
67
104
|
export function saveConfig(config) {
|
|
68
105
|
cachedConfig = undefined;
|
|
106
|
+
cachedUserConfig = undefined;
|
|
69
107
|
const configPath = getConfigPath();
|
|
70
108
|
const dir = path.dirname(configPath);
|
|
71
109
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -150,16 +188,16 @@ function pickKnownKeys(raw) {
|
|
|
150
188
|
const legacySemanticSearch = raw.semanticSearch;
|
|
151
189
|
config.semanticSearchMode = legacySemanticSearch ? "auto" : "off";
|
|
152
190
|
}
|
|
153
|
-
// Migrate legacy searchPaths into
|
|
191
|
+
// Migrate legacy searchPaths into sources
|
|
154
192
|
if (Array.isArray(raw.searchPaths)) {
|
|
155
193
|
const legacyPaths = raw.searchPaths.filter((d) => typeof d === "string");
|
|
156
194
|
if (legacyPaths.length > 0) {
|
|
157
|
-
const existing = config.
|
|
195
|
+
const existing = config.sources ?? [];
|
|
158
196
|
const migrated = legacyPaths
|
|
159
197
|
.filter((p) => !existing.some((s) => s.type === "filesystem" && s.path === p))
|
|
160
198
|
.map((p) => ({ type: "filesystem", path: p }));
|
|
161
199
|
if (migrated.length > 0) {
|
|
162
|
-
config.
|
|
200
|
+
config.sources = [...existing, ...migrated];
|
|
163
201
|
}
|
|
164
202
|
}
|
|
165
203
|
}
|
|
@@ -175,12 +213,32 @@ function pickKnownKeys(raw) {
|
|
|
175
213
|
const registries = parseRegistriesConfig(raw.registries);
|
|
176
214
|
if (registries)
|
|
177
215
|
config.registries = registries;
|
|
178
|
-
|
|
216
|
+
// Prefer the new `stashInheritance` field; fall back to the legacy boolean
|
|
217
|
+
// `disableGlobalStashes` so existing config files keep working unchanged.
|
|
218
|
+
if (raw.stashInheritance === "replace" || raw.stashInheritance === "merge") {
|
|
219
|
+
config.stashInheritance = raw.stashInheritance;
|
|
220
|
+
}
|
|
221
|
+
else if (typeof raw.disableGlobalStashes === "boolean") {
|
|
222
|
+
config.stashInheritance = raw.disableGlobalStashes ? "replace" : "merge";
|
|
179
223
|
config.disableGlobalStashes = raw.disableGlobalStashes;
|
|
180
224
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
225
|
+
// Load `sources` (new key) first, then fall back to legacy `stashes` key.
|
|
226
|
+
const sources = parseStashesConfig(raw.sources);
|
|
227
|
+
if (sources) {
|
|
228
|
+
config.sources = sources;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const legacyStashes = parseStashesConfig(raw.stashes);
|
|
232
|
+
if (legacyStashes) {
|
|
233
|
+
// Backwards-compat migration: `stashes[]` → `sources[]` in-memory.
|
|
234
|
+
// Emit a one-time deprecation warning and carry the value forward as
|
|
235
|
+
// `sources`. The renamed key is persisted on the next `akm config` write.
|
|
236
|
+
warn('Config key "stashes" is deprecated; rename it to "sources" in your config file ' +
|
|
237
|
+
`(edit it directly at ${_getConfigPath()}). ` +
|
|
238
|
+
"Your configuration has been loaded successfully — no manual action is required right now.");
|
|
239
|
+
config.sources = legacyStashes;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
184
242
|
const security = parseSecurityConfig(raw.security);
|
|
185
243
|
if (security)
|
|
186
244
|
config.security = security;
|
|
@@ -190,6 +248,18 @@ function pickKnownKeys(raw) {
|
|
|
190
248
|
if (typeof raw.writable === "boolean") {
|
|
191
249
|
config.writable = raw.writable;
|
|
192
250
|
}
|
|
251
|
+
if (typeof raw.defaultWriteTarget === "string" && raw.defaultWriteTarget.trim()) {
|
|
252
|
+
config.defaultWriteTarget = raw.defaultWriteTarget.trim();
|
|
253
|
+
}
|
|
254
|
+
if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
|
|
255
|
+
const searchRaw = raw.search;
|
|
256
|
+
const searchConfig = {};
|
|
257
|
+
if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
|
|
258
|
+
searchConfig.minScore = searchRaw.minScore;
|
|
259
|
+
}
|
|
260
|
+
if (Object.keys(searchConfig).length > 0)
|
|
261
|
+
config.search = searchConfig;
|
|
262
|
+
}
|
|
193
263
|
return config;
|
|
194
264
|
}
|
|
195
265
|
function readNormalizedConfig(configPath) {
|
|
@@ -197,6 +267,19 @@ function readNormalizedConfig(configPath) {
|
|
|
197
267
|
const expanded = raw ? expandEnvVars(raw) : undefined;
|
|
198
268
|
return expanded ? pickKnownKeys(expanded) : undefined;
|
|
199
269
|
}
|
|
270
|
+
function readNormalizedConfigFromText(_configPath, text) {
|
|
271
|
+
let raw;
|
|
272
|
+
try {
|
|
273
|
+
raw = JSON.parse(stripJsonComments(text));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
279
|
+
return undefined;
|
|
280
|
+
const expanded = expandEnvVars(raw);
|
|
281
|
+
return pickKnownKeys(expanded);
|
|
282
|
+
}
|
|
200
283
|
function parseOutputConfig(value) {
|
|
201
284
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
202
285
|
return undefined;
|
|
@@ -380,11 +463,10 @@ function parseLlmConfig(value) {
|
|
|
380
463
|
console.warn(`[akm] Ignoring llm config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
|
|
381
464
|
return undefined;
|
|
382
465
|
}
|
|
383
|
-
|
|
384
|
-
return undefined;
|
|
466
|
+
const model = typeof obj.model === "string" ? obj.model : "";
|
|
385
467
|
const result = {
|
|
386
468
|
endpoint: obj.endpoint,
|
|
387
|
-
model
|
|
469
|
+
model,
|
|
388
470
|
};
|
|
389
471
|
if (typeof obj.provider === "string" && obj.provider) {
|
|
390
472
|
result.provider = obj.provider;
|
|
@@ -428,11 +510,11 @@ function parseInstalledEntries(value) {
|
|
|
428
510
|
if (!Array.isArray(value))
|
|
429
511
|
return undefined;
|
|
430
512
|
const entries = value
|
|
431
|
-
.map((entry) =>
|
|
513
|
+
.map((entry) => parseInstalledStashEntry(entry))
|
|
432
514
|
.filter((entry) => entry !== undefined);
|
|
433
515
|
return entries.length > 0 ? entries : undefined;
|
|
434
516
|
}
|
|
435
|
-
function
|
|
517
|
+
function parseInstalledStashEntry(value) {
|
|
436
518
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
437
519
|
return undefined;
|
|
438
520
|
const obj = value;
|
|
@@ -470,6 +552,14 @@ function parseInstalledKitEntry(value) {
|
|
|
470
552
|
function asNonEmptyString(value) {
|
|
471
553
|
return typeof value === "string" && value ? value : undefined;
|
|
472
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Validate a legacy lockfile/installed-entry source string.
|
|
557
|
+
*
|
|
558
|
+
* Restricted to the four kinds that the install pipeline produces
|
|
559
|
+
* (`"npm" | "github" | "git" | "local"`). The full {@link KitSource} union is
|
|
560
|
+
* wider, but persisted `installed[]` entries should never carry the runtime
|
|
561
|
+
* provider kinds (`"filesystem" | "website"`).
|
|
562
|
+
*/
|
|
473
563
|
function asKitSource(value) {
|
|
474
564
|
if (value === "npm" || value === "github" || value === "git" || value === "local")
|
|
475
565
|
return value;
|
|
@@ -489,7 +579,7 @@ function parseStashesConfig(value) {
|
|
|
489
579
|
if (!Array.isArray(value))
|
|
490
580
|
return undefined;
|
|
491
581
|
const entries = value
|
|
492
|
-
.map((entry) =>
|
|
582
|
+
.map((entry) => parseSourceConfigEntry(entry))
|
|
493
583
|
.filter((entry) => entry !== undefined);
|
|
494
584
|
return entries;
|
|
495
585
|
}
|
|
@@ -550,13 +640,28 @@ function parseInstallAuditAllowedFinding(value) {
|
|
|
550
640
|
finding.reason = reason;
|
|
551
641
|
return finding;
|
|
552
642
|
}
|
|
553
|
-
|
|
643
|
+
/**
|
|
644
|
+
* Legacy stash type aliases that are normalized to canonical types at
|
|
645
|
+
* config-load time. Both "context-hub" and "github" were never distinct
|
|
646
|
+
* provider types — they were always git stashes — so we normalize them in
|
|
647
|
+
* memory to "git" without rewriting `config.json` on disk.
|
|
648
|
+
*/
|
|
649
|
+
const STASH_TYPE_ALIASES = {
|
|
650
|
+
"context-hub": "git",
|
|
651
|
+
github: "git",
|
|
652
|
+
};
|
|
653
|
+
function parseSourceConfigEntry(value) {
|
|
554
654
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
555
655
|
return undefined;
|
|
556
656
|
const obj = value;
|
|
557
|
-
const
|
|
558
|
-
if (!
|
|
657
|
+
const rawType = asNonEmptyString(obj.type);
|
|
658
|
+
if (!rawType)
|
|
559
659
|
return undefined;
|
|
660
|
+
if (rawType === "openviking") {
|
|
661
|
+
const name = asNonEmptyString(obj.name) ?? "unnamed";
|
|
662
|
+
throw new ConfigError(`openviking is not supported in akm v1. API-backed sources will return as a\nseparate QuerySource tier post-v1. Remove the source named "${name}" from your config file\nor downgrade to 0.6.x. See docs/migration/v1.md.`, "INVALID_CONFIG_FILE", `Run \`akm remove ${name}\` then re-run, or edit your config file directly at ${_getConfigPath()} to remove the openviking entry.`);
|
|
663
|
+
}
|
|
664
|
+
const type = STASH_TYPE_ALIASES[rawType] ?? rawType;
|
|
560
665
|
const entry = { type };
|
|
561
666
|
const entryPath = asNonEmptyString(obj.path);
|
|
562
667
|
if (entryPath)
|
|
@@ -571,6 +676,16 @@ function parseStashConfigEntry(value) {
|
|
|
571
676
|
entry.enabled = obj.enabled;
|
|
572
677
|
if (typeof obj.writable === "boolean")
|
|
573
678
|
entry.writable = obj.writable;
|
|
679
|
+
if (typeof obj.primary === "boolean")
|
|
680
|
+
entry.primary = obj.primary;
|
|
681
|
+
// Locked decision 4 (§6 v1 implementation plan): reject writable: true on
|
|
682
|
+
// website / npm sources at config load. The next sync() would clobber
|
|
683
|
+
// writes — allowing this is a footgun, not a feature. Throw early so the
|
|
684
|
+
// user sees the problem at `akm` startup, not when they try to write.
|
|
685
|
+
if (entry.writable === true && (type === "website" || type === "npm")) {
|
|
686
|
+
const label = entry.name ? ` "${entry.name}"` : "";
|
|
687
|
+
throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
|
|
688
|
+
}
|
|
574
689
|
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
575
690
|
entry.options = obj.options;
|
|
576
691
|
}
|
|
@@ -579,6 +694,124 @@ function parseStashConfigEntry(value) {
|
|
|
579
694
|
entry.wikiName = wikiName;
|
|
580
695
|
return entry;
|
|
581
696
|
}
|
|
697
|
+
// ── ConfiguredSource runtime construction ─────────────────────────────────────────
|
|
698
|
+
/**
|
|
699
|
+
* Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
|
|
700
|
+
* `name`. Uses a short hash of the discriminating fields so two equivalent
|
|
701
|
+
* entries collapse to the same generated name.
|
|
702
|
+
*/
|
|
703
|
+
function deriveStashEntryName(entry) {
|
|
704
|
+
if (entry.name)
|
|
705
|
+
return entry.name;
|
|
706
|
+
const seed = JSON.stringify({
|
|
707
|
+
type: entry.type,
|
|
708
|
+
path: entry.path ?? null,
|
|
709
|
+
url: entry.url ?? null,
|
|
710
|
+
});
|
|
711
|
+
const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
|
|
712
|
+
return `${entry.type}-${hash}`;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Convert a persisted {@link SourceConfigEntry} into the runtime
|
|
716
|
+
* {@link SourceSpec} discriminated union. Returns `undefined` when the
|
|
717
|
+
* entry is missing the fields its provider type requires (e.g. a
|
|
718
|
+
* `filesystem` entry with no `path`); callers should drop or warn for those.
|
|
719
|
+
*
|
|
720
|
+
* Unknown provider types fall back to `{ type: "filesystem", path: ... }` so
|
|
721
|
+
* legacy aliases (`"context-hub"`, `"github"` for git) still produce a usable
|
|
722
|
+
* runtime value when a path/url is supplied.
|
|
723
|
+
*/
|
|
724
|
+
export function parseSourceSpec(entry) {
|
|
725
|
+
switch (entry.type) {
|
|
726
|
+
case "filesystem":
|
|
727
|
+
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
728
|
+
case "git":
|
|
729
|
+
case "context-hub":
|
|
730
|
+
case "github":
|
|
731
|
+
// Note: a configured `github` provider entry historically meant "git
|
|
732
|
+
// repo over the GitHub web URL", not the registry-install `github:` ref.
|
|
733
|
+
return entry.url ? { type: "git", url: entry.url } : undefined;
|
|
734
|
+
case "website":
|
|
735
|
+
return entry.url
|
|
736
|
+
? {
|
|
737
|
+
type: "website",
|
|
738
|
+
url: entry.url,
|
|
739
|
+
...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
|
|
740
|
+
}
|
|
741
|
+
: undefined;
|
|
742
|
+
case "npm":
|
|
743
|
+
// Persisted `npm` stash entries are unusual but supported for symmetry.
|
|
744
|
+
return entry.path ? { type: "npm", package: entry.path } : undefined;
|
|
745
|
+
default:
|
|
746
|
+
// Unknown provider — best-effort fallback so callers still get something.
|
|
747
|
+
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Build the full ordered list of runtime {@link ConfiguredSource} values from a
|
|
752
|
+
* loaded {@link AkmConfig}. Order is the canonical iteration order:
|
|
753
|
+
*
|
|
754
|
+
* 1. The entry marked `primary: true` (or, as a backwards-compat shim,
|
|
755
|
+
* a synthetic filesystem entry built from the top-level `stashDir`).
|
|
756
|
+
* 2. Remaining `sources[]` entries in declared order.
|
|
757
|
+
* 3. Legacy `installed[]` entries, mapped into runtime entries.
|
|
758
|
+
*
|
|
759
|
+
* Entries with `enabled: false` are still emitted — callers decide whether
|
|
760
|
+
* to honour the flag (mirrors how `installed[]` entries have always been
|
|
761
|
+
* unconditional). Entries that fail {@link parseSourceSpec} are
|
|
762
|
+
* dropped silently.
|
|
763
|
+
*/
|
|
764
|
+
export function resolveConfiguredSources(config) {
|
|
765
|
+
const entries = [];
|
|
766
|
+
// `sources` is the canonical key. `stashes` is the legacy key that the loader
|
|
767
|
+
// migrates in-memory; only one of the two should be set at runtime.
|
|
768
|
+
const stashes = config.sources ?? config.stashes ?? [];
|
|
769
|
+
// (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
|
|
770
|
+
let primary = stashes.find((entry) => entry.primary === true);
|
|
771
|
+
if (!primary && config.stashDir) {
|
|
772
|
+
primary = { type: "filesystem", path: config.stashDir, primary: true };
|
|
773
|
+
}
|
|
774
|
+
if (primary) {
|
|
775
|
+
const runtime = toConfiguredSource(primary, true);
|
|
776
|
+
if (runtime)
|
|
777
|
+
entries.push(runtime);
|
|
778
|
+
}
|
|
779
|
+
// (2) Declared stashes (skip the primary entry — already added).
|
|
780
|
+
for (const entry of stashes) {
|
|
781
|
+
if (entry === primary)
|
|
782
|
+
continue;
|
|
783
|
+
const runtime = toConfiguredSource(entry, false);
|
|
784
|
+
if (runtime)
|
|
785
|
+
entries.push(runtime);
|
|
786
|
+
}
|
|
787
|
+
// (3) Legacy installed[] entries.
|
|
788
|
+
for (const installed of config.installed ?? []) {
|
|
789
|
+
entries.push({
|
|
790
|
+
name: installed.id,
|
|
791
|
+
type: "filesystem",
|
|
792
|
+
source: { type: "filesystem", path: installed.stashRoot },
|
|
793
|
+
enabled: true,
|
|
794
|
+
writable: installed.writable,
|
|
795
|
+
...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
return entries;
|
|
799
|
+
}
|
|
800
|
+
function toConfiguredSource(persisted, isPrimary) {
|
|
801
|
+
const source = parseSourceSpec(persisted);
|
|
802
|
+
if (!source)
|
|
803
|
+
return undefined;
|
|
804
|
+
return {
|
|
805
|
+
name: deriveStashEntryName(persisted),
|
|
806
|
+
type: persisted.type,
|
|
807
|
+
source,
|
|
808
|
+
...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
|
|
809
|
+
...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
|
|
810
|
+
...(isPrimary || persisted.primary ? { primary: true } : {}),
|
|
811
|
+
...(persisted.options ? { options: persisted.options } : {}),
|
|
812
|
+
...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
582
815
|
function parseRegistryConfigEntry(value) {
|
|
583
816
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
584
817
|
return undefined;
|
|
@@ -621,7 +854,8 @@ function mergeInstallAuditConfig(base, override) {
|
|
|
621
854
|
* Scalar fields follow normal override semantics. Known nested objects are
|
|
622
855
|
* deep-merged so project config files can override individual fields without
|
|
623
856
|
* clobbering sibling settings. `stashes` are additive by default, but a later
|
|
624
|
-
* layer can set `
|
|
857
|
+
* layer can set `stashInheritance: "replace"` (or the legacy
|
|
858
|
+
* `disableGlobalStashes: true`) to drop inherited stashes first.
|
|
625
859
|
*/
|
|
626
860
|
function mergeLoadedConfig(base, override) {
|
|
627
861
|
if (!override)
|
|
@@ -642,12 +876,25 @@ function mergeLoadedConfig(base, override) {
|
|
|
642
876
|
if (base.security && override.security) {
|
|
643
877
|
merged.security = mergeSecurityConfig(base.security, override.security);
|
|
644
878
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
879
|
+
// The new `stashInheritance` field wins; fall back to the legacy
|
|
880
|
+
// `disableGlobalStashes` boolean so old config files behave identically.
|
|
881
|
+
const replaceStashes = override.stashInheritance === "replace" ||
|
|
882
|
+
(override.stashInheritance === undefined && override.disableGlobalStashes === true);
|
|
883
|
+
// Merge `sources` (canonical key). Legacy `stashes` key is handled via the
|
|
884
|
+
// pickKnownKeys migration which promotes it to `sources` at load time.
|
|
885
|
+
const overrideSources = override.sources ?? override.stashes ?? [];
|
|
886
|
+
const baseSources = base.sources ?? base.stashes ?? [];
|
|
887
|
+
if (replaceStashes) {
|
|
888
|
+
merged.sources = [...overrideSources];
|
|
889
|
+
}
|
|
890
|
+
else if (overrideSources.length > 0) {
|
|
891
|
+
merged.sources = [...baseSources, ...overrideSources];
|
|
892
|
+
}
|
|
893
|
+
else if (baseSources.length > 0) {
|
|
894
|
+
merged.sources = [...baseSources];
|
|
895
|
+
}
|
|
896
|
+
// Clear deprecated stashes field on the merged result — sources is canonical.
|
|
897
|
+
delete merged.stashes;
|
|
651
898
|
return merged;
|
|
652
899
|
}
|
|
653
900
|
function applyRuntimeEnvApiKeys(config) {
|
|
@@ -713,7 +960,19 @@ function isFile(filePath) {
|
|
|
713
960
|
}
|
|
714
961
|
function getFileSignatureToken(filePath) {
|
|
715
962
|
try {
|
|
716
|
-
|
|
963
|
+
const stat = fs.statSync(filePath);
|
|
964
|
+
// mtimeMs alone is unreliable on filesystems with low-resolution mtime
|
|
965
|
+
// (HFS+, some network FS, or very fast back-to-back writes in tests).
|
|
966
|
+
// Combine mtime + size + content hash so the signature actually changes
|
|
967
|
+
// when content does.
|
|
968
|
+
let contentHash = "";
|
|
969
|
+
try {
|
|
970
|
+
contentHash = hashString(fs.readFileSync(filePath, "utf8"));
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
// ignore — fall back to stat-only signature
|
|
974
|
+
}
|
|
975
|
+
return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
|
|
717
976
|
}
|
|
718
977
|
catch {
|
|
719
978
|
return "missing";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error classes for structured exit code classification.
|
|
3
|
+
*
|
|
4
|
+
* - ConfigError -> exit 78 (configuration / environment problems)
|
|
5
|
+
* - UsageError -> exit 2 (bad CLI arguments or invalid input)
|
|
6
|
+
* - NotFoundError -> exit 1 (requested resource missing)
|
|
7
|
+
*
|
|
8
|
+
* Each error carries a machine-readable `code` field. Codes are stable
|
|
9
|
+
* identifiers safe to consume from scripts and JSON output. Existing throw
|
|
10
|
+
* sites without an explicit code receive a default code per error class so
|
|
11
|
+
* older call sites continue to compile and behave unchanged.
|
|
12
|
+
*
|
|
13
|
+
* Each error also exposes a `hint()` method returning an actionable hint
|
|
14
|
+
* string (or `undefined`). Hints can be supplied at construction time or
|
|
15
|
+
* derived from the error `code` via the per-class default mapping below.
|
|
16
|
+
* The CLI surfaces this via `error.hint()` rather than message-regex parsing.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Default hint for each ConfigError code. Keep these short, actionable, and
|
|
20
|
+
* imperative. Returning undefined means "no canned hint".
|
|
21
|
+
*/
|
|
22
|
+
const CONFIG_HINTS = {
|
|
23
|
+
STASH_DIR_NOT_FOUND: "Run `akm init` to create the default stash, or set stashDir in your config.",
|
|
24
|
+
STASH_DIR_NOT_A_DIRECTORY: "The configured stashDir exists but isn't a directory. Update stashDir to point at a folder.",
|
|
25
|
+
STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update stashDir.",
|
|
26
|
+
EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
|
|
27
|
+
LLM_NOT_CONFIGURED: 'Run `akm config set llm \'{"endpoint":"...","model":"..."}\'` to configure the LLM.',
|
|
28
|
+
};
|
|
29
|
+
/** Default hint for each UsageError code. */
|
|
30
|
+
const USAGE_HINTS = {
|
|
31
|
+
INVALID_SOURCE_VALUE: "Pick one of: stash, registry, both.",
|
|
32
|
+
INVALID_FORMAT_VALUE: "Pick one of: json, jsonl, text, yaml.",
|
|
33
|
+
INVALID_DETAIL_VALUE: "Pick one of: brief, normal, full, summary, agent.",
|
|
34
|
+
INVALID_JSON_CONFIG_VALUE: 'Quote JSON values in your shell, for example: akm config set embedding \'{"endpoint":"http://localhost:11434/v1/embeddings","model":"nomic-embed-text"}\'.',
|
|
35
|
+
MISSING_OR_AMBIGUOUS_TARGET: "Use `akm update --all` or pass a target like `akm update npm:@scope/pkg` (not both).",
|
|
36
|
+
TARGET_NOT_UPDATABLE: "Run `akm list` to view your sources, then retry with one of those values.",
|
|
37
|
+
MISSING_REQUIRED_ARGUMENT: "Refs use the form type:name, e.g. `akm show skill:deploy` or `akm show knowledge:guide.md`.",
|
|
38
|
+
};
|
|
39
|
+
/** Default hint for each NotFoundError code. */
|
|
40
|
+
const NOT_FOUND_HINTS = {
|
|
41
|
+
SOURCE_NOT_FOUND: "Run `akm list` to view your sources, then retry with one of those values.",
|
|
42
|
+
};
|
|
43
|
+
/** Raised when configuration or environment is invalid or missing. */
|
|
44
|
+
export class ConfigError extends Error {
|
|
45
|
+
code;
|
|
46
|
+
_hint;
|
|
47
|
+
constructor(msg, code = "INVALID_CONFIG_FILE", hint) {
|
|
48
|
+
super(msg);
|
|
49
|
+
this.name = "ConfigError";
|
|
50
|
+
this.code = code;
|
|
51
|
+
this._hint = hint;
|
|
52
|
+
// Fixes `instanceof` checks under ES5 transpilation targets.
|
|
53
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
54
|
+
}
|
|
55
|
+
hint() {
|
|
56
|
+
return this._hint ?? CONFIG_HINTS[this.code];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Raised when the user supplies invalid arguments or input. */
|
|
60
|
+
export class UsageError extends Error {
|
|
61
|
+
code;
|
|
62
|
+
_hint;
|
|
63
|
+
constructor(msg, code = "INVALID_FLAG_VALUE", hint) {
|
|
64
|
+
super(msg);
|
|
65
|
+
this.name = "UsageError";
|
|
66
|
+
this.code = code;
|
|
67
|
+
this._hint = hint;
|
|
68
|
+
// Fixes `instanceof` checks under ES5 transpilation targets.
|
|
69
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
70
|
+
}
|
|
71
|
+
hint() {
|
|
72
|
+
return this._hint ?? USAGE_HINTS[this.code];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Raised when a requested resource (asset, entry, file) is not found. */
|
|
76
|
+
export class NotFoundError extends Error {
|
|
77
|
+
code;
|
|
78
|
+
_hint;
|
|
79
|
+
constructor(msg, code = "ASSET_NOT_FOUND", hint) {
|
|
80
|
+
super(msg);
|
|
81
|
+
this.name = "NotFoundError";
|
|
82
|
+
this.code = code;
|
|
83
|
+
this._hint = hint;
|
|
84
|
+
// Fixes `instanceof` checks under ES5 transpilation targets.
|
|
85
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
86
|
+
}
|
|
87
|
+
hint() {
|
|
88
|
+
return this._hint ?? NOT_FOUND_HINTS[this.code];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* **Limitations**: This is a hand-rolled YAML-subset parser with intentional
|
|
13
13
|
* constraints for simplicity and safety:
|
|
14
|
-
* - **
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* - **Top-level values**: string, boolean, and number scalars are supported,
|
|
15
|
+
* as well as top-level list-valued keys using YAML block sequences
|
|
16
|
+
* (`- item`) or flow arrays (`[a, b, c]`).
|
|
17
|
+
* - **List item types**: list items must be scalar values and may be strings,
|
|
18
|
+
* booleans, or numbers.
|
|
18
19
|
* - **No nested objects beyond one level**: Only a single level of indented
|
|
19
20
|
* key-value pairs is supported.
|
|
20
|
-
* - **Scalar values only**: string, boolean, and number scalars are supported.
|
|
21
21
|
*/
|
|
22
22
|
export function parseFrontmatter(raw) {
|
|
23
23
|
const parsedBlock = parseFrontmatterBlock(raw);
|
|
@@ -26,28 +26,75 @@ export function parseFrontmatter(raw) {
|
|
|
26
26
|
}
|
|
27
27
|
const data = {};
|
|
28
28
|
let currentKey = null;
|
|
29
|
+
/** "scalar" | "list" | "object" | "pending" — "pending" means empty value, mode determined by next line */
|
|
30
|
+
let mode = "scalar";
|
|
29
31
|
let nested = null;
|
|
32
|
+
let currentList = null;
|
|
33
|
+
const flushPending = () => {
|
|
34
|
+
// Called when we start a new top-level key and the previous key was still "pending".
|
|
35
|
+
// An empty-value key followed by another top-level key means it was an empty scalar.
|
|
36
|
+
if (mode === "pending" && currentKey !== null) {
|
|
37
|
+
data[currentKey] = "";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
30
40
|
for (const line of parsedBlock.frontmatter.split(/\r?\n/)) {
|
|
41
|
+
// Block-sequence item: "- value" or " - value" (optional 2-space indent)
|
|
42
|
+
// Only match when the current key is in list or pending mode.
|
|
43
|
+
const seqItem = line.match(/^(?: {2})?- (.*)$/);
|
|
44
|
+
if (seqItem && currentKey !== null && (mode === "list" || mode === "pending")) {
|
|
45
|
+
if (mode === "pending") {
|
|
46
|
+
// First block-sequence item after an empty-value key — switch to list mode
|
|
47
|
+
currentList = [];
|
|
48
|
+
data[currentKey] = currentList;
|
|
49
|
+
mode = "list";
|
|
50
|
+
}
|
|
51
|
+
currentList.push(parseYamlScalar(seqItem[1].trim()));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// Indented nested key-value (object under a key with empty value)
|
|
31
55
|
const indented = line.match(/^ {2}(\w[\w-]*):\s*(.+)$/);
|
|
32
|
-
if (indented && currentKey &&
|
|
56
|
+
if (indented && currentKey !== null && (mode === "object" || mode === "pending")) {
|
|
57
|
+
if (mode === "pending") {
|
|
58
|
+
// First indented k-v after an empty-value key — switch to object mode
|
|
59
|
+
nested = {};
|
|
60
|
+
data[currentKey] = nested;
|
|
61
|
+
mode = "object";
|
|
62
|
+
}
|
|
33
63
|
nested[indented[1]] = parseYamlScalar(indented[2].trim());
|
|
34
64
|
continue;
|
|
35
65
|
}
|
|
66
|
+
// Top-level key (possibly with inline value)
|
|
36
67
|
const top = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
37
68
|
if (!top) {
|
|
38
69
|
continue;
|
|
39
70
|
}
|
|
71
|
+
// Starting a new top-level key — flush any pending empty-value key
|
|
72
|
+
flushPending();
|
|
40
73
|
currentKey = top[1];
|
|
41
74
|
const value = top[2].trim();
|
|
42
75
|
if (value === "") {
|
|
43
|
-
|
|
44
|
-
|
|
76
|
+
// Defer mode decision until we see the next line
|
|
77
|
+
mode = "pending";
|
|
78
|
+
nested = null;
|
|
79
|
+
currentList = null;
|
|
80
|
+
// Don't store anything yet — flushPending will set "" if no continuation
|
|
81
|
+
}
|
|
82
|
+
else if (value.startsWith("[") && value.endsWith("]")) {
|
|
83
|
+
// Inline flow array: tags: [ops, networking]
|
|
84
|
+
mode = "list";
|
|
85
|
+
nested = null;
|
|
86
|
+
currentList = parseFlowArray(value);
|
|
87
|
+
data[currentKey] = currentList;
|
|
45
88
|
}
|
|
46
89
|
else {
|
|
90
|
+
mode = "scalar";
|
|
47
91
|
nested = null;
|
|
92
|
+
currentList = null;
|
|
48
93
|
data[currentKey] = parseYamlScalar(value);
|
|
49
94
|
}
|
|
50
95
|
}
|
|
96
|
+
// Flush the last key if it was still pending (empty value, no continuation)
|
|
97
|
+
flushPending();
|
|
51
98
|
return {
|
|
52
99
|
data,
|
|
53
100
|
content: parsedBlock.content,
|
|
@@ -55,6 +102,15 @@ export function parseFrontmatter(raw) {
|
|
|
55
102
|
bodyStartLine: parsedBlock.bodyStartLine,
|
|
56
103
|
};
|
|
57
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Parse a YAML flow array string like `[a, b, c]` into an array of scalars.
|
|
107
|
+
*/
|
|
108
|
+
function parseFlowArray(value) {
|
|
109
|
+
const inner = value.slice(1, -1).trim();
|
|
110
|
+
if (!inner)
|
|
111
|
+
return [];
|
|
112
|
+
return inner.split(",").map((item) => parseYamlScalar(item.trim()));
|
|
113
|
+
}
|
|
58
114
|
export function parseFrontmatterBlock(raw) {
|
|
59
115
|
// Handle both LF and CRLF line endings throughout.
|
|
60
116
|
// The closing --- may be preceded by \r\n; capture and strip trailing \r
|