开源项目 OfficeCLI 的命令面全景:一套动词打通三种文档

2026-08-05

本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。

OfficeCLI 真正值得工程师研究的地方,不是”不装 Office 也能读写 docx”,而是它把三种结构完全不同的文档压成了同一张动词表——addsetgetqueryremove 这五个词在 .docx / .xlsx / .pptx 里指的是同一件事,Agent 学一次就能用在三种文件上。 对一个要靠上下文预算过日子的模型来说,这个”学一次”的价值,远大于多支持几个花哨属性。

先做几处消歧。OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个具体的开源项目,Apache-2.0 许可证,仓库 NOTICE 文件写的是 Copyright 2026 OfficeCLI,由 goworm 创建并维护。它和微软没有从属、授权或官方合作关系,名字里的 Office 以及下文提到的 Word / Excel / PowerPoint,指的都是文件格式和对应的应用,不是这个项目的归属。仓库 README 在开头给自己的定位是”世界上第一个也是最好的、为 AI Agent 设计的 Office 套件”——这是项目自己的说法,本文不替它背书,只看代码里能核实的部分。

这篇和站内几篇相邻文章的分工:Agent 工具设计的原则讲的是你自己造工具时怎么划分粒度,工具描述该怎么写讲的是描述文本本身的写法,opencode 的 CLI 命令面拆的是另一个项目的终端命令组织;本篇只做一件事——把 OfficeCLI 这套动词面的切法、它在仓库里的落点、以及它换来了什么代价,讲到你能自己去仓库对照的程度。

一、命令面到底长什么样

先把”这个工具有哪些动词”这件事落实。src/officecli/CommandBuilder.cs 里的 BuildRootCommand() 是唯一的装配点,所有子命令都在这一处挂上根命令:

rootCommand.Add(BuildViewCommand(jsonOption));
rootCommand.Add(BuildGetCommand(jsonOption));
rootCommand.Add(BuildQueryCommand(jsonOption));
rootCommand.Add(BuildSetCommand(jsonOption));
rootCommand.Add(BuildAddCommand(jsonOption));
rootCommand.Add(BuildRemoveCommand(jsonOption));
rootCommand.Add(BuildMoveCommand(jsonOption));
rootCommand.Add(BuildSwapCommand(jsonOption));

把仓库里所有 new Command("...") 扫一遍,对外可见的顶层动词是:openclosewatchunwatchviewgetquerysetaddremovemoveswaprefreshrawraw-setadd-partvalidatesavebatchdumpimportcreatemergepluginshelp。另外还有几个不在这张表上但确实注册了的:__resident-serve__ 是常驻进程自己用的内部命令,标了 Hiddenmark / unmark / get-marks / goto 也标了 Hidden,它们已经收进 watch 下面当子命令,顶层这几个只是旧写法的兼容别名;plugins 自己还带 list / info / lint 三个子命令。这里有个必须提前说清的坑:没有 check 这个命令,做 schema 校验的动词叫 validate,而它的实现文件偏偏叫 CommandBuilder.Check.cs。文件名和命令名对不上,是典型的历史遗留,你按文件名去猜命令一定会翻车。

CommandBuilder 本身是 C# 的分部类(partial class,就是同一个类拆到多个文件里写,编译时再合成一个),仓库里 src/officecli/CommandBuilder*.cs 一共 18 个文件,一个动词族一个文件。这不是洁癖,是让”改 set 的行为”不会顺手碰到 get 的代码。

README 里把这些动词归成三层:L1 是语义视图(view 的 text / annotated / outline / stats / issues 等模式),L2 是元素级 DOM 操作(get / query / set / add / remove / move / swap),L3 是原始 XML 兜底(raw / raw-set / add-part / validate)。src/officecli/Core/IDocumentHandler.cs 的接口注释用的是另一套名字——Semantic layer / Query layer / Raw layer,分层切法对得上,只是接口注释里的 Query layer 只点名了 get、query、set 三个,add / remove / move 这些写在下面的方法列表里。这里的 DOM 借的是浏览器那套说法——把文档看成一棵可寻址的树,用 /body/p[1]/r[1]/slide[1]/shape[2]/Sheet1/A1 这样的路径定位到某个节点。

