Skip to content

Commit d81d7dc

Browse files
ralyodioclaude
andauthored
Add the moshcode TUI shell + /agents <engine> passthrough sessions (#4)
Running `moshcode` with no args now opens a metal TUI (banner + live engine list + prompt). From there: /agents list engines /agents <engine> open a session with full stdin/stdout/stderr passthrough — the engine's own CLI owns the terminal; exit it to return /install <engine> install an engine /run <file.mosh> run moshscript A bare engine name is a shortcut, and `moshcode <engine> [args…]` opens a session directly from the shell. - src/engines.mjs: add gemini + aider; add resolveEngine / isInstalled / engineStatus / openSession (stdio-inherit passthrough) - src/ui.mjs: metal banner + acid-lime palette - src/tui.mjs: the interactive shell - bin/moshcode.mjs: no-arg → TUI; `moshcode <engine>` direct passthrough; `moshcode agents` shows install status Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9a42fdd commit d81d7dc

4 files changed

Lines changed: 255 additions & 6 deletions

File tree

bin/moshcode.mjs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
55
import path from "node:path";
66
import { compile, run } from "../src/interpreter.mjs";
77
import { defaultCommands } from "../src/commands.mjs";
8-
import { ENGINES, engineList } from "../src/engines.mjs";
8+
import { ENGINES, engineList, engineStatus, resolveEngine, openSession } from "../src/engines.mjs";
9+
import { tui } from "../src/tui.mjs";
910

1011
const HERE = path.dirname(fileURLToPath(import.meta.url));
1112
const EXAMPLE = path.join(HERE, "..", "examples", "alive.mosh");
@@ -36,11 +37,14 @@ function help() {
3637
console.log(`moshcode — metal scripting toolkit 🤘
3738
3839
usage:
40+
moshcode open the TUI shell (then /agents <engine>)
41+
moshcode <engine> [args…] open a passthrough session on an engine
3942
moshcode run [file.mosh] [--max N] run a moshscript (stdin with '-', or the
4043
built-in loop if no file); --max bounds
4144
the while loop (default 3)
4245
moshcode install <engine> install an agentic-coding engine
43-
moshcode engines list installable engines
46+
moshcode agents list engines + install status
47+
moshcode engines (alias of agents)
4448
moshcode commands list built-in moshscript commands
4549
moshcode help this
4650
@@ -61,8 +65,13 @@ env: MOSHCODE_API (default https://moshcoding.com), MOSHCODE_WEBHOOK_URL,
6165
async function main() {
6266
const [, , cmd, ...rest] = process.argv;
6367

64-
if (cmd === "engines") {
65-
console.log("installable engines:\n" + engineList());
68+
// No args → open the interactive TUI shell (/agents <engine>, etc.).
69+
if (cmd === undefined) return tui();
70+
71+
if (cmd === "engines" || cmd === "agents") {
72+
for (const e of engineStatus()) {
73+
console.log(`${e.installed ? "●" : "○"} ${e.key.padEnd(10)} ${e.desc}`);
74+
}
6675
return;
6776
}
6877
if (cmd === "install") {
@@ -116,8 +125,22 @@ async function main() {
116125
return;
117126
}
118127

128+
// `moshcode <engine> [args…]` → open a passthrough session directly.
129+
const resolved = resolveEngine(cmd);
130+
if (resolved) {
131+
const [key, engine] = resolved;
132+
const r = await openSession(engine, rest);
133+
if (!r.ok) {
134+
console.error(r.error?.code === "ENOENT"
135+
? `${key} isn't installed (\`${engine.bin}\`). run: moshcode install ${key}`
136+
: `launch failed: ${r.error?.message || r.error}`);
137+
process.exit(1);
138+
}
139+
process.exit(r.code ?? 0);
140+
}
141+
119142
help();
120-
if (cmd && cmd !== "help") process.exit(cmd === undefined ? 0 : 1);
143+
if (cmd && cmd !== "help") process.exit(1);
121144
}
122145

