Merge pull request #12 from united-pooh/feat/cli-create

feat(cli): normalize plugin init skeletons
This commit is contained in:
united_pooh
2026-03-14 21:11:10 +08:00
committed by GitHub
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: