akm-cli 0.6.0-rc1 → 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 (108) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +9 -9
  3. package/dist/cli.js +181 -111
  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/{curate.js → commands/curate.js} +8 -3
  7. package/dist/{info.js → commands/info.js} +15 -9
  8. package/dist/{init.js → commands/init.js} +4 -4
  9. package/dist/{install-audit.js → commands/install-audit.js} +4 -7
  10. package/dist/{installed-stashes.js → commands/installed-stashes.js} +77 -31
  11. package/dist/{migration-help.js → commands/migration-help.js} +2 -2
  12. package/dist/{registry-search.js → commands/registry-search.js} +8 -6
  13. package/dist/{remember.js → commands/remember.js} +55 -49
  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} +103 -78
  17. package/dist/{stash-add.js → commands/source-add.js} +42 -32
  18. package/dist/{stash-clone.js → commands/source-clone.js} +12 -10
  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} +1 -1
  23. package/dist/{asset-spec.js → core/asset-spec.js} +1 -1
  24. package/dist/{config.js → core/config.js} +79 -31
  25. package/dist/core/errors.js +90 -0
  26. package/dist/{frontmatter.js → core/frontmatter.js} +5 -3
  27. package/dist/core/write-source.js +280 -0
  28. package/dist/{db-search.js → indexer/db-search.js} +25 -19
  29. package/dist/{db.js → indexer/db.js} +70 -47
  30. package/dist/{file-context.js → indexer/file-context.js} +3 -3
  31. package/dist/{indexer.js → indexer/indexer.js} +123 -31
  32. package/dist/{manifest.js → indexer/manifest.js} +10 -10
  33. package/dist/{matchers.js → indexer/matchers.js} +3 -6
  34. package/dist/{metadata.js → indexer/metadata.js} +9 -5
  35. package/dist/{search-source.js → indexer/search-source.js} +52 -41
  36. package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
  37. package/dist/{walker.js → indexer/walker.js} +1 -1
  38. package/dist/{lockfile.js → integrations/lockfile.js} +1 -1
  39. package/dist/{llm-client.js → llm/client.js} +1 -1
  40. package/dist/{embedders → llm/embedders}/local.js +2 -2
  41. package/dist/{embedders → llm/embedders}/remote.js +1 -1
  42. package/dist/{embedders → llm/embedders}/types.js +1 -1
  43. package/dist/{metadata-enhance.js → llm/metadata-enhance.js} +2 -2
  44. package/dist/{cli-hints.js → output/cli-hints.js} +1 -0
  45. package/dist/{output-context.js → output/context.js} +21 -3
  46. package/dist/{renderers.js → output/renderers.js} +9 -65
  47. package/dist/{output-shapes.js → output/shapes.js} +18 -4
  48. package/dist/{output-text.js → output/text.js} +1 -1
  49. package/dist/{registry-build-index.js → registry/build-index.js} +16 -7
  50. package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
  51. package/dist/registry/factory.js +33 -0
  52. package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
  53. package/dist/{providers → registry/providers}/index.js +1 -1
  54. package/dist/{providers → registry/providers}/skills-sh.js +59 -3
  55. package/dist/{providers → registry/providers}/static-index.js +80 -12
  56. package/dist/registry/providers/types.js +25 -0
  57. package/dist/{registry-resolve.js → registry/resolve.js} +3 -3
  58. package/dist/{detect.js → setup/detect.js} +0 -27
  59. package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
  60. package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
  61. package/dist/{setup.js → setup/setup.js} +16 -56
  62. package/dist/{stash-include.js → sources/include.js} +1 -1
  63. package/dist/sources/provider-factory.js +36 -0
  64. package/dist/sources/provider.js +21 -0
  65. package/dist/sources/providers/filesystem.js +35 -0
  66. package/dist/{stash-providers → sources/providers}/git.js +53 -64
  67. package/dist/{stash-providers → sources/providers}/index.js +3 -4
  68. package/dist/sources/providers/install-types.js +14 -0
  69. package/dist/{stash-providers → sources/providers}/npm.js +42 -41
  70. package/dist/{stash-providers → sources/providers}/provider-utils.js +3 -3
  71. package/dist/{stash-providers → sources/providers}/sync-from-ref.js +2 -2
  72. package/dist/{stash-providers → sources/providers}/tar-utils.js +11 -8
  73. package/dist/{stash-providers → sources/providers}/website.js +29 -65
  74. package/dist/{stash-resolve.js → sources/resolve.js} +8 -7
  75. package/dist/{wiki.js → wiki/wiki.js} +12 -11
  76. package/dist/{workflow-authoring.js → workflows/authoring.js} +37 -14
  77. package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
  78. package/dist/{workflow-db.js → workflows/db.js} +1 -1
  79. package/dist/workflows/document-cache.js +20 -0
  80. package/dist/workflows/parser.js +379 -0
  81. package/dist/workflows/renderer.js +78 -0
  82. package/dist/{workflow-runs.js → workflows/runs.js} +72 -28
  83. package/dist/workflows/schema.js +11 -0
  84. package/dist/workflows/validator.js +48 -0
  85. package/docs/migration/release-notes/0.6.0.md +69 -23
  86. package/package.json +1 -1
  87. package/dist/errors.js +0 -45
  88. package/dist/llm.js +0 -16
  89. package/dist/registry-factory.js +0 -19
  90. package/dist/ripgrep.js +0 -2
  91. package/dist/stash-provider-factory.js +0 -35
  92. package/dist/stash-provider.js +0 -3
  93. package/dist/stash-providers/filesystem.js +0 -71
  94. package/dist/stash-providers/openviking.js +0 -348
  95. package/dist/stash-types.js +0 -1
  96. package/dist/workflow-markdown.js +0 -260
  97. /package/dist/{common.js → core/common.js} +0 -0
  98. /package/dist/{markdown.js → core/markdown.js} +0 -0
  99. /package/dist/{paths.js → core/paths.js} +0 -0
  100. /package/dist/{warn.js → core/warn.js} +0 -0
  101. /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
  102. /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
  103. /package/dist/{github.js → integrations/github.js} +0 -0
  104. /package/dist/{embedder.js → llm/embedder.js} +0 -0
  105. /package/dist/{embedders → llm/embedders}/cache.js +0 -0
  106. /package/dist/{registry-provider.js → registry/types.js} +0 -0
  107. /package/dist/{setup-steps.js → setup/steps.js} +0 -0
  108. /package/dist/{registry-types.js → sources/types.js} +0 -0
