diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 502da9f..d122752 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -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"); @@ -36,11 +37,14 @@ function help() { console.log(`moshcode β€” metal scripting toolkit 🀘 usage: + moshcode open the TUI shell (then /agents ) + moshcode [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 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 @@ -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 , 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") { @@ -116,8 +125,22 @@ async function main() { return; } + // `moshcode [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(); diff --git a/src/engines.mjs b/src/engines.mjs index 6f03353..96cd6b6 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -1,6 +1,11 @@ // Agentic-coding engines moshcode can install + wrap. `moshcode install ` -// runs the engine's official installer; moshcode itself stays lean (no vendored +// runs the engine's official installer; `/agents ` (or `moshcode `) +// 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)", @@ -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 })); + }); +} diff --git a/src/tui.mjs b/src/tui.mjs new file mode 100644 index 0000000..d3e392e --- /dev/null +++ b/src/tui.mjs @@ -0,0 +1,136 @@ +// The moshcode shell β€” run `moshcode` with no args. A metal prompt that opens +// passthrough sessions on any engine via `/agents `, 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 ")); + 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 ")} open a session (claude Β· codex Β· gemini Β· aider Β· opencode)`, + ` ${acid("/install ")} install an engine`, + ` ${acid("/run ")} 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 ")); continue; } + await runFile(rest[0]); + continue; + } + if (cmd === "install") { + if (!rest[0]) { console.log(err("usage: /install ")); 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. 🀘")); +} diff --git a/src/ui.mjs b/src/ui.mjs new file mode 100644 index 0000000..83748b0 --- /dev/null +++ b/src/ui.mjs @@ -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))); +}