feat(cli): normalize plugin init skeletons

Add interactive plugin init prompts and normalize generated plugin names to the astrbot_plugin_ convention.
Update CLI tests for the new skeleton layout and ignore generated plugin directories in git and coverage tooling.
Also include related runtime logging adjustments from the current worktree.
This commit is contained in:
united_pooh
2026-03-14 21:07:07 +08:00
parent d5430f7a94
commit eb94f21828
6 changed files with 161 additions and 32 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ plugins/.venv/
*.iml
uv.lock
/astrBot/
plugins/

View File

@@ -73,6 +73,7 @@ omit = [
"*/tests/*",
"*/__pycache__/*",
"*/_legacy_api.py",
"*/plugins/*"
]
[tool.coverage.report]

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import json
import re
import sys
import typing
@@ -45,6 +46,9 @@ BUILD_EXCLUDED_DIRS = {
BUILD_EXCLUDED_FILES = {
".astrbot-worker-state.json",
}
INIT_DEFAULT_AUTHOR = ""
INIT_DEFAULT_PYTHON_VERSION = "3.12"
INIT_DEFAULT_VERSION = "1.0.0"
class _CliPluginValidationError(RuntimeError):
@@ -262,12 +266,6 @@ def _handle_dev_meta_command(command: str, state: dict[str, Any]) -> bool:
return True
return False
def _slugify_plugin_name(value: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_").lower()
return slug or "my_plugin"
def _class_name_for_plugin(value: str) -> str:
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", value) if part]
if not parts:
@@ -280,18 +278,62 @@ def _sanitize_build_part(value: str) -> str:
return sanitized or "artifact"
def _render_init_plugin_yaml(*, plugin_name: str, display_name: str) -> str:
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
def _yaml_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def _normalize_init_plugin_name(value: str) -> str:
normalized = re.sub(r"[\s-]+", "_", value.strip())
normalized = re.sub(r"[^a-zA-Z0-9_]+", "_", normalized)
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
if not normalized:
normalized = "my_plugin"
prefix = "astrbot_plugin_"
if normalized == "astrbot_plugin":
return f"{prefix}my_plugin"
if normalized.startswith(prefix):
suffix = normalized.removeprefix(prefix).strip("_") or "my_plugin"
return f"{prefix}{suffix}"
return f"{prefix}{normalized}"
def _prompt_required_init_name() -> str:
while True:
value = click.prompt("插件名字", default="", show_default=False).strip()
if value:
return value
click.echo("插件名字不能为空")
def _collect_init_inputs(name: str | None) -> tuple[str, str, str]:
if name is not None:
return name, INIT_DEFAULT_AUTHOR, INIT_DEFAULT_VERSION
plugin_name = _prompt_required_init_name()
author = click.prompt("作者名字", default="", show_default=False).strip()
version = click.prompt("版本", default=INIT_DEFAULT_VERSION).strip()
return plugin_name, author, version or INIT_DEFAULT_VERSION
def _render_init_plugin_yaml(
*,
plugin_name: str,
display_name: str,
author: str,
version: str,
python_version: str,
) -> str:
class_name = _class_name_for_plugin(plugin_name)
return dedent(
f"""\
name: {plugin_name}
display_name: {display_name}
desc: 使用 AstrBot SDK 创建的插件
author: your-name
version: 0.1.0
display_name: {_yaml_string(display_name)}
desc: {_yaml_string("使用 AstrBot SDK 创建的插件")}
author: {_yaml_string(author)}
version: {_yaml_string(version)}
runtime:
python: "{python_version}"
python: {_yaml_string(python_version)}
components:
- class: main:{class_name}
"""
@@ -394,19 +436,24 @@ def _iter_build_files(plugin_dir: Path, output_dir: Path) -> list[Path]:
return files
def _init_plugin(name: str) -> None:
target_dir = Path(name)
def _init_plugin(name: str | None) -> None:
raw_name, author, version = _collect_init_inputs(name)
normalized_name = _normalize_init_plugin_name(raw_name)
target_dir = Path(normalized_name)
if target_dir.exists():
raise _CliPluginValidationError(f"目标目录已存在:{target_dir}")
plugin_name = _slugify_plugin_name(target_dir.name)
display_name = target_dir.name
plugin_name = normalized_name
display_name = raw_name
target_dir.mkdir(parents=True, exist_ok=False)
(target_dir / "tests").mkdir()
(target_dir / "plugin.yaml").write_text(
_render_init_plugin_yaml(
plugin_name=plugin_name,
display_name=display_name,
author=author,
version=version,
python_version=INIT_DEFAULT_PYTHON_VERSION,
),
encoding="utf-8",
)
@@ -419,7 +466,7 @@ def _init_plugin(name: str) -> None:
_render_init_test_py(plugin_name=plugin_name),
encoding="utf-8",
)
click.echo(f"已创建插件骨架:{target_dir}")
click.echo(f"已创建插件骨架:{target_dir.resolve()}")
click.echo("后续命令:")
click.echo(f" astrbot-sdk validate --plugin-dir {target_dir}")
click.echo(
@@ -485,13 +532,15 @@ def run(plugins_dir: Path) -> None:
@cli.command()
@click.argument("name", type=str)
def init(name: str) -> None:
"""Create a new plugin skeleton in the target directory."""
@click.argument("name", required=False, type=str)
def init(name: str | None) -> None:
"""Create a new plugin skeleton; omit name to enter interactive mode."""
_run_sync_entrypoint(
lambda: _init_plugin(name),
log_message=f"创建插件骨架:{name}",
context={"target": Path(name)},
log_message=(
f"创建插件骨架:{name}" if name is not None else "创建插件骨架:交互模式"
),
context={"target": name or "<interactive>"},
)

View File

@@ -344,6 +344,7 @@ class Peer:
asyncio.get_running_loop().create_future()
)
self._pending_results[request_id] = future
# FIXME: 这里会输出乱七八糟的各种东西
await self._send(
InitializeMessage(
id=request_id,
@@ -354,6 +355,7 @@ class Peer:
metadata=handshake_metadata,
)
)
# FIXME: 👆会输出各种乱七八糟的东西
result = await future
if result.kind != "initialize_result":
raise AstrBotError.protocol_error("initialize 必须收到 initialize_result")

View File

@@ -537,6 +537,7 @@ class SupervisorRuntime:
discovery = discover_plugins(self.plugins_dir)
self.skipped_plugins = dict(discovery.skipped_plugins)
plan_result = self.env_manager.plan(discovery.plugins)
logger.info(f"发现 {len(discovery.plugins)} 个插件,{len(plan_result.groups)} 个环境组")
self.skipped_plugins.update(plan_result.skipped_plugins)
try:
planned_sessions: list[WorkerSession] = []
@@ -598,7 +599,7 @@ class SupervisorRuntime:
aggregated_handlers = list(self.handler_to_worker.keys())
logger.info(
"Loaded plugins: {}", ", ".join(sorted(self.loaded_plugins)) or "none"
"Loaded plugins: \n{}", "\n ".join(sorted(self.loaded_plugins)) or "none"
)
await self.peer.start()

View File

@@ -304,7 +304,7 @@ class TestCliModule:
with runner.isolated_filesystem():
result = runner.invoke(cli, ["init", "demo-plugin"])
plugin_dir = Path("demo-plugin")
plugin_dir = Path("astrbot_plugin_demo_plugin")
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
main_file = (plugin_dir / "main.py").read_text(encoding="utf-8")
test_file = (plugin_dir / "tests" / "test_plugin.py").read_text(
@@ -313,14 +313,89 @@ class TestCliModule:
assert result.exit_code == 0
assert "已创建插件骨架" in result.output
assert "name: demo_plugin" in manifest
assert (
f'python: "{sys.version_info.major}.{sys.version_info.minor}"' in manifest
)
assert "class DemoPlugin(Star):" in main_file
assert "name: astrbot_plugin_demo_plugin" in manifest
assert 'display_name: "demo-plugin"' in manifest
assert 'author: ""' in manifest
assert 'version: "1.0.0"' in manifest
assert 'python: "3.12"' in manifest
assert "class AstrbotPluginDemoPlugin(Star):" in main_file
assert "MockContext" in test_file
assert "MockMessageEvent" in test_file
def test_init_command_normalizes_spaces_to_underscores(self):
"""init should normalize spaces in the generated directory and manifest name."""
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ["init", "demo plugin"])
plugin_dir = Path("astrbot_plugin_demo_plugin")
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
assert plugin_dir.is_dir()
assert result.exit_code == 0
assert "name: astrbot_plugin_demo_plugin" in manifest
assert 'display_name: "demo plugin"' in manifest
def test_init_command_converts_legacy_prefix_to_underscore_prefix(self):
"""init should translate the legacy astrbot-plugin prefix to astrbot_plugin."""
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ["init", "astrbot-plugin-demo"])
plugin_dir = Path("astrbot_plugin_demo")
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
assert plugin_dir.is_dir()
assert result.exit_code == 0
assert "name: astrbot_plugin_demo" in manifest
def test_init_command_enters_interactive_mode_without_name(self):
"""init without a name should prompt for plugin metadata interactively."""
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(
cli,
["init"],
input="hello world\nalice\n2.3.4\n",
)
plugin_dir = Path("astrbot_plugin_hello_world")
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
assert plugin_dir.is_dir()
assert result.exit_code == 0
assert "插件名字" in result.output
assert "作者名字" in result.output
assert "版本" in result.output
assert "name: astrbot_plugin_hello_world" in manifest
assert 'display_name: "hello world"' in manifest
assert 'author: "alice"' in manifest
assert 'version: "2.3.4"' in manifest
assert 'python: "3.12"' in manifest
def test_init_command_reprompts_for_empty_interactive_name(self):
"""init interactive mode should reject an empty plugin name and keep prompting."""
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(
cli,
["init"],
input="\nhello world\n\n\n",
)
plugin_dir = Path("astrbot_plugin_hello_world")
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
assert plugin_dir.is_dir()
assert result.exit_code == 0
assert "插件名字不能为空" in result.output
assert 'author: ""' in manifest
assert 'version: "1.0.0"' in manifest
def test_validate_command_checks_real_plugin_fixture(self):
"""validate should reuse loader-based discovery against a real v4 fixture."""
runner = CliRunner()
@@ -379,11 +454,11 @@ class TestCliModule:
[
"build",
"--plugin-dir",
"buildable-plugin",
"astrbot_plugin_buildable_plugin",
],
)
artifact_dir = Path("buildable-plugin") / "dist"
artifact_dir = Path("astrbot_plugin_buildable_plugin") / "dist"
artifacts = sorted(artifact_dir.glob("*.zip"))
assert len(artifacts) == 1
with zipfile.ZipFile(artifacts[0]) as archive: