feat(skill): add clawhub slash commands

This commit is contained in:
Hua
2026-03-17 14:16:31 +08:00
parent 6cd8a9eac7
commit 93d5e62455
9 changed files with 375 additions and 9 deletions

49
AGENTS.md Normal file
View File

@@ -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 `<workspace>/skills/`. Tests go in `tests/` with `test_<feature>.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_<feature>.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 `<workspace>/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.<name>.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.

View File

@@ -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`. 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 <en\|zh>` | Switch command language |
| `/persona current` | Show the active persona |
| `/persona list` | List available personas |
| `/persona set <name>` | Switch persona and start a new session |
| `/skill search <query>` | Search public skills on ClawHub |
| `/skill install <slug>` | 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.
<details> <details>
<summary><b>Heartbeat (Periodic Tasks)</b></summary> <summary><b>Heartbeat (Periodic Tasks)</b></summary>

View File

@@ -80,6 +80,7 @@ def help_lines(language: Any) -> list[str]:
text(active, "cmd_persona_current"), text(active, "cmd_persona_current"),
text(active, "cmd_persona_list"), text(active, "cmd_persona_list"),
text(active, "cmd_persona_set"), text(active, "cmd_persona_set"),
text(active, "cmd_skill"),
text(active, "cmd_stop"), text(active, "cmd_stop"),
text(active, "cmd_restart"), text(active, "cmd_restart"),
text(active, "cmd_help"), text(active, "cmd_help"),

View File

