diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py index bb9b83d40..17bb864af 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 b42d41f32..929a26336 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 11190e369..19f16fd0a 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(