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
23 changes: 15 additions & 8 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env node
import fs from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { runScript } from "../src/runtime.mjs";
Expand All @@ -12,6 +11,7 @@ import {
engineStatus,
resolveEngine,
openSession,
runCmd,
} from "../src/engines.mjs";
import { TOOLS, toolList, toolStatus, resolveTool, openTool } from "../src/tools.mjs";
import { runUpgrade } from "../src/upgrade.mjs";
Expand All @@ -20,6 +20,7 @@ import { locate, tilde } from "../src/pwd.mjs";
import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
import { login, loginDevice, whoami, logout } from "../src/auth.mjs";
import { tui } from "../src/tui.mjs";
import { moshcodeVersion } from "../src/ui.mjs";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const EXAMPLE = path.join(HERE, "..", "examples", "alive.mosh");
Expand Down Expand Up @@ -171,6 +172,11 @@ async function main() {
// No args → open the interactive TUI shell (/agents <engine>, etc.).
if (cmd === undefined) return tui();

if (cmd === "--version" || cmd === "-v" || cmd === "version") {
console.log(moshcodeVersion() || "unknown");
return;
}

if (cmd === "engines") {
printEngineStatus();
return;
Expand Down Expand Up @@ -222,13 +228,14 @@ async function main() {
}
const { install, desc, bin } = entry;
console.log(`🎸 installing ${target} — ${desc}\n$ ${install.cmd} ${install.args.join(" ")}\n`);
const child = spawn(install.cmd, install.args, { stdio: "inherit" });
child.on("error", (e) => { console.error(`install failed: ${e.message}`); process.exit(1); });
child.on("exit", (code) => {
if (code === 0) console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
backToPit(`install ${target}`, code);
});
return;
const result = await runCmd(install.cmd, install.args);
if (!result.ok) {
console.error(`install failed: ${result.error?.message || result.error || "unknown error"}`);
process.exitCode = 1;
return;
}
if (result.code === 0) console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
return backToPit(`install ${target}`, result.code);
}
if (cmd === "upgrade" || cmd === "update") {
console.log("🎸 moshcode upgrade — updating moshcode + installed engines/tools 🤘");
Expand Down
10 changes: 6 additions & 4 deletions src/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ const COMMANDS = [

{
name: "shell",
summary: "run a shell command (blocking, spawnSync $SHELL -c)",
summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL -c elsewhere)",
// The moshscript system verb for arbitrary shell commands. Blocking
// (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
// and the child owns the terminal for interactive commands. Returns
Expand All @@ -171,10 +171,12 @@ const COMMANDS = [
ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL -c ${JSON.stringify(cmd)}`);
return { ok: true, dryRun: true };
}
const sh = process.env.SHELL
|| (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh");
const sh = process.platform === "win32"
? (process.env.COMSPEC || "cmd.exe")
: (process.env.SHELL || "/bin/sh");
const shArgs = process.platform === "win32" ? ["/d", "/s", "/c", cmd] : ["-c", cmd];
ctx.out(` ▶ shell: ${cmd}`);
const res = spawnSync(sh, ["-c", cmd], { stdio: "inherit" });
const res = spawnSync(sh, shArgs, { stdio: "inherit" });
if (res.error) throw res.error;
const code = res.status ?? 1;
if (code !== 0) {
Expand Down
59 changes: 50 additions & 9 deletions src/engines.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// `agentsView` fall back to `agentArgs` — an autonomous session with native
// approvals bypassed/auto-approved.
import { spawn } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";

export const ENGINES = {
Expand Down Expand Up @@ -82,15 +82,54 @@ export function resolveEngine(token) {
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)) {
function executableCandidates(bin) {
const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
const dirs = path.isAbsolute(bin) || bin.includes(path.sep) ? [""] : (process.env.PATH || "").split(path.delimiter).filter(Boolean);
const seen = new Set();
const candidates = [];
for (const dir of dirs) {
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 */ }
const candidate = dir ? path.join(dir, bin + ext) : bin + ext;
const key = candidate.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
candidates.push(candidate);
}
}
}
return false;
return candidates;
}

function resolveExecutable(bin) {
for (const candidate of executableCandidates(bin)) {
try {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
} catch { /* keep looking */ }
}
return null;
}

function nodeShebang(file) {
try {
const head = readFileSync(file, "utf8").slice(0, 80);
return /^#!.*\bnode(?:\.exe)?\b/.test(head);
} catch {
return false;
}
}

function spawnSpec(bin, args = []) {
const resolved = resolveExecutable(bin);
if (!resolved) return { cmd: bin, args };
if (process.platform === "win32" && path.extname(resolved) === "" && nodeShebang(resolved)) {
return { cmd: process.execPath, args: [resolved, ...args] };
}
return { cmd: resolved, args };
}

/** Is `bin` an executable on PATH? (cross-platform-ish) */
export function isInstalled(bin) {
return Boolean(resolveExecutable(bin));
}

// Headless "run one prompt, print the answer, exit" invocation per engine — the
Expand Down Expand Up @@ -147,7 +186,8 @@ export function agentLaunchArgs(engine, args = []) {
export function runCmd(cmd, args = []) {
return new Promise((resolve) => {
let child;
try { child = spawn(cmd, args, { stdio: "inherit" }); }
const spec = spawnSpec(cmd, args);
try { child = spawn(spec.cmd, spec.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 }));
Expand All @@ -168,7 +208,8 @@ export function openPassthrough(target, args = []) {
for (const k of target.stripEnv) delete env[k];
}
let child;
try { child = spawn(target.bin, args, { stdio: "inherit", env }); }
const spec = spawnSpec(target.bin, args);
try { child = spawn(spec.cmd, spec.args, { stdio: "inherit", env }); }
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 }));
Expand Down
70 changes: 69 additions & 1 deletion src/tools.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,72 @@
// tools.mjs - Moshcode utility functions
// Adjacent workflow CLIs moshcode can install and transparently invoke.
// These are deliberately separate from coding engines: UGig owns marketplace
// workflows, CoinPay owns payment workflows, c0mpute owns the compute network,
// and moshcode only conducts their native command lines.
import { isInstalled, openPassthrough } from "./engines.mjs";

export const TOOLS = {
ugig: {
desc: "UGig — freelance marketplace CLI for humans and agents",
bin: "ugig",
// UGig isn't published to npm — it ships via its own install script.
install: { cmd: "bash", args: ["-c", "curl -fsSL https://ugig.net/install.sh | bash"] },
},
coinpay: {
desc: "CoinPay — wallets, payments, swaps, escrow, and settlement",
bin: "coinpay",
// CoinPay ships via its own install script (fetched from GitHub), not npm.
install: { cmd: "sh", args: ["-c", "curl -fsSL https://coinpayportal.com/install.sh | sh"] },
},
c0mpute: {
desc: "c0mpute — decentralized compute network CLI",
bin: "c0mpute",
// c0mpute ships via its own install script (the v1 stack installer).
install: { cmd: "sh", args: ["-c", "curl -fsSL https://c0mpute.com/install.sh | sh"] },
},
secrets: {
desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)",
// The passthrough target is the `logicsrc` binary; the moshcode command is
// `/secrets` so it reads as "manage secrets". LOGICSRC_BIN points at a local
// build before logicsrc ships a global install.
bin: process.env.LOGICSRC_BIN || "logicsrc",
// LogicSRC ships via its own install script (same pattern as the others).
install: { cmd: "sh", args: ["-c", "curl -fsSL https://logicsrc.com/install.sh | sh"] },
},
};

/** Resolve a name to `[key, tool]`, or null. */
export function resolveTool(token) {
if (!token) return null;
const key = String(token).trim().toLowerCase();
return TOOLS[key] ? [key, TOOLS[key]] : null;
}

/** Tool entries annotated with native executable install status. */
export function toolStatus() {
return Object.entries(TOOLS).map(([key, tool]) => ({
key,
...tool,
installed: isInstalled(tool.bin),
}));
}

export function toolList() {
return Object.entries(TOOLS)
.map(([key, tool]) => ` ${key.padEnd(10)} ${tool.desc}`)
.join("\n");
}

/** Prefer a native updater when one is added; npm installs are idempotent. */
export function toolUpgradeSpec(tool) {
return tool.upgrade || tool.install;
}

/** Invoke a tool without parsing or modifying its arguments or streams. */
export function openTool(tool, args = []) {
return openPassthrough(tool, args);
}

// Generic utilities used by the app/package surface.

/**
* Format a number as currency
Expand Down
21 changes: 17 additions & 4 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

import { runMoshcode, cliVerb, runAi } from "../src/cli.mjs";
import { aiExecArgs, pickAiEngine } from "../src/engines.mjs";
import { moshVocabulary } from "../src/commands.mjs";
import { runScript } from "../src/runtime.mjs";
import { createRegistry } from "../src/registry.mjs";

test("moshcode --version prints the package version", () => {
const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
const result = spawnSync(process.execPath, [fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)), "--version"], {
encoding: "utf8",
});
assert.equal(result.status, 0);
assert.equal(result.stdout.trim(), expected);
assert.equal(result.stderr, "");
});

function dryCtx() {
return { dryRun: true, lines: [], out(l) { this.lines.push(l); } };
}
Expand Down Expand Up @@ -164,7 +177,7 @@ test("shell() runs a real command and returns { ok, code }", () => {
const lines = [];
const ctx = { dryRun: false, out: (l) => lines.push(l) };
const cmd = moshVocabulary().get("shell");
const result = cmd.run(ctx, "true");
const result = cmd.run(ctx, "node -e process.exitCode=0");
assert.equal(result.ok, true);
assert.equal(result.code, 0);
});
Expand All @@ -173,15 +186,15 @@ test("shell() returns { ok: false } on non-zero exit without throwing", () => {
const lines = [];
const ctx = { dryRun: false, out: (l) => lines.push(l) };
const cmd = moshVocabulary().get("shell");
const result = cmd.run(ctx, "false");
const result = cmd.run(ctx, "node -e process.exitCode=7");
assert.equal(result.ok, false);
assert.ok(result.code !== 0);
assert.equal(result.code, 7);
});

test("shell() is callable from moshscript and the script continues on failure", async () => {
const lines = [];
await runScript(
`const r = shell("false"); say("continued, ok=" + r.ok);`,
`const r = shell("node -e process.exitCode=7"); say("continued, ok=" + r.ok);`,
{ commands: moshVocabulary(), out: (s) => lines.push(s) }
);
assert.match(lines.join("\n"), /continued, ok=false/);
Expand Down
6 changes: 6 additions & 0 deletions test/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,9 @@ test("the vocabulary exposes summaries for `moshcode commands`", () => {
assert.ok(cmd.summary.length > 0, `${cmd.name}() needs a summary`);
}
});

test("shell() summary describes the portable shell behavior", () => {
const cmd = moshVocabulary().get("shell");
assert.match(cmd.summary, /cmd\.exe on Windows/);
assert.match(cmd.summary, /\$SHELL -c elsewhere/);
});
Loading