组成部分它负责什么对应仓库位置你什么时候会碰到它
根命令装配把全部子命令挂上 RootCommand,并统一注入 --jsonsrc/officecli/CommandBuilder.cs想确认”到底有哪些动词”时
增删挪动族add / remove / move / swap 的参数与校验src/officecli/CommandBuilder.Add.cs往文档里塞元素、删元素、调顺序
属性写入族set 的属性应用、查找替换、结果回执src/officecli/CommandBuilder.Set.cs改字体颜色、批量替换文本
读取族get 单点读 + query 选择器批量读src/officecli/CommandBuilder.GetQuery.cs定位路径、列元素清单
序列化dump 把子树导成可重放的 batch JSONsrc/officecli/CommandBuilder.Dump.cs想把现成文档当蓝本复制
校验validate 走 OpenXML schema 校验并给非零退出码src/officecli/CommandBuilder.Check.cs交付前做一道自检门
能力清单声明每个元素支持哪些动词、哪些属性schemas/help/让 Agent 自己查”这个元素能不能 set”
三个实现体Word / Excel / PowerPoint 各自的落地src/officecli/Handlers/排查某属性为什么只在一种格式生效

规模上给几个你自己 ls 就能复现的数字:整个仓库 1201 个受版本控制的文件,src/officecli/ 下 469 个文件(其中 353 个 .cs),schemas/ 153 个文件(152 份 json),skills/ 下 11 个技能目录各带 1 份 SKILL.md,examples/ 381 个文件,sdk/ 下分 node 和 python 两套,assets/ 37 个,仓库根目录放着 en / zh / ja / ko 四个语言版本的 README 和一份根级 SKILL.md。

二、同一个动词凭什么在三种文档里语义一致

光把命令起成一样的名字不叫一致,一致要靠约束顶住。OfficeCLI 用了两道。

第一道在接口层。IDocumentHandler 是 Word / Excel / PowerPoint 三个 handler 共同实现的接口,读写方法只有这么几个签名:Get(path, depth)Query(selector)Set(path, properties)Add(parentPath, type, position, properties)Remove(path, properties)Move(...)CopyFrom(...)。CLI 层任何一个动词,最终都落到这几个方法上。也就是说,跨格式的语义一致不是文档里的承诺,是类型系统卡出来的——想给 pptx 单开一个别的形状的写入口,先得改共用接口。

CommandBuilder.Help.cs 里那段注释把这层关系讲得很直白:

// Recognized verbs that route help through the operation-scoped filter.
// Matches IDocumentHandler's public surface — keep in sync if new verbs
// are added to the handler API.
private static readonly string[] HelpVerbs =
    { "add", "set", "get", "query", "remove" };

帮助系统认的动词集合,被明文要求跟 handler 的公开面对齐。这就是为什么你敲 officecli help pptx set 得到的是”pptx 里哪些元素支持 set”,敲 officecli help docx set 得到的是同一个问题在 docx 上的答案——同一个提问模板,换个格式词就能复用。

第二道在 schema 层。schemas/help/ 下按格式分目录:docx 46 份、pptx 34 份、xlsx 41 份,另有一个 _shared/ 放 30 份共享定义,加上一份 _schema.json 元描述。每个元素一份 json,里面用布尔位声明它支持哪些操作:

"operations": {
  "add": true,
  "set": true,
  "get": true,
  "query": true,
  "remove": true
}

跨格式一致靠的是 extends。以 schemas/help/docx/picture.json 为例:

"extends": [
  "_shared/picture",
  "_shared/picture.docx-pptx",
  "_shared/picture.docx-xlsx"
]

_shared/picture.json 定义三种格式都成立的属性(比如 alt 替代文本、contentTypefileSize),picture.docx-pptx.json 放只在这两种格式里成立的部分,picture.docx-xlsx.json 同理。共享目录里这种两两组合的命名一抓一把:chart.docx-pptx.jsonole.pptx-xlsx.jsonrun.docx-xlsx.jsontable.pptx-xlsx.json。真正只属于某一种格式的东西才留在本格式文件里——docx 的 picture 里就单独定义了 decorative(把图片标记为装饰性、让屏幕阅读器跳过)。

schemas/README.md 说明了这些 json 有三个消费方:一是运行时给 Agent 看的帮助输出(schema 在构建期被嵌进二进制,所以运行时不依赖文件路径和网络),二是契约测试——每条 schema 声明都会拿真实 handler 实现去验,标了 enforcement: strict 的属性一旦漂移就挂 CI,标 report 的只记录,三是发布期的 wiki 生成——第三条在 README 里被明确标成 future,眼下还没启用,开发期是让 Agent 直接读 schema。同一份文件还写了一条硬规矩:任何改动 Add / Set / Get 行为的 PR,必须在同一个 PR 里更新对应 schema,否则契约测试失败。

这才是”语义一致”的实际含义:不是靠人自觉,是靠一份被测试盯着的声明表。

三、这套动词面对 Agent 到底友好在哪

把它拆成几条可验证的机制看。

输出信封是统一的。 --json 这个开关在根命令上定义一次,然后逐个传给每个子命令构造函数。get --json 返回的形状被有意做成和 queryget selected 一样的 {matches, results: [...]},代码注释里明说了动机是”让 Agent 和脚本在所有地方用同一条 jq 路径”。工具返回值的形状统一到什么程度才够用,站内另有一篇专门聊过:Agent 工具返回值怎么设计

退出码是分层的,不只有 0 和 1。 命令跑完了但有属性没写进去 → 2(unsupported_property,只要还剩一个没应用上就是 2,不必全军覆没);抛异常被兜底捕获(路径解析不出来、参数非法之类)→ 1;常驻进程活着但命令投递不进去 → 3(ResidentBusyExitCode,代码里专门给这个场景留了独立码,就是不想让它跟命令级失败混在一起);validate 有错误 → 1。Agent 可以只看退出码就决定是重试、换路径、还是换写法。

拼错的属性名会被有条件地自动纠正。 ApplySetWithCorrection 这个方法是 CLI、batch、MCP、常驻四个入口共用的一份实现(注释里写明以前是手工镜像的四份拷贝,容易漂移)。逻辑是:handler 拒掉某个属性名后,去候选池里找编辑距离为 1 且唯一的近邻(Levenshtein 距离 1,就是差一个字符的改动,比如 colotcolor),试写一次,成功才算数,同时在输出里明确告诉你”Auto-corrected: colot→color”。纠正是有的,但不静默。

“这个属性到底支不支持”以 handler 为准,不以 schema 为准。 add 这条路径上,属性字典被包进 TrackingPropertyDictionary 再交给 handler,handler 读过哪些键会被记下来,一个都没被碰过的键就报 unsupported_propertyset 走的是另一条:接口里的 Set 直接返回一串没能应用的属性名)。注释里写了这么改的原因:以前是拿 schema 做前置过滤,结果把 handler 其实认得、schema 还没来得及登记的合法别名给砍了。

写进去的值和你写的不一样时会回显。 BuildAppliedSuffix 会在回执后面追加一段 (applied: key=value, ...),只在存储形态跟请求不同的时候才出现——注释举的例子是裸的 font 被展开成 font.latin / font.eared 被规范成 #FF000014 被补成 14pt。写进去和写出来完全一致时,回执一个字都不多。这对 Agent 很关键:它能据此知道自己下一轮该拿什么值去比对,而不是自己脑补。

读取结果能自证读全了。 query --compact 的输出格式在代码注释里被明确称为”stability contract”(稳定契约):每行是 路径 TAB [标签] TAB "文本(截断在 60 字符,带 … 标记)",表格折叠成 [table RxC],最后一行永远是 total: N of M elements。注释里点破了 N 的用途——N 恰好等于上面的行数,所以 lineCount - 1 == N 就能证明读者看完了全部结果;M 是文档里所有顶层框架的数量,跟你的选择器无关,用来对比”我匹配到的”和”实际存在的”。契约还规定:列可以往后加,已有的列顺序和含义不许改。

