@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.
- package/CHANGELOG.md +90 -0
- package/package.json +5 -5
- package/src/cli/args.ts +4 -0
- package/src/core/agent-session.ts +29 -2
- package/src/core/bash-executor.ts +2 -1
- package/src/core/custom-commands/bundled/review/index.ts +369 -14
- package/src/core/custom-commands/bundled/wt/index.ts +1 -1
- package/src/core/session-manager.ts +158 -246
- package/src/core/session-storage.ts +379 -0
- package/src/core/settings-manager.ts +155 -4
- package/src/core/system-prompt.ts +62 -64
- package/src/core/tools/ask.ts +5 -4
- package/src/core/tools/bash-interceptor.ts +26 -61
- package/src/core/tools/bash.ts +13 -8
- package/src/core/tools/complete.ts +2 -4
- package/src/core/tools/edit-diff.ts +11 -4
- package/src/core/tools/edit.ts +7 -13
- package/src/core/tools/find.ts +111 -50
- package/src/core/tools/gemini-image.ts +128 -147
- package/src/core/tools/grep.ts +397 -415
- package/src/core/tools/index.test.ts +5 -1
- package/src/core/tools/index.ts +6 -8
- package/src/core/tools/jtd-to-json-schema.ts +174 -196
- package/src/core/tools/ls.ts +12 -10
- package/src/core/tools/lsp/client.ts +58 -9
- package/src/core/tools/lsp/config.ts +205 -656
- package/src/core/tools/lsp/defaults.json +465 -0
- package/src/core/tools/lsp/index.ts +55 -32
- package/src/core/tools/lsp/rust-analyzer.ts +49 -10
- package/src/core/tools/lsp/types.ts +1 -0
- package/src/core/tools/lsp/utils.ts +1 -1
- package/src/core/tools/read.ts +152 -76
- package/src/core/tools/render-utils.ts +70 -10
- package/src/core/tools/review.ts +38 -126
- package/src/core/tools/task/artifacts.ts +5 -4
- package/src/core/tools/task/executor.ts +204 -67
- package/src/core/tools/task/index.ts +129 -92
- package/src/core/tools/task/name-generator.ts +1544 -214
- package/src/core/tools/task/parallel.ts +30 -3
- package/src/core/tools/task/render.ts +85 -39
- package/src/core/tools/task/types.ts +34 -11
- package/src/core/tools/task/worker.ts +152 -27
- package/src/core/tools/web-fetch.ts +220 -1657
- package/src/core/tools/web-scrapers/academic.test.ts +239 -0
- package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
- package/src/core/tools/web-scrapers/arxiv.ts +88 -0
- package/src/core/tools/web-scrapers/aur.ts +175 -0
- package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
- package/src/core/tools/web-scrapers/bluesky.ts +284 -0
- package/src/core/tools/web-scrapers/brew.ts +177 -0
- package/src/core/tools/web-scrapers/business.test.ts +82 -0
- package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
- package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
- package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
- package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
- package/src/core/tools/web-scrapers/clojars.ts +180 -0
- package/src/core/tools/web-scrapers/coingecko.ts +184 -0
- package/src/core/tools/web-scrapers/crates-io.ts +128 -0
- package/src/core/tools/web-scrapers/crossref.ts +149 -0
- package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-scrapers/devto.ts +177 -0
- package/src/core/tools/web-scrapers/discogs.ts +308 -0
- package/src/core/tools/web-scrapers/discourse.ts +221 -0
- package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
- package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
- package/src/core/tools/web-scrapers/fdroid.ts +158 -0
- package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
- package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
- package/src/core/tools/web-scrapers/flathub.ts +239 -0
- package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-scrapers/github-gist.ts +68 -0
- package/src/core/tools/web-scrapers/github.ts +455 -0
- package/src/core/tools/web-scrapers/gitlab.ts +456 -0
- package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
- package/src/core/tools/web-scrapers/hackage.ts +94 -0
- package/src/core/tools/web-scrapers/hackernews.ts +208 -0
- package/src/core/tools/web-scrapers/hex.ts +121 -0
- package/src/core/tools/web-scrapers/huggingface.ts +385 -0
- package/src/core/tools/web-scrapers/iacr.ts +86 -0
- package/src/core/tools/web-scrapers/index.ts +250 -0
- package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
- package/src/core/tools/web-scrapers/lemmy.ts +220 -0
- package/src/core/tools/web-scrapers/lobsters.ts +186 -0
- package/src/core/tools/web-scrapers/mastodon.ts +310 -0
- package/src/core/tools/web-scrapers/maven.ts +152 -0
- package/src/core/tools/web-scrapers/mdn.ts +174 -0
- package/src/core/tools/web-scrapers/media.test.ts +138 -0
- package/src/core/tools/web-scrapers/metacpan.ts +253 -0
- package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
- package/src/core/tools/web-scrapers/npm.ts +114 -0
- package/src/core/tools/web-scrapers/nuget.ts +205 -0
- package/src/core/tools/web-scrapers/nvd.ts +243 -0
- package/src/core/tools/web-scrapers/ollama.ts +267 -0
- package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
- package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
- package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
- package/src/core/tools/web-scrapers/orcid.ts +299 -0
- package/src/core/tools/web-scrapers/osv.ts +189 -0
- package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
- package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
- package/src/core/tools/web-scrapers/packagist.ts +174 -0
- package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
- package/src/core/tools/web-scrapers/pubmed.ts +178 -0
- package/src/core/tools/web-scrapers/pypi.ts +129 -0
- package/src/core/tools/web-scrapers/rawg.ts +124 -0
- package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
- package/src/core/tools/web-scrapers/reddit.ts +104 -0
- package/src/core/tools/web-scrapers/repology.ts +262 -0
- package/src/core/tools/web-scrapers/research.test.ts +107 -0
- package/src/core/tools/web-scrapers/rfc.ts +209 -0
- package/src/core/tools/web-scrapers/rubygems.ts +117 -0
- package/src/core/tools/web-scrapers/searchcode.ts +217 -0
- package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
- package/src/core/tools/web-scrapers/security.test.ts +103 -0
- package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
- package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
- package/src/core/tools/web-scrapers/social.test.ts +259 -0
- package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
- package/src/core/tools/web-scrapers/spdx.ts +121 -0
- package/src/core/tools/web-scrapers/spotify.ts +218 -0
- package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
- package/src/core/tools/web-scrapers/standards.test.ts +122 -0
- package/src/core/tools/web-scrapers/terraform.ts +304 -0
- package/src/core/tools/web-scrapers/tldr.ts +51 -0
- package/src/core/tools/web-scrapers/twitter.ts +96 -0
- package/src/core/tools/web-scrapers/types.ts +234 -0
- package/src/core/tools/web-scrapers/utils.ts +162 -0
- package/src/core/tools/web-scrapers/vimeo.ts +152 -0
- package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
- package/src/core/tools/web-scrapers/w3c.ts +163 -0
- package/src/core/tools/web-scrapers/wikidata.ts +357 -0
- package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
- package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
- package/src/core/tools/web-scrapers/youtube.ts +371 -0
- package/src/core/tools/write.ts +21 -18
- package/src/core/voice.ts +3 -2
- package/src/lib/worktree/collapse.ts +2 -1
- package/src/lib/worktree/git.ts +2 -18
- package/src/main.ts +59 -3
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
- package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
- package/src/modes/interactive/components/hook-editor.ts +2 -1
- package/src/modes/interactive/components/model-selector.ts +19 -4
- package/src/modes/interactive/interactive-mode.ts +41 -38
- package/src/modes/interactive/theme/theme.ts +58 -58
- package/src/modes/rpc/rpc-mode.ts +10 -9
- package/src/prompts/review-request.md +27 -0
- package/src/prompts/reviewer.md +64 -68
- package/src/prompts/tools/output.md +22 -3
- package/src/prompts/tools/task.md +32 -33
- package/src/utils/clipboard.ts +2 -1
- package/src/utils/tools-manager.ts +110 -8
- package/examples/extensions/subagent/agents/reviewer.md +0 -35
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleMDN } from "./mdn";
|
|
3
|
+
import { handleReadTheDocs } from "./readthedocs";
|
|
4
|
+
|
|
5
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
6
|
+
|
|
7
|
+
describe.skipIf(SKIP)("handleMDN", () => {
|
|
8
|
+
it("returns null for non-MDN URLs", async () => {
|
|
9
|
+
const result = await handleMDN("https://example.com", 20);
|
|
10
|
+
expect(result).toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null for non-docs MDN URLs", async () => {
|
|
14
|
+
const result = await handleMDN("https://developer.mozilla.org/en-US/", 20);
|
|
15
|
+
expect(result).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns null for MDN blog URLs", async () => {
|
|
19
|
+
const result = await handleMDN("https://developer.mozilla.org/en-US/blog/", 20);
|
|
20
|
+
expect(result).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("fetches Array.map documentation", async () => {
|
|
24
|
+
const result = await handleMDN(
|
|
25
|
+
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map",
|
|
26
|
+
20,
|
|
27
|
+
);
|
|
28
|
+
expect(result).not.toBeNull();
|
|
29
|
+
expect(result?.method).toBe("mdn");
|
|
30
|
+
expect(result?.content).toContain("map");
|
|
31
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
32
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("fetches Promise documentation", async () => {
|
|
36
|
+
const result = await handleMDN(
|
|
37
|
+
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise",
|
|
38
|
+
20,
|
|
39
|
+
);
|
|
40
|
+
expect(result).not.toBeNull();
|
|
41
|
+
expect(result?.method).toBe("mdn");
|
|
42
|
+
expect(result?.content).toContain("Promise");
|
|
43
|
+
expect(result?.truncated).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fetches CSS documentation", async () => {
|
|
47
|
+
const result = await handleMDN("https://developer.mozilla.org/en-US/docs/Web/CSS/display", 20);
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
expect(result?.method).toBe("mdn");
|
|
50
|
+
expect(result?.content).toContain("display");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe.skipIf(SKIP)("handleReadTheDocs", () => {
|
|
55
|
+
it("returns null for non-RTD URLs", async () => {
|
|
56
|
+
const result = await handleReadTheDocs("https://example.com", 20);
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null for github.com URLs", async () => {
|
|
61
|
+
const result = await handleReadTheDocs("https://github.com/user/repo", 20);
|
|
62
|
+
expect(result).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fetches requests docs", async () => {
|
|
66
|
+
const result = await handleReadTheDocs("https://requests.readthedocs.io/en/latest/", 20);
|
|
67
|
+
expect(result).not.toBeNull();
|
|
68
|
+
expect(result?.method).toBe("readthedocs");
|
|
69
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
70
|
+
expect(result?.truncated).toBeDefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null for non-readthedocs sites", async () => {
|
|
74
|
+
// These sites use Sphinx/RTD theme but aren't hosted on readthedocs.io
|
|
75
|
+
expect(await handleReadTheDocs("https://www.sphinx-doc.org/en/master/", 20)).toBeNull();
|
|
76
|
+
expect(await handleReadTheDocs("https://docs.pytest.org/en/stable/", 20)).toBeNull();
|
|
77
|
+
expect(await handleReadTheDocs("https://pip.pypa.io/en/stable/", 20)).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles readthedocs.io subdomain", async () => {
|
|
81
|
+
const result = await handleReadTheDocs("https://flask.palletsprojects.readthedocs.io/en/latest/", 20);
|
|
82
|
+
expect(result).not.toBeNull();
|
|
83
|
+
expect(result?.method).toBe("readthedocs");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
type LocalizedText = string | Record<string, string>;
|
|
5
|
+
|
|
6
|
+
type FdroidPackage = {
|
|
7
|
+
packageName?: string;
|
|
8
|
+
name?: LocalizedText;
|
|
9
|
+
summary?: LocalizedText;
|
|
10
|
+
description?: LocalizedText;
|
|
11
|
+
author?: string | { name?: string; email?: string };
|
|
12
|
+
authorName?: string;
|
|
13
|
+
authorEmail?: string;
|
|
14
|
+
license?: string;
|
|
15
|
+
categories?: string[];
|
|
16
|
+
antiFeatures?: string[];
|
|
17
|
+
sourceCode?: string;
|
|
18
|
+
packages?: Array<{
|
|
19
|
+
versionName?: string;
|
|
20
|
+
versionCode?: number;
|
|
21
|
+
added?: number;
|
|
22
|
+
antiFeatures?: string[];
|
|
23
|
+
}>;
|
|
24
|
+
suggestedVersionCode?: number;
|
|
25
|
+
suggestedVersionName?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function pickLocalizedText(value?: LocalizedText): string | undefined {
|
|
29
|
+
if (!value) return undefined;
|
|
30
|
+
if (typeof value === "string") return value;
|
|
31
|
+
const preferred = value["en-US"] ?? value.en_US ?? value.en;
|
|
32
|
+
if (preferred) return preferred;
|
|
33
|
+
const first = Object.values(value).find((entry) => typeof entry === "string");
|
|
34
|
+
return first;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeAuthor(data: FdroidPackage): string | undefined {
|
|
38
|
+
if (data.authorName) return data.authorName;
|
|
39
|
+
if (typeof data.author === "string") return data.author;
|
|
40
|
+
if (data.author && typeof data.author !== "string" && typeof data.author.name === "string") return data.author.name;
|
|
41
|
+
if (data.authorEmail) return data.authorEmail;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeAuthorEmail(data: FdroidPackage): string | undefined {
|
|
46
|
+
if (data.authorEmail) return data.authorEmail;
|
|
47
|
+
if (data.author && typeof data.author !== "string" && typeof data.author.email === "string")
|
|
48
|
+
return data.author.email;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectAntiFeatures(data: FdroidPackage): string[] {
|
|
53
|
+
const values = new Set<string>();
|
|
54
|
+
for (const feature of data.antiFeatures ?? []) values.add(feature);
|
|
55
|
+
for (const pkg of data.packages ?? []) {
|
|
56
|
+
for (const feature of pkg.antiFeatures ?? []) values.add(feature);
|
|
57
|
+
}
|
|
58
|
+
return Array.from(values);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveSuggestedVersion(data: FdroidPackage): string | undefined {
|
|
62
|
+
if (data.suggestedVersionName) return data.suggestedVersionName;
|
|
63
|
+
if (data.suggestedVersionCode) {
|
|
64
|
+
const match = data.packages?.find((pkg) => pkg.versionCode === data.suggestedVersionCode);
|
|
65
|
+
if (match?.versionName) return match.versionName;
|
|
66
|
+
}
|
|
67
|
+
return data.packages?.[0]?.versionName;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handle F-Droid URLs via API
|
|
72
|
+
*/
|
|
73
|
+
export const handleFdroid: SpecialHandler = async (
|
|
74
|
+
url: string,
|
|
75
|
+
timeout: number,
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
): Promise<RenderResult | null> => {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = new URL(url);
|
|
80
|
+
if (parsed.hostname !== "f-droid.org" && parsed.hostname !== "www.f-droid.org") return null;
|
|
81
|
+
|
|
82
|
+
// Extract package name from /packages/{packageName} or /en/packages/{packageName}
|
|
83
|
+
const match = parsed.pathname.match(/^\/(?:en\/)?packages\/([^/]+)/);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
|
|
86
|
+
const packageName = decodeURIComponent(match[1]);
|
|
87
|
+
const fetchedAt = new Date().toISOString();
|
|
88
|
+
const apiUrl = `https://f-droid.org/api/v1/packages/${encodeURIComponent(packageName)}`;
|
|
89
|
+
|
|
90
|
+
const result = await loadPage(apiUrl, {
|
|
91
|
+
timeout,
|
|
92
|
+
headers: { Accept: "application/json" },
|
|
93
|
+
signal,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!result.ok) return null;
|
|
97
|
+
|
|
98
|
+
let data: FdroidPackage;
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(result.content) as FdroidPackage;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const displayName = pickLocalizedText(data.name) ?? packageName;
|
|
106
|
+
const summary = pickLocalizedText(data.summary);
|
|
107
|
+
const description = pickLocalizedText(data.description);
|
|
108
|
+
const author = normalizeAuthor(data);
|
|
109
|
+
const authorEmail = normalizeAuthorEmail(data);
|
|
110
|
+
const antiFeatures = collectAntiFeatures(data);
|
|
111
|
+
const latestVersion = resolveSuggestedVersion(data);
|
|
112
|
+
|
|
113
|
+
let md = `# ${displayName}\n\n`;
|
|
114
|
+
if (summary) md += `${summary}\n\n`;
|
|
115
|
+
|
|
116
|
+
md += `**Package:** ${packageName}`;
|
|
117
|
+
if (latestVersion) md += ` · **Latest:** ${latestVersion}`;
|
|
118
|
+
if (data.license) md += ` · **License:** ${data.license}`;
|
|
119
|
+
md += "\n";
|
|
120
|
+
|
|
121
|
+
if (author) {
|
|
122
|
+
md += `**Author:** ${author}`;
|
|
123
|
+
if (authorEmail && authorEmail !== author) md += ` <${authorEmail}>`;
|
|
124
|
+
md += "\n";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (data.sourceCode) md += `**Source Code:** ${data.sourceCode}\n`;
|
|
128
|
+
if (data.categories?.length) md += `**Categories:** ${data.categories.join(", ")}\n`;
|
|
129
|
+
if (antiFeatures.length) md += `**Anti-Features:** ${antiFeatures.join(", ")}\n`;
|
|
130
|
+
|
|
131
|
+
if (description) {
|
|
132
|
+
md += `\n## Description\n\n${description}\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (data.packages?.length) {
|
|
136
|
+
md += "\n## Version History\n\n";
|
|
137
|
+
for (const version of data.packages.slice(0, 10)) {
|
|
138
|
+
const label = version.versionName ?? "unknown";
|
|
139
|
+
const code = version.versionCode ? ` (${version.versionCode})` : "";
|
|
140
|
+
md += `- ${label}${code}\n`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const output = finalizeOutput(md);
|
|
145
|
+
return {
|
|
146
|
+
url,
|
|
147
|
+
finalUrl: url,
|
|
148
|
+
contentType: "text/markdown",
|
|
149
|
+
method: "fdroid",
|
|
150
|
+
content: output.content,
|
|
151
|
+
fetchedAt,
|
|
152
|
+
truncated: output.truncated,
|
|
153
|
+
notes: ["Fetched via F-Droid API"],
|
|
154
|
+
};
|
|
155
|
+
} catch {}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleArtifactHub } from "./artifacthub";
|
|
3
|
+
import { handleCoinGecko } from "./coingecko";
|
|
4
|
+
import { handleDiscogs } from "./discogs";
|
|
5
|
+
|
|
6
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(SKIP)("handleCoinGecko", () => {
|
|
9
|
+
it("returns null for non-CoinGecko URLs", async () => {
|
|
10
|
+
const result = await handleCoinGecko("https://example.com", 20);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns null for CoinGecko homepage", async () => {
|
|
15
|
+
const result = await handleCoinGecko("https://www.coingecko.com/", 20);
|
|
16
|
+
expect(result).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns null for CoinGecko categories page", async () => {
|
|
20
|
+
const result = await handleCoinGecko("https://www.coingecko.com/en/categories", 20);
|
|
21
|
+
expect(result).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("fetches Bitcoin data", async () => {
|
|
25
|
+
const result = await handleCoinGecko("https://www.coingecko.com/en/coins/bitcoin", 20);
|
|
26
|
+
expect(result).not.toBeNull();
|
|
27
|
+
expect(result?.method).toBe("coingecko");
|
|
28
|
+
expect(result?.content).toContain("Bitcoin");
|
|
29
|
+
expect(result?.content).toContain("BTC");
|
|
30
|
+
expect(result?.content).toContain("Price");
|
|
31
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
32
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
33
|
+
expect(result?.truncated).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("fetches Ethereum data", async () => {
|
|
37
|
+
const result = await handleCoinGecko("https://www.coingecko.com/en/coins/ethereum", 20);
|
|
38
|
+
expect(result).not.toBeNull();
|
|
39
|
+
expect(result?.method).toBe("coingecko");
|
|
40
|
+
expect(result?.content).toContain("Ethereum");
|
|
41
|
+
expect(result?.content).toContain("ETH");
|
|
42
|
+
expect(result?.content).toContain("Market Cap");
|
|
43
|
+
expect(result?.truncated).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles URL without locale prefix", async () => {
|
|
47
|
+
const result = await handleCoinGecko("https://www.coingecko.com/coins/bitcoin", 20);
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
expect(result?.method).toBe("coingecko");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe.skipIf(SKIP)("handleDiscogs", () => {
|
|
54
|
+
it("returns null for non-Discogs URLs", async () => {
|
|
55
|
+
const result = await handleDiscogs("https://example.com", 20);
|
|
56
|
+
expect(result).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null for Discogs homepage", async () => {
|
|
60
|
+
const result = await handleDiscogs("https://www.discogs.com/", 20);
|
|
61
|
+
expect(result).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns null for Discogs search page", async () => {
|
|
65
|
+
const result = await handleDiscogs("https://www.discogs.com/search/", 20);
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("fetches Daft Punk Discovery release", async () => {
|
|
70
|
+
// Release 249504: Daft Punk - Discovery
|
|
71
|
+
const result = await handleDiscogs("https://www.discogs.com/release/249504-Daft-Punk-Discovery", 20);
|
|
72
|
+
expect(result).not.toBeNull();
|
|
73
|
+
expect(result?.method).toBe("discogs");
|
|
74
|
+
expect(result?.content).toContain("Daft Punk");
|
|
75
|
+
expect(result?.content).toContain("Discovery");
|
|
76
|
+
expect(result?.content).toContain("Tracklist");
|
|
77
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
78
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
79
|
+
expect(result?.truncated).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("fetches master release", async () => {
|
|
83
|
+
// Master 33395: Daft Punk - Discovery (master)
|
|
84
|
+
const result = await handleDiscogs("https://www.discogs.com/master/33395-Daft-Punk-Discovery", 20);
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result?.method).toBe("discogs");
|
|
87
|
+
expect(result?.content).toContain("Daft Punk");
|
|
88
|
+
expect(result?.content).toContain("Master Release");
|
|
89
|
+
expect(result?.truncated).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles release URL with just ID", async () => {
|
|
93
|
+
const result = await handleDiscogs("https://www.discogs.com/release/249504", 20);
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result?.method).toBe("discogs");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe.skipIf(SKIP)("handleArtifactHub", () => {
|
|
100
|
+
it("returns null for non-ArtifactHub URLs", async () => {
|
|
101
|
+
const result = await handleArtifactHub("https://example.com", 20);
|
|
102
|
+
expect(result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns null for ArtifactHub homepage", async () => {
|
|
106
|
+
const result = await handleArtifactHub("https://artifacthub.io/", 20);
|
|
107
|
+
expect(result).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns null for ArtifactHub search page", async () => {
|
|
111
|
+
const result = await handleArtifactHub("https://artifacthub.io/packages/search", 20);
|
|
112
|
+
expect(result).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("fetches bitnami/nginx helm chart", async () => {
|
|
116
|
+
const result = await handleArtifactHub("https://artifacthub.io/packages/helm/bitnami/nginx", 20);
|
|
117
|
+
expect(result).not.toBeNull();
|
|
118
|
+
expect(result?.method).toBe("artifacthub");
|
|
119
|
+
expect(result?.content).toContain("nginx");
|
|
120
|
+
expect(result?.content).toContain("Helm Chart");
|
|
121
|
+
expect(result?.content).toContain("Version");
|
|
122
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
123
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
124
|
+
expect(result?.truncated).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("fetches prometheus-community/prometheus helm chart", async () => {
|
|
128
|
+
const result = await handleArtifactHub(
|
|
129
|
+
"https://artifacthub.io/packages/helm/prometheus-community/prometheus",
|
|
130
|
+
20,
|
|
131
|
+
);
|
|
132
|
+
expect(result).not.toBeNull();
|
|
133
|
+
expect(result?.method).toBe("artifacthub");
|
|
134
|
+
expect(result?.content).toContain("prometheus");
|
|
135
|
+
expect(result?.content).toContain("Repository");
|
|
136
|
+
expect(result?.truncated).toBeDefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles www subdomain", async () => {
|
|
140
|
+
const result = await handleArtifactHub("https://www.artifacthub.io/packages/helm/bitnami/nginx", 20);
|
|
141
|
+
expect(result).not.toBeNull();
|
|
142
|
+
expect(result?.method).toBe("artifacthub");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
type LocalizedText = string | Record<string, string | null | undefined> | null | undefined;
|
|
5
|
+
|
|
6
|
+
type AddonFile = {
|
|
7
|
+
permissions?: string[];
|
|
8
|
+
host_permissions?: string[];
|
|
9
|
+
optional_permissions?: string[];
|
|
10
|
+
optional_host_permissions?: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type AddonLicense = {
|
|
14
|
+
name?: LocalizedText;
|
|
15
|
+
slug?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type AddonVersion = {
|
|
20
|
+
version?: string;
|
|
21
|
+
license?: AddonLicense;
|
|
22
|
+
file?: AddonFile;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type AddonHomepage = {
|
|
26
|
+
url?: LocalizedText;
|
|
27
|
+
outgoing?: LocalizedText;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type AddonData = {
|
|
31
|
+
name?: LocalizedText;
|
|
32
|
+
summary?: LocalizedText;
|
|
33
|
+
description?: LocalizedText;
|
|
34
|
+
default_locale?: string;
|
|
35
|
+
authors?: Array<{ name?: string | null }>;
|
|
36
|
+
average_daily_users?: number;
|
|
37
|
+
weekly_downloads?: number;
|
|
38
|
+
ratings?: { average?: number; count?: number };
|
|
39
|
+
current_version?: AddonVersion;
|
|
40
|
+
categories?: string[] | Record<string, string[]>;
|
|
41
|
+
homepage?: AddonHomepage;
|
|
42
|
+
url?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function getLocalizedText(value: LocalizedText, defaultLocale?: string): string | undefined {
|
|
46
|
+
if (!value) return undefined;
|
|
47
|
+
if (typeof value === "string") return value;
|
|
48
|
+
|
|
49
|
+
const localized = value as Record<string, string | null | undefined>;
|
|
50
|
+
if (defaultLocale && localized[defaultLocale]) return localized[defaultLocale] ?? undefined;
|
|
51
|
+
if (localized["en-US"]) return localized["en-US"] ?? undefined;
|
|
52
|
+
|
|
53
|
+
for (const entry of Object.values(localized)) {
|
|
54
|
+
if (entry) return entry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeCategories(categories?: string[] | Record<string, string[]>): string[] {
|
|
61
|
+
if (!categories) return [];
|
|
62
|
+
if (Array.isArray(categories)) return categories.filter(Boolean);
|
|
63
|
+
|
|
64
|
+
const values: string[] = [];
|
|
65
|
+
for (const list of Object.values(categories)) {
|
|
66
|
+
if (Array.isArray(list)) {
|
|
67
|
+
for (const item of list) {
|
|
68
|
+
if (item) values.push(item);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
return values.filter((item) => {
|
|
75
|
+
if (seen.has(item)) return false;
|
|
76
|
+
seen.add(item);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectPermissions(file?: AddonFile): string[] {
|
|
82
|
+
if (!file) return [];
|
|
83
|
+
const permissions: string[] = [];
|
|
84
|
+
const seen = new Set<string>();
|
|
85
|
+
|
|
86
|
+
const add = (items?: string[]) => {
|
|
87
|
+
for (const item of items ?? []) {
|
|
88
|
+
if (!item || seen.has(item)) continue;
|
|
89
|
+
seen.add(item);
|
|
90
|
+
permissions.push(item);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
add(file.permissions);
|
|
95
|
+
add(file.host_permissions);
|
|
96
|
+
add(file.optional_permissions);
|
|
97
|
+
add(file.optional_host_permissions);
|
|
98
|
+
|
|
99
|
+
return permissions;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const handleFirefoxAddons: SpecialHandler = async (
|
|
103
|
+
url: string,
|
|
104
|
+
timeout: number,
|
|
105
|
+
signal?: AbortSignal,
|
|
106
|
+
): Promise<RenderResult | null> => {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = new URL(url);
|
|
109
|
+
if (parsed.hostname !== "addons.mozilla.org") return null;
|
|
110
|
+
|
|
111
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
112
|
+
const addonIndex = segments.indexOf("addon");
|
|
113
|
+
if (addonIndex === -1) return null;
|
|
114
|
+
|
|
115
|
+
const slug = segments[addonIndex + 1] ? decodeURIComponent(segments[addonIndex + 1]) : "";
|
|
116
|
+
if (!slug) return null;
|
|
117
|
+
|
|
118
|
+
const apiUrl = `https://addons.mozilla.org/api/v5/addons/addon/${encodeURIComponent(slug)}/`;
|
|
119
|
+
const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, signal });
|
|
120
|
+
if (!result.ok) return null;
|
|
121
|
+
|
|
122
|
+
let data: AddonData;
|
|
123
|
+
try {
|
|
124
|
+
data = JSON.parse(result.content) as AddonData;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const fetchedAt = new Date().toISOString();
|
|
130
|
+
const defaultLocale = data.default_locale || "en-US";
|
|
131
|
+
|
|
132
|
+
const name = getLocalizedText(data.name, defaultLocale) ?? slug;
|
|
133
|
+
const summary = getLocalizedText(data.summary, defaultLocale);
|
|
134
|
+
const descriptionRaw = getLocalizedText(data.description, defaultLocale);
|
|
135
|
+
const description = descriptionRaw ? htmlToBasicMarkdown(descriptionRaw) : undefined;
|
|
136
|
+
|
|
137
|
+
const authors = (data.authors ?? [])
|
|
138
|
+
.map((author) => author.name ?? "")
|
|
139
|
+
.map((author) => author.trim())
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
|
|
142
|
+
const ratingAverage = data.ratings?.average;
|
|
143
|
+
const ratingCount = data.ratings?.count;
|
|
144
|
+
const users = data.average_daily_users ?? data.weekly_downloads;
|
|
145
|
+
const version = data.current_version?.version;
|
|
146
|
+
const categories = normalizeCategories(data.categories);
|
|
147
|
+
|
|
148
|
+
const licenseName =
|
|
149
|
+
getLocalizedText(data.current_version?.license?.name, defaultLocale) ?? data.current_version?.license?.slug;
|
|
150
|
+
const licenseUrl = data.current_version?.license?.url;
|
|
151
|
+
|
|
152
|
+
const homepage =
|
|
153
|
+
getLocalizedText(data.homepage?.url, defaultLocale) ??
|
|
154
|
+
getLocalizedText(data.homepage?.outgoing, defaultLocale);
|
|
155
|
+
|
|
156
|
+
const permissions = collectPermissions(data.current_version?.file);
|
|
157
|
+
|
|
158
|
+
let md = `# ${name}\n\n`;
|
|
159
|
+
if (summary) md += `${summary}\n\n`;
|
|
160
|
+
|
|
161
|
+
if (authors.length > 0) {
|
|
162
|
+
md += `**Author${authors.length > 1 ? "s" : ""}:** ${authors.join(", ")}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (ratingAverage !== undefined) {
|
|
166
|
+
md += `**Rating:** ${ratingAverage.toFixed(2)}`;
|
|
167
|
+
if (ratingCount !== undefined) md += ` (${formatCount(ratingCount)} reviews)`;
|
|
168
|
+
md += "\n";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (users !== undefined) md += `**Users:** ${formatCount(users)}\n`;
|
|
172
|
+
if (version) md += `**Version:** ${version}\n`;
|
|
173
|
+
if (categories.length > 0) md += `**Categories:** ${categories.join(", ")}\n`;
|
|
174
|
+
|
|
175
|
+
if (licenseName && licenseUrl) {
|
|
176
|
+
md += `**License:** [${licenseName}](${licenseUrl})\n`;
|
|
177
|
+
} else if (licenseName) {
|
|
178
|
+
md += `**License:** ${licenseName}\n`;
|
|
179
|
+
} else if (licenseUrl) {
|
|
180
|
+
md += `**License:** ${licenseUrl}\n`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (homepage) md += `**Homepage:** ${homepage}\n`;
|
|
184
|
+
|
|
185
|
+
if (description) {
|
|
186
|
+
md += `\n## Description\n\n${description}\n`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (permissions.length > 0) {
|
|
190
|
+
const preview = permissions.slice(0, 40);
|
|
191
|
+
md += `\n## Permissions (${permissions.length})\n\n`;
|
|
192
|
+
for (const permission of preview) {
|
|
193
|
+
md += `- ${permission}\n`;
|
|
194
|
+
}
|
|
195
|
+
if (permissions.length > preview.length) {
|
|
196
|
+
md += `\n*...and ${permissions.length - preview.length} more*\n`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const output = finalizeOutput(md);
|
|
201
|
+
return {
|
|
202
|
+
url,
|
|
203
|
+
finalUrl: data.url ?? result.finalUrl ?? url,
|
|
204
|
+
contentType: "text/markdown",
|
|
205
|
+
method: "firefox-addons",
|
|
206
|
+
content: output.content,
|
|
207
|
+
fetchedAt,
|
|
208
|
+
truncated: output.truncated,
|
|
209
|
+
notes: ["Fetched via Firefox Add-ons API"],
|
|
210
|
+
};
|
|
211
|
+
} catch {}
|
|
212
|
+
|
|
213
|
+
return null;
|
|
214
|
+
};
|