Files
reverse-engineering-anything/src/cli/environmentCommands.ts
T
morluto b3657c557f perf(tests): run plain CLI commands on the file's shared process (#10412)
* refactor(cli): run one-shot commands through one shared owner

scripts/rea.mjs and the test CLI session worker each implemented the
one-shot command sequence: argument normalization, output admission,
configuration errors, version documents, the Amp scope refusal, and
result delivery. The worker copy had drifted: it never rendered
formatted version reports or the Amp refusal.

Move the sequence into src/cliOneShot.ts. Both callers supply a host
for their streams, exit and resource ownership. The launcher keeps
closed-pipe status, worker shutdown and process exit; the session
worker captures output per command.

The cliOutput bundle entry is superseded and removed. The dispatcher
test now replaces only the command runtime, so output admission runs
production code. A new parity test compares the file session with a
fresh process.

* test(cli): bundle the one-shot entry in the runtime bundle fixture

* perf(tests): run plain CLI commands on the file's shared process

`cli.run` in `cliTest` files started a fresh compiled CLI for every
command, about 0.45s and 260 MB each before any work. The file-scoped
session already existed, but callers had to opt in through
`runFileCli` or `sharedCliRun`, so 1,300 call sites still spawned.

Make the fixture choose: `cli.run` uses the shared process when it
reports the command exactly as a fresh process would, and starts one
otherwise (stdin, explicit deadlines, MCP and MCP doctor routing,
NODE_OPTIONS, or enabled logging). `freshProcess: true` keeps
explicit process-level checks. The session worker now presents the
launcher's entrypoint, so registration identity matches a fresh
process.

`runFileCli` and `sharedCliRun` are superseded and removed; their
call sites use `cli.run`, and paired fresh/shared checks use
`freshProcess`.

* style(tests): format the managed member summary call site

* test(cli): give each shared-session command an empty stdin

The shared CLI process reads session frames from its own stdin. A
command that reads stdin without supplied input, such as
`compare-wasm-interfaces - --json`, read that pipe and hung, where a
fresh process receives EOF. The worker now presents an ended, empty
stdin to each command and restores its own afterwards.

The parity test compared the routed path with itself after cli.run
began choosing the session. It now compares a fresh process with the
session directly, including the `mcp add` refusal and a command
reading absent stdin.

* test(cli): keep default command logging and launcher checks on fresh processes

environment-serve and environment-build log at info when REA_LOG_LEVEL is
unset, through a logger that writes to stderr directly. The shared CLI
process captures only process.stderr writes, so routed runs lost those
lines. Declare the default-logging commands beside the command names,
have infoLogger read that set, and start a fresh process for them.

The log-level precedence checks compared the shared process with itself
after routing; their fresh side now passes freshProcess: true.
2026-11-14 14:28:08 +08:00

732 lines
26 KiB
TypeScript

