@oh-my-pi/pi-coding-agent 3.24.0 → 3.30.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 (97) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +4 -4
  3. package/src/core/custom-commands/bundled/wt/index.ts +3 -0
  4. package/src/core/sdk.ts +7 -0
  5. package/src/core/tools/complete.ts +129 -0
  6. package/src/core/tools/index.test.ts +9 -1
  7. package/src/core/tools/index.ts +18 -5
  8. package/src/core/tools/jtd-to-json-schema.ts +252 -0
  9. package/src/core/tools/output.ts +125 -14
  10. package/src/core/tools/read.ts +4 -4
  11. package/src/core/tools/task/artifacts.ts +6 -9
  12. package/src/core/tools/task/executor.ts +189 -24
  13. package/src/core/tools/task/index.ts +23 -18
  14. package/src/core/tools/task/name-generator.ts +1577 -0
  15. package/src/core/tools/task/render.ts +137 -8
  16. package/src/core/tools/task/types.ts +26 -5
  17. package/src/core/tools/task/worker-protocol.ts +1 -0
  18. package/src/core/tools/task/worker.ts +136 -14
  19. package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
  20. package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
  21. package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
  22. package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
  23. package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
  24. package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
  25. package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
  26. package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
  27. package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
  28. package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
  29. package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
  30. package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
  31. package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
  32. package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
  33. package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
  34. package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
  35. package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
  36. package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
  37. package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
  38. package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
  39. package/src/core/tools/web-fetch-handlers/github.ts +424 -0
  40. package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
  41. package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
  42. package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
  43. package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
  44. package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
  45. package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
  46. package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
  47. package/src/core/tools/web-fetch-handlers/index.ts +69 -0
  48. package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
  49. package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
  50. package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
  51. package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
  52. package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
  53. package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
  54. package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
  55. package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
  56. package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
  57. package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
  58. package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
  59. package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
  60. package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
  61. package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
  62. package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
  63. package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
  64. package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
  65. package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
  66. package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
  67. package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
  68. package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
  69. package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
  70. package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
  71. package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
  72. package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
  73. package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
  74. package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
  75. package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
  76. package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
  77. package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
  78. package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
  79. package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
  80. package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
  81. package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
  82. package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
  83. package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
  84. package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
  85. package/src/core/tools/web-fetch-handlers/types.ts +163 -0
  86. package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
  87. package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
  88. package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
  89. package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
  90. package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
  91. package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
  92. package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
  93. package/src/core/tools/web-fetch.ts +152 -1324
  94. package/src/prompts/task.md +14 -50
  95. package/src/prompts/tools/output.md +2 -1
  96. package/src/prompts/tools/task.md +3 -1
  97. package/src/utils/tools-manager.ts +110 -8
