fix(cli): add save command parity

This commit is contained in:
Kayshen-X 2026-06-02 09:41:48 +08:00
parent 68394826fe
commit 7ea28b32c4
7 changed files with 153 additions and 19 deletions

View file

@ -191,6 +191,10 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
.unwrap_or_default();
tool_call("open_document", args)
}
"save" => {
let file_path = required_pos(positionals, 1, "Usage: op save <file.op>")?;
tool_call("save_document", vec![pair("filePath", file_path)])
}
"get" => map_get(flags),
"selection" => tool_call("get_selection", vec![]),
"insert" => map_insert(positionals),
@ -222,7 +226,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
"codegen:plan" | "codegen:submit" | "codegen:assemble" | "codegen:clean" => {
codegen_cli::map_codegen(positionals, flags)
}
"start" | "stop" | "save" | "install" | "uninstall" => Err(format!(
"start" | "stop" | "install" | "uninstall" => Err(format!(
"TS command {:?} is not implemented by the Rust HTTP MCP CLI yet",
positionals[0]
)),

View file

@ -155,6 +155,18 @@ fn parse_args_maps_ts_open_to_open_document() {
);
}
#[test]
fn parse_args_maps_ts_save_to_save_document_tool() {
let p = parse_args(&["save".to_string(), "/tmp/copy.op".to_string()]).expect("parse save");
assert_eq!(
p.command,
Command::ToolCall {
tool: "save_document".to_string(),
args: vec![("filePath".to_string(), "/tmp/copy.op".to_string())],
}
);
}
#[test]
fn parse_args_maps_ts_page_list_alias_to_rust_tool() {
let args = vec!["page".to_string(), "list".to_string()];

View file

@ -1,10 +1,8 @@
//! Stdio MCP server mode for the desktop binary.
//!
//! When `openpencil-desktop --mcp <path>` is invoked, we skip the
//! winit event loop and run a JSON-RPC stdio server against the
//! `.op` file at `<path>`. External CLIs (Claude Code / Codex /
//! Gemini / Copilot) can spawn the binary in this mode to drive
//! the Rust editor exactly the way they drive TS pen-mcp today.
//! `openpencil-desktop --mcp <path>` runs JSON-RPC stdio against a
//! `.op` file. External CLIs can spawn this mode to drive the Rust
//! editor the way they drive TS pen-mcp today.
//! The server backs the `.op` file with an `op_editor_core::
//! EditorState` (the canonical `jian_ops_schema::PenDocument`), not
//! the old shell-core `Document`. Loading a `.op` into a `PenDocument`
@ -41,18 +39,19 @@ use op_mcp::{
redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot, rename_component_snapshot,
rename_page_snapshot, rename_variable_snapshot, reorder_page_snapshot,
reorder_selected_snapshot, replace_all_matching_properties_snapshot, replace_node_snapshot,
run_stdio_with_applier, save_theme_preset_snapshot, search_all_unique_properties_snapshot,
selection_snapshot, set_active_axis_value_snapshot, set_active_page_snapshot,
set_active_tool_snapshot, set_design_md_snapshot, set_ellipse_arc_snapshot,
set_node_collapsed_snapshot, set_node_corner_radius_snapshot, set_node_fill_hex_snapshot,
set_node_flip_snapshot, set_node_font_size_snapshot, set_node_font_weight_snapshot,
set_node_hidden_snapshot, set_node_locked_snapshot, set_node_name_snapshot,
set_node_rotation_snapshot, set_node_stroke_hex_snapshot, set_node_stroke_width_snapshot,
set_node_text_snapshot, set_selection_set_snapshot, set_selection_snapshot,
set_themes_snapshot, set_variable_boolean_snapshot, set_variable_color_snapshot,
set_variable_number_snapshot, set_variable_string_snapshot, set_variables_snapshot,
set_viewport_snapshot, snapshot_layout_snapshot, toggle_node_selection_snapshot, undo_snapshot,
ungroup_selected_snapshot, update_node_snapshot, ToolRegistry,
run_stdio_with_applier, save_document_snapshot, save_theme_preset_snapshot,
search_all_unique_properties_snapshot, selection_snapshot, set_active_axis_value_snapshot,
set_active_page_snapshot, set_active_tool_snapshot, set_design_md_snapshot,
set_ellipse_arc_snapshot, set_node_collapsed_snapshot, set_node_corner_radius_snapshot,
set_node_fill_hex_snapshot, set_node_flip_snapshot, set_node_font_size_snapshot,
set_node_font_weight_snapshot, set_node_hidden_snapshot, set_node_locked_snapshot,
set_node_name_snapshot, set_node_rotation_snapshot, set_node_stroke_hex_snapshot,
set_node_stroke_width_snapshot, set_node_text_snapshot, set_selection_set_snapshot,
set_selection_snapshot, set_themes_snapshot, set_variable_boolean_snapshot,
set_variable_color_snapshot, set_variable_number_snapshot, set_variable_string_snapshot,
set_variables_snapshot, set_viewport_snapshot, snapshot_layout_snapshot,
toggle_node_selection_snapshot, undo_snapshot, ungroup_selected_snapshot, update_node_snapshot,
ToolRegistry,
};
/// Load a `.op` file into an `EditorState`. The `.op` format is plain
@ -373,6 +372,7 @@ fn rebuild_registry(doc: &EditorState) -> ToolRegistry {
r.register(Box::new(tool));
}
r.register(Box::new(open_document_snapshot(doc)));
r.register(Box::new(save_document_snapshot(doc)));
r.register(Box::new(document_info_snapshot(doc)));
r.register(Box::new(selection_snapshot(doc)));
r.register(Box::new(get_node_snapshot(doc)));
@ -679,6 +679,7 @@ fn tools_list_response(id_raw: &str, state: &EditorState) -> String {
const TOOL_SCHEMAS: &[&str] = &[
// --- read tools ---
r#"{"name":"open_document","description":"Connect to the current Rust MCP document and return metadata, context summary, and design prompt. filePath is accepted for TS CLI compatibility; the Rust server remains bound to the document it was started with.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Accepted for TS compatibility; use live://canvas/current server document"}}}}"#,
r#"{"name":"save_document","description":"Save the current Rust MCP document snapshot to a .op file. Used by the Rust HTTP CLI to match TS `op save`.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Target .op file path"}},"required":["filePath"]}}"#,
r#"{"name":"get_document_info","description":"Summarize the open document (page count, active page, etc).","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
r#"{"name":"get_selection","description":"Return the current selection state (ids, count).","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
r#"{"name":"get_node","description":"Read a node by id with depth-limited descendants.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string","description":"u64 node id"}},"required":["node_id"]}}"#,

View file

@ -57,7 +57,7 @@ fn tools_list_response_includes_all_registered_tools() {
// TOOL_SCHEMAS without being added to the list below.
assert_eq!(
TOOL_SCHEMAS.len(),
105,
106,
"tools/list catalog count must match the registered tools — add the new tool to this test"
);
// Production catalog excludes debug tools (we removed the
@ -90,6 +90,7 @@ fn tools_list_response_includes_all_registered_tools() {
for name in [
"get_document_info",
"open_document",
"save_document",
"get_selection",
"get_node",
"list_pages",

View file

@ -0,0 +1,55 @@
//! Document save tool used by the Rust HTTP CLI transport.
use std::collections::BTreeMap;
use std::path::PathBuf;
use op_editor_core::EditorState;
use super::{McpTool, ToolErrorCode, ToolOutcome};
pub struct SaveDocument {
document_json: String,
}
impl McpTool for SaveDocument {
fn name(&self) -> &str {
"save_document"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
let Some(path) = args.get("filePath").filter(|path| !path.trim().is_empty()) else {
return ToolOutcome::Err(
ToolErrorCode::MissingArgument,
"filePath is required".into(),
);
};
let path = resolve_path(path);
if let Err(e) = std::fs::write(&path, &self.document_json) {
return ToolOutcome::Err(
ToolErrorCode::ToolFailed,
format!("save document failed: {e}"),
);
}
let mut out = BTreeMap::new();
out.insert("ok".into(), "true".into());
out.insert("filePath".into(), path.display().to_string());
ToolOutcome::Ok(out)
}
}
pub fn save_document_snapshot(state: &EditorState) -> SaveDocument {
SaveDocument {
document_json: serde_json::to_string(&state.doc).unwrap_or_else(|_| "{}".into()),
}
}
fn resolve_path(raw: &str) -> PathBuf {
let path = PathBuf::from(raw);
if path.is_absolute() {
path
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}

View file

@ -0,0 +1,57 @@
//! Document save tool tests.
use std::collections::BTreeMap;
use std::path::PathBuf;
use super::test_fixtures::sample;
use super::{save_document_snapshot, McpTool, ToolErrorCode, ToolOutcome};
#[test]
fn save_document_writes_current_pen_document_to_target_path() {
let state = sample();
let dir = temp_dir("save-document");
std::fs::create_dir_all(&dir).expect("temp dir");
let out_path = dir.join("copy.op");
let tool = save_document_snapshot(&state);
let mut args = BTreeMap::new();
args.insert("filePath".into(), out_path.display().to_string());
match tool.call(&args) {
ToolOutcome::Ok(out) => {
assert_eq!(out.get("ok"), Some(&"true".to_string()));
assert_eq!(out.get("filePath"), Some(&out_path.display().to_string()));
let saved: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&out_path).expect("saved document"))
.expect("saved document json");
assert_eq!(saved["version"], state.doc.version);
assert!(saved["children"].is_array() || saved["pages"].is_array());
}
other => panic!("expected save ok, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_document_requires_file_path() {
let tool = save_document_snapshot(&sample());
match tool.call(&BTreeMap::new()) {
ToolOutcome::Err(code, message) => {
assert_eq!(code, ToolErrorCode::MissingArgument);
assert!(message.contains("filePath"));
}
other => panic!("expected missing argument, got {other:?}"),
}
}
fn temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
std::env::temp_dir().join(format!(
"openpencil-document-save-{label}-{}-{nanos}",
std::process::id()
))
}

View file

@ -41,6 +41,9 @@ mod design_md_tools_tests;
pub mod design_prompt;
#[cfg(test)]
mod design_prompt_tests;
pub mod document_save;
#[cfg(test)]
mod document_save_tests;
pub mod element_tools;
pub mod extra_read_tools;
#[cfg(test)]
@ -126,6 +129,7 @@ pub use design_md_tools::{
GetDesignMd, SetDesignMd,
};
pub use design_prompt::{get_design_prompt_snapshot, GetDesignPrompt};
pub use document_save::{save_document_snapshot, SaveDocument};
pub use extra_read_tools::{get_node_children_snapshot, ChildRecord, GetNodeChildren};
pub use json_serializer::response_to_json;
pub use node_attr_tools::{