busy-cli 0.1.2

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 (128) hide show
  1. package/README.md +129 -0
  2. package/dist/builders/context.d.ts +50 -0
  3. package/dist/builders/context.d.ts.map +1 -0
  4. package/dist/builders/context.js +190 -0
  5. package/dist/cache/index.d.ts +100 -0
  6. package/dist/cache/index.d.ts.map +1 -0
  7. package/dist/cache/index.js +270 -0
  8. package/dist/cli/index.d.ts +3 -0
  9. package/dist/cli/index.d.ts.map +1 -0
  10. package/dist/cli/index.js +463 -0
  11. package/dist/commands/package.d.ts +96 -0
  12. package/dist/commands/package.d.ts.map +1 -0
  13. package/dist/commands/package.js +285 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/loader.d.ts +6 -0
  18. package/dist/loader.d.ts.map +1 -0
  19. package/dist/loader.js +361 -0
  20. package/dist/merge.d.ts +16 -0
  21. package/dist/merge.d.ts.map +1 -0
  22. package/dist/merge.js +102 -0
  23. package/dist/package/manifest.d.ts +59 -0
  24. package/dist/package/manifest.d.ts.map +1 -0
  25. package/dist/package/manifest.js +265 -0
  26. package/dist/parser.d.ts +28 -0
  27. package/dist/parser.d.ts.map +1 -0
  28. package/dist/parser.js +220 -0
  29. package/dist/parsers/frontmatter.d.ts +14 -0
  30. package/dist/parsers/frontmatter.d.ts.map +1 -0
  31. package/dist/parsers/frontmatter.js +110 -0
  32. package/dist/parsers/imports.d.ts +48 -0
  33. package/dist/parsers/imports.d.ts.map +1 -0
  34. package/dist/parsers/imports.js +147 -0
  35. package/dist/parsers/links.d.ts +12 -0
  36. package/dist/parsers/links.d.ts.map +1 -0
  37. package/dist/parsers/links.js +79 -0
  38. package/dist/parsers/localdefs.d.ts +6 -0
  39. package/dist/parsers/localdefs.d.ts.map +1 -0
  40. package/dist/parsers/localdefs.js +132 -0
  41. package/dist/parsers/operations.d.ts +32 -0
  42. package/dist/parsers/operations.d.ts.map +1 -0
  43. package/dist/parsers/operations.js +313 -0
  44. package/dist/parsers/sections.d.ts +15 -0
  45. package/dist/parsers/sections.d.ts.map +1 -0
  46. package/dist/parsers/sections.js +173 -0
  47. package/dist/parsers/tools.d.ts +30 -0
  48. package/dist/parsers/tools.d.ts.map +1 -0
  49. package/dist/parsers/tools.js +178 -0
  50. package/dist/parsers/triggers.d.ts +35 -0
  51. package/dist/parsers/triggers.d.ts.map +1 -0
  52. package/dist/parsers/triggers.js +219 -0
  53. package/dist/providers/base.d.ts +60 -0
  54. package/dist/providers/base.d.ts.map +1 -0
  55. package/dist/providers/base.js +34 -0
  56. package/dist/providers/github.d.ts +18 -0
  57. package/dist/providers/github.d.ts.map +1 -0
  58. package/dist/providers/github.js +109 -0
  59. package/dist/providers/gitlab.d.ts +18 -0
  60. package/dist/providers/gitlab.d.ts.map +1 -0
  61. package/dist/providers/gitlab.js +101 -0
  62. package/dist/providers/index.d.ts +13 -0
  63. package/dist/providers/index.d.ts.map +1 -0
  64. package/dist/providers/index.js +17 -0
  65. package/dist/providers/local.d.ts +31 -0
  66. package/dist/providers/local.d.ts.map +1 -0
  67. package/dist/providers/local.js +116 -0
  68. package/dist/providers/url.d.ts +16 -0
  69. package/dist/providers/url.d.ts.map +1 -0
  70. package/dist/providers/url.js +45 -0
  71. package/dist/registry/index.d.ts +99 -0
  72. package/dist/registry/index.d.ts.map +1 -0
  73. package/dist/registry/index.js +320 -0
  74. package/dist/types/schema.d.ts +3259 -0
  75. package/dist/types/schema.d.ts.map +1 -0
  76. package/dist/types/schema.js +258 -0
  77. package/dist/utils/logger.d.ts +19 -0
  78. package/dist/utils/logger.d.ts.map +1 -0
  79. package/dist/utils/logger.js +23 -0
  80. package/dist/utils/slugify.d.ts +14 -0
  81. package/dist/utils/slugify.d.ts.map +1 -0
  82. package/dist/utils/slugify.js +28 -0
  83. package/package.json +61 -0
  84. package/src/__tests__/cache.test.ts +393 -0
  85. package/src/__tests__/cli-package.test.ts +667 -0
  86. package/src/__tests__/fixtures/automated-workflow.busy.md +84 -0
  87. package/src/__tests__/fixtures/concept.busy.md +30 -0
  88. package/src/__tests__/fixtures/document.busy.md +44 -0
  89. package/src/__tests__/fixtures/simple-operation.busy.md +45 -0
  90. package/src/__tests__/fixtures/tool-document.busy.md +71 -0
  91. package/src/__tests__/fixtures/tool.busy.md +54 -0
  92. package/src/__tests__/imports.test.ts +244 -0
  93. package/src/__tests__/integration.test.ts +432 -0
  94. package/src/__tests__/operations.test.ts +408 -0
  95. package/src/__tests__/package-manifest.test.ts +455 -0
  96. package/src/__tests__/providers.test.ts +672 -0
  97. package/src/__tests__/registry.test.ts +402 -0
  98. package/src/__tests__/schema.test.ts +467 -0
  99. package/src/__tests__/tools.test.ts +376 -0
  100. package/src/__tests__/triggers.test.ts +312 -0
  101. package/src/builders/context.ts +294 -0
  102. package/src/cache/index.ts +312 -0
  103. package/src/cli/index.ts +514 -0
  104. package/src/commands/package.ts +392 -0
  105. package/src/index.ts +46 -0
  106. package/src/loader.ts +474 -0
  107. package/src/merge.ts +126 -0
  108. package/src/package/manifest.ts +349 -0
  109. package/src/parser.ts +278 -0
  110. package/src/parsers/frontmatter.ts +135 -0
  111. package/src/parsers/imports.ts +196 -0
  112. package/src/parsers/links.ts +108 -0
  113. package/src/parsers/localdefs.ts +166 -0
  114. package/src/parsers/operations.ts +404 -0
  115. package/src/parsers/sections.ts +230 -0
  116. package/src/parsers/tools.ts +215 -0
  117. package/src/parsers/triggers.ts +252 -0
  118. package/src/providers/base.ts +77 -0
  119. package/src/providers/github.ts +129 -0
  120. package/src/providers/gitlab.ts +121 -0
  121. package/src/providers/index.ts +25 -0
  122. package/src/providers/local.ts +129 -0
  123. package/src/providers/url.ts +56 -0
  124. package/src/registry/index.ts +408 -0
  125. package/src/types/schema.ts +369 -0
  126. package/src/utils/logger.ts +25 -0
  127. package/src/utils/slugify.ts +31 -0
  128. package/tsconfig.json +21 -0
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Package Manifest Parser
3
+ *
4
+ * Parses package.busy.md files that define a package's contents.
5
+ * A package manifest lists all documents that are part of the package.
6
+ */
7
+
8
+ import { promises as fs } from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import matter from 'gray-matter';
11
+ import { CacheManager, calculateIntegrity } from '../cache/index.js';
12
+ import { PackageRegistry, PackageEntry } from '../registry/index.js';
13
+ import { providerRegistry } from '../providers/index.js';
14
+
15
+ // Ensure providers are registered
16
+ import '../providers/local.js';
17
+ import '../providers/github.js';
18
+ import '../providers/gitlab.js';
19
+ import '../providers/url.js';
20
+
21
+ /**
22
+ * A document listed in a package manifest
23
+ */
24
+ export interface PackageDocument {
25
+ name: string;
26
+ relativePath: string;
27
+ type?: string;
28
+ anchor?: string;
29
+ description?: string;
30
+ category?: string;
31
+ }
32
+
33
+ /**
34
+ * Parsed package manifest
35
+ */
36
+ export interface PackageManifest {
37
+ name: string;
38
+ type: string;
39
+ version: string;
40
+ description: string;
41
+ documents: PackageDocument[];
42
+ }
43
+
44
+ /**
45
+ * Result of fetching a package from manifest
46
+ */
47
+ export interface FetchPackageResult {
48
+ name: string;
49
+ version: string;
50
+ description: string;
51
+ documents: PackageDocument[];
52
+ cached: string;
53
+ integrity: string;
54
+ }
55
+
56
+ /**
57
+ * Check if a URL/path points to a package.busy.md manifest or a directory containing one
58
+ */
59
+ export function isPackageManifestUrl(url: string): boolean {
60
+ // Explicit package.busy.md reference
61
+ if (url.endsWith('/package.busy.md') || url.includes('/package.busy.md#')) {
62
+ return true;
63
+ }
64
+ if (url.endsWith('package.busy.md')) {
65
+ return true;
66
+ }
67
+ // Local path that might be a directory - check if it looks like a local path without extension
68
+ if ((url.startsWith('./') || url.startsWith('../') || url.startsWith('/')) && !url.endsWith('.md') && !url.endsWith('.busy')) {
69
+ return true; // Treat as potential package directory
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * Parse a Field/Value table and return a record
76
+ */
77
+ function parseFieldValueTable(tableContent: string): Record<string, string> {
78
+ const fields: Record<string, string> = {};
79
+ const rowPattern = /\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/g;
80
+ let match;
81
+
82
+ while ((match = rowPattern.exec(tableContent)) !== null) {
83
+ const field = match[1].trim();
84
+ const value = match[2].trim();
85
+
86
+ // Skip header row and separator
87
+ if (field === 'Field' || field.startsWith('-')) {
88
+ continue;
89
+ }
90
+
91
+ fields[field] = value;
92
+ }
93
+
94
+ return fields;
95
+ }
96
+
97
+ /**
98
+ * Parse a package manifest (package.busy.md content)
99
+ *
100
+ * Supports two formats:
101
+ * 1. Table-based: H3 headers with Field/Value tables (Path, Type, Description)
102
+ * 2. Link-based: Markdown links like - [Name](./path) - Description
103
+ */
104
+ export function parsePackageManifest(content: string): PackageManifest {
105
+ // Extract only first frontmatter block to avoid "multiple documents" error
106
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
107
+ let frontmatter: Record<string, any> = {};
108
+ let body = content;
109
+ if (frontmatterMatch) {
110
+ const { data } = matter(frontmatterMatch[0]);
111
+ frontmatter = data;
112
+ body = content.slice(frontmatterMatch[0].length);
113
+ }
114
+
115
+ const manifest: PackageManifest = {
116
+ name: frontmatter.Name || frontmatter.name || 'unknown',
117
+ type: frontmatter.Type || frontmatter.type || 'Package',
118
+ version: frontmatter.Version || frontmatter.version || 'latest',
119
+ description: frontmatter.Description || frontmatter.description || '',
120
+ documents: [],
121
+ };
122
+
123
+ // Find Package Contents section
124
+ const contentsMatch = body.match(/# Package Contents\n([\s\S]*?)(?=\n# |$)/);
125
+ if (!contentsMatch) {
126
+ return manifest;
127
+ }
128
+
129
+ const contentsBody = contentsMatch[1];
130
+ let currentCategory: string | undefined;
131
+
132
+ // Parse categories (H2 headers) and entries (H3 headers with tables)
133
+ const categoryPattern = /## ([^\n]+)\n([\s\S]*?)(?=\n## |$)/g;
134
+ let categoryMatch;
135
+
136
+ while ((categoryMatch = categoryPattern.exec(contentsBody)) !== null) {
137
+ currentCategory = categoryMatch[1].trim();
138
+ const categoryContent = categoryMatch[2];
139
+
140
+ // Parse entries (H3 headers with tables)
141
+ const entryPattern = /### ([^\n]+)\n([\s\S]*?)(?=\n### |$)/g;
142
+ let entryMatch;
143
+
144
+ while ((entryMatch = entryPattern.exec(categoryContent)) !== null) {
145
+ const name = entryMatch[1].trim();
146
+ const entryContent = entryMatch[2];
147
+
148
+ // Check if this entry uses table format (has | Path |)
149
+ if (entryContent.includes('| Path |') || entryContent.includes('| Path |')) {
150
+ const fields = parseFieldValueTable(entryContent);
151
+
152
+ if (fields['Path']) {
153
+ let relativePath = fields['Path'];
154
+ let anchor: string | undefined;
155
+
156
+ // Extract anchor if present
157
+ const anchorIndex = relativePath.indexOf('#');
158
+ if (anchorIndex !== -1) {
159
+ anchor = relativePath.slice(anchorIndex + 1).toLowerCase();
160
+ relativePath = relativePath.slice(0, anchorIndex);
161
+ }
162
+
163
+ manifest.documents.push({
164
+ name,
165
+ relativePath,
166
+ type: fields['Type'],
167
+ description: fields['Description'],
168
+ anchor,
169
+ category: currentCategory,
170
+ });
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // If no table-based entries found, try link-based format
177
+ if (manifest.documents.length === 0) {
178
+ const lines = body.split('\n');
179
+ currentCategory = undefined;
180
+
181
+ for (const line of lines) {
182
+ // Check for H2 headers (categories)
183
+ const h2Match = line.match(/^## (.+)$/);
184
+ if (h2Match) {
185
+ currentCategory = h2Match[1].trim();
186
+ continue;
187
+ }
188
+
189
+ // Check for markdown links in list items
190
+ // Format: - [Name](./relative/path.md) - Description
191
+ const linkMatch = line.match(/^-\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*-\s*(.+))?$/);
192
+ if (linkMatch) {
193
+ const name = linkMatch[1].trim();
194
+ let relativePath = linkMatch[2].trim();
195
+ const description = linkMatch[3]?.trim();
196
+
197
+ // Extract anchor if present
198
+ let anchor: string | undefined;
199
+ const anchorIndex = relativePath.indexOf('#');
200
+ if (anchorIndex !== -1) {
201
+ anchor = relativePath.slice(anchorIndex + 1).toLowerCase();
202
+ relativePath = relativePath.slice(0, anchorIndex);
203
+ }
204
+
205
+ manifest.documents.push({
206
+ name,
207
+ relativePath,
208
+ anchor,
209
+ description,
210
+ category: currentCategory,
211
+ });
212
+ }
213
+ }
214
+ }
215
+
216
+ return manifest;
217
+ }
218
+
219
+ /**
220
+ * Resolve a relative path from a manifest to an absolute URL
221
+ */
222
+ function resolveDocumentUrl(manifestUrl: string, relativePath: string): string {
223
+ // Get the base URL (directory containing the manifest)
224
+ const baseUrl = manifestUrl.substring(0, manifestUrl.lastIndexOf('/'));
225
+
226
+ // Handle ./ prefix
227
+ let cleanPath = relativePath;
228
+ if (cleanPath.startsWith('./')) {
229
+ cleanPath = cleanPath.slice(2);
230
+ }
231
+
232
+ return `${baseUrl}/${cleanPath}`;
233
+ }
234
+
235
+ /**
236
+ * Fetch a package from its manifest URL or local path
237
+ */
238
+ export async function fetchPackageFromManifest(
239
+ workspaceRoot: string,
240
+ manifestUrl: string
241
+ ): Promise<FetchPackageResult> {
242
+ // Find provider for the manifest URL
243
+ const provider = providerRegistry.findProvider(manifestUrl);
244
+ if (!provider) {
245
+ throw new Error(`No provider found for URL: ${manifestUrl}`);
246
+ }
247
+
248
+ // Parse the manifest URL to get raw URL
249
+ const parsedManifest = provider.parse(manifestUrl);
250
+
251
+ // Fetch the manifest content
252
+ const manifestContent = await provider.fetch(manifestUrl);
253
+
254
+ // Parse the manifest
255
+ const manifest = parsePackageManifest(manifestContent);
256
+
257
+ // Initialize cache
258
+ const cache = new CacheManager(workspaceRoot);
259
+ await cache.init();
260
+
261
+ // Calculate the base path/URL for resolving relative paths
262
+ const rawManifestUrl = provider.getRawUrl(parsedManifest);
263
+ const basePath = rawManifestUrl.substring(0, rawManifestUrl.lastIndexOf('/'));
264
+ const isLocal = provider.name === 'local';
265
+
266
+ // Fetch all documents listed in the manifest
267
+ const fetchedDocs: string[] = [];
268
+ let combinedContent = '';
269
+
270
+ for (const doc of manifest.documents) {
271
+ // Resolve the document path
272
+ let docRelativePath = doc.relativePath;
273
+ if (docRelativePath.startsWith('./')) {
274
+ docRelativePath = docRelativePath.slice(2);
275
+ }
276
+
277
+ const docPath = `${basePath}/${docRelativePath}`;
278
+
279
+ try {
280
+ let content: string;
281
+
282
+ if (isLocal) {
283
+ // Use local file system for local packages
284
+ content = await fs.readFile(docPath, 'utf-8');
285
+ } else {
286
+ // Use fetch for remote packages
287
+ const response = await fetch(docPath);
288
+ if (!response.ok) {
289
+ console.warn(`Warning: Failed to fetch ${docPath}: ${response.status}`);
290
+ continue;
291
+ }
292
+ content = await response.text();
293
+ }
294
+
295
+ combinedContent += content;
296
+
297
+ // Save to cache under package name
298
+ const cachePath = path.join(manifest.name, docRelativePath);
299
+ await cache.save(cachePath, content);
300
+
301
+ fetchedDocs.push(docRelativePath);
302
+ } catch (error) {
303
+ console.warn(`Warning: Failed to fetch ${doc.name}: ${error}`);
304
+ }
305
+ }
306
+
307
+ // Save the manifest itself
308
+ const manifestCachePath = path.join(manifest.name, 'package.busy.md');
309
+ await cache.save(manifestCachePath, manifestContent);
310
+
311
+ // Calculate integrity hash of all content
312
+ const integrity = calculateIntegrity(combinedContent);
313
+
314
+ // Add to local package registry
315
+ const registry = new PackageRegistry(workspaceRoot);
316
+ try {
317
+ await registry.load();
318
+ } catch {
319
+ await registry.init();
320
+ await registry.load();
321
+ }
322
+
323
+ // Use absolute path for local sources
324
+ const resolvedSource = isLocal ? parsedManifest.path : manifestUrl;
325
+
326
+ const entry: PackageEntry = {
327
+ id: manifest.name,
328
+ description: manifest.description,
329
+ source: resolvedSource,
330
+ provider: provider.name,
331
+ cached: `.libraries/${manifest.name}`,
332
+ version: manifest.version,
333
+ fetched: new Date().toISOString(),
334
+ integrity,
335
+ category: 'Packages',
336
+ };
337
+
338
+ registry.addPackage(entry);
339
+ await registry.save();
340
+
341
+ return {
342
+ name: manifest.name,
343
+ version: manifest.version,
344
+ description: manifest.description,
345
+ documents: manifest.documents,
346
+ cached: `.libraries/${manifest.name}`,
347
+ integrity,
348
+ };
349
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Main Parser Module - busy-python compatible document parsing
3
+ *
4
+ * This module provides the main entry points for parsing BUSY documents:
5
+ * - parseDocument: Parse a markdown string into a BusyDocument or ToolDocument
6
+ * - resolveImports: Resolve import references in a document
7
+ */
8
+
9
+ import matter from 'gray-matter';
10
+ import { resolve, dirname } from 'path';
11
+ import { readFileSync, existsSync } from 'fs';
12
+ import {
13
+ NewBusyDocument as BusyDocument, // Use new schema types for busy-python compat
14
+ ToolDocument,
15
+ Metadata,
16
+ MetadataSchema,
17
+ } from './types/schema.js';
18
+ import { parseImports } from './parsers/imports.js';
19
+ import { parseOperations } from './parsers/operations.js';
20
+ import { parseTriggers } from './parsers/triggers.js';
21
+ import { parseTools } from './parsers/tools.js';
22
+
23
+ /**
24
+ * Parse local definitions from markdown content
25
+ */
26
+ function parseLocalDefinitions(content: string): Array<{ name: string; content: string }> {
27
+ const definitions: Array<{ name: string; content: string }> = [];
28
+
29
+ // Find Local Definitions section
30
+ const localDefsMatch = content.match(/^#\s*\[?Local\s*Definitions\]?\s*$/im);
31
+
32
+ if (!localDefsMatch) {
33
+ return definitions;
34
+ }
35
+
36
+ // Get content after Local Definitions heading
37
+ const startIndex = localDefsMatch.index! + localDefsMatch[0].length;
38
+ const restContent = content.slice(startIndex);
39
+
40
+ // Find next top-level heading
41
+ const nextH1Match = restContent.match(/\n#\s+[^\#]/);
42
+ const defsContent = nextH1Match
43
+ ? restContent.slice(0, nextH1Match.index)
44
+ : restContent;
45
+
46
+ // Split by ## headings
47
+ const parts = defsContent.split(/\n(?=##\s+)/);
48
+
49
+ for (const part of parts) {
50
+ if (!part.trim()) continue;
51
+
52
+ // Match definition heading: ## DefinitionName
53
+ const headingMatch = part.match(/^##\s+([^\n]+)\s*\n?([\s\S]*)/);
54
+
55
+ if (headingMatch) {
56
+ const name = headingMatch[1].trim();
57
+ const defContent = (headingMatch[2] || '').trim();
58
+
59
+ definitions.push({
60
+ name,
61
+ content: defContent,
62
+ });
63
+ }
64
+ }
65
+
66
+ return definitions;
67
+ }
68
+
69
+ /**
70
+ * Parse setup section from markdown content
71
+ */
72
+ function parseSetup(content: string): string | undefined {
73
+ // Find Setup section
74
+ const setupMatch = content.match(/^#\s*\[?Setup\]?\s*$/im);
75
+
76
+ if (!setupMatch) {
77
+ return undefined;
78
+ }
79
+
80
+ // Get content after Setup heading
81
+ const startIndex = setupMatch.index! + setupMatch[0].length;
82
+ const restContent = content.slice(startIndex);
83
+
84
+ // Find next top-level heading
85
+ const nextH1Match = restContent.match(/\n#\s+[^\#]/);
86
+ const setupContent = nextH1Match
87
+ ? restContent.slice(0, nextH1Match.index)
88
+ : restContent;
89
+
90
+ const trimmed = setupContent.trim();
91
+ return trimmed || undefined;
92
+ }
93
+
94
+ /**
95
+ * Parse metadata from frontmatter
96
+ */
97
+ function parseMetadata(data: Record<string, any>): Metadata {
98
+ // Normalize Type field - YAML might parse [Document] as array
99
+ let type = data.Type;
100
+ if (Array.isArray(type)) {
101
+ type = `[${type.join(', ')}]`;
102
+ } else if (typeof type === 'string' && !type.startsWith('[')) {
103
+ type = `[${type}]`;
104
+ }
105
+
106
+ const metadata: Metadata = {
107
+ name: data.Name || '',
108
+ type: type || '[Document]',
109
+ description: data.Description || '',
110
+ };
111
+
112
+ // Add provider if present (for tool documents)
113
+ if (data.Provider) {
114
+ metadata.provider = data.Provider;
115
+ }
116
+
117
+ // Validate
118
+ const result = MetadataSchema.safeParse(metadata);
119
+ if (!result.success) {
120
+ throw new Error(`Invalid metadata: ${result.error.message}`);
121
+ }
122
+
123
+ return result.data;
124
+ }
125
+
126
+ /**
127
+ * Parse a BUSY markdown document
128
+ *
129
+ * @param content - The markdown content to parse
130
+ * @returns BusyDocument or ToolDocument (if Type is [Tool])
131
+ * @throws Error if frontmatter is missing or invalid
132
+ */
133
+ export function parseDocument(content: string): BusyDocument | ToolDocument {
134
+ // Trim leading whitespace for gray-matter
135
+ const trimmedContent = content.trimStart();
136
+
137
+ // Extract frontmatter - only parse the first block to avoid "multiple documents" error
138
+ // when the body contains --- horizontal rules
139
+ const frontmatterMatch = trimmedContent.match(/^---\n([\s\S]*?)\n---/);
140
+ if (!frontmatterMatch) {
141
+ throw new Error('Missing or empty frontmatter');
142
+ }
143
+ const frontmatterOnly = frontmatterMatch[0];
144
+ const { data } = matter(frontmatterOnly);
145
+
146
+ if (!data || Object.keys(data).length === 0) {
147
+ throw new Error('Missing or empty frontmatter');
148
+ }
149
+
150
+ // Parse metadata
151
+ const metadata = parseMetadata(data);
152
+
153
+ // Parse imports
154
+ const { imports } = parseImports(trimmedContent);
155
+
156
+ // Parse local definitions
157
+ const definitions = parseLocalDefinitions(trimmedContent);
158
+
159
+ // Parse setup
160
+ const setup = parseSetup(trimmedContent);
161
+
162
+ // Parse operations
163
+ const operations = parseOperations(trimmedContent);
164
+
165
+ // Parse triggers
166
+ const triggers = parseTriggers(trimmedContent);
167
+
168
+ // Check if this is a tool document
169
+ const isToolDocument = metadata.type.toLowerCase().includes('tool');
170
+
171
+ if (isToolDocument) {
172
+ // Parse tools section
173
+ const tools = parseTools(trimmedContent);
174
+
175
+ const toolDoc: ToolDocument = {
176
+ metadata,
177
+ imports,
178
+ definitions,
179
+ setup,
180
+ operations,
181
+ triggers,
182
+ tools,
183
+ };
184
+
185
+ return toolDoc;
186
+ }
187
+
188
+ const doc: BusyDocument = {
189
+ metadata,
190
+ imports,
191
+ definitions,
192
+ setup,
193
+ operations,
194
+ triggers,
195
+ };
196
+
197
+ return doc;
198
+ }
199
+
200
+ /**
201
+ * Resolve imports in a document to their parsed documents
202
+ *
203
+ * @param document - The document with imports to resolve
204
+ * @param basePath - Base path for resolving relative imports
205
+ * @param visited - Set of visited paths (for circular import detection)
206
+ * @returns Record mapping concept names to their resolved documents
207
+ * @throws Error if circular import detected or file not found
208
+ */
209
+ export function resolveImports(
210
+ document: BusyDocument | ToolDocument,
211
+ basePath: string,
212
+ visited: Set<string> = new Set()
213
+ ): Record<string, BusyDocument | ToolDocument> {
214
+ const resolved: Record<string, BusyDocument | ToolDocument> = {};
215
+
216
+ for (const imp of document.imports) {
217
+ // Resolve the import path
218
+ const importPath = resolve(dirname(basePath), imp.path);
219
+
220
+ // Check for circular imports
221
+ if (visited.has(importPath)) {
222
+ throw new Error(`Circular import detected: ${importPath}`);
223
+ }
224
+
225
+ // Check if file exists
226
+ if (!existsSync(importPath)) {
227
+ throw new Error(`Import not found: ${imp.path} (resolved to ${importPath})`);
228
+ }
229
+
230
+ // Mark as visited
231
+ visited.add(importPath);
232
+
233
+ try {
234
+ // Read and parse the imported document
235
+ const importContent = readFileSync(importPath, 'utf-8');
236
+ const importedDoc = parseDocument(importContent);
237
+
238
+ // Validate anchor if specified
239
+ if (imp.anchor) {
240
+ // Check if anchor exists in operations or definitions
241
+ const hasOperation = importedDoc.operations.some(
242
+ (op) => op.name.toLowerCase() === imp.anchor!.toLowerCase() ||
243
+ slugify(op.name) === imp.anchor!.toLowerCase()
244
+ );
245
+ const hasDefinition = importedDoc.definitions.some(
246
+ (def) => def.name.toLowerCase() === imp.anchor!.toLowerCase() ||
247
+ slugify(def.name) === imp.anchor!.toLowerCase()
248
+ );
249
+
250
+ if (!hasOperation && !hasDefinition) {
251
+ throw new Error(`Anchor '${imp.anchor}' not found in ${imp.path}`);
252
+ }
253
+ }
254
+
255
+ // Store resolved document
256
+ resolved[imp.conceptName] = importedDoc;
257
+
258
+ // Recursively resolve imports in the imported document
259
+ const nestedResolved = resolveImports(importedDoc, importPath, visited);
260
+ Object.assign(resolved, nestedResolved);
261
+ } finally {
262
+ // Remove from visited after processing (allow same doc in different branches)
263
+ visited.delete(importPath);
264
+ }
265
+ }
266
+
267
+ return resolved;
268
+ }
269
+
270
+ /**
271
+ * Simple slugify function for anchor matching
272
+ */
273
+ function slugify(text: string): string {
274
+ return text
275
+ .toLowerCase()
276
+ .replace(/[^\w\s-]/g, '')
277
+ .replace(/\s+/g, '-');
278
+ }