@@ -0,0 +1,136 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface BiorxivPaper {
5
+ biorxiv_doi?: string;
6
+ medrxiv_doi?: string;
7
+ title?: string;
8
+ authors?: string;
9
+ author_corresponding?: string;
10
+ author_corresponding_institution?: string;
11
+ abstract?: string;
12
+ date?: string;
13
+ category?: string;
14
+ version?: string;
15
+ type?: string;
16
+ license?: string;
17
+ jatsxml?: string;
18
+ published?: string; // Journal DOI if published
19
+ server?: string;
20
+ }
21
+
22
+ interface BiorxivResponse {
23
+ collection?: BiorxivPaper[];
24
+ messages?: { status: string; count: number }[];
25
+ }
26
+
27
+ /**
28
+ * Handle bioRxiv and medRxiv preprint URLs via their API
29
+ */
30
+ export const handleBiorxiv: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
31
+ try {
32
+ const parsed = new URL(url);
33
+ const hostname = parsed.hostname.toLowerCase();
34
+
35
+ // Check if it's bioRxiv or medRxiv
36
+ const isBiorxiv = hostname === "www.biorxiv.org" || hostname === "biorxiv.org";
37
+ const isMedrxiv = hostname === "www.medrxiv.org" || hostname === "medrxiv.org";
38
+
39
+ if (!isBiorxiv && !isMedrxiv) return null;
40
+
41
+ // Extract DOI from URL path: /content/10.1101/2024.01.01.123456
42
+ const match = parsed.pathname.match(/\/content\/(10\.\d{4,}\/[^\s?#]+)/);
43
+ if (!match) return null;
44
+
45
+ let doi = match[1];
46
+ // Remove version suffix if present (e.g., v1, v2)
47
+ doi = doi.replace(/v\d+$/, "");
48
+ // Remove trailing .full or .full.pdf
49
+ doi = doi.replace(/\.full(\.pdf)?$/, "");
50
+
51
+ const server = isBiorxiv ? "biorxiv" : "medrxiv";
52
+ const apiUrl = `https://api.${server}.org/details/${server}/${doi}/na/json`;
53
+
54
+ const result = await loadPage(apiUrl, {
55
+ timeout,
56
+ headers: { Accept: "application/json" },
57
+ });
58
+
59
+ if (!result.ok) return null;
60
+
61
+ let data: BiorxivResponse;
62
+ try {
63
+ data = JSON.parse(result.content);
64
+ } catch {
65
+ return null;
66
+ }
67
+
68
+ if (!data.collection || data.collection.length === 0) return null;
69
+
70
+ // Get the latest version (last in array)
71
+ const paper = data.collection[data.collection.length - 1];
72
+ if (!paper) return null;
73
+
74
+ const serverName = isBiorxiv ? "bioRxiv" : "medRxiv";
75
+ const paperDoi = paper.biorxiv_doi || paper.medrxiv_doi || doi;
76
+
77
+ // Build markdown output
78
+ let md = `# ${paper.title || "Untitled Preprint"}\n\n`;
79
+
80
+ // Metadata section
81
+ if (paper.authors) {
82
+ md += `**Authors:** ${paper.authors}\n`;
83
+ }
84
+ if (paper.author_corresponding) {
85
+ let correspondingLine = `**Corresponding Author:** ${paper.author_corresponding}`;
86
+ if (paper.author_corresponding_institution) {
87
+ correspondingLine += ` (${paper.author_corresponding_institution})`;
88
+ }
89
+ md += `${correspondingLine}\n`;
90
+ }
91
+ if (paper.date) {
92
+ md += `**Posted:** ${paper.date}\n`;
93
+ }
94
+ if (paper.category) {
95
+ md += `**Category:** ${paper.category}\n`;
96
+ }
97
+ if (paper.version) {
98
+ md += `**Version:** ${paper.version}\n`;
99
+ }
100
+ if (paper.license) {
101
+ md += `**License:** ${paper.license}\n`;
102
+ }
103
+ md += `**DOI:** [${paperDoi}](https://doi.org/${paperDoi})\n`;
104
+ md += `**Server:** ${serverName}\n`;
105
+
106
+ // Published status
107
+ if (paper.published) {
108
+ md += `\n> **Published in journal:** [${paper.published}](https://doi.org/${paper.published})\n`;
109
+ }
110
+
111
+ // Abstract
112
+ md += `\n---\n\n## Abstract\n\n${paper.abstract || "No abstract available."}\n`;
113
+
114
+ // Links section
115
+ md += `\n---\n\n## Links\n\n`;
116
+ md += `- [View on ${serverName}](https://www.${server}.org/content/${paperDoi})\n`;
117
+ md += `- [PDF](https://www.${server}.org/content/${paperDoi}.full.pdf)\n`;
118
+ if (paper.jatsxml) {
119
+ md += `- [JATS XML](${paper.jatsxml})\n`;
120
+ }
121
+
122
+ const output = finalizeOutput(md);
123
+ return {
124
+ url,
125
+ finalUrl: url,
126
+ contentType: "text/markdown",
127
+ method: server,
128
+ content: output.content,
129
+ fetchedAt: new Date().toISOString(),
130
+ truncated: output.truncated,
131
+ notes: [`Fetched via ${serverName} API`],
132
+ };
133
+ } catch {}
134
+
135
+ return null;
136
+ };
@@ -0,0 +1,277 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ const API_BASE = "https://public.api.bsky.app/xrpc";
5
+
6
+ interface BlueskyProfile {
7
+ did: string;
8
+ handle: string;
9
+ displayName?: string;
10
+ description?: string;
11
+ avatar?: string;
12
+ followersCount?: number;
13
+ followsCount?: number;
14
+ postsCount?: number;
15
+ createdAt?: string;
16
+ }
17
+
18
+ interface BlueskyPost {
19
+ uri: string;
20
+ cid: string;
21
+ author: BlueskyProfile;
22
+ record: {
23
+ text: string;
24
+ createdAt: string;
25
+ embed?: {
26
+ $type: string;
27
+ external?: { uri: string; title?: string; description?: string };
28
+ images?: Array<{ alt?: string; image: unknown }>;
29
+ record?: { uri: string };
30
+ };
31
+ facets?: Array<{
32
+ features: Array<{ $type: string; uri?: string; tag?: string; did?: string }>;
33
+ index: { byteStart: number; byteEnd: number };
34
+ }>;
35
+ };
36
+ likeCount?: number;
37
+ repostCount?: number;
38
+ replyCount?: number;
39
+ quoteCount?: number;
40
+ embed?: {
41
+ $type: string;
42
+ external?: { uri: string; title?: string; description?: string };
43
+ images?: Array<{ alt?: string; fullsize?: string; thumb?: string }>;
44
+ record?: { uri: string; value?: { text?: string }; author?: BlueskyProfile };
45
+ };
46
+ }
47
+
48
+ interface ThreadViewPost {
49
+ post: BlueskyPost;
50
+ parent?: ThreadViewPost | { $type: string };
51
+ replies?: Array<ThreadViewPost | { $type: string }>;
52
+ }
53
+
54
+ /**
55
+ * Resolve a handle to DID using the profile API
56
+ */
57
+ async function resolveHandle(handle: string, timeout: number): Promise<string | null> {
58
+ const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`;
59
+ const result = await loadPage(url, {
60
+ timeout,
61
+ headers: { Accept: "application/json" },
62
+ });
63
+
64
+ if (!result.ok) return null;
65
+
66
+ try {
67
+ const data = JSON.parse(result.content) as BlueskyProfile;
68
+ return data.did;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Format a post as markdown
76
+ */
77
+ function formatPost(post: BlueskyPost, isQuote = false): string {
78
+ const author = post.author;
79
+ const name = author.displayName || author.handle;
80
+ const handle = `@${author.handle}`;
81
+ const date = new Date(post.record.createdAt).toLocaleString("en-US", {
82
+ year: "numeric",
83
+ month: "short",
84
+ day: "numeric",
85
+ hour: "2-digit",
86
+ minute: "2-digit",
87
+ });
88
+
89
+ let md = "";
90
+
91
+ if (isQuote) {
92
+ md += `> **${name}** (${handle}) - ${date}\n>\n`;
93
+ md += post.record.text
94
+ .split("\n")
95
+ .map((line) => `> ${line}`)
96
+ .join("\n");
97
+ md += "\n";
98
+ } else {
99
+ md += `**${name}** (${handle})\n`;
100
+ md += `*${date}*\n\n`;
101
+ md += `${post.record.text}\n`;
102
+ }
103
+
104
+ // Handle embeds
105
+ const embed = post.embed;
106
+ if (embed) {
107
+ if (embed.$type === "app.bsky.embed.external#view" && embed.external) {
108
+ const ext = embed.external;
109
+ md += `\n📎 [${ext.title || ext.uri}](${ext.uri})`;
110
+ if (ext.description) md += `\n*${ext.description}*`;
111
+ md += "\n";
112
+ } else if (embed.$type === "app.bsky.embed.images#view" && embed.images) {
113
+ md += `\n🖼️ ${embed.images.length} image(s)`;
114
+ for (const img of embed.images) {
115
+ if (img.alt) md += `\n- Alt: "${img.alt}"`;
116
+ }
117
+ md += "\n";
118
+ } else if (
119
+ (embed.$type === "app.bsky.embed.record#view" || embed.$type === "app.bsky.embed.recordWithMedia#view") &&
120
+ embed.record
121
+ ) {
122
+ const rec = embed.record;
123
+ if (rec.value?.text && rec.author) {
124
+ md += "\n**Quoted post:**\n";
125
+ md += `> **${rec.author.displayName || rec.author.handle}** (@${rec.author.handle})\n`;
126
+ md += rec.value.text
127
+ .split("\n")
128
+ .map((line) => `> ${line}`)
129
+ .join("\n");
130
+ md += "\n";
131
+ }
132
+ }
133
+ }
134
+
135
+ // Stats
136
+ if (!isQuote) {
137
+ const stats: string[] = [];
138
+ if (post.likeCount) stats.push(`❤️ ${formatCount(post.likeCount)}`);
139
+ if (post.repostCount) stats.push(`🔁 ${formatCount(post.repostCount)}`);
140
+ if (post.replyCount) stats.push(`💬 ${formatCount(post.replyCount)}`);
141
+ if (post.quoteCount) stats.push(`📝 ${formatCount(post.quoteCount)}`);
142
+ if (stats.length) md += `\n${stats.join(" • ")}\n`;
143
+ }
144
+
145
+ return md;
146
+ }
147
+
148
+ /**
149
+ * Handle Bluesky post URLs
150
+ */
151
+ export const handleBluesky: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
152
+ try {
153
+ const parsed = new URL(url);
154
+ if (!["bsky.app", "www.bsky.app"].includes(parsed.hostname)) {
155
+ return null;
156
+ }
157
+
158
+ const fetchedAt = new Date().toISOString();
159
+ const pathParts = parsed.pathname.split("/").filter(Boolean);
160
+
161
+ // /profile/{handle}
162
+ if (pathParts[0] === "profile" && pathParts[1]) {
163
+ const handle = pathParts[1];
164
+
165
+ // /profile/{handle}/post/{rkey}
166
+ if (pathParts[2] === "post" && pathParts[3]) {
167
+ const rkey = pathParts[3];
168
+
169
+ // First resolve handle to DID
170
+ const did = await resolveHandle(handle, timeout);
171
+ if (!did) return null;
172
+
173
+ // Construct AT URI and fetch thread
174
+ const atUri = `at://${did}/app.bsky.feed.post/${rkey}`;
175
+ const threadUrl = `${API_BASE}/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}&depth=6&parentHeight=3`;
176
+
177
+ const result = await loadPage(threadUrl, {
178
+ timeout,
179
+ headers: { Accept: "application/json" },
180
+ });
181
+
182
+ if (!result.ok) return null;
183
+
184
+ const data = JSON.parse(result.content) as { thread: ThreadViewPost };
185
+ const thread = data.thread;
186
+
187
+ if (!thread.post) return null;
188
+
189
+ let md = `# Bluesky Post\n\n`;
190
+
191
+ // Show parent context if exists
192
+ if (thread.parent && "post" in thread.parent) {
193
+ md += "**Replying to:**\n";
194
+ md += formatPost(thread.parent.post, true);
195
+ md += "\n---\n\n";
196
+ }
197
+
198
+ // Main post
199
+ md += formatPost(thread.post);
200
+
201
+ // Show replies
202
+ if (thread.replies?.length) {
203
+ md += "\n---\n\n## Replies\n\n";
204
+ let replyCount = 0;
205
+ for (const reply of thread.replies) {
206
+ if (replyCount >= 10) break;
207
+ if ("post" in reply) {
208
+ md += formatPost(reply.post);
209
+ md += "\n---\n\n";
210
+ replyCount++;
211
+ }
212
+ }
213
+ }
214
+
215
+ const output = finalizeOutput(md);
216
+ return {
217
+ url,
218
+ finalUrl: url,
219
+ contentType: "text/markdown",
220
+ method: "bluesky-api",
221
+ content: output.content,
222
+ fetchedAt,
223
+ truncated: output.truncated,
224
+ notes: [`AT URI: ${atUri}`],
225
+ };
226
+ }
227
+
228
+ // Profile only
229
+ const profileUrl = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`;
230
+ const result = await loadPage(profileUrl, {
231
+ timeout,
232
+ headers: { Accept: "application/json" },
233
+ });
234
+
235
+ if (!result.ok) return null;
236
+
237
+ const profile = JSON.parse(result.content) as BlueskyProfile;
238
+
239
+ let md = `# ${profile.displayName || profile.handle}\n\n`;
240
+ md += `**@${profile.handle}**\n\n`;
241
+
242
+ if (profile.description) {
243
+ md += `${profile.description}\n\n`;
244
+ }
245
+
246
+ md += "---\n\n";
247
+ md += `- **Followers:** ${formatCount(profile.followersCount || 0)}\n`;
248
+ md += `- **Following:** ${formatCount(profile.followsCount || 0)}\n`;
249
+ md += `- **Posts:** ${formatCount(profile.postsCount || 0)}\n`;
250
+
251
+ if (profile.createdAt) {
252
+ const joined = new Date(profile.createdAt).toLocaleDateString("en-US", {
253
+ year: "numeric",
254
+ month: "long",
255
+ day: "numeric",
256
+ });
257
+ md += `- **Joined:** ${joined}\n`;
258
+ }
259
+
260
+ md += `\n**DID:** \`${profile.did}\`\n`;
261
+
262
+ const output = finalizeOutput(md);
263
+ return {
264
+ url,
265
+ finalUrl: url,
266
+ contentType: "text/markdown",
267
+ method: "bluesky-api",
268
+ content: output.content,
269
+ fetchedAt,
270
+ truncated: output.truncated,
271
+ notes: ["Fetched via AT Protocol API"],
272
+ };
273
+ }
274
+ } catch {}
275
+
276
+ return null;
277
+ };
@@ -0,0 +1,173 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface BrewFormula {
5
+ name: string;
6
+ full_name?: string;
7
+ desc?: string;
8
+ homepage?: string;
9
+ license?: string;
10
+ versions?: {
11
+ stable?: string;
12
+ head?: string;
13
+ bottle?: boolean;
14
+ };
15
+ dependencies?: string[];
16
+ build_dependencies?: string[];
17
+ optional_dependencies?: string[];
18
+ conflicts_with?: string[];
19
+ caveats?: string;
20
+ analytics?: {
21
+ install?: {
22
+ "30d"?: Record<string, number>;
23
+ "90d"?: Record<string, number>;
24
+ "365d"?: Record<string, number>;
25
+ };
26
+ };
27
+ }
28
+
29
+ interface BrewCask {
30
+ token: string;
31
+ name?: string[];
32
+ desc?: string;
33
+ homepage?: string;
34
+ version?: string;
35
+ sha256?: string;
36
+ caveats?: string;
37
+ depends_on?: {
38
+ macos?: Record<string, string[]>;
39
+ };
40
+ conflicts_with?: {
41
+ cask?: string[];
42
+ };
43
+ analytics?: {
44
+ install?: {
45
+ "30d"?: Record<string, number>;
46
+ "90d"?: Record<string, number>;
47
+ "365d"?: Record<string, number>;
48
+ };
49
+ };
50
+ }
51
+
52
+ function getInstallCount(analytics?: { install?: { "30d"?: Record<string, number> } }): number | null {
53
+ if (!analytics?.install?.["30d"]) return null;
54
+ const counts = Object.values(analytics.install["30d"]);
55
+ return counts.reduce((sum, n) => sum + n, 0);
56
+ }
57
+
58
+ /**
59
+ * Handle Homebrew formulae and cask URLs via API
60
+ */
61
+ export const handleBrew: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
62
+ try {
63
+ const parsed = new URL(url);
64
+ if (parsed.hostname !== "formulae.brew.sh") return null;
65
+
66
+ const formulaMatch = parsed.pathname.match(/^\/formula\/([^/]+)\/?$/);
67
+ const caskMatch = parsed.pathname.match(/^\/cask\/([^/]+)\/?$/);
68
+
69
+ if (!formulaMatch && !caskMatch) return null;
70
+
71
+ const fetchedAt = new Date().toISOString();
72
+ const isFormula = Boolean(formulaMatch);
73
+ const name = decodeURIComponent(isFormula ? formulaMatch![1] : caskMatch![1]);
74
+
75
+ const apiUrl = isFormula
76
+ ? `https://formulae.brew.sh/api/formula/${encodeURIComponent(name)}.json`
77
+ : `https://formulae.brew.sh/api/cask/${encodeURIComponent(name)}.json`;
78
+
79
+ const result = await loadPage(apiUrl, { timeout });
80
+ if (!result.ok) return null;
81
+
82
+ let md: string;
83
+
84
+ if (isFormula) {
85
+ const formula: BrewFormula = JSON.parse(result.content);
86
+
87
+ md = `# ${formula.full_name || formula.name}\n\n`;
88
+ if (formula.desc) md += `${formula.desc}\n\n`;
89
+
90
+ md += `**Version:** ${formula.versions?.stable || "unknown"}`;
91
+ if (formula.license) md += ` · **License:** ${formula.license}`;
92
+ md += "\n";
93
+
94
+ const installs = getInstallCount(formula.analytics);
95
+ if (installs !== null) {
96
+ md += `**Installs (30d):** ${formatCount(installs)}\n`;
97
+ }
98
+ md += "\n";
99
+
100
+ md += `\`\`\`bash\nbrew install ${formula.name}\n\`\`\`\n\n`;
101
+
102
+ if (formula.homepage) md += `**Homepage:** ${formula.homepage}\n`;
103
+
104
+ if (formula.dependencies?.length) {
105
+ md += `\n## Dependencies\n\n`;
106
+ for (const dep of formula.dependencies) {
107
+ md += `- ${dep}\n`;
108
+ }
109
+ }
110
+
111
+ if (formula.build_dependencies?.length) {
112
+ md += `\n## Build Dependencies\n\n`;
113
+ for (const dep of formula.build_dependencies) {
114
+ md += `- ${dep}\n`;
115
+ }
116
+ }
117
+
118
+ if (formula.conflicts_with?.length) {
119
+ md += `\n## Conflicts With\n\n`;
120
+ for (const conflict of formula.conflicts_with) {
121
+ md += `- ${conflict}\n`;
122
+ }
123
+ }
124
+
125
+ if (formula.caveats) {
126
+ md += `\n## Caveats\n\n${formula.caveats}\n`;
127
+ }
128
+ } else {
129
+ const cask: BrewCask = JSON.parse(result.content);
130
+
131
+ const displayName = cask.name?.[0] || cask.token;
132
+ md = `# ${displayName}\n\n`;
133
+ if (cask.desc) md += `${cask.desc}\n\n`;
134
+
135
+ md += `**Version:** ${cask.version || "unknown"}\n`;
136
+
137
+ const installs = getInstallCount(cask.analytics);
138
+ if (installs !== null) {
139
+ md += `**Installs (30d):** ${formatCount(installs)}\n`;
140
+ }
141
+ md += "\n";
142
+
143
+ md += `\`\`\`bash\nbrew install --cask ${cask.token}\n\`\`\`\n\n`;
144
+
145
+ if (cask.homepage) md += `**Homepage:** ${cask.homepage}\n`;
146
+
147
+ if (cask.conflicts_with?.cask?.length) {
148
+ md += `\n## Conflicts With\n\n`;
149
+ for (const conflict of cask.conflicts_with.cask) {
150
+ md += `- ${conflict}\n`;
151
+ }
152
+ }
153
+
154
+ if (cask.caveats) {
155
+ md += `\n## Caveats\n\n${cask.caveats}\n`;
156
+ }
157
+ }
158
+
159
+ const output = finalizeOutput(md);
160
+ return {
161
+ url,
162
+ finalUrl: url,
163
+ contentType: "text/markdown",
164
+ method: "brew",
165
+ content: output.content,
166
+ fetchedAt,
167
+ truncated: output.truncated,
168
+ notes: [`Fetched via Homebrew ${isFormula ? "formula" : "cask"} API`],
169
+ };
170
+ } catch {}
171
+
172
+ return null;
173
+ };
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleOpenCorporates } from "./opencorporates";
3
+ import { handleSecEdgar } from "./sec-edgar";
4
+
5
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
6
+
7
+ describe.skipIf(SKIP)("handleSecEdgar", () => {
8
+ it("returns null for non-matching URLs", async () => {
9
+ const result = await handleSecEdgar("https://example.com", 20);
10
+ expect(result).toBeNull();
11
+ });
12
+
13
+ it("returns null for SEC URLs without valid CIK", async () => {
14
+ const result = await handleSecEdgar("https://www.sec.gov/about.html", 20);
15
+ expect(result).toBeNull();
16
+ });
17
+
18
+ it("fetches Apple Inc filings by CIK query param", async () => {
19
+ const result = await handleSecEdgar(
20
+ "https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=0000320193",
21
+ 20,
22
+ );
23
+ expect(result).not.toBeNull();
24
+ expect(result?.method).toBe("sec-edgar");
25
+ expect(result?.content).toContain("APPLE INC");
26
+ expect(result?.content).toContain("0000320193");
27
+ expect(result?.content).toContain("10-K"); // Apple files 10-K annually
28
+ expect(result?.contentType).toBe("text/markdown");
29
+ expect(result?.fetchedAt).toBeTruthy();
30
+ expect(result?.truncated).toBeDefined();
31
+ });
32
+
33
+ it("fetches via data.sec.gov submissions URL", async () => {
34
+ const result = await handleSecEdgar("https://data.sec.gov/submissions/CIK0000320193.json", 20);
35
+ expect(result).not.toBeNull();
36
+ expect(result?.method).toBe("sec-edgar");
37
+ expect(result?.content).toContain("APPLE INC");
38
+ });
39
+
40
+ it("fetches via Archives path", async () => {
41
+ // Any filing URL with CIK in Archives path
42
+ const result = await handleSecEdgar(
43
+ "https://www.sec.gov/Archives/edgar/data/320193/000032019324000123/aapl-20240928.htm",
44
+ 20,
45
+ );
46
+ expect(result).not.toBeNull();
47
+ expect(result?.method).toBe("sec-edgar");
48
+ expect(result?.content).toContain("APPLE INC");
49
+ });
50
+ });
51
+
52
+ describe.skipIf(SKIP)("handleOpenCorporates", () => {
53
+ it("returns null for non-matching URLs", async () => {
54
+ const result = await handleOpenCorporates("https://example.com", 20);
55
+ expect(result).toBeNull();
56
+ });
57
+
58
+ it("returns null for OpenCorporates URLs without company path", async () => {
59
+ const result = await handleOpenCorporates("https://opencorporates.com/about", 20);
60
+ expect(result).toBeNull();
61
+ });
62
+
63
+ it("fetches Apple Inc Delaware registration", async () => {
64
+ const result = await handleOpenCorporates("https://opencorporates.com/companies/us_de/2927442", 20);
65
+ expect(result).not.toBeNull();
66
+ expect(result?.method).toBe("opencorporates");
67
+ expect(result?.content).toContain("APPLE INC");
68
+ expect(result?.content).toContain("2927442");
69
+ expect(result?.content).toContain("US_DE");
70
+ expect(result?.contentType).toBe("text/markdown");
71
+ expect(result?.fetchedAt).toBeTruthy();
72
+ expect(result?.truncated).toBeDefined();
73
+ });
74
+
75
+ it("fetches Microsoft Corporation", async () => {
76
+ // Microsoft is registered in Washington state
77
+ const result = await handleOpenCorporates("https://opencorporates.com/companies/us_wa/600413485", 20);
78
+ expect(result).not.toBeNull();
79
+ expect(result?.method).toBe("opencorporates");
80
+ expect(result?.content).toMatch(/microsoft/i);
81
+ });
82
+ });