Files
tsl-devkit/tests/cli/test_playbook_cli.py
T
csh 3d83740f88 Squashed 'docs/standards/playbook/' changes from c3f8137..25d895d
25d895d 🐛 fix(gitea_workflow): clean up temp repos after job steps
2bc3b11 🐛 fix(gitea_workflow): clean up temporary repo dirs in workflows
98c3f30 📝 docs(agent_rules): allow plan execution on current branch
16c7230 📝 docs(prompts): define custom verify layering
8efc4dd 🐛 fix(skills): quote commit-message description
bc8498f 🐛 fix(ci): install tomli for gitea tests
55cda3b 🐛 fix(tests): report missing toml parser clearly
c0729c7 🐛 fix(playbook): import Optional for cli compatibility
63e24bf 📦 deps(skills): sync thirdparty skills
d2f9356 🐛 fix(ci): isolate gitea workflow repos
588b81d 🐛 fix(ci): inline gitea workflow bootstrap
e0b1c3a ♻️ refactor(skills): standardize first-party skill contracts
2c5050d ♻️ refactor(skills): rename repo skills source dir
f049dfb 📦 deps(skills): drop duplicate first-party superpowers skills
234b335  feat(workflow): add superpowers planning and execution state tracking
c1702a6 📝 docs(markdown): format tracked markdown and drop stale templates
2325409 📝 docs(markdown): clarify optional markdownlint usage
214c44e 🔧 chore(markdown): add markdownlint baseline and lint fixes
a22b324 📝 docs(templates): add execution and memory-bank prompt templates
223a797 📝 docs(templates): update README for Claude Code and current features
4ac8672 📝 docs: simplify README + platform-agnostic tools + auto-create local rules
2431c9d 📝 docs: add claude_md config and use cross-platform paths
d64b248 📝 docs: fix README.md inaccuracies and add Claude Code info
c8d6bf2 🐛 fix(playbook): use relative paths in CLAUDE.md when not at project root
6518f0f  feat(playbook): auto-create CLAUDE.md with path discovery
6ec9a45  feat(skills): add skill_link symlink support + platform-agnostic prompt
9f8b6b5 📝 docs: update README and config example for Claude Code support
79cff6c 📝 docs(skills): add Claude Code platform support
452c6f5  feat(playbook): auto-inject AGENTS.md into CLAUDE.md
e1dbf3c 🐛 fix(skills): remove dual-path from commit-message skill
f3a7259 🔧 chore(ci): use prepare_repo.sh in both workflows
da08212 🔧 chore(ci): extract prepare_repo.sh and clean up workflows
7ade85e 🗑️ remove(tsl): drop syntax_book/, data/ source and build script
f94dba0 ♻️ refactor(skills): update playbook.py and tests for thirdparty/ layout
b3df412 ♻️ refactor(skills): separate thirdparty skills into thirdparty/ subdirectory
64950e7 📦 deps(skills): sync thirdparty skills
a2e3cb0  feat(playbook): add no_backup deploy controls
8609d59 🐛 fix(docs): repair reference catalog source links
956da11 🐛 fix(playbook): publish hidden ci test fixes
3f67754 📦 deps(skills): sync thirdparty skills
08ca87b 📦 deps(skills): add karpathy thirdparty sync
96b705b 📝 docs(tsl): rebuild canonical syntax and routing manual
3ed5052 📦 deps(skills): sync thirdparty skills
60108dd 📦 deps(skills): sync thirdparty skills
da85d4e 🐛 fix(thirdparty): prune nested project snapshots
a2a697e 📦 deps(skills): sync thirdparty skills
9df610a 🐛 fix(thirdparty): exclude duplicated superpowers skills
33dd5bb 🐛 fix(thirdparty): preserve optional manifest fields
91b0ea7 🐛 fix(thirdparty): preserve manifest during snapshot update
2e26f98 🔧 chore(thirdparty): generalize skills sync pipeline
5b9c1e3 📦 deps(skills): sync superpowers
2f2d34a 📝 docs(readme): normalize subtree command spacing
62db7db 🐛 fix(ci): serialize superpowers update and sync
3463223 🐛 fix(ci): use literal superpowers sync paths
48f6de8 📦 deps(skills): sync superpowers
4b23529 🔧 chore(ci): merge superpowers update and sync workflow
a56d75b 📦 deps(skills): sync superpowers
84bcefa 🔧 chore(ci): use ci[bot] commit author name
00a07e5 📦 deps(skills): sync superpowers
7b84daf 🐛 fix(templates): enforce main loop progress tracking
51373d7 🔧 chore(ci): automate superpowers sync workflow
eaaa39c 🐛 fix(ci): prevent stale superpowers sync from restoring skills block
79755c6 📦 deps(skills): sync superpowers
836d878 📦 deps(skills): sync superpowers
8216c9f 📦 deps(skills): sync superpowers
9439505 🐛 fix(playbook): address reported repo issues

