Agent Harness: GUI-to-CLI for Open Source Software

以下内容翻译自 https://github.com/HKUDS/CLI-Anything/blob/main/cli-anything-plugin/QUICKSTART.md

Agent Harness:为开源软件构建 GUI 到 CLI 的适配层

目的

本适配层为编码代理(Claude Code、Codex 等)提供了一个标准操作流程(SOP)和工具包,用于为开源的 GUI 应用程序构建功能强大、有状态的命令行界面(CLI)。目标是:让 AI 代理能够操作那些为人类设计的软件,而无需显示器或鼠标。

通用 SOP:将任何 GUI 应用程序转化为代理可用的 CLI

第一阶段:代码库分析

  1. 识别后端引擎 — 大多数 GUI 应用程序将表示层与逻辑层分离。找到核心库/框架(例如,Shotcut 的 MLT,GIMP 的 ImageMagick)。
  2. 将 GUI 操作映射到 API 调用 — 每一次按钮点击、拖拽和菜单项都对应一个函数调用。整理这些映射关系。
  3. 识别数据模型 — 它使用什么文件格式?项目状态如何表示?(XML、JSON、二进制、数据库?)
  4. 寻找现有的 CLI 工具 — 许多后端自带 CLI(meltffmpegconvert)。这些都是构建模块。
  5. 整理命令/撤销系统 — 如果应用程序有撤销/重做功能,它可能使用了命令模式。这些命令就是你的 CLI 操作。

第二阶段:CLI 架构设计

  1. 选择交互模式

    • 有状态的 REPL 用于交互式会话(代理需要维护上下文)
    • 子命令 CLI 用于一次性操作(脚本编写、管道处理)
    • 两者兼有(推荐)—— 一个能同时工作在两种模式下的 CLI
  2. 定义命令组,与应用程序的逻辑领域相匹配:

    • 项目管理(新建、打开、保存、关闭)
    • 核心操作(应用程序的主要目的)
    • 导入/导出(文件 I/O、格式转换)
    • 配置(设置、偏好、配置文件)
    • 会话/状态管理(撤销、重做、历史、状态)
  3. 设计状态模型

    • 哪些内容需要在命令之间持久化?(打开的项目、光标位置、选中区域)
    • 状态存储在哪里?(REPL 中存储在内存,CLI 中基于文件)
    • 状态如何序列化?(JSON 会话文件)
  4. 规划输出格式

    • 人类可读的(表格、颜色)用于交互式使用
    • 机器可读的(JSON)供代理消费
    • 两者兼有,通过 --json 标志控制

