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
22 changes: 15 additions & 7 deletions packages/amico-run/esbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { build } from "esbuild";
import { chmodSync } from "node:fs";

await build({
entryPoints: ["src/cli.ts"],
// Two bins from one package: the historical `amico-run` (entry cli.ts) and the new `amico`
// verb router (entry amico.ts, issue #108). Both share the launch path (src/launch.ts);
// amico.ts additionally bundles the spine verbs + the mcp-serve facade.
const common = {
bundle: true,
platform: "node",
target: "node20",
// ESM, not CJS: the package is "type": "module", so node executes dist/amico-run.js
// as ESM — a CJS bundle would die on `require is not defined in ES module scope`.
// ESM, not CJS: the package is "type": "module", so node executes the bundle as ESM —
// a CJS bundle would die on `require is not defined in ES module scope`.
format: "esm",
outfile: "dist/amico-run.js",
banner: { js: "#!/usr/bin/env node" },
sourcemap: true,
logLevel: "info",
});
chmodSync("dist/amico-run.js", 0o755);
};

for (const [entry, outfile] of [
["src/cli.ts", "dist/amico-run.js"],
["src/amico.ts", "dist/amico.js"],
]) {
await build({ ...common, entryPoints: [entry], outfile });
chmodSync(outfile, 0o755);
}
16 changes: 16 additions & 0 deletions packages/amico-run/launcher/amico
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Thin launcher for the `amico` verb router: resolve node, exec the bundled router.
# No logic lives here (mirror of launcher/amico-run). See src/amico.ts.
set -euo pipefail
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do # resolve symlink chains (node_modules/.bin)
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
if ! command -v node >/dev/null 2>&1; then
echo "amico: node >= 20 not found on PATH (install node or fix PATH; see provisioning runbook)" >&2
exit 64
fi
exec node "$DIR/../dist/amico.js" "$@"
3 changes: 2 additions & 1 deletion packages/amico-run/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"bin": {
"amico-run": "./launcher/amico-run"
"amico-run": "./launcher/amico-run",
"amico": "./launcher/amico"
},
"engines": {
"node": ">=20"
Expand Down
77 changes: 77 additions & 0 deletions packages/amico-run/src/amico.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// `amico` — the shared CLI verb router (issue #108, spec-20260708-112732 §7.3). Generalizes
// the `amico-run` bin: the same launch path (`amico run <script.jl> --spec …`) plus the
// spine bookkeeping verbs and the `mcp-serve` facade. ONE binary, invoked via bash by both
// runtimes (Claude Code + opencode) AND directly by the deterministic harness / cron / CI /
// Julia.
//
// B1 SCOPE: `run` / `resolve` / `sandbox` delegate VERBATIM to the existing amico-run launch
// path (src/launch.ts): `amico <verb> <args>` is exactly `amico-run <equivalent-args>`, so
// there is no behavior fork and the amico-run test suite still covers the real bodies. The
// spine verbs (catalog/vault/device/note) and `mcp-serve` are STUB seams (see verbs.ts,
// mcp_serve.ts) — routing works today; real bodies land in later spine slices.
import { launch } from "./launch.js";
import { SPINE_VERBS } from "./verbs.js";
import { serve } from "./mcp_serve.js";

function usage(): string {
const rows: [string, string][] = [
["run <script.jl> [--spec <s.json>] […]", "launch a solve — the amico-run launch path (delegates verbatim)"],
["resolve --platform <p> --kind <k> --size <n>", "tier resolution → JSON (amico-run subcommand)"],
["sandbox <workspace-dir> --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"],
...SPINE_VERBS.map((v) => [`${v.name} …`, `${v.summary} [stub → ${v.slice}]`] as [string, string]),
["mcp-serve [--list]", "expose the spine verbs as MCP tools (optional facade) [stub]"],
["--help, -h", "show this verb surface"],
];
const width = Math.max(...rows.map(([u]) => u.length));
const lines = rows.map(([u, d]) => ` amico ${u.padEnd(width)} ${d}`);
return `usage:\n${lines.join("\n")}`;
}

export async function main(argv: string[]): Promise<number> {
const head = argv[0];
const rest = argv.slice(1);

if (!head || head === "--help" || head === "-h") {
console.log(usage());
return head ? 0 : 64; // explicit --help is success; a bare `amico` is a usage error
}

// spine bookkeeping verbs (catalog/vault/device/note) — B1 stubs, print intent + exit 0.
const verb = SPINE_VERBS.find((v) => v.name === head);
if (verb) {
const { json, code } = await verb.run(rest);
console.log(JSON.stringify(json));
return code;
}

switch (head) {
// ── delegate verbatim to the existing amico-run launch path ──
// `amico run <args>` ≡ `amico-run <args>` (launch / --spec gate)
// `amico resolve <args>` ≡ `amico-run resolve <args>` (tier resolution subcommand)
// `amico sandbox <args>` ≡ `amico-run sandbox <args>` (env generation subcommand)
case "run":
return launch(rest);
case "resolve":
return launch(["resolve", ...rest]);
case "sandbox":
return launch(["sandbox", ...rest]);

case "mcp-serve":
return serve(rest);

default:
console.error(`amico: unknown verb "${head}"\n${usage()}`);
return 64;
}
}

main(process.argv.slice(2)).then(
(c) => {
process.exitCode = c;
},
(e) => {
// Any unexpected throw is an orchestrator fault, not a solve failure → 64.
console.error(`amico: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`);
process.exitCode = 64;
},
);
198 changes: 6 additions & 192 deletions packages/amico-run/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,196 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parse as parseToml } from "smol-toml";
import { LocalExecutor } from "./local_executor.js";
import { ConfigError, type Finished, type SubmitOpts } from "./types.js";
import { readAuthoring } from "./authoring.js";
import { runGate } from "./gate.js";
import { runVerification } from "./verify.js";
import { trySubcommand } from "./subcommands.js";
// The `amico-run` bin entry point. The launch logic itself lives in launch.ts so the new
// `amico` verb router (amico.ts, issue #108) can call the SAME launch path without
// re-running this bootstrap. This file is intentionally thin: resolve argv → launch() →
// set the process exit code, with the historical amico-run error/exit semantics unchanged.
import { launch } from "./launch.js";

function readTomlSafe(fp: string): Record<string, unknown> | undefined {
try {
return parseToml(readFileSync(fp, "utf8")) as Record<string, unknown>;
} catch {
return undefined;
}
}

const USAGE = `usage: amico-run <script.jl> [--executor local] [--lab <id-or-path>]
[--runs-root <path>] [--julia <path>] [--project <path>] [--sysimage <path>]
[--spec <solvespec.json>] (spec C: validate + gate before launch)
amico-run resolve --platform <p> --kind <k> --size <n> (tier resolution → JSON)
amico-run sandbox <workspace-dir> --packages A,B,… (generate env/Project.toml)
(a bare script literally named "resolve"/"sandbox" still launches — dispatch checks the file exists)`;

export async function main(argv: string[]): Promise<number> {
// spec C subcommands — dispatched before the launch flag loop
const sub = trySubcommand(argv);
if (sub !== undefined) return sub;

let script: string | undefined;
let executor = "local";
let specPath: string | undefined;
const opts: SubmitOpts = { julia: {} };
let projectExplicit = false;

for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = (): string => {
const v = argv[++i];
if (v === undefined) throw new ConfigError(`flag ${a} requires a value`);
return v;
};
try {
switch (a) {
case "--help":
case "-h":
console.log(USAGE);
return 0;
case "--executor":
executor = next();
break;
case "--lab":
opts.lab = next();
break;
case "--runs-root":
opts.runsRoot = next();
break;
case "--julia":
opts.julia!.julia = next();
break;
case "--project":
opts.julia!.project = next();
projectExplicit = true;
break;
case "--sysimage":
opts.julia!.sysimage = next();
break;
case "--spec":
specPath = next();
break;
default:
if (a.startsWith("-")) {
console.error(`amico-run: unknown flag ${a}\n${USAGE}`);
return 64;
}
if (script) {
console.error(`amico-run: multiple scripts given`);
return 64;
}
script = a;
}
} catch (e) {
if (e instanceof ConfigError) {
console.error(`amico-run: ${e.message}`);
return 64;
}
throw e;
}
}
if (!script) {
console.error(`amico-run: no script given\n${USAGE}`);
return 64;
}
if (executor !== "local") {
console.error(`amico-run: only --executor local is supported in β`);
return 64;
}

