@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
@@ -53,7 +53,11 @@ interface ArtifactHubPackage {
53
53
  * Handle Artifact Hub URLs via API
54
54
  * Supports Helm charts, OLM operators, Falco rules, OPA policies, etc.
55
55
  */
56
- export const handleArtifactHub: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
56
+ export const handleArtifactHub: SpecialHandler = async (
57
+ url: string,
58
+ timeout: number,
59
+ signal?: AbortSignal,
60
+ ): Promise<RenderResult | null> => {
57
61
  try {
58
62
  const parsed = new URL(url);
59
63
  if (parsed.hostname !== "artifacthub.io" && parsed.hostname !== "www.artifacthub.io") return null;
@@ -70,6 +74,7 @@ export const handleArtifactHub: SpecialHandler = async (url: string, timeout: nu
70
74
  const result = await loadPage(apiUrl, {
71
75
  timeout,
72
76
  headers: { Accept: "application/json" },
77
+ signal,
73
78
  });
74
79
 
75
80
  if (!result.ok) return null;
@@ -6,7 +6,11 @@ import { convertWithMarkitdown, fetchBinary } from "./utils";
6
6
  /**
7
7
  * Handle arXiv URLs via arXiv API
8
8
  */
9
- export const handleArxiv: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
9
+ export const handleArxiv: SpecialHandler = async (
10
+ url: string,
11
+ timeout: number,
12
+ signal?: AbortSignal,
13
+ ): Promise<RenderResult | null> => {
10
14
  try {
11
15
  const parsed = new URL(url);
12
16
  if (parsed.hostname !== "arxiv.org") return null;
@@ -22,7 +26,7 @@ export const handleArxiv: SpecialHandler = async (url: string, timeout: number):
22
26
 
23
27
  // Fetch metadata via arXiv API
24
28
  const apiUrl = `https://export.arxiv.org/api/query?id_list=${paperId}`;
25
- const result = await loadPage(apiUrl, { timeout });
29
+ const result = await loadPage(apiUrl, { timeout, signal });
26
30
 
27
31
  if (!result.ok) return null;
28
32
 
@@ -56,9 +60,9 @@ export const handleArxiv: SpecialHandler = async (url: string, timeout: number):
56
60
  if (match[1] === "pdf" || parsed.pathname.includes(".pdf")) {
57
61
  if (pdfLink) {
58
62
  notes.push("Fetching PDF for full content...");
59
- const pdfResult = await fetchBinary(pdfLink, timeout);
63
+ const pdfResult = await fetchBinary(pdfLink, timeout, signal);
60
64
  if (pdfResult.ok) {
61
- const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
65
+ const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout, signal);
62
66
  if (converted.ok && converted.content.length > 500) {
63
67
  md += `---\n\n## Full Paper\n\n${converted.content}\n`;
64
68
  notes.push("PDF converted via markitdown");
@@ -35,7 +35,11 @@ interface AurResponse {
35
35
  /**
36
36
  * Handle AUR (Arch User Repository) URLs via RPC API
37
37
  */
38
- export const handleAur: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
38
+ export const handleAur: SpecialHandler = async (
39
+ url: string,
40
+ timeout: number,
41
+ signal?: AbortSignal,
42
+ ): Promise<RenderResult | null> => {
39
43
  try {
40
44
  const parsed = new URL(url);
41
45
  if (parsed.hostname !== "aur.archlinux.org") return null;
@@ -49,7 +53,7 @@ export const handleAur: SpecialHandler = async (url: string, timeout: number): P
49
53
 
50
54
  // Fetch from AUR RPC API
51
55
  const apiUrl = `https://aur.archlinux.org/rpc/?v=5&type=info&arg=${encodeURIComponent(packageName)}`;
52
- const result = await loadPage(apiUrl, { timeout });
56
+ const result = await loadPage(apiUrl, { timeout, signal });
53
57
 
54
58
  if (!result.ok) return null;
55
59
 
@@ -27,7 +27,11 @@ interface BiorxivResponse {
27
27
  /**
28
28
  * Handle bioRxiv and medRxiv preprint URLs via their API
29
29
  */
30
- export const handleBiorxiv: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
30
+ export const handleBiorxiv: SpecialHandler = async (
31
+ url: string,
32
+ timeout: number,
33
+ signal?: AbortSignal,
34
+ ): Promise<RenderResult | null> => {
31
35
  try {
32
36
  const parsed = new URL(url);
33
37
  const hostname = parsed.hostname.toLowerCase();
@@ -54,6 +58,7 @@ export const handleBiorxiv: SpecialHandler = async (url: string, timeout: number
54
58
  const result = await loadPage(apiUrl, {
55
59
  timeout,
56
60
  headers: { Accept: "application/json" },
61
+ signal,
57
62
  });
58
63
 
59
64
  if (!result.ok) return null;
@@ -54,11 +54,12 @@ interface ThreadViewPost {
54
54
  /**
55
55
  * Resolve a handle to DID using the profile API
56
56
  */
57
- async function resolveHandle(handle: string, timeout: number): Promise<string | null> {
57
+ async function resolveHandle(handle: string, timeout: number, signal?: AbortSignal): Promise<string | null> {
58
58
  const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`;
59
59
  const result = await loadPage(url, {
60
60
  timeout,
61
61
  headers: { Accept: "application/json" },
62
+ signal,
62
63
  });
63
64
 
64
65
  if (!result.ok) return null;
@@ -148,7 +149,11 @@ function formatPost(post: BlueskyPost, isQuote = false): string {
148
149
  /**
149
150
  * Handle Bluesky post URLs
150
151
  */
151
- export const handleBluesky: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
152
+ export const handleBluesky: SpecialHandler = async (
153
+ url: string,
154
+ timeout: number,
155
+ signal?: AbortSignal,
156
+ ): Promise<RenderResult | null> => {
152
157
  try {
153
158
  const parsed = new URL(url);
154
159
  if (!["bsky.app", "www.bsky.app"].includes(parsed.hostname)) {
@@ -167,7 +172,7 @@ export const handleBluesky: SpecialHandler = async (url: string, timeout: number
167
172
  const rkey = pathParts[3];
168
173
 
169
174
  // First resolve handle to DID
170
- const did = await resolveHandle(handle, timeout);
175
+ const did = await resolveHandle(handle, timeout, signal);
171
176
  if (!did) return null;
172
177
 
173
178
  // Construct AT URI and fetch thread
@@ -177,6 +182,7 @@ export const handleBluesky: SpecialHandler = async (url: string, timeout: number
177
182
  const result = await loadPage(threadUrl, {
178
183
  timeout,
179
184
  headers: { Accept: "application/json" },
185
+ signal,
180
186
  });
181
187
 
182
188
  if (!result.ok) return null;
@@ -230,6 +236,7 @@ export const handleBluesky: SpecialHandler = async (url: string, timeout: number
230
236
  const result = await loadPage(profileUrl, {
231
237
  timeout,
232
238
  headers: { Accept: "application/json" },
239
+ signal,
233
240
  });
234
241
 
235
242
  if (!result.ok) return null;
@@ -58,7 +58,11 @@ function getInstallCount(analytics?: { install?: { "30d"?: Record<string, number
58
58
  /**
59
59
  * Handle Homebrew formulae and cask URLs via API
60
60
  */
61
- export const handleBrew: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
61
+ export const handleBrew: SpecialHandler = async (
62
+ url: string,
63
+ timeout: number,
64
+ signal?: AbortSignal,
65
+ ): Promise<RenderResult | null> => {
62
66
  try {
63
67
  const parsed = new URL(url);
64
68
  if (parsed.hostname !== "formulae.brew.sh") return null;
@@ -76,7 +80,7 @@ export const handleBrew: SpecialHandler = async (url: string, timeout: number):
76
80
  ? `https://formulae.brew.sh/api/formula/${encodeURIComponent(name)}.json`
77
81
  : `https://formulae.brew.sh/api/cask/${encodeURIComponent(name)}.json`;
78
82
 
79
- const result = await loadPage(apiUrl, { timeout });
83
+ const result = await loadPage(apiUrl, { timeout, signal });
80
84
  if (!result.ok) return null;
81
85
 
82
86
  let md: string;
@@ -7,7 +7,11 @@ import { finalizeOutput, loadPage } from "./types";
7
7
  * API: Plain text at https://cheat.sh/{topic}?T (T flag removes ANSI colors)
8
8
  * Supports: commands, language/topic queries (e.g., python/list, go/slice)
9
9
  */
10
- export const handleCheatSh: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
10
+ export const handleCheatSh: SpecialHandler = async (
11
+ url: string,
12
+ timeout: number,
13
+ signal?: AbortSignal,
14
+ ): Promise<RenderResult | null> => {
11
15
  try {
12
16
  const parsed = new URL(url);
13
17
  if (parsed.hostname !== "cheat.sh" && parsed.hostname !== "cht.sh") return null;
@@ -22,6 +26,7 @@ export const handleCheatSh: SpecialHandler = async (url: string, timeout: number
22
26
  const apiUrl = `https://cheat.sh/${encodeURIComponent(topic)}?T`;
23
27
  const result = await loadPage(apiUrl, {
24
28
  timeout,
29
+ signal,
25
30
  headers: {
26
31
  Accept: "text/plain",
27
32
  },
@@ -28,7 +28,11 @@ interface NuGetODataResponse {
28
28
  /**
29
29
  * Handle Chocolatey package URLs via NuGet v2 OData API
30
30
  */
31
- export const handleChocolatey: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
31
+ export const handleChocolatey: SpecialHandler = async (
32
+ url: string,
33
+ timeout: number,
34
+ signal?: AbortSignal,
35
+ ): Promise<RenderResult | null> => {
32
36
  try {
33
37
  const parsed = new URL(url);
34
38
  if (!parsed.hostname.includes("chocolatey.org")) return null;
@@ -53,6 +57,7 @@ export const handleChocolatey: SpecialHandler = async (url: string, timeout: num
53
57
 
54
58
  const result = await loadPage(apiUrl, {
55
59
  timeout,
60
+ signal,
56
61
  headers: {
57
62
  Accept: "application/json",
58
63
  },
@@ -0,0 +1,110 @@
1
+ import { parseFrontmatter } from "../../../discovery/helpers";
2
+ import type { RenderResult, SpecialHandler } from "./types";
3
+ import { finalizeOutput, loadPage } from "./types";
4
+
5
+ const ALLOWED_HOSTS = new Set(["choosealicense.com", "www.choosealicense.com"]);
6
+ const LICENSE_PATH = /^\/licenses\/([^/]+)\/?$/i;
7
+ const APPENDIX_PATH = /^\/appendix\/?$/i;
8
+
9
+ function asString(value: unknown): string | undefined {
10
+ if (typeof value !== "string") return undefined;
11
+ const trimmed = value.trim();
12
+ return trimmed.length > 0 ? trimmed : undefined;
13
+ }
14
+
15
+ function normalizeList(value: unknown): string[] {
16
+ if (Array.isArray(value)) {
17
+ return value
18
+ .filter((item): item is string => typeof item === "string")
19
+ .map((item) => item.trim())
20
+ .filter((item) => item.length > 0);
21
+ }
22
+ if (typeof value === "string") {
23
+ return value
24
+ .split(",")
25
+ .map((item) => item.trim())
26
+ .filter((item) => item.length > 0);
27
+ }
28
+ return [];
29
+ }
30
+
31
+ function formatLabel(value: string): string {
32
+ const cleaned = value.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
33
+ if (!cleaned) return value;
34
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
35
+ }
36
+
37
+ function formatSection(title: string, items: string[]): string {
38
+ let md = `## ${title}\n\n`;
39
+ if (items.length === 0) {
40
+ md += "- None listed\n\n";
41
+ return md;
42
+ }
43
+ for (const item of items) {
44
+ md += `- ${formatLabel(item)}\n`;
45
+ }
46
+ md += "\n";
47
+ return md;
48
+ }
49
+
50
+ export const handleChooseALicense: 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 (!ALLOWED_HOSTS.has(parsed.hostname)) return null;
58
+
59
+ const licenseMatch = parsed.pathname.match(LICENSE_PATH);
60
+ const isAppendix = APPENDIX_PATH.test(parsed.pathname);
61
+ if (!licenseMatch && !isAppendix) return null;
62
+
63
+ const licenseSlug = licenseMatch ? decodeURIComponent(licenseMatch[1]).toLowerCase() : "appendix";
64
+ const rawUrl = licenseMatch
65
+ ? `https://raw.githubusercontent.com/github/choosealicense.com/gh-pages/_licenses/${licenseSlug}.txt`
66
+ : "https://raw.githubusercontent.com/github/choosealicense.com/gh-pages/_pages/appendix.md";
67
+
68
+ const fetchedAt = new Date().toISOString();
69
+ const result = await loadPage(rawUrl, { timeout, headers: { Accept: "text/plain" }, signal });
70
+ if (!result.ok) return null;
71
+
72
+ const { frontmatter, body } = parseFrontmatter(result.content);
73
+
74
+ const title = asString(frontmatter.title) ?? formatLabel(licenseSlug);
75
+ const spdxId = asString(frontmatter["spdx-id"]) ?? "Unknown";
76
+ const description = asString(frontmatter.description);
77
+ const permissions = normalizeList(frontmatter.permissions);
78
+ const conditions = normalizeList(frontmatter.conditions);
79
+ const limitations = normalizeList(frontmatter.limitations);
80
+
81
+ let md = `# ${title}\n\n`;
82
+ if (description) md += `${description}\n\n`;
83
+
84
+ md += `**SPDX ID:** ${spdxId}\n`;
85
+ md += `**Source:** https://choosealicense.com${isAppendix ? "/appendix" : `/licenses/${licenseSlug}/`}\n\n`;
86
+
87
+ md += formatSection("Permissions", permissions);
88
+ md += formatSection("Conditions", conditions);
89
+ md += formatSection("Limitations", limitations);
90
+
91
+ const licenseText = body.trim();
92
+ if (licenseText.length > 0) {
93
+ md += `---\n\n## License Text\n\n${licenseText}\n`;
94
+ }
95
+
96
+ const output = finalizeOutput(md);
97
+ return {
98
+ url,
99
+ finalUrl: url,
100
+ contentType: "text/markdown",
101
+ method: "choosealicense",
102
+ content: output.content,
103
+ fetchedAt,
104
+ truncated: output.truncated,
105
+ notes: ["Fetched via Choose a License"],
106
+ };
107
+ } catch {}
108
+
109
+ return null;
110
+ };
@@ -0,0 +1,100 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface KevEntry {
5
+ cveID: string;
6
+ vendorProject?: string;
7
+ product?: string;
8
+ vulnerabilityName?: string;
9
+ shortDescription?: string;
10
+ requiredAction?: string;
11
+ dateAdded?: string;
12
+ dueDate?: string;
13
+ }
14
+
15
+ interface KevCatalog {
16
+ title?: string;
17
+ catalogVersion?: string;
18
+ dateReleased?: string;
19
+ count?: number;
20
+ vulnerabilities?: KevEntry[];
21
+ }
22
+
23
+ const CVE_PATTERN = /CVE-\d{4}-\d{4,7}/i;
24
+ const KEV_FEED_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
25
+
26
+ /**
27
+ * Handle CISA Known Exploited Vulnerabilities (KEV) URLs
28
+ */
29
+ export const handleCisaKev: SpecialHandler = async (
30
+ url: string,
31
+ timeout: number,
32
+ signal?: AbortSignal,
33
+ ): Promise<RenderResult | null> => {
34
+ try {
35
+ const parsed = new URL(url);
36
+ const hostname = parsed.hostname.toLowerCase();
37
+ if (!hostname.endsWith("cisa.gov")) return null;
38
+
39
+ const path = parsed.pathname.toLowerCase();
40
+ if (!path.includes("known-exploited-vulnerabilities")) return null;
41
+
42
+ const cveMatch = parsed.pathname.match(CVE_PATTERN) ?? parsed.search.match(CVE_PATTERN);
43
+ if (!cveMatch) return null;
44
+
45
+ const cveId = cveMatch[0].toUpperCase();
46
+ const fetchedAt = new Date().toISOString();
47
+
48
+ const result = await loadPage(KEV_FEED_URL, {
49
+ timeout,
50
+ headers: { Accept: "application/json" },
51
+ signal,
52
+ });
53
+
54
+ if (!result.ok) return null;
55
+
56
+ let data: KevCatalog;
57
+ try {
58
+ data = JSON.parse(result.content) as KevCatalog;
59
+ } catch {
60
+ return null;
61
+ }
62
+
63
+ const entry = data.vulnerabilities?.find((item) => item.cveID?.toUpperCase() === cveId);
64
+ if (!entry) return null;
65
+
66
+ let md = `# ${entry.cveID}\n\n`;
67
+ if (entry.vulnerabilityName) {
68
+ md += `${entry.vulnerabilityName}\n\n`;
69
+ }
70
+
71
+ md += "## Metadata\n\n";
72
+ if (entry.vendorProject) md += `**Vendor:** ${entry.vendorProject}\n`;
73
+ if (entry.product) md += `**Product:** ${entry.product}\n`;
74
+ if (entry.dateAdded) md += `**Date Added:** ${entry.dateAdded}\n`;
75
+ if (entry.dueDate) md += `**Due Date:** ${entry.dueDate}\n`;
76
+ md += "\n";
77
+
78
+ if (entry.shortDescription) {
79
+ md += `## Description\n\n${entry.shortDescription}\n\n`;
80
+ }
81
+
82
+ if (entry.requiredAction) {
83
+ md += `## Required Action\n\n${entry.requiredAction}\n\n`;
84
+ }
85
+
86
+ const output = finalizeOutput(md);
87
+ return {
88
+ url,
89
+ finalUrl: url,
90
+ contentType: "text/markdown",
91
+ method: "cisa-kev",
92
+ content: output.content,
93
+ fetchedAt,
94
+ truncated: output.truncated,
95
+ notes: ["Fetched via CISA KEV feed"],
96
+ };
97
+ } catch {}
98
+
99
+ return null;
100
+ };
@@ -0,0 +1,180 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ function isRecord(value: unknown): value is Record<string, unknown> {
5
+ return typeof value === "object" && value !== null;
6
+ }
7
+
8
+ function asString(value: unknown): string | null {
9
+ if (typeof value !== "string") return null;
10
+ const trimmed = value.trim();
11
+ return trimmed.length > 0 ? trimmed : null;
12
+ }
13
+
14
+ function asNumber(value: unknown): number | null {
15
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
16
+ }
17
+
18
+ function formatLicenses(licenses: unknown): string[] {
19
+ if (!Array.isArray(licenses)) return [];
20
+ const output: string[] = [];
21
+ for (const license of licenses) {
22
+ if (typeof license === "string") {
23
+ const trimmed = license.trim();
24
+ if (trimmed) output.push(trimmed);
25
+ continue;
26
+ }
27
+ if (isRecord(license)) {
28
+ const name = asString(license.name);
29
+ const url = asString(license.url);
30
+ if (name && url) {
31
+ output.push(`${name} (${url})`);
32
+ } else if (name) {
33
+ output.push(name);
34
+ } else if (url) {
35
+ output.push(url);
36
+ }
37
+ }
38
+ }
39
+ return output;
40
+ }
41
+
42
+ function formatDependencies(deps: unknown): string[] {
43
+ const output: string[] = [];
44
+ if (Array.isArray(deps)) {
45
+ for (const dep of deps) {
46
+ if (typeof dep === "string") {
47
+ const trimmed = dep.trim();
48
+ if (trimmed) output.push(trimmed);
49
+ continue;
50
+ }
51
+ if (Array.isArray(dep)) {
52
+ const name = asString(dep[0]);
53
+ const version = asString(dep[1]);
54
+ if (name && version) {
55
+ output.push(`${name}: ${version}`);
56
+ } else if (name) {
57
+ output.push(name);
58
+ }
59
+ continue;
60
+ }
61
+ if (isRecord(dep)) {
62
+ const name = asString(dep.name) ?? asString(dep.artifact) ?? asString(dep.jar_name);
63
+ const version = asString(dep.version);
64
+ if (name && version) {
65
+ output.push(`${name}: ${version}`);
66
+ } else if (name) {
67
+ output.push(name);
68
+ }
69
+ }
70
+ }
71
+ return output;
72
+ }
73
+
74
+ if (isRecord(deps)) {
75
+ for (const [name, version] of Object.entries(deps)) {
76
+ const versionText = asString(version);
77
+ if (versionText) {
78
+ output.push(`${name}: ${versionText}`);
79
+ } else if (name.trim()) {
80
+ output.push(name);
81
+ }
82
+ }
83
+ }
84
+
85
+ return output;
86
+ }
87
+
88
+ /**
89
+ * Handle Clojars URLs via API
90
+ */
91
+ export const handleClojars: SpecialHandler = async (
92
+ url: string,
93
+ timeout: number,
94
+ signal?: AbortSignal,
95
+ ): Promise<RenderResult | null> => {
96
+ try {
97
+ const parsed = new URL(url);
98
+ if (parsed.hostname !== "clojars.org" && parsed.hostname !== "www.clojars.org") return null;
99
+
100
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
101
+ if (!path) return null;
102
+
103
+ const segments = path.split("/").filter(Boolean);
104
+ if (segments.length < 1 || segments.length > 2) return null;
105
+
106
+ const groupFromUrl = segments.length === 2 ? decodeURIComponent(segments[0]) : null;
107
+ const artifactFromUrl = decodeURIComponent(segments[segments.length - 1]);
108
+
109
+ const apiUrl =
110
+ segments.length === 2
111
+ ? `https://clojars.org/api/artifacts/${encodeURIComponent(groupFromUrl ?? "")}/${encodeURIComponent(artifactFromUrl)}`
112
+ : `https://clojars.org/api/artifacts/${encodeURIComponent(artifactFromUrl)}`;
113
+
114
+ const fetchedAt = new Date().toISOString();
115
+
116
+ const result = await loadPage(apiUrl, {
117
+ timeout,
118
+ headers: { Accept: "application/json" },
119
+ signal,
120
+ });
121
+
122
+ if (!result.ok) return null;
123
+
124
+ let payload: unknown;
125
+ try {
126
+ payload = JSON.parse(result.content);
127
+ } catch {
128
+ return null;
129
+ }
130
+
131
+ const data = Array.isArray(payload) ? payload[0] : payload;
132
+ if (!isRecord(data)) return null;
133
+
134
+ const groupName = asString(data.group_name) ?? asString(data.group) ?? groupFromUrl;
135
+ const artifactName = asString(data.jar_name) ?? asString(data.artifact) ?? asString(data.name) ?? artifactFromUrl;
136
+ const version = asString(data.latest_version) ?? asString(data.version);
137
+ const description = asString(data.description) ?? asString(data.summary);
138
+ const downloads =
139
+ asNumber(data.downloads) ?? asNumber(data.downloads_total) ?? asNumber(data.total_downloads) ?? null;
140
+ const homepage = asString(data.homepage) ?? asString(data.url);
141
+ const licenses = formatLicenses(data.licenses);
142
+ const dependencies = formatDependencies(data.dependencies ?? data.deps);
143
+
144
+ const displayName =
145
+ groupName && artifactName && groupName !== artifactName
146
+ ? `${groupName}/${artifactName}`
147
+ : (artifactName ?? groupName ?? "Clojars artifact");
148
+
149
+ let md = `# ${displayName}\n\n`;
150
+ if (description) md += `${description}\n\n`;
151
+
152
+ if (groupName) md += `**Group:** ${groupName}\n`;
153
+ if (artifactName) md += `**Artifact:** ${artifactName}\n`;
154
+ if (version) md += `**Latest:** ${version}\n`;
155
+ if (downloads !== null) md += `**Downloads:** ${formatCount(downloads)}\n`;
156
+ if (homepage) md += `**Homepage:** ${homepage}\n`;
157
+ if (licenses.length > 0) md += `**Licenses:** ${licenses.join(", ")}\n`;
158
+
159
+ if (dependencies.length > 0) {
160
+ md += "\n## Dependencies\n\n";
161
+ for (const dep of dependencies) {
162
+ md += `- ${dep}\n`;
163
+ }
164
+ }
165
+
166
+ const output = finalizeOutput(md);
167
+ return {
168
+ url,
169
+ finalUrl: url,
170
+ contentType: "text/markdown",
171
+ method: "clojars",
172
+ content: output.content,
173
+ fetchedAt,
174
+ truncated: output.truncated,
175
+ notes: ["Fetched via Clojars API"],
176
+ };
177
+ } catch {}
178
+
179
+ return null;
180
+ };
@@ -29,7 +29,11 @@ interface CoinGeckoResponse {
29
29
  /**
30
30
  * Handle CoinGecko cryptocurrency URLs via API
31
31
  */
32
- export const handleCoinGecko: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
32
+ export const handleCoinGecko: SpecialHandler = async (
33
+ url: string,
34
+ timeout: number,
35
+ signal?: AbortSignal,
36
+ ): Promise<RenderResult | null> => {
33
37
  try {
34
38
  const parsed = new URL(url);
35
39
  if (!parsed.hostname.includes("coingecko.com")) return null;
@@ -46,6 +50,7 @@ export const handleCoinGecko: SpecialHandler = async (url: string, timeout: numb
46
50
  const result = await loadPage(apiUrl, {
47
51
  timeout,
48
52
  headers: { Accept: "application/json" },
53
+ signal,
49
54
  });
50
55
 
51
56
  if (!result.ok) return null;
@@ -17,7 +17,11 @@ function looksLikeHtml(content: string): boolean {
17
17
  /**
18
18
  * Handle crates.io URLs via API
19
19
  */
20
- export const handleCratesIo: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
20
+ export const handleCratesIo: SpecialHandler = async (
21
+ url: string,
22
+ timeout: number,
23
+ signal?: AbortSignal,
24
+ ): Promise<RenderResult | null> => {
21
25
  try {
22
26
  const parsed = new URL(url);
23
27
  if (parsed.hostname !== "crates.io" && parsed.hostname !== "www.crates.io") return null;
@@ -33,6 +37,7 @@ export const handleCratesIo: SpecialHandler = async (url: string, timeout: numbe
33
37
  const apiUrl = `https://crates.io/api/v1/crates/${crateName}`;
34
38
  const result = await loadPage(apiUrl, {
35
39
  timeout,
40
+ signal,
36
41
  headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" },
37
42
  });
38
43
 
@@ -101,7 +106,7 @@ export const handleCratesIo: SpecialHandler = async (url: string, timeout: numbe
101
106
 
102
107
  // Try to fetch README from docs.rs or repository
103
108
  const docsRsUrl = `https://docs.rs/crate/${crateName}/${crate.max_version}/source/README.md`;
104
- const readmeResult = await loadPage(docsRsUrl, { timeout: Math.min(timeout, 5) });
109
+ const readmeResult = await loadPage(docsRsUrl, { timeout: Math.min(timeout, 5), signal });
105
110
  if (readmeResult.ok && readmeResult.content.length > 100 && !looksLikeHtml(readmeResult.content)) {
106
111
  md += `\n---\n\n## README\n\n${readmeResult.content}\n`;
107
112
  }