Compare commits

..

2 Commits

Author SHA1 Message Date
Hua
0126061d53 docs(skill): sync README and AGENTS guidance
Some checks failed
Test Suite / test (3.11) (push) Failing after 1m23s
Test Suite / test (3.12) (push) Failing after 1m4s
Test Suite / test (3.13) (push) Failing after 1m4s
2026-03-17 16:00:59 +08:00
Hua
59b9b54cbc fix(skill): improve clawhub command handling 2026-03-17 15:55:41 +08:00
6 changed files with 171 additions and 19 deletions

View File

@@ -24,12 +24,16 @@ Recent history favors short Conventional Commit subjects such as `fix(memory): .
## Security & Configuration Tips ## 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. 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.
- If a change affects user-visible behavior, commands, workflows, or contributor conventions, update both `README.md` and `AGENTS.md` in the same patch so runtime docs and repo rules stay aligned.
## Chat Commands & Skills ## Chat Commands & Skills
- Slash commands are handled in `nanobot/agent/loop.py`; keep parsing logic there instead of scattering command behavior across channels. - 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`. - 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`. - If a slash command should appear in Telegram's native command menu, also update `nanobot/channels/telegram.py`.
- `/skill` currently supports `search`, `install`, `uninstall`, `list`, and `update`. Keep subcommand dispatch in `nanobot/agent/loop.py`.
- `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime. - `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime.
- `/skill uninstall` runs in a non-interactive context, so keep passing `--yes` when shelling out to ClawHub.
- Treat empty `/skill search` output as a user-visible "no results" case rather than a silent success. Surface npm/registry failures directly to the user.
- Never hardcode `~/.nanobot/workspace` for skill installation or lookup. Use the active runtime workspace from config or `--workspace`. - 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. - Workspace skills in `<workspace>/skills/` take precedence over built-in skills with the same directory name.

View File

@@ -1347,6 +1347,7 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot
| `/persona set <name>` | Switch persona and start a new session | | `/persona set <name>` | Switch persona and start a new session |
| `/skill search <query>` | Search public skills on ClawHub | | `/skill search <query>` | Search public skills on ClawHub |
| `/skill install <slug>` | Install a ClawHub skill into the active workspace | | `/skill install <slug>` | Install a ClawHub skill into the active workspace |
| `/skill uninstall <slug>` | Remove a ClawHub-managed skill from the active workspace |
| `/skill list` | List ClawHub-managed skills in the active workspace | | `/skill list` | List ClawHub-managed skills in the active workspace |
| `/skill update` | Update all ClawHub-managed skills in the active workspace | | `/skill update` | Update all ClawHub-managed skills in the active workspace |
| `/stop` | Stop the current task | | `/stop` | Stop the current task |
@@ -1354,9 +1355,14 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot
| `/help` | Show command help | | `/help` | Show command help |
`/skill` uses the active workspace for the current process, not a hard-coded `/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 `~/.nanobot/workspace` path. If you start nanobot with `--workspace`, skill install/uninstall/list/update
operate on that workspace's `skills/` directory. operate on that workspace's `skills/` directory.
`/skill search` can legitimately return no matches. In that case nanobot now replies with a
clear "no skills found" message instead of leaving the channel on a transient searching state.
If `npx clawhub@latest` cannot reach the npm registry, nanobot also surfaces the registry/network
error directly so the failure is visible to the user.
<details> <details>
<summary><b>Heartbeat (Periodic Tasks)</b></summary> <summary><b>Heartbeat (Periodic Tasks)</b></summary>

View File

