@oh-my-pi/pi-coding-agent 3.25.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +55 -32
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +152 -76
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -0,0 +1,373 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ const GRAPHQL_ENDPOINT = "https://sourcegraph.com/.api/graphql";
5
+ const GRAPHQL_HEADERS = {
6
+ Accept: "application/json",
7
+ "Content-Type": "application/json",
8
+ };
9
+
10
+ type SourcegraphTarget =
11
+ | { type: "search"; query: string }
12
+ | { type: "repo"; repoName: string; rev?: string }
13
+ | { type: "file"; repoName: string; rev?: string; filePath: string };
14
+
15
+ interface SourcegraphRepository {
16
+ name: string;
17
+ url: string;
18
+ description?: string | null;
19
+ defaultBranch?: { name: string } | null;
20
+ }
21
+
22
+ interface RepoQueryData {
23
+ repository?: SourcegraphRepository | null;
24
+ }
25
+
26
+ interface RepoFileQueryData {
27
+ repository?:
28
+ | (SourcegraphRepository & {
29
+ commit?: {
30
+ blob?: { content?: string | null } | null;
31
+ } | null;
32
+ })
33
+ | null;
34
+ }
35
+
36
+ interface SearchQueryData {
37
+ search?: {
38
+ results?: {
39
+ results?: SearchResultItem[] | null;
40
+ matchCount?: number | null;
41
+ limitHit?: boolean | null;
42
+ } | null;
43
+ } | null;
44
+ }
45
+
46
+ interface FileMatchResult {
47
+ __typename: "FileMatch";
48
+ repository?: { name?: string | null; url?: string | null } | null;
49
+ file?: { path?: string | null; url?: string | null } | null;
50
+ lineMatches?: Array<{ preview?: string | null; lineNumber?: number | null }> | null;
51
+ }
52
+
53
+ interface RepositoryResult {
54
+ __typename: "Repository";
55
+ name?: string | null;
56
+ url?: string | null;
57
+ }
58
+
59
+ type SearchResultItem = FileMatchResult | RepositoryResult | { __typename: string };
60
+
61
+ const REPO_QUERY = `query Repo($name: String!) {
62
+ repository(name: $name) {
63
+ name
64
+ url
65
+ description
66
+ defaultBranch {
67
+ name
68
+ }
69
+ }
70
+ }`;
71
+
72
+ const REPO_FILE_QUERY = `query RepoFile($name: String!, $path: String!, $rev: String!) {
73
+ repository(name: $name) {
74
+ name
75
+ url
76
+ description
77
+ defaultBranch {
78
+ name
79
+ }
80
+ commit(rev: $rev) {
81
+ blob(path: $path) {
82
+ content
83
+ }
84
+ }
85
+ }
86
+ }`;
87
+
88
+ const SEARCH_QUERY = `query Search($query: String!) {
89
+ search(query: $query, version: V2) {
90
+ results {
91
+ results {
92
+ __typename
93
+ ... on FileMatch {
94
+ repository {
95
+ name
96
+ url
97
+ }
98
+ file {
99
+ path
100
+ url
101
+ }
102
+ lineMatches {
103
+ preview
104
+ lineNumber
105
+ }
106
+ }
107
+ ... on Repository {
108
+ name
109
+ url
110
+ }
111
+ }
112
+ matchCount
113
+ limitHit
114
+ }
115
+ }
116
+ }`;
117
+
118
+ function parseSourcegraphUrl(url: string): SourcegraphTarget | null {
119
+ try {
120
+ const parsed = new URL(url);
121
+ if (parsed.hostname !== "sourcegraph.com" && parsed.hostname !== "www.sourcegraph.com") return null;
122
+
123
+ if (parsed.pathname.startsWith("/search")) {
124
+ const query = parsed.searchParams.get("q")?.trim();
125
+ if (!query) return null;
126
+ return { type: "search", query };
127
+ }
128
+
129
+ const parts = parsed.pathname
130
+ .split("/")
131
+ .filter(Boolean)
132
+ .map((part) => decodeURIComponent(part));
133
+ if (parts.length < 3) return null;
134
+
135
+ const hyphenIndex = parts.indexOf("-");
136
+ const repoParts = hyphenIndex === -1 ? parts : parts.slice(0, hyphenIndex);
137
+ if (repoParts.length < 3) return null;
138
+
139
+ const lastRepoPart = repoParts[repoParts.length - 1];
140
+ const atIndex = lastRepoPart.indexOf("@");
141
+ let rev: string | undefined;
142
+ let repoTail = lastRepoPart;
143
+ if (atIndex > 0) {
144
+ repoTail = lastRepoPart.slice(0, atIndex);
145
+ rev = lastRepoPart.slice(atIndex + 1) || undefined;
146
+ }
147
+
148
+ repoParts[repoParts.length - 1] = repoTail;
149
+ const repoName = repoParts.join("/");
150
+
151
+ if (hyphenIndex !== -1 && parts[hyphenIndex + 1] === "blob") {
152
+ const filePath = parts.slice(hyphenIndex + 2).join("/");
153
+ if (!filePath) return null;
154
+ return { type: "file", repoName, rev, filePath };
155
+ }
156
+
157
+ return { type: "repo", repoName, rev };
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function safeParseJson<T>(content: string): T | null {
164
+ try {
165
+ return JSON.parse(content) as T;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ async function fetchGraphql<T>(
172
+ query: string,
173
+ variables: Record<string, unknown>,
174
+ timeout: number,
175
+ signal?: AbortSignal,
176
+ ): Promise<T | null> {
177
+ const body = JSON.stringify({ query, variables });
178
+ const result = await loadPage(GRAPHQL_ENDPOINT, {
179
+ timeout,
180
+ headers: GRAPHQL_HEADERS,
181
+ method: "POST",
182
+ body,
183
+ signal,
184
+ });
185
+ if (!result.ok) return null;
186
+
187
+ const parsed = safeParseJson<{ data?: T; errors?: unknown }>(result.content);
188
+ if (!parsed?.data) return null;
189
+ if (Array.isArray(parsed.errors) && parsed.errors.length > 0) return null;
190
+ return parsed.data;
191
+ }
192
+
193
+ function isFileMatchResult(result: SearchResultItem): result is FileMatchResult {
194
+ return result.__typename === "FileMatch";
195
+ }
196
+
197
+ function isRepositoryResult(result: SearchResultItem): result is RepositoryResult {
198
+ return result.__typename === "Repository";
199
+ }
200
+
201
+ function formatRepoMarkdown(repo: SourcegraphRepository): string {
202
+ let md = `# ${repo.name}\n\n`;
203
+ if (repo.description) md += `${repo.description}\n\n`;
204
+ md += `**URL:** ${repo.url}\n`;
205
+ if (repo.defaultBranch?.name) md += `**Default branch:** ${repo.defaultBranch.name}\n`;
206
+ return md;
207
+ }
208
+
209
+ async function renderRepo(
210
+ repoName: string,
211
+ timeout: number,
212
+ signal?: AbortSignal,
213
+ ): Promise<{ content: string; ok: boolean }> {
214
+ const data = await fetchGraphql<RepoQueryData>(REPO_QUERY, { name: repoName }, timeout, signal);
215
+ if (!data?.repository) return { content: "", ok: false };
216
+
217
+ return { content: formatRepoMarkdown(data.repository), ok: true };
218
+ }
219
+
220
+ async function renderFile(
221
+ repoName: string,
222
+ filePath: string,
223
+ rev: string,
224
+ timeout: number,
225
+ signal?: AbortSignal,
226
+ ): Promise<{ content: string; ok: boolean }> {
227
+ const data = await fetchGraphql<RepoFileQueryData>(
228
+ REPO_FILE_QUERY,
229
+ { name: repoName, path: filePath, rev },
230
+ timeout,
231
+ signal,
232
+ );
233
+ const repo = data?.repository;
234
+ const content = repo?.commit?.blob?.content ?? null;
235
+ if (!repo || content === null) return { content: "", ok: false };
236
+
237
+ let md = `${formatRepoMarkdown(repo)}\n`;
238
+ md += `**Path:** ${filePath}\n`;
239
+ md += `**Revision:** ${rev}\n\n`;
240
+ md += `---\n\n## File\n\n`;
241
+ md += "```text\n";
242
+ md += `${content}\n`;
243
+ md += "```\n";
244
+ return { content: md, ok: true };
245
+ }
246
+
247
+ async function renderSearch(
248
+ query: string,
249
+ timeout: number,
250
+ signal?: AbortSignal,
251
+ ): Promise<{ content: string; ok: boolean }> {
252
+ const data = await fetchGraphql<SearchQueryData>(SEARCH_QUERY, { query }, timeout, signal);
253
+ const resultsData = data?.search?.results;
254
+ if (!resultsData) return { content: "", ok: false };
255
+ const results = resultsData.results ?? [];
256
+
257
+ let md = "# Sourcegraph Search\n\n";
258
+ md += `**Query:** \`${query}\`\n`;
259
+ if (typeof resultsData?.matchCount === "number") {
260
+ md += `**Matches:** ${resultsData.matchCount}\n`;
261
+ }
262
+ if (typeof resultsData?.limitHit === "boolean") {
263
+ md += `**Limit hit:** ${resultsData.limitHit ? "yes" : "no"}\n`;
264
+ }
265
+ md += "\n";
266
+
267
+ if (!results || results.length === 0) {
268
+ md += "_No results._\n";
269
+ return { content: md, ok: true };
270
+ }
271
+
272
+ const maxResults = 10;
273
+ md += "## Results\n\n";
274
+ for (const result of results.slice(0, maxResults)) {
275
+ if (isFileMatchResult(result)) {
276
+ const repoName = result.repository?.name ?? "unknown";
277
+ const filePath = result.file?.path ?? "unknown";
278
+ md += `### ${repoName}/${filePath}\n\n`;
279
+ if (result.repository?.url) md += `**Repository:** ${result.repository.url}\n`;
280
+ if (result.file?.url) md += `**File:** ${result.file.url}\n`;
281
+
282
+ const lineMatches = result.lineMatches ?? [];
283
+ if (lineMatches.length > 0) {
284
+ md += "\n```text\n";
285
+ for (const line of lineMatches.slice(0, 5)) {
286
+ const preview = (line.preview ?? "").replace(/\n/g, " ").trim();
287
+ const lineNumber = line.lineNumber ?? 0;
288
+ md += `L${lineNumber}: ${preview}\n`;
289
+ }
290
+ md += "```\n\n";
291
+ }
292
+ continue;
293
+ }
294
+
295
+ if (isRepositoryResult(result)) {
296
+ const name = result.name ?? "unknown";
297
+ md += `### ${name}\n\n`;
298
+ if (result.url) md += `**Repository:** ${result.url}\n`;
299
+ md += "\n";
300
+ }
301
+ }
302
+
303
+ if (results.length > maxResults) {
304
+ md += `... and ${results.length - maxResults} more results\n`;
305
+ }
306
+
307
+ return { content: md, ok: true };
308
+ }
309
+
310
+ export const handleSourcegraph: SpecialHandler = async (
311
+ url: string,
312
+ timeout: number,
313
+ signal?: AbortSignal,
314
+ ): Promise<RenderResult | null> => {
315
+ try {
316
+ const target = parseSourcegraphUrl(url);
317
+ if (!target) return null;
318
+
319
+ const fetchedAt = new Date().toISOString();
320
+ const notes = ["Fetched via Sourcegraph GraphQL API"];
321
+
322
+ switch (target.type) {
323
+ case "search": {
324
+ const result = await renderSearch(target.query, timeout, signal);
325
+ if (!result.ok) return null;
326
+ const output = finalizeOutput(result.content);
327
+ return {
328
+ url,
329
+ finalUrl: url,
330
+ contentType: "text/markdown",
331
+ method: "sourcegraph-search",
332
+ content: output.content,
333
+ fetchedAt,
334
+ truncated: output.truncated,
335
+ notes,
336
+ };
337
+ }
338
+ case "file": {
339
+ const rev = target.rev ?? "HEAD";
340
+ const result = await renderFile(target.repoName, target.filePath, rev, timeout, signal);
341
+ if (!result.ok) return null;
342
+ const output = finalizeOutput(result.content);
343
+ return {
344
+ url,
345
+ finalUrl: url,
346
+ contentType: "text/markdown",
347
+ method: "sourcegraph-file",
348
+ content: output.content,
349
+ fetchedAt,
350
+ truncated: output.truncated,
351
+ notes,
352
+ };
353
+ }
354
+ case "repo": {
355
+ const result = await renderRepo(target.repoName, timeout, signal);
356
+ if (!result.ok) return null;
357
+ const output = finalizeOutput(result.content);
358
+ return {
359
+ url,
360
+ finalUrl: url,
361
+ contentType: "text/markdown",
362
+ method: "sourcegraph-repo",
363
+ content: output.content,
364
+ fetchedAt,
365
+ truncated: output.truncated,
366
+ notes,
367
+ };
368
+ }
369
+ }
370
+ } catch {}
371
+
372
+ return null;
373
+ };
@@ -0,0 +1,121 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface SpdxCrossRef {
5
+ url?: string;
6
+ isValid?: boolean;
7
+ isLive?: boolean;
8
+ match?: string;
9
+ order?: number;
10
+ }
11
+
12
+ interface SpdxLicense {
13
+ licenseId: string;
14
+ name: string;
15
+ isOsiApproved?: boolean;
16
+ isFsfLibre?: boolean;
17
+ licenseText?: string;
18
+ licenseTextHtml?: string;
19
+ seeAlso?: string[];
20
+ crossRef?: SpdxCrossRef[];
21
+ comment?: string;
22
+ licenseComments?: string;
23
+ }
24
+
25
+ function formatYesNo(value?: boolean): string {
26
+ if (value === true) return "Yes";
27
+ if (value === false) return "No";
28
+ return "Unknown";
29
+ }
30
+
31
+ function collectCrossReferences(license: SpdxLicense): string[] {
32
+ const ordered = (license.crossRef ?? [])
33
+ .filter((ref) => ref.url)
34
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
35
+ .map((ref) => ref.url as string);
36
+
37
+ const seeAlso = (license.seeAlso ?? []).filter((url) => url);
38
+ const combined = [...ordered, ...seeAlso];
39
+ return combined.filter((url, index) => combined.indexOf(url) === index);
40
+ }
41
+
42
+ /**
43
+ * Handle SPDX license URLs via SPDX JSON API
44
+ */
45
+ export const handleSpdx: SpecialHandler = async (
46
+ url: string,
47
+ timeout: number,
48
+ signal?: AbortSignal,
49
+ ): Promise<RenderResult | null> => {
50
+ try {
51
+ const parsed = new URL(url);
52
+ if (parsed.hostname !== "spdx.org" && parsed.hostname !== "www.spdx.org") return null;
53
+
54
+ const match = parsed.pathname.match(/^\/licenses\/([^/]+?)(?:\.html)?\/?$/i);
55
+ if (!match) return null;
56
+
57
+ const licenseId = decodeURIComponent(match[1]);
58
+ if (!licenseId) return null;
59
+
60
+ const fetchedAt = new Date().toISOString();
61
+ const apiUrl = `https://spdx.org/licenses/${encodeURIComponent(licenseId)}.json`;
62
+ const result = await loadPage(apiUrl, {
63
+ timeout,
64
+ headers: { Accept: "application/json" },
65
+ signal,
66
+ });
67
+
68
+ if (!result.ok) return null;
69
+
70
+ let license: SpdxLicense;
71
+ try {
72
+ license = JSON.parse(result.content);
73
+ } catch {
74
+ return null;
75
+ }
76
+
77
+ const title = license.name || license.licenseId || licenseId;
78
+ let md = `# ${title}\n\n`;
79
+
80
+ md += `**License ID:** ${license.licenseId ? `\`${license.licenseId}\`` : `\`${licenseId}\``}\n`;
81
+ md += `**OSI Approved:** ${formatYesNo(license.isOsiApproved)}\n`;
82
+ md += `**FSF Libre:** ${formatYesNo(license.isFsfLibre)}\n`;
83
+
84
+ const description = license.licenseComments ?? license.comment;
85
+ if (description) {
86
+ md += `\n## Description\n\n${description}\n`;
87
+ }
88
+
89
+ const crossReferences = collectCrossReferences(license);
90
+ if (crossReferences.length) {
91
+ md += `\n## Cross References\n\n`;
92
+ for (const ref of crossReferences) {
93
+ md += `- ${ref}\n`;
94
+ }
95
+ }
96
+
97
+ const licenseText = license.licenseText
98
+ ? license.licenseText
99
+ : license.licenseTextHtml
100
+ ? htmlToBasicMarkdown(license.licenseTextHtml)
101
+ : null;
102
+
103
+ if (licenseText) {
104
+ md += `\n## License Text\n\n\`\`\`\n${licenseText}\n\`\`\`\n`;
105
+ }
106
+
107
+ const output = finalizeOutput(md);
108
+ return {
109
+ url,
110
+ finalUrl: url,
111
+ contentType: "text/markdown",
112
+ method: "spdx-api",
113
+ content: output.content,
114
+ fetchedAt,
115
+ truncated: output.truncated,
116
+ notes: ["Fetched via SPDX license API"],
117
+ };
118
+ } catch {}
119
+
120
+ return null;
121
+ };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Spotify URL handler for podcasts, tracks, albums, and playlists
3
+ *
4
+ * Uses oEmbed API and Open Graph metadata to extract information
5
+ * from Spotify URLs without requiring authentication.
6
+ */
7
+
8
+ import type { SpecialHandler } from "./types";
9
+ import { finalizeOutput, loadPage } from "./types";
10
+
11
+ interface SpotifyOEmbedResponse {
12
+ title?: string;
13
+ thumbnail_url?: string;
14
+ provider_name?: string;
15
+ html?: string;
16
+ width?: number;
17
+ height?: number;
18
+ }
19
+
20
+ interface OpenGraphData {
21
+ title?: string;
22
+ description?: string;
23
+ audio?: string;
24
+ image?: string;
25
+ type?: string;
26
+ duration?: string;
27
+ album?: string;
28
+ musician?: string;
29
+ artist?: string;
30
+ releaseDate?: string;
31
+ }
32
+
33
+ /**
34
+ * Parse Open Graph meta tags from HTML
35
+ */
36
+ function parseOpenGraph(html: string): OpenGraphData {
37
+ const og: OpenGraphData = {};
38
+
39
+ const metaPattern = /<meta\s+(?:property|name)="([^"]+)"\s+content="([^"]*)"[^>]*>/gi;
40
+ let match: RegExpExecArray | null = null;
41
+
42
+ while (true) {
43
+ match = metaPattern.exec(html);
44
+ if (match === null) break;
45
+ const [, property, content] = match;
46
+
47
+ if (property === "og:title") og.title = content;
48
+ else if (property === "og:description") og.description = content;
49
+ else if (property === "og:audio") og.audio = content;
50
+ else if (property === "og:image") og.image = content;
51
+ else if (property === "og:type") og.type = content;
52
+ else if (property === "music:duration") og.duration = content;
53
+ else if (property === "music:album") og.album = content;
54
+ else if (property === "music:musician") og.musician = content;
55
+ else if (property === "music:release_date") og.releaseDate = content;
56
+ else if (property === "twitter:audio:artist_name") og.artist = content;
57
+ }
58
+
59
+ return og;
60
+ }
61
+
62
+ /**
63
+ * Determine content type from URL path
64
+ */
65
+ function getContentType(url: string): string | null {
66
+ if (url.includes("/episode/")) return "podcast-episode";
67
+ if (url.includes("/show/")) return "podcast-show";
68
+ if (url.includes("/track/")) return "track";
69
+ if (url.includes("/album/")) return "album";
70
+ if (url.includes("/playlist/")) return "playlist";
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Format duration from seconds
76
+ */
77
+ function formatDuration(seconds: string | undefined): string | null {
78
+ if (!seconds) return null;
79
+ const num = parseInt(seconds, 10);
80
+ if (Number.isNaN(num)) return null;
81
+
82
+ const hours = Math.floor(num / 3600);
83
+ const minutes = Math.floor((num % 3600) / 60);
84
+ const secs = num % 60;
85
+
86
+ if (hours > 0) {
87
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
88
+ }
89
+ return `${minutes}:${secs.toString().padStart(2, "0")}`;
90
+ }
91
+
92
+ /**
93
+ * Format output based on content type and available metadata
94
+ */
95
+ function formatOutput(contentType: string, oEmbed: SpotifyOEmbedResponse, og: OpenGraphData, url: string): string {
96
+ const sections: string[] = [];
97
+
98
+ // Title
99
+ const title = og.title || oEmbed.title || "Unknown";
100
+ sections.push(`# ${title}\n`);
101
+
102
+ // Type
103
+ sections.push(`**Type**: ${contentType}\n`);
104
+
105
+ // Description
106
+ if (og.description) {
107
+ sections.push(`**Description**: ${og.description}\n`);
108
+ }
109
+
110
+ // Content-specific metadata
111
+ if (contentType === "track" || contentType === "podcast-episode") {
112
+ if (og.artist || og.musician) {
113
+ sections.push(`**Artist**: ${og.artist || og.musician}\n`);
114
+ }
115
+ if (og.album) {
116
+ sections.push(`**Album**: ${og.album}\n`);
117
+ }
118
+ if (og.duration) {
119
+ const formatted = formatDuration(og.duration);
120
+ if (formatted) {
121
+ sections.push(`**Duration**: ${formatted}\n`);
122
+ }
123
+ }
124
+ }
125
+
126
+ if (contentType === "album" && og.releaseDate) {
127
+ sections.push(`**Release Date**: ${og.releaseDate}\n`);
128
+ }
129
+
130
+ // Note about limited information
131
+ sections.push("\n---\n");
132
+ if (contentType === "playlist") {
133
+ sections.push(
134
+ "**Note**: Playlist details (tracks, creator, follower count) require authentication. " +
135
+ "Only basic metadata is available without Spotify API credentials.\n",
136
+ );
137
+ } else if (contentType === "album") {
138
+ sections.push(
139
+ "**Note**: Track listing and detailed album information require authentication. " +
140
+ "Only basic metadata is available without Spotify API credentials.\n",
141
+ );
142
+ } else if (contentType === "podcast-show") {
143
+ sections.push(
144
+ "**Note**: Episode listing and detailed show information require authentication. " +
145
+ "Only basic metadata is available without Spotify API credentials.\n",
146
+ );
147
+ }
148
+
149
+ sections.push(`**URL**: ${url}\n`);
150
+
151
+ if (oEmbed.thumbnail_url) {
152
+ sections.push(`**Thumbnail**: ${oEmbed.thumbnail_url}\n`);
153
+ } else if (og.image) {
154
+ sections.push(`**Image**: ${og.image}\n`);
155
+ }
156
+
157
+ return sections.join("\n");
158
+ }
159
+
160
+ export const handleSpotify: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
161
+ // Check if this is a Spotify URL
162
+ if (!url.includes("open.spotify.com/")) {
163
+ return null;
164
+ }
165
+
166
+ const contentType = getContentType(url);
167
+ if (!contentType) {
168
+ return null;
169
+ }
170
+
171
+ const notes: string[] = [];
172
+ let oEmbedData: SpotifyOEmbedResponse = {};
173
+ let ogData: OpenGraphData = {};
174
+
175
+ // Fetch oEmbed data
176
+ try {
177
+ const oEmbedUrl = `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`;
178
+ const response = await loadPage(oEmbedUrl, { timeout, signal });
179
+
180
+ if (response.ok) {
181
+ oEmbedData = JSON.parse(response.content) as SpotifyOEmbedResponse;
182
+ notes.push("Retrieved metadata via Spotify oEmbed API");
183
+ } else {
184
+ notes.push(`oEmbed API returned status ${response.status || "error"}`);
185
+ }
186
+ } catch (err) {
187
+ notes.push(`Failed to fetch oEmbed data: ${err instanceof Error ? err.message : String(err)}`);
188
+ }
189
+
190
+ // Fetch page HTML for Open Graph metadata
191
+ try {
192
+ const pageResponse = await loadPage(url, { timeout, signal });
193
+
194
+ if (pageResponse.ok) {
195
+ ogData = parseOpenGraph(pageResponse.content);
196
+ notes.push("Parsed Open Graph metadata from page HTML");
197
+ } else {
198
+ notes.push(`Page fetch returned status ${pageResponse.status || "error"}`);
199
+ }
200
+ } catch (err) {
201
+ notes.push(`Failed to fetch page HTML: ${err instanceof Error ? err.message : String(err)}`);
202
+ }
203
+
204
+ // Format output
205
+ const output = formatOutput(contentType, oEmbedData, ogData, url);
206
+ const { content, truncated } = finalizeOutput(output);
207
+
208
+ return {
209
+ url,
210
+ finalUrl: url,
211
+ contentType: "text/markdown",
212
+ method: "spotify",
213
+ content,
214
+ fetchedAt: new Date().toISOString(),
215
+ truncated,
216
+ notes,
217
+ };
218
+ };