//! pdfcraft-cli — headless PdfCraft. //! //! ```text //! pdfcraft-cli info [--password PW] document summary as JSON //! pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) //! pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) //! pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] //! [--insert-blank 1] [--title T] [--author A] [--full] //! pdfcraft-cli combine … --out combined.pdf //! pdfcraft-cli extract --pages 1,3,5 --out out.pdf //! pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR] //! pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] //! pdfcraft-cli tools automation tools and their JSON Schemas //! pdfcraft-cli run [key=value …] [--root DIR] [--out image.png] //! pdfcraft-cli run --script steps.json [--root DIR] [{"tool": "doc_open", "args": {…}}, …] //! pdfcraft-cli mcp [--root DIR] MCP server on stdin/stdout (opt-in) //! pdfcraft-cli ui --control FILE [key=value …] [--out shot.png] //! drive a running app started with --control FILE //! ``` //! //! `run` and `mcp` drive the same tool table (`pdfcraft-automation`). In `run`, values parse as //! JSON when they can (`pages=[1,3]`, `degrees=90`) and are strings otherwise. A script runs its //! steps in one session, so `doc_open` returns id 1, the next document id 2, and so on. A step's //! `"out"` (like `--out`) saves its image there. With `--root DIR`, every path a tool or step //! names, `"out"` included, must be inside DIR, and relative paths resolve inside it. //! //! The MCP server never starts on its own: it runs only when this command is launched (normally //! by an agent configured to use it), talks only over stdio, and exits when stdin closes. //! //! `check` is the robustness harness: every file is opened, inspected and fully rendered in a //! *separate child process* with a wall-clock timeout, so hangs, panics and aborts in any //! dependency are observed and reported instead of taking the harness down. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; use pdfcraft_engine::export::ImageFormat; use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect}; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let result = match args.first().map(String::as_str) { Some("info") => info(&args[1..]), Some("render") => render(&args[1..]), Some("text") => text(&args[1..]), Some("edit") => edit(&args[1..]), Some("combine") => combine(&args[1..]), Some("extract") => extract(&args[1..]), Some("split") => split(&args[1..]), Some("check") => check(&args[1..]), Some("check-one") => check_one(&args[1..]), Some("tools") => tools(), Some("run") => run(&args[1..]), Some("ui") => ui(&args[1..]), #[cfg(feature = "mcp")] Some("mcp") => mcp(&args[1..]), Some("--version") => version(), _ => Err("usage: pdfcraft-cli … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft" .into()), }; match result { Ok(()) => ExitCode::SUCCESS, Err(CliError::Stdout(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, Err(e) => { let _ = writeln!(std::io::stderr().lock(), "pdfcraft-cli: {e}"); ExitCode::FAILURE } } } // Keep stdout errors typed: a closed pipe is normal, but file and command errors still fail. #[derive(Debug)] enum CliError { Message(String), Stdout(std::io::Error), } impl From for CliError { fn from(message: String) -> Self { Self::Message(message) } } impl From<&str> for CliError { fn from(message: &str) -> Self { Self::Message(message.to_string()) } } impl std::fmt::Display for CliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Message(message) => f.write_str(message), Self::Stdout(error) => write!(f, "stdout: {error}"), } } } fn stdout_line(line: std::fmt::Arguments<'_>) -> Result<(), CliError> { let mut stdout = std::io::stdout().lock(); writeln!(stdout, "{line}").and_then(|()| stdout.flush()).map_err(CliError::Stdout) } fn version() -> Result<(), CliError> { stdout_line(format_args!("pdfcraft-cli {}", env!("CARGO_PKG_VERSION")))?; stdout_line(format_args!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD))?; stdout_line(format_args!("Web: {}", pdfcraft_engine::links::APP_PAGE))?; stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?; Ok(()) } fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) } fn positional(args: &[String]) -> Vec<&str> { let mut out = Vec::new(); let mut skip = false; for a in args { if skip { skip = false; continue; } if a.starts_with("--") { skip = true; continue; } out.push(a.as_str()); } out } fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) } fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; // Dynamic XFA forms: say what laying the template out gives (what the app shows). let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) .then(|| { let mut s = pdfcraft_engine::Session::new(); let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; let d = s.get(id)?; d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })) }) .flatten(); let json = serde_json::json!({ "file": path, "pdf_version": info.pdf_version, "file_size": info.file_size, "pages": info.pages.len(), "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), "xfa_layout": xfa_layout, "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; Ok(()) } fn text(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("text: missing file")?; let password = flag(args, "--password"); let bytes = read(path)?; let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); // A document the renderer could not open (wrong or missing password, unparseable file) has // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask // `inspect` why instead, so a protected file fails the same way `info` does. if r.page_count() == 0 { let info = inspect(bytes, password).map_err(|e| format!("text: {e}"))?; if !info.pages.is_empty() { return Err("text: the document could not be parsed".into()); } } let pages: Vec = match flag(args, "--page") { Some(p) => vec![p.parse::().map_err(|_| "bad --page")?.saturating_sub(1)], None => (0..r.page_count()).collect(), }; let mut failed: Vec = Vec::new(); for (n, p) in pages.iter().enumerate() { let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); if let Some(e) = out.error { eprintln!("page {}: {e}", p + 1); failed.push(p + 1); continue; } if n > 0 { stdout_line(format_args!("\u{c}"))?; } stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; } if failed.is_empty() { Ok(()) } else { Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) } } /// 1-based page list ("1,3,5") → 0-based indices. fn page_list(s: &str) -> Result, String> { s.split(',').map(|p| p.trim().parse::().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect() } /// The options `edit` understands, for its "unknown option" message. const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; /// Apply page and metadata edits through the engine and save (incrementally unless `--full`). /// /// Every argument is parsed before the file is opened: an option this command does not know /// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an /// unchanged copy and exit 0, which looks like a successful edit (#133). fn edit(args: &[String]) -> Result<(), CliError> { use pdfcraft_engine::{Edit, Session}; let mut path: Option<&str> = None; let mut out: Option<&str> = None; let mut password: Option<&str> = None; let mut full = false; let mut edits = Vec::new(); let mut i = 0; while let Some(arg) = args.get(i).map(String::as_str) { // Options that take no value, and the input file. match arg { "--full" => { full = true; i += 1; continue; } a if a.starts_with("--") => {} a => { if path.is_some() { return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); } path = Some(a); i += 1; continue; } } let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?; match arg { "--out" => out = Some(value), "--password" => password = Some(value), "--rotate" => { let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? }); } "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), "--move" => { let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; let to: usize = to.parse().map_err(|_| "bad target")?; edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.saturating_sub(1) }); } "--insert-blank" => { let at: usize = value.parse().map_err(|_| "bad position")?; edits.push(Edit::InsertBlankPage { at: at.saturating_sub(1), width: 612.0, height: 792.0 }); } "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), } i += 2; } let path = path.ok_or("edit: missing file")?; let out = out.ok_or("edit: missing --out")?; let mut session = Session::new(); let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; for e in edits { session.apply(id, e).map_err(|e| e.to_string())?; } let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) } fn file_stem(path: &str) -> String { Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) } fn combine(args: &[String]) -> Result<(), CliError> { let out = flag(args, "--out").ok_or("combine: missing --out")?; let inputs = positional(args); if inputs.len() < 2 { return Err("combine: give at least two input files".into()); } let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) } fn extract(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("extract: missing file")?; let out = flag(args, "--out").ok_or("extract: missing --out")?; let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) } fn split(args: &[String]) -> Result<(), CliError> { use pdfcraft_engine::SplitBy; let path = *positional(args).first().ok_or("split: missing file")?; let by = match (flag(args, "--every"), flag(args, "--before")) { (Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?), (None, Some(list)) => SplitBy::Before(page_list(list)?), _ => return Err("split: give either --every N or --before PAGES".into()), }; let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; let stem = file_stem(path); let parts = session.split(id, &by).map_err(|e| e.to_string())?; // An output folder that does not exist yet is created (#249); one that can't be is reported // as the folder's problem, not as the first part's. std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; for (a, b, bytes) in parts { let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?; stdout_line(format_args!("{}", name.display()))?; } Ok(()) } fn render(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("render: missing file")?; let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { Some("png") => Some(ImageFormat::Png), Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), Some("tif" | "tiff") => Some(ImageFormat::Tiff), Some("pam") => None, _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), }; let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() }); let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); if let Some(e) = p.error { return Err(e.into()); } let bytes = match format { Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?, None => { // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); pam.extend_from_slice(&p.rgba); pam } }; std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); Ok(()) } /// Child-process body for `check`: prints one JSON line. fn check_one(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("check-one: missing file")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; let start = Instant::now(); let bytes = read(path)?; let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), Ok(info) => { let mut r = PageRenderer::new(bytes, RenderConfig::default()); let mut failed = Vec::new(); for i in 0..info.pages.len().min(500) { let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error { failed.push(format!("p{} text: {e}", i + 1)); } if let Some(e) = p.error { failed.push(format!("p{}: {e}", i + 1)); } } let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) } }; // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, // so a panic in cos shows up as a crash of this process. if args.iter().any(|a| a == "--edit") { edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); } let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); stdout_line(format_args!("{line}"))?; Ok(()) } /// Open with cos, touch every object, edit the catalog, save incrementally and in full (both /// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. fn edit_round_trip(bytes: &[u8]) { use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; for num in doc.object_numbers().into_iter().take(20_000) { if let Ok(o) = doc.try_get(num) && let Object::Stream(s) = &*o { let _ = s.decoded(); } } if let Some(root) = doc.root() { let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); } let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] .into_iter() .flatten() { if let Ok(again) = Document::open(Arc::new(out)) { let _ = again.root().map(|r| again.get(r)); } } } fn short(s: &str) -> String { s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() } fn collect(paths: &[&str]) -> Vec { let mut out = Vec::new(); let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); while let Some(p) = stack.pop() { if p.is_dir() { if let Ok(rd) = std::fs::read_dir(&p) { stack.extend(rd.flatten().map(|e| e.path())); } } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { out.push(p); } } out.sort(); out } fn check(args: &[String]) -> Result<(), CliError> { let files = collect(&positional(args)); if files.is_empty() { return Err("check: no PDF files found".into()); } let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?); let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); let exe = std::env::current_exe().map_err(|e| e.to_string())?; let mut results = Vec::new(); let mut counts: std::collections::BTreeMap = Default::default(); for (n, f) in files.iter().enumerate() { let r = run_child(&exe, f, &dpi, timeout); let status = r["status"].as_str().unwrap_or("?").to_string(); *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; if status != "ok" { eprintln!( "[{}/{}] {status:<28} {} {}", n + 1, files.len(), f.display(), r["detail"].as_str().unwrap_or("").chars().take(160).collect::() ); } results.push(r); } eprintln!("\nchecked {} files: {:?}", files.len(), counts); if let Some(out) = flag(args, "--json") { std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; } Ok(()) } fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { let file_s = file.to_string_lossy().to_string(); let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); let mut child = match child { Ok(c) => c, Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), }; let start = Instant::now(); loop { match child.try_wait() { Ok(Some(status)) => { let mut out = String::new(); if let Some(mut so) = child.stdout.take() { use std::io::Read; let _ = so.read_to_string(&mut out); } return match serde_json::from_str::(out.trim()) { Ok(v) => v, Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), }; } Ok(None) if start.elapsed() > timeout => { let _ = child.kill(); let _ = child.wait(); return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); } Ok(None) => std::thread::sleep(Duration::from_millis(20)), Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), } } } // ---- automation -------------------------------------------------------------------------------- fn automation(args: &[String]) -> Result { 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), } } fn tools() -> Result<(), CliError> { let list: Vec = 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. fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, 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!( "{}", serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) ))?, }, } } Ok(()) } fn run(args: &[String]) -> Result<(), CliError> { 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::from_str(&text).map_err(|e| format!("{script}: {e}"))?; 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).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; let mut obj = serde_json::Map::new(); for kv in positional(args).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")) } #[cfg(feature = "mcp")] fn mcp(args: &[String]) -> Result<(), CliError> { let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?); eprintln!("pdfcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0]); server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) } // ---- UI control channel client ----------------------------------------------------------------- /// One request to a running app's control channel (`pdfcraft --control FILE`). fn ui(args: &[String]) -> Result<(), CliError> { use std::io::{BufRead, BufReader, Write as _}; let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; let info: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?).map_err(|e| format!("{file}: {e}"))?; let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; let pos = positional(args); let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; let mut params = serde_json::Map::new(); for kv in pos.iter().skip(1) { let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); } let stream = std::net::TcpStream::connect(("127.0.0.1", port as u16)) .map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; let mut write = stream.try_clone().map_err(|e| e.to_string())?; let mut lines = BufReader::new(stream).lines(); let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; match reply.get("error") { Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), None => Ok(reply["result"].clone()), } }; rpc(1, "auth", serde_json::json!({ "token": token }))?; let mut result = rpc(2, &method, serde_json::Value::Object(params))?; if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { use base64::Engine as _; let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; result["png_base64"] = serde_json::json!(format!("written to {out}")); } stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; Ok(()) }