diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py
index ad06eb68..eb916bcd 100644
--- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py
+++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py
@@ -60,6 +60,14 @@ def build_effective_tool_lists(
effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}")
continue
+ if name == "openswarm-skill":
+ policy = builtin_perms.get("Skill", "always_allow")
+ if policy == "always_allow":
+ effective_allowed.append("mcp__openswarm-skill__Skill")
+ else:
+ effective_disallowed.append("mcp__openswarm-skill__Skill")
+ continue
+
if name == "openswarm-web":
# Expose our DDG-backed web tools under an MCP prefix. Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either.
for wt in ("WebSearch", "WebFetch"):
@@ -97,4 +105,7 @@ def build_effective_tool_lists(
for bt in path_gate.CLAUDE_INTERNAL_SCHEDULER_TOOLS:
if bt not in effective_disallowed:
effective_disallowed.append(bt)
+ # The claude_code preset ships its own bare `Skill` tool that reads ~/.claude/skills directly; always withhold it so skills only ever load through our provider-agnostic mcp__openswarm-skill__Skill (or not at all).
+ if "Skill" not in effective_disallowed:
+ effective_disallowed.append("Skill")
return effective_allowed, effective_disallowed
diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py
index 2e311a52..b89f9a40 100644
--- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py
+++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py
@@ -13,6 +13,7 @@ from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names
from backend.apps.agents.manager.prompt.prompt_context import (
build_browser_context,
+ build_installed_skills_catalog,
build_mcp_registry_summary,
build_selected_app_context,
build_selected_settings_context,
@@ -32,12 +33,14 @@ def compose_turn_system_prompt(
# MCP servers and their tool inventories are intentionally NOT injected into the system prompt: the CLI's deferred-tool pool already exposes them by name via ToolSearch, and eagerly listing connected MCPs (account emails, full tool enumerations) here would defeat the deferral and leak every integration into every turn. The model discovers MCPs only when it actively calls ToolSearch; only the gated registry summary goes in.
browser_ctx = build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids)
mcp_registry_ctx = build_mcp_registry_summary(session.allowed_tools, session.active_mcps, get_all_tool_names)
+ skills_catalog_ctx = build_installed_skills_catalog()
composed_prompt = compose_system_prompt(
default_system_prompt,
mode_sys_prompt,
session.system_prompt,
browser_ctx,
mcp_registry_ctx,
+ skills_catalog_ctx,
)
# Pin the agent's notion of "now" to the host wall clock + zone so it can answer day-of-week questions without hallucinating.
diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py
index ee5155d8..6ef93805 100644
--- a/backend/apps/agents/manager/prompt/prompt_context.py
+++ b/backend/apps/agents/manager/prompt/prompt_context.py
@@ -314,6 +314,42 @@ def build_mcp_registry_summary(allowed_tools: List[str], active_mcps: List[str],
return "\n".join(sections)
+@typechecked
+def build_installed_skills_catalog() -> Optional[str]:
+ """Compact catalog of the user's installed skills (id + when-to-use), the
+ surface that lets the model reach for a skill on its own instead of waiting
+ for a manual `/` attach. Lists only LOCALLY installed, non-built-in skills
+ (id + description, never the body), so it costs a few hundred tokens, not the
+ 600k-entry registry. Returns None (no block, no Skill tool) when the Skill
+ tool is denied or nothing's installed, so the catalog and tool stay in sync."""
+ from backend.apps.tools_lib.tools_lib import load_builtin_permissions
+ if load_builtin_permissions().get("Skill", "always_allow") == "deny":
+ return None
+ try:
+ from backend.apps.skills.skills import sync_skills
+ skills = [s for s in sync_skills() if not s.built_in]
+ except Exception:
+ return None
+ if not skills:
+ return None
+
+ # Static preamble kept byte-identical across users so it caches; the per-skill lines below vary per install (same as the mcp registry).
+ lines = [
+ "",
+ "Skills are reusable playbooks the user installed. Each line is a skill id "
+ "and when to use it. When a request matches one, call Skill(id=\"\") to "
+ "load its full instructions, then follow them. Don't load a skill that isn't "
+ "relevant, and don't guess a skill's contents without loading it.",
+ "",
+ "Installed skills:",
+ ]
+ for s in skills:
+ blurb = (s.description or s.name).strip()
+ lines.append(f"- `{s.id}`, {blurb}")
+ lines.append("")
+ return "\n".join(lines)
+
+
# The agent runs on the claude_code preset (kept for its tool scaffolding, safety rules, and the exclude_dynamic_sections prompt-cache win, which a raw-string system prompt would all throw away). The preset opens with "You are Claude Code, Anthropic's official CLI", which leaks into chat. This block is APPENDED after the preset, so being later it overrides that identity. Edit AGENT_NAME / AGENT_BLURB to rebrand. Kept short so it costs ~80 cached tokens, not a wall.
AGENT_NAME = "OpenSwarm"
AGENT_IDENTITY = (
@@ -334,9 +370,9 @@ AGENT_IDENTITY = (
@typechecked
-def compose_system_prompt(default_prompt: Optional[str], mode_prompt: Optional[str], session_prompt: Optional[str], browser_ctx: Optional[str] = None, mcp_registry_ctx: Optional[str] = None) -> Optional[str]:
+def compose_system_prompt(default_prompt: Optional[str], mode_prompt: Optional[str], session_prompt: Optional[str], browser_ctx: Optional[str] = None, mcp_registry_ctx: Optional[str] = None, skills_catalog_ctx: Optional[str] = None) -> Optional[str]:
# Identity always leads so it overrides the preset's Claude Code persona, even when the user has no custom default/mode/session prompt of their own.
- parts = [AGENT_IDENTITY] + [p for p in (default_prompt, mode_prompt, session_prompt, mcp_registry_ctx, browser_ctx) if p]
+ parts = [AGENT_IDENTITY] + [p for p in (default_prompt, mode_prompt, session_prompt, mcp_registry_ctx, skills_catalog_ctx, browser_ctx) if p]
return "\n\n".join(parts)
@@ -403,19 +439,13 @@ def resolve_attached_skills(attached_skills: Optional[List]) -> str:
except Exception:
folder_by_id = {}
+ from backend.apps.skills.skills import format_skill_for_prompt
sections = []
for skill in attached_skills:
name = skill.get("name", "Unknown")
content = skill.get("content", "")
if not content:
continue
- block = f"[Using skill: {name}]\n\n{content}"
folder = folder_by_id.get(skill.get("id", ""))
- if folder:
- block += (
- f"\n\nThis skill bundles supporting files in {folder}. "
- "Read them with your normal file tools (Read / Glob / Bash) when "
- "the steps above call for one; don't guess their contents."
- )
- sections.append(block)
+ sections.append(format_skill_for_prompt(name, content, folder))
return "\n\n".join(sections)
diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py
index 62e3b979..e0d4a529 100644
--- a/backend/apps/agents/manager/register_builtin_mcp_servers.py
+++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py
@@ -89,6 +89,27 @@ def register_builtin_mcp_servers(
"type": "stdio",
}
+ # Skill server: exposes the Skill tool so the agent can load an installed skill on its own (the catalog in the prompt lists what's available). Gated on at least one non-built-in skill existing AND Skill not being denied, so we never offer a tool with an empty catalog. Kept in sync with build_installed_skills_catalog, which omits the catalog under the same conditions.
+ skill_denied = builtin_perms.get("Skill", "always_allow") == "deny"
+ if not skill_denied:
+ try:
+ from backend.apps.skills.skills import sync_skills
+ has_loadable_skill = any(not s.built_in for s in sync_skills())
+ except Exception:
+ has_loadable_skill = False
+ if has_loadable_skill:
+ skill_server_path = os.path.join(agents_dir, "skill_mcp_server.py")
+ mcp_servers["openswarm-skill"] = {
+ "command": sys.executable,
+ "args": [skill_server_path],
+ "env": {
+ "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
+ "OPENSWARM_AUTH_TOKEN": get_auth_token(),
+ "OPENSWARM_PARENT_SESSION_ID": session.id,
+ },
+ "type": "stdio",
+ }
+
# Always-on settings-meta server: SettingsRead / SettingsWrite let the agent read and edit its own OpenSwarm Settings autonomously. The backend (/api/settings-meta) enforces the only two guardrails: it can't disconnect the credential powering this run, and reads come back with secrets redacted. No activation gate, Settings is the agent's own house, not a third-party MCP.
settings_meta_server_path = os.path.join(
agents_dir, "settings_meta_server.py"
diff --git a/backend/apps/agents/skill_mcp_server.py b/backend/apps/agents/skill_mcp_server.py
new file mode 100644
index 00000000..2432f5bd
--- /dev/null
+++ b/backend/apps/agents/skill_mcp_server.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Stdio MCP server exposing the Skill tool: loads an installed skill's instructions on demand."""
+
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
+BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
+LOAD_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/skills/load"
+
+TOOLS = [
+ {
+ "name": "Skill",
+ "description": (
+ "Load the full instructions for an installed skill by its id (the ids "
+ "are listed in the block of your system prompt). Returns the "
+ "skill's SKILL.md body plus a note about any supporting files it bundles. "
+ "Call this when the user's request matches a listed skill, then follow "
+ "the loaded instructions."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The skill id from the catalog (e.g. 'deep-research').",
+ },
+ },
+ "required": ["id"],
+ },
+ },
+]
+
+
+def send_response(id_, result=None, error=None):
+ msg = {"jsonrpc": "2.0", "id": id_}
+ if error is not None:
+ msg["error"] = error
+ else:
+ msg["result"] = result
+ sys.stdout.write(json.dumps(msg) + "\n")
+ sys.stdout.flush()
+
+
+def p_post(url: str, body: dict, timeout: float = 30.0) -> dict:
+ payload = json.dumps(body).encode()
+ headers = {"Content-Type": "application/json"}
+ if BACKEND_AUTH:
+ headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
+ req = urllib.request.Request(
+ url,
+ data=payload,
+ headers=headers,
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ body_txt = e.read().decode(errors="replace") if e.fp else str(e)
+ return {"error": f"HTTP {e.code}: {body_txt[:500]}"}
+ except Exception as e:
+ return {"error": str(e)}
+
+
+def handle_tool_call(tool_name: str, arguments: dict) -> dict:
+ if tool_name == "Skill":
+ skill_id = str(arguments.get("id", "")).strip()
+ if not skill_id:
+ return {"content": [{"type": "text", "text": "Error: id is required"}], "isError": True}
+ r = p_post(LOAD_URL, {"id": skill_id})
+ if "error" in r:
+ return {"content": [{"type": "text", "text": f"Failed to load skill: {r['error']}"}], "isError": True}
+ if not r.get("ok"):
+ available = r.get("available", [])
+ hint = ", ".join(available) if available else "none installed"
+ return {"content": [{"type": "text", "text": f"No skill with id {skill_id!r}. Installed skill ids: {hint}."}], "isError": True}
+ return {"content": [{"type": "text", "text": r.get("text", "")}]}
+
+ return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
+
+
+def main():
+ for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ msg = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ method = msg.get("method")
+ id_ = msg.get("id")
+ params = msg.get("params", {}) or {}
+
+ if method == "initialize":
+ send_response(id_, {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {"tools": {}},
+ "serverInfo": {
+ "name": "openswarm-skill",
+ "version": "1.0.0",
+ },
+ })
+ elif method == "notifications/initialized":
+ pass
+ elif method == "tools/list":
+ send_response(id_, {"tools": TOOLS})
+ elif method == "tools/call":
+ tool_name = params.get("name", "")
+ arguments = params.get("arguments", {}) or {}
+ result = handle_tool_call(tool_name, arguments)
+ send_response(id_, result)
+ elif method == "ping":
+ send_response(id_, {})
+ elif id_ is not None:
+ send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py
index f9ffc7be..2cdf4b17 100644
--- a/backend/apps/skill_registry/skill_registry.py
+++ b/backend/apps/skill_registry/skill_registry.py
@@ -31,6 +31,13 @@ p_cache: dict[str, dict] = {}
p_cache_updated_at: float = 0
p_refresh_task: Optional[asyncio.Task] = None
+# The curated repo's recursive file tree, warmed hourly alongside the catalog. A curated install reads paths from here and fetches contents over raw, so it makes ZERO GitHub API calls in the normal case (the trees API is the 60/hr-limited part); update detection reads per-folder tree SHAs from it too. Empty until the first refresh warms it; install falls back to one live tree call then.
+p_curated_tree: list[dict] = []
+p_curated_tree_at: float = 0
+# Community repo trees for update detection, cached briefly (best-effort) so an updates check on skills.sh-installed skills doesn't refetch every page load nor burn the API.
+P_COMMUNITY_TREE_TTL = 600
+p_community_tree_cache: dict[str, tuple] = {}
+
def disk_cache_path() -> str:
base = os.environ.get("OPENSWARM_SKILL_CACHE_DIR") or os.path.expanduser(
@@ -160,6 +167,28 @@ async def p_fetch_all_skills() -> dict[str, dict]:
return skills
+async def p_warm_curated_tree() -> None:
+ """Best-effort: list the anthropics/skills repo once and cache its file paths so
+ curated installs need ZERO trees-API calls (they read paths here, fetch contents
+ over raw). One cheap call per hourly refresh, reused by every install in that hour.
+ Isolated, a failure here never touches the SKILL.md catalog; install falls back to
+ a live tree call while the cache is cold."""
+ global p_curated_tree, p_curated_tree_at
+ owner, _, repo = REPO.partition("/")
+ try:
+ async with httpx.AsyncClient(timeout=30.0, headers=github_headers()) as client:
+ tree = await p_tree_at(client, owner, repo, BRANCH)
+ if tree:
+ p_curated_tree = tree
+ p_curated_tree_at = time.time()
+ logger.info(f"Curated skill tree warmed: {len(p_tree_blob_paths(tree))} file paths cached")
+ except RegistryRateLimited:
+ # Visible on purpose: a rate-limited warm-up means installs stay on the slow live-call path until the IP's quota resets or a token is set.
+ logger.warning("Curated tree warm-up rate-limited by GitHub (60/hr anon limit). Set GITHUB_TOKEN or wait for the hourly reset; installs use a live tree call meanwhile.")
+ except Exception:
+ logger.debug("curated tree warm-up failed; installs fall back to a live tree call", exc_info=True)
+
+
async def p_refresh_loop():
global p_cache, p_cache_updated_at
backoff = P_RETRY_BACKOFF_START_S
@@ -175,6 +204,8 @@ async def p_refresh_loop():
except Exception as e:
logger.exception(f"Skill registry refresh error: {e}")
if ok:
+ # Warm the curated file-tree on the SLOW path only (never on the fast failure-retry below, which would burn the 60/hr quota in seconds).
+ await p_warm_curated_tree()
# Settle to the slow hourly refresh once we have a good catalog.
backoff = P_RETRY_BACKOFF_START_S
await asyncio.sleep(REFRESH_INTERVAL_S)
@@ -330,12 +361,28 @@ class RegistryRateLimited(Exception):
'try again shortly' rather than a generic failure."""
+def p_tree_blob_paths(tree: list[dict]) -> list[str]:
+ """The blob (file) paths from a GitHub recursive tree, ignoring tree (dir) entries."""
+ return [t["path"] for t in tree if t.get("type") == "blob" and isinstance(t.get("path"), str)]
+
+
+def p_folder_tree_sha(tree: list[dict], folder: str) -> str:
+ """The git tree SHA of `folder` within a recursive tree: a per-folder fingerprint
+ that changes iff something inside it changes, so one skill going stale never marks
+ its siblings stale. '' when the folder isn't present as a tree entry."""
+ for t in tree:
+ if t.get("type") == "tree" and t.get("path") == folder:
+ return t.get("sha", "") or ""
+ return ""
+
+
async def p_tree_at(client: httpx.AsyncClient, owner: str, repo: str, branch: str):
- """(tree | None) for a branch. None on 404 (branch absent); raises on 403."""
+ """(tree | None) for a branch. None on 404 (branch absent); raises on rate limit.
+ GitHub signals the limit as 403 (primary) or 429 (secondary), so treat both."""
r = await client.get(f"{P_GH_API}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1")
if r.status_code == 200:
return r.json().get("tree", [])
- if r.status_code == 403:
+ if r.status_code in (403, 429):
raise RegistryRateLimited()
return None
@@ -361,29 +408,29 @@ async def p_fetch_repo_tree(client: httpx.AsyncClient, owner: str, repo: str) ->
raise ValueError(f"repo {owner}/{repo} has no resolvable default branch")
-async def resolve_community_skill(source: str, skill_id: str) -> dict:
- """Resolve a skills.sh entry (source='owner/repo', skill_id=folder name) to
- its files via the GitHub trees API. Returns name/description/repo_url plus
- {relpath: content} and the list of script files. Fetches text only; never
- runs anything. Raises ValueError on a bad source or a missing skill, and
- RegistryRateLimited when GitHub's anon API is exhausted."""
- owner, _, repo = source.partition("/")
- if not owner or not repo:
- raise ValueError(f"unrecognized source '{source}' (expected owner/repo)")
- async with httpx.AsyncClient(timeout=30.0, headers=github_headers()) as client:
- branch, tree = await p_fetch_repo_tree(client, owner, repo)
- skill_md, members = select_skill_paths(tree, skill_id)
- skill_dir = skill_md[: -len("/SKILL.md")] if "/" in skill_md else ""
- prefix = (skill_dir + "/") if skill_dir else ""
-
- files: dict[str, str] = {}
- for p in members:
- rel = p[len(prefix):] if prefix else p
- raw = await client.get(f"{P_GH_RAW}/{owner}/{repo}/{branch}/{p}")
- if raw.status_code == 200:
- files[rel] = raw.text
- if "SKILL.md" not in files:
- raise ValueError("SKILL.md could not be fetched")
+async def p_build_resolved_skill(
+ client: httpx.AsyncClient,
+ owner: str,
+ repo: str,
+ branch: str,
+ skill_dir: str,
+ members: list[str],
+ skill_id: str,
+ version: str,
+) -> dict:
+ """Fetch every member file of a resolved skill folder and assemble the install
+ payload (relpaths, scripts list, secret scan, provenance). Shared by the community
+ and curated resolvers so both install the WHOLE folder identically. Fetches text
+ only; never runs anything. `version` is the folder's tree SHA, the update fingerprint."""
+ prefix = (skill_dir + "/") if skill_dir else ""
+ files: dict[str, str] = {}
+ for p in members:
+ rel = p[len(prefix):] if prefix else p
+ raw = await client.get(f"{P_GH_RAW}/{owner}/{repo}/{branch}/{p}")
+ if raw.status_code == 200:
+ files[rel] = raw.text
+ if "SKILL.md" not in files:
+ raise ValueError("SKILL.md could not be fetched")
meta, p_body = p_parse_frontmatter(files["SKILL.md"])
# Reuse the .swarm importer's content scan: flag files holding secret-shaped literals (the author's leaked key, or a sketchy skill) so the user sees it before installing from an unvetted repo.
@@ -397,9 +444,55 @@ async def resolve_community_skill(source: str, skill_id: str) -> dict:
"files": files,
"scripts": sorted(rel for rel in files if is_script_path(rel)),
"secret_findings": secret_findings,
+ "source": f"{owner}/{repo}",
+ "folder": skill_dir,
+ "version": version,
}
+async def resolve_community_skill(source: str, skill_id: str) -> dict:
+ """Resolve a skills.sh entry (source='owner/repo', skill_id=folder name) to
+ its files via the GitHub trees API. Returns name/description/repo_url plus
+ {relpath: content} and the list of script files. Fetches text only; never
+ runs anything. Raises ValueError on a bad source or a missing skill, and
+ RegistryRateLimited when GitHub's anon API is exhausted."""
+ owner, _, repo = source.partition("/")
+ if not owner or not repo:
+ raise ValueError(f"unrecognized source '{source}' (expected owner/repo)")
+ async with httpx.AsyncClient(timeout=30.0, headers=github_headers()) as client:
+ branch, tree = await p_fetch_repo_tree(client, owner, repo)
+ skill_md, members = select_skill_paths(tree, skill_id)
+ skill_dir = skill_md[: -len("/SKILL.md")] if "/" in skill_md else ""
+ version = p_folder_tree_sha(tree, skill_dir)
+ return await p_build_resolved_skill(client, owner, repo, branch, skill_dir, members, skill_id, version)
+
+
+async def resolve_curated_skill(folder: str) -> dict:
+ """Resolve a curated (anthropics/skills) skill folder to ALL its files via the
+ GitHub trees API, so multi-file curated skills (pdf/docx/pptx scripts, etc.)
+ install whole instead of just their SKILL.md. The exact folder comes from our
+ catalog, so we match it precisely (not by basename). Same payload shape as
+ resolve_community_skill. Raises ValueError if the folder has no SKILL.md and
+ RegistryRateLimited when GitHub's anon API is exhausted."""
+ owner, _, repo = REPO.partition("/")
+ skill_dir = folder.rstrip("/")
+ skill_id = skill_dir.rsplit("/", 1)[-1]
+ prefix = skill_dir + "/"
+ async with httpx.AsyncClient(timeout=30.0, headers=github_headers()) as client:
+ tree = p_curated_tree
+ if not tree:
+ # Cold cache (pre-first-refresh, or a failed/rate-limited warm-up): pay one live tree call this once.
+ tree = await p_tree_at(client, owner, repo, BRANCH)
+ if tree is None:
+ raise ValueError(f"could not read {REPO}@{BRANCH} tree")
+ blobs = p_tree_blob_paths(tree)
+ if (prefix + "SKILL.md") not in blobs:
+ raise ValueError(f"no SKILL.md at '{folder}'")
+ members = [p for p in blobs if p.startswith(prefix)][:P_MAX_SKILL_FILES]
+ version = p_folder_tree_sha(tree, skill_dir)
+ return await p_build_resolved_skill(client, owner, repo, BRANCH, skill_dir, members, skill_id, version)
+
+
async def p_community_search(q: str, limit: int) -> dict:
"""Live-proxy a query to the skills.sh wild registry. Not cached: it's a
600k-entry remote index, so we search it on demand rather than mirror it."""
@@ -472,6 +565,143 @@ async def registry_install(req: p_InstallRequest):
skill = write_folder_skill(
slug,
resolved["files"],
- {"name": resolved["name"], "description": resolved["description"]},
+ {
+ "name": resolved["name"], "description": resolved["description"],
+ "source": resolved.get("source", ""), "folder": resolved.get("folder", ""), "version": resolved.get("version", ""),
+ },
)
return {"installed": True, "skill": skill.model_dump(), "disclosure": disclosure}
+
+
+class p_CuratedInstallRequest(BaseModel):
+ folder: str
+
+
+@skill_registry.router.post("/install-curated")
+async def registry_install_curated(req: p_CuratedInstallRequest):
+ """Install a curated (anthropics/skills) skill with its FULL folder, not just
+ SKILL.md, so scripts/assets land too (the old path wrote only SKILL.md, which
+ left multi-file skills like pdf/docx with dead script references). Curated is
+ the vetted source, so this is one-click; files are still written inert, never
+ executed. Needs network at install time (the catalog only caches SKILL.md)."""
+ try:
+ resolved = await resolve_curated_skill(req.folder)
+ except RegistryRateLimited:
+ raise HTTPException(status_code=429, detail="GitHub rate limit hit fetching this skill; try again in a few minutes.")
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=f"could not fetch skill: {e}")
+
+ from backend.apps.skills.skills import write_folder_skill, unique_skill_slug
+ slug = unique_skill_slug(resolved["skill_id"])
+ skill = write_folder_skill(
+ slug,
+ resolved["files"],
+ {
+ "name": resolved["name"], "description": resolved["description"],
+ "source": resolved.get("source", ""), "folder": resolved.get("folder", ""), "version": resolved.get("version", ""),
+ },
+ )
+ return {
+ "installed": True,
+ "skill": skill.model_dump(),
+ "files": sorted(resolved["files"].keys()),
+ "scripts": resolved["scripts"],
+ }
+
+
+async def p_safe_repo_tree(source: str):
+ """Recursive tree for a community 'owner/repo', cached briefly and best-effort
+ (None on rate-limit / missing repo) so an updates check never fails the whole
+ list because one repo is unreachable."""
+ now = time.time()
+ hit = p_community_tree_cache.get(source)
+ if hit and now - hit[0] < P_COMMUNITY_TREE_TTL:
+ return hit[1]
+ owner, _, repo = source.partition("/")
+ tree = None
+ if owner and repo:
+ try:
+ async with httpx.AsyncClient(timeout=30.0, headers=github_headers()) as client:
+ p_branch, tree = await p_fetch_repo_tree(client, owner, repo)
+ except Exception:
+ tree = None
+ p_community_tree_cache[source] = (now, tree)
+ return tree
+
+
+@skill_registry.router.get("/updates")
+async def registry_updates():
+ """Which installed skills have a newer version upstream. Curated skills check
+ against the warmed tree (zero API calls); community skills re-fetch their repo
+ tree (best-effort, deduped per repo, cached). A skill with no recorded source
+ (user-created, or installed before versioning) is skipped, not reported."""
+ from backend.apps.skills.skills import sync_skills
+ outdated: list[str] = []
+ checked: list[str] = []
+ unknown: list[str] = []
+ community_trees: dict[str, object] = {}
+ for s in sync_skills():
+ if not s.source or not s.folder or not s.version:
+ continue
+ if s.source == REPO:
+ tree = p_curated_tree
+ else:
+ if s.source not in community_trees:
+ community_trees[s.source] = await p_safe_repo_tree(s.source)
+ tree = community_trees[s.source]
+ if not tree:
+ unknown.append(s.id)
+ continue
+ current = p_folder_tree_sha(tree, s.folder)
+ checked.append(s.id)
+ if current and current != s.version:
+ outdated.append(s.id)
+ return {"outdated": outdated, "checked": checked, "unknown": unknown}
+
+
+class p_UpdateRequest(BaseModel):
+ skill_id: str
+
+
+@skill_registry.router.post("/update")
+async def registry_update(req: p_UpdateRequest):
+ """Re-fetch an installed skill from its recorded source and overwrite it in place,
+ bumping its version. Re-runs the secret scan and returns any findings so the UI can
+ flag a community update that newly ships secrets. A skill with no source (user-made)
+ can't be updated."""
+ from backend.apps.skills.skills import sync_skills, write_folder_skill, p_clear_skill_dir
+ target = next((s for s in sync_skills() if s.id == req.skill_id), None)
+ if target is None:
+ raise HTTPException(status_code=404, detail="skill not found")
+ if not target.source or not target.folder:
+ raise HTTPException(status_code=400, detail="this skill has no upstream source to update from")
+ try:
+ if target.source == REPO:
+ resolved = await resolve_curated_skill(target.folder)
+ else:
+ resolved = await resolve_community_skill(target.source, target.folder.rsplit("/", 1)[-1])
+ except RegistryRateLimited:
+ raise HTTPException(status_code=429, detail="GitHub rate limit hit; try again in a few minutes.")
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=f"could not fetch skill: {e}")
+
+ # Overwrite in place: clear first so files removed upstream don't linger, keep the user's command alias, refresh everything else from source.
+ p_clear_skill_dir(target.id)
+ skill = write_folder_skill(
+ target.id,
+ resolved["files"],
+ {
+ "name": resolved["name"], "description": resolved["description"], "command": target.command,
+ "source": resolved.get("source", ""), "folder": resolved.get("folder", ""), "version": resolved.get("version", ""),
+ },
+ )
+ return {
+ "updated": True,
+ "skill": skill.model_dump(),
+ "scripts": resolved["scripts"],
+ "secret_findings": resolved.get("secret_findings", []),
+ }
diff --git a/backend/apps/skills/models.py b/backend/apps/skills/models.py
index c121e441..e27fe05e 100644
--- a/backend/apps/skills/models.py
+++ b/backend/apps/skills/models.py
@@ -15,6 +15,10 @@ class Skill(BaseModel):
# Multi-file skills live in ~/.claude/skills// with a SKILL.md plus supporting files (scripts, templates). dir_path is set for those; empty for a legacy flat .md skill. has_supporting_files flags extra files beyond SKILL.md so the prompt layer knows to point the agent at the folder for on-demand reading.
dir_path: str = ""
has_supporting_files: bool = False
+ # Provenance for registry-installed skills, used to detect + apply updates. source is owner/repo ('' for user-created), folder is the skill's path in that repo, version is the folder's git tree SHA at install time (changes iff something inside the folder changes upstream).
+ source: str = ""
+ folder: str = ""
+ version: str = ""
class SkillCreate(BaseModel):
@@ -31,6 +35,10 @@ class SkillUpdate(BaseModel):
command: Optional[str] = None
+class SkillLoadRequest(BaseModel):
+ id: str
+
+
class SkillWorkspaceSeedRequest(BaseModel):
workspace_id: str
skill_content: Optional[str] = None
diff --git a/backend/apps/skills/skills.py b/backend/apps/skills/skills.py
index 618c7f59..166e6772 100644
--- a/backend/apps/skills/skills.py
+++ b/backend/apps/skills/skills.py
@@ -8,7 +8,7 @@ import time
from contextlib import asynccontextmanager
from fastapi import HTTPException
from backend.config.Apps import SubApp
-from backend.apps.skills.models import Skill, SkillCreate, SkillUpdate, SkillWorkspaceSeedRequest
+from backend.apps.skills.models import Skill, SkillCreate, SkillLoadRequest, SkillUpdate, SkillWorkspaceSeedRequest
logger = logging.getLogger(__name__)
@@ -141,12 +141,23 @@ def p_seed_built_in_skills() -> None:
save_index(index)
+def p_prune_orphan_index() -> None:
+ """Drop index entries whose skill files are gone (deleted out-of-band, e.g. a
+ manual rm of the folder), so ghosts don't pile up as dead metadata or escalate
+ install slugs (pdf -> pdf-2 -> pdf-3) by squatting a name with nothing on disk."""
+ index = load_index()
+ alive = {k: v for k, v in index.items() if skill_md_path(k)[0] is not None}
+ if len(alive) != len(index):
+ save_index(alive)
+
+
@asynccontextmanager
async def skills_lifespan():
os.makedirs(SKILLS_DIR, exist_ok=True)
os.makedirs(SKILLS_WORKSPACE_DIR, exist_ok=True)
try:
p_seed_built_in_skills()
+ p_prune_orphan_index()
except Exception:
# Don't block app startup on a skill-seed failure; the worst case is the user has to manually paste the skill in once.
logger.exception("failed to seed built-in skills")
@@ -199,6 +210,9 @@ def p_build_skill(skill_id: str, content: str, md_path: str, kind: str, index: d
built_in=bool(meta.get("built_in", False)),
dir_path=skill_dir if kind == "folder" else "",
has_supporting_files=(kind == "folder" and p_has_supporting_files(skill_dir)),
+ source=meta.get("source", ""),
+ folder=meta.get("folder", ""),
+ version=meta.get("version", ""),
)
@@ -233,11 +247,51 @@ def sync_skills() -> list[Skill]:
return result
+def format_skill_for_prompt(name: str, content: str, folder: str | None) -> str:
+ """The exact prompt block for one skill, shared by manual attach
+ (resolve_attached_skills) and the on-demand Skill tool so both inject
+ byte-identical text. `folder` is the supporting-files dir when the skill
+ ships any, else None."""
+ block = f"[Using skill: {name}]\n\n{content}"
+ if folder:
+ block += (
+ f"\n\nThis skill bundles supporting files in {folder}. "
+ "Read them with your normal file tools (Read / Glob / Bash) when "
+ "the steps above call for one; don't guess their contents."
+ )
+ return block
+
+
+def p_resolve_skill(skill_id: str, skills_list: list[Skill]) -> Skill | None:
+ """Resolve the identifier the model handed the Skill tool: exact id first,
+ then a case-insensitive match on id/command/name so a near-miss still loads."""
+ for s in skills_list:
+ if s.id == skill_id:
+ return s
+ low = skill_id.strip().lower()
+ for s in skills_list:
+ if low and low in (s.id.lower(), s.command.lower(), s.name.lower()):
+ return s
+ return None
+
+
@skills.router.get("/list")
async def list_skills():
return {"skills": [s.model_dump() for s in sync_skills()]}
+@skills.router.post("/load")
+async def load_skill(body: SkillLoadRequest):
+ """Back the Skill tool: resolve a skill id to its prompt-ready text. On a miss
+ we return the installed ids (not a 404) so the model can self-correct its next call."""
+ skills_list = sync_skills()
+ target = p_resolve_skill(body.id, skills_list)
+ if target is None:
+ return {"ok": False, "error": "unknown_skill", "available": [s.id for s in skills_list]}
+ folder = target.dir_path if (target.dir_path and target.has_supporting_files) else None
+ return {"ok": True, "text": format_skill_for_prompt(target.name, target.content, folder)}
+
+
def p_parse_skill_frontmatter(raw: str) -> dict:
"""Extract YAML frontmatter fields from a SKILL.md file."""
if not raw.startswith("---"):
@@ -307,15 +361,24 @@ async def get_skill(skill_id: str):
raise HTTPException(status_code=404, detail="Skill not found")
+def p_clear_skill_dir(skill_id: str) -> None:
+ """Empty a skill's folder before an in-place update so files removed upstream
+ don't linger as orphans. write_folder_skill recreates the dir right after."""
+ import shutil
+ d = os.path.join(SKILLS_DIR, p_safe_slug(skill_id))
+ if os.path.isdir(d):
+ shutil.rmtree(d, ignore_errors=True)
+
+
def p_safe_slug(raw: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", (raw or "").strip().lower()).strip("-")
return slug or "skill"
def p_skill_exists(slug: str) -> bool:
+ # Existence is decided by FILES on disk only: a lingering index entry whose folder was deleted out-of-band (a manual rm) is a ghost and must not block reusing its slug.
return (
- slug in load_index()
- or os.path.isfile(os.path.join(SKILLS_DIR, f"{slug}.md"))
+ os.path.isfile(os.path.join(SKILLS_DIR, f"{slug}.md"))
or os.path.isdir(os.path.join(SKILLS_DIR, slug))
)
@@ -360,11 +423,16 @@ def write_folder_skill(skill_id: str, files: dict[str, str], meta: dict) -> Skil
f.write(content)
index = load_index()
- index[slug] = {
+ entry = {
"name": meta.get("name") or slug,
"description": meta.get("description", ""),
"command": meta.get("command", slug),
}
+ # Carry provenance (source/folder/version) when an installer supplies it, so updates can be detected later. User-created skills omit these and stay un-versioned.
+ for k in ("source", "folder", "version"):
+ if meta.get(k):
+ entry[k] = meta[k]
+ index[slug] = entry
save_index(index)
md_path, kind = skill_md_path(slug)
diff --git a/backend/apps/tools_lib/models.py b/backend/apps/tools_lib/models.py
index 56f795c9..71d81261 100644
--- a/backend/apps/tools_lib/models.py
+++ b/backend/apps/tools_lib/models.py
@@ -33,6 +33,8 @@ BUILTIN_TOOLS: list[BuiltinTool] = [
BuiltinTool(name="CronCreate", description="Create a scheduled or recurring task", category="scheduling", deferred=True),
BuiltinTool(name="CronList", description="List all scheduled tasks", category="scheduling", deferred=True),
BuiltinTool(name="CronDelete", description="Delete a scheduled task", category="scheduling", deferred=True),
+ # Skills
+ BuiltinTool(name="Skill", description="Load an installed skill's instructions on demand so the agent can find and use the right skill itself", category="skills"),
# Agent tools
BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"),
BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"),
diff --git a/backend/tests/test_skill_registry_community.py b/backend/tests/test_skill_registry_community.py
index ea11b4ef..67a14c0c 100644
--- a/backend/tests/test_skill_registry_community.py
+++ b/backend/tests/test_skill_registry_community.py
@@ -177,6 +177,194 @@ def test_confirm_install_writes_folder_lists_and_injects(skills_dir, monkeypatch
assert str(skills_dir / slug) in block
+class FakeResp:
+ def __init__(self, status, payload=None, text=""):
+ self.status_code = status
+ self.p_payload = payload
+ self.text = text
+
+ def json(self):
+ return self.p_payload
+
+
+CURATED_TREE = {"tree": [
+ {"type": "tree", "path": "skills/pdf", "sha": "PDFSHA1"},
+ {"type": "blob", "path": "skills/pdf/SKILL.md"},
+ {"type": "blob", "path": "skills/pdf/scripts/extract.py"},
+ {"type": "tree", "path": "skills/pdf/scripts", "sha": "SCRIPTSHA"},
+ {"type": "blob", "path": "skills/pdf/reference/notes.md"},
+ {"type": "blob", "path": "skills/pdf-extra/SKILL.md"},
+ {"type": "blob", "path": "skills/other/SKILL.md"},
+]}
+
+
+def test_curated_resolve_fetches_exact_folder_only(monkeypatch):
+ """Curated install must pull the WHOLE skill folder (so scripts/assets land),
+ matched by EXACT path: a sibling folder sharing a name prefix (skills/pdf vs
+ skills/pdf-extra) must not leak in. Here the cache is COLD, so it pays one live
+ tree call."""
+ import asyncio
+ import backend.apps.skill_registry.skill_registry as sr
+ monkeypatch.setattr(sr, "p_curated_tree", [])
+
+ class FakeClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def get(self, url):
+ if "git/trees" in url:
+ return FakeResp(200, payload=CURATED_TREE)
+ rel = url.split("/main/", 1)[1]
+ return FakeResp(200, text=f"content:{rel}") if rel.startswith("skills/pdf/") else FakeResp(404)
+
+ monkeypatch.setattr(sr.httpx, "AsyncClient", lambda *a, **k: FakeClient())
+
+ resolved = asyncio.run(sr.resolve_curated_skill("skills/pdf"))
+ assert set(resolved["files"].keys()) == {"SKILL.md", "scripts/extract.py", "reference/notes.md"}
+ assert resolved["scripts"] == ["scripts/extract.py"]
+ assert resolved["skill_id"] == "pdf"
+
+
+def test_curated_resolve_uses_warm_cache_with_zero_api_calls(monkeypatch):
+ """B1: when the tree cache is warm, a curated install makes ZERO trees-API calls,
+ it reads paths from the cache and pulls contents over raw only. It also records
+ provenance (source/folder/version) for later update detection."""
+ import asyncio
+ import backend.apps.skill_registry.skill_registry as sr
+ monkeypatch.setattr(sr, "p_curated_tree", CURATED_TREE["tree"])
+
+ class NoApiClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def get(self, url):
+ if "git/trees" in url or "api.github.com" in url:
+ raise AssertionError(f"warm cache must not hit the API: {url}")
+ rel = url.split("/main/", 1)[1]
+ return FakeResp(200, text=f"content:{rel}") if rel.startswith("skills/pdf/") else FakeResp(404)
+
+ monkeypatch.setattr(sr.httpx, "AsyncClient", lambda *a, **k: NoApiClient())
+
+ resolved = asyncio.run(sr.resolve_curated_skill("skills/pdf"))
+ assert set(resolved["files"].keys()) == {"SKILL.md", "scripts/extract.py", "reference/notes.md"}
+ assert resolved["source"] == "anthropics/skills"
+ assert resolved["folder"] == "skills/pdf"
+ assert resolved["version"] == "PDFSHA1"
+
+
+def test_warm_curated_tree_populates_cache(monkeypatch):
+ """The hourly warm-up caches the repo's full tree so later installs skip the API."""
+ import asyncio
+ import backend.apps.skill_registry.skill_registry as sr
+ monkeypatch.setattr(sr, "p_curated_tree", [])
+
+ class TreeClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def get(self, url):
+ assert "git/trees" in url
+ return FakeResp(200, payload=CURATED_TREE)
+
+ monkeypatch.setattr(sr.httpx, "AsyncClient", lambda *a, **k: TreeClient())
+ asyncio.run(sr.p_warm_curated_tree())
+ assert "skills/pdf/SKILL.md" in sr.p_tree_blob_paths(sr.p_curated_tree)
+
+
+def test_skill_update_detection_and_apply(skills_dir, monkeypatch):
+ """End-to-end versioning: a curated skill recorded at an OLD folder SHA reads as
+ outdated against the warmed tree; applying the update re-fetches, bumps the version,
+ and it stops being outdated."""
+ import secrets as p_secrets
+ from fastapi.testclient import TestClient
+ from backend.main import app
+ import backend.auth as auth_mod
+ import backend.apps.skill_registry.skill_registry as sr
+ import backend.apps.skills.skills as skills_mod
+ if not auth_mod.TOKEN:
+ auth_mod.TOKEN = p_secrets.token_urlsafe(32)
+ client = TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
+
+ # Upstream folder SHA for skills/pdf is PDFSHA1 (from the warmed tree).
+ monkeypatch.setattr(sr, "p_curated_tree", CURATED_TREE["tree"])
+ # Installed copy recorded at a STALE version, so it should read as outdated.
+ skills_mod.write_folder_skill("pdf", {"SKILL.md": "old"}, {
+ "name": "PDF", "source": "anthropics/skills", "folder": "skills/pdf", "version": "OLDSHA",
+ })
+
+ r = client.get("/api/skill-registry/updates")
+ assert r.status_code == 200 and "pdf" in r.json()["outdated"]
+
+ async def fake_resolve(folder):
+ return {"name": "PDF", "description": "pdfs", "skill_id": "pdf",
+ "files": {"SKILL.md": "new", "scripts/x.py": "print(1)"},
+ "scripts": ["scripts/x.py"], "secret_findings": [],
+ "source": "anthropics/skills", "folder": "skills/pdf", "version": "PDFSHA1"}
+ monkeypatch.setattr(sr, "resolve_curated_skill", fake_resolve)
+
+ u = client.post("/api/skill-registry/update", json={"skill_id": "pdf"})
+ assert u.status_code == 200 and u.json()["updated"] is True
+ assert (skills_dir / "pdf" / "scripts" / "x.py").exists()
+
+ r2 = client.get("/api/skill-registry/updates")
+ assert "pdf" not in r2.json()["outdated"] and "pdf" in r2.json()["checked"]
+
+
+def test_curated_install_writes_full_folder(skills_dir, monkeypatch):
+ """End-to-end: /install-curated writes the full folder (SKILL.md + scripts), and
+ the skill shows up in /api/skills/list flagged multi-file. (resolve mocked to
+ skip the network; the exact-folder fetch is proven separately.)"""
+ import secrets as p_secrets
+ from fastapi.testclient import TestClient
+ from backend.main import app
+ import backend.auth as auth_mod
+ if not auth_mod.TOKEN:
+ auth_mod.TOKEN = p_secrets.token_urlsafe(32)
+ client = TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
+
+ async def fake_resolve(folder):
+ return {
+ "name": "PDF", "description": "work with pdfs",
+ "repo_url": "https://github.com/anthropics/skills/tree/main/skills/pdf",
+ "skill_id": "pdf",
+ "files": {"SKILL.md": "# PDF\nRun scripts/extract.py", "scripts/extract.py": "print('x')"},
+ "scripts": ["scripts/extract.py"], "secret_findings": [],
+ }
+ monkeypatch.setattr("backend.apps.skill_registry.skill_registry.resolve_curated_skill", fake_resolve)
+
+ r = client.post("/api/skill-registry/install-curated", json={"folder": "skills/pdf"})
+ assert r.status_code == 200 and r.json()["installed"] is True
+ slug = r.json()["skill"]["id"]
+ assert (skills_dir / slug / "SKILL.md").exists()
+ assert (skills_dir / slug / "scripts" / "extract.py").exists()
+ listed = {s["id"]: s for s in client.get("/api/skills/list").json()["skills"]}
+ assert slug in listed and listed[slug]["has_supporting_files"] is True
+
+
+def test_manual_rm_leaves_no_ghost_blocking_slug(skills_dir):
+ """A folder deleted out-of-band (manual rm) must not keep squatting its slug via
+ a leftover index entry: existence is by files on disk, and a prune cleans the index."""
+ import shutil
+ skills_mod.write_folder_skill("pdf", {"SKILL.md": "x"}, {"name": "PDF"})
+ shutil.rmtree(skills_dir / "pdf") # delete the folder, leave the index entry
+ # The ghost index entry must NOT count as existing, so the clean slug is reclaimable.
+ assert skills_mod.p_skill_exists("pdf") is False
+ assert skills_mod.unique_skill_slug("pdf") == "pdf"
+ # And the prune drops the dead entry.
+ assert "pdf" in skills_mod.load_index()
+ skills_mod.p_prune_orphan_index()
+ assert "pdf" not in skills_mod.load_index()
+
+
def test_write_folder_skill_blocks_path_traversal(skills_dir):
skills_mod.write_folder_skill(
"evil",
diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx
index 82ed0813..80c2104f 100644
--- a/frontend/src/app/pages/Skills/Skills.tsx
+++ b/frontend/src/app/pages/Skills/Skills.tsx
@@ -47,6 +47,9 @@ import {
fetchAllRegistrySkills,
fetchSkillRegistryStats,
fetchSkillDetail,
+ fetchSkillUpdates,
+ installCuratedSkill,
+ updateInstalledSkill,
RegistrySkill,
RegistrySkillDetail,
} from '@/shared/state/skillRegistrySlice';
@@ -85,6 +88,7 @@ const Skills: React.FC = () => {
stats: regStats,
detail: regDetail,
detailLoading: regDetailLoading,
+ outdated: regOutdated,
} = useAppSelector((s) => s.skillRegistry);
const localSkills = Object.values(items);
@@ -119,6 +123,7 @@ const Skills: React.FC = () => {
dispatch(fetchSkills());
dispatch(fetchSkillRegistryStats());
dispatch(fetchAllRegistrySkills());
+ dispatch(fetchSkillUpdates());
}, [dispatch]);
const regGrouped = useMemo(() => {
@@ -186,16 +191,39 @@ const Skills: React.FC = () => {
const handleInstall = async () => {
if (!selectedReg) return;
- await dispatch(createSkill({
- name: selectedReg.name,
- description: selectedReg.description,
- content: selectedReg.content,
- command: selectedReg.name.toLowerCase().replace(/\s+/g, '-'),
- }));
+ try {
+ await dispatch(installCuratedSkill(selectedReg.folder)).unwrap();
+ } catch (e) {
+ // unwrap() rejects with a plain serialized object, not an Error instance, so read .message off it directly.
+ const msg = (e as { message?: string })?.message || 'unknown error';
+ setSnackbar({ open: true, message: `Install failed: ${msg}` });
+ return;
+ }
+ await dispatch(fetchSkills());
onboardingBus.emit('skill:installed');
setSnackbar({ open: true, message: `Installed "${selectedReg.name}" as a local skill` });
};
+ const [updatingId, setUpdatingId] = useState(null);
+ const handleUpdate = async (skill: Skill) => {
+ setUpdatingId(skill.id);
+ let result: { secret_findings: string[] } | null = null;
+ try {
+ result = await dispatch(updateInstalledSkill(skill.id)).unwrap();
+ } catch (e) {
+ const msg = (e as { message?: string })?.message || 'unknown error';
+ setSnackbar({ open: true, message: `Update failed: ${msg}` });
+ setUpdatingId(null);
+ return;
+ }
+ await Promise.all([dispatch(fetchSkills()), dispatch(fetchSkillUpdates())]);
+ setUpdatingId(null);
+ const flagged = result?.secret_findings?.length
+ ? ` (heads up: the update ships ${result.secret_findings.length} file(s) with secret-shaped content)`
+ : '';
+ setSnackbar({ open: true, message: `Updated "${skill.name}" to the latest version${flagged}` });
+ };
+
const handleEditInstall = () => {
if (!selectedReg) return;
setEditingId(null);
@@ -285,7 +313,8 @@ const Skills: React.FC = () => {
onClick: () => void;
icon?: React.ReactNode;
onboardingId?: string;
- }> = ({ label, selected, onClick, icon, onboardingId }) => (
+ trailing?: React.ReactNode;
+ }> = ({ label, selected, onClick, icon, onboardingId, trailing }) => (
{
>
{label}
+ {trailing && {trailing}}
);
@@ -438,6 +468,9 @@ const Skills: React.FC = () => {
selected={isSelected('local', sk.id)}
onClick={() => selectLocal(sk.id)}
icon={}
+ trailing={regOutdated.includes(sk.id)
+ ?
+ : undefined}
/>
))}
@@ -641,8 +674,27 @@ const Skills: React.FC = () => {
}}
/>
)}
+ {regOutdated.includes(selectedLocal.id) && (
+
+ )}
+ {regOutdated.includes(selectedLocal.id) && (
+ }
+ disabled={updatingId === selectedLocal.id}
+ onClick={() => handleUpdate(selectedLocal)}
+ sx={{ textTransform: 'none', fontSize: '0.78rem', py: 0.3, bgcolor: c.status.warning, '&:hover': { bgcolor: c.status.warning } }}
+ >
+ {updatingId === selectedLocal.id ? 'Updating...' : 'Update'}
+
+ )}
openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
diff --git a/frontend/src/app/pages/Tools/cards/ToolSection.tsx b/frontend/src/app/pages/Tools/cards/ToolSection.tsx
index 03f42fc5..9c32964e 100644
--- a/frontend/src/app/pages/Tools/cards/ToolSection.tsx
+++ b/frontend/src/app/pages/Tools/cards/ToolSection.tsx
@@ -20,6 +20,7 @@ import BlockIcon from '@mui/icons-material/Block';
import SecurityIcon from '@mui/icons-material/Security';
import PanToolIcon from '@mui/icons-material/PanTool';
import CallSplitIcon from '@mui/icons-material/CallSplit';
+import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { BuiltinTool } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { CATEGORY_ORDER } from '../toolsHelpers';
@@ -60,6 +61,7 @@ const ToolSection: React.FC = ({
planning: { label: 'Planning', color: '#ec4899', icon: },
scheduling: { label: 'Scheduling', color: '#14b8a6', icon: },
agents: { label: 'Agents', color: '#f97316', icon: },
+ skills: { label: 'Skills', color: '#7B61BD', icon: },
};
const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => (
diff --git a/frontend/src/app/pages/Tools/toolsHelpers.ts b/frontend/src/app/pages/Tools/toolsHelpers.ts
index bd0af9b5..5ffe9fb2 100644
--- a/frontend/src/app/pages/Tools/toolsHelpers.ts
+++ b/frontend/src/app/pages/Tools/toolsHelpers.ts
@@ -1,6 +1,6 @@
import { McpServer } from '@/shared/state/mcpRegistrySlice';
-export const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling'];
+export const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'skills', 'planning', 'scheduling'];
export interface ToolForm {
name: string;
diff --git a/frontend/src/shared/state/skillRegistrySlice.ts b/frontend/src/shared/state/skillRegistrySlice.ts
index 1d4b262d..e444236b 100644
--- a/frontend/src/shared/state/skillRegistrySlice.ts
+++ b/frontend/src/shared/state/skillRegistrySlice.ts
@@ -1,5 +1,6 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
+import { Skill } from '@/shared/state/skillsSlice';
const SKILL_REGISTRY_API = `${API_BASE}/skill-registry`;
@@ -24,6 +25,7 @@ interface SkillRegistryState {
stats: { total: number; categories: Record; lastUpdated: number } | null;
detail: RegistrySkillDetail | null;
detailLoading: boolean;
+ outdated: string[];
}
const initialState: SkillRegistryState = {
@@ -35,6 +37,7 @@ const initialState: SkillRegistryState = {
stats: null,
detail: null,
detailLoading: false,
+ outdated: [],
};
export const searchSkillRegistry = createAsyncThunk(
@@ -69,6 +72,60 @@ export const fetchSkillDetail = createAsyncThunk(
},
);
+export interface CuratedInstallResult {
+ installed: boolean;
+ skill: Skill;
+ files: string[];
+ scripts: string[];
+}
+
+// Curated install fetches the WHOLE skill folder (scripts/assets), not just SKILL.md, so multi-file skills land complete. Caller refreshes the local skills list after.
+export const installCuratedSkill = createAsyncThunk(
+ 'skillRegistry/installCurated',
+ async (folder: string) => {
+ const res = await fetch(`${SKILL_REGISTRY_API}/install-curated`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ folder }),
+ });
+ if (!res.ok) {
+ const detail = await res.json().catch(() => ({}));
+ throw new Error(detail.detail || `install failed (${res.status})`);
+ }
+ return (await res.json()) as CuratedInstallResult;
+ },
+);
+
+export interface SkillUpdatesResult {
+ outdated: string[];
+ checked: string[];
+ unknown: string[];
+}
+
+// Which installed skills have a newer version upstream. Curated checks are free (cached tree); community checks are best-effort.
+export const fetchSkillUpdates = createAsyncThunk('skillRegistry/updates', async () => {
+ const res = await fetch(`${SKILL_REGISTRY_API}/updates`);
+ if (!res.ok) throw new Error(`updates check failed (${res.status})`);
+ return (await res.json()) as SkillUpdatesResult;
+});
+
+// Re-fetch an installed skill from its recorded source and overwrite it in place, bumping its version. Caller refreshes the local skills list + updates after.
+export const updateInstalledSkill = createAsyncThunk(
+ 'skillRegistry/updateInstalled',
+ async (skillId: string) => {
+ const res = await fetch(`${SKILL_REGISTRY_API}/update`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ skill_id: skillId }),
+ });
+ if (!res.ok) {
+ const detail = await res.json().catch(() => ({}));
+ throw new Error(detail.detail || `update failed (${res.status})`);
+ }
+ return (await res.json()) as { updated: boolean; skill: Skill; scripts: string[]; secret_findings: string[] };
+ },
+);
+
const skillRegistrySlice = createSlice({
name: 'skillRegistry',
initialState,
@@ -119,6 +176,9 @@ const skillRegistrySlice = createSlice({
})
.addCase(fetchSkillDetail.rejected, (state) => {
state.detailLoading = false;
+ })
+ .addCase(fetchSkillUpdates.fulfilled, (state, action) => {
+ state.outdated = action.payload.outdated;
});
},
});
diff --git a/frontend/src/shared/state/skillsSlice.ts b/frontend/src/shared/state/skillsSlice.ts
index 4e1e31bc..b2ce6ca0 100644
--- a/frontend/src/shared/state/skillsSlice.ts
+++ b/frontend/src/shared/state/skillsSlice.ts
@@ -12,6 +12,10 @@ export interface Skill {
command: string;
/** Platform-shipped skill; UI hides delete, backend DELETE returns 409. Content still editable. */
built_in?: boolean;
+ /** Provenance for registry-installed skills (used for update detection). Empty for user-created skills. */
+ source?: string;
+ folder?: string;
+ version?: string;
}
interface SkillsState {