# Conflicts: # hermes_cli/env_loader.py # hermes_cli/urllib_security.py # tools/terminal_scope.py
1531 lines
72 KiB
Python
1531 lines
72 KiB
Python
#!/usr/bin/env python3
|
|
"""Skills Hub CLI — Unified interface for the Hermes Skills Hub."""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
|
|
# tools.skills_hub / tools.skills_guard are imported inside functions (cycles + startup cost).
|
|
from hermes_constants import display_hermes_home
|
|
|
|
_console = Console()
|
|
|
|
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
_VALID_CATEGORY_RE = re.compile(r"^[a-z][a-z0-9_/-]*$")
|
|
_TRUST_STYLE = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}
|
|
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
|
# Browse per-source limits. With the centralized index available, parallel_search_sources
|
|
# serves everything from "hermes-index", so that entry MUST exceed the whole catalog or browse
|
|
# silently caps the hub (50 and 5000 both truncated). The index is disk-cached and browse
|
|
# paginates client-side. The external limits only apply when the index is unavailable.
|
|
_BROWSE_LIMITS = {
|
|
"hermes-index": 1000000, "official": 200, "skills-sh": 200, "well-known": 50,
|
|
"github": 200, "clawhub": 500, "lobehub": 500, "browse-sh": 500}
|
|
# Programmatic (TUI gateway) browse keeps its own, lower caps.
|
|
_BROWSE_API_LIMITS = {
|
|
"hermes-index": 5000, "official": 100, "skills-sh": 100, "well-known": 25,
|
|
"github": 100, "clawhub": 50, "lobehub": 50, "browse-sh": 500}
|
|
_EXTRA_META_LABELS = (
|
|
("repo_url", "Repo"), ("detail_url", "Detail Page"), ("index_url", "Index"),
|
|
("endpoint", "Endpoint"), ("install_command", "Install Command"),
|
|
("installs", "Installs"), ("weekly_installs", "Weekly Installs"))
|
|
|
|
# --- Small shared helpers ---
|
|
|
|
def _display_source(r) -> str:
|
|
"""Source label for a result row: GitHub-tap rows surface their per-tap provider label."""
|
|
return ((r.extra or {}).get("provider") if r.source == "github" else None) or r.source
|
|
|
|
|
|
def _trust_cell(trust_level: str, source: str, official_label: str = "official") -> str:
|
|
"""Rich-styled trust cell; official-source rows show `official_label` instead of the level."""
|
|
label = official_label if source == "official" else trust_level
|
|
return f"[{_TRUST_STYLE.get(trust_level, 'dim')}]{label}[/]"
|
|
|
|
|
|
def _row(r, *fields) -> dict:
|
|
"""`{field: getattr(r, field)}` projection of a result/meta object, in the given order."""
|
|
return {f: getattr(r, f) for f in fields}
|
|
|
|
|
|
def _truncate(text: str, width: int) -> str:
|
|
return text[:width] + ("..." if len(text) > width else "")
|
|
|
|
|
|
def _ident_col(style: str) -> tuple:
|
|
# overflow="fold" keeps the full slug visible (wraps instead of ellipsis-truncating):
|
|
# browse.sh slugs end in a `-XXXXXX` hash that is part of the identifier users must
|
|
# copy into `hermes skills install`.
|
|
return "Identifier", {"style": style, "overflow": "fold", "no_wrap": False}
|
|
|
|
|
|
def _table(*columns, **table_kw) -> Table:
|
|
"""Rich table from (header, add_column-kwargs) pairs; a bare header is a dim column."""
|
|
table = Table(**table_kw)
|
|
for col in columns:
|
|
header, kw = col if isinstance(col, tuple) else (col, {"style": "dim"})
|
|
table.add_column(header, **kw)
|
|
return table
|
|
|
|
|
|
def _try(fn, *args):
|
|
"""`fn(*args)`, or None when it raises (adapter probes are best-effort)."""
|
|
try:
|
|
return fn(*args)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _sources():
|
|
"""Source router over all registries (authenticated GitHub when available)."""
|
|
from tools.skills_hub_github import GitHubAuth
|
|
from tools.skills_hub_search import create_source_router
|
|
return create_source_router(GitHubAuth())
|
|
|
|
|
|
def _confirm() -> bool:
|
|
"""Prompt `Confirm [y/N]:`; EOF/Ctrl-C counts as no."""
|
|
try:
|
|
return input("Confirm [y/N]: ").strip().lower() in {"y", "yes"}
|
|
except (EOFError, KeyboardInterrupt):
|
|
return False
|
|
|
|
|
|
def _confirm_or_cancel(c: Console, *lines: str, cancel: str = "[dim]Cancelled.[/]\n") -> bool:
|
|
"""Print `lines`, ask `_confirm()`; on refusal print `cancel` and return False."""
|
|
for line in lines:
|
|
c.print(line)
|
|
if _confirm():
|
|
return True
|
|
c.print(cancel)
|
|
return False
|
|
|
|
|
|
def _clear_skills_cache() -> None:
|
|
"""Invalidate the skills prompt cache so the change appears immediately."""
|
|
try:
|
|
from agent.prompt_builder import clear_skills_system_prompt_cache
|
|
clear_skills_system_prompt_cache(clear_snapshot=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _finish_change(c: Console, invalidate_cache: bool, what: str = "Change will take effect",
|
|
verb: str = "apply", notice: bool = True) -> None:
|
|
"""Apply-now (cache clear) or, when `notice`, tell the user the change lands next session."""
|
|
if invalidate_cache:
|
|
_clear_skills_cache()
|
|
return
|
|
if not notice:
|
|
return
|
|
c.print(f"[dim]{what} in your next session.[/]")
|
|
c.print(f"[dim]Use /reset to start a new session now, or --now to {verb} immediately (invalidates prompt cache).[/]\n")
|
|
|
|
|
|
def _print_error(c: Console, message: str) -> None:
|
|
c.print(f"[bold red]Error:[/] {message}\n")
|
|
|
|
|
|
def _print_listed(c: Console, label: str, items) -> None:
|
|
"""`[dim]Label: a, b[/]` when `items` is non-empty."""
|
|
if items:
|
|
c.print(f"[dim]{label}: {', '.join(items)}[/]")
|
|
|
|
|
|
def _report_pair(c: Console, success: bool, msg: str) -> bool:
|
|
"""`(success, message)` result: green message + blank line, or red error."""
|
|
if success:
|
|
c.print(f"[bold green]{msg}[/]\n")
|
|
else:
|
|
_print_error(c, msg)
|
|
return success
|
|
|
|
|
|
def _report_ok(c: Console, result: dict, fallback: str = "") -> bool:
|
|
"""skills_sync-style `{ok, message}` result: green message on success, red error otherwise."""
|
|
if result.get("ok"):
|
|
c.print(f"[bold green]{result['message']}[/]")
|
|
return True
|
|
_print_error(c, result.get("message", fallback))
|
|
return False
|
|
|
|
|
|
def _skill_md_preview(bundle) -> Optional[str]:
|
|
"""First 50 lines of the bundle's SKILL.md (None when absent)."""
|
|
if not bundle or "SKILL.md" not in bundle.files:
|
|
return None
|
|
content = bundle.files["SKILL.md"]
|
|
if isinstance(content, bytes):
|
|
content = content.decode("utf-8", errors="replace")
|
|
lines = content.split("\n")
|
|
more = f"\n\n... ({len(lines) - 50} more lines)" if len(lines) > 50 else ""
|
|
return "\n".join(lines[:50]) + more
|
|
|
|
|
|
def _format_extra_metadata_lines(extra: Dict[str, Any]) -> list[str]:
|
|
lines: list[str] = []
|
|
for key, label in _EXTRA_META_LABELS:
|
|
value = extra.get(key)
|
|
if value is not None and (value or key == "installs"): # installs: 0 is still shown
|
|
lines.append(f"[bold]{label}:[/] {value}")
|
|
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
|
|
|
|
|
|
# --- Identifier / source resolution ---
|
|
|
|
def _resolve_short_name(name: str, sources, console: Console) -> str:
|
|
"""Short name -> full identifier via search; "" when ambiguous/missing (one exact match wins,
|
|
several -> the single official one, else they are listed)."""
|
|
from tools.skills_hub_search import unified_search
|
|
c = console or _console
|
|
c.print(f"[dim]Resolving '{name}'...[/]")
|
|
results = unified_search(name, sources, source_filter="all", limit=20)
|
|
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:
|
|
official = [r for r in exact if r.source == "official"] # outranks community mirrors
|
|
if len(official) == 1:
|
|
c.print(f"[dim]Resolved to: {official[0].identifier} (official catalog)[/]")
|
|
return official[0].identifier
|
|
c.print(f"\n[yellow]Multiple skills named '{name}' found:[/]")
|
|
table = _table("Source", "Trust", _ident_col("bold cyan"))
|
|
for r in exact:
|
|
table.add_row(r.source, _trust_cell(r.trust_level, r.source), r.identifier)
|
|
c.print(table)
|
|
c.print("[bold]Use the full identifier to install a specific one.[/]\n")
|
|
return ""
|
|
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 ""
|
|
_print_error(c, f"No skill named '{name}' found in any source.")
|
|
return ""
|
|
|
|
|
|
def _resolve_source_meta_and_bundle(identifier: str, sources):
|
|
"""(meta, bundle, source) from ONE adapter — mixing skills.sh metadata with a ClawHub zip of
|
|
a same-named skill once showed the wrong SKILL.md. Falls back to the first meta-only hit.
|
|
"""
|
|
first_meta = None
|
|
first_meta_source = None
|
|
for src in sources:
|
|
meta = _try(src.inspect, identifier)
|
|
bundle = _try(src.fetch, identifier)
|
|
if bundle:
|
|
if meta is None:
|
|
meta = _try(src.inspect, identifier)
|
|
return meta, bundle, src
|
|
if first_meta is None and meta:
|
|
first_meta, first_meta_source = meta, src
|
|
return first_meta, None, first_meta_source
|
|
|
|
|
|
def _full_identifier(identifier: str, sources, c) -> str:
|
|
"""Identifiers without a slash are short names; "" when they cannot be resolved."""
|
|
return identifier if "/" in identifier else _resolve_short_name(identifier, sources, c)
|
|
|
|
|
|
def _resolve_identifier(identifier: str, sources, c) -> tuple:
|
|
"""Short-name resolution + (meta, bundle, source). identifier == "" means unresolved."""
|
|
from tools.skills_hub import skills_hub_http_session
|
|
with skills_hub_http_session():
|
|
identifier = _full_identifier(identifier, sources, c)
|
|
if not identifier:
|
|
return "", None, None, None
|
|
return (identifier, *_resolve_source_meta_and_bundle(identifier, sources))
|
|
|
|
|
|
def _is_valid_installed_skill_name(name: str) -> bool:
|
|
"""Accept identifier-shaped names, reject empty / sentinel-y values."""
|
|
candidate = name.strip().lower() if isinstance(name, str) else ""
|
|
return bool(candidate not in {"", "skill", "readme", "index", "unnamed-skill"}
|
|
and _VALID_NAME_RE.match(candidate))
|
|
|
|
|
|
def _existing_categories() -> List[str]:
|
|
"""Sorted category buckets under ``~/.hermes/skills/`` (children without their own SKILL.md)."""
|
|
from tools.skills_hub import SKILLS_DIR
|
|
from tools.skills_hub_install import _category_skill_dirs
|
|
try:
|
|
return sorted(name for name in set(_category_skill_dirs(SKILLS_DIR))
|
|
if not (SKILLS_DIR / name / "SKILL.md").exists())
|
|
except OSError: # FileNotFoundError is an OSError
|
|
return []
|
|
|
|
|
|
def _line_input(prompt: str) -> Optional[str]:
|
|
"""Stripped interactive line; None on EOF/Ctrl-C."""
|
|
from hermes_cli.cli_output import line_input
|
|
try:
|
|
return line_input(prompt).strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
return None
|
|
|
|
|
|
def _prompt_for_skill_name(c: Console, url: str, default: str = "") -> Optional[str]:
|
|
"""Prompt interactively for a skill name. Returns None on cancel/EOF."""
|
|
c.print()
|
|
c.print(f"[yellow]The SKILL.md at {url} doesn't declare a `name:` in its frontmatter,[/]\n"
|
|
"[yellow]and the URL path doesn't produce a valid identifier either.[/]")
|
|
c.print(f"[bold]Enter a skill name{f' [{default}]' if default else ''}:[/] "
|
|
"[dim](lowercase letters, digits, hyphens, underscores; starts with a letter)[/]")
|
|
answer = _line_input("Name: ")
|
|
if answer is None:
|
|
return None
|
|
answer = answer or default
|
|
if not _is_valid_installed_skill_name(answer):
|
|
c.print(f"[bold red]Invalid name:[/] {answer!r}. Aborting install.\n")
|
|
return None
|
|
return answer
|
|
|
|
|
|
def _prompt_for_category(c: Console, existing: List[str]) -> str:
|
|
"""Prompt interactively for a category. Empty/None input means flat install."""
|
|
c.print()
|
|
if existing:
|
|
c.print("[bold]Pick a category[/] "
|
|
"[dim](reuse an existing bucket, type a new one, or press Enter to install flat)[/]")
|
|
c.print(f"[dim]Existing: {', '.join(existing)}[/]")
|
|
else:
|
|
c.print("[bold]Category[/] "
|
|
f"[dim](optional — press Enter to install flat at {display_hermes_home()}/skills/<name>/)[/]")
|
|
answer = _line_input("Category: ")
|
|
if answer and not _VALID_CATEGORY_RE.match(answer):
|
|
c.print(f"[dim]Invalid category {answer!r} — installing flat.[/]")
|
|
return ""
|
|
return answer or ""
|
|
|
|
|
|
# --- search / browse / inspect ---
|
|
|
|
def do_search(query: str, source: str = "all", limit: int = 10, console: Optional[Console] = None,
|
|
as_json: bool = False) -> None:
|
|
"""Search registries -> Rich table, or a clean JSON array (``as_json``) for scripting."""
|
|
from tools.skills_hub_search import unified_search
|
|
c = console or _console
|
|
sources = _sources()
|
|
if as_json:
|
|
results = unified_search(query, sources, source_filter=source, limit=limit)
|
|
print(json.dumps([_row(r, "name", "identifier", "source", "trust_level", "description")
|
|
for r in results], indent=2))
|
|
return
|
|
|
|
c.print(f"\n[bold]Searching for:[/] {query}")
|
|
with c.status("[bold]Searching registries..."):
|
|
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(("Name", {"style": "bold cyan"}), ("Description", {"max_width": 60}), "Source",
|
|
"Trust", _ident_col("dim"), title=f"Skills Hub — {len(results)} result(s)")
|
|
for r in results:
|
|
table.add_row(r.name, _truncate(r.description, 60), _display_source(r),
|
|
_trust_cell(r.trust_level, r.source), r.identifier)
|
|
c.print(table)
|
|
c.print("[dim]Use: hermes skills inspect <identifier> to preview, "
|
|
"hermes skills install <identifier> to install "
|
|
"(--json for scripting)[/]\n")
|
|
|
|
|
|
def _rank_and_page(all_results, page: int, page_size: int):
|
|
"""Dedupe by identifier (higher trust wins; names are NOT unique across browse-sh sites),
|
|
sort official-first, slice one page -> (deduped, page_items, page, total_pages, start)."""
|
|
rank = lambda r: _TRUST_RANK.get(r.trust_level, 0) # noqa: E731
|
|
seen: dict = {}
|
|
for r in all_results:
|
|
if r.identifier not in seen or rank(r) > rank(seen[r.identifier]):
|
|
seen[r.identifier] = r
|
|
deduped = sorted(seen.values(),
|
|
key=lambda r: (-rank(r), r.source != "official", r.name.lower()))
|
|
total_pages = max(1, (len(deduped) + page_size - 1) // page_size)
|
|
page = max(1, min(page, total_pages))
|
|
start = (page - 1) * page_size
|
|
return deduped, deduped[start:start + page_size], page, total_pages, start
|
|
|
|
|
|
def _fetch_browse_results(c: Console, source: str):
|
|
"""Parallel fetch from all (or filtered) sources with a live per-source progress spinner."""
|
|
from tools.skills_hub_search import parallel_search_sources
|
|
with c.status("[bold]Fetching skills from registries...") as status:
|
|
# parallel_search_sources invokes the callback from the collecting thread as each
|
|
# source completes; the page itself is rendered once over the final, fully sorted set.
|
|
_done: List[str] = []
|
|
|
|
def _on_source_done(sid: str, count: int) -> None:
|
|
_done.append(f"{sid} ({count})")
|
|
status.update(
|
|
f"[bold]Fetching skills from registries...[/] [dim]done: {', '.join(_done)}[/]")
|
|
return parallel_search_sources(
|
|
_sources(), query="", per_source_limits=_BROWSE_LIMITS, source_filter=source,
|
|
overall_timeout=30, on_source_done=_on_source_done)
|
|
|
|
|
|
def _render_browse_page(c: Console, deduped, page_items, page: int, total_pages: int,
|
|
start: int, source: str, source_counts, timed_out) -> None:
|
|
official_count = sum(1 for r in deduped if r.source == "official")
|
|
loaded_label = f"{len(deduped)} skills loaded" + (
|
|
f", {len(timed_out)} source(s) still loading" if timed_out else "")
|
|
c.print(f"\n[bold]Skills Hub — Browse — {source if source != 'all' else 'all sources'}[/]"
|
|
f" [dim]({loaded_label}, 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()
|
|
|
|
table = _table(("#", {"style": "dim", "width": 4, "justify": "right"}),
|
|
("Name", {"style": "bold cyan", "max_width": 22}),
|
|
("Description", {"max_width": 44}), ("Source", {"style": "dim", "width": 12}),
|
|
("Trust", {"width": 10}),
|
|
_ident_col("dim"), show_header=True, header_style="bold")
|
|
for i, r in enumerate(page_items, start=start + 1):
|
|
table.add_row(str(i), r.name, _truncate(r.description, 44), _display_source(r),
|
|
_trust_cell(r.trust_level, r.source, official_label="★ official"),
|
|
r.identifier)
|
|
c.print(table)
|
|
|
|
nav_parts = ([f"[cyan]--page {page - 1}[/] ← prev"] if page > 1 else []) + (
|
|
[f"[cyan]--page {page + 1}[/] → next"] if page < total_pages else [])
|
|
if nav_parts:
|
|
c.print(f" {' | '.join(nav_parts)}")
|
|
if source == "all" and source_counts:
|
|
c.print(f" [dim]Sources: {', '.join(f'{sid}: {ct}' for sid, ct in sorted(source_counts.items()))}[/]")
|
|
if timed_out:
|
|
c.print(f" [yellow]⚡ Slow sources skipped: {', '.join(timed_out)} "
|
|
f"— run again for cached results[/]")
|
|
c.print("[dim]Tip: 'hermes skills inspect <identifier>' to preview, "
|
|
"'hermes skills install <identifier>' to install, "
|
|
"'hermes skills search <query>' to search deeper[/]\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 first."""
|
|
page_size = max(1, min(page_size, 100))
|
|
c = console or _console
|
|
all_results, source_counts, timed_out = _fetch_browse_results(c, source)
|
|
from tools.skills_hub_github import _provider_filter_of
|
|
if not all_results:
|
|
# Provider narrowing happens inside parallel_search_sources; keep the
|
|
# provider-specific empty message.
|
|
if _provider_filter_of(source):
|
|
c.print(f"[dim]No skills found for provider '{source}'.[/]\n")
|
|
else:
|
|
c.print("[dim]No skills found in the Skills Hub.[/]\n")
|
|
return
|
|
deduped, page_items, page, total_pages, start = _rank_and_page(all_results, page, page_size)
|
|
_render_browse_page(c, deduped, page_items, page, total_pages, start, source,
|
|
source_counts, timed_out)
|
|
|
|
|
|
def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> dict:
|
|
"""Paginated hub browse for programmatic callers (e.g. TUI gateway)."""
|
|
from tools.skills_hub_search import parallel_search_sources
|
|
page_size = max(1, min(page_size, 100))
|
|
# The shared parallel walker carries the index-aware source-skip logic — querying
|
|
# hermes-index AND the external APIs at once would double-count every skill.
|
|
all_results, _counts, _timed_out = parallel_search_sources(
|
|
_sources(), query="", per_source_limits=_BROWSE_API_LIMITS,
|
|
source_filter=source, overall_timeout=30)
|
|
if not all_results:
|
|
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
|
|
deduped, page_items, page, total_pages, _start = _rank_and_page(all_results, page, page_size)
|
|
return {
|
|
"items": [{**_row(r, "name", "description", "source"), "trust": r.trust_level,
|
|
"identifier": r.identifier} for r in page_items],
|
|
"page": page, "total_pages": total_pages, "total": len(deduped)}
|
|
|
|
|
|
def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
|
|
"""Preview a skill's SKILL.md content without installing."""
|
|
c = console or _console
|
|
identifier, meta, bundle, _src = _resolve_identifier(identifier, _sources(), c)
|
|
if not identifier:
|
|
return
|
|
if not meta:
|
|
_print_error(c, f"Could not find '{identifier}' in any source.")
|
|
return
|
|
c.print()
|
|
info_lines = [f"[bold]Name:[/] {meta.name}", f"[bold]Description:[/] {meta.description}",
|
|
f"[bold]Source:[/] {meta.source}",
|
|
f"[bold]Trust:[/] {_trust_cell(meta.trust_level, meta.source)}",
|
|
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}"))
|
|
preview = _skill_md_preview(bundle)
|
|
if preview is not None:
|
|
c.print(Panel(preview, title="SKILL.md Preview", subtitle="hermes skills install <id> to install"))
|
|
c.print()
|
|
|
|
|
|
def inspect_skill(identifier: str) -> Optional[dict]:
|
|
"""Skill metadata (+ SKILL.md preview) for programmatic callers."""
|
|
ident, meta, bundle, _ = _resolve_identifier(identifier, _sources(), Console(quiet=True))
|
|
if not ident or not meta:
|
|
return None
|
|
out = {**_row(meta, "name", "description", "source", "identifier"),
|
|
"tags": list(meta.tags) if meta.tags else []}
|
|
preview = _skill_md_preview(bundle)
|
|
if preview is not None:
|
|
out["skill_md_preview"] = preview
|
|
return out
|
|
|
|
|
|
# --- install ---
|
|
|
|
def _install_blocked(c: Console, bundle, message: str, verdict: str, detail: str,
|
|
q_path: Optional[Path] = None, lead: str = "", label: str = "Installation blocked:") -> None:
|
|
"""Print the blocked-install line, drop the quarantine copy, append the audit row."""
|
|
c.print(f"{lead}[bold red]{label}[/] {message}")
|
|
if q_path is not None:
|
|
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, verdict, detail)
|
|
|
|
|
|
def _scan_block_message(result, identifier: str) -> str:
|
|
"""User-facing sentence for a scan-blocked install (the audit row keeps the scanner's raw reason).
|
|
|
|
Says what happened (not installed), why in plain words (high-risk patterns), whether ``--force``
|
|
can help, and the read-only next step (``hermes skills inspect``). The hard-block rule mirrors
|
|
``tools.skills_guard.should_allow_install``: a dangerous verdict on a non-official source."""
|
|
n = len(result.findings)
|
|
findings = f"{n} high-risk pattern(s)" if n else "high-risk patterns"
|
|
hard_block = result.verdict == "dangerous" and result.trust_level in ("community", "trusted")
|
|
policy = ("Hermes never installs unverified skills with high-risk findings, even with --force."
|
|
if hard_block else "Re-run with --force to install anyway.")
|
|
return (f"the security scan found {findings} in '{identifier}' (listed above). "
|
|
f"{policy} Review the findings or ask the author to fix them; to read the skill without "
|
|
f"installing, run `hermes skills inspect {identifier}`.")
|
|
|
|
|
|
def _invalid_path(c: Console, bundle, exc: ValueError, q_path: Optional[Path] = None) -> None:
|
|
_install_blocked(c, bundle, f"{exc}\n", "invalid_path", str(exc), q_path=q_path)
|
|
|
|
|
|
def _resolve_url_bundle_name(c: Console, bundle, meta, identifier: str,
|
|
name_override: str, skip_confirm: bool) -> bool:
|
|
"""Name a URL-sourced bundle whose SKILL.md has none: --name override, else TTY prompt,
|
|
else an actionable refusal on non-interactive surfaces. False => abort the install."""
|
|
bundle_meta = bundle.metadata
|
|
if bundle.source != "url" or (bundle.name and not bundle_meta.get("awaiting_name")):
|
|
return True
|
|
url = bundle_meta.get("url") or identifier
|
|
if name_override and _is_valid_installed_skill_name(name_override):
|
|
bundle.name = name_override.strip()
|
|
elif name_override:
|
|
c.print(f"[bold red]Invalid --name:[/] {name_override!r}. Must be a lowercase identifier "
|
|
"(letters, digits, hyphens, underscores; starts with a letter).\n")
|
|
return False
|
|
elif skip_confirm:
|
|
# Non-interactive surface (slash command / TUI / gateway): can't prompt.
|
|
c.print(f"[bold red]Cannot install from URL:[/] {url}\n"
|
|
"[yellow]The SKILL.md has no `name:` in its frontmatter, "
|
|
"and the URL path doesn't produce a valid identifier.[/]\n\n"
|
|
"Retry with an explicit name:\n"
|
|
f" [bold]/skills install {url} --name <your-name>[/]\n"
|
|
f" [bold]hermes skills install {url} --name <your-name>[/]\n\n"
|
|
"[dim]Or ask the SKILL.md's author to add a `name:` field to "
|
|
"its YAML frontmatter.[/]\n")
|
|
return False
|
|
else:
|
|
chosen = _prompt_for_skill_name(c, url)
|
|
if not chosen:
|
|
c.print("[dim]Installation cancelled.[/]\n")
|
|
return False
|
|
bundle.name = chosen
|
|
bundle_meta["awaiting_name"] = False
|
|
# Keep SkillMeta in sync so "already installed" checks, audit logs and display agree.
|
|
if meta is not None:
|
|
meta.name = bundle.name
|
|
meta.path = bundle.name
|
|
return True
|
|
|
|
|
|
def _announce_blueprint(c: Console, skill_name: str) -> None:
|
|
"""Offer an installed skill's ``metadata.hermes.blueprint`` via /suggestions — never
|
|
auto-scheduled (installing must not silently create a recurring job). Never raises."""
|
|
try:
|
|
from tools.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion
|
|
try:
|
|
spec = blueprint_spec_for_installed(skill_name)
|
|
except BlueprintError as _rec_err:
|
|
c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n")
|
|
return
|
|
if spec is None:
|
|
return
|
|
lead = (f"[bold cyan]Blueprint:[/] '{skill_name}' is an automation "
|
|
f"(schedule [bold]{spec.schedule}[/])")
|
|
if register_blueprint_suggestion(spec) is not None:
|
|
c.print(f"{lead}.")
|
|
c.print("[dim]Added to your suggestions — run[/] [bold]/suggestions[/] "
|
|
"[dim]to schedule or dismiss it.[/]\n")
|
|
else:
|
|
# Dropped: already offered/dismissed (latched) or the pending list is at its cap.
|
|
c.print(f"{lead}, but it wasn't added to your suggestions (already offered/dismissed, "
|
|
"or the pending list is full — run [bold]/suggestions[/] to review).")
|
|
c.print("[dim]You can still schedule it any time by asking the agent "
|
|
"or via[/] [bold]hermes cron add[/][dim].[/]\n")
|
|
except Exception: # pragma: no cover - blueprint detection is best-effort
|
|
pass
|
|
|
|
|
|
def _pinned_sources(c: Console, sources, source_id: Optional[str], identifier: str):
|
|
"""Restrict `sources` to the adapter matching `source_id`; None when it is unknown."""
|
|
from tools.skills_hub_install import _source_matches
|
|
pinned = [src for src in sources if _source_matches(src, source_id)] if source_id else sources
|
|
if pinned:
|
|
return pinned
|
|
_print_error(c, f"no source adapter for '{source_id}'. "
|
|
f"Refusing to resolve '{identifier}' against other registries "
|
|
f"(that would change the skill's provenance).")
|
|
return None
|
|
|
|
|
|
def _print_fetch_failure(c: Console, sources, identifier: str, meta=None, source=None) -> None:
|
|
rate_limited = any(getattr(src, "is_rate_limited", False)
|
|
or getattr(getattr(src, "github", None), "is_rate_limited", False)
|
|
for src in sources)
|
|
# Index hit but files gone: a stale index entry, not a user typo — name it so users stop
|
|
# re-trying spellings (#3259). Only when no adapter was rate limited: a throttled fetch
|
|
# also yields meta-without-bundle, and calling that "stale" would send users away from a
|
|
# skill that exists.
|
|
if meta is not None and not rate_limited:
|
|
src_id = getattr(source, "source_id", lambda: "the registry")()
|
|
c.print(f"[bold red]Error:[/] '{identifier}' is listed in the {src_id} index, "
|
|
f"but its files no longer exist upstream.")
|
|
c.print("[dim]Stale index entry: the skill was likely renamed or removed by "
|
|
"its author. Try `hermes skills search` for an alternative.[/]\n")
|
|
return
|
|
c.print(f"[bold red]Error:[/] Could not download '{identifier}'.")
|
|
if rate_limited:
|
|
c.print("[yellow]Hint:[/] GitHub API rate limit exhausted "
|
|
"(unauthenticated: 60 requests/hour).\n"
|
|
"Set [bold]GITHUB_TOKEN[/] in your .env or install the [bold]gh[/] CLI and run "
|
|
"[bold]gh auth login[/] to raise the limit to 5,000/hr.\n")
|
|
else:
|
|
c.print(f"Check the name with [bold]hermes skills search {identifier.rsplit('/', 1)[-1]}[/] "
|
|
"and check your internet connection. If it keeps failing, run [bold]hermes doctor[/].\n")
|
|
|
|
|
|
def _scan_quarantined(c: Console, q_path: Path, bundle, meta, identifier: str):
|
|
"""Run the cached security scan on the quarantined bundle and print the report."""
|
|
from tools.skills_hub import HUB_DIR
|
|
from tools.skills_hub_models import source_url_for_bundle
|
|
from tools.skills_guard import scan_skill_cached, format_scan_report
|
|
c.print("[bold]Running security scan...[/]")
|
|
scan_source = ("official" if bundle.source == "official"
|
|
else bundle.identifier or getattr(meta, "identifier", "") or identifier)
|
|
result, prov = scan_skill_cached(
|
|
q_path, source=scan_source, source_url=source_url_for_bundle(bundle),
|
|
cache_dir=HUB_DIR / "scan-cache")
|
|
c.print(format_scan_report(result))
|
|
c.print(f"[dim]Scan provenance: {'fresh' if prov['fresh'] else 'cached'}; scanner "
|
|
f"{prov['scanner_version']}; hash {prov['bundle_hash']}[/]")
|
|
c.print(f"[dim]Source: {prov['source_url']}; scanned {prov['scanned_at']}; "
|
|
f"rules: {', '.join(prov['rules']) or 'none'}[/]")
|
|
return result
|
|
|
|
|
|
_INSTALL_PANELS = {
|
|
"official": (
|
|
"[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",
|
|
"Official Skill", "bright_cyan"),
|
|
"external": (
|
|
"[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",
|
|
"Disclaimer", "yellow")}
|
|
|
|
|
|
def _confirm_install(c: Console, bundle, category: str) -> bool:
|
|
"""Source-appropriate disclaimer panel + `Install '<name>'?` prompt."""
|
|
files_at = f"Files will be at: [cyan]{display_hermes_home()}/skills/{category + '/' if category else ''}{bundle.name}/[/]"
|
|
body, title, style = _INSTALL_PANELS["official" if bundle.source == "official" else "external"]
|
|
c.print()
|
|
c.print(Panel(body + files_at, title=title, border_style=style))
|
|
return _confirm_or_cancel(c, f"[bold]Install '{bundle.name}'?[/]",
|
|
cancel="[dim]Installation cancelled.[/]\n")
|
|
|
|
|
|
def do_install(identifier: str, category: str = "", force: bool = False,
|
|
console: Optional[Console] = None, skip_confirm: bool = False,
|
|
invalidate_cache: bool = True, name_override: str = "",
|
|
source_id: Optional[str] = None) -> None:
|
|
"""Fetch, quarantine, scan, confirm, and install a skill. ``source_id`` pins resolution to one
|
|
adapter; callers that know the provenance (``do_update``) must pass it so a bare identifier
|
|
cannot resolve to a same-named skill elsewhere."""
|
|
from tools.skills_hub import HubLockFile, ensure_hub_dirs, skills_hub_http_session
|
|
from tools.skills_hub_install import install_from_quarantine, quarantine_bundle
|
|
from tools.skills_guard import should_allow_install
|
|
c = console or _console
|
|
ensure_hub_dirs()
|
|
sources = _pinned_sources(c, _sources(), source_id, identifier)
|
|
if sources is None:
|
|
return
|
|
# One pooled guarded client for the whole resolve + fetch fan-out (tree, SKILL.md, N support files).
|
|
with skills_hub_http_session():
|
|
identifier = _full_identifier(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:
|
|
_print_fetch_failure(c, sources, identifier, meta=meta, source=_matched_source)
|
|
return
|
|
if not _resolve_url_bundle_name(c, bundle, meta, identifier, name_override, skip_confirm):
|
|
return
|
|
|
|
# URL-sourced skills: pick a category interactively when none was given (TTY only;
|
|
# non-interactive installs fall through to flat install like every other source).
|
|
if bundle.source == "url" and not category and not skip_confirm:
|
|
category = _prompt_for_category(c, _existing_categories())
|
|
# Official skills can be nested ("official/mlops/training/trl-fine-tuning"): keep every
|
|
# identifier segment between "official" and the final slug as the category path.
|
|
if bundle.source == "official" and not category:
|
|
category = "/".join(bundle.identifier.split("/")[1:-1])
|
|
|
|
existing = HubLockFile().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 = {**(getattr(meta, "extra", {}) or {}), **bundle.metadata}
|
|
|
|
try:
|
|
q_path = quarantine_bundle(bundle)
|
|
except ValueError as exc:
|
|
_invalid_path(c, bundle, exc)
|
|
return
|
|
c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]")
|
|
|
|
result = _scan_quarantined(c, q_path, bundle, meta, identifier)
|
|
allowed, _reason = should_allow_install(result, force=force)
|
|
if not allowed:
|
|
_install_blocked(c, bundle, _scan_block_message(result, identifier), result.verdict,
|
|
f"{len(result.findings)}_findings", q_path=q_path, lead="\n", label="Not installed:")
|
|
return
|
|
# Advisory second opinion — warn-and-continue by design (PII-class findings are
|
|
# informational); the install confirmation below is where the user decides.
|
|
_print_tier1_advisory(q_path, c)
|
|
metadata_lines = _format_extra_metadata_lines(extra_metadata)
|
|
if metadata_lines:
|
|
c.print(Panel("\n".join(metadata_lines), title="Upstream Metadata", border_style="blue"))
|
|
|
|
# skip_confirm bypasses the prompt (TUI mode, where input() hangs).
|
|
if not force and not skip_confirm and not _confirm_install(c, bundle, category):
|
|
shutil.rmtree(q_path, ignore_errors=True)
|
|
return
|
|
|
|
try:
|
|
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
|
|
except ValueError as exc:
|
|
_invalid_path(c, bundle, exc, q_path)
|
|
return
|
|
from tools.skills_hub import SKILLS_DIR
|
|
c.print(f"[bold green]Installed:[/] {install_dir.resolve().relative_to(Path(SKILLS_DIR).resolve()).as_posix()}")
|
|
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
|
_announce_blueprint(c, bundle.name)
|
|
_finish_change(c, invalidate_cache, "Skill will be available", "activate")
|
|
|
|
|
|
def _print_tier1_advisory(skill_dir, console) -> None:
|
|
"""Advisory SkillEvaluator Tier 1 report. Never raises/blocks: scanner missing, disabled via
|
|
``skills.tier1_advisory: false``, or erroring all degrade to silence. Secrets render red."""
|
|
try:
|
|
from tools.skillevaluator_scan import (format_tier1_report, run_tier1_scan,
|
|
tier1_advisory_enabled)
|
|
if not tier1_advisory_enabled():
|
|
return
|
|
report = run_tier1_scan(Path(skill_dir))
|
|
if not report.available:
|
|
return
|
|
text = format_tier1_report(report)
|
|
if not report.findings:
|
|
console.print(f"[dim]{text}[/]")
|
|
return
|
|
console.print(Panel(text, title="SkillEvaluator Tier 1 (advisory)",
|
|
border_style="red" if report.secrets_findings else "yellow"))
|
|
if report.secrets_findings:
|
|
console.print("[bold red]Possible credentials detected above.[/] "
|
|
"Review the flagged lines before using this skill.\n")
|
|
except Exception as exc: # advisory only — never break an install
|
|
logging.getLogger(__name__).debug("Tier 1 advisory scan skipped: %s", exc)
|
|
|
|
|
|
# --- list / check / update / audit ---
|
|
|
|
def do_list(source_filter: str = "all", enabled_only: bool = False,
|
|
console: Optional[Console] = None) -> None:
|
|
"""List installed skills (hub / builtin / local). Enabled state comes from the active
|
|
profile's config — ``-p`` swaps HERMES_HOME at process start, so no profile flag here."""
|
|
from tools.skills_hub import HubLockFile, ensure_hub_dirs
|
|
from tools.skills_sync import _read_manifest
|
|
from tools.skills_tool import _find_all_skills
|
|
from agent.skill_utils import get_disabled_skill_names
|
|
from agent.skill_commands import skill_command_collision_note
|
|
c = console or _console
|
|
ensure_hub_dirs()
|
|
hub_installed = {e["name"]: e for e in HubLockFile().list_installed()}
|
|
builtin_names = set(_read_manifest())
|
|
all_skills = _find_all_skills(skip_disabled=True) # include disabled ones to annotate status
|
|
disabled_names = get_disabled_skill_names()
|
|
|
|
table = _table(("Name", {"style": "bold cyan"}), "Category", "Source", "Trust", "Status",
|
|
title="Installed Skills" + (" (enabled only)" if enabled_only else ""))
|
|
|
|
counts = {"hub": 0, "builtin": 0, "local": 0}
|
|
enabled_count = disabled_count = 0
|
|
for skill in sorted(all_skills, key=lambda s: (s.get("category") or "", s["name"])):
|
|
name = skill["name"]
|
|
hub_entry = hub_installed.get(name)
|
|
if hub_entry:
|
|
source_type, source_display = "hub", hub_entry.get("source", "hub")
|
|
trust = hub_entry.get("trust_level", "community")
|
|
else:
|
|
source_type = source_display = trust = "builtin" if name in builtin_names else "local"
|
|
is_enabled = name not in disabled_names
|
|
if source_filter not in ("all", source_type) or (enabled_only and not is_enabled):
|
|
continue
|
|
counts[source_type] += 1
|
|
enabled_count += is_enabled
|
|
disabled_count += not is_enabled
|
|
status = "[bold green]enabled[/]" if is_enabled else "[dim red]disabled[/]"
|
|
# Name taken by a built-in: the skill loads but has no /<name> (see agent.skill_commands).
|
|
if note := skill_command_collision_note(name):
|
|
status += f"\n[yellow]{note}[/]"
|
|
table.add_row(name, skill.get("category", ""), source_display,
|
|
_trust_cell(trust, source_display), status)
|
|
|
|
c.print(table)
|
|
tail = (f"{enabled_count} enabled shown" if enabled_only
|
|
else f"{enabled_count} enabled, {disabled_count} disabled")
|
|
c.print(f"[dim]{counts['hub']} hub-installed, {counts['builtin']} builtin, "
|
|
f"{counts['local']} local — {tail}[/]\n")
|
|
|
|
|
|
def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None:
|
|
"""Check hub-installed skills for upstream updates."""
|
|
from tools.skills_hub_install 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(("Name", {"style": "bold cyan"}), "Source", "Status", title="Skill Updates")
|
|
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")
|
|
orphaned = [entry.get("name", "") for entry in results if entry.get("status") == "orphaned"]
|
|
if orphaned:
|
|
c.print(f"[yellow]Orphaned:[/] {', '.join(orphaned)} — lock-file entries whose local "
|
|
"directory is missing or replaced by a non-directory. For missing directories, "
|
|
"remove the stale entry with: hermes skills uninstall <name>\n")
|
|
|
|
|
|
def _has_local_edits(installed: dict) -> bool:
|
|
"""True when the on-disk content no longer matches the install-time hash."""
|
|
from tools.skills_hub import SKILLS_DIR
|
|
from tools.skills_guard import content_hash
|
|
recorded_hash = installed.get("content_hash", "")
|
|
skill_path = SKILLS_DIR / installed.get("install_path", "")
|
|
try:
|
|
return (bool(recorded_hash) and skill_path.is_dir()
|
|
and content_hash(skill_path) != recorded_hash)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def do_update(name: Optional[str] = None, console: Optional[Console] = None,
|
|
force: bool = False) -> None:
|
|
"""Update hub-installed skills. Locally edited ones are skipped unless ``force`` — the
|
|
update rmtree-replaces the user's work, so that must be an explicit choice.
|
|
|
|
Skills whose on-disk content no longer matches the hash recorded at install time have been edited
|
|
locally; updating them would silently destroy the user's work (``do_install(force=True)``
|
|
rmtree-replaces the directory). Those are skipped by default and only overwritten when ``force=True``.
|
|
Mirrors the user-modified protection bundled skills already get from ``hermes update`` (ported from
|
|
paperclipai/paperclip#10978's explicit-merge-mode rule: destructive replacement must be an explicit
|
|
caller choice, never a rerun default).
|
|
"""
|
|
from tools.skills_hub import HubLockFile
|
|
from tools.skills_hub_install import 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
|
|
|
|
skipped_local: list[str] = []
|
|
for entry in updates:
|
|
installed = lock.get_installed(entry["name"])
|
|
category = ""
|
|
if installed:
|
|
parent = str(Path(installed.get("install_path", "")).parent)
|
|
category = "" if parent == "." else parent
|
|
if not force and _has_local_edits(installed):
|
|
skipped_local.append(entry["name"])
|
|
c.print(f"[yellow]Skipping:[/] {entry['name']} — you have local edits "
|
|
"(update would overwrite them).")
|
|
continue
|
|
c.print(f"[bold]Updating:[/] {entry['name']}")
|
|
# Pin to the lockfile's source registry: a bare identifier such as "reddit" would
|
|
# otherwise fuzzy-resolve inside do_install to a same-named skill in a DIFFERENT
|
|
# registry, overwriting the user's files and rewriting the lock's `source`.
|
|
do_install(entry["identifier"], category=category, force=True, console=c,
|
|
source_id=entry.get("source", "") or None)
|
|
|
|
if len(updates) > len(skipped_local):
|
|
c.print(f"[bold green]Updated {len(updates) - len(skipped_local)} skill(s).[/]\n")
|
|
if skipped_local:
|
|
c.print(f"[dim]{len(skipped_local)} skill(s) kept your local edits: "
|
|
f"{', '.join(sorted(skipped_local))}.[/]")
|
|
c.print("[dim]Overwrite with: hermes skills update <name> --force[/]\n")
|
|
|
|
|
|
def do_audit(name: Optional[str] = None, console: Optional[Console] = None,
|
|
deep: bool = False) -> None:
|
|
"""Re-scan installed hub skills; ``deep`` adds an AST diagnostic (review aid, not a gate)."""
|
|
from tools.skills_hub import HubLockFile, SKILLS_DIR
|
|
from tools.skills_guard import scan_skill, format_scan_report
|
|
c = console or _console
|
|
installed = HubLockFile().list_installed()
|
|
if not installed:
|
|
c.print("[dim]No hub-installed skills to audit.[/]\n")
|
|
return
|
|
targets = [e for e in installed if e["name"] == name] if name else installed
|
|
if not targets:
|
|
_print_error(c, f"'{name}' is not a hub-installed skill.")
|
|
return
|
|
c.print(f"\n[bold]Auditing {len(targets)} skill(s)...[/]\n")
|
|
if deep:
|
|
from tools.skills_ast_audit import ast_scan_path, format_ast_report
|
|
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
|
|
c.print(format_scan_report(scan_skill(skill_path, source=entry.get("identifier", entry["source"]))))
|
|
if deep:
|
|
c.print(format_ast_report(ast_scan_path(skill_path), skill_name=entry["name"]))
|
|
c.print()
|
|
|
|
|
|
# --- uninstall / reset / bundled-skill management ---
|
|
|
|
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_install import uninstall_skill
|
|
c = console or _console
|
|
# skip_confirm bypasses the prompt (TUI mode, where input() hangs)
|
|
if not skip_confirm and not _confirm_or_cancel(c, f"\n[bold]Uninstall '{name}'?[/]"):
|
|
return
|
|
if _report_pair(c, *uninstall_skill(name)):
|
|
_finish_change(c, invalidate_cache)
|
|
|
|
|
|
def do_reset(name: str, restore: bool = False, console: Optional[Console] = None,
|
|
skip_confirm: bool = False, invalidate_cache: bool = True) -> None:
|
|
"""Reset a bundled skill's manifest tracking (+ optionally restore from bundled)."""
|
|
from tools.skills_sync_bundled_ops import reset_bundled_skill
|
|
c = console or _console
|
|
if not skip_confirm and restore and not _confirm_or_cancel(
|
|
c, f"\n[bold]Restore '{name}' from bundled source?[/]",
|
|
"[dim]This will DELETE your current copy and re-copy the bundled version.[/]"):
|
|
return
|
|
result = reset_bundled_skill(name, restore=restore)
|
|
if not _report_ok(c, result):
|
|
return
|
|
synced = result.get("synced") or {}
|
|
_print_listed(c, "Copied", synced.get("copied"))
|
|
_print_listed(c, "Updated", synced.get("updated"))
|
|
c.print()
|
|
_finish_change(c, invalidate_cache)
|
|
|
|
|
|
def do_list_modified(console: Optional[Console] = None, as_json: bool = False) -> None:
|
|
"""List bundled skills the user has edited (which `hermes update` keeps)."""
|
|
from tools.skills_sync_bundled_ops import list_user_modified_bundled_skills
|
|
c = console or _console
|
|
modified = list_user_modified_bundled_skills()
|
|
if as_json:
|
|
c.print(json.dumps([m["name"] for m in modified]))
|
|
return
|
|
if not modified:
|
|
c.print("[dim]No user-modified bundled skills — everything tracks upstream.[/]\n")
|
|
return
|
|
c.print(f"\n[bold]{len(modified)} user-modified bundled skill(s)[/] "
|
|
"[dim](kept as-is by `hermes update`):[/]")
|
|
for entry in modified:
|
|
c.print(f" [yellow]~[/] {entry['name']}")
|
|
c.print()
|
|
c.print("[dim]See changes: hermes skills diff <name>[/]")
|
|
c.print("[dim]Resume updates: hermes skills reset <name> (keep your copy, re-baseline)[/]")
|
|
c.print("[dim]Revert to stock: hermes skills reset <name> --restore[/]\n")
|
|
|
|
|
|
def _print_diff_line(c: Console, line: str) -> None:
|
|
"""Unified-diff line with light coloring (file headers +++/--- stay plain)."""
|
|
for prefix, style in (("+", "green"), ("-", "red"), ("@@", "cyan")):
|
|
if line.startswith(prefix) and not line.startswith(prefix * 3):
|
|
c.print(f"[{style}]{line}[/]")
|
|
return
|
|
c.print(line, highlight=False)
|
|
|
|
|
|
_DIFF_STATUS_LINE = {
|
|
"added": "[green]+ only in your copy:[/] {path}", "removed": "[red]- only in stock:[/] {path}",
|
|
"binary": "[yellow]~ {path}:[/] binary file differs"}
|
|
|
|
|
|
def do_diff(name: str, console: Optional[Console] = None) -> None:
|
|
"""Show how the user's copy of a bundled skill differs from the stock version."""
|
|
from tools.skills_sync_bundled_ops import diff_bundled_skill
|
|
c = console or _console
|
|
result = diff_bundled_skill(name)
|
|
if not result["ok"]:
|
|
_print_error(c, result["message"])
|
|
return
|
|
if not result["modified"]:
|
|
c.print(f"[green]{result['message']}[/]\n")
|
|
return
|
|
c.print(f"\n[bold]{result['message']}[/]\n")
|
|
for entry in result["diffs"]:
|
|
if entry["status"] == "modified":
|
|
for line in entry["diff"].splitlines():
|
|
_print_diff_line(c, line)
|
|
else:
|
|
line = _DIFF_STATUS_LINE.get(entry["status"], _DIFF_STATUS_LINE["binary"])
|
|
c.print(line.format(**entry))
|
|
c.print()
|
|
c.print(f"[dim]Revert with: hermes skills reset {name} --restore[/]\n")
|
|
|
|
|
|
def do_opt_out(remove: bool = False, console: Optional[Console] = None, skip_confirm: bool = False,
|
|
invalidate_cache: bool = True) -> None:
|
|
"""Write the .no-bundled-skills marker; with ``remove`` also delete pristine (tracked AND
|
|
unmodified) bundled skills. User-edited and non-bundled skills are never touched."""
|
|
from tools.skills_sync_bundled_ops import set_bundled_skills_opt_out, remove_pristine_bundled_skills
|
|
c = console or _console
|
|
res = set_bundled_skills_opt_out(True) # the marker first: always-safe
|
|
if not _report_ok(c, res):
|
|
return
|
|
c.print(f"[dim]Marker: {res['marker']}[/]")
|
|
if not remove:
|
|
c.print("[dim]Existing skills on disk were left in place. "
|
|
"Re-run with --remove to also delete unmodified bundled skills.[/]\n")
|
|
return
|
|
|
|
# Destructive step: preview, confirm, then delete.
|
|
preview = remove_pristine_bundled_skills(dry_run=True)
|
|
candidates = preview["removed"]
|
|
if not candidates:
|
|
c.print("[dim]No pristine bundled skills to remove "
|
|
"(nothing tracked, or all are user-modified/local).[/]\n")
|
|
return
|
|
c.print(f"\n[bold]Will remove {len(candidates)} unmodified bundled skill(s):[/]")
|
|
c.print(f"[dim]{', '.join(candidates)}[/]")
|
|
if preview["skipped"]:
|
|
c.print(f"[dim]Keeping {len(preview['skipped'])} (user-modified or non-bundled).[/]")
|
|
if not skip_confirm and not _confirm_or_cancel(
|
|
c, "[dim]This deletes the on-disk copies. User-edited and hub/local skills are NOT touched.[/]",
|
|
cancel="[dim]Marker kept; no skills deleted.[/]\n"):
|
|
return
|
|
result = remove_pristine_bundled_skills(dry_run=False)
|
|
c.print(f"[bold green]{result['message']}[/]")
|
|
_print_listed(c, "Removed", result["removed"])
|
|
c.print()
|
|
_finish_change(c, invalidate_cache, notice=False)
|
|
|
|
|
|
def do_opt_in(sync: bool = False, console: Optional[Console] = None,
|
|
invalidate_cache: bool = True) -> None:
|
|
"""Remove the opt-out marker so bundled-skill seeding resumes."""
|
|
from tools.skills_sync import sync_skills
|
|
from tools.skills_sync_bundled_ops import set_bundled_skills_opt_out
|
|
c = console or _console
|
|
if not _report_ok(c, set_bundled_skills_opt_out(False)):
|
|
return
|
|
if sync:
|
|
copied = len(sync_skills(quiet=True).get("copied", []))
|
|
c.print(f"[dim]Re-seeded {copied} bundled skill(s).[/]")
|
|
_finish_change(c, invalidate_cache, notice=False)
|
|
c.print()
|
|
|
|
|
|
def do_repair_official(name: str, restore: bool = False, console: Optional[Console] = None,
|
|
skip_confirm: bool = False, invalidate_cache: bool = True) -> None:
|
|
"""Backfill or restore official optional skills from repo source."""
|
|
from tools.skills_sync_optional import restore_official_optional_skill
|
|
c = console or _console
|
|
if restore and not skip_confirm and not _confirm_or_cancel(
|
|
c, f"\n[bold]Restore official optional skill '{name}' from repo source?[/]",
|
|
"[dim]Existing matching active copies will be moved to a restore backup before copying the official source.[/]",
|
|
):
|
|
return
|
|
result = restore_official_optional_skill(name, restore=restore)
|
|
if not _report_ok(c, result, "Repair failed"):
|
|
return
|
|
_print_listed(c, "Restored", result.get("restored"))
|
|
_print_listed(c, "Backfilled provenance", result.get("backfilled"))
|
|
if result.get("backed_up"):
|
|
c.print(f"[dim]Backed up: {', '.join(result['backed_up'])}[/]")
|
|
c.print(f"[dim]Backup dir: {result.get('backup_dir')}[/]")
|
|
c.print()
|
|
_finish_change(c, invalidate_cache, notice=False)
|
|
|
|
|
|
# --- taps / publish / snapshot ---
|
|
|
|
# action -> (TapsManager method, success line, failure line)
|
|
_TAP_OPS = {
|
|
"add": ("add", "[bold green]Added tap:[/] {repo}\n", "[yellow]Tap already exists:[/] {repo}\n"),
|
|
"remove": ("remove", "[bold green]Removed tap:[/] {repo}\n", "[bold red]Error:[/] Tap not found: {repo}\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(("Repo", {"style": "bold cyan"}), "Path", title="Configured Taps")
|
|
for t in taps:
|
|
table.add_row(t.get("repo") or t.get("name") or t.get("path", "unknown"),
|
|
t.get("path", "skills/"))
|
|
c.print(table)
|
|
c.print()
|
|
elif action in _TAP_OPS:
|
|
method, ok_line, fail_line = _TAP_OPS[action]
|
|
if not repo:
|
|
_print_error(c, f"Repo required. Usage: hermes skills tap {action} owner/repo")
|
|
return
|
|
c.print((ok_line if getattr(mgr, method)(repo) else fail_line).format(repo=repo))
|
|
else:
|
|
c.print(f"[bold red]Unknown tap action:[/] {action}. Use: list, add, remove\n")
|
|
|
|
|
|
def _read_frontmatter(skill_md: str) -> dict:
|
|
"""YAML frontmatter of a SKILL.md body ({} when absent/invalid)."""
|
|
import hermes_yaml as yaml
|
|
match = re.search(r'\n---\s*\n', skill_md[3:]) if skill_md.startswith("---") else None
|
|
try:
|
|
return (yaml.safe_load(skill_md[3:match.start() + 3]) or {}) if match else {}
|
|
except yaml.YAMLError:
|
|
return {}
|
|
|
|
|
|
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 SKILLS_DIR
|
|
from tools.skills_hub_github import GitHubAuth
|
|
from tools.skills_guard import scan_skill, format_scan_report
|
|
c = console or _console
|
|
path = Path(skill_path)
|
|
if not path.is_absolute():
|
|
path = SKILLS_DIR / path
|
|
if not (path / "SKILL.md").exists():
|
|
_print_error(c, f"No SKILL.md found at {path}")
|
|
return
|
|
skill_md = (path / "SKILL.md").read_text(encoding="utf-8-sig").lstrip("\ufeff") # tolerate BOM
|
|
fm = _read_frontmatter(skill_md)
|
|
name = fm.get("name", path.name)
|
|
if not fm.get("description", ""):
|
|
_print_error(c, "SKILL.md must have a 'description' in frontmatter.")
|
|
return
|
|
|
|
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:
|
|
_print_error(c, "--repo required for GitHub publish.\n"
|
|
"Usage: hermes skills publish <path> --to github --repo owner/repo")
|
|
return
|
|
auth = GitHubAuth()
|
|
if not auth.is_authenticated():
|
|
_print_error(c, "GitHub authentication required.\n"
|
|
f"Set GITHUB_TOKEN in {display_hermes_home()}/.env "
|
|
"or run 'gh auth login'.")
|
|
return
|
|
c.print(f"[bold]Publishing '{name}' to {repo}...[/]")
|
|
_report_pair(c, *_github_publish(path, name, repo, auth))
|
|
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:
|
|
"""Fork, branch, upload, and open a PR with the skill. Returns (success, message)."""
|
|
import base64
|
|
import httpx
|
|
headers = auth.get_headers()
|
|
api = "https://api.github.com/repos"
|
|
|
|
def call(method: str, path: str, timeout: int = 15, **kw):
|
|
return getattr(httpx, method)(f"{api}/{path}", headers=headers, timeout=timeout, **kw)
|
|
|
|
try:
|
|
resp = call("post", f"{target_repo}/forks", timeout=30)
|
|
if resp.status_code in {200, 202}:
|
|
fork_repo = resp.json()["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}"
|
|
|
|
try:
|
|
default_branch = call("get", target_repo).json().get("default_branch", "main")
|
|
except Exception:
|
|
default_branch = "main"
|
|
try:
|
|
ref = call("get", f"{fork_repo}/git/refs/heads/{default_branch}").json()
|
|
base_sha = ref["object"]["sha"]
|
|
except Exception as e:
|
|
return False, f"Failed to get base branch: {e}"
|
|
|
|
branch_name = f"add-skill-{skill_name}"
|
|
try:
|
|
call("post", f"{fork_repo}/git/refs",
|
|
json={"ref": f"refs/heads/{branch_name}", "sha": base_sha})
|
|
except Exception as e:
|
|
return False, f"Failed to create branch: {e}"
|
|
|
|
for f in skill_path.rglob("*"):
|
|
if not f.is_file():
|
|
continue
|
|
rel = str(f.relative_to(skill_path))
|
|
try:
|
|
call("put", f"{fork_repo}/contents/skills/{skill_name}/{rel}",
|
|
json={"message": f"Add {skill_name} skill: {rel}",
|
|
"content": base64.b64encode(f.read_bytes()).decode(), "branch": branch_name})
|
|
except Exception as e:
|
|
return False, f"Failed to upload {rel}: {e}"
|
|
|
|
try:
|
|
resp = call("post", f"{target_repo}/pulls", 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:
|
|
return True, f"PR created: {resp.json().get('html_url', '')}"
|
|
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
|
|
installed = HubLockFile().list_installed()
|
|
tap_list = TapsManager().list_taps()
|
|
snapshot = {
|
|
"hermes_version": "0.1.0",
|
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
"skills": [
|
|
{"name": entry["name"], "source": entry.get("source", ""),
|
|
"identifier": entry.get("identifier", ""),
|
|
"category": (str(Path(entry["install_path"]).parent)
|
|
if "/" in entry.get("install_path", "") else "")}
|
|
for entry in installed],
|
|
"taps": tap_list}
|
|
payload = json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n"
|
|
if output_path == "-":
|
|
sys.stdout.write(payload)
|
|
return
|
|
Path(output_path).write_text(payload, encoding="utf-8")
|
|
c.print(f"[bold green]Snapshot exported:[/] {Path(output_path)}")
|
|
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():
|
|
_print_error(c, f"File not found: {inp}")
|
|
return
|
|
try:
|
|
snapshot = json.loads(inp.read_text(encoding="utf-8-sig"))
|
|
except json.JSONDecodeError:
|
|
_print_error(c, f"Invalid JSON in {inp}")
|
|
return
|
|
|
|
taps = snapshot.get("taps", [])
|
|
if taps:
|
|
mgr = TapsManager()
|
|
for tap in taps:
|
|
if tap.get("repo", ""):
|
|
mgr.add(tap["repo"], tap.get("path", "skills/"))
|
|
c.print(f"[dim]Restored {len(taps)} tap(s)[/]")
|
|
|
|
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", "")
|
|
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=entry.get("category", ""), force=force, console=c)
|
|
c.print("[bold green]Snapshot import complete.[/]\n")
|
|
|
|
|
|
# --- CLI argparse entry point ---
|
|
|
|
def _snapshot_cli(args) -> None:
|
|
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")
|
|
|
|
|
|
def _tap_cli(args) -> None:
|
|
tap_action = getattr(args, "tap_action", None)
|
|
if not tap_action:
|
|
_console.print("Usage: hermes skills tap [list|add|remove]\n")
|
|
return
|
|
do_tap(tap_action, repo=getattr(args, "repo", "") or getattr(args, "name", ""))
|
|
|
|
|
|
# `hermes skills <action>` -> handler(args). Lambdas late-bind the do_* names so
|
|
# tests that patch("hermes_cli.skills_hub.do_install") still intercept.
|
|
_CLI_ACTIONS = {
|
|
"browse": lambda a: do_browse(page=a.page, page_size=a.size, source=a.source),
|
|
"search": lambda a: do_search(a.query, source=a.source, limit=a.limit,
|
|
as_json=getattr(a, "json", False)),
|
|
"install": lambda a: do_install(a.identifier, category=a.category, force=a.force,
|
|
skip_confirm=getattr(a, "yes", False),
|
|
name_override=getattr(a, "name", "") or ""),
|
|
"inspect": lambda a: do_inspect(a.identifier),
|
|
"list": lambda a: do_list(source_filter=a.source,
|
|
enabled_only=getattr(a, "enabled_only", False)),
|
|
"check": lambda a: do_check(name=getattr(a, "name", None)),
|
|
"update": lambda a: do_update(name=getattr(a, "name", None), force=getattr(a, "force", False)),
|
|
"audit": lambda a: do_audit(name=getattr(a, "name", None), deep=getattr(a, "deep", False)),
|
|
"uninstall": lambda a: do_uninstall(a.name, skip_confirm=getattr(a, "yes", False)),
|
|
"reset": lambda a: do_reset(a.name, restore=getattr(a, "restore", False),
|
|
skip_confirm=getattr(a, "yes", False)),
|
|
"list-modified": lambda a: do_list_modified(as_json=getattr(a, "json", False)),
|
|
"diff": lambda a: do_diff(a.name),
|
|
"opt-out": lambda a: do_opt_out(remove=getattr(a, "remove", False),
|
|
skip_confirm=getattr(a, "yes", False)),
|
|
"opt-in": lambda a: do_opt_in(sync=getattr(a, "sync", False)),
|
|
"repair-official": lambda a: do_repair_official(a.name, restore=getattr(a, "restore", False),
|
|
skip_confirm=getattr(a, "yes", False)),
|
|
"publish": lambda a: do_publish(a.skill_path, target=getattr(a, "to", "github"),
|
|
repo=getattr(a, "repo", "")),
|
|
"snapshot": _snapshot_cli, "tap": _tap_cli}
|
|
|
|
|
|
def skills_command(args) -> None:
|
|
"""Router for `hermes skills <subcommand>` — called from hermes_cli/main.py."""
|
|
handler = _CLI_ACTIONS.get(getattr(args, "skills_action", None))
|
|
if handler is None:
|
|
_console.print("Usage: hermes skills [browse|search|install|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
|
|
_console.print("Run 'hermes skills <command> --help' for details.\n")
|
|
return
|
|
handler(args)
|
|
|
|
|
|
# --- Slash command entry point (/skills in chat) ---
|
|
|
|
def _opt_value(args: List[str], flag: str, default: str, last: bool = False) -> str:
|
|
"""Value following `flag` (default if absent/trailing); `last` makes a repeated flag's final
|
|
occurrence win (historical install/publish/browse behaviour), else the first."""
|
|
hits = [args[i + 1] for i, a in enumerate(args) if a == flag and i + 1 < len(args)]
|
|
return (hits[-1] if last else hits[0]) if hits else default
|
|
|
|
|
|
def _int_or(text: str, default: int) -> int:
|
|
try:
|
|
return int(text)
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def _opt_int(args: List[str], flag: str, default: int) -> int:
|
|
"""Like _opt_value(last=True) but int-parsed; a non-integer keeps the default."""
|
|
return _int_or(_opt_value(args, flag, str(default), last=True), default)
|
|
|
|
|
|
def _slash_search(args, c):
|
|
source, limit, as_json, query_parts, i = "all", 25, False, [], 0
|
|
while i < len(args):
|
|
flag, value = args[i], args[i + 1] if i + 1 < len(args) else None
|
|
takes_value = flag in ("--source", "--limit") and value is not None
|
|
if flag == "--source" and takes_value:
|
|
source = value
|
|
elif flag == "--limit" and takes_value:
|
|
limit = _int_or(value, limit)
|
|
elif flag == "--json":
|
|
as_json = True
|
|
else:
|
|
query_parts.append(flag)
|
|
i += 2 if takes_value else 1
|
|
do_search(" ".join(query_parts), source=source, limit=limit, console=c, as_json=as_json)
|
|
|
|
|
|
def _slash_snapshot(args, c):
|
|
if len(args) > 1 and args[0] == "export":
|
|
do_snapshot_export(args[1], console=c)
|
|
elif len(args) > 1 and args[0] == "import":
|
|
do_snapshot_import(args[1], force="--force" in args, console=c)
|
|
else:
|
|
c.print("[bold red]Usage:[/] /skills snapshot export <file> | /skills snapshot import <file>\n")
|
|
|
|
|
|
def _first_positional(args):
|
|
"""First argument unless it is a flag (audit's historical parse)."""
|
|
return args[0] if args and not args[0].startswith("--") else None
|
|
|
|
|
|
# Slash commands run inside prompt_toolkit where input() hangs, so install/uninstall/reset
|
|
# always skip confirmation — typing the command is implicit consent. `--now` invalidates the
|
|
# prompt cache immediately (costs more money); the default defers to the next session.
|
|
_SLASH_ACTIONS = {
|
|
"browse": lambda args, c: do_browse(
|
|
page=_opt_int(args, "--page", 1), page_size=_opt_int(args, "--size", 20),
|
|
source=_opt_value(args, "--source", "all", last=True), console=c),
|
|
"search": _slash_search,
|
|
"install": lambda args, c: do_install(
|
|
args[0], category=_opt_value(args, "--category", "", last=True),
|
|
force="--force" in args, skip_confirm=True, invalidate_cache="--now" in args,
|
|
name_override=_opt_value(args, "--name", "", last=True), console=c),
|
|
"inspect": lambda args, c: do_inspect(args[0], console=c),
|
|
"list": lambda args, c: do_list(
|
|
source_filter=_opt_value(args, "--source", "all"),
|
|
enabled_only="--enabled-only" in args or "--enabled" in args, console=c),
|
|
"check": lambda args, c: do_check(name=args[0] if args else None, console=c),
|
|
"update": lambda args, c: do_update(
|
|
name=next((a for a in args if not a.startswith("--")), None), console=c,
|
|
force="--force" in args),
|
|
"audit": lambda args, c: do_audit(name=_first_positional(args), console=c,
|
|
deep="--deep" in args),
|
|
"uninstall": lambda args, c: do_uninstall(
|
|
args[0], console=c, skip_confirm=True, invalidate_cache="--now" in args),
|
|
"reset": lambda args, c: do_reset(
|
|
args[0], restore="--restore" in args, console=c, skip_confirm=True,
|
|
invalidate_cache="--now" in args),
|
|
**dict.fromkeys(("list-modified", "modified"),
|
|
lambda args, c: do_list_modified(console=c, as_json="--json" in args)),
|
|
"diff": lambda args, c: do_diff(args[0], console=c),
|
|
"publish": lambda args, c: do_publish(
|
|
args[0], target=_opt_value(args, "--to", "github", last=True),
|
|
repo=_opt_value(args, "--repo", "", last=True), console=c),
|
|
"snapshot": _slash_snapshot,
|
|
"tap": lambda args, c: (do_tap(args[0], repo=args[1] if len(args) > 1 else "", console=c)
|
|
if args else do_tap("list", console=c)),
|
|
**dict.fromkeys(("help", "--help", "-h"), lambda args, c: _print_skills_help(c))}
|
|
|
|
# Actions that need at least one argument -> usage lines printed when called bare.
|
|
_SLASH_USAGE = {
|
|
"search": ("[bold red]Usage:[/] /skills search <query> [--source skills-sh|github|official|nvidia|openai|anthropic|huggingface] [--limit N] [--json]\n",),
|
|
"install": ("[bold red]Usage:[/] /skills install <identifier-or-url> [--name <name>] [--category <cat>] [--force] [--now]\n",),
|
|
"inspect": ("[bold red]Usage:[/] /skills inspect <identifier>\n",),
|
|
"uninstall": ("[bold red]Usage:[/] /skills uninstall <name> [--now]\n",),
|
|
"reset": (
|
|
"[bold red]Usage:[/] /skills reset <name> [--restore] [--now]\n",
|
|
"[dim]Clears the bundled-skills manifest entry so future updates stop marking it as user-modified.[/]",
|
|
"[dim]Pass --restore to also replace the current copy with the bundled version.[/]\n"),
|
|
"diff": ("[bold red]Usage:[/] /skills diff <name>\n",),
|
|
"publish": ("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n",),
|
|
}
|
|
|
|
|
|
def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
|
"""Parse and dispatch `/skills <subcommand> [args]` from the chat interface."""
|
|
c = console or _console
|
|
parts = cmd.strip().split()
|
|
if parts and parts[0].lower() == "/skills":
|
|
parts = parts[1:]
|
|
if not parts:
|
|
_print_skills_help(c)
|
|
return
|
|
action, args = parts[0].lower(), parts[1:]
|
|
handler = _SLASH_ACTIONS.get(action)
|
|
if handler is None:
|
|
c.print(f"[bold red]Unknown action:[/] {action}")
|
|
_print_skills_help(c)
|
|
return
|
|
if not args and action in _SLASH_USAGE:
|
|
for line in _SLASH_USAGE[action]:
|
|
c.print(line)
|
|
return
|
|
handler(args, 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] [--enabled-only]\n"
|
|
" List installed skills; --enabled-only filters to the active profile's live set\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]list-modified[/] List bundled skills you've edited (kept by update)\n"
|
|
" [cyan]diff[/] <name> Diff your copy of a bundled skill vs the stock version\n"
|
|
" [cyan]reset[/] <name> [--restore] Reset bundled-skill tracking (fix 'user-modified' flag)\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"))
|