@oh-my-pi/pi-coding-agent 3.25.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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +4 -4
  3. package/src/core/tools/complete.ts +2 -4
  4. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  5. package/src/core/tools/read.ts +4 -4
  6. package/src/core/tools/task/executor.ts +146 -20
  7. package/src/core/tools/task/name-generator.ts +1544 -214
  8. package/src/core/tools/task/types.ts +19 -5
  9. package/src/core/tools/task/worker.ts +103 -13
  10. package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
  11. package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
  12. package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
  13. package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
  14. package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
  15. package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
  16. package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
  17. package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
  18. package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
  19. package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
  20. package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
  21. package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
  22. package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
  23. package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
  24. package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
  25. package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
  26. package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
  27. package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
  28. package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
  29. package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
  30. package/src/core/tools/web-fetch-handlers/github.ts +424 -0
  31. package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
  32. package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
  33. package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
  34. package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
  35. package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
  36. package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
  37. package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
  38. package/src/core/tools/web-fetch-handlers/index.ts +69 -0
  39. package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
  40. package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
  41. package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
  42. package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
  43. package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
  44. package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
  45. package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
  46. package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
  47. package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
  48. package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
  49. package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
  50. package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
  51. package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
  52. package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
  53. package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
  54. package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
  55. package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
  56. package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
  57. package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
  58. package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
  59. package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
  60. package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
  61. package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
  62. package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
  63. package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
  64. package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
  65. package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
  66. package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
  67. package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
  68. package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
  69. package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
  70. package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
  71. package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
  72. package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
  73. package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
  74. package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
  75. package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
  76. package/src/core/tools/web-fetch-handlers/types.ts +163 -0
  77. package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
  78. package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
  79. package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
  80. package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
  81. package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
  82. package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
  83. package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
  84. package/src/core/tools/web-fetch.ts +152 -1324
  85. package/src/utils/tools-manager.ts +110 -8
