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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ clients are welcome via PR.
fails closed; Mottainai does not silently fall back to WSL2, TCG, or
host-native execution.

The local Runtime is opt-in: plain `mottainai init` only sets up MCP client
registration. Pass `--runtime` to additionally ensure the local Runtime VM
The `mottainai runtime` namespace is the only local Runtime lifecycle
authority: `mottainai runtime ensure` reconciles the local Runtime VM and
`mottainai runtime status` reads its persisted state. `mottainai init` only
sets up MCP client registration and never provisions Runtime
(see [docs/local-runtime.md](docs/local-runtime.md)).

## Installation
Expand Down
14 changes: 7 additions & 7 deletions docs/local-runtime.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Canonical local Runtime

`mottainai init --runtime` owns one local Runtime profile,
`mottainai-local-runtime-v1`. Ensuring the Runtime is opt-in and separate from
MCP client registration: plain `mottainai init` only sets up the MCP
configuration/clients, so hosts without a hardware accelerator (CI, containers,
sandboxes) can still complete client setup. `--runtime` is required to
additionally ensure the local Runtime.
The `mottainai runtime` namespace is the only local Runtime lifecycle authority
for the `mottainai-local-runtime-v1` profile. Use `mottainai runtime ensure` to
reconcile it and `mottainai runtime status` to read its persisted state.
`mottainai init` only sets up MCP configuration/clients and never provisions the
Runtime, so hosts without a hardware accelerator (CI, containers, sandboxes)
can still complete client setup.

