@oh-my-pi/pi-coding-agent 3.30.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 (155) hide show
  1. package/CHANGELOG.md +71 -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/edit-diff.ts +11 -4
  16. package/src/core/tools/edit.ts +7 -13
  17. package/src/core/tools/find.ts +111 -50
  18. package/src/core/tools/gemini-image.ts +128 -147
  19. package/src/core/tools/grep.ts +397 -415
  20. package/src/core/tools/index.test.ts +5 -1
  21. package/src/core/tools/index.ts +6 -8
  22. package/src/core/tools/ls.ts +12 -10
  23. package/src/core/tools/lsp/client.ts +58 -9
  24. package/src/core/tools/lsp/config.ts +205 -656
  25. package/src/core/tools/lsp/defaults.json +465 -0
  26. package/src/core/tools/lsp/index.ts +55 -32
  27. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  28. package/src/core/tools/lsp/types.ts +1 -0
  29. package/src/core/tools/lsp/utils.ts +1 -1
  30. package/src/core/tools/read.ts +150 -74
  31. package/src/core/tools/render-utils.ts +70 -10
  32. package/src/core/tools/review.ts +38 -126
  33. package/src/core/tools/task/artifacts.ts +5 -4
  34. package/src/core/tools/task/executor.ts +94 -83
  35. package/src/core/tools/task/index.ts +129 -92
  36. package/src/core/tools/task/parallel.ts +30 -3
  37. package/src/core/tools/task/render.ts +85 -39
  38. package/src/core/tools/task/types.ts +15 -6
  39. package/src/core/tools/task/worker.ts +124 -89
  40. package/src/core/tools/web-fetch.ts +112 -377
  41. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  42. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  43. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  49. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  50. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  51. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  52. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  53. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  54. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  57. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  59. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  60. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  61. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  62. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  63. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  64. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  71. package/src/core/tools/web-scrapers/index.ts +250 -0
  72. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  73. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  74. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  75. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  76. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  79. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  82. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  83. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  84. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  86. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  87. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  90. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  93. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  96. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  99. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  102. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  103. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  104. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  105. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  106. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  107. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  111. package/src/core/tools/web-scrapers/utils.ts +162 -0
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  113. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  114. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  116. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  117. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  118. package/src/core/tools/write.ts +21 -18
  119. package/src/core/voice.ts +3 -2
  120. package/src/lib/worktree/collapse.ts +2 -1
  121. package/src/lib/worktree/git.ts +2 -18
  122. package/src/main.ts +59 -3
  123. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  124. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  125. package/src/modes/interactive/components/hook-editor.ts +2 -1
  126. package/src/modes/interactive/components/model-selector.ts +19 -4
  127. package/src/modes/interactive/interactive-mode.ts +41 -38
  128. package/src/modes/interactive/theme/theme.ts +58 -58
  129. package/src/modes/rpc/rpc-mode.ts +10 -9
  130. package/src/prompts/review-request.md +27 -0
  131. package/src/prompts/reviewer.md +64 -68
  132. package/src/prompts/tools/output.md +22 -3
  133. package/src/prompts/tools/task.md +32 -33
  134. package/src/utils/clipboard.ts +2 -1
  135. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  136. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  137. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  138. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  139. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  140. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -0,0 +1,214 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ type LocalizedText = string | Record<string, string | null | undefined> | null | undefined;
