Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
c1435dc5fa 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}/<browser-segment>/...`
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.
2026-05-27 17:06:09 -07:00
2 changed files with 117 additions and 1 deletions

View File

@@ -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("/")

View File

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