@@ -2,7 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { filterNonEmptyStrings } from "./common";
5
+ import { ConfigError } from "./errors";
5
6
  import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath } from "./paths";
7
+ import { warn } from "./warn";
6
8
  // ── Defaults ────────────────────────────────────────────────────────────────
7
9
  export const DEFAULT_CONFIG = {
8
10
  semanticSearchMode: "auto",
@@ -186,16 +188,16 @@ function pickKnownKeys(raw) {
186
188
  const legacySemanticSearch = raw.semanticSearch;
187
189
  config.semanticSearchMode = legacySemanticSearch ? "auto" : "off";
188
190
  }
189
- // Migrate legacy searchPaths into stashes
191
+ // Migrate legacy searchPaths into sources
190
192
  if (Array.isArray(raw.searchPaths)) {
191
193
  const legacyPaths = raw.searchPaths.filter((d) => typeof d === "string");
192
194
  if (legacyPaths.length > 0) {
193
- const existing = config.stashes ?? [];
195
+ const existing = config.sources ?? [];
194
196
  const migrated = legacyPaths
195
197
  .filter((p) => !existing.some((s) => s.type === "filesystem" && s.path === p))
196
198
  .map((p) => ({ type: "filesystem", path: p }));
197
199
  if (migrated.length > 0) {
198
- config.stashes = [...existing, ...migrated];
200
+ config.sources = [...existing, ...migrated];
199
201
  }
200
202
  }
201
203
  }
@@ -220,9 +222,23 @@ function pickKnownKeys(raw) {
220
222
  config.stashInheritance = raw.disableGlobalStashes ? "replace" : "merge";
221
223
  config.disableGlobalStashes = raw.disableGlobalStashes;
222
224
  }
223
- const stashes = parseStashesConfig(raw.stashes);
224
- if (stashes)
225
- config.stashes = stashes;
225
+ // Load `sources` (new key) first, then fall back to legacy `stashes` key.
226
+ const sources = parseStashesConfig(raw.sources);
227
+ if (sources) {
228
+ config.sources = sources;
229
+ }
230
+ else {
231
+ const legacyStashes = parseStashesConfig(raw.stashes);
232
+ if (legacyStashes) {
233
+ // Backwards-compat migration: `stashes[]` → `sources[]` in-memory.
234
+ // Emit a one-time deprecation warning and carry the value forward as
235
+ // `sources`. The renamed key is persisted on the next `akm config` write.
236
+ warn('Config key "stashes" is deprecated; rename it to "sources" in your config file ' +
237
+ `(edit it directly at ${_getConfigPath()}). ` +
238
+ "Your configuration has been loaded successfully — no manual action is required right now.");
239
+ config.sources = legacyStashes;
240
+ }
241
+ }
226
242
  const security = parseSecurityConfig(raw.security);
227
243
  if (security)
228
244
  config.security = security;
@@ -232,6 +248,18 @@ function pickKnownKeys(raw) {
232
248
  if (typeof raw.writable === "boolean") {
233
249
  config.writable = raw.writable;
234
250
  }
251
+ if (typeof raw.defaultWriteTarget === "string" && raw.defaultWriteTarget.trim()) {
252
+ config.defaultWriteTarget = raw.defaultWriteTarget.trim();
253
+ }
254
+ if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
255
+ const searchRaw = raw.search;
256
+ const searchConfig = {};
257
+ if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
258
+ searchConfig.minScore = searchRaw.minScore;
259
+ }
260
+ if (Object.keys(searchConfig).length > 0)
261
+ config.search = searchConfig;
262
+ }
235
263
  return config;
