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

@@ -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