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.
Files changed (118) hide show
  1. package/CHANGELOG.md +53 -5
  2. package/README.md +9 -9
  3. package/dist/cli.js +379 -1448
  4. package/dist/{completions.js → commands/completions.js} +1 -1
  5. package/dist/{config-cli.js → commands/config-cli.js} +109 -11
  6. package/dist/commands/curate.js +263 -0
  7. package/dist/{info.js → commands/info.js} +17 -11
  8. package/dist/{init.js → commands/init.js} +4 -4
  9. package/dist/{install-audit.js → commands/install-audit.js} +14 -2
  10. package/dist/{installed-kits.js → commands/installed-stashes.js} +122 -50
  11. package/dist/commands/migration-help.js +141 -0
  12. package/dist/{registry-search.js → commands/registry-search.js} +68 -9
  13. package/dist/commands/remember.js +178 -0
  14. package/dist/{stash-search.js → commands/search.js} +28 -69
  15. package/dist/{self-update.js → commands/self-update.js} +3 -3
  16. package/dist/{stash-show.js → commands/show.js} +106 -81
  17. package/dist/{stash-add.js → commands/source-add.js} +133 -67
  18. package/dist/{stash-clone.js → commands/source-clone.js} +15 -13
  19. package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
  20. package/dist/{vault.js → commands/vault.js} +43 -0
  21. package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
  22. package/dist/{asset-registry.js → core/asset-registry.js} +30 -6
  23. package/dist/{asset-spec.js → core/asset-spec.js} +13 -6
  24. package/dist/{common.js → core/common.js} +147 -50
  25. package/dist/{config.js → core/config.js} +288 -29
  26. package/dist/core/errors.js +90 -0
  27. package/dist/{frontmatter.js → core/frontmatter.js} +64 -8
  28. package/dist/{paths.js → core/paths.js} +4 -4
  29. package/dist/core/write-source.js +280 -0
  30. package/dist/{local-search.js → indexer/db-search.js} +49 -32
  31. package/dist/{db.js → indexer/db.js} +210 -81
  32. package/dist/{file-context.js → indexer/file-context.js} +3 -3
  33. package/dist/{indexer.js → indexer/indexer.js} +153 -30
  34. package/dist/{manifest.js → indexer/manifest.js} +10 -10
  35. package/dist/{matchers.js → indexer/matchers.js} +4 -7
  36. package/dist/{metadata.js → indexer/metadata.js} +9 -5
  37. package/dist/{search-source.js → indexer/search-source.js} +97 -55
  38. package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
  39. package/dist/{walker.js → indexer/walker.js} +1 -1
  40. package/dist/{lockfile.js → integrations/lockfile.js} +29 -2
  41. package/dist/{llm.js → llm/client.js} +12 -48
  42. package/dist/llm/embedder.js +127 -0
  43. package/dist/llm/embedders/cache.js +47 -0
  44. package/dist/llm/embedders/local.js +152 -0
  45. package/dist/llm/embedders/remote.js +121 -0
  46. package/dist/llm/embedders/types.js +39 -0
  47. package/dist/llm/metadata-enhance.js +53 -0
  48. package/dist/output/cli-hints.js +301 -0
  49. package/dist/output/context.js +95 -0
  50. package/dist/{renderers.js → output/renderers.js} +57 -61
  51. package/dist/output/shapes.js +212 -0
  52. package/dist/output/text.js +520 -0
  53. package/dist/{registry-build-index.js → registry/build-index.js} +48 -32
  54. package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
  55. package/dist/registry/factory.js +33 -0
  56. package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
  57. package/dist/registry/providers/index.js +11 -0
  58. package/dist/{providers → registry/providers}/skills-sh.js +60 -4
  59. package/dist/{providers → registry/providers}/static-index.js +126 -56
  60. package/dist/registry/providers/types.js +25 -0
  61. package/dist/{registry-resolve.js → registry/resolve.js} +10 -6
  62. package/dist/{detect.js → setup/detect.js} +0 -27
  63. package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
  64. package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
  65. package/dist/{setup.js → setup/setup.js} +162 -129
  66. package/dist/setup/steps.js +45 -0
  67. package/dist/{kit-include.js → sources/include.js} +1 -1
  68. package/dist/sources/provider-factory.js +36 -0
  69. package/dist/sources/provider.js +21 -0
  70. package/dist/sources/providers/filesystem.js +35 -0
  71. package/dist/{stash-providers → sources/providers}/git.js +218 -28
  72. package/dist/{stash-providers → sources/providers}/index.js +4 -4
  73. package/dist/sources/providers/install-types.js +14 -0
  74. package/dist/sources/providers/npm.js +160 -0
  75. package/dist/sources/providers/provider-utils.js +173 -0
  76. package/dist/sources/providers/sync-from-ref.js +45 -0
  77. package/dist/sources/providers/tar-utils.js +154 -0
  78. package/dist/{stash-providers → sources/providers}/website.js +60 -20
  79. package/dist/{stash-resolve.js → sources/resolve.js} +13 -12
  80. package/dist/{wiki.js → wiki/wiki.js} +18 -17
  81. package/dist/{workflow-authoring.js → workflows/authoring.js} +48 -17
  82. package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
  83. package/dist/{workflow-db.js → workflows/db.js} +1 -1
  84. package/dist/workflows/document-cache.js +20 -0
  85. package/dist/workflows/parser.js +379 -0
  86. package/dist/workflows/renderer.js +78 -0
  87. package/dist/{workflow-runs.js → workflows/runs.js} +84 -30
  88. package/dist/workflows/schema.js +11 -0
  89. package/dist/workflows/validator.js +48 -0
  90. package/docs/README.md +30 -0
  91. package/docs/migration/release-notes/0.0.13.md +4 -0
  92. package/docs/migration/release-notes/0.1.0.md +6 -0
  93. package/docs/migration/release-notes/0.2.0.md +6 -0
  94. package/docs/migration/release-notes/0.3.0.md +5 -0
  95. package/docs/migration/release-notes/0.5.0.md +6 -0
  96. package/docs/migration/release-notes/0.6.0.md +75 -0
  97. package/docs/migration/release-notes/README.md +21 -0
  98. package/package.json +3 -2
  99. package/dist/embedder.js +0 -351
  100. package/dist/errors.js +0 -34
  101. package/dist/migration-help.js +0 -110
  102. package/dist/registry-factory.js +0 -19
  103. package/dist/registry-install.js +0 -532
  104. package/dist/ripgrep.js +0 -2
  105. package/dist/stash-provider-factory.js +0 -35
  106. package/dist/stash-provider.js +0 -1
  107. package/dist/stash-providers/filesystem.js +0 -41
  108. package/dist/stash-providers/openviking.js +0 -348
  109. package/dist/stash-providers/provider-utils.js +0 -11
  110. package/dist/stash-types.js +0 -1
  111. package/dist/workflow-markdown.js +0 -251
  112. /package/dist/{markdown.js → core/markdown.js} +0 -0
  113. /package/dist/{warn.js → core/warn.js} +0 -0
  114. /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
  115. /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
  116. /package/dist/{github.js → integrations/github.js} +0 -0
  117. /package/dist/{registry-provider.js → registry/types.js} +0 -0
  118. /package/dist/{registry-types.js → sources/types.js} +0 -0