123146
main();

src/engines.mjs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Agentic-coding engines moshcode can install + wrap. `moshcode install <name>`
2-
// runs the engine's official installer; moshcode itself stays lean (no vendored
2+
// runs the engine's official installer; `/agents <name>` (or `moshcode <name>`)
3+
// opens a passthrough session on it. moshcode itself stays lean (no vendored
34
// fork). Add engines here.
5+
import { spawn } from "node:child_process";
6+
import { existsSync, statSync } from "node:fs";
7+
import path from "node:path";
8+
49
export const ENGINES = {
510
opencode: {
611
desc: "opencode — the open-source coding agent (SST/anomalyco)",
@@ -17,8 +22,60 @@ export const ENGINES = {
1722
bin: "codex",
1823
install: { cmd: "npm", args: ["install", "-g", "@openai/codex"] },
1924
},
25+
gemini: {
26+
desc: "Gemini CLI — Google's agentic CLI",
27+
bin: "gemini",
28+
install: { cmd: "npm", args: ["install", "-g", "@google/gemini-cli"] },
29+
},
30+
aider: {
31+
desc: "Aider — pair-programming in your terminal",
32+
bin: "aider",
33+
install: { cmd: "bash", args: ["-c", "curl -LsSf https://aider.chat/install.sh | sh"] },
34+
},
2035
};
2136

37+
/** Aliases so `/agents cc` etc. resolve. */
38+
const ALIASES = { cc: "claude", "claude-code": "claude", openai: "codex", gpt: "codex", google: "gemini" };
39+
40+
/** Resolve a name/alias to `[key, engine]`, or null. */
41+
export function resolveEngine(token) {
42+
if (!token) return null;
43+
const t = String(token).trim().toLowerCase();
44+
const key = ENGINES[t] ? t : ALIASES[t];
45+
return key ? [key, ENGINES[key]] : null;
46+
}
47+
48+
/** Is `bin` an executable on PATH? (cross-platform-ish) */
49+
export function isInstalled(bin) {
50+
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
51+
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
52+
for (const ext of exts) {
53+
try { if (existsSync(path.join(dir, bin + ext)) && statSync(path.join(dir, bin + ext)).isFile()) return true; } catch { /* keep looking */ }
54+
}
55+
}
56+
return false;
57+
}
58+
59+
/** Engine entries annotated with install status. */
60+
export function engineStatus() {
61+
return Object.entries(ENGINES).map(([key, e]) => ({ key, ...e, installed: isInstalled(e.bin) }));
62+
}
63+
2264
export function engineList() {
2365
return Object.entries(ENGINES).map(([k, v]) => ` ${k.padEnd(10)} ${v.desc}`).join("\n");
2466
}
67+
68+
/**
69+
* Open a session on an engine: spawn its CLI with stdio inherited so the child
70+
* fully owns the terminal (its own TUI, prompts, colors — full stdin/stdout/
71+
* stderr passthrough). Resolves { ok, code } when it exits.
72+
*/
73+
export function openSession(engine, args = []) {
74+
return new Promise((resolve) => {
75+
let child;
76+
try { child = spawn(engine.bin, args, { stdio: "inherit" }); }
77+
catch (e) { resolve({ ok: false, error: e }); return; }
78+
child.on("error", (e) => resolve({ ok: false, error: e }));
79+
child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
80+
});
81+
}

