Skip to content

feat: discover Claude Code skills + Telegram command name normalization - #197

Open
americodias wants to merge 1 commit into
overwirehq:mainfrom
americodias:pr/skill-discovery
Open

americodias wants to merge 1 commit into
overwirehq:mainfrom
americodias:pr/skill-discovery

Conversation

@americodias

Copy link
Copy Markdown

Summary

Discovers Claude Code skills from the standard on-disk locations and surfaces them as Telegram bot commands so they appear in the command menu and can be invoked with /<skill-name>. Also handles the dash-to-underscore normalization Telegram requires, transparently rewriting back before forwarding to Claude.

Why

Claude Code skills are first-class extensibility: drop a SKILL.md into .claude/skills/<name>/, with a YAML frontmatter name: and description:, and Claude can invoke it. The bot already passes through unknown slash commands to Claude (#131), which means skill commands like /git-activity do reach Claude — but:

  1. They're invisible in Telegram's command menu (no autocomplete).
  2. Telegram's Bot API only allows [a-z0-9_] in command names, so any skill named git-activity (the conventional dashed form) can't be a BotCommand at all. Users have to remember to type /git_activity and that doesn't match Claude's skill dispatcher, which uses the raw frontmatter name.

This PR fixes both: discovers skills from the same paths Claude Code itself uses, exposes them with normalized names in the menu, and rewrites the command back to the original form before passing to Claude.

What

src/bot/features/skill_discovery.py (new, 172 LOC) — scans the standard skill locations:

{project_dir}/.claude/skills/<skill>/SKILL.md                    (project)
~/.claude/skills/<skill>/SKILL.md                                (user)
~/.claude/plugins/marketplaces/<m>/{plugins,external_plugins}/<p>/skills/<s>/SKILL.md

with project > user > plugin precedence. Parses YAML frontmatter for name, description, argument-hint. Skips skills declaring user-invokable: false (so non-user-facing skills like agent-only ones don't pollute the menu) and skips collisions with built-in commands (start, new, status, etc).

The rewrite_skill_command(text, skills) helper maps a leading /<normalized> back to /<original_name> for discovered skills — leaves non-command text, unknown commands, and already-original-form commands untouched.

src/bot/orchestrator.py (+30 LOC) — wires it in:

  • __init__: scans skills once at startup
  • agentic_text: rewrites the leading slash before forwarding to Claude
  • agentic_new: re-scans on /new so newly-added skills appear without restarting
  • get_bot_commands: appends discovered skills to the agentic command list

tests/unit/test_bot/test_skill_discovery.py (new, 225 LOC) — 20 unit tests covering the multi-path discovery, precedence, shadowing, normalization, command rewrite, and user-invokable: false filter.

Compatibility

  • Pure addition for projects without .claude/skills/ directories — discover_skills() returns an empty dict, no behavior changes.
  • Uses the existing passthrough unknown slash commands plumbing from feat: passthrough unknown slash commands to Claude in agentic mode #131 — slash commands that don't match a registered handler still flow through to Claude as before. The rewrite step is a no-op when the command isn't a discovered skill.
  • No new dependencies. PyYAML is already present in pyproject.toml.

Test plan

  • 20 unit tests passing (pytest tests/unit/test_bot/test_skill_discovery.py -v)
  • Tested live against a project with 51 project-level + 21 plugin-level skills — discovery surfaced all 72 in the Telegram command menu, dashed names rendered as _ and round-tripped correctly through Claude
  • Test in a project with no .claude/skills/ (graceful no-op)
  • Test plugin-only skills (no project skills) resolution

Notes

The default _BUILTIN_COMMANDS list (skipped during discovery) is hardcoded to match the agentic-mode handlers (start, new, status, verbose, repo, tts, restart, help, sync_threads). If a user names a skill the same as a built-in, the built-in wins and a debug log notes the skip — this matches Claude Code's own behavior.

Discovers Claude Code skills from project, user, and plugin locations:

  {project_dir}/.claude/skills/<skill>/SKILL.md                      (project)
  ~/.claude/skills/<skill>/SKILL.md                                  (user)
  ~/.claude/plugins/marketplaces/<m>/{plugins,external_plugins}/<p>/skills/<s>/SKILL.md

Project takes precedence, then user, then plugin. Discovered skills are
exposed as Telegram bot commands so they appear in the command menu and
can be invoked with /<skill-name>.

Telegram's Bot API only allows [a-z0-9_] in command names, so dashed
skill names are normalized for the menu (git-activity -> git_activity)
and rewritten back to the original dashed form before forwarding to
Claude's skill dispatcher (which matches the raw frontmatter name).

Skips skills whose frontmatter declares 'user-invokable: false' so
non-user-facing skills (e.g. agent-only ones) don't pollute the menu.

Skills are re-scanned on /new so newly added skills appear without a
bot restart.

Includes unit tests covering the multi-path discovery, precedence,
shadowing, normalization, command rewrite, and user-invokable filter.

@RichardAtCT RichardAtCT left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right answer to #173 and #176 and I want it. Four things first.

1. A long or numerous skill set stops the bot booting. get_bot_commands appends every discovered skill with no cap. Telegram allows 100 commands and 32 characters per name, and _normalize() does not truncate. set_my_commands() runs uncaught in src/bot/core.py:142, so a BadRequest kills startup rather than degrading the menu.

Please truncate names to 32 characters, cap the list, and catch the error so a bad menu never blocks boot. Your own note says you discovered 72 skills — that is close to the limit.

2. Discovery reads the operator personal home directory. skill_discovery.py:62 walks the real ~/.claude/skills and ~/.claude/plugins, with no way to inject a root or turn it off. On a shared bot, the commands strangers see depend on what the operator happens to have installed.

It also breaks an existing test: test_agentic_bot_commands expects 6 commands and gets 39 on a machine with skills installed. Please take the roots as a parameter so tests and CI are hermetic.

3. Strip the @botusername suffix. rewrite_skill_command does head[1:].lower() with no @ handling. In a group Telegram sends /git_activity@mybot, so the rewrite misses. _handle_unknown_command already does this at orchestrator.py:1550 — reuse that. This bot supports group mode, so it matters.

4. Run make format. black --check fails on both new files.

Item 1 is the one I care most about. The rest are quick.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants