Coverage for mcp_bridge/tools/skill_loader.py: 10%
83 statements
« prev ^ index » next coverage.py v7.10.1, created at 2026-01-10 00:20 -0500
« prev ^ index » next coverage.py v7.10.1, created at 2026-01-10 00:20 -0500
1"""
2Skill Loader - Claude Code Slash Command Discovery
4Discovers and lists available skills (slash commands) from:
51. Project-local .claude/commands/
62. User-global ~/.claude/commands/
8Skills are markdown files with frontmatter defining the command behavior.
9"""
11import re
12from pathlib import Path
13from typing import Any
16def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
17 """
18 Parse YAML frontmatter from markdown content.
20 Returns:
21 Tuple of (metadata dict, body content)
22 """
23 if not content.startswith("---"):
24 return {}, content
26 # Find the closing ---
27 end_match = content.find("---", 3)
28 if end_match == -1:
29 return {}, content
31 frontmatter = content[3:end_match].strip()
32 body = content[end_match + 3 :].strip()
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
43 return metadata, body
46def discover_skills(project_path: str | None = None) -> list[dict[str, Any]]:
47 """
48 Discover available skills/commands.
50 Searches:
51 1. Project-local: {project}/.claude/commands/
52 2. User-global: ~/.claude/commands/
54 Args:
55 project_path: Project directory to search (defaults to cwd)
57 Returns:
58 List of skill definitions.
59 """
60 skills = []
61 search_paths = []
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))
69 # User-global commands
70 user_commands = Path.home() / ".claude" / "commands"
71 if user_commands.exists():
72 search_paths.append(("user", user_commands))
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)
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
95 return skills
98def list_skills(project_path: str | None = None) -> str:
99 """
100 List all available skills for MCP tool.
102 Args:
103 project_path: Project directory to search
105 Returns:
106 Formatted skill listing.
107 """
108 skills = discover_skills(project_path)
110 if not skills:
111 return "No skills found. Create .claude/commands/*.md files to add skills."
113 lines = [f"Found {len(skills)} skill(s):\n"]
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']}")
121 return "\n".join(lines)
124def get_skill(name: str, project_path: str | None = None) -> str:
125 """
126 Get the content of a specific skill.
128 Args:
129 name: Skill name (filename without .md)
130 project_path: Project directory to search
132 Returns:
133 Skill content or error message.
134 """
135 skills = discover_skills(project_path)
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'}"
142 try:
143 content = Path(skill["path"]).read_text()
144 metadata, body = parse_frontmatter(content)
146 lines = [
147 f"## Skill: {name}",
148 f"**Scope**: {skill['scope']}",
149 f"**Path**: {skill['path']}",
150 ]
152 if metadata.get("description"):
153 lines.append(f"**Description**: {metadata['description']}")
155 if metadata.get("allowed-tools"):
156 lines.append(f"**Allowed Tools**: {metadata['allowed-tools']}")
158 lines.extend(["", "---", "", body])
160 return "\n".join(lines)
162 except Exception as e:
163 return f"Error reading skill: {e}"
166def 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.
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
183 Returns:
184 Success or error message.
185 """
186 # Sanitize name
187 name = re.sub(r"[^a-zA-Z0-9_-]", "-", name.lower())
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"
195 # Ensure directory exists
196 commands_dir.mkdir(parents=True, exist_ok=True)
198 skill_path = commands_dir / f"{name}.md"
200 if skill_path.exists():
201 return f"Skill '{name}' already exists at {skill_path}"
203 # Create skill content
204 skill_content = f"""---
205description: {description}
206---
208{content}
209"""
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}"