641 lines
26 KiB
Python
641 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent.spec.skill_sources import (
|
|
SkillSourceContext,
|
|
_harness_family,
|
|
resolve_harness_skills,
|
|
)
|
|
|
|
|
|
def _write_skill(skills_dir: Path, name: str, *, user_invocable: bool | None = None) -> None:
|
|
"""
|
|
Write a minimal ``<skills_dir>/<name>/SKILL.md`` with valid frontmatter.
|
|
|
|
:param user_invocable: When not ``None``, emit a ``user-invocable:``
|
|
frontmatter line with this value. Omitted (the default) leaves the
|
|
field absent, which parses as user-invocable.
|
|
"""
|
|
d = skills_dir / name
|
|
d.mkdir(parents=True)
|
|
ui = "" if user_invocable is None else f"user-invocable: {str(user_invocable).lower()}\n"
|
|
(d / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {name} desc\n{ui}---\nbody\n")
|
|
|
|
|
|
def _ctx(root: Path, home: Path, skills_filter: str | list[str] = "all") -> SkillSourceContext:
|
|
"""Build a context with a single discovery root and a pinned home."""
|
|
return SkillSourceContext(
|
|
roots=(root,), home=home, skills_filter=skills_filter, bundle_dir=None
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"harness,expected",
|
|
[
|
|
("claude-sdk", "claude"),
|
|
("claude_sdk", "claude"), # in-process SDK executor-type spelling (B1)
|
|
("claude-native", "claude"),
|
|
("native-claude", "claude"),
|
|
("agents_sdk", None), # underscore non-claude executor type stays unmapped
|
|
("codex", "codex"),
|
|
("codex-native", "codex"),
|
|
("native-codex", "codex"),
|
|
("cursor", "cursor"),
|
|
("cursor-native", "cursor"),
|
|
("pi", "pi"),
|
|
("pi-native", "pi"),
|
|
("native-pi", "pi"),
|
|
("openai-agents", None),
|
|
("antigravity", None),
|
|
("qwen", None),
|
|
(None, None),
|
|
("", None),
|
|
],
|
|
)
|
|
def test_harness_family(harness: str | None, expected: str | None) -> None:
|
|
assert _harness_family(harness) == expected
|
|
|
|
|
|
def test_unknown_harness_falls_back_to_generic_host_walk(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
(home / ".claude" / "skills").mkdir(parents=True)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
workspace = tmp_path / "ws"
|
|
_write_skill(workspace / ".claude" / "skills", "ws-skill")
|
|
|
|
out = resolve_harness_skills(_ctx(workspace, home), "openai-agents")
|
|
assert [s.name for s in out] == ["ws-skill"]
|
|
|
|
|
|
def test_none_harness_falls_back_to_generic_host_walk(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
(home / ".claude" / "skills").mkdir(parents=True)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
workspace = tmp_path / "ws"
|
|
_write_skill(workspace / ".claude" / "skills", "ws-skill")
|
|
|
|
out = resolve_harness_skills(_ctx(workspace, home), None)
|
|
assert [s.name for s in out] == ["ws-skill"]
|
|
|
|
|
|
def _claude_home_with_plugin(
|
|
home: Path, *, plugin: str, marketplace: str, skill: str, enabled: bool
|
|
) -> Path:
|
|
"""Seed a fake ~/.claude with one installed plugin and an enablement flag."""
|
|
install = home / ".claude" / "plugins" / "cache" / marketplace / plugin / "1.0.0"
|
|
_write_skill(install / "skills", skill)
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {f"{plugin}@{marketplace}": enabled}})
|
|
)
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 2,
|
|
"plugins": {
|
|
f"{plugin}@{marketplace}": [
|
|
{"scope": "user", "installPath": str(install), "version": "1.0.0"}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
return home
|
|
|
|
|
|
def test_claude_provider_surfaces_enabled_plugin_skill_namespaced(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
home = _claude_home_with_plugin(
|
|
tmp_path / "home",
|
|
plugin="superpowers",
|
|
marketplace="claude-plugins-official",
|
|
skill="using-superpowers",
|
|
enabled=True,
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")
|
|
assert "superpowers:using-superpowers" in [s.name for s in out]
|
|
|
|
|
|
def test_claude_sdk_underscore_harness_surfaces_plugin_skills(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
B1 regression: the in-process Claude SDK harness flows in as the
|
|
underscore executor-type spelling ``claude_sdk``; it must still
|
|
resolve to the claude family and surface enabled-plugin skills.
|
|
"""
|
|
home = _claude_home_with_plugin(
|
|
tmp_path / "home",
|
|
plugin="superpowers",
|
|
marketplace="mkt",
|
|
skill="using-superpowers",
|
|
enabled=True,
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude_sdk")
|
|
assert "superpowers:using-superpowers" in [s.name for s in out]
|
|
|
|
|
|
def test_claude_provider_excludes_disabled_plugin(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
home = _claude_home_with_plugin(
|
|
tmp_path / "home",
|
|
plugin="superpowers",
|
|
marketplace="claude-plugins-official",
|
|
skill="using-superpowers",
|
|
enabled=False,
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-sdk")
|
|
assert "superpowers:using-superpowers" not in [s.name for s in out]
|
|
|
|
|
|
def test_claude_managed_plugin_surfaces_when_absent_from_settings(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
A plugin force-enabled only via ``managed_plugins.json`` — with no
|
|
``enabledPlugins`` entry in any settings file — is still surfaced.
|
|
Claude Code's managed (policy) tier enables it regardless of settings.
|
|
"""
|
|
home = tmp_path / "home"
|
|
install = home / ".claude" / "plugins" / "cache" / "mkt" / "secrev" / "1.0.0"
|
|
_write_skill(install / "skills", "do-review")
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(json.dumps({"enabledPlugins": {}}))
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 2,
|
|
"plugins": {
|
|
"secrev@mkt": [
|
|
{"scope": "user", "installPath": str(install), "version": "1.0.0"}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
(home / ".claude" / "plugins" / "managed_plugins.json").write_text(
|
|
json.dumps({"managed_plugins": ["secrev@mkt"]})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-sdk")
|
|
assert "secrev:do-review" in [s.name for s in out]
|
|
|
|
|
|
def test_claude_managed_plugin_overrides_settings_disable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
``managed_plugins.json`` force-enables even when ``enabledPlugins``
|
|
explicitly disables the same plugin: the managed tier is highest
|
|
precedence in Claude Code and cannot be overridden by a settings toggle.
|
|
"""
|
|
home = _claude_home_with_plugin(
|
|
tmp_path / "home",
|
|
plugin="secrev",
|
|
marketplace="mkt",
|
|
skill="do-review",
|
|
enabled=False,
|
|
)
|
|
(home / ".claude" / "plugins" / "managed_plugins.json").write_text(
|
|
json.dumps({"managed_plugins": ["secrev@mkt"]})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-sdk")
|
|
assert "secrev:do-review" in [s.name for s in out]
|
|
|
|
|
|
def test_claude_provider_tolerates_missing_files(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
(home / ".claude" / "skills").mkdir(parents=True)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")
|
|
assert out == [] # no plugins, empty host walk → no crash
|
|
|
|
|
|
def test_claude_provider_tolerates_malformed_json(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
(home / ".claude").mkdir(parents=True)
|
|
(home / ".claude" / "settings.json").write_text("{not json")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")
|
|
assert out == []
|
|
|
|
|
|
def test_claude_provider_none_filter_suppresses_plugins(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""``skills_filter="none"`` is hermetic: no plugin skills leak in."""
|
|
home = _claude_home_with_plugin(
|
|
tmp_path / "home",
|
|
plugin="superpowers",
|
|
marketplace="claude-plugins-official",
|
|
skill="using-superpowers",
|
|
enabled=True,
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
ctx = SkillSourceContext(
|
|
roots=(tmp_path / "ws",), home=home, skills_filter="none", bundle_dir=None
|
|
)
|
|
assert resolve_harness_skills(ctx, "claude-native") == []
|
|
|
|
|
|
def test_claude_provider_list_filter_selects_by_bare_name(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A list filter selects plugin skills by their bare (un-namespaced) name."""
|
|
home = tmp_path / "home"
|
|
install = home / ".claude" / "plugins" / "cache" / "mkt" / "superpowers" / "1.0.0"
|
|
_write_skill(install / "skills", "using-superpowers")
|
|
_write_skill(install / "skills", "writing-plans")
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"superpowers@mkt": True}})
|
|
)
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps({"version": 2, "plugins": {"superpowers@mkt": [{"installPath": str(install)}]}})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
ctx = SkillSourceContext(
|
|
roots=(tmp_path / "ws",),
|
|
home=home,
|
|
skills_filter=["writing-plans"],
|
|
bundle_dir=None,
|
|
)
|
|
names = [s.name for s in resolve_harness_skills(ctx, "claude-native")]
|
|
assert names == ["superpowers:writing-plans"]
|
|
|
|
|
|
def test_codex_provider_surfaces_home_codex_skills(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
_write_skill(home / ".codex" / "skills", "using-superpowers")
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "codex-native")
|
|
assert "using-superpowers" in [s.name for s in out]
|
|
|
|
|
|
def test_codex_provider_respects_none_filter(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
_write_skill(home / ".codex" / "skills", "using-superpowers")
|
|
ctx = SkillSourceContext(
|
|
roots=(tmp_path / "ws",), home=home, skills_filter="none", bundle_dir=None
|
|
)
|
|
assert resolve_harness_skills(ctx, "codex") == []
|
|
|
|
|
|
def test_cursor_provider_surfaces_skills_by_dir_name(tmp_path: Path) -> None:
|
|
"""
|
|
Cursor names a skill by its ``plugin--skill`` directory (collision-safe
|
|
across the many ``fe-*`` plugins), not the bare frontmatter ``name``.
|
|
"""
|
|
home = tmp_path / "home"
|
|
skill_dir = home / ".cursor" / "skills" / "fe-epl-tools--metric-view-adoption"
|
|
skill_dir.mkdir(parents=True)
|
|
# Frontmatter name is the BARE name; the dir carries the namespace.
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\nname: metric-view-adoption\ndescription: MV adoption workflow.\n---\nbody\n"
|
|
)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "cursor-native")
|
|
assert "fe-epl-tools--metric-view-adoption" in [s.name for s in out]
|
|
|
|
|
|
def test_cursor_provider_respects_none_filter(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
skill_dir = home / ".cursor" / "skills" / "fe-epl-tools--metric-view-adoption"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\nname: metric-view-adoption\ndescription: d\n---\nbody\n"
|
|
)
|
|
ctx = SkillSourceContext(
|
|
roots=(tmp_path / "ws",), home=home, skills_filter="none", bundle_dir=None
|
|
)
|
|
assert resolve_harness_skills(ctx, "cursor") == []
|
|
|
|
|
|
def test_pi_provider_is_bundle_only_noop(tmp_path: Path) -> None:
|
|
"""
|
|
Pi loads skills from the bundle (already carried by ``spec.skills``,
|
|
the base layer) and auto-discovers host skills internally — but
|
|
omnigent can't enumerate Pi's host-skill layout to name/resolve them,
|
|
so the provider surfaces nothing extra (under-report rather than list
|
|
a command that won't resolve).
|
|
"""
|
|
home = tmp_path / "home"
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "pi-native")
|
|
assert out == []
|
|
|
|
|
|
def test_pi_session_does_not_inherit_generic_claude_host_walk(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
A Pi session must not surface ``~/.claude/skills`` host skills (those
|
|
belong to Claude). Proves Pi has an explicit provider rather than
|
|
falling through to the generic host walk.
|
|
"""
|
|
home = tmp_path / "home"
|
|
_write_skill(home / ".claude" / "skills", "claude-only-skill")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "pi-native")
|
|
assert "claude-only-skill" not in [s.name for s in out]
|
|
|
|
|
|
def test_codex_provider_filters_user_invocable_false(tmp_path: Path) -> None:
|
|
"""Codex (and all harnesses) must not surface user-invocable:false skills."""
|
|
home = tmp_path / "home"
|
|
_write_skill(home / ".codex" / "skills", "triage", user_invocable=False)
|
|
_write_skill(home / ".codex" / "skills", "account-review-deck") # absent -> shown
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "codex-native")]
|
|
assert "triage" not in names
|
|
assert "account-review-deck" in names
|
|
|
|
|
|
def test_cursor_provider_filters_user_invocable_false(tmp_path: Path) -> None:
|
|
"""A user-invocable:false cursor skill is dropped (consistent across harnesses)."""
|
|
home = tmp_path / "home"
|
|
_write_skill(home / ".cursor" / "skills", "sra--triage", user_invocable=False)
|
|
_write_skill(home / ".cursor" / "skills", "fe--report", user_invocable=True)
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "cursor-native")]
|
|
assert "sra--triage" not in names
|
|
assert "fe--report" in names
|
|
|
|
|
|
def test_claude_plugin_provider_filters_user_invocable_false(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An enabled plugin's user-invocable:false skill is not surfaced."""
|
|
home = tmp_path / "home"
|
|
install = home / ".claude" / "plugins" / "cache" / "mkt" / "sra" / "1.0.0"
|
|
_write_skill(install / "skills", "triage", user_invocable=False)
|
|
_write_skill(install / "skills", "aisec-review", user_invocable=True)
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sra@mkt": True}})
|
|
)
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps({"version": 2, "plugins": {"sra@mkt": [{"installPath": str(install)}]}})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")]
|
|
assert "sra:triage" not in names
|
|
assert "sra:aisec-review" in names
|
|
|
|
|
|
def test_codex_provider_surfaces_skills_by_dir_name(tmp_path: Path) -> None:
|
|
"""
|
|
When a Codex skill's directory name differs from its frontmatter name,
|
|
the menu surfaces the DIRECTORY name — the command Codex registers and
|
|
the name the executor symlinks under (so menu label == runnable name).
|
|
"""
|
|
home = tmp_path / "home"
|
|
skill_dir = home / ".codex" / "skills" / "sra--triage"
|
|
skill_dir.mkdir(parents=True)
|
|
# Frontmatter name is bare; directory carries the namespace.
|
|
(skill_dir / "SKILL.md").write_text("---\nname: triage\ndescription: d\n---\nbody\n")
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "codex-native")]
|
|
assert "sra--triage" in names
|
|
assert "triage" not in names
|
|
|
|
|
|
def test_codex_menu_set_matches_executor_linked_set(tmp_path: Path) -> None:
|
|
"""
|
|
Invariant: the menu provider and the executor's symlink path select the
|
|
SAME skill set from the SAME sources (both via codex_skill_sources +
|
|
select_codex_skill_dirs) — so a / menu entry is always actually linked.
|
|
"""
|
|
from omnigent.inner.codex_executor import (
|
|
codex_skill_sources,
|
|
select_codex_skill_dirs,
|
|
)
|
|
|
|
home = tmp_path / "home"
|
|
host = home / ".codex" / "skills"
|
|
for n in ("alpha", "beta--gamma"):
|
|
d = host / n
|
|
d.mkdir(parents=True)
|
|
(d / "SKILL.md").write_text(f"---\nname: {n.split('--')[-1]}\ndescription: d\n---\nx\n")
|
|
|
|
# What the executor would symlink (keys = dir names linked into CODEX_HOME).
|
|
linked = set(select_codex_skill_dirs("all", codex_skill_sources(None, home)))
|
|
# What the menu surfaces.
|
|
menu = {s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "codex-native")}
|
|
assert menu == linked == {"alpha", "beta--gamma"}
|
|
|
|
|
|
def test_claude_provider_project_disable_overrides_global_enable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A plugin enabled globally but disabled in-project is excluded (scope precedence)."""
|
|
home = tmp_path / "home"
|
|
install = home / ".claude" / "plugins" / "cache" / "mkt" / "sp" / "1.0.0"
|
|
_write_skill(install / "skills", "using-superpowers")
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": True}}) # global: enabled
|
|
)
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps({"version": 2, "plugins": {"sp@mkt": [{"installPath": str(install)}]}})
|
|
)
|
|
# Project root disables it.
|
|
ws = tmp_path / "ws"
|
|
(ws / ".claude").mkdir(parents=True)
|
|
(ws / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": False}})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
ctx = SkillSourceContext(roots=(ws,), home=home, skills_filter="all", bundle_dir=None)
|
|
names = [s.name for s in resolve_harness_skills(ctx, "claude-native")]
|
|
assert "sp:using-superpowers" not in names
|
|
|
|
|
|
def test_claude_provider_install_path_from_later_scope_entry(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""installPath is taken from the first entry that has one, not blindly entries[0]."""
|
|
home = tmp_path / "home"
|
|
install = home / ".claude" / "plugins" / "cache" / "mkt" / "sp" / "1.0.0"
|
|
_write_skill(install / "skills", "using-superpowers")
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": True}})
|
|
)
|
|
# First entry lacks installPath; the second carries it.
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 2,
|
|
"plugins": {
|
|
"sp@mkt": [
|
|
{"scope": "project"},
|
|
{"scope": "user", "installPath": str(install)},
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")]
|
|
assert "sp:using-superpowers" in names
|
|
|
|
|
|
def _claude_home_plugin_installed(home: Path, key: str, skill: str) -> None:
|
|
"""Install (not toggle) one plugin + skill under a fake ~/.claude."""
|
|
install = home / ".claude" / "plugins" / "cache" / "mkt" / key.split("@")[0] / "1.0.0"
|
|
_write_skill(install / "skills", skill)
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps({"version": 2, "plugins": {key: [{"installPath": str(install)}]}})
|
|
)
|
|
|
|
|
|
def test_claude_local_settings_disable_overrides_settings_json_enable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""settings.local.json (local override) wins over settings.json within a scope."""
|
|
home = tmp_path / "home"
|
|
_claude_home_plugin_installed(home, "sp@mkt", "using-superpowers")
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": True}}) # enabled in shared
|
|
)
|
|
(home / ".claude" / "settings.local.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": False}}) # disabled locally
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")]
|
|
assert "sp:using-superpowers" not in names
|
|
|
|
|
|
def test_claude_local_settings_enable_overrides_settings_json_disable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A plugin enabled only in settings.local.json is surfaced."""
|
|
home = tmp_path / "home"
|
|
_claude_home_plugin_installed(home, "sp@mkt", "using-superpowers")
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": False}})
|
|
)
|
|
(home / ".claude" / "settings.local.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": True}})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")]
|
|
assert "sp:using-superpowers" in names
|
|
|
|
|
|
def test_claude_workspace_settings_win_over_bundle(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Among roots, the workspace (primary) overrides the shipped bundle."""
|
|
home = tmp_path / "home"
|
|
_claude_home_plugin_installed(home, "sp@mkt", "using-superpowers")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
workspace = tmp_path / "ws"
|
|
bundle = tmp_path / "bundle"
|
|
(workspace / ".claude").mkdir(parents=True)
|
|
(bundle / ".claude").mkdir(parents=True)
|
|
# Bundle enables; workspace disables — workspace must win.
|
|
(bundle / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": True}})
|
|
)
|
|
(workspace / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": False}})
|
|
)
|
|
ctx = SkillSourceContext(
|
|
roots=(workspace, bundle), home=home, skills_filter="all", bundle_dir=None
|
|
)
|
|
names = [s.name for s in resolve_harness_skills(ctx, "claude-native")]
|
|
assert "sp:using-superpowers" not in names
|
|
|
|
|
|
def test_non_invocable_host_skill_shadows_same_named_invocable_copy(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
Documented behavior: a workspace skill marked user-invocable:false
|
|
shadows a same-named invocable home skill, so it is filtered out
|
|
entirely rather than falling back to the invocable copy — the
|
|
project's authoritative copy wins (it shadows in execution too).
|
|
"""
|
|
home = tmp_path / "home"
|
|
_write_skill(home / ".claude" / "skills", "x") # home: invocable
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
workspace = tmp_path / "ws"
|
|
_write_skill(workspace / ".claude" / "skills", "x", user_invocable=False) # project: internal
|
|
|
|
out = resolve_harness_skills(_ctx(workspace, home), "claude-native")
|
|
assert "x" not in [s.name for s in out]
|
|
|
|
|
|
def test_claude_provider_string_false_enablement_does_not_enable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A JSON string ``"false"`` must not enable a plugin (only real bools count)."""
|
|
home = tmp_path / "home"
|
|
install = home / ".claude" / "plugins" / "cache" / "mkt" / "sp" / "1.0.0"
|
|
_write_skill(install / "skills", "using-superpowers")
|
|
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": "false"}}) # truthy string, not a bool
|
|
)
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps({"version": 2, "plugins": {"sp@mkt": [{"installPath": str(install)}]}})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")]
|
|
assert "sp:using-superpowers" not in names
|
|
|
|
|
|
def test_claude_provider_skips_install_path_outside_plugins_root(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An installPath escaping ~/.claude/plugins/ is skipped, not scanned."""
|
|
home = tmp_path / "home"
|
|
# Skill lives OUTSIDE the plugins cache root (a tampered/odd manifest).
|
|
outside = tmp_path / "evil"
|
|
_write_skill(outside / "skills", "using-superpowers")
|
|
(home / ".claude" / "plugins").mkdir(parents=True, exist_ok=True)
|
|
(home / ".claude" / "settings.json").write_text(
|
|
json.dumps({"enabledPlugins": {"sp@mkt": True}})
|
|
)
|
|
(home / ".claude" / "plugins" / "installed_plugins.json").write_text(
|
|
json.dumps({"version": 2, "plugins": {"sp@mkt": [{"installPath": str(outside)}]}})
|
|
)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "claude-native")]
|
|
assert "sp:using-superpowers" not in names
|
|
|
|
|
|
def test_cursor_provider_tolerates_unreadable_skills_dir(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An unreadable ~/.cursor/skills must yield [] (lenient), not 500 the menu."""
|
|
home = tmp_path / "home"
|
|
_write_skill(home / ".cursor" / "skills", "fe--report")
|
|
|
|
real_iterdir = Path.iterdir
|
|
|
|
def _boom(self: Path):
|
|
if self.name == "skills" and self.parent.name == ".cursor":
|
|
raise PermissionError("permission denied")
|
|
return real_iterdir(self)
|
|
|
|
monkeypatch.setattr("pathlib.Path.iterdir", _boom)
|
|
# Must not raise.
|
|
out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "cursor-native")
|
|
assert out == []
|