diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index f6fb1eae..e4455c8a 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -51,6 +51,7 @@ from app.agent.tools.impl.browse_webpage import BrowseWebpageTool from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool from app.agent.tools.impl.run_plugin_command import RunPluginCommandTool +from app.agent.tools.impl.list_all_commands import ListAllCommandsTool from app.core.plugin import PluginManager from app.log import logger from .base import MoviePilotTool @@ -126,6 +127,7 @@ class MoviePilotToolFactory: QueryInstalledPluginsTool, QueryPluginCapabilitiesTool, RunPluginCommandTool, + ListAllCommandsTool, ] # 创建内置工具 for ToolClass in tool_definitions: diff --git a/app/agent/tools/impl/list_all_commands.py b/app/agent/tools/impl/list_all_commands.py new file mode 100644 index 00000000..c861a74f --- /dev/null +++ b/app/agent/tools/impl/list_all_commands.py @@ -0,0 +1,78 @@ +"""查询所有可用命令工具(系统命令 + 插件命令)""" + +import json +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.command import Command +from app.log import logger + + +class ListAllCommandsInput(BaseModel): + """查询所有可用命令工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + + +class ListAllCommandsTool(MoviePilotTool): + name: str = "list_all_commands" + description: str = ( + "List all available commands in the system, including system preset commands " + "(e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) " + "and plugin-registered commands. " + "Use this tool to discover what commands are available before executing them with run_plugin_command. " + "This is especially useful when the user describes an action in natural language and you need to " + "find the matching command to fulfill their request." + ) + args_schema: Type[BaseModel] = ListAllCommandsInput + require_admin: bool = True + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + return "正在查询所有可用命令" + + async def run(self, **kwargs) -> str: + logger.info(f"执行工具: {self.name}") + + try: + command_obj = Command() + all_commands = command_obj.get_commands() + + if not all_commands: + return "当前没有可用的命令" + + commands_list = [] + for cmd, info in all_commands.items(): + cmd_info = { + "command": cmd, + "description": info.get("description", ""), + } + if info.get("category"): + cmd_info["category"] = info["category"] + # 标识命令类型 + if info.get("type") == "scheduler": + cmd_info["type"] = "scheduler" + elif info.get("pid"): + cmd_info["type"] = "plugin" + cmd_info["plugin_id"] = info["pid"] + else: + cmd_info["type"] = "system" + commands_list.append(cmd_info) + + result = { + "total": len(commands_list), + "commands": commands_list, + } + return json.dumps(result, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"查询可用命令失败: {e}", exc_info=True) + return json.dumps( + {"success": False, "message": f"查询可用命令时发生错误: {str(e)}"}, + ensure_ascii=False, + ) diff --git a/app/agent/tools/impl/run_plugin_command.py b/app/agent/tools/impl/run_plugin_command.py index 1d66817f..8d3f21e0 100644 --- a/app/agent/tools/impl/run_plugin_command.py +++ b/app/agent/tools/impl/run_plugin_command.py @@ -1,4 +1,4 @@ -"""运行插件命令工具""" +"""运行插件/系统命令工具""" import json from typing import Optional, Type @@ -6,14 +6,14 @@ from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool +from app.command import Command from app.core.event import eventmanager -from app.core.plugin import PluginManager from app.log import logger from app.schemas.types import EventType, MessageChannel class RunPluginCommandInput(BaseModel): - """运行插件命令工具的输入参数模型""" + """运行插件/系统命令工具的输入参数模型""" explanation: str = Field( ..., @@ -23,16 +23,20 @@ class RunPluginCommandInput(BaseModel): ..., description="The slash command to execute, e.g. '/cookiecloud'. " "Must start with '/'. Can include arguments after the command, e.g. '/command arg1 arg2'. " - "Use query_plugin_capabilities tool to discover available commands first.", + "Use query_plugin_capabilities tool to discover available plugin commands, " + "or list_all_commands tool to discover all available commands (including system commands).", ) class RunPluginCommandTool(MoviePilotTool): name: str = "run_plugin_command" description: str = ( - "Execute a plugin command by sending a CommandExcute event. " - "Plugin commands are slash-commands (starting with '/') registered by plugins. " - "Use the query_plugin_capabilities tool first to discover available commands and their descriptions. " + "Execute a system or plugin command by sending a CommandExcute event. " + "This tool supports ALL registered commands, including: " + "1) System preset commands (e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) " + "2) Plugin commands registered by installed plugins. " + "Use the query_plugin_capabilities tool to discover plugin commands, " + "or the list_all_commands tool to discover all available commands. " "The command will be executed asynchronously. " "Note: This tool triggers the command execution but the actual processing happens in the background." ) @@ -42,7 +46,7 @@ class RunPluginCommandTool(MoviePilotTool): def get_tool_message(self, **kwargs) -> Optional[str]: """生成友好的提示消息""" command = kwargs.get("command", "") - return f"正在执行插件命令: {command}" + return f"正在执行命令: {command}" async def run(self, command: str, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: command={command}") @@ -52,21 +56,17 @@ class RunPluginCommandTool(MoviePilotTool): if not command.startswith("/"): command = f"/{command}" - # 验证命令是否存在 - plugin_manager = PluginManager() - registered_commands = plugin_manager.get_plugin_commands() + # 从全局 Command 单例中验证命令是否存在(包含系统预设命令 + 插件命令 + 其他命令) cmd_name = command.split()[0] - matched_command = None - for cmd in registered_commands: - if cmd.get("cmd") == cmd_name: - matched_command = cmd - break + command_obj = Command() + matched_command = command_obj.get(cmd_name) if not matched_command: - # 列出可用命令帮助用户 + # 列出所有可用命令帮助用户 + all_commands = command_obj.get_commands() available_cmds = [ - f"{cmd.get('cmd')} - {cmd.get('desc', '无描述')}" - for cmd in registered_commands + f"{cmd} - {info.get('description', '无描述')}" + for cmd, info in all_commands.items() ] result = { "success": False, @@ -99,14 +99,16 @@ class RunPluginCommandTool(MoviePilotTool): "success": True, "message": f"命令 {cmd_name} 已触发执行", "command": command, - "command_desc": matched_command.get("desc", ""), - "plugin_id": matched_command.get("pid", ""), + "command_desc": matched_command.get("description", ""), } + # 如果是插件命令,附加插件ID + if matched_command.get("pid"): + result["plugin_id"] = matched_command["pid"] return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: - logger.error(f"执行插件命令失败: {e}", exc_info=True) + logger.error(f"执行命令失败: {e}", exc_info=True) return json.dumps( - {"success": False, "message": f"执行插件命令时发生错误: {str(e)}"}, + {"success": False, "message": f"执行命令时发生错误: {str(e)}"}, ensure_ascii=False, ) diff --git a/skills/command-execute/SKILL.md b/skills/command-execute/SKILL.md new file mode 100644 index 00000000..7e93fae5 --- /dev/null +++ b/skills/command-execute/SKILL.md @@ -0,0 +1,73 @@ +--- +name: command-execute +description: >- + Use this skill when the user's intent is to execute a system or plugin function. Applicable scenarios include: + 1) The user sends a slash command starting with / (e.g. /cookiecloud, /sites, /subscribes, etc.); + 2) The user describes an action in natural language that can be fulfilled by a system or plugin command + (e.g. "sync sites", "show subscriptions", "refresh subscriptions", "check downloads", etc.). + This skill helps you identify the user's intent, find the matching command, extract necessary parameters, + and execute the corresponding command. +allowed-tools: list_all_commands query_plugin_capabilities run_plugin_command +--- + +# Command Execute + +Use this skill to identify user intent and invoke the corresponding system or plugin command. + +## When to Use + +- The user sends a `/xxx` slash command (execute directly) +- The user describes an action in natural language, for example: + - "Sync sites" → `/cookiecloud` + - "Show my subscriptions" → `/subscribes` + - "Refresh subscriptions" → `/subscribe_refresh` + - "What's downloading?" → `/downloading` + - "Organize downloaded files" → `/transfer` + - "Clear cache" → `/clear_cache` + - "Restart the system" → `/restart` + - "Pause all QB tasks" → `/pause_torrents` (plugin command) + +## Tools + +- `list_all_commands` — List all available commands (system + plugin), returns command name, description, and category +- `query_plugin_capabilities` — Query detailed plugin capabilities (commands, actions, scheduled services) +- `run_plugin_command` — Execute a specified command (works for both system and plugin commands) + +## Workflow + +### Step 1: Identify User Intent + +Determine whether the user's message is requesting the execution of a command: + +- **Direct command**: Message starts with `/`, e.g. `/sites`, `/subscribes` → skip to Step 3 +- **Natural language**: The user describes an actionable request → continue to Step 2 + +### Step 2: Find Matching Command + +Use `list_all_commands` to retrieve all available commands. Match the user's described intent against the `description` and `category` fields of each command. + +If the user's description involves a specific plugin's functionality, additionally use `query_plugin_capabilities` to query that plugin's detailed capabilities. + +**Matching strategy**: +- Prefer exact matches on command description +- Then narrow down by category and match +- If no matching command is found, inform the user that no corresponding function is available + +### Step 3: Extract Parameters and Execute + +Some commands support additional arguments (space-separated after the command), for example: +- `/redo ` — Manually re-organize a specific record +- `/subscribe_delete ` — Delete a specific subscription + +Use `run_plugin_command` to execute the command in the format `/command_name arg1 arg2`. + +### Step 4: Report Result + +Command execution is asynchronous. After triggering, inform the user that the command has started. If the command does not exist, list available commands for reference. + +## Important Notes + +- Command execution requires admin privileges; the tool will automatically check permissions +- Both system and plugin commands are executed via the `run_plugin_command` tool — no need to distinguish between them +- If you are unsure which command matches the user's intent, use `list_all_commands` first to look up before deciding +- Never guess non-existent commands; always select from the available command list