akm-cli 0.1.2 → 0.1.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/README.md CHANGED
@@ -25,12 +25,15 @@ Upgrade in place with `akm upgrade`.
25
25
  ## Quick Start
26
26
 
27
27
  ```sh
28
- akm init # Initialize your working stash
28
+ akm setup # Guided setup: configure, initialize, and index
29
29
  akm add github:owner/repo # Add a kit from GitHub
30
30
  akm search "deploy" # Find assets
31
31
  akm show script:deploy.sh # View details and run command
32
32
  ```
33
33
 
34
+ If you want to skip the wizard, `akm init --dir ~/custom-stash` initializes the
35
+ working stash at a custom path.
36
+
34
37
  ## Features
35
38
 
36
39
  ### Works with Any AI Agent
package/dist/cli.js CHANGED
@@ -405,6 +405,18 @@ function formatSearchPlain(r, detail) {
405
405
  * - registry-* : Kit discovery from remote registries (npm, GitHub)
406
406
  * - installed-kits : Management of kits already installed locally
407
407
  */
408
+ const setupCommand = defineCommand({
409
+ meta: {
410
+ name: "setup",
411
+ description: "Interactive configuration wizard for embeddings, LLM, registries, and stash sources",
412
+ },
413
+ async run() {
414
+ await runWithJsonErrors(async () => {
415
+ const { runSetupWizard } = await import("./setup");
416
+ await runSetupWizard();
417
+ });
418
+ },
419
+ });
408
420
  const initCommand = defineCommand({
409
421
  meta: {
410
422
  name: "init",
@@ -977,6 +989,7 @@ const main = defineCommand({
977
989
  quiet: { type: "boolean", alias: "q", description: "Suppress stderr warnings", default: false },
978
990
  },
979
991
  subCommands: {
992
+ setup: setupCommand,
980
993
  init: initCommand,
981
994
  index: indexCommand,
982
995
  add: addCommand,
package/dist/detect.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Service detection utilities for the setup wizard.
3
+ *
4
+ * Pure detection functions with no user interaction — each returns
5
+ * a result object describing what was found.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ // ── Ollama Detection ────────────────────────────────────────────────────────
10
+ const OLLAMA_BASE = "http://localhost:11434";
11
+ /**
12
+ * Detect if Ollama is running and list available models.
13
+ *
14
+ * Tries the HTTP API first (`/api/tags`), then falls back to `ollama list`
15
+ * via subprocess. Returns available models sorted alphabetically.
16
+ */
17
+ export async function detectOllama() {
18
+ const result = { available: false, models: [], endpoint: OLLAMA_BASE };
19
+ // Try HTTP API first
20
+ try {
21
+ const response = await fetch(`${OLLAMA_BASE}/api/tags`, {
22
+ signal: AbortSignal.timeout(3000),
23
+ });
24
+ if (response.ok) {
25
+ const data = (await response.json());
26
+ if (Array.isArray(data.models)) {
27
+ result.models = data.models
28
+ .map((m) => (typeof m.name === "string" ? m.name.replace(/:latest$/, "") : ""))
29
+ .filter(Boolean)
30
+ .sort();
31
+ result.available = true;
32
+ return result;
33
+ }
34
+ }
35
+ }
36
+ catch {
37
+ // HTTP failed — try CLI fallback
38
+ }
39
+ // CLI fallback
40
+ try {
41
+ const proc = Bun.spawn(["ollama", "list"], {
42
+ stdout: "pipe",
43
+ stderr: "pipe",
44
+ });
45
+ const text = await new Response(proc.stdout).text();
46
+ const exitCode = await proc.exited;
47
+ if (exitCode === 0 && text.trim()) {
48
+ const lines = text.trim().split("\n").slice(1); // skip header
49
+ result.models = lines
50
+ .map((line) => {
51
+ const name = line.split(/\s+/)[0]?.replace(/:latest$/, "");
52
+ return name || "";
53
+ })
54
+ .filter(Boolean)
55
+ .sort();
56
+ result.available = true;
57
+ }
58
+ }
59
+ catch {
60
+ // Ollama not installed or not in PATH
61
+ }
62
+ return result;
63
+ }
64
+ // ── Agent Platform Detection ────────────────────────────────────────────────
65
+ const AGENT_PLATFORMS = [
66
+ { name: "Claude Code", relPath: ".claude" },
67
+ { name: "OpenCode", relPath: ".config/opencode" },
68
+ { name: "Continue", relPath: ".continue" },
69
+ { name: "Codeium / Windsurf", relPath: ".codeium" },
70
+ { name: "Cursor", relPath: ".cursor" },
71
+ { name: "Codex CLI", relPath: ".codex" },
72
+ ];
73
+ /**
74
+ * Scan the user's home directory for known agent platform config directories.
75
+ * Supports both HOME (Unix) and USERPROFILE (Windows).
76
+ */
77
+ export function detectAgentPlatforms() {
78
+ const home = process.env.HOME?.trim() || process.env.USERPROFILE?.trim();
79
+ if (!home)
80
+ return [];
81
+ return AGENT_PLATFORMS.filter((p) => {
82
+ const fullPath = path.join(home, p.relPath);
83
+ try {
84
+ return fs.statSync(fullPath).isDirectory();
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }).map((p) => ({
90
+ name: p.name,
91
+ path: path.join(home, p.relPath),
92
+ }));
93
+ }
94
+ // ── OpenViking Detection ────────────────────────────────────────────────────
95
+ /**
96
+ * Check if an OpenViking server is reachable at the given URL.
97
+ * Uses the lightweight /api/v1/fs/stat endpoint (GET) rather than
98
+ * the search endpoint which requires a running search index.
99
+ */
100
+ export async function detectOpenViking(url) {
101
+ const normalized = url.replace(/\/+$/, "");
102
+ try {
103
+ // Any HTTP response (even non-2xx) from the API endpoint means the server is reachable.
104
+ // Only network errors / timeouts indicate the server is truly unavailable.
105
+ await fetch(`${normalized}/api/v1/fs/stat?uri=${encodeURIComponent("viking://")}`, {
106
+ signal: AbortSignal.timeout(5000),
107
+ });
108
+ return { available: true, url: normalized };
109
+ }
110
+ catch {
111
+ // stat endpoint unreachable — try root URL as fallback
112
+ try {
113
+ await fetch(normalized, { signal: AbortSignal.timeout(5000) });
114
+ return { available: true, url: normalized };
115
+ }
116
+ catch {
117
+ return { available: false, url: normalized };
118
+ }
119
+ }
120
+ }
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
+ }
@@ -80,6 +80,7 @@ class ContextHubStashProvider {
80
80
  }
81
81
  }
82
82
  registerStashProvider("context-hub", (config) => new ContextHubStashProvider(config));
83
+ registerStashProvider("github", (config) => new ContextHubStashProvider(config));
83
84
  function getCachePaths(repoUrl) {
84
85
  const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
85
86
  const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "CLI tool to search, open, and run extension assets from an akm stash directory.",
6
6
  "keywords": [
@@ -58,6 +58,7 @@
58
58
  "bun": ">=1.0.0"
59
59
  },
60
60
  "dependencies": {
61
+ "@clack/prompts": "^1.1.0",
61
62
  "citty": "^0.2.1"
62
63
  }
63
64
  }