@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,239 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface FlathubScreenshotSize {
5
+ src?: string;
6
+ width?: string;
7
+ height?: string;
8
+ scale?: string;
9
+ }
10
+
11
+ interface FlathubScreenshot {
12
+ caption?: string | null;
13
+ sizes?: FlathubScreenshotSize[];
14
+ }
15
+
16
+ interface FlathubRelease {
17
+ version?: string;
18
+ timestamp?: string;
19
+ description?: string | null;
20
+ url?: string | null;
21
+ type?: string | null;
22
+ }
23
+
24
+ interface FlathubAppStream {
25
+ id?: string;
26
+ name?: string;
27
+ summary?: string;
28
+ description?: string;
29
+ developer_name?: string;
30
+ categories?: string[];
31
+ screenshots?: FlathubScreenshot[];
32
+ releases?: FlathubRelease[];
33
+ metadata?: Record<string, unknown>;
34
+ installs?: number | string;
35
+ permissions?: unknown;
36
+ }
37
+
38
+ function extractAppId(pathname: string): string | null {
39
+ const detailsMatch = pathname.match(/^\/apps\/details\/([^/]+)\/?$/);
40
+ if (detailsMatch) return decodeURIComponent(detailsMatch[1]);
41
+
42
+ const appMatch = pathname.match(/^\/apps\/([^/]+)\/?$/);
43
+ if (appMatch) return decodeURIComponent(appMatch[1]);
44
+
45
+ return null;
46
+ }
47
+
48
+ function parseNumber(value: unknown): number | null {
49
+ if (typeof value === "number" && Number.isFinite(value)) return value;
50
+ if (typeof value === "string") {
51
+ const cleaned = value.replace(/[^0-9.]/g, "");
52
+ if (!cleaned) return null;
53
+ const parsed = Number(cleaned);
54
+ if (!Number.isNaN(parsed)) return parsed;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function normalizeStringList(value: unknown): string[] {
60
+ if (Array.isArray(value)) {
61
+ return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
62
+ }
63
+ if (typeof value === "string") {
64
+ return value
65
+ .split(/[,;\n]+/)
66
+ .map((item) => item.trim())
67
+ .filter(Boolean);
68
+ }
69
+ return [];
70
+ }
71
+
72
+ function extractInstalls(app: FlathubAppStream): number | null {
73
+ const direct = parseNumber(app.installs);
74
+ if (direct !== null) return direct;
75
+
76
+ if (!app.metadata) return null;
77
+ for (const [key, value] of Object.entries(app.metadata)) {
78
+ if (!key.toLowerCase().includes("install")) continue;
79
+ const parsed = parseNumber(value);
80
+ if (parsed !== null) return parsed;
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function extractPermissions(app: FlathubAppStream): string[] {
87
+ const permissions: string[] = [];
88
+ permissions.push(...normalizeStringList(app.permissions));
89
+
90
+ if (app.metadata) {
91
+ for (const [key, value] of Object.entries(app.metadata)) {
92
+ if (!key.toLowerCase().includes("permission")) continue;
93
+ const list = normalizeStringList(value);
94
+ if (list.length) {
95
+ permissions.push(...list);
96
+ continue;
97
+ }
98
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
99
+ permissions.push(`${key}: ${String(value)}`);
100
+ }
101
+ }
102
+ }
103
+
104
+ return Array.from(new Set(permissions));
105
+ }
106
+
107
+ function screenshotArea(size?: FlathubScreenshotSize): number {
108
+ if (!size) return 0;
109
+ const width = Number(size.width);
110
+ const height = Number(size.height);
111
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return 0;
112
+ return width * height;
113
+ }
114
+
115
+ function bestScreenshotUrl(sizes?: FlathubScreenshotSize[]): string | null {
116
+ if (!sizes || sizes.length === 0) return null;
117
+
118
+ let best = sizes[0];
119
+ let bestArea = screenshotArea(best);
120
+
121
+ for (const size of sizes) {
122
+ const area = screenshotArea(size);
123
+ if (area > bestArea) {
124
+ best = size;
125
+ bestArea = area;
126
+ }
127
+ }
128
+
129
+ return best.src ?? sizes[0].src ?? null;
130
+ }
131
+
132
+ function formatReleaseDate(timestamp?: string | null): string | null {
133
+ if (!timestamp) return null;
134
+ const seconds = Number(timestamp);
135
+ if (!Number.isFinite(seconds)) return null;
136
+ const date = new Date(seconds * 1000);
137
+ if (Number.isNaN(date.getTime())) return null;
138
+ return date.toISOString().split("T")[0] ?? null;
139
+ }
140
+
141
+ export const handleFlathub: SpecialHandler = async (
142
+ url: string,
143
+ timeout: number,
144
+ signal?: AbortSignal,
145
+ ): Promise<RenderResult | null> => {
146
+ try {
147
+ const parsed = new URL(url);
148
+ if (parsed.hostname !== "flathub.org" && parsed.hostname !== "www.flathub.org") return null;
149
+
150
+ const appId = extractAppId(parsed.pathname);
151
+ if (!appId) return null;
152
+
153
+ const apiUrl = `https://flathub.org/api/v2/appstream/${encodeURIComponent(appId)}`;
154
+ const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } });
155
+ if (!result.ok) return null;
156
+
157
+ let app: FlathubAppStream;
158
+ try {
159
+ app = JSON.parse(result.content) as FlathubAppStream;
160
+ } catch {
161
+ return null;
162
+ }
163
+
164
+ const fetchedAt = new Date().toISOString();
165
+ const name = app.name ?? app.id ?? appId;
166
+
167
+ let md = `# ${name}\n\n`;
168
+ if (app.summary) md += `${app.summary}\n\n`;
169
+
170
+ md += "## Metadata\n\n";
171
+ md += `**App ID:** ${app.id ?? appId}\n`;
172
+ if (app.developer_name) md += `**Developer:** ${app.developer_name}\n`;
173
+
174
+ const installs = extractInstalls(app);
175
+ if (installs !== null) md += `**Installs:** ${formatCount(installs)}\n`;
176
+
177
+ if (app.categories?.length) {
178
+ md += "\n## Categories\n\n";
179
+ for (const category of app.categories) {
180
+ md += `- ${category}\n`;
181
+ }
182
+ }
183
+
184
+ if (app.description) {
185
+ const description = htmlToBasicMarkdown(app.description);
186
+ if (description) md += `\n## Description\n\n${description}\n`;
187
+ }
188
+
189
+ const permissions = extractPermissions(app);
190
+ if (permissions.length) {
191
+ md += "\n## Permissions\n\n";
192
+ for (const permission of permissions) {
193
+ md += `- ${permission}\n`;
194
+ }
195
+ }
196
+
197
+ if (app.screenshots?.length) {
198
+ md += "\n## Screenshots\n\n";
199
+ for (const screenshot of app.screenshots.slice(0, 5)) {
200
+ const screenshotUrl = bestScreenshotUrl(screenshot.sizes);
201
+ if (!screenshotUrl) continue;
202
+ const caption = screenshot.caption ? ` - ${screenshot.caption}` : "";
203
+ md += `- ${screenshotUrl}${caption}\n`;
204
+ }
205
+ }
206
+
207
+ if (app.releases?.length) {
208
+ md += "\n## Releases\n\n";
209
+ for (const release of app.releases.slice(0, 5)) {
210
+ const version = release.version ?? "unknown";
211
+ let line = `- **${version}**`;
212
+ const date = formatReleaseDate(release.timestamp);
213
+ if (date) line += ` (${date})`;
214
+ if (release.type) line += ` · ${release.type}`;
215
+ if (release.url) line += ` · ${release.url}`;
216
+ md += `${line}\n`;
217
+
218
+ if (release.description) {
219
+ const releaseDesc = htmlToBasicMarkdown(release.description).replace(/\n+/g, " ").trim();
220
+ if (releaseDesc) md += ` - ${releaseDesc}\n`;
221
+ }
222
+ }
223
+ }
224
+
225
+ const output = finalizeOutput(md);
226
+ return {
227
+ url,
228
+ finalUrl: result.finalUrl,
229
+ contentType: "text/markdown",
230
+ method: "flathub-appstream",
231
+ content: output.content,
232
+ fetchedAt,
233
+ truncated: output.truncated,
234
+ notes: ["Fetched via Flathub Appstream API"],
235
+ };
236
+ } catch {}
237
+
238
+ return null;
239
+ };
@@ -0,0 +1,272 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleGitHub } from "./github";
3
+ import { handleGitHubGist } from "./github-gist";
4
+
5
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
6
+
7
+ // =============================================================================
8
+ // GitHub Tests
9
+ // =============================================================================
10
+
11
+ describe.skipIf(SKIP)("handleGitHub", () => {
12
+ it("returns null for non-GitHub URLs", async () => {
13
+ const result = await handleGitHub("https://example.com", 10000);
14
+ expect(result).toBeNull();
15
+ });
16
+
17
+ it("returns null for other git hosting domains", async () => {
18
+ const result = await handleGitHub("https://gitlab.com/user/repo", 10000);
19
+ expect(result).toBeNull();
20
+ });
21
+
22
+ it("fetches repository root", async () => {
23
+ const result = await handleGitHub("https://github.com/facebook/react", 20000);
24
+ if (result !== null) {
25
+ expect(result.method).toBe("github-repo");
26
+ expect(result.contentType).toBe("text/markdown");
27
+ expect(result.content).toContain("facebook/react");
28
+ expect(result.content).toContain("Stars:");
29
+ expect(result.content).toContain("Forks:");
30
+ }
31
+ expect(result).toBeDefined();
32
+ });
33
+
34
+ it("fetches another repository", async () => {
35
+ const result = await handleGitHub("https://github.com/microsoft/typescript", 20000);
36
+ if (result !== null) {
37
+ expect(result.method).toBe("github-repo");
38
+ // GitHub returns "TypeScript" with capital T
39
+ expect(result.content).toContain("microsoft/TypeScript");
40
+ }
41
+ expect(result).toBeDefined();
42
+ });
43
+
44
+ it("fetches file blob", async () => {
45
+ const result = await handleGitHub("https://github.com/facebook/react/blob/main/README.md", 20000);
46
+ expect(result).not.toBeNull();
47
+ expect(result?.method).toBe("github-raw");
48
+ expect(result?.contentType).toBe("text/plain");
49
+ expect(result?.content.length).toBeGreaterThan(0);
50
+ });
51
+
52
+ it("fetches file blob from specific branch", async () => {
53
+ const result = await handleGitHub("https://github.com/facebook/react/blob/main/package.json", 20000);
54
+ expect(result).not.toBeNull();
55
+ expect(result?.method).toBe("github-raw");
56
+ expect(result?.content.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ it("fetches directory tree", async () => {
60
+ const result = await handleGitHub("https://github.com/facebook/react/tree/main/packages", 20000);
61
+ if (result !== null) {
62
+ expect(result.method).toBe("github-tree");
63
+ expect(result.contentType).toBe("text/markdown");
64
+ expect(result.content).toContain("facebook/react");
65
+ expect(result.content).toContain("Contents");
66
+ }
67
+ expect(result).toBeDefined();
68
+ });
69
+
70
+ it("fetches directory tree from root", async () => {
71
+ const result = await handleGitHub("https://github.com/facebook/react/tree/main", 20000);
72
+ if (result !== null) {
73
+ expect(result.method).toBe("github-tree");
74
+ expect(result.content).toContain("facebook/react");
75
+ }
76
+ expect(result).toBeDefined();
77
+ });
78
+
79
+ it("fetches issue", async () => {
80
+ const result = await handleGitHub("https://github.com/facebook/react/issues/1", 20000);
81
+ if (result !== null) {
82
+ expect(result.method).toBe("github-issue");
83
+ expect(result.contentType).toBe("text/markdown");
84
+ expect(result.content.length).toBeGreaterThan(0);
85
+ }
86
+ expect(result).toBeDefined();
87
+ });
88
+
89
+ it("fetches pull request", async () => {
90
+ const result = await handleGitHub("https://github.com/facebook/react/pull/1", 20000);
91
+ if (result !== null) {
92
+ expect(result.method).toBe("github-pr");
93
+ expect(result.contentType).toBe("text/markdown");
94
+ expect(result.content.length).toBeGreaterThan(0);
95
+ }
96
+ expect(result).toBeDefined();
97
+ });
98
+
99
+ it("fetches issues list", async () => {
100
+ const result = await handleGitHub("https://github.com/facebook/react/issues", 20000);
101
+ if (result !== null) {
102
+ expect(result.method).toBe("github-issues");
103
+ expect(result.contentType).toBe("text/markdown");
104
+ expect(result.content.length).toBeGreaterThan(0);
105
+ }
106
+ expect(result).toBeDefined();
107
+ });
108
+
109
+ it("handles repository with underscore in name", async () => {
110
+ const result = await handleGitHub("https://github.com/rust-lang/rust-analyzer", 20000);
111
+ if (result !== null) {
112
+ expect(result.method).toBe("github-repo");
113
+ }
114
+ expect(result).toBeDefined();
115
+ });
116
+
117
+ it("handles repository with dash in name", async () => {
118
+ const result = await handleGitHub("https://github.com/vercel/next.js", 20000);
119
+ if (result !== null) {
120
+ expect(result.method).toBe("github-repo");
121
+ }
122
+ expect(result).toBeDefined();
123
+ });
124
+
125
+ it("returns null for invalid URL structure", async () => {
126
+ const result = await handleGitHub("https://github.com/", 10000);
127
+ expect(result).toBeNull();
128
+ });
129
+
130
+ it("returns null for single path segment", async () => {
131
+ const result = await handleGitHub("https://github.com/facebook", 10000);
132
+ expect(result).toBeNull();
133
+ });
134
+
135
+ it("handles pulls list endpoint", async () => {
136
+ const result = await handleGitHub("https://github.com/facebook/react/pulls", 20000);
137
+ // Should be handled as pulls list but currently falls back to null
138
+ // This tests the actual behavior
139
+ expect(result).toBeDefined();
140
+ });
141
+
142
+ it("fetches file with path containing multiple directories", async () => {
143
+ const result = await handleGitHub(
144
+ "https://github.com/facebook/react/blob/main/packages/react/package.json",
145
+ 20000,
146
+ );
147
+ expect(result).not.toBeNull();
148
+ expect(result?.method).toBe("github-raw");
149
+ });
150
+
151
+ it("fetches deeply nested directory", async () => {
152
+ const result = await handleGitHub("https://github.com/facebook/react/tree/main/packages/react/src", 20000);
153
+ if (result !== null) {
154
+ expect(result.method).toBe("github-tree");
155
+ }
156
+ expect(result).toBeDefined();
157
+ });
158
+
159
+ it("returns null for discussion URLs", async () => {
160
+ const result = await handleGitHub("https://github.com/facebook/react/discussions", 10000);
161
+ // Discussions not fully implemented, should return null
162
+ expect(result).toBeDefined();
163
+ });
164
+
165
+ it("handles trailing slash in repository URL", async () => {
166
+ const result = await handleGitHub("https://github.com/facebook/react/", 20000);
167
+ if (result !== null) {
168
+ expect(result.method).toBe("github-repo");
169
+ }
170
+ expect(result).toBeDefined();
171
+ });
172
+ });
173
+
174
+ // =============================================================================
175
+ // GitHub Gist Tests
176
+ // =============================================================================
177
+
178
+ describe.skipIf(SKIP)("handleGitHubGist", () => {
179
+ it("returns null for non-gist URLs", async () => {
180
+ const result = await handleGitHubGist("https://example.com", 10000);
181
+ expect(result).toBeNull();
182
+ });
183
+
184
+ it("returns null for github.com URLs", async () => {
185
+ const result = await handleGitHubGist("https://github.com/user/repo", 10000);
186
+ expect(result).toBeNull();
187
+ });
188
+
189
+ it("returns null for gist.github.com root", async () => {
190
+ const result = await handleGitHubGist("https://gist.github.com/", 10000);
191
+ expect(result).toBeNull();
192
+ });
193
+
194
+ it("fetches a public gist with username", async () => {
195
+ // Using a valid public gist ID (may change but structure should be consistent)
196
+ const result = await handleGitHubGist("https://gist.github.com/gaearon/edf814aeee85062bc9b9830aeaf27b88", 20000);
197
+ if (result !== null) {
198
+ expect(result.method).toBe("github-gist");
199
+ expect(result.contentType).toBe("text/markdown");
200
+ expect(result.content).toContain("Gist by");
201
+ expect(result.content).toContain("Created:");
202
+ expect(result.content).toContain("Files:");
203
+ }
204
+ expect(result).toBeDefined();
205
+ });
206
+
207
+ it("fetches a public gist without username in URL", async () => {
208
+ // Same gist, accessed via short URL (without username)
209
+ const result = await handleGitHubGist("https://gist.github.com/edf814aeee85062bc9b9830aeaf27b88", 20000);
210
+ if (result !== null) {
211
+ expect(result.method).toBe("github-gist");
212
+ expect(result.content).toContain("Gist by");
213
+ }
214
+ expect(result).toBeDefined();
215
+ });
216
+
217
+ it("returns null for invalid gist ID format", async () => {
218
+ const result = await handleGitHubGist("https://gist.github.com/invalid-gist-id!", 10000);
219
+ expect(result).toBeNull();
220
+ });
221
+
222
+ it("returns null for non-hexadecimal gist ID", async () => {
223
+ const result = await handleGitHubGist("https://gist.github.com/notahexstring123", 10000);
224
+ expect(result).toBeNull();
225
+ });
226
+
227
+ it("handles gist URL with trailing slash", async () => {
228
+ const result = await handleGitHubGist("https://gist.github.com/gaearon/edf814aeee85062bc9b9830aeaf27b88/", 20000);
229
+ if (result !== null) {
230
+ expect(result.method).toBe("github-gist");
231
+ }
232
+ expect(result).toBeDefined();
233
+ });
234
+
235
+ it("handles gist with revision hash", async () => {
236
+ const result = await handleGitHubGist(
237
+ "https://gist.github.com/gaearon/edf814aeee85062bc9b9830aeaf27b88/abc123",
238
+ 20000,
239
+ );
240
+ // Should handle revision hash in URL path
241
+ expect(result).toBeDefined();
242
+ });
243
+
244
+ it("formats gist content as markdown with code blocks", async () => {
245
+ const result = await handleGitHubGist("https://gist.github.com/gaearon/edf814aeee85062bc9b9830aeaf27b88", 20000);
246
+ if (result !== null) {
247
+ expect(result.content).toContain("```");
248
+ expect(result.content).toContain("---");
249
+ }
250
+ expect(result).toBeDefined();
251
+ });
252
+
253
+ it("includes file metadata", async () => {
254
+ const result = await handleGitHubGist("https://gist.github.com/gaearon/edf814aeee85062bc9b9830aeaf27b88", 20000);
255
+ if (result !== null) {
256
+ expect(result.content).toContain("Created:");
257
+ expect(result.content).toContain("Updated:");
258
+ }
259
+ expect(result).toBeDefined();
260
+ });
261
+
262
+ it("returns null for nonexistent gist", async () => {
263
+ const result = await handleGitHubGist("https://gist.github.com/0000000000000000000000000000000000000000", 20000);
264
+ expect(result).toBeNull();
265
+ });
266
+
267
+ it("handles API rate limiting gracefully", async () => {
268
+ // This test just ensures no errors are thrown
269
+ const result = await handleGitHubGist("https://gist.github.com/gaearon/edf814aeee85062bc9b9830aeaf27b88", 5000);
270
+ expect(result).toBeDefined();
271
+ });
272
+ });
@@ -0,0 +1,68 @@
1
+ import { fetchGitHubApi } from "./github";
2
+ import type { RenderResult, SpecialHandler } from "./types";
3
+ import { finalizeOutput } from "./types";
4
+
5
+ /**
6
+ * Handle GitHub Gist URLs via GitHub API
7
+ */
8
+ export const handleGitHubGist: SpecialHandler = async (
9
+ url: string,
10
+ timeout: number,
11
+ signal?: AbortSignal,
12
+ ): Promise<RenderResult | null> => {
13
+ try {
14
+ const parsed = new URL(url);
15
+ if (parsed.hostname !== "gist.github.com") return null;
16
+
17
+ // Extract gist ID from /username/gistId or just /gistId
18
+ const parts = parsed.pathname.split("/").filter(Boolean);
19
+ if (parts.length === 0) return null;
20
+
21
+ // Gist ID is always the last path segment (or only segment for anonymous gists)
22
+ const gistId = parts[parts.length - 1];
23
+ if (!gistId || !/^[a-f0-9]+$/i.test(gistId)) return null;
24
+
25
+ const fetchedAt = new Date().toISOString();
26
+
27
+ // Fetch via GitHub API
28
+ const result = await fetchGitHubApi(`/gists/${gistId}`, timeout, signal);
29
+ if (!result.ok || !result.data) return null;
30
+
31
+ const gist = result.data as {
32
+ description: string | null;
33
+ owner?: { login: string };
34
+ created_at: string;
35
+ updated_at: string;
36
+ files: Record<string, { filename: string; language: string | null; size: number; content: string }>;
37
+ html_url: string;
38
+ };
39
+
40
+ const files = Object.values(gist.files);
41
+ const owner = gist.owner?.login || "anonymous";
42
+
43
+ let md = `# Gist by ${owner}\n\n`;
44
+ if (gist.description) md += `${gist.description}\n\n`;
45
+ md += `**Created:** ${gist.created_at} · **Updated:** ${gist.updated_at}\n`;
46
+ md += `**Files:** ${files.length}\n\n`;
47
+
48
+ for (const file of files) {
49
+ const lang = file.language?.toLowerCase() || "";
50
+ md += `---\n\n## ${file.filename}\n\n`;
51
+ md += `\`\`\`${lang}\n${file.content}\n\`\`\`\n\n`;
52
+ }
53
+
54
+ const output = finalizeOutput(md);
55
+ return {
56
+ url,
57
+ finalUrl: url,
58
+ contentType: "text/markdown",
59
+ method: "github-gist",
60
+ content: output.content,
61
+ fetchedAt,
62
+ truncated: output.truncated,
63
+ notes: ["Fetched via GitHub API"],
64
+ };
65
+ } catch {}
66
+
67
+ return null;
68
+ };