From 7ec373c9a751fd3d3af844ee6481a8add2e44e1e Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 12 Jul 2026 17:52:36 +0800 Subject: [PATCH] feat(cli): export pages nodes and live selection --- Cargo.lock | 1 + crates/op-cli/Cargo.toml | 1 + crates/op-cli/src/cli_export_tests.rs | 143 ++++++++++++++++++++++++++ crates/op-cli/src/command_helpers.rs | 33 ++++++ crates/op-cli/src/export_cli.rs | 88 ++++++++++++++++ crates/op-cli/src/main.rs | 60 ++++++----- crates/op-cli/src/usage.txt | 4 + 7 files changed, 299 insertions(+), 31 deletions(-) create mode 100644 crates/op-cli/src/cli_export_tests.rs create mode 100644 crates/op-cli/src/command_helpers.rs create mode 100644 crates/op-cli/src/export_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 7c50d6435..648499569 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3101,6 +3101,7 @@ dependencies = [ name = "op-cli" version = "0.8.0" dependencies = [ + "base64", "op-ai-skills", "op-config-store", "op-figma", diff --git a/crates/op-cli/Cargo.toml b/crates/op-cli/Cargo.toml index 05813d6bd..5e6b743a2 100644 --- a/crates/op-cli/Cargo.toml +++ b/crates/op-cli/Cargo.toml @@ -12,6 +12,7 @@ name = "op" path = "src/main.rs" [dependencies] +base64 = "0.22" op-ai-skills = { path = "../op-ai-skills" } op-config-store = { path = "../op-config-store" } op-figma = { path = "../op-figma" } diff --git a/crates/op-cli/src/cli_export_tests.rs b/crates/op-cli/src/cli_export_tests.rs new file mode 100644 index 000000000..e3003dbc9 --- /dev/null +++ b/crates/op-cli/src/cli_export_tests.rs @@ -0,0 +1,143 @@ +use super::*; +use base64::Engine as _; + +fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +#[test] +fn export_item_maps_to_dedicated_command() { + let parsed = parse_args(&args(&[ + "export", + "--item", + "page-2", + "--output", + "/tmp/page.png", + "--format", + "png", + "--scale", + "2", + ])) + .expect("parse export"); + assert_eq!( + parsed.command, + Command::Export { + item_id: Some("page-2".into()), + selection: false, + output: "/tmp/page.png".into(), + format: "png".into(), + scale: Some("2".into()), + } + ); +} + +#[test] +fn export_without_item_means_live_selection() { + let parsed = parse_args(&args(&[ + "export", + "--output", + "/tmp/selected.png", + "--format", + "png", + ])) + .expect("parse selection export"); + assert!(matches!( + parsed.command, + Command::Export { + item_id: None, + selection: false, + .. + } + )); +} + +#[test] +fn export_selection_flag_means_live_selection() { + let parsed = parse_args(&args(&[ + "export", + "--selection", + "--output", + "/tmp/selected.png", + ])) + .expect("parse --selection export"); + assert!(matches!( + parsed.command, + Command::Export { + item_id: None, + selection: true, + .. + } + )); +} + +#[test] +fn export_accepts_issue_formats_alias() { + let parsed = parse_args(&args(&[ + "export", + "--item", + "page-1", + "--output", + "/tmp/page.png", + "--formats", + "png", + ])) + .expect("parse --formats alias"); + assert!(matches!( + parsed.command, + Command::Export { format, .. } if format == "png" + )); +} + +#[test] +fn export_rejects_conflicting_target_and_format_flags() { + let target = parse_args(&args(&[ + "export", + "--item", + "n1", + "--selection", + "--output", + "/tmp/node.png", + ])); + assert!(target.unwrap_err().contains("--item and --selection")); + + let format = parse_args(&args(&[ + "export", + "--output", + "/tmp/node.png", + "--format", + "png", + "--formats", + "jpeg", + ])); + assert!(format.unwrap_err().contains("--format and --formats")); +} + +#[test] +fn write_export_response_decodes_png_to_exact_path() { + let path = + std::env::temp_dir().join(format!("op-cli-export-{}-selected.png", std::process::id())); + let png = [0x89, b'P', b'N', b'G', 13, 10, 26, 10]; + let response = serde_json::json!({ + "itemId": "n1", + "itemType": "node", + "format": "png", + "bytes_base64": base64::engine::general_purpose::STANDARD.encode(png), + }) + .to_string(); + + let output = export_cli::write_export_response(&response, &path).expect("write export"); + assert_eq!(std::fs::read(&path).expect("read export"), png); + assert!(output.contains("\"itemType\":\"node\""), "{output}"); + std::fs::remove_file(path).ok(); +} + +#[test] +fn write_export_response_rejects_invalid_payloads() { + let path = std::env::temp_dir().join("op-cli-export-invalid.png"); + assert!(export_cli::write_export_response("not-json", &path).is_err()); + assert!(export_cli::write_export_response( + r#"{"itemId":"n1","itemType":"node","format":"png","bytes_base64":"%%%"}"#, + &path, + ) + .is_err()); +} diff --git a/crates/op-cli/src/command_helpers.rs b/crates/op-cli/src/command_helpers.rs new file mode 100644 index 000000000..3d77eb8b6 --- /dev/null +++ b/crates/op-cli/src/command_helpers.rs @@ -0,0 +1,33 @@ +use crate::path_args::resolve_file_path_arg; +use crate::{Command, Flags}; + +pub(crate) fn flag_value(flags: &Flags, key: &str) -> Option { + flags.get(key).and_then(Clone::clone) +} + +pub(crate) fn push_file_path(pairs: &mut Vec<(String, String)>, flags: &Flags) { + if let Some(file) = flag_value(flags, "file") { + pairs.push(pair("filePath", resolve_file_path_arg(&file))); + } +} + +pub(crate) fn pair(key: impl Into, value: impl Into) -> (String, String) { + (key.into(), value.into()) +} + +pub(crate) fn tool_call_with_file(tool: &str, flags: &Flags) -> Result { + let mut pairs = Vec::new(); + push_file_path(&mut pairs, flags); + tool_call(tool, pairs) +} + +pub(crate) fn tool_call(tool: &str, args: Vec<(String, String)>) -> Result { + Ok(Command::ToolCall { + tool: tool.to_string(), + args, + }) +} + +pub(crate) fn version_json() -> String { + format!(r#"{{"version":"{}"}}"#, env!("CARGO_PKG_VERSION")) +} diff --git a/crates/op-cli/src/export_cli.rs b/crates/op-cli/src/export_cli.rs new file mode 100644 index 000000000..5ddd4b17f --- /dev/null +++ b/crates/op-cli/src/export_cli.rs @@ -0,0 +1,88 @@ +use std::path::Path; + +use base64::Engine as _; +use serde_json::Value; + +use crate::command_helpers::flag_value; +use crate::mcp_http_cli::{post, tool_call_body}; +use crate::{Command, Flags}; + +pub(crate) fn map_export(flags: &Flags) -> Result { + let item_id = flag_value(flags, "item"); + let selection = flags.contains_key("selection"); + if item_id.is_some() && selection { + return Err("--item and --selection cannot be used together".into()); + } + let format_flag = flag_value(flags, "format"); + let formats_flag = flag_value(flags, "formats"); + if let (Some(format), Some(formats)) = (&format_flag, &formats_flag) { + if format != formats { + return Err("--format and --formats cannot specify different values".into()); + } + } + let format = format_flag.or(formats_flag).unwrap_or_else(|| "png".into()); + if !matches!(format.as_str(), "png" | "jpeg" | "jpg" | "webp" | "pdf") { + return Err(format!("unsupported export format {format:?}")); + } + let output = flag_value(flags, "output").ok_or("--output is required")?; + let scale = flag_value(flags, "scale"); + if let Some(value) = &scale { + value + .parse::() + .map_err(|_| format!("--scale must be a number, got {value:?}"))?; + } + Ok(Command::Export { + item_id, + selection, + output, + format, + scale, + }) +} + +pub(crate) fn run_export( + port: u16, + item_id: Option<&str>, + output: &str, + format: &str, + scale: Option<&str>, +) -> Result { + let mut arguments = serde_json::Map::new(); + if let Some(item_id) = item_id { + arguments.insert("itemId".into(), Value::String(item_id.into())); + } + arguments.insert("format".into(), Value::String(format.into())); + if let Some(scale) = scale { + let scale = scale + .parse::() + .map_err(|_| format!("--scale must be a number, got {scale:?}"))?; + arguments.insert("scale".into(), Value::from(scale)); + } + let response = post( + port, + &tool_call_body("export_item", &Value::Object(arguments).to_string()), + )?; + write_export_response(&response, Path::new(output)) +} + +pub(crate) fn write_export_response(response: &str, output: &Path) -> Result { + let value: Value = serde_json::from_str(response) + .map_err(|error| format!("export_item returned invalid JSON: {error}"))?; + let encoded = value + .get("bytes_base64") + .and_then(Value::as_str) + .ok_or("export_item response is missing bytes_base64")?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| format!("export_item returned invalid Base64: {error}"))?; + std::fs::write(output, bytes) + .map_err(|error| format!("cannot write export to {}: {error}", output.display()))?; + + Ok(serde_json::json!({ + "output": output.to_string_lossy(), + "itemId": value.get("itemId").and_then(Value::as_str).unwrap_or(""), + "itemType": value.get("itemType").and_then(Value::as_str).unwrap_or(""), + "format": value.get("format").and_then(Value::as_str).unwrap_or(""), + }) + .to_string()) +} diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index bec2b166c..8445f09cf 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -9,6 +9,8 @@ use std::io::{self, Read}; mod app_control_cli; mod cli_conversion; mod codegen_cli; +mod command_helpers; +mod export_cli; mod figma_cli; mod mcp_http_cli; mod page_theme_cli; @@ -16,6 +18,9 @@ mod path_args; mod skill_export_cli; mod skill_install_cli; +use command_helpers::{ + flag_value, pair, push_file_path, tool_call, tool_call_with_file, version_json, +}; use mcp_http_cli::{ args_to_json, json_escape, post, pretty_json, status_json, tool_call_body, tools_list_body, }; @@ -62,6 +67,7 @@ fn run(args: &[String]) -> Result { | Command::ToolsList | Command::ToolCall { .. } | Command::ToolCallJson { .. } + | Command::Export { .. } ); let target_port = if port_explicit || !needs_server { port @@ -100,6 +106,19 @@ fn run(args: &[String]) -> Result { Command::ToolCallJson { tool, args_json } => { post(target_port, &tool_call_body(&tool, &args_json))? } + Command::Export { + item_id, + selection: _, + output, + format, + scale, + } => export_cli::run_export( + target_port, + item_id.as_deref(), + &output, + &format, + scale.as_deref(), + )?, }; Ok(if pretty { pretty_json(&out) } else { out }) } @@ -153,6 +172,13 @@ enum Command { tool: String, args_json: String, }, + Export { + item_id: Option, + selection: bool, + output: String, + format: String, + scale: Option, + }, } type Flags = BTreeMap>; @@ -274,6 +300,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result Ok(Command::StopMcp), + "export" => export_cli::map_export(flags), "skill:export" => Ok(Command::SkillExport { name: required_pos( positionals, @@ -741,42 +768,13 @@ fn required_pos(positionals: &[String], index: usize, usage: &str) -> Result Option { - flags.get(key).and_then(Clone::clone) -} - -fn push_file_path(pairs: &mut Vec<(String, String)>, flags: &Flags) { - if let Some(file) = flag_value(flags, "file") { - pairs.push(pair("filePath", resolve_file_path_arg(&file))); - } -} - -fn pair(k: impl Into, v: impl Into) -> (String, String) { - (k.into(), v.into()) -} - -fn tool_call_with_file(tool: &str, flags: &Flags) -> Result { - let mut pairs = Vec::new(); - push_file_path(&mut pairs, flags); - tool_call(tool, pairs) -} - -fn tool_call(tool: &str, args: Vec<(String, String)>) -> Result { - Ok(Command::ToolCall { - tool: tool.to_string(), - args, - }) -} - -fn version_json() -> String { - format!(r#"{{"version":"{}"}}"#, env!("CARGO_PKG_VERSION")) -} - #[cfg(test)] mod cli_conversion_tests; #[cfg(test)] mod cli_design_tests; #[cfg(test)] +mod cli_export_tests; +#[cfg(test)] mod cli_file_flag_tests; #[cfg(test)] mod cli_import_tests; diff --git a/crates/op-cli/src/usage.txt b/crates/op-cli/src/usage.txt index 5fe361bc4..ac54e5730 100644 --- a/crates/op-cli/src/usage.txt +++ b/crates/op-cli/src/usage.txt @@ -20,6 +20,10 @@ COMMON COMMANDS: op uninstall [--target T] uninstall openpencil-skill op skill:export [--out DIR] export embedded skill as DIR//SKILL.md op status check whether MCP HTTP is reachable + op export [--item ID|--selection] --output PATH [--format png] [--scale N] + export a page, arbitrary node, or the + current Live Canvas selection; omitting + --item is equivalent to --selection op get [--type T] [--name N] [--id ID] [--depth N] [--parent P] op selection get current selection op insert [--parent P] [--page PAGE] [--post-process]