feat(doc): 新增文档转换与规则化归档 Skill

This commit is contained in:
zhiye.sun
2026-08-25 15:32:12 +08:00
parent dbc2f65154
commit d255bdd524
20 changed files with 735 additions and 20 deletions
+3 -3
View File
@@ -69,7 +69,7 @@ plugins/<plugin>/skills/<skill>/
- 迁移采用“分析能力 → 编写中性规格 → 脱离原文独立实现”的方式,不做目录复制、批量替词或近义改写。 - 迁移采用“分析能力 → 编写中性规格 → 脱离原文独立实现”的方式,不做目录复制、批量替词或近义改写。
- 不复制来源 Skill 的正文、脚本、提示词、模板、示例、规则库、注释或独特文档结构。 - 不复制来源 Skill 的正文、脚本、提示词、模板、示例、规则库、注释或独特文档结构。
- 不写入公司名称、产品名称、内部域名、内部包名、项目名、人员信息、业务规则或环境路径。 - 不写入公司名称、产品名称、内部域名、内部包名、项目名、人员信息、业务规则或环境路径。
- 公司框架 API、组件契约、审批流程、版本矩阵和内部消息格式默认排除。 - 公司框架 API、组件契约、审批流程、版本矩阵和内部消息格式不得直接迁移;评估时应先抽象其通用问题,并优先设计基于项目配置、公开标准或用户显式输入的中性平替。只有不存在独立通用价值、无法安全替代或与 Codex 场景不成立时才排除。
- 通用技术规范必须根据公开标准或官方资料重新设计;需要引用时记录来源和许可证。 - 通用技术规范必须根据公开标准或官方资料重新设计;需要引用时记录来源和许可证。
- 来源文件只用于人工分析和哈希追踪,不得进入 `plugins/` 发布目录。 - 来源文件只用于人工分析和哈希追踪,不得进入 `plugins/` 发布目录。
- 上游变化只触发复核,不自动覆盖已迁移 Skill。 - 上游变化只触发复核,不自动覆盖已迁移 Skill。
@@ -105,12 +105,12 @@ plugins/<plugin>/skills/<skill>/
| 来源标识 | 使用本地迁移标识,不在发布目录写入公司品牌 | | 来源标识 | 使用本地迁移标识,不在发布目录写入公司品牌 |
| 来源 Skill | 原始名称和相对路径 | | 来源 Skill | 原始名称和相对路径 |
| 当前作用 | 根据源码确认的能力、输入、输出和关键边界 | | 当前作用 | 根据源码确认的能力、输入、输出和关键边界 |
| 迁移类型 | 新增、更新、合并、公开资料重建或排除 | | 迁移类型 | 新增、更新、合并、中性平替、公开资料重建或排除 |
| 风险 | 公司专属知识、内部依赖、重复能力和兼容性问题 | | 风险 | 公司专属知识、内部依赖、重复能力和兼容性问题 |
来源作用必须有文件依据;无法从静态内容确认的运行行为应明确标记为未验证。 来源作用必须有文件依据;无法从静态内容确认的运行行为应明确标记为未验证。
同一目标能力存在多个来源时,应在一个迁移方案中共同评估,明确合并、取舍或排除关系,不按来源机械创建多个目标 Skill。 同一目标能力存在多个来源时,应在一个迁移方案中共同评估,明确合并、取舍、中性平替或排除关系,不按来源机械创建多个目标 Skill。来源含有大量专有规则时,不得只罗列删除项;必须说明通用问题如何由配置、标准接口、用户输入或项目资料替代。
### 3. 给出目标名称、作用和组织结构 ### 3. 给出目标名称、作用和组织结构
+2 -1
View File
@@ -13,7 +13,7 @@ CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通
| 插件 | 用途 | 当前状态 | | 插件 | 用途 | 当前状态 |
| --- | --- | --- | | --- | --- | --- |
| `dev` | 软件设计、编码、审查与测试 | 已初始化,暂无 Skill | | `dev` | 软件设计、编码、审查与测试 | 已初始化,暂无 Skill |
| `doc` | 文档转换、整理与写作 | 已迁移 `format-md`、`docx-to-md` | | `doc` | 文档转换、整理与写作 | 已迁移 `format-md`、`docx-to-md`、`md-to-docx`、`xlsx-to-md`、`archive` |
| `git` | 分支、提交、变更提取与集成 | 已迁移 `commit-msg`、`branch` | | `git` | 分支、提交、变更提取与集成 | 已迁移 `commit-msg`、`branch` |
| `knowledge` | 项目初始化、交接、复盘与经验 | 已迁移 `handoff`、`init` | | `knowledge` | 项目初始化、交接、复盘与经验 | 已迁移 `handoff`、`init` |
| `skill` | 项目规范及 Skill 创建、迁移与维护 | 已迁移 `guidance` | | `skill` | 项目规范及 Skill 创建、迁移与维护 | 已迁移 `guidance` |
@@ -37,6 +37,7 @@ CraftKit/
## 开发约束 ## 开发约束
- Skill 必须独立重写,不复制公司插件的正文、脚本、模板、示例或规则库。 - Skill 必须独立重写,不复制公司插件的正文、脚本、模板、示例或规则库。
- 专有能力应先抽象为可配置、基于公开标准或用户显式输入的中性平替;不能仅因专有内容较多就整体排除。
- 发布内容不得出现公司名称、产品名称、内部域名、内部包名、项目名或人员信息。 - 发布内容不得出现公司名称、产品名称、内部域名、内部包名、项目名或人员信息。
- 每个 Skill 文件夹名必须与 `SKILL.md` frontmatter 的 `name` 一致。 - 每个 Skill 文件夹名必须与 `SKILL.md` frontmatter 的 `name` 一致。
- 新增或修改插件后必须执行 Skill 校验、插件校验和去公司化扫描。 - 新增或修改插件后必须执行 Skill 校验、插件校验和去公司化扫描。
+9 -8
View File
@@ -4,7 +4,7 @@
将两个本地来源中的通用能力整理为 CraftKit Skill。迁移结果必须适用于 Codex 插件市场,并与来源项目的品牌、内部框架、业务知识和运行环境解耦。 将两个本地来源中的通用能力整理为 CraftKit Skill。迁移结果必须适用于 Codex 插件市场,并与来源项目的品牌、内部框架、业务知识和运行环境解耦。
迁移不追求来源 Skill 与目标 Skill 一一对应。重复能力应合并,公司专属能力应排除,目标数量以职责清晰和实际复用价值为准。 迁移不追求来源 Skill 与目标 Skill 一一对应。重复能力应合并;公司专属实现不得迁移,但应优先将其解决的通用问题重建为中性平替,确无独立价值或无法安全替代时才排除。目标数量以职责清晰和实际复用价值为准。
## 2. 迁移路径 ## 2. 迁移路径
@@ -60,9 +60,9 @@
5. `knowledge/handoff`:模板化文档与项目上下文样本,已完成。 5. `knowledge/handoff`:模板化文档与项目上下文样本,已完成。
6. `skill/guidance`:大型参考资料、索引和渐进式加载样本,已完成;只检索项目资料和基于公开一级资料独立重建的中性公共基线。 6. `skill/guidance`:大型参考资料、索引和渐进式加载样本,已完成;只检索项目资料和基于公开一级资料独立重建的中性公共基线。
7. `knowledge/init`:成熟项目与空项目双模式初始化、参考项目提炼和共享/本地信息边界样本,已完成。 7. `knowledge/init`:成熟项目与空项目双模式初始化、参考项目提炼和共享/本地信息边界样本,已完成。
8. `skill/migrate`:脚本、检查、同步和状态追踪组成的复合工作流样本。 8. `skill/migrate`:不迁移。来源能力用于把 Claude Code Skill 转换为 Codex Skill;CraftKit 自始按 Codex 规范开发,不存在平台转换需求。来源扫描、目标检查和状态追踪继续由 `migration/scripts/` 作为仓库维护设施承担。
完成门槛:每种样本均通过对应验证,并形成可复用的命名、目录、独立实现、测试、扫描和状态同步规则。出现未覆盖的新结构或权限类型时,应补充样本,不直接扩批。 完成门槛:适用样本均通过对应验证,不适用样本记录排除依据,并形成可复用的命名、目录、独立实现、测试、扫描和状态同步规则。出现未覆盖的新结构或权限类型时,应补充样本,不直接扩批。当前特殊样本阶段已完成,可以进入同质批量迁移。
### 第 2 阶段:同质批量迁移 ### 第 2 阶段:同质批量迁移
@@ -78,9 +78,9 @@
所属插件:`doc` 所属插件:`doc`
1. `docx-to-md` 1. `docx-to-md`
2. `markdown-to-docx` 2. `md-to-docx`(已完成)
3. `excel-to-markdown` 3. `xlsx-to-md`(已完成)
4. `archive-docs` 4. `archive`(已完成,以可配置规则平替固定目录、业务文件名、专有章节拆分和内部系统校验)
重点验证图片、表格、合并单元格、编码、覆盖策略和路径安全。脚本及测试数据必须独立创建。 重点验证图片、表格、合并单元格、编码、覆盖策略和路径安全。脚本及测试数据必须独立创建。
@@ -230,5 +230,6 @@ plan-change
- [x] 迁移并验证只读 Git 特殊样本 `git/commit-msg`。 - [x] 迁移并验证只读 Git 特殊样本 `git/commit-msg`。
- [x] 迁移并验证有副作用 Git 特殊样本 `git/branch`。 - [x] 迁移并验证有副作用 Git 特殊样本 `git/branch`。
- [x] 迁移并验证模板化交接特殊样本 `knowledge/handoff`。 - [x] 迁移并验证模板化交接特殊样本 `knowledge/handoff`。
- [ ] 完成其余特殊样本并总结批量迁移规则。 - [x] 完成其余特殊样本并总结批量迁移规则。
- [ ] 按插件和风险类型推进同质批量迁移。 - [ ] 按插件和风险类型继续推进同质批量迁移。
- [x] 完成首个同质批量:`md-to-docx`、`xlsx-to-md`、`archive`。
+18 -5
View File
@@ -63,12 +63,17 @@
"source-a:4efb6f9dd0f39dff": { "source-a:4efb6f9dd0f39dff": {
"sourcePathHash": "4efb6f9dd0f39dff13339e62ff24b672552efcbcedb007e9ea86fe41950adbb0", "sourcePathHash": "4efb6f9dd0f39dff13339e62ff24b672552efcbcedb007e9ea86fe41950adbb0",
"sourceSha256": "a914823bdf50d9e5c8ee70d6363c5c0e7aea341ed390587a9285c70a7047558e", "sourceSha256": "a914823bdf50d9e5c8ee70d6363c5c0e7aea341ed390587a9285c70a7047558e",
"status": "pending" "status": "migrated",
"target": "plugins/doc/skills/xlsx-to-md",
"targetVersion": "0.1.0",
"reviewedAt": "2026-08-25"
}, },
"source-a:32e7cfc21d252985": { "source-a:32e7cfc21d252985": {
"sourcePathHash": "32e7cfc21d25298559bffbe7f0918f0f6e7d2aac29e3eca39d1f43d8226cf934", "sourcePathHash": "32e7cfc21d25298559bffbe7f0918f0f6e7d2aac29e3eca39d1f43d8226cf934",
"sourceSha256": "0f0486e3f25279a1a1cb8cecfef4dc01830471b5d31b52a9d17a425919997bc2", "sourceSha256": "0f0486e3f25279a1a1cb8cecfef4dc01830471b5d31b52a9d17a425919997bc2",
"status": "pending" "status": "excluded",
"reason": "CraftKit 原生面向 Codex,不需要 Claude Code 到 Codex 的平台转换能力",
"reviewedAt": "2026-08-25"
}, },
"source-a:bc91b57fa2a50049": { "source-a:bc91b57fa2a50049": {
"sourcePathHash": "bc91b57fa2a500498b31b0f1a87dfa8ab83c32e4b361b4ea1614ea6721234b7c", "sourcePathHash": "bc91b57fa2a500498b31b0f1a87dfa8ab83c32e4b361b4ea1614ea6721234b7c",
@@ -162,7 +167,10 @@
"source-b:b127c47139270a7f": { "source-b:b127c47139270a7f": {
"sourcePathHash": "b127c47139270a7f9157e18ad6a0c975b99caf07b9a0874daeac86d9a99ce48c", "sourcePathHash": "b127c47139270a7f9157e18ad6a0c975b99caf07b9a0874daeac86d9a99ce48c",
"sourceSha256": "0214b80071f07beaff4f5837b983546ff71dbaa65bc29e34a5a23c9d946b683b", "sourceSha256": "0214b80071f07beaff4f5837b983546ff71dbaa65bc29e34a5a23c9d946b683b",
"status": "pending" "status": "migrated",
"target": "plugins/doc/skills/md-to-docx",
"targetVersion": "0.1.0",
"reviewedAt": "2026-08-25"
}, },
"source-b:34ab291ce3cfed2e": { "source-b:34ab291ce3cfed2e": {
"sourcePathHash": "34ab291ce3cfed2e69cc6f08309b1a41142be91235b9a573daf98639b4a001cc", "sourcePathHash": "34ab291ce3cfed2e69cc6f08309b1a41142be91235b9a573daf98639b4a001cc",
@@ -319,7 +327,10 @@
"source-b:3763e17cff825df6": { "source-b:3763e17cff825df6": {
"sourcePathHash": "3763e17cff825df6920f57d84736093082014f2a0316d9e84ee18ac8078ff6e5", "sourcePathHash": "3763e17cff825df6920f57d84736093082014f2a0316d9e84ee18ac8078ff6e5",
"sourceSha256": "bd2e4ae7f8a9bded1f7a983289fb49556f30cd5d6c6382500dd8c464fdd66e02", "sourceSha256": "bd2e4ae7f8a9bded1f7a983289fb49556f30cd5d6c6382500dd8c464fdd66e02",
"status": "pending" "status": "migrated",
"target": "plugins/doc/skills/archive",
"targetVersion": "0.1.0",
"reviewedAt": "2026-08-25"
}, },
"source-b:9e3e75840a46af96": { "source-b:9e3e75840a46af96": {
"sourcePathHash": "9e3e75840a46af96c33a1885f192813239b511eb972a4962b30b76de7392c88c", "sourcePathHash": "9e3e75840a46af96c33a1885f192813239b511eb972a4962b30b76de7392c88c",
@@ -340,7 +351,9 @@
"source-b:988b200efd0b8be0": { "source-b:988b200efd0b8be0": {
"sourcePathHash": "988b200efd0b8be024060e0785da2f864bf7ee30d7c7b434fde7ab1e4cdcc9d8", "sourcePathHash": "988b200efd0b8be024060e0785da2f864bf7ee30d7c7b434fde7ab1e4cdcc9d8",
"sourceSha256": "e7fa073d592167c07b06698d664192baafb126afa1a6bfdc50a056e1ea13e56f", "sourceSha256": "e7fa073d592167c07b06698d664192baafb126afa1a6bfdc50a056e1ea13e56f",
"status": "pending" "status": "excluded",
"reason": "CraftKit 原生面向 Codex,不需要 Claude Code 到 Codex 的平台转换能力",
"reviewedAt": "2026-08-25"
}, },
"source-b:8a7cdf719d8f8a3e": { "source-b:8a7cdf719d8f8a3e": {
"sourcePathHash": "8a7cdf719d8f8a3e0200781a82062cc5a839e4d2c2fd400982771968245d62dd", "sourcePathHash": "8a7cdf719d8f8a3e0200781a82062cc5a839e4d2c2fd400982771968245d62dd",
+78
View File
@@ -0,0 +1,78 @@
"""验证文档批次三个独立实现的核心行为和安全边界。"""
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from docx import Document
from openpyxl import Workbook
ROOT = Path(__file__).resolve().parents[2]
MD_SCRIPT = ROOT / "plugins/doc/skills/md-to-docx/scripts/convert.py"
XLSX_SCRIPT = ROOT / "plugins/doc/skills/xlsx-to-md/scripts/convert.py"
ARCHIVE_SCRIPT = ROOT / "plugins/doc/skills/archive/scripts/archive.py"
class DocumentBatchTests(unittest.TestCase):
"""使用临时目录执行真实转换,不依赖仓库外测试材料。"""
def run_script(self, script: Path, *args: object) -> subprocess.CompletedProcess[str]:
"""使用当前受控 Python 运行时执行目标脚本。"""
return subprocess.run(
[str(Path(__import__("sys").executable)), str(script), *(str(value) for value in args)],
capture_output=True, text=True, encoding="utf-8", check=False,
)
def test_markdown_to_docx_preserves_common_blocks(self) -> None:
"""标题、列表、表格和代码块应生成可重新打开的 DOCX。"""
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp); source = folder / "sample.md"; output = folder / "sample.docx"
source.write_text("# 标题\n\n- 项目\n\n| 名称 | 值 |\n| --- | --- |\n| A | 1 |\n\n```py\nprint('ok')\n```\n", encoding="utf-8")
result = self.run_script(MD_SCRIPT, source)
self.assertEqual(0, result.returncode, result.stderr)
document = Document(output)
self.assertEqual("标题", document.paragraphs[0].text)
self.assertEqual(1, len(document.tables))
self.assertNotEqual(0, self.run_script(MD_SCRIPT, source).returncode)
def test_xlsx_to_markdown_handles_merges_and_escaping(self) -> None:
"""合并区域应填充值,表格特殊字符应安全转义。"""
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp); source = folder / "sample.xlsx"
workbook = Workbook(); sheet = workbook.active; sheet.title = "数据"
sheet.append(["名称", "值"]); sheet.append(["A|B", "=1+1"])
sheet.merge_cells("A3:B3"); sheet["A3"] = "合并"
workbook.create_sheet("空表"); workbook.save(source)
result = self.run_script(XLSX_SCRIPT, source)
self.assertEqual(0, result.returncode, result.stderr)
markdown = source.with_suffix(".md").read_text(encoding="utf-8")
self.assertIn(r"A\|B", markdown)
self.assertIn("| 合并 | 合并 |", markdown)
self.assertIn("_空工作表_", markdown)
def test_archive_is_dry_run_first_and_keeps_source(self) -> None:
"""预演不得写目标,执行后仍须保留来源并只抽取指定章节。"""
with tempfile.TemporaryDirectory() as temp:
root = Path(temp); source = root / "specs/api.md"; source.parent.mkdir()
source.write_text("---\nowner: team\n---\n# 概述\n正文\n## API\n接口\n## 其他\n忽略\n", encoding="utf-8")
config = root / "rules.json"
config.write_text(json.dumps({"version": 1, "archiveRoot": "archive", "rules": [{"match": "specs/*.md", "target": "{stem}.md", "section": "API", "stripFrontmatter": True, "requiredText": ["接口"]}]}, ensure_ascii=False), encoding="utf-8")
target = root / "archive/api.md"
preview = self.run_script(ARCHIVE_SCRIPT, "--root", root, "--config", config)
self.assertEqual(0, preview.returncode, preview.stderr); self.assertFalse(target.exists())
applied = self.run_script(ARCHIVE_SCRIPT, "--root", root, "--config", config, "--apply")
self.assertEqual(0, applied.returncode, applied.stderr)
self.assertTrue(source.exists()); self.assertEqual("## API\n接口\n", target.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "doc", "name": "doc",
"version": "0.1.0", "version": "0.2.0",
"description": "通用文档转换、整理与写作工具。", "description": "通用文档转换、表格提取、规则化归档与写作工具。",
"author": { "author": {
"name": "CraftKit" "name": "CraftKit"
}, },
@@ -9,7 +9,7 @@
"interface": { "interface": {
"displayName": "Doc", "displayName": "Doc",
"shortDescription": "文档转换、整理与写作工具", "shortDescription": "文档转换、整理与写作工具",
"longDescription": "提供 Markdown、Word、表格及结构化写作相关的通用工作流。", "longDescription": "提供 Markdown、Word、Excel、规则化归档及结构化写作相关的通用工作流。",
"developerName": "CraftKit", "developerName": "CraftKit",
"category": "Productivity", "category": "Productivity",
"capabilities": ["Read", "Write"], "capabilities": ["Read", "Write"],
+39
View File
@@ -0,0 +1,39 @@
---
name: archive
description: 按显式配置将项目文档复制、重命名、清理 Frontmatter 或抽取 Markdown 章节到归档目录,并生成操作报告。适用于需要可重复文档归档规则的项目;删除来源、猜测业务目录或隐式查询内部系统不应触发本 Skill。
---
# 文档归档
使用 `scripts/archive.py` 执行中性的规则化归档。规则由当前项目维护,不携带固定组织目录、业务名称、数据库表或包结构。
## 两阶段执行
1. 读取 `.craftkit/project.json` 的 `documents` 配置,或使用用户指定的规则文件。
2. 检查每条规则的来源、目标模板、转换和校验条件。
3. 不带 `--apply` 执行预演,向用户展示复制、跳过、冲突和失败项。
4. 用户确认后使用相同参数加 `--apply` 执行。
5. 检查 JSON 报告和目标文件,并确认来源文件仍然存在。
```text
python scripts/archive.py --root <project> --config <rules.json> [--report <report.json>]
python scripts/archive.py --root <project> --config <rules.json> --apply [--report <report.json>]
```
## 中性平替原则
- 固定公司目录改为 `archiveRoot` 与规则级 `target` 模板。
- 固定业务文件名改为 `{name}`、`{stem}`、`{suffix}`、`{relative}` 占位符。
- 专有文档拆分逻辑改为通用 Markdown 标题章节抽取。
- 内部数据库或服务校验改为显式 `requiredText` 内容校验;需要外部事实时由用户先提供结果,不隐式连接系统。
- 来源专属元数据清理改为可选 `stripFrontmatter`,不会默认删除内容。
## 安全边界
- 默认只预演;`--apply` 才写入。
- 永不删除或移动来源文件。
- 来源必须位于项目根内,目标必须位于归档根内;拒绝绝对路径和 `..` 逃逸。
- 冲突策略仅允许 `skip`、`overwrite`、`append`,默认 `skip`。覆盖或追加必须在用户确认的配置中明确出现。
- 规则不执行 Shell、SQL、模板代码或网络请求。
完整配置见 `references/config.md`。
@@ -0,0 +1,4 @@
interface:
display_name: "Archive Documents"
short_description: "按项目规则安全预演并归档文档"
default_prompt: "使用 $archive 根据项目规则预演文档归档,确认后再执行写入。"
@@ -0,0 +1,32 @@
# 归档配置
```json
{
"version": 1,
"archiveRoot": "docs/archive",
"defaults": { "onConflict": "skip" },
"rules": [
{
"match": "design/**/*.md",
"target": "design/{relative}",
"stripFrontmatter": true,
"requiredText": ["#"]
},
{
"match": "specs/*.md",
"target": "reference/{stem}-api.md",
"section": "API"
}
]
}
```
- `archiveRoot`:相对项目根的归档目录。
- `match`:相对项目根的 glob,只处理普通文件。
- `target`:可用 `{name}`、`{stem}`、`{suffix}`、`{relative}`、`{parent}`。
- `section`:可选 Markdown 标题文本,输出该标题及下属内容。
- `stripFrontmatter`:仅移除开头完整闭合的 Frontmatter。
- `requiredText`:归档前必须出现的文字,用于替代隐式外部校验。
- `onConflict`:`skip`、`overwrite` 或 `append`。
目标不得是绝对路径或逃逸归档根。`append` 仅适合文本文件。
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""按 JSON 规则预演或执行保留来源的文档归档。"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
def inside(path: Path, root: Path) -> bool:
"""判断规范化路径是否位于指定根目录内。"""
try:
path.relative_to(root); return True
except ValueError:
return False
def strip_frontmatter(text: str) -> str:
"""只移除文档开头完整闭合的 YAML Frontmatter。"""
return re.sub(r"\A---\s*\r?\n.*?\r?\n---\s*\r?\n?", "", text, count=1, flags=re.S)
def extract_section(text: str, title: str) -> str | None:
"""按标题文本抽取该 Markdown 章节及其子标题。"""
lines = text.splitlines(); start = level = None
for index, line in enumerate(lines):
match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
if match and match.group(2).strip() == title:
start, level = index, len(match.group(1)); break
if start is None or level is None:
return None
end = len(lines)
for index in range(start + 1, len(lines)):
match = re.match(r"^(#{1,6})\s+", lines[index])
if match and len(match.group(1)) <= level:
end = index; break
return "\n".join(lines[start:end]).rstrip() + "\n"
def target_for(template: str, source: Path, root: Path, archive_root: Path) -> Path:
"""展开有限占位符,并拒绝绝对路径与目录逃逸。"""
relative = source.relative_to(root)
values = {"name": source.name, "stem": source.stem, "suffix": source.suffix,
"relative": relative.as_posix(),
"parent": relative.parent.as_posix() if relative.parent != Path(".") else ""}
try:
candidate = Path(template.format(**values))
except KeyError as error:
raise ValueError(f"未知目标占位符:{error.args[0]}") from error
if candidate.is_absolute():
raise ValueError("目标模板不得生成绝对路径")
resolved = (archive_root / candidate).resolve()
if not inside(resolved, archive_root):
raise ValueError("目标路径逃逸归档目录")
return resolved
def process(root: Path, config: dict, apply: bool) -> list[dict]:
"""应用全部规则并返回可持久化的操作清单。"""
archive_value = config.get("archiveRoot", "docs/archive")
archive_root = (root / archive_value).resolve()
if Path(archive_value).is_absolute() or not inside(archive_root, root):
raise ValueError("archiveRoot 必须是项目根内的相对路径")
default_conflict = config.get("defaults", {}).get("onConflict", "skip")
actions: list[dict] = []
for rule in config.get("rules", []):
conflict = rule.get("onConflict", default_conflict)
if conflict not in {"skip", "overwrite", "append"}:
raise ValueError(f"不支持的冲突策略:{conflict}")
for source in sorted(root.glob(rule["match"])):
# 归档目录自身不能再次成为来源,避免宽泛 glob 在重复执行时形成递归副本。
if not source.is_file() or not inside(source.resolve(), root) or inside(source.resolve(), archive_root):
continue
target = target_for(rule["target"], source, root, archive_root)
text = source.read_text(encoding="utf-8-sig")
missing = [value for value in rule.get("requiredText", []) if value not in text]
if missing:
actions.append({"status": "failed", "source": str(source), "target": str(target), "reason": f"缺少必需文本:{missing}"}); continue
if rule.get("stripFrontmatter"):
text = strip_frontmatter(text)
if rule.get("section"):
section = extract_section(text, rule["section"])
if section is None:
actions.append({"status": "failed", "source": str(source), "target": str(target), "reason": "未找到指定章节"}); continue
text = section
if target.exists() and conflict == "skip":
actions.append({"status": "skipped", "source": str(source), "target": str(target), "reason": "目标已存在"}); continue
status = "planned"
if apply:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists() and conflict == "append":
prior = target.read_text(encoding="utf-8")
target.write_text(prior.rstrip() + "\n\n" + text.lstrip(), encoding="utf-8")
else:
target.write_text(text, encoding="utf-8")
status = "archived"
actions.append({"status": status, "source": str(source), "target": str(target)})
return actions
def main(argv: list[str] | None = None) -> int:
"""读取配置并输出 JSON 报告;默认不写归档文件。"""
parser = argparse.ArgumentParser(description="按规则预演或执行文档归档")
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--report", type=Path)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args(argv)
root, config_path = args.root.resolve(), args.config.resolve()
if not root.is_dir() or not config_path.is_file():
print("错误:项目根或配置文件不存在", file=sys.stderr); return 2
try:
config = json.loads(config_path.read_text(encoding="utf-8-sig"))
if config.get("version") != 1 or not isinstance(config.get("rules"), list):
raise ValueError("配置必须使用 version 1 并包含 rules 数组")
actions = process(root, config, args.apply)
encoded = json.dumps({"mode": "apply" if args.apply else "dry-run", "actions": actions}, ensure_ascii=False, indent=2)
if args.report:
report_path = args.report.resolve(); report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(encoded + "\n", encoding="utf-8")
print(encoded)
return 1 if any(item["status"] == "failed" for item in actions) else 0
except (OSError, ValueError, KeyError, json.JSONDecodeError) as error:
print(f"错误:{error}", file=sys.stderr); return 1
if __name__ == "__main__":
raise SystemExit(main())
+35
View File
@@ -0,0 +1,35 @@
---
name: md-to-docx
description: 将 Markdown 文档转换为 Word .docx,保留常见标题、段落、列表、表格、代码块、链接和本地图片。适用于需要可编辑 Word 版本的 Markdown;修订留痕、复杂排版复刻或旧版 .doc 不应触发本 Skill。
---
# Markdown 转 Word
使用 `scripts/convert.py` 生成可编辑的 `.docx`。转换以语义结构清晰为目标,不承诺像素级复刻 Markdown 渲染结果。
## 执行边界
- 只接受存在的 `.md` 或 `.markdown` 文件。
- 默认在输入文件旁生成同名 `.docx`;文件已存在时停止,只有用户明确同意覆盖后才传入 `--force`。
- 用户提供 `.docx` 模板时可传入 `--template`,转换器沿用模板样式并在文档末尾追加内容,不替换模板中的占位符。
- 不自动下载图片、字体或依赖;远程图片保留为文字提示,本地缺失图片产生警告。
- 不伪造修订记录、批注或目录。需要人工审阅留痕时应使用独立的文档修订流程。
## 转换流程
1. 确认输入、输出位置和是否使用模板。
2. 使用工作区依赖运行时执行:
```text
python scripts/convert.py <input.md> [--output <file.docx>] [--template <template.docx>] [--force]
```
3. 重新打开生成文件,检查标题、列表、表格和图片统计。
4. 如当前环境具备 DOCX 渲染能力,渲染页面并目视检查;否则明确说明只完成了结构校验。
5. 报告输出路径、转换统计和降级内容。
## 支持范围
转换器支持 ATX 标题、普通段落、粗体、斜体、行内代码、链接、图片、引用、围栏代码块、水平线、无序列表、有序列表和基础 Markdown 表格。
嵌套混合列表、原始 HTML、脚注、公式、任务列表、复杂表格合并和主题级样式可能降级。输入依赖特定 Markdown 扩展时,应先说明差异,不得声称无损转换。
@@ -0,0 +1,4 @@
interface:
display_name: "Markdown to DOCX"
short_description: "将 Markdown 转换为结构清晰的 Word 文档"
default_prompt: "使用 $md-to-docx 将这份 Markdown 转换为 Word,并报告降级内容。"
@@ -0,0 +1,10 @@
# 转换支持说明
| Markdown 内容 | DOCX 表达 | 说明 |
| --- | --- | --- |
| 标题 | Heading 1 至 Heading 6 | 沿用模板同名样式 |
| 有序、无序列表 | Word 列表样式 | 复杂混排需复核 |
| 表格 | Word 表格 | 不支持单元格合并 |
| 围栏代码块 | 等宽字体段落 | 不做语法高亮 |
| 本地图片 | 内嵌图片 | 相对输入文件定位 |
| 远程图片 | 文字提示 | 不访问网络 |
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""将常见 Markdown 结构转换为可编辑 DOCX。"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from docx import Document
from docx.shared import Inches, Pt
@dataclass
class Result:
"""记录生成物与转换统计。"""
output: Path
headings: int = 0
paragraphs: int = 0
lists: int = 0
tables: int = 0
images: int = 0
warnings: list[str] = field(default_factory=list)
def cells(line: str) -> list[str]:
"""拆分基础表格行并还原转义竖线。"""
return [item.strip().replace(r"\|", "|") for item in re.split(r"(?<!\\)\|", line.strip().strip("|"))]
def separator(line: str) -> bool:
"""判断 Markdown 表格分隔行。"""
values = cells(line)
return bool(values) and all(re.fullmatch(r":?-{3,}:?", item) for item in values)
class Converter:
"""使用可预测的小型解析器转换常见 Markdown。"""
def __init__(self, source: Path, output: Path, template: Path | None) -> None:
self.source = source
self.doc = Document(template) if template else Document()
self.result = Result(output)
def convert(self) -> Result:
"""按块转换、保存并重新打开产物完成结构校验。"""
lines = self.source.read_text(encoding="utf-8-sig").splitlines()
index = 0
while index < len(lines):
if lines[index].lstrip().startswith("```"):
index = self.add_code(lines, index)
elif index + 1 < len(lines) and "|" in lines[index] and separator(lines[index + 1]):
index = self.add_table(lines, index)
else:
self.add_line(lines[index])
index += 1
self.doc.save(self.result.output)
Document(self.result.output)
return self.result
def add_code(self, lines: list[str], start: int) -> int:
"""读取围栏代码块;未闭合时输出其余内容并记录警告。"""
index = start + 1
content: list[str] = []
while index < len(lines) and not lines[index].lstrip().startswith("```"):
content.append(lines[index])
index += 1
if index == len(lines):
self.result.warnings.append("代码围栏未闭合")
paragraph = self.doc.add_paragraph()
run = paragraph.add_run("\n".join(content))
run.font.name = "Consolas"
run.font.size = Pt(9)
self.result.paragraphs += 1
return min(index + 1, len(lines))
def add_table(self, lines: list[str], start: int) -> int:
"""将连续表格行转换为统一列数的 Word 表格。"""
rows = [cells(lines[start])]
index = start + 2
while index < len(lines) and lines[index].strip() and "|" in lines[index]:
rows.append(cells(lines[index]))
index += 1
width = max(map(len, rows))
table = self.doc.add_table(rows=len(rows), cols=width)
table.style = "Table Grid"
for row_no, values in enumerate(rows):
for col_no in range(width):
table.cell(row_no, col_no).text = (values[col_no] if col_no < len(values) else "").replace("<br>", "\n")
self.result.tables += 1
return index
def add_line(self, line: str) -> None:
"""识别标题、列表、引用、分隔线和普通段落。"""
value = line.strip()
if not value:
return
heading = re.match(r"^(#{1,6})\s+(.+)$", value)
if heading:
self.add_inline(self.doc.add_heading(level=len(heading.group(1))), heading.group(2))
self.result.headings += 1
return
unordered = re.match(r"^(\s*)[-+*]\s+(.+)$", line)
ordered = re.match(r"^(\s*)\d+[.)]\s+(.+)$", line)
if unordered or ordered:
match = unordered or ordered
paragraph = self.doc.add_paragraph(style="List Bullet" if unordered else "List Number")
paragraph.paragraph_format.left_indent = Inches(min(len(match.group(1)) // 2, 4) * 0.25)
self.add_inline(paragraph, match.group(2))
self.result.lists += 1
return
if value.startswith(">"):
paragraph = self.doc.add_paragraph(value.lstrip("> "))
paragraph.paragraph_format.left_indent = Inches(0.3)
for run in paragraph.runs:
run.italic = True
self.result.paragraphs += 1
return
if re.fullmatch(r"(?:-{3,}|\*{3,}|_{3,})", value):
self.doc.add_paragraph("────────")
return
paragraph = self.doc.add_paragraph()
self.add_inline(paragraph, value)
self.result.paragraphs += 1
def add_inline(self, paragraph, text: str) -> None:
"""转换图片与基础强调;链接保留为可读地址文本。"""
pattern = re.compile(r"(!?\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)")
cursor = 0
for match in pattern.finditer(text):
paragraph.add_run(text[cursor:match.start()])
token = match.group(0)
image = re.fullmatch(r"!\[([^\]]*)\]\(([^)]+)\)", token)
link = re.fullmatch(r"\[([^\]]+)\]\(([^)]+)\)", token)
if image:
self.add_image(paragraph, image.group(1), image.group(2))
elif link:
paragraph.add_run(f"{link.group(1)}({link.group(2)})")
else:
run = paragraph.add_run(token[2:-2] if token.startswith("**") else token[1:-1])
run.bold = token.startswith("**")
run.italic = token.startswith("*") and not token.startswith("**")
if token.startswith("`"):
run.font.name = "Consolas"
cursor = match.end()
paragraph.add_run(text[cursor:])
def add_image(self, paragraph, alt: str, target: str) -> None:
"""只处理本地图片,防止转换过程产生隐式网络访问。"""
if re.match(r"^[a-z][a-z0-9+.-]*://", target, re.I):
paragraph.add_run(f"[远程图片:{alt or target}]")
self.result.warnings.append(f"未下载远程图片:{target}")
return
path = (self.source.parent / target).resolve()
if not path.is_file():
paragraph.add_run(f"[缺失图片:{alt or target}]")
self.result.warnings.append(f"本地图片不存在:{target}")
return
try:
paragraph.add_run().add_picture(str(path), width=Inches(5.8))
self.result.images += 1
except (OSError, ValueError) as error:
paragraph.add_run(f"[无法嵌入图片:{alt or target}]")
self.result.warnings.append(f"图片无法嵌入:{target}({error})")
def main(argv: list[str] | None = None) -> int:
"""校验输入、覆盖权限与模板后执行转换。"""
parser = argparse.ArgumentParser(description="将 Markdown 转换为 DOCX")
parser.add_argument("input", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--template", type=Path)
parser.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
source = args.input.resolve()
output = (args.output or source.with_suffix(".docx")).resolve()
template = args.template.resolve() if args.template else None
if not source.is_file() or source.suffix.lower() not in {".md", ".markdown"}:
print("错误:输入必须是存在的 .md 或 .markdown 文件", file=sys.stderr); return 2
if output.suffix.lower() != ".docx" or (output.exists() and not args.force):
print("错误:输出必须是可写的 .docx;覆盖需使用 --force", file=sys.stderr); return 1
if template and (not template.is_file() or template.suffix.lower() != ".docx"):
print("错误:模板必须是存在的 .docx 文件", file=sys.stderr); return 2
output.parent.mkdir(parents=True, exist_ok=True)
try:
result = Converter(source, output, template).convert()
except (OSError, ValueError) as error:
print(f"错误:{error}", file=sys.stderr); return 1
print(f"DOCX:{result.output}")
print(f"标题:{result.headings},段落:{result.paragraphs},列表:{result.lists},表格:{result.tables},图片:{result.images}")
for warning in dict.fromkeys(result.warnings): print(f"警告:{warning}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
---
name: xlsx-to-md
description: 将 Excel .xlsx 工作簿转换为 Markdown,按工作表提取表格、公式或缓存值,并处理合并单元格和空工作表。适用于需要可读文本版本的现代 Excel 文件;旧版 .xls、宏、图表或公式重算不应触发本 Skill。
---
# Excel 转 Markdown
使用 `scripts/convert.py` 将 `.xlsx` 工作簿转换为单个 Markdown 文件,每个工作表形成独立章节。
## 执行边界
- 只接受 `.xlsx`,不把 `.xls` 伪装成受支持格式。
- 默认保留公式文本;只有用户希望读取工作簿内已有缓存值时才使用 `--values`。转换器不会计算公式。
- 合并单元格默认将锚点值填充到合并区域,可用 `--merged anchor` 仅保留左上角值。
- 默认在输入文件旁生成同名 `.md`,已存在时停止;覆盖必须获得用户确认并传入 `--force`。
- 不提取宏、图表、批注、数据验证、条件格式或图片,也不自动安装依赖。
## 工作流
1. 确认公式模式、合并单元格策略和输出路径。
2. 使用工作区依赖运行时执行:
```text
python scripts/convert.py <input.xlsx> [--output <file.md>] [--values] [--merged fill|anchor] [--force]
```
3. 抽查工作表数量、标题、边界行列、公式和转义字符。
4. 报告空工作表、公式缓存为空及未迁移对象等警告。
## 语义说明
Markdown 只能表达二维文本表格。日期以 ISO 可读格式输出,单元格换行转换为 `<br>`,竖线会转义。数字显示可能与 Excel 自定义格式不同;需要报表级显示保真时应使用电子表格工具直接查看源文件。
@@ -0,0 +1,4 @@
interface:
display_name: "XLSX to Markdown"
short_description: "将 Excel 工作簿按工作表转换为 Markdown"
default_prompt: "使用 $xlsx-to-md 将这个 Excel 工作簿转换为 Markdown,并说明公式和合并单元格策略。"
@@ -0,0 +1,6 @@
# 支持范围
- 支持:工作表、二维单元格、公式文本、缓存值、日期、布尔值、错误值、合并区域。
- 降级:自定义数字格式、隐藏行列、筛选状态、富文本、超链接样式。
- 不支持:`.xls`、VBA、图表、图片、批注、数据透视表、公式计算。
- 默认公式模式比缓存值模式更稳定,因为缓存值可能缺失或过期。
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""将 XLSX 工作簿的二维数据转换为 Markdown。"""
from __future__ import annotations
import argparse
import datetime as dt
import sys
from pathlib import Path
from openpyxl import load_workbook
def display(value: object) -> str:
"""将单元格值转换为稳定、可读且适合表格的文本。"""
if value is None:
return ""
if isinstance(value, dt.datetime):
value = value.isoformat(sep=" ", timespec="seconds")
elif isinstance(value, (dt.date, dt.time)):
value = value.isoformat()
elif isinstance(value, bool):
value = "TRUE" if value else "FALSE"
text = str(value).replace("\\", "\\\\").replace("|", "\\|")
return text.replace("\r\n", "<br>").replace("\n", "<br>").replace("\r", "<br>")
def matrix_for(sheet, merged_mode: str) -> tuple[list[list[str]], int]:
"""读取有效区域,并按选择的策略表达合并单元格。"""
values = [[cell.value for cell in row] for row in sheet.iter_rows()]
merged_count = len(sheet.merged_cells.ranges)
if merged_mode == "fill":
for merged in sheet.merged_cells.ranges:
anchor = values[merged.min_row - 1][merged.min_col - 1]
for row in range(merged.min_row - 1, merged.max_row):
for column in range(merged.min_col - 1, merged.max_col):
values[row][column] = anchor
while values and all(value is None for value in values[-1]):
values.pop()
width = max((max((i + 1 for i, value in enumerate(row) if value is not None), default=0) for row in values), default=0)
return [[display(value) for value in row[:width]] for row in values], merged_count
def render_sheet(title: str, rows: list[list[str]]) -> str:
"""将单个工作表渲染为 Markdown 章节。"""
lines = [f"## {title}", ""]
if not rows or not rows[0]:
return "\n".join(lines + ["_空工作表_"])
width = max(map(len, rows))
normalized = [row + [""] * (width - len(row)) for row in rows]
lines.extend((f"| {' | '.join(normalized[0])} |", f"| {' | '.join(['---'] * width)} |"))
lines.extend(f"| {' | '.join(row)} |" for row in normalized[1:])
return "\n".join(lines)
def convert(source: Path, output: Path, values_only: bool, merged_mode: str) -> tuple[int, int, int, list[str]]:
"""打开工作簿、转换全部工作表并写入 UTF-8 Markdown。"""
workbook = load_workbook(source, read_only=False, data_only=values_only)
sections = [f"# {source.stem}"]
row_count = merged_count = 0
warnings: list[str] = []
for sheet in workbook.worksheets:
rows, merged = matrix_for(sheet, merged_mode)
sections.append(render_sheet(sheet.title, rows))
row_count += len(rows)
merged_count += merged
if not rows or not rows[0]:
warnings.append(f"空工作表:{sheet.title}")
sheet_count = len(workbook.worksheets)
workbook.close()
output.write_text("\n\n".join(sections).rstrip() + "\n", encoding="utf-8")
return sheet_count, row_count, merged_count, warnings
def main(argv: list[str] | None = None) -> int:
"""校验文件类型、覆盖授权和输出路径后执行转换。"""
parser = argparse.ArgumentParser(description="将 XLSX 工作簿转换为 Markdown")
parser.add_argument("input", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--values", action="store_true", help="读取缓存值而不是公式文本")
parser.add_argument("--merged", choices=("fill", "anchor"), default="fill")
parser.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
source = args.input.resolve()
output = (args.output or source.with_suffix(".md")).resolve()
if not source.is_file() or source.suffix.lower() != ".xlsx":
print("错误:只支持存在的 .xlsx 文件;旧版 .xls 不受支持", file=sys.stderr); return 2
if output.suffix.lower() not in {".md", ".markdown"}:
print("错误:输出文件必须是 Markdown", file=sys.stderr); return 2
if output.exists() and not args.force:
print(f"错误:输出文件已存在:{output}", file=sys.stderr); return 1
output.parent.mkdir(parents=True, exist_ok=True)
try:
sheets, rows, merged, warnings = convert(source, output, args.values, args.merged)
except (OSError, ValueError) as error:
print(f"错误:{error}", file=sys.stderr); return 1
print(f"Markdown:{output}")
print(f"工作表:{sheets},数据行:{rows},合并区域:{merged}")
for warning in warnings: print(f"警告:{warning}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -6,6 +6,7 @@
"code": { "sourceRoots": [], "packageRoots": [], "modules": [] }, "code": { "sourceRoots": [], "packageRoots": [], "modules": [] },
"dependencies": { "internal": [], "public": [] }, "dependencies": { "internal": [], "public": [] },
"commands": { "build": [], "test": [], "check": [] }, "commands": { "build": [], "test": [], "check": [] },
"documents": { "archiveRoot": "docs/archive", "archiveRules": [] },
"guidance": { "guidance": {
"agentIndex": ".craftkit/agents/index.md", "agentIndex": ".craftkit/agents/index.md",
"standardsIndex": ".craftkit/standards/index.md", "standardsIndex": ".craftkit/standards/index.md",
@@ -10,6 +10,7 @@
- `code` 记录相对源码根、包根和模块。 - `code` 记录相对源码根、包根和模块。
- `dependencies.internal` 只记录用户确认可在当前仓库共享的依赖标识和用途。 - `dependencies.internal` 只记录用户确认可在当前仓库共享的依赖标识和用途。
- `commands` 只记录经过项目文件或用户确认的命令。 - `commands` 只记录经过项目文件或用户确认的命令。
- `documents.archiveRoot` 记录可共享的文档归档根;`documents.archiveRules` 记录项目确认的匹配、目标模板、转换和校验规则,不写入公司固定目录或外部系统凭据。
- `guidance` 指向 `.craftkit/` 内的索引入口。 - `guidance` 指向 `.craftkit/` 内的索引入口。
- `initialization.references` 记录参考项目名称、用途、允许提炼范围和可共享的相对位置。 - `initialization.references` 记录参考项目名称、用途、允许提炼范围和可共享的相对位置。