From c1435dc5fa80e87d0a7df7ede5453f9dcb130043 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 27 May 2026 17:06:09 -0700 Subject: [PATCH] Port from cline/cline#10945 (concept): accept browser-pasted GitHub URLs in `hermes plugins install` `git clone` only accepts the bare repo URL, but users routinely paste URLs they copied from a browser tab (`tree/`, `blob/`, `pull/`, `commit/`, `releases/`, `issues/`, etc.). Previously every such URL was passed verbatim to `git clone` and failed with `repository not found`. `_resolve_git_url()` now normalizes `https://github.com/{owner}/{repo}//...` down to `https://github.com/{owner}/{repo}.git` before handing off to git. Non-github hosts (gitlab, bitbucket, custom), SSH/file URLs, and the bare `https://github.com/owner/repo` form are all returned unchanged. Cline shipped the same UX concept for single-file plugins from blob URLs in cline#10945; hermes-agent plugins are directory-based, so this port is restricted to the URL-normalization piece that applies to our model. --- hermes_cli/plugins_cmd.py | 50 +++++++++++++++++++- tests/hermes_cli/test_plugins_cmd.py | 68 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 937fc7f7f6..525da5b06b 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -134,6 +134,51 @@ def _sanitize_plugin_name( return target +def _normalize_github_browser_url(url: str) -> str: + """Strip browser-only GitHub path segments so ``git clone`` accepts the URL. + + Users frequently paste URLs they copied from a browser tab, e.g. + ``https://github.com/owner/repo/tree/main/plugins/foo`` or + ``https://github.com/owner/repo/blob/main/README.md``. ``git clone`` does + not understand those — it only accepts the bare repo URL. Normalize the + common variants down to ``https://github.com/{owner}/{repo}.git`` so the + paste-from-address-bar workflow Just Works. + + Recognized trailing segments (after ``owner/repo``): + ``tree/*``, ``blob/*``, ``commit/*``, ``commits/*``, ``pull/*``, + ``pulls/*``, ``issues/*``, ``releases/*``, ``actions/*``, ``wiki/*``. + + Non-github.com hosts and URLs without a recognized segment are returned + unchanged — callers (and ``git clone``) will handle them as-is. + """ + # Only normalize https://github.com/... URLs. Leave gitlab, bitbucket, + # custom hosts, ssh URLs, and file:// alone. + prefix = "https://github.com/" + if not url.startswith(prefix): + return url + rest = url[len(prefix):].strip("/") + parts = rest.split("/") + if len(parts) < 2: + return url + owner, repo = parts[0], parts[1] + # Defensive: ensure owner/repo look sane before mutating. + if not owner or not repo or owner.startswith(".") or repo.startswith("."): + return url + # ``owner/repo.git`` is already canonical; leave that alone. + if len(parts) == 2 and repo.endswith(".git"): + return url + if len(parts) == 2: + # Bare repo URL with no trailing segment — git clone handles this. + return url + browser_segments = { + "tree", "blob", "commit", "commits", "pull", "pulls", + "issues", "releases", "actions", "wiki", + } + if parts[2] in browser_segments: + return f"https://github.com/{owner}/{repo}.git" + return url + + def _resolve_git_url(identifier: str) -> str: """Turn an identifier into a cloneable Git URL. @@ -141,6 +186,9 @@ def _resolve_git_url(identifier: str) -> str: - Full URL: https://github.com/owner/repo.git - Full URL: git@github.com:owner/repo.git - Full URL: ssh://git@github.com/owner/repo.git + - Browser URL: https://github.com/owner/repo/tree/main/path/to/plugin + (and ``blob/``, ``commit/``, ``pull/``, etc. — normalized to the bare + repo URL so ``git clone`` accepts it) - Shorthand: owner/repo → https://github.com/owner/repo.git NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a @@ -148,7 +196,7 @@ def _resolve_git_url(identifier: str) -> str: """ # Already a URL if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")): - return identifier + return _normalize_github_browser_url(identifier) # owner/repo shorthand parts = identifier.strip("/").split("/") diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index c918246e4e..fbe81e12e8 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -130,6 +130,74 @@ class TestResolveGitUrl: with pytest.raises(ValueError, match="Invalid plugin identifier"): _resolve_git_url("a/b/c") + # ── Browser-pasted GitHub URLs ────────────────────────────────────── + # Cline parity port (cline/cline#10945). ``git clone`` only accepts the + # bare repo URL; users routinely paste ``tree/``, ``blob/``, ``pull/``, + # etc. URLs from a browser tab. Those must be normalized down to + # ``https://github.com/{owner}/{repo}.git`` so install Just Works. + + def test_github_tree_url_normalized_to_repo(self): + url = _resolve_git_url("https://github.com/owner/repo/tree/main") + assert url == "https://github.com/owner/repo.git" + + def test_github_tree_with_subpath_normalized_to_repo(self): + url = _resolve_git_url( + "https://github.com/owner/repo/tree/main/plugins/foo" + ) + assert url == "https://github.com/owner/repo.git" + + def test_github_blob_url_normalized_to_repo(self): + url = _resolve_git_url( + "https://github.com/owner/repo/blob/main/README.md" + ) + assert url == "https://github.com/owner/repo.git" + + def test_github_pull_url_normalized_to_repo(self): + url = _resolve_git_url("https://github.com/owner/repo/pull/123") + assert url == "https://github.com/owner/repo.git" + + def test_github_commit_url_normalized_to_repo(self): + url = _resolve_git_url( + "https://github.com/owner/repo/commit/abc123def" + ) + assert url == "https://github.com/owner/repo.git" + + def test_github_releases_url_normalized_to_repo(self): + url = _resolve_git_url( + "https://github.com/owner/repo/releases/tag/v1.0" + ) + assert url == "https://github.com/owner/repo.git" + + def test_github_issues_url_normalized_to_repo(self): + url = _resolve_git_url("https://github.com/owner/repo/issues/42") + assert url == "https://github.com/owner/repo.git" + + def test_bare_github_url_unchanged(self): + # Already a cloneable repo URL — leave alone. + url = _resolve_git_url("https://github.com/owner/repo") + assert url == "https://github.com/owner/repo" + + def test_canonical_github_dotgit_url_unchanged(self): + url = _resolve_git_url("https://github.com/owner/repo.git") + assert url == "https://github.com/owner/repo.git" + + def test_non_github_host_passthrough_with_tree(self): + # gitlab, bitbucket, custom hosts — leave alone; their URL schemes + # differ and ``git clone`` may handle them correctly as-is. + url = _resolve_git_url("https://gitlab.com/owner/repo/tree/main") + assert url == "https://gitlab.com/owner/repo/tree/main" + + def test_github_user_profile_url_unchanged(self): + # Single-segment path (no repo) — return unchanged so the caller's + # downstream git invocation surfaces the real error. + url = _resolve_git_url("https://github.com/owner") + assert url == "https://github.com/owner" + + def test_github_url_with_unknown_segment_unchanged(self): + # Defensive: only normalize segments we explicitly recognize. + url = _resolve_git_url("https://github.com/owner/repo/branches") + assert url == "https://github.com/owner/repo/branches" + # ── _resolve_git_executable ─────────────────────────────────────────────────