diff --git a/ROADMAP.md b/ROADMAP.md index 4c5c6512..c14d1f37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log for 2026-10-12; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; before that: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -228,6 +229,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) | +| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs new file mode 100644 index 00000000..4245172a --- /dev/null +++ b/apps/deckcraft-cli/src/help.rs @@ -0,0 +1,153 @@ +pub struct Subcommand { + pub name: &'static str, + pub usage: &'static str, + pub summary: &'static str, + pub details: &'static str, +} + +pub const SUBCOMMANDS: &[Subcommand] = &[ + Subcommand { + name: "info", + usage: "info FILE", + summary: "print a presentation's slides, layouts and theme as JSON", + details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes, +notes, transition), its layouts, theme fonts, sections and slide size. + +The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command: + + deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'", + }, + Subcommand { + name: "render", + usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT", + summary: "draw slides as PNG images", + details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first; +OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per +slide. Prints each image's path and size. + + --slide N the slide at index N; indices start at 0, and 0 is the default + --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 + --edit draw what the editor shows: placeholder prompts and their dashed outlines + + deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", + }, + Subcommand { + name: "convert", + usage: "convert IN OUT", + summary: "save a presentation in the format OUT's extension names", + details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names: +.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide. Any other +extension is written as .deckcraft. Prints OUT. + + deckcraft-cli convert deck.deckcraft deck.pptx", + }, + Subcommand { + name: "run", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT] [--print]", + summary: "run commands on a presentation, then save or export it", + details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample, +else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps +everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the +current slide as .png or .jpg, or the presentation as .pdf or .pptx. + +Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one +JSON object. Quote it for the shell. A command that needs no parameters is just its id. + + deckcraft-cli run \\ + --cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\ + --cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\ + --cmd 'format.size={\"size\":28}' \\ + --save q3.deckcraft + +`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its +parameters. + +What the ids don't say: +- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is + [x, y, width, height]. +- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current; + slide.go {\"index\": N} makes slide N current. +- Shape ids are looked up on the current slide only. +- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts + on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands + take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on. + +If a command fails, run stops there and saves nothing. --print shows each command's answer, such as +the id of an inserted shape.", + }, + Subcommand { + name: "commands", + usage: "commands [FILTER]", + summary: "list the commands run can use, one per line", + details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in +the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case. + + deckcraft-cli commands format. + +In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what +the command answers. `deckcraft-cli describe COMMAND` spells one command out.", + }, + Subcommand { + name: "describe", + usage: "describe COMMAND", + summary: "explain one command and its parameters", + details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run +calls it. + + deckcraft-cli describe shape.insert", + }, + Subcommand { + name: "app", + usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]", + summary: "run a command in the DeckCraft app started with --control", + details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default) +and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD +[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object. + + deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}' + deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'", + }, + Subcommand { + name: "mcp", + usage: "mcp [--connect PORT] [--sample]", + summary: "serve the commands to an agent over MCP on stdio", + details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a +window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT. +--sample opens the sample presentation.", + }, + Subcommand { + name: "links", + usage: "links", + summary: "print the DeckCraft community and project links", + details: "Prints the DeckCraft community and project links.", + }, + Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." }, +]; + +pub fn page(name: &str) -> Option { + let name = match name { + "help" | "--help" | "-h" => return Some(overview()), + "--version" | "-V" => "version", + name => name, + }; + SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details)) +} + +pub fn overview() -> String { + let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default(); + let list: Vec = + SUBCOMMANDS.iter().map(|s| format!(" {: ExitCode { let args: Vec = std::env::args().skip(1).collect(); let rest = args.get(1..).unwrap_or(&[]); + let asks_help = rest.iter().any(|a| a == "--help" || a == "-h"); + if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) { + outln!("{page}"); + return ExitCode::SUCCESS; + } match args.first().map(String::as_str) { + Some("help" | "--help" | "-h") => match rest.first() { + None => { + outln!("{}", help::overview()); + ExitCode::SUCCESS + } + Some(name) => match help::page(name) { + Some(page) => { + outln!("{page}"); + ExitCode::SUCCESS + } + None => unknown(name), + }, + }, Some("--version" | "-V" | "version") => { outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS @@ -62,24 +54,17 @@ fn main() -> ExitCode { Some("render-transition") => report(render_transition(rest)), Some("convert") => report(convert(rest)), Some("run") => report(run(rest)), - Some("--help" | "-h" | "help") => { - outln!("{USAGE}"); - ExitCode::SUCCESS - } - Some("commands") if rest.first().is_some_and(|a| is_help(a)) => { - outln!("{USAGE}"); - ExitCode::SUCCESS - } Some("commands") => { - let s = Session::new(); let f = rest.first().map(|x| x.to_lowercase()); - let list: Vec = s - .commands() - .into_iter() + for c in deckcraft_engine::command_specs() + .iter() .filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str()))) - .map(|c| serde_json::to_value(c).unwrap_or_default()) - .collect(); - outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + { + match c.params.strip_prefix("{}") { + Some(answer) => outln!("{} — {}{answer}", c.id, c.label), + None => outln!("{} — {}: {}", c.id, c.label, c.params), + } + } ExitCode::SUCCESS } Some("describe") => report(describe(rest.first().map(String::as_str))), @@ -90,11 +75,8 @@ fn main() -> ExitCode { outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}"); ExitCode::SUCCESS } - _ => { - eprintln!("{USAGE}"); - eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); - ExitCode::FAILURE - } + Some(other) => unknown(other), + None => usage(), } } @@ -102,6 +84,22 @@ fn is_help(arg: &str) -> bool { matches!(arg, "--help" | "-h") } +/// The help page of subcommand `name`, else the overview. +fn help_page(name: &str) -> String { + help::page(name).unwrap_or_else(help::overview) +} + +fn unknown(sub: &str) -> ExitCode { + eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n"); + usage() +} + +fn usage() -> ExitCode { + eprintln!("{}", help::overview()); + eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); + ExitCode::FAILURE +} + fn report(r: Result<(), String>) -> ExitCode { match r { Ok(()) => ExitCode::SUCCESS, @@ -226,7 +224,7 @@ fn bench(args: &[String]) -> Result<(), String> { fn info(args: &[String]) -> Result<(), String> { if args.first().is_some_and(|a| is_help(a)) { - outln!("{USAGE}"); + outln!("{}", help_page("info")); return Ok(()); } let path = args.first().ok_or("info needs a FILE")?; @@ -254,7 +252,7 @@ fn render(args: &[String]) -> Result<(), String> { "--edit" => edit = true, "--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("render")); return Ok(()); } other if other.starts_with('-') => return Err(format!("unknown option `{other}`")), @@ -391,7 +389,7 @@ fn render_transition(args: &[String]) -> Result<(), String> { fn convert(args: &[String]) -> Result<(), String> { if args.first().is_some_and(|a| is_help(a)) { - outln!("{USAGE}"); + outln!("{}", help_page("convert")); return Ok(()); } let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; @@ -430,12 +428,12 @@ fn run(args: &[String]) -> Result<(), String> { match a.as_str() { "--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?), "--sample" => sample = true, - "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?), + "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?), "--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?), "--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?), "--print" => print = true, "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("run")); return Ok(()); } other => return Err(format!("unknown option `{other}`")), @@ -467,15 +465,66 @@ fn run(args: &[String]) -> Result<(), String> { fn describe(id: Option<&str>) -> Result<(), String> { if id.is_some_and(is_help) { - outln!("{USAGE}"); + outln!("{}", help_page("describe")); return Ok(()); } let id = id.ok_or("describe needs a command id")?; - let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?; - outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params); + let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?; + let mut head = format!("{}: {}", c.id, c.label); + if !c.menu.is_empty() { + head.push_str(&format!(", in {}", c.menu.join(" ▸ "))); + } + if let Some(k) = c.shortcut { + head.push_str(&format!(", shortcut {k}")); + } + outln!("{head}.\n"); + let (args, answer) = c.params.split_once('→').unwrap_or((c.params, "")); + let parsed = deckcraft_engine::cmd::parse_params(c.params); + match &parsed { + Err(_) => outln!("Parameters: {}", args.trim()), + Ok(params) if params.is_empty() => outln!("Takes no parameters."), + Ok(params) => print_params(params), + } + let params = parsed.unwrap_or_default(); + if let Some(acts) = c.acts_on() { + outln!("{acts}"); + } + let remark = remark(args); + if !remark.is_empty() { + outln!("Note: {remark}"); + } + if !answer.trim().is_empty() { + outln!("Answers with {}.", answer.trim()); + } + let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional)); + let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) }; + outln!("\nRun it with: deckcraft-cli run --cmd {call}"); Ok(()) } +fn print_params(params: &[deckcraft_engine::cmd::Param]) { + let name = |p: &deckcraft_engine::cmd::Param| { + let or = if p.or { "or " } else { "" }; + let optional = if p.optional { ", optional" } else { "" }; + format!("{or}{}{optional}", p.name) + }; + let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default(); + let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0; + outln!("Parameters:"); + for (k, p) in params.iter().enumerate() { + if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) { + outln!(" {}", if p.object == 0 { "either:" } else { "or:" }); + } + let indent = if sets { " " } else { " " }; + outln!("{}", format!("{indent}{: &str { + let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim(); + after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after) +} + fn app(args: &[String]) -> Result<(), String> { use deckcraft_mcp::{Backend, Remote, control_addr}; let mut port = deckcraft_mcp::DEFAULT_PORT.to_string(); @@ -487,7 +536,7 @@ fn app(args: &[String]) -> Result<(), String> { "--port" => port = it.next().cloned().ok_or("--port needs a value")?, "--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?), "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("app")); return Ok(()); } other => rest.push(other.to_string()), @@ -519,7 +568,7 @@ fn mcp(args: &[String]) -> Result<(), String> { "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?), "--sample" => sample = true, "--help" | "-h" => { - outln!("{USAGE}"); + outln!("{}", help_page("mcp")); return Ok(()); } other => return Err(format!("unknown mcp option `{other}`")), diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs new file mode 100644 index 00000000..3f1ed98b --- /dev/null +++ b/apps/deckcraft-cli/tests/cli.rs @@ -0,0 +1,139 @@ +use std::process::{Command, Output}; + +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() +} + +fn stdout(o: &Output) -> String { + assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr)); + String::from_utf8_lossy(&o.stdout).into_owned() +} + +fn stderr(o: &Output) -> String { + assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout)); + String::from_utf8_lossy(&o.stderr).into_owned() +} + +const SUBCOMMANDS: [&str; 9] = ["info", "render", "convert", "run", "commands", "describe", "app", "mcp", "links"]; + +fn words(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +#[test] +fn an_unknown_subcommand_is_named_before_the_overview() { + for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] { + let err = stderr(&cli(&args)); + assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}"); + } +} + +#[test] +fn no_subcommand_shows_the_overview_and_fails() { + assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on")); +} + +#[test] +fn help_lists_every_subcommand_with_a_run_example() { + for flag in ["help", "--help", "-h"] { + let out = stdout(&cli(&[flag])); + for sub in SUBCOMMANDS { + assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}"); + } + assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}"); + } +} + +#[test] +fn every_subcommand_answers_help_and_does_nothing_else() { + let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for sub in SUBCOMMANDS { + for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] { + let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap(); + assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}"); + } + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing"); + std::fs::remove_dir_all(&dir).unwrap(); + for args in [["help", "version"], ["--version", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}"); + } + for args in [["help", "help"], ["help", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}"); + } +} + +#[test] +fn run_help_says_what_the_ids_do_not() { + let out = words(&stdout(&cli(&["run", "--help"]))); + for fact in [ + "COMMAND is a command id, then `=`, then the command's parameters as one JSON object.", + "points on a 960×540 slide", + "Slide indices start at 0.", + "Shape ids are looked up on the current slide only.", + "A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.", + "format.* acts on the selected text or shapes", + ] { + assert!(out.contains(fact), "{fact}: {out}"); + } +} + +#[test] +fn describe_spells_out_a_command() { + assert_eq!( + stdout(&cli(&["describe", "shape.insert"])), + "shape.insert: Shapes, in Insert ▸ Illustrations. + +Parameters: + preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets) + rect [x,y,w,h] pt + text, optional string + +Run it with: deckcraft-cli run --cmd 'shape.insert={…}' +" + ); + assert_eq!( + stdout(&cli(&["describe", "format.size"])), + "format.size: Font Size, in Home ▸ Font. + +Parameters: + size pt +Acts on the selected text or shapes. + +Run it with: deckcraft-cli run --cmd 'format.size={…}' +" + ); + assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")); + assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n")); + assert_eq!( + stdout(&cli(&["describe", "shape.setDefault"])), + "shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n" + ); +} + +#[test] +fn describe_shows_alternative_sets_of_parameters() { + assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n")); + let remove = stdout(&cli(&["describe", "animation.remove"])); + assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}"); + assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}"); + assert!(stdout(&cli(&["describe", "shape.move"])).contains( + " either: + dx, optional pt (relative) + dy, optional pt (relative) + ids, optional + or: + x, optional pt (absolute) + y, optional pt (absolute) + ids, optional +" + )); +} + +#[test] +fn commands_lists_one_line_per_command() { + assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n"); + assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n"); + assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n"); +} diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 4d969798..c3e27804 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { has_selection, add ), - cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove), + cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove), cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim), cmd!( "animation.timing", diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 86a2642d..626002c1 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { "Slide Size", ["Design", "Customize"], None, - "{preset?: widescreen|standard|… | w, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}", + "{preset: widescreen|standard|…, orientation?: portrait|landscape, scale?: maximize|ensureFit|none} | {w: pt, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}", has_doc, slide_size ), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d7356b23..7f47d742 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3, orientation?: portrait|landscape, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|\"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export), cmd!( query "file.info", "Info", @@ -57,7 +57,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save), cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list), cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open), - cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard), + cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard), cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert), cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window), ] diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index b06753d5..15c20705 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -41,15 +41,7 @@ pub fn specs() -> Vec { cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size), cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow), cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink), - cmd!( - "format.color", - "Font Color", - ["Home", "Font"], - None, - "{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}", - has_text_or_shapes, - color - ), + cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color), cmd!( "format.textTransparency", "Text Transparency", @@ -186,7 +178,7 @@ pub fn specs() -> Vec { "Text Box Margins", ["Format Shape", "Text Box"], None, - "{left?, top?, right?, bottom?: pt}", + "{left?: pt, top?: pt, right?: pt, bottom?: pt}", has_text_or_shapes, margins ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b4770c19..1daf1c87 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -41,6 +41,9 @@ pub struct CommandSpec { /// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu). pub menu: &'static [&'static str], pub shortcut: Option<&'static str>, + /// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks + /// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values + /// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer. pub params: &'static str, pub enabled: Enabled, pub run: Run, @@ -61,6 +64,28 @@ pub struct CommandInfo { } impl CommandSpec { + /// Holds because `Session::execute` skips the selection check when `id` or `ids` is given. + pub fn acts_on(&self) -> Option { + let keys = param_keys(self.params).unwrap_or_default(); + let named = if keys.contains(&"ids") { + Some("the shapes in `ids`") + } else if keys.contains(&"id") { + Some("the shape in `id`") + } else { + None + }; + let reason = (self.enabled)(&Session::with_new()).err()?; + Some(match (reason.as_str(), named) { + (SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(), + (NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."), + (NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(), + (SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."), + (SELECT_TWO, None) => "Acts on two or more selected shapes.".into(), + (NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(), + _ => return None, + }) + } + pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); CommandInfo { @@ -87,6 +112,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> { } pub const NOTHING_SELECTED: &str = "nothing selected"; pub const SELECT_TWO: &str = "select two or more objects"; +pub const NO_TEXT_POINT: &str = "no text insertion point"; +pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape"; pub fn has_selection(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) } @@ -97,17 +124,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> { } pub fn has_text(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) } + if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) } } -/// Text editing or shapes with text selected (formatting commands apply to either). +/// Text editing or selected shapes (formatting commands apply to either). pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { - Ok(()) - } else { - Err("select text or a shape".into()) - } + if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) } } + pub fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } } @@ -164,6 +188,99 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } +pub(crate) fn param_keys(doc: &str) -> std::result::Result, String> { + let mut keys = vec![]; + for p in parse_params(doc)? { + if !keys.contains(&p.name) { + keys.push(p.name); + } + } + Ok(keys) +} + +#[derive(Debug, PartialEq)] +pub struct Param<'a> { + pub name: &'a str, + pub optional: bool, + /// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`). + pub about: &'a str, + /// An alternative to the parameter before it in the same object (`color? | none?`). + pub or: bool, + /// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1. + pub object: usize, +} + +/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset, +/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result +/// and is skipped. Errs where the doc strays from that grammar. +pub fn parse_params(doc: &str) -> std::result::Result>, String> { + let doc = doc.split('→').next().unwrap_or_default(); + let mut params: Vec = vec![]; + let mut open: Vec = vec![]; + let mut expect_key = false; + let mut or = false; + let mut objects: usize = 0; + let mut about_from: Option = None; + let mut chars = doc.char_indices().peekable(); + let mut prev = ' '; + fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option, to: usize) { + if let (Some(f), Some(p)) = (from.take(), params.last_mut()) { + p.about = doc.get(f..to).unwrap_or_default().trim(); + } + } + while let Some((i, c)) = chars.next() { + let in_object = open == ['{']; + match c { + '{' | '[' | '(' => { + open.push(c); + if open == ['{'] { + expect_key = true; + objects += 1; + or = false; + } + } + '}' | ']' | ')' => { + if in_object { + end_about(doc, &mut params, &mut about_from, i); + } + open.pop(); + } + ',' if in_object => { + end_about(doc, &mut params, &mut about_from, i); + expect_key = true; + } + '|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => { + end_about(doc, &mut params, &mut about_from, i); + expect_key = true; + or = true; + } + c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => { + let mut end = i + c.len_utf8(); + while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') { + end = j + n.len_utf8(); + chars.next(); + } + let after = doc.get(end..).unwrap_or_default(); + let rest = after.trim_start_matches('?').trim_start(); + let at = doc.len() - rest.len(); + about_from = match rest.chars().next() { + Some(':') => Some(at + 1), + Some('(') => Some(at), + Some(',' | '}' | '|') => None, + _ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::())), + }; + params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) }); + or = false; + expect_key = false; + } + c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])), + _ => {} + } + prev = c; + } + Ok(params) +} + // ---------- param helpers ---------- pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 2790f488..fa63aca3 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -30,8 +30,16 @@ pub fn specs() -> Vec { freeform ), cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites), - cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), - cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), + cmd!( + "shape.move", + "Move", + [], + None, + "{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}", + has_selection, + move_by + ), + cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds), cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate), cmd!( @@ -95,7 +103,15 @@ pub fn specs() -> Vec { lock ), cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default), - cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop), + cmd!( + "picture.crop", + "Crop", + ["Picture Format", "Size"], + None, + "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}", + has_selection, + crop + ), cmd!( "picture.adjust", "Corrections", diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e401a394..9ef78436 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -27,7 +27,15 @@ pub fn specs() -> Vec { s, p, "delCol" )), cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge), - cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split), + cmd!( + "table.split", + "Split Cells", + ["Table Layout", "Merge"], + None, + "{cell?: [r, c] (else the edited cell), id?} | {row?: index, col?: index, id?}", + has_selection, + split + ), cmd!( "table.style", "Table Styles", @@ -42,7 +50,7 @@ pub fn specs() -> Vec { "Table Style Options", ["Table Design", "Table Style Options"], None, - "{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}", + "{headerRow?: bool, totalRow?: bool, bandedRows?: bool, firstColumn?: bool, lastColumn?: bool, bandedColumns?: bool, id?}", has_selection, options ), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 9c66208b..e03719bd 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -105,7 +105,7 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti /// Edit the active text body and selection; refits auto-sizing text boxes afterwards. pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { - let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?; + let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?; // Materialise an inherited placeholder box before editing (so autofit can change it). let r = s.edit(|doc, sel| { let mut t = t0.clone(); @@ -840,7 +840,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { @@ -930,7 +930,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c4b21063..b53eff14 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2429,3 +2429,55 @@ fn format_language_marks_the_selected_text() { let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect(); assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]); } + +#[test] +fn every_params_doc_names_its_keys() { + for c in command_specs() { + assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params)); + } + assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]); + assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]); +} + +#[test] +fn a_params_doc_spells_out_each_parameter() { + use cmd::Param; + let p = |name, optional, about, or, object| Param { name, optional, about, or, object }; + assert_eq!( + cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(), + [ + p("preset", false, "rect|roundRect", false, 0), + p("rect", true, "[x,y,w,h] pt", false, 0), + p("index", true, "(else selected shapes)", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(), + [ + p("color", true, "", false, 0), + p("none", true, "true", true, 0), + p("gradient", true, "{stops, kind?: a|b}", false, 0), + p("ids", true, "", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{name} | {major, minor}").unwrap(), + [p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)] + ); +} + +#[test] +fn what_a_command_acts_on_names_only_keys_it_takes() { + for c in command_specs() { + let keys = cmd::param_keys(c.params).unwrap(); + let said = c.acts_on().unwrap_or_default(); + assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id); + assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id); + } + let acts = |id: &str| find_command(id).and_then(|c| c.acts_on()); + assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes.")); + assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes.")); + assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes.")); + assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes.")); + assert_eq!(acts("slide.new"), None); +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 576e0b4a..710067cd 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -22,9 +22,10 @@ const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone) current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \ list_commands and run them with run_command (or several with batch). Typical flow: new_presentation → add_slide \ {layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \ -inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shapes are selected with \ -run_command edit.select {ids:[…]}; formatting commands (format.*, shape.fill, shape.line…) act on the selection or \ -on `ids`. Save with save_presentation (.deckcraft or .pptx)."; +inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shape ids are looked up on the \ +current slide. An inserted shape is selected; run_command edit.select {ids:[…]} selects others. format.* acts on \ +the selected text or shapes; shape.fill, shape.line and the other shape commands whose parameters list `ids` act on \ +those shapes, else on the selected ones. Save with save_presentation (.deckcraft or .pptx)."; /// Resource URIs. pub const DOC_URI: &str = "deckcraft://document"; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0b7909de..44733a20 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -12,7 +12,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | Method | Params | What it does | |---|---|---| -| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) | +| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | | `document.inspect` | — | Slides, sections, layouts, shapes, selection | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) | @@ -30,4 +30,4 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude -and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell. +and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell. diff --git a/docs/mcp.md b/docs/mcp.md index 07284e70..9383736c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -35,5 +35,5 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any +`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe COMMAND` prints any command's parameters.