wizards 0.2.0 → 0.2.2
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/README.md +4 -1
- package/apps/runtime/dist/app.js +36 -4
- package/apps/runtime/dist/auth/local-ticket.js +8 -5
- package/apps/runtime/dist/cli/connect.js +531 -0
- package/apps/runtime/dist/cli/environment.js +3 -1
- package/apps/runtime/dist/cli/main.js +17 -0
- package/apps/runtime/dist/cli/mcp.js +175 -0
- package/apps/runtime/dist/cli/setup.js +49 -0
- package/apps/runtime/dist/env.js +1 -1
- package/apps/runtime/dist/mcp/auth.js +26 -0
- package/apps/runtime/dist/mcp/flow-app.js +94 -0
- package/apps/runtime/dist/mcp/handler.js +15 -5
- package/apps/runtime/dist/mcp/seen.js +65 -0
- package/apps/runtime/dist/mcp/tools.js +156 -4
- package/apps/runtime/dist/plugins/loader.js +23 -1
- package/apps/runtime/dist/routes/runs.js +7 -3
- package/apps/runtime/dist/routes/studio.js +20 -0
- package/apps/runtime/dist/secrets/signing.js +21 -3
- package/apps/runtime/dist/services/runs.js +190 -28
- package/apps/runtime/dist/services/wizards.js +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-COqPzrKT.js → ChangelogDialog-4HCt88rf.js} +1 -1
- package/apps/web/dist/assets/ChatPanel-B91fTrzg.js +1 -0
- package/apps/web/dist/assets/{CreditsDialog-pRbTW7Xh.js → CreditsDialog-D3fZRqXH.js} +1 -1
- package/apps/web/dist/assets/EditorPage-ZZjb9mGV.js +2 -0
- package/apps/web/dist/assets/{FlowPreview-CDmcN1oX.js → FlowPreview-CjUK5046.js} +1 -1
- package/apps/web/dist/assets/{HomePage-DUAiS9pg.js → HomePage-Cn5TCZk7.js} +1 -1
- package/apps/web/dist/assets/{ImportWizard-pzljVvcr.js → ImportWizard-DiV62Uno.js} +1 -1
- package/apps/web/dist/assets/{LocalRuntime-DPJMJlWc.js → LocalRuntime-DupxjXff.js} +1 -1
- package/apps/web/dist/assets/NewWizardPage-Bz3PIQGp.js +2 -0
- package/apps/web/dist/assets/{PublicRunner-DoZdJuyW.js → PublicRunner-Axs1zwqe.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-XKosfgO-.js → RunnerView-Bhv6RNME.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-in_RmI8i.js +2 -0
- package/apps/web/dist/assets/{SetupPage-BFQ0V__v.js → SetupPage-xiqJMR0J.js} +1 -1
- package/apps/web/dist/assets/{SharePage-C1yg5bc2.js → SharePage-C8B9BUSU.js} +1 -1
- package/apps/web/dist/assets/{api-DijQGkp-.js → api-KMi35npD.js} +1 -1
- package/apps/web/dist/assets/changelog-C2LDEzLQ.js +1 -0
- package/apps/web/dist/assets/index-2Ktgo6f1.js +10 -0
- package/apps/web/dist/assets/index-B9YVjvVY.css +1 -0
- package/apps/web/dist/assets/meta-4R1Lnr25.js +1 -0
- package/apps/web/dist/assets/oss-credits-roB62QJu.js +1 -0
- package/apps/web/dist/assets/{outputs-37WFkpRS.js → outputs-Dhqtf-SA.js} +1 -1
- package/apps/web/dist/assets/plug-CLjyCoHB.js +1 -0
- package/apps/web/dist/assets/{session-BerOchdC.js → session-B6-6VomQ.js} +1 -1
- package/apps/web/dist/assets/type-Bj9H3eOK.js +1 -0
- package/apps/web/dist/assets/ui-Cs6lpsmj.js +1 -0
- package/apps/web/dist/index.html +6 -5
- package/apps/web/dist/mcp/flow.html +748 -0
- package/modules/README.md +1 -1
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +24 -1
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +1 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/use-wizard/SKILL.md +18 -0
- package/apps/web/dist/assets/ChatPanel-aQP8iz10.js +0 -1
- package/apps/web/dist/assets/EditorPage-xkq6pvw-.js +0 -2
- package/apps/web/dist/assets/NewWizardPage-TwYgdbKl.js +0 -2
- package/apps/web/dist/assets/SettingsPage-C0_peJuV.js +0 -2
- package/apps/web/dist/assets/changelog-sa4SXBrg.js +0 -1
- package/apps/web/dist/assets/index-BAObuqzs.js +0 -10
- package/apps/web/dist/assets/index-BOl3401k.css +0 -1
- package/apps/web/dist/assets/meta-ijlGGCOr.js +0 -1
- package/apps/web/dist/assets/oss-credits-ClwxOPrf.js +0 -1
- package/apps/web/dist/assets/type-c_dt5Ne8.js +0 -1
- package/apps/web/dist/assets/ui-DKc92qJ1.js +0 -1
package/README.md
CHANGED
|
@@ -178,7 +178,10 @@ Development server, checks, how it is built, the managed mode:
|
|
|
178
178
|
[docs/development.md](docs/development.md).
|
|
179
179
|
|
|
180
180
|
Plugins add tools for steps, routes with their own tables and pages in the studio, without
|
|
181
|
-
changing the runtime: [docs/plugins
|
|
181
|
+
changing the runtime: [docs/content/dev/plugins](docs/content/dev/plugins/index.md).
|
|
182
|
+
|
|
183
|
+
The documentation — a user guide and the developer docs — is in [docs/content](docs/content)
|
|
184
|
+
and is a site of its own: `pnpm dev:docs`.
|
|
182
185
|
|
|
183
186
|
## License
|
|
184
187
|
|
package/apps/runtime/dist/app.js
CHANGED
|
@@ -22,6 +22,7 @@ import { pluginRoutes } from "./routes/plugins.js";
|
|
|
22
22
|
import { connectCallback, publicRoutes, runRoutes, shareRoutes } from "./routes/runs.js";
|
|
23
23
|
import { studio } from "./routes/studio.js";
|
|
24
24
|
import { wizardStream } from "./routes/wizard-stream.js";
|
|
25
|
+
import { runTicketValid } from "./secrets/signing.js";
|
|
25
26
|
import { ServiceError } from "./services/errors.js";
|
|
26
27
|
import { tenantOfLink, tenantStatus } from "./tenants/control.js";
|
|
27
28
|
const app = new Hono();
|
|
@@ -45,14 +46,45 @@ app.onError((err, c) => {
|
|
|
45
46
|
const ownHosts = new Set([new URL(env.appUrl).host, ...env.allowedHosts]);
|
|
46
47
|
const loopback = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
47
48
|
const hostAllowed = (host) => ownHosts.has(host) || loopback.test(host);
|
|
49
|
+
/**
|
|
50
|
+
* The widget an AI app shows (MCP Apps) runs on the host's origin and reaches one run with its
|
|
51
|
+
* run ticket: those requests may come from anywhere, the ticket is what lets them in.
|
|
52
|
+
*/
|
|
53
|
+
const ticketedRun = /^\/api\/runs\/([^/]+)(\/|$)/;
|
|
48
54
|
app.use("*", async (c, next) => {
|
|
49
|
-
if (managed) {
|
|
50
|
-
return next();
|
|
51
|
-
}
|
|
52
|
-
if (!hostAllowed(c.req.header("host") ?? new URL(c.req.url).host)) {
|
|
55
|
+
if (!managed && !hostAllowed(c.req.header("host") ?? new URL(c.req.url).host)) {
|
|
53
56
|
return c.text("Forbidden host", 403);
|
|
54
57
|
}
|
|
55
58
|
const origin = c.req.header("origin");
|
|
59
|
+
const path = c.req.path.startsWith(`${basePath}/`)
|
|
60
|
+
? c.req.path.slice(basePath.length)
|
|
61
|
+
: c.req.path;
|
|
62
|
+
const runId = ticketedRun.exec(path)?.[1];
|
|
63
|
+
if (origin && runId && runTicketValid(runId, c.req.query("rt"))) {
|
|
64
|
+
const cors = {
|
|
65
|
+
"access-control-allow-origin": origin,
|
|
66
|
+
"access-control-allow-credentials": "true",
|
|
67
|
+
vary: "Origin",
|
|
68
|
+
};
|
|
69
|
+
if (c.req.method === "OPTIONS") {
|
|
70
|
+
return c.body(null, 204, {
|
|
71
|
+
...cors,
|
|
72
|
+
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE",
|
|
73
|
+
"access-control-allow-headers": "content-type",
|
|
74
|
+
// A page on the internet asking a runtime on this computer (Private Network Access).
|
|
75
|
+
"access-control-allow-private-network": "true",
|
|
76
|
+
"access-control-max-age": "600",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
await next();
|
|
80
|
+
for (const [name, value] of Object.entries(cors)) {
|
|
81
|
+
c.res.headers.set(name, value);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (managed) {
|
|
86
|
+
return next();
|
|
87
|
+
}
|
|
56
88
|
const safe = c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS";
|
|
57
89
|
// MCP clients and scripts send no Origin; a browser always does on a cross-origin write.
|
|
58
90
|
if (!safe && origin && !hostAllowed(new URL(origin).host)) {
|
|
@@ -5,19 +5,22 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
5
5
|
* nothing new — it lets the command line and the desktop app enter a runtime that already runs.
|
|
6
6
|
*/
|
|
7
7
|
const TTL_MS = 60_000;
|
|
8
|
-
|
|
9
|
-
export
|
|
8
|
+
/** The header `wizards mcp` signs its requests with, and the one naming its client. */
|
|
9
|
+
export const LOCAL_MCP_HEADER = "x-engenty-local";
|
|
10
|
+
export const LOCAL_CLIENT_HEADER = "x-engenty-client";
|
|
11
|
+
const sign = (secret, expires, purpose) => createHmac("sha256", secret).update(`${purpose}.${expires}`).digest("hex");
|
|
12
|
+
export function mintLocalTicket(secret, now = Date.now(), purpose = "local-enter") {
|
|
10
13
|
const expires = now + TTL_MS;
|
|
11
|
-
return `${expires}.${sign(secret, expires)}`;
|
|
14
|
+
return `${expires}.${sign(secret, expires, purpose)}`;
|
|
12
15
|
}
|
|
13
16
|
/** When a ticket runs out, or null when it is not one of ours or already ran out. */
|
|
14
|
-
export function localTicketExpiry(secret, ticket, now = Date.now()) {
|
|
17
|
+
export function localTicketExpiry(secret, ticket, now = Date.now(), purpose = "local-enter") {
|
|
15
18
|
const [head, signature] = ticket.split(".");
|
|
16
19
|
const expires = Number(head);
|
|
17
20
|
if (!signature || !Number.isSafeInteger(expires) || expires <= now || expires > now + TTL_MS) {
|
|
18
21
|
return null;
|
|
19
22
|
}
|
|
20
23
|
const given = Buffer.from(signature);
|
|
21
|
-
const expected = Buffer.from(sign(secret, expires));
|
|
24
|
+
const expected = Buffer.from(sign(secret, expires, purpose));
|
|
22
25
|
return given.length === expected.length && timingSafeEqual(given, expected) ? expires : null;
|
|
23
26
|
}
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, dirname, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { installedByScript, packageRoot } from "./home.js";
|
|
7
|
+
import { findOnPath } from "./machine.js";
|
|
8
|
+
import { bad, dim, ok, tilde } from "./ui.js";
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* The wizards in the person's own AI apps: `wizards connect` writes this install's MCP
|
|
12
|
+
* server into each app's config, as the command `wizards mcp` (stdio). Every app starts
|
|
13
|
+
* servers that way; the command finds the running runtime itself and signs in with the data
|
|
14
|
+
* folder's secret, so a config holds neither a port nor a key.
|
|
15
|
+
*/
|
|
16
|
+
/** The name the server has in every app's config. */
|
|
17
|
+
export const SERVER_NAME = "engenty-wizards";
|
|
18
|
+
/**
|
|
19
|
+
* How an app starts this install's MCP server. An install made by install.sh has a command that
|
|
20
|
+
* stays where it is across updates; any other copy is started by its Node and its script. The
|
|
21
|
+
* studio connects apps too: from the runtime, which is not the command.
|
|
22
|
+
*/
|
|
23
|
+
export function serverCommand(paths, script = join(packageRoot, "bin", "wizards.mjs")) {
|
|
24
|
+
const moved = dirname(paths.home) !== join(homedir(), ".engenty");
|
|
25
|
+
const env = moved ? { env: { ENGENTY_HOME: dirname(paths.home) } } : {};
|
|
26
|
+
if (installedByScript(paths)) {
|
|
27
|
+
return { command: join(paths.bin, "wizards"), args: ["mcp"], ...env };
|
|
28
|
+
}
|
|
29
|
+
return { command: process.execPath, args: [script, "mcp"], ...env };
|
|
30
|
+
}
|
|
31
|
+
/** A JSON config with the server set under `at` (e.g. ["mcpServers"]); null when it is no JSON. */
|
|
32
|
+
export function withJsonServer(text, at, entry, name = SERVER_NAME) {
|
|
33
|
+
let root;
|
|
34
|
+
try {
|
|
35
|
+
root = text?.trim() ? JSON.parse(text) : {};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (!root || typeof root !== "object" || Array.isArray(root)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
let node = root;
|
|
44
|
+
for (const key of at) {
|
|
45
|
+
const next = node[key];
|
|
46
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
47
|
+
node[key] = {};
|
|
48
|
+
}
|
|
49
|
+
node = node[key];
|
|
50
|
+
}
|
|
51
|
+
node[name] = entry;
|
|
52
|
+
return `${JSON.stringify(root, null, 2)}\n`;
|
|
53
|
+
}
|
|
54
|
+
/** The same config without the server; null when it is no JSON. */
|
|
55
|
+
export function withoutJsonServer(text, at, name = SERVER_NAME) {
|
|
56
|
+
let root;
|
|
57
|
+
try {
|
|
58
|
+
root = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
let node = root;
|
|
64
|
+
for (const key of at) {
|
|
65
|
+
node = node?.[key];
|
|
66
|
+
}
|
|
67
|
+
if (node && typeof node === "object") {
|
|
68
|
+
delete node[name];
|
|
69
|
+
}
|
|
70
|
+
return `${JSON.stringify(root, null, 2)}\n`;
|
|
71
|
+
}
|
|
72
|
+
export function jsonHasServer(text, at, name = SERVER_NAME) {
|
|
73
|
+
try {
|
|
74
|
+
let node = JSON.parse(text);
|
|
75
|
+
for (const key of at) {
|
|
76
|
+
node = node?.[key];
|
|
77
|
+
}
|
|
78
|
+
return Boolean(node && typeof node === "object" && name in node);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const tomlString = (value) => JSON.stringify(value);
|
|
85
|
+
/** A TOML config (Codex) without the server's tables `[mcp_servers.<name>]` and below. */
|
|
86
|
+
export function withoutTomlServer(text, name = SERVER_NAME) {
|
|
87
|
+
const lines = text.split("\n");
|
|
88
|
+
const out = [];
|
|
89
|
+
let skipping = false;
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
const table = line.match(/^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/)?.[1];
|
|
92
|
+
if (table !== undefined) {
|
|
93
|
+
const key = table.replaceAll('"', "");
|
|
94
|
+
skipping = key === `mcp_servers.${name}` || key.startsWith(`mcp_servers.${name}.`);
|
|
95
|
+
}
|
|
96
|
+
if (!skipping) {
|
|
97
|
+
out.push(line);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out.join("\n").replace(/\n{3,}$/, "\n\n");
|
|
101
|
+
}
|
|
102
|
+
export function withTomlServer(text, server, name = SERVER_NAME) {
|
|
103
|
+
const rest = withoutTomlServer(text ?? "", name).trimEnd();
|
|
104
|
+
const table = [
|
|
105
|
+
`[mcp_servers.${name}]`,
|
|
106
|
+
`command = ${tomlString(server.command)}`,
|
|
107
|
+
`args = [${server.args.map(tomlString).join(", ")}]`,
|
|
108
|
+
// A run waits up to 45 s for a step; the default of 60 s leaves room for it.
|
|
109
|
+
"tool_timeout_sec = 120",
|
|
110
|
+
...(server.env
|
|
111
|
+
? [
|
|
112
|
+
`env = { ${Object.entries(server.env)
|
|
113
|
+
.map(([key, value]) => `${key} = ${tomlString(value)}`)
|
|
114
|
+
.join(", ")} }`,
|
|
115
|
+
]
|
|
116
|
+
: []),
|
|
117
|
+
].join("\n");
|
|
118
|
+
return `${rest ? `${rest}\n\n` : ""}${table}\n`;
|
|
119
|
+
}
|
|
120
|
+
export function tomlHasServer(text, name = SERVER_NAME) {
|
|
121
|
+
return text.split("\n").some((line) => line.trim() === `[mcp_servers.${name}]`);
|
|
122
|
+
}
|
|
123
|
+
/** Goose's YAML: the server's block below `extensions:`, at two spaces. */
|
|
124
|
+
export function withoutYamlExtension(text, name = SERVER_NAME) {
|
|
125
|
+
const lines = text.split("\n");
|
|
126
|
+
const out = [];
|
|
127
|
+
let skipping = false;
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (line === ` ${name}:`) {
|
|
130
|
+
skipping = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (skipping && line.trim() && !line.startsWith(" ")) {
|
|
134
|
+
skipping = false;
|
|
135
|
+
}
|
|
136
|
+
if (!skipping) {
|
|
137
|
+
out.push(line);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out.join("\n");
|
|
141
|
+
}
|
|
142
|
+
export function withYamlExtension(text, server, name = SERVER_NAME) {
|
|
143
|
+
const quote = (value) => JSON.stringify(value);
|
|
144
|
+
const block = [
|
|
145
|
+
` ${name}:`,
|
|
146
|
+
` name: ${name}`,
|
|
147
|
+
" type: stdio",
|
|
148
|
+
` cmd: ${quote(server.command)}`,
|
|
149
|
+
` args: [${server.args.map(quote).join(", ")}]`,
|
|
150
|
+
" enabled: true",
|
|
151
|
+
" timeout: 300",
|
|
152
|
+
...(server.env
|
|
153
|
+
? [
|
|
154
|
+
" envs:",
|
|
155
|
+
...Object.entries(server.env).map(([key, value]) => ` ${key}: ${quote(value)}`),
|
|
156
|
+
]
|
|
157
|
+
: []),
|
|
158
|
+
];
|
|
159
|
+
const lines = withoutYamlExtension(text ?? "", name).split("\n");
|
|
160
|
+
const at = lines.findIndex((line) => /^extensions:\s*(\{\s*\})?\s*$/.test(line));
|
|
161
|
+
if (at === -1) {
|
|
162
|
+
const rest = lines.join("\n").trimEnd();
|
|
163
|
+
return `${rest ? `${rest}\n` : ""}extensions:\n${block.join("\n")}\n`;
|
|
164
|
+
}
|
|
165
|
+
lines[at] = "extensions:";
|
|
166
|
+
lines.splice(at + 1, 0, ...block);
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
export function yamlHasExtension(text, name = SERVER_NAME) {
|
|
170
|
+
return text.split("\n").includes(` ${name}:`);
|
|
171
|
+
}
|
|
172
|
+
const home = homedir();
|
|
173
|
+
const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
|
|
174
|
+
/** A folder below the per-user application data: Library/Application Support, %APPDATA%, ~/.config. */
|
|
175
|
+
function appSupport(...parts) {
|
|
176
|
+
if (process.platform === "darwin") {
|
|
177
|
+
return join(home, "Library", "Application Support", ...parts);
|
|
178
|
+
}
|
|
179
|
+
if (process.platform === "win32") {
|
|
180
|
+
return join(appData, ...parts);
|
|
181
|
+
}
|
|
182
|
+
return join(process.env.XDG_CONFIG_HOME || join(home, ".config"), ...parts);
|
|
183
|
+
}
|
|
184
|
+
/** Claude Desktop's main process, as `ps` (macOS) or `tasklist` (Windows) names it. */
|
|
185
|
+
const CLAUDE_DESKTOP_PROCESS = {
|
|
186
|
+
darwin: /\/Claude\.app\/Contents\/MacOS\/Claude$/m,
|
|
187
|
+
win32: /^"?Claude\.exe"?,/im,
|
|
188
|
+
};
|
|
189
|
+
/** Whether an app's process runs; false where it cannot be told (Linux, no `ps`). */
|
|
190
|
+
export function processRunning(names, platform = process.platform, list = () => platform === "win32"
|
|
191
|
+
? execFileSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" })
|
|
192
|
+
: execFileSync("ps", ["-axo", "comm"], { encoding: "utf8" })) {
|
|
193
|
+
const pattern = names[platform];
|
|
194
|
+
if (!pattern) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
return pattern.test(list());
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const read = (file) => (existsSync(file) ? readFileSync(file, "utf8") : null);
|
|
205
|
+
/** Writes a config, keeping the version before it once next to it. */
|
|
206
|
+
function save(file, text) {
|
|
207
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
208
|
+
if (existsSync(file)) {
|
|
209
|
+
copyFileSync(file, `${file}.before-engenty`);
|
|
210
|
+
}
|
|
211
|
+
writeFileSync(file, text);
|
|
212
|
+
}
|
|
213
|
+
function jsonApp(def) {
|
|
214
|
+
const running = () => def.ownsFileWhileRunning?.()
|
|
215
|
+
? {
|
|
216
|
+
ok: false,
|
|
217
|
+
message: `${def.name} is running and would overwrite its config. Quit it (Cmd+Q), then run this again.`,
|
|
218
|
+
}
|
|
219
|
+
: null;
|
|
220
|
+
const snippet = (server) => JSON.stringify(def.at.reduceRight((inner, key) => ({ [key]: inner }), {
|
|
221
|
+
[SERVER_NAME]: def.entry(server),
|
|
222
|
+
}), null, 2);
|
|
223
|
+
return {
|
|
224
|
+
id: def.id,
|
|
225
|
+
name: def.name,
|
|
226
|
+
widgets: def.widgets,
|
|
227
|
+
where: def.file,
|
|
228
|
+
installed: () => existsSync(def.marker),
|
|
229
|
+
connected: async () => jsonHasServer(read(def.file) ?? "", def.at),
|
|
230
|
+
async connect(server) {
|
|
231
|
+
const busy = running();
|
|
232
|
+
if (busy) {
|
|
233
|
+
return busy;
|
|
234
|
+
}
|
|
235
|
+
const next = withJsonServer(read(def.file), def.at, def.entry(server));
|
|
236
|
+
if (next === null) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
message: `${def.file} is not plain JSON (comments?); add this to it:`,
|
|
240
|
+
snippet: snippet(server),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
save(def.file, next);
|
|
244
|
+
return { ok: true, message: `added to ${def.file}` };
|
|
245
|
+
},
|
|
246
|
+
async disconnect() {
|
|
247
|
+
const text = read(def.file);
|
|
248
|
+
if (!text || !jsonHasServer(text, def.at)) {
|
|
249
|
+
return { ok: true, message: "was not connected" };
|
|
250
|
+
}
|
|
251
|
+
const busy = running();
|
|
252
|
+
if (busy) {
|
|
253
|
+
return busy;
|
|
254
|
+
}
|
|
255
|
+
const next = withoutJsonServer(text, def.at);
|
|
256
|
+
if (next === null) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
message: `${def.file} is not plain JSON; remove "${SERVER_NAME}" there.`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
save(def.file, next);
|
|
263
|
+
return { ok: true, message: `removed from ${def.file}` };
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const stdioEntry = (server) => ({
|
|
268
|
+
command: server.command,
|
|
269
|
+
args: server.args,
|
|
270
|
+
...(server.env ? { env: server.env } : {}),
|
|
271
|
+
});
|
|
272
|
+
/** A command of an app: on the PATH, or among the clients this install brought. */
|
|
273
|
+
function appCommand(paths, name) {
|
|
274
|
+
return findOnPath(name, [join(paths.clients, "bin"), process.env.PATH ?? ""].join(delimiter));
|
|
275
|
+
}
|
|
276
|
+
function claudeCode(paths) {
|
|
277
|
+
const file = join(home, ".claude.json");
|
|
278
|
+
return {
|
|
279
|
+
id: "claude-code",
|
|
280
|
+
name: "Claude Code",
|
|
281
|
+
widgets: false,
|
|
282
|
+
where: "claude mcp (user scope)",
|
|
283
|
+
installed: () => Boolean(appCommand(paths, "claude")),
|
|
284
|
+
connected: async () => jsonHasServer(read(file) ?? "", ["mcpServers"]),
|
|
285
|
+
async connect(server) {
|
|
286
|
+
const claude = appCommand(paths, "claude");
|
|
287
|
+
if (!claude) {
|
|
288
|
+
return { ok: false, message: "the claude command was not found" };
|
|
289
|
+
}
|
|
290
|
+
// Claude Code keeps rewriting its own file: its command changes it, not we.
|
|
291
|
+
await run(claude, ["mcp", "remove", "--scope", "user", SERVER_NAME]).catch(() => undefined);
|
|
292
|
+
try {
|
|
293
|
+
await run(claude, [
|
|
294
|
+
"mcp",
|
|
295
|
+
"add",
|
|
296
|
+
"--scope",
|
|
297
|
+
"user",
|
|
298
|
+
...Object.entries(server.env ?? {}).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
|
|
299
|
+
SERVER_NAME,
|
|
300
|
+
"--",
|
|
301
|
+
server.command,
|
|
302
|
+
...server.args,
|
|
303
|
+
]);
|
|
304
|
+
return { ok: true, message: "added with `claude mcp add --scope user`" };
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return { ok: false, message: error.message.split("\n")[0] };
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
async disconnect() {
|
|
311
|
+
const claude = appCommand(paths, "claude");
|
|
312
|
+
if (!claude) {
|
|
313
|
+
return { ok: true, message: "was not connected" };
|
|
314
|
+
}
|
|
315
|
+
await run(claude, ["mcp", "remove", "--scope", "user", SERVER_NAME]).catch(() => undefined);
|
|
316
|
+
return { ok: true, message: "removed with `claude mcp remove`" };
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function codex() {
|
|
321
|
+
const dir = process.env.CODEX_HOME || join(home, ".codex");
|
|
322
|
+
const file = join(dir, "config.toml");
|
|
323
|
+
return {
|
|
324
|
+
id: "codex",
|
|
325
|
+
name: "Codex · ChatGPT app",
|
|
326
|
+
widgets: false,
|
|
327
|
+
where: file,
|
|
328
|
+
installed: () => existsSync(dir),
|
|
329
|
+
connected: async () => tomlHasServer(read(file) ?? ""),
|
|
330
|
+
async connect(server) {
|
|
331
|
+
save(file, withTomlServer(read(file), server));
|
|
332
|
+
return { ok: true, message: `added to ${file}` };
|
|
333
|
+
},
|
|
334
|
+
async disconnect() {
|
|
335
|
+
const text = read(file);
|
|
336
|
+
if (!text || !tomlHasServer(text)) {
|
|
337
|
+
return { ok: true, message: "was not connected" };
|
|
338
|
+
}
|
|
339
|
+
save(file, withoutTomlServer(text));
|
|
340
|
+
return { ok: true, message: `removed from ${file}` };
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function goose() {
|
|
345
|
+
const dir = process.platform === "win32"
|
|
346
|
+
? join(appData, "Block", "goose", "config")
|
|
347
|
+
: join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "goose");
|
|
348
|
+
const file = join(dir, "config.yaml");
|
|
349
|
+
return {
|
|
350
|
+
id: "goose",
|
|
351
|
+
name: "Goose",
|
|
352
|
+
widgets: true,
|
|
353
|
+
where: file,
|
|
354
|
+
installed: () => existsSync(dir),
|
|
355
|
+
connected: async () => yamlHasExtension(read(file) ?? ""),
|
|
356
|
+
async connect(server) {
|
|
357
|
+
save(file, withYamlExtension(read(file), server));
|
|
358
|
+
return { ok: true, message: `added to ${file}` };
|
|
359
|
+
},
|
|
360
|
+
async disconnect() {
|
|
361
|
+
const text = read(file);
|
|
362
|
+
if (!text || !yamlHasExtension(text)) {
|
|
363
|
+
return { ok: true, message: "was not connected" };
|
|
364
|
+
}
|
|
365
|
+
save(file, withoutYamlExtension(text));
|
|
366
|
+
return { ok: true, message: `removed from ${file}` };
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function openClaw(paths) {
|
|
371
|
+
const dir = join(home, ".openclaw");
|
|
372
|
+
const file = join(dir, "openclaw.json");
|
|
373
|
+
const json = jsonApp({
|
|
374
|
+
id: "openclaw",
|
|
375
|
+
name: "OpenClaw",
|
|
376
|
+
widgets: false,
|
|
377
|
+
file,
|
|
378
|
+
marker: dir,
|
|
379
|
+
at: ["mcp", "servers"],
|
|
380
|
+
entry: stdioEntry,
|
|
381
|
+
});
|
|
382
|
+
return {
|
|
383
|
+
...json,
|
|
384
|
+
// Its config is JSON5: its own command writes it when it is there.
|
|
385
|
+
async connect(server) {
|
|
386
|
+
const openclaw = appCommand(paths, "openclaw");
|
|
387
|
+
if (!openclaw) {
|
|
388
|
+
return json.connect(server);
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
await run(openclaw, [
|
|
392
|
+
"mcp",
|
|
393
|
+
"set",
|
|
394
|
+
SERVER_NAME,
|
|
395
|
+
"--command",
|
|
396
|
+
server.command,
|
|
397
|
+
"--args",
|
|
398
|
+
...server.args,
|
|
399
|
+
]);
|
|
400
|
+
return { ok: true, message: "added with `openclaw mcp set`" };
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
return { ok: false, message: error.message.split("\n")[0] };
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
async disconnect() {
|
|
407
|
+
const openclaw = appCommand(paths, "openclaw");
|
|
408
|
+
if (!openclaw) {
|
|
409
|
+
return json.disconnect();
|
|
410
|
+
}
|
|
411
|
+
await run(openclaw, ["mcp", "unset", SERVER_NAME]).catch(() => undefined);
|
|
412
|
+
return { ok: true, message: "removed with `openclaw mcp unset`" };
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/** The AI apps this install can connect to, in the order they are offered. */
|
|
417
|
+
export function apps(paths) {
|
|
418
|
+
const vscodeUser = appSupport("Code", "User");
|
|
419
|
+
return [
|
|
420
|
+
jsonApp({
|
|
421
|
+
id: "claude-desktop",
|
|
422
|
+
name: "Claude Desktop",
|
|
423
|
+
widgets: true,
|
|
424
|
+
file: join(appSupport("Claude"), "claude_desktop_config.json"),
|
|
425
|
+
marker: appSupport("Claude"),
|
|
426
|
+
at: ["mcpServers"],
|
|
427
|
+
entry: stdioEntry,
|
|
428
|
+
ownsFileWhileRunning: () => processRunning(CLAUDE_DESKTOP_PROCESS),
|
|
429
|
+
}),
|
|
430
|
+
claudeCode(paths),
|
|
431
|
+
codex(),
|
|
432
|
+
jsonApp({
|
|
433
|
+
id: "cursor",
|
|
434
|
+
name: "Cursor",
|
|
435
|
+
widgets: true,
|
|
436
|
+
file: join(home, ".cursor", "mcp.json"),
|
|
437
|
+
marker: join(home, ".cursor"),
|
|
438
|
+
at: ["mcpServers"],
|
|
439
|
+
entry: stdioEntry,
|
|
440
|
+
}),
|
|
441
|
+
jsonApp({
|
|
442
|
+
id: "vscode",
|
|
443
|
+
name: "VS Code (Copilot)",
|
|
444
|
+
widgets: true,
|
|
445
|
+
file: join(vscodeUser, "mcp.json"),
|
|
446
|
+
marker: vscodeUser,
|
|
447
|
+
at: ["servers"],
|
|
448
|
+
entry: (server) => ({ type: "stdio", ...stdioEntry(server) }),
|
|
449
|
+
}),
|
|
450
|
+
jsonApp({
|
|
451
|
+
id: "windsurf",
|
|
452
|
+
name: "Windsurf",
|
|
453
|
+
widgets: false,
|
|
454
|
+
file: join(home, ".codeium", "windsurf", "mcp_config.json"),
|
|
455
|
+
marker: join(home, ".codeium", "windsurf"),
|
|
456
|
+
at: ["mcpServers"],
|
|
457
|
+
entry: stdioEntry,
|
|
458
|
+
}),
|
|
459
|
+
jsonApp({
|
|
460
|
+
id: "gemini",
|
|
461
|
+
name: "Gemini CLI",
|
|
462
|
+
widgets: false,
|
|
463
|
+
file: join(home, ".gemini", "settings.json"),
|
|
464
|
+
marker: join(home, ".gemini"),
|
|
465
|
+
at: ["mcpServers"],
|
|
466
|
+
entry: (server) => ({ ...stdioEntry(server), timeout: 120_000 }),
|
|
467
|
+
}),
|
|
468
|
+
goose(),
|
|
469
|
+
openClaw(paths),
|
|
470
|
+
jsonApp({
|
|
471
|
+
id: "lm-studio",
|
|
472
|
+
name: "LM Studio",
|
|
473
|
+
widgets: false,
|
|
474
|
+
file: join(home, ".lmstudio", "mcp.json"),
|
|
475
|
+
marker: join(home, ".lmstudio"),
|
|
476
|
+
at: ["mcpServers"],
|
|
477
|
+
entry: stdioEntry,
|
|
478
|
+
}),
|
|
479
|
+
];
|
|
480
|
+
}
|
|
481
|
+
export async function appStates(paths) {
|
|
482
|
+
return Promise.all(apps(paths).map(async (app) => ({
|
|
483
|
+
id: app.id,
|
|
484
|
+
name: app.name,
|
|
485
|
+
widgets: app.widgets,
|
|
486
|
+
where: app.where,
|
|
487
|
+
installed: app.installed(),
|
|
488
|
+
connected: await app.connected(),
|
|
489
|
+
})));
|
|
490
|
+
}
|
|
491
|
+
/** `wizards connect [app …]` and `disconnect`: what was done, app by app. */
|
|
492
|
+
export async function connectCommand(paths, ids, remove) {
|
|
493
|
+
const unknown = ids.filter((id) => !apps(paths).some((app) => app.id === id));
|
|
494
|
+
if (unknown.length) {
|
|
495
|
+
console.error(`Unknown app ${unknown.join(", ")}. One of: ${apps(paths)
|
|
496
|
+
.map((app) => app.id)
|
|
497
|
+
.join(", ")}`);
|
|
498
|
+
return 2;
|
|
499
|
+
}
|
|
500
|
+
const results = await connectApps(paths, ids, remove);
|
|
501
|
+
if (!results.length) {
|
|
502
|
+
console.log("No AI app found on this machine. Name one: connect cursor");
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
505
|
+
for (const { app, outcome } of results) {
|
|
506
|
+
console.log(`${outcome.ok ? ok : bad} ${app.name.padEnd(22)} ${dim(tilde(outcome.message))}`);
|
|
507
|
+
if (!outcome.ok && outcome.snippet) {
|
|
508
|
+
console.log(outcome.snippet);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!remove && results.some((r) => r.outcome.ok)) {
|
|
512
|
+
console.log("");
|
|
513
|
+
console.log(dim("Restart the app (or reload its MCP servers) to see the wizards in it."));
|
|
514
|
+
console.log(dim("Claude Desktop, Cursor, VS Code and Goose show a run as a widget; the others as text."));
|
|
515
|
+
}
|
|
516
|
+
return results.every((r) => r.outcome.ok) ? 0 : 1;
|
|
517
|
+
}
|
|
518
|
+
/** Connects (or disconnects) the named apps; without names every app found on this machine. */
|
|
519
|
+
export async function connectApps(paths, ids, remove = false) {
|
|
520
|
+
const all = apps(paths);
|
|
521
|
+
const picked = ids.length
|
|
522
|
+
? all.filter((app) => ids.includes(app.id))
|
|
523
|
+
: all.filter((app) => app.installed());
|
|
524
|
+
const server = serverCommand(paths);
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const app of picked) {
|
|
527
|
+
const outcome = await (remove ? app.disconnect() : app.connect(server)).catch((error) => ({ ok: false, message: error.message }));
|
|
528
|
+
out.push({ app, outcome });
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { delimiter, dirname, join } from "node:path";
|
|
1
|
+
import { basename, delimiter, dirname, join } from "node:path";
|
|
2
2
|
/**
|
|
3
3
|
* Variables of the terminal the runtime inherits. Everything else it gets comes from the
|
|
4
4
|
* install's `.env` or is set by the command line. A key or a database address that the shell
|
|
@@ -80,6 +80,8 @@ export function runtimeEnv(paths, start, env = process.env, fileEnv = {}, platfo
|
|
|
80
80
|
APP_URL: start.url,
|
|
81
81
|
DATA_DIR: start.dataDir,
|
|
82
82
|
LOCAL_ACCESS_KEY: start.accessKey,
|
|
83
|
+
// The install the runtime belongs to: the studio connects AI apps to its command.
|
|
84
|
+
...(basename(paths.home) === "wizards" ? { ENGENTY_HOME: dirname(paths.home) } : {}),
|
|
83
85
|
// Keys entered in the settings live in the Keychain, as with the desktop app.
|
|
84
86
|
...(platform === "darwin" && !secretsGiven ? { SECRETS: "keychain" } : {}),
|
|
85
87
|
};
|