From 397ab74821b4d8499c5b769a3a52b578c67f1c44 Mon Sep 17 00:00:00 2001 From: syf2211 Date: Thu, 2 Jul 2026 06:11:56 +0000 Subject: [PATCH] fix(skill): make SKILL.md frontmatter compatible with OpenCode Fixes #439 OpenCode only recognizes name, description, license, compatibility, and flat string metadata in SKILL.md frontmatter. The bundled skill used a triggers field and nested metadata.openclaw.homepage, which surfaced as 'Unknown text label' warnings in the OpenCode UI. - Move triggers from frontmatter into the markdown body - Flatten metadata to a string-to-string homepage entry - Install/uninstall skills under ~/.config/opencode/skills - Add regression tests for OpenCode-compatible frontmatter --- agent_reach/cli.py | 20 +++++++---- agent_reach/skill/SKILL.md | 37 +++++++++---------- agent_reach/skill/SKILL_en.md | 3 +- tests/test_skill_command.py | 67 +++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 26 deletions(-) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 3cd45f61..1da35522 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -424,11 +424,12 @@ def _copy_skill_dir(target: str) -> str | None: print(f" Warning: Could not install skill: {e}") return None - # Determine skill install path (priority: .agents > openclaw > claude) + # Determine skill install path (priority: .agents > opencode > openclaw > claude) skill_dirs = [ - os.path.expanduser("~/.agents/skills"), # Generic agents (priority) - os.path.expanduser("~/.openclaw/skills"), # OpenClaw - os.path.expanduser("~/.claude/skills"), # Claude Code (if exists) + os.path.expanduser("~/.agents/skills"), # Generic agents (priority) + os.path.expanduser("~/.config/opencode/skills"), # OpenCode + os.path.expanduser("~/.openclaw/skills"), # OpenClaw + os.path.expanduser("~/.claude/skills"), # Claude Code (if exists) ] # Insert OPENCLAW_HOME path at the beginning if environment variable is set @@ -442,7 +443,12 @@ def _copy_skill_dir(target: str) -> str | None: target = os.path.join(skill_dir, "agent-reach") status = _copy_skill_dir(target) if status: - platform_name = "Agent" if ".agents" in skill_dir else "OpenClaw" if "openclaw" in skill_dir else "Claude Code" + platform_name = ( + "OpenCode" if "opencode" in skill_dir + else "Agent" if ".agents" in skill_dir + else "OpenClaw" if "openclaw" in skill_dir + else "Claude Code" + ) if status == "preserved": print(f"Skill already installed for {platform_name}, preserving existing files: {target}") else: @@ -460,7 +466,7 @@ def _copy_skill_dir(target: str) -> str | None: print(f"Skill installed: {target}") else: print(" -- Could not install agent skill (optional)") - print(" -- Tip: install OpenClaw, Claude Code, or create ~/.agents/skills/ manually") + print(" -- Tip: install OpenClaw, OpenCode, Claude Code, or create ~/.agents/skills/ manually") def _uninstall_skill(): @@ -469,6 +475,7 @@ def _uninstall_skill(): skill_dirs = [ ("~/.openclaw/skills/agent-reach", "OpenClaw"), + ("~/.config/opencode/skills/agent-reach", "OpenCode"), ("~/.claude/skills/agent-reach", "Claude Code"), ("~/.agents/skills/agent-reach", "Agent"), ] @@ -1412,6 +1419,7 @@ def _cmd_uninstall(args): # ── 2. Skill files ── skill_dirs = [ ("~/.openclaw/skills/agent-reach", "OpenClaw"), + ("~/.config/opencode/skills/agent-reach", "OpenCode"), ("~/.claude/skills/agent-reach", "Claude Code"), ("~/.agents/skills/agent-reach", "Agent"), ] diff --git a/agent_reach/skill/SKILL.md b/agent_reach/skill/SKILL.md index 918ba2dc..c0bb2a77 100644 --- a/agent_reach/skill/SKILL.md +++ b/agent_reach/skill/SKILL.md @@ -19,31 +19,32 @@ description: > 【路由方式】SKILL.md 包含路由表和常用命令,复杂场景需按需阅读对应分类的 references/*.md。 分类:search / social (小红书/推特/B站/V2EX/Reddit/Facebook/Instagram) / career(LinkedIn) / dev(github) / web(网页/文章/RSS) / video(YouTube/B站/播客)。 -triggers: - - research: 调研/全网调研/帮我调研/研究一下/research/深入了解 - - search: 搜/查/找/search/搜索/查一下/帮我搜/看看大家怎么说 - - social: - - 小红书: xiaohongshu/xhs/小红书/红书 - - Twitter: twitter/推特/x.com/推文 - - B站: bilibili/b站/哔哩哔哩 - - V2EX: v2ex - - Reddit: reddit - - Facebook: facebook/fb/facebook groups - - Instagram: instagram/ig - - career: 招聘/职位/求职/linkedin/领英/找工作 - - dev: github/代码/仓库/gh/issue/pr/分支/commit - - web: 网页/链接/文章/rss/读一下/打开这个 - - video: youtube/视频/播客/字幕/小宇宙/转录/yt - - finance: 雪球/股票/stock/xueqiu/行情/基金 metadata: - openclaw: - homepage: https://github.com/Panniantong/Agent-Reach + homepage: https://github.com/Panniantong/Agent-Reach --- # Agent Reach — 互联网能力路由器 15 平台、多后端。**本 skill 存在时必须用它访问这些平台,不要自己发明方案。** +## 触发关键词(Triggers) + +- research: 调研/全网调研/帮我调研/研究一下/research/深入了解 +- search: 搜/查/找/search/搜索/查一下/帮我搜/看看大家怎么说 +- social: + - 小红书: xiaohongshu/xhs/小红书/红书 + - Twitter: twitter/推特/x.com/推文 + - B站: bilibili/b站/哔哩哔哩 + - V2EX: v2ex + - Reddit: reddit + - Facebook: facebook/fb/facebook groups + - Instagram: instagram/ig +- career: 招聘/职位/求职/linkedin/领英/找工作 +- dev: github/代码/仓库/gh/issue/pr/分支/commit +- web: 网页/链接/文章/rss/读一下/打开这个 +- video: youtube/视频/播客/字幕/小宇宙/转录/yt +- finance: 雪球/股票/stock/xueqiu/行情/基金 + ## 常驻规则(全程适用) 1. **动手前先体检**:多后端/登录态平台(小红书/Reddit/B站/Twitter/Facebook/Instagram)先跑 diff --git a/agent_reach/skill/SKILL_en.md b/agent_reach/skill/SKILL_en.md index c30ed744..b4b07d41 100644 --- a/agent_reach/skill/SKILL_en.md +++ b/agent_reach/skill/SKILL_en.md @@ -17,8 +17,7 @@ description: > internet content); posting/commenting/liking (write operations); platforms that already have a dedicated skill installed (prefer that skill). metadata: - openclaw: - homepage: https://github.com/Panniantong/Agent-Reach + homepage: https://github.com/Panniantong/Agent-Reach --- # Agent Reach — internet capability router diff --git a/tests/test_skill_command.py b/tests/test_skill_command.py index 15f1edd2..214bd596 100644 --- a/tests/test_skill_command.py +++ b/tests/test_skill_command.py @@ -3,16 +3,83 @@ import importlib.resources import os +import re import tempfile import unittest from unittest.mock import patch +import yaml + from agent_reach.cli import _install_skill, _uninstall_skill +# OpenCode only recognizes these frontmatter keys (see opencode.ai/docs/skills). +OPENCODE_ALLOWED_FRONTMATTER_KEYS = frozenset( + {"name", "description", "license", "compatibility", "metadata"} +) + + +def _read_skill_frontmatter(resource_name: str) -> dict: + skill_dir = importlib.resources.files("agent_reach").joinpath("skill") + text = skill_dir.joinpath(resource_name).read_text(encoding="utf-8") + match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) + assert match, f"{resource_name} must start with YAML frontmatter" + return yaml.safe_load(match.group(1)) + class TestSkillCommand(unittest.TestCase): """Test skill install and uninstall via CLI helpers.""" + def test_skill_frontmatter_is_opencode_compatible(self): + """Frontmatter should only use fields recognized by OpenCode agents.""" + for resource_name in ("SKILL.md", "SKILL_en.md"): + with self.subTest(resource=resource_name): + frontmatter = _read_skill_frontmatter(resource_name) + unknown_keys = set(frontmatter) - OPENCODE_ALLOWED_FRONTMATTER_KEYS + self.assertEqual( + unknown_keys, + set(), + f"Unsupported frontmatter keys in {resource_name}: {unknown_keys}", + ) + metadata = frontmatter.get("metadata") + if metadata is not None: + self.assertIsInstance(metadata, dict) + for key, value in metadata.items(): + self.assertIsInstance( + key, + str, + f"metadata key must be a string in {resource_name}", + ) + self.assertIsInstance( + value, + str, + f"metadata[{key!r}] must be a string in {resource_name}", + ) + description = frontmatter.get("description", "") + self.assertGreaterEqual(len(description), 1) + self.assertLessEqual(len(description), 1024) + + def test_install_skill_to_opencode_directory(self): + """_install_skill should install to ~/.config/opencode/skills when present.""" + with tempfile.TemporaryDirectory() as tmpdir: + skill_parent = os.path.join(tmpdir, ".config", "opencode", "skills") + os.makedirs(skill_parent) + + with patch( + "agent_reach.cli.os.path.expanduser", + side_effect=lambda p: p.replace("~", tmpdir), + ): + env = os.environ.copy() + env.pop("OPENCLAW_HOME", None) + with patch.dict(os.environ, env, clear=True): + _install_skill() + + target = os.path.join(skill_parent, "agent-reach", "SKILL.md") + self.assertTrue(os.path.exists(target)) + with open(target, encoding="utf-8") as f: + content = f.read() + self.assertIn("Agent Reach", content) + self.assertNotIn("\ntriggers:", content[: content.index("\n# ")]) + def test_skill_resources_include_both_locales(self): """Package resources should expose both default and English skill markdown files.""" skill_dir = importlib.resources.files("agent_reach").joinpath("skill")