开源项目 OfficeCLI:152 份 json 撑起的文档即契约设计
本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。
如果你的工具有几百个参数,让模型少猜的办法不是把帮助文本写得更长,而是让帮助文本不再由人手写。 OfficeCLI 这个开源项目(Apache-2.0,NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护,仓库在 iOfficeAI/OfficeCLI)把这件事做成了一层结构:schemas/help/ 下的 152 份 json 是唯一事实源,运行时的帮助输出、参数校验、契约测试都从这里长出来,还额外给整棵树算了一个 CRC32 指纹,让下游能一条命令判断「升级后这份契约变没变」。
先做个消歧。OfficeCLI 是这个开源项目的专有名字,不是泛指「用命令行操作 Office」,也和微软没有任何从属、授权或官方合作关系;下文提到 Word / Excel / PowerPoint,指的是 .docx / .xlsx / .pptx 这三种文件格式和打开它们的应用,不是这个项目的归属。它是一个单文件二进制,机器上不装 Office 也能读写这三种文件。
这一篇跟站内几篇相邻文章分工不同:怎么给一个工具写出模型读得懂的描述文本,在工具描述怎么写里;模型吐出的结构化结果不稳该怎么兜底,在结构化输出不稳里;把能力切成清单式 manifest 的组织法,在组件化 manifest里。本篇只回答一个更靠后的问题:当描述文本多到人已经写不动、也对不齐实现时,怎么把它降格成可编译、可校验、可指纹的数据。
一、152 份 json 的形状:一个元素一份契约
先说这层解决什么。OfficeCLI 的命令面很宽——三种格式,每种格式底下几十种元素(段落、表格单元格、图表系列、条件格式、批注……),每种元素又有一堆属性。如果把这些写进代码里的帮助字符串,实现改了、字符串没改,Agent 拿到的就是过期承诺;而 Agent 读到过期承诺的后果不是报错,是它按承诺发命令、然后拿到一个自己也解释不了的失败。
它的做法是按 (格式, 元素) 一份 json 落盘。目录布局在 schemas/README.md 里写死:
schemas/
help/
_schema.json ← JSON Schema (draft 2020-12) describing the format below
docx/<element>.json ← Word per-element capability
pptx/<element>.json ← PowerPoint per-element capability
xlsx/<element>.json ← Excel per-element capability
自己数一遍:schemas/ 目录共 153 个文件,其中 152 份 json、1 份 README。json 拆开是 docx/ 46 份、xlsx/ 41 份、pptx/ 34 份、_shared/ 30 份,加上顶层那份 _schema.json。_shared/ 是跨格式复用的公共部分,具体格式文件靠 extends 字段引它。
_schema.json 是「描述 schema 的 schema」——一份能力 json 必须长什么样,由它规定。它要求每份文件至少有 format、element、operations、properties 四个键,并且 additionalProperties: false,多写一个字段就不合法。往下看几个字段,能看出这套设计想覆盖什么:
operations:这个元素接受哪些动词,枚举是add/set/get/query/remove。paths:这个元素在命令里怎么被寻址,分stable(稳定标识,如/body/p[@paraId=ID])和positional(位序,如/body/p[N])两种。properties:每个属性一条记录,带type(枚举里只有 string / bool / number / color / length / font-size / enum)、aliases、values、requires、readback,以及分动词的add/set/get布尔位。enforcement:值只能是strict或report。按schemas/README.md的说法,标strict的属性一旦契约测试发现漂移就断 CI,标report的只记日志。_schema.json里这个字段自己的描述补了一句默认值:新属性默认strict,历史欠债可以先挂report再迁移。
拿 schemas/help/docx/paragraph.json 里 lineRule 这一条看实际形状:
"lineRule": {
"type": "enum",
"values": ["auto", "exact", "atLeast"],
"aliases": ["linerule"],
"add": true, "set": true, "get": true,
"examples": ["--prop lineSpacing=14pt --prop lineRule=atLeast"],
"readback": "auto | exact | atLeast",
"enforcement": "report"
}
(原文件里这条还带一个 description 字段,写的是 auto / exact / atLeast 三个值分别对应倍数行距、固定行高、最小行高,上面为省版式略去了。)
一条记录里同时装了:类型、可选值、输入时容忍的别名、三个动词分别支不支持、一个可直接复制的例子、Get 读回来长什么样、以及这条约束当前的强制级别。人读的帮助、机器读的 json、契约测试要断言的东西,全是这一条的不同投影。
schemas/README.md 还写了一条硬性协作规则:任何改动某元素 Add / Set / Get 行为的 PR,必须在同一个 PR 里改对应的 schema 文件,否则 CI 契约测试会失败。这句是整套设计成立的前提——没有这条,json 三个月后就是一堆考古材料。
整层拆开是这么几块,你可以照着这个粒度去对自己的项目:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 元 schema | 规定一份能力 json 必须长什么样,字段封闭不许多写 | schemas/help/_schema.json | 新增元素、想知道字段能写什么的时候 |
| 逐元素能力 json | 每个「格式 + 元素」一份,声明动词、路径写法、属性、别名、强制级别 | schemas/help/docx/、xlsx/、pptx/ | 想确认某个元素到底能设哪些属性 |
| 共享基底 | 跨格式复用的公共属性,由具体格式文件 extends 引入 | schemas/help/_shared/ | 三种格式行为一致的属性不想抄三遍 |
| 嵌入规则 | 构建时把整棵树塞进程序集,运行时不依赖磁盘目录 | src/officecli/officecli.csproj | 好奇单文件二进制为什么不带 schema 目录也能跑 |
| 加载与合并 | 解析格式与元素别名、合并 extends、按动词过滤属性 | src/officecli/Help/SchemaHelpLoader.cs | 排查「help 认得这个名字吗」 |
| 三种渲染 | 人读视图、原始 JSON、一行一记录的扁平视图 | src/officecli/Help/SchemaHelpRenderer.cs、SchemaHelpFlatRenderer.cs | 想把能力树喂给 Agent 或直接 grep |
| 指纹 | 对嵌入的 schema 资源整体算 CRC32 | src/officecli/Help/SchemaCrc.cs | 升级二进制后要确认属性面没变 |
二、编译期嵌进二进制:这份契约怎么送到 Agent 手上
这里解决的是分发问题。单文件二进制的卖点是拷过去就能跑,如果 schema 要靠旁边一个目录才能读到,这个卖点就废了。
src/officecli/officecli.csproj 里一行 MSBuild(.NET 的构建脚本格式)把整棵树塞进程序集:
<EmbeddedResource Include="..\..\schemas\help\**\*.json"
LogicalName="schemas/help/%(RecursiveDir)%(Filename)%(Extension)" />
「嵌入资源」就是把文件的字节直接编译进可执行文件,运行时用一个逻辑名取出来,不碰文件系统。csproj 旁边的注释把动机写得很直白:这样 SchemaHelpLoader 用 Assembly.GetManifestResourceStream 就能读到,单文件安装包只发一个 exe,不需要在磁盘上摊开。
src/officecli/Help/SchemaHelpLoader.cs 是取用侧。它做的事比「按名字取文件」多几层,每一层都对应一类 Agent 会犯的错:
格式别名。 它维护一张不分大小写的映射表,word → docx、excel → xlsx、ppt / powerpoint → pptx。查不到时不是直接抛「未知格式」,而是先用编辑距离找最接近的候选,把 Did you mean: …? 拼进异常消息。
路径分隔符归一。 MSBuild 在 Windows 上生成资源名时 %(RecursiveDir) 可能用 \ 也可能用 /。加载器建索引时统一把 \ 换成 /,键做成 schemas/help/{format}/{element}.json 这种规范形式,再映射回 MSBuild 实际发出的那个名字。这类跨平台细节不处理,就是 Linux 上跑得好好的、Windows 上「资源找不到」。
元素名的两套命名空间。 命令里的路径写法是 /body/p[N]、/Sheet1/col[B],而 schema 文件名叫 paragraph.json、column.json。加载器允许每份 schema 用顶层 elementAliases 数组把自己的路径形式名字登记出来,于是 help docx p 和 help docx paragraph 等价。源码注释里还专门处理了 /:因为 set / get / query 都用 / 表示文档根,help 里也把 help xlsx / 映射到 workbook、help docx / 映射到 document、help pptx / 映射到 presentation。注释给的理由很值得抄——不这样做的话,Agent 会从 set / get 的词汇里合理外推出 /,然后撞上「unknown element ’/’」。这是把「模型的合理外推」当成需求,而不是当成用户错误。
继承与合并。 一份 schema 可以用 extends 引 _shared/ 下的基础文件,值可以是单个字符串也可以是数组。合并规则写在 MergeSchemaJson 的文档注释里:顶层标量和数组由 override 覆盖 base;properties 取并集,同名属性由 override 整条替换(不做属性内部的深合并,属性是原子的);合成时把 extends 和 shared_base 这两个标记字段去掉。属性顺序也有讲究——override 里声明的排前面,base 独有的按 base 顺序追加,保住格式文件作者手写的排列。
错误消息的输出面。 未知元素的报错里会回显用户传进来的字符串,加载器在这里调了一次截断到 64 字符,注释写明理由是防止响应放大:调用方原样回显任意长度的输入。这是把 MCP 场景下的攻击面当成一等公民在处理。
取到 schema 之后有三种渲染出口,都在 src/officecli/Help/ 下:SchemaHelpRenderer.cs 出人读版和原始 JSON,SchemaHelpFlatRenderer.cs 出扁平版。扁平版是专门给 grep 和 Agent 设计的,每条记录自成一行,两种行标签 ELEM 和 PROP,它自己文档注释里的示例长这样:
docx paragraph ELEM ops:[asgqr] paths:/body/p[@paraId=ID];/body/p[N]
docx paragraph PROP align enum ops:[asg] values:left|center|... aliases:alignment one of values ex:--prop align=center
src/officecli/CommandBuilder.Help.cs 里打印出来的入口清单,正好是这几层的对外投影:
officecli help <format> List all elements
officecli help <format> <element> Full element detail
officecli help <format> <element> --json Raw schema JSON
officecli help all Flat dump of every (format,element,property) — pipe to grep
officecli help all --jsonl Same dump as NDJSON (one JSON object per line)
(NDJSON 就是「一行一个完整 JSON 对象、行与行之间没有外层数组」的格式,好处是可以边读边解析、也能直接按行 grep。)
对你意味着什么:Agent 探索能力时不需要一次性把几百页帮助塞进上下文,可以先 help <format> 拿元素列表,再对单个元素要细节;要做离线索引就一把 help all --jsonl 拉走。同一份数据,三种粒度,零手工同步。
三、CRC 指纹:一条命令回答「能不能盲升级」
src/officecli/Help/SchemaCrc.cs 只干一件事:对嵌入的 schemas/help/** 所有资源算一个 CRC32,输出成八位十六进制。CRC32 是个校验和算法,输入变一个字节结果就变,用来判断「内容一不一样」,不是加密。
实现上有两处细节决定了它靠不靠谱。一是排序:所有资源名先规范化成小写、/ 分隔,再按序数比较排序,这样 MSBuild 在不同平台上的路径分隔符差异不会改变结果。二是喂入内容:每一项先把规范化后的名字按 UTF-8 喂进去,再喂文件原始字节。
entries.Sort((a, b) => string.CompareOrdinal(a.Canonical, b.Canonical));
uint crc = 0xFFFFFFFFu;
foreach (var (canonical, resource) in entries)
{
crc = Append(crc, System.Text.Encoding.UTF8.GetBytes(canonical));
...
}
把名字也算进去,意味着改名、挪目录同样会让指纹变化,而不是只有内容变了才变。用的是标准的反向 CRC32 多项式 0xEDB88320u,表在静态构造里现算。
对外只暴露一个内部命令,在 src/officecli/Program.cs 里,位置排得非常靠前——在几乎所有其它分发逻辑之前:
if (args.Length == 1 && args[0] == "--output-schema-crc")
{
Console.WriteLine(OfficeCli.Help.SchemaCrc.Compute());
return 0;
}
Program.cs 的注释把语义定死了:下游自动化把这个值钉住,二进制升级后重算一次,相同就说明属性面完全一致、可以盲升。它还特别声明这是一个 flag 而不是版本号,没有先后顺序语义——你不能从两个 CRC 值判断谁新谁旧,只能判断同不同。
SchemaCrc.cs 类注释里自己划了边界:指纹只覆盖 schema 文件,代码里实现的序列化行为(举的例子是 JSON 字段顺序)不在这个指纹范围内。这句要认真读。它的意思是 CRC 不变,只保证「文档承诺的属性面没变」,不保证「输出的字节没变」。如果你的下游流水线是按字段顺序做正则解析的,CRC 不变照样可能被升级打穿。
四、同名不同物:另一套 schema 管的是写盘顺序
仓库里「schema」这个词有两个所指,混起来会读歪。上面讲的是对外契约。src/officecli/Core/SchemaOrder.cs 里的 schema 是另一回事——OOXML 自身的内容模型顺序。
补一句背景:.docx / .xlsx / .pptx 本质是 zip 包,里面是一堆 XML。OOXML 规范对很多容器规定了子元素必须按固定次序出现,顺序错了,严格的消费方会拒收。这个文件的注释给了两个具体后果:Word 的 <w:kern> 排在 <w:sz> 后面、<w:pStyle> 不在 pPr 第一个,都会被 OOXML 校验拒绝;PowerPoint 遇到次序不对的 DrawingML 元素则是静默丢弃。
天真的做法是给每个容器手维护一张顺序数组。注释里点名了这条路的失败现场:光是 CT_RPr(run 的属性容器)就有约 40 个子元素,之前那个手写 switch 把 kern / w / position 悄悄漏成「追加到末尾」。
它换的路子是反射读 OpenXML SDK 自己的编译期粒子比较器(CompiledParticle.Compare)——就是 SDK 校验器判断顺序时用的那一套。「粒子」是 XML Schema 里描述内容模型的术语,一个粒子就是「这个位置允许出现哪个(哪些)子元素、能出现几次」这条规则;SDK 把每个 CT_* 类型的粒子树编译好带在身上,比较器就是拿它来判断两个子元素谁该排前面。这样顺序表是「由构造保证完整」的,SDK 升级跟着走。放置策略刻意做得很保守:只移动新加进来的那一个子元素,把它挪到第一个它应该排在前面的既有兄弟节点之前,其余节点一律不动。注释解释了为什么不整体排序——比较器会把无法识别的元素排到最前面,mc:AlternateContent、扩展列表这类外来节点一旦被盲排就会错位。
同一个文件还用同样的反射路径读 maxOccurs(某个子元素在父容器里最多能出现几次),用来决定「加同名子元素时是替换还是追加」。读不到时返回 MaxOccursUnknown,调用方按「可重复」处理,也就是宁可多留一个也不误删。
两套 schema 放在一起看,能提炼出这个仓库的一致取向:能让权威源自己回答的,就不要在本地抄一份。对外契约的权威源是 schemas/help/,写盘顺序的权威源是 SDK 的粒子表。你抄这套做法时,真正要找的是「谁是权威源」,而不是「json 还是别的格式」。
五、边界与代价
这套设计不是免费的,也不是万能的,几处代价写在源码注释里,比宣传材料更值得看。
schema 已经不是运行时闸门。 这是最反直觉的一点。src/officecli/Core/TrackingPropertyDictionary.cs 的头部注释写得很明确:它替换了原先在 CLI 入口做的 SchemaHelpLoader.ValidateProperties 预过滤,改成「handler 实际有没有读这个 key」来定义什么叫不支持的属性;schema 不再是运行时的门禁,handler 的真实消费才是。好处是 handler 真懂但 schema 没登记的别名不再被误杀,坏处是判定依赖一个用自定义比较器记录字典访问的技巧,注释自己列了已知漏洞——foreach 遍历不走比较器,所以穷举遍历字典的 handler 什么都不会被标记为已访问,只能靠重写枚举器打补丁。这类机制精巧,但精巧本身就是维护成本。
校验器刻意宽松,宽松就会漏。 ValidateProperties 的策略是:格式或元素不认识就直接返回空(不因为新元素 schema 还没落地而卡住);接受一批点号前缀的子属性命名空间(font.、alignment.、border. 这些)即使 schema 没逐个列出;接受带序号的形式如 series1.color。源码里有一段值得单独抄的注释:axis. / cataxis. / valaxis. 这些前缀是故意不放进宽松名单的,因为 handler 只支持一小撮固定子集,加一条通配前缀会让 axis.color 这种拼错静默通过、Add 成功而值被丢掉。宽松是有代价的,代价要一处一处地权衡,不能一刀切。
明确不管的事。 schemas/README.md 的「Not here」一节自己划了三条:叙述性的教程和最佳实践不在这里(属于 wiki)、内部实现说明不在这里(属于项目约定和代码注释)、临时的发布说明不在这里(属于 CHANGELOG)。而 README 里列的第三个消费方「发布时生成 wiki」标注的是 future,也就是说这条链路当下还没接上,Agent 直接读 schema。
风险面要按真实情况说。 这个二进制读写的是你磁盘上的真实文件,不是副本。src/officecli/Core/AtomicPackageWriter.cs 做的是崩溃原子的落盘——目标文件被原子替换,而不是就地重写,这样进程中途死掉不会留下半个损坏的包;但「原子」保的是不写坏,不是保留旧版本,改的仍然是你给的那个路径。要留退路只能自己先复制一份。
常驻进程模式下「什么时候真落盘」是可配的,src/officecli/Core/ResidentFlushPolicy.cs 定义了四种模式:each(每条修改命令返回前落盘)、auto(空闲防抖,间隔按实测保存耗时自适应,默认)、固定秒数、off(只有显式 save / close / 关停才写)。默认是 auto,意味着你发完命令去用别的工具打开文件,可能读到的还是旧内容。
批量执行的中途失败也要看清楚。src/officecli/CommandBuilder.Batch.cs 的帮助文本里写了「干净重放」的正确姿势:create 拒绝覆盖已存在的文件(退出 1,错误码 file_exists),也拒绝一个还被常驻进程持有的文件(错误码 file_locked);脚本要是忽略 create 的退出码继续跑,就会把这批命令重放到上一轮的文档上,于是 add style、add bookmark 这类命令报「已存在」。它给的可靠写法是 close -> rm -> create -> batch -> close,并且强调 rm 必须在 create 之前。
最后是外部资源。图片、表格数据、媒体这些属性允许传 URL——schemas/help/_shared/picture.json 里 src 的描述就写着接受文件路径、URL、data-URI。src/officecli/Core/SsrfGuard.cs 的注释把暴露面说得很清楚:当它作为 Agent 工具运行时,URL 可能来自不受信任的输入(批处理脚本、文档里嵌的指令、工具调用参数),无防护的抓取就是一个 SSRF 原语。SSRF 是「服务端请求伪造」:外部输入能指挥程序自己去发请求,于是攻击者借这台机器的网络位置去够它本来够不着的地址——注释点名的两个后果就是探测内网主机和云元数据端点、或者把它们的响应体塞进产出的文档里带走。它的对策是在连接回调里校验实际 IP、每个重定向跳转都校验,拒绝落到回环、私有、链路本地地址上。这层做了不等于你可以不管——它挡的是地址,挡不住「文档里一段文字诱导 Agent 去拉某个公网地址」这件事本身。
六、上手与避坑清单
别把 --output-schema-crc 当版本号排序。 会踩是因为它长得像个 build id,人本能想比大小。Program.cs 注释明说它是 flag、无顺序语义。正确用法只有一种:升级前后各取一次,相等就继续跑自动化,不等就先回去核对机器可读输出的假设。
别把 CRC 相等理解成输出字节不变。 会踩是因为「指纹一致」听起来包打天下。SchemaCrc.cs 自己声明代码里实现的序列化行为不在覆盖范围。如果你的解析依赖字段顺序,除了比 CRC,还得对代表性输出做一次快照 diff。
别拿 schema 当运行时白名单来推断行为。 会踩是因为文件里明明写着 add: true / set: true。但运行时判定已经换成了 handler 有没有真读这个 key,两者的宽严不完全重合。真要确认某个属性生效没有,靠 Get 读回来对比,别靠读 schema。
元素名先查别名再报错。 会踩是因为命令路径里的写法(p、col、tc)和 schema 文件名(paragraph、column、table-cell)不是一套。加载器已经用 elementAliases 帮你兜了,但兜的是它登记过的;你在 help <format> 的列表里看到的是文件名那一套,按那个列表去查最省事。
扁平 dump 优先于全量 JSON。 会踩是因为习惯性 --json 然后整个塞进上下文。help all 的扁平格式是一条记录一行、自带描述和示例,就是为 grep 设计的;先 grep 命中再取单个元素详情,比整树入上下文省得多。
批量重放前先 close 再 rm。 会踩是因为常驻进程还捏着文件句柄,create --force 能覆盖文件却不释放常驻锁。按帮助文本给的顺序来,别自己发明。
改行为的 PR 顺手改 schema。 会踩是因为改实现时觉得文档可以之后补。schemas/README.md 的编辑规则和 CI 契约测试就是为了让「之后补」这个选项不存在——你在自己项目里抄这套时,这条规则和那 152 份 json 同样重要,少了它,一年后你手上就是一堆自称是事实源的过期文件。
收个尾。这套东西真正可迁移的不是 json 的字段名,是三件事:给能力找一个唯一的、可 diff 的落盘位置;让帮助、校验、测试都从那一处派生而不是各写各的;再给整体加一个廉价的指纹,让下游有办法在不读内容的前提下判断变没变。想照着做,建议按这个顺序读源码——先 schemas/help/_schema.json 看契约长什么样,再 src/officecli/Help/SchemaHelpLoader.cs 看解析和合并怎么做,然后 src/officecli/Help/SchemaCrc.cs 看指纹只有几十行,最后 src/officecli/Core/TrackingPropertyDictionary.cs 看维护者后来为什么把运行时闸门从 schema 挪走了——最后这一步最有价值,因为它记录的是一次自我推翻。
至于参数层面的通用校验策略该放在哪一层、由谁兜底,那是另一个话题,可以接着看参数校验怎么做。
本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 OfficeCLI 开源项目仓库导读:353 个 C# 文件的三层切法 和 开源项目 OfficeCLI 的文档节点模型:三类文档共用一套接口。