causly-server 0.0.0-stage → 1.5.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/.env.example +120 -0
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +205 -3
- package/bin/causly.js +192 -0
- package/index.js +229 -0
- package/package.json +59 -6
- package/packs/calendar.js +141 -0
- package/packs/command.js +22 -0
- package/packs/database.js +95 -0
- package/packs/directory.js +63 -0
- package/packs/discord.js +63 -0
- package/packs/docker.js +202 -0
- package/packs/files.js +106 -0
- package/packs/git.js +211 -0
- package/packs/github.js +321 -0
- package/packs/gmail.js +98 -0
- package/packs/leads.js +46 -0
- package/packs/linear.js +104 -0
- package/packs/loader.js +247 -0
- package/packs/manifest.json +1152 -0
- package/packs/meetings.js +141 -0
- package/packs/notion.js +185 -0
- package/packs/pipeline.js +151 -0
- package/packs/project.js +70 -0
- package/packs/registry.js +149 -0
- package/packs/reminders.js +57 -0
- package/packs/research.js +34 -0
- package/packs/secrets.js +77 -0
- package/packs/sentry.js +97 -0
- package/packs/seo.js +70 -0
- package/packs/slack.js +95 -0
- package/packs/stripe.js +127 -0
- package/packs/supabase.js +73 -0
- package/packs/telegram.js +70 -0
- package/packs/terraform.js +247 -0
- package/packs/vercel.js +114 -0
- package/packs/workflow.js +84 -0
- package/prompts/cofounder.js +66 -0
- package/prompts/playbooks.js +75 -0
- package/tools/backupOps.js +87 -0
- package/tools/boundaryAttest.js +184 -0
- package/tools/calendarOps.js +563 -0
- package/tools/cliOps.js +233 -0
- package/tools/commandOps.js +198 -0
- package/tools/dbOps.js +117 -0
- package/tools/directoryOps.js +96 -0
- package/tools/discordOps.js +133 -0
- package/tools/dockerOps.js +259 -0
- package/tools/envFile.js +89 -0
- package/tools/envLoader.js +85 -0
- package/tools/fileOps.js +125 -0
- package/tools/gitOps.js +170 -0
- package/tools/githubOps.js +357 -0
- package/tools/gmailOps.js +221 -0
- package/tools/googleAuth.js +51 -0
- package/tools/googleLogin.js +230 -0
- package/tools/leadOps.js +447 -0
- package/tools/linearOps.js +304 -0
- package/tools/logger.js +47 -0
- package/tools/meetingOps.js +697 -0
- package/tools/notionOps.js +215 -0
- package/tools/paths.js +36 -0
- package/tools/pipelineOps.js +511 -0
- package/tools/projectOps.js +192 -0
- package/tools/reminderAlerts.js +48 -0
- package/tools/reminderOps.js +129 -0
- package/tools/scheduler.js +46 -0
- package/tools/secretsOps.js +133 -0
- package/tools/security.js +488 -0
- package/tools/sentryOps.js +130 -0
- package/tools/seoOps.js +561 -0
- package/tools/services.js +295 -0
- package/tools/setupOps.js +245 -0
- package/tools/slackOps.js +151 -0
- package/tools/statusOps.js +104 -0
- package/tools/store.js +111 -0
- package/tools/stripeOps.js +299 -0
- package/tools/supabaseOps.js +94 -0
- package/tools/telegramOps.js +153 -0
- package/tools/terminal.js +124 -0
- package/tools/terraformOps.js +171 -0
- package/tools/toolWrapper.js +60 -0
- package/tools/toolsetOps.js +123 -0
- package/tools/transcribeOps.js +221 -0
- package/tools/updateCheck.js +58 -0
- package/tools/vercelOps.js +198 -0
- package/tools/verifyBoundaryAttest.js +25 -0
- package/tools/webOps.js +474 -0
- package/tools/workflowOps.js +371 -0
- package/tools/workspace.js +85 -0
package/tools/cliOps.js
ADDED
|
@@ -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
|
+
}
|