from __future__ import annotations import argparse import os import subprocess import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch from pouch import cli REPO_ROOT = Path(__file__).resolve().parents[1] class CreateWorkflowTests(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() self.home = Path(self.temp_dir.name) self.skills_home = self.home / ".pouch" template = self.skills_home / "skills" / "_template" template.mkdir(parents=True) template.joinpath("SKILL.md").write_text( "---\nname: skill-name\ndescription: >-\n" " 简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically.\n" "---\n\n# Skill 名称\n\n## 步骤\n\n1. 第一步\n", encoding="utf-8", ) template.joinpath("README.md").write_text( "# skill-name\n\n" "用一句话告诉使用者这个 skill 能解决什么问题。\n\n" "给出用户可以直接说出的典型请求。\n\n" "给出一条可以直接交给 Agent 的示例请求。\n", encoding="utf-8", ) project_skill = self.skills_home / "skills" / "pouch" project_skill.mkdir() project_skill.joinpath("SKILL.md").write_text( "---\nname: pouch\ndescription: >-\n" " 创建和维护自研 skill。用于创建、安装、反馈或更新 skill 时。\n" "---\n\n# pouch\n\n## 步骤\n\n1. 维护 skill。\n", encoding="utf-8", ) project_skill.joinpath("README.md").write_text( "# pouch\n\n供人类使用的 pouch 说明。\n", encoding="utf-8", ) def tearDown(self) -> None: self.temp_dir.cleanup() def run_pouch(self, *args: str) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env["HOME"] = str(self.home) env["PYTHONPATH"] = str(REPO_ROOT) return subprocess.run( [sys.executable, "-m", "pouch", *args], cwd=REPO_ROOT, env=env, text=True, capture_output=True, check=False, ) def test_setup_command_is_not_exposed(self) -> None: help_result = self.run_pouch("--help") setup_result = self.run_pouch("setup", str(self.skills_home)) self.assertEqual(help_result.returncode, 0, help_result.stderr) self.assertNotIn("setup", help_result.stdout) self.assertNotEqual(setup_result.returncode, 0) def test_bootstrap_installs_project_skill_globally_for_all_agents(self) -> None: first = self.run_pouch("bootstrap") second = self.run_pouch("bootstrap") self.assertEqual(first.returncode, 0, first.stderr) self.assertEqual(second.returncode, 0, second.stderr) expected = (self.skills_home / "skills" / "pouch").resolve() for relative in ( ".cursor/skills/pouch", ".claude/skills/pouch", ".codex/skills/pouch", ".agents/skills/pouch", ): link = self.home / relative self.assertTrue(link.is_symlink(), relative) self.assertEqual(link.resolve(), expected) for agent in ("cursor", "claude", "codex", "agents"): self.assertIn(agent, first.stdout) def test_update_pulls_skills_home(self) -> None: with ( patch.object(cli, "POUCH_HOME", self.skills_home), patch.object(cli, "ensure_pouch_home"), patch.object(cli.subprocess, "run") as run, ): cli.cmd_update(argparse.Namespace()) run.assert_called_once_with( ["git", "-C", str(self.skills_home), "pull"], check=True, ) def test_create_writes_draft_and_brief_without_registering_owned_skill(self) -> None: project = self.home / "project" project.mkdir() result = self.run_pouch( "create", "migration-review", "--idea", "检查数据库迁移", "--from-project", str(project), ) self.assertEqual(result.returncode, 0, result.stderr) draft = self.skills_home / ".drafts" / "migration-review" self.assertTrue((draft / "SKILL.md").is_file()) self.assertEqual( (draft / "README.md").read_text(encoding="utf-8").splitlines()[0], "# migration-review", ) brief = (draft / "brief.yaml").read_text(encoding="utf-8") self.assertIn("idea: 检查数据库迁移", brief) self.assertIn(f"source_project: {project.resolve()}", brief) self.assertFalse((self.skills_home / "skills" / "migration-review").exists()) self.assertIn("请完善 pouch 草稿 migration-review", result.stdout) def test_check_rejects_template_placeholders(self) -> None: created = self.run_pouch("create", "migration-review", "--idea", "检查数据库迁移") self.assertEqual(created.returncode, 0, created.stderr) result = self.run_pouch("check", "migration-review") self.assertNotEqual(result.returncode, 0) self.assertIn("模板占位", result.stderr) def test_finalize_moves_valid_draft_and_removes_brief(self) -> None: created = self.run_pouch("create", "migration-review", "--idea", "检查数据库迁移") self.assertEqual(created.returncode, 0, created.stderr) draft = self.skills_home / ".drafts" / "migration-review" draft.joinpath("SKILL.md").write_text( "---\nname: migration-review\ndescription: >-\n" " 检查数据库迁移文件的安全性。用于用户修改数据库结构、迁移或回滚方案时。\n" "---\n\n# 数据库迁移检查\n\n## 步骤\n\n1. 检查迁移与回滚路径。\n", encoding="utf-8", ) draft.joinpath("README.md").write_text( "# migration-review\n\n" "用于检查数据库迁移安全性。\n\n" "## 使用示例\n\n请检查这次数据库迁移。\n", encoding="utf-8", ) result = self.run_pouch("finalize", "migration-review") self.assertEqual(result.returncode, 0, result.stderr) final = self.skills_home / "skills" / "migration-review" self.assertTrue((final / "SKILL.md").is_file()) self.assertTrue((final / "README.md").is_file()) self.assertFalse((final / "brief.yaml").exists()) self.assertFalse(draft.exists()) def test_check_rejects_missing_local_reference(self) -> None: draft = self.skills_home / ".drafts" / "reference-check" draft.mkdir(parents=True) draft.joinpath("SKILL.md").write_text( "---\nname: reference-check\ndescription: >-\n" " 检查本地参考资料。用于用户要求核对参考文档时。\n" "---\n\n# 参考检查\n\n读取 [规范](references/rules.md)。\n", encoding="utf-8", ) draft.joinpath("README.md").write_text( "# reference-check\n\n用于检查本地参考资料。\n", encoding="utf-8", ) result = self.run_pouch("check", "reference-check") self.assertNotEqual(result.returncode, 0) self.assertIn("引用文件不存在", result.stderr) def test_check_rejects_missing_readme(self) -> None: draft = self.skills_home / ".drafts" / "missing-readme" draft.mkdir(parents=True) draft.joinpath("SKILL.md").write_text( "---\nname: missing-readme\ndescription: >-\n" " 检查 skill 是否包含人类使用说明。用于维护 skill 结构时。\n" "---\n\n# README 检查\n\n1. 检查 README。\n", encoding="utf-8", ) result = self.run_pouch("check", "missing-readme") self.assertNotEqual(result.returncode, 0) self.assertIn("缺少 README.md", result.stderr) def test_finalize_does_not_overwrite_existing_owned_skill(self) -> None: created = self.run_pouch("create", "collision-check", "--idea", "检查名称冲突") self.assertEqual(created.returncode, 0, created.stderr) final = self.skills_home / "skills" / "collision-check" final.mkdir() final.joinpath("marker").write_text("keep", encoding="utf-8") result = self.run_pouch("finalize", "collision-check") self.assertNotEqual(result.returncode, 0) self.assertEqual(final.joinpath("marker").read_text(encoding="utf-8"), "keep") self.assertTrue((self.skills_home / ".drafts" / "collision-check" / "brief.yaml").is_file()) if __name__ == "__main__": unittest.main()