diff --git a/src/bot/features/skill_discovery.py b/src/bot/features/skill_discovery.py new file mode 100644 index 00000000..3ff0f766 --- /dev/null +++ b/src/bot/features/skill_discovery.py @@ -0,0 +1,172 @@ +"""Discover Claude Code skills from project, user, and plugin locations. + +Scans SKILL.md frontmatter for name, description, and argument-hint. +Returns a dict of skill name -> DiscoveredSkill. Project-agnostic -- works +with any Claude Code project, following the standard on-disk skill layout: + + {project_dir}/.claude/skills//SKILL.md (project) + ~/.claude/skills//SKILL.md (user) + ~/.claude/plugins/marketplaces//plugins/

/skills//SKILL.md (plugin) + ~/.claude/plugins/marketplaces//external_plugins/

/skills//SKILL.md + +Precedence: project > user > plugin. On collision the higher-precedence +entry wins, and the shadowed one is logged at debug. +""" + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, Optional, Tuple + +import structlog +import yaml + +logger = structlog.get_logger(__name__) + +# Commands that must not be overridden by skills +_BUILTIN_COMMANDS = frozenset({ + "start", "new", "status", "verbose", "repo", "tts", + "help", "sync_threads", "restart", +}) + + +@dataclass +class DiscoveredSkill: + name: str # Telegram-safe form (lowercase, [a-z0-9_]) + description: str + argument_hint: Optional[str] = None + original_name: str = "" # raw `name:` from frontmatter (may contain dashes) + source: str = "project" # "project" | "user" | "plugin" + + +def _normalize(raw_name: str) -> str: + return raw_name.strip().lower().replace(" ", "_").replace("-", "_") + + +def _iter_skill_files(project_dir: Path) -> Iterable[Tuple[Path, str]]: + """Yield (SKILL.md path, source) pairs in precedence order.""" + project_dir = Path(project_dir) + # Project skills: /.claude/skills//SKILL.md + project_skills = project_dir / ".claude" / "skills" + if project_skills.is_dir(): + for p in project_skills.glob("*/SKILL.md"): + yield p, "project" + + # User skills: ~/.claude/skills//SKILL.md + user_skills = Path.home() / ".claude" / "skills" + if user_skills.is_dir(): + for p in user_skills.glob("*/SKILL.md"): + yield p, "user" + + # Plugin skills: ~/.claude/plugins/marketplaces//{plugins,external_plugins}/

