import shutil import subprocess import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "playbook.py" MODE_ROOTS = { "snapshot": Path("custom/playbook"), "subtree": Path("docs/standards/playbook"), } def run_playbook( script: Path, config: Path, project_root: Path ) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(script), "-config", str(config)], cwd=project_root, capture_output=True, text=True, ) def copy_subtree_source(destination: Path) -> None: destination.mkdir(parents=True) shutil.copy2(ROOT / ".gitattributes", destination / ".gitattributes") for name in ("scripts", "templates"): shutil.copytree( ROOT / name, destination / name, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), ) (destination / "docs").mkdir() for name in ("common", "tsl", "markdown"): shutil.copytree(ROOT / "docs" / name, destination / "docs" / name) (destination / "rulesets").mkdir() shutil.copy2( ROOT / "rulesets" / "index.md", destination / "rulesets" / "index.md", ) for name in ("tsl", "markdown"): shutil.copytree( ROOT / "rulesets" / name, destination / "rulesets" / name, ) (destination / "skills").mkdir() for name in ("commit-message",): shutil.copytree( ROOT / "skills" / name, destination / "skills" / name, ) def write_config(project_root: Path, install_mode: str, playbook_root: Path) -> Path: config = project_root / "playbook.toml" config.write_text( f""" [playbook] project_root = "." playbook_root = "{playbook_root.as_posix()}" install_mode = "{install_mode}" [sync_rules] date = "2026-01-01" no_backup = true [sync_memory_bank] project_name = "Demo" no_backup = true [sync_standards] langs = ["tsl", "markdown"] gitattr_mode = "overwrite" no_backup = true [install_skills] agents_home = ".test-agents" mode = "list" skills = ["commit-message"] no_backup = true """.lstrip(), encoding="utf-8", newline="\n", ) return config def seed_custom_files(project_root: Path) -> None: custom_memory = project_root / "memory-bank" / "custom.md" custom_memory.parent.mkdir(parents=True) custom_memory.write_text("custom memory\n", encoding="utf-8", newline="\n") (project_root / "AGENTS.md").write_text( "# Existing Agents\n\nKeep this agent note.\n\nSee `.agents/index.md`.\n", encoding="utf-8", newline="\n", ) (project_root / "CLAUDE.md").write_text( "# Existing Claude\n\nKeep this Claude note.\n\n@AGENTS.md\n", encoding="utf-8", newline="\n", ) class PlaybookDeploymentTests(unittest.TestCase): def test_help_lists_only_current_cli_options(self): with tempfile.TemporaryDirectory() as tmp_dir: result = subprocess.run( [sys.executable, str(SCRIPT), "--help"], cwd=Path(tmp_dir), capture_output=True, text=True, ) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertIn("-config PATH", result.stdout) self.assertIn("-h, --help", result.stdout) self.assertNotIn("-h, -help", result.stdout) def test_install_all_excludes_legacy_superpowers_skills(self): with tempfile.TemporaryDirectory() as tmp_dir: project_root = Path(tmp_dir) / "project" project_root.mkdir() config = project_root / "playbook.toml" config.write_text( """ [playbook] project_root = "." playbook_root = "custom/playbook" install_mode = "snapshot" [install_skills] agents_home = ".test-agents" mode = "all" no_backup = true """.lstrip(), encoding="utf-8", newline="\n", ) result = run_playbook(SCRIPT, config, project_root) self.assertEqual( result.returncode, 0, msg=f"all-skills install failed\n{result.stdout}{result.stderr}", ) installed = { path.name for path in (project_root / ".test-agents" / "skills").iterdir() if path.is_dir() } self.assertIn("grill-with-docs", installed) self.assertIn("to-tickets", installed) self.assertTrue( { "using-superpowers", "brainstorming", "writing-plans", "executing-plans", }.isdisjoint(installed) ) def test_install_skills_list_requires_explicit_skills(self): with tempfile.TemporaryDirectory() as tmp_dir: project_root = Path(tmp_dir) / "project" project_root.mkdir() config = project_root / "playbook.toml" config.write_text( """ [playbook] project_root = "." playbook_root = "custom/playbook" install_mode = "snapshot" [install_skills] agents_home = ".test-agents" mode = "list" bundles = ["matt-pocock-workflow"] no_backup = true """.lstrip(), encoding="utf-8", newline="\n", ) result = run_playbook(SCRIPT, config, project_root) self.assertEqual( result.returncode, 2, msg=f"bundles unexpectedly supported\n{result.stdout}{result.stderr}", ) self.assertIn("ERROR: skills is required", result.stderr) def test_invalid_toml_is_reported_without_a_traceback(self): with tempfile.TemporaryDirectory() as tmp_dir: project_root = Path(tmp_dir) / "project" project_root.mkdir() config = project_root / "playbook.toml" config.write_text( r""" [playbook] project_root = "C:\workspace\project" """.lstrip(), encoding="utf-8", newline="\n", ) result = run_playbook(SCRIPT, config, project_root) self.assertEqual(result.returncode, 2) self.assertIn("ERROR: invalid TOML", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_playbook_deployment_modes(self): for install_mode, playbook_root in MODE_ROOTS.items(): with self.subTest(install_mode=install_mode): with tempfile.TemporaryDirectory() as tmp_dir: project_root = Path(tmp_dir) / "project" project_root.mkdir() if install_mode == "subtree": source_root = project_root / playbook_root copy_subtree_source(source_root) script = source_root / "scripts" / "playbook.py" else: source_root = ROOT script = SCRIPT seed_custom_files(project_root) config = write_config(project_root, install_mode, playbook_root) for run_number in (1, 2): result = run_playbook(script, config, project_root) self.assertEqual( result.returncode, 0, msg=( f"{install_mode} run {run_number} failed\n" f"{result.stdout}{result.stderr}" ), ) expected_paths = ( "AGENTS.md", "AGENT_RULES.md", "AGENT_RULES.local.md", "CLAUDE.md", ".gitattributes", "memory-bank/project-brief.md", "memory-bank/system-patterns.md", ".agents/index.md", ".agents/tsl/index.md", ".agents/markdown/index.md", ".test-agents/skills/commit-message/SKILL.md", ".test-agents/skills/commit-message/references/commit_policy.json", ".test-agents/skills/commit-message/scripts/validate_commit_message.py", ) missing = [ path for path in expected_paths if not (project_root / path).exists() ] self.assertEqual(missing, []) self.assertEqual( (project_root / "memory-bank/custom.md").read_text( encoding="utf-8" ), "custom memory\n", ) agents_text = (project_root / "AGENTS.md").read_text( encoding="utf-8" ) self.assertIn("Keep this agent note.", agents_text) self.assertEqual( agents_text.count(""), 1 ) claude_text = (project_root / "CLAUDE.md").read_text( encoding="utf-8" ) self.assertIn("Keep this Claude note.", claude_text) self.assertIn("@AGENTS.md", claude_text) self.assertEqual( claude_text.count(""), 1 ) docs_prefix = f"{playbook_root.as_posix()}/docs" agents_index = (project_root / ".agents/index.md").read_text( encoding="utf-8" ) self.assertIn(f"- {docs_prefix}", agents_index) source_skill = ROOT / "skills" / "commit-message" installed_commit_skill = ( project_root / ".test-agents/skills/commit-message" ) self.assertEqual( (installed_commit_skill / "SKILL.md").read_text(encoding="utf-8"), (source_skill / "SKILL.md").read_text(encoding="utf-8"), ) self.assertEqual( ( installed_commit_skill / "references/commit_policy.json" ).read_text(encoding="utf-8"), (source_skill / "references/commit_policy.json").read_text( encoding="utf-8" ), ) self.assertEqual( ( installed_commit_skill / "scripts/validate_commit_message.py" ).read_text(encoding="utf-8"), ( source_skill / "scripts/validate_commit_message.py" ).read_text(encoding="utf-8"), ) rules_text = (project_root / "AGENT_RULES.md").read_text( encoding="utf-8" ) self.assertIn( f"`{playbook_root.as_posix()}/` 是 Playbook 模板/供应商目录", rules_text, ) if install_mode == "snapshot": snapshot_root = project_root / playbook_root self.assertTrue((snapshot_root / "SOURCE.md").is_file()) self.assertTrue( (snapshot_root / "scripts/playbook.py").is_file() ) self.assertTrue( (snapshot_root / "scripts/main_loop.py").is_file() ) self.assertTrue( (snapshot_root / "playbook.example.toml").is_file() ) self.assertFalse( (snapshot_root / "playbook.toml.example").exists() ) self.assertFalse( (snapshot_root / "scripts/ticket_loop.py").exists() ) self.assertTrue((snapshot_root / "skills").is_dir()) else: self.assertFalse((source_root / "SOURCE.md").exists()) def test_sync_without_standards_keeps_language_rules(self): with tempfile.TemporaryDirectory() as tmp_dir: project_root = Path(tmp_dir) / "project" project_root.mkdir() playbook_root = MODE_ROOTS["snapshot"] full_config = write_config(project_root, "snapshot", playbook_root) result = run_playbook(SCRIPT, full_config, project_root) self.assertEqual( result.returncode, 0, msg=f"seed run failed\n{result.stdout}{result.stderr}", ) agents_md = project_root / "AGENTS.md" seeded = agents_md.read_text(encoding="utf-8") self.assertIn("`.agents/tsl/index.md`", seeded) self.assertIn("`.agents/markdown/index.md`", seeded) partial_config = project_root / "playbook-partial.toml" partial_config.write_text( f""" [playbook] project_root = "." playbook_root = "{playbook_root.as_posix()}" install_mode = "snapshot" [sync_memory_bank] project_name = "Demo" no_backup = true """.lstrip(), encoding="utf-8", newline="\n", ) result = run_playbook(SCRIPT, partial_config, project_root) self.assertEqual( result.returncode, 0, msg=f"partial run failed\n{result.stdout}{result.stderr}", ) after = agents_md.read_text(encoding="utf-8") self.assertIn("`.agents/tsl/index.md`", after) self.assertIn("`.agents/markdown/index.md`", after) if __name__ == "__main__": unittest.main()