a8efa5f359
- targets: cursor, claude, codex, agents (~/.agents/skills, .agents/skills) - agents dir covers OMP native discovery; drop omp/opencode targets - project-level agents/cursor/codex share .agents/skills via idempotent symlinks - migrate local state: remove ~/.config/opencode/skills links, reinstall into ~/.agents/skills
218 lines
8.7 KiB
Python
218 lines
8.7 KiB
Python
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 skiff 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 / ".skills"
|
|
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" / "skiff"
|
|
project_skill.mkdir()
|
|
project_skill.joinpath("SKILL.md").write_text(
|
|
"---\nname: skiff\ndescription: >-\n"
|
|
" 创建和维护自研 skill。用于创建、安装、反馈或更新 skill 时。\n"
|
|
"---\n\n# skiff\n\n## 步骤\n\n1. 维护 skill。\n",
|
|
encoding="utf-8",
|
|
)
|
|
project_skill.joinpath("README.md").write_text(
|
|
"# skiff\n\n供人类使用的 skiff 说明。\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def tearDown(self) -> None:
|
|
self.temp_dir.cleanup()
|
|
|
|
def run_skiff(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", "skiff", *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_skiff("--help")
|
|
setup_result = self.run_skiff("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_skiff("bootstrap")
|
|
second = self.run_skiff("bootstrap")
|
|
|
|
self.assertEqual(first.returncode, 0, first.stderr)
|
|
self.assertEqual(second.returncode, 0, second.stderr)
|
|
expected = (self.skills_home / "skills" / "skiff").resolve()
|
|
for relative in (
|
|
".cursor/skills/skiff",
|
|
".claude/skills/skiff",
|
|
".codex/skills/skiff",
|
|
".agents/skills/skiff",
|
|
):
|
|
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, "SKILLS_HOME", self.skills_home),
|
|
patch.object(cli, "ensure_skills_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_skiff(
|
|
"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("请完善 skiff 草稿 migration-review", result.stdout)
|
|
|
|
def test_check_rejects_template_placeholders(self) -> None:
|
|
created = self.run_skiff("create", "migration-review", "--idea", "检查数据库迁移")
|
|
self.assertEqual(created.returncode, 0, created.stderr)
|
|
|
|
result = self.run_skiff("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_skiff("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_skiff("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_skiff("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_skiff("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_skiff("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_skiff("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()
|