feat(cli): export pages nodes and live selection

This commit is contained in:
Kayshen-X 2026-07-12 17:52:36 +08:00
parent 1215633e54
commit 7ec373c9a7
7 changed files with 299 additions and 31 deletions

1
Cargo.lock generated
View file

@ -3101,6 +3101,7 @@ dependencies = [
name = "op-cli"
version = "0.8.0"
dependencies = [
"base64",
"op-ai-skills",
"op-config-store",
"op-figma",

View file

@ -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" }

View file

@ -0,0 +1,143 @@
use super::*;
use base64::Engine as _;
fn args(values: &[&str]) -> Vec<String> {
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());
}

View file

@ -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<String> {
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<String>, value: impl Into<String>) -> (String, String) {
(key.into(), value.into())
}
pub(crate) fn tool_call_with_file(tool: &str, flags: &Flags) -> Result<Command, String> {
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<Command, String> {
Ok(Command::ToolCall {
tool: tool.to_string(),
args,
})
}
pub(crate) fn version_json() -> String {
format!(r#"{{"version":"{}"}}"#, env!("CARGO_PKG_VERSION"))
}

View file

@ -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<Command, String> {
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::<f32>()
.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<String, String> {
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::<f64>()
.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<String, String> {
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())
}

View file

@ -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<String, String> {
| 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<String, String> {
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<String>,
selection: bool,
output: String,
format: String,
scale: Option<String>,
},
}
type Flags = BTreeMap<String, Option<String>>;
@ -274,6 +300,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
})
}
"stop" => 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<Str
.ok_or_else(|| usage.to_string())
}
fn flag_value(flags: &Flags, key: &str) -> Option<String> {
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<String>, v: impl Into<String>) -> (String, String) {
(k.into(), v.into())
}
fn tool_call_with_file(tool: &str, flags: &Flags) -> Result<Command, String> {
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<Command, String> {
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;

View file

@ -20,6 +20,10 @@ COMMON COMMANDS:
op uninstall [--target T] uninstall openpencil-skill
op skill:export <skill> [--out DIR] export embedded skill as DIR/<skill>/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 <json|@file|-> [--parent P] [--page PAGE] [--post-process]