Skip to content

Commit 221d75e

Browse files
committed
feat: implement slash commands
Implement slash commands. These are done by putting Markdown files in ~/.claude/commands/ The content of the file becomes a new command the user can trigger with /user:command-name. When UserPrompt receives a message that starts with slash, it tries to resolve this as a command, and if successful, outputs the contents of the command markdown. ```git-revs c25aa15 (Base revision) 5314236 Implement slash commands feature in user_prompt.py e5f18b5 Add unit tests for slash commands functionality 0e019c6 Add end-to-end tests for slash commands functionality 39821df Add pytest-asyncio as a dependency 88eb53f Enable asyncio mode in pytest configuration 61c1515 Fix async test with anyio marker 868d109 Fix second async test with anyio marker d865b48 Fix e2e test with anyio marker d64f3d2 Remove asyncio_mode option since we're using anyio 9dd15c2 Completely rewrite slash command tests to avoid async issues f65c92b Rewrite e2e slash command test to avoid async issues b24f654 Auto-commit format changes HEAD Auto-commit lint changes ``` codemcp-id: 235-feat-implement-slash-commands ghstack-source-id: e52dc18 Pull-Request-resolved: #225
1 parent 5295ec3 commit 221d75e

File tree

4 files changed

+310
-1
lines changed

4 files changed

+310
-1
lines changed

codemcp/tools/user_prompt.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,113 @@
22

33
import logging
44
import os
5+
import re
6+
from pathlib import Path
57

68
from ..git_query import find_git_root
79
from ..rules import get_applicable_rules_content
810

911
__all__ = [
1012
"user_prompt",
13+
"is_slash_command",
14+
"resolve_slash_command",
15+
"get_command_content",
1116
]
1217

1318

19+
def is_slash_command(text: str) -> bool:
20+
"""Check if the user's text starts with a slash command.
21+
22+
Args:
23+
text: The user's text to check
24+
25+
Returns:
26+
True if the text starts with a slash, False otherwise
27+
"""
28+
return bool(text and text.strip().startswith("/"))
29+
30+
31+
def resolve_slash_command(command: str) -> tuple[bool, str, str | None]:
32+
"""Resolve a slash command to a file path.
33+
34+
Args:
35+
command: The slash command (including the slash)
36+
37+
Returns:
38+
A tuple of (success, command_name, file_path)
39+
If success is False, file_path will be None
40+
"""
41+
# Strip the leading slash and any whitespace
42+
command = command.strip()[1:].strip()
43+
44+
# Check for the command format: user:command-name
45+
match = re.match(r"^user:([a-zA-Z0-9_-]+)$", command)
46+
if not match:
47+
return False, command, None
48+
49+
command_name = match.group(1)
50+
51+
# Get the commands directory path
52+
commands_dir = Path.home() / ".claude" / "commands"
53+
54+
# Create the commands directory if it doesn't exist
55+
os.makedirs(commands_dir, exist_ok=True)
56+
57+
# Check if the command file exists
58+
command_file = commands_dir / f"{command_name}.md"
59+
if not command_file.exists():
60+
return False, command_name, None
61+
62+
return True, command_name, str(command_file)
63+
64+
65+
async def get_command_content(file_path: str) -> str:
66+
"""Get the content of a command file.
67+
68+
Args:
69+
file_path: The path to the command file
70+
71+
Returns:
72+
The content of the command file
73+
"""
74+
try:
75+
# Import here to avoid circular imports
76+
from ..file_utils import async_open_text
77+
78+
# Read the file content
79+
content = await async_open_text(file_path)
80+
return content
81+
except Exception as e:
82+
logging.error(f"Error reading command file {file_path}: {e}")
83+
return f"Error reading command file: {e}"
84+
85+
1486
async def user_prompt(user_text: str, chat_id: str | None = None) -> str:
1587
"""Store the user's verbatim prompt text for later use.
1688
1789
This function processes the user's prompt and applies any relevant cursor rules.
90+
If the user's prompt starts with a slash, it tries to resolve it as a command.
1891
1992
Args:
2093
user_text: The user's original prompt verbatim
2194
chat_id: The unique ID of the current chat session
2295
2396
Returns:
24-
A message with any applicable cursor rules
97+
A message with any applicable cursor rules or command content
2598
"""
2699
logging.info(f"Received user prompt for chat ID {chat_id}: {user_text}")
27100