危险的批量写入被挡在门外。 MutationSelectorGuard.EnsureScoped 只对 setremove 生效,拒绝没有作用域的裸选择器。注释写得很吓人也很实在:一句 set "cell" 会重写每个工作表里的每个单元格,一句 remove "run" 会删掉全文每一段文字。允许的形态只有两种——以 / 开头的路径,或 Excel 的 Sheet1!A1 记法。query 故意不设防,因为它只读,而裸类型选择器正是它的主力用法。这条思路和参数校验该在哪一层做是同一类判断:校验放在最贴近 Agent 的那一层,而不是最深的那一层。

四、它动的是你磁盘上的真文件

这一节别跳过。OfficeCLI 不是在沙箱里操作副本,officecli set report.docx ... 改的就是 report.docx 本身。想留原件,只能你自己先复制一份再改。

常驻进程改变了”什么时候真的落盘”这件事。 open 会起一个常驻进程把文档留在内存里,默认空闲超时 12 分钟(代码里 DefaultOpenIdleSeconds = 12 * 60)。更容易被忽略的是自动起:TryResident 发现没有常驻进程时,会自己拉起一个空闲 60 秒的短命常驻,create 也一样。所以就算你从没敲过 open,你的命令也可能是走常驻路径执行的。这么做的原因写在注释里——多条命令并发打同一个文件时避免文件锁冲突;不想要这个行为,用环境变量 OFFICECLI_NO_AUTO_RESIDENT=1 关掉。

后果是:命令返回成功,不等于磁盘上的文件已经变了。close 的帮助文本原话是刷盘并停止常驻、释放文件,save 是刷盘但保持常驻温着,“在非 officecli 程序读这个文件之前,两者必须选一个”;活着的常驻进程也会在空闲后不久自动刷盘,节奏是自适应的 2 到 10 秒,可用 OFFICECLI_RESIDENT_FLUSH(取值 eachauto、秒数、off)调。如果你的流水线是”officecli 改一下、Python 读一下”,把它设成 each 才安全。

批量中途失败会留下什么,取决于你用了哪个开关。 batch 默认是原子的:在同目录建一个临时副本、全部命令跑在副本上、每一项都成功才把副本提升覆盖原文件,原文件全程不以写模式打开。代码注释给的理由是”部分应用是对程序化调用方最脏的结果——文档停在一个调用方从没要求过的状态,而失败结论和磁盘上真实落地的东西还对不上”。加 --best-effort 就退回旧语义:成功的项保留,失败的项跳过,这时磁盘上会留下半成品。--stop-on-error 只控制”要不要早停”,跟原子性正交——默认原子模式下早停也是全部回滚,配合 --best-effort 才是”前面的留着、从失败处停下”。

还有两个细节值得记:全部由只读动词组成的批次会跳过复制这一步(ReadOnlyBatchVerbsgetqueryviewvalidatedumpraw),所以只读批量不会有临时文件;进程被 kill -9 之类打断时临时副本自己清不掉,代码里有一段清扫逻辑,只清本文档对应的命名模式、且要同时通过”能独占打开”和”年龄超过 15 分钟”两道闸门,就是怕误删另一个正在跑的批次的工作文件。你在文档目录里看到 .文件名.batch-*.docx 这类隐藏文件,来源就在这。

它会按指令去拉外部资源。 加图片时的 src 属性由 src/officecli/Core/ImageSource.cs 解析,接受三种来源:本地路径、data: 内联 URI、以及 http(s) URL。表格数据、3D 模型、媒体走的是 FileSource,同样能吃 URL。这意味着一份来路不明的 batch 脚本、或者一段藏在文档里的指令,可以让这个工具替你发出网络请求。项目对此做了防护,src/officecli/Core/SsrfGuard.cs 的注释把威胁模型写得很清楚:URL 可能来自不可信输入,未加防护的抓取就是一个 SSRF 原语(服务端请求伪造,指诱导服务器去访问它内网里的地址),可以用来探测内网主机和云元数据端点,或者把它们的响应体塞进产出的文档里。具体做法是在连接回调里校验真实 IP——不是提前解析域名,而是在每一跳(含最多 10 次重定向的每一次)连接时校验,顺带关掉 DNS 重绑定的时间窗;落到回环、私有、链路本地地址一律拒绝。加上两个写死的上限:远程资源 100 MB(SsrfGuard.MaxRemoteBytes),HTTP 超时 30 秒。