// ── spec C: the launch gate. Failures leave NO run dir and exit 64. ──
if (specPath) {
let specRaw: unknown;
try {
specRaw = JSON.parse(readFileSync(specPath, "utf8"));
} catch (e) {
console.error(`amico-run: cannot read --spec ${specPath}: ${(e as Error).message}`);
return 64;
}
let scriptText: string;
try {
scriptText = readFileSync(script, "utf8");
} catch (e) {
console.error(`amico-run: cannot read script ${script}: ${(e as Error).message}`);
return 64;
}
const { config: authoring, warning } = readAuthoring();
if (warning) console.error(`amico-run: ${warning}`);
const gate = runGate(specRaw, scriptText, authoring);
if (!gate.ok) {
console.error(`amico-run: gate: ${gate.reason}`);
return 64;
}
// env resolution: spec env.project feeds --project unless the flag was explicit
const env = (specRaw as { env?: { kind?: string; project?: string } }).env;
if (env?.project && (env.kind === "project" || env.kind === "sandbox")) {
if (projectExplicit && opts.julia!.project !== env.project)
console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`);
else opts.julia!.project = env.project;
}
opts.spec = {
canonical: gate.stamp.specCanonical,
tier: gate.stamp.tier,
hashes: gate.stamp.hashes,
julia_binary: opts.julia!.julia,
env_project: opts.julia!.project,
};
}

// NOTE: `--sysimage <path>` is honored (passed through to the Julia process and
// recorded in the manifest) but amicode does NOT build one — the local
// PackageCompiler build (~25-50 min, CairoMakie-dominated) wasn't worth it. The
// intended fast-path is a prebuilt sysimage distributed like Piccolissimo's
// (CI build on self-hosted runners → R2 → manifest → download), pointed at via
// this flag. Until that exists, solves pay the cold start (inspector warms up).

let handle;
try {
handle = await new LocalExecutor().submit(script, opts);
} catch (e) {
if (e instanceof ConfigError) {
console.error(`amico-run: ${e.message}`);
return 64;
}
throw e;
}

const onSignal = (): void => {
void handle.abort();
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);

let fin: Finished | undefined;
for await (const ev of handle.events) {
if (ev.kind === "iter" || ev.kind === "done") console.log(ev.raw);
else if (ev.kind === "log") console.log(ev.line);
else fin = { status: ev.status, exitCode: ev.exitCode };
}
const f = fin ?? (await handle.finished);

// FINISHED-write failure lane (spec §6 last row): verdict file must exist on disk
if (!existsSync(join(handle.runDir, "FINISHED"))) {
console.error(`amico-run: FINISHED missing in ${handle.runDir} (write fault)`);
return 64;
}
// spec C: free-tier re-rollout verification runs AFTER FINISHED, BEFORE the
// AMICODE_FINISHED line — so consumers see a settled verification state. The
// harness (or the fallback) always writes verification.toml; the promote gate
// keys off agree==true.
if (opts.spec?.tier === "free") {
const { config: authoring } = readAuthoring();
await runVerification(handle.runDir, opts.spec, authoring);
const verified = readTomlSafe(join(handle.runDir, "verification.toml"));
console.log(`AMICODE_VERIFIED agree=${verified?.agree === true}`);
}
// stdout protocol line — camelCase by design (spec §4)
console.log(`AMICODE_FINISHED status=${f.status} exitCode=${f.exitCode} runDir=${handle.runDir}`);
if (f.status === "aborted") return 130;
if (f.status === "completed") return 0;
return f.exitCode === 0 ? 1 : f.exitCode;
}

main(process.argv.slice(2)).then(
launch(process.argv.slice(2)).then(
(c) => {
process.exitCode = c;
},
Expand Down
Loading
Loading