第三阶段:实现

  1. 从数据层开始 — 项目文件的 XML/JSON 操作

  2. 添加探测/信息命令 — 让代理在修改前先进行检查

  3. 添加变更命令 — 每个逻辑操作对应一个命令

  4. 添加后端集成 — 一个 utils/<software>_backend.py 模块,用于封装真实软件的 CLI。该模块处理:

    • 查找软件可执行文件(shutil.which()
    • 使用正确的参数调用它(subprocess.run()
    • 错误处理,如果未找到则给出清晰的安装说明
    • 示例(LibreOffice):
      # utils/lo_backend.py
      def convert_odf_to(odf_path, output_format, output_path=None, overwrite=False):
          lo = find_libreoffice()  # 引发 RuntimeError,附带安装说明
          subprocess.run([lo, "--headless", "--convert-to", output_format, ...])
          return {"output": final_path, "format": output_format, "method": "libreoffice-headless"}
      
  5. 添加渲染/导出 — 导出管道调用后端模块。生成有效的中间文件,然后调用真实软件进行转换。

  6. 添加会话管理 — 状态持久化、撤销/重做

    会话文件锁定 — 保存会话 JSON 时,使用独占文件锁定防止并发写入导致数据损坏。切勿使用裸的 open("w") + json.dump()open("w") 会在获取锁之前截断文件。相反,使用 "r+" 打开,然后锁定,在锁定内部进行截断:

    def _locked_save_json(path, data, **dump_kwargs) -> None:
        """使用独占文件锁定原子地写入 JSON。"""
        try:
            f = open(path, "r+")            # 打开时不截断
        except FileNotFoundError:
            os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
            f = open(path, "w")             # 首次保存——文件尚不存在
        with f:
            _locked = False
            try:
                import fcntl
                fcntl.flock(f.fileno(), fcntl.LOCK_EX)
                _locked = True
            except (ImportError, OSError):
                pass                        # Windows / 不支持的文件系统——继续,不加锁
            try:
                f.seek(0)
                f.truncate()                # 在锁内部截断
                json.dump(data, f, **dump_kwargs)
                f.flush()
            finally:
                if _locked:
                    fcntl.flock(f.fileno(), fcntl.LOCK_UN)
    
  7. 添加具有统一外观的 REPL — 交互式模式,包装子命令。

    • 从插件(cli-anything-plugin/repl_skin.py)复制 repl_skin.py 到 CLI 包的 utils/repl_skin.py
    • 导入并使用 ReplSkin 作为 REPL 界面:
      from cli_anything.<software>.utils.repl_skin import ReplSkin
      
      skin = ReplSkin("<software>", version="1.0.0")
      skin.print_banner()          # 品牌化的启动框(自动检测 skills/SKILL.md)
      pt_session = skin.create_prompt_session()  # 带有历史记录和样式的 prompt_toolkit
      line = skin.get_input(pt_session, project_name="my_project", modified=True)
      skin.help(commands_dict)     # 格式化的帮助列表
      skin.success("Saved")        # ✓ 绿色消息
      skin.error("Not found")      # ✗ 红色消息
      skin.warning("Unsaved")      # ⚠ 黄色消息
      skin.info("Processing...")   # ● 蓝色消息
      skin.status("Key", "value")  # 键值对状态行
      skin.table(headers, rows)    # 格式化的表格
      skin.progress(3, 10, "...")  # 进度条
      skin.print_goodbye()         # 样式化的退出消息
      
    • ReplSkin 会自动检测包目录内的 skills/SKILL.md 并在横幅中显示它。AI 代理可以读取显示的绝对路径处的技能文件。
    • 使 REPL 成为默认行为:在主 Click 组上使用 invoke_without_command=True,并在没有给定子命令时调用 repl 命令:
      @click.group(invoke_without_command=True)
      @click.pass_context
      def cli(ctx, ...):
          ...
          if ctx.invoked_subcommand is None:
              ctx.invoke(repl, project_path=None)
      
    • 这确保了不带参数的 cli-anything-<software> 会进入 REPL

第四阶段:测试计划(TEST.md - 第一部分)

在编写任何测试代码之前,在 agent-harness/cli_anything/<software>/tests/ 目录中创建一个 TEST.md 文件。此文件作为测试计划,必须包含:

  1. 测试清单计划 — 列出计划的测试文件和估计的测试数量:

    • test_core.py:计划 XX 个单元测试
    • test_full_e2e.py:计划 XX 个端到端测试
  2. 单元测试计划 — 对于每个核心模块,描述将要测试的内容:

    • 模块名称(例如 project.py
    • 要测试的函数
    • 要覆盖的边缘情况(无效输入、边界条件、错误处理)
    • 预期的测试数量
  3. 端到端测试计划 — 描述要测试的真实场景:

    • 将模拟哪些工作流程?
    • 将生成/处理哪些真实文件?
    • 将验证哪些输出属性?
    • 将执行哪些格式验证?
  4. 真实工作流程场景 — 详细说明每个多步骤工作流程:

    • 工作流程名称:简短标题
    • 模拟:什么真实任务(例如,“照片编辑流水线”、“播客制作”、“产品渲染设置”)
    • 操作链:逐步操作
    • 验证:将检查哪些输出属性

此计划文档确保在编写代码之前有全面的测试覆盖。

第五阶段:测试实现

现在根据 TEST.md 计划编写实际的测试代码:

  1. 单元测试test_core.py)— 每个核心函数独立测试,使用合成数据。无外部依赖。

  2. 端到端测试 — 中间文件test_full_e2e.py)— 验证你的 CLI 生成的项目文件结构是否正确(有效的 XML、正确的 ZIP 结构等)。

  3. 端到端测试 — 真实后端test_full_e2e.py)— 必须调用真实软件。 创建一个项目,通过实际软件后端导出,并验证输出:

    • 文件存在且大小 > 0
    • 正确的格式(PDF 魔数 %PDF-,DOCX/XLSX/PPTX 是有效的 ZIP/OOXML 等)
    • 尽可能验证内容(CSV 包含预期数据等)
    • 打印工件路径 以便用户手动检查:print(f"\n PDF: {path} ({size:,} bytes)")
    • 没有优雅降级 — 如果软件未安装,测试应失败,而不是跳过
  4. 输出验证不要仅仅因为导出成功退出就相信它有效。 以编程方式验证输出:

    • 魔数 / 文件格式验证
    • OOXML 格式(DOCX、XLSX、PPTX)的 ZIP 结构
    • 视频/图像的像素级分析(探测帧,比较亮度)
    • 音频分析(RMS 电平,频谱比较)
    • 针对预期值的时长/格式检查
  5. CLI 子进程测试 — 像真实用户/代理那样测试已安装的 CLI 命令。子进程测试也必须产生真实的最终输出(不仅仅是 ODF 中间文件)。使用 _resolve_cli 助手运行已安装的 cli-anything-<software> 命令:

    def _resolve_cli(name):
        """解析已安装的 CLI 命令;在开发环境中回退到 python -m。
    
        设置环境变量 CLI_ANYTHING_FORCE_INSTALLED=1 以要求使用已安装的命令。
        """
        import shutil
        force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
        path = shutil.which(name)
        if path:
            print(f"[_resolve_cli] 使用已安装的命令:{path}")
            return [path]
        if force:
            raise RuntimeError(f"{name} 在 PATH 中未找到。使用以下命令安装:pip install -e .")
        module = name.replace("cli-anything-", "cli_anything.") + "." + name.split("-")[-1] + "_cli"
        print(f"[_resolve_cli] 回退到:{sys.executable} -m {module}")
        return [sys.executable, "-m", module]
    
    
    class TestCLISubprocess:
        CLI_BASE = _resolve_cli("cli-anything-<software>")
    
        def _run(self, args, check=True):
            return subprocess.run(
                self.CLI_BASE + args,
                capture_output=True, text=True,
                check=check,
            )
    
        def test_help(self):
            result = self._run(["--help"])
            assert result.returncode == 0
    
        def test_project_new_json(self, tmp_dir):
            out = os.path.join(tmp_dir, "test.json")
            result = self._run(["--json", "project", "new", "-o", out])
            assert result.returncode == 0
            data = json.loads(result.stdout)
            # ... 验证结构
    

    子进程测试的关键规则:

    • 始终使用 _resolve_cli("cli-anything-<software>") — 切勿直接硬编码 sys.executable 或模块路径
    • 不要设置 cwd — 已安装的命令必须能从任何目录工作
    • 在 CI/发布测试中使用 CLI_ANYTHING_FORCE_INSTALLED=1 以确保测试的是已安装的命令(而不是回退方式)
    • 测试 --help--json、项目创建、关键命令和完整工作流程
  6. 往返测试 — 通过 CLI 创建项目,在 GUI 中打开,验证正确性

  7. 代理测试 — 让 AI 代理仅使用 CLI 完成一个真实任务

