diff --git a/CAPABILITIES.md b/CAPABILITIES.md index e29c5f5..47a3422 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. -**173 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). +**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). ## Capabilities (`library/Tiger`) @@ -187,6 +187,7 @@ - **Tiger_Agent_Provider_OpenAiCompatible** `@api` — the base adapter for every provider that speaks the OpenAI `/chat/completions` wire format. · `library/Tiger/Agent/Provider/OpenAiCompatible.php` - **Tiger_Agent_Provider_OpenRouter** `@api` — OpenRouter: one key, many models (incl. · `library/Tiger/Agent/Provider/OpenRouter.php` - **Tiger_Agent_Scout** `@api` — the agent's EYES: the read twin of the Forge (TIGERAGENT.md §2b). · `library/Tiger/Agent/Scout.php` +- **Tiger_Agent_Skills** `@api` — the INSTALLED side of skills: pull a chosen skill's files into a local, app-owned store, discover what's installed, toggle each on/off, and remove. · `library/Tiger/Agent/Skills.php` - **Tiger_Agent_Tools** `@api` — build the model's tool catalog + system prompt from the LIVE, role- filtered /api surface (TIGERAGENT.md §2, §5a). · `library/Tiger/Agent/Tools.php` ### Agent skills @@ -278,7 +279,7 @@ ## Modules (`modules/*` — activatable features) - **Access** (`access`, plugin) · services: Org, User · `modules/access` -- **Agent** (`agent`, app) · services: Agent, Settings · `modules/agent` +- **Agent** (`agent`, app) · services: Agent, Settings, Skills · `modules/agent` - **Ally** (`ally`, plugin) · services: Scan · `modules/ally` - **Analytics** (`analytics`, app) · services: Analytics, Reports · `modules/analytics` - **Backup** (`backup`, app) · services: Backup · `modules/backup` diff --git a/library/Tiger/Agent/Skills.php b/library/Tiger/Agent/Skills.php new file mode 100644 index 0000000..40f51f7 --- /dev/null +++ b/library/Tiger/Agent/Skills.php @@ -0,0 +1,194 @@ +/` — app-owned + * (survives `composer update`), files-are-source-of-truth (no DB row for a skill). "What's active" is the + * ONLY state, held in a config value (`tiger.agent.skills.active`, the live-override tier) — install ≠ + * activate ≠ remove. The loader (§4) reads the ACTIVE skills' bodies into the agent turn. + * + * @api + * @see Tiger_Skill_Index the browse/search catalog a user installs FROM + */ +class Tiger_Agent_Skills +{ + const ACTIVE_KEY = 'tiger.agent.skills.active'; // config: comma-separated active skill keys + const MAX_FILES = 40; // a skill folder is small; bound the fetch + const MAX_BYTES = 262144; // per file (256KB) + + /** The app-owned install store, or '' pre-boot. */ + public static function dir() + { + return defined('APPLICATION_PATH') ? APPLICATION_PATH . '/skills' : ''; + } + + /** + * Installed skills, each with its live active flag. + * + * @return array> [{key,name,description,sourceLabel,repo,url,active,dir}] + */ + public static function installed() + { + $dir = self::dir(); + if ($dir === '' || !is_dir($dir)) { return []; } + $active = self::active(); + $out = []; + foreach (glob($dir . '/*', GLOB_ONLYDIR) ?: [] as $d) { + $md = $d . '/SKILL.md'; + if (!is_file($md)) { continue; } + $front = Tiger_Skill_Source::parseFrontmatter((string) @file_get_contents($md)); + $meta = is_file($d . '/source.json') ? (array) json_decode((string) @file_get_contents($d . '/source.json'), true) : []; + $key = basename($d); + $out[] = [ + 'key' => $key, + 'name' => $front['name'] ?? $key, + 'description' => $front['description'] ?? '', + 'sourceLabel' => (string) ($meta['sourceLabel'] ?? ''), + 'repo' => (string) ($meta['repo'] ?? ''), + 'url' => (string) ($meta['url'] ?? ''), + 'active' => in_array($key, $active, true), + 'dir' => $d, + ]; + } + usort($out, static function ($a, $b) { return strcasecmp($a['name'], $b['name']); }); + return $out; + } + + /** Is a skill installed (by key)? */ + public static function isInstalled($key) + { + $d = self::dir(); + return $d !== '' && is_file($d . '/' . self::_safeKey($key) . '/SKILL.md'); + } + + /** The installed SKILL.md body (for the "view source" modal / the loader), or ''. */ + public static function body($key) + { + $md = self::dir() . '/' . self::_safeKey($key) . '/SKILL.md'; + return is_file($md) ? (string) @file_get_contents($md) : ''; + } + + // ----- active set (config tier) -------------------------------------------------------------- + + /** The active skill keys. */ + public static function active() + { + try { + $raw = (string) (new Tiger_Model_Config())->get('global', '', self::ACTIVE_KEY); + } catch (Throwable $e) { $raw = ''; } + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + } + + public static function isActive($key) { return in_array(self::_safeKey($key), self::active(), true); } + + /** + * Turn a skill on/off (idempotent). Writes the config active-set; no schema, effective next request. + * + * @return bool the resulting active state + */ + public static function setActive($key, $on) + { + $key = self::_safeKey($key); + $active = self::active(); + $has = in_array($key, $active, true); + if ($on && !$has) { $active[] = $key; } + if (!$on && $has) { $active = array_values(array_diff($active, [$key])); } + (new Tiger_Model_Config())->set('global', '', self::ACTIVE_KEY, implode(',', $active)); + return (bool) $on; + } + + // ----- install / remove ---------------------------------------------------------------------- + + /** + * Install a skill from a normalized browse entry ({source,name,repo,ref,path,sourceLabel,url}): fetch its + * folder's files (SKILL.md + resources) into the store + write provenance meta. Idempotent (re-fetches). + * + * @param array $entry a Tiger_Skill_Source entry + * @return string the installed key + * @throws RuntimeException if the SKILL.md can't be fetched + */ + public static function install(array $entry) + { + $repo = (string) ($entry['repo'] ?? ''); + $path = trim((string) ($entry['path'] ?? ''), '/'); + $ref = (string) ($entry['ref'] ?? 'main'); + [$org, $rname] = array_pad(explode('/', $repo, 2), 2, ''); + if ($org === '' || $rname === '') { throw new RuntimeException('skill.install.bad_repo'); } + + $key = self::_safeKey(($entry['source'] ?? 'src') . '__' . ($entry['name'] ?? basename($path))); + $dest = self::dir() . '/' . $key; + if (self::dir() === '') { throw new RuntimeException('skill.install.no_store'); } + + // List the skill folder's files via one git-trees call; fetch each blob under the path. + $body = @Tiger_Module_Github::get('https://api.github.com/repos/' . $org . '/' . $rname . '/git/trees/' . rawurlencode($ref) . '?recursive=1'); + $tree = $body ? json_decode((string) $body, true) : null; + $prefix = $path !== '' ? $path . '/' : ''; + $files = []; + foreach ((is_array($tree) && !empty($tree['tree'])) ? $tree['tree'] : [] as $node) { + if (($node['type'] ?? '') !== 'blob') { continue; } + $p = (string) ($node['path'] ?? ''); + if ($prefix !== '' && strpos($p, $prefix) !== 0) { continue; } + if (($node['size'] ?? 0) > self::MAX_BYTES) { continue; } + $files[] = $p; + if (count($files) >= self::MAX_FILES) { break; } + } + + @mkdir($dest, 0775, true); + $gotSkillMd = false; + foreach ($files as $p) { + $raw = @Tiger_Module_Github::fetchRaw($org, $rname, $ref, $p); + if ($raw === false || $raw === null) { continue; } + $rel = $prefix !== '' ? substr($p, strlen($prefix)) : $p; + if ($rel === '' || strpos($rel, '..') !== false) { continue; } + $target = $dest . '/' . $rel; + @mkdir(dirname($target), 0775, true); + @file_put_contents($target, $raw); + if (basename($rel) === 'SKILL.md') { $gotSkillMd = true; } + } + if (!$gotSkillMd) { + self::_rrmdir($dest); + throw new RuntimeException('skill.install.no_skillmd'); + } + + @file_put_contents($dest . '/source.json', json_encode([ + 'source' => (string) ($entry['source'] ?? ''), + 'sourceLabel' => (string) ($entry['sourceLabel'] ?? ''), + 'repo' => $repo, + 'ref' => $ref, + 'path' => $path, + 'url' => (string) ($entry['url'] ?? ''), + ])); + return $key; + } + + /** Uninstall a skill (delete its files + drop it from the active set). */ + public static function remove($key) + { + $key = self::_safeKey($key); + self::setActive($key, false); + $d = self::dir() . '/' . $key; + if (is_dir($d)) { self::_rrmdir($d); } + return true; + } + + /** Filesystem/config-safe key (source + name). */ + protected static function _safeKey($key) + { + return preg_replace('/[^A-Za-z0-9._-]/', '-', (string) $key); + } + + protected static function _rrmdir($dir) + { + if (!is_dir($dir)) { return; } + foreach (scandir($dir) ?: [] as $f) { + if ($f === '.' || $f === '..') { continue; } + $p = $dir . '/' . $f; + (is_dir($p) && !is_link($p)) ? self::_rrmdir($p) : @unlink($p); + } + @rmdir($dir); + } +} diff --git a/modules/agent/Bootstrap.php b/modules/agent/Bootstrap.php index 56302b1..3c63092 100644 --- a/modules/agent/Bootstrap.php +++ b/modules/agent/Bootstrap.php @@ -33,5 +33,14 @@ protected function _initAdminSettings() 'resource' => 'Agent_AdminController', 'order' => 45, ]); + // The Skills manager (browse/install/toggle/remove agent skills) — TIGERSKILLS.md. + Tiger_Admin_Settings::register([ + 'key' => 'agent-skills', + 'label' => 'Agent Skills', + 'icon' => 'fa-wand-magic-sparkles', + 'href' => '/agent/skills', + 'resource' => 'Agent_SkillsController', + 'order' => 46, + ]); } } diff --git a/modules/agent/configs/acl.ini b/modules/agent/configs/acl.ini index b96211f..781b64b 100644 --- a/modules/agent/configs/acl.ini +++ b/modules/agent/configs/acl.ini @@ -63,6 +63,16 @@ acl.rules.agent_scout_read.resource = "Tiger_Agent_Scout" acl.rules.agent_scout_read.privilege = "read" acl.rules.agent_scout_read.permission = "allow" +; --- Skills manager: browse/install/toggle/remove agent skills — admin+ (install writes app-owned files) --- +acl.resources.agent_skills_ctrl.resource = "Agent_SkillsController" +acl.resources.agent_skills_svc.resource = "Agent_Service_Skills" +acl.rules.agent_skills_ctrl.role = "admin" +acl.rules.agent_skills_ctrl.resource = "Agent_SkillsController" +acl.rules.agent_skills_ctrl.permission = "allow" +acl.rules.agent_skills_svc.role = "admin" +acl.rules.agent_skills_svc.resource = "Agent_Service_Skills" +acl.rules.agent_skills_svc.permission = "allow" + [staging : production] [testing : production] diff --git a/modules/agent/controllers/SkillsController.php b/modules/agent/controllers/SkillsController.php new file mode 100644 index 0000000..d110afe --- /dev/null +++ b/modules/agent/controllers/SkillsController.php @@ -0,0 +1,21 @@ +view->title = 'Agent Skills — Tiger Admin'; + } +} diff --git a/modules/agent/languages/en/agent.php b/modules/agent/languages/en/agent.php index 1239e5d..018a4e6 100644 --- a/modules/agent/languages/en/agent.php +++ b/modules/agent/languages/en/agent.php @@ -56,4 +56,10 @@ 'agent.aside.approve_all' => 'Approve all', 'agent.aside.thinking' => 'Working…', 'agent.aside.empty' => 'Start a conversation — the agent acts with your permissions.', + 'agent.skills.installed' => 'Skill installed.', + 'agent.skills.install_failed' => 'That skill could not be installed.', + 'agent.skills.none_found' => 'No SKILL.md found at that URL.', + 'agent.skills.enabled' => 'Skill turned on.', + 'agent.skills.disabled' => 'Skill turned off.', + 'agent.skills.removed' => 'Skill removed.', ]; diff --git a/modules/agent/services/Skills.php b/modules/agent/services/Skills.php new file mode 100644 index 0000000..767647b --- /dev/null +++ b/modules/agent/services/Skills.php @@ -0,0 +1,136 @@ +_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + $installed = []; + foreach (Tiger_Agent_Skills::installed() as $s) { $installed[$s['key']] = true; } + + $rows = []; + foreach (Tiger_Skill_Index::search((string) ($params['q'] ?? ''), !empty($params['refresh'])) as $e) { + $key = Agent_Service_Skills::installKey($e); + $rows[] = [ + 'name' => $e['name'], + 'description' => $e['description'], + 'sourceLabel' => $e['sourceLabel'], // provenance, NOT a vouch + 'repo' => $e['repo'], + 'ref' => $e['ref'], + 'path' => $e['path'], + 'url' => $e['url'], + 'installed' => isset($installed[$key]), + ]; + } + $this->_success(['skills' => $rows, 'sources' => array_map(static function ($s) { + return ['id' => $s->id(), 'label' => $s->label()]; + }, array_values(Tiger_Skill_Index::sources()))], null); + } + + /** Installed skills + their active state (drives the "Installed" list). */ + public function installed(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + $this->_success(['skills' => Tiger_Agent_Skills::installed()], null); + } + + /** + * Install a skill — from a browse entry (`repo`/`ref`/`path`/`name`/`source`) or a pasted `url`. + * + * @param array $params either a browse entry's fields, or `url` + * @return void + */ + public function install(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + try { + $keys = []; + if (!empty($params['url'])) { + foreach ((new Tiger_Skill_Source_Url((string) $params['url']))->scan() as $e) { + $keys[] = Tiger_Agent_Skills::install($e); + } + if (!$keys) { $this->_error('agent.skills.none_found'); return; } + } else { + $entry = [ + 'source' => (string) ($params['source'] ?? 'url'), + 'sourceLabel' => (string) ($params['sourceLabel'] ?? ''), + 'name' => (string) ($params['name'] ?? ''), + 'repo' => (string) ($params['repo'] ?? ''), + 'ref' => (string) ($params['ref'] ?? 'main'), + 'path' => (string) ($params['path'] ?? ''), + 'url' => (string) ($params['url'] ?? ''), + ]; + if ($entry['repo'] === '') { $this->_error('core.api.error.general'); return; } + $keys[] = Tiger_Agent_Skills::install($entry); + } + $this->_success(['installed' => $keys, 'skills' => Tiger_Agent_Skills::installed()], 'agent.skills.installed'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'agent.skills.install_failed'); + } + } + + /** Turn an installed skill on/off. */ + public function toggle(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + $key = (string) ($params['key'] ?? ''); + if ($key === '' || !Tiger_Agent_Skills::isInstalled($key)) { $this->_error('core.api.error.general'); return; } + $on = !empty($params['active']) && $params['active'] !== '0' && $params['active'] !== 'false'; + Tiger_Agent_Skills::setActive($key, $on); + $this->_success(['key' => $key, 'active' => $on], $on ? 'agent.skills.enabled' : 'agent.skills.disabled'); + } + + /** Uninstall a skill (files + active set). */ + public function remove(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + $key = (string) ($params['key'] ?? ''); + if ($key === '' || !Tiger_Agent_Skills::isInstalled($key)) { $this->_error('core.api.error.general'); return; } + Tiger_Agent_Skills::remove($key); + $this->_success(['key' => $key], 'agent.skills.removed'); + } + + /** + * The SKILL.md source, for the review-before-install modal — an installed one, or a not-yet-installed + * browse entry fetched live (read-before-run is the whole point). + * + * @param array $params either `key` (installed) or `repo`/`ref`/`path` (browse) + * @return void + */ + public function source(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + $key = (string) ($params['key'] ?? ''); + if ($key !== '' && Tiger_Agent_Skills::isInstalled($key)) { + $this->_success(['source' => Tiger_Agent_Skills::body($key)], null); + return; + } + $repo = (string) ($params['repo'] ?? ''); + [$org, $rname] = array_pad(explode('/', $repo, 2), 2, ''); + $path = trim((string) ($params['path'] ?? ''), '/'); + if ($org === '' || $rname === '') { $this->_error('core.api.error.general'); return; } + $raw = @Tiger_Module_Github::fetchRaw($org, $rname, (string) ($params['ref'] ?? 'main'), $path . '/SKILL.md'); + $this->_success(['source' => $raw !== false ? (string) $raw : ''], null); + } + + /** The install key a browse entry would become (mirrors Tiger_Agent_Skills::_safeKey(source__name)). */ + public static function installKey(array $entry) + { + return preg_replace('/[^A-Za-z0-9._-]/', '-', ($entry['source'] ?? 'src') . '__' . ($entry['name'] ?? '')); + } +} diff --git a/modules/agent/views/scripts/skills/index.phtml b/modules/agent/views/scripts/skills/index.phtml new file mode 100644 index 0000000..f9333f3 --- /dev/null +++ b/modules/agent/views/scripts/skills/index.phtml @@ -0,0 +1,155 @@ + +
+
+

