From 6f6b3fc8e3b2821bd864a3a5e824c1625c7ef60a Mon Sep 17 00:00:00 2001 From: Thomas Peoples Date: Mon, 9 Nov 2026 07:38:16 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=91=BB=20feature/WSK-00109:=20resolve?= =?UTF-8?q?=20the=20plugin=20root=20from=20marketplace.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wraith_tools/layout.py | 27 +++++++++++++++++++++++++++ tests/test_layout.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/wraith_tools/layout.py create mode 100644 tests/test_layout.py diff --git a/src/wraith_tools/layout.py b/src/wraith_tools/layout.py new file mode 100644 index 0000000..6bc5d92 --- /dev/null +++ b/src/wraith_tools/layout.py @@ -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 diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..3be0b15 --- /dev/null +++ b/tests/test_layout.py @@ -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) From 72fd2693a413c7b36f9e7ae9f5d57ad0d35bcdd2 Mon Sep 17 00:00:00 2001 From: Thomas Peoples Date: Mon, 9 Nov 2026 07:39:04 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=91=BB=20feature/WSK-00109:=20lint=20?= =?UTF-8?q?and=20build=20agents=20under=20the=20plugin=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wraith_tools/build_copilot.py | 7 +++++-- src/wraith_tools/lint_skills.py | 22 ++++++++++++++-------- src/wraith_tools/sync_commit_type.py | 24 +++++++++++++++++------- tests/test_build_copilot.py | 14 ++++++++++++++ tests/test_lint_skills.py | 16 ++++++++++++++++ tests/test_sync_commit_type.py | 5 +++++ 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/wraith_tools/build_copilot.py b/src/wraith_tools/build_copilot.py index c936cc6..ea131bb 100644 --- a/src/wraith_tools/build_copilot.py +++ b/src/wraith_tools/build_copilot.py @@ -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" @@ -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")): diff --git a/src/wraith_tools/lint_skills.py b/src/wraith_tools/lint_skills.py index 002c1e7..8ecd0d9 100644 --- a/src/wraith_tools/lint_skills.py +++ b/src/wraith_tools/lint_skills.py @@ -10,6 +10,8 @@ import yaml +from wraith_tools.layout import plugin_root + SKILL_KEYS = { "name", "description", @@ -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("*")): @@ -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: @@ -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 = ( @@ -276,7 +282,7 @@ 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 diff --git a/src/wraith_tools/sync_commit_type.py b/src/wraith_tools/sync_commit_type.py index e5c44d9..891ad69 100644 --- a/src/wraith_tools/sync_commit_type.py +++ b/src/wraith_tools/sync_commit_type.py @@ -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__": diff --git a/tests/test_build_copilot.py b/tests/test_build_copilot.py index f966190..4fecff5 100644 --- a/tests/test_build_copilot.py +++ b/tests/test_build_copilot.py @@ -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() diff --git a/tests/test_lint_skills.py b/tests/test_lint_skills.py index eaf240f..b7a1de1 100644 --- a/tests/test_lint_skills.py +++ b/tests/test_lint_skills.py @@ -307,3 +307,19 @@ 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")] diff --git a/tests/test_sync_commit_type.py b/tests/test_sync_commit_type.py index 15ae737..3516409 100644 --- a/tests/test_sync_commit_type.py +++ b/tests/test_sync_commit_type.py @@ -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" From 48dbb18ac5edd0512e063a4f26a77f4f173a3e31 Mon Sep 17 00:00:00 2001 From: Thomas Peoples Date: Mon, 9 Nov 2026 07:39:33 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=91=BB=20feature/WSK-00109:=20check?= =?UTF-8?q?=20manifests,=20listing=20files=20and=20allowlist=20in=20plugin?= =?UTF-8?q?/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugin_checks/test_hooks_config.py | 8 +++- .../plugin_checks/test_manifests.py | 43 ++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/wraith_tools/plugin_checks/test_hooks_config.py b/src/wraith_tools/plugin_checks/test_hooks_config.py index 53d7a0a..1db9e11 100644 --- a/src/wraith_tools/plugin_checks/test_hooks_config.py +++ b/src/wraith_tools/plugin_checks/test_hooks_config.py @@ -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() diff --git a/src/wraith_tools/plugin_checks/test_manifests.py b/src/wraith_tools/plugin_checks/test_manifests.py index 96854f0..3776bb9 100644 --- a/src/wraith_tools/plugin_checks/test_manifests.py +++ b/src/wraith_tools/plugin_checks/test_manifests.py @@ -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 = { @@ -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 @@ -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" @@ -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 From c0ec3725d1a352b74de99c56dcf03df5b8540cfb Mon Sep 17 00:00:00 2001 From: Thomas Peoples Date: Mon, 9 Nov 2026 07:39:39 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=91=BB=20docs/WSK-00109:=20document?= =?UTF-8?q?=20plugin=20root=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index de748c3..2526b67 100644 --- a/README.md +++ b/README.md @@ -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 `./` is linted, built and checked there, and CI, docs and evals stay outside it. A missing marketplace file or `"./"` means the repository root. From ae40781e1475ed9225d4dd6c849071f5626c3483 Mon Sep 17 00:00:00 2001 From: Thomas Peoples Date: Mon, 9 Nov 2026 07:42:12 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=91=BB=20feature/WSK-00109:=20report?= =?UTF-8?q?=20subfolder=20lint=20problems=20from=20the=20repo=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wraith_tools/lint_skills.py | 12 +++++++++++- tests/test_lint_skills.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/wraith_tools/lint_skills.py b/src/wraith_tools/lint_skills.py index 8ecd0d9..5ee226a 100644 --- a/src/wraith_tools/lint_skills.py +++ b/src/wraith_tools/lint_skills.py @@ -284,7 +284,17 @@ def lint(root): out.append(f"skills: L16 {len(skills)} skills (max {MAX_SKILLS})") 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): diff --git a/tests/test_lint_skills.py b/tests/test_lint_skills.py index b7a1de1..92c0929 100644 --- a/tests/test_lint_skills.py +++ b/tests/test_lint_skills.py @@ -323,3 +323,14 @@ def test_subfolder_plugin_and_root_em_dash(tmp_path): ) 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