Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit c2ef61b

Browse filesBrowse files
authored
feat: support multi-plugin repos with subpath in git URLs (MoonshotAI#1529)
1 parent cae2f6b commit c2ef61b
Copy full SHA for c2ef61b

2 files changed

+382-5Lines changed: 382 additions & 5 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎src/kimi_cli/cli/plugin.py‎

Copy file name to clipboardExpand all lines: src/kimi_cli/cli/plugin.py
+111-5Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,50 @@
1212
cli = typer.Typer(help="Manage plugins.")
1313

1414

15+
def _parse_git_url(target: str) -> tuple[str, str | None, str | None]:
16+
"""Parse a git URL into (clone_url, subpath, branch).
17+
18+
Splits .git URLs at the .git boundary. For GitHub/GitLab short URLs,
19+
treats the first two path segments as owner/repo and the rest as subpath.
20+
Strips ``tree/{branch}/`` or ``-/tree/{branch}/`` prefixes from
21+
browser-copied URLs and returns the branch name.
22+
"""
23+
# Path 1: URL contains .git followed by / or end-of-string
24+
idx = target.find(".git/")
25+
if idx == -1 and target.endswith(".git"):
26+
return target, None, None
27+
if idx != -1:
28+
clone_url = target[: idx + 4] # up to and including ".git"
29+
rest = target[idx + 5 :] # after ".git/"
30+
subpath = rest.strip("/") or None
31+
return clone_url, subpath, None
32+
33+
# Path 2: GitHub/GitLab short URL (no .git)
34+
from urllib.parse import urlparse
35+
36+
parsed = urlparse(target)
37+
segments = [s for s in parsed.path.split("/") if s]
38+
if len(segments) < 2:
39+
return target, None, None
40+
41+
owner_repo = "/".join(segments[:2])
42+
clone_url = f"{parsed.scheme}://{parsed.netloc}/{owner_repo}"
43+
rest_segments = segments[2:]
44+
45+
# GitLab uses /-/tree/{branch}/, strip leading "-"
46+
if rest_segments and rest_segments[0] == "-":
47+
rest_segments = rest_segments[1:]
48+
49+
# Strip tree/{branch}/ prefix and extract branch
50+
branch: str | None = None
51+
if len(rest_segments) >= 2 and rest_segments[0] == "tree":
52+
branch = rest_segments[1]
53+
rest_segments = rest_segments[2:]
54+
55+
subpath = "/".join(rest_segments) or None
56+
return clone_url, subpath, branch
57+
58+
1559
def _resolve_source(target: str) -> tuple[Path, Path | None]:
1660
"""Resolve plugin source to (local_dir, tmp_to_cleanup).
1761
@@ -23,22 +67,84 @@ def _resolve_source(target: str) -> tuple[Path, Path | None]:
2367

2468
# Git URL
2569
if target.startswith(("https://", "git@", "http://")) and (
26-
target.endswith(".git") or "github.com/" in target or "gitlab.com/" in target
70+
".git/" in target
71+
or target.endswith(".git")
72+
or "github.com/" in target
73+
or "gitlab.com/" in target
2774
):
2875
import subprocess
2976

77+
clone_url, subpath, branch = _parse_git_url(target)
78+
3079
tmp = Path(tempfile.mkdtemp(prefix="kimi-plugin-"))
31-
typer.echo(f"Cloning {target}...")
80+
typer.echo(f"Cloning {clone_url}...")
81+
clone_cmd = ["git", "clone", "--depth", "1"]
82+
if branch:
83+
clone_cmd += ["--branch", branch]
84+
clone_cmd += [clone_url, str(tmp / "repo")]
3285
result = subprocess.run(
33-
["git", "clone", "--depth", "1", target, str(tmp / "repo")],
86+
clone_cmd,
3487
capture_output=True,
3588
text=True,
3689
)
3790
if result.returncode != 0:
3891
shutil.rmtree(tmp, ignore_errors=True)
39-
typer.echo(f"Error: git clone failed: {result.stderr.strip()}", err=True)
92+
typer.echo(
93+
f"Error: git clone failed: {result.stderr.strip()}",
94+
err=True,
95+
)
4096
raise typer.Exit(1)
41-
return tmp / "repo", tmp
97+
98+
repo_root = tmp / "repo"
99+
100+
if subpath:
101+
source = (repo_root / subpath).resolve()
102+
if not source.is_relative_to(repo_root.resolve()):
103+
shutil.rmtree(tmp, ignore_errors=True)
104+
typer.echo(
105+
f"Error: subpath escapes repository: {subpath}",
106+
err=True,
107+
)
108+
raise typer.Exit(1)
109+
if not source.is_dir():
110+
shutil.rmtree(tmp, ignore_errors=True)
111+
typer.echo(
112+
f"Error: subpath '{subpath}' not found in repository",
113+
err=True,
114+
)
115+
raise typer.Exit(1)
116+
if not (source / "plugin.json").exists():
117+
shutil.rmtree(tmp, ignore_errors=True)
118+
typer.echo(
119+
f"Error: no plugin.json in '{subpath}'",
120+
err=True,
121+
)
122+
raise typer.Exit(1)
123+
return source, tmp
124+
125+
# No subpath — check root first
126+
if (repo_root / "plugin.json").exists():
127+
return repo_root, tmp
128+
129+
# Scan one level for available plugins
130+
available = sorted(
131+
d.name for d in repo_root.iterdir() if d.is_dir() and (d / "plugin.json").exists()
132+
)
133+
if available:
134+
names = "\n".join(f" - {n}" for n in available)
135+
typer.echo(
136+
f"Error: No plugin.json at repository root. "
137+
f"Available plugins:\n{names}\n"
138+
f"Use: kimi plugin install <url>/<plugin-name>",
139+
err=True,
140+
)
141+
else:
142+
typer.echo(
143+
"Error: No plugin.json found in repository",
144+
err=True,
145+
)
146+
shutil.rmtree(tmp, ignore_errors=True)
147+
raise typer.Exit(1)
42148

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

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.