Files
AstrBot/src-new/astrbot_sdk/cli.py
2026-03-15 00:54:56 +08:00

1089 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AstrBot SDK 的命令行入口。"""
from __future__ import annotations
import asyncio
import json
import re
import sys
import typing
import zipfile
from collections.abc import Coroutine
from dataclasses import dataclass, field
from pathlib import Path
from textwrap import dedent
from typing import Any
import click
from loguru import logger
from .errors import AstrBotError
from .runtime.bootstrap import run_plugin_worker, run_supervisor, run_websocket_server
from .runtime.loader import load_plugin, load_plugin_spec, validate_plugin_spec
EXIT_OK = 0
EXIT_UNEXPECTED = 1
EXIT_USAGE = 2
EXIT_PLUGIN_LOAD = 3
EXIT_RUNTIME = 4
EXIT_PLUGIN_EXECUTION = 5
BUILD_EXCLUDED_DIRS = {
".git",
".idea",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"dist",
}
BUILD_EXCLUDED_FILES = {
".astrbot-worker-state.json",
}
WATCH_POLL_INTERVAL_SECONDS = 0.5
INIT_DEFAULT_AUTHOR = ""
INIT_DEFAULT_PYTHON_VERSION = "3.12"
INIT_DEFAULT_VERSION = "1.0.0"
class _CliPluginValidationError(RuntimeError):
"""CLI 侧的插件结构或打包校验失败。"""
class _CliPluginLoadError(RuntimeError):
"""CLI 侧的本地开发插件加载失败。"""
class _CliPluginExecutionError(RuntimeError):
"""CLI 侧的本地开发插件执行失败。"""
@dataclass(slots=True)
class _PluginTreeWatcher:
plugin_dir: Path
snapshot: dict[str, tuple[int, int]] = field(init=False, default_factory=dict)
def __post_init__(self) -> None:
self.snapshot = _snapshot_watch_files(self.plugin_dir)
def poll_changes(self) -> list[str]:
current = _snapshot_watch_files(self.plugin_dir)
changed = sorted(
path
for path in set(self.snapshot) | set(current)
if self.snapshot.get(path) != current.get(path)
)
self.snapshot = current
return changed
def setup_logger(verbose: bool = False) -> None:
"""初始化 CLI 使用的日志配置。"""
logger.remove()
logger.add(
sys.stderr,
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>",
level="DEBUG" if verbose else "INFO",
colorize=True,
)
def _run_async_entrypoint(
entrypoint: Coroutine[Any, Any, object],
*,
log_message: str,
log_level: str = "info",
context: dict[str, Any] | None = None,
) -> None:
log_method = getattr(logger, log_level)
log_method(log_message)
try:
asyncio.run(entrypoint)
except Exception as exc:
exit_code, error_code, hint = _classify_cli_exception(exc)
_render_cli_error(
error_code=error_code,
message=str(exc),
hint=hint,
context=context,
)
if exit_code == EXIT_UNEXPECTED:
logger.exception("CLI 异常退出")
raise SystemExit(exit_code) from exc
def _run_sync_entrypoint(
entrypoint: typing.Callable[[], object],
*,
log_message: str,
log_level: str = "info",
context: dict[str, Any] | None = None,
) -> None:
log_method = getattr(logger, log_level)
log_method(log_message)
try:
entrypoint()
except Exception as exc:
exit_code, error_code, hint = _classify_cli_exception(exc)
_render_cli_error(
error_code=error_code,
message=str(exc),
hint=hint,
context=context,
)
if exit_code == EXIT_UNEXPECTED:
logger.exception("CLI 异常退出")
raise SystemExit(exit_code) from exc
def _classify_cli_exception(exc: Exception) -> tuple[int, str, str]:
if isinstance(exc, AstrBotError):
return (
EXIT_RUNTIME,
exc.code,
exc.hint or "请检查本地 mock core 与插件调用参数",
)
if isinstance(
exc,
(
_CliPluginValidationError,
_CliPluginLoadError,
FileNotFoundError,
ImportError,
ModuleNotFoundError,
),
):
return (
EXIT_PLUGIN_LOAD,
"plugin_load_error",
"请检查插件目录、plugin.yaml、requirements.txt 和导入路径",
)
if isinstance(exc, LookupError):
return (
EXIT_RUNTIME,
"dispatch_error",
"请检查 handler 或 capability 是否已正确注册",
)
if isinstance(exc, _CliPluginExecutionError):
return (
EXIT_PLUGIN_EXECUTION,
"plugin_execution_error",
"请检查插件生命周期、handler 或 capability 的实现",
)
return (
EXIT_UNEXPECTED,
"unexpected_error",
"请查看详细日志,必要时使用 --verbose 重试",
)
def _render_cli_error(
*,
error_code: str,
message: str,
hint: str = "",
context: dict[str, Any] | None = None,
) -> None:
click.echo(f"Error[{error_code}]: {message}", err=True)
if hint:
click.echo(f"Suggestion: {hint}", err=True)
if not context:
return
for key, value in context.items():
click.echo(f"{key}: {value}", err=True)
def _render_nonfatal_dev_error(
exc: Exception,
*,
context: dict[str, Any] | None = None,
) -> None:
exit_code, error_code, hint = _classify_cli_exception(exc)
_render_cli_error(
error_code=error_code,
message=str(exc),
hint=hint,
context=context,
)
if exit_code == EXIT_UNEXPECTED:
logger.exception("watch 模式收到未分类异常")
def _iter_watch_files(plugin_dir: Path) -> typing.Iterator[Path]:
root = plugin_dir.resolve()
for path in sorted(root.rglob("*")):
if path.is_dir():
continue
relative = path.relative_to(root)
if any(part in BUILD_EXCLUDED_DIRS for part in relative.parts[:-1]):
continue
if relative.name in BUILD_EXCLUDED_FILES:
continue
if path.suffix in {".pyc", ".pyo"}:
continue
yield path
def _snapshot_watch_files(plugin_dir: Path) -> dict[str, tuple[int, int]]:
root = plugin_dir.resolve()
snapshot: dict[str, tuple[int, int]] = {}
for path in _iter_watch_files(root):
try:
stat = path.stat()
except FileNotFoundError:
continue
snapshot[path.relative_to(root).as_posix()] = (
stat.st_mtime_ns,
stat.st_size,
)
return snapshot
def _format_watch_changes(changes: list[str], *, limit: int = 5) -> str:
if not changes:
return "未知文件"
preview = changes[:limit]
text = ", ".join(preview)
if len(changes) > limit:
text += f"{len(changes)} 个文件"
return text
class _ReloadableLocalDevRunner:
def __init__(
self,
*,
plugin_dir: Path,
state: dict[str, Any],
plugin_load_error: type[Exception],
plugin_execution_error: type[Exception],
plugin_harness,
stdout_platform_sink,
) -> None:
self.plugin_dir = plugin_dir
self.state = state
self._plugin_load_error = plugin_load_error
self._plugin_execution_error = plugin_execution_error
self._plugin_harness = plugin_harness
self._stdout_platform_sink = stdout_platform_sink
self._harness = None
self._lock = asyncio.Lock()
async def close(self) -> None:
async with self._lock:
await self._stop_harness()
async def reload(self) -> bool:
async with self._lock:
await self._stop_harness()
harness = self._plugin_harness.from_plugin_dir(
self.plugin_dir,
session_id=str(self.state["session_id"]),
user_id=str(self.state["user_id"]),
platform=str(self.state["platform"]),
group_id=typing.cast(str | None, self.state["group_id"]),
event_type=str(self.state["event_type"]),
platform_sink=self._stdout_platform_sink(stream=sys.stdout),
)
try:
await harness.start()
except self._plugin_load_error as exc:
_render_nonfatal_dev_error(
_CliPluginLoadError(str(exc)),
context={"plugin_dir": self.plugin_dir},
)
return False
except self._plugin_execution_error as exc:
_render_nonfatal_dev_error(
_CliPluginExecutionError(str(exc)),
context={"plugin_dir": self.plugin_dir},
)
return False
self._harness = harness
return True
async def dispatch_text(self, text: str) -> bool:
async with self._lock:
if self._harness is None:
click.echo("当前插件未成功加载,等待下一次文件变更后重试。")
return False
try:
await self._harness.dispatch_text(
text,
session_id=str(self.state["session_id"]),
user_id=str(self.state["user_id"]),
platform=str(self.state["platform"]),
group_id=typing.cast(str | None, self.state["group_id"]),
event_type=str(self.state["event_type"]),
)
except (self._plugin_load_error, self._plugin_execution_error) as exc:
_render_nonfatal_dev_error(
_CliPluginExecutionError(str(exc)),
context={"plugin_dir": self.plugin_dir},
)
return False
except Exception as exc:
_render_nonfatal_dev_error(
exc,
context={"plugin_dir": self.plugin_dir},
)
return False
return True
async def _stop_harness(self) -> None:
if self._harness is None:
return
try:
await self._harness.stop()
finally:
self._harness = None
async def _run_local_dev_watch(
*,
runner: _ReloadableLocalDevRunner,
event_text: str | None,
interactive: bool,
watch_poll_interval: float,
max_watch_reloads: int | None = None,
) -> None:
watcher = _PluginTreeWatcher(runner.plugin_dir)
reload_count = 0
async def reload_and_maybe_rerun(*, announce: str | None) -> None:
if announce:
click.echo(announce)
if not await runner.reload():
return
if event_text is not None:
await runner.dispatch_text(event_text)
async def watch_loop(stop_event: asyncio.Event) -> None:
nonlocal reload_count
while not stop_event.is_set():
await asyncio.sleep(watch_poll_interval)
changes = watcher.poll_changes()
if not changes:
continue
await reload_and_maybe_rerun(
announce=(
f"检测到文件变更,重新加载插件:{_format_watch_changes(changes)}"
)
)
reload_count += 1
if max_watch_reloads is not None and reload_count >= max_watch_reloads:
stop_event.set()
return
stop_event = asyncio.Event()
watch_task: asyncio.Task[None] | None = None
try:
await reload_and_maybe_rerun(
announce=(
"watch 模式已启动,监听插件目录变更。"
if event_text is not None
else "watch 模式已启动,监听插件目录变更并按需热重载。"
)
)
if max_watch_reloads == 0:
return
watch_task = asyncio.create_task(watch_loop(stop_event))
if interactive:
click.echo(
"本地交互模式已启动。可用命令:/session <id> /user <id> /platform <name> /group <id> /private /event <type> /exit"
)
while not stop_event.is_set():
line = await asyncio.to_thread(sys.stdin.readline)
if not line:
break
text = line.strip()
if not text:
continue
if _handle_dev_meta_command(text, runner.state):
if text in {"/exit", "/quit"}:
break
continue
await runner.dispatch_text(text)
stop_event.set()
return
await stop_event.wait()
finally:
stop_event.set()
if watch_task is not None:
watch_task.cancel()
try:
await watch_task
except asyncio.CancelledError:
pass
await runner.close()
async def _run_local_dev(
*,
plugin_dir: Path,
event_text: str | None,
interactive: bool,
watch: bool,
session_id: str,
user_id: str,
platform: str,
group_id: str | None,
event_type: str,
watch_poll_interval: float = WATCH_POLL_INTERVAL_SECONDS,
max_watch_reloads: int | None = None,
) -> None:
from .testing import (
PluginHarness,
StdoutPlatformSink,
_PluginExecutionError,
_PluginLoadError,
)
state = {
"session_id": session_id,
"user_id": user_id,
"platform": platform,
"group_id": group_id,
"event_type": event_type,
}
if watch:
runner = _ReloadableLocalDevRunner(
plugin_dir=plugin_dir,
state=state,
plugin_load_error=_PluginLoadError,
plugin_execution_error=_PluginExecutionError,
plugin_harness=PluginHarness,
stdout_platform_sink=StdoutPlatformSink,
)
await _run_local_dev_watch(
runner=runner,
event_text=event_text,
interactive=interactive,
watch_poll_interval=watch_poll_interval,
max_watch_reloads=max_watch_reloads,
)
return
sink = StdoutPlatformSink(stream=sys.stdout)
harness = PluginHarness.from_plugin_dir(
plugin_dir,
session_id=session_id,
user_id=user_id,
platform=platform,
group_id=group_id,
event_type=event_type,
platform_sink=sink,
)
try:
async with harness:
if interactive:
click.echo(
"本地交互模式已启动。可用命令:/session <id> /user <id> /platform <name> /group <id> /private /event <type> /exit"
)
while True:
line = await asyncio.to_thread(sys.stdin.readline)
if not line:
break
text = line.strip()
if not text:
continue
if _handle_dev_meta_command(text, state):
if text in {"/exit", "/quit"}:
break
continue
await harness.dispatch_text(
text,
session_id=str(state["session_id"]),
user_id=str(state["user_id"]),
platform=str(state["platform"]),
group_id=typing.cast(str | None, state["group_id"]),
event_type=str(state["event_type"]),
)
return
assert event_text is not None
await harness.dispatch_text(
event_text,
session_id=session_id,
user_id=user_id,
platform=platform,
group_id=group_id,
event_type=event_type,
)
except _PluginLoadError as exc:
raise _CliPluginLoadError(str(exc)) from exc
except _PluginExecutionError as exc:
raise _CliPluginExecutionError(str(exc)) from exc
def _handle_dev_meta_command(command: str, state: dict[str, Any]) -> bool:
if command in {"/exit", "/quit"}:
return True
if command.startswith("/session "):
state["session_id"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 session_id -> {state['session_id']}")
return True
if command.startswith("/user "):
state["user_id"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 user_id -> {state['user_id']}")
return True
if command.startswith("/platform "):
state["platform"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 platform -> {state['platform']}")
return True
if command.startswith("/group "):
state["group_id"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 group_id -> {state['group_id']}")
return True
if command == "/private":
state["group_id"] = None
click.echo("已切换为私聊上下文")
return True
if command.startswith("/event "):
state["event_type"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 event_type -> {state['event_type']}")
return True
return False
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:
return "MyPlugin"
return "".join(part[:1].upper() + part[1:] for part in parts)
def _sanitize_build_part(value: str) -> str:
sanitized = re.sub(r"[^a-zA-Z0-9._-]+", "_", value).strip("._-")
return sanitized or "artifact"
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: {_yaml_string(display_name)}
desc: {_yaml_string("使用 AstrBot SDK 创建的插件")}
author: {_yaml_string(author)}
version: {_yaml_string(version)}
runtime:
python: {_yaml_string(python_version)}
components:
- class: main:{class_name}
"""
)
def _render_init_main_py(*, plugin_name: str) -> str:
class_name = _class_name_for_plugin(plugin_name)
return dedent(
f"""\
from astrbot_sdk import Context, MessageEvent, Star, on_command
class {class_name}(Star):
@on_command("hello")
async def hello(self, event: MessageEvent, ctx: Context) -> None:
await event.reply("Hello, World!")
"""
)
def _render_init_readme(*, plugin_name: str) -> str:
return dedent(
f"""\
# {plugin_name}
一个最小可运行的 AstrBot SDK v4 插件。
## 目录结构
```
.
├── plugin.yaml
├── requirements.txt
├── main.py
└── tests
└── test_plugin.py
```
## 本地开发
```bash
astrbot-sdk validate
astrbot-sdk dev --local --event-text hello
astrbot-sdk dev --local --watch --event-text hello
```
## 运行测试
```bash
python -m pytest tests/test_plugin.py -v
```
"""
)
def _render_init_test_py(*, plugin_name: str) -> str:
class_name = _class_name_for_plugin(plugin_name)
return dedent(
f'''\
from pathlib import Path
import pytest
from astrbot_sdk.testing import MockContext, MockMessageEvent, PluginHarness
from main import {class_name}
@pytest.mark.asyncio
async def test_hello_handler():
plugin = {class_name}()
ctx = MockContext(
plugin_id="{plugin_name}",
plugin_metadata={{"display_name": "{class_name}"}},
)
event = MockMessageEvent(text="/hello", context=ctx)
await plugin.hello(event, ctx)
assert event.replies == ["Hello, World!"]
ctx.platform.assert_sent("Hello, World!")
@pytest.mark.asyncio
async def test_hello_dispatch():
plugin_dir = Path(__file__).resolve().parents[1]
async with PluginHarness.from_plugin_dir(plugin_dir) as harness:
records = await harness.dispatch_text("hello")
assert any(record.text == "Hello, World!" for record in records)
'''
)
def _ensure_plugin_dir_exists(plugin_dir: Path) -> Path:
resolved = plugin_dir.resolve()
if not resolved.exists() or not resolved.is_dir():
raise _CliPluginValidationError(f"插件目录不存在:{plugin_dir}")
return resolved
def _resolve_dev_plugin_dir(plugin_dir: Path | None) -> Path:
if plugin_dir is not None:
return plugin_dir
current_dir = Path.cwd()
if (current_dir / "plugin.yaml").exists():
return Path(".")
raise click.BadParameter(
"未提供 --plugin-dir且当前目录未找到 plugin.yaml",
param_hint="--plugin-dir",
)
def _load_validated_plugin(plugin_dir: Path) -> tuple[Any, Any]:
resolved_dir = _ensure_plugin_dir_exists(plugin_dir)
plugin = load_plugin_spec(resolved_dir)
try:
validate_plugin_spec(plugin)
except ValueError as exc:
raise _CliPluginValidationError(str(exc)) from exc
loaded = load_plugin(plugin)
if not loaded.instances:
raise _CliPluginValidationError(
"未找到可加载的组件,请检查 plugin.yaml 中的 components"
)
return plugin, loaded
def _build_kind(plugin: Any) -> str:
return (
"legacy-main"
if bool(plugin.manifest_data.get("__legacy_main__"))
else "plugin-yaml"
)
def _path_is_within(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True
def _iter_build_files(plugin_dir: Path, output_dir: Path) -> list[Path]:
files: list[Path] = []
for path in sorted(plugin_dir.rglob("*")):
if path.is_dir():
continue
if _path_is_within(path, output_dir):
continue
relative = path.relative_to(plugin_dir)
if any(part in BUILD_EXCLUDED_DIRS for part in relative.parts[:-1]):
continue
if relative.name in BUILD_EXCLUDED_FILES:
continue
if path.suffix in {".pyc", ".pyo"}:
continue
files.append(path)
return files
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 = 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",
)
(target_dir / "requirements.txt").write_text("", encoding="utf-8")
(target_dir / "main.py").write_text(
_render_init_main_py(plugin_name=plugin_name),
encoding="utf-8",
)
(target_dir / "README.md").write_text(
_render_init_readme(plugin_name=plugin_name),
encoding="utf-8",
)
(target_dir / "tests" / "test_plugin.py").write_text(
_render_init_test_py(plugin_name=plugin_name),
encoding="utf-8",
)
click.echo(f"已创建插件骨架:{target_dir.resolve()}")
click.echo("后续命令:")
click.echo(f" astrbot-sdk validate --plugin-dir {target_dir}")
click.echo(
f" astrbot-sdk dev --local --plugin-dir {target_dir} --event-text hello"
)
def _validate_plugin(plugin_dir: Path) -> None:
plugin, loaded = _load_validated_plugin(plugin_dir)
click.echo(f"校验通过:{plugin.name}")
click.echo(f"kind: {_build_kind(plugin)}")
click.echo(f"plugin_dir: {plugin.plugin_dir}")
click.echo(f"handlers: {len(loaded.handlers)}")
click.echo(f"capabilities: {len(loaded.capabilities)}")
click.echo(f"instances: {len(loaded.instances)}")
def _build_plugin(plugin_dir: Path, output_dir: Path | None) -> None:
plugin, _ = _load_validated_plugin(plugin_dir)
build_dir = (output_dir or (plugin.plugin_dir / "dist")).resolve()
build_dir.mkdir(parents=True, exist_ok=True)
version = _sanitize_build_part(str(plugin.manifest_data.get("version") or "0.0.0"))
archive_name = f"{_sanitize_build_part(plugin.name)}-{version}.zip"
archive_path = build_dir / archive_name
with zipfile.ZipFile(
archive_path,
mode="w",
compression=zipfile.ZIP_DEFLATED,
) as archive:
for path in _iter_build_files(plugin.plugin_dir, build_dir):
archive.write(path, arcname=path.relative_to(plugin.plugin_dir))
click.echo(f"构建完成:{archive_path}")
click.echo(f"artifact: {archive_path}")
@click.group()
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
@click.pass_context
def cli(ctx, verbose: bool) -> None:
"""AstrBot SDK CLI。"""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
setup_logger(verbose)
@cli.command()
@click.option(
"--plugins-dir",
default="plugins",
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Directory containing plugin folders",
)
@click.option(
"--worker-wire-codec",
default="json",
show_default=True,
type=click.Choice(["json", "msgpack"]),
help="Wire codec for supervisor-to-worker transport",
)
def run(plugins_dir: Path, worker_wire_codec: str) -> None:
"""Start the plugin supervisor over stdio."""
entrypoint = (
run_supervisor(plugins_dir=plugins_dir)
if worker_wire_codec == "json"
else run_supervisor(
plugins_dir=plugins_dir,
worker_wire_codec=worker_wire_codec,
)
)
_run_async_entrypoint(
entrypoint,
log_message=f"启动插件主管进程,插件目录:{plugins_dir}",
context={
"plugins_dir": plugins_dir,
"worker_wire_codec": worker_wire_codec,
},
)
@cli.command()
@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}" if name is not None else "创建插件骨架:交互模式"
),
context={"target": name or "<interactive>"},
)
@cli.command()
@click.option(
"--plugin-dir",
default=".",
show_default=True,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Plugin directory to validate",
)
def validate(plugin_dir: Path) -> None:
"""Validate plugin manifest, imports and handler discovery."""
_run_sync_entrypoint(
lambda: _validate_plugin(plugin_dir),
log_message=f"校验插件目录:{plugin_dir}",
context={"plugin_dir": plugin_dir},
)
@cli.command()
@click.option(
"--plugin-dir",
default=".",
show_default=True,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Plugin directory to package",
)
@click.option(
"--output-dir",
default=None,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Directory for the build artifact, defaults to <plugin-dir>/dist",
)
def build(plugin_dir: Path, output_dir: Path | None) -> None:
"""Validate and package a plugin into a zip artifact."""
_run_sync_entrypoint(
lambda: _build_plugin(plugin_dir, output_dir),
log_message=f"构建插件包:{plugin_dir}",
context={"plugin_dir": plugin_dir, "output_dir": output_dir},
)
@cli.command()
@click.option(
"--plugin-dir",
required=False,
default=None,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Plugin directory to run locally, defaults to current directory when plugin.yaml exists",
)
@click.option("--local", "local_mode", is_flag=True, help="Run against local mock core")
@click.option(
"--standalone",
"standalone_mode",
is_flag=True,
help="Deprecated alias of --local",
)
@click.option("--event-text", type=str, help="Single message text to dispatch")
@click.option("--interactive", is_flag=True, help="Read follow-up messages from stdin")
@click.option(
"--watch",
is_flag=True,
help="Reload the local harness when plugin files change",
)
@click.option("--session-id", default="local-session", show_default=True)
@click.option("--user-id", default="local-user", show_default=True)
@click.option("--platform", "platform_name", default="test", show_default=True)
@click.option("--group-id", default=None)
@click.option("--event-type", default="message", show_default=True)
def dev(
plugin_dir: Path | None,
local_mode: bool,
standalone_mode: bool,
event_text: str | None,
interactive: bool,
watch: bool,
session_id: str,
user_id: str,
platform_name: str,
group_id: str | None,
event_type: str,
) -> None:
"""Run a plugin against the local mock core for development."""
if not (local_mode or standalone_mode):
raise click.BadParameter("当前 dev 只支持 --local/--standalone 模式")
if interactive and event_text:
raise click.BadParameter("--interactive 与 --event-text 不能同时使用")
if not interactive and not event_text:
raise click.BadParameter("请提供 --event-text或改用 --interactive")
resolved_plugin_dir = _resolve_dev_plugin_dir(plugin_dir)
_run_async_entrypoint(
_run_local_dev(
plugin_dir=resolved_plugin_dir,
event_text=event_text,
interactive=interactive,
watch=watch,
session_id=session_id,
user_id=user_id,
platform=platform_name,
group_id=group_id,
event_type=event_type,
),
log_message=f"启动本地开发模式:{resolved_plugin_dir}",
context={
"plugin_dir": resolved_plugin_dir,
"session_id": session_id,
"platform": platform_name,
"event_type": event_type,
},
)
@cli.command(hidden=True)
@click.option(
"--plugin-dir",
required=False,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
)
@click.option(
"--group-metadata",
required=False,
type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
)
@click.option(
"--wire-codec",
default="json",
show_default=True,
type=click.Choice(["json", "msgpack"]),
)
def worker(
plugin_dir: Path | None, group_metadata: Path | None, wire_codec: str
) -> None:
"""Internal command used by the supervisor to start a worker."""
if plugin_dir is None and group_metadata is None:
raise click.UsageError("Either --plugin-dir or --group-metadata is required")
if plugin_dir is not None and group_metadata is not None:
raise click.UsageError(
"--plugin-dir and --group-metadata are mutually exclusive"
)
target = str(group_metadata or plugin_dir)
if group_metadata is not None:
entrypoint = (
run_plugin_worker(group_metadata=group_metadata)
if wire_codec == "json"
else run_plugin_worker(group_metadata=group_metadata, wire_codec=wire_codec)
)
else:
entrypoint = (
run_plugin_worker(plugin_dir=plugin_dir)
if wire_codec == "json"
else run_plugin_worker(plugin_dir=plugin_dir, wire_codec=wire_codec)
)
_run_async_entrypoint(
entrypoint,
log_message=f"启动插件工作进程:{target}",
log_level="debug",
context={"plugin_dir": plugin_dir, "wire_codec": wire_codec},
)
@cli.command(hidden=True)
@click.option("--port", default=8765, type=int, help="WebSocket server port")
@click.option(
"--wire-codec",
default="json",
show_default=True,
type=click.Choice(["json", "msgpack"]),
)
def websocket(port: int, wire_codec: str) -> None:
"""WebSocket runtime entrypoint kept for standalone bridge scenarios."""
entrypoint = (
run_websocket_server(port=port)
if wire_codec == "json"
else run_websocket_server(port=port, wire_codec=wire_codec)
)
_run_async_entrypoint(
entrypoint,
log_message=f"启动 WebSocket 服务器,端口:{port}",
context={"port": port, "wire_codec": wire_codec},
)