Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ uv.lock
# IDE and editors
.vscode
.idea
.zed/

# Logs and temporary files
botpy.log
Expand Down
25 changes: 23 additions & 2 deletions astrbot/cli/commands/cmd_plug.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
check_astrbot_root,
get_astrbot_root,
get_git_repo,
install_local_plugin,
manage_plugin,
)

Expand Down Expand Up @@ -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
Comment thread
LIghtJUNction marked this conversation as resolved.

plugins = build_plug_list(base_path / "plugins")

plugin = next(
Expand Down
9 changes: 8 additions & 1 deletion astrbot/cli/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -14,5 +20,6 @@
"check_dashboard",
"get_astrbot_root",
"get_git_repo",
"install_local_plugin",
"manage_plugin",
]
31 changes: 31 additions & 0 deletions astrbot/cli/utils/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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
Comment thread
LIghtJUNction marked this conversation as resolved.
Outdated


def manage_plugin(
plugin: dict,
plugins_dir: Path,
Expand Down
103 changes: 103 additions & 0 deletions tests/test_cli_plugin.py
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
LIghtJUNction marked this conversation as resolved.


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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add an assertion to verify that the non-editable local installation copies the directory instead of creating a symlink.

Suggested change
assert result.exit_code == 0
assert (
root / "data" / "plugins" / "astrbot_plugin_local_demo" / "metadata.yaml"
).exists()
target = root / "data" / "plugins" / "astrbot_plugin_local_demo"
assert result.exit_code == 0
assert not target.is_symlink()
assert (target / "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