From 6655a1d68d01a1c3ad764c13961a889cb057ff34 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Thu, 22 Oct 2026 16:42:30 +0800 Subject: [PATCH 1/3] fix(cli): accept command files in run (#1098) --- ROADMAP.md | 4 +- apps/deckcraft-cli/src/help.rs | 10 ++- apps/deckcraft-cli/src/main.rs | 50 +++++++++--- apps/deckcraft-cli/tests/cli.rs | 136 ++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..21f9568d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (CLI run accepts UTF-8 command files (#1098); native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **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 @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-22 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | @@ -240,6 +241,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | CLI command-file input (#1098); feature scores and milestone scope unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 4227905f..1d8cdce2 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT. }, Subcommand { name: "run", - usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--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 @@ -71,6 +71,14 @@ slide). --export-params passes file.export options as one JSON object, such as 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. +--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting +and command-line length limits. Blank lines and full-line # comments are ignored, as is surrounding +whitespace. CRLF line endings and an optional UTF-8 BOM at the start of the file are accepted. +Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear. +File errors name the path; line-reading, JSON and command errors also name the line number. + + deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft + deckcraft-cli run \\ --cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\ --cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\ diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index c6276efb..8d69c0bf 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -450,6 +450,41 @@ fn output_format(path: &str) -> Result<&'static str, String> { }) } +enum CommandSource { + Inline(String), + File(String), +} + +/// The same command syntax and execution path for --cmd and each command-file line. +fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> { + let (id, params) = match command.split_once('=') { + Some((id, j)) => (id, serde_json::from_str::(j).map_err(|e| format!("{id}: bad JSON: {e}"))?), + None => (command, json!({})), + }; + let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?; + if print { + outln!("{id} → {r}"); + } + Ok(()) +} + +fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> { + use std::io::{BufRead, BufReader}; + + let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + for (index, line) in BufReader::new(file).lines().enumerate() { + let line_number = index + 1; + let line = line.map_err(|e| format!("{path}:{line_number}: {e}"))?; + let line = if index == 0 { line.trim_start_matches('\u{feff}') } else { &line }; + let command = line.trim(); + if command.is_empty() || command.starts_with('#') { + continue; + } + run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?; + } + Ok(()) +} + fn run(args: &[String]) -> Result<(), String> { let mut input = None; let mut sample = false; @@ -463,7 +498,8 @@ 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 COMMAND[=PARAMS]")?), + "--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)), + "--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)), "--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?), "--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?), "--export-params" => { @@ -486,14 +522,10 @@ fn run(args: &[String]) -> Result<(), String> { } let mut s = Session::new(); open(&mut s, input.as_deref(), sample)?; - for c in cmds { - let (id, p) = match c.split_once('=') { - Some((id, j)) => (id.to_string(), serde_json::from_str::(j).map_err(|e| format!("{id}: bad JSON: {e}"))?), - None => (c.clone(), json!({})), - }; - let r = s.execute(&id, &p).map_err(|e| e.to_string())?; - if print { - outln!("{id} → {r}"); + for source in cmds { + match source { + CommandSource::Inline(command) => run_command(&mut s, &command, print)?, + CommandSource::File(path) => run_command_file(&mut s, &path, print)?, } } if let Some(p) = save { diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs index d04e55c7..dabcfc81 100644 --- a/apps/deckcraft-cli/tests/cli.rs +++ b/apps/deckcraft-cli/tests/cli.rs @@ -20,6 +20,139 @@ fn words(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } +fn command_file_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn inspected_titles(out: &str) -> Vec { + let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap(); + let doc: serde_json::Value = serde_json::from_str(answer).unwrap(); + doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn command_file_runs_utf8_lines_in_order_and_skips_comments() { + let dir = command_file_dir("lines"); + let file = dir.join("commands with spaces.txt"); + std::fs::write( + &file, + "\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}", + ) + .unwrap(); + let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]); + let out = stdout(&o); + let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect(); + assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]); + let titles = inspected_titles(&out); + assert_eq!(titles.len(), 4); + assert_eq!(titles[1], "Résumé = # 测试"); + assert_eq!(titles[3], "Last"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn command_files_and_inline_commands_keep_argument_order() { + let dir = command_file_dir("mixed"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + let saved = dir.join("result.deckcraft"); + std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap(); + std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap(); + let o = cli(&[ + "run", + "--cmd", + "slide.new={\"title\":\"Inline first\"}", + "--cmd-file", + &first.to_string_lossy(), + "--cmd", + "slide.new={\"title\":\"Inline middle\"}", + "--cmd-file", + &second.to_string_lossy(), + "--cmd", + "document.inspect", + "--print", + "--save", + &saved.to_string_lossy(), + ]); + let out = stdout(&o); + let titles = inspected_titles(&out); + assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]); + assert_eq!(out.lines().count(), 5); + let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]); + assert_eq!(inspected_titles(&stdout(&reopened)), titles); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn a_failed_file_command_stops_later_commands_and_final_outputs() { + let dir = command_file_dir("failure"); + let file = dir.join("commands.txt"); + let later = dir.join("later-in-file.deckcraft"); + let inline = dir.join("later-inline.deckcraft"); + let save = dir.join("saved.deckcraft"); + let export = dir.join("exported.png"); + let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()})); + for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] { + let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()})); + std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap(); + std::fs::write(&save, b"keep saved file").unwrap(); + std::fs::write(&export, b"keep exported file").unwrap(); + let o = cli(&[ + "run", + "--print", + "--cmd-file", + &file.to_string_lossy(), + "--cmd", + &inline_command, + "--save", + &save.to_string_lossy(), + "--export", + &export.to_string_lossy(), + ]); + let err = stderr(&o); + assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}"); + assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran"); + assert!(!later.exists() && !inline.exists(), "commands after the failure must not run"); + assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file"); + assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn command_file_read_errors_name_the_path_and_utf8_line() { + let dir = command_file_dir("read-errors"); + let missing = dir.join("missing.txt"); + let invalid = dir.join("invalid.txt"); + let save = dir.join("out.deckcraft"); + std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap(); + for file in [&missing, &dir, &invalid] { + let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]); + let err = stderr(&o); + assert!(err.contains(&file.display().to_string()), "{err}"); + if file == &invalid { + assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}"); + } + assert!(!save.exists()); + } + assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file")); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn inline_commands_keep_their_existing_syntax_and_errors() { + let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]); + let out = stdout(&o); + let titles = inspected_titles(&out); + assert_eq!(titles.len(), 3); + assert_eq!(titles[2], "Inline = #"); + assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:")); + assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`")); +} + #[test] fn an_unknown_subcommand_is_named_before_the_overview() { for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] { @@ -74,6 +207,9 @@ fn run_help_says_what_the_ids_do_not() { "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", + "--cmd-file FILE reads the same commands from a UTF-8 text file, one per line", + "commands execute in the order those options appear.", + "If a command fails, run stops there and saves nothing.", ] { assert!(out.contains(fact), "{fact}: {out}"); } From d0f0d14c996cee8aa2a3d21a8a22742d4b2749dc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:37:34 -0500 Subject: [PATCH 2/3] dc-upkeep-7: cap command-file line reads --- apps/deckcraft-cli/src/main.rs | 38 ++++++++++++++++++++++++++---- apps/deckcraft-cli/tests/it/cli.rs | 14 +++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index 76a4c244..be99ee07 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -455,6 +455,28 @@ enum CommandSource { File(String), } +const MAX_COMMAND_FILE_LINE_BYTES: usize = 1024 * 1024; + +fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec) -> std::io::Result { + line.clear(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(!line.is_empty()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let count = newline.unwrap_or(available.len()); + line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit")) + })?; + line.extend_from_slice(&available[..count]); + reader.consume(count + usize::from(newline.is_some())); + if newline.is_some() { + return Ok(true); + } + } +} + /// The same command syntax and execution path for --cmd and each command-file line. fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> { let (id, params) = match command.split_once('=') { @@ -469,13 +491,19 @@ fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String } fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> { - use std::io::{BufRead, BufReader}; + use std::io::BufReader; let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; - for (index, line) in BufReader::new(file).lines().enumerate() { - let line_number = index + 1; - let line = line.map_err(|e| format!("{path}:{line_number}: {e}"))?; - let line = if index == 0 { line.trim_start_matches('\u{feff}') } else { &line }; + let mut reader = BufReader::new(file); + let mut bytes = Vec::new(); + let mut line_number = 0usize; + loop { + line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?; + if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? { + break; + } + let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?; + let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line }; let command = line.trim(); if command.is_empty() || command.starts_with('#') { continue; diff --git a/apps/deckcraft-cli/tests/it/cli.rs b/apps/deckcraft-cli/tests/it/cli.rs index dabcfc81..fef870a8 100644 --- a/apps/deckcraft-cli/tests/it/cli.rs +++ b/apps/deckcraft-cli/tests/it/cli.rs @@ -142,6 +142,20 @@ fn command_file_read_errors_name_the_path_and_utf8_line() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn command_file_rejects_an_oversized_line_before_running_it() { + let dir = command_file_dir("line-limit"); + let file = dir.join("commands.txt"); + let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(1024 * 1024)); + std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap(); + + let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]); + let err = stderr(&o); + assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 1048576 byte limit"), "{err}"); + assert!(o.stdout.is_empty(), "the oversized command must not run"); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn inline_commands_keep_their_existing_syntax_and_errors() { let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]); From e0c64599cc60a8fc1ff3815bd93b9cf12e3a036a Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:09:02 -0500 Subject: [PATCH 3/3] dc-upkeep-7: allow larger command-file media --- apps/deckcraft-cli/src/help.rs | 5 +++-- apps/deckcraft-cli/src/main.rs | 2 +- apps/deckcraft-cli/tests/it/cli.rs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 1d8cdce2..b77c0478 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -72,8 +72,9 @@ Each --cmd is one command: COMMAND is a command id, then `=`, then the command's JSON object. Quote it for the shell. A command that needs no parameters is just its id. --cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting -and command-line length limits. Blank lines and full-line # comments are ignored, as is surrounding -whitespace. CRLF line endings and an optional UTF-8 BOM at the start of the file are accepted. +and command-line length limits. Each line can be up to 16 MiB, including commands with embedded +media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF +line endings and an optional UTF-8 BOM at the start of the file are accepted. Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear. File errors name the path; line-reading, JSON and command errors also name the line number. diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index be99ee07..2fb9fa8e 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -455,7 +455,7 @@ enum CommandSource { File(String), } -const MAX_COMMAND_FILE_LINE_BYTES: usize = 1024 * 1024; +const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024; fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec) -> std::io::Result { line.clear(); diff --git a/apps/deckcraft-cli/tests/it/cli.rs b/apps/deckcraft-cli/tests/it/cli.rs index fef870a8..a6ec0248 100644 --- a/apps/deckcraft-cli/tests/it/cli.rs +++ b/apps/deckcraft-cli/tests/it/cli.rs @@ -146,12 +146,12 @@ fn command_file_read_errors_name_the_path_and_utf8_line() { fn command_file_rejects_an_oversized_line_before_running_it() { let dir = command_file_dir("line-limit"); let file = dir.join("commands.txt"); - let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(1024 * 1024)); + let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024)); std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap(); let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]); let err = stderr(&o); - assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 1048576 byte limit"), "{err}"); + assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}"); assert!(o.stdout.is_empty(), "the oversized command must not run"); std::fs::remove_dir_all(dir).unwrap(); }