akm-cli 0.7.5 → 0.8.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 (152) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +43 -0
  3. package/dist/cli.js +853 -479
  4. package/dist/commands/agent-dispatch.js +102 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +823 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +244 -52
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1170 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +285 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +107 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/workflow-linter.js +53 -0
  38. package/dist/commands/lint.js +1 -0
  39. package/dist/commands/proposal.js +8 -7
  40. package/dist/commands/propose.js +78 -28
  41. package/dist/commands/reflect.js +143 -35
  42. package/dist/commands/registry-search.js +2 -2
  43. package/dist/commands/remember.js +54 -0
  44. package/dist/commands/schema-repair.js +130 -0
  45. package/dist/commands/search.js +21 -5
  46. package/dist/commands/show.js +121 -17
  47. package/dist/commands/source-add.js +10 -10
  48. package/dist/commands/source-manage.js +11 -19
  49. package/dist/commands/tasks.js +385 -0
  50. package/dist/commands/url-checker.js +39 -0
  51. package/dist/commands/vault.js +8 -26
  52. package/dist/core/action-contributors.js +25 -0
  53. package/dist/core/asset-ref.js +4 -0
  54. package/dist/core/asset-registry.js +4 -16
  55. package/dist/core/asset-spec.js +10 -0
  56. package/dist/core/common.js +94 -0
  57. package/dist/core/concurrent.js +22 -0
  58. package/dist/core/config.js +222 -128
  59. package/dist/core/events.js +73 -126
  60. package/dist/core/frontmatter.js +3 -1
  61. package/dist/core/markdown.js +17 -0
  62. package/dist/core/memory-improve.js +678 -0
  63. package/dist/core/parse.js +155 -0
  64. package/dist/core/paths.js +101 -3
  65. package/dist/core/proposal-validators.js +61 -0
  66. package/dist/core/proposals.js +49 -38
  67. package/dist/core/state-db.js +775 -0
  68. package/dist/core/time.js +51 -0
  69. package/dist/core/warn.js +59 -1
  70. package/dist/indexer/db-search.js +52 -238
  71. package/dist/indexer/db.js +378 -1
  72. package/dist/indexer/ensure-index.js +61 -0
  73. package/dist/indexer/graph-boost.js +247 -94
  74. package/dist/indexer/graph-db.js +201 -0
  75. package/dist/indexer/graph-dedup.js +99 -0
  76. package/dist/indexer/graph-extraction.js +409 -76
  77. package/dist/indexer/index-context.js +10 -0
  78. package/dist/indexer/indexer.js +442 -290
  79. package/dist/indexer/llm-cache.js +47 -0
  80. package/dist/indexer/match-contributors.js +141 -0
  81. package/dist/indexer/matchers.js +24 -190
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +194 -175
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/config.js +175 -3
  93. package/dist/integrations/agent/index.js +3 -1
  94. package/dist/integrations/agent/pipeline.js +39 -0
  95. package/dist/integrations/agent/profiles.js +67 -5
  96. package/dist/integrations/agent/prompts.js +77 -72
  97. package/dist/integrations/agent/runners.js +31 -0
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +71 -16
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +190 -123
  116. package/dist/output/shapes.js +33 -0
  117. package/dist/output/text.js +239 -2
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/setup/setup.js +510 -11
  121. package/dist/sources/provider-factory.js +2 -1
  122. package/dist/sources/providers/git.js +2 -2
  123. package/dist/sources/website-ingest.js +4 -0
  124. package/dist/tasks/backends/cron.js +200 -0
  125. package/dist/tasks/backends/exec-utils.js +25 -0
  126. package/dist/tasks/backends/index.js +32 -0
  127. package/dist/tasks/backends/launchd-template.xml +19 -0
  128. package/dist/tasks/backends/launchd.js +184 -0
  129. package/dist/tasks/backends/schtasks-template.xml +29 -0
  130. package/dist/tasks/backends/schtasks.js +212 -0
  131. package/dist/tasks/parser.js +198 -0
  132. package/dist/tasks/resolveAkmBin.js +84 -0
  133. package/dist/tasks/runner.js +432 -0
  134. package/dist/tasks/schedule.js +208 -0
  135. package/dist/tasks/schema.js +13 -0
  136. package/dist/tasks/validator.js +59 -0
  137. package/dist/wiki/index-template.md +12 -0
  138. package/dist/wiki/ingest-workflow-template.md +54 -0
  139. package/dist/wiki/log-template.md +8 -0
  140. package/dist/wiki/schema-template.md +61 -0
  141. package/dist/wiki/wiki-templates.js +12 -0
  142. package/dist/wiki/wiki.js +10 -61
  143. package/dist/workflows/authoring.js +5 -25
  144. package/dist/workflows/renderer.js +8 -3
  145. package/dist/workflows/runs.js +59 -91
  146. package/dist/workflows/validator.js +1 -1
  147. package/dist/workflows/workflow-template.md +24 -0
  148. package/docs/README.md +3 -0
  149. package/docs/migration/release-notes/0.7.0.md +1 -1
  150. package/docs/migration/release-notes/0.8.0.md +43 -0
  151. package/package.json +3 -2
  152. package/dist/templates/wiki-templates.js +0 -100
@@ -11,8 +11,9 @@ import path from "node:path";
11
11
  import * as p from "@clack/prompts";
12
12
  import { akmInit } from "../commands/init";
13
13
  import { isHttpUrl } from "../core/common";
14
- import { DEFAULT_CONFIG, getConfigPath, loadUserConfig, saveConfig } from "../core/config";
15
- import { getDefaultStashDir } from "../core/paths";
14
+ import { DEFAULT_CONFIG, loadUserConfig, saveConfig } from "../core/config";
15
+ import { getConfigPath, getDefaultStashDir } from "../core/paths";
16
+ import { warn } from "../core/warn";
16
17
  import { closeDatabase, isVecAvailable, openDatabase } from "../indexer/db";
17
18
  import { akmIndex } from "../indexer/indexer";
18
19
  import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "../indexer/semantic-status";
@@ -337,8 +338,11 @@ async function prepareSemanticSearchAssets(config) {
337
338
  return { ok: true };
338
339
  }
339
340
  // ── Steps ───────────────────────────────────────────────────────────────────
340
- async function stepStashDir(current) {
341
- const defaultDir = current.stashDir ?? getDefaultStashDir();
341
+ async function stepStashDir(current, options) {
342
+ const defaultDir = options?.preferredDir ?? current.stashDir ?? getDefaultStashDir();
343
+ if (options?.nonInteractive) {
344
+ return defaultDir;
345
+ }
342
346
  const choice = await prompt(() => p.select({
343
347
  message: "Where should akm store skills, commands, and other assets?",
344
348
  options: [
@@ -635,7 +639,7 @@ export async function stepRegistries(current) {
635
639
  * @internal Exported for testing only.
636
640
  */
637
641
  export async function stepAddSources(current, options) {
638
- const existingSources = [...(current.sources ?? current.stashes ?? [])];
642
+ const existingSources = [...(current.sources ?? [])];
639
643
  const sources = [];
640
644
  if (existingSources.length > 0) {
641
645
  p.note(renderConfiguredSourceList(existingSources), "Configured stash sources");
@@ -705,7 +709,7 @@ async function stepAgentPlatforms(current) {
705
709
  p.log.info("No agent platform configurations detected.");
706
710
  return [];
707
711
  }
708
- const existingPaths = new Set((current.sources ?? current.stashes ?? []).map((s) => s.path));
712
+ const existingPaths = new Set((current.sources ?? []).map((s) => s.path));
709
713
  // Filter out platforms already configured
710
714
  const newPlatforms = platforms.filter((pl) => !existingPaths.has(pl.path));
711
715
  if (newPlatforms.length === 0) {
@@ -734,6 +738,365 @@ async function stepAgentPlatforms(current) {
734
738
  }
735
739
  return entries;
736
740
  }
741
+ /**
742
+ * Step 1/2: Configure the small model connection used for metadata and bounded LLM features.
743
+ *
744
+ * Detects Ollama automatically and pre-selects it. The user may also choose
745
+ * OpenAI, LM Studio, a custom endpoint, or skip the step entirely.
746
+ */
747
+ export async function stepSmallModelConnection(current) {
748
+ p.log.step("Step 1/2: Configure your small model connection");
749
+ p.note([
750
+ "This connection is used for background processing:",
751
+ " • akm index (metadata enhancement)",
752
+ " • akm distill (lesson distillation)",
753
+ " • akm remember --enrich (memory compression)",
754
+ " • akm curate --rerank (search reranking)",
755
+ ].join("\n"));
756
+ // Probe for Ollama in the background while showing the note.
757
+ const spin = p.spinner();
758
+ spin.start("Detecting local services...");
759
+ const ollama = await detectOllama();
760
+ spin.stop(ollama.available ? `Ollama detected at ${ollama.endpoint}` : "No local services detected");
761
+ const ollamaEndpoint = ollama.available ? ollama.endpoint : undefined;
762
+ const providerOptions = [];
763
+ if (ollama.available) {
764
+ providerOptions.push({
765
+ value: "ollama",
766
+ label: "Ollama (local)",
767
+ hint: `detected at ${ollama.endpoint}`,
768
+ });
769
+ }
770
+ providerOptions.push({ value: "openai", label: "OpenAI", hint: "requires AKM_LLM_API_KEY" }, { value: "lmstudio", label: "LM Studio / local server", hint: "http://localhost:1234" }, { value: "custom", label: "Custom OpenAI-compatible endpoint" }, { value: "skip", label: "Skip — disable enrichment features" });
771
+ if (current.llm) {
772
+ providerOptions.push({
773
+ value: "keep",
774
+ label: `Keep current: ${current.llm.provider ?? current.llm.endpoint}`,
775
+ hint: current.llm.model,
776
+ });
777
+ }
778
+ const initialValue = current.llm ? "keep" : ollama.available ? "ollama" : "openai";
779
+ const providerChoice = await prompt(() => p.select({
780
+ message: "Provider:",
781
+ options: providerOptions,
782
+ initialValue,
783
+ }));
784
+ if (providerChoice === "keep") {
785
+ return { llm: cloneLlmConfig(current.llm), skipped: false, ollamaEndpoint };
786
+ }
787
+ if (providerChoice === "skip") {
788
+ p.note([
789
+ "Enrichment features disabled:",
790
+ " • akm index — metadata enhancement disabled",
791
+ " • akm distill — lesson generation",
792
+ " • akm remember --enrich",
793
+ " • akm curate --rerank",
794
+ "",
795
+ "You can configure this later with `akm setup`.",
796
+ ].join("\n"), "Warning");
797
+ return { llm: undefined, skipped: true, ollamaEndpoint };
798
+ }
799
+ let llm;
800
+ if (providerChoice === "ollama") {
801
+ const ollamaChatModels = ollama.models.filter((m) => !m.includes("embed") && !m.includes("nomic") && !m.includes("minilm") && !m.includes("bge"));
802
+ let model;
803
+ if (ollamaChatModels.length > 0) {
804
+ model = await prompt(() => p.select({
805
+ message: "Model name:",
806
+ options: [
807
+ ...ollamaChatModels.map((m) => ({ value: m, label: m })),
808
+ { value: "__custom__", label: "Enter a model name manually..." },
809
+ ],
810
+ initialValue: ollamaChatModels[0],
811
+ }));
812
+ if (model === "__custom__") {
813
+ model = await prompt(() => p.text({
814
+ message: "Model name:",
815
+ placeholder: "llama3.2",
816
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
817
+ }));
818
+ }
819
+ }
820
+ else {
821
+ model = await prompt(() => p.text({
822
+ message: "Model name (e.g. llama3.2):",
823
+ placeholder: "llama3.2",
824
+ defaultValue: "llama3.2",
825
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
826
+ }));
827
+ }
828
+ llm = {
829
+ provider: "ollama",
830
+ endpoint: `${ollama.endpoint}/v1/chat/completions`,
831
+ model: model.trim(),
832
+ temperature: 0.3,
833
+ maxTokens: 1024,
834
+ };
835
+ }
836
+ else if (providerChoice === "openai") {
837
+ const model = await prompt(() => p.text({
838
+ message: "Model name:",
839
+ placeholder: "gpt-4o-mini",
840
+ defaultValue: "gpt-4o-mini",
841
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
842
+ }));
843
+ if (!process.env.AKM_LLM_API_KEY) {
844
+ p.log.info("Set AKM_LLM_API_KEY in your shell before running `akm index`.");
845
+ }
846
+ llm = {
847
+ provider: "openai",
848
+ endpoint: "https://api.openai.com/v1/chat/completions",
849
+ model: model.trim() || "gpt-4o-mini",
850
+ temperature: 0.3,
851
+ maxTokens: 1024,
852
+ };
853
+ }
854
+ else if (providerChoice === "lmstudio") {
855
+ const endpoint = await prompt(() => p.text({
856
+ message: "Endpoint URL:",
857
+ placeholder: "http://localhost:1234/v1/chat/completions",
858
+ defaultValue: "http://localhost:1234/v1/chat/completions",
859
+ validate: (v) => {
860
+ if (!v?.trim())
861
+ return "Endpoint cannot be empty";
862
+ if (!v.startsWith("http://") && !v.startsWith("https://"))
863
+ return "Must start with http:// or https://";
864
+ },
865
+ }));
866
+ const model = await prompt(() => p.text({
867
+ message: "Model name:",
868
+ placeholder: "local-model",
869
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
870
+ }));
871
+ llm = {
872
+ provider: "lmstudio",
873
+ endpoint: endpoint.trim(),
874
+ model: model.trim(),
875
+ temperature: 0.3,
876
+ maxTokens: 1024,
877
+ };
878
+ }
879
+ else {
880
+ // custom
881
+ const endpoint = await prompt(() => p.text({
882
+ message: "OpenAI-compatible chat completions endpoint:",
883
+ placeholder: "https://your-host/v1/chat/completions",
884
+ validate: (v) => {
885
+ if (!v?.trim())
886
+ return "Endpoint cannot be empty";
887
+ if (!v.startsWith("http://") && !v.startsWith("https://"))
888
+ return "Must start with http:// or https://";
889
+ },
890
+ }));
891
+ const model = await prompt(() => p.text({
892
+ message: "Model name:",
893
+ placeholder: "gpt-4o-mini",
894
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
895
+ }));
896
+ const apiKeyInput = await promptOrBack(() => p.text({
897
+ message: "API key (optional — press Enter to skip):",
898
+ placeholder: "",
899
+ }));
900
+ llm = {
901
+ provider: "custom",
902
+ endpoint: endpoint.trim(),
903
+ model: model.trim(),
904
+ temperature: 0.3,
905
+ maxTokens: 1024,
906
+ ...(apiKeyInput?.trim() ? { apiKey: apiKeyInput.trim() } : {}),
907
+ };
908
+ }
909
+ // Best-effort probe — never blocks setup.
910
+ const probeSpin = p.spinner();
911
+ probeSpin.start("Probing LLM (structured-output round-trip)...");
912
+ const probe = await probeLlmCapabilities(llm);
913
+ if (probe.reachable && probe.structuredOutput) {
914
+ probeSpin.stop("LLM reachable; structured output verified.");
915
+ llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: true };
916
+ }
917
+ else if (probe.reachable) {
918
+ probeSpin.stop("LLM reachable but structured-output probe failed.");
919
+ llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: false };
920
+ }
921
+ else {
922
+ probeSpin.stop("LLM not reachable.");
923
+ p.log.warn(`Could not reach the LLM endpoint${probe.error ? ` (${probe.error})` : ""}. Configuration was saved; verify your endpoint and API key, then retry.`);
924
+ }
925
+ return { llm, skipped: false, ollamaEndpoint };
926
+ }
927
+ /**
928
+ * Step 2/2: Configure the agent connection used for agentic features.
929
+ *
930
+ * Options depend on whether Step 1 was completed or skipped.
931
+ */
932
+ export async function stepAgentConnection(current, smallModel) {
933
+ p.log.step("Step 2/2: Configure your agent connection");
934
+ p.note([
935
+ "This connection is used for agentic commands:",
936
+ " • akm propose (generate improvement proposals)",
937
+ " • akm reflect (reflect on assets and generate proposals)",
938
+ " • akm tasks run (run automated task prompts)",
939
+ ].join("\n"));
940
+ // Detect available CLI agents.
941
+ const detections = detectAgentCliProfiles(current.agent);
942
+ const availableClis = detections.filter((d) => d.available);
943
+ const agentOptions = [];
944
+ if (!smallModel.skipped && smallModel.llm) {
945
+ agentOptions.push({
946
+ value: "same-connection",
947
+ label: "Same connection, select model",
948
+ hint: `uses ${smallModel.llm.endpoint.replace("/v1/chat/completions", "")}`,
949
+ });
950
+ }
951
+ agentOptions.push({ value: "new-connection", label: "New connection (different endpoint)" });
952
+ if (availableClis.length > 0) {
953
+ agentOptions.push({
954
+ value: "cli-agent",
955
+ label: "Installed CLI agent",
956
+ hint: `${availableClis.map((d) => d.name).join(", ")} detected`,
957
+ });
958
+ }
959
+ agentOptions.push({ value: "none", label: "None — disable agentic features" });
960
+ if (current.agent) {
961
+ const currentDesc = current.agent.default
962
+ ? `CLI: ${current.agent.default}`
963
+ : current.agent.profiles?.default?.model
964
+ ? `SDK: ${current.agent.profiles.default.model}`
965
+ : "configured";
966
+ agentOptions.push({ value: "keep", label: `Keep current: ${currentDesc}` });
967
+ }
968
+ const initialAgentValue = current.agent
969
+ ? "keep"
970
+ : availableClis.length > 0 && smallModel.skipped
971
+ ? "cli-agent"
972
+ : !smallModel.skipped && smallModel.llm
973
+ ? "same-connection"
974
+ : availableClis.length > 0
975
+ ? "cli-agent"
976
+ : "none";
977
+ const agentChoice = await prompt(() => p.select({
978
+ message: "How do you want to run agent commands?",
979
+ options: agentOptions,
980
+ initialValue: initialAgentValue,
981
+ }));
982
+ if (agentChoice === "keep") {
983
+ return current.agent;
984
+ }
985
+ if (agentChoice === "none") {
986
+ p.note([
987
+ "Agentic features disabled:",
988
+ ' • akm propose — will show "no agent configured" error',
989
+ ' • akm reflect — will show "no agent configured" error',
990
+ ' • akm tasks run — will show "no agent configured" error',
991
+ "",
992
+ "You can configure this later with `akm setup`.",
993
+ ].join("\n"), "Warning");
994
+ return undefined;
995
+ }
996
+ if (agentChoice === "same-connection") {
997
+ if (smallModel.skipped || !smallModel.llm) {
998
+ p.log.warn("You skipped the small model connection. Configure one to use the same connection. Falling back to 'new connection'.");
999
+ // Fall through to new-connection flow
1000
+ }
1001
+ else {
1002
+ const baseEndpoint = smallModel.llm.endpoint.replace("/v1/chat/completions", "");
1003
+ p.log.info(`Endpoint: ${baseEndpoint} (from Step 1)`);
1004
+ const agentModel = await prompt(() => p.text({
1005
+ message: "Model to use for agent tasks (same model is fine, larger models work better):",
1006
+ placeholder: "qwen2.5-coder:32b",
1007
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1008
+ }));
1009
+ const profileName = smallModel.llm.provider ?? "default";
1010
+ return {
1011
+ ...(current.agent ?? {}),
1012
+ profiles: {
1013
+ ...(current.agent?.profiles ?? {}),
1014
+ [profileName]: {
1015
+ ...(current.agent?.profiles?.[profileName] ?? {}),
1016
+ sdkMode: true,
1017
+ model: agentModel.trim(),
1018
+ endpoint: smallModel.llm.endpoint,
1019
+ },
1020
+ },
1021
+ default: profileName,
1022
+ };
1023
+ }
1024
+ }
1025
+ if (agentChoice === "cli-agent") {
1026
+ if (availableClis.length === 0) {
1027
+ p.log.warn("No agent CLIs detected on PATH.");
1028
+ return current.agent;
1029
+ }
1030
+ const initialCli = pickDefaultAgentProfile(detections, current.agent?.default) ?? availableClis[0]?.name;
1031
+ const selectedCli = await prompt(() => p.select({
1032
+ message: "Which CLI agent?",
1033
+ options: availableClis.map((d) => ({
1034
+ value: d.name,
1035
+ label: d.name,
1036
+ hint: d.resolvedPath ?? d.bin,
1037
+ })),
1038
+ initialValue: initialCli,
1039
+ }));
1040
+ return {
1041
+ ...(current.agent ?? {}),
1042
+ default: selectedCli,
1043
+ };
1044
+ }
1045
+ // "new-connection" (also fall-through from "same-provider" when Step 1 was skipped)
1046
+ const newEndpoint = await prompt(() => p.text({
1047
+ message: "OpenAI-compatible chat completions endpoint:",
1048
+ placeholder: "https://your-host/v1/chat/completions",
1049
+ validate: (v) => {
1050
+ if (!v?.trim())
1051
+ return "Endpoint cannot be empty";
1052
+ if (!v.startsWith("http://") && !v.startsWith("https://"))
1053
+ return "Must start with http:// or https://";
1054
+ },
1055
+ }));
1056
+ const newApiKeyInput = await promptOrBack(() => p.text({
1057
+ message: "API key (optional — press Enter to skip):",
1058
+ placeholder: "",
1059
+ }));
1060
+ const newModel = await prompt(() => p.text({
1061
+ message: "Model name (larger is better, e.g. gpt-4o):",
1062
+ placeholder: "gpt-4o",
1063
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1064
+ }));
1065
+ const customProfile = {
1066
+ sdkMode: true,
1067
+ endpoint: newEndpoint.trim(),
1068
+ model: newModel.trim(),
1069
+ ...(newApiKeyInput?.trim() ? { apiKey: newApiKeyInput.trim() } : {}),
1070
+ };
1071
+ return {
1072
+ ...(current.agent ?? {}),
1073
+ profiles: {
1074
+ ...(current.agent?.profiles ?? {}),
1075
+ custom: customProfile,
1076
+ },
1077
+ default: "custom",
1078
+ };
1079
+ }
1080
+ /**
1081
+ * Print a feature capability summary after both connection steps are complete.
1082
+ */
1083
+ function printCapabilitySummary(smallModelSkipped, agentConfigured) {
1084
+ const lines = ["Setup complete. Here's what's enabled:", ""];
1085
+ lines.push(" ✓ akm search, akm curate, akm show — always available");
1086
+ if (!smallModelSkipped) {
1087
+ lines.push(" ✓ akm index, akm distill, akm remember — small model configured");
1088
+ }
1089
+ else {
1090
+ lines.push(" ✗ akm index, akm distill, akm remember — run `akm setup` to enable");
1091
+ }
1092
+ if (agentConfigured) {
1093
+ lines.push(" ✓ akm propose, akm reflect, akm tasks — agent configured");
1094
+ }
1095
+ else {
1096
+ lines.push(" ✗ akm propose, akm reflect, akm tasks — run `akm setup` to enable");
1097
+ }
1098
+ p.note(lines.join("\n"), "Feature Summary");
1099
+ }
737
1100
  export async function stepAgentSelection(current, detections) {
738
1101
  const available = detections.filter((d) => d.available);
739
1102
  if (available.length === 0) {
@@ -832,7 +1195,10 @@ export function buildSetupSteps(options) {
832
1195
  label: "Stash Directory",
833
1196
  nonInteractive: true,
834
1197
  async run(ctx) {
835
- const stashDir = await stepStashDir(ctx.config);
1198
+ const stashDir = await stepStashDir(ctx.config, {
1199
+ nonInteractive: ctx.nonInteractive,
1200
+ preferredDir: options.preferredStashDir,
1201
+ });
836
1202
  ctx.apply({ stashDir });
837
1203
  },
838
1204
  },
@@ -922,10 +1288,17 @@ export function buildSetupSteps(options) {
922
1288
  ];
923
1289
  return { steps, outcome };
924
1290
  }
925
- export async function runSetupWizard() {
1291
+ export async function runSetupWizard(opts) {
926
1292
  p.intro("akm setup");
927
1293
  const current = loadUserConfig();
928
1294
  const configPath = getConfigPath();
1295
+ // Resolve stash directory early so akmInit can run before any prompts
1296
+ const resolvedStashDir = opts?.dir ? path.resolve(opts.dir) : (current.stashDir ?? getDefaultStashDir());
1297
+ // Bootstrap directory structure before any prompts so the stash exists
1298
+ // even if the wizard is interrupted after this point.
1299
+ if (!opts?.noInit) {
1300
+ await akmInit({ dir: resolvedStashDir });
1301
+ }
929
1302
  // Quick connectivity check — skip network-dependent steps when offline
930
1303
  const online = await isOnline();
931
1304
  if (!online) {
@@ -936,6 +1309,7 @@ export async function runSetupWizard() {
936
1309
  const { steps, outcome } = buildSetupSteps({
937
1310
  online,
938
1311
  semanticSearchOutcome: { mode: current.semanticSearchMode, prepareAssets: false },
1312
+ preferredStashDir: resolvedStashDir,
939
1313
  });
940
1314
  // Wrap each step with a `p.log.step()` header so the wizard UI is
941
1315
  // unchanged. The canonical `runSetupSteps()` runner is used directly by
@@ -948,6 +1322,15 @@ export async function runSetupWizard() {
948
1322
  },
949
1323
  }));
950
1324
  await runSetupSteps(labeledSteps, ctx);
1325
+ // ── Two-step connection configuration ──────────────────────────────────────
1326
+ // Step 1/2: Small model connection (for enrichment features)
1327
+ const smallModelResult = await stepSmallModelConnection(ctx.config);
1328
+ if (!smallModelResult.skipped) {
1329
+ ctx.apply({ llm: smallModelResult.llm });
1330
+ }
1331
+ // Step 2/2: Agent connection (for agentic features)
1332
+ const agentConfig = await stepAgentConnection(ctx.config, smallModelResult);
1333
+ ctx.apply({ agent: agentConfig });
951
1334
  const newConfig = {
952
1335
  ...ctx.config,
953
1336
  // Preserve fields the steps don't manage explicitly.
@@ -958,7 +1341,10 @@ export async function runSetupWizard() {
958
1341
  const embedding = newConfig.embedding;
959
1342
  const llm = newConfig.llm;
960
1343
  const registries = newConfig.registries;
961
- const allStashes = newConfig.sources ?? newConfig.stashes ?? [];
1344
+ const allStashes = newConfig.sources ?? [];
1345
+ // Feature capability summary
1346
+ const agentConfigured = Boolean(agentConfig);
1347
+ printCapabilitySummary(smallModelResult.skipped, agentConfigured);
962
1348
  // Confirm before saving
963
1349
  const effectiveRegistries = registries ?? DEFAULT_CONFIG.registries ?? [];
964
1350
  p.note([
@@ -979,8 +1365,6 @@ export async function runSetupWizard() {
979
1365
  bail();
980
1366
  // Save config
981
1367
  saveConfig(newConfig);
982
- // Initialize stash directory
983
- await akmInit({ dir: stashDir });
984
1368
  if (semanticSearchMode.mode === "off") {
985
1369
  clearSemanticStatus();
986
1370
  }
@@ -1062,3 +1446,118 @@ export async function runSetupWizard() {
1062
1446
  }
1063
1447
  p.outro(`Configuration saved to ${configPath}`);
1064
1448
  }
1449
+ // ── Non-interactive / scripting entry points ─────────────────────────────────
1450
+ /**
1451
+ * Run setup in non-interactive mode, applying all defaults.
1452
+ * Safe to call from CI or scripts. Idempotent — re-running produces the same result.
1453
+ */
1454
+ export async function runSetupWithDefaults(opts) {
1455
+ const current = loadUserConfig();
1456
+ const stashDir = opts.dir ? path.resolve(opts.dir) : (current.stashDir ?? getDefaultStashDir());
1457
+ // Bootstrap directory structure first
1458
+ let initResult;
1459
+ if (!opts.noInit) {
1460
+ initResult = await akmInit({ dir: stashDir });
1461
+ }
1462
+ // Run steps in non-interactive mode (applies defaults, skips prompts)
1463
+ const ctx = createSetupContext(current, { nonInteractive: true });
1464
+ const { steps } = buildSetupSteps({
1465
+ online: false,
1466
+ semanticSearchOutcome: { mode: current.semanticSearchMode, prepareAssets: false },
1467
+ preferredStashDir: stashDir,
1468
+ });
1469
+ await runSetupSteps(steps, ctx);
1470
+ // Ensure stashDir is set
1471
+ if (!ctx.config.stashDir)
1472
+ ctx.apply({ stashDir });
1473
+ // Auto-detect agent CLI if not already configured
1474
+ if (!ctx.config.agent) {
1475
+ const detected = detectAgentCliProfiles(undefined);
1476
+ const defaultProfile = pickDefaultAgentProfile(detected, undefined);
1477
+ if (defaultProfile) {
1478
+ ctx.apply({ agent: { default: defaultProfile } });
1479
+ }
1480
+ }
1481
+ saveConfig(ctx.config);
1482
+ return {
1483
+ configPath: getConfigPath(),
1484
+ stashDir,
1485
+ stashCreated: initResult?.created ?? false,
1486
+ written: true,
1487
+ fields: Object.keys(ctx.config).filter((k) => ctx.config[k] !== undefined),
1488
+ ripgrep: initResult?.ripgrep,
1489
+ };
1490
+ }
1491
+ /**
1492
+ * Apply a JSON config blob non-interactively, merging it with the current config.
1493
+ * Validates required sub-fields and strips unknown/restricted keys.
1494
+ */
1495
+ export async function runSetupFromConfig(opts) {
1496
+ // Phase 1: Parse JSON
1497
+ let incoming;
1498
+ try {
1499
+ incoming = JSON.parse(opts.configJson);
1500
+ }
1501
+ catch (e) {
1502
+ throw new Error(`Invalid JSON in --config: ${e.message}`);
1503
+ }
1504
+ // Phase 2: Validate — only allow safe top-level keys
1505
+ const ALLOWED_KEYS = new Set(["stashDir", "llm", "embedding", "agent", "semanticSearchMode", "output"]);
1506
+ for (const key of Object.keys(incoming)) {
1507
+ if (!ALLOWED_KEYS.has(key)) {
1508
+ warn(`[akm setup] Ignoring unknown or restricted config key: "${key}"`);
1509
+ delete incoming[key];
1510
+ }
1511
+ }
1512
+ // Validate required sub-fields
1513
+ if (incoming.llm) {
1514
+ if (!incoming.llm.endpoint?.trim())
1515
+ throw new Error("llm.endpoint is required when llm is provided");
1516
+ if (!incoming.llm.model?.trim())
1517
+ throw new Error("llm.model is required when llm is provided");
1518
+ }
1519
+ if (incoming.embedding) {
1520
+ if (!incoming.embedding.endpoint?.trim())
1521
+ throw new Error("embedding.endpoint is required when embedding is provided");
1522
+ if (!incoming.embedding.model?.trim())
1523
+ throw new Error("embedding.model is required when embedding is provided");
1524
+ }
1525
+ // Phase 3: Merge with existing config
1526
+ const current = loadUserConfig();
1527
+ const stashDir = opts.dir
1528
+ ? path.resolve(opts.dir)
1529
+ : incoming.stashDir
1530
+ ? path.resolve(incoming.stashDir)
1531
+ : (current.stashDir ?? getDefaultStashDir());
1532
+ const merged = {
1533
+ ...current,
1534
+ ...incoming,
1535
+ stashDir,
1536
+ };
1537
+ // Bootstrap directory structure
1538
+ let initResult;
1539
+ if (!opts.noInit) {
1540
+ initResult = await akmInit({ dir: stashDir });
1541
+ }
1542
+ // Optional probe
1543
+ if (opts.probe && merged.llm) {
1544
+ try {
1545
+ const caps = await probeLlmCapabilities(merged.llm);
1546
+ if (caps.reachable) {
1547
+ merged.llm = { ...merged.llm, capabilities: { structuredOutput: caps.structuredOutput ?? false } };
1548
+ }
1549
+ }
1550
+ catch {
1551
+ // Non-fatal: probe failure is informational only
1552
+ }
1553
+ }
1554
+ saveConfig(merged);
1555
+ return {
1556
+ configPath: getConfigPath(),
1557
+ stashDir,
1558
+ stashCreated: initResult?.created ?? false,
1559
+ written: true,
1560
+ fields: Object.keys(incoming).filter((k) => incoming[k] !== undefined),
1561
+ ripgrep: initResult?.ripgrep,
1562
+ };
1563
+ }
@@ -9,6 +9,7 @@
9
9
  * Both share `create-provider-registry.ts` for the underlying string→factory
10
10
  * map.
11
11
  */
12
+ import { getSources } from "../core/config";
12
13
  import { createProviderRegistry } from "../registry/create-provider-registry";
13
14
  // ── Factory map ─────────────────────────────────────────────────────────────
14
15
  const registry = createProviderRegistry();
@@ -24,7 +25,7 @@ export function resolveSourceProviderFactory(type) {
24
25
  */
25
26
  export function resolveSourceProviders(config) {
26
27
  const providers = [];
27
- for (const entry of config.sources ?? config.stashes ?? []) {
28
+ for (const entry of getSources(config)) {
28
29
  if (entry.enabled === false)
29
30
  continue;
30
31
  const factory = registry.resolve(entry.type);
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { TYPE_DIRS } from "../../core/asset-spec";
6
6
  import { resolveStashDir } from "../../core/common";
7
- import { loadConfig } from "../../core/config";
7
+ import { getSources, loadConfig } from "../../core/config";
8
8
  import { ConfigError, UsageError } from "../../core/errors";
9
9
  import { getRegistryCacheDir, getRegistryIndexCacheDir } from "../../core/paths";
10
10
  import { sanitizeCommitMessage } from "../../core/write-source";
@@ -407,7 +407,7 @@ export function saveGitStash(name, message, writableOverride) {
407
407
  let writable = false;
408
408
  if (name) {
409
409
  const config = loadConfig();
410
- const stash = findGitStashByTarget(config.sources ?? config.stashes ?? [], name);
410
+ const stash = findGitStashByTarget(getSources(config), name);
411
411
  if (!stash)
412
412
  throw new UsageError(`No git stash found with name "${name}"`);
413
413
  if (!GIT_STASH_TYPES.has(stash.type)) {
@@ -167,6 +167,10 @@ async function crawlWebsite(startUrl, options) {
167
167
  return pages;
168
168
  }
169
169
  async function fetchWebsitePage(pageUrl) {
170
+ const parsedUrl = new URL(pageUrl);
171
+ if (parsedUrl.hostname.endsWith(".invalid")) {
172
+ throw new Error(`Refusing to fetch reserved invalid hostname: ${parsedUrl.hostname}`);
173
+ }
170
174
  const response = await fetchWithRetry(pageUrl, {
171
175
  headers: {
172
176
  Accept: "text/html, text/markdown, text/plain;q=0.9, application/xhtml+xml;q=0.8",