diff --git a/.gitignore b/.gitignore index dab9e4c..9dc2061 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ -.DS_Store -coverage/ -*.log +node_modules/ +.DS_Store +coverage/ +*.log 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 }); +});