mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge main into #1420
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+54
@@ -746,6 +746,46 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.3"
|
||||
@@ -1737,6 +1777,7 @@ dependencies = [
|
||||
name = "figcraft-cli"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
@@ -1745,6 +1786,7 @@ dependencies = [
|
||||
"figcraft-svg",
|
||||
"figcraft-text",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2564,6 +2606,12 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -5006,6 +5054,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
|
||||
@@ -57,6 +57,8 @@ craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d39
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
# Command-line parsing for figcraft-cli (craft-contrib RFC-0012: clap derive, as photocraft does).
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -22,3 +22,5 @@ figcraft-svg = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
+510
-155
@@ -1,175 +1,530 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
|
||||
//! The command line, as clap types (craft-contrib RFC-0012). Values are parsed into their types
|
||||
//! here, so a bad one is a usage error naming its flag (exit 2) before any work starts, and the
|
||||
//! handlers never see it. A flag a subcommand doesn't take is a usage error, and so is one given
|
||||
//! twice, except the automation roots, which repeat. A value starting with `-` is written
|
||||
//! `--filter=-x`; after `--` every argument is a positional.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use crate::{CliError, CliResult};
|
||||
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
|
||||
use figcraft_doc::{PrintSettings, VdCompat};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
pub positional: Vec<String>,
|
||||
values: BTreeMap<String, Vec<String>>,
|
||||
flags: Vec<String>,
|
||||
/// FigCraft without a window
|
||||
///
|
||||
/// Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig
|
||||
/// (best effort; `info` lists what wasn't imported), .sketch (Sketch 48 or newer, best effort),
|
||||
/// .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer).
|
||||
#[derive(Parser)]
|
||||
#[command(name = "figcraft-cli", bin_name = "figcraft-cli", version, arg_required_else_help = true, after_help = "See docs/cli.md and docs/mcp.md.")]
|
||||
pub struct Cli {
|
||||
/// Lay text out with the fonts built into FigCraft only, for renders that match on every
|
||||
/// machine (or set FIGCRAFT_NO_SYSTEM_FONTS=1). Otherwise installed fonts are indexed the
|
||||
/// first time text is laid out, as in the app
|
||||
#[arg(long, global = true)]
|
||||
pub no_system_fonts: bool,
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
|
||||
let mut a = Args::default();
|
||||
// The first bad option, reported once the rest of the line is checked for `--help`.
|
||||
let mut bad: Option<CliError> = None;
|
||||
let mut it = argv.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
if arg == "--" {
|
||||
a.positional.extend(it.cloned());
|
||||
break;
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
Info(InfoArgs),
|
||||
Export(ExportArgs),
|
||||
Convert(ConvertArgs),
|
||||
Render(RenderArgs),
|
||||
Run(RunArgs),
|
||||
Commands(CommandsArgs),
|
||||
Mcp(McpArgs),
|
||||
/// Publish plugins to a server's plugin directory
|
||||
Plugin {
|
||||
#[command(subcommand)]
|
||||
command: PluginCommand,
|
||||
},
|
||||
Perf(PerfArgs),
|
||||
}
|
||||
|
||||
/// The folders a file subcommand may touch. Without either flag it reads and writes as the user
|
||||
/// who started it; with one, reads are allowed only inside a read or write root and writes only
|
||||
/// inside a write root, so a read root alone denies writing altogether.
|
||||
#[derive(Args)]
|
||||
pub struct RootArgs {
|
||||
/// Confine file access: a folder that may be read (repeatable; it must exist)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub automation_read_root: Vec<String>,
|
||||
/// Confine file access: a folder that may be read and written (repeatable; it must exist)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub automation_write_root: Vec<String>,
|
||||
}
|
||||
|
||||
/// Pages, node counts, top-level frames, style/variable/image counts
|
||||
#[derive(Args)]
|
||||
pub struct InfoArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: String,
|
||||
/// Print JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// Export frames, a layer or the whole file
|
||||
///
|
||||
/// <OUT> is a folder: one file per top-level frame of the page. An <OUT> with an image extension,
|
||||
/// or --node, writes one file. `fig` (or an <OUT> ending in .fig) writes the whole file as .fig,
|
||||
/// experimental: other apps may not open every file. `penpot` (or an <OUT> ending in .penpot)
|
||||
/// writes the whole file as a Penpot 2.x archive. The format comes from --format, else <OUT>'s
|
||||
/// extension, else png.
|
||||
#[derive(Args)]
|
||||
pub struct ExportArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: String,
|
||||
/// The folder or file to write
|
||||
#[arg(short = 'o', long = "out", value_name = "OUT")]
|
||||
pub out: String,
|
||||
/// png, jpg, svg, webp, pdf, xml (Android Vector Drawable), fig or penpot
|
||||
#[arg(long, value_name = "FORMAT", value_parser = format)]
|
||||
pub format: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub scale: ScaleArg,
|
||||
/// The page to export (default the first)
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub page: Option<String>,
|
||||
/// The layer to export, by id (12 or 0:12) or name
|
||||
#[arg(long, value_name = "ID|NAME")]
|
||||
pub node: Option<String>,
|
||||
/// PDF: grow each page by this bleed, in points or a length like 3mm or 0.125in (up to 2in)
|
||||
#[arg(long, value_name = "LENGTH", value_parser = bleed, default_value = "0")]
|
||||
pub bleed: f64,
|
||||
/// PDF: draw crop marks outside the bleed
|
||||
#[arg(long)]
|
||||
pub crop_marks: bool,
|
||||
/// PDF: draw bleed marks outside the bleed
|
||||
#[arg(long)]
|
||||
pub bleed_marks: bool,
|
||||
/// PDF: write CMYK colour
|
||||
#[arg(long)]
|
||||
pub cmyk: bool,
|
||||
/// Android Vector Drawable: what the format can't draw is approximated (bestEffort), left out
|
||||
/// (exact) or dropped without notes (ignore)
|
||||
#[arg(long, value_name = "bestEffort|exact|ignore", value_parser = vd_compat, default_value = "bestEffort")]
|
||||
pub vd_compat: VdCompat,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
impl ExportArgs {
|
||||
/// The PDF print options.
|
||||
pub fn print(&self) -> PrintSettings {
|
||||
PrintSettings { bleed: self.bleed, crop_marks: self.crop_marks, bleed_marks: self.bleed_marks, cmyk: self.cmyk }
|
||||
}
|
||||
}
|
||||
|
||||
/// `--scale`, shared by `export` and `render`.
|
||||
#[derive(Args)]
|
||||
pub struct ScaleArg {
|
||||
/// Raster scale, in (0, 64]: 2 or 2x
|
||||
// allow_negative_numbers makes `--scale -3` an out-of-range value rather than an unexpected argument.
|
||||
#[arg(long, value_name = "N", value_parser = scale, default_value = "1", allow_negative_numbers = true)]
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// Convert by extension (.figcraft, .json, .svg, .png, .jpg, .webp, .pdf, .fig, .penpot)
|
||||
#[derive(Args)]
|
||||
pub struct ConvertArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "IN")]
|
||||
pub input: String,
|
||||
/// The file to write; its extension picks the format
|
||||
#[arg(value_name = "OUT")]
|
||||
pub output: String,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// Render the whole page with its background as png, jpg or webp
|
||||
#[derive(Args)]
|
||||
pub struct RenderArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: String,
|
||||
/// The image to write
|
||||
#[arg(short = 'o', long = "out", value_name = "OUT.png")]
|
||||
pub out: String,
|
||||
#[command(flatten)]
|
||||
pub scale: ScaleArg,
|
||||
/// The page to render (default the first)
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub page: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// Run a script of commands in a headless session, one JSON result line per step
|
||||
///
|
||||
/// The script is a JSON array (or JSON lines) of {"command": id, "params": {...}}, run as one
|
||||
/// all-or-nothing undo step.
|
||||
#[derive(Args)]
|
||||
pub struct RunArgs {
|
||||
/// The script to run
|
||||
#[arg(value_name = "SCRIPT.json|SCRIPT.jsonl")]
|
||||
pub script: String,
|
||||
/// Open this file first instead of a new document
|
||||
#[arg(long = "in", value_name = "FILE")]
|
||||
pub input: Option<String>,
|
||||
/// Save the result here, by extension
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub out: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// List every command with its label, shortcut, undoable flag (*) and parameters
|
||||
#[derive(Args)]
|
||||
pub struct CommandsArgs {
|
||||
/// Print JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
/// Only the commands whose id, label or parameters contain this text
|
||||
#[arg(long, value_name = "TEXT")]
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Serve MCP on stdio: headless, or attached to a running app
|
||||
///
|
||||
/// The automation roots confine the headless session's file access. A server started with
|
||||
/// --attach is confined by the app it attaches to: start that app with `figcraft --control PORT`
|
||||
/// and the same roots.
|
||||
#[derive(Args)]
|
||||
pub struct McpArgs {
|
||||
/// Attach to the app started with `figcraft --control PORT`; a bare port means 127.0.0.1:PORT.
|
||||
/// Refuses the automation roots
|
||||
#[arg(long, value_name = "PORT", value_parser = attach, conflicts_with_all = ["automation_read_root", "automation_write_root"])]
|
||||
pub attach: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum PluginCommand {
|
||||
Publish(PublishArgs),
|
||||
}
|
||||
|
||||
/// Send a plugin folder to a figcraft-server's plugin directory as a new version
|
||||
///
|
||||
/// The folder holds figcraft-plugin.json, its module and an optional assets/ folder. The server's
|
||||
/// key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out
|
||||
/// of the shell history); it is never printed.
|
||||
#[derive(Args)]
|
||||
pub struct PublishArgs {
|
||||
/// The plugin folder
|
||||
#[arg(value_name = "FOLDER")]
|
||||
pub folder: String,
|
||||
/// The server, ws://host:port/ (optionally ?key=KEY)
|
||||
#[arg(long, value_name = "URL")]
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
/// Measure the hot paths on synthetic documents and print Markdown tables
|
||||
#[derive(Args)]
|
||||
pub struct PerfArgs {
|
||||
/// Document sizes in layers, each clamped to 10..=2,000,000
|
||||
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "1000,10000")]
|
||||
pub layers: Vec<usize>,
|
||||
/// Component instance counts, capped to 100,000; 0 skips the components table
|
||||
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "2000")]
|
||||
pub instances: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ValueError {
|
||||
#[error("expected a number in (0, 64], like 2 or 2x")]
|
||||
Scale,
|
||||
#[error("expected points or a length like 3mm or 0.125in, up to 2in")]
|
||||
Bleed,
|
||||
#[error("expected bestEffort, exact or ignore")]
|
||||
VdCompat,
|
||||
#[error("expected png, jpg, svg, webp, pdf, xml, fig or penpot")]
|
||||
Format,
|
||||
#[error("expected whole numbers, like 1000,10_000")]
|
||||
WholeNumber,
|
||||
#[error("expected a port number or host:port")]
|
||||
Attach,
|
||||
}
|
||||
|
||||
fn scale(s: &str) -> Result<f64, ValueError> {
|
||||
s.trim_end_matches('x').parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0).ok_or(ValueError::Scale)
|
||||
}
|
||||
|
||||
fn bleed(s: &str) -> Result<f64, ValueError> {
|
||||
PrintSettings::parse_length(s).filter(|b| *b <= PrintSettings::MAX_BLEED).ok_or(ValueError::Bleed)
|
||||
}
|
||||
|
||||
fn vd_compat(s: &str) -> Result<VdCompat, ValueError> {
|
||||
VdCompat::from_key(s).ok_or(ValueError::VdCompat)
|
||||
}
|
||||
|
||||
/// `--format`, any case; kept as text for the exporter, which also reads it from the extension.
|
||||
fn format(s: &str) -> Result<String, ValueError> {
|
||||
let f = s.to_ascii_lowercase();
|
||||
match f.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" | "fig" | "penpot" => Ok(f),
|
||||
_ => Err(ValueError::Format),
|
||||
}
|
||||
}
|
||||
|
||||
fn whole_number(s: &str) -> Result<usize, ValueError> {
|
||||
s.trim().replace('_', "").parse().map_err(|_| ValueError::WholeNumber)
|
||||
}
|
||||
|
||||
/// `mcp --attach`: `host:port` as given, or a bare port on 127.0.0.1.
|
||||
fn attach(s: &str) -> Result<String, ValueError> {
|
||||
if s.contains(':') {
|
||||
return Ok(s.to_owned());
|
||||
}
|
||||
let port: u16 = s.parse().map_err(|_| ValueError::Attach)?;
|
||||
Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string())
|
||||
}
|
||||
|
||||
/// Parse the arguments, the program name first. clap reads them as `OsString`s, so a non-Unicode
|
||||
/// one is a usage error rather than a panic. A bare `--help` or `-h` before `--` is help even after
|
||||
/// a mistake on the same line (`mcp --attach nope --help`, `export -o --help`), which clap would
|
||||
/// report first.
|
||||
pub fn parse(args: Vec<OsString>) -> Result<Cli, clap::Error> {
|
||||
let mut cmd = Cli::command();
|
||||
match cmd.try_get_matches_from_mut(&args) {
|
||||
Ok(matches) => Cli::from_arg_matches(&matches),
|
||||
Err(e) => match args.iter().skip(1).take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") {
|
||||
Some(flag) => {
|
||||
let help: Vec<OsString> = subcommand_path(&cmd, &args).into_iter().chain([flag.clone()]).collect();
|
||||
Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e))
|
||||
}
|
||||
let name = if let Some(n) = arg.strip_prefix("--") {
|
||||
n
|
||||
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
|
||||
n
|
||||
} else {
|
||||
a.positional.push(arg.clone());
|
||||
continue;
|
||||
};
|
||||
let (name, inline) = match name.split_once('=') {
|
||||
Some((n, v)) => (n, Some(v.to_string())),
|
||||
None => (name, None),
|
||||
};
|
||||
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
|
||||
if inline.is_none() && matches!(name, "help" | "h") {
|
||||
return Err(CliError::Help);
|
||||
}
|
||||
if valued.contains(&name) {
|
||||
match inline.or_else(|| it.next().cloned()) {
|
||||
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
|
||||
None => {
|
||||
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
|
||||
}
|
||||
}
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
}
|
||||
}
|
||||
match bad {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(a),
|
||||
}
|
||||
None => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
self.values.get(k).and_then(|v| v.last()).cloned()
|
||||
}
|
||||
|
||||
/// Every value of an option that may be given more than once, in order.
|
||||
pub fn values(&self, k: &str) -> &[String] {
|
||||
self.values.get(k).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn flag(&self, k: &str) -> bool {
|
||||
self.flags.iter().any(|f| f == k)
|
||||
}
|
||||
|
||||
pub fn one_positional(&self, usage: &str) -> CliResult<String> {
|
||||
match self.positional.as_slice() {
|
||||
[one] => Ok(one.clone()),
|
||||
_ => Err(CliError::Usage(format!("expected: {usage}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
|
||||
/// `--bleed-marks`, `--cmyk`.
|
||||
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
|
||||
let bleed = match self.value("bleed") {
|
||||
None => 0.0,
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
|
||||
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
|
||||
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
|
||||
};
|
||||
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
|
||||
}
|
||||
|
||||
/// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
|
||||
pub fn vd_compat(&self) -> CliResult<figcraft_doc::VdCompat> {
|
||||
match self.value("vd-compat") {
|
||||
None => Ok(figcraft_doc::VdCompat::default()),
|
||||
Some(s) => figcraft_doc::VdCompat::from_key(&s)
|
||||
.ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> CliResult<f64> {
|
||||
match self.value("scale") {
|
||||
None => Ok(1.0),
|
||||
Some(s) => s
|
||||
.trim_end_matches('x')
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0)
|
||||
.ok_or_else(|| CliError::Usage(format!("--scale must be a number in (0, 64], got `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
|
||||
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
|
||||
match self.value(k) {
|
||||
None => Ok(default.to_vec()),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
.map(|v| v.trim().replace('_', "").parse::<usize>().map_err(|_| CliError::Usage(format!("--{k} must be whole numbers, got `{s}`"))))
|
||||
.collect(),
|
||||
}
|
||||
/// The program name and the subcommands `args` names (`figcraft-cli plugin publish`): at each
|
||||
/// level, the first argument that isn't a flag, while it names a subcommand.
|
||||
fn subcommand_path(cmd: &clap::Command, args: &[OsString]) -> Vec<OsString> {
|
||||
let mut path: Vec<OsString> = args.first().cloned().into_iter().collect();
|
||||
let mut at = cmd;
|
||||
let mut rest = args.iter().skip(1).filter(|a| !a.to_string_lossy().starts_with('-'));
|
||||
while let Some(sub) = rest.next().and_then(|a| a.to_str()).and_then(|name| at.find_subcommand(name)) {
|
||||
path.push(sub.get_name().into());
|
||||
at = sub;
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
use clap::error::ErrorKind;
|
||||
|
||||
fn v(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|x| x.to_string()).collect()
|
||||
use super::{Cli, Command, PluginCommand, parse};
|
||||
|
||||
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
|
||||
parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect())
|
||||
}
|
||||
|
||||
fn ok(args: &[&str]) -> Command {
|
||||
match try_parse(args) {
|
||||
Ok(cli) => cli.command,
|
||||
Err(e) => panic!("{args:?}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(args: &[&str]) -> ErrorKind {
|
||||
match try_parse(args) {
|
||||
Ok(_) => panic!("{args:?} parsed"),
|
||||
Err(e) => e.kind(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_definition_is_consistent() {
|
||||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_and_the_roots() {
|
||||
let Command::Info(a) = ok(&["info", "a.figcraft", "--json", "--automation-read-root", "in", "--automation-read-root=more"]) else {
|
||||
panic!("info")
|
||||
};
|
||||
assert_eq!(a.file, "a.figcraft");
|
||||
assert!(a.json);
|
||||
assert_eq!(a.roots.automation_read_root, ["in", "more"]);
|
||||
assert!(a.roots.automation_write_root.is_empty());
|
||||
let Command::Info(a) = ok(&["info", "--json", "--", "--drawing.figcraft"]) else { panic!("info") };
|
||||
assert_eq!(a.file, "--drawing.figcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_takes_every_documented_option() {
|
||||
let Command::Export(a) = ok(&[
|
||||
"export",
|
||||
"app.figcraft",
|
||||
"-o",
|
||||
"poster.pdf",
|
||||
"--format=PDF",
|
||||
"--scale",
|
||||
"2x",
|
||||
"--page",
|
||||
"Screens",
|
||||
"--node",
|
||||
"Poster",
|
||||
"--bleed",
|
||||
"3mm",
|
||||
"--crop-marks",
|
||||
"--bleed-marks",
|
||||
"--cmyk",
|
||||
"--vd-compat",
|
||||
"exact",
|
||||
]) else {
|
||||
panic!("export")
|
||||
};
|
||||
assert_eq!((a.file.as_str(), a.out.as_str(), a.format.as_deref()), ("app.figcraft", "poster.pdf", Some("pdf")));
|
||||
assert_eq!((a.scale.scale, a.page.as_deref(), a.node.as_deref()), (2.0, Some("Screens"), Some("Poster")));
|
||||
let print = a.print();
|
||||
assert!((print.bleed - 3.0 * 72.0 / 25.4).abs() < 1e-9, "{}", print.bleed);
|
||||
assert!(print.crop_marks && print.bleed_marks && print.cmyk);
|
||||
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::Exact);
|
||||
// The defaults, and `--out` for `-o`.
|
||||
let Command::Export(a) = ok(&["export", "app.figcraft", "--out=exports/"]) else { panic!("export") };
|
||||
assert_eq!((a.out.as_str(), a.format.as_deref(), a.scale.scale, a.print().bleed), ("exports/", None, 1.0, 0.0));
|
||||
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_render_run_commands_perf() {
|
||||
let Command::Convert(a) = ok(&["convert", "in.svg", "out.figcraft"]) else { panic!("convert") };
|
||||
assert_eq!((a.input.as_str(), a.output.as_str()), ("in.svg", "out.figcraft"));
|
||||
let Command::Render(a) = ok(&["render", "doc.figcraft", "--out", "doc.png", "--scale=0.5", "--page", "P"]) else { panic!("render") };
|
||||
assert_eq!((a.out.as_str(), a.scale.scale, a.page.as_deref()), ("doc.png", 0.5, Some("P")));
|
||||
let Command::Run(a) = ok(&["run", "s.jsonl", "--in", "a.figcraft", "--out", "b.figcraft", "--automation-write-root", "."]) else {
|
||||
panic!("run")
|
||||
};
|
||||
assert_eq!((a.script.as_str(), a.input.as_deref(), a.out.as_deref()), ("s.jsonl", Some("a.figcraft"), Some("b.figcraft")));
|
||||
assert_eq!(a.roots.automation_write_root, ["."]);
|
||||
let Command::Commands(a) = ok(&["commands", "--json", "--filter=--help"]) else { panic!("commands") };
|
||||
assert!(a.json);
|
||||
assert_eq!(a.filter.as_deref(), Some("--help"));
|
||||
let Command::Perf(a) = ok(&["perf"]) else { panic!("perf") };
|
||||
assert_eq!((a.layers, a.instances), (vec![1000, 10_000], vec![2000]));
|
||||
let Command::Perf(a) = ok(&["perf", "--layers", "1000,10_000, 50000", "--instances=0"]) else { panic!("perf") };
|
||||
assert_eq!((a.layers, a.instances), (vec![1000, 10_000, 50_000], vec![0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_and_plugin_publish() {
|
||||
let Command::Mcp(a) = ok(&["mcp"]) else { panic!("mcp") };
|
||||
assert_eq!(a.attach, None);
|
||||
let Command::Mcp(a) = ok(&["mcp", "--attach", "7979"]) else { panic!("mcp") };
|
||||
assert_eq!(a.attach.as_deref(), Some("127.0.0.1:7979"));
|
||||
let Command::Mcp(a) = ok(&["mcp", "--attach=localhost:7979"]) else { panic!("mcp") };
|
||||
assert_eq!(a.attach.as_deref(), Some("localhost:7979"));
|
||||
let Command::Mcp(a) = ok(&["mcp", "--automation-read-root", "in", "--automation-write-root", "out"]) else { panic!("mcp") };
|
||||
assert_eq!((a.roots.automation_read_root, a.roots.automation_write_root), (vec!["in".to_owned()], vec!["out".to_owned()]));
|
||||
let Command::Plugin { command: PluginCommand::Publish(a) } = ok(&["plugin", "publish", "dist/counter", "--server", "ws://h:47900/"]) else {
|
||||
panic!("plugin")
|
||||
};
|
||||
assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_flag_goes_before_or_after_the_subcommand() {
|
||||
for args in [&["--no-system-fonts", "commands"][..], &["commands", "--no-system-fonts"]] {
|
||||
assert!(try_parse(args).is_ok_and(|cli| cli.no_system_fonts), "{args:?}");
|
||||
}
|
||||
assert!(try_parse(&["commands"]).is_ok_and(|cli| !cli.no_system_fonts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_are_usage_errors() {
|
||||
use ErrorKind::{
|
||||
ArgumentConflict, DisplayHelpOnMissingArgumentOrSubcommand, InvalidSubcommand, InvalidValue, MissingRequiredArgument, TooManyValues,
|
||||
UnknownArgument, ValueValidation,
|
||||
};
|
||||
let cases: &[(&[&str], ErrorKind)] = &[
|
||||
(&[], DisplayHelpOnMissingArgumentOrSubcommand),
|
||||
(&["bogus"], InvalidSubcommand),
|
||||
(&["version"], InvalidSubcommand),
|
||||
(&["export", "x.figcraft"], MissingRequiredArgument),
|
||||
(&["info", "--nope", "a"], UnknownArgument),
|
||||
(&["info", "a", "b"], UnknownArgument),
|
||||
(&["info", "--json=1", "a"], TooManyValues),
|
||||
(&["render", "a", "-o"], InvalidValue),
|
||||
(&["export", "a", "-o", "b", "--scale", "0"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--scale", "-3"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--scale", "65"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--bleed", "lots"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--bleed", "3in"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--vd-compat", "sloppy"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--format", "bmp"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--page", "A", "--page", "B"], ArgumentConflict),
|
||||
(&["perf", "--shapes", "lots"], UnknownArgument),
|
||||
(&["perf", "--layers", "10,x"], ValueValidation),
|
||||
(&["perf", "--layers", "10", "--layers", "20"], ArgumentConflict),
|
||||
(&["mcp", "--attach", "notaport"], ValueValidation),
|
||||
(&["mcp", "--attach", "7979", "--automation-read-root", "."], ArgumentConflict),
|
||||
(&["mcp", "--automation-write-root"], InvalidValue),
|
||||
(&["mcp", "stray"], UnknownArgument),
|
||||
(&["convert", "a"], MissingRequiredArgument),
|
||||
(&["plugin", "publish", "dist"], MissingRequiredArgument),
|
||||
(&["commands", "--filter", "--json"], InvalidValue),
|
||||
];
|
||||
for (args, want) in cases {
|
||||
assert_eq!(kind(args), *want, "{args:?}");
|
||||
assert_eq!(try_parse(args).err().map(|e| e.exit_code()), Some(2), "{args:?}");
|
||||
}
|
||||
assert_eq!(try_parse(&["plugin"]).err().map(|e| e.exit_code()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_version_exit_0() {
|
||||
assert_eq!(kind(&["--version"]), ErrorKind::DisplayVersion);
|
||||
assert_eq!(kind(&["-V"]), ErrorKind::DisplayVersion);
|
||||
for args in [
|
||||
&["--help"][..],
|
||||
&["-h"],
|
||||
&["help"],
|
||||
&["help", "export"],
|
||||
&["export", "x.figcraft", "-o", "out", "--help"],
|
||||
&["export", "-o", "--help"],
|
||||
&["mcp", "--attach", "notaport", "--help"],
|
||||
&["mcp", "--bogus", "-h"],
|
||||
&["--no-system-fonts", "plugin", "publish", "--bogus", "--help"],
|
||||
] {
|
||||
let e = try_parse(args).err();
|
||||
assert_eq!(e.as_ref().map(clap::Error::kind), Some(ErrorKind::DisplayHelp), "{args:?}");
|
||||
assert_eq!(e.map(|e| e.exit_code()), Some(0), "{args:?}");
|
||||
}
|
||||
// The help is the subcommand's, even after a mistake.
|
||||
let help = try_parse(&["plugin", "publish", "--bogus", "--help"]).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(help.contains("Usage: figcraft-cli plugin publish"), "{help}");
|
||||
// After `--`, `-h` is a file name.
|
||||
let Command::Info(a) = ok(&["info", "--", "-h"]) else { panic!("info") };
|
||||
assert_eq!(a.file, "-h");
|
||||
}
|
||||
|
||||
/// Every subcommand's help renders and documents each of its options.
|
||||
#[test]
|
||||
fn parses_options_and_flags() {
|
||||
let a = Args::parse(&v(&["in.figcraft", "-o", "out", "--scale=2", "--json"]), &["o", "scale"], &["json"]).unwrap();
|
||||
assert_eq!(a.positional, ["in.figcraft"]);
|
||||
assert_eq!(a.value("o").as_deref(), Some("out"));
|
||||
assert_eq!(a.scale().unwrap(), 2.0);
|
||||
assert!(a.flag("json"));
|
||||
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
|
||||
assert_eq!(a.values("root"), ["a", "b"]);
|
||||
assert_eq!(a.value("root").as_deref(), Some("b"));
|
||||
assert!(a.values("none").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert!(matches!(Args::parse(&v(&["--nope"]), &[], &[]), Err(CliError::Usage(_))));
|
||||
assert!(matches!(Args::parse(&v(&["-o"]), &["o"], &[]), Err(CliError::Usage(_))));
|
||||
assert!(matches!(Args::parse(&v(&["--json=1"]), &[], &["json"]), Err(CliError::Usage(_))));
|
||||
let a = Args::parse(&v(&["--scale", "abc"]), &["scale"], &[]).unwrap();
|
||||
assert!(a.scale().is_err());
|
||||
let a = Args::parse(&v(&["--scale", "-3"]), &["scale"], &[]).unwrap();
|
||||
assert!(a.scale().is_err());
|
||||
// Negative numbers are positionals, not options.
|
||||
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
|
||||
assert_eq!(a.positional, ["-5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_asked_for_anywhere_but_as_a_value() {
|
||||
// Also after a bad option: help wins over the usage error (#1161 review).
|
||||
for argv in
|
||||
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
|
||||
{
|
||||
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
|
||||
fn every_subcommand_has_help() {
|
||||
let mut cli = Cli::command();
|
||||
cli.build();
|
||||
let mut todo = vec![cli];
|
||||
while let Some(mut cmd) = todo.pop() {
|
||||
let help = cmd.render_long_help().to_string();
|
||||
assert!(help.contains("Usage: figcraft-cli"), "{}: {help}", cmd.get_name());
|
||||
for arg in cmd.get_arguments().filter(|a| !matches!(a.get_long(), Some("help" | "version"))) {
|
||||
assert!(arg.get_help().is_some(), "{} {}", cmd.get_name(), arg.get_id());
|
||||
}
|
||||
todo.extend(cmd.get_subcommands().filter(|s| s.get_name() != "help").cloned());
|
||||
}
|
||||
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
|
||||
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
|
||||
// `--help=x` is no request for help, just an unknown option.
|
||||
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
|
||||
}
|
||||
}
|
||||
|
||||
+42
-168
@@ -10,77 +10,38 @@ mod perf;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use args::Args;
|
||||
use args::{Cli, Command, PluginCommand, RootArgs};
|
||||
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|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
|
||||
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>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
|
||||
|
||||
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .sketch (Sketch 48 or newer, best effort), .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 (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x archive); 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.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
|
||||
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
|
||||
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
|
||||
confined by the app it attaches to, not by flags passed to the CLI: start that app with
|
||||
`figcraft --control PORT` and the same roots (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
fn roots_of(r: &RootArgs) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
if r.automation_read_root.is_empty() && r.automation_write_root.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(&r.automation_read_root, &r.automation_write_root).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
/// Runs `f` confined to the roots `r` carries.
|
||||
fn confined<R>(r: &RootArgs, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(r)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
Ok(code) => code,
|
||||
Err(CliError::Help) => {
|
||||
println!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
// `args_os`: a non-Unicode argument is clap's usage error, not a panic.
|
||||
let cli = match args::parse(std::env::args_os().collect()) {
|
||||
Ok(cli) => cli,
|
||||
Err(e) => {
|
||||
// Help and `--version` on stdout with 0, usage errors on stderr with 2.
|
||||
let _ = e.print();
|
||||
return ExitCode::from(u8::try_from(e.exit_code()).unwrap_or(2));
|
||||
}
|
||||
};
|
||||
match run(cli) {
|
||||
Ok(code) => code,
|
||||
Err(CliError::Usage(msg)) => {
|
||||
eprintln!("error: {msg}\n\n{USAGE}");
|
||||
eprintln!("error: {msg}\n\nFor more information, try '--help'.");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
Err(CliError::Failed(msg)) => {
|
||||
@@ -92,9 +53,7 @@ fn main() -> ExitCode {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CliError {
|
||||
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
|
||||
Help,
|
||||
/// Bad arguments: usage is printed, exit code 2.
|
||||
/// Bad arguments found after parsing (an output extension no format writes): exit code 2.
|
||||
Usage(String),
|
||||
/// The operation failed: exit code 1.
|
||||
Failed(String),
|
||||
@@ -108,10 +67,8 @@ impl From<String> for CliError {
|
||||
|
||||
pub type CliResult<T> = Result<T, CliError>;
|
||||
|
||||
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
|
||||
let flags = [flags, &["no-system-fonts"]].concat();
|
||||
let args = Args::parse(argv, valued, &flags)?;
|
||||
if !args.flag("no-system-fonts") {
|
||||
fn run(cli: Cli) -> CliResult<ExitCode> {
|
||||
if !cli.no_system_fonts {
|
||||
// Text lays out with the installed fonts, like the desktop app, so headless renders and
|
||||
// auto layout sizes match what users see.
|
||||
figcraft_text::scan_system_fonts_on_demand();
|
||||
@@ -119,106 +76,28 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Arg
|
||||
// Nothing scans them then, not even a `.sketch` import matching its fonts.
|
||||
figcraft_text::opt_out_of_system_fonts();
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
|
||||
return Err(CliError::Usage("missing subcommand".into()));
|
||||
};
|
||||
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
|
||||
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
|
||||
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
|
||||
match cmd.as_str() {
|
||||
"help" | "--help" | "-h" => {
|
||||
println!("{USAGE}");
|
||||
match cli.command {
|
||||
Command::Info(a) => confined(&a.roots, || files::info(&a.file, a.json)),
|
||||
Command::Export(a) => {
|
||||
let target =
|
||||
files::Target { print: a.print(), vd_compat: a.vd_compat, scale: a.scale.scale, format: a.format, page: a.page, node: a.node };
|
||||
confined(&a.roots, || files::export(&a.file, &a.out, &target))
|
||||
}
|
||||
Command::Convert(a) => confined(&a.roots, || files::convert(&a.input, &a.output)),
|
||||
Command::Render(a) => confined(&a.roots, || files::render(&a.file, &a.out, a.scale.scale, a.page.as_deref())),
|
||||
Command::Run(a) => confined(&a.roots, || files::run_script(&a.script, a.input.as_deref(), a.out.as_deref())),
|
||||
Command::Commands(a) => {
|
||||
commands(a.json, a.filter.as_deref());
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"--version" | "-V" | "version" => {
|
||||
println!("figcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(ExitCode::SUCCESS)
|
||||
// `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them.
|
||||
Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?),
|
||||
Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server),
|
||||
Command::Perf(a) => {
|
||||
let sizes: Vec<usize> = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
||||
let instances: Vec<usize> = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
|
||||
perf::run(&sizes, &instances, !cli.no_system_fonts)
|
||||
}
|
||||
"info" => {
|
||||
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
|
||||
let file = a.one_positional("info <file>")?;
|
||||
confined(&a, || files::info(&file, a.flag("json")))
|
||||
}
|
||||
"export" => {
|
||||
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &["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()?,
|
||||
vd_compat: a.vd_compat()?,
|
||||
};
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
}
|
||||
"convert" => {
|
||||
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[i, o] => confined(&a, || files::convert(i, o)),
|
||||
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
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()))?;
|
||||
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
|
||||
}
|
||||
"run" => {
|
||||
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
let script = a.one_positional("run <script.json|.jsonl>")?;
|
||||
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
|
||||
}
|
||||
"commands" => {
|
||||
let a = parse_args(&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 valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = parse_args(&rest, &["server"], &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[verb, folder] if verb == "publish" => {
|
||||
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
|
||||
publish_plugin(folder, &server)
|
||||
}
|
||||
_ => Err(CliError::Usage("expected: plugin publish <folder> --server ws://host:port/".into())),
|
||||
}
|
||||
}
|
||||
"perf" => {
|
||||
let a = parse_args(&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, !a.flag("no-system-fonts"))
|
||||
}
|
||||
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,13 +149,8 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
|
||||
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> 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)
|
||||
Some(addr) => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -535,17 +535,6 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn layer_sizes_parse_as_a_list() {
|
||||
use crate::args::Args;
|
||||
let argv = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
|
||||
let a = Args::parse(&argv(&["--layers", "1000,10_000, 50000"]), &["layers"], &[]).unwrap();
|
||||
assert_eq!(a.usizes("layers", &[1]).unwrap(), vec![1000, 10_000, 50_000]);
|
||||
assert_eq!(Args::parse(&[], &["layers"], &[]).unwrap().usizes("layers", &[7]).unwrap(), vec![7]);
|
||||
let bad = Args::parse(&argv(&["--layers", "10,x"]), &["layers"], &[]).unwrap();
|
||||
assert!(bad.usizes("layers", &[1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_components_at_a_small_scale() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
|
||||
@@ -65,8 +65,9 @@ fn option_terminator_allows_dash_prefixed_filenames() {
|
||||
|
||||
#[test]
|
||||
fn global_font_flag_respects_option_values() {
|
||||
for filter in ["--", "--no-system-fonts"] {
|
||||
let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap();
|
||||
// A value starting with `-` is written with `=` (RFC-0012).
|
||||
for filter in ["--filter=--", "--filter=--no-system-fonts"] {
|
||||
let output = bin().args(["commands", filter, "--no-system-fonts", "--json"]).output().unwrap();
|
||||
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
|
||||
}
|
||||
@@ -98,15 +99,15 @@ fn bad_arguments_exit_2_without_panicking() {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("USAGE"), "{args:?}");
|
||||
assert!(err.contains("Usage: figcraft-cli") || err.contains("try '--help'"), "{args:?}: {err}");
|
||||
assert!(!err.contains("panicked"));
|
||||
}
|
||||
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
}
|
||||
|
||||
/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first
|
||||
/// (#1155), whatever else is on the line; nothing runs.
|
||||
/// `--help` / `-h` after a subcommand prints that subcommand's help on stdout and exits 0, as it
|
||||
/// does first (#1155), whatever else is on the line; nothing runs.
|
||||
#[test]
|
||||
fn help_after_a_subcommand_prints_the_usage() {
|
||||
for args in [
|
||||
@@ -130,13 +131,13 @@ fn help_after_a_subcommand_prints_the_usage() {
|
||||
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}");
|
||||
assert!(text.contains("Usage: figcraft-cli"), "{args:?}: {text}");
|
||||
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
// A value that happens to be `-h` is still a value, not a request for help.
|
||||
let out = bin().args(["commands", "--filter", "--help"]).output().unwrap();
|
||||
// A value that happens to be `--help` is still a value, not a request for help.
|
||||
let out = bin().args(["commands", "--filter=--help"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text");
|
||||
assert!(!String::from_utf8_lossy(&out.stdout).contains("Usage:"), "--filter took `--help` as its text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+25
-6
@@ -4,13 +4,32 @@ FigCraft without a window: inspect, export, convert and render documents, run co
|
||||
commands, serve MCP and measure performance. Build it with
|
||||
`cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`).
|
||||
|
||||
`figcraft-cli --help` (or `-h`, or `help`) prints usage on stdout and exits with 0, and so does
|
||||
`--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit
|
||||
with code 2; failures exit with 1. The CLI never panics on input.
|
||||
The CLI follows [RFC-0012](https://github.com/storytold/craft-contrib/blob/main/rfcs/0012-cli-conventions.md),
|
||||
the command-line conventions the Craft apps share, in how it parses its arguments (with clap). The
|
||||
rules it doesn't follow yet are listed below. `figcraft-cli --help` is the source of truth for
|
||||
the flags; this page explains what they do.
|
||||
|
||||
Put options before `--` to pass a filename beginning with a dash, for example
|
||||
`figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is literal,
|
||||
including a filename named `--no-system-fonts`.
|
||||
`figcraft-cli --help` (or `-h`, `help`, `help <subcommand>`) prints help on stdout and exits with
|
||||
0. So does `--help` or `-h` after a subcommand (`figcraft-cli mcp --help`), even after a mistake on
|
||||
the same line. `--version` (or `-V`) prints the version. Bad arguments are usage errors, caught
|
||||
before any work starts: an option the subcommand doesn't take, an option given twice (only the
|
||||
automation roots repeat), a missing or bad value (`--scale 0`, `--format bmp`, `--attach nope`)
|
||||
and `mcp --attach` with automation roots. They print `error: …` on stderr and exit with code 2.
|
||||
Failures exit with 1. The CLI never panics on input.
|
||||
|
||||
A value that starts with `-` is written with `=`, for example `--filter=--json`, so an option never
|
||||
takes the next option as its value. Put options before `--` to pass a filename beginning with a
|
||||
dash, for example `figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is
|
||||
literal, including a filename named `--no-system-fonts`.
|
||||
|
||||
Not yet as RFC-0012 asks:
|
||||
- Arguments that name files are read as text, so a file name that isn't valid Unicode is a usage
|
||||
error (exit 2) rather than reaching the command.
|
||||
- Failures are messages rather than error enums, and `main` isn't yet a `run(args, out, err)`
|
||||
function the tests call in-process.
|
||||
- `FIGCRAFT_NO_SYSTEM_FONTS` and `FIGCRAFT_SERVER_KEY` are read where they're used, not declared
|
||||
on a flag, so `--help` names them only in the flag descriptions.
|
||||
- There are no generated shell completions or man pages, and no ast-grep checks in CI yet.
|
||||
|
||||
## Fonts
|
||||
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user