@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,217 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface SearchcodeResult {
5
+ id?: number | string;
6
+ filename?: string;
7
+ repo?: string;
8
+ language?: string;
9
+ code?: string;
10
+ lines?: number | string | Array<number | string>;
11
+ location?: string;
12
+ url?: string;
13
+ }
14
+
15
+ interface SearchcodeSearchResponse {
16
+ query?: string;
17
+ results?: SearchcodeResult[];
18
+ total?: number;
19
+ total_results?: number;
20
+ nextpage?: number;
21
+ }
22
+
23
+ const VALID_HOSTS = new Set(["searchcode.com", "www.searchcode.com"]);
24
+
25
+ function parseLineNumbers(lines: SearchcodeResult["lines"]): number[] | null {
26
+ if (typeof lines === "number" && Number.isFinite(lines)) return [lines];
27
+
28
+ if (typeof lines === "string") {
29
+ const parts = lines.split(/[,\s]+/).filter(Boolean);
30
+ const parsed = parts.map((part) => Number.parseInt(part, 10)).filter((value) => Number.isFinite(value));
31
+ return parsed.length ? parsed : null;
32
+ }
33
+
34
+ if (Array.isArray(lines)) {
35
+ const parsed = lines.map((part) => Number.parseInt(String(part), 10)).filter((value) => Number.isFinite(value));
36
+ return parsed.length ? parsed : null;
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ function formatLineNumbers(lines: number[] | null): string | null {
43
+ if (!lines || lines.length === 0) return null;
44
+ if (lines.length <= 10) return lines.join(", ");
45
+ const min = Math.min(...lines);
46
+ const max = Math.max(...lines);
47
+ return `${min}-${max} (${lines.length} lines)`;
48
+ }
49
+
50
+ function formatCodeBlock(
51
+ code: string | undefined,
52
+ language: string | undefined,
53
+ lines: number[] | null,
54
+ ): string | null {
55
+ if (!code) return null;
56
+
57
+ const normalized = code.replace(/\r\n/g, "\n").trimEnd();
58
+ const codeLines = normalized.split("\n");
59
+ const languageTag = typeof language === "string" ? language.trim().toLowerCase() : "";
60
+
61
+ let displayLines = codeLines;
62
+ if (lines && lines.length === codeLines.length) {
63
+ displayLines = codeLines.map((line, index) => `${lines[index]}: ${line}`);
64
+ }
65
+
66
+ const fence = languageTag ? languageTag : "";
67
+ return `\n\n\`\`\`${fence}\n${displayLines.join("\n")}\n\`\`\`\n`;
68
+ }
69
+
70
+ export const handleSearchcode: SpecialHandler = async (
71
+ url: string,
72
+ timeout: number,
73
+ signal?: AbortSignal,
74
+ ): Promise<RenderResult | null> => {
75
+ try {
76
+ const parsed = new URL(url);
77
+ if (!VALID_HOSTS.has(parsed.hostname)) return null;
78
+
79
+ const fetchedAt = new Date().toISOString();
80
+ const viewMatch = parsed.pathname.match(/^\/codesearch\/view\/([^/?#]+)/);
81
+ if (viewMatch) {
82
+ const id = viewMatch[1];
83
+ const apiUrl = `https://searchcode.com/api/result/${encodeURIComponent(id)}/`;
84
+ const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } });
85
+ if (!result.ok) return null;
86
+
87
+ let data: SearchcodeResult;
88
+ try {
89
+ data = JSON.parse(result.content) as SearchcodeResult;
90
+ } catch {
91
+ return null;
92
+ }
93
+
94
+ const filename = data.filename || data.location || `Result ${id}`;
95
+ const lineNumbers = parseLineNumbers(data.lines);
96
+ const formattedLines = formatLineNumbers(lineNumbers);
97
+ const viewUrl = data.url || `https://searchcode.com/codesearch/view/${id}`;
98
+ const snippetBlock = formatCodeBlock(data.code, data.language, lineNumbers);
99
+
100
+ let md = `# ${filename}\n\n`;
101
+ md += `## Description\n\n`;
102
+ md += "Code snippet from searchcode.com.\n\n";
103
+ md += `## Metadata\n\n`;
104
+ if (data.repo) md += `**Repository:** ${data.repo}\n`;
105
+ if (data.language) md += `**Language:** ${data.language}\n`;
106
+ if (data.filename) md += `**File:** ${data.filename}\n`;
107
+ if (data.location) md += `**Location:** ${data.location}\n`;
108
+ if (formattedLines) md += `**Lines:** ${formattedLines}\n`;
109
+ md += `**Result ID:** ${id}\n`;
110
+ md += `**URL:** ${viewUrl}\n`;
111
+
112
+ md += `\n## Snippet`;
113
+ if (snippetBlock) {
114
+ md += snippetBlock;
115
+ } else {
116
+ md += "\n\n_No snippet available._\n";
117
+ }
118
+
119
+ const output = finalizeOutput(md);
120
+ return {
121
+ url,
122
+ finalUrl: url,
123
+ contentType: "text/markdown",
124
+ method: "searchcode",
125
+ content: output.content,
126
+ fetchedAt,
127
+ truncated: output.truncated,
128
+ notes: ["Fetched via searchcode API"],
129
+ };
130
+ }
131
+
132
+ const query = parsed.searchParams.get("q");
133
+ const isSearchPage =
134
+ parsed.pathname === "/" || parsed.pathname === "/codesearch" || parsed.pathname === "/codesearch/";
135
+ if (!query || !isSearchPage) return null;
136
+
137
+ const pageRaw = parsed.searchParams.get("p") ?? parsed.searchParams.get("page");
138
+ const pageNumber = pageRaw ? Number.parseInt(pageRaw, 10) : 0;
139
+ const page = Number.isFinite(pageNumber) && pageNumber >= 0 ? pageNumber : 0;
140
+ const apiUrl = `https://searchcode.com/api/codesearch_I/?q=${encodeURIComponent(query)}&p=${page}`;
141
+ const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } });
142
+ if (!result.ok) return null;
143
+
144
+ let data: SearchcodeSearchResponse;
145
+ try {
146
+ data = JSON.parse(result.content) as SearchcodeSearchResponse;
147
+ } catch {
148
+ return null;
149
+ }
150
+
151
+ const results = Array.isArray(data.results) ? data.results : [];
152
+ const total =
153
+ typeof data.total === "number"
154
+ ? data.total
155
+ : typeof data.total_results === "number"
156
+ ? data.total_results
157
+ : null;
158
+
159
+ let md = `# Searchcode Results\n\n`;
160
+ md += `## Description\n\n`;
161
+ md += `Search results for \`${query}\` on searchcode.com.\n\n`;
162
+ md += `## Metadata\n\n`;
163
+ md += `**Query:** \`${query}\`\n`;
164
+ md += `**Page:** ${page}\n`;
165
+ if (total !== null) md += `**Total Results:** ${formatCount(total)}\n`;
166
+ md += `**Result Count:** ${results.length}\n`;
167
+ if (typeof data.nextpage === "number") md += `**Next Page:** ${data.nextpage}\n`;
168
+
169
+ md += `\n## Results\n\n`;
170
+
171
+ if (results.length === 0) {
172
+ md += "_No results found._\n";
173
+ } else {
174
+ const maxResults = 10;
175
+ for (const resultItem of results.slice(0, maxResults)) {
176
+ const id = resultItem.id !== undefined ? String(resultItem.id) : null;
177
+ const filename = resultItem.filename || resultItem.location || "Result";
178
+ const lineNumbers = parseLineNumbers(resultItem.lines);
179
+ const formattedLines = formatLineNumbers(lineNumbers);
180
+ const viewUrl = resultItem.url || (id ? `https://searchcode.com/codesearch/view/${id}` : null);
181
+ const snippetBlock = formatCodeBlock(resultItem.code, resultItem.language, lineNumbers);
182
+
183
+ md += `### ${filename}\n\n`;
184
+ if (resultItem.repo) md += `**Repository:** ${resultItem.repo}\n`;
185
+ if (resultItem.language) md += `**Language:** ${resultItem.language}\n`;
186
+ if (resultItem.filename) md += `**File:** ${resultItem.filename}\n`;
187
+ if (resultItem.location) md += `**Location:** ${resultItem.location}\n`;
188
+ if (formattedLines) md += `**Lines:** ${formattedLines}\n`;
189
+ if (viewUrl) md += `**URL:** ${viewUrl}\n`;
190
+
191
+ if (snippetBlock) {
192
+ md += `${snippetBlock}\n`;
193
+ }
194
+
195
+ md += "\n";
196
+ }
197
+
198
+ if (results.length > maxResults) {
199
+ md += `\n_Only showing first ${maxResults} results._\n`;
200
+ }
201
+ }
202
+
203
+ const output = finalizeOutput(md);
204
+ return {
205
+ url,
206
+ finalUrl: url,
207
+ contentType: "text/markdown",
208
+ method: "searchcode",
209
+ content: output.content,
210
+ fetchedAt,
211
+ truncated: output.truncated,
212
+ notes: ["Fetched via searchcode API"],
213
+ };
214
+ } catch {}
215
+
216
+ return null;
217
+ };
@@ -153,7 +153,11 @@ function buildFilingUrl(cik: string, accessionNumber: string, document: string):
153
153
  /**
154
154
  * Handle SEC EDGAR URLs via data.sec.gov API
155
155
  */