第六阶段:测试文档(TEST.md - 第二部分)

在所有测试成功运行后,附加到现有的 TEST.md:

  1. 测试结果 — 粘贴完整的 pytest -v --tb=no 输出,显示所有通过的测试及其名称和状态
  2. 摘要统计 — 总测试数、通过率、执行时间
  3. 覆盖说明 — 测试未覆盖的任何缺口或领域

TEST.md 现在既作为测试计划(在实现之前编写)又作为测试结果文档(在执行后附加),提供测试过程的完整记录。

第六阶段半:SKILL.md 生成

生成一个 SKILL.md 文件,使 CLI 能够通过技能创建者方法被 AI 代理发现和使用。此文件作为一个自包含的技能定义,可以被 Claude Code 或其他 AI 助手加载。

目的: SKILL.md 文件遵循标准格式,使 AI 代理能够:

  • 发现 CLI 的能力
  • 理解命令结构和用法
  • 生成正确的命令调用
  • 以编程方式处理输出

SKILL.md 结构:

  1. YAML 前置元数据 — 用于技能发现的触发元数据:

    ---
    name: "cli-anything-<software>"
    description: "对 CLI 功能的简要描述"
    ---
    
  2. Markdown 正文 — 使用说明,包括:

    • 安装先决条件
    • 基本命令语法
    • 命令组及其功能
    • 使用示例
    • 代理特定指导(JSON 输出、错误处理)