src/tui.mjs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// The moshcode shell — run `moshcode` with no args. A metal prompt that opens
2+
// passthrough sessions on any engine via `/agents <engine>`, installs engines,
3+
// and runs moshscript. Each session hands the whole terminal to the engine's own
4+
// CLI and takes it back on exit.
5+
import readline from "node:readline";
6+
import { spawn } from "node:child_process";
7+
import fs from "node:fs";
8+
import { ENGINES, resolveEngine, engineStatus, openSession } from "./engines.mjs";
9+
import { compile, run } from "./interpreter.mjs";
10+
import { defaultCommands } from "./commands.mjs";
11+
import { banner, hr, acid, ash, bone, dim, ok, err, info } from "./ui.mjs";
12+
13+
const PROMPT = () => acid("mosh ") + dim("▸ ");
14+
const mkrl = () => readline.createInterface({ input: process.stdin, output: process.stdout });
15+
const ask = (rl) => new Promise((res) => rl.question(PROMPT(), res));
16+
17+
function printEngines() {
18+
console.log(bone(" engines") + ash(" — open one with ") + acid("/agents <name>"));
19+
for (const e of engineStatus()) {
20+
const dot = e.installed ? acid("●") : ash("○");
21+
console.log(` ${dot} ${bone(e.key.padEnd(9))} ${ash(e.installed ? "installed" : "not installed — /install " + e.key)}`);
22+
}
23+
}
24+
25+
function printHelp() {
26+
console.log([
27+
bone(" commands"),
28+
` ${acid("/agents")} list coding engines`,
29+
` ${acid("/agents <name>")} open a session (claude · codex · gemini · aider · opencode)`,
30+
` ${acid("/install <name>")} install an engine`,
31+
` ${acid("/run <file.mosh>")} run a moshscript program`,
32+
` ${acid("/help")} this`,
33+
` ${acid("/quit")} leave the pit (or Ctrl-D)`,
34+
"",
35+
ash(" shortcut: type an engine name by itself, e.g. ") + acid("claude"),
36+
].join("\n"));
37+
}
38+
39+
async function openEngine(key, engine, args) {
40+
if (!engine.installed && !args.length) {
41+
console.log(info(`${key} isn't installed — try ${acid("/install " + key)} first.`));
42+
}
43+
console.log(info(`opening ${bone(key)} — hand-off to its CLI, exit it to come back…`));
44+
console.log(hr());
45+
const r = await openSession(engine, args);
46+
console.log(hr());
47+
if (!r.ok) {
48+
console.log(r.error?.code === "ENOENT"
49+
? err(`${key} isn't on PATH (\`${engine.bin}\`). install it with /install ${key}`)
50+
: err(`couldn't launch ${key}: ${r.error?.message || r.error}`));
51+
} else {
52+
console.log(info(`${key} exited${r.code != null ? ` (code ${r.code})` : ""}. back in the pit.`));
53+
}
54+
}
55+
56+
function installEngine(key) {
57+
return new Promise((resolve) => {
58+
const engine = ENGINES[key];
59+
if (!engine) { console.log(err(`unknown engine "${key}"`)); return resolve(); }
60+
console.log(info(`installing ${key}: ${engine.install.cmd} ${engine.install.args.join(" ")}`));
61+
console.log(hr());
62+
const child = spawn(engine.install.cmd, engine.install.args, { stdio: "inherit" });
63+
child.on("error", (e) => { console.log(hr()); console.log(err(`install failed: ${e.message}`)); resolve(); });
64+
child.on("exit", (code) => { console.log(hr()); console.log(code === 0 ? ok(`${key} installed. 🤘`) : err(`install exited ${code}`)); resolve(); });
65+
});
66+
}
67+
68+
async function runFile(file) {
69+
let src;
70+
try { src = fs.readFileSync(file, "utf8"); }
71+
catch (e) { console.log(err(`can't read ${file}: ${e.message}`)); return; }
72+
let ast;
73+
try { ast = compile(src); } catch (e) { console.log(err(String(e.message || e))); return; }
74+
console.log(hr());
75+
const ctx = { vars: { alive: true }, iter: 0, maxIterations: 100000, out: (s) => console.log(s), commands: defaultCommands() };
76+
try { await run(ast, ctx); } catch (e) { console.log(err(String(e.message || e))); }
77+
console.log(hr());
78+
console.log(info(`moshscript done — ${ctx.iter} loop(s).`));
79+
}
80+
81+
export async function tui() {
82+
console.log(banner());
83+
console.log();
84+
printEngines();
85+
console.log("\n" + ash(" /help for commands · /quit to leave") + "\n");
86+
87+
let rl = mkrl();
88+
for (;;) {
89+
let line;
90+
try { line = await ask(rl); } catch { break; }
91+
if (line == null) break; // Ctrl-D
92+
line = line.trim();
93+
if (!line) continue;
94+
95+
const [raw, ...rest] = line.split(/\s+/);
96+
const cmd = raw.toLowerCase().replace(/^\//, "");
97+
98+
if (cmd === "quit" || cmd === "exit" || cmd === "q") break;
99+
if (cmd === "help" || cmd === "?" || cmd === "h") { printHelp(); continue; }
100+
if (cmd === "run") {
101+
if (!rest[0]) { console.log(err("usage: /run <file.mosh>")); continue; }
102+
await runFile(rest[0]);
103+
continue;
104+
}
105+
if (cmd === "install") {
106+
if (!rest[0]) { console.log(err("usage: /install <engine>")); continue; }
107+
rl.close();
108+
await installEngine(rest[0].toLowerCase());
109+
rl = mkrl();
110+
continue;
111+
}
112+
if (cmd === "agents" || cmd === "agent" || cmd === "engines") {
113+
if (!rest[0]) { printEngines(); continue; }
114+
const resolved = resolveEngine(rest[0]);
115+
if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; }
116+
const [key, engine] = resolved;
117+
rl.close();
118+
await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest.slice(1));
119+
rl = mkrl();
120+
continue;
121+
}
122+
// Bare engine name → open it.
123+
const resolved = resolveEngine(cmd);
124+
if (resolved) {
125+
const [key, engine] = resolved;
126+
rl.close();
127+
await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest);
128+
rl = mkrl();
129+
continue;
130+
}
131+
console.log(err(`unknown command "${line}". /help for the list.`));
132+
}
133+
134+
try { rl.close(); } catch { /* noop */ }
135+
console.log("\n" + ash("code hard, mosh harder. 🤘"));
136+
}