/skills//SKILL.md + marketplaces = Path.home() / ".claude" / "plugins" / "marketplaces" + if marketplaces.is_dir(): + for marketplace in marketplaces.iterdir(): + if not marketplace.is_dir(): + continue + for plugin_root in ("plugins", "external_plugins"): + root = marketplace / plugin_root + if not root.is_dir(): + continue + for p in root.glob("*/skills/*/SKILL.md"): + yield p, "plugin" + + +def discover_skills(project_dir: Path) -> Dict[str, DiscoveredSkill]: + """Scan standard Claude Code skill locations and return a name -> skill map. + + Returns a dict mapping the Telegram-safe command name (lowercase, no /, + dashes replaced with underscores) to a DiscoveredSkill. The original + dashed name is preserved in `original_name` so callers can rewrite the + command text back to the form Claude Code expects. + + Skips: + - files without valid YAML frontmatter or without a `name:` field + - names that clash with built-in bot commands + - skills with `user-invokable: false` in frontmatter + - lower-precedence entries when a higher-precedence skill has the same name + """ + discovered: Dict[str, DiscoveredSkill] = {} + + for skill_md, source in _iter_skill_files(project_dir): + try: + text = skill_md.read_text(encoding="utf-8") + + match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) + if not match: + continue + + meta = yaml.safe_load(match.group(1)) + if not isinstance(meta, dict) or "name" not in meta: + continue + + if meta.get("user-invokable") is False: + logger.debug("Skipping skill (user-invokable: false)", path=str(skill_md)) + continue + + raw_name = str(meta["name"]).strip() + cmd_name = _normalize(raw_name) + + if cmd_name in _BUILTIN_COMMANDS: + logger.debug("Skipping skill (conflicts with built-in)", skill=cmd_name) + continue + + if cmd_name in discovered: + logger.debug( + "Skipping shadowed skill", + skill=cmd_name, + shadowed_by=discovered[cmd_name].source, + shadowed_source=source, + path=str(skill_md), + ) + continue + + description = str(meta.get("description", "")).strip() + if not description: + description = f"Run /{cmd_name} skill" + + discovered[cmd_name] = DiscoveredSkill( + name=cmd_name, + description=description[:256], + argument_hint=meta.get("argument-hint"), + original_name=raw_name, + source=source, + ) + except Exception as e: + logger.warning( + "Failed to parse skill frontmatter", + path=str(skill_md), + error=str(e), + ) + + if discovered: + by_source: Dict[str, int] = {} + for s in discovered.values(): + by_source[s.source] = by_source.get(s.source, 0) + 1 + logger.info( + "Skills discovered", + count=len(discovered), + by_source=by_source, + names=sorted(discovered.keys()), + ) + + return discovered + + +def rewrite_skill_command(text: str, skills: Dict[str, DiscoveredSkill]) -> str: + """Rewrite a leading / to / for discovered skills. + + Telegram's Bot API only permits `[a-z0-9_]` in command names, so skill + names containing dashes are normalized for the menu (e.g. git-activity + -> git_activity). Claude Code's skill dispatcher matches the raw name, + so we undo the substitution before forwarding to it. Leaves non-command + text, unknown commands, and already-original-form commands untouched. + """ + if not text.startswith("/"): + return text + head, sep, rest = text.partition(" ") + cmd = head[1:].lower() + skill = skills.get(cmd) + if skill and skill.original_name and skill.original_name != cmd: + return f"/{skill.original_name}" + (f"{sep}{rest}" if sep else "") + return text diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 6d9719f0..74f010f6 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -32,6 +32,11 @@ from ..claude.sdk_integration import StreamUpdate from ..config.settings import Settings from ..projects import PrivateTopicsUnavailableError +from .features.skill_discovery import ( + DiscoveredSkill, + discover_skills, + rewrite_skill_command, +) from .utils.draft_streamer import DraftStreamer, generate_draft_id from .utils.html_format import escape_html from .utils.image_extractor import ( @@ -135,6 +140,18 @@ def __init__(self, settings: Settings, deps: Dict[str, Any]): self.deps = deps self._active_requests: Dict[int, ActiveRequest] = {} self._known_commands: frozenset[str] = frozenset() + self._skills: Dict[str, DiscoveredSkill] = discover_skills( + settings.approved_directory + ) + + def _refresh_skills(self) -> None: + """Re-scan skill directories. Called from /new so newly-added skills + appear without restarting the bot.""" + self._skills = discover_skills(self.settings.approved_directory) + + def rewrite_skill_command(self, text: str) -> str: + """Undo dash->underscore normalization for discovered skill commands.""" + return rewrite_skill_command(text, self._skills) def _inject_deps(self, handler: Callable) -> Callable: # type: ignore[type-arg] """Wrap handler to inject dependencies into context.bot_data.""" @@ -464,6 +481,11 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg] ] if self.settings.enable_project_threads: commands.append(BotCommand("sync_threads", "Sync project topics")) + for skill_name, skill in sorted(self._skills.items()): + desc = skill.description[:50] + if skill.argument_hint: + desc = f"{desc} ({skill.argument_hint})" + commands.append(BotCommand(skill_name, desc[:256])) return commands else: commands = [ @@ -550,6 +572,9 @@ async def agentic_new( context.user_data["session_started"] = True context.user_data["force_new_session"] = True + # Re-scan skills so newly-added ones appear without bot restart. + self._refresh_skills() + await update.message.reply_text("Session reset. What's next?") async def agentic_status( @@ -919,6 +944,11 @@ async def agentic_text( user_id = update.effective_user.id message_text = update.message.text + # Telegram only allows [a-z0-9_] in command names, so dashed skills + # (e.g. /git-activity) are exposed as /git_activity. Restore the + # original dashed form before forwarding to Claude's skill dispatcher. + message_text = self.rewrite_skill_command(message_text) + logger.info( "Agentic text message", user_id=user_id, diff --git a/tests/unit/test_bot/test_skill_discovery.py b/tests/unit/test_bot/test_skill_discovery.py new file mode 100644 index 00000000..27de0f29 --- /dev/null +++ b/tests/unit/test_bot/test_skill_discovery.py @@ -0,0 +1,225 @@ +"""Unit tests for skill_discovery: multi-path scan + command rewrite.""" + +from pathlib import Path + +import pytest + +from src.bot.features.skill_discovery import ( + DiscoveredSkill, + discover_skills, + rewrite_skill_command, +) + + +def _write_skill( + root: Path, + name: str, + description: str = "Test skill", + extra_frontmatter: str = "", +) -> None: + root.mkdir(parents=True, exist_ok=True) + (root / "SKILL.md").write_text( + "---\n" + f"name: {name}\n" + f"description: {description}\n" + f"{extra_frontmatter}" + "---\n\nbody\n" + ) + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + """Point Path.home() at a tmp dir so tests don't touch the real ~/.claude/.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + # Path.home() on POSIX honors $HOME; monkeypatching HOME is enough. + return home + + +@pytest.fixture +def fake_project(tmp_path): + project = tmp_path / "project" + project.mkdir() + return project + + +class TestDiscoverSkills: + def test_discovers_project_skills(self, fake_project, fake_home): + _write_skill( + fake_project / ".claude/skills/foo", + name="foo", + description="Foo skill", + ) + result = discover_skills(fake_project) + assert "foo" in result + assert result["foo"].original_name == "foo" + assert result["foo"].source == "project" + assert result["foo"].description == "Foo skill" + + def test_dashed_name_normalized_original_preserved(self, fake_project, fake_home): + _write_skill( + fake_project / ".claude/skills/git-activity", + name="git-activity", + description="Git activity", + ) + result = discover_skills(fake_project) + assert "git_activity" in result + assert "git-activity" not in result + assert result["git_activity"].original_name == "git-activity" + assert result["git_activity"].name == "git_activity" + + def test_discovers_user_level_skills(self, fake_project, fake_home): + _write_skill( + fake_home / ".claude/skills/ubar", + name="ubar", + description="User skill", + ) + result = discover_skills(fake_project) + assert "ubar" in result + assert result["ubar"].source == "user" + + def test_discovers_plugin_skills(self, fake_project, fake_home): + _write_skill( + fake_home / ".claude/plugins/marketplaces/mp1/plugins/p1/skills/plug", + name="plug", + description="Plugin skill", + ) + result = discover_skills(fake_project) + assert "plug" in result + assert result["plug"].source == "plugin" + + def test_discovers_external_plugin_skills(self, fake_project, fake_home): + _write_skill( + fake_home / ".claude/plugins/marketplaces/mp1/external_plugins/ep1/skills/ext", + name="ext", + description="External plugin skill", + ) + result = discover_skills(fake_project) + assert "ext" in result + assert result["ext"].source == "plugin" + + def test_project_shadows_plugin_on_collision(self, fake_project, fake_home): + _write_skill( + fake_home / ".claude/plugins/marketplaces/mp/plugins/p/skills/shared", + name="shared", + description="Plugin version", + ) + _write_skill( + fake_project / ".claude/skills/shared", + name="shared", + description="Project version", + ) + result = discover_skills(fake_project) + assert result["shared"].source == "project" + assert result["shared"].description == "Project version" + + def test_user_shadows_plugin_on_collision(self, fake_project, fake_home): + _write_skill( + fake_home / ".claude/plugins/marketplaces/mp/plugins/p/skills/shared", + name="shared", + description="Plugin version", + ) + _write_skill( + fake_home / ".claude/skills/shared", + name="shared", + description="User version", + ) + result = discover_skills(fake_project) + assert result["shared"].source == "user" + assert result["shared"].description == "User version" + + def test_skips_builtin_command_conflicts(self, fake_project, fake_home): + _write_skill( + fake_project / ".claude/skills/status", + name="status", + description="Tries to override built-in", + ) + result = discover_skills(fake_project) + assert "status" not in result + + def test_skips_user_invokable_false(self, fake_project, fake_home): + _write_skill( + fake_project / ".claude/skills/hidden", + name="hidden", + description="Not user-callable", + extra_frontmatter="user-invokable: false\n", + ) + result = discover_skills(fake_project) + assert "hidden" not in result + + def test_does_not_filter_disable_model_invocation(self, fake_project, fake_home): + """disable-model-invocation controls agent routing, not user menu visibility.""" + _write_skill( + fake_project / ".claude/skills/closeday", + name="closeday", + description="Daily synthesis", + extra_frontmatter="disable-model-invocation: true\n", + ) + result = discover_skills(fake_project) + assert "closeday" in result + + def test_skips_files_without_frontmatter(self, fake_project, fake_home): + bad = fake_project / ".claude/skills/bad" + bad.mkdir(parents=True) + (bad / "SKILL.md").write_text("no frontmatter here\n") + result = discover_skills(fake_project) + assert "bad" not in result + + def test_missing_project_dir_returns_empty(self, tmp_path, fake_home): + # No .claude/skills/ at project, no user skills, no plugins + result = discover_skills(tmp_path / "nonexistent") + assert result == {} + + def test_description_truncated_to_256(self, fake_project, fake_home): + long = "x" * 500 + _write_skill( + fake_project / ".claude/skills/verbose", + name="verbose_skill", + description=long, + ) + result = discover_skills(fake_project) + assert len(result["verbose_skill"].description) == 256 + + +class TestRewriteSkillCommand: + def _skills(self) -> dict: + return { + "git_activity": DiscoveredSkill( + name="git_activity", + description="Git activity", + original_name="git-activity", + source="project", + ), + "log": DiscoveredSkill( + name="log", + description="Live log", + original_name="log", + source="project", + ), + } + + def test_rewrites_dashed_command(self): + assert rewrite_skill_command("/git_activity", self._skills()) == "/git-activity" + + def test_preserves_args(self): + assert ( + rewrite_skill_command("/git_activity today", self._skills()) + == "/git-activity today" + ) + + def test_leaves_non_dashed_unchanged(self): + assert rewrite_skill_command("/log hello", self._skills()) == "/log hello" + + def test_leaves_unknown_command_unchanged(self): + assert rewrite_skill_command("/unknown arg", self._skills()) == "/unknown arg" + + def test_leaves_plain_text_unchanged(self): + assert rewrite_skill_command("hello world", self._skills()) == "hello world" + + def test_empty_string(self): + assert rewrite_skill_command("", self._skills()) == "" + + def test_handles_bare_slash_without_args(self): + # "/" alone should not crash + assert rewrite_skill_command("/", self._skills()) == "/"