Agent Skills

+

Installable know-how for the AI agent. Tiger browses these repos — it does not vouch for them; review a skill before you install and turn it on.

+
+
+ +
+
+
+
Browse skills
+
+
+ + +
+
+ +
+
+
+
Add from a GitHub URL
+
+
+ + +
+
Any repo, branch, subfolder, or a link straight to a SKILL.md.
+
+
+
+ +
+
+
Installed
+
+
+ +
+
+
+
+ + + + diff --git a/tests/Integration/Agent/SkillsTest.php b/tests/Integration/Agent/SkillsTest.php new file mode 100644 index 0000000..d2486ff --- /dev/null +++ b/tests/Integration/Agent/SkillsTest.php @@ -0,0 +1,118 @@ +/). + $this->skillDir = Tiger_Agent_Skills::dir() . '/anthropic-skills__demo'; + @mkdir($this->skillDir, 0775, true); + file_put_contents($this->skillDir . '/SKILL.md', "---\nname: demo\ndescription: A demo skill.\n---\nDo the thing."); + file_put_contents($this->skillDir . '/source.json', json_encode(['sourceLabel' => 'Anthropic Skills', 'repo' => 'anthropics/skills'])); + } + + protected function tearDown(): void + { + foreach (['/SKILL.md', '/source.json'] as $f) { @unlink($this->skillDir . $f); } + @rmdir($this->skillDir); + @rmdir(Tiger_Agent_Skills::dir()); + parent::tearDown(); + } + + private function call(string $action, array $params = []): object + { + return (new Agent_Service_Skills(['action' => $action] + $params))->getResponse(); + } + + #[Test] + public function discovers_an_installed_skill(): void + { + $rows = Tiger_Agent_Skills::installed(); + $demo = array_values(array_filter($rows, fn($s) => $s['key'] === 'anthropic-skills__demo')); + $this->assertNotEmpty($demo, 'the seeded skill is discovered'); + $this->assertSame('demo', $demo[0]['name']); + $this->assertSame('A demo skill.', $demo[0]['description']); + $this->assertFalse($demo[0]['active'], 'install != activate — off by default'); + $this->assertTrue(Tiger_Agent_Skills::isInstalled('anthropic-skills__demo')); + } + + #[Test] + public function active_set_round_trips_through_config(): void + { + $this->assertFalse(Tiger_Agent_Skills::isActive('anthropic-skills__demo')); + Tiger_Agent_Skills::setActive('anthropic-skills__demo', true); + $this->assertTrue(Tiger_Agent_Skills::isActive('anthropic-skills__demo'), 'turning it on persists to config'); + $this->assertContains('anthropic-skills__demo', Tiger_Agent_Skills::active()); + Tiger_Agent_Skills::setActive('anthropic-skills__demo', false); + $this->assertFalse(Tiger_Agent_Skills::isActive('anthropic-skills__demo'), 'and off again'); + } + + #[Test] + public function remove_deletes_files_and_clears_active(): void + { + Tiger_Agent_Skills::setActive('anthropic-skills__demo', true); + Tiger_Agent_Skills::remove('anthropic-skills__demo'); + $this->assertFalse(Tiger_Agent_Skills::isInstalled('anthropic-skills__demo'), 'files gone'); + $this->assertNotContains('anthropic-skills__demo', Tiger_Agent_Skills::active(), 'dropped from the active set'); + } + + // ----- service --------------------------------------------------------------------------------- + + #[Test] + public function service_is_denied_for_a_guest(): void + { + $this->login('anon', 'org-test', 'guest'); + foreach (['search', 'installed', 'install', 'toggle', 'remove'] as $action) { + $res = $this->call($action); + $this->assertSame(0, (int) $res->result, "guest denied on {$action}"); + $this->assertStringContainsString('not_allowed', json_encode($res->messages)); + } + } + + #[Test] + public function service_lists_toggles_and_removes(): void + { + $this->loginAs('admin'); + + $list = $this->call('installed'); + $this->assertSame(1, (int) $list->result); + $this->assertNotEmpty(array_filter($list->data['skills'], fn($s) => $s['key'] === 'anthropic-skills__demo')); + + $on = $this->call('toggle', ['key' => 'anthropic-skills__demo', 'active' => 1]); + $this->assertSame(1, (int) $on->result); + $this->assertTrue($on->data['active']); + $this->assertTrue(Tiger_Agent_Skills::isActive('anthropic-skills__demo')); + + $rm = $this->call('remove', ['key' => 'anthropic-skills__demo']); + $this->assertSame(1, (int) $rm->result); + $this->assertFalse(Tiger_Agent_Skills::isInstalled('anthropic-skills__demo')); + } + + #[Test] + public function toggle_and_remove_reject_an_unknown_key(): void + { + $this->loginAs('admin'); + $this->assertSame(0, (int) $this->call('toggle', ['key' => 'nope__nope', 'active' => 1])->result); + $this->assertSame(0, (int) $this->call('remove', ['key' => 'nope__nope'])->result); + } +}