开源项目 OfficeCLI 与 Pascal Editor:Agent 操作专业软件的两条路

2026-08-05

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

两条路真正的分歧不在谁更先进,而在文档到底由谁持有:开源项目 OfficeCLI(GitHub 上的 iOfficeAI/OfficeCLI 仓库,不是泛指”用命令行操作 Office”这件事)让 Agent 持有一个磁盘上的文件,Pascal Editor 让 Agent 持有一个正在运行的软件实例。 这个差别决定了后面所有工程细节——工具面怎么设计、状态放哪、什么时候落盘、离线能不能跑、坏掉的时候你去哪查。

先做个消歧。OfficeCLI 是 GitHub 上 iOfficeAI 组织下的一个开源项目(Apache-2.0,NOTICE 文件写明 Copyright 2026 OfficeCLI,由 goworm 创建维护),不是微软出品,跟微软没有任何从属或授权关系;文中提到 Word、Excel、PowerPoint 时,指的是 .docx / .xlsx / .pptx 这三种文件格式和对应的桌面软件,不是这个项目的归属。仓库 README 开头这样定位自己:世界上第一个也是最好的、为 AI agent 设计的 Office 套件。那是项目自己的说法,本文只做转述,不替它背书,也不据此下任何”首个/最好”的结论——下面写的都是代码和文档里能当场核到的东西。

一、两条路的分岔点在哪

Agent 要操作一款专业软件,通常只有两种做法。

一种是绕过软件,直接操作它的产物文件。Agent 面对的是磁盘上的 .docx,谁都没打开它,也不需要装 Office。代价是:这个格式的语义你得自己实现一遍,实现到什么程度,Agent 的能力边界就在哪。

另一种是不绕过软件,在软件进程里开一个协议口子,让 Agent 通过工具调用去操作正在运行的那个实例。站内梳理过 Pascal Editor 走的就是这条路——把 MCP 服务端做进编辑器,Agent 操作的是活着的场景状态。代价也很清楚:软件得开着,Agent 能干什么完全由软件愿意暴露多少内部对象决定。

这篇只做两种取向的对照,不排座次。站内三篇邻近的文章各管一段,别混着读:Pascal Editor 那条路的能力与边界 讲的是在软件里开 MCP 口子这一侧的完整取舍;三个开源 Agent 项目的横向对比 比的是项目整体形态;跨平台扩展机制的三体对比 比的是扩展点怎么设计。本篇只做一件事:把「重写读写器」这条路上必须补齐的那几层摊开,让你能拿它去对照另一条路。

二、要脱离宿主软件,OfficeCLI 得自己补齐哪几层

选了「不依赖软件」这条路,就意味着凡是 Office 本来帮你做的事,现在都得自己长出来。OfficeCLI 仓库的目录结构基本就是这份补课清单。

先说清楚 OOXML 是什么:一个 .docx 本质上是个 zip 压缩包,解开后是一堆 XML 文件(叫「部件」,part),正文一份、样式一份、编号一份、主题一份,部件之间靠关系文件互相引用。读写 OOXML 有两个层次——低层是把这些 XML 部件安全地读出来写回去,高层是理解「第 3 段第 2 个 run 的字体」这种语义。OfficeCLI 的 csproj 里只有两个 NuGet 依赖(DocumentFormat.OpenXmlSystem.CommandLine,见 THIRD-PARTY-NOTICES.txt),低层部件访问用的是开源的 Open-XML-SDK;语义层、渲染、公式求值这些是仓库自己的代码。所以「重写一套读写器」这句话要说准确:重写的是语义层和它上面的一切,不是 zip 和 XML 解析。

按目录数一遍,规模大致是这样(你在仓库里 ls 一次就能复现):全仓 1201 个受版本控制的文件,src/officecli/ 469 个文件、其中 353 个 .csschemas/ 153 个文件、152 份 json;skills/ 11 个技能目录、各带 1 份 SKILL.md;examples/ 381 个文件;sdk/ 下 node 与 python 两套薄封装;assets/ 37 个;根目录 4 个语言版本的 README(en/zh/ja/ko)加一份根级 SKILL.md。

