Skip to content

Commit 6067a70

Browse files
feat: support local plugin install (#8448)
* feat: support local plugin install * fix: make editable plugin install symlink * fix: harden local plugin install * Update tests/test_cli_plugin.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update astrbot/cli/commands/cmd_plug.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 89ec07a commit 6067a70

5 files changed

Lines changed: 324 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ uv.lock
99
# IDE and editors
1010
.vscode
1111
.idea
12+
.zed/
1213

1314
# Logs and temporary files
1415
botpy.log

astrbot/cli/commands/cmd_plug.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
check_astrbot_root,
1111
get_astrbot_root,
1212
get_git_repo,
13+
install_local_plugin,
1314
manage_plugin,
1415
)
1516

@@ -143,12 +144,32 @@ def list(all: bool) -> None:
143144

144145

145146
@plug.command()
146-
@click.argument("name")
147+
@click.argument("name", required=False)
148+
@click.option(
149+
"--editable",
150+
"-e",
151+
"local_path",
152+
type=click.Path(exists=True, file_okay=False, path_type=Path),
153+
help="Install a plugin from a local directory as a symlink",
154+
)
147155
@click.option("--proxy", help="Proxy server address")
148-
def install(name: str, proxy: str | None) -> None:
156+
def install(name: str | None, local_path: Path | None, proxy: str | None) -> None:
149157
"""Install a plugin"""
150158
base_path = _get_data_path()
151159
plug_path = base_path / "plugins"
160+
161+
if local_path is not None:
162+
install_local_plugin(local_path, plug_path, editable=True)
163+
return
164+
165+
if name is None:
166+
raise click.ClickException("Missing plugin name or local plugin path")
167+
168+
local_name_path = Path(name).expanduser()
169+
if local_name_path.exists() and local_name_path.is_dir():
170+
install_local_plugin(local_name_path, plug_path, editable=False)
171+
return
172+
152173
plugins = build_plug_list(base_path / "plugins")
153174

154175
plugin = next(

astrbot/cli/utils/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
check_dashboard,
44
get_astrbot_root,
55
)
6-
from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin
6+
from .plugin import (
7+
PluginStatus,
8+
build_plug_list,
9+
get_git_repo,
10+
install_local_plugin,
11+
manage_plugin,
12+
)
713
from .version_comparator import VersionComparator
814

915
__all__ = [
@@ -14,5 +20,6 @@
1420
"check_dashboard",
1521
"get_astrbot_root",
1622
"get_git_repo",
23+
"install_local_plugin",
1724
"manage_plugin",
1825
]

astrbot/cli/utils/plugin.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import shutil
22
import tempfile
3+
import uuid
34
from enum import Enum
45
from io import BytesIO
56
from pathlib import Path
@@ -19,6 +20,35 @@ class PluginStatus(str, Enum):
1920
NOT_PUBLISHED = "unpublished"
2021

2122

23+
LOCAL_PLUGIN_COPY_IGNORE = shutil.ignore_patterns(
24+
".git",
25+
"__pycache__",
26+
"*.pyc",
27+
".venv",
28+
"venv",
29+
".idea",
30+
".vscode",
31+
".zed",
32+
)
33+
34+
35+
def _validate_plugin_dir_name(plugin_name: str, source_path: Path) -> str:
36+
plugin_name = plugin_name.strip()
37+
plugin_path = Path(plugin_name)
38+
has_separator = "/" in plugin_name or "\\" in plugin_name
39+
if (
40+
not plugin_name
41+
or plugin_name in {".", ".."}
42+
or plugin_path.is_absolute()
43+
or has_separator
44+
or plugin_path.name != plugin_name
45+
):
46+
raise click.ClickException(
47+
f"Local plugin {source_path} metadata.yaml has invalid name: {plugin_name}"
48+
)
49+
return plugin_name
50+
51+
2252
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
2353
"""Download code from a Git repository and extract to the specified path"""
2454
temp_dir = Path(tempfile.mkdtemp())
@@ -184,6 +214,78 @@ def build_plug_list(plugins_dir: Path) -> list:
184214
return result
185215

186216

