File tree Expand file tree Collapse file tree
cycode/cli/apps/ai_guardrails/ides
tests/cli/commands/ai_guardrails/ides Expand file tree Collapse file tree Original file line number Diff line number Diff line change 1919
2020SKILL_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.
2428MAX_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 )
Original file line number Diff line number Diff line change 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
1717from cycode .cli .apps .ai_guardrails .ides .base import IDE , DecisionAction , HookDecision
1818from cycode .cli .apps .ai_guardrails .scan .payload import AIHookPayload
1919from cycode .cli .apps .ai_guardrails .scan .types import AiHookEventType
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
Original file line number Diff line number Diff line change 2020 resolve_cached_plugin_dir ,
2121 walk_enabled_plugins ,
2222)
23+ from cycode .cli .apps .ai_guardrails .ides ._skill_utils import walk_plugin_skills
2324from cycode .cli .apps .ai_guardrails .ides .base import IDE , DecisionAction , HookDecision
2425from cycode .cli .apps .ai_guardrails .scan .payload import AIHookPayload
2526from 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 , {}
Original file line number Diff line number Diff line change 3737 load_plugin_json ,
3838 walk_enabled_plugins ,
3939)
40+ from cycode .cli .apps .ai_guardrails .ides ._skill_utils import walk_plugin_skills
4041from cycode .cli .apps .ai_guardrails .ides .base import IDE , DecisionAction , HookDecision
4142from cycode .cli .apps .ai_guardrails .scan .payload import AIHookPayload
4243from 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 {}
Original file line number Diff line number Diff 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 = '---\n name: codex-skill\n ---\n Body.\n ' )
391+
392+ entry , _ = _read_codex_plugin (plugin_dir )
393+
394+ assert [s ['path' ] for s in entry ['skill_files' ]] == [str (skill_file )]
Original file line number Diff line number Diff line change 1515from cycode .cli .apps .ai_guardrails .ides .base import HookDecision
1616from 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 = '---\n name: copilot-skill\n ---\n Body.\n ' )
514+
515+ entry , _ = _read_copilot_plugin (plugin_dir )
516+
517+ assert [s ['path' ] for s in entry ['skill_files' ]] == [str (skill_file )]
You can’t perform that action at this time.
0 commit comments