Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
import path from "node:path";
import { compile, run } from "../src/interpreter.mjs";
import { defaultCommands } from "../src/commands.mjs";
import { ENGINES, engineList } from "../src/engines.mjs";
import { ENGINES, engineList, engineStatus, resolveEngine, openSession } from "../src/engines.mjs";
import { tui } from "../src/tui.mjs";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const EXAMPLE = path.join(HERE, "..", "examples", "alive.mosh");
Expand Down Expand Up @@ -36,11 +37,14 @@ function help() {
console.log(`moshcode — metal scripting toolkit 🤘

usage:
moshcode open the TUI shell (then /agents <engine>)
moshcode <engine> [args…] open a passthrough session on an engine
moshcode run [file.mosh] [--max N] run a moshscript (stdin with '-', or the
built-in loop if no file); --max bounds
the while loop (default 3)
moshcode install <engine> install an agentic-coding engine
moshcode engines list installable engines
moshcode agents list engines + install status
moshcode engines (alias of agents)
moshcode commands list built-in moshscript commands
moshcode help this

Expand All @@ -61,8 +65,13 @@ env: MOSHCODE_API (default https://moshcoding.com), MOSHCODE_WEBHOOK_URL,
async function main() {
const [, , cmd, ...rest] = process.argv;

if (cmd === "engines") {
console.log("installable engines:\n" + engineList());
// No args → open the interactive TUI shell (/agents <engine>, etc.).
if (cmd === undefined) return tui();

if (cmd === "engines" || cmd === "agents") {
for (const e of engineStatus()) {
console.log(`${e.installed ? "●" : "○"} ${e.key.padEnd(10)} ${e.desc}`);
}
return;
}
if (cmd === "install") {
Expand Down Expand Up @@ -116,8 +125,22 @@ async function main() {
return;
}

// `moshcode <engine> [args…]` → open a passthrough session directly.
const resolved = resolveEngine(cmd);
if (resolved) {
const [key, engine] = resolved;
const r = await openSession(engine, rest);
if (!r.ok) {
console.error(r.error?.code === "ENOENT"
? `${key} isn't installed (\`${engine.bin}\`). run: moshcode install ${key}`
: `launch failed: ${r.error?.message || r.error}`);
process.exit(1);
}
process.exit(r.code ?? 0);
}

help();
if (cmd && cmd !== "help") process.exit(cmd === undefined ? 0 : 1);
if (cmd && cmd !== "help") process.exit(1);
}

main();
59 changes: 58 additions & 1 deletion src/engines.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// Agentic-coding engines moshcode can install + wrap. `moshcode install <name>`
// runs the engine's official installer; moshcode itself stays lean (no vendored
// runs the engine's official installer; `/agents <name>` (or `moshcode <name>`)
// opens a passthrough session on it. moshcode itself stays lean (no vendored
// fork). Add engines here.
import { spawn } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import path from "node:path";

export const ENGINES = {
opencode: {
desc: "opencode — the open-source coding agent (SST/anomalyco)",
Expand All @@ -17,8 +22,60 @@ export const ENGINES = {
bin: "codex",
install: { cmd: "npm", args: ["install", "-g", "@openai/codex"] },
},
gemini: {
desc: "Gemini CLI — Google's agentic CLI",
bin: "gemini",
install: { cmd: "npm", args: ["install", "-g", "@google/gemini-cli"] },
},
aider: {
desc: "Aider — pair-programming in your terminal",
bin: "aider",
install: { cmd: "bash", args: ["-c", "curl -LsSf https://aider.chat/install.sh | sh"] },
},
};

/** Aliases so `/agents cc` etc. resolve. */
const ALIASES = { cc: "claude", "claude-code": "claude", openai: "codex", gpt: "codex", google: "gemini" };

/** Resolve a name/alias to `[key, engine]`, or null. */
export function resolveEngine(token) {
if (!token) return null;
const t = String(token).trim().toLowerCase();
const key = ENGINES[t] ? t : ALIASES[t];
return key ? [key, ENGINES[key]] : null;
}

/** Is `bin` an executable on PATH? (cross-platform-ish) */
export function isInstalled(bin) {
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
for (const ext of exts) {
try { if (existsSync(path.join(dir, bin + ext)) && statSync(path.join(dir, bin + ext)).isFile()) return true; } catch { /* keep looking */ }
}
}
return false;
}

/** Engine entries annotated with install status. */
export function engineStatus() {
return Object.entries(ENGINES).map(([key, e]) => ({ key, ...e, installed: isInstalled(e.bin) }));
}

export function engineList() {
return Object.entries(ENGINES).map(([k, v]) => ` ${k.padEnd(10)} ${v.desc}`).join("\n");
}

/**
* Open a session on an engine: spawn its CLI with stdio inherited so the child
* fully owns the terminal (its own TUI, prompts, colors — full stdin/stdout/
* stderr passthrough). Resolves { ok, code } when it exits.
*/
export function openSession(engine, args = []) {
return new Promise((resolve) => {
let child;
try { child = spawn(engine.bin, args, { stdio: "inherit" }); }
catch (e) { resolve({ ok: false, error: e }); return; }
child.on("error", (e) => resolve({ ok: false, error: e }));
child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
});
}
136 changes: 136 additions & 0 deletions src/tui.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// The moshcode shell — run `moshcode` with no args. A metal prompt that opens
// passthrough sessions on any engine via `/agents <engine>`, installs engines,
// and runs moshscript. Each session hands the whole terminal to the engine's own
// CLI and takes it back on exit.
import readline from "node:readline";
import { spawn } from "node:child_process";
import fs from "node:fs";
import { ENGINES, resolveEngine, engineStatus, openSession } from "./engines.mjs";
import { compile, run } from "./interpreter.mjs";
import { defaultCommands } from "./commands.mjs";
import { banner, hr, acid, ash, bone, dim, ok, err, info } from "./ui.mjs";

const PROMPT = () => acid("mosh ") + dim("▸ ");
const mkrl = () => readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (rl) => new Promise((res) => rl.question(PROMPT(), res));

function printEngines() {
console.log(bone(" engines") + ash(" — open one with ") + acid("/agents <name>"));
for (const e of engineStatus()) {
const dot = e.installed ? acid("●") : ash("○");
console.log(` ${dot} ${bone(e.key.padEnd(9))} ${ash(e.installed ? "installed" : "not installed — /install " + e.key)}`);
}
}

function printHelp() {
console.log([
bone(" commands"),
` ${acid("/agents")} list coding engines`,
` ${acid("/agents <name>")} open a session (claude · codex · gemini · aider · opencode)`,
` ${acid("/install <name>")} install an engine`,
` ${acid("/run <file.mosh>")} run a moshscript program`,
` ${acid("/help")} this`,
` ${acid("/quit")} leave the pit (or Ctrl-D)`,
"",
ash(" shortcut: type an engine name by itself, e.g. ") + acid("claude"),
].join("\n"));
}

async function openEngine(key, engine, args) {
if (!engine.installed && !args.length) {
console.log(info(`${key} isn't installed — try ${acid("/install " + key)} first.`));
}
console.log(info(`opening ${bone(key)} — hand-off to its CLI, exit it to come back…`));
console.log(hr());
const r = await openSession(engine, args);
console.log(hr());
if (!r.ok) {
console.log(r.error?.code === "ENOENT"
? err(`${key} isn't on PATH (\`${engine.bin}\`). install it with /install ${key}`)
: err(`couldn't launch ${key}: ${r.error?.message || r.error}`));
} else {
console.log(info(`${key} exited${r.code != null ? ` (code ${r.code})` : ""}. back in the pit.`));
}
}

function installEngine(key) {
return new Promise((resolve) => {
const engine = ENGINES[key];
if (!engine) { console.log(err(`unknown engine "${key}"`)); return resolve(); }
console.log(info(`installing ${key}: ${engine.install.cmd} ${engine.install.args.join(" ")}`));
console.log(hr());
const child = spawn(engine.install.cmd, engine.install.args, { stdio: "inherit" });
child.on("error", (e) => { console.log(hr()); console.log(err(`install failed: ${e.message}`)); resolve(); });
child.on("exit", (code) => { console.log(hr()); console.log(code === 0 ? ok(`${key} installed. 🤘`) : err(`install exited ${code}`)); resolve(); });
});
}

