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 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.62.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": {
@@ -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
- const base = process.env.XDG_RUNTIME_DIR || join(homedir(), ".moshcode") || tmpdir();
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 daemonStatus();
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 ${daemon.running ? `running (pid ${daemon.pid})` : daemon.stale ? `NOT running — stale pidfile for ${daemon.pid}` : "not running"}`);
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
- const routed = platform === "linux" ? existsSync(marker) : platform === "macos" ? existsSync(marker) : null;
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: names are pointed at a bridge that is not
3023
- // there, so every Moshpit name fails instead of falling through.
3024
- if (routed && !daemon.running) {
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("! routing is in place but the bridge is not running Moshpit names will fail.");
3027
- out(" fix with: sudo moshcode dns enable undo with: sudo moshcode dns disable");
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
- const known = await fetchTlds({ registryBase }).catch(() => null);
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
- 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
  }