from __future__ import annotations import os import subprocess import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch from pouch.project import normalize_skill_entry from pouch.sources import fetch_source REPO_ROOT = Path(__file__).resolve().parents[1] def write_skill(root: Path, name: str) -> Path: skill = root / name skill.mkdir(parents=True) skill.joinpath("SKILL.md").write_text( f"---\nname: {name}\ndescription: >-\n" f" 测试 {name} skill。\n---\n\n# {name}\n", encoding="utf-8", ) return skill class CustomSourceTests(unittest.TestCase): def test_legacy_manifest_sources_normalize_to_new_model(self) -> None: self.assertEqual( normalize_skill_entry({"name": "ack", "source": "owned"})["source"], "builtin", ) self.assertEqual( normalize_skill_entry( {"name": "think", "source": "registry", "registry": "waza"} )["source"], "catalog:waza", ) def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() self.home = Path(self.temp_dir.name) self.skills_home = self.home / ".pouch" (self.skills_home / "skills").mkdir(parents=True) (self.skills_home / "catalog.yaml").write_text("", 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_local_source_lists_and_installs_qualified_skill(self) -> None: company = self.home / "company" expected = write_skill(company / "internal" / "skills", "code-review") added = self.run_pouch( "source", "add", "company", "--local", str(company), "--skills-path", "internal/skills", ) listed = self.run_pouch("list", "--source", "company") installed = self.run_pouch("add", "company/code-review", "-g", "-a", "codex") self.assertEqual(added.returncode, 0, added.stderr) self.assertEqual(listed.returncode, 0, listed.stderr) self.assertIn("code-review", listed.stdout) self.assertEqual(installed.returncode, 0, installed.stderr) link = self.home / ".codex" / "skills" / "code-review" self.assertTrue(link.is_symlink()) self.assertEqual(link.resolve(), expected.resolve()) config = self.home / ".config" / "pouch" / "config.yaml" self.assertIn("company:", config.read_text(encoding="utf-8")) def test_local_source_can_expose_a_single_skill(self) -> None: source = self.home / "single-source" source.mkdir() source.joinpath("SKILL.md").write_text( "---\nname: solo\ndescription: Test single source.\n---\n", encoding="utf-8", ) added = self.run_pouch( "source", "add", "solo", "--local", str(source), "--skills-path", ".", ) installed = self.run_pouch( "add", "solo", "-g", "-a", "codex", ) self.assertEqual(added.returncode, 0, added.stderr) self.assertEqual(installed.returncode, 0, installed.stderr) self.assertEqual( (self.home / ".codex" / "skills" / "solo").resolve(), source.resolve(), ) def test_project_add_persists_resolved_source_in_manifest(self) -> None: company = self.home / "company" expected = write_skill(company / "skills", "code-review") project = self.home / "project" project.mkdir() added = self.run_pouch("source", "add", "company", "--local", str(company)) self.assertEqual(added.returncode, 0, added.stderr) result = self.run_pouch( "add", "company/code-review", "--project", str(project), "-a", "codex", ) self.assertEqual(result.returncode, 0, result.stderr) manifest = project.joinpath(".pouch.yaml").read_text(encoding="utf-8") self.assertIn('name: "code-review"', manifest) self.assertIn("source: company", manifest) self.assertEqual( (project / ".agents" / "skills" / "code-review").resolve(), expected.resolve(), ) def test_project_add_updates_manifest_when_source_changes(self) -> None: write_skill(self.skills_home / "skills", "code-review") company = self.home / "company" write_skill(company / "skills", "code-review") project = self.home / "project" project.mkdir() project.joinpath(".pouch.yaml").write_text( "skills:\n - code-review\n", encoding="utf-8", ) added = self.run_pouch("source", "add", "company", "--local", str(company)) self.assertEqual(added.returncode, 0, added.stderr) result = self.run_pouch( "add", "company/code-review", "--project", str(project), "-a", "codex", ) self.assertEqual(result.returncode, 0, result.stderr) manifest = project.joinpath(".pouch.yaml").read_text(encoding="utf-8") self.assertIn("source: company", manifest) def test_unqualified_duplicate_requires_explicit_source(self) -> None: write_skill(self.skills_home / "skills", "code-review") company = self.home / "company" write_skill(company / "skills", "code-review") added = self.run_pouch("source", "add", "company", "--local", str(company)) self.assertEqual(added.returncode, 0, added.stderr) result = self.run_pouch("add", "code-review", "-g", "-a", "codex") self.assertNotEqual(result.returncode, 0) self.assertIn("builtin/code-review", result.stderr) self.assertIn("company/code-review", result.stderr) def test_custom_source_namespace_is_not_shadowed_by_catalog_collection(self) -> None: company = self.home / "company" expected = write_skill(company / "skills", "code-review") catalog_repo = self.home / "catalog-repo" write_skill(catalog_repo / "skills", "other-skill") self.skills_home.joinpath("catalog.yaml").write_text( "company:\n" f" repo: {catalog_repo}\n" " ref: main\n" " path: skills\n", encoding="utf-8", ) added = self.run_pouch("source", "add", "company", "--local", str(company)) result = self.run_pouch( "add", "company/code-review", "-g", "-a", "codex", ) self.assertEqual(added.returncode, 0, added.stderr) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( (self.home / ".codex" / "skills" / "code-review").resolve(), expected.resolve(), ) def test_sync_uses_manifest_source(self) -> None: company = self.home / "company" expected = write_skill(company / "skills", "code-review") project = self.home / "project" project.mkdir() project.joinpath(".pouch.yaml").write_text( "skills:\n - name: code-review\n source: company\n" "targets:\n - codex\n", encoding="utf-8", ) added = self.run_pouch("source", "add", "company", "--local", str(company)) self.assertEqual(added.returncode, 0, added.stderr) result = self.run_pouch("sync", "--project", str(project), "-a", "codex") self.assertEqual(result.returncode, 0, result.stderr) link = project / ".agents" / "skills" / "code-review" self.assertEqual(link.resolve(), expected.resolve()) def test_sync_reports_missing_machine_source(self) -> None: project = self.home / "project" project.mkdir() project.joinpath(".pouch.yaml").write_text( "skills:\n - name: code-review\n source: company\n", encoding="utf-8", ) result = self.run_pouch("sync", "--project", str(project), "-a", "codex") self.assertNotEqual(result.returncode, 0) self.assertIn("本机尚未配置", result.stderr) self.assertIn("pouch source add company", result.stderr) def test_git_source_is_cloned_to_default_checkout(self) -> None: upstream = self.home / "upstream" write_skill(upstream / "skills", "release-check") subprocess.run(["git", "init", "-b", "main", str(upstream)], check=True, capture_output=True) subprocess.run(["git", "-C", str(upstream), "add", "."], check=True) subprocess.run( [ "git", "-C", str(upstream), "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial", ], check=True, capture_output=True, ) result = self.run_pouch("source", "add", "company", str(upstream)) self.assertEqual(result.returncode, 0, result.stderr) checkout = self.home / ".local" / "share" / "pouch" / "sources" / "company" self.assertTrue((checkout / ".git").is_dir()) self.assertTrue((checkout / "skills" / "release-check" / "SKILL.md").is_file()) def test_git_source_clone_is_shallow(self) -> None: checkout = self.home / "checkout" entry = { "repo": "https://example.test/company-skills.git", "ref": "main", "checkout": str(checkout), } with patch("pouch.sources.subprocess.run") as run: fetch_source("company", entry) run.assert_called_once_with( [ "git", "clone", "--depth", "1", "--branch", "main", "--", "https://example.test/company-skills.git", str(checkout), ], check=True, ) if __name__ == "__main__": unittest.main()