生成过程:

  1. 使用 skill_generator.py 提取 CLI 元数据:

    from skill_generator import generate_skill_file
    
    skill_path = generate_skill_file(
        harness_path="/path/to/agent-harness"
    )
    # 默认输出:cli_anything/<software>/skills/SKILL.md
    
  2. 生成器自动提取:

    • 软件名称和版本(来自 setup.py)
    • 命令组(来自 CLI 文件的 Click 装饰器)
    • README.md 中的文档
    • 系统包要求
  3. 自定义模板(可选):

    • 默认模板:templates/SKILL.md.template
    • 使用 Jinja2 占位符填充动态内容
    • 可以为特定软件部分进行扩展

输出位置:

SKILL.md 生成在 Python 包内部,以便与 pip install 一起安装:

<software>/
└── agent-harness/
    └── cli_anything/
        └── <software>/
            └── skills/
                └── SKILL.md

手动生成:

cd cli-anything-plugin
python skill_generator.py /path/to/software/agent-harness

与 CLI 构建的集成:

SKILL.md 的生成应在第六阶段(测试文档)成功完成后运行,确保在创建技能定义之前 CLI 已被完整记录和测试。

关键原则:

  • SKILL.md 必须是自包含的(理解它不需要外部依赖)
  • 包含针对程序化使用的代理特定指导
  • 记录 --json 标志的用法以获得机器可读的输出
  • 列出所有命令组及其简要描述
  • 提供演示常见工作流程的真实示例

CLI 横幅中的技能路径:

ReplSkin 自动检测包内的 skills/SKILL.md 并在启动横幅中显示绝对路径。AI 代理可以读取显示的路径处的文件:

# 在 REPL 初始化中(例如 shotcut_cli.py)
from cli_anything.<software>.utils.repl_skin import ReplSkin

skin = ReplSkin("<software>", version="1.0.0")
skin.print_banner()  # 自动检测并显示:◇ 技能:/path/to/cli_anything/<software>/skills/SKILL.md

包数据:

确保 setup.py 将技能文件包含为包数据,以便随 pip 安装:

package_data={
    "cli_anything.<software>": ["skills/*.md"],
},

关键经验教训

使用真实软件 — 不要重新实现它

这是第一条规则。 CLI 必须调用实际的软件进行渲染和导出 — 而不是用 Python 重新实现软件的功能。

反模式: 构建基于 Pillow 的图像合成器来替代 GIMP,或者生成 bpy 脚本却从不调用 Blender。这会产生一个玩具,无法处理真实的工作负载,并且与真实软件的行为相偏离。

正确的方法:

  1. 使用软件的 CLI/脚本接口作为后端:

    • LibreOffice:libreoffice --headless --convert-to pdf/docx/xlsx/pptx
    • Blender:blender --background --python script.py
    • GIMP:gimp -i -b '(script-fu-console-eval ...)'
    • Inkscape:inkscape --actions="..." --export-filename=...
    • Shotcut/Kdenlive:melt project.mlt -consumer avformat:output.mp4
    • Audacity:sox 用于效果处理
    • OBS:obs-websocket 协议
  2. 软件是必需的依赖项,而不是可选的。将其添加到安装说明中。没有实际软件,CLI 是无用的。

  3. 生成有效的项目/中间文件(ODF、MLT XML、.blend、SVG 等),然后将它们交给真实软件进行渲染。你的 CLI 是该软件的结构化命令行界面,而不是它的替代品。

示例 — LibreOffice CLI 导出管道:

# 1. 将文档构建为有效的 ODF 文件(我们的 XML 构建器)
odf_path = write_odf(tmp_path, doc_type, project)

# 2. 通过真实的 LibreOffice 进行转换(而不是重新实现)
subprocess.run([
    "libreoffice", "--headless",
    "--convert-to", "pdf",
    "--outdir", output_dir,
    odf_path,
])
# 结果:由 LibreOffice 完整引擎渲染的真实 PDF

