claude-world-studio 1.0.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 (46) hide show
  1. package/.env.example +30 -0
  2. package/.mcp.json +51 -0
  3. package/README.md +224 -0
  4. package/client/App.tsx +446 -0
  5. package/client/components/ChatWindow.tsx +790 -0
  6. package/client/components/FileExplorer.tsx +218 -0
  7. package/client/components/FilePreviewModal.tsx +179 -0
  8. package/client/components/PublishDialog.tsx +307 -0
  9. package/client/components/SettingsPage.tsx +452 -0
  10. package/client/components/Sidebar.tsx +198 -0
  11. package/client/components/ToolUseBlock.tsx +140 -0
  12. package/client/index.html +12 -0
  13. package/client/index.tsx +10 -0
  14. package/client/styles/globals.css +48 -0
  15. package/demo/01-welcome.png +0 -0
  16. package/demo/02-pipeline-cards.png +0 -0
  17. package/demo/03-custom-topic-fill.png +0 -0
  18. package/demo/04-topic-typed.png +0 -0
  19. package/demo/05-loading-state.png +0 -0
  20. package/demo/06-tool-calls.png +0 -0
  21. package/demo/07-history-rich.png +0 -0
  22. package/demo/09-en-cards.png +0 -0
  23. package/demo/10-ja-cards.png +0 -0
  24. package/demo/capture-remaining.mjs +73 -0
  25. package/demo/capture.mjs +110 -0
  26. package/demo/demo-walkthrough-2.webm +0 -0
  27. package/demo/demo-walkthrough.webm +0 -0
  28. package/package.json +48 -0
  29. package/postcss.config.js +6 -0
  30. package/scripts/threads_api.py +536 -0
  31. package/server/ai-client.ts +356 -0
  32. package/server/db.ts +299 -0
  33. package/server/mcp-config.ts +85 -0
  34. package/server/routes/accounts.ts +88 -0
  35. package/server/routes/files.ts +175 -0
  36. package/server/routes/publish.ts +77 -0
  37. package/server/routes/sessions.ts +59 -0
  38. package/server/routes/settings.ts +220 -0
  39. package/server/server.ts +261 -0
  40. package/server/services/social-publisher.ts +74 -0
  41. package/server/services/studio-mcp.ts +107 -0
  42. package/server/session.ts +167 -0
  43. package/server/types.ts +86 -0
  44. package/tailwind.config.js +8 -0
  45. package/tsconfig.json +16 -0
  46. package/vite.config.ts +19 -0