@@ -8,6 +8,7 @@ import os
import re import re
import shutil import shutil
import sys import sys
import tempfile
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Awaitable, Callable from typing import TYPE_CHECKING, Awaitable, Callable
@@ -57,7 +58,19 @@ class AgentLoop:
""" """
_TOOL_RESULT_MAX_CHARS = 16_000 _TOOL_RESULT_MAX_CHARS = 16_000
_CLAWHUB_TIMEOUT_SECONDS = 300 _CLAWHUB_TIMEOUT_SECONDS = 60
_CLAWHUB_INSTALL_TIMEOUT_SECONDS = 180
_CLAWHUB_NETWORK_ERROR_MARKERS = (
"eai_again",
"enotfound",
"etimedout",
"econnrefused",
"econnreset",
"fetch failed",
"network request failed",
"registry.npmjs.org",
)
_CLAWHUB_NPM_CACHE_DIR = Path(tempfile.gettempdir()) / "nanobot-npm-cache"
def __init__( def __init__(
self, self,
@@ -183,15 +196,40 @@ class AgentLoop:
"""Decode subprocess output conservatively for CLI surfacing.""" """Decode subprocess output conservatively for CLI surfacing."""
return data.decode("utf-8", errors="replace").strip() return data.decode("utf-8", errors="replace").strip()
async def _run_clawhub(self, language: str, *args: str) -> tuple[int, str]: @classmethod
def _is_clawhub_network_error(cls, output: str) -> bool:
lowered = output.lower()
return any(marker in lowered for marker in cls._CLAWHUB_NETWORK_ERROR_MARKERS)
def _format_clawhub_error(self, language: str, code: int, output: str) -> str:
if output and self._is_clawhub_network_error(output):
return "\n\n".join([text(language, "skill_command_network_failed"), output])
return output or text(language, "skill_command_failed", code=code)
def _clawhub_env(self) -> dict[str, str]:
"""Configure npm so ClawHub fails fast and uses a writable cache directory."""
env = os.environ.copy()
env.setdefault("NO_COLOR", "1")
env.setdefault("FORCE_COLOR", "0")
env.setdefault("npm_config_cache", str(self._CLAWHUB_NPM_CACHE_DIR))
env.setdefault("npm_config_update_notifier", "false")
env.setdefault("npm_config_audit", "false")
env.setdefault("npm_config_fund", "false")
env.setdefault("npm_config_fetch_retries", "0")
env.setdefault("npm_config_fetch_timeout", "5000")
env.setdefault("npm_config_fetch_retry_mintimeout", "1000")
env.setdefault("npm_config_fetch_retry_maxtimeout", "5000")
return env
async def _run_clawhub(
self, language: str, *args: str, timeout_seconds: int | None = None,
) -> tuple[int, str]:
"""Run the ClawHub CLI and return (exit_code, combined_output).""" """Run the ClawHub CLI and return (exit_code, combined_output)."""
npx = shutil.which("npx") npx = shutil.which("npx")
if not npx: if not npx:
return 127, text(language, "skill_npx_missing") return 127, text(language, "skill_npx_missing")
env = os.environ.copy() env = self._clawhub_env()
env.setdefault("NO_COLOR", "1")
env.setdefault("FORCE_COLOR", "0")
proc = None proc = None
try: try:
@@ -205,7 +243,7 @@ class AgentLoop:
env=env, env=env,
) )
stdout, stderr = await asyncio.wait_for( stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=self._CLAWHUB_TIMEOUT_SECONDS, proc.communicate(), timeout=timeout_seconds or self._CLAWHUB_TIMEOUT_SECONDS,
) )
except FileNotFoundError: except FileNotFoundError:
return 127, text(language, "skill_npx_missing") return 127, text(language, "skill_npx_missing")
@@ -231,6 +269,7 @@ class AgentLoop:
"""Handle ClawHub skill management commands for the active workspace.""" """Handle ClawHub skill management commands for the active workspace."""
language = self._get_session_language(session) language = self._get_session_language(session)
parts = msg.content.strip().split() parts = msg.content.strip().split()
search_query: str | None = None
if len(parts) == 1: if len(parts) == 1:
return OutboundMessage( return OutboundMessage(
channel=msg.channel, channel=msg.channel,
@@ -249,7 +288,14 @@ class AgentLoop:
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=text(language, "skill_search_missing_query"), content=text(language, "skill_search_missing_query"),
) )
code, output = await self._run_clawhub(language, "search", query_parts[2].strip(), "--limit", "5") search_query = query_parts[2].strip()
code, output = await self._run_clawhub(
language,
"search",
search_query,
"--limit",
"5",
)
elif subcommand == "install": elif subcommand == "install":
if len(parts) < 3: if len(parts) < 3:
return OutboundMessage( return OutboundMessage(
@@ -258,13 +304,38 @@ class AgentLoop:
content=text(language, "skill_install_missing_slug"), content=text(language, "skill_install_missing_slug"),
) )
code, output = await self._run_clawhub( code, output = await self._run_clawhub(
language, "install", parts[2], "--workdir", workspace, language,
"install",
parts[2],
"--workdir",
workspace,
timeout_seconds=self._CLAWHUB_INSTALL_TIMEOUT_SECONDS,
)
elif subcommand == "uninstall":
if len(parts) < 3:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "skill_uninstall_missing_slug"),
)
code, output = await self._run_clawhub(
language,
"uninstall",
parts[2],
"--yes",
"--workdir",
workspace,
) )
elif subcommand == "list": elif subcommand == "list":
code, output = await self._run_clawhub(language, "list", "--workdir", workspace) code, output = await self._run_clawhub(language, "list", "--workdir", workspace)
elif subcommand == "update": elif subcommand == "update":
code, output = await self._run_clawhub( code, output = await self._run_clawhub(
language, "update", "--all", "--workdir", workspace, language,
"update",
"--all",
"--workdir",
workspace,
timeout_seconds=self._CLAWHUB_INSTALL_TIMEOUT_SECONDS,
) )
else: else:
return OutboundMessage( return OutboundMessage(
@@ -274,13 +345,20 @@ class AgentLoop:
) )
if code != 0: if code != 0:
content = output or text(language, "skill_command_failed", code=code) content = self._format_clawhub_error(language, code, output)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content)
if subcommand == "search" and not output:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "skill_search_no_results", query=search_query or ""),
)
notes: list[str] = [] notes: list[str] = []
if output: if output:
notes.append(output) notes.append(output)
if subcommand in {"install", "update"}: if subcommand in {"install", "uninstall", "update"}:
notes.append(text(language, "skill_applied_to_workspace", workspace=workspace)) 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) 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) return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content)

