"""Unified command registry for Butler gateway slash commands. Supports two tiers of command handling: 1. **Registered handlers** — ``CommandDef.handler`` is a callable that receives a ``CommandContext`` and returns ``str | None``. These are resolved via ``dispatch()`` and bypass the legacy if/elif chain. 2. **Legacy inline** — commands without a handler fall through to ``ButlerMessageHandler._handle_command``'s if/elif chain. New commands should always use tier 1. """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Optional, cast if TYPE_CHECKING: pass logger = logging.getLogger(__name__) @dataclass class CommandContext: """Immutable bag of state passed to every registered command handler.""" cmd: str arg: str session_key: str platform: str external_id: str | None orchestrator: Any session_registry: Any def reset_loop(self) -> None: """Convenience: destroy the current session's AgentLoop.""" self.session_registry.reset(self.session_key) CommandHandler = Callable[[CommandContext], Optional[str]] def require_owner(ctx: CommandContext) -> Optional[str]: """Sprint 18-1 真源: 5 个 commands/*.py 共享 owner-gate 守门. 非 owner 返 owner_required_message, owner 返 None (继续执行). 行为完全等价于原先 dialog/info/project/permission/lifecycle 各自的本地 _require_owner / _check_owner_or_return, 单一真源便于改 owner gate 规则. """ return require_owner_kw( platform=ctx.platform, external_id=ctx.external_id, session_key=ctx.session_key ) def require_owner_kw( platform: str, external_id: str | None, session_key: str ) -> Optional[str]: """Sprint 19-4 SEC-19-4: kwargs 变体真源, 供 legacy 不走 CommandContext 的 调用方 (registry_commands 等) 使用. 行为与 require_owner 一致. """ # 延迟导入避免 module 加载顺序耦合 (owner_gate 不依赖本模块, 这里是单向引用) from butler.gateway.owner_gate import is_gateway_owner, owner_required_message if not is_gateway_owner( platform=platform, external_id=external_id, session_key=session_key ): return cast(str, owner_required_message()) return None @dataclass(frozen=True) class CommandDef: name: str aliases: tuple[str, ...] = () category: str = "其他" help_text: str = "" handler_ref: str = "" handler: Optional[CommandHandler] = field(default=None, repr=False, compare=False) visibility: str = "public" # public | admin | hidden _REGISTRY: dict[str, CommandDef] = {} _ALIAS_MAP: dict[str, str] = {} def register(cmd: CommandDef) -> None: _REGISTRY[cmd.name] = cmd for alias in cmd.aliases: _ALIAS_MAP[alias] = cmd.name def lookup(name: str) -> CommandDef | None: canonical = _ALIAS_MAP.get(name, name) return _REGISTRY.get(canonical) def dispatch(ctx: CommandContext) -> tuple[bool, str | None]: """Try to dispatch *ctx.cmd* through a registered handler. Returns ``(handled, response)``. If no handler is registered for the command, returns ``(False, None)`` so the caller can fall through to legacy handling. """ cmd_def = lookup(ctx.cmd) if cmd_def is None or cmd_def.handler is None: return False, None def _on_success() -> None: from butler.gateway.gateway_transcript import record_gateway_tool_action from butler.gateway.outbound_prefs import mark_slash_reply_single_bubble record_gateway_tool_action( ctx.session_key, tool_name=f"slash:{ctx.cmd}", args_preview=str(ctx.arg or "")[:400], ) mark_slash_reply_single_bubble() from butler.gateway.command_registry_ops import dispatch_registered_command return cast( tuple[bool, str | None], dispatch_registered_command( cmd=ctx.cmd, handler=cmd_def.handler, ctx=ctx, on_success=_on_success, ), ) def all_commands(*, visibility: str | None = None) -> list[CommandDef]: cmds = list(_REGISTRY.values()) if visibility: cmds = [c for c in cmds if c.visibility == visibility] return sorted(cmds, key=lambda c: (c.category, c.name)) def categories() -> dict[str, list[CommandDef]]: result: dict[str, list[CommandDef]] = {} for cmd in _REGISTRY.values(): if cmd.visibility == "hidden": continue result.setdefault(cmd.category, []).append(cmd) return {k: sorted(v, key=lambda c: c.name) for k, v in sorted(result.items())} def format_registry_help(topic: str = "") -> str: """Generate help text from the registry.""" if topic: cmd = lookup(f"/{topic}") or lookup(topic) if cmd: aliases = ", ".join(cmd.aliases) if cmd.aliases else "无" return ( f"命令: {cmd.name}\n" f"别名: {aliases}\n" f"分类: {cmd.category}\n" f"{cmd.help_text}" ) return f"未找到命令: {topic}" cats = categories() lines = ["Butler 命令帮助\n"] for cat, cmds in cats.items(): lines.append(f"[{cat}]") for c in cmds: alias_hint = f" ({', '.join(c.aliases[:2])})" if c.aliases else "" lines.append(f" {c.name}{alias_hint} — {c.help_text.split(chr(10))[0]}") lines.append("") lines.append("查看详情: /帮助 <命令名>") return "\n".join(lines) def _register_defaults() -> None: """Register all known Butler commands.""" _defaults: list[CommandDef] = [ CommandDef("/项目", ("/projects",), "项目管理", "列出所有项目"), CommandDef("/切换", ("/switch",), "项目管理", "切换当前项目"), CommandDef("/状态", ("/status",), "项目管理", "当前项目与管家状态"), CommandDef("/项目概况", (), "项目管理", "项目仪表盘(代码/任务/工具统计)"), CommandDef("/项目待办", (), "项目管理", "项目级待办事项"), CommandDef("/总览", ("/overview",), "项目管理", "项目总览与概要"), CommandDef("/会话", ("/session",), "对话控制", "查看/管理会话"), CommandDef("/新对话", ("/new", "/新会话"), "对话控制", "重置当前会话"), CommandDef("/steer", ("/指引",), "对话控制", "插入引导消息"), CommandDef("/queue", (), "对话控制", "入站队列模式"), CommandDef("/待办", ("/todo",), "对话控制", "查看/管理会话待办"), CommandDef("/循环", ("/loop",), "对话控制", "启动目标循环模式"), CommandDef("/停止循环", ("/stoploop",), "对话控制", "停止目标循环"), CommandDef("/模型", ("/model",), "模型", "查看/切换当前模型"), CommandDef("/预设", (), "模型", "列出 butler:// 预设"), CommandDef("/记忆待审", (), "记忆", "查看待审批记忆"), CommandDef("/批准记忆", (), "记忆", "批准待审记忆"), CommandDef("/拒绝记忆", (), "记忆", "拒绝待审记忆"), CommandDef("/记忆图谱", (), "记忆", "查看三元组关系图"), CommandDef("/记忆提炼", (), "记忆", "从 transcript 提炼记忆"), CommandDef("/记忆状态", (), "记忆", "查看记忆系统状态"), CommandDef("/权限", ("/perms",), "权限安全", "查看当前权限状态"), CommandDef("/批准一次", (), "权限安全", "放行一次被拦截的操作"), CommandDef("/始终允许", (), "权限安全", "永久放行某类操作"), CommandDef("/批准执行", (), "权限安全", "批准 terminal 命令"), CommandDef("/批准沙箱外", ("/approve-unsandboxed",), "权限安全", "批准 terminal 沙箱外执行"), CommandDef("/批准模式", (), "权限安全", "按模式批准(24h 有效)"), CommandDef("/确认安装", (), "权限安全", "确认 Skill/MCP 安装"), CommandDef("/确认", ("/approve",), "规划模式", "确认 workflow 步骤"), CommandDef("/取消", ("/cancel",), "规划模式", "取消 workflow 步骤"), CommandDef("/计划", ("/plan",), "规划模式", "进入规划模式"), CommandDef("/执行", ("/execute",), "规划模式", "退出规划,开始执行"), CommandDef("/git", (), "开发工具", "Git 状态摘要"), CommandDef("/测试", (), "开发工具", "运行项目测试"), CommandDef("/构建", (), "开发工具", "运行项目构建"), CommandDef("/开发状态", (), "开发工具", "开发环境概况"), CommandDef("/开发验收", (), "开发工具", "跑开发冒烟测试"), CommandDef("/诊断", ("/health",), "系统管理", "系统诊断"), CommandDef("/doctor", (), "系统管理", "安全审计报告"), CommandDef("/config", ("/配置",), "系统管理", "查看/修改系统配置"), CommandDef("/导出", ("/export",), "系统管理", "导出会话为 Markdown"), CommandDef("/回滚", (), "系统管理", "回滚 transcript"), CommandDef("/定时", ("/runtime",), "系统管理", "查看定时任务"), CommandDef("/工作流", ("/workflow",), "系统管理", "工作流管理"), CommandDef("/技能", ("/skill",), "系统管理", "Skill 搜索/安装/管理"), CommandDef("/mcp", (), "系统管理", "MCP 搜索/安装/管理"), CommandDef("/预算", ("/budget",), "系统管理", "设置本轮 token 预算"), CommandDef("/任务", ("/tasks",), "系统管理", "查看委派任务列表"), CommandDef("/评价", ("/evaluate",), "系统管理", "评估/评价当前结果"), CommandDef("/分叉", ("/fork",), "系统管理", "会话分叉"), CommandDef("/备忘", (), "日常生活", "查看备忘录"), CommandDef("/通讯录", (), "日常生活", "查看通讯录"), CommandDef("/记账", (), "日常生活", "查看记账概览"), CommandDef("/打卡", (), "日常生活", "查看习惯打卡"), ] for cmd in _defaults: register(cmd) _register_defaults() __all__ = [ "CommandContext", "CommandDef", "CommandHandler", "all_commands", "categories", "dispatch", "format_registry_help", "lookup", "register", ]