渲染差距

这是第二大陷阱。 大多数 GUI 应用程序在渲染时通过其引擎应用效果。当你构建一个直接操作项目文件的 CLI 时,你还必须处理渲染——而简单的方法会静默地丢弃效果。

问题: 你的 CLI 将滤镜/效果添加到项目文件格式中。但在渲染时,如果你使用一个简单的工具(例如 ffmpeg concat demuxer),它读取原始媒体文件并忽略所有项目级效果。输出看起来与输入相同。用户看不出发生了什么。

解决方案 — 滤镜转换层:

  1. 最佳情况: 使用应用程序的原生渲染器(MLT 项目的 melt)。它读取项目文件并应用所有内容。
  2. 后备方案: 构建一个转换层,将项目格式的效果转换为渲染工具的原生语法(例如 MLT 滤镜 → ffmpeg -filter_complex)。
  3. 最后的手段: 生成一个用户可以手动运行的渲染脚本。

渲染的优先级顺序: 原生引擎 → 转换后的滤镜图 → 脚本。

滤镜转换陷阱

在格式之间转换效果时(例如 MLT → ffmpeg),注意:

  • 重复的滤镜类型: 有些工具(ffmpeg)不允许在同一个链中出现两次相同的滤镜。如果你的项目同时有 brightnesssaturation 滤镜,并且两者都映射到 ffmpeg 的 eq=,你必须合并它们为一个单独的 eq=brightness=X:saturation=Y
  • 顺序约束: ffmpeg 的 concat 滤镜要求交错的流顺序:[v0][a0][v1][a1][v2][a2],而不是分组的 [v0][v1][v2][a0][a1][a2]。如果你不知道这一点,错误信息(“媒体类型不匹配”)会很隐晦。
  • 参数空间差异: 效果参数通常使用不同的尺度。MLT 亮度 1.15 = +15%,但 ffmpeg eq=brightness=0.06 在 -1..1 的尺度上。明确记录每一个映射。
  • 无法映射的效果: 有些效果在渲染工具中没有等价物。优雅地处理(警告、跳过)而不是崩溃。

时间码精度

非整数帧率(29.97fps = 30000/1001)会导致累积舍入误差:

  • 使用 round(),而不是 int() 进行浮点到帧的转换。int(9000 * 29.97) 会截断并丢失帧;round() 得到正确答案。
  • 对时间码显示使用整数算术。 通过 round(frames * fps_den * 1000 / fps_num) 将帧转换为总毫秒数,然后用整数除法分解。避免在长时间内漂移的中间浮点数。
  • 在非整数 FPS 的往返测试中接受 ±1 帧的容差。 数学上的精确相等是不可能的。

输出验证方法

永远不要仅仅因为导出没有错误运行就认为它是正确的。验证:

# 视频:使用 ffmpeg 探测特定帧
# 第 0 帧用于淡入(应接近全黑)
# 中间帧用于颜色效果(比较亮度/饱和度与源)
# 最后一帧用于淡出(应接近全黑)

# 比较不同分辨率之间的像素值时,
# 排除信箱模式/柱箱模式(黑色填充条)。
# 水平帧中的垂直视频将有大约 40% 的黑色像素。

# 音频:检查开始/结束时的 RMS 电平以检查淡入淡出
# 将频谱特性与源进行比较

测试策略

四个测试层,各有其目的:

  1. 单元测试test_core.py):合成数据,无外部依赖。独立测试每个函数。快速、确定性强,适合 CI。
  2. 端到端测试 — 原生test_full_e2e.py):测试项目文件生成管道(ODF 结构、XML 内容、格式验证)。验证你的 CLI 生成的中间文件是否正确。
  3. 端到端测试 — 真实后端test_full_e2e.py):调用真实软件(LibreOffice、Blender、melt 等)生成最终输出文件(PDF、DOCX、渲染图像、视频)。验证输出文件:
    • 存在且大小 > 0
    • 具有正确的格式(魔数、ZIP 结构等)
    • 在可验证的情况下包含预期内容
    • 打印工件路径 以便用户可以手动检查结果
  4. CLI 子进程测试(在 test_full_e2e.py 中):通过 subprocess.run 调用已安装的 cli-anything-<software> 命令,端到端运行完整工作流程:创建项目 → 添加内容 → 通过真实软件导出 → 验证输出。