5
+
6
+ type AddonFile = {
7
+ permissions?: string[];
8
+ host_permissions?: string[];
9
+ optional_permissions?: string[];
10
+ optional_host_permissions?: string[];
11
+ };
12
+
13
+ type AddonLicense = {
14
+ name?: LocalizedText;
15
+ slug?: string;
16
+ url?: string;
17
+ };
18
+
19
+ type AddonVersion = {
20
+ version?: string;
21
+ license?: AddonLicense;
22
+ file?: AddonFile;
23
+ };
24
+
25
+ type AddonHomepage = {
26
+ url?: LocalizedText;
27
+ outgoing?: LocalizedText;
28
+ };
29
+
30
+ type AddonData = {
31
+ name?: LocalizedText;
32
+ summary?: LocalizedText;
33
+ description?: LocalizedText;
34
+ default_locale?: string;
35
+ authors?: Array<{ name?: string | null }>;
36
+ average_daily_users?: number;
37
+ weekly_downloads?: number;
38
+ ratings?: { average?: number; count?: number };
39
+ current_version?: AddonVersion;
40
+ categories?: string[] | Record<string, string[]>;
41
+ homepage?: AddonHomepage;
42
+ url?: string;
43
+ };
44
+
45
+ function getLocalizedText(value: LocalizedText, defaultLocale?: string): string | undefined {
46
+ if (!value) return undefined;
47
+ if (typeof value === "string") return value;
48
+
49
+ const localized = value as Record<string, string | null | undefined>;
50
+ if (defaultLocale && localized[defaultLocale]) return localized[defaultLocale] ?? undefined;
51
+ if (localized["en-US"]) return localized["en-US"] ?? undefined;
52
+
53
+ for (const entry of Object.values(localized)) {
54
+ if (entry) return entry;
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ function normalizeCategories(categories?: string[] | Record<string, string[]>): string[] {
61
+ if (!categories) return [];
62
+ if (Array.isArray(categories)) return categories.filter(Boolean);
63
+
64
+ const values: string[] = [];
65
+ for (const list of Object.values(categories)) {
66
+ if (Array.isArray(list)) {
67
+ for (const item of list) {
68
+ if (item) values.push(item);
69
+ }
70
+ }
71
+ }
72
+
73
+ const seen = new Set<string>();
74
+ return values.filter((item) => {
75
+ if (seen.has(item)) return false;
76
+ seen.add(item);
77
+ return true;
78
+ });
79
+ }
80
+
81
+ function collectPermissions(file?: AddonFile): string[] {
82
+ if (!file) return [];
83
+ const permissions: string[] = [];
84
+ const seen = new Set<string>();
85
+
86
+ const add = (items?: string[]) => {
87
+ for (const item of items ?? []) {
88
+ if (!item || seen.has(item)) continue;
89
+ seen.add(item);
90
+ permissions.push(item);
91
+ }
92
+ };
93
+
94
+ add(file.permissions);
95
+ add(file.host_permissions);
96
+ add(file.optional_permissions);
97
+ add(file.optional_host_permissions);
98
+
99
+ return permissions;
100
+ }
101
+
102
+ export const handleFirefoxAddons: SpecialHandler = async (
103
+ url: string,
104
+ timeout: number,
105
+ signal?: AbortSignal,
106
+ ): Promise<RenderResult | null> => {
107
+ try {
108
+ const parsed = new URL(url);
109
+ if (parsed.hostname !== "addons.mozilla.org") return null;
110
+
111
+ const segments = parsed.pathname.split("/").filter(Boolean);
112
+ const addonIndex = segments.indexOf("addon");
113
+ if (addonIndex === -1) return null;
114
+
115
+ const slug = segments[addonIndex + 1] ? decodeURIComponent(segments[addonIndex + 1]) : "";
116
+ if (!slug) return null;
117
+
118
+ const apiUrl = `https://addons.mozilla.org/api/v5/addons/addon/${encodeURIComponent(slug)}/`;
119
+ const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, signal });
120
+ if (!result.ok) return null;
121
+
122
+ let data: AddonData;
123
+ try {
124
+ data = JSON.parse(result.content) as AddonData;
125
+ } catch {
126
+ return null;
127
+ }
128
+
129
+ const fetchedAt = new Date().toISOString();
130
+ const defaultLocale = data.default_locale || "en-US";
131
+
132
+ const name = getLocalizedText(data.name, defaultLocale) ?? slug;
133
+ const summary = getLocalizedText(data.summary, defaultLocale);
134
+ const descriptionRaw = getLocalizedText(data.description, defaultLocale);
135
+ const description = descriptionRaw ? htmlToBasicMarkdown(descriptionRaw) : undefined;
136
+
137
+ const authors = (data.authors ?? [])
138
+ .map((author) => author.name ?? "")
139
+ .map((author) => author.trim())
140
+ .filter(Boolean);
141
+
142
+ const ratingAverage = data.ratings?.average;
143
+ const ratingCount = data.ratings?.count;
144
+ const users = data.average_daily_users ?? data.weekly_downloads;
145
+ const version = data.current_version?.version;
146
+ const categories = normalizeCategories(data.categories);
147
+
148
+ const licenseName =
149
+ getLocalizedText(data.current_version?.license?.name, defaultLocale) ?? data.current_version?.license?.slug;
150
+ const licenseUrl = data.current_version?.license?.url;
151
+
152
+ const homepage =
153
+ getLocalizedText(data.homepage?.url, defaultLocale) ??
154
+ getLocalizedText(data.homepage?.outgoing, defaultLocale);
155
+
156
+ const permissions = collectPermissions(data.current_version?.file);
157
+
158
+ let md = `# ${name}\n\n`;
159
+ if (summary) md += `${summary}\n\n`;
160
+
161
+ if (authors.length > 0) {
162
+ md += `**Author${authors.length > 1 ? "s" : ""}:** ${authors.join(", ")}\n`;
163
+ }
164
+
165
+ if (ratingAverage !== undefined) {
166
+ md += `**Rating:** ${ratingAverage.toFixed(2)}`;
167
+ if (ratingCount !== undefined) md += ` (${formatCount(ratingCount)} reviews)`;
168
+ md += "\n";
169
+ }
170
+
171
+ if (users !== undefined) md += `**Users:** ${formatCount(users)}\n`;
172
+ if (version) md += `**Version:** ${version}\n`;
173
+ if (categories.length > 0) md += `**Categories:** ${categories.join(", ")}\n`;
174
+
175
+ if (licenseName && licenseUrl) {
176
+ md += `**License:** [${licenseName}](${licenseUrl})\n`;
177
+ } else if (licenseName) {
178
+ md += `**License:** ${licenseName}\n`;
179
+ } else if (licenseUrl) {
180
+ md += `**License:** ${licenseUrl}\n`;
181
+ }
182
+
183
+ if (homepage) md += `**Homepage:** ${homepage}\n`;
184
+
185
+ if (description) {
186
+ md += `\n## Description\n\n${description}\n`;
187
+ }
188
+
189
+ if (permissions.length > 0) {
190
+ const preview = permissions.slice(0, 40);
191
+ md += `\n## Permissions (${permissions.length})\n\n`;
192
+ for (const permission of preview) {
193
+ md += `- ${permission}\n`;
194
+ }
195
+ if (permissions.length > preview.length) {
196
+ md += `\n*...and ${permissions.length - preview.length} more*\n`;
197
+ }
198
+ }
199
+
200
+ const output = finalizeOutput(md);
201
+ return {
202
+ url,
203
+ finalUrl: data.url ?? result.finalUrl ?? url,
204
+ contentType: "text/markdown",
205
+ method: "firefox-addons",
206
+ content: output.content,
207
+ fetchedAt,
208
+ truncated: output.truncated,
209
+ notes: ["Fetched via Firefox Add-ons API"],
210
+ };
211
+ } catch {}
212
+
213
+ return null;
214
+ };
@@ -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
+ };
@@ -5,7 +5,11 @@ import { finalizeOutput } from "./types";
5
5
  /**
6
6
  * Handle GitHub Gist URLs via GitHub API
7
7
  */