组成部分它负责什么对应仓库位置你什么时候会碰到它
语义层文档处理器/slide[1]/shape[2] 这样的路径和 --prop 属性翻译成对 OOXML 部件的改动src/officecli/Handlers/(Pptx 64 个 .cs、Word 54 个、Excel 47 个)每一次 get / set / add
渲染与截图把文档渲染成 HTML,再外壳调用浏览器出 PNGsrc/officecli/Core/HtmlScreenshot.csview html / view screenshot
常驻与落盘策略文档留在内存,决定什么时候真正写回磁盘src/officecli/ResidentServer.cssrc/officecli/Core/ResidentFlushPolicy.cs另一个程序要读同一个文件时
实时预览服务本地 HTTP 服务 + 浏览器里点选回读src/officecli/Core/Watch/WatchServer.csofficecli watch,默认端口 26315
帮助 schema元素与属性的机读清单,让 Agent 查而不是猜schemas/help/(152 份 json)officecli help pptx shape
场景技能包分场景的写作规则,用 load_skill 现取现用skills/(11 个目录,含 officecli-pitch-deckofficecli-financial-modelmorph-ppt 等)做融资 deck、财务模型、Morph 演示时
MCP 服务端只暴露一个工具,把命令行字符串直通 CLIsrc/officecli/McpServer.csofficecli mcp claude 注册之后
插件协议把三大格式之外的活外包给独立进程plugins/plugin-protocol.md要处理 .doc / .hwpx / 导出 .pdf

表里的 morph-ppt 值得解释一句:Morph 是 PowerPoint 的「平滑切换」,相邻两页里名字相同的形状之间自动补出位移、缩放、旋转的过渡动画,看起来像一个连续镜头。它对内容的要求跟普通排版完全不同——形状得跨页保持命名一致,所以仓库把它单独做成一层技能,在通用 pptx 规则之上只补跨页命名绑定、transition=morph 的命令行细节这些 Morph 专属的东西,其余照搬基础规则。这也是技能包这种组织方式的价值:规则按场景切片,用到哪片取哪片,不必一次性堆进上下文。

Handlers/ 下那几十个文件不是几十个类。C# 有个语法叫分部类(partial class):同一个类可以拆到多个文件里写,编译时再拼成一个。PowerPointHandler 就是这么组织的——PowerPointHandler.Add.Shape.csPowerPointHandler.Chart.csPowerPointHandler.Animations.csPowerPointHandler.HtmlPreview.Text.cs 分别装形状新增、图表、动画、HTML 预览的文本排布。对读代码的人来说,这意味着找一个能力时按功能名去 ls 目录,比在一个万行文件里翻要快。

值得单独拎出来的是渲染这一层。README 把它称作项目的基石:Agent 生成幻灯片时如果只能读 DOM,它看不出标题溢出还是两个形状叠在一起。HtmlScreenshot.cs 的注释交代得很清楚——它不内嵌浏览器引擎,而是外壳调用机器上已有的浏览器,顺序是 playwright CLI → Chromium 系(Chrome/Edge/Chromium)→ Firefox,用 --headless=new --dump-dom 拿渲染后的 DOM,虚拟时间预算写死 15000(毫秒),墙钟兜底 --timeout=20000,外层进程等待默认 60000 毫秒。这是一个明确的取舍:二进制体积不为渲染引擎买单,代价是「零依赖」这句话在截图这一档要打折。打折的程度还分档:整页截图有三级回退(源码里的 Backends() 依次给出 playwright、chrome、firefox),三者有其一就能出图;但取渲染后 DOM 的 DumpDom、以及按元素裁剪出图的 CaptureClipped 是 chrome 系专用,FindChrome() 返回 null 时后者直接以「clip mode requires a Chrome-family browser」拒绝执行。所以「这台机器能不能截图」不是一个是非题,得看你用的是哪一档。

三、同一个 MCP,在两条路上是两种东西

两条路都会用到 MCP,但暴露出来的东西完全不是一回事。