防护到位是一回事,暴露面存在是另一回事。你在 CI 或者服务器上跑它,就要意识到这个进程有出网能力。

watch 会在本机开 HTTP 服务。 默认端口 26315,把文档渲染成 HTML 给浏览器实时预览。这是个便利功能,也是一个新增的本地监听面,共享机器上要留意。

五、边界与代价

统一动词面不是白拿的,它换掉了几样东西。

放弃了各格式的”母语表达”。 Excel 用户脑子里的操作是”筛选、透视、条件格式”,PowerPoint 用户脑子里是”母版、动画、层级”,这些在统一动词面下都要被翻译成”对某个路径 add/set 一个带属性的元素”。schema 里 xlsx 有 pivottable.jsonconditionalformatting.jsonsparkline.json,pptx 有 animation.jsontransition.jsonmodel3d.json,能力是有的,但你得先接受用 add --type pivottable --prop ... 这种句式去表达它。对人不算顺手,对 Agent 反而正好——它本来就不需要”顺手”,它需要的是可枚举。

路径是位置型的,会漂移。 /body/p[5] 指的是第 5 个段落,你在前面插一段,它就指向别的东西了。schema 的元描述 _schema.json 里,paths 分成 stablepositional 两类,但不是每个元素都两样齐全:docx 的 bookmark 两样都有(stable/bookmark[@name=NAME]positional/bookmark[N]),comment、footnote 同理;docx 的 picture 就只声明了 positional,形态是 /body/p[@paraId=X]/r[N]——前半段虽然用 @paraId 锚住了段落,末尾的 r[N] 仍是序号。所以判断一条路径稳不稳,别看它长什么样,去查那个元素的 schema 有没有给 stable;多步编辑之间,能用 stable 形态就别用纯序号。

dump 的子树导出不是完整快照。 参数说明里写死了:子树 dump 不包含处在兄弟路径上的资源(styles、numbering、theme;pptx 的 master / layout / theme;xlsx 的工作簿设置与命名区域),重放的目标文档必须已经定义了被引用的样式、numId 和版式。拿它当”复制一段带样式的内容到空白文档”用,会掉格式。而且 --format 目前只认 batch 一个值,别的值直接报 invalid_format

query --compact 有明确的适用面。 它和 --json 互斥,代码里直接抛错说”这是纯文本行格式,二选一”;xlsx 也不支持,报错里指路 view text --range

L3 是逃生舱,出去了就没有护栏。 raw / raw-set 直接按 XPath 操作 OOXML 部件(.docx / .xlsx / .pptx 本质上是 zip 包,里面装着一堆 XML 部件,OOXML 就是这套包结构和 XML 的规范)。走这条路,前面讲的属性校验、自动纠错、能力 schema 一概不适用,你自己保证写出来的 XML 合法——所以 validate 才是这一层的常驻搭档。set 命令里有一处细节能看出项目对这个逃生舱的态度:Word 的 /styles/ 路径上遇到不支持的属性,会走一套专门的样式提示,而不是甩一句”用 raw-set 吧”,注释里的理由是”逃生舱不该成为默认答案,把用户推过去等于训练他们放弃正规词汇表”。

它明确不管的事。 不做 .doc.hwpx 这类非 OOXML 格式的本体支持,也不自带 PDF 导出——README 把这些归给插件体系(plugins 命令下的 dump-reader / exporter / format-handler 三类插件)。它也不替你做版本控制:没有内建的 undo,唯一可靠的回退手段是你自己在动手前留副本。

六、上手与避坑清单

别按文件名猜命令。 会踩是因为 CommandBuilder.Check.cs 里装的是 validate。怎么避:以 officecli help 的输出为准,那份列表是从根命令实际注册的子命令生成的,不会跟代码脱节。