async function runFile(file) {
let src;
try { src = fs.readFileSync(file, "utf8"); }
catch (e) { console.log(err(`can't read ${file}: ${e.message}`)); return; }
let ast;
try { ast = compile(src); } catch (e) { console.log(err(String(e.message || e))); return; }
console.log(hr());
const ctx = { vars: { alive: true }, iter: 0, maxIterations: 100000, out: (s) => console.log(s), commands: defaultCommands() };
try { await run(ast, ctx); } catch (e) { console.log(err(String(e.message || e))); }
console.log(hr());
console.log(info(`moshscript done — ${ctx.iter} loop(s).`));
}

export async function tui() {
console.log(banner());
console.log();
printEngines();
console.log("\n" + ash(" /help for commands · /quit to leave") + "\n");

let rl = mkrl();
for (;;) {
let line;
try { line = await ask(rl); } catch { break; }
if (line == null) break; // Ctrl-D
line = line.trim();
if (!line) continue;

const [raw, ...rest] = line.split(/\s+/);
const cmd = raw.toLowerCase().replace(/^\//, "");

if (cmd === "quit" || cmd === "exit" || cmd === "q") break;
if (cmd === "help" || cmd === "?" || cmd === "h") { printHelp(); continue; }
if (cmd === "run") {
if (!rest[0]) { console.log(err("usage: /run <file.mosh>")); continue; }
await runFile(rest[0]);
continue;
}
if (cmd === "install") {
if (!rest[0]) { console.log(err("usage: /install <engine>")); continue; }
rl.close();
await installEngine(rest[0].toLowerCase());
rl = mkrl();
continue;
}
if (cmd === "agents" || cmd === "agent" || cmd === "engines") {
if (!rest[0]) { printEngines(); continue; }
const resolved = resolveEngine(rest[0]);
if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; }
const [key, engine] = resolved;
rl.close();
await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest.slice(1));
rl = mkrl();
continue;
}
// Bare engine name → open it.
const resolved = resolveEngine(cmd);
if (resolved) {
const [key, engine] = resolved;
rl.close();
await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest);
rl = mkrl();
continue;
}
console.log(err(`unknown command "${line}". /help for the list.`));
}

try { rl.close(); } catch { /* noop */ }
console.log("\n" + ash("code hard, mosh harder. 🤘"));
}
33 changes: 33 additions & 0 deletions src/ui.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Metal terminal styling — poison acid-lime (#9EF01A) on near-black, the
// moshcoding palette. Truecolor ANSI with a NO_COLOR opt-out.
const useColor = process.env.NO_COLOR == null && process.stdout.isTTY === true;
const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s));
const wrap = (o, c) => (s) => (useColor ? `\x1b[${o}m${s}\x1b[${c}m` : String(s));

export const acid = rgb(158, 240, 26);
export const bone = rgb(238, 242, 232);
export const ash = rgb(139, 147, 138);
export const danger = rgb(255, 77, 61);
export const spotify = rgb(29, 185, 84);
export const dim = wrap(2, 22);

export const ok = (s) => acid("✓ ") + s;
export const err = (s) => danger("✗ ") + s;
export const info = (s) => ash("· ") + s;

export function banner() {
return [
acid(" ███╗ ███╗ ██████╗ ███████╗██╗ ██╗"),
acid(" ████╗ ████║██╔═══██╗██╔════╝██║ ██║") + ash(" code hard,"),
acid(" ██╔████╔██║██║ ██║███████╗███████║") + ash(" mosh harder"),
acid(" ██║╚██╔╝██║██║ ██║╚════██║██╔══██║"),
acid(" ██║ ╚═╝ ██║╚██████╔╝███████║██║ ██║") + dim(" ⚡ #moshcoding"),
acid(" ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝"),
"",
" " + bone("moshcode") + ash(" · a wall of distortion for your coding agents"),
].join("\n");
}

export function hr() {
return ash("─".repeat(Math.min(process.stdout.columns || 60, 60)));
}
Loading