@tiens.nguyen/gu-cli 1.0.738 → 1.0.740
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/gonext_agent_chat.py +82 -0
- package/gu-cli.mjs +35 -0
- package/gu-repl.mjs +112 -6
- package/gu_mcp.py +486 -0
- package/gu_skills.py +93 -0
- package/mcp-command.mjs +324 -0
- package/mcp-config.mjs +329 -0
- package/package.json +6 -1
- package/skills.mjs +175 -0
package/mcp-command.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gu mcp …` and `/mcp …` — ONE code path (task #269).
|
|
3
|
+
*
|
|
4
|
+
* list every server, whether it connects, how many tools (the default)
|
|
5
|
+
* tools <name> that server's tools
|
|
6
|
+
* add … `claude mcp add`'s shape; `/mcp add` alone asks step by step
|
|
7
|
+
* remove <name> from your ~/.gonext/mcp.json
|
|
8
|
+
* enable|disable <name> keep it configured, stop starting it
|
|
9
|
+
* approve|unapprove <name> a project's .mcp.json server, for this folder
|
|
10
|
+
* allow <name> [tool] its tools run without asking (all, or one)
|
|
11
|
+
* disallow <name> back to asking
|
|
12
|
+
* reconnect <name> test it again now
|
|
13
|
+
*
|
|
14
|
+
* "Connected" is decided by the same Python client the agent uses (gu_mcp.py probe), so this
|
|
15
|
+
* screen cannot say yes to a server the agent then fails to start.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { resolvePython } from "./platform-tools.mjs";
|
|
21
|
+
import {
|
|
22
|
+
ADD_USAGE,
|
|
23
|
+
addAllowRule,
|
|
24
|
+
addServer,
|
|
25
|
+
approveProjectServer,
|
|
26
|
+
describeServer,
|
|
27
|
+
listServers,
|
|
28
|
+
parseAddArgs,
|
|
29
|
+
readUserConfig,
|
|
30
|
+
removeAllowRules,
|
|
31
|
+
removeServer,
|
|
32
|
+
setDisabled,
|
|
33
|
+
userConfigPath,
|
|
34
|
+
SERVER_NAME_RE,
|
|
35
|
+
} from "./mcp-config.mjs";
|
|
36
|
+
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
export const MCP_SUBCOMMANDS = [
|
|
39
|
+
"list", "tools", "add", "remove", "enable", "disable", "approve", "unapprove", "allow", "disallow", "reconnect",
|
|
40
|
+
];
|
|
41
|
+
export const MCP_USAGE = `gu mcp [${MCP_SUBCOMMANDS.join("|")}] …`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Probe servers with the agent's own client. → { [name]: { ok, ms, tools?, error? } }
|
|
45
|
+
* A probe that cannot run at all (no python) reports that for every server rather than throwing.
|
|
46
|
+
*/
|
|
47
|
+
export async function probeServers(servers, { cwd, timeoutS = 20 } = {}) {
|
|
48
|
+
if (!servers.length) return {};
|
|
49
|
+
const py = await resolvePython();
|
|
50
|
+
const req = JSON.stringify({
|
|
51
|
+
servers: servers.map(({ name, command, args, env }) => ({ name, command, args, env })),
|
|
52
|
+
cwd,
|
|
53
|
+
timeout: timeoutS,
|
|
54
|
+
});
|
|
55
|
+
return await new Promise((resolveP) => {
|
|
56
|
+
let out = "";
|
|
57
|
+
let err = "";
|
|
58
|
+
let child;
|
|
59
|
+
const fail = (why) => resolveP(Object.fromEntries(servers.map((s) => [s.name, { ok: false, error: why }])));
|
|
60
|
+
try {
|
|
61
|
+
child = spawn(py.cmd, [...py.args, join(HERE, "gu_mcp.py"), "probe"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
fail(`could not run python: ${e.message}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), (timeoutS + 10) * 1000);
|
|
67
|
+
child.stdout.on("data", (d) => (out += d));
|
|
68
|
+
child.stderr.on("data", (d) => (err += d));
|
|
69
|
+
child.on("error", (e) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
fail(`could not run python: ${e.message}`);
|
|
72
|
+
});
|
|
73
|
+
child.on("close", () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
try {
|
|
76
|
+
resolveP(JSON.parse(out).results ?? {});
|
|
77
|
+
} catch {
|
|
78
|
+
fail(`the probe did not answer${err.trim() ? `: ${err.trim().split("\n").at(-1)}` : ""}`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
child.stdin.end(req);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Split a typed command line into words: spaces separate, quotes group, backslash escapes. */
|
|
86
|
+
export function splitWords(line) {
|
|
87
|
+
const words = [];
|
|
88
|
+
let cur = "";
|
|
89
|
+
let q = null;
|
|
90
|
+
let any = false;
|
|
91
|
+
const s = String(line ?? "");
|
|
92
|
+
for (let i = 0; i < s.length; i++) {
|
|
93
|
+
const c = s[i];
|
|
94
|
+
if (q) {
|
|
95
|
+
if (c === q) q = null;
|
|
96
|
+
else if (c === "\\" && q === '"' && i + 1 < s.length) cur += s[++i];
|
|
97
|
+
else cur += c;
|
|
98
|
+
} else if (c === '"' || c === "'") {
|
|
99
|
+
q = c;
|
|
100
|
+
any = true;
|
|
101
|
+
} else if (c === "\\" && i + 1 < s.length) {
|
|
102
|
+
cur += s[++i];
|
|
103
|
+
any = true;
|
|
104
|
+
} else if (/\s/.test(c)) {
|
|
105
|
+
if (cur || any) words.push(cur);
|
|
106
|
+
cur = "";
|
|
107
|
+
any = false;
|
|
108
|
+
} else {
|
|
109
|
+
cur += c;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (cur || any) words.push(cur);
|
|
113
|
+
return words;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function statusLine(s, probe) {
|
|
117
|
+
const pad = s.name.padEnd(22);
|
|
118
|
+
if (s.error) return `✗ ${pad} ${s.scope.padEnd(7)} ${s.error}`;
|
|
119
|
+
if (s.disabled) return `– ${pad} ${s.scope.padEnd(7)} disabled — /mcp enable ${s.name}`;
|
|
120
|
+
if (!s.approved) return `○ ${pad} ${s.scope.padEnd(7)} not approved for this folder — /mcp approve ${s.name}`;
|
|
121
|
+
if (!probe) return `· ${pad} ${s.scope.padEnd(7)} not checked`;
|
|
122
|
+
if (probe.ok) return `✓ ${pad} ${s.scope.padEnd(7)} connected · ${probe.tools.length} tools (${(probe.ms / 1000).toFixed(1)}s)`;
|
|
123
|
+
return `✗ ${pad} ${s.scope.padEnd(7)} failed: ${probe.error}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run one command. `print(line, kind?)` writes a line (kind: "ok" | "warn" | "dim" | undefined);
|
|
128
|
+
* `ask(label)` → the typed answer ("" = Enter), only given in the terminal UI, where it makes
|
|
129
|
+
* `add` interactive. → an exit code (0 ok, 1 failed, 2 usage).
|
|
130
|
+
*/
|
|
131
|
+
export async function runMcpCommand(words, { cwd = process.cwd(), print = console.log, ask = null, probe = probeServers } = {}) {
|
|
132
|
+
const w = (words ?? []).map(String);
|
|
133
|
+
const sub = (w[0] ?? "list").toLowerCase();
|
|
134
|
+
const rest = w.slice(1);
|
|
135
|
+
const needName = () => {
|
|
136
|
+
const n = rest[0];
|
|
137
|
+
if (!n) {
|
|
138
|
+
print(`which server? — gu mcp ${sub} <name>`, "warn");
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return n;
|
|
142
|
+
};
|
|
143
|
+
const known = (name) => listServers({ cwd }).servers.find((s) => s.name === name);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
switch (sub) {
|
|
147
|
+
case "list":
|
|
148
|
+
case "ls": {
|
|
149
|
+
const { servers, warnings } = listServers({ cwd });
|
|
150
|
+
for (const x of warnings) print(x, "warn");
|
|
151
|
+
if (!servers.length) {
|
|
152
|
+
print("No MCP servers yet.");
|
|
153
|
+
print(`Add one: /mcp add (or in a shell: ${ADD_USAGE})`, "dim");
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const results = await probe(servers.filter((s) => s.runnable), { cwd });
|
|
157
|
+
print(`MCP servers — yours in ${userConfigPath()}, this folder's in ./.mcp.json`, "dim");
|
|
158
|
+
for (const s of servers) {
|
|
159
|
+
print(statusLine(s, results[s.name]), results[s.name]?.ok ? "ok" : undefined);
|
|
160
|
+
if (!s.error) print(` ${describeServer(s)}`, "dim");
|
|
161
|
+
}
|
|
162
|
+
const { allow, deny } = readUserConfig();
|
|
163
|
+
if (allow.length) print(`Runs without asking: ${allow.join(", ")}`, "dim");
|
|
164
|
+
if (deny.length) print(`Blocked: ${deny.join(", ")}`, "dim");
|
|
165
|
+
print("Tools of one: /mcp tools <name>", "dim");
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
case "tools":
|
|
169
|
+
case "reconnect": {
|
|
170
|
+
const name = needName();
|
|
171
|
+
if (!name) return 2;
|
|
172
|
+
const s = known(name);
|
|
173
|
+
if (!s) {
|
|
174
|
+
print(`No MCP server named "${name}". /mcp lists them.`, "warn");
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
if (!s.runnable) {
|
|
178
|
+
print(statusLine(s, null), "warn");
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
const r = (await probe([s], { cwd }))[name];
|
|
182
|
+
print(statusLine(s, r), r?.ok ? "ok" : "warn");
|
|
183
|
+
if (r?.ok && sub === "tools") {
|
|
184
|
+
for (const t of r.tools) print(` ${t.name}${t.description ? ` — ${t.description}` : ""}`, "dim");
|
|
185
|
+
}
|
|
186
|
+
return r?.ok ? 0 : 1;
|
|
187
|
+
}
|
|
188
|
+
case "add": {
|
|
189
|
+
let parsed;
|
|
190
|
+
if (!rest.length && ask) parsed = await askForServer(ask, print);
|
|
191
|
+
else parsed = parseAddArgs(rest);
|
|
192
|
+
if (!parsed) return 0; // cancelled
|
|
193
|
+
if (parsed.error) {
|
|
194
|
+
print(parsed.error, "warn");
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
if (known(parsed.name)) {
|
|
198
|
+
if (!ask) {
|
|
199
|
+
print(`"${parsed.name}" already exists — gu mcp remove ${parsed.name} first`, "warn");
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
const yes = /^y/i.test(await ask(`"${parsed.name}" already exists. Replace it? (y/N)`));
|
|
203
|
+
if (!yes) return 0;
|
|
204
|
+
}
|
|
205
|
+
const { file } = addServer({ ...parsed, cwd });
|
|
206
|
+
print(`Added "${parsed.name}" to ${file}.`, "ok");
|
|
207
|
+
const s = known(parsed.name);
|
|
208
|
+
const r = (await probe([s], { cwd }))[parsed.name];
|
|
209
|
+
print(statusLine(s, r), r?.ok ? "ok" : "warn");
|
|
210
|
+
if (!r?.ok) {
|
|
211
|
+
print("It is saved; fix the command and run /mcp reconnect " + parsed.name, "dim");
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
// You added it yourself: offer to trust its tools now, rather than one question per tool later.
|
|
215
|
+
if (ask) {
|
|
216
|
+
const yes = /^y/i.test(await ask(`Let the agent use its ${r.tools.length} tools without asking each time? (y/N)`));
|
|
217
|
+
if (yes) {
|
|
218
|
+
addAllowRule({ rule: `mcp__${parsed.name}__*` });
|
|
219
|
+
print(`Its tools run without asking. Undo: /mcp disallow ${parsed.name}`, "dim");
|
|
220
|
+
} else {
|
|
221
|
+
print("The agent will ask before each tool the first time it uses it.", "dim");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
case "remove":
|
|
227
|
+
case "rm": {
|
|
228
|
+
const name = needName();
|
|
229
|
+
if (!name) return 2;
|
|
230
|
+
const s = known(name);
|
|
231
|
+
if (s?.scope === "project") {
|
|
232
|
+
approveProjectServer({ name, cwd, approve: false });
|
|
233
|
+
print(`"${name}" is this project's (./.mcp.json) — it will not start here any more. Edit that file to delete it.`);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
const found = removeServer({ name });
|
|
237
|
+
print(found ? `Removed "${name}".` : `No MCP server named "${name}" in ${userConfigPath()}.`, found ? "ok" : "warn");
|
|
238
|
+
return found ? 0 : 1;
|
|
239
|
+
}
|
|
240
|
+
case "enable":
|
|
241
|
+
case "disable": {
|
|
242
|
+
const name = needName();
|
|
243
|
+
if (!name) return 2;
|
|
244
|
+
if (!known(name)) {
|
|
245
|
+
print(`No MCP server named "${name}".`, "warn");
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
setDisabled({ name, disabled: sub === "disable" });
|
|
249
|
+
print(sub === "disable" ? `"${name}" will not start (still configured).` : `"${name}" will start on the next turn.`, "ok");
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
case "approve":
|
|
253
|
+
case "unapprove": {
|
|
254
|
+
const name = needName();
|
|
255
|
+
if (!name) return 2;
|
|
256
|
+
const s = known(name);
|
|
257
|
+
if (!s || s.scope !== "project") {
|
|
258
|
+
print(`No project MCP server named "${name}" in ./.mcp.json.`, "warn");
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
if (sub === "approve") print(`It runs: ${describeServer(s)}`, "dim");
|
|
262
|
+
approveProjectServer({ name, cwd, approve: sub === "approve" });
|
|
263
|
+
print(sub === "approve" ? `Approved "${name}" for this folder.` : `"${name}" will not start in this folder.`, "ok");
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
case "allow": {
|
|
267
|
+
const name = needName();
|
|
268
|
+
if (!name) return 2;
|
|
269
|
+
if (!SERVER_NAME_RE.test(name)) {
|
|
270
|
+
print(`"${name}" is not a server name.`, "warn");
|
|
271
|
+
return 2;
|
|
272
|
+
}
|
|
273
|
+
const tool = rest[1];
|
|
274
|
+
const rule = tool ? `mcp__${name}__${tool}` : `mcp__${name}__*`;
|
|
275
|
+
addAllowRule({ rule });
|
|
276
|
+
print(`${tool ? `${name}/${tool}` : `Every tool of ${name}`} runs without asking. Undo: /mcp disallow ${name}`, "ok");
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
case "disallow": {
|
|
280
|
+
const name = needName();
|
|
281
|
+
if (!name) return 2;
|
|
282
|
+
removeAllowRules({ server: name });
|
|
283
|
+
print(`The agent asks again before using ${name}'s tools.`, "ok");
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
default:
|
|
287
|
+
print(`unknown "${sub}" — ${MCP_USAGE}`, "warn");
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
} catch (e) {
|
|
291
|
+
print(e?.message || String(e), "warn");
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** `/mcp add` with nothing after it: one question at a time. "" at the name = cancel. */
|
|
297
|
+
async function askForServer(ask, print) {
|
|
298
|
+
print("Add an MCP server (stdio). Enter on an empty name cancels.", "dim");
|
|
299
|
+
const name = (await ask("Name (letters, digits, - or _):")).trim();
|
|
300
|
+
if (!name) return null;
|
|
301
|
+
if (!SERVER_NAME_RE.test(name)) return { error: `"${name}" is not a valid name — use letters, digits, _ or -` };
|
|
302
|
+
const line = (await ask("Command and its arguments (e.g. npx -y @modelcontextprotocol/server-filesystem ~/code):")).trim();
|
|
303
|
+
const words = splitWords(line);
|
|
304
|
+
if (!words.length) return { error: "no command given" };
|
|
305
|
+
const envLine = (await ask("Environment variables, KEY=value separated by spaces (Enter for none):")).trim();
|
|
306
|
+
const env = {};
|
|
307
|
+
for (const kv of splitWords(envLine)) {
|
|
308
|
+
const eq = kv.indexOf("=");
|
|
309
|
+
if (eq <= 0) return { error: `"${kv}" is not KEY=value` };
|
|
310
|
+
env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
311
|
+
}
|
|
312
|
+
const scopeAns = (await ask("For every folder, or only this project (./.mcp.json)? [every/project] (Enter = every):")).trim().toLowerCase();
|
|
313
|
+
const scope = scopeAns.startsWith("p") ? "project" : "user";
|
|
314
|
+
return { name, command: words[0], args: words.slice(1), env, scope };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Project servers waiting for approval in this folder — the terminal mentions them at start. */
|
|
318
|
+
export function unapprovedProjectServers(cwd) {
|
|
319
|
+
try {
|
|
320
|
+
return listServers({ cwd }).servers.filter((s) => s.scope === "project" && !s.approved && !s.error);
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
}
|
package/mcp-config.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP SERVERS — where they are configured, and who may call their tools (task #269).
|
|
3
|
+
*
|
|
4
|
+
* ~/.gonext/mcp.json yours: { mcpServers, disabled, permissions: { allow, deny }, approvedProjects }
|
|
5
|
+
* <project>/.mcp.json the project's — Claude Code's own file and format, so one file serves both
|
|
6
|
+
*
|
|
7
|
+
* The shapes are Claude Code's: `mcpServers: { name: { type: "stdio", command, args, env } }`, and
|
|
8
|
+
* permission rules `mcp__<server>` / `mcp__<server>__*` / `mcp__<server>__<tool>`. A command copied
|
|
9
|
+
* from an MCP server's README (`claude mcp add …`) works as `gu mcp add …`.
|
|
10
|
+
*
|
|
11
|
+
* A PROJECT'S SERVERS DO NOT START UNTIL YOU APPROVE THEM. Starting a stdio server runs a command,
|
|
12
|
+
* and a project's .mcp.json came with the repository — cloning something is not agreeing to run
|
|
13
|
+
* what it names. Claude Code asks before connecting them for the same reason. Approval is per
|
|
14
|
+
* folder and per server, and lives in YOUR file, never in the project's.
|
|
15
|
+
*
|
|
16
|
+
* PERMISSIONS: deny, then ask, then allow — the first rule that matches decides, and a deny can
|
|
17
|
+
* never be carved open by a narrower allow (Claude Code's order). No rule = ask. A server saying a
|
|
18
|
+
* tool is read-only (readOnlyHint) is NOT a rule: the MCP spec says clients must treat those hints
|
|
19
|
+
* as untrusted, and a hostile server would simply claim it.
|
|
20
|
+
*
|
|
21
|
+
* ONE WRITER SHAPE. Both `gu mcp` and `/mcp` go through updateUserConfig, which re-reads the file,
|
|
22
|
+
* changes one thing and writes it back atomically — keys it does not know are kept. Rebuilding the
|
|
23
|
+
* object from what one side knows is how a settings screen once deleted the other side's entries.
|
|
24
|
+
*/
|
|
25
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { dirname, join, resolve } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
30
|
+
|
|
31
|
+
export function userConfigPath(home = homedir()) {
|
|
32
|
+
return join(home, ".gonext", "mcp.json");
|
|
33
|
+
}
|
|
34
|
+
export function projectConfigPath(cwd) {
|
|
35
|
+
return join(cwd, ".mcp.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJson(file) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = readFileSync(file, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
return { data: null }; // no file: nothing configured, not an error
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(text);
|
|
47
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return { data: null, error: "not a JSON object" };
|
|
48
|
+
return { data };
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { data: null, error: `not valid JSON (${e.message})` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readUserConfig(home = homedir()) {
|
|
55
|
+
const { data, error } = readJson(userConfigPath(home));
|
|
56
|
+
const cfg = data ?? {};
|
|
57
|
+
return {
|
|
58
|
+
raw: cfg,
|
|
59
|
+
error,
|
|
60
|
+
mcpServers: isObj(cfg.mcpServers) ? cfg.mcpServers : {},
|
|
61
|
+
disabled: Array.isArray(cfg.disabled) ? cfg.disabled.map(String) : [],
|
|
62
|
+
allow: Array.isArray(cfg.permissions?.allow) ? cfg.permissions.allow.map(String) : [],
|
|
63
|
+
deny: Array.isArray(cfg.permissions?.deny) ? cfg.permissions.deny.map(String) : [],
|
|
64
|
+
approvedProjects: isObj(cfg.approvedProjects) ? cfg.approvedProjects : {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
69
|
+
|
|
70
|
+
/** Write ~/.gonext/mcp.json atomically: a crash mid-write leaves the old file, never half of one. */
|
|
71
|
+
function writeUserRaw(raw, home) {
|
|
72
|
+
const file = userConfigPath(home);
|
|
73
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
74
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
75
|
+
writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
76
|
+
renameSync(tmp, file);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-read, change, write back. `mutate(raw)` edits the parsed file in place. A file that is not
|
|
81
|
+
* valid JSON is refused rather than overwritten — it may be hand-edited and one typo from fine.
|
|
82
|
+
*/
|
|
83
|
+
export function updateUserConfig(mutate, home = homedir()) {
|
|
84
|
+
const { data, error } = readJson(userConfigPath(home));
|
|
85
|
+
if (error) throw new Error(`${userConfigPath(home)} is ${error} — fix or remove it first`);
|
|
86
|
+
const raw = data ?? {};
|
|
87
|
+
mutate(raw);
|
|
88
|
+
writeUserRaw(raw, home);
|
|
89
|
+
return raw;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Normalize one server entry → { type, command, args, env } | { error }. */
|
|
93
|
+
export function normalizeServer(entry) {
|
|
94
|
+
if (!isObj(entry)) return { error: "not an object" };
|
|
95
|
+
const type = String(entry.type ?? (entry.url ? "http" : "stdio"));
|
|
96
|
+
if (type !== "stdio") return { error: `transport "${type}" is not supported yet — only stdio` };
|
|
97
|
+
const command = String(entry.command ?? "").trim();
|
|
98
|
+
if (!command) return { error: "no command" };
|
|
99
|
+
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
100
|
+
const env = isObj(entry.env) ? Object.fromEntries(Object.entries(entry.env).map(([k, v]) => [k, String(v)])) : {};
|
|
101
|
+
return { type, command, args, env };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every server this folder knows of, with its state.
|
|
106
|
+
* → { servers: [{ name, scope, type, command, args, env, disabled, approved, runnable, error? }], warnings }
|
|
107
|
+
*/
|
|
108
|
+
export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
|
|
109
|
+
const user = readUserConfig(home);
|
|
110
|
+
const warnings = [];
|
|
111
|
+
if (user.error) warnings.push(`${userConfigPath(home)} is ${user.error} — its servers are skipped`);
|
|
112
|
+
const servers = [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const add = (name, entry, scope, approved) => {
|
|
115
|
+
if (!SERVER_NAME_RE.test(name)) {
|
|
116
|
+
warnings.push(`${scope} MCP server "${name}": the name must be letters, digits, _ or - — skipped`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (seen.has(name)) return; // yours wins over the project's
|
|
120
|
+
seen.add(name);
|
|
121
|
+
const n = normalizeServer(entry);
|
|
122
|
+
const disabled = user.disabled.includes(name);
|
|
123
|
+
servers.push({
|
|
124
|
+
name,
|
|
125
|
+
scope,
|
|
126
|
+
...(n.error ? { error: n.error } : n),
|
|
127
|
+
disabled,
|
|
128
|
+
approved,
|
|
129
|
+
runnable: !n.error && !disabled && approved,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
for (const [name, entry] of Object.entries(user.mcpServers)) add(name, entry, "user", true);
|
|
133
|
+
if (cwd) {
|
|
134
|
+
const proj = readJson(projectConfigPath(cwd));
|
|
135
|
+
if (proj.error) warnings.push(`${projectConfigPath(cwd)} is ${proj.error} — its servers are skipped`);
|
|
136
|
+
const approvedHere = (user.approvedProjects[resolve(cwd)] ?? []).map(String);
|
|
137
|
+
for (const [name, entry] of Object.entries(isObj(proj.data?.mcpServers) ? proj.data.mcpServers : {})) {
|
|
138
|
+
add(name, entry, "project", approvedHere.includes(name));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { servers, warnings };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What the worker hands the agent for one turn: the servers it may start, and the rules. */
|
|
145
|
+
export function mcpForTurn({ cwd, home = homedir() } = {}) {
|
|
146
|
+
const { servers } = listServers({ cwd, home });
|
|
147
|
+
const user = readUserConfig(home);
|
|
148
|
+
return {
|
|
149
|
+
servers: servers
|
|
150
|
+
.filter((s) => s.runnable)
|
|
151
|
+
.map(({ name, command, args, env, scope }) => ({ name, command, args, env, scope })),
|
|
152
|
+
allow: user.allow,
|
|
153
|
+
deny: user.deny,
|
|
154
|
+
configPath: userConfigPath(home),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ---------------------------------------------------------------- permissions ---------- */
|
|
159
|
+
|
|
160
|
+
/** Does a rule name this server's tool? `mcp__s`, `mcp__s__*`, `mcp__s__tool`, `mcp__s__get_*`, `mcp__*`. */
|
|
161
|
+
export function ruleMatches(rule, server, tool) {
|
|
162
|
+
const r = String(rule).trim();
|
|
163
|
+
if (r === "mcp__*" || r === "*") return true;
|
|
164
|
+
if (r === `mcp__${server}`) return true;
|
|
165
|
+
const full = `mcp__${server}__${tool}`;
|
|
166
|
+
if (!r.includes("*")) return r === full;
|
|
167
|
+
const re = new RegExp("^" + r.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
|
|
168
|
+
return re.test(full);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** → "deny" | "allow" | "ask". Deny first, then allow; nothing matching = ask. */
|
|
172
|
+
export function decide({ allow = [], deny = [] }, server, tool) {
|
|
173
|
+
if (deny.some((r) => ruleMatches(r, server, tool))) return "deny";
|
|
174
|
+
if (allow.some((r) => ruleMatches(r, server, tool))) return "allow";
|
|
175
|
+
return "ask";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---------------------------------------------------------------- changes -------------- */
|
|
179
|
+
|
|
180
|
+
export function addServer({ name, command, args = [], env = {}, scope = "user", cwd, home = homedir() }) {
|
|
181
|
+
if (!SERVER_NAME_RE.test(name ?? "")) throw new Error(`"${name}" is not a valid name — use letters, digits, _ or -`);
|
|
182
|
+
if (!String(command ?? "").trim()) throw new Error("no command given");
|
|
183
|
+
const entry = { type: "stdio", command: String(command), args: args.map(String), env };
|
|
184
|
+
if (scope === "project") {
|
|
185
|
+
if (!cwd) throw new Error("no project folder");
|
|
186
|
+
const file = projectConfigPath(cwd);
|
|
187
|
+
const { data, error } = readJson(file);
|
|
188
|
+
if (error) throw new Error(`${file} is ${error} — fix or remove it first`);
|
|
189
|
+
const raw = data ?? {};
|
|
190
|
+
raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
|
|
191
|
+
raw.mcpServers[name] = entry;
|
|
192
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
193
|
+
writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", "utf8");
|
|
194
|
+
renameSync(tmp, file);
|
|
195
|
+
// You added it yourself, from this folder: that IS the approval.
|
|
196
|
+
approveProjectServer({ name, cwd, home });
|
|
197
|
+
return { file };
|
|
198
|
+
}
|
|
199
|
+
updateUserConfig((raw) => {
|
|
200
|
+
raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
|
|
201
|
+
raw.mcpServers[name] = entry;
|
|
202
|
+
if (Array.isArray(raw.disabled)) raw.disabled = raw.disabled.filter((d) => d !== name);
|
|
203
|
+
}, home);
|
|
204
|
+
return { file: userConfigPath(home) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Remove from YOUR file. A project's server cannot be removed from here — only not approved. */
|
|
208
|
+
export function removeServer({ name, home = homedir() }) {
|
|
209
|
+
let found = false;
|
|
210
|
+
updateUserConfig((raw) => {
|
|
211
|
+
if (isObj(raw.mcpServers) && name in raw.mcpServers) {
|
|
212
|
+
delete raw.mcpServers[name];
|
|
213
|
+
found = true;
|
|
214
|
+
}
|
|
215
|
+
if (Array.isArray(raw.disabled)) raw.disabled = raw.disabled.filter((d) => d !== name);
|
|
216
|
+
if (isObj(raw.permissions)) {
|
|
217
|
+
for (const k of ["allow", "deny"]) {
|
|
218
|
+
if (Array.isArray(raw.permissions[k])) {
|
|
219
|
+
raw.permissions[k] = raw.permissions[k].filter((r) => !(r === `mcp__${name}` || String(r).startsWith(`mcp__${name}__`)));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}, home);
|
|
224
|
+
return found;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function setDisabled({ name, disabled, home = homedir() }) {
|
|
228
|
+
updateUserConfig((raw) => {
|
|
229
|
+
const list = Array.isArray(raw.disabled) ? raw.disabled.filter((d) => d !== name) : [];
|
|
230
|
+
if (disabled) list.push(name);
|
|
231
|
+
raw.disabled = list;
|
|
232
|
+
}, home);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function approveProjectServer({ name, cwd, home = homedir(), approve = true }) {
|
|
236
|
+
const key = resolve(cwd);
|
|
237
|
+
updateUserConfig((raw) => {
|
|
238
|
+
raw.approvedProjects = isObj(raw.approvedProjects) ? raw.approvedProjects : {};
|
|
239
|
+
const list = (raw.approvedProjects[key] ?? []).filter((n) => n !== name);
|
|
240
|
+
if (approve) list.push(name);
|
|
241
|
+
raw.approvedProjects[key] = list;
|
|
242
|
+
}, home);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Add an allow rule (no duplicates). Used by "Always allow" at the approval prompt. */
|
|
246
|
+
export function addAllowRule({ rule, home = homedir() }) {
|
|
247
|
+
updateUserConfig((raw) => {
|
|
248
|
+
raw.permissions = isObj(raw.permissions) ? raw.permissions : {};
|
|
249
|
+
const allow = Array.isArray(raw.permissions.allow) ? raw.permissions.allow : [];
|
|
250
|
+
if (!allow.includes(rule)) allow.push(rule);
|
|
251
|
+
raw.permissions.allow = allow;
|
|
252
|
+
}, home);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function removeAllowRules({ server, home = homedir() }) {
|
|
256
|
+
updateUserConfig((raw) => {
|
|
257
|
+
if (isObj(raw.permissions) && Array.isArray(raw.permissions.allow)) {
|
|
258
|
+
raw.permissions.allow = raw.permissions.allow.filter(
|
|
259
|
+
(r) => !(r === `mcp__${server}` || String(r).startsWith(`mcp__${server}__`))
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}, home);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ---------------------------------------------------------------- display -------------- */
|
|
266
|
+
|
|
267
|
+
const SECRET_KEY_RE = /TOKEN|SECRET|PASSWORD|PASSWD|KEY|AUTH|CREDENTIAL/i;
|
|
268
|
+
|
|
269
|
+
/** Env for display: values of secret-looking names become "****" + last 2. */
|
|
270
|
+
export function maskEnv(env = {}) {
|
|
271
|
+
return Object.fromEntries(
|
|
272
|
+
Object.entries(env).map(([k, v]) => [k, SECRET_KEY_RE.test(k) ? (String(v).length > 6 ? `****${String(v).slice(-2)}` : "****") : v])
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** One line naming the command, with secret env values masked. */
|
|
277
|
+
export function describeServer(s) {
|
|
278
|
+
const env = Object.entries(maskEnv(s.env ?? {})).map(([k, v]) => `${k}=${v}`);
|
|
279
|
+
return [...env, s.command, ...(s.args ?? [])].map((w) => (/\s/.test(w) ? JSON.stringify(w) : w)).join(" ");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* ---------------------------------------------------------------- `add` arguments ------ */
|
|
283
|
+
|
|
284
|
+
export const ADD_USAGE =
|
|
285
|
+
"gu mcp add [--scope user|project] [-e KEY=value …] <name> -- <command> [args…]";
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `claude mcp add`'s shape: options, then the name, then `--`, then the command. Without a `--`
|
|
289
|
+
* the first word after the name starts the command (a common way people type it).
|
|
290
|
+
* → { name, command, args, env, scope } | { error }
|
|
291
|
+
*/
|
|
292
|
+
export function parseAddArgs(words) {
|
|
293
|
+
const w = (words ?? []).map(String);
|
|
294
|
+
const dash = w.indexOf("--");
|
|
295
|
+
const head = dash >= 0 ? w.slice(0, dash) : w;
|
|
296
|
+
let tail = dash >= 0 ? w.slice(dash + 1) : [];
|
|
297
|
+
const env = {};
|
|
298
|
+
let scope = "user";
|
|
299
|
+
let name = null;
|
|
300
|
+
for (let i = 0; i < head.length; i++) {
|
|
301
|
+
const a = head[i];
|
|
302
|
+
if (a === "-e" || a === "--env") {
|
|
303
|
+
const kv = head[++i] ?? "";
|
|
304
|
+
const eq = kv.indexOf("=");
|
|
305
|
+
if (eq <= 0) return { error: `-e needs KEY=value, got "${kv}" — ${ADD_USAGE}` };
|
|
306
|
+
env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
307
|
+
} else if (a === "-s" || a === "--scope") {
|
|
308
|
+
scope = head[++i] ?? "";
|
|
309
|
+
if (scope === "local") scope = "user"; // Claude Code's default scope; gu's user file is the same idea
|
|
310
|
+
if (!["user", "project"].includes(scope)) return { error: `--scope must be user or project — ${ADD_USAGE}` };
|
|
311
|
+
} else if (a === "-t" || a === "--transport") {
|
|
312
|
+
const t = head[++i] ?? "";
|
|
313
|
+
if (t !== "stdio") return { error: `transport "${t}" is not supported yet — only stdio` };
|
|
314
|
+
} else if (a.startsWith("-")) {
|
|
315
|
+
return { error: `unknown option ${a} — ${ADD_USAGE}` };
|
|
316
|
+
} else if (name === null) {
|
|
317
|
+
name = a;
|
|
318
|
+
} else if (dash < 0) {
|
|
319
|
+
tail = head.slice(i);
|
|
320
|
+
break;
|
|
321
|
+
} else {
|
|
322
|
+
return { error: `unexpected "${a}" before -- — ${ADD_USAGE}` };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!name) return { error: `no name — ${ADD_USAGE}` };
|
|
326
|
+
if (!SERVER_NAME_RE.test(name)) return { error: `"${name}" is not a valid name — use letters, digits, _ or -` };
|
|
327
|
+
if (!tail.length) return { error: `no command — ${ADD_USAGE}` };
|
|
328
|
+
return { name, command: tail[0], args: tail.slice(1), env, scope };
|
|
329
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.740",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -89,8 +89,13 @@
|
|
|
89
89
|
"add-server.mjs",
|
|
90
90
|
"powershell-alias.mjs",
|
|
91
91
|
"install-path.mjs",
|
|
92
|
+
"skills.mjs",
|
|
93
|
+
"mcp-config.mjs",
|
|
94
|
+
"mcp-command.mjs",
|
|
92
95
|
"gonext_probe_agent.py",
|
|
93
96
|
"gonext_agent_chat.py",
|
|
97
|
+
"gu_mcp.py",
|
|
98
|
+
"gu_skills.py",
|
|
94
99
|
"prompt_scaffold_check.py",
|
|
95
100
|
"gonext_transcribe.py",
|
|
96
101
|
"gonext_mlx_embed.py",
|