Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 117 additions & 2 deletions src/ccbot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
# Status polling task
_status_poll_task: asyncio.Task | None = None

# Claude Code commands shown in bot menu (forwarded via tmux)
# Claude Code built-in commands shown in bot menu (forwarded via tmux)
CC_COMMANDS: dict[str, str] = {
"clear": "↗ Clear conversation history",
"compact": "↗ Compact conversation context",
Expand All @@ -157,6 +157,104 @@
}


def _parse_skill_md(skill_md: Path) -> tuple[str, str] | None:
"""Parse a SKILL.md file and return (name, description) or None."""
try:
text = skill_md.read_text(encoding="utf-8", errors="ignore")
except OSError:
return None

lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return None

name = ""
description = ""
in_desc = False
for line in lines[1:]:
if line.strip() == "---":
break
if in_desc:
stripped = line.strip()
if stripped and not stripped.endswith(":"):
description += " " + stripped
else:
in_desc = False
if line.startswith("name:"):
name = line.split(":", 1)[1].strip()
elif line.startswith("description:"):
val = line.split(":", 1)[1].strip()
if val in ("|", ">"):
in_desc = True
else:
description = val

if not name:
return None
return name, description.strip()


import re

# Telegram bot commands: 1-32 chars, lowercase letters, digits, underscores only
_RE_VALID_BOT_COMMAND = re.compile(r"^[a-z0-9_]{1,32}$")


def _skill_name_to_command(name: str) -> str | None:
"""Convert a skill name to a valid Telegram bot command.

Replaces hyphens with underscores (e.g. 'agent-browser' -> 'agent_browser').
Returns None if the result is still not a valid bot command.
"""
cmd = name.replace("-", "_")
if _RE_VALID_BOT_COMMAND.match(cmd):
return cmd
return None


def _discover_skill_commands() -> dict[str, str]:
"""Scan Claude Code skills directory and return {command: description} for bot commands.

Reads YAML frontmatter from each skill's SKILL.md to extract the name and
description fields. Walks nested directories (e.g. gstack/review/SKILL.md)
to discover bundled skill packs. Converts hyphen-case names to underscores
for Telegram compatibility. Skips skills whose names collide with built-in
bot commands.
"""
skills: dict[str, str] = {}
skills_path = config.claude_skills_path
if not skills_path or not skills_path.is_dir():
return skills

# Names already taken by bot handlers or CC_COMMANDS
reserved = {
"start", "history", "screenshot", "esc", "kill", "unbind", "usage",
} | set(CC_COMMANDS.keys())

# Walk all SKILL.md files recursively
seen_commands: set[str] = set()
for skill_md in sorted(skills_path.rglob("SKILL.md")):
result = _parse_skill_md(skill_md)
if result is None:
continue
name, description = result

cmd = _skill_name_to_command(name)
if cmd is None or cmd in reserved or cmd in seen_commands:
continue
seen_commands.add(cmd)

# Truncate description to fit Telegram's 256-char limit for commands
desc = description if description else f"{name} skill"
first_sentence = desc.split(". ")[0].rstrip(".")
if len(first_sentence) > 250:
first_sentence = first_sentence[:247] + "..."
skills[cmd] = f"↗ {first_sentence}"

logger.info("Discovered %d skill commands from %s", len(skills), skills_path)
return skills


def is_user_allowed(user_id: int | None) -> bool:
return user_id is not None and config.is_user_allowed(user_id)

Expand Down Expand Up @@ -505,6 +603,11 @@ async def forward_command_handler(
cmd_text = update.message.text or ""
# The full text is already a slash command like "/clear" or "/compact foo"
cc_slash = cmd_text.split("@")[0] # strip bot mention
# Convert underscores back to hyphens for skill commands that were renamed
# for Telegram compatibility (e.g. /agent_browser -> /agent-browser)
parts = cc_slash.split(None, 1)
cmd_part = parts[0].replace("_", "-")
cc_slash = cmd_part if len(parts) == 1 else f"{cmd_part} {parts[1]}"
wid = session_manager.resolve_window_for_thread(user.id, thread_id)
if not wid:
await safe_reply(update.message, "❌ No session bound to this topic.")
Expand Down Expand Up @@ -1822,10 +1925,22 @@ async def post_init(application: Application) -> None:
BotCommand("unbind", "Unbind topic from session (keeps window running)"),
BotCommand("usage", "Show Claude Code usage remaining"),
]
# Add Claude Code slash commands
# Add Claude Code built-in slash commands
for cmd_name, desc in CC_COMMANDS.items():
bot_commands.append(BotCommand(cmd_name, desc))

# Discover and add skill commands from ~/.claude/skills/
skill_commands = _discover_skill_commands()
for cmd_name, desc in skill_commands.items():
bot_commands.append(BotCommand(cmd_name, desc))

# Telegram allows max 100 bot commands
if len(bot_commands) > 100:
logger.warning(
"Too many bot commands (%d), truncating to 100", len(bot_commands)
)
bot_commands = bot_commands[:100]

await application.bot.set_my_commands(bot_commands)

# Re-resolve stale window IDs from persisted state against live tmux windows
Expand Down
10 changes: 10 additions & 0 deletions src/ccbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ def __init__(self) -> None:

self.monitor_poll_interval = float(os.getenv("MONITOR_POLL_INTERVAL", "2.0"))

# Claude Code skills directory for dynamic slash command discovery
custom_skills_path = os.getenv("CCBOT_CLAUDE_SKILLS_PATH")
if custom_skills_path:
self.claude_skills_path: Path | None = Path(custom_skills_path)
elif claude_config_dir:
self.claude_skills_path = Path(claude_config_dir) / "skills"
else:
skills_default = Path.home() / ".claude" / "skills"
self.claude_skills_path = skills_default if skills_default.is_dir() else None

# Display user messages in history and real-time notifications
# When True, user messages are shown with a 👤 prefix
self.show_user_messages = (
Expand Down
Loading