@@ -6,10 +6,11 @@ import asyncio
import json import json
import os import os
import re import re
import shutil
import sys import sys
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Awaitable, Callable
from loguru import logger from loguru import logger
@@ -24,9 +25,9 @@ from nanobot.agent.i18n import (
text, text,
) )
from nanobot.agent.memory import MemoryConsolidator from nanobot.agent.memory import MemoryConsolidator
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.cron import CronTool 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.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
@@ -56,6 +57,7 @@ class AgentLoop:
""" """
_TOOL_RESULT_MAX_CHARS = 16_000 _TOOL_RESULT_MAX_CHARS = 16_000
_CLAWHUB_TIMEOUT_SECONDS = 300
def __init__( def __init__(
self, self,
@@ -176,6 +178,113 @@ class AgentLoop:
text(language, "cmd_lang_set"), 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: def _register_default_tools(self) -> None:
"""Register the default set of tools.""" """Register the default set of tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None 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) return await self._handle_language_command(msg, session)
if cmd == "/persona": if cmd == "/persona":
return await self._handle_persona_command(msg, session) return await self._handle_persona_command(msg, session)
if cmd == "/skill":
return await self._handle_skill_command(msg, session)
if cmd == "/help": if cmd == "/help":
return OutboundMessage( return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)), channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)),

View File

@@ -12,9 +12,14 @@ from telegram import BotCommand, ReplyParameters, Update
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from telegram.request import HTTPXRequest 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.events import OutboundMessage
from nanobot.bus.queue import MessageBus 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.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import TelegramConfig, TelegramInstanceConfig from nanobot.config.schema import TelegramConfig, TelegramInstanceConfig
@@ -159,7 +164,7 @@ class TelegramChannel(BaseChannel):
name = "telegram" name = "telegram"
display_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): def __init__(self, config: TelegramConfig | TelegramInstanceConfig, bus: MessageBus):
super().__init__(config, bus) 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("new", self._forward_command))
self._app.add_handler(CommandHandler("lang", 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("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("stop", self._forward_command))
self._app.add_handler(CommandHandler("restart", self._forward_command)) self._app.add_handler(CommandHandler("restart", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help)) self._app.add_handler(CommandHandler("help", self._on_help))

View File

@@ -12,9 +12,18 @@
"cmd_persona_current": "/persona current — Show the active persona", "cmd_persona_current": "/persona current — Show the active persona",
"cmd_persona_list": "/persona list — List available personas", "cmd_persona_list": "/persona list — List available personas",
"cmd_persona_set": "/persona set <name> — Switch persona and start a new session", "cmd_persona_set": "/persona set <name> — Switch persona and start a new session",
"cmd_skill": "/skill <search|install|list|update> ... — Manage ClawHub skills",
"cmd_stop": "/stop — Stop the current task", "cmd_stop": "/stop — Stop the current task",
"cmd_restart": "/restart — Restart the bot", "cmd_restart": "/restart — Restart the bot",
"cmd_help": "/help — Show available commands", "cmd_help": "/help — Show available commands",
"skill_usage": "Usage:\n/skill search <query>\n/skill install <slug>\n/skill list\n/skill update",
"skill_search_missing_query": "Missing query.\n\nUsage:\n/skill search <query>",
"skill_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install <slug>",
"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}", "current_persona": "Current persona: {persona}",
"available_personas": "Available personas:\n{items}", "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.", "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", "new": "Start a new conversation",
"lang": "Switch language", "lang": "Switch language",
"persona": "Show or switch personas", "persona": "Show or switch personas",
"skill": "Search or install skills",
"stop": "Stop the current task", "stop": "Stop the current task",
"help": "Show command help", "help": "Show command help",
"restart": "Restart the bot" "restart": "Restart the bot"

View File

@@ -12,9 +12,18 @@
"cmd_persona_current": "/persona current — 查看当前人格", "cmd_persona_current": "/persona current — 查看当前人格",
"cmd_persona_list": "/persona list — 查看可用人格", "cmd_persona_list": "/persona list — 查看可用人格",
"cmd_persona_set": "/persona set <name> — 切换人格并开始新会话", "cmd_persona_set": "/persona set <name> — 切换人格并开始新会话",
"cmd_skill": "/skill <search|install|list|update> ... — 管理 ClawHub skills",
"cmd_stop": "/stop — 停止当前任务", "cmd_stop": "/stop — 停止当前任务",
"cmd_restart": "/restart — 重启机器人", "cmd_restart": "/restart — 重启机器人",
"cmd_help": "/help — 查看命令帮助", "cmd_help": "/help — 查看命令帮助",
"skill_usage": "用法:\n/skill search <query>\n/skill install <slug>\n/skill list\n/skill update",
"skill_search_missing_query": "缺少搜索关键词。\n\n用法\n/skill search <query>",
"skill_install_missing_slug": "缺少 skill slug。\n\n用法\n/skill install <slug>",
"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}", "current_persona": "当前人格:{persona}",
"available_personas": "可用人格:\n{items}", "available_personas": "可用人格:\n{items}",
"unknown_persona": "未知人格:{name}\n可用人格{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。", "unknown_persona": "未知人格:{name}\n可用人格{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。",
@@ -40,6 +49,7 @@
"new": "开启新对话", "new": "开启新对话",
"lang": "切换语言", "lang": "切换语言",
"persona": "查看或切换人格", "persona": "查看或切换人格",
"skill": "搜索或安装技能",
"stop": "停止当前任务", "stop": "停止当前任务",
"help": "查看命令帮助", "help": "查看命令帮助",
"restart": "重启机器人" "restart": "重启机器人"

View File

@@ -27,21 +27,24 @@ npx --yes clawhub@latest search "web scraping" --limit 5
## Install ## Install
```bash ```bash
npx --yes clawhub@latest install <slug> --workdir ~/.nanobot/workspace npx --yes clawhub@latest install <slug> --workdir <nanobot-workspace>
``` ```
Replace `<slug>` 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 `<slug>` with the skill name from search results. Replace `<nanobot-workspace>` with the
active workspace for the current nanobot process. This places the skill into
`<nanobot-workspace>/skills/`, where nanobot loads workspace skills from. Always include
`--workdir`.
## Update ## Update
```bash ```bash
npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace npx --yes clawhub@latest update --all --workdir <nanobot-workspace>
``` ```
## List installed ## List installed
```bash ```bash
npx --yes clawhub@latest list --workdir ~/.nanobot/workspace npx --yes clawhub@latest list --workdir <nanobot-workspace>
``` ```
## Notes ## Notes
@@ -49,5 +52,6 @@ npx --yes clawhub@latest list --workdir ~/.nanobot/workspace
- Requires Node.js (`npx` comes with it). - Requires Node.js (`npx` comes with it).
- No API key needed for search and install. - No API key needed for search and install.
- Login (`npx --yes clawhub@latest login`) is only required for publishing. - 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 <nanobot-workspace>` 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. - After install, remind the user to start a new session to load the skill.

View File

@@ -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 <search|install|list|update>" 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 <query>" in usage.content
assert missing_slug is not None
assert "Missing skill slug" in missing_slug.content