mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M14.1: MCP server + agent CLI
- crates/automation (L5, no egui): hand-rolled JSON-RPC 2.0 MCP server over stdio (initialize, ping, tools/list, tools/call); headless backend (in-process Session) and bridge backend (desktop control channel). Tools: list_commands, execute_command, get_project/comp/layer, get/set_property, add_keyframe, render_frame (inline PNG), open/save_project, undo/redo; bridge-only screenshot, ui_* and raw control passthrough. - effectcraft-cli: info, commands, exec, run, props, get, set, render-frame, mcp [--bridge PORT]; --json, --project/--demo/--empty, --save/--save-as. - engine: prop.get, layer.tree node paths + time, Session::resolve_comp, render_rgba8, execute_checked (rejects unknown params, lists accepted keys). - control channel: render.frame; engine.execute runs every engine command directly (no dialogs, validated params). - docs: control-protocol.md, agents.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
dee16181fe
commit
c672e03160
22 files changed
+2398
-101
No files matched your search
@@ -14,7 +14,6 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
effectcraft-engine = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-host = { workspace = true }
|
||||
effectcraft-automation = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
@@ -1,89 +1,391 @@
|
||||
//! EffectCraft headless CLI.
|
||||
//! EffectCraft headless CLI: one-shot agent commands, frame rendering and the MCP server.
|
||||
//!
|
||||
//! ```text
|
||||
//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png
|
||||
//! effectcraft-cli run [project.ecproj | --demo] '<command-id>' '<json params>' … (prints results)
|
||||
//! effectcraft-cli commands (lists commands)
|
||||
//! effectcraft-cli info project + engine summary
|
||||
//! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands
|
||||
//! effectcraft-cli exec <command-id> [--params JSON] run one command
|
||||
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
|
||||
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png]
|
||||
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
|
||||
//!
|
||||
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
|
||||
//! Saving: --save (back to --project) | --save-as F.ecproj
|
||||
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
|
||||
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
|
||||
//! ```
|
||||
//!
|
||||
//! `<comp>` is a comp id or name (`-` = the active comp); `<layer>` an id, `#n` or name; `<value>`
|
||||
//! is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. See `docs/agents.md`.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
use effectcraft_automation::{Backend, McpServer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn usage() -> ! {
|
||||
eprintln!("usage: effectcraft-cli <frame|run|commands> [args] (see --help in source docs)");
|
||||
std::process::exit(2)
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|mcp> [args] [--json]
|
||||
info project + engine summary
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
exec <command-id> [--params JSON] run one engine command
|
||||
run <id> <json> [<id> <json> ...] run several commands in order
|
||||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png]
|
||||
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
|
||||
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json
|
||||
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
|
||||
|
||||
/// Options that take a value.
|
||||
const VALUED: &[&str] =
|
||||
&["--params", "--project", "--bridge", "--save-as", "--filter", "--time", "--frame", "--comp", "--max-side", "--scale", "--out", "--expression", "--depth"];
|
||||
|
||||
struct Args {
|
||||
pos: Vec<String>,
|
||||
opts: Vec<(String, Option<String>)>,
|
||||
/// `--project F` or a positional `*.ecproj`.
|
||||
project: Option<String>,
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, args: &[String]) {
|
||||
if let Some(p) = args.iter().find(|a| a.ends_with(".ecproj")) {
|
||||
if let Err(e) = s.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("effectcraft: {e}");
|
||||
std::process::exit(1);
|
||||
impl Args {
|
||||
fn parse(raw: Vec<String>) -> Result<Args, String> {
|
||||
let (mut pos, mut opts) = (vec![], vec![]);
|
||||
let mut it = raw.into_iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a.starts_with("--") && a.len() > 2 {
|
||||
let (k, inline) = match a.split_once('=') {
|
||||
Some((k, v)) => (k.to_string(), Some(v.to_string())),
|
||||
None => (a, None),
|
||||
};
|
||||
if VALUED.contains(&k.as_str()) {
|
||||
let v = inline.or_else(|| it.next()).ok_or_else(|| format!("{k} needs a value"))?;
|
||||
opts.push((k, Some(v)));
|
||||
} else {
|
||||
opts.push((k, None));
|
||||
}
|
||||
} else {
|
||||
pos.push(a);
|
||||
}
|
||||
}
|
||||
} else if args.iter().any(|a| a == "--demo") || true {
|
||||
let _ = s.execute("file.openDemoProject", json!({}));
|
||||
let mut a = Args { pos, opts, project: None };
|
||||
a.project = match a.opt("--project") {
|
||||
Some(p) => Some(p.to_string()),
|
||||
None => a.pos.iter().position(|x| x.ends_with(".ecproj")).map(|i| a.pos.remove(i)),
|
||||
};
|
||||
Ok(a)
|
||||
}
|
||||
fn flag(&self, k: &str) -> bool {
|
||||
self.opts.iter().any(|(o, _)| o == k)
|
||||
}
|
||||
fn opt(&self, k: &str) -> Option<&str> {
|
||||
self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref())
|
||||
}
|
||||
fn num(&self, k: &str) -> Result<Option<f64>, String> {
|
||||
self.opt(k).map(|v| v.parse::<f64>().map_err(|_| format!("{k}: not a number: {v}"))).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
fn opt<'a>(args: &'a [String], k: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
/// A comp/layer reference: number → id, `-` → none (active), else a name / `#n`.
|
||||
fn reference(s: &str) -> Option<Value> {
|
||||
match s {
|
||||
"-" | "" => None,
|
||||
_ => Some(s.parse::<u64>().map(Value::from).unwrap_or_else(|_| json!(s))),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON if it parses, else the bare string.
|
||||
fn value_arg(s: &str) -> Value {
|
||||
serde_json::from_str(s).unwrap_or_else(|_| json!(s))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(cmd) = args.first().cloned() else { usage() };
|
||||
let rest = &args[1..];
|
||||
let mut s = Session::default();
|
||||
match cmd.as_str() {
|
||||
"frame" => {
|
||||
open(&mut s, rest);
|
||||
if let Some(c) = opt(rest, "--comp") {
|
||||
let _ = s.execute("comp.open", json!({"comp": c}));
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") {
|
||||
println!("{USAGE}");
|
||||
std::process::exit(if raw.is_empty() { 2 } else { 0 });
|
||||
}
|
||||
let mut args = match Args::parse(raw) {
|
||||
Ok(a) => a,
|
||||
Err(e) => fail_usage(&e),
|
||||
};
|
||||
let json_out = args.flag("--json");
|
||||
if args.pos.is_empty() {
|
||||
fail_usage("missing subcommand");
|
||||
}
|
||||
let cmd = args.pos.remove(0);
|
||||
match run(&cmd, &args, json_out) {
|
||||
Ok(()) => {}
|
||||
Err(Failure::Usage(e)) => fail_usage(&e),
|
||||
Err(Failure::Error(e)) => {
|
||||
if json_out {
|
||||
println!("{}", json!({"error": e}));
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap_or_else(|| {
|
||||
eprintln!("no composition");
|
||||
std::process::exit(1)
|
||||
});
|
||||
let comp = s.project.comp(cid).cloned().unwrap_or_else(|| std::process::exit(1));
|
||||
let t = if let Some(f) = opt(rest, "--frame").and_then(|f| f.parse().ok()) {
|
||||
comp.frame_rate.tick_of(f)
|
||||
} else {
|
||||
opt(rest, "--time").and_then(|v| v.parse().ok()).map(Tick::from_seconds_f64).unwrap_or(s.time())
|
||||
};
|
||||
let scale = opt(rest, "--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let out = opt(rest, "--out").unwrap_or("frame.png");
|
||||
let t0 = std::time::Instant::now();
|
||||
let img = s.render(cid, t, RenderOpts { scale, ..Default::default() });
|
||||
let el = t0.elapsed();
|
||||
let bg = comp.background;
|
||||
let rgba = img.to_rgba8_over(bg);
|
||||
image::save_buffer(out, &rgba, img.width, img.height, image::ColorType::Rgba8).unwrap_or_else(|e| {
|
||||
eprintln!("cannot write {out}: {e}");
|
||||
std::process::exit(1)
|
||||
});
|
||||
eprintln!("rendered {}x{} at {:.3}s in {:.1} ms → {out}", img.width, img.height, t.seconds(), el.as_secs_f64() * 1000.0);
|
||||
eprintln!("effectcraft-cli {cmd}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
"run" => {
|
||||
open(&mut s, rest);
|
||||
let pairs: Vec<&String> = rest.iter().filter(|a| !a.ends_with(".ecproj") && *a != "--demo").collect();
|
||||
for ch in pairs.chunks(2) {
|
||||
let params: Value = ch.get(1).and_then(|p| serde_json::from_str(p).ok()).unwrap_or(json!({}));
|
||||
match s.execute(ch[0], params) {
|
||||
Ok(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()),
|
||||
Err(e) => {
|
||||
eprintln!("{}: {e}", ch[0]);
|
||||
std::process::exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_usage(e: &str) -> ! {
|
||||
eprintln!("effectcraft-cli: {e}\n{USAGE}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
enum Failure {
|
||||
Usage(String),
|
||||
Error(String),
|
||||
}
|
||||
impl From<effectcraft_automation::Error> for Failure {
|
||||
fn from(e: effectcraft_automation::Error) -> Self {
|
||||
Failure::Error(e.to_string())
|
||||
}
|
||||
}
|
||||
impl From<String> for Failure {
|
||||
fn from(e: String) -> Self {
|
||||
Failure::Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_err<T>(m: &str) -> Result<T, Failure> {
|
||||
Err(Failure::Usage(m.to_string()))
|
||||
}
|
||||
|
||||
/// Build the backend: a bridge to the app, or a headless session with the requested project.
|
||||
fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
|
||||
if let Some(addr) = args.opt("--bridge") {
|
||||
if args.project.is_some() || args.flag("--demo") {
|
||||
return usage_err("--bridge drives the running app's project; use `exec file.open` to open another");
|
||||
}
|
||||
return Ok(Backend::bridge(addr)?);
|
||||
}
|
||||
let mut b = Backend::headless(effectcraft_host::session());
|
||||
if let Some(p) = &args.project {
|
||||
b.exec("file.open", json!({"path": p}))?;
|
||||
} else if args.flag("--demo") || (default_demo && !args.flag("--empty")) {
|
||||
b.exec("file.openDemoProject", json!({}))?;
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// `--save` / `--save-as` after a mutating command.
|
||||
fn maybe_save(b: &mut Backend, args: &Args) -> Result<Option<String>, Failure> {
|
||||
let r = if let Some(p) = args.opt("--save-as") {
|
||||
b.exec("file.saveAs", json!({"path": p}))?
|
||||
} else if args.flag("--save") {
|
||||
b.exec("file.save", json!({})).map_err(|e| Failure::Error(format!("{e} (use --project F to save in place, or --save-as F)")))?
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(r.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| args.opt("--save-as").map(str::to_string)))
|
||||
}
|
||||
|
||||
fn tool(b: &mut Backend, name: &str, a: Value) -> Result<Value, Failure> {
|
||||
match tools::run(b, name, &a)? {
|
||||
Reply::Json(v) => Ok(v),
|
||||
Reply::Image { info, .. } => Ok(info),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a result: compact JSON with `--json`, else pretty JSON.
|
||||
fn emit(v: &Value, json_out: bool) {
|
||||
if json_out {
|
||||
println!("{v}");
|
||||
} else {
|
||||
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
match cmd {
|
||||
"info" => {
|
||||
let mut b = backend(args, true)?;
|
||||
let p = tool(&mut b, "get_project", json!({}))?;
|
||||
let comp = tool(&mut b, "get_comp", json!({})).ok();
|
||||
let info = json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"mode": if b.is_bridge() { "bridge" } else { "headless" },
|
||||
"commands": effectcraft_engine::command_specs().len(),
|
||||
"effects": effectcraft_engine::effects::registry().len(),
|
||||
"project": p,
|
||||
"activeComp": comp,
|
||||
});
|
||||
if json_out {
|
||||
emit(&info, true);
|
||||
} else {
|
||||
println!(
|
||||
"EffectCraft {} ({}), {} commands, {} effects",
|
||||
info["version"].as_str().unwrap_or(""),
|
||||
info["mode"].as_str().unwrap_or(""),
|
||||
info["commands"],
|
||||
info["effects"]
|
||||
);
|
||||
println!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
|
||||
for i in p["items"].as_array().into_iter().flatten() {
|
||||
println!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
|
||||
}
|
||||
if let Some(c) = comp {
|
||||
println!(
|
||||
"active comp {} \"{}\" {}x{} @ {} fps, {} s",
|
||||
c["id"],
|
||||
c["name"].as_str().unwrap_or(""),
|
||||
c["width"],
|
||||
c["height"],
|
||||
c["frameRate"],
|
||||
c["duration"]
|
||||
);
|
||||
for l in c["layers"].as_array().into_iter().flatten() {
|
||||
println!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"commands" => {
|
||||
for c in effectcraft_engine::command_specs() {
|
||||
println!("{:<36} {:<40} {}", c.id, c.label, c.shortcut.unwrap_or(""));
|
||||
let mut b = backend(args, false)?;
|
||||
let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled")}))?;
|
||||
if json_out {
|
||||
emit(&v, true);
|
||||
} else {
|
||||
for c in v.as_array().into_iter().flatten() {
|
||||
println!(
|
||||
"{:<36} {:<36} {:<16} {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or(""),
|
||||
c["shortcut"].as_str().unwrap_or(""),
|
||||
c["params"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => usage(),
|
||||
"exec" => {
|
||||
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
|
||||
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
|
||||
(Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?,
|
||||
(None, None) => json!({}),
|
||||
};
|
||||
let mut b = backend(args, true)?;
|
||||
let r = b.exec(&id, params)?;
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
emit(&with_saved(r, saved), json_out);
|
||||
}
|
||||
"run" => {
|
||||
if args.pos.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] pairs");
|
||||
}
|
||||
let pairs = args.pos.clone();
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
let mut i = 0;
|
||||
while i < pairs.len() {
|
||||
let id = &pairs[i];
|
||||
let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) {
|
||||
Some(p) => {
|
||||
i += 1;
|
||||
serde_json::from_str(p).map_err(|e| Failure::Usage(format!("{id}: params are not valid JSON: {e}")))?
|
||||
}
|
||||
None => json!({}),
|
||||
};
|
||||
i += 1;
|
||||
let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
|
||||
if !json_out {
|
||||
emit(&r, false);
|
||||
}
|
||||
results.push(json!({"command": id, "result": r}));
|
||||
}
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
if json_out {
|
||||
emit(&with_saved(json!(results), saved), true);
|
||||
}
|
||||
}
|
||||
"props" => {
|
||||
let [comp, layer] = positional::<2>(args, "props <comp> <layer>")?;
|
||||
let mut b = backend(args, true)?;
|
||||
let a = json!({"comp": reference(&comp), "layer": reference(&layer), "flat": args.flag("--flat") || !json_out, "time": args.num("--time")?, "depth": args.num("--depth")?});
|
||||
let v = tool(&mut b, "get_layer", a)?;
|
||||
if json_out {
|
||||
emit(&v, true);
|
||||
} else {
|
||||
println!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
|
||||
for p in v["properties"].as_array().into_iter().flatten() {
|
||||
let mut extra = String::new();
|
||||
if let Some(k) = p.get("keys") {
|
||||
extra += &format!(" [{k} keys]");
|
||||
}
|
||||
if let Some(e) = p.get("expression").and_then(Value::as_str) {
|
||||
extra += &format!(" expr: {e}");
|
||||
}
|
||||
println!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
"get" => {
|
||||
let [comp, layer, path] = positional::<3>(args, "get <comp> <layer> <path>")?;
|
||||
let mut b = backend(args, true)?;
|
||||
let v = tool(&mut b, "get_property", json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "time": args.num("--time")?}))?;
|
||||
emit(&v, json_out);
|
||||
}
|
||||
"set" => {
|
||||
let expr = args.opt("--expression").map(str::to_string);
|
||||
let need = if expr.is_some() { 3 } else { 4 };
|
||||
if args.pos.len() < need {
|
||||
return usage_err("set <comp> <layer> <path> <value> [--time S] [--expression E]");
|
||||
}
|
||||
let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone());
|
||||
let value = args.pos.get(3).map(|v| value_arg(v));
|
||||
let mut b = backend(args, true)?;
|
||||
let a =
|
||||
json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr});
|
||||
let v = tool(&mut b, "set_property", a)?;
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
emit(&with_saved(v, saved), json_out);
|
||||
}
|
||||
"render-frame" | "frame" => {
|
||||
let mut b = backend(args, true)?;
|
||||
let comp = args.opt("--comp").and_then(reference);
|
||||
let mut time = args.num("--time")?;
|
||||
let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0);
|
||||
let needs_info = args.opt("--frame").is_some() || args.opt("--scale").is_some();
|
||||
if needs_info {
|
||||
let c = tool(&mut b, "get_comp", json!({"comp": comp}))?;
|
||||
if let Some(f) = args.num("--frame")? {
|
||||
time = Some(f / c["frameRate"].as_f64().unwrap_or(30.0));
|
||||
}
|
||||
if let Some(k) = args.num("--scale")? {
|
||||
let long = c["width"].as_f64().unwrap_or(0.0).max(c["height"].as_f64().unwrap_or(0.0));
|
||||
max_side = (long * k).round().max(1.0) as u32;
|
||||
}
|
||||
}
|
||||
let out = args.opt("--out").unwrap_or("frame.png").to_string();
|
||||
let t0 = std::time::Instant::now();
|
||||
let f = b.render(comp.as_ref(), time, max_side)?;
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?;
|
||||
let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0});
|
||||
if json_out {
|
||||
emit(&info, true);
|
||||
} else {
|
||||
eprintln!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let b = backend(args, false)?;
|
||||
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
|
||||
}
|
||||
other => return usage_err(&format!("unknown subcommand `{other}`")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn positional<const N: usize>(args: &Args, usage: &str) -> Result<[String; N], Failure> {
|
||||
if args.pos.len() < N {
|
||||
return usage_err(usage);
|
||||
}
|
||||
Ok(std::array::from_fn(|i| args.pos[i].clone()))
|
||||
}
|
||||
|
||||
fn with_saved(v: Value, saved: Option<String>) -> Value {
|
||||
match saved {
|
||||
None => v,
|
||||
Some(p) => json!({"result": v, "saved": p}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! End-to-end tests of the agent CLI: JSON output, project round-trips, rendering and MCP over stdio.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
}
|
||||
|
||||
/// Run with `--json`; returns (exit code, parsed stdout).
|
||||
fn run_json(args: &[&str]) -> (i32, Value) {
|
||||
let out = bin().args(args).arg("--json").output().unwrap();
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let v =
|
||||
serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: not JSON ({e}): {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr)));
|
||||
(out.status.code().unwrap_or(-1), v)
|
||||
}
|
||||
|
||||
fn ok_json(args: &[&str]) -> Value {
|
||||
let (code, v) = run_json(args);
|
||||
assert_eq!(code, 0, "{args:?}: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_and_commands() {
|
||||
let v = ok_json(&["info"]);
|
||||
assert_eq!(v["mode"], "headless");
|
||||
assert!(v["commands"].as_u64().unwrap() > 50);
|
||||
assert!(v["activeComp"]["layers"].as_array().unwrap().len() > 1, "demo project by default");
|
||||
|
||||
let v = ok_json(&["commands", "--filter", "layer.new"]);
|
||||
let ids: Vec<&str> = v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap()).collect();
|
||||
assert!(ids.contains(&"layer.newSolid") && ids.contains(&"layer.newText"));
|
||||
assert!(ids.iter().all(|i| i.contains("layer.new") || !i.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_set_get_with_saved_project() {
|
||||
let proj = tmp("edit.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let v = ok_json(&["exec", "comp.new", "--params", r#"{"name":"Main","width":320,"height":180,"duration":3}"#, "--empty", "--save-as", p]);
|
||||
assert_eq!(v["saved"], p);
|
||||
// Positional params + positional project + in-place save.
|
||||
let v = ok_json(&["exec", "layer.newSolid", r##"{"color":"#00ff00","name":"Green"}"##, p, "--save"]);
|
||||
assert!(v["result"]["layer"].is_u64());
|
||||
|
||||
let v = ok_json(&["set", "Main", "Green", "transform/opacity", "25", "--project", p, "--save"]);
|
||||
assert_eq!(v["result"]["value"], 25.0);
|
||||
ok_json(&["set", "Main", "#1", "transform/position", "[0,90]", "--time", "0", p, "--save"]);
|
||||
ok_json(&["set", "Main", "#1", "transform/position", "[320,90]", "--time", "2", p, "--save"]);
|
||||
|
||||
let v = ok_json(&["get", "Main", "#1", "transform/position", "--time", "1", p]);
|
||||
assert_eq!(v["keys"].as_array().unwrap().len(), 2);
|
||||
assert!((v["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{v}");
|
||||
assert_eq!(ok_json(&["get", "-", "Green", "transform/opacity", p])["value"], 25.0);
|
||||
|
||||
let v = ok_json(&["props", "Main", "#1", p]);
|
||||
assert_eq!(v["name"], "Green");
|
||||
let tree = v["properties"]["children"].as_array().unwrap();
|
||||
assert!(!tree.is_empty());
|
||||
// The human (flat) listing shows paths.
|
||||
let out = bin().args(["props", "Main", "#1", p]).output().unwrap();
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("transform/position"));
|
||||
|
||||
// Several commands in one process.
|
||||
let v = ok_json(&["run", p, "time.set", r#"{"time":1}"#, "project.summary"]);
|
||||
assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_writes_png() {
|
||||
let out = tmp("f.png");
|
||||
let o = out.to_str().unwrap();
|
||||
let v = ok_json(&["render-frame", "--time", "1", "--max-side", "240", "--out", o]);
|
||||
assert_eq!(v["width"], 240);
|
||||
assert_eq!(v["height"], 135);
|
||||
let png = std::fs::read(&out).unwrap();
|
||||
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
|
||||
let v = ok_json(&["render-frame", "--frame", "15", "--scale", "0.125", "--out", o]);
|
||||
assert_eq!(v["width"], 240);
|
||||
assert!((v["time"].as_f64().unwrap() - 0.5).abs() < 0.02, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_json() {
|
||||
let (code, v) = run_json(&["exec", "no.such.command", "--empty"]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(v["error"].as_str().unwrap().contains("unknown command"));
|
||||
let (code, v) = run_json(&["exec", "layer.select", r#"{"index":2}"#]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(v["error"].as_str().unwrap().contains("accepted: layers, add, toggle"), "{v}");
|
||||
let (code, v) = run_json(&["get", "-", "#99", "transform/opacity"]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(v["error"].is_string());
|
||||
let out = bin().args(["get", "only-one-arg"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
let msgs = [
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}),
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
|
||||
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
|
||||
json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_property", "arguments": {"layer": "#1", "path": "transform/opacity"}}}),
|
||||
json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "render_frame", "arguments": {"max_side": 64}}}),
|
||||
];
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for m in &msgs {
|
||||
writeln!(stdin, "{m}").unwrap();
|
||||
}
|
||||
} // EOF ends the server.
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 4, "one reply per request, none for the notification");
|
||||
assert_eq!(replies[0]["result"]["serverInfo"]["name"], "effectcraft");
|
||||
assert!(replies[1]["result"]["tools"].as_array().unwrap().len() >= 13);
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
Reference in new issue
Block a user