@oh-my-pi/pi-coding-agent 3.24.0 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +4 -4
  3. package/src/core/custom-commands/bundled/wt/index.ts +3 -0
  4. package/src/core/sdk.ts +7 -0
  5. package/src/core/tools/complete.ts +129 -0
  6. package/src/core/tools/index.test.ts +9 -1
  7. package/src/core/tools/index.ts +18 -5
  8. package/src/core/tools/jtd-to-json-schema.ts +252 -0
  9. package/src/core/tools/output.ts +125 -14
  10. package/src/core/tools/read.ts +4 -4
  11. package/src/core/tools/task/artifacts.ts +6 -9
  12. package/src/core/tools/task/executor.ts +189 -24
  13. package/src/core/tools/task/index.ts +23 -18
  14. package/src/core/tools/task/name-generator.ts +1577 -0
  15. package/src/core/tools/task/render.ts +137 -8
  16. package/src/core/tools/task/types.ts +26 -5
  17. package/src/core/tools/task/worker-protocol.ts +1 -0
  18. package/src/core/tools/task/worker.ts +136 -14
  19. package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
  20. package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
  21. package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
  22. package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
  23. package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
  24. package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
  25. package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
  26. package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
  27. package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
  28. package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
  29. package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
  30. package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
  31. package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
  32. package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
  33. package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
  34. package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
  35. package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
  36. package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
  37. package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
  38. package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
  39. package/src/core/tools/web-fetch-handlers/github.ts +424 -0
  40. package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
  41. package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
  42. package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
  43. package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
  44. package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
  45. package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
  46. package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
  47. package/src/core/tools/web-fetch-handlers/index.ts +69 -0
  48. package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
  49. package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
  50. package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
  51. package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
  52. package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
  53. package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
  54. package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
  55. package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
  56. package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
  57. package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
  58. package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
  59. package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
  60. package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
  61. package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
  62. package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
  63. package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
  64. package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
  65. package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
  66. package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
  67. package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
  68. package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
  69. package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
  70. package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
  71. package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
  72. package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
  73. package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
  74. package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
  75. package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
  76. package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
  77. package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
  78. package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
  79. package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
  80. package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
  81. package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
  82. package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
  83. package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
  84. package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
  85. package/src/core/tools/web-fetch-handlers/types.ts +163 -0
  86. package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
  87. package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
  88. package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
  89. package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
  90. package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
  91. package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
  92. package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
  93. package/src/core/tools/web-fetch.ts +152 -1324
  94. package/src/prompts/task.md +14 -50
  95. package/src/prompts/tools/output.md +2 -1
  96. package/src/prompts/tools/task.md +3 -1
  97. package/src/utils/tools-manager.ts +110 -8
