diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index cfe487a05..fbcf46e1b 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -98,6 +98,12 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "Revert memory to a previous Dream snapshot.", "undo-2", ), + BuiltinCommandSpec( + "/skill", + "List skills", + "List all enabled skills available to the agent.", + "wrench", + ), BuiltinCommandSpec( "/help", "Show help", @@ -642,6 +648,25 @@ async def cmd_pairing(ctx: CommandContext) -> OutboundMessage: ) +async def cmd_skill(ctx: CommandContext) -> OutboundMessage: + """List all enabled skills (name and description only).""" + loop = ctx.loop + skills = loop.context.skills.list_skills(filter_unavailable=False) + if not skills: + content = "No skills available." + else: + lines = [f"Available skills ({len(skills)}):", ""] + for entry in skills: + desc = loop.context.skills._get_skill_description(entry["name"]) + lines.append(f"- **{entry['name']}** — {desc}") + content = "\n".join(lines) + return OutboundMessage( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + content=content, + metadata=dict(ctx.msg.metadata or {}), + ) + async def cmd_help(ctx: CommandContext) -> OutboundMessage: """Return available slash commands.""" return OutboundMessage( @@ -681,6 +706,7 @@ def register_builtin_commands(router: CommandRouter) -> None: router.prefix("/dream-log ", cmd_dream_log) router.exact("/dream-restore", cmd_dream_restore) router.prefix("/dream-restore ", cmd_dream_restore) + router.exact("/skill", cmd_skill) router.exact("/help", cmd_help) router.exact("/pairing", cmd_pairing) router.prefix("/pairing ", cmd_pairing) diff --git a/tests/command/test_skill_command.py b/tests/command/test_skill_command.py new file mode 100644 index 000000000..b820ce2ed --- /dev/null +++ b/tests/command/test_skill_command.py @@ -0,0 +1,138 @@ +"""Tests for the /skill built-in command.""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from nanobot.agent.loop import AgentLoop +from nanobot.agent.skills import SkillsLoader +from nanobot.bus.events import InboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.command.builtin import cmd_skill, register_builtin_commands +from nanobot.command.router import CommandContext, CommandRouter +from nanobot.config.schema import ModelPresetConfig + + +def _provider(default_model: str = "test-model") -> MagicMock: + provider = MagicMock() + provider.get_default_model.return_value = default_model + provider.generation = MagicMock() + provider.generation.max_tokens = 4096 + provider.generation.temperature = 0.1 + provider.generation.reasoning_effort = None + return provider + + +def _make_loop(tmp_path: Path) -> AgentLoop: + return AgentLoop( + bus=MessageBus(), + provider=_provider(), + workspace=tmp_path, + model="test-model", + context_window_tokens=8000, + model_presets={ + "default": ModelPresetConfig( + model="test-model", + max_tokens=4096, + context_window_tokens=8000, + ), + }, + ) + + +def _ctx(loop: AgentLoop, raw: str = "/skill") -> CommandContext: + msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw) + return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args="", loop=loop) + + +def _write_skill(base: Path, name: str, *, description: str = "", body: str = "# Skill\n") -> None: + skill_dir = base / name + skill_dir.mkdir(parents=True, exist_ok=True) + frontmatter = f"---\nname: {name}\n" + if description: + frontmatter += f"description: {description}\n" + frontmatter += "---\n\n" + (skill_dir / "SKILL.md").write_text(frontmatter + body, encoding="utf-8") + + +def _loop_with_skills(tmp_path: Path) -> AgentLoop: + """Create a loop with an empty builtin dir so only workspace skills appear.""" + loop = _make_loop(tmp_path) + empty_builtin = tmp_path / "empty_builtin" + empty_builtin.mkdir() + loop.context.skills = SkillsLoader(tmp_path, builtin_skills_dir=empty_builtin) + return loop + + +@pytest.mark.asyncio +async def test_skill_command_no_skills(tmp_path: Path) -> None: + loop = _loop_with_skills(tmp_path) + out = await cmd_skill(_ctx(loop)) + assert out.content == "No skills available." + + +@pytest.mark.asyncio +async def test_skill_command_lists_names_and_descriptions(tmp_path: Path) -> None: + ws_skills = tmp_path / "skills" + ws_skills.mkdir() + _write_skill(ws_skills, "weather", description="Get current weather and forecasts") + _write_skill(ws_skills, "cron", description="Schedule recurring tasks") + + loop = _loop_with_skills(tmp_path) + out = await cmd_skill(_ctx(loop)) + + assert "Available skills (2):" in out.content + assert "**weather** — Get current weather and forecasts" in out.content + assert "**cron** — Schedule recurring tasks" in out.content + # Must NOT contain file paths + assert ".md" not in out.content + assert "/skills/" not in out.content + + +@pytest.mark.asyncio +async def test_skill_command_excludes_disabled(tmp_path: Path) -> None: + ws_skills = tmp_path / "skills" + ws_skills.mkdir() + _write_skill(ws_skills, "alpha", description="Alpha skill") + _write_skill(ws_skills, "beta", description="Beta skill") + + loop = _make_loop(tmp_path) + loop.context.skills.disabled_skills = {"alpha"} + + out = await cmd_skill(_ctx(loop)) + + assert "alpha" not in out.content + assert "**beta** — Beta skill" in out.content + + +@pytest.mark.asyncio +async def test_skill_command_fallback_description(tmp_path: Path) -> None: + ws_skills = tmp_path / "skills" + ws_skills.mkdir() + _write_skill(ws_skills, "plain", description="", body="# Plain Skill\n") + + loop = _loop_with_skills(tmp_path) + out = await cmd_skill(_ctx(loop)) + + assert "**plain** — plain" in out.content + + +@pytest.mark.asyncio +async def test_skill_command_no_render_as_text(tmp_path: Path) -> None: + """Output is markdown; CLI should render it (not forced as plain text).""" + loop = _make_loop(tmp_path) + out = await cmd_skill(_ctx(loop)) + assert out.metadata.get("render_as") != "text" + + +@pytest.mark.asyncio +async def test_skill_command_registered_on_router(tmp_path: Path) -> None: + router = CommandRouter() + register_builtin_commands(router) + loop = _loop_with_skills(tmp_path) + + out = await router.dispatch(_ctx(loop, "/skill")) + + assert out is not None + assert "No skills available." in out.content