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:
parent
85da7e40e0
commit
6565a0f0ce
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue