@oh-my-pi/pi-coding-agent 3.25.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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +4 -4
  3. package/src/core/tools/complete.ts +2 -4
  4. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  5. package/src/core/tools/read.ts +4 -4
  6. package/src/core/tools/task/executor.ts +146 -20
  7. package/src/core/tools/task/name-generator.ts +1544 -214
  8. package/src/core/tools/task/types.ts +19 -5
  9. package/src/core/tools/task/worker.ts +103 -13
  10. package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
  11. package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
  12. package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
  13. package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
  14. package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
  15. package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
  16. package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
  17. package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
  18. package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
  19. package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
  20. package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
  21. package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
  22. package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
  23. package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
  24. package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
  25. package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
  26. package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
  27. package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
  28. package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
  29. package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
  30. package/src/core/tools/web-fetch-handlers/github.ts +424 -0
  31. package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
  32. package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
  33. package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
  34. package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
  35. package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
  36. package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
  37. package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
  38. package/src/core/tools/web-fetch-handlers/index.ts +69 -0
  39. package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
  40. package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
  41. package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
  42. package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
  43. package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
  44. package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
  45. package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
  46. package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
  47. package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
  48. package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
  49. package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
  50. package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
  51. package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
  52. package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
  53. package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
  54. package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
  55. package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
  56. package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
  57. package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
  58. package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
  59. package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
  60. package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
  61. package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
  62. package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
  63. package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
  64. package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
  65. package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
  66. package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
  67. package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
  68. package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
  69. package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
  70. package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
  71. package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
  72. package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
  73. package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
  74. package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
  75. package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
  76. package/src/core/tools/web-fetch-handlers/types.ts +163 -0
  77. package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
  78. package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
  79. package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
  80. package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
  81. package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
  82. package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
  83. package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
  84. package/src/core/tools/web-fetch.ts +152 -1324
  85. package/src/utils/tools-manager.ts +110 -8