101+
# Check if this is a slash command
102+
if is_slash_command(user_text):
103+
success, command_name, file_path = resolve_slash_command(user_text)
104+
if success and file_path:
105+
command_content = await get_command_content(file_path)
106+
logging.info(f"Resolved slash command {command_name} to file {file_path}")
107+
return command_content
108+
else:
109+
logging.info(f"Failed to resolve slash command {user_text}")
110+
return f"Unknown slash command: {command_name}"
111+
28112
# Get the current working directory to find repo root
29113
cwd = os.getcwd()
30114
repo_root = find_git_root(cwd)

e2e/test_slash_commands.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
3+
import asyncio
4+
import os
5+
import tempfile
6+
from pathlib import Path
7+
from unittest.mock import MagicMock, patch
8+
9+
import pytest
10+
11+
from codemcp.tools.user_prompt import user_prompt
12+
13+
14+
@pytest.fixture
15+
def mock_commands_dir() -> Path:
16+
"""Create a temporary directory with test command files."""
17+
# Create a temporary directory
18+
temp_dir = tempfile.mkdtemp()
19+
20+
# Create the commands directory
21+
commands_dir = Path(temp_dir) / ".claude" / "commands"
22+
os.makedirs(commands_dir, exist_ok=True)
23+
24+
# Create test command files
25+
test_cmd = commands_dir / "test-command.md"
26+
with open(test_cmd, "w") as f:
27+
f.write("# Test Command\nThis is a test command content.")
28+
29+
help_cmd = commands_dir / "help.md"
30+
with open(help_cmd, "w") as f:
31+
f.write(
32+
"# Available Commands\n- `/user:test-command`: A test command\n- `/user:help`: This help message"
33+
)
34+
35+
return commands_dir
36+
37+
38+
def test_slash_command_e2e(mock_commands_dir: Path) -> None:
39+
"""Test slash commands in an end-to-end scenario."""
40+
# Save original home
41+
original_home = Path.home
42+
43+
try:
44+
# Mock Path.home to use our temp directory
45+
Path.home = MagicMock(return_value=mock_commands_dir.parent.parent)
46+
47+
# Mock async_open_text to read the actual files
48+
with patch("codemcp.file_utils.async_open_text") as mock_open:
49+
# Set up different return values based on which file is being read
50+
def side_effect(file_path, **kwargs):
51+
if "test-command.md" in file_path:
52+
return "# Test Command\nThis is a test command content."
53+
elif "help.md" in file_path:
54+
return "# Available Commands\n- `/user:test-command`: A test command\n- `/user:help`: This help message"
55+
else:
56+
raise FileNotFoundError(f"File not found: {file_path}")
57+
58+
mock_open.side_effect = side_effect
59+
60+
# Test a valid slash command
61+
result = asyncio.run(user_prompt("/user:test-command", "test-chat-id"))
62+
assert "# Test Command" in result
63+
assert "This is a test command content." in result
64+
65+
# Test the help command
66+
result = asyncio.run(user_prompt("/user:help", "test-chat-id"))
67+
assert "# Available Commands" in result
68+
assert "`/user:test-command`" in result
69+
assert "`/user:help`" in result
70+
71+
# Test an invalid slash command
72+
result = asyncio.run(user_prompt("/user:invalid-command", "test-chat-id"))
73+
assert "Unknown slash command: invalid-command" in result
74+
75+
# Test a non-slash command
76+
with patch("codemcp.tools.user_prompt.find_git_root", return_value=None):
77+
result = asyncio.run(user_prompt("normal message", "test-chat-id"))
78+
assert "User prompt received" in result
79+
finally:
80+
# Restore original home
81+
Path.home = original_home
82+
83+
# Clean up the temporary directory
84+
import shutil
85+
86+
shutil.rmtree(mock_commands_dir.parent.parent)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"anyio>=3.7.0",
1414
"pyyaml>=6.0.0",
1515
"pytest-xdist>=3.6.1",
16+
"pytest-asyncio>=0.21.0",
1617
"editorconfig>=0.17.0",
1718
"click>=8.1.8",
1819
]