没有优雅降级。 必须安装真实软件。当软件缺失时,测试绝不能跳过或伪造结果——没有该软件,CLI 是无用的。软件是硬性依赖,而不是可选的。

示例 — LibreOffice 的真实端到端测试:

class TestWriterToPDF:
    def test_rich_writer_to_pdf(self, tmp_dir):
        proj = create_document(doc_type="writer", name="Report")
        add_heading(proj, text="Quarterly Report", level=1)
        add_table(proj, rows=3, cols=3, data=[...])

        pdf_path = os.path.join(tmp_dir, "report.pdf")
        result = export(proj, pdf_path, preset="pdf", overwrite=True)

        # 验证真实的输出文件
        assert os.path.exists(result["output"])
        assert result["file_size"] > 1000  # 不是可疑的小文件
        with open(result["output"], "rb") as f:
            assert f.read(5) == b"%PDF-"  # 验证格式魔数
        print(f"\n  PDF:{result['output']} ({result['file_size']:,} bytes)")


class TestCLISubprocessE2E:
    CLI_BASE = _resolve_cli("cli-anything-libreoffice")

    def test_full_writer_pdf_workflow(self, tmp_dir):
        proj_path = os.path.join(tmp_dir, "test.json")
        pdf_path = os.path.join(tmp_dir, "output.pdf")
        self._run(["document", "new", "-o", proj_path, "--type", "writer"])
        self._run(["--project", proj_path, "writer", "add-heading", "-t", "Title"])
        self._run(["--project", proj_path, "export", "render", pdf_path, "-p", "pdf", "--overwrite"])
        assert os.path.exists(pdf_path)
        with open(pdf_path, "rb") as f:
            assert f.read(5) == b"%PDF-"

在强制安装模式下运行测试以确保使用真实命令:

CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/<software>/tests/ -v -s

-s 标志会显示 [_resolve_cli] 的打印输出,确认使用的是哪个后端,并打印工件路径供手动检查。

真实工作流程测试场景应包括:

  • 多段编辑(类似 YouTube 的剪切/修剪)
  • 蒙太奇组合(许多短片段)
  • 画中画合成
  • 调色流水线
  • 音频混音(类似播客)
  • 繁重的撤销/重做压力测试
  • 复杂项目的保存/加载往返
  • 迭代细化(添加、修改、删除、重新添加)

关键原则

  • 使用真实软件 — CLI 必须调用实际应用程序进行渲染和导出。生成有效的中间文件(ODF、MLT XML、.blend、SVG),然后将它们交给真实软件。切勿用 Python 重新实现渲染引擎。
  • 软件是硬性依赖 — 不是可选的,也不是优雅降级的。如果未安装 LibreOffice,cli-anything-libreoffice 必须清晰地报错,而不是用回退库静默地产生劣质输出。
  • 直接操作原生格式 — 解析和修改应用程序的原生项目文件(MLT XML、ODF、SVG 等)作为数据层。
  • 利用现有的 CLI 工具 — 使用 libreoffice --headlessblender --backgroundmeltffmpeginkscape --actionssox 作为子进程进行渲染。
  • 验证渲染产生正确的输出 — 参见上面的“渲染差距”。
  • 端到端测试必须产生真实工件 — PDF、DOCX、渲染图像、视频。打印输出路径以便用户检查。切勿仅测试中间格式。
  • 失败时要响亮而清晰 — 代理需要明确的错误信息来自我纠正。
  • 尽可能幂等 — 两次运行同一命令应该是安全的。
  • 提供内省能力infoliststatus 命令对于代理在执行操作前了解当前状态至关重要。
  • JSON 输出模式 — 每个命令都应支持 --json 以便机器解析。