@@ -3,9 +3,9 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { fetchWithRetry } from "./common";
7
- import { UsageError } from "./errors";
8
- import { asRecord, asString, GITHUB_API_BASE, githubHeaders } from "./github";
6
+ import { fetchWithRetry, jsonWithByteCap } from "../core/common";
7
+ import { UsageError } from "../core/errors";
8
+ import { asRecord, asString, GITHUB_API_BASE, githubHeaders } from "../integrations/github";
9
9
  /**
10
10
  * Validate that a URL is safe to pass to git.
11
11
  * Allowlists https:, http:, ssh:, git: schemes and git@ SSH shorthand.
@@ -451,7 +451,7 @@ function fileUriToPath(ref) {
451
451
  /**
452
452
  * Build a human-readable local ID from an absolute path.
453
453
  * /home/user/akm/skills → ~/akm/skills
454
- * /tmp/my-kit → /tmp/my-kit
454
+ * /tmp/my-stash → /tmp/my-stash
455
455
  */
456
456
  function toReadableLocalId(absolutePath) {
457
457
  const home = os.homedir();
@@ -571,16 +571,20 @@ export function maxSatisfying(versions, range) {
571
571
  candidates.sort((a, b) => compareSemver(b.parsed, a.parsed));
572
572
  return candidates[0].version;
573
573
  }
574
+ // Cap JSON responses at 10 MB — npm package manifests and GitHub API
575
+ // responses are typically a few KB; a compromised registry streaming
576
+ // tens of MB of JSON is a DoS surface, not a feature.
577
+ const REGISTRY_JSON_BYTE_CAP = 10 * 1024 * 1024;
574
578
  async function fetchJson(url, headers) {
575
579
  const response = await fetchWithRetry(url, { headers });
576
580
  if (!response.ok) {
577
581
  throw new Error(`Request failed (${response.status}) for ${url}`);
578
582
  }
579
- return (await response.json());
583
+ return jsonWithByteCap(response, REGISTRY_JSON_BYTE_CAP);
580
584
  }
581
585
  async function tryFetchJson(url, headers) {
582
586
  const response = await fetchWithRetry(url, { headers });
583
587
  if (!response.ok)
584
588
  return null;
585
- return (await response.json());
589
+ return jsonWithByteCap(response, REGISTRY_JSON_BYTE_CAP);
586
590
  }
@@ -91,30 +91,3 @@ export function detectAgentPlatforms() {
91
91
  path: path.join(home, p.relPath),
92
92
  }));
