Headless: file.setAuthor takes a name; review.trackChanges lists its value param (#90) (#224)

file.setAuthor now documents {"name": string} in the command list, trims the
name, strips control characters, caps it at 255 characters and refuses a blank
name. wordcraft-cli run and headless mcp take --author NAME. review.trackChanges
lists {"value"?: bool} so scripts find the explicit, idempotent form.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-10 15:43:05 +02:00 committed by GitHub
parent 85da7e40e0
commit 6565a0f0ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 119 additions and 9 deletions

View File

@ -16,10 +16,12 @@ USAGE:
wordcraft-cli inspect <file> document structure (JSON)
wordcraft-cli render <file> <out.png> [--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 <command> <file> [--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<Session, String> {
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<String> {
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<dyn wordcraft_mcp::Backend> = 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")));
}
}

View File

@ -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<CommandSpec> {
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<CommandSpec> {
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);

View File

@ -25,6 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)),

View File

@ -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();

View File

@ -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 |