akm-cli 0.1.2 → 0.2.0

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/dist/setup.js ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Interactive configuration wizard for akm.
3
+ *
4
+ * Walks users through service detection, embedding/LLM setup,
5
+ * registry selection, stash sources, and agent platform discovery.
6
+ * Collects all choices and writes config once at the end.
7
+ */
8
+ import * as p from "@clack/prompts";
9
+ import { DEFAULT_CONFIG, getConfigPath, loadConfig, saveConfig } from "./config";
10
+ import { detectAgentPlatforms, detectOllama, detectOpenViking } from "./detect";
11
+ import { akmIndex } from "./indexer";
12
+ import { akmInit } from "./init";
13
+ import { getDefaultStashDir } from "./paths";
14
+ // ── Constants ───────────────────────────────────────────────────────────────
15
+ /** Recommended GitHub repositories shown during setup. */
16
+ const RECOMMENDED_GITHUB_REPOS = [
17
+ {
18
+ url: "https://github.com/andrewyng/context-hub",
19
+ name: "context-hub",
20
+ hint: "community knowledge",
21
+ },
22
+ ];
23
+ // ── Helpers ─────────────────────────────────────────────────────────────────
24
+ function bail() {
25
+ p.cancel("Setup cancelled. No changes were saved.");
26
+ process.exit(0);
27
+ }
28
+ /**
29
+ * Check if a prompt result was cancelled (Escape). If so, ask the user
30
+ * whether they really want to quit. Returns true if the user chose to
31
+ * stay (i.e. the caller should re-prompt), or calls bail() to exit.
32
+ *
33
+ * @internal Exported for testing only.
34
+ */
35
+ export async function onCancel(value) {
36
+ if (!p.isCancel(value))
37
+ return false;
38
+ const confirmExit = await p.confirm({
39
+ message: "Exit the wizard? No changes will be saved.",
40
+ initialValue: false,
41
+ });
42
+ // Only exit when the user explicitly confirms "Yes".
43
+ // Pressing Escape on the confirmation (isCancel) or choosing "No"
44
+ // both mean "stay in the wizard".
45
+ if (confirmExit === true) {
46
+ bail();
47
+ }
48
+ // User chose to stay
49
+ return true;
50
+ }
51
+ /**
52
+ * Run a prompt function in a loop, retrying if the user presses Escape
53
+ * but decides to stay. Returns the non-cancelled result.
54
+ */
55
+ async function prompt(fn) {
56
+ for (;;) {
57
+ const result = await fn();
58
+ if (await onCancel(result))
59
+ continue;
60
+ return result;
61
+ }
62
+ }
63
+ /**
64
+ * Like `prompt`, but pressing Escape returns `null` instead of re-prompting.
65
+ * Use inside sub-actions so the user can back out to the parent menu.
66
+ */
67
+ async function promptOrBack(fn) {
68
+ const result = await fn();
69
+ if (p.isCancel(result))
70
+ return null;
71
+ return result;
72
+ }
73
+ // ── Steps ───────────────────────────────────────────────────────────────────
74
+ async function stepStashDir(current) {
75
+ const defaultDir = current.stashDir ?? getDefaultStashDir();
76
+ const choice = await prompt(() => p.select({
77
+ message: "Where should akm store skills, commands, and other assets?",
78
+ options: [
79
+ { value: "default", label: defaultDir, hint: current.stashDir ? "current" : "default" },
80
+ { value: "custom", label: "Enter a custom path..." },
81
+ ],
82
+ }));
83
+ if (choice === "default")
84
+ return defaultDir;
85
+ const customPath = await prompt(() => p.text({
86
+ message: "Enter the stash directory path:",
87
+ placeholder: defaultDir,
88
+ validate: (v) => {
89
+ if (!v?.trim())
90
+ return "Path cannot be empty";
91
+ },
92
+ }));
93
+ return customPath.trim();
94
+ }
95
+ async function stepOllama(current) {
96
+ const spin = p.spinner();
97
+ spin.start("Checking for Ollama...");
98
+ const ollama = await detectOllama();
99
+ if (!ollama.available) {
100
+ spin.stop("Ollama not detected");
101
+ p.log.info("Ollama is not running. Embeddings will use the built-in local model.\n" +
102
+ "To use Ollama later, install it from https://ollama.com and re-run `akm setup`.");
103
+ // Preserve existing embedding/LLM config when Ollama is not available
104
+ return { embedding: current.embedding, llm: current.llm };
105
+ }
106
+ spin.stop(`Ollama detected at ${ollama.endpoint}`);
107
+ if (ollama.models.length > 0) {
108
+ p.log.info(`Available models: ${ollama.models.join(", ")}`);
109
+ }
110
+ // Embedding model selection
111
+ const embeddingModels = ollama.models.filter((m) => m.includes("embed") || m.includes("nomic") || m.includes("minilm") || m.includes("bge"));
112
+ const hasEmbeddingModels = embeddingModels.length > 0;
113
+ let embedding;
114
+ const embeddingOptions = [];
115
+ for (const m of embeddingModels) {
116
+ embeddingOptions.push({ value: m, label: m, hint: "Ollama" });
117
+ }
118
+ embeddingOptions.push({
119
+ value: "local",
120
+ label: "Built-in local embeddings",
121
+ hint: "no server needed",
122
+ });
123
+ if (current.embedding) {
124
+ embeddingOptions.push({
125
+ value: "keep",
126
+ label: `Keep current: ${current.embedding.provider ?? current.embedding.endpoint}`,
127
+ hint: current.embedding.model,
128
+ });
129
+ }
130
+ const embChoice = await prompt(() => p.select({
131
+ message: "Which embedding provider should akm use?",
132
+ options: embeddingOptions,
133
+ initialValue: hasEmbeddingModels ? embeddingModels[0] : "local",
134
+ }));
135
+ if (embChoice === "keep") {
136
+ embedding = current.embedding;
137
+ }
138
+ else if (embChoice !== "local") {
139
+ // Ask for dimension — different models produce different sizes
140
+ const dimChoice = await prompt(() => p.text({
141
+ message: "Embedding dimension (must match your index; 384 is common for MiniLM/nomic):",
142
+ placeholder: "384",
143
+ defaultValue: "384",
144
+ validate: (v) => {
145
+ const n = Number(v);
146
+ if (!Number.isInteger(n) || n <= 0)
147
+ return "Must be a positive integer";
148
+ },
149
+ }));
150
+ embedding = {
151
+ provider: "ollama",
152
+ endpoint: `${ollama.endpoint}/v1/embeddings`,
153
+ model: embChoice,
154
+ dimension: Number(dimChoice),
155
+ };
156
+ }
157
+ // else: undefined → use built-in local
158
+ // LLM model selection
159
+ const chatModels = ollama.models.filter((m) => !embeddingModels.includes(m));
160
+ const allLlmCandidates = chatModels.length > 0 ? chatModels : ollama.models;
161
+ let llm;
162
+ const llmOptions = [];
163
+ for (const m of allLlmCandidates) {
164
+ llmOptions.push({ value: m, label: m, hint: "Ollama" });
165
+ }
166
+ llmOptions.push({
167
+ value: "none",
168
+ label: "Skip LLM enhancement",
169
+ hint: "use heuristic metadata",
170
+ });
171
+ if (current.llm) {
172
+ llmOptions.push({
173
+ value: "keep",
174
+ label: `Keep current: ${current.llm.provider ?? current.llm.endpoint}`,
175
+ hint: current.llm.model,
176
+ });
177
+ }
178
+ const llmChoice = await prompt(() => p.select({
179
+ message: "Use an LLM for richer metadata during indexing?",
180
+ options: llmOptions,
181
+ initialValue: allLlmCandidates.length > 0 ? allLlmCandidates[0] : "none",
182
+ }));
183
+ if (llmChoice === "keep") {
184
+ llm = current.llm;
185
+ }
186
+ else if (llmChoice !== "none") {
187
+ llm = {
188
+ provider: "ollama",
189
+ endpoint: `${ollama.endpoint}/v1/chat/completions`,
190
+ model: llmChoice,
191
+ temperature: 0.3,
192
+ maxTokens: 512,
193
+ };
194
+ }
195
+ return { embedding, llm };
196
+ }
197
+ async function stepRegistries(current) {
198
+ const defaults = DEFAULT_CONFIG.registries ?? [];
199
+ const currentRegistries = current.registries ?? defaults;
200
+ const defaultUrls = new Set(defaults.map((r) => r.url));
201
+ const enabledUrls = new Set(currentRegistries.filter((r) => r.enabled !== false).map((r) => r.url));
202
+ // Collect custom (non-default) registries to preserve them
203
+ const customRegistries = currentRegistries.filter((r) => !defaultUrls.has(r.url));
204
+ // Show default registries for toggling
205
+ const options = defaults.map((r) => ({
206
+ value: r.url,
207
+ label: r.name ?? r.url,
208
+ hint: r.provider ?? "static index",
209
+ }));
210
+ if (customRegistries.length > 0) {
211
+ p.log.info(`You have ${customRegistries.length} custom registr${customRegistries.length === 1 ? "y" : "ies"} that will be preserved.`);
212
+ }
213
+ const selected = await prompt(() => p.multiselect({
214
+ message: "Which built-in registries should be enabled?",
215
+ options,
216
+ initialValues: options.filter((o) => enabledUrls.has(o.value)).map((o) => o.value),
217
+ }));
218
+ // If all defaults are selected and there are no custom registries,
219
+ // return undefined to use the built-in defaults (avoids pinning)
220
+ const allDefaultsSelected = defaults.every((r) => selected.includes(r.url));
221
+ if (allDefaultsSelected && customRegistries.length === 0) {
222
+ return undefined;
223
+ }
224
+ // Build explicit list: toggled defaults + preserved custom registries
225
+ const result = defaults.map((r) => ({
226
+ ...r,
227
+ enabled: selected.includes(r.url),
228
+ }));
229
+ // Re-add custom registries unchanged
230
+ for (const custom of customRegistries) {
231
+ result.push(custom);
232
+ }
233
+ return result;
234
+ }
235
+ /**
236
+ * @internal Exported for testing only.
237
+ */
238
+ export async function stepStashSources(current) {
239
+ const stashes = [...(current.stashes ?? [])];
240
+ if (stashes.length > 0) {
241
+ p.log.info(`You have ${stashes.length} existing stash source(s).`);
242
+ }
243
+ // ── Recommended GitHub repos ───────────────────────────────────────────
244
+ const existingUrls = new Set(stashes.map((s) => s.url));
245
+ const repoOptions = RECOMMENDED_GITHUB_REPOS.map((r) => ({
246
+ value: r.url,
247
+ label: r.name,
248
+ hint: existingUrls.has(r.url) ? `${r.hint} (already added)` : r.hint,
249
+ }));
250
+ const selectedRepos = await prompt(() => p.multiselect({
251
+ message: "Recommended GitHub repositories — toggle to add or remove:",
252
+ options: repoOptions,
253
+ initialValues: repoOptions.filter((o) => existingUrls.has(o.value)).map((o) => o.value),
254
+ required: false,
255
+ }));
256
+ // Add newly selected repos
257
+ for (const url of selectedRepos) {
258
+ if (!existingUrls.has(url)) {
259
+ const rec = RECOMMENDED_GITHUB_REPOS.find((r) => r.url === url);
260
+ stashes.push({ type: "github", url, name: rec?.name });
261
+ existingUrls.add(url);
262
+ }
263
+ }
264
+ // Remove deselected repos that were previously configured
265
+ for (const rec of RECOMMENDED_GITHUB_REPOS) {
266
+ if (existingUrls.has(rec.url) && !selectedRepos.includes(rec.url)) {
267
+ const idx = stashes.findIndex((s) => s.url === rec.url);
268
+ if (idx !== -1) {
269
+ stashes.splice(idx, 1);
270
+ existingUrls.delete(rec.url);
271
+ p.log.info(`Removed ${rec.name}.`);
272
+ }
273
+ }
274
+ }
275
+ // ── Additional stash sources loop ──────────────────────────────────────
276
+ let addMore = true;
277
+ while (addMore) {
278
+ const action = await prompt(() => p.select({
279
+ message: "Add another stash source?",
280
+ options: [
281
+ { value: "openviking", label: "OpenViking server", hint: "remote stash" },
282
+ { value: "github-repo", label: "GitHub repository", hint: "custom URL" },
283
+ { value: "filesystem", label: "Filesystem path", hint: "local directory" },
284
+ { value: "done", label: "Done — no more sources" },
285
+ ],
286
+ }));
287
+ if (action === "done") {
288
+ addMore = false;
289
+ break;
290
+ }
291
+ if (action === "openviking") {
292
+ const url = await promptOrBack(() => p.text({
293
+ message: "Enter the OpenViking server URL:",
294
+ placeholder: "https://your-openviking-server.example.com",
295
+ validate: (v) => {
296
+ if (!v?.trim())
297
+ return "URL cannot be empty";
298
+ if (!v.startsWith("http://") && !v.startsWith("https://"))
299
+ return "URL must start with http:// or https://";
300
+ },
301
+ }));
302
+ if (url === null)
303
+ continue;
304
+ const spin = p.spinner();
305
+ spin.start("Checking OpenViking server...");
306
+ const result = await detectOpenViking(url.trim());
307
+ if (result.available) {
308
+ spin.stop("Server is reachable");
309
+ }
310
+ else {
311
+ spin.stop("Server not reachable — adding anyway (it may be temporarily down)");
312
+ }
313
+ const name = await promptOrBack(() => p.text({
314
+ message: "Give this stash a name (optional):",
315
+ placeholder: "my-openviking",
316
+ }));
317
+ if (name === null)
318
+ continue;
319
+ // Use the normalized URL from detection (trailing slashes stripped)
320
+ const entry = { type: "openviking", url: result.url };
321
+ if (name.trim())
322
+ entry.name = name.trim();
323
+ if (!stashes.some((s) => s.url === entry.url)) {
324
+ stashes.push(entry);
325
+ }
326
+ else {
327
+ p.log.warn("This URL is already configured.");
328
+ }
329
+ }
330
+ if (action === "github-repo") {
331
+ const url = await promptOrBack(() => p.text({
332
+ message: "Enter the GitHub repository URL:",
333
+ placeholder: "https://github.com/owner/repo",
334
+ validate: (v) => {
335
+ if (!v?.trim())
336
+ return "URL cannot be empty";
337
+ },
338
+ }));
339
+ if (url === null)
340
+ continue;
341
+ const name = await promptOrBack(() => p.text({
342
+ message: "Give this stash a name (optional):",
343
+ placeholder: "my-repo",
344
+ }));
345
+ if (name === null)
346
+ continue;
347
+ const entry = { type: "github", url: url.trim() };
348
+ if (name.trim())
349
+ entry.name = name.trim();
350
+ if (!stashes.some((s) => s.url === entry.url)) {
351
+ stashes.push(entry);
352
+ }
353
+ else {
354
+ p.log.warn("This URL is already configured.");
355
+ }
356
+ }
357
+ if (action === "filesystem") {
358
+ const fsPath = await promptOrBack(() => p.text({
359
+ message: "Enter the directory path:",
360
+ placeholder: "/path/to/stash",
361
+ validate: (v) => {
362
+ if (!v?.trim())
363
+ return "Path cannot be empty";
364
+ },
365
+ }));
366
+ if (fsPath === null)
367
+ continue;
368
+ const resolved = fsPath.trim();
369
+ const name = await promptOrBack(() => p.text({
370
+ message: "Give this stash a name (optional):",
371
+ placeholder: "my-stash",
372
+ }));
373
+ if (name === null)
374
+ continue;
375
+ const entry = { type: "filesystem", path: resolved };
376
+ if (name.trim())
377
+ entry.name = name.trim();
378
+ if (!stashes.some((s) => s.path === entry.path)) {
379
+ stashes.push(entry);
380
+ }
381
+ else {
382
+ p.log.warn("This path is already configured.");
383
+ }
384
+ }
385
+ }
386
+ return stashes;
387
+ }
388
+ async function stepAgentPlatforms(current) {
389
+ const platforms = detectAgentPlatforms();
390
+ if (platforms.length === 0) {
391
+ p.log.info("No agent platform configurations detected.");
392
+ return [];
393
+ }
394
+ const existingPaths = new Set((current.stashes ?? []).map((s) => s.path));
395
+ // Filter out platforms already configured
396
+ const newPlatforms = platforms.filter((pl) => !existingPaths.has(pl.path));
397
+ if (newPlatforms.length === 0) {
398
+ p.log.info(`Detected ${platforms.length} agent platform(s), all already configured as stash sources.`);
399
+ return [];
400
+ }
401
+ const selected = await prompt(() => p.multiselect({
402
+ message: "Found agent platform configurations. Add as stash sources?",
403
+ options: newPlatforms.map((pl) => ({
404
+ value: pl.path,
405
+ label: pl.name,
406
+ hint: pl.path,
407
+ })),
408
+ required: false,
409
+ }));
410
+ const entries = [];
411
+ for (const selectedPath of selected) {
412
+ const platform = newPlatforms.find((pl) => pl.path === selectedPath);
413
+ if (platform) {
414
+ entries.push({
415
+ type: "filesystem",
416
+ path: platform.path,
417
+ name: platform.name.toLowerCase().replace(/\s+/g, "-"),
418
+ });
419
+ }
420
+ }
421
+ return entries;
422
+ }
423
+ // ── Main Wizard ─────────────────────────────────────────────────────────────
424
+ export async function runSetupWizard() {
425
+ p.intro("akm setup");
426
+ const current = loadConfig();
427
+ const configPath = getConfigPath();
428
+ // Step 1: Stash directory
429
+ p.log.step("Step 1: Stash Directory");
430
+ const stashDir = await stepStashDir(current);
431
+ // Step 2: Ollama / Embedding / LLM
432
+ p.log.step("Step 2: Embedding & LLM");
433
+ const { embedding, llm } = await stepOllama(current);
434
+ // Step 3: Registries
435
+ p.log.step("Step 3: Registries");
436
+ const registries = await stepRegistries(current);
437
+ // Step 4: Stash sources
438
+ p.log.step("Step 4: Stash Sources");
439
+ const stashes = await stepStashSources(current);
440
+ // Step 5: Agent platform detection
441
+ p.log.step("Step 5: Agent Platform Detection");
442
+ const platformStashes = await stepAgentPlatforms(current);
443
+ // Merge platform stashes into main stashes list
444
+ const allStashes = [...stashes];
445
+ for (const ps of platformStashes) {
446
+ if (!allStashes.some((s) => s.path === ps.path)) {
447
+ allStashes.push(ps);
448
+ }
449
+ }
450
+ // Build final config
451
+ const newConfig = {
452
+ ...current,
453
+ stashDir,
454
+ embedding,
455
+ llm,
456
+ registries,
457
+ stashes: allStashes.length > 0 ? allStashes : undefined,
458
+ // Preserve existing fields
459
+ semanticSearch: current.semanticSearch,
460
+ installed: current.installed,
461
+ output: current.output,
462
+ };
463
+ // Confirm before saving
464
+ const effectiveRegistries = registries ?? DEFAULT_CONFIG.registries ?? [];
465
+ p.note([
466
+ `Stash directory: ${stashDir}`,
467
+ `Embedding: ${embedding ? `${embedding.provider ?? "remote"} / ${embedding.model}` : "built-in local"}`,
468
+ `LLM: ${llm ? `${llm.provider ?? "remote"} / ${llm.model}` : "disabled"}`,
469
+ `Registries: ${effectiveRegistries.filter((r) => r.enabled !== false).length} enabled`,
470
+ `Stash sources: ${allStashes.length}`,
471
+ ].join("\n"), "Configuration Summary");
472
+ const shouldSave = await prompt(() => p.confirm({
473
+ message: "Save this configuration?",
474
+ initialValue: true,
475
+ }));
476
+ if (!shouldSave)
477
+ bail();
478
+ // Save config
479
+ saveConfig(newConfig);
480
+ // Initialize stash directory
481
+ await akmInit({ dir: stashDir });
482
+ // Build search index
483
+ const spin = p.spinner();
484
+ spin.start("Building search index...");
485
+ try {
486
+ const indexResult = await akmIndex({ stashDir });
487
+ spin.stop(`Indexed ${indexResult.totalEntries} assets.`);
488
+ }
489
+ catch (err) {
490
+ spin.stop("Indexing failed — you can run `akm index` manually later.");
491
+ p.log.warn(String(err));
492
+ }
493
+ // API key reminder
494
+ if (embedding?.apiKey === undefined && embedding?.provider !== "ollama") {
495
+ // Only remind about API keys for non-Ollama remote providers
496
+ if (embedding?.endpoint && !embedding.endpoint.includes("localhost")) {
497
+ p.log.info("Reminder: Set your embedding API key via the AKM_EMBED_API_KEY environment variable.");
498
+ }
499
+ }
500
+ if (llm?.apiKey === undefined && llm?.provider !== "ollama") {
501
+ if (llm?.endpoint && !llm.endpoint.includes("localhost")) {
502
+ p.log.info("Reminder: Set your LLM API key via the AKM_LLM_API_KEY environment variable.");
503
+ }
504
+ }
505
+ p.outro(`Configuration saved to ${configPath}`);
506
+ }
@@ -98,7 +98,9 @@ export async function akmClone(options) {
98
98
  }
