1212cli = 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+
1559def _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