From 96b3a624a676eedf3586b284c7b0fe8ec19e96bd Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 15 Aug 2026 17:57:45 -0400 Subject: [PATCH] Skills: add marketplace.json source adapter + ComposioHQ as a default source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsing a large skill collection by scraping every SKILL.md is O(N) raw fetches — ComposioHQ's 864 files measured at ~159s, far past an inline (zero-daemon) browse request's timeout. Repos increasingly publish a machine-readable `.claude-plugin/marketplace.json` (the Claude plugin-marketplace standard); reading it is ONE fetch for the whole curated list. - Tiger_Skill_Source_Marketplace — reads `.claude-plugin/marketplace.json`, handles flat (`source`) and grouped (`skills[]`) plugins, resolves paths against a configurable repo root (traversal-refused), and emits the same normalized entries as SkillsDir (install/dedup/search unchanged). A network seam (`_manifestRaw`) makes it unit-testable offline. - Wire ComposioHQ/awesome-claude-skills as a second built-in source via the new adapter — 107 skills in one fetch. Community-curated, NOT an endorsement (Tiger browses, never vouches; review-before-install stands). - Tests: flat+grouped parsing, root resolution, traversal/empty rejection, both built-in sources registered; seed both built-in caches so no unit test hits the network. 9 Skill tests, 817 unit total, green. - Regenerate CAPABILITIES.md; document the two adapter kinds + the registry landscape in TIGERSKILLS.md §2a. Co-Authored-By: Claude Opus 4.8 --- CAPABILITIES.md | 3 +- TIGERSKILLS.md | 40 ++++++-- library/Tiger/Skill/Index.php | 3 + library/Tiger/Skill/Source/Marketplace.php | 106 +++++++++++++++++++++ tests/Unit/Skill/SkillIndexTest.php | 71 ++++++++++++-- 5 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 library/Tiger/Skill/Source/Marketplace.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 47a3422..7835dd8 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**174 classes** across **31 capabilities** · **17 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**175 classes** across **31 capabilities** · **17 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -194,6 +194,7 @@ - **Tiger_Skill_Index** `@api` — the internal, searchable skill catalog built by running the source adapters. · `library/Tiger/Skill/Index.php` - **Tiger_Skill_Source** `@api` — a browse adapter for one supported skill repo (scan + normalize, NOT endorse). · `library/Tiger/Skill/Source.php` +- **Tiger_Skill_Source_Marketplace** `@api` — the adapter for a repo that publishes a machine-readable **`.claude-plugin/marketplace.json`** manifest (the Claude plugin-marketplace standard). · `library/Tiger/Skill/Source/Marketplace.php` - **Tiger_Skill_Source_SkillsDir** `@api` — the adapter for the common "collection" layout: a repo whose skills live as `//SKILL.md` folders (e.g. · `library/Tiger/Skill/Source/SkillsDir.php` - **Tiger_Skill_Source_Url** `@api` — the "paste a GitHub URL" adapter. · `library/Tiger/Skill/Source/Url.php` diff --git a/TIGERSKILLS.md b/TIGERSKILLS.md index da97dc7..2daa790 100644 --- a/TIGERSKILLS.md +++ b/TIGERSKILLS.md @@ -69,9 +69,36 @@ curator's blessing. - **Search the supported repos** — each *supported* well-known repo gets a **`Tiger_Skill_Source`** adapter that knows THAT repo's layout, scans it, and normalizes its skills into one list. **`Tiger_Skill_Index`** runs the adapters, caches each independently (last-good on outage — the `Tiger_Module_Registry` pattern), - then merges + de-dupes + searches. The built-in supported source is the official **`anthropics/skills`** - collection (`Tiger_Skill_Source_SkillsDir` — the `skills//SKILL.md` layout); more are added as config - sources or by pasting a URL. **"Supported" = "Tiger can read its layout," NOT "Tiger trusts its skills."** + then merges + de-dupes + searches. Two built-in supported sources ship: + - **`anthropics/skills`** — the official collection, via **`Tiger_Skill_Source_SkillsDir`** (the raw + `skills//SKILL.md` layout: one git-trees call + a raw fetch per `SKILL.md`). Right for an + official-sized repo (~17 skills). + - **`ComposioHQ/awesome-claude-skills`** — a 100+-skill community collection, via + **`Tiger_Skill_Source_Marketplace`** (§2a). Community-curated, **not** a Tiger endorsement. + + More are added as config sources or by pasting a URL. **"Supported" = "Tiger can read its layout," NOT + "Tiger trusts its skills."** + +#### 2a. Two adapter kinds — raw scan vs. a machine-readable manifest + +A big collection can't be scanned file-by-file inside the inline (zero-daemon) browse request — 800+ raw +`SKILL.md` fetches is minutes of wall-clock and would time out. The fix is to read the repo's **manifest** +when it has one: + +| Adapter | Reads | Cost | Use when | +|---|---|---|---| +| **`Tiger_Skill_Source_SkillsDir`** | every `//SKILL.md` (git-trees + a raw fetch each) | O(N) fetches | small repos, no manifest | +| **`Tiger_Skill_Source_Marketplace`** | the repo's **`.claude-plugin/marketplace.json`** (name+description+source per plugin) | **one fetch** | any repo publishing the Claude plugin-marketplace manifest | + +The **`.claude-plugin/marketplace.json`** standard (both `anthropics/skills` and ComposioHQ ship one) is the +emerging machine-readable index — one HTTP call yields the whole curated list, so a 107-skill collection +browses instantly and can't time out. The adapter handles both a **flat** plugin (`source` = the skill +folder) and a **grouped** plugin (`skills[]` = many folders sharing a description), resolves paths against a +configurable repo `root` (traversal-refused), and emits the same normalized entries as `SkillsDir` — so +install/dedup/search are identical. **Roadmap:** the sibling registry conventions surfacing now +(`.well-known/agent-skills/index.json`; the SkillMD `/v1/search`, OpenAgentSkill, skillregistry.io APIs) are +the same idea behind an endpoint instead of a repo file — a thin `Tiger_Skill_Source_Index` adapter when one +proves worth supporting. (`index.dev`, checked, is a dev-hiring marketplace — unrelated.) A normalized entry carries **provenance** (`sourceLabel` — "Anthropic Skills", "From github.com/…"), the `name` + `description`, and the `repo`/`ref`/`path` — so the user can **review the `SKILL.md` before @@ -84,9 +111,10 @@ catalog is the **adapters + index** above, not Tiger's own registry: **Tiger is owner.** > **BUILT (Phase 1):** `Tiger_Skill_Source` (+ `parseFrontmatter`, spec-correct: name + description, block -> scalars), `Tiger_Skill_Source_SkillsDir`, `Tiger_Skill_Source_Url`, `Tiger_Skill_Index` (scan / cache / -> merge / search). Proven live against `anthropics/skills` (17 skills). Next: install + the active-set (§3) + -> the admin surface (§6). +> scalars), `Tiger_Skill_Source_SkillsDir`, `Tiger_Skill_Source_Marketplace` (the `.claude-plugin/marketplace.json` +> reader, §2a), `Tiger_Skill_Source_Url`, `Tiger_Skill_Index` (scan / cache / merge / search). Two built-in +> sources: `anthropics/skills` (17, SkillsDir) + `ComposioHQ/awesome-claude-skills` (107, Marketplace). +> Install + the active-set (§3) + the admin surface (§6) shipped in increment 2. --- diff --git a/library/Tiger/Skill/Index.php b/library/Tiger/Skill/Index.php index 4f8a4eb..167cf95 100644 --- a/library/Tiger/Skill/Index.php +++ b/library/Tiger/Skill/Index.php @@ -45,6 +45,9 @@ public static function sources() $out = []; // Built-in: the official Anthropic Agent Skills collection (skills/*/SKILL.md). $out['anthropic-skills'] = new Tiger_Skill_Source_SkillsDir('anthropic-skills', 'Anthropic Skills', 'anthropics/skills', 'main', 'skills'); + // Built-in: the Composio community collection — 100+ skills via its .claude-plugin/marketplace.json + // (ONE fetch, no per-SKILL.md scrape). Community-curated, NOT a Tiger endorsement — review before install. + $out['composio-skills'] = new Tiger_Skill_Source_Marketplace('composio-skills', 'Composio Skills (community)', 'ComposioHQ/awesome-claude-skills', 'master', 'composio-skills/.claude-plugin/marketplace.json', ''); // Config-declared sources: tiger.agent.skills.sources. = { label, repo|url, ref?, base?, enabled? }. $cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; diff --git a/library/Tiger/Skill/Source/Marketplace.php b/library/Tiger/Skill/Source/Marketplace.php new file mode 100644 index 0000000..476964c --- /dev/null +++ b/library/Tiger/Skill/Source/Marketplace.php @@ -0,0 +1,106 @@ +id = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $id)); + $this->label = (string) $label; + if (strpos($repo, 'github.com') !== false) { + $p = Tiger_Module_Github::parseRepo($repo); + $repo = ($p && !empty($p['org']) && !empty($p['repo'])) ? $p['org'] . '/' . $p['repo'] : $repo; + } + $this->repo = trim((string) $repo, '/'); + $this->ref = (string) ($ref ?: 'main'); + $this->manifest = ltrim(trim((string) $manifest), '/'); + $this->root = trim((string) $root, '/'); + } + + public function id() { return $this->id; } + public function label() { return $this->label; } + + public function scan() + { + $raw = $this->_manifestRaw(); + $data = $raw ? json_decode((string) $raw, true) : null; + if (!is_array($data) || empty($data['plugins']) || !is_array($data['plugins'])) { return []; } + + $out = []; + foreach ($data['plugins'] as $plugin) { + if (!is_array($plugin)) { continue; } + $desc = (string) ($plugin['description'] ?? ''); + + // Grouped plugin: skills[] lists many folder paths, each a skill (shares the group description). + if (!empty($plugin['skills']) && is_array($plugin['skills'])) { + foreach ($plugin['skills'] as $s) { + $path = $this->_resolve((string) $s); + if ($path === '') { continue; } + $out[] = $this->entry($this->repo, $this->ref, $path, ['name' => basename($path), 'description' => $desc]); + } + continue; + } + // Flat plugin: a single source folder; the plugin name is the skill name. + if (!empty($plugin['source'])) { + $path = $this->_resolve((string) $plugin['source']); + if ($path === '') { continue; } + $name = !empty($plugin['name']) ? (string) $plugin['name'] : basename($path); + $out[] = $this->entry($this->repo, $this->ref, $path, ['name' => $name, 'description' => $desc]); + } + } + return $out; + } + + /** Resolve a manifest `source`/`skills[]` path (usually `./name`) to a repo-relative folder path. */ + protected function _resolve($src) + { + $src = trim((string) $src); + if ($src === '') { return ''; } + $src = preg_replace('#^\./#', '', $src); // strip a leading ./ + $src = trim($src, '/'); + if ($src === '' || strpos($src, '..') !== false) { return ''; } // no traversal + return $this->root !== '' ? $this->root . '/' . $src : $src; + } + + /** Network seam (overridden in tests): fetch the manifest's raw bytes. */ + protected function _manifestRaw() + { + [$org, $repo] = array_pad(explode('/', $this->repo, 2), 2, ''); + if ($org === '' || $repo === '' || $this->manifest === '') { return ''; } + return (string) @Tiger_Module_Github::fetchRaw($org, $repo, $this->ref, $this->manifest); + } +} diff --git a/tests/Unit/Skill/SkillIndexTest.php b/tests/Unit/Skill/SkillIndexTest.php index 535206e..76148de 100644 --- a/tests/Unit/Skill/SkillIndexTest.php +++ b/tests/Unit/Skill/SkillIndexTest.php @@ -9,6 +9,7 @@ use Tiger\Tests\Support\UnitTestCase; use Tiger_Skill_Index; use Tiger_Skill_Source; +use Tiger_Skill_Source_Marketplace; use Tiger_Skill_Source_SkillsDir; use Tiger_Skill_Source_Url; @@ -20,25 +21,32 @@ */ #[CoversClass(Tiger_Skill_Source::class)] #[CoversClass(Tiger_Skill_Source_Url::class)] +#[CoversClass(Tiger_Skill_Source_Marketplace::class)] #[CoversClass(Tiger_Skill_Index::class)] final class SkillIndexTest extends UnitTestCase { - private string $builtinCache = ''; + /** Every built-in source id — each gets a FRESH empty cache so no test touches the network. */ + private const BUILTIN_SOURCES = ['anthropic-skills', 'composio-skills']; + + private array $builtinCaches = []; protected function setUp(): void { parent::setUp(); Tiger_Skill_Index::clearSources(); - // Short-circuit the built-in Anthropic source's network scan with a FRESH, empty cache. - $this->builtinCache = APPLICATION_ROOT . '/var/cache/skills/anthropic-skills.json'; - @mkdir(dirname($this->builtinCache), 0775, true); - file_put_contents($this->builtinCache, json_encode(['at' => time(), 'entries' => []])); + // Short-circuit every built-in source's network scan with a FRESH, empty cache. + @mkdir(APPLICATION_ROOT . '/var/cache/skills', 0775, true); + foreach (self::BUILTIN_SOURCES as $id) { + $file = APPLICATION_ROOT . '/var/cache/skills/' . $id . '.json'; + file_put_contents($file, json_encode(['at' => time(), 'entries' => []])); + $this->builtinCaches[] = $file; + } } protected function tearDown(): void { Tiger_Skill_Index::clearSources(); - @unlink($this->builtinCache); + foreach ($this->builtinCaches as $file) { @unlink($file); } parent::tearDown(); } @@ -116,10 +124,57 @@ public function index_merges_a_source_and_searches_it(): void } #[Test] - public function built_in_anthropic_source_is_registered(): void + public function built_in_sources_are_registered(): void + { + $sources = Tiger_Skill_Index::sources(); + $this->assertArrayHasKey('anthropic-skills', $sources, 'the official collection ships as a supported source'); + $this->assertArrayHasKey('composio-skills', $sources, 'the Composio community collection ships as a supported source'); + } + + // ----- marketplace.json adapter (one fetch, no per-SKILL.md scrape) ----------------------------- + + #[Test] + public function marketplace_adapter_reads_flat_and_grouped_plugins(): void + { + $manifest = json_encode(['plugins' => [ + ['name' => 'brand-guidelines', 'description' => 'Apply brand colors.', 'source' => './brand-guidelines'], + ['name' => 'docs', 'description' => 'Document suite.', 'skills' => ['./skills/pdf', './skills/docx']], + ['name' => 'evil', 'description' => 'nope', 'source' => '../../etc/passwd'], // traversal → dropped + ['name' => 'empty'], // no source/skills → skipped + ]]); + $entries = (new MarketplaceStub($manifest, 'acme/skills', 'master'))->scan(); + + $byKey = array_column($entries, null, 'key'); + // flat: name from the plugin, path from ./source at repo root + $this->assertArrayHasKey('mp:brand-guidelines', $byKey); + $this->assertSame('brand-guidelines', $byKey['mp:brand-guidelines']['path']); + $this->assertSame('Apply brand colors.', $byKey['mp:brand-guidelines']['description']); + // grouped: one entry per skills[] path, sharing the group description; name from the folder + $this->assertArrayHasKey('mp:pdf', $byKey); + $this->assertSame('skills/pdf', $byKey['mp:pdf']['path']); + $this->assertSame('Document suite.', $byKey['mp:docx']['description']); + // traversal + empty are refused + $this->assertArrayNotHasKey('mp:passwd', $byKey); + $this->assertCount(3, $entries, 'brand-guidelines + pdf + docx; traversal and empty dropped'); + } + + #[Test] + public function marketplace_adapter_resolves_source_against_a_root(): void + { + $manifest = json_encode(['plugins' => [['name' => 'x', 'description' => 'd', 'source' => './x']]]); + $entries = (new MarketplaceStub($manifest, 'acme/skills', 'main', 'composio-skills/.claude-plugin/marketplace.json', 'sub'))->scan(); + $this->assertSame('sub/x', $entries[0]['path'], 'a non-empty root prefixes the resolved source path'); + } +} + +/** A Marketplace adapter whose manifest bytes are canned (the network seam overridden) — id is fixed 'mp'. */ +final class MarketplaceStub extends Tiger_Skill_Source_Marketplace +{ + public function __construct(private string $raw, string $repo, string $ref, string $manifest = '.claude-plugin/marketplace.json', string $root = '') { - $this->assertArrayHasKey('anthropic-skills', Tiger_Skill_Index::sources(), 'the official collection ships as a supported source'); + parent::__construct('mp', 'Marketplace Stub', $repo, $ref, $manifest, $root); } + protected function _manifestRaw() { return $this->raw; } } /** A network-free source that yields canned entries through the base's normalization. */