@@ -0,0 +1,174 @@
1
+ import type { SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface MDNSection {
5
+ type: string;
6
+ value: {
7
+ id?: string;
8
+ title?: string;
9
+ content?: string;
10
+ isH3?: boolean;
11
+ code?: string;
12
+ language?: string;
13
+ items?: Array<{ term: string; description: string }>;
14
+ rows?: string[][];
15
+ };
16
+ }
17
+
18
+ interface MDNDoc {
19
+ doc: {
20
+ title: string;
21
+ summary: string;
22
+ mdn_url: string;
23
+ body: MDNSection[];
24
+ browserCompat?: unknown;
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Convert MDN body sections to markdown
30
+ */
31
+ function convertMDNBody(sections: MDNSection[]): string {
32
+ const parts: string[] = [];
33
+
34
+ for (const section of sections) {
35
+ const { type, value } = section;
36
+
37
+ switch (type) {
38
+ case "prose":
39
+ if (value.content) {
40
+ const markdown = htmlToBasicMarkdown(value.content);
41
+ if (value.title) {
42
+ const level = value.isH3 ? "###" : "##";
43
+ parts.push(`${level} ${value.title}\n\n${markdown}`);
44
+ } else {
45
+ parts.push(markdown);
46
+ }
47
+ }
48
+ break;
49
+
50
+ case "browser_compatibility":
51
+ if (value.title) {
52
+ parts.push(`## ${value.title}\n\n(See browser compatibility data at MDN)`);
53
+ }
54
+ break;
55
+
56
+ case "specifications":
57
+ if (value.title) {
58
+ parts.push(`## ${value.title}\n\n(See specifications at MDN)`);
59
+ }
60
+ break;
61
+
62
+ case "code_example":
63
+ if (value.title) {
64
+ parts.push(`### ${value.title}`);
65
+ }
66
+ if (value.code) {
67
+ const lang = value.language || "";
68
+ parts.push(`\`\`\`${lang}\n${value.code}\n\`\`\``);
69
+ }
70
+ break;
71
+
72
+ case "definition_list":
73
+ if (value.items) {
74
+ for (const item of value.items) {
75
+ parts.push(`**${item.term}**`);
76
+ const desc = htmlToBasicMarkdown(item.description);
77
+ parts.push(desc);
78
+ }
79
+ }
80
+ break;
81
+
82
+ case "table":
83
+ if (value.rows && value.rows.length > 0) {
84
+ // Simple markdown table
85
+ const header = value.rows[0].map((cell) => htmlToBasicMarkdown(cell)).join(" | ");
86
+ const separator = value.rows[0].map(() => "---").join(" | ");
87
+ const bodyRows = value.rows
88
+ .slice(1)
89
+ .map((row) => row.map((cell) => htmlToBasicMarkdown(cell)).join(" | "));
90
+
91
+ parts.push(`| ${header} |`);
92
+ parts.push(`| ${separator} |`);
93
+ for (const row of bodyRows) {
94
+ parts.push(`| ${row} |`);
95
+ }
96
+ }
97
+ break;
98
+
99
+ default:
100
+ // Skip unknown types
101
+ break;
102
+ }
103
+ }
104
+
105
+ return parts.join("\n\n");
106
+ }
107
+
108
+ export const handleMDN: SpecialHandler = async (url: string, timeout: number) => {
109
+ const urlObj = new URL(url);
110
+
111
+ // Only handle developer.mozilla.org
112
+ if (!urlObj.hostname.includes("developer.mozilla.org")) {
113
+ return null;
114
+ }
115
+
116
+ // Only handle docs paths
117
+ if (!urlObj.pathname.includes("/docs/")) {
118
+ return null;
119
+ }
120
+
121
+ const notes: string[] = [];
122
+
123
+ // Construct JSON API URL
124
+ const jsonUrl = url.replace(/\/?$/, "/index.json");
125
+
126
+ try {
127
+ const result = await loadPage(jsonUrl, { timeout, headers: { Accept: "application/json" } });
128
+
129
+ if (!result.ok) {
130
+ notes.push(`Failed to fetch MDN JSON API (status ${result.status || "unknown"})`);
131
+ return null;
132
+ }
133
+
134
+ const data: MDNDoc = JSON.parse(result.content);
135
+ const { doc } = data;
136
+
137
+ if (!doc || !doc.title) {
138
+ notes.push("Invalid MDN JSON structure");
139
+ return null;
140
+ }
141
+
142
+ // Build markdown content
143
+ const parts: string[] = [];
144
+
145
+ parts.push(`# ${doc.title}`);
146
+
147
+ if (doc.summary) {
148
+ const summary = htmlToBasicMarkdown(doc.summary);
149
+ parts.push(summary);
150
+ }
151
+
152
+ if (doc.body && doc.body.length > 0) {
153
+ const bodyMarkdown = convertMDNBody(doc.body);
154
+ parts.push(bodyMarkdown);
155
+ }
156
+
157
+ const rawContent = parts.join("\n\n");
158
+ const { content, truncated } = finalizeOutput(rawContent);
159
+
160
+ return {
161
+ url,
162
+ finalUrl: doc.mdn_url || result.finalUrl,
163
+ contentType: "text/markdown",
164
+ method: "mdn",
165
+ content,
166
+ fetchedAt: new Date().toISOString(),
167
+ truncated,
168
+ notes,
169
+ };
170
+ } catch (err) {
171
+ notes.push(`MDN handler error: ${err instanceof Error ? err.message : String(err)}`);
172
+ return null;
173
+ }
174
+ };
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleHuggingFace } from "./huggingface";
3
+ import { handleSpotify } from "./spotify";
4
+ import { handleVimeo } from "./vimeo";
5
+
6
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
7
+
8
+ describe.skipIf(SKIP)("handleVimeo", () => {
9
+ it("returns null for non-Vimeo URLs", async () => {
10
+ const result = await handleVimeo("https://example.com", 10);
11
+ expect(result).toBeNull();
12
+ });
13
+
14
+ it("returns null for invalid Vimeo URLs", async () => {
15
+ const result = await handleVimeo("https://vimeo.com/invalid", 10);
16
+ expect(result).toBeNull();
17
+ });
18
+
19
+ it("fetches video metadata via oEmbed", async () => {
20
+ const result = await handleVimeo("https://vimeo.com/1084537", 20);
21
+ expect(result).not.toBeNull();
22
+ expect(result?.method).toBe("vimeo");
23
+ expect(result?.contentType).toBe("text/markdown");
24
+ expect(result?.content).toContain("Video ID");
25
+ expect(result?.notes).toContain("Fetched via Vimeo oEmbed API");
26
+ });
27
+
28
+ it("handles player.vimeo.com URLs", async () => {
29
+ const result = await handleVimeo("https://player.vimeo.com/video/1084537", 20);
30
+ expect(result).not.toBeNull();
31
+ expect(result?.method).toBe("vimeo");
32
+ expect(result?.content).toContain("Video ID");
33
+ });
34
+
35
+ it("handles vimeo.com/user/video format", async () => {
36
+ const result = await handleVimeo("https://vimeo.com/staffpicks/1084537", 20);
37
+ expect(result).not.toBeNull();
38
+ expect(result?.method).toBe("vimeo");
39
+ });
40
+ });
41
+
42
+ describe.skipIf(SKIP)("handleSpotify", () => {
43
+ it("returns null for non-Spotify URLs", async () => {
44
+ const result = await handleSpotify("https://example.com", 10);
45
+ expect(result).toBeNull();
46
+ });
47
+
48
+ it("returns null for invalid Spotify URLs", async () => {
49
+ const result = await handleSpotify("https://open.spotify.com/invalid/xyz", 10);
50
+ expect(result).toBeNull();
51
+ });
52
+
53
+ it("identifies track URLs", async () => {
54
+ const result = await handleSpotify("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", 20);
55
+ expect(result).not.toBeNull();
56
+ expect(result?.method).toBe("spotify");
57
+ expect(result?.contentType).toBe("text/markdown");
58
+ expect(result?.content).toContain("Type");
59
+ expect(result?.content).toContain("track");
60
+ });
61
+
62
+ it("identifies album URLs", async () => {
63
+ const result = await handleSpotify("https://open.spotify.com/album/2ODvWsOgouMbaA5xf0RkJe", 20);
64
+ expect(result).not.toBeNull();
65
+ expect(result?.method).toBe("spotify");
66
+ expect(result?.content).toContain("album");
67
+ });
68
+
69
+ it("identifies playlist URLs", async () => {
70
+ const result = await handleSpotify("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", 20);
71
+ expect(result).not.toBeNull();
72
+ expect(result?.method).toBe("spotify");
73
+ expect(result?.content).toContain("playlist");
74
+ });
75
+
76
+ it("identifies podcast episode URLs", async () => {
77
+ const result = await handleSpotify("https://open.spotify.com/episode/0Q86acNRm6V9GYx55SXKwf", 20);
78
+ expect(result).not.toBeNull();
79
+ expect(result?.method).toBe("spotify");
80
+ expect(result?.content).toContain("podcast-episode");
81
+ });
82
+
83
+ it("identifies podcast show URLs", async () => {
84
+ const result = await handleSpotify("https://open.spotify.com/show/2MAi0BvDc6GTFvKFPXnkCL", 20);
85
+ expect(result).not.toBeNull();
86
+ expect(result?.method).toBe("spotify");
87
+ expect(result?.content).toContain("podcast-show");
88
+ });
89
+ });
90
+
91
+ describe.skipIf(SKIP)("handleHuggingFace", () => {
92
+ it("returns null for non-HF URLs", async () => {
93
+ const result = await handleHuggingFace("https://example.com", 10);
94
+ expect(result).toBeNull();
95
+ });
96
+
97
+ it("returns null for invalid HF URLs", async () => {
98
+ const result = await handleHuggingFace("https://huggingface.co", 10);
99
+ expect(result).toBeNull();
100
+ });
101
+
102
+ it("fetches model info", async () => {
103
+ const result = await handleHuggingFace("https://huggingface.co/bert-base-uncased", 20);
104
+ expect(result).not.toBeNull();
105
+ expect(result?.method).toBe("huggingface");
106
+ expect(result?.contentType).toBe("text/markdown");
107
+ expect(result?.content).toContain("bert-base-uncased");
108
+ });
109
+
110
+ it("fetches dataset info", async () => {
111
+ const result = await handleHuggingFace("https://huggingface.co/datasets/squad", 20);
112
+ expect(result).not.toBeNull();
113
+ expect(result?.method).toBe("huggingface");
114
+ expect(result?.content).toContain("squad");
115
+ });
116
+
117
+ it("fetches space info", async () => {
118
+ const result = await handleHuggingFace("https://huggingface.co/spaces/gradio/hello_world", 20);
119
+ expect(result).not.toBeNull();
120
+ expect(result?.method).toBe("huggingface");
121
+ expect(result?.content).toContain("gradio/hello_world");
122
+ });
123
+
124
+ it("fetches model without org prefix", async () => {
125
+ // Some models like bert-base-uncased don't have an org prefix
126
+ const result = await handleHuggingFace("https://huggingface.co/bert-base-uncased", 20);
127
+ expect(result).not.toBeNull();
128
+ expect(result?.method).toBe("huggingface");
129
+ expect(result?.content).toContain("bert-base-uncased");
130
+ });
131
+
132
+ it("handles org/model format", async () => {
133
+ const result = await handleHuggingFace("https://huggingface.co/google/bert_uncased_L-2_H-128_A-2", 20);
134
+ expect(result).not.toBeNull();
135
+ expect(result?.method).toBe("huggingface");
136
+ expect(result?.content).toContain("google/bert_uncased_L-2_H-128_A-2");
137
+ });
138
+ });
@@ -0,0 +1,247 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface ModuleResponse {
5
+ name: string;
6
+ version: string;
7
+ abstract?: string;
8
+ author: string;
9
+ distribution: string;
10
+ release: string;
11
+ path: string;
12
+ pod?: string;
13
+ }
14
+
15
+ interface ReleaseResponse {
16
+ name: string;
17
+ version: string;
18
+ abstract?: string;
19
+ author: string;
20
+ distribution: string;
21
+ license?: string[];
22
+ stat?: { mtime: number };
23
+ download_url?: string;
24
+ dependency?: Array<{
25
+ module: string;
26
+ version: string;
27
+ phase: string;
28
+ relationship: string;
29
+ }>;
30
+ metadata?: {
31
+ resources?: {
32
+ repository?: { url?: string; web?: string };
33
+ homepage?: string;
34
+ bugtracker?: { web?: string };
35
+ };
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Handle MetaCPAN URLs via fastapi.metacpan.org
41
+ */
42
+ export const handleMetaCPAN: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
43
+ try {
44
+ const parsed = new URL(url);
45
+ if (parsed.hostname !== "metacpan.org" && parsed.hostname !== "www.metacpan.org") return null;
46
+
47
+ const fetchedAt = new Date().toISOString();
48
+
49
+ // Match /pod/Module::Name pattern
50
+ const podMatch = parsed.pathname.match(/^\/pod\/(.+?)(?:\/|$)/);
51
+ if (podMatch) {
52
+ const moduleName = decodeURIComponent(podMatch[1]);
53
+ return await fetchModule(url, moduleName, timeout, fetchedAt);
54
+ }
55
+
56
+ // Match /release/AUTHOR/Distribution pattern
57
+ const releaseMatch = parsed.pathname.match(/^\/release\/([^/]+)\/([^/]+)/);
58
+ if (releaseMatch) {
59
+ const distribution = decodeURIComponent(releaseMatch[2]);
60
+ return await fetchRelease(url, distribution, timeout, fetchedAt);
61
+ }
62
+
63
+ // Match /release/Distribution pattern (without author)
64
+ const simpleReleaseMatch = parsed.pathname.match(/^\/release\/([^/]+)$/);
65
+ if (simpleReleaseMatch) {
66
+ const distribution = decodeURIComponent(simpleReleaseMatch[1]);
67
+ return await fetchRelease(url, distribution, timeout, fetchedAt);
68
+ }
69
+
70
+ return null;
71
+ } catch {}
72
+
73
+ return null;
74
+ };
75
+
76
+ async function fetchModule(
77
+ url: string,
78
+ moduleName: string,
79
+ timeout: number,
80
+ fetchedAt: string,
81
+ ): Promise<RenderResult | null> {
82
+ const apiUrl = `https://fastapi.metacpan.org/v1/module/${moduleName}`;
83
+ const result = await loadPage(apiUrl, { timeout });
84
+
85
+ if (!result.ok) return null;
86
+
87
+ let module: ModuleResponse;
88
+ try {
89
+ module = JSON.parse(result.content);
90
+ } catch {
91
+ return null;
92
+ }
93
+
94
+ // Fetch additional release info for dependencies and metadata
95
+ const releaseUrl = `https://fastapi.metacpan.org/v1/release/${module.distribution}`;
96
+ const releaseResult = await loadPage(releaseUrl, { timeout: Math.min(timeout, 5) });
97
+
98
+ let release: ReleaseResponse | null = null;
99
+ if (releaseResult.ok) {
100
+ try {
101
+ release = JSON.parse(releaseResult.content);
102
+ } catch {}
103
+ }
104
+
105
+ const md = formatModuleMarkdown(module, release);
106
+ const output = finalizeOutput(md);
107
+
108
+ return {
109
+ url,
110
+ finalUrl: url,
111
+ contentType: "text/markdown",
112
+ method: "metacpan",
113
+ content: output.content,
114
+ fetchedAt,
115
+ truncated: output.truncated,
116
+ notes: ["Fetched via MetaCPAN API"],
117
+ };
118
+ }
119
+
120
+ async function fetchRelease(
121
+ url: string,
122
+ distribution: string,
123
+ timeout: number,
124
+ fetchedAt: string,
125
+ ): Promise<RenderResult | null> {
126
+ const apiUrl = `https://fastapi.metacpan.org/v1/release/${distribution}`;
127
+ const result = await loadPage(apiUrl, { timeout });
128
+
129
+ if (!result.ok) return null;
130
+
131
+ let release: ReleaseResponse;
132
+ try {
133
+ release = JSON.parse(result.content);
134
+ } catch {
135
+ return null;
136
+ }
137
+
138
+ const md = formatReleaseMarkdown(release);
139
+ const output = finalizeOutput(md);
140
+
141
+ return {
142
+ url,
143
+ finalUrl: url,
144
+ contentType: "text/markdown",
145
+ method: "metacpan",
146
+ content: output.content,
147
+ fetchedAt,
148
+ truncated: output.truncated,
149
+ notes: ["Fetched via MetaCPAN API"],
150
+ };
151
+ }
152
+
153
+ function formatModuleMarkdown(module: ModuleResponse, release: ReleaseResponse | null): string {
154
+ let md = `# ${module.name}\n\n`;
155
+ if (module.abstract) md += `${module.abstract}\n\n`;
156
+
157
+ md += `**Version:** ${module.version}`;
158
+ md += ` · **Distribution:** ${module.distribution}`;
159
+ md += ` · **Author:** [${module.author}](https://metacpan.org/author/${module.author})\n`;
160
+
161
+ if (release) {
162
+ if (release.license?.length) {
163
+ md += `**License:** ${release.license.join(", ")}\n`;
164
+ }
165
+
166
+ const resources = release.metadata?.resources;
167
+ if (resources?.repository?.web || resources?.repository?.url) {
168
+ const repoUrl = resources.repository.web || resources.repository.url;
169
+ md += `**Repository:** ${repoUrl}\n`;
170
+ }
171
+ if (resources?.homepage) {
172
+ md += `**Homepage:** ${resources.homepage}\n`;
173
+ }
174
+ if (resources?.bugtracker?.web) {
175
+ md += `**Issues:** ${resources.bugtracker.web}\n`;
176
+ }
177
+
178
+ // Show runtime dependencies
179
+ const runtimeDeps = release.dependency?.filter(
180
+ (d) => d.phase === "runtime" && d.relationship === "requires" && d.module !== "perl",
181
+ );
182
+ if (runtimeDeps?.length) {
183
+ md += `\n## Dependencies\n\n`;
184
+ for (const dep of runtimeDeps.slice(0, 20)) {
185
+ md += `- **${dep.module}**`;
186
+ if (dep.version && dep.version !== "0") md += ` >= ${dep.version}`;
187
+ md += "\n";
188
+ }
189
+ if (runtimeDeps.length > 20) {
190
+ md += `\n*...and ${runtimeDeps.length - 20} more*\n`;
191
+ }
192
+ }
193
+ }
194
+
195
+ md += `\n## Installation\n\n\`\`\`bash\ncpanm ${module.name}\n\`\`\`\n`;
196
+
197
+ return md;
198
+ }
199
+
200
+ function formatReleaseMarkdown(release: ReleaseResponse): string {
201
+ let md = `# ${release.distribution}\n\n`;
202
+ if (release.abstract) md += `${release.abstract}\n\n`;
203
+
204
+ md += `**Version:** ${release.version}`;
205
+ md += ` · **Author:** [${release.author}](https://metacpan.org/author/${release.author})\n`;
206
+
207
+ if (release.license?.length) {
208
+ md += `**License:** ${release.license.join(", ")}\n`;
209
+ }
210
+
211
+ if (release.stat?.mtime) {
212
+ const date = new Date(release.stat.mtime * 1000).toISOString().split("T")[0];
213
+ md += `**Released:** ${date}\n`;
214
+ }
215
+
216
+ const resources = release.metadata?.resources;
217
+ if (resources?.repository?.web || resources?.repository?.url) {
218
+ const repoUrl = resources.repository.web || resources.repository.url;
219
+ md += `**Repository:** ${repoUrl}\n`;
220
+ }
221
+ if (resources?.homepage) {
222
+ md += `**Homepage:** ${resources.homepage}\n`;
223
+ }
224
+ if (resources?.bugtracker?.web) {
225
+ md += `**Issues:** ${resources.bugtracker.web}\n`;
226
+ }
227
+
228
+ // Show runtime dependencies
229
+ const runtimeDeps = release.dependency?.filter(
230
+ (d) => d.phase === "runtime" && d.relationship === "requires" && d.module !== "perl",
231
+ );
232
+ if (runtimeDeps?.length) {
233
+ md += `\n## Dependencies\n\n`;
234
+ for (const dep of runtimeDeps.slice(0, 20)) {
235
+ md += `- **${dep.module}**`;
236
+ if (dep.version && dep.version !== "0") md += ` >= ${dep.version}`;
237
+ md += "\n";
238
+ }
239
+ if (runtimeDeps.length > 20) {
240
+ md += `\n*...and ${runtimeDeps.length - 20} more*\n`;
241
+ }
242
+ }
243
+
244
+ md += `\n## Installation\n\n\`\`\`bash\ncpanm ${release.distribution}\n\`\`\`\n`;
245
+
246
+ return md;
247
+ }
@@ -0,0 +1,107 @@
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 (url: string, timeout: number): Promise<RenderResult | null> => {
8
+ try {
9
+ const parsed = new URL(url);
10
+ if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return null;
11
+
12
+ // Extract package name from /package/[scope/]name
13
+ const match = parsed.pathname.match(/^\/package\/(.+?)(?:\/|$)/);
14
+ if (!match) return null;
15
+
16
+ let packageName = decodeURIComponent(match[1]);
17
+ // Handle scoped packages: /package/@scope/name
18
+ if (packageName.startsWith("@")) {
19
+ const scopeMatch = parsed.pathname.match(/^\/package\/(@[^/]+\/[^/]+)/);
20
+ if (scopeMatch) packageName = decodeURIComponent(scopeMatch[1]);
21
+ }
22
+
23
+ const fetchedAt = new Date().toISOString();
24
+
25
+ // Fetch from npm registry - use /latest endpoint for smaller response
26
+ const latestUrl = `https://registry.npmjs.org/${packageName}/latest`;
27
+ const downloadsUrl = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`;
28
+
29
+ // Fetch package info and download stats in parallel
30
+ const [result, downloadsResult] = await Promise.all([
31
+ loadPage(latestUrl, { timeout }),
32
+ loadPage(downloadsUrl, { timeout: Math.min(timeout, 5) }),
33
+ ]);
34
+
35
+ if (!result.ok) return null;
36
+
37
+ // Parse download stats
38
+ let weeklyDownloads: number | null = null;
39
+ if (downloadsResult.ok) {
40
+ try {
41
+ const dlData = JSON.parse(downloadsResult.content) as { downloads?: number };
42
+ weeklyDownloads = dlData.downloads ?? null;
43
+ } catch {}
44
+ }
45
+
46
+ let pkg: {
47
+ name: string;
48
+ version: string;
49
+ description?: string;
50
+ license?: string;
51
+ homepage?: string;
52
+ repository?: { url: string } | string;
53
+ keywords?: string[];
54
+ maintainers?: Array<{ name: string }>;
55
+ dependencies?: Record<string, string>;
56
+ readme?: string;
57
+ };
58
+
59
+ try {
60
+ pkg = JSON.parse(result.content);
61
+ } catch {
62
+ return null; // JSON parse failed (truncated response)
63
+ }
64
+
65
+ let md = `# ${pkg.name}\n\n`;
66
+ if (pkg.description) md += `${pkg.description}\n\n`;
67
+
68
+ md += `**Latest:** ${pkg.version || "unknown"}`;
69
+ if (pkg.license) md += ` · **License:** ${typeof pkg.license === "string" ? pkg.license : pkg.license}`;
70
+ md += "\n";
71
+ if (weeklyDownloads !== null) {
72
+ md += `**Weekly Downloads:** ${formatCount(weeklyDownloads)}\n`;
73
+ }
74
+ md += "\n";
75
+
76
+ if (pkg.homepage) md += `**Homepage:** ${pkg.homepage}\n`;
77
+ const repoUrl = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url;
78
+ if (repoUrl) md += `**Repository:** ${repoUrl.replace(/^git\+/, "").replace(/\.git$/, "")}\n`;
79
+ if (pkg.keywords?.length) md += `**Keywords:** ${pkg.keywords.join(", ")}\n`;
80
+ if (pkg.maintainers?.length) md += `**Maintainers:** ${pkg.maintainers.map((m) => m.name).join(", ")}\n`;
81
+
82
+ if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
83
+ md += `\n## Dependencies\n\n`;
84
+ for (const [dep, version] of Object.entries(pkg.dependencies)) {
85
+ md += `- ${dep}: ${version}\n`;
86
+ }
87
+ }
88
+
89
+ if (pkg.readme) {
90
+ md += `\n---\n\n## README\n\n${pkg.readme}\n`;
91
+ }
92
+
93
+ const output = finalizeOutput(md);
94
+ return {
95
+ url,
96
+ finalUrl: url,
97
+ contentType: "text/markdown",
98
+ method: "npm",
99
+ content: output.content,
100
+ fetchedAt,
101
+ truncated: output.truncated,
102
+ notes: ["Fetched via npm registry"],
103
+ };
104
+ } catch {}
105
+
106
+ return null;
107
+ };