[eric] merge #113 (aidan): skills agent-discovery + multi-file install + versioning

This commit is contained in:
ciregenz
2026-06-29 21:26:00 -07:00
15 changed files with 852 additions and 48 deletions
@@ -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
@@ -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.
@@ -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>",
"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=\"<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("</skills>")
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)
@@ -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 <skills> 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"
+125
View File
@@ -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 <skills> 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 <skills> 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()
+256 -26
View File
@@ -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", []),
}
+8
View File
@@ -15,6 +15,10 @@ class Skill(BaseModel):
# Multi-file skills live in ~/.claude/skills/<id>/ with a SKILL.md plus supporting files (scripts, templates). dir_path is set for those; empty for a legacy flat <id>.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
+72 -4
View File
@@ -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)
+2
View File
@@ -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"),
@@ -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",
+59 -7
View File
@@ -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<string | null>(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 }) => (
<Box
onClick={onClick}
data-onboarding={onboardingId}
@@ -306,6 +335,7 @@ const Skills: React.FC = () => {
>
{label}
</Typography>
{trailing && <Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', flexShrink: 0 }}>{trailing}</Box>}
</Box>
);
@@ -438,6 +468,9 @@ const Skills: React.FC = () => {
selected={isSelected('local', sk.id)}
onClick={() => selectLocal(sk.id)}
icon={<FolderIcon sx={{ fontSize: 15, color: c.text.tertiary, flexShrink: 0 }} />}
trailing={regOutdated.includes(sk.id)
? <Tooltip title="Update available"><Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: c.status.warning }} /></Tooltip>
: undefined}
/>
))}
</Box>
@@ -641,8 +674,27 @@ const Skills: React.FC = () => {
}}
/>
)}
{regOutdated.includes(selectedLocal.id) && (
<Chip
label="Update available"
size="small"
sx={{ bgcolor: `${c.status.warning}22`, color: c.status.warning, fontWeight: 600, fontSize: '0.7rem', height: 20 }}
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{regOutdated.includes(selectedLocal.id) && (
<Button
size="small"
variant="contained"
startIcon={<DownloadIcon sx={{ fontSize: 16 }} />}
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'}
</Button>
)}
<ShareButton target={{ kind: 'skill', id: selectedLocal.id, name: selectedLocal.name }} />
<Tooltip title="Edit">
<IconButton size="small" onClick={() => openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
@@ -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<ToolSectionProps> = ({
planning: { label: 'Planning', color: '#ec4899', icon: <MapIcon sx={{ fontSize: 16 }} /> },
scheduling: { label: 'Scheduling', color: '#14b8a6', icon: <ScheduleIcon sx={{ fontSize: 16 }} /> },
agents: { label: 'Agents', color: '#f97316', icon: <CallSplitIcon sx={{ fontSize: 16 }} /> },
skills: { label: 'Skills', color: '#7B61BD', icon: <AutoAwesomeIcon sx={{ fontSize: 16 }} /> },
};
const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => (
+1 -1
View File
@@ -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;
@@ -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<string, number>; 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;
});
},
});
+4
View File
@@ -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 {