CLI: run --script --report prints one JSON document for the run (#1891) (#1974)

Per step: number, tool, status (ok/error/skipped) and result or error.
Without the flag the output is unchanged; exit codes are unchanged.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-15 02:34:30 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 9c42c0c5fe
commit b870ad32e9
4 changed files with 147 additions and 7 deletions
+73 -5
View File
@@ -64,7 +64,8 @@ pdfcraft-cli batch <files or dirs...> --steps convert,ocr,optimize --out-dir DIR
[--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages]
pdfcraft-cli tools automation tools and their JSON Schemas
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
pdfcraft-cli run --script steps.json [--root DIR] [--report]
[{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run
",
mcp_usage!(),
"\
@@ -256,6 +257,7 @@ const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--root", takes_value: true },
OptionSpec { name: "--script", takes_value: true },
OptionSpec { name: "--out", takes_value: true },
OptionSpec { name: "--report", takes_value: false },
];
const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
@@ -783,22 +785,88 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_au
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
}
None => stdout_line(format_args!(
"{}",
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
))?,
None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
},
}
}
Ok(())
}
/// What a PNG result is reported as when it isn't written to a file.
fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
}
/// A tool's result as JSON values, for the `--report` of a script. Same content as
/// [`print_output`], collected instead of printed.
fn output_values(
auto: &pdfcraft_automation::Automation,
content: Vec<pdfcraft_automation::Content>,
out: Option<&str>,
) -> Result<Vec<serde_json::Value>, String> {
let mut values = Vec::new();
for c in content {
values.push(match c {
pdfcraft_automation::Content::Json(v) => v,
pdfcraft_automation::Content::Png { data, width, height } => match out {
Some(path) => {
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
}
None => png_summary(&data, width, height),
},
});
}
Ok(values)
}
/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
/// with the result or the message. The first failing step ends the run, as without `--report`;
/// the steps after it are listed as skipped and the exit status is the same.
fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
let mut report = Vec::with_capacity(steps.len());
let mut failure: Option<String> = None;
for (i, step) in steps.iter().enumerate() {
let n = i + 1;
let tool = step["tool"].as_str();
if failure.is_some() {
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
continue;
}
let outcome = match tool {
None => Err(format!("step {n}: missing \"tool\"")),
Some(t) => auto
.call(t, &step["args"])
.map_err(|e| e.to_string())
.and_then(|content| output_values(auto, content, step["out"].as_str()))
.map_err(|e| format!("step {n} ({t}): {e}")),
};
match outcome {
Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
Err(message) => {
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
failure = Some(message);
}
}
}
let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
}
fn run(args: &[String]) -> Result<(), CliError> {
validate_options("run", args, RUN_OPTIONS)?;
let report = args.iter().any(|a| a == "--report");
if report && flag(args, "--script").is_none() {
return Err("run: --report needs --script".to_string().into());
}
let mut auto = automation(args)?;
if let Some(script) = flag(args, "--script") {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
let steps: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
if report {
return run_script_report(&mut auto, &steps);
}
for (i, step) in steps.iter().enumerate() {
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;