@@ -0,0 +1,85 @@
1
+ import { existsSync } from "fs";
2
+ import { isAbsolute } from "path";
3
+ import type { Settings, Language } from "./types.js";
4
+ import store from "./db.js";
5
+
6
+ /** Validate a path setting: must be absolute and exist on disk */
7
+ function isValidPath(p: string): boolean {
8
+ return !!p && isAbsolute(p) && existsSync(p);
9
+ }
10
+
11
+ export function getSettings(): Settings {
12
+ const all = store.getAllSettings();
13
+ return {
14
+ language: (all.language as Language) || "zh-TW",
15
+ trendPulseVenvPython:
16
+ all.trendPulseVenvPython || process.env.TREND_PULSE_PYTHON || "",
17
+ cfBrowserVenvPython:
18
+ all.cfBrowserVenvPython || process.env.CF_BROWSER_PYTHON || "",
19
+ notebooklmServerPath:
20
+ all.notebooklmServerPath || process.env.NOTEBOOKLM_SERVER_PATH || "",
21
+ cfBrowserUrl:
22
+ all.cfBrowserUrl || process.env.CF_BROWSER_URL || "",
23
+ cfBrowserApiKey:
24
+ all.cfBrowserApiKey || process.env.CF_BROWSER_API_KEY || "",
25
+ defaultWorkspace:
26
+ all.defaultWorkspace || process.env.DEFAULT_WORKSPACE || process.cwd(),
27
+ };
28
+ }
29
+
30
+ interface McpServerConfig {
31
+ name: string;
32
+ path: string;
33
+ build: () => Record<string, any>;
34
+ }
35
+
36
+ export function buildMcpServers(settings: Settings) {
37
+ const servers: Record<string, any> = {};
38
+
39
+ const configs: McpServerConfig[] = [
40
+ {
41
+ name: "trend-pulse",
42
+ path: settings.trendPulseVenvPython,
43
+ build: () => ({
44
+ command: settings.trendPulseVenvPython,
45
+ args: ["-m", "trend_pulse.server"],
46
+ env: {},
47
+ }),
48
+ },
49
+ {
50
+ name: "cf-browser",
51
+ path: settings.cfBrowserVenvPython,
52
+ build: () => ({
53
+ command: settings.cfBrowserVenvPython,
54
+ args: ["-m", "cf_browser_mcp.server"],
55
+ env: {
56
+ CF_BROWSER_URL: settings.cfBrowserUrl,
57
+ CF_BROWSER_API_KEY: settings.cfBrowserApiKey,
58
+ },
59
+ }),
60
+ },
61
+ {
62
+ name: "notebooklm",
63
+ path: settings.notebooklmServerPath,
64
+ build: () => ({
65
+ command: "python3",
66
+ args: [settings.notebooklmServerPath],
67
+ env: {},
68
+ }),
69
+ },
70
+ ];
71
+
72
+ for (const cfg of configs) {
73
+ if (!cfg.path) continue;
74
+
75
+ if (!isValidPath(cfg.path)) {
76
+ console.warn(`[MCP] ${cfg.name} skipped: path not found → ${cfg.path}`);
77
+ continue;
78
+ }
79
+
80
+ servers[cfg.name] = cfg.build();
81
+ console.log(`[MCP] ${cfg.name} enabled → ${cfg.path}`);
82
+ }
83
+
84
+ return servers;
85
+ }
@@ -0,0 +1,88 @@
1
+ import { Router } from "express";
2
+ import store from "../db.js";
3
+
4
+ const VALID_PLATFORMS = ["threads", "instagram"] as const;
5
+
6
+ function maskToken(token: string): string {
7
+ if (!token || token.length < 12) return token ? "***" : "";
8
+ return token.slice(0, 8) + "..." + token.slice(-4);
9
+ }
10
+
11
+ const router = Router();
12
+
13
+ // List all accounts (tokens masked)
14
+ router.get("/", (_req, res) => {
15
+ const accounts = store.getAllAccounts().map((a) => ({
16
+ ...a,
17
+ token: maskToken(a.token),
18
+ }));
19
+ res.json(accounts);
20
+ });
21
+
22
+ // Get single account (token masked)
23
+ router.get("/:id", (req, res) => {
24
+ const account = store.getAccount(req.params.id);
25
+ if (!account) {
26
+ return res.status(404).json({ error: "Account not found" });
27
+ }
28
+ res.json({ ...account, token: maskToken(account.token) });
29
+ });
30
+
31
+ // Create account
32
+ router.post("/", (req, res) => {
33
+ const { name, handle, platform, token, user_id, style, persona_prompt } = req.body || {};
34
+
35
+ if (!name || !handle || !platform) {
36
+ return res.status(400).json({ error: "name, handle, and platform are required" });
37
+ }
38
+ if (!VALID_PLATFORMS.includes(platform)) {
39
+ return res.status(400).json({ error: `platform must be one of: ${VALID_PLATFORMS.join(", ")}` });
40
+ }
41
+
42
+ const account = store.createAccount({
43
+ name, handle, platform, token, user_id, style, persona_prompt,
44
+ });
45
+ res.status(201).json({ ...account, token: maskToken(account.token) });
46
+ });
47
+
48
+ // Update account
49
+ router.put("/:id", (req, res) => {
50
+ const existing = store.getAccount(req.params.id);
51
+ if (!existing) {
52
+ return res.status(404).json({ error: "Account not found" });
53
+ }
54
+
55
+ const { name, handle, platform, token, user_id, style, persona_prompt } = req.body || {};
56
+
57
+ if (platform && !VALID_PLATFORMS.includes(platform)) {
58
+ return res.status(400).json({ error: `platform must be one of: ${VALID_PLATFORMS.join(", ")}` });
59
+ }
60
+
61
+ store.updateAccount(req.params.id, {
62
+ name: name ?? existing.name,
63
+ handle: handle ?? existing.handle,
64
+ platform: platform ?? existing.platform,
65
+ user_id: user_id ?? existing.user_id,
66
+ style: style ?? existing.style,
67
+ persona_prompt: persona_prompt ?? existing.persona_prompt,
68
+ });
69
+
70
+ // Only update token if a non-empty value is provided
71
+ if (token) {
72
+ store.updateAccountToken(req.params.id, token);
73
+ }
74
+
75
+ const updated = store.getAccount(req.params.id)!;
76
+ res.json({ ...updated, token: maskToken(updated.token) });
77
+ });
78
+
79
+ // Delete account
80
+ router.delete("/:id", (req, res) => {
81
+ const deleted = store.deleteAccount(req.params.id);
82
+ if (!deleted) {
83
+ return res.status(404).json({ error: "Account not found" });
84
+ }
85
+ res.json({ success: true });
86
+ });
87
+
88
+ export default router;
@@ -0,0 +1,175 @@
1
+ import { Router } from "express";
2
+ import fs from "fs";
3
+ import fsp from "fs/promises";
4
+ import path from "path";
5
+ import store from "../db.js";
6
+
7
+ const MAX_DEPTH = 5;
8
+ const MAX_TEXT_BYTES = 100 * 1024;
9
+
10
+ /**
11
+ * Check that `target` is strictly within `base` directory.
12
+ * Both paths must exist on disk so realpathSync can resolve symlinks.
13
+ * Returns false if either path doesn't exist (deny by default).
14
+ */
15
+ function isWithinWorkspace(base: string, target: string): boolean {
16
+ try {
17
+ const resolvedBase = fs.realpathSync(base);
18
+ const resolvedTarget = fs.realpathSync(target);
19
+ return (
20
+ resolvedTarget === resolvedBase ||
21
+ resolvedTarget.startsWith(resolvedBase + path.sep)
22
+ );
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ const router = Router();
29
+
30
+ interface FileEntry {
31
+ name: string;
32
+ path: string;
33
+ type: "file" | "directory";
34
+ size?: number;
35
+ modified?: string;
36
+ children?: FileEntry[];
37
+ }
38
+
39
+ async function buildFileTree(
40
+ dirPath: string,
41
+ basePath: string,
42
+ depth = 0,
43
+ maxDepth = 3
44
+ ): Promise<FileEntry[]> {
45
+ if (depth >= maxDepth) return [];
46
+
47
+ try {
48
+ const entries = await fsp.readdir(dirPath, { withFileTypes: true });
49
+ const result: FileEntry[] = [];
50
+
51
+ for (const entry of entries) {
52
+ // Skip hidden files and common non-useful dirs
53
+ if (
54
+ entry.name.startsWith(".") ||
55
+ entry.name === "node_modules" ||
56
+ entry.name === "__pycache__"
57
+ ) {
58
+ continue;
59
+ }
60
+
61
+ const fullPath = path.join(dirPath, entry.name);
62
+ const relativePath = path.relative(basePath, fullPath);
63
+
64
+ if (entry.isDirectory()) {
65
+ result.push({
66
+ name: entry.name,
67
+ path: relativePath,
68
+ type: "directory",
69
+ children: await buildFileTree(fullPath, basePath, depth + 1, maxDepth),
70
+ });
71
+ } else {
72
+ try {
73
+ const stat = await fsp.stat(fullPath);
74
+ result.push({
75
+ name: entry.name,
76
+ path: relativePath,
77
+ type: "file",
78
+ size: stat.size,
79
+ modified: stat.mtime.toISOString(),
80
+ });
81
+ } catch {
82
+ // Skip files we can't stat (broken symlinks, permission issues)
83
+ }
84
+ }
85
+ }
86
+
87
+ // Sort: directories first, then alphabetical
88
+ return result.sort((a, b) => {
89
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
90
+ return a.name.localeCompare(b.name);
91
+ });
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ // List workspace file tree
98
+ router.get("/:sessionId/files", async (req, res) => {
99
+ const session = store.getSession(req.params.sessionId);
100
+ if (!session) {
101
+ return res.status(404).json({ error: "Session not found" });
102
+ }
103
+
104
+ const rawDepth = parseInt(req.query.depth as string) || 3;
105
+ const depth = Math.min(Math.max(rawDepth, 1), MAX_DEPTH);
106
+ const tree = await buildFileTree(session.workspace_path, session.workspace_path, 0, depth);
107
+
108
+ res.json({
109
+ workspace: session.workspace_path,
110
+ tree,
111
+ });
112
+ });
113
+
114
+ // Read file content
115
+ router.get("/:sessionId/files/*", async (req, res) => {
116
+ const session = store.getSession(req.params.sessionId);
117
+ if (!session) {
118
+ return res.status(404).json({ error: "Session not found" });
119
+ }
120
+
121
+ // Extract the file path from the wildcard params
122
+ const filePath = (req.params as Record<string, string>)[0] as string | undefined;
123
+ if (!filePath) {
124
+ return res.status(400).json({ error: "File path required" });
125
+ }
126
+
127
+ const requestedPath = path.resolve(session.workspace_path, filePath);
128
+
129
+ try {
130
+ // Resolve symlinks FIRST, then use the resolved path for all ops (eliminates TOCTOU)
131
+ const resolvedPath = await fsp.realpath(requestedPath);
132
+ const resolvedBase = await fsp.realpath(session.workspace_path);
133
+
134
+ // Security: verify resolved path is within workspace
135
+ if (resolvedPath !== resolvedBase && !resolvedPath.startsWith(resolvedBase + path.sep)) {
136
+ return res.status(403).json({ error: "Path traversal not allowed" });
137
+ }
138
+
139
+ const stat = await fsp.stat(resolvedPath);
140
+ if (stat.isDirectory()) {
141
+ return res.status(400).json({ error: "Path is a directory" });
142
+ }
143
+
144
+ // For binary files, send raw; for text, send JSON
145
+ const ext = path.extname(resolvedPath).toLowerCase();
146
+ const binaryExts = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".pdf", ".mp3", ".mp4", ".wav", ".m4a", ".webm", ".ogg"];
147
+
148
+ if (binaryExts.includes(ext)) {
149
+ res.sendFile(resolvedPath);
150
+ } else {
151
+ // Text file - limit by byte size, then decode
152
+ if (stat.size > MAX_TEXT_BYTES) {
153
+ const fh = await fsp.open(resolvedPath, "r");
154
+ try {
155
+ const buf = Buffer.alloc(MAX_TEXT_BYTES);
156
+ await fh.read(buf, 0, MAX_TEXT_BYTES, 0);
157
+ const content = buf.toString("utf-8");
158
+ res.json({ path: filePath, content, truncated: true, size: stat.size });
159
+ } finally {
160
+ await fh.close();
161
+ }
162
+ } else {
163
+ const content = await fsp.readFile(resolvedPath, "utf-8");
164
+ res.json({ path: filePath, content, truncated: false, size: stat.size });
165
+ }
166
+ }
167
+ } catch (err) {
168
+ const code = (err as NodeJS.ErrnoException).code;
169
+ if (code === "ENOENT") return res.status(404).json({ error: "File not found" });
170
+ if (code === "EACCES") return res.status(403).json({ error: "Permission denied" });
171
+ return res.status(500).json({ error: "Failed to read file" });
172
+ }
173
+ });
174
+
175
+ export default router;
@@ -0,0 +1,77 @@
1
+ import { Router } from "express";
2
+ import store from "../db.js";
3
+ import { publishToThreads } from "../services/social-publisher.js";
4
+
5
+ const MAX_HISTORY_LIMIT = 500;
6
+
7
+ const router = Router();
8
+
9
+ // Publish content to a specific account
10
+ router.post("/", async (req, res) => {
11
+ const { accountId, text, sessionId, score, imageUrl, pollOptions, linkComment, tag } = req.body;
12
+
13
+ if (!accountId || !text) {
14
+ return res.status(400).json({ error: "accountId and text are required" });
15
+ }
16
+
17
+ const account = store.getAccount(accountId);
18
+ if (!account) {
19
+ return res.status(400).json({ error: `Account not found: ${accountId}` });
20
+ }
21
+ if (!account.token) {
22
+ return res.status(400).json({ error: `No token configured for account: ${account.name}` });
23
+ }
24
+
25
+ const record = store.addPublish({
26
+ session_id: sessionId || null,
27
+ platform: account.platform,
28
+ account: accountId,
29
+ content: text,
30
+ post_id: null,
31
+ post_url: null,
32
+ status: "pending",
33
+ });
34
+
35
+ try {
36
+ let result: any;
37
+
38
+ if (account.platform === "threads") {
39
+ result = await publishToThreads({
40
+ text,
41
+ token: account.token,
42
+ score,
43
+ imageUrl,
44
+ pollOptions,
45
+ linkComment,
46
+ tag,
47
+ });
48
+ } else {
49
+ throw new Error(`Publishing to ${account.platform} is not yet supported`);
50
+ }
51
+
52
+ store.updatePublishStatus(record.id, "published", result?.id, result?.permalink);
53
+
54
+ res.json({
55
+ success: true,
56
+ id: record.id,
57
+ postId: result?.id,
58
+ postUrl: result?.permalink,
59
+ });
60
+ } catch (error) {
61
+ store.updatePublishStatus(record.id, "failed");
62
+ res.status(500).json({
63
+ error: (error as Error).message,
64
+ id: record.id,
65
+ });
66
+ }
67
+ });
68
+
69
+ // Get publish history
70
+ router.get("/history", (req, res) => {
71
+ const rawLimit = parseInt(req.query.limit as string) || 50;
72
+ const limit = Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT);
73
+ const history = store.getPublishHistory(limit);
74
+ res.json(history);
75
+ });
76
+
77
+ export default router;
@@ -0,0 +1,59 @@
1
+ import { Router } from "express";
2
+ import store from "../db.js";
3
+ import { removeSession } from "../server.js";
4
+
5
+ const router = Router();
6
+
7
+ // List all sessions
8
+ router.get("/", (_req, res) => {
9
+ const sessions = store.getAllSessions();
10
+ res.json(sessions);
11
+ });
12
+
13
+ // Create new session
14
+ router.post("/", (req, res) => {
15
+ const { title, workspacePath } = req.body || {};
16
+ const session = store.createSession(title, workspacePath);
17
+ res.status(201).json(session);
18
+ });
19
+
20
+ // Get single session
21
+ router.get("/:id", (req, res) => {
22
+ const session = store.getSession(req.params.id);
23
+ if (!session) {
24
+ return res.status(404).json({ error: "Session not found" });
25
+ }
26
+ res.json(session);
27
+ });
28
+
29
+ // Update session title
30
+ router.patch("/:id", (req, res) => {
31
+ const { title } = req.body || {};
32
+ if (!title || typeof title !== "string") {
33
+ return res.status(400).json({ error: "Title required" });
34
+ }
35
+ const session = store.getSession(req.params.id);
36
+ if (!session) {
37
+ return res.status(404).json({ error: "Session not found" });
38
+ }
39
+ store.updateSessionTitle(req.params.id, title.slice(0, 200));
40
+ res.json({ success: true });
41
+ });
42
+
43
+ // Delete session (also clean up in-memory agent session)
44
+ router.delete("/:id", (req, res) => {
45
+ removeSession(req.params.id);
46
+ const deleted = store.deleteSession(req.params.id);
47
+ if (!deleted) {
48
+ return res.status(404).json({ error: "Session not found" });
49
+ }
50
+ res.json({ success: true });
51
+ });
52
+
53
+ // Get session messages
54
+ router.get("/:id/messages", (req, res) => {
55
+ const messages = store.getMessages(req.params.id);
56
+ res.json(messages);
57
+ });
58
+
59
+ export default router;
@@ -0,0 +1,220 @@
1
+ import { Router } from "express";
2
+ import { existsSync, readFileSync, readdirSync } from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import store from "../db.js";
6
+ import { getSettings } from "../mcp-config.js";
7
+
8
+ const { join, isAbsolute } = path;
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ const router = Router();
13
+
14
+ const SENSITIVE_KEYS = ["cfBrowserApiKey"] as const;
15
+
16
+ function maskValue(value: string): string {
17
+ if (!value || value.length < 12) return value ? "***" : "";
18
+ return value.slice(0, 8) + "..." + value.slice(-4);
19
+ }
20
+
21
+ // Get all settings (tokens masked)
22
+ router.get("/", (_req, res) => {
23
+ const settings = getSettings();
24
+ const masked: Record<string, string> = { ...settings };
25
+
26
+ for (const key of SENSITIVE_KEYS) {
27
+ if (masked[key]) {
28
+ masked[key] = maskValue(masked[key]);
29
+ }
30
+ }
31
+
32
+ res.json(masked);
33
+ });
34
+
35
+ // Auto-detect installed MCP tools
36
+ router.get("/detect", (_req, res) => {
37
+ const home = process.env.HOME || "";
38
+ const workspace =
39
+ getSettings().defaultWorkspace ||
40
+ process.env.DEFAULT_WORKSPACE ||
41
+ process.cwd();
42
+
43
+ const searchRoots = [
44
+ workspace,
45
+ join(home, "github"),
46
+ join(home, "projects"),
47
+ join(home, "dev"),
48
+ join(home, "code"),
49
+ ].filter((p) => isAbsolute(p) && existsSync(p));
50
+
51
+ // trend-pulse
52
+ const tpCandidates = searchRoots.flatMap((root) => [
53
+ join(root, "trend-pulse/.venv/bin/python"),
54
+ join(root, "trend-pulse/.venv/bin/python3"),
55
+ ]);
56
+ const trendPulsePython = tpCandidates.find(existsSync) || "";
57
+
58
+ // cf-browser
59
+ const cbCandidates = searchRoots.flatMap((root) => [
60
+ join(root, "cf-browser/mcp-server/.venv/bin/python"),
61
+ join(root, "cf-browser/mcp-server/.venv/bin/python3"),
62
+ ]);
63
+ const cfBrowserPython = cbCandidates.find(existsSync) || "";
64
+
65
+ // notebooklm-skill
66
+ const nlmCandidates = [
67
+ ...searchRoots.flatMap((root) => [
68
+ join(root, "notebooklm-skill/mcp-server/server.py"),
69
+ join(root, "notebooklm-skill/mcp_server.py"),
70
+ ]),
71
+ ...searchRoots.flatMap((root) => {
72
+ try {
73
+ return readdirSync(root, { withFileTypes: true })
74
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
75
+ .flatMap((e) => [
76
+ join(root, e.name, "notebooklm-skill/mcp-server/server.py"),
77
+ ]);
78
+ } catch { return []; }
79
+ }),
80
+ ];
81
+ const notebooklmPath = nlmCandidates.find(existsSync) || "";
82
+
83
+ // cf-browser URL/Key
84
+ let cfBrowserUrl = "";
85
+ let cfBrowserApiKeyFound = false;
86
+ const cfEnvCandidates = [
87
+ ...searchRoots.flatMap((root) => [
88
+ join(root, "cf-browser/.env"),
89
+ join(root, "cf-browser/worker/.env"),
90
+ ]),
91
+ join(__dirname, "../../.env"),
92
+ ];
93
+ for (const envPath of cfEnvCandidates) {
94
+ if (existsSync(envPath)) {
95
+ try {
96
+ const content = readFileSync(envPath, "utf-8");
97
+ const urlMatch = content.match(/^CF_BROWSER_URL=(.+)/m);
98
+ const keyMatch = content.match(/^CF_BROWSER_API_KEY=(.+)/m);
99
+ if (urlMatch && !cfBrowserUrl) cfBrowserUrl = urlMatch[1].trim();
100
+ if (keyMatch && !cfBrowserApiKeyFound) cfBrowserApiKeyFound = true;
101
+ } catch {}
102
+ }
103
+ }
104
+
105
+ const detected: Record<string, { value: string; found: boolean }> = {
106
+ trendPulseVenvPython: { value: trendPulsePython, found: !!trendPulsePython },
107
+ cfBrowserVenvPython: { value: cfBrowserPython, found: !!cfBrowserPython },
108
+ notebooklmServerPath: { value: notebooklmPath, found: !!notebooklmPath },
109
+ cfBrowserUrl: { value: cfBrowserUrl, found: !!cfBrowserUrl },
110
+ cfBrowserApiKey: { value: "", found: cfBrowserApiKeyFound },
111
+ defaultWorkspace: { value: workspace, found: existsSync(workspace) },
112
+ };
113
+
114
+ res.json(detected);
115
+ });
116
+
117
+ // Apply detected values to DB
118
+ router.post("/detect/apply", (_req, res) => {
119
+ const home = process.env.HOME || "";
120
+ const workspace =
121
+ getSettings().defaultWorkspace ||
122
+ process.env.DEFAULT_WORKSPACE ||
123
+ process.cwd();
124
+
125
+ let applied = 0;
126
+
127
+ // cf-browser .env
128
+ const searchRoots = [workspace, join(home, "github")]
129
+ .filter((p) => isAbsolute(p) && existsSync(p));
130
+
131
+ const cfEnvCandidates = [
132
+ ...searchRoots.flatMap((root) => [
133
+ join(root, "cf-browser/.env"),
134
+ join(root, "cf-browser/worker/.env"),
135
+ ]),
136
+ join(__dirname, "../../.env"),
137
+ ];
138
+ for (const envPath of cfEnvCandidates) {
139
+ if (existsSync(envPath)) {
140
+ try {
141
+ const content = readFileSync(envPath, "utf-8");
142
+ const urlMatch = content.match(/^CF_BROWSER_URL=(.+)/m);
143
+ const keyMatch = content.match(/^CF_BROWSER_API_KEY=(.+)/m);
144
+ if (urlMatch) { store.setSetting("cfBrowserUrl", urlMatch[1].trim()); applied++; }
145
+ if (keyMatch) { store.setSetting("cfBrowserApiKey", keyMatch[1].trim()); applied++; }
146
+ } catch (err) {
147
+ console.warn("[Settings] Error reading cf-browser .env:", err);
148
+ }
149
+ }
150
+ }
151
+
152
+ // MCP paths
153
+ const mcpRoots = [workspace, join(home, "github"), join(home, "projects")]
154
+ .filter((p) => isAbsolute(p) && existsSync(p));
155
+
156
+ const tpPath = mcpRoots.flatMap((r) => [
157
+ join(r, "trend-pulse/.venv/bin/python"),
158
+ join(r, "trend-pulse/.venv/bin/python3"),
159
+ ]).find(existsSync);
160
+ if (tpPath) { store.setSetting("trendPulseVenvPython", tpPath); applied++; }
161
+
162
+ const cbPath = mcpRoots.flatMap((r) => [
163
+ join(r, "cf-browser/mcp-server/.venv/bin/python"),
164
+ join(r, "cf-browser/mcp-server/.venv/bin/python3"),
165
+ ]).find(existsSync);
166
+ if (cbPath) { store.setSetting("cfBrowserVenvPython", cbPath); applied++; }
167
+
168
+ const nlmPath = [
169
+ ...mcpRoots.flatMap((r) => [join(r, "notebooklm-skill/mcp-server/server.py")]),
170
+ ...mcpRoots.flatMap((r) => {
171
+ try {
172
+ return readdirSync(r, { withFileTypes: true })
173
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
174
+ .map((e) => join(r, e.name, "notebooklm-skill/mcp-server/server.py"));
175
+ } catch { return []; }
176
+ }),
177
+ ].find(existsSync);
178
+ if (nlmPath) { store.setSetting("notebooklmServerPath", nlmPath); applied++; }
179
+
180
+ res.json({ success: true, applied });
181
+ });
182
+
183
+ // Update settings
184
+ router.put("/", (req, res) => {
185
+ const updates = req.body;
186
+ if (!updates || typeof updates !== "object") {
187
+ return res.status(400).json({ error: "Invalid settings" });
188
+ }
189
+
190
+ const allowedKeys = [
191
+ "language",
192
+ "trendPulseVenvPython",
193
+ "cfBrowserVenvPython",
194
+ "notebooklmServerPath",
195
+ "cfBrowserUrl",
196
+ "cfBrowserApiKey",
197
+ "defaultWorkspace",
198
+ ];
199
+
200
+ const pathKeys = [
201
+ "trendPulseVenvPython",
202
+ "cfBrowserVenvPython",
203
+ "notebooklmServerPath",
204
+ "defaultWorkspace",
205
+ ];
206
+
207
+ for (const [key, value] of Object.entries(updates)) {
208
+ if (!allowedKeys.includes(key) || typeof value !== "string") continue;
209
+
210
+ if (pathKeys.includes(key) && value) {
211
+ if (!isAbsolute(value) || /[;&|`$(){}]/.test(value)) continue;
212
+ }
213
+
214
+ store.setSetting(key, value);
215
+ }
216
+
217
+ res.json({ success: true });
218
+ });
219
+
220
+ export default router;