#!/usr/bin/env python3 import sys from datetime import datetime, timezone from pathlib import Path from shutil import copy2, copytree import subprocess import tomllib ORDER = ["vendor", "sync_templates", "sync_standards", "install_skills", "format_md"] SCRIPT_DIR = Path(__file__).resolve().parent PLAYBOOK_ROOT = SCRIPT_DIR.parent def usage() -> str: return "Usage:\n python scripts/playbook.py -config \n python scripts/playbook.py -h" def load_config(path: Path) -> dict: return tomllib.loads(path.read_text(encoding="utf-8")) def log(message: str) -> None: print(message) def ensure_dir(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) def normalize_langs(raw: object) -> list[str]: if raw is None: return ["tsl"] if isinstance(raw, str): langs = [raw] else: langs = list(raw) cleaned: list[str] = [] for lang in langs: item = str(lang).strip() if not item: continue if "/" in item or "\\" in item or ".." in item: raise ValueError(f"invalid lang: {item}") cleaned.append(item) if not cleaned: raise ValueError("langs is empty") return cleaned def read_git_commit(root: Path) -> str: try: result = subprocess.run( ["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True, check=True, ) except (OSError, subprocess.CalledProcessError): return "N/A" return result.stdout.strip() or "N/A" def write_docs_index(dest_prefix: Path, langs: list[str]) -> None: lines = [ "# 文档导航(Docs Index)", "", f"本快照为裁剪版 Playbook(langs: {','.join(langs)})。", "", "## 跨语言(common)", "", "- 提交信息与版本号:`common/commit_message.md`", ] for lang in langs: if lang == "tsl": lines += [ "", "## TSL(tsl)", "", "- 代码风格:`tsl/code_style.md`", "- 命名规范:`tsl/naming.md`", "- 语法手册:`tsl/syntax_book/index.md`", "- 工具链与验证命令(模板):`tsl/toolchain.md`", ] elif lang == "cpp": lines += [ "", "## C++(cpp)", "", "- 代码风格:`cpp/code_style.md`", "- 命名规范:`cpp/naming.md`", "- 工具链与验证命令(模板):`cpp/toolchain.md`", "- 第三方依赖(Conan):`cpp/dependencies_conan.md`", "- clangd 配置:`cpp/clangd.md`", ] elif lang == "python": lines += [ "", "## Python(python)", "", "- 代码风格:`python/style_guide.md`", "- 工具链:`python/tooling.md`", "- 配置清单:`python/configuration.md`", ] elif lang == "markdown": lines += [ "", "## Markdown(markdown)", "", "- 代码块与行内代码格式:`markdown/index.md`", ] docs_index = dest_prefix / "docs/index.md" ensure_dir(docs_index.parent) docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8") def write_snapshot_readme(dest_prefix: Path, langs: list[str]) -> None: lines = [ "# Playbook(裁剪快照)", "", f"本目录为从 Playbook vendoring 的裁剪快照(langs: {','.join(langs)})。", "", "## 使用", "", "在目标项目根目录执行:", "", "```sh", "python docs/standards/playbook/scripts/playbook.py -config playbook.toml", "```", "", "配置示例:`docs/standards/playbook/playbook.toml.example`", "", "文档入口:", "", "- `docs/standards/playbook/docs/index.md`", "- `.agents/index.md`", ] (dest_prefix / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8") def write_source_file(dest_prefix: Path, langs: list[str]) -> None: commit = read_git_commit(PLAYBOOK_ROOT) timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") lines = [ "# SOURCE", "", f"- Source: {PLAYBOOK_ROOT}", f"- Commit: {commit}", f"- Date: {timestamp}", f"- Langs: {','.join(langs)}", "- Generated-by: scripts/playbook.py", ] (dest_prefix / "SOURCE.md").write_text("\n".join(lines) + "\n", encoding="utf-8") def vendor_action(config: dict, context: dict) -> int: try: langs = normalize_langs(config.get("langs")) except ValueError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 target_dir = config.get("target_dir", "docs/standards/playbook") target_path = Path(target_dir) if target_path.is_absolute() or ".." in target_path.parts: print(f"ERROR: invalid target_dir: {target_dir}", file=sys.stderr) return 2 project_root: Path = context["project_root"] dest_prefix = project_root / target_path dest_standards = dest_prefix.parent ensure_dir(dest_standards) if dest_prefix.exists(): timestamp = datetime.now().strftime("%Y%m%d%H%M%S") backup = dest_standards / f"{dest_prefix.name}.bak.{timestamp}" dest_prefix.rename(backup) log(f"Backed up existing snapshot -> {backup}") ensure_dir(dest_prefix) gitattributes_src = PLAYBOOK_ROOT / ".gitattributes" if gitattributes_src.is_file(): copy2(gitattributes_src, dest_prefix / ".gitattributes") copytree(PLAYBOOK_ROOT / "scripts", dest_prefix / "scripts") copytree(PLAYBOOK_ROOT / "codex", dest_prefix / "codex") copy2(PLAYBOOK_ROOT / "SKILLS.md", dest_prefix / "SKILLS.md") common_docs = PLAYBOOK_ROOT / "docs/common" if common_docs.is_dir(): copytree(common_docs, dest_prefix / "docs/common") rulesets_root = PLAYBOOK_ROOT / "rulesets" ensure_dir(dest_prefix / "rulesets") if (rulesets_root / "index.md").is_file(): copy2(rulesets_root / "index.md", dest_prefix / "rulesets/index.md") templates_ci = PLAYBOOK_ROOT / "templates/ci" if templates_ci.is_dir(): copytree(templates_ci, dest_prefix / "templates/ci") for lang in langs: docs_src = PLAYBOOK_ROOT / "docs" / lang rules_src = PLAYBOOK_ROOT / "rulesets" / lang if not docs_src.is_dir(): print(f"ERROR: docs not found for lang={lang}", file=sys.stderr) return 2 if not rules_src.is_dir(): print(f"ERROR: rulesets not found for lang={lang}", file=sys.stderr) return 2 copytree(docs_src, dest_prefix / "docs" / lang) copytree(rules_src, dest_prefix / "rulesets" / lang) templates_src = PLAYBOOK_ROOT / "templates" / lang if templates_src.is_dir(): copytree(templates_src, dest_prefix / "templates" / lang) example_config = PLAYBOOK_ROOT / "playbook.toml.example" if example_config.is_file(): copy2(example_config, dest_prefix / "playbook.toml.example") write_docs_index(dest_prefix, langs) write_snapshot_readme(dest_prefix, langs) write_source_file(dest_prefix, langs) log(f"Vendored snapshot -> {dest_prefix}") return 0 def run_action(name: str, config: dict, context: dict) -> int: if name == "vendor": return vendor_action(config, context) print(f"[action] {name}") return 0 def main(argv: list[str]) -> int: if "-h" in argv or "-help" in argv: print(usage()) return 0 if "-config" not in argv: print("ERROR: -config is required.\n" + usage(), file=sys.stderr) return 2 idx = argv.index("-config") if idx + 1 >= len(argv) or not argv[idx + 1]: print("ERROR: -config requires a path.\n" + usage(), file=sys.stderr) return 2 config_path = Path(argv[idx + 1]).expanduser() if not config_path.is_file(): print(f"ERROR: config not found: {config_path}", file=sys.stderr) return 2 config = load_config(config_path) playbook_config = config.get("playbook", {}) project_root = playbook_config.get("project_root") if project_root: root = Path(project_root).expanduser() else: root = config_path.parent context = {"project_root": root.resolve(), "config_path": config_path.resolve()} for name in ORDER: if name in config: result = run_action(name, config[name], context) if result != 0: return result return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))