@clubnet/seedclub 0.2.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/LICENSE +22 -0
- package/README.md +246 -0
- package/assets/extensions/seedclub/api-client.ts +102 -0
- package/assets/extensions/seedclub/auth.ts +89 -0
- package/assets/extensions/seedclub/commands/add.ts +601 -0
- package/assets/extensions/seedclub/commands/seedclub.ts +67 -0
- package/assets/extensions/seedclub/commands/signals.ts +86 -0
- package/assets/extensions/seedclub/commands/sort.ts +91 -0
- package/assets/extensions/seedclub/dia-cookies.ts +126 -0
- package/assets/extensions/seedclub/index.ts +166 -0
- package/assets/extensions/seedclub/package-lock.json +65 -0
- package/assets/extensions/seedclub/package.json +11 -0
- package/assets/extensions/seedclub/tool-utils.ts +32 -0
- package/assets/extensions/seedclub/tools/signals.ts +275 -0
- package/assets/extensions/seedclub/tools/utility.ts +31 -0
- package/assets/extensions/seedclub/twitter-client.ts +277 -0
- package/assets/extensions/seedclub-ui/editor.ts +93 -0
- package/assets/extensions/seedclub-ui/index.ts +15 -0
- package/assets/extensions/seedclub-ui/update.ts +73 -0
- package/assets/extensions/seedclub-ui/welcome.ts +250 -0
- package/assets/theme/seedclub.json +86 -0
- package/bin/cli.js +195 -0
- package/package.json +30 -0
- package/postinstall.js +175 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /add — add signals from URLs, @handles, or your Twitter activity.
|
|
3
|
+
*
|
|
4
|
+
* Every signal is a URL. Content on the internet.
|
|
5
|
+
*
|
|
6
|
+
* URLs/@handles → created instantly
|
|
7
|
+
* "my bookmarks" → each bookmarked tweet is a signal (the tweet URL)
|
|
8
|
+
* "my likes" → each liked tweet is a signal (the tweet URL)
|
|
9
|
+
* "who I follow" → each account is a signal (the profile URL)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
14
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { api } from "../api-client.js";
|
|
16
|
+
import { getApiBase } from "../auth.js";
|
|
17
|
+
import { createSignal, importSignals } from "../tools/signals.js";
|
|
18
|
+
import { checkTwitterCredentials, getTwitterClient } from "../twitter-client.js";
|
|
19
|
+
|
|
20
|
+
interface Signal {
|
|
21
|
+
type: string;
|
|
22
|
+
name: string;
|
|
23
|
+
externalUrl: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ────────────────────────────────────────
|
|
29
|
+
// URL/handle parsing
|
|
30
|
+
// ────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function tryParseUrls(raw: string): Signal[] | null {
|
|
33
|
+
const tokens = raw
|
|
34
|
+
.split(/[,\n]+/)
|
|
35
|
+
.flatMap((s) => s.trim().split(/\s+/))
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
|
|
38
|
+
const results: Signal[] = [];
|
|
39
|
+
for (const token of tokens) {
|
|
40
|
+
const signal = classifyUrl(token);
|
|
41
|
+
if (!signal) return null; // one bad token = not a URL list
|
|
42
|
+
results.push(signal);
|
|
43
|
+
}
|
|
44
|
+
return results.length > 0 ? results : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function classifyUrl(s: string): Signal | null {
|
|
48
|
+
s = s.trim();
|
|
49
|
+
|
|
50
|
+
// @handle
|
|
51
|
+
if (s.startsWith("@")) {
|
|
52
|
+
const handle = s.slice(1).replace(/\/$/, "");
|
|
53
|
+
return { type: "twitter_account", name: handle, externalUrl: `https://x.com/${handle}`, metadata: { handle } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// x.com / twitter.com tweet URLs (must check before profile)
|
|
57
|
+
const tweet = s.match(/(?:x\.com|twitter\.com)\/([a-zA-Z0-9_]+)\/status\/(\d+)/);
|
|
58
|
+
if (tweet) {
|
|
59
|
+
const url = `https://x.com/${tweet[1]}/status/${tweet[2]}`;
|
|
60
|
+
return {
|
|
61
|
+
type: "twitter_account",
|
|
62
|
+
name: `Tweet by @${tweet[1]}`,
|
|
63
|
+
externalUrl: url,
|
|
64
|
+
metadata: { handle: tweet[1], tweetId: tweet[2] },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// x.com / twitter.com profile URLs
|
|
69
|
+
const tw = s.match(/(?:x\.com|twitter\.com)\/([a-zA-Z0-9_]+)\/?(?:\?.*)?$/);
|
|
70
|
+
if (tw) {
|
|
71
|
+
return {
|
|
72
|
+
type: "twitter_account",
|
|
73
|
+
name: tw[1],
|
|
74
|
+
externalUrl: `https://x.com/${tw[1]}`,
|
|
75
|
+
metadata: { handle: tw[1] },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// GitHub
|
|
80
|
+
const gh = s.match(/github\.com\/([a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_.-]+)?)/);
|
|
81
|
+
if (gh) return { type: "github_profile", name: gh[1], externalUrl: `https://github.com/${gh[1]}` };
|
|
82
|
+
|
|
83
|
+
// Substack
|
|
84
|
+
const ss = s.match(/([a-zA-Z0-9-]+)\.substack\.com/);
|
|
85
|
+
if (ss)
|
|
86
|
+
return {
|
|
87
|
+
type: "newsletter",
|
|
88
|
+
name: ss[1].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
89
|
+
externalUrl: s.startsWith("http") ? s : `https://${s}`,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Reddit
|
|
93
|
+
const rd = s.match(/r\/([a-zA-Z0-9_]+)/);
|
|
94
|
+
if (rd) return { type: "subreddit", name: `r/${rd[1]}`, externalUrl: `https://reddit.com/r/${rd[1]}` };
|
|
95
|
+
|
|
96
|
+
// Any other URL
|
|
97
|
+
if (s.includes("://") || s.match(/^[a-zA-Z0-9-]+\.[a-z]{2,}/)) {
|
|
98
|
+
const url = s.startsWith("http") ? s : `https://${s}`;
|
|
99
|
+
const domain = url
|
|
100
|
+
.replace(/https?:\/\//, "")
|
|
101
|
+
.replace(/\/.*/, "")
|
|
102
|
+
.replace(/^www\./, "");
|
|
103
|
+
const name = domain
|
|
104
|
+
.split(".")[0]
|
|
105
|
+
.replace(/-/g, " ")
|
|
106
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
107
|
+
return { type: "blog", name, externalUrl: url };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ────────────────────────────────────────
|
|
114
|
+
// Twitter query parsing
|
|
115
|
+
// ────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
interface TwitterQuery {
|
|
118
|
+
action: "likes" | "bookmarks" | "following" | "search";
|
|
119
|
+
count?: number;
|
|
120
|
+
query?: string;
|
|
121
|
+
since?: Date;
|
|
122
|
+
author?: string; // filter by @username
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const MONTHS = [
|
|
126
|
+
"january",
|
|
127
|
+
"february",
|
|
128
|
+
"march",
|
|
129
|
+
"april",
|
|
130
|
+
"may",
|
|
131
|
+
"june",
|
|
132
|
+
"july",
|
|
133
|
+
"august",
|
|
134
|
+
"september",
|
|
135
|
+
"october",
|
|
136
|
+
"november",
|
|
137
|
+
"december",
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const WORD_NUMBERS: Record<string, number> = {
|
|
141
|
+
one: 1,
|
|
142
|
+
two: 2,
|
|
143
|
+
three: 3,
|
|
144
|
+
four: 4,
|
|
145
|
+
five: 5,
|
|
146
|
+
six: 6,
|
|
147
|
+
seven: 7,
|
|
148
|
+
eight: 8,
|
|
149
|
+
nine: 9,
|
|
150
|
+
ten: 10,
|
|
151
|
+
eleven: 11,
|
|
152
|
+
twelve: 12,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function _parseNumber(s: string): number | undefined {
|
|
156
|
+
const digitMatch = s.match(/(\d+)/);
|
|
157
|
+
if (digitMatch) return parseInt(digitMatch[1], 10);
|
|
158
|
+
for (const [word, num] of Object.entries(WORD_NUMBERS)) {
|
|
159
|
+
if (s.includes(word)) return num;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseDateFilter(s: string): Date | undefined {
|
|
165
|
+
const now = new Date();
|
|
166
|
+
|
|
167
|
+
// "from 2024" / "from 2025" — just a year
|
|
168
|
+
const justYear = s.match(/(?:from|since|in)\s+(20\d{2})(?:\s|$)/);
|
|
169
|
+
if (justYear) return new Date(parseInt(justYear[1], 10), 0, 1);
|
|
170
|
+
|
|
171
|
+
// "from november 2025" / "since march"
|
|
172
|
+
for (let i = 0; i < MONTHS.length; i++) {
|
|
173
|
+
if (s.includes(MONTHS[i])) {
|
|
174
|
+
const yearMatch = s.match(/\b(20\d{2})\b/);
|
|
175
|
+
const year = yearMatch
|
|
176
|
+
? parseInt(yearMatch[1], 10)
|
|
177
|
+
: i > now.getMonth()
|
|
178
|
+
? now.getFullYear() - 1
|
|
179
|
+
: now.getFullYear();
|
|
180
|
+
return new Date(year, i, 1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// "last 3 months" / "last three months"
|
|
185
|
+
const monthsAgo = s.match(/last\s+(\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+months?/);
|
|
186
|
+
if (monthsAgo) {
|
|
187
|
+
const n = WORD_NUMBERS[monthsAgo[1]] || parseInt(monthsAgo[1], 10);
|
|
188
|
+
const d = new Date(now);
|
|
189
|
+
d.setMonth(d.getMonth() - n);
|
|
190
|
+
return d;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// "last 2 weeks" / "last two weeks"
|
|
194
|
+
const weeksAgo = s.match(/last\s+(\d+|one|two|three|four)\s+weeks?/);
|
|
195
|
+
if (weeksAgo) {
|
|
196
|
+
const n = WORD_NUMBERS[weeksAgo[1]] || parseInt(weeksAgo[1], 10);
|
|
197
|
+
return new Date(now.getTime() - n * 7 * 86400000);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (s.includes("last week")) return new Date(now.getTime() - 7 * 86400000);
|
|
201
|
+
if (s.includes("last month")) {
|
|
202
|
+
const d = new Date(now);
|
|
203
|
+
d.setMonth(d.getMonth() - 1);
|
|
204
|
+
return d;
|
|
205
|
+
}
|
|
206
|
+
if (s.includes("last year")) {
|
|
207
|
+
const d = new Date(now);
|
|
208
|
+
d.setFullYear(d.getFullYear() - 1);
|
|
209
|
+
return d;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseTwitterQuery(input: string): TwitterQuery | null {
|
|
215
|
+
const s = input.toLowerCase().trim();
|
|
216
|
+
|
|
217
|
+
// Likes
|
|
218
|
+
if (s.match(/likes?/)) {
|
|
219
|
+
const since = parseDateFilter(s);
|
|
220
|
+
const author = parseAuthorFilter(s);
|
|
221
|
+
// Only grab a number as count if it's NOT a year and NOT part of "N months/weeks"
|
|
222
|
+
const countMatch = s.match(/(?:last\s+)?(\d+)\s*(?:likes?|bookmarks?)/);
|
|
223
|
+
const count = since ? 200 : countMatch ? parseInt(countMatch[1], 10) : 20;
|
|
224
|
+
return { action: "likes", count, since, author };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Bookmarks
|
|
228
|
+
if (s.match(/bookmarks?/)) {
|
|
229
|
+
const since = parseDateFilter(s);
|
|
230
|
+
const author = parseAuthorFilter(s);
|
|
231
|
+
const countMatch = s.match(/(?:last\s+)?(\d+)\s*(?:likes?|bookmarks?)/);
|
|
232
|
+
const count = since ? 200 : countMatch ? parseInt(countMatch[1], 10) : 20;
|
|
233
|
+
return { action: "bookmarks", count, since, author };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Following
|
|
237
|
+
if (s.match(/who\s+i\s+follow|my\s+following|following/)) {
|
|
238
|
+
return { action: "following", count: 50 };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Search
|
|
242
|
+
const searchMatch = s.match(/(?:search|tweets?\s+about)\s+(.+)/);
|
|
243
|
+
if (searchMatch) {
|
|
244
|
+
return { action: "search", query: searchMatch[1], count: 20 };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseAuthorFilter(s: string): string | undefined {
|
|
251
|
+
const m = s.match(/(?:from|by)\s+@?([a-zA-Z0-9_]+)/);
|
|
252
|
+
if (
|
|
253
|
+
m &&
|
|
254
|
+
![
|
|
255
|
+
"last",
|
|
256
|
+
"the",
|
|
257
|
+
"my",
|
|
258
|
+
"november",
|
|
259
|
+
"december",
|
|
260
|
+
"january",
|
|
261
|
+
"february",
|
|
262
|
+
"march",
|
|
263
|
+
"april",
|
|
264
|
+
"may",
|
|
265
|
+
"june",
|
|
266
|
+
"july",
|
|
267
|
+
"august",
|
|
268
|
+
"september",
|
|
269
|
+
"october",
|
|
270
|
+
"2024",
|
|
271
|
+
"2025",
|
|
272
|
+
"2026",
|
|
273
|
+
].includes(m[1].toLowerCase())
|
|
274
|
+
) {
|
|
275
|
+
return m[1];
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ────────────────────────────────────────
|
|
281
|
+
// Tweet → Signal conversion
|
|
282
|
+
// ────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/** Each tweet becomes a signal — the URL is the tweet itself. */
|
|
285
|
+
function tweetsToSignals(tweets: any[]): Signal[] {
|
|
286
|
+
const seen = new Set<string>();
|
|
287
|
+
const signals: Signal[] = [];
|
|
288
|
+
|
|
289
|
+
for (const tweet of tweets) {
|
|
290
|
+
const username = tweet.author?.username || tweet.authorUsername;
|
|
291
|
+
const tweetId = tweet.id || tweet.tweetId;
|
|
292
|
+
if (!username || !tweetId) continue;
|
|
293
|
+
|
|
294
|
+
const url = `https://x.com/${username}/status/${tweetId}`;
|
|
295
|
+
if (seen.has(url)) continue;
|
|
296
|
+
seen.add(url);
|
|
297
|
+
|
|
298
|
+
const text = tweet.text || tweet.fullText || "";
|
|
299
|
+
|
|
300
|
+
signals.push({
|
|
301
|
+
type: "twitter_account",
|
|
302
|
+
name: `@${username}`,
|
|
303
|
+
externalUrl: url,
|
|
304
|
+
description: text || undefined,
|
|
305
|
+
metadata: {
|
|
306
|
+
handle: username,
|
|
307
|
+
tweetId,
|
|
308
|
+
authorName: tweet.author?.name || tweet.authorName,
|
|
309
|
+
createdAt: tweet.createdAt,
|
|
310
|
+
likeCount: tweet.likeCount,
|
|
311
|
+
retweetCount: tweet.retweetCount,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return signals;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Following/followers → account signals */
|
|
319
|
+
function usersToSignals(users: any[]): Signal[] {
|
|
320
|
+
return users.map((u: any) => ({
|
|
321
|
+
type: "twitter_account",
|
|
322
|
+
name: u.username || u.name,
|
|
323
|
+
externalUrl: `https://x.com/${u.username}`,
|
|
324
|
+
metadata: { handle: u.username },
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function filterTweets(tweets: any[], query: TwitterQuery): any[] {
|
|
329
|
+
let result = tweets;
|
|
330
|
+
if (query.since) result = result.filter((t: any) => new Date(t.createdAt) >= query.since!);
|
|
331
|
+
if (query.author) {
|
|
332
|
+
const a = query.author.toLowerCase();
|
|
333
|
+
result = result.filter((t: any) => (t.author?.username || t.authorUsername || "").toLowerCase() === a);
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function filterLabel(query: TwitterQuery): string {
|
|
339
|
+
const parts: string[] = [];
|
|
340
|
+
if (query.since) parts.push(`since ${query.since.toLocaleDateString()}`);
|
|
341
|
+
if (query.author) parts.push(`from @${query.author}`);
|
|
342
|
+
return parts.length ? ` (${parts.join(", ")})` : "";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ────────────────────────────────────────
|
|
346
|
+
// Twitter auth
|
|
347
|
+
// ────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
async function ensureTwitterAuth(pi: ExtensionAPI, ctx: any): Promise<boolean> {
|
|
350
|
+
let twitterConnected = false;
|
|
351
|
+
try {
|
|
352
|
+
const user = await api.get<any>("/user");
|
|
353
|
+
twitterConnected = !!user.user?.twitterConnected;
|
|
354
|
+
} catch {}
|
|
355
|
+
|
|
356
|
+
if (!twitterConnected) {
|
|
357
|
+
ctx.ui.notify("Connecting your Twitter account...", "info");
|
|
358
|
+
const connected = await connectTwitterViaBrowser(pi, ctx);
|
|
359
|
+
if (!connected) return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const check = await checkTwitterCredentials();
|
|
363
|
+
if (!check.valid) {
|
|
364
|
+
ctx.ui.notify("Log in to x.com in your default browser, then try again.", "info");
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function connectTwitterViaBrowser(pi: ExtensionAPI, ctx: any): Promise<boolean> {
|
|
372
|
+
const apiBase = getApiBase();
|
|
373
|
+
const port = await findAvailablePort();
|
|
374
|
+
const state = randomBytes(16).toString("hex");
|
|
375
|
+
const authUrl = `${apiBase}/auth/cli/twitter?port=${port}&state=${state}`;
|
|
376
|
+
|
|
377
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
378
|
+
pi.exec(openCmd, [authUrl]).catch(() => {
|
|
379
|
+
ctx.ui.notify(`Open this link to connect Twitter:\n${authUrl}`, "info");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
await waitForTwitterCallback(port, state);
|
|
384
|
+
ctx.ui.notify("Twitter connected.", "info");
|
|
385
|
+
return true;
|
|
386
|
+
} catch (err: any) {
|
|
387
|
+
if (err.message === "cancelled") {
|
|
388
|
+
ctx.ui.notify("Twitter connection cancelled.", "info");
|
|
389
|
+
} else {
|
|
390
|
+
ctx.ui.notify(err.message || "Twitter connection failed.", "error");
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function findAvailablePort(): Promise<number> {
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
const server = createServer();
|
|
399
|
+
server.listen(0, "127.0.0.1", () => {
|
|
400
|
+
const addr = server.address();
|
|
401
|
+
if (addr && typeof addr === "object") {
|
|
402
|
+
server.close(() => resolve(addr.port));
|
|
403
|
+
} else {
|
|
404
|
+
reject(new Error("Could not find available port"));
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
server.on("error", reject);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function waitForTwitterCallback(port: number, state: string): Promise<void> {
|
|
412
|
+
return new Promise((resolve, reject) => {
|
|
413
|
+
const timeout = setTimeout(() => {
|
|
414
|
+
server.close();
|
|
415
|
+
reject(new Error("Timed out."));
|
|
416
|
+
}, 300_000);
|
|
417
|
+
|
|
418
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
419
|
+
const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
|
|
420
|
+
if (url.pathname !== "/callback") {
|
|
421
|
+
res.writeHead(404);
|
|
422
|
+
res.end();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const done = (body: string) => {
|
|
427
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
428
|
+
res.end(body);
|
|
429
|
+
clearTimeout(timeout);
|
|
430
|
+
server.close();
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
if (url.searchParams.get("state") !== state) {
|
|
434
|
+
done("<h1>Invalid state</h1>");
|
|
435
|
+
reject(new Error("Invalid state"));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (url.searchParams.get("error")) {
|
|
439
|
+
done("<h1>Cancelled</h1><p>You can close this tab.</p>");
|
|
440
|
+
reject(new Error(url.searchParams.get("error")!));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
done("<h1>Twitter connected</h1><p>You can close this tab.</p>");
|
|
445
|
+
resolve();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
server.listen(port, "127.0.0.1");
|
|
449
|
+
server.on("error", (err) => {
|
|
450
|
+
clearTimeout(timeout);
|
|
451
|
+
reject(err);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ────────────────────────────────────────
|
|
457
|
+
// Create signals (batch or single)
|
|
458
|
+
// ────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
async function addSignals(signals: Signal[], ctx: any): Promise<void> {
|
|
461
|
+
if (signals.length === 0) {
|
|
462
|
+
ctx.ui.notify("Nothing to add.", "info");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// If signals have metadata/description, use individual creates to preserve it
|
|
467
|
+
const hasRichData = signals.some((s) => s.description || s.metadata);
|
|
468
|
+
|
|
469
|
+
if (!hasRichData && signals.length >= 3) {
|
|
470
|
+
// Plain URL import (fast batch)
|
|
471
|
+
const input = signals.map((s) => s.externalUrl).join("\n");
|
|
472
|
+
const result = await importSignals({ input });
|
|
473
|
+
if ("error" in result) {
|
|
474
|
+
ctx.ui.notify(result.error, "error");
|
|
475
|
+
} else {
|
|
476
|
+
ctx.ui.notify(`${result.created} added, ${result.skipped} skipped`, "info");
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Individual creates to preserve description + metadata
|
|
482
|
+
let created = 0;
|
|
483
|
+
let skipped = 0;
|
|
484
|
+
for (const signal of signals) {
|
|
485
|
+
const result = await createSignal(signal as any);
|
|
486
|
+
if ("error" in result) {
|
|
487
|
+
skipped++;
|
|
488
|
+
} else {
|
|
489
|
+
created++;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
ctx.ui.notify(`${created} added${skipped > 0 ? `, ${skipped} skipped` : ""}`, "info");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ────────────────────────────────────────
|
|
496
|
+
// Execute Twitter query
|
|
497
|
+
// ────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
async function executeTwitterQuery(query: TwitterQuery, ctx: any): Promise<void> {
|
|
500
|
+
const client = await getTwitterClient();
|
|
501
|
+
ctx.ui.notify(`Fetching ${query.action}...`, "info");
|
|
502
|
+
|
|
503
|
+
switch (query.action) {
|
|
504
|
+
case "likes": {
|
|
505
|
+
const r = await client.getLikes(query.count || 20);
|
|
506
|
+
if (!r.success) {
|
|
507
|
+
ctx.ui.notify(r.error || "Failed to fetch likes", "error");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const tweets = filterTweets(r.tweets || [], query);
|
|
511
|
+
ctx.ui.notify(`Found ${tweets.length} likes${filterLabel(query)}`, "info");
|
|
512
|
+
await addSignals(tweetsToSignals(tweets), ctx);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
case "bookmarks": {
|
|
517
|
+
const r = await client.getBookmarks(query.count || 20);
|
|
518
|
+
if (!r.success) {
|
|
519
|
+
ctx.ui.notify(r.error || "Failed to fetch bookmarks", "error");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const tweets = filterTweets(r.tweets || [], query);
|
|
523
|
+
ctx.ui.notify(`Found ${tweets.length} bookmarks${filterLabel(query)}`, "info");
|
|
524
|
+
await addSignals(tweetsToSignals(tweets), ctx);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
case "following": {
|
|
529
|
+
const me = await client.getCurrentUser();
|
|
530
|
+
if (!me.success || !me.user) {
|
|
531
|
+
ctx.ui.notify("Failed to get current user", "error");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const r = await client.getFollowing(me.user.id, query.count || 50);
|
|
535
|
+
if (!r.success) {
|
|
536
|
+
ctx.ui.notify(r.error || "Failed to fetch following", "error");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
await addSignals(usersToSignals(r.users || []), ctx);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
case "search": {
|
|
544
|
+
const r = await client.search(query.query!, query.count || 20);
|
|
545
|
+
if (!r.success) {
|
|
546
|
+
ctx.ui.notify(r.error || "Search failed", "error");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
await addSignals(tweetsToSignals(r.tweets || []), ctx);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ────────────────────────────────────────
|
|
556
|
+
// Registration
|
|
557
|
+
// ────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
export function registerAddInterceptor(pi: ExtensionAPI) {
|
|
560
|
+
pi.on("input", async (event, ctx) => {
|
|
561
|
+
if (!event.text.startsWith("/add ") && event.text !== "/add") {
|
|
562
|
+
return { action: "continue" as const };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const raw = event.text.slice(4).trim();
|
|
566
|
+
if (!raw) {
|
|
567
|
+
ctx.ui.notify(
|
|
568
|
+
"Usage:\n /add @handle\n /add https://example.com\n /add my bookmarks\n /add my last 10 likes\n /add who I follow",
|
|
569
|
+
"info",
|
|
570
|
+
);
|
|
571
|
+
return { action: "handled" as const };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// URLs and @handles — instant
|
|
575
|
+
const urlSignals = tryParseUrls(raw);
|
|
576
|
+
if (urlSignals) {
|
|
577
|
+
await addSignals(urlSignals, ctx);
|
|
578
|
+
return { action: "handled" as const };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Twitter natural language
|
|
582
|
+
const twitterQuery = parseTwitterQuery(raw);
|
|
583
|
+
if (twitterQuery) {
|
|
584
|
+
const authed = await ensureTwitterAuth(pi, ctx);
|
|
585
|
+
if (!authed) return { action: "handled" as const };
|
|
586
|
+
try {
|
|
587
|
+
await executeTwitterQuery(twitterQuery, ctx);
|
|
588
|
+
} catch (err: any) {
|
|
589
|
+
ctx.ui.notify(err.message || "Twitter request failed", "error");
|
|
590
|
+
}
|
|
591
|
+
return { action: "handled" as const };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Unknown
|
|
595
|
+
ctx.ui.notify(
|
|
596
|
+
"Not sure what that is. Try:\n /add @handle\n /add https://example.com\n /add my bookmarks\n /add my last 10 likes\n /add who I follow",
|
|
597
|
+
"info",
|
|
598
|
+
);
|
|
599
|
+
return { action: "handled" as const };
|
|
600
|
+
});
|
|
601
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /seedclub — the main menu.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { getStoredToken } from "../auth.js";
|
|
7
|
+
import { getUnsortedSignals } from "../tools/signals.js";
|
|
8
|
+
import { getCurrentUser } from "../tools/utility.js";
|
|
9
|
+
import { runSortFlow } from "./sort.js";
|
|
10
|
+
|
|
11
|
+
interface SeedclubDeps {
|
|
12
|
+
connect: (args: string | undefined, ctx: any) => Promise<void>;
|
|
13
|
+
disconnect: (ctx: any) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
|
|
17
|
+
pi.registerCommand("seedclub", {
|
|
18
|
+
description: "Seed Club",
|
|
19
|
+
handler: async (args, ctx) => {
|
|
20
|
+
const stored = await getStoredToken();
|
|
21
|
+
const hasEnvToken = !!process.env.SEEDCLUB_TOKEN || !!process.env.SEED_NETWORK_TOKEN;
|
|
22
|
+
const isConnected = !!stored || hasEnvToken;
|
|
23
|
+
|
|
24
|
+
if (!isConnected) {
|
|
25
|
+
return await deps.connect(args, ctx);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const user = await getCurrentUser();
|
|
29
|
+
if ("error" in user) {
|
|
30
|
+
ctx.ui.notify("Session expired. Reconnecting...", "info");
|
|
31
|
+
return await deps.connect(args, ctx);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Main menu
|
|
35
|
+
let unsortedCount = "?";
|
|
36
|
+
let unsortedResult: any = null;
|
|
37
|
+
try {
|
|
38
|
+
const result = await getUnsortedSignals();
|
|
39
|
+
if (!("error" in result)) {
|
|
40
|
+
unsortedResult = result;
|
|
41
|
+
unsortedCount = String(result.unsortedCount ?? result.unsorted?.length ?? 0);
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
const sortLabel =
|
|
46
|
+
unsortedCount === "0" ? "Sort signals (all sorted)" : `Sort signals (${unsortedCount} unsorted)`;
|
|
47
|
+
|
|
48
|
+
const choice = await ctx.ui.select(user.name, ["Add signals", sortLabel, "---", "Disconnect"]);
|
|
49
|
+
|
|
50
|
+
if (!choice || choice === "---") return;
|
|
51
|
+
|
|
52
|
+
if (choice.startsWith("Sort signals")) {
|
|
53
|
+
return await runSortFlow(pi, ctx, unsortedResult);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
switch (choice) {
|
|
57
|
+
case "Add signals":
|
|
58
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
59
|
+
ctx.ui.setEditorText("/add ");
|
|
60
|
+
break;
|
|
61
|
+
case "Disconnect":
|
|
62
|
+
await deps.disconnect(ctx);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|