moshcode 0.24.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/LICENSE +21 -0
- package/README.md +580 -0
- package/bin/moshcode.mjs +674 -0
- package/bin/moshscript.mjs +29 -0
- package/examples/alive.mosh +6 -0
- package/examples/scripting-the-cli.mosh +21 -0
- package/examples/team-secrets.mosh +20 -0
- package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
- package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
- package/examples/templates/bun-caddy-sqlite/README.md +97 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
- package/examples/templates/bun-caddy-sqlite/package.json +15 -0
- package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
- package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
- package/examples/templates/bun-caddy-sqlite/template.json +10 -0
- package/examples/templates/caddy-proxy/Caddyfile +36 -0
- package/examples/templates/caddy-proxy/README.md +104 -0
- package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-proxy/template.json +8 -0
- package/examples/templates/caddy-static/Caddyfile +16 -0
- package/examples/templates/caddy-static/README.md +90 -0
- package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-static/site/index.html +11 -0
- package/examples/templates/caddy-static/template.json +8 -0
- package/install.sh +194 -0
- package/package.json +28 -0
- package/prd/0000-template.md +49 -0
- package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
- package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
- package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
- package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
- package/prd/0005-hosted-moshpit-resolver.md +192 -0
- package/prd/0006-help.md +359 -0
- package/prd/0007-profullstack-site-init.md +1183 -0
- package/prd/README.md +26 -0
- package/src/ads.mjs +58 -0
- package/src/auth.mjs +193 -0
- package/src/cli-schema.mjs +533 -0
- package/src/cli.mjs +118 -0
- package/src/commands.mjs +259 -0
- package/src/completion.mjs +594 -0
- package/src/console.mjs +244 -0
- package/src/dns-system.mjs +404 -0
- package/src/dns.mjs +2872 -0
- package/src/doh-server.mjs +256 -0
- package/src/doh.mjs +218 -0
- package/src/engines.mjs +385 -0
- package/src/escalate.mjs +85 -0
- package/src/help.mjs +443 -0
- package/src/integrations.mjs +265 -0
- package/src/mcp-catalog.mjs +50 -0
- package/src/mcp.mjs +155 -0
- package/src/mirror.mjs +187 -0
- package/src/notify.mjs +86 -0
- package/src/open-url.mjs +34 -0
- package/src/parking-http.mjs +65 -0
- package/src/pins.mjs +190 -0
- package/src/pit-url.mjs +13 -0
- package/src/prd.mjs +341 -0
- package/src/pty.mjs +176 -0
- package/src/pwd.mjs +103 -0
- package/src/registry.mjs +37 -0
- package/src/release-install.mjs +191 -0
- package/src/runtime.mjs +161 -0
- package/src/selfupdate.mjs +215 -0
- package/src/serve.mjs +502 -0
- package/src/skills.mjs +93 -0
- package/src/tabs.mjs +144 -0
- package/src/templates.mjs +456 -0
- package/src/tools.mjs +231 -0
- package/src/trade.mjs +137 -0
- package/src/trust.mjs +712 -0
- package/src/tui.mjs +736 -0
- package/src/ui.mjs +49 -0
- package/src/uninstall.mjs +113 -0
- package/src/upgrade.mjs +217 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// `/mcp` and `/skill` command flows, shared by the TUI and the CLI. Each parses
|
|
2
|
+
// a canonical spec, plans the per-engine fan-out, runs it, and prints a
|
|
3
|
+
// per-engine summary. See prd/0003.
|
|
4
|
+
import { ENGINES, isInstalled } from "./engines.mjs";
|
|
5
|
+
import {
|
|
6
|
+
MCP_ENGINES, deriveName, isRemoteTarget, planMcpAdd, runMcpAdd,
|
|
7
|
+
} from "./mcp.mjs";
|
|
8
|
+
import {
|
|
9
|
+
SKILL_ENGINES, planSkillInstall, runSkillInstall, skillName,
|
|
10
|
+
} from "./skills.mjs";
|
|
11
|
+
import { catalogList, resolveCatalog } from "./mcp-catalog.mjs";
|
|
12
|
+
import { MCP_VERBS, SKILL_VERBS } from "./cli-schema.mjs";
|
|
13
|
+
import { acid, ash, bone, ok, err, info } from "./ui.mjs";
|
|
14
|
+
|
|
15
|
+
function splitKV(pair) {
|
|
16
|
+
const i = String(pair).indexOf("=");
|
|
17
|
+
return i === -1 ? [String(pair), ""] : [pair.slice(0, i), pair.slice(i + 1)];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function headerName(header) {
|
|
21
|
+
const i = String(header).indexOf(":");
|
|
22
|
+
return i === -1 ? null : String(header).slice(0, i).trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function flagValue(rest, index, flag) {
|
|
26
|
+
const value = rest[index + 1];
|
|
27
|
+
if (value === undefined || value === "--" || String(value).startsWith("-")) {
|
|
28
|
+
return { error: `${flag} requires a value` };
|
|
29
|
+
}
|
|
30
|
+
return { value };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse `/mcp` tokens (after the `mcp` word) into { list } | { spec } | { error }. */
|
|
34
|
+
export function parseMcp(tokens) {
|
|
35
|
+
const verb = tokens[0];
|
|
36
|
+
if (!verb || verb === "list") return { list: true, json: tokens.slice(1).includes("--json") };
|
|
37
|
+
if (verb === "catalog") return { showCatalog: true };
|
|
38
|
+
const verbSchema = MCP_VERBS.find(({ name }) => name === verb);
|
|
39
|
+
if (!verbSchema?.acceptsServerSpec) {
|
|
40
|
+
const choices = MCP_VERBS.map(({ name }) => name);
|
|
41
|
+
return { error: `unknown mcp verb "${verb}" — try ${choices.slice(0, -1).join(", ")}, or ${choices.at(-1)}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rest = tokens.slice(1);
|
|
45
|
+
let name, transport, cmdParts = null;
|
|
46
|
+
const env = [], headers = [], positional = [];
|
|
47
|
+
for (let i = 0; i < rest.length; i++) {
|
|
48
|
+
const t = rest[i];
|
|
49
|
+
if (t === "--") { cmdParts = rest.slice(i + 1); break; }
|
|
50
|
+
else if (t === "--name") {
|
|
51
|
+
const next = flagValue(rest, i, t);
|
|
52
|
+
if (next.error) return next;
|
|
53
|
+
name = next.value; i++;
|
|
54
|
+
}
|
|
55
|
+
else if (t === "-t" || t === "--transport") {
|
|
56
|
+
const next = flagValue(rest, i, t);
|
|
57
|
+
if (next.error) return next;
|
|
58
|
+
transport = next.value; i++;
|
|
59
|
+
}
|
|
60
|
+
else if (t === "-e" || t === "--env") {
|
|
61
|
+
const next = flagValue(rest, i, t);
|
|
62
|
+
if (next.error) return next;
|
|
63
|
+
env.push(splitKV(next.value)); i++;
|
|
64
|
+
}
|
|
65
|
+
else if (t === "-H" || t === "--header") {
|
|
66
|
+
const next = flagValue(rest, i, t);
|
|
67
|
+
if (next.error) return next;
|
|
68
|
+
headers.push(next.value); i++;
|
|
69
|
+
}
|
|
70
|
+
else positional.push(t);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (verb === "add") name = name || positional.shift();
|
|
74
|
+
let target, args = [];
|
|
75
|
+
if (cmdParts) { target = cmdParts[0]; args = cmdParts.slice(1); }
|
|
76
|
+
else { target = positional[0]; args = positional.slice(1); }
|
|
77
|
+
|
|
78
|
+
// A bare known name is enough: `mcp add porkbun` fills the command in from
|
|
79
|
+
// the catalog. Only when nothing else was given — an explicit target always
|
|
80
|
+
// wins, so the catalog can never override what was actually typed.
|
|
81
|
+
let catalog = null;
|
|
82
|
+
if (!target) {
|
|
83
|
+
catalog = resolveCatalog(name) || resolveCatalog(positional[0]);
|
|
84
|
+
if (catalog) {
|
|
85
|
+
name = name || catalog.key;
|
|
86
|
+
target = catalog.target;
|
|
87
|
+
args = catalog.args;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A token still starting with `-` at this point was never consumed as a flag,
|
|
92
|
+
// so it is a typo or an engine-native flag moshcode does not take (`-s user`).
|
|
93
|
+
// Left alone it becomes the server NAME or its command and gets spliced
|
|
94
|
+
// straight into every engine's own `mcp add` argv. Everything after `--` is
|
|
95
|
+
// the user's command line and is deliberately not second-guessed.
|
|
96
|
+
const stray = [name, cmdParts ? null : target]
|
|
97
|
+
.find((t) => typeof t === "string" && t.startsWith("-"));
|
|
98
|
+
if (stray) {
|
|
99
|
+
return {
|
|
100
|
+
error: `unknown mcp flag "${stray}" — mcp takes --name, -t/--transport, -e/--env, and -H/--header; put a command's own flags after --`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (verb === "install" && !name) {
|
|
105
|
+
if (target && isRemoteTarget(target)) name = deriveName(target);
|
|
106
|
+
else return { error: "a stdio command server needs an explicit --name" };
|
|
107
|
+
}
|
|
108
|
+
if (!name) return { error: "missing server name" };
|
|
109
|
+
if (!target) return { error: "missing server URL or command" };
|
|
110
|
+
if (env.some(([key]) => String(key).trim() === "")) {
|
|
111
|
+
return { error: "mcp --env requires a non-empty key" };
|
|
112
|
+
}
|
|
113
|
+
if (headers.some((header) => headerName(header) === null)) {
|
|
114
|
+
return { error: "mcp --header requires a Name: Value header" };
|
|
115
|
+
}
|
|
116
|
+
if (headers.some((header) => headerName(header) === "")) {
|
|
117
|
+
return { error: "mcp --header requires a non-empty header name" };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
spec: { name, target, args, transport, env, headers },
|
|
121
|
+
...(catalog ? { catalog } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const DOT = { installed: acid("●"), missing: ash("○") };
|
|
126
|
+
function line(key, statusText) { return ` ${bone(key.padEnd(9))} ${statusText}`; }
|
|
127
|
+
|
|
128
|
+
function integrationTargetStatus(supportedKeys, { installedSet } = {}) {
|
|
129
|
+
const supported = new Set(supportedKeys);
|
|
130
|
+
const keys = [...supportedKeys, ...Object.keys(ENGINES).filter((key) => !supported.has(key))];
|
|
131
|
+
return keys.map((key) => ({
|
|
132
|
+
name: key,
|
|
133
|
+
binary: ENGINES[key].bin,
|
|
134
|
+
installed: installedSet ? installedSet.has(key) : isInstalled(ENGINES[key].bin, ENGINES[key].binDirs),
|
|
135
|
+
supported: supported.has(key),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** MCP capability and install status for every engine. */
|
|
140
|
+
export function mcpTargetStatus(options) {
|
|
141
|
+
return integrationTargetStatus(MCP_ENGINES, options);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Skills capability and install status for every engine. */
|
|
145
|
+
export function skillTargetStatus(options) {
|
|
146
|
+
return integrationTargetStatus(SKILL_ENGINES, options);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Print the known-server catalog. */
|
|
150
|
+
export function printMcpCatalog() {
|
|
151
|
+
console.log(bone(" known mcp servers") + ash(" — register one with ") + acid("/mcp add <name>"));
|
|
152
|
+
console.log(catalogList());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Print the MCP support matrix + install status. */
|
|
156
|
+
export function printMcpTargets(json = false) {
|
|
157
|
+
const targets = mcpTargetStatus();
|
|
158
|
+
if (json) { console.log(JSON.stringify(targets, null, 2)); return; }
|
|
159
|
+
console.log(bone(" mcp") + ash(" — register a server everywhere with ") + acid("/mcp install <url>"));
|
|
160
|
+
for (const target of targets) {
|
|
161
|
+
const dot = target.supported && target.installed ? DOT.installed : DOT.missing;
|
|
162
|
+
// "no MCP support" would be a claim about the engine; what this column
|
|
163
|
+
// actually knows is whether moshcode can register a server there. Kimi runs
|
|
164
|
+
// MCP servers perfectly well and simply has no command to add one from a
|
|
165
|
+
// script — the fan-out states each engine's own reason when you run it.
|
|
166
|
+
console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "mcp add supported" : "no mcp add command")}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Print the skills support matrix + install status. */
|
|
171
|
+
export function printSkillTargets(json = false) {
|
|
172
|
+
const targets = skillTargetStatus();
|
|
173
|
+
if (json) { console.log(JSON.stringify(targets, null, 2)); return; }
|
|
174
|
+
console.log(bone(" skills") + ash(" — install a skill everywhere with ") + acid("/skill install <git-url>"));
|
|
175
|
+
for (const target of targets) {
|
|
176
|
+
const dot = target.supported && target.installed ? DOT.installed : DOT.missing;
|
|
177
|
+
console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "skills supported" : "no skills primitive")}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function summarize(results) {
|
|
182
|
+
for (const r of results) {
|
|
183
|
+
if (r.status === "added" || r.status === "installed") console.log(line(r.key, ok(r.status)));
|
|
184
|
+
else if (r.status === "failed") console.log(line(r.key, err(`failed${r.code != null ? ` (code ${r.code})` : r.signal ? ` (${r.signal})` : ""}`)));
|
|
185
|
+
else if (r.status === "not-installed") console.log(line(r.key, ash("not installed — /install " + r.key)));
|
|
186
|
+
else console.log(line(r.key, ash(`skipped — ${r.reason}`)));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Did any engine we actually ran come back failed?
|
|
192
|
+
*
|
|
193
|
+
* Only "failed" counts. An engine that is skipped ("no MCP support") or absent
|
|
194
|
+
* ("not installed") was never attempted, and the summary already prints both in
|
|
195
|
+
* grey rather than red — treating them as failures would make `mcp add` exit
|
|
196
|
+
* non-zero on a perfectly good box that simply does not have all six engines.
|
|
197
|
+
* This is the rule `upgrade` already applies to its own fan-out, which counts
|
|
198
|
+
* `!r.ok` over the engines it ran and exits 1 if any of them failed.
|
|
199
|
+
*/
|
|
200
|
+
const anyFailed = (results) => results.some((r) => r.status === "failed");
|
|
201
|
+
|
|
202
|
+
/** Run `/mcp …`. `tokens` are the words after `mcp`. `run`/`installedSet` are injectable for tests. */
|
|
203
|
+
export async function mcpCommand(tokens, { run, installedSet } = {}) {
|
|
204
|
+
const parsed = parseMcp(tokens);
|
|
205
|
+
if (parsed.list) { printMcpTargets(parsed.json); return 0; }
|
|
206
|
+
if (parsed.showCatalog) { printMcpCatalog(); return 0; }
|
|
207
|
+
if (parsed.error) { console.log(err(parsed.error)); return 1; }
|
|
208
|
+
|
|
209
|
+
const { spec } = parsed;
|
|
210
|
+
console.log(info(`registering ${bone(spec.name)} → ${ash(spec.target)} across MCP engines…`));
|
|
211
|
+
const results = await runMcpAdd(planMcpAdd(spec, { installedSet }), run ? { run } : {});
|
|
212
|
+
summarize(results);
|
|
213
|
+
// Credentials are named, never registered: an API key copied into five
|
|
214
|
+
// engines' config files is five places to leak it from and five to rotate.
|
|
215
|
+
const missing = (parsed.catalog?.env || []).filter((k) => !process.env[k]);
|
|
216
|
+
if (missing.length) {
|
|
217
|
+
console.log(ash(` note: ${spec.name} needs ${missing.join(" and ")} in the environment.`));
|
|
218
|
+
if (parsed.catalog?.note) console.log(ash(` ${parsed.catalog.note}`));
|
|
219
|
+
if (parsed.catalog?.docs) console.log(ash(` ${parsed.catalog.docs}`));
|
|
220
|
+
}
|
|
221
|
+
if (spec.headers.length || /^https?:/i.test(spec.target)) {
|
|
222
|
+
console.log(ash(" note: OAuth/HTTP servers may still need per-engine auth (e.g. `opencode mcp auth`, `codex mcp login`)."));
|
|
223
|
+
}
|
|
224
|
+
return anyFailed(results) ? 1 : 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Run `/skill …`. `tokens` are the words after `skill`. `run`/`installedSet` are injectable for tests. */
|
|
228
|
+
export async function skillCommand(tokens, { run, installedSet } = {}) {
|
|
229
|
+
const verb = tokens[0];
|
|
230
|
+
if (!verb || verb === "list") { printSkillTargets(tokens.slice(1).includes("--json")); return 0; }
|
|
231
|
+
if (verb !== "install") {
|
|
232
|
+
console.log(err(`unknown skill verb "${verb}" — try ${SKILL_VERBS.map(({ name }) => name).join(" or ")}`));
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const rest = tokens.slice(1);
|
|
237
|
+
let name, source;
|
|
238
|
+
for (let i = 0; i < rest.length; i++) {
|
|
239
|
+
if (rest[i] === "--name") {
|
|
240
|
+
const next = flagValue(rest, i, rest[i]);
|
|
241
|
+
if (next.error) { console.log(err(next.error)); return 1; }
|
|
242
|
+
name = next.value;
|
|
243
|
+
i++;
|
|
244
|
+
}
|
|
245
|
+
else if (!source) source = rest[i];
|
|
246
|
+
}
|
|
247
|
+
// A source still starting with `-` was never consumed as a flag, so it is a
|
|
248
|
+
// typo or an engine-native flag moshcode does not take (`-s user`). Left
|
|
249
|
+
// alone it becomes the skill SOURCE and is spliced straight into every
|
|
250
|
+
// engine's own argv — `gemini skills install -s --scope user`, and a
|
|
251
|
+
// `git clone --depth 1 -s <dest>` where `-s` (`--shared`) makes git read the
|
|
252
|
+
// destination as the repository — while the URL the user actually typed is
|
|
253
|
+
// dropped on the floor. Same guard `mcp` already applies to its own spec.
|
|
254
|
+
if (source?.startsWith("-")) {
|
|
255
|
+
console.log(err(`unknown skill flag "${source}" — skill install takes --name; a source that really starts with "-" must be written as ./${source}`));
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
if (!source) { console.log(err("usage: /skill install <git-url|path> [--name <name>]")); return 1; }
|
|
259
|
+
|
|
260
|
+
const spec = { source, name: skillName(source, name) };
|
|
261
|
+
console.log(info(`installing skill ${bone(spec.name)} → ${ash(source)} across skills engines…`));
|
|
262
|
+
const results = await runSkillInstall(planSkillInstall(spec, { installedSet }), run ? { run } : {});
|
|
263
|
+
summarize(results);
|
|
264
|
+
return anyFailed(results) ? 1 : 0;
|
|
265
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Known MCP servers, so a name is enough: `moshcode mcp add porkbun` instead of
|
|
2
|
+
// remembering an npx invocation and its package scope.
|
|
3
|
+
//
|
|
4
|
+
// This is a convenience layer, not a gate — `mcp add <name> -- <cmd> …` still
|
|
5
|
+
// takes anything. An entry here only means "we know the canonical way to run
|
|
6
|
+
// this one".
|
|
7
|
+
//
|
|
8
|
+
// `env` lists the variables the server needs to do real work. They are NOT
|
|
9
|
+
// baked into the registration: an API key belongs in the environment (or a
|
|
10
|
+
// secrets manager), not copied into five engines' config files. They are
|
|
11
|
+
// printed as a reminder instead.
|
|
12
|
+
|
|
13
|
+
export const MCP_CATALOG = {
|
|
14
|
+
porkbun: {
|
|
15
|
+
target: "npx",
|
|
16
|
+
args: ["-y", "@porkbunllc/mcp-server"],
|
|
17
|
+
desc: "Porkbun — domains, DNS records, SSL, email forwarding",
|
|
18
|
+
// Linked as the official MCP server from Porkbun's own API documentation,
|
|
19
|
+
// though the source lives on an individual's account rather than a Porkbun
|
|
20
|
+
// org — worth knowing before handing it DNS-write credentials.
|
|
21
|
+
docs: "https://porkbun.com/api/json/v3/documentation",
|
|
22
|
+
env: ["PORKBUN_API_KEY", "PORKBUN_SECRET_API_KEY"],
|
|
23
|
+
// The doc tools work with no credentials at all, so it is worth trying
|
|
24
|
+
// before deciding whether to trust it with keys.
|
|
25
|
+
note: "API access is off by default and must be enabled per-domain; the docs tools work without keys",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Resolve a catalog name to a spec fragment, or null. Own properties only. */
|
|
30
|
+
export function resolveCatalog(token) {
|
|
31
|
+
if (!token) return null;
|
|
32
|
+
const key = String(token).trim().toLowerCase();
|
|
33
|
+
// MCP_CATALOG is a plain object literal, so `constructor` and friends would
|
|
34
|
+
// otherwise resolve to something off Object.prototype with no target.
|
|
35
|
+
if (!Object.hasOwn(MCP_CATALOG, key)) return null;
|
|
36
|
+
const entry = MCP_CATALOG[key];
|
|
37
|
+
return { key, ...entry, args: [...(entry.args || [])] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Names, for help text and error messages. */
|
|
41
|
+
export function catalogNames() {
|
|
42
|
+
return Object.keys(MCP_CATALOG);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One line per known server, for `mcp catalog`. */
|
|
46
|
+
export function catalogList() {
|
|
47
|
+
return Object.entries(MCP_CATALOG)
|
|
48
|
+
.map(([key, e]) => ` ${key.padEnd(10)} ${e.desc}`)
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Register MCP (Model Context Protocol) servers across every engine that
|
|
2
|
+
// supports them, from one canonical definition. MoshCode drives each engine's
|
|
3
|
+
// own `mcp add` so the engine owns its config format. See prd/0003.
|
|
4
|
+
import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
|
|
7
|
+
// Coding engines that can register MCP servers. Aider has no MCP support.
|
|
8
|
+
export const MCP_ENGINES = ["claude", "gemini", "codex", "opencode", "privacycode"];
|
|
9
|
+
|
|
10
|
+
/** Is this target a remote server URL (vs a local stdio command)? */
|
|
11
|
+
export function isRemoteTarget(target) {
|
|
12
|
+
return /^https?:\/\//i.test(String(target));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Second-level labels that are part of a multi-part public suffix rather than a
|
|
16
|
+
// name, as in co.uk / com.au / co.za. Dropping only the TLD would leave these.
|
|
17
|
+
const SUFFIX_LABELS = ["co", "com", "net", "org", "gov", "edu", "ac"];
|
|
18
|
+
|
|
19
|
+
/** Derive a sane server name from a remote URL's host (e.g. mcp.sentry.dev → sentry). */
|
|
20
|
+
export function deriveName(target) {
|
|
21
|
+
const sanitize = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "");
|
|
22
|
+
try {
|
|
23
|
+
const hostname = new URL(target).hostname;
|
|
24
|
+
const ipHost = hostname.replace(/^\[|\]$/g, "");
|
|
25
|
+
if (isIP(ipHost)) {
|
|
26
|
+
const ipName = ipHost.replace(/[.:]+/g, "-").replace(/^-+|-+$/g, "");
|
|
27
|
+
return sanitize(`ip-${ipName}`);
|
|
28
|
+
}
|
|
29
|
+
const labels = hostname.split(".").filter(Boolean);
|
|
30
|
+
let withoutTld = labels.slice(0, -1); // drop the TLD
|
|
31
|
+
// ...and the generic label of a multi-part suffix, as long as a real name
|
|
32
|
+
// still precedes it (a bare "co.uk" host has nothing better to offer).
|
|
33
|
+
if (withoutTld.length > 1 && SUFFIX_LABELS.includes(withoutTld[withoutTld.length - 1])) {
|
|
34
|
+
withoutTld = withoutTld.slice(0, -1);
|
|
35
|
+
}
|
|
36
|
+
const meaningful = withoutTld.filter((l) => !["mcp", "www", "api", "app"].includes(l));
|
|
37
|
+
const pick = meaningful[meaningful.length - 1] || withoutTld[withoutTld.length - 1] || labels[0];
|
|
38
|
+
return sanitize(pick) || "server";
|
|
39
|
+
} catch {
|
|
40
|
+
return "server";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Convert a `"Key: Value"` header into OpenCode's `Key=Value` form. */
|
|
45
|
+
function headerToEq(header) {
|
|
46
|
+
const i = String(header).indexOf(":");
|
|
47
|
+
return i === -1 ? String(header) : `${header.slice(0, i).trim()}=${header.slice(i + 1).trim()}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build one engine's native `mcp add` argv for a canonical server spec, or a
|
|
52
|
+
* skip reason when the engine can't express it.
|
|
53
|
+
*
|
|
54
|
+
* spec: { name, target, args?, transport?, env?: [[k,v]], headers?: ["Key: Value"] }
|
|
55
|
+
* `target` is a URL (remote) or a stdio command; `args` are stdio command args.
|
|
56
|
+
* Returns { argv } or { skip }.
|
|
57
|
+
*/
|
|
58
|
+
export function mcpAddArgs(key, spec) {
|
|
59
|
+
const { name, target, args = [], env = [], headers = [] } = spec;
|
|
60
|
+
const remote = isRemoteTarget(target);
|
|
61
|
+
const transport = spec.transport || (remote ? "http" : "stdio");
|
|
62
|
+
|
|
63
|
+
switch (key) {
|
|
64
|
+
case "claude": {
|
|
65
|
+
const argv = ["mcp", "add", "-s", "user"];
|
|
66
|
+
if (remote) argv.push("-t", transport);
|
|
67
|
+
for (const [k, v] of env) argv.push("-e", `${k}=${v}`);
|
|
68
|
+
for (const h of headers) argv.push("-H", h);
|
|
69
|
+
argv.push(name);
|
|
70
|
+
if (remote) argv.push(target);
|
|
71
|
+
else argv.push("--", target, ...args);
|
|
72
|
+
return { argv };
|
|
73
|
+
}
|
|
74
|
+
case "gemini": {
|
|
75
|
+
const argv = ["mcp", "add", "-s", "user"];
|
|
76
|
+
if (remote) argv.push("-t", transport);
|
|
77
|
+
for (const [k, v] of env) argv.push("-e", `${k}=${v}`);
|
|
78
|
+
for (const h of headers) argv.push("-H", h);
|
|
79
|
+
argv.push(name);
|
|
80
|
+
if (remote) argv.push(target);
|
|
81
|
+
else argv.push(target, ...args);
|
|
82
|
+
return { argv };
|
|
83
|
+
}
|
|
84
|
+
case "kimi":
|
|
85
|
+
// Kimi Code runs MCP servers, but nothing registers one from a script: it
|
|
86
|
+
// reads ~/.kimi-code/mcp.json, edited by hand or through the in-session
|
|
87
|
+
// /mcp-config picker. (The deprecated Python kimi-cli did have `kimi mcp
|
|
88
|
+
// add`; Kimi Code dropped the subcommand.) MoshCode drives each engine's
|
|
89
|
+
// own CLI rather than writing its config file, so this is a stated skip —
|
|
90
|
+
// and a more useful one than the blanket "no MCP support", which would
|
|
91
|
+
// read as "kimi cannot do MCP at all".
|
|
92
|
+
return { skip: "no scriptable `mcp add` — add it in kimi with /mcp-config, or in ~/.kimi-code/mcp.json" };
|
|
93
|
+
case "codex": {
|
|
94
|
+
if (headers.length) {
|
|
95
|
+
return { skip: "Codex supports only a bearer-token env var, not literal headers" };
|
|
96
|
+
}
|
|
97
|
+
const argv = ["mcp", "add", name];
|
|
98
|
+
for (const [k, v] of env) argv.push("--env", `${k}=${v}`);
|
|
99
|
+
if (remote) argv.push("--url", target);
|
|
100
|
+
else argv.push("--", target, ...args);
|
|
101
|
+
return { argv };
|
|
102
|
+
}
|
|
103
|
+
// privacycode is opencode-derived, so it shares opencode's `mcp add` surface
|
|
104
|
+
// — including the "remote servers only, non-interactively" limitation.
|
|
105
|
+
case "opencode":
|
|
106
|
+
case "privacycode": {
|
|
107
|
+
if (!remote) {
|
|
108
|
+
return { skip: `${key === "privacycode" ? "privacycode" : "OpenCode"} CLI adds only remote (--url) servers non-interactively` };
|
|
109
|
+
}
|
|
110
|
+
const argv = ["mcp", "add", name, "--url", target];
|
|
111
|
+
for (const [k, v] of env) argv.push("--env", `${k}=${v}`);
|
|
112
|
+
for (const h of headers) argv.push("--header", headerToEq(h));
|
|
113
|
+
return { argv };
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
return { skip: "no MCP support" };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Plan the fan-out: one entry per engine with its native argv or skip reason,
|
|
122
|
+
* annotated with install status. Pure + testable.
|
|
123
|
+
*
|
|
124
|
+
* Every engine, not just MCP_ENGINES — R6 requires an engine that cannot
|
|
125
|
+
* express the server to be skipped *with a stated reason*, and the PRD's own
|
|
126
|
+
* UX example ends on `· aider skipped — no MCP support`. Mapping MCP_ENGINES
|
|
127
|
+
* dropped those engines before they reached the summary, so the fan-out
|
|
128
|
+
* reported five rows where /mcp list reports six. MCP_ENGINES stays the
|
|
129
|
+
* capability set (it is what the matrix splits "supported" on); it is only the
|
|
130
|
+
* iteration that widens. Supported engines keep their existing order.
|
|
131
|
+
*/
|
|
132
|
+
export function planMcpAdd(spec, { installedSet } = {}) {
|
|
133
|
+
const rest = Object.keys(ENGINES).filter((key) => !MCP_ENGINES.includes(key));
|
|
134
|
+
return [...MCP_ENGINES, ...rest].map((key) => {
|
|
135
|
+
const bin = ENGINES[key].bin;
|
|
136
|
+
const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs);
|
|
137
|
+
return { key, bin, installed, ...mcpAddArgs(key, spec) };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Execute a plan: run each installed, non-skipped engine's `mcp add`. Returns
|
|
143
|
+
* results [{ key, status: "added"|"skipped"|"failed"|"not-installed", reason? }].
|
|
144
|
+
* `run` is injectable for tests; defaults to the real spawner.
|
|
145
|
+
*/
|
|
146
|
+
export async function runMcpAdd(plan, { run = runCmd } = {}) {
|
|
147
|
+
const results = [];
|
|
148
|
+
for (const item of plan) {
|
|
149
|
+
if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
|
|
150
|
+
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 });
|
|
153
|
+
}
|
|
154
|
+
return results;
|
|
155
|
+
}
|
package/src/mirror.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Live session mirror — the CLI half of `/sessions` on app.moshcode.sh.
|
|
2
|
+
//
|
|
3
|
+
// While the pit is open, everything moshcode prints is tee'd to the app so you
|
|
4
|
+
// can watch this machine from a browser, and commands typed there are handed
|
|
5
|
+
// back to the prompt as if you'd typed them. It is deliberately best-effort:
|
|
6
|
+
// every network call is swallowed, because a flaky link must never take down
|
|
7
|
+
// the terminal you're actually working in.
|
|
8
|
+
//
|
|
9
|
+
// What it can't see: once an engine takes the terminal (`/agents claude`), the
|
|
10
|
+
// child writes straight to the tty on its own fd — those bytes never pass
|
|
11
|
+
// through this process. The mirror shows the hand-off, not the engine's screen.
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { loadCreds } from "./auth.mjs";
|
|
14
|
+
|
|
15
|
+
const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty
|
|
16
|
+
const MAX_BUFFER = 16000; // flush early once a batch gets big
|
|
17
|
+
|
|
18
|
+
export function createMirror({
|
|
19
|
+
version = "",
|
|
20
|
+
cwd = process.cwd(),
|
|
21
|
+
fetchImpl = fetch,
|
|
22
|
+
credentials = loadCreds(),
|
|
23
|
+
} = {}) {
|
|
24
|
+
const creds = credentials;
|
|
25
|
+
let sessionId = null;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
let pending = "";
|
|
28
|
+
let flushTimer = null;
|
|
29
|
+
let engine = null;
|
|
30
|
+
let engineDirty = false;
|
|
31
|
+
// The parked long-poll, so stop() can cut it loose instead of leaving the
|
|
32
|
+
// process alive for up to a full poll window after the pit closes.
|
|
33
|
+
let poll = null;
|
|
34
|
+
const onCommand = new Set();
|
|
35
|
+
|
|
36
|
+
const api = (creds?.api || "https://app.moshcode.sh").replace(/\/+$/, "");
|
|
37
|
+
const headers = { "content-type": "application/json", authorization: `Bearer ${creds?.token}` };
|
|
38
|
+
|
|
39
|
+
const post = async (path, body, opts = {}) => {
|
|
40
|
+
try {
|
|
41
|
+
const r = await fetchImpl(`${api}${path}`, { method: "POST", headers, body: JSON.stringify(body), ...opts });
|
|
42
|
+
return r.ok ? await r.json().catch(() => ({})) : null;
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// The browser runs a real terminal emulator over this stream, so it has to
|
|
47
|
+
// know how wide the tty on this end is — otherwise every line wraps at the
|
|
48
|
+
// wrong column and anything that redraws in place lands crooked.
|
|
49
|
+
//
|
|
50
|
+
// Piped output (CI, `mosh | tee`) has no size at all, and half a size is no
|
|
51
|
+
// use to an emulator, so send nothing rather than nulls: the app keeps
|
|
52
|
+
// whatever it already had and the page falls back to filling its box.
|
|
53
|
+
const size = () => {
|
|
54
|
+
const { columns, rows } = process.stdout;
|
|
55
|
+
return columns && rows ? { cols: columns, rows } : {};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function flush() {
|
|
59
|
+
flushTimer = null;
|
|
60
|
+
if (!sessionId || (!pending && !engineDirty)) return;
|
|
61
|
+
const chunk = pending;
|
|
62
|
+
pending = "";
|
|
63
|
+
engineDirty = false;
|
|
64
|
+
await post(`/api/sessions/${sessionId}/output`, { chunk, engine, ...size() });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function schedule() {
|
|
68
|
+
if (flushTimer || stopped) return;
|
|
69
|
+
flushTimer = setTimeout(flush, FLUSH_MS);
|
|
70
|
+
flushTimer.unref?.(); // never hold the process open on account of the mirror
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Tee a chunk of terminal output to the app. */
|
|
74
|
+
function write(text) {
|
|
75
|
+
if (!sessionId || stopped || !text) return;
|
|
76
|
+
pending += text;
|
|
77
|
+
if (pending.length >= MAX_BUFFER) { clearTimeout(flushTimer); flushTimer = null; flush(); }
|
|
78
|
+
else schedule();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Note which engine owns the terminal right now (null = back in the pit). */
|
|
82
|
+
function setEngine(name) {
|
|
83
|
+
const next = name || null;
|
|
84
|
+
if (next === engine) return;
|
|
85
|
+
engine = next;
|
|
86
|
+
engineDirty = true;
|
|
87
|
+
schedule();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Dragging a window edge fires `resize` continuously, so settle first and
|
|
91
|
+
// send one empty post carrying the final geometry.
|
|
92
|
+
let resizeTimer = null;
|
|
93
|
+
function onResize() {
|
|
94
|
+
if (stopped || !sessionId) return;
|
|
95
|
+
clearTimeout(resizeTimer);
|
|
96
|
+
resizeTimer = setTimeout(() => {
|
|
97
|
+
resizeTimer = null;
|
|
98
|
+
post(`/api/sessions/${sessionId}/output`, { chunk: "", engine, ...size() });
|
|
99
|
+
}, 120);
|
|
100
|
+
resizeTimer.unref?.();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Long-poll for commands typed on the web. One request parks on the server
|
|
104
|
+
// until something is queued, so a command lands in well under a second
|
|
105
|
+
// without us hammering the API.
|
|
106
|
+
async function pump() {
|
|
107
|
+
while (!stopped && sessionId) {
|
|
108
|
+
let got = null;
|
|
109
|
+
try {
|
|
110
|
+
poll = new AbortController();
|
|
111
|
+
const r = await fetchImpl(`${api}/api/sessions/${sessionId}/commands`, { headers, signal: poll.signal });
|
|
112
|
+
got = r.ok ? await r.json() : null;
|
|
113
|
+
} catch { /* network blip or stop() aborting us — handled below */ }
|
|
114
|
+
if (stopped) return;
|
|
115
|
+
if (!got) { await sleep(5000); continue; }
|
|
116
|
+
for (const c of got.commands || []) {
|
|
117
|
+
for (const fn of onCommand) { try { fn(c.body); } catch { /* handler's problem */ } }
|
|
118
|
+
post(`/api/sessions/${sessionId}/commands/${c.id}`, {});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
get id() { return sessionId; },
|
|
125
|
+
get url() { return sessionId ? `${api}/sessions/${sessionId}` : null; },
|
|
126
|
+
/** Register with the app. Resolves false when not logged in or unreachable. */
|
|
127
|
+
async start() {
|
|
128
|
+
if (!creds?.token) return false;
|
|
129
|
+
const r = await post("/api/sessions", {
|
|
130
|
+
name: `mosh @ ${os.hostname()}`,
|
|
131
|
+
host: os.hostname(),
|
|
132
|
+
version,
|
|
133
|
+
cwd,
|
|
134
|
+
...size(),
|
|
135
|
+
});
|
|
136
|
+
if (!r?.id) return false;
|
|
137
|
+
sessionId = r.id;
|
|
138
|
+
// A resize carries no output of its own, so nudge a flush: the new
|
|
139
|
+
// geometry rides the next post and the watching browser reshapes with us
|
|
140
|
+
// instead of waiting for whatever gets printed next.
|
|
141
|
+
process.stdout.on("resize", onResize);
|
|
142
|
+
pump();
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
write,
|
|
146
|
+
setEngine,
|
|
147
|
+
/** Subscribe to commands sent from the web. Returns an unsubscribe fn. */
|
|
148
|
+
onCommand(fn) { onCommand.add(fn); return () => onCommand.delete(fn); },
|
|
149
|
+
async stop() {
|
|
150
|
+
if (!sessionId || stopped) return;
|
|
151
|
+
stopped = true;
|
|
152
|
+
clearTimeout(flushTimer);
|
|
153
|
+
clearTimeout(resizeTimer);
|
|
154
|
+
flushTimer = null;
|
|
155
|
+
resizeTimer = null;
|
|
156
|
+
process.stdout.off?.("resize", onResize);
|
|
157
|
+
try { poll?.abort(); } catch { /* already gone */ }
|
|
158
|
+
// Flush whatever is left before saying goodbye, so the last thing you
|
|
159
|
+
// did is visible in the mirror rather than lost with the process.
|
|
160
|
+
if (pending) {
|
|
161
|
+
const chunk = pending;
|
|
162
|
+
pending = "";
|
|
163
|
+
await post(`/api/sessions/${sessionId}/output`, { chunk, engine, ...size() });
|
|
164
|
+
}
|
|
165
|
+
await post(`/api/sessions/${sessionId}/end`, {});
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Tee process stdout/stderr into `sink` while leaving the real terminal
|
|
174
|
+
* untouched. Returns a restore fn.
|
|
175
|
+
*/
|
|
176
|
+
export function teeOutput(sink) {
|
|
177
|
+
const targets = [process.stdout, process.stderr];
|
|
178
|
+
const originals = targets.map((s) => s.write.bind(s));
|
|
179
|
+
targets.forEach((stream, i) => {
|
|
180
|
+
stream.write = (chunk, enc, cb) => {
|
|
181
|
+
try { sink(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); }
|
|
182
|
+
catch { /* mirroring must never break printing */ }
|
|
183
|
+
return originals[i](chunk, enc, cb);
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
return () => targets.forEach((stream, i) => { stream.write = originals[i]; });
|
|
187
|
+
}
|