causly-server 0.0.0-stage → 1.5.1

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 (91) hide show
  1. package/.env.example +120 -0
  2. package/CHANGELOG.md +126 -0
  3. package/LICENSE +21 -0
  4. package/README.md +377 -3
  5. package/bin/causly.js +192 -0
  6. package/index.js +229 -0
  7. package/package.json +59 -6
  8. package/packs/calendar.js +141 -0
  9. package/packs/command.js +22 -0
  10. package/packs/database.js +95 -0
  11. package/packs/directory.js +63 -0
  12. package/packs/discord.js +63 -0
  13. package/packs/docker.js +202 -0
  14. package/packs/files.js +106 -0
  15. package/packs/git.js +211 -0
  16. package/packs/github.js +321 -0
  17. package/packs/gmail.js +98 -0
  18. package/packs/leads.js +46 -0
  19. package/packs/linear.js +104 -0
  20. package/packs/loader.js +247 -0
  21. package/packs/manifest.json +1152 -0
  22. package/packs/meetings.js +141 -0
  23. package/packs/notion.js +185 -0
  24. package/packs/pipeline.js +151 -0
  25. package/packs/project.js +70 -0
  26. package/packs/registry.js +149 -0
  27. package/packs/reminders.js +57 -0
  28. package/packs/research.js +34 -0
  29. package/packs/secrets.js +77 -0
  30. package/packs/sentry.js +97 -0
  31. package/packs/seo.js +70 -0
  32. package/packs/slack.js +95 -0
  33. package/packs/stripe.js +127 -0
  34. package/packs/supabase.js +73 -0
  35. package/packs/telegram.js +70 -0
  36. package/packs/terraform.js +247 -0
  37. package/packs/vercel.js +114 -0
  38. package/packs/workflow.js +84 -0
  39. package/prompts/cofounder.js +66 -0
  40. package/prompts/playbooks.js +75 -0
  41. package/tools/backupOps.js +87 -0
  42. package/tools/boundaryAttest.js +184 -0
  43. package/tools/calendarOps.js +563 -0
  44. package/tools/cliOps.js +233 -0
  45. package/tools/commandOps.js +198 -0
  46. package/tools/dbOps.js +117 -0
  47. package/tools/directoryOps.js +96 -0
  48. package/tools/discordOps.js +133 -0
  49. package/tools/dockerOps.js +259 -0
  50. package/tools/envFile.js +89 -0
  51. package/tools/envLoader.js +85 -0
  52. package/tools/fileOps.js +125 -0
  53. package/tools/gitOps.js +170 -0
  54. package/tools/githubOps.js +357 -0
  55. package/tools/gmailOps.js +221 -0
  56. package/tools/googleAuth.js +51 -0
  57. package/tools/googleLogin.js +230 -0
  58. package/tools/leadOps.js +447 -0
  59. package/tools/linearOps.js +304 -0
  60. package/tools/logger.js +47 -0
  61. package/tools/meetingOps.js +697 -0
  62. package/tools/notionOps.js +215 -0
  63. package/tools/paths.js +36 -0
  64. package/tools/pipelineOps.js +511 -0
  65. package/tools/projectOps.js +192 -0
  66. package/tools/reminderAlerts.js +48 -0
  67. package/tools/reminderOps.js +129 -0
  68. package/tools/scheduler.js +46 -0
  69. package/tools/secretsOps.js +133 -0
  70. package/tools/security.js +488 -0
  71. package/tools/sentryOps.js +130 -0
  72. package/tools/seoOps.js +561 -0
  73. package/tools/services.js +295 -0
  74. package/tools/setupOps.js +245 -0
  75. package/tools/slackOps.js +151 -0
  76. package/tools/statusOps.js +104 -0
  77. package/tools/store.js +111 -0
  78. package/tools/stripeOps.js +299 -0
  79. package/tools/supabaseOps.js +94 -0
  80. package/tools/telegramOps.js +153 -0
  81. package/tools/terminal.js +124 -0
  82. package/tools/terraformOps.js +171 -0
  83. package/tools/toolWrapper.js +60 -0
  84. package/tools/toolsetOps.js +123 -0
  85. package/tools/transcribeOps.js +221 -0
  86. package/tools/updateCheck.js +58 -0
  87. package/tools/vercelOps.js +198 -0
  88. package/tools/verifyBoundaryAttest.js +25 -0
  89. package/tools/webOps.js +474 -0
  90. package/tools/workflowOps.js +371 -0
  91. package/tools/workspace.js +85 -0