93
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
- }
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { IS_WINDOWS } from "./common";
4
+ import { IS_WINDOWS } from "../core/common";
5
5
  import { RG_BINARY, resolveRg } from "./ripgrep-resolve";
6
6
  /**
7
7
  * Platform and architecture detection for ripgrep binary downloads.
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { IS_WINDOWS } from "./common";
4
- import { getBinDir } from "./paths";
3
+ import { IS_WINDOWS } from "../core/common";
4
+ import { getBinDir } from "../core/paths";
5
5
  export const RG_BINARY = IS_WINDOWS ? "rg.exe" : "rg";
6
6
  function canExecute(filePath) {
7
7
  if (!fs.existsSync(filePath))
@@ -7,25 +7,26 @@
7
7
  */
8
8
  import path from "node:path";
9
9
  import * as p from "@clack/prompts";
10
- import { isHttpUrl } from "./common";
11
- import { DEFAULT_CONFIG, getConfigPath, loadUserConfig, saveConfig } from "./config";
12
- import { closeDatabase, isVecAvailable, openDatabase } from "./db";
13
- import { detectAgentPlatforms, detectOllama, detectOpenViking } from "./detect";
14
- import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "./embedder";
15
- import { akmIndex } from "./indexer";
16
- import { akmInit } from "./init";
17
- import { probeLlmCapabilities } from "./llm";
18
- import { getDefaultStashDir } from "./paths";
19
- import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus } from "./semantic-status";
10
+ import { akmInit } from "../commands/init";
11
+ import { isHttpUrl } from "../core/common";
12
+ import { DEFAULT_CONFIG, getConfigPath, loadUserConfig, saveConfig } from "../core/config";
13
+ import { getDefaultStashDir } from "../core/paths";
14
+ import { closeDatabase, isVecAvailable, openDatabase } from "../indexer/db";
15
+ import { akmIndex } from "../indexer/indexer";
16
+ import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "../indexer/semantic-status";
17
+ import { probeLlmCapabilities } from "../llm/client";
18
+ import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "../llm/embedder";
19
+ import { detectAgentPlatforms, detectOllama } from "./detect";
20
+ import { createSetupContext, runSetupSteps } from "./steps";
20
21
  // ── Constants ───────────────────────────────────────────────────────────────
21
- /** Recommended GitHub repositories shown during setup. */
22
- const RECOMMENDED_GITHUB_REPOS = [
23
- {
24
- url: "https://github.com/andrewyng/context-hub",
25
- name: "context-hub",
26
- hint: "community knowledge",
27
- },
28
- ];
22
+ /**
23
+ * Recommended GitHub repositories shown during setup.
24
+ *
25
+ * Currently empty — populating from the akm-registry at runtime is a
26
+ * separate feature. The wizard prompt infrastructure is retained for that
27
+ * future use.
28
+ */
29
+ const RECOMMENDED_GITHUB_REPOS = [];
29
30
  // Approximate first-download sizes used in the setup note.
