moshcode 0.63.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 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, codex,
995
- opencode, privacycode) in one go. Kimi is skipped with a reason: it runs MCP
996
- servers but has no command to register one from a script — add those in-session
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
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
- try { child = spawn(spec.cmd, spec.args, { stdio: "inherit" }); }
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
- child.on("error", (e) => resolve({ ok: false, error: e }));
416
- child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
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
 
@@ -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
- case "gemini": {
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
- const r = await run(item.bin, item.argv);
152
- results.push({ key: item.key, status: ranOk(r) ? "added" : "failed", code: r.code, signal: r.signal ?? null });
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
  }