feat(cli): export pages nodes and live selection
This commit is contained in:
parent
1215633e54
commit
7ec373c9a7
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3101,6 +3101,7 @@ dependencies = [
|
|||
name = "op-cli"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"op-ai-skills",
|
||||
"op-config-store",
|
||||
"op-figma",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
143
crates/op-cli/src/cli_export_tests.rs
Normal file
143
crates/op-cli/src/cli_export_tests.rs
Normal 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());
|
||||
}
|
||||
33
crates/op-cli/src/command_helpers.rs
Normal file
33
crates/op-cli/src/command_helpers.rs
Normal 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"))
|
||||
}
|
||||
88
crates/op-cli/src/export_cli.rs
Normal file
88
crates/op-cli/src/export_cli.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue