Skip to content

Commit 88d4018

Browse files
Altruistusclaude
andcommitted
CM-71972: Collect user-scope skills for every IDE, not just Claude Code
Codex, Copilot and Cursor each get get_skills() reading their own skills directory, honoring $CODEX_HOME and $COPILOT_HOME the way the rest of those integrations do. collect_all_skills already sweeps every registered IDE and dedupes by path, so nothing else changes and an IDE with no skills directory simply contributes none. The session-start tests now isolate the home directory instead of stubbing collect_all_skills, so the two skill tests exercise the real sweep against files they create rather than asserting against a mock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d9c1d44 commit 88d4018

7 files changed

Lines changed: 105 additions & 16 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +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
23+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills, walk_skill_dirs
2424
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
2525
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2626
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -54,6 +54,11 @@ def _codex_home() -> Path:
5454
return Path.home() / _CONFIG_DIR_NAME
5555

5656

57+
def _codex_skills_dir() -> Path:
58+
"""User-scope Codex skills directory (honors ``$CODEX_HOME``)."""
59+
return _codex_home() / 'skills'
60+
61+
5762
def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path:
5863
"""Return the Codex ``config.toml`` path for the given scope."""
5964
if scope == 'repo' and repo_path:
@@ -311,3 +316,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
311316
global_config_file = build_global_config_file(config_path, config.get('mcp_servers'))
312317
enriched_plugins = _resolve_codex_plugins(config)
313318
return global_config_file, enriched_plugins
319+
320+
def get_skills(self) -> list[dict]:
321+
return walk_skill_dirs(_codex_skills_dir())

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +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
40+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills, walk_skill_dirs
4141
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
4242
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
4343
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -106,6 +106,11 @@ def _copilot_home() -> Path:
106106
return Path.home() / '.copilot'
107107

108108

109+
def _copilot_skills_dir() -> Path:
110+
"""User-scope Copilot skills directory (honors ``$COPILOT_HOME``)."""
111+
return _copilot_home() / 'skills'
112+
113+
109114
def _vscode_agent_plugins_dir() -> Path:
110115
# Resolved at call time (not a module-level Path constant): on py<=3.10 a Path
111116
# instance binds its filesystem accessor at creation, which breaks fake-fs tests
@@ -488,3 +493,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
488493
build_global_config_file(_vscode_mcp_config_path(), config.get('servers')) if config else None
489494
)
490495
return global_config_file, _collect_installed_plugins()
496+
497+
def get_skills(self) -> list[dict]:
498+
return walk_skill_dirs(_copilot_skills_dir())

cycode/cli/apps/ai_guardrails/ides/cursor.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
99
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file
10+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_skill_dirs
1011
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1112
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1213
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -45,6 +46,11 @@ def _cursor_mcp_config_path() -> Path:
4546
return Path.home() / '.cursor' / _MCP_CONFIG_FILENAME
4647

4748

49+
def _cursor_skills_dir() -> Path:
50+
"""User-scope Cursor skills directory (``~/.cursor/skills``, all platforms)."""
51+
return Path.home() / '.cursor' / 'skills'
52+
53+
4854
def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]:
4955
"""Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
5056
path = config_path or _cursor_mcp_config_path()
@@ -125,3 +131,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
125131
config_path = _cursor_mcp_config_path()
126132
global_config_file = build_global_config_file(config_path, config.get('mcpServers'))
127133
return global_config_file, {}
134+
135+
def get_skills(self) -> list[dict]:
136+
return walk_skill_dirs(_cursor_skills_dir())

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,20 @@ def test_read_codex_plugin_collects_plugin_skills(fs: FakeFilesystem) -> None:
392392
entry, _ = _read_codex_plugin(plugin_dir)
393393

394394
assert [s['path'] for s in entry['skill_files']] == [str(skill_file)]
395+
396+
397+
# skills
398+
399+
400+
def test_get_skills_reads_user_scope_skills(fs: FakeFilesystem) -> None:
401+
body = '---\nname: dummy-skill\ndescription: Dummy.\n---\n\nDo it.\n'
402+
skill_file = Path.home() / '.codex' / 'skills' / 'dummy-skill' / 'SKILL.md'
403+
fs.create_file(skill_file, contents=body)
404+
405+
skills = Codex().get_skills()
406+
407+
assert skills == [{'path': str(skill_file), 'content': body}]
408+
409+
410+
def test_get_skills_no_skills_dir_returns_empty(fs: FakeFilesystem) -> None:
411+
assert Codex().get_skills() == []

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,3 +515,20 @@ def test_read_copilot_plugin_collects_plugin_skills(fs: FakeFilesystem) -> None:
515515
entry, _ = _read_copilot_plugin(plugin_dir)
516516

517517
assert [s['path'] for s in entry['skill_files']] == [str(skill_file)]
518+
519+
520+
# skills
521+
522+
523+
def test_get_skills_reads_user_scope_skills(fs: FakeFilesystem) -> None:
524+
body = '---\nname: dummy-skill\ndescription: Dummy.\n---\n\nDo it.\n'
525+
skill_file = Path.home() / '.copilot' / 'skills' / 'dummy-skill' / 'SKILL.md'
526+
fs.create_file(skill_file, contents=body)
527+
528+
skills = Copilot().get_skills()
529+
530+
assert skills == [{'path': str(skill_file), 'content': body}]
531+
532+
533+
def test_get_skills_no_skills_dir_returns_empty(fs: FakeFilesystem) -> None:
534+
assert Copilot().get_skills() == []

tests/cli/commands/ai_guardrails/ides/test_cursor.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from typing import Any
66
from unittest.mock import patch
77

8+
from pyfakefs.fake_filesystem import FakeFilesystem
9+
810
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
911
from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor
1012
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -153,3 +155,20 @@ def test_session_context_no_config_returns_empty() -> None:
153155
global_config_file, plugins = Cursor().get_session_context()
154156
assert global_config_file is None
155157
assert plugins == {}
158+
159+
160+
# skills
161+
162+
163+
def test_get_skills_reads_user_scope_skills(fs: FakeFilesystem) -> None:
164+
body = '---\nname: dummy-skill\ndescription: Dummy.\n---\n\nDo it.\n'
165+
skill_file = Path.home() / '.cursor' / 'skills' / 'dummy-skill' / 'SKILL.md'
166+
fs.create_file(skill_file, contents=body)
167+
168+
skills = Cursor().get_skills()
169+
170+
assert skills == [{'path': str(skill_file), 'content': body}]
171+
172+
173+
def test_get_skills_no_skills_dir_returns_empty(fs: FakeFilesystem) -> None:
174+
assert Cursor().get_skills() == []

tests/cli/commands/ai_guardrails/test_session_start_command.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,26 @@ def _isolated_session_context_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPa
3232

3333

3434
@pytest.fixture(autouse=True)
35-
def _no_local_skills(monkeypatch: pytest.MonkeyPatch) -> None:
36-
"""Keep the skills sweep away from the developer's real ~/.claude/skills.
35+
def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
36+
"""Point every home-relative lookup at a scratch directory.
3737
38-
Unlike the MCP collectors, which these tests stub per IDE, the skills sweep walks the
39-
filesystem directly - so without this every assertion would depend on whoever ran it.
40-
A test that cares about skills overrides ``collect_all_skills`` itself.
38+
The skills sweep walks the filesystem rather than going through a collector these tests
39+
stub, so without this it would read the real ~/.claude/skills of whoever ran it.
4140
"""
42-
monkeypatch.setattr(_session_start_mod, 'collect_all_skills', list)
41+
home = tmp_path / 'home'
42+
home.mkdir()
43+
monkeypatch.setattr(Path, 'home', lambda: home)
44+
45+
return home
46+
47+
48+
def _write_claude_skill(home: Path, name: str, content: str) -> Path:
49+
"""Create a user-scope Claude Code skill under the isolated home and return its path."""
50+
skill_file = home / '.claude' / 'skills' / name / 'SKILL.md'
51+
skill_file.parent.mkdir(parents=True, exist_ok=True)
52+
skill_file.write_text(content, encoding='utf-8')
53+
54+
return skill_file
4355

