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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ Run from the plugin repo root.
uv sync
uv run pytest
```

## Plugin root

The hooks find the plugin folder from `.claude-plugin/marketplace.json`: a single plugin whose `source` is `./<folder>` is linted, built and checked there, and CI, docs and evals stay outside it. A missing marketplace file or `"./"` means the repository root.
7 changes: 5 additions & 2 deletions src/wraith_tools/build_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import sys
from pathlib import Path

from wraith_tools.layout import plugin_root

KEYS = ("name", "description", "tools")
TARGET = "com.github.copilot/agents"

Expand All @@ -29,8 +31,9 @@ def render_agent(source_text: str, stem: str) -> str:


def build(root: Path, check: bool) -> list[str]:
src_dir = root / "agents"
out_dir = root / TARGET
base = plugin_root(root)
src_dir = base / "agents"
out_dir = base / TARGET
wanted: dict[str, str] = {}
if src_dir.is_dir():
for src in sorted(src_dir.glob("*.md")):
Expand Down
27 changes: 27 additions & 0 deletions src/wraith_tools/layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Find the plugin folder from the marketplace manifest.

A repo without .claude-plugin/marketplace.json, or whose single plugin
source is "./", is its own plugin root, so older layouts keep working.
"""

from __future__ import annotations

import json
from pathlib import Path


def plugin_root(repo: Path) -> Path:
repo = Path(repo)
path = repo / ".claude-plugin" / "marketplace.json"
if not path.is_file():
return repo
plugins = json.loads(path.read_text(encoding="utf-8"))["plugins"]
source = plugins[0]["source"]
if (
not isinstance(source, str)
or not source.startswith("./")
or ".." in Path(source).parts
):
raise ValueError(f"unsupported plugin source: {source!r}")
rel = source[2:].strip("/")
return repo / rel if rel else repo
34 changes: 25 additions & 9 deletions src/wraith_tools/lint_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import yaml

from wraith_tools.layout import plugin_root

SKILL_KEYS = {
"name",
"description",
Expand Down Expand Up @@ -215,7 +217,7 @@ def upstream_scope(root):
yield path, text


def check_text_rules(root, skills, agents, out):
def check_text_rules(root, skills, agents, out, repo=None):
known = {d.name for d in skills} | {a.stem for a in agents}
for sub in ("skills", "agents", "hooks"):
for path in sorted((root / sub).rglob("*")):
Expand All @@ -226,13 +228,16 @@ def check_text_rules(root, skills, agents, out):
for name in sorted(set(WRAITH_RE.findall(text))):
if name not in known:
out.append(f"{rel}: L10 unresolved wraith:{name}")
em_paths = []
for sub in EM_DASH_DIRS:
em_paths += [p for p in (root / sub).rglob("*") if p.is_file()]
em_paths += [root / n for n in EM_DASH_FILES if (root / n).is_file()]
repo = repo or root
bases = sorted({root, repo})
em_paths = set()
for base in bases:
for sub in EM_DASH_DIRS:
em_paths |= {p for p in (base / sub).rglob("*") if p.is_file()}
em_paths |= {base / n for n in EM_DASH_FILES if (base / n).is_file()}
for path in sorted(em_paths):
if EM_DASH in (read_text(path) or ""):
rel = path.relative_to(root).as_posix()
rel = path.relative_to(repo).as_posix()
out.append(f"{rel}: L11 contains em dash")
for path, text in upstream_scope(root):
for token in UPSTREAM_TOKENS:
Expand All @@ -258,7 +263,8 @@ def check_tree(root, out):


def lint(root):
root = Path(root)
repo = Path(root)
root = plugin_root(repo)
out = []
sroot = root / "skills"
skills = (
Expand All @@ -276,9 +282,19 @@ def lint(root):
check_agent(root, agent, out)
if len(skills) > MAX_SKILLS:
out.append(f"skills: L16 {len(skills)} skills (max {MAX_SKILLS})")
check_text_rules(root, skills, agents, out)
check_text_rules(root, skills, agents, out, repo)
check_tree(root, out)
return out
if root == repo:
return out
# Report every path from the repo root. L11 already does; the other
# checks name paths inside the plugin folder.
pre = root.relative_to(repo).as_posix()
return [
m
if ": L11 " in m
else (pre + m[1:] if m.startswith(".:") else f"{pre}/{m}")
for m in out
]


def main(argv=None):
Expand Down
8 changes: 6 additions & 2 deletions src/wraith_tools/plugin_checks/test_hooks_config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from pathlib import Path

from wraith_tools.layout import plugin_root

ROOT = Path.cwd()
PLUGIN = plugin_root(ROOT)


def test_plugin_ships_no_hooks():
# The Claude directory refuses command hooks from unverified orgs.
assert not (ROOT / "hooks").exists()
assert not (ROOT / "com.github.copilot" / "hooks").exists()
for base in {ROOT, PLUGIN}:
assert not (base / "hooks").exists()
assert not (base / "com.github.copilot" / "hooks").exists()
43 changes: 38 additions & 5 deletions src/wraith_tools/plugin_checks/test_manifests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,25 @@

import yaml

from wraith_tools.layout import plugin_root

ROOT = Path.cwd()
PLUGIN = plugin_root(ROOT)
ALLOWED = {
".claude-plugin",
"plugin.json",
"com.github.copilot",
"skills",
"agents",
"assets",
"README.md",
"LICENSE",
"PRIVACY.md",
"TERMS.md",
"SUPPORT.md",
"THIRD_PARTY_NOTICES.md",
}

URL = "https://github.com/wraith-proxy/wraith-skills"
SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
CLAUDE_KEYS = {
Expand All @@ -29,7 +47,9 @@ def load(*parts):


def test_claude_manifest_fields():
data = load(".claude-plugin", "plugin.json")
data = json.loads(
(PLUGIN / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")
)
assert data["name"] == "wraith"
assert data["license"] == "MIT"
assert data["repository"] == URL
Expand All @@ -38,7 +58,7 @@ def test_claude_manifest_fields():


def test_copilot_manifest_fields():
data = load("plugin.json")
data = json.loads((PLUGIN / "plugin.json").read_text(encoding="utf-8"))
assert data["$schema"] == SCHEMA
assert data["name"] == "wraith"
assert data["license"] == "MIT"
Expand All @@ -51,12 +71,25 @@ def test_marketplace_entry():
assert data["name"] == "wraith-proxy"
assert len(data["plugins"]) == 1
assert data["plugins"][0]["name"] == "wraith"
assert data["plugins"][0]["source"] == "./"
assert (ROOT / data["plugins"][0]["source"]).resolve() == PLUGIN.resolve()


def test_manifest_versions_match():
text = (ROOT / ".cz.yaml").read_text(encoding="utf-8")
py = str(yaml.safe_load(text)["commitizen"]["version"])
claude = load(".claude-plugin", "plugin.json")["version"]
copilot = load("plugin.json")["version"]
claude = json.loads(
(PLUGIN / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")
)["version"]
copilot = json.loads((PLUGIN / "plugin.json").read_text(encoding="utf-8"))[
"version"
]
assert py == claude == copilot


def test_plugin_folder_holds_only_plugin_content():
if PLUGIN == ROOT:
return
assert {p.name for p in PLUGIN.iterdir()} <= ALLOWED
assert (PLUGIN / "LICENSE").read_bytes() == (ROOT / "LICENSE").read_bytes()
readme = (PLUGIN / "README.md").read_text(encoding="utf-8")
assert len(readme.split()) >= 40
24 changes: 17 additions & 7 deletions src/wraith_tools/sync_commit_type.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
"""Pick the commit type for an upstream sync from its changed paths.

The marketplace source is the repository root, so everything ships unless
it is listed here. A sync that changes only non-shipped paths is a chore
and does not cut a release.
When the marketplace source is the repository root, everything ships
unless it is listed here. When the plugin lives in a subfolder, only paths
under that folder ship. A sync that changes only non-shipped paths is a
chore and does not cut a release.
"""

from __future__ import annotations

import sys
from pathlib import Path

from wraith_tools.layout import plugin_root

NON_SHIPPED_DIRS = {"scripts", "tests", "evals", "docs", ".github"}
NON_SHIPPED_FILES = {"pyproject.toml", "uv.lock", "upstream-pins.txt"}


def is_shipped(path: str) -> bool:
def is_shipped(path: str, prefix: str = "") -> bool:
if prefix:
return path.startswith(prefix)
head, sep, _ = path.partition("/")
if sep:
return head not in NON_SHIPPED_DIRS
return path not in NON_SHIPPED_FILES and not path.startswith(".")


def commit_type(paths: list[str]) -> str:
return "fix" if any(is_shipped(p) for p in paths) else "chore"
def commit_type(paths: list[str], prefix: str = "") -> str:
shipped = any(is_shipped(p, prefix) for p in paths)
return "fix" if shipped else "chore"


def main() -> None:
repo = Path.cwd()
root = plugin_root(repo)
prefix = "" if root == repo else root.relative_to(repo).as_posix() + "/"
paths = [line.strip() for line in sys.stdin if line.strip()]
print(commit_type(paths))
print(commit_type(paths, prefix))


if __name__ == "__main__":
Expand Down
14 changes: 14 additions & 0 deletions tests/test_build_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,17 @@ def test_main_modes(tmp_path, monkeypatch, capsys):
assert "wrote 1 files" in capsys.readouterr().out
assert build_copilot.main(["--check"]) == 0
assert "copilot agents up to date" in capsys.readouterr().out


def test_build_under_plugin_subfolder(tmp_path):
meta = tmp_path / ".claude-plugin"
meta.mkdir()
(meta / "marketplace.json").write_text(
'{"plugins":[{"source":"./plugin"}]}', encoding="utf-8"
)
(tmp_path / "plugin").mkdir()
make(tmp_path / "plugin", stem="a")
build_copilot.build(tmp_path, False)
out = tmp_path / "plugin/com.github.copilot/agents/a.agent.md"
assert out.exists()
assert not (tmp_path / "com.github.copilot").exists()
32 changes: 32 additions & 0 deletions tests/test_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import json

import pytest

from wraith_tools.layout import plugin_root


def market(repo, source):
path = repo / ".claude-plugin" / "marketplace.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"plugins": [{"source": source}]}))


def test_no_marketplace_means_repo_root(tmp_path):
assert plugin_root(tmp_path) == tmp_path


def test_dot_slash_means_repo_root(tmp_path):
market(tmp_path, "./")
assert plugin_root(tmp_path) == tmp_path


def test_subfolder_source(tmp_path):
market(tmp_path, "./plugin")
assert plugin_root(tmp_path) == tmp_path / "plugin"


@pytest.mark.parametrize("source", ["../x", "plugin", "./a/../..", 3])
def test_unsafe_or_odd_source_is_rejected(tmp_path, source):
market(tmp_path, source)
with pytest.raises(ValueError):
plugin_root(tmp_path)
27 changes: 27 additions & 0 deletions tests/test_lint_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,30 @@ def test_missing_notices_file(tmp_path):
skill(metadata="\n source: SP@abcdef1:a")
)
assert "L7" in rules(lint_skills.lint(tmp_path))


def test_subfolder_plugin_and_root_em_dash(tmp_path):
make_repo(
tmp_path,
{
".claude-plugin/marketplace.json": (
'{"plugins":[{"source":"./plugin"}]}'
),
"plugin/THIRD_PARTY_NOTICES.md": NOTICES,
"plugin/skills/demo/SKILL.md": skill(),
"evals/x.md": f"a {EM} b\n",
},
)
problems = lint_skills.lint(tmp_path)
assert [p for p in problems if p.startswith("evals/x.md: L11")]


def test_subfolder_problems_are_reported_from_the_repo_root(tmp_path):
(tmp_path / ".claude-plugin").mkdir()
(tmp_path / ".claude-plugin" / "marketplace.json").write_text(
'{"plugins": [{"source": "./plugin"}]}'
)
(tmp_path / "plugin" / "skills" / "broken").mkdir(parents=True)
problems = lint_skills.lint(tmp_path)
assert problems
assert all(p.startswith("plugin/") for p in problems), problems
5 changes: 5 additions & 0 deletions tests/test_sync_commit_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,8 @@ def run(text):
assert run("") == "chore"
assert run("uv.lock\nscripts/a.py\n") == "chore"
assert run("uv.lock\nskills/x/SKILL.md\n") == "fix"


def test_subfolder_prefix_decides_what_ships():
assert commit_type(["plugin/skills/x/SKILL.md"], "plugin/") == "fix"
assert commit_type(["README.md", "docs/a.md"], "plugin/") == "chore"