akm-cli 0.6.0-rc1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +9 -9
  3. package/dist/cli.js +199 -114
  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} +69 -3
  16. package/dist/{stash-show.js → commands/show.js} +104 -84
  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} +133 -56
  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} +79 -47
  30. package/dist/{file-context.js → indexer/file-context.js} +3 -3
  31. package/dist/{indexer.js → indexer/indexer.js} +132 -33
  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} +3 -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} +2 -2
  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} +34 -18
  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 +91 -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
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { fetchWithRetry, ResponseTooLargeError, readBodyWithByteCap } from "../common";
5
- import { ConfigError, UsageError } from "../errors";
6
- import { getRegistryIndexCacheDir } from "../paths";
7
- import { registerStashProvider } from "../stash-provider-factory";
8
- import { isDirectory, isExpired, sanitizeString } from "./provider-utils";
4
+ import { fetchWithRetry, ResponseTooLargeError, readBodyWithByteCap } from "../../core/common";
5
+ import { ConfigError, UsageError } from "../../core/errors";
6
+ import { getRegistryIndexCacheDir } from "../../core/paths";
7
+ import { warn } from "../../core/warn";
8
+ import { registerSourceProvider } from "../provider-factory";
9
+ import { isExpired, sanitizeString } from "./provider-utils";
9
10
  /** Refresh website snapshots every 12 hours to balance freshness with scraping load. */
10
11
  const CACHE_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000;
11
12
  /** Allow up to 7 days of stale snapshots when refresh fails so search remains available during outages. */
@@ -28,68 +29,34 @@ const WEBSITE_PAGE_BYTE_CAP = 5 * 1024 * 1024;
28
29
  */
29
30
  const WEBSITE_CRAWL_WALL_CLOCK_MS = 10 * 60 * 1000;
30
31
  /**
31
- * Website stash provider. Implements {@link SyncableStashProvider} (which
32
- * extends LiveStashProvider) scrapes pages into a local mirror so the FTS5
33
- * indexer can walk them.
32
+ * Website source provider scrapes pages into a local mirror so the FTS5
33
+ * indexer can walk them. Implements the v1 {@link SourceProvider} interface
34
+ * (spec §2.1): `{ name, kind, init, path, sync }`.
35
+ *
36
+ * Reading is the indexer's job — this class doesn't implement `search` or
37
+ * `show`.
34
38
  */