@@ -0,0 +1,173 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface BrewFormula {
5
+ name: string;
6
+ full_name?: string;
7
+ desc?: string;
8
+ homepage?: string;
9
+ license?: string;
10
+ versions?: {
11
+ stable?: string;
12
+ head?: string;
13
+ bottle?: boolean;
14
+ };
15
+ dependencies?: string[];
16
+ build_dependencies?: string[];
17
+ optional_dependencies?: string[];
18
+ conflicts_with?: string[];
19
+ caveats?: string;
20
+ analytics?: {
21
+ install?: {
22
+ "30d"?: Record<string, number>;
23
+ "90d"?: Record<string, number>;
24
+ "365d"?: Record<string, number>;
25
+ };
26
+ };
27
+ }
28
+
29
+ interface BrewCask {
30
+ token: string;
31
+ name?: string[];
32
+ desc?: string;
33
+ homepage?: string;
34
+ version?: string;
35
+ sha256?: string;
36
+ caveats?: string;
37
+ depends_on?: {
38
+ macos?: Record<string, string[]>;
39
+ };
40
+ conflicts_with?: {
41
+ cask?: string[];
42
+ };
43
+ analytics?: {
44
+ install?: {
45
+ "30d"?: Record<string, number>;
46
+ "90d"?: Record<string, number>;
47
+ "365d"?: Record<string, number>;
48
+ };
49
+ };
50
+ }
51
+
52
+ function getInstallCount(analytics?: { install?: { "30d"?: Record<string, number> } }): number | null {
53
+ if (!analytics?.install?.["30d"]) return null;
54
+ const counts = Object.values(analytics.install["30d"]);
55
+ return counts.reduce((sum, n) => sum + n, 0);
56
+ }
57
+
58
+ /**
59
+ * Handle Homebrew formulae and cask URLs via API
60
+ */
61
+ export const handleBrew: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
62
+ try {
63
+ const parsed = new URL(url);
64
+ if (parsed.hostname !== "formulae.brew.sh") return null;
65
+
66
+ const formulaMatch = parsed.pathname.match(/^\/formula\/([^/]+)\/?$/);
67
+ const caskMatch = parsed.pathname.match(/^\/cask\/([^/]+)\/?$/);
68
+
69
+ if (!formulaMatch && !caskMatch) return null;
70
+
71
+ const fetchedAt = new Date().toISOString();
72
+ const isFormula = Boolean(formulaMatch);
73
+ const name = decodeURIComponent(isFormula ? formulaMatch![1] : caskMatch![1]);
74
+
75
+ const apiUrl = isFormula
76
+ ? `https://formulae.brew.sh/api/formula/${encodeURIComponent(name)}.json`
77
+ : `https://formulae.brew.sh/api/cask/${encodeURIComponent(name)}.json`;
78
+
79
+ const result = await loadPage(apiUrl, { timeout });
80
+ if (!result.ok) return null;
81
+
82
+ let md: string;
83
+
84
+ if (isFormula) {
85
+ const formula: BrewFormula = JSON.parse(result.content);
86
+
87
+ md = `# ${formula.full_name || formula.name}\n\n`;
88
+ if (formula.desc) md += `${formula.desc}\n\n`;
89
+
90
+ md += `**Version:** ${formula.versions?.stable || "unknown"}`;
91
+ if (formula.license) md += ` · **License:** ${formula.license}`;
92
+ md += "\n";
93
+
94
+ const installs = getInstallCount(formula.analytics);
95
+ if (installs !== null) {
96
+ md += `**Installs (30d):** ${formatCount(installs)}\n`;
97
+ }
98
+ md += "\n";
99
+
100
+ md += `\`\`\`bash\nbrew install ${formula.name}\n\`\`\`\n\n`;
101
+
102
+ if (formula.homepage) md += `**Homepage:** ${formula.homepage}\n`;
103
+
104
+ if (formula.dependencies?.length) {
105
+ md += `\n## Dependencies\n\n`;
106
+ for (const dep of formula.dependencies) {
107
+ md += `- ${dep}\n`;
108
+ }
109
+ }
110
+
111
+ if (formula.build_dependencies?.length) {
112
+ md += `\n## Build Dependencies\n\n`;
113
+ for (const dep of formula.build_dependencies) {
114
+ md += `- ${dep}\n`;
115
+ }
116
+ }
117
+
118
+ if (formula.conflicts_with?.length) {
119
+ md += `\n## Conflicts With\n\n`;
120
+ for (const conflict of formula.conflicts_with) {
121
+ md += `- ${conflict}\n`;
122
+ }
123
+ }
124
+
125
+ if (formula.caveats) {
126
+ md += `\n## Caveats\n\n${formula.caveats}\n`;
127
+ }
128
+ } else {
129
+ const cask: BrewCask = JSON.parse(result.content);
130
+
131
+ const displayName = cask.name?.[0] || cask.token;
132
+ md = `# ${displayName}\n\n`;
133
+ if (cask.desc) md += `${cask.desc}\n\n`;
134
+
135
+ md += `**Version:** ${cask.version || "unknown"}\n`;
136
+
137
+ const installs = getInstallCount(cask.analytics);
138
+ if (installs !== null) {
139
+ md += `**Installs (30d):** ${formatCount(installs)}\n`;
140
+ }
141
+ md += "\n";
142
+
143
+ md += `\`\`\`bash\nbrew install --cask ${cask.token}\n\`\`\`\n\n`;
144
+
145
+ if (cask.homepage) md += `**Homepage:** ${cask.homepage}\n`;
146
+
147
+ if (cask.conflicts_with?.cask?.length) {
148
+ md += `\n## Conflicts With\n\n`;
149
+ for (const conflict of cask.conflicts_with.cask) {
150
+ md += `- ${conflict}\n`;
151
+ }
152
+ }
153
+
154
+ if (cask.caveats) {
155
+ md += `\n## Caveats\n\n${cask.caveats}\n`;
156
+ }
157
+ }
158
+
159
+ const output = finalizeOutput(md);
160
+ return {
161
+ url,
162
+ finalUrl: url,
163
+ contentType: "text/markdown",
164
+ method: "brew",
165
+ content: output.content,
166
+ fetchedAt,
167
+ truncated: output.truncated,
168
+ notes: [`Fetched via Homebrew ${isFormula ? "formula" : "cask"} API`],
169
+ };
170
+ } catch {}
171
+
172
+ return null;
173
+ };
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleOpenCorporates } from "./opencorporates";
3
+ import { handleSecEdgar } from "./sec-edgar";
4
+
5
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
6
+
7
+ describe.skipIf(SKIP)("handleSecEdgar", () => {
8
+ it("returns null for non-matching URLs", async () => {
9
+ const result = await handleSecEdgar("https://example.com", 20);
10
+ expect(result).toBeNull();
11
+ });
12
+
13
+ it("returns null for SEC URLs without valid CIK", async () => {
14
+ const result = await handleSecEdgar("https://www.sec.gov/about.html", 20);
15
+ expect(result).toBeNull();
16
+ });
17
+
18
+ it("fetches Apple Inc filings by CIK query param", async () => {
19
+ const result = await handleSecEdgar(
20
+ "https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=0000320193",
21
+ 20,
22
+ );
23
+ expect(result).not.toBeNull();
24
+ expect(result?.method).toBe("sec-edgar");
25
+ expect(result?.content).toContain("APPLE INC");
26
+ expect(result?.content).toContain("0000320193");
27
+ expect(result?.content).toContain("10-K"); // Apple files 10-K annually
28
+ expect(result?.contentType).toBe("text/markdown");
29
+ expect(result?.fetchedAt).toBeTruthy();
30
+ expect(result?.truncated).toBeDefined();
31
+ });
32
+
33
+ it("fetches via data.sec.gov submissions URL", async () => {
34
+ const result = await handleSecEdgar("https://data.sec.gov/submissions/CIK0000320193.json", 20);
35
+ expect(result).not.toBeNull();
36
+ expect(result?.method).toBe("sec-edgar");
37
+ expect(result?.content).toContain("APPLE INC");
38
+ });
39
+
40
+ it("fetches via Archives path", async () => {
41
+ // Any filing URL with CIK in Archives path
42
+ const result = await handleSecEdgar(
43
+ "https://www.sec.gov/Archives/edgar/data/320193/000032019324000123/aapl-20240928.htm",
44
+ 20,
45
+ );
46
+ expect(result).not.toBeNull();
47
+ expect(result?.method).toBe("sec-edgar");
48
+ expect(result?.content).toContain("APPLE INC");
49
+ });
50
+ });
51
+
52
+ describe.skipIf(SKIP)("handleOpenCorporates", () => {
53
+ it("returns null for non-matching URLs", async () => {
54
+ const result = await handleOpenCorporates("https://example.com", 20);
55
+ expect(result).toBeNull();
56
+ });
57
+
58
+ it("returns null for OpenCorporates URLs without company path", async () => {
59
+ const result = await handleOpenCorporates("https://opencorporates.com/about", 20);
60
+ expect(result).toBeNull();
61
+ });
62
+
63
+ it("fetches Apple Inc Delaware registration", async () => {
64
+ const result = await handleOpenCorporates("https://opencorporates.com/companies/us_de/2927442", 20);
65
+ expect(result).not.toBeNull();
66
+ expect(result?.method).toBe("opencorporates");
67
+ expect(result?.content).toContain("APPLE INC");
68
+ expect(result?.content).toContain("2927442");
69
+ expect(result?.content).toContain("US_DE");
70
+ expect(result?.contentType).toBe("text/markdown");
71
+ expect(result?.fetchedAt).toBeTruthy();
72
+ expect(result?.truncated).toBeDefined();
73
+ });
74
+
75
+ it("fetches Microsoft Corporation", async () => {
76
+ // Microsoft is registered in Washington state
77
+ const result = await handleOpenCorporates("https://opencorporates.com/companies/us_wa/600413485", 20);
78
+ expect(result).not.toBeNull();
79
+ expect(result?.method).toBe("opencorporates");
80
+ expect(result?.content).toMatch(/microsoft/i);
81
+ });
82
+ });
@@ -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
+ }