McpServer.cs 里的实现相当克制:一个基于 stdio 的最小 JSON-RPC 2.0 服务端,initialize 里写死 protocolVersion2024-11-05,只处理 initialize / tools/list / tools/call / ping;JSON 全部用 Utf8JsonWriter 手写,注释里给的理由是避开反射以适配 PublishTrimmed(发布时裁剪未用代码的开关)。最关键的一条在 HandleToolsCall:这个服务端只对外声明一个工具,名字就叫 officecli,传进来的工具名不是它就直接报错,注释写明了理由——错路由的调用不能悄悄执行,否则会以一个不存在的工具名去改文件。

这一个工具只有一个参数 command,接受字符串或者预先切好的 argv 数组,内容就是你在终端里会敲的那行命令,然后走 RootCommand.Parse(argv) 交给和 CLI 完全同一个 System.CommandLine 根命令执行。注释把动机说得很清楚:不做每条命令的参数搬运,就不会有参数被悄悄丢掉,模型照着技能文件里的 CLI 示例原样写就行。字符串形式由自带的 Tokenize 切分,它认识单双引号和双引号内的反斜杠转义,但从不调用 shell,所以没有命令注入面;模型如果照抄示例带上了开头的 officecliExtractArgv 会把它剥掉。

这就是「重写读写器」这条路上 MCP 的样子:工具面等于一套命令行语法,能力清单靠 helpload_skill 现查,不常驻在上下文里。而在软件里开口子那条路上,MCP 工具面直接就是软件的内部对象模型——一个工具对应一种场景内操作,工具数量和粒度由软件决定。哪种更省 token、哪种更好排错,取决于你的任务形态,MCP 工具数量该怎么控制 里的那套判据在两条路上都适用。

还有个细节值得看:MCP 进程启动时会把 OFFICECLI_NO_AUTO_RESIDENT 默认设成 1。源码注释解释了这只是「不主动拉起常驻进程」,不是「绕过已有的常驻」——如果别的 officecli 已经为这个文件持有常驻,命令照样路由过去,所以不会出现两个写者抢同一个文件。副作用是:没有常驻时,MCP 的每次改动会直接开、改、存,响应返回时已经在磁盘上;有常驻时,则跟着那个常驻的延迟落盘节奏走。同一个工具调用,落盘时机不一样。

四、状态放在哪,以及什么时候真的写进了磁盘

这是最容易出事的一层,也是两条路差异最大的一层。它改的是你磁盘上的真实文件,不是副本——set 一下,原文件就是改动后的那份,没有自动备份,回滚要靠你自己的版本控制。

常驻模式(resident)的存在是为了性能:文档留在内存里,多步操作不用反复开关文件。ResidentServer.cs 里两个独立的空闲计时器都由命令活动重置——一个管进程关停(默认 12 分钟,命令自动拉起的常驻是 60 秒),一个更短的管自动保存。落盘策略由 OFFICECLI_RESIDENT_FLUSH 控制,四种模式:each / auto / 固定秒数 / off;默认的自动保存在空闲后 2 到 10 秒自适应触发,具体值按这份文档实测的保存成本缩放。

由此得出一条硬规则,README 和根级 SKILL.md 都反复强调:officecli 自己的读(get / query / view / dump)永远看得到最新改动,所以流程中间不需要保存;但只要下一步是非 officecli 的程序去读这个文件——python-docx、openpyxl、Word 本身、渲染器、上传投递——就必须先 save(冲盘、保留常驻)或者 close(冲盘并释放)。踩这个坑的典型症状是:Agent 报告已经改好了,下游脚本读到的还是改前的内容,而且看起来毫无道理。

批量操作的失败语义也要记住。batch 现在默认是原子的:每一项都会执行并汇报,所以「N 成功 M 失败」仍然有意义,但只要有任何一项失败,整批回滚,磁盘上的文件与批处理开始前逐字节相同,JSON 汇总里带 "atomicRolledBack": true。想要「成功的先留下」得显式加 --best-effort--stop-on-error 只改变在哪一项停下,不改变留不留——两个开关要组合起来用才是「首个失败即停且保留已成功部分」。中途失败留下什么,取决于你用了哪个组合,这件事必须在写脚本之前想清楚。

