@tiens.nguyen/gu-cli 1.0.739 → 1.0.741
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 +126 -3
- package/gu-cli.mjs +58 -0
- package/gu-repl.mjs +173 -9
- package/gu_hooks.py +231 -0
- package/gu_mcp.py +486 -0
- package/gu_skills.py +93 -0
- package/hooks-command.mjs +71 -0
- package/hooks-config.mjs +172 -0
- package/mcp-command.mjs +324 -0
- package/mcp-config.mjs +329 -0
- package/package.json +9 -1
- package/skills.mjs +175 -0
- package/terminal-layout.mjs +48 -0
package/hooks-config.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOOKS — commands gu runs at fixed moments of a turn (task #270). Where they are set, and which
|
|
3
|
+
* ones a turn gets.
|
|
4
|
+
*
|
|
5
|
+
* ~/.gonext/settings.json yours, every folder
|
|
6
|
+
* <project>/.gonext/settings.json this project's — run only once you approve them here
|
|
7
|
+
*
|
|
8
|
+
* { "hooks": { "<event>": [ { "matcher": "edit_file|create_file",
|
|
9
|
+
* "hooks": [ { "type": "command", "command": "python3",
|
|
10
|
+
* "args": ["/path/hook.py"], "timeout": 30 } ] } ] } }
|
|
11
|
+
*
|
|
12
|
+
* gu's OWN event names — onStart, onAsk, onToolDone, onTurnEnd — and gu's own tool names in
|
|
13
|
+
* matchers (edit_file, grep_repo, mcp__<server>__<tool>). Claude Code's names (SessionStart, …) are
|
|
14
|
+
* recognised only to say what to write instead: a hook filed under a name gu never fires would
|
|
15
|
+
* otherwise fail in silence.
|
|
16
|
+
*
|
|
17
|
+
* A PROJECT'S HOOKS ARE COMMANDS THAT CAME WITH THE REPOSITORY, so they never run until you approve
|
|
18
|
+
* them for that folder — and the approval is of their exact content (a hash): an edited hooks
|
|
19
|
+
* block needs approving again. The approval lives in YOUR settings file, never the project's.
|
|
20
|
+
*
|
|
21
|
+
* THE SESSION. onStart fires on the first turn of a gu session. The terminal writes a new session
|
|
22
|
+
* id into the folder's session file when it starts (and on /reset); sessionForTurn reads it back so
|
|
23
|
+
* the agent can tell "new session" from "next turn" with nothing added to the API.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { dirname, join, resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
export const HOOK_EVENTS = ["onStart", "onAsk", "onToolDone", "onTurnEnd"];
|
|
31
|
+
/** Claude Code's names → gu's. Only used to tell the user what to rename. */
|
|
32
|
+
export const CLAUDE_EVENT_NAMES = {
|
|
33
|
+
SessionStart: "onStart",
|
|
34
|
+
UserPromptSubmit: "onAsk",
|
|
35
|
+
PostToolUse: "onToolDone",
|
|
36
|
+
Stop: "onTurnEnd",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const userSettingsPath = (home = homedir()) => join(home, ".gonext", "settings.json");
|
|
40
|
+
export const projectSettingsPath = (cwd) => join(cwd, ".gonext", "settings.json");
|
|
41
|
+
|
|
42
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
43
|
+
|
|
44
|
+
function readJson(file) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return { data: null };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(text);
|
|
53
|
+
return isObj(data) ? { data } : { data: null, error: "not a JSON object" };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { data: null, error: `not valid JSON (${e.message})` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A settings file's hooks, cleaned: only gu events, only command hooks. → { hooks, warnings } */
|
|
60
|
+
export function cleanHooks(raw, where) {
|
|
61
|
+
const hooks = {};
|
|
62
|
+
const warnings = [];
|
|
63
|
+
if (!isObj(raw)) return { hooks, warnings };
|
|
64
|
+
for (const [event, groups] of Object.entries(raw)) {
|
|
65
|
+
if (!HOOK_EVENTS.includes(event)) {
|
|
66
|
+
const gu = CLAUDE_EVENT_NAMES[event];
|
|
67
|
+
warnings.push(
|
|
68
|
+
gu
|
|
69
|
+
? `${where}: "${event}" is Claude Code's name — gu calls it "${gu}". Skipped until renamed.`
|
|
70
|
+
: `${where}: "${event}" is not a gu hook event (${HOOK_EVENTS.join(", ")}). Skipped.`
|
|
71
|
+
);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const kept = [];
|
|
75
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
76
|
+
if (!isObj(g)) continue;
|
|
77
|
+
const hs = (Array.isArray(g.hooks) ? g.hooks : [])
|
|
78
|
+
.filter((h) => isObj(h) && (h.type ?? "command") === "command" && String(h.command ?? "").trim())
|
|
79
|
+
.map((h) => ({
|
|
80
|
+
type: "command",
|
|
81
|
+
command: String(h.command),
|
|
82
|
+
...(Array.isArray(h.args) ? { args: h.args.map(String) } : {}),
|
|
83
|
+
...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
|
|
84
|
+
...(isObj(h.env) ? { env: Object.fromEntries(Object.entries(h.env).map(([k, v]) => [k, String(v)])) } : {}),
|
|
85
|
+
}));
|
|
86
|
+
if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs });
|
|
87
|
+
}
|
|
88
|
+
if (kept.length) hooks[event] = kept;
|
|
89
|
+
}
|
|
90
|
+
return { hooks, warnings };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The exact content being approved — any edit changes it. */
|
|
94
|
+
export function hooksFingerprint(hooks) {
|
|
95
|
+
return createHash("sha256").update(JSON.stringify(hooks ?? {})).digest("hex").slice(0, 16);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** → { user, project, projectApproved, projectPending, warnings } */
|
|
99
|
+
export function readHooks({ cwd, home = homedir() } = {}) {
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const u = readJson(userSettingsPath(home));
|
|
102
|
+
if (u.error) warnings.push(`${userSettingsPath(home)} is ${u.error} — its hooks are skipped`);
|
|
103
|
+
const user = cleanHooks(u.data?.hooks, "~/.gonext/settings.json");
|
|
104
|
+
warnings.push(...user.warnings);
|
|
105
|
+
let project = { hooks: {}, warnings: [] };
|
|
106
|
+
let projectApproved = false;
|
|
107
|
+
if (cwd && resolve(projectSettingsPath(cwd)) !== resolve(userSettingsPath(home))) {
|
|
108
|
+
const p = readJson(projectSettingsPath(cwd));
|
|
109
|
+
if (p.error) warnings.push(`${projectSettingsPath(cwd)} is ${p.error} — its hooks are skipped`);
|
|
110
|
+
project = cleanHooks(p.data?.hooks, ".gonext/settings.json");
|
|
111
|
+
warnings.push(...project.warnings);
|
|
112
|
+
const approved = u.data?.approvedProjectHooks?.[resolve(cwd)];
|
|
113
|
+
projectApproved = Object.keys(project.hooks).length > 0 && approved === hooksFingerprint(project.hooks);
|
|
114
|
+
}
|
|
115
|
+
const hasProject = Object.keys(project.hooks).length > 0;
|
|
116
|
+
return {
|
|
117
|
+
user: user.hooks,
|
|
118
|
+
project: project.hooks,
|
|
119
|
+
projectApproved,
|
|
120
|
+
projectPending: hasProject && !projectApproved,
|
|
121
|
+
warnings,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What one turn runs: yours first, then the project's when approved. */
|
|
126
|
+
export function hooksForTurn({ cwd, home = homedir() } = {}) {
|
|
127
|
+
const r = readHooks({ cwd, home });
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const ev of HOOK_EVENTS) {
|
|
130
|
+
const groups = [...(r.user[ev] ?? []), ...(r.projectApproved ? r.project[ev] ?? [] : [])];
|
|
131
|
+
if (groups.length) out[ev] = groups;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Approve (or withdraw) this folder's hooks, as they are right now. */
|
|
137
|
+
export function approveProjectHooks({ cwd, home = homedir(), approve = true }) {
|
|
138
|
+
const r = readHooks({ cwd, home });
|
|
139
|
+
const file = userSettingsPath(home);
|
|
140
|
+
const { data, error } = readJson(file);
|
|
141
|
+
if (error) throw new Error(`${file} is ${error} — fix or remove it first`);
|
|
142
|
+
const raw = data ?? {};
|
|
143
|
+
raw.approvedProjectHooks = isObj(raw.approvedProjectHooks) ? raw.approvedProjectHooks : {};
|
|
144
|
+
if (approve) raw.approvedProjectHooks[resolve(cwd)] = hooksFingerprint(r.project);
|
|
145
|
+
else delete raw.approvedProjectHooks[resolve(cwd)];
|
|
146
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
147
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
148
|
+
writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
149
|
+
renameSync(tmp, file);
|
|
150
|
+
return r.project;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---------------------------------------------------------------- the session ------------ */
|
|
154
|
+
|
|
155
|
+
/** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
|
|
156
|
+
export function sessionFileFor(cwd, home = homedir()) {
|
|
157
|
+
const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
|
|
158
|
+
return join(home, ".gonext", "sessions", `${hash}.json`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** → { id, startedAt } | null. null = no terminal session (the web, or an older terminal). */
|
|
162
|
+
export function sessionForTurn(cwd, home = homedir()) {
|
|
163
|
+
if (!cwd) return null;
|
|
164
|
+
const { data } = readJson(sessionFileFor(cwd, home));
|
|
165
|
+
const id = data?.hookSessionId;
|
|
166
|
+
return typeof id === "string" && id ? { id, startedAt: String(data.hookSessionStartedAt ?? "") } : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Describe one hook for a list: the command and its args, as one line. */
|
|
170
|
+
export function describeHook(h) {
|
|
171
|
+
return [h.command, ...(h.args ?? [])].map((w) => (/\s/.test(w) ? JSON.stringify(w) : w)).join(" ");
|
|
172
|
+
}
|
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
|
+
}
|