src/ui.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Metal terminal styling — poison acid-lime (#9EF01A) on near-black, the
2+
// moshcoding palette. Truecolor ANSI with a NO_COLOR opt-out.
3+
const useColor = process.env.NO_COLOR == null && process.stdout.isTTY === true;
4+
const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s));
5+
const wrap = (o, c) => (s) => (useColor ? `\x1b[${o}m${s}\x1b[${c}m` : String(s));
6+
7+
export const acid = rgb(158, 240, 26);
8+
export const bone = rgb(238, 242, 232);
9+
export const ash = rgb(139, 147, 138);
10+
export const danger = rgb(255, 77, 61);
11+
export const spotify = rgb(29, 185, 84);
12+
export const dim = wrap(2, 22);
13+
14+
export const ok = (s) => acid("✓ ") + s;
15+
export const err = (s) => danger("✗ ") + s;
16+
export const info = (s) => ash("· ") + s;
17+
18+
export function banner() {
19+
return [
20+
acid(" ███╗ ███╗ ██████╗ ███████╗██╗ ██╗"),
21+
acid(" ████╗ ████║██╔═══██╗██╔════╝██║ ██║") + ash(" code hard,"),
22+
acid(" ██╔████╔██║██║ ██║███████╗███████║") + ash(" mosh harder"),
23+
acid(" ██║╚██╔╝██║██║ ██║╚════██║██╔══██║"),
24+
acid(" ██║ ╚═╝ ██║╚██████╔╝███████║██║ ██║") + dim(" ⚡ #moshcoding"),
25+
acid(" ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝"),
26+
"",
27+
" " + bone("moshcode") + ash(" · a wall of distortion for your coding agents"),
28+
].join("\n");
29+
}
30+
31+
export function hr() {
32+
return ash("─".repeat(Math.min(process.stdout.columns || 60, 60)));
33+
}

0 commit comments

Comments
 (0)