From 3d1582ce9e9e03323b69367aa1725c102dfadeac Mon Sep 17 00:00:00 2001 From: csh Date: Fri, 23 Jan 2026 14:37:55 +0800 Subject: [PATCH] :sparkles: feat(sync): add templates and standards actions --- scripts/playbook.py | 420 ++++++++++++++++++++++++++++++++- tests/cli/test_playbook_cli.py | 36 +++ 2 files changed, 455 insertions(+), 1 deletion(-) diff --git a/scripts/playbook.py b/scripts/playbook.py index d444b4c..7166fa6 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -228,10 +228,426 @@ def vendor_action(config: dict, context: dict) -> int: return 0 +def replace_placeholders(text: str, project_name: str | None, date_value: str) -> str: + result = text.replace("{{DATE}}", date_value) + if project_name: + result = result.replace("{{PROJECT_NAME}}", project_name) + return result + + +def backup_path(path: Path, no_backup: bool) -> None: + if not path.exists() or no_backup: + return + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + backup = path.with_name(f"{path.name}.bak.{timestamp}") + path.rename(backup) + log(f"Backed up: {path} -> {backup}") + + +def rename_template_files(root: Path) -> None: + for template in root.rglob("*.template.md"): + target = template.with_name(template.name.replace(".template.md", ".md")) + template.rename(target) + + +def replace_placeholders_in_dir(root: Path, project_name: str | None, date_value: str) -> None: + for file_path in root.rglob("*.md"): + text = file_path.read_text(encoding="utf-8") + updated = replace_placeholders(text, project_name, date_value) + if updated != text: + file_path.write_text(updated, encoding="utf-8") + + +def extract_block_lines(text: str, start: str, end: str) -> list[str]: + lines = text.splitlines() + block: list[str] = [] + in_block = False + for line in lines: + if line.strip() == start: + in_block = True + if in_block: + block.append(line) + if in_block and line.strip() == end: + break + if not block or block[-1].strip() != end: + return [] + return block + + +def update_agents_section( + agents_path: Path, + template_path: Path, + start_marker: str, + end_marker: str, + project_name: str | None, + date_value: str, +) -> None: + template_text = template_path.read_text(encoding="utf-8") + template_text = replace_placeholders(template_text, project_name, date_value) + block = extract_block_lines(template_text, start_marker, end_marker) + if not block: + log("Skip: markers not found in template") + return + + if not agents_path.exists(): + agents_path.write_text(template_text + "\n", encoding="utf-8") + log("Created: AGENTS.md") + return + + agents_text = agents_path.read_text(encoding="utf-8") + if start_marker in agents_text: + lines = agents_text.splitlines() + updated: list[str] = [] + in_block = False + replaced = False + for line in lines: + if not replaced and line.strip() == start_marker: + updated.extend(block) + in_block = True + replaced = True + continue + if in_block: + if line.strip() == end_marker: + in_block = False + continue + updated.append(line) + agents_path.write_text("\n".join(updated) + "\n", encoding="utf-8") + log("Updated: AGENTS.md (section)") + else: + if ".agents/index.md" in agents_text: + log("Skip: AGENTS.md already references .agents/index.md") + return + updated = agents_text.rstrip("\n") + "\n\n" + "\n".join(block) + "\n" + agents_path.write_text(updated, encoding="utf-8") + log("Appended: AGENTS.md (section)") + + +def sync_templates_action(config: dict, context: dict) -> int: + project_root: Path = context["project_root"] + if project_root.resolve() == PLAYBOOK_ROOT.resolve(): + log("Skip: playbook root equals project root.") + return 0 + + templates_dir = PLAYBOOK_ROOT / "templates" + if not templates_dir.is_dir(): + print(f"ERROR: templates not found: {templates_dir}", file=sys.stderr) + return 2 + + project_name = config.get("project_name") + date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d") + force = bool(config.get("force", False)) + no_backup = bool(config.get("no_backup", False)) + full = bool(config.get("full", False)) + + memory_src = templates_dir / "memory-bank" + prompts_src = templates_dir / "prompts" + agents_src = templates_dir / "AGENTS.template.md" + rules_src = templates_dir / "AGENT_RULES.template.md" + + if memory_src.is_dir(): + memory_dst = project_root / "memory-bank" + if memory_dst.exists() and not force: + log("memory-bank/ already exists. Use force to overwrite.") + else: + backup_path(memory_dst, no_backup) + copytree(memory_src, memory_dst) + rename_template_files(memory_dst) + replace_placeholders_in_dir(memory_dst, project_name, date_value) + log("Synced: memory-bank/") + + if prompts_src.is_dir(): + prompts_dst = project_root / "docs/prompts" + if prompts_dst.exists() and not force: + log("docs/prompts/ already exists. Use force to overwrite.") + else: + backup_path(prompts_dst, no_backup) + ensure_dir(prompts_dst.parent) + copytree(prompts_src, prompts_dst) + rename_template_files(prompts_dst) + replace_placeholders_in_dir(prompts_dst, project_name, date_value) + log("Synced: docs/prompts/") + + if agents_src.is_file(): + agents_dst = project_root / "AGENTS.md" + if full: + start_marker = "" + end_marker = "" + else: + start_marker = "" + end_marker = "" + update_agents_section( + agents_dst, agents_src, start_marker, end_marker, project_name, date_value + ) + + if rules_src.is_file(): + rules_dst = project_root / "AGENT_RULES.md" + if rules_dst.exists() and not force: + log("AGENT_RULES.md already exists. Use force to overwrite.") + else: + backup_path(rules_dst, no_backup) + text = rules_src.read_text(encoding="utf-8") + text = replace_placeholders(text, project_name, date_value) + rules_dst.write_text(text + "\n", encoding="utf-8") + log("Synced: AGENT_RULES.md") + + return 0 + + +def render_agents_block(langs: list[str]) -> list[str]: + entries = [f"`.agents/{lang}/index.md`" for lang in langs] + langs_line = "、".join(entries) if entries else "" + lines = [ + "", + "请以 `.agents/` 下的规则为准:", + "- 入口:`.agents/index.md`", + f"- 语言规则:{langs_line}" if langs_line else "- 语言规则:", + "", + ] + return lines + + +def update_agents_block(agents_md: Path, block_lines: list[str]) -> None: + start = "" + end = "" + if not agents_md.exists(): + content = "# Agent Instructions\n\n" + "\n".join(block_lines) + "\n" + agents_md.write_text(content, encoding="utf-8") + log("Created AGENTS.md") + return + + text = agents_md.read_text(encoding="utf-8") + if start in text: + lines = text.splitlines() + updated: list[str] = [] + in_block = False + replaced = False + for line in lines: + if not replaced and line.strip() == start: + updated.extend(block_lines) + in_block = True + replaced = True + continue + if in_block: + if line.strip() == end: + in_block = False + continue + updated.append(line) + agents_md.write_text("\n".join(updated) + "\n", encoding="utf-8") + log("Updated AGENTS.md (playbook block).") + else: + if ".agents/index.md" in text: + log("Skip: AGENTS.md already references .agents/index.md") + return + updated = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n" + agents_md.write_text(updated, encoding="utf-8") + log("Appended playbook block to AGENTS.md") + + +def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str | None) -> None: + agents_index = agents_root / "index.md" + if agents_index.exists(): + return + lines = [ + "# .agents(多语言)", + "", + "本目录用于存放仓库级/语言级的代理规则集。", + "", + "建议约定:", + "", + "- `.agents/tsl/`:TSL 相关规则集(由 playbook 同步;适用于 `.tsl`/`.tsf`)", + "- `.agents/cpp/`:C++ 相关规则集(由 playbook 同步;适用于 C++23/Modules)", + "- `.agents/python/`:Python 相关规则集(由 playbook 同步)", + "- `.agents/markdown/`:Markdown 相关规则集(仅代码格式化)", + "", + "规则发生冲突时,建议以“更靠近代码的目录规则更具体”为准。", + "", + "入口建议从:", + "", + ] + for lang in langs: + lines.append(f"- `.agents/{lang}/index.md`") + lines += [ + "", + "标准快照文档入口:", + "", + f"- {docs_prefix or 'docs/standards/playbook/docs/'}", + ] + agents_index.write_text("\n".join(lines) + "\n", encoding="utf-8") + log("Created .agents/index.md") + + +def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None: + replacements = { + "`docs/tsl/": f"`{docs_prefix}/tsl/", + "`docs/cpp/": f"`{docs_prefix}/cpp/", + "`docs/python/": f"`{docs_prefix}/python/", + "`docs/markdown/": f"`{docs_prefix}/markdown/", + "`docs/common/": f"`{docs_prefix}/common/", + } + for md_path in agents_dir.glob("*.md"): + if not md_path.is_file(): + continue + text = md_path.read_text(encoding="utf-8") + updated = text + for old, new in replacements.items(): + updated = updated.replace(old, new) + if updated != text: + md_path.write_text(updated, encoding="utf-8") + + +def read_gitattributes_entries(path: Path) -> list[str]: + entries: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + entries.append(stripped) + return entries + + +def sync_gitattributes_overwrite(src: Path, dst: Path) -> None: + if src.resolve() == dst.resolve(): + log("Skip: .gitattributes source equals destination.") + return + backup_path(dst, False) + copy2(src, dst) + log("Synced .gitattributes from standards (overwrite).") + + +def sync_gitattributes_append(src: Path, dst: Path, source_note: str) -> None: + src_entries = read_gitattributes_entries(src) + dst_entries: list[str] = [] + if dst.exists(): + dst_entries = read_gitattributes_entries(dst) + missing = [line for line in src_entries if line not in set(dst_entries)] + if not missing: + log("No missing .gitattributes rules to append.") + return + + original = dst.read_text(encoding="utf-8") if dst.exists() else "" + backup_path(dst, False) + header = f"# Added from playbook .gitattributes (source: {source_note})" + content = original.rstrip("\n") + if content: + content += "\n\n" + content += header + "\n" + "\n".join(missing) + "\n" + dst.write_text(content, encoding="utf-8") + log("Appended missing .gitattributes rules from standards.") + + +def sync_gitattributes_block(src: Path, dst: Path) -> None: + begin = "# BEGIN playbook .gitattributes" + end = "# END playbook .gitattributes" + begin_old = "# BEGIN tsl-playbook .gitattributes" + end_old = "# END tsl-playbook .gitattributes" + + src_lines = src.read_text(encoding="utf-8").splitlines() + block_lines = [begin] + src_lines + [end] + + if dst.exists(): + original = dst.read_text(encoding="utf-8").splitlines() + updated: list[str] = [] + in_block = False + replaced = False + for line in original: + if line == begin or line == begin_old: + if not replaced: + updated.extend(block_lines) + replaced = True + in_block = True + continue + if in_block: + if line == end or line == end_old: + in_block = False + continue + updated.append(line) + if not replaced: + if updated and updated[-1].strip(): + updated.append("") + updated.extend(block_lines) + backup_path(dst, False) + dst.write_text("\n".join(updated) + "\n", encoding="utf-8") + else: + dst.write_text("\n".join(block_lines) + "\n", encoding="utf-8") + log("Synced .gitattributes from standards (block).") + + +def sync_standards_action(config: dict, context: dict) -> int: + if "langs" not in config: + print("ERROR: langs is required for sync_standards", file=sys.stderr) + return 2 + try: + langs = normalize_langs(config.get("langs")) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + project_root: Path = context["project_root"] + agents_root = project_root / ".agents" + ensure_dir(agents_root) + + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + for lang in langs: + src = PLAYBOOK_ROOT / "rulesets" / lang + if not src.is_dir(): + print(f"ERROR: agents ruleset not found: {src}", file=sys.stderr) + return 2 + dst = agents_root / lang + if dst.exists(): + backup = agents_root / f"{lang}.bak.{timestamp}" + dst.rename(backup) + log(f"Backed up existing {lang} agents -> {backup.name}") + copytree(src, dst) + log(f"Synced .agents/{lang} from standards.") + + docs_prefix = None + try: + rel_snapshot = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve()) + if str(rel_snapshot) != ".": + docs_prefix = f"{rel_snapshot.as_posix()}/docs" + except ValueError: + docs_prefix = None + + if docs_prefix: + for lang in langs: + rewrite_agents_docs_links(agents_root / lang, docs_prefix) + + agents_md = project_root / "AGENTS.md" + block_lines = render_agents_block(langs) + update_agents_block(agents_md, block_lines) + + create_agents_index(agents_root, langs, docs_prefix) + + gitattributes_src = PLAYBOOK_ROOT / ".gitattributes" + if gitattributes_src.is_file(): + mode = str(config.get("gitattr_mode", "append")).lower() + gitattributes_dst = project_root / ".gitattributes" + source_note = str(gitattributes_src) + try: + source_note = str(gitattributes_src.resolve().relative_to(project_root.resolve())) + except ValueError: + source_note = str(gitattributes_src) + + if mode == "skip": + log("Skip: .gitattributes sync (mode=skip).") + elif mode == "overwrite": + sync_gitattributes_overwrite(gitattributes_src, gitattributes_dst) + elif mode == "block": + sync_gitattributes_block(gitattributes_src, gitattributes_dst) + else: + sync_gitattributes_append(gitattributes_src, gitattributes_dst, source_note) + + return 0 + + def run_action(name: str, config: dict, context: dict) -> int: + print(f"[action] {name}") if name == "vendor": return vendor_action(config, context) - print(f"[action] {name}") + if name == "sync_templates": + return sync_templates_action(config, context) + if name == "sync_standards": + return sync_standards_action(config, context) return 0 @@ -257,6 +673,8 @@ def main(argv: list[str]) -> int: project_root = playbook_config.get("project_root") if project_root: root = Path(project_root).expanduser() + if not root.is_absolute(): + root = (config_path.parent / root).resolve() else: root = config_path.parent context = {"project_root": root.resolve(), "config_path": config_path.resolve()} diff --git a/tests/cli/test_playbook_cli.py b/tests/cli/test_playbook_cli.py index e091624..b7cb3a6 100644 --- a/tests/cli/test_playbook_cli.py +++ b/tests/cli/test_playbook_cli.py @@ -65,5 +65,41 @@ langs = ["tsl"] self.assertEqual(result.returncode, 0) self.assertTrue(snapshot.is_file()) + def test_sync_templates_creates_memory_bank(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_body = f""" +[playbook] +project_root = "{tmp_dir}" + +[sync_templates] +project_name = "Demo" +""" + config_path = Path(tmp_dir) / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + result = run_cli("-config", str(config_path)) + + memory_bank = Path(tmp_dir) / "memory-bank/project-brief.md" + self.assertEqual(result.returncode, 0) + self.assertTrue(memory_bank.is_file()) + + def test_sync_standards_creates_agents(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_body = f""" +[playbook] +project_root = "{tmp_dir}" + +[sync_standards] +langs = ["tsl"] +""" + config_path = Path(tmp_dir) / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + result = run_cli("-config", str(config_path)) + + agents_index = Path(tmp_dir) / ".agents/tsl/index.md" + self.assertEqual(result.returncode, 0) + self.assertTrue(agents_index.is_file()) + if __name__ == "__main__": unittest.main()