From a8efa5f359170655500e9da9793443e949afc7c6 Mon Sep 17 00:00:00 2001 From: laily Date: Mon, 24 Aug 2026 14:25:08 +0800 Subject: [PATCH] feat(skiff): replace opencode target with agents standard dir - 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 --- AGENTS.md | 6 +- README.md | 1 + skiff/README.md | 4 +- skiff/agents.py | 3 +- skiff/cli.py | 2 +- skiff/paths.py | 8 ++- skills/skiff/SKILL.md | 2 +- tests/test_agents_target.py | 108 ++++++++++++++++++++++++++++++++++ tests/test_create_workflow.py | 6 +- tests/test_install_script.py | 1 + tests/test_select.py | 8 +-- 11 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 tests/test_agents_target.py diff --git a/AGENTS.md b/AGENTS.md index adcb399..197bcf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,8 +148,8 @@ catalog.yaml ←── skiff add / fetch ▼ ▼ ~/.cursor/skills/ project/.agents/skills/ ~/.claude/skills/ project/.claude/skills/ -~/.codex/skills/ project/.opencode/skills/ -~/.config/opencode/skills/ project/.skills.yaml +~/.codex/skills/ project/.skills.yaml +~/.agents/skills/ (agents 标准目录,覆盖 OMP) ``` ### Skill 三层分类 @@ -177,7 +177,7 @@ catalog.yaml ←── skiff add / fetch | Cursor | `~/.cursor/skills/` | `.agents/skills/` | | Claude Code | `~/.claude/skills/` | `.claude/skills/` | | Codex | `~/.codex/skills/` | `.agents/skills/` | -| OpenCode | `~/.config/opencode/skills/` | `.opencode/skills/` | +| Agents 标准 | `~/.agents/skills/` | `.agents/skills/`(与 cursor/codex 共用;覆盖 OMP) | --- diff --git a/README.md b/README.md index 9f52789..f65fdf5 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ npx skills find typescript ~/.cursor/skills/ project/.agents/skills/ ~/.claude/skills/ project/.claude/skills/ ~/.codex/skills/ project/.skills.yaml +~/.agents/skills/ (agents 标准目录,覆盖 OMP) ``` ## 设计原则 diff --git a/skiff/README.md b/skiff/README.md index 33785f1..18786bc 100644 --- a/skiff/README.md +++ b/skiff/README.md @@ -58,7 +58,7 @@ skiff bootstrap | 命令 | 说明 | |------|------| | `skiff list [--source NAME]` | 列出所有来源或指定 source 中的 skill | -| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 | +| `skiff status [--target all\|cursor\|claude\|codex\|agents]` | 安装状态总览 | ### 项目初始化 @@ -87,6 +87,7 @@ skiff bootstrap | cursor | `~/.cursor/skills/` | | claude | `~/.claude/skills/` | | codex | `~/.codex/skills/` | +| agents | `~/.agents/skills/`(agents 标准目录,覆盖 OMP) | ### 预置 Catalog Source @@ -171,6 +172,7 @@ skiff 不保存 token。可以使用 `company/code-review`,也可以使用 | cursor | `/.agents/skills/` | | claude | `/.claude/skills/` | | codex | `/.agents/skills/` | +| agents | `/.agents/skills/`(与 cursor/codex 共用路径,软链幂等) | ### 脚手架与健康检查 diff --git a/skiff/agents.py b/skiff/agents.py index b3a2a10..cbfb7e5 100644 --- a/skiff/agents.py +++ b/skiff/agents.py @@ -9,8 +9,7 @@ AGENT_ALIASES: dict[str, str] = { "claude": "claude", "claude-code": "claude", "codex": "codex", - "opencode": "opencode", - "*": "*", + "agents": "agents", } AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()}) diff --git a/skiff/cli.py b/skiff/cli.py index 3b7601e..b38731d 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -1655,7 +1655,7 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None: nargs="+", action="append", metavar="AGENT", - help="目标 agent(cursor、claude、claude-code、codex、opencode、*)", + help="目标 agent(cursor、claude、claude-code、codex、agents、*)", ) parser.add_argument( "-g", diff --git a/skiff/paths.py b/skiff/paths.py index 2c4eaef..4b7e415 100644 --- a/skiff/paths.py +++ b/skiff/paths.py @@ -20,17 +20,19 @@ AGENT_GLOBAL: dict[str, Path] = { "cursor": HOME / ".cursor" / "skills", "claude": HOME / ".claude" / "skills", "codex": HOME / ".codex" / "skills", - "opencode": HOME / ".config" / "opencode" / "skills", + # agents 标准目录:OMP 原生 canonical(agents provider),cursor/codex 项目级同路径 + "agents": HOME / ".agents" / "skills", } AGENT_PROJECT: dict[str, str] = { "cursor": ".agents/skills", "claude": ".claude/skills", "codex": ".agents/skills", - "opencode": ".opencode/skills", + # 与 cursor/codex 共用 .agents/skills;软链幂等,同路径只写一次 + "agents": ".agents/skills", } -ALL_TARGETS = ("cursor", "claude", "codex", "opencode") +ALL_TARGETS = ("cursor", "claude", "codex", "agents") def resolve_targets(target: str | None) -> list[str]: diff --git a/skills/skiff/SKILL.md b/skills/skiff/SKILL.md index 20a384e..1e65324 100644 --- a/skills/skiff/SKILL.md +++ b/skills/skiff/SKILL.md @@ -19,7 +19,7 @@ SSOT 固定在 `~/.skills/skills//`。内容通过 **symlink** 分发到 ```bash skiff add --list -skiff add -a cursor -a claude -a codex -y +skiff add -a cursor -a claude -a codex -a agents -y ``` `skiff add ` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.skills/skills//` 的软链,不要在 Agent 目录创建副本。 diff --git a/tests/test_agents_target.py b/tests/test_agents_target.py new file mode 100644 index 0000000..fff9441 --- /dev/null +++ b/tests/test_agents_target.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def write_skill(root: Path, name: str) -> None: + skill = root / "skills" / 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", + ) + + +class AgentsTargetTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.home = Path(self.temp_dir.name) + self.skills_home = self.home / ".skills" + self.skills_home.mkdir(parents=True) + (self.skills_home / "catalog.yaml").write_text("", encoding="utf-8") + write_skill(self.skills_home, "demo-skill") + + 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_global_add_creates_symlink_in_agents_skills(self) -> None: + result = self.run_skiff("add", "builtin/demo-skill", "-g", "-a", "agents") + + self.assertEqual(result.returncode, 0, result.stderr) + link = self.home / ".agents" / "skills" / "demo-skill" + self.assertTrue(link.is_symlink()) + self.assertEqual( + link.resolve(), (self.skills_home / "skills" / "demo-skill").resolve() + ) + + removed = self.run_skiff("rm", "builtin/demo-skill", "-g", "-a", "agents") + self.assertEqual(removed.returncode, 0, removed.stderr) + self.assertFalse(link.exists()) + + def test_project_add_targets_agents_and_codex_share_single_link(self) -> None: + project = self.home / "app" + project.mkdir() + + added = self.run_skiff( + "add", + "builtin/demo-skill", + "--project", + str(project), + "-a", + "agents", + "-a", + "codex", + ) + + self.assertEqual(added.returncode, 0, added.stderr) + # agents 与 codex 项目级共用 .agents/skills,软链幂等只写一份 + links = list((project / ".agents" / "skills").glob("demo-skill")) + self.assertEqual(len(links), 1) + self.assertTrue(links[0].is_symlink()) + manifest = (project / ".skills.yaml").read_text(encoding="utf-8") + self.assertIn("demo-skill", manifest) + + removed = self.run_skiff( + "rm", "builtin/demo-skill", "--project", str(project), "-y" + ) + self.assertEqual(removed.returncode, 0, removed.stderr) + self.assertFalse((project / ".agents" / "skills" / "demo-skill").exists()) + + def test_status_lists_installed_skill_for_agents_target(self) -> None: + installed = self.run_skiff("add", "builtin/demo-skill", "-g", "-a", "agents") + status = self.run_skiff("status", "-a", "agents") + + self.assertEqual(installed.returncode, 0, installed.stderr) + self.assertEqual(status.returncode, 0, status.stderr) + self.assertIn("demo-skill", status.stdout) + + def test_opencode_target_is_no_longer_supported(self) -> None: + result = self.run_skiff("add", "builtin/demo-skill", "-g", "-a", "opencode") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("未知 agent", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_create_workflow.py b/tests/test_create_workflow.py index 19e9166..69d0775 100644 --- a/tests/test_create_workflow.py +++ b/tests/test_create_workflow.py @@ -83,13 +83,13 @@ class CreateWorkflowTests(unittest.TestCase): ".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) - self.assertIn("cursor", first.stdout) - self.assertIn("claude", first.stdout) - self.assertIn("codex", first.stdout) + for agent in ("cursor", "claude", "codex", "agents"): + self.assertIn(agent, first.stdout) def test_update_pulls_skills_home(self) -> None: with ( diff --git a/tests/test_install_script.py b/tests/test_install_script.py index c2825d9..87e706c 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -32,6 +32,7 @@ class InstallScriptTests(unittest.TestCase): ".cursor/skills/skiff", ".claude/skills/skiff", ".codex/skills/skiff", + ".agents/skills/skiff", ): link = home / relative self.assertTrue(link.is_symlink(), relative) diff --git a/tests/test_select.py b/tests/test_select.py index 5110528..0016ea9 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -78,7 +78,7 @@ class SelectorTests(unittest.TestCase): "ack", "builtin", installed=False, - readonly_status="全局: cursor,claude,codex,opencode", + readonly_status="全局: cursor,claude,codex,agents", ) ] screen = Mock() @@ -101,7 +101,7 @@ class SelectorTests(unittest.TestCase): if call.args[0] == 3 ) self.assertIn("[ ] ack", title) - self.assertIn("全局: cursor,claude,codex,opencode", title) + self.assertIn("全局: cursor,claude,codex,agents", title) self.assertNotIn("初始化", title) def test_selector_renders_description_on_indented_second_line(self) -> None: @@ -370,7 +370,7 @@ class SelectCommandTests(unittest.TestCase): other.mkdir() global_dirs = { target: root / target / "skills" - for target in ("cursor", "claude", "codex", "opencode") + for target in ("cursor", "claude", "codex", "agents") } for directory in global_dirs.values(): directory.mkdir(parents=True) @@ -385,7 +385,7 @@ class SelectCommandTests(unittest.TestCase): note = cli._global_installation_note( "ack", expected, - ["cursor", "claude", "codex", "opencode"], + ["cursor", "claude", "codex", "agents"], ) self.assertEqual(note, "全局: cursor;全局同名冲突: claude")