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,215 @@
1
+ /**
2
+ * Tool Parser - Matches busy-python Tool and ToolDocument models
3
+ *
4
+ * Tools have:
5
+ * - name: string
6
+ * - description: string
7
+ * - inputs: list[str]
8
+ * - outputs: list[str]
9
+ * - examples: Optional[list[str]]
10
+ * - providers: Optional[dict[str, dict[str, Any]]] (provider_name -> {action, parameters})
11
+ */
12
+
13
+ import { Tool } from '../types/schema.js';
14
+
15
+ /**
16
+ * Parse provider mappings from tool content
17
+ *
18
+ * @param content - Content of the tool section
19
+ * @returns Record of provider name to {action, parameters}
20
+ */
21
+ export function parseToolProviders(content: string): Record<string, { action: string; parameters?: Record<string, any> }> {
22
+ const providers: Record<string, { action: string; parameters?: Record<string, any> }> = {};
23
+
24
+ // Find Providers section - get everything after ### Providers or ### [Providers]
25
+ // Or if content starts with #### (direct provider definitions)
26
+ let providersContent: string;
27
+
28
+ const providersMatch = content.match(/###\s*\[?Providers\]?\s*\n([\s\S]*)$/i);
29
+
30
+ if (providersMatch) {
31
+ providersContent = providersMatch[1];
32
+ } else if (content.match(/^####\s+/m)) {
33
+ // Content starts with provider definitions directly
34
+ providersContent = content;
35
+ } else {
36
+ return providers;
37
+ }
38
+
39
+ // Trim at next ### that's not #### (to stop at next H3 section)
40
+ const nextSectionMatch = providersContent.match(/\n###\s+[^\#]/);
41
+ if (nextSectionMatch) {
42
+ providersContent = providersContent.slice(0, nextSectionMatch.index);
43
+ }
44
+
45
+ // Split by #### headings (provider names)
46
+ const parts = providersContent.split(/(?=####\s+)/);
47
+
48
+ for (const part of parts) {
49
+ if (!part.trim()) continue;
50
+
51
+ // Match provider heading: #### providerName
52
+ const providerMatch = part.match(/^####\s+(\w+)\s*\n?([\s\S]*)/);
53
+
54
+ if (providerMatch) {
55
+ const providerName = providerMatch[1];
56
+ const providerContent = providerMatch[2] || '';
57
+
58
+ // Extract Action
59
+ const actionMatch = providerContent.match(/Action:\s*(.+)/i);
60
+ const action = actionMatch ? actionMatch[1].trim() : '';
61
+
62
+ // Extract Parameters
63
+ const paramsMatch = providerContent.match(/Parameters:\s*\n([\s\S]*?)(?=####|Action:|$)/i);
64
+ let parameters: Record<string, any> | undefined;
65
+
66
+ if (paramsMatch) {
67
+ parameters = parseYamlLikeParams(paramsMatch[1]);
68
+ }
69
+
70
+ if (action) {
71
+ providers[providerName] = {
72
+ action,
73
+ parameters: parameters && Object.keys(parameters).length > 0 ? parameters : undefined,
74
+ };
75
+ }
76
+ }
77
+ }
78
+
79
+ return providers;
80
+ }
81
+
82
+ /**
83
+ * Parse YAML-like indented parameters
84
+ */
85
+ function parseYamlLikeParams(content: string): Record<string, any> {
86
+ const params: Record<string, any> = {};
87
+ const lines = content.split('\n');
88
+
89
+ let currentKey = '';
90
+ let currentNested: Record<string, any> | null = null;
91
+
92
+ for (const line of lines) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed) continue;
95
+
96
+ // Check indentation level
97
+ const leadingSpaces = line.match(/^(\s*)/)?.[1].length || 0;
98
+
99
+ // Match key: value pair
100
+ const kvMatch = trimmed.match(/^(\w+):\s*(.*)$/);
101
+
102
+ if (kvMatch) {
103
+ const key = kvMatch[1];
104
+ const value = kvMatch[2].trim();
105
+
106
+ if (leadingSpaces <= 2) {
107
+ // Top-level key
108
+ if (value) {
109
+ params[key] = value;
110
+ } else {
111
+ // Nested object
112
+ currentKey = key;
113
+ currentNested = {};
114
+ params[key] = currentNested;
115
+ }
116
+ } else if (currentNested && currentKey) {
117
+ // Nested key
118
+ currentNested[key] = value;
119
+ }
120
+ }
121
+ }
122
+
123
+ return params;
124
+ }
125
+
126
+ /**
127
+ * Parse bullet list items from a section
128
+ */
129
+ function parseBulletList(content: string, sectionName: string): string[] {
130
+ const pattern = new RegExp(
131
+ `###\\s*\\[?${sectionName}\\]?\\s*\\n([\\s\\S]*?)(?=\\n###|\\n##|$)`,
132
+ 'i'
133
+ );
134
+ const match = content.match(pattern);
135
+
136
+ if (!match) {
137
+ return [];
138
+ }
139
+
140
+ const items: string[] = [];
141
+ const lines = match[1].split('\n');
142
+
143
+ for (const line of lines) {
144
+ const trimmed = line.trim();
145
+ const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
146
+ if (bulletMatch) {
147
+ items.push(bulletMatch[1].trim());
148
+ }
149
+ }
150
+
151
+ return items;
152
+ }
153
+
154
+ /**
155
+ * Parse tools from markdown content
156
+ *
157
+ * @param content - Full markdown document content
158
+ * @returns Array of Tool objects
159
+ */
160
+ export function parseTools(content: string): Tool[] {
161
+ const tools: Tool[] = [];
162
+
163
+ // Find Tools section - handle both with and without brackets
164
+ const toolsMatch = content.match(/^#\s*\[?Tools\]?\s*$/im);
165
+
166
+ if (!toolsMatch) {
167
+ return [];
168
+ }
169
+
170
+ // Get content after Tools heading until next top-level section or end
171
+ const startIndex = toolsMatch.index! + toolsMatch[0].length;
172
+ const restContent = content.slice(startIndex);
173
+
174
+ // Find next top-level heading (# not ##)
175
+ const nextH1Match = restContent.match(/\n#\s+[^\#]/);
176
+ const toolsContent = nextH1Match
177
+ ? restContent.slice(0, nextH1Match.index)
178
+ : restContent;
179
+
180
+ // Split by ## headings to find individual tools
181
+ const parts = toolsContent.split(/\n(?=##\s+)/);
182
+
183
+ for (const part of parts) {
184
+ if (!part.trim()) continue;
185
+
186
+ // Match tool heading: ## tool_name
187
+ const headingMatch = part.match(/^##\s+(\S+)\s*\n([\s\S]*)/);
188
+
189
+ if (headingMatch) {
190
+ const name = headingMatch[1].trim();
191
+ const toolContent = headingMatch[2] || '';
192
+
193
+ // Extract description (first paragraph before any ### section)
194
+ const descMatch = toolContent.match(/^([^#\n][\s\S]*?)(?=\n###|$)/);
195
+ const description = descMatch ? descMatch[1].trim() : '';
196
+
197
+ // Parse sections
198
+ const inputs = parseBulletList(toolContent, 'Inputs');
199
+ const outputs = parseBulletList(toolContent, 'Outputs');
200
+ const examples = parseBulletList(toolContent, 'Examples');
201
+ const providers = parseToolProviders(toolContent);
202
+
203
+ tools.push({
204
+ name,
205
+ description,
206
+ inputs,
207
+ outputs,
208
+ examples: examples.length > 0 ? examples : undefined,
209
+ providers: Object.keys(providers).length > 0 ? providers : undefined,
210
+ });
211
+ }
212
+ }
213
+
214
+ return tools;
215
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Trigger Parser - Matches busy-python trigger parsing
3
+ *
4
+ * Supports two trigger formats:
5
+ * 1. Time-based (alarm): "Set alarm for <time> to run <Operation>"
6
+ * 2. Event-based: "When <event> [from <filter>], run <Operation>"
7
+ *
8
+ * Triggers can appear in:
9
+ * - # [Triggers] section as bullet points
10
+ * - Frontmatter as a Triggers array
11
+ */
12
+
13
+ import { Trigger } from '../types/schema.js';
14
+ import matter from 'gray-matter';
15
+
16
+ // Weekday mapping
17
+ const WEEKDAYS: Record<string, number> = {
18
+ sunday: 0,
19
+ monday: 1,
20
+ tuesday: 2,
21
+ wednesday: 3,
22
+ thursday: 4,
23
+ friday: 5,
24
+ saturday: 6,
25
+ sun: 0,
26
+ mon: 1,
27
+ tue: 2,
28
+ wed: 3,
29
+ thu: 4,
30
+ fri: 5,
31
+ sat: 6,
32
+ };
33
+
34
+ /**
35
+ * Parse a time specification into a cron expression
36
+ *
37
+ * @param timeSpec - Time string like "6am", "3pm each day", "9am on Monday"
38
+ * @returns Cron expression string
39
+ */
40
+ export function parseTimeSpec(timeSpec: string): string {
41
+ const spec = timeSpec.toLowerCase().trim();
42
+
43
+ // Extract hour and AM/PM
44
+ const timeMatch = spec.match(/(\d{1,2})\s*(am|pm)/i);
45
+ if (!timeMatch) {
46
+ return '* * * * *'; // Default: every minute (invalid spec)
47
+ }
48
+
49
+ let hour = parseInt(timeMatch[1], 10);
50
+ const isPM = timeMatch[2].toLowerCase() === 'pm';
51
+
52
+ // Convert to 24-hour format
53
+ if (isPM && hour !== 12) {
54
+ hour += 12;
55
+ } else if (!isPM && hour === 12) {
56
+ hour = 0;
57
+ }
58
+
59
+ // Check for day specifications
60
+ const dayOfWeek = parseDayOfWeek(spec);
61
+
62
+ return `0 ${hour} * * ${dayOfWeek}`;
63
+ }
64
+
65
+ /**
66
+ * Parse day of week specification from time string
67
+ */
68
+ function parseDayOfWeek(spec: string): string {
69
+ // Check for "weekdays"
70
+ if (spec.includes('weekdays')) {
71
+ return '1-5';
72
+ }
73
+
74
+ // Check for "weekends"
75
+ if (spec.includes('weekends')) {
76
+ return '0,6';
77
+ }
78
+
79
+ // Check for specific days
80
+ const days: number[] = [];
81
+
82
+ for (const [dayName, dayNum] of Object.entries(WEEKDAYS)) {
83
+ // Match full day name or abbreviation
84
+ const pattern = new RegExp(`\\b${dayName}\\b`, 'i');
85
+ if (pattern.test(spec)) {
86
+ if (!days.includes(dayNum)) {
87
+ days.push(dayNum);
88
+ }
89
+ }
90
+ }
91
+
92
+ if (days.length > 0) {
93
+ return days.sort((a, b) => a - b).join(',');
94
+ }
95
+
96
+ // Default: every day
97
+ return '*';
98
+ }
99
+
100
+ /**
101
+ * Parse a trigger declaration text into a Trigger object
102
+ *
103
+ * @param text - The trigger declaration text
104
+ * @returns Trigger object
105
+ */
106
+ export function parseTriggerDeclaration(text: string): Trigger {
107
+ const rawText = text.trim();
108
+
109
+ // Try to match alarm pattern: "Set alarm for <time> to run <Operation>"
110
+ const alarmMatch = rawText.match(/set\s+alarm\s+for\s+(.+?)\s+to\s+run\s+(\w+)/i);
111
+
112
+ if (alarmMatch) {
113
+ const timeSpec = alarmMatch[1];
114
+ const operation = alarmMatch[2];
115
+ const schedule = parseTimeSpec(timeSpec);
116
+
117
+ return {
118
+ rawText,
119
+ triggerType: 'alarm',
120
+ schedule,
121
+ operation,
122
+ queueWhenPaused: true,
123
+ };
124
+ }
125
+
126
+ // Try to match event pattern: "When <event> [from <filter>], run <Operation>"
127
+ const eventMatch = rawText.match(/when\s+([\w.]+)(?:\s+from\s+(.+?))?,\s*run\s+(\w+)/i);
128
+
129
+ if (eventMatch) {
130
+ const eventType = eventMatch[1];
131
+ const filterSpec = eventMatch[2];
132
+ const operation = eventMatch[3];
133
+
134
+ let filter: Record<string, string> | undefined;
135
+ if (filterSpec) {
136
+ // Treat filter as email pattern by default
137
+ filter = { from: filterSpec.trim() };
138
+ }
139
+
140
+ return {
141
+ rawText,
142
+ triggerType: 'event',
143
+ eventType,
144
+ filter,
145
+ operation,
146
+ queueWhenPaused: true,
147
+ };
148
+ }
149
+
150
+ // Fallback: couldn't parse, return minimal trigger
151
+ return {
152
+ rawText,
153
+ triggerType: 'event',
154
+ operation: 'Unknown',
155
+ queueWhenPaused: true,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Parse triggers from frontmatter data
161
+ */
162
+ function parseFrontmatterTriggers(data: Record<string, any>): Trigger[] {
163
+ const triggers: Trigger[] = [];
164
+
165
+ if (!data?.Triggers || !Array.isArray(data.Triggers)) {
166
+ return [];
167
+ }
168
+
169
+ for (const item of data.Triggers) {
170
+ if (typeof item === 'object' && item !== null) {
171
+ // Handle structured trigger from frontmatter
172
+ if (item.schedule) {
173
+ // Alarm trigger with explicit cron
174
+ triggers.push({
175
+ rawText: JSON.stringify(item),
176
+ triggerType: 'alarm',
177
+ schedule: item.schedule,
178
+ operation: item.operation || 'Unknown',
179
+ queueWhenPaused: item.queue_when_paused ?? true,
180
+ });
181
+ } else if (item.event_type) {
182
+ // Event trigger
183
+ triggers.push({
184
+ rawText: JSON.stringify(item),
185
+ triggerType: 'event',
186
+ eventType: item.event_type,
187
+ filter: item.filter,
188
+ operation: item.operation || 'Unknown',
189
+ queueWhenPaused: item.queue_when_paused ?? true,
190
+ });
191
+ }
192
+ }
193
+ }
194
+
195
+ return triggers;
196
+ }
197
+
198
+ /**
199
+ * Parse triggers from markdown content
200
+ * Combines triggers from both Triggers section and frontmatter
201
+ *
202
+ * @param content - Full markdown document content
203
+ * @returns Array of Trigger objects
204
+ */
205
+ export function parseTriggers(content: string): Trigger[] {
206
+ const triggers: Trigger[] = [];
207
+
208
+ // Extract only first frontmatter block to avoid "multiple documents" error
209
+ const trimmedContent = content.trimStart();
210
+ const frontmatterMatch = trimmedContent.match(/^---\n([\s\S]*?)\n---/);
211
+ if (frontmatterMatch) {
212
+ try {
213
+ const { data } = matter(frontmatterMatch[0]);
214
+ if (data && typeof data === 'object') {
215
+ const frontmatterTriggers = parseFrontmatterTriggers(data);
216
+ triggers.push(...frontmatterTriggers);
217
+ }
218
+ } catch (e) {
219
+ // Frontmatter parsing error, continue without frontmatter triggers
220
+ }
221
+ }
222
+
223
+ // Find Triggers section
224
+ const triggersMatch = content.match(/^#\s*\[?Triggers\]?\s*$/im);
225
+
226
+ if (triggersMatch) {
227
+ // Get content after Triggers heading
228
+ const startIndex = triggersMatch.index! + triggersMatch[0].length;
229
+ const restContent = content.slice(startIndex);
230
+
231
+ // Find next top-level heading
232
+ const nextH1Match = restContent.match(/\n#\s+[^\#]/);
233
+ const triggersContent = nextH1Match
234
+ ? restContent.slice(0, nextH1Match.index)
235
+ : restContent;
236
+
237
+ // Parse bullet items
238
+ const lines = triggersContent.split('\n');
239
+
240
+ for (const line of lines) {
241
+ const trimmed = line.trim();
242
+ const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
243
+
244
+ if (bulletMatch) {
245
+ const trigger = parseTriggerDeclaration(bulletMatch[1]);
246
+ triggers.push(trigger);
247
+ }
248
+ }
249
+ }
250
+
251
+ return triggers;
252
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Provider system for URL handling
3
+ *
4
+ * Providers handle fetching content from different URL sources (GitHub, GitLab, generic URLs).
5
+ */
6
+
7
+ export interface ParsedURL {
8
+ provider: string;
9
+ org?: string;
10
+ repo?: string;
11
+ ref?: string;
12
+ path: string;
13
+ anchor?: string;
14
+ rawUrl?: string;
15
+ }
16
+
17
+ export interface Provider {
18
+ name: string;
19
+
20
+ /**
21
+ * Check if this provider handles the given URL
22
+ */
23
+ matches(url: string): boolean;
24
+
25
+ /**
26
+ * Parse URL into components
27
+ */
28
+ parse(url: string): ParsedURL;
29
+
30
+ /**
31
+ * Convert parsed URL to raw content URL
32
+ */
33
+ getRawUrl(parsed: ParsedURL): string;
34
+
35
+ /**
36
+ * Fetch content from URL
37
+ */
38
+ fetch(url: string): Promise<string>;
39
+
40
+ /**
41
+ * Get the latest version/tag for a repo (optional)
42
+ */
43
+ getLatestVersion?(parsed: ParsedURL): Promise<string>;
44
+ }
45
+
46
+ /**
47
+ * Provider registry - manages all URL providers
48
+ */
49
+ export class ProviderRegistry {
50
+ private providers: Provider[] = [];
51
+
52
+ /**
53
+ * Register a provider
54
+ */
55
+ register(provider: Provider): void {
56
+ this.providers.push(provider);
57
+ }
58
+
59
+ /**
60
+ * Find provider that handles a URL
61
+ */
62
+ findProvider(url: string): Provider | undefined {
63
+ return this.providers.find(p => p.matches(url));
64
+ }
65
+
66
+ /**
67
+ * Get all registered providers
68
+ */
69
+ getProviders(): Provider[] {
70
+ return [...this.providers];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Global provider registry instance
76
+ */
77
+ export const providerRegistry = new ProviderRegistry();
@@ -0,0 +1,129 @@
1
+ /**
2
+ * GitHub URL provider
3
+ *
4
+ * Handles URLs like:
5
+ * - https://github.com/org/repo/blob/ref/path/to/file.md
6
+ * - https://github.com/org/repo/tree/ref/path/to/dir
7
+ */
8
+
9
+ import { Provider, ParsedURL, providerRegistry } from './base.js';
10
+
11
+ // Pattern for GitHub blob URLs (single file)
12
+ const BLOB_PATTERN = /github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)/;
13
+
14
+ // Pattern for GitHub tree URLs (directory)
15
+ const TREE_PATTERN = /github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/;
16
+
17
+ // Pattern for raw GitHub URLs
18
+ const RAW_PATTERN = /raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)/;
19
+
20
+ export class GitHubProvider implements Provider {
21
+ name = 'github';
22
+
23
+ matches(url: string): boolean {
24
+ // Use proper URL parsing to check the hostname
25
+ try {
26
+ const parsed = new URL(url);
27
+ return parsed.hostname === 'github.com' || parsed.hostname === 'raw.githubusercontent.com';
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ parse(url: string): ParsedURL {
34
+ // Extract anchor if present
35
+ const [urlWithoutAnchor, anchor] = url.split('#');
36
+
37
+ // Try blob pattern first
38
+ let match = urlWithoutAnchor.match(BLOB_PATTERN);
39
+ if (match) {
40
+ return {
41
+ provider: 'github',
42
+ org: match[1],
43
+ repo: match[2],
44
+ ref: match[3],
45
+ path: match[4],
46
+ anchor,
47
+ };
48
+ }
49
+
50
+ // Try tree pattern (directory)
51
+ match = urlWithoutAnchor.match(TREE_PATTERN);
52
+ if (match) {
53
+ return {
54
+ provider: 'github',
55
+ org: match[1],
56
+ repo: match[2],
57
+ ref: match[3],
58
+ path: match[4],
59
+ anchor,
60
+ };
61
+ }
62
+
63
+ // Try raw URL pattern
64
+ match = urlWithoutAnchor.match(RAW_PATTERN);
65
+ if (match) {
66
+ return {
67
+ provider: 'github',
68
+ org: match[1],
69
+ repo: match[2],
70
+ ref: match[3],
71
+ path: match[4],
72
+ anchor,
73
+ rawUrl: urlWithoutAnchor,
74
+ };
75
+ }
76
+
77
+ throw new Error(`Cannot parse GitHub URL: ${url}`);
78
+ }
79
+
80
+ getRawUrl(parsed: ParsedURL): string {
81
+ if (parsed.rawUrl) {
82
+ return parsed.rawUrl;
83
+ }
84
+ return `https://raw.githubusercontent.com/${parsed.org}/${parsed.repo}/${parsed.ref}/${parsed.path}`;
85
+ }
86
+
87
+ async fetch(url: string): Promise<string> {
88
+ const parsed = this.parse(url);
89
+ const rawUrl = this.getRawUrl(parsed);
90
+
91
+ const response = await fetch(rawUrl);
92
+ if (!response.ok) {
93
+ throw new Error(`Failed to fetch ${rawUrl}: ${response.status} ${response.statusText}`);
94
+ }
95
+
96
+ return response.text();
97
+ }
98
+
99
+ async getLatestVersion(parsed: ParsedURL): Promise<string> {
100
+ const apiUrl = `https://api.github.com/repos/${parsed.org}/${parsed.repo}/tags`;
101
+
102
+ const response = await fetch(apiUrl, {
103
+ headers: {
104
+ Accept: 'application/vnd.github.v3+json',
105
+ // Add User-Agent header as required by GitHub API
106
+ 'User-Agent': 'busy-parser',
107
+ },
108
+ });
109
+
110
+ if (!response.ok) {
111
+ throw new Error(`Failed to fetch tags from GitHub API: ${response.status}`);
112
+ }
113
+
114
+ const tags = await response.json() as Array<{ name: string }>;
115
+
116
+ if (tags.length === 0) {
117
+ throw new Error(`No tags found for ${parsed.org}/${parsed.repo}`);
118
+ }
119
+
120
+ // Return the first (latest) tag
121
+ // Note: GitHub API returns tags in reverse chronological order for most repos
122
+ // For proper semver sorting, we'd need to parse and sort the tags
123
+ return tags[0].name;
124
+ }
125
+ }
126
+
127
+ // Create and register the GitHub provider
128
+ export const githubProvider = new GitHubProvider();
129
+ providerRegistry.register(githubProvider);