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. */