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.
- package/README.md +129 -0
- package/dist/builders/context.d.ts +50 -0
- package/dist/builders/context.d.ts.map +1 -0
- package/dist/builders/context.js +190 -0
- package/dist/cache/index.d.ts +100 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +270 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +463 -0
- package/dist/commands/package.d.ts +96 -0
- package/dist/commands/package.d.ts.map +1 -0
- package/dist/commands/package.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/loader.d.ts +6 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +361 -0
- package/dist/merge.d.ts +16 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +102 -0
- package/dist/package/manifest.d.ts +59 -0
- package/dist/package/manifest.d.ts.map +1 -0
- package/dist/package/manifest.js +265 -0
- package/dist/parser.d.ts +28 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +220 -0
- package/dist/parsers/frontmatter.d.ts +14 -0
- package/dist/parsers/frontmatter.d.ts.map +1 -0
- package/dist/parsers/frontmatter.js +110 -0
- package/dist/parsers/imports.d.ts +48 -0
- package/dist/parsers/imports.d.ts.map +1 -0
- package/dist/parsers/imports.js +147 -0
- package/dist/parsers/links.d.ts +12 -0
- package/dist/parsers/links.d.ts.map +1 -0
- package/dist/parsers/links.js +79 -0
- package/dist/parsers/localdefs.d.ts +6 -0
- package/dist/parsers/localdefs.d.ts.map +1 -0
- package/dist/parsers/localdefs.js +132 -0
- package/dist/parsers/operations.d.ts +32 -0
- package/dist/parsers/operations.d.ts.map +1 -0
- package/dist/parsers/operations.js +313 -0
- package/dist/parsers/sections.d.ts +15 -0
- package/dist/parsers/sections.d.ts.map +1 -0
- package/dist/parsers/sections.js +173 -0
- package/dist/parsers/tools.d.ts +30 -0
- package/dist/parsers/tools.d.ts.map +1 -0
- package/dist/parsers/tools.js +178 -0
- package/dist/parsers/triggers.d.ts +35 -0
- package/dist/parsers/triggers.d.ts.map +1 -0
- package/dist/parsers/triggers.js +219 -0
- package/dist/providers/base.d.ts +60 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +34 -0
- package/dist/providers/github.d.ts +18 -0
- package/dist/providers/github.d.ts.map +1 -0
- package/dist/providers/github.js +109 -0
- package/dist/providers/gitlab.d.ts +18 -0
- package/dist/providers/gitlab.d.ts.map +1 -0
- package/dist/providers/gitlab.js +101 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +17 -0
- package/dist/providers/local.d.ts +31 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +116 -0
- package/dist/providers/url.d.ts +16 -0
- package/dist/providers/url.d.ts.map +1 -0
- package/dist/providers/url.js +45 -0
- package/dist/registry/index.d.ts +99 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +320 -0
- package/dist/types/schema.d.ts +3259 -0
- package/dist/types/schema.d.ts.map +1 -0
- package/dist/types/schema.js +258 -0
- package/dist/utils/logger.d.ts +19 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +23 -0
- package/dist/utils/slugify.d.ts +14 -0
- package/dist/utils/slugify.d.ts.map +1 -0
- package/dist/utils/slugify.js +28 -0
- package/package.json +61 -0
- package/src/__tests__/cache.test.ts +393 -0
- package/src/__tests__/cli-package.test.ts +667 -0
- package/src/__tests__/fixtures/automated-workflow.busy.md +84 -0
- package/src/__tests__/fixtures/concept.busy.md +30 -0
- package/src/__tests__/fixtures/document.busy.md +44 -0
- package/src/__tests__/fixtures/simple-operation.busy.md +45 -0
- package/src/__tests__/fixtures/tool-document.busy.md +71 -0
- package/src/__tests__/fixtures/tool.busy.md +54 -0
- package/src/__tests__/imports.test.ts +244 -0
- package/src/__tests__/integration.test.ts +432 -0
- package/src/__tests__/operations.test.ts +408 -0
- package/src/__tests__/package-manifest.test.ts +455 -0
- package/src/__tests__/providers.test.ts +672 -0
- package/src/__tests__/registry.test.ts +402 -0
- package/src/__tests__/schema.test.ts +467 -0
- package/src/__tests__/tools.test.ts +376 -0
- package/src/__tests__/triggers.test.ts +312 -0
- package/src/builders/context.ts +294 -0
- package/src/cache/index.ts +312 -0
- package/src/cli/index.ts +514 -0
- package/src/commands/package.ts +392 -0
- package/src/index.ts +46 -0
- package/src/loader.ts +474 -0
- package/src/merge.ts +126 -0
- package/src/package/manifest.ts +349 -0
- package/src/parser.ts +278 -0
- package/src/parsers/frontmatter.ts +135 -0
- package/src/parsers/imports.ts +196 -0
- package/src/parsers/links.ts +108 -0
- package/src/parsers/localdefs.ts +166 -0
- package/src/parsers/operations.ts +404 -0
- package/src/parsers/sections.ts +230 -0
- package/src/parsers/tools.ts +215 -0
- package/src/parsers/triggers.ts +252 -0
- package/src/providers/base.ts +77 -0
- package/src/providers/github.ts +129 -0
- package/src/providers/gitlab.ts +121 -0
- package/src/providers/index.ts +25 -0
- package/src/providers/local.ts +129 -0
- package/src/providers/url.ts +56 -0
- package/src/registry/index.ts +408 -0
- package/src/types/schema.ts +369 -0
- package/src/utils/logger.ts +25 -0
- package/src/utils/slugify.ts +31 -0
- 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
|
+
}
|