156
- export const handleSecEdgar: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
156
+ export const handleSecEdgar: SpecialHandler = async (
157
+ url: string,
158
+ timeout: number,
159
+ signal?: AbortSignal,
160
+ ): Promise<RenderResult | null> => {
157
161
  try {
158
162
  const parsed = new URL(url);
159
163
 
@@ -171,6 +175,7 @@ export const handleSecEdgar: SpecialHandler = async (url: string, timeout: numbe
171
175
  const apiUrl = `https://data.sec.gov/submissions/CIK${cik}.json`;
172
176
  const result = await loadPage(apiUrl, {
173
177
  timeout,
178
+ signal,
174
179
  headers: {
175
180
  "User-Agent": "CodingAgent/1.0 (research tool)",
176
181
  Accept: "application/json",
@@ -43,7 +43,7 @@ function extractPaperId(url: string): string | null {
43
43
  return null;
44
44
  }
45
45
 
46
- export const handleSemanticScholar: SpecialHandler = async (url: string, timeout: number) => {
46
+ export const handleSemanticScholar: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
47
47
  if (!url.includes("semanticscholar.org")) return null;
48
48
 
49
49
  const paperId = extractPaperId(url);
@@ -77,7 +77,7 @@ export const handleSemanticScholar: SpecialHandler = async (url: string, timeout
77
77
 
78
78
  const apiUrl = `https://api.semanticscholar.org/graph/v1/paper/${paperId}?fields=${fields}`;
79
79
 
80
- const { content, ok, finalUrl } = await loadPage(apiUrl, { timeout });
80
+ const { content, ok, finalUrl } = await loadPage(apiUrl, { timeout, signal });
81
81
 
82
82
  if (!ok || !content) {
83
83
  return {
@@ -0,0 +1,200 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface SnapcraftPublisher {
5
+ "display-name"?: string;
6
+ username?: string;
7
+ id?: string;
8
+ validation?: string;
9
+ }
10
+
11
+ interface SnapcraftChannel {
12
+ name?: string;
13
+ track?: string;
14
+ risk?: string;
15
+ branch?: string | null;
16
+ architecture?: string;
17
+ "released-at"?: string;
18
+ }
19
+
20
+ interface SnapcraftDownload {
21
+ size?: number;
22
+ url?: string;
23
+ "sha3-384"?: string;
24
+ }
25
+
26
+ interface SnapcraftChannelMapEntry {
27
+ channel?: SnapcraftChannel;
28
+ version?: string;
29
+ revision?: number | string;
30
+ download?: SnapcraftDownload;
31
+ type?: string;
32
+ "created-at"?: string;
33
+ }
34
+
35
+ interface SnapcraftSnap {
36
+ name?: string;
37
+ title?: string;
38
+ summary?: string;
39
+ description?: string;
40
+ publisher?: SnapcraftPublisher;
41
+ version?: string;
42
+ confinement?: string;
43
+ base?: string;
44
+ downloads?: number;
45
+ download?: number;
46
+ }
47
+
48
+ interface SnapcraftResponse {
49
+ name?: string;
50
+ title?: string;
51
+ summary?: string;
52
+ description?: string;
53
+ publisher?: SnapcraftPublisher;
54
+ version?: string;
55
+ confinement?: string;
56
+ base?: string;
57
+ downloads?: number;
58
+ download?: number;
59
+ snap?: SnapcraftSnap;
60
+ "channel-map"?: SnapcraftChannelMapEntry[];
61
+ }
62
+
63
+ function formatPublisher(publisher?: SnapcraftPublisher): string | null {
64
+ if (!publisher) return null;
65
+ const displayName = publisher["display-name"] ?? publisher.username ?? publisher.id;
66
+ if (!displayName) return null;
67
+ if (publisher.username && displayName !== publisher.username) {
68
+ return `${displayName} (@${publisher.username})`;
69
+ }
70
+ return displayName;
71
+ }
72
+
73
+ function formatChannelName(channel?: SnapcraftChannel): string | null {
74
+ if (!channel) return null;
75
+ if (channel.name?.includes("/")) return channel.name;
76
+ if (channel.track && channel.risk) {
77
+ const branch = channel.branch ? `/${channel.branch}` : "";
78
+ return `${channel.track}/${channel.risk}${branch}`;
79
+ }
80
+ return channel.name ?? null;
81
+ }
82
+
83
+ function pickVersionFromChannels(entries: SnapcraftChannelMapEntry[]): string | undefined {
84
+ const stable = entries.find((entry) => entry.channel?.risk === "stable" && entry.version);
85
+ if (stable?.version) return stable.version;
86
+ const first = entries.find((entry) => entry.version);
87
+ return first?.version;
88
+ }
89
+
90
+ function extractDownloads(snapInfo: SnapcraftSnap | SnapcraftResponse, data: SnapcraftResponse): number | null {
91
+ const candidates = [snapInfo.downloads, snapInfo.download, data.downloads, data.download];
92
+ for (const value of candidates) {
93
+ if (typeof value === "number" && Number.isFinite(value)) return value;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ export const handleSnapcraft: SpecialHandler = async (
99
+ url: string,
100
+ timeout: number,
101
+ signal?: AbortSignal,
102
+ ): Promise<RenderResult | null> => {
103
+ try {
104
+ const parsed = new URL(url);
105
+ if (parsed.hostname !== "snapcraft.io" && parsed.hostname !== "www.snapcraft.io") return null;
106
+
107
+ const installMatch = parsed.pathname.match(/^\/install\/([^/]+)\/?$/);
108
+ const directMatch = parsed.pathname.match(/^\/([^/]+)\/?$/);
109
+ if (!installMatch && !directMatch) return null;
110
+
111
+ const snapName = decodeURIComponent((installMatch ?? directMatch)![1]);
112
+ const fetchedAt = new Date().toISOString();
113
+
114
+ const apiUrl = `https://api.snapcraft.io/v2/snaps/info/${encodeURIComponent(snapName)}`;
115
+ const result = await loadPage(apiUrl, {
116
+ timeout,
117
+ signal,
118
+ headers: {
119
+ Accept: "application/json",
120
+ "Snap-Device-Series": "16",
121
+ },
122
+ });
123
+ if (!result.ok) return null;
124
+
125
+ let data: SnapcraftResponse;
126
+ try {
127
+ data = JSON.parse(result.content) as SnapcraftResponse;
128
+ } catch {
129
+ return null;
130
+ }
131
+
132
+ const snapInfo = data.snap ?? data;
133
+ const name = snapInfo.title ?? snapInfo.name ?? data.name ?? snapName;
134
+ const summary = snapInfo.summary ?? data.summary;
135
+ const description = snapInfo.description ?? data.description;
136
+ const publisher = formatPublisher(snapInfo.publisher ?? data.publisher);
137
+ const confinement = snapInfo.confinement ?? data.confinement;
138
+ const base = snapInfo.base ?? data.base;
139
+
140
+ const channelMap = data["channel-map"] ?? [];
141
+ let version = snapInfo.version ?? data.version;
142
+ if (!version && channelMap.length > 0) {
143
+ version = pickVersionFromChannels(channelMap);
144
+ }
145
+
146
+ const downloads = extractDownloads(snapInfo, data);
147
+
148
+ const channels = new Map<string, { version?: string; architectures: Set<string> }>();
149
+ for (const entry of channelMap) {
150
+ const channelName = formatChannelName(entry.channel);
151
+ if (!channelName) continue;
152
+ const existing = channels.get(channelName) ?? { architectures: new Set<string>() };
153
+ if (!existing.version && entry.version) existing.version = entry.version;
154
+ if (entry.channel?.architecture) existing.architectures.add(entry.channel.architecture);
155
+ channels.set(channelName, existing);
156
+ }
157
+
158
+ let md = `# ${name}\n\n`;
159
+ if (summary) md += `${summary}\n\n`;
160
+
161
+ md += `**Version:** ${version ?? "unknown"}`;
162
+ if (confinement) md += ` · **Confinement:** ${confinement}`;
163
+ if (base) md += ` · **Base:** ${base}`;
164
+ md += "\n";
165
+ if (publisher) md += `**Publisher:** ${publisher}\n`;
166
+ if (downloads !== null) md += `**Downloads:** ${formatCount(downloads)}\n`;
167
+ md += "\n";
168
+
169
+ if (channels.size > 0) {
170
+ md += "## Channels\n\n";
171
+ const sortedChannels = Array.from(channels.entries()).sort((a, b) => a[0].localeCompare(b[0]));
172
+ for (const [channelName, info] of sortedChannels) {
173
+ const arches = Array.from(info.architectures).sort();
174
+ const versionSuffix = info.version ? `: ${info.version}` : "";
175
+ const archSuffix = arches.length > 0 ? ` (${arches.join(", ")})` : "";
176
+ md += `- ${channelName}${versionSuffix}${archSuffix}\n`;
177
+ }
178
+ md += "\n";
179
+ }
180
+
181
+ const descriptionText = description ?? summary;
182
+ if (descriptionText) {
183
+ md += `## Description\n\n${descriptionText}\n`;
184
+ }
185
+
186
+ const output = finalizeOutput(md);
187
+ return {
188
+ url,
189
+ finalUrl: url,
190
+ contentType: "text/markdown",
191
+ method: "snapcraft",
192
+ content: output.content,
193
+ fetchedAt,
194
+ truncated: output.truncated,
195
+ notes: ["Fetched via Snapcraft API"],
196
+ };
197
+ } catch {}
198
+
199
+ return null;
200
+ };