View File

@@ -12,16 +12,19 @@
"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_skill": "/skill <search|install|uninstall|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_usage": "Usage:\n/skill search <query>\n/skill install <slug>\n/skill uninstall <slug>\n/skill list\n/skill update",
"skill_search_missing_query": "Missing query.\n\nUsage:\n/skill search <query>", "skill_search_missing_query": "Missing query.\n\nUsage:\n/skill search <query>",
"skill_search_no_results": "No skills found for \"{query}\". Try broader keywords, or use /skill install <slug> if you know the exact slug.",
"skill_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install <slug>", "skill_install_missing_slug": "Missing skill slug.\n\nUsage:\n/skill install <slug>",
"skill_uninstall_missing_slug": "Missing skill slug.\n\nUsage:\n/skill uninstall <slug>",
"skill_npx_missing": "npx is not installed. Install Node.js first, then retry /skill.", "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_timeout": "The ClawHub command timed out. Check npm connectivity or proxy settings and try again.",
"skill_command_failed": "ClawHub command failed with exit code {code}.", "skill_command_failed": "ClawHub command failed with exit code {code}.",
"skill_command_network_failed": "ClawHub could not reach the npm registry. Check your network, proxy, or npm registry configuration and retry.",
"skill_command_completed": "ClawHub command completed: {command}", "skill_command_completed": "ClawHub command completed: {command}",
"skill_applied_to_workspace": "Applied to workspace: {workspace}", "skill_applied_to_workspace": "Applied to workspace: {workspace}",
"current_persona": "Current persona: {persona}", "current_persona": "Current persona: {persona}",

View File

