@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,273 @@
1
+ /**
2
+ * MusicBrainz URL handler for artists, releases, and recordings
3
+ */
4
+
5
+ import type { RenderResult, SpecialHandler } from "./types";
6
+ import { finalizeOutput, loadPage } from "./types";
7
+
8
+ type MusicBrainzEntity = "artist" | "release" | "recording";
9
+
10
+ interface MusicBrainzLifeSpan {
11
+ begin?: string;
12
+ end?: string;
13
+ ended?: boolean;
14
+ }
15
+
16
+ interface MusicBrainzArtist {
17
+ id: string;
18
+ name: string;
19
+ type?: string;
20
+ country?: string;
21
+ "life-span"?: MusicBrainzLifeSpan;
22
+ }
23
+
24
+ interface MusicBrainzArtistCredit {
25
+ name?: string;
26
+ artist?: {
27
+ id?: string;
28
+ name: string;
29
+ };
30
+ }
31
+
32
+ interface MusicBrainzRecording {
33
+ id: string;
34
+ title: string;
35
+ length?: number;
36
+ "artist-credit"?: MusicBrainzArtistCredit[];
37
+ }
38
+
39
+ interface MusicBrainzTrack {
40
+ id?: string;
41
+ title?: string;
42
+ number?: string;
43
+ position?: number;
44
+ length?: number;
45
+ recording?: {
46
+ title?: string;
47
+ length?: number;
48
+ };
49
+ }
50
+
51
+ interface MusicBrainzMedium {
52
+ position?: number;
53
+ format?: string;
54
+ "track-count"?: number;
55
+ tracks?: MusicBrainzTrack[];
56
+ }
57
+
58
+ interface MusicBrainzRelease {
59
+ id: string;
60
+ title: string;
61
+ "track-count"?: number;
62
+ media?: MusicBrainzMedium[];
63
+ }
64
+
65
+ const MUSICBRAINZ_HOSTS = new Set(["musicbrainz.org", "www.musicbrainz.org"]);
66
+ const USER_AGENT = "omp-web-fetch/1.0 (https://github.com/anthropics)";
67
+ const MAX_TRACKS = 50;
68
+
69
+ function parseEntity(url: URL): { entity: MusicBrainzEntity; mbid: string } | null {
70
+ if (!MUSICBRAINZ_HOSTS.has(url.hostname)) return null;
71
+
72
+ const parts = url.pathname.split("/").filter(Boolean);
73
+ if (parts.length < 2) return null;
74
+
75
+ const entity = parts[0] as MusicBrainzEntity;
76
+ if (entity !== "artist" && entity !== "release" && entity !== "recording") return null;
77
+
78
+ const mbid = parts[1];
79
+ if (!/^[0-9a-fA-F-]{36}$/.test(mbid)) return null;
80
+
81
+ return { entity, mbid };
82
+ }
83
+
84
+ async function fetchJson<T>(apiUrl: string, timeout: number, signal?: AbortSignal): Promise<T | null> {
85
+ const result = await loadPage(apiUrl, {
86
+ timeout,
87
+ signal,
88
+ headers: {
89
+ "User-Agent": USER_AGENT,
90
+ Accept: "application/json",
91
+ },
92
+ });
93
+
94
+ if (!result.ok) return null;
95
+
96
+ try {
97
+ return JSON.parse(result.content) as T;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function formatLifeSpan(life: MusicBrainzLifeSpan | undefined): string | null {
104
+ if (!life) return null;
105
+
106
+ const begin = life.begin?.trim();
107
+ const end = life.end?.trim();
108
+
109
+ if (begin && end) return `${begin} - ${end}`;
110
+ if (begin && !end) return `${begin} - ${life.ended ? "ended" : "present"}`;
111
+ if (!begin && end) return `? - ${end}`;
112
+ if (life.ended !== undefined) return life.ended ? "ended" : "present";
113
+
114
+ return null;
115
+ }
116
+
117
+ function formatDurationMs(lengthMs: number | undefined): string | null {
118
+ if (!lengthMs || lengthMs <= 0) return null;
119
+
120
+ const totalSeconds = Math.round(lengthMs / 1000);
121
+ const hours = Math.floor(totalSeconds / 3600);
122
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
123
+ const seconds = totalSeconds % 60;
124
+
125
+ if (hours > 0) {
126
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
127
+ }
128
+
129
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
130
+ }
131
+
132
+ function formatArtistCredits(credits: MusicBrainzArtistCredit[] | undefined): string | null {
133
+ if (!credits?.length) return null;
134
+
135
+ const names = credits
136
+ .map((credit) => credit.name || credit.artist?.name)
137
+ .filter((name): name is string => Boolean(name));
138
+
139
+ if (!names.length) return null;
140
+ return names.join(", ");
141
+ }
142
+
143
+ function formatTrack(track: MusicBrainzTrack): string {
144
+ const title = track.title || track.recording?.title || "Untitled";
145
+ const duration = formatDurationMs(track.length ?? track.recording?.length);
146
+ const number = track.number || (track.position ? String(track.position) : null);
147
+
148
+ const prefix = number ? `${number}. ` : "- ";
149
+ let line = `${prefix}${title}`;
150
+ if (duration) line += ` (${duration})`;
151
+ return line;
152
+ }
153
+
154
+ function buildMediumLabel(medium: MusicBrainzMedium, includePosition: boolean): string | null {
155
+ const parts: string[] = [];
156
+ if (includePosition && medium.position) parts.push(`Disc ${medium.position}`);
157
+ if (medium.format) parts.push(medium.format);
158
+ return parts.length ? parts.join(" - ") : null;
159
+ }
160
+
161
+ function buildArtistMarkdown(artist: MusicBrainzArtist): string {
162
+ let md = `# ${artist.name}\n\n`;
163
+ const meta: string[] = [];
164
+
165
+ if (artist.type) meta.push(`**Type**: ${artist.type}`);
166
+ if (artist.country) meta.push(`**Country**: ${artist.country}`);
167
+
168
+ const lifeSpan = formatLifeSpan(artist["life-span"]);
169
+ if (lifeSpan) meta.push(`**Life Span**: ${lifeSpan}`);
170
+
171
+ if (meta.length) md += `${meta.join("\n")}\n`;
172
+
173
+ return md;
174
+ }
175
+
176
+ function buildReleaseMarkdown(release: MusicBrainzRelease): string {
177
+ let md = `# ${release.title}\n\n`;
178
+
179
+ const media = release.media ?? [];
180
+ const totalTracks =
181
+ release["track-count"] ??
182
+ media.reduce((sum, medium) => sum + (medium["track-count"] ?? medium.tracks?.length ?? 0), 0);
183
+
184
+ if (totalTracks) {
185
+ md += `**Tracks**: ${totalTracks}\n\n`;
186
+ }
187
+
188
+ if (media.length) {
189
+ md += "## Tracks\n\n";
190
+ const includePosition = media.length > 1;
191
+
192
+ for (const medium of media) {
193
+ const label = buildMediumLabel(medium, includePosition);
194
+ if (label) md += `### ${label}\n\n`;
195
+
196
+ const tracks = medium.tracks ?? [];
197
+ if (tracks.length) {
198
+ const lines = tracks.slice(0, MAX_TRACKS).map(formatTrack).join("\n");
199
+ md += `${lines}\n\n`;
200
+
201
+ if (tracks.length > MAX_TRACKS) {
202
+ md += `_Showing first ${MAX_TRACKS} of ${tracks.length} tracks._\n\n`;
203
+ }
204
+ } else if (medium["track-count"]) {
205
+ md += `- ${medium["track-count"]} tracks (details unavailable)\n\n`;
206
+ }
207
+ }
208
+ }
209
+
210
+ return md;
211
+ }
212
+
213
+ function buildRecordingMarkdown(recording: MusicBrainzRecording): string {
214
+ let md = `# ${recording.title}\n\n`;
215
+ const meta: string[] = [];
216
+
217
+ const artists = formatArtistCredits(recording["artist-credit"]);
218
+ if (artists) meta.push(`**Artists**: ${artists}`);
219
+
220
+ const length = formatDurationMs(recording.length);
221
+ if (length) meta.push(`**Length**: ${length}`);
222
+
223
+ if (meta.length) md += `${meta.join("\n")}\n`;
224
+
225
+ return md;
226
+ }
227
+
228
+ export const handleMusicBrainz: SpecialHandler = async (
229
+ url: string,
230
+ timeout: number,
231
+ signal?: AbortSignal,
232
+ ): Promise<RenderResult | null> => {
233
+ try {
234
+ const parsed = new URL(url);
235
+ const parsedEntity = parseEntity(parsed);
236
+ if (!parsedEntity) return null;
237
+
238
+ const { entity, mbid } = parsedEntity;
239
+ const fetchedAt = new Date().toISOString();
240
+ let md = "";
241
+
242
+ if (entity === "artist") {
243
+ const apiUrl = `https://musicbrainz.org/ws/2/artist/${mbid}?fmt=json&inc=url-rels`;
244
+ const artist = await fetchJson<MusicBrainzArtist>(apiUrl, timeout, signal);
245
+ if (!artist) return null;
246
+ md = buildArtistMarkdown(artist);
247
+ } else if (entity === "release") {
248
+ const apiUrl = `https://musicbrainz.org/ws/2/release/${mbid}?fmt=json&inc=recordings`;
249
+ const release = await fetchJson<MusicBrainzRelease>(apiUrl, timeout, signal);
250
+ if (!release) return null;
251
+ md = buildReleaseMarkdown(release);
252
+ } else {
253
+ const apiUrl = `https://musicbrainz.org/ws/2/recording/${mbid}?fmt=json`;
254
+ const recording = await fetchJson<MusicBrainzRecording>(apiUrl, timeout, signal);
255
+ if (!recording) return null;
256
+ md = buildRecordingMarkdown(recording);
257
+ }
258
+
259
+ const output = finalizeOutput(md);
260
+ return {
261
+ url,
262
+ finalUrl: url,
263
+ contentType: "text/markdown",
264
+ method: "musicbrainz-api",
265
+ content: output.content,
266
+ fetchedAt,
267
+ truncated: output.truncated,
268
+ notes: ["Fetched via MusicBrainz API"],
269
+ };
270
+ } catch {}
271
+
272
+ return null;
273
+ };
@@ -0,0 +1,114 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ /**
5
+ * Handle npm URLs via registry API
6
+ */
7
+ export const handleNpm: SpecialHandler = async (
8
+ url: string,
9
+ timeout: number,
10
+ signal?: AbortSignal,
11
+ ): Promise<RenderResult | null> => {
12
+ try {
13
+ const parsed = new URL(url);
14
+ if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return null;
15
+
16
+ // Extract package name from /package/[scope/]name
17
+ const match = parsed.pathname.match(/^\/package\/(.+?)(?:\/|$)/);
18
+ if (!match) return null;
19
+
20
+ let packageName = decodeURIComponent(match[1]);
21
+ // Handle scoped packages: /package/@scope/name
22
+ if (packageName.startsWith("@")) {
23
+ const scopeMatch = parsed.pathname.match(/^\/package\/(@[^/]+\/[^/]+)/);
24
+ if (scopeMatch) packageName = decodeURIComponent(scopeMatch[1]);
25
+ }
26
+
27
+ const fetchedAt = new Date().toISOString();
28
+
29
+ // Fetch from npm registry - use /latest endpoint for smaller response
30
+ const latestUrl = `https://registry.npmjs.org/${packageName}/latest`;
31
+ const downloadsUrl = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`;
32
+
33
+ // Fetch package info and download stats in parallel
34
+ const [result, downloadsResult] = await Promise.all([
35
+ loadPage(latestUrl, { timeout, signal }),
36
+ loadPage(downloadsUrl, { timeout: Math.min(timeout, 5), signal }),
37
+ ]);
38
+
39
+ if (!result.ok) return null;
40
+
41
+ // Parse download stats
42
+ let weeklyDownloads: number | null = null;
43
+ if (downloadsResult.ok) {
44
+ try {
45
+ const dlData = JSON.parse(downloadsResult.content) as { downloads?: number };
46
+ weeklyDownloads = dlData.downloads ?? null;
47
+ } catch {}
48
+ }
49
+
50
+ let pkg: {
51
+ name: string;
52
+ version: string;
53
+ description?: string;
54
+ license?: string | { type: string };
55
+ homepage?: string;
56
+ repository?: { url: string } | string;
57
+ keywords?: string[];
58
+ maintainers?: Array<{ name: string }>;
59
+ dependencies?: Record<string, string>;
60
+ readme?: string;
61
+ };
62
+
63
+ try {
64
+ pkg = JSON.parse(result.content);
65
+ } catch {
66
+ return null; // JSON parse failed (truncated response)
67
+ }
68
+
69
+ let md = `# ${pkg.name}\n\n`;
70
+ if (pkg.description) md += `${pkg.description}\n\n`;
71
+
72
+ md += `**Latest:** ${pkg.version || "unknown"}`;
73
+ if (pkg.license) {
74
+ const license = typeof pkg.license === "string" ? pkg.license : (pkg.license.type ?? String(pkg.license));
75
+ md += ` · **License:** ${license}`;
76
+ }
77
+ md += "\n";
78
+ if (weeklyDownloads !== null) {
79
+ md += `**Weekly Downloads:** ${formatCount(weeklyDownloads)}\n`;
80
+ }
81
+ md += "\n";
82
+
83
+ if (pkg.homepage) md += `**Homepage:** ${pkg.homepage}\n`;
84
+ const repoUrl = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url;
85
+ if (repoUrl) md += `**Repository:** ${repoUrl.replace(/^git\+/, "").replace(/\.git$/, "")}\n`;
86
+ if (pkg.keywords?.length) md += `**Keywords:** ${pkg.keywords.join(", ")}\n`;
87
+ if (pkg.maintainers?.length) md += `**Maintainers:** ${pkg.maintainers.map((m) => m.name).join(", ")}\n`;
88
+
89
+ if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
90
+ md += `\n## Dependencies\n\n`;
91
+ for (const [dep, version] of Object.entries(pkg.dependencies)) {
92
+ md += `- ${dep}: ${version}\n`;
93
+ }
94
+ }
95
+
96
+ if (pkg.readme) {
97
+ md += `\n---\n\n## README\n\n${pkg.readme}\n`;
98
+ }
99
+
100
+ const output = finalizeOutput(md);
101
+ return {
102
+ url,
103
+ finalUrl: url,
104
+ contentType: "text/markdown",
105
+ method: "npm",
106
+ content: output.content,
107
+ fetchedAt,
108
+ truncated: output.truncated,
109
+ notes: ["Fetched via npm registry"],
110
+ };
111
+ } catch {}
112
+
113
+ return null;
114
+ };
@@ -0,0 +1,205 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface NuGetCatalogEntry {
5
+ id: string;
6
+ version: string;
7
+ description?: string;
8
+ authors?: string;
9
+ projectUrl?: string;
10
+ licenseUrl?: string;
11
+ licenseExpression?: string;
12
+ tags?: string[];
13
+ dependencyGroups?: Array<{
14
+ targetFramework?: string;
15
+ dependencies?: Array<{
16
+ id: string;
17
+ range: string;
18
+ }>;
19
+ }>;
20
+ published?: string;
21
+ }
22
+
23
+ interface NuGetRegistrationItem {
24
+ catalogEntry: NuGetCatalogEntry;
25
+ packageContent?: string;
26
+ }
27
+
28
+ interface NuGetRegistrationPage {
29
+ items?: NuGetRegistrationItem[];
30
+ "@id"?: string;
31
+ }
32
+
33
+ interface NuGetRegistrationIndex {
34
+ items: NuGetRegistrationPage[];
35
+ }
36
+
37
+ /**
38
+ * Handle NuGet URLs via API
39
+ */
40
+ export const handleNuGet: SpecialHandler = async (
41
+ url: string,
42
+ timeout: number,
43
+ signal?: AbortSignal,
44
+ ): Promise<RenderResult | null> => {
45
+ try {
46
+ const parsed = new URL(url);
47
+ if (parsed.hostname !== "www.nuget.org" && parsed.hostname !== "nuget.org") return null;
48
+
49
+ // Extract package name and optional version from /packages/name or /packages/name/version
50
+ const match = parsed.pathname.match(/^\/packages\/([^/]+)(?:\/([^/]+))?/i);
51
+ if (!match) return null;
52
+
53
+ const packageName = decodeURIComponent(match[1]);
54
+ const requestedVersion = match[2] ? decodeURIComponent(match[2]) : null;
55
+ const fetchedAt = new Date().toISOString();
56
+
57
+ // Fetch from NuGet registration API (package name must be lowercase)
58
+ const apiUrl = `https://api.nuget.org/v3/registration5-gz-semver2/${packageName.toLowerCase()}/index.json`;
59
+ const result = await loadPage(apiUrl, { timeout, signal });
60
+
61
+ if (!result.ok) return null;
62
+
63
+ let index: NuGetRegistrationIndex;
64
+ try {
65
+ index = JSON.parse(result.content);
66
+ } catch {
67
+ return null;
68
+ }
69
+
70
+ if (!index.items?.length) return null;
71
+
72
+ // Get the latest page (or fetch it if not inlined)
73
+ let latestPage = index.items[index.items.length - 1];
74
+
75
+ // If items are not inlined, fetch the page
76
+ if (!latestPage.items && latestPage["@id"]) {
77
+ const pageResult = await loadPage(latestPage["@id"], { timeout, signal });
78
+ if (!pageResult.ok) return null;
79
+ try {
80
+ latestPage = JSON.parse(pageResult.content);
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ if (!latestPage.items?.length) return null;
87
+
88
+ // Find the requested version or get the latest
89
+ let targetEntry: NuGetCatalogEntry | null = null;
90
+
91
+ if (requestedVersion) {
92
+ // Search all pages for the requested version
93
+ for (const page of index.items) {
94
+ let pageItems = page.items;
95
+
96
+ // Fetch page if items not inlined
97
+ if (!pageItems && page["@id"]) {
98
+ const pageResult = await loadPage(page["@id"], { timeout: Math.min(timeout, 5), signal });
99
+ if (pageResult.ok) {
100
+ try {
101
+ const fetchedPage = JSON.parse(pageResult.content) as NuGetRegistrationPage;
102
+ pageItems = fetchedPage.items;
103
+ } catch {}
104
+ }
105
+ }
106
+
107
+ if (pageItems) {
108
+ const found = pageItems.find(
109
+ (item) => item.catalogEntry.version.toLowerCase() === requestedVersion.toLowerCase(),
110
+ );
111
+ if (found) {
112
+ targetEntry = found.catalogEntry;
113
+ break;
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ // If no specific version requested or not found, use the latest
120
+ if (!targetEntry) {
121
+ const latestItem = latestPage.items[latestPage.items.length - 1];
122
+ targetEntry = latestItem.catalogEntry;
123
+ }
124
+
125
+ // Fetch download stats via search API
126
+ let totalDownloads: number | null = null;
127
+ const searchUrl = `https://api.nuget.org/v3/query?q=packageid:${encodeURIComponent(packageName)}&prerelease=true&take=1`;
128
+ const searchResult = await loadPage(searchUrl, { timeout: Math.min(timeout, 5), signal });
129
+
130
+ if (searchResult.ok) {
131
+ try {
132
+ const searchData = JSON.parse(searchResult.content) as {
133
+ data?: Array<{ totalDownloads?: number }>;
134
+ };
135
+ totalDownloads = searchData.data?.[0]?.totalDownloads ?? null;
136
+ } catch {}
137
+ }
138
+
139
+ // Format markdown output
140
+ let md = `# ${targetEntry.id}\n\n`;
141
+ if (targetEntry.description) md += `${targetEntry.description}\n\n`;
142
+
143
+ md += `**Version:** ${targetEntry.version}`;
144
+ if (targetEntry.licenseExpression) {
145
+ md += ` · **License:** ${targetEntry.licenseExpression}`;
146
+ } else if (targetEntry.licenseUrl) {
147
+ md += ` · **License:** [View](${targetEntry.licenseUrl})`;
148
+ }
149
+ md += "\n";
150
+
151
+ if (totalDownloads !== null) {
152
+ md += `**Total Downloads:** ${formatCount(totalDownloads)}\n`;
153
+ }
154
+
155
+ if (targetEntry.authors) md += `**Authors:** ${targetEntry.authors}\n`;
156
+ if (targetEntry.projectUrl) md += `**Project URL:** ${targetEntry.projectUrl}\n`;
157
+ if (targetEntry.tags?.length) md += `**Tags:** ${targetEntry.tags.join(", ")}\n`;
158
+ if (targetEntry.published) {
159
+ const pubDate = targetEntry.published.split("T")[0];
160
+ md += `**Published:** ${pubDate}\n`;
161
+ }
162
+
163
+ // Show dependencies by target framework
164
+ if (targetEntry.dependencyGroups?.length) {
165
+ const hasAnyDeps = targetEntry.dependencyGroups.some((g) => g.dependencies?.length);
166
+ if (hasAnyDeps) {
167
+ md += `\n## Dependencies\n\n`;
168
+ for (const group of targetEntry.dependencyGroups) {
169
+ if (!group.dependencies?.length) continue;
170
+ const framework = group.targetFramework || "All Frameworks";
171
+ md += `### ${framework}\n\n`;
172
+ for (const dep of group.dependencies) {
173
+ md += `- ${dep.id} (${dep.range})\n`;
174
+ }
175
+ md += "\n";
176
+ }
177
+ }
178
+ }
179
+
180
+ // Show recent versions from the latest page
181
+ if (latestPage.items && latestPage.items.length > 1) {
182
+ md += `## Recent Versions\n\n`;
183
+ const recentVersions = latestPage.items.slice(-5).reverse();
184
+ for (const item of recentVersions) {
185
+ const entry = item.catalogEntry;
186
+ const pubDate = entry.published?.split("T")[0] || "unknown";
187
+ md += `- **${entry.version}** (${pubDate})\n`;
188
+ }
189
+ }
190
+
191
+ const output = finalizeOutput(md);
192
+ return {
193
+ url,
194
+ finalUrl: url,
195
+ contentType: "text/markdown",
196
+ method: "nuget",
197
+ content: output.content,
198
+ fetchedAt,
199
+ truncated: output.truncated,
200
+ notes: ["Fetched via NuGet API"],
201
+ };
202
+ } catch {}
203
+
204
+ return null;
205
+ };