规则

  • 真实软件必须是硬性依赖。 CLI 必须调用实际软件(LibreOffice、Blender、GIMP 等)进行渲染和导出。不要在 Python 中重新实现渲染。不要优雅地降级到回退库。如果未安装该软件,CLI 必须报错并给出清晰的安装说明。
  • 每个 cli_anything/<software>/ 目录必须包含一个 README.md,说明如何安装软件依赖、如何安装 CLI、如何运行测试,并显示基本用法。
  • 端到端测试必须调用真实软件 并产生真实的输出文件(PDF、DOCX、渲染图像、视频)。测试必须验证输出存在、格式正确,并打印工件路径以便用户检查结果。切勿仅测试中间文件。
  • 每个导出/渲染函数必须经过验证,在标记为正常工作之前,必须以编程方式分析输出。“它运行没有错误”是不够的。
  • 注册表中的每个滤镜/效果必须有对应的渲染映射,或者明确记录为“仅限项目(不渲染)”。
  • 测试套件必须包含真实文件的端到端测试,而不仅仅是带有合成数据的单元测试。格式假设在真实媒体中不断被打破。
  • 端到端测试必须包含子进程测试,通过 _resolve_cli() 调用已安装的 cli-anything-<software> 命令。测试必须针对实际安装的包,而不仅仅是源导入。
  • 每个 cli_anything/<software>/tests/ 目录必须包含一个 TEST.md,记录测试覆盖的内容、测试了哪些真实工作流程以及完整的测试结果输出。
  • 每个 CLI 必须使用统一的 REPL 皮肤repl_skin.py)用于交互模式。将 cli-anything-plugin/repl_skin.py 复制到 utils/repl_skin.py,并使用 ReplSkin 处理横幅、提示、帮助、消息和告别。当 CLI 在没有子命令的情况下调用时(invoke_without_command=True),REPL 必须是默认行为。

目录结构

<software>/
└── agent-harness/
    ├── <SOFTWARE>.md          # 特定项目的分析和 SOP
    ├── setup.py               # PyPI 包配置(第七阶段)
    ├── cli_anything/          # 命名空间包(此处没有 __init__.py)
    │   └── <software>/        # 此 CLI 的子包
    │       ├── __init__.py
    │       ├── __main__.py    # python3 -m cli_anything.<software>
    │       ├── README.md      # 如何运行 — 必需
    │       ├── <software>_cli.py  # 主 CLI 入口点(Click + REPL)
    │       ├── core/          # 核心模块(每个领域一个)
    │       │   ├── __init__.py
    │       │   ├── project.py     # 项目创建/打开/保存/信息
    │       │   ├── ...            # 特定领域的模块
    │       │   ├── export.py      # 渲染管道 + 滤镜转换
    │       │   └── session.py     # 有状态会话、撤销/重做
    │       ├── utils/         # 共享工具
    │       │   ├── __init__.py
    │       │   ├── <software>_backend.py  # 后端:调用真实软件
    │       │   └── repl_skin.py  # 统一的 REPL 皮肤(从插件复制)
    │       └── tests/         # 测试套件
    │           ├── TEST.md        # 测试文档和结果 — 必需
    │           ├── test_core.py   # 单元测试(合成数据)
    │           └── test_full_e2e.py # 端到端测试(真实文件)
    └── examples/              # 示例脚本和工作流程

关键点: cli_anything/ 目录不得包含 __init__.py。这使其成为 PEP 420 命名空间包——多个单独安装的 PyPI 包可以各自在 cli_anything/ 下贡献一个子包而不会冲突。例如,cli-anything-gimp 添加 cli_anything/gimp/cli-anything-blender 添加 cli_anything/blender/,两者共存于同一个 Python 环境中。

注意:此 HARNESS.md 是 cli-anything-plugin 的一部分。各个软件目录都引用此文件 — 请勿重复复制。

将此应用于其他软件

同样的 SOP 适用于任何 GUI 应用程序:

软件 后端 CLI 原生格式 系统包 CLI 如何使用它
LibreOffice libreoffice --headless .odt/.ods/.odp (ODF ZIP) apt install libreoffice 生成 ODF → 转换为 PDF/DOCX/XLSX/PPTX
Blender blender --background --python .blend-cli.json apt install blender 生成 bpy 脚本 → Blender 渲染为 PNG/MP4
GIMP gimp -i -b '(script-fu ...)' .xcf apt install gimp Script-Fu 命令 → GIMP 处理并导出
Inkscape inkscape --actions="..." .svg (XML) apt install inkscape 操作 SVG → Inkscape 导出为 PNG/PDF
Shotcut/Kdenlive meltffmpeg .mlt (XML) apt install melt ffmpeg 构建 MLT XML → melt/ffmpeg 渲染视频
Audacity sox .aup3 apt install sox 生成 sox 命令 → sox 处理音频
OBS Studio obs-websocket scene.json apt install obs-studio WebSocket API → OBS 捕获/录制

