@oh-my-pi/pi-coding-agent 3.25.0 → 3.31.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 (157) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +55 -32
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +152 -76
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -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, signal?: AbortSignal) => {
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, signal });
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, signal });
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,310 @@
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, signal?: AbortSignal): 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
+ signal,
60
+ });
61
+ if (!result.ok) return false;
62
+ const data = JSON.parse(result.content);
63
+ // Mastodon instances return uri/domain field
64
+ return !!(data.uri || data.domain || data.title);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Format a date string to readable format
72
+ */
73
+ function formatDate(isoDate: string): string {
74
+ try {
75
+ const date = new Date(isoDate);
76
+ return date.toLocaleDateString("en-US", {
77
+ year: "numeric",
78
+ month: "short",
79
+ day: "numeric",
80
+ hour: "2-digit",
81
+ minute: "2-digit",
82
+ });
83
+ } catch {
84
+ return isoDate;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Format a status/post as markdown
90
+ */
91
+ function formatStatus(status: MastodonStatus, isReblog = false): string {
92
+ // Handle reblogs (boosts)
93
+ if (status.reblog && !isReblog) {
94
+ let md = `🔁 **${status.account.display_name || status.account.username}** boosted:\n\n`;
95
+ md += formatStatus(status.reblog, true);
96
+ return md;
97
+ }
98
+
99
+ const account = status.account;
100
+ let md = "";
101
+
102
+ if (!isReblog) {
103
+ md += `# Post by ${account.display_name || account.username}\n\n`;
104
+ }
105
+
106
+ md += `**@${account.acct}**`;
107
+ if (account.bot) md += " 🤖";
108
+ md += ` · ${formatDate(status.created_at)}`;
109
+ if (status.visibility !== "public") md += ` · ${status.visibility}`;
110
+ md += "\n\n";
111
+
112
+ // Content warning / spoiler
113
+ if (status.spoiler_text) {
114
+ md += `> ⚠️ **CW:** ${status.spoiler_text}\n\n`;
115
+ }
116
+
117
+ // Main content (convert HTML to markdown)
118
+ const content = htmlToBasicMarkdown(status.content);
119
+ md += `${content}\n\n`;
120
+
121
+ // Poll
122
+ if (status.poll) {
123
+ md += "**Poll:**\n";
124
+ for (const option of status.poll.options) {
125
+ const pct =
126
+ status.poll.votes_count > 0 ? ((option.votes_count / status.poll.votes_count) * 100).toFixed(1) : "0";
127
+ md += `- ${option.title} (${pct}%, ${option.votes_count} votes)\n`;
128
+ }
129
+ md += `Total: ${status.poll.votes_count} votes${status.poll.expired ? " (closed)" : ""}\n\n`;
130
+ }
131
+
132
+ // Media attachments
133
+ if (status.media_attachments.length > 0) {
134
+ md += "**Attachments:**\n";
135
+ for (const media of status.media_attachments) {
136
+ const desc = media.description ? ` - ${media.description}` : "";
137
+ md += `- [${media.type}](${media.url})${desc}\n`;
138
+ }
139
+ md += "\n";
140
+ }
141
+
142
+ // Stats
143
+ md += `---\n`;
144
+ md += `💬 ${formatCount(status.replies_count)} replies · `;
145
+ md += `🔁 ${formatCount(status.reblogs_count)} boosts · `;
146
+ md += `⭐ ${formatCount(status.favourites_count)} favorites\n`;
147
+
148
+ return md;
149
+ }
150
+
151
+ /**
152
+ * Format an account/profile as markdown
153
+ */
154
+ function formatAccount(account: MastodonAccount): string {
155
+ let md = `# ${account.display_name || account.username}\n\n`;
156
+
157
+ md += `**@${account.acct}**`;
158
+ if (account.bot) md += " 🤖 Bot";
159
+ md += "\n\n";
160
+
161
+ // Bio
162
+ if (account.note) {
163
+ const bio = htmlToBasicMarkdown(account.note);
164
+ if (bio && bio !== account.display_name) {
165
+ md += `${bio}\n\n`;
166
+ }
167
+ }
168
+
169
+ // Stats
170
+ md += `**Followers:** ${formatCount(account.followers_count)} · `;
171
+ md += `**Following:** ${formatCount(account.following_count)} · `;
172
+ md += `**Posts:** ${formatCount(account.statuses_count)}\n\n`;
173
+
174
+ md += `**Joined:** ${formatDate(account.created_at)}\n`;
175
+ md += `**Profile:** ${account.url}\n`;
176
+
177
+ // Profile fields (links, pronouns, etc.)
178
+ if (account.fields && account.fields.length > 0) {
179
+ md += "\n**Profile Fields:**\n";
180
+ for (const field of account.fields) {
181
+ const value = htmlToBasicMarkdown(field.value);
182
+ md += `- **${field.name}:** ${value}\n`;
183
+ }
184
+ }
185
+
186
+ return md;
187
+ }
188
+
189
+ /**
190
+ * Handle Mastodon/Fediverse URLs
191
+ */
192
+ export const handleMastodon: SpecialHandler = async (
193
+ url: string,
194
+ timeout: number,
195
+ signal?: AbortSignal,
196
+ ): Promise<RenderResult | null> => {
197
+ try {
198
+ const parsed = new URL(url);
199
+
200
+ // Check for @user/postid or @user pattern
201
+ const postMatch = parsed.pathname.match(/^\/@([^/]+)\/(\d+)$/);
202
+ const profileMatch = parsed.pathname.match(/^\/@([^/]+)$/);
203
+
204
+ if (!postMatch && !profileMatch) return null;
205
+
206
+ // Verify this is a Mastodon instance
207
+ if (!(await isMastodonInstance(parsed.hostname, timeout, signal))) {
208
+ return null;
209
+ }
210
+
211
+ const fetchedAt = new Date().toISOString();
212
+ const instance = parsed.hostname;
213
+
214
+ if (postMatch) {
215
+ // Fetch status/post
216
+ const [, , statusId] = postMatch;
217
+ const apiUrl = `https://${instance}/api/v1/statuses/${statusId}`;
218
+
219
+ const result = await loadPage(apiUrl, {
220
+ timeout,
221
+ headers: { Accept: "application/json" },
222
+ signal,
223
+ });
224
+
225
+ if (!result.ok) return null;
226
+
227
+ let status: MastodonStatus;
228
+ try {
229
+ status = JSON.parse(result.content);
230
+ } catch {
231
+ return null;
232
+ }
233
+
234
+ const md = formatStatus(status);
235
+ const output = finalizeOutput(md);
236
+
237
+ return {
238
+ url,
239
+ finalUrl: status.url || url,
240
+ contentType: "text/markdown",
241
+ method: "mastodon",
242
+ content: output.content,
243
+ fetchedAt,
244
+ truncated: output.truncated,
245
+ notes: [`Fetched via Mastodon API (${instance})`],
246
+ };
247
+ }
248
+
249
+ if (profileMatch) {
250
+ // Fetch account by username lookup
251
+ const [, username] = profileMatch;
252
+ const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${encodeURIComponent(username)}`;
253
+
254
+ const result = await loadPage(lookupUrl, {
255
+ timeout,
256
+ headers: { Accept: "application/json" },
257
+ signal,
258
+ });
259
+
260
+ if (!result.ok) return null;
261
+
262
+ let account: MastodonAccount;
263
+ try {
264
+ account = JSON.parse(result.content);
265
+ } catch {
266
+ return null;
267
+ }
268
+
269
+ // Fetch recent statuses
270
+ const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=5&exclude_replies=true`;
271
+ const statusesResult = await loadPage(statusesUrl, {
272
+ timeout,
273
+ headers: { Accept: "application/json" },
274
+ signal,
275
+ });
276
+
277
+ let md = formatAccount(account);
278
+
279
+ if (statusesResult.ok) {
280
+ try {
281
+ const statuses: MastodonStatus[] = JSON.parse(statusesResult.content);
282
+ if (statuses.length > 0) {
283
+ md += "\n---\n\n## Recent Posts\n\n";
284
+ for (const status of statuses.slice(0, 5)) {
285
+ md += `### ${formatDate(status.created_at)}\n\n`;
286
+ const content = htmlToBasicMarkdown(status.content);
287
+ md += `${content}\n\n`;
288
+ md += `💬 ${status.replies_count} · 🔁 ${status.reblogs_count} · ⭐ ${status.favourites_count}\n\n`;
289
+ }
290
+ }
291
+ } catch {}
292
+ }
293
+
294
+ const output = finalizeOutput(md);
295
+
296
+ return {
297
+ url,
298
+ finalUrl: account.url || url,
299
+ contentType: "text/markdown",
300
+ method: "mastodon",
301
+ content: output.content,
302
+ fetchedAt,
303
+ truncated: output.truncated,
304
+ notes: [`Fetched via Mastodon API (${instance})`],
305
+ };
306
+ }
307
+ } catch {}
308
+
309
+ return null;
310
+ };
@@ -0,0 +1,152 @@
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 (
29
+ url: string,
30
+ timeout: number,
31
+ signal?: AbortSignal,
32
+ ): Promise<RenderResult | null> => {
33
+ try {
34
+ const parsed = new URL(url);
35
+ const hostname = parsed.hostname;
36
+
37
+ // Check if this is a Maven URL
38
+ const isSearchMaven = hostname === "search.maven.org";
39
+ const isMvnRepository = hostname === "mvnrepository.com" || hostname === "www.mvnrepository.com";
40
+
41
+ if (!isSearchMaven && !isMvnRepository) return null;
42
+
43
+ let groupId: string | null = null;
44
+ let artifactId: string | null = null;
45
+ let version: string | null = null;
46
+
47
+ if (isSearchMaven) {
48
+ // Pattern: /artifact/{groupId}/{artifactId}[/{version}[/{packaging}]]
49
+ const match = parsed.pathname.match(/^\/artifact\/([^/]+)\/([^/]+)(?:\/([^/]+))?/);
50
+ if (!match) return null;
51
+ groupId = match[1];
52
+ artifactId = match[2];
53
+ version = match[3] || null;
54
+ } else if (isMvnRepository) {
55
+ // Pattern: /artifact/{groupId}/{artifactId}[/{version}]
56
+ const match = parsed.pathname.match(/^\/artifact\/([^/]+)\/([^/]+)(?:\/([^/]+))?/);
57
+ if (!match) return null;
58
+ groupId = match[1];
59
+ artifactId = match[2];
60
+ version = match[3] || null;
61
+ }
62
+
63
+ if (!groupId || !artifactId) return null;
64
+
65
+ const fetchedAt = new Date().toISOString();
66
+
67
+ // Query Maven Central API
68
+ const apiUrl = `https://search.maven.org/solrsearch/select?q=g:${encodeURIComponent(groupId)}+AND+a:${encodeURIComponent(artifactId)}&wt=json&rows=1`;
69
+ const result = await loadPage(apiUrl, {
70
+ timeout,
71
+ headers: { Accept: "application/json" },
72
+ signal,
73
+ });
74
+
75
+ if (!result.ok) return null;
76
+
77
+ let data: MavenResponse;
78
+ try {
79
+ data = JSON.parse(result.content);
80
+ } catch {
81
+ return null;
82
+ }
83
+
84
+ if (data.response.numFound === 0) return null;
85
+
86
+ const doc = data.response.docs[0];
87
+ const displayVersion = version || doc.latestVersion;
88
+
89
+ let md = `# ${doc.g}:${doc.a}\n\n`;
90
+ md += `**Group ID:** ${doc.g}\n`;
91
+ md += `**Artifact ID:** ${doc.a}\n`;
92
+ md += `**Latest Version:** ${doc.latestVersion}`;
93
+ if (version && version !== doc.latestVersion) {
94
+ md += ` (viewing ${version})`;
95
+ }
96
+ md += "\n";
97
+
98
+ if (doc.p) md += `**Packaging:** ${doc.p}\n`;
99
+ if (doc.versionCount) md += `**Versions:** ${formatCount(doc.versionCount)}\n`;
100
+ if (doc.timestamp) {
101
+ const date = new Date(doc.timestamp);
102
+ md += `**Last Updated:** ${date.toISOString().split("T")[0]}\n`;
103
+ }
104
+
105
+ // Add dependency snippets
106
+ md += `\n## Maven Dependency\n\n`;
107
+ md += "```xml\n";
108
+ md += `<dependency>\n`;
109
+ md += ` <groupId>${doc.g}</groupId>\n`;
110
+ md += ` <artifactId>${doc.a}</artifactId>\n`;
111
+ md += ` <version>${displayVersion}</version>\n`;
112
+ md += `</dependency>\n`;
113
+ md += "```\n";
114
+
115
+ md += `\n## Gradle Dependency\n\n`;
116
+ md += "```groovy\n";
117
+ md += `implementation '${doc.g}:${doc.a}:${displayVersion}'\n`;
118
+ md += "```\n";
119
+
120
+ md += `\n## Gradle (Kotlin DSL)\n\n`;
121
+ md += "```kotlin\n";
122
+ md += `implementation("${doc.g}:${doc.a}:${displayVersion}")\n`;
123
+ md += "```\n";
124
+
125
+ // Add available classifiers/extensions if present
126
+ if (doc.ec && doc.ec.length > 0) {
127
+ const extensions = doc.ec.filter((e) => e && e !== "-");
128
+ if (extensions.length > 0) {
129
+ md += `\n## Available Extensions\n\n`;
130
+ md += `${extensions.map((e) => `- ${e}`).join("\n")}\n`;
131
+ }
132
+ }
133
+
134
+ md += `\n## Links\n\n`;
135
+ md += `- [Maven Central](https://search.maven.org/artifact/${doc.g}/${doc.a}/${displayVersion}/jar)\n`;
136
+ md += `- [MVN Repository](https://mvnrepository.com/artifact/${doc.g}/${doc.a}/${displayVersion})\n`;
137
+
138
+ const output = finalizeOutput(md);
139
+ return {
140
+ url,
141
+ finalUrl: url,
142
+ contentType: "text/markdown",
143
+ method: "maven",
144
+ content: output.content,
145
+ fetchedAt,
146
+ truncated: output.truncated,
147
+ notes: ["Fetched via Maven Central API"],
148
+ };
149
+ } catch {}
150
+
151
+ return null;
152
+ };