217+
def _cleanup_local_plugin_target(target_path: Path) -> None:
218+
if target_path.is_symlink() or target_path.is_file():
219+
target_path.unlink(missing_ok=True)
220+
elif target_path.exists():
221+
shutil.rmtree(target_path, ignore_errors=True)
222+
223+
224+
def _copy_local_plugin(source_path: Path, plugins_dir: Path, target_path: Path) -> None:
225+
temp_target = plugins_dir / f".{target_path.name}.tmp-{uuid.uuid4().hex}"
226+
try:
227+
shutil.copytree(source_path, temp_target, ignore=LOCAL_PLUGIN_COPY_IGNORE)
228+
temp_target.rename(target_path)
229+
except FileExistsError:
230+
raise click.ClickException(
231+
f"Plugin {target_path.name} already exists"
232+
) from None
233+
except Exception:
234+
raise
235+
finally:
236+
if temp_target.exists() or temp_target.is_symlink():
237+
_cleanup_local_plugin_target(temp_target)
238+
239+
240+
def install_local_plugin(
241+
source_path: Path,
242+
plugins_dir: Path,
243+
editable: bool = False,
244+
) -> None:
245+
"""Install a plugin from a local directory."""
246+
source_path = source_path.expanduser().resolve()
247+
plugins_dir = plugins_dir.resolve()
248+
249+
if not source_path.exists() or not source_path.is_dir():
250+
raise click.ClickException(f"Local plugin path does not exist: {source_path}")
251+
252+
metadata = load_yaml_metadata(source_path)
253+
plugin_name = metadata.get("name")
254+
if not isinstance(plugin_name, str) or not plugin_name.strip():
255+
raise click.ClickException(
256+
f"Local plugin {source_path} must contain metadata.yaml with a valid name"
257+
)
258+
plugin_name = _validate_plugin_dir_name(plugin_name, source_path)
259+
260+
target_path = plugins_dir / plugin_name
261+
if target_path.exists():
262+
raise click.ClickException(f"Plugin {plugin_name} already exists")
263+
264+
try:
265+
plugins_dir.mkdir(parents=True, exist_ok=True)
266+
if editable:
267+
try:
268+
target_path.symlink_to(source_path, target_is_directory=True)
269+
except OSError as e:
270+
raise click.ClickException(
271+
f"Failed to create symlink for editable install: {e}. "
272+
"On Windows, you may need to run as Administrator or enable Developer Mode."
273+
) from e
274+
else:
275+
_copy_local_plugin(source_path, plugins_dir, target_path)
276+
click.echo(f"Plugin {plugin_name} installed successfully from {source_path}")
277+
except FileExistsError:
278+
raise click.ClickException(f"Plugin {plugin_name} already exists") from None
279+
except click.ClickException:
280+
raise
281+
except Exception as e:
282+
if editable and target_path.is_symlink():
283+
_cleanup_local_plugin_target(target_path)
284+
raise click.ClickException(
285+
f"Error installing local plugin {plugin_name}: {e}"
286+
) from e
287+
288+
187289
def manage_plugin(
188290
plugin: dict,
189291
plugins_dir: Path,

tests/test_cli_plugin.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from click import ClickException
5+
from click.testing import CliRunner
6+
7+
import astrbot.cli.utils.plugin as plugin_utils
8+
from astrbot.cli.commands.cmd_plug import plug
9+
10+
11+
def _write_plugin(path: Path, name: str = "astrbot_plugin_local_demo") -> None:
12+
path.mkdir(parents=True)
13+
(path / "metadata.yaml").write_text(
14+
"\n".join(
15+
[
16+
f"name: {name}",
17+
"desc: Local plugin",
18+
"version: 1.0.0",
19+
"author: AstrBot",
20+
"repo: https://example.com/local-plugin",
21+
],
22+
),
23+
encoding="utf-8",
24+
)
25+
(path / "main.py").write_text("PLUGIN_LOADED = True\n", encoding="utf-8")
26+
27+
28+
def _write_ignored_plugin_files(path: Path) -> None:
29+
for ignored_dir in [".git", ".venv", "__pycache__", ".idea", ".vscode", ".zed"]:
30+
ignored_path = path / ignored_dir
31+
ignored_path.mkdir()
32+
(ignored_path / "ignored.txt").write_text("ignored\n", encoding="utf-8")
33+
(path / "__pycache__" / "main.pyc").write_bytes(b"ignored")
34+
35+
36+
def _write_astrbot_root(path: Path) -> None:
37+
(path / ".astrbot").touch()
38+
(path / "data" / "plugins").mkdir(parents=True)
39+
40+
41+
def test_plugin_install_editable_symlinks_local_plugin(
42+
monkeypatch: pytest.MonkeyPatch,
43+
tmp_path: Path,
44+
) -> None:
45+
root = tmp_path / "root"
46+
source = tmp_path / "source-plugin"
47+
root.mkdir()
48+
_write_astrbot_root(root)
49+
_write_plugin(source)
50+
monkeypatch.chdir(root)
51+
52+
result = CliRunner().invoke(
53+
plug,
54+
["install", "-e", str(source)],
55+
catch_exceptions=False,
56+
)
57+
58+
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
59+
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
60+
assert result.exit_code == 0
61+
assert target.is_symlink()
62+
assert (target / "metadata.yaml").exists()
63+
assert (target / "main.py").read_text(encoding="utf-8") == "PLUGIN_LOADED = True\n"
64+
65+
66+
def test_plugin_install_accepts_local_path_without_editable_flag(
67+
monkeypatch: pytest.MonkeyPatch,
68+
tmp_path: Path,
69+
) -> None:
70+
root = tmp_path / "root"
71+
source = tmp_path / "source-plugin"
72+
root.mkdir()
73+
_write_astrbot_root(root)
74+
_write_plugin(source)
75+
_write_ignored_plugin_files(source)
76+
monkeypatch.chdir(root)
77+
78+
result = CliRunner().invoke(plug, ["install", str(source)])
79+
80+
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
81+
assert result.exit_code == 0
82+
assert not target.is_symlink()
83+
assert (target / "metadata.yaml").exists()
84+
assert not (target / ".git").exists()
85+
assert not (target / ".venv").exists()
86+
assert not (target / "__pycache__").exists()
87+
assert not (target / ".idea").exists()
88+
assert not (target / ".vscode").exists()
89+
assert not (target / ".zed").exists()
90+
91+
92+
def test_plugin_install_editable_rejects_existing_plugin(
93+
monkeypatch: pytest.MonkeyPatch,
94+
tmp_path: Path,
95+
) -> None:
96+
root = tmp_path / "root"
97+
source = tmp_path / "source-plugin"
98+
root.mkdir()
99+
_write_astrbot_root(root)
100+
_write_plugin(source)
101+
_write_plugin(root / "data" / "plugins" / "astrbot_plugin_local_demo")
102+
monkeypatch.chdir(root)
103+
104+
result = CliRunner().invoke(plug, ["install", "-e", str(source)])
105+
106+
assert result.exit_code != 0
107+
assert "already exists" in result.output
108+
109+
110+
def test_plugin_install_rejects_plugin_name_with_path_separator(
111+
monkeypatch: pytest.MonkeyPatch,
112+
tmp_path: Path,
113+
) -> None:
114+
root = tmp_path / "root"
115+
source = tmp_path / "source-plugin"
116+
root.mkdir()
117+
_write_astrbot_root(root)
118+
_write_plugin(source, name="../bad_plugin")
119+
monkeypatch.chdir(root)
120+
121+
result = CliRunner().invoke(plug, ["install", str(source)])
122+
123+
assert result.exit_code != 0
124+
assert "invalid name" in result.output
125+
assert not (root / "data" / "bad_plugin").exists()
126+
127+
128+
def test_plugin_install_copy_does_not_delete_existing_target_on_race(
129+
monkeypatch: pytest.MonkeyPatch,
130+
tmp_path: Path,
131+
) -> None:
132+
root = tmp_path / "root"
133+
source = tmp_path / "source-plugin"
134+
root.mkdir()
135+
_write_astrbot_root(root)
136+
_write_plugin(source)
137+
monkeypatch.chdir(root)
138+
139+
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
140+
target.mkdir()
141+
marker = target / "keep.txt"
142+
marker.write_text("keep\n", encoding="utf-8")
143+
144+
result = CliRunner().invoke(plug, ["install", str(source)])
145+
146+
assert result.exit_code != 0
147+
assert "already exists" in result.output
148+
assert marker.read_text(encoding="utf-8") == "keep\n"
149+
150+
151+
def test_plugin_install_copy_does_not_delete_concurrently_created_target(
152+
monkeypatch: pytest.MonkeyPatch,
153+
tmp_path: Path,
154+
) -> None:
155+
source = tmp_path / "source-plugin"
156+
plugins_dir = tmp_path / "plugins"
157+
_write_plugin(source)
158+
159+
target = plugins_dir / "astrbot_plugin_local_demo"
160+
161+
def create_target_then_fail(
162+
_source_path: Path,
163+
_plugins_dir: Path,
164+
_target_path: Path,
165+
) -> None:
166+
target.mkdir(parents=True)
167+
(target / "keep.txt").write_text("keep\n", encoding="utf-8")
168+
raise FileExistsError
169+
170+
monkeypatch.setattr(plugin_utils, "_copy_local_plugin", create_target_then_fail)
171+
172+
with pytest.raises(ClickException, match="already exists"):
173+
plugin_utils.install_local_plugin(source, plugins_dir)
174+
175+
assert (target / "keep.txt").read_text(encoding="utf-8") == "keep\n"
176+
177+
178+
def test_plugin_install_requires_name_or_editable_path(
179+
monkeypatch: pytest.MonkeyPatch,
180+
tmp_path: Path,
181+
) -> None:
182+
root = tmp_path / "root"
183+
root.mkdir()
184+
_write_astrbot_root(root)
185+
monkeypatch.chdir(root)
186+
187+
result = CliRunner().invoke(plug, ["install"])
188+
189+
assert result.exit_code != 0
190+
assert "Missing plugin name or local plugin path" in result.output

0 commit comments

Comments
 (0)