Skip to content

Commit 79006fb

Browse files
Altruistusclaude
andcommitted
CM-71972: Collect plugin skills for every IDE, not just Claude Code
A plugin's skills live at <plugin_dir>/skills/<name>/SKILL.md, which is a property of the plugin format rather than of any one IDE - Codex and Copilot plugins use the same layout, and all three readers already hold the resolved plugin_dir. So the scan moved into _skill_utils and all three call it: a plugin shipping skills is now inventoried whichever IDE loaded it. User-scope collection stays Claude Code only, because that is the only tool with a SKILL.md convention of its own today - the SCM side agrees, it classifies .claude/skills/*/SKILL.md as category Skill while Cursor's .mdc rules and CLAUDE.md are category Rule. The hook is generic: IDE.get_skills defaults to [] and any IDE opts in by overriding it, so adding one is two lines once its skills directory is confirmed rather than guessed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d44dc71 commit 79006fb

6 files changed

Lines changed: 60 additions & 5 deletions

File tree

‎cycode/cli/apps/ai_guardrails/ides/_skill_utils.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919

2020
SKILL_FILE_NAME = 'SKILL.md'
2121

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+
2226
# A skill is instructions, not data. Anything larger is not a skill we can usefully inventory,
2327
# and sending it would push the one-request report toward the API's body limit.
2428
MAX_SKILL_FILE_BYTES = 256 * 1024
@@ -88,3 +92,12 @@ def walk_skill_dirs(skills_root: Path) -> list[dict]:
8892
skills.append(skill)
8993

9094
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)

‎cycode/cli/apps/ai_guardrails/ides/claude_code.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
resolve_cached_plugin_dir,
1414
walk_enabled_plugins,
1515
)
16-
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_skill_dirs
16+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills, walk_skill_dirs
1717
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1818
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1919
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -36,9 +36,6 @@
3636
_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'
3737
_CLAUDE_SKILLS_DIR = Path.home() / '.claude' / 'skills'
3838

39-
# Claude hardcodes a plugin's skills under this subdirectory, the same way it hardcodes ".mcp.json".
40-
_PLUGIN_SKILLS_SUBDIR = 'skills'
41-
4239
_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
4340
_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'
4441

@@ -206,7 +203,7 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
206203

207204
# Attached to the plugin entry rather than the top-level skills list so the backend keeps the
208205
# plugin provenance (marketplace, plugin, version) that a marketplace-installed skill has.
209-
skill_files = walk_skill_dirs(plugin_dir / _PLUGIN_SKILLS_SUBDIR)
206+
skill_files = walk_plugin_skills(plugin_dir)
210207
if skill_files:
211208
entry['skill_files'] = skill_files
212209

‎cycode/cli/apps/ai_guardrails/ides/codex.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
resolve_cached_plugin_dir,
2121
walk_enabled_plugins,
2222
)
23+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills
2324
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
2425
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2526
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -121,6 +122,12 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
121122
if field in manifest:
122123
entry[field] = manifest[field]
123124

125+
# Same plugin-format layout as every other IDE's plugins, so a plugin shipping skills is
126+
# inventoried whichever IDE loaded it.
127+
skill_files = walk_plugin_skills(plugin_dir)
128+
if skill_files:
129+
entry['skill_files'] = skill_files
130+
124131
mcp_ref = manifest.get('mcpServers')
125132
if not mcp_ref:
126133
return entry, {}

‎cycode/cli/apps/ai_guardrails/ides/copilot.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
load_plugin_json,
3838
walk_enabled_plugins,
3939
)
40+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills
4041
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
4142
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
4243
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -178,6 +179,12 @@ def _read_copilot_plugin(plugin_dir: Path) -> tuple[dict, dict]:
178179
if field in manifest:
179180
entry[field] = manifest[field]
180181

182+
# Same plugin-format layout as every other IDE's plugins, so a plugin shipping skills is
183+
# inventoried whichever IDE loaded it.
184+
skill_files = walk_plugin_skills(plugin_dir)
185+
if skill_files:
186+
entry['skill_files'] = skill_files
187+
181188
mcp_ref = manifest.get('mcpServers')
182189
mcp_config_path = plugin_dir / mcp_ref if isinstance(mcp_ref, str) else plugin_dir / '.mcp.json'
183190
mcp_doc = load_plugin_json(mcp_config_path) or {}

‎tests/cli/commands/ai_guardrails/ides/test_codex.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,18 @@ def test_read_codex_plugin_no_mcp_config_file_when_no_manifest(tmp_path: Path) -
377377

378378
assert 'mcp_config_file' not in entry
379379
assert servers == {}
380+
381+
382+
def test_read_codex_plugin_collects_plugin_skills(fs: FakeFilesystem) -> None:
383+
"""Plugin skills are a property of the plugin format, so Codex plugins carry them too."""
384+
plugin_dir = Path('/dummy/codex-marketplace/dummy-plugin')
385+
fs.create_file(
386+
plugin_dir / '.codex-plugin' / 'plugin.json',
387+
contents=json.dumps({'name': 'dummy-plugin', 'version': '3.0.0'}),
388+
)
389+
skill_file = plugin_dir / 'skills' / 'codex-skill' / 'SKILL.md'
390+
fs.create_file(skill_file, contents='---\nname: codex-skill\n---\nBody.\n')
391+
392+
entry, _ = _read_codex_plugin(plugin_dir)
393+
394+
assert [s['path'] for s in entry['skill_files']] == [str(skill_file)]

‎tests/cli/commands/ai_guardrails/ides/test_copilot.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
1616
from cycode.cli.apps.ai_guardrails.ides.copilot import (
1717
Copilot,
18+
_read_copilot_plugin,
1819
_vscode_mcp_config_path,
1920
split_mcp_tool_name,
2021
)
@@ -499,3 +500,18 @@ def test_parse_mcp_payload_matches_plugin_server_via_normalized_name(fs: FakeFil
499500

500501
assert unified.mcp_server_name == 'dummy-tracker'
501502
assert unified.mcp_tool_name == 'fetch_api'
503+
504+
505+
def test_read_copilot_plugin_collects_plugin_skills(fs: FakeFilesystem) -> None:
506+
"""Plugin skills are a property of the plugin format, so Copilot plugins carry them too."""
507+
plugin_dir = Path('/dummy/copilot-marketplace/dummy-plugin')
508+
fs.create_file(
509+
plugin_dir / 'plugin.json',
510+
contents=json.dumps({'name': 'dummy-plugin', 'version': '4.0.0'}),
511+
)
512+
skill_file = plugin_dir / 'skills' / 'copilot-skill' / 'SKILL.md'
513+
fs.create_file(skill_file, contents='---\nname: copilot-skill\n---\nBody.\n')
514+
515+
entry, _ = _read_copilot_plugin(plugin_dir)
516+
517+
assert [s['path'] for s in entry['skill_files']] == [str(skill_file)]

0 commit comments

Comments
 (0)