|
| 1 | +"""Shared skill-collection helpers for IDE integrations. |
| 2 | +
|
| 3 | +A skill is a directory holding a ``SKILL.md``: ``<skills root>/<skill name>/SKILL.md``. |
| 4 | +The same layout is used for user-scope skills (``~/.claude/skills/``) and for the skills a |
| 5 | +plugin ships (``<plugin dir>/skills/``), so one walker serves both. |
| 6 | +
|
| 7 | +Unlike an MCP config - a small JSON file at a known path - a ``SKILL.md`` body is unbounded |
| 8 | +prose, and the number of installed skills is unbounded too. Both are capped here rather than |
| 9 | +downstream: the whole session-context report is one request, so an oversized skill would cost |
| 10 | +the device its MCP inventory as well. |
| 11 | +""" |
| 12 | + |
| 13 | +from pathlib import Path |
| 14 | +from typing import Optional |
| 15 | + |
| 16 | +from cycode.logger import get_logger |
| 17 | + |
| 18 | +logger = get_logger('AI Guardrails Skills') |
| 19 | + |
| 20 | +SKILL_FILE_NAME = 'SKILL.md' |
| 21 | + |
| 22 | +# Where a plugin keeps its skills, relative to the plugin directory. A property of the plugin format |
| 23 | +# rather than of any one IDE, so Claude Code, Codex and Copilot plugins all use it. |
| 24 | +PLUGIN_SKILLS_SUBDIR = 'skills' |
| 25 | + |
| 26 | +# A skill is instructions, not data. Anything larger is not a skill we can usefully inventory, |
| 27 | +# and sending it would push the one-request report toward the API's body limit. |
| 28 | +MAX_SKILL_FILE_BYTES = 256 * 1024 |
| 29 | + |
| 30 | +# Per skills root, not per device: a developer with more installed skills than this in one place |
| 31 | +# is an outlier we would rather truncate than let define the payload size. |
| 32 | +MAX_SKILLS_PER_ROOT = 200 |
| 33 | + |
| 34 | + |
| 35 | +def _read_skill_file(skill_file: Path) -> Optional[dict]: |
| 36 | + """Read one ``SKILL.md`` into the session-context file shape, or None if unusable.""" |
| 37 | + try: |
| 38 | + size = skill_file.stat().st_size |
| 39 | + except OSError as e: |
| 40 | + logger.debug('Failed to stat skill file, %s', {'path': str(skill_file)}, exc_info=e) |
| 41 | + return None |
| 42 | + |
| 43 | + if size > MAX_SKILL_FILE_BYTES: |
| 44 | + logger.debug( |
| 45 | + 'Skill file exceeds the size cap; skipping, %s', |
| 46 | + {'path': str(skill_file), 'size': size, 'cap': MAX_SKILL_FILE_BYTES}, |
| 47 | + ) |
| 48 | + return None |
| 49 | + |
| 50 | + try: |
| 51 | + content = skill_file.read_text(encoding='utf-8') |
| 52 | + except Exception as e: |
| 53 | + logger.debug('Failed to read skill file, %s', {'path': str(skill_file)}, exc_info=e) |
| 54 | + return None |
| 55 | + |
| 56 | + if not content.strip(): |
| 57 | + return None |
| 58 | + |
| 59 | + return {'path': str(skill_file), 'content': content} |
| 60 | + |
| 61 | + |
| 62 | +def walk_skill_dirs(skills_root: Path) -> list[dict]: |
| 63 | + """Collect every ``<skills_root>/<name>/SKILL.md`` as ``{"path", "content"}``. |
| 64 | +
|
| 65 | + Exactly one directory level is scanned. A skill directory may hold nested references and |
| 66 | + scripts, but its ``SKILL.md`` always sits at the top of it, so there is nothing to recurse |
| 67 | + into - which is also what keeps this bounded without a depth cap. |
| 68 | +
|
| 69 | + Results are sorted by path: the session-context report is deduplicated by hashing the whole |
| 70 | + payload, so an unstable order would re-send an unchanged inventory. |
| 71 | + """ |
| 72 | + if not skills_root.is_dir(): |
| 73 | + return [] |
| 74 | + |
| 75 | + try: |
| 76 | + skill_dirs = sorted(d for d in skills_root.iterdir() if d.is_dir()) |
| 77 | + except OSError as e: |
| 78 | + logger.debug('Failed to list skills root, %s', {'path': str(skills_root)}, exc_info=e) |
| 79 | + return [] |
| 80 | + |
| 81 | + skills: list[dict] = [] |
| 82 | + for skill_dir in skill_dirs: |
| 83 | + if len(skills) >= MAX_SKILLS_PER_ROOT: |
| 84 | + logger.debug( |
| 85 | + 'Skills root exceeds the count cap; truncating, %s', |
| 86 | + {'path': str(skills_root), 'cap': MAX_SKILLS_PER_ROOT}, |
| 87 | + ) |
| 88 | + break |
| 89 | + |
| 90 | + skill = _read_skill_file(skill_dir / SKILL_FILE_NAME) |
| 91 | + if skill: |
| 92 | + skills.append(skill) |
| 93 | + |
| 94 | + return skills |
| 95 | + |
| 96 | + |
| 97 | +def walk_plugin_skills(plugin_dir: Path) -> list[dict]: |
| 98 | + """Collect the skills a plugin ships, from ``<plugin_dir>/skills/<name>/SKILL.md``. |
| 99 | +
|
| 100 | + Shared by every IDE with a plugin system: the layout belongs to the plugin format, so a plugin |
| 101 | + shipping skills is inventoried whichever IDE loaded it. |
| 102 | + """ |
| 103 | + return walk_skill_dirs(plugin_dir / PLUGIN_SKILLS_SUBDIR) |
0 commit comments