35
- class WebsiteStashProvider {
36
- type = "website";
37
- kind = "syncable";
39
+ class WebsiteSourceProvider {
40
+ kind = "website";
38
41
  name;
39
- config;
42
+ #config;
43
+ #url;
40
44
  constructor(config) {
41
- this.config = config;
45
+ this.#config = config;
42
46
  this.name = config.name ?? "website";
43
- validateWebsiteUrl(config.url ?? "");
47
+ this.#url = validateWebsiteUrl(config.url ?? "");
44
48
  }
45
- /** Content is indexed through the standard FTS5 pipeline. */
46
- async search(_options) {
47
- return { hits: [] };
49
+ async init(_ctx) {
50
+ // URL validation already happens in the constructor; nothing else to do.
48
51
  }
49
- /** Content is local files, shown via showLocal. */
50
- async show(_ref, _view) {
51
- throw new Error("Website provider content is shown via local index");
52
+ path() {
53
+ return getCachePaths(this.#url).stashDir;
52
54
  }
53
- /** Content is local; no remote show needed. */
54
- canShow(_ref) {
55
- return false;
56
- }
57
- async sync(config, options) {
58
- const cachePaths = await ensureWebsiteMirror(config, { requireStashDir: true, force: options?.force });
59
- const syncedAt = (options?.now ?? new Date()).toISOString();
60
- const url = config.url ?? "";
61
- // #123 added "website" to the StashSource union, so we can use it directly.
62
- return {
63
- id: url,
64
- source: "website",
65
- ref: url,
66
- artifactUrl: url,
67
- contentDir: cachePaths.stashDir,
68
- cacheDir: cachePaths.rootDir,
69
- extractedDir: cachePaths.stashDir,
70
- syncedAt,
71
- };
72
- }
73
- getContentDir(config) {
74
- const url = config.url ?? "";
75
- return getCachePaths(url).stashDir;
76
- }
77
- async remove(config) {
78
- const url = config.url;
79
- if (!url)
80
- return;
81
- const paths = getCachePaths(url);
82
- if (isDirectory(paths.rootDir)) {
83
- try {
84
- fs.rmSync(paths.rootDir, { recursive: true, force: true });
85
- }
86
- catch {
87
- /* best-effort */
88
- }
89
- }
55
+ async sync() {
56
+ await ensureWebsiteMirror(this.#config, { requireStashDir: true });
90
57
  }
91
58
  }
92
- registerStashProvider("website", (config) => new WebsiteStashProvider(config));
59
+ registerSourceProvider("website", (config) => new WebsiteSourceProvider(config));
93
60
  function getCachePaths(siteUrl) {
94
61
  const key = createHash("sha256").update(normalizeSiteUrl(siteUrl)).digest("hex").slice(0, 16);
95
62
  const rootDir = path.join(getRegistryIndexCacheDir(), `website-${key}`);
@@ -182,12 +149,9 @@ async function crawlWebsite(startUrl, options) {
182
149
  const visited = new Set();
183
150
  const pages = [];
184
151
  const deadline = Date.now() + WEBSITE_CRAWL_WALL_CLOCK_MS;
185
- let stoppedAtDeadline = false;
186
152
  while (queue.length > 0 && pages.length < options.maxPages) {
187
- if (Date.now() > deadline) {
188
- stoppedAtDeadline = true;
153
+ if (Date.now() > deadline)
189
154
  break;
190
- }
191
155
  const next = queue.shift();
192
156
  if (!next)
193
157
  break;
@@ -212,8 +176,8 @@ async function crawlWebsite(startUrl, options) {
212
176
  queue.push({ url: candidate, depth: next.depth + 1 });
213
177
  }
214
178
  }
215
- if (stoppedAtDeadline) {
216
- console.warn(`[akm] website crawl stopped at the ${WEBSITE_CRAWL_WALL_CLOCK_MS / 1000}s wall-clock cap with ${pages.length}/${options.maxPages} pages collected from ${startUrl}.`);
179
+ if (Date.now() > deadline) {
180
+ warn("[akm] website crawl stopped at the %ds wall-clock cap with %d/%d pages collected from %s.", WEBSITE_CRAWL_WALL_CLOCK_MS / 1000, pages.length, options.maxPages, startUrl);
217
181
  }
218
182
  return pages;
219
183
  }
@@ -516,4 +480,4 @@ function safeCodePointToString(value) {
516
480
  return undefined;
517
481
  }
518
482
  }
519
- export { ensureWebsiteMirror, getCachePaths, validateWebsiteInputUrl, validateWebsiteUrl, WebsiteStashProvider };
483
+ export { ensureWebsiteMirror, getCachePaths, validateWebsiteInputUrl, validateWebsiteUrl, WebsiteSourceProvider };
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile, resolveAssetPathFromName, TYPE_DIRS, } from "./asset-spec";
4
- import { hasErrnoCode, isWithin } from "./common";
5
- import { NotFoundError, UsageError } from "./errors";
6
- import { runMatchers } from "./file-context";
7
- import { walkStashFlat } from "./walker";
3
+ import { deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile, resolveAssetPathFromName, TYPE_DIRS, } from "../core/asset-spec";
4
+ import { hasErrnoCode, isWithin } from "../core/common";
5
+ import { NotFoundError, UsageError } from "../core/errors";
6
+ import { runMatchers } from "../indexer/file-context";
7
+ import { walkStashFlat } from "../indexer/walker";
8
8
  /**
9
9
  * Resolve an asset path from a stash directory, type, and name.
10
10
  */
@@ -50,7 +50,8 @@ function resolveInTypeDir(stashDir, typeDir, type, name) {
50
50
  function resolveAndValidateTypeRoot(root, type, name) {
51
51
  const rootStat = readTypeRootStat(root, type, name);
52
52
  if (!rootStat.isDirectory()) {
53
- throw new NotFoundError(`Stash type root is not a directory for ref: ${type}:${name}`);
53
+ throw new NotFoundError(`Asset directory for ${type} assets is not accessible — got a file where a directory was expected for ref: ${type}:${name}. ` +
54
+ "Run `akm index` to rebuild the index, or check your source configuration.", "ASSET_NOT_FOUND", "Run `akm list` to see your configured sources and verify the source path exists.");
54
55
  }
55
56
  return fs.realpathSync(root);
56
57
  }
@@ -60,7 +61,7 @@ function readTypeRootStat(root, type, name) {
60
61
  }
61
62
  catch (error) {
62
63
  if (hasErrnoCode(error, "ENOENT")) {
63
- throw new NotFoundError(`Stash type root not found for ref: ${type}:${name}`);
64
+ throw new NotFoundError(`Asset not found for ref: ${type}:${name}. No ${type} assets are present in the configured source.`, "ASSET_NOT_FOUND", "Run `akm list` to see your configured sources, or `akm index` to rebuild the search index.");
64
65
  }
65
66
  throw error;
66
67
  }
@@ -15,13 +15,13 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { parse as yamlParse } from "yaml";
18
- import { isWithin } from "./common";
19
- import { loadUserConfig, saveConfig } from "./config";
20
- import { NotFoundError, UsageError } from "./errors";
21
- import { parseFrontmatter, parseFrontmatterBlock } from "./frontmatter";
22
- import { resolveStashSources } from "./search-source";
23
- import { akmSearch } from "./stash-search";
24
- import { buildIndexMd, buildLogMd, buildSchemaMd } from "./templates/wiki-templates";
18
+ import { akmSearch } from "../commands/search";
19
+ import { isWithin } from "../core/common";
20
+ import { loadUserConfig, saveConfig } from "../core/config";
21
+ import { NotFoundError, UsageError } from "../core/errors";
22
+ import { parseFrontmatter, parseFrontmatterBlock } from "../core/frontmatter";
23
+ import { resolveSourceEntries } from "../indexer/search-source";
24
+ import { buildIndexMd, buildLogMd, buildSchemaMd } from "../templates/wiki-templates";
25
25
  // ── Constants ───────────────────────────────────────────────────────────────
26
26
  export const WIKIS_SUBDIR = "wikis";
27
27
  export const SCHEMA_MD = "schema.md";
@@ -64,7 +64,7 @@ function wikiNotFoundMessage(name) {
64
64
  return `Wiki not found: ${name}. Run \`akm wiki create ${name}\` to create it or \`akm wiki register ${name} <path-or-repo>\` to register an external wiki.`;
65
65
  }
66
66
  function registeredWikiSources(stashDir) {
67
- return resolveStashSources(stashDir)
67
+ return resolveSourceEntries(stashDir)
68
68
  .filter((source) => typeof source.wikiName === "string")
69
69
  .map((source) => ({
70
70
  name: source.wikiName,
@@ -76,7 +76,7 @@ function registeredWikiSources(stashDir) {
76
76
  export function resolveWikiSource(stashDir, name) {
77
77
  validateWikiName(name);
78
78
  const wikiDir = resolveWikiDir(stashDir, name);
79
- if (fs.existsSync(wikiDir)) {
79
+ if (fs.existsSync(wikiDir) && isRecognizedStashWiki(wikiDir)) {
80
80
  return { name, path: wikiDir, mode: "stash" };
81
81
  }
82
82
  const external = registeredWikiSources(stashDir).find((source) => source.name === name);
@@ -87,7 +87,7 @@ export function resolveWikiSource(stashDir, name) {
87
87
  export function ensureWikiNameAvailable(stashDir, name) {
88
88
  validateWikiName(name);
89
89
  const wikiDir = resolveWikiDir(stashDir, name);
90
- if (fs.existsSync(wikiDir)) {
90
+ if (fs.existsSync(wikiDir) && isRecognizedStashWiki(wikiDir)) {
91
91
  throw new UsageError(`Wiki already exists: ${name}.`, "RESOURCE_ALREADY_EXISTS");
92
92
  }
93
93
  const external = registeredWikiSources(stashDir).find((source) => source.name === name);
@@ -164,6 +164,17 @@ function scanWikiFiles(wikiDir) {
164
164
  }
165
165
  return { pages, raws, lastModifiedMs, pagesLastModifiedMs };
166
166
  }
167
+ function hasWikiInfrastructure(wikiDir) {
168
+ for (const file of WIKI_SPECIAL_FILES) {
169
+ if (fs.existsSync(path.join(wikiDir, file)))
170
+ return true;
171
+ }
172
+ return false;
173
+ }
174
+ function isRecognizedStashWiki(wikiDir, buckets) {
175
+ const scanned = buckets ?? scanWikiFiles(wikiDir);
176
+ return scanned.pages.length > 0 || hasWikiInfrastructure(wikiDir);
177
+ }
167
178
  function readSchemaDescription(wikiDir) {
168
179
  const schemaPath = path.join(wikiDir, SCHEMA_MD);
169
180
  let raw;
@@ -207,6 +218,8 @@ export function listWikis(stashDir) {
207
218
  }
208
219
  const summarize = (name, dir) => {
209
220
  const buckets = scanWikiFiles(dir);
221
+ if (!isRecognizedStashWiki(dir, buckets))
222
+ return;
210
223
  const summary = {
211
224
  name,
212
225
  path: dir,
@@ -353,15 +366,18 @@ export function createWiki(stashDir, name) {
353
366
  * ignore that (e.g. idempotent cleanup) by catching.
354
367
  */
355
368
  export function removeWiki(stashDir, name, options = {}) {
356
- const resolved = resolveWikiSource(stashDir, name);
357
- const wikiDir = resolved.path;
358
- if (resolved.mode === "external") {
369
+ validateWikiName(name);
370
+ const wikiDir = resolveWikiDir(stashDir, name);
371
+ const external = registeredWikiSources(stashDir).find((source) => source.name === name);
372
+ const isStashWiki = fs.existsSync(wikiDir) && isRecognizedStashWiki(wikiDir);
373
+ if (!isStashWiki && external) {
359
374
  const config = loadUserConfig();
360
- const stashes = (config.stashes ?? []).filter((entry) => entry.wikiName !== name);
375
+ const filteredSources = (config.sources ?? config.stashes ?? []).filter((entry) => entry.wikiName !== name);
361
376
  const installed = (config.installed ?? []).filter((entry) => entry.wikiName !== name);
362
377
  saveConfig({
363
378
  ...config,
364
- stashes: stashes.length > 0 ? stashes : undefined,
379
+ sources: filteredSources.length > 0 ? filteredSources : undefined,
380
+ stashes: undefined,
365
381
  installed: installed.length > 0 ? installed : undefined,
366
382
  });
367
383
  return {
@@ -372,8 +388,8 @@ export function removeWiki(stashDir, name, options = {}) {
372
388
  unregistered: true,
373
389
  };
374
390
  }
375
- if (!fs.existsSync(wikiDir)) {
376
- throw new NotFoundError(`Wiki not found: ${name}.`, "STASH_NOT_FOUND");
391
+ if (!fs.existsSync(wikiDir) || (!isStashWiki && !options.withSources)) {
392
+ throw new NotFoundError(wikiNotFoundMessage(name), "STASH_NOT_FOUND");
377
393
  }
378
394
  const wikisRoot = resolveWikisRoot(stashDir);
379
395
  if (!isWithin(wikiDir, wikisRoot)) {
@@ -527,7 +543,7 @@ export async function searchInWiki(input) {
527
543
  const rawDir = path.join(wikiDir, RAW_SUBDIR);
528
544
  const filtered = [];
529
545
  for (const hit of response.hits) {
530
- // hits can be StashSearchHit or RegistrySearchResultHit (union); filter
546
+ // hits can be SourceSearchHit or RegistrySearchResultHit (union); filter
531
547
  // by path inclusion. Registry hits have no path and are dropped.
532
548
  if (hit.type === "registry")
533
549
  continue;
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { resolveAssetPathFromName } from "./asset-spec";
4
- import { isWithin, resolveStashDir } from "./common";
5
- import { UsageError } from "./errors";
6
- import { warn } from "./warn";
7
- import { parseWorkflowMarkdown, WorkflowValidationError } from "./workflow-markdown";
3
+ import { resolveAssetPathFromName } from "../core/asset-spec";
4
+ import { isWithin, resolveStashDir } from "../core/common";
5
+ import { UsageError } from "../core/errors";
6
+ import { warn } from "../core/warn";
7
+ import { parseWorkflow } from "./parser";
8
8
  const DEFAULT_WORKFLOW_TEMPLATE = renderWorkflowTemplate({
9
9
  title: "Example Workflow",
10
10
  firstStepTitle: "First Step",
@@ -23,7 +23,10 @@ export function buildWorkflowTemplate(name) {
23
23
  firstStepTitle: `${title} Setup`,
24
24
  firstStepId: `${stepId}-setup`,
25
25
  });
26
- parseWorkflowMarkdown(customized);
26
+ const result = parseWorkflow(customized, { path: `<template:${name}>` });
27
+ if (!result.ok) {
28
+ throw new UsageError(formatWorkflowErrors(`<template:${name}>`, result.errors));
29
+ }
27
30
  return customized;
28
31
  }
29
32
  export function createWorkflowAsset(input) {
@@ -41,14 +44,10 @@ export function createWorkflowAsset(input) {
41
44
  const content = input.from
42
45
  ? readWorkflowSource(input.from, stashDir)
43
46
  : (input.content ?? buildWorkflowTemplate(normalizedName));
44
- try {
45
- parseWorkflowMarkdown(content);
46
- }
47
- catch (error) {
48
- if (error instanceof WorkflowValidationError) {
49
- throw new UsageError(error.message);
50
- }
51
- throw error;
47
+ const sourcePath = input.from ?? `workflows/${normalizedName}.md`;
48
+ const result = parseWorkflow(content, { path: sourcePath });
49
+ if (!result.ok) {
50
+ throw new UsageError(formatWorkflowErrors(sourcePath, result.errors));
52
51
  }
53
52
  fs.mkdirSync(path.dirname(assetPath), { recursive: true });
54
53
  fs.writeFileSync(assetPath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
@@ -110,6 +109,30 @@ function slugifyWorkflowStepId(name) {
110
109
  .replace(/[^a-z0-9]+/g, "-")
111
110
  .replace(/^-+|-+$/g, "") || "workflow");
112
111
  }
112
+ export function formatWorkflowErrors(path, errors) {
113
+ const lines = errors.map((e) => ` ${path}:${e.line} — ${e.message}`);
114
+ const heading = errors.length === 1 ? "Workflow has 1 error:" : `Workflow has ${errors.length} errors:`;
115
+ return [heading, ...lines].join("\n");
116
+ }
117
+ /**
118
+ * Validate a workflow by ref (`workflow:<name>`) or filesystem path.
119
+ *
120
+ * Returns the parse result plus the source-relative path used. Throws
121
+ * `UsageError` only when the target cannot be located on disk; parse
122
+ * failures are returned as `{ ok: false, errors }` so callers can
123
+ * format them however they like.
124
+ */
125
+ export function validateWorkflowSource(target) {
126
+ if (target.startsWith("workflow:")) {
127
+ throw new UsageError(`validateWorkflowSource expects a filesystem path; resolve refs to paths in the caller before invoking.`);
128
+ }
129
+ const resolved = path.resolve(target);
130
+ if (!fs.existsSync(resolved)) {
131
+ throw new UsageError(`Workflow file not found: "${target}".`);
132
+ }
133
+ const content = fs.readFileSync(resolved, "utf8");
134
+ return { path: target, parse: parseWorkflow(content, { path: target }) };
135
+ }
113
136
  function renderWorkflowTemplate(input) {
114
137
  return `---
115
138
  description: Describe what this workflow accomplishes
@@ -1,4 +1,4 @@
1
- import { UsageError } from "./errors";
1
+ import { UsageError } from "../core/errors";
2
2
  export const WORKFLOW_STEP_STATES = [
3
3
  "completed",
4
4
  "blocked",
@@ -14,6 +14,7 @@ export const WORKFLOW_SUBCOMMANDS = new Set([
14
14
  "create",
15
15
  "template",
16
16
  "resume",
17
+ "validate",
17
18
  ]);
18
19
  export function parseWorkflowJsonObject(raw, flagName) {
19
20
  if (!raw)
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { getWorkflowDbPath } from "./paths";
4
+ import { getWorkflowDbPath } from "../core/paths";
5
5
  export function openWorkflowDatabase(dbPath = getWorkflowDbPath()) {
6
6
  const dir = path.dirname(dbPath);
7
7
  if (!fs.existsSync(dir)) {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Side-channel cache that lets the workflow renderer hand a validated
3
+ * `WorkflowDocument` to the indexer without persisting it through the
4
+ * `entry_json` column or widening `StashEntry` with a workflow-shaped field.
5
+ *
6
+ * The renderer is called during metadata generation; the indexer writes the
7
+ * document to `workflow_documents` after `upsertEntry` returns the row id.
8
+ * A WeakMap keyed by the entry object preserves the parse work between the
9
+ * two phases without leaking memory if the entry is dropped.
10
+ */
11
+ const cache = new WeakMap();
12
+ export function cacheWorkflowDocument(entry, doc) {
13
+ cache.set(entry, doc);
14
+ }
15
+ export function takeWorkflowDocument(entry) {
16
+ const doc = cache.get(entry);
17
+ if (doc !== undefined)
18
+ cache.delete(entry);
19
+ return doc;
20
+ }