@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,73 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ /**
5
+ * Handle cheat.sh / cht.sh URLs for command cheatsheets
6
+ *
7
+ * API: Plain text at https://cheat.sh/{topic}?T (T flag removes ANSI colors)
8
+ * Supports: commands, language/topic queries (e.g., python/list, go/slice)
9
+ */
10
+ export const handleCheatSh: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
11
+ try {
12
+ const parsed = new URL(url);
13
+ if (parsed.hostname !== "cheat.sh" && parsed.hostname !== "cht.sh") return null;
14
+
15
+ // Extract topic from path (everything after /)
16
+ const topic = parsed.pathname.slice(1);
17
+ if (!topic || topic === "" || topic === "/") return null;
18
+
19
+ const fetchedAt = new Date().toISOString();
20
+
21
+ // Fetch from cheat.sh API with ?T to disable ANSI colors
22
+ const apiUrl = `https://cheat.sh/${encodeURIComponent(topic)}?T`;
23
+ const result = await loadPage(apiUrl, {
24
+ timeout,
25
+ headers: {
26
+ Accept: "text/plain",
27
+ },
28
+ });
29
+
30
+ if (!result.ok || !result.content.trim()) return null;
31
+
32
+ // Format the cheatsheet as markdown
33
+ const decodedTopic = decodeURIComponent(topic);
34
+ let md = `# cheat.sh/${decodedTopic}\n\n`;
35
+
36
+ // The content is already plain text, wrap in code block for readability
37
+ // Detect if it looks like code/commands vs prose
38
+ const content = result.content.trim();
39
+ const lines = content.split("\n");
40
+ const hasCodeIndicators = lines.some(
41
+ (line) =>
42
+ line.startsWith("$") ||
43
+ line.startsWith("#") ||
44
+ line.includes("()") ||
45
+ line.includes("=>") ||
46
+ /^\s*(if|for|while|def|func|fn|let|const|var)\b/.test(line),
47
+ );
48
+
49
+ if (hasCodeIndicators || decodedTopic.includes("/")) {
50
+ // Likely code examples - use code block
51
+ // Try to detect language from topic
52
+ const lang = decodedTopic.split("/")[0] || "bash";
53
+ md += `\`\`\`${lang}\n${content}\n\`\`\`\n`;
54
+ } else {
55
+ // Command cheatsheet - format as-is (already has good structure)
56
+ md += `\`\`\`\n${content}\n\`\`\`\n`;
57
+ }
58
+
59
+ const output = finalizeOutput(md);
60
+ return {
61
+ url,
62
+ finalUrl: url,
63
+ contentType: "text/markdown",
64
+ method: "cheat.sh",
65
+ content: output.content,
66
+ fetchedAt,
67
+ truncated: output.truncated,
68
+ notes: ["Fetched via cheat.sh"],
69
+ };
70
+ } catch {}
71
+
72
+ return null;
73
+ };
@@ -0,0 +1,153 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface NuGetODataEntry {
5
+ Id: string;
6
+ Version: string;
7
+ Title?: string;
8
+ Description?: string;
9
+ Summary?: string;
10
+ Authors?: string;
11
+ ProjectUrl?: string;
12
+ PackageSourceUrl?: string;
13
+ Tags?: string;
14
+ DownloadCount?: number;
15
+ VersionDownloadCount?: number;
16
+ Published?: string;
17
+ LicenseUrl?: string;
18
+ ReleaseNotes?: string;
19
+ Dependencies?: string;
20
+ }
21
+
22
+ interface NuGetODataResponse {
23
+ d?: {
24
+ results?: NuGetODataEntry[];
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Handle Chocolatey package URLs via NuGet v2 OData API
30
+ */
31
+ export const handleChocolatey: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
32
+ try {
33
+ const parsed = new URL(url);
34
+ if (!parsed.hostname.includes("chocolatey.org")) return null;
35
+
36
+ // Extract package name from /packages/{name} or /packages/{name}/{version}
37
+ const match = parsed.pathname.match(/^\/packages\/([^/]+)(?:\/([^/]+))?/);
38
+ if (!match) return null;
39
+
40
+ const packageName = decodeURIComponent(match[1]);
41
+ const specificVersion = match[2] ? decodeURIComponent(match[2]) : null;
42
+
43
+ const fetchedAt = new Date().toISOString();
44
+
45
+ // Build OData query - filter by Id and optionally version
46
+ let apiUrl = `https://community.chocolatey.org/api/v2/Packages()?$filter=Id%20eq%20'${encodeURIComponent(packageName)}'`;
47
+ if (specificVersion) {
48
+ apiUrl += `%20and%20Version%20eq%20'${encodeURIComponent(specificVersion)}'`;
49
+ } else {
50
+ // Get latest version by ordering and taking first
51
+ apiUrl += "&$orderby=Version%20desc&$top=1";
52
+ }
53
+
54
+ const result = await loadPage(apiUrl, {
55
+ timeout,
56
+ headers: {
57
+ Accept: "application/json",
58
+ },
59
+ });
60
+
61
+ if (!result.ok) return null;
62
+
63
+ let data: NuGetODataResponse;
64
+ try {
65
+ data = JSON.parse(result.content);
66
+ } catch {
67
+ return null;
68
+ }
69
+
70
+ const pkg = data.d?.results?.[0];
71
+ if (!pkg) return null;
72
+
73
+ // Build markdown output
74
+ let md = `# ${pkg.Title || pkg.Id}\n\n`;
75
+
76
+ if (pkg.Summary) {
77
+ md += `${pkg.Summary}\n\n`;
78
+ } else if (pkg.Description) {
79
+ // Use first paragraph of description as summary
80
+ const firstPara = pkg.Description.split(/\n\n/)[0];
81
+ md += `${firstPara}\n\n`;
82
+ }
83
+
84
+ md += `**Version:** ${pkg.Version}`;
85
+ if (pkg.Authors) md += ` · **Authors:** ${pkg.Authors}`;
86
+ md += "\n";
87
+
88
+ if (pkg.DownloadCount !== undefined) {
89
+ md += `**Total Downloads:** ${formatCount(pkg.DownloadCount)}`;
90
+ if (pkg.VersionDownloadCount !== undefined) {
91
+ md += ` · **Version Downloads:** ${formatCount(pkg.VersionDownloadCount)}`;
92
+ }
93
+ md += "\n";
94
+ }
95
+
96
+ if (pkg.Published) {
97
+ const date = new Date(pkg.Published);
98
+ md += `**Published:** ${date.toISOString().split("T")[0]}\n`;
99
+ }
100
+
101
+ md += "\n";
102
+
103
+ if (pkg.ProjectUrl) md += `**Project URL:** ${pkg.ProjectUrl}\n`;
104
+ if (pkg.PackageSourceUrl) md += `**Source:** ${pkg.PackageSourceUrl}\n`;
105
+ if (pkg.LicenseUrl) md += `**License:** ${pkg.LicenseUrl}\n`;
106
+
107
+ if (pkg.Tags) {
108
+ const tags = pkg.Tags.split(/\s+/).filter((t) => t.length > 0);
109
+ if (tags.length > 0) {
110
+ md += `**Tags:** ${tags.join(", ")}\n`;
111
+ }
112
+ }
113
+
114
+ // Full description if different from summary
115
+ if (pkg.Description && pkg.Description !== pkg.Summary) {
116
+ md += `\n## Description\n\n${pkg.Description}\n`;
117
+ }
118
+
119
+ if (pkg.ReleaseNotes) {
120
+ md += `\n## Release Notes\n\n${pkg.ReleaseNotes}\n`;
121
+ }
122
+
123
+ if (pkg.Dependencies) {
124
+ // Dependencies format: "id:version|id:version"
125
+ const deps = pkg.Dependencies.split("|").filter((d) => d.trim().length > 0);
126
+ if (deps.length > 0) {
127
+ md += `\n## Dependencies\n\n`;
128
+ for (const dep of deps) {
129
+ const [depId, depVersion] = dep.split(":");
130
+ if (depId) {
131
+ md += `- ${depId}${depVersion ? `: ${depVersion}` : ""}\n`;
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ md += `\n---\n**Install:** \`choco install ${packageName}\`\n`;
138
+
139
+ const output = finalizeOutput(md);
140
+ return {
141
+ url,
142
+ finalUrl: url,
143
+ contentType: "text/markdown",
144
+ method: "chocolatey",
145
+ content: output.content,
146
+ fetchedAt,
147
+ truncated: output.truncated,
148
+ notes: ["Fetched via Chocolatey NuGet API"],
149
+ };
150
+ } catch {}
151
+
152
+ return null;
153
+ };
@@ -0,0 +1,179 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface CoinGeckoResponse {
5
+ id: string;
6
+ symbol: string;
7
+ name: string;
8
+ description?: { en?: string };
9
+ links?: {
10
+ homepage?: string[];
11
+ blockchain_site?: string[];
12
+ repos_url?: { github?: string[] };
13
+ };
14
+ market_data?: {
15
+ current_price?: { usd?: number };
16
+ market_cap?: { usd?: number };
17
+ total_volume?: { usd?: number };
18
+ price_change_percentage_24h?: number;
19
+ ath?: { usd?: number };
20
+ ath_date?: { usd?: string };
21
+ circulating_supply?: number;
22
+ total_supply?: number;
23
+ max_supply?: number;
24
+ };
25
+ categories?: string[];
26
+ genesis_date?: string;
27
+ }
28
+
29
+ /**
30
+ * Handle CoinGecko cryptocurrency URLs via API
31
+ */
32
+ export const handleCoinGecko: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
33
+ try {
34
+ const parsed = new URL(url);
35
+ if (!parsed.hostname.includes("coingecko.com")) return null;
36
+
37
+ // Extract coin ID from /coins/{id} or /en/coins/{id}
38
+ const match = parsed.pathname.match(/^(?:\/[a-z]{2})?\/coins\/([^/?#]+)/);
39
+ if (!match) return null;
40
+
41
+ const coinId = decodeURIComponent(match[1]);
42
+ const fetchedAt = new Date().toISOString();
43
+
44
+ // Fetch from CoinGecko API
45
+ const apiUrl = `https://api.coingecko.com/api/v3/coins/${coinId}?localization=false&tickers=false&community_data=false&developer_data=false`;
46
+ const result = await loadPage(apiUrl, {
47
+ timeout,
48
+ headers: { Accept: "application/json" },
49
+ });
50
+
51
+ if (!result.ok) return null;
52
+
53
+ let coin: CoinGeckoResponse;
54
+ try {
55
+ coin = JSON.parse(result.content);
56
+ } catch {
57
+ return null;
58
+ }
59
+
60
+ const market = coin.market_data;
61
+
62
+ let md = `# ${coin.name} (${coin.symbol.toUpperCase()})\n\n`;
63
+
64
+ // Price and market data
65
+ if (market?.current_price?.usd !== undefined) {
66
+ md += `**Price:** $${formatPrice(market.current_price.usd)}`;
67
+ if (market.price_change_percentage_24h !== undefined) {
68
+ const change = market.price_change_percentage_24h;
69
+ const sign = change >= 0 ? "+" : "";
70
+ md += ` (${sign}${change.toFixed(2)}% 24h)`;
71
+ }
72
+ md += "\n";
73
+ }
74
+
75
+ if (market?.market_cap?.usd) {
76
+ md += `**Market Cap:** $${formatLargeNumber(market.market_cap.usd)}\n`;
77
+ }
78
+
79
+ if (market?.total_volume?.usd) {
80
+ md += `**24h Volume:** $${formatLargeNumber(market.total_volume.usd)}\n`;
81
+ }
82
+
83
+ if (market?.ath?.usd !== undefined) {
84
+ md += `**All-Time High:** $${formatPrice(market.ath.usd)}`;
85
+ if (market.ath_date?.usd) {
86
+ const athDate = new Date(market.ath_date.usd).toLocaleDateString("en-US", {
87
+ year: "numeric",
88
+ month: "short",
89
+ day: "numeric",
90
+ });
91
+ md += ` (${athDate})`;
92
+ }
93
+ md += "\n";
94
+ }
95
+
96
+ md += "\n";
97
+
98
+ // Supply info
99
+ if (market?.circulating_supply) {
100
+ md += `**Circulating Supply:** ${formatCount(Math.round(market.circulating_supply))}`;
101
+ if (market.max_supply) {
102
+ const percent = ((market.circulating_supply / market.max_supply) * 100).toFixed(1);
103
+ md += ` / ${formatCount(Math.round(market.max_supply))} (${percent}%)`;
104
+ } else if (market.total_supply) {
105
+ md += ` / ${formatCount(Math.round(market.total_supply))} total`;
106
+ }
107
+ md += "\n";
108
+ }
109
+
110
+ if (coin.genesis_date) {
111
+ md += `**Launch Date:** ${coin.genesis_date}\n`;
112
+ }
113
+
114
+ if (coin.categories?.length) {
115
+ md += `**Categories:** ${coin.categories.join(", ")}\n`;
116
+ }
117
+
118
+ // Links
119
+ const links: string[] = [];
120
+ if (coin.links?.homepage?.[0]) {
121
+ links.push(`[Website](${coin.links.homepage[0]})`);
122
+ }
123
+ if (coin.links?.blockchain_site?.[0]) {
124
+ links.push(`[Explorer](${coin.links.blockchain_site[0]})`);
125
+ }
126
+ if (coin.links?.repos_url?.github?.[0]) {
127
+ links.push(`[GitHub](${coin.links.repos_url.github[0]})`);
128
+ }
129
+ if (links.length) {
130
+ md += `**Links:** ${links.join(" · ")}\n`;
131
+ }
132
+
133
+ // Description
134
+ if (coin.description?.en) {
135
+ const desc = coin.description.en
136
+ .replace(/<[^>]+>/g, "") // Strip HTML
137
+ .replace(/\r\n/g, "\n")
138
+ .trim();
139
+ if (desc) {
140
+ md += `\n## About\n\n${desc}\n`;
141
+ }
142
+ }
143
+
144
+ const output = finalizeOutput(md);
145
+ return {
146
+ url,
147
+ finalUrl: url,
148
+ contentType: "text/markdown",
149
+ method: "coingecko",
150
+ content: output.content,
151
+ fetchedAt,
152
+ truncated: output.truncated,
153
+ notes: ["Fetched via CoinGecko API"],
154
+ };
155
+ } catch {}
156
+
157
+ return null;
158
+ };
159
+
160
+ /**
161
+ * Format price with appropriate decimal places
162
+ */
163
+ function formatPrice(price: number): string {
164
+ if (price >= 1000) return price.toLocaleString("en-US", { maximumFractionDigits: 2 });
165
+ if (price >= 1) return price.toFixed(2);
166
+ if (price >= 0.01) return price.toFixed(4);
167
+ if (price >= 0.0001) return price.toFixed(6);
168
+ return price.toFixed(8);
169
+ }
170
+
171
+ /**
172
+ * Format large numbers with B/M/K suffixes
173
+ */
174
+ function formatLargeNumber(n: number): string {
175
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
176
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
177
+ if (n >= 1_000) return `${(n / 1_000).toFixed(2)}K`;
178
+ return n.toFixed(2);
179
+ }
@@ -0,0 +1,123 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ /**
5
+ * Check if content looks like HTML
6
+ */
7
+ function looksLikeHtml(content: string): boolean {
8
+ const trimmed = content.trim().toLowerCase();
9
+ return (
10
+ trimmed.startsWith("<!doctype") ||
11
+ trimmed.startsWith("<html") ||
12
+ trimmed.startsWith("<head") ||
13
+ trimmed.startsWith("<body")
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Handle crates.io URLs via API
19
+ */
20
+ export const handleCratesIo: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
21
+ try {
22
+ const parsed = new URL(url);
23
+ if (parsed.hostname !== "crates.io" && parsed.hostname !== "www.crates.io") return null;
24
+
25
+ // Extract crate name from /crates/name or /crates/name/version
26
+ const match = parsed.pathname.match(/^\/crates\/([^/]+)/);
27
+ if (!match) return null;
28
+
29
+ const crateName = decodeURIComponent(match[1]);
30
+ const fetchedAt = new Date().toISOString();
31
+
32
+ // Fetch from crates.io API
33
+ const apiUrl = `https://crates.io/api/v1/crates/${crateName}`;
34
+ const result = await loadPage(apiUrl, {
35
+ timeout,
36
+ headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" },
37
+ });
38
+
39
+ if (!result.ok) return null;
40
+
41
+ let data: {
42
+ crate: {
43
+ name: string;
44
+ description: string | null;
45
+ downloads: number;
46
+ recent_downloads: number;
47
+ max_version: string;
48
+ repository: string | null;
49
+ homepage: string | null;
50
+ documentation: string | null;
51
+ categories: string[];
52
+ keywords: string[];
53
+ created_at: string;
54
+ updated_at: string;
55
+ };
56
+ versions: Array<{
57
+ num: string;
58
+ downloads: number;
59
+ created_at: string;
60
+ license: string | null;
61
+ rust_version: string | null;
62
+ }>;
63
+ };
64
+
65
+ try {
66
+ data = JSON.parse(result.content);
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ const crate = data.crate;
72
+ const latestVersion = data.versions?.[0];
73
+
74
+ // Format download counts
75
+ const formatDownloads = (n: number): string =>
76
+ n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}K` : String(n);
77
+
78
+ let md = `# ${crate.name}\n\n`;
79
+ if (crate.description) md += `${crate.description}\n\n`;
80
+
81
+ md += `**Latest:** ${crate.max_version}`;
82
+ if (latestVersion?.license) md += ` · **License:** ${latestVersion.license}`;
83
+ if (latestVersion?.rust_version) md += ` · **MSRV:** ${latestVersion.rust_version}`;
84
+ md += "\n";
85
+ md += `**Downloads:** ${formatDownloads(crate.downloads)} total · ${formatDownloads(crate.recent_downloads)} recent\n\n`;
86
+
87
+ if (crate.repository) md += `**Repository:** ${crate.repository}\n`;
88
+ if (crate.homepage && crate.homepage !== crate.repository) md += `**Homepage:** ${crate.homepage}\n`;
89
+ if (crate.documentation) md += `**Docs:** ${crate.documentation}\n`;
90
+ if (crate.keywords?.length) md += `**Keywords:** ${crate.keywords.join(", ")}\n`;
91
+ if (crate.categories?.length) md += `**Categories:** ${crate.categories.join(", ")}\n`;
92
+
93
+ // Show recent versions
94
+ if (data.versions?.length > 0) {
95
+ md += `\n## Recent Versions\n\n`;
96
+ for (const ver of data.versions.slice(0, 5)) {
97
+ const date = ver.created_at.split("T")[0];
98
+ md += `- **${ver.num}** (${date}) - ${formatDownloads(ver.downloads)} downloads\n`;
99
+ }
100
+ }
101
+
102
+ // Try to fetch README from docs.rs or repository
103
+ const docsRsUrl = `https://docs.rs/crate/${crateName}/${crate.max_version}/source/README.md`;
104
+ const readmeResult = await loadPage(docsRsUrl, { timeout: Math.min(timeout, 5) });
105
+ if (readmeResult.ok && readmeResult.content.length > 100 && !looksLikeHtml(readmeResult.content)) {
106
+ md += `\n---\n\n## README\n\n${readmeResult.content}\n`;
107
+ }
108
+
109
+ const output = finalizeOutput(md);
110
+ return {
111
+ url,
112
+ finalUrl: url,
113
+ contentType: "text/markdown",
114
+ method: "crates.io",
115
+ content: output.content,
116
+ fetchedAt,
117
+ truncated: output.truncated,
118
+ notes: ["Fetched via crates.io API"],
119
+ };
120
+ } catch {}
121
+
122
+ return null;
123
+ };