diff --git a/AGENTS.md b/AGENTS.md index 1f5adb2..c65aff3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ plugins//skills// - 迁移采用“分析能力 → 编写中性规格 → 脱离原文独立实现”的方式,不做目录复制、批量替词或近义改写。 - 不复制来源 Skill 的正文、脚本、提示词、模板、示例、规则库、注释或独特文档结构。 - 不写入公司名称、产品名称、内部域名、内部包名、项目名、人员信息、业务规则或环境路径。 -- 公司框架 API、组件契约、审批流程、版本矩阵和内部消息格式默认排除。 +- 公司框架 API、组件契约、审批流程、版本矩阵和内部消息格式不得直接迁移;评估时应先抽象其通用问题,并优先设计基于项目配置、公开标准或用户显式输入的中性平替。只有不存在独立通用价值、无法安全替代或与 Codex 场景不成立时才排除。 - 通用技术规范必须根据公开标准或官方资料重新设计;需要引用时记录来源和许可证。 - 来源文件只用于人工分析和哈希追踪,不得进入 `plugins/` 发布目录。 - 上游变化只触发复核,不自动覆盖已迁移 Skill。 @@ -105,12 +105,12 @@ plugins//skills// | 来源标识 | 使用本地迁移标识,不在发布目录写入公司品牌 | | 来源 Skill | 原始名称和相对路径 | | 当前作用 | 根据源码确认的能力、输入、输出和关键边界 | -| 迁移类型 | 新增、更新、合并、公开资料重建或排除 | +| 迁移类型 | 新增、更新、合并、中性平替、公开资料重建或排除 | | 风险 | 公司专属知识、内部依赖、重复能力和兼容性问题 | 来源作用必须有文件依据;无法从静态内容确认的运行行为应明确标记为未验证。 -同一目标能力存在多个来源时,应在一个迁移方案中共同评估,明确合并、取舍或排除关系,不按来源机械创建多个目标 Skill。 +同一目标能力存在多个来源时,应在一个迁移方案中共同评估,明确合并、取舍、中性平替或排除关系,不按来源机械创建多个目标 Skill。来源含有大量专有规则时,不得只罗列删除项;必须说明通用问题如何由配置、标准接口、用户输入或项目资料替代。 ### 3. 给出目标名称、作用和组织结构 diff --git a/README.md b/README.md index 69125a9..f4017d7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ CraftKit 是一组面向 Codex 插件市场的中性 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` | | `knowledge` | 项目初始化、交接、复盘与经验 | 已迁移 `handoff`、`init` | | `skill` | 项目规范及 Skill 创建、迁移与维护 | 已迁移 `guidance` | @@ -37,6 +37,7 @@ CraftKit/ ## 开发约束 - Skill 必须独立重写,不复制公司插件的正文、脚本、模板、示例或规则库。 +- 专有能力应先抽象为可配置、基于公开标准或用户显式输入的中性平替;不能仅因专有内容较多就整体排除。 - 发布内容不得出现公司名称、产品名称、内部域名、内部包名、项目名或人员信息。 - 每个 Skill 文件夹名必须与 `SKILL.md` frontmatter 的 `name` 一致。 - 新增或修改插件后必须执行 Skill 校验、插件校验和去公司化扫描。 diff --git a/migration/MIGRATION_PLAN.md b/migration/MIGRATION_PLAN.md index 910eb02..e04a266 100644 --- a/migration/MIGRATION_PLAN.md +++ b/migration/MIGRATION_PLAN.md @@ -4,7 +4,7 @@ 将两个本地来源中的通用能力整理为 CraftKit Skill。迁移结果必须适用于 Codex 插件市场,并与来源项目的品牌、内部框架、业务知识和运行环境解耦。 -迁移不追求来源 Skill 与目标 Skill 一一对应。重复能力应合并,公司专属能力应排除,目标数量以职责清晰和实际复用价值为准。 +迁移不追求来源 Skill 与目标 Skill 一一对应。重复能力应合并;公司专属实现不得迁移,但应优先将其解决的通用问题重建为中性平替,确无独立价值或无法安全替代时才排除。目标数量以职责清晰和实际复用价值为准。 ## 2. 迁移路径 @@ -60,9 +60,9 @@ 5. `knowledge/handoff`:模板化文档与项目上下文样本,已完成。 6. `skill/guidance`:大型参考资料、索引和渐进式加载样本,已完成;只检索项目资料和基于公开一级资料独立重建的中性公共基线。 7. `knowledge/init`:成熟项目与空项目双模式初始化、参考项目提炼和共享/本地信息边界样本,已完成。 -8. `skill/migrate`:脚本、检查、同步和状态追踪组成的复合工作流样本。 +8. `skill/migrate`:不迁移。来源能力用于把 Claude Code Skill 转换为 Codex Skill;CraftKit 自始按 Codex 规范开发,不存在平台转换需求。来源扫描、目标检查和状态追踪继续由 `migration/scripts/` 作为仓库维护设施承担。 -完成门槛:每种样本均通过对应验证,并形成可复用的命名、目录、独立实现、测试、扫描和状态同步规则。出现未覆盖的新结构或权限类型时,应补充样本,不直接扩批。 +完成门槛:适用样本均通过对应验证,不适用样本记录排除依据,并形成可复用的命名、目录、独立实现、测试、扫描和状态同步规则。出现未覆盖的新结构或权限类型时,应补充样本,不直接扩批。当前特殊样本阶段已完成,可以进入同质批量迁移。 ### 第 2 阶段:同质批量迁移 @@ -78,9 +78,9 @@ 所属插件:`doc` 1. `docx-to-md` -2. `markdown-to-docx` -3. `excel-to-markdown` -4. `archive-docs` +2. `md-to-docx`(已完成) +3. `xlsx-to-md`(已完成) +4. `archive`(已完成,以可配置规则平替固定目录、业务文件名、专有章节拆分和内部系统校验) 重点验证图片、表格、合并单元格、编码、覆盖策略和路径安全。脚本及测试数据必须独立创建。 @@ -230,5 +230,6 @@ plan-change - [x] 迁移并验证只读 Git 特殊样本 `git/commit-msg`。 - [x] 迁移并验证有副作用 Git 特殊样本 `git/branch`。 - [x] 迁移并验证模板化交接特殊样本 `knowledge/handoff`。 -- [ ] 完成其余特殊样本并总结批量迁移规则。 -- [ ] 按插件和风险类型推进同质批量迁移。 +- [x] 完成其余特殊样本并总结批量迁移规则。 +- [ ] 按插件和风险类型继续推进同质批量迁移。 +- [x] 完成首个同质批量:`md-to-docx`、`xlsx-to-md`、`archive`。 diff --git a/migration/source-lock.json b/migration/source-lock.json index d7f1a92..d3272bc 100644 --- a/migration/source-lock.json +++ b/migration/source-lock.json @@ -63,12 +63,17 @@ "source-a:4efb6f9dd0f39dff": { "sourcePathHash": "4efb6f9dd0f39dff13339e62ff24b672552efcbcedb007e9ea86fe41950adbb0", "sourceSha256": "a914823bdf50d9e5c8ee70d6363c5c0e7aea341ed390587a9285c70a7047558e", - "status": "pending" + "status": "migrated", + "target": "plugins/doc/skills/xlsx-to-md", + "targetVersion": "0.1.0", + "reviewedAt": "2026-08-25" }, "source-a:32e7cfc21d252985": { "sourcePathHash": "32e7cfc21d25298559bffbe7f0918f0f6e7d2aac29e3eca39d1f43d8226cf934", "sourceSha256": "0f0486e3f25279a1a1cb8cecfef4dc01830471b5d31b52a9d17a425919997bc2", - "status": "pending" + "status": "excluded", + "reason": "CraftKit 原生面向 Codex,不需要 Claude Code 到 Codex 的平台转换能力", + "reviewedAt": "2026-08-25" }, "source-a:bc91b57fa2a50049": { "sourcePathHash": "bc91b57fa2a500498b31b0f1a87dfa8ab83c32e4b361b4ea1614ea6721234b7c", @@ -162,7 +167,10 @@ "source-b:b127c47139270a7f": { "sourcePathHash": "b127c47139270a7f9157e18ad6a0c975b99caf07b9a0874daeac86d9a99ce48c", "sourceSha256": "0214b80071f07beaff4f5837b983546ff71dbaa65bc29e34a5a23c9d946b683b", - "status": "pending" + "status": "migrated", + "target": "plugins/doc/skills/md-to-docx", + "targetVersion": "0.1.0", + "reviewedAt": "2026-08-25" }, "source-b:34ab291ce3cfed2e": { "sourcePathHash": "34ab291ce3cfed2e69cc6f08309b1a41142be91235b9a573daf98639b4a001cc", @@ -319,7 +327,10 @@ "source-b:3763e17cff825df6": { "sourcePathHash": "3763e17cff825df6920f57d84736093082014f2a0316d9e84ee18ac8078ff6e5", "sourceSha256": "bd2e4ae7f8a9bded1f7a983289fb49556f30cd5d6c6382500dd8c464fdd66e02", - "status": "pending" + "status": "migrated", + "target": "plugins/doc/skills/archive", + "targetVersion": "0.1.0", + "reviewedAt": "2026-08-25" }, "source-b:9e3e75840a46af96": { "sourcePathHash": "9e3e75840a46af96c33a1885f192813239b511eb972a4962b30b76de7392c88c", @@ -340,7 +351,9 @@ "source-b:988b200efd0b8be0": { "sourcePathHash": "988b200efd0b8be024060e0785da2f864bf7ee30d7c7b434fde7ab1e4cdcc9d8", "sourceSha256": "e7fa073d592167c07b06698d664192baafb126afa1a6bfdc50a056e1ea13e56f", - "status": "pending" + "status": "excluded", + "reason": "CraftKit 原生面向 Codex,不需要 Claude Code 到 Codex 的平台转换能力", + "reviewedAt": "2026-08-25" }, "source-b:8a7cdf719d8f8a3e": { "sourcePathHash": "8a7cdf719d8f8a3e0200781a82062cc5a839e4d2c2fd400982771968245d62dd", diff --git a/migration/tests/test_doc_batch.py b/migration/tests/test_doc_batch.py new file mode 100644 index 0000000..cc41e7c --- /dev/null +++ b/migration/tests/test_doc_batch.py @@ -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() diff --git a/plugins/doc/.codex-plugin/plugin.json b/plugins/doc/.codex-plugin/plugin.json index 1a4a416..7302a7e 100644 --- a/plugins/doc/.codex-plugin/plugin.json +++ b/plugins/doc/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "doc", - "version": "0.1.0", - "description": "通用文档转换、整理与写作工具。", + "version": "0.2.0", + "description": "通用文档转换、表格提取、规则化归档与写作工具。", "author": { "name": "CraftKit" }, @@ -9,7 +9,7 @@ "interface": { "displayName": "Doc", "shortDescription": "文档转换、整理与写作工具", - "longDescription": "提供 Markdown、Word、表格及结构化写作相关的通用工作流。", + "longDescription": "提供 Markdown、Word、Excel、规则化归档及结构化写作相关的通用工作流。", "developerName": "CraftKit", "category": "Productivity", "capabilities": ["Read", "Write"], diff --git a/plugins/doc/skills/archive/SKILL.md b/plugins/doc/skills/archive/SKILL.md new file mode 100644 index 0000000..71c7e51 --- /dev/null +++ b/plugins/doc/skills/archive/SKILL.md @@ -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 --config [--report ] +python scripts/archive.py --root --config --apply [--report ] +``` + +## 中性平替原则 + +- 固定公司目录改为 `archiveRoot` 与规则级 `target` 模板。 +- 固定业务文件名改为 `{name}`、`{stem}`、`{suffix}`、`{relative}` 占位符。 +- 专有文档拆分逻辑改为通用 Markdown 标题章节抽取。 +- 内部数据库或服务校验改为显式 `requiredText` 内容校验;需要外部事实时由用户先提供结果,不隐式连接系统。 +- 来源专属元数据清理改为可选 `stripFrontmatter`,不会默认删除内容。 + +## 安全边界 + +- 默认只预演;`--apply` 才写入。 +- 永不删除或移动来源文件。 +- 来源必须位于项目根内,目标必须位于归档根内;拒绝绝对路径和 `..` 逃逸。 +- 冲突策略仅允许 `skip`、`overwrite`、`append`,默认 `skip`。覆盖或追加必须在用户确认的配置中明确出现。 +- 规则不执行 Shell、SQL、模板代码或网络请求。 + +完整配置见 `references/config.md`。 diff --git a/plugins/doc/skills/archive/agents/openai.yaml b/plugins/doc/skills/archive/agents/openai.yaml new file mode 100644 index 0000000..65565e4 --- /dev/null +++ b/plugins/doc/skills/archive/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Archive Documents" + short_description: "按项目规则安全预演并归档文档" + default_prompt: "使用 $archive 根据项目规则预演文档归档,确认后再执行写入。" diff --git a/plugins/doc/skills/archive/references/config.md b/plugins/doc/skills/archive/references/config.md new file mode 100644 index 0000000..98756e3 --- /dev/null +++ b/plugins/doc/skills/archive/references/config.md @@ -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` 仅适合文本文件。 diff --git a/plugins/doc/skills/archive/scripts/archive.py b/plugins/doc/skills/archive/scripts/archive.py new file mode 100644 index 0000000..90d3a1d --- /dev/null +++ b/plugins/doc/skills/archive/scripts/archive.py @@ -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()) diff --git a/plugins/doc/skills/md-to-docx/SKILL.md b/plugins/doc/skills/md-to-docx/SKILL.md new file mode 100644 index 0000000..f41c5d3 --- /dev/null +++ b/plugins/doc/skills/md-to-docx/SKILL.md @@ -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 [--output ] [--template ] [--force] + ``` + +3. 重新打开生成文件,检查标题、列表、表格和图片统计。 +4. 如当前环境具备 DOCX 渲染能力,渲染页面并目视检查;否则明确说明只完成了结构校验。 +5. 报告输出路径、转换统计和降级内容。 + +## 支持范围 + +转换器支持 ATX 标题、普通段落、粗体、斜体、行内代码、链接、图片、引用、围栏代码块、水平线、无序列表、有序列表和基础 Markdown 表格。 + +嵌套混合列表、原始 HTML、脚注、公式、任务列表、复杂表格合并和主题级样式可能降级。输入依赖特定 Markdown 扩展时,应先说明差异,不得声称无损转换。 diff --git a/plugins/doc/skills/md-to-docx/agents/openai.yaml b/plugins/doc/skills/md-to-docx/agents/openai.yaml new file mode 100644 index 0000000..b9edebe --- /dev/null +++ b/plugins/doc/skills/md-to-docx/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Markdown to DOCX" + short_description: "将 Markdown 转换为结构清晰的 Word 文档" + default_prompt: "使用 $md-to-docx 将这份 Markdown 转换为 Word,并报告降级内容。" diff --git a/plugins/doc/skills/md-to-docx/references/support.md b/plugins/doc/skills/md-to-docx/references/support.md new file mode 100644 index 0000000..44180bb --- /dev/null +++ b/plugins/doc/skills/md-to-docx/references/support.md @@ -0,0 +1,10 @@ +# 转换支持说明 + +| Markdown 内容 | DOCX 表达 | 说明 | +| --- | --- | --- | +| 标题 | Heading 1 至 Heading 6 | 沿用模板同名样式 | +| 有序、无序列表 | Word 列表样式 | 复杂混排需复核 | +| 表格 | Word 表格 | 不支持单元格合并 | +| 围栏代码块 | 等宽字体段落 | 不做语法高亮 | +| 本地图片 | 内嵌图片 | 相对输入文件定位 | +| 远程图片 | 文字提示 | 不访问网络 | diff --git a/plugins/doc/skills/md-to-docx/scripts/convert.py b/plugins/doc/skills/md-to-docx/scripts/convert.py new file mode 100644 index 0000000..e63da38 --- /dev/null +++ b/plugins/doc/skills/md-to-docx/scripts/convert.py @@ -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"(? 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("
", "\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()) diff --git a/plugins/doc/skills/xlsx-to-md/SKILL.md b/plugins/doc/skills/xlsx-to-md/SKILL.md new file mode 100644 index 0000000..416d94c --- /dev/null +++ b/plugins/doc/skills/xlsx-to-md/SKILL.md @@ -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 [--output ] [--values] [--merged fill|anchor] [--force] + ``` + +3. 抽查工作表数量、标题、边界行列、公式和转义字符。 +4. 报告空工作表、公式缓存为空及未迁移对象等警告。 + +## 语义说明 + +Markdown 只能表达二维文本表格。日期以 ISO 可读格式输出,单元格换行转换为 `
`,竖线会转义。数字显示可能与 Excel 自定义格式不同;需要报表级显示保真时应使用电子表格工具直接查看源文件。 diff --git a/plugins/doc/skills/xlsx-to-md/agents/openai.yaml b/plugins/doc/skills/xlsx-to-md/agents/openai.yaml new file mode 100644 index 0000000..4e2791b --- /dev/null +++ b/plugins/doc/skills/xlsx-to-md/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "XLSX to Markdown" + short_description: "将 Excel 工作簿按工作表转换为 Markdown" + default_prompt: "使用 $xlsx-to-md 将这个 Excel 工作簿转换为 Markdown,并说明公式和合并单元格策略。" diff --git a/plugins/doc/skills/xlsx-to-md/references/support.md b/plugins/doc/skills/xlsx-to-md/references/support.md new file mode 100644 index 0000000..130cc10 --- /dev/null +++ b/plugins/doc/skills/xlsx-to-md/references/support.md @@ -0,0 +1,6 @@ +# 支持范围 + +- 支持:工作表、二维单元格、公式文本、缓存值、日期、布尔值、错误值、合并区域。 +- 降级:自定义数字格式、隐藏行列、筛选状态、富文本、超链接样式。 +- 不支持:`.xls`、VBA、图表、图片、批注、数据透视表、公式计算。 +- 默认公式模式比缓存值模式更稳定,因为缓存值可能缺失或过期。 diff --git a/plugins/doc/skills/xlsx-to-md/scripts/convert.py b/plugins/doc/skills/xlsx-to-md/scripts/convert.py new file mode 100644 index 0000000..1257aba --- /dev/null +++ b/plugins/doc/skills/xlsx-to-md/scripts/convert.py @@ -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", "
").replace("\n", "
").replace("\r", "
") + + +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()) diff --git a/plugins/knowledge/skills/init/assets/project.json b/plugins/knowledge/skills/init/assets/project.json index 2017b15..b7acfd9 100644 --- a/plugins/knowledge/skills/init/assets/project.json +++ b/plugins/knowledge/skills/init/assets/project.json @@ -6,6 +6,7 @@ "code": { "sourceRoots": [], "packageRoots": [], "modules": [] }, "dependencies": { "internal": [], "public": [] }, "commands": { "build": [], "test": [], "check": [] }, + "documents": { "archiveRoot": "docs/archive", "archiveRules": [] }, "guidance": { "agentIndex": ".craftkit/agents/index.md", "standardsIndex": ".craftkit/standards/index.md", diff --git a/plugins/knowledge/skills/init/references/project-config.md b/plugins/knowledge/skills/init/references/project-config.md index f2fcbae..5a09aea 100644 --- a/plugins/knowledge/skills/init/references/project-config.md +++ b/plugins/knowledge/skills/init/references/project-config.md @@ -10,6 +10,7 @@ - `code` 记录相对源码根、包根和模块。 - `dependencies.internal` 只记录用户确认可在当前仓库共享的依赖标识和用途。 - `commands` 只记录经过项目文件或用户确认的命令。 +- `documents.archiveRoot` 记录可共享的文档归档根;`documents.archiveRules` 记录项目确认的匹配、目标模板、转换和校验规则,不写入公司固定目录或外部系统凭据。 - `guidance` 指向 `.craftkit/` 内的索引入口。 - `initialization.references` 记录参考项目名称、用途、允许提炼范围和可共享的相对位置。