fix(cli): add save command parity
This commit is contained in:
parent
68394826fe
commit
7ea28b32c4
|
|
@ -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]
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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"]}}"#,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
55
crates/op-mcp/src/document_save.rs
Normal file
55
crates/op-mcp/src/document_save.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
57
crates/op-mcp/src/document_save_tests.rs
Normal file
57
crates/op-mcp/src/document_save_tests.rs
Normal 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()
|
||||
))
|
||||
}
|
||||
|
|
@ -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::{
|
||||
|
|
|
|||
Loading…
Reference in a new issue