From 8f3ac0c34760fa1058700181f0243a9f23459610 Mon Sep 17 00:00:00 2001 From: yuyili Date: Thu, 2 Jul 2026 11:49:18 +0800 Subject: [PATCH] =?UTF-8?q?FEAT:=20=E6=B7=BB=E5=8A=A0skills=5Fhub=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mkdocs/en/skill.md | 67 ++- docs/mkdocs/zh/skill.md | 115 ++++- examples/skills_hub/.env | 8 + examples/skills_hub/.gitignore | 1 + examples/skills_hub/README.md | 51 ++ examples/skills_hub/agent/__init__.py | 5 + examples/skills_hub/agent/agent.py | 43 ++ examples/skills_hub/agent/config.py | 19 + examples/skills_hub/agent/hub.py | 49 ++ examples/skills_hub/agent/prompts.py | 19 + examples/skills_hub/run_agent.py | 91 ++++ tests/skills/hub/__init__.py | 0 tests/skills/hub/test_claude_marketplace.py | 141 +++++ tests/skills/hub/test_clawhub.py | 223 ++++++++ tests/skills/hub/test_github.py | 340 ++++++++++++ tests/skills/hub/test_hermes_index.py | 219 ++++++++ tests/skills/hub/test_install.py | 294 +++++++++++ tests/skills/hub/test_lobehub.py | 134 +++++ tests/skills/hub/test_skills_sh.py | 150 ++++++ tests/skills/hub/test_source.py | 56 ++ tests/skills/hub/test_types.py | 155 ++++++ tests/skills/hub/test_well_known.py | 203 ++++++++ trpc_agent_sdk/skills/__init__.py | 10 + trpc_agent_sdk/skills/_repository.py | 16 +- trpc_agent_sdk/skills/hub/__init__.py | 59 +++ .../skills/hub/_claude_marketplace.py | 103 ++++ trpc_agent_sdk/skills/hub/_clawhub.py | 482 ++++++++++++++++++ trpc_agent_sdk/skills/hub/_github.py | 391 ++++++++++++++ trpc_agent_sdk/skills/hub/_hermes_index.py | 187 +++++++ trpc_agent_sdk/skills/hub/_install.py | 193 +++++++ trpc_agent_sdk/skills/hub/_lobehub.py | 160 ++++++ trpc_agent_sdk/skills/hub/_skills_sh.py | 465 +++++++++++++++++ trpc_agent_sdk/skills/hub/_source.py | 42 ++ trpc_agent_sdk/skills/hub/_types.py | 77 +++ trpc_agent_sdk/skills/hub/_well_known.py | 242 +++++++++ 35 files changed, 4780 insertions(+), 30 deletions(-) create mode 100644 examples/skills_hub/.env create mode 100644 examples/skills_hub/.gitignore create mode 100644 examples/skills_hub/README.md create mode 100644 examples/skills_hub/agent/__init__.py create mode 100644 examples/skills_hub/agent/agent.py create mode 100644 examples/skills_hub/agent/config.py create mode 100644 examples/skills_hub/agent/hub.py create mode 100644 examples/skills_hub/agent/prompts.py create mode 100644 examples/skills_hub/run_agent.py create mode 100644 tests/skills/hub/__init__.py create mode 100644 tests/skills/hub/test_claude_marketplace.py create mode 100644 tests/skills/hub/test_clawhub.py create mode 100644 tests/skills/hub/test_github.py create mode 100644 tests/skills/hub/test_hermes_index.py create mode 100644 tests/skills/hub/test_install.py create mode 100644 tests/skills/hub/test_lobehub.py create mode 100644 tests/skills/hub/test_skills_sh.py create mode 100644 tests/skills/hub/test_source.py create mode 100644 tests/skills/hub/test_types.py create mode 100644 tests/skills/hub/test_well_known.py create mode 100644 trpc_agent_sdk/skills/hub/__init__.py create mode 100644 trpc_agent_sdk/skills/hub/_claude_marketplace.py create mode 100644 trpc_agent_sdk/skills/hub/_clawhub.py create mode 100644 trpc_agent_sdk/skills/hub/_github.py create mode 100644 trpc_agent_sdk/skills/hub/_hermes_index.py create mode 100644 trpc_agent_sdk/skills/hub/_install.py create mode 100644 trpc_agent_sdk/skills/hub/_lobehub.py create mode 100644 trpc_agent_sdk/skills/hub/_skills_sh.py create mode 100644 trpc_agent_sdk/skills/hub/_source.py create mode 100644 trpc_agent_sdk/skills/hub/_types.py create mode 100644 trpc_agent_sdk/skills/hub/_well_known.py diff --git a/docs/mkdocs/en/skill.md b/docs/mkdocs/en/skill.md index 4be0c437..60a9120e 100644 --- a/docs/mkdocs/en/skill.md +++ b/docs/mkdocs/en/skill.md @@ -2143,17 +2143,56 @@ The **Dynamic Tool Selection** mechanism has been fully implemented and verified - ❌ All tools need to be available simultaneously - ❌ Token cost is not a primary concern -## References and Examples - -- Background: - - Blog: - https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills - - Open repository: https://github.com/anthropics/skills -- This repository: - - Interactive demo: [examples/skills/run_agent.py](../../../examples/skills/run_agent.py) - - Dynamic tool selection full example: [examples/skills_with_dynamic_tools/run_agent.py](../../../examples/skills_with_dynamic_tools/run_agent.py) - - Example structure guide: [examples/skills/README.md](../../../examples/skills/README.md) - - Example skills: - - [examples/skills/skills/python-math/SKILL.md](../../../examples/skills/skills/python-math/SKILL.md) - - [examples/skills/skills/file_tools/SKILL.md](../../../examples/skills/skills/file_tools/SKILL.md) - - [examples/skills/skills/user_file_ops/SKILL.md](../../../examples/skills/skills/user_file_ops/SKILL.md) +## Skill Hub - Discovering and Fetching Skills from Remote Sources + +**Skill Hub** (`trpc_agent_sdk.skills.hub`) is a set of adapters (`SkillSource`) for discovering and fetching skills from remote sources. It provides three capabilities: searching for available skills from a source (GitHub, ClawHub, skills.sh, and others), inspecting metadata for a specific skill, and downloading the complete file contents for that skill. Users can also implement the `SkillSource` interface to integrate their own skill source. + +### `SkillSource` Contract + +Every adapter implements the same four-method interface: + +```python +from trpc_agent_sdk.skills.hub import SkillSource, SkillMeta, SkillBundle + +class SkillSource(ABC): + def source_id(self) -> str: ... + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: ... + def inspect(self, identifier: str) -> SkillMeta | None: ... + def fetch(self, identifier: str) -> SkillBundle | None: ... +``` + +- `SkillMeta` - lightweight search/inspect result (`name`, `description`, `source`, `identifier`, plus optional `repo`/`path`/`tags`/`extra`) +- `SkillBundle` - the downloaded skill (`name`, `files: dict[str, str | bytes]`, `source`, `identifier`, `metadata`) + +`fetch()` only returns an in-memory `SkillBundle`. Writing it to disk (including overwrite policy, atomic writes, and concurrency safety) is the **caller's** responsibility, because different harnesses have different installation semantics. For this purpose, the SDK also exports three path validation functions: + +```python +from trpc_agent_sdk.skills.hub import validate_skill_name, validate_category_name, validate_bundle_rel_path +``` + +### Built-in Adapters + +| Adapter | Source | Identifier format | +| --- | --- | --- | +| `GitHubSource` | GitHub repos, via the Contents / Git Trees API | `"owner/repo/path/to/skill-dir"` | +| `WellKnownSkillSource` | Any domain exposing `/.well-known/skills/index.json` | `well-known:{base_url}/{skill_name}` or a raw HTTPS URL | +| `HermesIndexSource` | A centralized, pre-crawled skills catalog | Same identifiers as the underlying `GitHubSource` entries | +| `SkillsShSource` | [skills.sh](https://skills.sh) | `skills-sh/{owner}/{repo}/{skill_path}` | +| `ClawHubSource` | [ClawHub](https://clawhub.ai) | slug, e.g. `"notion"` | +| `ClaudeMarketplaceSource` | Claude Code marketplace repos (`.claude-plugin/marketplace.json`) | Resolves to a `GitHubSource` identifier | +| `LobeHubSource` | LobeHub agent marketplace (converted to synthetic `SKILL.md`) | `lobehub/{agent_id}` | + +### Minimal Usage + +```python +from trpc_agent_sdk.skills.hub import GitHubAuth, GitHubSource + +source = GitHubSource(GitHubAuth()) # no authentication is required for public repositories +meta = source.inspect("anthropics/skills/skills/skill-creator") +bundle = source.fetch("anthropics/skills/skills/skill-creator") +# bundle.files: {"SKILL.md": "...", "scripts/...": "...", ...} +``` + +### Full Example + +See the complete Skill Hub usage example: [examples/skills_hub/run_agent.py](../../../examples/skills_hub/run_agent.py) diff --git a/docs/mkdocs/zh/skill.md b/docs/mkdocs/zh/skill.md index 1c5789f0..d217ef96 100644 --- a/docs/mkdocs/zh/skill.md +++ b/docs/mkdocs/zh/skill.md @@ -2141,17 +2141,104 @@ Tools: - ❌ 所有工具都需要同时可用 - ❌ Token 成本不是主要考虑因素 -## 参考和示例 - -- 背景: - - 博客: - https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills - - 开放仓库:https://github.com/anthropics/skills -- 本仓库: - - 交互式演示:[examples/skills/run_agent.py](../../../examples/skills/run_agent.py) - - 动态工具选择完整示例:[examples/skills_with_dynamic_tools/run_agent.py](../../../examples/skills_with_dynamic_tools/run_agent.py) - - 示例结构说明:[examples/skills/README.md](../../../examples/skills/README.md) - - 示例技能: - - [examples/skills/skills/python-math/SKILL.md](../../../examples/skills/skills/python-math/SKILL.md) - - [examples/skills/skills/file_tools/SKILL.md](../../../examples/skills/skills/file_tools/SKILL.md) - - [examples/skills/skills/user_file_ops/SKILL.md](../../../examples/skills/skills/user_file_ops/SKILL.md) +## Skill Hub —— 从远程来源发现并获取 Skill + +**Skill Hub**(`trpc_agent_sdk.skills.hub`)是一组用于从远程来源发现和获取 skill 的适配器(`SkillSource`)。它能做三件事:搜索某个来源(GitHub、ClawHub、skills.sh 等)上有哪些 skill、查看某个 skill 的元信息、下载它的完整文件内容。用户也可以实现SkillSource 接口来对接自己的skill source. + +### `SkillSource` 契约 + +每个适配器都实现同样的四方法接口: + +```python +from trpc_agent_sdk.skills.hub import SkillSource, SkillMeta, SkillBundle + +class SkillSource(ABC): + def source_id(self) -> str: ... + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: ... + def inspect(self, identifier: str) -> SkillMeta | None: ... + def fetch(self, identifier: str) -> SkillBundle | None: ... +``` + +- `SkillMeta` —— 轻量的 search/inspect 结果(`name`、`description`、`source`、`identifier`,以及可选的 `repo`/`path`/`tags`/`extra`) +- `SkillBundle` —— 下载到的 skill(`name`、`files: dict[str, str | bytes]`、`source`、`identifier`、`metadata`) + +`fetch()` 只会返回内存中的 `SkillBundle`。如果要使用远程 skill,需要先安装到本地目录。SDK 提供了 `SkillSpec`、`SkillSpecsConfig` 和 `create_default_skill_repository(additional_skill_specs=...)` 的联动入口:把要下载的 `SkillSpec` 列表和 `install_path` 打包成一个 `SkillSpecsConfig`,构造 repository 时先原子写入 `install_path`,再像普通本地 skill 一样扫描索引。`install_path` 可省略,默认落到系统临时目录下的 `trpc_agent_skills`。SDK 也导出了三个路径校验函数,供自定义安装逻辑复用: + +```python +from trpc_agent_sdk.skills.hub import validate_skill_name, validate_category_name, validate_bundle_rel_path +``` + +### 内置适配器 + +| 适配器 | 来源 | 标识符格式 | +| --- | --- | --- | +| `GitHubSource` | GitHub 仓库,如 https://github.com/anthropics/skills | `owner/repo/path/to/skill-dir`,例如 `"anthropics/skills/skills/skill-creator"` | +| `WellKnownSkillSource` | 暴露 `/.well-known/skills/index.json` 的站点,如 https://www.mintlify.com/docs/.well-known/skills/index.json | HTTPS URL,例如 `"https://example.com/.well-known/skills/plan"` | +| `HermesIndexSource` | [Hermes Skills Index](https://hermes-agent.nousresearch.com/docs/api/skills-index.json) | `owner/repo/path`,例如 `"anthropics/skills/skills/skill-creator"` | +| `SkillsShSource` | [skills.sh](https://skills.sh) | `skills-sh/{owner}/{repo}/{skill_path}`,例如 `"skills-sh/owner/repo/plan"` | +| `ClawHubSource` | [ClawHub](https://clawhub.ai) | `{slug}`,例如 `notion` | +| `ClaudeMarketplaceSource` | 含 `.claude-plugin/marketplace.json` 的 GitHub marketplace 仓库,如 https://github.com/anthropics/skills | `owner/repo/path`,例如 `"anthropics/skills/plugins/docx"` | +| `LobeHubSource` | [LobeHub agent marketplace](https://chat-agents.lobehub.com/index.json) | `lobehub/{agent_id}`,例如 `lobehub/writer-bot` | + +### 用法 +下面介绍一下 Skill Hub 的基础用法: + +```python +from trpc_agent_sdk.skills.hub import GitHubAuth, GitHubSource + +# GitHubAuth 不传 token 时按未认证方式访问(限流 60 次/时,仅公开仓库)。 +# search 需要 taps 声明"搜哪些仓库";fetch/inspect 直接吃完整 identifier,无需 taps。 +source = GitHubSource( + GitHubAuth(), + taps=[{"repo": "anthropics/skills", "path": "skills/"}], +) + +# 1) 搜索:在 taps 里匹配关键词,返回一组轻量的 SkillMeta +for meta in source.search("skill", limit=5): + print(meta.name, "-", meta.identifier) + +# 2) 查看元信息:只取某个 skill 的 SKILL.md 元数据用于预览 +meta = source.inspect("anthropics/skills/skills/skill-creator") +print(meta.name, meta.description) + +# 3) 下载:把完整文件内容拉到内存里的 SkillBundle(不落盘) +bundle = source.fetch("anthropics/skills/skills/skill-creator") +if bundle is not None: + print(bundle.name, "共", len(bundle.files), "个文件") + print(bundle.files["SKILL.md"][:200]) # files 是 {相对路径: 内容} +``` + +`fetch()` 拿到的只是内存里的 `SkillBundle`。要把它接到 agent 用,下面介绍如何把 Skill Hub 和现有的 skill repository 连接起来: + +```python +from trpc_agent_sdk.skills import SkillSpec +from trpc_agent_sdk.skills import SkillSpecsConfig +from trpc_agent_sdk.skills import SkillToolSet +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.hub import ClawHubSource, GitHubAuth, GitHubSource + +repository = create_default_skill_repository( + additional_skill_specs=SkillSpecsConfig( + specs=[ + # 每个 SkillSpec 对应一个来源;要从多个来源各取一个 skill, + # 就在 specs 里多放几个 SkillSpec。 + SkillSpec( + source=GitHubSource(GitHubAuth()), + identifier="anthropics/skills/skills/skill-creator", + name="skill-creator", + ), + SkillSpec( + source=ClawHubSource(), + identifier="notion", + name="notion", + ), + ], + install_path="data/skills/.downloaded", # 省略则默认落到系统临时目录: /trpc_agent_skills/ + ), +) +skill_toolset = SkillToolSet(repository=repository) +``` + +### 完整示例 + +查看完整的 Skill Hub 使用示例:[examples/skills_hub/run_agent.py](../../../examples/skills_hub/run_agent.py) diff --git a/examples/skills_hub/.env b/examples/skills_hub/.env new file mode 100644 index 00000000..d18e95fa --- /dev/null +++ b/examples/skills_hub/.env @@ -0,0 +1,8 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name + +# Optional: a GitHub personal access token (raises the unauthenticated +# 60 req/hr GitHub API limit to 5,000/hr). Not required to run this demo. +# GITHUB_TOKEN= diff --git a/examples/skills_hub/.gitignore b/examples/skills_hub/.gitignore new file mode 100644 index 00000000..1269488f --- /dev/null +++ b/examples/skills_hub/.gitignore @@ -0,0 +1 @@ +data diff --git a/examples/skills_hub/README.md b/examples/skills_hub/README.md new file mode 100644 index 00000000..c0ea5526 --- /dev/null +++ b/examples/skills_hub/README.md @@ -0,0 +1,51 @@ +# Skill Hub 示例 + +本示例演示 `trpc_agent_sdk.skills.hub`(Skill Hub):在启动 `LlmAgent` 之前,通过 `SkillSpecsConfig`(打包 `SkillSpec` + `install_path`)+ `create_default_skill_repository(additional_skill_specs=...)` 从 GitHub 上按需拉取一个技能(Anthropic 官方的 `skill-creator`),写入本地技能目录,再像本地技能一样交给 `SkillToolSet` 使用。 + +## 什么是 Skill Hub + +Skill Hub(`trpc_agent_sdk.skills.hub`)把"从各种来源发现并获取 skill"统一到同一个接口后面。每个来源都是一个 `SkillSource` 适配器,对外提供一致的 `search` / `inspect` / `fetch` 三种能力: + +| 适配器 | 来源 | +| --- | --- | +| `GitHubSource` | GitHub 仓库目录(本示例使用) | +| `WellKnownSkillSource` | 站点 `.well-known/skills` | +| `HermesIndexSource` | Hermes 内置 skill index | +| `SkillsShSource` | skills.sh | +| `ClawHubSource` | ClawHub registry | +| `ClaudeMarketplaceSource` | Claude Skills Marketplace | +| `LobeHubSource` | LobeHub | + +`SkillSource.fetch()` 只返回内存中的 `SkillBundle`(`name` + `files` + `metadata`)。SDK 提供 `SkillSpec` 声明、`SkillSpecsConfig`(`specs` + `install_path`,`install_path` 省略时默认落到系统临时目录)和 `create_default_skill_repository(additional_skill_specs=...)`,在构造 repository 时把远程 skill 写入 `install_path`,再交给标准 `FsSkillRepository` 扫描。 + +## 关键特性 + +- `GitHubSource(GitHubAuth(token))` 无需认证即可拉取公开仓库(60 次/小时限额,足够本示例使用;设置 `GITHUB_TOKEN` 可提升到 5000 次/小时) +- 安装逻辑内部复用 SDK 导出的路径校验,避免恶意 `SkillBundle` 写出到目标目录之外 +- 已安装的技能会被跳过,除非 `SkillSpec(replace_if_exists=True)` +- 拉取完成后,技能通过标准的 `create_default_skill_repository` + `SkillToolSet` 链路对 agent 可见,和本地技能没有区别 + +## Agent 层级结构说明 + +- 根节点:`LlmAgent`(`skill_hub_demo_agent`),挂载 `SkillToolSet` 与 `skill_repository` +- 无子 Agent;单智能体通过 `skill_load` / `skill_list_docs` 等技能工具完成任务 + +## 关键代码解释 + +- `agent/hub.py`: + - `create_skill_tool_set()`:用 `SkillSpecsConfig` 打包 GitHub `SkillSpec` 与 `install_path`,调用 `create_default_skill_repository(additional_skill_specs=...)` 完成安装 + 索引 +- `agent/agent.py`:`create_agent(skills_dir)` 把返回的 `skill_repository` / `skill_tool_set` 绑定到 `LlmAgent` +- `run_agent.py`:清空 `data/` 目录(保证每次都重新走一遍 Skill Hub 拉取流程),创建 agent,跑一轮 `skill_load` + 总结的对话,并打印实际下载到的文件列表 + +## 环境与运行 + +- Python 3.10+;仓库根目录执行 `pip install -e .` +- 配置 `TRPC_AGENT_API_KEY`、`TRPC_AGENT_BASE_URL`、`TRPC_AGENT_MODEL_NAME`(可用 `.env`) +- 可选:`GITHUB_TOKEN`,用于提高 GitHub API 限额(本示例只读取公开仓库,不设置也能跑) + +```bash +cd examples/skills_hub +python3 run_agent.py +``` + +运行后会在 `data/skills/.downloaded/hub/skill-creator/` 下看到从 GitHub 拉取的真实文件(`SKILL.md`、`scripts/`、`references/` 等)。 diff --git a/examples/skills_hub/agent/__init__.py b/examples/skills_hub/agent/__init__.py new file mode 100644 index 00000000..bc6e483f --- /dev/null +++ b/examples/skills_hub/agent/__init__.py @@ -0,0 +1,5 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. diff --git a/examples/skills_hub/agent/agent.py b/examples/skills_hub/agent/agent.py new file mode 100644 index 00000000..b07753af --- /dev/null +++ b/examples/skills_hub/agent/agent.py @@ -0,0 +1,43 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +""" Agent that demonstrates fetching a skill from the Skill Hub before running. """ + +from pathlib import Path + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import OpenAIModel + +from .config import get_model_config +from .hub import create_skill_tool_set +from .prompts import INSTRUCTION + + +def _create_model() -> LLMModel: + """ Create a model""" + api_key, url, model_name = get_model_config() + model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + return model + + +def create_agent(skills_dir: Path) -> LlmAgent: + """Fetch a skill from GitHub via the Skill Hub, then build an agent that can use it. + + Args: + skills_dir: Local directory to install fetched skills into. Populated + by `create_default_skill_repository(additional_skill_specs=...)` + before the agent is constructed. + """ + skill_tool_set, skill_repository = create_skill_tool_set(skills_dir) + + return LlmAgent( + name="skill_hub_demo_agent", + description="An assistant that fetches skills on demand from the Skill Hub.", + model=_create_model(), + instruction=INSTRUCTION, + tools=[skill_tool_set], + skill_repository=skill_repository, + ) diff --git a/examples/skills_hub/agent/config.py b/examples/skills_hub/agent/config.py new file mode 100644 index 00000000..db0d491b --- /dev/null +++ b/examples/skills_hub/agent/config.py @@ -0,0 +1,19 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +""" Agent config module""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Get model config from environment variables""" + api_key = os.getenv('TRPC_AGENT_API_KEY', '') + url = os.getenv('TRPC_AGENT_BASE_URL', '') + model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '') + if not api_key or not url or not model_name: + raise ValueError('''TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, + and TRPC_AGENT_MODEL_NAME must be set in environment variables''') + return api_key, url, model_name diff --git a/examples/skills_hub/agent/hub.py b/examples/skills_hub/agent/hub.py new file mode 100644 index 00000000..c62dc0f5 --- /dev/null +++ b/examples/skills_hub/agent/hub.py @@ -0,0 +1,49 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Skill Hub glue: declare a remote skill and build a repository for it.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from trpc_agent_sdk.code_executors import create_local_workspace_runtime +from trpc_agent_sdk.skills import BaseSkillRepository +from trpc_agent_sdk.skills import SkillToolSet +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import GitHubSource +from trpc_agent_sdk.skills.hub import SkillSpec +from trpc_agent_sdk.skills.hub import SkillSpecsConfig + +# `skill-creator` is Anthropic's own skill for building and iterating on +# skills -- a fitting "meta" skill to fetch through the Skill Hub for a demo. +GITHUB_SKILL_IDENTIFIER = "anthropics/skills/skills/skill-creator" +GITHUB_SKILL_NAME = "skill-creator" + + +def create_skill_tool_set(skills_dir: Path) -> tuple[SkillToolSet, BaseSkillRepository]: + """Build a `SkillToolSet` backed by a GitHub skill installed through the repository factory.""" + workspace_runtime = create_local_workspace_runtime() + # Unauthenticated requests are capped at 60 req/hr, which is plenty for + # this demo. Set GITHUB_TOKEN to raise that limit for repeated runs. + source = GitHubSource(GitHubAuth(os.getenv("GITHUB_TOKEN") or None)) + repository = create_default_skill_repository( + additional_skill_specs=SkillSpecsConfig( + specs=[ + SkillSpec( + source=source, + identifier=GITHUB_SKILL_IDENTIFIER, + name=GITHUB_SKILL_NAME, + on_error="raise", + ), + ], + install_path=str(skills_dir / ".downloaded"), + ), + workspace_runtime=workspace_runtime, + ) + skill_toolset = SkillToolSet(repository=repository) + return skill_toolset, repository diff --git a/examples/skills_hub/agent/prompts.py b/examples/skills_hub/agent/prompts.py new file mode 100644 index 00000000..ab0d68dc --- /dev/null +++ b/examples/skills_hub/agent/prompts.py @@ -0,0 +1,19 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +""" prompts for agent""" + +INSTRUCTION = """ +Be a concise, helpful assistant that can use Agent Skills. + +A skill named "skill-creator" was just fetched on demand from GitHub via the +Skill Hub (`trpc_agent_sdk.skills.hub`) and installed locally before you were +started, so it is available like any other local skill. + +When asked about a skill, call skill_load to load its documentation, then +skill_list_docs to see what documentation and files are available. Summarize +what you find concisely; do not run any of the skill's scripts unless +explicitly asked to. +""" diff --git a/examples/skills_hub/run_agent.py b/examples/skills_hub/run_agent.py new file mode 100644 index 00000000..8002d5fc --- /dev/null +++ b/examples/skills_hub/run_agent.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 + +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +""" +Example demonstrating the Skill Hub (`trpc_agent_sdk.skills.hub`): fetching a +skill from GitHub on demand and using it like any locally-installed Agent +Skill. +""" +import asyncio +import shutil +import uuid +from pathlib import Path + +from dotenv import load_dotenv +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +EXAMPLE_DIR = Path(__file__).resolve().parent +load_dotenv(EXAMPLE_DIR / ".env") + +QUERY = """ +Load the skill-creator skill and, without running any of its scripts, +summarize in a few bullet points: what it is for, and what top-level +files/folders it ships with. +""" + + +async def run_skill_hub_demo() -> None: + """Fetch a skill via the Skill Hub, then run the demo agent against it.""" + from agent.agent import create_agent + + data_dir = EXAMPLE_DIR / "data" + skills_dir = data_dir / "skills" + + # Remove any skill installed by a previous run so this demo always + # re-downloads it via the Skill Hub. + if data_dir.exists(): + shutil.rmtree(data_dir) + skills_dir.mkdir(parents=True, exist_ok=True) + + root_agent = create_agent(skills_dir) + + app_name = "skill_hub_demo" + user_id = "demo_user" + session_id = str(uuid.uuid4()) + session_service = InMemorySessionService() + runner = Runner(app_name=app_name, agent=root_agent, session_service=session_service) + + print(f"🆔 Session ID: {session_id[:8]}...") + print(f"📝 User: {QUERY.strip()}") + print("🤖 Assistant: ", end="", flush=True) + + user_content = Content(parts=[Part.from_text(text=QUERY)]) + async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=user_content): + if not event.content or not event.content.parts: + continue + + if event.partial: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + continue + + for part in event.content.parts: + if part.thought: + continue + if part.function_call: + print(f"\n🔧 [Invoke Tool: {part.function_call.name}({part.function_call.args})]") + elif part.function_response: + print(f"📊 [Tool Result: {part.function_response.response}]") + + print("\n" + "-" * 40) + + install_root = skills_dir / ".downloaded" + print(f"\nSkill files fetched from GitHub via the Skill Hub into {install_root}:") + skill_files = [f for f in sorted(install_root.rglob("*")) if f.is_file()] + if skill_files: + for skill_file in skill_files: + print(f"- {skill_file.relative_to(install_root)}") + else: + print("- ") + + +if __name__ == "__main__": + asyncio.run(run_skill_hub_demo()) diff --git a/tests/skills/hub/__init__.py b/tests/skills/hub/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/skills/hub/test_claude_marketplace.py b/tests/skills/hub/test_claude_marketplace.py new file mode 100644 index 00000000..7b155786 --- /dev/null +++ b/tests/skills/hub/test_claude_marketplace.py @@ -0,0 +1,141 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._claude_marketplace. + +Covers: +- source_id / default marketplaces / custom marketplaces +- search: identifier resolution for "./relative", "owner/repo", and bare source paths +- fetch / inspect: delegate to GitHubSource and relabel the source field +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import ClaudeMarketplaceSource +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub._claude_marketplace import DEFAULT_KNOWN_MARKETPLACES + + +class TestConstruction: + + def test_default_marketplaces(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + assert source._marketplaces == list(DEFAULT_KNOWN_MARKETPLACES) + + def test_custom_marketplaces(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["custom/repo"]) + assert source._marketplaces == ["custom/repo"] + + def test_source_id(self): + assert ClaudeMarketplaceSource(GitHubAuth()).source_id() == "claude-marketplace" + + +class TestSearch: + + def test_resolves_relative_source_path(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "./plugins/docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx") + assert len(results) == 1 + assert results[0].identifier == "anthropics/skills/plugins/docx" + assert results[0].repo == "anthropics/skills" + + def test_resolves_absolute_source_path(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "other/repo/docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx") + assert results[0].identifier == "other/repo/docx" + + def test_resolves_bare_source_path(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx") + assert results[0].identifier == "anthropics/skills/docx" + + def test_filters_non_matching_plugins(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + assert source.search("notfound") == [] + + def test_respects_limit_across_marketplaces(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["repo1", "repo2"]) + plugins = [{"name": "docx", "description": "", "source": "docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx", limit=1) + assert len(results) == 1 + + +class TestFetch: + + def test_delegates_to_github_and_relabels_source(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + bundle = SkillBundle(name="docx", files={"SKILL.md": "body"}, source="github", identifier="anthropics/skills/docx") + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.fetch.return_value = bundle + result = source.fetch("anthropics/skills/docx") + assert result is bundle + assert result.source == "claude-marketplace" + + def test_returns_none_when_github_fetch_fails(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.fetch.return_value = None + assert source.fetch("anthropics/skills/docx") is None + + +class TestInspect: + + def test_delegates_to_github_and_relabels_source(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + meta = SkillMeta(name="docx", description="", source="github", identifier="anthropics/skills/docx") + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.inspect.return_value = meta + result = source.inspect("anthropics/skills/docx") + assert result is meta + assert result.source == "claude-marketplace" + + def test_returns_none_when_github_inspect_fails(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.inspect.return_value = None + assert source.inspect("anthropics/skills/docx") is None + + +class TestFetchMarketplaceIndex: + + def test_parses_plugins_from_marketplace_json(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + resp = MagicMock() + resp.status_code = 200 + resp.text = '{"plugins": [{"name": "docx"}]}' + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.httpx.get", return_value=resp): + plugins = source._fetch_marketplace_index("anthropics/skills") + assert plugins == [{"name": "docx"}] + + def test_returns_empty_on_non_200(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + resp = MagicMock() + resp.status_code = 404 + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.httpx.get", return_value=resp): + assert source._fetch_marketplace_index("anthropics/skills") == [] + + def test_returns_empty_on_invalid_json(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + resp = MagicMock() + resp.status_code = 200 + resp.text = "not json" + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.httpx.get", return_value=resp): + assert source._fetch_marketplace_index("anthropics/skills") == [] diff --git a/tests/skills/hub/test_clawhub.py b/tests/skills/hub/test_clawhub.py new file mode 100644 index 00000000..acadbcd8 --- /dev/null +++ b/tests/skills/hub/test_clawhub.py @@ -0,0 +1,223 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._clawhub. + +Covers: +- source_id +- _normalize_tags: list vs dict-of-version-tags vs other +- _coerce_skill_payload: nested "skill" payload merging +- _search_score: exact / prefix / substring / term-overlap scoring +- _dedupe_results: case-insensitive de-dup by identifier +- inspect: builds SkillMeta from the skill endpoint +- fetch: resolves latest version then downloads a ZIP bundle +- _extract_files: dict-of-files, list-of-file-meta with inline/raw content +""" + +from __future__ import annotations + +import io +import zipfile +from unittest.mock import MagicMock +from unittest.mock import patch + +import httpx +import pytest + +from trpc_agent_sdk.skills.hub import ClawHubSource +from trpc_agent_sdk.skills.hub import SkillMeta + + +def _resp(status_code=200, json_data=None, content=b""): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = json_data + resp.content = content + resp.headers = {} + return resp + + +class TestSourceId: + + def test_source_id(self): + assert ClawHubSource().source_id() == "clawhub" + + +class TestNormalizeTags: + + def test_list_tags(self): + assert ClawHubSource._normalize_tags(["a", "b"]) == ["a", "b"] + + def test_dict_tags_excludes_latest(self): + assert set(ClawHubSource._normalize_tags({"latest": "1.0", "1.0": "1.0"})) == {"1.0"} + + def test_other_type_returns_empty(self): + assert ClawHubSource._normalize_tags(None) == [] + + +class TestCoerceSkillPayload: + + def test_merges_nested_skill_payload(self): + data = {"skill": {"name": "notion"}, "latestVersion": "1.2.0"} + result = ClawHubSource._coerce_skill_payload(data) + assert result == {"name": "notion", "latestVersion": "1.2.0"} + + def test_returns_dict_as_is_when_no_nested_skill(self): + data = {"name": "notion"} + assert ClawHubSource._coerce_skill_payload(data) == data + + def test_non_dict_returns_none(self): + assert ClawHubSource._coerce_skill_payload(["not", "a", "dict"]) is None + + +class TestSearchScore: + + def test_exact_identifier_match_scores_highest(self): + exact = SkillMeta(name="notion", description="", source="clawhub", identifier="notion") + prefix = SkillMeta(name="notion-helper", description="", source="clawhub", identifier="notion-helper") + exact_score = ClawHubSource._search_score("notion", exact) + prefix_score = ClawHubSource._search_score("notion", prefix) + assert exact_score > prefix_score > 0 + + def test_unrelated_substring_scores_lower_than_prefix_match(self): + prefix = SkillMeta(name="notion-helper", description="", source="clawhub", identifier="notion-helper") + substring = SkillMeta(name="pro-notion-x", description="", source="clawhub", identifier="pro-notion-x") + prefix_score = ClawHubSource._search_score("notion", prefix) + substring_score = ClawHubSource._search_score("notion", substring) + assert prefix_score > substring_score > 0 + + def test_no_match_scores_zero(self): + meta = SkillMeta(name="docx", description="word docs", source="clawhub", identifier="docx") + assert ClawHubSource._search_score("notion", meta) == 0 + + def test_empty_query_scores_one(self): + meta = SkillMeta(name="docx", description="", source="clawhub", identifier="docx") + assert ClawHubSource._search_score("", meta) == 1 + + +class TestDedupeResults: + + def test_dedupes_case_insensitively_by_identifier(self): + a = SkillMeta(name="Notion", description="", source="clawhub", identifier="notion") + b = SkillMeta(name="notion again", description="", source="clawhub", identifier="Notion") + assert len(ClawHubSource._dedupe_results([a, b])) == 1 + + +class TestInspect: + + def test_builds_meta(self): + source = ClawHubSource() + data = {"displayName": "Notion", "summary": "Notion integration", "slug": "notion", "tags": ["productivity"]} + with patch.object(source, "_get_json", return_value=data): + meta = source.inspect("notion") + assert meta.name == "Notion" + assert meta.description == "Notion integration" + assert meta.source == "clawhub" + assert meta.identifier == "notion" + assert meta.tags == ["productivity"] + + def test_returns_none_when_no_data(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value=None): + assert source.inspect("notion") is None + + +class TestFetch: + + def test_returns_none_when_skill_data_missing(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value=None): + assert source.fetch("notion") is None + + def test_returns_none_when_version_unresolvable(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value={"slug": "notion"}), \ + patch.object(source, "_resolve_latest_version", return_value=None): + assert source.fetch("notion") is None + + def test_downloads_zip_bundle(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value={"slug": "notion"}), \ + patch.object(source, "_resolve_latest_version", return_value="1.0.0"), \ + patch.object(source, "_download_zip", return_value={"SKILL.md": "body"}): + bundle = source.fetch("notion") + assert bundle is not None + assert bundle.name == "notion" + assert bundle.files == {"SKILL.md": "body"} + assert bundle.source == "clawhub" + + def test_falls_back_to_version_metadata_when_zip_incomplete(self): + source = ClawHubSource() + version_data = {"files": {"SKILL.md": "from version metadata"}} + with patch.object(source, "_get_json", side_effect=[{"slug": "notion"}, version_data]), \ + patch.object(source, "_resolve_latest_version", return_value="1.0.0"), \ + patch.object(source, "_download_zip", return_value={}): + bundle = source.fetch("notion") + assert bundle is not None + assert bundle.files == {"SKILL.md": "from version metadata"} + + def test_returns_none_when_no_skill_md_anywhere(self): + source = ClawHubSource() + with patch.object(source, "_get_json", side_effect=[{"slug": "notion"}, {"files": {}}]), \ + patch.object(source, "_resolve_latest_version", return_value="1.0.0"), \ + patch.object(source, "_download_zip", return_value={}): + assert source.fetch("notion") is None + + +class TestExtractFiles: + + def test_dict_file_list(self): + source = ClawHubSource() + result = source._extract_files({"files": {"SKILL.md": "body", "bad": 1}}) + assert result == {"SKILL.md": "body"} + + def test_list_file_meta_with_inline_content(self): + source = ClawHubSource() + result = source._extract_files({"files": [{"path": "SKILL.md", "content": "body"}]}) + assert result == {"SKILL.md": "body"} + + def test_list_file_meta_with_raw_url(self): + source = ClawHubSource() + with patch.object(source, "_fetch_text", return_value="fetched body"): + result = source._extract_files({"files": [{"name": "SKILL.md", "rawUrl": "https://example.com/SKILL.md"}]}) + assert result == {"SKILL.md": "fetched body"} + + def test_no_files_key_returns_empty(self): + source = ClawHubSource() + assert source._extract_files({}) == {} + + +class TestDownloadZip: + + def _make_zip_bytes(self, files: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return buf.getvalue() + + def test_extracts_text_files_from_zip(self): + source = ClawHubSource() + zip_bytes = self._make_zip_bytes({"SKILL.md": b"body", "scripts/run.sh": b"echo hi"}) + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(content=zip_bytes)): + files = source._download_zip("notion", "1.0.0") + assert files == {"SKILL.md": "body", "scripts/run.sh": "echo hi"} + + def test_rejects_unsafe_zip_member_paths(self): + source = ClawHubSource() + zip_bytes = self._make_zip_bytes({"SKILL.md": b"body", "../../evil.sh": b"rm -rf /"}) + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(content=zip_bytes)): + files = source._download_zip("notion", "1.0.0") + assert files == {"SKILL.md": "body"} + + def test_returns_empty_on_non_200(self): + source = ClawHubSource() + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(status_code=404)): + assert source._download_zip("notion", "1.0.0") == {} + + def test_returns_empty_on_bad_zip(self): + source = ClawHubSource() + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(content=b"not a zip")): + assert source._download_zip("notion", "1.0.0") == {} diff --git a/tests/skills/hub/test_github.py b/tests/skills/hub/test_github.py new file mode 100644 index 00000000..9de86643 --- /dev/null +++ b/tests/skills/hub/test_github.py @@ -0,0 +1,340 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._github. + +Covers: +- GitHubAuth: headers with/without a token +- GitHubSource.source_id / is_rate_limited +- _parse_frontmatter_quick: valid, missing, and malformed YAML frontmatter +- inspect: builds SkillMeta from SKILL.md frontmatter (incl. hermes tags override) +- fetch: builds SkillBundle from a directory tree, rejects dirs without SKILL.md +- search: filters taps by query, dedupes by name, respects limit +- rate limit detection on a 403 response with exhausted quota +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import httpx +import pytest + +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import GitHubSource + + +def _resp(status_code=200, json_data=None, text="", headers=None): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = json_data + resp.text = text + resp.headers = headers or {} + return resp + + +class TestGitHubAuth: + + def test_no_token_unauthenticated(self): + auth = GitHubAuth() + assert auth.is_authenticated() is False + headers = auth.get_headers() + assert headers["Accept"] == "application/vnd.github.v3+json" + assert "Authorization" not in headers + + def test_with_token_authenticated(self): + auth = GitHubAuth("secret-pat") + assert auth.is_authenticated() is True + headers = auth.get_headers() + assert headers["Authorization"] == "token secret-pat" + + def test_empty_token_is_unauthenticated(self): + auth = GitHubAuth("") + assert auth.is_authenticated() is False + assert "Authorization" not in auth.get_headers() + + +class TestSourceId: + + def test_source_id(self): + assert GitHubSource(GitHubAuth()).source_id() == "github" + + def test_not_rate_limited_initially(self): + assert GitHubSource(GitHubAuth()).is_rate_limited is False + + +class TestParseFrontmatterQuick: + + def test_valid_frontmatter(self): + content = "---\nname: plan\ndescription: A planning skill\n---\nBody text" + fm = GitHubSource._parse_frontmatter_quick(content) + assert fm == {"name": "plan", "description": "A planning skill"} + + def test_no_frontmatter_delimiter(self): + assert GitHubSource._parse_frontmatter_quick("just a body") == {} + + def test_unterminated_frontmatter(self): + assert GitHubSource._parse_frontmatter_quick("---\nname: plan\nno closing delimiter") == {} + + def test_malformed_yaml_returns_empty(self): + content = "---\nname: [unterminated\n---\nbody" + assert GitHubSource._parse_frontmatter_quick(content) == {} + + def test_non_dict_yaml_returns_empty(self): + content = "---\n- a\n- b\n---\nbody" + assert GitHubSource._parse_frontmatter_quick(content) == {} + + +class TestInspect: + + def test_returns_none_for_malformed_identifier(self): + source = GitHubSource(GitHubAuth()) + assert source.inspect("owner-only") is None + + def test_builds_meta_from_frontmatter(self): + source = GitHubSource(GitHubAuth()) + content = "---\nname: plan\ndescription: Plan things\ntags: [a, b]\n---\nBody" + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta is not None + assert meta.name == "plan" + assert meta.description == "Plan things" + assert meta.source == "github" + assert meta.identifier == "owner/repo/skills/plan" + assert meta.repo == "owner/repo" + assert meta.path == "skills/plan" + assert meta.tags == ["a", "b"] + + def test_hermes_metadata_tags_take_priority(self): + source = GitHubSource(GitHubAuth()) + content = ( + "---\n" + "name: plan\n" + "description: Plan things\n" + "tags: [fallback]\n" + "metadata:\n" + " hermes:\n" + " tags: [priority]\n" + "---\n" + "Body" + ) + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta.tags == ["priority"] + + def test_empty_hermes_tags_list_falls_back_to_raw_tags(self): + # An empty (but present) `metadata.hermes.tags: []` must not shadow a + # populated top-level `tags:` list. + source = GitHubSource(GitHubAuth()) + content = ( + "---\n" + "name: plan\n" + "description: Plan things\n" + "tags: [a, b]\n" + "metadata:\n" + " hermes:\n" + " tags: []\n" + "---\n" + "Body" + ) + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta.tags == ["a", "b"] + + def test_returns_none_when_content_missing(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_fetch_file_content", return_value=None): + assert source.inspect("owner/repo/plan") is None + + def test_falls_back_to_dir_name_when_frontmatter_lacks_name(self): + source = GitHubSource(GitHubAuth()) + content = "---\ndescription: no name field\n---\nBody" + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta.name == "plan" + + +class TestFetch: + + def test_returns_none_for_malformed_identifier(self): + source = GitHubSource(GitHubAuth()) + assert source.fetch("owner-only") is None + + def test_returns_none_when_no_skill_md(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory", return_value={"other.txt": "x"}): + assert source.fetch("owner/repo/skills/plan") is None + + def test_returns_none_when_directory_empty(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory", return_value={}): + assert source.fetch("owner/repo/skills/plan") is None + + def test_builds_bundle_from_downloaded_files(self): + source = GitHubSource(GitHubAuth()) + files = {"SKILL.md": "---\nname: plan\n---\nbody", "scripts/run.sh": "echo hi"} + with patch.object(source, "_download_directory", return_value=files): + bundle = source.fetch("owner/repo/skills/plan") + assert bundle is not None + assert bundle.name == "plan" + assert bundle.files == files + assert bundle.source == "github" + assert bundle.identifier == "owner/repo/skills/plan" + + +class TestSearch: + + def test_no_taps_returns_empty(self): + source = GitHubSource(GitHubAuth()) + assert source.search("anything") == [] + + def test_filters_by_query_across_taps(self): + source = GitHubSource(GitHubAuth(), taps=[{"repo": "owner/repo", "path": "skills/"}]) + + def fake_list_skills_in_repo(repo, path): + from trpc_agent_sdk.skills.hub import SkillMeta + return [ + SkillMeta(name="plan", description="planning skill", source="github", identifier="owner/repo/skills/plan"), + SkillMeta(name="docx", description="word docs", source="github", identifier="owner/repo/skills/docx"), + ] + + with patch.object(source, "_list_skills_in_repo", side_effect=fake_list_skills_in_repo): + results = source.search("plan") + assert len(results) == 1 + assert results[0].name == "plan" + + def test_dedupes_by_name_and_respects_limit(self): + source = GitHubSource( + GitHubAuth(), + taps=[{"repo": "owner/repo1"}, {"repo": "owner/repo2"}], + ) + + def fake_list_skills_in_repo(repo, path): + from trpc_agent_sdk.skills.hub import SkillMeta + return [SkillMeta(name="plan", description="", source="github", identifier=f"{repo}/plan")] + + with patch.object(source, "_list_skills_in_repo", side_effect=fake_list_skills_in_repo): + results = source.search("", limit=10) + assert len(results) == 1 + + def test_tap_error_is_skipped(self): + source = GitHubSource(GitHubAuth(), taps=[{"repo": "owner/repo"}]) + with patch.object(source, "_list_skills_in_repo", side_effect=RuntimeError("boom")): + assert source.search("anything") == [] + + +class TestRateLimit: + + def test_flags_rate_limited_on_403_with_exhausted_quota(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=403, headers={"X-RateLimit-Remaining": "0"}) + source._check_rate_limit_response(resp) + assert source.is_rate_limited is True + + def test_403_with_remaining_quota_is_not_rate_limited(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=403, headers={"X-RateLimit-Remaining": "10"}) + source._check_rate_limit_response(resp) + assert source.is_rate_limited is False + + def test_non_403_does_not_flag_rate_limit(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=404) + source._check_rate_limit_response(resp) + assert source.is_rate_limited is False + + def test_fetch_file_content_flags_rate_limit_via_httpx_get(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=403, headers={"X-RateLimit-Remaining": "0"}) + with patch("trpc_agent_sdk.skills.hub._github.httpx.get", return_value=resp): + content = source._fetch_file_content("owner/repo", "SKILL.md") + assert content is None + assert source.is_rate_limited is True + + +class TestFetchFileContent: + + def test_returns_text_on_200(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=200, text="file contents") + with patch("trpc_agent_sdk.skills.hub._github.httpx.get", return_value=resp): + assert source._fetch_file_content("owner/repo", "SKILL.md") == "file contents" + + def test_returns_none_on_http_error(self): + source = GitHubSource(GitHubAuth()) + with patch( + "trpc_agent_sdk.skills.hub._github.httpx.get", + side_effect=httpx.ConnectError("boom"), + ): + assert source._fetch_file_content("owner/repo", "SKILL.md") is None + + +class TestDownloadDirectory: + + def test_falls_back_to_contents_api_when_tree_unavailable(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory_via_tree", return_value=None), \ + patch.object(source, "_download_directory_recursive", return_value={"SKILL.md": "body"}) as recursive_mock: + files = source._download_directory("owner/repo", "skills/plan") + recursive_mock.assert_called_once_with("owner/repo", "skills/plan") + assert files == {"SKILL.md": "body"} + + def test_uses_tree_result_when_available(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory_via_tree", return_value={"SKILL.md": "body"}), \ + patch.object(source, "_download_directory_recursive") as recursive_mock: + files = source._download_directory("owner/repo", "skills/plan") + recursive_mock.assert_not_called() + assert files == {"SKILL.md": "body"} + + def test_download_directory_via_tree_path_not_found_returns_empty_dict(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=("main", [{"type": "blob", "path": "other/file.txt"}])): + result = source._download_directory_via_tree("owner/repo", "skills/plan") + assert result == {} + + def test_download_directory_via_tree_none_when_tree_unavailable(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=None): + assert source._download_directory_via_tree("owner/repo", "skills/plan") is None + + def test_download_directory_via_tree_fetches_matching_blobs(self): + source = GitHubSource(GitHubAuth()) + tree_entries = [ + {"type": "blob", "path": "skills/plan/SKILL.md"}, + {"type": "blob", "path": "skills/plan/scripts/run.sh"}, + {"type": "tree", "path": "skills/plan/scripts"}, + {"type": "blob", "path": "skills/other/SKILL.md"}, + ] + with patch.object(source, "_get_repo_tree", return_value=("main", tree_entries)), \ + patch.object(source, "_fetch_file_content", side_effect=lambda repo, path: f"content:{path}"): + files = source._download_directory_via_tree("owner/repo", "skills/plan") + assert files == { + "SKILL.md": "content:skills/plan/SKILL.md", + "scripts/run.sh": "content:skills/plan/scripts/run.sh", + } + + +class TestFindSkillInRepoTree: + + def test_finds_skill_dir_by_suffix_match(self): + source = GitHubSource(GitHubAuth()) + tree_entries = [ + {"type": "blob", "path": "components/skills/dev/plan/SKILL.md"}, + ] + with patch.object(source, "_get_repo_tree", return_value=("main", tree_entries)): + result = source._find_skill_in_repo_tree("owner/repo", "plan") + assert result == "owner/repo/components/skills/dev/plan" + + def test_returns_none_when_not_found(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=("main", [])): + assert source._find_skill_in_repo_tree("owner/repo", "plan") is None + + def test_returns_none_when_tree_unavailable(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=None): + assert source._find_skill_in_repo_tree("owner/repo", "plan") is None diff --git a/tests/skills/hub/test_hermes_index.py b/tests/skills/hub/test_hermes_index.py new file mode 100644 index 00000000..9520a6c9 --- /dev/null +++ b/tests/skills/hub/test_hermes_index.py @@ -0,0 +1,219 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._hermes_index. + +Covers: +- source_id / is_available +- search: empty query returns featured, non-empty filters by text, respects limit +- inspect / _find_entry: exact match, prefix-normalized match, no match +- fetch: resolved_github_id delegation, repo+path fallback delegation, no-match +- index loading is cached across calls (single fetch) +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub._hermes_index import HermesIndexSource + + +_INDEX = { + "skills": [ + { + "identifier": "owner/repo/skills/plan", + "name": "plan", + "description": "Plan things", + "tags": ["planning"], + "source": "github", + "repo": "owner/repo", + "path": "skills/plan", + }, + { + "identifier": "skills-sh/owner2/repo2/docx", + "name": "docx", + "description": "Word docs", + "tags": [], + "source": "skills.sh", + "repo": "owner2/repo2", + "path": "docx", + }, + ] +} + + +class TestSourceId: + + def test_source_id(self): + assert HermesIndexSource(GitHubAuth()).source_id() == "hermes-index" + + +class TestIsAvailable: + + def test_unavailable_when_index_load_fails(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=None): + assert source.is_available is False + + def test_available_when_index_has_skills(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + assert source.is_available is True + + def test_index_is_loaded_only_once(self): + source = HermesIndexSource(GitHubAuth()) + with patch( + "trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX + ) as load_mock: + assert source.is_available is True + assert source.is_available is True + load_mock.assert_called_once() + + +class TestSearch: + + def test_empty_query_returns_featured_up_to_limit(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + results = source.search("", limit=1) + assert len(results) == 1 + assert results[0].name == "plan" + + def test_filters_by_text(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + results = source.search("docx") + assert len(results) == 1 + assert results[0].name == "docx" + + def test_no_skills_in_index_returns_empty(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value={"skills": []}): + assert source.search("anything") == [] + + def test_unavailable_index_returns_empty(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=None): + assert source.search("anything") == [] + + +class TestFindEntryAndInspect: + + def test_exact_identifier_match(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + meta = source.inspect("owner/repo/skills/plan") + assert meta is not None + assert meta.name == "plan" + + def test_prefix_normalized_match(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + meta = source.inspect("owner2/repo2/docx") + assert meta is not None + assert meta.name == "docx" + + def test_no_match_returns_none(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + assert source.inspect("nonexistent") is None + + +class TestFetch: + + def test_uses_resolved_github_id_when_present(self): + source = HermesIndexSource(GitHubAuth()) + index = { + "skills": [ + { + "identifier": "owner/repo/skills/plan", + "name": "plan", + "resolved_github_id": "owner/repo/actual/path/plan", + "source": "github", + } + ] + } + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="resolved") + fake_github = MagicMock() + fake_github.fetch.return_value = bundle + + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=index), \ + patch.object(source, "_get_github", return_value=fake_github): + result = source.fetch("owner/repo/skills/plan") + + fake_github.fetch.assert_called_once_with("owner/repo/actual/path/plan") + assert result is bundle + assert result.identifier == "owner/repo/skills/plan" + + def test_falls_back_to_repo_and_path(self): + source = HermesIndexSource(GitHubAuth()) + index = { + "skills": [ + { + "identifier": "owner/repo/skills/plan", + "name": "plan", + "repo": "owner/repo", + "path": "skills/plan", + "source": "github", + } + ] + } + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="x") + fake_github = MagicMock() + fake_github.fetch.return_value = bundle + + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=index), \ + patch.object(source, "_get_github", return_value=fake_github): + result = source.fetch("owner/repo/skills/plan") + + fake_github.fetch.assert_called_once_with("owner/repo/skills/plan") + assert result is bundle + + def test_no_entry_returns_none(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + assert source.fetch("nonexistent") is None + + def test_github_fetch_failure_returns_none(self): + source = HermesIndexSource(GitHubAuth()) + fake_github = MagicMock() + fake_github.fetch.return_value = None + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX), \ + patch.object(source, "_get_github", return_value=fake_github): + assert source.fetch("owner/repo/skills/plan") is None + + +class TestLoadHermesIndex: + + def test_load_returns_none_on_non_200(self): + from trpc_agent_sdk.skills.hub._hermes_index import _load_hermes_index + + resp = MagicMock() + resp.status_code = 404 + with patch("trpc_agent_sdk.skills.hub._hermes_index.httpx.get", return_value=resp): + assert _load_hermes_index() is None + + def test_load_returns_data_on_200(self): + from trpc_agent_sdk.skills.hub._hermes_index import _load_hermes_index + + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = _INDEX + with patch("trpc_agent_sdk.skills.hub._hermes_index.httpx.get", return_value=resp): + assert _load_hermes_index() == _INDEX + + def test_load_returns_none_when_skills_key_missing(self): + from trpc_agent_sdk.skills.hub._hermes_index import _load_hermes_index + + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"no_skills_key": True} + with patch("trpc_agent_sdk.skills.hub._hermes_index.httpx.get", return_value=resp): + assert _load_hermes_index() is None diff --git a/tests/skills/hub/test_install.py b/tests/skills/hub/test_install.py new file mode 100644 index 00000000..6cf974b0 --- /dev/null +++ b/tests/skills/hub/test_install.py @@ -0,0 +1,294 @@ +"""Tests for Skills Hub remote-skill installation helpers.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.hub import SkillSpec +from trpc_agent_sdk.skills.hub import SkillSpecsConfig +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillSource +from trpc_agent_sdk.skills.hub import sync_remote_skills +from trpc_agent_sdk.skills.hub._install import _fetch_remote_skill +from trpc_agent_sdk.skills.hub._install import _find_existing_skill_dirs +from trpc_agent_sdk.skills.hub._install import _write_bundle_files + + +class FakeSource(SkillSource): + """Minimal in-memory SkillSource for install tests.""" + + def __init__(self, bundle: SkillBundle | None = None, *, raise_on_fetch: bool = False): + self.bundle = bundle + self.raise_on_fetch = raise_on_fetch + self.fetch_calls: list[str] = [] + + def source_id(self) -> str: + return "fake" + + def search(self, query: str, limit: int = 10) -> list: + return [] + + def inspect(self, identifier: str): + return None + + def fetch(self, identifier: str) -> SkillBundle | None: + self.fetch_calls.append(identifier) + if self.raise_on_fetch: + raise RuntimeError("network boom") + return self.bundle + + +def _bundle(name: str = "plan", **metadata) -> SkillBundle: + return SkillBundle( + name=name, + files={ + "SKILL.md": f"---\nname: {name}\ndescription: test skill\n---\nbody", + "scripts/run.sh": "echo hi", + }, + source="fake", + identifier="whatever", + metadata=metadata, + ) + + +class TestRemoteSkillValidation: + + def test_valid_name_is_accepted(self): + remote_skill = SkillSpec(source=FakeSource(), identifier="id", name="plan") + assert remote_skill.name == "plan" + + def test_unsafe_name_raises(self): + with pytest.raises(ValueError): + SkillSpec(source=FakeSource(), identifier="id", name="../escape") + + def test_nested_name_raises(self): + with pytest.raises(ValueError): + SkillSpec(source=FakeSource(), identifier="id", name="a/b") + + def test_defaults(self): + remote_skill = SkillSpec(source=FakeSource(), identifier="id", name="plan") + assert remote_skill.category is None + assert remote_skill.replace_if_exists is False + assert remote_skill.on_error == "skip" + + +class TestFindExistingSkillDirs: + + def test_missing_skills_path_returns_empty(self, tmp_path: Path): + assert _find_existing_skill_dirs(tmp_path / "nonexistent", "plan") == [] + + def test_finds_across_multiple_categories(self, tmp_path: Path): + dir1 = tmp_path / "hub" / "plan" + dir2 = tmp_path / "dev" / "plan" + dir1.mkdir(parents=True) + dir2.mkdir(parents=True) + + found = _find_existing_skill_dirs(tmp_path, "plan") + + assert sorted(found) == sorted([dir1, dir2]) + + def test_ignores_hidden_category_dirs(self, tmp_path: Path): + (tmp_path / ".tmp" / "plan").mkdir(parents=True) + assert _find_existing_skill_dirs(tmp_path, "plan") == [] + + +class TestWriteBundleFiles: + + def test_writes_text_and_bytes(self, tmp_path: Path): + _write_bundle_files( + skills_path=tmp_path, + category="hub", + name="plan", + files={"SKILL.md": "body", "assets/logo.png": b"\x89PNG"}, + ) + + target = tmp_path / "hub" / "plan" + assert (target / "SKILL.md").read_text() == "body" + assert (target / "assets" / "logo.png").read_bytes() == b"\x89PNG" + + def test_overwrites_existing_target_dir(self, tmp_path: Path): + target = tmp_path / "hub" / "plan" + target.mkdir(parents=True) + (target / "stale.txt").write_text("old") + + _write_bundle_files(skills_path=tmp_path, category="hub", name="plan", files={"SKILL.md": "new"}) + + assert not (target / "stale.txt").exists() + assert (target / "SKILL.md").read_text() == "new" + + def test_rejects_unsafe_category(self, tmp_path: Path): + with pytest.raises(ValueError): + _write_bundle_files(skills_path=tmp_path, category="../escape", name="plan", files={"SKILL.md": "x"}) + + def test_rejects_unsafe_bundle_path_and_cleans_up(self, tmp_path: Path): + with pytest.raises(ValueError): + _write_bundle_files( + skills_path=tmp_path, + category="hub", + name="plan", + files={"../escape.txt": "x"}, + ) + assert not (tmp_path / "hub" / "plan").exists() + assert not (tmp_path / ".tmp").exists() or not any((tmp_path / ".tmp").iterdir()) + + +class TestFetchRemoteSkill: + + def test_skips_when_already_installed(self, tmp_path: Path): + (tmp_path / "hub" / "plan").mkdir(parents=True) + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert source.fetch_calls == [] + + def test_fetches_and_writes_when_missing(self, tmp_path: Path): + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="fetch-me", name="plan", category="hub") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert source.fetch_calls == ["fetch-me"] + assert (tmp_path / "hub" / "plan" / "SKILL.md").exists() + + def test_raises_when_fetch_returns_none(self, tmp_path: Path): + source = FakeSource(bundle=None) + remote_skill = SkillSpec(source=source, identifier="missing", name="plan") + + with pytest.raises(ValueError, match="could not fetch"): + _fetch_remote_skill(remote_skill, tmp_path) + + def test_replace_if_exists_refetches_and_overwrites(self, tmp_path: Path): + existing = tmp_path / "hub" / "plan" + existing.mkdir(parents=True) + (existing / "OLD.md").write_text("old") + + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="id", name="plan", category="hub", replace_if_exists=True) + + _fetch_remote_skill(remote_skill, tmp_path) + + assert source.fetch_calls == ["id"] + assert not (existing / "OLD.md").exists() + assert (existing / "SKILL.md").exists() + + def test_category_resolution(self, tmp_path: Path): + source = FakeSource(bundle=_bundle(category="dev")) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert (tmp_path / "dev" / "plan" / "SKILL.md").exists() + + def test_explicit_category_wins_over_bundle_metadata(self, tmp_path: Path): + source = FakeSource(bundle=_bundle(category="dev")) + remote_skill = SkillSpec(source=source, identifier="id", name="plan", category="custom") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert (tmp_path / "custom" / "plan" / "SKILL.md").exists() + assert not (tmp_path / "dev").exists() + + +class TestSyncRemoteSkills: + + def test_empty_remote_skills_is_noop(self, tmp_path: Path): + target = tmp_path / "skills" + sync_remote_skills([], target) + assert not target.exists() + + def test_creates_install_root(self, tmp_path: Path): + target = tmp_path / "skills" + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + sync_remote_skills([remote_skill], target) + + assert target.is_dir() + assert (target / "hub" / "plan" / "SKILL.md").exists() + + def test_on_error_skip_continues_to_next_remote_skill(self, tmp_path: Path): + failing = FakeSource(bundle=None) + succeeding = FakeSource(bundle=_bundle()) + remote_skills = [ + SkillSpec(source=failing, identifier="fails", name="broken", on_error="skip"), + SkillSpec(source=succeeding, identifier="works", name="plan", on_error="skip"), + ] + + sync_remote_skills(remote_skills, tmp_path) + + assert not (tmp_path / "hub" / "broken").exists() + assert (tmp_path / "hub" / "plan" / "SKILL.md").exists() + + def test_on_error_raise_propagates_and_stops(self, tmp_path: Path): + failing = FakeSource(bundle=None) + succeeding = FakeSource(bundle=_bundle()) + remote_skills = [ + SkillSpec(source=failing, identifier="fails", name="broken", on_error="raise"), + SkillSpec(source=succeeding, identifier="works", name="plan"), + ] + + with pytest.raises(ValueError, match="could not fetch"): + sync_remote_skills(remote_skills, tmp_path) + + assert succeeding.fetch_calls == [] + + def test_fetch_exception_is_skipped_by_default(self, tmp_path: Path): + source = FakeSource(raise_on_fetch=True) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + sync_remote_skills([remote_skill], tmp_path) + + assert not (tmp_path / "hub" / "plan").exists() + + +class TestCreateDefaultSkillRepositoryRemoteSkills: + + def test_additional_skill_specs_are_installed_and_indexed(self, tmp_path: Path): + install_root = tmp_path / "downloaded" + source = FakeSource(bundle=_bundle(name="plan")) + + repository = create_default_skill_repository( + additional_skill_specs=SkillSpecsConfig( + specs=[SkillSpec(source=source, identifier="id", name="plan")], + install_path=str(install_root), + ), + use_cached_repository=False, + ) + + assert source.fetch_calls == ["id"] + assert repository.skill_list() == ["plan"] + assert repository.path("plan") == str(install_root / "hub" / "plan") + + def test_install_path_defaults_to_system_temp_dir(self): + config = SkillSpecsConfig( + specs=[SkillSpec(source=FakeSource(bundle=_bundle()), identifier="id", name="plan")], + ) + + assert config.install_path == str(Path(tempfile.gettempdir()) / "trpc_agent_skills") + + def test_local_roots_precede_remote_install_root(self, tmp_path: Path): + local_root = tmp_path / "local" + local_skill = local_root / "local-category" / "plan" + local_skill.mkdir(parents=True) + (local_skill / "SKILL.md").write_text("---\nname: plan\ndescription: local\n---\nlocal body") + + install_root = tmp_path / "downloaded" + source = FakeSource(bundle=_bundle(name="plan")) + + repository = create_default_skill_repository( + str(local_root), + additional_skill_specs=SkillSpecsConfig( + specs=[SkillSpec(source=source, identifier="id", name="plan")], + install_path=str(install_root), + ), + use_cached_repository=False, + ) + + assert repository.path("plan") == str(local_skill) + assert (install_root / "hub" / "plan" / "SKILL.md").exists() diff --git a/tests/skills/hub/test_lobehub.py b/tests/skills/hub/test_lobehub.py new file mode 100644 index 00000000..635a4c88 --- /dev/null +++ b/tests/skills/hub/test_lobehub.py @@ -0,0 +1,134 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._lobehub. + +Covers: +- source_id +- search: filters agents by query text, respects the "lobehub/" identifier prefix +- inspect: exact identifier match against the index +- fetch: converts the agent JSON into a synthetic SKILL.md bundle +- _convert_to_skill_md: frontmatter + body shape +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import LobeHubSource + + +_INDEX = { + "agents": [ + { + "identifier": "writer-bot", + "meta": {"title": "Writer Bot", "description": "Helps you write.", "tags": ["writing"]}, + }, + { + "identifier": "coder-bot", + "meta": {"title": "Coder Bot", "description": "Helps you code.", "tags": ["coding"]}, + }, + ] +} + + +class TestSourceId: + + def test_source_id(self): + assert LobeHubSource().source_id() == "lobehub" + + +class TestSearch: + + def test_filters_by_query(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + results = source.search("write") + assert len(results) == 1 + assert results[0].identifier == "lobehub/writer-bot" + + def test_index_unavailable_returns_empty(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=None): + assert source.search("anything") == [] + + def test_respects_limit(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + results = source.search("bot", limit=1) + assert len(results) == 1 + + +class TestInspect: + + def test_finds_exact_identifier(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + meta = source.inspect("lobehub/writer-bot") + assert meta is not None + assert meta.identifier == "lobehub/writer-bot" + assert meta.description == "Helps you write." + + def test_strips_lobehub_prefix(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + meta = source.inspect("writer-bot") + assert meta is not None + + def test_returns_none_when_not_found(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + assert source.inspect("nonexistent") is None + + def test_returns_none_when_index_unavailable(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=None): + assert source.inspect("writer-bot") is None + + +class TestFetch: + + def test_builds_bundle_from_agent_json(self): + source = LobeHubSource() + agent_data = { + "identifier": "writer-bot", + "meta": {"title": "Writer Bot", "description": "Helps you write.", "tags": ["writing"]}, + "config": {"systemRole": "You are a writing assistant."}, + } + with patch.object(source, "_fetch_agent", return_value=agent_data): + bundle = source.fetch("lobehub/writer-bot") + assert bundle is not None + assert bundle.name == "writer-bot" + assert bundle.identifier == "lobehub/writer-bot" + assert "SKILL.md" in bundle.files + assert "Writer Bot" in bundle.files["SKILL.md"] + assert "You are a writing assistant." in bundle.files["SKILL.md"] + + def test_returns_none_when_agent_fetch_fails(self): + source = LobeHubSource() + with patch.object(source, "_fetch_agent", return_value=None): + assert source.fetch("lobehub/writer-bot") is None + + +class TestConvertToSkillMd: + + def test_includes_frontmatter_and_body(self): + agent_data = { + "identifier": "writer-bot", + "meta": {"title": "Writer Bot", "description": "Helps you write.", "tags": ["writing", "assistant"]}, + "config": {"systemRole": "Be helpful."}, + } + md = LobeHubSource._convert_to_skill_md(agent_data) + assert md.startswith("---\n") + assert "name: writer-bot" in md + assert "# Writer Bot" in md + assert "Be helpful." in md + + def test_missing_system_role_uses_placeholder(self): + agent_data = {"identifier": "writer-bot", "meta": {"title": "Writer Bot"}} + md = LobeHubSource._convert_to_skill_md(agent_data) + assert "(No system role defined)" in md diff --git a/tests/skills/hub/test_skills_sh.py b/tests/skills/hub/test_skills_sh.py new file mode 100644 index 00000000..f3117de6 --- /dev/null +++ b/tests/skills/hub/test_skills_sh.py @@ -0,0 +1,150 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._skills_sh. + +Covers: +- source_id +- _normalize_identifier: alias-prefix stripping +- _candidate_identifiers: standard skill-path candidates, deduped +- _wrap_identifier +- _token_variants: slug/case/underscore normalization used for fuzzy matching +- fetch: resolves via first matching candidate through the underlying GitHubSource +- inspect: delegates to GitHub inspect via candidates, then discovery fallback +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub._skills_sh import SkillsShSource + + +class TestSourceId: + + def test_source_id(self): + assert SkillsShSource(GitHubAuth()).source_id() == "skills-sh" + + +class TestNormalizeIdentifier: + + @pytest.mark.parametrize( + "raw,expected", + [ + ("skills-sh/owner/repo/plan", "owner/repo/plan"), + ("skills.sh/owner/repo/plan", "owner/repo/plan"), + ("owner/repo/plan", "owner/repo/plan"), + ], + ) + def test_strips_known_prefixes(self, raw, expected): + assert SkillsShSource._normalize_identifier(raw) == expected + + +class TestCandidateIdentifiers: + + def test_generates_standard_paths(self): + candidates = SkillsShSource._candidate_identifiers("owner/repo/plan") + assert candidates == [ + "owner/repo/plan", + "owner/repo/skills/plan", + "owner/repo/.agents/skills/plan", + "owner/repo/.claude/skills/plan", + ] + + def test_short_identifier_returned_as_is(self): + assert SkillsShSource._candidate_identifiers("owner/repo") == ["owner/repo"] + + def test_dedupes_when_skill_path_already_prefixed(self): + candidates = SkillsShSource._candidate_identifiers("owner/repo/skills/plan") + assert candidates.count("owner/repo/skills/plan") == 1 + + +class TestWrapIdentifier: + + def test_wraps_with_prefix(self): + assert SkillsShSource._wrap_identifier("owner/repo/plan") == "skills-sh/owner/repo/plan" + + +class TestTokenVariants: + + def test_generates_case_and_separator_variants(self): + variants = SkillsShSource._token_variants("Skill_Creator") + assert "skill_creator" in variants + assert "skill-creator" in variants + + def test_empty_value_returns_empty_set(self): + assert SkillsShSource._token_variants(None) == set() + assert SkillsShSource._token_variants("") == set() + + def test_strips_html_tags(self): + variants = SkillsShSource._token_variants("plan") + assert "plan" in variants + + +class TestMatchesSkillTokens: + + def test_matches_on_name(self): + meta = SkillMeta(name="plan", description="", source="github", identifier="owner/repo/skills/plan") + assert SkillsShSource._matches_skill_tokens(meta, ["plan"]) is True + + def test_no_match(self): + meta = SkillMeta(name="plan", description="", source="github", identifier="owner/repo/skills/plan") + assert SkillsShSource._matches_skill_tokens(meta, ["docx"]) is False + + +class TestFetch: + + def test_returns_bundle_from_first_matching_candidate(self): + source = SkillsShSource(GitHubAuth()) + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="owner/repo/plan") + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "fetch", return_value=bundle) as fetch_mock: + result = source.fetch("owner/repo/plan") + assert result is bundle + assert result.source == "skills.sh" + assert result.identifier == "skills-sh/owner/repo/plan" + fetch_mock.assert_called_once_with("owner/repo/plan") + + def test_falls_back_to_discovery_when_no_candidate_matches(self): + source = SkillsShSource(GitHubAuth()) + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="owner/repo/other/plan") + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "fetch", side_effect=[None, None, None, None, bundle]), \ + patch.object(source, "_discover_identifier", return_value="owner/repo/other/plan"): + result = source.fetch("owner/repo/plan") + assert result is bundle + + def test_returns_none_when_nothing_resolves(self): + source = SkillsShSource(GitHubAuth()) + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "fetch", return_value=None), \ + patch.object(source, "_discover_identifier", return_value=None): + assert source.fetch("owner/repo/plan") is None + + +class TestInspect: + + def test_returns_meta_from_candidate(self): + source = SkillsShSource(GitHubAuth()) + meta = SkillMeta(name="plan", description="", source="github", identifier="owner/repo/plan") + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "inspect", return_value=meta): + result = source.inspect("owner/repo/plan") + assert result is not None + assert result.source == "skills.sh" + assert result.identifier == "skills-sh/owner/repo/plan" + + def test_returns_none_when_unresolvable(self): + source = SkillsShSource(GitHubAuth()) + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "inspect", return_value=None), \ + patch.object(source, "_discover_identifier", return_value=None): + assert source.inspect("owner/repo/plan") is None diff --git a/tests/skills/hub/test_source.py b/tests/skills/hub/test_source.py new file mode 100644 index 00000000..96d44434 --- /dev/null +++ b/tests/skills/hub/test_source.py @@ -0,0 +1,56 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._source. + +Covers: +- SkillSource cannot be instantiated directly (ABC contract) +- A subclass missing any abstract method also cannot be instantiated +- A subclass implementing all four methods can be instantiated and used +""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub import SkillSource + + +class TestSkillSourceContract: + + def test_cannot_instantiate_abstract_class(self): + with pytest.raises(TypeError): + SkillSource() # type: ignore[abstract] + + def test_subclass_missing_methods_cannot_instantiate(self): + class Incomplete(SkillSource): + def source_id(self) -> str: + return "incomplete" + + with pytest.raises(TypeError): + Incomplete() # type: ignore[abstract] + + def test_complete_subclass_is_instantiable(self): + class Complete(SkillSource): + def source_id(self) -> str: + return "complete" + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + return [] + + def inspect(self, identifier: str) -> SkillMeta | None: + return None + + def fetch(self, identifier: str) -> SkillBundle | None: + return None + + source = Complete() + assert source.source_id() == "complete" + assert source.search("q") == [] + assert source.inspect("id") is None + assert source.fetch("id") is None + assert isinstance(source, SkillSource) diff --git a/tests/skills/hub/test_types.py b/tests/skills/hub/test_types.py new file mode 100644 index 00000000..c910d655 --- /dev/null +++ b/tests/skills/hub/test_types.py @@ -0,0 +1,155 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._types. + +Covers: +- SkillMeta / SkillBundle dataclass defaults +- validate_skill_name / validate_category_name: single-segment-only paths +- validate_bundle_rel_path: nested paths allowed +- Path traversal / absolute-path / Windows-drive / non-string rejection +""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub import validate_bundle_rel_path +from trpc_agent_sdk.skills.hub import validate_category_name +from trpc_agent_sdk.skills.hub import validate_skill_name + + +class TestSkillMeta: + + def test_required_fields(self): + meta = SkillMeta(name="plan", description="desc", source="github", identifier="owner/repo/plan") + assert meta.name == "plan" + assert meta.description == "desc" + assert meta.source == "github" + assert meta.identifier == "owner/repo/plan" + + def test_defaults(self): + meta = SkillMeta(name="plan", description="desc", source="github", identifier="owner/repo/plan") + assert meta.repo is None + assert meta.path is None + assert meta.tags == [] + assert meta.extra == {} + + def test_default_collections_are_independent_per_instance(self): + a = SkillMeta(name="a", description="", source="s", identifier="a") + b = SkillMeta(name="b", description="", source="s", identifier="b") + a.tags.append("x") + a.extra["k"] = "v" + assert b.tags == [] + assert b.extra == {} + + +class TestSkillBundle: + + def test_required_fields(self): + bundle = SkillBundle( + name="plan", + files={"SKILL.md": "---\nname: plan\n---\nbody"}, + source="github", + identifier="owner/repo/plan", + ) + assert bundle.name == "plan" + assert bundle.files["SKILL.md"].startswith("---") + assert bundle.metadata == {} + + def test_files_can_hold_bytes(self): + bundle = SkillBundle( + name="plan", + files={"SKILL.md": "text", "logo.png": b"\x89PNG"}, + source="github", + identifier="id", + ) + assert isinstance(bundle.files["logo.png"], bytes) + + def test_default_metadata_independent_per_instance(self): + a = SkillBundle(name="a", files={}, source="s", identifier="a") + b = SkillBundle(name="b", files={}, source="s", identifier="b") + a.metadata["category"] = "dev" + assert b.metadata == {} + + +class TestValidateSkillName: + + @pytest.mark.parametrize("name", ["plan", "skill-creator", "skill_1", "a"]) + def test_valid_single_segment_names(self, name): + assert validate_skill_name(name) == name + + def test_strips_whitespace(self): + assert validate_skill_name(" plan ") == "plan" + + @pytest.mark.parametrize( + "name", + [ + "", + " ", + "/plan", + "../plan", + "a/b", + "a/../b", + "C:", + ], + ) + def test_rejects_unsafe_names(self, name): + with pytest.raises(ValueError): + validate_skill_name(name) + + def test_rejects_non_string(self): + with pytest.raises(ValueError): + validate_skill_name(123) # type: ignore[arg-type] + + +class TestValidateCategoryName: + + def test_valid_single_segment_category(self): + assert validate_category_name("hub") == "hub" + + def test_rejects_nested_category(self): + with pytest.raises(ValueError): + validate_category_name("hub/sub") + + def test_rejects_traversal(self): + with pytest.raises(ValueError): + validate_category_name("..") + + +class TestValidateBundleRelPath: + + def test_allows_nested_paths(self): + assert validate_bundle_rel_path("scripts/run.sh") == "scripts/run.sh" + + def test_allows_single_segment(self): + assert validate_bundle_rel_path("SKILL.md") == "SKILL.md" + + def test_normalizes_backslashes_to_forward_slashes(self): + assert validate_bundle_rel_path("scripts\\run.sh") == "scripts/run.sh" + + def test_collapses_dot_segments(self): + assert validate_bundle_rel_path("scripts/./run.sh") == "scripts/run.sh" + + @pytest.mark.parametrize( + "rel_path", + [ + "", + " ", + "/etc/passwd", + "../secret", + "scripts/../../secret", + "C:/Windows/System32", + ], + ) + def test_rejects_unsafe_paths(self, rel_path): + with pytest.raises(ValueError): + validate_bundle_rel_path(rel_path) + + def test_rejects_non_string(self): + with pytest.raises(ValueError): + validate_bundle_rel_path(None) # type: ignore[arg-type] diff --git a/tests/skills/hub/test_well_known.py b/tests/skills/hub/test_well_known.py new file mode 100644 index 00000000..706ac5e8 --- /dev/null +++ b/tests/skills/hub/test_well_known.py @@ -0,0 +1,203 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills.hub._well_known. + +Covers: +- source_id +- _query_to_index_url: URL normalization for different query shapes +- _parse_identifier: index.json#fragment, .../SKILL.md, and bare skill URLs +- search / inspect / fetch against a mocked index + skill files +- fetch rejects unsafe skill names / file paths advertised by a malicious index +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub._well_known import WellKnownSkillSource + + +class TestSourceId: + + def test_source_id(self): + assert WellKnownSkillSource().source_id() == "well-known" + + +class TestBasePathNormalization: + + def test_default_base_path(self): + source = WellKnownSkillSource() + assert source._base_path == "/.well-known/skills" + + def test_custom_base_path_gets_leading_slash(self): + source = WellKnownSkillSource("custom/skills") + assert source._base_path == "/custom/skills" + + def test_trailing_slash_stripped(self): + source = WellKnownSkillSource("/custom/skills/") + assert source._base_path == "/custom/skills" + + +class TestQueryToIndexUrl: + + def test_rejects_non_http_query(self): + source = WellKnownSkillSource() + assert source._query_to_index_url("not-a-url") is None + + def test_query_ending_in_index_json_is_passthrough(self): + source = WellKnownSkillSource() + url = "https://example.com/.well-known/skills/index.json" + assert source._query_to_index_url(url) == url + + def test_query_with_base_path_segment(self): + source = WellKnownSkillSource() + url = "https://example.com/.well-known/skills/plan" + assert source._query_to_index_url(url) == "https://example.com/.well-known/skills/index.json" + + def test_bare_domain_appends_default_index_path(self): + source = WellKnownSkillSource() + assert source._query_to_index_url("https://example.com") == ( + "https://example.com/.well-known/skills/index.json" + ) + + +class TestParseIdentifier: + + def test_rejects_non_http_identifier(self): + source = WellKnownSkillSource() + assert source._parse_identifier("not-a-url") is None + + def test_index_json_with_fragment(self): + source = WellKnownSkillSource() + parsed = source._parse_identifier( + "well-known:https://example.com/.well-known/skills/index.json#plan" + ) + assert parsed == { + "index_url": "https://example.com/.well-known/skills/index.json", + "base_url": "https://example.com/.well-known/skills", + "skill_name": "plan", + "skill_url": "https://example.com/.well-known/skills/plan", + } + + def test_index_json_without_fragment_is_rejected(self): + source = WellKnownSkillSource() + assert source._parse_identifier("https://example.com/.well-known/skills/index.json") is None + + def test_skill_md_url(self): + source = WellKnownSkillSource() + parsed = source._parse_identifier("https://example.com/.well-known/skills/plan/SKILL.md") + assert parsed["skill_name"] == "plan" + assert parsed["base_url"] == "https://example.com/.well-known/skills" + + def test_bare_skill_url(self): + source = WellKnownSkillSource() + parsed = source._parse_identifier("https://example.com/.well-known/skills/plan") + assert parsed["skill_name"] == "plan" + + def test_url_missing_base_path_is_rejected(self): + source = WellKnownSkillSource() + assert source._parse_identifier("https://example.com/plan") is None + + +class TestSearch: + + def test_non_url_query_returns_empty(self): + source = WellKnownSkillSource() + assert source.search("plan") == [] + + def test_returns_metas_from_index(self): + source = WellKnownSkillSource() + index = { + "index_url": "https://example.com/.well-known/skills/index.json", + "base_url": "https://example.com/.well-known/skills", + "skills": [{"name": "plan", "description": "Plan things", "files": ["SKILL.md"]}], + } + with patch.object(source, "_parse_index", return_value=index): + results = source.search("https://example.com/.well-known/skills/index.json") + assert len(results) == 1 + assert results[0].name == "plan" + assert results[0].identifier == "well-known:https://example.com/.well-known/skills/plan" + + def test_index_fetch_failure_returns_empty(self): + source = WellKnownSkillSource() + with patch.object(source, "_parse_index", return_value=None): + assert source.search("https://example.com") == [] + + +class TestInspect: + + def test_returns_none_for_unparseable_identifier(self): + source = WellKnownSkillSource() + assert source.inspect("not-a-url") is None + + def test_builds_meta_from_entry_and_skill_md(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "description": "fallback desc", "files": ["SKILL.md"]} + skill_md = "---\nname: plan\ndescription: real desc\n---\nbody" + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", return_value=skill_md): + meta = source.inspect("https://example.com/.well-known/skills/plan") + assert meta.name == "plan" + assert meta.description == "real desc" + assert meta.source == "well-known" + + def test_returns_none_when_entry_missing(self): + source = WellKnownSkillSource() + with patch.object(source, "_index_entry", return_value=None): + assert source.inspect("https://example.com/.well-known/skills/plan") is None + + def test_returns_none_when_skill_md_fetch_fails(self): + source = WellKnownSkillSource() + with patch.object(source, "_index_entry", return_value={"name": "plan"}), \ + patch.object(source, "_fetch_text", return_value=None): + assert source.inspect("https://example.com/.well-known/skills/plan") is None + + +class TestFetch: + + def test_returns_none_for_unparseable_identifier(self): + source = WellKnownSkillSource() + assert source.fetch("not-a-url") is None + + def test_downloads_all_declared_files(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["SKILL.md", "scripts/run.sh"]} + texts = { + "https://example.com/.well-known/skills/plan/SKILL.md": "---\nname: plan\n---\nbody", + "https://example.com/.well-known/skills/plan/scripts/run.sh": "echo hi", + } + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", side_effect=lambda url: texts[url]): + bundle = source.fetch("https://example.com/.well-known/skills/plan") + assert bundle is not None + assert bundle.name == "plan" + assert set(bundle.files) == {"SKILL.md", "scripts/run.sh"} + + def test_rejects_unsafe_file_path_from_index(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["../../etc/passwd"]} + with patch.object(source, "_index_entry", return_value=entry): + assert source.fetch("https://example.com/.well-known/skills/plan") is None + + def test_missing_skill_md_in_downloaded_files_returns_none(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["other.txt"]} + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", return_value="content"): + assert source.fetch("https://example.com/.well-known/skills/plan") is None + + def test_returns_none_when_a_file_fetch_fails(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["SKILL.md", "missing.txt"]} + + def fake_fetch_text(url): + return None if "missing.txt" in url else "---\nname: plan\n---\nbody" + + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", side_effect=fake_fetch_text): + assert source.fetch("https://example.com/.well-known/skills/plan") is None diff --git a/trpc_agent_sdk/skills/__init__.py b/trpc_agent_sdk/skills/__init__.py index 36cfd3f1..dae59ac9 100644 --- a/trpc_agent_sdk/skills/__init__.py +++ b/trpc_agent_sdk/skills/__init__.py @@ -53,6 +53,7 @@ from ._constants import SkillLoadModeNames from ._constants import SkillProfileNames from ._constants import SkillToolsNames +from . import hub from ._dynamic_toolset import DynamicSkillToolSet from ._registry import SkillRegistry from ._repository import BaseSkillRepository @@ -95,6 +96,10 @@ from ._url_root import SkillRootResolver from ._utils import get_state_delta from ._utils import set_state_delta +from .hub import SkillSpec +from .hub import SkillSpecsConfig +from .hub import async_sync_remote_skills +from .hub import sync_remote_skills from .tools import SkillLoadTool from .tools import SkillRunTool from .tools import SkillExecTool @@ -105,6 +110,7 @@ from .tools import skill_select_tools __all__ = [ + "hub", "SelectionMode", "docs_scan_prefix", "docs_state_key", @@ -166,6 +172,8 @@ "touch_loaded_order", "SkillToolSet", "Skill", + "SkillSpec", + "SkillSpecsConfig", "SkillConfig", "SkillFrontMatter", "SkillRequires", @@ -175,6 +183,8 @@ "SkillRootResolver", "get_state_delta", "set_state_delta", + "sync_remote_skills", + "async_sync_remote_skills", "SkillLoadTool", "SkillRunTool", "SkillExecTool", diff --git a/trpc_agent_sdk/skills/_repository.py b/trpc_agent_sdk/skills/_repository.py index 6c1fe053..26e8fe0d 100644 --- a/trpc_agent_sdk/skills/_repository.py +++ b/trpc_agent_sdk/skills/_repository.py @@ -41,6 +41,8 @@ from ._url_root import SkillRootResolver from ._utils import is_doc_file from ._utils import is_script_file +from .hub import SkillSpecsConfig +from .hub import sync_remote_skills BASE_DIR_PLACEHOLDER = "__BASE_DIR__" VisibilityFilter = Callable[[SkillSummary], bool] @@ -685,6 +687,7 @@ def create_default_skill_repository( workspace_runtime: Optional[BaseWorkspaceRuntime] = None, enable_hot_reload: bool = True, use_cached_repository: bool = True, + additional_skill_specs: Optional[SkillSpecsConfig] = None, ) -> BaseSkillRepository: """Create a new filesystem skill repository. @@ -693,20 +696,29 @@ def create_default_skill_repository( workspace_runtime: Optional workspace runtime. enable_hot_reload: Whether to enable skill hot reload checks. use_cached_repository: Whether to use cached repository. + additional_skill_specs: Optional remote skills plus their install path. + The skills are fetched into ``install_path`` before indexing, and + that path is added as an extra scan root. Returns: A configured :class:`FsSkillRepository`. """ + resolved_roots = list[str](roots) + if additional_skill_specs and additional_skill_specs.specs: + install_path = Path(additional_skill_specs.install_path) + sync_remote_skills(additional_skill_specs.specs, install_path) + resolved_roots.append(str(install_path)) + if workspace_runtime is None: workspace_runtime = create_local_workspace_runtime() if use_cached_repository: return CachedFsSkillRepository( - *roots, + *resolved_roots, workspace_runtime=workspace_runtime, enable_hot_reload=enable_hot_reload, ) else: return FsSkillRepository( - *roots, + *resolved_roots, workspace_runtime=workspace_runtime, enable_hot_reload=enable_hot_reload, ) diff --git a/trpc_agent_sdk/skills/hub/__init__.py b/trpc_agent_sdk/skills/hub/__init__.py new file mode 100644 index 00000000..6d104fa3 --- /dev/null +++ b/trpc_agent_sdk/skills/hub/__init__.py @@ -0,0 +1,59 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Skills Hub — source adapters for discovering and fetching agent skills. + +Provides: + + - `SkillSource`: the abstract base class all adapters subclass. + - `SkillMeta` / `SkillBundle`: the data types adapters exchange. + - `SkillSpec`: a declaration for installing a fetched skill locally. + - Path validators (`validate_skill_name`, `validate_category_name`, + `validate_bundle_rel_path`) used when writing fetched bundles to disk. + - `GitHubAuth`: GitHub API authentication via an explicitly injected PAT. + - Seven concrete adapters: `GitHubSource`, `WellKnownSkillSource`, + `HermesIndexSource`, `SkillsShSource`, `ClawHubSource`, + `ClaudeMarketplaceSource`, `LobeHubSource`. +""" + +from ._claude_marketplace import ClaudeMarketplaceSource +from ._clawhub import ClawHubSource +from ._github import GitHubAuth +from ._github import GitHubSource +from ._hermes_index import HermesIndexSource +from ._install import SkillSpec +from ._install import SkillSpecsConfig +from ._install import async_sync_remote_skills +from ._install import sync_remote_skills +from ._lobehub import LobeHubSource +from ._skills_sh import SkillsShSource +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta +from ._types import validate_bundle_rel_path +from ._types import validate_category_name +from ._types import validate_skill_name +from ._well_known import WellKnownSkillSource + +__all__ = [ + "SkillSource", + "SkillMeta", + "SkillBundle", + "SkillSpec", + "SkillSpecsConfig", + "sync_remote_skills", + "async_sync_remote_skills", + "validate_bundle_rel_path", + "validate_category_name", + "validate_skill_name", + "GitHubAuth", + "GitHubSource", + "WellKnownSkillSource", + "SkillsShSource", + "ClawHubSource", + "ClaudeMarketplaceSource", + "LobeHubSource", + "HermesIndexSource", +] diff --git a/trpc_agent_sdk/skills/hub/_claude_marketplace.py b/trpc_agent_sdk/skills/hub/_claude_marketplace.py new file mode 100644 index 00000000..f0819a57 --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_claude_marketplace.py @@ -0,0 +1,103 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Claude Code marketplace source adapter.""" + +from __future__ import annotations + +import json + +import httpx + +from ._github import GitHubAuth +from ._github import GitHubSource +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta + +DEFAULT_KNOWN_MARKETPLACES = ( + "anthropics/skills", + "aiskillstore/marketplace", +) + + +class ClaudeMarketplaceSource(SkillSource): + """ + Discover skills from Claude Code marketplace repos. + Marketplace repos contain .claude-plugin/marketplace.json with plugin listings. + """ + + def __init__( + self, + auth: GitHubAuth, + marketplaces: list[str] | None = None, + ) -> None: + self.auth = auth + self._marketplaces = list(marketplaces or DEFAULT_KNOWN_MARKETPLACES) + + def source_id(self) -> str: + return "claude-marketplace" + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + results: list[SkillMeta] = [] + query_lower = query.lower() + + for marketplace_repo in self._marketplaces: + plugins = self._fetch_marketplace_index(marketplace_repo) + for plugin in plugins: + searchable = f"{plugin.get('name', '')} {plugin.get('description', '')}".lower() + if query_lower in searchable: + source_path = plugin.get("source", "") + if source_path.startswith("./"): + identifier = f"{marketplace_repo}/{source_path[2:]}" + elif "/" in source_path: + identifier = source_path + else: + identifier = f"{marketplace_repo}/{source_path}" + + results.append( + SkillMeta( + name=plugin.get("name", ""), + description=plugin.get("description", ""), + source="claude-marketplace", + identifier=identifier, + repo=marketplace_repo, + )) + + return results[:limit] + + def fetch(self, identifier: str) -> SkillBundle | None: + # Delegate to GitHub Contents API since marketplace skills live in GitHub repos + gh = GitHubSource(auth=self.auth) + bundle = gh.fetch(identifier) + if bundle: + bundle.source = "claude-marketplace" + return bundle + + def inspect(self, identifier: str) -> SkillMeta | None: + gh = GitHubSource(auth=self.auth) + meta = gh.inspect(identifier) + if meta: + meta.source = "claude-marketplace" + return meta + + def _fetch_marketplace_index(self, repo: str) -> list[dict]: + """Fetch and parse .claude-plugin/marketplace.json from a repo.""" + url = f"https://api.github.com/repos/{repo}/contents/.claude-plugin/marketplace.json" + try: + resp = httpx.get( + url, + headers={ + **self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw" + }, + timeout=15, + ) + if resp.status_code != 200: + return [] + data = json.loads(resp.text) + except (httpx.HTTPError, json.JSONDecodeError): + return [] + + return data.get("plugins", []) diff --git a/trpc_agent_sdk/skills/hub/_clawhub.py b/trpc_agent_sdk/skills/hub/_clawhub.py new file mode 100644 index 00000000..7eb6155a --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_clawhub.py @@ -0,0 +1,482 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""ClawHub (clawhub.ai) source adapter.""" + +from __future__ import annotations + +import json +import re +import time +from typing import Any + +import httpx +from trpc_agent_sdk.log import logger + +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta +from ._types import validate_bundle_rel_path + + +class ClawHubSource(SkillSource): + """ + Fetch skills from ClawHub (clawhub.ai) via their HTTP API. + All skills are treated as community trust — ClawHavoc incident showed + their vetting is insufficient (341 malicious skills found Feb 2026). + """ + + BASE_URL = "https://clawhub.ai/api/v1" + + def source_id(self) -> str: + return "clawhub" + + @staticmethod + def _normalize_tags(tags: Any) -> list[str]: + if isinstance(tags, list): + return [str(t) for t in tags] + if isinstance(tags, dict): + return [str(k) for k in tags if str(k) != "latest"] + return [] + + @staticmethod + def _coerce_skill_payload(data: Any) -> dict[str, Any] | None: + if not isinstance(data, dict): + return None + nested = data.get("skill") + if isinstance(nested, dict): + merged = dict(nested) + latest_version = data.get("latestVersion") + if latest_version is not None and "latestVersion" not in merged: + merged["latestVersion"] = latest_version + return merged + return data + + @staticmethod + def _query_terms(query: str) -> list[str]: + return [term for term in re.split(r"[^a-z0-9]+", query.lower()) if term] + + @classmethod + def _search_score(cls, query: str, meta: SkillMeta) -> int: + query_norm = query.strip().lower() + if not query_norm: + return 1 + + identifier = (meta.identifier or "").lower() + name = (meta.name or "").lower() + description = (meta.description or "").lower() + normalized_identifier = " ".join(cls._query_terms(identifier)) + normalized_name = " ".join(cls._query_terms(name)) + query_terms = cls._query_terms(query_norm) + identifier_terms = cls._query_terms(identifier) + name_terms = cls._query_terms(name) + score = 0 + + if query_norm == identifier: + score += 140 + if query_norm == name: + score += 130 + if normalized_identifier == query_norm: + score += 125 + if normalized_name == query_norm: + score += 120 + if normalized_identifier.startswith(query_norm): + score += 95 + if normalized_name.startswith(query_norm): + score += 90 + if query_terms and identifier_terms[:len(query_terms)] == query_terms: + score += 70 + if query_terms and name_terms[:len(query_terms)] == query_terms: + score += 65 + if query_norm in identifier: + score += 40 + if query_norm in name: + score += 35 + if query_norm in description: + score += 10 + + for term in query_terms: + if term in identifier_terms: + score += 15 + if term in name_terms: + score += 12 + if term in description: + score += 3 + + return score + + @staticmethod + def _dedupe_results(results: list[SkillMeta]) -> list[SkillMeta]: + seen: set[str] = set() + deduped: list[SkillMeta] = [] + for result in results: + key = (result.identifier or result.name).lower() + if key in seen: + continue + seen.add(key) + deduped.append(result) + return deduped + + def _exact_slug_meta(self, query: str) -> SkillMeta | None: + slug = query.strip().split("/")[-1] + query_terms = self._query_terms(query) + candidates: list[str] = [] + + if slug and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", slug): + candidates.append(slug) + + if query_terms: + base_slug = "-".join(query_terms) + if len(query_terms) >= 2: + candidates.extend([ + f"{base_slug}-agent", + f"{base_slug}-skill", + f"{base_slug}-tool", + f"{base_slug}-assistant", + f"{base_slug}-playbook", + base_slug, + ]) + else: + candidates.append(base_slug) + + seen: set[str] = set() + for candidate in candidates: + if candidate in seen: + continue + seen.add(candidate) + meta = self.inspect(candidate) + if meta: + return meta + + return None + + def _finalize_search_results(self, query: str, results: list[SkillMeta], limit: int) -> list[SkillMeta]: + query_norm = query.strip() + if not query_norm: + return self._dedupe_results(results)[:limit] + + filtered = [meta for meta in results if self._search_score(query_norm, meta) > 0] + filtered.sort(key=lambda meta: ( + -self._search_score(query_norm, meta), + meta.name.lower(), + meta.identifier.lower(), + )) + filtered = self._dedupe_results(filtered) + + exact = self._exact_slug_meta(query_norm) + if exact: + filtered = [meta for meta in filtered if self._search_score(query_norm, meta) >= 20] + filtered = self._dedupe_results([exact] + filtered) + + if filtered: + return filtered[:limit] + + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", query_norm): + return [] + + return self._dedupe_results(results)[:limit] + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + query = query.strip() + + if query: + query_terms = self._query_terms(query) + if len(query_terms) >= 2: + direct = self._exact_slug_meta(query) + if direct: + return [direct] + + results = self._search_catalog(query, limit=limit) + if results: + return results + + # Empty query or catalog fallback failure: use the lightweight listing API. + try: + resp = httpx.get( + f"{self.BASE_URL}/skills", + params={ + "search": query, + "limit": limit + }, + timeout=15, + ) + if resp.status_code != 200: + return [] + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return [] + + skills_data = data.get("items", data) if isinstance(data, dict) else data + if not isinstance(skills_data, list): + return [] + + results = [] + for item in skills_data[:limit]: + slug = item.get("slug") + if not slug: + continue + display_name = item.get("displayName") or item.get("name") or slug + summary = item.get("summary") or item.get("description") or "" + tags = self._normalize_tags(item.get("tags", [])) + results.append( + SkillMeta( + name=display_name, + description=summary, + source="clawhub", + identifier=slug, + tags=tags, + )) + + return self._finalize_search_results(query, results, limit) + + def fetch(self, identifier: str) -> SkillBundle | None: + slug = identifier.split("/")[-1] + + skill_data = self._get_json(f"{self.BASE_URL}/skills/{slug}") + if not isinstance(skill_data, dict): + return None + + latest_version = self._resolve_latest_version(slug, skill_data) + if not latest_version: + logger.warning("ClawHub fetch failed for %s: could not resolve latest version", slug) + return None + + # Primary method: download the skill as a ZIP bundle from /download + files = self._download_zip(slug, latest_version) + + # Fallback: try the version metadata endpoint for inline/raw content + if "SKILL.md" not in files: + version_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions/{latest_version}") + if isinstance(version_data, dict): + # Files may be nested under version_data["version"]["files"] + files = self._extract_files(version_data) or files + if "SKILL.md" not in files: + nested = version_data.get("version", {}) + if isinstance(nested, dict): + files = self._extract_files(nested) or files + + if "SKILL.md" not in files: + logger.warning( + "ClawHub fetch for %s resolved version %s but could not retrieve file content", + slug, + latest_version, + ) + return None + + return SkillBundle( + name=slug, + files=files, + source="clawhub", + identifier=slug, + ) + + def inspect(self, identifier: str) -> SkillMeta | None: + slug = identifier.split("/")[-1] + data = self._coerce_skill_payload(self._get_json(f"{self.BASE_URL}/skills/{slug}")) + if not isinstance(data, dict): + return None + + tags = self._normalize_tags(data.get("tags", [])) + + return SkillMeta( + name=data.get("displayName") or data.get("name") or data.get("slug") or slug, + description=data.get("summary") or data.get("description") or "", + source="clawhub", + identifier=data.get("slug") or slug, + tags=tags, + ) + + def _search_catalog(self, query: str, limit: int = 10) -> list[SkillMeta]: + catalog = self._load_catalog_index() + if not catalog: + return [] + + return self._finalize_search_results(query, catalog, limit) + + def _load_catalog_index(self) -> list[SkillMeta]: + cursor: str | None = None + results: list[SkillMeta] = [] + seen: set[str] = set() + max_pages = 50 + + for _ in range(max_pages): + params: dict[str, Any] = {"limit": 200} + if cursor: + params["cursor"] = cursor + + try: + resp = httpx.get(f"{self.BASE_URL}/skills", params=params, timeout=30) + if resp.status_code != 200: + break + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + break + + items = data.get("items", []) if isinstance(data, dict) else [] + if not isinstance(items, list) or not items: + break + + for item in items: + slug = item.get("slug") + if not isinstance(slug, str) or not slug or slug in seen: + continue + seen.add(slug) + display_name = item.get("displayName") or item.get("name") or slug + summary = item.get("summary") or item.get("description") or "" + tags = self._normalize_tags(item.get("tags", [])) + results.append( + SkillMeta( + name=display_name, + description=summary, + source="clawhub", + identifier=slug, + tags=tags, + )) + + cursor = data.get("nextCursor") if isinstance(data, dict) else None + if not isinstance(cursor, str) or not cursor: + break + + return results + + def _get_json(self, url: str, timeout: int = 20) -> Any | None: + try: + resp = httpx.get(url, timeout=timeout) + if resp.status_code != 200: + return None + return resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return None + + def _resolve_latest_version(self, slug: str, skill_data: dict[str, Any]) -> str | None: + latest = skill_data.get("latestVersion") + if isinstance(latest, dict): + version = latest.get("version") + if isinstance(version, str) and version: + return version + + tags = skill_data.get("tags") + if isinstance(tags, dict): + latest_tag = tags.get("latest") + if isinstance(latest_tag, str) and latest_tag: + return latest_tag + + versions_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions") + if isinstance(versions_data, list) and versions_data: + first = versions_data[0] + if isinstance(first, dict): + version = first.get("version") + if isinstance(version, str) and version: + return version + return None + + def _extract_files(self, version_data: dict[str, Any]) -> dict[str, str]: + files: dict[str, str] = {} + file_list = version_data.get("files") + + if isinstance(file_list, dict): + return {k: v for k, v in file_list.items() if isinstance(v, str)} + + if not isinstance(file_list, list): + return files + + for file_meta in file_list: + if not isinstance(file_meta, dict): + continue + + fname = file_meta.get("path") or file_meta.get("name") + if not fname or not isinstance(fname, str): + continue + + inline_content = file_meta.get("content") + if isinstance(inline_content, str): + files[fname] = inline_content + continue + + raw_url = file_meta.get("rawUrl") or file_meta.get("downloadUrl") or file_meta.get("url") + if isinstance(raw_url, str) and raw_url.startswith("http"): + content = self._fetch_text(raw_url) + if content is not None: + files[fname] = content + + return files + + def _download_zip(self, slug: str, version: str) -> dict[str, str]: + """Download skill as a ZIP bundle from the /download endpoint and extract text files.""" + import io + import zipfile + + files: dict[str, str] = {} + max_retries = 3 + for attempt in range(max_retries): + try: + resp = httpx.get( + f"{self.BASE_URL}/download", + params={ + "slug": slug, + "version": version + }, + timeout=30, + follow_redirects=True, + ) + if resp.status_code == 429: + try: + retry_after = int(resp.headers.get("retry-after", "5")) + except (ValueError, TypeError): + retry_after = 5 + retry_after = min(retry_after, 15) # Cap wait time + logger.debug( + "ClawHub download rate-limited for %s, retrying in %ds (attempt %d/%d)", + slug, + retry_after, + attempt + 1, + max_retries, + ) + time.sleep(retry_after) + continue + if resp.status_code != 200: + logger.debug("ClawHub ZIP download for %s v%s returned %s", slug, version, resp.status_code) + return files + + with zipfile.ZipFile(io.BytesIO(resp.content)) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + try: + name = validate_bundle_rel_path(info.filename) + except ValueError: + logger.debug("Skipping unsafe ZIP member path: %s", info.filename) + continue + # Only extract text-sized files (skip large binaries) + if info.file_size > 500_000: + logger.debug("Skipping large file in ZIP: %s (%d bytes)", name, info.file_size) + continue + try: + raw = zf.read(info.filename) + files[name] = raw.decode("utf-8") + except (UnicodeDecodeError, KeyError): + logger.debug("Skipping non-text file in ZIP: %s", name) + continue + + return files + + except zipfile.BadZipFile: + logger.warning("ClawHub returned invalid ZIP for %s v%s", slug, version) + return files + except httpx.HTTPError as exc: + logger.debug("ClawHub ZIP download failed for %s v%s: %s", slug, version, exc) + return files + + logger.debug("ClawHub ZIP download exhausted retries for %s v%s", slug, version) + return files + + def _fetch_text(self, url: str) -> str | None: + try: + resp = httpx.get(url, timeout=20) + if resp.status_code == 200: + return resp.text + except httpx.HTTPError: + return None + return None diff --git a/trpc_agent_sdk/skills/hub/_github.py b/trpc_agent_sdk/skills/hub/_github.py new file mode 100644 index 00000000..4c85ecfb --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_github.py @@ -0,0 +1,391 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""GitHub repo source adapter (Contents / Git Trees API).""" + +from __future__ import annotations + +import re + +import httpx +import yaml +from trpc_agent_sdk.log import logger + +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta + + +class GitHubAuth: + """GitHub API authentication via a personal access token (PAT). + + The token must be passed explicitly by the caller (e.g. sourced from your + own secrets manager) rather than auto-detected from the environment or a + local `gh` CLI — this SDK may run multi-tenant, unlike a single-tenant CLI + tool. Requests are unauthenticated (60 req/hr, public repos only) when no + token is provided. + """ + + def __init__(self, token: str | None = None): + self._token = token + + def get_headers(self) -> dict[str, str]: + """Return authorization headers for GitHub API requests.""" + headers = {"Accept": "application/vnd.github.v3+json"} + if self._token: + headers["Authorization"] = f"token {self._token}" + return headers + + def is_authenticated(self) -> bool: + return bool(self._token) + + +class GitHubSource(SkillSource): + """Fetch skills from GitHub repos via the Contents API. + + `search` only looks at repos the caller explicitly declares via `taps` + (there is no built-in default tap list) — each tap is a + `{"repo": "owner/repo", "path": "skills/"}` mapping. `fetch`/`inspect` + don't need `taps` at all since they take a full identifier directly. + """ + + def __init__(self, auth: GitHubAuth, taps: list[dict] | None = None): + self.auth = auth + self.taps = list(taps) if taps else [] + # Set when GitHub returns 403 with rate limit exhausted + self._rate_limited: bool = False + + def source_id(self) -> str: + return "github" + + @property + def is_rate_limited(self) -> bool: + """Whether GitHub API rate limit was hit during operations.""" + return self._rate_limited + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + """Search all taps for skills matching the query.""" + results: list[SkillMeta] = [] + query_lower = query.lower() + + for tap in self.taps: + try: + skills = self._list_skills_in_repo(tap["repo"], tap.get("path", "")) + for skill in skills: + searchable = f"{skill.name} {skill.description} {' '.join(skill.tags)}".lower() + if query_lower in searchable: + results.append(skill) + except Exception as e: + logger.debug(f"Failed to search {tap['repo']}: {e}") + continue + + # Deduplicate by name, keeping the first match. + seen: dict[str, SkillMeta] = {} + for r in results: + if r.name not in seen: + seen[r.name] = r + results = list(seen.values()) + + return results[:limit] + + def fetch(self, identifier: str) -> SkillBundle | None: + """ + Download a skill from GitHub. + identifier format: "owner/repo/path/to/skill-dir" + """ + parts = identifier.split("/", 2) + if len(parts) < 3: + return None + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2] + + files = self._download_directory(repo, skill_path) + if not files or "SKILL.md" not in files: + return None + + skill_name = skill_path.rstrip("/").split("/")[-1] + + return SkillBundle( + name=skill_name, + files=files, + source="github", + identifier=identifier, + ) + + def inspect(self, identifier: str) -> SkillMeta | None: + """Fetch just the SKILL.md metadata for preview.""" + parts = identifier.split("/", 2) + if len(parts) < 3: + return None + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2].rstrip("/") + skill_md_path = f"{skill_path}/SKILL.md" + + content = self._fetch_file_content(repo, skill_md_path) + if not content: + return None + + fm = self._parse_frontmatter_quick(content) + skill_name = fm.get("name", skill_path.split("/")[-1]) + description = fm.get("description", "") + + tags = [] + metadata = fm.get("metadata", {}) + if isinstance(metadata, dict): + hermes_meta = metadata.get("hermes", {}) + if isinstance(hermes_meta, dict): + tags = hermes_meta.get("tags", []) + if not tags: + raw_tags = fm.get("tags", []) + tags = raw_tags if isinstance(raw_tags, list) else [] + + return SkillMeta( + name=skill_name, + description=str(description), + source="github", + identifier=identifier, + repo=repo, + path=skill_path, + tags=[str(t) for t in tags], + ) + + # -- Internal helpers -- + + def _list_skills_in_repo(self, repo: str, path: str) -> list[SkillMeta]: + """List skill directories in a GitHub repo path.""" + url = f"https://api.github.com/repos/{repo}/contents/{path.rstrip('/')}" + try: + resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15, follow_redirects=True) + if resp.status_code != 200: + return [] + except httpx.HTTPError: + return [] + + entries = resp.json() + if not isinstance(entries, list): + return [] + + skills: list[SkillMeta] = [] + for entry in entries: + if entry.get("type") != "dir": + continue + + dir_name = entry["name"] + if dir_name.startswith((".", "_")): + continue + + prefix = path.rstrip("/") + skill_identifier = f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}" + meta = self.inspect(skill_identifier) + if meta: + skills.append(meta) + + return skills + + # -- Repo tree (Git Trees API) -- + + def _get_repo_tree(self, repo: str) -> tuple[str, list[dict]] | None: + """Fetch the recursive repo tree via the Git Trees API. + + Returns ``(default_branch, tree_entries)`` or ``None``. + """ + headers = self.auth.get_headers() + + # Resolve default branch + try: + resp = httpx.get( + f"https://api.github.com/repos/{repo}", + headers=headers, + timeout=15, + follow_redirects=True, + ) + if resp.status_code != 200: + self._check_rate_limit_response(resp) + return None + default_branch = resp.json().get("default_branch", "main") + except (httpx.HTTPError, ValueError): + return None + + # Fetch recursive tree + try: + resp = httpx.get( + f"https://api.github.com/repos/{repo}/git/trees/{default_branch}", + params={"recursive": "1"}, + headers=headers, + timeout=30, + follow_redirects=True, + ) + if resp.status_code != 200: + self._check_rate_limit_response(resp) + return None + tree_data = resp.json() + if tree_data.get("truncated"): + logger.debug("Git tree truncated for %s, cannot use tree API", repo) + return None + except (httpx.HTTPError, ValueError): + return None + + entries = tree_data.get("tree", []) + return (default_branch, entries) + + def _check_rate_limit_response(self, resp: "httpx.Response") -> None: + """Flag the instance as rate-limited when GitHub returns 403 + exhausted quota.""" + if resp.status_code == 403: + remaining = resp.headers.get("X-RateLimit-Remaining", "") + if remaining == "0": + self._rate_limited = True + logger.warning("GitHub API rate limit exhausted (unauthenticated: 60 req/hr). " + "Set GITHUB_TOKEN or install the gh CLI to raise the limit to 5,000/hr.") + + def _download_directory(self, repo: str, path: str) -> dict[str, str]: + """Recursively download all text files from a GitHub directory. + + Uses the Git Trees API first (single call for the entire tree) to + avoid per-directory rate limiting that causes silent subdirectory + loss. Falls back to the recursive Contents API when the tree + endpoint is unavailable or the response is truncated. + """ + files = self._download_directory_via_tree(repo, path) + if files is not None: + return files + logger.debug("Tree API unavailable for %s/%s, falling back to Contents API", repo, path) + return self._download_directory_recursive(repo, path) + + def _download_directory_via_tree(self, repo: str, path: str) -> dict[str, str] | None: + """Download an entire directory using the Git Trees API (single request). + + Returns: + dict of files if the path exists and has content, + empty dict ``{}`` if the tree was fetched but the path doesn't exist + (prevents unnecessary Contents API fallback), + ``None`` if the tree couldn't be fetched (triggers Contents API fallback). + """ + path = path.rstrip("/") + + tree_result = self._get_repo_tree(repo) + if tree_result is None: + return None + _default_branch, tree_entries = tree_result + + # Check if ANY entry lives under the target path + prefix = f"{path}/" + has_entries = any(item.get("path", "").startswith(prefix) for item in tree_entries) + if not has_entries: + # Path definitively doesn't exist in the repo — return empty + # instead of None to skip the Contents API fallback. + return {} + + # Filter to blobs under our target path and fetch content + files: dict[str, str] = {} + for item in tree_entries: + if item.get("type") != "blob": + continue + item_path = item.get("path", "") + if not item_path.startswith(prefix): + continue + rel_path = item_path[len(prefix):] + content = self._fetch_file_content(repo, item_path) + if content is not None: + files[rel_path] = content + else: + logger.debug("Skipped file (fetch failed): %s/%s", repo, item_path) + + return files if files else None + + def _download_directory_recursive(self, repo: str, path: str) -> dict[str, str]: + """Recursively download via Contents API (fallback).""" + url = f"https://api.github.com/repos/{repo}/contents/{path.rstrip('/')}" + try: + resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15, follow_redirects=True) + if resp.status_code != 200: + logger.debug("Contents API returned %d for %s/%s", resp.status_code, repo, path) + return {} + except httpx.HTTPError: + return {} + + entries = resp.json() + if not isinstance(entries, list): + return {} + + files: dict[str, str] = {} + for entry in entries: + name = entry.get("name", "") + entry_type = entry.get("type", "") + + if entry_type == "file": + content = self._fetch_file_content(repo, entry.get("path", "")) + if content is not None: + rel_path = name + files[rel_path] = content + elif entry_type == "dir": + sub_files = self._download_directory_recursive(repo, entry.get("path", "")) + if not sub_files: + logger.debug("Empty or failed subdirectory: %s/%s", repo, entry.get("path", "")) + for sub_name, sub_content in sub_files.items(): + files[f"{name}/{sub_name}"] = sub_content + + return files + + def _find_skill_in_repo_tree(self, repo: str, skill_name: str) -> str | None: + """Use the GitHub Trees API to find a skill directory anywhere in the repo. + + Returns the full identifier (``repo/path/to/skill``) or ``None``. + This is a single API call regardless of repo depth, so it efficiently + handles deeply nested directory structures like + ``cli-tool/components/skills/development//SKILL.md``. + """ + tree_result = self._get_repo_tree(repo) + if tree_result is None: + return None + _default_branch, tree_entries = tree_result + + # Look for SKILL.md files inside directories named + skill_md_suffix = f"/{skill_name}/SKILL.md" + for entry in tree_entries: + if entry.get("type") != "blob": + continue + path = entry.get("path", "") + if path.endswith(skill_md_suffix) or path == f"{skill_name}/SKILL.md": + # Strip /SKILL.md to get the skill directory path + skill_dir = path[:-len("/SKILL.md")] + return f"{repo}/{skill_dir}" + + return None + + def _fetch_file_content(self, repo: str, path: str) -> str | None: + """Fetch a single file's content from GitHub.""" + url = f"https://api.github.com/repos/{repo}/contents/{path}" + try: + resp = httpx.get( + url, + headers={ + **self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw" + }, + timeout=15, + follow_redirects=True, + ) + if resp.status_code == 200: + return resp.text + self._check_rate_limit_response(resp) + except httpx.HTTPError as e: + logger.debug("GitHub contents API fetch failed: %s", e) + return None + + @staticmethod + def _parse_frontmatter_quick(content: str) -> dict: + """Parse YAML frontmatter from SKILL.md content.""" + if not content.startswith("---"): + return {} + match = re.search(r'\n---\s*\n', content[3:]) + if not match: + return {} + yaml_text = content[3:match.start() + 3] + try: + parsed = yaml.safe_load(yaml_text) + return parsed if isinstance(parsed, dict) else {} + except yaml.YAMLError: + return {} diff --git a/trpc_agent_sdk/skills/hub/_hermes_index.py b/trpc_agent_sdk/skills/hub/_hermes_index.py new file mode 100644 index 00000000..a106272b --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_hermes_index.py @@ -0,0 +1,187 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Centralized Hermes Skills Index source adapter. + +The index is a JSON catalog published to a docs site and rebuilt daily by +CI. It contains metadata + resolved GitHub paths for every skill, eliminating +the need to hit the GitHub API for search or path discovery. +""" + +from __future__ import annotations + +import json + +import httpx +from trpc_agent_sdk.log import logger + +from ._github import GitHubAuth +from ._github import GitHubSource +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta + +HERMES_INDEX_URL = "https://hermes-agent.nousresearch.com/docs/api/skills-index.json" + + +def _load_hermes_index() -> dict | None: + """Fetch the centralized skills index.""" + try: + resp = httpx.get(HERMES_INDEX_URL, timeout=15, follow_redirects=True) + if resp.status_code != 200: + logger.debug("Hermes index fetch returned %d", resp.status_code) + return None + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError) as e: + logger.debug("Hermes index fetch failed: %s", e) + return None + + if not isinstance(data, dict) or "skills" not in data: + return None + + return data + + +class HermesIndexSource(SkillSource): + """Skill source backed by the centralized Hermes Skills Index. + + When the index is unavailable, all methods return empty / None so + downstream sources take over transparently. + """ + + def __init__(self, auth: GitHubAuth): + self._index: dict | None = None + self._loaded = False + self.auth = auth + # Lazily create GitHubSource for fetch — only used when actually + # downloading files, which requires real GitHub API calls. + self._github: GitHubSource | None = None + + def _ensure_loaded(self) -> dict: + if not self._loaded: + self._index = _load_hermes_index() + self._loaded = True + return self._index or {} + + def _get_github(self) -> GitHubSource: + if self._github is None: + self._github = GitHubSource(auth=self.auth) + return self._github + + def source_id(self) -> str: + return "hermes-index" + + @property + def is_available(self) -> bool: + """Whether the index is loaded and has skills.""" + index = self._ensure_loaded() + return bool(index.get("skills")) + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + """Search the cached index. Zero API calls.""" + index = self._ensure_loaded() + skills = index.get("skills", []) + if not skills: + return [] + + if not query.strip(): + # No query — return featured/popular + return [self._to_meta(s) for s in skills[:limit]] + + query_lower = query.lower() + results: list[SkillMeta] = [] + for s in skills: + searchable = f"{s.get('name', '')} {s.get('description', '')} {' '.join(s.get('tags', []))}".lower() + if query_lower in searchable: + results.append(self._to_meta(s)) + if len(results) >= limit: + break + return results + + def fetch(self, identifier: str) -> SkillBundle | None: + """Fetch a skill using the resolved path from the index. + + If the index has a ``resolved_github_id`` for this skill, we skip + the entire candidate/discovery chain and go directly to GitHub + with the exact path. This reduces install from ~31 API calls to + just the file content downloads (~5-22 depending on skill size). + """ + index = self._ensure_loaded() + entry = self._find_entry(identifier, index) + if not entry: + return None + + # Use resolved path if available + resolved = entry.get("resolved_github_id") + if resolved: + bundle = self._get_github().fetch(resolved) + if bundle: + bundle.source = entry.get("source", "hermes-index") + bundle.identifier = identifier + return bundle + + # Fall back to identifier-based fetch via repo/path + repo = entry.get("repo", "") + path = entry.get("path", "") + if repo and path: + github_id = f"{repo}/{path}" + bundle = self._get_github().fetch(github_id) + if bundle: + bundle.source = entry.get("source", "hermes-index") + bundle.identifier = identifier + return bundle + + return None + + def inspect(self, identifier: str) -> SkillMeta | None: + """Return metadata from the index. Zero API calls.""" + index = self._ensure_loaded() + entry = self._find_entry(identifier, index) + if entry: + return self._to_meta(entry) + return None + + def _find_entry(self, identifier: str, index: dict) -> dict | None: + """Look up a skill in the index by identifier or name.""" + skills = index.get("skills", []) + + # Exact identifier match + for s in skills: + if s.get("identifier") == identifier: + return s + + # Try without source prefix (e.g. "skills-sh/" stripped) + normalized = identifier + for prefix in ("skills-sh/", "skills.sh/", "official/", "github/", "clawhub/"): + if identifier.startswith(prefix): + normalized = identifier[len(prefix):] + break + + # Match on normalized identifier or name + for s in skills: + sid = s.get("identifier", "") + # Strip prefix from stored identifier too + stored_normalized = sid + for prefix in ("skills-sh/", "skills.sh/", "official/", "github/", "clawhub/"): + if sid.startswith(prefix): + stored_normalized = sid[len(prefix):] + break + if stored_normalized == normalized: + return s + + return None + + @staticmethod + def _to_meta(entry: dict) -> SkillMeta: + return SkillMeta( + name=entry.get("name", ""), + description=entry.get("description", ""), + source=entry.get("source", "hermes-index"), + identifier=entry.get("identifier", ""), + repo=entry.get("repo"), + path=entry.get("path"), + tags=entry.get("tags", []), + extra=entry.get("extra", {}), + ) diff --git a/trpc_agent_sdk/skills/hub/_install.py b/trpc_agent_sdk/skills/hub/_install.py new file mode 100644 index 00000000..14051820 --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_install.py @@ -0,0 +1,193 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Remote skill installation helpers for Skills Hub bundles. + +Skill Hub adapters fetch skills as in-memory :class:`SkillBundle` objects. +This module owns the shared policy for installing those bundles into a local +filesystem skill root that :class:`FsSkillRepository` can scan. +""" + +from __future__ import annotations + +import asyncio +import shutil +import tempfile +from dataclasses import dataclass +from dataclasses import field +from pathlib import Path +from typing import Literal +from typing import Sequence + +from trpc_agent_sdk.log import logger + +from ._source import SkillSource +from ._types import validate_bundle_rel_path +from ._types import validate_category_name +from ._types import validate_skill_name + +_DEFAULT_CATEGORY = "hub" + + +@dataclass(frozen=True) +class SkillSpec: + """Declares one remote skill to fetch into a local skills directory.""" + + source: SkillSource + """Source to fetch the skill from, e.g. ``ClawHubSource()``.""" + + identifier: str + """Source-specific identifier passed to ``source.fetch``.""" + + name: str + """Directory name and existence-check key under any category directory.""" + + category: str | None = None + """Category directory to place the skill under. + + Defaults to the fetched bundle's ``metadata["category"]`` value, falling + back to ``"hub"``. + """ + + replace_if_exists: bool = False + """Whether to overwrite an already-installed skill with the same name.""" + + on_error: Literal["skip", "raise"] = "skip" + """Whether installation errors should be logged and skipped or raised.""" + + def __post_init__(self) -> None: + validate_skill_name(self.name) + + +def _default_install_path() -> str: + return str(Path(tempfile.gettempdir()) / "trpc_agent_skills") + + +@dataclass(frozen=True) +class SkillSpecsConfig: + """A batch of remote skills plus where to install them locally. + + ``install_path`` defaults to a stable directory under the system temp + directory, so callers that only care about *what* to install can omit + *where*. Reusing a stable path also lets already-installed skills be + skipped on later runs. + """ + + specs: list[SkillSpec] + """Remote skills to fetch.""" + + install_path: str = field(default_factory=_default_install_path) + """Writable local directory the skills are fetched into. + + Defaults to ``/trpc_agent_skills``. + """ + + +def _find_existing_skill_dirs(skills_path: Path, name: str) -> list[Path]: + if not skills_path.is_dir(): + return [] + found: list[Path] = [] + for category_dir in skills_path.iterdir(): + if not category_dir.is_dir() or category_dir.name.startswith("."): + continue + candidate = category_dir / name + if candidate.is_dir(): + found.append(candidate) + return found + + +def _write_bundle_files( + *, + skills_path: Path, + category: str, + name: str, + files: dict[str, str | bytes], +) -> None: + safe_category = validate_category_name(category) + target_dir = skills_path / safe_category / name + + tmp_root = skills_path / ".tmp" + tmp_root.mkdir(parents=True, exist_ok=True) + staging_dir = Path(tempfile.mkdtemp(prefix=f"{name}-", dir=tmp_root)) + try: + for rel_path, content in files.items(): + safe_rel_path = validate_bundle_rel_path(rel_path) + dest = staging_dir / safe_rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + dest.write_bytes(content) + else: + dest.write_text(content, encoding="utf-8") + + target_dir.parent.mkdir(parents=True, exist_ok=True) + if target_dir.exists(): + shutil.rmtree(target_dir) + staging_dir.rename(target_dir) + finally: + if staging_dir.exists(): + shutil.rmtree(staging_dir, ignore_errors=True) + try: + tmp_root.rmdir() + except OSError: + pass # non-empty: another concurrent fetch may still be staging + + +def _fetch_remote_skill(remote_skill: SkillSpec, skills_path: Path) -> None: + name = remote_skill.name + + existing = _find_existing_skill_dirs(skills_path, name) + if existing and not remote_skill.replace_if_exists: + logger.debug("Skipping remote skill %r: already present at %s", name, existing[0]) + return + + bundle = remote_skill.source.fetch(remote_skill.identifier) + if bundle is None: + raise ValueError(f"Skill source {remote_skill.source.source_id()!r} could not fetch " + f"identifier {remote_skill.identifier!r}.") + + category = remote_skill.category or bundle.metadata.get("category") or _DEFAULT_CATEGORY + + if existing and remote_skill.replace_if_exists: + for existing_dir in existing: + shutil.rmtree(existing_dir, ignore_errors=True) + + _write_bundle_files(skills_path=skills_path, category=category, name=name, files=bundle.files) + logger.info( + "Fetched remote skill %r from source %r into %s/%s", + name, + remote_skill.source.source_id(), + category, + name, + ) + + +def sync_remote_skills(remote_skills: Sequence[SkillSpec], install_root: Path) -> None: + """Download declared-but-missing remote skills into ``install_root``. + + An existing skill under ``install_root///`` is left + untouched unless its declaration sets ``replace_if_exists=True``. Errors + are handled per declaration via ``on_error``. + """ + if not remote_skills: + return + + install_root.mkdir(parents=True, exist_ok=True) + for remote_skill in remote_skills: + try: + _fetch_remote_skill(remote_skill, install_root) + except Exception as exc: # pylint: disable=broad-exception-caught + if remote_skill.on_error == "raise": + raise + logger.warning( + "Skipping remote skill (identifier=%r, source=%r): %s", + remote_skill.identifier, + remote_skill.source.source_id(), + exc, + ) + + +async def async_sync_remote_skills(remote_skills: Sequence[SkillSpec], install_root: Path) -> None: + """Async wrapper around :func:`sync_remote_skills`.""" + await asyncio.to_thread(sync_remote_skills, remote_skills, install_root) diff --git a/trpc_agent_sdk/skills/hub/_lobehub.py b/trpc_agent_sdk/skills/hub/_lobehub.py new file mode 100644 index 00000000..9422e7a2 --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_lobehub.py @@ -0,0 +1,160 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""LobeHub agent marketplace source adapter.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +from trpc_agent_sdk.log import logger + +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta + + +class LobeHubSource(SkillSource): + """ + Fetch skills from LobeHub's agent marketplace (14,500+ agents). + LobeHub agents are system prompt templates — we convert them to SKILL.md on fetch. + Data lives in GitHub: lobehub/lobe-chat-agents. + """ + + INDEX_URL = "https://chat-agents.lobehub.com/index.json" + + def source_id(self) -> str: + return "lobehub" + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + index = self._fetch_index() + if not index: + return [] + + query_lower = query.lower() + results: list[SkillMeta] = [] + + agents = index.get("agents", index) if isinstance(index, dict) else index + if not isinstance(agents, list): + return [] + + for agent in agents: + meta = agent.get("meta", agent) + title = meta.get("title", agent.get("identifier", "")) + desc = meta.get("description", "") + tags = meta.get("tags", []) + + searchable = f"{title} {desc} {' '.join(tags) if isinstance(tags, list) else ''}".lower() + if query_lower in searchable: + identifier = agent.get("identifier", title.lower().replace(" ", "-")) + results.append( + SkillMeta( + name=identifier, + description=desc[:200], + source="lobehub", + identifier=f"lobehub/{identifier}", + tags=tags if isinstance(tags, list) else [], + )) + + if len(results) >= limit: + break + + return results + + def fetch(self, identifier: str) -> SkillBundle | None: + # Strip "lobehub/" prefix if present + agent_id = identifier.split("/", 1)[-1] if identifier.startswith("lobehub/") else identifier + + agent_data = self._fetch_agent(agent_id) + if not agent_data: + return None + + skill_md = self._convert_to_skill_md(agent_data) + return SkillBundle( + name=agent_id, + files={"SKILL.md": skill_md}, + source="lobehub", + identifier=f"lobehub/{agent_id}", + ) + + def inspect(self, identifier: str) -> SkillMeta | None: + agent_id = identifier.split("/", 1)[-1] if identifier.startswith("lobehub/") else identifier + index = self._fetch_index() + if not index: + return None + + agents = index.get("agents", index) if isinstance(index, dict) else index + if not isinstance(agents, list): + return None + + for agent in agents: + if agent.get("identifier") == agent_id: + meta = agent.get("meta", agent) + return SkillMeta( + name=agent_id, + description=meta.get("description", ""), + source="lobehub", + identifier=f"lobehub/{agent_id}", + tags=meta.get("tags", []) if isinstance(meta.get("tags"), list) else [], + ) + return None + + def _fetch_index(self) -> Any | None: + """Fetch the LobeHub agent index.""" + try: + resp = httpx.get(self.INDEX_URL, timeout=30) + if resp.status_code != 200: + return None + return resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return None + + def _fetch_agent(self, agent_id: str) -> dict | None: + """Fetch a single agent's JSON file.""" + url = f"https://chat-agents.lobehub.com/{agent_id}.json" + try: + resp = httpx.get(url, timeout=15) + if resp.status_code == 200: + return resp.json() + except (httpx.HTTPError, json.JSONDecodeError) as e: + logger.debug("LobeHub agent fetch failed: %s", e) + return None + + @staticmethod + def _convert_to_skill_md(agent_data: dict) -> str: + """Convert a LobeHub agent JSON into SKILL.md format.""" + meta = agent_data.get("meta", agent_data) + identifier = agent_data.get("identifier", "lobehub-agent") + title = meta.get("title", identifier) + description = meta.get("description", "") + tags = meta.get("tags", []) + system_role = agent_data.get("config", {}).get("systemRole", "") + + tag_list = tags if isinstance(tags, list) else [] + fm_lines = [ + "---", + f"name: {identifier}", + f"description: {description[:500]}", + "metadata:", + " hermes:", + f" tags: [{', '.join(str(t) for t in tag_list)}]", + " lobehub:", + " source: lobehub", + "---", + ] + + body_lines = [ + f"# {title}", + "", + description, + "", + "## Instructions", + "", + system_role if system_role else "(No system role defined)", + ] + + return "\n".join(fm_lines) + "\n\n" + "\n".join(body_lines) + "\n" diff --git a/trpc_agent_sdk/skills/hub/_skills_sh.py b/trpc_agent_sdk/skills/hub/_skills_sh.py new file mode 100644 index 00000000..b7386a19 --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_skills_sh.py @@ -0,0 +1,465 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""skills.sh discovery adapter — resolves back to the underlying GitHub repo.""" + +from __future__ import annotations + +import json +import re +from typing import Any + +import httpx + +from ._github import GitHubAuth +from ._github import GitHubSource +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta + + +class SkillsShSource(SkillSource): + """Discover skills via skills.sh and fetch content from the underlying GitHub repo.""" + + BASE_URL = "https://skills.sh" + SEARCH_URL = f"{BASE_URL}/api/search" + _SKILL_LINK_RE = re.compile(r'href=["\']/(?P(?!agents/|_next/|api/)[^"\'/]+/[^"\'/]+/[^"\'/]+)["\']') + _INSTALL_CMD_RE = re.compile( + r'npx\s+skills\s+add\s+(?Phttps?://github\.com/[^\s<]+|[^\s<]+)' + r'(?:\s+--skill\s+(?P[^\s<]+))?', + re.IGNORECASE, + ) + _PAGE_H1_RE = re.compile(r']*>(?P.*?)</h1>', re.IGNORECASE | re.DOTALL) + _PROSE_H1_RE = re.compile( + r'<div[^>]*class=["\'][^"\']*prose[^"\']*["\'][^>]*>.*?<h1[^>]*>(?P<title>.*?)</h1>', + re.IGNORECASE | re.DOTALL, + ) + _PROSE_P_RE = re.compile( + r'<div[^>]*class=["\'][^"\']*prose[^"\']*["\'][^>]*>.*?<p[^>]*>(?P<body>.*?)</p>', + re.IGNORECASE | re.DOTALL, + ) + _WEEKLY_INSTALLS_RE = re.compile(r'Weekly Installs.*?children\\":\\"(?P<count>[0-9.,Kk]+)\\"', re.DOTALL) + + def __init__(self, auth: GitHubAuth): + self.auth = auth + self.github = GitHubSource(auth=auth) + + def source_id(self) -> str: + return "skills-sh" + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + if not query.strip(): + return self._featured_skills(limit) + + try: + resp = httpx.get( + self.SEARCH_URL, + params={ + "q": query, + "limit": limit + }, + timeout=20, + ) + if resp.status_code != 200: + return [] + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return [] + + items = data.get("skills", []) if isinstance(data, dict) else [] + if not isinstance(items, list): + return [] + + results: list[SkillMeta] = [] + for item in items[:limit]: + meta = self._meta_from_search_item(item) + if meta: + results.append(meta) + + return results + + def fetch(self, identifier: str) -> SkillBundle | None: + canonical = self._normalize_identifier(identifier) + detail = self._fetch_detail_page(canonical) + for candidate in self._candidate_identifiers(canonical): + bundle = self.github.fetch(candidate) + if bundle: + bundle.source = "skills.sh" + bundle.identifier = self._wrap_identifier(canonical) + bundle.metadata.update(self._detail_to_metadata(canonical, detail)) + return bundle + + resolved = self._discover_identifier(canonical, detail=detail) + if resolved: + bundle = self.github.fetch(resolved) + if bundle: + bundle.source = "skills.sh" + bundle.identifier = self._wrap_identifier(canonical) + bundle.metadata.update(self._detail_to_metadata(canonical, detail)) + return bundle + return None + + def inspect(self, identifier: str) -> SkillMeta | None: + canonical = self._normalize_identifier(identifier) + detail = self._fetch_detail_page(canonical) + meta = self._resolve_github_meta(canonical, detail=detail) + if meta: + return self._finalize_inspect_meta(meta, canonical, detail) + return None + + def _featured_skills(self, limit: int) -> list[SkillMeta]: + try: + resp = httpx.get(self.BASE_URL, timeout=20) + if resp.status_code != 200: + return [] + except httpx.HTTPError: + return [] + + seen: set[str] = set() + results: list[SkillMeta] = [] + for match in self._SKILL_LINK_RE.finditer(resp.text): + canonical = match.group("id") + if canonical in seen: + continue + seen.add(canonical) + parts = canonical.split("/", 2) + if len(parts) < 3: + continue + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2] + results.append( + SkillMeta( + name=skill_path.split("/")[-1], + description=f"Featured on skills.sh from {repo}", + source="skills.sh", + identifier=self._wrap_identifier(canonical), + repo=repo, + path=skill_path, + )) + if len(results) >= limit: + break + + return results + + def _meta_from_search_item(self, item: dict) -> SkillMeta | None: + if not isinstance(item, dict): + return None + + canonical = item.get("id") + repo = item.get("source") + skill_path = item.get("skillId") + if not isinstance(canonical, str) or canonical.count("/") < 2: + if not (isinstance(repo, str) and isinstance(skill_path, str)): + return None + canonical = f"{repo}/{skill_path}" + + parts = canonical.split("/", 2) + if len(parts) < 3: + return None + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2] + installs = item.get("installs") + installs_label = f" · {int(installs):,} installs" if isinstance(installs, int) else "" + + return SkillMeta( + name=str(item.get("name") or skill_path.split("/")[-1]), + description=f"Indexed by skills.sh from {repo}{installs_label}", + source="skills.sh", + identifier=self._wrap_identifier(canonical), + repo=repo, + path=skill_path, + extra={ + "installs": installs, + "detail_url": f"{self.BASE_URL}/{canonical}", + "repo_url": f"https://github.com/{repo}", + }, + ) + + def _fetch_detail_page(self, identifier: str) -> dict | None: + try: + resp = httpx.get(f"{self.BASE_URL}/{identifier}", timeout=20) + if resp.status_code != 200: + return None + except httpx.HTTPError: + return None + + return self._parse_detail_page(identifier, resp.text) + + def _parse_detail_page(self, identifier: str, html: str) -> dict | None: + parts = identifier.split("/", 2) + if len(parts) < 3: + return None + + default_repo = f"{parts[0]}/{parts[1]}" + skill_token = parts[2] + repo = default_repo + install_skill = skill_token + + install_command = None + install_match = self._INSTALL_CMD_RE.search(html) + if install_match: + install_command = install_match.group(0).strip() + repo_value = (install_match.group("repo") or "").strip() + install_skill = (install_match.group("skill") or install_skill).strip() + repo = self._extract_repo_slug(repo_value) or repo + + page_title = self._extract_first_match(self._PAGE_H1_RE, html) + body_title = self._extract_first_match(self._PROSE_H1_RE, html) + body_summary = self._extract_first_match(self._PROSE_P_RE, html) + weekly_installs = self._extract_weekly_installs(html) + security_audits = self._extract_security_audits(html, identifier) + + return { + "repo": repo, + "install_skill": install_skill, + "page_title": page_title, + "body_title": body_title, + "body_summary": body_summary, + "weekly_installs": weekly_installs, + "install_command": install_command, + "repo_url": f"https://github.com/{repo}", + "detail_url": f"{self.BASE_URL}/{identifier}", + "security_audits": security_audits, + } + + def _discover_identifier(self, identifier: str, detail: dict | None = None) -> str | None: + parts = identifier.split("/", 2) + if len(parts) < 3: + return None + + default_repo = f"{parts[0]}/{parts[1]}" + repo = detail.get("repo", default_repo) if isinstance(detail, dict) else default_repo + skill_token = parts[2].split("/")[-1] + tokens = [skill_token] + if isinstance(detail, dict): + tokens.extend([ + detail.get("install_skill", ""), + detail.get("page_title", ""), + detail.get("body_title", ""), + ]) + + # Standard skill paths + base_paths = ["skills/", ".agents/skills/", ".claude/skills/"] + + for base_path in base_paths: + try: + skills = self.github._list_skills_in_repo(repo, base_path) + except Exception: + continue + for meta in skills: + if self._matches_skill_tokens(meta, tokens): + return meta.identifier + + # Prefer a single recursive tree lookup before brute-forcing every + # top-level directory. This avoids large request bursts on categorized + # repos like borghei/claude-skills. + tree_result = self.github._find_skill_in_repo_tree(repo, skill_token) + if tree_result: + return tree_result + + # Fallback: scan repo root for directories that might contain skills + try: + root_url = f"https://api.github.com/repos/{repo}/contents/" + resp = httpx.get(root_url, headers=self.github.auth.get_headers(), timeout=15, follow_redirects=True) + if resp.status_code == 200: + entries = resp.json() + if isinstance(entries, list): + for entry in entries: + if entry.get("type") != "dir": + continue + dir_name = entry["name"] + if dir_name.startswith((".", "_")): + continue + if dir_name in ("skills", ".agents", ".claude"): + continue # already tried + # Try direct: repo/dir/skill_token + direct_id = f"{repo}/{dir_name}/{skill_token}" + meta = self.github.inspect(direct_id) + if meta: + return meta.identifier + # Try listing skills in this directory + try: + skills = self.github._list_skills_in_repo(repo, dir_name + "/") + except Exception: + continue + for meta in skills: + if self._matches_skill_tokens(meta, tokens): + return meta.identifier + except Exception: + pass + + return None + + def _resolve_github_meta(self, identifier: str, detail: dict | None = None) -> SkillMeta | None: + for candidate in self._candidate_identifiers(identifier): + meta = self.github.inspect(candidate) + if meta: + return meta + + resolved = self._discover_identifier(identifier, detail=detail) + if resolved: + return self.github.inspect(resolved) + return None + + def _finalize_inspect_meta(self, meta: SkillMeta, canonical: str, detail: dict | None) -> SkillMeta: + meta.source = "skills.sh" + meta.identifier = self._wrap_identifier(canonical) + merged_extra = dict(meta.extra) + merged_extra.update(self._detail_to_metadata(canonical, detail)) + meta.extra = merged_extra + + if isinstance(detail, dict): + body_summary = detail.get("body_summary") + weekly_installs = detail.get("weekly_installs") + if body_summary: + meta.description = body_summary + elif meta.description and weekly_installs: + meta.description = f"{meta.description} · {weekly_installs} weekly installs on skills.sh" + return meta + + @classmethod + def _matches_skill_tokens(cls, meta: SkillMeta, skill_tokens: list[str]) -> bool: + candidates = set() + candidates.update(cls._token_variants(meta.name)) + candidates.update(cls._token_variants(meta.path)) + candidates.update(cls._token_variants(meta.identifier.split("/", 2)[-1] if meta.identifier else None)) + + for token in skill_tokens: + variants = cls._token_variants(token) + if variants & candidates: + return True + return False + + @staticmethod + def _token_variants(value: str | None) -> set[str]: + if not value: + return set() + + plain = SkillsShSource._strip_html(str(value)).strip().strip("/").lower() + if not plain: + return set() + + base = plain.split("/")[-1] + sanitized = re.sub(r'[^a-z0-9/_-]+', '-', plain).strip('-') + sanitized_base = sanitized.split("/")[-1] if sanitized else "" + slash_tail = plain.split("/")[-1] + slash_tail_clean = slash_tail.lstrip('@') + slash_tail_clean = slash_tail_clean.split('/')[-1] + + variants = { + plain, + plain.replace("_", "-"), + plain.replace("/", "-"), + base, + base.replace("_", "-"), + base.replace("/", "-"), + sanitized, + sanitized.replace("/", "-") if sanitized else "", + sanitized_base, + slash_tail_clean, + slash_tail_clean.replace("_", "-"), + } + return {v for v in variants if v} + + @staticmethod + def _extract_repo_slug(repo_value: str) -> str | None: + repo_value = repo_value.strip() + if repo_value.startswith("https://github.com/"): + repo_value = repo_value[len("https://github.com/"):] + repo_value = repo_value.strip("/") + parts = repo_value.split("/") + if len(parts) >= 2: + return f"{parts[0]}/{parts[1]}" + return None + + @staticmethod + def _extract_first_match(pattern: re.Pattern, text: str) -> str | None: + match = pattern.search(text) + if not match: + return None + value = next((group for group in match.groups() if group), None) + if value is None: + return None + return SkillsShSource._strip_html(value).strip() or None + + def _detail_to_metadata(self, canonical: str, detail: dict | None) -> dict[str, Any]: + parts = canonical.split("/", 2) + repo = f"{parts[0]}/{parts[1]}" if len(parts) >= 2 else "" + metadata = { + "detail_url": f"{self.BASE_URL}/{canonical}", + } + if repo: + metadata["repo_url"] = f"https://github.com/{repo}" + if isinstance(detail, dict): + for key in ("weekly_installs", "install_command", "repo_url", "detail_url", "security_audits"): + value = detail.get(key) + if value: + metadata[key] = value + return metadata + + @staticmethod + def _extract_weekly_installs(html: str) -> str | None: + match = SkillsShSource._WEEKLY_INSTALLS_RE.search(html) + if not match: + return None + return match.group("count") + + @staticmethod + def _extract_security_audits(html: str, identifier: str) -> dict[str, str]: + audits: dict[str, str] = {} + for audit in ("agent-trust-hub", "socket", "snyk"): + idx = html.find(f"/security/{audit}") + if idx == -1: + continue + window = html[idx:idx + 500] + match = re.search(r'(Pass|Warn|Fail)', window, re.IGNORECASE) + if match: + audits[audit] = match.group(1).title() + return audits + + @staticmethod + def _strip_html(value: str) -> str: + return re.sub(r'<[^>]+>', '', value) + + @staticmethod + def _normalize_identifier(identifier: str) -> str: + prefix_aliases = ( + "skills-sh/", + "skills.sh/", + "skils-sh/", + "skils.sh/", + ) + for prefix in prefix_aliases: + if identifier.startswith(prefix): + return identifier[len(prefix):] + return identifier + + @staticmethod + def _candidate_identifiers(identifier: str) -> list[str]: + parts = identifier.split("/", 2) + if len(parts) < 3: + return [identifier] + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2].lstrip("/") + candidates = [ + f"{repo}/{skill_path}", + f"{repo}/skills/{skill_path}", + f"{repo}/.agents/skills/{skill_path}", + f"{repo}/.claude/skills/{skill_path}", + ] + + seen = set() + deduped: list[str] = [] + for candidate in candidates: + if candidate not in seen: + seen.add(candidate) + deduped.append(candidate) + return deduped + + @staticmethod + def _wrap_identifier(identifier: str) -> str: + return f"skills-sh/{identifier}" diff --git a/trpc_agent_sdk/skills/hub/_source.py b/trpc_agent_sdk/skills/hub/_source.py new file mode 100644 index 00000000..f5e1a71b --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_source.py @@ -0,0 +1,42 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""The `SkillSource` contract shared by all skill registry adapters.""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod + +from ._types import SkillBundle +from ._types import SkillMeta + + +class SkillSource(ABC): + """Contract every skill registry adapter implements. + + Concrete adapters subclass this directly, e.g. + `class GitHubSource(SkillSource)`. A custom source does the same. + """ + + @abstractmethod + def source_id(self) -> str: + """Stable identifier for this source, e.g. ``"clawhub"`` / ``"github"``.""" + ... + + @abstractmethod + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + """Search for skills matching a free-text query string.""" + ... + + @abstractmethod + def inspect(self, identifier: str) -> SkillMeta | None: + """Fetch metadata for a skill without downloading its files.""" + ... + + @abstractmethod + def fetch(self, identifier: str) -> SkillBundle | None: + """Download a skill bundle by identifier.""" + ... diff --git a/trpc_agent_sdk/skills/hub/_types.py b/trpc_agent_sdk/skills/hub/_types.py new file mode 100644 index 00000000..fd9fde6f --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_types.py @@ -0,0 +1,77 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Shared data types for Skills Hub source adapters.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from dataclasses import field +from pathlib import PurePosixPath +from typing import Any +from typing import Union + + +@dataclass +class SkillMeta: + """Minimal metadata returned by search results.""" + + name: str + description: str + source: str # "official", "github", "clawhub", "claude-marketplace", "lobehub", ... + identifier: str # source-specific ID (e.g. "openai/skills/skill-creator") + repo: str | None = None + path: str | None = None + tags: list[str] = field(default_factory=list) + extra: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class SkillBundle: + """A downloaded skill, ready for the caller to write to disk.""" + + name: str + files: dict[str, Union[str, bytes]] # relative_path -> file content + source: str + identifier: str + metadata: dict[str, Any] = field(default_factory=dict) + + +def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str: + """Normalize and validate bundle-controlled paths before touching disk.""" + if not isinstance(path_value, str): + raise ValueError(f"Unsafe {field_name}: expected a string") + + raw = path_value.strip() + if not raw: + raise ValueError(f"Unsafe {field_name}: empty path") + + normalized = raw.replace("\\", "/") + path = PurePosixPath(normalized) + parts = [part for part in path.parts if part not in ("", ".")] + + if normalized.startswith("/") or path.is_absolute(): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if not parts or any(part == ".." for part in parts): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if re.fullmatch(r"[A-Za-z]:", parts[0]): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if not allow_nested and len(parts) != 1: + raise ValueError(f"Unsafe {field_name}: {path_value}") + + return "/".join(parts) + + +def validate_skill_name(name: str) -> str: + return _normalize_bundle_path(name, field_name="skill name", allow_nested=False) + + +def validate_category_name(category: str) -> str: + return _normalize_bundle_path(category, field_name="category", allow_nested=False) + + +def validate_bundle_rel_path(rel_path: str) -> str: + return _normalize_bundle_path(rel_path, field_name="bundle file path", allow_nested=True) diff --git a/trpc_agent_sdk/skills/hub/_well_known.py b/trpc_agent_sdk/skills/hub/_well_known.py new file mode 100644 index 00000000..90364522 --- /dev/null +++ b/trpc_agent_sdk/skills/hub/_well_known.py @@ -0,0 +1,242 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Well-known Agent Skills endpoint source adapter.""" + +from __future__ import annotations + +import json +from urllib.parse import urlparse +from urllib.parse import urlunparse + +import httpx +from trpc_agent_sdk.log import logger + +from ._github import GitHubSource +from ._source import SkillSource +from ._types import SkillBundle +from ._types import SkillMeta +from ._types import validate_bundle_rel_path +from ._types import validate_skill_name + +DEFAULT_BASE_PATH = "/.well-known/skills" + + +class WellKnownSkillSource(SkillSource): + """Read skills from a domain exposing a well-known skills index endpoint.""" + + def __init__(self, base_path: str | None = None) -> None: + raw = (base_path or DEFAULT_BASE_PATH).strip() + if not raw.startswith("/"): + raw = f"/{raw}" + self._base_path = raw.rstrip("/") + + def source_id(self) -> str: + return "well-known" + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + index_url = self._query_to_index_url(query) + if not index_url: + return [] + + parsed = self._parse_index(index_url) + if not parsed: + return [] + + results: list[SkillMeta] = [] + for entry in parsed["skills"][:limit]: + name = entry.get("name") + if not isinstance(name, str) or not name: + continue + description = entry.get("description", "") + files = entry.get("files", ["SKILL.md"]) + results.append( + SkillMeta( + name=name, + description=str(description), + source="well-known", + identifier=self._wrap_identifier(parsed["base_url"], name), + path=name, + extra={ + "index_url": parsed["index_url"], + "base_url": parsed["base_url"], + "files": files if isinstance(files, list) else ["SKILL.md"], + }, + )) + return results + + def inspect(self, identifier: str) -> SkillMeta | None: + parsed = self._parse_identifier(identifier) + if not parsed: + return None + + entry = self._index_entry(parsed["index_url"], parsed["skill_name"]) + if not entry: + return None + + skill_md = self._fetch_text(f"{parsed['skill_url']}/SKILL.md") + if skill_md is None: + return None + + fm = GitHubSource._parse_frontmatter_quick(skill_md) + description = str(fm.get("description") or entry.get("description") or "") + name = str(fm.get("name") or parsed["skill_name"]) + return SkillMeta( + name=name, + description=description, + source="well-known", + identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]), + path=parsed["skill_name"], + extra={ + "index_url": parsed["index_url"], + "base_url": parsed["base_url"], + "files": entry.get("files", ["SKILL.md"]), + "endpoint": parsed["skill_url"], + }, + ) + + def fetch(self, identifier: str) -> SkillBundle | None: + parsed = self._parse_identifier(identifier) + if not parsed: + return None + + try: + skill_name = validate_skill_name(parsed["skill_name"]) + except ValueError: + logger.warning("Well-known skill identifier contained unsafe skill name: %s", identifier) + return None + + entry = self._index_entry(parsed["index_url"], parsed["skill_name"]) + if not entry: + return None + + files = entry.get("files", ["SKILL.md"]) + if not isinstance(files, list) or not files: + files = ["SKILL.md"] + + downloaded: dict[str, str] = {} + for rel_path in files: + if not isinstance(rel_path, str) or not rel_path: + continue + try: + safe_rel_path = validate_bundle_rel_path(rel_path) + except ValueError: + logger.warning( + "Well-known skill %s advertised unsafe file path: %r", + identifier, + rel_path, + ) + return None + text = self._fetch_text(f"{parsed['skill_url']}/{safe_rel_path}") + if text is None: + return None + downloaded[safe_rel_path] = text + + if "SKILL.md" not in downloaded: + return None + + return SkillBundle( + name=skill_name, + files=downloaded, + source="well-known", + identifier=self._wrap_identifier(parsed["base_url"], skill_name), + metadata={ + "index_url": parsed["index_url"], + "base_url": parsed["base_url"], + "endpoint": parsed["skill_url"], + "files": files, + }, + ) + + def _query_to_index_url(self, query: str) -> str | None: + query = query.strip() + if not query.startswith(("http://", "https://")): + return None + if query.endswith("/index.json"): + return query + if f"{self._base_path}/" in query: + base_url = query.split(f"{self._base_path}/", 1)[0] + self._base_path + return f"{base_url}/index.json" + return query.rstrip("/") + f"{self._base_path}/index.json" + + def _parse_identifier(self, identifier: str) -> dict | None: + raw = identifier[len("well-known:"):] if identifier.startswith("well-known:") else identifier + if not raw.startswith(("http://", "https://")): + return None + + parsed_url = urlparse(raw) + clean_url = urlunparse(parsed_url._replace(fragment="")) + fragment = parsed_url.fragment + + if clean_url.endswith("/index.json"): + if not fragment: + return None + base_url = clean_url[:-len("/index.json")] + skill_name = fragment + skill_url = f"{base_url}/{skill_name}" + return { + "index_url": clean_url, + "base_url": base_url, + "skill_name": skill_name, + "skill_url": skill_url, + } + + if clean_url.endswith("/SKILL.md"): + skill_url = clean_url[:-len("/SKILL.md")] + else: + skill_url = clean_url.rstrip("/") + + if f"{self._base_path}/" not in skill_url: + return None + + base_url, skill_name = skill_url.rsplit("/", 1) + return { + "index_url": f"{base_url}/index.json", + "base_url": base_url, + "skill_name": skill_name, + "skill_url": skill_url, + } + + def _parse_index(self, index_url: str) -> dict | None: + try: + resp = httpx.get(index_url, timeout=20, follow_redirects=True) + if resp.status_code != 200: + return None + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return None + + skills = data.get("skills", []) if isinstance(data, dict) else [] + if not isinstance(skills, list): + return None + + return { + "index_url": index_url, + "base_url": index_url[:-len("/index.json")], + "skills": skills, + } + + def _index_entry(self, index_url: str, skill_name: str) -> dict | None: + parsed = self._parse_index(index_url) + if not parsed: + return None + for entry in parsed["skills"]: + if isinstance(entry, dict) and entry.get("name") == skill_name: + return entry + return None + + @staticmethod + def _fetch_text(url: str) -> str | None: + try: + resp = httpx.get(url, timeout=20, follow_redirects=True) + if resp.status_code == 200: + return resp.text + except httpx.HTTPError: + return None + return None + + @staticmethod + def _wrap_identifier(base_url: str, skill_name: str) -> str: + return f"well-known:{base_url.rstrip('/')}/{skill_name}"