@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +4 -4
  3. package/src/core/custom-commands/bundled/wt/index.ts +3 -0
  4. package/src/core/sdk.ts +7 -0
  5. package/src/core/tools/complete.ts +129 -0
  6. package/src/core/tools/index.test.ts +9 -1
  7. package/src/core/tools/index.ts +18 -5
  8. package/src/core/tools/jtd-to-json-schema.ts +252 -0
  9. package/src/core/tools/output.ts +125 -14
  10. package/src/core/tools/read.ts +4 -4
  11. package/src/core/tools/task/artifacts.ts +6 -9
  12. package/src/core/tools/task/executor.ts +189 -24
  13. package/src/core/tools/task/index.ts +23 -18
  14. package/src/core/tools/task/name-generator.ts +1577 -0
  15. package/src/core/tools/task/render.ts +137 -8
  16. package/src/core/tools/task/types.ts +26 -5
  17. package/src/core/tools/task/worker-protocol.ts +1 -0
  18. package/src/core/tools/task/worker.ts +136 -14
  19. package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
  20. package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
  21. package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
  22. package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
  23. package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
  24. package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
  25. package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
  26. package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
  27. package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
  28. package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
  29. package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
  30. package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
  31. package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
  32. package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
  33. package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
  34. package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
  35. package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
  36. package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
  37. package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
  38. package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
  39. package/src/core/tools/web-fetch-handlers/github.ts +424 -0
  40. package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
  41. package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
  42. package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
  43. package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
  44. package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
  45. package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
  46. package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
  47. package/src/core/tools/web-fetch-handlers/index.ts +69 -0
  48. package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
  49. package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
  50. package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
  51. package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
  52. package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
  53. package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
  54. package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
  55. package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
  56. package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
  57. package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
  58. package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
  59. package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
  60. package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
  61. package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
  62. package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
  63. package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
  64. package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
  65. package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
  66. package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
  67. package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
  68. package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
  69. package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
  70. package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
  71. package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
  72. package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
  73. package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
  74. package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
  75. package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
  76. package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
  77. package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
  78. package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
  79. package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
  80. package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
  81. package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
  82. package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
  83. package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
  84. package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
  85. package/src/core/tools/web-fetch-handlers/types.ts +163 -0
  86. package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
  87. package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
  88. package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
  89. package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
  90. package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
  91. package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
  92. package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
  93. package/src/core/tools/web-fetch.ts +152 -1324
  94. package/src/prompts/task.md +14 -50
  95. package/src/prompts/tools/output.md +2 -1
  96. package/src/prompts/tools/task.md +3 -1
  97. package/src/utils/tools-manager.ts +110 -8
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleBluesky } from "./bluesky";
3
+ import { handleMastodon } from "./mastodon";
4
+
5
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
6
+
7
+ describe.skipIf(SKIP)("handleMastodon", () => {
8
+ it("returns null for non-Mastodon URLs", async () => {
9
+ const result = await handleMastodon("https://example.com", 20);
10
+ expect(result).toBeNull();
11
+ });
12
+
13
+ it("returns null for URLs without @user pattern", async () => {
14
+ const result = await handleMastodon("https://mastodon.social/about", 20);
15
+ expect(result).toBeNull();
16
+ });
17
+
18
+ it(
19
+ "fetches a Mastodon profile",
20
+ async () => {
21
+ // @Gargron is Eugen Rochko, creator of Mastodon - very stable
22
+ const result = await handleMastodon("https://mastodon.social/@Gargron", 20);
23
+ expect(result).not.toBeNull();
24
+ expect(result?.method).toBe("mastodon");
25
+ expect(result?.contentType).toBe("text/markdown");
26
+ expect(result?.content).toContain("Gargron");
27
+ expect(result?.content).toContain("@Gargron");
28
+ expect(result?.content).toContain("**Followers:**");
29
+ expect(result?.content).toContain("**Following:**");
30
+ expect(result?.content).toContain("**Posts:**");
31
+ expect(result?.fetchedAt).toBeTruthy();
32
+ expect(result?.truncated).toBeDefined();
33
+ expect(result?.notes?.[0]).toContain("Mastodon API");
34
+ },
35
+ { timeout: 30000 },
36
+ );
37
+
38
+ it(
39
+ "fetches a Mastodon post",
40
+ async () => {
41
+ // Gargron's post ID 1 - the first ever Mastodon post
42
+ const result = await handleMastodon("https://mastodon.social/@Gargron/1", 20);
43
+ // Post 1 may not exist anymore; check gracefully
44
+ if (result !== null) {
45
+ expect(result.method).toBe("mastodon");
46
+ expect(result.contentType).toBe("text/markdown");
47
+ expect(result.content).toContain("Post by");
48
+ expect(result.content).toContain("@Gargron");
49
+ expect(result.fetchedAt).toBeTruthy();
50
+ expect(result.truncated).toBeDefined();
51
+ expect(result.notes?.[0]).toContain("Mastodon API");
52
+ }
53
+ },
54
+ { timeout: 30000 },
55
+ );
56
+
57
+ it(
58
+ "handles a stable pinned post",
59
+ async () => {
60
+ // Use a well-known post from mastodon.social - Gargron's announcement post
61
+ const result = await handleMastodon("https://mastodon.social/@Gargron/109318821117356215", 20);
62
+ // May not exist, check gracefully
63
+ if (result !== null) {
64
+ expect(result.method).toBe("mastodon");
65
+ expect(result.contentType).toBe("text/markdown");
66
+ expect(result.content).toContain("@Gargron");
67
+ expect(result.content).toContain("replies");
68
+ expect(result.content).toContain("boosts");
69
+ expect(result.content).toContain("favorites");
70
+ expect(result.fetchedAt).toBeTruthy();
71
+ }
72
+ },
73
+ { timeout: 30000 },
74
+ );
75
+
76
+ it(
77
+ "includes recent posts in profile",
78
+ async () => {
79
+ const result = await handleMastodon("https://mastodon.social/@Gargron", 20);
80
+ expect(result).not.toBeNull();
81
+ // May include recent posts section
82
+ if (result?.content?.includes("## Recent Posts")) {
83
+ expect(result.content).toMatch(/###\s+\w+/); // Date header
84
+ }
85
+ },
86
+ { timeout: 30000 },
87
+ );
88
+
89
+ it("returns null for non-Mastodon instance with @user pattern", async () => {
90
+ // A site that has @user pattern but isn't Mastodon
91
+ const result = await handleMastodon("https://twitter.com/@jack", 20);
92
+ expect(result).toBeNull();
93
+ });
94
+ });
95
+
96
+ describe.skipIf(SKIP)("handleBluesky", () => {
97
+ it("returns null for non-Bluesky URLs", async () => {
98
+ const result = await handleBluesky("https://example.com", 20);
99
+ expect(result).toBeNull();
100
+ });
101
+
102
+ it("returns null for bsky.app URLs without profile path", async () => {
103
+ const result = await handleBluesky("https://bsky.app/about", 20);
104
+ expect(result).toBeNull();
105
+ });
106
+
107
+ it(
108
+ "fetches a Bluesky profile",
109
+ async () => {
110
+ // bsky.app official account - stable
111
+ const result = await handleBluesky("https://bsky.app/profile/bsky.app", 20);
112
+ expect(result).not.toBeNull();
113
+ expect(result?.method).toBe("bluesky-api");
114
+ expect(result?.contentType).toBe("text/markdown");
115
+ expect(result?.content).toContain("bsky.app");
116
+ expect(result?.content).toContain("@bsky.app");
117
+ expect(result?.content).toContain("**Followers:**");
118
+ expect(result?.content).toContain("**Following:**");
119
+ expect(result?.content).toContain("**Posts:**");
120
+ expect(result?.content).toContain("**DID:**");
121
+ expect(result?.fetchedAt).toBeTruthy();
122
+ expect(result?.truncated).toBeDefined();
123
+ expect(result?.notes).toContain("Fetched via AT Protocol API");
124
+ },
125
+ { timeout: 30000 },
126
+ );
127
+
128
+ it(
129
+ "fetches Jay Graber's profile",
130
+ async () => {
131
+ // Jay Graber - CEO of Bluesky, very stable
132
+ const result = await handleBluesky("https://bsky.app/profile/jay.bsky.team", 20);
133
+ expect(result).not.toBeNull();
134
+ expect(result?.method).toBe("bluesky-api");
135
+ expect(result?.contentType).toBe("text/markdown");
136
+ expect(result?.content).toContain("@jay.bsky.team");
137
+ expect(result?.content).toContain("**Followers:**");
138
+ expect(result?.fetchedAt).toBeTruthy();
139
+ expect(result?.truncated).toBeDefined();
140
+ },
141
+ { timeout: 30000 },
142
+ );
143
+
144
+ it(
145
+ "fetches a Bluesky post",
146
+ async () => {
147
+ // A post from bsky.app - use a well-known stable post
148
+ const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
149
+ // Post may not exist, check gracefully
150
+ if (result !== null) {
151
+ expect(result.method).toBe("bluesky-api");
152
+ expect(result.contentType).toBe("text/markdown");
153
+ expect(result.content).toContain("# Bluesky Post");
154
+ expect(result.content).toContain("@bsky.app");
155
+ expect(result.fetchedAt).toBeTruthy();
156
+ expect(result.truncated).toBeDefined();
157
+ expect(result.notes?.[0]).toContain("AT URI");
158
+ }
159
+ },
160
+ { timeout: 30000 },
161
+ );
162
+
163
+ it(
164
+ "includes post stats",
165
+ async () => {
166
+ const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
167
+ // Stats include likes, reposts, replies
168
+ if (result?.content) {
169
+ // Should have some engagement markers
170
+ const hasStats =
171
+ result.content.includes("❤️") || result.content.includes("🔁") || result.content.includes("💬");
172
+ expect(hasStats || result.content.includes("# Bluesky Post")).toBe(true);
173
+ }
174
+ },
175
+ { timeout: 30000 },
176
+ );
177
+
178
+ it(
179
+ "handles www.bsky.app URLs",
180
+ async () => {
181
+ const result = await handleBluesky("https://www.bsky.app/profile/bsky.app", 20);
182
+ expect(result).not.toBeNull();
183
+ expect(result?.method).toBe("bluesky-api");
184
+ },
185
+ { timeout: 30000 },
186
+ );
187
+
188
+ it("returns null for invalid profile handle", async () => {
189
+ const result = await handleBluesky("https://bsky.app/profile/", 20);
190
+ expect(result).toBeNull();
191
+ });
192
+ });
@@ -0,0 +1,259 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleReddit } from "./reddit";
3
+ import { handleStackOverflow } from "./stackoverflow";
4
+ import { handleTwitter } from "./twitter";
5
+
6
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
7
+
8
+ describe.skipIf(SKIP)("handleTwitter", () => {
9
+ it("returns null for non-Twitter URLs", async () => {
10
+ const result = await handleTwitter("https://example.com", 10);
11
+ expect(result).toBeNull();
12
+ });
13
+
14
+ it(
15
+ "handles twitter.com status URLs",
16
+ async () => {
17
+ const result = await handleTwitter("https://twitter.com/jack/status/20", 10000);
18
+ expect(result).not.toBeNull();
19
+ expect(result?.method).toMatch(/^twitter/);
20
+ expect(result?.contentType).toMatch(/^text\/(markdown|plain)$/);
21
+ // Either successful fetch or blocked/unavailable message
22
+ if (result?.method === "twitter-nitter") {
23
+ expect(result?.content).toContain("Tweet by");
24
+ expect(result?.notes?.[0]).toContain("Via Nitter");
25
+ } else if (result?.method === "twitter-blocked") {
26
+ expect(result?.content).toContain("blocks automated access");
27
+ expect(result?.notes?.[0]).toContain("Nitter instances unavailable");
28
+ }
29
+ },
30
+ { timeout: 30000 },
31
+ );
32
+
33
+ it(
34
+ "handles x.com status URLs",
35
+ async () => {
36
+ const result = await handleTwitter("https://x.com/elonmusk/status/1", 10000);
37
+ expect(result).not.toBeNull();
38
+ expect(result?.method).toMatch(/^twitter/);
39
+ expect(result?.contentType).toMatch(/^text\/(markdown|plain)$/);
40
+ // Either successful fetch or blocked/unavailable message
41
+ if (result?.method === "twitter-nitter") {
42
+ expect(result?.finalUrl).toContain("nitter");
43
+ } else if (result?.method === "twitter-blocked") {
44
+ expect(result?.content).toContain("blocks automated access");
45
+ }
46
+ },
47
+ { timeout: 30000 },
48
+ );
49
+
50
+ it(
51
+ "handles www.twitter.com URLs",
52
+ async () => {
53
+ const result = await handleTwitter("https://www.twitter.com/twitter/status/1", 10000);
54
+ expect(result).not.toBeNull();
55
+ expect(result?.method).toMatch(/^twitter/);
56
+ },
57
+ { timeout: 30000 },
58
+ );
59
+
60
+ it(
61
+ "handles www.x.com URLs",
62
+ async () => {
63
+ const result = await handleTwitter("https://www.x.com/twitter/status/1", 10000);
64
+ expect(result).not.toBeNull();
65
+ expect(result?.method).toMatch(/^twitter/);
66
+ },
67
+ { timeout: 30000 },
68
+ );
69
+
70
+ it(
71
+ "may fail due to Nitter availability",
72
+ async () => {
73
+ // Test that failure returns helpful message instead of null
74
+ const result = await handleTwitter("https://twitter.com/nonexistent/status/999999999999999999", 10000);
75
+ expect(result).not.toBeNull();
76
+ // Should return blocked message when Nitter fails
77
+ if (result?.method === "twitter-blocked") {
78
+ expect(result?.content).toContain("Nitter instances were unavailable");
79
+ expect(result?.content).toContain("Try:");
80
+ }
81
+ },
82
+ { timeout: 30000 },
83
+ );
84
+ });
85
+
86
+ describe.skipIf(SKIP)("handleReddit", () => {
87
+ it("returns null for non-Reddit URLs", async () => {
88
+ const result = await handleReddit("https://example.com", 10);
89
+ expect(result).toBeNull();
90
+ });
91
+
92
+ it("fetches subreddit", async () => {
93
+ const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
94
+ expect(result).not.toBeNull();
95
+ expect(result?.method).toBe("reddit");
96
+ expect(result?.contentType).toBe("text/markdown");
97
+ expect(result?.content).toContain("# r/programming");
98
+ expect(result?.content).toMatch(/\*\*.*\*\*/); // Contains bold formatting
99
+ expect(result?.notes).toContain("Fetched via Reddit JSON API");
100
+ });
101
+
102
+ it("fetches individual post", async () => {
103
+ // Use a more reliable recent post URL
104
+ const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
105
+ // Individual post may fail if post doesn't exist, check if we get data
106
+ if (result !== null) {
107
+ expect(result.method).toBe("reddit");
108
+ expect(result.contentType).toBe("text/markdown");
109
+ expect(result.content).toContain("# r/");
110
+ expect(result.notes).toContain("Fetched via Reddit JSON API");
111
+ }
112
+ });
113
+
114
+ it("includes comments in post when available", async () => {
115
+ const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
116
+ // Comments test - just verify structure if post with comments is found
117
+ if (result?.content?.includes("## Top Comments")) {
118
+ expect(result.content).toContain("### u/");
119
+ expect(result.content).toContain("points");
120
+ }
121
+ });
122
+
123
+ it("handles old.reddit.com", async () => {
124
+ const result = await handleReddit("https://old.reddit.com/r/programming/", 20000);
125
+ expect(result).not.toBeNull();
126
+ expect(result?.method).toBe("reddit");
127
+ expect(result?.contentType).toBe("text/markdown");
128
+ expect(result?.content).toContain("# r/");
129
+ expect(result?.notes).toContain("Fetched via Reddit JSON API");
130
+ });
131
+
132
+ it("handles reddit.com without www", async () => {
133
+ const result = await handleReddit("https://reddit.com/r/programming/", 20000);
134
+ expect(result).not.toBeNull();
135
+ expect(result?.method).toBe("reddit");
136
+ });
137
+
138
+ it("handles URLs with query parameters", async () => {
139
+ const result = await handleReddit("https://www.reddit.com/r/programming/?sort=top", 20000);
140
+ expect(result).not.toBeNull();
141
+ expect(result?.method).toBe("reddit");
142
+ expect(result?.content).toContain("# r/");
143
+ });
144
+
145
+ it("returns null for malformed Reddit URLs", async () => {
146
+ const result = await handleReddit("https://www.reddit.com/invalid", 20000);
147
+ // May return null or empty result
148
+ if (result !== null) {
149
+ expect(result.content).toBeDefined();
150
+ }
151
+ });
152
+ });
153
+
154
+ describe.skipIf(SKIP)("handleStackOverflow", () => {
155
+ it("returns null for non-SO URLs", async () => {
156
+ const result = await handleStackOverflow("https://example.com", 10);
157
+ expect(result).toBeNull();
158
+ });
159
+
160
+ it("returns null for SO URLs without question ID", async () => {
161
+ const result = await handleStackOverflow("https://stackoverflow.com/", 10);
162
+ expect(result).toBeNull();
163
+ });
164
+
165
+ it("fetches a known question", async () => {
166
+ // Use a well-known question that definitely exists
167
+ const result = await handleStackOverflow(
168
+ "https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
169
+ 20000,
170
+ );
171
+ // API may fail or rate limit, check gracefully
172
+ if (result !== null) {
173
+ expect(result.method).toBe("stackoverflow");
174
+ expect(result.contentType).toBe("text/markdown");
175
+ expect(result.content).toContain("# ");
176
+ expect(result.content).toContain("**Score:");
177
+ expect(result.content).toContain("**Tags:");
178
+ expect(result.content).toContain("## Question");
179
+ expect(result.notes).toContain("Fetched via Stack Exchange API");
180
+ }
181
+ });
182
+
183
+ it("includes answers", async () => {
184
+ const result = await handleStackOverflow(
185
+ "https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
186
+ 20000,
187
+ );
188
+ if (result?.content?.includes("## Answers")) {
189
+ expect(result.content).toContain("### Score:");
190
+ }
191
+ });
192
+
193
+ it("shows accepted answer marker when present", async () => {
194
+ const result = await handleStackOverflow(
195
+ "https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
196
+ 20000,
197
+ );
198
+ // Some questions may have accepted answers
199
+ if (result?.content?.includes("(Accepted)")) {
200
+ expect(result.content).toContain("## Answers");
201
+ }
202
+ });
203
+
204
+ it("handles stackoverflow.com", async () => {
205
+ const result = await handleStackOverflow(
206
+ "https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array",
207
+ 20000,
208
+ );
209
+ expect(result).not.toBeNull();
210
+ expect(result?.method).toBe("stackoverflow");
211
+ expect(result?.content).toContain("# ");
212
+ expect(result?.content).toContain("## Question");
213
+ });
214
+
215
+ it("handles other StackExchange sites", async () => {
216
+ const result = await handleStackOverflow("https://math.stackexchange.com/questions/1000/", 20000);
217
+ // API may fail, check gracefully
218
+ if (result !== null) {
219
+ expect(result.method).toBe("stackoverflow");
220
+ expect(result.contentType).toBe("text/markdown");
221
+ expect(result.content).toContain("# ");
222
+ expect(result.notes).toContain("Fetched via Stack Exchange API");
223
+ }
224
+ });
225
+
226
+ it("extracts question ID from URL", async () => {
227
+ const result = await handleStackOverflow(
228
+ "https://stackoverflow.com/questions/1234567/some-long-question-title",
229
+ 20000,
230
+ );
231
+ // Should attempt to fetch, may or may not exist
232
+ // Either returns valid result or null
233
+ if (result !== null) {
234
+ expect(result.method).toBe("stackoverflow");
235
+ }
236
+ });
237
+
238
+ it("handles URLs without trailing slash", async () => {
239
+ const result = await handleStackOverflow("https://stackoverflow.com/questions/11227809", 20000);
240
+ // API may fail, check gracefully
241
+ if (result !== null) {
242
+ expect(result.method).toBe("stackoverflow");
243
+ }
244
+ });
245
+
246
+ it("includes question metadata", async () => {
247
+ const result = await handleStackOverflow(
248
+ "https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
249
+ 20000,
250
+ );
251
+ // API may fail, check gracefully
252
+ if (result !== null) {
253
+ expect(result.content).toContain("**Score:");
254
+ expect(result.content).toContain("**Answers:");
255
+ expect(result.content).toContain("**Tags:");
256
+ expect(result.content).toContain("**Asked by:");
257
+ }
258
+ });
259
+ });
@@ -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) => {
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 });
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 });
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
+ };