@@ -0,0 +1,186 @@
1
+ import type { SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ // =============================================================================
5
+ // Lobste.rs Types
6
+ // =============================================================================
7
+
8
+ interface LobstersStory {
9
+ short_id: string;
10
+ title: string;
11
+ url?: string;
12
+ description?: string;
13
+ submitter_user: {
14
+ username: string;
15
+ };
16
+ score: number;
17
+ comment_count: number;
18
+ created_at: string;
19
+ tags: string[];
20
+ }
21
+
22
+ interface LobstersComment {
23
+ short_id: string;
24
+ comment: string;
25
+ commenting_user: {
26
+ username: string;
27
+ };
28
+ score: number;
29
+ created_at: string;
30
+ indent_level: number;
31
+ comments?: LobstersComment[];
32
+ }
33
+
34
+ interface LobstersStoryResponse {
35
+ short_id: string;
36
+ title: string;
37
+ url?: string;
38
+ description?: string;
39
+ submitter_user: {
40
+ username: string;
41
+ };
42
+ score: number;
43
+ comment_count: number;
44
+ created_at: string;
45
+ tags: string[];
46
+ comments: LobstersComment[];
47
+ }
48
+
49
+ // =============================================================================
50
+ // Handler
51
+ // =============================================================================
52
+
53
+ /**
54
+ * Render comments recursively
55
+ */
56
+ function renderComments(comments: LobstersComment[], maxDepth = 5): string {
57
+ let md = "";
58
+ for (const comment of comments) {
59
+ if (comment.indent_level >= maxDepth) continue;
60
+
61
+ const indent = " ".repeat(comment.indent_level);
62
+ md += `${indent}### ${comment.commenting_user.username} · ${comment.score} points\n\n`;
63
+ md += `${indent}${comment.comment.split("\n").join(`\n${indent}`)}\n\n`;
64
+
65
+ if (comment.comments && comment.comments.length > 0) {
66
+ md += renderComments(comment.comments, maxDepth);
67
+ }
68
+
69
+ md += `${indent}---\n\n`;
70
+ }
71
+ return md;
72
+ }
73
+
74
+ /**
75
+ * Handle Lobste.rs URLs via JSON API
76
+ */
77
+ export const handleLobsters: SpecialHandler = async (url: string, timeout: number) => {
78
+ try {
79
+ const parsed = new URL(url);
80
+ if (!parsed.hostname.includes("lobste.rs")) return null;
81
+
82
+ const fetchedAt = new Date().toISOString();
83
+ let jsonUrl = "";
84
+ let md = "";
85
+
86
+ // Story page: lobste.rs/s/{short_id}/{slug}
87
+ const storyMatch = parsed.pathname.match(/^\/s\/([^/]+)/);
88
+ if (storyMatch) {
89
+ jsonUrl = `https://lobste.rs/s/${storyMatch[1]}.json`;
90
+ const result = await loadPage(jsonUrl, { timeout });
91
+ if (!result.ok) return null;
92
+
93
+ const story = JSON.parse(result.content) as LobstersStoryResponse;
94
+
95
+ md = `# ${story.title}\n\n`;
96
+ md += `**${story.submitter_user.username}** · ${story.score} points · ${story.comment_count} comments`;
97
+ if (story.tags.length > 0) {
98
+ md += ` · [${story.tags.join(", ")}]`;
99
+ }
100
+ md += `\n`;
101
+ md += `*${new Date(story.created_at).toISOString().split("T")[0]}*\n\n`;
102
+
103
+ if (story.description) {
104
+ md += `---\n\n${story.description}\n\n`;
105
+ } else if (story.url) {
106
+ md += `**Link:** ${story.url}\n\n`;
107
+ }
108
+
109
+ // Add comments
110
+ if (story.comments && story.comments.length > 0) {
111
+ md += `---\n\n## Comments\n\n`;
112
+ md += renderComments(story.comments);
113
+ }
114
+
115
+ const output = finalizeOutput(md);
116
+ return {
117
+ url,
118
+ finalUrl: jsonUrl,
119
+ contentType: "text/markdown",
120
+ method: "lobsters",
121
+ content: output.content,
122
+ fetchedAt,
123
+ truncated: output.truncated,
124
+ notes: ["Fetched via Lobste.rs JSON API"],
125
+ };
126
+ }
127
+
128
+ // Front page, newest, or tag page
129
+ if (parsed.pathname === "/" || parsed.pathname === "/newest" || parsed.pathname.startsWith("/t/")) {
130
+ if (parsed.pathname === "/") {
131
+ jsonUrl = "https://lobste.rs/hottest.json";
132
+ } else if (parsed.pathname === "/newest") {
133
+ jsonUrl = "https://lobste.rs/newest.json";
134
+ } else {
135
+ const tagMatch = parsed.pathname.match(/^\/t\/([^/]+)/);
136
+ if (tagMatch) {
137
+ jsonUrl = `https://lobste.rs/t/${tagMatch[1]}.json`;
138
+ }
139
+ }
140
+
141
+ if (!jsonUrl) return null;
142
+
143
+ const result = await loadPage(jsonUrl, { timeout });
144
+ if (!result.ok) return null;
145
+
146
+ const stories = JSON.parse(result.content) as LobstersStory[];
147
+ const listingStories = stories.slice(0, 20);
148
+
149
+ const title =
150
+ parsed.pathname === "/"
151
+ ? "Lobste.rs Front Page"
152
+ : parsed.pathname === "/newest"
153
+ ? "Lobste.rs Newest"
154
+ : `Lobste.rs Tag: ${parsed.pathname.split("/")[2]}`;
155
+
156
+ md = `# ${title}\n\n`;
157
+
158
+ for (const story of listingStories) {
159
+ md += `- **${story.title}** (${story.score} pts, ${story.comment_count} comments)\n`;
160
+ md += ` by ${story.submitter_user.username}`;
161
+ if (story.tags.length > 0) {
162
+ md += ` · [${story.tags.join(", ")}]`;
163
+ }
164
+ md += `\n`;
165
+ if (story.url) {
166
+ md += ` ${story.url}\n`;
167
+ }
168
+ md += ` https://lobste.rs/s/${story.short_id}\n\n`;
169
+ }
170
+
171
+ const output = finalizeOutput(md);
172
+ return {
173
+ url,
174
+ finalUrl: jsonUrl,
175
+ contentType: "text/markdown",
176
+ method: "lobsters",
177
+ content: output.content,
178
+ fetchedAt,
179
+ truncated: output.truncated,
180
+ notes: ["Fetched via Lobste.rs JSON API"],
181
+ };
182
+ }
183
+ } catch {}
184
+
185
+ return null;
186
+ };
@@ -0,0 +1,302 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface MastodonAccount {
5
+ id: string;
6
+ username: string;
7
+ acct: string;
8
+ display_name: string;
9
+ note: string;
10
+ url: string;
11
+ avatar: string;
12
+ header: string;
13
+ followers_count: number;
14
+ following_count: number;
15
+ statuses_count: number;
16
+ created_at: string;
17
+ bot: boolean;
18
+ fields?: Array<{ name: string; value: string }>;
19
+ }
20
+
21
+ interface MastodonMediaAttachment {
22
+ id: string;
23
+ type: "image" | "video" | "gifv" | "audio" | "unknown";
24
+ url: string;
25
+ preview_url?: string;
26
+ description?: string;
27
+ }
28
+
29
+ interface MastodonStatus {
30
+ id: string;
31
+ created_at: string;
32
+ content: string;
33
+ url: string;
34
+ account: MastodonAccount;
35
+ reblogs_count: number;
36
+ favourites_count: number;
37
+ replies_count: number;
38
+ reblog?: MastodonStatus;
39
+ media_attachments: MastodonMediaAttachment[];
40
+ spoiler_text?: string;
41
+ sensitive: boolean;
42
+ visibility: "public" | "unlisted" | "private" | "direct";
43
+ in_reply_to_id?: string;
44
+ poll?: {
45
+ options: Array<{ title: string; votes_count: number }>;
46
+ votes_count: number;
47
+ expired: boolean;
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Check if a domain is a Mastodon instance by probing the API
53
+ */
54
+ async function isMastodonInstance(hostname: string, timeout: number): Promise<boolean> {
55
+ try {
56
+ const result = await loadPage(`https://${hostname}/api/v1/instance`, {
57
+ timeout: Math.min(timeout, 5),
58
+ headers: { Accept: "application/json" },
59
+ });
60
+ if (!result.ok) return false;
61
+ const data = JSON.parse(result.content);
62
+ // Mastodon instances return uri/domain field
63
+ return !!(data.uri || data.domain || data.title);
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Format a date string to readable format
71
+ */
72
+ function formatDate(isoDate: string): string {
73
+ try {
74
+ const date = new Date(isoDate);
75
+ return date.toLocaleDateString("en-US", {
76
+ year: "numeric",
77
+ month: "short",
78
+ day: "numeric",
79
+ hour: "2-digit",
80
+ minute: "2-digit",
81
+ });
82
+ } catch {
83
+ return isoDate;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Format a status/post as markdown
89
+ */
90
+ function formatStatus(status: MastodonStatus, isReblog = false): string {
91
+ // Handle reblogs (boosts)
92
+ if (status.reblog && !isReblog) {
93
+ let md = `🔁 **${status.account.display_name || status.account.username}** boosted:\n\n`;
94
+ md += formatStatus(status.reblog, true);
95
+ return md;
96
+ }
97
+
98
+ const account = status.account;
99
+ let md = "";
100
+
101
+ if (!isReblog) {
102
+ md += `# Post by ${account.display_name || account.username}\n\n`;
103
+ }
104
+
105
+ md += `**@${account.acct}**`;
106
+ if (account.bot) md += " 🤖";
107
+ md += ` · ${formatDate(status.created_at)}`;
108
+ if (status.visibility !== "public") md += ` · ${status.visibility}`;
109
+ md += "\n\n";
110
+
111
+ // Content warning / spoiler
112
+ if (status.spoiler_text) {
113
+ md += `> ⚠️ **CW:** ${status.spoiler_text}\n\n`;
114
+ }
115
+
116
+ // Main content (convert HTML to markdown)
117
+ const content = htmlToBasicMarkdown(status.content);
118
+ md += `${content}\n\n`;
119
+
120
+ // Poll
121
+ if (status.poll) {
122
+ md += "**Poll:**\n";
123
+ for (const option of status.poll.options) {
124
+ const pct =
125
+ status.poll.votes_count > 0 ? ((option.votes_count / status.poll.votes_count) * 100).toFixed(1) : "0";
126
+ md += `- ${option.title} (${pct}%, ${option.votes_count} votes)\n`;
127
+ }
128
+ md += `Total: ${status.poll.votes_count} votes${status.poll.expired ? " (closed)" : ""}\n\n`;
129
+ }
130
+
131
+ // Media attachments
132
+ if (status.media_attachments.length > 0) {
133
+ md += "**Attachments:**\n";
134
+ for (const media of status.media_attachments) {
135
+ const desc = media.description ? ` - ${media.description}` : "";
136
+ md += `- [${media.type}](${media.url})${desc}\n`;
137
+ }
138
+ md += "\n";
139
+ }
140
+
141
+ // Stats
142
+ md += `---\n`;
143
+ md += `💬 ${formatCount(status.replies_count)} replies · `;
144
+ md += `🔁 ${formatCount(status.reblogs_count)} boosts · `;
145
+ md += `⭐ ${formatCount(status.favourites_count)} favorites\n`;
146
+
147
+ return md;
148
+ }
149
+
150
+ /**
151
+ * Format an account/profile as markdown
152
+ */
153
+ function formatAccount(account: MastodonAccount): string {
154
+ let md = `# ${account.display_name || account.username}\n\n`;
155
+
156
+ md += `**@${account.acct}**`;
157
+ if (account.bot) md += " 🤖 Bot";
158
+ md += "\n\n";
159
+
160
+ // Bio
161
+ if (account.note) {
162
+ const bio = htmlToBasicMarkdown(account.note);
163
+ if (bio && bio !== account.display_name) {
164
+ md += `${bio}\n\n`;
165
+ }
166
+ }
167
+
168
+ // Stats
169
+ md += `**Followers:** ${formatCount(account.followers_count)} · `;
170
+ md += `**Following:** ${formatCount(account.following_count)} · `;
171
+ md += `**Posts:** ${formatCount(account.statuses_count)}\n\n`;
172
+
173
+ md += `**Joined:** ${formatDate(account.created_at)}\n`;
174
+ md += `**Profile:** ${account.url}\n`;
175
+
176
+ // Profile fields (links, pronouns, etc.)
177
+ if (account.fields && account.fields.length > 0) {
178
+ md += "\n**Profile Fields:**\n";
179
+ for (const field of account.fields) {
180
+ const value = htmlToBasicMarkdown(field.value);
181
+ md += `- **${field.name}:** ${value}\n`;
182
+ }
183
+ }
184
+
185
+ return md;
186
+ }
187
+
188
+ /**
189
+ * Handle Mastodon/Fediverse URLs
190
+ */
191
+ export const handleMastodon: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
192
+ try {
193
+ const parsed = new URL(url);
194
+
195
+ // Check for @user/postid or @user pattern
196
+ const postMatch = parsed.pathname.match(/^\/@([^/]+)\/(\d+)$/);
197
+ const profileMatch = parsed.pathname.match(/^\/@([^/]+)$/);
198
+
199
+ if (!postMatch && !profileMatch) return null;
200
+
201
+ // Verify this is a Mastodon instance
202
+ if (!(await isMastodonInstance(parsed.hostname, timeout))) {
203
+ return null;
204
+ }
205
+
206
+ const fetchedAt = new Date().toISOString();
207
+ const instance = parsed.hostname;
208
+
209
+ if (postMatch) {
210
+ // Fetch status/post
211
+ const [, , statusId] = postMatch;
212
+ const apiUrl = `https://${instance}/api/v1/statuses/${statusId}`;
213
+
214
+ const result = await loadPage(apiUrl, {
215
+ timeout,
216
+ headers: { Accept: "application/json" },
217
+ });
218
+
219
+ if (!result.ok) return null;
220
+
221
+ let status: MastodonStatus;
222
+ try {
223
+ status = JSON.parse(result.content);
224
+ } catch {
225
+ return null;
226
+ }
227
+
228
+ const md = formatStatus(status);
229
+ const output = finalizeOutput(md);
230
+
231
+ return {
232
+ url,
233
+ finalUrl: status.url || url,
234
+ contentType: "text/markdown",
235
+ method: "mastodon",
236
+ content: output.content,
237
+ fetchedAt,
238
+ truncated: output.truncated,
239
+ notes: [`Fetched via Mastodon API (${instance})`],
240
+ };
241
+ }
242
+
243
+ if (profileMatch) {
244
+ // Fetch account by username lookup
245
+ const [, username] = profileMatch;
246
+ const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${encodeURIComponent(username)}`;
247
+
248
+ const result = await loadPage(lookupUrl, {
249
+ timeout,
250
+ headers: { Accept: "application/json" },
251
+ });
252
+
253
+ if (!result.ok) return null;
254
+
255
+ let account: MastodonAccount;
256
+ try {
257
+ account = JSON.parse(result.content);
258
+ } catch {
259
+ return null;
260
+ }
261
+
262
+ // Fetch recent statuses
263
+ const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=5&exclude_replies=true`;
264
+ const statusesResult = await loadPage(statusesUrl, {
265
+ timeout,
266
+ headers: { Accept: "application/json" },
267
+ });
268
+
269
+ let md = formatAccount(account);
270
+
271
+ if (statusesResult.ok) {
272
+ try {
273
+ const statuses: MastodonStatus[] = JSON.parse(statusesResult.content);
274
+ if (statuses.length > 0) {
275
+ md += "\n---\n\n## Recent Posts\n\n";
276
+ for (const status of statuses.slice(0, 5)) {
277
+ md += `### ${formatDate(status.created_at)}\n\n`;
278
+ const content = htmlToBasicMarkdown(status.content);
279
+ md += `${content}\n\n`;
280
+ md += `💬 ${status.replies_count} · 🔁 ${status.reblogs_count} · ⭐ ${status.favourites_count}\n\n`;
281
+ }
282
+ }
283
+ } catch {}
284
+ }
285
+
286
+ const output = finalizeOutput(md);
287
+
288
+ return {
289
+ url,
290
+ finalUrl: account.url || url,
291
+ contentType: "text/markdown",
292
+ method: "mastodon",
293
+ content: output.content,
294
+ fetchedAt,
295
+ truncated: output.truncated,
296
+ notes: [`Fetched via Mastodon API (${instance})`],
297
+ };
298
+ }
299
+ } catch {}
300
+
301
+ return null;
302
+ };
@@ -0,0 +1,147 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface MavenDoc {
5
+ id: string;
6
+ g: string; // groupId
7
+ a: string; // artifactId
8
+ latestVersion: string;
9
+ repositoryId: string;
10
+ p: string; // packaging
11
+ timestamp: number;
12
+ versionCount: number;
13
+ text?: string[];
14
+ ec?: string[]; // extensions/classifiers
15
+ }
16
+
17
+ interface MavenResponse {
18
+ response: {
19
+ numFound: number;
20
+ docs: MavenDoc[];
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Handle Maven Central URLs via Solr API
26
+ * Supports: search.maven.org/artifact/... and mvnrepository.com/artifact/...
27
+ */
28
+ export const handleMaven: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
29
+ try {
30
+ const parsed = new URL(url);
31
+ const hostname = parsed.hostname;
32
+
33
+ // Check if this is a Maven URL
34
+ const isSearchMaven = hostname === "search.maven.org";
35
+ const isMvnRepository = hostname === "mvnrepository.com" || hostname === "www.mvnrepository.com";
36
+
37
+ if (!isSearchMaven && !isMvnRepository) return null;
38
+
39
+ let groupId: string | null = null;
40
+ let artifactId: string | null = null;
41
+ let version: string | null = null;
42
+
43
+ if (isSearchMaven) {
44
+ // Pattern: /artifact/{groupId}/{artifactId}[/{version}[/{packaging}]]
45
+ const match = parsed.pathname.match(/^\/artifact\/([^/]+)\/([^/]+)(?:\/([^/]+))?/);
46
+ if (!match) return null;
47
+ groupId = match[1];
48
+ artifactId = match[2];
49
+ version = match[3] || null;
50
+ } else if (isMvnRepository) {
51
+ // Pattern: /artifact/{groupId}/{artifactId}[/{version}]
52
+ const match = parsed.pathname.match(/^\/artifact\/([^/]+)\/([^/]+)(?:\/([^/]+))?/);
53
+ if (!match) return null;
54
+ groupId = match[1];
55
+ artifactId = match[2];
56
+ version = match[3] || null;
57
+ }
58
+
59
+ if (!groupId || !artifactId) return null;
60
+
61
+ const fetchedAt = new Date().toISOString();
62
+
63
+ // Query Maven Central API
64
+ const apiUrl = `https://search.maven.org/solrsearch/select?q=g:${encodeURIComponent(groupId)}+AND+a:${encodeURIComponent(artifactId)}&wt=json&rows=1`;
65
+ const result = await loadPage(apiUrl, {
66
+ timeout,
67
+ headers: { Accept: "application/json" },
68
+ });
69
+
70
+ if (!result.ok) return null;
71
+
72
+ let data: MavenResponse;
73
+ try {
74
+ data = JSON.parse(result.content);
75
+ } catch {
76
+ return null;
77
+ }
78
+
79
+ if (data.response.numFound === 0) return null;
80
+
81
+ const doc = data.response.docs[0];
82
+ const displayVersion = version || doc.latestVersion;
83
+
84
+ let md = `# ${doc.g}:${doc.a}\n\n`;
85
+ md += `**Group ID:** ${doc.g}\n`;
86
+ md += `**Artifact ID:** ${doc.a}\n`;
87
+ md += `**Latest Version:** ${doc.latestVersion}`;
88
+ if (version && version !== doc.latestVersion) {
89
+ md += ` (viewing ${version})`;
90
+ }
91
+ md += "\n";
92
+
93
+ if (doc.p) md += `**Packaging:** ${doc.p}\n`;
94
+ if (doc.versionCount) md += `**Versions:** ${formatCount(doc.versionCount)}\n`;
95
+ if (doc.timestamp) {
96
+ const date = new Date(doc.timestamp);
97
+ md += `**Last Updated:** ${date.toISOString().split("T")[0]}\n`;
98
+ }
99
+
100
+ // Add dependency snippets
101
+ md += `\n## Maven Dependency\n\n`;
102
+ md += "```xml\n";
103
+ md += `<dependency>\n`;
104
+ md += ` <groupId>${doc.g}</groupId>\n`;
105
+ md += ` <artifactId>${doc.a}</artifactId>\n`;
106
+ md += ` <version>${displayVersion}</version>\n`;
107
+ md += `</dependency>\n`;
108
+ md += "```\n";
109
+
110
+ md += `\n## Gradle Dependency\n\n`;
111
+ md += "```groovy\n";
112
+ md += `implementation '${doc.g}:${doc.a}:${displayVersion}'\n`;
113
+ md += "```\n";
114
+
115
+ md += `\n## Gradle (Kotlin DSL)\n\n`;
116
+ md += "```kotlin\n";
117
+ md += `implementation("${doc.g}:${doc.a}:${displayVersion}")\n`;
118
+ md += "```\n";
119
+
120
+ // Add available classifiers/extensions if present
121
+ if (doc.ec && doc.ec.length > 0) {
122
+ const extensions = doc.ec.filter((e) => e && e !== "-");
123
+ if (extensions.length > 0) {
124
+ md += `\n## Available Extensions\n\n`;
125
+ md += `${extensions.map((e) => `- ${e}`).join("\n")}\n`;
126
+ }
127
+ }
128
+
129
+ md += `\n## Links\n\n`;
130
+ md += `- [Maven Central](https://search.maven.org/artifact/${doc.g}/${doc.a}/${displayVersion}/jar)\n`;
131
+ md += `- [MVN Repository](https://mvnrepository.com/artifact/${doc.g}/${doc.a}/${displayVersion})\n`;
132
+
133
+ const output = finalizeOutput(md);
134
+ return {
135
+ url,
136
+ finalUrl: url,
137
+ contentType: "text/markdown",
138
+ method: "maven",
139
+ content: output.content,
140
+ fetchedAt,
141
+ truncated: output.truncated,
142
+ notes: ["Fetched via Maven Central API"],
143
+ };
144
+ } catch {}
145
+
146
+ return null;
147
+ };