4456

4557
# Auth tests
@@ -620,16 +632,16 @@ def test_reports_skill_files(
620632
mock_collect: MagicMock,
621633
mock_load_config: MagicMock,
622634
mock_ctx: MagicMock,
623-
monkeypatch: pytest.MonkeyPatch,
635+
_isolated_home: Path,
624636
) -> None:
625637
"""User-scope skills ride alongside the MCP inventory in the same report."""
626638
mock_get_auth.return_value = MagicMock(tenant_id='tenant-1')
627639
mock_ai_client = MagicMock()
628640
mock_get_client.return_value = mock_ai_client
629641
mock_collect.return_value = ({}, {})
630-
skill_path = '/home/u/.claude/skills/dummy-skill/SKILL.md'
631-
skills = [{'path': skill_path, 'content': '---\nname: dummy-skill\n---\nBody.\n'}]
632-
monkeypatch.setattr(_session_start_mod, 'collect_all_skills', lambda: skills)
642+
content = '---\nname: dummy-skill\n---\nBody.\n'
643+
skill_file = _write_claude_skill(_isolated_home, 'dummy-skill', content)
644+
skills = [{'path': str(skill_file), 'content': content}]
633645

634646
payload = {'session_id': 'session-123'}
635647

@@ -659,17 +671,16 @@ def test_editing_a_skill_re_reports(
659671
mock_collect: MagicMock,
660672
mock_load_config: MagicMock,
661673
mock_ctx: MagicMock,
662-
monkeypatch: pytest.MonkeyPatch,
674+
_isolated_home: Path,
663675
) -> None:
664676
"""Skill bodies are part of the dedup digest, so an edit sends a fresh report."""
665677
mock_get_auth.return_value = MagicMock(tenant_id='tenant-1')
666678
mock_ai_client = MagicMock()
667679
mock_get_client.return_value = mock_ai_client
668680
mock_collect.return_value = ({}, {})
669-
path = '/home/u/.claude/skills/dummy-skill/SKILL.md'
670681
payload = json.dumps({'session_id': 'session-123'})
671682

672-
monkeypatch.setattr(_session_start_mod, 'collect_all_skills', lambda: [{'path': path, 'content': 'first'}])
683+
_write_claude_skill(_isolated_home, 'dummy-skill', 'first')
673684
with patch('sys.stdin', new=StringIO(payload)):
674685
session_start_command(mock_ctx, ide='claude-code')
675686

@@ -678,7 +689,7 @@ def test_editing_a_skill_re_reports(
678689
session_start_command(mock_ctx, ide='claude-code')
679690
assert mock_ai_client.report_session_context.call_count == 1
680691

681-
monkeypatch.setattr(_session_start_mod, 'collect_all_skills', lambda: [{'path': path, 'content': 'edited'}])
692+
_write_claude_skill(_isolated_home, 'dummy-skill', 'edited')
682693
with patch('sys.stdin', new=StringIO(payload)):
683694
session_start_command(mock_ctx, ide='claude-code')
684695
assert mock_ai_client.report_session_context.call_count == 2

0 commit comments

Comments
 (0)