@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,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discogs URL handler for music releases and masters
|
|
3
|
+
*
|
|
4
|
+
* Uses the Discogs API to extract structured metadata about releases.
|
|
5
|
+
* API docs: https://www.discogs.com/developers
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
9
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
10
|
+
|
|
11
|
+
interface DiscogsArtist {
|
|
12
|
+
name: string;
|
|
13
|
+
anv?: string; // artist name variation
|
|
14
|
+
role?: string;
|
|
15
|
+
join?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DiscogsTrack {
|
|
19
|
+
position: string;
|
|
20
|
+
title: string;
|
|
21
|
+
duration?: string;
|
|
22
|
+
artists?: DiscogsArtist[];
|
|
23
|
+
extraartists?: DiscogsArtist[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DiscogsLabel {
|
|
27
|
+
name: string;
|
|
28
|
+
catno?: string; // catalog number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DiscogsFormat {
|
|
32
|
+
name: string;
|
|
33
|
+
qty?: string;
|
|
34
|
+
descriptions?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DiscogsRelease {
|
|
38
|
+
id: number;
|
|
39
|
+
title: string;
|
|
40
|
+
artists?: DiscogsArtist[];
|
|
41
|
+
year?: number;
|
|
42
|
+
released?: string;
|
|
43
|
+
country?: string;
|
|
44
|
+
genres?: string[];
|
|
45
|
+
styles?: string[];
|
|
46
|
+
labels?: DiscogsLabel[];
|
|
47
|
+
formats?: DiscogsFormat[];
|
|
48
|
+
tracklist?: DiscogsTrack[];
|
|
49
|
+
extraartists?: DiscogsArtist[];
|
|
50
|
+
notes?: string;
|
|
51
|
+
uri?: string;
|
|
52
|
+
master_id?: number;
|
|
53
|
+
master_url?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DiscogsMaster {
|
|
57
|
+
id: number;
|
|
58
|
+
title: string;
|
|
59
|
+
artists?: DiscogsArtist[];
|
|
60
|
+
year?: number;
|
|
61
|
+
genres?: string[];
|
|
62
|
+
styles?: string[];
|
|
63
|
+
tracklist?: DiscogsTrack[];
|
|
64
|
+
notes?: string;
|
|
65
|
+
uri?: string;
|
|
66
|
+
main_release?: number;
|
|
67
|
+
main_release_url?: string;
|
|
68
|
+
versions_url?: string;
|
|
69
|
+
num_for_sale?: number;
|
|
70
|
+
lowest_price?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Format artist names, handling name variations
|
|
75
|
+
*/
|
|
76
|
+
function formatArtists(artists: DiscogsArtist[] | undefined): string {
|
|
77
|
+
if (!artists?.length) return "Unknown Artist";
|
|
78
|
+
return artists
|
|
79
|
+
.map((a) => {
|
|
80
|
+
const name = a.anv || a.name;
|
|
81
|
+
const join = a.join || ", ";
|
|
82
|
+
return name + (a.join ? ` ${join} ` : "");
|
|
83
|
+
})
|
|
84
|
+
.join("")
|
|
85
|
+
.replace(/[,&]\s*$/, "")
|
|
86
|
+
.trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format a single track
|
|
91
|
+
*/
|
|
92
|
+
function formatTrack(track: DiscogsTrack): string {
|
|
93
|
+
let line = track.position ? `${track.position}. ` : "- ";
|
|
94
|
+
line += track.title;
|
|
95
|
+
if (track.duration) line += ` (${track.duration})`;
|
|
96
|
+
if (track.artists?.length) {
|
|
97
|
+
line += ` - ${formatArtists(track.artists)}`;
|
|
98
|
+
}
|
|
99
|
+
return line;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format credits/extraartists grouped by role
|
|
104
|
+
*/
|
|
105
|
+
function formatCredits(extraartists: DiscogsArtist[] | undefined): string {
|
|
106
|
+
if (!extraartists?.length) return "";
|
|
107
|
+
|
|
108
|
+
const byRole: Record<string, string[]> = {};
|
|
109
|
+
for (const artist of extraartists) {
|
|
110
|
+
const role = artist.role || "Other";
|
|
111
|
+
if (!byRole[role]) byRole[role] = [];
|
|
112
|
+
byRole[role].push(artist.anv || artist.name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
for (const [role, names] of Object.entries(byRole)) {
|
|
117
|
+
lines.push(`- **${role}**: ${names.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Format formats (e.g., "2×LP, Album, Reissue")
|
|
124
|
+
*/
|
|
125
|
+
function formatFormats(formats: DiscogsFormat[] | undefined): string {
|
|
126
|
+
if (!formats?.length) return "";
|
|
127
|
+
|
|
128
|
+
return formats
|
|
129
|
+
.map((f) => {
|
|
130
|
+
const parts: string[] = [];
|
|
131
|
+
if (f.qty && parseInt(f.qty, 10) > 1) parts.push(`${f.qty}×`);
|
|
132
|
+
parts.push(f.name);
|
|
133
|
+
if (f.descriptions?.length) parts.push(f.descriptions.join(", "));
|
|
134
|
+
return parts.join(" ");
|
|
135
|
+
})
|
|
136
|
+
.join(" + ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Format labels with catalog numbers
|
|
141
|
+
*/
|
|
142
|
+
function formatLabels(labels: DiscogsLabel[] | undefined): string {
|
|
143
|
+
if (!labels?.length) return "";
|
|
144
|
+
return labels
|
|
145
|
+
.map((l) => {
|
|
146
|
+
if (l.catno && l.catno !== "none") return `${l.name} (${l.catno})`;
|
|
147
|
+
return l.name;
|
|
148
|
+
})
|
|
149
|
+
.join(", ");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build markdown for a release
|
|
154
|
+
*/
|
|
155
|
+
function buildReleaseMarkdown(release: DiscogsRelease): string {
|
|
156
|
+
const sections: string[] = [];
|
|
157
|
+
|
|
158
|
+
// Title with artist
|
|
159
|
+
const artist = formatArtists(release.artists);
|
|
160
|
+
sections.push(`# ${artist} - ${release.title}\n`);
|
|
161
|
+
|
|
162
|
+
// Metadata
|
|
163
|
+
const meta: string[] = [];
|
|
164
|
+
if (release.year) meta.push(`**Year**: ${release.year}`);
|
|
165
|
+
if (release.country) meta.push(`**Country**: ${release.country}`);
|
|
166
|
+
|
|
167
|
+
const format = formatFormats(release.formats);
|
|
168
|
+
if (format) meta.push(`**Format**: ${format}`);
|
|
169
|
+
|
|
170
|
+
const labels = formatLabels(release.labels);
|
|
171
|
+
if (labels) meta.push(`**Label**: ${labels}`);
|
|
172
|
+
|
|
173
|
+
if (release.genres?.length) meta.push(`**Genre**: ${release.genres.join(", ")}`);
|
|
174
|
+
if (release.styles?.length) meta.push(`**Style**: ${release.styles.join(", ")}`);
|
|
175
|
+
|
|
176
|
+
if (release.master_id) {
|
|
177
|
+
meta.push(`**Master Release**: [${release.master_id}](https://www.discogs.com/master/${release.master_id})`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (meta.length) sections.push(`${meta.join("\n")}\n`);
|
|
181
|
+
|
|
182
|
+
// Tracklist
|
|
183
|
+
if (release.tracklist?.length) {
|
|
184
|
+
sections.push("## Tracklist\n");
|
|
185
|
+
const tracks = release.tracklist.map(formatTrack);
|
|
186
|
+
sections.push(`${tracks.join("\n")}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Credits
|
|
190
|
+
const credits = formatCredits(release.extraartists);
|
|
191
|
+
if (credits) {
|
|
192
|
+
sections.push("## Credits\n");
|
|
193
|
+
sections.push(`${credits}\n`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Notes
|
|
197
|
+
if (release.notes) {
|
|
198
|
+
sections.push("## Notes\n");
|
|
199
|
+
sections.push(`${release.notes}\n`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return sections.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build markdown for a master release
|
|
207
|
+
*/
|
|
208
|
+
function buildMasterMarkdown(master: DiscogsMaster): string {
|
|
209
|
+
const sections: string[] = [];
|
|
210
|
+
|
|
211
|
+
// Title with artist
|
|
212
|
+
const artist = formatArtists(master.artists);
|
|
213
|
+
sections.push(`# ${artist} - ${master.title}\n`);
|
|
214
|
+
sections.push("*Master Release*\n");
|
|
215
|
+
|
|
216
|
+
// Metadata
|
|
217
|
+
const meta: string[] = [];
|
|
218
|
+
if (master.year) meta.push(`**Year**: ${master.year}`);
|
|
219
|
+
if (master.genres?.length) meta.push(`**Genre**: ${master.genres.join(", ")}`);
|
|
220
|
+
if (master.styles?.length) meta.push(`**Style**: ${master.styles.join(", ")}`);
|
|
221
|
+
|
|
222
|
+
if (master.main_release) {
|
|
223
|
+
meta.push(`**Main Release**: [${master.main_release}](https://www.discogs.com/release/${master.main_release})`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (master.num_for_sale !== undefined && master.num_for_sale > 0) {
|
|
227
|
+
meta.push(`**For Sale**: ${master.num_for_sale} copies`);
|
|
228
|
+
if (master.lowest_price !== undefined) {
|
|
229
|
+
meta.push(`**Lowest Price**: $${master.lowest_price.toFixed(2)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (meta.length) sections.push(`${meta.join("\n")}\n`);
|
|
234
|
+
|
|
235
|
+
// Tracklist
|
|
236
|
+
if (master.tracklist?.length) {
|
|
237
|
+
sections.push("## Tracklist\n");
|
|
238
|
+
const tracks = master.tracklist.map(formatTrack);
|
|
239
|
+
sections.push(`${tracks.join("\n")}\n`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Notes
|
|
243
|
+
if (master.notes) {
|
|
244
|
+
sections.push("## Notes\n");
|
|
245
|
+
sections.push(`${master.notes}\n`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return sections.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const handleDiscogs: SpecialHandler = async (
|
|
252
|
+
url: string,
|
|
253
|
+
timeout: number,
|
|
254
|
+
signal?: AbortSignal,
|
|
255
|
+
): Promise<RenderResult | null> => {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = new URL(url);
|
|
258
|
+
if (!parsed.hostname.includes("discogs.com")) return null;
|
|
259
|
+
|
|
260
|
+
// Match release or master URLs
|
|
261
|
+
// Patterns: /release/{id}, /master/{id}
|
|
262
|
+
// Also handles: /release/{id}-Artist-Title, /master/{id}-Artist-Title
|
|
263
|
+
const releaseMatch = parsed.pathname.match(/\/release\/(\d+)/);
|
|
264
|
+
const masterMatch = parsed.pathname.match(/\/master\/(\d+)/);
|
|
265
|
+
|
|
266
|
+
if (!releaseMatch && !masterMatch) return null;
|
|
267
|
+
|
|
268
|
+
const fetchedAt = new Date().toISOString();
|
|
269
|
+
const isRelease = !!releaseMatch;
|
|
270
|
+
const id = isRelease ? releaseMatch[1] : masterMatch![1];
|
|
271
|
+
|
|
272
|
+
const apiUrl = isRelease ? `https://api.discogs.com/releases/${id}` : `https://api.discogs.com/masters/${id}`;
|
|
273
|
+
|
|
274
|
+
const result = await loadPage(apiUrl, {
|
|
275
|
+
timeout,
|
|
276
|
+
signal,
|
|
277
|
+
headers: {
|
|
278
|
+
Accept: "application/json",
|
|
279
|
+
"User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (!result.ok) return null;
|
|
284
|
+
|
|
285
|
+
let md: string;
|
|
286
|
+
if (isRelease) {
|
|
287
|
+
const release = JSON.parse(result.content) as DiscogsRelease;
|
|
288
|
+
md = buildReleaseMarkdown(release);
|
|
289
|
+
} else {
|
|
290
|
+
const master = JSON.parse(result.content) as DiscogsMaster;
|
|
291
|
+
md = buildMasterMarkdown(master);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const output = finalizeOutput(md);
|
|
295
|
+
return {
|
|
296
|
+
url,
|
|
297
|
+
finalUrl: url,
|
|
298
|
+
contentType: "text/markdown",
|
|
299
|
+
method: "discogs",
|
|
300
|
+
content: output.content,
|
|
301
|
+
fetchedAt,
|
|
302
|
+
truncated: output.truncated,
|
|
303
|
+
notes: [`Fetched via Discogs API (${isRelease ? "release" : "master"})`],
|
|
304
|
+
};
|
|
305
|
+
} catch {}
|
|
306
|
+
|
|
307
|
+
return null;
|
|
308
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface DiscourseUser {
|
|
5
|
+
username?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DiscoursePost {
|
|
10
|
+
id: number;
|
|
11
|
+
username?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
created_at?: string;
|
|
14
|
+
cooked?: string;
|
|
15
|
+
raw?: string;
|
|
16
|
+
like_count?: number;
|
|
17
|
+
post_number?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DiscoursePostResponse extends DiscoursePost {
|
|
21
|
+
topic_id?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DiscourseTopic {
|
|
25
|
+
id?: number;
|
|
26
|
+
title?: string;
|
|
27
|
+
fancy_title?: string;
|
|
28
|
+
posts_count?: number;
|
|
29
|
+
created_at?: string;
|
|
30
|
+
views?: number;
|
|
31
|
+
like_count?: number;
|
|
32
|
+
tags?: string[];
|
|
33
|
+
category_id?: number;
|
|
34
|
+
category_slug?: string;
|
|
35
|
+
category?: { id?: number; name?: string; slug?: string };
|
|
36
|
+
excerpt?: string;
|
|
37
|
+
details?: { created_by?: DiscourseUser };
|
|
38
|
+
post_stream?: { posts?: DiscoursePost[] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_POSTS = 20;
|
|
42
|
+
|
|
43
|
+
function normalizeBasePath(basePath: string): string {
|
|
44
|
+
if (!basePath || basePath === "/") return "";
|
|
45
|
+
return basePath.replace(/\/$/, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseTopicPath(pathname: string): { basePath: string; topicId: string } | null {
|
|
49
|
+
const match = pathname.match(/^(.*?)(?:\/t\/)(?:[^/]+\/)?(\d+)(?:\.json)?(?:\/|$)/);
|
|
50
|
+
if (!match) return null;
|
|
51
|
+
return { basePath: match[1] ?? "", topicId: match[2] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parsePostPath(pathname: string): { basePath: string; postId: string } | null {
|
|
55
|
+
const match = pathname.match(/^(.*?)(?:\/posts\/)(\d+)(?:\.json)?(?:\/|$)/);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
return { basePath: match[1] ?? "", postId: match[2] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatAuthor(user?: DiscourseUser | null): string {
|
|
61
|
+
if (!user) return "unknown";
|
|
62
|
+
const name = user.name?.trim();
|
|
63
|
+
const username = user.username?.trim();
|
|
64
|
+
if (name && username && name !== username) return `${name} (@${username})`;
|
|
65
|
+
if (username) return `@${username}`;
|
|
66
|
+
if (name) return name;
|
|
67
|
+
return "unknown";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatIsoDate(value?: string): string {
|
|
71
|
+
if (!value) return "unknown";
|
|
72
|
+
const date = new Date(value);
|
|
73
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
74
|
+
return date.toISOString().split("T")[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatCategory(topic: DiscourseTopic): string | null {
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
const name = topic.category?.name ?? topic.category_slug;
|
|
80
|
+
if (name) parts.push(name);
|
|
81
|
+
const id = topic.category?.id ?? topic.category_id;
|
|
82
|
+
if (id != null) parts.push(`#${id}`);
|
|
83
|
+
return parts.length ? parts.join(" ") : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatPostBody(post: DiscoursePost): string {
|
|
87
|
+
const raw = post.raw?.trim();
|
|
88
|
+
if (raw) return raw;
|
|
89
|
+
const cooked = post.cooked?.trim();
|
|
90
|
+
if (!cooked) return "";
|
|
91
|
+
return htmlToBasicMarkdown(cooked);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildTopicUrl(baseUrl: string, topicId: string): string {
|
|
95
|
+
const topicUrl = new URL(`${baseUrl}/t/${topicId}.json`);
|
|
96
|
+
topicUrl.searchParams.set("include_raw", "1");
|
|
97
|
+
return topicUrl.toString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildPostUrl(baseUrl: string, postId: string): string {
|
|
101
|
+
const postUrl = new URL(`${baseUrl}/posts/${postId}.json`);
|
|
102
|
+
postUrl.searchParams.set("include_raw", "1");
|
|
103
|
+
return postUrl.toString();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle Discourse forum URLs via API
|
|
108
|
+
*/
|
|
109
|
+
export const handleDiscourse: SpecialHandler = async (
|
|
110
|
+
url: string,
|
|
111
|
+
timeout: number,
|
|
112
|
+
signal?: AbortSignal,
|
|
113
|
+
): Promise<RenderResult | null> => {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
const topicMatch = parseTopicPath(parsed.pathname);
|
|
117
|
+
const postMatch = topicMatch ? null : parsePostPath(parsed.pathname);
|
|
118
|
+
if (!topicMatch && !postMatch) return null;
|
|
119
|
+
|
|
120
|
+
const basePath = normalizeBasePath(topicMatch?.basePath ?? postMatch?.basePath ?? "");
|
|
121
|
+
const baseUrl = `${parsed.origin}${basePath}`;
|
|
122
|
+
|
|
123
|
+
let requestedPost: DiscoursePost | null = null;
|
|
124
|
+
let topicId = topicMatch?.topicId ?? null;
|
|
125
|
+
|
|
126
|
+
if (!topicId && postMatch) {
|
|
127
|
+
const postResult = await loadPage(buildPostUrl(baseUrl, postMatch.postId), { timeout, signal });
|
|
128
|
+
if (!postResult.ok) return null;
|
|
129
|
+
|
|
130
|
+
let postData: DiscoursePostResponse;
|
|
131
|
+
try {
|
|
132
|
+
postData = JSON.parse(postResult.content) as DiscoursePostResponse;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!postData.topic_id) return null;
|
|
138
|
+
topicId = String(postData.topic_id);
|
|
139
|
+
requestedPost = postData;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!topicId) return null;
|
|
143
|
+
|
|
144
|
+
const topicResult = await loadPage(buildTopicUrl(baseUrl, topicId), { timeout, signal });
|
|
145
|
+
if (!topicResult.ok) return null;
|
|
146
|
+
|
|
147
|
+
let topic: DiscourseTopic;
|
|
148
|
+
try {
|
|
149
|
+
topic = JSON.parse(topicResult.content) as DiscourseTopic;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const title = topic.title || topic.fancy_title;
|
|
155
|
+
if (!title) return null;
|
|
156
|
+
|
|
157
|
+
const fetchedAt = new Date().toISOString();
|
|
158
|
+
|
|
159
|
+
const posts: DiscoursePost[] = [...(topic.post_stream?.posts ?? [])];
|
|
160
|
+
if (requestedPost && !posts.some((post) => post.id === requestedPost?.id)) {
|
|
161
|
+
posts.unshift(requestedPost);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let md = `# ${title}\n\n`;
|
|
165
|
+
|
|
166
|
+
const metaParts: string[] = [];
|
|
167
|
+
if (topic.id != null) metaParts.push(`**Topic ID:** ${topic.id}`);
|
|
168
|
+
if (topic.posts_count != null) metaParts.push(`**Posts:** ${topic.posts_count}`);
|
|
169
|
+
if (topic.views != null) metaParts.push(`**Views:** ${topic.views}`);
|
|
170
|
+
if (topic.like_count != null) metaParts.push(`**Likes:** ${topic.like_count}`);
|
|
171
|
+
if (metaParts.length) md += `${metaParts.join(" | ")}\n`;
|
|
172
|
+
|
|
173
|
+
const categoryLabel = formatCategory(topic);
|
|
174
|
+
if (categoryLabel) md += `**Category:** ${categoryLabel}\n`;
|
|
175
|
+
if (topic.tags?.length) md += `**Tags:** ${topic.tags.join(", ")}\n`;
|
|
176
|
+
|
|
177
|
+
const createdBy = formatAuthor(topic.details?.created_by ?? null);
|
|
178
|
+
if (createdBy !== "unknown" || topic.created_at) {
|
|
179
|
+
md += `**Created by:** ${createdBy} - ${formatIsoDate(topic.created_at)}\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
md += "\n";
|
|
183
|
+
|
|
184
|
+
const description = topic.excerpt
|
|
185
|
+
? htmlToBasicMarkdown(topic.excerpt)
|
|
186
|
+
: posts.length
|
|
187
|
+
? formatPostBody(posts[0])
|
|
188
|
+
: "";
|
|
189
|
+
if (description) {
|
|
190
|
+
md += `## Description\n\n${description}\n\n`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (posts.length) {
|
|
194
|
+
md += "## Posts\n\n";
|
|
195
|
+
for (const post of posts.slice(0, MAX_POSTS)) {
|
|
196
|
+
const author = formatAuthor({ name: post.name, username: post.username });
|
|
197
|
+
const date = formatIsoDate(post.created_at);
|
|
198
|
+
const likes = post.like_count ?? 0;
|
|
199
|
+
const content = formatPostBody(post);
|
|
200
|
+
const postLabel = post.post_number != null ? `Post ${post.post_number}` : `Post ${post.id}`;
|
|
201
|
+
|
|
202
|
+
md += `### ${postLabel} - ${author} - ${date} - Likes: ${likes}\n\n`;
|
|
203
|
+
md += content ? `${content}\n\n---\n\n` : "_No content available._\n\n---\n\n";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const output = finalizeOutput(md);
|
|
208
|
+
return {
|
|
209
|
+
url,
|
|
210
|
+
finalUrl: url,
|
|
211
|
+
contentType: "text/markdown",
|
|
212
|
+
method: "discourse-api",
|
|
213
|
+
content: output.content,
|
|
214
|
+
fetchedAt,
|
|
215
|
+
truncated: output.truncated,
|
|
216
|
+
notes: ["Fetched via Discourse API"],
|
|
217
|
+
};
|
|
218
|
+
} catch {}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface DockerHubRepo {
|
|
5
|
+
name: string;
|
|
6
|
+
namespace: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
star_count?: number;
|
|
9
|
+
pull_count?: number;
|
|
10
|
+
last_updated?: string;
|
|
11
|
+
is_official?: boolean;
|
|
12
|
+
is_automated?: boolean;
|
|
13
|
+
user?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DockerHubTag {
|
|
17
|
+
name: string;
|
|
18
|
+
last_updated?: string;
|
|
19
|
+
full_size?: number;
|
|
20
|
+
digest?: string;
|
|
21
|
+
images?: Array<{
|
|
22
|
+
architecture?: string;
|
|
23
|
+
os?: string;
|
|
24
|
+
size?: number;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DockerHubTagsResponse {
|
|
29
|
+
results?: DockerHubTag[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatSize(bytes: number): string {
|
|
33
|
+
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)}GB`;
|
|
34
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}MB`;
|
|
35
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)}KB`;
|
|
36
|
+
return `${bytes}B`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle Docker Hub URLs via API
|
|
41
|
+
*/
|
|
42
|
+
export const handleDockerHub: SpecialHandler = async (
|
|
43
|
+
url: string,
|
|
44
|
+
timeout: number,
|
|
45
|
+
signal?: AbortSignal,
|
|
46
|
+
): Promise<RenderResult | null> => {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(url);
|
|
49
|
+
if (!parsed.hostname.includes("hub.docker.com")) return null;
|
|
50
|
+
|
|
51
|
+
let namespace: string;
|
|
52
|
+
let repository: string;
|
|
53
|
+
|
|
54
|
+
// Official images: /_ /{image}
|
|
55
|
+
const officialMatch = parsed.pathname.match(/^\/_\/([^/]+)/);
|
|
56
|
+
if (officialMatch) {
|
|
57
|
+
namespace = "library";
|
|
58
|
+
repository = officialMatch[1];
|
|
59
|
+
} else {
|
|
60
|
+
// Regular images: /r/{namespace}/{repository}
|
|
61
|
+
const repoMatch = parsed.pathname.match(/^\/r\/([^/]+)\/([^/]+)/);
|
|
62
|
+
if (!repoMatch) return null;
|
|
63
|
+
namespace = repoMatch[1];
|
|
64
|
+
repository = repoMatch[2];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fetchedAt = new Date().toISOString();
|
|
68
|
+
|
|
69
|
+
// Fetch repository info and tags in parallel
|
|
70
|
+
const repoUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/`;
|
|
71
|
+
const tagsUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/?page_size=10`;
|
|
72
|
+
|
|
73
|
+
const [repoResult, tagsResult] = await Promise.all([
|
|
74
|
+
loadPage(repoUrl, { timeout, headers: { Accept: "application/json" }, signal }),
|
|
75
|
+
loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" }, signal }),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
if (!repoResult.ok) return null;
|
|
79
|
+
|
|
80
|
+
let repo: DockerHubRepo;
|
|
81
|
+
try {
|
|
82
|
+
repo = JSON.parse(repoResult.content);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Parse tags
|
|
88
|
+
let tags: DockerHubTag[] = [];
|
|
89
|
+
if (tagsResult.ok) {
|
|
90
|
+
try {
|
|
91
|
+
const tagsData = JSON.parse(tagsResult.content) as DockerHubTagsResponse;
|
|
92
|
+
tags = tagsData.results ?? [];
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build markdown output
|
|
97
|
+
const fullName = namespace === "library" ? repo.name : `${namespace}/${repo.name}`;
|
|
98
|
+
let md = `# ${fullName}\n\n`;
|
|
99
|
+
|
|
100
|
+
if (repo.description) {
|
|
101
|
+
md += `${repo.description}\n\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Stats line
|
|
105
|
+
const stats: string[] = [];
|
|
106
|
+
if (repo.pull_count !== undefined) stats.push(`**Pulls:** ${formatCount(repo.pull_count)}`);
|
|
107
|
+
if (repo.star_count !== undefined) stats.push(`**Stars:** ${formatCount(repo.star_count)}`);
|
|
108
|
+
if (repo.is_official) stats.push("**Official Image**");
|
|
109
|
+
if (repo.is_automated) stats.push("**Automated Build**");
|
|
110
|
+
if (stats.length > 0) {
|
|
111
|
+
md += `${stats.join(" · ")}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (repo.last_updated) {
|
|
115
|
+
const date = new Date(repo.last_updated);
|
|
116
|
+
md += `**Last Updated:** ${date.toISOString().split("T")[0]}\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
md += "\n";
|
|
120
|
+
|
|
121
|
+
// Docker pull command
|
|
122
|
+
md += "## Quick Start\n\n";
|
|
123
|
+
md += "```bash\n";
|
|
124
|
+
md += `docker pull ${fullName}\n`;
|
|
125
|
+
md += "```\n\n";
|
|
126
|
+
|
|
127
|
+
// Tags
|
|
128
|
+
if (tags.length > 0) {
|
|
129
|
+
md += "## Recent Tags\n\n";
|
|
130
|
+
md += "| Tag | Size | Architectures | Updated |\n";
|
|
131
|
+
md += "|-----|------|---------------|--------|\n";
|
|
132
|
+
|
|
133
|
+
for (const tag of tags) {
|
|
134
|
+
const size = tag.full_size ? formatSize(tag.full_size) : "-";
|
|
135
|
+
const archs =
|
|
136
|
+
tag.images
|
|
137
|
+
?.map((img) => img.architecture)
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(", ") || "-";
|
|
140
|
+
const updated = tag.last_updated ? new Date(tag.last_updated).toISOString().split("T")[0] : "-";
|
|
141
|
+
md += `| \`${tag.name}\` | ${size} | ${archs} | ${updated} |\n`;
|
|
142
|
+
}
|
|
143
|
+
md += "\n";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const output = finalizeOutput(md);
|
|
147
|
+
return {
|
|
148
|
+
url,
|
|
149
|
+
finalUrl: url,
|
|
150
|
+
contentType: "text/markdown",
|
|
151
|
+
method: "dockerhub",
|
|
152
|
+
content: output.content,
|
|
153
|
+
fetchedAt,
|
|
154
|
+
truncated: output.truncated,
|
|
155
|
+
notes: ["Fetched via Docker Hub API"],
|
|
156
|
+
};
|
|
157
|
+
} catch {}
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
};
|