软件是必需的依赖项,而不是可选的。 CLI 生成有效的中间文件(ODF、MLT XML、bpy 脚本、SVG)并将它们交给真实软件进行渲染。这正是使 CLI 真正有用的原因——它是该软件的命令行界面,而不是替代品。

模式总是相同的:构建数据 → 调用真实软件 → 验证输出

第七阶段:PyPI 发布和安装

在构建和测试 CLI 之后,使其可安装和可发现。

所有 cli-anything CLI 在共享的 cli_anything 命名空间下使用 PEP 420 命名空间包。这允许多个 CLI 包在同一 Python 环境中并排安装而不会冲突。

  1. 将包结构化为命名空间包:

    agent-harness/
    ├── setup.py
    └── cli_anything/           # 此处没有 __init__.py(命名空间包)
        └── <software>/         # 例如 gimp、blender、audacity
            ├── __init__.py     # 有 __init__.py(常规子包)
            ├── <software>_cli.py
            ├── core/
            ├── utils/
            └── tests/
    

    关键规则:cli_anything/没有 __init__.py。每个子包(gimp/blender/ 等)确实__init__.py。这使得多个包可以贡献于同一个命名空间。

  2. agent-harness/ 目录中创建 setup.py:

    from setuptools import setup, find_namespace_packages
    
    setup(
        name="cli-anything-<software>",
        version="1.0.0",
        packages=find_namespace_packages(include=["cli_anything.*"]),
        install_requires=[
            "click>=8.0.0",
            "prompt-toolkit>=3.0.0",
            # 在此添加 Python 库依赖项
        ],
        entry_points={
            "console_scripts": [
                "cli-anything-<software>=cli_anything.<software>.<software>_cli:main",
            ],
        },
        python_requires=">=3.10",
    )
    

    重要细节:

    • 使用 find_namespace_packages,而不是 find_packages
    • 使用 include=["cli_anything.*"] 限定发现范围
    • 入口点格式:cli_anything.<software>.<software>_cli:main
    • 系统包(LibreOffice、Blender 等)是硬性依赖,无法在 install_requires 中表达。在 README.md 中记录,并让后端模块引发一个带有安装说明的清晰错误:
      # 在 utils/<software>_backend.py 中
      def find_<software>():
          path = shutil.which("<software>")
          if path:
              return path
          raise RuntimeError(
              "<Software> 未安装。请使用以下命令安装:\n"
              "  apt install <software>   # Debian/Ubuntu\n"
              "  brew install <software>  # macOS"
          )
      
  3. 所有导入都使用 cli_anything.<software> 前缀:

    from cli_anything.gimp.core.project import create_project
    from cli_anything.gimp.core.session import Session
    from cli_anything.blender.core.scene import create_scene
    
  4. 测试本地安装:

    cd /root/cli-anything/<software>/agent-harness
    pip install -e .
    
  5. 验证 PATH 安装:

    which cli-anything-<software>
    cli-anything-<software> --help
    
  6. 针对已安装的命令运行测试:

    cd /root/cli-anything/<software>/agent-harness
    CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/<software>/tests/ -v -s
    

    输出必须显示 [_resolve_cli] 使用已安装的命令:/path/to/cli-anything-<software>,确认子进程测试针对真实安装的二进制文件运行,而不是模块回退。

  7. 验证命名空间跨包工作(当安装了多个 CLI 时):

    import cli_anything.gimp
    import cli_anything.blender
    # 两者都解析到各自的源目录
    

为什么使用命名空间包:

  • 多个 CLI 在同一 Python 环境中共存而不冲突
  • 在单个 cli_anything 命名空间下进行干净、有组织的导入
  • 每个 CLI 可通过 pip 独立安装/卸载
  • 代理可以通过 cli_anything.* 发现所有已安装的 CLI
  • 标准的 Python 打包方式——无需黑客或变通方法
posted @ 2026-03-19 13:17  悠哉大斌  阅读(83)  评论(0)    收藏  举报