@oh-my-pi/pi-coding-agent 3.24.0 → 3.30.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 +34 -0
- package/package.json +4 -4
- package/src/core/custom-commands/bundled/wt/index.ts +3 -0
- package/src/core/sdk.ts +7 -0
- package/src/core/tools/complete.ts +129 -0
- package/src/core/tools/index.test.ts +9 -1
- package/src/core/tools/index.ts +18 -5
- package/src/core/tools/jtd-to-json-schema.ts +252 -0
- package/src/core/tools/output.ts +125 -14
- package/src/core/tools/read.ts +4 -4
- package/src/core/tools/task/artifacts.ts +6 -9
- package/src/core/tools/task/executor.ts +189 -24
- package/src/core/tools/task/index.ts +23 -18
- package/src/core/tools/task/name-generator.ts +1577 -0
- package/src/core/tools/task/render.ts +137 -8
- package/src/core/tools/task/types.ts +26 -5
- package/src/core/tools/task/worker-protocol.ts +1 -0
- package/src/core/tools/task/worker.ts +136 -14
- package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
- package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
- package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
- package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
- package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
- package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
- package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
- package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
- package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
- package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
- package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
- package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
- package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
- package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
- package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
- package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
- package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
- package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
- package/src/core/tools/web-fetch-handlers/github.ts +424 -0
- package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
- package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
- package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
- package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
- package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
- package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
- package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
- package/src/core/tools/web-fetch-handlers/index.ts +69 -0
- package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
- package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
- package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
- package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
- package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
- package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
- package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
- package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
- package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
- package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
- package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
- package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
- package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
- package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
- package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
- package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
- package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
- package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
- package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
- package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
- package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
- package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
- package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
- package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
- package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
- package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
- package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
- package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
- package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
- package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
- package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
- package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
- package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
- package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
- package/src/core/tools/web-fetch-handlers/types.ts +163 -0
- package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
- package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
- package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
- package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
- package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
- package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
- package/src/core/tools/web-fetch.ts +152 -1324
- package/src/prompts/task.md +14 -50
- package/src/prompts/tools/output.md +2 -1
- package/src/prompts/tools/task.md +3 -1
- package/src/utils/tools-manager.ts +110 -8
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleStackOverflow } from "./stackoverflow";
|
|
3
|
+
|
|
4
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
5
|
+
|
|
6
|
+
describe.skipIf(SKIP)("handleStackOverflow", () => {
|
|
7
|
+
it("returns null for non-SE URLs", async () => {
|
|
8
|
+
const result = await handleStackOverflow("https://example.com", 20);
|
|
9
|
+
expect(result).toBeNull();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns null for SE site without question path", async () => {
|
|
13
|
+
const result = await handleStackOverflow("https://stackoverflow.com/tags", 20);
|
|
14
|
+
expect(result).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns null for SE user profile URLs", async () => {
|
|
18
|
+
const result = await handleStackOverflow("https://stackoverflow.com/users/1", 20);
|
|
19
|
+
expect(result).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// stackoverflow.com - "What is a NullPointerException" (classic, highly voted)
|
|
23
|
+
it("fetches stackoverflow.com question", async () => {
|
|
24
|
+
const result = await handleStackOverflow(
|
|
25
|
+
"https://stackoverflow.com/questions/218384/what-is-a-nullpointerexception-and-how-do-i-fix-it",
|
|
26
|
+
20,
|
|
27
|
+
);
|
|
28
|
+
expect(result).not.toBeNull();
|
|
29
|
+
expect(result?.method).toBe("stackexchange");
|
|
30
|
+
expect(result?.content).toContain("NullPointerException");
|
|
31
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
32
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
33
|
+
expect(result?.truncated).toBeDefined();
|
|
34
|
+
expect(result?.notes?.[0]).toContain("site=stackoverflow");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// unix.stackexchange.com - "Why does my shell script choke on whitespace" (classic)
|
|
38
|
+
it("fetches unix.stackexchange.com question", async () => {
|
|
39
|
+
const result = await handleStackOverflow(
|
|
40
|
+
"https://unix.stackexchange.com/questions/131766/why-does-my-shell-script-choke-on-whitespace-or-other-special-characters",
|
|
41
|
+
20,
|
|
42
|
+
);
|
|
43
|
+
expect(result).not.toBeNull();
|
|
44
|
+
expect(result?.method).toBe("stackexchange");
|
|
45
|
+
expect(result?.content).toContain("whitespace");
|
|
46
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
47
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
48
|
+
expect(result?.notes?.[0]).toContain("site=unix");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// superuser.com - "What are PATH and other environment variables" (stable)
|
|
52
|
+
it("fetches superuser.com question", async () => {
|
|
53
|
+
const result = await handleStackOverflow(
|
|
54
|
+
"https://superuser.com/questions/284342/what-are-path-and-other-environment-variables-and-how-can-i-set-or-use-them",
|
|
55
|
+
20,
|
|
56
|
+
);
|
|
57
|
+
expect(result).not.toBeNull();
|
|
58
|
+
expect(result?.method).toBe("stackexchange");
|
|
59
|
+
expect(result?.content).toContain("PATH");
|
|
60
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
61
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
62
|
+
expect(result?.notes?.[0]).toContain("site=superuser");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// askubuntu.com - "What is the difference between apt and apt-get" (iconic)
|
|
66
|
+
it("fetches askubuntu.com question", async () => {
|
|
67
|
+
const result = await handleStackOverflow(
|
|
68
|
+
"https://askubuntu.com/questions/445384/what-is-the-difference-between-apt-and-apt-get",
|
|
69
|
+
20,
|
|
70
|
+
);
|
|
71
|
+
expect(result).not.toBeNull();
|
|
72
|
+
expect(result?.method).toBe("stackexchange");
|
|
73
|
+
expect(result?.content).toContain("apt");
|
|
74
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
75
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
76
|
+
expect(result?.notes?.[0]).toContain("site=askubuntu");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// serverfault.com - "What is a reverse proxy" (stable sysadmin topic)
|
|
80
|
+
it("fetches serverfault.com question", async () => {
|
|
81
|
+
const result = await handleStackOverflow(
|
|
82
|
+
"https://serverfault.com/questions/127021/what-is-the-difference-between-a-proxy-and-a-reverse-proxy",
|
|
83
|
+
20,
|
|
84
|
+
);
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result?.method).toBe("stackexchange");
|
|
87
|
+
expect(result?.content).toMatch(/proxy/i);
|
|
88
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
89
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
90
|
+
expect(result?.notes?.[0]).toContain("site=serverfault");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Test with www. prefix
|
|
94
|
+
it("handles www.stackoverflow.com URLs", async () => {
|
|
95
|
+
const result = await handleStackOverflow(
|
|
96
|
+
"https://www.stackoverflow.com/questions/218384/what-is-a-nullpointerexception",
|
|
97
|
+
20,
|
|
98
|
+
);
|
|
99
|
+
expect(result).not.toBeNull();
|
|
100
|
+
expect(result?.method).toBe("stackexchange");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Verify response structure
|
|
104
|
+
it("returns complete response structure", async () => {
|
|
105
|
+
const result = await handleStackOverflow("https://stackoverflow.com/questions/218384", 20);
|
|
106
|
+
expect(result).not.toBeNull();
|
|
107
|
+
expect(result).toHaveProperty("url");
|
|
108
|
+
expect(result).toHaveProperty("finalUrl");
|
|
109
|
+
expect(result).toHaveProperty("contentType", "text/markdown");
|
|
110
|
+
expect(result).toHaveProperty("method", "stackexchange");
|
|
111
|
+
expect(result).toHaveProperty("content");
|
|
112
|
+
expect(result).toHaveProperty("fetchedAt");
|
|
113
|
+
expect(result).toHaveProperty("truncated");
|
|
114
|
+
expect(result).toHaveProperty("notes");
|
|
115
|
+
// Content should have question structure
|
|
116
|
+
expect(result?.content).toContain("# ");
|
|
117
|
+
expect(result?.content).toContain("Score:");
|
|
118
|
+
expect(result?.content).toContain("Tags:");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface SOQuestion {
|
|
5
|
+
title: string;
|
|
6
|
+
body: string;
|
|
7
|
+
score: number;
|
|
8
|
+
owner: { display_name: string };
|
|
9
|
+
creation_date: number;
|
|
10
|
+
tags: string[];
|
|
11
|
+
answer_count: number;
|
|
12
|
+
is_answered: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SOAnswer {
|
|
16
|
+
body: string;
|
|
17
|
+
score: number;
|
|
18
|
+
is_accepted: boolean;
|
|
19
|
+
owner: { display_name: string };
|
|
20
|
+
creation_date: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Standalone SE network sites (not *.stackexchange.com subdomains)
|
|
24
|
+
const STANDALONE_SE_SITES: Record<string, string> = {
|
|
25
|
+
"stackoverflow.com": "stackoverflow",
|
|
26
|
+
"superuser.com": "superuser",
|
|
27
|
+
"serverfault.com": "serverfault",
|
|
28
|
+
"askubuntu.com": "askubuntu",
|
|
29
|
+
"mathoverflow.net": "mathoverflow",
|
|
30
|
+
"stackapps.com": "stackapps",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract the API site parameter from a Stack Exchange hostname
|
|
35
|
+
* Returns null if not a recognized SE site
|
|
36
|
+
*/
|
|
37
|
+
function getSiteParam(hostname: string): string | null {
|
|
38
|
+
// Remove www. prefix if present
|
|
39
|
+
const host = hostname.replace(/^www\./, "");
|
|
40
|
+
|
|
41
|
+
// Check standalone sites first
|
|
42
|
+
if (STANDALONE_SE_SITES[host]) {
|
|
43
|
+
return STANDALONE_SE_SITES[host];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Handle *.stackexchange.com subdomains (e.g., unix.stackexchange.com → unix)
|
|
47
|
+
const seMatch = host.match(/^([a-z0-9-]+)\.stackexchange\.com$/);
|
|
48
|
+
if (seMatch) {
|
|
49
|
+
return seMatch[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Handle Stack Exchange network URLs via API
|
|
57
|
+
* Supports stackoverflow.com, *.stackexchange.com, superuser.com, serverfault.com, askubuntu.com, etc.
|
|
58
|
+
*/
|
|
59
|
+
export const handleStackOverflow: SpecialHandler = async (
|
|
60
|
+
url: string,
|
|
61
|
+
timeout: number,
|
|
62
|
+
): Promise<RenderResult | null> => {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = new URL(url);
|
|
65
|
+
const site = getSiteParam(parsed.hostname);
|
|
66
|
+
if (!site) return null;
|
|
67
|
+
|
|
68
|
+
// Extract question ID from URL patterns like /questions/12345/...
|
|
69
|
+
const match = parsed.pathname.match(/\/questions\/(\d+)/);
|
|
70
|
+
if (!match) return null;
|
|
71
|
+
|
|
72
|
+
const questionId = match[1];
|
|
73
|
+
const fetchedAt = new Date().toISOString();
|
|
74
|
+
|
|
75
|
+
// Fetch question with answers
|
|
76
|
+
const apiUrl = `https://api.stackexchange.com/2.3/questions/${questionId}?order=desc&sort=votes&site=${site}&filter=withbody`;
|
|
77
|
+
const qResult = await loadPage(apiUrl, { timeout });
|
|
78
|
+
|
|
79
|
+
if (!qResult.ok) return null;
|
|
80
|
+
|
|
81
|
+
const qData = JSON.parse(qResult.content) as { items: SOQuestion[] };
|
|
82
|
+
if (!qData.items?.length) return null;
|
|
83
|
+
|
|
84
|
+
const question = qData.items[0];
|
|
85
|
+
|
|
86
|
+
let md = `# ${question.title}\n\n`;
|
|
87
|
+
md += `**Score:** ${question.score} · **Answers:** ${question.answer_count}`;
|
|
88
|
+
md += question.is_answered ? " (Answered)" : "";
|
|
89
|
+
md += `\n**Tags:** ${question.tags.join(", ")}\n`;
|
|
90
|
+
md += `**Asked by:** ${question.owner.display_name} · ${new Date(question.creation_date * 1000).toISOString().split("T")[0]}\n\n`;
|
|
91
|
+
md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`;
|
|
92
|
+
|
|
93
|
+
// Fetch answers
|
|
94
|
+
const aUrl = `https://api.stackexchange.com/2.3/questions/${questionId}/answers?order=desc&sort=votes&site=${site}&filter=withbody`;
|
|
95
|
+
const aResult = await loadPage(aUrl, { timeout });
|
|
96
|
+
|
|
97
|
+
if (aResult.ok) {
|
|
98
|
+
const aData = JSON.parse(aResult.content) as { items: SOAnswer[] };
|
|
99
|
+
if (aData.items?.length) {
|
|
100
|
+
md += `---\n\n## Answers\n\n`;
|
|
101
|
+
for (const answer of aData.items.slice(0, 5)) {
|
|
102
|
+
const accepted = answer.is_accepted ? " (Accepted)" : "";
|
|
103
|
+
md += `### Score: ${answer.score}${accepted} · by ${answer.owner.display_name}\n\n`;
|
|
104
|
+
md += `${htmlToBasicMarkdown(answer.body)}\n\n---\n\n`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const output = finalizeOutput(md);
|
|
110
|
+
return {
|
|
111
|
+
url,
|
|
112
|
+
finalUrl: url,
|
|
113
|
+
contentType: "text/markdown",
|
|
114
|
+
method: "stackexchange",
|
|
115
|
+
content: output.content,
|
|
116
|
+
fetchedAt,
|
|
117
|
+
truncated: output.truncated,
|
|
118
|
+
notes: [`Fetched via Stack Exchange API (site=${site})`],
|
|
119
|
+
};
|
|
120
|
+
} catch {}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleCheatSh } from "./cheatsh";
|
|
3
|
+
import { handleRfc } from "./rfc";
|
|
4
|
+
import { handleTldr } from "./tldr";
|
|
5
|
+
|
|
6
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(SKIP)("handleRfc", () => {
|
|
9
|
+
it("returns null for non-RFC URLs", async () => {
|
|
10
|
+
const result = await handleRfc("https://example.com", 20);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns null for non-matching RFC domains", async () => {
|
|
15
|
+
const result = await handleRfc("https://www.ietf.org/about/", 20);
|
|
16
|
+
expect(result).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("fetches RFC 2616 (HTTP/1.1)", async () => {
|
|
20
|
+
const result = await handleRfc("https://www.rfc-editor.org/rfc/rfc2616", 20);
|
|
21
|
+
expect(result).not.toBeNull();
|
|
22
|
+
expect(result?.method).toBe("rfc");
|
|
23
|
+
expect(result?.content).toContain("HTTP/1.1");
|
|
24
|
+
expect(result?.content).toContain("Hypertext Transfer Protocol");
|
|
25
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
26
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
27
|
+
expect(result?.truncated).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("fetches RFC 2616 via datatracker URL", async () => {
|
|
31
|
+
const result = await handleRfc("https://datatracker.ietf.org/doc/rfc2616/", 20);
|
|
32
|
+
expect(result).not.toBeNull();
|
|
33
|
+
expect(result?.method).toBe("rfc");
|
|
34
|
+
expect(result?.content).toContain("HTTP/1.1");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("fetches RFC 2616 via tools.ietf.org URL", async () => {
|
|
38
|
+
const result = await handleRfc("https://tools.ietf.org/html/rfc2616", 20);
|
|
39
|
+
expect(result).not.toBeNull();
|
|
40
|
+
expect(result?.method).toBe("rfc");
|
|
41
|
+
expect(result?.content).toContain("HTTP/1.1");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("fetches RFC 793 (TCP)", async () => {
|
|
45
|
+
const result = await handleRfc("https://www.rfc-editor.org/rfc/rfc793", 20);
|
|
46
|
+
expect(result).not.toBeNull();
|
|
47
|
+
expect(result?.method).toBe("rfc");
|
|
48
|
+
expect(result?.content).toContain("Transmission Control Protocol");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe.skipIf(SKIP)("handleCheatSh", () => {
|
|
53
|
+
it("returns null for non-cheat.sh URLs", async () => {
|
|
54
|
+
const result = await handleCheatSh("https://example.com", 20);
|
|
55
|
+
expect(result).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns null for empty topic", async () => {
|
|
59
|
+
const result = await handleCheatSh("https://cheat.sh/", 20);
|
|
60
|
+
expect(result).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("fetches curl cheatsheet", async () => {
|
|
64
|
+
const result = await handleCheatSh("https://cheat.sh/curl", 20);
|
|
65
|
+
expect(result).not.toBeNull();
|
|
66
|
+
expect(result?.method).toBe("cheat.sh");
|
|
67
|
+
expect(result?.content).toContain("curl");
|
|
68
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
69
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
70
|
+
expect(result?.truncated).toBeDefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fetches tar cheatsheet", async () => {
|
|
74
|
+
const result = await handleCheatSh("https://cheat.sh/tar", 20);
|
|
75
|
+
expect(result).not.toBeNull();
|
|
76
|
+
expect(result?.method).toBe("cheat.sh");
|
|
77
|
+
expect(result?.content).toContain("tar");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("fetches cheatsheet via cht.sh alias", async () => {
|
|
81
|
+
const result = await handleCheatSh("https://cht.sh/curl", 20);
|
|
82
|
+
expect(result).not.toBeNull();
|
|
83
|
+
expect(result?.method).toBe("cheat.sh");
|
|
84
|
+
expect(result?.content).toContain("curl");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe.skipIf(SKIP)("handleTldr", () => {
|
|
89
|
+
it("returns null for non-tldr URLs", async () => {
|
|
90
|
+
const result = await handleTldr("https://example.com", 20);
|
|
91
|
+
expect(result).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns null for nested paths", async () => {
|
|
95
|
+
const result = await handleTldr("https://tldr.sh/nested/path", 20);
|
|
96
|
+
expect(result).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("fetches git tldr page", async () => {
|
|
100
|
+
const result = await handleTldr("https://tldr.sh/git", 20);
|
|
101
|
+
expect(result).not.toBeNull();
|
|
102
|
+
expect(result?.method).toBe("tldr");
|
|
103
|
+
expect(result?.content).toContain("git");
|
|
104
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
105
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
106
|
+
expect(result?.truncated).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("fetches curl tldr page", async () => {
|
|
110
|
+
const result = await handleTldr("https://tldr.sh/curl", 20);
|
|
111
|
+
expect(result).not.toBeNull();
|
|
112
|
+
expect(result?.method).toBe("tldr");
|
|
113
|
+
expect(result?.content).toContain("curl");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("fetches via tldr.ostera.io alias", async () => {
|
|
117
|
+
const result = await handleTldr("https://tldr.ostera.io/git", 20);
|
|
118
|
+
expect(result).not.toBeNull();
|
|
119
|
+
expect(result?.method).toBe("tldr");
|
|
120
|
+
expect(result?.content).toContain("git");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface TerraformModule {
|
|
5
|
+
id: string;
|
|
6
|
+
namespace: string;
|
|
7
|
+
name: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
version: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
published_at?: string;
|
|
13
|
+
downloads: number;
|
|
14
|
+
verified?: boolean;
|
|
15
|
+
root?: {
|
|
16
|
+
inputs?: Array<{
|
|
17
|
+
name: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
default?: unknown;
|
|
21
|
+
required?: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
outputs?: Array<{
|
|
24
|
+
name: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
}>;
|
|
27
|
+
dependencies?: Array<{
|
|
28
|
+
name: string;
|
|
29
|
+
source: string;
|
|
30
|
+
version?: string;
|
|
31
|
+
}>;
|
|
32
|
+
resources?: Array<{
|
|
33
|
+
name: string;
|
|
34
|
+
type: string;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
submodules?: Array<{
|
|
38
|
+
path: string;
|
|
39
|
+
name: string;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TerraformProvider {
|
|
44
|
+
id: string;
|
|
45
|
+
namespace: string;
|
|
46
|
+
name: string;
|
|
47
|
+
alias?: string;
|
|
48
|
+
version: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
source?: string;
|
|
51
|
+
published_at?: string;
|
|
52
|
+
downloads: number;
|
|
53
|
+
tier?: string;
|
|
54
|
+
logo_url?: string;
|
|
55
|
+
docs?: Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
path: string;
|
|
59
|
+
slug: string;
|
|
60
|
+
category: string;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Handle Terraform Registry URLs via API
|
|
66
|
+
*/
|
|
67
|
+
export const handleTerraform: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = new URL(url);
|
|
70
|
+
if (!parsed.hostname.includes("registry.terraform.io")) return null;
|
|
71
|
+
|
|
72
|
+
const fetchedAt = new Date().toISOString();
|
|
73
|
+
|
|
74
|
+
// Match module URL: /modules/{namespace}/{name}/{provider}
|
|
75
|
+
const moduleMatch = parsed.pathname.match(/^\/modules\/([^/]+)\/([^/]+)\/([^/]+)/);
|
|
76
|
+
if (moduleMatch) {
|
|
77
|
+
const [, namespace, name, provider] = moduleMatch;
|
|
78
|
+
return await handleModuleUrl(url, namespace, name, provider, timeout, fetchedAt);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Match provider URL: /providers/{namespace}/{type}
|
|
82
|
+
const providerMatch = parsed.pathname.match(/^\/providers\/([^/]+)\/([^/]+)/);
|
|
83
|
+
if (providerMatch) {
|
|
84
|
+
const [, namespace, type] = providerMatch;
|
|
85
|
+
return await handleProviderUrl(url, namespace, type, timeout, fetchedAt);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
} catch {}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
async function handleModuleUrl(
|
|
95
|
+
url: string,
|
|
96
|
+
namespace: string,
|
|
97
|
+
name: string,
|
|
98
|
+
provider: string,
|
|
99
|
+
timeout: number,
|
|
100
|
+
fetchedAt: string,
|
|
101
|
+
): Promise<RenderResult | null> {
|
|
102
|
+
const apiUrl = `https://registry.terraform.io/v1/modules/${namespace}/${name}/${provider}`;
|
|
103
|
+
const result = await loadPage(apiUrl, {
|
|
104
|
+
timeout,
|
|
105
|
+
headers: { Accept: "application/json" },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (!result.ok) return null;
|
|
109
|
+
|
|
110
|
+
let mod: TerraformModule;
|
|
111
|
+
try {
|
|
112
|
+
mod = JSON.parse(result.content);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let md = `# ${mod.namespace}/${mod.name}/${mod.provider}\n\n`;
|
|
118
|
+
|
|
119
|
+
if (mod.description) md += `${mod.description}\n\n`;
|
|
120
|
+
|
|
121
|
+
// Metadata line
|
|
122
|
+
md += `**Version:** ${mod.version}`;
|
|
123
|
+
if (mod.verified) md += " ✓ Verified";
|
|
124
|
+
md += `\n`;
|
|
125
|
+
md += `**Downloads:** ${formatCount(mod.downloads)}\n`;
|
|
126
|
+
if (mod.published_at) {
|
|
127
|
+
md += `**Published:** ${new Date(mod.published_at).toLocaleDateString()}\n`;
|
|
128
|
+
}
|
|
129
|
+
if (mod.source) {
|
|
130
|
+
md += `**Source:** ${mod.source}\n`;
|
|
131
|
+
}
|
|
132
|
+
md += "\n";
|
|
133
|
+
|
|
134
|
+
// Usage example
|
|
135
|
+
md += `## Usage\n\n\`\`\`hcl\nmodule "${mod.name}" {\n source = "${mod.namespace}/${mod.name}/${mod.provider}"\n version = "${mod.version}"\n}\n\`\`\`\n\n`;
|
|
136
|
+
|
|
137
|
+
// Inputs
|
|
138
|
+
const inputs = mod.root?.inputs;
|
|
139
|
+
if (inputs && inputs.length > 0) {
|
|
140
|
+
md += `## Inputs (${inputs.length})\n\n`;
|
|
141
|
+
md += "| Name | Type | Required | Description |\n";
|
|
142
|
+
md += "|------|------|----------|-------------|\n";
|
|
143
|
+
for (const input of inputs.slice(0, 30)) {
|
|
144
|
+
const required = (input.required ?? input.default === undefined) ? "Yes" : "No";
|
|
145
|
+
const type = input.type ?? "any";
|
|
146
|
+
const desc = (input.description ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ").slice(0, 80);
|
|
147
|
+
md += `| ${input.name} | \`${type}\` | ${required} | ${desc} |\n`;
|
|
148
|
+
}
|
|
149
|
+
if (inputs.length > 30) {
|
|
150
|
+
md += `\n*... and ${inputs.length - 30} more inputs*\n`;
|
|
151
|
+
}
|
|
152
|
+
md += "\n";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Outputs
|
|
156
|
+
const outputs = mod.root?.outputs;
|
|
157
|
+
if (outputs && outputs.length > 0) {
|
|
158
|
+
md += `## Outputs (${outputs.length})\n\n`;
|
|
159
|
+
for (const output of outputs.slice(0, 20)) {
|
|
160
|
+
md += `- **${output.name}**`;
|
|
161
|
+
if (output.description) md += `: ${output.description.replace(/\n/g, " ").slice(0, 100)}`;
|
|
162
|
+
md += "\n";
|
|
163
|
+
}
|
|
164
|
+
if (outputs.length > 20) {
|
|
165
|
+
md += `\n*... and ${outputs.length - 20} more outputs*\n`;
|
|
166
|
+
}
|
|
167
|
+
md += "\n";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Dependencies
|
|
171
|
+
const deps = mod.root?.dependencies;
|
|
172
|
+
if (deps && deps.length > 0) {
|
|
173
|
+
md += `## Dependencies (${deps.length})\n\n`;
|
|
174
|
+
for (const dep of deps.slice(0, 15)) {
|
|
175
|
+
md += `- **${dep.name}**: ${dep.source}`;
|
|
176
|
+
if (dep.version) md += ` (${dep.version})`;
|
|
177
|
+
md += "\n";
|
|
178
|
+
}
|
|
179
|
+
if (deps.length > 15) {
|
|
180
|
+
md += `\n*... and ${deps.length - 15} more dependencies*\n`;
|
|
181
|
+
}
|
|
182
|
+
md += "\n";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Resources
|
|
186
|
+
const resources = mod.root?.resources;
|
|
187
|
+
if (resources && resources.length > 0) {
|
|
188
|
+
md += `## Resources (${resources.length})\n\n`;
|
|
189
|
+
for (const res of resources.slice(0, 20)) {
|
|
190
|
+
md += `- \`${res.type}\` (${res.name})\n`;
|
|
191
|
+
}
|
|
192
|
+
if (resources.length > 20) {
|
|
193
|
+
md += `\n*... and ${resources.length - 20} more resources*\n`;
|
|
194
|
+
}
|
|
195
|
+
md += "\n";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Submodules
|
|
199
|
+
if (mod.submodules && mod.submodules.length > 0) {
|
|
200
|
+
md += `## Submodules (${mod.submodules.length})\n\n`;
|
|
201
|
+
for (const sub of mod.submodules.slice(0, 10)) {
|
|
202
|
+
md += `- **${sub.name}**: \`${sub.path}\`\n`;
|
|
203
|
+
}
|
|
204
|
+
if (mod.submodules.length > 10) {
|
|
205
|
+
md += `\n*... and ${mod.submodules.length - 10} more submodules*\n`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const output = finalizeOutput(md);
|
|
210
|
+
return {
|
|
211
|
+
url,
|
|
212
|
+
finalUrl: url,
|
|
213
|
+
contentType: "text/markdown",
|
|
214
|
+
method: "terraform",
|
|
215
|
+
content: output.content,
|
|
216
|
+
fetchedAt,
|
|
217
|
+
truncated: output.truncated,
|
|
218
|
+
notes: ["Fetched via Terraform Registry API"],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function handleProviderUrl(
|
|
223
|
+
url: string,
|
|
224
|
+
namespace: string,
|
|
225
|
+
type: string,
|
|
226
|
+
timeout: number,
|
|
227
|
+
fetchedAt: string,
|
|
228
|
+
): Promise<RenderResult | null> {
|
|
229
|
+
const apiUrl = `https://registry.terraform.io/v1/providers/${namespace}/${type}`;
|
|
230
|
+
const result = await loadPage(apiUrl, {
|
|
231
|
+
timeout,
|
|
232
|
+
headers: { Accept: "application/json" },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!result.ok) return null;
|
|
236
|
+
|
|
237
|
+
let provider: TerraformProvider;
|
|
238
|
+
try {
|
|
239
|
+
provider = JSON.parse(result.content);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let md = `# ${provider.namespace}/${provider.name}\n\n`;
|
|
245
|
+
|
|
246
|
+
if (provider.description) md += `${provider.description}\n\n`;
|
|
247
|
+
|
|
248
|
+
// Metadata
|
|
249
|
+
md += `**Version:** ${provider.version}\n`;
|
|
250
|
+
if (provider.tier) md += `**Tier:** ${provider.tier}\n`;
|
|
251
|
+
md += `**Downloads:** ${formatCount(provider.downloads)}\n`;
|
|
252
|
+
if (provider.published_at) {
|
|
253
|
+
md += `**Published:** ${new Date(provider.published_at).toLocaleDateString()}\n`;
|
|
254
|
+
}
|
|
255
|
+
if (provider.source) {
|
|
256
|
+
md += `**Source:** ${provider.source}\n`;
|
|
257
|
+
}
|
|
258
|
+
md += "\n";
|
|
259
|
+
|
|
260
|
+
// Usage example
|
|
261
|
+
md += `## Usage\n\n\`\`\`hcl\nterraform {\n required_providers {\n ${provider.name} = {\n source = "${provider.namespace}/${provider.name}"\n version = "~> ${provider.version}"\n }\n }\n}\n\nprovider "${provider.name}" {\n # Configuration options\n}\n\`\`\`\n\n`;
|
|
262
|
+
|
|
263
|
+
// Documentation summary
|
|
264
|
+
if (provider.docs && provider.docs.length > 0) {
|
|
265
|
+
const categories = new Map<string, typeof provider.docs>();
|
|
266
|
+
for (const doc of provider.docs) {
|
|
267
|
+
const cat = doc.category || "other";
|
|
268
|
+
if (!categories.has(cat)) categories.set(cat, []);
|
|
269
|
+
categories.get(cat)!.push(doc);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
md += `## Documentation\n\n`;
|
|
273
|
+
for (const [category, docs] of categories) {
|
|
274
|
+
md += `### ${category.charAt(0).toUpperCase() + category.slice(1)} (${docs.length})\n\n`;
|
|
275
|
+
for (const doc of docs.slice(0, 15)) {
|
|
276
|
+
md += `- [${doc.title}](https://registry.terraform.io/providers/${namespace}/${type}/latest/docs/${doc.category}/${doc.slug})\n`;
|
|
277
|
+
}
|
|
278
|
+
if (docs.length > 15) {
|
|
279
|
+
md += `\n*... and ${docs.length - 15} more*\n`;
|
|
280
|
+
}
|
|
281
|
+
md += "\n";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const output = finalizeOutput(md);
|
|
286
|
+
return {
|
|
287
|
+
url,
|
|
288
|
+
finalUrl: url,
|
|
289
|
+
contentType: "text/markdown",
|
|
290
|
+
method: "terraform",
|
|
291
|
+
content: output.content,
|
|
292
|
+
fetchedAt,
|
|
293
|
+
truncated: output.truncated,
|
|
294
|
+
notes: ["Fetched via Terraform Registry API"],
|
|
295
|
+
};
|
|
296
|
+
}
|