825 lines
29 KiB
Rust
825 lines
29 KiB
Rust
//! `op` - the OpenPencil CLI.
|
|
//! Keeps common TS `op` aliases while preserving low-level `op <tool> key=value`.
|
|
|
|
use serde_json::Value;
|
|
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::io::{self, Read};
|
|
|
|
mod app_control_cli;
|
|
mod codegen_cli;
|
|
mod figma_cli;
|
|
mod mcp_http_cli;
|
|
mod path_args;
|
|
mod skill_install_cli;
|
|
|
|
use mcp_http_cli::{
|
|
args_to_json, json_escape, post, pretty_json, status_json, tool_call_body, tools_list_body,
|
|
};
|
|
use path_args::{resolve_file_path_arg, resolve_file_path_value};
|
|
|
|
#[cfg(test)]
|
|
use mcp_http_cli::{http_request, status_json_from_running};
|
|
|
|
#[cfg(test)]
|
|
use figma_cli::figma_default_out_path;
|
|
|
|
/// Default HTTP MCP port, matching TS `@zseven-w/pen-mcp`.
|
|
const DEFAULT_PORT: u16 = 3100;
|
|
|
|
const USAGE: &str = include_str!("usage.txt");
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
match run(&args) {
|
|
Ok(out) => {
|
|
println!("{out}");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("op: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse `args`, perform the request, return the text to print.
|
|
fn run(args: &[String]) -> Result<String, String> {
|
|
let Parsed {
|
|
port,
|
|
port_explicit,
|
|
pretty,
|
|
command,
|
|
} = parse_args(args)?;
|
|
// For commands that talk to a running server, resolve the live
|
|
// editor's published port (`~/.openpencil/.op-mcp-port`) unless the user
|
|
// pinned `--port` explicitly. `op start` keeps the requested port.
|
|
let needs_server = matches!(
|
|
command,
|
|
Command::Status
|
|
| Command::ToolsList
|
|
| Command::ToolCall { .. }
|
|
| Command::ToolCallJson { .. }
|
|
);
|
|
let target_port = if port_explicit || !needs_server {
|
|
port
|
|
} else {
|
|
app_control_cli::discover_running_port().unwrap_or(port)
|
|
};
|
|
let out = match command {
|
|
Command::Help => USAGE.to_string(),
|
|
Command::Version => version_json(),
|
|
Command::Status => status_json(target_port),
|
|
Command::StartMcp {
|
|
document_path,
|
|
headless,
|
|
} => app_control_cli::run_start(port, document_path.as_deref(), headless)?,
|
|
Command::StopMcp => app_control_cli::run_stop()?,
|
|
Command::InstallSkill { target } => skill_install_cli::run_install(target.as_deref())?,
|
|
Command::UninstallSkill { target } => skill_install_cli::run_uninstall(target.as_deref())?,
|
|
Command::ToolsList => post(target_port, &tools_list_body())?,
|
|
Command::ImportFigma { fig_path, out_path } => {
|
|
figma_cli::run_import_figma(&fig_path, &out_path)?
|
|
}
|
|
Command::ToolCall { tool, args } => {
|
|
post(target_port, &tool_call_body(&tool, &args_to_json(&args)))?
|
|
}
|
|
Command::ToolCallJson { tool, args_json } => {
|
|
post(target_port, &tool_call_body(&tool, &args_json))?
|
|
}
|
|
};
|
|
Ok(if pretty { pretty_json(&out) } else { out })
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct Parsed {
|
|
port: u16,
|
|
/// Whether `--port` was passed explicitly. When false, server-bound
|
|
/// commands resolve the running editor's port via discovery instead
|
|
/// of assuming the default.
|
|
port_explicit: bool,
|
|
pretty: bool,
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Command {
|
|
Help,
|
|
Version,
|
|
Status,
|
|
StartMcp {
|
|
document_path: Option<String>,
|
|
headless: bool,
|
|
},
|
|
StopMcp,
|
|
InstallSkill {
|
|
target: Option<String>,
|
|
},
|
|
UninstallSkill {
|
|
target: Option<String>,
|
|
},
|
|
ToolsList,
|
|
ImportFigma {
|
|
fig_path: String,
|
|
out_path: String,
|
|
},
|
|
ToolCall {
|
|
tool: String,
|
|
args: Vec<(String, String)>,
|
|
},
|
|
ToolCallJson {
|
|
tool: String,
|
|
args_json: String,
|
|
},
|
|
}
|
|
|
|
type Flags = BTreeMap<String, Option<String>>;
|
|
|
|
/// Parse command-line args. `--port`, `--pretty`, `--help`, and
|
|
/// `--version` are global; the rest are left for command aliases or
|
|
/// low-level MCP tool arguments.
|
|
fn parse_args(args: &[String]) -> Result<Parsed, String> {
|
|
let mut port = DEFAULT_PORT;
|
|
let mut port_explicit = false;
|
|
let mut pretty = false;
|
|
let mut positionals = Vec::new();
|
|
let mut flags: Flags = BTreeMap::new();
|
|
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
let arg = &args[i];
|
|
if arg == "--" {
|
|
positionals.extend(args[i + 1..].iter().cloned());
|
|
break;
|
|
}
|
|
if arg == "-h" {
|
|
flags.insert("help".into(), None);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if arg == "-V" {
|
|
flags.insert("version".into(), None);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if let Some(raw) = arg.strip_prefix("--") {
|
|
let (key, inline_value) = match raw.split_once('=') {
|
|
Some((k, v)) => (k.to_string(), Some(v.to_string())),
|
|
None => (raw.to_string(), None),
|
|
};
|
|
match key.as_str() {
|
|
"port" => {
|
|
let raw_port = match inline_value {
|
|
Some(v) => v,
|
|
None => {
|
|
let next = args
|
|
.get(i + 1)
|
|
.ok_or("--port needs a value (e.g. --port 3100)")?;
|
|
i += 1;
|
|
next.clone()
|
|
}
|
|
};
|
|
port = raw_port
|
|
.parse::<u16>()
|
|
.map_err(|_| format!("--port must be a u16, got {raw_port:?}"))?;
|
|
port_explicit = true;
|
|
}
|
|
"pretty" => pretty = true,
|
|
"help" => {
|
|
flags.insert("help".into(), None);
|
|
}
|
|
"version" => {
|
|
flags.insert("version".into(), None);
|
|
}
|
|
_ => {
|
|
let value = match inline_value {
|
|
Some(v) => Some(v),
|
|
None if args.get(i + 1).is_some_and(|next| !next.starts_with("--")) => {
|
|
i += 1;
|
|
Some(args[i].clone())
|
|
}
|
|
None => None,
|
|
};
|
|
flags.insert(key, value);
|
|
}
|
|
}
|
|
} else {
|
|
positionals.push(arg.clone());
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
let command = if flags.contains_key("help") {
|
|
Command::Help
|
|
} else if flags.contains_key("version") {
|
|
Command::Version
|
|
} else if positionals.is_empty() {
|
|
Command::Help
|
|
} else {
|
|
command_from_positionals(&positionals, &flags)?
|
|
};
|
|
Ok(Parsed {
|
|
port,
|
|
port_explicit,
|
|
pretty,
|
|
command,
|
|
})
|
|
}
|
|
|
|
fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
match positionals[0].as_str() {
|
|
"help" | "-h" | "--help" => Ok(Command::Help),
|
|
"version" => Ok(Command::Version),
|
|
"tools" => Ok(Command::ToolsList),
|
|
"status" => Ok(Command::Status),
|
|
"start" => Ok(Command::StartMcp {
|
|
document_path: flag_value(flags, "file"),
|
|
headless: flags.contains_key("headless"),
|
|
}),
|
|
"stop" => Ok(Command::StopMcp),
|
|
"install" => Ok(Command::InstallSkill {
|
|
target: flag_value(flags, "target"),
|
|
}),
|
|
"uninstall" => Ok(Command::UninstallSkill {
|
|
target: flag_value(flags, "target"),
|
|
}),
|
|
"open" => {
|
|
let args = flag_value(flags, "file")
|
|
.or_else(|| positionals.get(1).cloned())
|
|
.map(|path| vec![pair("filePath", resolve_file_path_arg(&path))])
|
|
.unwrap_or_default();
|
|
tool_call("open_document", args)
|
|
}
|
|
"save" => {
|
|
let file_path =
|
|
resolve_file_path_arg(&required_pos(positionals, 1, "Usage: op save <file.op>")?);
|
|
let mut args = vec![pair("filePath", file_path)];
|
|
if let Some(source) = flag_value(flags, "file") {
|
|
args.push(pair("sourceFilePath", resolve_file_path_arg(&source)));
|
|
}
|
|
tool_call("save_document", args)
|
|
}
|
|
"get" => map_get(flags),
|
|
"selection" => map_selection(flags),
|
|
"insert" => map_insert(positionals, flags),
|
|
"update" => map_update(positionals, flags),
|
|
"delete" => map_delete(positionals, flags),
|
|
"read-nodes" => map_read_nodes(positionals, flags),
|
|
"move" => map_reparent("move_node", positionals, flags),
|
|
"copy" => map_reparent("copy_node", positionals, flags),
|
|
"replace" => map_replace(positionals, flags),
|
|
"design" => map_design_like("batch_design", positionals.get(1), flags, true),
|
|
"design:skeleton" => map_design_skeleton(positionals, flags),
|
|
"design:content" => map_design_content(positionals, flags),
|
|
"design:refine" => map_design_refine(flags),
|
|
"page" => map_page(positionals, flags),
|
|
"vars" => tool_call_with_file("get_variables", flags),
|
|
"vars:set" => map_vars_set(positionals, flags),
|
|
"themes" => tool_call_with_file("get_variables", flags),
|
|
"themes:set" => map_themes_set(positionals, flags),
|
|
"theme:save" => map_theme_save(positionals, flags),
|
|
"theme:load" => map_theme_load(positionals, flags),
|
|
"theme:list" => map_theme_list(positionals),
|
|
"layout" => map_layout(flags),
|
|
"find-space" => map_find_space(flags),
|
|
"import:svg" => map_import_svg(positionals, flags),
|
|
"import:figma" => figma_cli::map_import_figma(positionals, flags),
|
|
"codegen:plan" | "codegen:submit" | "codegen:assemble" | "codegen:clean" => {
|
|
codegen_cli::map_codegen(positionals, flags)
|
|
}
|
|
tool => generic_tool_call(tool, &positionals[1..], flags),
|
|
}
|
|
}
|
|
|
|
fn map_get(flags: &Flags) -> Result<Command, String> {
|
|
let mut pairs = Vec::new();
|
|
if let Some(id) = flag_value(flags, "id") {
|
|
pairs.push(pair("nodeIds", format!(r#"["{}"]"#, json_escape(&id))));
|
|
}
|
|
if flag_value(flags, "type").is_some() || flag_value(flags, "name").is_some() {
|
|
let mut fields = Vec::new();
|
|
if let Some(kind) = flag_value(flags, "type") {
|
|
fields.push(format!(r#""type":"{}""#, json_escape(&kind)));
|
|
}
|
|
if let Some(name) = flag_value(flags, "name") {
|
|
fields.push(format!(r#""name":"{}""#, json_escape(&name)));
|
|
}
|
|
pairs.push(pair("patterns", format!("[{{{}}}]", fields.join(","))));
|
|
}
|
|
if let Some(parent) = flag_value(flags, "parent") {
|
|
pairs.push(pair("parentId", parent));
|
|
}
|
|
if let Some(depth) = flag_value(flags, "depth") {
|
|
pairs.push(pair("readDepth", depth));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("batch_get", pairs)
|
|
}
|
|
|
|
fn map_selection(flags: &Flags) -> Result<Command, String> {
|
|
let mut pairs = Vec::new();
|
|
if let Some(depth) = flag_value(flags, "depth") {
|
|
pairs.push(pair("readDepth", depth));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("get_selection", pairs)
|
|
}
|
|
|
|
fn map_insert(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let raw = resolve_arg(positionals.get(1).map(String::as_str))?;
|
|
let mut pairs = data_pairs(&raw)?;
|
|
if let Some(parent) = flag_value(flags, "parent") {
|
|
pairs.push(pair("parent", parent));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
if flags.contains_key("post-process") {
|
|
pairs.push(pair("postProcess", "true"));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("insert_node", pairs)
|
|
}
|
|
|
|
fn map_update(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let node_id = required_pos(positionals, 1, "Usage: op update <node-id> <json>")?;
|
|
let raw = resolve_arg(positionals.get(2).map(String::as_str))?;
|
|
let mut pairs = vec![pair("nodeId", node_id)];
|
|
pairs.extend(data_pairs(&raw)?);
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
if flags.contains_key("post-process") {
|
|
pairs.push(pair("postProcess", "true"));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("update_node", pairs)
|
|
}
|
|
|
|
fn map_replace(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let node_id = required_pos(positionals, 1, "Usage: op replace <node-id> <json>")?;
|
|
let raw = resolve_arg(positionals.get(2).map(String::as_str))?;
|
|
let mut pairs = vec![pair("nodeId", node_id)];
|
|
pairs.extend(data_pairs(&raw)?);
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
if flags.contains_key("post-process") {
|
|
pairs.push(pair("postProcess", "true"));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("replace_node", pairs)
|
|
}
|
|
|
|
fn map_delete(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let id = required_pos(positionals, 1, "Usage: op delete <node-id>")?;
|
|
let mut pairs = vec![pair("nodeId", id)];
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("delete_node", pairs)
|
|
}
|
|
|
|
fn map_read_nodes(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let mut pairs = Vec::new();
|
|
if let Some(ids) = positionals.get(1) {
|
|
pairs.push(pair("nodeIds", ids.clone()));
|
|
}
|
|
if let Some(depth) = flag_value(flags, "depth") {
|
|
pairs.push(pair("depth", depth));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
if flags.contains_key("vars") {
|
|
pairs.push(pair("includeVariables", "true"));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("read_nodes", pairs)
|
|
}
|
|
|
|
fn map_reparent(tool: &str, positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let id = required_pos(
|
|
positionals,
|
|
1,
|
|
"Usage: op move/copy <node-id> [--parent <parent-id>] [--page PAGE]",
|
|
)?;
|
|
let id_key = if tool == "copy_node" {
|
|
"sourceId"
|
|
} else {
|
|
"nodeId"
|
|
};
|
|
let parent = flag_value(flags, "parent").unwrap_or_else(|| "null".into());
|
|
let mut pairs = vec![pair(id_key, id), pair("parent", parent)];
|
|
if tool == "move_node" {
|
|
if let Some(index) = flag_value(flags, "index") {
|
|
pairs.push(pair("index", index));
|
|
}
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call(tool, pairs)
|
|
}
|
|
|
|
fn map_design_like(
|
|
tool: &str,
|
|
payload: Option<&String>,
|
|
flags: &Flags,
|
|
default_post_process: bool,
|
|
) -> Result<Command, String> {
|
|
let raw = resolve_arg(payload.map(String::as_str))?;
|
|
let trimmed = raw.trim();
|
|
let mut pairs = Vec::new();
|
|
if trimmed.starts_with('[') {
|
|
pairs.push(pair("nodes_json", trimmed));
|
|
} else {
|
|
pairs.push(pair("operations", trimmed));
|
|
}
|
|
if default_post_process {
|
|
pairs.push(pair("postProcess", "true"));
|
|
}
|
|
if let Some(canvas_width) = flag_value(flags, "canvas-width") {
|
|
pairs.push(pair("canvasWidth", canvas_width));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call(tool, pairs)
|
|
}
|
|
|
|
fn map_design_content(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let section_id = required_pos(
|
|
positionals,
|
|
1,
|
|
"Usage: op design:content <section-id> <json|@file|->",
|
|
)?;
|
|
let raw = resolve_arg(positionals.get(2).map(String::as_str))?;
|
|
let payload: Value = serde_json::from_str(raw.trim())
|
|
.map_err(|e| format!("invalid design:content JSON payload: {e}"))?;
|
|
let children = payload
|
|
.get("children")
|
|
.ok_or("design:content JSON payload must contain a children array")?;
|
|
if !children.is_array() {
|
|
return Err("design:content children must be an array".into());
|
|
}
|
|
|
|
let mut args = serde_json::Map::new();
|
|
args.insert("sectionId".into(), Value::String(section_id));
|
|
args.insert("children".into(), children.clone());
|
|
if let Some(canvas_width) = flag_value(flags, "canvas-width") {
|
|
let width = canvas_width
|
|
.parse::<i64>()
|
|
.map_err(|_| format!("--canvas-width must be an integer, got {canvas_width:?}"))?;
|
|
args.insert("canvasWidth".into(), Value::Number(width.into()));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
args.insert("pageId".into(), Value::String(page));
|
|
}
|
|
if let Some(file) = flag_value(flags, "file") {
|
|
args.insert("filePath".into(), resolve_file_path_value(&file));
|
|
}
|
|
let args_json = serde_json::to_string(&Value::Object(args))
|
|
.map_err(|e| format!("cannot serialize design:content args: {e}"))?;
|
|
Ok(Command::ToolCallJson {
|
|
tool: "design_content".into(),
|
|
args_json,
|
|
})
|
|
}
|
|
|
|
fn map_design_skeleton(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let raw = resolve_arg(positionals.get(1).map(String::as_str))?;
|
|
let payload: Value = serde_json::from_str(raw.trim())
|
|
.map_err(|e| format!("invalid design:skeleton JSON payload: {e}"))?;
|
|
let root_frame = payload
|
|
.get("rootFrame")
|
|
.ok_or("design:skeleton JSON payload must contain rootFrame")?;
|
|
if !root_frame.is_object() {
|
|
return Err("design:skeleton rootFrame must be an object".into());
|
|
}
|
|
let sections = payload
|
|
.get("sections")
|
|
.ok_or("design:skeleton JSON payload must contain sections")?;
|
|
if !sections.is_array() {
|
|
return Err("design:skeleton sections must be an array".into());
|
|
}
|
|
|
|
let mut args = serde_json::Map::new();
|
|
args.insert("rootFrame".into(), root_frame.clone());
|
|
args.insert("sections".into(), sections.clone());
|
|
if let Some(style_guide) = payload.get("styleGuide") {
|
|
if !style_guide.is_object() {
|
|
return Err("design:skeleton styleGuide must be an object".into());
|
|
}
|
|
args.insert("styleGuide".into(), style_guide.clone());
|
|
}
|
|
if let Some(canvas_width) = flag_value(flags, "canvas-width") {
|
|
let width = canvas_width
|
|
.parse::<i64>()
|
|
.map_err(|_| format!("--canvas-width must be an integer, got {canvas_width:?}"))?;
|
|
args.insert("canvasWidth".into(), Value::Number(width.into()));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
args.insert("pageId".into(), Value::String(page));
|
|
}
|
|
if let Some(file) = flag_value(flags, "file") {
|
|
args.insert("filePath".into(), resolve_file_path_value(&file));
|
|
}
|
|
let args_json = serde_json::to_string(&Value::Object(args))
|
|
.map_err(|e| format!("cannot serialize design:skeleton args: {e}"))?;
|
|
Ok(Command::ToolCallJson {
|
|
tool: "design_skeleton".into(),
|
|
args_json,
|
|
})
|
|
}
|
|
|
|
fn map_design_refine(flags: &Flags) -> Result<Command, String> {
|
|
let root_id = flag_value(flags, "root-id").ok_or("Usage: op design:refine --root-id <id>")?;
|
|
let mut pairs = vec![pair("rootId", root_id)];
|
|
if let Some(canvas_width) = flag_value(flags, "canvas-width") {
|
|
pairs.push(pair("canvasWidth", canvas_width));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("design_refine", pairs)
|
|
}
|
|
|
|
fn map_page(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let sub = positionals
|
|
.get(1)
|
|
.map(String::as_str)
|
|
.ok_or("Usage: op page list|add|remove|rename|reorder|duplicate ...")?;
|
|
match sub {
|
|
"list" => {
|
|
let mut pairs = Vec::new();
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("list_pages", pairs)
|
|
}
|
|
"add" => {
|
|
let name = flag_value(flags, "name").or_else(|| positionals.get(2).cloned());
|
|
let mut pairs = Vec::new();
|
|
if let Some(name) = name {
|
|
pairs.push(pair("name", name));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("add_page", pairs)
|
|
}
|
|
"remove" | "delete" => {
|
|
let page_id = required_pos(positionals, 2, "Usage: op page remove <page-id>")?;
|
|
let mut pairs = vec![pair("pageId", page_id)];
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("remove_page", pairs)
|
|
}
|
|
"rename" => {
|
|
let page_id = required_pos(positionals, 2, "Usage: op page rename <page-id> <name>")?;
|
|
let name = required_pos(positionals, 3, "Usage: op page rename <page-id> <name>")?;
|
|
let mut pairs = vec![pair("pageId", page_id), pair("name", name)];
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("rename_page", pairs)
|
|
}
|
|
"reorder" => {
|
|
let page_id = required_pos(positionals, 2, "Usage: op page reorder <page-id> <index>")?;
|
|
let index = required_pos(positionals, 3, "Usage: op page reorder <page-id> <index>")?;
|
|
let mut pairs = vec![pair("pageId", page_id), pair("index", index)];
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("reorder_page", pairs)
|
|
}
|
|
"duplicate" => {
|
|
let page_id = required_pos(positionals, 2, "Usage: op page duplicate <page-id>")?;
|
|
let mut pairs = vec![pair("pageId", page_id)];
|
|
if let Some(name) = flag_value(flags, "name") {
|
|
pairs.push(pair("name", name));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("duplicate_page", pairs)
|
|
}
|
|
_ => Err(format!("unknown page subcommand {sub:?}")),
|
|
}
|
|
}
|
|
|
|
fn map_vars_set(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let raw = resolve_arg(positionals.get(1).map(String::as_str))?;
|
|
let mut pairs = vec![pair("variables", raw)];
|
|
if flags.contains_key("replace") {
|
|
pairs.push(pair("replace", "true"));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("set_variables", pairs)
|
|
}
|
|
|
|
fn map_themes_set(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let raw = resolve_arg(positionals.get(1).map(String::as_str))?;
|
|
let mut pairs = vec![pair("themes", raw)];
|
|
if flags.contains_key("replace") {
|
|
pairs.push(pair("replace", "true"));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("set_themes", pairs)
|
|
}
|
|
|
|
fn map_theme_save(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let preset_path = required_pos(positionals, 1, "Usage: op theme:save <file.optheme>")?;
|
|
let mut pairs = vec![pair("presetPath", preset_path)];
|
|
if let Some(name) = flag_value(flags, "name") {
|
|
pairs.push(pair("name", name));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("save_theme_preset", pairs)
|
|
}
|
|
|
|
fn map_theme_load(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let preset_path = required_pos(positionals, 1, "Usage: op theme:load <file.optheme>")?;
|
|
let mut pairs = vec![pair("presetPath", preset_path)];
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("load_theme_preset", pairs)
|
|
}
|
|
|
|
fn map_theme_list(positionals: &[String]) -> Result<Command, String> {
|
|
let directory = required_pos(positionals, 1, "Usage: op theme:list <directory>")?;
|
|
tool_call("list_theme_presets", vec![pair("directory", directory)])
|
|
}
|
|
|
|
fn map_layout(flags: &Flags) -> Result<Command, String> {
|
|
let mut pairs = Vec::new();
|
|
if let Some(parent) = flag_value(flags, "parent") {
|
|
pairs.push(pair("parentId", parent));
|
|
}
|
|
if let Some(depth) = flag_value(flags, "depth") {
|
|
pairs.push(pair("maxDepth", depth));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("snapshot_layout", pairs)
|
|
}
|
|
|
|
fn map_find_space(flags: &Flags) -> Result<Command, String> {
|
|
let direction = flag_value(flags, "direction").unwrap_or_else(|| "right".into());
|
|
let width = flag_value(flags, "width").unwrap_or_else(|| "400".into());
|
|
let height = flag_value(flags, "height").unwrap_or_else(|| "300".into());
|
|
let mut pairs = vec![
|
|
pair("direction", direction),
|
|
pair("width", width),
|
|
pair("height", height),
|
|
];
|
|
if let Some(padding) = flag_value(flags, "padding") {
|
|
pairs.push(pair("padding", padding));
|
|
}
|
|
if let Some(node_id) = flag_value(flags, "node") {
|
|
pairs.push(pair("nodeId", node_id));
|
|
}
|
|
if let Some(node_id) = flag_value(flags, "node-id") {
|
|
pairs.push(pair("nodeId", node_id));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("find_empty_space", pairs)
|
|
}
|
|
|
|
fn map_import_svg(positionals: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let path = required_pos(
|
|
positionals,
|
|
1,
|
|
"Usage: op import:svg <file.svg> [--x N] [--y N] [--parent P] [--page PAGE]",
|
|
)?;
|
|
let mut pairs = vec![pair("svgPath", path)];
|
|
if let Some(x) = flag_value(flags, "x") {
|
|
pairs.push(pair("x", x));
|
|
}
|
|
if let Some(y) = flag_value(flags, "y") {
|
|
pairs.push(pair("y", y));
|
|
}
|
|
if let Some(parent) = flag_value(flags, "parent") {
|
|
pairs.push(pair("parent", parent));
|
|
}
|
|
if let Some(page) = flag_value(flags, "page") {
|
|
pairs.push(pair("pageId", page));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
tool_call("import_svg", pairs)
|
|
}
|
|
|
|
fn generic_tool_call(tool: &str, rest: &[String], flags: &Flags) -> Result<Command, String> {
|
|
let mut pairs = Vec::new();
|
|
for kv in rest {
|
|
let (k, v) = kv
|
|
.split_once('=')
|
|
.ok_or_else(|| format!("argument must be key=value, got {kv:?}"))?;
|
|
if k.is_empty() {
|
|
return Err(format!("argument has an empty key: {kv:?}"));
|
|
}
|
|
pairs.push(pair(k, v));
|
|
}
|
|
push_file_path(&mut pairs, flags);
|
|
for (k, v) in flags {
|
|
if is_global_compat_flag(k) {
|
|
continue;
|
|
}
|
|
pairs.push(pair(k, v.clone().unwrap_or_else(|| "true".into())));
|
|
}
|
|
tool_call(tool, pairs)
|
|
}
|
|
|
|
fn is_global_compat_flag(key: &str) -> bool {
|
|
["file", "page", "post-process", "canvas-width", "depth"].contains(&key)
|
|
}
|
|
|
|
fn parse_json_object(raw: &str) -> Result<Value, String> {
|
|
let value: Value =
|
|
serde_json::from_str(raw.trim()).map_err(|e| format!("invalid JSON payload: {e}"))?;
|
|
if !value.is_object() {
|
|
return Err("JSON payload must be an object".into());
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn data_pairs(raw: &str) -> Result<Vec<(String, String)>, String> {
|
|
parse_json_object(raw)?;
|
|
Ok(vec![pair("data", raw.trim())])
|
|
}
|
|
|
|
fn resolve_arg(arg: Option<&str>) -> Result<String, String> {
|
|
match arg {
|
|
Some("-") => {
|
|
let mut input = String::new();
|
|
io::stdin()
|
|
.read_to_string(&mut input)
|
|
.map_err(|e| format!("stdin read failed: {e}"))?;
|
|
if input.trim().is_empty() {
|
|
Err("No data received from stdin".into())
|
|
} else {
|
|
Ok(input.trim().to_string())
|
|
}
|
|
}
|
|
Some(path) if path.starts_with('@') => {
|
|
let path = &path[1..];
|
|
fs::read_to_string(path)
|
|
.map(|s| s.trim().to_string())
|
|
.map_err(|e| format!("cannot read file {path:?}: {e}"))
|
|
}
|
|
Some(value) => Ok(value.to_string()),
|
|
None => Err("No data provided. Pass as argument, @filepath, or '-' for stdin".into()),
|
|
}
|
|
}
|
|
|
|
fn required_pos(positionals: &[String], index: usize, usage: &str) -> Result<String, String> {
|
|
positionals
|
|
.get(index)
|
|
.cloned()
|
|
.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_design_tests;
|
|
#[cfg(test)]
|
|
mod cli_file_flag_tests;
|
|
#[cfg(test)]
|
|
mod cli_import_tests;
|
|
#[cfg(test)]
|
|
mod cli_node_tests;
|
|
#[cfg(test)]
|
|
mod cli_selection_tests;
|
|
#[cfg(test)]
|
|
mod tests;
|