509 lines
25 KiB
Rust
509 lines
25 KiB
Rust
//! printcraft-cli — headless PrintCraft.
|
||
//!
|
||
//! ```text
|
||
//! printcraft-cli info <file.pdf> [--password PW] document summary as JSON
|
||
//! printcraft-cli render <file.pdf> --page N [--dpi 96] --out x.pam
|
||
//! printcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||
//! printcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
|
||
//! [--insert-blank 1] [--title T] [--author A] [--full]
|
||
//! printcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
||
//! printcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||
//! printcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||
//! printcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||
//! printcraft-cli tools automation tools and their JSON Schemas
|
||
//! printcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
|
||
//! printcraft-cli run --script steps.json [--root DIR] [{"tool": "doc_open", "args": {…}}, …]
|
||
//! printcraft-cli mcp [--root DIR] MCP server on stdin/stdout (opt-in)
|
||
//! printcraft-cli ui --control FILE <method> [key=value …] [--out shot.png]
|
||
//! drive a running app started with --control FILE
|
||
//! ```
|
||
//!
|
||
//! `run` and `mcp` drive the same tool table (`printcraft-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.
|
||
//!
|
||
//! 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 printcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect};
|
||
|
||
fn main() -> ExitCode {
|
||
let args: Vec<String> = 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") => {
|
||
println!("printcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||
println!("Discord: {} (help and feedback)", printcraft_engine::links::DISCORD);
|
||
println!("Web: {}", printcraft_engine::links::APP_PAGE);
|
||
println!("Source: {}", printcraft_engine::links::GITHUB);
|
||
Ok(())
|
||
}
|
||
_ => Err("usage: printcraft-cli <info|render|text|edit|combine|extract|split|check|tools|run|mcp|ui> … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft"
|
||
.into()),
|
||
};
|
||
match result {
|
||
Ok(()) => ExitCode::SUCCESS,
|
||
Err(e) => {
|
||
eprintln!("printcraft-cli: {e}");
|
||
ExitCode::FAILURE
|
||
}
|
||
}
|
||
}
|
||
|
||
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<Arc<Vec<u8>>, String> {
|
||
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
|
||
}
|
||
|
||
fn info(args: &[String]) -> Result<(), String> {
|
||
let path = *positional(args).first().ok_or("info: missing file")?;
|
||
let info = inspect(read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||
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,
|
||
"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::<Vec<_>>(),
|
||
"warnings": info.warnings,
|
||
});
|
||
println!("{}", serde_json::to_string_pretty(&json).unwrap_or_default());
|
||
Ok(())
|
||
}
|
||
|
||
fn text(args: &[String]) -> Result<(), String> {
|
||
let path = *positional(args).first().ok_or("text: missing file")?;
|
||
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
|
||
let pages: Vec<usize> = match flag(args, "--page") {
|
||
Some(p) => vec![p.parse::<usize>().map_err(|_| "bad --page")?.saturating_sub(1)],
|
||
None => (0..r.page_count()).collect(),
|
||
};
|
||
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);
|
||
continue;
|
||
}
|
||
if n > 0 {
|
||
println!("\u{c}");
|
||
}
|
||
println!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 1-based page list ("1,3,5") → 0-based indices.
|
||
fn page_list(s: &str) -> Result<Vec<usize>, String> {
|
||
s.split(',').map(|p| p.trim().parse::<usize>().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect()
|
||
}
|
||
|
||
/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
|
||
fn edit(args: &[String]) -> Result<(), String> {
|
||
use printcraft_engine::{Edit, Session};
|
||
let path = *positional(args).first().ok_or("edit: missing file")?;
|
||
let out = flag(args, "--out").ok_or("edit: missing --out")?;
|
||
let mut session = Session::new();
|
||
let id = session.open(path, Some(path.to_string()), read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||
let mut edits = Vec::new();
|
||
let mut i = 0;
|
||
while i < args.len() {
|
||
let value = args.get(i + 1).map(String::as_str).unwrap_or("");
|
||
match args[i].as_str() {
|
||
"--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() }),
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
for e in edits {
|
||
session.apply(id, e).map_err(|e| e.to_string())?;
|
||
}
|
||
let bytes = if args.iter().any(|a| a == "--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}"))
|
||
}
|
||
|
||
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<(), String> {
|
||
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::<Result<Vec<_>, String>>()?;
|
||
let bytes = printcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
|
||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
|
||
}
|
||
|
||
fn extract(args: &[String]) -> Result<(), String> {
|
||
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 = printcraft_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}"))
|
||
}
|
||
|
||
fn split(args: &[String]) -> Result<(), String> {
|
||
use printcraft_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 = printcraft_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);
|
||
for (a, b, bytes) in session.split(id, &by).map_err(|e| e.to_string())? {
|
||
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()))?;
|
||
println!("{}", name.display());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn render(args: &[String]) -> Result<(), String> {
|
||
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 (.pam)")?;
|
||
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);
|
||
}
|
||
// PAM (netpbm RGB_ALPHA) keeps this tool dependency-free; convert with any image tool.
|
||
let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?;
|
||
write!(f, "P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).map_err(|e| e.to_string())?;
|
||
f.write_all(&p.rgba).map_err(|e| e.to_string())?;
|
||
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<(), String> {
|
||
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::<String>() });
|
||
println!("{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 printcraft_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"PrintCraftFuzz".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::<Vec<_>>().join("-").to_lowercase()
|
||
}
|
||
|
||
fn collect(paths: &[&str]) -> Vec<PathBuf> {
|
||
let mut out = Vec::new();
|
||
let mut stack: Vec<PathBuf> = 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<(), String> {
|
||
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<String, usize> = 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::<String>()
|
||
);
|
||
}
|
||
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::<serde_json::Value>(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<printcraft_automation::Automation, String> {
|
||
let a = printcraft_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<(), String> {
|
||
let list: Vec<serde_json::Value> = printcraft_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();
|
||
println!("{}", 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).
|
||
fn print_output(content: Vec<printcraft_automation::Content>, out: Option<&str>) -> Result<(), String> {
|
||
for c in content {
|
||
match c {
|
||
printcraft_automation::Content::Json(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()),
|
||
printcraft_automation::Content::Png { data, width, height } => match out {
|
||
Some(path) => {
|
||
std::fs::write(path, &data).map_err(|e| format!("{path}: {e}"))?;
|
||
println!("{}", serde_json::json!({ "image": path, "width": width, "height": height }));
|
||
}
|
||
None => println!(
|
||
"{}",
|
||
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<(), String> {
|
||
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}"))?;
|
||
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(content, step["out"].as_str())?;
|
||
}
|
||
return Ok(());
|
||
}
|
||
let tool = *positional(args).first().ok_or("run: missing tool name (see `printcraft-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(content, flag(args, "--out"))
|
||
}
|
||
|
||
#[cfg(feature = "mcp")]
|
||
fn mcp(args: &[String]) -> Result<(), String> {
|
||
let mut server = printcraft_automation::mcp::McpServer::new(automation(args)?);
|
||
eprintln!("printcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", printcraft_automation::mcp::PROTOCOL_VERSIONS[0]);
|
||
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| e.to_string())
|
||
}
|
||
|
||
// ---- UI control channel client -----------------------------------------------------------------
|
||
|
||
/// One request to a running app's control channel (`printcraft --control FILE`).
|
||
fn ui(args: &[String]) -> Result<(), String> {
|
||
use std::io::{BufRead, BufReader, Write as _};
|
||
let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `printcraft --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<serde_json::Value, String> {
|
||
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}"));
|
||
}
|
||
println!("{}", serde_json::to_string_pretty(&result).unwrap_or_default());
|
||
Ok(())
|
||
}
|