diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 02346b1..6f4b64e 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -16,10 +16,12 @@ USAGE: wordcraft-cli inspect document structure (JSON) wordcraft-cli render [--page N] [--scale S] wordcraft-cli run [--file F | --template T] --cmd 'id={json}' [--cmd …] [--save OUT] [--print] - run commands headlessly, then save + [--author NAME] run commands headlessly, then save (--author names + who tracked changes and comments are by) wordcraft-cli commands [--json] list every command wordcraft-cli parity [--markdown] feature-catalog parity - wordcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless, or bridged to the app) + wordcraft-cli mcp [--connect HOST:PORT | --author NAME] + MCP server on stdio (headless, or bridged to the app) wordcraft-cli zotero [--cmd 'id={json}' …] [--save OUT] [--trace] [--port P] run a Zotero command on a document (Zotero must be running): addEditCitation, addEditBibliography, @@ -35,6 +37,11 @@ fn open(path: &str) -> Result { Ok(s) } +/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`). +fn set_author(s: &mut Session, name: &str) -> Result<(), String> { + s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}")) +} + fn arg_value(args: &[String], name: &str) -> Option { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned() } @@ -74,10 +81,11 @@ const RUN_OPTIONS: &[OptionSpec] = &[ OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }, OptionSpec { name: "--print", takes_value: false }, + OptionSpec { name: "--author", takes_value: true }, ]; const COMMANDS_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--json", takes_value: false }]; const PARITY_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markdown", takes_value: false }]; -const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--connect", takes_value: true }]; +const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--connect", takes_value: true }, OptionSpec { name: "--author", takes_value: true }]; const ZOTERO_OPTIONS: &[OptionSpec] = &[ OptionSpec { name: "--cmd", takes_value: true }, OptionSpec { name: "--save", takes_value: true }, @@ -142,6 +150,9 @@ fn run(args: &[String]) -> Result<(), String> { } _ => Session::new(wordcraft_doc::Document::new()), }; + if let Some(name) = arg_value(&rest, "--author") { + set_author(&mut s, &name)?; + } let mut i = 0; while i < rest.len() { if rest.get(i).map(String::as_str) == Some("--cmd") { @@ -202,7 +213,12 @@ fn run(args: &[String]) -> Result<(), String> { } "mcp" => { validate_options("mcp", &rest, MCP_OPTIONS)?; + let author = arg_value(&rest, "--author"); let backend: Box = match arg_value(&rest, "--connect") { + // The app's user name is the person's own setting; don't rewrite it from a flag. + Some(_) if author.is_some() => { + return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into()); + } Some(addr) => { let remote = wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?; if let Some(w) = remote.key_warning() { @@ -210,7 +226,13 @@ fn run(args: &[String]) -> Result<(), String> { } Box::new(remote) } - None => Box::new(wordcraft_mcp::Headless::default()), + None => { + let mut headless = wordcraft_mcp::Headless::default(); + if let Some(name) = author { + set_author(&mut headless.session, &name)?; + } + Box::new(headless) + } }; let mut server = wordcraft_mcp::Server::new(backend); let stdin = std::io::stdin(); @@ -345,4 +367,17 @@ mod tests { assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into())); assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into())); } + + #[test] + fn author_flag_names_the_headless_session() { + assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok()); + assert!(validate_options("mcp", &args(&["--author", "Claude"]), MCP_OPTIONS).is_ok()); + let mut s = Session::new(wordcraft_doc::Document::new()); + assert!(set_author(&mut s, " Claude (copyedit) ").is_ok()); + assert_eq!(s.author, "Claude (copyedit)"); + assert!(set_author(&mut s, " ").is_err_and(|e| e.starts_with("--author:"))); + assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok()); + // The app's user name is the person's own; a bridge flag must not rewrite it. + assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.contains("--author"))); + } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e39b445..d11dae6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -6,6 +6,26 @@ use wordcraft_doc::{Block, Document, StoryRef}; use super::{pos_json, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; +/// Longest user name kept, in characters; longer names are cut, not refused. +pub const MAX_AUTHOR_CHARS: usize = 255; + +/// Sets the user name that new tracked changes and comments are recorded under. The name is +/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to +/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author. +fn set_author(s: &mut Session, v: &Value) -> CmdResult { + let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); + let name = name.trim_end(); + if name.is_empty() { + return Err(CmdError::Params("`name` must not be blank".into())); + } + s.author = name.to_string(); + let mut r = sel_result(s)?; + if let Some(o) = r.as_object_mut() { + o.insert("name".into(), json!(s.author)); + } + Ok(r) +} + pub fn specs() -> Vec { vec![ CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(), @@ -43,11 +63,9 @@ pub fn specs() -> Vec { sel_result(s) }) .pure(), - CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", |s, v| { - s.author = p::req_str(v, "name")?.to_string(); - sel_result(s) - }) - .pure(), + CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author) + .params(r#"{"name": string (author of new tracked changes and comments)}"#) + .pure(), CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(), CommandSpec::new("document.text", "Document Text", "Agents", |s, v| { let story = super::story_param(s, v); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index a2cc822..a8a4729 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -25,6 +25,7 @@ pub fn specs() -> Vec { s.doc.settings.track_changes = p::bool(v, "value").unwrap_or(!s.doc.settings.track_changes); Ok(json!({"value": s.doc.settings.track_changes})) }) + .params(r#"{"value"?: bool (omit to toggle)}"#) .key("Mod+Shift+E"), CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, _| resolve_all(s, true)), CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, _| resolve_all(s, false)), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 13ea76d..5df02fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -662,6 +662,53 @@ fn tracked_split_gives_the_new_paragraph_mark_to_its_author() { } } +#[test] +fn set_author_names_tracked_changes_headlessly() { + // Issue #90: a script or agent labels its own edits without any dialog. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "original"})); + let r = run(&mut s, "file.setAuthor", json!({"name": " Claude (copyedit)\n"})); + assert_eq!(r["name"], "Claude (copyedit)"); + assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": " added"})); + run(&mut s, "select.text", json!({"text": "orig"})); + run(&mut s, "text.delete", json!({})); + let ch = run(&mut s, "review.changes", json!({})); + let ch = ch.as_array().unwrap(); + assert_eq!(ch.len(), 2); + assert!(ch.iter().all(|c| c["author"] == "Claude (copyedit)"), "{ch:?}"); + // Comments use the same name. + run(&mut s, "select.text", json!({"text": "added"})); + run(&mut s, "review.newComment", json!({"text": "Tightened."})); + assert!(s.doc.comments.values().all(|c| c.author == "Claude (copyedit)" && c.initials == "C(")); +} + +#[test] +fn set_author_refuses_blank_names_and_clamps_long_ones() { + let mut s = s(); + for bad in [json!(null), json!({}), json!({"name": 5}), json!({"name": ""}), json!({"name": " \t\n "}), json!({"name": "\u{0}\u{7}"})] { + assert!(s.run("file.setAuthor", &bad).is_err(), "{bad} should be refused"); + assert_eq!(s.author, "WordCraft User", "a refused name leaves the author alone"); + } + run(&mut s, "file.setAuthor", json!({"name": "Ada\u{0}\u{1b} Lovelace"})); + assert_eq!(s.author, "Ada Lovelace", "control characters can't reach the saved XML"); + run(&mut s, "file.setAuthor", json!({"name": "é".repeat(100_000)})); + assert_eq!(s.author.chars().count(), cmd::file::MAX_AUTHOR_CHARS); +} + +#[test] +fn track_changes_and_set_author_list_their_params() { + // Scripts that only read the command list must find the explicit, idempotent form. + let reg = cmd::registry(); + assert!(reg.get("review.trackChanges").unwrap().params.contains(r#""value"?: bool"#)); + assert!(reg.get("file.setAuthor").unwrap().params.contains(r#""name": string"#)); + let mut s = s(); + for _ in 0..2 { + assert_eq!(run(&mut s, "review.trackChanges", json!({"value": true}))["value"], true); + } +} + #[test] fn no_markup_view_lays_out_the_final_text() { let mut s = s(); diff --git a/docs/mcp.md b/docs/mcp.md index b9a51cb..00a9d6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -15,6 +15,15 @@ With `--connect`, the bridge sends the app's control key, which it reads from `WORDCRAFT_CONTROL_KEY` or from the key file the app writes at start. It sends the key only to loopback addresses; see [Keys](control-protocol.md#keys). +## Tracked changes by an agent + +Tracked changes and comments are recorded under the session's user name ("WordCraft User" by +default). Name the agent so reviewers can tell its edits from people's: start the headless server +with `wordcraft-cli mcp --author "Claude (copyedit)"`, or run +`file.setAuthor {"name": "Claude (copyedit)"}`. Turn tracking on explicitly with +`review.trackChanges {"value": true}`; without `value` it toggles, which turns tracking *off* in +a document that was saved with it on. `wordcraft-cli run` takes `--author` too. + ## Tools | Tool | What it does |