还有几处对外暴露面,用之前心里得有数:watch 会起一个本地 HTTP 服务(默认端口 26315),WatchServer.cs 里有针对 DNS rebinding 的防护,只接受 Host 为 localhost / 127.0.0.1 的请求;UpdateChecker 会去 https://d.officecli.ai(失败回落 GitHub 仓库)检查更新,MCP 这种长驻进程里额外挂了个后台任务,启动时跑一次、之后每小时唤醒一次,实际检查由 24 小时时间戳去抖动,可以用 officecli config autoUpdate false 关掉或用 OFFICECLI_SKIP_UPDATE=1 单次跳过;officecli install 会探测你机器上的 AI 工具并往它们的配置目录写技能文件(SkillInstaller 里能看到 .claude.cursor.config/opencode 等条目)。另外,README 给的一键安装是 curl ... | bash / irm ... | iex 这种管道执行远程脚本的形式——这类命令的信任模型你自己判断,仓库也提供了从 Releases 手动下载二进制的方式。

五、边界与代价:这个设计明确放弃了什么

它不是 Office,保真度是自己实现出来的。 每一个属性、每一种效果,都得在 Handlers/ 里有对应实现才存在。README 那一长串能力清单反过来读就是边界:列出来的能干,没列的得先去 help 里确认。文档里也留了明确的能力缺口,例如实时预览的点选:.pptx 覆盖形状/图片/表格/图表/连接符/组合,.docx 只覆盖顶层段落和表格,.xlsx 根本不发 data-path,所以 mark / selection 在 xlsx 上永远解析成 stale=true;组合形状按整体选中,钻进组合内部的单个子元素在 v1 不支持。

三大格式之外,主仓一概不管。 plugins/plugin-protocol.md 开篇就写了动机:不让遗留格式(.doc.rtf.odt)、地区格式(.hwpx)、导出目标(.pdf.epub)撑大主二进制,也不让外部实现被主仓的许可证绑住。老的 .doc 属于复合文件格式(把多个流塞进一个二进制容器,与 zip+XML 的 OOXML 完全是两套东西),解析器又重又冷门,于是被推到进程外。协议定义了三种插件:dump-reader 把外来格式一行一个 JSON 地吐成可重放的批命令,落到源文件旁边的同名原生文件上(后续调用比 mtime 复用它,删掉即可强制重转);exporter 把原生格式导出成外来格式,硬性要求不许写源文件,主程序据此省掉防御性快照;format-handler 长驻进程,从头到尾自己持有那个文件。这三种插件都是独立进程,意味着崩溃、超时、许可证过期都是你需要单独排查的一层:退出码 2 是源文件损坏、3 是这个构建不支持、4 是许可证过期、5 是协议版本不匹配、6 是主程序的空闲看门狗开的枪。看门狗只看空闲不看总时长——stdout 写出任何字节,或者 stderr 上出现一行 {"heartbeat":true},计时器就重置;manifest 里的 idle_timeout_seconds.default 必须是正整数,不允许填 0(避免出现永不回收的插件),只有用户可以在运行时用 OFFICECLI_PLUGIN_IDLE_TIMEOUT_SECONDS 覆盖,填 0 才彻底关掉看门狗。

它不管协作语义。 谁在改、改到哪一版、要不要审批,都不在这一层。文档里最接近人工复核的机制是 mark:改动提案只活在 watch 进程里,等人过目之后由另一条 set 管线落地——注意它不进文件,进程一停就没了;要写进文件里的批注得用 Word 原生的 add --type comment。真正的改动边界约定还是得你自己在流程上定,改动边界怎么和 Agent 约定 那一套在这里照样要用。

什么场景下别选这条路。 如果你的目标产物压根不是一个能脱离软件独立存在的文件——比如要在一个活着的编辑器场景里连续调整、要即时看到软件自己的反馈回路、要用到软件内部才有的求解器——那么绕过软件这条路的价值就大打折扣,那正是在软件里开口子那条路成立的地方。反过来,如果你要在 CI、Docker、无显示器的服务器上批量出一千份文件,把宿主软件拉进来才是自找麻烦。

六、上手与避坑清单

