//! `drawcraft-cli`: DrawCraft from the command line. //! //! ```text //! drawcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] //! drawcraft-cli run [--in file.drawcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.drawcraft]... [--scale 2] //! drawcraft-cli commands //! drawcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! drawcraft-cli perf [--paths 50000] //! ``` #![forbid(unsafe_code)] use std::io::Write; use std::process::ExitCode; mod perf; use drawcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server}; use serde_json::{Value, json}; const USAGE: &str = "\ drawcraft-cli — DrawCraft automation USAGE: drawcraft-cli mcp [--connect ADDR | --headless] Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 (drawcraft --control 7979), falling back to a headless in-process session. drawcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N] Headless batch: open FILE (.drawcraft/.svg) or start a new document, run commands in order, export (.svg, .png, .pdf, .jpg, .webp, .drawcraft by extension). Prints one JSON result per step. drawcraft-cli commands Print the command catalogue as JSON. drawcraft-cli bench FILE [--size WxH] [--iters N] Render FILE (.drawcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. drawcraft-cli perf [--paths N] Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded. "; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("mcp") => mcp(&args[1..]), Some("run") => run(&args[1..]), Some("commands") => commands(), Some("bench") => bench(&args[1..]), Some("perf") => perf::run(&args[1..]), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) } Some("-V" | "--version") => { println!("drawcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) } Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")), }; match r { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("drawcraft-cli: {e}"); ExitCode::FAILURE } } } fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut headless = false; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?), "--headless" => headless = true, other => return Err(format!("unknown mcp option `{other}`")), } } if headless && connect.is_some() { return Err("use either --connect or --headless".into()); } let backend: Box = if headless { Box::new(Headless::with_document()) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) } else { match Remote::connect(DEFAULT_ADDR) { Ok(r) => Box::new(r), Err(_) => Box::new(Headless::with_document()), } }; eprintln!("drawcraft-cli: MCP server on stdio ({})", backend.describe()); let stdin = std::io::stdin(); let stdout = std::io::stdout(); Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) } fn commands() -> Result<(), String> { let mut h = Headless::new(); let v = h.call("engine.commands", json!({}))?; println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); Ok(()) } enum Step { Cmd(String, Value), Export(String), } fn run(args: &[String]) -> Result<(), String> { let mut input: Option = None; let mut steps: Vec = vec![]; let mut scale = 1.0; let mut it = args.iter(); while let Some(a) = it.next() { let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}")); match a.as_str() { "--in" | "-i" => input = Some(val("a file")?), "--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))), "--params" | "-p" => { let raw = val("JSON")?; let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?; if !p.is_object() { return Err(format!("--params must be a JSON object, got {raw}")); } match steps.last_mut() { Some(Step::Cmd(_, params)) => *params = p, _ => return Err("--params must follow a --cmd".into()), } } "--export" | "-o" => steps.push(Step::Export(val("a file")?)), "--scale" | "-s" => { let raw = val("a number")?; scale = raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?; } other => return Err(format!("unknown run option `{other}`")), } } let mut h = Headless::new(); let mut out = std::io::stdout().lock(); let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string()); if let Some(path) = &input { let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?; emit(json!({"step": "open", "path": path, "result": r}))?; } else if !matches!(steps.first(), Some(Step::Cmd(id, _)) if id == "file.new") { h.ensure_document(); } for step in steps { match step { Step::Cmd(id, params) => { let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?; emit(json!({"step": "cmd", "command": id, "result": r}))?; } Step::Export(path) => { let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?; emit(json!({"step": "export", "result": r}))?; } } } Ok(()) } fn bench(args: &[String]) -> Result<(), String> { let mut file = None; let (mut w, mut h, mut iters) = (2880u32, 1800u32, 5u32); let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--size" => { let v = it.next().ok_or("--size needs WxH")?; let (a, b) = v.split_once('x').ok_or("--size needs WxH")?; w = a.parse().map_err(|_| "bad width")?; h = b.parse().map_err(|_| "bad height")?; } "--iters" => iters = it.next().and_then(|v| v.parse().ok()).ok_or("--iters needs a number")?, f if file.is_none() => file = Some(f.to_string()), other => return Err(format!("unknown bench option `{other}`")), } } let file = file.ok_or("bench needs a FILE")?; let mut hl = Headless::new(); hl.call("app.open", json!({ "path": file })).map_err(|e| format!("open {file}: {e}"))?; let doc = hl.session.doc().map_err(|e| e.to_string())?.doc.clone(); let b = doc.artboards.first().map(|a| a.rect).ok_or("document has no artboard")?; let z = (w as f64 / b.width()).min(h as f64 / b.height()) * 0.95; let view = drawcraft_geom::Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * drawcraft_geom::Affine::scale(z) * drawcraft_geom::Affine::translate(-b.center().to_vec2()); let opts = drawcraft_render::RenderOptions::default(); println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::()); for threads in [drawcraft_render::default_threads(), 0] { let mut r = drawcraft_render::Renderer::new(); r.threads = threads; r.render(&doc, w, h, view, &opts); let t = std::time::Instant::now(); for _ in 0..iters { r.render(&doc, w, h, view, &opts); } println!( " threads {threads}: {:.1} ms/frame (drawn {}, culled {})", t.elapsed().as_secs_f64() * 1000.0 / iters as f64, r.stats.drawn, r.stats.culled ); } Ok(()) }