@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /signals command — quick list/search without LLM.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { listSignals, searchSignals } from "../tools/signals.js";
|
|
7
|
+
|
|
8
|
+
const VALID_TYPES = [
|
|
9
|
+
"twitter_account",
|
|
10
|
+
"company",
|
|
11
|
+
"person",
|
|
12
|
+
"blog",
|
|
13
|
+
"github_profile",
|
|
14
|
+
"topic",
|
|
15
|
+
"newsletter",
|
|
16
|
+
"podcast",
|
|
17
|
+
"subreddit",
|
|
18
|
+
"custom",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const TYPE_ALIASES: Record<string, string> = {
|
|
22
|
+
twitter: "twitter_account",
|
|
23
|
+
x: "twitter_account",
|
|
24
|
+
gh: "github_profile",
|
|
25
|
+
github: "github_profile",
|
|
26
|
+
reddit: "subreddit",
|
|
27
|
+
sub: "subreddit",
|
|
28
|
+
pod: "podcast",
|
|
29
|
+
news: "newsletter",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function registerSignalsCommand(pi: ExtensionAPI) {
|
|
33
|
+
pi.registerCommand("signals", {
|
|
34
|
+
description: "List or search signals. Usage: /signals [type|search <query>]",
|
|
35
|
+
handler: async (args, ctx) => {
|
|
36
|
+
const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
37
|
+
const subcommand = parts[0]?.toLowerCase() ?? "";
|
|
38
|
+
|
|
39
|
+
// Search mode
|
|
40
|
+
if (subcommand === "search" || subcommand === "find" || subcommand === "s") {
|
|
41
|
+
const query = parts.slice(1).join(" ");
|
|
42
|
+
if (!query) {
|
|
43
|
+
ctx.ui.notify("Usage: /signals search <query>", "warning");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const result = await searchSignals({ query, limit: 20 });
|
|
47
|
+
if ("error" in result) {
|
|
48
|
+
ctx.ui.notify(String(result.error), "error");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!result.signals?.length) {
|
|
52
|
+
ctx.ui.notify(`No signals matching "${query}"`, "info");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const lines = result.signals.map((s: any) => {
|
|
56
|
+
return ` ${s.name} [${s.type}]${s.tags?.length ? ` ${s.tags.join(", ")}` : ""}`;
|
|
57
|
+
});
|
|
58
|
+
ctx.ui.notify(`Found ${result.total} signals:\n${lines.join("\n")}`, "info");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Type filter mode
|
|
63
|
+
const typeFilter =
|
|
64
|
+
TYPE_ALIASES[subcommand] || (subcommand && VALID_TYPES.includes(subcommand) ? subcommand : undefined);
|
|
65
|
+
|
|
66
|
+
const result = await listSignals({
|
|
67
|
+
type: typeFilter,
|
|
68
|
+
limit: 25,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if ("error" in result) {
|
|
72
|
+
ctx.ui.notify(String(result.error), "error");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!result.signals?.length) {
|
|
77
|
+
ctx.ui.notify(typeFilter ? `No ${typeFilter} signals` : "No signals yet. Use /add to create some.", "info");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const lines = result.signals.map((s: any) => ` ${s.name} [${s.type}]`);
|
|
82
|
+
const header = typeFilter ? `${result.total} ${typeFilter} signals:` : `${result.total} signals:`;
|
|
83
|
+
ctx.ui.notify(`${header}\n${lines.join("\n")}`, "info");
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /sort command — sort unsorted signals into buckets.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { getApiBase } from "../auth.js";
|
|
7
|
+
import { deleteUnsortedSignals, getUnsortedSignals } from "../tools/signals.js";
|
|
8
|
+
|
|
9
|
+
export async function runSortFlow(pi: ExtensionAPI, ctx: any, prefetchedResult?: any) {
|
|
10
|
+
let result = prefetchedResult;
|
|
11
|
+
|
|
12
|
+
if (!result) {
|
|
13
|
+
ctx.ui.notify("Checking for unsorted signals...", "info");
|
|
14
|
+
result = await getUnsortedSignals();
|
|
15
|
+
if ("error" in result) {
|
|
16
|
+
ctx.ui.notify(result.error, "error");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const count = result.unsortedCount ?? result.unsorted?.length ?? 0;
|
|
22
|
+
|
|
23
|
+
if (count === 0) {
|
|
24
|
+
ctx.ui.notify("All signals are sorted.", "info");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const choice = await ctx.ui.select(`${count} unsorted signal${count === 1 ? "" : "s"}`, [
|
|
29
|
+
"Sort automatically",
|
|
30
|
+
"Sort manually (browser)",
|
|
31
|
+
"Delete unsorted signals",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
if (!choice) return;
|
|
35
|
+
|
|
36
|
+
if (choice === "Sort automatically") {
|
|
37
|
+
return await autoSort(pi);
|
|
38
|
+
} else if (choice === "Sort manually (browser)") {
|
|
39
|
+
return await manualSort(pi, ctx);
|
|
40
|
+
} else if (choice === "Delete unsorted signals") {
|
|
41
|
+
const confirm = await ctx.ui.select(`Delete ${count} unsorted signals? This cannot be undone.`, [
|
|
42
|
+
"Yes, delete unsorted",
|
|
43
|
+
"Cancel",
|
|
44
|
+
]);
|
|
45
|
+
if (confirm !== "Yes, delete unsorted") return;
|
|
46
|
+
const del = await deleteUnsortedSignals();
|
|
47
|
+
if ("error" in del) {
|
|
48
|
+
ctx.ui.notify(del.error, "error");
|
|
49
|
+
} else {
|
|
50
|
+
ctx.ui.notify(`Deleted ${del.deleted} signals`, "info");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerSortCommand(pi: ExtensionAPI) {
|
|
56
|
+
pi.registerCommand("sort", {
|
|
57
|
+
description: "Sort unsorted signals into buckets",
|
|
58
|
+
handler: async (args, ctx) => {
|
|
59
|
+
const arg = args?.trim().toLowerCase();
|
|
60
|
+
|
|
61
|
+
if (arg === "auto" || arg === "a") {
|
|
62
|
+
return await autoSort(pi);
|
|
63
|
+
}
|
|
64
|
+
if (arg === "manual" || arg === "m" || arg === "browse") {
|
|
65
|
+
return await manualSort(pi, ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return await runSortFlow(pi, ctx);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function autoSort(pi: ExtensionAPI) {
|
|
74
|
+
pi.sendUserMessage(
|
|
75
|
+
"Sort my unsorted signals. Call leaf_get_unsorted_signals first. Then score signals in batches of 20 — for each batch, call leaf_submit_sort_scores immediately before moving to the next batch. Score each signal across all 10 buckets (0-1) based on its description/content. If the user has an angel.md, weight toward their thesis. Work fast, don't explain each score.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function manualSort(pi: ExtensionAPI, ctx: any) {
|
|
80
|
+
const apiBase = getApiBase();
|
|
81
|
+
const isDev = apiBase.includes("localhost") || apiBase.includes("127.0.0.1");
|
|
82
|
+
const url = isDev ? `${apiBase}/signals` : "https://beta.seedclub.com/signals";
|
|
83
|
+
|
|
84
|
+
ctx.ui.notify(`Opening signals page...\n${url}`, "info");
|
|
85
|
+
|
|
86
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
87
|
+
|
|
88
|
+
pi.exec(openCmd, [url]).catch(() => {
|
|
89
|
+
ctx.ui.notify(`Couldn't open browser. Visit:\n${url}`, "warning");
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read Twitter cookies directly from Dia browser's cookie database.
|
|
3
|
+
*
|
|
4
|
+
* Dia is Chromium-based (The Browser Company) but sweet-cookie doesn't
|
|
5
|
+
* know about it. We read the encrypted cookies and decrypt them using
|
|
6
|
+
* the macOS Keychain password, same as Chrome does.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { createDecipheriv, pbkdf2Sync } from "node:crypto";
|
|
11
|
+
import { copyFileSync, existsSync, mkdtempSync, unlinkSync } from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const DIA_COOKIES_DB = join(homedir(), "Library/Application Support/Dia/User Data/Default/Cookies");
|
|
16
|
+
|
|
17
|
+
export function isDiaAvailable(): boolean {
|
|
18
|
+
return process.platform === "darwin" && existsSync(DIA_COOKIES_DB);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getKeychainPassword(): string | null {
|
|
22
|
+
const attempts: string[][] = [
|
|
23
|
+
["find-generic-password", "-a", "Dia", "-w"],
|
|
24
|
+
["find-generic-password", "-s", "Dia Safe Storage", "-w"],
|
|
25
|
+
];
|
|
26
|
+
for (const args of attempts) {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync("security", args, {
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
}).trim();
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function decryptValue(encrypted: Buffer, key: Buffer): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const prefix = encrypted.subarray(0, 3).toString("ascii");
|
|
40
|
+
if (prefix !== "v10" && prefix !== "v11") {
|
|
41
|
+
return encrypted.toString("utf-8");
|
|
42
|
+
}
|
|
43
|
+
const iv = Buffer.alloc(16, " ");
|
|
44
|
+
const data = encrypted.subarray(3);
|
|
45
|
+
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
46
|
+
// Disable auto-padding so we can manually handle the output
|
|
47
|
+
decipher.setAutoPadding(false);
|
|
48
|
+
const result = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
49
|
+
|
|
50
|
+
// Remove PKCS7 padding
|
|
51
|
+
const padByte = result[result.length - 1];
|
|
52
|
+
const unpadded = padByte > 0 && padByte <= 16 ? result.subarray(0, result.length - padByte) : result;
|
|
53
|
+
|
|
54
|
+
// The first block (16 bytes) may be garbled due to IV mismatch.
|
|
55
|
+
// Extract the longest trailing run of printable ASCII — that's the cookie value.
|
|
56
|
+
const raw = unpadded.toString("binary");
|
|
57
|
+
const match = raw.match(/[\x20-\x7e]+$/);
|
|
58
|
+
return match ? match[0] : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DiaCookieResult {
|
|
65
|
+
authToken: string;
|
|
66
|
+
ct0: string;
|
|
67
|
+
cookieHeader: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readDiaTwitterCookies(): DiaCookieResult | null {
|
|
71
|
+
if (!isDiaAvailable()) return null;
|
|
72
|
+
|
|
73
|
+
const password = getKeychainPassword();
|
|
74
|
+
if (!password) return null;
|
|
75
|
+
|
|
76
|
+
const key = pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
|
|
77
|
+
|
|
78
|
+
// Copy DB to avoid lock contention with the running browser
|
|
79
|
+
const tmp = join(mkdtempSync(join(tmpdir(), "dia-")), "Cookies");
|
|
80
|
+
try {
|
|
81
|
+
copyFileSync(DIA_COOKIES_DB, tmp);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
// Use sqlite3 CLI via stdin to avoid shell quoting issues
|
|
88
|
+
const sql = `SELECT hex(name), hex(encrypted_value) FROM cookies WHERE (host_key LIKE '%.x.com%' OR host_key LIKE '%.twitter.com%') AND name IN ('auth_token', 'ct0');`;
|
|
89
|
+
const raw = execFileSync("sqlite3", [tmp], {
|
|
90
|
+
encoding: "utf-8",
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
input: sql,
|
|
93
|
+
}).trim();
|
|
94
|
+
|
|
95
|
+
if (!raw) return null;
|
|
96
|
+
|
|
97
|
+
let authToken: string | null = null;
|
|
98
|
+
let ct0: string | null = null;
|
|
99
|
+
|
|
100
|
+
for (const line of raw.split("\n")) {
|
|
101
|
+
const [hexName, hexValue] = line.split("|");
|
|
102
|
+
if (!hexName || !hexValue) continue;
|
|
103
|
+
|
|
104
|
+
const name = Buffer.from(hexName, "hex").toString("utf-8");
|
|
105
|
+
const encrypted = Buffer.from(hexValue, "hex");
|
|
106
|
+
const value = decryptValue(encrypted, key);
|
|
107
|
+
|
|
108
|
+
if (name === "auth_token" && value) authToken = value;
|
|
109
|
+
if (name === "ct0" && value) ct0 = value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!authToken || !ct0) return null;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
authToken,
|
|
116
|
+
ct0,
|
|
117
|
+
cookieHeader: `auth_token=${authToken}; ct0=${ct0}`,
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(tmp);
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed Club core extension.
|
|
3
|
+
*
|
|
4
|
+
* Registers tools, commands, and auth for Seed Network.
|
|
5
|
+
* Loaded as a pi extension from ~/.seedclub/agent/extensions/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
10
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { clearCredentials, setCachedToken } from "./api-client.js";
|
|
12
|
+
import { getApiBase, getStoredToken, storeToken } from "./auth.js";
|
|
13
|
+
import { registerAddInterceptor } from "./commands/add.js";
|
|
14
|
+
import { registerSeedclubCommand } from "./commands/seedclub.js";
|
|
15
|
+
import { registerSortCommand } from "./commands/sort.js";
|
|
16
|
+
import { registerSignalTools } from "./tools/signals.js";
|
|
17
|
+
import { getCurrentUser, registerUtilityTools } from "./tools/utility.js";
|
|
18
|
+
|
|
19
|
+
export default function (pi: ExtensionAPI) {
|
|
20
|
+
// Tools (read-only signals + user info)
|
|
21
|
+
registerSignalTools(pi);
|
|
22
|
+
registerUtilityTools(pi);
|
|
23
|
+
|
|
24
|
+
// Commands — /seedclub menu, /add, /sort
|
|
25
|
+
registerSeedclubCommand(pi, { connect, disconnect });
|
|
26
|
+
registerAddInterceptor(pi);
|
|
27
|
+
registerSortCommand(pi);
|
|
28
|
+
|
|
29
|
+
// Show connection status on session start
|
|
30
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
31
|
+
const stored = await getStoredToken();
|
|
32
|
+
if (stored) {
|
|
33
|
+
const isDev = stored.apiBase?.includes("localhost") || stored.apiBase?.includes("127.0.0.1");
|
|
34
|
+
ctx.ui.setStatus("seed", `seed: ${stored.email}`);
|
|
35
|
+
if (isDev) ctx.ui.setStatus("seed-api", `dev: ${stored.apiBase}`);
|
|
36
|
+
} else if (process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN) {
|
|
37
|
+
ctx.ui.setStatus("seed", "seed: connected (env)");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// --- Auth handlers ---
|
|
42
|
+
|
|
43
|
+
async function connect(args: string | undefined, ctx: any) {
|
|
44
|
+
const token = args?.trim();
|
|
45
|
+
if (token) {
|
|
46
|
+
if (!token.startsWith("sn_")) {
|
|
47
|
+
ctx.ui.notify("Invalid token. Tokens start with sn_", "error");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await verifyAndStore(token, ctx);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const apiBase = getApiBase();
|
|
55
|
+
const port = await findAvailablePort();
|
|
56
|
+
const state = randomBytes(16).toString("hex");
|
|
57
|
+
const authUrl = `${apiBase}/auth/cli/authorize?port=${port}&state=${state}`;
|
|
58
|
+
|
|
59
|
+
ctx.ui.notify("Opening browser to sign in...", "info");
|
|
60
|
+
|
|
61
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
62
|
+
pi.exec(openCmd, [authUrl]).catch(() => {
|
|
63
|
+
ctx.ui.notify(`Open this link:\n${authUrl}`, "info");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = await waitForCallback(port, state);
|
|
68
|
+
await verifyAndStore(result.token, ctx, result.email);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
ctx.ui.notify(error instanceof Error ? error.message : "Auth failed", "error");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function disconnect(ctx: any) {
|
|
75
|
+
await clearCredentials();
|
|
76
|
+
ctx.ui.setStatus("seed", undefined);
|
|
77
|
+
ctx.ui.notify("Logged out", "info");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function verifyAndStore(token: string, ctx: any, emailHint?: string) {
|
|
81
|
+
const apiBase = getApiBase();
|
|
82
|
+
await storeToken(token, emailHint || "pending", apiBase);
|
|
83
|
+
setCachedToken(token, apiBase);
|
|
84
|
+
|
|
85
|
+
const result = await getCurrentUser();
|
|
86
|
+
if ("error" in result) {
|
|
87
|
+
await clearCredentials();
|
|
88
|
+
ctx.ui.notify(`Token verification failed: ${result.error}`, "error");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await storeToken(token, result.email, apiBase);
|
|
93
|
+
ctx.ui.notify(`Connected as ${result.email}`, "success");
|
|
94
|
+
ctx.ui.setStatus("seed", `seed: ${result.email}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Helpers ---
|
|
99
|
+
|
|
100
|
+
function findAvailablePort(): Promise<number> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const server = createServer();
|
|
103
|
+
server.listen(0, "127.0.0.1", () => {
|
|
104
|
+
const addr = server.address();
|
|
105
|
+
if (addr && typeof addr === "object") {
|
|
106
|
+
server.close(() => resolve(addr.port));
|
|
107
|
+
} else {
|
|
108
|
+
reject(new Error("Could not find available port"));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
server.on("error", reject);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function waitForCallback(port: number, state: string): Promise<{ token: string; email: string }> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const timeout = setTimeout(() => {
|
|
118
|
+
server.close();
|
|
119
|
+
reject(new Error("Timed out (5 min)."));
|
|
120
|
+
}, 300_000);
|
|
121
|
+
|
|
122
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
123
|
+
const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
|
|
124
|
+
if (url.pathname !== "/callback") {
|
|
125
|
+
res.writeHead(404);
|
|
126
|
+
res.end();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const done = (status: number, body: string) => {
|
|
131
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
132
|
+
res.end(body);
|
|
133
|
+
clearTimeout(timeout);
|
|
134
|
+
server.close();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (url.searchParams.get("state") !== state) {
|
|
138
|
+
done(400, "<h1>Invalid state</h1>");
|
|
139
|
+
reject(new Error("Invalid state"));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const error = url.searchParams.get("error");
|
|
143
|
+
if (error) {
|
|
144
|
+
done(400, `<h1>Failed</h1><p>${error}</p>`);
|
|
145
|
+
reject(new Error(error));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const token = url.searchParams.get("token");
|
|
149
|
+
if (!token?.startsWith("sn_")) {
|
|
150
|
+
done(400, "<h1>Invalid token</h1>");
|
|
151
|
+
reject(new Error("Invalid token"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const email = url.searchParams.get("email") || "unknown";
|
|
156
|
+
done(200, `<h1>Connected</h1><p>Signed in as ${email}. You can close this tab.</p>`);
|
|
157
|
+
resolve({ token, email });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
server.listen(port, "127.0.0.1");
|
|
161
|
+
server.on("error", (err) => {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
reject(err);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seedclub-extension",
|
|
3
|
+
"lockfileVersion": 3,
|
|
4
|
+
"requires": true,
|
|
5
|
+
"packages": {
|
|
6
|
+
"": {
|
|
7
|
+
"name": "seedclub-extension",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@connormartin/bird": "0.8.0"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"node_modules/@connormartin/bird": {
|
|
13
|
+
"version": "0.8.0",
|
|
14
|
+
"resolved": "https://registry.npmjs.org/@connormartin/bird/-/bird-0.8.0.tgz",
|
|
15
|
+
"integrity": "sha512-bFBn158ecVbx/EfFI+yMpSTHrCLp10/dpuuYNcbhrxroZOU9ehnguKp14pLPab6BagL0nDg46JJPOLD99I8KIw==",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@connormartin/sweet-cookie": "0.1.0",
|
|
18
|
+
"commander": "^14.0.2",
|
|
19
|
+
"json5": "^2.2.3",
|
|
20
|
+
"kleur": "^4.1.5"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"bird": "dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"node_modules/@connormartin/sweet-cookie": {
|
|
30
|
+
"version": "0.1.0",
|
|
31
|
+
"resolved": "https://registry.npmjs.org/@connormartin/sweet-cookie/-/sweet-cookie-0.1.0.tgz",
|
|
32
|
+
"integrity": "sha512-eHOrnuj9M4Th1yWFGvVDm5Pvdxwt0f91/k66cowh5+57t+A5cPWudyaDRQhTE6dPLj8sRanTKB3gYTrspyYuGA==",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"node_modules/commander": {
|
|
38
|
+
"version": "14.0.3",
|
|
39
|
+
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
|
40
|
+
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"node_modules/json5": {
|
|
46
|
+
"version": "2.2.3",
|
|
47
|
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
|
48
|
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
|
49
|
+
"bin": {
|
|
50
|
+
"json5": "lib/cli.js"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=6"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"node_modules/kleur": {
|
|
57
|
+
"version": "4.1.5",
|
|
58
|
+
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
|
59
|
+
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=6"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps a handler function into a tool execute function.
|
|
3
|
+
* Returns JSON results on success, clear error messages on failure.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NotConnectedError } from "./api-client.js";
|
|
7
|
+
|
|
8
|
+
export function wrapExecute(fn: (params: any) => Promise<any>) {
|
|
9
|
+
return async (_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
|
|
10
|
+
try {
|
|
11
|
+
const result = await fn(params);
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
14
|
+
details: result ?? {},
|
|
15
|
+
};
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error instanceof NotConnectedError) {
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text" as const, text: "Not connected to Seed Club. Run /seedclub to authenticate." }],
|
|
20
|
+
details: { notConnected: true },
|
|
21
|
+
isError: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
27
|
+
details: { error: message },
|
|
28
|
+
isError: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|