@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,299 @@
1
+ /**
2
+ * ORCID handler for web-fetch
3
+ */
4
+
5
+ import type { RenderResult, SpecialHandler } from "./types";
6
+ import { finalizeOutput, loadPage } from "./types";
7
+
8
+ const MAX_WORKS = 50;
9
+ const ORCID_PATTERN = /\/(\d{4}-\d{4}-\d{4}-\d{3}[\dXx])(?:\/|$)/;
10
+
11
+ interface OrcidName {
12
+ "given-names"?: { value?: string };
13
+ "family-name"?: { value?: string };
14
+ "credit-name"?: { value?: string };
15
+ }
16
+
17
+ interface OrcidBiography {
18
+ content?: string;
19
+ }
20
+
21
+ interface OrcidPerson {
22
+ name?: OrcidName;
23
+ biography?: OrcidBiography;
24
+ }
25
+
26
+ interface OrcidSummaryDate {
27
+ year?: { value?: string };
28
+ month?: { value?: string };
29
+ day?: { value?: string };
30
+ }
31
+
32
+ interface OrcidOrganizationAddress {
33
+ city?: string;
34
+ region?: string;
35
+ country?: string;
36
+ }
37
+
38
+ interface OrcidOrganization {
39
+ name?: string;
40
+ address?: OrcidOrganizationAddress;
41
+ }
42
+
43
+ interface OrcidAffiliationSummary {
44
+ organization?: OrcidOrganization;
45
+ "role-title"?: string;
46
+ "department-name"?: string;
47
+ "start-date"?: OrcidSummaryDate;
48
+ "end-date"?: OrcidSummaryDate;
49
+ }
50
+
51
+ interface OrcidAffiliationGroupSummary {
52
+ "employment-summary"?: OrcidAffiliationSummary;
53
+ "education-summary"?: OrcidAffiliationSummary;
54
+ }
55
+
56
+ interface OrcidAffiliationGroup {
57
+ summaries?: OrcidAffiliationGroupSummary[];
58
+ }
59
+
60
+ interface OrcidAffiliationsContainer {
61
+ "affiliation-group"?: OrcidAffiliationGroup[];
62
+ "employment-summary"?: OrcidAffiliationSummary[];
63
+ "education-summary"?: OrcidAffiliationSummary[];
64
+ }
65
+
66
+ interface OrcidWorkTitle {
67
+ title?: { value?: string };
68
+ }
69
+
70
+ interface OrcidWorkSummary {
71
+ title?: OrcidWorkTitle;
72
+ }
73
+
74
+ interface OrcidWorkGroup {
75
+ "work-summary"?: OrcidWorkSummary[];
76
+ }
77
+
78
+ interface OrcidWorksContainer {
79
+ group?: OrcidWorkGroup[];
80
+ }
81
+
82
+ interface OrcidActivitiesSummary {
83
+ employments?: OrcidAffiliationsContainer;
84
+ educations?: OrcidAffiliationsContainer;
85
+ works?: OrcidWorksContainer;
86
+ }
87
+
88
+ interface OrcidRecord {
89
+ "orcid-identifier"?: { path?: string; uri?: string };
90
+ person?: OrcidPerson;
91
+ "activities-summary"?: OrcidActivitiesSummary;
92
+ }
93
+
94
+ function isOrcidHost(hostname: string): boolean {
95
+ return hostname === "orcid.org" || hostname === "www.orcid.org";
96
+ }
97
+
98
+ function extractOrcidId(pathname: string): string | null {
99
+ const match = pathname.match(ORCID_PATTERN);
100
+ return match?.[1] ?? null;
101
+ }
102
+
103
+ function formatName(name?: OrcidName): string | null {
104
+ const credit = name?.["credit-name"]?.value?.trim();
105
+ if (credit) return credit;
106
+
107
+ const given = name?.["given-names"]?.value?.trim();
108
+ const family = name?.["family-name"]?.value?.trim();
109
+ if (given && family) return `${given} ${family}`;
110
+ return given || family || null;
111
+ }
112
+
113
+ function formatDate(date?: OrcidSummaryDate): string | null {
114
+ const year = date?.year?.value;
115
+ if (!year) return null;
116
+
117
+ const month = date?.month?.value;
118
+ const day = date?.day?.value;
119
+ if (month && day) {
120
+ return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
121
+ }
122
+ if (month) return `${year}-${month.padStart(2, "0")}`;
123
+ return year;
124
+ }
125
+
126
+ function collectAffiliations(
127
+ container: OrcidAffiliationsContainer | undefined,
128
+ key: "employment-summary" | "education-summary",
129
+ ): OrcidAffiliationSummary[] {
130
+ const summaries: OrcidAffiliationSummary[] = [];
131
+
132
+ if (!container) return summaries;
133
+
134
+ const direct = container[key];
135
+ if (direct?.length) summaries.push(...direct);
136
+
137
+ const groups = container["affiliation-group"];
138
+ if (groups?.length) {
139
+ for (const group of groups) {
140
+ const groupSummaries = group.summaries || [];
141
+ for (const summary of groupSummaries) {
142
+ const entry = summary[key];
143
+ if (entry) summaries.push(entry);
144
+ }
145
+ }
146
+ }
147
+
148
+ return summaries;
149
+ }
150
+
151
+ function formatAffiliation(summary: OrcidAffiliationSummary): string | null {
152
+ const organization = summary.organization?.name?.trim();
153
+ const role = summary["role-title"]?.trim();
154
+ const department = summary["department-name"]?.trim();
155
+
156
+ const address = summary.organization?.address;
157
+ const locationParts = [address?.city, address?.region, address?.country].filter(Boolean) as string[];
158
+ const location = locationParts.length > 0 ? locationParts.join(", ") : null;
159
+
160
+ const start = formatDate(summary["start-date"]);
161
+ const end = formatDate(summary["end-date"]);
162
+ let dates: string | null = null;
163
+ if (start && end) {
164
+ dates = `${start} - ${end}`;
165
+ } else if (start) {
166
+ dates = `${start} - Present`;
167
+ } else if (end) {
168
+ dates = `Until ${end}`;
169
+ }
170
+
171
+ const label = organization || role || department;
172
+ if (!label) return null;
173
+
174
+ const details: string[] = [];
175
+ if (organization && role) details.push(role);
176
+ if (!organization && role && department) details.push(department);
177
+ if (organization && department) details.push(`Dept: ${department}`);
178
+ if (location) details.push(`Location: ${location}`);
179
+ if (dates) details.push(`Dates: ${dates}`);
180
+
181
+ if (details.length === 0) return label;
182
+ return `${label} (${details.join("; ")})`;
183
+ }
184
+
185
+ function collectWorkTitles(container: OrcidWorksContainer | undefined): string[] {
186
+ const titles: string[] = [];
187
+ const seen = new Set<string>();
188
+ const groups = container?.group || [];
189
+
190
+ for (const group of groups) {
191
+ const summaries = group["work-summary"] || [];
192
+ for (const summary of summaries) {
193
+ const title = summary.title?.title?.value?.trim();
194
+ if (!title || seen.has(title)) continue;
195
+ seen.add(title);
196
+ titles.push(title);
197
+ if (titles.length >= MAX_WORKS) return titles;
198
+ }
199
+ }
200
+
201
+ return titles;
202
+ }
203
+
204
+ export const handleOrcid: SpecialHandler = async (
205
+ url: string,
206
+ timeout: number,
207
+ signal?: AbortSignal,
208
+ ): Promise<RenderResult | null> => {
209
+ try {
210
+ const parsed = new URL(url);
211
+ if (!isOrcidHost(parsed.hostname)) return null;
212
+
213
+ const orcid = extractOrcidId(parsed.pathname);
214
+ if (!orcid) return null;
215
+
216
+ const fetchedAt = new Date().toISOString();
217
+ const apiUrl = `https://pub.orcid.org/v3.0/${orcid}/record`;
218
+
219
+ const result = await loadPage(apiUrl, {
220
+ timeout,
221
+ headers: { Accept: "application/json" },
222
+ signal,
223
+ });
224
+
225
+ if (!result.ok || !result.content) return null;
226
+
227
+ let record: OrcidRecord;
228
+ try {
229
+ record = JSON.parse(result.content) as OrcidRecord;
230
+ } catch {
231
+ return null;
232
+ }
233
+
234
+ const personName = formatName(record.person?.name);
235
+ const biography = record.person?.biography?.content?.trim();
236
+
237
+ const activities = record["activities-summary"];
238
+ const employments = collectAffiliations(activities?.employments, "employment-summary");
239
+ const educations = collectAffiliations(activities?.educations, "education-summary");
240
+ const works = collectWorkTitles(activities?.works);
241
+
242
+ let md = `# ${personName || "ORCID Profile"}\n\n`;
243
+ md += `**ORCID:** ${orcid}\n`;
244
+ md += `**ORCID Profile:** https://orcid.org/${orcid}\n\n`;
245
+
246
+ md += "## Biography\n\n";
247
+ md += biography ? `${biography}\n\n` : "No biography available.\n\n";
248
+
249
+ md += "## Affiliations\n\n";
250
+ let hasAffiliations = false;
251
+
252
+ if (employments.length > 0) {
253
+ hasAffiliations = true;
254
+ md += "### Employment\n\n";
255
+ for (const summary of employments) {
256
+ const line = formatAffiliation(summary);
257
+ if (line) md += `- ${line}\n`;
258
+ }
259
+ md += "\n";
260
+ }
261
+
262
+ if (educations.length > 0) {
263
+ hasAffiliations = true;
264
+ md += "### Education\n\n";
265
+ for (const summary of educations) {
266
+ const line = formatAffiliation(summary);
267
+ if (line) md += `- ${line}\n`;
268
+ }
269
+ md += "\n";
270
+ }
271
+
272
+ if (!hasAffiliations) {
273
+ md += "No affiliations available.\n\n";
274
+ }
275
+
276
+ md += "## Works\n\n";
277
+ if (works.length > 0) {
278
+ for (const title of works) {
279
+ md += `- ${title}\n`;
280
+ }
281
+ } else {
282
+ md += "No works available.\n";
283
+ }
284
+
285
+ const output = finalizeOutput(md);
286
+ return {
287
+ url,
288
+ finalUrl: url,
289
+ contentType: "text/markdown",
290
+ method: "orcid-api",
291
+ content: output.content,
292
+ fetchedAt,
293
+ truncated: output.truncated,
294
+ notes: ["Fetched via ORCID Public API"],
295
+ };
296
+ } catch {
297
+ return null;
298
+ }
299
+ };
@@ -0,0 +1,189 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface OsvSeverity {
5
+ type: string;
6
+ score: string;
7
+ }
8
+
9
+ interface OsvAffectedRange {
10
+ type: string;
11
+ events?: Array<{ introduced?: string; fixed?: string; last_affected?: string; limit?: string }>;
12
+ }
13
+
14
+ interface OsvAffected {
15
+ package?: {
16
+ ecosystem: string;
17
+ name: string;
18
+ purl?: string;
19
+ };
20
+ ranges?: OsvAffectedRange[];
21
+ versions?: string[];
22
+ severity?: OsvSeverity[];
23
+ database_specific?: Record<string, unknown>;
24
+ ecosystem_specific?: Record<string, unknown>;
25
+ }
26
+
27
+ interface OsvReference {
28
+ type: string;
29
+ url: string;
30
+ }
31
+
32
+ interface OsvVulnerability {
33
+ id: string;
34
+ summary?: string;
35
+ details?: string;
36
+ aliases?: string[];
37
+ modified?: string;
38
+ published?: string;
39
+ withdrawn?: string;
40
+ severity?: OsvSeverity[];
41
+ affected?: OsvAffected[];
42
+ references?: OsvReference[];
43
+ credits?: Array<{ name: string; contact?: string[]; type?: string }>;
44
+ database_specific?: Record<string, unknown>;
45
+ }
46
+
47
+ /**
48
+ * Handle OSV (Open Source Vulnerabilities) URLs
49
+ */
50
+ export const handleOsv: SpecialHandler = async (
51
+ url: string,
52
+ timeout: number,
53
+ signal?: AbortSignal,
54
+ ): Promise<RenderResult | null> => {
55
+ try {
56
+ const parsed = new URL(url);
57
+ if (parsed.hostname !== "osv.dev") return null;
58
+
59
+ // Extract vulnerability ID from /vulnerability/{id}
60
+ const match = parsed.pathname.match(/^\/vulnerability\/([A-Za-z0-9-]+)$/);
61
+ if (!match) return null;
62
+
63
+ const vulnId = match[1];
64
+ const fetchedAt = new Date().toISOString();
65
+
66
+ // Fetch from OSV API
67
+ const apiUrl = `https://api.osv.dev/v1/vulns/${vulnId}`;
68
+ const result = await loadPage(apiUrl, {
69
+ timeout,
70
+ headers: { Accept: "application/json" },
71
+ signal,
72
+ });
73
+
74
+ if (!result.ok) return null;
75
+
76
+ let vuln: OsvVulnerability;
77
+ try {
78
+ vuln = JSON.parse(result.content);
79
+ } catch {
80
+ return null;
81
+ }
82
+
83
+ let md = `# ${vuln.id}\n\n`;
84
+
85
+ // Summary
86
+ if (vuln.summary) {
87
+ md += `${vuln.summary}\n\n`;
88
+ }
89
+
90
+ // Metadata section
91
+ md += "## Metadata\n\n";
92
+ if (vuln.aliases?.length) {
93
+ md += `**Aliases:** ${vuln.aliases.join(", ")}\n`;
94
+ }
95
+ if (vuln.published) {
96
+ md += `**Published:** ${vuln.published.split("T")[0]}\n`;
97
+ }
98
+ if (vuln.modified) {
99
+ md += `**Modified:** ${vuln.modified.split("T")[0]}\n`;
100
+ }
101
+ if (vuln.withdrawn) {
102
+ md += `**Withdrawn:** ${vuln.withdrawn.split("T")[0]}\n`;
103
+ }
104
+
105
+ // Severity
106
+ const severities = vuln.severity || vuln.affected?.flatMap((a) => a.severity || []) || [];
107
+ if (severities.length) {
108
+ const formatted = severities.map((s) => `${s.type}: ${s.score}`).join(", ");
109
+ md += `**Severity:** ${formatted}\n`;
110
+ }
111
+ md += "\n";
112
+
113
+ // Details
114
+ if (vuln.details) {
115
+ md += `## Details\n\n${vuln.details}\n\n`;
116
+ }
117
+
118
+ // Affected packages
119
+ if (vuln.affected?.length) {
120
+ md += "## Affected Packages\n\n";
121
+ for (const affected of vuln.affected) {
122
+ const pkg = affected.package;
123
+ if (!pkg) continue;
124
+
125
+ md += `### ${pkg.ecosystem}: ${pkg.name}\n\n`;
126
+
127
+ // Version ranges
128
+ if (affected.ranges?.length) {
129
+ for (const range of affected.ranges) {
130
+ if (!range.events?.length) continue;
131
+ const parts: string[] = [];
132
+ for (const event of range.events) {
133
+ if (event.introduced) parts.push(`introduced: ${event.introduced}`);
134
+ if (event.fixed) parts.push(`fixed: ${event.fixed}`);
135
+ if (event.last_affected) parts.push(`last_affected: ${event.last_affected}`);
136
+ if (event.limit) parts.push(`limit: ${event.limit}`);
137
+ }
138
+ if (parts.length) {
139
+ md += `- **${range.type}:** ${parts.join(" → ")}\n`;
140
+ }
141
+ }
142
+ }
143
+
144
+ // Specific versions
145
+ if (affected.versions?.length) {
146
+ const versions =
147
+ affected.versions.length > 10
148
+ ? `${affected.versions.slice(0, 10).join(", ")}... (${affected.versions.length} total)`
149
+ : affected.versions.join(", ");
150
+ md += `- **Versions:** ${versions}\n`;
151
+ }
152
+
153
+ md += "\n";
154
+ }
155
+ }
156
+
157
+ // References
158
+ if (vuln.references?.length) {
159
+ md += "## References\n\n";
160
+ for (const ref of vuln.references) {
161
+ md += `- [${ref.type}](${ref.url})\n`;
162
+ }
163
+ md += "\n";
164
+ }
165
+
166
+ // Credits
167
+ if (vuln.credits?.length) {
168
+ md += "## Credits\n\n";
169
+ for (const credit of vuln.credits) {
170
+ const type = credit.type ? ` (${credit.type})` : "";
171
+ md += `- ${credit.name}${type}\n`;
172
+ }
173
+ }
174
+
175
+ const output = finalizeOutput(md);
176
+ return {
177
+ url,
178
+ finalUrl: url,
179
+ contentType: "text/markdown",
180
+ method: "osv",
181
+ content: output.content,
182
+ fetchedAt,
183
+ truncated: output.truncated,
184
+ notes: ["Fetched via OSV API"],
185
+ };
186
+ } catch {}
187
+
188
+ return null;
189
+ };
@@ -0,0 +1,199 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleChocolatey } from "./chocolatey";
3
+ import { handleDockerHub } from "./dockerhub";
4
+ import { handleHackage } from "./hackage";
5
+ import { handleMetaCPAN } from "./metacpan";
6
+ import { handleRepology } from "./repology";
7
+ import { handleTerraform } from "./terraform";
8
+
9
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
10
+
11
+ describe.skipIf(SKIP)("handleMetaCPAN", () => {
12
+ it("returns null for non-MetaCPAN URLs", async () => {
13
+ const result = await handleMetaCPAN("https://example.com", 20);
14
+ expect(result).toBeNull();
15
+ });
16
+
17
+ it("returns null for non-matching MetaCPAN paths", async () => {
18
+ const result = await handleMetaCPAN("https://metacpan.org/about", 20);
19
+ expect(result).toBeNull();
20
+ });
21
+
22
+ it("fetches Moose module", async () => {
23
+ const result = await handleMetaCPAN("https://metacpan.org/pod/Moose", 20);
24
+ expect(result).not.toBeNull();
25
+ expect(result?.method).toBe("metacpan");
26
+ expect(result?.content).toContain("Moose");
27
+ expect(result?.contentType).toBe("text/markdown");
28
+ expect(result?.fetchedAt).toBeTruthy();
29
+ expect(result?.truncated).toBeDefined();
30
+ });
31
+
32
+ it("fetches release by distribution name", async () => {
33
+ const result = await handleMetaCPAN("https://metacpan.org/release/Moose", 20);
34
+ expect(result).not.toBeNull();
35
+ expect(result?.method).toBe("metacpan");
36
+ expect(result?.content).toContain("Moose");
37
+ });
38
+ });
39
+
40
+ describe.skipIf(SKIP)("handleHackage", () => {
41
+ it("returns null for non-Hackage URLs", async () => {
42
+ const result = await handleHackage("https://example.com", 20);
43
+ expect(result).toBeNull();
44
+ });
45
+
46
+ it("returns null for non-package Hackage paths", async () => {
47
+ const result = await handleHackage("https://hackage.haskell.org/", 20);
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ it("fetches aeson package", async () => {
52
+ const result = await handleHackage("https://hackage.haskell.org/package/aeson", 20);
53
+ expect(result).not.toBeNull();
54
+ expect(result?.method).toBe("hackage");
55
+ expect(result?.content).toContain("aeson");
56
+ expect(result?.content).toContain("JSON");
57
+ expect(result?.contentType).toBe("text/markdown");
58
+ expect(result?.fetchedAt).toBeTruthy();
59
+ expect(result?.truncated).toBeDefined();
60
+ });
61
+
62
+ it("fetches text package", async () => {
63
+ const result = await handleHackage("https://hackage.haskell.org/package/text", 20);
64
+ expect(result).not.toBeNull();
65
+ expect(result?.method).toBe("hackage");
66
+ expect(result?.content).toContain("text");
67
+ });
68
+ });
69
+
70
+ describe.skipIf(SKIP)("handleDockerHub", () => {
71
+ it("returns null for non-DockerHub URLs", async () => {
72
+ const result = await handleDockerHub("https://example.com", 20);
73
+ expect(result).toBeNull();
74
+ });
75
+
76
+ it("returns null for non-matching DockerHub paths", async () => {
77
+ const result = await handleDockerHub("https://hub.docker.com/search", 20);
78
+ expect(result).toBeNull();
79
+ });
80
+
81
+ it("fetches official nginx image", async () => {
82
+ const result = await handleDockerHub("https://hub.docker.com/_/nginx", 20);
83
+ expect(result).not.toBeNull();
84
+ expect(result?.method).toBe("dockerhub");
85
+ expect(result?.content).toContain("nginx");
86
+ expect(result?.content).toContain("docker pull");
87
+ expect(result?.contentType).toBe("text/markdown");
88
+ expect(result?.fetchedAt).toBeTruthy();
89
+ expect(result?.truncated).toBeDefined();
90
+ });
91
+
92
+ it("fetches grafana/grafana image", async () => {
93
+ const result = await handleDockerHub("https://hub.docker.com/r/grafana/grafana", 20);
94
+ expect(result).not.toBeNull();
95
+ expect(result?.method).toBe("dockerhub");
96
+ expect(result?.content).toContain("grafana");
97
+ expect(result?.content).toContain("docker pull");
98
+ });
99
+ });
100
+
101
+ describe.skipIf(SKIP)("handleChocolatey", () => {
102
+ it("returns null for non-Chocolatey URLs", async () => {
103
+ const result = await handleChocolatey("https://example.com", 20);
104
+ expect(result).toBeNull();
105
+ });
106
+
107
+ it("returns null for non-package Chocolatey paths", async () => {
108
+ const result = await handleChocolatey("https://community.chocolatey.org/", 20);
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ it("fetches git package", async () => {
113
+ const result = await handleChocolatey("https://community.chocolatey.org/packages/git", 20);
114
+ expect(result).not.toBeNull();
115
+ expect(result?.method).toBe("chocolatey");
116
+ expect(result?.content).toContain("Git");
117
+ expect(result?.content).toContain("choco install");
118
+ expect(result?.contentType).toBe("text/markdown");
119
+ expect(result?.fetchedAt).toBeTruthy();
120
+ expect(result?.truncated).toBeDefined();
121
+ });
122
+
123
+ it("fetches nodejs package", async () => {
124
+ const result = await handleChocolatey("https://community.chocolatey.org/packages/nodejs", 20);
125
+ expect(result).not.toBeNull();
126
+ expect(result?.method).toBe("chocolatey");
127
+ expect(result?.content).toContain("Node");
128
+ });
129
+ });
130
+
131
+ describe.skipIf(SKIP)("handleRepology", () => {
132
+ it("returns null for non-Repology URLs", async () => {
133
+ const result = await handleRepology("https://example.com", 20);
134
+ expect(result).toBeNull();
135
+ });
136
+
137
+ it("returns null for non-project Repology paths", async () => {
138
+ const result = await handleRepology("https://repology.org/", 20);
139
+ expect(result).toBeNull();
140
+ });
141
+
142
+ it("fetches firefox project", async () => {
143
+ const result = await handleRepology("https://repology.org/project/firefox", 20);
144
+ expect(result).not.toBeNull();
145
+ expect(result?.method).toBe("repology");
146
+ expect(result?.content).toContain("firefox");
147
+ expect(result?.content).toContain("Repositories");
148
+ expect(result?.contentType).toBe("text/markdown");
149
+ expect(result?.fetchedAt).toBeTruthy();
150
+ expect(result?.truncated).toBeDefined();
151
+ });
152
+
153
+ it("fetches vim project", async () => {
154
+ const result = await handleRepology("https://repology.org/project/vim/versions", 20);
155
+ expect(result).not.toBeNull();
156
+ expect(result?.method).toBe("repology");
157
+ expect(result?.content).toContain("vim");
158
+ });
159
+ });
160
+
161
+ describe.skipIf(SKIP)("handleTerraform", () => {
162
+ it("returns null for non-Terraform URLs", async () => {
163
+ const result = await handleTerraform("https://example.com", 20);
164
+ expect(result).toBeNull();
165
+ });
166
+
167
+ it("returns null for non-matching Terraform paths", async () => {
168
+ const result = await handleTerraform("https://registry.terraform.io/", 20);
169
+ expect(result).toBeNull();
170
+ });
171
+
172
+ it("fetches hashicorp/aws provider", async () => {
173
+ const result = await handleTerraform("https://registry.terraform.io/providers/hashicorp/aws", 20);
174
+ expect(result).not.toBeNull();
175
+ expect(result?.method).toBe("terraform");
176
+ expect(result?.content).toContain("aws");
177
+ expect(result?.content).toContain("hashicorp");
178
+ expect(result?.content).toContain("required_providers");
179
+ expect(result?.contentType).toBe("text/markdown");
180
+ expect(result?.fetchedAt).toBeTruthy();
181
+ expect(result?.truncated).toBeDefined();
182
+ });
183
+
184
+ it("fetches terraform-aws-modules/vpc/aws module", async () => {
185
+ const result = await handleTerraform("https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws", 20);
186
+ expect(result).not.toBeNull();
187
+ expect(result?.method).toBe("terraform");
188
+ expect(result?.content).toContain("vpc");
189
+ expect(result?.content).toContain("terraform-aws-modules");
190
+ expect(result?.content).toContain("module");
191
+ });
192
+
193
+ it("fetches hashicorp/random provider", async () => {
194
+ const result = await handleTerraform("https://registry.terraform.io/providers/hashicorp/random", 20);
195
+ expect(result).not.toBeNull();
196
+ expect(result?.method).toBe("terraform");
197
+ expect(result?.content).toContain("random");
198
+ });
199
+ });