Coverage for mcp_bridge/tools/skill_loader.py: 10%

1"""

2 Skill Loader - Claude Code Slash Command Discovery

3

4 Discovers and lists available skills (slash commands) from:

5 1. Project-local .claude/commands/

6 2. User-global ~/.claude/commands/

7

8 Skills are markdown files with frontmatter defining the command behavior.

9"""

10

11 import re

12 from pathlib import Path

13 from typing import Any

14

15

16 def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:

17 """

18 Parse YAML frontmatter from markdown content.

19

20 Returns:

21 Tuple of (metadata dict, body content)

22 """

23 if not content.startswith("---"):

24 return {}, content

25

26 # Find the closing ---

27 end_match = content.find("---", 3)

28 if end_match == -1:

29 return {}, content

30

31 frontmatter = content[3: end_match].strip()

32 body = content[end_match + 3 :].strip()

33

34 # Simple YAML parsing for key: value pairs

35 metadata = {}

36 for line in frontmatter.split("\n"):

37 if ":" in line:

38 key, _, value = line.partition(":")

39 key = key.strip()

40 value = value.strip().strip('"').strip("'")

41 metadata[key] = value

42

43 return metadata, body

44

45

46 def discover_skills(project_path: str | None = None) -> list[dict[str, Any]]:

47 """

48 Discover available skills/commands.

49

50 Searches:

51 1. Project-local: {project}/.claude/commands/

52 2. User-global: ~/.claude/commands/

53

54 Args:

55 project_path: Project directory to search (defaults to cwd)

56

57 Returns:

58 List of skill definitions.

59 """

60 skills = []

61 search_paths = []

62

63 # Project-local commands

64 project = Path(project_path) if project_path else Path.cwd()

65 project_commands = project / ".claude" / "commands"

66 if project_commands.exists():

67 search_paths.append(("project", project_commands))

68

69 # User-global commands

70 user_commands = Path.home() / ".claude" / "commands"

71 if user_commands.exists():

72 search_paths.append(("user", user_commands))

73

74 for scope, commands_dir in search_paths:

75 for md_file in commands_dir.glob("**/*.md"):

76 try:

77 content = md_file.read_text()

78 metadata, body = parse_frontmatter(content)

79

80 skills.append(

81 {

82 "name": md_file.stem,

83 "scope": scope,

84 "path": str(md_file),

85 "description": metadata.get("description", ""),

86 "allowed_tools": metadata.get("allowed-tools", "").split(",")

87 if metadata.get("allowed-tools")

88 else [],

89 "body_preview": body[:200] + "..." if len(body) > 200 else body,

90 }

91 )

92 except Exception:

93 continue

94

95 return skills

96

97

98 def list_skills(project_path: str | None = None) -> str:

99 """

100 List all available skills for MCP tool.

101

102 Args:

103 project_path: Project directory to search

104

105 Returns:

106 Formatted skill listing.

107 """

108 skills = discover_skills(project_path)

109

110 if not skills:

111 return "No skills found. Create .claude/commands/*.md files to add skills."

112

113 lines = [f"Found {len(skills)} skill(s):\n"]

114

115 for skill in skills:

116 scope_badge = "[project]" if skill["scope"] == "project" else "[user]"

117 lines.append(f" /{skill['name']} {scope_badge}")

118 if skill["description"]:

119 lines.append(f" {skill['description']}")

120

121 return "\n".join(lines)

122

123

124 def get_skill(name: str, project_path: str | None = None) -> str:

125 """

126 Get the content of a specific skill.

127

128 Args:

129 name: Skill name (filename without .md)

130 project_path: Project directory to search

131

132 Returns:

133 Skill content or error message.

134 """

135 skills = discover_skills(project_path)

136

137 skill = next((s for s in skills if s["name"] == name), None)

138 if not skill:

139 available = ", ".join(s["name"] for s in skills)

140 return f"Skill '{name}' not found. Available: {available or 'none'}"

141

142 try:

143 content = Path(skill["path"]).read_text()

144 metadata, body = parse_frontmatter(content)

145

146 lines = [

147 f"## Skill: {name}",

148 f"**Scope**: {skill['scope']}",

149 f"**Path**: {skill['path']}",

150 ]

151

152 if metadata.get("description"):

153 lines.append(f"**Description**: {metadata['description']}")

154

155 if metadata.get("allowed-tools"):

156 lines.append(f"**Allowed Tools**: {metadata['allowed-tools']}")

157

158 lines.extend(["", "---", "", body])

159

160 return "\n".join(lines)

161

162 except Exception as e:

163 return f"Error reading skill: {e}"

164

165

166 def create_skill(

167 name: str,

168 description: str,

169 content: str,

170 scope: str = "project",

171 project_path: str | None = None,

172) -> str:

173 """

174 Create a new skill file.

175

176 Args:

177 name: Skill name (will be used as filename)

178 description: Short description for frontmatter

179 content: Skill body content

180 scope: "project" or "user"

181 project_path: Project directory for project-scope skills

182

183 Returns:

184 Success or error message.

185 """

186 # Sanitize name

187 name = re.sub(r"[^a-zA-Z0-9_-]", "-", name.lower())

188

189 if scope == "project":

190 base_dir = Path(project_path) if project_path else Path.cwd()

191 commands_dir = base_dir / ".claude" / "commands"

192 else:

193 commands_dir = Path.home() / ".claude" / "commands"

194

195 # Ensure directory exists

196 commands_dir.mkdir(parents=True, exist_ok=True)

197

198 skill_path = commands_dir / f"{name}.md"

199

200 if skill_path.exists():

201 return f"Skill '{name}' already exists at {skill_path}"

202

203 # Create skill content

204 skill_content = f"""---

205 description: {description}

206---

207

208{content}

209"""

210

211 try:

212 skill_path.write_text(skill_content)

213 return f"Created skill '{name}' at {skill_path}"

214 except Exception as e:

215 return f"Error creating skill: {e}"