Two fixes for /skills install and /skills uninstall slash commands: 1. input() hangs indefinitely inside prompt_toolkit's TUI event loop, soft-locking the CLI. The user typing the slash command is already implicit consent, so confirmation is now always skipped. 2. Cache invalidation was unconditional — installing or uninstalling a skill mid-session silently broke the prompt cache, increasing costs. The slash handler now defers cache invalidation by default (skill takes effect next session). Pass --now to invalidate immediately, with a message explaining the cost tradeoff. The CLI argparse path (hermes skills install) is unaffected and still invalidates. Fixes #3474 Salvaged from PR #3496 by dlkakbs.
1199 lines
44 KiB
Python
1199 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Skills Hub CLI — Unified interface for the Hermes Skills Hub.
|
|
|
|
Powers both:
|
|
- `hermes skills <subcommand>` (CLI argparse entry point)
|
|
- `/skills <subcommand>` (slash command in the interactive chat)
|
|
|
|
All logic lives in shared do_* functions. The CLI entry point and slash command
|
|
handler are thin wrappers that parse args and delegate.
|
|
"""
|
|
|
|
import json
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
|
|
# Lazy imports to avoid circular dependencies and slow startup.
|
|
# tools.skills_hub and tools.skills_guard are imported inside functions.
|
|
|
|
_console = Console()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared do_* functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _resolve_short_name(name: str, sources, console: Console) -> str:
|
|
"""
|
|
Resolve a short skill name (e.g. 'pptx') to a full identifier by searching
|
|
all sources. If exactly one match is found, returns its identifier. If multiple
|
|
matches exist, shows them and asks the user to use the full identifier.
|
|
Returns empty string if nothing found or ambiguous.
|
|
"""
|
|
from tools.skills_hub import unified_search
|
|
|
|
c = console or _console
|
|
c.print(f"[dim]Resolving '{name}'...[/]")
|
|
|
|
results = unified_search(name, sources, source_filter="all", limit=20)
|
|
|
|
# Filter to exact name matches (case-insensitive)
|
|
exact = [r for r in results if r.name.lower() == name.lower()]
|
|
|
|
if len(exact) == 1:
|
|
c.print(f"[dim]Resolved to: {exact[0].identifier}[/]")
|
|
return exact[0].identifier
|
|
|
|
if len(exact) > 1:
|
|
c.print(f"\n[yellow]Multiple skills named '{name}' found:[/]")
|
|
table = Table()
|
|
table.add_column("Source", style="dim")
|
|
table.add_column("Trust", style="dim")
|
|
table.add_column("Identifier", style="bold cyan")
|
|
for r in exact:
|
|
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim")
|
|
trust_label = "official" if r.source == "official" else r.trust_level
|
|
table.add_row(r.source, f"[{trust_style}]{trust_label}[/]", r.identifier)
|
|
c.print(table)
|
|
c.print("[bold]Use the full identifier to install a specific one.[/]\n")
|
|
return ""
|
|
|
|
# No exact match — check if there are partial matches to suggest
|
|
if results:
|
|
c.print(f"[yellow]No exact match for '{name}'. Did you mean one of these?[/]")
|
|
for r in results[:5]:
|
|
c.print(f" [cyan]{r.name}[/] — {r.identifier}")
|
|
c.print()
|
|
return ""
|
|
|
|
c.print(f"[bold red]Error:[/] No skill named '{name}' found in any source.\n")
|
|
return ""
|
|
|
|
|
|
def _format_extra_metadata_lines(extra: Dict[str, Any]) -> list[str]:
|
|
lines: list[str] = []
|
|
if not extra:
|
|
return lines
|
|
|
|
if extra.get("repo_url"):
|
|
lines.append(f"[bold]Repo:[/] {extra['repo_url']}")
|
|
if extra.get("detail_url"):
|
|
lines.append(f"[bold]Detail Page:[/] {extra['detail_url']}")
|
|
if extra.get("index_url"):
|
|
lines.append(f"[bold]Index:[/] {extra['index_url']}")
|
|
if extra.get("endpoint"):
|
|
lines.append(f"[bold]Endpoint:[/] {extra['endpoint']}")
|
|
if extra.get("install_command"):
|
|
lines.append(f"[bold]Install Command:[/] {extra['install_command']}")
|
|
if extra.get("installs") is not None:
|
|
lines.append(f"[bold]Installs:[/] {extra['installs']}")
|
|
if extra.get("weekly_installs"):
|
|
lines.append(f"[bold]Weekly Installs:[/] {extra['weekly_installs']}")
|
|
|
|
security = extra.get("security_audits")
|
|
if isinstance(security, dict) and security:
|
|
ordered = ", ".join(f"{name}={status}" for name, status in sorted(security.items()))
|
|
lines.append(f"[bold]Security:[/] {ordered}")
|
|
|
|
return lines
|
|
|
|
|
|
def _resolve_source_meta_and_bundle(identifier: str, sources):
|
|
"""Resolve metadata and bundle for a specific identifier."""
|
|
meta = None
|
|
bundle = None
|
|
matched_source = None
|
|
|
|
for src in sources:
|
|
if meta is None:
|
|
try:
|
|
meta = src.inspect(identifier)
|
|
if meta:
|
|
matched_source = src
|
|
except Exception:
|
|
meta = None
|
|
try:
|
|
bundle = src.fetch(identifier)
|
|
except Exception:
|
|
bundle = None
|
|
if bundle:
|
|
matched_source = src
|
|
if meta is None:
|
|
try:
|
|
meta = src.inspect(identifier)
|
|
except Exception:
|
|
meta = None
|
|
break
|
|
|
|
return meta, bundle, matched_source
|
|
|
|
|
|
def _derive_category_from_install_path(install_path: str) -> str:
|
|
path = Path(install_path)
|
|
parent = str(path.parent)
|
|
return "" if parent == "." else parent
|
|
|
|
|
|
def do_search(query: str, source: str = "all", limit: int = 10,
|
|
console: Optional[Console] = None) -> None:
|
|
"""Search registries and display results as a Rich table."""
|
|
from tools.skills_hub import GitHubAuth, create_source_router, unified_search
|
|
|
|
c = console or _console
|
|
c.print(f"\n[bold]Searching for:[/] {query}")
|
|
|
|
auth = GitHubAuth()
|
|
sources = create_source_router(auth)
|
|
results = unified_search(query, sources, source_filter=source, limit=limit)
|
|
|
|
if not results:
|
|
c.print("[dim]No skills found matching your query.[/]\n")
|
|
return
|
|
|
|
table = Table(title=f"Skills Hub — {len(results)} result(s)")
|
|
table.add_column("Name", style="bold cyan")
|
|
table.add_column("Description", max_width=60)
|
|
table.add_column("Source", style="dim")
|
|
table.add_column("Trust", style="dim")
|
|
table.add_column("Identifier", style="dim")
|
|
|
|
for r in results:
|
|
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim")
|
|
trust_label = "official" if r.source == "official" else r.trust_level
|
|
table.add_row(
|
|
r.name,
|
|
r.description[:60] + ("..." if len(r.description) > 60 else ""),
|
|
r.source,
|
|
f"[{trust_style}]{trust_label}[/]",
|
|
r.identifier,
|
|
)
|
|
|
|
c.print(table)
|
|
c.print("[dim]Use: hermes skills inspect <identifier> to preview, "
|
|
"hermes skills install <identifier> to install[/]\n")
|
|
|
|
|
|
def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
|
console: Optional[Console] = None) -> None:
|
|
"""Browse all available skills across registries, paginated.
|
|
|
|
Official skills are always shown first, regardless of source filter.
|
|
"""
|
|
from tools.skills_hub import (
|
|
GitHubAuth, create_source_router,
|
|
)
|
|
|
|
# Clamp page_size to safe range
|
|
page_size = max(1, min(page_size, 100))
|
|
|
|
c = console or _console
|
|
|
|
auth = GitHubAuth()
|
|
sources = create_source_router(auth)
|
|
|
|
# Collect results from all (or filtered) sources
|
|
# Use empty query to get everything; per-source limits prevent overload
|
|
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
|
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
|
|
"claude-marketplace": 50, "lobehub": 50}
|
|
|
|
all_results: list = []
|
|
source_counts: dict = {}
|
|
|
|
for src in sources:
|
|
sid = src.source_id()
|
|
if source != "all" and sid != source and sid != "official":
|
|
# Always include official source for the "first" placement
|
|
continue
|
|
try:
|
|
limit = _PER_SOURCE_LIMIT.get(sid, 50)
|
|
results = src.search("", limit=limit)
|
|
source_counts[sid] = len(results)
|
|
all_results.extend(results)
|
|
except Exception:
|
|
continue
|
|
|
|
if not all_results:
|
|
c.print("[dim]No skills found in the Skills Hub.[/]\n")
|
|
return
|
|
|
|
# Deduplicate by name, preferring higher trust
|
|
seen: dict = {}
|
|
for r in all_results:
|
|
rank = _TRUST_RANK.get(r.trust_level, 0)
|
|
if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0):
|
|
seen[r.name] = r
|
|
deduped = list(seen.values())
|
|
|
|
# Sort: official first, then by trust level (desc), then alphabetically
|
|
deduped.sort(key=lambda r: (
|
|
-_TRUST_RANK.get(r.trust_level, 0),
|
|
r.source != "official",
|
|
r.name.lower(),
|
|
))
|
|
|
|
# Paginate
|
|
total = len(deduped)
|
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
|
page = max(1, min(page, total_pages))
|
|
start = (page - 1) * page_size
|
|
end = min(start + page_size, total)
|
|
page_items = deduped[start:end]
|
|
|
|
# Count official vs other
|
|
official_count = sum(1 for r in deduped if r.source == "official")
|
|
|
|
# Build header
|
|
source_label = f"— {source}" if source != "all" else "— all sources"
|
|
c.print(f"\n[bold]Skills Hub — Browse {source_label}[/]"
|
|
f" [dim]({total} skills, page {page}/{total_pages})[/]")
|
|
if official_count > 0 and page == 1:
|
|
c.print(f"[bright_cyan]★ {official_count} official optional skill(s) from Nous Research[/]")
|
|
c.print()
|
|
|
|
# Build table
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("#", style="dim", width=4, justify="right")
|
|
table.add_column("Name", style="bold cyan", max_width=25)
|
|
table.add_column("Description", max_width=50)
|
|
table.add_column("Source", style="dim", width=12)
|
|
table.add_column("Trust", width=10)
|
|
|
|
for i, r in enumerate(page_items, start=start + 1):
|
|
trust_style = {"builtin": "bright_cyan", "trusted": "green",
|
|
"community": "yellow"}.get(r.trust_level, "dim")
|
|
trust_label = "★ official" if r.source == "official" else r.trust_level
|
|
|
|
desc = r.description[:50]
|
|
if len(r.description) > 50:
|
|
desc += "..."
|
|
|
|
table.add_row(
|
|
str(i),
|
|
r.name,
|
|
desc,
|
|
r.source,
|
|
f"[{trust_style}]{trust_label}[/]",
|
|
)
|
|
|
|
c.print(table)
|
|
|
|
# Navigation hints
|
|
nav_parts = []
|
|
if page > 1:
|
|
nav_parts.append(f"[cyan]--page {page - 1}[/] ← prev")
|
|
if page < total_pages:
|
|
nav_parts.append(f"[cyan]--page {page + 1}[/] → next")
|
|
|
|
if nav_parts:
|
|
c.print(f" {' | '.join(nav_parts)}")
|
|
|
|
# Source summary
|
|
if source == "all" and source_counts:
|
|
parts = [f"{sid}: {ct}" for sid, ct in sorted(source_counts.items())]
|
|
c.print(f" [dim]Sources: {', '.join(parts)}[/]")
|
|
|
|
c.print("[dim]Use: hermes skills inspect <identifier> to preview, "
|
|
"hermes skills install <identifier> to install[/]\n")
|
|
|
|
|
|
def do_install(identifier: str, category: str = "", force: bool = False,
|
|
console: Optional[Console] = None, skip_confirm: bool = False,
|
|
invalidate_cache: bool = True) -> None:
|
|
"""Fetch, quarantine, scan, confirm, and install a skill."""
|
|
from tools.skills_hub import (
|
|
GitHubAuth, create_source_router, ensure_hub_dirs,
|
|
quarantine_bundle, install_from_quarantine, HubLockFile,
|
|
)
|
|
from tools.skills_guard import scan_skill, should_allow_install, format_scan_report
|
|
|
|
c = console or _console
|
|
ensure_hub_dirs()
|
|
|
|
# Resolve which source adapter handles this identifier
|
|
auth = GitHubAuth()
|
|
sources = create_source_router(auth)
|
|
|
|
# If identifier looks like a short name (no slashes), resolve it via search
|
|
if "/" not in identifier:
|
|
identifier = _resolve_short_name(identifier, sources, c)
|
|
if not identifier:
|
|
return
|
|
|
|
c.print(f"\n[bold]Fetching:[/] {identifier}")
|
|
|
|
meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources)
|
|
|
|
if not bundle:
|
|
c.print(f"[bold red]Error:[/] Could not fetch '{identifier}' from any source.\n")
|
|
return
|
|
|
|
# Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox")
|
|
if bundle.source == "official" and not category:
|
|
id_parts = bundle.identifier.split("/") # ["official", "category", "skill"]
|
|
if len(id_parts) >= 3:
|
|
category = id_parts[1]
|
|
|
|
# Check if already installed
|
|
lock = HubLockFile()
|
|
existing = lock.get_installed(bundle.name)
|
|
if existing:
|
|
c.print(f"[yellow]Warning:[/] '{bundle.name}' is already installed at {existing['install_path']}")
|
|
if not force:
|
|
c.print("Use --force to reinstall.\n")
|
|
return
|
|
|
|
extra_metadata = dict(getattr(meta, "extra", {}) or {})
|
|
extra_metadata.update(getattr(bundle, "metadata", {}) or {})
|
|
|
|
# Quarantine the bundle
|
|
q_path = quarantine_bundle(bundle)
|
|
c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]")
|
|
|
|
# Scan
|
|
c.print("[bold]Running security scan...[/]")
|
|
scan_source = getattr(bundle, "identifier", "") or getattr(meta, "identifier", "") or identifier
|
|
result = scan_skill(q_path, source=scan_source)
|
|
c.print(format_scan_report(result))
|
|
|
|
# Check install policy
|
|
allowed, reason = should_allow_install(result, force=force)
|
|
if not allowed:
|
|
c.print(f"\n[bold red]Installation blocked:[/] {reason}")
|
|
# Clean up quarantine
|
|
shutil.rmtree(q_path, ignore_errors=True)
|
|
from tools.skills_hub import append_audit_log
|
|
append_audit_log("BLOCKED", bundle.name, bundle.source,
|
|
bundle.trust_level, result.verdict,
|
|
f"{len(result.findings)}_findings")
|
|
return
|
|
|
|
if extra_metadata:
|
|
metadata_lines = _format_extra_metadata_lines(extra_metadata)
|
|
if metadata_lines:
|
|
c.print(Panel("\n".join(metadata_lines), title="Upstream Metadata", border_style="blue"))
|
|
|
|
# Confirm with user — show appropriate warning based on source
|
|
# skip_confirm bypasses the prompt (needed in TUI mode where input() hangs)
|
|
if not force and not skip_confirm:
|
|
c.print()
|
|
if bundle.source == "official":
|
|
c.print(Panel(
|
|
"[bold bright_cyan]This is an official optional skill maintained by Nous Research.[/]\n\n"
|
|
"It ships with hermes-agent but is not activated by default.\n"
|
|
"Installing will copy it to your skills directory where the agent can use it.\n\n"
|
|
f"Files will be at: [cyan]~/.hermes/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
|
title="Official Skill",
|
|
border_style="bright_cyan",
|
|
))
|
|
else:
|
|
c.print(Panel(
|
|
"[bold yellow]You are installing a third-party skill at your own risk.[/]\n\n"
|
|
"External skills can contain instructions that influence agent behavior,\n"
|
|
"shell commands, and scripts. Even after automated scanning, you should\n"
|
|
"review the installed files before use.\n\n"
|
|
f"Files will be at: [cyan]~/.hermes/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
|
title="Disclaimer",
|
|
border_style="yellow",
|
|
))
|
|
c.print(f"[bold]Install '{bundle.name}'?[/]")
|
|
try:
|
|
answer = input("Confirm [y/N]: ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
answer = "n"
|
|
if answer not in ("y", "yes"):
|
|
c.print("[dim]Installation cancelled.[/]\n")
|
|
shutil.rmtree(q_path, ignore_errors=True)
|
|
return
|
|
|
|
# Install
|
|
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
|
|
from tools.skills_hub import SKILLS_DIR
|
|
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
|
|
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
|
|
|
if invalidate_cache:
|
|
# Invalidate the skills prompt cache so the new skill appears immediately
|
|
try:
|
|
from agent.prompt_builder import clear_skills_system_prompt_cache
|
|
clear_skills_system_prompt_cache(clear_snapshot=True)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
c.print("[dim]Skill will be available in your next session.[/]")
|
|
c.print("[dim]Use /reset to start a new session now, or --now to activate immediately (invalidates prompt cache).[/]\n")
|
|
|
|
|
|
def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
|
|
"""Preview a skill's SKILL.md content without installing."""
|
|
from tools.skills_hub import GitHubAuth, create_source_router
|
|
|
|
c = console or _console
|
|
auth = GitHubAuth()
|
|
sources = create_source_router(auth)
|
|
|
|
if "/" not in identifier:
|
|
identifier = _resolve_short_name(identifier, sources, c)
|
|
if not identifier:
|
|
return
|
|
|
|
meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources)
|
|
|
|
if not meta:
|
|
c.print(f"[bold red]Error:[/] Could not find '{identifier}' in any source.\n")
|
|
return
|
|
|
|
c.print()
|
|
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(meta.trust_level, "dim")
|
|
trust_label = "official" if meta.source == "official" else meta.trust_level
|
|
|
|
info_lines = [
|
|
f"[bold]Name:[/] {meta.name}",
|
|
f"[bold]Description:[/] {meta.description}",
|
|
f"[bold]Source:[/] {meta.source}",
|
|
f"[bold]Trust:[/] [{trust_style}]{trust_label}[/]",
|
|
f"[bold]Identifier:[/] {meta.identifier}",
|
|
]
|
|
if meta.tags:
|
|
info_lines.append(f"[bold]Tags:[/] {', '.join(meta.tags)}")
|
|
info_lines.extend(_format_extra_metadata_lines(meta.extra))
|
|
|
|
c.print(Panel("\n".join(info_lines), title=f"Skill: {meta.name}"))
|
|
|
|
if bundle and "SKILL.md" in bundle.files:
|
|
content = bundle.files["SKILL.md"]
|
|
if isinstance(content, bytes):
|
|
content = content.decode("utf-8", errors="replace")
|
|
# Show first 50 lines as preview
|
|
lines = content.split("\n")
|
|
preview = "\n".join(lines[:50])
|
|
if len(lines) > 50:
|
|
preview += f"\n\n... ({len(lines) - 50} more lines)"
|
|
c.print(Panel(preview, title="SKILL.md Preview", subtitle="hermes skills install <id> to install"))
|
|
|
|
c.print()
|
|
|
|
|
|
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
|
|
"""List installed skills, distinguishing hub, builtin, and local skills."""
|
|
from tools.skills_hub import HubLockFile, ensure_hub_dirs
|
|
from tools.skills_sync import _read_manifest
|
|
from tools.skills_tool import _find_all_skills
|
|
|
|
c = console or _console
|
|
ensure_hub_dirs()
|
|
lock = HubLockFile()
|
|
hub_installed = {e["name"]: e for e in lock.list_installed()}
|
|
builtin_names = set(_read_manifest())
|
|
|
|
all_skills = _find_all_skills()
|
|
|
|
table = Table(title="Installed Skills")
|
|
table.add_column("Name", style="bold cyan")
|
|
table.add_column("Category", style="dim")
|
|
table.add_column("Source", style="dim")
|
|
table.add_column("Trust", style="dim")
|
|
|
|
hub_count = 0
|
|
builtin_count = 0
|
|
local_count = 0
|
|
|
|
for skill in sorted(all_skills, key=lambda s: (s.get("category") or "", s["name"])):
|
|
name = skill["name"]
|
|
category = skill.get("category", "")
|
|
hub_entry = hub_installed.get(name)
|
|
|
|
if hub_entry:
|
|
source_type = "hub"
|
|
source_display = hub_entry.get("source", "hub")
|
|
trust = hub_entry.get("trust_level", "community")
|
|
hub_count += 1
|
|
elif name in builtin_names:
|
|
source_type = "builtin"
|
|
source_display = "builtin"
|
|
trust = "builtin"
|
|
builtin_count += 1
|
|
else:
|
|
source_type = "local"
|
|
source_display = "local"
|
|
trust = "local"
|
|
local_count += 1
|
|
|
|
if source_filter != "all" and source_filter != source_type:
|
|
continue
|
|
|
|
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}.get(trust, "dim")
|
|
trust_label = "official" if source_display == "official" else trust
|
|
table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]")
|
|
|
|
c.print(table)
|
|
c.print(
|
|
f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local[/]\n"
|
|
)
|
|
|
|
|
|
def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None:
|
|
"""Check hub-installed skills for upstream updates."""
|
|
from tools.skills_hub import check_for_skill_updates
|
|
|
|
c = console or _console
|
|
results = check_for_skill_updates(name=name)
|
|
if not results:
|
|
c.print("[dim]No hub-installed skills to check.[/]\n")
|
|
return
|
|
|
|
table = Table(title="Skill Updates")
|
|
table.add_column("Name", style="bold cyan")
|
|
table.add_column("Source", style="dim")
|
|
table.add_column("Status", style="dim")
|
|
|
|
for entry in results:
|
|
table.add_row(entry.get("name", ""), entry.get("source", ""), entry.get("status", ""))
|
|
|
|
c.print(table)
|
|
update_count = sum(1 for entry in results if entry.get("status") == "update_available")
|
|
c.print(f"[dim]{update_count} update(s) available across {len(results)} checked skill(s)[/]\n")
|
|
|
|
|
|
def do_update(name: Optional[str] = None, console: Optional[Console] = None) -> None:
|
|
"""Update hub-installed skills with upstream changes."""
|
|
from tools.skills_hub import HubLockFile, check_for_skill_updates
|
|
|
|
c = console or _console
|
|
lock = HubLockFile()
|
|
updates = [entry for entry in check_for_skill_updates(name=name) if entry.get("status") == "update_available"]
|
|
if not updates:
|
|
c.print("[dim]No updates available.[/]\n")
|
|
return
|
|
|
|
for entry in updates:
|
|
installed = lock.get_installed(entry["name"])
|
|
category = _derive_category_from_install_path(installed.get("install_path", "")) if installed else ""
|
|
c.print(f"[bold]Updating:[/] {entry['name']}")
|
|
do_install(entry["identifier"], category=category, force=True, console=c)
|
|
|
|
c.print(f"[bold green]Updated {len(updates)} skill(s).[/]\n")
|
|
|
|
|
|
def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> None:
|
|
"""Re-run security scan on installed hub skills."""
|
|
from tools.skills_hub import HubLockFile, SKILLS_DIR
|
|
from tools.skills_guard import scan_skill, format_scan_report
|
|
|
|
c = console or _console
|
|
lock = HubLockFile()
|
|
installed = lock.list_installed()
|
|
|
|
if not installed:
|
|
c.print("[dim]No hub-installed skills to audit.[/]\n")
|
|
return
|
|
|
|
targets = installed
|
|
if name:
|
|
targets = [e for e in installed if e["name"] == name]
|
|
if not targets:
|
|
c.print(f"[bold red]Error:[/] '{name}' is not a hub-installed skill.\n")
|
|
return
|
|
|
|
c.print(f"\n[bold]Auditing {len(targets)} skill(s)...[/]\n")
|
|
|
|
for entry in targets:
|
|
skill_path = SKILLS_DIR / entry["install_path"]
|
|
if not skill_path.exists():
|
|
c.print(f"[yellow]Warning:[/] {entry['name']} — path missing: {entry['install_path']}")
|
|
continue
|
|
|
|
result = scan_skill(skill_path, source=entry.get("identifier", entry["source"]))
|
|
c.print(format_scan_report(result))
|
|
c.print()
|
|
|
|
|
|
def do_uninstall(name: str, console: Optional[Console] = None,
|
|
skip_confirm: bool = False,
|
|
invalidate_cache: bool = True) -> None:
|
|
"""Remove a hub-installed skill with confirmation."""
|
|
from tools.skills_hub import uninstall_skill
|
|
|
|
c = console or _console
|
|
|
|
# skip_confirm bypasses the prompt (needed in TUI mode where input() hangs)
|
|
if not skip_confirm:
|
|
c.print(f"\n[bold]Uninstall '{name}'?[/]")
|
|
try:
|
|
answer = input("Confirm [y/N]: ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
answer = "n"
|
|
if answer not in ("y", "yes"):
|
|
c.print("[dim]Cancelled.[/]\n")
|
|
return
|
|
|
|
success, msg = uninstall_skill(name)
|
|
if success:
|
|
c.print(f"[bold green]{msg}[/]\n")
|
|
if invalidate_cache:
|
|
try:
|
|
from agent.prompt_builder import clear_skills_system_prompt_cache
|
|
clear_skills_system_prompt_cache(clear_snapshot=True)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
c.print("[dim]Change will take effect in your next session.[/]")
|
|
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
|
|
else:
|
|
c.print(f"[bold red]Error:[/] {msg}\n")
|
|
|
|
|
|
def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> None:
|
|
"""Manage taps (custom GitHub repo sources)."""
|
|
from tools.skills_hub import TapsManager
|
|
|
|
c = console or _console
|
|
mgr = TapsManager()
|
|
|
|
if action == "list":
|
|
taps = mgr.list_taps()
|
|
if not taps:
|
|
c.print("[dim]No custom taps configured. Using default sources only.[/]\n")
|
|
return
|
|
table = Table(title="Configured Taps")
|
|
table.add_column("Repo", style="bold cyan")
|
|
table.add_column("Path", style="dim")
|
|
for t in taps:
|
|
label = t.get("repo") or t.get("name") or t.get("path", "unknown")
|
|
table.add_row(label, t.get("path", "skills/"))
|
|
c.print(table)
|
|
c.print()
|
|
|
|
elif action == "add":
|
|
if not repo:
|
|
c.print("[bold red]Error:[/] Repo required. Usage: hermes skills tap add owner/repo\n")
|
|
return
|
|
if mgr.add(repo):
|
|
c.print(f"[bold green]Added tap:[/] {repo}\n")
|
|
else:
|
|
c.print(f"[yellow]Tap already exists:[/] {repo}\n")
|
|
|
|
elif action == "remove":
|
|
if not repo:
|
|
c.print("[bold red]Error:[/] Repo required. Usage: hermes skills tap remove owner/repo\n")
|
|
return
|
|
if mgr.remove(repo):
|
|
c.print(f"[bold green]Removed tap:[/] {repo}\n")
|
|
else:
|
|
c.print(f"[bold red]Error:[/] Tap not found: {repo}\n")
|
|
|
|
else:
|
|
c.print(f"[bold red]Unknown tap action:[/] {action}. Use: list, add, remove\n")
|
|
|
|
|
|
def do_publish(skill_path: str, target: str = "github", repo: str = "",
|
|
console: Optional[Console] = None) -> None:
|
|
"""Publish a local skill to a registry (GitHub PR or ClawHub submission)."""
|
|
from tools.skills_hub import GitHubAuth, SKILLS_DIR
|
|
from tools.skills_guard import scan_skill, format_scan_report
|
|
|
|
c = console or _console
|
|
path = Path(skill_path)
|
|
|
|
# Resolve relative to skills dir if not absolute
|
|
if not path.is_absolute():
|
|
path = SKILLS_DIR / path
|
|
if not path.exists() or not (path / "SKILL.md").exists():
|
|
c.print(f"[bold red]Error:[/] No SKILL.md found at {path}\n")
|
|
return
|
|
|
|
# Validate the skill
|
|
import yaml
|
|
skill_md = (path / "SKILL.md").read_text(encoding="utf-8")
|
|
fm = {}
|
|
if skill_md.startswith("---"):
|
|
import re
|
|
match = re.search(r'\n---\s*\n', skill_md[3:])
|
|
if match:
|
|
try:
|
|
fm = yaml.safe_load(skill_md[3:match.start() + 3]) or {}
|
|
except yaml.YAMLError:
|
|
pass
|
|
|
|
name = fm.get("name", path.name)
|
|
description = fm.get("description", "")
|
|
if not description:
|
|
c.print("[bold red]Error:[/] SKILL.md must have a 'description' in frontmatter.\n")
|
|
return
|
|
|
|
# Self-scan before publishing
|
|
c.print(f"[bold]Scanning '{name}' before publish...[/]")
|
|
result = scan_skill(path, source="self")
|
|
c.print(format_scan_report(result))
|
|
if result.verdict == "dangerous":
|
|
c.print("[bold red]Cannot publish a skill with DANGEROUS verdict.[/]\n")
|
|
return
|
|
|
|
if target == "github":
|
|
if not repo:
|
|
c.print("[bold red]Error:[/] --repo required for GitHub publish.\n"
|
|
"Usage: hermes skills publish <path> --to github --repo owner/repo\n")
|
|
return
|
|
|
|
auth = GitHubAuth()
|
|
if not auth.is_authenticated():
|
|
c.print("[bold red]Error:[/] GitHub authentication required.\n"
|
|
"Set GITHUB_TOKEN in ~/.hermes/.env or run 'gh auth login'.\n")
|
|
return
|
|
|
|
c.print(f"[bold]Publishing '{name}' to {repo}...[/]")
|
|
success, msg = _github_publish(path, name, repo, auth)
|
|
if success:
|
|
c.print(f"[bold green]{msg}[/]\n")
|
|
else:
|
|
c.print(f"[bold red]Error:[/] {msg}\n")
|
|
|
|
elif target == "clawhub":
|
|
c.print("[yellow]ClawHub publishing is not yet supported. "
|
|
"Submit manually at https://clawhub.ai/submit[/]\n")
|
|
else:
|
|
c.print(f"[bold red]Unknown target:[/] {target}. Use 'github' or 'clawhub'.\n")
|
|
|
|
|
|
def _github_publish(skill_path: Path, skill_name: str, target_repo: str,
|
|
auth) -> tuple:
|
|
"""Create a PR to a GitHub repo with the skill. Returns (success, message)."""
|
|
import httpx
|
|
|
|
headers = auth.get_headers()
|
|
|
|
# 1. Fork the repo
|
|
try:
|
|
resp = httpx.post(
|
|
f"https://api.github.com/repos/{target_repo}/forks",
|
|
headers=headers, timeout=30,
|
|
)
|
|
if resp.status_code in (200, 202):
|
|
fork = resp.json()
|
|
fork_repo = fork["full_name"]
|
|
elif resp.status_code == 403:
|
|
return False, "GitHub token lacks permission to fork repos"
|
|
else:
|
|
return False, f"Failed to fork {target_repo}: {resp.status_code}"
|
|
except httpx.HTTPError as e:
|
|
return False, f"Network error forking repo: {e}"
|
|
|
|
# 2. Get default branch
|
|
try:
|
|
resp = httpx.get(
|
|
f"https://api.github.com/repos/{target_repo}",
|
|
headers=headers, timeout=15,
|
|
)
|
|
default_branch = resp.json().get("default_branch", "main")
|
|
except Exception:
|
|
default_branch = "main"
|
|
|
|
# 3. Get the base tree SHA
|
|
try:
|
|
resp = httpx.get(
|
|
f"https://api.github.com/repos/{fork_repo}/git/refs/heads/{default_branch}",
|
|
headers=headers, timeout=15,
|
|
)
|
|
base_sha = resp.json()["object"]["sha"]
|
|
except Exception as e:
|
|
return False, f"Failed to get base branch: {e}"
|
|
|
|
# 4. Create a new branch
|
|
branch_name = f"add-skill-{skill_name}"
|
|
try:
|
|
httpx.post(
|
|
f"https://api.github.com/repos/{fork_repo}/git/refs",
|
|
headers=headers, timeout=15,
|
|
json={"ref": f"refs/heads/{branch_name}", "sha": base_sha},
|
|
)
|
|
except Exception as e:
|
|
return False, f"Failed to create branch: {e}"
|
|
|
|
# 5. Upload skill files
|
|
for f in skill_path.rglob("*"):
|
|
if not f.is_file():
|
|
continue
|
|
rel = str(f.relative_to(skill_path))
|
|
upload_path = f"skills/{skill_name}/{rel}"
|
|
try:
|
|
import base64
|
|
content_b64 = base64.b64encode(f.read_bytes()).decode()
|
|
httpx.put(
|
|
f"https://api.github.com/repos/{fork_repo}/contents/{upload_path}",
|
|
headers=headers, timeout=15,
|
|
json={
|
|
"message": f"Add {skill_name} skill: {rel}",
|
|
"content": content_b64,
|
|
"branch": branch_name,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
return False, f"Failed to upload {rel}: {e}"
|
|
|
|
# 6. Create PR
|
|
try:
|
|
resp = httpx.post(
|
|
f"https://api.github.com/repos/{target_repo}/pulls",
|
|
headers=headers, timeout=15,
|
|
json={
|
|
"title": f"Add skill: {skill_name}",
|
|
"body": f"Submitting the `{skill_name}` skill via Hermes Skills Hub.\n\n"
|
|
f"This skill was scanned by the Hermes Skills Guard before submission.",
|
|
"head": f"{fork_repo.split('/')[0]}:{branch_name}",
|
|
"base": default_branch,
|
|
},
|
|
)
|
|
if resp.status_code == 201:
|
|
pr_url = resp.json().get("html_url", "")
|
|
return True, f"PR created: {pr_url}"
|
|
else:
|
|
return False, f"Failed to create PR: {resp.status_code} {resp.text[:200]}"
|
|
except httpx.HTTPError as e:
|
|
return False, f"Network error creating PR: {e}"
|
|
|
|
|
|
def do_snapshot_export(output_path: str, console: Optional[Console] = None) -> None:
|
|
"""Export current hub skill configuration to a portable JSON file."""
|
|
from tools.skills_hub import HubLockFile, TapsManager
|
|
|
|
c = console or _console
|
|
lock = HubLockFile()
|
|
taps = TapsManager()
|
|
|
|
installed = lock.list_installed()
|
|
tap_list = taps.list_taps()
|
|
|
|
snapshot = {
|
|
"hermes_version": "0.1.0",
|
|
"exported_at": __import__("datetime").datetime.now(
|
|
__import__("datetime").timezone.utc
|
|
).isoformat(),
|
|
"skills": [
|
|
{
|
|
"name": entry["name"],
|
|
"source": entry.get("source", ""),
|
|
"identifier": entry.get("identifier", ""),
|
|
"category": str(Path(entry.get("install_path", "")).parent)
|
|
if "/" in entry.get("install_path", "") else "",
|
|
}
|
|
for entry in installed
|
|
],
|
|
"taps": tap_list,
|
|
}
|
|
|
|
out = Path(output_path)
|
|
out.write_text(json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n")
|
|
c.print(f"[bold green]Snapshot exported:[/] {out}")
|
|
c.print(f"[dim]{len(installed)} skill(s), {len(tap_list)} tap(s)[/]\n")
|
|
|
|
|
|
def do_snapshot_import(input_path: str, force: bool = False,
|
|
console: Optional[Console] = None) -> None:
|
|
"""Re-install skills from a snapshot file."""
|
|
from tools.skills_hub import TapsManager
|
|
|
|
c = console or _console
|
|
inp = Path(input_path)
|
|
if not inp.exists():
|
|
c.print(f"[bold red]Error:[/] File not found: {inp}\n")
|
|
return
|
|
|
|
try:
|
|
snapshot = json.loads(inp.read_text())
|
|
except json.JSONDecodeError:
|
|
c.print(f"[bold red]Error:[/] Invalid JSON in {inp}\n")
|
|
return
|
|
|
|
# Restore taps first
|
|
taps = snapshot.get("taps", [])
|
|
if taps:
|
|
mgr = TapsManager()
|
|
for tap in taps:
|
|
repo = tap.get("repo", "")
|
|
if repo:
|
|
mgr.add(repo, tap.get("path", "skills/"))
|
|
c.print(f"[dim]Restored {len(taps)} tap(s)[/]")
|
|
|
|
# Install skills
|
|
skills = snapshot.get("skills", [])
|
|
if not skills:
|
|
c.print("[dim]No skills in snapshot to install.[/]\n")
|
|
return
|
|
|
|
c.print(f"[bold]Importing {len(skills)} skill(s) from snapshot...[/]\n")
|
|
for entry in skills:
|
|
identifier = entry.get("identifier", "")
|
|
category = entry.get("category", "")
|
|
if not identifier:
|
|
c.print(f"[yellow]Skipping entry with no identifier: {entry.get('name', '?')}[/]")
|
|
continue
|
|
|
|
c.print(f"[bold]--- {entry.get('name', identifier)} ---[/]")
|
|
do_install(identifier, category=category, force=force, console=c)
|
|
|
|
c.print("[bold green]Snapshot import complete.[/]\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI argparse entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def skills_command(args) -> None:
|
|
"""Router for `hermes skills <subcommand>` — called from hermes_cli/main.py."""
|
|
action = getattr(args, "skills_action", None)
|
|
|
|
if action == "browse":
|
|
do_browse(page=args.page, page_size=args.size, source=args.source)
|
|
elif action == "search":
|
|
do_search(args.query, source=args.source, limit=args.limit)
|
|
elif action == "install":
|
|
do_install(args.identifier, category=args.category, force=args.force,
|
|
skip_confirm=getattr(args, "yes", False))
|
|
elif action == "inspect":
|
|
do_inspect(args.identifier)
|
|
elif action == "list":
|
|
do_list(source_filter=args.source)
|
|
elif action == "check":
|
|
do_check(name=getattr(args, "name", None))
|
|
elif action == "update":
|
|
do_update(name=getattr(args, "name", None))
|
|
elif action == "audit":
|
|
do_audit(name=getattr(args, "name", None))
|
|
elif action == "uninstall":
|
|
do_uninstall(args.name)
|
|
elif action == "publish":
|
|
do_publish(
|
|
args.skill_path,
|
|
target=getattr(args, "to", "github"),
|
|
repo=getattr(args, "repo", ""),
|
|
)
|
|
elif action == "snapshot":
|
|
snap_action = getattr(args, "snapshot_action", None)
|
|
if snap_action == "export":
|
|
do_snapshot_export(args.output)
|
|
elif snap_action == "import":
|
|
do_snapshot_import(args.input, force=getattr(args, "force", False))
|
|
else:
|
|
_console.print("Usage: hermes skills snapshot [export|import]\n")
|
|
elif action == "tap":
|
|
tap_action = getattr(args, "tap_action", None)
|
|
repo = getattr(args, "repo", "") or getattr(args, "name", "")
|
|
if not tap_action:
|
|
_console.print("Usage: hermes skills tap [list|add|remove]\n")
|
|
return
|
|
do_tap(tap_action, repo=repo)
|
|
else:
|
|
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|publish|snapshot|tap]\n")
|
|
_console.print("Run 'hermes skills <command> --help' for details.\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slash command entry point (/skills in chat)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
|
"""
|
|
Parse and dispatch `/skills <subcommand> [args]` from the chat interface.
|
|
|
|
Examples:
|
|
/skills search kubernetes
|
|
/skills install openai/skills/skill-creator
|
|
/skills install openai/skills/skill-creator --force
|
|
/skills inspect openai/skills/skill-creator
|
|
/skills list
|
|
/skills list --source hub
|
|
/skills check
|
|
/skills update
|
|
/skills audit
|
|
/skills audit my-skill
|
|
/skills uninstall my-skill
|
|
/skills tap list
|
|
/skills tap add owner/repo
|
|
/skills tap remove owner/repo
|
|
"""
|
|
c = console or _console
|
|
parts = cmd.strip().split()
|
|
|
|
# Strip the leading "/skills" if present
|
|
if parts and parts[0].lower() == "/skills":
|
|
parts = parts[1:]
|
|
|
|
if not parts:
|
|
_print_skills_help(c)
|
|
return
|
|
|
|
action = parts[0].lower()
|
|
args = parts[1:]
|
|
|
|
if action == "browse":
|
|
page = 1
|
|
page_size = 20
|
|
source = "all"
|
|
i = 0
|
|
while i < len(args):
|
|
if args[i] == "--page" and i + 1 < len(args):
|
|
try:
|
|
page = int(args[i + 1])
|
|
except ValueError:
|
|
pass
|
|
i += 2
|
|
elif args[i] == "--size" and i + 1 < len(args):
|
|
try:
|
|
page_size = int(args[i + 1])
|
|
except ValueError:
|
|
pass
|
|
i += 2
|
|
elif args[i] == "--source" and i + 1 < len(args):
|
|
source = args[i + 1]
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
do_browse(page=page, page_size=page_size, source=source, console=c)
|
|
|
|
elif action == "search":
|
|
if not args:
|
|
c.print("[bold red]Usage:[/] /skills search <query> [--source skills-sh|well-known|github|official] [--limit N]\n")
|
|
return
|
|
source = "all"
|
|
limit = 10
|
|
query_parts = []
|
|
i = 0
|
|
while i < len(args):
|
|
if args[i] == "--source" and i + 1 < len(args):
|
|
source = args[i + 1]
|
|
i += 2
|
|
elif args[i] == "--limit" and i + 1 < len(args):
|
|
try:
|
|
limit = int(args[i + 1])
|
|
except ValueError:
|
|
pass
|
|
i += 2
|
|
else:
|
|
query_parts.append(args[i])
|
|
i += 1
|
|
do_search(" ".join(query_parts), source=source, limit=limit, console=c)
|
|
|
|
elif action == "install":
|
|
if not args:
|
|
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force] [--now]\n")
|
|
return
|
|
identifier = args[0]
|
|
category = ""
|
|
# Slash commands run inside prompt_toolkit where input() hangs.
|
|
# Always skip confirmation — the user typing the command is implicit consent.
|
|
skip_confirm = True
|
|
force = "--force" in args
|
|
# --now invalidates prompt cache immediately (costs more money).
|
|
# Default: defer to next session to preserve cache.
|
|
invalidate_cache = "--now" in args
|
|
for i, a in enumerate(args):
|
|
if a == "--category" and i + 1 < len(args):
|
|
category = args[i + 1]
|
|
do_install(identifier, category=category, force=force,
|
|
skip_confirm=skip_confirm, invalidate_cache=invalidate_cache,
|
|
console=c)
|
|
|
|
elif action == "inspect":
|
|
if not args:
|
|
c.print("[bold red]Usage:[/] /skills inspect <identifier>\n")
|
|
return
|
|
do_inspect(args[0], console=c)
|
|
|
|
elif action == "list":
|
|
source_filter = "all"
|
|
if "--source" in args:
|
|
idx = args.index("--source")
|
|
if idx + 1 < len(args):
|
|
source_filter = args[idx + 1]
|
|
do_list(source_filter=source_filter, console=c)
|
|
|
|
elif action == "check":
|
|
name = args[0] if args else None
|
|
do_check(name=name, console=c)
|
|
|
|
elif action == "update":
|
|
name = args[0] if args else None
|
|
do_update(name=name, console=c)
|
|
|
|
elif action == "audit":
|
|
name = args[0] if args else None
|
|
do_audit(name=name, console=c)
|
|
|
|
elif action == "uninstall":
|
|
if not args:
|
|
c.print("[bold red]Usage:[/] /skills uninstall <name> [--now]\n")
|
|
return
|
|
# Slash commands run inside prompt_toolkit where input() hangs.
|
|
skip_confirm = True
|
|
invalidate_cache = "--now" in args
|
|
do_uninstall(args[0], console=c, skip_confirm=skip_confirm,
|
|
invalidate_cache=invalidate_cache)
|
|
|
|
elif action == "publish":
|
|
if not args:
|
|
c.print("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n")
|
|
return
|
|
skill_path = args[0]
|
|
target = "github"
|
|
repo = ""
|
|
for i, a in enumerate(args):
|
|
if a == "--to" and i + 1 < len(args):
|
|
target = args[i + 1]
|
|
if a == "--repo" and i + 1 < len(args):
|
|
repo = args[i + 1]
|
|
do_publish(skill_path, target=target, repo=repo, console=c)
|
|
|
|
elif action == "snapshot":
|
|
if not args:
|
|
c.print("[bold red]Usage:[/] /skills snapshot export <file> | /skills snapshot import <file>\n")
|
|
return
|
|
snap_action = args[0]
|
|
if snap_action == "export" and len(args) > 1:
|
|
do_snapshot_export(args[1], console=c)
|
|
elif snap_action == "import" and len(args) > 1:
|
|
force = "--force" in args
|
|
do_snapshot_import(args[1], force=force, console=c)
|
|
else:
|
|
c.print("[bold red]Usage:[/] /skills snapshot export <file> | /skills snapshot import <file>\n")
|
|
|
|
elif action == "tap":
|
|
if not args:
|
|
do_tap("list", console=c)
|
|
return
|
|
tap_action = args[0]
|
|
repo = args[1] if len(args) > 1 else ""
|
|
do_tap(tap_action, repo=repo, console=c)
|
|
|
|
elif action in ("help", "--help", "-h"):
|
|
_print_skills_help(c)
|
|
|
|
else:
|
|
c.print(f"[bold red]Unknown action:[/] {action}")
|
|
_print_skills_help(c)
|
|
|
|
|
|
def _print_skills_help(console: Console) -> None:
|
|
"""Print help for the /skills slash command."""
|
|
console.print(Panel(
|
|
"[bold]Skills Hub Commands:[/]\n\n"
|
|
" [cyan]browse[/] [--source official] Browse all available skills (paginated)\n"
|
|
" [cyan]search[/] <query> Search registries for skills\n"
|
|
" [cyan]install[/] <identifier> Install a skill (with security scan)\n"
|
|
" [cyan]inspect[/] <identifier> Preview a skill without installing\n"
|
|
" [cyan]list[/] [--source hub|builtin|local] List installed skills\n"
|
|
" [cyan]check[/] [name] Check hub skills for upstream updates\n"
|
|
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
|
|
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
|
|
" [cyan]uninstall[/] <name> Remove a hub-installed skill\n"
|
|
" [cyan]publish[/] <path> --repo <r> Publish a skill to GitHub via PR\n"
|
|
" [cyan]snapshot[/] export|import Export/import skill configurations\n"
|
|
" [cyan]tap[/] list|add|remove Manage skill sources\n",
|
|
title="/skills",
|
|
))
|