30
31
  // LOCAL_MODEL_APPROX_SIZE_MB tracks the default local model (DEFAULT_LOCAL_MODEL).
31
32
  const LOCAL_MODEL_APPROX_SIZE_MB = 130;
@@ -136,11 +137,11 @@ async function prepareSemanticSearchAssets(config) {
136
137
  const remote = isRemoteEmbeddingConfig(config.embedding);
137
138
  // For local embeddings, ensure the required package is installed first.
138
139
  if (!remote) {
139
- if (!(await isTransformersAvailable())) {
140
+ if (!isTransformersAvailable()) {
140
141
  const spin = p.spinner();
141
142
  spin.start("Installing @huggingface/transformers...");
142
143
  try {
143
- const pkgRoot = path.resolve(import.meta.dir, "..");
144
+ const pkgRoot = path.resolve(import.meta.dir, "../..");
144
145
  const proc = Bun.spawn(["bun", "add", "@huggingface/transformers"], {
145
146
  cwd: pkgRoot,
146
147
  stdout: "pipe",
@@ -499,40 +500,44 @@ async function stepRegistries(current) {
499
500
  /**
500
501
  * @internal Exported for testing only.
501
502
  */
502
- export async function stepStashSources(current) {
503
- const stashes = [...(current.stashes ?? [])];
503
+ export async function stepAddSources(current) {
504
+ const stashes = [...(current.sources ?? current.stashes ?? [])];
504
505
  if (stashes.length > 0) {
505
506
  p.log.info(`You have ${stashes.length} existing stash source(s).`);
506
507
  }
507
508
  // ── Recommended GitHub repos ───────────────────────────────────────────
508
- const existingUrls = new Set(stashes.map((s) => s.url));
509
- const repoOptions = RECOMMENDED_GITHUB_REPOS.map((r) => ({
510
- value: r.url,
511
- label: r.name,
512
- hint: existingUrls.has(r.url) ? `${r.hint} (already added)` : r.hint,
513
- }));
514
- const selectedRepos = await prompt(() => p.multiselect({
515
- message: "Recommended GitHub repositories toggle to add or remove:",
516
- options: repoOptions,
517
- initialValues: repoOptions.filter((o) => existingUrls.has(o.value)).map((o) => o.value),
518
- required: false,
519
- }));
520
- // Add newly selected repos
521
- for (const url of selectedRepos) {
522
- if (!existingUrls.has(url)) {
523
- const rec = RECOMMENDED_GITHUB_REPOS.find((r) => r.url === url);
524
- stashes.push({ type: "git", url, name: rec?.name });
525
- existingUrls.add(url);
509
+ // Skip the prompt entirely when there are no recommendations to show.
510
+ // The infrastructure is retained for a future registry-driven version.
511
+ if (RECOMMENDED_GITHUB_REPOS.length > 0) {
512
+ const existingUrls = new Set(stashes.map((s) => s.url));
513
+ const repoOptions = RECOMMENDED_GITHUB_REPOS.map((r) => ({
514
+ value: r.url,
515
+ label: r.name,
516
+ hint: existingUrls.has(r.url) ? `${r.hint} (already added)` : r.hint,
517
+ }));
518
+ const selectedRepos = await prompt(() => p.multiselect({
519
+ message: "Recommended GitHub repositories — toggle to add or remove:",
520
+ options: repoOptions,
521
+ initialValues: repoOptions.filter((o) => existingUrls.has(o.value)).map((o) => o.value),
522
+ required: false,
523
+ }));
524
+ // Add newly selected repos
525
+ for (const url of selectedRepos) {
526
+ if (!existingUrls.has(url)) {
527
+ const rec = RECOMMENDED_GITHUB_REPOS.find((r) => r.url === url);
528
+ stashes.push({ type: "git", url, name: rec?.name });
529
+ existingUrls.add(url);
530
+ }
526
531
  }
527
- }
528
- // Remove deselected repos that were previously configured
529
- for (const rec of RECOMMENDED_GITHUB_REPOS) {
530
- if (existingUrls.has(rec.url) && !selectedRepos.includes(rec.url)) {
531
- const idx = stashes.findIndex((s) => s.url === rec.url);
532
- if (idx !== -1) {
533
- stashes.splice(idx, 1);
534
- existingUrls.delete(rec.url);
535
- p.log.info(`Removed ${rec.name}.`);
532
+ // Remove deselected repos that were previously configured
533
+ for (const rec of RECOMMENDED_GITHUB_REPOS) {
534
+ if (existingUrls.has(rec.url) && !selectedRepos.includes(rec.url)) {
535
+ const idx = stashes.findIndex((s) => s.url === rec.url);
536
+ if (idx !== -1) {
537
+ stashes.splice(idx, 1);
538
+ existingUrls.delete(rec.url);
539
+ p.log.info(`Removed ${rec.name}.`);
540
+ }
536
541
  }
537
542
  }
538
543
  }
@@ -542,7 +547,6 @@ export async function stepStashSources(current) {
542
547
  const action = await prompt(() => p.select({
543
548
  message: "Add another stash source?",
544
549
  options: [
545
- { value: "openviking", label: "OpenViking server", hint: "remote stash" },
546
550
  { value: "github-repo", label: "GitHub repository", hint: "custom URL" },
547
551
  { value: "filesystem", label: "Filesystem path", hint: "local directory" },
548
552
  { value: "done", label: "Done — no more sources" },
@@ -552,45 +556,6 @@ export async function stepStashSources(current) {
552
556
  addMore = false;
553
557
  break;
554
558
  }
555
- if (action === "openviking") {
556
- const url = await promptOrBack(() => p.text({
557
- message: "Enter the OpenViking server URL:",
558
- placeholder: "https://your-openviking-server.example.com",
559
- validate: (v) => {
560
- if (!v?.trim())
561
- return "URL cannot be empty";
562
- if (!v.startsWith("http://") && !v.startsWith("https://"))
563
- return "URL must start with http:// or https://";
564
- },
565
- }));
566
- if (url === null)
567
- continue;
568
- const spin = p.spinner();
569
- spin.start("Checking OpenViking server...");
570
- const result = await detectOpenViking(url.trim());
571
- if (result.available) {
572
- spin.stop("Server is reachable");
573
- }
574
- else {
575
- spin.stop("Server not reachable — adding anyway (it may be temporarily down)");
576
- }
577
- const name = await promptOrBack(() => p.text({
578
- message: "Give this stash a name (optional):",
579
- placeholder: "my-openviking",
580
- }));
581
- if (name === null)
582
- continue;
583
- // Use the normalized URL from detection (trailing slashes stripped)
584
- const entry = { type: "openviking", url: result.url };
585
- if (name.trim())
586
- entry.name = name.trim();
587
- if (!stashes.some((s) => s.url === entry.url)) {
588
- stashes.push(entry);
589
- }
590
- else {
591
- p.log.warn("This URL is already configured.");
592
- }
593
- }
594
559
  if (action === "github-repo") {
595
560
  const url = await promptOrBack(() => p.text({
596
561
  message: "Enter the GitHub repository URL:",
@@ -685,60 +650,128 @@ async function stepAgentPlatforms(current) {
685
650
  return entries;
686
651
  }
687
652
  // ── Main Wizard ─────────────────────────────────────────────────────────────
653
+ /**
654
+ * Build the canonical list of `SetupStep`s for the interactive wizard.
655
+ * Exposed (and exported) so tests and `akm init` can compose subsets.
656
+ *
657
+ * Each step wraps the existing `step*` functions, accumulating its result
658
+ * into the shared `SetupContext`. The `nonInteractive` flag controls
659
+ * inclusion in `akm init` (a non-interactive preset of `akm setup`).
660
+ */
661
+ export function buildSetupSteps(options) {
662
+ const outcome = { semantic: options.semanticSearchOutcome };
663
+ // Local cache of Ollama-detected fields surfaced from the embedding step
664
+ // to the LLM step. Mutable by design — `stepLlm` needs them.
665
+ let ollamaEndpoint;
666
+ let ollamaChatModels;
667
+ const steps = [
668
+ {
669
+ id: "stash-dir",
670
+ label: "Stash Directory",
671
+ nonInteractive: true,
672
+ async run(ctx) {
673
+ const stashDir = await stepStashDir(ctx.config);
674
+ ctx.apply({ stashDir });
675
+ },
676
+ },
677
+ {
678
+ id: "embedding",
679
+ label: "Embedding",
680
+ async run(ctx) {
681
+ if (!options.online) {
682
+ ctx.apply({ embedding: ctx.config.embedding });
683
+ return;
684
+ }
685
+ const result = await stepOllama(ctx.config);
686
+ ollamaEndpoint = result.ollamaEndpoint;
687
+ ollamaChatModels = result.ollamaChatModels;
688
+ ctx.apply({ embedding: result.embedding });
689
+ },
690
+ },
691
+ {
692
+ id: "llm",
693
+ label: "LLM Provider",
694
+ async run(ctx) {
695
+ if (!options.online) {
696
+ ctx.apply({ llm: ctx.config.llm });
697
+ return;
698
+ }
699
+ const llm = await stepLlm(ctx.config, ollamaEndpoint, ollamaChatModels);
700
+ ctx.apply({ llm });
701
+ },
702
+ },
703
+ {
704
+ id: "semantic-search",
705
+ label: "Semantic Search",
706
+ async run(ctx) {
707
+ const semantic = await stepSemanticSearch(ctx.config, ctx.config.embedding);
708
+ outcome.semantic = semantic;
709
+ ctx.apply({ semanticSearchMode: semantic.mode });
710
+ },
711
+ },
712
+ {
713
+ id: "registries",
714
+ label: "Registries",
715
+ async run(ctx) {
716
+ const registries = await stepRegistries(ctx.config);
717
+ ctx.apply({ registries });
718
+ },
719
+ },
720
+ {
721
+ id: "stash-sources",
722
+ label: "Stash Sources",
723
+ async run(ctx) {
724
+ const stashes = await stepAddSources(ctx.config);
725
+ const platforms = await stepAgentPlatforms(ctx.config);
726
+ const merged = [...stashes];
727
+ for (const ps of platforms) {
728
+ if (!merged.some((s) => s.path === ps.path))
729
+ merged.push(ps);
730
+ }
731
+ ctx.apply({ sources: merged.length > 0 ? merged : undefined });
732
+ },
733
+ },
734
+ ];
735
+ return { steps, outcome };
736
+ }
688
737
  export async function runSetupWizard() {
689
738
  p.intro("akm setup");
690
739
  const current = loadUserConfig();
691
740
  const configPath = getConfigPath();
692
- // Step 1: Stash directory
693
- p.log.step("Step 1: Stash Directory");
694
- const stashDir = await stepStashDir(current);
695
741
  // Quick connectivity check — skip network-dependent steps when offline
696
742
  const online = await isOnline();
697
743
  if (!online) {
698
744
  p.log.warn("No network connectivity detected. Skipping Ollama detection and remote embedding checks.\n" +
699
745
  "Local-only setup will continue. Re-run `akm setup` when online for full configuration.");
700
746
  }
701
- // Step 2: Embedding (Ollama detection drives the embedding choice + surfaces
702
- // the Ollama endpoint to the LLM step that follows).
703
- p.log.step("Step 2: Embedding");
704
- const { embedding, ollamaEndpoint, ollamaChatModels } = online
705
- ? await stepOllama(current)
706
- : { embedding: current.embedding };
707
- // Step 2b: LLM provider Anthropic / OpenAI / Gemini / Ollama / custom.
708
- p.log.step("Step 2b: LLM Provider");
709
- const llm = online ? await stepLlm(current, ollamaEndpoint, ollamaChatModels) : current.llm;
710
- // Step 3: Semantic search assets
711
- p.log.step("Step 3: Semantic Search");
712
- const semanticSearchMode = await stepSemanticSearch(current, embedding);
713
- // Step 4: Registries
714
- p.log.step("Step 4: Registries");
715
- const registries = await stepRegistries(current);
716
- // Step 5: Stash sources
717
- p.log.step("Step 5: Stash Sources");
718
- const stashes = await stepStashSources(current);
719
- // Step 6: Agent platform detection
720
- p.log.step("Step 6: Agent Platform Detection");
721
- const platformStashes = await stepAgentPlatforms(current);
722
- // Merge platform stashes into main stashes list
723
- const allStashes = [...stashes];
724
- for (const ps of platformStashes) {
725
- if (!allStashes.some((s) => s.path === ps.path)) {
726
- allStashes.push(ps);
727
- }
728
- }
729
- // Build final config
747
+ const ctx = createSetupContext(current, { nonInteractive: false });
748
+ const { steps, outcome } = buildSetupSteps({
749
+ online,
750
+ semanticSearchOutcome: { mode: current.semanticSearchMode, prepareAssets: false },
751
+ });
752
+ // Wrap each step with a `p.log.step()` header so the wizard UI is
753
+ // unchanged. The canonical `runSetupSteps()` runner is used directly by
754
+ // `akm init` (non-interactive) and by tests.
755
+ const labeledSteps = steps.map((step) => ({
756
+ ...step,
757
+ async run(stepCtx) {
758
+ p.log.step(step.label);
759
+ await step.run(stepCtx);
760
+ },
761
+ }));
762
+ await runSetupSteps(labeledSteps, ctx);
730
763
  const newConfig = {
731
- ...current,
732
- stashDir,
733
- embedding,
734
- llm,
735
- registries,
736
- stashes: allStashes.length > 0 ? allStashes : undefined,
737
- // Preserve existing fields
738
- semanticSearchMode: semanticSearchMode.mode,
764
+ ...ctx.config,
765
+ // Preserve fields the steps don't manage explicitly.
739
766
  installed: current.installed,
740
767
  output: current.output,
741
768
  };
769
+ const semanticSearchMode = outcome.semantic;
770
+ const stashDir = newConfig.stashDir ?? current.stashDir ?? getDefaultStashDir();
771
+ const embedding = newConfig.embedding;
772
+ const llm = newConfig.llm;
773
+ const registries = newConfig.registries;
774
+ const allStashes = newConfig.stashes ?? [];
742
775
  // Confirm before saving
743
776
  const effectiveRegistries = registries ?? DEFAULT_CONFIG.registries ?? [];
744
777
  p.note([
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Composable runner abstraction for `akm setup`.
3
+ *
4
+ * The interactive wizard in `setup.ts` historically ran a fixed series of
5
+ * step functions (`stepStashDir`, `stepOllama`, `stepLlm`, ...) inline.
6
+ * This module formalizes that pattern so steps can be:
7
+ * - reused by `akm init` (non-interactive preset, see Finding 31),
8
+ * - tested in isolation by passing a stub `SetupContext`, and
9
+ * - extended by plugins without touching the wizard call site.
10
+ *
11
+ * Steps mutate state through `SetupContext.apply()`, which accumulates a
12
+ * delta on top of the original config. `stepLlm` reading the embedding
13
+ * endpoint that `stepSemanticSearch` produced is the canonical example of
14
+ * why mutable accumulation is preferred over immutable returns.
15
+ */
16
+ /**
17
+ * Build a fresh `SetupContext` over a starting config. The returned context
18
+ * applies deltas in-place onto an internal accumulator and exposes the
19
+ * latest snapshot via `ctx.config`.
20
+ */
21
+ export function createSetupContext(initial, options) {
22
+ let acc = { ...initial };
23
+ return {
24
+ get config() {
25
+ return acc;
26
+ },
27
+ nonInteractive: options.nonInteractive,
28
+ apply(delta) {
29
+ acc = { ...acc, ...delta };
30
+ },
31
+ };
32
+ }
33
+ /**
34
+ * Run a list of steps against a context. Steps marked interactive-only are
35
+ * skipped when `ctx.nonInteractive` is true. Returns the final accumulated
36
+ * config so callers can persist it without re-reading the context.
37
+ */
38
+ export async function runSetupSteps(steps, ctx) {
39
+ for (const step of steps) {
40
+ if (ctx.nonInteractive && !step.nonInteractive)
41
+ continue;
42
+ await step.run(ctx);
43
+ }
44
+ return ctx.config;
45
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { isWithin } from "./common";
3
+ import { isWithin } from "../core/common";
4
4
  // ── Helpers ─────────────────────────────────────────────────────────────────
5
5
  /** Key to check in package.json for akm include configuration. */
6
6
  const INCLUDE_CONFIG_KEYS = ["akm"];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Source provider factory map.
3
+ *
4
+ * Maps source kind identifiers (e.g. "filesystem", "git", "website", "npm")
5
+ * to factory functions that build {@link SourceProvider} instances from a
6
+ * {@link SourceConfigEntry}.
7
+ *
8
+ * Distinct from the registry-discovery factory (`registry/factory.ts`).
9
+ * Both share `create-provider-registry.ts` for the underlying string→factory
10
+ * map.
11
+ */
12
+ import { createProviderRegistry } from "../registry/create-provider-registry";
13
+ // ── Factory map ─────────────────────────────────────────────────────────────
14
+ const registry = createProviderRegistry();
15
+ export function registerSourceProvider(type, factory) {
16
+ registry.register(type, factory);
17
+ }
18
+ export function resolveSourceProviderFactory(type) {
19
+ return registry.resolve(type);
20
+ }
21
+ /**
22
+ * Build a {@link SourceProvider} for every enabled source in the config that
23
+ * has a registered factory.
24
+ */
25
+ export function resolveSourceProviders(config) {
26
+ const providers = [];
27
+ for (const entry of config.sources ?? config.stashes ?? []) {
28
+ if (entry.enabled === false)
29
+ continue;
30
+ const factory = registry.resolve(entry.type);
31
+ if (factory) {
32
+ providers.push(factory(entry));
33
+ }
34
+ }
35
+ return providers;
36
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * SourceProvider — minimal v1 interface (spec §2.1).
3
+ *
4
+ * A SourceProvider gets files into a directory. The indexer walks `path()`
5
+ * and reads files from disk. Search and show go through the indexer, not
6
+ * through provider methods.
7
+ *
8
+ * Three required members + one optional:
9
+ * - name configured source name
10
+ * - kind "filesystem" | "git" | "website" | "npm"
11
+ * - init(ctx) called once after construction
12
+ * - path() the directory the indexer walks (stable for instance lifetime)
13
+ * - sync?() refresh the directory from upstream (no-op for filesystem)
14
+ *
15
+ * All other writing/reading concerns live outside this interface:
16
+ * - Writes: src/core/write-source.ts (Phase 5)
17
+ * - Reads: src/indexer.ts (Phase 4)
18
+ * - Install: src/sources/providers/sync-from-ref.ts (install-time helpers,
19
+ * separate from configured-source plumbing)
20
+ */
21
+ export {};
@@ -0,0 +1,35 @@
1
+ import { resolveStashDir } from "../../core/common";
2
+ import { ConfigError } from "../../core/errors";
3
+ import { registerSourceProvider } from "../provider-factory";
4
+ /**
5
+ * Filesystem source — points at a directory the user already manages.
6
+ *
7
+ * Implements the v1 {@link SourceProvider} interface (spec §2.1, §2.4):
8
+ * just `{ name, kind, init, path }`. No `sync()` — content is the user's
9
+ * own directory, never refreshed by akm.
10
+ */
11
+ class FilesystemSourceProvider {
12
+ kind = "filesystem";
13
+ name;
14
+ #stashDir;
15
+ constructor(entry) {
16
+ if (entry.type !== "filesystem") {
17
+ throw new ConfigError(`FilesystemSourceProvider invoked with type="${entry.type}"`);
18
+ }
19
+ this.#stashDir = entry.path ?? resolveStashDir();
20
+ if (!this.#stashDir) {
21
+ throw new ConfigError("filesystem source requires a `path`");
22
+ }
23
+ this.name = entry.name ?? this.#stashDir;
24
+ }
25
+ async init(_ctx) {
26
+ // Filesystem sources resolve their path eagerly in the constructor;
27
+ // init has nothing to do beyond letting the registry know we're ready.
28
+ }
29
+ path() {
30
+ return this.#stashDir;
31
+ }
32
+ }
33
+ // ── Self-register ───────────────────────────────────────────────────────────
34
+ registerSourceProvider("filesystem", (config) => new FilesystemSourceProvider(config));
35
+ export { FilesystemSourceProvider };