@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,308 @@
1
+ /**
2
+ * Discogs URL handler for music releases and masters
3
+ *
4
+ * Uses the Discogs API to extract structured metadata about releases.
5
+ * API docs: https://www.discogs.com/developers
6
+ */
7
+
8
+ import type { RenderResult, SpecialHandler } from "./types";
9
+ import { finalizeOutput, loadPage } from "./types";
10
+
11
+ interface DiscogsArtist {
12
+ name: string;
13
+ anv?: string; // artist name variation
14
+ role?: string;
15
+ join?: string;
16
+ }
17
+
18
+ interface DiscogsTrack {
19
+ position: string;
20
+ title: string;
21
+ duration?: string;
22
+ artists?: DiscogsArtist[];
23
+ extraartists?: DiscogsArtist[];
24
+ }
25
+
26
+ interface DiscogsLabel {
27
+ name: string;
28
+ catno?: string; // catalog number
29
+ }
30
+
31
+ interface DiscogsFormat {
32
+ name: string;
33
+ qty?: string;
34
+ descriptions?: string[];
35
+ }
36
+
37
+ interface DiscogsRelease {
38
+ id: number;
39
+ title: string;
40
+ artists?: DiscogsArtist[];
41
+ year?: number;
42
+ released?: string;
43
+ country?: string;
44
+ genres?: string[];
45
+ styles?: string[];
46
+ labels?: DiscogsLabel[];
47
+ formats?: DiscogsFormat[];
48
+ tracklist?: DiscogsTrack[];
49
+ extraartists?: DiscogsArtist[];
50
+ notes?: string;
51
+ uri?: string;
52
+ master_id?: number;
53
+ master_url?: string;
54
+ }
55
+
56
+ interface DiscogsMaster {
57
+ id: number;
58
+ title: string;
59
+ artists?: DiscogsArtist[];
60
+ year?: number;
61
+ genres?: string[];
62
+ styles?: string[];
63
+ tracklist?: DiscogsTrack[];
64
+ notes?: string;
65
+ uri?: string;
66
+ main_release?: number;
67
+ main_release_url?: string;
68
+ versions_url?: string;
69
+ num_for_sale?: number;
70
+ lowest_price?: number;
71
+ }
72
+
73
+ /**
74
+ * Format artist names, handling name variations
75
+ */
76
+ function formatArtists(artists: DiscogsArtist[] | undefined): string {
77
+ if (!artists?.length) return "Unknown Artist";
78
+ return artists
79
+ .map((a) => {
80
+ const name = a.anv || a.name;
81
+ const join = a.join || ", ";
82
+ return name + (a.join ? ` ${join} ` : "");
83
+ })
84
+ .join("")
85
+ .replace(/[,&]\s*$/, "")
86
+ .trim();
87
+ }
88
+
89
+ /**
90
+ * Format a single track
91
+ */
92
+ function formatTrack(track: DiscogsTrack): string {
93
+ let line = track.position ? `${track.position}. ` : "- ";
94
+ line += track.title;
95
+ if (track.duration) line += ` (${track.duration})`;
96
+ if (track.artists?.length) {
97
+ line += ` - ${formatArtists(track.artists)}`;
98
+ }
99
+ return line;
100
+ }
101
+
102
+ /**
103
+ * Format credits/extraartists grouped by role
104
+ */
105
+ function formatCredits(extraartists: DiscogsArtist[] | undefined): string {
106
+ if (!extraartists?.length) return "";
107
+
108
+ const byRole: Record<string, string[]> = {};
109
+ for (const artist of extraartists) {
110
+ const role = artist.role || "Other";
111
+ if (!byRole[role]) byRole[role] = [];
112
+ byRole[role].push(artist.anv || artist.name);
113
+ }
114
+
115
+ const lines: string[] = [];
116
+ for (const [role, names] of Object.entries(byRole)) {
117
+ lines.push(`- **${role}**: ${names.join(", ")}`);
118
+ }
119
+ return lines.join("\n");
120
+ }
121
+
122
+ /**
123
+ * Format formats (e.g., "2×LP, Album, Reissue")
124
+ */
125
+ function formatFormats(formats: DiscogsFormat[] | undefined): string {
126
+ if (!formats?.length) return "";
127
+
128
+ return formats
129
+ .map((f) => {
130
+ const parts: string[] = [];
131
+ if (f.qty && parseInt(f.qty, 10) > 1) parts.push(`${f.qty}×`);
132
+ parts.push(f.name);
133
+ if (f.descriptions?.length) parts.push(f.descriptions.join(", "));
134
+ return parts.join(" ");
135
+ })
136
+ .join(" + ");
137
+ }
138
+
139
+ /**
140
+ * Format labels with catalog numbers
141
+ */
142
+ function formatLabels(labels: DiscogsLabel[] | undefined): string {
143
+ if (!labels?.length) return "";
144
+ return labels
145
+ .map((l) => {
146
+ if (l.catno && l.catno !== "none") return `${l.name} (${l.catno})`;
147
+ return l.name;
148
+ })
149
+ .join(", ");
150
+ }
151
+
152
+ /**
153
+ * Build markdown for a release
154
+ */
155
+ function buildReleaseMarkdown(release: DiscogsRelease): string {
156
+ const sections: string[] = [];
157
+
158
+ // Title with artist
159
+ const artist = formatArtists(release.artists);
160
+ sections.push(`# ${artist} - ${release.title}\n`);
161
+
162
+ // Metadata
163
+ const meta: string[] = [];
164
+ if (release.year) meta.push(`**Year**: ${release.year}`);
165
+ if (release.country) meta.push(`**Country**: ${release.country}`);
166
+
167
+ const format = formatFormats(release.formats);
168
+ if (format) meta.push(`**Format**: ${format}`);
169
+
170
+ const labels = formatLabels(release.labels);
171
+ if (labels) meta.push(`**Label**: ${labels}`);
172
+
173
+ if (release.genres?.length) meta.push(`**Genre**: ${release.genres.join(", ")}`);
174
+ if (release.styles?.length) meta.push(`**Style**: ${release.styles.join(", ")}`);
175
+
176
+ if (release.master_id) {
177
+ meta.push(`**Master Release**: [${release.master_id}](https://www.discogs.com/master/${release.master_id})`);
178
+ }
179
+
180
+ if (meta.length) sections.push(`${meta.join("\n")}\n`);
181
+
182
+ // Tracklist
183
+ if (release.tracklist?.length) {
184
+ sections.push("## Tracklist\n");
185
+ const tracks = release.tracklist.map(formatTrack);
186
+ sections.push(`${tracks.join("\n")}\n`);
187
+ }
188
+
189
+ // Credits
190
+ const credits = formatCredits(release.extraartists);
191
+ if (credits) {
192
+ sections.push("## Credits\n");
193
+ sections.push(`${credits}\n`);
194
+ }
195
+
196
+ // Notes
197
+ if (release.notes) {
198
+ sections.push("## Notes\n");
199
+ sections.push(`${release.notes}\n`);
200
+ }
201
+
202
+ return sections.join("\n");
203
+ }
204
+
205
+ /**
206
+ * Build markdown for a master release
207
+ */
208
+ function buildMasterMarkdown(master: DiscogsMaster): string {
209
+ const sections: string[] = [];
210
+
211
+ // Title with artist
212
+ const artist = formatArtists(master.artists);
213
+ sections.push(`# ${artist} - ${master.title}\n`);
214
+ sections.push("*Master Release*\n");
215
+
216
+ // Metadata
217
+ const meta: string[] = [];
218
+ if (master.year) meta.push(`**Year**: ${master.year}`);
219
+ if (master.genres?.length) meta.push(`**Genre**: ${master.genres.join(", ")}`);
220
+ if (master.styles?.length) meta.push(`**Style**: ${master.styles.join(", ")}`);
221
+
222
+ if (master.main_release) {
223
+ meta.push(`**Main Release**: [${master.main_release}](https://www.discogs.com/release/${master.main_release})`);
224
+ }
225
+
226
+ if (master.num_for_sale !== undefined && master.num_for_sale > 0) {
227
+ meta.push(`**For Sale**: ${master.num_for_sale} copies`);
228
+ if (master.lowest_price !== undefined) {
229
+ meta.push(`**Lowest Price**: $${master.lowest_price.toFixed(2)}`);
230
+ }
231
+ }
232
+
233
+ if (meta.length) sections.push(`${meta.join("\n")}\n`);
234
+
235
+ // Tracklist
236
+ if (master.tracklist?.length) {
237
+ sections.push("## Tracklist\n");
238
+ const tracks = master.tracklist.map(formatTrack);
239
+ sections.push(`${tracks.join("\n")}\n`);
240
+ }
241
+
242
+ // Notes
243
+ if (master.notes) {
244
+ sections.push("## Notes\n");
245
+ sections.push(`${master.notes}\n`);
246
+ }
247
+
248
+ return sections.join("\n");
249
+ }
250
+
251
+ export const handleDiscogs: SpecialHandler = async (
252
+ url: string,
253
+ timeout: number,
254
+ signal?: AbortSignal,
255
+ ): Promise<RenderResult | null> => {
256
+ try {
257
+ const parsed = new URL(url);
258
+ if (!parsed.hostname.includes("discogs.com")) return null;
259
+
260
+ // Match release or master URLs
261
+ // Patterns: /release/{id}, /master/{id}
262
+ // Also handles: /release/{id}-Artist-Title, /master/{id}-Artist-Title
263
+ const releaseMatch = parsed.pathname.match(/\/release\/(\d+)/);
264
+ const masterMatch = parsed.pathname.match(/\/master\/(\d+)/);
265
+
266
+ if (!releaseMatch && !masterMatch) return null;
267
+
268
+ const fetchedAt = new Date().toISOString();
269
+ const isRelease = !!releaseMatch;
270
+ const id = isRelease ? releaseMatch[1] : masterMatch![1];
271
+
272
+ const apiUrl = isRelease ? `https://api.discogs.com/releases/${id}` : `https://api.discogs.com/masters/${id}`;
273
+
274
+ const result = await loadPage(apiUrl, {
275
+ timeout,
276
+ signal,
277
+ headers: {
278
+ Accept: "application/json",
279
+ "User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
280
+ },
281
+ });
282
+
283
+ if (!result.ok) return null;
284
+
285
+ let md: string;
286
+ if (isRelease) {
287
+ const release = JSON.parse(result.content) as DiscogsRelease;
288
+ md = buildReleaseMarkdown(release);
289
+ } else {
290
+ const master = JSON.parse(result.content) as DiscogsMaster;
291
+ md = buildMasterMarkdown(master);
292
+ }
293
+
294
+ const output = finalizeOutput(md);
295
+ return {
296
+ url,
297
+ finalUrl: url,
298
+ contentType: "text/markdown",
299
+ method: "discogs",
300
+ content: output.content,
301
+ fetchedAt,
302
+ truncated: output.truncated,
303
+ notes: [`Fetched via Discogs API (${isRelease ? "release" : "master"})`],
304
+ };
305
+ } catch {}
306
+
307
+ return null;
308
+ };
@@ -0,0 +1,221 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface DiscourseUser {
5
+ username?: string;
6
+ name?: string;
7
+ }
8
+
9
+ interface DiscoursePost {
10
+ id: number;
11
+ username?: string;
12
+ name?: string;
13
+ created_at?: string;
14
+ cooked?: string;
15
+ raw?: string;
16
+ like_count?: number;
17
+ post_number?: number;
18
+ }
19
+
20
+ interface DiscoursePostResponse extends DiscoursePost {
21
+ topic_id?: number;
22
+ }
23
+
24
+ interface DiscourseTopic {
25
+ id?: number;
26
+ title?: string;
27
+ fancy_title?: string;
28
+ posts_count?: number;
29
+ created_at?: string;
30
+ views?: number;
31
+ like_count?: number;
32
+ tags?: string[];
33
+ category_id?: number;
34
+ category_slug?: string;
35
+ category?: { id?: number; name?: string; slug?: string };
36
+ excerpt?: string;
37
+ details?: { created_by?: DiscourseUser };
38
+ post_stream?: { posts?: DiscoursePost[] };
39
+ }
40
+
41
+ const MAX_POSTS = 20;
42
+
43
+ function normalizeBasePath(basePath: string): string {
44
+ if (!basePath || basePath === "/") return "";
45
+ return basePath.replace(/\/$/, "");
46
+ }
47
+
48
+ function parseTopicPath(pathname: string): { basePath: string; topicId: string } | null {
49
+ const match = pathname.match(/^(.*?)(?:\/t\/)(?:[^/]+\/)?(\d+)(?:\.json)?(?:\/|$)/);
50
+ if (!match) return null;
51
+ return { basePath: match[1] ?? "", topicId: match[2] };
52
+ }
53
+
54
+ function parsePostPath(pathname: string): { basePath: string; postId: string } | null {
55
+ const match = pathname.match(/^(.*?)(?:\/posts\/)(\d+)(?:\.json)?(?:\/|$)/);
56
+ if (!match) return null;
57
+ return { basePath: match[1] ?? "", postId: match[2] };
58
+ }
59
+
60
+ function formatAuthor(user?: DiscourseUser | null): string {
61
+ if (!user) return "unknown";
62
+ const name = user.name?.trim();
63
+ const username = user.username?.trim();
64
+ if (name && username && name !== username) return `${name} (@${username})`;
65
+ if (username) return `@${username}`;
66
+ if (name) return name;
67
+ return "unknown";
68
+ }
69
+
70
+ function formatIsoDate(value?: string): string {
71
+ if (!value) return "unknown";
72
+ const date = new Date(value);
73
+ if (Number.isNaN(date.getTime())) return value;
74
+ return date.toISOString().split("T")[0];
75
+ }
76
+
77
+ function formatCategory(topic: DiscourseTopic): string | null {
78
+ const parts: string[] = [];
79
+ const name = topic.category?.name ?? topic.category_slug;
80
+ if (name) parts.push(name);
81
+ const id = topic.category?.id ?? topic.category_id;
82
+ if (id != null) parts.push(`#${id}`);
83
+ return parts.length ? parts.join(" ") : null;
84
+ }
85
+
86
+ function formatPostBody(post: DiscoursePost): string {
87
+ const raw = post.raw?.trim();
88
+ if (raw) return raw;
89
+ const cooked = post.cooked?.trim();
90
+ if (!cooked) return "";
91
+ return htmlToBasicMarkdown(cooked);
92
+ }
93
+
94
+ function buildTopicUrl(baseUrl: string, topicId: string): string {
95
+ const topicUrl = new URL(`${baseUrl}/t/${topicId}.json`);
96
+ topicUrl.searchParams.set("include_raw", "1");
97
+ return topicUrl.toString();
98
+ }
99
+
100
+ function buildPostUrl(baseUrl: string, postId: string): string {
101
+ const postUrl = new URL(`${baseUrl}/posts/${postId}.json`);
102
+ postUrl.searchParams.set("include_raw", "1");
103
+ return postUrl.toString();
104
+ }
105
+
106
+ /**
107
+ * Handle Discourse forum URLs via API
108
+ */
109
+ export const handleDiscourse: SpecialHandler = async (
110
+ url: string,
111
+ timeout: number,
112
+ signal?: AbortSignal,
113
+ ): Promise<RenderResult | null> => {
114
+ try {
115
+ const parsed = new URL(url);
116
+ const topicMatch = parseTopicPath(parsed.pathname);
117
+ const postMatch = topicMatch ? null : parsePostPath(parsed.pathname);
118
+ if (!topicMatch && !postMatch) return null;
119
+
120
+ const basePath = normalizeBasePath(topicMatch?.basePath ?? postMatch?.basePath ?? "");
121
+ const baseUrl = `${parsed.origin}${basePath}`;
122
+
123
+ let requestedPost: DiscoursePost | null = null;
124
+ let topicId = topicMatch?.topicId ?? null;
125
+
126
+ if (!topicId && postMatch) {
127
+ const postResult = await loadPage(buildPostUrl(baseUrl, postMatch.postId), { timeout, signal });
128
+ if (!postResult.ok) return null;
129
+
130
+ let postData: DiscoursePostResponse;
131
+ try {
132
+ postData = JSON.parse(postResult.content) as DiscoursePostResponse;
133
+ } catch {
134
+ return null;
135
+ }
136
+
137
+ if (!postData.topic_id) return null;
138
+ topicId = String(postData.topic_id);
139
+ requestedPost = postData;
140
+ }
141
+
142
+ if (!topicId) return null;
143
+
144
+ const topicResult = await loadPage(buildTopicUrl(baseUrl, topicId), { timeout, signal });
145
+ if (!topicResult.ok) return null;
146
+
147
+ let topic: DiscourseTopic;
148
+ try {
149
+ topic = JSON.parse(topicResult.content) as DiscourseTopic;
150
+ } catch {
151
+ return null;
152
+ }
153
+
154
+ const title = topic.title || topic.fancy_title;
155
+ if (!title) return null;
156
+
157
+ const fetchedAt = new Date().toISOString();
158
+
159
+ const posts: DiscoursePost[] = [...(topic.post_stream?.posts ?? [])];
160
+ if (requestedPost && !posts.some((post) => post.id === requestedPost?.id)) {
161
+ posts.unshift(requestedPost);
162
+ }
163
+
164
+ let md = `# ${title}\n\n`;
165
+
166
+ const metaParts: string[] = [];
167
+ if (topic.id != null) metaParts.push(`**Topic ID:** ${topic.id}`);
168
+ if (topic.posts_count != null) metaParts.push(`**Posts:** ${topic.posts_count}`);
169
+ if (topic.views != null) metaParts.push(`**Views:** ${topic.views}`);
170
+ if (topic.like_count != null) metaParts.push(`**Likes:** ${topic.like_count}`);
171
+ if (metaParts.length) md += `${metaParts.join(" | ")}\n`;
172
+
173
+ const categoryLabel = formatCategory(topic);
174
+ if (categoryLabel) md += `**Category:** ${categoryLabel}\n`;
175
+ if (topic.tags?.length) md += `**Tags:** ${topic.tags.join(", ")}\n`;
176
+
177
+ const createdBy = formatAuthor(topic.details?.created_by ?? null);
178
+ if (createdBy !== "unknown" || topic.created_at) {
179
+ md += `**Created by:** ${createdBy} - ${formatIsoDate(topic.created_at)}\n`;
180
+ }
181
+
182
+ md += "\n";
183
+
184
+ const description = topic.excerpt
185
+ ? htmlToBasicMarkdown(topic.excerpt)
186
+ : posts.length
187
+ ? formatPostBody(posts[0])
188
+ : "";
189
+ if (description) {
190
+ md += `## Description\n\n${description}\n\n`;
191
+ }
192
+
193
+ if (posts.length) {
194
+ md += "## Posts\n\n";
195
+ for (const post of posts.slice(0, MAX_POSTS)) {
196
+ const author = formatAuthor({ name: post.name, username: post.username });
197
+ const date = formatIsoDate(post.created_at);
198
+ const likes = post.like_count ?? 0;
199
+ const content = formatPostBody(post);
200
+ const postLabel = post.post_number != null ? `Post ${post.post_number}` : `Post ${post.id}`;
201
+
202
+ md += `### ${postLabel} - ${author} - ${date} - Likes: ${likes}\n\n`;
203
+ md += content ? `${content}\n\n---\n\n` : "_No content available._\n\n---\n\n";
204
+ }
205
+ }
206
+
207
+ const output = finalizeOutput(md);
208
+ return {
209
+ url,
210
+ finalUrl: url,
211
+ contentType: "text/markdown",
212
+ method: "discourse-api",
213
+ content: output.content,
214
+ fetchedAt,
215
+ truncated: output.truncated,
216
+ notes: ["Fetched via Discourse API"],
217
+ };
218
+ } catch {}
219
+
220
+ return null;
221
+ };
@@ -0,0 +1,160 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface DockerHubRepo {
5
+ name: string;
6
+ namespace: string;
7
+ description?: string;
8
+ star_count?: number;
9
+ pull_count?: number;
10
+ last_updated?: string;
11
+ is_official?: boolean;
12
+ is_automated?: boolean;
13
+ user?: string;
14
+ }
15
+
16
+ interface DockerHubTag {
17
+ name: string;
18
+ last_updated?: string;
19
+ full_size?: number;
20
+ digest?: string;
21
+ images?: Array<{
22
+ architecture?: string;
23
+ os?: string;
24
+ size?: number;
25
+ }>;
26
+ }
27
+
28
+ interface DockerHubTagsResponse {
29
+ results?: DockerHubTag[];
30
+ }
31
+
32
+ function formatSize(bytes: number): string {
33
+ if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)}GB`;
34
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}MB`;
35
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)}KB`;
36
+ return `${bytes}B`;
37
+ }
38
+
39
+ /**
40
+ * Handle Docker Hub URLs via API
41
+ */
42
+ export const handleDockerHub: SpecialHandler = async (
43
+ url: string,
44
+ timeout: number,
45
+ signal?: AbortSignal,
46
+ ): Promise<RenderResult | null> => {
47
+ try {
48
+ const parsed = new URL(url);
49
+ if (!parsed.hostname.includes("hub.docker.com")) return null;
50
+
51
+ let namespace: string;
52
+ let repository: string;
53
+
54
+ // Official images: /_ /{image}
55
+ const officialMatch = parsed.pathname.match(/^\/_\/([^/]+)/);
56
+ if (officialMatch) {
57
+ namespace = "library";
58
+ repository = officialMatch[1];
59
+ } else {
60
+ // Regular images: /r/{namespace}/{repository}
61
+ const repoMatch = parsed.pathname.match(/^\/r\/([^/]+)\/([^/]+)/);
62
+ if (!repoMatch) return null;
63
+ namespace = repoMatch[1];
64
+ repository = repoMatch[2];
65
+ }
66
+
67
+ const fetchedAt = new Date().toISOString();
68
+
69
+ // Fetch repository info and tags in parallel
70
+ const repoUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/`;
71
+ const tagsUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/?page_size=10`;
72
+
73
+ const [repoResult, tagsResult] = await Promise.all([
74
+ loadPage(repoUrl, { timeout, headers: { Accept: "application/json" }, signal }),
75
+ loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" }, signal }),
76
+ ]);
77
+
78
+ if (!repoResult.ok) return null;
79
+
80
+ let repo: DockerHubRepo;
81
+ try {
82
+ repo = JSON.parse(repoResult.content);
83
+ } catch {
84
+ return null;
85
+ }
86
+
87
+ // Parse tags
88
+ let tags: DockerHubTag[] = [];
89
+ if (tagsResult.ok) {
90
+ try {
91
+ const tagsData = JSON.parse(tagsResult.content) as DockerHubTagsResponse;
92
+ tags = tagsData.results ?? [];
93
+ } catch {}
94
+ }
95
+
96
+ // Build markdown output
97
+ const fullName = namespace === "library" ? repo.name : `${namespace}/${repo.name}`;
98
+ let md = `# ${fullName}\n\n`;
99
+
100
+ if (repo.description) {
101
+ md += `${repo.description}\n\n`;
102
+ }
103
+
104
+ // Stats line
105
+ const stats: string[] = [];
106
+ if (repo.pull_count !== undefined) stats.push(`**Pulls:** ${formatCount(repo.pull_count)}`);
107
+ if (repo.star_count !== undefined) stats.push(`**Stars:** ${formatCount(repo.star_count)}`);
108
+ if (repo.is_official) stats.push("**Official Image**");
109
+ if (repo.is_automated) stats.push("**Automated Build**");
110
+ if (stats.length > 0) {
111
+ md += `${stats.join(" · ")}\n`;
112
+ }
113
+
114
+ if (repo.last_updated) {
115
+ const date = new Date(repo.last_updated);
116
+ md += `**Last Updated:** ${date.toISOString().split("T")[0]}\n`;
117
+ }
118
+
119
+ md += "\n";
120
+
121
+ // Docker pull command
122
+ md += "## Quick Start\n\n";
123
+ md += "```bash\n";
124
+ md += `docker pull ${fullName}\n`;
125
+ md += "```\n\n";
126
+
127
+ // Tags
128
+ if (tags.length > 0) {
129
+ md += "## Recent Tags\n\n";
130
+ md += "| Tag | Size | Architectures | Updated |\n";
131
+ md += "|-----|------|---------------|--------|\n";
132
+
133
+ for (const tag of tags) {
134
+ const size = tag.full_size ? formatSize(tag.full_size) : "-";
135
+ const archs =
136
+ tag.images
137
+ ?.map((img) => img.architecture)
138
+ .filter(Boolean)
139
+ .join(", ") || "-";
140
+ const updated = tag.last_updated ? new Date(tag.last_updated).toISOString().split("T")[0] : "-";
141
+ md += `| \`${tag.name}\` | ${size} | ${archs} | ${updated} |\n`;
142
+ }
143
+ md += "\n";
144
+ }
145
+
146
+ const output = finalizeOutput(md);
147
+ return {
148
+ url,
149
+ finalUrl: url,
150
+ contentType: "text/markdown",
151
+ method: "dockerhub",
152
+ content: output.content,
153
+ fetchedAt,
154
+ truncated: output.truncated,
155
+ notes: ["Fetched via Docker Hub API"],
156
+ };
157
+ } catch {}
158
+
159
+ return null;
160
+ };