99
99
  else {
100
100
  const resolvedSource = path.resolve(sourcePath);
101
- const resolvedDest = path.resolve(path.join(destRoot, typeDir, destName));
101
+ const sourceExt = path.extname(sourcePath);
102
+ const effectiveDestName = !path.extname(destName) && sourceExt ? destName + sourceExt : destName;
103
+ const resolvedDest = path.resolve(path.join(destRoot, typeDir, effectiveDestName));
102
104
  if (resolvedSource === resolvedDest) {
103
105
  throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`);
104
106
  }
@@ -26,8 +26,6 @@ export function resolveStashProviders(config) {
26
26
  for (const entry of config.stashes ?? []) {
27
27
  if (entry.enabled === false)
28
28
  continue;
29
- if (entry.type === "filesystem")
30
- continue;
31
29
  const factory = registry.resolve(entry.type);
32
30
  if (factory) {
33
31
  providers.push(factory(entry));
@@ -8,21 +8,20 @@ class FilesystemStashProvider {
8
8
  type = "filesystem";
9
9
  name;
10
10
  stashDir;
11
- config;
12
11
  constructor(entry) {
13
- this.config = loadConfig();
14
12
  this.stashDir = entry.path ?? resolveStashDir();
15
13
  this.name = entry.name ?? this.stashDir;
16
14
  }
17
15
  async search(options) {
18
- const sources = resolveStashSources(this.stashDir, this.config);
16
+ const config = loadConfig();
17
+ const sources = resolveStashSources(this.stashDir, config);
19
18
  const result = await searchLocal({
20
19
  query: options.query.toLowerCase(),
21
20
  searchType: options.type ?? "any",
22
21
  limit: options.limit,
23
22
  stashDir: this.stashDir,
24
23
  sources,
25
- config: this.config,
24
+ config,
26
25
  });
27
26
  return {
28
27
  hits: result.hits,
@@ -35,7 +34,7 @@ class FilesystemStashProvider {
35
34
  return showLocal({ ref, view });
36
35
  }
37
36
  canShow(ref) {
38
- return !ref.trim().startsWith("viking://");
37
+ return !ref.includes("://");
39
38
  }
40
39
  }
41
40
  // ── Self-register ───────────────────────────────────────────────────────────