feat(skills): install skills from a direct HTTP(S) URL (#16323)

* feat(skills): install skills from a direct HTTP(S) URL

Adds UrlSource adapter so `hermes skills install <url-to-SKILL.md>` and
`/skills install <url>` work as first-class operations — no more
improvising with curl + patch + cp.

- Claims identifiers that start with http(s):// and end in .md
- Skips /.well-known/skills/ URLs (WellKnownSkillSource handles those)
- Skill name from YAML frontmatter, URL-slug fallback
- Single-file SKILL.md only (v1 scope — multi-file skills need a manifest)
- Trust level 'community'; full security scan still runs
- Lock file stores the URL as identifier so `hermes skills update`
  re-fetches from the same URL cleanly

Scope matches real user need from @versun's docx feedback where
`https://sharethis.chat/SKILL.md` had no first-class install path.

* feat(skills): interactive name/category for URL installs + --name override

Follow-up to the UrlSource adapter. The previous commit fell back to weak
heuristics when frontmatter had no ``name:`` and could produce garbage names
like ``SKILL`` or ``unnamed-skill``. Now:

tools/skills_hub.py
- ``UrlSource._is_valid_skill_name()`` — strict identifier check
  (``^[a-z][a-z0-9_-]*$``), rejects sentinel values (``SKILL``, ``README``,
  ``INDEX``, ``unnamed-skill``, empty, non-strings).
- ``_resolve_skill_name()`` returns ``Optional[str]`` — ``None`` when
  nothing valid is resolvable. Also ignores unsafe frontmatter names
  (``../evil``) and falls through to URL slug instead of returning None
  immediately, so a URL with a bad frontmatter but a good path still
  works.
- ``fetch()``/``inspect()`` carry an ``awaiting_name=True`` marker in
  metadata/extra when resolution fails, letting ``do_install`` decide
  whether to prompt, apply an override, or error out.

hermes_cli/skills_hub.py
- ``do_install`` gains a ``name_override`` parameter.
- On URL-sourced bundles with ``awaiting_name=True``:
  1. If ``name_override`` is valid → use it.
  2. If ``name_override`` is invalid → refuse with a clear error.
  3. Else if ``skip_confirm=True`` (non-interactive: slash / TUI /
     gateway / scripts) → refuse with an actionable retry hint pointing
     at ``--name <your-name>`` on both CLI and slash forms.
  4. Else (interactive TTY) → prompt for the name.
- Interactive TTY also prompts for a category when none is given for a
  URL-sourced install, hinting existing category buckets so users can
  reuse ``productivity``, ``devops``, etc. Empty input → flat install.
- ``_existing_categories()`` scans ``~/.hermes/skills/`` for subdirs that
  look like category buckets (contain nested SKILL.md files); skips
  top-level skills and hidden dirs.
- ``_prompt_for_skill_name()`` / ``_prompt_for_category()`` helpers
  (EOF/Ctrl-C-safe, match the existing ``Confirm [y/N]`` prompt style).

hermes_cli/main.py
- ``hermes skills install`` argparse gains ``--name <name>``.

hermes_cli/skills_hub.py (slash)
- ``/skills install <url> --name <x>`` parsing added.

Tests
- tests/tools/test_skills_hub.py: updated ``UrlSource`` tests to assert
  the new ``awaiting_name`` metadata; added 4 new tests for
  ``_is_valid_skill_name`` rejection sets and the awaiting-name marker.
- tests/hermes_cli/test_skills_hub.py: 8 new tests covering --name
  override accept/reject, non-interactive error, interactive name prompt,
  interactive category prompt, cancel-aborts-install, and
  ``_existing_categories`` scan behavior (buckets vs flat skills).
- E2E verified all four paths (no-name/no-override → error;
  --name override → install; frontmatter name → install;
  invalid --name → rejection).

---------

Co-authored-by: teknium1 <teknium@noreply.github.com>
This commit is contained in:
Teknium
2026-04-26 20:57:10 -07:00
committed by GitHub
parent b288934dff
commit 9c416e20ab
5 changed files with 773 additions and 7 deletions

View File

@@ -8681,11 +8681,17 @@ Examples:
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
skills_install.add_argument(
"identifier", help="Skill identifier (e.g. openai/skills/skill-creator)"
"identifier",
help="Skill identifier (e.g. openai/skills/skill-creator) or a direct HTTP(S) URL to a SKILL.md file",
)
skills_install.add_argument(
"--category", default="", help="Category folder to install into"
)
skills_install.add_argument(
"--name",
default="",
help="Override the skill name (useful when installing from a URL whose SKILL.md has no `name:` frontmatter)",
)
skills_install.add_argument(
"--force", action="store_true", help="Install despite blocked scan verdict"
)

View File

@@ -11,9 +11,10 @@ handler are thin wrappers that parse args and delegate.
"""
import json
import re
import shutil
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from rich.console import Console
from rich.panel import Panel
@@ -141,6 +142,103 @@ def _derive_category_from_install_path(install_path: str) -> str:
return "" if parent == "." else parent
# ---------------------------------------------------------------------------
# Interactive name/category resolution for URL-installed skills
# ---------------------------------------------------------------------------
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
_VALID_CATEGORY_RE = re.compile(r"^[a-z][a-z0-9_/-]*$")
def _is_valid_installed_skill_name(name: str) -> bool:
"""Accept identifier-shaped names, reject empty / sentinel-y values."""
if not isinstance(name, str):
return False
candidate = name.strip().lower()
if not candidate or candidate in {"skill", "readme", "index", "unnamed-skill"}:
return False
return bool(_VALID_NAME_RE.match(candidate))
def _existing_categories() -> List[str]:
"""Return sorted subdirectory names under ``~/.hermes/skills/`` that look
like category buckets (contain at least one ``SKILL.md`` somewhere below).
Used to suggest reusable categories when interactively installing from a
URL. Hidden dirs (``.hub``, ``.trash``) are skipped.
"""
from tools.skills_hub import SKILLS_DIR
out: List[str] = []
try:
for entry in SKILLS_DIR.iterdir():
if not entry.is_dir() or entry.name.startswith("."):
continue
# Only count as a category if it contains skills, not if it IS a skill.
# Heuristic: if ``<entry>/SKILL.md`` exists, it's a skill at the
# top level (no category); otherwise treat as a category bucket.
if (entry / "SKILL.md").exists():
continue
# Has at least one nested SKILL.md?
try:
if any(entry.rglob("SKILL.md")):
out.append(entry.name)
except OSError:
continue
except (FileNotFoundError, OSError):
return []
return sorted(set(out))
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 "
f"frontmatter,[/]\n[yellow]and the URL path doesn't produce a valid "
f"identifier either.[/]"
)
default_hint = f" [{default}]" if default else ""
c.print(
f"[bold]Enter a skill name{default_hint}:[/] "
f"[dim](lowercase letters, digits, hyphens, underscores; starts with a letter)[/]"
)
try:
answer = input("Name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
if not answer and default:
answer = 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[/] [dim](optional — press Enter to install flat at ~/.hermes/skills/<name>/)[/]"
)
try:
answer = input("Category: ").strip()
except (EOFError, KeyboardInterrupt):
return ""
if not answer:
return ""
if not _VALID_CATEGORY_RE.match(answer):
c.print(f"[dim]Invalid category {answer!r} — installing flat.[/]")
return ""
return answer
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."""
@@ -309,8 +407,17 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
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."""
invalidate_cache: bool = True,
name_override: str = "") -> None:
"""Fetch, quarantine, scan, confirm, and install a skill.
``name_override`` lets non-interactive callers (slash commands, gateway,
scripts) supply a skill name when the upstream SKILL.md lacks a valid
``name:`` frontmatter field. On interactive TTY surfaces, a missing name
triggers a prompt instead; ``skip_confirm=True`` means "non-interactive"
(so pair it with ``name_override`` when installing from a URL that has
no frontmatter).
"""
from tools.skills_hub import (
GitHubAuth, create_source_router, ensure_hub_dirs,
quarantine_bundle, install_from_quarantine, HubLockFile,
@@ -354,6 +461,58 @@ def do_install(identifier: str, category: str = "", force: bool = False,
c.print()
return
# URL-sourced skills may arrive with an empty name when SKILL.md has no
# ``name:`` in frontmatter AND the URL path doesn't yield a valid
# identifier. Resolve by (1) --name override, (2) interactive prompt on
# a TTY, (3) refuse with an actionable error on non-interactive surfaces.
bundle_meta = getattr(bundle, "metadata", {}) or {}
if bundle.source == "url" and (not bundle.name or bundle_meta.get("awaiting_name")):
if name_override and _is_valid_installed_skill_name(name_override):
bundle.name = name_override.strip()
bundle_meta["awaiting_name"] = False
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
elif skip_confirm:
# Non-interactive surface (slash command / TUI / gateway). Can't
# prompt — emit an actionable error.
url = bundle_meta.get("url") or identifier
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
else:
# Interactive TTY — prompt.
url = bundle_meta.get("url") or identifier
chosen = _prompt_for_skill_name(c, url)
if not chosen:
c.print("[dim]Installation cancelled.[/]\n")
return
bundle.name = chosen
bundle_meta["awaiting_name"] = False
# Keep SkillMeta in sync so downstream "already installed" checks,
# audit logs, and display all see the final name.
if meta is not None:
meta.name = bundle.name
meta.path = bundle.name
# URL-sourced skills: offer to pick a category interactively when the
# caller didn't specify one (TTY only — non-interactive installs fall
# through to flat install, matching all other sources).
if bundle.source == "url" and not category and not skip_confirm:
category = _prompt_for_category(c, _existing_categories())
# 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"]
@@ -1164,7 +1323,8 @@ def skills_command(args) -> None:
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))
skip_confirm=getattr(args, "yes", False),
name_override=getattr(args, "name", "") or "")
elif action == "inspect":
do_inspect(args.identifier)
elif action == "list":
@@ -1221,6 +1381,7 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
/skills search kubernetes
/skills install openai/skills/skill-creator
/skills install openai/skills/skill-creator --force
/skills install https://example.com/path/SKILL.md
/skills inspect openai/skills/skill-creator
/skills list
/skills list --source hub
@@ -1297,10 +1458,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
elif action == "install":
if not args:
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force] [--now]\n")
c.print("[bold red]Usage:[/] /skills install <identifier-or-url> [--name <name>] [--category <cat>] [--force] [--now]\n")
return
identifier = args[0]
category = ""
name_override = ""
# Slash commands run inside prompt_toolkit where input() hangs.
# Always skip confirmation — the user typing the command is implicit consent.
skip_confirm = True
@@ -1311,9 +1473,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
for i, a in enumerate(args):
if a == "--category" and i + 1 < len(args):
category = args[i + 1]
elif a == "--name" and i + 1 < len(args):
name_override = args[i + 1]
do_install(identifier, category=category, force=force,
skip_confirm=skip_confirm, invalidate_cache=invalidate_cache,
console=c)
name_override=name_override, console=c)
elif action == "inspect":
if not args:

View File

@@ -316,3 +316,211 @@ def test_do_install_scans_with_resolved_identifier(monkeypatch, tmp_path, hub_en
do_install("skils-sh/anthropics/skills/frontend-design", console=console, skip_confirm=True)
assert scanned["source"] == canonical_identifier
# ---------------------------------------------------------------------------
# UrlSource-specific install paths: --name override, interactive prompts,
# non-interactive error, existing-category scan.
# ---------------------------------------------------------------------------
def _make_url_bundle_fetcher(name="", awaiting_name=True, url="https://example.com/SKILL.md"):
"""Return a fake source that simulates ``UrlSource.fetch`` for a
URL-sourced skill whose name hasn't been auto-resolved."""
class _UrlSource:
def inspect(self, identifier):
return type("Meta", (), {
"extra": {"url": url, "awaiting_name": awaiting_name},
"identifier": url,
"name": name,
"path": name,
})()
def fetch(self, identifier):
return type("Bundle", (), {
"name": name,
"files": {"SKILL.md": "---\ndescription: ok\n---\n# body\n"},
"source": "url",
"identifier": url,
"trust_level": "community",
"metadata": {"url": url, "awaiting_name": awaiting_name},
})()
return _UrlSource
def _install_mocks(monkeypatch, tmp_path, source_factory, category_hint=""):
"""Wire the minimum set of monkeypatches for a do_install dry run."""
import tools.skills_hub as hub
import tools.skills_guard as guard
q_path = tmp_path / "skills" / ".hub" / "quarantine" / "pending"
q_path.mkdir(parents=True)
install_calls: list = []
def _install_from_quarantine(q, name, category, bundle, result):
install_calls.append({"name": name, "category": category})
install_dir = tmp_path / "skills" / (f"{category}/" if category else "") / name
install_dir.mkdir(parents=True, exist_ok=True)
return install_dir
monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None)
monkeypatch.setattr(hub, "create_source_router", lambda auth: [source_factory()])
monkeypatch.setattr(hub, "quarantine_bundle", lambda bundle: q_path)
monkeypatch.setattr(hub, "install_from_quarantine", _install_from_quarantine)
monkeypatch.setattr(
hub, "HubLockFile",
lambda: type("Lock", (), {"get_installed": lambda self, n: None})(),
)
monkeypatch.setattr(
guard, "scan_skill",
lambda skill_path, source="community": guard.ScanResult(
skill_name="pending", source=source, trust_level="community", verdict="safe",
),
)
monkeypatch.setattr(guard, "format_scan_report", lambda result: "scan ok")
monkeypatch.setattr(guard, "should_allow_install", lambda result, force=False: (True, "ok"))
return install_calls
def test_url_install_uses_name_override_on_non_interactive_surface(monkeypatch, tmp_path, hub_env):
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_install(
"https://example.com/SKILL.md",
console=console, skip_confirm=True,
name_override="my-url-skill",
)
assert installs == [{"name": "my-url-skill", "category": ""}]
def test_url_install_rejects_invalid_name_override(monkeypatch, tmp_path, hub_env):
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_install(
"https://example.com/SKILL.md",
console=console, skip_confirm=True,
name_override="SKILL", # rejected by _is_valid_installed_skill_name
)
assert installs == [] # did NOT install
assert "Invalid --name" in sink.getvalue()
def test_url_install_actionable_error_on_non_interactive_with_no_name(monkeypatch, tmp_path, hub_env):
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_install(
"https://example.com/SKILL.md",
console=console, skip_confirm=True,
# No name_override — should error out with a retry hint.
)
assert installs == []
out = sink.getvalue()
assert "Cannot install from URL" in out
assert "--name <your-name>" in out
def test_url_install_prompts_interactively_when_tty(monkeypatch, tmp_path, hub_env):
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
# Simulate user typing "my-interactive" to name prompt, then "" to category.
answers = iter(["my-interactive", ""])
monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_install(
"https://example.com/SKILL.md",
console=console, skip_confirm=False, # interactive
force=True, # skip the final confirm prompt (tested elsewhere)
)
assert installs == [{"name": "my-interactive", "category": ""}]
def test_url_install_prompts_category_and_uses_typed_value(monkeypatch, tmp_path, hub_env):
import tools.skills_hub as hub
installs = _install_mocks(
monkeypatch, tmp_path,
_make_url_bundle_fetcher(name="sharethis-chat", awaiting_name=False),
)
# Stage an existing category bucket so _existing_categories finds it.
(hub.SKILLS_DIR / "productivity" / "notion").mkdir(parents=True)
(hub.SKILLS_DIR / "productivity" / "notion" / "SKILL.md").write_text("# notion")
# Name is already resolved (from frontmatter) → only category prompt fires.
answers = iter(["productivity"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_install(
"https://example.com/sharethis-chat/SKILL.md",
console=console, skip_confirm=False, force=True,
)
assert installs == [{"name": "sharethis-chat", "category": "productivity"}]
assert "Existing: productivity" in sink.getvalue()
def test_url_install_cancel_name_prompt_aborts(monkeypatch, tmp_path, hub_env):
installs = _install_mocks(monkeypatch, tmp_path, _make_url_bundle_fetcher())
# Empty input with no default → name prompt returns None → abort.
monkeypatch.setattr("builtins.input", lambda prompt="": "")
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_install(
"https://example.com/SKILL.md",
console=console, skip_confirm=False, force=True,
)
assert installs == []
assert "Installation cancelled" in sink.getvalue()
# ── _existing_categories ────────────────────────────────────────────────────
def test_existing_categories_skips_top_level_skills(monkeypatch, tmp_path, hub_env):
import tools.skills_hub as hub
from hermes_cli.skills_hub import _existing_categories
# Category bucket with nested skill.
(hub.SKILLS_DIR / "productivity" / "notion").mkdir(parents=True)
(hub.SKILLS_DIR / "productivity" / "notion" / "SKILL.md").write_text("# notion")
# Flat skill at top level (NOT a category).
(hub.SKILLS_DIR / "my-flat-skill").mkdir()
(hub.SKILLS_DIR / "my-flat-skill" / "SKILL.md").write_text("# flat")
# Empty dir (NOT a category — no SKILL.md below).
(hub.SKILLS_DIR / "empty-dir").mkdir()
# Hidden dir (ignored).
(hub.SKILLS_DIR / ".hub").mkdir(exist_ok=True)
cats = _existing_categories()
assert cats == ["productivity"]
def test_existing_categories_returns_empty_when_skills_dir_missing(monkeypatch, tmp_path, hub_env):
# hub_env creates tmp_path/skills/.hub — we point SKILLS_DIR at a missing sibling.
import tools.skills_hub as hub
monkeypatch.setattr(hub, "SKILLS_DIR", tmp_path / "does-not-exist")
from hermes_cli.skills_hub import _existing_categories
assert _existing_categories() == []

View File

@@ -12,6 +12,7 @@ from tools.skills_hub import (
GitHubSource,
LobeHubSource,
SkillsShSource,
UrlSource,
WellKnownSkillSource,
OptionalSkillSource,
SkillMeta,
@@ -673,6 +674,211 @@ class TestWellKnownSkillSource:
assert bundle is None
class TestUrlSource:
def _source(self):
return UrlSource()
# ── _matches ────────────────────────────────────────────────────────
def test_matches_bare_md_url(self):
assert self._source()._matches("https://example.com/path/SKILL.md") is True
def test_matches_http_scheme(self):
assert self._source()._matches("http://example.com/SKILL.md") is True
def test_rejects_non_md_url(self):
assert self._source()._matches("https://example.com/path/") is False
assert self._source()._matches("https://example.com/skills.json") is False
def test_rejects_well_known_url(self):
# Leave these for WellKnownSkillSource.
assert self._source()._matches(
"https://example.com/.well-known/skills/git-workflow/SKILL.md"
) is False
assert self._source()._matches(
"https://example.com/.well-known/skills/index.json"
) is False
def test_rejects_wrapped_identifiers(self):
assert self._source()._matches("github:owner/repo/skill") is False
assert self._source()._matches("well-known:https://example.com/x") is False
assert self._source()._matches("official/security/1password") is False
def test_rejects_non_string(self):
assert self._source()._matches(None) is False # type: ignore[arg-type]
assert self._source()._matches(123) is False # type: ignore[arg-type]
def test_search_returns_empty(self):
# Direct-URL source is not searchable.
assert self._source().search("anything") == []
# ── inspect ─────────────────────────────────────────────────────────
@patch("tools.skills_hub.httpx.get")
def test_inspect_reads_frontmatter_from_url(self, mock_get):
mock_get.return_value = MagicMock(
status_code=200,
text=(
"---\n"
"name: sharethis-chat\n"
"description: Share agent conversations.\n"
"metadata:\n"
" hermes:\n"
" tags: [sharing, chat]\n"
"---\n\n# Body\n"
),
)
meta = self._source().inspect("https://sharethis.chat/SKILL.md")
assert meta is not None
assert meta.name == "sharethis-chat"
assert meta.description == "Share agent conversations."
assert meta.source == "url"
assert meta.identifier == "https://sharethis.chat/SKILL.md"
assert meta.trust_level == "community"
assert meta.tags == ["sharing", "chat"]
assert meta.extra["awaiting_name"] is False
@patch("tools.skills_hub.httpx.get")
def test_inspect_returns_none_when_url_not_md(self, mock_get):
# _matches filters first — no HTTP call.
meta = self._source().inspect("https://example.com/not-a-skill")
assert meta is None
mock_get.assert_not_called()
@patch("tools.skills_hub.httpx.get")
def test_inspect_returns_none_on_404(self, mock_get):
mock_get.return_value = MagicMock(status_code=404)
assert self._source().inspect("https://example.com/SKILL.md") is None
@patch("tools.skills_hub.httpx.get")
def test_inspect_returns_none_on_http_error(self, mock_get):
mock_get.side_effect = httpx.HTTPError("boom")
assert self._source().inspect("https://example.com/SKILL.md") is None
@patch("tools.skills_hub.httpx.get")
def test_inspect_flags_awaiting_name_when_unresolvable(self, mock_get):
# No frontmatter name + a URL path that can't produce a valid slug
# (``SKILL`` isn't a valid skill name).
mock_get.return_value = MagicMock(
status_code=200,
text="---\ndescription: unnamed.\n---\n",
)
meta = self._source().inspect("https://example.com/SKILL.md")
assert meta is not None
assert meta.name == ""
assert meta.extra["awaiting_name"] is True
# ── fetch ───────────────────────────────────────────────────────────
@patch("tools.skills_hub.httpx.get")
def test_fetch_builds_single_file_bundle(self, mock_get):
skill_md = (
"---\n"
"name: sharethis-chat\n"
"description: Share.\n"
"---\n\n# Body\n"
)
mock_get.return_value = MagicMock(status_code=200, text=skill_md)
bundle = self._source().fetch("https://sharethis.chat/SKILL.md")
assert bundle is not None
assert bundle.name == "sharethis-chat"
assert bundle.source == "url"
assert bundle.identifier == "https://sharethis.chat/SKILL.md"
assert bundle.trust_level == "community"
assert bundle.files == {"SKILL.md": skill_md}
assert bundle.metadata["url"] == "https://sharethis.chat/SKILL.md"
assert bundle.metadata["awaiting_name"] is False
@patch("tools.skills_hub.httpx.get")
def test_fetch_falls_back_to_url_directory_name(self, mock_get):
# Frontmatter has no ``name:`` — we slug from the URL directory.
mock_get.return_value = MagicMock(
status_code=200,
text="---\ndescription: No name.\n---\n\n# Body\n",
)
bundle = self._source().fetch("https://example.com/my-skill/SKILL.md")
assert bundle is not None
assert bundle.name == "my-skill"
assert bundle.metadata["awaiting_name"] is False
@patch("tools.skills_hub.httpx.get")
def test_fetch_falls_back_to_filename_when_no_parent_dir(self, mock_get):
mock_get.return_value = MagicMock(
status_code=200,
text="---\ndescription: Bare file.\n---\n",
)
bundle = self._source().fetch("https://example.com/my-skill.md")
assert bundle is not None
assert bundle.name == "my-skill"
assert bundle.metadata["awaiting_name"] is False
@patch("tools.skills_hub.httpx.get")
def test_fetch_awaiting_name_when_unresolvable(self, mock_get):
# Bare ``SKILL.md`` at the domain root with no frontmatter name.
mock_get.return_value = MagicMock(
status_code=200,
text="---\ndescription: Bare.\n---\n\n# Body\n",
)
bundle = self._source().fetch("https://example.com/SKILL.md")
assert bundle is not None
assert bundle.name == ""
assert bundle.metadata["awaiting_name"] is True
# File content still present — CLI will reuse it after picking a name.
assert bundle.files["SKILL.md"].startswith("---\n")
@patch("tools.skills_hub.httpx.get")
def test_fetch_awaiting_name_rejects_sentinel_slug(self, mock_get):
# Frontmatter has no name AND the URL filename slug is ``README`` —
# our valid-name check rejects it, so we flag awaiting_name.
mock_get.return_value = MagicMock(
status_code=200,
text="---\ndescription: no name.\n---\n",
)
bundle = self._source().fetch("https://example.com/README.md")
assert bundle is not None
assert bundle.name == ""
assert bundle.metadata["awaiting_name"] is True
@patch("tools.skills_hub.httpx.get")
def test_fetch_ignores_unsafe_frontmatter_name_and_falls_through_to_slug(self, mock_get):
# Traversal / unsafe names are rejected by ``_is_valid_skill_name``;
# resolver falls through to URL slug (``my-skill`` here) and succeeds.
mock_get.return_value = MagicMock(
status_code=200,
text="---\nname: ../evil\ndescription: Bad.\n---\n",
)
bundle = self._source().fetch("https://example.com/my-skill/SKILL.md")
assert bundle is not None
assert bundle.name == "my-skill"
@patch("tools.skills_hub.httpx.get")
def test_fetch_returns_none_on_404(self, mock_get):
mock_get.return_value = MagicMock(status_code=404)
assert self._source().fetch("https://example.com/SKILL.md") is None
@patch("tools.skills_hub.httpx.get")
def test_fetch_skips_non_matching_identifier(self, mock_get):
assert self._source().fetch("owner/repo/skill") is None
mock_get.assert_not_called()
# ── _is_valid_skill_name ────────────────────────────────────────────
def test_is_valid_skill_name_accepts_identifiers(self):
valid = ["my-skill", "my_skill", "sharethis-chat", "a", "skill-1", "s1"]
for name in valid:
assert UrlSource._is_valid_skill_name(name), f"should accept {name!r}"
def test_is_valid_skill_name_rejects_sentinel_and_garbage(self):
invalid = [
"",
"SKILL", "skill", "README", "readme", "INDEX", "index",
"unnamed-skill",
"../evil", "a/b", "has space", "has.dot",
"-leading-dash", "1-leading-digit",
None, 123, ["list"],
]
for name in invalid:
assert not UrlSource._is_valid_skill_name(name), f"should reject {name!r}"
class TestCheckForSkillUpdates:
def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path):
from tools.skills_guard import content_hash
@@ -755,6 +961,17 @@ class TestCreateSourceRouter:
sources = create_source_router(auth=MagicMock(spec=GitHubAuth))
assert any(isinstance(src, WellKnownSkillSource) for src in sources)
def test_includes_url_source(self):
sources = create_source_router(auth=MagicMock(spec=GitHubAuth))
assert any(isinstance(src, UrlSource) for src in sources)
def test_url_source_runs_before_github_source(self):
# UrlSource must win over GitHubSource when both could claim a URL.
sources = create_source_router(auth=MagicMock(spec=GitHubAuth))
url_idx = next(i for i, src in enumerate(sources) if isinstance(src, UrlSource))
gh_idx = next(i for i, src in enumerate(sources) if isinstance(src, GitHubSource))
assert url_idx < gh_idx
# ---------------------------------------------------------------------------
# HubLockFile

View File

@@ -931,6 +931,176 @@ class WellKnownSkillSource(SkillSource):
return f"well-known:{base_url.rstrip('/')}/{skill_name}"
# ---------------------------------------------------------------------------
# Direct URL source adapter
# ---------------------------------------------------------------------------
class UrlSource(SkillSource):
"""Fetch a single-file SKILL.md skill directly from an HTTP(S) URL.
The identifier IS the URL (e.g. ``https://example.com/path/SKILL.md``).
Only single-file skills are supported — multi-file skills with
``references/`` or ``scripts/`` subfolders need a manifest we can't
discover from a bare URL.
The skill name is read from the ``name:`` field in the SKILL.md YAML
frontmatter (with a URL-slug fallback). Trust level is always
``community`` and the same security scan runs as for every other source.
"""
def source_id(self) -> str:
return "url"
def trust_level_for(self, identifier: str) -> str:
return "community"
# Search is meaningless for a direct URL — skip (return empty).
def search(self, query: str, limit: int = 10) -> List[SkillMeta]:
return []
def _matches(self, identifier: str) -> bool:
"""Return True iff this source should handle ``identifier``.
We claim bare HTTP(S) URLs that end in ``.md`` (typically
``.../SKILL.md``). Wrapped identifiers (``github:``,
``well-known:``, etc.) and ``/.well-known/skills/`` URLs are
left for their respective adapters.
"""
if not isinstance(identifier, str):
return False
ident = identifier.strip()
if not ident.lower().startswith(("http://", "https://")):
return False
# Don't steal well-known URLs.
if "/.well-known/skills/" in ident or ident.rstrip("/").endswith("/index.json"):
return False
# Only claim URLs that look like a markdown file.
try:
path = urlparse(ident).path
except ValueError:
return False
return path.lower().endswith(".md")
def inspect(self, identifier: str) -> Optional[SkillMeta]:
if not self._matches(identifier):
return None
url = identifier.strip()
text = self._fetch_text(url)
if text is None:
return None
fm = GitHubSource._parse_frontmatter_quick(text)
name = self._resolve_skill_name(fm, url)
description = str(fm.get("description") or "")
tags: List[str] = []
metadata = fm.get("metadata", {})
if isinstance(metadata, dict):
hermes_meta = metadata.get("hermes", {})
if isinstance(hermes_meta, dict):
raw_tags = hermes_meta.get("tags", [])
if isinstance(raw_tags, list):
tags = [str(t) for t in raw_tags]
return SkillMeta(
name=name or "",
description=description,
source="url",
identifier=url,
trust_level="community",
path=name or "",
tags=tags,
extra={"url": url, "awaiting_name": name is None},
)
def fetch(self, identifier: str) -> Optional[SkillBundle]:
if not self._matches(identifier):
return None
url = identifier.strip()
text = self._fetch_text(url)
if text is None:
return None
fm = GitHubSource._parse_frontmatter_quick(text)
name = self._resolve_skill_name(fm, url)
# When auto-resolution fails, return a bundle with an empty name and
# ``awaiting_name=True`` in metadata. The install flow (``do_install``)
# either prompts the user on a TTY or refuses with an actionable error
# on non-interactive surfaces. Keep the expensive HTTP fetch's result
# so the caller doesn't have to re-download after picking a name.
skill_name = ""
if name is not None:
try:
skill_name = _validate_skill_name(name)
except ValueError:
logger.warning("URL skill %s produced unsafe skill name: %r", url, name)
return None
return SkillBundle(
name=skill_name,
files={"SKILL.md": text},
source="url",
identifier=url,
trust_level="community",
metadata={"url": url, "awaiting_name": not skill_name},
)
@staticmethod
def _fetch_text(url: str) -> Optional[str]:
try:
resp = httpx.get(url, timeout=20, follow_redirects=True)
if resp.status_code == 200:
return resp.text
except httpx.HTTPError as exc:
logger.debug("UrlSource fetch failed for %s: %s", url, exc)
return None
return None
# Skill names must look like identifiers: lowercase letters/digits with
# optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless
# (``SKILL``, ``README``, empty) candidates before they hit the disk.
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
@classmethod
def _is_valid_skill_name(cls, name: Optional[str]) -> bool:
if not isinstance(name, str):
return False
candidate = name.strip().lower()
if not candidate or candidate in {"skill", "readme", "index", "unnamed-skill"}:
return False
return bool(cls._VALID_NAME_RE.match(candidate))
@classmethod
def _resolve_skill_name(cls, fm: dict, url: str) -> Optional[str]:
"""Pick a skill name from frontmatter or URL.
Returns ``None`` when neither source produces a valid identifier;
callers (CLI ``do_install``) then prompt the user or refuse. Preferring
a clean failure over a useless auto-name like ``SKILL`` or ``unnamed-skill``.
"""
# 1. Frontmatter ``name:`` is authoritative when present and valid.
fm_name = fm.get("name") if isinstance(fm, dict) else None
if isinstance(fm_name, str) and cls._is_valid_skill_name(fm_name):
return fm_name.strip()
# 2. URL-slug heuristic: ``.../<name>/SKILL.md`` → ``<name>``;
# ``.../<name>.md`` → ``<name>``. Validate each candidate.
try:
path = urlparse(url).path
except ValueError:
return None
parts = [p for p in path.split("/") if p]
if parts and parts[-1].lower() == "skill.md" and len(parts) >= 2:
candidate = parts[-2]
if cls._is_valid_skill_name(candidate):
return candidate
if parts:
candidate = re.sub(r"\.md$", "", parts[-1], flags=re.IGNORECASE)
if cls._is_valid_skill_name(candidate):
return candidate
# Nothing usable — let the caller handle it.
return None
# ---------------------------------------------------------------------------
# skills.sh source adapter
# ---------------------------------------------------------------------------
@@ -2931,6 +3101,7 @@ def create_source_router(auth: Optional[GitHubAuth] = None) -> List[SkillSource]
HermesIndexSource(auth=auth), # Centralized index (search + resolved install paths)
SkillsShSource(auth=auth),
WellKnownSkillSource(),
UrlSource(), # Direct HTTP(S) URL to a SKILL.md file
GitHubSource(auth=auth, extra_taps=extra_taps),
ClawHubSource(),
ClaudeMarketplaceSource(auth=auth),