akm-cli 0.0.20 → 0.0.22

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 (45) hide show
  1. package/README.md +8 -5
  2. package/dist/asset-spec.js +96 -9
  3. package/dist/cli.js +195 -55
  4. package/dist/common.js +15 -2
  5. package/dist/config-cli.js +65 -6
  6. package/dist/config.js +206 -22
  7. package/dist/create-provider-registry.js +18 -0
  8. package/dist/db.js +156 -53
  9. package/dist/embedder.js +36 -18
  10. package/dist/errors.js +6 -0
  11. package/dist/file-context.js +18 -19
  12. package/dist/frontmatter.js +19 -3
  13. package/dist/indexer.js +126 -89
  14. package/dist/{stash-registry.js → installed-kits.js} +16 -24
  15. package/dist/kit-include.js +108 -0
  16. package/dist/local-search.js +429 -0
  17. package/dist/lockfile.js +47 -5
  18. package/dist/matchers.js +6 -0
  19. package/dist/metadata.js +22 -16
  20. package/dist/paths.js +4 -0
  21. package/dist/providers/skills-sh.js +3 -2
  22. package/dist/providers/static-index.js +4 -9
  23. package/dist/registry-build-index.js +356 -0
  24. package/dist/registry-factory.js +19 -0
  25. package/dist/registry-install.js +114 -109
  26. package/dist/registry-resolve.js +44 -9
  27. package/dist/registry-search.js +14 -9
  28. package/dist/renderers.js +23 -7
  29. package/dist/ripgrep-install.js +9 -4
  30. package/dist/self-update.js +31 -4
  31. package/dist/stash-add.js +75 -6
  32. package/dist/stash-clone.js +1 -1
  33. package/dist/stash-provider-factory.js +52 -0
  34. package/dist/stash-provider.js +1 -0
  35. package/dist/stash-providers/filesystem.js +42 -0
  36. package/dist/stash-providers/index.js +9 -0
  37. package/dist/stash-providers/openviking.js +337 -0
  38. package/dist/stash-resolve.js +33 -3
  39. package/dist/stash-search.js +70 -402
  40. package/dist/stash-show.js +24 -5
  41. package/dist/stash-source.js +19 -11
  42. package/dist/walker.js +15 -10
  43. package/dist/warn.js +7 -0
  44. package/package.json +1 -1
  45. package/dist/provider-registry.js +0 -8
