Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 111 additions & 5 deletions src/kimi_cli/cli/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@
cli = typer.Typer(help="Manage plugins.")


def _parse_git_url(target: str) -> tuple[str, str | None, str | None]:
"""Parse a git URL into (clone_url, subpath, branch).

Splits .git URLs at the .git boundary. For GitHub/GitLab short URLs,
treats the first two path segments as owner/repo and the rest as subpath.
Strips ``tree/{branch}/`` or ``-/tree/{branch}/`` prefixes from
browser-copied URLs and returns the branch name.
"""
# Path 1: URL contains .git followed by / or end-of-string
idx = target.find(".git/")
if idx == -1 and target.endswith(".git"):
return target, None, None
if idx != -1:
clone_url = target[: idx + 4] # up to and including ".git"
rest = target[idx + 5 :] # after ".git/"
subpath = rest.strip("/") or None
return clone_url, subpath, None

# Path 2: GitHub/GitLab short URL (no .git)
from urllib.parse import urlparse

parsed = urlparse(target)
segments = [s for s in parsed.path.split("/") if s]
if len(segments) < 2:
return target, None, None

owner_repo = "/".join(segments[:2])
clone_url = f"{parsed.scheme}://{parsed.netloc}/{owner_repo}"
Comment on lines +41 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve GitLab subgroup namespaces in short URLs

GitLab short URLs are not always owner/repo: projects under subgroups have paths like group/subgroup/repo. Slicing to segments[:2] turns https://gitlab.com/group/subgroup/repo/my-plugin into a clone URL of https://gitlab.com/group/subgroup and a subpath of repo/my-plugin, so subgroup-hosted plugin repos cannot be cloned or resolved at all even though this change advertises GitLab short URL support.

Useful? React with 👍 / 👎.

rest_segments = segments[2:]

# GitLab uses /-/tree/{branch}/, strip leading "-"
if rest_segments and rest_segments[0] == "-":
rest_segments = rest_segments[1:]

# Strip tree/{branch}/ prefix and extract branch
branch: str | None = None
if len(rest_segments) >= 2 and rest_segments[0] == "tree":
branch = rest_segments[1]
rest_segments = rest_segments[2:]
Comment on lines +51 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the selected branch from browser tree/... URLs

When a user pastes a browser URL such as https://github.com/org/repo/tree/release-1.2/my-plugin, this code removes tree/release-1.2 but never carries the branch into _resolve_source(), which still runs plain git clone --depth 1 <repo>. I checked git clone -h: --branch is what makes clone checkout <branch> instead of the remote's HEAD. For any non-default branch, install will either report subpath not found or silently install the copy from the default branch if the same path exists there.

Useful? React with 👍 / 👎.

Comment on lines +51 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle slash-containing tree refs correctly

When a pasted browser URL points at a ref containing / (for example feature/foo or GitHub’s dependabot/... branches), _parse_git_url() only captures rest_segments[1] as the branch and treats the remaining ref segments as part of the plugin path. _resolve_source() then runs git clone --branch <first-segment> and looks for the plugin under the wrong directory, so valid .../tree/<ref>/<plugin> URLs either fail to clone or resolve the wrong source tree.

Useful? React with 👍 / 👎.


subpath = "/".join(rest_segments) or None
return clone_url, subpath, branch


def _resolve_source(target: str) -> tuple[Path, Path | None]:
"""Resolve plugin source to (local_dir, tmp_to_cleanup).

Expand All @@ -23,22 +67,84 @@ def _resolve_source(target: str) -> tuple[Path, Path | None]:

# Git URL
if target.startswith(("https://", "git@", "http://")) and (
target.endswith(".git") or "github.com/" in target or "gitlab.com/" in target
".git/" in target
or target.endswith(".git")
or "github.com/" in target
or "gitlab.com/" in target
):
import subprocess

clone_url, subpath, branch = _parse_git_url(target)

tmp = Path(tempfile.mkdtemp(prefix="kimi-plugin-"))
typer.echo(f"Cloning {target}...")
typer.echo(f"Cloning {clone_url}...")
clone_cmd = ["git", "clone", "--depth", "1"]
if branch:
clone_cmd += ["--branch", branch]
clone_cmd += [clone_url, str(tmp / "repo")]
result = subprocess.run(
["git", "clone", "--depth", "1", target, str(tmp / "repo")],
clone_cmd,
capture_output=True,
text=True,
)
if result.returncode != 0:
shutil.rmtree(tmp, ignore_errors=True)
typer.echo(f"Error: git clone failed: {result.stderr.strip()}", err=True)
typer.echo(
f"Error: git clone failed: {result.stderr.strip()}",
err=True,
)
raise typer.Exit(1)
return tmp / "repo", tmp

repo_root = tmp / "repo"

if subpath:
source = (repo_root / subpath).resolve()
if not source.is_relative_to(repo_root.resolve()):
shutil.rmtree(tmp, ignore_errors=True)
typer.echo(
f"Error: subpath escapes repository: {subpath}",
err=True,
)
raise typer.Exit(1)
if not source.is_dir():
shutil.rmtree(tmp, ignore_errors=True)
typer.echo(
f"Error: subpath '{subpath}' not found in repository",
err=True,
)
raise typer.Exit(1)
if not (source / "plugin.json").exists():
shutil.rmtree(tmp, ignore_errors=True)
typer.echo(
f"Error: no plugin.json in '{subpath}'",
err=True,
)
raise typer.Exit(1)
return source, tmp

# No subpath — check root first
if (repo_root / "plugin.json").exists():
return repo_root, tmp

# Scan one level for available plugins
available = sorted(
d.name for d in repo_root.iterdir() if d.is_dir() and (d / "plugin.json").exists()
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The available = sorted(...) comprehension is on a single line and appears to exceed the repo’s Ruff line-length = 100 limit (E501 is not ignored for src/**). This will likely fail lint/typecheck CI; please wrap the generator and conditions across multiple lines.

Suggested change
d.name for d in repo_root.iterdir() if d.is_dir() and (d / "plugin.json").exists()
d.name
for d in repo_root.iterdir()
if d.is_dir() and (d / "plugin.json").exists()

Copilot uses AI. Check for mistakes.
)
if available:
names = "\n".join(f" - {n}" for n in available)
typer.echo(
f"Error: No plugin.json at repository root. "
f"Available plugins:\n{names}\n"
f"Use: kimi plugin install <url>/<plugin-name>",
err=True,
)
else:
typer.echo(
"Error: No plugin.json found in repository",
err=True,
)
shutil.rmtree(tmp, ignore_errors=True)
raise typer.Exit(1)

p = Path(target).expanduser().resolve()

Expand Down
Loading
Loading