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

2330 lines
74 KiB
Rust

use bridge_runtime::{
build_query_request, build_write_request, execute_runtime_query, BridgeContext, BridgeError,
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, RuntimeSourceWire, RuntimeTargetWire,
RuntimeToolInvocationWire,
};
use clap::ValueEnum;
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,
};
use mnote_editor_core::{
apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock,
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
VisibilitySnapshot,
};
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(),
}
}
}
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>,
pub reason: Option<String>,
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>,
},
Query {
name: String,
normalized_input: Value,
transport: CliTransportPlan,
#[serde(skip_serializing_if = "Option::is_none")]
execution: Option<Value>,
},
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>,
},
}
#[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>,
}
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",
"block insert",
"block patch",
"block move",
"block embed",
"mindmap get",
"mindmap put",
"mindmap op",
"search documents",
"search blocks",
"sidebar dataset",
"editor markdown-roundtrip",
"editor session-demo",
"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(),
));
}
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",
"block.insert",
"block.patch",
"block.move",
"block.embed",
"mindmap.get",
"mindmap.put",
"mindmap.op",
"search.documents",
"search.blocks",
"sidebar.dataset",
"editor.markdown_roundtrip",
"editor.session_demo",
"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,
}
}
pub fn render_plain_output(output: &CliJsonOutput) -> String {
match &output.operation {
CliOperationOutput::Command {
name,
command_id,
transport,
execution,
..
} => format!(
"domain={} action={} kind=command name={} command_id={} function={} executed={} request_id={} trace_id={}",
output.domain,
output.action,
name,
command_id,
transport.function_name,
execution.is_some(),
output.context.request_id,
output.context.trace_id,
),
CliOperationOutput::Query {
name,
transport,
execution,
..
} => format!(
"domain={} action={} kind=query name={} function={} executed={} request_id={} trace_id={}",
output.domain,
output.action,
name,
transport.function_name,
execution.is_some(),
output.context.request_id,
output.context.trace_id,
),
CliOperationOutput::Tool {
tool_name,
invocation_kind,
execution_mode,
toolset_id,
effect,
requires_confirmation,
execution,
..
} => format!(
"domain={} action={} kind=tool tool={} invocation_kind={} execution_mode={} toolset={} effect={} requires_confirmation={} executed={} request_id={} trace_id={}",
output.domain,
output.action,
tool_name,
invocation_kind,
execution_mode,
toolset_id,
effect,
requires_confirmation,
execution.is_some(),
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,
ctx,
query.name,
json!({
"pageId": page_id,
"workspaceId": workspace_id,
}),
CliTransportPlan {
kind: "runtime_query_plan".into(),
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(),
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,
ctx,
command.name,
command.command_id,
json!({
"pageId": page_id,
"workspaceId": workspace_id,
"title": title,
}),
CliTransportPlan {
kind: "runtime_command_plan".into(),
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(),
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,
ctx,
command.name,
command.command_id,
json!({
"pageId": page_id,
"workspaceId": workspace_id,
"revision": revision,
"content": content_value,
"conflictDetectionKey": conflict_detection_key,
}),
CliTransportPlan {
kind: "runtime_command_plan".into(),
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,
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 {
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,
ctx,
command.name,
command.command_id,
json!({
"pageId": args.page_id,
"workspaceId": args.workspace_id,
"parentId": args.parent_id,
"sortOrder": args.sort_order,
}),
CliTransportPlan {
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,
ctx,
command.name,
command.command_id,
json!({
"pageId": args.page_id,
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
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,
ctx,
command.name,
command.command_id,
json!({
"pageId": args.page_id,
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
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_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 {
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,
ctx,
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 {
kind: "runtime_command_plan".into(),
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,
ctx,
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 {
kind: "runtime_command_plan".into(),
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,
ctx,
command.name,
command.command_id,
json!({
"sourceDocumentId": args.source_document_id,
"blockId": args.block_id,
"targetDocumentId": args.target_document_id,
}),
CliTransportPlan {
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,
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 {
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,
}),
},
))
}
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);
let envelope = QueryEnvelope {
name: "search.documents".into(),
payload: SearchDocuments {
query: query.into(),
workspace_id: workspace_id.into(),
page_id: None,
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,
},
};
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
Ok(build_query_output(
"search",
"documents",
&bridge,
ctx,
envelope.name,
json!({
"query": query,
"workspaceId": workspace_id,
"pagination": {
"limit": limit,
"cursor": cursor,
},
"filters": {
"titleOnly": false,
"exact": false,
"includeOcr": false,
"timeRange": "any",
"timeField": "updated",
},
}),
CliTransportPlan {
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
"query": query,
"workspaceId": workspace_id,
"pageId": Value::Null,
"limit": limit,
"cursor": cursor,
"titleOnly": false,
"exact": false,
"includeOcr": false,
"timeRange": "any",
"timeField": "updated",
"customRangeFrom": Value::Null,
"customRangeTo": Value::Null,
}),
},
))
}
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,
ctx,
envelope.name,
json!({
"query": query,
"pageId": page_id,
"pagination": {
"limit": limit,
"cursor": cursor,
},
}),
CliTransportPlan {
kind: "runtime_query_plan".into(),
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,
ctx,
envelope.name,
json!({
"workspaceId": workspace_id,
}),
CliTransportPlan {
kind: "runtime_query_plan".into(),
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,
ctx,
envelope.name,
json!({
"workspaceId": workspace_id,
"documentId": document_id,
"mindmapId": mindmap_id,
}),
CliTransportPlan {
kind: "runtime_query_plan".into(),
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,
ctx,
command.name,
command.command_id,
json!({
"workspaceId": workspace_id,
"documentId": document_id,
"mindmapId": mindmap_id,
"createOnly": create_only,
"data": data,
}),
CliTransportPlan {
kind: "runtime_command_plan".into(),
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}")))?;
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,
},
})
}
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,
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,
};
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"))
}
fn build_query_output(
domain: &str,
action: &str,
bridge: &BridgeContext,
ctx: &CliContext,
name: String,
normalized_input: Value,
transport: CliTransportPlan,
) -> CliJsonOutput {
CliJsonOutput {
ok: true,
entrypoint: "mnote-cli",
domain: domain.into(),
action: action.into(),
context: build_output_context(bridge, ctx),
operation: CliOperationOutput::Query {
name,
normalized_input,
transport,
execution: None,
},
}
}
fn build_command_output(
domain: &str,
action: &str,
bridge: &BridgeContext,
ctx: &CliContext,
name: String,
command_id: String,
normalized_input: Value,
transport: CliTransportPlan,
) -> CliJsonOutput {
CliJsonOutput {
ok: true,
entrypoint: "mnote-cli",
domain: domain.into(),
action: action.into(),
context: build_output_context(bridge, ctx),
operation: CliOperationOutput::Command {
name,
command_id,
normalized_input,
transport,
execution: None,
},
}
}
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(),
reason: ctx.reason.clone(),
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 {
CliError::validation(error.message)
}
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
CliError::validation(error.to_string())
}
#[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"));
assert!(commands.contains(&"block patch"));
assert!(commands.contains(&"block move"));
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"));
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()]
);
}
#[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");
assert_eq!(command_id, "cmd_page_save_page_1");
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "page.body.save");
assert_eq!(
transport.args_json,
json!({
"id": "page_1",
"content": [{"id": "block_1"}],
"expectedRevision": 7,
"conflictDetectionKey": "conflict_1",
})
);
}
_ => panic!("expected command output"),
}
}
#[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");
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");
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"),
}
}
#[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");
assert_eq!(transport.kind, "runtime_query_plan");
assert_eq!(transport.function_name, "sidebar.dataset.list");
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,
..
} => {
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");
assert_eq!(transport.function_name, "mindmaps.get");
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");
assert_eq!(transport.function_name, "mindmaps.put");
assert_eq!(
transport.args_json,
json!({
"docId": "page_1",
"mindmapId": "mind_1",
"data": {
"data": {
"text": "中心主题"
},
"children": []
},
"createOnly": true,
})
);
}
_ => panic!("expected command output"),
}
}
}