@@ -25,6 +25,10 @@ export function parseConfigValue(key, value) {
25
25
  return { llm: parseLlmConnectionValue(value) };
26
26
  case "registries":
27
27
  return { registries: parseRegistriesValue(value) };
28
+ case "remoteStashSources":
29
+ return { remoteStashSources: parseStashesValue(value) };
30
+ case "stashes":
31
+ return { stashes: parseStashesValue(value) };
28
32
  case "output.format":
29
33
  return { output: { format: parseOutputFormat(value) } };
30
34
  case "output.detail":
@@ -47,6 +51,10 @@ export function getConfigValue(config, key) {
47
51
  return config.llm ?? null;
48
52
  case "registries":
49
53
  return config.registries ?? DEFAULT_CONFIG.registries ?? [];
54
+ case "remoteStashSources":
55
+ return config.remoteStashSources ?? [];
56
+ case "stashes":
57
+ return config.stashes ?? [];
50
58
  case "output.format":
51
59
  return config.output?.format ?? null;
52
60
  case "output.detail":
@@ -63,6 +71,8 @@ export function setConfigValue(config, key, rawValue) {
63
71
  case "embedding":
64
72
  case "llm":
65
73
  case "registries":
74
+ case "remoteStashSources":
75
+ case "stashes":
66
76
  case "output.format":
67
77
  case "output.detail":
68
78
  return mergeConfigValue(config, parseConfigValue(key, rawValue));
@@ -80,6 +90,10 @@ export function unsetConfigValue(config, key) {
80
90
  return { ...config, llm: undefined };
81
91
  case "registries":
82
92
  return { ...config, registries: undefined };
93
+ case "remoteStashSources":
94
+ return { ...config, remoteStashSources: undefined };
95
+ case "stashes":
96
+ return { ...config, stashes: undefined };
83
97
  case "output.format":
84
98
  return { ...config, output: mergeOutputConfig(config.output, { format: undefined }) };
85
99
  case "output.detail":
@@ -89,15 +103,24 @@ export function unsetConfigValue(config, key) {
89
103
  }
90
104
  }
91
105
  export function listConfig(config) {
92
- return {
93
- ...DEFAULT_CONFIG,
94
- ...config,
106
+ const result = {
107
+ semanticSearch: config.semanticSearch,
108
+ registries: config.registries ?? DEFAULT_CONFIG.registries ?? [],
95
109
  output: mergeOutputConfig(DEFAULT_CONFIG.output, config.output) ?? null,
96
110
  stashDir: config.stashDir ?? null,
97
- embedding: config.embedding ?? null,
98
- llm: config.llm ?? null,
99
- registries: config.registries ?? DEFAULT_CONFIG.registries ?? [],
111
+ installed: config.installed ?? [],
112
+ stashes: config.stashes ?? [],
100
113
  };
114
+ if (config.embedding)
115
+ result.embedding = config.embedding;
116
+ if (config.llm)
117
+ result.llm = config.llm;
118
+ // Show legacy keys only if they still have content
119
+ if (config.searchPaths?.length)
120
+ result.searchPaths = config.searchPaths;
121
+ if (config.remoteStashSources?.length)
122
+ result.remoteStashSources = config.remoteStashSources;
123
+ return result;
101
124
  }
102
125
  function mergeConfigValue(config, partial) {
103
126
  return {
@@ -236,3 +259,39 @@ function parseUnknownPositiveInteger(value, key) {
236
259
  }
237
260
  return value;
238
261
  }
262
+ function parseStashesValue(value) {
263
+ if (value === "null" || value === "")
264
+ return undefined;
265
+ let parsed;
266
+ try {
267
+ parsed = JSON.parse(value);
268
+ }
269
+ catch {
270
+ throw new UsageError(`Invalid value for stashes: expected JSON array of {type, path?, url?, name?, enabled?, options?} objects`);
271
+ }
272
+ if (!Array.isArray(parsed)) {
273
+ throw new UsageError(`Invalid value for stashes: expected a JSON array`);
274
+ }
275
+ return parsed.map((entry, i) => {
276
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
277
+ throw new UsageError(`Invalid value for stashes[${i}]: expected an object with a "type" field`);
278
+ }
279
+ const obj = entry;
280
+ if (typeof obj.type !== "string" || !obj.type) {
281
+ throw new UsageError(`Invalid value for stashes[${i}]: "type" is required`);
282
+ }
283
+ const result = { type: obj.type };
284
+ if (typeof obj.path === "string" && obj.path)
285
+ result.path = obj.path;
286
+ if (typeof obj.url === "string" && obj.url)
287
+ result.url = obj.url;
288
+ if (typeof obj.name === "string" && obj.name)
289
+ result.name = obj.name;
290
+ if (typeof obj.enabled === "boolean")
291
+ result.enabled = obj.enabled;
292
+ if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
293
+ result.options = obj.options;
294
+ }
295
+ return result;
296
+ });
297
+ }
package/dist/config.js CHANGED
@@ -23,10 +23,11 @@ export function getConfigPath() {
23
23
  }
24
24
  // ── Load / Save / Update ────────────────────────────────────────────────────
25
25
  let cachedConfig;
26
- export function loadConfig() {
26
+ export function loadConfig(opts) {
27
27
  const configPath = getConfigPath();
28
+ let stat;
28
29
  try {
29
- const stat = fs.statSync(configPath);
30
+ stat = fs.statSync(configPath);
30
31
  if (cachedConfig && cachedConfig.path === configPath && cachedConfig.mtime === stat.mtimeMs) {
31
32
  return cachedConfig.config;
32
33
  }
@@ -37,10 +38,9 @@ export function loadConfig() {
37
38
  return { ...DEFAULT_CONFIG };
38
39
  }
39
40
  const raw = readConfigObject(configPath);
40
- const config = raw ? pickKnownKeys(raw) : { ...DEFAULT_CONFIG };
41
- // Inject API keys from environment variables.
42
- // API keys should be provided via AKM_EMBED_API_KEY and AKM_LLM_API_KEY
43
- // rather than stored in the config file.
41
+ const expanded = raw ? expandEnvVars(raw) : undefined;
42
+ const config = expanded ? pickKnownKeys(expanded) : { ...DEFAULT_CONFIG };
43
+ // Legacy: inject API keys from well-known env vars when not set via ${} substitution
44
44
  if (config.embedding && !config.embedding.apiKey) {
45
45
  const envKey = process.env.AKM_EMBED_API_KEY?.trim();
46
46
  if (envKey)
@@ -51,23 +51,101 @@ export function loadConfig() {
51
51
  if (envKey)
52
52
  config.llm.apiKey = envKey;
53
53
  }
54
- // Cache the parsed config with its path and mtime for subsequent calls
55
- try {
56
- const stat = fs.statSync(configPath);
57
- cachedConfig = { config, path: configPath, mtime: stat.mtimeMs };
58
- }
59
- catch {
60
- // If we can't stat (unlikely since we just read it), skip caching
54
+ if (!opts?.readOnly) {
55
+ // Migrate installed[source: "local"] → stashes[type: "filesystem"]
56
+ try {
57
+ migrateLocalInstalledToStashes(config);
58
+ }
59
+ catch (err) {
60
+ console.warn("[agentikit] Warning: config migration (local→stashes) failed:", err instanceof Error ? err.message : String(err));
61
+ }
62
+ // Migrate remoteStashSources → stashes[]
63
+ try {
64
+ migrateRemoteStashSourcesToStashes(config);
65
+ }
66
+ catch (err) {
67
+ console.warn("[agentikit] Warning: config migration (remoteStashSources→stashes) failed:", err instanceof Error ? err.message : String(err));
68
+ }
61
69
  }
70
+ // Cache the parsed config with its path and mtime for subsequent calls.
71
+ // Reuse the stat already obtained above (avoids a second syscall + TOCTOU gap).
72
+ cachedConfig = { config, path: configPath, mtime: stat.mtimeMs };
62
73
  return config;
63
74
  }
75
+ /**
76
+ * Migrate installed entries with source "local" to stashes[] as filesystem entries.
77
+ * Local directories are search paths, not registry kits — they don't need version
78
+ * tracking, cache management, or update support.
79
+ *
80
+ * Mutates the config in place and persists to disk if any entries are migrated.
81
+ */
82
+ function migrateLocalInstalledToStashes(config) {
83
+ const installed = config.installed;
84
+ if (!installed)
85
+ return;
86
+ const localEntries = installed.filter((e) => e.source === "local");
87
+ if (localEntries.length === 0)
88
+ return;
89
+ const stashes = [...(config.stashes ?? [])];
90
+ const existingPaths = new Set(stashes.filter((s) => !!s.path).map((s) => path.resolve(s.path)));
91
+ let migrated = 0;
92
+ for (const entry of localEntries) {
93
+ const resolved = path.resolve(entry.stashRoot);
94
+ if (existingPaths.has(resolved))
95
+ continue;
96
+ stashes.push({
97
+ type: "filesystem",
98
+ path: resolved,
99
+ name: entry.id,
100
+ });
101
+ existingPaths.add(resolved);
102
+ migrated++;
103
+ }
104
+ if (migrated === 0)
105
+ return;
106
+ // Remove local entries from installed, add to stashes
107
+ config.installed = installed.filter((e) => e.source !== "local");
108
+ config.stashes = stashes;
109
+ saveConfig(config);
110
+ }
111
+ /**
112
+ * Migrate remoteStashSources[] to stashes[] entries.
113
+ * Each remote source becomes a typed stash entry (e.g. type: "openviking").
114
+ *
115
+ * Mutates the config in place and persists to disk if any entries are migrated.
116
+ */
117
+ function migrateRemoteStashSourcesToStashes(config) {
118
+ const remoteSources = config.remoteStashSources;
119
+ if (!remoteSources || remoteSources.length === 0)
120
+ return;
121
+ const stashes = [...(config.stashes ?? [])];
122
+ const existingUrls = new Set(stashes.filter((s) => !!s.url).map((s) => s.url));
123
+ let migrated = 0;
124
+ for (const entry of remoteSources) {
125
+ if (!entry.url || existingUrls.has(entry.url))
126
+ continue;
127
+ stashes.push({
128
+ type: entry.type ?? "openviking",
129
+ url: entry.url,
130
+ name: entry.name,
131
+ options: entry.options,
132
+ });
133
+ existingUrls.add(entry.url);
134
+ migrated++;
135
+ }
136
+ if (migrated === 0)
137
+ return;
138
+ config.stashes = stashes;
139
+ config.remoteStashSources = undefined;
140
+ saveConfig(config);
141
+ }
64
142
  export function saveConfig(config) {
65
143
  cachedConfig = undefined;
66
144
  const configPath = getConfigPath();
67
145
  const dir = path.dirname(configPath);
68
146
  fs.mkdirSync(dir, { recursive: true });
69
147
  const sanitized = sanitizeConfigForWrite(config);
70
- const tmpPath = `${configPath}.tmp.${process.pid}`;
148
+ const tmpPath = `${configPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
71
149
  try {
72
150
  fs.writeFileSync(tmpPath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
73
151
  fs.renameSync(tmpPath, configPath);
@@ -89,19 +167,37 @@ export function saveConfig(config) {
89
167
  */
90
168
  function sanitizeConfigForWrite(config) {
91
169
  const sanitized = { ...config };
92
- if (sanitized.embedding) {
93
- const { apiKey, ...rest } = sanitized.embedding;
170
+ if (config.embedding) {
171
+ const { apiKey, ...rest } = config.embedding;
94
172
  sanitized.embedding = rest;
95
173
  }
96
- if (sanitized.llm) {
97
- const { apiKey, ...rest } = sanitized.llm;
174
+ if (config.llm) {
175
+ const { apiKey, ...rest } = config.llm;
98
176
  sanitized.llm = rest;
99
177
  }
178
+ // Drop empty/migrated keys to keep config clean
179
+ if (!config.searchPaths?.length)
180
+ delete sanitized.searchPaths;
181
+ if (!config.remoteStashSources?.length)
182
+ delete sanitized.remoteStashSources;
100
183
  return sanitized;
101
184
  }
102
185
  export function updateConfig(partial) {
103
186
  const current = loadConfig();
187
+ // Shallow-merge for top-level scalar fields; deep-merge known object-type config keys.
104
188
  const merged = { ...current, ...partial };
189
+ // Deep-merge output — partial update should not wipe sibling keys
190
+ if (current.output && partial.output && partial.output !== current.output) {
191
+ merged.output = { ...current.output, ...partial.output };
192
+ }
193
+ // Deep-merge embedding — only when both sides are objects and partial does not intend to clear
194
+ if (current.embedding && partial.embedding && partial.embedding !== current.embedding) {
195
+ merged.embedding = { ...current.embedding, ...partial.embedding };
196
+ }
197
+ // Deep-merge llm — same pattern
198
+ if (current.llm && partial.llm && partial.llm !== current.llm) {
199
+ merged.llm = { ...current.llm, ...partial.llm };
200
+ }
105
201
  saveConfig(merged);
106
202
  return merged;
107
203
  }
@@ -129,6 +225,12 @@ function pickKnownKeys(raw) {
129
225
  const registries = parseRegistriesConfig(raw.registries);
130
226
  if (registries)
131
227
  config.registries = registries;
228
+ const remoteStash = parseStashesConfig(raw.remoteStashSources);
229
+ if (remoteStash)
230
+ config.remoteStashSources = remoteStash;
231
+ const stashes = parseStashesConfig(raw.stashes);
232
+ if (stashes)
233
+ config.stashes = stashes;
132
234
  const output = parseOutputConfig(raw.output);
133
235
  if (output)
134
236
  config.output = output;
@@ -147,6 +249,49 @@ function parseOutputConfig(value) {
147
249
  }
148
250
  return Object.keys(output).length > 0 ? output : undefined;
149
251
  }
252
+ /**
253
+ * Field names that hold URLs and must NOT have env var substitution applied.
254
+ * Expanding ${VAR} inside a URL could leak secrets by redirecting requests to
255
+ * an attacker-controlled server if the config file is world-readable.
256
+ */
257
+ const URL_FIELD_NAMES = new Set(["url", "endpoint", "artifactUrl"]);
258
+ /**
259
+ * Recursively expand `${VAR}` references in all string values.
260
+ * Supports `${VAR}`, `${VAR:-default}`, and bare `$VAR` at the start of a value.
261
+ * Non-string values pass through unchanged.
262
+ *
263
+ * URL-type fields (named `url`, `endpoint`, `artifactUrl`, or whose value starts
264
+ * with `http://` / `https://`) are skipped to prevent secret injection into URLs.
265
+ */
266
+ function expandEnvVars(value, fieldName) {
267
+ if (typeof value === "string") {
268
+ // Skip URL-type fields by name or by value prefix
269
+ if ((fieldName !== undefined && URL_FIELD_NAMES.has(fieldName)) ||
270
+ value.startsWith("http://") ||
271
+ value.startsWith("https://")) {
272
+ return value;
273
+ }
274
+ return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
275
+ if (braced) {
276
+ const [name, ...rest] = braced.split(":-");
277
+ const fallback = rest.join(":-");
278
+ return process.env[name] ?? fallback ?? "";
279
+ }
280
+ return process.env[bare] ?? "";
281
+ });
282
+ }
283
+ if (Array.isArray(value)) {
284
+ return value.map((item) => expandEnvVars(item));
285
+ }
286
+ if (value !== null && typeof value === "object") {
287
+ const out = {};
288
+ for (const [k, v] of Object.entries(value)) {
289
+ out[k] = expandEnvVars(v, k);
290
+ }
291
+ return out;
292
+ }
293
+ return value;
294
+ }
150
295
  function readConfigObject(configPath) {
151
296
  try {
152
297
  const text = fs.readFileSync(configPath, "utf8");
@@ -168,7 +313,6 @@ export function stripJsonComments(text) {
168
313
  let result = "";
169
314
  let i = 0;
170
315
  let inString = false;
171
- let stringChar = "";
172
316
  while (i < text.length) {
173
317
  if (inString) {
174
318
  if (text[i] === "\\") {
@@ -176,16 +320,16 @@ export function stripJsonComments(text) {
176
320
  i += 2;
177
321
  continue;
178
322
  }
179
- if (text[i] === stringChar) {
323
+ if (text[i] === '"') {
180
324
  inString = false;
181
325
  }
182
326
  result += text[i];
183
327
  i++;
184
328
  continue;
185
329
  }
186
- if (text[i] === '"' || text[i] === "'") {
330
+ // JSON only uses double-quoted strings; single quotes are not valid JSON
331
+ if (text[i] === '"') {
187
332
  inString = true;
188
- stringChar = text[i];
189
333
  result += text[i];
190
334
  i++;
191
335
  continue;
@@ -213,6 +357,10 @@ function parseEmbeddingConfig(value) {
213
357
  const obj = value;
214
358
  if (typeof obj.endpoint !== "string" || !obj.endpoint)
215
359
  return undefined;
360
+ if (!obj.endpoint.startsWith("http://") && !obj.endpoint.startsWith("https://")) {
361
+ console.warn(`[agentikit] Ignoring embedding config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
362
+ return undefined;
363
+ }
216
364
  if (typeof obj.model !== "string" || !obj.model)
217
365
  return undefined;
218
366
  const result = {
@@ -242,6 +390,10 @@ function parseLlmConfig(value) {
242
390
  const obj = value;
243
391
  if (typeof obj.endpoint !== "string" || !obj.endpoint)
244
392
  return undefined;
393
+ if (!obj.endpoint.startsWith("http://") && !obj.endpoint.startsWith("https://")) {
394
+ console.warn(`[agentikit] Ignoring llm config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
395
+ return undefined;
396
+ }
245
397
  if (typeof obj.model !== "string" || !obj.model)
246
398
  return undefined;
247
399
  const result = {
@@ -324,6 +476,38 @@ function parseRegistriesConfig(value) {
324
476
  // which overrides the default. Only return undefined if the field was not an array.
325
477
  return entries;
326
478
  }
479
+ function parseStashesConfig(value) {
480
+ if (!Array.isArray(value))
481
+ return undefined;
482
+ const entries = value
483
+ .map((entry) => parseStashConfigEntry(entry))
484
+ .filter((entry) => entry !== undefined);
485
+ return entries;
486
+ }
487
+ function parseStashConfigEntry(value) {
488
+ if (typeof value !== "object" || value === null || Array.isArray(value))
489
+ return undefined;
490
+ const obj = value;
491
+ const type = asNonEmptyString(obj.type);
492
+ if (!type)
493
+ return undefined;
494
+ const entry = { type };
495
+ const entryPath = asNonEmptyString(obj.path);
496
+ if (entryPath)
497
+ entry.path = entryPath;
498
+ const url = asNonEmptyString(obj.url);
499
+ if (url)
500
+ entry.url = url;
501
+ const name = asNonEmptyString(obj.name);
502
+ if (name)
503
+ entry.name = name;
504
+ if (typeof obj.enabled === "boolean")
505
+ entry.enabled = obj.enabled;
506
+ if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
507
+ entry.options = obj.options;
508
+ }
509
+ return entry;
510
+ }
327
511
  function parseRegistryConfigEntry(value) {
328
512
  if (typeof value !== "object" || value === null || Array.isArray(value))
329
513
  return undefined;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Generic factory-map utility.
3
+ *
4
+ * Creates a lightweight registry that maps string keys to factory functions.
5
+ * Both registry-factory.ts (kit discovery) and stash-provider-factory.ts
6
+ * (stash source providers) are built on this utility.
7
+ */
8
+ export function createProviderRegistry() {
9
+ const map = new Map();
10
+ return {
11
+ register(type, factory) {
12
+ map.set(type, factory);
13
+ },
14
+ resolve(type) {
15
+ return map.get(type) ?? null;
16
+ },
17
+ };
18
+ }