@@ -12,16 +12,19 @@
"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_skill": "/skill <search|install|uninstall|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_usage": "用法:\n/skill search <query>\n/skill install <slug>\n/skill uninstall <slug>\n/skill list\n/skill update",
"skill_search_missing_query": "缺少搜索关键词。\n\n用法\n/skill search <query>", "skill_search_missing_query": "缺少搜索关键词。\n\n用法\n/skill search <query>",
"skill_search_no_results": "没有找到与“{query}”相关的 skill。请尝试更宽泛的关键词如果你知道精确 slug也可以直接用 /skill install <slug>。",
"skill_install_missing_slug": "缺少 skill slug。\n\n用法\n/skill install <slug>", "skill_install_missing_slug": "缺少 skill slug。\n\n用法\n/skill install <slug>",
"skill_uninstall_missing_slug": "缺少 skill slug。\n\n用法\n/skill uninstall <slug>",
"skill_npx_missing": "未安装 npx。请先安装 Node.js然后再重试 /skill。", "skill_npx_missing": "未安装 npx。请先安装 Node.js然后再重试 /skill。",
"skill_command_timeout": "ClawHub 命令执行超时,请稍后重试。", "skill_command_timeout": "ClawHub 命令执行超时。请检查 npm 网络、代理或 registry 配置后重试。",
"skill_command_failed": "ClawHub 命令执行失败,退出码 {code}。", "skill_command_failed": "ClawHub 命令执行失败,退出码 {code}。",
"skill_command_network_failed": "ClawHub 无法连接到 npm registry。请检查网络、代理或 npm registry 配置后重试。",
"skill_command_completed": "ClawHub 命令执行完成:{command}", "skill_command_completed": "ClawHub 命令执行完成:{command}",
"skill_applied_to_workspace": "已应用到工作区:{workspace}", "skill_applied_to_workspace": "已应用到工作区:{workspace}",
"current_persona": "当前人格:{persona}", "current_persona": "当前人格:{persona}",

View File

@@ -63,6 +63,54 @@ async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None:
"--limit", "--limit",
"5", "5",
) )
env = create_proc.await_args.kwargs["env"]
assert env["npm_config_cache"].endswith("nanobot-npm-cache")
assert env["npm_config_fetch_retries"] == "0"
assert env["npm_config_fetch_timeout"] == "5000"
@pytest.mark.asyncio
async def test_skill_search_surfaces_npm_network_errors(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
proc = _FakeProcess(
returncode=1,
stderr=(
"npm error code EAI_AGAIN\n"
"npm error request to https://registry.npmjs.org/clawhub failed"
),
)
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 test")
)
assert response is not None
assert "could not reach the npm registry" in response.content
assert "EAI_AGAIN" in response.content
@pytest.mark.asyncio
async def test_skill_search_empty_output_returns_no_results(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
proc = _FakeProcess(stdout="")
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 selfimprovingagent",
)
)
assert response is not None
assert 'No skills found for "selfimprovingagent"' in response.content
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -74,6 +122,11 @@ async def test_skill_search_runs_clawhub_search(tmp_path: Path) -> None:
("install", "demo-skill"), ("install", "demo-skill"),
"Installed demo-skill", "Installed demo-skill",
), ),
(
"/skill uninstall demo-skill",
("uninstall", "demo-skill", "--yes"),
"Uninstalled demo-skill",
),
( (
"/skill list", "/skill list",
("list",), ("list",),
@@ -117,7 +170,7 @@ async def test_skill_help_includes_skill_command(tmp_path: Path) -> None:
) )
assert response is not None assert response is not None
assert "/skill <search|install|list|update>" in response.content assert "/skill <search|install|uninstall|list|update>" in response.content
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -143,8 +196,13 @@ async def test_skill_usage_errors_are_user_facing(tmp_path: Path) -> None:
missing_slug = await loop._process_message( missing_slug = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill install") InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill install")
) )
missing_uninstall_slug = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/skill uninstall")
)
assert usage is not None assert usage is not None
assert "/skill search <query>" in usage.content assert "/skill search <query>" in usage.content
assert missing_slug is not None assert missing_slug is not None
assert "Missing skill slug" in missing_slug.content assert "Missing skill slug" in missing_slug.content
assert missing_uninstall_slug is not None
assert "/skill uninstall <slug>" in missing_uninstall_slug.content