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
2 changes: 1 addition & 1 deletion modules/agent/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ protected function _initAdminSettings()
'key' => 'agent-skills',
'label' => 'Agent Skills',
'icon' => 'fa-wand-magic-sparkles',
'href' => '/agent/skills',
'href' => '/admin/settings/agent/skills', // pretty alias (routes.ini); /agent/skills still works
'resource' => 'Agent_SkillsController',
'order' => 46,
]);
Expand Down
17 changes: 17 additions & 0 deletions modules/agent/configs/routes.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
; SPDX-License-Identifier: BSD-3-Clause
; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
;
; Pretty admin URL for the Agent Skills manager (ROUTING.md — folds into the config cascade). The
; canonical /agent/skills path keeps working for free; this is the /admin/settings/<module>/<screen>
; alias (an interim one-off ahead of the general admin-URL normalization to /admin/settings/[module]/*).
; A static route resolves to a dispatchable controller, so the RouteOverride plugin leaves it alone even
; though it sits under the reserved /admin prefix.
[production]
resources.router.routes.agentSkillsAdmin.type = "Zend_Controller_Router_Route_Static"
resources.router.routes.agentSkillsAdmin.route = "admin/settings/agent/skills"
resources.router.routes.agentSkillsAdmin.defaults.module = "agent"
resources.router.routes.agentSkillsAdmin.defaults.controller = "skills"
resources.router.routes.agentSkillsAdmin.defaults.action = "index"
[staging : production]
[testing : production]
[development : production]
76 changes: 76 additions & 0 deletions modules/agent/services/Skills.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,82 @@ public function installed(array $params): void
$this->_success(['skills' => Tiger_Agent_Skills::installed()], null);
}

/**
* DataTables source — the ONE grid: the browse catalog merged with what's installed, so a row's status +
* action controls tell you whether it's installed (like the Modules screen). Installed skills are pinned
* to the top (then active-first, then by name). Search filters name/description/provenance; `refresh`
* re-scans the sources (bypass the per-source cache). Cached scans, merged + paginated in PHP (mixed
* origins, no shared DB order).
*
* @param array $params DataTables request (+ optional `refresh`)
* @return void
*/
public function datatable(array $params): void
{
if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; }

$dt = $this->_dtParams($params);
$search = strtolower(trim((string) $dt['search']));
$refresh = !empty($params['refresh']);

// Installed skills keyed by install key (key == safeKey(source__name), which installKey() mirrors).
$installed = [];
foreach (Tiger_Agent_Skills::installed() as $s) { $installed[$s['key']] = $s; }

$items = [];
$seen = [];
// The browse catalog (per-source cached; only the sources scan hits the network, and only on refresh).
foreach (Tiger_Skill_Index::all($refresh) as $e) {
$key = Agent_Service_Skills::installKey($e);
$inst = $installed[$key] ?? null;
$items[] = $this->_row($key, (string) $e['source'], $e['name'], $e['description'], $e['sourceLabel'],
$e['repo'], $e['ref'], $e['path'], $e['url'], $inst !== null, $inst !== null && !empty($inst['active']));
$seen[$key] = true;
}
// Installed but not in any catalog (a pasted-URL install, or a source that's since delisted).
foreach ($installed as $key => $s) {
if (isset($seen[$key])) { continue; }
$items[] = $this->_row($key, '', $s['name'], $s['description'], $s['sourceLabel'],
(string) $s['repo'], '', '', (string) $s['url'], true, !empty($s['active']));
}

if ($search !== '') {
$items = array_values(array_filter($items, static function ($r) use ($search) {
return strpos(strtolower($r['name'] . ' ' . $r['description'] . ' ' . $r['sourceLabel']), $search) !== false;
}));
}

// Pin installed to the top → active-first within installed → then by name.
usort($items, static function ($a, $b) {
if ($a['installed'] !== $b['installed']) { return $a['installed'] ? -1 : 1; }
if ($a['installed'] && $a['active'] !== $b['active']) { return $a['active'] ? -1 : 1; }
return strcasecmp($a['name'], $b['name']);
});

$total = count($items);
$len = ($dt['length'] > 0) ? $dt['length'] : 25;
$page = array_slice($items, (int) $dt['start'], $len);
$this->_dtResponse($dt['draw'], $total, $total, $page);
}

/** One normalized grid row (a catalog entry and/or an installed skill). */
private function _row($key, $source, $name, $desc, $sourceLabel, $repo, $ref, $path, $url, $installed, $active): array
{
return [
'key' => $key,
'source' => $source, // adapter id — needed so an Install uses the same key
'name' => (string) $name,
'description' => (string) $desc,
'sourceLabel' => (string) $sourceLabel, // provenance, NOT a vouch
'repo' => (string) $repo,
'ref' => (string) $ref,
'path' => (string) $path,
'url' => (string) $url,
'installed' => (bool) $installed,
'active' => (bool) $active,
];
}

/**
* Install a skill — from a browse entry (`repo`/`ref`/`path`/`name`/`source`) or a pasted `url`.
*
Expand Down
Loading
Loading