moshcode 0.62.0 → 0.64.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/README.md +8 -4
- package/package.json +1 -1
- package/src/dns-system.mjs +18 -2
- package/src/dns.mjs +119 -11
- package/src/engines.mjs +26 -4
- package/src/integrations.mjs +19 -0
- package/src/mcp.mjs +30 -5
package/README.md
CHANGED
|
@@ -991,10 +991,14 @@ moshcode mcp catalog # what we know how to run
|
|
|
991
991
|
moshcode mcp add porkbun # expands to: npx -y @porkbunllc/mcp-server
|
|
992
992
|
```
|
|
993
993
|
|
|
994
|
-
That registers it across every engine that supports MCP (claude, gemini,
|
|
995
|
-
opencode, privacycode) in one go. Kimi is skipped with a reason: it runs
|
|
996
|
-
servers but has no command to register one from a script — add those
|
|
997
|
-
with its own `/mcp-config`, or in `~/.kimi-code/mcp.json`.
|
|
994
|
+
That registers it across every engine that supports MCP (claude, gemini, qwen,
|
|
995
|
+
codex, opencode, privacycode) in one go. Kimi is skipped with a reason: it runs
|
|
996
|
+
MCP servers but has no command to register one from a script — add those
|
|
997
|
+
in-session with its own `/mcp-config`, or in `~/.kimi-code/mcp.json`.
|
|
998
|
+
|
|
999
|
+
Re-running an install is safe: an engine that already has the server reports
|
|
1000
|
+
`already registered` rather than an error, so the summary only goes red when
|
|
1001
|
+
something actually went wrong.
|
|
998
1002
|
|
|
999
1003
|
The catalog is a convenience, never a gate — an explicit command always wins, so
|
|
1000
1004
|
`moshcode mcp add porkbun -- node ./my-fork.js` runs your fork.
|
package/package.json
CHANGED
package/src/dns-system.mjs
CHANGED
|
@@ -332,8 +332,24 @@ function defaultRunner(command, args) {
|
|
|
332
332
|
* root to listen on 5354, and requiring it to write a pidfile somewhere
|
|
333
333
|
* privileged would make the whole daemon need privileges it otherwise does not.
|
|
334
334
|
*/
|
|
335
|
-
export function pidfilePath() {
|
|
336
|
-
|
|
335
|
+
export function pidfilePath(env = process.env, exists = existsSync) {
|
|
336
|
+
// `dns enable` escalates, so the run that *starts* the bridge is root and the
|
|
337
|
+
// runs that later ask about it are not. Under sudo both XDG_RUNTIME_DIR and
|
|
338
|
+
// HOME belong to root, so the pidfile went to /root/.moshcode — a path the
|
|
339
|
+
// unprivileged `dns status` and `dns disable` never look at and could not
|
|
340
|
+
// read if they did. The bridge was reported "not running" for the rest of its
|
|
341
|
+
// life, and the fix status advised started a second one on top of it.
|
|
342
|
+
//
|
|
343
|
+
// So an escalated run records against the invoking user's runtime dir, and
|
|
344
|
+
// only when that directory is really there: deriving /run/user/<uid> on a
|
|
345
|
+
// machine without one trades an unreadable path for a nonexistent one. macOS
|
|
346
|
+
// has no /run/user and falls through unchanged — the escalated paths this
|
|
347
|
+
// matters for are the systemd-resolved ones.
|
|
348
|
+
const invoker = env.SUDO_UID ? `/run/user/${env.SUDO_UID}` : null;
|
|
349
|
+
const base = (invoker && exists(invoker) ? invoker : null)
|
|
350
|
+
|| env.XDG_RUNTIME_DIR
|
|
351
|
+
|| join(homedir(), ".moshcode")
|
|
352
|
+
|| tmpdir();
|
|
337
353
|
return join(base, "moshpit-dns.pid");
|
|
338
354
|
}
|
|
339
355
|
|
package/src/dns.mjs
CHANGED
|
@@ -1742,7 +1742,7 @@ export function parseUdpListeners(text) {
|
|
|
1742
1742
|
return out;
|
|
1743
1743
|
}
|
|
1744
1744
|
|
|
1745
|
-
const defaultUdpListeners = async () => {
|
|
1745
|
+
export const defaultUdpListeners = async () => {
|
|
1746
1746
|
const { execFile } = await import("node:child_process");
|
|
1747
1747
|
const text = await new Promise((resolve) => {
|
|
1748
1748
|
execFile("ss", ["-lnup"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : String(stdout)));
|
|
@@ -1783,6 +1783,89 @@ export function portHolder(listeners, { host = DEFAULT_HOST, port = DEFAULT_PORT
|
|
|
1783
1783
|
return null;
|
|
1784
1784
|
}
|
|
1785
1785
|
|
|
1786
|
+
/**
|
|
1787
|
+
* What is actually on the bridge's port, rather than what our pidfile claims.
|
|
1788
|
+
*
|
|
1789
|
+
* `status` used to answer this from the pidfile alone, and that file only ever
|
|
1790
|
+
* describes a bridge *this tool* started, in *this* privilege context. Every
|
|
1791
|
+
* other way a bridge reaches 5354 read as "not running": a systemd unit, a
|
|
1792
|
+
* hand-started `dns start`, or — the common one — an `enable` that escalated to
|
|
1793
|
+
* root and therefore wrote its pidfile under root's HOME instead of the
|
|
1794
|
+
* invoking user's runtime dir.
|
|
1795
|
+
*
|
|
1796
|
+
* That is not a cosmetic lie. Status followed it with "routing is in place but
|
|
1797
|
+
* the bridge is not running", and the fix it advised starts a second bridge on
|
|
1798
|
+
* 127.0.0.1 while the working one holds 0.0.0.0. The kernel delivers to the
|
|
1799
|
+
* more specific socket, so the advice shadows the bridge it was meant to
|
|
1800
|
+
* rescue and the machine stops resolving — the outage `portHolder` above
|
|
1801
|
+
* already describes, arrived at this time by following our own instructions.
|
|
1802
|
+
*
|
|
1803
|
+
* So the port gets asked. A bridge nothing here started is still a bridge.
|
|
1804
|
+
* Both questions are put because they fail differently: a resolver that has
|
|
1805
|
+
* stopped answering Moshpit names loses a namespace, and one that has stopped
|
|
1806
|
+
* forwarding takes the machine off the internet.
|
|
1807
|
+
*/
|
|
1808
|
+
export async function bridgePresence({
|
|
1809
|
+
host = DEFAULT_HOST,
|
|
1810
|
+
port = DEFAULT_PORT,
|
|
1811
|
+
recorded = { running: false, pid: null, stale: false },
|
|
1812
|
+
listeners = defaultUdpListeners,
|
|
1813
|
+
answers = probeResolver,
|
|
1814
|
+
forwards = probeForwarding,
|
|
1815
|
+
} = {}) {
|
|
1816
|
+
const [moshpit, clearnet] = await Promise.all([
|
|
1817
|
+
answers({ host, port }).catch(() => false),
|
|
1818
|
+
forwards({ host, port, name: CLEARNET_PROBE }).catch(() => false),
|
|
1819
|
+
]);
|
|
1820
|
+
const answering = Boolean(moshpit || clearnet);
|
|
1821
|
+
|
|
1822
|
+
// Ours and alive is the ordinary case, and the probe still runs first: a
|
|
1823
|
+
// recorded pid that no longer answers is worth saying out loud rather than
|
|
1824
|
+
// reporting as a healthy bridge on the strength of the file alone.
|
|
1825
|
+
if (recorded.running) {
|
|
1826
|
+
return { kind: "ours", pid: recorded.pid, answering, forwards: clearnet, moshpit };
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
if (!answering) {
|
|
1830
|
+
return recorded.stale
|
|
1831
|
+
? { kind: "stale", pid: recorded.pid, answering: false, forwards: false, moshpit: false }
|
|
1832
|
+
: { kind: "none", pid: null, answering: false, forwards: false, moshpit: false };
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Only asked once something is known to be there, because `ss` is the
|
|
1836
|
+
// expensive half and an unattributable owner is not a reason to call a
|
|
1837
|
+
// demonstrably answering bridge absent.
|
|
1838
|
+
const holder = portHolder(await listeners().catch(() => []), { host, port });
|
|
1839
|
+
return {
|
|
1840
|
+
kind: "foreign",
|
|
1841
|
+
pid: holder?.pid ?? null,
|
|
1842
|
+
process: holder?.process ?? null,
|
|
1843
|
+
answering: true,
|
|
1844
|
+
forwards: clearnet,
|
|
1845
|
+
moshpit,
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** One line for `status`, kept next to the states it names. */
|
|
1850
|
+
export function describeBridge(presence, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
|
|
1851
|
+
switch (presence.kind) {
|
|
1852
|
+
case "ours":
|
|
1853
|
+
return presence.answering
|
|
1854
|
+
? `running (pid ${presence.pid})`
|
|
1855
|
+
: `running (pid ${presence.pid}) — but not answering on ${host}:${port}`;
|
|
1856
|
+
case "foreign": {
|
|
1857
|
+
const who = presence.pid
|
|
1858
|
+
? `pid ${presence.pid}${presence.process ? `, ${presence.process}` : ""}`
|
|
1859
|
+
: "owner not visible";
|
|
1860
|
+
return `answering on ${host}:${port} (${who}) — started by something other than \`dns enable\``;
|
|
1861
|
+
}
|
|
1862
|
+
case "stale":
|
|
1863
|
+
return `NOT running — stale pidfile for ${presence.pid}`;
|
|
1864
|
+
default:
|
|
1865
|
+
return "not running";
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1786
1869
|
/**
|
|
1787
1870
|
* Everything that has to be true of the machine before the routing is written.
|
|
1788
1871
|
*
|
|
@@ -2151,7 +2234,7 @@ import { existsSync } from "node:fs";
|
|
|
2151
2234
|
import { fileURLToPath } from "node:url";
|
|
2152
2235
|
import {
|
|
2153
2236
|
applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
|
|
2154
|
-
requiredPort, startDaemon, stopDaemon,
|
|
2237
|
+
probeResolver, requiredPort, startDaemon, stopDaemon,
|
|
2155
2238
|
} from "./dns-system.mjs";
|
|
2156
2239
|
import { escalateSelf } from "./escalate.mjs";
|
|
2157
2240
|
|
|
@@ -2230,6 +2313,8 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
|
|
|
2230
2313
|
applyWith = applyWithRollback,
|
|
2231
2314
|
verify = verifyResolution,
|
|
2232
2315
|
bridgeStatus = daemonStatus,
|
|
2316
|
+
presenceImpl = bridgePresence,
|
|
2317
|
+
exists = existsSync,
|
|
2233
2318
|
startBridge = startDaemon,
|
|
2234
2319
|
proxyReachableImpl = proxyReachable,
|
|
2235
2320
|
findLocalProxyImpl = findLocalProxy,
|
|
@@ -3009,25 +3094,48 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
|
|
|
3009
3094
|
|
|
3010
3095
|
if (sub === "status") {
|
|
3011
3096
|
const platform = detectPlatform();
|
|
3012
|
-
const daemon = await
|
|
3097
|
+
const daemon = await bridgeStatus();
|
|
3098
|
+
const statusPort = requiredPort(platform, port);
|
|
3099
|
+
const presence = await presenceImpl({ port: statusPort, recorded: daemon });
|
|
3013
3100
|
out(`platform ${platform || process.platform}`);
|
|
3014
|
-
out(`bridge ${
|
|
3101
|
+
out(`bridge ${describeBridge(presence, { port: statusPort })}`);
|
|
3015
3102
|
|
|
3016
3103
|
// Routing is read off the filesystem rather than remembered, so a config
|
|
3017
3104
|
// someone edited or removed by hand is reported as it actually is.
|
|
3018
3105
|
const marker = platform === "macos" ? "/etc/resolver" : MOSHPIT_DROPIN;
|
|
3019
|
-
|
|
3106
|
+
// Injected like every other system call this command makes. Read straight
|
|
3107
|
+
// off the filesystem, "is this machine routed" made the status tests depend
|
|
3108
|
+
// on whether the machine running them happened to have Moshpit enabled —
|
|
3109
|
+
// green on a developer's box, red on a clean runner.
|
|
3110
|
+
const routed = platform === "linux" || platform === "macos" ? exists(marker) : null;
|
|
3020
3111
|
out(`routing ${routed === null ? "(check NRPT: Get-DnsClientNrptRule)" : routed ? `configured (${marker})` : "not configured"}`);
|
|
3021
3112
|
|
|
3022
|
-
// The state worth shouting about
|
|
3023
|
-
//
|
|
3024
|
-
|
|
3113
|
+
// The state worth shouting about, and the condition is "nothing answers"
|
|
3114
|
+
// rather than "our pidfile is empty". Those are not the same machine, and
|
|
3115
|
+
// shouting on the second one sent people to start a bridge that shadowed
|
|
3116
|
+
// the working one they already had.
|
|
3117
|
+
//
|
|
3118
|
+
// The advice drops its `sudo` too: the CLI escalates the one step that
|
|
3119
|
+
// needs root, and teaching `sudo moshcode` is how `sudo moshcode update`
|
|
3120
|
+
// ends up reinstalling the whole tool into /root.
|
|
3121
|
+
if (routed && !presence.answering) {
|
|
3122
|
+
out("");
|
|
3123
|
+
out(`! routing is in place but nothing answers on ${DEFAULT_HOST}:${statusPort} — Moshpit names will fail.`);
|
|
3124
|
+
out(" fix with: moshcode dns enable undo with: moshcode dns disable");
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
// Answering but not forwarding is the dangerous half, and it is invisible
|
|
3128
|
+
// from the Moshpit side: names resolve, and everything else on the machine
|
|
3129
|
+
// stops. Catch-all routing is what makes it total.
|
|
3130
|
+
if (routed && presence.answering && !presence.forwards) {
|
|
3025
3131
|
out("");
|
|
3026
|
-
out(
|
|
3027
|
-
out("
|
|
3132
|
+
out(`! the bridge on ${DEFAULT_HOST}:${statusPort} answers Moshpit names but is not forwarding`);
|
|
3133
|
+
out(" clearnet lookups routed through it will fail. Restart it, or `moshcode dns disable`.");
|
|
3028
3134
|
}
|
|
3029
3135
|
|
|
3030
|
-
|
|
3136
|
+
// The injected one, like every other caller. Reaching past it here made
|
|
3137
|
+
// `status` the one subcommand that could not be tested without a network.
|
|
3138
|
+
const known = await fetchTldsImpl({ registryBase }).catch(() => null);
|
|
3031
3139
|
const probe = known
|
|
3032
3140
|
? await resolveName(`probe.${known[0] || "moshpit"}`, { registryBase }).catch(() => null)
|
|
3033
3141
|
: null;
|
package/src/engines.mjs
CHANGED
|
@@ -405,15 +405,37 @@ export function agentLaunchArgs(engine, args = []) {
|
|
|
405
405
|
* Spawn an arbitrary command with stdio inherited (so its own progress/prompts
|
|
406
406
|
* own the terminal). Resolves { ok, code, signal } on exit. Used by install +
|
|
407
407
|
* upgrade to run engine installers/updaters.
|
|
408
|
+
*
|
|
409
|
+
* With `{ capture: true }` the child's stdout/stderr are piped and *echoed
|
|
410
|
+
* through* rather than inherited, and the combined text comes back as `output`.
|
|
411
|
+
* The terminal still sees exactly what it saw before — the tee exists so a
|
|
412
|
+
* caller can read the engine's own words about *why* it exited non-zero, which
|
|
413
|
+
* a bare exit code cannot tell apart (see `alreadyRegistered` in mcp.mjs).
|
|
414
|
+
* Inherit stays the default: piping costs a couple of streams, and every other
|
|
415
|
+
* caller runs installers whose output nobody needs to parse.
|
|
416
|
+
*
|
|
417
|
+
* stdin is inherited either way, so a child that prompts still reaches the user.
|
|
408
418
|
*/
|
|
409
|
-
export function runCmd(cmd, args = []) {
|
|
419
|
+
export function runCmd(cmd, args = [], { capture = false } = {}) {
|
|
410
420
|
return new Promise((resolve) => {
|
|
411
421
|
let child;
|
|
412
422
|
const spec = spawnSpec(cmd, args);
|
|
413
|
-
|
|
423
|
+
const stdio = capture ? ["inherit", "pipe", "pipe"] : "inherit";
|
|
424
|
+
try { child = spawn(spec.cmd, spec.args, { stdio }); }
|
|
414
425
|
catch (e) { resolve({ ok: false, error: e }); return; }
|
|
415
|
-
|
|
416
|
-
|
|
426
|
+
let output = "";
|
|
427
|
+
if (capture) {
|
|
428
|
+
for (const [stream, sink] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
|
|
429
|
+
stream?.on("data", (chunk) => { output += chunk.toString(); sink.write(chunk); });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
child.on("error", (e) => resolve({ ok: false, error: e, output }));
|
|
433
|
+
// "exit" fires as soon as the process is gone, which with pipes can leave
|
|
434
|
+
// the last chunk still queued — the one line we are trying to read. "close"
|
|
435
|
+
// waits for the streams too. With stdio inherited there are no streams, so
|
|
436
|
+
// the two are the same moment and existing callers are unaffected; the
|
|
437
|
+
// distinction is kept explicit so neither branch changes by accident.
|
|
438
|
+
child.on(capture ? "close" : "exit", (code, signal) => resolve({ ok: true, code, signal, output }));
|
|
417
439
|
});
|
|
418
440
|
}
|
|
419
441
|
|
package/src/integrations.mjs
CHANGED
|
@@ -105,6 +105,22 @@ export function parseMcp(tokens) {
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// A remote server is a URL and nothing else — every engine's builder pushes
|
|
109
|
+
// the target alone and discards `args`. So a leftover token here is not a
|
|
110
|
+
// command line, it is something the user typed that this command will silently
|
|
111
|
+
// throw away. `mcp install <url> --dry-run` is the case that matters: the flag
|
|
112
|
+
// does not exist, it lands here, and the install goes ahead and writes to
|
|
113
|
+
// every engine's config — the exact opposite of what the person typing it
|
|
114
|
+
// expected. Say so instead of dropping it on the floor.
|
|
115
|
+
if (!cmdParts && target && isRemoteTarget(target) && args.length) {
|
|
116
|
+
const extra = args[0];
|
|
117
|
+
return {
|
|
118
|
+
error: extra.startsWith("-")
|
|
119
|
+
? `unknown mcp flag "${extra}" — mcp takes --name, -t/--transport, -e/--env, and -H/--header, and has no --dry-run`
|
|
120
|
+
: `unexpected argument "${extra}" after a remote server URL — a URL server takes no command arguments`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
if (verb === "install" && !name) {
|
|
109
125
|
if (target && isRemoteTarget(target)) name = deriveName(target);
|
|
110
126
|
else return { error: "a stdio command server needs an explicit --name" };
|
|
@@ -185,6 +201,9 @@ export function printSkillTargets(json = false) {
|
|
|
185
201
|
function summarize(results) {
|
|
186
202
|
for (const r of results) {
|
|
187
203
|
if (r.status === "added" || r.status === "installed" || r.status === "removed") console.log(line(r.key, ok(r.status)));
|
|
204
|
+
// Nothing to do and nothing wrong: grey, like the other "we didn't act"
|
|
205
|
+
// rows, rather than the green of a change we actually made.
|
|
206
|
+
else if (r.status === "already") console.log(line(r.key, ash("already registered")));
|
|
188
207
|
else if (r.status === "failed") console.log(line(r.key, err(`failed${r.code != null ? ` (code ${r.code})` : r.signal ? ` (${r.signal})` : ""}`)));
|
|
189
208
|
else if (r.status === "not-installed") console.log(line(r.key, ash("not installed — /install " + r.key)));
|
|
190
209
|
else console.log(line(r.key, ash(`skipped — ${r.reason}`)));
|
package/src/mcp.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
|
|
|
5
5
|
import { isIP } from "node:net";
|
|
6
6
|
|
|
7
7
|
// Coding engines that can register MCP servers. Aider has no MCP support.
|
|
8
|
-
export const MCP_ENGINES = ["claude", "gemini", "codex", "opencode", "privacycode"];
|
|
8
|
+
export const MCP_ENGINES = ["claude", "gemini", "qwen", "codex", "opencode", "privacycode"];
|
|
9
9
|
|
|
10
10
|
/** Is this target a remote server URL (vs a local stdio command)? */
|
|
11
11
|
export function isRemoteTarget(target) {
|
|
@@ -71,7 +71,11 @@ export function mcpAddArgs(key, spec) {
|
|
|
71
71
|
else argv.push("--", target, ...args);
|
|
72
72
|
return { argv };
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
// Qwen Code is a Gemini CLI fork and kept the whole `mcp add` surface —
|
|
75
|
+
// same `-s/-t/-e/-H` flags, same "URL or command" positional. It shares the
|
|
76
|
+
// builder rather than getting a copy, so the two can only drift on purpose.
|
|
77
|
+
case "gemini":
|
|
78
|
+
case "qwen": {
|
|
75
79
|
const argv = ["mcp", "add", "-s", "user"];
|
|
76
80
|
if (remote) argv.push("-t", transport);
|
|
77
81
|
for (const [k, v] of env) argv.push("-e", `${k}=${v}`);
|
|
@@ -138,9 +142,27 @@ export function planMcpAdd(spec, { installedSet } = {}) {
|
|
|
138
142
|
});
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Did this engine exit non-zero only because the server was already there?
|
|
147
|
+
*
|
|
148
|
+
* Registering the same server twice is the normal way to re-run `mcp install`,
|
|
149
|
+
* and it is not a failure — but Claude Code and Gemini/Qwen exit 1 on it, so the
|
|
150
|
+
* fan-out summary painted `claude ✗ failed (code 1)` next to opencode's cheerful
|
|
151
|
+
* green box. Read from a box where four engines already had the server, that
|
|
152
|
+
* says "moshcode cannot register with Claude Code" — which is exactly the wrong
|
|
153
|
+
* conclusion, and the reason this function exists rather than a nicer exit code.
|
|
154
|
+
*
|
|
155
|
+
* Matched against the engine's own words, so it stays honest: an engine that
|
|
156
|
+
* fails for any *other* reason still comes back failed.
|
|
157
|
+
*/
|
|
158
|
+
const ALREADY_RE = /already (?:exists|configured|registered|added)|exists in (?:user|global|project) config/i;
|
|
159
|
+
export function alreadyRegistered(r) {
|
|
160
|
+
return ALREADY_RE.test(String(r?.output ?? ""));
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
/**
|
|
142
164
|
* Execute a plan: run each installed, non-skipped engine's `mcp add`. Returns
|
|
143
|
-
* results [{ key, status: "added"|"skipped"|"failed"|"not-installed", reason? }].
|
|
165
|
+
* results [{ key, status: "added"|"already"|"skipped"|"failed"|"not-installed", reason? }].
|
|
144
166
|
* `run` is injectable for tests; defaults to the real spawner.
|
|
145
167
|
*/
|
|
146
168
|
export async function runMcpAdd(plan, { run = runCmd } = {}) {
|
|
@@ -148,8 +170,11 @@ export async function runMcpAdd(plan, { run = runCmd } = {}) {
|
|
|
148
170
|
for (const item of plan) {
|
|
149
171
|
if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
|
|
150
172
|
if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
// capture so a non-zero exit can be read for "already exists" rather than
|
|
174
|
+
// reported as a failure; the child's output still reaches the terminal.
|
|
175
|
+
const r = await run(item.bin, item.argv, { capture: true });
|
|
176
|
+
const status = ranOk(r) ? "added" : alreadyRegistered(r) ? "already" : "failed";
|
|
177
|
+
results.push({ key: item.key, status, code: r.code, signal: r.signal ?? null });
|
|
153
178
|
}
|
|
154
179
|
return results;
|
|
155
180
|
}
|