diff --git a/crates/op-cli/src/cli_export_tests.rs b/crates/op-cli/src/cli_export_tests.rs index 237c7f227..a60ed8c2c 100644 --- a/crates/op-cli/src/cli_export_tests.rs +++ b/crates/op-cli/src/cli_export_tests.rs @@ -195,3 +195,38 @@ fn export_deck_rejects_a_node_export_format() { .to_string(); assert!(error.contains("unsupported deck format"), "{error}"); } + +#[test] +fn use_template_accepts_a_bare_id_or_a_flag() { + for argv in [ + vec!["use-template", "slide-deck"], + vec!["use-template", "--template", "slide-deck"], + ] { + let parsed = parse_args(&args(&argv)).expect("parse use-template"); + assert!(matches!( + parsed.command, + Command::UseTemplate { ref template_id } if template_id == "slide-deck" + )); + } + assert!(parse_args(&args(&["use-template"])) + .unwrap_err() + .to_string() + .contains("template id")); +} + +#[test] +fn templates_filters_are_optional() { + let bare = parse_args(&args(&["templates"])).expect("parse templates"); + assert!(matches!( + bare.command, + Command::Templates { + scene: None, + tag: None + } + )); + let filtered = parse_args(&args(&["templates", "--scene", "slides"])).expect("parse filter"); + assert!(matches!( + filtered.command, + Command::Templates { scene: Some(ref scene), .. } if scene == "slides" + )); +} diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index cfa771db8..5b966fadb 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -18,6 +18,7 @@ mod path_args; mod skill_export_cli; mod skill_install_cli; mod skill_install_error; +mod template_cli; use cli_error::CliError; @@ -129,6 +130,15 @@ fn run(args: &[String]) -> Result { Command::ExportDeck { output, format } => { export_cli::run_export_deck(target_port, &target_token, &output, &format)? } + Command::Templates { scene, tag } => template_cli::run_templates( + target_port, + &target_token, + scene.as_deref(), + tag.as_deref(), + )?, + Command::UseTemplate { template_id } => { + template_cli::run_use_template(target_port, &target_token, &template_id)? + } Command::Export { item_id, selection: _, @@ -218,6 +228,13 @@ enum Command { output: String, format: String, }, + Templates { + scene: Option, + tag: Option, + }, + UseTemplate { + template_id: String, + }, } type Flags = BTreeMap>; @@ -343,6 +360,8 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result Ok(Command::StopMcp), "export" => export_cli::map_export(flags), "export-deck" => export_cli::map_export_deck(flags), + "templates" => template_cli::map_templates(flags), + "use-template" => template_cli::map_use_template(flags, positionals), "skill:export" => Ok(Command::SkillExport { name: required_pos( positionals, diff --git a/crates/op-cli/src/template_cli.rs b/crates/op-cli/src/template_cli.rs new file mode 100644 index 000000000..91e97acec --- /dev/null +++ b/crates/op-cli/src/template_cli.rs @@ -0,0 +1,68 @@ +//! `op templates` and `op use-template` — the shipped scene-template +//! catalogue, including the 16:9 presentation deck templates. +//! +//! Both are thin passes over the matching MCP tools; the catalogue itself is +//! resolved editor-side so the CLI never carries a second copy of it. + +use serde_json::Value; + +use crate::cli_error::CliError; +use crate::command_helpers::flag_value; +use crate::mcp_http_cli::{post, tool_call_body}; +use crate::{Command, Flags}; + +pub(crate) fn map_templates(flags: &Flags) -> Result { + Ok(Command::Templates { + scene: flag_value(flags, "scene"), + tag: flag_value(flags, "tag"), + }) +} + +pub(crate) fn map_use_template(flags: &Flags, positionals: &[String]) -> Result { + // Accept both `op use-template ` and `--template `: the bare + // positional is what a person types, the flag is what a script generates. + // `positionals[0]` is the command word itself, so the id is at index 1. + let template_id = positionals + .get(1) + .cloned() + .or_else(|| flag_value(flags, "template")) + .ok_or_else(|| CliError::usage("a template id is required (op templates lists them)"))?; + Ok(Command::UseTemplate { template_id }) +} + +pub(crate) fn run_templates( + port: u16, + token: &str, + scene: Option<&str>, + tag: Option<&str>, +) -> Result { + let mut arguments = serde_json::Map::new(); + if let Some(scene) = scene { + arguments.insert("scene".into(), Value::String(scene.into())); + } + if let Some(tag) = tag { + arguments.insert("tag".into(), Value::String(tag.into())); + } + post( + port, + token, + &tool_call_body( + "list_scene_templates", + &Value::Object(arguments).to_string(), + ), + ) +} + +pub(crate) fn run_use_template( + port: u16, + token: &str, + template_id: &str, +) -> Result { + let mut arguments = serde_json::Map::new(); + arguments.insert("templateId".into(), Value::String(template_id.into())); + post( + port, + token, + &tool_call_body("use_scene_template", &Value::Object(arguments).to_string()), + ) +} diff --git a/crates/op-cli/src/usage.txt b/crates/op-cli/src/usage.txt index 4a23a2634..b7758076e 100644 --- a/crates/op-cli/src/usage.txt +++ b/crates/op-cli/src/usage.txt @@ -28,6 +28,12 @@ COMMON COMMANDS: export the active page's boards as a presentation deck: PowerPoint, self- contained HTML, or slide-per-page PDF + op templates [--scene S] [--tag T] list shipped scene templates + (tutorial / comparison / carousel / + slides / …), including 16:9 decks + op use-template start from a scene template: takes + over a blank starter page, otherwise + appends its boards op get [--type T] [--name N] [--id ID] [--depth N] [--parent P] op selection get current selection op insert [--parent P] [--page PAGE] [--post-process] diff --git a/crates/op-editor-core/src/collab_gate.rs b/crates/op-editor-core/src/collab_gate.rs index 9ae4f318c..a60730e63 100644 --- a/crates/op-editor-core/src/collab_gate.rs +++ b/crates/op-editor-core/src/collab_gate.rs @@ -424,6 +424,7 @@ impl EditorCommand { | C::InsertSubtree { .. } | C::InsertAuthoredSubtree { .. } | C::InsertAuthoredSubtreePreservingRoots { .. } + | C::AdoptSceneTemplate { .. } | C::RefineDesign { .. } | C::Batch { .. } | C::ReplaceAllMatchingProperties { .. } diff --git a/crates/op-editor-core/src/command.rs b/crates/op-editor-core/src/command.rs index b18a04753..b557e0849 100644 --- a/crates/op-editor-core/src/command.rs +++ b/crates/op-editor-core/src/command.rs @@ -270,6 +270,16 @@ pub enum EditorCommand { /// or legacy page index. page_id: Option, }, + /// Bring a shipped scene template into the document by catalogue id. + /// + /// The boards and the palette they depend on travel together, which is + /// why this is its own command rather than an authored-subtree insert: + /// a template's variables have to land in the same transaction as its + /// frames, or the boards resolve against a palette that is not there. + AdoptSceneTemplate { + /// Catalogue id, as listed by `scene_template_catalogue`. + template_id: String, + }, /// Run deterministic post-generation cleanup for a layered design /// root. Unlike most write commands, a valid root with no needed /// edits is still accepted so `design_refine` can be idempotent. diff --git a/crates/op-editor-core/src/command_apply.rs b/crates/op-editor-core/src/command_apply.rs index 440d22060..73993fbfa 100644 --- a/crates/op-editor-core/src/command_apply.rs +++ b/crates/op-editor-core/src/command_apply.rs @@ -309,6 +309,23 @@ impl EditorState { parent_id, page_id, } => apply_authored_subtree_on_page(self, nodes, &parent_id, page_id.as_deref(), true), + EditorCommand::AdoptSceneTemplate { template_id } => { + // A missing document is a corrupt or renamed asset rather + // than a user error, and on wasm it can simply not be + // fetched yet; either way there is nothing to apply and the + // document is left untouched. + let Some(source) = + crate::scene_template_catalog::scene_template_document(&template_id) + else { + return Ok(false); + }; + let Some(boards) = + crate::scene_template_append::template_boards(source, &template_id) + else { + return Ok(false); + }; + self.adopt_template_boards(boards) + } EditorCommand::RefineDesign { root_id, canvas_width, diff --git a/crates/op-editor-core/src/command_batch.rs b/crates/op-editor-core/src/command_batch.rs index e533cf97a..fc6c42f37 100644 --- a/crates/op-editor-core/src/command_batch.rs +++ b/crates/op-editor-core/src/command_batch.rs @@ -79,6 +79,7 @@ fn batchable(cmd: &EditorCommand) -> bool { | C::InsertSubtree { .. } | C::InsertAuthoredSubtree { .. } | C::InsertAuthoredSubtreePreservingRoots { .. } + | C::AdoptSceneTemplate { .. } | C::RefineDesign { .. } | C::SetVariableScalar { .. } | C::CreateVariable { .. } diff --git a/crates/op-editor-core/src/scene_template_append_tests.rs b/crates/op-editor-core/src/scene_template_append_tests.rs index 2dc6faf7e..05def4c46 100644 --- a/crates/op-editor-core/src/scene_template_append_tests.rs +++ b/crates/op-editor-core/src/scene_template_append_tests.rs @@ -256,3 +256,42 @@ fn variable_references(serialized: &str) -> Vec { } found } + +#[test] +fn adopting_a_catalogue_template_by_command_brings_its_boards_and_palette() { + // The command exists because boards and palette must land together: + // applying it has to leave both, or the frames resolve against a + // palette that is not there. + let template = crate::scene_template_catalog::scene_template_catalogue() + .iter() + .find(|template| template.style_guide.is_some()) + .expect("catalogue ships at least one template carrying a palette"); + + let mut state = EditorState::new(); + let variables_before = state.doc.variables.as_ref().map_or(0, BTreeMap::len); + let changed = state.apply(crate::EditorCommand::AdoptSceneTemplate { + template_id: template.id.clone(), + }); + + assert!(changed, "adopting {} changed nothing", template.id); + assert!( + !state.active_children().is_empty(), + "adopting {} left no boards", + template.id + ); + assert!( + state.doc.variables.as_ref().map_or(0, BTreeMap::len) > variables_before, + "adopting {} brought no palette", + template.id + ); +} + +#[test] +fn adopting_an_unknown_template_id_leaves_the_document_alone() { + let mut state = EditorState::new(); + let changed = state.apply(crate::EditorCommand::AdoptSceneTemplate { + template_id: "no-such-template".to_owned(), + }); + assert!(!changed); + assert!(state.active_children().is_empty()); +} diff --git a/crates/op-host-services/src/mcp_serve.rs b/crates/op-host-services/src/mcp_serve.rs index 59ffa552b..97a427662 100644 --- a/crates/op-host-services/src/mcp_serve.rs +++ b/crates/op-host-services/src/mcp_serve.rs @@ -640,6 +640,8 @@ fn rebuild_registry(doc: &EditorState, requested_tool: Option<&str>) -> ToolRegi register_tool!("export_item", export_item_snapshot(doc)); register_tool!("export_nodes", export_nodes_snapshot(doc)); register_tool!("export_deck", export_deck_snapshot(doc)); + register_tool!("list_scene_templates", list_scene_templates_snapshot()); + register_tool!("use_scene_template", use_scene_template_snapshot()); register_tool!("get_active_theme", get_active_theme_snapshot(doc)); register_tool!("list_components", list_components_snapshot(doc)); register_tool!("get_component", get_component_snapshot(doc)); @@ -791,6 +793,8 @@ pub(crate) mod export_item_tool; use export_item_tool::export_item_snapshot; pub(crate) mod export_deck_tool; use export_deck_tool::export_deck_snapshot; +pub(crate) mod scene_template_tools; +use scene_template_tools::{list_scene_templates_snapshot, use_scene_template_snapshot}; #[cfg(test)] mod codegen_wire_tests; diff --git a/crates/op-host-services/src/mcp_serve/scene_template_tools.rs b/crates/op-host-services/src/mcp_serve/scene_template_tools.rs new file mode 100644 index 000000000..e520a8046 --- /dev/null +++ b/crates/op-host-services/src/mcp_serve/scene_template_tools.rs @@ -0,0 +1,120 @@ +//! MCP tools `list_scene_templates` and `use_scene_template` — the shipped +//! scene-template catalogue, including the 16:9 deck templates. +//! +//! The catalogue has been in the editor since `v0.8.3` behind File ▸ New from +//! template, so an agent could only ever start from a blank frame. Listing it +//! is what lets one start from a real layout, and the deck templates are the +//! entry point to the whole presentation workflow. +//! +//! `use_scene_template` returns a command rather than mutating: the boards and +//! the palette they resolve against have to land in one transaction, which is +//! exactly what `EditorCommand::AdoptSceneTemplate` gives. On an untouched +//! starter page the template takes the page over; anywhere else it appends to +//! the right of what is already there. + +use std::collections::BTreeMap; + +use op_editor_core::scene_template_catalog::{scene_template_by_id, scene_template_catalogue}; +use op_editor_core::EditorCommand; +use op_mcp::{McpTool, ToolErrorCode, ToolOutcome}; + +pub struct ListSceneTemplates; + +impl McpTool for ListSceneTemplates { + fn name(&self) -> &str { + "list_scene_templates" + } + + fn call(&self, args: &BTreeMap) -> ToolOutcome { + let scene_filter = args + .get("scene") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + let tag_filter = args + .get("tag") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + + let templates: Vec = scene_template_catalogue() + .iter() + .filter(|template| { + scene_filter.is_none_or(|scene| scene.eq_ignore_ascii_case(template.scene.as_str())) + }) + .filter(|template| { + tag_filter.is_none_or(|tag| { + template + .tags + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(tag)) + }) + }) + .map(|template| { + serde_json::json!({ + "id": template.id, + "scene": template.scene.as_str(), + "title": template.title_fallback, + "summary": template.summary_fallback, + "tags": template.tags, + "frames": template.frames, + "frameWidth": template.frame_width, + "frameHeight": template.frame_height, + // Absent rather than null when a template carries no + // guide: that is the gate on generating from it, not a + // missing field. + "styleGuide": template.style_guide, + }) + }) + .collect(); + + ToolOutcome::OkJson(serde_json::json!({ "templates": templates }).to_string()) + } +} + +pub struct UseSceneTemplate; + +impl McpTool for UseSceneTemplate { + fn name(&self) -> &str { + "use_scene_template" + } + + fn call(&self, args: &BTreeMap) -> ToolOutcome { + let template_id = match args.get("templateId").map(|value| value.trim()) { + Some(id) if !id.is_empty() => id, + _ => { + return ToolOutcome::Err( + ToolErrorCode::MissingArgument, + "templateId is required — call list_scene_templates for the catalogue".into(), + ); + } + }; + // Validate here so an unknown id is a named argument error rather + // than a command the host silently applies as a no-op. + let Some(template) = scene_template_by_id(template_id) else { + return ToolOutcome::Err( + ToolErrorCode::InvalidArgument, + format!("unknown template id {template_id:?}"), + ); + }; + ToolOutcome::OkJsonWithCommand( + serde_json::json!({ + "id": template.id, + "title": template.title_fallback, + "frames": template.frames, + "frameWidth": template.frame_width, + "frameHeight": template.frame_height, + }) + .to_string(), + EditorCommand::AdoptSceneTemplate { + template_id: template.id.clone(), + }, + ) + } +} + +pub fn list_scene_templates_snapshot() -> ListSceneTemplates { + ListSceneTemplates +} + +pub fn use_scene_template_snapshot() -> UseSceneTemplate { + UseSceneTemplate +} diff --git a/crates/op-host-services/src/mcp_serve/schemas.rs b/crates/op-host-services/src/mcp_serve/schemas.rs index 2b531a2e6..69b1ed68d 100644 --- a/crates/op-host-services/src/mcp_serve/schemas.rs +++ b/crates/op-host-services/src/mcp_serve/schemas.rs @@ -31,6 +31,8 @@ pub const TOOL_SCHEMAS: &[&str] = &[ r#"{"name":"export_item","description":"Export a page, arbitrary node, or current selection to base64-encoded image/PDF bytes.","inputSchema":{"type":"object","properties":{"itemId":{"type":"string"},"format":{"type":"string","enum":["png","jpeg","webp","pdf"]},"scale":{"type":"number"}},"required":["format"]}}"#, r#"{"name":"export_nodes","description":"Export one or more nodes to base64-encoded image/PDF bytes. format is one of png|jpeg|webp|pdf.","inputSchema":{"type":"object","properties":{"nodeIds":{"type":"array","items":{"type":"string"}},"format":{"type":"string","enum":["png","jpeg","webp","pdf"]},"scale":{"type":"number"}},"required":["nodeIds","format"]}}"#, r#"{"name":"export_deck","description":"Export the active page's boards as a presentation deck in PowerPoint, self-contained HTML, or PDF. Writes a file at outputPath; use export_item/export_nodes for base64 node-level exports.","inputSchema":{"type":"object","properties":{"format":{"type":"string","enum":["pptx","html","pdf"]},"outputPath":{"type":"string","description":"Destination file path for the deck"}},"required":["format","outputPath"]}}"#, + r#"{"name":"list_scene_templates","description":"List the shipped scene templates, including the 16:9 presentation deck templates. Optionally filter by scene or tag.","inputSchema":{"type":"object","properties":{"scene":{"type":"string","description":"Scene filter, e.g. slides, tutorial, comparison, carousel"},"tag":{"type":"string","description":"Tag filter"}}}}"#, + r#"{"name":"use_scene_template","description":"Start from a shipped scene template. On an untouched starter page the template takes the page over; otherwise its boards are appended to the right. Call list_scene_templates for ids.","inputSchema":{"type":"object","properties":{"templateId":{"type":"string"}},"required":["templateId"]}}"#, r#"{"name":"get_active_theme","description":"Return the active theme axis pinning per axis.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"list_components","description":"List registered components (saved Frames / Groups promoted via Save as Component). Returns count + a `;`-separated record of `name|id` pairs.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"get_component","description":"Fetch one component by id with detail: name, root node kind, and the subtree's leaf count.","inputSchema":{"type":"object","properties":{"component_id":{"type":"string","description":"positive u64 component id"}},"required":["component_id"]}}"#, diff --git a/crates/op-host-services/src/mcp_serve/tests.rs b/crates/op-host-services/src/mcp_serve/tests.rs index 9bcb29e37..56bc00cf9 100644 --- a/crates/op-host-services/src/mcp_serve/tests.rs +++ b/crates/op-host-services/src/mcp_serve/tests.rs @@ -24,7 +24,7 @@ fn tools_list_response_includes_all_registered_tools() { // TOOL_SCHEMAS without being added to the list below. assert_eq!( TOOL_SCHEMAS.len(), - 124, + 126, "tools/list catalog count must match the registered tools — add the new tool to this test" ); // Production catalog excludes debug tools (we removed the diff --git a/crates/op-host-services/src/mcp_serve/tool_profile.rs b/crates/op-host-services/src/mcp_serve/tool_profile.rs index 444a91e01..12c50f2cc 100644 --- a/crates/op-host-services/src/mcp_serve/tool_profile.rs +++ b/crates/op-host-services/src/mcp_serve/tool_profile.rs @@ -380,6 +380,16 @@ pub const TOOL_PROFILES: &[ToolProfile] = &[ ToolAccess::Read, ToolSurface::LocalFilesystem, ), + ToolProfile::new( + "list_scene_templates", + ToolAccess::Read, + ToolSurface::InMemory, + ), + ToolProfile::new( + "use_scene_template", + ToolAccess::Write, + ToolSurface::InMemory, + ), ToolProfile::new("export_item", ToolAccess::Read, ToolSurface::InMemory), ToolProfile::new("export_nodes", ToolAccess::Read, ToolSurface::InMemory), ToolProfile::new("find_empty_space", ToolAccess::Read, ToolSurface::InMemory),