方括号路径不加引号。 为什么会踩:/slide[1] 里的方括号会被 bash / zsh 当成通配符展开,展开失败的报错还跟文档无关,很难联想。怎么避:路径一律用引号包起来,'/slide[1]'——仓库根级 SKILL.md 的常见陷阱表里第二条就是它。

猜属性名。 为什么会踩:属性名很像别的库(比如设位置用 x/y),Agent 凭印象拼一个,返回 unsupported_property 之后开始猜第二个、第三个。怎么避:SKILL.md 的原话是一次 help 查询胜过一轮猜-失败-重试,officecli help <格式> <元素> 直接给出这个元素能设的属性;schemas/help/ 下那 152 份 json 就是这份清单的机读形态。顺带记一个反直觉点:docx 的 textbox / shape 定位用的是 anchor.x / anchor.y,不是裸的 x/y

以为 PPT 里 shape[1] 是正文。 为什么会踩:视觉上第一个内容框,索引却常常落在标题占位符上。怎么避:SKILL.md 明确写了 shape[1] 通常是标题占位符,内容形状从 shape[2] 起;多步流程里更该改用稳定 ID 寻址(/slide[1]/shape[@id=550950021]),位置索引会因为插入删除整体漂移,稳定 ID 不会。

改完直接让别的程序读。 为什么会踩:常驻模式下改动先落在内存,officecli 自己的读一切正常,于是没人怀疑磁盘。怎么避:交接给非 officecli 的程序(包括打开 Word 看一眼)之前先 saveclose;流水线里每条命令后面都有外部读取的,直接设 OFFICECLI_RESIDENT_FLUSH=each

把 batch 当成「能成几条算几条」。 为什么会踩:默认是原子的,一项失败整批回滚,但每一项仍然照常汇报,「3 成功 1 失败」的输出很容易让人以为那 3 条留下了。怎么避:看 JSON 里有没有 "atomicRolledBack": true;确实想保留部分结果(比如有损的 dump→batch 重放)就显式加 --best-effort

在没有浏览器的机器上依赖截图。 为什么会踩:「单个二进制、零依赖」的印象会让人以为渲染也是自带的,实际上 HtmlScreenshot.cs 是去找机器上已有的浏览器。怎么避:把「playwright CLI、chrome 系浏览器、Firefox 三者至少有一个」写进镜像的前置条件,用到裁剪截图或 DOM 回读的还得是 chrome 系;拿不到图时,按 MCP 工具描述里那句要求,明说「未做视觉核验」,而不是假装通过。

给插件 manifest 填 idle_timeout_seconds: 0 为什么会踩:想给长任务留余量,顺手写 0。怎么避:协议规定 manifest 里 0 不合法,长任务的正确做法是在 stderr 上定期打 {"heartbeat":true}——看门狗看的是空闲时长,不是总时长。

写 PR 时把几件事打包提交。 为什么会踩:改一处顺手带上相邻的重构。怎么避:CONTRIBUTING.md 的第一条规则是一个 PR 只做一件不可再拆的改动,第二条是必须给出可验证的验证方法(命令序列、可复现脚本,或权威参考)。

收个尾:自检三问,以及接下来该读哪个文件

选型之前问自己三个问题:产物是不是一个要脱离软件独立存在的文件?跑的环境里能不能有那个软件在开着?出问题时你希望在命令行退出码里排查,还是在软件进程日志里排查?三个答案基本就把两条路分开了。

想继续往下读代码,建议这个顺序:先 plugins/plugin-protocol.md 从头到尾读一遍——它是整个仓库里边界写得最实的一份文档,三种插件的职责、握手、退出码、看门狗都在里面;再看 src/officecli/McpServer.cs,只有五百多行,能看清楚一个「薄壳型」MCP 服务端到底薄在哪;然后 src/officecli/Core/HtmlScreenshot.cs 的开头三十行,那里写着这个项目在体积和自足之间做的那次取舍;最后回到根级 SKILL.md,它是 Agent 实际读到的那份说明书,你会发现里面每一条陷阱表,都对应着有人真的踩过。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 的安全边界:四道防线挡住了什么、漏了什么OfficeCLI 开源项目仓库导读:353 个 C# 文件的三层切法

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