@@ -0,0 +1,233 @@
1
+ // Logic behind the `causly-server` command: init, remove, doctor.
2
+ // bin/causly.js is a thin wrapper so all of this can be tested with temp folders.
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { execFile } from "child_process";
7
+ import { PACKAGE_ROOT, causlyHome, envFiles } from "./paths.js";
8
+
9
+ export const SERVER_NAME = "causly-server";
10
+ export const CLIENTS = ["claude", "cursor", "windsurf"];
11
+
12
+ const CLIENT_LABEL = { claude: "Claude Desktop", cursor: "Cursor", windsurf: "Windsurf" };
13
+
14
+ /** Config file of each editor/app that speaks MCP. */
15
+ export function clientConfigPath(client, { platform = os.platform(), home = os.homedir(), env = process.env } = {}) {
16
+ switch (client) {
17
+ case "claude":
18
+ if (platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
19
+ if (platform === "darwin") return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
20
+ return path.join(home, ".config", "Claude", "claude_desktop_config.json");
21
+ case "cursor":
22
+ return path.join(home, ".cursor", "mcp.json");
23
+ case "windsurf":
24
+ return path.join(home, ".codeium", "windsurf", "mcp_config.json");
25
+ default:
26
+ throw new Error(`Unknown client "${client}". Use one of: ${CLIENTS.join(", ")}, all.`);
27
+ }
28
+ }
29
+
30
+ /** A client counts as installed when its config folder exists (Claude Desktop, Cursor, Windsurf create it on first run). */
31
+ export function detectClients(opts = {}) {
32
+ return CLIENTS.filter((c) => fs.existsSync(path.dirname(clientConfigPath(c, opts))));
33
+ }
34
+
35
+ /**
36
+ * What the client should run. From a global install or a git checkout that is
37
+ * `node <path>/index.js` with the absolute node path (GUI apps often lack node on PATH).
38
+ * From the temporary npx cache that path would disappear, so use `npx -y causly-server`.
39
+ */
40
+ export function serverEntry({ root = PACKAGE_ROOT, platform = os.platform(), execPath = process.execPath, workspace, toolsets } = {}) {
41
+ const fromNpxCache = root.split(/[\\/]/).includes("_npx");
42
+ const entry = fromNpxCache
43
+ ? platform === "win32"
44
+ ? { command: "cmd", args: ["/c", "npx", "-y", SERVER_NAME] }
45
+ : { command: "npx", args: ["-y", SERVER_NAME] }
46
+ : { command: execPath, args: [path.join(root, "index.js")] };
47
+ const env = {};
48
+ if (workspace) env.CAUSLY_WORKSPACE = path.resolve(workspace);
49
+ if (toolsets) env.CAUSLY_TOOLSETS = toolsets;
50
+ if (Object.keys(env).length) entry.env = env;
51
+ return entry;
52
+ }
53
+
54
+ export function readJson(file) {
55
+ if (!fs.existsSync(file)) return { config: {}, existed: false, broken: false };
56
+ const raw = fs.readFileSync(file, "utf8");
57
+ if (!raw.trim()) return { config: {}, existed: true, broken: false };
58
+ try {
59
+ const config = JSON.parse(raw);
60
+ if (config && typeof config === "object" && !Array.isArray(config)) return { config, existed: true, broken: false };
61
+ } catch {
62
+ // fall through
63
+ }
64
+ return { config: {}, existed: true, broken: true };
65
+ }
66
+
67
+ export function backup(file) {
68
+ const copy = `${file}.backup-${Date.now()}`;
69
+ fs.copyFileSync(file, copy);
70
+ return copy;
71
+ }
72
+
73
+ /** Add or update our entry, keeping every other server and setting in the file. */
74
+ export function upsertServerEntry(file, entry) {
75
+ const { config, existed, broken } = readJson(file);
76
+ let backedUp;
77
+ if (existed) backedUp = backup(file);
78
+ if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
79
+ const had = SERVER_NAME in config.mcpServers;
80
+ config.mcpServers[SERVER_NAME] = entry;
81
+ fs.mkdirSync(path.dirname(file), { recursive: true });
82
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
83
+ return { updated: had, created: !had, backup: backedUp, replacedBrokenFile: broken };
84
+ }
85
+
86
+ export function removeServerEntry(file) {
87
+ const { config, existed, broken } = readJson(file);
88
+ if (!existed || broken || !config.mcpServers || !(SERVER_NAME in config.mcpServers)) return { removed: false };
89
+ const copy = backup(file);
90
+ delete config.mcpServers[SERVER_NAME];
91
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
92
+ return { removed: true, backup: copy };
93
+ }
94
+
95
+ export function pickClients(client, opts) {
96
+ if (!client || client === "auto") {
97
+ const found = detectClients(opts);
98
+ return found.length ? found : ["claude"];
99
+ }
100
+ if (client === "all") return [...CLIENTS];
101
+ return String(client)
102
+ .split(",")
103
+ .map((c) => c.trim())
104
+ .filter(Boolean)
105
+ .map((c) => {
106
+ if (!CLIENTS.includes(c)) throw new Error(`Unknown client "${c}". Use one of: ${CLIENTS.join(", ")}, all.`);
107
+ return c;
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Set the server up for the user's editors. Creates ~/.causly/.env from the template
113
+ * (unless a checkout already has a .env) and registers the server with each client.
114
+ */
115
+ // Claude Desktop and others do not refresh tools mid-chat, so everyday packs load at startup.
116
+ export const DEFAULT_TOOLSETS = "research,leads,seo,pipeline,reminders,meetings";
117
+
118
+ export function runInit({ client, workspace, toolsets, platform, home, env, root = PACKAGE_ROOT } = {}) {
119
+ const where = { platform, home, env };
120
+ const clients = pickClients(client, where);
121
+ const lines = [];
122
+
123
+ if (workspace && !fs.existsSync(workspace)) throw new Error(`The workspace folder does not exist: ${workspace}`);
124
+
125
+ const envFile = path.join(causlyHome(), ".env");
126
+ const template = path.join(root, ".env.example");
127
+ const checkoutEnv = path.join(root, ".env");
128
+ let envPath = fs.existsSync(checkoutEnv) ? checkoutEnv : envFile;
129
+ if (!fs.existsSync(envPath)) {
130
+ fs.mkdirSync(path.dirname(envFile), { recursive: true });
131
+ if (fs.existsSync(template)) fs.copyFileSync(template, envFile);
132
+ else fs.writeFileSync(envFile, "# causly-server settings: KEY=value, one per line\n");
133
+ lines.push(`Created ${envFile} (add your API keys there)`);
134
+ } else {
135
+ lines.push(`Using existing ${envPath}`);
136
+ }
137
+
138
+ const entry = serverEntry({ root, platform, workspace, toolsets: toolsets ?? DEFAULT_TOOLSETS });
139
+ const results = [];
140
+ for (const c of clients) {
141
+ const file = clientConfigPath(c, where);
142
+ const r = upsertServerEntry(file, entry);
143
+ results.push({ client: c, file, ...r });
144
+ lines.push(`${r.updated ? "Updated" : "Added"} ${CLIENT_LABEL[c]}: ${file}${r.replacedBrokenFile ? " (the old file was not valid JSON; kept a backup)" : ""}`);
145
+ }
146
+ lines.push(`Restart ${clients.map((c) => CLIENT_LABEL[c]).join(" and ")} to load it.`);
147
+ lines.push(`Claude Code: claude mcp add ${SERVER_NAME} -- ${[entry.command, ...entry.args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ")}`);
148
+ if (!workspace) lines.push("Tip: limit file access to one folder with --workspace <folder> (re-run init).");
149
+ return { env_file: envPath, entry, clients: results, lines };
150
+ }
151
+
152
+ /** Shown after init: what to try first. */
153
+ export function welcomeLines() {
154
+ return [
155
+ "",
156
+ "Try asking your agent:",
157
+ ' "Find companies hiring React developers and add the best three to my pipeline."',
158
+ ' "Save notes from my call with Acme: they want a demo next week, follow up on Friday."',
159
+ ' "What is due this week? Include meeting action items and reminders."',
160
+ ' "Prep me for my next meeting." (needs Google Calendar: causly-server login google)',
161
+ "",
162
+ "Useful commands: causly-server status | setup | login google | toolsets | backup | doctor --test",
163
+ ];
164
+ }
165
+
166
+ export function runRemove({ client, platform, home, env } = {}) {
167
+ const where = { platform, home, env };
168
+ const clients = client ? pickClients(client, where) : [...CLIENTS];
169
+ const lines = [];
170
+ const results = clients.map((c) => {
171
+ const file = clientConfigPath(c, where);
172
+ const r = removeServerEntry(file);
173
+ if (r.removed) lines.push(`Removed from ${CLIENT_LABEL[c]}: ${file}`);
174
+ return { client: c, file, ...r };
175
+ });
176
+ if (!lines.length) lines.push("Nothing to remove: causly-server is not set up in any client.");
177
+ else lines.push(`Your settings in ${causlyHome()} were left alone; delete that folder to remove them too.`);
178
+ return { clients: results, lines };
179
+ }
180
+
181
+ // ---------------------------------------------------------------- doctor
182
+
183
+ const SERVICES = [
184
+ ["GitHub", ["GITHUB_TOKEN"]],
185
+ ["Slack", ["SLACK_BOT_TOKEN"]],
186
+ ["Gmail", ["GMAIL_CLIENT_ID", "GMAIL_CLIENT_SECRET", "GMAIL_REFRESH_TOKEN"]],
187
+ ["Google Calendar", ["CALENDAR_REFRESH_TOKEN"]],
188
+ ["Telegram", ["TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"]],
189
+ ["Linear", ["LINEAR_API_KEY"]],
190
+ ["Stripe", ["STRIPE_API_KEY"]],
191
+ ["Discord", ["DISCORD_BOT_TOKEN"]],
192
+ ["Groq (meeting transcription)", ["GROQ_API_KEY"]],
193
+ ["Notion", ["NOTION_API_KEY"]],
194
+ ["Sentry", ["SENTRY_AUTH_TOKEN"]],
195
+ ];
196
+
197
+ const versionAtLeast = (v, [maj, min]) => {
198
+ const [a, b] = String(v).replace(/^v/, "").split(".").map(Number);
199
+ return a > maj || (a === maj && b >= min);
200
+ };
201
+
202
+ function probe(exec, cmd, args) {
203
+ return new Promise((resolve) => exec(cmd, args, { timeout: 8000, windowsHide: true }, (err, stdout) => resolve(err ? null : String(stdout).split("\n")[0].trim())));
204
+ }
205
+
206
+ export async function runDoctor({ env = process.env, exec = execFile, nodeVersion = process.versions.node, platform, home } = {}) {
207
+ const checks = [];
208
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
209
+
210
+ add("Node.js", versionAtLeast(nodeVersion, [20, 0]), `${nodeVersion}${versionAtLeast(nodeVersion, [22, 13]) ? "" : " (22.13+ is needed for pipeline, meetings and reminders)"}`);
211
+
212
+ const files = envFiles().filter((f) => fs.existsSync(f));
213
+ add("Settings file", files.length > 0, files.length ? files.join(", ") : `none found; run "causly-server init" or create ${path.join(causlyHome(), ".env")}`);
214
+
215
+ const loaded = (keys) => keys.every((k) => env[k]);
216
+ const configured = SERVICES.filter(([, keys]) => loaded(keys)).map(([n]) => n);
217
+ const missing = SERVICES.filter(([, keys]) => !loaded(keys)).map(([n]) => n);
218
+ add("Integrations configured", configured.length > 0, configured.length ? configured.join(", ") : "none yet (add keys to the settings file)");
219
+
220
+ const ff = await probe(exec, "ffmpeg", ["-version"]);
221
+ add("ffmpeg (large recordings)", Boolean(ff), ff ? ff.slice(0, 40) : "not found; only needed for recordings over 25 MB");
222
+
223
+ add("Workspace limit", Boolean(env.CAUSLY_WORKSPACE), env.CAUSLY_WORKSPACE || "off: file tools can reach any folder (set with: causly-server init --workspace <folder>)");
224
+
225
+ const where = { platform, home, env };
226
+ const registered = CLIENTS.filter((c) => {
227
+ const { config } = readJson(clientConfigPath(c, where));
228
+ return config.mcpServers && SERVER_NAME in config.mcpServers;
229
+ });
230
+ add("Set up in", registered.length > 0, registered.length ? registered.map((c) => CLIENT_LABEL[c]).join(", ") : 'no editor yet; run "causly-server init"');
231
+
232
+ return { ok: checks.filter((c) => c.name === "Node.js" || c.name === "Settings file").every((c) => c.ok), checks, not_configured: missing };
233
+ }
@@ -0,0 +1,198 @@
1
+ import { exec, execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import os from "os";
4
+ import { classifyCommandRisk } from "./security.js";
5
+
6
+ const execAsync = promisify(exec);
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ const IS_WINDOWS = os.platform() === "win32";
10
+
11
+ // Resolve PowerShell by absolute path instead of relying on PATH lookup —
12
+ // Claude Desktop's spawned environment has an unreliable/stripped PATH on
13
+ // some machines, which made `execFile("powershell.exe", ...)` fail with
14
+ // ENOENT even though PowerShell was installed. The System32 location is
15
+ // standard on every Windows install; SystemRoot env var (or C:\Windows as
16
+ // a fallback) makes it correct even on non-C drive Windows installs.
17
+ const POWERSHELL_PATH = IS_WINDOWS
18
+ ? `${process.env.SystemRoot || "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
19
+ : null;
20
+
21
+ // cmd.exe is used as a last-resort fallback below. Resolve it by absolute
22
+ // path too — the same stripped-environment issue that breaks PATH lookup
23
+ // for powershell.exe also breaks lookup of "cmd.exe" (via a missing/broken
24
+ // ComSpec env var), so execAsync(command) with the default shell option
25
+ // can ENOENT even though cmd.exe is present on every Windows install.
26
+ const CMD_PATH = IS_WINDOWS
27
+ ? `${process.env.SystemRoot || "C:\\Windows"}\\System32\\cmd.exe`
28
+ : null;
29
+
30
+ // Claude Desktop launches this server with a stripped-down environment —
31
+ // on Windows, PATHEXT in particular often arrives missing .EXE/.CMD/.BAT,
32
+ // which makes PowerShell unable to resolve commands like `node` or `npm`
33
+ // even though their directory is present in PATH and the file exists.
34
+ // Restore a sane default so command resolution works the way it would in
35
+ // a normal interactive terminal.
36
+ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC";
37
+
38
+ // Find the actual casing of an env var key (Windows env keys can be
39
+ // "Path", "PATH", "PathExt", etc. depending on how they were originally
40
+ // set). Returns the real key if found, otherwise undefined.
41
+ function findKey(env, name) {
42
+ return Object.keys(env).find((k) => k.toUpperCase() === name.toUpperCase());
43
+ }
44
+
45
+ function buildEnv() {
46
+ if (!IS_WINDOWS) return process.env;
47
+ const env = { ...process.env };
48
+
49
+ // IMPORTANT: always read/write through the env's *actual* existing key
50
+ // casing (e.g. "Path" vs "PATH"). Setting env.PATH when the real key is
51
+ // "Path" creates two distinct keys in the plain JS object. Node then
52
+ // serializes BOTH into the child process's environment block. Nested
53
+ // processes (cmd.exe spawned by npm, which then spawns node) can end up
54
+ // reading the wrong/empty duplicate for their own command resolution —
55
+ // this was the actual cause of "'node' is not recognized" inside
56
+ // `npm run <script>`, even though PATH looked correct when echoed at
57
+ // the top level.
58
+ const pathExtKey = findKey(env, "PATHEXT") || "PATHEXT";
59
+ const currentExt = env[pathExtKey] || "";
60
+ // Normalize: split, drop empty segments (a stray leading/trailing/double
61
+ // semicolon produces an empty entry, e.g. ";.COM;.EXE"). An empty entry
62
+ // here silently breaks Windows' extension-less command resolution
63
+ // (`node`, `npm`, `where`, etc.) inside nested cmd.exe processes — this
64
+ // was the actual root cause of "'node' is not recognized" inside
65
+ // `npm run <script>` (npm spawns its own cmd.exe internally), even
66
+ // though the PATHEXT value looked fine when echoed at the top level.
67
+ const currentExtList = currentExt.split(";").map((e) => e.trim()).filter(Boolean);
68
+ const missingExt = DEFAULT_PATHEXT.split(";").filter(
69
+ (ext) => !currentExtList.some((e) => e.toUpperCase() === ext)
70
+ );
71
+ env[pathExtKey] = [...currentExtList, ...missingExt].join(";") || DEFAULT_PATHEXT;
72
+
73
+ // Claude Desktop's spawned environment also carries a stripped-down PATH
74
+ // on some machines — missing the Node.js install dir and the npm global
75
+ // bin dir. This breaks any subprocess spawned BY a tool we run (e.g.
76
+ // `npm install`'s own internal `node postinstall.js` calls, or
77
+ // `npm run <script>`'s nested cmd.exe -> node.exe spawn), even though
78
+ // our own top-level `node`/`npm` calls succeed via PowerShell's separate
79
+ // system-PATH resolution. Ensure these are always present.
80
+ const pathKey = findKey(env, "PATH") || "PATH";
81
+ const home = env.USERPROFILE || env.HOME || "C:\\Users\\nihal";
82
+ const criticalPaths = [
83
+ "C:\\Program Files\\nodejs",
84
+ `${home}\\AppData\\Roaming\\npm`,
85
+ ];
86
+ const currentPath = env[pathKey] || "";
87
+ const currentPathList = currentPath.split(";").map((p) => p.trim()).filter(Boolean);
88
+ const missingPaths = criticalPaths.filter(
89
+ (p) => !currentPathList.some((existing) => existing.toLowerCase() === p.toLowerCase())
90
+ );
91
+ env[pathKey] = [...currentPathList, ...missingPaths].join(";");
92
+
93
+ return env;
94
+ }
95
+
96
+ // Commands containing these patterns are blocked outright — no override.
97
+ // Keeps a single mistaken/careless call from wiping a drive or the OS.
98
+ const BLOCKED_PATTERNS = [
99
+ /rm\s+-rf\s+\/(?!\S)/i, // rm -rf /
100
+ /rm\s+-rf\s+[a-z]:\\?\s*$/i, // rm -rf C:\ or D:\
101
+ /format\s+[a-z]:/i, // format C:
102
+ /del\s+\/s\s+\/q\s+[a-z]:\\?\s*$/i, // del /s /q C:\
103
+ /:\(\)\{.*\};:/, // fork bomb
104
+ /shutdown|restart-computer/i,
105
+ /reg\s+delete/i,
106
+ ];
107
+
108
+ const DEFAULT_TIMEOUT_MS = 60_000; // 1 minute
109
+ const MAX_OUTPUT_CHARS = 20_000;
110
+
111
+ function truncate(str) {
112
+ if (!str) return str;
113
+ return str.length > MAX_OUTPUT_CHARS
114
+ ? str.slice(0, MAX_OUTPUT_CHARS) + `\n... [truncated, ${str.length - MAX_OUTPUT_CHARS} more chars]`
115
+ : str;
116
+ }
117
+
118
+ /**
119
+ * Run a shell command in a given working directory.
120
+ * Blocks a short list of catastrophic patterns; everything else is allowed
121
+ * since this server runs with full trusted access on the user's own machine.
122
+ */
123
+ export async function runCommand({ command, cwd, timeout_ms = DEFAULT_TIMEOUT_MS }) {
124
+ for (const pattern of BLOCKED_PATTERNS) {
125
+ if (pattern.test(command)) {
126
+ throw new Error(
127
+ `Blocked: this command matches a destructive pattern (${pattern}) and was not run. If this is a false positive, rephrase the command.`
128
+ );
129
+ }
130
+ }
131
+
132
+ const riskInfo = classifyCommandRisk(command);
133
+
134
+ const runOpts = {
135
+ cwd,
136
+ timeout: timeout_ms,
137
+ maxBuffer: 10 * 1024 * 1024, // 10MB
138
+ windowsHide: true,
139
+ env: buildEnv(),
140
+ };
141
+
142
+ async function runOnWindows() {
143
+ const psArgs = ["-NoProfile", "-NonInteractive", "-Command", command];
144
+ try {
145
+ // Preferred: absolute path, bypasses any PATH resolution issues.
146
+ return await execFileAsync(POWERSHELL_PATH, psArgs, runOpts);
147
+ } catch (err) {
148
+ if (err.code !== "ENOENT") throw err;
149
+ try {
150
+ // Fallback 1: let the OS resolve "powershell.exe" via PATH.
151
+ return await execFileAsync("powershell.exe", psArgs, runOpts);
152
+ } catch (err2) {
153
+ if (err2.code !== "ENOENT") throw err2;
154
+ try {
155
+ // Fallback 2: let the OS resolve "cmd.exe" via PATH/ComSpec.
156
+ return await execAsync(command, runOpts);
157
+ } catch (err3) {
158
+ if (err3.code !== "ENOENT") throw err3;
159
+ // Fallback 3: absolute path to cmd.exe, bypasses ComSpec/PATH
160
+ // entirely. Runs the command as-is (loses PowerShell-specific
161
+ // syntax, but covers plain commands like npm/node/git).
162
+ return await execFileAsync(CMD_PATH, ["/d", "/s", "/c", command], runOpts);
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ try {
169
+ // On Windows, spawn powershell.exe directly as the target process
170
+ // (not via exec's "shell" option) — this is what makes windowsHide
171
+ // actually suppress the console window. Using exec's shell option
172
+ // with a custom shell path leaves windowsHide ineffective on Windows,
173
+ // which was popping up (and auto-closing) a visible terminal window
174
+ // on every single tool call.
175
+ const { stdout, stderr } = IS_WINDOWS ? await runOnWindows() : await execAsync(command, runOpts);
176
+
177
+ return {
178
+ command,
179
+ cwd,
180
+ stdout: truncate(stdout),
181
+ stderr: truncate(stderr),
182
+ exit_code: 0,
183
+ risk: riskInfo,
184
+ };
185
+ } catch (err) {
186
+ // exec/execFile reject on non-zero exit code — surface it as data, not
187
+ // a hard throw, so the caller can see stdout/stderr even on failure.
188
+ return {
189
+ command,
190
+ cwd,
191
+ stdout: truncate(err.stdout || ""),
192
+ stderr: truncate(err.stderr || err.message),
193
+ exit_code: err.code ?? 1,
194
+ timed_out: err.killed && err.signal === "SIGTERM",
195
+ risk: riskInfo,
196
+ };
197
+ }
198
+ }
package/tools/dbOps.js ADDED
@@ -0,0 +1,117 @@
1
+ import pg from "pg";
2
+ import mysql from "mysql2/promise";
3
+
4
+ const { Pool: PgPool } = pg;
5
+
6
+ // Connection pools cached by connection_string so repeated calls reuse them.
7
+ const pgPools = new Map();
8
+ const mysqlPools = new Map();
9
+
10
+ function getPgPool(connection_string) {
11
+ if (!pgPools.has(connection_string)) {
12
+ pgPools.set(connection_string, new PgPool({ connectionString: connection_string, max: 5 }));
13
+ }
14
+ return pgPools.get(connection_string);
15
+ }
16
+
17
+ function getMysqlPool(connection_string) {
18
+ if (!mysqlPools.has(connection_string)) {
19
+ mysqlPools.set(connection_string, mysql.createPool(connection_string));
20
+ }
21
+ return mysqlPools.get(connection_string);
22
+ }
23
+
24
+ function requireConnString(connection_string) {
25
+ if (!connection_string) {
26
+ throw new Error(
27
+ "connection_string is required, e.g. postgres://user:pass@host:5432/dbname or mysql://user:pass@host:3306/dbname"
28
+ );
29
+ }
30
+ }
31
+
32
+ // ---------- Postgres ----------
33
+
34
+ /** Run a read or write SQL query against a Postgres database. */
35
+ export async function postgresQuery({ connection_string, sql, params }) {
36
+ requireConnString(connection_string);
37
+ const pool = getPgPool(connection_string);
38
+ const result = await pool.query(sql, params || []);
39
+ return {
40
+ rows: result.rows,
41
+ row_count: result.rowCount,
42
+ fields: result.fields?.map((f) => f.name),
43
+ };
44
+ }
45
+
46
+ /** List all tables in a Postgres database (public schema by default). */
47
+ export async function postgresListTables({ connection_string, schema = "public" }) {
48
+ requireConnString(connection_string);
49
+ const pool = getPgPool(connection_string);
50
+ const result = await pool.query(
51
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name`,
52
+ [schema]
53
+ );
54
+ return { tables: result.rows.map((r) => r.table_name) };
55
+ }
56
+
57
+ /** Get column names, types, and nullability for a Postgres table. */
58
+ export async function postgresDescribeTable({ connection_string, table, schema = "public" }) {
59
+ requireConnString(connection_string);
60
+ const pool = getPgPool(connection_string);
61
+ const result = await pool.query(
62
+ `SELECT column_name, data_type, is_nullable, column_default
63
+ FROM information_schema.columns
64
+ WHERE table_schema = $1 AND table_name = $2
65
+ ORDER BY ordinal_position`,
66
+ [schema, table]
67
+ );
68
+ return { columns: result.rows };
69
+ }
70
+
71
+ /** Test a Postgres connection string — returns server version on success. */
72
+ export async function postgresTestConnection({ connection_string }) {
73
+ requireConnString(connection_string);
74
+ const pool = getPgPool(connection_string);
75
+ const result = await pool.query("SELECT version()");
76
+ return { connected: true, version: result.rows[0].version };
77
+ }
78
+
79
+ // ---------- MySQL ----------
80
+
81
+ /** Run a read or write SQL query against a MySQL database. */
82
+ export async function mysqlQuery({ connection_string, sql, params }) {
83
+ requireConnString(connection_string);
84
+ const pool = getMysqlPool(connection_string);
85
+ const [rows, fields] = await pool.query(sql, params || []);
86
+ return {
87
+ rows: Array.isArray(rows) ? rows : [],
88
+ affected_rows: rows?.affectedRows,
89
+ insert_id: rows?.insertId,
90
+ fields: fields?.map((f) => f.name),
91
+ };
92
+ }
93
+
94
+ /** List all tables in a MySQL database. */
95
+ export async function mysqlListTables({ connection_string }) {
96
+ requireConnString(connection_string);
97
+ const pool = getMysqlPool(connection_string);
98
+ const [rows] = await pool.query("SHOW TABLES");
99
+ const tables = rows.map((r) => Object.values(r)[0]);
100
+ return { tables };
101
+ }
102
+
103
+ /** Get column names, types, and nullability for a MySQL table. */
104
+ export async function mysqlDescribeTable({ connection_string, table }) {
105
+ requireConnString(connection_string);
106
+ const pool = getMysqlPool(connection_string);
107
+ const [rows] = await pool.query(`DESCRIBE \`${table}\``);
108
+ return { columns: rows };
109
+ }
110
+
111
+ /** Test a MySQL connection string — returns server version on success. */
112
+ export async function mysqlTestConnection({ connection_string }) {
113
+ requireConnString(connection_string);
114
+ const pool = getMysqlPool(connection_string);
115
+ const [rows] = await pool.query("SELECT VERSION() as version");
116
+ return { connected: true, version: rows[0].version };
117
+ }
@@ -0,0 +1,96 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { isPathDenied } from "./security.js";
4
+
5
+ function assertPathAllowed(targetPath) {
6
+ const check = isPathDenied(targetPath);
7
+ if (check.denied) throw new Error(check.reason);
8
+ }
9
+
10
+ /** List immediate contents (files + folders) of a directory. */
11
+ export async function listDirectory({ path: dirPath }) {
12
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
13
+ return {
14
+ path: dirPath,
15
+ entries: entries.map((e) => ({
16
+ name: e.name,
17
+ type: e.isDirectory() ? "directory" : "file",
18
+ })),
19
+ };
20
+ }
21
+
22
+ /** Recursively build a tree view of a directory, up to max_depth. */
23
+ export async function directoryTree({ path: dirPath, max_depth = 5, _currentDepth = 0 }) {
24
+ if (_currentDepth >= max_depth) {
25
+ return { name: path.basename(dirPath), type: "directory", truncated: true };
26
+ }
27
+
28
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
29
+ const children = [];
30
+
31
+ for (const entry of entries) {
32
+ // Skip common noise directories.
33
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
34
+
35
+ const fullPath = path.join(dirPath, entry.name);
36
+ if (entry.isDirectory()) {
37
+ children.push(
38
+ await directoryTree({ path: fullPath, max_depth, _currentDepth: _currentDepth + 1 })
39
+ );
40
+ } else {
41
+ children.push({ name: entry.name, type: "file" });
42
+ }
43
+ }
44
+
45
+ return { name: path.basename(dirPath), type: "directory", children };
46
+ }
47
+
48
+ /** Create a new directory (and any missing parents). */
49
+ export async function createDirectory({ path: dirPath }) {
50
+ assertPathAllowed(dirPath);
51
+ await fs.mkdir(dirPath, { recursive: true });
52
+ return { path: dirPath, created: true };
53
+ }
54
+
55
+ /** Delete a directory. Recursive by default so nested folders can be removed. */
56
+ export async function deleteDirectory({ path: dirPath, recursive = true }) {
57
+ assertPathAllowed(dirPath);
58
+ await fs.rm(dirPath, { recursive, force: false });
59
+ return { path: dirPath, deleted: true };
60
+ }
61
+
62
+ /**
63
+ * Search for files/folders by name pattern (case-insensitive substring
64
+ * match) starting from a root directory.
65
+ */
66
+ export async function searchFiles({ root_path, pattern, max_results = 100 }) {
67
+ const results = [];
68
+ const lowerPattern = pattern.toLowerCase();
69
+
70
+ async function walk(currentPath) {
71
+ if (results.length >= max_results) return;
72
+
73
+ let entries;
74
+ try {
75
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
76
+ } catch {
77
+ return; // permission denied or path vanished — skip silently
78
+ }
79
+
80
+ for (const entry of entries) {
81
+ if (results.length >= max_results) return;
82
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
83
+
84
+ const fullPath = path.join(currentPath, entry.name);
85
+ if (entry.name.toLowerCase().includes(lowerPattern)) {
86
+ results.push({ path: fullPath, type: entry.isDirectory() ? "directory" : "file" });
87
+ }
88
+ if (entry.isDirectory()) {
89
+ await walk(fullPath);
90
+ }
91
+ }
92
+ }
93
+
94
+ await walk(root_path);
95
+ return { root_path, pattern, matches: results, truncated: results.length >= max_results };
96
+ }