Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/capacium/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class FrameworkAdapter(ABC):
def install_capability(self, cap_name: str, version: str, source_dir: Path, owner: str = "global", kind: str = "skill") -> bool:
if kind == "mcp-server":
return self.install_mcp_server(cap_name, version, source_dir, owner)
from ..models import SKILL_LAYER_KIND_VALUES
if kind not in SKILL_LAYER_KIND_VALUES:
# Kind-placement contract (V6): bundle/connector-pack roots are
# containers — their members get placed individually; the root
# itself produces no client artifacts.
return True
return self.install_skill(cap_name, version, source_dir, owner)

def remove_capability(self, cap_name: str, owner: str = "global", kind: str = "skill") -> bool:
Expand Down
86 changes: 61 additions & 25 deletions src/capacium/adapters/cursor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Cursor adapter — Skills + MCP.

Cursor supports SKILL.md via .cursor/skills/ (project-only) since 2026.
MCP: .cursor/mcp.json (project-local preferred, global fallback).
MCP: .cursor/mcp.json — project-local only with an explicit project root
(V7/STAB-006: never implicit Path.cwd()), global ~/.cursor/mcp.json
otherwise.
"""
import json
import shutil
Expand All @@ -11,6 +13,7 @@
from ..storage import StorageManager
from ..symlink_manager import SymlinkManager
from ..manifest import Manifest
from ..utils.project_scope import get_project_root
from .base import FrameworkAdapter, _cap_id, ensure_package_dir
from .mcp_config_patcher import McpConfigPatcher

Expand All @@ -22,22 +25,41 @@ class CursorAdapter(FrameworkAdapter):
def __init__(self):
self.storage = StorageManager()
self.symlink_manager = SymlinkManager()
self._skills_dir = Path.cwd() / ".cursor" / "skills"
self.project_mcp_path = Path.cwd() / ".cursor" / "mcp.json"
self.global_mcp_path = Path.home() / ".cursor" / "mcp.json"
self._legacy_rules_dir = Path.cwd() / ".cursor" / "rules"
self._legacy_global_rules_dir = Path.home() / ".cursor" / "rules"

@property
def skills_dir(self) -> Path:
return self._skills_dir
def project_root(self) -> Optional[Path]:
return get_project_root()

def install_skill(self, cap_name: str, version: str, source_dir: Path, owner: str = "global") -> bool:
self.skills_dir.mkdir(parents=True, exist_ok=True)
@property
def skills_dir(self) -> Optional[Path]:
root = self.project_root
return root / ".cursor" / "skills" if root else None

@property
def project_mcp_path(self) -> Optional[Path]:
root = self.project_root
return root / ".cursor" / "mcp.json" if root else None

@property
def _legacy_rules_dir(self) -> Optional[Path]:
root = self.project_root
return root / ".cursor" / "rules" if root else None

def install_skill(self, cap_name: str, version: str, source_dir: Path, owner: str = "global") -> bool:
package_dir = ensure_package_dir(self.storage, cap_name, version, source_dir, owner=owner)

link_path = self.skills_dir / _cap_id(cap_name, owner)
skills_dir = self.skills_dir
if skills_dir is None:
# Cursor skills are project-scoped; without an explicit project
# root we must not write into the current working directory.
print(f" cursor: skill '{cap_name}' cached only — pass --project "
"<path> to link it into a project's .cursor/skills.")
return True
skills_dir.mkdir(parents=True, exist_ok=True)

link_path = skills_dir / _cap_id(cap_name, owner)
success = self.symlink_manager.create_symlink(package_dir, link_path)

metadata_path = package_dir / ".capacium-meta.json"
Expand All @@ -47,15 +69,19 @@ def install_skill(self, cap_name: str, version: str, source_dir: Path, owner: st
return success

def remove_skill(self, cap_name: str, owner: str = "global") -> bool:
link_path = self.skills_dir / _cap_id(cap_name, owner)
if link_path.exists():
if link_path.is_symlink():
self.symlink_manager.remove_symlink(link_path)
elif link_path.is_dir():
shutil.rmtree(link_path)
else:
link_path.unlink()
skills_dir = self.skills_dir
if skills_dir is not None:
link_path = skills_dir / _cap_id(cap_name, owner)
if link_path.exists():
if link_path.is_symlink():
self.symlink_manager.remove_symlink(link_path)
elif link_path.is_dir():
shutil.rmtree(link_path)
else:
link_path.unlink()
for legacy_dir in (self._legacy_rules_dir, self._legacy_global_rules_dir):
if legacy_dir is None:
continue
legacy_path = legacy_dir / f"{cap_name}.mdc"
if legacy_path.exists():
try:
Expand All @@ -65,9 +91,11 @@ def remove_skill(self, cap_name: str, owner: str = "global") -> bool:
return True

def capability_exists(self, cap_name: str, owner: str = "global") -> bool:
link_path = self.skills_dir / _cap_id(cap_name, owner)
if link_path.exists() and link_path.is_symlink():
return True
skills_dir = self.skills_dir
if skills_dir is not None:
link_path = skills_dir / _cap_id(cap_name, owner)
if link_path.exists() and link_path.is_symlink():
return True
return McpConfigPatcher.mcp_server_exists_json(
self._get_mcp_path(), McpConfigPatcher.build_server_key(cap_name, owner), self.MCP_SECTION_KEY,
)
Expand All @@ -93,15 +121,19 @@ def remove_mcp_server(self, cap_name: str, owner: str = "global") -> bool:
)

def list_capabilities(self) -> List[str]:
if not self.skills_dir.exists():
skills_dir = self.skills_dir
if skills_dir is None or not skills_dir.exists():
return []
return sorted(
d.name for d in self.skills_dir.iterdir()
d.name for d in skills_dir.iterdir()
if d.is_dir() and not d.name.startswith(".")
)

def get_capability_metadata(self, cap_name: str) -> Optional[Dict[str, Any]]:
link_path = self.skills_dir / cap_name
skills_dir = self.skills_dir
if skills_dir is None:
return None
link_path = skills_dir / cap_name
if link_path.exists() and link_path.is_symlink():
target_dir = link_path.resolve()
metadata_path = target_dir / ".capacium-meta.json"
Expand All @@ -111,6 +143,10 @@ def get_capability_metadata(self, cap_name: str) -> Optional[Dict[str, Any]]:
return None

def _get_mcp_path(self) -> Path:
if self.project_mcp_path.parent.exists():
return self.project_mcp_path
"""Project config only with an explicit project root — the previous
cwd-probing wrote mcp.json/.bak files into package directories
whenever they happened to contain a .cursor folder (V7)."""
project_path = self.project_mcp_path
if project_path is not None:
return project_path
return self.global_mcp_path
Loading
Loading