tests/test_slash_commands.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python3
2+
3+
import asyncio
4+
import os
5+
import tempfile
6+
from pathlib import Path
7+
from unittest.mock import MagicMock, patch
8+
9+
from codemcp.tools.user_prompt import is_slash_command, resolve_slash_command
10+
11+
12+
def test_is_slash_command():
13+
"""Test the is_slash_command function."""
14+
# Test valid slash commands
15+
assert is_slash_command("/command") is True
16+
assert is_slash_command(" /command ") is True
17+
assert is_slash_command("/user:command-name") is True
18+
19+
# Test invalid cases
20+
assert is_slash_command("command") is False
21+
assert is_slash_command("") is False
22+
assert is_slash_command(None) is False
23+
assert is_slash_command(" command ") is False
24+
25+
26+
def test_resolve_slash_command():
27+
"""Test the resolve_slash_command function."""
28+
# Valid command format but non-existent file
29+
with patch("os.makedirs"), patch("pathlib.Path.exists", return_value=False):
30+
success, command_name, file_path = resolve_slash_command("/user:test-command")
31+
assert success is False
32+
assert command_name == "test-command"
33+
assert file_path is None
34+
35+
# Valid command format with existing file
36+
with (
37+
patch("os.makedirs"),
38+
patch("pathlib.Path.exists", return_value=True),
39+
patch(
40+
"pathlib.Path.__truediv__",
41+
return_value=Path("/home/user/.claude/commands/test-command.md"),
42+
),
43+
):
44+
success, command_name, file_path = resolve_slash_command("/user:test-command")
45+
assert success is True
46+
assert command_name == "test-command"
47+
assert file_path == "/home/user/.claude/commands/test-command.md"
48+
49+
# Invalid command format (missing user: prefix)
50+
success, command_name, file_path = resolve_slash_command("/test-command")
51+
assert success is False
52+
assert command_name == "test-command"
53+
assert file_path is None
54+
55+
# Invalid command format (invalid characters)
56+
success, command_name, file_path = resolve_slash_command("/user:test@command")
57+
assert success is False
58+
assert command_name == "user:test@command"
59+
assert file_path is None
60+
61+
62+
def test_get_command_content():
63+
"""Test the get_command_content function."""
64+
from codemcp.tools.user_prompt import get_command_content
65+
66+
# Create a temporary file for testing
67+
with tempfile.NamedTemporaryFile(mode="w+", suffix=".md") as temp_file:
68+
temp_file.write("# Test Command\nThis is a test command content.")
69+
temp_file.flush()
70+
71+
# Mock file_utils.async_open_text to return our test content
72+
with patch("codemcp.file_utils.async_open_text") as mock_open:
73+
# Set up the mock to return our content
74+
mock_open.return_value = "# Test Command\nThis is a test command content."
75+
76+
# Run the coroutine in the event loop
77+
result = asyncio.run(get_command_content(temp_file.name))
78+
79+
# Verify the result
80+
assert "# Test Command" in result
81+
assert "This is a test command content." in result
82+
83+
# Test error handling
84+
with patch(
85+
"codemcp.file_utils.async_open_text", side_effect=Exception("Test error")
86+
):
87+
# Run the coroutine in the event loop
88+
result = asyncio.run(get_command_content("non-existent-file"))
89+
90+
# Verify error handling
91+
assert "Error reading command file" in result
92+
assert "Test error" in result
93+
94+
95+
def test_user_prompt_with_slash_command():
96+
"""Test the user_prompt function with slash commands."""
97+
from codemcp.tools.user_prompt import user_prompt
98+
99+
# Create a temporary directory and markdown file for testing
100+
with tempfile.TemporaryDirectory() as temp_dir:
101+
# Mock Path.home() to return our temporary directory
102+
original_home = Path.home
103+
Path.home = MagicMock(return_value=Path(temp_dir))
104+
105+
try:
106+
# Create the .claude/commands directory
107+
commands_dir = Path(temp_dir) / ".claude" / "commands"
108+
os.makedirs(commands_dir, exist_ok=True)
109+
110+
# Create a test command file
111+
command_file = commands_dir / "test-command.md"
112+
with open(command_file, "w") as f:
113+
f.write("# Test Command\nThis is a test command content.")
114+
115+
# Mock file_utils.async_open_text to return our test content
116+
with patch("codemcp.file_utils.async_open_text") as mock_open:
117+
mock_open.return_value = (
118+
"# Test Command\nThis is a test command content."
119+
)
120+
121+
# Test with a valid slash command
122+
result = asyncio.run(user_prompt("/user:test-command", "test-chat-id"))
123+
assert "# Test Command" in result
124+
assert "This is a test command content." in result
125+
126+
# Test with an invalid slash command
127+
result = asyncio.run(user_prompt("/user:non-existent", "test-chat-id"))
128+
assert "Unknown slash command: non-existent" in result
129+
130+
# Test with a non-slash command
131+
with patch(
132+
"codemcp.tools.user_prompt.find_git_root", return_value=None
133+
):
134+
result = asyncio.run(user_prompt("regular command", "test-chat-id"))
135+
assert "User prompt received" in result
136+
finally:
137+
# Restore original Path.home
138+
Path.home = original_home

0 commit comments

Comments
 (0)