Skip to content
Merged
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
7 changes: 3 additions & 4 deletions skill_manager/application/skills/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,9 @@ def set_skill_all_harnesses(self, skill_ref: str, target: str) -> dict[str, obje
failures: list[dict[str, str]] = []
flipped_any = False

# Bulk set-all only targets harnesses whose CLI is installed on the
# system. Enabling on an uninstalled harness would write a symlink
# into a folder no runtime reads, which is misleading and happens
# to cascade across overlapping discovery roots in the catalog.
# Bulk set-all only targets harnesses that are installed or otherwise
# interactable. Enabling on an unavailable harness would write a
# symlink into a folder no runtime reads, which is misleading.
for adapter in self.read_models.enabled_installed_adapters():
has_binding = adapter.has_binding(entry.package_dir)
if target == "enabled" and has_binding:
Expand Down
3 changes: 0 additions & 3 deletions skill_manager/harness/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,6 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition
"skills": FileTreeBindingProfile(
managed_env="SKILL_MANAGER_CURSOR_ROOT",
managed_default=lambda context: context.home / ".cursor" / "skills",
managed_candidates=(
lambda context: context.home / ".cursor" / "skills-cursor",
),
availability="cli_or_app",
app_probe_paths=(
lambda _context: Path("/Applications/Cursor.app"),
Expand Down
5 changes: 0 additions & 5 deletions skill_manager/harness/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ class FileTreeBindingProfile:
shape: Literal["file-tree"] = "file-tree"
managed_env: str | None = None
managed_default: PathResolver | None = None
managed_candidates: tuple[PathResolver, ...] = ()
discovery_roots: tuple[FileTreeDiscoveryRoot, ...] = ()
availability: FileTreeAvailability = "cli"
app_probe_paths: tuple[PathResolver, ...] = ()
Expand All @@ -41,10 +40,6 @@ def resolve_managed_root(self, context: ResolutionContext) -> Path:
override = context.env.get(self.managed_env)
if override:
return Path(override)
for resolver in self.managed_candidates:
candidate = resolver(context)
if candidate.is_dir():
return candidate
return self.managed_default(context)


Expand Down
44 changes: 34 additions & 10 deletions tests/integration/test_skills_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def seed_unmanage_fixture(spec):
path.symlink_to(target)


def seed_cursor_owned_skill_fixture(spec):
seed_skill_package(spec.cursor_owned_root, "cursor-built", "Cursor Built")


class SkillsMutationTests(unittest.TestCase):
def test_enable_managed_skill_creates_symlink(self) -> None:
with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness:
Expand Down Expand Up @@ -149,16 +153,16 @@ def test_set_skill_harnesses_rejects_invalid_target(self) -> None:
expected_status=422,
)

def test_set_skill_harnesses_only_targets_installed_harnesses(self) -> None:
def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None:
"""Bulk set-all must not write symlinks into folders no runtime reads.

With only codex + claude CLIs available on PATH, enabling-all should
produce symlinks in those two managed roots only, regardless of how
many harnesses are supported in the catalog.
produce symlinks in those two managed roots plus any valid app-probed
harnesses, regardless of how many harnesses are supported in the catalog.
"""
with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness:
# Simulate a machine that only has codex + claude installed by
# removing the other CLI stubs from the fake PATH.
# Simulate missing non-core CLIs by removing their stubs from the
# fake PATH. Cursor may still be available through its app probe.
for cli in ("cursor-agent", "opencode", "openclaw"):
stub = harness.spec.bin_dir / cli
if stub.exists():
Expand All @@ -173,7 +177,6 @@ def test_set_skill_harnesses_only_targets_installed_harnesses(self) -> None:
installed_by_harness = {col["harness"]: col["installed"] for col in skills["harnessColumns"]}
self.assertTrue(installed_by_harness["codex"])
self.assertTrue(installed_by_harness["claude"])
self.assertFalse(installed_by_harness["cursor"])
self.assertFalse(installed_by_harness["opencode"])
self.assertFalse(installed_by_harness["openclaw"])

Expand All @@ -184,12 +187,18 @@ def test_set_skill_harnesses_only_targets_installed_harnesses(self) -> None:

self.assertTrue(result["ok"])
self.assertEqual(result["failed"], [])
# Only installed harnesses should flip.
self.assertEqual(set(result["succeeded"]), {"codex", "claude"})
# Only installed or otherwise interactable harnesses should flip.
expected = {"codex", "claude"}
if installed_by_harness["cursor"]:
expected.add("cursor")
self.assertEqual(set(result["succeeded"]), expected)
self.assertTrue((harness.spec.codex_root / "shared-audit").is_symlink())
self.assertTrue((harness.spec.claude_root / "shared-audit").is_symlink())
# Uninstalled harness folders remain untouched.
self.assertFalse((harness.spec.cursor_root / "shared-audit").exists())
if installed_by_harness["cursor"]:
self.assertTrue((harness.spec.cursor_root / "shared-audit").is_symlink())
else:
self.assertFalse((harness.spec.cursor_root / "shared-audit").exists())
# Unavailable harness folders remain untouched.
self.assertFalse((harness.spec.opencode_root / "shared-audit").exists())
self.assertFalse((harness.spec.openclaw_managed_root / "shared-audit").exists())

Expand Down Expand Up @@ -220,6 +229,21 @@ def test_manage_all_skills_centralizes_all_found_local_rows(self) -> None:
self.assertEqual(result["failures"], [])
self.assertEqual(refreshed["summary"]["unmanaged"], 0)

def test_manage_all_ignores_cursor_owned_skills_cursor_directory(self) -> None:
with AppTestHarness(fixture_factory=seed_cursor_owned_skill_fixture) as harness:
skills = harness.get_json("/api/skills")

self.assertNotIn("Cursor Built", [row["name"] for row in skills["rows"]])

result = harness.post_json("/api/skills/manage-all")

self.assertTrue(result["ok"])
self.assertEqual(result["managedCount"], 0)
self.assertEqual(result["failures"], [])
self.assertTrue((harness.spec.cursor_owned_root / "cursor-built" / "SKILL.md").is_file())
self.assertFalse((harness.spec.cursor_owned_root / "cursor-built").is_symlink())
self.assertFalse((harness.spec.cursor_root / "cursor-built").exists())

def test_manage_rejects_missing_harness_install_before_creating_bindings(self) -> None:
with AppTestHarness(mixed=True) as harness:
(harness.spec.bin_dir / "codex").unlink()
Expand Down
4 changes: 4 additions & 0 deletions tests/support/fake_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def claude_root(self) -> Path:
def cursor_root(self) -> Path:
return self.home / ".cursor" / "skills"

@property
def cursor_owned_root(self) -> Path:
return self.home / ".cursor" / "skills-cursor"

@property
def opencode_root(self) -> Path:
return self.xdg_config_home / "opencode" / "skills"
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/test_skills_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,40 @@ def test_adapter_reports_missing_cli_as_not_installed(self) -> None:
self.assertFalse(openclaw.status().installed)
self.assertEqual(openclaw.scan().skills, ())

def test_cursor_skills_use_skills_root_and_ignore_skills_cursor(self) -> None:
with TemporaryDirectory() as temp_dir:
spec = create_fake_home_spec(Path(temp_dir))
seed_skill_package(spec.cursor_root, "managed-cursor", "Managed Cursor")
seed_skill_package(spec.cursor_owned_root, "cursor-built", "Cursor Built")

cursor = _adapter("cursor", spec)
scan = cursor.scan()

self.assertEqual(cursor.managed_root, spec.cursor_root)
self.assertEqual(
[skill.package.declared_name for skill in scan.skills],
["Managed Cursor"],
)

def test_cursor_app_probe_keeps_skills_adapter_installed_without_cli(self) -> None:
with TemporaryDirectory() as temp_dir:
spec = create_fake_home_spec(Path(temp_dir))
(spec.bin_dir / "cursor-agent").unlink()
(spec.home / "Applications" / "Cursor.app").mkdir(parents=True)

cursor = _adapter("cursor", spec)
kernel = HarnessKernelService.from_environment(
spec.env(),
support_store=HarnessSupportStore(spec.root / "settings.json"),
)
cursor_status = next(
status for status in kernel.harness_statuses() if status.harness == "cursor"
)

self.assertTrue(cursor.status().installed)
self.assertTrue(cursor_status.installed)
self.assertEqual(cursor_status.managed_location, spec.cursor_root)

def test_enable_creates_symlink(self) -> None:
with TemporaryDirectory() as temp_dir:
spec = create_fake_home_spec(Path(temp_dir))
Expand Down