import subprocess import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "playbook.py" SOURCE_GITATTR = ROOT / ".gitattributes" DEFAULT_DEPLOY_ROOT = "docs/standards/playbook" def run_cli(*args, cwd=None): return subprocess.run( [sys.executable, str(SCRIPT), *args], capture_output=True, text=True, cwd=cwd, ) def run_script(script_path: Path, *args, cwd: Path | None = None): return subprocess.run( [sys.executable, str(script_path), *args], capture_output=True, text=True, cwd=str(cwd) if cwd else None, ) def read_entries(path: Path) -> list[str]: entries = [] 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 class PlaybookConfigActionTests(unittest.TestCase): def _run_sync_standards(self, root: Path, mode: str) -> subprocess.CompletedProcess: config_body = f""" [playbook] project_root = \"{root}\" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_standards] langs = [\"tsl\"] gitattr_mode = \"{mode}\" """ config_path = root / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") return run_cli("-config", str(config_path), cwd=root) def test_gitattr_mode_skip(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) sentinel = "*.keep text eol=lf\n" (root / ".gitattributes").write_text(sentinel, encoding="utf-8") result = self._run_sync_standards(root, "skip") self.assertEqual(result.returncode, 0) self.assertEqual( (root / ".gitattributes").read_text(encoding="utf-8"), sentinel, ) def test_gitattr_mode_overwrite(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) (root / ".gitattributes").write_text("bad\n", encoding="utf-8") result = self._run_sync_standards(root, "overwrite") self.assertEqual(result.returncode, 0) self.assertEqual( (root / ".gitattributes").read_text(encoding="utf-8"), SOURCE_GITATTR.read_text(encoding="utf-8"), ) def test_gitattr_mode_block(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) result = self._run_sync_standards(root, "block") self.assertEqual(result.returncode, 0) content = (root / ".gitattributes").read_text(encoding="utf-8") self.assertIn("# BEGIN playbook .gitattributes", content) self.assertIn("# END playbook .gitattributes", content) def test_gitattr_mode_append(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) src_entries = read_entries(SOURCE_GITATTR) (root / ".gitattributes").write_text( src_entries[0] + "\n", encoding="utf-8" ) result = self._run_sync_standards(root, "append") self.assertEqual(result.returncode, 0) content = (root / ".gitattributes").read_text(encoding="utf-8") self.assertIn("Added from playbook .gitattributes", content) def test_sync_rules_no_backup_skips_backup_file(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) rules = root / "AGENT_RULES.md" rules.write_text("old rules", encoding="utf-8") config_body = f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_rules] force = true no_backup = true """ config_path = root / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) backups = list(root.glob("AGENT_RULES.md.bak.*")) self.assertEqual(backups, []) self.assertTrue(rules.is_file()) def test_sync_standards_no_backup_skips_agents_and_gitattributes_backup(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) agents = root / ".agents" / "tsl" agents.mkdir(parents=True) (agents / "index.md").write_text("old", encoding="utf-8") gitattributes = root / ".gitattributes" gitattributes.write_text("*.txt text\n", encoding="utf-8") config_body = f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_standards] langs = ["tsl"] gitattr_mode = "append" no_backup = true """ config_path = root / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) agents_backups = list((root / ".agents").glob("tsl.bak.*")) self.assertEqual(agents_backups, []) git_backups = list(root.glob(".gitattributes.bak.*")) self.assertEqual(git_backups, []) def test_install_skills_no_backup_replaces_existing_skill_without_backup(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) skills_root = root / "agents" / "skills" existing = skills_root / "brainstorming" existing.mkdir(parents=True) (existing / "stale.txt").write_text("old", encoding="utf-8") config_body = f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [install_skills] agents_home = "{root / 'agents'}" mode = "list" skills = ["brainstorming"] no_backup = true """ config_path = root / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) backups = list(skills_root.glob("brainstorming.bak.*")) self.assertEqual(backups, []) self.assertFalse((existing / "stale.txt").exists()) self.assertTrue((existing / "SKILL.md").is_file()) def test_sync_memory_bank_adds_missing_files_without_deleting_custom(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) memory_bank = root / "memory-bank" memory_bank.mkdir(parents=True) custom = memory_bank / "custom.md" custom.write_text("custom", encoding="utf-8") config_body = f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_memory_bank] project_name = "Demo" """ config_path = root / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertTrue(custom.exists()) self.assertTrue((memory_bank / "project-brief.md").is_file()) def test_sync_prompts_adds_missing_files_without_deleting_custom(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) prompts = root / "docs" / "prompts" prompts.mkdir(parents=True) custom = prompts / "custom.md" custom.write_text("custom", encoding="utf-8") config_body = f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_prompts] """ config_path = root / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertTrue(custom.exists()) self.assertTrue((prompts / "system" / "agent-behavior.md").is_file()) self.assertFalse((root / "docs" / "workflows").exists()) def test_sync_memory_bank_force_overwrites_template_files_only(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) memory_bank = root / "memory-bank" memory_bank.mkdir(parents=True) custom = memory_bank / "custom.md" custom.write_text("custom", encoding="utf-8") brief = memory_bank / "project-brief.md" brief.write_text("OLD", encoding="utf-8") config_body = f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_memory_bank] project_name = "Demo" force = true no_backup = true """ config_path = root / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertTrue(custom.exists()) self.assertNotIn("OLD", brief.read_text(encoding="utf-8")) backups = list(memory_bank.glob("project-brief.md.bak.*")) self.assertEqual(backups, []) def test_sync_templates_replaces_playbook_scripts_without_main_language_support( self, ): with tempfile.TemporaryDirectory() as tmp_dir: config_body = f""" [playbook] project_root = \"{tmp_dir}\" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_rules] [sync_memory_bank] [sync_standards] langs = [\"cpp\", \"tsl\"] """ config_path = Path(tmp_dir) / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) agents_md = Path(tmp_dir) / "AGENTS.md" text = agents_md.read_text(encoding="utf-8") self.assertIn(".agents/cpp/index.md", text) self.assertNotIn("{{MAIN_LANGUAGE}}", text) tech_context = Path(tmp_dir) / "memory-bank" / "tech-context.md" tech_context_text = tech_context.read_text(encoding="utf-8") self.assertNotIn("{{LANGUAGE_1}}", tech_context_text) self.assertNotIn("{{MAIN_LANGUAGE}}", tech_context_text) self.assertNotIn("**主要语言**", tech_context_text) rules_md = Path(tmp_dir) / "AGENT_RULES.md" rules_text = rules_md.read_text(encoding="utf-8") self.assertIn( "docs/standards/playbook/scripts/main_loop.py claim", rules_text, ) self.assertIn( "`docs/standards/playbook/` 是 Playbook 模板/供应商目录", rules_text, ) self.assertIn( "默认排除 `docs/standards/playbook/`", rules_text, ) self.assertIn("docs/superpowers/plans", rules_text) self.assertNotIn("plan_progress.py", rules_text) self.assertNotIn("record-spec", rules_text) self.assertIn( "追加到 `memory-bank/progress.md` 的", rules_text, ) self.assertIn("领取前不得进入 `$executing-plans`", rules_text) self.assertIn( "`$subagent-driven-development` 仅在 Plan 或平台明确要求时使用", rules_text, ) self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules_text) self.assertNotIn("{{PLAYBOOK_ROOT}}", rules_text) self.assertFalse(rules_text.endswith("\n\n")) def test_sync_standards_rewrites_typescript_docs_prefix_for_snapshot_playbook(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) install_config = root / "install.toml" install_config.write_text( f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_standards] langs = ["typescript"] """, encoding="utf-8", ) install_result = run_cli("-config", str(install_config)) self.assertEqual(install_result.returncode, 0, msg=install_result.stderr) sync_config = root / "sync.toml" sync_config.write_text( f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_standards] langs = ["typescript"] """, encoding="utf-8", ) snapshot_script = ( root / "docs" / "standards" / "playbook" / "scripts" / "playbook.py" ) sync_result = run_script( snapshot_script, "-config", str(sync_config), cwd=root ) self.assertEqual(sync_result.returncode, 0, msg=sync_result.stderr) agents_index = root / ".agents" / "typescript" / "index.md" text = agents_index.read_text(encoding="utf-8") self.assertIn("`docs/standards/playbook/docs/typescript/", text) self.assertNotIn("`docs/typescript/", text) def test_sync_memory_bank_includes_active_context_and_human_readable_progress(self): with tempfile.TemporaryDirectory() as tmp_dir: config_body = f""" [playbook] project_root = "{tmp_dir}" playbook_root = "{DEFAULT_DEPLOY_ROOT}" install_mode = "snapshot" [sync_rules] [sync_memory_bank] project_name = "MyProject" """ config_path = Path(tmp_dir) / "playbook.toml" config_path.write_text(config_body, encoding="utf-8") result = run_cli("-config", str(config_path)) self.assertEqual(result.returncode, 0, msg=result.stderr) active_context = Path(tmp_dir) / "memory-bank" / "active-context.md" self.assertTrue(active_context.is_file()) progress = Path(tmp_dir) / "memory-bank" / "progress.md" progress_text = progress.read_text(encoding="utf-8") self.assertIn("## Current Focus", progress_text) self.assertIn("## 状态块示例", progress_text) self.assertNotIn("phase: planning", progress_text) self.assertNotIn("executor: executing-plans", progress_text) self.assertNotIn("workflow-state", progress_text) self.assertIn("## Plan Status", progress_text) self.assertIn("", progress_text) self.assertIn("", progress_text) system_patterns = Path(tmp_dir) / "memory-bank" / "system-patterns.md" system_patterns_text = system_patterns.read_text(encoding="utf-8") self.assertIn("# 系统模式与约束", system_patterns_text) self.assertIn("## 核心不变量", system_patterns_text) agents_md = Path(tmp_dir) / "AGENTS.md" agents_text = agents_md.read_text(encoding="utf-8") self.assertIn("memory-bank/active-context.md", agents_text) rules_md = Path(tmp_dir) / "AGENT_RULES.md" rules_text = rules_md.read_text(encoding="utf-8") self.assertIn("memory-bank/active-context.md", rules_text) if __name__ == "__main__": unittest.main()