别忘了给路径加引号。 会踩是因为 zsh、bash 这些 shell 会对 [1] 做通配展开,/slide[1] 到不了程序手里。怎么避:仓库自己的示例就在示范这件事——SKILL.md 里的写法一律是 officecli set doc.docx '/body/p[1]' --find weather --prop bold=true 这种单引号形态,add 命令的参数说明里也专门写了”含方括号的路径在 zsh 下用单引号包起来”。

别用裸的 key=value 传属性。 会踩是因为 addset 都设了 TreatUnmatchedTokensAsErrors = false,多余的 token 不会直接报错。怎么避:一律用 --prop key=value。写漏了程序会给你一条 Bare property 'xxx' ignored. Did you mean: --prop xxx 的警告并返回退出码 2,但前提是你真的去看 stderr 和退出码了。

别把 --from--prop 混在一条命令里。 会踩是因为直觉上”复制一个再顺手改改”很自然。怎么避:这个组合会被直接拒绝,因为 --from 是逐字复制、不应用属性覆盖;正确做法是先 add --from 复制,拿到返回的新路径,再对新路径 set。同理,--index--after--before 三选一,同时给两个也会报错。

别在改完之后立刻让别的程序去读文件。 会踩是因为常驻进程可能还没刷盘,而你的命令已经返回成功了。怎么避:读之前先 officecli save <文件>(刷盘、常驻继续温着)或 officecli close <文件>(刷盘并释放文件,两个命令都要带文件参数);如果整条流水线都是”改一下读一下”,设 OFFICECLI_RESIDENT_FLUSH=each。顺带记一点:没有常驻进程时执行 close 不是错误,它会告诉你”已经在磁盘上了,没什么可关的”并返回成功——所以”改完就 close”可以放心当成固定习惯。

别以为 --best-effort 是”更宽容所以更好”。 会踩是因为这个名字听起来很友善。怎么避:默认的原子模式才是给程序化调用准备的(要么全成要么什么都不落),--best-effort 会在磁盘上留下半成品文档。只有在你明知有一批注定失败的项、且要的就是”能落多少落多少”时才用它——注释里给的典型场景正是重放一份含已知不支持项的 dump。

别忽略退出码 2。 会踩是因为它对应的场景是”命令跑完了、但有些属性没写进去”,输出看着像成功。怎么避:脚本里把 2 单独处理,去读 JSON 信封里的 warnings 数组,看是 unsupported_propertyauto_corrected 还是 text_overflow

别在不可信的输入上直接跑 batch。 会踩是因为 batch 脚本里的 src 可以是 URL,工具会真的去发请求。怎么避:把 SSRF 防护当成最后一道网而不是第一道——先自己过滤脚本里的外部 URL;在能出网的构建机上跑时,宁可把图片提前下载到本地再传本地路径。

收尾:看完这篇之后读哪几个文件

如果你要判断 OfficeCLI 适不适合接进自己的 Agent,我建议按这个顺序花半小时:

src/officecli/Core/IDocumentHandler.cs,40 行接口就能看清它把文档抽象成了什么;再 schemas/help/_schema.json 加任意一份元素 json,看它怎么声明能力、以及 extends 怎么把三种格式的共同部分提出去;然后 src/officecli/CommandBuilder.Set.cs,这是单个动词最完整的一条链路——参数解析、作用域守卫、常驻转发、属性应用、回执构造、退出码;最后 src/officecli/CommandBuilder.Batch.cs 里那段原子提交的注释,它决定了失败时你的文件会是什么样。

接入前的三个问题,问自己就行:我的流水线里,officecli 改完文件之后下一个读它的是谁,中间有没有刷盘;批量失败时我要的是全回滚还是留半成品,对应的开关有没有显式写上;这个进程被允许出网吗,如果不允许,我在哪一层拦。这三个答不上来,先别接。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 把开源项目 OfficeCLI 接进你的 Agent:内置 MCP 服务器与一键注册路径OfficeCLI 开源项目的选择器语法:三份解析实现与最常见的选错点

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。