The profile is intentionally not a user-selectable provider: QEMU is always the
machine substrate, with `KVM` on Linux, `HVF` on macOS, and `WHPX` on Windows.
If the required accelerator is unavailable, `--runtime` fails with an
If the required accelerator is unavailable, `runtime ensure` fails with an
actionable diagnostic rather than silently skipping Runtime provisioning. It
never selects TCG, WSL/WSL2, a host-native process, or an arbitrary system
QEMU installation.
Expand Down
42 changes: 39 additions & 3 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,8 @@ function main() {
const configPath = path.join(installDirectory, "mottainai.config.json");

// The packed consumer smoke is intentionally hermetic and must not claim
// host virtualization hardware it does not own. The production init path
// ensures the local Runtime; dry-run validates the released CLI/config
// surface without provisioning a VM in the package harness.
// host virtualization hardware it does not own. `init` only validates the
// released CLI/config surface; Runtime lifecycle belongs to `runtime`.
console.log("running init --yes --dry-run --scope project --client none --no-doctor --json...");
const initResult = spawnSync(
process.execPath,
Expand Down Expand Up @@ -167,6 +166,43 @@ function main() {
fail(`dry-run init unexpectedly wrote configuration: ${JSON.stringify(initSummary)}`);
if (fs.existsSync(configPath)) fail(`dry-run init wrote configuration file at ${configPath}`);

const runtimeStateDirectory = path.join(installDirectory, "runtime-state");
console.log("running packed runtime ensure --help...");
const runtimeEnsureHelpResult = spawnSync(process.execPath, [primaryBin, "runtime", "ensure", "--help"], {
cwd: installDirectory,
encoding: "utf8",
timeout: 10_000,
});
if (runtimeEnsureHelpResult.status !== 0 || !runtimeEnsureHelpResult.stdout.includes("runtime ensure"))
fail(
`runtime ensure help was not callable: ${runtimeEnsureHelpResult.status}\n${runtimeEnsureHelpResult.stdout}\n${runtimeEnsureHelpResult.stderr}`,
);

console.log("running packed runtime status --json...");
const runtimeStatusResult = spawnSync(
process.execPath,
[primaryBin, "runtime", "status", "--json", "--state-directory", runtimeStateDirectory],
{
cwd: installDirectory,
encoding: "utf8",
env: { ...process.env, HOME: installDirectory, USERPROFILE: installDirectory },
timeout: 10_000,
},
);
if (runtimeStatusResult.status !== 0)
fail(
`runtime status exited with status ${runtimeStatusResult.status}:\n${runtimeStatusResult.stdout}\n${runtimeStatusResult.stderr}`,
);
let runtimeStatus;
try {
runtimeStatus = JSON.parse(runtimeStatusResult.stdout);
} catch {
fail(`runtime status --json did not print valid JSON:\n${runtimeStatusResult.stdout}`);
}
if (runtimeStatus.ok !== true || runtimeStatus.lifecycle !== "absent")
fail(`runtime status did not report an absent Runtime: ${JSON.stringify(runtimeStatus)}`);
if (fs.existsSync(runtimeStateDirectory)) fail("runtime status created the state directory");

console.log("running packed Mottainai gh-inari companion smoke...");
run(process.execPath, ["scripts/gh-inari-package-smoke.mjs", installedPackageDirectory], {
cwd: repoRoot,
Expand Down
83 changes: 80 additions & 3 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,70 @@ test("early public CLI failure includes bounded runtime identity without stdout
}
});

test("public CLI exposes read-only runtime status without creating state", () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-runtime-status-"));
const stateDirectory = path.join(workspace, "runtime-state");
try {
const result = spawnSync(
process.execPath,
["--import", "tsx", entryPoint, "runtime", "status", "--json", "--state-directory", stateDirectory],
{
cwd: path.resolve(path.dirname(entryPoint), ".."),
env: { ...process.env, HOME: workspace, USERPROFILE: workspace },
encoding: "utf8",
},
);
assert.equal(result.status, 0, `${result.stdout}${result.stderr}`);
assert.deepEqual(JSON.parse(result.stdout), {
ok: true,
machineId: "mottainai-local-runtime-v1",
lifecycle: "absent",
stateDirectory: path.join(path.resolve(stateDirectory), "mottainai-local-runtime-v1"),
stateFile: path.join(path.resolve(stateDirectory), "mottainai-local-runtime-v1", "state.json"),
});
assert.equal(fs.existsSync(stateDirectory), false);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

test("top-level init rejects the removed Runtime provisioning option before writing anything", () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-init-runtime-"));
const configPath = path.join(workspace, "mottainai.config.json");
try {
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
entryPoint,
"init",
"--yes",
"--workspace",
workspace,
"--config",
configPath,
"--scope",
"project",
"--client",
"none",
"--no-doctor",
"--runtime",
],
{
cwd: path.resolve(path.dirname(entryPoint), ".."),
env: { ...process.env, HOME: workspace, USERPROFILE: workspace },
encoding: "utf8",
},
);
assert.equal(result.status, 1);
assert.match(result.stderr, /use `mottainai runtime ensure`/);
assert.equal(fs.existsSync(configPath), false);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

test("hooks repair restores an invalid policy through the public CLI", () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-hooks-repair-"));
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-cli-hooks-bin-"));
Expand All @@ -46,7 +110,12 @@ test("hooks repair restores an invalid policy through the public CLI", () => {
["--import", "tsx", entryPoint, "hooks", "repair", "--client", "claude", "--workspace", workspace],
{
cwd: path.resolve(path.dirname(entryPoint), ".."),
env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, HOME: workspace, USERPROFILE: workspace },
env: {
...process.env,
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
HOME: workspace,
USERPROFILE: workspace,
},
encoding: "utf8",
},
);
Expand Down Expand Up @@ -78,15 +147,23 @@ test("public CLI dispatch projects the workflow authority through a supported cl
fs.mkdirSync(path.join(workspace, ".mottainai"));
fs.writeFileSync(
path.join(workspace, ".mottainai", "workflow.json"),
JSON.stringify({ ...BUILTIN_PRESETS.standard, protectedBranches: ["release/*"], protectedBranchRule: { ...BUILTIN_PRESETS.standard.protectedBranchRule, sourceWrite: "enforce" } }),
JSON.stringify({
...BUILTIN_PRESETS.standard,
protectedBranches: ["release/*"],
protectedBranchRule: { ...BUILTIN_PRESETS.standard.protectedBranchRule, sourceWrite: "enforce" },
}),
);
const result = spawnSync(
process.execPath,
["--import", "tsx", entryPoint, "hooks", "dispatch", "--client", "claude", "--workspace", workspace],
{
cwd: path.resolve(path.dirname(entryPoint), ".."),
env: { ...process.env, HOME: workspace, USERPROFILE: workspace },
input: JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Write", tool_input: { file_path: "tracked.txt" } }),
input: JSON.stringify({
hook_event_name: "PreToolUse",
tool_name: "Write",
tool_input: { file_path: "tracked.txt" },
}),
encoding: "utf8",
},
);
Expand Down
63 changes: 47 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { localTools } from "./local-tools.js";
import { dispatchClientHook, runManagedHooksCommand } from "./hooks/commands.js";
import type { HookCommandContext } from "./hooks/commands.js";
import { formatInitHuman, runInit } from "./init.js";
import { createLocalRuntimeProvisioner } from "./local-runtime/index.js";
import {
createLocalRuntimeProvisioner,
formatLocalRuntimeEnsureHuman,
formatLocalRuntimeStatusHuman,
readLocalRuntimeStatus,
} from "./local-runtime/index.js";
import { createRuntimeDiagnostic, formatRuntimeDiagnosticHuman } from "./runtime-diagnostic.js";
import { runServer } from "./server.js";
import {
Expand Down Expand Up @@ -56,6 +61,8 @@ import type { CleanupPlan } from "./workflow/domain/cleanup-plan.js";
const USAGE = `usage:
mottainai start the MCP stdio server
mottainai init [options] initialize a workspace configuration
mottainai runtime ensure [options] reconcile the local Runtime
mottainai runtime status [options] show persisted local Runtime state
mottainai serve start the MCP stdio server explicitly
mottainai dashboard [options] start the local semantic project viewer (fixture|live)
mottainai manager [options] start the local Zellij-backed agent Manager
Expand Down Expand Up @@ -123,6 +130,10 @@ init options:
--no-doctor skip post-initialization diagnostics
--latest register the unpinned npm package

runtime options:
--state-directory path local Runtime state root
--json emit one JSON document

policy/task options:
--workspace path Git repository root; defaults to the current Git repository's top level
--type type explicit branch type for "task start" (required)
Expand All @@ -136,6 +147,11 @@ hooks options:
--mode observe|warn|enforce set the managed rollout mode for install/repair
`;

const RUNTIME_USAGE = `usage:
mottainai runtime ensure [--state-directory path] [--json]
mottainai runtime status [--state-directory path] [--json]
`;

function flag(argv: string[], name: string): string | undefined {
const index = argv.indexOf(`--${name}`);
return index === -1 ? undefined : argv[index + 1];
Expand Down Expand Up @@ -479,25 +495,34 @@ export async function runCli(args: string[]): Promise<number> {
if (command === "init") {
const summary = await runInit({
args: argv,
// The local Runtime is Mottainai-managed hard-isolation infrastructure,
// not part of MCP client registration; ensuring it is opt-in via
// --runtime so `init` still succeeds for MCP-only setup on hosts
// without a hardware accelerator (docs/local-runtime.md).
...(hasFlag(argv, "runtime")
? {
localRuntime: createLocalRuntimeProvisioner(),
localRuntimeOptions: {
environment: process.env,
homeDirectory: process.env.HOME ?? process.env.USERPROFILE,
platform: process.platform,
architecture: process.arch,
},
}
: {}),
});
if (hasFlag(argv, "json")) print(summary);
else console.log(formatInitHuman(summary));
return summary.ok ? 0 : 1;
} else if (command === "runtime") {
const action = argv[0];
if (action !== "ensure" && action !== "status") fail(USAGE);
if (hasFlag(argv, "help")) {
console.log(RUNTIME_USAGE);
return 0;
}
const runtimeOptions = {
environment: process.env,
homeDirectory: process.env.HOME ?? process.env.USERPROFILE,
platform: process.platform,
architecture: process.arch,
stateDirectory: requireFlagValue(argv, "state-directory"),
};
if (action === "status") {
const status = readLocalRuntimeStatus(runtimeOptions);
if (hasFlag(argv, "json")) print(status);
else console.log(formatLocalRuntimeStatusHuman(status));
return 0;
}
const result = await createLocalRuntimeProvisioner().ensure(runtimeOptions);
if (hasFlag(argv, "json")) print(result);
else console.log(formatLocalRuntimeEnsureHuman(result));
return result.ok ? 0 : 1;
} else if (command === "semantic") {
return runSemanticCommand(argv[0], argv.slice(1));
} else if (command === "dashboard") {
Expand Down Expand Up @@ -886,6 +911,12 @@ export async function runCli(args: string[]): Promise<number> {
});
} else if (args[0] === "init" && hasFlag(args, "json")) {
print({ ok: false, error: message });
} else if (args[0] === "runtime" && hasFlag(args, "json")) {
const code =
typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
? error.code
: undefined;
print({ ok: false, ...(code === undefined ? {} : { code }), error: message });
} else {
console.error(
args[0] === "doctor"
Expand Down
Loading
Loading