236
264
  }
237
265
  function readNormalizedConfig(configPath) {
@@ -435,11 +463,10 @@ function parseLlmConfig(value) {
435
463
  console.warn(`[akm] Ignoring llm config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
436
464
  return undefined;
437
465
  }
438
- if (typeof obj.model !== "string" || !obj.model)
439
- return undefined;
466
+ const model = typeof obj.model === "string" ? obj.model : "";
440
467
  const result = {
441
468
  endpoint: obj.endpoint,
442
- model: obj.model,
469
+ model,
443
470
  };
444
471
  if (typeof obj.provider === "string" && obj.provider) {
445
472
  result.provider = obj.provider;
@@ -531,7 +558,7 @@ function asNonEmptyString(value) {
531
558
  * Restricted to the four kinds that the install pipeline produces
532
559
  * (`"npm" | "github" | "git" | "local"`). The full {@link KitSource} union is
533
560
  * wider, but persisted `installed[]` entries should never carry the runtime
534
- * provider kinds (`"filesystem" | "website" | "openviking"`).
561
+ * provider kinds (`"filesystem" | "website"`).
535
562
  */
536
563
  function asKitSource(value) {
537
564
  if (value === "npm" || value === "github" || value === "git" || value === "local")
@@ -552,7 +579,7 @@ function parseStashesConfig(value) {
552
579
  if (!Array.isArray(value))
553
580
  return undefined;
554
581
  const entries = value
555
- .map((entry) => parseStashConfigEntry(entry))
582
+ .map((entry) => parseSourceConfigEntry(entry))
556
583
  .filter((entry) => entry !== undefined);
557
584
  return entries;
558
585
  }
@@ -623,13 +650,17 @@ const STASH_TYPE_ALIASES = {
623
650
  "context-hub": "git",
624
651
  github: "git",
625
652
  };
626
- function parseStashConfigEntry(value) {
653
+ function parseSourceConfigEntry(value) {
627
654
  if (typeof value !== "object" || value === null || Array.isArray(value))
628
655
  return undefined;
629
656
  const obj = value;
630
657
  const rawType = asNonEmptyString(obj.type);
631
658
  if (!rawType)
632
659
  return undefined;
660
+ if (rawType === "openviking") {
661
+ const name = asNonEmptyString(obj.name) ?? "unnamed";
662
+ throw new ConfigError(`openviking is not supported in akm v1. API-backed sources will return as a\nseparate QuerySource tier post-v1. Remove the source named "${name}" from your config file\nor downgrade to 0.6.x. See docs/migration/v1.md.`, "INVALID_CONFIG_FILE", `Run \`akm remove ${name}\` then re-run, or edit your config file directly at ${_getConfigPath()} to remove the openviking entry.`);
663
+ }
633
664
  const type = STASH_TYPE_ALIASES[rawType] ?? rawType;
634
665
  const entry = { type };
635
666
  const entryPath = asNonEmptyString(obj.path);
@@ -647,6 +678,14 @@ function parseStashConfigEntry(value) {
647
678
  entry.writable = obj.writable;
648
679
  if (typeof obj.primary === "boolean")
649
680
  entry.primary = obj.primary;
681
+ // Locked decision 4 (§6 v1 implementation plan): reject writable: true on
682
+ // website / npm sources at config load. The next sync() would clobber
683
+ // writes — allowing this is a footgun, not a feature. Throw early so the
684
+ // user sees the problem at `akm` startup, not when they try to write.
685
+ if (entry.writable === true && (type === "website" || type === "npm")) {
686
+ const label = entry.name ? ` "${entry.name}"` : "";
687
+ throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
688
+ }
650
689
  if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
651
690
  entry.options = obj.options;
652
691
  }
@@ -655,9 +694,9 @@ function parseStashConfigEntry(value) {
655
694
  entry.wikiName = wikiName;
656
695
  return entry;
657
696
  }
658
- // ── StashEntry runtime construction ─────────────────────────────────────────
697
+ // ── ConfiguredSource runtime construction ─────────────────────────────────────────
659
698
  /**
660
- * Synthesize a stable identifier when a {@link StashConfigEntry} omits its
699
+ * Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
661
700
  * `name`. Uses a short hash of the discriminating fields so two equivalent
662
701
  * entries collapse to the same generated name.
663
702
  */
@@ -673,8 +712,8 @@ function deriveStashEntryName(entry) {
673
712
  return `${entry.type}-${hash}`;
674
713
  }
675
714
  /**
676
- * Convert a persisted {@link StashConfigEntry} into the runtime
677
- * {@link StashSource} discriminated union. Returns `undefined` when the
715
+ * Convert a persisted {@link SourceConfigEntry} into the runtime
716
+ * {@link SourceSpec} discriminated union. Returns `undefined` when the
678
717
  * entry is missing the fields its provider type requires (e.g. a
679
718
  * `filesystem` entry with no `path`); callers should drop or warn for those.
680
719
  *
@@ -682,7 +721,7 @@ function deriveStashEntryName(entry) {
682
721
  * legacy aliases (`"context-hub"`, `"github"` for git) still produce a usable
683
722
  * runtime value when a path/url is supplied.
684
723
  */
685
- export function parseStashEntrySource(entry) {
724
+ export function parseSourceSpec(entry) {
686
725
  switch (entry.type) {
687
726
  case "filesystem":
688
727
  return entry.path ? { type: "filesystem", path: entry.path } : undefined;
@@ -700,8 +739,6 @@ export function parseStashEntrySource(entry) {
700
739
  ...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
701
740
  }
702
741
  : undefined;
703
- case "openviking":
704
- return entry.url ? { type: "openviking", url: entry.url } : undefined;
705
742
  case "npm":
706
743
  // Persisted `npm` stash entries are unusual but supported for symmetry.
707
744
  return entry.path ? { type: "npm", package: entry.path } : undefined;
@@ -711,29 +748,31 @@ export function parseStashEntrySource(entry) {
711
748
  }
712
749
  }
713
750
  /**
714
- * Build the full ordered list of runtime {@link StashEntry} values from a
751
+ * Build the full ordered list of runtime {@link ConfiguredSource} values from a
715
752
  * loaded {@link AkmConfig}. Order is the canonical iteration order:
716
753
  *
717
754
  * 1. The entry marked `primary: true` (or, as a backwards-compat shim,
718
755
  * a synthetic filesystem entry built from the top-level `stashDir`).
719
- * 2. Remaining `stashes[]` entries in declared order.
756
+ * 2. Remaining `sources[]` entries in declared order.
720
757
  * 3. Legacy `installed[]` entries, mapped into runtime entries.
721
758
  *
722
759
  * Entries with `enabled: false` are still emitted — callers decide whether
723
760
  * to honour the flag (mirrors how `installed[]` entries have always been
724
- * unconditional). Entries that fail {@link parseStashEntrySource} are
761
+ * unconditional). Entries that fail {@link parseSourceSpec} are
725
762
  * dropped silently.
726
763
  */
727
- export function resolveStashEntries(config) {
764
+ export function resolveConfiguredSources(config) {
728
765
  const entries = [];
729
- const stashes = config.stashes ?? [];
766
+ // `sources` is the canonical key. `stashes` is the legacy key that the loader
767
+ // migrates in-memory; only one of the two should be set at runtime.
768
+ const stashes = config.sources ?? config.stashes ?? [];
730
769
  // (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
731
770
  let primary = stashes.find((entry) => entry.primary === true);
732
771
  if (!primary && config.stashDir) {
733
772
  primary = { type: "filesystem", path: config.stashDir, primary: true };
734
773
  }
735
774
  if (primary) {
736
- const runtime = toStashEntry(primary, true);
775
+ const runtime = toConfiguredSource(primary, true);
737
776
  if (runtime)
738
777
  entries.push(runtime);
739
778
  }
@@ -741,7 +780,7 @@ export function resolveStashEntries(config) {
741
780
  for (const entry of stashes) {
742
781
  if (entry === primary)
743
782
  continue;
744
- const runtime = toStashEntry(entry, false);
783
+ const runtime = toConfiguredSource(entry, false);
745
784
  if (runtime)
746
785
  entries.push(runtime);
747
786
  }
@@ -758,8 +797,8 @@ export function resolveStashEntries(config) {
758
797
  }
759
798
  return entries;
760
799
  }
761
- function toStashEntry(persisted, isPrimary) {
762
- const source = parseStashEntrySource(persisted);
800
+ function toConfiguredSource(persisted, isPrimary) {
801
+ const source = parseSourceSpec(persisted);
763
802
  if (!source)
764
803
  return undefined;
765
804
  return {
@@ -841,12 +880,21 @@ function mergeLoadedConfig(base, override) {
841
880
  // `disableGlobalStashes` boolean so old config files behave identically.
842
881
  const replaceStashes = override.stashInheritance === "replace" ||
843
882
  (override.stashInheritance === undefined && override.disableGlobalStashes === true);
883
+ // Merge `sources` (canonical key). Legacy `stashes` key is handled via the
884
+ // pickKnownKeys migration which promotes it to `sources` at load time.
885
+ const overrideSources = override.sources ?? override.stashes ?? [];
886
+ const baseSources = base.sources ?? base.stashes ?? [];
844
887
  if (replaceStashes) {
845
- merged.stashes = [...(override.stashes ?? [])];
888
+ merged.sources = [...overrideSources];
889
+ }
890
+ else if (overrideSources.length > 0) {
891
+ merged.sources = [...baseSources, ...overrideSources];
846
892
  }
847
- else if (override.stashes) {
848
- merged.stashes = [...(base.stashes ?? []), ...override.stashes];
893
+ else if (baseSources.length > 0) {
894
+ merged.sources = [...baseSources];
849
895
  }
896
+ // Clear deprecated stashes field on the merged result — sources is canonical.
897
+ delete merged.stashes;
850
898
  return merged;
851
899
  }
852
900
  function applyRuntimeEnvApiKeys(config) {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Typed error classes for structured exit code classification.
3
+ *
4
+ * - ConfigError -> exit 78 (configuration / environment problems)
5
+ * - UsageError -> exit 2 (bad CLI arguments or invalid input)
6
+ * - NotFoundError -> exit 1 (requested resource missing)
7
+ *
8
+ * Each error carries a machine-readable `code` field. Codes are stable
9
+ * identifiers safe to consume from scripts and JSON output. Existing throw
10
+ * sites without an explicit code receive a default code per error class so
11
+ * older call sites continue to compile and behave unchanged.
12
+ *
13
+ * Each error also exposes a `hint()` method returning an actionable hint
14
+ * string (or `undefined`). Hints can be supplied at construction time or
15
+ * derived from the error `code` via the per-class default mapping below.
16
+ * The CLI surfaces this via `error.hint()` rather than message-regex parsing.
17
+ */
18
+ /**
19
+ * Default hint for each ConfigError code. Keep these short, actionable, and
20
+ * imperative. Returning undefined means "no canned hint".
21
+ */
22
+ const CONFIG_HINTS = {
23
+ STASH_DIR_NOT_FOUND: "Run `akm init` to create the default stash, or set stashDir in your config.",
24
+ STASH_DIR_NOT_A_DIRECTORY: "The configured stashDir exists but isn't a directory. Update stashDir to point at a folder.",
25
+ STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update stashDir.",
26
+ EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
27
+ LLM_NOT_CONFIGURED: 'Run `akm config set llm \'{"endpoint":"...","model":"..."}\'` to configure the LLM.',
28
+ };
29
+ /** Default hint for each UsageError code. */
30
+ const USAGE_HINTS = {
31
+ INVALID_SOURCE_VALUE: "Pick one of: stash, registry, both.",
32
+ INVALID_FORMAT_VALUE: "Pick one of: json, jsonl, text, yaml.",
33
+ INVALID_DETAIL_VALUE: "Pick one of: brief, normal, full, summary, agent.",
34
+ INVALID_JSON_CONFIG_VALUE: 'Quote JSON values in your shell, for example: akm config set embedding \'{"endpoint":"http://localhost:11434/v1/embeddings","model":"nomic-embed-text"}\'.',
35
+ MISSING_OR_AMBIGUOUS_TARGET: "Use `akm update --all` or pass a target like `akm update npm:@scope/pkg` (not both).",
36
+ TARGET_NOT_UPDATABLE: "Run `akm list` to view your sources, then retry with one of those values.",
37
+ MISSING_REQUIRED_ARGUMENT: "Refs use the form type:name, e.g. `akm show skill:deploy` or `akm show knowledge:guide.md`.",
38
+ };
39
+ /** Default hint for each NotFoundError code. */
40
+ const NOT_FOUND_HINTS = {
41
+ SOURCE_NOT_FOUND: "Run `akm list` to view your sources, then retry with one of those values.",
42
+ };
43
+ /** Raised when configuration or environment is invalid or missing. */
44
+ export class ConfigError extends Error {
45
+ code;
46
+ _hint;
47
+ constructor(msg, code = "INVALID_CONFIG_FILE", hint) {
48
+ super(msg);
49
+ this.name = "ConfigError";
50
+ this.code = code;
51
+ this._hint = hint;
52
+ // Fixes `instanceof` checks under ES5 transpilation targets.
53
+ Object.setPrototypeOf(this, new.target.prototype);
54
+ }
55
+ hint() {
56
+ return this._hint ?? CONFIG_HINTS[this.code];
57
+ }
58
+ }
59
+ /** Raised when the user supplies invalid arguments or input. */
60
+ export class UsageError extends Error {
61
+ code;
62
+ _hint;
63
+ constructor(msg, code = "INVALID_FLAG_VALUE", hint) {
64
+ super(msg);
65
+ this.name = "UsageError";
66
+ this.code = code;
67
+ this._hint = hint;
68
+ // Fixes `instanceof` checks under ES5 transpilation targets.
69
+ Object.setPrototypeOf(this, new.target.prototype);
70
+ }
71
+ hint() {
72
+ return this._hint ?? USAGE_HINTS[this.code];
73
+ }
74
+ }
75
+ /** Raised when a requested resource (asset, entry, file) is not found. */
76
+ export class NotFoundError extends Error {
77
+ code;
78
+ _hint;
79
+ constructor(msg, code = "ASSET_NOT_FOUND", hint) {
80
+ super(msg);
81
+ this.name = "NotFoundError";
82
+ this.code = code;
83
+ this._hint = hint;
84
+ // Fixes `instanceof` checks under ES5 transpilation targets.
85
+ Object.setPrototypeOf(this, new.target.prototype);
86
+ }
87
+ hint() {
88
+ return this._hint ?? NOT_FOUND_HINTS[this.code];
89
+ }
90
+ }
@@ -11,11 +11,13 @@
11
11
  *
12
12
  * **Limitations**: This is a hand-rolled YAML-subset parser with intentional
13
13
  * constraints for simplicity and safety:
14
- * - **List support**: YAML block sequences (`- item`) and flow arrays
15
- * (`[a, b, c]`) are both supported for top-level list-valued keys.
14
+ * - **Top-level values**: string, boolean, and number scalars are supported,
15
+ * as well as top-level list-valued keys using YAML block sequences
16
+ * (`- item`) or flow arrays (`[a, b, c]`).
17
+ * - **List item types**: list items must be scalar values and may be strings,
18
+ * booleans, or numbers.
16
19
  * - **No nested objects beyond one level**: Only a single level of indented
17
20
  * key-value pairs is supported.
18
- * - **Scalar values only**: string, boolean, and number scalars are supported.
19
21
  */
20
22
  export function parseFrontmatter(raw) {
21
23
  const parsedBlock = parseFrontmatterBlock(raw);
@@ -0,0 +1,280 @@
1
+ /**
2
+ * write-source — the only place in the codebase that branches on `source.kind`.
3
+ *
4
+ * v1 architecture spec §2.6 / §2.7 / §10 step 5: writing to a source is *not*
5
+ * a SourceProvider interface concern. It's a small command-layer helper that
6
+ * does a plain filesystem write, plus a git-specific commit (and optional
7
+ * push) when the source is backed by a git working tree.
8
+ *
9
+ * If a third kind ever needs special write handling, it gets added here. For
10
+ * v1 there are exactly two cases. Adding more parallel scoring systems for
11
+ * different provider kinds is explicitly disallowed by CLAUDE.md.
12
+ *
13
+ * This module is the **single dispatch point** for `kind`-branching write
14
+ * logic. Callers (remember, import, source-add, etc.) MUST go through
15
+ * `writeAssetToSource` / `deleteAssetFromSource` rather than re-inlining the
16
+ * filesystem-write + git-commit dance.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { getCachePaths, parseGitRepoUrl } from "../sources/providers/git";
22
+ import { makeAssetRef } from "./asset-ref";
23
+ import { resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
24
+ import { isWithin, resolveStashDir } from "./common";
25
+ import { resolveConfiguredSources } from "./config";
26
+ import { ConfigError, UsageError } from "./errors";
27
+ /**
28
+ * Source kinds that the loader is allowed to mark `writable: true`. Anything
29
+ * else is rejected at config load (per locked decision 4) — see
30
+ * {@link assertWritableAllowedForKind}.
31
+ */
32
+ const REJECTED_WRITABLE_KINDS = new Set(["website", "npm"]);
33
+ // ── Public helpers ──────────────────────────────────────────────────────────
34
+ /**
35
+ * Resolve the effective `writable` flag for a source config entry, applying
36
+ * the v1 default policy from spec §5.4:
37
+ *
38
+ * - `filesystem` → `true` by default
39
+ * - everything else → `false` by default
40
+ *
41
+ * Users can opt out for `filesystem` via `writable: false`. They cannot opt
42
+ * **in** for `website` / `npm` — that combination is rejected at config load
43
+ * (see {@link assertWritableAllowedForKind}).
44
+ */
45
+ export function resolveWritable(entry) {
46
+ if (typeof entry.writable === "boolean")
47
+ return entry.writable;
48
+ return entry.type === "filesystem";
49
+ }
50
+ /**
51
+ * Reject `writable: true` on `website` / `npm` sources at config-load time.
52
+ * Per locked decision 4 (§6 of the v1 implementation plan): `sync()` would
53
+ * clobber writes on the next refresh, so allowing writes here is a footgun.
54
+ *
55
+ * Throws {@link ConfigError} when the combination is rejected.
56
+ */
57
+ export function assertWritableAllowedForKind(entry) {
58
+ if (entry.writable !== true)
59
+ return;
60
+ if (REJECTED_WRITABLE_KINDS.has(entry.type)) {
61
+ const label = entry.name ? ` "${entry.name}"` : "";
62
+ throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${entry.type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
63
+ }
64
+ }
65
+ /**
66
+ * Write a textual asset (`content`) into `source` at the path implied by
67
+ * `ref`. Always:
68
+ *
69
+ * 1. Refuses if `config.writable` is not truthy (per §5.4).
70
+ * 2. Performs a plain filesystem write to `path.join(source.path, …)`.
71
+ *
72
+ * For sources of `kind === "git"`, additionally:
73
+ *
74
+ * 3. `git -C <path> add <file>`
75
+ * 4. `git -C <path> commit -m "Update <ref>"`
76
+ * 5. `git -C <path> push` when `config.options.pushOnCommit` is truthy.
77
+ *
78
+ * Any other `kind` reaching this helper is a configuration bug — the loader
79
+ * rejects unsupported writable kinds — so we throw {@link ConfigError}.
80
+ */
81
+ export async function writeAssetToSource(source, config, ref, content) {
82
+ ensureWritable(source, config);
83
+ const filePath = resolveAssetFilePath(source, ref);
84
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
85
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
86
+ fs.writeFileSync(filePath, normalized, "utf8");
87
+ await runKindSpecificCommit(source, config, filePath, `Update ${formatRefForMessage(ref)}`);
88
+ return { path: filePath, ref: makeAssetRef(ref.type, ref.name, ref.origin) };
89
+ }
90
+ /**
91
+ * Delete the asset at `ref` from `source`. Symmetric to
92
+ * {@link writeAssetToSource}: same writable check, same git-commit-and-push
93
+ * convenience for `kind === "git"`.
94
+ */
95
+ export async function deleteAssetFromSource(source, config, ref) {
96
+ ensureWritable(source, config);
97
+ const filePath = resolveAssetFilePath(source, ref);
98
+ if (!fs.existsSync(filePath)) {
99
+ throw new UsageError(`Asset "${formatRefForMessage(ref)}" not found in source "${source.name}" (expected at ${filePath}).`, "MISSING_REQUIRED_ARGUMENT");
100
+ }
101
+ fs.unlinkSync(filePath);
102
+ await runKindSpecificCommit(source, config, filePath, `Remove ${formatRefForMessage(ref)}`);
103
+ return { path: filePath, ref: makeAssetRef(ref.type, ref.name, ref.origin) };
104
+ }
105
+ /**
106
+ * Resolve the destination for a write per locked decision 3:
107
+ *
108
+ * 1. Explicit `--target <name>` (when supplied)
109
+ * 2. `config.defaultWriteTarget`
110
+ * 3. `config.stashDir` (the working stash created by `akm init`)
111
+ * 4. `ConfigError("no writable source configured; run `akm init`")`
112
+ *
113
+ * The legacy `first-writable-in-source-array-order` fallback is *not* used —
114
+ * see plan §6 decision 3 for the rationale.
115
+ */
116
+ export function resolveWriteTarget(akmConfig, explicitTarget) {
117
+ const configuredSources = resolveConfiguredSources(akmConfig);
118
+ // 1. Explicit --target wins.
119
+ if (explicitTarget) {
120
+ const match = configuredSources.find((s) => s.name === explicitTarget);
121
+ if (!match) {
122
+ throw new UsageError(`--target must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm list\` to see available sources.`, "INVALID_FLAG_VALUE");
123
+ }
124
+ // Up-front writable check so an explicit --target fails fast with a
125
+ // ConfigError (rather than the generic UsageError ensureWritable would
126
+ // raise after we've already started building paths). Resolve the
127
+ // effective writable flag (filesystem defaults to true; everything else
128
+ // defaults to false) so unset values are interpreted correctly.
129
+ const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
130
+ if (!effectiveWritable) {
131
+ throw new ConfigError(`source ${explicitTarget} is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${explicitTarget}" source in your config, or pass --target to a different source.`);
132
+ }
133
+ return adaptConfiguredSource(match);
134
+ }
135
+ // 2. config.defaultWriteTarget.
136
+ if (akmConfig.defaultWriteTarget) {
137
+ const match = configuredSources.find((s) => s.name === akmConfig.defaultWriteTarget);
138
+ if (match)
139
+ return adaptConfiguredSource(match);
140
+ // Fall through if the named target no longer exists — surface a clear error.
141
+ throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" does not match any configured source.`, "INVALID_CONFIG_FILE", "Update `defaultWriteTarget` in your config (run `akm config get defaultWriteTarget`) or run `akm list` to see configured sources.");
142
+ }
143
+ // 3. Working stash (config.stashDir / resolveStashDir()).
144
+ try {
145
+ const stashDir = resolveStashDir({ readOnly: true });
146
+ return {
147
+ source: { kind: "filesystem", name: "stash", path: stashDir },
148
+ config: { type: "filesystem", path: stashDir, name: "stash", writable: true },
149
+ };
150
+ }
151
+ catch {
152
+ // Fall through to the final ConfigError below.
153
+ }
154
+ // 4. Nothing usable.
155
+ throw new ConfigError("no writable source configured; run `akm init`", "STASH_DIR_NOT_FOUND", "Run `akm init` to create a working stash, or set `defaultWriteTarget` in your config.");
156
+ }
157
+ // ── Internals ───────────────────────────────────────────────────────────────
158
+ function ensureWritable(source, config) {
159
+ // Apply the same default-resolution rule as resolveWritable so callers can
160
+ // pass through a SourceConfigEntry with an absent `writable` field.
161
+ const writable = resolveWritable(config);
162
+ if (!writable) {
163
+ throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
164
+ }
165
+ }
166
+ function resolveAssetFilePath(source, ref) {
167
+ const typeDir = TYPE_DIRS[ref.type];
168
+ if (!typeDir) {
169
+ throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
170
+ }
171
+ const typeRoot = path.join(source.path, typeDir);
172
+ const assetPath = resolveAssetPathFromName(ref.type, typeRoot, ref.name);
173
+ if (!isWithin(assetPath, typeRoot)) {
174
+ throw new UsageError(`Resolved asset path escapes its source: "${ref.name}" in source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
175
+ }
176
+ return assetPath;
177
+ }
178
+ async function runKindSpecificCommit(source, config, filePath, message) {
179
+ if (source.kind === "filesystem") {
180
+ return; // No commit step.
181
+ }
182
+ if (source.kind === "git") {
183
+ runGitCommit(source.path, filePath, message);
184
+ if (config.options?.pushOnCommit) {
185
+ runGitPush(source.path);
186
+ }
187
+ return;
188
+ }
189
+ // Reject any other kind reaching the helper. The config loader is the
190
+ // first line of defence (assertWritableAllowedForKind), but we throw here
191
+ // so external callers that bypass the loader still get a clear error.
192
+ throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
193
+ "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
194
+ }
195
+ function runGitCommit(repoDir, filePath, message) {
196
+ // Stage the specific file rather than `add -A` so unrelated working-tree
197
+ // changes don't get folded into the asset commit.
198
+ const relPath = path.relative(repoDir, filePath) || filePath;
199
+ const addResult = spawnSync("git", ["-C", repoDir, "add", "--", relPath], { encoding: "utf8" });
200
+ if (addResult.status !== 0) {
201
+ throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
202
+ }
203
+ // Provide a fallback identity so fresh CI/test environments without
204
+ // user.name/user.email configured can always commit.
205
+ const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", message], { encoding: "utf8" });
206
+ if (commitResult.status !== 0) {
207
+ // `nothing to commit` is a no-op success — the file may have matched the
208
+ // existing tree exactly. Surface other errors verbatim.
209
+ const stderr = commitResult.stderr ?? "";
210
+ if (/nothing to commit|no changes added/i.test(stderr) ||
211
+ /nothing to commit|no changes added/i.test(commitResult.stdout ?? "")) {
212
+ return;
213
+ }
214
+ throw new Error(`git commit failed: ${stderr.trim() || "unknown error"}`);
215
+ }
216
+ }
217
+ function runGitPush(repoDir) {
218
+ const pushResult = spawnSync("git", ["-C", repoDir, "push"], { encoding: "utf8", timeout: 120_000 });
219
+ if (pushResult.status !== 0) {
220
+ throw new Error(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`);
221
+ }
222
+ }
223
+ function formatRefForMessage(ref) {
224
+ return ref.origin ? `${ref.origin}//${ref.type}:${ref.name}` : `${ref.type}:${ref.name}`;
225
+ }
226
+ /**
227
+ * Derive a {@link WriteTargetSource} + persisted {@link SourceConfigEntry}
228
+ * from the runtime {@link ConfiguredSource} representation used elsewhere in
229
+ * the codebase. The mapping is:
230
+ *
231
+ * ConfiguredSource.type → WriteTargetSource.kind
232
+ * ConfiguredSource.name → WriteTargetSource.name
233
+ * ConfiguredSource.source.* → WriteTargetSource.path (via parseSourceSpec)
234
+ *
235
+ * Legacy aliases (`context-hub`, `github`) have already been normalised to
236
+ * `git` by the config loader, so this mapping is straightforward.
237
+ */
238
+ function adaptConfiguredSource(runtime) {
239
+ const writePath = pathFromConfiguredSource(runtime);
240
+ if (!writePath) {
241
+ throw new ConfigError(`Source "${runtime.name}" has no resolvable on-disk path; writes are unsupported for this entry.`, "INVALID_CONFIG_FILE");
242
+ }
243
+ // Map the runtime kind to the write helper's `kind` discriminator. Only
244
+ // filesystem and git produce writable sources at v1.
245
+ const kind = runtime.type === "filesystem" || runtime.type === "git" ? runtime.type : runtime.type;
246
+ const config = {
247
+ type: runtime.type,
248
+ name: runtime.name,
249
+ ...(writePath !== undefined ? { path: writePath } : {}),
250
+ ...(runtime.writable !== undefined ? { writable: runtime.writable } : {}),
251
+ ...(runtime.options ? { options: runtime.options } : {}),
252
+ };
253
+ return {
254
+ source: { kind, name: runtime.name, path: writePath },
255
+ config,
256
+ };
257
+ }
258
+ function pathFromConfiguredSource(runtime) {
259
+ // ConfiguredSource.source is the parsed SourceSpec (filesystem|git|website|npm).
260
+ // For writable kinds we only ever care about a local on-disk path: filesystem
261
+ // sources expose it directly; git sources resolve through the cache mirror
262
+ // (handled by the existing source provider). For v1 the helper trusts
263
+ // callers to materialise the cache path beforehand and does not re-clone.
264
+ const spec = runtime.source;
265
+ if (spec.type === "filesystem")
266
+ return spec.path;
267
+ // For git sources we fall back to the cached repo directory the provider
268
+ // already materialised. The lookup is intentionally lazy — we only import
269
+ // it when needed to keep the helper's import graph small.
270
+ if (spec.type === "git") {
271
+ try {
272
+ const repo = parseGitRepoUrl(spec.url);
273
+ return getCachePaths(repo.canonicalUrl).repoDir;
274
+ }
275
+ catch {
276
+ return undefined;
277
+ }
278
+ }
279
+ return undefined;
280
+ }