Files
mnote/rust/crates/mnote-cli/src/lib.rs
T

2330 lines
74 KiB
Rust
Raw Normal View History

use bridge_runtime::{
build_query_request, build_write_request, execute_runtime_query, BridgeContext, BridgeError,
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, RuntimeSourceWire, RuntimeTargetWire,
RuntimeToolInvocationWire,
};
use clap::ValueEnum;
2026-04-15 20:01:12 +08:00
use core_protocol::{
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
ListSidebarDataset, MindmapOp, MindmapTreeNode, MoveDocumentPage, PatchPageBlock, PutMindmap,
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
2026-04-15 20:01:12 +08:00
};
use mnote_editor_core::{
apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock,
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
VisibilitySnapshot,
};
2026-04-15 20:01:12 +08:00
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliContext {
pub actor_id: String,
pub actor_type: String,
pub session_id: Option<String>,
pub reason: Option<String>,
pub idempotency_key: Option<String>,
pub validate_only: bool,
pub dry_run: bool,
}
impl Default for CliContext {
fn default() -> Self {
Self {
actor_id: "cli_user".into(),
actor_type: "human".into(),
session_id: None,
reason: None,
idempotency_key: None,
validate_only: false,
dry_run: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliError {
pub code: &'static str,
pub message: String,
}
impl CliError {
pub fn validation(message: impl Into<String>) -> Self {
Self {
code: "VALIDATION_ERROR",
message: message.into(),
}
}
pub fn transport(message: impl Into<String>) -> Self {
Self {
code: "TRANSPORT_ERROR",
message: message.into(),
}
}
2026-04-15 20:01:12 +08:00
}
pub type CliResult<T> = Result<T, CliError>;
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CliJsonOutput {
pub ok: bool,
pub entrypoint: &'static str,
pub domain: String,
pub action: String,
pub context: CliOutputContext,
pub operation: CliOperationOutput,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CliOutputContext {
pub request_id: String,
pub trace_id: String,
pub actor_id: String,
pub actor_type: String,
pub session_id: Option<String>,
2026-05-06 21:44:20 +08:00
pub reason: Option<String>,
2026-04-15 20:01:12 +08:00
pub workspace_id: Option<String>,
pub idempotency_key: Option<String>,
pub validate_only: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum CliOperationOutput {
Command {
name: String,
command_id: String,
normalized_input: Value,
transport: CliTransportPlan,
#[serde(skip_serializing_if = "Option::is_none")]
execution: Option<Value>,
2026-04-15 20:01:12 +08:00
},
Query {
name: String,
normalized_input: Value,
transport: CliTransportPlan,
#[serde(skip_serializing_if = "Option::is_none")]
execution: Option<Value>,
2026-04-15 20:01:12 +08:00
},
Tool {
tool_name: String,
invocation_kind: String,
execution_mode: String,
toolset_id: String,
effect: String,
requires_confirmation: bool,
normalized_input: Value,
#[serde(skip_serializing_if = "Option::is_none")]
execution: Option<Value>,
2026-04-15 20:01:12 +08:00
},
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CliTransportPlan {
pub kind: String,
pub function_name: String,
pub payload_json: String,
pub args_json: Value,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCliOutput {
pub ok: bool,
pub entrypoint: &'static str,
pub operation: &'static str,
pub block_count: usize,
pub output_markdown: String,
pub visible_block_ids: Vec<String>,
pub outline: Vec<EditorOutlineItem>,
pub audit: Vec<EditorAuditItem>,
pub change_report: EditorChangeReportOutput,
#[serde(skip_serializing_if = "Option::is_none")]
pub undo_applied: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redo_applied: Option<bool>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorOutlineItem {
pub block_id: String,
pub level: u8,
pub title: String,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAuditItem {
pub command: String,
pub target_block_id: Option<String>,
pub before: Option<EditorBlockSnapshot>,
pub after: Option<EditorBlockSnapshot>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorBlockSnapshot {
pub block_id: String,
pub block_type: String,
pub text: String,
pub parent_id: Option<String>,
pub indent: u16,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct EditorChangeReportOutput {
#[serde(default)]
pub created_blocks: Vec<String>,
#[serde(default)]
pub updated_blocks: Vec<String>,
#[serde(default)]
pub moved_blocks: Vec<String>,
#[serde(default)]
pub removed_blocks: Vec<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum EditorInputKindArg {
PlainText,
Markdown,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum EditorAiScenarioArg {
MeetingNotesToTodos,
LongParagraphToTitle,
PageReorder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageCreateArgs<'a> {
pub page_id: &'a str,
pub workspace_id: &'a str,
pub parent_id: Option<&'a str>,
pub title: &'a str,
pub access_scope: &'a str,
pub content_json: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageMoveArgs<'a> {
pub page_id: &'a str,
pub workspace_id: Option<&'a str>,
pub parent_id: Option<&'a str>,
pub sort_order: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageDeleteArgs<'a> {
pub page_id: &'a str,
pub workspace_id: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageRestoreArgs<'a> {
pub page_id: &'a str,
pub workspace_id: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockMoveArgs<'a> {
pub source_document_id: &'a str,
pub block_id: &'a str,
pub target_document_id: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockEmbedArgs<'a> {
pub source_document_id: &'a str,
pub block_id: &'a str,
pub target_document_id: &'a str,
pub target_block_id: Option<&'a str>,
}
2026-04-15 20:01:12 +08:00
pub fn supported_command_surface() -> Vec<&'static str> {
let mut surface = vec![
"page get",
"page title",
"page save",
"page create",
"page move",
"page delete",
"page restore",
2026-04-15 20:01:12 +08:00
"block insert",
"block patch",
"block move",
"block embed",
2026-04-15 20:01:12 +08:00
"mindmap get",
"mindmap put",
"mindmap op",
"search documents",
"search blocks",
"sidebar dataset",
"editor markdown-roundtrip",
"editor session-demo",
2026-04-15 20:01:12 +08:00
"tool run",
];
for tool_name in default_tool_registry().tool_names() {
surface.push(Box::leak(
format!("tool run --tool-name {tool_name}").into_boxed_str(),
));
2026-04-15 20:01:12 +08:00
}
surface
}
pub fn supported_json_contracts() -> Vec<&'static str> {
let mut contracts = vec![
"page.get",
"page.title",
"page.save",
"page.create",
"page.move",
"page.delete",
"page.restore",
2026-04-15 20:01:12 +08:00
"block.insert",
"block.patch",
"block.move",
"block.embed",
2026-04-15 20:01:12 +08:00
"mindmap.get",
"mindmap.put",
"mindmap.op",
"search.documents",
"search.blocks",
"sidebar.dataset",
"editor.markdown_roundtrip",
"editor.session_demo",
2026-04-15 20:01:12 +08:00
"tool.run",
];
for tool_name in default_tool_registry().tool_names() {
contracts.push(Box::leak(format!("tool.{tool_name}").into_boxed_str()));
}
contracts
}
pub fn editor_markdown_roundtrip(markdown: &str) -> CliResult<EditorCliOutput> {
let document = import_markdown(markdown).map_err(map_editor_error)?;
let projection = VisibilitySnapshot::derive(&document);
Ok(build_editor_output(
"markdown_roundtrip",
document.blocks().len(),
export_markdown(&document),
&projection,
Vec::new(),
EditorChangeReportOutput::default(),
None,
None,
))
}
pub fn editor_ai_pipeline(
kind: EditorInputKind,
scenario: EditorAiScenario,
input: &str,
) -> CliResult<EditorCliOutput> {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind,
scenario,
input: input.to_string(),
})
.map_err(map_editor_error)?;
let projection = VisibilitySnapshot::derive(&result.document);
Ok(build_editor_output(
"ai_pipeline",
result.document.blocks().len(),
result.markdown,
&projection,
result
.audit
.into_iter()
.map(|item| EditorAuditItem {
command: item.command,
target_block_id: item.target_block_id,
before: item.before.map(|snapshot| EditorBlockSnapshot {
block_id: snapshot.block_id,
block_type: snapshot.block_type,
text: snapshot.text,
parent_id: snapshot.parent_id,
indent: snapshot.indent,
}),
after: item.after.map(|snapshot| EditorBlockSnapshot {
block_id: snapshot.block_id,
block_type: snapshot.block_type,
text: snapshot.text,
parent_id: snapshot.parent_id,
indent: snapshot.indent,
}),
})
.collect(),
EditorChangeReportOutput {
created_blocks: result.change_report.created_blocks,
updated_blocks: result.change_report.updated_blocks,
moved_blocks: result.change_report.moved_blocks,
removed_blocks: result.change_report.removed_blocks,
notes: result.change_report.notes,
},
None,
None,
))
}
pub fn editor_session_demo(markdown: &str) -> CliResult<EditorCliOutput> {
let document = import_markdown(markdown).map_err(map_editor_error)?;
let bootstrap = if document.blocks().is_empty() {
mnote_editor_core::DocumentModel::new(vec![DocumentBlock::new(
"demo_root",
BlockType::Paragraph,
)
.with_text("demo root")])
} else {
document
};
let mut session = EditorSession::new(bootstrap);
let primary_block_id = session
.document()
.blocks()
.first()
.map(|block| block.id.clone())
.ok_or_else(|| CliError::validation("session demo 缺少可编辑块"))?;
let original_text = session
.document()
.block(&primary_block_id)
.map(|block| block.content.text.clone())
.unwrap_or_default();
let next_text = if original_text.trim().is_empty() {
"demo edited".to_string()
} else {
format!("{original_text} [edited]")
};
session
.apply_command(EditorCommand::ReplaceBlock {
block_id: primary_block_id.clone(),
text: next_text,
})
.map_err(map_editor_error)?;
session
.apply_command(EditorCommand::InsertBlockAfter {
after_block_id: Some(primary_block_id),
block: DocumentBlock::new("cli_demo_block", BlockType::Paragraph)
.with_text("CLI demo block"),
})
.map_err(map_editor_error)?;
let undo_applied = session.undo();
let redo_applied = session.redo();
let projection = VisibilitySnapshot::derive(session.document());
Ok(build_editor_output(
"session_demo",
session.document().blocks().len(),
export_markdown(session.document()),
&projection,
Vec::new(),
EditorChangeReportOutput::default(),
Some(undo_applied),
Some(redo_applied),
))
}
pub fn render_editor_plain_output(output: &EditorCliOutput) -> String {
format!(
"entrypoint={} operation={} block_count={} visible_blocks={} audit_count={} updated_blocks={} undo_applied={} redo_applied={}",
output.entrypoint,
output.operation,
output.block_count,
output.visible_block_ids.join(","),
output.audit.len(),
output.change_report.updated_blocks.join(","),
output.undo_applied.unwrap_or(false),
output.redo_applied.unwrap_or(false),
)
}
fn build_editor_output(
operation: &'static str,
block_count: usize,
output_markdown: String,
projection: &VisibilitySnapshot,
audit: Vec<EditorAuditItem>,
change_report: EditorChangeReportOutput,
undo_applied: Option<bool>,
redo_applied: Option<bool>,
) -> EditorCliOutput {
EditorCliOutput {
ok: true,
entrypoint: "mnote-cli",
operation,
block_count,
output_markdown,
visible_block_ids: projection.visible_block_ids.clone(),
outline: projection
.outline
.iter()
.map(|entry| EditorOutlineItem {
block_id: entry.block_id.clone(),
level: entry.level,
title: entry.title.clone(),
depth: entry.depth,
})
.collect(),
audit,
change_report,
undo_applied,
redo_applied,
}
}
2026-04-15 20:01:12 +08:00
pub fn render_plain_output(output: &CliJsonOutput) -> String {
match &output.operation {
CliOperationOutput::Command {
name,
command_id,
transport,
execution,
2026-04-15 20:01:12 +08:00
..
} => format!(
"domain={} action={} kind=command name={} command_id={} function={} executed={} request_id={} trace_id={}",
2026-04-15 20:01:12 +08:00
output.domain,
output.action,
name,
command_id,
transport.function_name,
execution.is_some(),
2026-04-15 20:01:12 +08:00
output.context.request_id,
output.context.trace_id,
),
CliOperationOutput::Query {
name,
transport,
execution,
..
2026-04-15 20:01:12 +08:00
} => format!(
"domain={} action={} kind=query name={} function={} executed={} request_id={} trace_id={}",
2026-04-15 20:01:12 +08:00
output.domain,
output.action,
name,
transport.function_name,
execution.is_some(),
2026-04-15 20:01:12 +08:00
output.context.request_id,
output.context.trace_id,
),
CliOperationOutput::Tool {
tool_name,
invocation_kind,
execution_mode,
toolset_id,
effect,
requires_confirmation,
execution,
2026-04-15 20:01:12 +08:00
..
} => format!(
"domain={} action={} kind=tool tool={} invocation_kind={} execution_mode={} toolset={} effect={} requires_confirmation={} executed={} request_id={} trace_id={}",
2026-04-15 20:01:12 +08:00
output.domain,
output.action,
tool_name,
invocation_kind,
execution_mode,
toolset_id,
effect,
requires_confirmation,
execution.is_some(),
2026-04-15 20:01:12 +08:00
output.context.request_id,
output.context.trace_id,
),
}
}
pub fn plan_page_get(
ctx: &CliContext,
page_id: &str,
workspace_id: Option<&str>,
) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, workspace_id, "page", "get", page_id);
let query = QueryEnvelope {
name: "documents.content.get".into(),
payload: GetPageContent {
page_id: page_id.into(),
workspace_id: workspace_id.map(|value| value.into()),
},
};
let request = build_query_request(&bridge, &query).map_err(map_bridge_error)?;
Ok(build_query_output(
"page",
"get",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
query.name,
json!({
"pageId": page_id,
"workspaceId": workspace_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_query_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": page_id,
}),
},
))
}
pub fn plan_page_title(
ctx: &CliContext,
page_id: &str,
workspace_id: Option<&str>,
title: &str,
) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, workspace_id, "page", "title", page_id);
let command = CommandEnvelope {
name: "tree.node.rename".into(),
2026-04-15 20:01:12 +08:00
command_id: build_command_id("page", "title", page_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: workspace_id.map(|value| value.into()),
page_id: Some(page_id.into()),
block_id: None,
}),
payload: UpdatePageTitle {
page_id: page_id.into(),
title: title.into(),
},
reason: ctx.reason.clone(),
refs: vec!["phase2-cli".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"page",
"title",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
command.name,
command.command_id,
json!({
"pageId": page_id,
"workspaceId": workspace_id,
"title": title,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": page_id,
"title": title,
}),
},
))
}
pub fn plan_page_save(
ctx: &CliContext,
page_id: &str,
workspace_id: Option<&str>,
revision: Option<u64>,
content_json: &str,
conflict_detection_key: Option<&str>,
) -> CliResult<CliJsonOutput> {
let content_value = parse_json_value(content_json, "content_json")?;
let bridge = build_bridge_context(ctx, workspace_id, "page", "save", page_id);
let command = CommandEnvelope {
name: "page.body.save".into(),
2026-04-15 20:01:12 +08:00
command_id: build_command_id("page", "save", page_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: workspace_id.map(|value| value.into()),
page_id: Some(page_id.into()),
block_id: None,
}),
payload: SavePageContent {
page_id: page_id.into(),
workspace_id: workspace_id.map(|value| value.into()),
revision,
content_json: content_json.into(),
conflict_detection_key: conflict_detection_key.map(|value| value.into()),
},
reason: ctx.reason.clone(),
refs: vec!["phase2-cli".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"page",
"save",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
command.name,
command.command_id,
json!({
"pageId": page_id,
"workspaceId": workspace_id,
"revision": revision,
"content": content_value,
"conflictDetectionKey": conflict_detection_key,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": page_id,
"content": content_value,
"expectedRevision": revision,
"conflictDetectionKey": conflict_detection_key,
}),
},
))
}
pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResult<CliJsonOutput> {
let content_value = parse_json_value(args.content_json, "content_json")?;
let bridge = build_bridge_context(ctx, Some(args.workspace_id), "page", "create", args.page_id);
let command = CommandEnvelope {
name: "tree.node.create".into(),
command_id: build_command_id("page", "create", args.page_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: Some(args.workspace_id.into()),
page_id: Some(args.page_id.into()),
block_id: None,
}),
payload: CreateDocumentPage {
page_id: args.page_id.into(),
parent_page_id: args.parent_id.map(|value| value.into()),
title: args.title.into(),
workspace_seed_id: args.workspace_id.into(),
},
reason: ctx.reason.clone(),
refs: vec!["phase8-cli".into(), "task-048".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"page",
"create",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
command.name,
command.command_id,
json!({
"pageId": args.page_id,
"workspaceId": args.workspace_id,
"parentId": args.parent_id,
"title": args.title,
"accessScope": args.access_scope,
"content": content_value,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": args.page_id,
"workspaceId": args.workspace_id,
"parentId": args.parent_id,
"title": args.title,
"accessScope": args.access_scope,
"content": content_value,
}),
},
))
}
pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "move", args.page_id);
let command = CommandEnvelope {
name: "tree.subtree.move".into(),
command_id: build_command_id("page", "move", args.page_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: args.workspace_id.map(|value| value.into()),
page_id: Some(args.page_id.into()),
block_id: None,
}),
payload: MoveDocumentPage {
page_id: args.page_id.into(),
parent_page_id: args.parent_id.map(|value| value.into()),
sort_order: args.sort_order,
},
reason: ctx.reason.clone(),
refs: vec!["phase8-cli".into(), "task-048".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"page",
"move",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
command.name,
command.command_id,
json!({
"pageId": args.page_id,
"workspaceId": args.workspace_id,
"parentId": args.parent_id,
"sortOrder": args.sort_order,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": args.page_id,
"parentId": args.parent_id,
"sortOrder": args.sort_order,
}),
},
))
}
pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "delete", args.page_id);
let command = CommandEnvelope {
name: "tree.node.archive".into(),
command_id: build_command_id("page", "delete", args.page_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: args.workspace_id.map(|value| value.into()),
page_id: Some(args.page_id.into()),
block_id: None,
}),
payload: DeleteDocumentPage {
page_id: args.page_id.into(),
},
reason: ctx.reason.clone(),
refs: vec!["phase8-cli".into(), "task-048".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"page",
"delete",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
command.name,
command.command_id,
json!({
"pageId": args.page_id,
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": args.page_id,
}),
},
))
}
pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "restore", args.page_id);
let command = CommandEnvelope {
name: "tree.node.restore".into(),
command_id: build_command_id("page", "restore", args.page_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: args.workspace_id.map(|value| value.into()),
page_id: Some(args.page_id.into()),
block_id: None,
}),
payload: RestoreDocumentPage {
page_id: args.page_id.into(),
},
reason: ctx.reason.clone(),
refs: vec!["phase8-cli".into(), "task-048".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"page",
"restore",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
command.name,
command.command_id,
json!({
"pageId": args.page_id,
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": args.page_id,
}),
},
))
}
2026-04-15 20:01:12 +08:00
pub fn plan_block_insert(
ctx: &CliContext,
workspace_id: &str,
page_id: &str,
content: &str,
block_type: &str,
parent_block_id: Option<&str>,
prev_block_id: Option<&str>,
) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, Some(workspace_id), "block", "insert", page_id);
let command = CommandEnvelope {
name: "insert_block".into(),
command_id: build_command_id("block", "insert", page_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: Some(workspace_id.into()),
page_id: Some(page_id.into()),
block_id: None,
}),
payload: core_protocol::InsertBlock {
2026-04-15 20:01:12 +08:00
page_id: page_id.into(),
block_type: block_type.into(),
content: content.into(),
parent_block_id: parent_block_id.map(|value| value.into()),
prev_block_id: prev_block_id.map(|value| value.into()),
},
reason: ctx.reason.clone(),
refs: vec!["phase2-cli".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"block",
"insert",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
command.name,
command.command_id,
json!({
"workspaceId": workspace_id,
"pageId": page_id,
"content": content,
"blockType": block_type,
"parentBlockId": parent_block_id,
"prevBlockId": prev_block_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"pageId": page_id,
"content": content,
"blockType": block_type,
"parentBlockId": parent_block_id,
"prevBlockId": prev_block_id,
}),
},
))
}
pub fn plan_block_patch(
ctx: &CliContext,
workspace_id: Option<&str>,
page_id: &str,
block_id: &str,
revision: Option<u64>,
snapshot_json: &str,
conflict_detection_key: Option<&str>,
) -> CliResult<CliJsonOutput> {
let snapshot_value = parse_json_value(snapshot_json, "snapshot_json")?;
let bridge = build_bridge_context(ctx, workspace_id, "block", "patch", block_id);
let command = CommandEnvelope {
name: "blocks.patch".into(),
command_id: build_command_id("block", "patch", block_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: workspace_id.map(|value| value.into()),
page_id: Some(page_id.into()),
block_id: Some(block_id.into()),
}),
payload: PatchPageBlock {
page_id: page_id.into(),
block_id: block_id.into(),
workspace_id: workspace_id.map(|value| value.into()),
revision,
block_snapshot_json: snapshot_json.into(),
conflict_detection_key: conflict_detection_key.map(|value| value.into()),
},
reason: ctx.reason.clone(),
refs: vec!["phase2-cli".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"block",
"patch",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
command.name,
command.command_id,
json!({
"workspaceId": workspace_id,
"pageId": page_id,
"blockId": block_id,
"revision": revision,
"snapshot": snapshot_value,
"conflictDetectionKey": conflict_detection_key,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"pageId": page_id,
"blockId": block_id,
"snapshot": snapshot_value,
"expectedRevision": revision,
"conflictDetectionKey": conflict_detection_key,
}),
},
))
}
pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, None, "block", "move", args.block_id);
let command = CommandEnvelope {
name: "blocks.move".into(),
command_id: build_command_id("block", "move", args.block_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: None,
page_id: Some(args.target_document_id.into()),
block_id: Some(args.block_id.into()),
}),
payload: core_protocol::MoveBlock {
block_id: args.block_id.into(),
new_parent_block_id: None,
new_page_id: Some(args.target_document_id.into()),
prev_block_id: None,
},
reason: ctx.reason.clone(),
refs: vec!["phase8-cli".into(), "task-048".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"block",
"move",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
command.name,
command.command_id,
json!({
"sourceDocumentId": args.source_document_id,
"blockId": args.block_id,
"targetDocumentId": args.target_document_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"id": args.block_id,
"sourceDocumentId": args.source_document_id,
"targetDocumentId": args.target_document_id,
}),
},
))
}
pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, None, "block", "embed", args.block_id);
let command = CommandEnvelope {
name: "blocks.embed".into(),
command_id: build_command_id("block", "embed", args.block_id),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: None,
page_id: Some(args.target_document_id.into()),
block_id: Some(args.block_id.into()),
}),
payload: core_protocol::EmbedBlock {
source_document_id: args.source_document_id.into(),
source_block_id: args.block_id.into(),
target_document_id: args.target_document_id.into(),
target_block_id: args.target_block_id.map(|value| value.into()),
},
reason: ctx.reason.clone(),
refs: vec!["phase8-cli".into(), "task-048".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"block",
"embed",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
command.name,
command.command_id,
json!({
"sourceDocumentId": args.source_document_id,
"blockId": args.block_id,
"targetDocumentId": args.target_document_id,
"targetBlockId": args.target_block_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"sourceDocumentId": args.source_document_id,
"blockId": args.block_id,
"targetDocumentId": args.target_document_id,
"targetBlockId": args.target_block_id,
}),
},
))
}
2026-04-15 20:01:12 +08:00
pub fn plan_search_documents(
ctx: &CliContext,
query: &str,
workspace_id: Option<&str>,
limit: u32,
cursor: Option<&str>,
) -> CliResult<CliJsonOutput> {
let workspace_id =
workspace_id.ok_or_else(|| CliError::validation("search documents 缺少 --workspace-id"))?;
let bridge = build_bridge_context(ctx, Some(workspace_id), "search", "documents", query);
2026-04-15 20:01:12 +08:00
let envelope = QueryEnvelope {
name: "search.documents".into(),
payload: SearchDocuments {
2026-04-15 20:01:12 +08:00
query: query.into(),
workspace_id: workspace_id.into(),
page_id: None,
2026-04-15 20:01:12 +08:00
pagination: Pagination {
limit,
cursor: cursor.map(|value| value.into()),
},
title_only: false,
exact: false,
include_ocr: false,
time_range: "any".into(),
time_field: "updated".into(),
custom_range_from: None,
custom_range_to: None,
2026-04-15 20:01:12 +08:00
},
};
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
Ok(build_query_output(
"search",
"documents",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
envelope.name,
json!({
"query": query,
"workspaceId": workspace_id,
"pagination": {
"limit": limit,
"cursor": cursor,
},
"filters": {
"titleOnly": false,
"exact": false,
"includeOcr": false,
"timeRange": "any",
"timeField": "updated",
},
2026-04-15 20:01:12 +08:00
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_query_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"query": query,
"workspaceId": workspace_id,
"pageId": Value::Null,
2026-04-15 20:01:12 +08:00
"limit": limit,
"cursor": cursor,
"titleOnly": false,
"exact": false,
"includeOcr": false,
"timeRange": "any",
"timeField": "updated",
"customRangeFrom": Value::Null,
"customRangeTo": Value::Null,
2026-04-15 20:01:12 +08:00
}),
},
))
}
pub fn plan_search_blocks(
ctx: &CliContext,
query: &str,
page_id: Option<&str>,
limit: u32,
cursor: Option<&str>,
) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, None, "search", "blocks", query);
let envelope = QueryEnvelope {
name: "search_blocks".into(),
payload: SearchBlocks {
query: query.into(),
page_id: page_id.map(|value| value.into()),
pagination: Pagination {
limit,
cursor: cursor.map(|value| value.into()),
},
},
};
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
Ok(build_query_output(
"search",
"blocks",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
envelope.name,
json!({
"query": query,
"pageId": page_id,
"pagination": {
"limit": limit,
"cursor": cursor,
},
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_query_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"query": query,
"pageId": page_id,
"limit": limit,
"cursor": cursor,
}),
},
))
}
pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(ctx, Some(workspace_id), "sidebar", "dataset", workspace_id);
let envelope = QueryEnvelope {
name: "sidebar.dataset.list".into(),
payload: ListSidebarDataset {
workspace_id: workspace_id.into(),
},
};
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
Ok(build_query_output(
"sidebar",
"dataset",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
envelope.name,
json!({
"workspaceId": workspace_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_query_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"workspaceId": workspace_id,
}),
},
))
}
pub fn plan_mindmap_get(
ctx: &CliContext,
workspace_id: Option<&str>,
document_id: &str,
mindmap_id: &str,
) -> CliResult<CliJsonOutput> {
let bridge = build_bridge_context(
ctx,
workspace_id,
"mindmap",
"get",
&format!("{document_id}_{mindmap_id}"),
);
let envelope = QueryEnvelope {
name: "mindmaps.get".into(),
payload: GetMindmap {
document_id: document_id.into(),
mindmap_id: mindmap_id.into(),
workspace_id: workspace_id.map(|value| value.into()),
},
};
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
Ok(build_query_output(
"mindmap",
"get",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
envelope.name,
json!({
"workspaceId": workspace_id,
"documentId": document_id,
"mindmapId": mindmap_id,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_query_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"docId": document_id,
"mindmapId": mindmap_id,
}),
},
))
}
pub fn plan_mindmap_put(
ctx: &CliContext,
workspace_id: Option<&str>,
document_id: &str,
mindmap_id: &str,
data_json: &str,
create_only: bool,
) -> CliResult<CliJsonOutput> {
let data = parse_json_value(data_json, "data_json")?;
serde_json::from_value::<MindmapTreeNode>(data.clone())
.map_err(|error| CliError::validation(format!("data_json 不是合法导图树: {error}")))?;
let bridge = build_bridge_context(
ctx,
workspace_id,
"mindmap",
"put",
&format!("{document_id}_{mindmap_id}"),
);
let command = CommandEnvelope {
name: "mindmaps.put".into(),
command_id: build_command_id("mindmap", "put", &format!("{document_id}_{mindmap_id}")),
idempotency_key: bridge.idempotency_key.clone(),
actor: build_actor_payload(ctx),
source: build_source_payload(),
target: Some(TargetRef {
workspace_id: workspace_id.map(|value| value.into()),
page_id: Some(document_id.into()),
block_id: Some(mindmap_id.into()),
}),
payload: PutMindmap {
document_id: document_id.into(),
mindmap_id: mindmap_id.into(),
data_json: serde_json::to_string(&data).map_err(|error| {
CliError::validation(format!("mindmap data_json 序列化失败: {error}"))
})?,
create_only,
},
reason: ctx.reason.clone(),
refs: vec!["phase6-cli".into(), "task-032".into()],
dry_run: ctx.dry_run,
validate_only: ctx.validate_only,
};
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
Ok(build_command_output(
"mindmap",
"put",
&bridge,
2026-05-06 21:44:20 +08:00
ctx,
2026-04-15 20:01:12 +08:00
command.name,
command.command_id,
json!({
"workspaceId": workspace_id,
"documentId": document_id,
"mindmapId": mindmap_id,
"createOnly": create_only,
"data": data,
}),
CliTransportPlan {
2026-06-07 10:35:21 +08:00
kind: "runtime_command_plan".into(),
2026-04-15 20:01:12 +08:00
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"docId": document_id,
"mindmapId": mindmap_id,
"data": data,
"createOnly": create_only,
}),
},
))
}
pub fn plan_mindmap_op(
ctx: &CliContext,
workspace_id: Option<&str>,
document_id: &str,
mindmap_id: &str,
ops_json: &str,
) -> CliResult<CliJsonOutput> {
let ops_value = parse_json_value(ops_json, "ops_json")?;
let ops: Vec<MindmapOp> = serde_json::from_value(ops_value.clone())
.map_err(|error| CliError::validation(format!("ops_json 不是合法 MindmapOp[]: {error}")))?;
2026-04-15 20:01:12 +08:00
if ops.is_empty() {
return Err(CliError::validation("ops_json 不能为空数组"));
}
let bridge = build_bridge_context(
ctx,
workspace_id,
"mindmap",
"op",
&format!("{document_id}_{mindmap_id}"),
);
let spec = default_tool_registry()
.tool("mindmap_apply_ops")
.ok_or_else(|| CliError::validation("未注册 tool: mindmap_apply_ops"))?;
Ok(CliJsonOutput {
ok: true,
entrypoint: "mnote-cli",
domain: "mindmap".into(),
action: "op".into(),
context: build_output_context(&bridge, ctx),
operation: CliOperationOutput::Tool {
tool_name: "mindmap_apply_ops".into(),
invocation_kind: invocation_kind_label(&InvocationKind::Command).into(),
execution_mode: core_protocol::tool_mode_label(&ToolExecutionMode::Result).into(),
toolset_id: spec.toolset_id.into(),
effect: tool_effect_label(&spec.effect).into(),
requires_confirmation: spec.requires_confirmation,
normalized_input: json!({
"toolName": "mindmap_apply_ops",
"workspaceId": workspace_id,
"documentId": document_id,
"mindmapId": mindmap_id,
"ops": ops_value,
"reason": ctx.reason,
"validateOnly": bridge.validate_only,
"dryRun": bridge.dry_run,
}),
execution: None,
2026-04-15 20:01:12 +08:00
},
})
}
pub fn plan_tool_run(
ctx: &CliContext,
tool_name: &str,
invocation_kind: InvocationKind,
execution_mode: ToolExecutionMode,
args_json: &str,
) -> CliResult<CliJsonOutput> {
let args_value = parse_json_value(args_json, "args_json")?;
let bridge = build_bridge_context(ctx, None, "tool", "run", tool_name);
let spec = default_tool_registry()
.tool(tool_name)
.ok_or_else(|| CliError::validation(format!("未知 tool: {tool_name}")))?;
if spec.invocation_kind != invocation_kind {
return Err(CliError::validation(format!(
"tool {tool_name} 仅支持 {},当前传入 {}",
invocation_kind_label(&spec.invocation_kind),
invocation_kind_label(&invocation_kind),
)));
}
let invocation = ToolInvocation {
tool: tool_name.into(),
kind: invocation_kind,
2026-04-15 20:01:12 +08:00
mode: execution_mode,
args_json: args_json.into(),
};
let operation = CliOperationOutput::Tool {
tool_name: invocation.tool,
invocation_kind: invocation_kind_label(&invocation.kind).into(),
execution_mode: core_protocol::tool_mode_label(&invocation.mode).into(),
toolset_id: spec.toolset_id.into(),
effect: tool_effect_label(&spec.effect).into(),
requires_confirmation: spec.requires_confirmation,
normalized_input: json!({
"toolName": tool_name,
"invocationKind": invocation_kind_label(&invocation.kind),
"executionMode": core_protocol::tool_mode_label(&invocation.mode),
"toolsetId": spec.toolset_id,
"effect": tool_effect_label(&spec.effect),
"requiresConfirmation": spec.requires_confirmation,
"validateOnly": bridge.validate_only,
"dryRun": bridge.dry_run,
"args": args_value,
}),
execution: None,
2026-04-15 20:01:12 +08:00
};
Ok(CliJsonOutput {
ok: true,
entrypoint: "mnote-cli",
domain: "tool".into(),
action: "run".into(),
context: build_output_context(&bridge, ctx),
operation,
})
}
pub fn execute_output(output: &CliJsonOutput, ctx: &CliContext) -> CliResult<CliJsonOutput> {
let mut next = output.clone();
match &mut next.operation {
CliOperationOutput::Query {
name,
transport,
execution,
..
} => {
*execution = Some(execute_query(name, transport, &next.context, ctx)?);
}
CliOperationOutput::Command {
name,
normalized_input,
transport,
execution,
..
} => {
*execution = Some(execute_command(
name,
normalized_input,
transport,
&next.context,
ctx,
)?);
}
CliOperationOutput::Tool {
tool_name,
invocation_kind,
execution_mode,
normalized_input,
execution,
..
} => {
*execution = Some(execute_tool(
tool_name,
invocation_kind,
execution_mode,
normalized_input,
&next.context,
ctx,
)?);
}
}
Ok(next)
}
fn execute_query(
name: &str,
transport: &CliTransportPlan,
context: &CliOutputContext,
cli_ctx: &CliContext,
) -> CliResult<Value> {
match name {
"documents.content.get"
| "documents.meta.get"
| "mindmaps.get"
| "sidebar.dataset.list" => reject_non_local_cli_transport(name),
"search.documents" => execute_search_documents(transport, context, cli_ctx),
other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))),
}
}
fn execute_command(
name: &str,
normalized_input: &Value,
transport: &CliTransportPlan,
context: &CliOutputContext,
cli_ctx: &CliContext,
) -> CliResult<Value> {
match name {
"tree.node.create"
| "tree.subtree.move"
| "tree.node.archive"
| "tree.node.restore"
| "tree.node.rename"
| "page.body.save"
| "mindmaps.put" => reject_non_local_cli_transport(name),
"insert_block" => execute_block_insert(normalized_input, context, cli_ctx),
"blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx),
"blocks.move" => execute_block_move(normalized_input, cli_ctx),
"blocks.embed" => execute_block_embed(normalized_input, cli_ctx),
other => Err(CliError::validation(format!(
"暂不支持执行 command: {other}"
))),
}
}
fn execute_tool(
tool_name: &str,
invocation_kind: &str,
execution_mode: &str,
normalized_input: &Value,
context: &CliOutputContext,
cli_ctx: &CliContext,
) -> CliResult<Value> {
if execution_mode != "result" {
return Err(CliError::validation(
"tool --execute 目前只支持 mode=result",
));
}
let args = normalized_input.get("args").cloned().unwrap_or(Value::Null);
let runtime_args = merge_tool_args(tool_name, normalized_input, &args, context);
let target = build_tool_target(tool_name, normalized_input, context)?;
let runtime_context = build_runtime_context(context, cli_ctx);
let data = load_tool_data(tool_name, &runtime_args, target.as_ref(), cli_ctx)?;
execute_runtime_query(RuntimeInput::Tool {
context: runtime_context,
tool: RuntimeToolInvocationWire {
tool: tool_name.into(),
kind: invocation_kind.into(),
mode: Some("result".into()),
args_json: runtime_args,
target,
reason: cli_ctx.reason.clone(),
refs: vec!["mnote-cli-execute".into()],
},
data: Some(data),
})
.map_err(|error| CliError::transport(error.message))
}
fn merge_tool_args(
tool_name: &str,
normalized_input: &Value,
args: &Value,
context: &CliOutputContext,
) -> Value {
let mut merged = args.as_object().cloned().unwrap_or_default();
if let Some(workspace_id) = context.workspace_id.as_ref() {
merged
.entry("workspaceId")
.or_insert_with(|| json!(workspace_id));
}
if let Some(document_id) = normalized_input.get("documentId").cloned() {
merged.entry("documentId").or_insert(document_id);
}
if let Some(mindmap_id) = normalized_input.get("mindmapId").cloned() {
merged.entry("mindmapId").or_insert(mindmap_id);
}
if let Some(ops) = normalized_input.get("ops").cloned() {
merged.entry("ops").or_insert(ops);
}
if tool_name == "mindmap_apply_ops" {
if let Some(page_id) = normalized_input.get("documentId").cloned() {
merged.entry("pageId").or_insert(page_id);
}
}
Value::Object(merged)
}
fn execute_search_documents(
_transport: &CliTransportPlan,
_context: &CliOutputContext,
_cli_ctx: &CliContext,
) -> CliResult<Value> {
reject_non_local_cli_transport("search.documents")
}
fn execute_block_insert(
_normalized_input: &Value,
_context: &CliOutputContext,
_cli_ctx: &CliContext,
) -> CliResult<Value> {
reject_non_local_cli_transport("insert_block")
}
fn execute_block_patch(
_normalized_input: &Value,
_transport: &CliTransportPlan,
_cli_ctx: &CliContext,
) -> CliResult<Value> {
reject_non_local_cli_transport("blocks.patch")
}
fn execute_block_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
reject_non_local_cli_transport("blocks.move")
}
fn execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
reject_non_local_cli_transport("blocks.embed")
}
fn build_tool_target(
tool_name: &str,
normalized_input: &Value,
context: &CliOutputContext,
) -> CliResult<Option<RuntimeTargetWire>> {
let args = normalized_input.get("args").cloned().unwrap_or(Value::Null);
let workspace_id = args
.get("workspaceId")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| context.workspace_id.clone());
let page_id = args
.get("documentId")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
args.get("pageId")
.and_then(Value::as_str)
.map(str::to_string)
});
let block_id = args
.get("mindmapId")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
args.get("blockId")
.and_then(Value::as_str)
.map(str::to_string)
});
if matches!(
tool_name,
"search_web" | "image_read" | "slash_run" | "event_replay" | "index_rebuild"
) {
return Ok(None);
}
if workspace_id.is_none() && page_id.is_none() && block_id.is_none() {
return Ok(None);
}
Ok(Some(RuntimeTargetWire {
workspace_id,
page_id,
block_id,
}))
}
fn load_tool_data(
tool_name: &str,
args: &Value,
target: Option<&RuntimeTargetWire>,
_cli_ctx: &CliContext,
) -> CliResult<Value> {
match tool_name {
"search_web" | "slash_run" | "event_replay" | "index_rebuild" => {
Ok(json!({ "source": "cli" }))
}
"image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })),
"doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => {
let _ = read_doc_target(args, target)?;
Err(non_local_cli_transport_error(tool_name))
}
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
let document_id = read_doc_target(args, target)?;
let mindmap_id = args
.get("mindmapId")
.and_then(Value::as_str)
.or_else(|| target.and_then(|value| value.block_id.as_deref()))
.ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?;
let _ = (document_id, mindmap_id);
Err(non_local_cli_transport_error(tool_name))
}
other => Err(CliError::validation(format!(
"暂不支持 tool 数据加载: {other}"
))),
}
}
fn reject_non_local_cli_transport(operation: &str) -> CliResult<Value> {
Err(non_local_cli_transport_error(operation))
}
fn non_local_cli_transport_error(operation: &str) -> CliError {
CliError::validation(format!(
"非 local_folder 的 CLI 执行链已移除: {operation};请使用 local-first local_folder 路径"
))
}
fn build_runtime_context(
context: &CliOutputContext,
cli_ctx: &CliContext,
) -> RuntimeBridgeContextWire {
RuntimeBridgeContextWire {
deployment_id: None,
project_id: None,
workspace_id: context.workspace_id.clone(),
request_id: context.request_id.clone(),
trace_id: context.trace_id.clone(),
actor: RuntimeActorWire {
actor_type: context.actor_type.clone(),
actor_id: context.actor_id.clone(),
session_id: context.session_id.clone(),
},
source: RuntimeSourceWire {
channel: "cli".into(),
client: "mnote-cli".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
tenant_id: None,
auth_token: None,
idempotency_key: cli_ctx.idempotency_key.clone(),
validate_only: cli_ctx.validate_only,
dry_run: cli_ctx.dry_run,
}
}
fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResult<String> {
args.get("documentId")
.and_then(Value::as_str)
.or_else(|| args.get("pageId").and_then(Value::as_str))
.map(str::to_string)
.or_else(|| target.and_then(|value| value.page_id.clone()))
.ok_or_else(|| CliError::validation("缺少 documentId/pageId"))
}
2026-04-15 20:01:12 +08:00
fn build_query_output(
domain: &str,
action: &str,
bridge: &BridgeContext,
2026-05-06 21:44:20 +08:00
ctx: &CliContext,
2026-04-15 20:01:12 +08:00
name: String,
normalized_input: Value,
transport: CliTransportPlan,
) -> CliJsonOutput {
CliJsonOutput {
ok: true,
entrypoint: "mnote-cli",
domain: domain.into(),
action: action.into(),
2026-05-06 21:44:20 +08:00
context: build_output_context(bridge, ctx),
2026-04-15 20:01:12 +08:00
operation: CliOperationOutput::Query {
name,
normalized_input,
transport,
execution: None,
2026-04-15 20:01:12 +08:00
},
}
}
fn build_command_output(
domain: &str,
action: &str,
bridge: &BridgeContext,
2026-05-06 21:44:20 +08:00
ctx: &CliContext,
2026-04-15 20:01:12 +08:00
name: String,
command_id: String,
normalized_input: Value,
transport: CliTransportPlan,
) -> CliJsonOutput {
CliJsonOutput {
ok: true,
entrypoint: "mnote-cli",
domain: domain.into(),
action: action.into(),
2026-05-06 21:44:20 +08:00
context: build_output_context(bridge, ctx),
2026-04-15 20:01:12 +08:00
operation: CliOperationOutput::Command {
name,
command_id,
normalized_input,
transport,
execution: None,
2026-04-15 20:01:12 +08:00
},
}
}
fn build_output_context(bridge: &BridgeContext, ctx: &CliContext) -> CliOutputContext {
CliOutputContext {
request_id: bridge.request_id.clone(),
trace_id: bridge.trace_id.clone(),
actor_id: bridge.actor_id.clone(),
actor_type: bridge.actor_type.clone(),
session_id: bridge.session_id.clone(),
2026-05-06 21:44:20 +08:00
reason: ctx.reason.clone(),
2026-04-15 20:01:12 +08:00
workspace_id: bridge.workspace_id.clone(),
idempotency_key: ctx.idempotency_key.clone(),
validate_only: bridge.validate_only,
dry_run: bridge.dry_run,
}
}
fn build_bridge_context(
ctx: &CliContext,
workspace_id: Option<&str>,
domain: &str,
action: &str,
target: &str,
) -> BridgeContext {
let key = sanitize_id(&format!("{domain}_{action}_{target}"));
BridgeContext {
deployment_id: None,
project_id: None,
workspace_id: workspace_id.map(|value| value.into()),
request_id: format!("req_cli_{key}"),
trace_id: format!("trace_cli_{key}"),
actor_type: ctx.actor_type.clone(),
actor_id: ctx.actor_id.clone(),
session_id: ctx.session_id.clone(),
tenant_id: None,
auth_token: None,
source_channel: "cli".into(),
source_client: "mnote-cli".into(),
idempotency_key: ctx.idempotency_key.clone(),
validate_only: ctx.validate_only,
dry_run: ctx.dry_run,
}
}
fn build_actor_payload(ctx: &CliContext) -> ActorPayload {
ActorPayload {
actor_type: ctx.actor_type.clone(),
actor_id: ctx.actor_id.clone(),
session_id: ctx.session_id.clone(),
}
}
fn build_source_payload() -> SourcePayload {
SourcePayload {
channel: "cli".into(),
client: "mnote-cli".into(),
}
}
fn build_command_id(domain: &str, action: &str, target: &str) -> String {
format!(
"cmd_{}",
sanitize_id(&format!("{domain}_{action}_{target}"))
)
}
fn sanitize_id(input: &str) -> String {
input
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
fn parse_json_value(raw: &str, field: &str) -> CliResult<Value> {
serde_json::from_str::<Value>(raw)
.map_err(|error| CliError::validation(format!("{field} 不是合法 JSON: {error}")))
}
fn invocation_kind_label(kind: &InvocationKind) -> &'static str {
match kind {
InvocationKind::Command => "command",
InvocationKind::Query => "query",
InvocationKind::Job => "job",
}
}
fn map_bridge_error(error: BridgeError) -> CliError {
2026-04-15 20:01:12 +08:00
CliError::validation(error.message)
}
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
CliError::validation(error.to_string())
}
2026-04-15 20:01:12 +08:00
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supported_surface_covers_phase2_minimum_domains() {
let commands = supported_command_surface();
assert!(commands.contains(&"page get"));
assert!(commands.contains(&"page create"));
assert!(commands.contains(&"page move"));
2026-04-15 20:01:12 +08:00
assert!(commands.contains(&"block patch"));
assert!(commands.contains(&"block move"));
2026-04-15 20:01:12 +08:00
assert!(commands.contains(&"mindmap get"));
assert!(commands.contains(&"search documents"));
assert!(commands.contains(&"sidebar dataset"));
assert!(commands.contains(&"editor markdown-roundtrip"));
assert!(commands.contains(&"editor session-demo"));
2026-04-15 20:01:12 +08:00
assert!(commands.contains(&"tool run"));
}
#[test]
fn editor_markdown_roundtrip_returns_outline_and_markdown() {
let output =
editor_markdown_roundtrip("# 标题\n普通段落").expect("roundtrip should succeed");
assert_eq!(output.operation, "markdown_roundtrip");
assert_eq!(output.block_count, 2);
assert!(output.output_markdown.contains("# 标题"));
assert_eq!(
output.visible_block_ids,
vec!["imported_1".to_string(), "imported_2".to_string()]
);
assert_eq!(output.outline.len(), 1);
assert_eq!(output.outline[0].title, "标题");
}
#[test]
fn editor_session_demo_applies_insert_and_redo() {
let output = editor_session_demo("原始段落").expect("session demo should succeed");
assert_eq!(output.operation, "session_demo");
assert_eq!(output.block_count, 2);
assert_eq!(output.undo_applied, Some(true));
assert_eq!(output.redo_applied, Some(true));
assert!(output.output_markdown.contains("原始段落 [edited]"));
assert!(output.output_markdown.contains("CLI demo block"));
}
#[test]
fn editor_ai_pipeline_exposes_audit_and_change_report() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::MeetingNotesToTodos,
"待办:整理会议纪要\n普通段落",
)
.expect("ai pipeline should succeed");
assert_eq!(output.operation, "ai_pipeline");
assert_eq!(output.audit.len(), 1);
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
assert!(output.output_markdown.contains("待办:整理会议纪要"));
}
#[test]
fn editor_ai_pipeline_reorders_pages() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::PageReorder,
"第一页\n\n第二页",
)
.expect("ai pipeline should succeed");
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
assert_eq!(
output.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_meeting_notes_to_todos() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::MeetingNotesToTodos,
"待办:整理会议纪要\n普通段落",
)
.expect("ai regression meeting notes should succeed");
assert!(output.output_markdown.contains("- [ ] 待办:整理会议纪要"));
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_long_paragraph_to_title() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::LongParagraphToTitle,
"这是一个很长的段落,用来验证 AI 回归样例会把首块提炼成标题,并保留后续结构化内容。",
)
.expect("ai regression long paragraph should succeed");
assert!(output.output_markdown.starts_with("# 这是一个很长的段落"));
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_page_reorder() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::PageReorder,
"第一页\n\n第二页",
)
.expect("ai regression reorder should succeed");
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
assert_eq!(
output.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}
2026-04-15 20:01:12 +08:00
#[test]
fn page_save_json_contract_uses_documents_save() {
let output = plan_page_save(
&CliContext::default(),
"page_1",
Some("ws_1"),
Some(7),
r#"[{"id":"block_1"}]"#,
Some("conflict_1"),
)
.expect("page save plan should build");
match output.operation {
CliOperationOutput::Command {
name,
command_id,
transport,
..
} => {
assert_eq!(name, "page.body.save");
2026-04-15 20:01:12 +08:00
assert_eq!(command_id, "cmd_page_save_page_1");
2026-06-07 10:35:21 +08:00
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "page.body.save");
2026-04-15 20:01:12 +08:00
assert_eq!(
transport.args_json,
json!({
"id": "page_1",
"content": [{"id": "block_1"}],
"expectedRevision": 7,
"conflictDetectionKey": "conflict_1",
})
);
}
_ => panic!("expected command output"),
}
}
2026-05-06 21:44:20 +08:00
#[test]
fn output_context_keeps_reason_for_audit_trace() {
let ctx = CliContext {
reason: Some("phase7-cli-first".into()),
..CliContext::default()
};
let output = plan_page_save(&ctx, "page_1", Some("ws_1"), None, "[]", None)
.expect("page save plan should build");
assert_eq!(output.context.reason.as_deref(), Some("phase7-cli-first"));
}
#[test]
fn page_create_json_contract_uses_documents_create() {
let output = plan_page_create(
&CliContext::default(),
&PageCreateArgs {
page_id: "page_1",
workspace_id: "ws_1",
parent_id: Some("parent_1"),
title: "新页面",
access_scope: "private",
content_json: r#"[{"id":"block_1"}]"#,
},
)
.expect("page create plan should build");
match output.operation {
CliOperationOutput::Command {
name, transport, ..
} => {
assert_eq!(name, "tree.node.create");
2026-06-07 10:35:21 +08:00
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "tree.node.create");
assert_eq!(transport.args_json["workspaceId"], json!("ws_1"));
assert_eq!(transport.args_json["parentId"], json!("parent_1"));
}
_ => panic!("expected command output"),
}
}
#[test]
fn page_move_json_contract_uses_tree_subtree_move() {
let output = plan_page_move(
&CliContext::default(),
&PageMoveArgs {
page_id: "page_1",
workspace_id: Some("ws_1"),
parent_id: Some("parent_2"),
sort_order: 3,
},
)
.expect("page move plan should build");
match output.operation {
CliOperationOutput::Command {
name, transport, ..
} => {
assert_eq!(name, "tree.subtree.move");
2026-06-07 10:35:21 +08:00
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "tree.subtree.move");
assert_eq!(transport.args_json["sortOrder"], json!(3));
}
_ => panic!("expected command output"),
}
}
2026-04-15 20:01:12 +08:00
#[test]
fn sidebar_dataset_json_contract_uses_single_dataset_query() {
let output = plan_sidebar_dataset(&CliContext::default(), "ws_1")
.expect("sidebar dataset plan should build");
match output.operation {
CliOperationOutput::Query {
name, transport, ..
} => {
assert_eq!(name, "sidebar.dataset.list");
2026-06-07 10:35:21 +08:00
assert_eq!(transport.kind, "runtime_query_plan");
assert_eq!(transport.function_name, "sidebar.dataset.list");
2026-04-15 20:01:12 +08:00
assert_eq!(transport.args_json, json!({ "workspaceId": "ws_1" }));
}
_ => panic!("expected query output"),
}
}
#[test]
fn tool_run_keeps_kind_and_args_json() {
let output = plan_tool_run(
&CliContext::default(),
"doc_get",
InvocationKind::Query,
ToolExecutionMode::Plan,
r#"{"pageId":"page_1"}"#,
)
.expect("tool plan should build");
match output.operation {
CliOperationOutput::Tool {
tool_name,
invocation_kind,
execution_mode,
toolset_id,
effect,
requires_confirmation,
normalized_input,
..
2026-04-15 20:01:12 +08:00
} => {
assert_eq!(tool_name, "doc_get");
assert_eq!(invocation_kind, "query");
assert_eq!(execution_mode, "plan");
assert_eq!(toolset_id, "toolset.doc_read");
assert_eq!(effect, "read");
assert!(!requires_confirmation);
assert_eq!(
normalized_input,
json!({
"toolName": "doc_get",
"invocationKind": "query",
"executionMode": "plan",
"toolsetId": "toolset.doc_read",
"effect": "read",
"requiresConfirmation": false,
"validateOnly": false,
"dryRun": false,
"args": {
"pageId": "page_1",
},
})
);
}
_ => panic!("expected tool output"),
}
}
#[test]
fn tool_run_rejects_unknown_tool() {
let error = plan_tool_run(
&CliContext::default(),
"doc_unknown",
InvocationKind::Query,
ToolExecutionMode::Plan,
r#"{}"#,
)
.expect_err("unknown tool should fail");
assert_eq!(error.code, "VALIDATION_ERROR");
assert!(error.message.contains("未知 tool"));
}
#[test]
fn mindmap_get_json_contract_uses_mindmaps_get() {
let output = plan_mindmap_get(&CliContext::default(), Some("ws_1"), "page_1", "mind_1")
.expect("mindmap get plan should build");
match output.operation {
CliOperationOutput::Query {
name, transport, ..
} => {
assert_eq!(name, "mindmaps.get");
2026-06-07 10:35:21 +08:00
assert_eq!(transport.function_name, "mindmaps.get");
2026-04-15 20:01:12 +08:00
assert_eq!(
transport.args_json,
json!({
"docId": "page_1",
"mindmapId": "mind_1",
})
);
}
_ => panic!("expected query output"),
}
}
#[test]
fn mindmap_put_json_contract_uses_mindmaps_put() {
let output = plan_mindmap_put(
&CliContext::default(),
Some("ws_1"),
"page_1",
"mind_1",
r#"{"data":{"text":"中心主题"},"children":[]}"#,
true,
)
.expect("mindmap put plan should build");
match output.operation {
CliOperationOutput::Command {
name, transport, ..
} => {
assert_eq!(name, "mindmaps.put");
2026-06-07 10:35:21 +08:00
assert_eq!(transport.function_name, "mindmaps.put");
2026-04-15 20:01:12 +08:00
assert_eq!(
transport.args_json,
json!({
"docId": "page_1",
"mindmapId": "mind_1",
"data": {
"data": {
"text": "中心主题"
},
"children": []
},
"createOnly": true,
})
);
}
_ => panic!("expected command output"),
}
}
}