mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 04:22:46 +00:00
.fig files open through file.open, file.import, drag and drop, the dialogs, the CLI and MCP. A generic reader written from the public format description decodes the zip, the deflate/zstd chunks and the kiwi schema and message, then maps nodes by field name onto the REST importer: layer tree, geometry, paints with styles resolved, strokes, effects, text runs, vectors as outlines, auto layout including grid, constraints, components, sets, instances expanded from their main with overrides, styles, variables with modes and bindings, prototyping and image fills. Unsupported layers come back as warnings. Fuzzed; tested on fixtures generated in code. New dependency: ruzstd (pure-Rust zstd decoder).
179 lines
7.5 KiB
Rust
179 lines
7.5 KiB
Rust
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
|
|
//! command scripts, list commands, serve MCP on stdio and measure performance.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
mod args;
|
|
mod files;
|
|
mod perf;
|
|
|
|
use std::io::Write;
|
|
use std::process::ExitCode;
|
|
|
|
use args::Args;
|
|
use serde_json::{Value, json};
|
|
|
|
pub const USAGE: &str = "figcraft-cli — FigCraft without a window
|
|
|
|
USAGE:
|
|
figcraft-cli info <file> [--json]
|
|
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]
|
|
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
|
|
figcraft-cli convert <in> <out>
|
|
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
|
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
|
figcraft-cli commands [--json] [--filter TEXT]
|
|
figcraft-cli mcp [--attach PORT]
|
|
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
|
figcraft-cli help | --help | --version
|
|
|
|
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
|
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
|
|
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
|
|
is given; PDFs take print options (a bleed like 3mm or 0.125in,
|
|
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
|
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
|
See docs/cli.md and docs/mcp.md.";
|
|
|
|
fn main() -> ExitCode {
|
|
let argv: Vec<String> = std::env::args().skip(1).collect();
|
|
match run(&argv) {
|
|
Ok(code) => code,
|
|
Err(CliError::Usage(msg)) => {
|
|
eprintln!("error: {msg}\n\n{USAGE}");
|
|
ExitCode::from(2)
|
|
}
|
|
Err(CliError::Failed(msg)) => {
|
|
eprintln!("error: {msg}");
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum CliError {
|
|
/// Bad arguments: usage is printed, exit code 2.
|
|
Usage(String),
|
|
/// The operation failed: exit code 1.
|
|
Failed(String),
|
|
}
|
|
|
|
impl From<String> for CliError {
|
|
fn from(s: String) -> Self {
|
|
CliError::Failed(s)
|
|
}
|
|
}
|
|
|
|
pub type CliResult<T> = Result<T, CliError>;
|
|
|
|
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
|
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
|
|
match cmd.as_str() {
|
|
"help" | "--help" | "-h" => {
|
|
println!("{USAGE}");
|
|
Ok(ExitCode::SUCCESS)
|
|
}
|
|
"--version" | "-V" | "version" => {
|
|
println!("figcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
|
Ok(ExitCode::SUCCESS)
|
|
}
|
|
"info" => {
|
|
let a = Args::parse(rest, &[], &["json"])?;
|
|
let file = a.one_positional("info <file>")?;
|
|
files::info(&file, a.flag("json"))
|
|
}
|
|
"export" => {
|
|
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
|
|
let file = a.one_positional("export <file> -o <out>")?;
|
|
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
|
let target =
|
|
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
|
|
files::export(&file, &out, &target)
|
|
}
|
|
"convert" => {
|
|
let a = Args::parse(rest, &[], &[])?;
|
|
match a.positional.as_slice() {
|
|
[i, o] => files::convert(i, o),
|
|
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
|
}
|
|
}
|
|
"render" => {
|
|
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
|
|
let file = a.one_positional("render <file> -o <out.png>")?;
|
|
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
|
|
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
|
|
}
|
|
"run" => {
|
|
let a = Args::parse(rest, &["in", "out"], &[])?;
|
|
let script = a.one_positional("run <script.json|.jsonl>")?;
|
|
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
|
|
}
|
|
"commands" => {
|
|
let a = Args::parse(rest, &["filter"], &["json"])?;
|
|
if !a.positional.is_empty() {
|
|
return Err(CliError::Usage("commands takes no positional arguments".into()));
|
|
}
|
|
commands(a.flag("json"), a.value("filter").as_deref());
|
|
Ok(ExitCode::SUCCESS)
|
|
}
|
|
"mcp" => {
|
|
let a = Args::parse(rest, &["attach"], &[])?;
|
|
if !a.positional.is_empty() {
|
|
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
|
}
|
|
mcp(a.value("attach").as_deref())
|
|
}
|
|
"perf" => {
|
|
let a = Args::parse(rest, &["layers", "instances"], &[])?;
|
|
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
|
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
|
|
perf::run(&sizes, &instances)
|
|
}
|
|
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
|
|
}
|
|
}
|
|
|
|
fn commands(as_json: bool, filter: Option<&str>) {
|
|
let f = filter.map(str::to_lowercase).unwrap_or_default();
|
|
let list: Vec<&figcraft_engine::CommandSpec> = figcraft_engine::all_commands()
|
|
.iter()
|
|
.filter(|c| f.is_empty() || c.id.to_lowercase().contains(&f) || c.label.to_lowercase().contains(&f) || c.params.to_lowercase().contains(&f))
|
|
.collect();
|
|
let stdout = std::io::stdout();
|
|
let mut out = stdout.lock();
|
|
if as_json {
|
|
let v: Vec<Value> = list
|
|
.iter()
|
|
.map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history}))
|
|
.collect();
|
|
let _ = writeln!(out, "{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
|
return;
|
|
}
|
|
for c in list {
|
|
let sc = c.shortcut().map(|s| format!(" ({s})")).unwrap_or_default();
|
|
let undo = if c.history { "*" } else { "" };
|
|
let _ = writeln!(out, "{}{undo} {}{sc}\n {}", c.id, c.label, c.params);
|
|
}
|
|
}
|
|
|
|
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
|
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
|
Some(p) => {
|
|
let addr = if p.contains(':') {
|
|
p.to_string()
|
|
} else {
|
|
format!("127.0.0.1:{}", p.parse::<u16>().map_err(|_| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?)
|
|
};
|
|
let b = figcraft_mcp::Attached::connect(&addr)
|
|
.map_err(|e| format!("can't reach the FigCraft app at {addr}: {e} (start it with `figcraft --control PORT`)"))?;
|
|
Box::new(b)
|
|
}
|
|
None => Box::new(figcraft_mcp::Headless::new()),
|
|
};
|
|
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
|
|
let mut server = figcraft_mcp::Server::new(backend);
|
|
let stdin = std::io::stdin();
|
|
let stdout = std::io::stdout();
|
|
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
|
|
Ok(ExitCode::SUCCESS)
|
|
}
|