From a0644d1588f573ce9ae14b832a005e0f61a6d737 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sat, 30 May 2026 23:06:29 +0800 Subject: [PATCH 1/5] feat: support local plugin install --- .gitignore | 1 + astrbot/cli/commands/cmd_plug.py | 25 +++++++- astrbot/cli/utils/__init__.py | 9 ++- astrbot/cli/utils/plugin.py | 31 ++++++++++ tests/test_cli_plugin.py | 103 +++++++++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 tests/test_cli_plugin.py diff --git a/.gitignore b/.gitignore index 5eb9616c8c..8304f1e311 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ uv.lock # IDE and editors .vscode .idea +.zed/ # Logs and temporary files botpy.log diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py index 462c8e8b9e..bb9b83d403 100644 --- a/astrbot/cli/commands/cmd_plug.py +++ b/astrbot/cli/commands/cmd_plug.py @@ -10,6 +10,7 @@ check_astrbot_root, get_astrbot_root, get_git_repo, + install_local_plugin, manage_plugin, ) @@ -143,12 +144,32 @@ def list(all: bool) -> None: @plug.command() -@click.argument("name") +@click.argument("name", required=False) +@click.option( + "--editable", + "-e", + "local_path", + type=click.Path(exists=True, file_okay=False, path_type=Path), + help="Install a plugin from a local directory", +) @click.option("--proxy", help="Proxy server address") -def install(name: str, proxy: str | None) -> None: +def install(name: str | None, local_path: Path | None, proxy: str | None) -> None: """Install a plugin""" base_path = _get_data_path() plug_path = base_path / "plugins" + + if local_path is not None: + install_local_plugin(local_path, plug_path) + return + + if name is None: + raise click.ClickException("Missing plugin name or local plugin path") + + local_name_path = Path(name).expanduser() + if local_name_path.exists() and local_name_path.is_dir(): + install_local_plugin(local_name_path, plug_path) + return + plugins = build_plug_list(base_path / "plugins") plugin = next( diff --git a/astrbot/cli/utils/__init__.py b/astrbot/cli/utils/__init__.py index 3830682f0d..f46a9b940e 100644 --- a/astrbot/cli/utils/__init__.py +++ b/astrbot/cli/utils/__init__.py @@ -3,7 +3,13 @@ check_dashboard, get_astrbot_root, ) -from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin +from .plugin import ( + PluginStatus, + build_plug_list, + get_git_repo, + install_local_plugin, + manage_plugin, +) from .version_comparator import VersionComparator __all__ = [ @@ -14,5 +20,6 @@ "check_dashboard", "get_astrbot_root", "get_git_repo", + "install_local_plugin", "manage_plugin", ] diff --git a/astrbot/cli/utils/plugin.py b/astrbot/cli/utils/plugin.py index c06dda3500..b42d41f329 100644 --- a/astrbot/cli/utils/plugin.py +++ b/astrbot/cli/utils/plugin.py @@ -190,6 +190,37 @@ def build_plug_list(plugins_dir: Path) -> list: return result +def install_local_plugin(source_path: Path, plugins_dir: Path) -> None: + """Install a plugin from a local directory.""" + source_path = source_path.expanduser().resolve() + plugins_dir = plugins_dir.resolve() + + if not source_path.exists() or not source_path.is_dir(): + raise click.ClickException(f"Local plugin path does not exist: {source_path}") + + metadata = load_yaml_metadata(source_path) + plugin_name = metadata.get("name") + if not isinstance(plugin_name, str) or not plugin_name.strip(): + raise click.ClickException( + f"Local plugin {source_path} must contain metadata.yaml with a valid name" + ) + + target_path = plugins_dir / plugin_name + if target_path.exists(): + raise click.ClickException(f"Plugin {plugin_name} already exists") + + try: + plugins_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_path, target_path) + click.echo(f"Plugin {plugin_name} installed successfully from {source_path}") + except Exception as e: + if target_path.exists(): + shutil.rmtree(target_path, ignore_errors=True) + raise click.ClickException( + f"Error installing local plugin {plugin_name}: {e}" + ) from e + + def manage_plugin( plugin: dict, plugins_dir: Path, diff --git a/tests/test_cli_plugin.py b/tests/test_cli_plugin.py new file mode 100644 index 0000000000..11190e369d --- /dev/null +++ b/tests/test_cli_plugin.py @@ -0,0 +1,103 @@ +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from astrbot.cli.commands.cmd_plug import plug + + +def _write_plugin(path: Path, name: str = "astrbot_plugin_local_demo") -> None: + path.mkdir(parents=True) + (path / "metadata.yaml").write_text( + "\n".join( + [ + f"name: {name}", + "desc: Local plugin", + "version: 1.0.0", + "author: AstrBot", + "repo: https://example.com/local-plugin", + ], + ), + encoding="utf-8", + ) + (path / "main.py").write_text("PLUGIN_LOADED = True\n", encoding="utf-8") + + +def _write_astrbot_root(path: Path) -> None: + (path / ".astrbot").touch() + (path / "data" / "plugins").mkdir(parents=True) + + +def test_plugin_install_editable_copies_local_plugin( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "root" + source = tmp_path / "source-plugin" + root.mkdir() + _write_astrbot_root(root) + _write_plugin(source) + monkeypatch.chdir(root) + + result = CliRunner().invoke( + plug, + ["install", "-e", str(source)], + catch_exceptions=False, + ) + + target = root / "data" / "plugins" / "astrbot_plugin_local_demo" + assert result.exit_code == 0 + assert (target / "metadata.yaml").exists() + assert (target / "main.py").read_text(encoding="utf-8") == "PLUGIN_LOADED = True\n" + + +def test_plugin_install_accepts_local_path_without_editable_flag( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "root" + source = tmp_path / "source-plugin" + root.mkdir() + _write_astrbot_root(root) + _write_plugin(source) + monkeypatch.chdir(root) + + result = CliRunner().invoke(plug, ["install", str(source)]) + + assert result.exit_code == 0 + assert ( + root / "data" / "plugins" / "astrbot_plugin_local_demo" / "metadata.yaml" + ).exists() + + +def test_plugin_install_editable_rejects_existing_plugin( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "root" + source = tmp_path / "source-plugin" + root.mkdir() + _write_astrbot_root(root) + _write_plugin(source) + _write_plugin(root / "data" / "plugins" / "astrbot_plugin_local_demo") + monkeypatch.chdir(root) + + result = CliRunner().invoke(plug, ["install", "-e", str(source)]) + + assert result.exit_code != 0 + assert "already exists" in result.output + + +def test_plugin_install_requires_name_or_editable_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "root" + root.mkdir() + _write_astrbot_root(root) + monkeypatch.chdir(root) + + result = CliRunner().invoke(plug, ["install"]) + + assert result.exit_code != 0 + assert "Missing plugin name or local plugin path" in result.output From 748901decdfa864d7ad51485cd17d6c65c68fa69 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sat, 30 May 2026 23:25:59 +0800 Subject: [PATCH 2/5] fix: make editable plugin install symlink --- astrbot/cli/commands/cmd_plug.py | 6 ++--- astrbot/cli/utils/plugin.py | 42 +++++++++++++++++++++++++++++--- tests/test_cli_plugin.py | 24 +++++++++++++++--- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py index bb9b83d403..17bb864af0 100644 --- a/astrbot/cli/commands/cmd_plug.py +++ b/astrbot/cli/commands/cmd_plug.py @@ -150,7 +150,7 @@ def list(all: bool) -> None: "-e", "local_path", type=click.Path(exists=True, file_okay=False, path_type=Path), - help="Install a plugin from a local directory", + help="Install a plugin from a local directory as a symlink", ) @click.option("--proxy", help="Proxy server address") def install(name: str | None, local_path: Path | None, proxy: str | None) -> None: @@ -159,7 +159,7 @@ def install(name: str | None, local_path: Path | None, proxy: str | None) -> Non plug_path = base_path / "plugins" if local_path is not None: - install_local_plugin(local_path, plug_path) + install_local_plugin(local_path, plug_path, editable=True) return if name is None: @@ -167,7 +167,7 @@ def install(name: str | None, local_path: Path | None, proxy: str | None) -> Non local_name_path = Path(name).expanduser() if local_name_path.exists() and local_name_path.is_dir(): - install_local_plugin(local_name_path, plug_path) + install_local_plugin(local_name_path, plug_path, editable=False) return plugins = build_plug_list(base_path / "plugins") diff --git a/astrbot/cli/utils/plugin.py b/astrbot/cli/utils/plugin.py index b42d41f329..929a26336f 100644 --- a/astrbot/cli/utils/plugin.py +++ b/astrbot/cli/utils/plugin.py @@ -19,6 +19,18 @@ class PluginStatus(str, Enum): NOT_PUBLISHED = "unpublished" +LOCAL_PLUGIN_COPY_IGNORE = shutil.ignore_patterns( + ".git", + "__pycache__", + "*.pyc", + ".venv", + "venv", + ".idea", + ".vscode", + ".zed", +) + + def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None: """Download code from a Git repository and extract to the specified path""" temp_dir = Path(tempfile.mkdtemp()) @@ -190,7 +202,18 @@ def build_plug_list(plugins_dir: Path) -> list: return result -def install_local_plugin(source_path: Path, plugins_dir: Path) -> None: +def _cleanup_local_plugin_target(target_path: Path) -> None: + if target_path.is_symlink() or target_path.is_file(): + target_path.unlink(missing_ok=True) + elif target_path.exists(): + shutil.rmtree(target_path, ignore_errors=True) + + +def install_local_plugin( + source_path: Path, + plugins_dir: Path, + editable: bool = False, +) -> None: """Install a plugin from a local directory.""" source_path = source_path.expanduser().resolve() plugins_dir = plugins_dir.resolve() @@ -211,11 +234,22 @@ def install_local_plugin(source_path: Path, plugins_dir: Path) -> None: try: plugins_dir.mkdir(parents=True, exist_ok=True) - shutil.copytree(source_path, target_path) + if editable: + try: + target_path.symlink_to(source_path, target_is_directory=True) + except OSError as e: + raise click.ClickException( + f"Failed to create symlink for editable install: {e}. " + "On Windows, you may need to run as Administrator or enable Developer Mode." + ) from e + else: + shutil.copytree(source_path, target_path, ignore=LOCAL_PLUGIN_COPY_IGNORE) click.echo(f"Plugin {plugin_name} installed successfully from {source_path}") + except click.ClickException: + _cleanup_local_plugin_target(target_path) + raise except Exception as e: - if target_path.exists(): - shutil.rmtree(target_path, ignore_errors=True) + _cleanup_local_plugin_target(target_path) raise click.ClickException( f"Error installing local plugin {plugin_name}: {e}" ) from e diff --git a/tests/test_cli_plugin.py b/tests/test_cli_plugin.py index 11190e369d..19f16fd0a8 100644 --- a/tests/test_cli_plugin.py +++ b/tests/test_cli_plugin.py @@ -23,12 +23,20 @@ def _write_plugin(path: Path, name: str = "astrbot_plugin_local_demo") -> None: (path / "main.py").write_text("PLUGIN_LOADED = True\n", encoding="utf-8") +def _write_ignored_plugin_files(path: Path) -> None: + for ignored_dir in [".git", ".venv", "__pycache__", ".idea", ".vscode", ".zed"]: + ignored_path = path / ignored_dir + ignored_path.mkdir() + (ignored_path / "ignored.txt").write_text("ignored\n", encoding="utf-8") + (path / "__pycache__" / "main.pyc").write_bytes(b"ignored") + + def _write_astrbot_root(path: Path) -> None: (path / ".astrbot").touch() (path / "data" / "plugins").mkdir(parents=True) -def test_plugin_install_editable_copies_local_plugin( +def test_plugin_install_editable_symlinks_local_plugin( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -47,6 +55,7 @@ def test_plugin_install_editable_copies_local_plugin( target = root / "data" / "plugins" / "astrbot_plugin_local_demo" assert result.exit_code == 0 + assert target.is_symlink() assert (target / "metadata.yaml").exists() assert (target / "main.py").read_text(encoding="utf-8") == "PLUGIN_LOADED = True\n" @@ -60,14 +69,21 @@ def test_plugin_install_accepts_local_path_without_editable_flag( root.mkdir() _write_astrbot_root(root) _write_plugin(source) + _write_ignored_plugin_files(source) monkeypatch.chdir(root) result = CliRunner().invoke(plug, ["install", str(source)]) + target = root / "data" / "plugins" / "astrbot_plugin_local_demo" assert result.exit_code == 0 - assert ( - root / "data" / "plugins" / "astrbot_plugin_local_demo" / "metadata.yaml" - ).exists() + assert not target.is_symlink() + assert (target / "metadata.yaml").exists() + assert not (target / ".git").exists() + assert not (target / ".venv").exists() + assert not (target / "__pycache__").exists() + assert not (target / ".idea").exists() + assert not (target / ".vscode").exists() + assert not (target / ".zed").exists() def test_plugin_install_editable_rejects_existing_plugin( From 34a5498af33cee0c62c537f800afa0c94c5b9c21 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sat, 30 May 2026 23:50:56 +0800 Subject: [PATCH 3/5] fix: harden local plugin install --- astrbot/cli/utils/plugin.py | 43 +++++++++++++++++++++-- tests/test_cli_plugin.py | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/astrbot/cli/utils/plugin.py b/astrbot/cli/utils/plugin.py index 929a26336f..fb89c5723f 100644 --- a/astrbot/cli/utils/plugin.py +++ b/astrbot/cli/utils/plugin.py @@ -1,5 +1,6 @@ import shutil import tempfile +import uuid from enum import Enum from io import BytesIO from pathlib import Path @@ -31,6 +32,23 @@ class PluginStatus(str, Enum): ) +def _validate_plugin_dir_name(plugin_name: str, source_path: Path) -> str: + plugin_name = plugin_name.strip() + plugin_path = Path(plugin_name) + has_separator = "/" in plugin_name or "\\" in plugin_name + if ( + not plugin_name + or plugin_name in {".", ".."} + or plugin_path.is_absolute() + or has_separator + or plugin_path.name != plugin_name + ): + raise click.ClickException( + f"Local plugin {source_path} metadata.yaml has invalid name: {plugin_name}" + ) + return plugin_name + + def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None: """Download code from a Git repository and extract to the specified path""" temp_dir = Path(tempfile.mkdtemp()) @@ -209,6 +227,22 @@ def _cleanup_local_plugin_target(target_path: Path) -> None: shutil.rmtree(target_path, ignore_errors=True) +def _copy_local_plugin(source_path: Path, plugins_dir: Path, target_path: Path) -> None: + temp_target = plugins_dir / f".{target_path.name}.tmp-{uuid.uuid4().hex}" + try: + shutil.copytree(source_path, temp_target, ignore=LOCAL_PLUGIN_COPY_IGNORE) + temp_target.rename(target_path) + except FileExistsError: + raise click.ClickException( + f"Plugin {target_path.name} already exists" + ) from None + except Exception: + raise + finally: + if temp_target.exists() or temp_target.is_symlink(): + _cleanup_local_plugin_target(temp_target) + + def install_local_plugin( source_path: Path, plugins_dir: Path, @@ -227,6 +261,7 @@ def install_local_plugin( raise click.ClickException( f"Local plugin {source_path} must contain metadata.yaml with a valid name" ) + plugin_name = _validate_plugin_dir_name(plugin_name, source_path) target_path = plugins_dir / plugin_name if target_path.exists(): @@ -243,13 +278,15 @@ def install_local_plugin( "On Windows, you may need to run as Administrator or enable Developer Mode." ) from e else: - shutil.copytree(source_path, target_path, ignore=LOCAL_PLUGIN_COPY_IGNORE) + _copy_local_plugin(source_path, plugins_dir, target_path) click.echo(f"Plugin {plugin_name} installed successfully from {source_path}") + except FileExistsError: + raise click.ClickException(f"Plugin {plugin_name} already exists") from None except click.ClickException: - _cleanup_local_plugin_target(target_path) raise except Exception as e: - _cleanup_local_plugin_target(target_path) + if editable and target_path.is_symlink(): + _cleanup_local_plugin_target(target_path) raise click.ClickException( f"Error installing local plugin {plugin_name}: {e}" ) from e diff --git a/tests/test_cli_plugin.py b/tests/test_cli_plugin.py index 19f16fd0a8..872c684425 100644 --- a/tests/test_cli_plugin.py +++ b/tests/test_cli_plugin.py @@ -1,8 +1,10 @@ from pathlib import Path import pytest +from click import ClickException from click.testing import CliRunner +import astrbot.cli.utils.plugin as plugin_utils from astrbot.cli.commands.cmd_plug import plug @@ -104,6 +106,74 @@ def test_plugin_install_editable_rejects_existing_plugin( assert "already exists" in result.output +def test_plugin_install_rejects_plugin_name_with_path_separator( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "root" + source = tmp_path / "source-plugin" + root.mkdir() + _write_astrbot_root(root) + _write_plugin(source, name="../bad_plugin") + monkeypatch.chdir(root) + + result = CliRunner().invoke(plug, ["install", str(source)]) + + assert result.exit_code != 0 + assert "invalid name" in result.output + assert not (root / "data" / "bad_plugin").exists() + + +def test_plugin_install_copy_does_not_delete_existing_target_on_race( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "root" + source = tmp_path / "source-plugin" + root.mkdir() + _write_astrbot_root(root) + _write_plugin(source) + monkeypatch.chdir(root) + + target = root / "data" / "plugins" / "astrbot_plugin_local_demo" + target.mkdir() + marker = target / "keep.txt" + marker.write_text("keep\n", encoding="utf-8") + + result = CliRunner().invoke(plug, ["install", str(source)]) + + assert result.exit_code != 0 + assert "already exists" in result.output + assert marker.read_text(encoding="utf-8") == "keep\n" + + +def test_plugin_install_copy_does_not_delete_concurrently_created_target( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "source-plugin" + plugins_dir = tmp_path / "plugins" + _write_plugin(source) + + target = plugins_dir / "astrbot_plugin_local_demo" + + def create_target_then_fail( + _source_path: Path, + _plugins_dir: Path, + _target_path: Path, + ) -> None: + target.mkdir(parents=True) + (target / "keep.txt").write_text("keep\n", encoding="utf-8") + raise FileExistsError + + monkeypatch.setattr(plugin_utils, "_copy_local_plugin", create_target_then_fail) + + with pytest.raises(ClickException, match="already exists"): + plugin_utils.install_local_plugin(source, plugins_dir) + + assert (target / "keep.txt").read_text(encoding="utf-8") == "keep\n" + + def test_plugin_install_requires_name_or_editable_path( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, From 422ac6be6760b20c2cca40bfde3ea422a76bfd0b Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 31 May 2026 00:44:54 +0800 Subject: [PATCH 4/5] Update tests/test_cli_plugin.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/test_cli_plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cli_plugin.py b/tests/test_cli_plugin.py index 872c684425..d52e4e5b62 100644 --- a/tests/test_cli_plugin.py +++ b/tests/test_cli_plugin.py @@ -55,6 +55,7 @@ def test_plugin_install_editable_symlinks_local_plugin( catch_exceptions=False, ) + target = root / "data" / "plugins" / "astrbot_plugin_local_demo" target = root / "data" / "plugins" / "astrbot_plugin_local_demo" assert result.exit_code == 0 assert target.is_symlink() From f2a685d6e0f04c89f9bd656141c91070a43787ff Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 31 May 2026 00:45:10 +0800 Subject: [PATCH 5/5] Update astrbot/cli/commands/cmd_plug.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>