import { basename } from "node:path";
import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { EnvironmentBuildCaptureInput } from "../application/environment/environmentBuild.js";
import type {
EnvironmentExportFile,
EnvironmentExportResult,
} from "../application/environment/environmentExport.js";
import type { EnvironmentSource } from "../application/environment/environmentSource.js";
import type { ServedState } from "../application/environment/environmentState.js";
import { z } from "incur";
import type { Logger } from "pino";
import {
CLI_COMMANDS,
DEFAULT_INFO_LOGGING_COMMANDS,
} from "../cliCommandNames.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import { logCliCommand } from "../cliLogging.js";
import { parseEnvironmentName } from "../config/environmentName.js";
import {
mcpHttpHostSchema,
mcpHttpPortSchema,
parseMcpHttpEndpoint,
type McpHttpEndpoint,
} from "../config/mcpHttpEndpoint.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { StreamableHttpListenError } from "../server/http/streamableHttpListenError.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import { createLogger } from "../logger.js";
import { TEMPLATE_RULES } from "../domain/analysisView/environmentManifestRules.js";
import { inspectWebNetworkCaptureInputSchema } from "../domain/webNetworkCapture.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import type { CliInstance } from "./types.js";
import { cliDecimalField, publishArgvInput } from "./options.js";
/** Same envelope label as the other JSON-input commands and `parseCliJsonInput`. */
const cliError = (error: AnalysisError): JsonValue => ({
error: "Application workflow failed",
...projectAnalysisError(error),
});
/** The listener announces its URL, and a build its stages, at info like `rea mcp --http`; an explicit REA_LOG_LEVEL still wins. */
const infoLogger = (
command: string,
environment: Readonly<NodeJS.ProcessEnv>,
logger: Logger,
): Logger =>
environment.REA_LOG_LEVEL === undefined &&
DEFAULT_INFO_LOGGING_COMMANDS.has(command)
? createLogger("cli", "info")
: logger;
/** Resolve until the one-shot signal requests shutdown. */
const untilAborted = (signal: AbortSignal): Promise<undefined> =>
new Promise((resolve) => {
if (signal.aborted) resolve(undefined);
else
signal.addEventListener("abort", () => resolve(undefined), {
once: true,
});
});
/** A listener failure as the command's typed refusal. */
export const transportError = (
endpoint: McpHttpEndpoint,
cause: StreamableHttpListenError,
): AnalysisResourceConstraintError => {
const failure = cause.classification.ok
? cause.classification.value
: cause.classification.error;
return new AnalysisResourceConstraintError(
CLI_COMMANDS.environmentServe,
"transport",
cause.message,
{ host: endpoint.host, port: endpoint.port },
{
cause,
retryable: false,
diagnostics: {
listener_failure: {
kind: failure.kind,
...("systemCode" in failure && failure.systemCode !== undefined
? { system_code: failure.systemCode }
: {}),
...(failure.kind === "host_allowlist"
? {
reason: failure.reason,
}
: {}),
},
listener_phase: cause.phase,
},
remediationAction: cause.remediationAction,
},
);
};
interface ServeArguments {
readonly manifest: string;
readonly evidence: string;
readonly replayOnly: boolean;
readonly host?: string | undefined;
readonly port?: number | undefined;
}
interface ServeInputs {
readonly source: EnvironmentSource;
readonly served: ServedState;
}
const cancelledServe = (signal: AbortSignal): JsonValue =>
cliError(
new AnalysisCancelledError(CLI_COMMANDS.environmentServe, {
cause: signal.reason,
}),
);
/** Pure replay, as `--replay-only` asks for: no state is derived. */
const REPLAY_ONLY: ServedState = {
state: undefined,
report: { served: false, reason: "replay-only" },
};
/** Read and authenticate the manifest and capture, then prepare the state under the served name unless pure replay is asked for; a refusal is the command's output. */
const loadServeInputs = async (
input: ServeArguments,
environmentName: string | undefined,
signal: AbortSignal,
): Promise<Result<ServeInputs, JsonValue>> => {
const read = (path: string) =>
parseCliJsonInput(path, CLI_COMMANDS.environmentServe, signal, {
snapshot: true,
});
const manifest = await read(input.manifest);
if (!manifest.ok) return manifest;
const evidence = await read(input.evidence);
if (!evidence.ok) return evidence;
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
if (signal.aborted) return err(cancelledServe(signal));
const source = loadEnvironmentSource(manifest.value, evidence.value);
if (!source.ok) return err(cliError(source.error));
if (input.replayOnly)
return ok({ source: source.value, served: REPLAY_ONLY });
const { prepareServedState } =
await import("../application/environment/environmentState.js");
if (signal.aborted) return err(cancelledServe(signal));
return ok({
source: source.value,
served: prepareServedState(source.value, evidence.value, {
name: environmentName,
}),
});
};
/** Load the Evidence files, bind the listener and run until a signal ends it. */
const serveEnvironmentCommand = async (
input: ServeArguments,
environment: Readonly<NodeJS.ProcessEnv>,
logger: Logger,
signal: AbortSignal,
): Promise<JsonValue> => {
if (signal.aborted) return cancelledServe(signal);
const endpoint = parseMcpHttpEndpoint(environment, {
...(input.host === undefined ? {} : { host: input.host }),
...(input.port === undefined ? {} : { port: input.port }),
});
if (!endpoint.ok) return cliError(endpoint.error);
const environmentName = parseEnvironmentName(environment);
if (!environmentName.ok) return cliError(environmentName.error);
const inputs = await loadServeInputs(input, environmentName.value, signal);
if (!inputs.ok) return inputs.error;
const {
source,
served: { state, report },
} = inputs.value;
const commandLogger = infoLogger(
CLI_COMMANDS.environmentServe,
environment,
logger,
);
if (!report.served && report.reason === "unavailable")
commandLogger.warn(
{ code: report.code, error: report.message },
"Environment state unavailable; serving recorded replay",
);
if (signal.aborted) return cancelledServe(signal);
// Loaded here so registering the command catalog never loads the HTTP listener.
const { serveEnvironment } =
await import("../server/environment/serveEnvironment.js");
const { StreamableHttpListenError } =
await import("../server/http/streamableHttpListenError.js");
if (signal.aborted) return cancelledServe(signal);
// A listener that fails after binding ends the command instead of serving nothing.
let failListener: (error: StreamableHttpListenError) => void = () =>
undefined;
const listenerFailed = new Promise<StreamableHttpListenError>((resolve) => {
failListener = resolve;
});
let served: import("../server/environment/serveEnvironment.js").ServedEnvironment;
try {
const started = await serveEnvironment({
source,
...(state === undefined ? {} : { state }),
signal,
...endpoint.value,
...(environmentName.value === undefined
? {}
: { environmentName: environmentName.value }),
logger: commandLogger,
onerror: failListener,
});
if (!started.ok) return cliError(started.error);
served = started.value;
} catch (cause: unknown) {
if (signal.aborted && cause === signal.reason)
return cancelledServe(signal);
if (!(cause instanceof StreamableHttpListenError)) throw cause;
return cliError(transportError(endpoint.value, cause));
}
const failure = await Promise.race([untilAborted(signal), listenerFailed]);
const listenerError =
failure === undefined ? undefined : transportError(endpoint.value, failure);
const observation = {
url: served.url,
environment: served.card.name,
tools: served.card.capabilities.tools.length,
evidence_id: source.evidenceId,
manifest_evidence_id: source.manifestEvidenceId,
state: report,
};
try {
await served.close();
} catch (cause: unknown) {
if (!(cause instanceof AnalysisError)) throw cause;
if (listenerError !== undefined)
return cliError(
analysisErrorWithCleanupFailure(
listenerError,
cause,
CLI_COMMANDS.environmentServe,
).bindCleanupDependencies([cause]),
);
cause.retainDiagnostics({
shutdown_observation: { status: "shutdown_requested", ...observation },
});
return cliError(cause);
}
if (listenerError !== undefined) return cliError(listenerError);
return { status: "stopped", ...observation };
};
interface ExportArguments {
readonly manifest: string;
readonly evidence: string;
readonly directory: string;
readonly packageTarball?: string | undefined;
readonly name?: string | undefined;
}
/** The written files with sizes, whether the bundle serves the state, and the pinned package source, as the command prints them. */
const exportDocument = (result: EnvironmentExportResult): JsonValue => ({
status: result.status,
directory: result.directory,
name: result.name,
tool_count: result.tool_count,
evidence_id: result.evidence_id,
manifest_evidence_id: result.manifest_evidence_id,
state: result.state,
package_source: result.package_source,
files: result.files.map(({ path, bytes }) => ({ path, bytes })),
});
/** Read each file once, authenticate the pair as the serve command does, then write the bundle from the same bytes. */
const exportEnvironmentCommand = async (
input: ExportArguments,
): Promise<JsonValue> => {
const { readEnvironmentExportInputs, exportEnvironmentBundle } =
await import("../application/environment/environmentExport.js");
const inputs = await readEnvironmentExportInputs(
input.manifest,
input.evidence,
);
if (!inputs.ok) return cliError(inputs.error);
// Loaded here, as for environment-serve, so registering the command catalog never loads it.
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
const source = loadEnvironmentSource(
inputs.value.manifest.value,
inputs.value.evidence.value,
CLI_COMMANDS.environmentExport,
);
if (!source.ok) return cliError(source.error);
const exported = await exportEnvironmentBundle({
...inputs.value,
source: source.value,
directory: absoluteCallerPath(input.directory),
name: input.name,
packageTarball:
input.packageTarball === undefined
? undefined
: absoluteCallerPath(input.packageTarball),
});
return exported.ok
? exportDocument(exported.value)
: cliError(exported.error);
};
interface BuildArguments {
readonly capture: string;
readonly directory?: string | undefined;
readonly name?: string | undefined;
readonly templateRule?:
| readonly (typeof TEMPLATE_RULES)[number][]
| undefined;
readonly formatRulesOnly: boolean;
readonly packageTarball?: string | undefined;
readonly dryRun: boolean;
readonly captureTimeoutMs?: number | undefined;
}
const conflictingRules = (): JsonValue =>
cliError(
new AnalysisInputError(CLI_COMMANDS.environmentBuild, undefined, [
{
path: ["format_rules_only"],
reason: "invalid_value",
message:
"--format-rules-only applies no opt-in template rules; it cannot be combined with --template-rule.",
},
]),
);
/** Inspect a HAR as inspect-web-network-capture does and serialize the Evidence as it prints it; a refusal is the command's output. */
const inspectBuildHar = async (
capturePath: string,
callerInputPath: string,
captureTimeoutMs: number | undefined,
environment: Readonly<NodeJS.ProcessEnv>,
signal: AbortSignal,
): Promise<Result<EnvironmentExportFile, JsonValue>> => {
const { createWebNetworkCaptureService } =
await import("../composition/webNetworkCaptures.js");
const { environmentExportEvidenceFile } =
await import("../application/environment/environmentExport.js");
const { ENVIRONMENT_BUILD_OPERATION } =
await import("../application/environment/environmentBuild.js");
const service = createWebNetworkCaptureService(environment);
const inspected = await finishAnalysisCommand(
() =>
service.inspect(
{
capture_path: capturePath,
format: "har",
...(captureTimeoutMs === undefined
? {}
: { capture_timeout_ms: captureTimeoutMs }),
},
{ signal },
{ capture: callerInputPath },
),
service,
"web-network-capture",
"inspect_web_network_capture",
signal,
);
if (!inspected.ok)
return err({
error: "Application workflow failed",
...inspected.error,
});
const file = await environmentExportEvidenceFile(
"evidence",
capturePath,
inspected.value,
ENVIRONMENT_BUILD_OPERATION,
);
return file.ok ? file : err(cliError(file.error));
};
/** Read the capture once, then build or plan the bundle from it. */
const buildEnvironmentCommand = async (
input: BuildArguments,
environment: Readonly<NodeJS.ProcessEnv>,
logger: Logger,
signal: AbortSignal,
): Promise<JsonValue> => {
if (input.formatRulesOnly && input.templateRule !== undefined)
return conflictingRules();
const {
buildEnvironment,
classifyBuildCapture,
DEFAULT_BUILD_TEMPLATE_RULES,
ENVIRONMENT_BUILD_OPERATION,
} = await import("../application/environment/environmentBuild.js");
const capturePath = absoluteCallerPath(input.capture);
const kind = classifyBuildCapture(capturePath);
if (!kind.ok) return cliError(kind.error);
if (input.directory === undefined && !input.dryRun)
return cliError(
new AnalysisInputError(
CLI_COMMANDS.environmentBuild,
{ userMessage: "Name the bundle directory, or pass --dry-run." },
[
{
path: ["directory"],
reason: "invalid_value",
message: "The bundle directory may be omitted only with --dry-run.",
},
],
),
);
const commandLogger = infoLogger(
CLI_COMMANDS.environmentBuild,
environment,
logger,
);
const progress = (stage: string): void => {
commandLogger.info({ stage }, "Building environment");
};
let capture: EnvironmentExportFile;
if (kind.value === "har") {
progress("inspect");
const inspected = await inspectBuildHar(
capturePath,
input.capture,
input.captureTimeoutMs,
environment,
signal,
);
if (!inspected.ok) return inspected.error;
capture = inspected.value;
} else {
const { readEnvironmentExportFile } =
await import("../application/environment/environmentExport.js");
const read = await readEnvironmentExportFile(
"evidence",
capturePath,
ENVIRONMENT_BUILD_OPERATION,
["capture"],
);
if (!read.ok) return cliError(read.error);
capture = read.value;
}
if (signal.aborted)
return cliError(
new AnalysisCancelledError(CLI_COMMANDS.environmentBuild, {
cause: signal.reason,
}),
);
const captureInput: EnvironmentBuildCaptureInput = kind.value;
const built = await buildEnvironment({
capture,
captureInput,
capturePath,
templateRules: input.formatRulesOnly
? []
: (input.templateRule ?? DEFAULT_BUILD_TEMPLATE_RULES),
directory:
input.directory === undefined
? undefined
: absoluteCallerPath(input.directory),
name: input.name,
packageTarball:
input.packageTarball === undefined
? undefined
: absoluteCallerPath(input.packageTarball),
dryRun: input.dryRun,
progress,
signal,
});
return built.ok
? (built.value as unknown as JsonValue)
: cliError(built.error);
};
/** Register the one-step build from a recorded capture to a bundle folder. */
const registerBuildCommand = (
cli: CliInstance,
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
cli.command(CLI_COMMANDS.environmentBuild, {
description:
"Build an agent environment bundle from a recorded capture in one offline step: inspect a HAR (or read capture Evidence), project the environment manifest, derive the state, report which tools the environment answers from state, which replay and why each write is withheld, then write the bundle environment-export writes",
args: z.object({
capture: localPathStringSchema.describe(
"A .har file, or capture Evidence JSON from inspect-web-network-capture (HAR) or capture-browser-scenario",
),
directory: localPathStringSchema
.optional()
.describe(
"Bundle directory to create; must be absent or empty, nothing is overwritten; omit only with --dry-run",
),
}),
options: z.object({
name: z
.string()
.optional()
.describe(
"Env folder and env id (kebab-case); default the manifest's name, or the capture file's name when the origin is a loopback or IP address",
),
templateRule: z
.array(z.enum(TEMPLATE_RULES))
.optional()
.describe(
"Opt-in template rule; repeat to select several. Replaces the default after-uid-literal, opaque-token, sibling-variance and prefix-variance",
),
formatRulesOnly: z
.boolean()
.default(false)
.describe("Apply only the four format rules, the views' own default"),
packageTarball: localPathStringSchema
.optional()
.describe(
"Packed rea-agents tarball (npm pack) copied into the env folder and installed by the Dockerfile instead of the registry package pinned to this version",
),
dryRun: z
.boolean()
.default(false)
.describe("Run every stage and report, but write nothing"),
// Decimal tokens only: numeric coercion would read "0x1" as 1 ms.
captureTimeoutMs: cliDecimalField(
inspectWebNetworkCaptureInputSchema.shape.capture_timeout_ms,
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.environmentBuild, () =>
buildEnvironmentCommand(
{
capture: args.capture,
directory: args.directory,
name: options.name,
templateRule: options.templateRule,
formatRulesOnly: options.formatRulesOnly,
packageTarball: options.packageTarball,
dryRun: options.dryRun,
captureTimeoutMs: options.captureTimeoutMs,
},
environment,
logger,
signal,
),
),
),
});
};
const manifestArgument = z
.string()
.describe(
"Environment manifest Evidence JSON path (inspect-analysis-view environment-manifest output)",
);
const evidenceOption = z
.string()
.describe(
"Parent capture Evidence JSON path (inspect-web-network-capture or capture-browser-scenario output) the manifest cites",
);
interface CompareArguments {
readonly manifest: string;
readonly evidence: string;
readonly probes: readonly string[];
}
/** Read the environment and every probe, then compare; a refusal is the command's output. */
const compareEnvironmentCommand = async (
input: CompareArguments,
): Promise<JsonValue> => {
const read = (path: string) =>
parseCliJsonInput(path, CLI_COMMANDS.environmentCompare, undefined, {
snapshot: true,
});
const manifest = await read(input.manifest);
if (!manifest.ok) return manifest.error;
const evidence = await read(input.evidence);
if (!evidence.ok) return evidence.error;
const probes = [];
for (const path of input.probes) {
const probe = await read(path);
if (!probe.ok) return probe.error;
probes.push({ label: basename(path), value: probe.value });
}
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
const source = loadEnvironmentSource(
manifest.value,
evidence.value,
CLI_COMMANDS.environmentCompare,
);
if (!source.ok) return cliError(source.error);
const { compareEnvironment } =
await import("../application/environment/environmentCompare.js");
const report = compareEnvironment({
source: source.value,
captureValue: evidence.value,
probes,
operation: CLI_COMMANDS.environmentCompare,
});
return report.ok
? (report.value as unknown as JsonValue)
: cliError(report.error);
};
/** Register the comparison of an environment with probe captures of its application. */
const registerCompareCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.environmentCompare, {
description:
"Compare an environment with probe captures of the application it was captured from: each probe is one call sequence recorded against a freshly reset application, answered by the environment from a reset state, with agreement reported per step, tool and probe",
args: z.object({ manifest: manifestArgument }),
options: z.object({
evidence: evidenceOption,
probe: z
.array(z.string())
.min(1)
.describe(
"Probe capture Evidence JSON path (inspect-web-network-capture or capture-browser-scenario output) of one call sequence made against a freshly reset application; repeat once per sequence",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.environmentCompare, () =>
compareEnvironmentCommand({
manifest: args.manifest,
evidence: options.evidence,
probes: options.probe,
}),
),
});
};
/** Register the one-shot bundle export command. */
const registerExportCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.environmentExport, {
description:
"Export an environment manifest and its capture Evidence as an agent environment bundle folder the agentenv framework runs with agent-env run",
args: z.object({
manifest: manifestArgument,
directory: localPathStringSchema.describe(
"Bundle directory to create; must be absent or empty, nothing is overwritten",
),
}),
options: z.object({
evidence: evidenceOption,
packageTarball: localPathStringSchema
.optional()
.describe(
"Packed rea-agents tarball (npm pack) copied into the env folder and installed by the Dockerfile instead of the registry package pinned to this version",
),
name: z
.string()
.optional()
.describe(
"Env folder and env id (kebab-case); default the manifest's name",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.environmentExport, () =>
exportEnvironmentCommand({
manifest: args.manifest,
evidence: options.evidence,
directory: args.directory,
packageTarball: options.packageTarball,
name: options.name,
}),
),
});
};
/** Register the long-running agent environment server command and the bundle export. */
export const registerEnvironmentCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
registerBuildCommand(cli, logger, environment);
registerExportCommand(cli, logger);
registerCompareCommand(cli, logger);
cli.command(CLI_COMMANDS.environmentServe, {
description:
"Serve an environment manifest as an agent environment over streamable HTTP until SIGINT or SIGTERM: read tools, and write tools whose effect the capture confirmed, answer from the state the capture observed, derived at startup, or every tool replays retained capture records with --replay-only or when the state is unavailable",
args: z.object({ manifest: manifestArgument }),
options: z.object({
evidence: evidenceOption,
replayOnly: z
.boolean()
.default(false)
.describe(
"Serve pure replay: derive no state, serve every manifest tool from its retained records and answer every /agentenv method as not found",
),
// Flags use the MCP_HOST and MCP_PORT owners shared with rea mcp --http.
host: mcpHttpHostSchema
.optional()
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
port: publishArgvInput(
mcpHttpPortSchema
.optional()
.describe(
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
),
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.environmentServe, () =>
serveEnvironmentCommand(
{
manifest: args.manifest,
evidence: options.evidence,
replayOnly: options.replayOnly,
host: options.host,
port: options.port,
},
environment,
logger,
signal,
),
),
),
});
};