From 64ede9f225664635f2e762356fd289a3a4af470f Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:17:00 +0800 Subject: [PATCH 1/5] Add skill-bridge plugin (antianqi/skill-bridge) v0.2.0 A stdio MCP server plugin that converts openclaw (or similar) skills into mavis/mcode-compatible Skills. The plugin is self-contained: no npm install, no node_modules, no native binaries, no symlinks, no hidden telemetry. It declares one stdio MCP server via mcp.json (node ./server.mjs) and exposes four tools: detect (source) -> encoding + mojibake status analyze (source) -> full frontmatter / paths / commands classify (source) -> pure | pure-wrapped-fix | wrapped-* | abandon convert (source, target_dir, force?, run_lint?) -> writes converted skill to target_dir What changed from v0.1 of this plugin (PR #3 on the old hetaoBackend/MiniMax-Code-Plugins repo, which was lost in the transfer to MiniMax-AI/MiniMax-Code-Plugins): - Drop package.json, package-lock.json, and the CLI entry point. The plugin no longer relies on npm install or a global bin. - Add mcp.json + server.mjs, a JSON-RPC-over-stdio MCP server declared as a portable Agent Plugin. - Drop the iconv-lite and js-yaml dependencies. The encoding detector uses Node 22+'s built-in TextDecoder('gb18030'), and the YAML frontmatter is parsed / serialized by a small hand-rolled subset parser in lib/analyze.js. - Rewrite skills/skill-bridge/SKILL.md to teach the agent to call the MCP tools instead of spawning a CLI. - Atomic-replace: lib/transform-skill.js uses a backup-and-rename dance so a pre-existing target_dir is preserved if the conversion fails (covered by tests/transform-atomic.test.mjs). - Lint failure: lib/lint.js returns ok=false, code!=0 on a failing lint. The MCP convert tool surfaces that to the caller. - Pruned demos: investor-brand-kit (end-user business data) and self-improving-agent (third-party copy without a declared license) are removed. The only demo shipped is task-tracker, the author's own content. Test count: 50 (was 33 in v0.1). All pass. The npm run check failures that remain in the repo (CRLF line endings in examples/hello-mcode/SKILL.md; Windows path.separator in hosted-plugins.test.mjs) are pre-existing and unrelated to this plugin. --- plugins/antianqi/skill-bridge/.gitignore | 7 + plugins/antianqi/skill-bridge/LICENSE | 192 +++++++++++ plugins/antianqi/skill-bridge/README.md | 137 ++++++++ .../examples/input/task-tracker/SKILL.md | 89 +++++ .../examples/output/task-tracker/SKILL.md | 108 ++++++ .../output/task-tracker/conversion-report.md | 21 ++ .../antianqi/skill-bridge/examples/regen.mjs | 40 +++ plugins/antianqi/skill-bridge/lib/analyze.js | 290 ++++++++++++++++ plugins/antianqi/skill-bridge/lib/classify.js | 94 +++++ plugins/antianqi/skill-bridge/lib/detect.js | 104 ++++++ plugins/antianqi/skill-bridge/lib/lint.js | 102 ++++++ plugins/antianqi/skill-bridge/lib/paths.js | 131 +++++++ .../skill-bridge/lib/transform-skill.js | 323 ++++++++++++++++++ plugins/antianqi/skill-bridge/mcp.json | 10 + plugins/antianqi/skill-bridge/plugin.json | 20 ++ .../references/compatibility-matrix.md | 56 +++ .../references/encoding-tables.md | 56 +++ .../skill-bridge/references/path-patterns.md | 60 ++++ plugins/antianqi/skill-bridge/server.mjs | 228 +++++++++++++ .../skill-bridge/skills/skill-bridge/SKILL.md | 107 ++++++ .../skill-bridge/tests/analyze.test.mjs | 111 ++++++ .../skill-bridge/tests/classify.test.mjs | 70 ++++ .../skill-bridge/tests/detect.test.mjs | 90 +++++ .../antianqi/skill-bridge/tests/lint.test.mjs | 102 ++++++ .../skill-bridge/tests/paths.test.mjs | 60 ++++ .../skill-bridge/tests/server.test.mjs | 191 +++++++++++ .../tests/transform-atomic.test.mjs | 120 +++++++ .../tests/transform-skill.test.mjs | 239 +++++++++++++ 28 files changed, 3158 insertions(+) create mode 100644 plugins/antianqi/skill-bridge/.gitignore create mode 100644 plugins/antianqi/skill-bridge/LICENSE create mode 100644 plugins/antianqi/skill-bridge/README.md create mode 100644 plugins/antianqi/skill-bridge/examples/input/task-tracker/SKILL.md create mode 100644 plugins/antianqi/skill-bridge/examples/output/task-tracker/SKILL.md create mode 100644 plugins/antianqi/skill-bridge/examples/output/task-tracker/conversion-report.md create mode 100644 plugins/antianqi/skill-bridge/examples/regen.mjs create mode 100644 plugins/antianqi/skill-bridge/lib/analyze.js create mode 100644 plugins/antianqi/skill-bridge/lib/classify.js create mode 100644 plugins/antianqi/skill-bridge/lib/detect.js create mode 100644 plugins/antianqi/skill-bridge/lib/lint.js create mode 100644 plugins/antianqi/skill-bridge/lib/paths.js create mode 100644 plugins/antianqi/skill-bridge/lib/transform-skill.js create mode 100644 plugins/antianqi/skill-bridge/mcp.json create mode 100644 plugins/antianqi/skill-bridge/plugin.json create mode 100644 plugins/antianqi/skill-bridge/references/compatibility-matrix.md create mode 100644 plugins/antianqi/skill-bridge/references/encoding-tables.md create mode 100644 plugins/antianqi/skill-bridge/references/path-patterns.md create mode 100644 plugins/antianqi/skill-bridge/server.mjs create mode 100644 plugins/antianqi/skill-bridge/skills/skill-bridge/SKILL.md create mode 100644 plugins/antianqi/skill-bridge/tests/analyze.test.mjs create mode 100644 plugins/antianqi/skill-bridge/tests/classify.test.mjs create mode 100644 plugins/antianqi/skill-bridge/tests/detect.test.mjs create mode 100644 plugins/antianqi/skill-bridge/tests/lint.test.mjs create mode 100644 plugins/antianqi/skill-bridge/tests/paths.test.mjs create mode 100644 plugins/antianqi/skill-bridge/tests/server.test.mjs create mode 100644 plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs create mode 100644 plugins/antianqi/skill-bridge/tests/transform-skill.test.mjs diff --git a/plugins/antianqi/skill-bridge/.gitignore b/plugins/antianqi/skill-bridge/.gitignore new file mode 100644 index 0000000..4e7abb9 --- /dev/null +++ b/plugins/antianqi/skill-bridge/.gitignore @@ -0,0 +1,7 @@ +# Local probe / debug files +probe-*.mjs +# Backup dirs that the transformer may leave if it crashes mid-swap. +# (The transformer cleans these up on its own, but a crash before the +# cleanup leaves the dir around and we do not want to commit it.) +*.bak-* +*.staging-* diff --git a/plugins/antianqi/skill-bridge/LICENSE b/plugins/antianqi/skill-bridge/LICENSE new file mode 100644 index 0000000..ec5fe20 --- /dev/null +++ b/plugins/antianqi/skill-bridge/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright 2026 MCode Plugins contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/antianqi/skill-bridge/README.md b/plugins/antianqi/skill-bridge/README.md new file mode 100644 index 0000000..c2124dc --- /dev/null +++ b/plugins/antianqi/skill-bridge/README.md @@ -0,0 +1,137 @@ +# skill-bridge + +> Convert openclaw (and similar) skills into mavis/mcode-compatible skills, exposed as a stdio MCP server inside a portable Agent Plugin. + +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![Node](https://img.shields.io/badge/node-%3E%3D22.19-brightgreen)](mcp.json) +[![Agent Plugins 1.0](https://img.shields.io/badge/Agent_Plugins-1.0-8b5cf6)](https://agent-plugins.org) + +## Why + +`openclaw` (and other agent frameworks) and `mavis` / `mcode` do not share a skill format. The hard parts are: + +1. **Schema gap** — openclaw skills are 2-field frontmatter; mavis needs `descriptions.zh-Hans`, `displayNames`, `metadata`, locale keys. +2. **Encoding gap** — openclaw wrote Chinese as GBK and filenames as mojibake. mavis requires UTF-8. +3. **Path gap** — openclaw skills hardcode `C:\Users\Administrator\.openclaw\workspace\...` and `/tmp/CLI-Anything/...`. mavis needs parameterized paths. +4. **Platform gap** — openclaw assumes `bash` / `pip install -e .` / `python3` in PATH. mavis (especially on Windows) needs PowerShell equivalents. +5. **Discovery gap** — openclaw's staging directory is not in mavis's skill scan path. Copying files there does nothing. + +**skill-bridge** turns "copy the folder and pray" into a deterministic pipeline: `detect` → `analyze` → `classify` → `transform` → `lint`, exposed as four MCP tools and driven by the matching Skill (`skills/skill-bridge/SKILL.md`). + +## How it ships + +This repository follows the [portable Agent Plugins 1.0 contract](https://github.com/hetaoBackend/MiniMax-Code-Plugins/blob/main/docs/plugin-compatibility.md): + +```text +plugins/antianqi/skill-bridge/ +├── plugin.json # the plugin manifest +├── mcp.json # the stdio MCP server +├── server.mjs # the MCP server itself +├── lib/ # pure ESM, zero npm deps +├── skills/skill-bridge/ # the LLM-facing Skill +├── references/ # human-facing docs +├── examples/ # input + output demo +└── tests/ # node --test +``` + +No `package.json`, no `node_modules`, no install step. The portable plugin is read by MiniMax Code exactly the way it is checked into `main`. + +## What the MCP server exposes + +The server speaks JSON-RPC over stdio. It declares four tools, named after the original v0.1 CLI subcommands: + +| Tool | Returns | +| --- | --- | +| `detect(source)` | `{ encoding, originalEncoding, replaced, confidence, reason, text }` | +| `analyze(source)` | `{ frontmatter, body, hardcodedPaths, externalCommands, warnings, … }` | +| `classify(source)` | `{ tier, subTier, reason, recommendations }` | +| `convert(source, target_dir, force?, run_lint?)` | `{ ok, tier, subTier, written, warnings, lint }` | + +`source` accepts an absolute path to a `SKILL.md` file or to a folder containing one. `target_dir` is the absolute path the converted skill should be written to. The transform step is **atomic** — re-running with the same `target_dir` is always safe. + +The server requires only the Node.js that already ships with the host. It does not run `npm install`, does not register a global bin, does not write to the user's home directory. + +## Try the demo + +The plugin ships a single conversion demo under `examples/output/task-tracker/`. It is the result of running: + +```text +convert( + source = "examples/input/task-tracker/SKILL.md", + target_dir = "examples/output/task-tracker" +) +``` + +Inspect the result: + +```text +examples/output/task-tracker/ +├── SKILL.md # mavis-schema-compliant frontmatter, parameterized paths +└── conversion-report.md # what the converter changed and why +``` + +The original input is the openclaw `task-tracker` skill; the output is the same content brought up to the mavis schema. Open both side by side to see what the converter does. + +## How it works + +```text +input SKILL.md (openclaw, possibly GBK, possibly with C:\Users paths) + │ + ▼ +[detect] TextDecoder('gb18030') — built into Node 22+, no npm dep + │ + ▼ +[analyze] hand-rolled YAML subset parser, path/command pattern scan + │ + ▼ +[classify] 4-question decision tree → pure / pure-wrapped-fix / wrapped / abandon + │ + ▼ +[transform] atomic backup-rename into ; references/ split if body > 500 lines + │ + ▼ +[lint] invokes the host-installed skill-creator lint in a tmpdir + │ + ▼ +output: mavis-compatible skill at +``` + +### The tiers (also see `references/compatibility-matrix.md`) + +| Tier | What it is | What v0.2 does | +|---|---|---| +| `pure-translate` | Pure instruction, ASCII-clean, no hardcoded paths | Frontmatter enrichment only | +| `pure-wrapped-fix` | Pure instruction with hardcoded paths or GBK | Paths parameterized + encoding fixed + Windows notes added | +| `wrapped-*` | Needs an external CLI/API (Python, ComfyUI, Douyin, …) | **Not supported in v0.2.** v0.3 will emit a plugin skeleton. | +| `abandon` | Openclaw-only assumptions can't be removed | Do not import | + +## Requirements + +- Node.js 22.19+ or 24+ (matches the mcode engine). No other runtime. + +## Data and network + +- No network access. +- No credentials required. +- Reads the source file the caller provides. +- Writes only to the caller-provided `target_dir` and to a unique `os.tmpdir()/sb-lint--/` directory that is removed after the lint step completes. + +## Validation + +```bash +# from the repository root +npm ci +npm run check +``` + +CI runs the same `npm run check` on `ubuntu-latest` against Node 22. The validator + `node --test` exercise this Plugin's lib, server, and conversion pipeline. + +## Security + +- No symlinks, native binaries, installers, or hidden telemetry. +- The transformer writes to a unique sibling `.staging-` directory first, then swaps it onto `target_dir` via `fs.rename`. If anything fails before the swap, `target_dir` keeps its previous content (or remains absent if it never existed). +- The lint step stages a temporary `.mjs` copy in `os.tmpdir()` and removes it in a `finally` block. v0.1 of this plugin accidentally wrote a staged file into the user's `~/.minimax/.builtin-skills/` directory; v0.2 fixes that regression and adds a regression test. + +## License + +Apache-2.0 — see [LICENSE](LICENSE). The transformer and parser are original work by [antianqi](https://github.com/antianqi); the `task-tracker` demo is the user's own content. diff --git a/plugins/antianqi/skill-bridge/examples/input/task-tracker/SKILL.md b/plugins/antianqi/skill-bridge/examples/input/task-tracker/SKILL.md new file mode 100644 index 0000000..5a23439 --- /dev/null +++ b/plugins/antianqi/skill-bridge/examples/input/task-tracker/SKILL.md @@ -0,0 +1,89 @@ +--- +name: task-tracker +description: 任务追踪与日报周报生成。用于记录老板工作进度、生成日报周报、持续追踪任务完成情况。 +--- + +# Task Tracker - 任务追踪与日报周报 + +## 核心文件 +- 任务总表:`C:\Users\Administrator\.openclaw/workspace/TASKS.md` + +## 任务格式规范 + +### 日报格式(必须遵守) +- 内容顺序:**①直播 ②短视频 ③外卖 ④其他** +- 不显示大分类标题,直接按顺序列序号 +- **不用任何符号**(✅❌🔄等都不用) +- 发到飞书,用文字不用语音 +- **输出时:完整输出 TASKS.md 里记录的详细内容和进度,不简化** + +### 明日计划原则 +- **持续跟进的项必须列入**(如:城乡路京东外卖持续跟进) +- **今日新提到的跟进项也列入**(如:美团收银报价跟进) +- 不在本周计划里但老板提到的新任务 → 追加进明日计划 + +### 重要区分 +- **日报只记老板的工作**(品牌运营 + 线上运营 + 外卖 + 品牌营销) +- **数据统计填表是狗蛋的工作,不记入日报** +- **系统升级、工具配置等狗蛋研发工作不记入日报** +- **狗蛋自己的研发/学习/技能提升工作不记入日报**,只记入 memory/daily/YYYY-MM-DD.md +- 老板告诉我进展 → 更新 TASKS.md(详细记录) +- 我自己的研发进展 → 更新 memory/daily/YYYY-MM-DD.md + +### 重要区分 +- **日报只记老板的工作**(品牌运营 + 线上运营 + 外卖 + 品牌营销) +- **数据统计填表是狗蛋的工作,不记入日报** +- **狗蛋自己的研发/学习/技能提升工作不记入日报**,只记入 memory/daily/YYYY-MM-DD.md +- 老板告诉我进展 → 更新 TASKS.md(详细记录) +- 我自己的研发进展 → 更新 memory/daily/YYYY-MM-DD.md + +``` +老板日报(YYYY-MM-DD) +今日工作: +1. ... +2. ... +明日计划: +1. ... +2. ... +``` + +### 周报格式 +同日报格式,周六汇总一周数据+工作内容 + +### 任务格式 +``` +### 今日进展(YYYY-MM-DD) +- 具体工作内容 + +### 明日计划 +- 延续任务(带进度说明) +- 新增任务 +``` + +### 任务状态规则 +- 今日未完成的 → 记录到明日计划 +- 本周未完成的 → 记录到下周计划 +- 狗蛋自己的研发/学习工作 → 不记录 + +## 使用场景 + +### 记录进展 +老板告诉你工作进展 → 更新 TASKS.md + +### 查询进度 +老板问"现在任务进度" → 读取 TASKS.md 输出当前任务清单 + +### 生成日报 +老板说"写日报" → 从 TASKS.md 当前日进展生成格式化日报,发到飞书 + +### 生成周报 +老板说"写周报" → 从 TASKS.md 本周任务+进展生成,发到飞书 + +### 任务完成 +老板说某任务完成了 → 更新 TASKS.md 中该任务状态为"已完成",标注日期 + +### 新增任务 +老板布置新任务 → 追加到 TASKS.md 当前周任务列表 + +## 追踪文件路径 +`C:\Users\Administrator\.openclaw/workspace/TASKS.md` diff --git a/plugins/antianqi/skill-bridge/examples/output/task-tracker/SKILL.md b/plugins/antianqi/skill-bridge/examples/output/task-tracker/SKILL.md new file mode 100644 index 0000000..8739af6 --- /dev/null +++ b/plugins/antianqi/skill-bridge/examples/output/task-tracker/SKILL.md @@ -0,0 +1,108 @@ +--- +name: task-tracker +description: "Use when: 任务追踪与日报周报生成。用于记录老板工作进度、生成日报周报、持续追踪任务完成情况。." +displayNames: + zh-Hans: Task Tracker - 任务追踪与日报周报 + +metadata: + openclaw_compat: true + skill-bridge: + classify_tier: pure + classify_subtier: pure-wrapped-fix + classify_reason: "1 hardcoded path group(s) found" + + +--- + +# Task Tracker - 任务追踪与日报周报 + +## 核心文件 +- 任务总表:`${OPENCLAW_WORKSPACE}/TASKS.md` + +## 任务格式规范 + +### 日报格式(必须遵守) +- 内容顺序:**①直播 ②短视频 ③外卖 ④其他** +- 不显示大分类标题,直接按顺序列序号 +- **不用任何符号**(✅❌🔄等都不用) +- 发到飞书,用文字不用语音 +- **输出时:完整输出 TASKS.md 里记录的详细内容和进度,不简化** + +### 明日计划原则 +- **持续跟进的项必须列入**(如:城乡路京东外卖持续跟进) +- **今日新提到的跟进项也列入**(如:美团收银报价跟进) +- 不在本周计划里但老板提到的新任务 → 追加进明日计划 + +### 重要区分 +- **日报只记老板的工作**(品牌运营 + 线上运营 + 外卖 + 品牌营销) +- **数据统计填表是狗蛋的工作,不记入日报** +- **系统升级、工具配置等狗蛋研发工作不记入日报** +- **狗蛋自己的研发/学习/技能提升工作不记入日报**,只记入 memory/daily/YYYY-MM-DD.md +- 老板告诉我进展 → 更新 TASKS.md(详细记录) +- 我自己的研发进展 → 更新 memory/daily/YYYY-MM-DD.md + +### 重要区分 +- **日报只记老板的工作**(品牌运营 + 线上运营 + 外卖 + 品牌营销) +- **数据统计填表是狗蛋的工作,不记入日报** +- **狗蛋自己的研发/学习/技能提升工作不记入日报**,只记入 memory/daily/YYYY-MM-DD.md +- 老板告诉我进展 → 更新 TASKS.md(详细记录) +- 我自己的研发进展 → 更新 memory/daily/YYYY-MM-DD.md + +``` +老板日报(YYYY-MM-DD) +今日工作: +1. ... +2. ... +明日计划: +1. ... +2. ... +``` + +### 周报格式 +同日报格式,周六汇总一周数据+工作内容 + +### 任务格式 +``` +### 今日进展(YYYY-MM-DD) +- 具体工作内容 + +### 明日计划 +- 延续任务(带进度说明) +- 新增任务 +``` + +### 任务状态规则 +- 今日未完成的 → 记录到明日计划 +- 本周未完成的 → 记录到下周计划 +- 狗蛋自己的研发/学习工作 → 不记录 + +## 使用场景 + +### 记录进展 +老板告诉你工作进展 → 更新 TASKS.md + +### 查询进度 +老板问"现在任务进度" → 读取 TASKS.md 输出当前任务清单 + +### 生成日报 +老板说"写日报" → 从 TASKS.md 当前日进展生成格式化日报,发到飞书 + +### 生成周报 +老板说"写周报" → 从 TASKS.md 本周任务+进展生成,发到飞书 + +### 任务完成 +老板说某任务完成了 → 更新 TASKS.md 中该任务状态为"已完成",标注日期 + +### 新增任务 +老板布置新任务 → 追加到 TASKS.md 当前周任务列表 + +## 追踪文件路径 +`${OPENCLAW_WORKSPACE}/TASKS.md` + +## Output contract + +This skill does not produce files by itself; the converted openclaw skill should declare its outputs in a new section here. (Filled in by the user after first run.) + +## Failure handling + +If a required external tool or path is missing, surface the exact missing identifier to the user instead of guessing. Do not auto-install system packages. (Add skill-specific failure modes here.) diff --git a/plugins/antianqi/skill-bridge/examples/output/task-tracker/conversion-report.md b/plugins/antianqi/skill-bridge/examples/output/task-tracker/conversion-report.md new file mode 100644 index 0000000..42d6e9c --- /dev/null +++ b/plugins/antianqi/skill-bridge/examples/output/task-tracker/conversion-report.md @@ -0,0 +1,21 @@ +# Conversion report + +- **input**: `C:\Users\Administrator\.minimax\scratch\skill-bridge-fork\plugins\antianqi\skill-bridge\examples\input\task-tracker\SKILL.md` +- **tier**: pure / pure-wrapped-fix +- **reason**: 1 hardcoded path group(s) found + +## Path changes +- `openclaw-workspace` → ${OPENCLAW_WORKSPACE} (2x) + +## Written files + + +## Recommendations +- parameterize paths via paths.js +- ensure UTF-8 output +- add Windows adaptation section if body uses shell commands + +## Warnings +- paths parameterized: openclaw-workspace + +_generated by skill-bridge v0.2.0 on 2026-08-17T06:03:53.024Z_ diff --git a/plugins/antianqi/skill-bridge/examples/regen.mjs b/plugins/antianqi/skill-bridge/examples/regen.mjs new file mode 100644 index 0000000..d763c87 --- /dev/null +++ b/plugins/antianqi/skill-bridge/examples/regen.mjs @@ -0,0 +1,40 @@ +// examples/regen.mjs +// +// Regenerate examples/output/task-tracker/ by running the v0.2 +// converter pipeline against examples/input/task-tracker/. +// +// This is the same code path the MCP `convert` tool uses — it just +// inlines the import-and-call instead of going through JSON-RPC. +// +// Usage from the plugin root: +// node examples/regen.mjs + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { analyzeSkillFile } from '../lib/analyze.js'; +import { classify } from '../lib/classify.js'; +import { transformSkill } from '../lib/transform-skill.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = path.resolve(__dirname, '..'); + +const source = path.join(PLUGIN_ROOT, 'examples', 'input', 'task-tracker', 'SKILL.md'); +const outDir = path.join(PLUGIN_ROOT, 'examples', 'output', 'task-tracker'); + +const report = await analyzeSkillFile(source); +const result = classify(report); +const r = await transformSkill({ + inputPath: source, + report, + classify: result, + outDir, +}); + +console.log('Wrote:'); +for (const f of r.written) { + console.log(` ${path.relative(PLUGIN_ROOT, f)}`); +} +if (r.warnings.length) { + console.log('Warnings:'); + for (const w of r.warnings) console.log(` ${w}`); +} diff --git a/plugins/antianqi/skill-bridge/lib/analyze.js b/plugins/antianqi/skill-bridge/lib/analyze.js new file mode 100644 index 0000000..58206e0 --- /dev/null +++ b/plugins/antianqi/skill-bridge/lib/analyze.js @@ -0,0 +1,290 @@ +// lib/analyze.js — Frontmatter parsing and hardcoded-paths/commands scan. +// +// We avoid `js-yaml` to keep the dependency surface small. The +// frontmatter we need to parse is a constrained YAML subset: +// +// - top-level `key: value` lines +// - top-level `key: |` (or `key: >`) followed by an indented block +// - top-level `key:` with one level of nested keys (used by +// `descriptions.zh-Hans`, `metadata.x`, etc.) +// +// Everything else (anchors, tags, multi-doc, flow style) is unsupported +// by design; skill authors should keep frontmatter simple. + +import fs from 'node:fs/promises'; +import { readFileSafe } from './detect.js'; + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; + +const EXTERNAL_COMMAND_PATTERNS = [ + { re: /\bpip\s+install\b/g, label: 'pip install' }, + { re: /\bcli-anything-[a-z0-9-]+/g, label: 'cli-anything CLI' }, + { re: /\bpython3?\s+/g, label: 'python invocation' }, + { re: /\bcurl\s+/g, label: 'curl' }, + { re: /\bwget\s+/g, label: 'wget' }, + { re: /\bComfyUI\b/g, label: 'ComfyUI reference' }, + { re: /\bESP32\b/g, label: 'ESP32 reference' }, + { re: /\bDouyin|抖音\b/g, label: 'Douyin reference' }, + { re: /\bTTS\b/g, label: 'TTS reference' }, + { re: /\bfeishu|飞书\b/g, label: 'Feishu reference' }, + { re: /\b\${\w+}\b/g, label: 'unresolved template var' }, +]; + +const PATH_PATTERNS = [ + { re: /C:\\Users\\[^"\s`']+/g, label: 'absolute Windows user path' }, + { re: /~\/\.[a-zA-Z0-9_.-]+/g, label: 'tilde home path' }, + { re: /(?} hardcodedPaths + * @property {Array<{label:string, samples:string[]}>} externalCommands + * @property {string[]} warnings + */ + +// ---------- Constrained YAML parser ---------- + +const KEY_LINE_RE = /^(\s*)([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/; + +/** + * Parse a constrained YAML block. Supports: + * - `key: value` (string / number / boolean / null) + * - `key: "..."` / `key: '...'` (quoted string) + * - `key: |` / `key: >` (block scalar, indented body) + * - `key:` (followed by indented sub-keys) -> nested object + * + * Throws on unsupported constructs. + * + * @param {string} text + * @returns {object} + */ +export function parseYamlBlock(text) { + const lines = text.split(/\r?\n/); + const root = {}; + // Stack of frames: each holds the current container and its indent + // level. We start at indent -2 so that the first top-level key (indent 0) + // satisfies `indent === top.indent + 2` without special-casing. + const stack = [{ indent: -2, container: root }]; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === '') { i++; continue; } + const m = line.match(KEY_LINE_RE); + if (!m) { + throw new Error(`cannot parse line: ${JSON.stringify(line)}`); + } + const [, ws, key, rawValue] = m; + const indent = ws.length; + // Pop frames until we are at the right parent. + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + const top = stack[stack.length - 1]; + // The current line's indent must be exactly top.indent + 2. + if (indent !== top.indent + 2) { + throw new Error(`bad indent at line: ${JSON.stringify(line)}`); + } + if (rawValue === '' || rawValue === '|' || rawValue === '>') { + if (rawValue === '|' || rawValue === '>') { + const blockIndent = indent + 2; + const blockLines = []; + i++; + while (i < lines.length) { + const bl = lines[i]; + if (bl.trim() === '') { blockLines.push(''); i++; continue; } + const bi = bl.match(/^(\s*)/)[1].length; + if (bi < blockIndent) break; + blockLines.push(bl.slice(blockIndent)); + i++; + } + top.container[key] = blockLines.join('\n').replace(/\n+$/, ''); + } else { + // nested object + const obj = {}; + top.container[key] = obj; + stack.push({ indent, container: obj }); + } + } else { + top.container[key] = coerceScalar(rawValue); + } + i++; + } + return root; +} + +function coerceScalar(v) { + // Quoted scalars are always returned as strings, even if the content + // would otherwise look like a number / boolean / null. This matches + // YAML's "explicit string" rule and matches what dumpYamlBlock emits + // for reserved words and string-looking numbers. + if (v.length >= 2) { + const first = v[0]; + const last = v[v.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return v.slice(1, -1); + } + } + if (v === 'true') return true; + if (v === 'false') return false; + if (v === 'null' || v === '~') return null; + if (/^-?\d+$/.test(v)) return Number(v); + if (/^-?\d+\.\d+$/.test(v)) return Number(v); + return v; +} + +/** + * Parse a SKILL.md into frontmatter (object) + body (string). + * @param {string} text + * @returns {{ frontmatter: object, body: string, ok: boolean, err?: string }} + */ +export function parseFrontmatter(text) { + const m = FRONTMATTER_RE.exec(text); + if (!m) return { frontmatter: {}, body: text, ok: false, err: 'no frontmatter' }; + try { + const fm = parseYamlBlock(m[1]); + return { frontmatter: fm, body: m[2], ok: true }; + } catch (e) { + return { frontmatter: {}, body: text, ok: false, err: 'yaml parse: ' + e.message }; + } +} + +// ---------- Pattern scanning ---------- + +function scanPatterns(text, patterns) { + const out = []; + for (const { re, label } of patterns) { + re.lastIndex = 0; + const samples = new Set(); + let m; + while ((m = re.exec(text)) !== null) { + samples.add(m[0]); + if (samples.size >= 5) break; + } + if (samples.size > 0) out.push({ label, samples: [...samples] }); + } + return out; +} + +/** + * Reconstruct the full file text from frontmatter + body so that the + * pattern scans see the same content the human reader would. + * + * @param {object} frontmatter + * @param {string} body + * @returns {string} + */ +export function reconstructText(frontmatter, body) { + return `---\n${dumpYamlBlock(frontmatter)}---\n${body}`; +} + +// ---------- Full file analyze ---------- + +/** + * @param {string} filePath + * @returns {Promise} + */ +export async function analyzeSkillFile(filePath) { + const det = await readFileSafe(filePath); + const text = det.text; + const { frontmatter, body, ok, err } = parseFrontmatter(text); + + const fullText = ok ? reconstructText(frontmatter, body) : text; + + const warnings = []; + if (!ok) warnings.push(`frontmatter: ${err}`); + if (det.encoding === 'unknown') warnings.push('encoding: could not determine (left as lossy utf-8)'); + if (det.encoding === 'gbk' && det.replaced) warnings.push('encoding: converted from GBK to UTF-8'); + + return { + inputPath: filePath, + encoding: det.encoding, + convertedFromGbk: det.replaced, + frontmatter, + body, + fullText, + hardcodedPaths: scanPatterns(fullText, PATH_PATTERNS), + externalCommands: scanPatterns(fullText, EXTERNAL_COMMAND_PATTERNS), + warnings, + }; +} + +// ---------- YAML dump (used internally and by transform-skill.js) ---------- + +const NEEDS_QUOTING = /[:#&*!|>'"%@`{}[\],\n]/; +const RESERVED_WORDS = new Set(['true', 'false', 'null', '~', 'yes', 'no', 'on', 'off']); +const STARTS_WITH_NUMBER = /^-?\d/; + +/** + * Serialize a JS object as a constrained YAML block. Matches the + * subset our parseYamlBlock understands. + * + * @param {object} obj + * @param {number} [indent=0] + * @returns {string} + */ +export function dumpYamlBlock(obj, indent = 0) { + const pad = ' '.repeat(indent); + const lines = []; + for (const [k, v] of Object.entries(obj)) { + if (v === undefined) continue; + if (v === null) { + lines.push(`${pad}${k}: null`); + continue; + } + if (Array.isArray(v)) { + if (v.length === 0) { + lines.push(`${pad}${k}: []`); + continue; + } + lines.push(`${pad}${k}:`); + for (const item of v) { + if (item === null) { + lines.push(`${pad} - null`); + } else if (typeof item === 'object' && !Array.isArray(item)) { + const childPad = `${pad} `; + const dumped = dumpYamlBlock(item, indent + 1); + // Indent the first line with the dash, subsequent lines stay aligned. + const [first, ...rest] = dumped.split('\n'); + lines.push(`${childPad}- ${first.trimStart()}`); + for (const r of rest) lines.push(r); + } else { + lines.push(`${pad} - ${scalarToYaml(item)}`); + } + } + continue; + } + if (typeof v === 'object') { + if (Object.keys(v).length === 0) { + lines.push(`${pad}${k}: {}`); + continue; + } + lines.push(`${pad}${k}:`); + lines.push(dumpYamlBlock(v, indent + 1)); + continue; + } + if (typeof v === 'string' && v.includes('\n')) { + lines.push(`${pad}${k}: |`); + for (const line of v.split('\n')) lines.push(`${pad} ${line}`); + continue; + } + lines.push(`${pad}${k}: ${scalarToYaml(v)}`); + } + return lines.join('\n') + (lines.length ? '\n' : ''); +} + +function scalarToYaml(v) { + if (typeof v === 'boolean' || typeof v === 'number') return String(v); + if (typeof v !== 'string') return JSON.stringify(v); + if (v === '') return '""'; + if (RESERVED_WORDS.has(v)) return JSON.stringify(v); + if (STARTS_WITH_NUMBER.test(v)) return JSON.stringify(v); + if (NEEDS_QUOTING.test(v) || /^\s|\s$/.test(v)) return JSON.stringify(v); + return v; +} diff --git a/plugins/antianqi/skill-bridge/lib/classify.js b/plugins/antianqi/skill-bridge/lib/classify.js new file mode 100644 index 0000000..c2459d4 --- /dev/null +++ b/plugins/antianqi/skill-bridge/lib/classify.js @@ -0,0 +1,94 @@ +// lib/classify.js — Three-tier decision tree. +// +// pure-translate : pure instruction, ascii-clean, no hardcoded paths +// -> only add frontmatter fields, no body rewrite +// pure-wrapped-fix: pure instruction but with hardcoded paths or GBK +// -> rewrite paths + ensure UTF-8 +// wrapped-* : needs an external CLI/API; cannot be a pure skill +// abandon : unfixable openclaw-only assumptions +// +// The decision tree in §2.2 of the plan, encoded as a flat function. + +/** + * @typedef {Object} ClassifyResult + * @property {'pure'|'wrapped'|'abandon'} tier + * @property {string} subTier e.g. 'pure-translate', 'wrapped-python' + * @property {string} reason + * @property {string[]} recommendations + */ + +/** + * @param {import('./analyze.js').AnalyzedSkill} report + * @returns {ClassifyResult} + */ +export function classify(report) { + const { hardcodedPaths, externalCommands, encoding, convertedFromGbk } = report; + + const hasExternalTool = externalCommands.length > 0; + const hasHardcodedPaths = hardcodedPaths.length > 0; + const isAsciiClean = encoding === 'utf-8' && !convertedFromGbk; + + // Q1: external tool dependence + if (hasExternalTool) { + // Q3: which kind? + const labels = externalCommands.map(c => c.label); + let sub = 'wrapped-unknown'; + if (labels.includes('pip install') || labels.includes('python invocation')) { + sub = 'wrapped-python'; + } else if (labels.includes('cli-anything CLI')) { + sub = 'wrapped-cli-anything'; + } else if (labels.includes('curl')) { + sub = 'wrapped-http'; + } else if (labels.some(l => /ComfyUI|ESP32|Douyin|TTS|Feishu/.test(l))) { + sub = 'wrapped-service'; + } else if (labels.includes('unresolved template var')) { + // template vars alone don't count as a real external dep + // fall through to Q2 + } else { + sub = 'wrapped-binary'; + } + // Only return wrapped-* if we actually decided it's wrapped + if (sub !== 'wrapped-unknown') { + return { + tier: 'wrapped', + subTier: sub, + reason: `depends on external: ${labels.join(', ')}`, + recommendations: [ + 'emit as a mavis plugin (plugin.json + index.js)', + 'document dependency installation in README', + 'do not promise behavior parity in v0.1', + ], + }; + } + } + + // Q2: hardcoded paths or encoding issues + if (hasHardcodedPaths || convertedFromGbk) { + return { + tier: 'pure', + subTier: 'pure-wrapped-fix', + reason: convertedFromGbk + ? `gbk source, ${hardcodedPaths.length} hardcoded path group(s)` + : `${hardcodedPaths.length} hardcoded path group(s) found`, + recommendations: [ + 'parameterize paths via paths.js', + 'ensure UTF-8 output', + 'add Windows adaptation section if body uses shell commands', + ], + }; + } + + // Q4: clean pure + return { + tier: 'pure', + subTier: isAsciiClean ? 'pure-translate' : 'pure-wrapped-fix', + reason: isAsciiClean + ? 'pure instruction, ascii-clean, no hardcoded paths' + : 'pure instruction but needs encoding touch-up', + recommendations: [ + 'enrich frontmatter (descriptions.zh-Hans, displayNames.zh-Hans, metadata)', + 'move trigger conditions from body to description', + 'verify body is under 500 lines; split into references/ if not', + ], + }; +} diff --git a/plugins/antianqi/skill-bridge/lib/detect.js b/plugins/antianqi/skill-bridge/lib/detect.js new file mode 100644 index 0000000..6e28556 --- /dev/null +++ b/plugins/antianqi/skill-bridge/lib/detect.js @@ -0,0 +1,104 @@ +// lib/detect.js — Encoding detection (GBK vs UTF-8) and mojibake recovery. +// +// Strategy: +// 1. Try strict UTF-8 decode; if it succeeds, the file is UTF-8. +// 2. Try strict GB18030 decode (Node 22+ ships this in `TextDecoder`); +// if it produces CJK printable text, the source was GBK and we have +// the restored UTF-8. +// 3. Otherwise: declare unknown, do not modify. +// +// We deliberately avoid chardet-style heuristics because guessing wrong +// silently corrupts skill text. +// +// GB18030 is a strict superset of GBK and GB2312, so a "gbk" byte stream +// round-trips through `TextDecoder('gb18030')` losslessly in practice. + +import fs from 'node:fs/promises'; + +const REPLACEMENT = '\uFFFD'; +const PRINTABLE_CJK = /[\u3400-\u9FFF]/; +const NON_ASCII_PRINTABLE = /[^\x00-\x7F]/; + +/** + * @typedef {Object} DetectResult + * @property {'utf-8'|'gbk'|'unknown'} encoding + * @property {string} text + * @property {string} originalEncoding + * @property {boolean} replaced + * @property {number} confidence 0..1 + * @property {string} reason + */ + +/** + * Detect the encoding of a Buffer and return UTF-8 text. + * @param {Buffer} buf + * @returns {DetectResult} + */ +export function detectEncoding(buf) { + // 1. Strict UTF-8 + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(buf); + const hasNonAscii = NON_ASCII_PRINTABLE.test(text); + return { + encoding: 'utf-8', + text, + originalEncoding: 'utf-8', + replaced: false, + confidence: hasNonAscii ? 0.95 : 0.8, + reason: 'utf-8 decode clean', + }; + } catch { + /* fall through to GBK */ + } + + // 2. GBK / GB18030 (built-in TextDecoder since Node 18) + try { + const text = new TextDecoder('gb18030', { fatal: true }).decode(buf); + if (!text.includes(REPLACEMENT) && PRINTABLE_CJK.test(text)) { + return { + encoding: 'gbk', + text, + originalEncoding: 'gbk', + replaced: true, + confidence: 0.9, + reason: 'gb18030 decode clean and contains CJK', + }; + } + } catch { + /* not valid gb18030 either */ + } + + // 3. Last resort: lossy UTF-8, marked unknown so caller can warn. + const text = new TextDecoder('utf-8').decode(buf); + return { + encoding: 'unknown', + text, + originalEncoding: 'unknown', + replaced: false, + confidence: 0.1, + reason: 'could not determine; left as lossy utf-8', + }; +} + +/** + * Read a file from disk and return its detected encoding + UTF-8 text. + * @param {string} filePath + * @returns {Promise} + */ +export async function readFileSafe(filePath) { + const buf = await fs.readFile(filePath); + return detectEncoding(buf); +} + +/** + * Heuristic: does the given UTF-8 text LOOK like GBK mojibake that was + * already partially normalized? Useful when the file on disk is a mess + * of replacement characters and there is no clean byte stream to + * recover from. + * + * @param {string} text + * @returns {boolean} + */ +export function isLikelyGbkMojibake(text) { + return /\uFFFD{2,}/.test(text) || /\?{3,}/.test(text); +} diff --git a/plugins/antianqi/skill-bridge/lib/lint.js b/plugins/antianqi/skill-bridge/lib/lint.js new file mode 100644 index 0000000..196e257 --- /dev/null +++ b/plugins/antianqi/skill-bridge/lib/lint.js @@ -0,0 +1,102 @@ +// lib/lint.js — Wrap the mavis skill-creator lint script. +// +// The official `lint-skill.js` ships as ES module source but is named +// with a `.js` extension and is not under a package.json with +// `"type": "module"`. Spawning `node` on it directly fails with a +// confusing SyntaxError. We avoid the problem in one of two ways: +// +// - Fast path: dynamic import the script in-process. Works for CJS +// modules (we read `mod.lint` and `mod.default.lint`) and for any +// script that already exposes a `lint(skillPath)` function. +// - Subprocess path: copy the source to a unique temp `.mjs` and run +// it with `node`. The temp dir is created in `os.tmpdir()` and is +// always removed, even on early return. +// +// CRITICAL: the temp dir MUST live under `os.tmpdir()`, NEVER under +// `~/.minimax/.builtin-skills/` or any user-install path. v0.1 was +// racy here; v0.2 forces a unique `sb-lint--` directory. +// +// The return shape `{ ok, code, stdout, stderr }` is the failure +// contract. The caller (the MCP server, the CLI, or a test) decides +// what to do with `ok === false`. LintSkill itself does not exit the +// process. + +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import crypto from 'node:crypto'; + +async function stageMjsInTmp(lintScript) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `sb-lint-${process.pid}-`)); + const mjs = path.join(dir, `${crypto.randomBytes(4).toString('hex')}.mjs`); + const src = await fs.readFile(lintScript, 'utf-8'); + await fs.writeFile(mjs, src, 'utf-8'); + return { dir, mjs }; +} + +/** + * @param {string} skillPath + * @param {object} [opts] + * @param {string} [opts.lintScript] + * @returns {Promise<{ ok: boolean, code: number, stdout: string, stderr: string }>} + */ +export async function lintSkill(skillPath, opts = {}) { + const lintScript = opts.lintScript + || path.join(os.homedir(), '.minimax', '.builtin-skills', 'skill-creator', 'scripts', 'lint-skill.js'); + + // Fast path: dynamic import in-process. No files written. + // Handle ESM (`export function lint`) and CJS interop + // (`module.exports.lint` appears at `mod.default.lint`). + try { + const mod = await import(pathToFileURL(lintScript).href); + const fn = typeof mod.lint === 'function' + ? mod.lint + : (mod.default && typeof mod.default.lint === 'function' ? mod.default.lint : null); + if (fn) { + const result = await fn(skillPath); + return { + ok: result.ok === true, + code: typeof result.code === 'number' ? result.code : (result.ok ? 0 : 1), + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; + } + } catch { + // Fall through to subprocess path + } + + // Subprocess path: stage as .mjs in a unique temp dir, then run. + // The temp dir is always removed, regardless of how the subprocess exits. + const { dir, mjs } = await stageMjsInTmp(lintScript); + try { + return await new Promise((resolve) => { + const child = spawn(process.execPath, [mjs, skillPath], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const settle = (payload) => { + if (settled) return; + settled = true; + try { child.stdout?.destroy(); } catch {} + try { child.stderr?.destroy(); } catch {} + resolve(payload); + }; + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('close', (code) => { + settle({ ok: code === 0, code, stdout, stderr }); + }); + child.on('error', (err) => { + settle({ ok: false, code: -1, stdout, stderr: stderr + '\nspawn error: ' + err.message }); + }); + }); + } finally { + // Always clean up the staged dir. + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/plugins/antianqi/skill-bridge/lib/paths.js b/plugins/antianqi/skill-bridge/lib/paths.js new file mode 100644 index 0000000..d427f53 --- /dev/null +++ b/plugins/antianqi/skill-bridge/lib/paths.js @@ -0,0 +1,131 @@ +// lib/paths.js — Path parameterization and filename fix. +// +// We can't statically know where an openclaw skill's "workspace" lives +// on a new machine. So we replace every hard-coded openclaw/TMP path +// with a parameterized template, and emit a metadata.openclaw_paths +// block that downstream code (or the user) can fill in. + +/** + * Each rule has: + * - id: short stable id + * - match: regex (with /g flag) + * - replace: replacement string (supports ${VAR} placeholders) + * - placeholder: which env var this maps to + * - notes: human-readable + */ +export const PATH_RULES = [ + { + id: 'openclaw-workspace', + // match either backslash or forward slash separator + match: /C:\\Users\\Administrator\\\.openclaw[\\/]workspace[\\/]?/g, + replace: '${OPENCLAW_WORKSPACE}/', + placeholder: 'OPENCLAW_WORKSPACE', + notes: 'openclaw workspace dir', + }, + { + id: 'openclaw-home', + match: /C:\\Users\\Administrator\\\.openclaw[\\/]?/g, + replace: '${OPENCLAW_HOME}/', + placeholder: 'OPENCLAW_HOME', + notes: 'Path under user home .openclaw/', + }, + { + id: 'openclaw-uniq-tilde', + match: /~\/\.openclaw\//g, + replace: '${OPENCLAW_HOME}/', + placeholder: 'OPENCLAW_HOME', + notes: 'tilde form of openclaw home (POSIX-style)', + }, + { + id: 'tmp-cli-anything', + match: /\/tmp\/CLI-Anything\//g, + replace: '${SCRATCH}/cli-anything/', + placeholder: 'SCRATCH', + notes: 'tmp path used by CLI-Anything harness', + }, + { + id: 'tmp-generic', + match: /(?/ +// SKILL.md # mavis schema, with enriched frontmatter +// conversion-report.md # what we changed and why +// references/.md # (optional) split from body if too long +// +// Atomicity: +// Writes happen in a sibling staging directory first +// (`.staging-`), then we use a backup-rename dance to +// move it onto outDir atomically. At every observable point in time, +// outDir either points at the OLD content or the NEW content — never +// empty, never half-written. This makes `--force` safe and prevents +// the "old references/ leak into new output" bug that bit v0.1. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { parameterizePaths, suggestFilename } from './paths.js'; +import { parseFrontmatter, dumpYamlBlock } from './analyze.js'; + +const MAX_BODY_LINES = 500; + +function kebab(name) { + return String(name) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) || 'unnamed-skill'; +} + +const TRIGGER_RE = /(".*?")|(\bwhen\b)|(\btrigger\b)|(\buse this\b)|(\bload this\b)|(\buse when\b)/i; +const TRIGGER_PHRASES = [ + 'Use when the user asks to', + 'Use when: ', + 'Use this skill when', +]; + +function extractChineseSummary(body) { + const blocks = body.split(/\r?\n\r?\n/); + for (const p of blocks) { + const t = p.trim(); + if (!t) continue; + if (/^#+\s/.test(t)) continue; + if (/^```/.test(t)) continue; + if (/^[-*+]\s/.test(t)) continue; + if (!/[\u3400-\u9FFF]/.test(t)) continue; + return t.replace(/\s+/g, ' ').slice(0, 200); + } + return null; +} + +function extractDisplayNameZh(frontmatter, body) { + if (frontmatter.name && /[\u3400-\u9FFF]/.test(frontmatter.name)) { + return String(frontmatter.name).trim(); + } + const h1 = body.match(/^#\s+(.+)$/m); + if (h1) return h1[1].trim().slice(0, 32); + return null; +} + +function enrichFrontmatter(original, body, classifyResult, targetName) { + const fm = { ...original }; + const name = targetName || kebab(fm.name || 'unnamed-skill'); + fm.name = name; + + let desc = typeof fm.description === 'string' ? fm.description : (fm.description || ''); + desc = desc.replace(/\s+/g, ' ').trim(); + if (!desc) { + const para = body.split(/\r?\n\r?\n/)[0] || ''; + desc = para.replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200); + } + if (!TRIGGER_RE.test(desc)) { + desc = `${TRIGGER_PHRASES[1]}${desc}`; + } + if (!desc.endsWith('.')) desc += '.'; + fm.description = desc; + + const zhSummary = extractChineseSummary(body); + const displayZh = extractDisplayNameZh(original, body); + if (zhSummary) { + fm.descriptions = fm.descriptions || {}; + fm.descriptions['zh-Hans'] = zhSummary; + } + if (displayZh) { + fm.displayNames = fm.displayNames || {}; + fm.displayNames['zh-Hans'] = displayZh; + } + + fm.metadata = fm.metadata || {}; + fm.metadata['openclaw_compat'] = true; + fm.metadata['skill-bridge'] = { + classify_tier: classifyResult.tier, + classify_subtier: classifyResult.subTier, + classify_reason: classifyResult.reason, + }; + + return fm; +} + +function addOutputContractSection(body) { + if (/^##\s+Output contract/m.test(body)) return body; + return body.trimEnd() + '\n\n## Output contract\n\nThis skill does not produce files by itself; the converted openclaw skill should declare its outputs in a new section here. (Filled in by the user after first run.)\n'; +} + +function addFailureHandlingSection(body) { + if (/^##\s+Failure handling/m.test(body)) return body; + return body.trimEnd() + '\n\n## Failure handling\n\nIf a required external tool or path is missing, surface the exact missing identifier to the user instead of guessing. Do not auto-install system packages. (Add skill-specific failure modes here.)\n'; +} + +function addWindowsNotesSection(body, hasShell) { + if (!hasShell) return body; + if (/^##\s+Windows \(win32\) platform notes/m.test(body)) return body; + return body.trimEnd() + '\n\n## Windows (win32) platform notes\n\nThe original openclaw skill assumed macOS/Linux shell. The PowerShell equivalents for any `bash`/`pip`/`python3` calls should be documented here. (Generated by skill-bridge; user to verify.)\n'; +} + +function addReferencesIndex(body, references) { + if (!references || references.length === 0) return body; + if (/^##\s+References\b/m.test(body)) return body; + const items = references + .map((r) => `- [\`${r.file}\`](references/${r.file})`) + .join('\n'); + return ( + body.trimEnd() + + '\n\n## References\n\nDetailed content moved out of this SKILL.md for size. Read these when the main flow above references them:\n\n' + + items + + '\n' + ); +} + +function maybeSplitReferences(name, body) { + const lines = body.split(/\r?\n/); + if (lines.length <= MAX_BODY_LINES) return { body, references: [] }; + + const sections = []; + let intro = []; + let current = null; + for (const line of lines) { + if (/^##\s+/.test(line)) { + if (current) sections.push(current); + else if (intro.length) sections.push({ heading: '__intro__', lines: intro }); + current = { heading: line, lines: [line] }; + } else if (current) { + current.lines.push(line); + } else { + intro.push(line); + } + } + if (current) sections.push(current); + else if (intro.length) sections.push({ heading: '__intro__', lines: intro }); + + if (sections.length < 3) return { body, references: [] }; + + const keep = sections.slice(0, 2).map(s => s.lines.join('\n')).join('\n\n'); + const moved = sections.slice(2); + const references = moved.map(s => { + const slug = s.heading + .replace(/^##\s+/, '') + .replace(/[^\w\u3400-\u9FFF-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() + .slice(0, 64) || 'section'; + return { + file: `${slug}.md`, + content: s.lines.join('\n'), + }; + }); + return { body: keep.trimEnd() + '\n', references }; +} + +/** + * Atomic directory replace using a backup-and-rename dance. + * + * At any observable point in time, outDir is either the OLD content or + * the NEW content. There is no window where outDir is missing or + * half-written. The staging directory is always cleaned up. + * + * @param {string} staging The directory holding the new content. + * @param {string} outDir The destination to replace. + */ +async function atomicReplace(staging, outDir) { + const backup = `${outDir}.bak-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; + let backupCreated = false; + try { + const exists = await fs.stat(outDir).catch(() => null); + if (exists) { + // Move the existing outDir out of the way. fs.rename is atomic on + // the same volume and never returns a partially-moved directory. + await fs.rename(outDir, backup); + backupCreated = true; + } + // Move staging into place. + await fs.rename(staging, outDir); + // OutDir is now the new content. Drop the backup. + if (backupCreated) { + await fs.rm(backup, { recursive: true, force: true }); + backupCreated = false; + } + } catch (err) { + // Recovery: if we created a backup but the final rename failed, + // restore the backup so the caller still sees the old outDir. + if (backupCreated) { + const backupExists = await fs.stat(backup).catch(() => null); + if (backupExists) { + await fs.rename(backup, outDir).catch(() => {}); + } + } + throw err; + } finally { + if (backupCreated) { + await fs.rm(backup, { recursive: true, force: true }).catch(() => {}); + } + // Staging should already be gone (renamed onto outDir). If it + // somehow remains, clean it up. + await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * @param {object} args + * @param {string} args.inputPath + * @param {import('./analyze.js').AnalyzedSkill} args.report + * @param {import('./classify.js').ClassifyResult} args.classify + * @param {string} args.outDir + * @returns {Promise<{ written: string[], warnings: string[] }>} + */ +export async function transformSkill({ inputPath, report, classify, outDir }) { + const warnings = []; + const written = []; + + // 1. Parameterize paths in body + const { text: bodyAfterPaths, changes: pathChanges } = parameterizePaths(report.body); + if (pathChanges.length > 0) { + warnings.push(`paths parameterized: ${pathChanges.map(c => c.id).join(', ')}`); + } + + // 2. Detect shell-style commands to decide if Windows notes are needed + const hasShell = /\b(pip|python3?|curl|wget|bash|cli-anything-)/.test(bodyAfterPaths); + + // 3. Maybe split into references/ + const { body: bodySplit, references } = maybeSplitReferences(report.frontmatter.name || '', bodyAfterPaths); + + // 4. Add the missing sections. References index goes BEFORE + // Output contract / Failure handling / Windows notes so the moved-out + // content is reachable from the top of the body, not buried under + // boilerplate at the end. + let finalBody = bodySplit; + finalBody = addReferencesIndex(finalBody, references); + finalBody = addOutputContractSection(finalBody); + finalBody = addFailureHandlingSection(finalBody); + finalBody = addWindowsNotesSection(finalBody, hasShell); + + // 5. Enrich frontmatter (target name = basename of outDir so name matches dir) + const targetName = path.basename(outDir); + const enrichedFm = enrichFrontmatter(report.frontmatter, finalBody, classify, targetName); + + // 6. Serialize + const fmYaml = dumpYamlBlock(enrichedFm); + const skillText = `---\n${fmYaml}---\n\n${finalBody.trimStart()}`; + + // 7. Atomic write: stage everything under a sibling temp dir, then + // swap into outDir via the backup-rename dance. + const stageDir = `${outDir}.staging-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; + try { + await fs.mkdir(stageDir, { recursive: true }); + const skillOut = path.join(stageDir, 'SKILL.md'); + await fs.writeFile(skillOut, skillText, 'utf-8'); + + for (const ref of references) { + const refPath = path.join(stageDir, 'references', ref.file); + await fs.mkdir(path.dirname(refPath), { recursive: true }); + await fs.writeFile(refPath, ref.content.trim() + '\n', 'utf-8'); + } + + const reportMd = renderConversionReport({ inputPath, classify, pathChanges, written: [], warnings }); + const reportPath = path.join(stageDir, 'conversion-report.md'); + await fs.writeFile(reportPath, reportMd, 'utf-8'); + + await atomicReplace(stageDir, outDir); + } catch (err) { + // Make sure staging is gone even if the catch ran mid-write. + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + throw err; + } + + // 8. Record the final paths (post-rename) for the caller. + written.push(path.join(outDir, 'SKILL.md')); + for (const ref of references) { + written.push(path.join(outDir, 'references', ref.file)); + } + written.push(path.join(outDir, 'conversion-report.md')); + + return { written, warnings }; +} + +function renderConversionReport({ inputPath, classify, pathChanges, written, warnings }) { + return [ + `# Conversion report`, + ``, + `- **input**: \`${inputPath}\``, + `- **tier**: ${classify.tier} / ${classify.subTier}`, + `- **reason**: ${classify.reason}`, + ``, + `## Path changes`, + pathChanges.length === 0 + ? `_none_` + : pathChanges.map(c => `- \`${c.id}\` → \${${c.placeholder}} (${c.count}x)`).join('\n'), + ``, + `## Written files`, + written.map(f => `- \`${f}\``).join('\n'), + ``, + `## Recommendations`, + classify.recommendations.map(r => `- ${r}`).join('\n'), + ``, + `## Warnings`, + warnings.length === 0 ? `_none_` : warnings.map(w => `- ${w}`).join('\n'), + ``, + `_generated by skill-bridge v0.2.0 on ${new Date().toISOString()}_`, + ``, + ].join('\n'); +} diff --git a/plugins/antianqi/skill-bridge/mcp.json b/plugins/antianqi/skill-bridge/mcp.json new file mode 100644 index 0000000..1be3bf4 --- /dev/null +++ b/plugins/antianqi/skill-bridge/mcp.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "skill-bridge": { + "type": "stdio", + "command": "node", + "args": ["./server.mjs"] + } + } +} diff --git a/plugins/antianqi/skill-bridge/plugin.json b/plugins/antianqi/skill-bridge/plugin.json new file mode 100644 index 0000000..bc9b488 --- /dev/null +++ b/plugins/antianqi/skill-bridge/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "skill-bridge", + "version": "0.2.0", + "description": "Convert an openclaw (or similar) skill into a portable mavis/mcode-compatible skill, exposed as a stdio MCP server. Detects encoding, parameterizes hardcoded paths, enriches frontmatter, and runs the official skill-creator lint.", + "author": { + "name": "antianqi", + "url": "https://github.com/antianqi" + }, + "homepage": "https://github.com/antianqi/skill-bridge", + "repository": "https://github.com/antianqi/skill-bridge.git", + "license": "Apache-2.0", + "keywords": [ + "minimax-code", + "mcp", + "skill-migration", + "openclaw", + "converter" + ] +} diff --git a/plugins/antianqi/skill-bridge/references/compatibility-matrix.md b/plugins/antianqi/skill-bridge/references/compatibility-matrix.md new file mode 100644 index 0000000..b25fafd --- /dev/null +++ b/plugins/antianqi/skill-bridge/references/compatibility-matrix.md @@ -0,0 +1,56 @@ +# Compatibility Matrix — openclaw skills → mavis tiers + +This table maps every openclaw skill we know about into the three-tier model. It is generated by running the `classify` MCP tool against each source and is updated whenever the upstream openclaw workspace changes. + +The matrix is **information only**; it is not a list of bundled demos. The only conversion demo shipped in this plugin is `examples/output/task-tracker/`. The other 35 openclaw skills are listed here so plugin consumers know what `tier` to expect for each one, and so `wrapped-*` skills can be migrated in a later version. + +## Tier legend + +- **pure-translate** — frontmatter enrichment only, body is fine as-is. +- **pure-wrapped-fix** — frontmatter + path parameterization + encoding fix + Windows notes. +- **wrapped-\*** — needs an external CLI / API; **not in v0.2**. +- **abandon** — openclaw-only assumptions cannot be removed; **do not import**. + +## The openclaw skills + +| Skill | Tier (v0.2) | Why | +|---|---|---| +| `task-tracker` | pure-wrapped-fix | has hardcoded `${OPENCLAW_WORKSPACE}/TASKS.md` | +| `identity-state-updater` | wrapped (feishu) | references feishu API | +| `skill-vetter` | wrapped (python) | references `python3` | +| `auto-memory-extract` | wrapped (memory I/O) | depends on openclaw memory paths | +| `history_compressor` | wrapped (LLM call) | assumes a particular LLM tool | +| `magic-docs` | wrapped (python) | DOCX tooling via Python | +| `skill_orchestrator` | wrapped (openclaw runtime) | uses openclaw hook system | +| `skill_hooks` | wrapped (openclaw runtime) | openclaw-only | +| `execution_logger` | wrapped (openclaw runtime) | openclaw-only | +| `short-drama` (短剧生成) | wrapped (ComfyUI) | hardcoded ComfyUI workflow JSON paths | +| `comfyui-cli` | wrapped (cli-anything) | depends on `cli-anything-comfyui` | +| `comfyui-outfit` | wrapped (ComfyUI) | same as above | +| `esp32` | wrapped (ESP32 toolchain) | depends on esptool.py | +| `esp32-voice-assistant` | wrapped (ESP32 + TTS) | same as above | +| `multi-engine-tts` | wrapped (TTS services) | 5+ external TTS APIs | +| `minimax-tts` | wrapped (TTS) | single TTS API | +| `minimax-tokenplan-image-generation` | wrapped (image API) | external service | +| `mmx-search` | wrapped (search) | depends on minimax-search backend | +| `flux-fill` | wrapped (ComfyUI) | image gen via ComfyUI | +| `reverse-prompt-selfie` | wrapped (ComfyUI) | same as above | +| `douyin-video` | wrapped (Douyin) | external service | +| `douyin-video-analysis` | wrapped (Douyin) | external service | +| `douyin-search` | wrapped (Douyin) | external service | +| `feishu-*` (5 skills) | wrapped (feishu) | feishu API surface | +| `wechat-*` (4 skills) | wrapped (wechat) | WeChat API surface | +| `mcode-*` (3 skills) | pure-translate | mavis-bound, no fixes needed | +| `misc-tasks` (2 skills) | pure-wrapped-fix | only hardcoded paths | + +(The exact per-skill count varies as openclaw evolves; this table lists the tier assignment for each skill we have classified at least once. Run `classify` on a fresh source to confirm.) + +## How to read this + +- `pure` tiers are always convertible. Use the `convert` tool. +- `wrapped-*` tiers are not convertible in v0.2. Tell the user; plan a v0.3 plugin that wraps the external dependency. +- `abandon` tiers mean the openclaw skill embeds assumptions that cannot be safely migrated (e.g. it requires the openclaw TUI itself). Do not auto-convert these. + +## Bundled demo + +The only example we ship inside this plugin is `examples/output/task-tracker/`, the result of running `convert` against the original `examples/input/task-tracker/`. It exists so users can see what the converter produces without having to bring their own openclaw skill. diff --git a/plugins/antianqi/skill-bridge/references/encoding-tables.md b/plugins/antianqi/skill-bridge/references/encoding-tables.md new file mode 100644 index 0000000..9bf54e9 --- /dev/null +++ b/plugins/antianqi/skill-bridge/references/encoding-tables.md @@ -0,0 +1,56 @@ +# Encoding Tables + +> v0.1 status: the converter only distinguishes **UTF-8** vs **GBK**. We do not +> maintain a static GBK→Unicode table; we use `iconv-lite` for full-table +> decode when needed. +> +> This document explains the detection algorithm so future contributors can +> extend it to GB2312, Big5, etc. + +## How detection works + +1. Read the file as raw bytes. +2. Try strict UTF-8 decode (no replacement chars = success). +3. Else try `iconv-lite` GBK decode. If it yields CJK characters without replacement chars, the source is GBK → re-decode and continue. +4. Else: declare `unknown`; leave as lossy UTF-8; warn the user. + +## Why not `chardet`? + +`chardet` (and `franc` for languages) is a probabilistic library. In our use case the false-positive cost is high: silently mis-decoding a SKILL.md produces a skill that loads but contains garbled instructions. The "two passes, prefer the one with no replacement chars" approach has a low false-positive rate for the binary-clean files we care about. + +## GBK vs GB18030 vs GB2312 + +GB18030 is a superset of GBK which is a superset of GB2312. `iconv-lite` supports GBK and GB18030 out of the box; we use GBK because that's what we observed in the openclaw workspace dumps. If you see GB18030-only files (rare), switch the encoding name in `lib/detect.js`. + +## Filename mojibake + +GBK **filenames** (vs GBK **file contents**) are a separate, harder problem: + +- A GBK-encoded filename is stored as raw bytes on disk (NTFS / ext4 store bytes; the encoding is only a convention). +- Reading a directory listing via `Get-ChildItem` (PowerShell) returns names in the **system code page** on Windows (CP936 for Chinese systems) — and loses information if the system code page is different. +- There is no "GBK filename to UTF-8 filename" mapping without a complete byte-level decode of the directory. + +For v0.1, we do NOT rename files. We surface the warning and let the user rename manually: + +``` +$ mcode-skill-bridge suggest-filename '�̾�����.md' +``` + +(planned for v0.2; for now, the CLI's `analyze` command flags the directory listing.) + +## Extending + +To add support for a new encoding: + +1. Add the encoding name to `lib/detect.js`: + ```js + if (iconv.encodingExists('big5')) { + // try Big5 decode + } + ``` +2. Add a fixture under `tests/fixtures/encoding/big5.txt` and a test in `tests/detect.test.mjs`. +3. Update this document. + +## Why we don't bundle a GBK table + +`iconv-lite`'s GBK table is ~50KB compressed. Bundling our own would double the package size for a single encoding. If `iconv-lite` ever stops working for us, we can ship a minimal table covering the GBK basic range (0x8140-0xFEFE, ~21000 entries) as a separate npm package. diff --git a/plugins/antianqi/skill-bridge/references/path-patterns.md b/plugins/antianqi/skill-bridge/references/path-patterns.md new file mode 100644 index 0000000..36b0f47 --- /dev/null +++ b/plugins/antianqi/skill-bridge/references/path-patterns.md @@ -0,0 +1,60 @@ +# Path Patterns + +This document describes the hardcoded path patterns that `skill-bridge` recognizes and replaces, the placeholder variables used, and how downstream code should resolve them at runtime. + +## Placeholders + +| Placeholder | Meaning | Default suggested value | +|---|---|---| +| `${OPENCLAW_HOME}` | openclaw root dir (where the user kept `.openclaw/`) | unset — user must set | +| `${OPENCLAW_WORKSPACE}` | openclaw workspace (typically `${OPENCLAW_HOME}/workspace`) | unset — user must set | +| `${SCRATCH}` | OS-appropriate scratch dir | `os.tmpdir()` | +| `${DATA_DIR}` | mavis data dir | `~/.minimax` | + +## The 6 rules (in priority order) + +```js +// 1. openclaw workspace — most specific, checked first +{C:\Users\Administrator\.openclaw[/\]workspace[/\]? + → ${OPENCLAW_WORKSPACE}/} + +// 2. openclaw home (any other subdir) +{C:\Users\Administrator\.openclaw[/\]? + → ${OPENCLAW_HOME}/} + +// 3. tilde form +{~/.openclaw/ + → ${OPENCLAW_HOME}/} + +// 4. CLI-Anything scratch +{/tmp/CLI-Anything/ + → ${SCRATCH}/cli-anything/} + +// 5. generic /tmp +{/(?![\w/])/tmp/ + → ${SCRATCH}/} + +// 6. mavis data dir +{C:\Users\Administrator\.minimax[/\]? + → ${DATA_DIR}/} +``` + +The order matters: rule 1 must run before rule 2, otherwise `workspace/` would be replaced with `${OPENCLAW_HOME}/workspace/` and then re-matched by rule 1, leaving a double placeholder. + +After all rules run, a post-pass collapses runs of slashes that may appear at the boundary between the placeholder and what was originally the separator — e.g. `${OPENCLAW_HOME}//workspace/foo.md` becomes `${OPENCLAW_HOME}/workspace/foo.md`. + +## Why we don't auto-resolve the placeholders + +`OPENCLAW_HOME` is genuinely environment-specific. We don't pretend to know where the user's old openclaw workspace is on a new machine. The skill body says `${OPENCLAW_HOME}/workspace/TASKS.md` and downstream code (or the user) fills in the env var at runtime. + +For users who don't have an openclaw workspace anymore, the path is effectively dead and the skill should be rewritten to not depend on it. This is a content decision, not a tool decision. + +## Extending the rules + +If you have a new pattern (e.g. a hardcoded `/home/foo/claude/` from another framework), add it to `lib/paths.js` `PATH_RULES`. Order matters: more specific patterns go first. Re-run the tests: + +```bash +node --test tests/paths.test.mjs +``` + +Add a test for the new pattern in the same file before opening a PR. diff --git a/plugins/antianqi/skill-bridge/server.mjs b/plugins/antianqi/skill-bridge/server.mjs new file mode 100644 index 0000000..2b7d9ba --- /dev/null +++ b/plugins/antianqi/skill-bridge/server.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +// server.mjs — stdio MCP server for skill-bridge. +// +// Exposes four tools that mirror the original CLI subcommands but +// communicate over JSON-RPC on stdin/stdout: +// +// detect (source) -> { encoding, originalEncoding, +// replaced, confidence, reason } +// analyze (source) -> full AnalyzedSkill report +// classify (source) -> { tier, subTier, reason, ... } +// convert (source, target_dir, +// force?, run_lint?) -> { tier, subTier, written, warnings, +// lint } +// +// `source` may be a path to a SKILL.md file OR a directory containing one. +// Paths are resolved relative to the calling agent's filesystem; we do +// not use any host-specific state. +// +// References: +// - Agent Plugins 1.0 MCP schema: +// https://agent-plugins.org/schemas/1.0.0/mcp.schema.json +// - hello-mcode-mcp example shipped by the community registry. + +import { createInterface } from 'node:readline'; +import { readFileSafe } from './lib/detect.js'; +import { analyzeSkillFile, parseFrontmatter } from './lib/analyze.js'; +import { classify } from './lib/classify.js'; +import { transformSkill } from './lib/transform-skill.js'; +import { lintSkill } from './lib/lint.js'; + +const SERVER_INFO = { name: 'skill-bridge', version: '0.2.0' }; +const PROTOCOL_VERSION = '2025-06-18'; + +// ---------- MCP plumbing ---------- + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function ok(id, result) { + send({ jsonrpc: '2.0', id, result }); +} + +function fail(id, code, message, data) { + send({ jsonrpc: '2.0', id, error: { code, message, data } }); +} + +const TOOLS = [ + { + name: 'detect', + description: + 'Detect the encoding of a SKILL.md file. Returns one of: utf-8, gbk, unknown. ' + + 'If gbk, the text field is the UTF-8-restored content.', + inputSchema: { + type: 'object', + properties: { + source: { + type: 'string', + description: 'Absolute path to a SKILL.md file or a directory containing one.', + }, + }, + required: ['source'], + additionalProperties: false, + }, + }, + { + name: 'analyze', + description: + 'Full analysis of a SKILL.md: frontmatter, body, hardcoded paths, ' + + 'external commands, and warnings. Use this when the caller wants to ' + + 'inspect the skill before deciding what to do.', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string', description: 'Path to SKILL.md or skill folder.' }, + }, + required: ['source'], + additionalProperties: false, + }, + }, + { + name: 'classify', + description: + 'Classify a skill into one of: pure / pure-translate / pure-wrapped-fix, ' + + 'or wrapped-* (not yet supported in v0.2), or abandon.', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string' }, + }, + required: ['source'], + additionalProperties: false, + }, + }, + { + name: 'convert', + description: + 'Run the full conversion pipeline and write the result to target_dir. ' + + 'In v0.2 only `pure` skills are converted. Lint runs by default; ' + + 'pass run_lint=false to skip.', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string' }, + target_dir: { type: 'string' }, + force: { type: 'boolean', default: false }, + run_lint: { type: 'boolean', default: true }, + }, + required: ['source', 'target_dir'], + additionalProperties: false, + }, + }, +]; + +function toolResultText(payload) { + return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] }; +} + +async function handle(message) { + const { method, params, id } = message; + try { + if (method === 'initialize') { + return { + result: { + protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }, + }; + } + if (method === 'notifications/initialized') { + return null; // no-op + } + if (method === 'tools/list') { + return { result: { tools: TOOLS } }; + } + if (method === 'tools/call') { + const name = params?.name; + const args = params?.arguments ?? {}; + return { + result: await invokeTool(name, args), + }; + } + return { error: { code: -32601, message: `Method not found: ${String(method)}` } }; + } catch (e) { + return { error: { code: -32000, message: e?.message ?? String(e) } }; + } +} + +async function invokeTool(name, args) { + switch (name) { + case 'detect': { + const r = await readFileSafe(String(args.source)); + return toolResultText(r); + } + case 'analyze': { + const r = await analyzeSkillFile(String(args.source)); + return toolResultText(r); + } + case 'classify': { + const report = await analyzeSkillFile(String(args.source)); + return toolResultText(classify(report)); + } + case 'convert': { + const source = String(args.source); + const targetDir = String(args.target_dir); + const force = Boolean(args.force); + const runLint = args.run_lint !== false; + const report = await analyzeSkillFile(source); + const result = classify(report); + if (result.tier === 'abandon') { + return toolResultText({ ok: false, tier: 'abandon', reason: result.reason }); + } + if (result.tier !== 'pure') { + return toolResultText({ + ok: false, + tier: result.tier, + subTier: result.subTier, + reason: result.reason, + note: 'v0.2 only emits pure skills. wrapped-* support is planned for v0.3.', + }); + } + // The transformer writes to a staging dir and renames onto target_dir. + // It does NOT touch target_dir if anything fails. The `force` flag + // here is informational; the transformer is always safe to re-run. + void force; + const r = await transformSkill({ + inputPath: source, + report, + classify: result, + outDir: targetDir, + }); + let lint = null; + if (runLint) { + const lr = await lintSkill(targetDir); + lint = { ok: lr.ok, code: lr.code, stdout: lr.stdout, stderr: lr.stderr }; + } + return toolResultText({ + ok: true, + tier: result.tier, + subTier: result.subTier, + written: r.written, + warnings: r.warnings, + lint, + }); + } + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +input.on('line', (line) => { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch { + return; // ignore malformed lines + } + if (message.id === undefined) return; // notifications have no id + Promise.resolve(handle(message)).then((response) => { + if (response === null || response === undefined) return; + if (response.error) return fail(message.id, response.error.code, response.error.message, response.error.data); + return ok(message.id, response.result); + }); +}); diff --git a/plugins/antianqi/skill-bridge/skills/skill-bridge/SKILL.md b/plugins/antianqi/skill-bridge/skills/skill-bridge/SKILL.md new file mode 100644 index 0000000..e6e7d39 --- /dev/null +++ b/plugins/antianqi/skill-bridge/skills/skill-bridge/SKILL.md @@ -0,0 +1,107 @@ +--- +name: skill-bridge +description: | + Convert an openclaw (or similar) skill folder into a mavis/mcode-compatible + skill via the bundled stdio MCP server `skill-bridge`. Use when the user + wants to migrate a skill from openclaw, reuse a skill from another + framework, or port a hand-written skill that does not follow the mavis + schema. Do NOT use to create a brand-new skill from scratch (use + `skill-creator` instead), or to lint/refine an existing mavis skill + (use `skill-refiner`). +descriptions: + zh-Hans: | + 通过内置的 stdio MCP server `skill-bridge`,把 openclaw(或类似框架) + 的 skill 转换为 mavis/mcode 兼容的 skill。需要迁移/移植/复用 skill 时使用。 +displayNames: + zh-Hans: Skill 移植桥 +metadata: + openclaw_compat: true + auto-invoke: "" +--- + +# skill-bridge + +Bring a non-mavis skill into the mavis world. The skill itself is the **thin LLM-facing layer**; the heavy lifting lives in the stdio MCP server declared in `mcp.json` at the plugin root. + +The MCP server exposes four tools, named after the original v0.1 CLI subcommands: + +| Tool | Purpose | +| --- | --- | +| `detect(source)` | Identify UTF-8 vs GBK; restore mojibake if needed. | +| `analyze(source)` | Full report: frontmatter, body, hardcoded paths, external commands. | +| `classify(source)` | One of `pure` (translatable), `pure-wrapped-fix`, `wrapped-*`, or `abandon`. | +| `convert(source, target_dir, force?, run_lint?)` | Run the full pipeline; write to `target_dir`. Lint runs unless `run_lint=false`. | + +`source` accepts either an absolute path to a `SKILL.md` file or to a directory containing one. `target_dir` is an absolute path that will be created or replaced atomically. + +## When to use this skill + +- The user has an `openclaw` workspace (or any non-mavis skill bundle) and wants to use those skills inside mavis. +- The user found a skill on GitHub written in a different agent framework and wants to reuse it. +- The user wrote a `SKILL.md` themselves years ago and wants to bring it up to mavis's current schema. + +Do **not** use this skill for: + +- Creating a new skill from scratch → `skill-creator` +- Fixing or refining an existing mavis skill → `skill-refiner` +- Listing what skills are available → read `` from the system prompt + +## Inputs to collect + +- **Source path**: an absolute path to either a `SKILL.md` file or to a folder containing one. If the user gave a relative path, resolve it against the user's cwd before calling the tool. +- **Output path**: an absolute path for the converted skill. Default: a folder whose basename matches the kebab-case name. If the user names a scope: + - user → `/.minimax/skills//` + - agent → `/.minimax/agents/mavis/skills//` + - project → `/.minimax/skills//` +- **Force overwrite (optional)**: only confirm with the user if the target already exists. The server is safe to re-run; `force` is informational. + +## Procedure + +1. **Detect** the source. Call `detect(source)` and inspect `encoding`. + - If `encoding === "unknown"`, warn the user before continuing. + - If `encoding === "gbk"` and `replaced === true`, tell the user the source was GBK and we restored it. +2. **Analyze** the full report. Call `analyze(source)` and check `hardcodedPaths` and `externalCommands`. + - Non-empty `externalCommands` → the skill is likely `wrapped-*` (v0.2 only emits `pure`; stop and tell the user). +3. **Classify**. Call `classify(source)`. In v0.2, proceed only if `tier === "pure"`. +4. **Convert**. Call `convert(source, target_dir)`. + - If the tool returns `ok: false` with `tier: "abandon"` or `tier: "wrapped"`, stop and explain why. + - If `ok: true`, read `target_dir/conversion-report.md` and surface the `warnings` array to the user verbatim. + - Skim `target_dir/SKILL.md`. If anything looks wrong (missing section, garbled encoding, broken path), tell the user **before** claiming success. +5. **Lint feedback**. The `convert` response already includes the `lint` object (`ok`, `code`, `stdout`, `stderr`). If `ok === false`, surface the lint output and do not claim the conversion is done. +6. **Tell the user** what was written, what to review, and how to use the new skill. Suggest `skill({name: ""})` to verify it loads. + +## Output contract + +- A directory at `target_dir` containing at minimum: + - `SKILL.md` — mavis-schema-compliant + - `conversion-report.md` — what was changed + - optionally `references/.md` if the body was split + +The server replaces `target_dir` atomically: at every observable point in time the directory is either the OLD content or the NEW content, never empty or half-written. Re-running with the same `target_dir` is always safe. + +## Failure handling + +- `tier: abandon` from `classify` → do not write; explain the reason to the user. +- `tier: wrapped` in v0.2 → tell the user the server only supports `pure` right now; v0.3 will add `wrapped`. +- `lint.ok === false` → do not claim success; show the `lint.stdout` and `lint.stderr` verbatim. +- `encoding === "unknown"` → ask the user to confirm the source is genuinely UTF-8 before writing. +- Target already exists → atomic replace happens by default; only ask the user if you want to confirm before overwriting. + +## Examples + +**Input**: `/path/to/openclaw/skills/task-tracker` + +**Good path**: +1. `detect(...)` → `utf-8`, no replacement. +2. `classify(...)` → `pure / pure-wrapped-fix` (one hardcoded path group). +3. `convert(source, target_dir)` → `ok: true`, two warnings about path parameterization. +4. Confirm `lint.ok === true`, surface the two warnings to the user. + +**Bad path**: copy the `SKILL.md` to `/.minimax/agents/mavis/skills//` directly. The user's previous attempt at this failed because (a) the path is not in the mavis scan list and (b) GBK content was not detected. + +## Additional resources + +- `references/compatibility-matrix.md` — known openclaw skills and their tier +- `references/path-patterns.md` — the hardcoded path patterns we replace +- The MCP server itself: see `mcp.json` + `server.mjs` in the plugin root +- The plan that produced this skill: see the plugin's `README.md` diff --git a/plugins/antianqi/skill-bridge/tests/analyze.test.mjs b/plugins/antianqi/skill-bridge/tests/analyze.test.mjs new file mode 100644 index 0000000..1681d4c --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/analyze.test.mjs @@ -0,0 +1,111 @@ +// tests/analyze.test.mjs +// +// The frontmatter parser is hand-rolled to avoid the js-yaml npm dep. +// These tests pin the exact subset we support and the round-trip +// behavior of the dump. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseYamlBlock, dumpYamlBlock, parseFrontmatter } from '../lib/analyze.js'; + +test('parseYamlBlock: simple scalars', () => { + const fm = parseYamlBlock(`name: hello\nversion: "1.0"\nflag: true\nmissing: null\n`); + assert.equal(fm.name, 'hello'); + assert.equal(fm.version, '1.0'); + assert.equal(fm.flag, true); + assert.equal(fm.missing, null); +}); + +test('parseYamlBlock: quoted strings preserve spaces', () => { + const fm = parseYamlBlock(`title: "Hello World"\nsub: 'a b c'\n`); + assert.equal(fm.title, 'Hello World'); + assert.equal(fm.sub, 'a b c'); +}); + +test('parseYamlBlock: block scalar with |', () => { + const fm = parseYamlBlock(`body: |\n line 1\n line 2\n line 3\n`); + assert.equal(fm.body, 'line 1\nline 2\nline 3'); +}); + +test('parseYamlBlock: one level of nested mapping', () => { + const fm = parseYamlBlock(`metadata:\n author: alice\n version: "0.1.0"\ndescriptions:\n zh-Hans: 你好\n`); + assert.deepEqual(fm.metadata, { author: 'alice', version: '0.1.0' }); + assert.equal(fm.descriptions['zh-Hans'], '你好'); +}); + +test('parseYamlBlock: bad indent throws', () => { + assert.throws( + () => parseYamlBlock(`a:\n b: 1\n`), + /bad indent/, + ); +}); + +test('parseYamlBlock: number coercion', () => { + const fm = parseYamlBlock(`a: 42\nb: -3.14\nc: "42"\n`); + // `42` and `-3.14` parse as numbers; `"42"` (quoted) stays a string. + assert.equal(fm.a, 42); + assert.equal(fm.b, -3.14); + assert.equal(fm.c, '42'); +}); + +test('parseFrontmatter: round-trip from SKILL.md text', () => { + const text = `--- +name: foo +description: "A test" +metadata: + author: alice +--- +# Body`; + const { frontmatter, body, ok } = parseFrontmatter(text); + assert.equal(ok, true); + assert.equal(frontmatter.name, 'foo'); + assert.equal(frontmatter.description, 'A test'); + assert.equal(frontmatter.metadata.author, 'alice'); + assert.match(body, /^# Body/); +}); + +test('parseFrontmatter: missing frontmatter returns ok=false', () => { + const text = '# Just a heading\n\nno frontmatter here'; + const r = parseFrontmatter(text); + assert.equal(r.ok, false); + assert.equal(r.frontmatter.name, undefined); +}); + +test('dumpYamlBlock + parseYamlBlock round-trip preserves content', () => { + // Note: arrays are not part of the parseYamlBlock subset. We verify + // them in dumpYamlBlock unit tests below; the round-trip here covers + // only the shapes (scalars + one level of nested mapping) that the + // parser supports. + const original = { + name: 'round-trip', + description: 'Use this skill to round-trip.', + descriptions: { 'zh-Hans': '回环测试' }, + metadata: { 'skill-bridge': { tier: 'pure' } }, + }; + const text = dumpYamlBlock(original); + const parsed = parseYamlBlock(text); + assert.equal(parsed.name, 'round-trip'); + assert.equal(parsed.description, 'Use this skill to round-trip.'); + assert.equal(parsed.descriptions['zh-Hans'], '回环测试'); + assert.equal(parsed.metadata['skill-bridge'].tier, 'pure'); +}); + +test('dumpYamlBlock: string with newline uses block scalar', () => { + const text = dumpYamlBlock({ body: 'line 1\nline 2' }); + assert.match(text, /^body: \|\n/m); + assert.match(text, / line 1\n line 2/); +}); + +test('dumpYamlBlock: reserved words get quoted', () => { + const text = dumpYamlBlock({ flag: 'true', no: 'null' }); + // 'true' / 'null' / 'yes' / 'no' / etc. must be quoted or they would + // round-trip as their YAML-typed values, not as strings. + assert.match(text, /flag: "true"/); + assert.match(text, /no: "null"/); +}); + +test('dumpYamlBlock: leading/trailing space gets quoted', () => { + const text = dumpYamlBlock({ x: ' hi', y: 'bye ' }); + assert.match(text, /x: " hi"/); + assert.match(text, /y: "bye "/); +}); diff --git a/plugins/antianqi/skill-bridge/tests/classify.test.mjs b/plugins/antianqi/skill-bridge/tests/classify.test.mjs new file mode 100644 index 0000000..ceef306 --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/classify.test.mjs @@ -0,0 +1,70 @@ +// tests/classify.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classify } from '../lib/classify.js'; + +function report(overrides = {}) { + return { + inputPath: 'fake', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: {}, + body: '', + hardcodedPaths: [], + externalCommands: [], + warnings: [], + ...overrides, + }; +} + +test('pure-translate: clean instruction, no paths, no external tools', () => { + const r = classify(report()); + assert.equal(r.tier, 'pure'); + assert.equal(r.subTier, 'pure-translate'); +}); + +test('pure-wrapped-fix: has hardcoded Windows path', () => { + const r = classify(report({ + hardcodedPaths: [{ label: 'absolute Windows user path', samples: ['C:\\Users\\Administrator\\.openclaw\\'] }], + })); + assert.equal(r.tier, 'pure'); + assert.equal(r.subTier, 'pure-wrapped-fix'); +}); + +test('pure-wrapped-fix: GBK source was converted', () => { + const r = classify(report({ encoding: 'gbk', convertedFromGbk: true })); + assert.equal(r.subTier, 'pure-wrapped-fix'); + assert.ok(/gbk/i.test(r.reason)); +}); + +test('wrapped-python: pip install detected', () => { + const r = classify(report({ + externalCommands: [{ label: 'pip install', samples: ['pip install -e .'] }], + })); + assert.equal(r.tier, 'wrapped'); + assert.equal(r.subTier, 'wrapped-python'); +}); + +test('wrapped-cli-anything: CLI tool detected', () => { + const r = classify(report({ + externalCommands: [{ label: 'cli-anything CLI', samples: ['cli-anything-comfyui'] }], + })); + assert.equal(r.tier, 'wrapped'); + assert.equal(r.subTier, 'wrapped-cli-anything'); +}); + +test('wrapped-service: ComfyUI reference', () => { + const r = classify(report({ + externalCommands: [{ label: 'ComfyUI reference', samples: ['ComfyUI'] }], + })); + assert.equal(r.tier, 'wrapped'); + assert.equal(r.subTier, 'wrapped-service'); +}); + +test('wrapped-http: curl detected', () => { + const r = classify(report({ + externalCommands: [{ label: 'curl', samples: ['curl '] }], + })); + assert.equal(r.tier, 'wrapped'); + assert.equal(r.subTier, 'wrapped-http'); +}); diff --git a/plugins/antianqi/skill-bridge/tests/detect.test.mjs b/plugins/antianqi/skill-bridge/tests/detect.test.mjs new file mode 100644 index 0000000..3344e26 --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/detect.test.mjs @@ -0,0 +1,90 @@ +// tests/detect.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { detectEncoding, isLikelyGbkMojibake } from '../lib/detect.js'; + +// Minimal GBK encoder for tests. We do NOT want a production dependency +// on iconv-lite (the whole point of v0.2 is to ship with zero npm deps), +// and we do NOT want to round-trip through the Node TextDecoder in tests +// (the decoder would be exercising the very code path we are testing). +// +// The table below covers the characters used in this test file and the +// "Short Chinese string" corpus. Adding a new test that needs different +// characters means adding entries here. +const GBK_TABLE = { + '短': [0xB6, 0xCC], + '剧': [0xBE, 0xE7], + '生': [0xC9, 0xFA], + '成': [0xB3, 0xC9], + '工': [0xB9, 0xA4], + '作': [0xD7, 0xF7], + '流': [0xC1, 0xF7], + '中': [0xD6, 0xD0], + '文': [0xCE, 0xC4], + '段': [0xB6, 0xCE], + '落': [0xC2, 0xD4], + '正': [0xD5, 0xFD], + '常': [0xB3, 0xA3], + '世': [0xCA, 0xC0], + '界': [0xBD, 0xE7], + '你': [0xC4, 0xE3], + '好': [0xBA, 0xC3], + '再': [0xD4, 0xD9], + '见': [0xBC, 0xFB], +}; + +function encodeGbk(str) { + const out = []; + for (const ch of str) { + const code = ch.codePointAt(0); + if (code < 0x80) { + out.push(code); + } else { + const bytes = GBK_TABLE[ch]; + if (!bytes) throw new Error(`test corpus missing GBK entry for ${JSON.stringify(ch)}`); + out.push(bytes[0], bytes[1]); + } + } + return Buffer.from(out); +} + +test('UTF-8 clean ASCII', () => { + const r = detectEncoding(Buffer.from('hello world', 'utf-8')); + assert.equal(r.encoding, 'utf-8'); + assert.equal(r.replaced, false); + assert.equal(r.text, 'hello world'); +}); + +test('UTF-8 clean Chinese', () => { + const r = detectEncoding(Buffer.from('你好世界', 'utf-8')); + assert.equal(r.encoding, 'utf-8'); + assert.equal(r.text, '你好世界'); +}); + +test('GBK round-trip is detected as gbk', () => { + const original = '短剧生成工作流'; + const buf = encodeGbk(original); + const r = detectEncoding(buf); + assert.equal(r.encoding, 'gbk'); + assert.equal(r.replaced, true); + assert.equal(r.text, original); +}); + +test('Unknown bytes fall through to lossy utf-8', () => { + // Random binary that is neither valid UTF-8 nor valid GBK CJK. + // 0xff 0xfe is a UTF-16 LE BOM; the strict UTF-8 decoder will reject. + // The bytes below are not part of any GBK lead/continuation pair either, + // so the GBK decoder will also reject. We expect "unknown" (the + // lossy-utf-8 fallback). + const buf = Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90, 0xa0, 0xb0]); + const r = detectEncoding(buf); + // We accept either "unknown" or "gbk" because the heuristic is + // intentionally loose; what we care about is that the text is not + // silently treated as clean utf-8. + assert.ok(['unknown', 'gbk'].includes(r.encoding), 'should not falsely claim clean utf-8'); +}); + +test('isLikelyGbkMojibake detects U+FFFD cluster', () => { + assert.equal(isLikelyGbkMojibake('xxx ���� xxx'), true); + assert.equal(isLikelyGbkMojibake('正常中文'), false); +}); diff --git a/plugins/antianqi/skill-bridge/tests/lint.test.mjs b/plugins/antianqi/skill-bridge/tests/lint.test.mjs new file mode 100644 index 0000000..4bd48b1 --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/lint.test.mjs @@ -0,0 +1,102 @@ +// tests/lint.test.mjs — regression tests for the review blockers: +// +// 1. `lib/lint.js` MUST NOT write a staged `.mjs` next to +// `~/.minimax/.builtin-skills/skill-creator/scripts/lint-skill.js`. +// That's the user's install area; polluting it is rude and racy. +// +// 2. The temp dir we DO write to must be removed on every code path. +// +// 3. The fast path (in-process dynamic import) must also surface a +// failed lint result faithfully, without touching the install dir. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { lintSkill } from '../lib/lint.js'; + +// A lint script that does NOT export a `lint` function, forcing the +// subprocess path. Pure CJS, no ESM `import` syntax, so the fast path's +// `import()` of the .js file succeeds and returns an empty module +// (`mod.lint` undefined → fall through). When staged to a .mjs and run +// by node, the same `console.log` works fine in ESM mode. +const FAULT_FREE_LINT = ` +console.log('lint ok for ' + process.argv[2]); +`; + +// A lint script that DOES export a `lint` function (CJS). This drives +// the fast path in-process, returning a failing result without spawning +// a subprocess. Used to verify the install dir is not touched on the +// failure path either. +const FAILING_FAST_LINT = ` +module.exports = { + lint: (p) => ({ ok: false, code: 2, stdout: 'lint failed for ' + p, stderr: '' }), +}; +`; + +async function writeLintScript(content) { + // This directory stands in for ~/.minimax/.builtin-skills/... in real use. + // We never let lintSkill write into it — that's the whole point of this test. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-lint-fixture-')); + const lintScript = path.join(dir, 'lint-skill.js'); + await fs.writeFile(lintScript, content, 'utf-8'); + return { dir, lintScript }; +} + +async function assertNoLeftoverStagingInTmp() { + // lintSkill uses prefix `sb-lint-${pid}-`. After it resolves, no such + // directory created by *this* test process should remain. + const tmpRoot = os.tmpdir(); + const entries = await fs.readdir(tmpRoot); + const leftover = entries.filter((e) => e.startsWith(`sb-lint-${process.pid}-`)); + assert.equal( + leftover.length, + 0, + `temp staging dirs left behind: ${leftover.join(', ')}`, + ); +} + +test('lintSkill (subprocess path) stages the .mjs in os.tmpdir() — install dir is untouched', async () => { + const { dir, lintScript } = await writeLintScript(FAULT_FREE_LINT); + let skillPath; + try { + skillPath = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-lint-target-')); + const r = await lintSkill(skillPath, { lintScript }); + assert.equal(r.ok, true, `expected ok, stderr was:\n${r.stderr}`); + assert.ok(/lint ok/.test(r.stdout), `stdout: ${r.stdout}`); + // Install dir must contain only lint-skill.js, never a staged .mjs. + const siblings = await fs.readdir(dir); + assert.ok( + !siblings.some((f) => f.endsWith('.mjs')), + `install dir should not have staged .mjs; got: ${siblings.join(', ')}`, + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + if (skillPath) await fs.rm(skillPath, { recursive: true, force: true }); + } + await assertNoLeftoverStagingInTmp(); +}); + +test('lintSkill (fast path) returns the lint-script failure faithfully without touching disk', async () => { + const { dir, lintScript } = await writeLintScript(FAILING_FAST_LINT); + let skillPath; + try { + skillPath = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-lint-target-')); + const r = await lintSkill(skillPath, { lintScript }); + assert.equal(r.ok, false, 'expected ok=false on lint failure'); + assert.equal(r.code, 2, `expected exit code 2, got ${r.code}`); + assert.ok(/lint failed/.test(r.stdout), `stdout: ${r.stdout}`); + + // No temp staging dir should have been created — fast path never + // touches disk, and there is no subprocess to spawn. + const tmpRoot = os.tmpdir(); + const entries = await fs.readdir(tmpRoot); + const leftover = entries.filter((e) => e.startsWith(`sb-lint-${process.pid}-`)); + assert.equal(leftover.length, 0, `fast path should not stage anything; got: ${leftover.join(', ')}`); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + if (skillPath) await fs.rm(skillPath, { recursive: true, force: true }); + } +}); + diff --git a/plugins/antianqi/skill-bridge/tests/paths.test.mjs b/plugins/antianqi/skill-bridge/tests/paths.test.mjs new file mode 100644 index 0000000..c6664c2 --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/paths.test.mjs @@ -0,0 +1,60 @@ +// tests/paths.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parameterizePaths, suggestFilename, PATH_RULES } from '../lib/paths.js'; + +test('parameterizePaths replaces openclaw home', () => { + // Use a path that hits the home rule (not the more specific workspace rule) + const r = parameterizePaths('read C:\\Users\\Administrator\\.openclaw\\config\\foo.md'); + assert.ok(r.text.includes('${OPENCLAW_HOME}')); + assert.ok(r.changes.some(c => c.id === 'openclaw-home')); +}); + +test('parameterizePaths prefers openclaw-workspace when workspace/ is present', () => { + const r = parameterizePaths('read C:\\Users\\Administrator\\.openclaw\\workspace\\foo.md'); + assert.ok(r.text.includes('${OPENCLAW_WORKSPACE}')); + assert.ok(r.text.includes('foo.md')); + assert.ok(!r.text.includes('${OPENCLAW_HOME}')); +}); + +test('parameterizePaths replaces /tmp/CLI-Anything once', () => { + const r = parameterizePaths('source: /tmp/CLI-Anything/gimp/agent-harness'); + assert.equal(r.text, 'source: ${SCRATCH}/cli-anything/gimp/agent-harness'); +}); + +test('parameterizePaths does not double-replace CLI-Anything', () => { + const r = parameterizePaths('cd /tmp/CLI-Anything/foo'); + // Should be ${SCRATCH}/cli-anything/foo, NOT ${SCRATCH}/${SCRATCH}/cli-anything/foo + assert.ok(!r.text.includes('${SCRATCH}/${SCRATCH}'), `got: ${r.text}`); + assert.ok(r.text.startsWith('cd ${SCRATCH}/cli-anything/foo')); +}); + +test('parameterizePaths generic /tmp', () => { + const r = parameterizePaths('cd /tmp/myscript.sh'); + assert.equal(r.text, 'cd ${SCRATCH}/myscript.sh'); +}); + +test('parameterizePaths returns empty changes for clean text', () => { + const r = parameterizePaths('pure text with no paths'); + assert.equal(r.changes.length, 0); + assert.equal(r.text, 'pure text with no paths'); +}); + +test('suggestFilename keeps ASCII names', () => { + const r = suggestFilename('task-tracker.md'); + assert.equal(r.recoverable, true); + assert.equal(r.name, 'task-tracker.md'); +}); + +test('suggestFilename flags mojibake names', () => { + const r = suggestFilename('�̾�����.md'); + assert.equal(r.recoverable, false); +}); + +test('PATH_RULES has stable ids', () => { + const ids = PATH_RULES.map(r => r.id); + assert.ok(new Set(ids).size === ids.length, 'ids must be unique'); + for (const id of ids) { + assert.ok(/^[a-z0-9-]+$/.test(id), `bad id: ${id}`); + } +}); diff --git a/plugins/antianqi/skill-bridge/tests/server.test.mjs b/plugins/antianqi/skill-bridge/tests/server.test.mjs new file mode 100644 index 0000000..b78a818 --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/server.test.mjs @@ -0,0 +1,191 @@ +// tests/server.test.mjs +// +// Spawns server.mjs as a real subprocess and exercises the JSON-RPC +// protocol over stdio. This is the same protocol mavis will use to +// invoke the plugin's MCP server, so any regression here is caught +// before review. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import os from 'node:os'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SERVER = path.join(__dirname, '..', 'server.mjs'); + +/** + * Minimal JSON-RPC client that talks to the spawned server over stdio. + * Each request/response is one JSON object per line. + */ +function startServer() { + const child = spawn(process.execPath, [SERVER], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + let nextId = 1; + const pending = new Map(); + let buffer = ''; + child.stdout.on('data', (chunk) => { + buffer += chunk.toString('utf-8'); + let idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id !== undefined && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`)); + else resolve(msg.result); + } + } + }); + const stderr = []; + child.stderr.on('data', (d) => stderr.push(d.toString('utf-8'))); + + function send(method, params) { + return new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + } + function notify(method, params) { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); + } + async function stop() { + notify('shutdown', {}); + child.stdin.end(); + await new Promise((r) => child.on('close', r)); + return stderr.join(''); + } + return { send, notify, stop }; +} + +test('server: initialize handshake', async () => { + const s = startServer(); + try { + const r = await s.send('initialize', { protocolVersion: '2025-06-18' }); + assert.equal(r.protocolVersion, '2025-06-18'); + assert.equal(r.serverInfo.name, 'skill-bridge'); + assert.match(r.serverInfo.version, /^\d+\.\d+\.\d+/); + } finally { + await s.stop(); + } +}); + +test('server: tools/list advertises the four tools', async () => { + const s = startServer(); + try { + const r = await s.send('tools/list'); + const names = r.tools.map((t) => t.name).sort(); + assert.deepEqual(names, ['analyze', 'classify', 'convert', 'detect']); + } finally { + await s.stop(); + } +}); + +test('server: detect on utf-8 file', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile(file, '---\nname: x\ndescription: y\n---\n\n# X\n', 'utf-8'); + const s = startServer(); + try { + const r = await s.send('tools/call', { name: 'detect', arguments: { source: file } }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.encoding, 'utf-8'); + assert.equal(payload.replaced, false); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: classify on a pure-instruction skill', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile( + file, + '---\nname: y\ndescription: "A pure skill."\n---\n\n# Y\n\nJust instructions.\n', + 'utf-8', + ); + const s = startServer(); + try { + const r = await s.send('tools/call', { name: 'classify', arguments: { source: file } }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.tier, 'pure'); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: convert writes output and returns lint object', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile( + file, + '---\nname: demo-skill\ndescription: "Demo."\n---\n\n# Demo\n\nUse /tmp/x for cache.\n', + 'utf-8', + ); + const out = path.join(dir, 'out'); + const s = startServer(); + try { + const r = await s.send('tools/call', { + name: 'convert', + arguments: { source: file, target_dir: out, run_lint: false }, + }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.ok, true); + assert.equal(payload.tier, 'pure'); + assert.ok(payload.written.some((f) => f.endsWith('SKILL.md'))); + assert.equal(payload.lint, null, 'run_lint=false → no lint field'); + const written = await fs.readdir(out); + assert.ok(written.includes('SKILL.md')); + assert.ok(written.includes('conversion-report.md')); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: convert on wrapped skill returns ok=false with reason', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile( + file, + '---\nname: w\ndescription: "Uses pip."\n---\n\n# W\n\nRun `pip install foo`.\n', + 'utf-8', + ); + const out = path.join(dir, 'out'); + const s = startServer(); + try { + const r = await s.send('tools/call', { + name: 'convert', + arguments: { source: file, target_dir: out, run_lint: false }, + }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.ok, false); + assert.equal(payload.tier, 'wrapped'); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: unknown method returns JSON-RPC error', async () => { + const s = startServer(); + try { + await assert.rejects( + s.send('tools/banana', {}), + /Method not found/, + ); + } finally { + await s.stop(); + } +}); diff --git a/plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs b/plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs new file mode 100644 index 0000000..99ca8bb --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs @@ -0,0 +1,120 @@ +// tests/transform-atomic.test.mjs +// +// Regression tests for the "atomic replace" guarantee in +// lib/transform-skill.js. +// +// hetaoBackend's review on PR #3 said: "所谓原子替换先删除 outDir 再 rename; +// rename 失败会丢失旧输出。需要失败保留测试。" +// +// v0.2 fixes this by staging to a sibling temp dir and using a +// backup-and-rename dance: outDir is moved to a backup first, the +// staging dir is renamed onto outDir, and the backup is removed. If +// anything fails, the backup is moved back so outDir is restored. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { transformSkill } from '../lib/transform-skill.js'; + +const SAMPLE = { + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'atomic-test', description: 'Atomic rename test.' }, + body: '# Top\n\n## Procedure\n\nDo it.\n', + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-translate', reason: 'r', recommendations: [] }, +}; + +async function tmpdir() { + return await fs.mkdtemp(path.join(os.tmpdir(), 'sb-atomic-')); +} + +test('1st run creates outDir with the new content', async () => { + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-1'); + await transformSkill({ ...SAMPLE, outDir }); + const entries = await fs.readdir(outDir); + assert.ok(entries.includes('SKILL.md')); + assert.ok(entries.includes('conversion-report.md')); + await fs.rm(out, { recursive: true, force: true }); +}); + +test('2nd run replaces outDir cleanly (no stale references/)', async () => { + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-2'); + + // 1st pass: long body that triggers the references/ split. + const sectionBody = (label) => { + const lines = [`## ${label}`]; + for (let i = 0; i < 200; i++) lines.push(`${label} line ${i}.`); + return lines.join('\n'); + }; + const longBody = [ + '# Top', '', + 'Intro.', + '', + sectionBody('A'), + sectionBody('B'), + sectionBody('C'), + sectionBody('D'), + ].join('\n'); + await transformSkill({ + ...SAMPLE, + report: { ...SAMPLE.report, body: longBody }, + outDir, + }); + const refsAfterFirst = await fs.readdir(path.join(outDir, 'references')); + assert.ok(refsAfterFirst.length > 0, '1st pass should produce references/'); + + // 2nd pass: short body that does NOT trigger the split. The atomic + // replace must wipe the old references/ — not just overwrite SKILL.md. + await transformSkill({ + ...SAMPLE, + report: { ...SAMPLE.report, body: '# Top\n\nShort body, no split.\n' }, + outDir, + }); + const refsAfterSecond = await fs.readdir(path.join(outDir, 'references')).catch(() => null); + assert.equal(refsAfterSecond, null, 'stale references/ must be removed by atomic replace'); + + // And the new SKILL.md reflects the new body. + const skill = await fs.readFile(path.join(outDir, 'SKILL.md'), 'utf-8'); + assert.ok(skill.includes('Short body, no split.')); + assert.ok(!/A line 0/.test(skill), 'old long-body content must not leak into the new SKILL.md'); + + await fs.rm(out, { recursive: true, force: true }); +}); + +test('outDir is preserved when transformSkill fails before any write', async () => { + // Force a deterministic failure with a NUL byte in the outDir path. + // Node fs APIs always reject NUL bytes, so transformSkill throws + // before it touches anything. The pre-existing outDir (and its + // sentinel) must remain untouched on disk. + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-3'); + await fs.mkdir(outDir, { recursive: true }); + const sentinel = path.join(outDir, 'SENTINEL.md'); + await fs.writeFile(sentinel, 'keep me', 'utf-8'); + + // NUL byte in the path makes any fs call throw. + const badOut = path.join(out, 'bad\0segment', 'skill'); + + await assert.rejects( + transformSkill({ ...SAMPLE, outDir: badOut }), + (err) => err instanceof Error, + 'transformSkill must reject when outDir is unusable', + ); + + // Pre-existing outDir and its sentinel must still be intact. + const stillThere = await fs.stat(outDir); + assert.ok(stillThere.isDirectory(), 'outDir must still exist'); + const content = await fs.readFile(sentinel, 'utf-8'); + assert.equal(content, 'keep me', 'sentinel must be unchanged'); + + await fs.rm(out, { recursive: true, force: true }); +}); diff --git a/plugins/antianqi/skill-bridge/tests/transform-skill.test.mjs b/plugins/antianqi/skill-bridge/tests/transform-skill.test.mjs new file mode 100644 index 0000000..f265f45 --- /dev/null +++ b/plugins/antianqi/skill-bridge/tests/transform-skill.test.mjs @@ -0,0 +1,239 @@ +// tests/transform-skill.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { transformSkill } from '../lib/transform-skill.js'; +import { parseFrontmatter } from '../lib/analyze.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +const SAMPLE = `--- +name: test-skill +description: "A test skill for unit tests." +--- + +# Test Skill + +## Inputs to collect + +- A thing. + +## Procedure + +1. Do the thing. + +This skill uses C:\\Users\\Administrator\\.openclaw\\workspace\\foo.md +`; + +async function tmpdir() { + return await fs.mkdtemp(path.join(os.tmpdir(), 'sb-test-')); +} + +test('transformSkill writes SKILL.md and conversion-report.md', async () => { + const out = await tmpdir(); + // Use a stable outDir basename so the resulting name is deterministic. + const outDir = path.join(path.dirname(out), 'test-skill'); + const r = await transformSkill({ + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'test-skill', description: 'A test skill for unit tests.' }, + body: SAMPLE.split('---\n').slice(2).join('---\n'), + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-wrapped-fix', reason: 'has hardcoded path', recommendations: [] }, + outDir, + }); + assert.ok(r.written.some(f => f.endsWith('SKILL.md'))); + assert.ok(r.written.some(f => f.endsWith('conversion-report.md'))); + + const skill = await fs.readFile(path.join(outDir, 'SKILL.md'), 'utf-8'); + // Original path should be parameterized (workspace rule wins for this input) + assert.ok(skill.includes('${OPENCLAW_WORKSPACE}')); + // Required sections should be added + assert.ok(/^##\s+Output contract/m.test(skill)); + assert.ok(/^##\s+Failure handling/m.test(skill)); + // Frontmatter enrichment + const { frontmatter, body } = parseFrontmatter(skill); + assert.equal(frontmatter.name, 'test-skill'); + assert.equal(frontmatter.metadata['skill-bridge'].classify_tier, 'pure'); + await fs.rm(outDir, { recursive: true, force: true }); +}); + +test('transformSkill adds Windows notes when shell commands present', async () => { + const out = await tmpdir(); + const bodyWithShell = '## Procedure\n\nRun `pip install foo` and then `python3 main.py`.'; + await transformSkill({ + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'shell-skill', description: 'shell skill' }, + body: bodyWithShell, + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-wrapped-fix', reason: 'r', recommendations: [] }, + outDir: out, + }); + const skill = await fs.readFile(path.join(out, 'SKILL.md'), 'utf-8'); + assert.ok(/^##\s+Windows \(win32\) platform notes/m.test(skill), 'should add Windows section'); + await fs.rm(out, { recursive: true, force: true }); +}); + +test('transformSkill does NOT add Windows notes for pure prose', async () => { + const out = await tmpdir(); + const body = '## Procedure\n\nJust do the thing. No shell needed.'; + await transformSkill({ + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'pure-skill', description: 'pure' }, + body, + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-translate', reason: 'r', recommendations: [] }, + outDir: out, + }); + const skill = await fs.readFile(path.join(out, 'SKILL.md'), 'utf-8'); + assert.ok(!/^##\s+Windows \(win32\) platform notes/m.test(skill)); + await fs.rm(out, { recursive: true, force: true }); +}); + +test('transformSkill adds a References index when body is split into references/', async () => { + // Build a body with > 500 lines and 4 `##` sections so maybeSplitReferences + // (sections.length >= 3) fires. + const sectionBody = (label) => { + const lines = [`## ${label}`]; + for (let i = 0; i < 200; i++) lines.push(`Section ${label} line ${i}.`); + return lines.join('\n'); + }; + const longBody = [ + '# Top', + '', + 'Intro paragraph that does not count as a section.', + '', + sectionBody('Alpha'), + '', + sectionBody('Beta'), + '', + sectionBody('Gamma'), + '', + sectionBody('Delta'), + ].join('\n'); + + const out = await tmpdir(); + const outDir = path.join(out, 'split-skill'); + const r = await transformSkill({ + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'split-skill', description: 'Test the split.' }, + body: longBody, + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-wrapped-fix', reason: 'r', recommendations: [] }, + outDir, + }); + + // The split must have produced at least one references file. + const refsDir = path.join(outDir, 'references'); + const refFiles = await fs.readdir(refsDir); + assert.ok(refFiles.length >= 1, `expected references/ to be populated, got: ${refFiles.join(', ')}`); + + // SKILL.md must surface them with a References section AND markdown links. + const skill = await fs.readFile(path.join(outDir, 'SKILL.md'), 'utf-8'); + assert.ok(/^##\s+References\b/m.test(skill), 'should add a ## References section to SKILL.md'); + assert.ok( + /references\/[a-z0-9-]+\.md/.test(skill), + 'should list each references/*.md as a link inside the index', + ); + + // The link target must exist on disk. + const linked = skill.match(/references\/([a-z0-9-]+\.md)/); + assert.ok(linked, 'should find a references/*.md link in the body'); + assert.ok( + refFiles.includes(linked[1]), + `linked file ${linked[1]} should exist in references/`, + ); + + assert.ok(r.written.length >= 3, 'should record SKILL.md + references + conversion-report.md'); + await fs.rm(out, { recursive: true, force: true }); +}); + +test('transformSkill replaces outDir atomically (no stale references/ on re-run)', async () => { + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-skill'); + + // 1st pass: long body that triggers split. + const sectionBody = (label) => { + const lines = [`## ${label}`]; + for (let i = 0; i < 200; i++) lines.push(`${label} line ${i}.`); + return lines.join('\n'); + }; + const longBody = [ + '# Top', '', + 'Intro.', + '', + sectionBody('A'), + sectionBody('B'), + sectionBody('C'), + sectionBody('D'), + ].join('\n'); + + await transformSkill({ + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'atomic-skill', description: 'first run' }, + body: longBody, + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-wrapped-fix', reason: 'r', recommendations: [] }, + outDir, + }); + + // 1st pass leaves a populated references/ directory. + const refsAfterFirst = await fs.readdir(path.join(outDir, 'references')); + assert.ok(refsAfterFirst.length > 0, '1st pass should produce references/'); + + // 2nd pass: short body that does NOT trigger split. The atomic replace + // must wipe the old references/ — not just overwrite SKILL.md. + const shortBody = '# Top\n\nShort body, no split.\n\n## Procedure\n\nDo it.'; + await transformSkill({ + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'atomic-skill', description: 'second run' }, + body: shortBody, + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-translate', reason: 'r', recommendations: [] }, + outDir, + }); + + // No stale references/ on disk. + const refsAfterSecond = await fs.readdir(path.join(outDir, 'references')).catch(() => null); + assert.equal( + refsAfterSecond, + null, + 'stale references/ from previous run must be removed by atomic replace', + ); + + // And the new SKILL.md reflects the new body (not the long one). + const skill = await fs.readFile(path.join(outDir, 'SKILL.md'), 'utf-8'); + assert.ok(skill.includes('Short body, no split.'), 'SKILL.md should reflect 2nd pass body'); + assert.ok(!/Section A line 0/.test(skill), 'old long-body content must not leak into new SKILL.md'); + + await fs.rm(out, { recursive: true, force: true }); +}); From 4c3ffe46a7f8d0061337e2e8078d1222e0df26ce Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:02:24 +0800 Subject: [PATCH 2/5] fix: accept directory sources in detect and analyze (review #2) The README and SKILL.md promise that `source` may be either a SKILL.md file path OR a directory containing one, but the implementation (`lib/detect.js:88-91` and `lib/analyze.js:193-194`) called `fs.readFile` directly. A directory source produced `EISDIR` and the MCP server returned no usable response. - `lib/detect.js`: add `resolveSkillSource(filePath)` that stats the path and, for a directory, looks for `SKILL.md` inside. `readFileSafe` now resolves first, then reads the resolved file. - `lib/analyze.js`: `analyzeSkillFile` uses the same resolver so the directory contract is uniform across `detect`, `analyze`, and `classify`/`convert`. `AnalyzedSkill.inputPath` now reports the resolved file, not the directory. - `tests/detect.test.mjs`: three new tests - directory with SKILL.md reads cleanly - directory without SKILL.md throws a descriptive error - file path is returned unchanged by `resolveSkillSource` `node --test plugins/antianqi/skill-bridge/tests/*.test.mjs` reports 53/53 pass (was 50/50 before this commit, so the existing surface area is unchanged). --- plugins/antianqi/skill-bridge/lib/analyze.js | 582 +++++++++--------- plugins/antianqi/skill-bridge/lib/detect.js | 239 +++---- .../skill-bridge/tests/detect.test.mjs | 216 ++++--- 3 files changed, 553 insertions(+), 484 deletions(-) diff --git a/plugins/antianqi/skill-bridge/lib/analyze.js b/plugins/antianqi/skill-bridge/lib/analyze.js index 58206e0..f1eb68e 100644 --- a/plugins/antianqi/skill-bridge/lib/analyze.js +++ b/plugins/antianqi/skill-bridge/lib/analyze.js @@ -1,290 +1,292 @@ -// lib/analyze.js — Frontmatter parsing and hardcoded-paths/commands scan. -// -// We avoid `js-yaml` to keep the dependency surface small. The -// frontmatter we need to parse is a constrained YAML subset: -// -// - top-level `key: value` lines -// - top-level `key: |` (or `key: >`) followed by an indented block -// - top-level `key:` with one level of nested keys (used by -// `descriptions.zh-Hans`, `metadata.x`, etc.) -// -// Everything else (anchors, tags, multi-doc, flow style) is unsupported -// by design; skill authors should keep frontmatter simple. - -import fs from 'node:fs/promises'; -import { readFileSafe } from './detect.js'; - -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; - -const EXTERNAL_COMMAND_PATTERNS = [ - { re: /\bpip\s+install\b/g, label: 'pip install' }, - { re: /\bcli-anything-[a-z0-9-]+/g, label: 'cli-anything CLI' }, - { re: /\bpython3?\s+/g, label: 'python invocation' }, - { re: /\bcurl\s+/g, label: 'curl' }, - { re: /\bwget\s+/g, label: 'wget' }, - { re: /\bComfyUI\b/g, label: 'ComfyUI reference' }, - { re: /\bESP32\b/g, label: 'ESP32 reference' }, - { re: /\bDouyin|抖音\b/g, label: 'Douyin reference' }, - { re: /\bTTS\b/g, label: 'TTS reference' }, - { re: /\bfeishu|飞书\b/g, label: 'Feishu reference' }, - { re: /\b\${\w+}\b/g, label: 'unresolved template var' }, -]; - -const PATH_PATTERNS = [ - { re: /C:\\Users\\[^"\s`']+/g, label: 'absolute Windows user path' }, - { re: /~\/\.[a-zA-Z0-9_.-]+/g, label: 'tilde home path' }, - { re: /(?} hardcodedPaths - * @property {Array<{label:string, samples:string[]}>} externalCommands - * @property {string[]} warnings - */ - -// ---------- Constrained YAML parser ---------- - -const KEY_LINE_RE = /^(\s*)([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/; - -/** - * Parse a constrained YAML block. Supports: - * - `key: value` (string / number / boolean / null) - * - `key: "..."` / `key: '...'` (quoted string) - * - `key: |` / `key: >` (block scalar, indented body) - * - `key:` (followed by indented sub-keys) -> nested object - * - * Throws on unsupported constructs. - * - * @param {string} text - * @returns {object} - */ -export function parseYamlBlock(text) { - const lines = text.split(/\r?\n/); - const root = {}; - // Stack of frames: each holds the current container and its indent - // level. We start at indent -2 so that the first top-level key (indent 0) - // satisfies `indent === top.indent + 2` without special-casing. - const stack = [{ indent: -2, container: root }]; - let i = 0; - while (i < lines.length) { - const line = lines[i]; - if (line.trim() === '') { i++; continue; } - const m = line.match(KEY_LINE_RE); - if (!m) { - throw new Error(`cannot parse line: ${JSON.stringify(line)}`); - } - const [, ws, key, rawValue] = m; - const indent = ws.length; - // Pop frames until we are at the right parent. - while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { - stack.pop(); - } - const top = stack[stack.length - 1]; - // The current line's indent must be exactly top.indent + 2. - if (indent !== top.indent + 2) { - throw new Error(`bad indent at line: ${JSON.stringify(line)}`); - } - if (rawValue === '' || rawValue === '|' || rawValue === '>') { - if (rawValue === '|' || rawValue === '>') { - const blockIndent = indent + 2; - const blockLines = []; - i++; - while (i < lines.length) { - const bl = lines[i]; - if (bl.trim() === '') { blockLines.push(''); i++; continue; } - const bi = bl.match(/^(\s*)/)[1].length; - if (bi < blockIndent) break; - blockLines.push(bl.slice(blockIndent)); - i++; - } - top.container[key] = blockLines.join('\n').replace(/\n+$/, ''); - } else { - // nested object - const obj = {}; - top.container[key] = obj; - stack.push({ indent, container: obj }); - } - } else { - top.container[key] = coerceScalar(rawValue); - } - i++; - } - return root; -} - -function coerceScalar(v) { - // Quoted scalars are always returned as strings, even if the content - // would otherwise look like a number / boolean / null. This matches - // YAML's "explicit string" rule and matches what dumpYamlBlock emits - // for reserved words and string-looking numbers. - if (v.length >= 2) { - const first = v[0]; - const last = v[v.length - 1]; - if ((first === '"' && last === '"') || (first === "'" && last === "'")) { - return v.slice(1, -1); - } - } - if (v === 'true') return true; - if (v === 'false') return false; - if (v === 'null' || v === '~') return null; - if (/^-?\d+$/.test(v)) return Number(v); - if (/^-?\d+\.\d+$/.test(v)) return Number(v); - return v; -} - -/** - * Parse a SKILL.md into frontmatter (object) + body (string). - * @param {string} text - * @returns {{ frontmatter: object, body: string, ok: boolean, err?: string }} - */ -export function parseFrontmatter(text) { - const m = FRONTMATTER_RE.exec(text); - if (!m) return { frontmatter: {}, body: text, ok: false, err: 'no frontmatter' }; - try { - const fm = parseYamlBlock(m[1]); - return { frontmatter: fm, body: m[2], ok: true }; - } catch (e) { - return { frontmatter: {}, body: text, ok: false, err: 'yaml parse: ' + e.message }; - } -} - -// ---------- Pattern scanning ---------- - -function scanPatterns(text, patterns) { - const out = []; - for (const { re, label } of patterns) { - re.lastIndex = 0; - const samples = new Set(); - let m; - while ((m = re.exec(text)) !== null) { - samples.add(m[0]); - if (samples.size >= 5) break; - } - if (samples.size > 0) out.push({ label, samples: [...samples] }); - } - return out; -} - -/** - * Reconstruct the full file text from frontmatter + body so that the - * pattern scans see the same content the human reader would. - * - * @param {object} frontmatter - * @param {string} body - * @returns {string} - */ -export function reconstructText(frontmatter, body) { - return `---\n${dumpYamlBlock(frontmatter)}---\n${body}`; -} - -// ---------- Full file analyze ---------- - -/** - * @param {string} filePath - * @returns {Promise} - */ -export async function analyzeSkillFile(filePath) { - const det = await readFileSafe(filePath); - const text = det.text; - const { frontmatter, body, ok, err } = parseFrontmatter(text); - - const fullText = ok ? reconstructText(frontmatter, body) : text; - - const warnings = []; - if (!ok) warnings.push(`frontmatter: ${err}`); - if (det.encoding === 'unknown') warnings.push('encoding: could not determine (left as lossy utf-8)'); - if (det.encoding === 'gbk' && det.replaced) warnings.push('encoding: converted from GBK to UTF-8'); - - return { - inputPath: filePath, - encoding: det.encoding, - convertedFromGbk: det.replaced, - frontmatter, - body, - fullText, - hardcodedPaths: scanPatterns(fullText, PATH_PATTERNS), - externalCommands: scanPatterns(fullText, EXTERNAL_COMMAND_PATTERNS), - warnings, - }; -} - -// ---------- YAML dump (used internally and by transform-skill.js) ---------- - -const NEEDS_QUOTING = /[:#&*!|>'"%@`{}[\],\n]/; -const RESERVED_WORDS = new Set(['true', 'false', 'null', '~', 'yes', 'no', 'on', 'off']); -const STARTS_WITH_NUMBER = /^-?\d/; - -/** - * Serialize a JS object as a constrained YAML block. Matches the - * subset our parseYamlBlock understands. - * - * @param {object} obj - * @param {number} [indent=0] - * @returns {string} - */ -export function dumpYamlBlock(obj, indent = 0) { - const pad = ' '.repeat(indent); - const lines = []; - for (const [k, v] of Object.entries(obj)) { - if (v === undefined) continue; - if (v === null) { - lines.push(`${pad}${k}: null`); - continue; - } - if (Array.isArray(v)) { - if (v.length === 0) { - lines.push(`${pad}${k}: []`); - continue; - } - lines.push(`${pad}${k}:`); - for (const item of v) { - if (item === null) { - lines.push(`${pad} - null`); - } else if (typeof item === 'object' && !Array.isArray(item)) { - const childPad = `${pad} `; - const dumped = dumpYamlBlock(item, indent + 1); - // Indent the first line with the dash, subsequent lines stay aligned. - const [first, ...rest] = dumped.split('\n'); - lines.push(`${childPad}- ${first.trimStart()}`); - for (const r of rest) lines.push(r); - } else { - lines.push(`${pad} - ${scalarToYaml(item)}`); - } - } - continue; - } - if (typeof v === 'object') { - if (Object.keys(v).length === 0) { - lines.push(`${pad}${k}: {}`); - continue; - } - lines.push(`${pad}${k}:`); - lines.push(dumpYamlBlock(v, indent + 1)); - continue; - } - if (typeof v === 'string' && v.includes('\n')) { - lines.push(`${pad}${k}: |`); - for (const line of v.split('\n')) lines.push(`${pad} ${line}`); - continue; - } - lines.push(`${pad}${k}: ${scalarToYaml(v)}`); - } - return lines.join('\n') + (lines.length ? '\n' : ''); -} - -function scalarToYaml(v) { - if (typeof v === 'boolean' || typeof v === 'number') return String(v); - if (typeof v !== 'string') return JSON.stringify(v); - if (v === '') return '""'; - if (RESERVED_WORDS.has(v)) return JSON.stringify(v); - if (STARTS_WITH_NUMBER.test(v)) return JSON.stringify(v); - if (NEEDS_QUOTING.test(v) || /^\s|\s$/.test(v)) return JSON.stringify(v); - return v; -} +// lib/analyze.js — Frontmatter parsing and hardcoded-paths/commands scan. +// +// We avoid `js-yaml` to keep the dependency surface small. The +// frontmatter we need to parse is a constrained YAML subset: +// +// - top-level `key: value` lines +// - top-level `key: |` (or `key: >`) followed by an indented block +// - top-level `key:` with one level of nested keys (used by +// `descriptions.zh-Hans`, `metadata.x`, etc.) +// +// Everything else (anchors, tags, multi-doc, flow style) is unsupported +// by design; skill authors should keep frontmatter simple. + +import fs from 'node:fs/promises'; +import { readFileSafe, resolveSkillSource } from './detect.js'; + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; + +const EXTERNAL_COMMAND_PATTERNS = [ + { re: /\bpip\s+install\b/g, label: 'pip install' }, + { re: /\bcli-anything-[a-z0-9-]+/g, label: 'cli-anything CLI' }, + { re: /\bpython3?\s+/g, label: 'python invocation' }, + { re: /\bcurl\s+/g, label: 'curl' }, + { re: /\bwget\s+/g, label: 'wget' }, + { re: /\bComfyUI\b/g, label: 'ComfyUI reference' }, + { re: /\bESP32\b/g, label: 'ESP32 reference' }, + { re: /\bDouyin|抖音\b/g, label: 'Douyin reference' }, + { re: /\bTTS\b/g, label: 'TTS reference' }, + { re: /\bfeishu|飞书\b/g, label: 'Feishu reference' }, + { re: /\b\${\w+}\b/g, label: 'unresolved template var' }, +]; + +const PATH_PATTERNS = [ + { re: /C:\\Users\\[^"\s`']+/g, label: 'absolute Windows user path' }, + { re: /~\/\.[a-zA-Z0-9_.-]+/g, label: 'tilde home path' }, + { re: /(?} hardcodedPaths + * @property {Array<{label:string, samples:string[]}>} externalCommands + * @property {string[]} warnings + */ + +// ---------- Constrained YAML parser ---------- + +const KEY_LINE_RE = /^(\s*)([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/; + +/** + * Parse a constrained YAML block. Supports: + * - `key: value` (string / number / boolean / null) + * - `key: "..."` / `key: '...'` (quoted string) + * - `key: |` / `key: >` (block scalar, indented body) + * - `key:` (followed by indented sub-keys) -> nested object + * + * Throws on unsupported constructs. + * + * @param {string} text + * @returns {object} + */ +export function parseYamlBlock(text) { + const lines = text.split(/\r?\n/); + const root = {}; + // Stack of frames: each holds the current container and its indent + // level. We start at indent -2 so that the first top-level key (indent 0) + // satisfies `indent === top.indent + 2` without special-casing. + const stack = [{ indent: -2, container: root }]; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === '') { i++; continue; } + const m = line.match(KEY_LINE_RE); + if (!m) { + throw new Error(`cannot parse line: ${JSON.stringify(line)}`); + } + const [, ws, key, rawValue] = m; + const indent = ws.length; + // Pop frames until we are at the right parent. + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + const top = stack[stack.length - 1]; + // The current line's indent must be exactly top.indent + 2. + if (indent !== top.indent + 2) { + throw new Error(`bad indent at line: ${JSON.stringify(line)}`); + } + if (rawValue === '' || rawValue === '|' || rawValue === '>') { + if (rawValue === '|' || rawValue === '>') { + const blockIndent = indent + 2; + const blockLines = []; + i++; + while (i < lines.length) { + const bl = lines[i]; + if (bl.trim() === '') { blockLines.push(''); i++; continue; } + const bi = bl.match(/^(\s*)/)[1].length; + if (bi < blockIndent) break; + blockLines.push(bl.slice(blockIndent)); + i++; + } + top.container[key] = blockLines.join('\n').replace(/\n+$/, ''); + } else { + // nested object + const obj = {}; + top.container[key] = obj; + stack.push({ indent, container: obj }); + } + } else { + top.container[key] = coerceScalar(rawValue); + } + i++; + } + return root; +} + +function coerceScalar(v) { + // Quoted scalars are always returned as strings, even if the content + // would otherwise look like a number / boolean / null. This matches + // YAML's "explicit string" rule and matches what dumpYamlBlock emits + // for reserved words and string-looking numbers. + if (v.length >= 2) { + const first = v[0]; + const last = v[v.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return v.slice(1, -1); + } + } + if (v === 'true') return true; + if (v === 'false') return false; + if (v === 'null' || v === '~') return null; + if (/^-?\d+$/.test(v)) return Number(v); + if (/^-?\d+\.\d+$/.test(v)) return Number(v); + return v; +} + +/** + * Parse a SKILL.md into frontmatter (object) + body (string). + * @param {string} text + * @returns {{ frontmatter: object, body: string, ok: boolean, err?: string }} + */ +export function parseFrontmatter(text) { + const m = FRONTMATTER_RE.exec(text); + if (!m) return { frontmatter: {}, body: text, ok: false, err: 'no frontmatter' }; + try { + const fm = parseYamlBlock(m[1]); + return { frontmatter: fm, body: m[2], ok: true }; + } catch (e) { + return { frontmatter: {}, body: text, ok: false, err: 'yaml parse: ' + e.message }; + } +} + +// ---------- Pattern scanning ---------- + +function scanPatterns(text, patterns) { + const out = []; + for (const { re, label } of patterns) { + re.lastIndex = 0; + const samples = new Set(); + let m; + while ((m = re.exec(text)) !== null) { + samples.add(m[0]); + if (samples.size >= 5) break; + } + if (samples.size > 0) out.push({ label, samples: [...samples] }); + } + return out; +} + +/** + * Reconstruct the full file text from frontmatter + body so that the + * pattern scans see the same content the human reader would. + * + * @param {object} frontmatter + * @param {string} body + * @returns {string} + */ +export function reconstructText(frontmatter, body) { + return `---\n${dumpYamlBlock(frontmatter)}---\n${body}`; +} + +// ---------- Full file analyze ---------- + +/** + * @param {string} filePath a SKILL.md path or a directory containing one + * @returns {Promise} + */ +export async function analyzeSkillFile(filePath) { + // Resolve directory → SKILL.md; throws a descriptive error if not found. + const resolved = await resolveSkillSource(filePath); + const det = await readFileSafe(resolved); + const text = det.text; + const { frontmatter, body, ok, err } = parseFrontmatter(text); + + const fullText = ok ? reconstructText(frontmatter, body) : text; + + const warnings = []; + if (!ok) warnings.push(`frontmatter: ${err}`); + if (det.encoding === 'unknown') warnings.push('encoding: could not determine (left as lossy utf-8)'); + if (det.encoding === 'gbk' && det.replaced) warnings.push('encoding: converted from GBK to UTF-8'); + + return { + inputPath: resolved, + encoding: det.encoding, + convertedFromGbk: det.replaced, + frontmatter, + body, + fullText, + hardcodedPaths: scanPatterns(fullText, PATH_PATTERNS), + externalCommands: scanPatterns(fullText, EXTERNAL_COMMAND_PATTERNS), + warnings, + }; +} + +// ---------- YAML dump (used internally and by transform-skill.js) ---------- + +const NEEDS_QUOTING = /[:#&*!|>'"%@`{}[\],\n]/; +const RESERVED_WORDS = new Set(['true', 'false', 'null', '~', 'yes', 'no', 'on', 'off']); +const STARTS_WITH_NUMBER = /^-?\d/; + +/** + * Serialize a JS object as a constrained YAML block. Matches the + * subset our parseYamlBlock understands. + * + * @param {object} obj + * @param {number} [indent=0] + * @returns {string} + */ +export function dumpYamlBlock(obj, indent = 0) { + const pad = ' '.repeat(indent); + const lines = []; + for (const [k, v] of Object.entries(obj)) { + if (v === undefined) continue; + if (v === null) { + lines.push(`${pad}${k}: null`); + continue; + } + if (Array.isArray(v)) { + if (v.length === 0) { + lines.push(`${pad}${k}: []`); + continue; + } + lines.push(`${pad}${k}:`); + for (const item of v) { + if (item === null) { + lines.push(`${pad} - null`); + } else if (typeof item === 'object' && !Array.isArray(item)) { + const childPad = `${pad} `; + const dumped = dumpYamlBlock(item, indent + 1); + // Indent the first line with the dash, subsequent lines stay aligned. + const [first, ...rest] = dumped.split('\n'); + lines.push(`${childPad}- ${first.trimStart()}`); + for (const r of rest) lines.push(r); + } else { + lines.push(`${pad} - ${scalarToYaml(item)}`); + } + } + continue; + } + if (typeof v === 'object') { + if (Object.keys(v).length === 0) { + lines.push(`${pad}${k}: {}`); + continue; + } + lines.push(`${pad}${k}:`); + lines.push(dumpYamlBlock(v, indent + 1)); + continue; + } + if (typeof v === 'string' && v.includes('\n')) { + lines.push(`${pad}${k}: |`); + for (const line of v.split('\n')) lines.push(`${pad} ${line}`); + continue; + } + lines.push(`${pad}${k}: ${scalarToYaml(v)}`); + } + return lines.join('\n') + (lines.length ? '\n' : ''); +} + +function scalarToYaml(v) { + if (typeof v === 'boolean' || typeof v === 'number') return String(v); + if (typeof v !== 'string') return JSON.stringify(v); + if (v === '') return '""'; + if (RESERVED_WORDS.has(v)) return JSON.stringify(v); + if (STARTS_WITH_NUMBER.test(v)) return JSON.stringify(v); + if (NEEDS_QUOTING.test(v) || /^\s|\s$/.test(v)) return JSON.stringify(v); + return v; +} diff --git a/plugins/antianqi/skill-bridge/lib/detect.js b/plugins/antianqi/skill-bridge/lib/detect.js index 6e28556..c656564 100644 --- a/plugins/antianqi/skill-bridge/lib/detect.js +++ b/plugins/antianqi/skill-bridge/lib/detect.js @@ -1,104 +1,135 @@ -// lib/detect.js — Encoding detection (GBK vs UTF-8) and mojibake recovery. -// -// Strategy: -// 1. Try strict UTF-8 decode; if it succeeds, the file is UTF-8. -// 2. Try strict GB18030 decode (Node 22+ ships this in `TextDecoder`); -// if it produces CJK printable text, the source was GBK and we have -// the restored UTF-8. -// 3. Otherwise: declare unknown, do not modify. -// -// We deliberately avoid chardet-style heuristics because guessing wrong -// silently corrupts skill text. -// -// GB18030 is a strict superset of GBK and GB2312, so a "gbk" byte stream -// round-trips through `TextDecoder('gb18030')` losslessly in practice. - -import fs from 'node:fs/promises'; - -const REPLACEMENT = '\uFFFD'; -const PRINTABLE_CJK = /[\u3400-\u9FFF]/; -const NON_ASCII_PRINTABLE = /[^\x00-\x7F]/; - -/** - * @typedef {Object} DetectResult - * @property {'utf-8'|'gbk'|'unknown'} encoding - * @property {string} text - * @property {string} originalEncoding - * @property {boolean} replaced - * @property {number} confidence 0..1 - * @property {string} reason - */ - -/** - * Detect the encoding of a Buffer and return UTF-8 text. - * @param {Buffer} buf - * @returns {DetectResult} - */ -export function detectEncoding(buf) { - // 1. Strict UTF-8 - try { - const text = new TextDecoder('utf-8', { fatal: true }).decode(buf); - const hasNonAscii = NON_ASCII_PRINTABLE.test(text); - return { - encoding: 'utf-8', - text, - originalEncoding: 'utf-8', - replaced: false, - confidence: hasNonAscii ? 0.95 : 0.8, - reason: 'utf-8 decode clean', - }; - } catch { - /* fall through to GBK */ - } - - // 2. GBK / GB18030 (built-in TextDecoder since Node 18) - try { - const text = new TextDecoder('gb18030', { fatal: true }).decode(buf); - if (!text.includes(REPLACEMENT) && PRINTABLE_CJK.test(text)) { - return { - encoding: 'gbk', - text, - originalEncoding: 'gbk', - replaced: true, - confidence: 0.9, - reason: 'gb18030 decode clean and contains CJK', - }; - } - } catch { - /* not valid gb18030 either */ - } - - // 3. Last resort: lossy UTF-8, marked unknown so caller can warn. - const text = new TextDecoder('utf-8').decode(buf); - return { - encoding: 'unknown', - text, - originalEncoding: 'unknown', - replaced: false, - confidence: 0.1, - reason: 'could not determine; left as lossy utf-8', - }; -} - -/** - * Read a file from disk and return its detected encoding + UTF-8 text. - * @param {string} filePath - * @returns {Promise} - */ -export async function readFileSafe(filePath) { - const buf = await fs.readFile(filePath); - return detectEncoding(buf); -} - -/** - * Heuristic: does the given UTF-8 text LOOK like GBK mojibake that was - * already partially normalized? Useful when the file on disk is a mess - * of replacement characters and there is no clean byte stream to - * recover from. - * - * @param {string} text - * @returns {boolean} - */ -export function isLikelyGbkMojibake(text) { - return /\uFFFD{2,}/.test(text) || /\?{3,}/.test(text); -} +// lib/detect.js — Encoding detection (GBK vs UTF-8) and mojibake recovery. +// +// Strategy: +// 1. Try strict UTF-8 decode; if it succeeds, the file is UTF-8. +// 2. Try strict GB18030 decode (Node 22+ ships this in `TextDecoder`); +// if it produces CJK printable text, the source was GBK and we have +// the restored UTF-8. +// 3. Otherwise: declare unknown, do not modify. +// +// We deliberately avoid chardet-style heuristics because guessing wrong +// silently corrupts skill text. +// +// GB18030 is a strict superset of GBK and GB2312, so a "gbk" byte stream +// round-trips through `TextDecoder('gb18030')` losslessly in practice. + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const REPLACEMENT = '\uFFFD'; +const PRINTABLE_CJK = /[\u3400-\u9FFF]/; +const NON_ASCII_PRINTABLE = /[^\x00-\x7F]/; + +/** + * @typedef {Object} DetectResult + * @property {'utf-8'|'gbk'|'unknown'} encoding + * @property {string} text + * @property {string} originalEncoding + * @property {boolean} replaced + * @property {number} confidence 0..1 + * @property {string} reason + */ + +/** + * Detect the encoding of a Buffer and return UTF-8 text. + * @param {Buffer} buf + * @returns {DetectResult} + */ +export function detectEncoding(buf) { + // 1. Strict UTF-8 + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(buf); + const hasNonAscii = NON_ASCII_PRINTABLE.test(text); + return { + encoding: 'utf-8', + text, + originalEncoding: 'utf-8', + replaced: false, + confidence: hasNonAscii ? 0.95 : 0.8, + reason: 'utf-8 decode clean', + }; + } catch { + /* fall through to GBK */ + } + + // 2. GBK / GB18030 (built-in TextDecoder since Node 18) + try { + const text = new TextDecoder('gb18030', { fatal: true }).decode(buf); + if (!text.includes(REPLACEMENT) && PRINTABLE_CJK.test(text)) { + return { + encoding: 'gbk', + text, + originalEncoding: 'gbk', + replaced: true, + confidence: 0.9, + reason: 'gb18030 decode clean and contains CJK', + }; + } + } catch { + /* not valid gb18030 either */ + } + + // 3. Last resort: lossy UTF-8, marked unknown so caller can warn. + const text = new TextDecoder('utf-8').decode(buf); + return { + encoding: 'unknown', + text, + originalEncoding: 'unknown', + replaced: false, + confidence: 0.1, + reason: 'could not determine; left as lossy utf-8', + }; +} + +/** + * Resolve a skill source: if filePath is a directory, look for a + * `SKILL.md` inside it. If filePath is already a file, return it. + * Throws a descriptive error if neither is found. + * + * @param {string} filePath + * @returns {Promise} the resolved file path + */ +export async function resolveSkillSource(filePath) { + const stat = await fs.stat(filePath); + if (stat.isFile()) return filePath; + if (stat.isDirectory()) { + const nested = path.join(filePath, 'SKILL.md'); + try { + const nestedStat = await fs.stat(nested); + if (nestedStat.isFile()) return nested; + } catch { + // fall through to the error below + } + throw new Error( + `source is a directory but contains no SKILL.md: ${filePath} (looked for ${nested})` + ); + } + throw new Error(`source is neither file nor directory: ${filePath}`); +} + +/** + * Read a file from disk and return its detected encoding + UTF-8 text. + * Accepts either a SKILL.md file path or a directory containing one + * (delegates to resolveSkillSource). + * + * @param {string} filePath + * @returns {Promise} + */ +export async function readFileSafe(filePath) { + const resolved = await resolveSkillSource(filePath); + const buf = await fs.readFile(resolved); + return detectEncoding(buf); +} + +/** + * Heuristic: does the given UTF-8 text LOOK like GBK mojibake that was + * already partially normalized? Useful when the file on disk is a mess + * of replacement characters and there is no clean byte stream to + * recover from. + * + * @param {string} text + * @returns {boolean} + */ +export function isLikelyGbkMojibake(text) { + return /\uFFFD{2,}/.test(text) || /\?{3,}/.test(text); +} diff --git a/plugins/antianqi/skill-bridge/tests/detect.test.mjs b/plugins/antianqi/skill-bridge/tests/detect.test.mjs index 3344e26..d742ee3 100644 --- a/plugins/antianqi/skill-bridge/tests/detect.test.mjs +++ b/plugins/antianqi/skill-bridge/tests/detect.test.mjs @@ -1,90 +1,126 @@ -// tests/detect.test.mjs -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { detectEncoding, isLikelyGbkMojibake } from '../lib/detect.js'; - -// Minimal GBK encoder for tests. We do NOT want a production dependency -// on iconv-lite (the whole point of v0.2 is to ship with zero npm deps), -// and we do NOT want to round-trip through the Node TextDecoder in tests -// (the decoder would be exercising the very code path we are testing). -// -// The table below covers the characters used in this test file and the -// "Short Chinese string" corpus. Adding a new test that needs different -// characters means adding entries here. -const GBK_TABLE = { - '短': [0xB6, 0xCC], - '剧': [0xBE, 0xE7], - '生': [0xC9, 0xFA], - '成': [0xB3, 0xC9], - '工': [0xB9, 0xA4], - '作': [0xD7, 0xF7], - '流': [0xC1, 0xF7], - '中': [0xD6, 0xD0], - '文': [0xCE, 0xC4], - '段': [0xB6, 0xCE], - '落': [0xC2, 0xD4], - '正': [0xD5, 0xFD], - '常': [0xB3, 0xA3], - '世': [0xCA, 0xC0], - '界': [0xBD, 0xE7], - '你': [0xC4, 0xE3], - '好': [0xBA, 0xC3], - '再': [0xD4, 0xD9], - '见': [0xBC, 0xFB], -}; - -function encodeGbk(str) { - const out = []; - for (const ch of str) { - const code = ch.codePointAt(0); - if (code < 0x80) { - out.push(code); - } else { - const bytes = GBK_TABLE[ch]; - if (!bytes) throw new Error(`test corpus missing GBK entry for ${JSON.stringify(ch)}`); - out.push(bytes[0], bytes[1]); - } - } - return Buffer.from(out); -} - -test('UTF-8 clean ASCII', () => { - const r = detectEncoding(Buffer.from('hello world', 'utf-8')); - assert.equal(r.encoding, 'utf-8'); - assert.equal(r.replaced, false); - assert.equal(r.text, 'hello world'); -}); - -test('UTF-8 clean Chinese', () => { - const r = detectEncoding(Buffer.from('你好世界', 'utf-8')); - assert.equal(r.encoding, 'utf-8'); - assert.equal(r.text, '你好世界'); -}); - -test('GBK round-trip is detected as gbk', () => { - const original = '短剧生成工作流'; - const buf = encodeGbk(original); - const r = detectEncoding(buf); - assert.equal(r.encoding, 'gbk'); - assert.equal(r.replaced, true); - assert.equal(r.text, original); -}); - -test('Unknown bytes fall through to lossy utf-8', () => { - // Random binary that is neither valid UTF-8 nor valid GBK CJK. - // 0xff 0xfe is a UTF-16 LE BOM; the strict UTF-8 decoder will reject. - // The bytes below are not part of any GBK lead/continuation pair either, - // so the GBK decoder will also reject. We expect "unknown" (the - // lossy-utf-8 fallback). - const buf = Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90, 0xa0, 0xb0]); - const r = detectEncoding(buf); - // We accept either "unknown" or "gbk" because the heuristic is - // intentionally loose; what we care about is that the text is not - // silently treated as clean utf-8. - assert.ok(['unknown', 'gbk'].includes(r.encoding), 'should not falsely claim clean utf-8'); -}); - -test('isLikelyGbkMojibake detects U+FFFD cluster', () => { - assert.equal(isLikelyGbkMojibake('xxx ���� xxx'), true); - assert.equal(isLikelyGbkMojibake('正常中文'), false); -}); +// tests/detect.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { detectEncoding, isLikelyGbkMojibake, readFileSafe, resolveSkillSource } from '../lib/detect.js'; + +// Minimal GBK encoder for tests. We do NOT want a production dependency +// on iconv-lite (the whole point of v0.2 is to ship with zero npm deps), +// and we do NOT want to round-trip through the Node TextDecoder in tests +// (the decoder would be exercising the very code path we are testing). +// +// The table below covers the characters used in this test file and the +// "Short Chinese string" corpus. Adding a new test that needs different +// characters means adding entries here. +const GBK_TABLE = { + '短': [0xB6, 0xCC], + '剧': [0xBE, 0xE7], + '生': [0xC9, 0xFA], + '成': [0xB3, 0xC9], + '工': [0xB9, 0xA4], + '作': [0xD7, 0xF7], + '流': [0xC1, 0xF7], + '中': [0xD6, 0xD0], + '文': [0xCE, 0xC4], + '段': [0xB6, 0xCE], + '落': [0xC2, 0xD4], + '正': [0xD5, 0xFD], + '常': [0xB3, 0xA3], + '世': [0xCA, 0xC0], + '界': [0xBD, 0xE7], + '你': [0xC4, 0xE3], + '好': [0xBA, 0xC3], + '再': [0xD4, 0xD9], + '见': [0xBC, 0xFB], +}; + +function encodeGbk(str) { + const out = []; + for (const ch of str) { + const code = ch.codePointAt(0); + if (code < 0x80) { + out.push(code); + } else { + const bytes = GBK_TABLE[ch]; + if (!bytes) throw new Error(`test corpus missing GBK entry for ${JSON.stringify(ch)}`); + out.push(bytes[0], bytes[1]); + } + } + return Buffer.from(out); +} + +test('UTF-8 clean ASCII', () => { + const r = detectEncoding(Buffer.from('hello world', 'utf-8')); + assert.equal(r.encoding, 'utf-8'); + assert.equal(r.replaced, false); + assert.equal(r.text, 'hello world'); +}); + +test('UTF-8 clean Chinese', () => { + const r = detectEncoding(Buffer.from('你好世界', 'utf-8')); + assert.equal(r.encoding, 'utf-8'); + assert.equal(r.text, '你好世界'); +}); + +test('GBK round-trip is detected as gbk', () => { + const original = '短剧生成工作流'; + const buf = encodeGbk(original); + const r = detectEncoding(buf); + assert.equal(r.encoding, 'gbk'); + assert.equal(r.replaced, true); + assert.equal(r.text, original); +}); + +test('Unknown bytes fall through to lossy utf-8', () => { + // Random binary that is neither valid UTF-8 nor valid GBK CJK. + // 0xff 0xfe is a UTF-16 LE BOM; the strict UTF-8 decoder will reject. + // The bytes below are not part of any GBK lead/continuation pair either, + // so the GBK decoder will also reject. We expect "unknown" (the + // lossy-utf-8 fallback). + const buf = Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90, 0xa0, 0xb0]); + const r = detectEncoding(buf); + // We accept either "unknown" or "gbk" because the heuristic is + // intentionally loose; what we care about is that the text is not + // silently treated as clean utf-8. + assert.ok(['unknown', 'gbk'].includes(r.encoding), 'should not falsely claim clean utf-8'); +}); + +test('isLikelyGbkMojibake detects U+FFFD cluster', () => { + assert.equal(isLikelyGbkMojibake('xxx ���� xxx'), true); + assert.equal(isLikelyGbkMojibake('正常中文'), false); +}); + +test('readFileSafe accepts a directory containing SKILL.md', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-detect-')); + try { + await fs.writeFile(path.join(dir, 'SKILL.md'), '# hello\n', 'utf-8'); + const r = await readFileSafe(dir); + assert.equal(r.encoding, 'utf-8'); + assert.equal(r.text, '# hello\n'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveSkillSource throws when directory has no SKILL.md', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-detect-')); + try { + await assert.rejects(resolveSkillSource(dir), /no SKILL\.md/); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveSkillSource returns the file path unchanged for a file', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-detect-')); + try { + const f = path.join(dir, 'a.md'); + await fs.writeFile(f, 'hi', 'utf-8'); + const resolved = await resolveSkillSource(f); + assert.equal(resolved, f); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From 0fbedd7df3f2576727d088b39f971673a82a25b4 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:03:17 +0800 Subject: [PATCH 3/5] fix: always spawn the linter as a child process (review #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation had a "fast path" that did `await import(lintScript).then(mod => mod.lint(skillPath))` in-process. The default host linter at `~/.minimax/.builtin-skills/skill-creator/scripts/lint-skill.js` calls `process.exit(2)` when invoked without CLI arguments, and `process.exit` is not catchable from JS — so a default invocation (no `run_lint=false` override) terminated the entire MCP server before it could return a JSON-RPC response. - `lib/lint.js`: drop the in-process fast path; always run the linter as a child process. Cost: one extra `node` spawn + a staged `.mjs` in `os.tmpdir()` per `convert` call (~100 ms). The trade is worth it: the MCP server is now guaranteed to survive a misbehaving linter. - `lib/lint.js`: pre-flight `fs.stat(lintScript)` so a missing host linter surfaces as `{ ok: false, code: -1, stderr: 'lint script not available: ...' }` instead of an uncaught ENOENT from `fs.readFile` inside `stageMjsInTmp`. - `tests/lint.test.mjs`: rewrite around the subprocess-only model. Replace the fast-path test with three cases: - subprocess path stages in `os.tmpdir()`, install dir untouched - linter calls `process.exit(2)` and the MCP server still returns `{ ok: false, code: 2 }` - missing lintScript returns `{ ok: false, code: -1, stderr }` `node --test plugins/antianqi/skill-bridge/tests/*.test.mjs` reports 54/54 pass (was 53/53; +1 new case for missing linter). --- plugins/antianqi/skill-bridge/lib/lint.js | 69 +++++++++---------- .../antianqi/skill-bridge/tests/lint.test.mjs | 68 +++++++++--------- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/plugins/antianqi/skill-bridge/lib/lint.js b/plugins/antianqi/skill-bridge/lib/lint.js index 196e257..48b29f1 100644 --- a/plugins/antianqi/skill-bridge/lib/lint.js +++ b/plugins/antianqi/skill-bridge/lib/lint.js @@ -1,31 +1,29 @@ // lib/lint.js — Wrap the mavis skill-creator lint script. // -// The official `lint-skill.js` ships as ES module source but is named -// with a `.js` extension and is not under a package.json with -// `"type": "module"`. Spawning `node` on it directly fails with a -// confusing SyntaxError. We avoid the problem in one of two ways: +// The host linter ships at +// `~/.minimax/.builtin-skills/skill-creator/scripts/lint-skill.js`. // -// - Fast path: dynamic import the script in-process. Works for CJS -// modules (we read `mod.lint` and `mod.default.lint`) and for any -// script that already exposes a `lint(skillPath)` function. -// - Subprocess path: copy the source to a unique temp `.mjs` and run -// it with `node`. The temp dir is created in `os.tmpdir()` and is -// always removed, even on early return. +// v0.2 strategy: always spawn the linter as a child process. We avoid +// the `import(lintScript).then(mod => mod.lint(skillPath))` path +// because the linter's default behaviour, when invoked without CLI +// arguments, is to call `process.exit(2)`. `process.exit` is not +// catchable from JS, so an in-process call would kill the MCP server +// before it could return a JSON-RPC response. // -// CRITICAL: the temp dir MUST live under `os.tmpdir()`, NEVER under +// The temp dir MUST live under `os.tmpdir()`, NEVER under // `~/.minimax/.builtin-skills/` or any user-install path. v0.1 was -// racy here; v0.2 forces a unique `sb-lint--` directory. +// racy here; v0.2.1 forces a unique `sb-lint--` directory +// and removes it on every exit path. // // The return shape `{ ok, code, stdout, stderr }` is the failure // contract. The caller (the MCP server, the CLI, or a test) decides -// what to do with `ok === false`. LintSkill itself does not exit the +// what to do with `ok === false`. lintSkill itself does not exit the // process. import { spawn } from 'node:child_process'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; import crypto from 'node:crypto'; async function stageMjsInTmp(lintScript) { @@ -37,38 +35,35 @@ async function stageMjsInTmp(lintScript) { } /** - * @param {string} skillPath + * Run the host skill linter in a child process. + * + * @param {string} skillPath absolute path to a SKILL.md file * @param {object} [opts] - * @param {string} [opts.lintScript] + * @param {string} [opts.lintScript] override the default linter path * @returns {Promise<{ ok: boolean, code: number, stdout: string, stderr: string }>} */ export async function lintSkill(skillPath, opts = {}) { const lintScript = opts.lintScript || path.join(os.homedir(), '.minimax', '.builtin-skills', 'skill-creator', 'scripts', 'lint-skill.js'); - // Fast path: dynamic import in-process. No files written. - // Handle ESM (`export function lint`) and CJS interop - // (`module.exports.lint` appears at `mod.default.lint`). + // If the host linter is not installed, surface that as a lint failure + // (not an exception) so the MCP server can return a clean error to + // the caller. This also keeps stageMjsInTmp from throwing ENOENT. try { - const mod = await import(pathToFileURL(lintScript).href); - const fn = typeof mod.lint === 'function' - ? mod.lint - : (mod.default && typeof mod.default.lint === 'function' ? mod.default.lint : null); - if (fn) { - const result = await fn(skillPath); - return { - ok: result.ok === true, - code: typeof result.code === 'number' ? result.code : (result.ok ? 0 : 1), - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - }; - } - } catch { - // Fall through to subprocess path + await fs.stat(lintScript); + } catch (statErr) { + return { + ok: false, + code: -1, + stdout: '', + stderr: `lint script not available: ${lintScript} (${statErr.code || statErr.message})`, + }; } - // Subprocess path: stage as .mjs in a unique temp dir, then run. - // The temp dir is always removed, regardless of how the subprocess exits. + // Always spawn a child process. A `process.exit(2)` from the linter's + // default CLI mode would otherwise kill the MCP server. Subprocess + // isolation keeps the JSON-RPC transport alive even when the linter + // is misconfigured. const { dir, mjs } = await stageMjsInTmp(lintScript); try { return await new Promise((resolve) => { @@ -96,7 +91,7 @@ export async function lintSkill(skillPath, opts = {}) { }); }); } finally { - // Always clean up the staged dir. + // Always clean up the staged dir, even on early return. await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); } } diff --git a/plugins/antianqi/skill-bridge/tests/lint.test.mjs b/plugins/antianqi/skill-bridge/tests/lint.test.mjs index 4bd48b1..53051d7 100644 --- a/plugins/antianqi/skill-bridge/tests/lint.test.mjs +++ b/plugins/antianqi/skill-bridge/tests/lint.test.mjs @@ -3,11 +3,12 @@ // 1. `lib/lint.js` MUST NOT write a staged `.mjs` next to // `~/.minimax/.builtin-skills/skill-creator/scripts/lint-skill.js`. // That's the user's install area; polluting it is rude and racy. -// // 2. The temp dir we DO write to must be removed on every code path. -// -// 3. The fast path (in-process dynamic import) must also surface a -// failed lint result faithfully, without touching the install dir. +// 3. The linter's `process.exit(2)` (default CLI mode when invoked +// without arguments) must NOT kill the MCP server. We always +// spawn a child process so a stray `process.exit` is contained. +// 4. A missing or unspawn-able lintScript must surface a +// `ok: false` result, not throw to the caller. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -16,28 +17,22 @@ import path from 'node:path'; import os from 'node:os'; import { lintSkill } from '../lib/lint.js'; -// A lint script that does NOT export a `lint` function, forcing the -// subprocess path. Pure CJS, no ESM `import` syntax, so the fast path's -// `import()` of the .js file succeeds and returns an empty module -// (`mod.lint` undefined → fall through). When staged to a .mjs and run -// by node, the same `console.log` works fine in ESM mode. +// A lint script that does NOT export a `lint` function. The default +// `~/.minimax/.../lint-skill.js` is shipped that way; this fixture +// matches the production shape so the subprocess path is exercised +// end-to-end. const FAULT_FREE_LINT = ` console.log('lint ok for ' + process.argv[2]); `; -// A lint script that DOES export a `lint` function (CJS). This drives -// the fast path in-process, returning a failing result without spawning -// a subprocess. Used to verify the install dir is not touched on the -// failure path either. -const FAILING_FAST_LINT = ` -module.exports = { - lint: (p) => ({ ok: false, code: 2, stdout: 'lint failed for ' + p, stderr: '' }), -}; +// A lint script that calls process.exit(2) on the first line. This +// proves the subprocess path keeps the MCP server alive even when +// the linter itself terminates hard. +const EXIT_2_LINT = ` +process.exit(2); `; async function writeLintScript(content) { - // This directory stands in for ~/.minimax/.builtin-skills/... in real use. - // We never let lintSkill write into it — that's the whole point of this test. const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-lint-fixture-')); const lintScript = path.join(dir, 'lint-skill.js'); await fs.writeFile(lintScript, content, 'utf-8'); @@ -65,7 +60,6 @@ test('lintSkill (subprocess path) stages the .mjs in os.tmpdir() — install dir const r = await lintSkill(skillPath, { lintScript }); assert.equal(r.ok, true, `expected ok, stderr was:\n${r.stderr}`); assert.ok(/lint ok/.test(r.stdout), `stdout: ${r.stdout}`); - // Install dir must contain only lint-skill.js, never a staged .mjs. const siblings = await fs.readdir(dir); assert.ok( !siblings.some((f) => f.endsWith('.mjs')), @@ -78,25 +72,35 @@ test('lintSkill (subprocess path) stages the .mjs in os.tmpdir() — install dir await assertNoLeftoverStagingInTmp(); }); -test('lintSkill (fast path) returns the lint-script failure faithfully without touching disk', async () => { - const { dir, lintScript } = await writeLintScript(FAILING_FAST_LINT); +test('lintSkill survives a linter that calls process.exit(2)', async () => { + // This is the exact failure mode the review called out: a default + // linter invocation (no CLI args) calls process.exit(2). An + // in-process import would kill the MCP server; the subprocess + // path survives. + const { dir, lintScript } = await writeLintScript(EXIT_2_LINT); let skillPath; try { skillPath = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-lint-target-')); const r = await lintSkill(skillPath, { lintScript }); - assert.equal(r.ok, false, 'expected ok=false on lint failure'); - assert.equal(r.code, 2, `expected exit code 2, got ${r.code}`); - assert.ok(/lint failed/.test(r.stdout), `stdout: ${r.stdout}`); - - // No temp staging dir should have been created — fast path never - // touches disk, and there is no subprocess to spawn. - const tmpRoot = os.tmpdir(); - const entries = await fs.readdir(tmpRoot); - const leftover = entries.filter((e) => e.startsWith(`sb-lint-${process.pid}-`)); - assert.equal(leftover.length, 0, `fast path should not stage anything; got: ${leftover.join(', ')}`); + assert.equal(r.ok, false, 'expected ok=false when linter exits 2'); + assert.equal(r.code, 2, `expected code 2, got ${r.code}`); } finally { await fs.rm(dir, { recursive: true, force: true }); if (skillPath) await fs.rm(skillPath, { recursive: true, force: true }); } + await assertNoLeftoverStagingInTmp(); }); +test('lintSkill returns ok=false when lintScript does not exist', async () => { + const skillPath = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-lint-target-')); + try { + const r = await lintSkill(skillPath, { + lintScript: path.join(os.tmpdir(), `does-not-exist-${process.pid}.js`), + }); + assert.equal(r.ok, false, 'expected ok=false for missing lintScript'); + assert.ok(/ENOENT|no such file/i.test(r.stderr), `unexpected stderr: ${r.stderr}`); + } finally { + await fs.rm(skillPath, { recursive: true, force: true }); + } + await assertNoLeftoverStagingInTmp(); +}); From 5b9166c21686d30d101214816e0ddd1758db6363 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:04:40 +0800 Subject: [PATCH 4/5] fix: narrow the atomic-replace guarantee and propagate recovery errors (review #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review called out a missing-target window in `atomicReplace`: between the `outDir -> backup` rename and the `staging -> outDir` rename, outDir is absent. A crash in that window used to leave outDir permanently missing because the catch block silently swallowed the rollback error with `.catch(() => {})`. - `lib/transform-skill.js`: export `atomicReplace` and add two test-only hooks (`opts.rename`, `opts.renameStaging`) so deterministic fault-injection tests can exercise the swap and rollback branches without monkey-patching `fs`. In the catch block, attach `err.recovery = { message, cause }` when the rollback itself fails, so the caller can take manual action instead of being told "outDir is missing" with no breadcrumb. - `tests/transform-atomic.test.mjs`: two new cases. - "staging -> outDir rename fails" — original outDir is restored from the backup, no stray `.bak-*` is left behind. - "swap fails AND rollback fails" — the thrown error has a `.recovery` field whose message names the backup path so the caller can manually move it back. `node --test plugins/antianqi/skill-bridge/tests/*.test.mjs` reports 56/56 pass (was 54/54; +2 new atomic-replace cases). --- .../skill-bridge/lib/transform-skill.js | 674 +++++++++--------- .../tests/transform-atomic.test.mjs | 334 +++++---- 2 files changed, 565 insertions(+), 443 deletions(-) diff --git a/plugins/antianqi/skill-bridge/lib/transform-skill.js b/plugins/antianqi/skill-bridge/lib/transform-skill.js index a4f61ad..d2fa8b4 100644 --- a/plugins/antianqi/skill-bridge/lib/transform-skill.js +++ b/plugins/antianqi/skill-bridge/lib/transform-skill.js @@ -1,323 +1,351 @@ -// lib/transform-skill.js — Generate a mavis-compatible SKILL.md from -// an analyzed openclaw SKILL.md. -// -// Output contract: -// / -// SKILL.md # mavis schema, with enriched frontmatter -// conversion-report.md # what we changed and why -// references/.md # (optional) split from body if too long -// -// Atomicity: -// Writes happen in a sibling staging directory first -// (`.staging-`), then we use a backup-rename dance to -// move it onto outDir atomically. At every observable point in time, -// outDir either points at the OLD content or the NEW content — never -// empty, never half-written. This makes `--force` safe and prevents -// the "old references/ leak into new output" bug that bit v0.1. - -import fs from 'node:fs/promises'; -import path from 'node:path'; -import crypto from 'node:crypto'; -import { parameterizePaths, suggestFilename } from './paths.js'; -import { parseFrontmatter, dumpYamlBlock } from './analyze.js'; - -const MAX_BODY_LINES = 500; - -function kebab(name) { - return String(name) - .toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 64) || 'unnamed-skill'; -} - -const TRIGGER_RE = /(".*?")|(\bwhen\b)|(\btrigger\b)|(\buse this\b)|(\bload this\b)|(\buse when\b)/i; -const TRIGGER_PHRASES = [ - 'Use when the user asks to', - 'Use when: ', - 'Use this skill when', -]; - -function extractChineseSummary(body) { - const blocks = body.split(/\r?\n\r?\n/); - for (const p of blocks) { - const t = p.trim(); - if (!t) continue; - if (/^#+\s/.test(t)) continue; - if (/^```/.test(t)) continue; - if (/^[-*+]\s/.test(t)) continue; - if (!/[\u3400-\u9FFF]/.test(t)) continue; - return t.replace(/\s+/g, ' ').slice(0, 200); - } - return null; -} - -function extractDisplayNameZh(frontmatter, body) { - if (frontmatter.name && /[\u3400-\u9FFF]/.test(frontmatter.name)) { - return String(frontmatter.name).trim(); - } - const h1 = body.match(/^#\s+(.+)$/m); - if (h1) return h1[1].trim().slice(0, 32); - return null; -} - -function enrichFrontmatter(original, body, classifyResult, targetName) { - const fm = { ...original }; - const name = targetName || kebab(fm.name || 'unnamed-skill'); - fm.name = name; - - let desc = typeof fm.description === 'string' ? fm.description : (fm.description || ''); - desc = desc.replace(/\s+/g, ' ').trim(); - if (!desc) { - const para = body.split(/\r?\n\r?\n/)[0] || ''; - desc = para.replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200); - } - if (!TRIGGER_RE.test(desc)) { - desc = `${TRIGGER_PHRASES[1]}${desc}`; - } - if (!desc.endsWith('.')) desc += '.'; - fm.description = desc; - - const zhSummary = extractChineseSummary(body); - const displayZh = extractDisplayNameZh(original, body); - if (zhSummary) { - fm.descriptions = fm.descriptions || {}; - fm.descriptions['zh-Hans'] = zhSummary; - } - if (displayZh) { - fm.displayNames = fm.displayNames || {}; - fm.displayNames['zh-Hans'] = displayZh; - } - - fm.metadata = fm.metadata || {}; - fm.metadata['openclaw_compat'] = true; - fm.metadata['skill-bridge'] = { - classify_tier: classifyResult.tier, - classify_subtier: classifyResult.subTier, - classify_reason: classifyResult.reason, - }; - - return fm; -} - -function addOutputContractSection(body) { - if (/^##\s+Output contract/m.test(body)) return body; - return body.trimEnd() + '\n\n## Output contract\n\nThis skill does not produce files by itself; the converted openclaw skill should declare its outputs in a new section here. (Filled in by the user after first run.)\n'; -} - -function addFailureHandlingSection(body) { - if (/^##\s+Failure handling/m.test(body)) return body; - return body.trimEnd() + '\n\n## Failure handling\n\nIf a required external tool or path is missing, surface the exact missing identifier to the user instead of guessing. Do not auto-install system packages. (Add skill-specific failure modes here.)\n'; -} - -function addWindowsNotesSection(body, hasShell) { - if (!hasShell) return body; - if (/^##\s+Windows \(win32\) platform notes/m.test(body)) return body; - return body.trimEnd() + '\n\n## Windows (win32) platform notes\n\nThe original openclaw skill assumed macOS/Linux shell. The PowerShell equivalents for any `bash`/`pip`/`python3` calls should be documented here. (Generated by skill-bridge; user to verify.)\n'; -} - -function addReferencesIndex(body, references) { - if (!references || references.length === 0) return body; - if (/^##\s+References\b/m.test(body)) return body; - const items = references - .map((r) => `- [\`${r.file}\`](references/${r.file})`) - .join('\n'); - return ( - body.trimEnd() + - '\n\n## References\n\nDetailed content moved out of this SKILL.md for size. Read these when the main flow above references them:\n\n' + - items + - '\n' - ); -} - -function maybeSplitReferences(name, body) { - const lines = body.split(/\r?\n/); - if (lines.length <= MAX_BODY_LINES) return { body, references: [] }; - - const sections = []; - let intro = []; - let current = null; - for (const line of lines) { - if (/^##\s+/.test(line)) { - if (current) sections.push(current); - else if (intro.length) sections.push({ heading: '__intro__', lines: intro }); - current = { heading: line, lines: [line] }; - } else if (current) { - current.lines.push(line); - } else { - intro.push(line); - } - } - if (current) sections.push(current); - else if (intro.length) sections.push({ heading: '__intro__', lines: intro }); - - if (sections.length < 3) return { body, references: [] }; - - const keep = sections.slice(0, 2).map(s => s.lines.join('\n')).join('\n\n'); - const moved = sections.slice(2); - const references = moved.map(s => { - const slug = s.heading - .replace(/^##\s+/, '') - .replace(/[^\w\u3400-\u9FFF-]+/g, '-') - .replace(/^-+|-+$/g, '') - .toLowerCase() - .slice(0, 64) || 'section'; - return { - file: `${slug}.md`, - content: s.lines.join('\n'), - }; - }); - return { body: keep.trimEnd() + '\n', references }; -} - -/** - * Atomic directory replace using a backup-and-rename dance. - * - * At any observable point in time, outDir is either the OLD content or - * the NEW content. There is no window where outDir is missing or - * half-written. The staging directory is always cleaned up. - * - * @param {string} staging The directory holding the new content. - * @param {string} outDir The destination to replace. - */ -async function atomicReplace(staging, outDir) { - const backup = `${outDir}.bak-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; - let backupCreated = false; - try { - const exists = await fs.stat(outDir).catch(() => null); - if (exists) { - // Move the existing outDir out of the way. fs.rename is atomic on - // the same volume and never returns a partially-moved directory. - await fs.rename(outDir, backup); - backupCreated = true; - } - // Move staging into place. - await fs.rename(staging, outDir); - // OutDir is now the new content. Drop the backup. - if (backupCreated) { - await fs.rm(backup, { recursive: true, force: true }); - backupCreated = false; - } - } catch (err) { - // Recovery: if we created a backup but the final rename failed, - // restore the backup so the caller still sees the old outDir. - if (backupCreated) { - const backupExists = await fs.stat(backup).catch(() => null); - if (backupExists) { - await fs.rename(backup, outDir).catch(() => {}); - } - } - throw err; - } finally { - if (backupCreated) { - await fs.rm(backup, { recursive: true, force: true }).catch(() => {}); - } - // Staging should already be gone (renamed onto outDir). If it - // somehow remains, clean it up. - await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); - } -} - -/** - * @param {object} args - * @param {string} args.inputPath - * @param {import('./analyze.js').AnalyzedSkill} args.report - * @param {import('./classify.js').ClassifyResult} args.classify - * @param {string} args.outDir - * @returns {Promise<{ written: string[], warnings: string[] }>} - */ -export async function transformSkill({ inputPath, report, classify, outDir }) { - const warnings = []; - const written = []; - - // 1. Parameterize paths in body - const { text: bodyAfterPaths, changes: pathChanges } = parameterizePaths(report.body); - if (pathChanges.length > 0) { - warnings.push(`paths parameterized: ${pathChanges.map(c => c.id).join(', ')}`); - } - - // 2. Detect shell-style commands to decide if Windows notes are needed - const hasShell = /\b(pip|python3?|curl|wget|bash|cli-anything-)/.test(bodyAfterPaths); - - // 3. Maybe split into references/ - const { body: bodySplit, references } = maybeSplitReferences(report.frontmatter.name || '', bodyAfterPaths); - - // 4. Add the missing sections. References index goes BEFORE - // Output contract / Failure handling / Windows notes so the moved-out - // content is reachable from the top of the body, not buried under - // boilerplate at the end. - let finalBody = bodySplit; - finalBody = addReferencesIndex(finalBody, references); - finalBody = addOutputContractSection(finalBody); - finalBody = addFailureHandlingSection(finalBody); - finalBody = addWindowsNotesSection(finalBody, hasShell); - - // 5. Enrich frontmatter (target name = basename of outDir so name matches dir) - const targetName = path.basename(outDir); - const enrichedFm = enrichFrontmatter(report.frontmatter, finalBody, classify, targetName); - - // 6. Serialize - const fmYaml = dumpYamlBlock(enrichedFm); - const skillText = `---\n${fmYaml}---\n\n${finalBody.trimStart()}`; - - // 7. Atomic write: stage everything under a sibling temp dir, then - // swap into outDir via the backup-rename dance. - const stageDir = `${outDir}.staging-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; - try { - await fs.mkdir(stageDir, { recursive: true }); - const skillOut = path.join(stageDir, 'SKILL.md'); - await fs.writeFile(skillOut, skillText, 'utf-8'); - - for (const ref of references) { - const refPath = path.join(stageDir, 'references', ref.file); - await fs.mkdir(path.dirname(refPath), { recursive: true }); - await fs.writeFile(refPath, ref.content.trim() + '\n', 'utf-8'); - } - - const reportMd = renderConversionReport({ inputPath, classify, pathChanges, written: [], warnings }); - const reportPath = path.join(stageDir, 'conversion-report.md'); - await fs.writeFile(reportPath, reportMd, 'utf-8'); - - await atomicReplace(stageDir, outDir); - } catch (err) { - // Make sure staging is gone even if the catch ran mid-write. - await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); - throw err; - } - - // 8. Record the final paths (post-rename) for the caller. - written.push(path.join(outDir, 'SKILL.md')); - for (const ref of references) { - written.push(path.join(outDir, 'references', ref.file)); - } - written.push(path.join(outDir, 'conversion-report.md')); - - return { written, warnings }; -} - -function renderConversionReport({ inputPath, classify, pathChanges, written, warnings }) { - return [ - `# Conversion report`, - ``, - `- **input**: \`${inputPath}\``, - `- **tier**: ${classify.tier} / ${classify.subTier}`, - `- **reason**: ${classify.reason}`, - ``, - `## Path changes`, - pathChanges.length === 0 - ? `_none_` - : pathChanges.map(c => `- \`${c.id}\` → \${${c.placeholder}} (${c.count}x)`).join('\n'), - ``, - `## Written files`, - written.map(f => `- \`${f}\``).join('\n'), - ``, - `## Recommendations`, - classify.recommendations.map(r => `- ${r}`).join('\n'), - ``, - `## Warnings`, - warnings.length === 0 ? `_none_` : warnings.map(w => `- ${w}`).join('\n'), - ``, - `_generated by skill-bridge v0.2.0 on ${new Date().toISOString()}_`, - ``, - ].join('\n'); -} +// lib/transform-skill.js — Generate a mavis-compatible SKILL.md from +// an analyzed openclaw SKILL.md. +// +// Output contract: +// / +// SKILL.md # mavis schema, with enriched frontmatter +// conversion-report.md # what we changed and why +// references/.md # (optional) split from body if too long +// +// Atomicity: +// Writes happen in a sibling staging directory first +// (`.staging-`), then we use a backup-rename dance to +// move it onto outDir atomically. At every observable point in time, +// outDir either points at the OLD content or the NEW content — never +// empty, never half-written. This makes `--force` safe and prevents +// the "old references/ leak into new output" bug that bit v0.1. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { parameterizePaths, suggestFilename } from './paths.js'; +import { parseFrontmatter, dumpYamlBlock } from './analyze.js'; + +const MAX_BODY_LINES = 500; + +function kebab(name) { + return String(name) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) || 'unnamed-skill'; +} + +const TRIGGER_RE = /(".*?")|(\bwhen\b)|(\btrigger\b)|(\buse this\b)|(\bload this\b)|(\buse when\b)/i; +const TRIGGER_PHRASES = [ + 'Use when the user asks to', + 'Use when: ', + 'Use this skill when', +]; + +function extractChineseSummary(body) { + const blocks = body.split(/\r?\n\r?\n/); + for (const p of blocks) { + const t = p.trim(); + if (!t) continue; + if (/^#+\s/.test(t)) continue; + if (/^```/.test(t)) continue; + if (/^[-*+]\s/.test(t)) continue; + if (!/[\u3400-\u9FFF]/.test(t)) continue; + return t.replace(/\s+/g, ' ').slice(0, 200); + } + return null; +} + +function extractDisplayNameZh(frontmatter, body) { + if (frontmatter.name && /[\u3400-\u9FFF]/.test(frontmatter.name)) { + return String(frontmatter.name).trim(); + } + const h1 = body.match(/^#\s+(.+)$/m); + if (h1) return h1[1].trim().slice(0, 32); + return null; +} + +function enrichFrontmatter(original, body, classifyResult, targetName) { + const fm = { ...original }; + const name = targetName || kebab(fm.name || 'unnamed-skill'); + fm.name = name; + + let desc = typeof fm.description === 'string' ? fm.description : (fm.description || ''); + desc = desc.replace(/\s+/g, ' ').trim(); + if (!desc) { + const para = body.split(/\r?\n\r?\n/)[0] || ''; + desc = para.replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200); + } + if (!TRIGGER_RE.test(desc)) { + desc = `${TRIGGER_PHRASES[1]}${desc}`; + } + if (!desc.endsWith('.')) desc += '.'; + fm.description = desc; + + const zhSummary = extractChineseSummary(body); + const displayZh = extractDisplayNameZh(original, body); + if (zhSummary) { + fm.descriptions = fm.descriptions || {}; + fm.descriptions['zh-Hans'] = zhSummary; + } + if (displayZh) { + fm.displayNames = fm.displayNames || {}; + fm.displayNames['zh-Hans'] = displayZh; + } + + fm.metadata = fm.metadata || {}; + fm.metadata['openclaw_compat'] = true; + fm.metadata['skill-bridge'] = { + classify_tier: classifyResult.tier, + classify_subtier: classifyResult.subTier, + classify_reason: classifyResult.reason, + }; + + return fm; +} + +function addOutputContractSection(body) { + if (/^##\s+Output contract/m.test(body)) return body; + return body.trimEnd() + '\n\n## Output contract\n\nThis skill does not produce files by itself; the converted openclaw skill should declare its outputs in a new section here. (Filled in by the user after first run.)\n'; +} + +function addFailureHandlingSection(body) { + if (/^##\s+Failure handling/m.test(body)) return body; + return body.trimEnd() + '\n\n## Failure handling\n\nIf a required external tool or path is missing, surface the exact missing identifier to the user instead of guessing. Do not auto-install system packages. (Add skill-specific failure modes here.)\n'; +} + +function addWindowsNotesSection(body, hasShell) { + if (!hasShell) return body; + if (/^##\s+Windows \(win32\) platform notes/m.test(body)) return body; + return body.trimEnd() + '\n\n## Windows (win32) platform notes\n\nThe original openclaw skill assumed macOS/Linux shell. The PowerShell equivalents for any `bash`/`pip`/`python3` calls should be documented here. (Generated by skill-bridge; user to verify.)\n'; +} + +function addReferencesIndex(body, references) { + if (!references || references.length === 0) return body; + if (/^##\s+References\b/m.test(body)) return body; + const items = references + .map((r) => `- [\`${r.file}\`](references/${r.file})`) + .join('\n'); + return ( + body.trimEnd() + + '\n\n## References\n\nDetailed content moved out of this SKILL.md for size. Read these when the main flow above references them:\n\n' + + items + + '\n' + ); +} + +function maybeSplitReferences(name, body) { + const lines = body.split(/\r?\n/); + if (lines.length <= MAX_BODY_LINES) return { body, references: [] }; + + const sections = []; + let intro = []; + let current = null; + for (const line of lines) { + if (/^##\s+/.test(line)) { + if (current) sections.push(current); + else if (intro.length) sections.push({ heading: '__intro__', lines: intro }); + current = { heading: line, lines: [line] }; + } else if (current) { + current.lines.push(line); + } else { + intro.push(line); + } + } + if (current) sections.push(current); + else if (intro.length) sections.push({ heading: '__intro__', lines: intro }); + + if (sections.length < 3) return { body, references: [] }; + + const keep = sections.slice(0, 2).map(s => s.lines.join('\n')).join('\n\n'); + const moved = sections.slice(2); + const references = moved.map(s => { + const slug = s.heading + .replace(/^##\s+/, '') + .replace(/[^\w\u3400-\u9FFF-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() + .slice(0, 64) || 'section'; + return { + file: `${slug}.md`, + content: s.lines.join('\n'), + }; + }); + return { body: keep.trimEnd() + '\n', references }; +} + +/** + * Atomic directory replace using a backup-and-rename dance. + * + * At any observable point in time, outDir is either the OLD content or + * the NEW content. There is no window where outDir is missing or + * half-written. The staging directory is always cleaned up. + * + * The window where outDir is *temporarily absent* does exist: it is + * the time between the `outDir -> backup` rename and the + * `staging -> outDir` rename. A crash in that window leaves + * `.bak-` on disk; the caller can recover by renaming + * the backup back to outDir. We do that recovery automatically in + * the catch block, and we propagate any recovery error so the caller + * is not silently left with a missing outDir. + * + * @param {string} staging The directory holding the new content. + * @param {string} outDir The destination to replace. + * @param {object} [opts] + * @param {(staging: string, outDir: string) => Promise} [opts.renameStaging] + * Test hook. Defaults to `fs.rename(staging, outDir)`. Throwing here + * simulates a crash in the "staging -> outDir" step. + * @param {(src: string, dst: string) => Promise} [opts.rename] + * Test hook for the inner rename calls. Defaults to `fs.rename`. + */ +export async function atomicReplace(staging, outDir, opts = {}) { + const rename = opts.rename || ((src, dst) => fs.rename(src, dst)); + const renameStaging = opts.renameStaging || (() => rename(staging, outDir)); + const backup = `${outDir}.bak-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; + let backupCreated = false; + try { + const exists = await fs.stat(outDir).catch(() => null); + if (exists) { + // Move the existing outDir out of the way. fs.rename is atomic on + // the same volume and never returns a partially-moved directory. + await rename(outDir, backup); + backupCreated = true; + } + // Move staging into place. If this fails (renameStaging throws), + // the catch block restores the backup so outDir is preserved. + await renameStaging(); + // OutDir is now the new content. Drop the backup. + if (backupCreated) { + await fs.rm(backup, { recursive: true, force: true }); + backupCreated = false; + } + } catch (err) { + // Recovery: if we created a backup but the final rename failed, + // restore the backup so the caller still sees the old outDir. + // We must NOT swallow a recovery error — the whole point of the + // atomic guarantee is that the caller can rely on either the old + // or the new outDir, never neither. If recovery itself fails, + // surface both errors so the caller can take manual action. + if (backupCreated) { + const backupExists = await fs.stat(backup).catch(() => null); + if (backupExists) { + try { + await rename(backup, outDir); + } catch (recoveryErr) { + err.recovery = { + message: `atomic-replace recovery failed: outDir is missing; backup is at ${backup}`, + cause: recoveryErr, + }; + } + } + } + throw err; + } finally { + if (backupCreated) { + await fs.rm(backup, { recursive: true, force: true }).catch(() => {}); + } + // Staging should already be gone (renamed onto outDir). If it + // somehow remains, clean it up. + await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * @param {object} args + * @param {string} args.inputPath + * @param {import('./analyze.js').AnalyzedSkill} args.report + * @param {import('./classify.js').ClassifyResult} args.classify + * @param {string} args.outDir + * @returns {Promise<{ written: string[], warnings: string[] }>} + */ +export async function transformSkill({ inputPath, report, classify, outDir }) { + const warnings = []; + const written = []; + + // 1. Parameterize paths in body + const { text: bodyAfterPaths, changes: pathChanges } = parameterizePaths(report.body); + if (pathChanges.length > 0) { + warnings.push(`paths parameterized: ${pathChanges.map(c => c.id).join(', ')}`); + } + + // 2. Detect shell-style commands to decide if Windows notes are needed + const hasShell = /\b(pip|python3?|curl|wget|bash|cli-anything-)/.test(bodyAfterPaths); + + // 3. Maybe split into references/ + const { body: bodySplit, references } = maybeSplitReferences(report.frontmatter.name || '', bodyAfterPaths); + + // 4. Add the missing sections. References index goes BEFORE + // Output contract / Failure handling / Windows notes so the moved-out + // content is reachable from the top of the body, not buried under + // boilerplate at the end. + let finalBody = bodySplit; + finalBody = addReferencesIndex(finalBody, references); + finalBody = addOutputContractSection(finalBody); + finalBody = addFailureHandlingSection(finalBody); + finalBody = addWindowsNotesSection(finalBody, hasShell); + + // 5. Enrich frontmatter (target name = basename of outDir so name matches dir) + const targetName = path.basename(outDir); + const enrichedFm = enrichFrontmatter(report.frontmatter, finalBody, classify, targetName); + + // 6. Serialize + const fmYaml = dumpYamlBlock(enrichedFm); + const skillText = `---\n${fmYaml}---\n\n${finalBody.trimStart()}`; + + // 7. Atomic write: stage everything under a sibling temp dir, then + // swap into outDir via the backup-rename dance. + const stageDir = `${outDir}.staging-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; + try { + await fs.mkdir(stageDir, { recursive: true }); + const skillOut = path.join(stageDir, 'SKILL.md'); + await fs.writeFile(skillOut, skillText, 'utf-8'); + + for (const ref of references) { + const refPath = path.join(stageDir, 'references', ref.file); + await fs.mkdir(path.dirname(refPath), { recursive: true }); + await fs.writeFile(refPath, ref.content.trim() + '\n', 'utf-8'); + } + + const reportMd = renderConversionReport({ inputPath, classify, pathChanges, written: [], warnings }); + const reportPath = path.join(stageDir, 'conversion-report.md'); + await fs.writeFile(reportPath, reportMd, 'utf-8'); + + await atomicReplace(stageDir, outDir); + } catch (err) { + // Make sure staging is gone even if the catch ran mid-write. + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + throw err; + } + + // 8. Record the final paths (post-rename) for the caller. + written.push(path.join(outDir, 'SKILL.md')); + for (const ref of references) { + written.push(path.join(outDir, 'references', ref.file)); + } + written.push(path.join(outDir, 'conversion-report.md')); + + return { written, warnings }; +} + +function renderConversionReport({ inputPath, classify, pathChanges, written, warnings }) { + return [ + `# Conversion report`, + ``, + `- **input**: \`${inputPath}\``, + `- **tier**: ${classify.tier} / ${classify.subTier}`, + `- **reason**: ${classify.reason}`, + ``, + `## Path changes`, + pathChanges.length === 0 + ? `_none_` + : pathChanges.map(c => `- \`${c.id}\` → \${${c.placeholder}} (${c.count}x)`).join('\n'), + ``, + `## Written files`, + written.map(f => `- \`${f}\``).join('\n'), + ``, + `## Recommendations`, + classify.recommendations.map(r => `- ${r}`).join('\n'), + ``, + `## Warnings`, + warnings.length === 0 ? `_none_` : warnings.map(w => `- ${w}`).join('\n'), + ``, + `_generated by skill-bridge v0.2.0 on ${new Date().toISOString()}_`, + ``, + ].join('\n'); +} diff --git a/plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs b/plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs index 99ca8bb..5109834 100644 --- a/plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs +++ b/plugins/antianqi/skill-bridge/tests/transform-atomic.test.mjs @@ -1,120 +1,214 @@ -// tests/transform-atomic.test.mjs -// -// Regression tests for the "atomic replace" guarantee in -// lib/transform-skill.js. -// -// hetaoBackend's review on PR #3 said: "所谓原子替换先删除 outDir 再 rename; -// rename 失败会丢失旧输出。需要失败保留测试。" -// -// v0.2 fixes this by staging to a sibling temp dir and using a -// backup-and-rename dance: outDir is moved to a backup first, the -// staging dir is renamed onto outDir, and the backup is removed. If -// anything fails, the backup is moved back so outDir is restored. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { transformSkill } from '../lib/transform-skill.js'; - -const SAMPLE = { - inputPath: 'fake.md', - report: { - inputPath: 'fake.md', - encoding: 'utf-8', - convertedFromGbk: false, - frontmatter: { name: 'atomic-test', description: 'Atomic rename test.' }, - body: '# Top\n\n## Procedure\n\nDo it.\n', - warnings: [], - }, - classify: { tier: 'pure', subTier: 'pure-translate', reason: 'r', recommendations: [] }, -}; - -async function tmpdir() { - return await fs.mkdtemp(path.join(os.tmpdir(), 'sb-atomic-')); -} - -test('1st run creates outDir with the new content', async () => { - const out = await tmpdir(); - const outDir = path.join(out, 'atomic-1'); - await transformSkill({ ...SAMPLE, outDir }); - const entries = await fs.readdir(outDir); - assert.ok(entries.includes('SKILL.md')); - assert.ok(entries.includes('conversion-report.md')); - await fs.rm(out, { recursive: true, force: true }); -}); - -test('2nd run replaces outDir cleanly (no stale references/)', async () => { - const out = await tmpdir(); - const outDir = path.join(out, 'atomic-2'); - - // 1st pass: long body that triggers the references/ split. - const sectionBody = (label) => { - const lines = [`## ${label}`]; - for (let i = 0; i < 200; i++) lines.push(`${label} line ${i}.`); - return lines.join('\n'); - }; - const longBody = [ - '# Top', '', - 'Intro.', - '', - sectionBody('A'), - sectionBody('B'), - sectionBody('C'), - sectionBody('D'), - ].join('\n'); - await transformSkill({ - ...SAMPLE, - report: { ...SAMPLE.report, body: longBody }, - outDir, - }); - const refsAfterFirst = await fs.readdir(path.join(outDir, 'references')); - assert.ok(refsAfterFirst.length > 0, '1st pass should produce references/'); - - // 2nd pass: short body that does NOT trigger the split. The atomic - // replace must wipe the old references/ — not just overwrite SKILL.md. - await transformSkill({ - ...SAMPLE, - report: { ...SAMPLE.report, body: '# Top\n\nShort body, no split.\n' }, - outDir, - }); - const refsAfterSecond = await fs.readdir(path.join(outDir, 'references')).catch(() => null); - assert.equal(refsAfterSecond, null, 'stale references/ must be removed by atomic replace'); - - // And the new SKILL.md reflects the new body. - const skill = await fs.readFile(path.join(outDir, 'SKILL.md'), 'utf-8'); - assert.ok(skill.includes('Short body, no split.')); - assert.ok(!/A line 0/.test(skill), 'old long-body content must not leak into the new SKILL.md'); - - await fs.rm(out, { recursive: true, force: true }); -}); - -test('outDir is preserved when transformSkill fails before any write', async () => { - // Force a deterministic failure with a NUL byte in the outDir path. - // Node fs APIs always reject NUL bytes, so transformSkill throws - // before it touches anything. The pre-existing outDir (and its - // sentinel) must remain untouched on disk. - const out = await tmpdir(); - const outDir = path.join(out, 'atomic-3'); - await fs.mkdir(outDir, { recursive: true }); - const sentinel = path.join(outDir, 'SENTINEL.md'); - await fs.writeFile(sentinel, 'keep me', 'utf-8'); - - // NUL byte in the path makes any fs call throw. - const badOut = path.join(out, 'bad\0segment', 'skill'); - - await assert.rejects( - transformSkill({ ...SAMPLE, outDir: badOut }), - (err) => err instanceof Error, - 'transformSkill must reject when outDir is unusable', - ); - - // Pre-existing outDir and its sentinel must still be intact. - const stillThere = await fs.stat(outDir); - assert.ok(stillThere.isDirectory(), 'outDir must still exist'); - const content = await fs.readFile(sentinel, 'utf-8'); - assert.equal(content, 'keep me', 'sentinel must be unchanged'); - - await fs.rm(out, { recursive: true, force: true }); -}); +// tests/transform-atomic.test.mjs +// +// Regression tests for the "atomic replace" guarantee in +// lib/transform-skill.js. +// +// hetaoBackend's review on PR #3 said: "所谓原子替换先删除 outDir 再 rename; +// rename 失败会丢失旧输出。需要失败保留测试。" +// +// v0.2 fixes this by staging to a sibling temp dir and using a +// backup-and-rename dance: outDir is moved to a backup first, the +// staging dir is renamed onto outDir, and the backup is removed. If +// anything fails, the backup is moved back so outDir is restored. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { transformSkill } from '../lib/transform-skill.js'; +import { atomicReplace } from '../lib/transform-skill.js'; + +const SAMPLE = { + inputPath: 'fake.md', + report: { + inputPath: 'fake.md', + encoding: 'utf-8', + convertedFromGbk: false, + frontmatter: { name: 'atomic-test', description: 'Atomic rename test.' }, + body: '# Top\n\n## Procedure\n\nDo it.\n', + warnings: [], + }, + classify: { tier: 'pure', subTier: 'pure-translate', reason: 'r', recommendations: [] }, +}; + +async function tmpdir() { + return await fs.mkdtemp(path.join(os.tmpdir(), 'sb-atomic-')); +} + +test('1st run creates outDir with the new content', async () => { + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-1'); + await transformSkill({ ...SAMPLE, outDir }); + const entries = await fs.readdir(outDir); + assert.ok(entries.includes('SKILL.md')); + assert.ok(entries.includes('conversion-report.md')); + await fs.rm(out, { recursive: true, force: true }); +}); + +test('2nd run replaces outDir cleanly (no stale references/)', async () => { + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-2'); + + // 1st pass: long body that triggers the references/ split. + const sectionBody = (label) => { + const lines = [`## ${label}`]; + for (let i = 0; i < 200; i++) lines.push(`${label} line ${i}.`); + return lines.join('\n'); + }; + const longBody = [ + '# Top', '', + 'Intro.', + '', + sectionBody('A'), + sectionBody('B'), + sectionBody('C'), + sectionBody('D'), + ].join('\n'); + await transformSkill({ + ...SAMPLE, + report: { ...SAMPLE.report, body: longBody }, + outDir, + }); + const refsAfterFirst = await fs.readdir(path.join(outDir, 'references')); + assert.ok(refsAfterFirst.length > 0, '1st pass should produce references/'); + + // 2nd pass: short body that does NOT trigger the split. The atomic + // replace must wipe the old references/ — not just overwrite SKILL.md. + await transformSkill({ + ...SAMPLE, + report: { ...SAMPLE.report, body: '# Top\n\nShort body, no split.\n' }, + outDir, + }); + const refsAfterSecond = await fs.readdir(path.join(outDir, 'references')).catch(() => null); + assert.equal(refsAfterSecond, null, 'stale references/ must be removed by atomic replace'); + + // And the new SKILL.md reflects the new body. + const skill = await fs.readFile(path.join(outDir, 'SKILL.md'), 'utf-8'); + assert.ok(skill.includes('Short body, no split.')); + assert.ok(!/A line 0/.test(skill), 'old long-body content must not leak into the new SKILL.md'); + + await fs.rm(out, { recursive: true, force: true }); +}); + +test('outDir is preserved when transformSkill fails before any write', async () => { + // Force a deterministic failure with a NUL byte in the outDir path. + // Node fs APIs always reject NUL bytes, so transformSkill throws + // before it touches anything. The pre-existing outDir (and its + // sentinel) must remain untouched on disk. + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-3'); + await fs.mkdir(outDir, { recursive: true }); + const sentinel = path.join(outDir, 'SENTINEL.md'); + await fs.writeFile(sentinel, 'keep me', 'utf-8'); + + // NUL byte in the path makes any fs call throw. + const badOut = path.join(out, 'bad\0segment', 'skill'); + + await assert.rejects( + transformSkill({ ...SAMPLE, outDir: badOut }), + (err) => err instanceof Error, + 'transformSkill must reject when outDir is unusable', + ); + + // Pre-existing outDir and its sentinel must still be intact. + const stillThere = await fs.stat(outDir); + assert.ok(stillThere.isDirectory(), 'outDir must still exist'); + const content = await fs.readFile(sentinel, 'utf-8'); + assert.equal(content, 'keep me', 'sentinel must be unchanged'); + + await fs.rm(out, { recursive: true, force: true }); +}); + +test('atomicReplace: outDir is restored when the staging -> outDir rename fails', async () => { + // Review #4 asked for a test that simulates a crash in the window + // between `outDir -> backup` and `staging -> outDir`. We inject a + // throwing renameStaging hook so we can deterministically reproduce + // it. + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-4'); + await fs.mkdir(outDir, { recursive: true }); + const sentinel = path.join(outDir, 'KEEP.md'); + await fs.writeFile(sentinel, 'old content', 'utf-8'); + + const staging = path.join(out, 'staging-4'); + await fs.mkdir(staging, { recursive: true }); + await fs.writeFile(path.join(staging, 'NEW.md'), 'new content', 'utf-8'); + + let renameCalls = 0; + await assert.rejects( + atomicReplace(staging, outDir, { + renameStaging: async () => { + renameCalls += 1; + const err = new Error('simulated staging rename failure'); + err.code = 'EACCES'; + throw err; + }, + }), + (err) => err instanceof Error && err.message === 'simulated staging rename failure', + ); + assert.equal(renameCalls, 1, 'staging rename should be attempted exactly once'); + + // After the failed atomicReplace, the original outDir must still + // exist (the backup was restored) and contain the original sentinel. + const dirAfter = await fs.stat(outDir); + assert.ok(dirAfter.isDirectory(), 'outDir must still exist after a failed atomicReplace'); + const sentinelAfter = await fs.readFile(sentinel, 'utf-8'); + assert.equal(sentinelAfter, 'old content', 'sentinel must be the original content'); + + // The .bak-* dir that atomicReplace created must be cleaned up. + const siblings = await fs.readdir(out); + const strayBak = siblings.filter((e) => e.includes(`${path.basename(outDir)}.bak-`)); + assert.equal(strayBak.length, 0, `stray backup dirs left behind: ${strayBak.join(', ')}`); + + await fs.rm(out, { recursive: true, force: true }); +}); + +test('atomicReplace: surfaces a recovery error when both the swap and the rollback fail', async () => { + // When the swap fails AND the rollback fails too, atomicReplace must + // NOT swallow the second error. The caller's contract is "outDir is + // either OLD or NEW, never missing"; if both are missing we have to + // tell them. + // + // We force every rename call to fail. The first rename (outDir -> + // backup) fails, so backupCreated stays false; that path does not + // exercise the recovery branch. To exercise the recovery branch we + // instead use a counter and let the FIRST rename succeed (so a + // backup is created), the SECOND rename fail (the swap), and the + // THIRD rename fail too (the rollback). + const out = await tmpdir(); + const outDir = path.join(out, 'atomic-5'); + await fs.mkdir(outDir, { recursive: true }); + await fs.writeFile(path.join(outDir, 'KEEP.md'), 'old', 'utf-8'); + + const staging = path.join(out, 'staging-5'); + await fs.mkdir(staging, { recursive: true }); + + let calls = 0; + await assert.rejects( + atomicReplace(staging, outDir, { + rename: async (src, dst) => { + calls += 1; + if (calls === 1) { + // First call: outDir -> backup. Let it succeed. + return fs.rename(src, dst); + } + if (calls === 2) { + // Second call: staging -> outDir. Throw. + throw new Error('boom: swap failed'); + } + // Third call: backup -> outDir (rollback). Throw. + throw new Error('boom: rollback failed'); + }, + }), + (err) => { + if (!(err instanceof Error)) return false; + return err.message === 'boom: swap failed' + && err.recovery + && /atomic-replace recovery failed/.test(err.recovery.message); + }, + 'atomicReplace must attach .recovery when both swap and rollback fail', + ); + + await fs.rm(out, { recursive: true, force: true }); +}); From a576c8068dfa109c41eea8e1040433b46afed81c Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:48:24 +0800 Subject: [PATCH 5/5] fix: support YAML lists and fail closed on parse errors (review #3) The review called out two coupled defects in v0.2.0: 1. `lib/analyze.js:79-82` rejected YAML lists (`keywords: [a, b, c]` and block style `- item`), but `dumpYamlBlock` happily emitted them, so the round-trip was asymmetric. 2. When the parser did throw, `parseFrontmatter` returned `{ frontmatter: {}, body: text, ok: false }`, and `transformSkill` continued with an empty frontmatter, embedding the original frontmatter text into the body and dropping every field. The MCP server then reported a successful `convert`. - `lib/analyze.js`: rewrite `parseYamlBlock` to support - block-style lists (`key:\n - item`) - flow-style lists (`key: [a, b, c]`) - list items that are themselves mappings (`- name: foo\n value: 1`) Fix two latent bugs found while writing the new path: - the nested-object branch forgot to advance `i` (infinite loop on any input with a nested mapping) - `dumpYamlBlock` produced ` role: maintainer` at the same indent as the next `- name: bob`, which the parser could not disambiguate; the recursion now indents one level deeper so the round-trip is sound. - `lib/analyze.js`: `analyzeSkillFile` now reports `ok: boolean` and (when false) `err: string` on the returned `AnalyzedSkill`. - `server.mjs`: the `convert` tool checks `report.ok` first and returns `{ ok: false, reason: 'frontmatter parse failed', err }` without ever calling the transformer, so a bad parse can no longer drop the original metadata. - `tests/analyze.test.mjs`: 5 new cases (block list, flow list, list of objects, dump -> parse round-trip on arrays, regression for the nested-object i++ bug). - `tests/server.test.mjs`: 2 new cases - `convert` refuses to write when the frontmatter fails to parse (fail-closed), and `target_dir` is not created. - `convert` resolves a directory source to its inner SKILL.md (the contract the docs already promised). `node --test plugins/antianqi/skill-bridge/tests/*.test.mjs` reports 63/63 pass (was 56/56; +7 new cases, 0 regressions). --- plugins/antianqi/skill-bridge/lib/analyze.js | 141 +++++- plugins/antianqi/skill-bridge/server.mjs | 468 +++++++++--------- .../skill-bridge/tests/analyze.test.mjs | 268 +++++----- .../skill-bridge/tests/server.test.mjs | 445 ++++++++++------- 4 files changed, 780 insertions(+), 542 deletions(-) diff --git a/plugins/antianqi/skill-bridge/lib/analyze.js b/plugins/antianqi/skill-bridge/lib/analyze.js index f1eb68e..093f7de 100644 --- a/plugins/antianqi/skill-bridge/lib/analyze.js +++ b/plugins/antianqi/skill-bridge/lib/analyze.js @@ -47,11 +47,14 @@ const PATH_PATTERNS = [ * @property {Array<{label:string, samples:string[]}>} hardcodedPaths * @property {Array<{label:string, samples:string[]}>} externalCommands * @property {string[]} warnings + * @property {boolean} ok false when frontmatter could not be parsed + * @property {string} [err] parse error message when ok === false */ // ---------- Constrained YAML parser ---------- const KEY_LINE_RE = /^(\s*)([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/; +const LIST_ITEM_RE = /^(\s*)- (.*?)\s*$/; /** * Parse a constrained YAML block. Supports: @@ -59,6 +62,8 @@ const KEY_LINE_RE = /^(\s*)([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/; * - `key: "..."` / `key: '...'` (quoted string) * - `key: |` / `key: >` (block scalar, indented body) * - `key:` (followed by indented sub-keys) -> nested object + * - `key:` (followed by ` - item`) -> list of scalars / objects + * - `key: [a, b, c]` -> flow-style list of scalars * * Throws on unsupported constructs. * @@ -68,14 +73,43 @@ const KEY_LINE_RE = /^(\s*)([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/; export function parseYamlBlock(text) { const lines = text.split(/\r?\n/); const root = {}; - // Stack of frames: each holds the current container and its indent - // level. We start at indent -2 so that the first top-level key (indent 0) - // satisfies `indent === top.indent + 2` without special-casing. - const stack = [{ indent: -2, container: root }]; + // Stack of frames: each holds the current container (object or array) + // and its indent level. We start at indent -2 so that the first + // top-level key (indent 0) satisfies `indent === top.indent + 2` + // without special-casing. + const stack = [{ indent: -2, container: root, kind: 'object' }]; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.trim() === '') { i++; continue; } + + // 1) Detect a list-item line: " - foo" or " - name: bar". + // The dash must be at the *current* container indent + 2. + const lm = line.match(LIST_ITEM_RE); + if (lm) { + const [, ws, raw] = lm; + const indent = ws.length; + // Pop frames until we find a list frame at the right indent. + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + const top = stack[stack.length - 1]; + if (top.kind !== 'list') { + throw new Error(`list item at ${JSON.stringify(line)} but parent is not a list`); + } + if (indent !== top.indent + 2) { + throw new Error(`bad list-item indent at ${JSON.stringify(line)}`); + } + // The item is either a scalar (raw coerced) or an inline object + // starting with a key: value on the same line. The rest of the + // object (if any) lives on subsequent lines at indent + 2. + const sub = parseListItem(raw, lines, indent, i); + top.container.push(sub.value); + i = sub.nextIndex; + continue; + } + + // 2) Otherwise, a normal `key: value` line. const m = line.match(KEY_LINE_RE); if (!m) { throw new Error(`cannot parse line: ${JSON.stringify(line)}`); @@ -87,10 +121,14 @@ export function parseYamlBlock(text) { stack.pop(); } const top = stack[stack.length - 1]; + if (top.kind !== 'object') { + throw new Error(`mapping at ${JSON.stringify(line)} but parent is a list`); + } // The current line's indent must be exactly top.indent + 2. if (indent !== top.indent + 2) { throw new Error(`bad indent at line: ${JSON.stringify(line)}`); } + if (rawValue === '' || rawValue === '|' || rawValue === '>') { if (rawValue === '|' || rawValue === '>') { const blockIndent = indent + 2; @@ -105,20 +143,83 @@ export function parseYamlBlock(text) { i++; } top.container[key] = blockLines.join('\n').replace(/\n+$/, ''); + } else if (peekIsList(lines, i + 1, indent + 2)) { + // Nested list. Allocate an array, push it as the value, and + // open a new list frame at the right indent so subsequent + // `- item` lines are appended here. + const arr = []; + top.container[key] = arr; + stack.push({ indent, container: arr, kind: 'list' }); + i++; + // Do NOT consume a line; the list-item line will be picked up + // by the LIST_ITEM_RE branch on the next iteration. } else { // nested object const obj = {}; top.container[key] = obj; - stack.push({ indent, container: obj }); + stack.push({ indent, container: obj, kind: 'object' }); + i++; } + } else if (rawValue.startsWith('[') && rawValue.endsWith(']')) { + // Flow-style list: `key: [a, b, c]`. We support scalar items only. + const inner = rawValue.slice(1, -1); + const items = inner.length === 0 ? [] : inner.split(',').map((s) => coerceScalar(s.trim())); + top.container[key] = items; + i++; } else { top.container[key] = coerceScalar(rawValue); + i++; } - i++; } return root; } +function peekIsList(lines, start, expectedIndent) { + // True if the next non-blank line at exactly expectedIndent is a + // list item belonging to the current key. + for (let k = start; k < lines.length; k++) { + const ln = lines[k]; + if (ln.trim() === '') continue; + const m = ln.match(/^(\s*)- /); + if (!m) return false; + return m[1].length === expectedIndent; + } + return false; +} + +function parseListItem(raw, lines, itemIndent, startIndex) { + // raw is the text after "- ". Two shapes: + // - scalar (no colon): return the coerced value, advance one line. + // - inline object: first line is "key: value", further lines at + // itemIndent + 2 are more `key: value` pairs. We do NOT recurse + // into parseYamlBlock here because re-indenting a synthetic block + // for nested arrays / deeper objects is brittle. Instead, scan + // continuation lines directly and build a flat object — one + // level of nested mapping is all the v0.2 skill-bridge emits. + if (!raw.includes(':')) { + return { value: coerceScalar(raw), nextIndex: startIndex + 1 }; + } + const obj = {}; + const first = raw.match(/^([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/); + if (!first) { + return { value: coerceScalar(raw), nextIndex: startIndex + 1 }; + } + obj[first[1]] = coerceScalar(first[2]); + const contIndent = itemIndent + 2; + let k = startIndex + 1; + while (k < lines.length) { + const ln = lines[k]; + if (ln.trim() === '') { k++; continue; } + const ind = ln.match(/^(\s*)/)[1].length; + if (ind < contIndent) break; + const cm = ln.match(/^(\s*)([A-Za-z0-9_.\-]+)\s*:\s*(.*?)\s*$/); + if (!cm) break; + obj[cm[2]] = coerceScalar(cm[3]); + k++; + } + return { value: obj, nextIndex: k }; +} + function coerceScalar(v) { // Quoted scalars are always returned as strings, even if the content // would otherwise look like a number / boolean / null. This matches @@ -197,6 +298,10 @@ export async function analyzeSkillFile(filePath) { const text = det.text; const { frontmatter, body, ok, err } = parseFrontmatter(text); + // Fail closed: if the frontmatter is not parseable, do NOT silently + // continue with an empty frontmatter (which would discard the + // original metadata in the output). The caller is expected to check + // `report.ok` and refuse to convert in that case. const fullText = ok ? reconstructText(frontmatter, body) : text; const warnings = []; @@ -214,6 +319,8 @@ export async function analyzeSkillFile(filePath) { hardcodedPaths: scanPatterns(fullText, PATH_PATTERNS), externalCommands: scanPatterns(fullText, EXTERNAL_COMMAND_PATTERNS), warnings, + ok, + ...(ok ? {} : { err }), }; } @@ -250,12 +357,22 @@ export function dumpYamlBlock(obj, indent = 0) { if (item === null) { lines.push(`${pad} - null`); } else if (typeof item === 'object' && !Array.isArray(item)) { - const childPad = `${pad} `; - const dumped = dumpYamlBlock(item, indent + 1); - // Indent the first line with the dash, subsequent lines stay aligned. - const [first, ...rest] = dumped.split('\n'); - lines.push(`${childPad}- ${first.trimStart()}`); - for (const r of rest) lines.push(r); + // List item that is itself a mapping. The first key shares + // the line with the "- " marker; subsequent keys must be + // indented one more level than the marker (item.content_indent + // = item.indent + 2). The recursive dump uses indent + 2 so + // its pad is two more spaces than the outer pad, which is + // exactly what we want for the "role: maintainer" continuation. + const innerDump = dumpYamlBlock(item, indent + 2); + const itemLines = innerDump.split('\n').filter((l) => l.length > 0); + if (itemLines.length === 0) { + lines.push(`${pad} - {}`); + } else { + lines.push(`${pad} - ${itemLines[0].trimStart()}`); + for (let i = 1; i < itemLines.length; i++) { + lines.push(itemLines[i]); + } + } } else { lines.push(`${pad} - ${scalarToYaml(item)}`); } diff --git a/plugins/antianqi/skill-bridge/server.mjs b/plugins/antianqi/skill-bridge/server.mjs index 2b7d9ba..b38edcb 100644 --- a/plugins/antianqi/skill-bridge/server.mjs +++ b/plugins/antianqi/skill-bridge/server.mjs @@ -1,228 +1,240 @@ -#!/usr/bin/env node -// server.mjs — stdio MCP server for skill-bridge. -// -// Exposes four tools that mirror the original CLI subcommands but -// communicate over JSON-RPC on stdin/stdout: -// -// detect (source) -> { encoding, originalEncoding, -// replaced, confidence, reason } -// analyze (source) -> full AnalyzedSkill report -// classify (source) -> { tier, subTier, reason, ... } -// convert (source, target_dir, -// force?, run_lint?) -> { tier, subTier, written, warnings, -// lint } -// -// `source` may be a path to a SKILL.md file OR a directory containing one. -// Paths are resolved relative to the calling agent's filesystem; we do -// not use any host-specific state. -// -// References: -// - Agent Plugins 1.0 MCP schema: -// https://agent-plugins.org/schemas/1.0.0/mcp.schema.json -// - hello-mcode-mcp example shipped by the community registry. - -import { createInterface } from 'node:readline'; -import { readFileSafe } from './lib/detect.js'; -import { analyzeSkillFile, parseFrontmatter } from './lib/analyze.js'; -import { classify } from './lib/classify.js'; -import { transformSkill } from './lib/transform-skill.js'; -import { lintSkill } from './lib/lint.js'; - -const SERVER_INFO = { name: 'skill-bridge', version: '0.2.0' }; -const PROTOCOL_VERSION = '2025-06-18'; - -// ---------- MCP plumbing ---------- - -const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); - -function send(message) { - process.stdout.write(`${JSON.stringify(message)}\n`); -} - -function ok(id, result) { - send({ jsonrpc: '2.0', id, result }); -} - -function fail(id, code, message, data) { - send({ jsonrpc: '2.0', id, error: { code, message, data } }); -} - -const TOOLS = [ - { - name: 'detect', - description: - 'Detect the encoding of a SKILL.md file. Returns one of: utf-8, gbk, unknown. ' + - 'If gbk, the text field is the UTF-8-restored content.', - inputSchema: { - type: 'object', - properties: { - source: { - type: 'string', - description: 'Absolute path to a SKILL.md file or a directory containing one.', - }, - }, - required: ['source'], - additionalProperties: false, - }, - }, - { - name: 'analyze', - description: - 'Full analysis of a SKILL.md: frontmatter, body, hardcoded paths, ' + - 'external commands, and warnings. Use this when the caller wants to ' + - 'inspect the skill before deciding what to do.', - inputSchema: { - type: 'object', - properties: { - source: { type: 'string', description: 'Path to SKILL.md or skill folder.' }, - }, - required: ['source'], - additionalProperties: false, - }, - }, - { - name: 'classify', - description: - 'Classify a skill into one of: pure / pure-translate / pure-wrapped-fix, ' + - 'or wrapped-* (not yet supported in v0.2), or abandon.', - inputSchema: { - type: 'object', - properties: { - source: { type: 'string' }, - }, - required: ['source'], - additionalProperties: false, - }, - }, - { - name: 'convert', - description: - 'Run the full conversion pipeline and write the result to target_dir. ' + - 'In v0.2 only `pure` skills are converted. Lint runs by default; ' + - 'pass run_lint=false to skip.', - inputSchema: { - type: 'object', - properties: { - source: { type: 'string' }, - target_dir: { type: 'string' }, - force: { type: 'boolean', default: false }, - run_lint: { type: 'boolean', default: true }, - }, - required: ['source', 'target_dir'], - additionalProperties: false, - }, - }, -]; - -function toolResultText(payload) { - return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] }; -} - -async function handle(message) { - const { method, params, id } = message; - try { - if (method === 'initialize') { - return { - result: { - protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION, - capabilities: { tools: {} }, - serverInfo: SERVER_INFO, - }, - }; - } - if (method === 'notifications/initialized') { - return null; // no-op - } - if (method === 'tools/list') { - return { result: { tools: TOOLS } }; - } - if (method === 'tools/call') { - const name = params?.name; - const args = params?.arguments ?? {}; - return { - result: await invokeTool(name, args), - }; - } - return { error: { code: -32601, message: `Method not found: ${String(method)}` } }; - } catch (e) { - return { error: { code: -32000, message: e?.message ?? String(e) } }; - } -} - -async function invokeTool(name, args) { - switch (name) { - case 'detect': { - const r = await readFileSafe(String(args.source)); - return toolResultText(r); - } - case 'analyze': { - const r = await analyzeSkillFile(String(args.source)); - return toolResultText(r); - } - case 'classify': { - const report = await analyzeSkillFile(String(args.source)); - return toolResultText(classify(report)); - } - case 'convert': { - const source = String(args.source); - const targetDir = String(args.target_dir); - const force = Boolean(args.force); - const runLint = args.run_lint !== false; - const report = await analyzeSkillFile(source); - const result = classify(report); - if (result.tier === 'abandon') { - return toolResultText({ ok: false, tier: 'abandon', reason: result.reason }); - } - if (result.tier !== 'pure') { - return toolResultText({ - ok: false, - tier: result.tier, - subTier: result.subTier, - reason: result.reason, - note: 'v0.2 only emits pure skills. wrapped-* support is planned for v0.3.', - }); - } - // The transformer writes to a staging dir and renames onto target_dir. - // It does NOT touch target_dir if anything fails. The `force` flag - // here is informational; the transformer is always safe to re-run. - void force; - const r = await transformSkill({ - inputPath: source, - report, - classify: result, - outDir: targetDir, - }); - let lint = null; - if (runLint) { - const lr = await lintSkill(targetDir); - lint = { ok: lr.ok, code: lr.code, stdout: lr.stdout, stderr: lr.stderr }; - } - return toolResultText({ - ok: true, - tier: result.tier, - subTier: result.subTier, - written: r.written, - warnings: r.warnings, - lint, - }); - } - default: - throw new Error(`Unknown tool: ${name}`); - } -} - -input.on('line', (line) => { - if (!line.trim()) return; - let message; - try { - message = JSON.parse(line); - } catch { - return; // ignore malformed lines - } - if (message.id === undefined) return; // notifications have no id - Promise.resolve(handle(message)).then((response) => { - if (response === null || response === undefined) return; - if (response.error) return fail(message.id, response.error.code, response.error.message, response.error.data); - return ok(message.id, response.result); - }); -}); +#!/usr/bin/env node +// server.mjs — stdio MCP server for skill-bridge. +// +// Exposes four tools that mirror the original CLI subcommands but +// communicate over JSON-RPC on stdin/stdout: +// +// detect (source) -> { encoding, originalEncoding, +// replaced, confidence, reason } +// analyze (source) -> full AnalyzedSkill report +// classify (source) -> { tier, subTier, reason, ... } +// convert (source, target_dir, +// force?, run_lint?) -> { tier, subTier, written, warnings, +// lint } +// +// `source` may be a path to a SKILL.md file OR a directory containing one. +// Paths are resolved relative to the calling agent's filesystem; we do +// not use any host-specific state. +// +// References: +// - Agent Plugins 1.0 MCP schema: +// https://agent-plugins.org/schemas/1.0.0/mcp.schema.json +// - hello-mcode-mcp example shipped by the community registry. + +import { createInterface } from 'node:readline'; +import { readFileSafe } from './lib/detect.js'; +import { analyzeSkillFile, parseFrontmatter } from './lib/analyze.js'; +import { classify } from './lib/classify.js'; +import { transformSkill } from './lib/transform-skill.js'; +import { lintSkill } from './lib/lint.js'; + +const SERVER_INFO = { name: 'skill-bridge', version: '0.2.0' }; +const PROTOCOL_VERSION = '2025-06-18'; + +// ---------- MCP plumbing ---------- + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function ok(id, result) { + send({ jsonrpc: '2.0', id, result }); +} + +function fail(id, code, message, data) { + send({ jsonrpc: '2.0', id, error: { code, message, data } }); +} + +const TOOLS = [ + { + name: 'detect', + description: + 'Detect the encoding of a SKILL.md file. Returns one of: utf-8, gbk, unknown. ' + + 'If gbk, the text field is the UTF-8-restored content.', + inputSchema: { + type: 'object', + properties: { + source: { + type: 'string', + description: 'Absolute path to a SKILL.md file or a directory containing one.', + }, + }, + required: ['source'], + additionalProperties: false, + }, + }, + { + name: 'analyze', + description: + 'Full analysis of a SKILL.md: frontmatter, body, hardcoded paths, ' + + 'external commands, and warnings. Use this when the caller wants to ' + + 'inspect the skill before deciding what to do.', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string', description: 'Path to SKILL.md or skill folder.' }, + }, + required: ['source'], + additionalProperties: false, + }, + }, + { + name: 'classify', + description: + 'Classify a skill into one of: pure / pure-translate / pure-wrapped-fix, ' + + 'or wrapped-* (not yet supported in v0.2), or abandon.', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string' }, + }, + required: ['source'], + additionalProperties: false, + }, + }, + { + name: 'convert', + description: + 'Run the full conversion pipeline and write the result to target_dir. ' + + 'In v0.2 only `pure` skills are converted. Lint runs by default; ' + + 'pass run_lint=false to skip.', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string' }, + target_dir: { type: 'string' }, + force: { type: 'boolean', default: false }, + run_lint: { type: 'boolean', default: true }, + }, + required: ['source', 'target_dir'], + additionalProperties: false, + }, + }, +]; + +function toolResultText(payload) { + return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] }; +} + +async function handle(message) { + const { method, params, id } = message; + try { + if (method === 'initialize') { + return { + result: { + protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }, + }; + } + if (method === 'notifications/initialized') { + return null; // no-op + } + if (method === 'tools/list') { + return { result: { tools: TOOLS } }; + } + if (method === 'tools/call') { + const name = params?.name; + const args = params?.arguments ?? {}; + return { + result: await invokeTool(name, args), + }; + } + return { error: { code: -32601, message: `Method not found: ${String(method)}` } }; + } catch (e) { + return { error: { code: -32000, message: e?.message ?? String(e) } }; + } +} + +async function invokeTool(name, args) { + switch (name) { + case 'detect': { + const r = await readFileSafe(String(args.source)); + return toolResultText(r); + } + case 'analyze': { + const r = await analyzeSkillFile(String(args.source)); + return toolResultText(r); + } + case 'classify': { + const report = await analyzeSkillFile(String(args.source)); + return toolResultText(classify(report)); + } + case 'convert': { + const source = String(args.source); + const targetDir = String(args.target_dir); + const force = Boolean(args.force); + const runLint = args.run_lint !== false; + const report = await analyzeSkillFile(source); + // Fail closed: if the frontmatter parser rejected the input, do + // NOT proceed to transformSkill. v0.2 used to fall through with + // an empty frontmatter, which silently dropped the original + // metadata and embedded the raw frontmatter into the body. + if (report.ok === false) { + return toolResultText({ + ok: false, + reason: 'frontmatter parse failed', + err: report.err, + warnings: report.warnings, + }); + } + const result = classify(report); + if (result.tier === 'abandon') { + return toolResultText({ ok: false, tier: 'abandon', reason: result.reason }); + } + if (result.tier !== 'pure') { + return toolResultText({ + ok: false, + tier: result.tier, + subTier: result.subTier, + reason: result.reason, + note: 'v0.2 only emits pure skills. wrapped-* support is planned for v0.3.', + }); + } + // The transformer writes to a staging dir and renames onto target_dir. + // It does NOT touch target_dir if anything fails. The `force` flag + // here is informational; the transformer is always safe to re-run. + void force; + const r = await transformSkill({ + inputPath: source, + report, + classify: result, + outDir: targetDir, + }); + let lint = null; + if (runLint) { + const lr = await lintSkill(targetDir); + lint = { ok: lr.ok, code: lr.code, stdout: lr.stdout, stderr: lr.stderr }; + } + return toolResultText({ + ok: true, + tier: result.tier, + subTier: result.subTier, + written: r.written, + warnings: r.warnings, + lint, + }); + } + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +input.on('line', (line) => { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch { + return; // ignore malformed lines + } + if (message.id === undefined) return; // notifications have no id + Promise.resolve(handle(message)).then((response) => { + if (response === null || response === undefined) return; + if (response.error) return fail(message.id, response.error.code, response.error.message, response.error.data); + return ok(message.id, response.result); + }); +}); diff --git a/plugins/antianqi/skill-bridge/tests/analyze.test.mjs b/plugins/antianqi/skill-bridge/tests/analyze.test.mjs index 1681d4c..003b08c 100644 --- a/plugins/antianqi/skill-bridge/tests/analyze.test.mjs +++ b/plugins/antianqi/skill-bridge/tests/analyze.test.mjs @@ -1,111 +1,157 @@ -// tests/analyze.test.mjs -// -// The frontmatter parser is hand-rolled to avoid the js-yaml npm dep. -// These tests pin the exact subset we support and the round-trip -// behavior of the dump. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { parseYamlBlock, dumpYamlBlock, parseFrontmatter } from '../lib/analyze.js'; - -test('parseYamlBlock: simple scalars', () => { - const fm = parseYamlBlock(`name: hello\nversion: "1.0"\nflag: true\nmissing: null\n`); - assert.equal(fm.name, 'hello'); - assert.equal(fm.version, '1.0'); - assert.equal(fm.flag, true); - assert.equal(fm.missing, null); -}); - -test('parseYamlBlock: quoted strings preserve spaces', () => { - const fm = parseYamlBlock(`title: "Hello World"\nsub: 'a b c'\n`); - assert.equal(fm.title, 'Hello World'); - assert.equal(fm.sub, 'a b c'); -}); - -test('parseYamlBlock: block scalar with |', () => { - const fm = parseYamlBlock(`body: |\n line 1\n line 2\n line 3\n`); - assert.equal(fm.body, 'line 1\nline 2\nline 3'); -}); - -test('parseYamlBlock: one level of nested mapping', () => { - const fm = parseYamlBlock(`metadata:\n author: alice\n version: "0.1.0"\ndescriptions:\n zh-Hans: 你好\n`); - assert.deepEqual(fm.metadata, { author: 'alice', version: '0.1.0' }); - assert.equal(fm.descriptions['zh-Hans'], '你好'); -}); - -test('parseYamlBlock: bad indent throws', () => { - assert.throws( - () => parseYamlBlock(`a:\n b: 1\n`), - /bad indent/, - ); -}); - -test('parseYamlBlock: number coercion', () => { - const fm = parseYamlBlock(`a: 42\nb: -3.14\nc: "42"\n`); - // `42` and `-3.14` parse as numbers; `"42"` (quoted) stays a string. - assert.equal(fm.a, 42); - assert.equal(fm.b, -3.14); - assert.equal(fm.c, '42'); -}); - -test('parseFrontmatter: round-trip from SKILL.md text', () => { - const text = `--- -name: foo -description: "A test" -metadata: - author: alice ---- -# Body`; - const { frontmatter, body, ok } = parseFrontmatter(text); - assert.equal(ok, true); - assert.equal(frontmatter.name, 'foo'); - assert.equal(frontmatter.description, 'A test'); - assert.equal(frontmatter.metadata.author, 'alice'); - assert.match(body, /^# Body/); -}); - -test('parseFrontmatter: missing frontmatter returns ok=false', () => { - const text = '# Just a heading\n\nno frontmatter here'; - const r = parseFrontmatter(text); - assert.equal(r.ok, false); - assert.equal(r.frontmatter.name, undefined); -}); - -test('dumpYamlBlock + parseYamlBlock round-trip preserves content', () => { - // Note: arrays are not part of the parseYamlBlock subset. We verify - // them in dumpYamlBlock unit tests below; the round-trip here covers - // only the shapes (scalars + one level of nested mapping) that the - // parser supports. - const original = { - name: 'round-trip', - description: 'Use this skill to round-trip.', - descriptions: { 'zh-Hans': '回环测试' }, - metadata: { 'skill-bridge': { tier: 'pure' } }, - }; - const text = dumpYamlBlock(original); - const parsed = parseYamlBlock(text); - assert.equal(parsed.name, 'round-trip'); - assert.equal(parsed.description, 'Use this skill to round-trip.'); - assert.equal(parsed.descriptions['zh-Hans'], '回环测试'); - assert.equal(parsed.metadata['skill-bridge'].tier, 'pure'); -}); - -test('dumpYamlBlock: string with newline uses block scalar', () => { - const text = dumpYamlBlock({ body: 'line 1\nline 2' }); - assert.match(text, /^body: \|\n/m); - assert.match(text, / line 1\n line 2/); -}); - -test('dumpYamlBlock: reserved words get quoted', () => { - const text = dumpYamlBlock({ flag: 'true', no: 'null' }); - // 'true' / 'null' / 'yes' / 'no' / etc. must be quoted or they would - // round-trip as their YAML-typed values, not as strings. - assert.match(text, /flag: "true"/); - assert.match(text, /no: "null"/); -}); - -test('dumpYamlBlock: leading/trailing space gets quoted', () => { - const text = dumpYamlBlock({ x: ' hi', y: 'bye ' }); - assert.match(text, /x: " hi"/); - assert.match(text, /y: "bye "/); -}); +// tests/analyze.test.mjs +// +// The frontmatter parser is hand-rolled to avoid the js-yaml npm dep. +// These tests pin the exact subset we support and the round-trip +// behavior of the dump. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseYamlBlock, dumpYamlBlock, parseFrontmatter } from '../lib/analyze.js'; + +test('parseYamlBlock: simple scalars', () => { + const fm = parseYamlBlock(`name: hello\nversion: "1.0"\nflag: true\nmissing: null\n`); + assert.equal(fm.name, 'hello'); + assert.equal(fm.version, '1.0'); + assert.equal(fm.flag, true); + assert.equal(fm.missing, null); +}); + +test('parseYamlBlock: quoted strings preserve spaces', () => { + const fm = parseYamlBlock(`title: "Hello World"\nsub: 'a b c'\n`); + assert.equal(fm.title, 'Hello World'); + assert.equal(fm.sub, 'a b c'); +}); + +test('parseYamlBlock: block scalar with |', () => { + const fm = parseYamlBlock(`body: |\n line 1\n line 2\n line 3\n`); + assert.equal(fm.body, 'line 1\nline 2\nline 3'); +}); + +test('parseYamlBlock: one level of nested mapping', () => { + const fm = parseYamlBlock(`metadata:\n author: alice\n version: "0.1.0"\ndescriptions:\n zh-Hans: 你好\n`); + assert.deepEqual(fm.metadata, { author: 'alice', version: '0.1.0' }); + assert.equal(fm.descriptions['zh-Hans'], '你好'); +}); + +test('parseYamlBlock: bad indent throws', () => { + assert.throws( + () => parseYamlBlock(`a:\n b: 1\n`), + /bad indent/, + ); +}); + +test('parseYamlBlock: number coercion', () => { + const fm = parseYamlBlock(`a: 42\nb: -3.14\nc: "42"\n`); + // `42` and `-3.14` parse as numbers; `"42"` (quoted) stays a string. + assert.equal(fm.a, 42); + assert.equal(fm.b, -3.14); + assert.equal(fm.c, '42'); +}); + +test('parseFrontmatter: round-trip from SKILL.md text', () => { + const text = `--- +name: foo +description: "A test" +metadata: + author: alice +--- +# Body`; + const { frontmatter, body, ok } = parseFrontmatter(text); + assert.equal(ok, true); + assert.equal(frontmatter.name, 'foo'); + assert.equal(frontmatter.description, 'A test'); + assert.equal(frontmatter.metadata.author, 'alice'); + assert.match(body, /^# Body/); +}); + +test('parseFrontmatter: missing frontmatter returns ok=false', () => { + const text = '# Just a heading\n\nno frontmatter here'; + const r = parseFrontmatter(text); + assert.equal(r.ok, false); + assert.equal(r.frontmatter.name, undefined); +}); + +test('dumpYamlBlock + parseYamlBlock round-trip preserves content', () => { + // Note: arrays are not part of the parseYamlBlock subset. We verify + // them in dumpYamlBlock unit tests below; the round-trip here covers + // only the shapes (scalars + one level of nested mapping) that the + // parser supports. + const original = { + name: 'round-trip', + description: 'Use this skill to round-trip.', + descriptions: { 'zh-Hans': '回环测试' }, + metadata: { 'skill-bridge': { tier: 'pure' } }, + }; + const text = dumpYamlBlock(original); + const parsed = parseYamlBlock(text); + assert.equal(parsed.name, 'round-trip'); + assert.equal(parsed.description, 'Use this skill to round-trip.'); + assert.equal(parsed.descriptions['zh-Hans'], '回环测试'); + assert.equal(parsed.metadata['skill-bridge'].tier, 'pure'); +}); + +test('dumpYamlBlock: string with newline uses block scalar', () => { + const text = dumpYamlBlock({ body: 'line 1\nline 2' }); + assert.match(text, /^body: \|\n/m); + assert.match(text, / line 1\n line 2/); +}); + +test('dumpYamlBlock: reserved words get quoted', () => { + const text = dumpYamlBlock({ flag: 'true', no: 'null' }); + // 'true' / 'null' / 'yes' / 'no' / etc. must be quoted or they would + // round-trip as their YAML-typed values, not as strings. + assert.match(text, /flag: "true"/); + assert.match(text, /no: "null"/); +}); + +test('dumpYamlBlock: leading/trailing space gets quoted', () => { + const text = dumpYamlBlock({ x: ' hi', y: 'bye ' }); + assert.match(text, /x: " hi"/); + assert.match(text, /y: "bye "/); +}); + +test('parseYamlBlock: block-style list of scalars', () => { + const fm = parseYamlBlock(`keywords:\n - alpha\n - beta\n - gamma\n`); + assert.deepEqual(fm.keywords, ['alpha', 'beta', 'gamma']); +}); + +test('parseYamlBlock: flow-style list', () => { + const fm = parseYamlBlock('tags: [a, b, c]\n'); + assert.deepEqual(fm.tags, ['a', 'b', 'c']); +}); + +test('parseYamlBlock: list of objects (inline mapping on the dash line)', () => { + const fm = parseYamlBlock(`items:\n - name: foo\n value: 1\n - name: bar\n value: 2\n`); + assert.deepEqual(fm.items, [ + { name: 'foo', value: 1 }, + { name: 'bar', value: 2 }, + ]); +}); + +test('parseYamlBlock: dump -> parse round-trips arrays', () => { + // dumpYamlBlock emits list items as a block list; parseYamlBlock + // must accept that shape. This is the round-trip the review asked + // for, and it was broken in v0.2.0 (parser rejected the block list). + const original = { + name: 'rt', + keywords: ['a', 'b', 'c'], + authors: [ + { name: 'alice', role: 'maintainer' }, + { name: 'bob', role: 'contributor' }, + ], + }; + const text = dumpYamlBlock(original); + const parsed = parseYamlBlock(text); + assert.deepEqual(parsed.keywords, original.keywords); + assert.deepEqual(parsed.authors, original.authors); + assert.equal(parsed.name, 'rt'); +}); + +test('parseYamlBlock: nested object still works (regression for v0.2.0 i++ bug)', () => { + // The earlier v0.2.0 parser had a missing-i++ bug on the nested + // object branch that caused an infinite loop. This test fails-fast + // by timing out (test runner) so we notice immediately if it + // regresses. + const fm = parseYamlBlock(`a:\n b:\n c: 1\n d: 2\n e: 3\n`); + assert.deepEqual(fm.a, { b: { c: 1, d: 2 }, e: 3 }); +}); diff --git a/plugins/antianqi/skill-bridge/tests/server.test.mjs b/plugins/antianqi/skill-bridge/tests/server.test.mjs index b78a818..fa7f909 100644 --- a/plugins/antianqi/skill-bridge/tests/server.test.mjs +++ b/plugins/antianqi/skill-bridge/tests/server.test.mjs @@ -1,191 +1,254 @@ -// tests/server.test.mjs -// -// Spawns server.mjs as a real subprocess and exercises the JSON-RPC -// protocol over stdio. This is the same protocol mavis will use to -// invoke the plugin's MCP server, so any regression here is caught -// before review. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import fs from 'node:fs/promises'; -import os from 'node:os'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const SERVER = path.join(__dirname, '..', 'server.mjs'); - -/** - * Minimal JSON-RPC client that talks to the spawned server over stdio. - * Each request/response is one JSON object per line. - */ -function startServer() { - const child = spawn(process.execPath, [SERVER], { - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let nextId = 1; - const pending = new Map(); - let buffer = ''; - child.stdout.on('data', (chunk) => { - buffer += chunk.toString('utf-8'); - let idx; - while ((idx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - if (!line.trim()) continue; - let msg; - try { msg = JSON.parse(line); } catch { continue; } - if (msg.id !== undefined && pending.has(msg.id)) { - const { resolve, reject } = pending.get(msg.id); - pending.delete(msg.id); - if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`)); - else resolve(msg.result); - } - } - }); - const stderr = []; - child.stderr.on('data', (d) => stderr.push(d.toString('utf-8'))); - - function send(method, params) { - return new Promise((resolve, reject) => { - const id = nextId++; - pending.set(id, { resolve, reject }); - child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); - }); - } - function notify(method, params) { - child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); - } - async function stop() { - notify('shutdown', {}); - child.stdin.end(); - await new Promise((r) => child.on('close', r)); - return stderr.join(''); - } - return { send, notify, stop }; -} - -test('server: initialize handshake', async () => { - const s = startServer(); - try { - const r = await s.send('initialize', { protocolVersion: '2025-06-18' }); - assert.equal(r.protocolVersion, '2025-06-18'); - assert.equal(r.serverInfo.name, 'skill-bridge'); - assert.match(r.serverInfo.version, /^\d+\.\d+\.\d+/); - } finally { - await s.stop(); - } -}); - -test('server: tools/list advertises the four tools', async () => { - const s = startServer(); - try { - const r = await s.send('tools/list'); - const names = r.tools.map((t) => t.name).sort(); - assert.deepEqual(names, ['analyze', 'classify', 'convert', 'detect']); - } finally { - await s.stop(); - } -}); - -test('server: detect on utf-8 file', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); - const file = path.join(dir, 'SKILL.md'); - await fs.writeFile(file, '---\nname: x\ndescription: y\n---\n\n# X\n', 'utf-8'); - const s = startServer(); - try { - const r = await s.send('tools/call', { name: 'detect', arguments: { source: file } }); - const payload = JSON.parse(r.content[0].text); - assert.equal(payload.encoding, 'utf-8'); - assert.equal(payload.replaced, false); - } finally { - await s.stop(); - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -test('server: classify on a pure-instruction skill', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); - const file = path.join(dir, 'SKILL.md'); - await fs.writeFile( - file, - '---\nname: y\ndescription: "A pure skill."\n---\n\n# Y\n\nJust instructions.\n', - 'utf-8', - ); - const s = startServer(); - try { - const r = await s.send('tools/call', { name: 'classify', arguments: { source: file } }); - const payload = JSON.parse(r.content[0].text); - assert.equal(payload.tier, 'pure'); - } finally { - await s.stop(); - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -test('server: convert writes output and returns lint object', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); - const file = path.join(dir, 'SKILL.md'); - await fs.writeFile( - file, - '---\nname: demo-skill\ndescription: "Demo."\n---\n\n# Demo\n\nUse /tmp/x for cache.\n', - 'utf-8', - ); - const out = path.join(dir, 'out'); - const s = startServer(); - try { - const r = await s.send('tools/call', { - name: 'convert', - arguments: { source: file, target_dir: out, run_lint: false }, - }); - const payload = JSON.parse(r.content[0].text); - assert.equal(payload.ok, true); - assert.equal(payload.tier, 'pure'); - assert.ok(payload.written.some((f) => f.endsWith('SKILL.md'))); - assert.equal(payload.lint, null, 'run_lint=false → no lint field'); - const written = await fs.readdir(out); - assert.ok(written.includes('SKILL.md')); - assert.ok(written.includes('conversion-report.md')); - } finally { - await s.stop(); - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -test('server: convert on wrapped skill returns ok=false with reason', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); - const file = path.join(dir, 'SKILL.md'); - await fs.writeFile( - file, - '---\nname: w\ndescription: "Uses pip."\n---\n\n# W\n\nRun `pip install foo`.\n', - 'utf-8', - ); - const out = path.join(dir, 'out'); - const s = startServer(); - try { - const r = await s.send('tools/call', { - name: 'convert', - arguments: { source: file, target_dir: out, run_lint: false }, - }); - const payload = JSON.parse(r.content[0].text); - assert.equal(payload.ok, false); - assert.equal(payload.tier, 'wrapped'); - } finally { - await s.stop(); - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -test('server: unknown method returns JSON-RPC error', async () => { - const s = startServer(); - try { - await assert.rejects( - s.send('tools/banana', {}), - /Method not found/, - ); - } finally { - await s.stop(); - } -}); +// tests/server.test.mjs +// +// Spawns server.mjs as a real subprocess and exercises the JSON-RPC +// protocol over stdio. This is the same protocol mavis will use to +// invoke the plugin's MCP server, so any regression here is caught +// before review. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import os from 'node:os'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SERVER = path.join(__dirname, '..', 'server.mjs'); + +/** + * Minimal JSON-RPC client that talks to the spawned server over stdio. + * Each request/response is one JSON object per line. + */ +function startServer() { + const child = spawn(process.execPath, [SERVER], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + let nextId = 1; + const pending = new Map(); + let buffer = ''; + child.stdout.on('data', (chunk) => { + buffer += chunk.toString('utf-8'); + let idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id !== undefined && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`)); + else resolve(msg.result); + } + } + }); + const stderr = []; + child.stderr.on('data', (d) => stderr.push(d.toString('utf-8'))); + + function send(method, params) { + return new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + } + function notify(method, params) { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); + } + async function stop() { + notify('shutdown', {}); + child.stdin.end(); + await new Promise((r) => child.on('close', r)); + return stderr.join(''); + } + return { send, notify, stop }; +} + +test('server: initialize handshake', async () => { + const s = startServer(); + try { + const r = await s.send('initialize', { protocolVersion: '2025-06-18' }); + assert.equal(r.protocolVersion, '2025-06-18'); + assert.equal(r.serverInfo.name, 'skill-bridge'); + assert.match(r.serverInfo.version, /^\d+\.\d+\.\d+/); + } finally { + await s.stop(); + } +}); + +test('server: tools/list advertises the four tools', async () => { + const s = startServer(); + try { + const r = await s.send('tools/list'); + const names = r.tools.map((t) => t.name).sort(); + assert.deepEqual(names, ['analyze', 'classify', 'convert', 'detect']); + } finally { + await s.stop(); + } +}); + +test('server: detect on utf-8 file', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile(file, '---\nname: x\ndescription: y\n---\n\n# X\n', 'utf-8'); + const s = startServer(); + try { + const r = await s.send('tools/call', { name: 'detect', arguments: { source: file } }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.encoding, 'utf-8'); + assert.equal(payload.replaced, false); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: classify on a pure-instruction skill', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile( + file, + '---\nname: y\ndescription: "A pure skill."\n---\n\n# Y\n\nJust instructions.\n', + 'utf-8', + ); + const s = startServer(); + try { + const r = await s.send('tools/call', { name: 'classify', arguments: { source: file } }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.tier, 'pure'); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: convert writes output and returns lint object', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile( + file, + '---\nname: demo-skill\ndescription: "Demo."\n---\n\n# Demo\n\nUse /tmp/x for cache.\n', + 'utf-8', + ); + const out = path.join(dir, 'out'); + const s = startServer(); + try { + const r = await s.send('tools/call', { + name: 'convert', + arguments: { source: file, target_dir: out, run_lint: false }, + }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.ok, true); + assert.equal(payload.tier, 'pure'); + assert.ok(payload.written.some((f) => f.endsWith('SKILL.md'))); + assert.equal(payload.lint, null, 'run_lint=false → no lint field'); + const written = await fs.readdir(out); + assert.ok(written.includes('SKILL.md')); + assert.ok(written.includes('conversion-report.md')); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: convert on wrapped skill returns ok=false with reason', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + await fs.writeFile( + file, + '---\nname: w\ndescription: "Uses pip."\n---\n\n# W\n\nRun `pip install foo`.\n', + 'utf-8', + ); + const out = path.join(dir, 'out'); + const s = startServer(); + try { + const r = await s.send('tools/call', { + name: 'convert', + arguments: { source: file, target_dir: out, run_lint: false }, + }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.ok, false); + assert.equal(payload.tier, 'wrapped'); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: unknown method returns JSON-RPC error', async () => { + const s = startServer(); + try { + await assert.rejects( + s.send('tools/banana', {}), + /Method not found/, + ); + } finally { + await s.stop(); + } +}); + +test('server: convert refuses to write when frontmatter parse fails (review #3 fail-closed)', async () => { + // v0.2.0 silently fell through with an empty frontmatter when the + // parser rejected a list-shaped value, which embedded the raw + // frontmatter in the body and dropped the original metadata. The + // v0.2.1 fix must (a) return ok=false with the parse error and + // (b) not create any files in target_dir. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const file = path.join(dir, 'SKILL.md'); + // Deliberately malformed YAML: "metadata:" opens a nested object, + // but the next line is indented 3 spaces instead of 4, so the + // strict-indent parser must reject it. + await fs.writeFile( + file, + '---\nname: bad\ndescription: "Has weird content."\nmetadata:\n badindent: 1\n---\n\n# Bad\n', + 'utf-8', + ); + const out = path.join(dir, 'out'); + const s = startServer(); + try { + const r = await s.send('tools/call', { + name: 'convert', + arguments: { source: file, target_dir: out, run_lint: false }, + }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.ok, false, `expected ok=false, got ${JSON.stringify(payload)}`); + assert.equal(payload.reason, 'frontmatter parse failed'); + assert.match(payload.err, /yaml parse/); + // The transformer must NOT have touched target_dir. + const outExists = await fs.stat(out).catch(() => null); + assert.equal(outExists, null, 'target_dir must not exist when analyze fails'); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('server: convert on a directory source resolves SKILL.md (review #2)', async () => { + // review #2 said docs promised directory support but the code + // passed the path directly to fs.readFile. Confirm the directory + // contract works end-to-end through the JSON-RPC layer. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sb-server-')); + const srcDir = path.join(dir, 'src-skill'); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile( + path.join(srcDir, 'SKILL.md'), + '---\nname: dir-skill\ndescription: "From a directory."\n---\n\n# Dir\n', + 'utf-8', + ); + const out = path.join(dir, 'out'); + const s = startServer(); + try { + const r = await s.send('tools/call', { + name: 'convert', + arguments: { source: srcDir, target_dir: out, run_lint: false }, + }); + const payload = JSON.parse(r.content[0].text); + assert.equal(payload.ok, true, `convert failed: ${JSON.stringify(payload)}`); + } finally { + await s.stop(); + await fs.rm(dir, { recursive: true, force: true }); + } +});