From 93d5e62455f8f217180cfe09b4845185389eb08b Mon Sep 17 00:00:00 2001 From: Hua Date: Tue, 17 Mar 2026 14:16:31 +0800 Subject: [PATCH] feat(skill): add clawhub slash commands --- AGENTS.md | 49 +++++++++++ README.md | 25 ++++++ nanobot/agent/i18n.py | 1 + nanobot/agent/loop.py | 115 +++++++++++++++++++++++- nanobot/channels/telegram.py | 10 ++- nanobot/locales/en.json | 10 +++ nanobot/locales/zh.json | 10 +++ nanobot/skills/clawhub/SKILL.md | 14 +-- tests/test_skill_commands.py | 150 ++++++++++++++++++++++++++++++++ 9 files changed, 375 insertions(+), 9 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/test_skill_commands.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a8ee5f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`nanobot/` is the main Python package. Core agent logic lives in `nanobot/agent/`, channel integrations in `nanobot/channels/`, providers in `nanobot/providers/`, and CLI/config code in `nanobot/cli/` and `nanobot/config/`. Localized command/help text lives in `nanobot/locales/`. Bundled prompts and built-in skills live in `nanobot/templates/` and `nanobot/skills/`, while workspace-installed skills are loaded from `/skills/`. Tests go in `tests/` with `test_.py` names. The WhatsApp bridge is a separate TypeScript project in `bridge/`. + +## Build, Test, and Development Commands +- `uv sync --extra dev`: install Python runtime and developer dependencies from `pyproject.toml` and `uv.lock`. +- `uv run pytest`: run the full Python test suite. +- `uv run pytest tests/test_web_tools.py -q`: run one focused test file during iteration. +- `uv run pytest tests/test_skill_commands.py -q`: run the ClawHub slash-command regression tests. +- `uv run ruff check .`: lint Python code and normalize import ordering. +- `uv run nanobot agent`: start the local CLI agent. +- `cd bridge && npm install && npm run build`: install and compile the WhatsApp bridge. +- `bash tests/test_docker.sh`: smoke-test the Docker image and onboarding flow. + +## Coding Style & Naming Conventions +Target Python 3.11+ and keep Python code consistent with Ruff: 4-space indentation, `snake_case` for functions/modules, `PascalCase` for classes, and `UPPER_SNAKE_CASE` for constants. Ruff uses a 100-character target; stay near it even though long-line errors are ignored. Prefer explicit type hints and small functions. In `bridge/src/`, keep the current ESM TypeScript style and avoid reformatting unrelated lines. + +## Testing Guidelines +Write pytest tests using `tests/test_.py` naming. Add a regression test for every bug fix and cover async flows, channel adapters, and tool behavior when touched. If you change slash commands or command help, update the related loop/localization tests and, when relevant, Telegram command-menu coverage. `pytest-asyncio` is already enabled with automatic asyncio handling. There is no published coverage gate, so prefer targeted assertions over smoke-only tests. + +## Commit & Pull Request Guidelines +Recent history favors short Conventional Commit subjects such as `fix(memory): ...`, `feat(web): ...`, and `docs: ...`. Use imperative mood, add a scope when it helps, and keep unrelated changes out of the same commit. PRs should summarize the behavior change, note config or channel impact, list the tests you ran, and link the relevant issue or PR discussion. Include screenshots only when CLI output or user-visible behavior changed. + +## Security & Configuration Tips +Do not commit real API keys, tokens, chat logs, or workspace data. Keep local secrets in `~/.nanobot/config.json` and use sanitized examples in docs and tests. If you change authentication, network access, or other safety-sensitive behavior, update `README.md` or `SECURITY.md` in the same PR. + +## Chat Commands & Skills +- Slash commands are handled in `nanobot/agent/loop.py`; keep parsing logic there instead of scattering command behavior across channels. +- When a slash command changes user-visible wording, update both `nanobot/locales/en.json` and `nanobot/locales/zh.json`. +- If a slash command should appear in Telegram's native command menu, also update `nanobot/channels/telegram.py`. +- `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime. +- Never hardcode `~/.nanobot/workspace` for skill installation or lookup. Use the active runtime workspace from config or `--workspace`. +- Workspace skills in `/skills/` take precedence over built-in skills with the same directory name. + +## Multi-Instance Channel Notes +The repository supports multi-instance channel configs through `channels..instances`. Each +instance must define a unique `name`, and runtime routing uses `channel/name` rather than +`channel:name`. + +- Supported multi-instance channels currently include `whatsapp`, `telegram`, `discord`, + `feishu`, `mochat`, `dingtalk`, `slack`, `email`, `qq`, `matrix`, and `wecom`. +- Keep backward compatibility with single-instance configs when touching channel schema or docs. +- If a channel persists local runtime state, isolate it per instance instead of sharing one global + directory. +- `matrix` instances should keep separate sync/encryption stores. +- `mochat` instances should keep separate cursor/runtime state. +- `whatsapp` multi-instance means multiple bridge processes, usually with different `bridgeUrl`, + `BRIDGE_PORT`, and `AUTH_DIR` values. diff --git a/README.md b/README.md index a1f374c..38ea3a3 100644 --- a/README.md +++ b/README.md @@ -1314,6 +1314,31 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. +### Chat Slash Commands + +These commands are available inside chats handled by `nanobot agent` or `nanobot gateway`: + +| Command | Description | +|---------|-------------| +| `/new` | Start a new conversation | +| `/lang current` | Show the active command language | +| `/lang list` | List available command languages | +| `/lang set ` | Switch command language | +| `/persona current` | Show the active persona | +| `/persona list` | List available personas | +| `/persona set ` | Switch persona and start a new session | +| `/skill search ` | Search public skills on ClawHub | +| `/skill install ` | Install a ClawHub skill into the active workspace | +| `/skill list` | List ClawHub-managed skills in the active workspace | +| `/skill update` | Update all ClawHub-managed skills in the active workspace | +| `/stop` | Stop the current task | +| `/restart` | Restart the bot process | +| `/help` | Show command help | + +`/skill` uses the active workspace for the current process, not a hard-coded +`~/.nanobot/workspace` path. If you start nanobot with `--workspace`, skill install/list/update +operate on that workspace's `skills/` directory. +
Heartbeat (Periodic Tasks) diff --git a/nanobot/agent/i18n.py b/nanobot/agent/i18n.py index 8b4fa25..ed03b25 100644 --- a/nanobot/agent/i18n.py +++ b/nanobot/agent/i18n.py @@ -80,6 +80,7 @@ def help_lines(language: Any) -> list[str]: text(active, "cmd_persona_current"), text(active, "cmd_persona_list"), text(active, "cmd_persona_set"), + text(active, "cmd_skill"), text(active, "cmd_stop"), text(active, "cmd_restart"), text(active, "cmd_help"), diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 42bb5ed..478e011 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -6,10 +6,11 @@ import asyncio import json import os import re +import shutil import sys from contextlib import AsyncExitStack from pathlib import Path -from typing import TYPE_CHECKING, Any, Awaitable, Callable +from typing import TYPE_CHECKING, Awaitable, Callable from loguru import logger @@ -24,9 +25,9 @@ from nanobot.agent.i18n import ( text, ) from nanobot.agent.memory import MemoryConsolidator +from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.subagent import SubagentManager from nanobot.agent.tools.cron import CronTool -from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.registry import ToolRegistry @@ -56,6 +57,7 @@ class AgentLoop: """ _TOOL_RESULT_MAX_CHARS = 16_000 + _CLAWHUB_TIMEOUT_SECONDS = 300 def __init__( self, @@ -176,6 +178,113 @@ class AgentLoop: text(language, "cmd_lang_set"), ]) + @staticmethod + def _decode_subprocess_output(data: bytes) -> str: + """Decode subprocess output conservatively for CLI surfacing.""" + return data.decode("utf-8", errors="replace").strip() + + async def _run_clawhub(self, language: str, *args: str) -> tuple[int, str]: + """Run the ClawHub CLI and return (exit_code, combined_output).""" + npx = shutil.which("npx") + if not npx: + return 127, text(language, "skill_npx_missing") + + env = os.environ.copy() + env.setdefault("NO_COLOR", "1") + env.setdefault("FORCE_COLOR", "0") + + proc = None + try: + proc = await asyncio.create_subprocess_exec( + npx, + "--yes", + "clawhub@latest", + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=self._CLAWHUB_TIMEOUT_SECONDS, + ) + except FileNotFoundError: + return 127, text(language, "skill_npx_missing") + except asyncio.TimeoutError: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.communicate() + return 124, text(language, "skill_command_timeout") + except asyncio.CancelledError: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.communicate() + raise + + output_parts = [ + self._decode_subprocess_output(stdout), + self._decode_subprocess_output(stderr), + ] + output = "\n".join(part for part in output_parts if part).strip() + return proc.returncode or 0, output + + async def _handle_skill_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: + """Handle ClawHub skill management commands for the active workspace.""" + language = self._get_session_language(session) + parts = msg.content.strip().split() + if len(parts) == 1: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "skill_usage"), + ) + + subcommand = parts[1].lower() + workspace = str(self.workspace) + + if subcommand == "search": + query_parts = msg.content.strip().split(None, 2) + if len(query_parts) < 3 or not query_parts[2].strip(): + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "skill_search_missing_query"), + ) + code, output = await self._run_clawhub(language, "search", query_parts[2].strip(), "--limit", "5") + elif subcommand == "install": + if len(parts) < 3: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "skill_install_missing_slug"), + ) + code, output = await self._run_clawhub( + language, "install", parts[2], "--workdir", workspace, + ) + elif subcommand == "list": + code, output = await self._run_clawhub(language, "list", "--workdir", workspace) + elif subcommand == "update": + code, output = await self._run_clawhub( + language, "update", "--all", "--workdir", workspace, + ) + else: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "skill_usage"), + ) + + if code != 0: + content = output or text(language, "skill_command_failed", code=code) + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + + notes: list[str] = [] + if output: + notes.append(output) + if subcommand in {"install", "update"}: + notes.append(text(language, "skill_applied_to_workspace", workspace=workspace)) + content = "\n\n".join(notes) if notes else text(language, "skill_command_completed", command=subcommand) + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + def _register_default_tools(self) -> None: """Register the default set of tools.""" allowed_dir = self.workspace if self.restrict_to_workspace else None @@ -618,6 +727,8 @@ class AgentLoop: return await self._handle_language_command(msg, session) if cmd == "/persona": return await self._handle_persona_command(msg, session) + if cmd == "/skill": + return await self._handle_skill_command(msg, session) if cmd == "/help": return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)), diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 3eca542..b6f5433 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -12,9 +12,14 @@ from telegram import BotCommand, ReplyParameters, Update from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.request import HTTPXRequest +from nanobot.agent.i18n import ( + help_lines, + normalize_language_code, + telegram_command_descriptions, + text, +) from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.agent.i18n import help_lines, normalize_language_code, telegram_command_descriptions, text from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir from nanobot.config.schema import TelegramConfig, TelegramInstanceConfig @@ -159,7 +164,7 @@ class TelegramChannel(BaseChannel): name = "telegram" display_name = "Telegram" - COMMAND_NAMES = ("start", "new", "lang", "persona", "stop", "help", "restart") + COMMAND_NAMES = ("start", "new", "lang", "persona", "skill", "stop", "help", "restart") def __init__(self, config: TelegramConfig | TelegramInstanceConfig, bus: MessageBus): super().__init__(config, bus) @@ -228,6 +233,7 @@ class TelegramChannel(BaseChannel): self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("lang", self._forward_command)) self._app.add_handler(CommandHandler("persona", self._forward_command)) + self._app.add_handler(CommandHandler("skill", self._forward_command)) self._app.add_handler(CommandHandler("stop", self._forward_command)) self._app.add_handler(CommandHandler("restart", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) diff --git a/nanobot/locales/en.json b/nanobot/locales/en.json index b86d2a9..5be1eef 100644 --- a/nanobot/locales/en.json +++ b/nanobot/locales/en.json @@ -12,9 +12,18 @@ "cmd_persona_current": "/persona current — Show the active persona", "cmd_persona_list": "/persona list — List available personas", "cmd_persona_set": "/persona set — Switch persona and start a new session", + "cmd_skill": "/skill ... — Manage ClawHub skills", "cmd_stop": "/stop — Stop the current task", "cmd_restart": "/restart — Restart the bot", "cmd_help": "/help — Show available commands", + "skill_usage": "Usage:\n/skill search \n/skill install \n/skill list\n/skill update", + "skill_search_missing_query": "Missing query.\n\nUsage:\n/skill search ", + "skill_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install ", + "skill_npx_missing": "npx is not installed. Install Node.js first, then retry /skill.", + "skill_command_timeout": "The ClawHub command timed out. Please try again.", + "skill_command_failed": "ClawHub command failed with exit code {code}.", + "skill_command_completed": "ClawHub command completed: {command}", + "skill_applied_to_workspace": "Applied to workspace: {workspace}", "current_persona": "Current persona: {persona}", "available_personas": "Available personas:\n{items}", "unknown_persona": "Unknown persona: {name}\nAvailable personas: {personas}\nCreate one under {path} and add SOUL.md or USER.md.", @@ -40,6 +49,7 @@ "new": "Start a new conversation", "lang": "Switch language", "persona": "Show or switch personas", + "skill": "Search or install skills", "stop": "Stop the current task", "help": "Show command help", "restart": "Restart the bot" diff --git a/nanobot/locales/zh.json b/nanobot/locales/zh.json index 1621b86..47c63b9 100644 --- a/nanobot/locales/zh.json +++ b/nanobot/locales/zh.json @@ -12,9 +12,18 @@ "cmd_persona_current": "/persona current — 查看当前人格", "cmd_persona_list": "/persona list — 查看可用人格", "cmd_persona_set": "/persona set — 切换人格并开始新会话", + "cmd_skill": "/skill ... — 管理 ClawHub skills", "cmd_stop": "/stop — 停止当前任务", "cmd_restart": "/restart — 重启机器人", "cmd_help": "/help — 查看命令帮助", + "skill_usage": "用法:\n/skill search \n/skill install \n/skill list\n/skill update", + "skill_search_missing_query": "缺少搜索关键词。\n\n用法:\n/skill search ", + "skill_install_missing_slug": "缺少 skill slug。\n\n用法:\n/skill install ", + "skill_npx_missing": "未安装 npx。请先安装 Node.js,然后再重试 /skill。", + "skill_command_timeout": "ClawHub 命令执行超时,请稍后重试。", + "skill_command_failed": "ClawHub 命令执行失败,退出码 {code}。", + "skill_command_completed": "ClawHub 命令执行完成:{command}", + "skill_applied_to_workspace": "已应用到工作区:{workspace}", "current_persona": "当前人格:{persona}", "available_personas": "可用人格:\n{items}", "unknown_persona": "未知人格:{name}\n可用人格:{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。", @@ -40,6 +49,7 @@ "new": "开启新对话", "lang": "切换语言", "persona": "查看或切换人格", + "skill": "搜索或安装技能", "stop": "停止当前任务", "help": "查看命令帮助", "restart": "重启机器人" diff --git a/nanobot/skills/clawhub/SKILL.md b/nanobot/skills/clawhub/SKILL.md index 7409bf4..0f5c726 100644 --- a/nanobot/skills/clawhub/SKILL.md +++ b/nanobot/skills/clawhub/SKILL.md @@ -27,21 +27,24 @@ npx --yes clawhub@latest search "web scraping" --limit 5 ## Install ```bash -npx --yes clawhub@latest install --workdir ~/.nanobot/workspace +npx --yes clawhub@latest install --workdir ``` -Replace `` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`. +Replace `` with the skill name from search results. Replace `` with the +active workspace for the current nanobot process. This places the skill into +`/skills/`, where nanobot loads workspace skills from. Always include +`--workdir`. ## Update ```bash -npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace +npx --yes clawhub@latest update --all --workdir ``` ## List installed ```bash -npx --yes clawhub@latest list --workdir ~/.nanobot/workspace +npx --yes clawhub@latest list --workdir ``` ## Notes @@ -49,5 +52,6 @@ npx --yes clawhub@latest list --workdir ~/.nanobot/workspace - Requires Node.js (`npx` comes with it). - No API key needed for search and install. - Login (`npx --yes clawhub@latest login`) is only required for publishing. -- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace. +- `--workdir ` is critical — without it, skills install to the current directory + instead of the active nanobot workspace. - After install, remind the user to start a new session to load the skill. diff --git a/tests/test_skill_commands.py b/tests/test_skill_commands.py new file mode 100644 index 0000000..ed44317 --- /dev/null +++ b/tests/test_skill_commands.py @@ -0,0 +1,150 @@ +"""Tests for /skill slash command integration.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.bus.events import InboundMessage + + +def _make_loop(workspace: Path): + """Create an AgentLoop with a real workspace and lightweight mocks.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + with patch("nanobot.agent.loop.SubagentManager"): + loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) + return loop + + +class _FakeProcess: + def __init__(self, *, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: + self.returncode = returncode + self._stdout = stdout.encode("utf-8") + self._stderr = stderr.encode("utf-8") + self.killed = False + + async def communicate(self) -> tuple[bytes, bytes]: + return self._stdout, self._stderr + + def kill(self) -> None: + self.killed = True + + +@pytest.mark.asyncio +async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + proc = _FakeProcess(stdout="skill-a\nskill-b") + create_proc = AsyncMock(return_value=proc) + + with patch("nanobot.agent.loop.shutil.which", return_value="/usr/bin/npx"), \ + patch("nanobot.agent.loop.asyncio.create_subprocess_exec", create_proc): + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill search web scraping") + ) + + assert response is not None + assert response.content == "skill-a\nskill-b" + assert create_proc.await_count == 1 + args = create_proc.await_args.args + assert args == ( + "/usr/bin/npx", + "--yes", + "clawhub@latest", + "search", + "web scraping", + "--limit", + "5", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("command", "expected_args", "expected_output"), + [ + ( + "/skill install demo-skill", + ("install", "demo-skill"), + "Installed demo-skill", + ), + ( + "/skill list", + ("list",), + "demo-skill", + ), + ( + "/skill update", + ("update", "--all"), + "Updated 1 skill", + ), + ], +) +async def test_skill_commands_use_active_workspace( + tmp_path: Path, command: str, expected_args: tuple[str, ...], expected_output: str, +) -> None: + loop = _make_loop(tmp_path) + proc = _FakeProcess(stdout=expected_output) + create_proc = AsyncMock(return_value=proc) + + with patch("nanobot.agent.loop.shutil.which", return_value="/usr/bin/npx"), \ + patch("nanobot.agent.loop.asyncio.create_subprocess_exec", create_proc): + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=command) + ) + + assert response is not None + assert expected_output in response.content + args = create_proc.await_args.args + assert args[:3] == ("/usr/bin/npx", "--yes", "clawhub@latest") + assert args[3:] == (*expected_args, "--workdir", str(tmp_path)) + if command != "/skill list": + assert f"Applied to workspace: {tmp_path}" in response.content + + +@pytest.mark.asyncio +async def test_skill_help_includes_skill_command(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/help") + ) + + assert response is not None + assert "/skill " in response.content + + +@pytest.mark.asyncio +async def test_skill_missing_npx_returns_guidance(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + + with patch("nanobot.agent.loop.shutil.which", return_value=None): + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill list") + ) + + assert response is not None + assert "npx is not installed" in response.content + + +@pytest.mark.asyncio +async def test_skill_usage_errors_are_user_facing(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + + usage = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill") + ) + missing_slug = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill install") + ) + + assert usage is not None + assert "/skill search " in usage.content + assert missing_slug is not None + assert "Missing skill slug" in missing_slug.content