diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b6a6ea..1fa19d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,41 @@ on: - "v*" jobs: + verify: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Verify Python + run: | + uv sync --dev + uv run pytest -q + uv run python -m compileall -q scripts + + - name: Verify Node launcher + run: node --test tests/test_getbijiex_launcher.mjs + + - name: Verify npx Skill installation + if: runner.os == 'Linux' + shell: bash + run: | + npx -y skills@1.5.20 add . --list | tee /tmp/getbijiex-skills.txt + grep -q "getbijiex" /tmp/getbijiex-skills.txt + build: + needs: verify strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 41729ee..9ab1492 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,15 @@ tmp/ # Local scripts artifacts .DrissionPage/ +# Node/npm artifacts +node_modules/ +.npm/ +npm-debug.log* +*.tgz +coverage/ + +# Claude Code local worktrees +.claude/ + # macOS .DS_Store diff --git a/GetbijiEx.spec b/GetbijiEx.spec index f0337d1..f76e5f5 100644 --- a/GetbijiEx.spec +++ b/GetbijiEx.spec @@ -14,7 +14,7 @@ a = Analysis( binaries=[], datas=[ (str(ROOT / 'scripts'), './scripts'), - (str(ROOT / 'skill'), './skill'), + (str(ROOT / 'skills'), './skills'), ], hiddenimports=[ 'scripts.app_paths', diff --git a/README.md b/README.md index 9563fd9..ae3398b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,30 @@ **不会终端、不会 Python、只会用 Agent,也可以安装。直接看下面第一部分。** +## 已经装好软件?一条 npx 命令安装 Skill + +如果电脑里已经保留了 GetbijiEx App、Windows 程序或源码,可以把下面一整行复制给支持 Agent Skills 的 Agent 执行: + +```bash +npx skills add Likely7/GetbijiEx --skill getbijiex +``` + +指定安装到 Claude Code: + +```bash +npx skills add Likely7/GetbijiEx --skill getbijiex --agent claude-code -g -y +``` + +指定安装到 Codex: + +```bash +npx skills add Likely7/GetbijiEx --skill getbijiex --agent codex -g -y +``` + +> **这条 npx 命令只安装 Skill,不会安装 GetbijiEx 软件本身。** 它使用的是公开的 `skills` 安装工具,不是本项目发布了一个同名 npm 软件包。执行这条命令需要 Node.js 22.20+。第一次导出时,Skill 会自动查找标准位置和 PATH 中已有的 GetbijiEx;如果没有找到,Agent 会在本机查找并代你配置真实路径。使用源码版时还需要 Python 3.11+ 和 `uv`。 + +如果你的 Agent 不支持标准 Agent Skills 目录,继续使用下面的完整提示词,让它自己确认 Skill 目录并安装。 + --- ## 最简单的用法:把这段话复制给你的 Agent @@ -56,7 +80,17 @@ ## 已经装好 GetbijiEx,只想单独安装 Skill -有。Skill 的安装命令就是 `install-skill`。 +### 支持 Agent Skills 的 Agent:直接复制 npx 命令 + +```bash +npx skills add Likely7/GetbijiEx --skill getbijiex +``` + +不带 `-g` 时默认安装到当前项目;希望这个 Agent 在所有项目中都能使用时,加上 `-g`。如果 Agent 要求明确指定目标,可以加上 `--agent claude-code`、`--agent codex` 或它实际支持的 Agent 名称。安装工具会把整个 `getbijiex` Skill 和跨平台启动器一起安装。 + +### 不支持标准安装方式:让 Agent 调用 GetbijiEx 自带安装器 + +GetbijiEx 自带的 Skill 安装子命令是 `install-skill`。 > **注意:Skill 不是导出软件本身。** Skill 只是告诉 Agent 怎么调用 GetbijiEx。电脑里必须同时保留 GetbijiEx 软件或源码,删掉以后 Skill 就无法导出。 @@ -277,12 +311,16 @@ CLI 的 stdout 只输出 JSON,过程日志写入 stderr,供 Agent 稳定解 PyInstaller 不能从 macOS 直接交叉构建 Windows 应用。仓库中的 GitHub Actions 会在对应系统分别构建。 -## 自动化测试 +## 自动化验证 ```bash uv run pytest -q +node --test tests/test_getbijiex_launcher.mjs +npx -y skills@1.5.20 add . --list ``` +最后一条命令用于确认标准 Skill 安装工具能从本仓库发现 `getbijiex`。 + --- diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f38ecd0..cac9b17 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,6 +1,6 @@ # 技术决策记录 -> 最后更新:2026-07-25 +> 最后更新:2026-07-26 本文记录已经确定并影响后续维护的关键决策。若要改变某项决策,应新增一条替代记录,不直接删除历史原因。 @@ -41,10 +41,10 @@ ## ADR-006:Skill 随应用打包并支持多 Agent 安装 -**状态:** 已采用 -**背景:** 单独维护 Skill 仓库会增加版本漂移,小白用户也难以手动配置调用路径。 -**决策:** `skill/SKILL.md` 作为模板随应用打包,安装时把占位符替换为真实 CLI 路径;预设 Claude Code、Codex,并允许 `--dir` 指定其他 Agent 目录。 -**结果:** Skill 与应用版本同步,GUI 和 CLI 都能完成安装。 +**状态:** 已采用 +**背景:** 单独维护 Skill 仓库会增加版本漂移,小白用户也难以手动配置调用路径。 +**决策:** `skills/getbijiex/` 作为唯一 Skill 来源并随应用打包;GetbijiEx 自带安装器会复制完整目录,并把默认 npx 启动区块替换为真实 CLI 路径;预设 Claude Code、Codex,并允许 `--dir` 指定其他 Agent 目录。 +**结果:** Skill 与应用版本同步,GUI、GetbijiEx CLI 和标准 Agent Skills 安装工具都能完成安装。 ## ADR-007:应用数据使用操作系统标准目录 @@ -66,3 +66,10 @@ **背景:** `config/biji_auth.json` 曾进入本地 Git 历史,公开推送会泄露认证信息。 **决策:** 文件加入 `.gitignore`,并在首次公开推送前清理全部待推送历史。 **结果:** 当前公开仓库与构建产物不包含认证文件;后续发布检查必须继续验证。 + +## ADR-010:使用标准 Agent Skills CLI 分发独立 Skill + +**状态:** 已采用 +**背景:** 用户需要一条可以直接复制的 npx 命令,但项目尚无正式 GitHub Release;此时发布自有 npm 软件安装器会引入二进制下载、校验、平台版本和 npm 发布权限等额外依赖。 +**决策:** 仓库使用标准 `skills/getbijiex/` 结构,通过 `npx skills add Likely7/GetbijiEx --skill getbijiex` 从 GitHub 分发 Skill。原样安装的 Skill 携带 Node 启动器,用于定位标准安装位置、PATH、环境变量或用户明确配置的 GetbijiEx;为避免执行当前项目中的不可信脚本,不自动信任工作目录里的同名源码。本阶段不发布 `npx getbijiex` 软件安装器。 +**结果:** Skill 可以在没有自有 npm 包和正式 Release 的情况下单独安装,同时保留“Skill 不等于软件本体”的边界。正式 Release 完成后,再独立评估通过 npm 下载和校验应用程序。 diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 4ec0a87..fca038a 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -23,25 +23,26 @@ GetbijiEx 用于把 biji.com(Get笔记/得到大脑)知识库中订阅博主 | 导出进度 | 已完成 | GUI 显示当前条目和总体进度,完成后打开输出目录 | | 跨平台数据目录 | 已完成 | macOS、Windows、Linux 分别使用各自标准应用数据目录 | | 双模式应用 | 已完成 | 无参数启动 GUI,带参数进入 JSON CLI | -| Agent Skill | 已完成 | 支持 Claude Code、Codex 和自定义 Skill 根目录 | -| 小白安装说明 | 已完成 | README 提供可直接复制给 Agent 的完整安装提示词、单独安装 Skill 提示词和各平台命令 | +| Agent Skill | 已完成 | 支持标准 `npx skills add`、Claude Code、Codex 和自定义 Skill 根目录 | +| 小白安装说明 | 已完成 | README 提供 npx 单行命令、可直接复制给 Agent 的完整安装提示词和各平台命令 | | macOS 打包 | 已完成 | 本机与 GitHub Actions 均已验证 | | Windows 打包 | 已完成 | GitHub Actions 已验证可生成 Windows 产物 | -| 自动化测试 | 已完成 | 覆盖 Token 提取、列表分页、CLI、Skill 安装和数据迁移 | +| 自动化测试 | 已完成 | 覆盖 Token 提取、列表分页、CLI、Skill 安装、npx 启动器和数据迁移 | | 正式 Release | 待完成 | 需要确定版本号、生成发布说明并上传版本产物 | ## 最近验证基线 -最近一次完整发布基线为提交 `9d466ca`: +2026-07-26 对 npx Skill 安装改造完成了本地发布前验证: -- `pytest`:20 个测试通过。 -- macOS PyInstaller 构建成功。 -- Windows GitHub Actions 构建成功。 -- macOS GUI 已实际启动并检查关键控件。 -- Claude Code、Codex、自定义目录三种 Skill 安装方式均验证通过。 -- Git 历史和打包产物中不包含 `config/biji_auth.json`。 +- `pytest`:28 个测试通过。 +- Node 启动器:15 个测试通过,其中包含不信任工作目录源码、缺失 `uv`、CLI 依赖导入失败提示和参数/退出码转发测试。 +- 标准 `skills` CLI 可以发现并在隔离 HOME 中安装 `getbijiex`。 +- 源码版和打包版 `install-skill` 均能复制完整 Skill 资源并生成直连命令。 +- macOS Apple Silicon PyInstaller 构建成功,打包版 CLI 和 Skill 安装命令通过烟雾测试。 +- Git diff 格式检查和敏感信息扫描通过。 +- Linux 与 Windows 的 Node 启动器测试已纳入 GitHub Actions;推送后仍需确认远端工作流结果。 -本文件之后的改动必须按 [发布检查清单](RELEASE_CHECKLIST.md) 重新验证,不能沿用旧结果代替新验证。 +历史完整发布基线为提交 `9d466ca`;本文件之后的改动仍须按 [发布检查清单](RELEASE_CHECKLIST.md) 重新验证,不能沿用旧结果代替新验证。 ## 当前工作重点 @@ -57,6 +58,7 @@ GetbijiEx 用于把 biji.com(Get笔记/得到大脑)知识库中订阅博主 - macOS 应用未做 Apple Developer ID 签名和公证,首次打开可能需要用户在系统安全设置中确认。 - macOS 构建产物与构建机器架构一致;当前本地验证产物为 Apple Silicon。 - GitHub Actions 可以构建 Windows 产物,但真实 Windows GUI 与 Chrome 联动仍需要人工验收。 +- npx 命令只安装 Skill,不包含 GetbijiEx 软件本身;使用 npx 安装的 Skill 需要 Node.js 22.20+,本机必须已有 App、Windows 程序或源码;源码模式还需要 Python 3.11+ 和 `uv`。 - Biji 是外部服务,其接口结构和鉴权规则变化可能导致功能失效。 ## 项目文档 diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 66b5a43..fd5079c 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -16,11 +16,15 @@ uv sync --dev uv run pytest -q uv run python -m compileall -q scripts +node --test tests/test_getbijiex_launcher.mjs +npx -y skills@1.5.20 add . --list git diff --check ``` - [ ] 全部测试通过,失败数为 0。 - [ ] Python 模块编译检查通过。 +- [ ] Node 跨平台启动器测试在 Linux 和 Windows CI 上通过。 +- [ ] 标准 `skills` CLI 能发现 `getbijiex`。 - [ ] Git diff 格式检查通过。 ## 3. 敏感信息检查 @@ -49,7 +53,11 @@ git diff --check - [ ] Claude Code 安装路径正确。 - [ ] Codex 安装路径正确。 - [ ] `--dir` 自定义目录安装正确。 -- [ ] 安装后的 `SKILL.md` 不再包含 `{{CLI_COMMAND}}` 占位符。 +- [ ] GetbijiEx 自带安装器会复制完整 Skill 资源,并把默认启动区块替换为本机直连命令。 +- [ ] `npx skills add Likely7/GetbijiEx --skill getbijiex` 能安装 `SKILL.md` 和跨平台启动器。 +- [ ] npx 安装后的启动器能自动定位、显式配置并调用现有 GetbijiEx,参数和退出码不会丢失。 +- [ ] 启动器不会自动执行当前工作目录或父目录中的同名 `scripts/biji_cli.py`。 +- [ ] Skill 中不存在未处理的 `{{CLI_COMMAND}}` 占位符。 ## 6. 平台构建 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6675bbb..2d92057 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,7 @@ - [x] 完成 GetbijiEx 品牌改名与旧数据目录迁移。 - [x] 建立 macOS、Windows 自动构建流程。 - [x] README 提供可直接复制给 Agent 的安装和单独安装 Skill 提示词。 +- [x] 支持 `npx skills add` 从 GitHub 单独安装 Skill,并提供跨平台本机启动器。 ## 阶段二:首个公开版本(当前阶段) @@ -35,7 +36,7 @@ - [x] 给 Windows 用户补充 SmartScreen 和首次运行说明。 - [x] 给 macOS 用户补充未签名应用的首次打开说明。 -- [ ] 在 CI 中增加自动测试步骤,构建前先运行 `pytest`。 +- [x] 在 CI 中增加 Python、Linux/Windows Node 启动器和标准 Skill 发现测试,验证通过后再构建。 - [ ] 增加失败导出的可恢复机制或失败条目清单。 - [ ] 改善窄窗口、小屏幕和系统字体放大时的 GUI 布局。 diff --git a/scripts/skill_installer.py b/scripts/skill_installer.py index f2d8967..c6f3d8c 100644 --- a/scripts/skill_installer.py +++ b/scripts/skill_installer.py @@ -1,18 +1,16 @@ -""" -把 GetbijiEx 的 Skill 安装到 Claude Code、Codex 或自定义 Agent 目录。 - -模板在仓库 skill/SKILL.md,其中的 {{CLI_COMMAND}} 会被替换为 -当前环境的真实调用方式: -- 打包版:app 可执行文件的绝对路径 -- 源码版:uv run python scripts/biji_cli.py -""" +"""把 GetbijiEx Skill 安装到 Claude Code、Codex 或自定义 Agent 目录。""" + +import os import shlex +import shutil import sys from pathlib import Path from scripts.app_paths import is_frozen, project_root, resources_root SKILL_NAME = "getbijiex" +COMMAND_START = "" +COMMAND_END = "" # 各 Agent 的 Skill 安装根目录(相对 home) AGENT_SKILL_DIRS = { @@ -25,11 +23,26 @@ class UnsupportedAgentError(ValueError): pass -def cli_command() -> str: +class SkillTemplateError(ValueError): + pass + + +def _powershell_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def cli_command(platform: str | None = None) -> str: + platform = platform or sys.platform if is_frozen(): - return shlex.quote(str(Path(sys.executable).resolve())) - root = project_root() - return f"cd {shlex.quote(str(root))} && uv run python scripts/biji_cli.py" + executable = str(Path(sys.executable).resolve()) + if platform.startswith("win"): + return f"& {_powershell_quote(executable)}" + return shlex.quote(executable) + + root = str(project_root().resolve()) + if platform.startswith("win"): + return f"uv --directory {_powershell_quote(root)} run python scripts/biji_cli.py" + return f"uv --directory {shlex.quote(root)} run python scripts/biji_cli.py" def skill_target(agent: str = "claude", target_dir: str | None = None) -> Path: @@ -46,10 +59,49 @@ def skill_target(agent: str = "claude", target_dir: str | None = None) -> Path: return base / SKILL_NAME / "SKILL.md" +def _render_direct_command( + template: str, + command: str, + platform: str | None = None, +) -> str: + if template.count(COMMAND_START) != 1 or template.count(COMMAND_END) != 1: + raise SkillTemplateError("Skill 模板必须包含且只包含一组 CLI 命令标记。") + platform = platform or sys.platform + shell_description = ( + "PowerShell 中" if platform.startswith("win") else "Bash 兼容终端中" + ) + code_fence = "powershell" if platform.startswith("win") else "bash" + start = template.index(COMMAND_START) + end = template.index(COMMAND_END, start) + len(COMMAND_END) + replacement = ( + f"{COMMAND_START}\n" + "以下命令前缀由 GetbijiEx 安装器生成,后文统称为 `CLI`。" + f"请在 {shell_description}执行:\n\n" + f"```{code_fence}\n" + f"{command}\n" + "```\n" + f"{COMMAND_END}" + ) + return template[:start] + replacement + template[end:] + + def install_skill(agent: str = "claude", target_dir: str | None = None) -> Path: - template_path = resources_root() / "skill" / "SKILL.md" + source = resources_root() / "skills" / SKILL_NAME + template_path = source / "SKILL.md" template = template_path.read_text(encoding="utf-8") + rendered = _render_direct_command(template, cli_command(), sys.platform) + target = skill_target(agent, target_dir) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(template.replace("{{CLI_COMMAND}}", cli_command()), encoding="utf-8") + destination = target.parent + destination.parent.mkdir(parents=True, exist_ok=True) + + temporary = destination.with_name(f".{destination.name}.tmp-{os.getpid()}") + if temporary.exists(): + shutil.rmtree(temporary) + shutil.copytree(source, temporary) + (temporary / "SKILL.md").write_text(rendered, encoding="utf-8") + + if destination.exists(): + shutil.rmtree(destination) + temporary.replace(destination) return target diff --git a/skill/SKILL.md b/skills/getbijiex/SKILL.md similarity index 60% rename from skill/SKILL.md rename to skills/getbijiex/SKILL.md index aa89076..04221a9 100644 --- a/skill/SKILL.md +++ b/skills/getbijiex/SKILL.md @@ -1,20 +1,34 @@ --- name: getbijiex description: 导出 biji.com(Get笔记/得到大脑)知识库中订阅博主(多为抖音)的笔记为 Markdown。当用户想导出 biji 笔记、知识库内容、订阅博主笔记,或提到"biji""得到大脑""Get笔记"导出时使用。依赖本机已安装 GetbijiEx 或其源码。 +compatibility: 需要本机已安装 GetbijiEx App、Windows 程序或源码;npx 安装模式还需要 Node.js 22.20+,源码模式另外需要 Python 3.11+ 和 uv。 --- # GetbijiEx 本机装有「GetbijiEx」,以下 CLI 就是它的命令行模式,所有命令**输出 JSON 到 stdout**,过程日志在 stderr。 -CLI 命令前缀(后文统称 `CLI`): +## CLI 命令 + + +如果本 Skill 是通过 `npx skills add` 安装的,先把下面脚本路径解析为**当前 Skill 根目录下的绝对路径**,后文统称这条命令为 `CLI`: + +```bash +node "<当前 Skill 根目录>/scripts/getbijiex-cli.mjs" +``` + +不要把 `<当前 Skill 根目录>` 原样交给终端。如果启动器返回 `GetbijiExNotFound`,请你自己在本机查找 GetbijiEx,然后运行以下任意一条配置命令,不要要求用户猜路径。为避免执行当前项目中的不可信脚本,启动器不会自动把工作目录里的 `scripts/biji_cli.py` 当成 GetbijiEx 源码: ```bash -{{CLI_COMMAND}} +node "<当前 Skill 根目录>/scripts/getbijiex-cli.mjs" configure --executable "" +node "<当前 Skill 根目录>/scripts/getbijiex-cli.mjs" configure --source "<包含 scripts/biji_cli.py 的 GetbijiEx 源码根目录>" ``` + ## 子命令 +把下列示例中的 `CLI` 替换为上面确定的实际命令前缀: + ```bash CLI topics # 知识库列表:name / id_alias / count / source CLI follows # 该知识库的博主列表:name / follow_id / topic_id / note_count @@ -37,3 +51,5 @@ CLI token # 刷新 Token(弹出浏览器,见下) - 导出结果默认在应用数据目录(JSON 结果里的 `output_dir` 字段有完整路径),`markdown_path` 是成品。 - 用户想换输出位置时用 `--output-dir`。 +- Skill 只是调用说明和启动器,不包含 GetbijiEx 导出程序本体。 +- 源码模式需要 Python 3.11+ 和 `uv`;如果返回 `GetbijiExDependencyMissing`,先安装 `uv` 并确认它已加入 PATH,然后重试。 diff --git a/skills/getbijiex/scripts/getbijiex-cli.mjs b/skills/getbijiex/scripts/getbijiex-cli.mjs new file mode 100644 index 0000000..d9db9cb --- /dev/null +++ b/skills/getbijiex/scripts/getbijiex-cli.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { constants as fsConstants, realpathSync } from "node:fs"; +import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const CONFIG_VERSION = 1; +const CONFIG_FILE_NAME = "cli_runtime.json"; + +class LauncherError extends Error { + constructor(kind, message) { + super(message); + this.name = kind; + this.kind = kind; + } +} + +export function appDataRoot({ + platform = process.platform, + env = process.env, + home = os.homedir(), +} = {}) { + if (env.GETBIJIEX_DATA_DIR) { + return path.resolve(env.GETBIJIEX_DATA_DIR); + } + if (platform === "darwin") { + return path.join(home, "Library", "Application Support", "GetbijiEx"); + } + if (platform === "win32") { + return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "GetbijiEx"); + } + return path.join(home, ".local", "share", "GetbijiEx"); +} + +export function runtimeConfigPath(options = {}) { + return path.join(appDataRoot(options), "config", CONFIG_FILE_NAME); +} + +async function isFile(filePath) { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function isDirectory(directoryPath) { + try { + return (await stat(directoryPath)).isDirectory(); + } catch { + return false; + } +} + +async function isRunnableFile(filePath, platform = process.platform) { + if (!(await isFile(filePath))) { + return false; + } + if (platform === "win32") { + return true; + } + try { + await access(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +export async function normalizeExecutablePath(input, platform = process.platform) { + const candidate = path.resolve(input); + if (platform === "darwin" && candidate.toLowerCase().endsWith(".app")) { + return path.join(candidate, "Contents", "MacOS", "GetbijiEx"); + } + if (platform === "win32" && (await isDirectory(candidate))) { + return path.join(candidate, "GetbijiEx.exe"); + } + return candidate; +} + +export function sourceDescriptor(root) { + const resolvedRoot = path.resolve(root); + return { + type: "source", + path: resolvedRoot, + command: "uv", + argsPrefix: [ + "--directory", + resolvedRoot, + "run", + "python", + "scripts/biji_cli.py", + ], + cwd: resolvedRoot, + }; +} + +export function executableDescriptor(executablePath) { + const resolvedPath = path.resolve(executablePath); + return { + type: "executable", + path: resolvedPath, + command: resolvedPath, + argsPrefix: [], + cwd: path.dirname(resolvedPath), + }; +} + +async function validateSource(root) { + return isFile(path.join(root, "scripts", "biji_cli.py")); +} + +async function validateDescriptor(descriptor, platform = process.platform) { + if (descriptor.type === "source") { + return validateSource(descriptor.path); + } + if (descriptor.type === "executable") { + return isRunnableFile(descriptor.path, platform); + } + return false; +} + +async function descriptorFromExecutable(input, platform = process.platform) { + return executableDescriptor(await normalizeExecutablePath(input, platform)); +} + +function descriptorFromConfig(config) { + if (!config || config.version !== CONFIG_VERSION || typeof config.path !== "string") { + throw new LauncherError("GetbijiExConfigInvalid", "保存的 GetbijiEx CLI 配置格式无效,请重新配置。"); + } + if (config.type === "source") { + return sourceDescriptor(config.path); + } + if (config.type === "executable") { + return executableDescriptor(config.path); + } + throw new LauncherError("GetbijiExConfigInvalid", "保存的 GetbijiEx CLI 类型无效,请重新配置。"); +} + +async function readRuntimeConfig(configPath) { + try { + return JSON.parse(await readFile(configPath, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") { + return null; + } + if (error instanceof SyntaxError) { + throw new LauncherError("GetbijiExConfigInvalid", `配置文件不是有效 JSON:${configPath}`); + } + throw error; + } +} + +function pathExecutableCandidates({ platform, env }) { + const pathValue = env.PATH || env.Path || env.path || ""; + const names = platform === "win32" + ? ["GetbijiEx.exe", "getbijiex.exe"] + : ["GetbijiEx", "getbijiex"]; + return pathValue + .split(path.delimiter) + .filter(Boolean) + .flatMap((directory) => names.map((name) => path.join(directory, name))); +} + +function standardExecutableCandidates({ platform, env, home }) { + if (platform === "darwin") { + return [ + "/Applications/GetbijiEx.app/Contents/MacOS/GetbijiEx", + path.join(home, "Applications", "GetbijiEx.app", "Contents", "MacOS", "GetbijiEx"), + ]; + } + if (platform === "win32") { + return [ + env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "GetbijiEx", "GetbijiEx.exe"), + env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "GetbijiEx", "GetbijiEx.exe"), + env.ProgramFiles && path.join(env.ProgramFiles, "GetbijiEx", "GetbijiEx.exe"), + env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "GetbijiEx", "GetbijiEx.exe"), + ].filter(Boolean); + } + return [ + path.join(home, ".local", "bin", "GetbijiEx"), + "/opt/GetbijiEx/GetbijiEx", + ]; +} + +async function firstValidExecutable(candidates, platform) { + for (const candidate of candidates) { + if (await isRunnableFile(candidate, platform)) { + return executableDescriptor(candidate); + } + } + return null; +} + +export async function resolveRuntime({ + platform = process.platform, + env = process.env, + home = os.homedir(), + configPath = runtimeConfigPath({ platform, env, home }), +} = {}) { + if (env.GETBIJIEX_EXECUTABLE) { + const descriptor = await descriptorFromExecutable(env.GETBIJIEX_EXECUTABLE, platform); + if (!(await validateDescriptor(descriptor, platform))) { + throw new LauncherError( + "GetbijiExPathInvalid", + `GETBIJIEX_EXECUTABLE 指向的程序不存在或不可执行:${descriptor.path}`, + ); + } + return descriptor; + } + + if (env.GETBIJIEX_SOURCE) { + const descriptor = sourceDescriptor(env.GETBIJIEX_SOURCE); + if (!(await validateDescriptor(descriptor, platform))) { + throw new LauncherError( + "GetbijiExPathInvalid", + `GETBIJIEX_SOURCE 不是有效源码目录:${descriptor.path}`, + ); + } + return descriptor; + } + + const savedConfig = await readRuntimeConfig(configPath); + if (savedConfig) { + const descriptor = descriptorFromConfig(savedConfig); + if (!(await validateDescriptor(descriptor, platform))) { + throw new LauncherError( + "GetbijiExConfigStale", + `保存的 GetbijiEx 路径已经失效:${descriptor.path}。请重新运行 configure。`, + ); + } + return descriptor; + } + + const executable = await firstValidExecutable( + [ + ...standardExecutableCandidates({ platform, env, home }), + ...pathExecutableCandidates({ platform, env }), + ], + platform, + ); + if (executable) { + return executable; + } + + throw new LauncherError( + "GetbijiExNotFound", + "没有找到 GetbijiEx。请在本机找到 GetbijiEx.app、GetbijiEx.exe 或源码目录,然后运行 configure --executable <路径> 或 configure --source <目录>。", + ); +} + +export async function configureRuntime(args, options = {}) { + let type = null; + let rawPath = null; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument !== "--executable" && argument !== "--source") { + throw new LauncherError("GetbijiExConfigureError", `不支持的 configure 参数:${argument}`); + } + if (type !== null) { + throw new LauncherError("GetbijiExConfigureError", "--executable 和 --source 只能选择一个。"); + } + type = argument === "--source" ? "source" : "executable"; + rawPath = args[index + 1]; + if (!rawPath) { + throw new LauncherError("GetbijiExConfigureError", `${argument} 后面必须提供路径。`); + } + index += 1; + } + if (!type) { + throw new LauncherError( + "GetbijiExConfigureError", + "请使用 configure --executable <路径> 或 configure --source <目录>。", + ); + } + + const platform = options.platform || process.platform; + const descriptor = type === "source" + ? sourceDescriptor(rawPath) + : await descriptorFromExecutable(rawPath, platform); + if (!(await validateDescriptor(descriptor, platform))) { + throw new LauncherError( + "GetbijiExPathInvalid", + type === "source" + ? `不是有效的 GetbijiEx 源码目录:${descriptor.path}` + : `GetbijiEx 程序不存在或不可执行:${descriptor.path}`, + ); + } + + const configPath = options.configPath || runtimeConfigPath(options); + await mkdir(path.dirname(configPath), { recursive: true }); + const config = { version: CONFIG_VERSION, type, path: descriptor.path }; + const temporaryPath = `${configPath}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + await rename(temporaryPath, configPath); + return { ...config, config_path: configPath }; +} + +function dependencyErrorMessage(result) { + const detail = String(result.stderr || result.error?.message || "") + .trim() + .replace(/\s+/g, " "); + const suffix = detail ? `:${detail}` : "。"; + return `源码模式需要 uv、Python 3.11+ 和可用的项目依赖${suffix}`; +} + +function validateSourceRuntime(descriptor) { + const result = spawnSync( + descriptor.command, + [...descriptor.argsPrefix, "--help"], + { + cwd: descriptor.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }, + ); + if (result.error || result.signal || result.status !== 0) { + throw new LauncherError( + "GetbijiExDependencyMissing", + dependencyErrorMessage(result), + ); + } +} + +export function runRuntime(descriptor, args, options = {}) { + if (descriptor.type === "source" && !options.skipSourceValidation) { + validateSourceRuntime(descriptor); + } + const result = spawnSync( + descriptor.command, + [...descriptor.argsPrefix, ...args], + { + cwd: descriptor.cwd, + stdio: "inherit", + shell: false, + }, + ); + if (result.error) { + throw new LauncherError( + "GetbijiExLaunchError", + `无法启动 GetbijiEx:${result.error.message}`, + ); + } + if (result.signal) { + return 1; + } + return result.status ?? 1; +} + +function outputError(error) { + const kind = error instanceof LauncherError ? error.kind : error?.name || "Error"; + const message = error?.message || String(error); + process.stdout.write(`${JSON.stringify({ error: `${kind}: ${message}` })}\n`); +} + +export async function main(args = process.argv.slice(2)) { + try { + if (args[0] === "configure") { + const result = await configureRuntime(args.slice(1)); + process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`); + return 0; + } + const runtime = await resolveRuntime(); + return runRuntime(runtime, args); + } catch (error) { + outputError(error); + return 1; + } +} + +function comparablePath(filePath) { + try { + return realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +const isDirectRun = process.argv[1] + && comparablePath(process.argv[1]) === comparablePath(fileURLToPath(import.meta.url)); +if (isDirectRun) { + process.exitCode = await main(); +} diff --git a/tests/test_getbijiex_launcher.mjs b/tests/test_getbijiex_launcher.mjs new file mode 100644 index 0000000..6a80f48 --- /dev/null +++ b/tests/test_getbijiex_launcher.mjs @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + appDataRoot, + configureRuntime, + normalizeExecutablePath, + resolveRuntime, + runRuntime, + runtimeConfigPath, + sourceDescriptor, +} from "../skills/getbijiex/scripts/getbijiex-cli.mjs"; + +async function temporaryDirectory(prefix) { + return mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function createSource(root) { + const scripts = path.join(root, "scripts"); + await mkdir(scripts, { recursive: true }); + await writeFile(path.join(scripts, "biji_cli.py"), "# test\n", "utf8"); +} + +async function createExecutable(filePath) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, "#!/bin/sh\nexit 0\n", "utf8"); + await chmod(filePath, 0o755); +} + +test("appDataRoot follows platform conventions", () => { + assert.equal( + appDataRoot({ platform: "darwin", env: {}, home: "/Users/test" }), + path.join("/Users/test", "Library", "Application Support", "GetbijiEx"), + ); + assert.equal( + appDataRoot({ platform: "win32", env: { APPDATA: "C:\\Data" }, home: "C:\\Users\\test" }), + path.join("C:\\Data", "GetbijiEx"), + ); + assert.equal( + appDataRoot({ platform: "linux", env: {}, home: "/home/test" }), + path.join("/home/test", ".local", "share", "GetbijiEx"), + ); +}); + +test("runtimeConfigPath supports isolated data directories", () => { + assert.equal( + runtimeConfigPath({ env: { GETBIJIEX_DATA_DIR: "/tmp/custom data" } }), + path.join(path.resolve("/tmp/custom data"), "config", "cli_runtime.json"), + ); +}); + +test("normalizeExecutablePath accepts macOS app bundles", async () => { + const app = path.resolve("Applications", "GetbijiEx.app"); + assert.equal( + await normalizeExecutablePath(app, "darwin"), + path.join(app, "Contents", "MacOS", "GetbijiEx"), + ); +}); + +test("environment source overrides saved configuration", async () => { + const root = await temporaryDirectory("getbijiex-node-中文-"); + const source = path.join(root, "源码 path"); + await createSource(source); + const configPath = path.join(root, "config.json"); + await writeFile( + configPath, + JSON.stringify({ version: 1, type: "source", path: "/missing" }), + "utf8", + ); + + const runtime = await resolveRuntime({ + platform: "linux", + env: { GETBIJIEX_SOURCE: source, PATH: "" }, + home: root, + cwd: root, + configPath, + }); + + assert.deepEqual(runtime, sourceDescriptor(source)); + assert.deepEqual(runtime.argsPrefix.slice(0, 3), ["--directory", source, "run"]); +}); + +test("configure persists and resolveRuntime reads a source path", async () => { + const root = await temporaryDirectory("getbijiex-config-"); + const source = path.join(root, "Source With Spaces"); + const configPath = path.join(root, "settings", "cli_runtime.json"); + await createSource(source); + + const configured = await configureRuntime(["--source", source], { + platform: "linux", + configPath, + }); + const saved = JSON.parse(await readFile(configPath, "utf8")); + const runtime = await resolveRuntime({ + platform: "linux", + env: { PATH: "" }, + home: root, + cwd: root, + configPath, + }); + + assert.equal(configured.type, "source"); + assert.deepEqual(saved, { version: 1, type: "source", path: path.resolve(source) }); + assert.equal(runtime.path, path.resolve(source)); +}); + +test("configure accepts a macOS app and stores its inner executable", async () => { + const root = await temporaryDirectory("getbijiex-app-"); + const app = path.join(root, "GetbijiEx App.app"); + const executable = path.join(app, "Contents", "MacOS", "GetbijiEx"); + const configPath = path.join(root, "config.json"); + await createExecutable(executable); + + const configured = await configureRuntime(["--executable", app], { + platform: "darwin", + configPath, + }); + + assert.equal(configured.path, executable); +}); + +test("resolveRuntime does not trust source files in the working directory", async () => { + const root = await temporaryDirectory("getbijiex-untrusted-"); + const nested = path.join(root, "one", "two"); + await createSource(root); + await mkdir(nested, { recursive: true }); + + await assert.rejects( + resolveRuntime({ + platform: "linux", + env: { PATH: "" }, + home: path.join(root, "home"), + cwd: nested, + configPath: path.join(root, "missing.json"), + }), + { name: "GetbijiExNotFound" }, + ); +}); + +test("resolveRuntime reports stale saved paths instead of silently falling back", async () => { + const root = await temporaryDirectory("getbijiex-stale-"); + const configPath = path.join(root, "config.json"); + await writeFile( + configPath, + JSON.stringify({ version: 1, type: "source", path: path.join(root, "gone") }), + "utf8", + ); + + await assert.rejects( + resolveRuntime({ + platform: "linux", + env: { PATH: "" }, + home: root, + cwd: root, + configPath, + }), + { name: "GetbijiExConfigStale" }, + ); +}); + +test("resolveRuntime returns a clear not-found error", async () => { + const root = await temporaryDirectory("getbijiex-missing-"); + + await assert.rejects( + resolveRuntime({ + platform: "linux", + env: { PATH: "" }, + home: root, + cwd: root, + configPath: path.join(root, "missing.json"), + }), + { name: "GetbijiExNotFound" }, + ); +}); + +test("source runtime reports a clear error when uv is unavailable", async () => { + const root = await temporaryDirectory("getbijiex-no-uv-"); + const source = path.join(root, "source"); + await createSource(source); + const descriptor = { + ...sourceDescriptor(source), + command: path.join(root, "missing-uv"), + }; + + assert.throws( + () => runRuntime(descriptor, ["--help"]), + { name: "GetbijiExDependencyMissing" }, + ); +}); + +test("source runtime turns a failed dependency preflight into a structured error", async () => { + const root = await temporaryDirectory("getbijiex-bad-python-"); + const source = path.join(root, "source"); + await createSource(source); + const descriptor = { + ...sourceDescriptor(source), + command: path.join(root, "missing-runtime"), + }; + + assert.throws( + () => runRuntime(descriptor, ["topics"]), + (error) => error.name === "GetbijiExDependencyMissing" + && error.message.includes("Python 3.11+"), + ); +}); + +test("launcher turns a source CLI import failure into structured JSON", async () => { + const root = await temporaryDirectory("getbijiex-broken-import-"); + const source = path.join(root, "source"); + const scripts = path.join(source, "scripts"); + await mkdir(scripts, { recursive: true }); + await writeFile( + path.join(source, "pyproject.toml"), + "[project]\n" + + "name = \"getbijiex-broken-import\"\n" + + "version = \"0.0.0\"\n" + + "requires-python = \">=3.11\"\n" + + "dependencies = []\n", + "utf8", + ); + await writeFile( + path.join(scripts, "biji_cli.py"), + "import definitely_extra_missing_dependency\n", + "utf8", + ); + const launcher = path.resolve("skills/getbijiex/scripts/getbijiex-cli.mjs"); + + const result = spawnSync(process.execPath, [launcher, "topics"], { + encoding: "utf8", + env: { + ...process.env, + GETBIJIEX_SOURCE: source, + GETBIJIEX_DATA_DIR: path.join(root, "data"), + }, + }); + + assert.equal(result.status, 1); + assert.match( + JSON.parse(result.stdout).error, + /^GetbijiExDependencyMissing:/, + ); + assert.equal(result.stderr, ""); +}); + +test("launcher forwards arguments, output, and exit status", async () => { + const root = await temporaryDirectory("getbijiex-forward-"); + const executable = process.execPath; + const fakeCli = path.join(root, "fake-getbijiex.mjs"); + await writeFile( + fakeCli, + "const args = process.argv.slice(2).join(' ');\n" + + "process.stdout.write(`stdout:${args}\\n`);\n" + + "process.stderr.write(`stderr:${args}\\n`);\n" + + "process.exitCode = 7;\n", + "utf8", + ); + const launcher = path.resolve("skills/getbijiex/scripts/getbijiex-cli.mjs"); + + const result = spawnSync( + process.execPath, + [launcher, fakeCli, "export", "--name", "博主 名称"], + { + encoding: "utf8", + env: { + ...process.env, + GETBIJIEX_EXECUTABLE: executable, + GETBIJIEX_DATA_DIR: path.join(root, "data"), + }, + }, + ); + + assert.equal(result.status, 7); + assert.match(result.stdout, /stdout:export --name 博主 名称/); + assert.match(result.stderr, /stderr:export --name 博主 名称/); +}); + +test("launcher runs when invoked from a copied installation path", async () => { + const root = await temporaryDirectory("getbijiex-copy-"); + const executable = process.execPath; + const fakeCli = path.join(root, "fake-getbijiex.mjs"); + const installedDirectory = path.join(root, "installed"); + await writeFile( + fakeCli, + "process.stdout.write(`copied:${process.argv.slice(2).join(' ')}\\n`);\n", + "utf8", + ); + await mkdir(installedDirectory, { recursive: true }); + const launcher = path.resolve("skills/getbijiex/scripts/getbijiex-cli.mjs"); + const installedLauncher = path.join(installedDirectory, "getbijiex-cli.mjs"); + await writeFile(installedLauncher, await readFile(launcher)); + + const result = spawnSync(process.execPath, [installedLauncher, fakeCli, "--help"], { + encoding: "utf8", + env: { + ...process.env, + GETBIJIEX_LAUNCHER_LIBRARY: "0", + GETBIJIEX_EXECUTABLE: executable, + GETBIJIEX_DATA_DIR: path.join(root, "data"), + }, + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /copied:--help/); +}); + +test("launcher prints JSON when no runtime can be found", async () => { + const root = await temporaryDirectory("getbijiex-json-error-"); + const launcher = path.resolve("skills/getbijiex/scripts/getbijiex-cli.mjs"); + const result = spawnSync(process.execPath, [launcher, "topics"], { + cwd: root, + encoding: "utf8", + env: { + GETBIJIEX_LAUNCHER_LIBRARY: "0", + GETBIJIEX_DATA_DIR: path.join(root, "data"), + HOME: root, + PATH: "", + }, + }); + + assert.equal(result.status, 1); + assert.match(JSON.parse(result.stdout).error, /^GetbijiExNotFound:/); + assert.equal(result.stderr, ""); +}); diff --git a/tests/test_skill_installer.py b/tests/test_skill_installer.py index 79bbef3..409b1c3 100644 --- a/tests/test_skill_installer.py +++ b/tests/test_skill_installer.py @@ -21,14 +21,25 @@ def test_skill_target_supports_codex(monkeypatch, tmp_path): assert target == tmp_path / ".codex" / "skills" / "getbijiex" / "SKILL.md" -def test_install_skill_supports_custom_directory(monkeypatch, tmp_path): +def make_skill_resources(tmp_path, template=None): resource_root = tmp_path / "resources" - template_dir = resource_root / "skill" - template_dir.mkdir(parents=True) - (template_dir / "SKILL.md").write_text( - "name: getbijiex\n{{CLI_COMMAND}}\n", - encoding="utf-8", - ) + template_dir = resource_root / "skills" / "getbijiex" + scripts_dir = template_dir / "scripts" + scripts_dir.mkdir(parents=True) + if template is None: + template = ( + "name: getbijiex\n" + f"{skill_installer.COMMAND_START}\n" + "node launcher.mjs\n" + f"{skill_installer.COMMAND_END}\n" + ) + (template_dir / "SKILL.md").write_text(template, encoding="utf-8") + (scripts_dir / "getbijiex-cli.mjs").write_text("// launcher\n", encoding="utf-8") + return resource_root + + +def test_install_skill_copies_resources_and_renders_command(monkeypatch, tmp_path): + resource_root = make_skill_resources(tmp_path) custom_root = tmp_path / "other-agent" / "skills" monkeypatch.setattr(skill_installer, "resources_root", lambda: resource_root) monkeypatch.setattr(skill_installer, "cli_command", lambda: "GetbijiEx topics") @@ -36,7 +47,118 @@ def test_install_skill_supports_custom_directory(monkeypatch, tmp_path): target = skill_installer.install_skill(target_dir=str(custom_root)) assert target == custom_root / "getbijiex" / "SKILL.md" - assert target.read_text(encoding="utf-8") == "name: getbijiex\nGetbijiEx topics\n" + text = target.read_text(encoding="utf-8") + assert "GetbijiEx topics" in text + assert "node launcher.mjs" not in text + assert "{{CLI_COMMAND}}" not in text + assert (target.parent / "scripts" / "getbijiex-cli.mjs").read_text( + encoding="utf-8" + ) == "// launcher\n" + + +def test_install_skill_is_idempotent(monkeypatch, tmp_path): + resource_root = make_skill_resources(tmp_path) + custom_root = tmp_path / "agent-skills" + monkeypatch.setattr(skill_installer, "resources_root", lambda: resource_root) + commands = iter(["first-command", "second-command"]) + monkeypatch.setattr(skill_installer, "cli_command", lambda: next(commands)) + + target = skill_installer.install_skill(target_dir=str(custom_root)) + (target.parent / "obsolete.txt").write_text("old", encoding="utf-8") + target = skill_installer.install_skill(target_dir=str(custom_root)) + + text = target.read_text(encoding="utf-8") + assert "second-command" in text + assert "first-command" not in text + assert not (target.parent / "obsolete.txt").exists() + + +@pytest.mark.parametrize( + "template", + [ + "name: getbijiex\n", + ( + f"{skill_installer.COMMAND_START}\n{skill_installer.COMMAND_END}\n" + f"{skill_installer.COMMAND_START}\n{skill_installer.COMMAND_END}\n" + ), + ], +) +def test_install_skill_rejects_invalid_command_markers(monkeypatch, tmp_path, template): + resource_root = make_skill_resources(tmp_path, template=template) + monkeypatch.setattr(skill_installer, "resources_root", lambda: resource_root) + + with pytest.raises(skill_installer.SkillTemplateError, match="命令标记"): + skill_installer.install_skill(target_dir=str(tmp_path / "skills")) + + +def test_cli_command_quotes_posix_source_path(monkeypatch, tmp_path): + root = tmp_path / "源码 path's" + monkeypatch.setattr(skill_installer, "is_frozen", lambda: False) + monkeypatch.setattr(skill_installer, "project_root", lambda: root) + + command = skill_installer.cli_command(platform="darwin") + + assert command.startswith("uv --directory ") + assert "scripts/biji_cli.py" in command + assert "'\"'\"'" in command + + +def test_cli_command_quotes_windows_source_path(monkeypatch, tmp_path): + root = tmp_path / "Source path's" + monkeypatch.setattr(skill_installer, "is_frozen", lambda: False) + monkeypatch.setattr(skill_installer, "project_root", lambda: root) + + command = skill_installer.cli_command(platform="win32") + + assert command == ( + f"uv --directory '{str(root.resolve()).replace(chr(39), chr(39) * 2)}' " + "run python scripts/biji_cli.py" + ) + + +def test_cli_command_uses_powershell_call_operator_for_frozen_windows(monkeypatch): + monkeypatch.setattr(skill_installer, "is_frozen", lambda: True) + monkeypatch.setattr(skill_installer.sys, "executable", "C:/Program Files/GetbijiEx/GetbijiEx.exe") + + command = skill_installer.cli_command(platform="win32") + + assert command.startswith("& '") + assert "GetbijiEx.exe" in command + + +def test_render_direct_command_marks_windows_as_powershell(): + template = ( + f"{skill_installer.COMMAND_START}\n" + "old command\n" + f"{skill_installer.COMMAND_END}\n" + ) + + rendered = skill_installer._render_direct_command( + template, + "& 'C:\\Program Files\\GetbijiEx\\GetbijiEx.exe'", + platform="win32", + ) + + assert "请在 PowerShell 中执行" in rendered + assert "```powershell" in rendered + assert "```bash" not in rendered + + +def test_render_direct_command_marks_posix_as_bash(): + template = ( + f"{skill_installer.COMMAND_START}\n" + "old command\n" + f"{skill_installer.COMMAND_END}\n" + ) + + rendered = skill_installer._render_direct_command( + template, + "'/Applications/GetbijiEx.app/Contents/MacOS/GetbijiEx'", + platform="darwin", + ) + + assert "请在 Bash 兼容终端中执行" in rendered + assert "```bash" in rendered def test_unknown_agent_requires_custom_directory():