Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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 `<base>/<name>/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`

Expand Down
40 changes: 34 additions & 6 deletions TIGERSKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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/<name>/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 `<base>/<name>/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
Expand All @@ -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.

---

Expand Down
3 changes: 3 additions & 0 deletions library/Tiger/Skill/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id> = { label, repo|url, ref?, base?, enabled? }.
$cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null;
Expand Down
106 changes: 106 additions & 0 deletions library/Tiger/Skill/Source/Marketplace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Skill_Source_Marketplace — the adapter for a repo that publishes a machine-readable
* **`.claude-plugin/marketplace.json`** manifest (the Claude plugin-marketplace standard). ONE fetch
* yields every skill's `name` + `description` + `source` path, so a large collection is scanned in a
* single HTTP call instead of one raw fetch per `SKILL.md` (the SkillsDir path). This is the source kind
* that scales — a 100+-skill community repo browses instantly and can't time out the inline scan.
*
* A manifest is `{ plugins: [ {name, description, source, skills?[]}, … ] }`. Two entry shapes are handled:
* a **flat** plugin (`source` = the skill folder → one entry), and a **grouped** plugin (`skills[]` = many
* folder paths → one entry each, sharing the group description). `source`/`skills[]` paths are resolved
* against a configurable repo `root` (they're usually `./name` relative to the manifest's collection dir).
*
* Provenance only — Tiger reads the manifest, it does not endorse the skills. The user reviews each
* SKILL.md before installing (the same wall as every other source).
*
* @api
* @see Tiger_Skill_Source
* @see Tiger_Skill_Source_SkillsDir the raw scan-every-SKILL.md adapter (for repos with no manifest)
*/
class Tiger_Skill_Source_Marketplace extends Tiger_Skill_Source
{
protected $id;
protected $label;
protected $repo; // owner/repo
protected $ref; // branch or tag
protected $manifest; // path to the marketplace.json within the repo
protected $root; // repo dir that plugin `source`/`skills[]` paths resolve against ('' = repo root)

/**
* @param string $id stable adapter id ([a-z0-9-])
* @param string $label human provenance label
* @param string $repo owner/repo (or a full GitHub URL)
* @param string $ref branch/tag to read (default 'main')
* @param string $manifest path to the manifest in the repo (default '.claude-plugin/marketplace.json')
* @param string $root base dir the plugin source paths resolve against (default '' = repo root)
*/
public function __construct($id, $label, $repo, $ref = 'main', $manifest = '.claude-plugin/marketplace.json', $root = '')
{
$this->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);
}
}
71 changes: 63 additions & 8 deletions tests/Unit/Skill/SkillIndexTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
}

Expand Down Expand Up @@ -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. */
Expand Down
Loading