8
- export const handleGitHubGist: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
8
+ export const handleGitHubGist: SpecialHandler = async (
9
+ url: string,
10
+ timeout: number,
11
+ signal?: AbortSignal,
12
+ ): Promise<RenderResult | null> => {
9
13
  try {
10
14
  const parsed = new URL(url);
11
15
  if (parsed.hostname !== "gist.github.com") return null;
@@ -21,7 +25,7 @@ export const handleGitHubGist: SpecialHandler = async (url: string, timeout: num
21
25
  const fetchedAt = new Date().toISOString();
22
26
 
23
27
  // Fetch via GitHub API
24
- const result = await fetchGitHubApi(`/gists/${gistId}`, timeout);
28
+ const result = await fetchGitHubApi(`/gists/${gistId}`, timeout, signal);
25
29
  if (!result.ok || !result.data) return null;
26
30
 
27
31
  const gist = result.data as {
@@ -1,5 +1,5 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, loadPage } from "./types";
2
+ import { createRequestSignal, finalizeOutput, loadPage } from "./types";
3
3
 
4
4
  interface GitHubUrl {
5
5
  type: "blob" | "tree" | "repo" | "issue" | "issues" | "pull" | "pulls" | "discussion" | "discussions" | "other";
@@ -64,16 +64,19 @@ function parseGitHubUrl(url: string): GitHubUrl | null {
64
64
  * Convert GitHub blob URL to raw URL
65
65
  */
66
66
  function toRawGitHubUrl(gh: GitHubUrl): string {
67
- return `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/refs/heads/${gh.ref}/${gh.path}`;
67
+ return `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/${gh.ref}/${gh.path}`;
68
68
  }
69
69
 
70
70
  /**
71
71
  * Fetch from GitHub API
72
72
  */
73
- export async function fetchGitHubApi(endpoint: string, timeout: number): Promise<{ data: unknown; ok: boolean }> {
73
+ export async function fetchGitHubApi(
74
+ endpoint: string,
75
+ timeout: number,
76
+ signal?: AbortSignal,
77
+ ): Promise<{ data: unknown; ok: boolean }> {
74
78
  try {
75
- const controller = new AbortController();
76
- const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
79
+ const { signal: requestSignal, cleanup } = createRequestSignal(timeout * 1000, signal);
77
80
 
78
81
  const headers: Record<string, string> = {
79
82
  Accept: "application/vnd.github.v3+json",
@@ -86,18 +89,20 @@ export async function fetchGitHubApi(endpoint: string, timeout: number): Promise
86
89
  headers.Authorization = `Bearer ${token}`;
87
90
  }
88
91
 
89
- const response = await fetch(`https://api.github.com${endpoint}`, {
90
- signal: controller.signal,
91
- headers,
92
- });
92
+ try {
93
+ const response = await fetch(`https://api.github.com${endpoint}`, {
94
+ signal: requestSignal,
95
+ headers,
96
+ });
93
97
 
94
- clearTimeout(timeoutId);
98
+ if (!response.ok) {
99
+ return { data: null, ok: false };
100
+ }
95
101
 
96
- if (!response.ok) {
97
- return { data: null, ok: false };
102
+ return { data: await response.json(), ok: true };
103
+ } finally {
104
+ cleanup();
98
105
  }
99
-
100
- return { data: await response.json(), ok: true };
101
106
  } catch {
102
107
  return { data: null, ok: false };
103
108
  }
@@ -106,13 +111,17 @@ export async function fetchGitHubApi(endpoint: string, timeout: number): Promise
106
111
  /**
107
112
  * Render GitHub issue/PR to markdown
108
113
  */
109
- async function renderGitHubIssue(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> {
114
+ async function renderGitHubIssue(
115
+ gh: GitHubUrl,
116
+ timeout: number,
117
+ signal?: AbortSignal,
118
+ ): Promise<{ content: string; ok: boolean }> {
110
119
  const endpoint =
111
120
  gh.type === "pull"
112
121
  ? `/repos/${gh.owner}/${gh.repo}/pulls/${gh.number}`
113
122
  : `/repos/${gh.owner}/${gh.repo}/issues/${gh.number}`;
114
123
 
115
- const result = await fetchGitHubApi(endpoint, timeout);
124
+ const result = await fetchGitHubApi(endpoint, timeout, signal);
116
125
  if (!result.ok || !result.data) return { content: "", ok: false };
117
126
 
118
127
  const issue = result.data as {
@@ -143,6 +152,7 @@ async function renderGitHubIssue(gh: GitHubUrl, timeout: number): Promise<{ cont
143
152
  const commentsResult = await fetchGitHubApi(
144
153
  `/repos/${gh.owner}/${gh.repo}/issues/${gh.number}/comments?per_page=50`,
145
154
  timeout,
155
+ signal,
146
156
  );
147
157
  if (commentsResult.ok && Array.isArray(commentsResult.data)) {
148
158
  md += `## Comments (${issue.comments})\n\n`;
@@ -163,8 +173,12 @@ async function renderGitHubIssue(gh: GitHubUrl, timeout: number): Promise<{ cont
163
173
  /**
164
174
  * Render GitHub issues list to markdown
165
175
  */
166
- async function renderGitHubIssuesList(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> {
167
- const result = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/issues?state=open&per_page=30`, timeout);
176
+ async function renderGitHubIssuesList(
177
+ gh: GitHubUrl,
178
+ timeout: number,
179
+ signal?: AbortSignal,
180
+ ): Promise<{ content: string; ok: boolean }> {
181
+ const result = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/issues?state=open&per_page=30`, timeout, signal);
168
182
  if (!result.ok || !Array.isArray(result.data)) return { content: "", ok: false };
169
183
 
170
184
  const issues = result.data as Array<{
@@ -193,9 +207,13 @@ async function renderGitHubIssuesList(gh: GitHubUrl, timeout: number): Promise<{
193
207
  /**
194
208
  * Render GitHub tree (directory) to markdown
195
209
  */
196
- async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> {
210
+ async function renderGitHubTree(
211
+ gh: GitHubUrl,
212
+ timeout: number,
213
+ signal?: AbortSignal,
214
+ ): Promise<{ content: string; ok: boolean }> {
197
215
  // Fetch repo info first to get default branch if ref not specified
198
- const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout);
216
+ const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout, signal);
199
217
  if (!repoResult.ok) return { content: "", ok: false };
200
218
 
201
219
  const repo = repoResult.data as {
@@ -210,7 +228,11 @@ async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ conte
210
228
  md += `**Branch:** ${ref}\n\n`;
211
229
 
212
230
  // Fetch directory contents
213
- const contentsResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/contents/${dirPath}?ref=${ref}`, timeout);
231
+ const contentsResult = await fetchGitHubApi(
232
+ `/repos/${gh.owner}/${gh.repo}/contents/${dirPath}?ref=${ref}`,
233
+ timeout,
234
+ signal,
235
+ );
214
236
 
215
237
  if (contentsResult.ok && Array.isArray(contentsResult.data)) {
216
238
  const items = contentsResult.data as Array<{
@@ -240,8 +262,8 @@ async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ conte
240
262
  const readmeFile = items.find((item) => item.type === "file" && /^readme\.md$/i.test(item.name));
241
263
  if (readmeFile) {
242
264
  const readmePath = dirPath ? `${dirPath}/${readmeFile.name}` : readmeFile.name;
243
- const rawUrl = `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/refs/heads/${ref}/${readmePath}`;
244
- const readmeResult = await loadPage(rawUrl, { timeout });
265
+ const rawUrl = `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/${ref}/${readmePath}`;
266
+ const readmeResult = await loadPage(rawUrl, { timeout, signal });
245
267
  if (readmeResult.ok) {
246
268
  md += `---\n\n## README\n\n${readmeResult.content}`;
247
269
  }
@@ -254,9 +276,13 @@ async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ conte
254
276
  /**
255
277
  * Render GitHub repo to markdown (file list + README)
256
278
  */
257
- async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> {
279
+ async function renderGitHubRepo(
280
+ gh: GitHubUrl,
281
+ timeout: number,
282
+ signal?: AbortSignal,
283
+ ): Promise<{ content: string; ok: boolean }> {
258
284
  // Fetch repo info
259
- const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout);
285
+ const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout, signal);
260
286
  if (!repoResult.ok) return { content: "", ok: false };
261
287
 
262
288
  const repo = repoResult.data as {
@@ -281,6 +307,7 @@ async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ conte
281
307
  const treeResult = await fetchGitHubApi(
282
308
  `/repos/${gh.owner}/${gh.repo}/git/trees/${repo.default_branch}?recursive=1`,
283
309
  timeout,
310
+ signal,
284
311
  );
285
312
  if (treeResult.ok && treeResult.data) {
286
313
  const tree = (treeResult.data as { tree: Array<{ path: string; type: string }> }).tree;
@@ -297,7 +324,7 @@ async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ conte
297
324
  }
298
325
 
299
326
  // Fetch README
300
- const readmeResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/readme`, timeout);
327
+ const readmeResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/readme`, timeout, signal);
301
328
  if (readmeResult.ok && readmeResult.data) {
302
329
  const readme = readmeResult.data as { content: string; encoding: string };
303
330
  if (readme.encoding === "base64") {
@@ -312,7 +339,11 @@ async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ conte
312
339
  /**
313
340
  * Handle GitHub URLs specially
314
341
  */
315
- export const handleGitHub: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
342
+ export const handleGitHub: SpecialHandler = async (
343
+ url: string,
344
+ timeout: number,
345
+ signal?: AbortSignal,
346
+ ): Promise<RenderResult | null> => {
316
347
  const gh = parseGitHubUrl(url);
317
348
  if (!gh) return null;
318
349
 
@@ -324,7 +355,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number)
324
355
  // Convert to raw URL and fetch
325
356
  const rawUrl = toRawGitHubUrl(gh);
326
357
  notes.push(`Fetched raw: ${rawUrl}`);
327
- const result = await loadPage(rawUrl, { timeout });
358
+ const result = await loadPage(rawUrl, { timeout, signal });
328
359
  if (result.ok) {
329
360
  const output = finalizeOutput(result.content);
330
361
  return {
@@ -343,7 +374,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number)
343
374
 
344
375
  case "tree": {
345
376
  notes.push(`Fetched via GitHub API`);
346
- const result = await renderGitHubTree(gh, timeout);
377
+ const result = await renderGitHubTree(gh, timeout, signal);
347
378
  if (result.ok) {
348
379
  const output = finalizeOutput(result.content);
349
380
  return {
@@ -363,7 +394,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number)
363
394
  case "issue":
364
395
  case "pull": {
365
396
  notes.push(`Fetched via GitHub API`);
366
- const result = await renderGitHubIssue(gh, timeout);
397
+ const result = await renderGitHubIssue(gh, timeout, signal);
367
398
  if (result.ok) {
368
399
  const output = finalizeOutput(result.content);
369
400
  return {
@@ -382,7 +413,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number)
382
413
 
383
414
  case "issues": {
384
415
  notes.push(`Fetched via GitHub API`);
385
- const result = await renderGitHubIssuesList(gh, timeout);
416
+ const result = await renderGitHubIssuesList(gh, timeout, signal);
386
417
  if (result.ok) {
387
418
  const output = finalizeOutput(result.content);
388
419
  return {
@@ -401,7 +432,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number)
401
432
 
402
433
  case "repo": {
403
434
  notes.push(`Fetched via GitHub API`);
404
- const result = await renderGitHubRepo(gh, timeout);
435
+ const result = await renderGitHubRepo(gh, timeout, signal);
405
436
  if (result.ok) {
406
437
  const output = finalizeOutput(result.content);
407
438
  return {