mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 16:23:59 +00:00
187 lines
8.8 KiB
Rust
187 lines
8.8 KiB
Rust
#[cfg(feature = "mcp")]
|
|
use super::options::MCP_OPTIONS;
|
|
use super::options::{RUN_OPTIONS, flag, positional, validate_options};
|
|
use super::*;
|
|
|
|
// ---- automation --------------------------------------------------------------------------------
|
|
|
|
pub(crate) fn automation(args: &[String]) -> Result<pdfcraft_automation::Automation, String> {
|
|
let a = pdfcraft_automation::Automation::new();
|
|
match flag(args, "--root") {
|
|
Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
|
|
None => Ok(a),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn tools() -> Result<(), CliError> {
|
|
let list: Vec<serde_json::Value> = pdfcraft_automation::tools()
|
|
.iter()
|
|
.map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
|
|
.collect();
|
|
stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
|
|
/// written like a tool's own output, so `--root` confines it too.
|
|
pub(crate) fn print_output(
|
|
auto: &pdfcraft_automation::Automation,
|
|
content: Vec<pdfcraft_automation::Content>,
|
|
out: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
for c in content {
|
|
match c {
|
|
pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?,
|
|
pdfcraft_automation::Content::Png { data, width, height } => match out {
|
|
Some(path) => {
|
|
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!("{}", 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)))
|
|
}
|
|
|
|
pub(crate) 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))?;
|
|
print_output(&auto, content, step["out"].as_str()).map_err(|e| match e {
|
|
CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)),
|
|
stdout => stdout,
|
|
})?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
|
|
let mut obj = serde_json::Map::new();
|
|
for kv in positional(args, RUN_OPTIONS).iter().skip(1) {
|
|
let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
|
|
let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
|
|
obj.insert(k.to_string(), value);
|
|
}
|
|
let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
|
|
print_output(&auto, content, flag(args, "--out"))
|
|
}
|
|
|
|
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
|
|
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
|
|
/// that are left. A malformed root flag is an error, never a root silently left out.
|
|
#[cfg(feature = "mcp")]
|
|
fn automation_roots(command: &str, args: &[String]) -> Result<(Option<Arc<pdfcraft_automation::file_access::AutomationRoots>>, Vec<String>), String> {
|
|
let mut found = automation_args::AutomationArgs::new(None, None);
|
|
let mut left = Vec::new();
|
|
let mut it = args.iter().cloned();
|
|
while let Some(arg) = it.next() {
|
|
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
|
|
left.push(arg);
|
|
}
|
|
}
|
|
let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
|
|
.map_err(|e| format!("{command}: {e}"))?;
|
|
Ok((roots, left))
|
|
}
|
|
|
|
#[cfg(feature = "mcp")]
|
|
pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> {
|
|
let (roots, args) = automation_roots("mcp", args)?;
|
|
let args = args.as_slice();
|
|
validate_options("mcp", args, MCP_OPTIONS)?;
|
|
let compact = args.iter().any(|a| a == "--compact");
|
|
let mut auto = automation(args)?;
|
|
if let Some(roots) = roots {
|
|
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pdfcraft-cli: files confined: read root {}, write root {}",
|
|
show(roots.read_root()),
|
|
show(roots.write_root())
|
|
);
|
|
auto = auto.with_automation_roots(Some(roots));
|
|
}
|
|
let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
|
|
let _ = writeln!(
|
|
std::io::stderr().lock(),
|
|
"pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
|
|
pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0],
|
|
if compact { ", compact tool list" } else { "" }
|
|
);
|
|
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string()))
|
|
}
|