///|
/// A PROCESS SHIM: Claude Code as a workflow engine. Speaks the child
/// contract (`docs/child-contract.md`): one request line
/// on stdin, one result file at `--result-file`. It drives
/// `claude -p --output-format stream-json` underneath and reads that stream
/// for the answer, the usage, and the steps. Point a `LaunchSpec` at the
/// PUBLISHED shim — no build step, no path to hard-code:
///
/// moonx moonbitlang/workflow/shim/claude --result-file {result_file} [--model m] [--cwd d] [--writable] [--schema f] [-- claude args...]
///
/// The runner substitutes a fresh path for `{result_file}`. `moonx` runs the wasm build, which this shim fully
/// supports: it spawns the CLI, keeps the parent's stdin-EOF cancel channel
/// live, and writes the result exactly as the native build does. Building
/// locally (`just shims`) is for developing THIS shim, where the published
/// version is not what you want to run.
///
/// Kind policy: read-only runs (the default) disallow the editing and
/// shell tools; `worker`, or `--writable`, accepts edits and allows them.
/// The request's `limits.max_steps` IS enforced here — each `assistant`
/// message is a step, and the CLI is torn down the moment the ceiling is
/// exceeded — because the CLI has no turn cap of its own.
async fn main {
@shim.serve(default_command="claude", run~)
}
///|
/// One request, start to finish: the result file's content.
async fn run(
options : @shim.Options,
request : @shim.Request,
) -> @shim.ShimResult {
// The call's own schema (request) wins; `--schema ` is the
// deployment default for scripts that pass none.
let schema = match (request_schema(request), options.schema) {
(Some(schema), _) => Some(schema)
(None, Some(path)) =>
Some(@utf8.decode_lossy(@fs.read_file(path).binary())) catch {
error =>
return @shim.ShimResult(
Failed("cannot read --schema \{path}: \{error}"),
)
}
(None, None) => None
}
let state = Claude()
let exit = @shim.run_cli(
command=options.command,
args=claude_args(request, options, schema),
cwd?=options.cwd,
observe=line => {
// Count first, then judge the ceiling: the over-limit step DID happen
// (the CLI already made the call), so its step and cost are reported
// with the stop — steps and charges stay consistent.
state.observe(line)
if request.max_steps is Some(limit) && state.steps > limit {
return @shim.Stop
}
@shim.Continue
},
)
state.result(exit)
}
///|
/// The CLI argv for one request. The prompt is the first positional so
/// the variadic tool lists can sit last without swallowing anything;
/// pass-through args go before them for the same reason.
fn claude_args(
request : @shim.Request,
options : @shim.Options,
schema : String?,
) -> Array[String] {
let args = [
"-p",
request.prompt,
"--output-format",
"stream-json",
"--verbose",
]
if options.model is Some(model) {
args.push("--model")
args.push(model)
}
if schema is Some(schema) {
args.push("--json-schema")
args.push(schema)
}
args.push_iter(options.extra.iter())
let mutating = ["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"]
if options.writable || request.kind == "worker" {
args.push("--permission-mode")
args.push("acceptEdits")
args.push("--allowedTools")
} else {
args.push("--disallowedTools")
}
args.push_iter(mutating.iter())
args
}
///|
test "argv: read-only by default, writable for worker, extras before the tool list" {
let request = @shim.Request(
request_id="r",
kind="explore",
input=Json::null(),
prompt="why?",
)
let options = @shim.Options(
command="claude",
result_file="/r",
model="opus",
extra=["--max-budget-usd", "1"],
)
assert_eq(claude_args(request, options, None), [
"-p", "why?", "--output-format", "stream-json", "--verbose", "--model", "opus",
"--max-budget-usd", "1", "--disallowedTools", "Edit", "Write", "MultiEdit", "NotebookEdit",
"Bash",
])
let worker = @shim.Request(
request_id="r",
kind="worker",
input=Json::null(),
prompt="why?",
)
let plain = @shim.Options(command="claude", result_file="/r")
let writable = @shim.Options(
command="claude",
result_file="/r",
writable=true,
)
assert_eq(
claude_args(request, writable, None),
claude_args(worker, plain, None),
)
assert_eq(claude_args(worker, plain, Some("{}")), [
"-p", "why?", "--output-format", "stream-json", "--verbose", "--json-schema",
"{}", "--permission-mode", "acceptEdits", "--allowedTools", "Edit", "Write",
"MultiEdit", "NotebookEdit", "Bash",
])
}
///|
/// The request's schema as the inline JSON text `--json-schema` takes.
fn request_schema(request : @shim.Request) -> String? {
request.schema.map(schema => schema.stringify())
}
///|
test "the request's schema becomes the inline --json-schema text" {
let request = @shim.Request(
request_id="r",
kind="explore",
input=Json::null(),
prompt="why?",
schema={ "type": "object" },
)
assert_eq(request_schema(request), Some("{\"type\":\"object\"}"))
let unshaped = @shim.Request(
request_id="r",
kind="explore",
input=Json::null(),
prompt="why?",
)
assert_eq(request_schema(unshaped), None)
}