git-subtree-dir: docs/standards/playbook
git-subtree-split: 25d895d8b3f56624ccfe99ad7289e9eb49e0f316
2026-05-24 13:04:14 +08:00

735 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "playbook.py"
CUSTOM_DEPLOY_ROOT = "custom/playbook"
def run_script(script, *args):
return subprocess.run(
[sys.executable, str(script), *args],
capture_output=True,
text=True,
)
def run_cli(*args):
return run_script(SCRIPT, *args)
def write_config(root: Path, name: str, body: str) -> Path:
config_path = root / name
config_path.write_text(body, encoding="utf-8")
return config_path
def bash_path(path: Path) -> str:
resolved = path.resolve()
if sys.platform != "win32":
return resolved.as_posix()
drive = resolved.drive.rstrip(":").lower()
rest = resolved.as_posix()[2:]
return f"/mnt/{drive}{rest}"
class PlaybookCliTests(unittest.TestCase):
def assert_style_cleanup_tsl_docs_prefix(
self, root: Path, agents_home: Path, docs_prefix: str
) -> None:
agents_index = (root / ".agents" / "tsl" / "index.md").read_text(encoding="utf-8")
self.assertIn(f"`{docs_prefix}/tsl/index.md`", agents_index)
self.assertNotIn("`docs/tsl/index.md`", agents_index)
skill_file = (agents_home / "skills" / "style-cleanup" / "SKILL.md").read_text(
encoding="utf-8"
)
self.assertIn(f"`{docs_prefix}/tsl/code_style.md`", skill_file)
self.assertNotIn("`docs/tsl/code_style.md`", skill_file)
def test_help_shows_usage(self):
result = run_cli("-h")
self.assertEqual(result.returncode, 0)
self.assertIn("Usage:", result.stdout + result.stderr)
def test_record_spec_updates_progress_workflow_state(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
progress = root / "memory-bank" / "progress.md"
progress.parent.mkdir(parents=True)
progress.write_text("# 当前进展\n", encoding="utf-8")
result = run_cli(
"-record-spec",
"docs/superpowers/specs/2026-05-18-demo-design.md",
"-progress",
str(progress),
)
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
text = progress.read_text(encoding="utf-8")
self.assertIn("phase: planning", text)
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
def test_record_plan_updates_progress_workflow_state(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
progress = root / "memory-bank" / "progress.md"
progress.parent.mkdir(parents=True)
progress.write_text(
"\n".join(
[
"# 当前进展",
"",
"## Workflow State",
"",
"<!-- workflow-state:start -->",
"phase: planning",
"spec: docs/superpowers/specs/2026-05-18-demo-design.md",
"<!-- workflow-state:end -->",
]
)
+ "\n",
encoding="utf-8",
)
result = run_cli(
"-record-plan",
"docs/superpowers/plans/2026-05-18-demo.md",
"-progress",
str(progress),
)
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
text = progress.read_text(encoding="utf-8")
self.assertIn("phase: planning", text)
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text)
self.assertIn("executor: executing-plans", text)
self.assertIn(
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
text,
)
def test_missing_config_is_error(self):
result = run_cli()
self.assertNotEqual(result.returncode, 0)
self.assertIn("-config", result.stdout + result.stderr)
def test_action_order(self):
config_body = f"""
[playbook]
project_root = "."
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[format_md]
[sync_standards]
langs = ["tsl"]
"""
with tempfile.TemporaryDirectory() as tmp_dir:
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)
output = result.stdout + result.stderr
self.assertIn("sync_standards", output)
self.assertIn("format_md", output)
def test_format_md_only_does_not_require_deploy_root(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
[format_md]
"""
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)
def test_vendor_creates_snapshot(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[vendor]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
snapshot = root / CUSTOM_DEPLOY_ROOT / "SOURCE.md"
self.assertEqual(result.returncode, 0)
self.assertTrue(snapshot.is_file())
def test_vendor_docs_index_uses_new_tsl_entrypoints(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[vendor]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
docs_index = root / CUSTOM_DEPLOY_ROOT / "docs/index.md"
self.assertEqual(result.returncode, 0)
text = docs_index.read_text(encoding="utf-8")
self.assertIn("`tsl/index.md`", text)
self.assertIn("`tsl/syntax/index.md`", text)
self.assertIn("`tsl/finance/index.md`", text)
self.assertIn("`tsl/modules/index.md`", text)
self.assertIn("`tsl/reference/index.md`", text)
self.assertNotIn("`tsl/syntax_book/index.md`", text)
def test_external_clone_requires_explicit_deploy_root(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
[vendor]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
self.assertNotEqual(result.returncode, 0)
self.assertIn("deploy_root", result.stdout + result.stderr)
def test_sync_memory_bank_creates_memory_bank(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_memory_bank]
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}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[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())
def test_sync_standards_updates_agents_index_when_langs_expand(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
first_config = root / "playbook-first.toml"
first_config.write_text(
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_standards]
langs = ["tsl"]
no_backup = true
""",
encoding="utf-8",
)
first_result = run_cli("-config", str(first_config))
self.assertEqual(first_result.returncode, 0)
second_config = root / "playbook-second.toml"
second_config.write_text(
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_standards]
langs = ["tsl", "cpp"]
no_backup = true
""",
encoding="utf-8",
)
second_result = run_cli("-config", str(second_config))
self.assertEqual(second_result.returncode, 0)
agents_index = (root / ".agents" / "index.md").read_text(encoding="utf-8")
self.assertIn("`.agents/tsl/index.md`", agents_index)
self.assertIn("`.agents/cpp/index.md`", agents_index)
def test_sync_standards_agents_index_only_lists_configured_langs(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_standards]
langs = ["tsl", "markdown"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
agents_index = (root / ".agents" / "index.md").read_text(encoding="utf-8")
self.assertIn("`.agents/tsl/`TSL 相关规则集", agents_index)
self.assertIn("`.agents/markdown/`Markdown 相关规则集", agents_index)
self.assertNotIn("`.agents/cpp/`", agents_index)
self.assertNotIn("`.agents/python/`", agents_index)
self.assertNotIn("`.agents/typescript/`", agents_index)
def test_sync_standards_agents_block_has_blank_lines(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[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))
self.assertEqual(result.returncode, 0)
agents_md = Path(tmp_dir) / "AGENTS.md"
lines = agents_md.read_text(encoding="utf-8").splitlines()
start_idx = lines.index("<!-- playbook:agents:start -->")
end_idx = lines.index("<!-- playbook:agents:end -->")
block = lines[start_idx : end_idx + 1]
self.assertEqual(block[1], "")
bullet_idx = next(i for i, line in enumerate(block) if line.startswith("- "))
self.assertEqual(block[bullet_idx - 1], "")
def test_install_skills(self):
with tempfile.TemporaryDirectory() as tmp_dir:
target = Path(tmp_dir) / "agents"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[install_skills]
agents_home = "{target}"
mode = "list"
skills = ["brainstorming"]
"""
config_path = Path(tmp_dir) / "playbook.toml"
config_path.write_text(config_body, encoding="utf-8")
result = run_cli("-config", str(config_path))
skill_file = target / "skills/brainstorming/SKILL.md"
self.assertEqual(result.returncode, 0)
self.assertTrue(skill_file.is_file())
def test_install_generated_thirdparty_karpathy_skill_after_sync(self):
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_root = Path(tmp_dir)
mirror = tmp_root / "origin.git"
repo = tmp_root / "repo"
target = tmp_root / "agents"
clone_mirror = subprocess.run(
["git", "clone", "--mirror", str(ROOT), str(mirror)],
capture_output=True,
text=True,
)
self.assertEqual(clone_mirror.returncode, 0, msg=clone_mirror.stderr)
clone_repo = subprocess.run(
["git", "clone", str(mirror), str(repo)],
capture_output=True,
text=True,
)
self.assertEqual(clone_repo.returncode, 0, msg=clone_repo.stderr)
set_remote = subprocess.run(
["git", "-C", str(repo), "remote", "set-url", "origin", bash_path(mirror)],
capture_output=True,
text=True,
)
self.assertEqual(set_remote.returncode, 0, msg=set_remote.stderr)
manifest_src = ROOT / ".gitea" / "ci" / "thirdparty_skills.json"
manifest_dst = repo / ".gitea" / "ci" / "thirdparty_skills.json"
manifest_dst.write_text(manifest_src.read_text(encoding="utf-8"), encoding="utf-8")
sync_src = ROOT / ".gitea" / "ci" / "sync_thirdparty_skills.sh"
sync_dst = repo / ".gitea" / "ci" / "sync_thirdparty_skills.sh"
sync_dst.write_text(sync_src.read_text(encoding="utf-8"), encoding="utf-8")
sync_result = subprocess.run(
["bash", ".gitea/ci/sync_thirdparty_skills.sh"],
cwd=repo,
capture_output=True,
text=True,
)
self.assertEqual(
sync_result.returncode, 0, msg=sync_result.stdout + sync_result.stderr
)
config_body = f"""
[playbook]
project_root = "{tmp_root}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[install_skills]
agents_home = "{target}"
mode = "list"
skills = ["karpathy-guidelines"]
"""
config_path = tmp_root / "playbook.toml"
config_path.write_text(config_body, encoding="utf-8")
result = run_script(repo / "scripts" / "playbook.py", "-config", str(config_path))
skill_file = target / "skills" / "karpathy-guidelines" / "SKILL.md"
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
self.assertTrue(skill_file.is_file())
def test_install_skills_rejects_removed_tsl_guide(self):
with tempfile.TemporaryDirectory() as tmp_dir:
target = Path(tmp_dir) / "agents"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[install_skills]
agents_home = "{target}"
mode = "list"
skills = ["tsl-guide"]
"""
config_path = Path(tmp_dir) / "playbook.toml"
config_path.write_text(config_body, encoding="utf-8")
result = run_cli("-config", str(config_path))
self.assertNotEqual(result.returncode, 0)
self.assertIn("skill not found: tsl-guide", result.stdout + result.stderr)
def test_install_skills_rejects_codex_home(self):
with tempfile.TemporaryDirectory() as tmp_dir:
target = Path(tmp_dir) / "codex"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[install_skills]
codex_home = "{target}"
mode = "list"
skills = ["brainstorming"]
"""
config_path = Path(tmp_dir) / "playbook.toml"
config_path.write_text(config_body, encoding="utf-8")
result = run_cli("-config", str(config_path))
self.assertNotEqual(result.returncode, 0)
self.assertIn("codex_home", result.stdout + result.stderr)
def test_external_clone_flow_rewrites_links_with_configured_deploy_root(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
agents_home = root / "agents-home"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[vendor]
langs = ["tsl"]
[sync_standards]
langs = ["tsl"]
no_backup = true
[install_skills]
agents_home = "{agents_home}"
mode = "list"
skills = ["style-cleanup"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
self.assertTrue((root / CUSTOM_DEPLOY_ROOT / "SOURCE.md").is_file())
self.assert_style_cleanup_tsl_docs_prefix(
root, agents_home, f"{CUSTOM_DEPLOY_ROOT}/docs"
)
def test_deployed_snapshot_rewrites_links_from_snapshot_location(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
vendor_config = write_config(
root,
"vendor.toml",
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[vendor]
langs = ["tsl"]
""",
)
vendor_result = run_cli("-config", str(vendor_config))
self.assertEqual(vendor_result.returncode, 0, msg=vendor_result.stdout + vendor_result.stderr)
vendored_script = root / CUSTOM_DEPLOY_ROOT / "scripts" / "playbook.py"
agents_home = root / "local-agents"
sync_config = write_config(
root,
"sync.toml",
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_standards]
langs = ["tsl"]
no_backup = true
[install_skills]
agents_home = "{agents_home}"
mode = "list"
skills = ["style-cleanup"]
""",
)
sync_result = run_script(vendored_script, "-config", str(sync_config))
self.assertEqual(sync_result.returncode, 0, msg=sync_result.stdout + sync_result.stderr)
self.assert_style_cleanup_tsl_docs_prefix(
root, agents_home, f"{CUSTOM_DEPLOY_ROOT}/docs"
)
def test_sync_claude_md_creates_when_no_claude_md(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_memory_bank]
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))
self.assertEqual(result.returncode, 0)
claude_md = Path(tmp_dir) / "CLAUDE.md"
self.assertTrue(claude_md.exists())
text = claude_md.read_text(encoding="utf-8")
self.assertIn("@AGENTS.md", text)
self.assertIn("<!-- playbook:claude:start -->", text)
def test_sync_claude_md_appends_block_to_existing_claude_md(self):
with tempfile.TemporaryDirectory() as tmp_dir:
claude_md = Path(tmp_dir) / "CLAUDE.md"
claude_md.write_text("# My project\n\nSome existing content.\n", encoding="utf-8")
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_memory_bank]
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))
self.assertEqual(result.returncode, 0)
text = claude_md.read_text(encoding="utf-8")
self.assertIn("@AGENTS.md", text)
self.assertIn("@AGENT_RULES.md", text)
self.assertIn("<!-- playbook:claude:start -->", text)
self.assertIn("Some existing content.", text)
def test_sync_claude_md_updates_existing_block(self):
with tempfile.TemporaryDirectory() as tmp_dir:
claude_md = Path(tmp_dir) / "CLAUDE.md"
claude_md.write_text(
"# My project\n\n"
"<!-- playbook:claude:start -->\n"
"@AGENTS.md\n"
"<!-- playbook:claude:end -->\n",
encoding="utf-8",
)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_memory_bank]
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))
self.assertEqual(result.returncode, 0)
text = claude_md.read_text(encoding="utf-8")
self.assertIn("@AGENTS.md", text)
self.assertIn("@AGENT_RULES.md", text)
self.assertEqual(text.count("<!-- playbook:claude:start -->"), 1)
def test_sync_claude_md_skips_when_already_references_agents(self):
with tempfile.TemporaryDirectory() as tmp_dir:
claude_md = Path(tmp_dir) / "CLAUDE.md"
original = "# My project\n\n@AGENTS.md\n"
claude_md.write_text(original, encoding="utf-8")
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[sync_memory_bank]
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))
self.assertEqual(result.returncode, 0)
self.assertEqual(claude_md.read_text(encoding="utf-8"), original)
def test_install_skills_creates_symlink_when_skill_link_configured(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
agents_home = root / "agents"
link_home = root / "claude"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[install_skills]
agents_home = "{agents_home}"
skill_link = "{link_home}"
mode = "list"
skills = ["commit-message"]
"""
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.stdout + result.stderr)
skills_dst = agents_home / "skills"
link_path = link_home / "skills"
self.assertTrue(skills_dst.is_dir())
self.assertTrue(link_path.is_dir(), "link_path should be accessible as dir")
self.assertEqual(link_path.resolve(), skills_dst.resolve())
self.assertTrue((link_path / "commit-message" / "SKILL.md").is_file())
def test_install_skills_symlink_is_idempotent(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
agents_home = root / "agents"
link_home = root / "claude"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[install_skills]
agents_home = "{agents_home}"
skill_link = "{link_home}"
mode = "list"
skills = ["commit-message"]
no_backup = true
"""
config_path = root / "playbook.toml"
config_path.write_text(config_body, encoding="utf-8")
run_cli("-config", str(config_path))
result = run_cli("-config", str(config_path))
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
self.assertTrue((link_home / "skills").is_dir())
def test_install_skills_no_symlink_when_skill_link_absent(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
agents_home = root / "agents"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[install_skills]
agents_home = "{agents_home}"
mode = "list"
skills = ["commit-message"]
"""
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.stdout + result.stderr)
self.assertFalse(any(
p.is_symlink() for p in (agents_home / "skills").iterdir()
if p.is_symlink()
) if (agents_home / "skills").exists() else False)
if __name__ == "__main__":
unittest.main()